/** * Server Expert Agent - 서버 전문가 AI 상담 시스템 * * 기능: * - 대화형 서버 추천 상담 * - 세션 기반 정보 수집 * - 충분한 정보 수집 시 자동 추천 * - 추천 후 사용자 선택 및 주문 흐름 * - Brave Search / Context7 도구로 최신 트렌드 반영 */ import type { Env, ServerSession } from './types'; import { createLogger } from './utils/logger'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; const logger = createLogger('server-agent'); // KV Session Management const SESSION_TTL = 3600; // 1 hour const SESSION_KEY_PREFIX = 'server_session:'; export async function getServerSession( kv: KVNamespace, userId: string ): Promise { try { const key = `${SESSION_KEY_PREFIX}${userId}`; const data = await kv.get(key, 'json'); if (!data) { logger.info('세션 없음', { userId }); return null; } logger.info('세션 조회 성공', { userId, status: (data as ServerSession).status }); return data as ServerSession; } catch (error) { logger.error('세션 조회 실패', error as Error, { userId }); return null; } } export async function saveServerSession( kv: KVNamespace, userId: string, session: ServerSession ): Promise { try { const key = `${SESSION_KEY_PREFIX}${userId}`; session.updatedAt = Date.now(); await kv.put(key, JSON.stringify(session), { expirationTtl: SESSION_TTL, }); logger.info('세션 저장 성공', { userId, status: session.status }); } catch (error) { logger.error('세션 저장 실패', error as Error, { userId }); throw error; } } export async function deleteServerSession( kv: KVNamespace, userId: string ): Promise { try { const key = `${SESSION_KEY_PREFIX}${userId}`; await kv.delete(key); logger.info('세션 삭제 성공', { userId }); } catch (error) { logger.error('세션 삭제 실패', error as Error, { userId }); throw error; } } // Server Expert AI Tools const serverExpertTools = [ { type: 'function' as const, function: { name: 'search_trends', description: '최신 기술 트렌드, 서버 요구사항, 프레임워크 인기도를 검색합니다. 예: "2024 WordPress server requirements", "Next.js hosting best practices"', parameters: { type: 'object', properties: { query: { type: 'string', description: '검색 쿼리 (영문 권장, 기술 키워드 포함)', }, }, required: ['query'], }, }, }, { type: 'function' as const, function: { name: 'lookup_framework_docs', description: '프레임워크/라이브러리 공식 문서에서 서버 요구사항, 배포 가이드, 권장 환경을 조회합니다.', parameters: { type: 'object', properties: { library: { type: 'string', description: '라이브러리/프레임워크 이름 (예: nextjs, laravel, django, wordpress)', }, topic: { type: 'string', description: '조회할 주제 (예: deployment requirements, production setup, server specs)', }, }, required: ['library', 'topic'], }, }, }, ]; // Execute server expert tool async function executeServerExpertTool( toolName: string, args: Record, env: Env ): Promise { logger.info('도구 실행', { toolName, args }); switch (toolName) { case 'search_trends': { const result = await executeSearchWeb({ query: args.query as string }, env); return result; } case 'lookup_framework_docs': { const result = await executeLookupDocs({ library: args.library as string, query: args.topic as string, }, env); return result; } default: return `알 수 없는 도구: ${toolName}`; } } // Tech stack inference from use case function inferTechStack(useCase: string): string[] { const useCaseLower = useCase.toLowerCase(); if (/블로그|blog|wordpress/.test(useCaseLower)) { return ['wordpress']; } if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(useCaseLower)) { return ['ecommerce']; } if (/커뮤니티|게시판|forum|community/.test(useCaseLower)) { return ['php', 'mysql']; } if (/api|백엔드|backend/.test(useCaseLower)) { return ['nodejs', 'express']; } return ['web']; // Default } // Expected users inference from scale // Returns concurrent users (not DAU) function inferExpectedUsers(scale: string): number { // DAU → 동시접속자 변환 (5-10% 비율 적용) if (scale === 'personal') return 10; // DAU 100명 → 동접 10명 if (scale === 'business') return 50; // DAU 500명 → 동접 50명 return 10; // Default to personal } // OpenAI API 응답 타입 interface OpenAIToolCall { id: string; type: 'function'; function: { name: string; arguments: string; }; } interface OpenAIMessage { role: 'assistant'; content: string | null; tool_calls?: OpenAIToolCall[]; } interface OpenAIAPIResponse { choices: Array<{ message: OpenAIMessage; finish_reason: string; }>; } // RecommendResponse 타입 (server-tool.ts와 동일) interface RecommendResponse { recommendations: Array<{ server: { instance_name: string; vcpu: number; memory_gb: number; storage_gb: number; transfer_tb: number; monthly_price: number; provider_name: string; region_code: string; region_name: string; }; score: number; estimated_capacity?: { max_concurrent_users?: number; }; bandwidth_analysis?: { estimated_monthly_tb?: number; overage_tb?: number; overage_cost_krw?: number; }; }>; } // OpenAI 호출 (서버 전문가 AI with Function Calling) async function callServerExpertAI( env: Env, session: ServerSession, userMessage: string, recommendationData?: RecommendResponse ): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['collectedInfo'] }> { if (!env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY not configured'); } const { getOpenAIUrl } = await import('./utils/api-urls'); // Build conversation history const conversationHistory = session.messages.map(m => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, content: m.content, })); // 검토 모드: 추천 결과가 있을 때 const isReviewMode = !!recommendationData; const systemPrompt = isReviewMode ? `당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다. ## 전문성 (30년 경력) - 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 - 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 - 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 - 수천 개의 서버 구축 경험 ## 검토 대상 추천 결과 ${JSON.stringify(recommendationData?.recommendations, null, 2)} ## 사용자 요구사항 - 용도: ${session.collectedInfo.useCase || '웹 서비스'} - 규모: ${session.collectedInfo.scale === 'business' ? '사업용' : '개인용'} ${session.collectedInfo.expectedDau ? `- 일일 방문자(DAU): ${session.collectedInfo.expectedDau}명` : ''} ${session.collectedInfo.expectedConcurrent ? `- 동시접속자: ${session.collectedInfo.expectedConcurrent}명` : ''} ${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetLimit}원` : ''} ## 사용자 수 관련 참고사항 - DAU(일일 활성 사용자)와 동시접속자는 다른 개념입니다 - 일반적으로 동시접속자는 DAU의 5-10% 수준입니다 - 서버 스펙은 동시접속자 기준으로 계산됩니다 ## 검토 작업 다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요: 1. 추천된 서버가 용도와 규모에 적합한지 2. 스펙이 충분한지 (RAM, CPU, 스토리지) 3. DAU/동시접속자 기준이 적절한지 4. 대역폭 경고(overage)가 있다면 언급 5. 더 적합한 스펙이 필요하다면 제안 ## 응답 형식 (반드시 JSON만 반환) { "action": "recommend", "message": "검토 코멘트 (자연스럽고 친근한 어조, 2-3문장)", "collectedInfo": ${JSON.stringify(session.collectedInfo)} } 중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.` : `당신은 30년 경력의 시니어 클라우드 아키텍트입니다. ## 전문성 (30년 경력) - 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 - 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 - 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 - 수천 개의 서버 구축 경험으로 용도만 들으면 최적 스펙을 바로 판단 가능 ## 성격 - 따뜻하고 친근하지만 전문적인 어조 - 비기술자도 이해하기 쉽게 설명 - 고객의 예산과 상황을 항상 배려 - 불필요한 기술 용어 사용 자제 ## 금지 사항 (절대 위반 금지) - AWS, GCP, Azure, Vultr, Linode, DigitalOcean 등 다른 클라우드 프로바이더 언급 금지 - 경쟁사 서비스 추천 금지 - 우리 서비스(Anvil)만 추천 - "다른 곳도 고려해보세요" 같은 멘트 금지 ## 도구 사용 가이드 (적극적으로 활용할 것) - 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인 - "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출 - 블로그, 쇼핑몰 같은 일반적 용도는 경험으로 바로 답변 - 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...") ## 대화 흐름 1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: 블로그, 쇼핑몰, 커뮤니티)" 2. 규모 파악: "개인용인가요, 사업용인가요?" 3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?" 4. 정보가 충분하면 즉시 추천 (추가 질문 없이) ## 핵심 규칙 (반드시 준수) - 기술 스택, 트래픽 패턴은 절대 묻지 않음 (30년 경험으로 알아서 추론) - 사용자 수를 언급하면 DAU인지 동시접속자인지 반드시 한 번 확인 - "방문자 1000명", "유저 500명" 등 언급 시 → "말씀하신 방문자는 일일 방문자(DAU)인가요, 동시접속자인가요?" - DAU와 동시접속자를 구분해서 설명: "일반적으로 동시접속자는 일일 방문자의 5-10% 정도입니다" - "모르겠어요", "아무거나", "글쎄요" → 즉시 action="recommend" (기본값: 개인용 웹서비스) - 용도+규모 한번에 말하면 → 즉시 action="recommend" - 용도만 말해도 → 개인용으로 가정하고 action="recommend" 가능 - 질문은 최대 2번까지, 그 이후는 무조건 action="recommend" ## 사용자 수 관련 용어 정리 - **DAU (일일 활성 사용자)**: 하루 동안 서비스를 사용하는 전체 사용자 수 - **동시접속자 (Concurrent Users)**: 같은 시간에 동시에 접속해 있는 사용자 수 - **중요**: 서버 스펙은 동시접속자를 기준으로 계산해야 합니다 - **일반 공식**: 동시접속자 = DAU × 5-10% 예시: - "하루 방문자 1000명" → DAU 1000명 → 동시접속자 50-100명 - "동시 접속 100명" → 그대로 동시접속자 100명 사용 ## 추론 규칙 (30년 경험 기반) - 블로그 → WordPress, 1GB RAM이면 충분, DAU 100명 (동시접속자 10명) - 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명) - 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB - 게임서버 → 고사양 CPU, 낮은 레이턴시 리전 - 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명) ## 현재 수집된 정보 ${JSON.stringify(session.collectedInfo, null, 2)} ## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지) { "action": "question" | "recommend", "message": "사용자에게 보여줄 메시지 (도구에서 얻은 정보를 자연스럽게 포함)", "collectedInfo": { "useCase": "용도 (없으면 '웹서비스')", "scale": "personal 또는 business (없으면 'personal')", "expectedDau": "일일 방문자 수 (사용자가 명시한 경우)", "expectedConcurrent": "동시접속자 수 (사용자가 명시하거나 DAU에서 계산)" } } 중요: 정보가 부족해도 기본값으로 action="recommend" 하세요. 30년 경험이면 충분합니다.`; try { // Messages array that we'll build up with tool results const messages: Array<{ role: string; content: string | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; name?: string }> = [ { role: 'system', content: systemPrompt }, ...conversationHistory, { role: 'user', content: userMessage }, ]; const MAX_TOOL_CALLS = 3; let toolCallCount = 0; // Loop to handle tool calls while (toolCallCount < MAX_TOOL_CALLS) { const response = await fetch(getOpenAIUrl(env), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages, tools: serverExpertTools, tool_choice: 'auto', max_tokens: 800, temperature: 0.7, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`OpenAI API error: ${response.status} - ${error}`); } const data = await response.json() as OpenAIAPIResponse; const assistantMessage = data.choices[0].message; // Check if AI wants to call tools if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { logger.info('도구 호출 요청', { tools: assistantMessage.tool_calls.map(tc => tc.function.name), }); // Add assistant message with tool calls messages.push({ role: 'assistant', content: assistantMessage.content, tool_calls: assistantMessage.tool_calls, }); // Execute each tool and add results for (const toolCall of assistantMessage.tool_calls) { const args = JSON.parse(toolCall.function.arguments); const result = await executeServerExpertTool(toolCall.function.name, args, env); messages.push({ role: 'tool', tool_call_id: toolCall.id, name: toolCall.function.name, content: result, }); toolCallCount++; } // Continue loop to get AI's response with tool results continue; } // No tool calls - parse the final response const aiResponse = assistantMessage.content || ''; logger.info('AI 응답', { response: aiResponse.slice(0, 200), toolCallCount }); // JSON 파싱 (마크다운 코드 블록 제거) const jsonMatch = aiResponse.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) || aiResponse.match(/(\{[\s\S]*\})/); if (!jsonMatch) { logger.error('JSON 파싱 실패', new Error('No JSON found'), { response: aiResponse }); throw new Error('AI 응답 형식 오류'); } const parsed = JSON.parse(jsonMatch[1]); // Validate response structure if (!parsed.action || !parsed.message) { throw new Error('Invalid AI response structure'); } return { action: parsed.action, message: parsed.message, collectedInfo: parsed.collectedInfo || session.collectedInfo, }; } // Max tool calls reached, force a recommendation logger.warn('최대 도구 호출 횟수 도달', { toolCallCount }); return { action: 'recommend', message: '분석이 완료되었습니다. 최적의 서버를 추천해 드리겠습니다.', collectedInfo: session.collectedInfo, }; } catch (error) { logger.error('Server Expert AI 호출 실패', error as Error); throw error; } } // Main consultation processing export async function processServerConsultation( userMessage: string, session: ServerSession, env: Env ): Promise { try { logger.info('상담 처리 시작', { userId: session.telegramUserId, message: userMessage.slice(0, 50), status: session.status }); // 취소 키워드 처리 (selecting 또는 ordering 상태에서) if ((session.status === 'selecting' || session.status === 'ordering') && /취소|다시|처음/.test(userMessage)) { await deleteServerSession(env.SESSION_KV, session.telegramUserId); logger.info('사용자 요청으로 상담 취소', { userId: session.telegramUserId }); return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.'; } // 선택 단계 처리 if (session.status === 'selecting' && session.lastRecommendation) { const selectionMatch = userMessage.match(/(\d+)(?:번|번째)?|첫\s*번째|두\s*번째|세\s*번째/); if (selectionMatch) { let selectedIndex = -1; // 숫자 추출 if (selectionMatch[1]) { selectedIndex = parseInt(selectionMatch[1], 10) - 1; } else if (userMessage.includes('첫')) { selectedIndex = 0; } else if (userMessage.includes('두')) { selectedIndex = 1; } else if (userMessage.includes('세')) { selectedIndex = 2; } // 유효성 검증 if (selectedIndex >= 0 && selectedIndex < session.lastRecommendation.recommendations.length) { const selected = session.lastRecommendation.recommendations[selectedIndex]; // Mark session as ordering session.status = 'ordering'; await saveServerSession(env.SESSION_KV, session.telegramUserId, session); // 주문 확인 메시지 생성 (인라인 버튼 포함) const keyboardData = JSON.stringify({ type: 'server_order', userId: session.telegramUserId, index: selectedIndex, plan: selected.plan_name }); return `🖥️ ${selected.plan_name} 신청 확인\n\n` + `• 제공사: ${selected.provider}\n` + `• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB / ${selected.specs.storage_gb}GB\n` + `• 리전: ${selected.region.name} (${selected.region.code})\n` + `• 가격: ₩${selected.price.monthly_krw.toLocaleString()}/월\n\n` + `신청하시겠습니까?\n\n` + `__KEYBOARD__${keyboardData}__END__`; } else { return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`; } } // 선택하지 않고 다른 질문을 한 경우 return '서버 번호를 선택해주세요. (예: 1번)\n또는 "취소"라고 말씀하시면 처음부터 다시 시작합니다.'; } // Add user message to history session.messages.push({ role: 'user', content: userMessage }); // Call Server Expert AI const aiResult = await callServerExpertAI(env, session, userMessage); // Update collected info session.collectedInfo = { ...session.collectedInfo, ...aiResult.collectedInfo }; // Add AI response to history session.messages.push({ role: 'assistant', content: aiResult.message }); if (aiResult.action === 'recommend') { // Mark session as recommending session.status = 'recommending'; await saveServerSession(env.SESSION_KV, session.telegramUserId, session); // 1. Call recommendation API (추천 먼저 받기) logger.info('추천 API 호출', { collectedInfo: session.collectedInfo }); const { executeServerAction, getRecommendationData } = await import('./tools/server-tool'); const techStack = session.collectedInfo.useCase ? inferTechStack(session.collectedInfo.useCase) : ['web']; // 동시접속자 우선 사용, 없으면 scale 기반 추론 let expectedUsers = 10; // Default if (session.collectedInfo.expectedConcurrent) { expectedUsers = session.collectedInfo.expectedConcurrent; } else if (session.collectedInfo.expectedDau) { // DAU가 있으면 10% 비율로 동시접속자 계산 expectedUsers = Math.ceil(session.collectedInfo.expectedDau * 0.1); } else if (session.collectedInfo.scale) { expectedUsers = inferExpectedUsers(session.collectedInfo.scale); } const recommendationData = await getRecommendationData( { tech_stack: techStack, expected_users: expectedUsers, use_case: session.collectedInfo.useCase || '웹 서비스', region_preference: session.collectedInfo.regionPreference, budget_limit: session.collectedInfo.budgetLimit, lang: 'ko', }, env ); // 추천 결과를 세션에 저장 if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { session.lastRecommendation = { recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ plan_name: rec.server.instance_name, provider: rec.server.provider_name, specs: { vcpu: rec.server.vcpu, ram_gb: rec.server.memory_gb, storage_gb: rec.server.storage_gb }, region: { code: rec.server.region_code, name: rec.server.region_name }, price: { monthly_krw: Math.round(rec.server.monthly_price), bandwidth_tb: rec.server.transfer_tb }, score: rec.score, max_users: rec.estimated_capacity?.max_concurrent_users || 0 })), createdAt: Date.now() }; // 2. AI에게 추천 결과 전달하여 검토 요청 logger.info('AI 검토 요청', { recommendationCount: recommendationData.recommendations.length }); const reviewResult = await callServerExpertAI(env, session, userMessage, recommendationData); // 3. 포맷팅된 추천 결과 생성 const formattedRecommendation = await executeServerAction( 'recommend', { tech_stack: techStack, expected_users: expectedUsers, use_case: session.collectedInfo.useCase || '웹 서비스', region_preference: session.collectedInfo.regionPreference, budget_limit: session.collectedInfo.budgetLimit, lang: 'ko', }, env, session.telegramUserId ); // Mark session as selecting (사용자 선택 대기) session.status = 'selecting'; await saveServerSession(env.SESSION_KV, session.telegramUserId, session); // 4. AI 검토 코멘트 + 추천 결과 함께 반환 return `${reviewResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; } else { // 추천 결과 없음 - 세션 삭제 session.status = 'completed'; await deleteServerSession(env.SESSION_KV, session.telegramUserId); return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; } } else { // Continue gathering information session.status = 'gathering'; await saveServerSession(env.SESSION_KV, session.telegramUserId, session); return aiResult.message; } } catch (error) { logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId }); // Clean up session on error await deleteServerSession(env.SESSION_KV, session.telegramUserId); return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.'; } }