feat: implement conversation storage service

- ensureConversationTable(): 사용자별 동적 테이블 생성/확인
- saveConversationMessage(): 메시지 저장 + 메타 테이블 업데이트
- getRecentConversations(): 최근 N개 메시지 조회 (시간순 정렬)
- searchConversations(): 키워드 검색 (LIKE OR 조건)
- extractKeywords(): 불용어 제거 키워드 추출 (최대 5개)
- getSmartContext(): 최근 20개 + 키워드 매칭 10개 (중복 제거)
- getConversationStats(): 통계 조회 (메시지 수, 첫/마지막 메시지 시간)
- getConversationHistory(): 히스토리 조회 (페이지네이션)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-05 12:21:21 +09:00
parent a6d1c4ecd4
commit 2a84f12d46

View File

@@ -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<void> {
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<void> {
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<ConversationMessage[]> {
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<ConversationMessage>();
// 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<ConversationMessage[]> {
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<ConversationMessage>();
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<ConversationMessage[]> {
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<number, ConversationMessage>();
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<ConversationStats | null> {
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<ConversationTableMeta>();
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<ConversationMessage[]> {
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<ConversationMessage>();
// 3. 시간 순서대로 재정렬 (오래된 것 → 최신)
return results.reverse();
} catch (error) {
logger.error('대화 히스토리 조회 실패', error as Error, { telegramId, limit, offset });
return [];
}
}