Remove server recommendation consultation system: - 30-year expert AI persona - Session-based information gathering - Brave Search / Context7 tool integration - Automatic spec inference Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1175 lines
30 KiB
Markdown
1175 lines
30 KiB
Markdown
# 대화 저장 시스템 구현 계획
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** 요약 기반 대화 저장을 전체 대화 기록 시스템으로 전환
|
|
|
|
**Architecture:** 메타 테이블(conversation_tables) + 사용자별 동적 테이블(conv_{telegram_id}) + 6개월 아카이브
|
|
|
|
**Tech Stack:** Cloudflare Workers, D1 SQLite, TypeScript, OpenAI GPT-4o-mini
|
|
|
|
---
|
|
|
|
## Task 1: 타입 정의 추가
|
|
|
|
**Files:**
|
|
- Modify: `src/types.ts`
|
|
|
|
**Step 1: ConversationMessage 타입 추가**
|
|
|
|
`src/types.ts` 파일 끝에 추가:
|
|
|
|
```typescript
|
|
// ============================================
|
|
// Conversation Storage Types
|
|
// ============================================
|
|
|
|
export interface ConversationMessage {
|
|
id?: number;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
tool_calls?: string; // JSON: [{name, arguments, id}]
|
|
tool_results?: string; // JSON: [{tool_call_id, result}]
|
|
created_at?: string;
|
|
}
|
|
|
|
export interface ConversationTableMeta {
|
|
telegram_id: string;
|
|
table_name: string;
|
|
message_count: number;
|
|
last_message_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ConversationStats {
|
|
telegram_id: string;
|
|
message_count: number;
|
|
first_message_at: string | null;
|
|
last_message_at: string | null;
|
|
archived_summaries: number;
|
|
}
|
|
|
|
export interface ArchiveResult {
|
|
processed_users: number;
|
|
archived_messages: number;
|
|
created_summaries: number;
|
|
errors: string[];
|
|
}
|
|
```
|
|
|
|
**Step 2: 테스트 (타입 체크)**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS (no type errors)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/types.ts
|
|
git commit -m "feat: add conversation storage types"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: 마이그레이션 SQL 작성
|
|
|
|
**Files:**
|
|
- Create: `migrations/008_conversation_tables.sql`
|
|
|
|
**Step 1: 마이그레이션 파일 생성**
|
|
|
|
```sql
|
|
-- 대화 테이블 메타 정보
|
|
CREATE TABLE IF NOT EXISTS conversation_tables (
|
|
telegram_id TEXT PRIMARY KEY,
|
|
table_name TEXT NOT NULL,
|
|
message_count INTEGER DEFAULT 0,
|
|
last_message_at DATETIME,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX IF NOT EXISTS idx_conv_tables_last_msg ON conversation_tables(last_message_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_conv_tables_count ON conversation_tables(message_count DESC);
|
|
```
|
|
|
|
**Step 2: 로컬 테스트**
|
|
|
|
Run: `wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql`
|
|
Expected: 성공 메시지
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add migrations/008_conversation_tables.sql
|
|
git commit -m "feat: add conversation_tables migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: 대화 저장 서비스 구현
|
|
|
|
**Files:**
|
|
- Create: `src/services/conversation-storage.ts`
|
|
|
|
**Step 1: 기본 구조 작성**
|
|
|
|
```typescript
|
|
import type { Env, 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<string> {
|
|
const tableName = `conv_${telegramId}`;
|
|
|
|
// 1. 메타 테이블에서 확인
|
|
const existing = await db
|
|
.prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
|
|
.bind(telegramId)
|
|
.first<{ table_name: string }>();
|
|
|
|
if (existing) {
|
|
return existing.table_name;
|
|
}
|
|
|
|
// 2. 테이블 생성 (raw SQL - DDL은 prepared statement 불가)
|
|
await db.exec(`
|
|
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
|
|
)
|
|
`);
|
|
|
|
// 3. 인덱스 생성
|
|
await db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC)
|
|
`);
|
|
|
|
// 4. 메타 테이블에 등록
|
|
await db
|
|
.prepare('INSERT INTO conversation_tables (telegram_id, table_name) VALUES (?, ?)')
|
|
.bind(telegramId, tableName)
|
|
.run();
|
|
|
|
logger.info('대화 테이블 생성', { telegramId, tableName });
|
|
|
|
return tableName;
|
|
}
|
|
|
|
/**
|
|
* 메시지 저장
|
|
*/
|
|
export async function saveConversationMessage(
|
|
db: D1Database,
|
|
telegramId: string,
|
|
message: Omit<ConversationMessage, 'id' | 'created_at'>
|
|
): Promise<void> {
|
|
const tableName = await ensureConversationTable(db, telegramId);
|
|
|
|
// 1. 메시지 저장
|
|
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();
|
|
|
|
// 2. 메타 테이블 업데이트
|
|
await db
|
|
.prepare(`
|
|
UPDATE conversation_tables
|
|
SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP
|
|
WHERE telegram_id = ?
|
|
`)
|
|
.bind(telegramId)
|
|
.run();
|
|
}
|
|
|
|
/**
|
|
* 최근 메시지 조회
|
|
*/
|
|
export async function getRecentConversations(
|
|
db: D1Database,
|
|
telegramId: string,
|
|
limit: number = 20
|
|
): Promise<ConversationMessage[]> {
|
|
// 테이블 존재 확인
|
|
const meta = await db
|
|
.prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
|
|
.bind(telegramId)
|
|
.first<{ table_name: string }>();
|
|
|
|
if (!meta) {
|
|
return [];
|
|
}
|
|
|
|
const { results } = await db
|
|
.prepare(`
|
|
SELECT id, role, content, tool_calls, tool_results, created_at
|
|
FROM ${meta.table_name}
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`)
|
|
.bind(limit)
|
|
.all<ConversationMessage>();
|
|
|
|
// 시간순 정렬 (오래된 것 → 최신)
|
|
return (results || []).reverse();
|
|
}
|
|
|
|
/**
|
|
* 키워드로 관련 메시지 검색
|
|
*/
|
|
export async function searchConversations(
|
|
db: D1Database,
|
|
telegramId: string,
|
|
keywords: string[],
|
|
limit: number = 10
|
|
): Promise<ConversationMessage[]> {
|
|
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 [];
|
|
}
|
|
|
|
// LIKE 쿼리 생성 (OR 조건)
|
|
const likeConditions = keywords.map(() => `content LIKE ?`).join(' OR ');
|
|
const likeValues = keywords.map(k => `%${k}%`);
|
|
|
|
const { results } = await db
|
|
.prepare(`
|
|
SELECT id, role, content, tool_calls, tool_results, created_at
|
|
FROM ${meta.table_name}
|
|
WHERE ${likeConditions}
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`)
|
|
.bind(...likeValues, limit)
|
|
.all<ConversationMessage>();
|
|
|
|
return results || [];
|
|
}
|
|
|
|
/**
|
|
* 메시지에서 키워드 추출
|
|
*/
|
|
export function extractKeywords(message: string): string[] {
|
|
// 1. 특수문자 제거, 공백으로 분리
|
|
const words = message
|
|
.replace(/[^\w\s가-힣]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(w => w.length >= 2);
|
|
|
|
// 2. 불용어 제거
|
|
const filtered = words.filter(w => !STOP_WORDS.has(w));
|
|
|
|
// 3. 중복 제거 후 최대 5개
|
|
const unique = [...new Set(filtered)];
|
|
|
|
return unique.slice(0, 5);
|
|
}
|
|
|
|
/**
|
|
* 스마트 컨텍스트 조회 (최근 20개 + 관련 10개)
|
|
*/
|
|
export async function getSmartContext(
|
|
db: D1Database,
|
|
telegramId: string,
|
|
currentMessage: string
|
|
): Promise<ConversationMessage[]> {
|
|
// 1. 최근 20개 조회
|
|
const recent = await getRecentConversations(db, telegramId, 20);
|
|
|
|
// 2. 키워드 추출
|
|
const keywords = extractKeywords(currentMessage);
|
|
|
|
if (keywords.length === 0) {
|
|
return recent;
|
|
}
|
|
|
|
// 3. 관련 대화 검색 (최근 20개 제외)
|
|
const recentIds = new Set(recent.map(m => m.id));
|
|
const related = await searchConversations(db, telegramId, keywords, 20);
|
|
const filteredRelated = related
|
|
.filter(m => !recentIds.has(m.id))
|
|
.slice(0, 10);
|
|
|
|
// 4. 병합 및 시간순 정렬
|
|
const combined = [...filteredRelated, ...recent];
|
|
combined.sort((a, b) => {
|
|
const timeA = new Date(a.created_at || 0).getTime();
|
|
const timeB = new Date(b.created_at || 0).getTime();
|
|
return timeA - timeB;
|
|
});
|
|
|
|
return combined;
|
|
}
|
|
|
|
/**
|
|
* 대화 통계 조회
|
|
*/
|
|
export async function getConversationStats(
|
|
db: D1Database,
|
|
telegramId: string
|
|
): Promise<ConversationStats | null> {
|
|
const meta = await db
|
|
.prepare('SELECT * FROM conversation_tables WHERE telegram_id = ?')
|
|
.bind(telegramId)
|
|
.first<ConversationTableMeta>();
|
|
|
|
if (!meta) {
|
|
return null;
|
|
}
|
|
|
|
// 첫 메시지 시간 조회
|
|
const firstMsg = await db
|
|
.prepare(`SELECT MIN(created_at) as first_at FROM ${meta.table_name}`)
|
|
.first<{ first_at: string | null }>();
|
|
|
|
// 아카이브된 요약 수
|
|
const archiveCount = 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: telegramId,
|
|
message_count: meta.message_count,
|
|
first_message_at: firstMsg?.first_at || null,
|
|
last_message_at: meta.last_message_at,
|
|
archived_summaries: archiveCount?.cnt || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 대화 히스토리 조회 (명령어용)
|
|
*/
|
|
export async function getConversationHistory(
|
|
db: D1Database,
|
|
telegramId: string,
|
|
limit: number = 20,
|
|
offset: number = 0
|
|
): Promise<ConversationMessage[]> {
|
|
const meta = await db
|
|
.prepare('SELECT table_name FROM conversation_tables WHERE telegram_id = ?')
|
|
.bind(telegramId)
|
|
.first<{ table_name: string }>();
|
|
|
|
if (!meta) {
|
|
return [];
|
|
}
|
|
|
|
const { results } = await db
|
|
.prepare(`
|
|
SELECT id, role, content, tool_calls, tool_results, created_at
|
|
FROM ${meta.table_name}
|
|
ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`)
|
|
.bind(limit, offset)
|
|
.all<ConversationMessage>();
|
|
|
|
return (results || []).reverse();
|
|
}
|
|
```
|
|
|
|
**Step 2: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/services/conversation-storage.ts
|
|
git commit -m "feat: implement conversation storage service"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: 아카이브 서비스 구현
|
|
|
|
**Files:**
|
|
- Create: `src/services/archive-service.ts`
|
|
|
|
**Step 1: 아카이브 서비스 작성**
|
|
|
|
```typescript
|
|
import type { Env, ConversationMessage, ArchiveResult } from '../types';
|
|
import { createLogger } from '../utils/logger';
|
|
|
|
const logger = createLogger('archive-service');
|
|
|
|
const ARCHIVE_DAYS = 180; // 6개월
|
|
const BATCH_SIZE = 100; // 요약당 메시지 수
|
|
|
|
/**
|
|
* 메시지 배열을 AI 요약으로 변환
|
|
*/
|
|
async function generateArchiveSummary(
|
|
env: Env,
|
|
messages: ConversationMessage[],
|
|
startDate: string,
|
|
endDate: string
|
|
): Promise<string> {
|
|
// 사용자 메시지만 추출
|
|
const userMessages = messages
|
|
.filter(m => m.role === 'user')
|
|
.map(m => `- ${m.content}`)
|
|
.join('\n');
|
|
|
|
const prompt = `당신은 대화 아카이브 전문가입니다.
|
|
아래 기간의 사용자 발언을 분석하여 핵심 정보를 요약하세요.
|
|
|
|
## 기간
|
|
${startDate} ~ ${endDate}
|
|
|
|
## 사용자 발언 (${messages.filter(m => m.role === 'user').length}개)
|
|
${userMessages}
|
|
|
|
## 요구사항
|
|
1. 주요 관심사, 요청사항, 질문 주제를 파악
|
|
2. 중요한 맥락 정보 (직업, 프로젝트, 목표 등) 보존
|
|
3. 무의미한 내용 (인사, 단순 확인) 제외
|
|
4. 300-400자 이내로 작성
|
|
5. 한국어로 작성
|
|
|
|
아카이브 요약:`;
|
|
|
|
if (env.OPENAI_API_KEY) {
|
|
const { generateProfileWithOpenAI } = await import('../openai-service');
|
|
const summary = await generateProfileWithOpenAI(env, prompt);
|
|
return `[${startDate} ~ ${endDate} 아카이브] ${summary}`;
|
|
}
|
|
|
|
// 폴백: 단순 요약
|
|
return `[${startDate} ~ ${endDate} 아카이브] 이 기간 동안 ${messages.length}개의 대화가 있었습니다.`;
|
|
}
|
|
|
|
/**
|
|
* 특정 사용자의 오래된 대화 아카이브
|
|
*/
|
|
export async function archiveUserConversations(
|
|
env: Env,
|
|
telegramId: string,
|
|
tableName: string,
|
|
olderThanDays: number = ARCHIVE_DAYS
|
|
): Promise<{ archived: number; summaries: number }> {
|
|
const db = env.DB;
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
const cutoffStr = cutoffDate.toISOString();
|
|
|
|
// 1. 아카이브 대상 메시지 조회
|
|
const { results: oldMessages } = await db
|
|
.prepare(`
|
|
SELECT id, role, content, tool_calls, tool_results, created_at
|
|
FROM ${tableName}
|
|
WHERE created_at < ?
|
|
ORDER BY created_at ASC
|
|
`)
|
|
.bind(cutoffStr)
|
|
.all<ConversationMessage>();
|
|
|
|
if (!oldMessages || oldMessages.length === 0) {
|
|
return { archived: 0, summaries: 0 };
|
|
}
|
|
|
|
logger.info('아카이브 대상', { telegramId, count: oldMessages.length });
|
|
|
|
// 2. 사용자 ID 조회
|
|
const user = await db
|
|
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
|
.bind(telegramId)
|
|
.first<{ id: number }>();
|
|
|
|
if (!user) {
|
|
logger.warn('사용자 없음', { telegramId });
|
|
return { archived: 0, summaries: 0 };
|
|
}
|
|
|
|
// 3. BATCH_SIZE 단위로 요약 생성
|
|
let summariesCreated = 0;
|
|
const messageIds: number[] = [];
|
|
|
|
for (let i = 0; i < oldMessages.length; i += BATCH_SIZE) {
|
|
const batch = oldMessages.slice(i, i + BATCH_SIZE);
|
|
const startDate = batch[0].created_at?.split('T')[0] || 'unknown';
|
|
const endDate = batch[batch.length - 1].created_at?.split('T')[0] || 'unknown';
|
|
|
|
// 요약 생성
|
|
const summary = await generateArchiveSummary(env, batch, startDate, endDate);
|
|
|
|
// 최신 generation 조회
|
|
const latestGen = await db
|
|
.prepare('SELECT MAX(generation) as gen FROM summaries WHERE user_id = ?')
|
|
.bind(user.id)
|
|
.first<{ gen: number | null }>();
|
|
|
|
const newGeneration = (latestGen?.gen || 0) + 1;
|
|
|
|
// summaries 테이블에 저장
|
|
await db
|
|
.prepare(`
|
|
INSERT INTO summaries (user_id, chat_id, generation, summary, message_count)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`)
|
|
.bind(user.id, telegramId, newGeneration, summary, batch.length)
|
|
.run();
|
|
|
|
summariesCreated++;
|
|
batch.forEach(m => m.id && messageIds.push(m.id));
|
|
}
|
|
|
|
// 4. 원본 메시지 삭제
|
|
if (messageIds.length > 0) {
|
|
// SQLite는 IN 절에 많은 파라미터 바인딩이 어려우므로 청크 단위 삭제
|
|
const chunkSize = 100;
|
|
for (let i = 0; i < messageIds.length; i += chunkSize) {
|
|
const chunk = messageIds.slice(i, i + chunkSize);
|
|
const placeholders = chunk.map(() => '?').join(',');
|
|
await db
|
|
.prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders})`)
|
|
.bind(...chunk)
|
|
.run();
|
|
}
|
|
}
|
|
|
|
// 5. 메타 테이블 업데이트
|
|
await db
|
|
.prepare('UPDATE conversation_tables SET message_count = message_count - ? WHERE telegram_id = ?')
|
|
.bind(messageIds.length, telegramId)
|
|
.run();
|
|
|
|
logger.info('아카이브 완료', {
|
|
telegramId,
|
|
archived: messageIds.length,
|
|
summaries: summariesCreated
|
|
});
|
|
|
|
return { archived: messageIds.length, summaries: summariesCreated };
|
|
}
|
|
|
|
/**
|
|
* 전체 사용자 아카이브 (Cron용)
|
|
*/
|
|
export async function archiveOldConversations(env: Env): Promise<ArchiveResult> {
|
|
const db = env.DB;
|
|
const result: ArchiveResult = {
|
|
processed_users: 0,
|
|
archived_messages: 0,
|
|
created_summaries: 0,
|
|
errors: [],
|
|
};
|
|
|
|
// 1. 모든 대화 테이블 조회
|
|
const { results: tables } = await db
|
|
.prepare('SELECT telegram_id, table_name FROM conversation_tables')
|
|
.all<{ telegram_id: string; table_name: string }>();
|
|
|
|
if (!tables || tables.length === 0) {
|
|
logger.info('아카이브 대상 없음');
|
|
return result;
|
|
}
|
|
|
|
// 2. 각 사용자별 아카이브 실행
|
|
for (const table of tables) {
|
|
try {
|
|
const { archived, summaries } = await archiveUserConversations(
|
|
env,
|
|
table.telegram_id,
|
|
table.table_name
|
|
);
|
|
|
|
if (archived > 0) {
|
|
result.processed_users++;
|
|
result.archived_messages += archived;
|
|
result.created_summaries += summaries;
|
|
}
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
result.errors.push(`${table.telegram_id}: ${errMsg}`);
|
|
logger.error('사용자 아카이브 실패', error as Error, {
|
|
telegramId: table.telegram_id
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info('전체 아카이브 완료', result);
|
|
return result;
|
|
}
|
|
```
|
|
|
|
**Step 2: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/services/archive-service.ts
|
|
git commit -m "feat: implement archive service for old conversations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: summary-service.ts 수정
|
|
|
|
**Files:**
|
|
- Modify: `src/summary-service.ts`
|
|
|
|
**Step 1: getSmartContext 연동**
|
|
|
|
`generateAIResponse` 함수 수정 - `getConversationContext` 대신 `getSmartContext` 사용:
|
|
|
|
```typescript
|
|
// 기존 import 유지 + 추가
|
|
import { getSmartContext } from './services/conversation-storage';
|
|
|
|
// generateAIResponse 함수 내부 수정
|
|
export async function generateAIResponse(
|
|
env: Env,
|
|
userId: number,
|
|
chatId: string,
|
|
userMessage: string,
|
|
telegramUserId?: string
|
|
): Promise<string> {
|
|
// 기존 context 대신 스마트 컨텍스트 사용
|
|
const smartMessages = telegramUserId
|
|
? await getSmartContext(env.DB, telegramUserId, userMessage)
|
|
: [];
|
|
|
|
// 아카이브된 요약도 조회 (기존 로직 유지)
|
|
const summaries = await getAllSummaries(env.DB, userId, chatId);
|
|
|
|
// 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시)
|
|
const integratedProfile = summaries.length > 0
|
|
? summaries
|
|
.slice()
|
|
.reverse()
|
|
.map((s) => `[v${s.generation}] ${s.summary}`)
|
|
.join('\n\n')
|
|
: null;
|
|
|
|
// ... 기존 systemPrompt 생성 로직 유지 ...
|
|
|
|
// recentContext를 스마트 컨텍스트로 교체
|
|
const recentContext = smartMessages.slice(-30).map((m) => ({
|
|
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
|
|
content: m.content,
|
|
}));
|
|
|
|
// ... 나머지 로직 유지 ...
|
|
}
|
|
```
|
|
|
|
**Step 2: addToBuffer 함수는 유지** (마이그레이션 완료 전까지 호환성)
|
|
|
|
기존 `addToBuffer`, `getBufferedMessages`, `processAndSummarize` 함수는 삭제하지 않고 유지.
|
|
새 시스템과 병행 운영 후 마이그레이션 완료 시 제거.
|
|
|
|
**Step 3: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/summary-service.ts
|
|
git commit -m "feat: integrate smart context in AI response generation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: conversation-service.ts 수정
|
|
|
|
**Files:**
|
|
- Modify: `src/services/conversation-service.ts`
|
|
|
|
**Step 1: 새 저장 시스템 사용**
|
|
|
|
```typescript
|
|
import type { Env, KeyboardData } from '../types';
|
|
import {
|
|
addToBuffer,
|
|
processAndSummarize,
|
|
generateAIResponse,
|
|
} from '../summary-service';
|
|
import { saveConversationMessage } from './conversation-storage';
|
|
import { sendChatAction } from '../telegram';
|
|
import { createLogger } from '../utils/logger';
|
|
|
|
const logger = createLogger('conversation');
|
|
|
|
export interface ConversationResult {
|
|
responseText: string;
|
|
isProfileUpdated: boolean;
|
|
keyboardData?: KeyboardData | null;
|
|
}
|
|
|
|
export class ConversationService {
|
|
constructor(private env: Env) {}
|
|
|
|
async processUserMessage(
|
|
userId: number,
|
|
chatId: string,
|
|
text: string,
|
|
telegramUserId: string
|
|
): Promise<ConversationResult> {
|
|
// 1. 타이핑 액션 전송
|
|
sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(err =>
|
|
logger.error('타이핑 액션 전송 실패', err as Error)
|
|
);
|
|
|
|
// 2. 새 시스템에 사용자 메시지 저장
|
|
await saveConversationMessage(this.env.DB, telegramUserId, {
|
|
role: 'user',
|
|
content: text,
|
|
});
|
|
|
|
// 3. 기존 버퍼에도 저장 (호환성 - 나중에 제거)
|
|
await addToBuffer(this.env.DB, userId, chatId, 'user', text);
|
|
|
|
// 4. AI 응답 생성
|
|
let responseText = await generateAIResponse(
|
|
this.env,
|
|
userId,
|
|
chatId,
|
|
text,
|
|
telegramUserId
|
|
);
|
|
|
|
// __DIRECT__ 마커 제거
|
|
if (responseText.includes('__DIRECT__')) {
|
|
const directIndex = responseText.indexOf('__DIRECT__');
|
|
responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim();
|
|
}
|
|
|
|
// 5. 새 시스템에 봇 응답 저장
|
|
await saveConversationMessage(this.env.DB, telegramUserId, {
|
|
role: 'assistant',
|
|
content: responseText,
|
|
});
|
|
|
|
// 6. 기존 버퍼에도 저장 (호환성)
|
|
await addToBuffer(this.env.DB, userId, chatId, 'bot', responseText);
|
|
|
|
// 7. 기존 요약 프로세스 유지 (호환성)
|
|
const { summarized } = await processAndSummarize(
|
|
this.env,
|
|
userId,
|
|
chatId
|
|
);
|
|
|
|
// 8. 키보드 데이터 파싱
|
|
let keyboardData: KeyboardData | null = null;
|
|
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/s);
|
|
|
|
if (keyboardMatch) {
|
|
logger.debug('키보드 마커 감지', { preview: keyboardMatch[1].substring(0, 100) });
|
|
responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/s, '');
|
|
try {
|
|
keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData;
|
|
logger.debug('키보드 파싱 성공', { type: keyboardData.type });
|
|
} catch (e) {
|
|
logger.error('키보드 파싱 오류', e as Error, { rawData: keyboardMatch[1] });
|
|
}
|
|
}
|
|
|
|
return {
|
|
responseText,
|
|
isProfileUpdated: summarized,
|
|
keyboardData
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/services/conversation-service.ts
|
|
git commit -m "feat: save messages to new conversation storage"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: 명령어 추가 (/history, /search)
|
|
|
|
**Files:**
|
|
- Modify: `src/commands.ts`
|
|
|
|
**Step 1: import 추가**
|
|
|
|
```typescript
|
|
import {
|
|
getConversationHistory,
|
|
searchConversations,
|
|
getConversationStats,
|
|
extractKeywords
|
|
} from './services/conversation-storage';
|
|
```
|
|
|
|
**Step 2: /history 명령어 추가**
|
|
|
|
```typescript
|
|
case '/history': {
|
|
const limit = _args ? parseInt(_args, 10) : 20;
|
|
const validLimit = Math.min(Math.max(limit, 1), 100); // 1-100 제한
|
|
|
|
const messages = await getConversationHistory(env.DB, chatId, validLimit);
|
|
|
|
if (messages.length === 0) {
|
|
return '📜 저장된 대화가 없습니다.';
|
|
}
|
|
|
|
const formatted = messages.map(m => {
|
|
const time = m.created_at
|
|
? new Date(m.created_at).toLocaleString('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
: '';
|
|
const role = m.role === 'user' ? '나' : '봇';
|
|
const content = m.content.length > 50
|
|
? m.content.substring(0, 50) + '...'
|
|
: m.content;
|
|
return `[${time}] ${role}: ${content}`;
|
|
}).join('\n');
|
|
|
|
return `📜 <b>최근 대화</b> (${messages.length}개)\n\n${formatted}\n\n/history 50 으로 더 보기`;
|
|
}
|
|
|
|
case '/search': {
|
|
if (!_args || _args.trim().length < 2) {
|
|
return '🔍 검색어를 입력해주세요.\n예: /search 도메인';
|
|
}
|
|
|
|
const keywords = extractKeywords(_args);
|
|
if (keywords.length === 0) {
|
|
return '🔍 유효한 검색어가 없습니다.';
|
|
}
|
|
|
|
const results = await searchConversations(env.DB, chatId, keywords, 15);
|
|
|
|
if (results.length === 0) {
|
|
return `🔍 "${_args}" 검색 결과가 없습니다.`;
|
|
}
|
|
|
|
const formatted = results.map(m => {
|
|
const date = m.created_at
|
|
? new Date(m.created_at).toLocaleDateString('ko-KR')
|
|
: '';
|
|
const content = m.content.length > 60
|
|
? m.content.substring(0, 60) + '...'
|
|
: m.content;
|
|
return `[${date}] ${content}`;
|
|
}).join('\n');
|
|
|
|
return `🔍 <b>"${_args}"</b> 검색 결과 (${results.length}건)\n\n${formatted}`;
|
|
}
|
|
```
|
|
|
|
**Step 3: /stats 명령어 수정**
|
|
|
|
기존 `/stats` 케이스를 수정하여 새 통계 포함:
|
|
|
|
```typescript
|
|
case '/stats': {
|
|
const stats = await getConversationStats(env.DB, chatId);
|
|
|
|
if (!stats) {
|
|
return '📈 아직 대화 기록이 없습니다.';
|
|
}
|
|
|
|
const firstDate = stats.first_message_at
|
|
? new Date(stats.first_message_at).toLocaleDateString('ko-KR')
|
|
: '없음';
|
|
const lastDate = stats.last_message_at
|
|
? new Date(stats.last_message_at).toLocaleDateString('ko-KR')
|
|
: '없음';
|
|
|
|
return `📈 <b>대화 통계</b>
|
|
|
|
총 메시지: ${stats.message_count.toLocaleString()}개
|
|
첫 대화: ${firstDate}
|
|
최근 대화: ${lastDate}
|
|
아카이브된 요약: ${stats.archived_summaries}개
|
|
|
|
/history - 대화 기록 보기
|
|
/search 키워드 - 대화 검색`;
|
|
}
|
|
```
|
|
|
|
**Step 4: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/commands.ts
|
|
git commit -m "feat: add /history and /search commands"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Cron에 아카이브 추가
|
|
|
|
**Files:**
|
|
- Modify: `src/index.ts`
|
|
|
|
**Step 1: Cron 핸들러에 아카이브 추가**
|
|
|
|
`index.ts`의 `scheduled` 핸들러 찾아서 아카이브 호출 추가:
|
|
|
|
```typescript
|
|
// scheduled 핸들러 내부에 추가
|
|
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
|
|
const scheduledLogger = createLogger('scheduled');
|
|
|
|
// 기존 로직...
|
|
|
|
// UTC 15:00 (KST 00:00) - 아카이브 실행
|
|
if (event.cron === '0 15 * * *') {
|
|
try {
|
|
const { archiveOldConversations } = await import('./services/archive-service');
|
|
const result = await archiveOldConversations(env);
|
|
scheduledLogger.info('아카이브 완료', result);
|
|
} catch (error) {
|
|
scheduledLogger.error('아카이브 실패', error as Error);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: 타입 체크**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/index.ts
|
|
git commit -m "feat: add conversation archive to daily cron"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: 기존 데이터 마이그레이션 스크립트
|
|
|
|
**Files:**
|
|
- Create: `scripts/migrate-conversations.ts`
|
|
|
|
**Step 1: 마이그레이션 스크립트 작성**
|
|
|
|
```typescript
|
|
/**
|
|
* 기존 message_buffer 데이터를 새 conversation 테이블로 마이그레이션
|
|
*
|
|
* 실행: npx wrangler d1 execute telegram-conversations --local --file=scripts/migrate-conversations.sql
|
|
* 또는 Worker 내부에서 1회성 실행
|
|
*/
|
|
|
|
import type { Env } from '../src/types';
|
|
|
|
export async function migrateExistingConversations(env: Env): Promise<{
|
|
migrated_users: number;
|
|
migrated_messages: number;
|
|
errors: string[];
|
|
}> {
|
|
const db = env.DB;
|
|
const result = {
|
|
migrated_users: 0,
|
|
migrated_messages: 0,
|
|
errors: [] as string[],
|
|
};
|
|
|
|
// 1. 기존 message_buffer에서 사용자 목록 조회
|
|
const { results: users } = await db
|
|
.prepare(`
|
|
SELECT DISTINCT u.telegram_id, mb.user_id
|
|
FROM message_buffer mb
|
|
JOIN users u ON mb.user_id = u.id
|
|
`)
|
|
.all<{ telegram_id: string; user_id: number }>();
|
|
|
|
if (!users || users.length === 0) {
|
|
console.log('마이그레이션 대상 없음');
|
|
return result;
|
|
}
|
|
|
|
// 2. 각 사용자별 마이그레이션
|
|
for (const user of users) {
|
|
try {
|
|
const tableName = `conv_${user.telegram_id}`;
|
|
|
|
// 테이블 생성
|
|
await db.exec(`
|
|
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
|
|
)
|
|
`);
|
|
|
|
await db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_${tableName}_created ON ${tableName}(created_at DESC)
|
|
`);
|
|
|
|
// 메시지 복사
|
|
const { results: messages } = await db
|
|
.prepare(`
|
|
SELECT role, message as content, created_at
|
|
FROM message_buffer
|
|
WHERE user_id = ?
|
|
ORDER BY created_at ASC
|
|
`)
|
|
.bind(user.user_id)
|
|
.all<{ role: string; content: string; created_at: string }>();
|
|
|
|
if (messages && messages.length > 0) {
|
|
for (const msg of messages) {
|
|
const role = msg.role === 'bot' ? 'assistant' : 'user';
|
|
await db
|
|
.prepare(`INSERT INTO ${tableName} (role, content, created_at) VALUES (?, ?, ?)`)
|
|
.bind(role, msg.content, msg.created_at)
|
|
.run();
|
|
}
|
|
|
|
result.migrated_messages += messages.length;
|
|
}
|
|
|
|
// 메타 테이블에 등록
|
|
await db
|
|
.prepare(`
|
|
INSERT OR REPLACE INTO conversation_tables (telegram_id, table_name, message_count, last_message_at)
|
|
VALUES (?, ?, ?, (SELECT MAX(created_at) FROM ${tableName}))
|
|
`)
|
|
.bind(user.telegram_id, tableName, messages?.length || 0)
|
|
.run();
|
|
|
|
result.migrated_users++;
|
|
console.log(`마이그레이션 완료: ${user.telegram_id} (${messages?.length || 0}개)`);
|
|
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
result.errors.push(`${user.telegram_id}: ${errMsg}`);
|
|
console.error(`마이그레이션 실패: ${user.telegram_id}`, error);
|
|
}
|
|
}
|
|
|
|
console.log('전체 마이그레이션 결과:', result);
|
|
return result;
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add scripts/migrate-conversations.ts
|
|
git commit -m "feat: add migration script for existing conversations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: 테스트 및 배포
|
|
|
|
**Step 1: 로컬 테스트**
|
|
|
|
```bash
|
|
# 1. 마이그레이션 SQL 적용 (로컬)
|
|
wrangler d1 execute telegram-conversations --local --file=migrations/008_conversation_tables.sql
|
|
|
|
# 2. 로컬 서버 실행
|
|
npm run dev
|
|
|
|
# 3. 테스트 메시지 전송 후 /history, /search, /stats 명령어 확인
|
|
```
|
|
|
|
**Step 2: 타입 체크 & 빌드**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
npm run build # 또는 wrangler deploy --dry-run
|
|
```
|
|
Expected: PASS
|
|
|
|
**Step 3: 프로덕션 마이그레이션 적용**
|
|
|
|
```bash
|
|
wrangler d1 execute telegram-conversations --file=migrations/008_conversation_tables.sql
|
|
```
|
|
|
|
**Step 4: 배포**
|
|
|
|
```bash
|
|
npm run deploy
|
|
```
|
|
|
|
**Step 5: Commit (최종)**
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "feat: complete conversation storage system implementation"
|
|
```
|
|
|
|
---
|
|
|
|
## 완료 후 검증 체크리스트
|
|
|
|
- [ ] `/history` 명령어가 최근 대화를 표시하는가
|
|
- [ ] `/search 키워드` 명령어가 관련 대화를 찾는가
|
|
- [ ] `/stats` 명령어가 통계를 표시하는가
|
|
- [ ] 새 메시지가 `conv_{telegram_id}` 테이블에 저장되는가
|
|
- [ ] AI 응답이 스마트 컨텍스트를 사용하는가
|
|
- [ ] 기존 기능 (명령어, 도메인, 서버, 예치금)이 정상 동작하는가
|
|
|
|
---
|
|
|
|
## 롤백 계획
|
|
|
|
1. **코드 롤백**: `conversation-service.ts`에서 `saveConversationMessage` 호출 제거
|
|
2. **DB 유지**: 새 테이블들은 독립적이므로 삭제 불필요
|
|
3. **기존 시스템**: `message_buffer` + `summaries`가 그대로 작동
|