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:
405
src/services/conversation-storage.ts
Normal file
405
src/services/conversation-storage.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user