diff --git a/src/agents/server-agent.ts b/src/agents/server-agent.ts index a1cbaa8..8174453 100644 --- a/src/agents/server-agent.ts +++ b/src/agents/server-agent.ts @@ -3,13 +3,19 @@ * * 기능: * - 대화형 서버 추천 상담 - * - 세션 기반 정보 수집 + * - 세션 기반 정보 수집 (D1) * - 충분한 정보 수집 시 자동 추천 * - 추천 후 사용자 선택 및 주문 흐름 * - Brave Search / Context7 도구로 최신 트렌드 반영 + * + * Manual Test: + * 1. User: "서버 추천" + * 2. Expected: Category detection → 1-2 questions → Recommendation + * 3. User: "1번" + * 4. Expected: Order confirmation */ -import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from '../types'; +import type { Env, ServerSession, ServerSessionStatus, BandwidthInfo, RecommendResponse } from '../types'; import { createLogger } from '../utils/logger'; import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool'; import { formatTrafficInfo } from '../utils/formatters'; @@ -18,8 +24,85 @@ import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from '../constants'; const logger = createLogger('server-agent'); // D1 Session Management -const SESSION_TTL_MS = 3600 * 1000; // 1 hour in milliseconds +const SERVER_SESSION_TTL_MS = 60 * 60 * 1000; // 1시간 +/** + * 새 서버 세션 생성 + * + * @param userId - Telegram User ID + * @param status - 세션 상태 + * @returns 새로운 ServerSession 객체 + */ +export function createServerSession( + userId: string, + status: ServerSessionStatus = 'gathering' +): ServerSession { + const now = Date.now(); + return { + user_id: userId, + status, + collected_info: {}, + messages: [], + created_at: now, + updated_at: now, + expires_at: now + SERVER_SESSION_TTL_MS, + }; +} + +/** + * 세션 만료 여부 확인 + * + * @param session - ServerSession + * @returns true if expired, false otherwise + */ +export function isSessionExpired(session: ServerSession): boolean { + return session.expires_at < Date.now(); +} + +/** + * 세션에 메시지 추가 + * + * @param session - ServerSession + * @param role - 메시지 역할 ('user' | 'assistant') + * @param content - 메시지 내용 + */ +export function addMessageToSession( + session: ServerSession, + role: 'user' | 'assistant', + content: string +): void { + session.messages.push({ role, content }); + + // 최대 메시지 수 제한 (20개) + const MAX_MESSAGES = 20; + if (session.messages.length > MAX_MESSAGES) { + session.messages = session.messages.slice(-MAX_MESSAGES); + logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', { + userId: session.user_id, + maxMessages: MAX_MESSAGES, + }); + } +} + +/** + * 서버 세션 존재 여부 확인 (라우팅용) + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @returns true if active session exists, false otherwise + */ +export async function hasServerSession(db: D1Database, userId: string): Promise { + const session = await getServerSession(db, userId); + return session !== null && !isSessionExpired(session); +} + +/** + * D1에서 서버 세션 조회 + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @returns ServerSession 또는 null (세션 없거나 만료) + */ export async function getServerSession( db: D1Database, userId: string @@ -36,6 +119,7 @@ export async function getServerSession( messages: string | null; created_at: number; updated_at: number; + expires_at: number; }>(); if (!result) { @@ -44,16 +128,17 @@ export async function getServerSession( } const session: ServerSession = { - telegramUserId: result.user_id, - status: result.status as ServerSession['status'], - collectedInfo: result.collected_info ? JSON.parse(result.collected_info) : {}, - lastRecommendation: result.last_recommendation ? JSON.parse(result.last_recommendation) : undefined, + user_id: result.user_id, + status: result.status as ServerSessionStatus, + collected_info: result.collected_info ? JSON.parse(result.collected_info) : {}, + last_recommendation: result.last_recommendation ? JSON.parse(result.last_recommendation) : undefined, messages: result.messages ? JSON.parse(result.messages) : [], - createdAt: result.created_at, - updatedAt: result.updated_at, + created_at: result.created_at, + updated_at: result.updated_at, + expires_at: result.expires_at, }; - logger.info('세션 조회 성공', { userId, status: session.status, hasLastRecommendation: !!session.lastRecommendation }); + logger.info('세션 조회 성공', { userId, status: session.status, hasLastRecommendation: !!session.last_recommendation }); return session; } catch (error) { logger.error('세션 조회 실패', error as Error, { userId }); @@ -61,14 +146,19 @@ export async function getServerSession( } } +/** + * 서버 세션 저장 (생성 또는 업데이트) + * + * @param db - D1 Database + * @param session - ServerSession + */ export async function saveServerSession( db: D1Database, - userId: string, session: ServerSession ): Promise { try { const now = Date.now(); - const expiresAt = now + SESSION_TTL_MS; + const expiresAt = now + SERVER_SESSION_TTL_MS; await db.prepare(` INSERT INTO server_sessions @@ -82,23 +172,29 @@ export async function saveServerSession( updated_at = excluded.updated_at, expires_at = excluded.expires_at `).bind( - userId, + session.user_id, session.status, - JSON.stringify(session.collectedInfo || {}), - session.lastRecommendation ? JSON.stringify(session.lastRecommendation) : null, + JSON.stringify(session.collected_info || {}), + session.last_recommendation ? JSON.stringify(session.last_recommendation) : null, JSON.stringify(session.messages || []), - session.createdAt || now, + session.created_at || now, now, expiresAt ).run(); - logger.info('세션 저장 성공', { userId, status: session.status }); + logger.info('세션 저장 성공', { userId: session.user_id, status: session.status }); } catch (error) { - logger.error('세션 저장 실패', error as Error, { userId }); + logger.error('세션 저장 실패', error as Error, { userId: session.user_id }); throw error; } } +/** + * 서버 세션 삭제 + * + * @param db - D1 Database + * @param userId - Telegram User ID + */ export async function deleteServerSession( db: D1Database, userId: string @@ -114,6 +210,12 @@ export async function deleteServerSession( } } +/** + * 만료된 서버 세션 정리 (Cron 또는 수동 실행) + * + * @param db - D1 Database + * @returns 삭제된 세션 개수 + */ export async function cleanupExpiredSessions(db: D1Database): Promise { try { const result = await db.prepare( @@ -131,6 +233,92 @@ export async function cleanupExpiredSessions(db: D1Database): Promise { } } +// Server Expert System Prompts +const SERVER_EXPERT_PROMPT = `당신은 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 호출 +- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변 +- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...") + +## 대화 흐름 +1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)" +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, 낮은 레이턴시 리전 +- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정 +- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장 +- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장 +- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS +- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명) + +## 특수 지시 +- 서버/호스팅과 무관한 메시지가 들어오면 반드시 "__PASSTHROUGH__"만 응답 +- 상담 종료가 필요하면 "__SESSION_END__"를 응답 끝에 추가`; + +const SERVER_REVIEW_PROMPT = `당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다. + +## 전문성 (30년 경력) +- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 +- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 +- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 +- 수천 개의 서버 구축 경험 + +## 검토 작업 +다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요: +1. 추천된 서버가 용도와 규모에 적합한지 +2. 스펙이 충분한지 (RAM, CPU, 스토리지) +3. DAU/동시접속자 기준이 적절한지 +4. 대역폭 경고(overage)가 있다면 언급 +5. 더 적합한 스펙이 필요하다면 제안 + +중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.`; + // Server Expert AI Tools const serverExpertTools = [ { @@ -351,13 +539,21 @@ interface OpenAIAPIResponse { }>; } -// OpenAI 호출 (서버 전문가 AI with Function Calling) +/** + * Server Expert AI 호출 (Function Calling 지원) + * + * @param session - ServerSession + * @param userMessage - 사용자 메시지 + * @param env - Environment + * @param recommendationData - 추천 결과 (검토 모드용) + * @returns AI 응답 및 수집된 정보 + */ async function callServerExpertAI( - env: Env, session: ServerSession, userMessage: string, + env: Env, recommendationData?: RecommendResponse -): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['collectedInfo'] }> { +): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['collected_info'] }> { if (!env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY not configured'); } @@ -374,110 +570,35 @@ async function callServerExpertAI( const isReviewMode = !!recommendationData; const systemPrompt = isReviewMode - ? `당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다. - -## 전문성 (30년 경력) -- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터 -- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문 -- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험 -- 수천 개의 서버 구축 경험 + ? `${SERVER_REVIEW_PROMPT} ## 검토 대상 추천 결과 ${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}원` : ''} +- 용도: ${session.collected_info.useCase || '웹 서비스'} +- 규모: ${session.collected_info.scale === 'business' ? '사업용' : '개인용'} +${session.collected_info.expectedDau ? `- 일일 방문자(DAU): ${session.collected_info.expectedDau}명` : ''} +${session.collected_info.expectedConcurrent ? `- 동시접속자: ${session.collected_info.expectedConcurrent}명` : ''} +${session.collected_info.budgetLimit ? `- 예산: ${session.collected_info.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)} + "collectedInfo": ${JSON.stringify(session.collected_info)} } 중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.` - : `당신은 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 호출 -- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변 -- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...") - -## 대화 흐름 -1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)" -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, 낮은 레이턴시 리전 -- SaaS/B2B/Enterprise → 최소 4GB+ RAM, PostgreSQL+Redis 권장, 500명+ 동시접속 가정 -- API 서버 → 트래픽에 따라 2~8GB, Redis 캐시 권장 -- 실시간 서비스 (WebSocket) → 최소 4GB RAM, Redis 권장 -- 고성능 DB (PostgreSQL, MongoDB) → 최소 4GB+ RAM, 높은 IOPS -- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명), SaaS→DAU 2000명 (동접 200명) + : `${SERVER_EXPERT_PROMPT} ## 현재 수집된 정보 -${JSON.stringify(session.collectedInfo, null, 2)} +${JSON.stringify(session.collected_info, null, 2)} ## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지) { @@ -597,7 +718,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)} } // AI 응답에서 리전 정보가 없으면 사용자 메시지에서 추출 시도 - const finalCollectedInfo = parsed.collectedInfo || session.collectedInfo; + const finalCollectedInfo = parsed.collectedInfo || session.collected_info; if (!finalCollectedInfo.regionPreference) { // 전체 대화 히스토리에서 리전 감지 @@ -611,7 +732,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)} finalCollectedInfo.regionPreference = detectedRegions; logger.info('사용자 메시지에서 리전 자동 감지', { regions: detectedRegions, - userId: session.telegramUserId + userId: session.user_id }); } } @@ -628,7 +749,7 @@ ${JSON.stringify(session.collectedInfo, null, 2)} return { action: 'recommend', message: '분석이 완료되었습니다. 최적의 서버를 추천해 드리겠습니다.', - collectedInfo: session.collectedInfo, + collectedInfo: session.collected_info, }; } catch (error) { logger.error('Server Expert AI 호출 실패', error as Error); @@ -636,28 +757,42 @@ ${JSON.stringify(session.collectedInfo, null, 2)} } } -// Main consultation processing +/** + * 서버 상담 처리 (메인 함수) + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @param userMessage - 사용자 메시지 + * @param env - Environment + * @param options - Optional settings + * @returns AI 응답 메시지 + */ export async function processServerConsultation( + db: D1Database, + userId: string, userMessage: string, - session: ServerSession, env: Env, - sendIntermediateMessage?: (message: string) => Promise + options?: { sendIntermediateMessage?: (msg: string) => Promise } ): Promise { + logger.info('서버 상담 시작', { userId, message: userMessage.substring(0, 100) }); + try { - logger.info('상담 처리 시작', { - userId: session.telegramUserId, - message: userMessage.slice(0, 50), - status: session.status - }); + // 1. Check for existing session + let session = await getServerSession(db, userId); + + // 2. Create new session if none exists + if (!session) { + session = createServerSession(userId, 'gathering'); + } // ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리 if (session.status === 'ordering') { // "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임 - const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`; + const orderConfirmKey = `server_order_confirm:${session.user_id}`; await env.SESSION_KV?.delete(orderConfirmKey); - await deleteServerSession(env.DB, session.telegramUserId); + await deleteServerSession(db, session.user_id); - logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.telegramUserId }); + logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.user_id }); return '__PASSTHROUGH__'; // 일반 대화로 전환 } @@ -665,9 +800,9 @@ export async function processServerConsultation( // "취소", "다시", "처음", "리셋", "초기화" 등 if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) || /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { - await deleteServerSession(env.DB, session.telegramUserId); + await deleteServerSession(db, session.user_id); logger.info('사용자 요청으로 상담 취소', { - userId: session.telegramUserId, + userId: session.user_id, previousStatus: session.status, trigger: userMessage.slice(0, 20) }); @@ -676,41 +811,34 @@ export async function processServerConsultation( // "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋) if (/서버\s*추천/.test(userMessage)) { - await deleteServerSession(env.DB, session.telegramUserId); + await deleteServerSession(db, session.user_id); logger.info('서버 추천 키워드로 세션 리셋', { - userId: session.telegramUserId, + userId: session.user_id, previousStatus: session.status }); // 새 세션 생성하고 시작 메시지 반환 - const newSession: ServerSession = { - telegramUserId: session.telegramUserId, - status: SERVER_CONSULTATION_STATUS.GATHERING, - collectedInfo: {}, - messages: [], - createdAt: Date.now(), - updatedAt: Date.now() - }; - await saveServerSession(env.DB, session.telegramUserId, newSession); + const newSession = createServerSession(session.user_id, 'gathering'); + await saveServerSession(db, newSession); return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!'; } // 선택 단계 처리 logger.info('[SESSION DEBUG] 선택 단계 체크', { - userId: session.telegramUserId, + userId: session.user_id, status: session.status, - hasLastRecommendation: !!session.lastRecommendation, - recommendationCount: session.lastRecommendation?.recommendations?.length || 0, - willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.lastRecommendation + hasLastRecommendation: !!session.last_recommendation, + recommendationCount: session.last_recommendation?.recommendations?.length || 0, + willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.last_recommendation }); - if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.lastRecommendation) { + if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.last_recommendation) { // 상담과 무관한 키워드 감지 (selecting 상태에서만) // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; if (unrelatedPatterns.test(userMessage)) { - await deleteServerSession(env.DB, session.telegramUserId); + await deleteServerSession(db, session.user_id); logger.info('무관한 요청으로 세션 자동 종료', { - userId: session.telegramUserId, + userId: session.user_id, message: userMessage.slice(0, 30) }); // 'PASSTHROUGH' 반환하여 상위에서 일반 처리로 전환 @@ -734,24 +862,24 @@ export async function processServerConsultation( } // 유효성 검증 - if (selectedIndex >= 0 && selectedIndex < session.lastRecommendation.recommendations.length) { - const selected = session.lastRecommendation.recommendations[selectedIndex]; + if (selectedIndex >= 0 && selectedIndex < session.last_recommendation.recommendations.length) { + const selected = session.last_recommendation.recommendations[selectedIndex]; // Mark session as ordering session.status = 'ordering'; - await saveServerSession(env.DB, session.telegramUserId, session); + await saveServerSession(db, session); // 주문 확인 세션 저장 (텍스트 기반 확인) - const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`; + const orderConfirmKey = `server_order_confirm:${session.user_id}`; const orderConfirmData = JSON.stringify({ - userId: session.telegramUserId, + userId: session.user_id, index: selectedIndex, plan: selected.plan_name, pricingId: selected.pricing_id, region: selected.region.code, label: `${selected.plan_name.toLowerCase().replace(/\s+/g, '-')}-server`, }); - logger.info('주문 확인 세션 저장', { orderConfirmKey, userId: session.telegramUserId }); + logger.info('주문 확인 세션 저장', { orderConfirmKey, userId: session.user_id }); await env.SESSION_KV.put(orderConfirmKey, orderConfirmData, { expirationTtl: 300 }); logger.info('주문 확인 세션 저장 완료', { orderConfirmKey }); @@ -786,7 +914,7 @@ export async function processServerConsultation( `\n⚠️ 정말 신청하시려면 '신청'이라고 입력하세요.\n` + `(5분 내 응답 없으면 자동 취소됩니다)`; } else { - return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`; + return `번호를 다시 확인해주세요. 1번부터 ${session.last_recommendation.recommendations.length}번 중에서 선택해주세요.`; } } @@ -798,26 +926,26 @@ export async function processServerConsultation( session.messages.push({ role: 'user', content: userMessage }); // Call Server Expert AI - const aiResult = await callServerExpertAI(env, session, userMessage); + const aiResult = await callServerExpertAI(session, userMessage, env); // Update collected info - session.collectedInfo = { ...session.collectedInfo, ...aiResult.collectedInfo }; + session.collected_info = { ...session.collected_info, ...aiResult.collectedInfo }; // Add AI response to history session.messages.push({ role: 'assistant', content: aiResult.message }); if (aiResult.action === 'recommend') { // Send intermediate message to user - if (sendIntermediateMessage) { - await sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.'); + if (options?.sendIntermediateMessage) { + await options?.sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.'); } // Mark session as recommending session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING; - await saveServerSession(env.DB, session.telegramUserId, session); + await saveServerSession(db, session); // 1. Call recommendation API (추천 먼저 받기) - logger.info('추천 API 호출', { collectedInfo: session.collectedInfo }); + logger.info('추천 API 호출', { collectedInfo: session.collected_info }); const { executeServerAction, getRecommendationData } = await import('../tools/server-tool'); @@ -825,8 +953,8 @@ export async function processServerConsultation( const allMessages = session.messages.map(m => m.content).join(' '); // Tech Stack: useCase에서 추론 + 전체 메시지에서 추출한 것 병합 - let techStack = session.collectedInfo.useCase - ? inferTechStack(session.collectedInfo.useCase) + let techStack = session.collected_info.useCase + ? inferTechStack(session.collected_info.useCase) : ['web']; // 전체 메시지에서 추가 tech stack 추출 @@ -841,32 +969,32 @@ export async function processServerConsultation( logger.info('메시지에서 tech stack 추출', { extracted: extractedTech, merged: techStack, - userId: session.telegramUserId + userId: session.user_id }); } // 동시접속자 우선 사용, 없으면 scale 기반 추론 let expectedUsers = 10; // Default - const concurrent = Number(session.collectedInfo.expectedConcurrent) || 0; - const dau = Number(session.collectedInfo.expectedDau) || 0; + const concurrent = Number(session.collected_info.expectedConcurrent) || 0; + const dau = Number(session.collected_info.expectedDau) || 0; if (concurrent > 0) { expectedUsers = concurrent; } else if (dau > 0) { // DAU가 있으면 10% 비율로 동시접속자 계산 expectedUsers = Math.ceil(dau * 0.1); - } else if (session.collectedInfo.scale) { - expectedUsers = inferExpectedUsers(session.collectedInfo.scale, techStack); + } else if (session.collected_info.scale) { + expectedUsers = inferExpectedUsers(session.collected_info.scale, techStack); } // 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출) - let finalRegionPreference = session.collectedInfo.regionPreference; + let finalRegionPreference = session.collected_info.regionPreference; if (!finalRegionPreference) { finalRegionPreference = extractRegionPreference(allMessages); if (finalRegionPreference) { logger.info('추천 직전 리전 재감지', { regions: finalRegionPreference, - userId: session.telegramUserId + userId: session.user_id }); } } @@ -875,9 +1003,9 @@ export async function processServerConsultation( { tech_stack: techStack, expected_users: expectedUsers, - use_case: session.collectedInfo.useCase || '웹 서비스', + use_case: session.collected_info.useCase || '웹 서비스', region_preference: finalRegionPreference, - budget_limit: session.collectedInfo.budgetLimit, + budget_limit: session.collected_info.budgetLimit, lang: LANGUAGE_CODE.KOREAN, }, env @@ -885,7 +1013,7 @@ export async function processServerConsultation( // 추천 결과를 세션에 저장 if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) { - session.lastRecommendation = { + session.last_recommendation = { recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ pricing_id: rec.server.id, // cloud-instances-db.anvil_pricing.id plan_name: rec.server.instance_name, @@ -912,12 +1040,12 @@ export async function processServerConsultation( score: rec.score, max_users: rec.estimated_capacity?.max_concurrent_users || 0 })), - createdAt: Date.now() + created_at: Date.now() }; // 2. AI에게 추천 결과 전달하여 검토 요청 logger.info('AI 검토 요청', { recommendationCount: recommendationData.recommendations.length }); - const reviewResult = await callServerExpertAI(env, session, userMessage, recommendationData); + const reviewResult = await callServerExpertAI(session, userMessage, env, recommendationData); // 3. 포맷팅된 추천 결과 생성 const formattedRecommendation = await executeServerAction( @@ -925,18 +1053,18 @@ export async function processServerConsultation( { tech_stack: techStack, expected_users: expectedUsers, - use_case: session.collectedInfo.useCase || '웹 서비스', - region_preference: session.collectedInfo.regionPreference, - budget_limit: session.collectedInfo.budgetLimit, + use_case: session.collected_info.useCase || '웹 서비스', + region_preference: session.collected_info.regionPreference, + budget_limit: session.collected_info.budgetLimit, lang: LANGUAGE_CODE.KOREAN, }, env, - session.telegramUserId + session.user_id ); // Mark session as selecting (사용자 선택 대기) session.status = SERVER_CONSULTATION_STATUS.SELECTING; - await saveServerSession(env.DB, session.telegramUserId, session); + await saveServerSession(db, session); // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) // __DIRECT__ 마커가 앞에 와야 제대로 처리됨 @@ -944,22 +1072,26 @@ export async function processServerConsultation( } else { // 추천 결과 없음 - 세션 삭제 session.status = SERVER_CONSULTATION_STATUS.COMPLETED; - await deleteServerSession(env.DB, session.telegramUserId); + await deleteServerSession(db, session.user_id); return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; } } else { // Continue gathering information session.status = SERVER_CONSULTATION_STATUS.GATHERING; - await saveServerSession(env.DB, session.telegramUserId, session); + await saveServerSession(db, session); return aiResult.message; } } catch (error) { - logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId }); + logger.error('상담 처리 실패', error as Error, { userId }); - // Clean up session on error - await deleteServerSession(env.DB, session.telegramUserId); + // Clean up session on error (if exists) + try { + await deleteServerSession(db, userId); + } catch (deleteError) { + logger.error('세션 삭제 실패 (무시)', deleteError as Error, { userId }); + } return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.'; } diff --git a/src/openai-service.ts b/src/openai-service.ts index 49dae1a..4d2f109 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -6,7 +6,7 @@ import { createLogger } from './utils/logger'; import { metrics } from './utils/metrics'; import { getOpenAIUrl } from './utils/api-urls'; import { ERROR_MESSAGES } from './constants/messages'; -import { getServerSession, processServerConsultation } from './agents/server-agent'; +import { hasServerSession, processServerConsultation } from './agents/server-agent'; import { processTroubleshootConsultation, hasTroubleshootSession } from './agents/troubleshoot-agent'; import { processDomainConsultation, hasDomainSession } from './agents/domain-agent'; import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent'; @@ -210,13 +210,11 @@ export async function generateOpenAIResponse( // Check if server consultation session is active if (telegramUserId && env.DB) { try { - const session = await getServerSession(env.DB, telegramUserId); + const hasSession = await hasServerSession(env.DB, telegramUserId); - if (session && session.status !== 'completed') { + if (hasSession) { logger.info('Active server session detected, routing to consultation', { - userId: telegramUserId, - status: session.status, - hasLastRecommendation: !!session.lastRecommendation + userId: telegramUserId }); // Create callback for intermediate messages @@ -229,7 +227,13 @@ export async function generateOpenAIResponse( }; } - const result = await processServerConsultation(userMessage, session, env, sendIntermediateMessage); + const result = await processServerConsultation( + env.DB, + telegramUserId, + userMessage, + env, + { sendIntermediateMessage } + ); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (result !== '__PASSTHROUGH__') { diff --git a/src/routes/api/chat.ts b/src/routes/api/chat.ts index 63bf240..2f2a2a7 100644 --- a/src/routes/api/chat.ts +++ b/src/routes/api/chat.ts @@ -334,7 +334,7 @@ async function handleChatApi(request: Request, env: Env): Promise { const { getServerSession, deleteServerSession } = await import('../../agents/server-agent'); const session = await getServerSession(env.DB, telegramUserId); - if (!session || !session.lastRecommendation) { + if (!session || !session.last_recommendation) { await env.SESSION_KV.delete(orderSessionKey); const processingTimeMs = Date.now() - startTime; @@ -345,7 +345,7 @@ async function handleChatApi(request: Request, env: Env): Promise { }); } - const selected = session.lastRecommendation.recommendations[orderData.index]; + const selected = session.last_recommendation.recommendations[orderData.index]; if (!selected) { await env.SESSION_KV.delete(orderSessionKey); await deleteServerSession(env.DB, telegramUserId); @@ -402,7 +402,7 @@ async function handleChatApi(request: Request, env: Env): Promise { selected.region.code, 'anvil', price, - `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` + `${selected.plan_name} - ${orderData.label || session.collected_info?.useCase || 'server'}` ); await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts index 5da1d74..a275403 100644 --- a/src/routes/handlers/message-handler.ts +++ b/src/routes/handlers/message-handler.ts @@ -124,7 +124,7 @@ export async function handleMessage( const { getServerSession, deleteServerSession } = await import('../../agents/server-agent'); const session = await getServerSession(env.DB, telegramUserId); - if (!session || !session.lastRecommendation) { + if (!session || !session.last_recommendation) { await env.SESSION_KV.delete(orderSessionKey); await sendMessage( env.BOT_TOKEN, @@ -134,7 +134,7 @@ export async function handleMessage( return; } - const selected = session.lastRecommendation.recommendations[orderData.index]; + const selected = session.last_recommendation.recommendations[orderData.index]; if (!selected) { await env.SESSION_KV.delete(orderSessionKey); await deleteServerSession(env.DB, telegramUserId); @@ -183,7 +183,7 @@ export async function handleMessage( selected.region.code, 'anvil', price, - `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` + `${selected.plan_name} - ${orderData.label || session.collected_info?.useCase || 'server'}` ); await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); diff --git a/src/tools/server-tool.ts b/src/tools/server-tool.ts index e870d88..10962e9 100644 --- a/src/tools/server-tool.ts +++ b/src/tools/server-tool.ts @@ -664,7 +664,7 @@ export async function executeServerAction( switch (action) { case 'start_consultation': { // Import session functions - const { saveServerSession } = await import('../agents/server-agent'); + const { createServerSession, saveServerSession } = await import('../agents/server-agent'); if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; @@ -674,16 +674,8 @@ export async function executeServerAction( return '🚫 세션 저장소가 설정되지 않았습니다.'; } - const session: import('../types').ServerSession = { - telegramUserId, - status: 'gathering', - collectedInfo: {}, - messages: [], - createdAt: Date.now(), - updatedAt: Date.now(), - }; - - await saveServerSession(env.DB, telegramUserId, session); + const session = createServerSession(telegramUserId, 'gathering'); + await saveServerSession(env.DB, session); logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) }); @@ -691,7 +683,7 @@ export async function executeServerAction( } case 'continue_consultation': { - const { getServerSession, processServerConsultation } = await import('../agents/server-agent'); + const { processServerConsultation } = await import('../agents/server-agent'); if (!telegramUserId) { return '🚫 사용자 인증이 필요합니다.'; @@ -705,12 +697,7 @@ export async function executeServerAction( return '🚫 메시지가 필요합니다.'; } - const session = await getServerSession(env.DB, telegramUserId); - if (!session) { - return '세션이 만료되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.'; - } - - const result = await processServerConsultation(args.message, session, env); + const result = await processServerConsultation(env.DB, telegramUserId, args.message, env); return result; } @@ -771,30 +758,24 @@ export async function executeServerAction( // 세션에 추천 결과 저장 (선택 기능 활성화) if (telegramUserId && env?.DB && recommendationData.recommendations && recommendationData.recommendations.length > 0) { try { - const { getServerSession, saveServerSession } = await import('../agents/server-agent'); + const { getServerSession, saveServerSession, createServerSession } = await import('../agents/server-agent'); // 기존 세션 조회 또는 새로 생성 let session = await getServerSession(env.DB, telegramUserId); if (!session) { // 세션이 없으면 새로 생성 - session = { - telegramUserId, - status: 'selecting', - collectedInfo: { - useCase: use_case, - scale: expected_users <= 50 ? 'personal' : 'business', - expectedConcurrent: expected_users, - }, - messages: [], - createdAt: Date.now(), - updatedAt: Date.now(), + session = createServerSession(telegramUserId, 'selecting'); + session.collected_info = { + useCase: use_case, + scale: expected_users <= 50 ? 'personal' : 'business', + expectedConcurrent: expected_users, }; logger.info('새 세션 생성 (추천 결과 저장용)', { userId: telegramUserId }); } - // lastRecommendation 저장 - session.lastRecommendation = { + // last_recommendation 저장 + session.last_recommendation = { recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({ pricing_id: rec.server.id, plan_name: rec.server.instance_name, @@ -821,17 +802,17 @@ export async function executeServerAction( score: rec.score, max_users: rec.estimated_capacity?.max_concurrent_users || 0 })), - createdAt: Date.now() + created_at: Date.now() }; // status를 'selecting'으로 변경 session.status = 'selecting'; - session.updatedAt = Date.now(); + session.updated_at = Date.now(); - await saveServerSession(env.DB, telegramUserId, session); + await saveServerSession(env.DB, session); logger.info('추천 결과 세션 저장 완료', { userId: telegramUserId, - recommendationCount: session.lastRecommendation.recommendations.length, + recommendationCount: session.last_recommendation.recommendations.length, status: session.status }); } catch (sessionError) { diff --git a/src/types.ts b/src/types.ts index 3e74c15..5f0a538 100644 --- a/src/types.ts +++ b/src/types.ts @@ -244,11 +244,19 @@ export interface ManageMemoryArgs { memory_id?: number; } -// Server Consultation Session +// Server Consultation Session Status +export type ServerSessionStatus = + | 'gathering' // 정보 수집 중 + | 'recommending' // 추천 중 + | 'selecting' // 선택 대기 + | 'ordering' // 주문 대기 + | 'completed'; // 완료 + +// Server Consultation Session (D1) export interface ServerSession { - telegramUserId: string; - status: 'gathering' | 'recommending' | 'selecting' | 'ordering' | 'completed'; - collectedInfo: { + user_id: string; + status: ServerSessionStatus; + collected_info: { useCase?: string; scale?: 'personal' | 'business'; budgetLimit?: number; @@ -257,9 +265,10 @@ export interface ServerSession { expectedConcurrent?: number; // 동시접속자 (Concurrent Users) }; messages: Array<{ role: 'user' | 'assistant'; content: string }>; - createdAt: number; - updatedAt: number; - lastRecommendation?: { + created_at: number; + updated_at: number; + expires_at: number; + last_recommendation?: { recommendations: Array<{ pricing_id: number; // cloud-instances-db.anvil_pricing.id plan_name: string; @@ -279,7 +288,7 @@ export interface ServerSession { score: number; max_users: number; }>; - createdAt: number; + created_at: number; }; }