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 []; } }