feat: improve recommendation diversity and KRW rounding
- Add spec diversity: recommend Budget/Balanced/Premium tiers instead of same spec - Add bandwidth-based filtering: prioritize servers with adequate transfer allowance - Fix KRW rounding: server price 500원, TB cost 500원, GB cost 1원 - Add bandwidth warning to infrastructure_tips when traffic exceeds 2x included Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -372,8 +372,13 @@ echo $REPORT_URL
|
|||||||
- `src/__tests__/{utils,bandwidth}.test.ts`
|
- `src/__tests__/{utils,bandwidth}.test.ts`
|
||||||
- `vitest.config.ts`
|
- `vitest.config.ts`
|
||||||
|
|
||||||
|
### Spec Diversity & Bandwidth-Based Filtering (Latest)
|
||||||
|
- **Spec diversity**: No region specified → 3 different spec tiers (Budget/Balanced/Premium) from different regions
|
||||||
|
- **Bandwidth-based filtering**: High bandwidth workloads (>2TB) automatically prioritize servers with adequate transfer allowance
|
||||||
|
- **Bandwidth warning**: `infrastructure_tips` includes warning when estimated traffic exceeds included bandwidth by 2x+
|
||||||
|
- **Previous issue fixed**: Was recommending same Basic 1GB spec × 3 (only region different), now recommends diverse specs
|
||||||
|
|
||||||
### Region Diversity & Bug Fixes
|
### Region Diversity & Bug Fixes
|
||||||
- **Region diversity**: No region specified → same spec from 3 different regions for comparison
|
|
||||||
- **Cache key fix**: `region_preference` now included in cache key
|
- **Cache key fix**: `region_preference` now included in cache key
|
||||||
- **Server ID fix**: Changed from `ai.id` (instance) to `ap.id` (pricing) for unique region+instance identification
|
- **Server ID fix**: Changed from `ai.id` (instance) to `ap.id` (pricing) for unique region+instance identification
|
||||||
- **Prompt cleanup**: Removed obsolete Linode/Vultr/DigitalOcean references (Anvil only)
|
- **Prompt cleanup**: Removed obsolete Linode/Vultr/DigitalOcean references (Anvil only)
|
||||||
|
|||||||
@@ -282,9 +282,10 @@ export async function handleRecommend(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply exchange rate to candidates if needed (Korean users)
|
// Apply exchange rate to candidates if needed (Korean users)
|
||||||
|
// 서버 가격: 500원 단위 반올림
|
||||||
if (lang === 'ko' && exchangeRate !== 1) {
|
if (lang === 'ko' && exchangeRate !== 1) {
|
||||||
candidates.forEach(c => {
|
candidates.forEach(c => {
|
||||||
c.monthly_price = Math.round(c.monthly_price * exchangeRate);
|
c.monthly_price = Math.round((c.monthly_price * exchangeRate) / 500) * 500;
|
||||||
c.currency = 'KRW';
|
c.currency = 'KRW';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -304,6 +305,46 @@ export async function handleRecommend(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bandwidth-based filtering: prioritize servers with adequate transfer allowance
|
||||||
|
// If estimated bandwidth exceeds typical 1TB allowance by 2x+, prefer higher-tier servers
|
||||||
|
let filteredCandidates = candidates;
|
||||||
|
let bandwidthWarning: string | undefined;
|
||||||
|
|
||||||
|
const estimatedMonthlyTb = bandwidthEstimate.monthly_tb;
|
||||||
|
const typicalIncludedTb = 1; // Most basic plans include 1TB
|
||||||
|
|
||||||
|
if (estimatedMonthlyTb > typicalIncludedTb * 2) {
|
||||||
|
// High bandwidth workload - prioritize servers with higher transfer allowance
|
||||||
|
const highTransferServers = candidates.filter(c =>
|
||||||
|
c.transfer_tb && c.transfer_tb >= estimatedMonthlyTb * 0.8
|
||||||
|
);
|
||||||
|
const mediumTransferServers = candidates.filter(c =>
|
||||||
|
c.transfer_tb && c.transfer_tb >= estimatedMonthlyTb * 0.5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (highTransferServers.length >= 3) {
|
||||||
|
// Enough high-transfer servers - use those, but include some lower tiers for comparison
|
||||||
|
filteredCandidates = [
|
||||||
|
...highTransferServers,
|
||||||
|
...candidates.filter(c => !highTransferServers.includes(c)).slice(0, 5)
|
||||||
|
];
|
||||||
|
console.log(`[Recommend] Bandwidth filter: prioritized ${highTransferServers.length} high-transfer servers`);
|
||||||
|
} else if (mediumTransferServers.length >= 3) {
|
||||||
|
filteredCandidates = [
|
||||||
|
...mediumTransferServers,
|
||||||
|
...candidates.filter(c => !mediumTransferServers.includes(c)).slice(0, 5)
|
||||||
|
];
|
||||||
|
console.log(`[Recommend] Bandwidth filter: prioritized ${mediumTransferServers.length} medium-transfer servers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warning for high bandwidth workload
|
||||||
|
const overageRatio = estimatedMonthlyTb / typicalIncludedTb;
|
||||||
|
bandwidthWarning = lang === 'ko'
|
||||||
|
? `⚠️ CDN 적용 후에도 원본 서버 트래픽(${estimatedMonthlyTb.toFixed(1)}TB)이 기본 포함량(${typicalIncludedTb}TB)의 ${overageRatio.toFixed(1)}배입니다. 상위 플랜을 고려하세요.`
|
||||||
|
: `⚠️ Even with CDN, origin traffic (${estimatedMonthlyTb.toFixed(1)}TB) is ${overageRatio.toFixed(1)}x the typical included bandwidth (${typicalIncludedTb}TB). Consider higher-tier plans.`;
|
||||||
|
console.log(`[Recommend] Bandwidth warning: ${bandwidthWarning}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Use initially fetched benchmark data (already filtered by tech stack)
|
// Use initially fetched benchmark data (already filtered by tech stack)
|
||||||
const benchmarkData = benchmarkDataAll;
|
const benchmarkData = benchmarkDataAll;
|
||||||
|
|
||||||
@@ -315,7 +356,7 @@ export async function handleRecommend(
|
|||||||
env,
|
env,
|
||||||
env.OPENAI_API_KEY,
|
env.OPENAI_API_KEY,
|
||||||
body,
|
body,
|
||||||
candidates,
|
filteredCandidates, // Use bandwidth-filtered candidates
|
||||||
benchmarkData,
|
benchmarkData,
|
||||||
vpsBenchmarks,
|
vpsBenchmarks,
|
||||||
techSpecs,
|
techSpecs,
|
||||||
@@ -327,7 +368,7 @@ export async function handleRecommend(
|
|||||||
} catch (aiError) {
|
} catch (aiError) {
|
||||||
console.warn('[Recommend] AI failed, using rule-based fallback:', aiError instanceof Error ? aiError.message : String(aiError));
|
console.warn('[Recommend] AI failed, using rule-based fallback:', aiError instanceof Error ? aiError.message : String(aiError));
|
||||||
const fallbackRecommendations = generateRuleBasedRecommendations(
|
const fallbackRecommendations = generateRuleBasedRecommendations(
|
||||||
candidates,
|
filteredCandidates, // Use bandwidth-filtered candidates
|
||||||
body,
|
body,
|
||||||
minVcpu || 1,
|
minVcpu || 1,
|
||||||
minMemoryMb || 1024,
|
minMemoryMb || 1024,
|
||||||
@@ -352,9 +393,15 @@ export async function handleRecommend(
|
|||||||
|
|
||||||
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
|
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
|
||||||
|
|
||||||
|
// Add bandwidth warning to infrastructure_tips if applicable
|
||||||
|
const allTips = [...(aiResult.infrastructure_tips || [])];
|
||||||
|
if (bandwidthWarning) {
|
||||||
|
allTips.unshift(bandwidthWarning); // Add at the beginning for visibility
|
||||||
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
recommendations: aiResult.recommendations,
|
recommendations: aiResult.recommendations,
|
||||||
infrastructure_tips: aiResult.infrastructure_tips || [],
|
infrastructure_tips: allTips,
|
||||||
bandwidth_estimate: {
|
bandwidth_estimate: {
|
||||||
monthly_tb: bandwidthEstimate.monthly_tb,
|
monthly_tb: bandwidthEstimate.monthly_tb,
|
||||||
monthly_gb: bandwidthEstimate.monthly_gb,
|
monthly_gb: bandwidthEstimate.monthly_gb,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ ${languageInstruction}`;
|
|||||||
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
||||||
|
|
||||||
// Pre-filter candidates to reduce AI prompt size and cost
|
// Pre-filter candidates to reduce AI prompt size and cost
|
||||||
// Ensure region diversity when no region_preference is specified
|
// Ensure BOTH spec diversity AND region diversity when no region_preference is specified
|
||||||
let topCandidates: Server[];
|
let topCandidates: Server[];
|
||||||
const hasRegionPreference = req.region_preference && req.region_preference.length > 0;
|
const hasRegionPreference = req.region_preference && req.region_preference.length > 0;
|
||||||
|
|
||||||
@@ -99,24 +99,55 @@ ${languageInstruction}`;
|
|||||||
.sort((a, b) => a.monthly_price - b.monthly_price)
|
.sort((a, b) => a.monthly_price - b.monthly_price)
|
||||||
.slice(0, 15);
|
.slice(0, 15);
|
||||||
} else {
|
} else {
|
||||||
// No region preference: pick ONLY the best server from EACH region
|
// No region preference: ensure SPEC DIVERSITY + REGION DIVERSITY
|
||||||
// This forces AI to recommend different regions (no choice!)
|
// Group servers by spec tier (based on instance_name which reflects vcpu+memory)
|
||||||
const bestByRegion = new Map<string, Server>();
|
const specGroups = new Map<string, Server[]>();
|
||||||
for (const server of candidates) {
|
for (const server of candidates) {
|
||||||
const region = server.region_name;
|
const specKey = server.instance_name; // e.g., "Basic 1GB", "Standard 4GB", "Pro 8GB"
|
||||||
const existing = bestByRegion.get(region);
|
const existing = specGroups.get(specKey) || [];
|
||||||
// Keep the cheapest server that meets requirements for each region
|
existing.push(server);
|
||||||
if (!existing || server.monthly_price < existing.monthly_price) {
|
specGroups.set(specKey, existing);
|
||||||
bestByRegion.set(region, server);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array and sort by price
|
// Sort spec groups by price (cheapest spec first)
|
||||||
topCandidates = Array.from(bestByRegion.values())
|
const sortedSpecGroups = Array.from(specGroups.entries())
|
||||||
.sort((a, b) => a.monthly_price - b.monthly_price);
|
.map(([spec, servers]) => ({
|
||||||
|
spec,
|
||||||
|
servers: servers.sort((a, b) => a.monthly_price - b.monthly_price),
|
||||||
|
minPrice: Math.min(...servers.map(s => s.monthly_price))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.minPrice - b.minPrice);
|
||||||
|
|
||||||
console.log(`[AI] Region diversity FORCED: ${topCandidates.length} regions, 1 server each`);
|
// Select servers ensuring spec diversity with region diversity within each tier
|
||||||
console.log(`[AI] Regions: ${topCandidates.map(s => s.region_name).join(', ')}`);
|
// Pick 5 servers from each of the first 3 spec tiers (different regions)
|
||||||
|
topCandidates = [];
|
||||||
|
const usedRegions = new Set<string>();
|
||||||
|
|
||||||
|
for (const group of sortedSpecGroups.slice(0, 5)) { // Up to 5 spec tiers
|
||||||
|
let addedFromThisTier = 0;
|
||||||
|
for (const server of group.servers) {
|
||||||
|
// Prefer different regions within the same spec tier
|
||||||
|
if (addedFromThisTier < 3 && !usedRegions.has(server.region_name)) {
|
||||||
|
topCandidates.push(server);
|
||||||
|
usedRegions.add(server.region_name);
|
||||||
|
addedFromThisTier++;
|
||||||
|
} else if (addedFromThisTier < 2) {
|
||||||
|
// Allow same region if we don't have enough
|
||||||
|
topCandidates.push(server);
|
||||||
|
addedFromThisTier++;
|
||||||
|
}
|
||||||
|
if (topCandidates.length >= 15) break;
|
||||||
|
}
|
||||||
|
if (topCandidates.length >= 15) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort final list by price
|
||||||
|
topCandidates.sort((a, b) => a.monthly_price - b.monthly_price);
|
||||||
|
|
||||||
|
const specDiversity = new Set(topCandidates.map(s => s.instance_name));
|
||||||
|
const regionDiversity = new Set(topCandidates.map(s => s.region_name));
|
||||||
|
console.log(`[AI] Spec diversity: ${specDiversity.size} specs (${Array.from(specDiversity).join(', ')})`);
|
||||||
|
console.log(`[AI] Region diversity: ${regionDiversity.size} regions (${Array.from(regionDiversity).join(', ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
|
console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`);
|
||||||
|
|||||||
@@ -348,14 +348,15 @@ export function calculateBandwidthInfo(
|
|||||||
const isKorean = lang === 'ko';
|
const isKorean = lang === 'ko';
|
||||||
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
||||||
|
|
||||||
// KRW: GB당은 1원 단위, TB당/총 비용은 100원 단위 반올림
|
// KRW 반올림: GB당 1원, TB당 500원, 총 비용 1원
|
||||||
const roundKrw100 = (usd: number) => Math.round((usd * exchangeRate) / 100) * 100;
|
const toKrw = (usd: number) => Math.round(usd * exchangeRate); // 1원 단위
|
||||||
const toKrw = (usd: number) => Math.round(usd * exchangeRate);
|
const toKrw500 = (usd: number) => Math.round((usd * exchangeRate) / 500) * 500; // 500원 단위
|
||||||
|
|
||||||
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
||||||
const overagePerTb = isKorean ? roundKrw100(overagePerTbUsd) : overagePerTbUsd;
|
const overagePerTb = isKorean ? toKrw500(overagePerTbUsd) : overagePerTbUsd; // 500원 단위
|
||||||
const overageCost = isKorean ? roundKrw100(overageCostUsd) : Math.round(overageCostUsd * 100) / 100;
|
const overageCost = isKorean ? toKrw500(overageCostUsd) : overageCostUsd; // 500원 단위
|
||||||
const totalCost = isKorean ? roundKrw100(totalCostUsd) : Math.round(totalCostUsd * 100) / 100;
|
// totalCost: 서버 가격(500원) + 초과 비용(500원)
|
||||||
|
const totalCost = isKorean ? server.monthly_price + toKrw500(overageCostUsd) : totalCostUsd;
|
||||||
|
|
||||||
// CDN savings calculation
|
// CDN savings calculation
|
||||||
const cdnEnabled = bandwidthEstimate.cdn_enabled;
|
const cdnEnabled = bandwidthEstimate.cdn_enabled;
|
||||||
@@ -363,7 +364,7 @@ export function calculateBandwidthInfo(
|
|||||||
const grossMonthlyTb = bandwidthEstimate.gross_monthly_tb;
|
const grossMonthlyTb = bandwidthEstimate.gross_monthly_tb;
|
||||||
const cdnSavingsTb = cdnEnabled ? grossMonthlyTb - estimatedTb : 0;
|
const cdnSavingsTb = cdnEnabled ? grossMonthlyTb - estimatedTb : 0;
|
||||||
const cdnSavingsCostUsd = cdnSavingsTb * overagePerTbUsd;
|
const cdnSavingsCostUsd = cdnSavingsTb * overagePerTbUsd;
|
||||||
const cdnSavingsCost = isKorean ? roundKrw100(cdnSavingsCostUsd) : Math.round(cdnSavingsCostUsd * 100) / 100;
|
const cdnSavingsCost = isKorean ? toKrw500(cdnSavingsCostUsd) : cdnSavingsCostUsd; // 500원 단위
|
||||||
|
|
||||||
let warning: string | undefined;
|
let warning: string | undefined;
|
||||||
if (overageTb > includedTb) {
|
if (overageTb > includedTb) {
|
||||||
@@ -380,12 +381,12 @@ export function calculateBandwidthInfo(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
included_transfer_tb: includedTb,
|
included_transfer_tb: includedTb,
|
||||||
overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000,
|
overage_cost_per_gb: overagePerGb, // 반올림 없음
|
||||||
overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100,
|
overage_cost_per_tb: overagePerTb, // 반올림 없음
|
||||||
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
||||||
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
||||||
estimated_overage_cost: overageCost,
|
estimated_overage_cost: overageCost, // 반올림 없음
|
||||||
total_estimated_cost: totalCost,
|
total_estimated_cost: totalCost, // 서버 가격(반올림) + 초과 비용(반올림 X)
|
||||||
currency,
|
currency,
|
||||||
warning,
|
warning,
|
||||||
// CDN breakdown
|
// CDN breakdown
|
||||||
@@ -393,6 +394,6 @@ export function calculateBandwidthInfo(
|
|||||||
cdn_cache_hit_rate: cdnCacheHitRate,
|
cdn_cache_hit_rate: cdnCacheHitRate,
|
||||||
gross_monthly_tb: Math.round(grossMonthlyTb * 10) / 10,
|
gross_monthly_tb: Math.round(grossMonthlyTb * 10) / 10,
|
||||||
cdn_savings_tb: Math.round(cdnSavingsTb * 10) / 10,
|
cdn_savings_tb: Math.round(cdnSavingsTb * 10) / 10,
|
||||||
cdn_savings_cost: cdnSavingsCost
|
cdn_savings_cost: cdnSavingsCost // 반올림 없음
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user