- ensureConversationTable(): 사용자별 동적 테이블 생성/확인 - saveConversationMessage(): 메시지 저장 + 메타 테이블 업데이트 - getRecentConversations(): 최근 N개 메시지 조회 (시간순 정렬) - searchConversations(): 키워드 검색 (LIKE OR 조건) - extractKeywords(): 불용어 제거 키워드 추출 (최대 5개) - getSmartContext(): 최근 20개 + 키워드 매칭 10개 (중복 제거) - getConversationStats(): 통계 조회 (메시지 수, 첫/마지막 메시지 시간) - getConversationHistory(): 히스토리 조회 (페이지네이션) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
406 lines
10 KiB
TypeScript
406 lines
10 KiB
TypeScript
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 [];
|
|
}
|
|
}
|