import type { Env, OpenAIMessage, ToolCall } from './types'; import { tools, selectToolsForMessage, executeTool } from './tools'; import { retryWithBackoff, RetryError } from './utils/retry'; import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker'; import { createLogger } from './utils/logger'; import { metrics } from './utils/metrics'; import { getOpenAIUrl } from './utils/api-urls'; import { ERROR_MESSAGES } from './constants/messages'; import { processTroubleshootConsultation, hasTroubleshootSession } from './agents/troubleshoot-agent'; import { processDomainConsultation, hasDomainSession } from './agents/domain-agent'; import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent'; import { processDdosConsultation, hasDdosSession } from './agents/ddos-agent'; import { sendMessage } from './telegram'; const logger = createLogger('openai'); // 사용자 메시지에서 저장할 정보 추출 (패턴 기반) const SAVEABLE_PATTERNS = [ // 회사/직장 /(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z0-9]+)(?:에서|에)\s*(?:일해|일하고|근무|다녀)/, // 기술/언어 공부 /(?:요즘|지금|현재)?\s*([가-힣A-Za-z0-9+#]+)(?:로|을|를)?\s*(?:공부|개발|작업|배우)/, // 직무/역할 /(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z]+)\s*(?:개발자|엔지니어|디자이너|기획자)/, // 해외 거주 /([가-힣A-Za-z]+)(?:에서|에)\s*(?:살아|거주|있어)/, // 서버/인프라 - 클라우드 제공자 /(?:나|저|우리)?\s*(?:는|은)?\s*(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24)\s*(?:사용|쓰|이용)/, // 서버/인프라 - 서버 수량 /서버\s*(\d+)\s*(?:대|개)|(\d+)\s*(?:대|개)\s*서버/, // 서버/인프라 - 트래픽/사용자 규모 /(?:트래픽|DAU|MAU|동시접속|사용자|유저)\s*(?:가|이)?\s*(?:약|대략|월|일)?\s*(\d+[\d,]*)\s*(?:명|만|천)?/, // 서버/인프라 - 컨테이너/오케스트레이션 /(?:쿠버네티스|k8s|도커|docker|컨테이너)\s*(?:사용|쓰|운영|돌려)/, ]; function extractSaveableInfo(message: string): string | null { // 제외 패턴 (이름, 생일, 국내 지역) if (/(?:이름|생일|서울|부산|대전|대구|광주|인천)/.test(message)) { return null; } for (const pattern of SAVEABLE_PATTERNS) { if (pattern.test(message)) { return message.trim(); } } return null; } // 메모리 카테고리 감지 type MemoryCategory = 'company' | 'tech' | 'role' | 'location' | 'server' | null; function detectMemoryCategory(content: string): MemoryCategory { // 회사/직장: ~에서 일해, 근무, 다녀 if (/(?:에서|에)\s*(?:일해|일하고|근무|다녀)/.test(content)) { return 'company'; } // 기술/공부: ~공부, 배워, 개발 if (/(?:공부|개발|작업|배우)/.test(content)) { return 'tech'; } // 직무: 개발자, 엔지니어, 디자이너, 기획자 if (/(?:개발자|엔지니어|디자이너|기획자)/.test(content)) { return 'role'; } // 해외거주: ~에서 살아, 거주 if (/(?:에서|에)\s*(?:살아|거주|있어)/.test(content)) { return 'location'; } // 서버/인프라: 클라우드, 서버 수량, 트래픽, 컨테이너 if (/(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24|서버\s*\d|트래픽|DAU|MAU|동시접속|쿠버네티스|k8s|도커|docker|컨테이너)/i.test(content)) { return 'server'; } return null; } // 백그라운드에서 메모리 저장 (응답에 영향 없음, 카테고리별 덮어쓰기) async function saveMemorySilently( db: D1Database | undefined, telegramUserId: string | undefined, content: string ): Promise { if (!db || !telegramUserId) return; try { const user = await db .prepare('SELECT id FROM users WHERE telegram_id = ?') .bind(telegramUserId) .first<{ id: number }>(); if (!user) return; const category = detectMemoryCategory(content); // 카테고리가 감지되면, 동일 카테고리의 기존 메모리 삭제 if (category) { const existing = await db .prepare('SELECT id, content FROM user_memories WHERE user_id = ?') .bind(user.id) .all<{ id: number; content: string }>(); if (existing.results && existing.results.length > 0) { // Collect IDs to delete const idsToDelete = existing.results .filter(memory => detectMemoryCategory(memory.content) === category) .map(memory => memory.id); if (idsToDelete.length > 0) { // Single batch delete instead of N individual deletes const placeholders = idsToDelete.map(() => '?').join(','); await db.prepare( `DELETE FROM user_memories WHERE id IN (${placeholders})` ).bind(...idsToDelete).run(); logger.info('Deleted existing memories of same category', { userId: telegramUserId, category, deletedCount: idsToDelete.length }); } } } // 새 메모리 저장 await db .prepare('INSERT INTO user_memories (user_id, content) VALUES (?, ?)') .bind(user.id, content) .run(); logger.info('Silent memory save', { userId: telegramUserId, category: category || 'uncategorized', contentLength: content.length }); } catch (error) { logger.error('Silent memory save failed', error as Error); } } // Circuit Breaker 인스턴스 (전역 공유) export const openaiCircuitBreaker = new CircuitBreaker({ failureThreshold: 3, // 3회 연속 실패 시 차단 resetTimeoutMs: 30000, // 30초 후 복구 시도 monitoringWindowMs: 60000 // 1분 윈도우 }); interface OpenAIResponse { choices: { message: OpenAIMessage; finish_reason: string; }[]; } // OpenAI API 호출 (retry + circuit breaker 적용) async function callOpenAI( env: Env, apiKey: string, messages: OpenAIMessage[], selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용 ): Promise { const timer = metrics.startTimer('api_call_duration', { service: 'openai' }); try { return await retryWithBackoff( async () => { const response = await fetch(getOpenAIUrl(env), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages, tools: selectedTools?.length ? selectedTools : undefined, tool_choice: selectedTools?.length ? 'auto' : undefined, max_tokens: 1000, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`OpenAI API 오류: ${response.status} - ${error}`); } return response.json(); }, { maxRetries: 3, initialDelayMs: 1000, maxDelayMs: 10000, } ); } finally { timer(); // duration 자동 기록 (성공/실패 관계없이) } } // 메인 응답 생성 함수 export async function generateOpenAIResponse( env: Env, userMessage: string, systemPrompt: string, recentContext: { role: 'user' | 'assistant'; content: string }[], telegramUserId?: string, db?: D1Database, chatIdStr?: string ): Promise { // Check if troubleshoot session is active if (telegramUserId && env.DB) { try { const hasTroubleshootSess = await hasTroubleshootSession(env.DB, telegramUserId); if (hasTroubleshootSess) { logger.info('트러블슈팅 세션 감지, 트러블슈팅 에이전트로 라우팅', { userId: telegramUserId }); const troubleshootResponse = await processTroubleshootConsultation(env.DB, telegramUserId, userMessage, env); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (troubleshootResponse !== '__PASSTHROUGH__') { return troubleshootResponse; } // Continue to normal flow below } } catch (error) { logger.error('Troubleshoot session check failed, continuing with normal flow', error as Error); // Continue with normal flow if session check fails } // Check if domain consultation session is active try { const hasDomainSess = await hasDomainSession(env.DB, telegramUserId); if (hasDomainSess) { logger.info('도메인 세션 감지, 도메인 에이전트로 라우팅', { userId: telegramUserId }); const domainResponse = await processDomainConsultation(env.DB, telegramUserId, userMessage, env); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (domainResponse !== '__PASSTHROUGH__') { return domainResponse; } // Continue to normal flow below } } catch (error) { logger.error('Domain session check failed, continuing with normal flow', error as Error, { telegramUserId }); // Continue with normal flow if session check fails } // Check for active deposit session try { const hasDepositSess = await hasDepositSession(env.DB, telegramUserId); if (hasDepositSess) { logger.info('예치금 세션 감지, 예치금 에이전트로 라우팅', { userId: telegramUserId }); const depositResponse = await processDepositConsultation(env.DB, telegramUserId, userMessage, env); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (depositResponse !== '__PASSTHROUGH__') { return depositResponse; } // Continue to normal flow below } } catch (error) { logger.error('Deposit session check failed, continuing with normal flow', error as Error, { telegramUserId }); // Continue with normal flow if session check fails } // Check for active DDoS defense session try { const hasDdosSess = await hasDdosSession(env.DB, telegramUserId); if (hasDdosSess) { logger.info('DDoS 방어 세션 감지, DDoS 에이전트로 라우팅', { userId: telegramUserId }); const ddosResponse = await processDdosConsultation(env.DB, telegramUserId, userMessage, env); // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 if (ddosResponse !== '__PASSTHROUGH__') { return ddosResponse; } // Continue to normal flow below } } catch (error) { logger.error('DDoS session check failed, continuing with normal flow', error as Error, { telegramUserId }); // Continue with normal flow if session check fails } } if (!env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY not configured'); } const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장 try { // Circuit Breaker로 전체 실행 감싸기 return await openaiCircuitBreaker.execute(async () => { const messages: OpenAIMessage[] = [ { role: 'system', content: systemPrompt }, ...recentContext.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, })), { role: 'user', content: userMessage }, ]; // 동적 도구 선택 const selectedTools = selectToolsForMessage(userMessage); // 첫 번째 호출 let response = await callOpenAI(env, apiKey, messages, selectedTools); let assistantMessage = response.choices[0].message; logger.info('tool_calls', { calls: assistantMessage.tool_calls ? assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments })) : 'none' }); logger.info('content', { preview: assistantMessage.content?.slice(0, 100) }); // Function Calling 처리 (최대 3회 반복) let iterations = 0; while (assistantMessage.tool_calls && iterations < 3) { iterations++; // 도구 호출을 병렬 실행 type ToolResult = { early: true; result: string; toolCall: ToolCall; } | { early: false; message: OpenAIMessage; } | null; const toolPromises = assistantMessage.tool_calls.map(async (toolCall): Promise => { let args: Record; try { args = JSON.parse(toolCall.function.arguments); } catch (parseError) { logger.error('Failed to parse tool arguments', parseError as Error, { toolName: toolCall.function.name, raw: toolCall.function.arguments.slice(0, 200) // 일부만 로깅 }); return null; // 파싱 실패 시 null 반환 } const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db); // Early return 체크 (__KEYBOARD__, __DIRECT__) if (result.includes('__KEYBOARD__') || result.includes('__DIRECT__')) { return { early: true as const, result, toolCall }; } return { early: false as const, message: { role: 'tool' as const, tool_call_id: toolCall.id, content: result, } }; }); const results = await Promise.all(toolPromises); // Early return 처리 const earlyResult = results.find((r): r is { early: true; result: string; toolCall: ToolCall } => r !== null && r.early === true ); if (earlyResult) { if (earlyResult.result.includes('__DIRECT__')) { // Remove __DIRECT__ marker and everything before it (AI commentary) const directIndex = earlyResult.result.indexOf('__DIRECT__'); return earlyResult.result.slice(directIndex + '__DIRECT__'.length).trim(); } return earlyResult.result; } // 정상 결과 처리 (null 제외) const toolResults = results .filter((r): r is { early: false; message: OpenAIMessage } => r !== null && r.early === false ); // 메모리 저장([SAVED])이 포함되어 있으면 모든 메모리 관련 결과를 숨김 const hasSaveResult = toolResults.some(r => r.message.content === '[SAVED]'); const isMemoryResult = (content: string | null) => content === '[SAVED]' || content?.startsWith('📋 저장된 기억'); if (hasSaveResult) { // 메모리 저장이 있으면: 메모리 관련 도구 호출을 모두 숨기고 다시 호출 const nonMemoryResults = toolResults.filter(r => !isMemoryResult(r.message.content)); if (nonMemoryResults.length === 0) { // 메모리 작업만 했으면 도구 호출 없이 다시 호출 response = await callOpenAI(env, apiKey, messages, undefined); assistantMessage = response.choices[0].message; break; } // 메모리 외 다른 도구도 있으면 그것만 포함 const filteredToolCalls = assistantMessage.tool_calls?.filter(tc => { const matchingResult = toolResults.find(r => r.message.tool_call_id === tc.id); return matchingResult && !isMemoryResult(matchingResult.message.content); }); if (filteredToolCalls && filteredToolCalls.length > 0) { messages.push({ role: 'assistant', content: assistantMessage.content, tool_calls: filteredToolCalls, }); messages.push(...nonMemoryResults.map(r => r.message)); } } else { // 메모리 저장이 없으면 모든 결과 포함 if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { messages.push({ role: 'assistant', content: assistantMessage.content, tool_calls: assistantMessage.tool_calls, }); messages.push(...toolResults.map(r => r.message)); } } // 다시 호출 (도구 없이 응답 생성) response = await callOpenAI(env, apiKey, messages, undefined); assistantMessage = response.choices[0].message; } const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.'; // 백그라운드 메모리 저장 (AI 응답과 무관하게) const saveableInfo = extractSaveableInfo(userMessage); if (saveableInfo) { // 비동기로 저장, 응답 지연 없음 saveMemorySilently(db, telegramUserId, saveableInfo).catch(err => { logger.debug('Memory save failed (non-critical)', { error: (err as Error).message }); }); } return finalResponse; }); } catch (error) { // 에러 처리 if (error instanceof CircuitBreakerError) { logger.error('Circuit breaker open', error as Error); return ERROR_MESSAGES.SERVICE_UNAVAILABLE; } if (error instanceof RetryError) { logger.error('All retry attempts failed', error as Error); return ERROR_MESSAGES.AI_RESPONSE_FAILED; } // 기타 에러 logger.error('Unexpected error', error as Error); return ERROR_MESSAGES.UNEXPECTED_ERROR; } } // 프로필 생성용 (도구 없이) export async function generateProfileWithOpenAI( env: Env, prompt: string ): Promise { if (!env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY not configured'); } const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장 try { // Circuit Breaker로 실행 감싸기 return await openaiCircuitBreaker.execute(async () => { const response = await callOpenAI( env, apiKey, [{ role: 'user', content: prompt }], undefined // 도구 없이 호출 ); return response.choices[0].message.content || '프로필 생성 실패'; }); } catch (error) { // 에러 처리 if (error instanceof CircuitBreakerError) { logger.error('Profile - Circuit breaker open', error as Error); return ERROR_MESSAGES.PROFILE_GENERATION_FAILED; } if (error instanceof RetryError) { logger.error('Profile - All retry attempts failed', error as Error); return ERROR_MESSAGES.PROFILE_GENERATION_FAILED; } // 기타 에러 logger.error('Profile - Unexpected error', error as Error); return ERROR_MESSAGES.PROFILE_GENERATION_UNEXPECTED; } }