Files
telegram-bot-workers/src/server-agent.ts
kappa fbe696b88c refactor: complete P0-P1 improvements
Constants migration:
- server-agent.ts: SERVER_CONSULTATION_STATUS, LANGUAGE_CODE
- troubleshoot-agent.ts: TROUBLESHOOT_STATUS
- notification.ts: NOTIFICATION_TYPE

API improvements:
- search-tool.ts: Zod schema validation for Brave/Context7 APIs
- api-helper.ts: Centralized API call utility with retry/timeout

Testing:
- kv-cache.test.ts: 38 test cases for cache abstraction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:02:27 +09:00

967 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Server Expert Agent - 서버 전문가 AI 상담 시스템
*
* 기능:
* - 대화형 서버 추천 상담
* - 세션 기반 정보 수집
* - 충분한 정보 수집 시 자동 추천
* - 추천 후 사용자 선택 및 주문 흐름
* - Brave Search / Context7 도구로 최신 트렌드 반영
*/
import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './types';
import { createLogger } from './utils/logger';
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
import { formatTrafficInfo } from './utils/formatters';
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
export async function getServerSession(
db: D1Database,
userId: string
): Promise<ServerSession | null> {
try {
const now = Date.now();
const result = await db.prepare(
'SELECT * FROM server_sessions WHERE user_id = ? AND expires_at > ?'
).bind(userId, now).first<{
user_id: string;
status: string;
collected_info: string | null;
last_recommendation: string | null;
messages: string | null;
created_at: number;
updated_at: number;
}>();
if (!result) {
logger.info('세션 없음', { userId });
return null;
}
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,
messages: result.messages ? JSON.parse(result.messages) : [],
createdAt: result.created_at,
updatedAt: result.updated_at,
};
logger.info('세션 조회 성공', { userId, status: session.status, hasLastRecommendation: !!session.lastRecommendation });
return session;
} catch (error) {
logger.error('세션 조회 실패', error as Error, { userId });
return null;
}
}
export async function saveServerSession(
db: D1Database,
userId: string,
session: ServerSession
): Promise<void> {
try {
const now = Date.now();
const expiresAt = now + SESSION_TTL_MS;
await db.prepare(`
INSERT INTO server_sessions
(user_id, status, collected_info, last_recommendation, messages, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
status = excluded.status,
collected_info = excluded.collected_info,
last_recommendation = excluded.last_recommendation,
messages = excluded.messages,
updated_at = excluded.updated_at,
expires_at = excluded.expires_at
`).bind(
userId,
session.status,
JSON.stringify(session.collectedInfo || {}),
session.lastRecommendation ? JSON.stringify(session.lastRecommendation) : null,
JSON.stringify(session.messages || []),
session.createdAt || now,
now,
expiresAt
).run();
logger.info('세션 저장 성공', { userId, status: session.status });
} catch (error) {
logger.error('세션 저장 실패', error as Error, { userId });
throw error;
}
}
export async function deleteServerSession(
db: D1Database,
userId: string
): Promise<void> {
try {
await db.prepare('DELETE FROM server_sessions WHERE user_id = ?')
.bind(userId)
.run();
logger.info('세션 삭제 성공', { userId });
} catch (error) {
logger.error('세션 삭제 실패', error as Error, { userId });
throw error;
}
}
export async function cleanupExpiredSessions(db: D1Database): Promise<number> {
try {
const result = await db.prepare(
'DELETE FROM server_sessions WHERE expires_at < ?'
).bind(Date.now()).run();
const deleted = result.meta.changes || 0;
if (deleted > 0) {
logger.info('만료 세션 정리', { deleted });
}
return deleted;
} catch (error) {
logger.error('만료 세션 정리 실패', error as Error);
return 0;
}
}
// 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<string, unknown>,
env: Env
): Promise<string> {
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}`;
}
}
/**
* 사용자 메시지에서 리전 선호도 추출
* @param message 사용자 메시지
* @returns 감지된 리전 코드 배열 (undefined if none)
*/
function extractRegionPreference(message: string): string[] | undefined {
const lower = message.toLowerCase();
const regions: string[] = [];
// 한국/서울
if (/한국|서울|seoul|korea|kr\b/.test(lower)) {
regions.push('seoul');
}
// 일본/도쿄
if (/일본|도쿄|tokyo|japan|jp\b/.test(lower)) {
regions.push('tokyo');
}
// 오사카
if (/오사카|osaka/.test(lower)) {
regions.push('osaka');
}
// 싱가포르
if (/싱가포르|singapore|sg\b/.test(lower)) {
regions.push('singapore');
}
return regions.length > 0 ? regions : undefined;
}
/**
* 사용자 메시지에서 기술 스택 추출
* @param messages 사용자 메시지 (전체 대화 내용)
* @returns 감지된 tech stack 배열
*/
function extractTechStack(messages: string): string[] {
const lower = messages.toLowerCase();
const stack: string[] = [];
// 데이터베이스
if (/postgresql|postgres|postgis/.test(lower)) stack.push('postgresql');
if (/mysql|mariadb/.test(lower)) stack.push('mysql');
if (/mongodb|mongo/.test(lower)) stack.push('mongodb');
// 캐시/메시징
if (/redis/.test(lower)) stack.push('redis');
if (/memcached/.test(lower)) stack.push('memcached');
if (/kafka|rabbitmq/.test(lower)) stack.push('messaging');
// 런타임
if (/node\.?js|nodejs|express/.test(lower)) stack.push('nodejs');
if (/python|django|flask|fastapi/.test(lower)) stack.push('python');
if (/java|spring/.test(lower)) stack.push('java');
if (/golang|go\s/.test(lower)) stack.push('go');
// 플랫폼
if (/wordpress/.test(lower)) stack.push('wordpress');
if (/laravel|php/.test(lower)) stack.push('php');
// 서비스 유형
if (/saas|b2b|enterprise/.test(lower)) stack.push('saas');
if (/ecommerce|쇼핑몰|이커머스/.test(lower)) stack.push('ecommerce');
if (/게임|game|minecraft|팰월드|palworld/.test(lower)) stack.push('game');
if (/streaming|스트리밍|video/.test(lower)) stack.push('streaming');
return stack;
}
// Tech stack inference from use case
function inferTechStack(useCase: string): string[] {
const lower = useCase.toLowerCase();
// 고성능 데이터베이스 감지
if (/postgresql|postgres|postgis/.test(lower)) {
return ['postgresql', 'nodejs'];
}
if (/redis|memcached|cache/.test(lower)) {
return ['redis', 'nodejs'];
}
if (/mongodb|mongo/.test(lower)) {
return ['mongodb', 'nodejs'];
}
// SaaS / B2B 감지 - 일반적으로 고성능 필요
if (/saas|b2b|enterprise|엔터프라이즈/.test(lower)) {
return ['nodejs', 'postgresql', 'redis'];
}
// 실시간 서비스
if (/realtime|real-time|실시간|websocket|socket\.io/.test(lower)) {
return ['nodejs', 'redis'];
}
// 기존 규칙들...
if (/블로그|blog|wordpress/.test(lower)) return ['wordpress'];
if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(lower)) return ['ecommerce'];
if (/커뮤니티|게시판|forum|community/.test(lower)) return ['php', 'mysql'];
if (/api|백엔드|backend/.test(lower)) return ['nodejs', 'express'];
if (/게임|game|minecraft|마인크래프트|팰월드|palworld/.test(lower)) return ['game'];
return ['web']; // Default
}
// Expected users inference from scale
// Returns concurrent users (not DAU)
function inferExpectedUsers(scale: string, techStack?: string[]): number {
// 고성능 기술 스택이면 기본 사용자 수 증가
const isHighPerf = techStack?.some(t =>
['postgresql', 'redis', 'mongodb', 'elasticsearch', 'kafka'].includes(t.toLowerCase())
);
// SaaS/Enterprise면 더 높은 기본값
const isSaaS = techStack?.some(t =>
['saas', 'enterprise', 'b2b'].includes(t.toLowerCase())
) || scale === 'saas' || scale === 'enterprise';
if (isSaaS) {
return scale === 'business' ? 500 : 200;
}
if (isHighPerf) {
return scale === 'business' ? 300 : 100;
}
// 기존 기본값
// 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;
}>;
}
// 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, 낮은 레이턴시 리전
- 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명)
## 현재 수집된 정보
${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) {
// 검토 모드에서는 도구 없이 JSON 응답만 요청
const requestBody = isReviewMode
? {
model: 'gpt-4o-mini',
messages,
response_format: { type: 'json_object' },
max_tokens: 500,
temperature: 0.5,
}
: {
model: 'gpt-4o-mini',
messages,
tools: serverExpertTools,
tool_choice: 'auto',
response_format: { type: 'json_object' },
max_tokens: 800,
temperature: 0.7,
};
const response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify(requestBody),
});
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 tools in parallel for better performance
const toolResults = await Promise.all(
assistantMessage.tool_calls.map(async (toolCall) => {
const args = JSON.parse(toolCall.function.arguments);
const result = await executeServerExpertTool(toolCall.function.name, args, env);
return {
role: 'tool' as const,
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
};
})
);
messages.push(...toolResults);
toolCallCount += toolResults.length;
// 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');
}
// AI 응답에서 리전 정보가 없으면 사용자 메시지에서 추출 시도
const finalCollectedInfo = parsed.collectedInfo || session.collectedInfo;
if (!finalCollectedInfo.regionPreference) {
// 전체 대화 히스토리에서 리전 감지
const allMessages = [
...session.messages.map(m => m.content),
userMessage,
].join(' ');
const detectedRegions = extractRegionPreference(allMessages);
if (detectedRegions) {
finalCollectedInfo.regionPreference = detectedRegions;
logger.info('사용자 메시지에서 리전 자동 감지', {
regions: detectedRegions,
userId: session.telegramUserId
});
}
}
return {
action: parsed.action,
message: parsed.message,
collectedInfo: finalCollectedInfo,
};
}
// 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,
sendIntermediateMessage?: (message: string) => Promise<void>
): Promise<string> {
try {
logger.info('상담 처리 시작', {
userId: session.telegramUserId,
message: userMessage.slice(0, 50),
status: session.status
});
// ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리
if (session.status === 'ordering') {
// "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임
const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`;
await env.SESSION_KV?.delete(orderConfirmKey);
await deleteServerSession(env.DB, session.telegramUserId);
logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.telegramUserId });
return '__PASSTHROUGH__'; // 일반 대화로 전환
}
// 취소 키워드 처리 (모든 상태에서 작동)
// "취소", "다시", "처음", "리셋", "초기화" 등
if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) ||
/취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) {
await deleteServerSession(env.DB, session.telegramUserId);
logger.info('사용자 요청으로 상담 취소', {
userId: session.telegramUserId,
previousStatus: session.status,
trigger: userMessage.slice(0, 20)
});
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
}
// "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋)
if (/서버\s*추천/.test(userMessage)) {
await deleteServerSession(env.DB, session.telegramUserId);
logger.info('서버 추천 키워드로 세션 리셋', {
userId: session.telegramUserId,
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);
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등';
}
// 선택 단계 처리
logger.info('[SESSION DEBUG] 선택 단계 체크', {
userId: session.telegramUserId,
status: session.status,
hasLastRecommendation: !!session.lastRecommendation,
recommendationCount: session.lastRecommendation?.recommendations?.length || 0,
willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.lastRecommendation
});
if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.lastRecommendation) {
// 상담과 무관한 키워드 감지 (selecting 상태에서만)
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
if (unrelatedPatterns.test(userMessage)) {
await deleteServerSession(env.DB, session.telegramUserId);
logger.info('무관한 요청으로 세션 자동 종료', {
userId: session.telegramUserId,
message: userMessage.slice(0, 30)
});
// 'PASSTHROUGH' 반환하여 상위에서 일반 처리로 전환
return '__PASSTHROUGH__';
}
const selectionMatch = userMessage.match(/^(\d+)\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.DB, session.telegramUserId, session);
// 주문 확인 세션 저장 (텍스트 기반 확인)
const orderConfirmKey = `server_order_confirm:${session.telegramUserId}`;
const orderConfirmData = JSON.stringify({
userId: session.telegramUserId,
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 });
await env.SESSION_KV.put(orderConfirmKey, orderConfirmData, { expirationTtl: 300 });
logger.info('주문 확인 세션 저장 완료', { orderConfirmKey });
// 트래픽 정보 포맷팅
let trafficInfo = '';
if (selected.price.estimated_monthly_tb !== undefined) {
const bandwidthInfo: BandwidthInfo = {
included_transfer_tb: selected.price.bandwidth_tb,
overage_cost_per_gb: 0,
overage_cost_per_tb: 0,
estimated_monthly_tb: selected.price.estimated_monthly_tb,
estimated_overage_tb: selected.price.overage_tb || 0,
estimated_overage_cost: selected.price.overage_cost_krw || 0,
total_estimated_cost: selected.price.monthly_krw + (selected.price.overage_cost_krw || 0),
currency: 'KRW',
gross_monthly_tb: selected.price.gross_monthly_tb,
cdn_cache_hit_rate: selected.price.cdn_cache_hit_rate,
};
trafficInfo = `${formatTrafficInfo(bandwidthInfo)}\n`;
}
// 가격 표시 (항상 KRW로 표시)
const priceDisplay = `${selected.price.monthly_krw.toLocaleString()}`;
return `🖥️ ${selected.plan_name} 신청 확인\n\n` +
`• 제공사: ${selected.provider}\n` +
`• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB RAM / ${selected.specs.storage_gb}GB SSD\n` +
`• 리전: ${selected.region.name} (${selected.region.code})\n` +
`• 가격: ${priceDisplay}/월\n` +
`• 대역폭: ${selected.price.bandwidth_tb}TB 포함\n` +
trafficInfo +
`\n⚠ 정말 신청하시려면 '신청'이라고 입력하세요.\n` +
`(5분 내 응답 없으면 자동 취소됩니다)`;
} 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') {
// Send intermediate message to user
if (sendIntermediateMessage) {
await sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.');
}
// Mark session as recommending
session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING;
await saveServerSession(env.DB, session.telegramUserId, session);
// 1. Call recommendation API (추천 먼저 받기)
logger.info('추천 API 호출', { collectedInfo: session.collectedInfo });
const { executeServerAction, getRecommendationData } = await import('./tools/server-tool');
// 전체 메시지 내용 (tech stack 추출 및 리전 추출에 재사용)
const allMessages = session.messages.map(m => m.content).join(' ');
// Tech Stack: useCase에서 추론 + 전체 메시지에서 추출한 것 병합
let techStack = session.collectedInfo.useCase
? inferTechStack(session.collectedInfo.useCase)
: ['web'];
// 전체 메시지에서 추가 tech stack 추출
const extractedTech = extractTechStack(allMessages);
if (extractedTech.length > 0) {
// 추출된 tech를 기존 stack에 병합 (중복 제거)
techStack = [...new Set([...techStack, ...extractedTech])];
// 'web' 제거 (더 구체적인 stack이 있으면)
if (techStack.length > 1 && techStack.includes('web')) {
techStack = techStack.filter(t => t !== 'web');
}
logger.info('메시지에서 tech stack 추출', {
extracted: extractedTech,
merged: techStack,
userId: session.telegramUserId
});
}
// 동시접속자 우선 사용, 없으면 scale 기반 추론
let expectedUsers = 10; // Default
const concurrent = Number(session.collectedInfo.expectedConcurrent) || 0;
const dau = Number(session.collectedInfo.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);
}
// 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출)
let finalRegionPreference = session.collectedInfo.regionPreference;
if (!finalRegionPreference) {
finalRegionPreference = extractRegionPreference(allMessages);
if (finalRegionPreference) {
logger.info('추천 직전 리전 재감지', {
regions: finalRegionPreference,
userId: session.telegramUserId
});
}
}
const recommendationData = await getRecommendationData(
{
tech_stack: techStack,
expected_users: expectedUsers,
use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: finalRegionPreference,
budget_limit: session.collectedInfo.budgetLimit,
lang: LANGUAGE_CODE.KOREAN,
},
env
);
// 추천 결과를 세션에 저장
if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
session.lastRecommendation = {
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,
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,
estimated_monthly_tb: rec.bandwidth_info?.estimated_monthly_tb,
gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb,
cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate,
overage_tb: rec.bandwidth_info?.estimated_overage_tb,
overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost,
currency: rec.server.currency,
},
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: LANGUAGE_CODE.KOREAN,
},
env,
session.telegramUserId
);
// Mark session as selecting (사용자 선택 대기)
session.status = SERVER_CONSULTATION_STATUS.SELECTING;
await saveServerSession(env.DB, session.telegramUserId, session);
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
// __DIRECT__ 마커가 앞에 와야 제대로 처리됨
return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
} else {
// 추천 결과 없음 - 세션 삭제
session.status = SERVER_CONSULTATION_STATUS.COMPLETED;
await deleteServerSession(env.DB, session.telegramUserId);
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
}
} else {
// Continue gathering information
session.status = SERVER_CONSULTATION_STATUS.GATHERING;
await saveServerSession(env.DB, session.telegramUserId, session);
return aiResult.message;
}
} catch (error) {
logger.error('상담 처리 실패', error as Error, { userId: session.telegramUserId });
// Clean up session on error
await deleteServerSession(env.DB, session.telegramUserId);
return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
}
}