From 2a84f12d46bd423877937d747781a44ccbcfccd3 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 5 Feb 2026 12:21:21 +0900 Subject: [PATCH] feat: implement conversation storage service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensureConversationTable(): 사용자별 동적 테이블 생성/확인 - saveConversationMessage(): 메시지 저장 + 메타 테이블 업데이트 - getRecentConversations(): 최근 N개 메시지 조회 (시간순 정렬) - searchConversations(): 키워드 검색 (LIKE OR 조건) - extractKeywords(): 불용어 제거 키워드 추출 (최대 5개) - getSmartContext(): 최근 20개 + 키워드 매칭 10개 (중복 제거) - getConversationStats(): 통계 조회 (메시지 수, 첫/마지막 메시지 시간) - getConversationHistory(): 히스토리 조회 (페이지네이션) Co-Authored-By: Claude Opus 4.5 --- src/services/conversation-storage.ts | 405 +++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 src/services/conversation-storage.ts diff --git a/src/services/conversation-storage.ts b/src/services/conversation-storage.ts new file mode 100644 index 0000000..e08275c --- /dev/null +++ b/src/services/conversation-storage.ts @@ -0,0 +1,405 @@ +import type { + 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 { + try { + // 1. 메타 테이블에서 확인 + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (meta) { + // 테이블이 이미 존재함 + logger.debug('대화 테이블 존재 확인', { telegramId, tableName: meta.table_name }); + return; + } + + // 2. 테이블명 생성 + const tableName = `conv_${telegramId}`; + logger.info('대화 테이블 생성 시작', { telegramId, tableName }); + + // 3. 테이블 생성 (동적 SQL) + await db + .prepare( + `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 + )` + ) + .run(); + + // 4. 메타 테이블에 등록 + await db + .prepare( + `INSERT INTO conversation_tables (telegram_id, table_name, message_count, last_message_at) + VALUES (?, ?, 0, NULL)` + ) + .bind(telegramId, tableName) + .run(); + + logger.info('대화 테이블 생성 완료', { telegramId, tableName }); + } catch (error) { + logger.error('대화 테이블 생성 실패', error as Error, { telegramId }); + throw error; + } +} + +/** + * 메시지 저장 + */ +export async function saveConversationMessage( + db: D1Database, + telegramId: string, + message: ConversationMessage +): Promise { + try { + // 1. 테이블 존재 확인 + await ensureConversationTable(db, telegramId); + + const tableName = `conv_${telegramId}`; + + // 2. 메시지 저장 + 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(); + + // 3. 메타 테이블 업데이트 + await db + .prepare( + `UPDATE conversation_tables + SET message_count = message_count + 1, + last_message_at = CURRENT_TIMESTAMP + WHERE telegram_id = ?` + ) + .bind(telegramId) + .run(); + + logger.debug('메시지 저장 완료', { telegramId, role: message.role }); + } catch (error) { + logger.error('메시지 저장 실패', error as Error, { telegramId, role: message.role }); + throw error; + } +} + +/** + * 최근 N개 메시지 조회 + */ +export async function getRecentConversations( + db: D1Database, + telegramId: string, + limit: number = 20 +): Promise { + try { + // 1. 테이블 존재 확인 + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (!meta) { + logger.debug('대화 테이블 없음', { telegramId }); + return []; + } + + const tableName = meta.table_name; + + // 2. 최근 메시지 조회 (시간순 정렬: 오래된 것 → 최신) + const { results } = await db + .prepare( + `SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${tableName} + ORDER BY created_at DESC + LIMIT ?` + ) + .bind(limit) + .all(); + + // 3. 시간 순서대로 재정렬 (오래된 것 → 최신) + return results.reverse(); + } catch (error) { + logger.error('최근 대화 조회 실패', error as Error, { telegramId, limit }); + return []; + } +} + +/** + * 키워드로 대화 검색 + */ +export async function searchConversations( + db: D1Database, + telegramId: string, + keywords: string[], + limit: number = 10 +): Promise { + try { + // 1. 테이블 존재 확인 + 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 []; + } + + const tableName = meta.table_name; + + // 2. LIKE 쿼리 조건 생성 (OR 조건) + const conditions = keywords.map(() => 'content LIKE ?').join(' OR '); + const bindings = keywords.map((kw) => `%${kw}%`); + + // 3. 검색 실행 + const { results } = await db + .prepare( + `SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${tableName} + WHERE ${conditions} + ORDER BY created_at DESC + LIMIT ?` + ) + .bind(...bindings, limit) + .all(); + + return results; + } catch (error) { + logger.error('대화 검색 실패', error as Error, { telegramId, keywords }); + return []; + } +} + +/** + * 메시지에서 키워드 추출 + */ +export function extractKeywords(message: string): string[] { + try { + // 1. 특수문자 제거, 공백으로 분리 + const words = message + .replace(/[^\w\s가-힣]/g, ' ') + .split(/\s+/) + .filter((word) => word.length >= 2); + + // 2. 불용어 제거 + const keywords = words.filter((word) => !STOP_WORDS.has(word)); + + // 3. 최대 5개 반환 + return keywords.slice(0, 5); + } catch (error) { + logger.error('키워드 추출 실패', error as Error, { message }); + return []; + } +} + +/** + * 스마트 컨텍스트 조회 + * - 최근 20개 메시지 + * - 키워드 매칭 관련 10개 메시지 + * - 중복 제거, 시간순 정렬 + */ +export async function getSmartContext( + db: D1Database, + telegramId: string, + currentMessage: string +): Promise { + try { + // 1. 최근 20개 메시지 + const recentMessages = await getRecentConversations(db, telegramId, 20); + + // 2. 키워드 추출 + const keywords = extractKeywords(currentMessage); + if (keywords.length === 0) { + // 키워드 없으면 최근 메시지만 반환 + return recentMessages; + } + + // 3. 키워드 관련 메시지 검색 (10개) + const relatedMessages = await searchConversations(db, telegramId, keywords, 10); + + // 4. 중복 제거 (id 기준) + const messageMap = new Map(); + + for (const msg of recentMessages) { + if (msg.id) { + messageMap.set(msg.id, msg); + } + } + + for (const msg of relatedMessages) { + if (msg.id && !messageMap.has(msg.id)) { + messageMap.set(msg.id, msg); + } + } + + // 5. 시간순 정렬 (오래된 것 → 최신) + const allMessages = Array.from(messageMap.values()); + allMessages.sort((a, b) => { + const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; + const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; + return timeA - timeB; + }); + + logger.debug('스마트 컨텍스트 생성', { + telegramId, + recentCount: recentMessages.length, + relatedCount: relatedMessages.length, + totalCount: allMessages.length, + keywords, + }); + + return allMessages; + } catch (error) { + logger.error('스마트 컨텍스트 조회 실패', error as Error, { telegramId }); + // 실패 시 최근 메시지라도 반환 + return await getRecentConversations(db, telegramId, 20); + } +} + +/** + * 대화 통계 조회 + */ +export async function getConversationStats( + db: D1Database, + telegramId: string +): Promise { + try { + // 1. 메타 테이블 조회 + const meta = await db + .prepare( + 'SELECT telegram_id, message_count, created_at AS first_message_at, last_message_at FROM conversation_tables WHERE telegram_id = ?' + ) + .bind(telegramId) + .first(); + + if (!meta) { + return null; + } + + // 2. 아카이브된 요약 개수 조회 (summaries 테이블에서) + // Note: summaries 테이블은 기존 프로필 시스템에서 사용 중 + const archivedCount = 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: meta.telegram_id, + message_count: meta.message_count, + first_message_at: meta.created_at, + last_message_at: meta.last_message_at, + archived_summaries: archivedCount?.cnt || 0, + }; + } catch (error) { + logger.error('통계 조회 실패', error as Error, { telegramId }); + return null; + } +} + +/** + * 대화 히스토리 조회 (명령어용) + */ +export async function getConversationHistory( + db: D1Database, + telegramId: string, + limit: number = 50, + offset: number = 0 +): Promise { + try { + // 1. 테이블 존재 확인 + const meta = await db + .prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?') + .bind(telegramId) + .first<{ table_name: string }>(); + + if (!meta) { + return []; + } + + const tableName = meta.table_name; + + // 2. 히스토리 조회 (페이지네이션) + const { results } = await db + .prepare( + `SELECT id, role, content, tool_calls, tool_results, created_at + FROM ${tableName} + ORDER BY created_at DESC + LIMIT ? OFFSET ?` + ) + .bind(limit, offset) + .all(); + + // 3. 시간 순서대로 재정렬 (오래된 것 → 최신) + return results.reverse(); + } catch (error) { + logger.error('대화 히스토리 조회 실패', error as Error, { telegramId, limit, offset }); + return []; + } +}