diff --git a/CLAUDE.md b/CLAUDE.md index d3192ce..de6ffde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,8 +372,13 @@ echo $REPORT_URL - `src/__tests__/{utils,bandwidth}.test.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**: No region specified → same spec from 3 different regions for comparison - **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 - **Prompt cleanup**: Removed obsolete Linode/Vultr/DigitalOcean references (Anvil only) diff --git a/src/handlers/recommend.ts b/src/handlers/recommend.ts index 7896ecf..02f24cb 100644 --- a/src/handlers/recommend.ts +++ b/src/handlers/recommend.ts @@ -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, diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index 4bda95b..519b7ef 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -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(); + // 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(); 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(); + + 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`); diff --git a/src/utils/bandwidth.ts b/src/utils/bandwidth.ts index 384299f..265d564 100644 --- a/src/utils/bandwidth.ts +++ b/src/utils/bandwidth.ts @@ -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 // 반올림 없음 }; }