# 대화 저장 시스템 구현 계획 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** 요약 기반 대화 저장을 전체 대화 기록 시스템으로 전환 **Architecture:** 메타 테이블(conversation_tables) + 사용자별 동적 테이블(conv_{telegram_id}) + 6개월 아카이브 **Tech Stack:** Cloudflare Workers, D1 SQLite, TypeScript, OpenAI GPT-4o-mini --- ## Task 1: 타입 정의 추가 **Files:** - Modify: `src/types.ts` **Step 1: ConversationMessage 타입 추가** `src/types.ts` 파일 끝에 추가: ```typescript // ============================================ // Conversation Storage Types // ============================================ export interface ConversationMessage { id?: number; role: 'user' | 'assistant'; content: string; tool_calls?: string; // JSON: [{name, arguments, id}] tool_results?: string; // JSON: [{tool_call_id, result}] created_at?: string; } export interface ConversationTableMeta { telegram_id: string; table_name: string; message_count: number; last_message_at: string | null; created_at: string; } export interface ConversationStats { telegram_id: string; message_count: number; first_message_at: string | null; last_message_at: string | null; archived_summaries: number; } export interface ArchiveResult { processed_users: number; archived_messages: number; created_summaries: number; errors: string[]; } ``` **Step 2: 테스트 (타입 체크)** Run: `npm run typecheck` Expected: PASS (no type errors) **Step 3: Commit** ```bash git add src/types.ts git commit -m "feat: add conversation storage types" ``` --- ## Task 2: 마이그레이션 SQL 작성 **Files:** - Create: `migrations/008_conversation_tables.sql` **Step 1: 마이그레이션 파일 생성** ```sql -- 대화 테이블 메타 정보 CREATE TABLE IF NOT EXISTS conversation_tables ( telegram_id TEXT PRIMARY KEY, table_name TEXT NOT NULL, message_count INTEGER DEFAULT 0, last_message_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 인덱스 CREATE INDEX IF NOT EXISTS idx_conv_tables_last_msg ON conversation_tables(last_message_at DESC); CREATE INDEX IF NOT EXISTS idx_conv_tables_count ON conversation_tables(message_count DESC); ``` **Step 2: 로컬 테스트** Run: `wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql` Expected: 성공 메시지 **Step 3: Commit** ```bash git add migrations/008_conversation_tables.sql git commit -m "feat: add conversation_tables migration" ``` --- ## Task 3: 대화 저장 서비스 구현 **Files:** - Create: `src/services/conversation-storage.ts` **Step 1: 기본 구조 작성** ```typescript import type { Env, ConversationMessage, ConversationTableMeta, ConversationStats } from '../types'; import { createLogger } from '../utils/logger'; const logger = createLogger('conversation-storage'); // 불용어 목록 (키워드 추출 시 제외) const STOP_WORDS = new Set([ '은', '는', '이', '가', '을', '를', '에', '도', '로', '의', '와', '과', '에서', '으로', '하고', '해서', '했어', '할게', '할까', '해줘', '해주세요', '좀', '그', '저', '이거', '저거', '그거', '뭐', '어떻게', '왜', '언제', '네', '예', '아니', '응', '음', '아', '오', 'ㅇㅇ', 'ㄴㄴ' ]); /** * 사용자별 대화 테이블 존재 확인 및 생성 */ export async function ensureConversationTable( db: D1Database, telegramId: string ): Promise { const tableName = `conv_${telegramId}`; // 1. 메타 테이블에서 확인 const existing = await db .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') .bind(telegramId) .first<{ table_name: string }>(); if (existing) { return existing.table_name; } // 2. 테이블 생성 (raw SQL - DDL은 prepared statement 불가) await db.exec(` CREATE TABLE IF NOT EXISTS ${tableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), content TEXT NOT NULL, tool_calls TEXT, tool_results TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); // 3. 인덱스 생성 await db.exec(` CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC) `); // 4. 메타 테이블에 등록 await db .prepare('INSERT INTO conversation_tables (telegram_id, table_name) VALUES (?, ?)') .bind(telegramId, tableName) .run(); logger.info('대화 테이블 생성', { telegramId, tableName }); return tableName; } /** * 메시지 저장 */ export async function saveConversationMessage( db: D1Database, telegramId: string, message: Omit ): Promise { const tableName = await ensureConversationTable(db, telegramId); // 1. 메시지 저장 await db .prepare(`INSERT INTO ${tableName} (role, content, tool_calls, tool_results) VALUES (?, ?, ?, ?)`) .bind( message.role, message.content, message.tool_calls || null, message.tool_results || null ) .run(); // 2. 메타 테이블 업데이트 await db .prepare(` UPDATE conversation_tables SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE telegram_id = ? `) .bind(telegramId) .run(); } /** * 최근 메시지 조회 */ export async function getRecentConversations( db: D1Database, telegramId: string, limit: number = 20 ): Promise { // 테이블 존재 확인 const meta = await db .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') .bind(telegramId) .first<{ table_name: string }>(); if (!meta) { return []; } const { results } = await db .prepare(` SELECT id, role, content, tool_calls, tool_results, created_at FROM ${meta.table_name} ORDER BY created_at DESC LIMIT ? `) .bind(limit) .all(); // 시간순 정렬 (오래된 것 → 최신) return (results || []).reverse(); } /** * 키워드로 관련 메시지 검색 */ export async function searchConversations( db: D1Database, telegramId: string, keywords: string[], limit: number = 10 ): Promise { const meta = await db .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') .bind(telegramId) .first<{ table_name: string }>(); if (!meta || keywords.length === 0) { return []; } // LIKE 쿼리 생성 (OR 조건) const likeConditions = keywords.map(() => `content LIKE ?`).join(' OR '); const likeValues = keywords.map(k => `%${k}%`); const { results } = await db .prepare(` SELECT id, role, content, tool_calls, tool_results, created_at FROM ${meta.table_name} WHERE ${likeConditions} ORDER BY created_at DESC LIMIT ? `) .bind(...likeValues, limit) .all(); return results || []; } /** * 메시지에서 키워드 추출 */ export function extractKeywords(message: string): string[] { // 1. 특수문자 제거, 공백으로 분리 const words = message .replace(/[^\w\s가-힣]/g, ' ') .split(/\s+/) .filter(w => w.length >= 2); // 2. 불용어 제거 const filtered = words.filter(w => !STOP_WORDS.has(w)); // 3. 중복 제거 후 최대 5개 const unique = [...new Set(filtered)]; return unique.slice(0, 5); } /** * 스마트 컨텍스트 조회 (최근 20개 + 관련 10개) */ export async function getSmartContext( db: D1Database, telegramId: string, currentMessage: string ): Promise { // 1. 최근 20개 조회 const recent = await getRecentConversations(db, telegramId, 20); // 2. 키워드 추출 const keywords = extractKeywords(currentMessage); if (keywords.length === 0) { return recent; } // 3. 관련 대화 검색 (최근 20개 제외) const recentIds = new Set(recent.map(m => m.id)); const related = await searchConversations(db, telegramId, keywords, 20); const filteredRelated = related .filter(m => !recentIds.has(m.id)) .slice(0, 10); // 4. 병합 및 시간순 정렬 const combined = [...filteredRelated, ...recent]; combined.sort((a, b) => { const timeA = new Date(a.created_at || 0).getTime(); const timeB = new Date(b.created_at || 0).getTime(); return timeA - timeB; }); return combined; } /** * 대화 통계 조회 */ export async function getConversationStats( db: D1Database, telegramId: string ): Promise { const meta = await db .prepare('SELECT * FROM conversation_tables WHERE telegram_id = ?') .bind(telegramId) .first(); if (!meta) { return null; } // 첫 메시지 시간 조회 const firstMsg = await db .prepare(`SELECT MIN(created_at) as first_at FROM ${meta.table_name}`) .first<{ first_at: string | null }>(); // 아카이브된 요약 수 const archiveCount = await db .prepare('SELECT COUNT(*) as cnt FROM summaries WHERE user_id = (SELECT id FROM users WHERE telegram_id = ?)') .bind(telegramId) .first<{ cnt: number }>(); return { telegram_id: telegramId, message_count: meta.message_count, first_message_at: firstMsg?.first_at || null, last_message_at: meta.last_message_at, archived_summaries: archiveCount?.cnt || 0, }; } /** * 대화 히스토리 조회 (명령어용) */ export async function getConversationHistory( db: D1Database, telegramId: string, limit: number = 20, offset: number = 0 ): Promise { const meta = await db .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') .bind(telegramId) .first<{ table_name: string }>(); if (!meta) { return []; } const { results } = await db .prepare(` SELECT id, role, content, tool_calls, tool_results, created_at FROM ${meta.table_name} ORDER BY created_at DESC LIMIT ? OFFSET ? `) .bind(limit, offset) .all(); return (results || []).reverse(); } ``` **Step 2: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 3: Commit** ```bash git add src/services/conversation-storage.ts git commit -m "feat: implement conversation storage service" ``` --- ## Task 4: 아카이브 서비스 구현 **Files:** - Create: `src/services/archive-service.ts` **Step 1: 아카이브 서비스 작성** ```typescript import type { Env, ConversationMessage, ArchiveResult } from '../types'; import { createLogger } from '../utils/logger'; const logger = createLogger('archive-service'); const ARCHIVE_DAYS = 180; // 6개월 const BATCH_SIZE = 100; // 요약당 메시지 수 /** * 메시지 배열을 AI 요약으로 변환 */ async function generateArchiveSummary( env: Env, messages: ConversationMessage[], startDate: string, endDate: string ): Promise { // 사용자 메시지만 추출 const userMessages = messages .filter(m => m.role === 'user') .map(m => `- ${m.content}`) .join('\n'); const prompt = `당신은 대화 아카이브 전문가입니다. 아래 기간의 사용자 발언을 분석하여 핵심 정보를 요약하세요. ## 기간 ${startDate} ~ ${endDate} ## 사용자 발언 (${messages.filter(m => m.role === 'user').length}개) ${userMessages} ## 요구사항 1. 주요 관심사, 요청사항, 질문 주제를 파악 2. 중요한 맥락 정보 (직업, 프로젝트, 목표 등) 보존 3. 무의미한 내용 (인사, 단순 확인) 제외 4. 300-400자 이내로 작성 5. 한국어로 작성 아카이브 요약:`; if (env.OPENAI_API_KEY) { const { generateProfileWithOpenAI } = await import('../openai-service'); const summary = await generateProfileWithOpenAI(env, prompt); return `[${startDate} ~ ${endDate} 아카이브] ${summary}`; } // 폴백: 단순 요약 return `[${startDate} ~ ${endDate} 아카이브] 이 기간 동안 ${messages.length}개의 대화가 있었습니다.`; } /** * 특정 사용자의 오래된 대화 아카이브 */ export async function archiveUserConversations( env: Env, telegramId: string, tableName: string, olderThanDays: number = ARCHIVE_DAYS ): Promise<{ archived: number; summaries: number }> { const db = env.DB; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); const cutoffStr = cutoffDate.toISOString(); // 1. 아카이브 대상 메시지 조회 const { results: oldMessages } = await db .prepare(` SELECT id, role, content, tool_calls, tool_results, created_at FROM ${tableName} WHERE created_at < ? ORDER BY created_at ASC `) .bind(cutoffStr) .all(); if (!oldMessages || oldMessages.length === 0) { return { archived: 0, summaries: 0 }; } logger.info('아카이브 대상', { telegramId, count: oldMessages.length }); // 2. 사용자 ID 조회 const user = await db .prepare('SELECT id FROM users WHERE telegram_id = ?') .bind(telegramId) .first<{ id: number }>(); if (!user) { logger.warn('사용자 없음', { telegramId }); return { archived: 0, summaries: 0 }; } // 3. BATCH_SIZE 단위로 요약 생성 let summariesCreated = 0; const messageIds: number[] = []; for (let i = 0; i < oldMessages.length; i += BATCH_SIZE) { const batch = oldMessages.slice(i, i + BATCH_SIZE); const startDate = batch[0].created_at?.split('T')[0] || 'unknown'; const endDate = batch[batch.length - 1].created_at?.split('T')[0] || 'unknown'; // 요약 생성 const summary = await generateArchiveSummary(env, batch, startDate, endDate); // 최신 generation 조회 const latestGen = await db .prepare('SELECT MAX(generation) as gen FROM summaries WHERE user_id = ?') .bind(user.id) .first<{ gen: number | null }>(); const newGeneration = (latestGen?.gen || 0) + 1; // summaries 테이블에 저장 await db .prepare(` INSERT INTO summaries (user_id, chat_id, generation, summary, message_count) VALUES (?, ?, ?, ?, ?) `) .bind(user.id, telegramId, newGeneration, summary, batch.length) .run(); summariesCreated++; batch.forEach(m => m.id && messageIds.push(m.id)); } // 4. 원본 메시지 삭제 if (messageIds.length > 0) { // SQLite는 IN 절에 많은 파라미터 바인딩이 어려우므로 청크 단위 삭제 const chunkSize = 100; for (let i = 0; i < messageIds.length; i += chunkSize) { const chunk = messageIds.slice(i, i + chunkSize); const placeholders = chunk.map(() => '?').join(','); await db .prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders})`) .bind(...chunk) .run(); } } // 5. 메타 테이블 업데이트 await db .prepare('UPDATE conversation_tables SET message_count = message_count - ? WHERE telegram_id = ?') .bind(messageIds.length, telegramId) .run(); logger.info('아카이브 완료', { telegramId, archived: messageIds.length, summaries: summariesCreated }); return { archived: messageIds.length, summaries: summariesCreated }; } /** * 전체 사용자 아카이브 (Cron용) */ export async function archiveOldConversations(env: Env): Promise { const db = env.DB; const result: ArchiveResult = { processed_users: 0, archived_messages: 0, created_summaries: 0, errors: [], }; // 1. 모든 대화 테이블 조회 const { results: tables } = await db .prepare('SELECT telegram_id, table_name FROM conversation_tables') .all<{ telegram_id: string; table_name: string }>(); if (!tables || tables.length === 0) { logger.info('아카이브 대상 없음'); return result; } // 2. 각 사용자별 아카이브 실행 for (const table of tables) { try { const { archived, summaries } = await archiveUserConversations( env, table.telegram_id, table.table_name ); if (archived > 0) { result.processed_users++; result.archived_messages += archived; result.created_summaries += summaries; } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); result.errors.push(`${table.telegram_id}: ${errMsg}`); logger.error('사용자 아카이브 실패', error as Error, { telegramId: table.telegram_id }); } } logger.info('전체 아카이브 완료', result); return result; } ``` **Step 2: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 3: Commit** ```bash git add src/services/archive-service.ts git commit -m "feat: implement archive service for old conversations" ``` --- ## Task 5: summary-service.ts 수정 **Files:** - Modify: `src/summary-service.ts` **Step 1: getSmartContext 연동** `generateAIResponse` 함수 수정 - `getConversationContext` 대신 `getSmartContext` 사용: ```typescript // 기존 import 유지 + 추가 import { getSmartContext } from './services/conversation-storage'; // generateAIResponse 함수 내부 수정 export async function generateAIResponse( env: Env, userId: number, chatId: string, userMessage: string, telegramUserId?: string ): Promise { // 기존 context 대신 스마트 컨텍스트 사용 const smartMessages = telegramUserId ? await getSmartContext(env.DB, telegramUserId, userMessage) : []; // 아카이브된 요약도 조회 (기존 로직 유지) const summaries = await getAllSummaries(env.DB, userId, chatId); // 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시) const integratedProfile = summaries.length > 0 ? summaries .slice() .reverse() .map((s) => `[v${s.generation}] ${s.summary}`) .join('\n\n') : null; // ... 기존 systemPrompt 생성 로직 유지 ... // recentContext를 스마트 컨텍스트로 교체 const recentContext = smartMessages.slice(-30).map((m) => ({ role: m.role === 'user' ? 'user' as const : 'assistant' as const, content: m.content, })); // ... 나머지 로직 유지 ... } ``` **Step 2: addToBuffer 함수는 유지** (마이그레이션 완료 전까지 호환성) 기존 `addToBuffer`, `getBufferedMessages`, `processAndSummarize` 함수는 삭제하지 않고 유지. 새 시스템과 병행 운영 후 마이그레이션 완료 시 제거. **Step 3: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 4: Commit** ```bash git add src/summary-service.ts git commit -m "feat: integrate smart context in AI response generation" ``` --- ## Task 6: conversation-service.ts 수정 **Files:** - Modify: `src/services/conversation-service.ts` **Step 1: 새 저장 시스템 사용** ```typescript import type { Env, KeyboardData } from '../types'; import { addToBuffer, processAndSummarize, generateAIResponse, } from '../summary-service'; import { saveConversationMessage } from './conversation-storage'; import { sendChatAction } from '../telegram'; import { createLogger } from '../utils/logger'; const logger = createLogger('conversation'); export interface ConversationResult { responseText: string; isProfileUpdated: boolean; keyboardData?: KeyboardData | null; } export class ConversationService { constructor(private env: Env) {} async processUserMessage( userId: number, chatId: string, text: string, telegramUserId: string ): Promise { // 1. 타이핑 액션 전송 sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(err => logger.error('타이핑 액션 전송 실패', err as Error) ); // 2. 새 시스템에 사용자 메시지 저장 await saveConversationMessage(this.env.DB, telegramUserId, { role: 'user', content: text, }); // 3. 기존 버퍼에도 저장 (호환성 - 나중에 제거) await addToBuffer(this.env.DB, userId, chatId, 'user', text); // 4. AI 응답 생성 let responseText = await generateAIResponse( this.env, userId, chatId, text, telegramUserId ); // __DIRECT__ 마커 제거 if (responseText.includes('__DIRECT__')) { const directIndex = responseText.indexOf('__DIRECT__'); responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); } // 5. 새 시스템에 봇 응답 저장 await saveConversationMessage(this.env.DB, telegramUserId, { role: 'assistant', content: responseText, }); // 6. 기존 버퍼에도 저장 (호환성) await addToBuffer(this.env.DB, userId, chatId, 'bot', responseText); // 7. 기존 요약 프로세스 유지 (호환성) const { summarized } = await processAndSummarize( this.env, userId, chatId ); // 8. 키보드 데이터 파싱 let keyboardData: KeyboardData | null = null; const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s); if (keyboardMatch) { logger.debug('키보드 마커 감지', { preview: keyboardMatch[1].substring(0, 100) }); responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, ''); try { keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData; logger.debug('키보드 파싱 성공', { type: keyboardData.type }); } catch (e) { logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] }); } } return { responseText, isProfileUpdated: summarized, keyboardData }; } } ``` **Step 2: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 3: Commit** ```bash git add src/services/conversation-service.ts git commit -m "feat: save messages to new conversation storage" ``` --- ## Task 7: 명령어 추가 (/history, /search) **Files:** - Modify: `src/commands.ts` **Step 1: import 추가** ```typescript import { getConversationHistory, searchConversations, getConversationStats, extractKeywords } from './services/conversation-storage'; ``` **Step 2: /history 명령어 추가** ```typescript case '/history': { const limit = _args ? parseInt(_args, 10) : 20; const validLimit = Math.min(Math.max(limit, 1), 100); // 1-100 제한 const messages = await getConversationHistory(env.DB, chatId, validLimit); if (messages.length === 0) { return '📜 저장된 대화가 없습니다.'; } const formatted = messages.map(m => { const time = m.created_at ? new Date(m.created_at).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; const role = m.role === 'user' ? '나' : '봇'; const content = m.content.length > 50 ? m.content.substring(0, 50) + '...' : m.content; return `[${time}] ${role}: ${content}`; }).join('\n'); return `📜 최근 대화 (${messages.length}개)\n\n${formatted}\n\n/history 50 으로 더 보기`; } case '/search': { if (!_args || _args.trim().length < 2) { return '🔍 검색어를 입력해주세요.\n예: /search 도메인'; } const keywords = extractKeywords(_args); if (keywords.length === 0) { return '🔍 유효한 검색어가 없습니다.'; } const results = await searchConversations(env.DB, chatId, keywords, 15); if (results.length === 0) { return `🔍 "${_args}" 검색 결과가 없습니다.`; } const formatted = results.map(m => { const date = m.created_at ? new Date(m.created_at).toLocaleDateString('ko-KR') : ''; const content = m.content.length > 60 ? m.content.substring(0, 60) + '...' : m.content; return `[${date}] ${content}`; }).join('\n'); return `🔍 "${_args}" 검색 결과 (${results.length}건)\n\n${formatted}`; } ``` **Step 3: /stats 명령어 수정** 기존 `/stats` 케이스를 수정하여 새 통계 포함: ```typescript case '/stats': { const stats = await getConversationStats(env.DB, chatId); if (!stats) { return '📈 아직 대화 기록이 없습니다.'; } const firstDate = stats.first_message_at ? new Date(stats.first_message_at).toLocaleDateString('ko-KR') : '없음'; const lastDate = stats.last_message_at ? new Date(stats.last_message_at).toLocaleDateString('ko-KR') : '없음'; return `📈 대화 통계 총 메시지: ${stats.message_count.toLocaleString()}개 첫 대화: ${firstDate} 최근 대화: ${lastDate} 아카이브된 요약: ${stats.archived_summaries}개 /history - 대화 기록 보기 /search 키워드 - 대화 검색`; } ``` **Step 4: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 5: Commit** ```bash git add src/commands.ts git commit -m "feat: add /history and /search commands" ``` --- ## Task 8: Cron에 아카이브 추가 **Files:** - Modify: `src/index.ts` **Step 1: Cron 핸들러에 아카이브 추가** `index.ts`의 `scheduled` 핸들러 찾아서 아카이브 호출 추가: ```typescript // scheduled 핸들러 내부에 추가 async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { const scheduledLogger = createLogger('scheduled'); // 기존 로직... // UTC 15:00 (KST 00:00) - 아카이브 실행 if (event.cron === '0 15 * * *') { try { const { archiveOldConversations } = await import('./services/archive-service'); const result = await archiveOldConversations(env); scheduledLogger.info('아카이브 완료', result); } catch (error) { scheduledLogger.error('아카이브 실패', error as Error); } } } ``` **Step 2: 타입 체크** Run: `npm run typecheck` Expected: PASS **Step 3: Commit** ```bash git add src/index.ts git commit -m "feat: add conversation archive to daily cron" ``` --- ## Task 9: 기존 데이터 마이그레이션 스크립트 **Files:** - Create: `scripts/migrate-conversations.ts` **Step 1: 마이그레이션 스크립트 작성** ```typescript /** * 기존 message_buffer 데이터를 새 conversation 테이블로 마이그레이션 * * 실행: npx wrangler d1 execute telegram-conversations --local --file=scripts/migrate-conversations.sql * 또는 Worker 내부에서 1회성 실행 */ import type { Env } from '../src/types'; export async function migrateExistingConversations(env: Env): Promise<{ migrated_users: number; migrated_messages: number; errors: string[]; }> { const db = env.DB; const result = { migrated_users: 0, migrated_messages: 0, errors: [] as string[], }; // 1. 기존 message_buffer에서 사용자 목록 조회 const { results: users } = await db .prepare(` SELECT DISTINCT u.telegram_id, mb.user_id FROM message_buffer mb JOIN users u ON mb.user_id = u.id `) .all<{ telegram_id: string; user_id: number }>(); if (!users || users.length === 0) { console.log('마이그레이션 대상 없음'); return result; } // 2. 각 사용자별 마이그레이션 for (const user of users) { try { const tableName = `conv_${user.telegram_id}`; // 테이블 생성 await db.exec(` CREATE TABLE IF NOT EXISTS ${tableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL CHECK(role IN ('user', 'assistant')), content TEXT NOT NULL, tool_calls TEXT, tool_results TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); await db.exec(` CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC) `); // 메시지 복사 const { results: messages } = await db .prepare(` SELECT role, message as content, created_at FROM message_buffer WHERE user_id = ? ORDER BY created_at ASC `) .bind(user.user_id) .all<{ role: string; content: string; created_at: string }>(); if (messages && messages.length > 0) { for (const msg of messages) { const role = msg.role === 'bot' ? 'assistant' : 'user'; await db .prepare(`INSERT INTO ${tableName} (role, content, created_at) VALUES (?, ?, ?)`) .bind(role, msg.content, msg.created_at) .run(); } result.migrated_messages += messages.length; } // 메타 테이블에 등록 await db .prepare(` INSERT OR REPLACE INTO conversation_tables (telegram_id, table_name, message_count, last_message_at) VALUES (?, ?, ?, (SELECT MAX(created_at) FROM ${tableName})) `) .bind(user.telegram_id, tableName, messages?.length || 0) .run(); result.migrated_users++; console.log(`마이그레이션 완료: ${user.telegram_id} (${messages?.length || 0}개)`); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); result.errors.push(`${user.telegram_id}: ${errMsg}`); console.error(`마이그레이션 실패: ${user.telegram_id}`, error); } } console.log('전체 마이그레이션 결과:', result); return result; } ``` **Step 2: Commit** ```bash git add scripts/migrate-conversations.ts git commit -m "feat: add migration script for existing conversations" ``` --- ## Task 10: 테스트 및 배포 **Step 1: 로컬 테스트** ```bash # 1. 마이그레이션 SQL 적용 (로컬) wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql # 2. 로컬 서버 실행 npm run dev # 3. 테스트 메시지 전송 후 /history, /search, /stats 명령어 확인 ``` **Step 2: 타입 체크 & 빌드** ```bash npm run typecheck npm run build # 또는 wrangler deploy --dry-run ``` Expected: PASS **Step 3: 프로덕션 마이그레이션 적용** ```bash wrangler d1 execute telegram-conversations --file=migrations/008_conversation_tables.sql ``` **Step 4: 배포** ```bash npm run deploy ``` **Step 5: Commit (최종)** ```bash git add . git commit -m "feat: complete conversation storage system implementation" ``` --- ## 완료 후 검증 체크리스트 - [ ] `/history` 명령어가 최근 대화를 표시하는가 - [ ] `/search 키워드` 명령어가 관련 대화를 찾는가 - [ ] `/stats` 명령어가 통계를 표시하는가 - [ ] 새 메시지가 `conv_{telegram_id}` 테이블에 저장되는가 - [ ] AI 응답이 스마트 컨텍스트를 사용하는가 - [ ] 기존 기능 (명령어, 도메인, 서버, 예치금)이 정상 동작하는가 --- ## 롤백 계획 1. **코드 롤백**: `conversation-service.ts`에서 `saveConversationMessage` 호출 제거 2. **DB 유지**: 새 테이블들은 독립적이므로 삭제 불필요 3. **기존 시스템**: `message_buffer` + `summaries`가 그대로 작동