diff --git a/MEMORY_FEATURE.md b/MEMORY_FEATURE.md new file mode 100644 index 0000000..5a3c14a --- /dev/null +++ b/MEMORY_FEATURE.md @@ -0,0 +1,162 @@ +# Memory Feature Implementation + +## Overview +사용자가 명시적으로 기억해달라고 요청한 정보를 저장하고 관리하는 기능입니다. + +## Files Created/Modified + +### 1. Migration (NEW) +- `migrations/003_add_user_memories.sql` + - user_memories 테이블 생성 + - user_id, content, created_at 컬럼 + - 인덱스: idx_memories_user (user_id, created_at DESC) + +### 2. Memory Service (NEW) +- `src/services/memory-service.ts` + - `saveMemory(db, userId, content)` - 기억 저장 + - `getMemories(db, userId)` - 모든 기억 조회 (최신순) + - `deleteMemory(db, userId, memoryId)` - ID로 삭제 + - `deleteMemoryByContent(db, userId, searchText)` - LIKE 검색으로 삭제 + - `formatMemoriesForPrompt(memories)` - 시스템 프롬프트용 포맷팅 + +### 3. Memory Tool (NEW) +- `src/tools/memory-tool.ts` + - Function Calling 도구 정의 + - 키워드: 기억해줘, 기억해, 잊어줘, 지워줘, 내 기억, 저장된 정보, remember, forget + - Actions: save, list, delete + - 고정 형식 응답: + - save: "✅ 기억했습니다: {content}" + - list: "📋 저장된 기억 (N개)\n\n1. ... (ID: X)" + - delete: "✅ N개의 기억을 삭제했습니다:\n\n1. ..." + +### 4. Types (MODIFIED) +- `src/types.ts` + - `Memory` 인터페이스 추가 + - `ManageMemoryArgs` 인터페이스 추가 + +### 5. Tools Index (MODIFIED) +- `src/tools/index.ts` + - manageMemoryTool import + - ManageMemoryArgsSchema 추가 (Zod validation) + - tools 배열에 manageMemoryTool 추가 + - TOOL_CATEGORIES에 memory 카테고리 추가 + - CATEGORY_PATTERNS에 memory 패턴 추가 + - executeTool에 manage_memory 케이스 추가 + +### 6. Summary Service (MODIFIED) +- `src/summary-service.ts` + - generateAIResponse 함수 수정: + - getMemories 호출로 사용자 기억 조회 + - formatMemoriesForPrompt로 포맷팅 + - 시스템 프롬프트에 "사용자가 기억해달라고 한 정보" 섹션 추가 + - manage_memory 도구 사용 안내 추가 + +## Database Schema + +```sql +CREATE TABLE IF NOT EXISTS user_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id, created_at DESC); +``` + +## API Usage Examples + +### 1. 기억 저장 +**사용자**: "나는 Go 개발자야. 기억해줘." +**AI**: manage_memory(action="save", content="Go 개발자") +**응답**: "✅ 기억했습니다: Go 개발자" + +### 2. 기억 조회 +**사용자**: "내가 뭐라고 했지?" +**AI**: manage_memory(action="list") +**응답**: +``` +📋 저장된 기억 (2개) + +1. Go 개발자 (ID: 1) +2. 서울 거주 (ID: 2) +``` + +### 3. 기억 삭제 (ID) +**사용자**: "1번 지워줘" +**AI**: manage_memory(action="delete", memory_id=1) +**응답**: "✅ 기억을 삭제했습니다 (ID: 1)" + +### 4. 기억 삭제 (내용 검색) +**사용자**: "Go 관련된 거 잊어줘" +**AI**: manage_memory(action="delete", content="Go") +**응답**: +``` +✅ 1개의 기억을 삭제했습니다: + +1. Go 개발자 +``` + +## System Prompt Integration + +기억이 있는 경우 시스템 프롬프트에 다음과 같이 추가됩니다: + +``` +## 사용자가 기억해달라고 한 정보 +- Go 개발자 +- 서울 거주 +- 반려견 이름은 뭉치 + +위 정보들은 사용자가 명시적으로 기억해달라고 요청한 중요한 정보입니다. 대화 시 이를 참고하세요. +``` + +## Deployment Steps + +1. **D1 마이그레이션 실행** (로컬 테스트) +```bash +wrangler d1 execute telegram-conversations --local --file=migrations/003_add_user_memories.sql +``` + +2. **프로덕션 배포** (⚠️ 주의: 스키마 변경) +```bash +wrangler d1 execute telegram-conversations --file=migrations/003_add_user_memories.sql +``` + +3. **TypeScript 검증** +```bash +npx tsc --noEmit +``` + +4. **로컬 테스트** +```bash +npm run dev +``` + +5. **배포** +```bash +npm run deploy +``` + +## Testing Checklist + +- [ ] "기억해줘" → save action 호출 확인 +- [ ] "내 기억" → list action 호출 확인 +- [ ] "잊어줘" → delete action 호출 확인 +- [ ] 저장된 기억이 시스템 프롬프트에 포함되는지 확인 +- [ ] AI가 기억된 정보를 참고하여 응답하는지 확인 +- [ ] 삭제 시 해당 기억이 더 이상 프롬프트에 나타나지 않는지 확인 + +## Performance Considerations + +- **인덱스**: user_id + created_at DESC 복합 인덱스로 조회 최적화 +- **캐싱**: getMemories는 매 AI 응답마다 호출되므로 향후 KV 캐싱 고려 가능 +- **제한**: content 최대 1000자 (Zod validation) + +## Future Enhancements + +- [ ] 기억 수 제한 (사용자당 최대 N개) +- [ ] 기억 중요도/우선순위 설정 +- [ ] 기억 카테고리/태그 기능 +- [ ] 기억 검색 개선 (전문 검색, 유사도 검색) +- [ ] 기억 자동 만료 (TTL) diff --git a/migrations/003_add_user_memories.sql b/migrations/003_add_user_memories.sql new file mode 100644 index 0000000..8c625fb --- /dev/null +++ b/migrations/003_add_user_memories.sql @@ -0,0 +1,13 @@ +-- Migration 003: Add User Memories Table +-- 사용자가 기억해달라고 요청한 정보를 저장하는 테이블 + +CREATE TABLE IF NOT EXISTS user_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 사용자별 기억 조회 성능 최적화 +CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id, created_at DESC); diff --git a/src/commands.ts b/src/commands.ts index 71ecba7..627f79f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -21,14 +21,19 @@ export async function handleCommand( 명령어: /profile - 내 프로필 보기 -/reset - 대화 초기화 -/help - 도움말`; +/help - 도움말 + +💡 중요한 정보는 "기억해줘"로 저장하세요!`; case '/help': return `📖 도움말 /profile - 내 프로필 보기 -/reset - 대화 초기화 + +기억 기능: +• "OOO 기억해줘" - 정보 저장 +• "내 기억 보여줘" - 저장 목록 +• "OOO 잊어줘" - 삭제 대화할수록 당신을 더 잘 이해합니다 💡`; @@ -78,46 +83,6 @@ ${summary.summary} 버퍼 대기: ${ctx.recentMessages.length}개`; } - case '/reset': { - const ctx = await getConversationContext(env.DB, userId, chatId); - const msgCount = ctx.totalMessages; - const profileGen = ctx.previousSummary?.generation || 0; - - if (msgCount === 0 && profileGen === 0) { - return '📭 삭제할 데이터가 없습니다.'; - } - - return `⚠️ 정말 초기화할까요? - -삭제될 데이터: -• 메시지 버퍼: ${ctx.recentMessages.length}개 -• 프로필: v${profileGen} -• 총 메시지 기록: ${msgCount}개 - -이 작업은 되돌릴 수 없습니다! - -확인하려면 /reset-confirm 을 입력하세요. -취소하려면 아무 메시지나 보내세요.`; - } - - case '/reset-confirm': { - const result = await env.DB.batch([ - env.DB.prepare('DELETE FROM message_buffer WHERE user_id = ?').bind(userId), - env.DB.prepare('DELETE FROM summaries WHERE user_id = ?').bind(userId), - ]); - - const deletedMessages = result[0].meta.changes || 0; - const deletedSummaries = result[1].meta.changes || 0; - - return `🗑️ 초기화 완료! - -삭제됨: -• 메시지: ${deletedMessages}개 -• 프로필: ${deletedSummaries}개 - -새로운 대화를 시작하세요!`; - } - case '/debug': { // 디버그용 명령어 (개발 시 유용) const ctx = await getConversationContext(env.DB, userId, chatId); diff --git a/src/openai-service.ts b/src/openai-service.ts index 4badf24..9b088ee 100644 --- a/src/openai-service.ts +++ b/src/openai-service.ts @@ -9,6 +9,126 @@ import { ERROR_MESSAGES } from './constants/messages'; 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) { + for (const memory of existing.results) { + if (detectMemoryCategory(memory.content) === category) { + await db + .prepare('DELETE FROM user_memories WHERE id = ?') + .bind(memory.id) + .run(); + logger.info('Memory replaced (same category)', { + userId: telegramUserId, + category, + oldContent: memory.content.slice(0, 30), + newContent: content.slice(0, 30) + }); + } + } + } + } + + // 새 메모리 저장 + 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회 연속 실패 시 차단 @@ -104,12 +224,41 @@ export async function generateOpenAIResponse( userId: telegramUserId, status: session.status }); - return await processServerConsultation(userMessage, session, env); + const result = await processServerConsultation(userMessage, session, env); + + // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 + if (result !== '__PASSTHROUGH__') { + return result; + } + // Continue to normal flow below } } catch (error) { logger.error('Session check failed, continuing with normal flow', error as Error); // Continue with normal flow if session check fails } + + // Check if troubleshoot session is active + try { + const { getTroubleshootSession, processTroubleshoot } = await import('./troubleshoot-agent'); + const troubleshootSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId); + + if (troubleshootSession && troubleshootSession.status !== 'completed') { + logger.info('Active troubleshoot session detected, routing to troubleshoot', { + userId: telegramUserId, + status: troubleshootSession.status + }); + const result = await processTroubleshoot(userMessage, troubleshootSession, env); + + // PASSTHROUGH: 무관한 메시지는 일반 처리로 전환 + if (result !== '__PASSTHROUGH__') { + return result; + } + // 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 + } } if (!env.OPENAI_API_KEY) { @@ -203,16 +352,49 @@ export async function generateOpenAIResponse( const toolResults = results .filter((r): r is { early: false; message: OpenAIMessage } => r !== null && r.early === false - ) - .map(r => r.message); + ); - // 대화에 추가 - messages.push({ - role: 'assistant', - content: assistantMessage.content, - tool_calls: assistantMessage.tool_calls, - }); - messages.push(...toolResults); + // 메모리 저장([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); @@ -221,6 +403,13 @@ export async function generateOpenAIResponse( const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.'; + // 백그라운드 메모리 저장 (AI 응답과 무관하게) + const saveableInfo = extractSaveableInfo(userMessage); + if (saveableInfo) { + // 비동기로 저장, 응답 지연 없음 + saveMemorySilently(db, telegramUserId, saveableInfo).catch(() => {}); + } + return finalResponse; }); } catch (error) { diff --git a/src/server-agent.ts b/src/server-agent.ts index 3a521dd..380d50c 100644 --- a/src/server-agent.ts +++ b/src/server-agent.ts @@ -526,7 +526,20 @@ export async function processServerConsultation( // 선택 단계 처리 if (session.status === 'selecting' && session.lastRecommendation) { - const selectionMatch = userMessage.match(/(\d+)(?:번|번째)?|첫\s*번째|두\s*번째|세\s*번째/); + // 상담과 무관한 키워드 감지 (selecting 상태에서만) + // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 + const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; + if (unrelatedPatterns.test(userMessage)) { + await deleteServerSession(env.SESSION_KV, 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; diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts new file mode 100644 index 0000000..2d9c726 --- /dev/null +++ b/src/services/memory-service.ts @@ -0,0 +1,198 @@ +import type { Memory } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('memory-service'); + +// D1 query result type guard +interface D1MemoryRow { + id: number; + user_id: number; + content: string; + created_at: string; +} + +function isMemoryRow(item: unknown): item is D1MemoryRow { + if (typeof item !== 'object' || item === null) return false; + const row = item as Record; + return ( + typeof row.id === 'number' && + typeof row.user_id === 'number' && + typeof row.content === 'string' && + typeof row.created_at === 'string' + ); +} + +function isMemoryArray(data: unknown): data is D1MemoryRow[] { + return Array.isArray(data) && data.every(isMemoryRow); +} + +/** + * 사용자 기억 저장 + * @param db D1 Database + * @param userId 사용자 ID (users.id) + * @param content 기억할 내용 + * @returns 저장된 기억 ID + */ +export async function saveMemory( + db: D1Database, + userId: number, + content: string +): Promise { + try { + const result = await db + .prepare('INSERT INTO user_memories (user_id, content) VALUES (?, ?)') + .bind(userId, content) + .run(); + + if (!result.success) { + throw new Error('Failed to save memory'); + } + + logger.info('기억 저장 완료', { userId, contentLength: content.length }); + + // D1 returns meta.last_row_id for insert operations + return result.meta.last_row_id as number; + } catch (error) { + logger.error('기억 저장 실패', error as Error, { userId }); + throw error; + } +} + +/** + * 사용자의 모든 기억 조회 + * @param db D1 Database + * @param userId 사용자 ID (users.id) + * @returns 기억 목록 (최신순) + */ +export async function getMemories( + db: D1Database, + userId: number +): Promise { + try { + const { results } = await db + .prepare(` + SELECT id, user_id, content, created_at + FROM user_memories + WHERE user_id = ? + ORDER BY created_at DESC + `) + .bind(userId) + .all(); + + if (!isMemoryArray(results)) { + logger.warn('Invalid memory data format', { userId }); + return []; + } + + logger.info('기억 조회 완료', { userId, count: results.length }); + + return results as Memory[]; + } catch (error) { + logger.error('기억 조회 실패', error as Error, { userId }); + return []; + } +} + +/** + * 기억 삭제 (ID로) + * @param db D1 Database + * @param userId 사용자 ID (users.id) + * @param memoryId 기억 ID + * @returns 삭제된 행 수 + */ +export async function deleteMemory( + db: D1Database, + userId: number, + memoryId: number +): Promise { + try { + const result = await db + .prepare('DELETE FROM user_memories WHERE id = ? AND user_id = ?') + .bind(memoryId, userId) + .run(); + + if (!result.success) { + throw new Error('Failed to delete memory'); + } + + const deletedCount = result.meta.changes || 0; + logger.info('기억 삭제 완료', { userId, memoryId, deletedCount }); + + return deletedCount; + } catch (error) { + logger.error('기억 삭제 실패', error as Error, { userId, memoryId }); + throw error; + } +} + +/** + * 기억 삭제 (내용 검색으로) + * @param db D1 Database + * @param userId 사용자 ID (users.id) + * @param searchText 검색할 텍스트 + * @returns 삭제된 기억 내용 배열 + */ +export async function deleteMemoryByContent( + db: D1Database, + userId: number, + searchText: string +): Promise { + try { + // 먼저 삭제될 항목들을 조회 + const { results } = await db + .prepare(` + SELECT content + FROM user_memories + WHERE user_id = ? AND content LIKE ? + `) + .bind(userId, `%${searchText}%`) + .all(); + + if (!isMemoryArray(results)) { + return []; + } + + const contents = (results as D1MemoryRow[]).map((row) => row.content); + + if (contents.length === 0) { + logger.info('삭제할 기억 없음', { userId, searchText }); + return []; + } + + // 삭제 실행 + const result = await db + .prepare('DELETE FROM user_memories WHERE user_id = ? AND content LIKE ?') + .bind(userId, `%${searchText}%`) + .run(); + + if (!result.success) { + throw new Error('Failed to delete memories by content'); + } + + logger.info('기억 삭제 완료', { + userId, + searchText, + deletedCount: contents.length, + }); + + return contents; + } catch (error) { + logger.error('기억 삭제 실패', error as Error, { userId, searchText }); + throw error; + } +} + +/** + * 기억 목록을 시스템 프롬프트용으로 포맷팅 + * @param memories 기억 배열 + * @returns 포맷된 문자열 + */ +export function formatMemoriesForPrompt(memories: Memory[]): string { + if (memories.length === 0) { + return ''; + } + + const memoryLines = memories.map((m) => `- ${m.content}`).join('\n'); + + return `## 사용자 배경 정보 (내부 참고용 - 직접 언급 금지)\n${memoryLines}`; +} diff --git a/src/summary-service.ts b/src/summary-service.ts index 904177d..4e91e78 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -372,6 +372,11 @@ export async function generateAIResponse( .join('\n\n') : null; + // 사용자 기억 조회 및 포맷팅 + const { getMemories, formatMemoriesForPrompt } = await import('./services/memory-service'); + const memories = await getMemories(env.DB, userId); + const memoriesSection = formatMemoriesForPrompt(memories); + const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다. ${integratedProfile ? ` ## 사용자 프로필 (${context.summaries.length}개 버전 통합) @@ -380,16 +385,25 @@ ${integratedProfile} 위 프로필들을 종합하여 사용자의 관심사, 맥락, 변화를 이해하고 개인화된 응답을 제공하세요. 최신 버전(높은 번호)의 정보를 우선시하되, 이전 버전의 맥락도 고려하세요. ` : ''} +${memoriesSection ? ` +${memoriesSection} + +위 배경 정보는 대화 맥락 이해용입니다. "기억", "저장된 정보" 등 직접 언급하지 마세요. +` : ''} - 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요. - 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요. - 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요. - 서버, VPS, 클라우드, 호스팅 관련 요청: • 첫 요청: manage_server(action="start_consultation")을 호출하여 상담 시작 • 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요) +- 기술 문제, 에러, 오류, 장애 관련 요청: + • "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시 + • manage_troubleshoot(action="start")를 호출하여 트러블슈팅 시작 + • 트러블슈팅 진행 중인 메시지는 자동으로 전문가 AI에게 전달됨 - 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요. - 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요. - 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요. -- manage_deposit, manage_domain, manage_server, suggest_domains 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.`; +- manage_deposit, manage_domain, manage_server, manage_troubleshoot, suggest_domains 도구 결과는 그대로 전달하세요.`; const recentContext = context.recentMessages.slice(-10).map((m) => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, diff --git a/src/tools/index.ts b/src/tools/index.ts index 0275364..8156bc6 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { searchWebTool, lookupDocsTool, executeSearchWeb, executeLookupDocs } fr import { manageDomainTool, suggestDomainsTool, executeManageDomain, executeSuggestDomains } from './domain-tool'; import { manageDepositTool, executeManageDeposit } from './deposit-tool'; import { manageServerTool, executeManageServer } from './server-tool'; +import { manageTroubleshootTool, executeManageTroubleshoot } from './troubleshoot-tool'; import { getCurrentTimeTool, calculateTool, executeGetCurrentTime, executeCalculate } from './utility-tools'; import { redditSearchTool, executeRedditSearch } from './reddit-tool'; import type { Env } from '../types'; @@ -78,6 +79,10 @@ const ManageServerArgsSchema = z.object({ message: z.string().min(1).max(500).optional(), // For continue_consultation }); +const ManageTroubleshootArgsSchema = z.object({ + action: z.enum(['start', 'cancel']), +}); + // All tools array (used by OpenAI API) export const tools = [ weatherTool, @@ -88,6 +93,7 @@ export const tools = [ manageDomainTool, manageDepositTool, manageServerTool, + manageTroubleshootTool, suggestDomainsTool, redditSearchTool, ]; @@ -97,6 +103,7 @@ export const TOOL_CATEGORIES: Record = { domain: [manageDomainTool.function.name, suggestDomainsTool.function.name], deposit: [manageDepositTool.function.name], server: [manageServerTool.function.name], + troubleshoot: [manageTroubleshootTool.function.name], weather: [weatherTool.function.name], search: [searchWebTool.function.name, lookupDocsTool.function.name], reddit: [redditSearchTool.function.name], @@ -108,6 +115,7 @@ export const CATEGORY_PATTERNS: Record = { domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i, deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i, server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i, + troubleshoot: /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i, weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i, search: /검색|찾아|뭐야|뉴스|최신/i, reddit: /레딧|reddit|서브레딧|subreddit/i, @@ -124,7 +132,7 @@ export function selectToolsForMessage(message: string): typeof tools { } // 패턴 매칭 없으면 전체 도구 사용 (폴백) - if (selectedCategories.size === 1) { + if (selectedCategories.size === 1) { // utility만 있으면 폴백 logger.info('패턴 매칭 없음 → 전체 도구 사용'); return tools; } @@ -244,6 +252,15 @@ export async function executeTool( return executeRedditSearch(result.data, env); } + case 'manage_troubleshoot': { + const result = ManageTroubleshootArgsSchema.safeParse(args); + if (!result.success) { + logger.error('Invalid troubleshoot args', new Error(result.error.message), { args }); + return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; + } + return executeManageTroubleshoot(result.data, env, telegramUserId); + } + default: return `알 수 없는 도구: ${name}`; } diff --git a/src/tools/memory-tool.ts b/src/tools/memory-tool.ts new file mode 100644 index 0000000..d268c1f --- /dev/null +++ b/src/tools/memory-tool.ts @@ -0,0 +1,140 @@ +import type { Env, ManageMemoryArgs } from '../types'; +import { + saveMemory, + getMemories, + deleteMemory, + deleteMemoryByContent, +} from '../services/memory-service'; +import { createLogger, maskUserId } from '../utils/logger'; + +const logger = createLogger('memory-tool'); + +export const manageMemoryTool = { + type: 'function', + function: { + name: 'manage_memory', + description: + '사용자 기억 조회/삭제 전용. list: 사용자가 "내 기억", "뭘 기억해?" 요청 시. delete: 사용자가 "잊어줘" 요청 시. save는 시스템이 자동 처리하므로 호출하지 마세요.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['save', 'list', 'delete'], + description: 'save=저장, list=조회, delete=삭제', + }, + content: { + type: 'string', + description: '저장할 내용 또는 삭제할 내용 (검색어)', + }, + memory_id: { + type: 'number', + description: '삭제할 기억의 ID (선택)', + }, + }, + required: ['action'], + }, + }, +}; + +/** + * 기억 관리 도구 실행 + */ +export async function executeManageMemory( + args: ManageMemoryArgs, + _env?: Env, + telegramUserId?: string, + db?: D1Database +): Promise { + const { action, content, memory_id } = args; + logger.info('시작', { + action, + hasContent: !!content, + memoryId: memory_id, + userId: maskUserId(telegramUserId), + }); + + if (!telegramUserId || !db) { + return '🚫 기억 기능을 사용할 수 없습니다.'; + } + + // 사용자 조회 + const user = await db + .prepare('SELECT id FROM users WHERE telegram_id = ?') + .bind(telegramUserId) + .first<{ id: number }>(); + + if (!user) { + return '🚫 사용자 정보를 찾을 수 없습니다.'; + } + + const userId = user.id; + + try { + switch (action) { + case 'save': { + if (!content) { + return '🚫 저장할 내용을 입력해주세요.'; + } + + await saveMemory(db, userId, content); + // 자동 저장 시 AI가 이 결과를 사용자에게 전달하지 않도록 내부 마커 사용 + return '[SAVED]'; + } + + case 'list': { + const memories = await getMemories(db, userId); + + if (memories.length === 0) { + return '📋 저장된 기억이 없습니다.'; + } + + const memoryList = memories + .map((m, index) => `${index + 1}. ${m.content} (ID: ${m.id})`) + .join('\n'); + + return `📋 저장된 기억 (${memories.length}개)\n\n${memoryList}`; + } + + case 'delete': { + // ID로 삭제 + if (memory_id) { + const deletedCount = await deleteMemory(db, userId, memory_id); + + if (deletedCount === 0) { + return `🚫 ID ${memory_id}에 해당하는 기억을 찾을 수 없습니다.`; + } + + return `✅ 기억을 삭제했습니다 (ID: ${memory_id})`; + } + + // 내용 검색으로 삭제 + if (content) { + const deletedContents = await deleteMemoryByContent( + db, + userId, + content + ); + + if (deletedContents.length === 0) { + return `🚫 "${content}"와 일치하는 기억이 없습니다.`; + } + + const deletedList = deletedContents + .map((c, i) => `${i + 1}. ${c}`) + .join('\n'); + + return `✅ ${deletedContents.length}개의 기억을 삭제했습니다:\n\n${deletedList}`; + } + + return '🚫 삭제할 기억의 ID 또는 검색어를 입력해주세요.'; + } + + default: + return `🚫 알 수 없는 작업: ${action}`; + } + } catch (error) { + logger.error('기억 처리 오류', error as Error, { action }); + return '🚫 기억 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } +} diff --git a/src/tools/troubleshoot-tool.ts b/src/tools/troubleshoot-tool.ts new file mode 100644 index 0000000..9883591 --- /dev/null +++ b/src/tools/troubleshoot-tool.ts @@ -0,0 +1,75 @@ +import type { Env } from '../types'; +import { createLogger } from '../utils/logger'; + +const logger = createLogger('troubleshoot-tool'); + +export const manageTroubleshootTool = { + type: 'function', + function: { + name: 'manage_troubleshoot', + description: '기술 문제 해결 도우미. 서버 에러, 도메인 문제, 배포 실패, 네트워크 오류, 데이터베이스 이슈 등을 진단하고 해결합니다. 사용자가 "에러", "문제", "안돼", "느려", "오류" 등을 언급하면 이 도구를 사용하세요.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['start', 'cancel'], + description: 'start=문제 해결 시작, cancel=세션 취소', + }, + }, + required: ['action'], + }, + }, +}; + +export async function executeManageTroubleshoot( + args: { action: 'start' | 'cancel' }, + env?: Env, + telegramUserId?: string +): Promise { + const { action } = args; + + logger.info('트러블슈팅 도구 호출', { action, userId: telegramUserId }); + + if (!env?.SESSION_KV || !telegramUserId) { + return '🚫 트러블슈팅 기능을 사용할 수 없습니다.'; + } + + const { getTroubleshootSession, saveTroubleshootSession, deleteTroubleshootSession } = await import('../troubleshoot-agent'); + + if (action === 'cancel') { + await deleteTroubleshootSession(env.SESSION_KV, telegramUserId); + return '✅ 트러블슈팅 세션이 취소되었습니다.'; + } + + // action === 'start' + const existingSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId); + + if (existingSession && existingSession.status !== 'completed') { + return '이미 진행 중인 트러블슈팅 세션이 있습니다. 계속 진행해주세요.\n\n현재까지 파악된 정보:\n' + + (existingSession.collectedInfo.category ? `• 분류: ${existingSession.collectedInfo.category}\n` : '') + + (existingSession.collectedInfo.symptoms ? `• 증상: ${existingSession.collectedInfo.symptoms}\n` : ''); + } + + // Create new session + const newSession = { + telegramUserId, + status: 'gathering' as const, + collectedInfo: {}, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + await saveTroubleshootSession(env.SESSION_KV, telegramUserId, newSession); + + logger.info('트러블슈팅 세션 시작', { userId: telegramUserId }); + + return '__DIRECT__🔧 기술 문제 해결 도우미입니다.\n\n' + + '어떤 문제가 발생했나요? 최대한 자세히 설명해주세요.\n\n' + + '💡 예시:\n' + + '• "서버가 502 에러를 반환해요"\n' + + '• "도메인이 연결이 안돼요"\n' + + '• "배포가 실패해요"\n' + + '• "데이터베이스 연결이 느려요"'; +} diff --git a/src/troubleshoot-agent.ts b/src/troubleshoot-agent.ts new file mode 100644 index 0000000..f7ee4b6 --- /dev/null +++ b/src/troubleshoot-agent.ts @@ -0,0 +1,456 @@ +/** + * Troubleshoot Agent - 시스템 트러블슈팅 전문가 + * + * 기능: + * - 대화형 문제 진단 및 해결 + * - 세션 기반 정보 수집 + * - 카테고리별 전문 솔루션 제공 + * - Brave Search / Context7 도구로 최신 해결책 검색 + * + * Manual Test: + * 1. User: "서버가 502 에러가 나요" + * 2. Expected: Category detection → 1-2 questions → Solution + * 3. User: "해결됐어요" + * 4. Expected: Session deleted + */ + +import type { Env, TroubleshootSession } from './types'; +import { createLogger } from './utils/logger'; +import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; + +const logger = createLogger('troubleshoot-agent'); + +// KV Session Management +const SESSION_TTL = 3600; // 1 hour +const SESSION_KEY_PREFIX = 'troubleshoot_session:'; + +export async function getTroubleshootSession( + kv: KVNamespace, + userId: string +): Promise { + try { + const key = `${SESSION_KEY_PREFIX}${userId}`; + logger.info('세션 조회 시도', { userId, key }); + const data = await kv.get(key, 'json'); + + if (!data) { + logger.info('세션 없음', { userId, key }); + return null; + } + + logger.info('세션 조회 성공', { userId, key, status: (data as TroubleshootSession).status }); + return data as TroubleshootSession; + } catch (error) { + logger.error('세션 조회 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); + return null; + } +} + +export async function saveTroubleshootSession( + kv: KVNamespace, + userId: string, + session: TroubleshootSession +): Promise { + try { + const key = `${SESSION_KEY_PREFIX}${userId}`; + session.updatedAt = Date.now(); + + const sessionData = JSON.stringify(session); + logger.info('세션 저장 시도', { userId, key, status: session.status, dataLength: sessionData.length }); + + await kv.put(key, sessionData, { + expirationTtl: SESSION_TTL, + }); + + logger.info('세션 저장 성공', { userId, key, status: session.status }); + } catch (error) { + logger.error('세션 저장 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` }); + throw error; + } +} + +export async function deleteTroubleshootSession( + kv: KVNamespace, + userId: string +): Promise { + try { + const key = `${SESSION_KEY_PREFIX}${userId}`; + await kv.delete(key); + logger.info('세션 삭제 성공', { userId }); + } catch (error) { + logger.error('세션 삭제 실패', error as Error, { userId }); + throw error; + } +} + +// Troubleshoot Expert AI Tools +const troubleshootTools = [ + { + type: 'function' as const, + function: { + name: 'search_solution', + description: 'Brave Search로 에러 메시지, 해결책, Stack Overflow 답변 검색합니다. 예: "nginx 502 bad gateway solution", "mysql connection pool exhausted fix"', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '검색 쿼리 (에러 메시지, 기술 스택 포함, 영문 권장)', + }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'lookup_docs', + description: '프레임워크/라이브러리 공식 문서에서 트러블슈팅 가이드, 에러 코드, 디버깅 방법을 조회합니다.', + parameters: { + type: 'object', + properties: { + library: { + type: 'string', + description: '라이브러리/프레임워크 이름 (예: nginx, nodejs, mysql, docker)', + }, + topic: { + type: 'string', + description: '조회할 주제 (예: troubleshooting, error codes, debugging, common issues)', + }, + }, + required: ['library', 'topic'], + }, + }, + }, +]; + +// Execute troubleshoot tool +async function executeTroubleshootTool( + toolName: string, + args: Record, + env: Env +): Promise { + logger.info('도구 실행', { toolName, args }); + + switch (toolName) { + case 'search_solution': { + const result = await executeSearchWeb({ query: args.query as string }, env); + return result; + } + case 'lookup_docs': { + const result = await executeLookupDocs({ + library: args.library as string, + query: args.topic as string, + }, env); + return result; + } + default: + return `알 수 없는 도구: ${toolName}`; + } +} + +// 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 callTroubleshootExpertAI( + env: Env, + session: TroubleshootSession, + userMessage: string +): Promise<{ action: 'question' | 'diagnose' | 'solve'; message: string; collectedInfo: TroubleshootSession['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 systemPrompt = `당신은 20년 경력의 시니어 DevOps/SRE 엔지니어입니다. + +## 전문성 (20년 경력) +- Linux/Windows Server 관리 마스터 (Apache, Nginx, IIS 등) +- 네트워크 트러블슈팅 전문가 (DNS, SSL, 방화벽, CDN) +- 애플리케이션 배포 및 디버깅 경험 수천 건 +- 모든 주요 기술 스택 경험 (Node.js, Python, PHP, Java, Ruby, Go 등) +- 데이터베이스 성능 튜닝 (MySQL, PostgreSQL, MongoDB, Redis) +- 컨테이너/오케스트레이션 (Docker, Kubernetes) +- 클라우드 인프라 마스터 (Anvil 전문) + +## 성격 +- 차분하고 체계적인 문제 해결 +- 비기술자도 이해하기 쉽게 설명 +- 단계별로 명확한 지시 +- 긴급 상황에서도 침착함 유지 +- 따뜻하고 격려하는 어조 + +## 금지 사항 (절대 위반 금지) +- AWS, GCP, Azure, DigitalOcean, Vultr, Linode 등 다른 클라우드 서비스 추천 금지 +- 경쟁사 서비스 언급 금지 +- 우리 서비스(Anvil)만 언급 + +## 문제 카테고리 +1. **Server/Infrastructure**: 서버 느림, 502/503 에러, 메모리 부족, CPU 과부하, 디스크 풀 +2. **Domain/DNS**: 도메인 연결 안됨, SSL 인증서 오류, DNS 전파 지연, 네임서버 문제 +3. **Code/Deploy**: 배포 실패, 빌드 에러, 의존성 충돌, 환경변수 누락 +4. **Network**: 연결 끊김, 타임아웃, CORS 오류, 방화벽 차단 +5. **Database**: 쿼리 느림, 연결 풀 고갈, 데드락, 인덱스 누락 + +## 도구 사용 가이드 (적극 활용) +- 에러 메시지, Stack trace 언급 시 → **반드시** search_solution 호출 +- 특정 프레임워크/라이브러리 문제 → lookup_docs 호출하여 공식 가이드 확인 +- 도구 결과를 자연스럽게 해결책에 포함 (예: "공식 문서에 따르면...", "최근 Stack Overflow 답변을 보니...") +- 검색 쿼리는 영문으로 (더 많은 결과) + +## 대화 흐름 +1. **문제 청취**: 사용자 증상 경청, 카테고리 자동 분류 +2. **정보 수집**: 1-2개 질문으로 환경/에러 메시지 확인 +3. **진단**: 수집된 정보 기반 원인 분석 (도구 활용) +4. **해결**: 단계별 명확한 솔루션 제시 (명령어 포함) +5. **확인**: 해결 여부 확인, 필요시 추가 지원 + +## 핵심 규칙 (반드시 준수) +- 에러 메시지가 명확하면 즉시 action="diagnose" 또는 "solve"로 진단/해결 제시 +- 정보가 애매하면 action="question"으로 최대 2개 질문 +- 해결책은 구체적이고 실행 가능한 명령어/코드 포함 +- 해결 후 "해결되셨나요?" 확인 +- 해결 안되면 추가 조치 또는 상위 엔지니어 에스컬레이션 제안 + +## 현재 수집된 정보 +${JSON.stringify(session.collectedInfo, null, 2)} + +## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지) +{ + "action": "question" | "diagnose" | "solve", + "message": "사용자에게 보여줄 메시지 (도구 결과 자연스럽게 포함)", + "collectedInfo": { + "category": "카테고리 (자동 분류)", + "symptoms": "증상 요약", + "environment": "환경 정보 (OS, 프레임워크 등)", + "errorMessage": "에러 메시지 (있는 경우)" + } +} + +action 선택 기준: +- "question": 정보가 부족하여 추가 질문 필요 (최대 2회) +- "diagnose": 정보 충분, 원인 분석 제시 +- "solve": 즉시 해결책 제시 가능 + +중요: 20년 경험으로 일반적인 문제는 즉시 solve 가능합니다.`; + + try { + // Messages array that we'll build up with tool results + const messages: Array<{ role: string; content: string | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; name?: string }> = [ + { role: 'system', content: systemPrompt }, + ...conversationHistory, + { role: 'user', content: userMessage }, + ]; + + const MAX_TOOL_CALLS = 3; + let toolCallCount = 0; + + // Loop to handle tool calls + while (toolCallCount < MAX_TOOL_CALLS) { + const requestBody = { + model: 'gpt-4o-mini', + messages, + tools: troubleshootTools, + tool_choice: 'auto', + response_format: { type: 'json_object' }, + max_tokens: 1500, + temperature: 0.5, + }; + + 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) { + const args = JSON.parse(toolCall.function.arguments); + const result = await executeTroubleshootTool(toolCall.function.name, args, env); + + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + name: toolCall.function.name, + content: result, + }); + + toolCallCount++; + } + + // Continue loop to get AI's response with tool results + continue; + } + + // No tool calls - parse the final response + const aiResponse = assistantMessage.content || ''; + logger.info('AI 응답', { response: aiResponse.slice(0, 200), toolCallCount }); + + // JSON 파싱 (마크다운 코드 블록 제거) + const jsonMatch = aiResponse.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) || + aiResponse.match(/(\{[\s\S]*\})/); + + if (!jsonMatch) { + logger.error('JSON 파싱 실패', new Error('No JSON found'), { response: aiResponse }); + throw new Error('AI 응답 형식 오류'); + } + + const parsed = JSON.parse(jsonMatch[1]); + + // Validate response structure + if (!parsed.action || !parsed.message) { + throw new Error('Invalid AI response structure'); + } + + return { + action: parsed.action, + message: parsed.message, + collectedInfo: parsed.collectedInfo || session.collectedInfo, + }; + } + + // Max tool calls reached, force a solve + logger.warn('최대 도구 호출 횟수 도달', { toolCallCount }); + return { + action: 'solve', + message: '수집한 정보를 바탕으로 해결책을 제시해드리겠습니다.', + collectedInfo: session.collectedInfo, + }; + } catch (error) { + logger.error('Troubleshoot Expert AI 호출 실패', error as Error); + throw error; + } +} + +// Main troubleshooting processing +export async function processTroubleshoot( + userMessage: string, + session: TroubleshootSession, + env: Env +): Promise { + try { + logger.info('트러블슈팅 처리 시작', { + userId: session.telegramUserId, + message: userMessage.slice(0, 50), + status: session.status + }); + + // 취소 키워드 처리 (모든 상태에서 작동) + if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) || + /취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) { + await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId); + logger.info('사용자 요청으로 트러블슈팅 취소', { + userId: session.telegramUserId, + previousStatus: session.status, + trigger: userMessage.slice(0, 20) + }); + return '트러블슈팅이 취소되었습니다. 다시 시작하려면 문제를 말씀해주세요.'; + } + + // 해결 완료 키워드 + if (/해결[됐됨했함]|고마워|감사|끝|완료/.test(userMessage)) { + await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId); + logger.info('문제 해결 완료', { + userId: session.telegramUserId, + category: session.collectedInfo.category + }); + return '✅ 문제가 해결되어 다행입니다! 앞으로도 언제든 도움이 필요하시면 말씀해주세요. 😊'; + } + + // 상담과 무관한 키워드 감지 (passthrough) + const unrelatedPatterns = /서버\s*추천|날씨|계산|도메인\s*추천|입금|충전|잔액|기억|저장/; + if (unrelatedPatterns.test(userMessage)) { + await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId); + logger.info('무관한 요청으로 세션 자동 종료', { + userId: session.telegramUserId, + message: userMessage.slice(0, 30) + }); + return '__PASSTHROUGH__'; + } + + // Add user message to history + session.messages.push({ role: 'user', content: userMessage }); + + // Call Troubleshoot Expert AI + const aiResult = await callTroubleshootExpertAI(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 }); + + // Update session status based on action + if (aiResult.action === 'diagnose') { + session.status = 'diagnosing'; + } else if (aiResult.action === 'solve') { + session.status = 'solving'; + } else { + session.status = 'gathering'; + } + + await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session); + + return aiResult.message; + } catch (error) { + logger.error('트러블슈팅 처리 실패', error as Error, { userId: session.telegramUserId }); + + // Clean up session on error + await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId); + + return '죄송합니다. 트러블슈팅 중 오류가 발생했습니다.\n다시 시도하려면 문제를 말씀해주세요.'; + } +} diff --git a/src/types.ts b/src/types.ts index 18e756c..091c002 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,13 @@ export interface ConversationContext { totalMessages: number; } +export interface Memory { + id: number; + user_id: number; + content: string; + created_at: string; +} + // Cloudflare Email Workers 타입 export interface EmailMessage { from: string; @@ -221,6 +228,12 @@ export interface ManageServerArgs { message?: string; // For continue_consultation } +export interface ManageMemoryArgs { + action: "save" | "list" | "delete"; + content?: string; + memory_id?: number; +} + // Server Consultation Session export interface ServerSession { telegramUserId: string; @@ -258,6 +271,21 @@ export interface ServerSession { }; } +// Troubleshooting Session +export interface TroubleshootSession { + telegramUserId: string; + status: 'gathering' | 'diagnosing' | 'solving' | 'completed'; + collectedInfo: { + category?: string; // Server/Infrastructure, Domain/DNS, Code/Deploy, Network, Database + symptoms?: string; // 증상 요약 + environment?: string; // OS, 프레임워크, 버전 등 + errorMessage?: string; // 에러 메시지 + }; + messages: Array<{ role: 'user' | 'assistant'; content: string }>; + createdAt: number; + updatedAt: number; +} + // Deposit Agent 결과 타입 export interface DepositBalanceResult { balance: number; diff --git a/wrangler.toml b/wrangler.toml index ce2a473..3a9dc0a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,8 +7,8 @@ binding = "AI" [vars] ENVIRONMENT = "production" -SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수) -MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우) +SUMMARY_THRESHOLD = "50" # 프로필 업데이트 주기 (메시지 수) +MAX_SUMMARIES_PER_USER = "1" # 유지할 프로필 버전 수 N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택) # Admin IDs moved to secrets (see bottom of file)