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:
kappa
2026-01-27 14:44:34 +09:00
parent 23abd0e64e
commit 8c543eeaa5
4 changed files with 116 additions and 32 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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`);

View File

@@ -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 // 반올림 없음
}; };
} }