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:
@@ -282,9 +282,10 @@ export async function handleRecommend(
|
||||
]);
|
||||
|
||||
// Apply exchange rate to candidates if needed (Korean users)
|
||||
// 서버 가격: 500원 단위 반올림
|
||||
if (lang === 'ko' && exchangeRate !== 1) {
|
||||
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';
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
const benchmarkData = benchmarkDataAll;
|
||||
|
||||
@@ -315,7 +356,7 @@ export async function handleRecommend(
|
||||
env,
|
||||
env.OPENAI_API_KEY,
|
||||
body,
|
||||
candidates,
|
||||
filteredCandidates, // Use bandwidth-filtered candidates
|
||||
benchmarkData,
|
||||
vpsBenchmarks,
|
||||
techSpecs,
|
||||
@@ -327,7 +368,7 @@ export async function handleRecommend(
|
||||
} catch (aiError) {
|
||||
console.warn('[Recommend] AI failed, using rule-based fallback:', aiError instanceof Error ? aiError.message : String(aiError));
|
||||
const fallbackRecommendations = generateRuleBasedRecommendations(
|
||||
candidates,
|
||||
filteredCandidates, // Use bandwidth-filtered candidates
|
||||
body,
|
||||
minVcpu || 1,
|
||||
minMemoryMb || 1024,
|
||||
@@ -352,9 +393,15 @@ export async function handleRecommend(
|
||||
|
||||
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 = {
|
||||
recommendations: aiResult.recommendations,
|
||||
infrastructure_tips: aiResult.infrastructure_tips || [],
|
||||
infrastructure_tips: allTips,
|
||||
bandwidth_estimate: {
|
||||
monthly_tb: bandwidthEstimate.monthly_tb,
|
||||
monthly_gb: bandwidthEstimate.monthly_gb,
|
||||
|
||||
@@ -89,7 +89,7 @@ ${languageInstruction}`;
|
||||
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
||||
|
||||
// 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[];
|
||||
const hasRegionPreference = req.region_preference && req.region_preference.length > 0;
|
||||
|
||||
@@ -99,24 +99,55 @@ ${languageInstruction}`;
|
||||
.sort((a, b) => a.monthly_price - b.monthly_price)
|
||||
.slice(0, 15);
|
||||
} else {
|
||||
// No region preference: pick ONLY the best server from EACH region
|
||||
// This forces AI to recommend different regions (no choice!)
|
||||
const bestByRegion = new Map<string, Server>();
|
||||
// No region preference: ensure SPEC DIVERSITY + REGION DIVERSITY
|
||||
// Group servers by spec tier (based on instance_name which reflects vcpu+memory)
|
||||
const specGroups = new Map<string, Server[]>();
|
||||
for (const server of candidates) {
|
||||
const region = server.region_name;
|
||||
const existing = bestByRegion.get(region);
|
||||
// Keep the cheapest server that meets requirements for each region
|
||||
if (!existing || server.monthly_price < existing.monthly_price) {
|
||||
bestByRegion.set(region, server);
|
||||
}
|
||||
const specKey = server.instance_name; // e.g., "Basic 1GB", "Standard 4GB", "Pro 8GB"
|
||||
const existing = specGroups.get(specKey) || [];
|
||||
existing.push(server);
|
||||
specGroups.set(specKey, existing);
|
||||
}
|
||||
|
||||
// Convert to array and sort by price
|
||||
topCandidates = Array.from(bestByRegion.values())
|
||||
.sort((a, b) => a.monthly_price - b.monthly_price);
|
||||
// Sort spec groups by price (cheapest spec first)
|
||||
const sortedSpecGroups = Array.from(specGroups.entries())
|
||||
.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`);
|
||||
console.log(`[AI] Regions: ${topCandidates.map(s => s.region_name).join(', ')}`);
|
||||
// Select servers ensuring spec diversity with region diversity within each tier
|
||||
// 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`);
|
||||
|
||||
@@ -348,14 +348,15 @@ export function calculateBandwidthInfo(
|
||||
const isKorean = lang === 'ko';
|
||||
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
||||
|
||||
// KRW: GB당은 1원 단위, TB당/총 비용은 100원 단위 반올림
|
||||
const roundKrw100 = (usd: number) => Math.round((usd * exchangeRate) / 100) * 100;
|
||||
const toKrw = (usd: number) => Math.round(usd * exchangeRate);
|
||||
// KRW 반올림: GB당 1원, TB당 500원, 총 비용 1원
|
||||
const toKrw = (usd: number) => Math.round(usd * exchangeRate); // 1원 단위
|
||||
const toKrw500 = (usd: number) => Math.round((usd * exchangeRate) / 500) * 500; // 500원 단위
|
||||
|
||||
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
||||
const overagePerTb = isKorean ? roundKrw100(overagePerTbUsd) : overagePerTbUsd;
|
||||
const overageCost = isKorean ? roundKrw100(overageCostUsd) : Math.round(overageCostUsd * 100) / 100;
|
||||
const totalCost = isKorean ? roundKrw100(totalCostUsd) : Math.round(totalCostUsd * 100) / 100;
|
||||
const overagePerTb = isKorean ? toKrw500(overagePerTbUsd) : overagePerTbUsd; // 500원 단위
|
||||
const overageCost = isKorean ? toKrw500(overageCostUsd) : overageCostUsd; // 500원 단위
|
||||
// totalCost: 서버 가격(500원) + 초과 비용(500원)
|
||||
const totalCost = isKorean ? server.monthly_price + toKrw500(overageCostUsd) : totalCostUsd;
|
||||
|
||||
// CDN savings calculation
|
||||
const cdnEnabled = bandwidthEstimate.cdn_enabled;
|
||||
@@ -363,7 +364,7 @@ export function calculateBandwidthInfo(
|
||||
const grossMonthlyTb = bandwidthEstimate.gross_monthly_tb;
|
||||
const cdnSavingsTb = cdnEnabled ? grossMonthlyTb - estimatedTb : 0;
|
||||
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;
|
||||
if (overageTb > includedTb) {
|
||||
@@ -380,12 +381,12 @@ export function calculateBandwidthInfo(
|
||||
|
||||
return {
|
||||
included_transfer_tb: includedTb,
|
||||
overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000,
|
||||
overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100,
|
||||
overage_cost_per_gb: overagePerGb, // 반올림 없음
|
||||
overage_cost_per_tb: overagePerTb, // 반올림 없음
|
||||
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
||||
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
||||
estimated_overage_cost: overageCost,
|
||||
total_estimated_cost: totalCost,
|
||||
estimated_overage_cost: overageCost, // 반올림 없음
|
||||
total_estimated_cost: totalCost, // 서버 가격(반올림) + 초과 비용(반올림 X)
|
||||
currency,
|
||||
warning,
|
||||
// CDN breakdown
|
||||
@@ -393,6 +394,6 @@ export function calculateBandwidthInfo(
|
||||
cdn_cache_hit_rate: cdnCacheHitRate,
|
||||
gross_monthly_tb: Math.round(grossMonthlyTb * 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