## vCPU 계산 로직 개선 - 카테고리 합산 → 병목 분석(Max)으로 변경 - nginx+nodejs+postgresql 조합: 16 vCPU → 10 vCPU - 요청 흐름(web→app→db)에서 가장 느린 컴포넌트가 병목 ## 메모리 계산 로직 개선 - memory_intensive 서비스: Max → 합산으로 변경 - java+elasticsearch+redis: 8GB → 11GB (실제 동시 실행 반영) ## 대역폭 추정 개선 - 사용자 활동률(activeUserRatio) 추가 - video: 30%, gaming: 50%, e-commerce: 40% - 비디오 1000명: 257TB → ~80TB/월 (현실적) ## DAU 변환 비율 개선 - 용도별 차등 적용 (getDauMultiplier) - gaming: 10-20배, blog: 30-50배, saas: 5-10배 ## aliases 대소문자 수정 - LOWER(aliases) LIKE로 case-insensitive 매칭 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2069 lines
71 KiB
TypeScript
2069 lines
71 KiB
TypeScript
/**
|
||
* Cloudflare Worker - Server Recommendation System
|
||
*
|
||
* AI-powered server recommendation service using Workers AI, D1, and KV.
|
||
*/
|
||
|
||
interface Env {
|
||
AI: Ai; // Legacy - kept for fallback
|
||
DB: D1Database;
|
||
CACHE: KVNamespace;
|
||
OPENAI_API_KEY: string;
|
||
}
|
||
|
||
interface ValidationError {
|
||
error: string;
|
||
missing_fields?: string[];
|
||
invalid_fields?: { field: string; reason: string }[];
|
||
schema: Record<string, string>;
|
||
example: Record<string, any>;
|
||
}
|
||
|
||
interface RecommendRequest {
|
||
tech_stack: string[];
|
||
expected_users: number;
|
||
use_case: string;
|
||
traffic_pattern?: 'steady' | 'spiky' | 'growing';
|
||
region_preference?: string[];
|
||
budget_limit?: number;
|
||
provider_filter?: string[]; // Filter by specific providers (e.g., ["Linode", "Vultr"])
|
||
lang?: 'en' | 'zh' | 'ja' | 'ko'; // Response language
|
||
}
|
||
|
||
interface Server {
|
||
id: number;
|
||
provider_name: string;
|
||
instance_id: string;
|
||
instance_name: string;
|
||
vcpu: number;
|
||
memory_mb: number;
|
||
memory_gb: number;
|
||
storage_gb: number;
|
||
network_speed_gbps: number | null;
|
||
instance_family: string | null;
|
||
gpu_count: number;
|
||
gpu_type: string | null;
|
||
monthly_price: number;
|
||
currency: 'USD' | 'KRW';
|
||
region_name: string;
|
||
region_code: string;
|
||
}
|
||
|
||
interface RecommendationResult {
|
||
server: Server;
|
||
score: number;
|
||
analysis: {
|
||
tech_fit: string;
|
||
capacity: string;
|
||
cost_efficiency: string;
|
||
scalability: string;
|
||
};
|
||
estimated_capacity: {
|
||
max_daily_users?: number;
|
||
max_concurrent_users: number;
|
||
requests_per_second: number;
|
||
};
|
||
benchmark_reference?: BenchmarkReference;
|
||
vps_benchmark_reference?: {
|
||
plan_name: string;
|
||
geekbench_single: number;
|
||
geekbench_multi: number;
|
||
monthly_price_usd: number;
|
||
performance_per_dollar: number;
|
||
};
|
||
}
|
||
|
||
interface BenchmarkReference {
|
||
processor_name: string;
|
||
benchmarks: {
|
||
name: string;
|
||
category: string;
|
||
score: number;
|
||
percentile: number;
|
||
}[];
|
||
}
|
||
|
||
interface BenchmarkData {
|
||
id: number;
|
||
processor_name: string;
|
||
benchmark_name: string;
|
||
category: string;
|
||
score: number;
|
||
percentile: number;
|
||
cores: number | null;
|
||
}
|
||
|
||
interface VPSBenchmark {
|
||
id: number;
|
||
provider_name: string;
|
||
plan_name: string;
|
||
cpu_type: string;
|
||
vcpu: number;
|
||
memory_gb: number;
|
||
country_code: string;
|
||
geekbench_single: number;
|
||
geekbench_multi: number;
|
||
geekbench_total: number;
|
||
monthly_price_usd: number;
|
||
performance_per_dollar: number;
|
||
geekbench_version: string;
|
||
gb6_single_normalized: number;
|
||
gb6_multi_normalized: number;
|
||
}
|
||
|
||
interface TechSpec {
|
||
id: number;
|
||
name: string;
|
||
category: string;
|
||
vcpu_per_users: number;
|
||
vcpu_per_users_max: number | null;
|
||
min_memory_mb: number;
|
||
max_memory_mb: number | null;
|
||
description: string | null;
|
||
aliases: string | null;
|
||
is_memory_intensive: boolean;
|
||
is_cpu_intensive: boolean;
|
||
}
|
||
|
||
interface BandwidthEstimate {
|
||
monthly_gb: number;
|
||
monthly_tb: number;
|
||
daily_gb: number;
|
||
category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
|
||
description: string;
|
||
estimated_dau_min: number; // Daily Active Users estimate (min)
|
||
estimated_dau_max: number; // Daily Active Users estimate (max)
|
||
}
|
||
|
||
/**
|
||
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
|
||
*/
|
||
function getDauMultiplier(useCase: string): { min: number; max: number } {
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
|
||
// Gaming: users stay online longer, higher concurrent ratio
|
||
return { min: 10, max: 20 };
|
||
} else if (/blog|news|static|블로그|뉴스|포트폴리오/.test(useCaseLower)) {
|
||
// Blog/Static: short visits, lower concurrent ratio
|
||
return { min: 30, max: 50 };
|
||
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
|
||
// SaaS/API: business hours concentration
|
||
return { min: 5, max: 10 };
|
||
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
|
||
// E-commerce: moderate session lengths
|
||
return { min: 20, max: 30 };
|
||
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
|
||
// Forum/Community: moderate engagement
|
||
return { min: 15, max: 25 };
|
||
} else if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
|
||
// Video/Streaming: medium-long sessions
|
||
return { min: 8, max: 12 };
|
||
} else {
|
||
// Default: general web app
|
||
return { min: 10, max: 14 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
|
||
*/
|
||
function getActiveUserRatio(useCase: string): number {
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
|
||
// Video/Streaming: only 30% of DAU actually stream
|
||
return 0.3;
|
||
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
|
||
// Gaming: 50% of DAU are active players
|
||
return 0.5;
|
||
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
|
||
// E-commerce: 40% browse products
|
||
return 0.4;
|
||
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
|
||
// API/SaaS: 60% active usage
|
||
return 0.6;
|
||
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
|
||
// Forum/Community: 50% active posting/reading
|
||
return 0.5;
|
||
} else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) {
|
||
// Static/Blog: 30% active readers
|
||
return 0.3;
|
||
} else {
|
||
// Default: 50% active
|
||
return 0.5;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Estimate monthly bandwidth based on concurrent users and use case
|
||
*
|
||
* Formula: concurrent_users × dau_multiplier × active_ratio × avg_page_size_mb × requests_per_session × active_hours × 30
|
||
*
|
||
* Multipliers by use case:
|
||
* - Static site/blog: 0.5 MB/request, 5 requests/session
|
||
* - API/SaaS: 0.1 MB/request, 50 requests/session
|
||
* - E-commerce: 1 MB/request, 20 requests/session
|
||
* - Media/Video: 50 MB/request, 10 requests/session
|
||
* - Gaming: 0.05 MB/request, 1000 requests/session
|
||
* - Forum/Community: 0.3 MB/request, 30 requests/session
|
||
*/
|
||
function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
|
||
const useCaseLower = useCase.toLowerCase();
|
||
|
||
// Determine use case category and bandwidth multiplier
|
||
let avgPageSizeMB: number;
|
||
let requestsPerSession: number;
|
||
let categoryMultiplier: number;
|
||
|
||
if (/video|stream|media|youtube|netflix|vod|동영상|스트리밍|미디어/.test(useCaseLower)) {
|
||
// Video/Streaming: very heavy bandwidth
|
||
avgPageSizeMB = 50; // 50MB per video segment
|
||
requestsPerSession = 10;
|
||
categoryMultiplier = 1.5;
|
||
} else if (/download|file|storage|cdn|파일|다운로드|저장소/.test(useCaseLower)) {
|
||
// File downloads: heavy bandwidth
|
||
avgPageSizeMB = 20;
|
||
requestsPerSession = 5;
|
||
categoryMultiplier = 1.3;
|
||
} else if (/game|gaming|minecraft|게임/.test(useCaseLower)) {
|
||
// Gaming: many small packets
|
||
avgPageSizeMB = 0.05;
|
||
requestsPerSession = 1000;
|
||
categoryMultiplier = 1.2;
|
||
} else if (/e-?commerce|shop|store|쇼핑|커머스|온라인몰/.test(useCaseLower)) {
|
||
// E-commerce: images heavy
|
||
avgPageSizeMB = 1;
|
||
requestsPerSession = 20;
|
||
categoryMultiplier = 1.0;
|
||
} else if (/api|saas|backend|서비스|백엔드/.test(useCaseLower)) {
|
||
// API/SaaS: many small requests
|
||
avgPageSizeMB = 0.1;
|
||
requestsPerSession = 50;
|
||
categoryMultiplier = 0.8;
|
||
} else if (/forum|community|board|게시판|커뮤니티|포럼/.test(useCaseLower)) {
|
||
// Forum/Community: moderate
|
||
avgPageSizeMB = 0.3;
|
||
requestsPerSession = 30;
|
||
categoryMultiplier = 0.9;
|
||
} else if (/blog|static|portfolio|블로그|포트폴리오/.test(useCaseLower)) {
|
||
// Static/Blog: light
|
||
avgPageSizeMB = 0.5;
|
||
requestsPerSession = 5;
|
||
categoryMultiplier = 0.5;
|
||
} else {
|
||
// Default: moderate web app
|
||
avgPageSizeMB = 0.5;
|
||
requestsPerSession = 15;
|
||
categoryMultiplier = 1.0;
|
||
}
|
||
|
||
// Traffic pattern adjustment
|
||
let patternMultiplier = 1.0;
|
||
if (trafficPattern === 'spiky') {
|
||
patternMultiplier = 1.5; // Account for peak loads
|
||
} else if (trafficPattern === 'growing') {
|
||
patternMultiplier = 1.3; // Headroom for growth
|
||
}
|
||
|
||
// Assume 8 active hours per day average (varies by use case)
|
||
const activeHoursPerDay = 8;
|
||
|
||
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
||
const dauMultiplier = getDauMultiplier(useCase);
|
||
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
||
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
||
|
||
// Calculate daily bandwidth with active user ratio
|
||
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
||
const activeUserRatio = getActiveUserRatio(useCase);
|
||
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
||
|
||
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active DAU: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%)`);
|
||
|
||
const dailyBandwidthMB = activeDau * avgPageSizeMB * requestsPerSession * categoryMultiplier * patternMultiplier;
|
||
const dailyBandwidthGB = dailyBandwidthMB / 1024;
|
||
|
||
// Monthly bandwidth
|
||
const monthlyGB = dailyBandwidthGB * 30;
|
||
const monthlyTB = monthlyGB / 1024;
|
||
|
||
// Categorize
|
||
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
|
||
let description: string;
|
||
|
||
if (monthlyTB < 0.5) {
|
||
category = 'light';
|
||
description = `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
|
||
} else if (monthlyTB < 2) {
|
||
category = 'moderate';
|
||
description = `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
|
||
} else if (monthlyTB < 6) {
|
||
category = 'heavy';
|
||
description = `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
|
||
} else {
|
||
category = 'very_heavy';
|
||
description = `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
|
||
}
|
||
|
||
return {
|
||
monthly_gb: Math.round(monthlyGB),
|
||
monthly_tb: Math.round(monthlyTB * 10) / 10,
|
||
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
|
||
category,
|
||
description,
|
||
estimated_dau_min: estimatedDauMin,
|
||
estimated_dau_max: estimatedDauMax
|
||
};
|
||
}
|
||
|
||
interface AIRecommendationResponse {
|
||
recommendations: Array<{
|
||
server_id: number;
|
||
score: number;
|
||
analysis: {
|
||
tech_fit: string;
|
||
capacity: string;
|
||
cost_efficiency: string;
|
||
scalability: string;
|
||
};
|
||
estimated_capacity: {
|
||
max_daily_users?: number;
|
||
max_concurrent_users: number;
|
||
requests_per_second: number;
|
||
};
|
||
}>;
|
||
infrastructure_tips?: string[];
|
||
}
|
||
|
||
/**
|
||
* i18n Messages for multi-language support
|
||
*/
|
||
const i18n: Record<string, {
|
||
missingFields: string;
|
||
invalidFields: string;
|
||
schema: Record<string, string>;
|
||
example: Record<string, any>;
|
||
aiLanguageInstruction: string;
|
||
}> = {
|
||
en: {
|
||
missingFields: 'Missing required fields',
|
||
invalidFields: 'Invalid field values',
|
||
schema: {
|
||
tech_stack: "(required) string[] - e.g. ['nginx', 'nodejs']",
|
||
expected_users: "(required) number - expected concurrent users, e.g. 1000",
|
||
use_case: "(required) string - e.g. 'e-commerce website'",
|
||
traffic_pattern: "(optional) 'steady' | 'spiky' | 'growing'",
|
||
region_preference: "(optional) string[] - e.g. ['korea', 'japan']",
|
||
budget_limit: "(optional) number - max monthly USD",
|
||
provider_filter: "(optional) string[] - e.g. ['linode', 'vultr']",
|
||
lang: "(optional) 'en' | 'zh' | 'ja' | 'ko' - response language"
|
||
},
|
||
example: {
|
||
tech_stack: ["nginx", "nodejs", "postgresql"],
|
||
expected_users: 5000,
|
||
use_case: "SaaS application"
|
||
},
|
||
aiLanguageInstruction: 'Respond in English.'
|
||
},
|
||
zh: {
|
||
missingFields: '缺少必填字段',
|
||
invalidFields: '字段值无效',
|
||
schema: {
|
||
tech_stack: "(必填) string[] - 例如 ['nginx', 'nodejs']",
|
||
expected_users: "(必填) number - 预计同时在线用户数,例如 1000",
|
||
use_case: "(必填) string - 例如 '电商网站'",
|
||
traffic_pattern: "(可选) 'steady' | 'spiky' | 'growing'",
|
||
region_preference: "(可选) string[] - 例如 ['korea', 'japan']",
|
||
budget_limit: "(可选) number - 每月最高预算(美元)",
|
||
provider_filter: "(可选) string[] - 例如 ['linode', 'vultr']",
|
||
lang: "(可选) 'en' | 'zh' | 'ja' | 'ko' - 响应语言"
|
||
},
|
||
example: {
|
||
tech_stack: ["nginx", "nodejs", "postgresql"],
|
||
expected_users: 5000,
|
||
use_case: "SaaS应用程序"
|
||
},
|
||
aiLanguageInstruction: 'Respond in Chinese (Simplified). All analysis text must be in Chinese.'
|
||
},
|
||
ja: {
|
||
missingFields: '必須フィールドがありません',
|
||
invalidFields: 'フィールド値が無効です',
|
||
schema: {
|
||
tech_stack: "(必須) string[] - 例: ['nginx', 'nodejs']",
|
||
expected_users: "(必須) number - 予想同時接続ユーザー数、例: 1000",
|
||
use_case: "(必須) string - 例: 'ECサイト'",
|
||
traffic_pattern: "(任意) 'steady' | 'spiky' | 'growing'",
|
||
region_preference: "(任意) string[] - 例: ['korea', 'japan']",
|
||
budget_limit: "(任意) number - 月額予算上限(USD)",
|
||
provider_filter: "(任意) string[] - 例: ['linode', 'vultr']",
|
||
lang: "(任意) 'en' | 'zh' | 'ja' | 'ko' - 応答言語"
|
||
},
|
||
example: {
|
||
tech_stack: ["nginx", "nodejs", "postgresql"],
|
||
expected_users: 5000,
|
||
use_case: "SaaSアプリケーション"
|
||
},
|
||
aiLanguageInstruction: 'Respond in Japanese. All analysis text must be in Japanese.'
|
||
},
|
||
ko: {
|
||
missingFields: '필수 필드가 누락되었습니다',
|
||
invalidFields: '필드 값이 잘못되었습니다',
|
||
schema: {
|
||
tech_stack: "(필수) string[] - 예: ['nginx', 'nodejs']",
|
||
expected_users: "(필수) number - 예상 동시 접속자 수, 예: 1000",
|
||
use_case: "(필수) string - 예: '이커머스 웹사이트'",
|
||
traffic_pattern: "(선택) 'steady' | 'spiky' | 'growing'",
|
||
region_preference: "(선택) string[] - 예: ['korea', 'japan']",
|
||
budget_limit: "(선택) number - 월 예산 한도(원화, KRW)",
|
||
provider_filter: "(선택) string[] - 예: ['linode', 'vultr']",
|
||
lang: "(선택) 'en' | 'zh' | 'ja' | 'ko' - 응답 언어"
|
||
},
|
||
example: {
|
||
tech_stack: ["nginx", "nodejs", "postgresql"],
|
||
expected_users: 5000,
|
||
use_case: "SaaS 애플리케이션"
|
||
},
|
||
aiLanguageInstruction: 'Respond in Korean. All analysis text must be in Korean.'
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Helper function to get allowed CORS origin
|
||
*/
|
||
function getAllowedOrigin(request: Request): string {
|
||
const allowedOrigins = [
|
||
'https://server-recommend.kappa-d8e.workers.dev',
|
||
];
|
||
const origin = request.headers.get('Origin');
|
||
if (origin && allowedOrigins.includes(origin)) {
|
||
return origin;
|
||
}
|
||
// Allow requests without Origin header (non-browser, curl, etc.)
|
||
if (!origin) {
|
||
return '*';
|
||
}
|
||
return allowedOrigins[0];
|
||
}
|
||
|
||
/**
|
||
* Rate limiting check using KV storage
|
||
*/
|
||
async function checkRateLimit(clientIP: string, env: Env): Promise<{ allowed: boolean; requestId: string }> {
|
||
const requestId = crypto.randomUUID();
|
||
|
||
// If CACHE is not configured, allow the request
|
||
if (!env.CACHE) {
|
||
return { allowed: true, requestId };
|
||
}
|
||
|
||
const now = Date.now();
|
||
const maxRequests = 60;
|
||
const kvKey = `ratelimit:${clientIP}`;
|
||
|
||
try {
|
||
const recordJson = await env.CACHE.get(kvKey);
|
||
const record = recordJson ? JSON.parse(recordJson) as { count: number; resetTime: number } : null;
|
||
|
||
if (!record || record.resetTime < now) {
|
||
// New window
|
||
await env.CACHE.put(
|
||
kvKey,
|
||
JSON.stringify({ count: 1, resetTime: now + 60000 }),
|
||
{ expirationTtl: 60 }
|
||
);
|
||
return { allowed: true, requestId };
|
||
}
|
||
|
||
if (record.count >= maxRequests) {
|
||
return { allowed: false, requestId };
|
||
}
|
||
|
||
// Increment count
|
||
record.count++;
|
||
await env.CACHE.put(
|
||
kvKey,
|
||
JSON.stringify(record),
|
||
{ expirationTtl: 60 }
|
||
);
|
||
return { allowed: true, requestId };
|
||
} catch (error) {
|
||
console.error('[RateLimit] KV error:', error);
|
||
// On error, allow the request (fail open)
|
||
return { allowed: true, requestId };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main request handler
|
||
*/
|
||
export default {
|
||
async fetch(request: Request, env: Env): Promise<Response> {
|
||
const requestId = crypto.randomUUID();
|
||
|
||
try {
|
||
const url = new URL(request.url);
|
||
const path = url.pathname;
|
||
|
||
// Rate limiting (except for health checks)
|
||
if (path !== '/api/health') {
|
||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||
const rateCheck = await checkRateLimit(clientIP, env);
|
||
|
||
if (!rateCheck.allowed) {
|
||
const origin = getAllowedOrigin(request);
|
||
return jsonResponse(
|
||
{ error: 'Too many requests', request_id: rateCheck.requestId },
|
||
429,
|
||
{
|
||
'Access-Control-Allow-Origin': origin,
|
||
'Vary': 'Origin',
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
// CORS headers for all responses
|
||
const origin = getAllowedOrigin(request);
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': origin,
|
||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'Content-Type',
|
||
'Vary': 'Origin',
|
||
};
|
||
|
||
// Handle preflight requests
|
||
if (request.method === 'OPTIONS') {
|
||
return new Response(null, { headers: corsHeaders });
|
||
}
|
||
|
||
// Route handling
|
||
if (path === '/api/health') {
|
||
return handleHealth(corsHeaders);
|
||
}
|
||
|
||
if (path === '/api/servers' && request.method === 'GET') {
|
||
return handleGetServers(request, env, corsHeaders);
|
||
}
|
||
|
||
if (path === '/api/recommend' && request.method === 'POST') {
|
||
return handleRecommend(request, env, corsHeaders);
|
||
}
|
||
|
||
return jsonResponse(
|
||
{ error: 'Not found', request_id: requestId },
|
||
404,
|
||
corsHeaders
|
||
);
|
||
} catch (error) {
|
||
console.error('[Worker] Unhandled error:', error);
|
||
const origin = getAllowedOrigin(request);
|
||
return jsonResponse(
|
||
{
|
||
error: 'Internal server error',
|
||
request_id: requestId,
|
||
},
|
||
500,
|
||
{
|
||
'Access-Control-Allow-Origin': origin,
|
||
'Vary': 'Origin',
|
||
}
|
||
);
|
||
}
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Health check endpoint
|
||
*/
|
||
function handleHealth(corsHeaders: Record<string, string>): Response {
|
||
return jsonResponse(
|
||
{
|
||
status: 'ok',
|
||
timestamp: new Date().toISOString(),
|
||
service: 'server-recommend',
|
||
},
|
||
200,
|
||
corsHeaders
|
||
);
|
||
}
|
||
|
||
/**
|
||
* GET /api/servers - Server list with filtering
|
||
*/
|
||
async function handleGetServers(
|
||
request: Request,
|
||
env: Env,
|
||
corsHeaders: Record<string, string>
|
||
): Promise<Response> {
|
||
try {
|
||
const url = new URL(request.url);
|
||
const provider = url.searchParams.get('provider');
|
||
const minCpu = url.searchParams.get('minCpu');
|
||
const minMemory = url.searchParams.get('minMemory');
|
||
const region = url.searchParams.get('region');
|
||
|
||
console.log('[GetServers] Query params:', {
|
||
provider,
|
||
minCpu,
|
||
minMemory,
|
||
region,
|
||
});
|
||
|
||
// Build SQL query dynamically
|
||
let query = `
|
||
SELECT
|
||
it.id,
|
||
p.display_name as provider_name,
|
||
it.instance_id,
|
||
it.instance_name,
|
||
it.vcpu,
|
||
it.memory_mb,
|
||
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
|
||
it.storage_gb,
|
||
it.network_speed_gbps,
|
||
it.instance_family,
|
||
it.gpu_count,
|
||
it.gpu_type,
|
||
MIN(pr.monthly_price) as monthly_price,
|
||
MIN(r.region_name) as region_name,
|
||
MIN(r.region_code) as region_code
|
||
FROM instance_types it
|
||
JOIN providers p ON it.provider_id = p.id
|
||
JOIN pricing pr ON pr.instance_type_id = it.id
|
||
JOIN regions r ON pr.region_id = r.id
|
||
WHERE p.id IN (1, 2) -- Linode, Vultr only
|
||
AND (
|
||
-- Korea (Seoul)
|
||
r.region_code IN ('icn', 'ap-northeast-2') OR
|
||
LOWER(r.region_name) LIKE '%seoul%' OR
|
||
-- Japan (Tokyo, Osaka)
|
||
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||
LOWER(r.region_code) LIKE '%tyo%' OR
|
||
LOWER(r.region_code) LIKE '%osa%' OR
|
||
LOWER(r.region_name) LIKE '%tokyo%' OR
|
||
LOWER(r.region_name) LIKE '%osaka%' OR
|
||
-- Singapore
|
||
r.region_code IN ('sgp', 'ap-southeast-1') OR
|
||
LOWER(r.region_code) LIKE '%sin%' OR
|
||
LOWER(r.region_code) LIKE '%sgp%' OR
|
||
LOWER(r.region_name) LIKE '%singapore%'
|
||
)
|
||
`;
|
||
|
||
const params: (string | number)[] = [];
|
||
|
||
if (provider) {
|
||
query += ` AND p.name = ?`;
|
||
params.push(provider);
|
||
}
|
||
|
||
if (minCpu) {
|
||
const parsedCpu = parseInt(minCpu, 10);
|
||
if (isNaN(parsedCpu)) {
|
||
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
|
||
}
|
||
query += ` AND it.vcpu >= ?`;
|
||
params.push(parsedCpu);
|
||
}
|
||
|
||
if (minMemory) {
|
||
const parsedMemory = parseInt(minMemory, 10);
|
||
if (isNaN(parsedMemory)) {
|
||
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
|
||
}
|
||
query += ` AND it.memory_mb >= ?`;
|
||
params.push(parsedMemory * 1024);
|
||
}
|
||
|
||
if (region) {
|
||
query += ` AND r.region_code = ?`;
|
||
params.push(region);
|
||
}
|
||
|
||
query += ` GROUP BY it.id ORDER BY MIN(pr.monthly_price) ASC LIMIT 100`;
|
||
|
||
const result = await env.DB.prepare(query).bind(...params).all();
|
||
|
||
if (!result.success) {
|
||
throw new Error('Database query failed');
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
const servers = (result.results as unknown[]).filter(isValidServer);
|
||
const invalidCount = result.results.length - servers.length;
|
||
if (invalidCount > 0) {
|
||
console.warn(`[GetServers] Filtered out ${invalidCount} invalid server records`);
|
||
}
|
||
|
||
console.log('[GetServers] Found servers:', servers.length);
|
||
|
||
return jsonResponse(
|
||
{
|
||
servers,
|
||
count: servers.length,
|
||
filters: { provider, minCpu, minMemory, region },
|
||
},
|
||
200,
|
||
corsHeaders
|
||
);
|
||
} catch (error) {
|
||
console.error('[GetServers] Error:', error);
|
||
const requestId = crypto.randomUUID();
|
||
return jsonResponse(
|
||
{
|
||
error: 'Failed to retrieve servers',
|
||
request_id: requestId,
|
||
},
|
||
500,
|
||
corsHeaders
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* POST /api/recommend - AI-powered server recommendation
|
||
*/
|
||
async function handleRecommend(
|
||
request: Request,
|
||
env: Env,
|
||
corsHeaders: Record<string, string>
|
||
): Promise<Response> {
|
||
const requestId = crypto.randomUUID();
|
||
|
||
try {
|
||
// Parse and validate request
|
||
const body = await request.json() as RecommendRequest;
|
||
const lang = body.lang || 'en';
|
||
const validationError = validateRecommendRequest(body, lang);
|
||
if (validationError) {
|
||
return jsonResponse(validationError, 400, corsHeaders);
|
||
}
|
||
|
||
console.log('[Recommend] Request summary:', {
|
||
tech_stack_count: body.tech_stack.length,
|
||
expected_users: body.expected_users,
|
||
use_case_length: body.use_case.length,
|
||
traffic_pattern: body.traffic_pattern,
|
||
has_region_pref: !!body.region_preference,
|
||
has_budget: !!body.budget_limit,
|
||
has_provider_filter: !!body.provider_filter,
|
||
lang: lang,
|
||
});
|
||
|
||
// Generate cache key
|
||
const cacheKey = generateCacheKey(body);
|
||
console.log('[Recommend] Cache key:', cacheKey);
|
||
|
||
// Check cache (optional - may not be configured)
|
||
if (env.CACHE) {
|
||
const cached = await env.CACHE.get(cacheKey);
|
||
if (cached) {
|
||
console.log('[Recommend] Cache hit');
|
||
return jsonResponse(
|
||
{ ...JSON.parse(cached), cached: true },
|
||
200,
|
||
corsHeaders
|
||
);
|
||
}
|
||
}
|
||
|
||
console.log('[Recommend] Cache miss or disabled');
|
||
|
||
// Phase 1: Execute independent queries in parallel
|
||
const [techSpecs, benchmarkDataAll] = await Promise.all([
|
||
queryTechSpecs(env.DB, body.tech_stack),
|
||
queryBenchmarkData(env.DB, body.tech_stack).catch(err => {
|
||
console.warn('[Recommend] Benchmark data unavailable:', err.message);
|
||
return [] as BenchmarkData[];
|
||
}),
|
||
]);
|
||
console.log('[Recommend] Tech specs matched:', techSpecs.length);
|
||
console.log('[Recommend] Benchmark data points (initial):', benchmarkDataAll.length);
|
||
|
||
// Calculate minimum memory with proper aggregation
|
||
// Memory-intensive services (Java, Elasticsearch, Redis): sum their memory requirements
|
||
// Non-memory-intensive services: 256MB overhead each
|
||
const memoryIntensiveSpecs = techSpecs.filter(s => s.is_memory_intensive);
|
||
const otherSpecs = techSpecs.filter(s => !s.is_memory_intensive);
|
||
|
||
let minMemoryMb: number | undefined;
|
||
if (memoryIntensiveSpecs.length > 0 || otherSpecs.length > 0) {
|
||
// Sum memory-intensive requirements
|
||
const memoryIntensiveSum = memoryIntensiveSpecs.reduce((sum, s) => sum + s.min_memory_mb, 0);
|
||
// Add 256MB overhead per non-memory-intensive service
|
||
const otherOverhead = otherSpecs.length * 256;
|
||
minMemoryMb = memoryIntensiveSum + otherOverhead;
|
||
|
||
console.log(`[Recommend] Memory calculation: ${memoryIntensiveSpecs.length} memory-intensive (${(memoryIntensiveSum/1024).toFixed(1)}GB) + ${otherSpecs.length} other services (${(otherOverhead/1024).toFixed(1)}GB) = ${(minMemoryMb/1024).toFixed(1)}GB total`);
|
||
}
|
||
|
||
// Calculate minimum vCPU with category-based weighting
|
||
// Different tech categories have different bottleneck characteristics
|
||
let minVcpu: number | undefined;
|
||
if (techSpecs.length > 0) {
|
||
// Group specs by category
|
||
const categoryWeights: Record<string, number> = {
|
||
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
|
||
'runtime': 1.0, // nodejs, php, python: actual computation
|
||
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
|
||
'cache': 0.5, // redis, memcached: supporting role
|
||
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
|
||
'container': 0.3, // docker: orchestration overhead
|
||
'messaging': 0.5, // rabbitmq, kafka: I/O bound
|
||
'default': 0.7 // unknown categories
|
||
};
|
||
|
||
// Calculate weighted vCPU requirements per category
|
||
const categoryRequirements = new Map<string, number>();
|
||
|
||
for (const spec of techSpecs) {
|
||
const category = spec.category || 'default';
|
||
const weight = categoryWeights[category] || categoryWeights['default'];
|
||
const vcpuNeeded = Math.ceil(body.expected_users / spec.vcpu_per_users);
|
||
const weightedVcpu = vcpuNeeded * weight;
|
||
|
||
const existing = categoryRequirements.get(category) || 0;
|
||
// Take max within same category (not additive)
|
||
categoryRequirements.set(category, Math.max(existing, weightedVcpu));
|
||
|
||
console.log(`[Recommend] ${spec.name} (${category}): ${vcpuNeeded} vCPU × ${weight} weight = ${weightedVcpu.toFixed(1)} weighted vCPU`);
|
||
}
|
||
|
||
// Find bottleneck: use MAX across categories, not SUM
|
||
// Request flow (web_server → runtime → database) means the slowest component is the bottleneck
|
||
// SUM would over-provision since components process the SAME requests sequentially
|
||
let maxWeightedVcpu = 0;
|
||
let bottleneckCategory = '';
|
||
for (const [category, vcpu] of categoryRequirements) {
|
||
console.log(`[Recommend] Category '${category}': ${vcpu.toFixed(1)} weighted vCPU`);
|
||
if (vcpu > maxWeightedVcpu) {
|
||
maxWeightedVcpu = vcpu;
|
||
bottleneckCategory = category;
|
||
}
|
||
}
|
||
|
||
minVcpu = Math.max(Math.ceil(maxWeightedVcpu), 1); // At least 1 vCPU
|
||
console.log(`[Recommend] Bottleneck: '${bottleneckCategory}' with ${maxWeightedVcpu.toFixed(1)} weighted vCPU → ${minVcpu} vCPU (for ${body.expected_users} users)`);
|
||
}
|
||
|
||
// Calculate bandwidth estimate for provider filtering
|
||
const bandwidthEstimate = estimateBandwidth(body.expected_users, body.use_case, body.traffic_pattern);
|
||
console.log(`[Recommend] Bandwidth estimate: ${bandwidthEstimate.monthly_tb >= 1 ? bandwidthEstimate.monthly_tb + ' TB' : bandwidthEstimate.monthly_gb + ' GB'}/month (${bandwidthEstimate.category})`);
|
||
|
||
// Phase 2: Query candidate servers (depends on minMemoryMb, minVcpu, bandwidth)
|
||
const candidates = await queryCandidateServers(env.DB, body, minMemoryMb, minVcpu, bandwidthEstimate, lang);
|
||
console.log('[Recommend] Candidate servers:', candidates.length);
|
||
|
||
if (candidates.length === 0) {
|
||
return jsonResponse(
|
||
{
|
||
error: 'No servers found matching your requirements',
|
||
recommendations: [],
|
||
request_id: requestId,
|
||
},
|
||
200,
|
||
corsHeaders
|
||
);
|
||
}
|
||
|
||
// Calculate average specs from candidates for VPS benchmark queries
|
||
const avgCores = Math.round(
|
||
candidates.reduce((sum, s) => sum + s.vcpu, 0) / candidates.length
|
||
);
|
||
const avgMemory = Math.round(
|
||
candidates.reduce((sum, s) => sum + s.memory_gb, 0) / candidates.length
|
||
);
|
||
|
||
// Use initially fetched benchmark data (already filtered by tech stack)
|
||
const benchmarkData = benchmarkDataAll;
|
||
|
||
// Get unique providers from candidates
|
||
const providers = [...new Set(candidates.map((c) => c.provider_name))];
|
||
console.log('[Recommend] Providers:', providers);
|
||
|
||
// Query VPS benchmarks using consolidated single query (includes provider-specific and spec-based matching)
|
||
const vpsBenchmarks = await queryVPSBenchmarksBatch(env.DB, avgCores, avgMemory, providers).catch(err => {
|
||
console.warn('[Recommend] VPS benchmark data unavailable:', err.message);
|
||
return [] as VPSBenchmark[];
|
||
});
|
||
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
|
||
|
||
// Use OpenAI GPT-4o-mini to analyze and recommend (techSpecs already queried above)
|
||
const aiResult = await getAIRecommendations(
|
||
env.OPENAI_API_KEY,
|
||
body,
|
||
candidates,
|
||
benchmarkData,
|
||
vpsBenchmarks,
|
||
techSpecs,
|
||
lang
|
||
);
|
||
|
||
console.log('[Recommend] Generated recommendations:', aiResult.recommendations.length);
|
||
|
||
const response = {
|
||
recommendations: aiResult.recommendations,
|
||
infrastructure_tips: aiResult.infrastructure_tips || [],
|
||
total_candidates: candidates.length,
|
||
cached: false,
|
||
};
|
||
|
||
// Cache result for 1 hour (if cache is configured)
|
||
if (env.CACHE) {
|
||
await env.CACHE.put(cacheKey, JSON.stringify(response), {
|
||
expirationTtl: 3600,
|
||
});
|
||
}
|
||
|
||
return jsonResponse(response, 200, corsHeaders);
|
||
} catch (error) {
|
||
console.error('[Recommend] Error:', error);
|
||
console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||
return jsonResponse(
|
||
{
|
||
error: 'Failed to generate recommendations',
|
||
details: error instanceof Error ? error.message : 'Unknown error',
|
||
request_id: requestId,
|
||
},
|
||
500,
|
||
corsHeaders
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Type guard to validate Server object structure
|
||
*/
|
||
function isValidServer(obj: unknown): obj is Server {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
const s = obj as Record<string, unknown>;
|
||
return (
|
||
typeof s.id === 'number' &&
|
||
typeof s.provider_name === 'string' &&
|
||
typeof s.instance_id === 'string' &&
|
||
typeof s.vcpu === 'number' &&
|
||
typeof s.memory_mb === 'number' &&
|
||
typeof s.monthly_price === 'number'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type guard to validate VPSBenchmark object structure
|
||
*/
|
||
function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
const v = obj as Record<string, unknown>;
|
||
return (
|
||
typeof v.id === 'number' &&
|
||
typeof v.provider_name === 'string' &&
|
||
typeof v.vcpu === 'number' &&
|
||
typeof v.geekbench_single === 'number'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type guard to validate TechSpec object structure
|
||
*/
|
||
function isValidTechSpec(obj: unknown): obj is TechSpec {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
const t = obj as Record<string, unknown>;
|
||
return (
|
||
typeof t.id === 'number' &&
|
||
typeof t.name === 'string' &&
|
||
typeof t.vcpu_per_users === 'number' &&
|
||
typeof t.min_memory_mb === 'number'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type guard to validate BenchmarkData object structure
|
||
*/
|
||
function isValidBenchmarkData(obj: unknown): obj is BenchmarkData {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
const b = obj as Record<string, unknown>;
|
||
return (
|
||
typeof b.id === 'number' &&
|
||
typeof b.processor_name === 'string' &&
|
||
typeof b.benchmark_name === 'string' &&
|
||
typeof b.score === 'number'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type guard to validate AI recommendation structure
|
||
*/
|
||
function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
const r = obj as Record<string, unknown>;
|
||
return (
|
||
(typeof r.server_id === 'number' || typeof r.server_id === 'string') &&
|
||
typeof r.score === 'number' &&
|
||
r.analysis !== null &&
|
||
typeof r.analysis === 'object' &&
|
||
r.estimated_capacity !== null &&
|
||
typeof r.estimated_capacity === 'object'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Validate recommendation request
|
||
*/
|
||
function validateRecommendRequest(body: any, lang: string = 'en'): ValidationError | null {
|
||
// Ensure lang is valid
|
||
const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en';
|
||
const messages = i18n[validLang];
|
||
|
||
if (!body || typeof body !== 'object') {
|
||
return {
|
||
error: 'Request body must be a JSON object',
|
||
missing_fields: ['tech_stack', 'expected_users', 'use_case'],
|
||
schema: messages.schema,
|
||
example: messages.example
|
||
};
|
||
}
|
||
|
||
const missingFields: string[] = [];
|
||
const invalidFields: { field: string; reason: string }[] = [];
|
||
|
||
// Check required fields
|
||
if (!body.tech_stack) {
|
||
missingFields.push('tech_stack');
|
||
} else if (!Array.isArray(body.tech_stack) || body.tech_stack.length === 0) {
|
||
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
|
||
} else if (body.tech_stack.length > 20) {
|
||
invalidFields.push({ field: 'tech_stack', reason: 'must not exceed 20 items' });
|
||
} else if (!body.tech_stack.every((item: any) => typeof item === 'string')) {
|
||
invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' });
|
||
}
|
||
|
||
if (body.expected_users === undefined) {
|
||
missingFields.push('expected_users');
|
||
} else if (typeof body.expected_users !== 'number' || body.expected_users < 1) {
|
||
invalidFields.push({ field: 'expected_users', reason: 'must be a positive number' });
|
||
} else if (body.expected_users > 10000000) {
|
||
invalidFields.push({ field: 'expected_users', reason: 'must not exceed 10,000,000' });
|
||
}
|
||
|
||
if (!body.use_case) {
|
||
missingFields.push('use_case');
|
||
} else if (typeof body.use_case !== 'string' || body.use_case.trim().length === 0) {
|
||
invalidFields.push({ field: 'use_case', reason: 'must be a non-empty string' });
|
||
} else if (body.use_case.length > 500) {
|
||
invalidFields.push({ field: 'use_case', reason: 'must not exceed 500 characters' });
|
||
}
|
||
|
||
// Check optional fields if provided
|
||
if (body.traffic_pattern !== undefined && !['steady', 'spiky', 'growing'].includes(body.traffic_pattern)) {
|
||
invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" });
|
||
}
|
||
|
||
if (body.region_preference !== undefined) {
|
||
if (!Array.isArray(body.region_preference)) {
|
||
invalidFields.push({ field: 'region_preference', reason: 'must be an array' });
|
||
} else if (body.region_preference.length > 10) {
|
||
invalidFields.push({ field: 'region_preference', reason: 'must not exceed 10 items' });
|
||
} else if (!body.region_preference.every((item: any) => typeof item === 'string')) {
|
||
invalidFields.push({ field: 'region_preference', reason: 'all items must be strings' });
|
||
}
|
||
}
|
||
|
||
if (body.budget_limit !== undefined && (typeof body.budget_limit !== 'number' || body.budget_limit < 0)) {
|
||
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
||
}
|
||
|
||
if (body.provider_filter !== undefined) {
|
||
if (!Array.isArray(body.provider_filter)) {
|
||
invalidFields.push({ field: 'provider_filter', reason: 'must be an array' });
|
||
} else if (body.provider_filter.length > 10) {
|
||
invalidFields.push({ field: 'provider_filter', reason: 'must not exceed 10 items' });
|
||
} else if (!body.provider_filter.every((item: any) => typeof item === 'string')) {
|
||
invalidFields.push({ field: 'provider_filter', reason: 'all items must be strings' });
|
||
}
|
||
}
|
||
|
||
// Validate lang field if provided
|
||
if (body.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(body.lang)) {
|
||
invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" });
|
||
}
|
||
|
||
// Return error if any issues found
|
||
if (missingFields.length > 0 || invalidFields.length > 0) {
|
||
return {
|
||
error: missingFields.length > 0 ? messages.missingFields : messages.invalidFields,
|
||
...(missingFields.length > 0 && { missing_fields: missingFields }),
|
||
...(invalidFields.length > 0 && { invalid_fields: invalidFields }),
|
||
schema: messages.schema,
|
||
example: messages.example
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Escape LIKE pattern special characters
|
||
*/
|
||
function escapeLikePattern(pattern: string): string {
|
||
return pattern.replace(/[%_\\]/g, '\\$&');
|
||
}
|
||
|
||
/**
|
||
* Query candidate servers from database
|
||
* @param minMemoryMb - Minimum memory requirement from tech specs (optional)
|
||
* @param minVcpu - Minimum vCPU requirement based on expected users (optional)
|
||
* @param bandwidthEstimate - Bandwidth estimate for provider prioritization (optional)
|
||
* @param lang - Language for currency selection: 'ko' → KRW, others → retail USD
|
||
*/
|
||
async function queryCandidateServers(
|
||
db: D1Database,
|
||
req: RecommendRequest,
|
||
minMemoryMb?: number,
|
||
minVcpu?: number,
|
||
bandwidthEstimate?: BandwidthEstimate,
|
||
lang: string = 'en'
|
||
): Promise<Server[]> {
|
||
// Select price column based on language
|
||
// Korean → monthly_price_krw (KRW), Others → monthly_price_retail (1.21x USD)
|
||
const priceColumn = lang === 'ko' ? 'pr.monthly_price_krw' : 'pr.monthly_price_retail';
|
||
const currency = lang === 'ko' ? 'KRW' : 'USD';
|
||
|
||
// Check if region preference is specified
|
||
const hasRegionPref = req.region_preference && req.region_preference.length > 0;
|
||
|
||
let query = `
|
||
SELECT
|
||
it.id,
|
||
p.display_name as provider_name,
|
||
it.instance_id,
|
||
it.instance_name,
|
||
it.vcpu,
|
||
it.memory_mb,
|
||
ROUND(it.memory_mb / 1024.0, 1) as memory_gb,
|
||
it.storage_gb,
|
||
it.network_speed_gbps,
|
||
it.instance_family,
|
||
it.gpu_count,
|
||
it.gpu_type,
|
||
MIN(${priceColumn}) as monthly_price,
|
||
'${currency}' as currency,
|
||
r.region_name as region_name,
|
||
r.region_code as region_code,
|
||
r.country_code as country_code
|
||
FROM instance_types it
|
||
JOIN providers p ON it.provider_id = p.id
|
||
JOIN pricing pr ON pr.instance_type_id = it.id
|
||
JOIN regions r ON pr.region_id = r.id
|
||
WHERE p.id IN (1, 2) -- Linode, Vultr only
|
||
`;
|
||
|
||
const params: (string | number)[] = [];
|
||
|
||
if (req.budget_limit) {
|
||
// Use same price column as display for budget filtering
|
||
query += ` AND ${priceColumn} <= ?`;
|
||
params.push(req.budget_limit);
|
||
}
|
||
|
||
// Filter by minimum memory requirement (from tech specs)
|
||
if (minMemoryMb && minMemoryMb > 0) {
|
||
query += ` AND it.memory_mb >= ?`;
|
||
params.push(minMemoryMb);
|
||
console.log(`[Candidates] Filtering by minimum memory: ${minMemoryMb}MB (${(minMemoryMb/1024).toFixed(1)}GB)`);
|
||
}
|
||
|
||
// Filter by minimum vCPU requirement (from expected users + tech specs)
|
||
if (minVcpu && minVcpu > 0) {
|
||
query += ` AND it.vcpu >= ?`;
|
||
params.push(minVcpu);
|
||
console.log(`[Candidates] Filtering by minimum vCPU: ${minVcpu}`);
|
||
}
|
||
|
||
// Provider filtering based on bandwidth requirements
|
||
// Heavy bandwidth (>2TB/month) → Linode only (better bandwidth allowance)
|
||
// Very heavy bandwidth (>6TB/month) → Linode only with warning
|
||
if (bandwidthEstimate) {
|
||
if (bandwidthEstimate.category === 'very_heavy') {
|
||
// >6TB/month: Linode only (includes up to 20TB depending on plan)
|
||
query += ` AND p.id = 1`; // Linode only
|
||
console.log(`[Candidates] Very heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode only`);
|
||
} else if (bandwidthEstimate.category === 'heavy') {
|
||
// 2-6TB/month: Prefer Linode, but allow Vultr
|
||
// Order by Linode first (handled in ORDER BY)
|
||
console.log(`[Candidates] Heavy bandwidth (${bandwidthEstimate.monthly_tb}TB/month): Linode preferred`);
|
||
}
|
||
}
|
||
|
||
// Country name to code mapping for common names
|
||
// Note: Use specific city names to avoid LIKE pattern collisions (e.g., 'de' matches 'Delhi')
|
||
const countryNameToCode: Record<string, string[]> = {
|
||
'korea': ['seoul', 'ap-northeast-2'],
|
||
'south korea': ['seoul', 'ap-northeast-2'],
|
||
'japan': ['tokyo', 'osaka', 'ap-northeast-1', 'ap-northeast-3'],
|
||
'singapore': ['singapore', 'ap-southeast-1'],
|
||
'indonesia': ['jakarta', 'ap-southeast-3'],
|
||
'india': ['mumbai', 'delhi', 'bangalore', 'hyderabad', 'ap-south-1'],
|
||
'australia': ['sydney', 'melbourne', 'ap-southeast-2'],
|
||
'germany': ['frankfurt', 'nuremberg', 'falkenstein', 'eu-central-1'],
|
||
'usa': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'],
|
||
'united states': ['us-east', 'us-west', 'virginia', 'oregon', 'ohio'],
|
||
'uk': ['london', 'manchester', 'eu-west-2'],
|
||
'united kingdom': ['london', 'manchester', 'eu-west-2'],
|
||
'netherlands': ['amsterdam', 'eu-west-1'],
|
||
'france': ['paris', 'eu-west-3'],
|
||
'hong kong': ['hong kong', 'ap-east-1'],
|
||
'taiwan': ['taipei', 'ap-northeast-1'],
|
||
'brazil': ['sao paulo', 'sa-east-1'],
|
||
'canada': ['montreal', 'toronto', 'ca-central-1'],
|
||
};
|
||
|
||
// Flexible region matching: region_code, region_name, or country_code
|
||
if (req.region_preference && req.region_preference.length > 0) {
|
||
// User specified region → filter to that region only
|
||
const regionConditions: string[] = [];
|
||
for (const region of req.region_preference) {
|
||
const lowerRegion = region.toLowerCase();
|
||
|
||
// Expand country names to their codes/cities
|
||
const expandedRegions = countryNameToCode[lowerRegion] || [lowerRegion];
|
||
const allRegions = [lowerRegion, ...expandedRegions];
|
||
|
||
for (const r of allRegions) {
|
||
const escapedRegion = escapeLikePattern(r);
|
||
regionConditions.push(`(
|
||
LOWER(r.region_code) = ? OR
|
||
LOWER(r.region_code) LIKE ? ESCAPE '\\' OR
|
||
LOWER(r.region_name) LIKE ? ESCAPE '\\' OR
|
||
LOWER(r.country_code) = ?
|
||
)`);
|
||
params.push(r, `%${escapedRegion}%`, `%${escapedRegion}%`, r);
|
||
}
|
||
}
|
||
query += ` AND (${regionConditions.join(' OR ')})`;
|
||
} else {
|
||
// No region specified → default to Seoul/Tokyo/Osaka/Singapore
|
||
query += ` AND (
|
||
-- Korea (Seoul)
|
||
r.region_code IN ('icn', 'ap-northeast-2') OR
|
||
LOWER(r.region_name) LIKE '%seoul%' OR
|
||
-- Japan (Tokyo, Osaka)
|
||
r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR
|
||
LOWER(r.region_code) LIKE '%tyo%' OR
|
||
LOWER(r.region_code) LIKE '%osa%' OR
|
||
LOWER(r.region_name) LIKE '%tokyo%' OR
|
||
LOWER(r.region_name) LIKE '%osaka%' OR
|
||
-- Singapore
|
||
r.region_code IN ('sgp', 'ap-southeast-1') OR
|
||
LOWER(r.region_code) LIKE '%sin%' OR
|
||
LOWER(r.region_code) LIKE '%sgp%' OR
|
||
LOWER(r.region_name) LIKE '%singapore%'
|
||
)`;
|
||
}
|
||
|
||
// Filter by provider if specified
|
||
if (req.provider_filter && req.provider_filter.length > 0) {
|
||
const placeholders = req.provider_filter.map(() => '?').join(',');
|
||
query += ` AND (p.name IN (${placeholders}) OR p.display_name IN (${placeholders}))`;
|
||
params.push(...req.provider_filter, ...req.provider_filter);
|
||
}
|
||
|
||
// Group by instance + region to show each server per region
|
||
// For heavy bandwidth, prioritize Linode (p.id=1) over Vultr (p.id=2)
|
||
const orderByClause = bandwidthEstimate?.category === 'heavy'
|
||
? `ORDER BY CASE WHEN p.id = 1 THEN 0 ELSE 1 END, monthly_price ASC`
|
||
: `ORDER BY monthly_price ASC`;
|
||
query += ` GROUP BY it.id, r.id ${orderByClause} LIMIT 50`;
|
||
|
||
const result = await db.prepare(query).bind(...params).all();
|
||
|
||
if (!result.success) {
|
||
throw new Error('Failed to query candidate servers');
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
const validServers = (result.results as unknown[]).filter(isValidServer);
|
||
const invalidCount = result.results.length - validServers.length;
|
||
if (invalidCount > 0) {
|
||
console.warn(`[Candidates] Filtered out ${invalidCount} invalid server records`);
|
||
}
|
||
return validServers;
|
||
}
|
||
|
||
/**
|
||
* Query relevant benchmark data for tech stack
|
||
*/
|
||
async function queryBenchmarkData(
|
||
db: D1Database,
|
||
techStack: string[],
|
||
coreCount?: number
|
||
): Promise<BenchmarkData[]> {
|
||
// Map tech stack to relevant benchmark types
|
||
const techToBenchmark: Record<string, string[]> = {
|
||
'node.js': ['pts-node-octane', 'pts-node-express-loadtest'],
|
||
'nodejs': ['pts-node-octane', 'pts-node-express-loadtest'],
|
||
'express': ['pts-node-express-loadtest'],
|
||
'nginx': ['pts-nginx'],
|
||
'apache': ['pts-apache'],
|
||
'php': ['pts-phpbench'],
|
||
'redis': ['pts-redis'],
|
||
'mysql': ['pts-mysqlslap'],
|
||
'postgresql': ['pts-mysqlslap'], // Use MySQL benchmark as proxy
|
||
'docker': ['pts-compress-7zip', 'pts-postmark'], // CPU + I/O for containers
|
||
'mongodb': ['pts-postmark'], // I/O intensive
|
||
'python': ['pts-coremark', 'pts-compress-7zip'],
|
||
'java': ['pts-coremark', 'pts-compress-7zip'],
|
||
'go': ['pts-coremark', 'pts-compress-7zip'],
|
||
'rust': ['pts-coremark', 'pts-compress-7zip'],
|
||
};
|
||
|
||
// Find relevant benchmark types
|
||
const relevantBenchmarks = new Set<string>();
|
||
for (const tech of techStack) {
|
||
const benchmarks = techToBenchmark[tech.toLowerCase()] || [];
|
||
benchmarks.forEach(b => relevantBenchmarks.add(b));
|
||
}
|
||
|
||
// Always include general CPU benchmark
|
||
relevantBenchmarks.add('pts-compress-7zip');
|
||
|
||
if (relevantBenchmarks.size === 0) {
|
||
return [];
|
||
}
|
||
|
||
const benchmarkNames = Array.from(relevantBenchmarks);
|
||
const placeholders = benchmarkNames.map(() => '?').join(',');
|
||
|
||
// Query benchmark data, optionally filtering by core count
|
||
let query = `
|
||
SELECT
|
||
p.id,
|
||
p.name as processor_name,
|
||
bt.name as benchmark_name,
|
||
bt.category,
|
||
br.score,
|
||
br.percentile,
|
||
p.cores
|
||
FROM benchmark_results br
|
||
JOIN processors p ON br.processor_id = p.id
|
||
JOIN benchmark_types bt ON br.benchmark_type_id = bt.id
|
||
WHERE bt.name IN (${placeholders})
|
||
`;
|
||
|
||
const params: (string | number)[] = [...benchmarkNames];
|
||
|
||
// If we know core count, filter to similar processors
|
||
if (coreCount && coreCount > 0) {
|
||
query += ` AND (p.cores IS NULL OR (p.cores >= ? AND p.cores <= ?))`;
|
||
params.push(Math.max(1, coreCount - 2), coreCount + 4);
|
||
}
|
||
|
||
query += ` ORDER BY br.percentile DESC, br.score DESC LIMIT 50`;
|
||
|
||
const result = await db.prepare(query).bind(...params).all();
|
||
|
||
if (!result.success) {
|
||
console.warn('[Benchmark] Query failed');
|
||
return [];
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
return (result.results as unknown[]).filter(isValidBenchmarkData);
|
||
}
|
||
|
||
/**
|
||
* Get benchmark reference for a server
|
||
*/
|
||
function getBenchmarkReference(
|
||
benchmarks: BenchmarkData[],
|
||
vcpu: number
|
||
): BenchmarkReference | undefined {
|
||
// Find benchmarks from processors with similar core count
|
||
const similarBenchmarks = benchmarks.filter(b =>
|
||
b.cores === null || (b.cores >= vcpu - 2 && b.cores <= vcpu + 4)
|
||
);
|
||
|
||
if (similarBenchmarks.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
// Group by processor and get the best match
|
||
const byProcessor = new Map<string, BenchmarkData[]>();
|
||
for (const b of similarBenchmarks) {
|
||
const existing = byProcessor.get(b.processor_name) || [];
|
||
existing.push(b);
|
||
byProcessor.set(b.processor_name, existing);
|
||
}
|
||
|
||
// Find processor with most benchmark data
|
||
let bestProcessor = '';
|
||
let maxBenchmarks = 0;
|
||
for (const [name, data] of byProcessor) {
|
||
if (data.length > maxBenchmarks) {
|
||
maxBenchmarks = data.length;
|
||
bestProcessor = name;
|
||
}
|
||
}
|
||
|
||
if (!bestProcessor) {
|
||
return undefined;
|
||
}
|
||
|
||
const processorBenchmarks = byProcessor.get(bestProcessor)!;
|
||
return {
|
||
processor_name: bestProcessor,
|
||
benchmarks: processorBenchmarks.map(b => ({
|
||
name: b.benchmark_name,
|
||
category: b.category,
|
||
score: b.score,
|
||
percentile: b.percentile,
|
||
})),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Query VPS benchmarks - prioritize matching provider
|
||
*/
|
||
async function queryVPSBenchmarks(
|
||
db: D1Database,
|
||
vcpu: number,
|
||
memoryGb: number,
|
||
providerHint?: string
|
||
): Promise<VPSBenchmark[]> {
|
||
const vcpuMin = Math.max(1, vcpu - 1);
|
||
const vcpuMax = vcpu + 2;
|
||
const memMin = Math.max(1, memoryGb - 2);
|
||
const memMax = memoryGb + 4;
|
||
|
||
// First try to find benchmarks from the same provider
|
||
if (providerHint) {
|
||
const providerQuery = `
|
||
SELECT *
|
||
FROM vps_benchmarks
|
||
WHERE (LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\')
|
||
ORDER BY gb6_single_normalized DESC
|
||
LIMIT 20
|
||
`;
|
||
const escapedHint = escapeLikePattern(providerHint.toLowerCase());
|
||
const providerPattern = `%${escapedHint}%`;
|
||
const providerResult = await db.prepare(providerQuery).bind(providerPattern, providerPattern).all();
|
||
|
||
if (providerResult.success && providerResult.results.length > 0) {
|
||
// Validate each result with type guard
|
||
return (providerResult.results as unknown[]).filter(isValidVPSBenchmark);
|
||
}
|
||
}
|
||
|
||
// Fallback: Find VPS with similar specs
|
||
const query = `
|
||
SELECT *
|
||
FROM vps_benchmarks
|
||
WHERE vcpu >= ? AND vcpu <= ?
|
||
AND memory_gb >= ? AND memory_gb <= ?
|
||
ORDER BY gb6_single_normalized DESC
|
||
LIMIT 10
|
||
`;
|
||
|
||
const result = await db.prepare(query).bind(vcpuMin, vcpuMax, memMin, memMax).all();
|
||
|
||
if (!result.success) {
|
||
return [];
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
return (result.results as unknown[]).filter(isValidVPSBenchmark);
|
||
}
|
||
|
||
/**
|
||
* Query VPS benchmarks in a single batched query
|
||
* Consolidates multiple provider-specific queries into one for better performance
|
||
*/
|
||
async function queryVPSBenchmarksBatch(
|
||
db: D1Database,
|
||
vcpu: number,
|
||
memoryGb: number,
|
||
providers: string[]
|
||
): Promise<VPSBenchmark[]> {
|
||
const vcpuMin = Math.max(1, vcpu - 1);
|
||
const vcpuMax = vcpu + 2;
|
||
const memMin = Math.max(1, memoryGb - 2);
|
||
const memMax = memoryGb + 4;
|
||
|
||
// Build provider conditions for up to 3 providers
|
||
const providerConditions: string[] = [];
|
||
const params: (string | number)[] = [];
|
||
|
||
const limitedProviders = providers.slice(0, 3);
|
||
for (const provider of limitedProviders) {
|
||
const pattern = `%${escapeLikePattern(provider.toLowerCase())}%`;
|
||
providerConditions.push(`(LOWER(provider_name) LIKE ? ESCAPE '\\' OR LOWER(plan_name) LIKE ? ESCAPE '\\')`);
|
||
params.push(pattern, pattern);
|
||
}
|
||
|
||
// Build query with provider matching OR spec matching
|
||
const query = `
|
||
SELECT * FROM vps_benchmarks
|
||
WHERE ${providerConditions.length > 0 ? `(${providerConditions.join(' OR ')})` : '1=0'}
|
||
OR (vcpu >= ? AND vcpu <= ? AND memory_gb >= ? AND memory_gb <= ?)
|
||
ORDER BY gb6_single_normalized DESC
|
||
LIMIT 30
|
||
`;
|
||
|
||
params.push(vcpuMin, vcpuMax, memMin, memMax);
|
||
|
||
const result = await db.prepare(query).bind(...params).all();
|
||
|
||
if (!result.success) {
|
||
console.warn('[VPSBenchmarksBatch] Query failed');
|
||
return [];
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
return (result.results as unknown[]).filter(isValidVPSBenchmark);
|
||
}
|
||
|
||
/**
|
||
* Format VPS benchmark data for AI prompt
|
||
* Uses GB6-normalized scores (GB5 scores converted with ×1.45 factor)
|
||
*/
|
||
function formatVPSBenchmarkSummary(benchmarks: VPSBenchmark[]): string {
|
||
if (benchmarks.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
const lines = ['Real VPS performance data (Geekbench 6 normalized):'];
|
||
for (const b of benchmarks.slice(0, 5)) {
|
||
const versionNote = b.geekbench_version?.startsWith('5.') ? ' [GB5→6]' : '';
|
||
lines.push(
|
||
`- ${b.plan_name} (${b.country_code}): Single=${b.gb6_single_normalized}, Multi=${b.gb6_multi_normalized}${versionNote}, $${b.monthly_price_usd}/mo, Perf/$=${b.performance_per_dollar.toFixed(1)}`
|
||
);
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Format benchmark data for AI prompt
|
||
*/
|
||
function formatBenchmarkSummary(benchmarks: BenchmarkData[]): string {
|
||
if (benchmarks.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
// Group by benchmark type
|
||
const byType = new Map<string, BenchmarkData[]>();
|
||
for (const b of benchmarks) {
|
||
const existing = byType.get(b.benchmark_name) || [];
|
||
existing.push(b);
|
||
byType.set(b.benchmark_name, existing);
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
for (const [type, data] of byType) {
|
||
// Get top 3 performers for this benchmark
|
||
const top3 = data.slice(0, 3);
|
||
const scores = top3.map(d =>
|
||
`${d.processor_name}${d.cores ? ` (${d.cores} cores)` : ''}: ${d.score} (${d.percentile}th percentile)`
|
||
);
|
||
lines.push(`### ${type} (${data[0].category})`);
|
||
lines.push(scores.join('\n'));
|
||
lines.push('');
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Query tech stack specifications from database
|
||
* Matches user's tech_stack against canonical names and aliases
|
||
*/
|
||
async function queryTechSpecs(
|
||
db: D1Database,
|
||
techStack: string[]
|
||
): Promise<TechSpec[]> {
|
||
if (!techStack || techStack.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// Normalize user input
|
||
const normalizedStack = techStack.map(t => t.toLowerCase().trim());
|
||
|
||
// Build query that matches both name and aliases (case-insensitive)
|
||
// Using LOWER() for alias matching since aliases are stored as JSON array strings
|
||
const conditions: string[] = [];
|
||
const params: string[] = [];
|
||
|
||
for (const tech of normalizedStack) {
|
||
conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`);
|
||
params.push(tech, `%"${tech}"%`);
|
||
}
|
||
|
||
const query = `
|
||
SELECT
|
||
id, name, category,
|
||
vcpu_per_users, vcpu_per_users_max,
|
||
min_memory_mb, max_memory_mb,
|
||
description, aliases,
|
||
is_memory_intensive, is_cpu_intensive
|
||
FROM tech_specs
|
||
WHERE ${conditions.join(' OR ')}
|
||
ORDER BY category, name
|
||
`;
|
||
|
||
try {
|
||
const result = await db.prepare(query).bind(...params).all();
|
||
|
||
if (!result.success) {
|
||
console.warn('[TechSpecs] Query failed');
|
||
return [];
|
||
}
|
||
|
||
// Validate each result with type guard
|
||
const validSpecs = (result.results as unknown[]).filter(isValidTechSpec);
|
||
console.log(`[TechSpecs] Found ${validSpecs.length} specs for: ${normalizedStack.join(', ')}`);
|
||
return validSpecs;
|
||
} catch (error) {
|
||
console.error('[TechSpecs] Error:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format tech specs for AI prompt
|
||
*/
|
||
function formatTechSpecsForPrompt(techSpecs: TechSpec[]): string {
|
||
if (!techSpecs || techSpecs.length === 0) {
|
||
return `Tech stack resource guidelines:
|
||
- Default: 1 vCPU per 100-300 users, 1-2GB RAM`;
|
||
}
|
||
|
||
const lines = ['Tech stack resource guidelines (MUST follow minimum RAM requirements):'];
|
||
|
||
for (const spec of techSpecs) {
|
||
const vcpuRange = spec.vcpu_per_users_max
|
||
? `${spec.vcpu_per_users}-${spec.vcpu_per_users_max}`
|
||
: `${spec.vcpu_per_users}`;
|
||
|
||
// Convert MB to GB for readability
|
||
const minMemoryGB = (spec.min_memory_mb / 1024).toFixed(1).replace('.0', '');
|
||
const maxMemoryGB = spec.max_memory_mb ? (spec.max_memory_mb / 1024).toFixed(1).replace('.0', '') : null;
|
||
const memoryRange = maxMemoryGB ? `${minMemoryGB}-${maxMemoryGB}GB` : `${minMemoryGB}GB+`;
|
||
|
||
let line = `- ${spec.name}: 1 vCPU per ${vcpuRange} users, MINIMUM ${minMemoryGB}GB RAM`;
|
||
|
||
// Add warnings for special requirements
|
||
const warnings: string[] = [];
|
||
if (spec.is_memory_intensive) warnings.push('⚠️ MEMORY-INTENSIVE: must have at least ' + minMemoryGB + 'GB RAM');
|
||
if (spec.is_cpu_intensive) warnings.push('⚠️ CPU-INTENSIVE');
|
||
if (warnings.length > 0) {
|
||
line += ` [${warnings.join(', ')}]`;
|
||
}
|
||
|
||
lines.push(line);
|
||
}
|
||
|
||
// Add explicit warning for memory-intensive apps
|
||
const memoryIntensive = techSpecs.filter(s => s.is_memory_intensive);
|
||
if (memoryIntensive.length > 0) {
|
||
const maxMinMemory = Math.max(...memoryIntensive.map(s => s.min_memory_mb));
|
||
lines.push('');
|
||
lines.push(`⚠️ CRITICAL: This tech stack includes memory-intensive apps. Servers with less than ${(maxMinMemory / 1024).toFixed(0)}GB RAM will NOT work properly!`);
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Get AI-powered recommendations using OpenAI GPT-4o-mini
|
||
*/
|
||
async function getAIRecommendations(
|
||
apiKey: string,
|
||
req: RecommendRequest,
|
||
candidates: Server[],
|
||
benchmarkData: BenchmarkData[],
|
||
vpsBenchmarks: VPSBenchmark[],
|
||
techSpecs: TechSpec[],
|
||
lang: string = 'en'
|
||
): Promise<{ recommendations: RecommendationResult[]; infrastructure_tips?: string[] }> {
|
||
// Build dynamic tech specs prompt from database
|
||
const techSpecsPrompt = formatTechSpecsForPrompt(techSpecs);
|
||
|
||
// Ensure lang is valid
|
||
const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en';
|
||
const languageInstruction = i18n[validLang].aiLanguageInstruction;
|
||
|
||
// Build system prompt with benchmark awareness
|
||
const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements.
|
||
|
||
CRITICAL RULES:
|
||
1. NEVER over-provision. Recommend the minimum specs needed.
|
||
2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements.
|
||
3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads.
|
||
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
|
||
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
|
||
|
||
BANDWIDTH CONSIDERATIONS (VERY IMPORTANT):
|
||
- Estimated monthly bandwidth is provided based on concurrent users and use case.
|
||
- TOTAL COST = Base server price + Bandwidth overage charges
|
||
- Provider bandwidth allowances:
|
||
* Linode: 1TB (1GB plan) to 20TB (192GB plan) included free, $0.005/GB overage
|
||
* Vultr: 1TB-10TB depending on plan, $0.01/GB overage (2x Linode rate)
|
||
* DigitalOcean: 1TB-12TB depending on plan, $0.01/GB overage
|
||
- For bandwidth >1TB/month: Linode is often cheaper despite higher base price
|
||
- For bandwidth >3TB/month: Linode is STRONGLY preferred (overage savings significant)
|
||
- Always mention bandwidth implications in cost_efficiency analysis
|
||
|
||
${techSpecsPrompt}
|
||
|
||
Use REAL BENCHMARK DATA to validate capacity estimates.
|
||
|
||
${languageInstruction}`;
|
||
|
||
// Build user prompt with requirements and candidates
|
||
|
||
// Estimate bandwidth based on concurrent users and use case
|
||
const bandwidthEstimate = estimateBandwidth(req.expected_users, req.use_case, req.traffic_pattern);
|
||
console.log('[AI] Bandwidth estimate:', bandwidthEstimate);
|
||
|
||
// Detect high-traffic based on bandwidth estimate (more accurate than keyword matching)
|
||
const isHighTraffic = bandwidthEstimate.category === 'heavy' || bandwidthEstimate.category === 'very_heavy';
|
||
|
||
// Format benchmark data for the prompt
|
||
const benchmarkSummary = formatBenchmarkSummary(benchmarkData);
|
||
const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks);
|
||
|
||
const userPrompt = `Analyze these server options and recommend the top 3 best matches.
|
||
|
||
## User Requirements
|
||
- Tech Stack: ${req.tech_stack.join(', ')}
|
||
- Expected Concurrent Users: ${req.expected_users} ${req.traffic_pattern === 'spiky' ? '(with traffic spikes)' : req.traffic_pattern === 'growing' ? '(growing user base)' : '(steady traffic)'}
|
||
- **Estimated DAU (Daily Active Users)**: ${bandwidthEstimate.estimated_dau_min.toLocaleString()}-${bandwidthEstimate.estimated_dau_max.toLocaleString()}명 (동시 접속 ${req.expected_users}명 기준)
|
||
- Use Case: ${req.use_case}
|
||
- Traffic Pattern: ${req.traffic_pattern || 'steady'}
|
||
- **Estimated Monthly Bandwidth**: ${bandwidthEstimate.monthly_tb >= 1 ? `${bandwidthEstimate.monthly_tb} TB` : `${bandwidthEstimate.monthly_gb} GB`} (${bandwidthEstimate.category})
|
||
${isHighTraffic ? `- ⚠️ HIGH BANDWIDTH WORKLOAD (${bandwidthEstimate.monthly_tb} TB/month): MUST recommend Linode over Vultr. Linode includes 1-6TB/month transfer vs Vultr overage charges ($0.01/GB). Bandwidth cost savings > base price difference.` : ''}
|
||
${req.region_preference ? `- Region Preference: ${req.region_preference.join(', ')}` : ''}
|
||
${req.budget_limit ? `- Budget Limit: $${req.budget_limit}/month` : ''}
|
||
|
||
## Real VPS Benchmark Data (Geekbench 6 normalized - actual VPS tests)
|
||
${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'}
|
||
|
||
## CPU Benchmark Reference (from Phoronix Test Suite)
|
||
${benchmarkSummary || 'No relevant CPU benchmark data available.'}
|
||
|
||
## Available Servers
|
||
${candidates.map((s, idx) => `
|
||
${idx + 1}. ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''}
|
||
ID: ${s.id}
|
||
Instance: ${s.instance_id}
|
||
vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB
|
||
Network: ${s.network_speed_gbps ? `${s.network_speed_gbps} Gbps` : 'N/A'}${s.gpu_count > 0 ? ` | GPU: ${s.gpu_count}x ${s.gpu_type || 'Unknown'}` : ' | GPU: None'}
|
||
Price: ${s.currency === 'KRW' ? '₩' : '$'}${s.currency === 'KRW' ? Math.round(s.monthly_price).toLocaleString() : s.monthly_price.toFixed(2)}/month (${s.currency}) | Region: ${s.region_name} (${s.region_code})
|
||
`).join('\n')}
|
||
|
||
Return ONLY a valid JSON object (no markdown, no code blocks) with this exact structure:
|
||
{
|
||
"recommendations": [
|
||
{
|
||
"server_id": 123,
|
||
"score": 95,
|
||
"analysis": {
|
||
"tech_fit": "Why this server fits the tech stack",
|
||
"capacity": "MUST mention: '동시 접속 X명 요청 (DAU A-B명), 최대 동시 Y명까지 처리 가능' format",
|
||
"cost_efficiency": "MUST include: base price + bandwidth cost estimate. Example: '$5/month + ~$X bandwidth = ~$Y total'",
|
||
"scalability": "Scalability potential including bandwidth headroom"
|
||
},
|
||
"estimated_capacity": {
|
||
"max_concurrent_users": 7500,
|
||
"requests_per_second": 1000
|
||
}
|
||
}
|
||
],
|
||
"infrastructure_tips": [
|
||
"Practical tip 1",
|
||
"Practical tip 2"
|
||
]
|
||
}
|
||
|
||
Provide exactly 3 recommendations:
|
||
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load (highest score if viable)
|
||
2. BALANCED option: Some headroom for traffic spikes
|
||
3. PREMIUM option: Ready for 2-3x growth
|
||
|
||
SCORING (100 points total):
|
||
- Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score.
|
||
- Capacity Fit (30%): Can it handle the concurrent users and bandwidth?
|
||
- Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance.
|
||
|
||
The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`;
|
||
|
||
console.log('[AI] Sending request to OpenAI GPT-4o-mini');
|
||
|
||
// Create AbortController with 30 second timeout
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||
|
||
try {
|
||
const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'gpt-4o-mini',
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userPrompt },
|
||
],
|
||
max_tokens: 2000,
|
||
temperature: 0.3,
|
||
}),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!openaiResponse.ok) {
|
||
const errorText = await openaiResponse.text();
|
||
const sanitized = errorText.slice(0, 100).replace(/sk-[a-zA-Z0-9-_]+/g, 'sk-***');
|
||
console.error('[AI] OpenAI API error:', openaiResponse.status, sanitized);
|
||
throw new Error(`OpenAI API error: ${openaiResponse.status}`);
|
||
}
|
||
|
||
const openaiResult = await openaiResponse.json() as {
|
||
choices: Array<{ message: { content: string } }>;
|
||
};
|
||
|
||
const response = openaiResult.choices[0]?.message?.content || '';
|
||
|
||
console.log('[AI] Response received from OpenAI, length:', response.length);
|
||
console.log('[AI] Raw response preview:', response.substring(0, 500));
|
||
|
||
// Parse AI response
|
||
const aiResult = parseAIResponse(response);
|
||
console.log('[AI] Parsed recommendations count:', aiResult.recommendations.length);
|
||
|
||
// Pre-index VPS benchmarks by provider for O(1) lookups
|
||
const vpsByProvider = new Map<string, VPSBenchmark[]>();
|
||
for (const vps of vpsBenchmarks) {
|
||
const providerKey = vps.provider_name.toLowerCase();
|
||
const existing = vpsByProvider.get(providerKey) || [];
|
||
existing.push(vps);
|
||
vpsByProvider.set(providerKey, existing);
|
||
}
|
||
|
||
// Map AI recommendations to full results
|
||
const results: RecommendationResult[] = [];
|
||
for (const aiRec of aiResult.recommendations) {
|
||
// Handle both string and number server_id from AI
|
||
const serverId = Number(aiRec.server_id);
|
||
const server = candidates.find((s) => s.id === serverId);
|
||
if (!server) {
|
||
console.warn('[AI] Server not found:', aiRec.server_id);
|
||
continue;
|
||
}
|
||
|
||
// Get benchmark reference for this server's CPU count
|
||
const benchmarkRef = getBenchmarkReference(benchmarkData, server.vcpu);
|
||
|
||
// Find matching VPS benchmark using pre-indexed data
|
||
const providerName = server.provider_name.toLowerCase();
|
||
let matchingVPS: VPSBenchmark | undefined;
|
||
|
||
// Try to find from indexed provider benchmarks
|
||
for (const [providerKey, benchmarks] of vpsByProvider.entries()) {
|
||
if (providerKey.includes(providerName) || providerName.includes(providerKey)) {
|
||
// First try exact or close vCPU match
|
||
matchingVPS = benchmarks.find(
|
||
(v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1)
|
||
);
|
||
// Fallback to any from this provider
|
||
if (!matchingVPS && benchmarks.length > 0) {
|
||
matchingVPS = benchmarks[0];
|
||
}
|
||
if (matchingVPS) break;
|
||
}
|
||
}
|
||
|
||
// Final fallback: similar specs from any provider
|
||
if (!matchingVPS) {
|
||
matchingVPS = vpsBenchmarks.find(
|
||
(v) => v.vcpu === server.vcpu || (v.vcpu >= server.vcpu - 1 && v.vcpu <= server.vcpu + 1)
|
||
);
|
||
}
|
||
|
||
results.push({
|
||
server: server,
|
||
score: aiRec.score,
|
||
analysis: aiRec.analysis,
|
||
estimated_capacity: aiRec.estimated_capacity,
|
||
benchmark_reference: benchmarkRef,
|
||
vps_benchmark_reference: matchingVPS
|
||
? {
|
||
plan_name: matchingVPS.plan_name,
|
||
geekbench_single: matchingVPS.geekbench_single,
|
||
geekbench_multi: matchingVPS.geekbench_multi,
|
||
monthly_price_usd: matchingVPS.monthly_price_usd,
|
||
performance_per_dollar: matchingVPS.performance_per_dollar,
|
||
}
|
||
: undefined,
|
||
});
|
||
|
||
if (results.length >= 3) break;
|
||
}
|
||
|
||
return {
|
||
recommendations: results,
|
||
infrastructure_tips: aiResult.infrastructure_tips,
|
||
};
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
// Handle timeout specifically
|
||
if (error instanceof Error && error.name === 'AbortError') {
|
||
console.error('[AI] Request timed out after 30 seconds');
|
||
throw new Error('AI request timed out - please try again');
|
||
}
|
||
console.error('[AI] Error:', error);
|
||
throw new Error(`AI processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse AI response and extract JSON
|
||
*/
|
||
function parseAIResponse(response: any): AIRecommendationResponse {
|
||
try {
|
||
// Handle different response formats
|
||
let content: string;
|
||
|
||
if (typeof response === 'string') {
|
||
content = response;
|
||
} else if (response.response) {
|
||
content = response.response;
|
||
} else if (response.result && response.result.response) {
|
||
content = response.result.response;
|
||
} else if (response.choices && response.choices[0]?.message?.content) {
|
||
content = response.choices[0].message.content;
|
||
} else {
|
||
console.error('[AI] Unexpected response format:', response);
|
||
throw new Error('Unexpected AI response format');
|
||
}
|
||
|
||
// Remove markdown code blocks if present
|
||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||
|
||
// Find JSON object in response
|
||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||
if (!jsonMatch) {
|
||
throw new Error('No JSON found in AI response');
|
||
}
|
||
|
||
const parsed = JSON.parse(jsonMatch[0]);
|
||
|
||
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
||
throw new Error('Invalid recommendations structure');
|
||
}
|
||
|
||
// Validate each recommendation with type guard
|
||
const validRecommendations = parsed.recommendations.filter(isValidAIRecommendation);
|
||
if (validRecommendations.length === 0 && parsed.recommendations.length > 0) {
|
||
console.warn('[AI] All recommendations failed validation, raw:', JSON.stringify(parsed.recommendations[0]).slice(0, 200));
|
||
throw new Error('AI recommendations failed validation');
|
||
}
|
||
|
||
return {
|
||
recommendations: validRecommendations,
|
||
infrastructure_tips: Array.isArray(parsed.infrastructure_tips) ? parsed.infrastructure_tips : [],
|
||
} as AIRecommendationResponse;
|
||
} catch (error) {
|
||
console.error('[AI] Parse error:', error);
|
||
console.error('[AI] Response was:', response);
|
||
throw new Error(`Failed to parse AI response: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitize special characters for cache key
|
||
*/
|
||
function sanitizeCacheValue(value: string): string {
|
||
return value.replace(/[|:,]/g, '_');
|
||
}
|
||
|
||
/**
|
||
* Generate cache key from request parameters
|
||
*/
|
||
function generateCacheKey(req: RecommendRequest): string {
|
||
// Don't mutate original arrays - create sorted copies
|
||
const sortedStack = [...req.tech_stack].sort();
|
||
const sanitizedStack = sortedStack.map(sanitizeCacheValue).join(',');
|
||
|
||
// Hash use_case to avoid special characters and length issues
|
||
const useCaseHash = hashString(req.use_case);
|
||
|
||
const parts = [
|
||
`stack:${sanitizedStack}`,
|
||
`users:${req.expected_users}`,
|
||
`case:${useCaseHash}`,
|
||
];
|
||
|
||
if (req.traffic_pattern) {
|
||
parts.push(`traffic:${req.traffic_pattern}`);
|
||
}
|
||
|
||
if (req.region_preference) {
|
||
const sortedRegions = [...req.region_preference].sort();
|
||
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
|
||
parts.push(`reg:${sanitizedRegions}`);
|
||
}
|
||
|
||
if (req.budget_limit) {
|
||
parts.push(`budget:${req.budget_limit}`);
|
||
}
|
||
|
||
if (req.provider_filter && req.provider_filter.length > 0) {
|
||
const sortedProviders = [...req.provider_filter].sort();
|
||
const sanitizedProviders = sortedProviders.map(sanitizeCacheValue).join(',');
|
||
parts.push(`prov:${sanitizedProviders}`);
|
||
}
|
||
|
||
// Include language in cache key
|
||
if (req.lang) {
|
||
parts.push(`lang:${req.lang}`);
|
||
}
|
||
|
||
return `recommend:${parts.join('|')}`;
|
||
}
|
||
|
||
/**
|
||
* Simple hash function for strings
|
||
*/
|
||
function hashString(str: string): string {
|
||
let hash = 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
const char = str.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash = hash & hash; // Convert to 32-bit integer
|
||
}
|
||
return Math.abs(hash).toString(36);
|
||
}
|
||
|
||
/**
|
||
* JSON response helper
|
||
*/
|
||
function jsonResponse(
|
||
data: any,
|
||
status: number,
|
||
headers: Record<string, string> = {}
|
||
): Response {
|
||
return new Response(JSON.stringify(data), {
|
||
status,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'X-Frame-Options': 'DENY',
|
||
'Cache-Control': 'no-store',
|
||
...headers,
|
||
},
|
||
});
|
||
}
|