refactor: delete server-agent.ts (905 lines)
Remove server recommendation consultation system: - 30-year expert AI persona - Session-based information gathering - Brave Search / Context7 tool integration - Automatic spec inference Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
536
src/agents/ddos-agent.ts
Normal file
536
src/agents/ddos-agent.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* DDoS Defense Agent - 보안 전문가 AI
|
||||
*
|
||||
* 기능:
|
||||
* - 대화형 DDoS 공격 분석 및 방어
|
||||
* - 세션 기반 정보 수집 (D1)
|
||||
* - Cloudflare/서버 방화벽 방어 조치 (STUB)
|
||||
* - 실시간 상태 모니터링 (STUB)
|
||||
*
|
||||
* Manual Test:
|
||||
* 1. User: "사이트가 DDoS 공격 받고 있어"
|
||||
* 2. Expected: 증상 파악 → 분석 → 방어 권장사항
|
||||
* 3. User: "방어 적용해줘"
|
||||
* 4. Expected: 방어 조치 적용 (STUB)
|
||||
*/
|
||||
|
||||
import type { Env, DdosSession, OpenAIToolCall, OpenAIAPIResponse } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool';
|
||||
import { SessionManager } from '../utils/session-manager';
|
||||
import { getSessionConfig, AI_CONFIG } from '../constants/agent-config';
|
||||
import {
|
||||
analyzeTraffic,
|
||||
detectAttackType,
|
||||
getProtectionStatus,
|
||||
applyRecommendedMitigation,
|
||||
activateEmergencyDefense,
|
||||
} from '../services/ddos-defense-service';
|
||||
|
||||
const logger = createLogger('ddos-agent');
|
||||
|
||||
// Session manager instance
|
||||
const sessionManager = new SessionManager<DdosSession>(getSessionConfig('ddos'));
|
||||
|
||||
/**
|
||||
* DDoS 세션 존재 여부 확인 (라우팅용)
|
||||
*/
|
||||
export async function hasDdosSession(db: D1Database, userId: string): Promise<boolean> {
|
||||
return await sessionManager.has(db, userId);
|
||||
}
|
||||
|
||||
// DDoS Defense Expert System Prompt
|
||||
const DDOS_EXPERT_PROMPT = `당신은 친절한 보안 도우미입니다. 사이트 장애나 공격 상황에서 사용자를 돕습니다.
|
||||
|
||||
## 대화 스타일
|
||||
- 따뜻하고 안심시키는 어조 (걱정 마세요, 도와드릴게요)
|
||||
- 한 번에 하나씩만 질문 (절대 여러 개 동시에 묻지 않기)
|
||||
- 전문 용어는 피하고, 꼭 필요하면 쉽게 설명
|
||||
- 짧고 명확한 문장
|
||||
- 사용자가 모르면 괜찮다고 안심시키기
|
||||
|
||||
## 정보 수집 순서 (자연스럽게, 대화 흐름에 따라)
|
||||
1. 증상 파악: "어떤 증상이 나타나고 있나요?"
|
||||
2. 대상 확인: "어떤 사이트(또는 서버)인가요?"
|
||||
3. 필요시 추가 질문 (한 번에 하나씩)
|
||||
|
||||
## 중요 규칙
|
||||
- 사용자가 답을 모르면: "괜찮아요, 다른 방법으로 확인해볼게요"
|
||||
- 기술적 정보를 한꺼번에 요구하지 않기
|
||||
- 상황이 파악되면 바로 도움 제공
|
||||
- 방어 조치 전 항상 "이렇게 해볼까요?" 확인
|
||||
|
||||
## 도구 사용
|
||||
- 증상/대상 파악 후 → analyze_attack
|
||||
- 현재 상태 궁금하면 → check_protection_status
|
||||
- 조치 적용 동의 시 → apply_mitigation
|
||||
- 긴급 상황 시 → activate_emergency
|
||||
|
||||
## 특수 지시
|
||||
- 사이트 장애/공격과 무관한 메시지 → "__PASSTHROUGH__"만 응답
|
||||
- 문제 해결 또는 종료 요청 시 → "__SESSION_END__"를 응답 끝에 추가
|
||||
|
||||
## 현재 상태
|
||||
방어 시스템 연동 준비 중입니다. 지금은:
|
||||
- 상황 분석 및 조언 가능
|
||||
- 실제 방어 조치는 시뮬레이션으로 안내`;
|
||||
|
||||
// DDoS Defense Tools for Function Calling
|
||||
const DDOS_TOOLS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'analyze_attack',
|
||||
description: 'DDoS 공격 패턴을 분석합니다. 증상, 트래픽 패턴, 공격 유형을 파악합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description: '공격 대상 (도메인, IP, 서비스명)',
|
||||
},
|
||||
symptoms: {
|
||||
type: 'string',
|
||||
description: '관찰된 증상 (예: 응답 지연, 접속 불가, 높은 트래픽)',
|
||||
},
|
||||
},
|
||||
required: ['target'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'check_protection_status',
|
||||
description: '현재 DDoS 방어 상태를 확인합니다. 활성화된 규칙, 차단된 요청 수 등을 조회합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description: '확인할 대상 (도메인 또는 서버)',
|
||||
},
|
||||
},
|
||||
required: ['target'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'apply_mitigation',
|
||||
description: '권장 방어 조치를 적용합니다. Cloudflare WAF, Rate Limiting, IP 차단 등의 조치를 실행합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description: '방어 대상',
|
||||
},
|
||||
mitigation_type: {
|
||||
type: 'string',
|
||||
enum: ['rate_limiting', 'waf_rule', 'ip_block', 'under_attack_mode', 'auto'],
|
||||
description: '적용할 방어 유형 (auto는 분석 결과 기반 자동 선택)',
|
||||
},
|
||||
severity: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high', 'critical'],
|
||||
description: '공격 심각도',
|
||||
},
|
||||
},
|
||||
required: ['target', 'mitigation_type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'activate_emergency',
|
||||
description: '긴급 방어 모드를 활성화합니다. 심각한 공격 상황에서 최대 방어 조치를 즉시 적용합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description: '긴급 방어 대상',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '긴급 방어 사유',
|
||||
},
|
||||
},
|
||||
required: ['target', 'reason'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'search_ddos_solutions',
|
||||
description: 'Brave Search로 최신 DDoS 방어 기법, 사례, 해결책을 검색합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '검색 쿼리 (영문 권장, 예: "cloudflare under attack mode setup")',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'lookup_security_docs',
|
||||
description: 'Cloudflare, nginx 등 보안 관련 공식 문서를 조회합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: {
|
||||
type: 'string',
|
||||
description: '서비스명 (예: cloudflare, nginx, iptables)',
|
||||
},
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '조회할 주제 (예: ddos protection, rate limiting, waf)',
|
||||
},
|
||||
},
|
||||
required: ['service', 'topic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Execute DDoS defense tool
|
||||
async function executeDdosTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
session: DdosSession,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
logger.info('도구 실행', { toolName, args });
|
||||
|
||||
switch (toolName) {
|
||||
case 'analyze_attack': {
|
||||
const target = typeof args.target === 'string' ? args.target : '';
|
||||
if (!target) {
|
||||
return JSON.stringify({ error: 'target이 필요합니다' });
|
||||
}
|
||||
const symptoms = typeof args.symptoms === 'string' ? args.symptoms : '';
|
||||
|
||||
// Detect attack type from symptoms
|
||||
const attackType = detectAttackType(symptoms);
|
||||
|
||||
// Update session with collected info
|
||||
session.collected_info.target = target;
|
||||
session.collected_info.symptoms = symptoms;
|
||||
session.collected_info.attack_type = attackType;
|
||||
|
||||
// Analyze traffic (STUB)
|
||||
const analysis = await analyzeTraffic(target, env);
|
||||
|
||||
return JSON.stringify({
|
||||
target,
|
||||
detected_attack_type: attackType,
|
||||
analysis,
|
||||
message: '공격 분석 완료. 권장 조치를 확인하세요.',
|
||||
});
|
||||
}
|
||||
|
||||
case 'check_protection_status': {
|
||||
const target = typeof args.target === 'string' ? args.target : '';
|
||||
if (!target) {
|
||||
return JSON.stringify({ error: 'target이 필요합니다' });
|
||||
}
|
||||
const status = await getProtectionStatus(target, env);
|
||||
return JSON.stringify(status);
|
||||
}
|
||||
|
||||
case 'apply_mitigation': {
|
||||
const target = typeof args.target === 'string' ? args.target : '';
|
||||
if (!target) {
|
||||
return JSON.stringify({ error: 'target이 필요합니다' });
|
||||
}
|
||||
const mitigationType = typeof args.mitigation_type === 'string' ? args.mitigation_type : 'auto';
|
||||
const severity = typeof args.severity === 'string' ? args.severity : 'medium';
|
||||
|
||||
const analysis = {
|
||||
attack_type: session.collected_info.attack_type || 'unknown',
|
||||
severity: severity as 'low' | 'medium' | 'high' | 'critical',
|
||||
estimated_volume: session.collected_info.traffic_volume || 'N/A',
|
||||
source_analysis: '',
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const result = await applyRecommendedMitigation(
|
||||
analysis,
|
||||
target,
|
||||
session.collected_info.provider || 'cloudflare',
|
||||
env
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
mitigation_type: mitigationType,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
case 'activate_emergency': {
|
||||
const target = typeof args.target === 'string' ? args.target : '';
|
||||
if (!target) {
|
||||
return JSON.stringify({ error: 'target이 필요합니다' });
|
||||
}
|
||||
const reason = typeof args.reason === 'string' ? args.reason : '긴급 상황';
|
||||
|
||||
logger.warn('긴급 방어 모드 요청', { target, reason });
|
||||
|
||||
const result = await activateEmergencyDefense(target, env);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case 'search_ddos_solutions': {
|
||||
const query = args.query as string;
|
||||
const result = await executeSearchWeb({ query }, env);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'lookup_security_docs': {
|
||||
const service = args.service as string;
|
||||
const topic = args.topic as string;
|
||||
const result = await executeLookupDocs({ library: service, query: topic }, env);
|
||||
return result;
|
||||
}
|
||||
|
||||
default:
|
||||
return `알 수 없는 도구: ${toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDoS Expert AI 호출 (Function Calling 지원)
|
||||
*/
|
||||
async function callDdosExpertAI(
|
||||
session: DdosSession,
|
||||
userMessage: string,
|
||||
env: Env
|
||||
): Promise<{ response: string; calledTools: string[] }> {
|
||||
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 systemPrompt = `${DDOS_EXPERT_PROMPT}
|
||||
|
||||
## 현재 수집된 정보
|
||||
${JSON.stringify(session.collected_info, null, 2)}`;
|
||||
|
||||
try {
|
||||
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_CALL_ROUNDS = 3;
|
||||
let toolCallRound = 0;
|
||||
const calledTools: string[] = [];
|
||||
|
||||
// Loop to handle tool calls
|
||||
while (toolCallRound < MAX_TOOL_CALL_ROUNDS) {
|
||||
const requestBody = {
|
||||
model: AI_CONFIG.model,
|
||||
messages,
|
||||
tools: DDOS_TOOLS,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: AI_CONFIG.maxTokens.ddos,
|
||||
temperature: AI_CONFIG.temperature.ddos,
|
||||
};
|
||||
|
||||
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 each tool and add results
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments);
|
||||
} catch (parseError) {
|
||||
logger.error('도구 인자 JSON 파싱 실패', parseError as Error, {
|
||||
toolName: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments?.slice(0, 200),
|
||||
});
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
content: JSON.stringify({ error: '도구 인자 파싱 실패' }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await executeDdosTool(toolCall.function.name, args, session, env);
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
content: result,
|
||||
});
|
||||
|
||||
// Track which tools were called
|
||||
calledTools.push(toolCall.function.name);
|
||||
}
|
||||
|
||||
// Count this round of tool calls
|
||||
toolCallRound++;
|
||||
|
||||
// Continue loop to get AI's response with tool results
|
||||
continue;
|
||||
}
|
||||
|
||||
// No tool calls - return final response
|
||||
const aiResponse = assistantMessage.content || '';
|
||||
logger.info('AI 응답', { response: aiResponse.slice(0, 200) });
|
||||
|
||||
// Check for special markers
|
||||
if (aiResponse.includes('__PASSTHROUGH__')) {
|
||||
return { response: '__PASSTHROUGH__', calledTools };
|
||||
}
|
||||
|
||||
// Check for session end marker
|
||||
const sessionEnd = aiResponse.includes('__SESSION_END__');
|
||||
const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim();
|
||||
|
||||
return {
|
||||
response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse,
|
||||
calledTools,
|
||||
};
|
||||
}
|
||||
|
||||
// Max tool call rounds reached
|
||||
logger.warn('최대 도구 호출 라운드 도달', { toolCallRound, totalToolsCalled: calledTools.length });
|
||||
return {
|
||||
response: '수집한 정보를 바탕으로 방어 권장사항을 제시해드리겠습니다.',
|
||||
calledTools,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('DDoS Expert AI 호출 실패', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDoS 방어 상담 처리 (메인 함수)
|
||||
*/
|
||||
export async function processDdosConsultation(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
userMessage: string,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
logger.info('DDoS 방어 상담 시작', { userId, message: userMessage.substring(0, 100) });
|
||||
|
||||
try {
|
||||
// 1. Check for existing session
|
||||
let session = await sessionManager.get(db, userId);
|
||||
|
||||
// 2. Create new session if none exists
|
||||
if (!session) {
|
||||
session = sessionManager.create(userId, 'gathering');
|
||||
}
|
||||
|
||||
// 3. Add user message to session
|
||||
sessionManager.addMessage(session, 'user', userMessage);
|
||||
|
||||
// 4. Call AI to get response and possible tool calls
|
||||
const aiResult = await callDdosExpertAI(session, userMessage, env);
|
||||
|
||||
// 5. Handle __PASSTHROUGH__ - not DDoS related
|
||||
if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) {
|
||||
logger.info('DDoS 상담 패스스루', { userId });
|
||||
return '__PASSTHROUGH__';
|
||||
}
|
||||
|
||||
// 6. Handle __SESSION_END__ - session complete
|
||||
if (aiResult.response.includes('[세션 종료]')) {
|
||||
logger.info('DDoS 상담 세션 종료', { userId });
|
||||
await sessionManager.delete(db, userId);
|
||||
return aiResult.response.replace('[세션 종료]', '').trim();
|
||||
}
|
||||
|
||||
// 7. Add assistant response to session and save
|
||||
sessionManager.addMessage(session, 'assistant', aiResult.response);
|
||||
|
||||
// Update session status based on which tools were called
|
||||
for (const toolName of aiResult.calledTools) {
|
||||
if (toolName === 'analyze_attack') {
|
||||
session.status = 'analyzing';
|
||||
break;
|
||||
} else if (toolName === 'apply_mitigation' || toolName === 'activate_emergency') {
|
||||
session.status = 'mitigating';
|
||||
break;
|
||||
} else if (toolName === 'check_protection_status') {
|
||||
session.status = 'monitoring';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
session.updated_at = Date.now();
|
||||
await sessionManager.save(db, session);
|
||||
|
||||
logger.info('DDoS 방어 상담 완료', {
|
||||
userId,
|
||||
duration: Date.now() - startTime,
|
||||
status: session.status
|
||||
});
|
||||
|
||||
return aiResult.response;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('DDoS 방어 상담 오류', error as Error, { userId });
|
||||
return '죄송합니다. DDoS 방어 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
/**
|
||||
* Server Expert Agent - 서버 전문가 AI 상담 시스템
|
||||
*
|
||||
* 기능:
|
||||
* - 대화형 서버 추천 상담
|
||||
* - 세션 기반 정보 수집 (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, OpenAIToolCall, OpenAIAPIResponse } 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';
|
||||
import { ServerSessionManager } from '../utils/session-manager';
|
||||
import { getSessionConfig } from '../constants/agent-config';
|
||||
|
||||
const logger = createLogger('server-agent');
|
||||
|
||||
// Session manager instance
|
||||
const sessionManager = new ServerSessionManager(getSessionConfig('server'));
|
||||
|
||||
/**
|
||||
* 서버 세션 존재 여부 확인 (라우팅용)
|
||||
*
|
||||
* @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<boolean> {
|
||||
return await sessionManager.has(db, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 서버 세션 정리 (Cron 또는 수동 실행)
|
||||
*
|
||||
* @param db - D1 Database
|
||||
* @returns 삭제된 세션 개수
|
||||
*/
|
||||
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 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 = [
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Server Expert AI 호출 (Function Calling 지원)
|
||||
*
|
||||
* @param session - ServerSession
|
||||
* @param userMessage - 사용자 메시지
|
||||
* @param env - Environment
|
||||
* @param recommendationData - 추천 결과 (검토 모드용)
|
||||
* @returns AI 응답 및 수집된 정보
|
||||
*/
|
||||
async function callServerExpertAI(
|
||||
session: ServerSession,
|
||||
userMessage: string,
|
||||
env: Env,
|
||||
recommendationData?: RecommendResponse
|
||||
): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['collected_info'] }> {
|
||||
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
|
||||
? `${SERVER_REVIEW_PROMPT}
|
||||
|
||||
## 검토 대상 추천 결과
|
||||
${JSON.stringify(recommendationData?.recommendations, null, 2)}
|
||||
|
||||
## 사용자 요구사항
|
||||
- 용도: ${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% 수준입니다
|
||||
- 서버 스펙은 동시접속자 기준으로 계산됩니다
|
||||
|
||||
## 응답 형식 (반드시 JSON만 반환)
|
||||
{
|
||||
"action": "recommend",
|
||||
"message": "검토 코멘트 (자연스럽고 친근한 어조, 2-3문장)",
|
||||
"collectedInfo": ${JSON.stringify(session.collected_info)}
|
||||
}
|
||||
|
||||
중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.`
|
||||
: `${SERVER_EXPERT_PROMPT}
|
||||
|
||||
## 현재 수집된 정보
|
||||
${JSON.stringify(session.collected_info, 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.collected_info;
|
||||
|
||||
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.user_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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.collected_info,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Server Expert AI 호출 실패', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 상담 처리 (메인 함수)
|
||||
*
|
||||
* @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,
|
||||
env: Env,
|
||||
options?: { sendIntermediateMessage?: (msg: string) => Promise<void> }
|
||||
): Promise<string> {
|
||||
logger.info('서버 상담 시작', { userId, message: userMessage.substring(0, 100) });
|
||||
|
||||
try {
|
||||
// 1. Check for existing session
|
||||
let session = await sessionManager.get(db, userId);
|
||||
|
||||
// 2. Create new session if none exists
|
||||
if (!session) {
|
||||
session = sessionManager.create(userId, 'gathering');
|
||||
}
|
||||
|
||||
// ordering 상태에서 "신청" 외 메시지 입력 시 세션 정리
|
||||
if (session.status === 'ordering') {
|
||||
// "신청"은 message-handler에서 처리, 여기까지 오면 다른 메시지임
|
||||
const orderConfirmKey = `server_order_confirm:${session.user_id}`;
|
||||
await env.SESSION_KV?.delete(orderConfirmKey);
|
||||
await sessionManager.delete(db, session.user_id);
|
||||
|
||||
logger.info('주문 확인 세션 취소 (다른 메시지 입력)', { userId: session.user_id });
|
||||
return '__PASSTHROUGH__'; // 일반 대화로 전환
|
||||
}
|
||||
|
||||
// 취소 키워드 처리 (모든 상태에서 작동)
|
||||
// "취소", "다시", "처음", "리셋", "초기화" 등
|
||||
if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) ||
|
||||
/취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) {
|
||||
await sessionManager.delete(db, session.user_id);
|
||||
logger.info('사용자 요청으로 상담 취소', {
|
||||
userId: session.user_id,
|
||||
previousStatus: session.status,
|
||||
trigger: userMessage.slice(0, 20)
|
||||
});
|
||||
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
|
||||
}
|
||||
|
||||
// "서버 추천" 키워드로 새로 시작 요청 (기존 세션 리셋)
|
||||
if (/서버\s*추천/.test(userMessage)) {
|
||||
await sessionManager.delete(db, session.user_id);
|
||||
logger.info('서버 추천 키워드로 세션 리셋', {
|
||||
userId: session.user_id,
|
||||
previousStatus: session.status
|
||||
});
|
||||
// 새 세션 생성하고 시작 메시지 반환
|
||||
const newSession = sessionManager.create(session.user_id, 'gathering');
|
||||
await sessionManager.save(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.user_id,
|
||||
status: session.status,
|
||||
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.last_recommendation) {
|
||||
// 상담과 무관한 키워드 감지 (selecting 상태에서만)
|
||||
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
|
||||
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
|
||||
if (unrelatedPatterns.test(userMessage)) {
|
||||
await sessionManager.delete(db, session.user_id);
|
||||
logger.info('무관한 요청으로 세션 자동 종료', {
|
||||
userId: session.user_id,
|
||||
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.last_recommendation.recommendations.length) {
|
||||
const selected = session.last_recommendation.recommendations[selectedIndex];
|
||||
|
||||
// Mark session as ordering
|
||||
session.status = 'ordering';
|
||||
await sessionManager.save(db, session);
|
||||
|
||||
// 주문 확인 세션 저장 (텍스트 기반 확인)
|
||||
const orderConfirmKey = `server_order_confirm:${session.user_id}`;
|
||||
const orderConfirmData = JSON.stringify({
|
||||
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.user_id });
|
||||
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.last_recommendation.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(session, userMessage, env);
|
||||
|
||||
// Update collected info
|
||||
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 (options?.sendIntermediateMessage) {
|
||||
await options?.sendIntermediateMessage('🔍 요청하신 조건에 맞는 서버를 분석 중입니다...\n잠시만 기다려 주세요.');
|
||||
}
|
||||
|
||||
// Mark session as recommending
|
||||
session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING;
|
||||
await sessionManager.save(db, session);
|
||||
|
||||
// 1. Call recommendation API (추천 먼저 받기)
|
||||
logger.info('추천 API 호출', { collectedInfo: session.collected_info });
|
||||
|
||||
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.collected_info.useCase
|
||||
? inferTechStack(session.collected_info.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.user_id
|
||||
});
|
||||
}
|
||||
|
||||
// 동시접속자 우선 사용, 없으면 scale 기반 추론
|
||||
let expectedUsers = 10; // Default
|
||||
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.collected_info.scale) {
|
||||
expectedUsers = inferExpectedUsers(session.collected_info.scale, techStack);
|
||||
}
|
||||
|
||||
// 리전 선호도 최종 확인 (세션에 없으면 메시지에서 재추출)
|
||||
let finalRegionPreference = session.collected_info.regionPreference;
|
||||
if (!finalRegionPreference) {
|
||||
finalRegionPreference = extractRegionPreference(allMessages);
|
||||
if (finalRegionPreference) {
|
||||
logger.info('추천 직전 리전 재감지', {
|
||||
regions: finalRegionPreference,
|
||||
userId: session.user_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recommendationData = await getRecommendationData(
|
||||
{
|
||||
tech_stack: techStack,
|
||||
expected_users: expectedUsers,
|
||||
use_case: session.collected_info.useCase || '웹 서비스',
|
||||
region_preference: finalRegionPreference,
|
||||
budget_limit: session.collected_info.budgetLimit,
|
||||
lang: LANGUAGE_CODE.KOREAN,
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
// 추천 결과를 세션에 저장
|
||||
if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
|
||||
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,
|
||||
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
|
||||
})),
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
// 2. AI에게 추천 결과 전달하여 검토 요청
|
||||
logger.info('AI 검토 요청', { recommendationCount: recommendationData.recommendations.length });
|
||||
const reviewResult = await callServerExpertAI(session, userMessage, env, recommendationData);
|
||||
|
||||
// 3. 포맷팅된 추천 결과 생성
|
||||
const formattedRecommendation = await executeServerAction(
|
||||
'recommend',
|
||||
{
|
||||
tech_stack: techStack,
|
||||
expected_users: expectedUsers,
|
||||
use_case: session.collected_info.useCase || '웹 서비스',
|
||||
region_preference: session.collected_info.regionPreference,
|
||||
budget_limit: session.collected_info.budgetLimit,
|
||||
lang: LANGUAGE_CODE.KOREAN,
|
||||
},
|
||||
env,
|
||||
session.user_id
|
||||
);
|
||||
|
||||
// Mark session as selecting (사용자 선택 대기)
|
||||
session.status = SERVER_CONSULTATION_STATUS.SELECTING;
|
||||
await sessionManager.save(db, session);
|
||||
|
||||
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
|
||||
// __DIRECT__ 마커가 앞에 와야 제대로 처리됨
|
||||
return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
|
||||
} else {
|
||||
// 추천 결과 없음 - 세션 삭제
|
||||
session.status = SERVER_CONSULTATION_STATUS.COMPLETED;
|
||||
await sessionManager.delete(db, session.user_id);
|
||||
|
||||
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
|
||||
}
|
||||
} else {
|
||||
// Continue gathering information
|
||||
session.status = SERVER_CONSULTATION_STATUS.GATHERING;
|
||||
await sessionManager.save(db, session);
|
||||
|
||||
return aiResult.message;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('상담 처리 실패', error as Error, { userId });
|
||||
|
||||
// Clean up session on error (if exists)
|
||||
try {
|
||||
await sessionManager.delete(db, userId);
|
||||
} catch (deleteError) {
|
||||
logger.error('세션 삭제 실패 (무시)', deleteError as Error, { userId });
|
||||
}
|
||||
|
||||
return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user