- Enhance OpenAI message types with tool_calls support - Improve security validation and rate limiting - Update utility tools and weather tool - Minor fixes in deposit-agent and domain-register Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
import { Env, BufferedMessage, Summary, ConversationContext, WorkersAITextGenerationOutput, WorkersAITextGenerationInput } from './types';
|
|
import { createLogger } from './utils/logger';
|
|
|
|
const logger = createLogger('summary-service');
|
|
|
|
// Type Guards for D1 query results
|
|
interface D1BufferedMessageRow {
|
|
id: number;
|
|
role: string;
|
|
message: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface D1SummaryRow {
|
|
id: number;
|
|
generation: number;
|
|
summary: string;
|
|
message_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
function isBufferedMessageRow(item: unknown): item is D1BufferedMessageRow {
|
|
if (typeof item !== 'object' || item === null) return false;
|
|
const row = item as Record<string, unknown>;
|
|
return (
|
|
typeof row.id === 'number' &&
|
|
typeof row.role === 'string' &&
|
|
typeof row.message === 'string' &&
|
|
typeof row.created_at === 'string'
|
|
);
|
|
}
|
|
|
|
function isBufferedMessageArray(data: unknown): data is D1BufferedMessageRow[] {
|
|
return Array.isArray(data) && data.every(isBufferedMessageRow);
|
|
}
|
|
|
|
function isSummaryRow(item: unknown): item is D1SummaryRow {
|
|
if (typeof item !== 'object' || item === null) return false;
|
|
const row = item as Record<string, unknown>;
|
|
return (
|
|
typeof row.id === 'number' &&
|
|
typeof row.generation === 'number' &&
|
|
typeof row.summary === 'string' &&
|
|
typeof row.message_count === 'number' &&
|
|
typeof row.created_at === 'string'
|
|
);
|
|
}
|
|
|
|
function isSummaryArray(data: unknown): data is D1SummaryRow[] {
|
|
return Array.isArray(data) && data.every(isSummaryRow);
|
|
}
|
|
|
|
// 설정값 가져오기
|
|
const getConfig = (env: Env) => ({
|
|
summaryThreshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10),
|
|
maxSummaries: parseInt(env.MAX_SUMMARIES_PER_USER || '3', 10),
|
|
});
|
|
|
|
// 버퍼에 메시지 추가
|
|
export async function addToBuffer(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string,
|
|
role: 'user' | 'bot',
|
|
message: string
|
|
): Promise<number> {
|
|
await db
|
|
.prepare(`
|
|
INSERT INTO message_buffer (user_id, chat_id, role, message)
|
|
VALUES (?, ?, ?, ?)
|
|
`)
|
|
.bind(userId, chatId, role, message)
|
|
.run();
|
|
|
|
const count = await db
|
|
.prepare('SELECT COUNT(*) as cnt FROM message_buffer WHERE user_id = ? AND chat_id = ?')
|
|
.bind(userId, chatId)
|
|
.first<{ cnt: number }>();
|
|
|
|
return count?.cnt || 0;
|
|
}
|
|
|
|
// 버퍼 메시지 조회
|
|
export async function getBufferedMessages(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string
|
|
): Promise<BufferedMessage[]> {
|
|
const { results } = await db
|
|
.prepare(`
|
|
SELECT id, role, message, created_at
|
|
FROM message_buffer
|
|
WHERE user_id = ? AND chat_id = ?
|
|
ORDER BY created_at ASC
|
|
`)
|
|
.bind(userId, chatId)
|
|
.all();
|
|
|
|
if (!isBufferedMessageArray(results)) {
|
|
logger.warn('Invalid message buffer data format', { userId, chatId });
|
|
return [];
|
|
}
|
|
|
|
// Type narrowing ensures results is D1BufferedMessageRow[]
|
|
const validatedResults: D1BufferedMessageRow[] = results;
|
|
|
|
return validatedResults.map(row => ({
|
|
id: row.id,
|
|
role: row.role as 'user' | 'bot',
|
|
message: row.message,
|
|
created_at: row.created_at
|
|
}));
|
|
}
|
|
|
|
// 최신 요약 조회
|
|
export async function getLatestSummary(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string
|
|
): Promise<Summary | null> {
|
|
const summary = await db
|
|
.prepare(`
|
|
SELECT id, generation, summary, message_count, created_at
|
|
FROM summaries
|
|
WHERE user_id = ? AND chat_id = ?
|
|
ORDER BY generation DESC
|
|
LIMIT 1
|
|
`)
|
|
.bind(userId, chatId)
|
|
.first<Summary>();
|
|
|
|
return summary || null;
|
|
}
|
|
|
|
// 모든 요약 조회 (최대 3개, 최신순)
|
|
export async function getAllSummaries(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string
|
|
): Promise<Summary[]> {
|
|
const { results } = await db
|
|
.prepare(`
|
|
SELECT id, generation, summary, message_count, created_at
|
|
FROM summaries
|
|
WHERE user_id = ? AND chat_id = ?
|
|
ORDER BY generation DESC
|
|
LIMIT 3
|
|
`)
|
|
.bind(userId, chatId)
|
|
.all();
|
|
|
|
if (!isSummaryArray(results)) {
|
|
logger.warn('Invalid summaries data format', { userId, chatId });
|
|
return [];
|
|
}
|
|
|
|
// Type narrowing ensures results is D1SummaryRow[] which matches Summary[]
|
|
const validatedResults: D1SummaryRow[] = results;
|
|
|
|
return validatedResults;
|
|
}
|
|
|
|
// 전체 컨텍스트 조회
|
|
export async function getConversationContext(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string
|
|
): Promise<ConversationContext> {
|
|
const [summaries, recentMessages] = await Promise.all([
|
|
getAllSummaries(db, userId, chatId),
|
|
getBufferedMessages(db, userId, chatId),
|
|
]);
|
|
|
|
const previousSummary = summaries[0] || null; // 최신 요약 (호환성)
|
|
const totalMessages = (previousSummary?.message_count || 0) + recentMessages.length;
|
|
|
|
return {
|
|
previousSummary,
|
|
summaries,
|
|
recentMessages,
|
|
totalMessages,
|
|
};
|
|
}
|
|
|
|
// AI 요약 생성 (모든 요약 통합)
|
|
async function generateSummary(
|
|
env: Env,
|
|
allSummaries: Summary[],
|
|
messages: BufferedMessage[]
|
|
): Promise<string> {
|
|
// 사용자 메시지만 추출
|
|
const userMessages = messages
|
|
.filter((m) => m.role === 'user')
|
|
.map((m) => `- ${m.message}`)
|
|
.join('\n');
|
|
|
|
// 사용자 메시지 수
|
|
const userMsgCount = messages.filter((m) => m.role === 'user').length;
|
|
|
|
let prompt: string;
|
|
|
|
if (allSummaries.length > 0) {
|
|
// 모든 기존 프로필 통합 (오래된 것부터)
|
|
const existingProfiles = allSummaries
|
|
.slice()
|
|
.reverse()
|
|
.map((s) => `[v${s.generation}] ${s.summary}`)
|
|
.join('\n\n');
|
|
|
|
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
|
기존 사용자 프로필들과 새로운 대화를 통합하여 사용자에 대한 이해를 업데이트하세요.
|
|
|
|
## 기존 사용자 프로필 (${allSummaries.length}개 버전)
|
|
${existingProfiles}
|
|
|
|
## 새로운 사용자 발언 (${userMsgCount}개)
|
|
${userMessages}
|
|
|
|
## 요구사항
|
|
1. **통합 분석**: 모든 기존 프로필을 종합하고 새로운 정보를 추가
|
|
2. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
|
|
3. **의미 있는 정보 추출**:
|
|
- 사용자의 관심사, 취미, 선호도
|
|
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
|
|
- 요청사항, 목표, 해결하려는 문제
|
|
- 개인적 맥락 (직업, 상황, 배경 등)
|
|
- 관심사 변화 추이
|
|
4. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
|
|
5. **간결하게**: 400-500자 이내
|
|
6. **한국어로 작성**
|
|
|
|
통합된 사용자 프로필:`;
|
|
} else {
|
|
prompt = `당신은 사용자 프로필 분석 전문가입니다.
|
|
대화 내용에서 사용자에 대한 정보를 추출하여 프로필을 작성하세요.
|
|
|
|
## 사용자 발언 (${userMsgCount}개)
|
|
${userMessages}
|
|
|
|
## 요구사항
|
|
1. **사용자 중심**: 봇 응답은 무시하고 사용자가 말한 내용만 분석
|
|
2. **의미 있는 정보 추출**:
|
|
- 사용자의 관심사, 취미, 선호도
|
|
- 질문한 주제들 (무엇에 대해 알고 싶어하는지)
|
|
- 요청사항, 목표, 해결하려는 문제
|
|
- 개인적 맥락 (직업, 상황, 배경 등)
|
|
3. **무의미한 내용 제외**: 인사말, 단순 확인, 감사 표현 등은 생략
|
|
4. **간결하게**: 200-300자 이내
|
|
5. **한국어로 작성**
|
|
|
|
사용자 프로필:`;
|
|
}
|
|
|
|
// OpenAI 사용 (설정된 경우)
|
|
if (env.OPENAI_API_KEY) {
|
|
const { generateProfileWithOpenAI } = await import('./openai-service');
|
|
return generateProfileWithOpenAI(env, prompt);
|
|
}
|
|
|
|
// 폴백: Workers AI
|
|
const input: WorkersAITextGenerationInput = {
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 500,
|
|
};
|
|
const response = await env.AI.run(
|
|
'@cf/meta/llama-3.1-8b-instruct' as '@cf/meta/llama-3.1-8b-instruct-fp8',
|
|
input
|
|
) as WorkersAITextGenerationOutput;
|
|
|
|
return response.response || '프로필 생성 실패';
|
|
}
|
|
|
|
// 오래된 요약 정리
|
|
async function cleanupOldSummaries(
|
|
db: D1Database,
|
|
userId: number,
|
|
chatId: string,
|
|
maxSummaries: number
|
|
): Promise<void> {
|
|
await db
|
|
.prepare(`
|
|
DELETE FROM summaries
|
|
WHERE user_id = ? AND chat_id = ?
|
|
AND id NOT IN (
|
|
SELECT id FROM summaries
|
|
WHERE user_id = ? AND chat_id = ?
|
|
ORDER BY generation DESC
|
|
LIMIT ?
|
|
)
|
|
`)
|
|
.bind(userId, chatId, userId, chatId, maxSummaries)
|
|
.run();
|
|
}
|
|
|
|
// 요약 실행 및 저장
|
|
export async function processAndSummarize(
|
|
env: Env,
|
|
userId: number,
|
|
chatId: string
|
|
): Promise<{ summarized: boolean; summary?: string }> {
|
|
const config = getConfig(env);
|
|
const messages = await getBufferedMessages(env.DB, userId, chatId);
|
|
|
|
if (messages.length < config.summaryThreshold) {
|
|
return { summarized: false };
|
|
}
|
|
|
|
// 모든 기존 요약 조회 (통합 분석용)
|
|
const allSummaries = await getAllSummaries(env.DB, userId, chatId);
|
|
const latestSummary = allSummaries[0] || null;
|
|
|
|
// AI 요약 생성 (모든 요약 통합)
|
|
const newSummary = await generateSummary(
|
|
env,
|
|
allSummaries,
|
|
messages
|
|
);
|
|
|
|
const newGeneration = (latestSummary?.generation || 0) + 1;
|
|
const newMessageCount = (latestSummary?.message_count || 0) + messages.length;
|
|
|
|
// 트랜잭션 실행
|
|
const results = await env.DB.batch([
|
|
env.DB
|
|
.prepare(`
|
|
INSERT INTO summaries (user_id, chat_id, generation, summary, message_count)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`)
|
|
.bind(userId, chatId, newGeneration, newSummary, newMessageCount),
|
|
|
|
env.DB
|
|
.prepare('DELETE FROM message_buffer WHERE user_id = ? AND chat_id = ?')
|
|
.bind(userId, chatId),
|
|
]);
|
|
|
|
// Batch 결과 검증
|
|
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0);
|
|
if (!allSuccessful) {
|
|
logger.error('Batch 부분 실패 (프로필 업데이트)', undefined, {
|
|
results,
|
|
userId,
|
|
chatId,
|
|
generation: newGeneration,
|
|
messageCount: newMessageCount,
|
|
context: 'update_summary'
|
|
});
|
|
throw new Error('프로필 업데이트 실패 - 관리자에게 문의하세요');
|
|
}
|
|
|
|
// 오래된 요약 정리
|
|
await cleanupOldSummaries(env.DB, userId, chatId, config.maxSummaries);
|
|
|
|
return { summarized: true, summary: newSummary };
|
|
}
|
|
|
|
// AI 응답 생성 (컨텍스트 포함)
|
|
export async function generateAIResponse(
|
|
env: Env,
|
|
userId: number,
|
|
chatId: string,
|
|
userMessage: string,
|
|
telegramUserId?: string
|
|
): Promise<string> {
|
|
const context = await getConversationContext(env.DB, userId, chatId);
|
|
|
|
// 모든 요약 통합 (최신순 → 오래된순으로 정렬하여 시간순 표시)
|
|
const integratedProfile = context.summaries.length > 0
|
|
? context.summaries
|
|
.slice()
|
|
.reverse() // 오래된 것부터 표시
|
|
.map((s) => `[v${s.generation}] ${s.summary}`)
|
|
.join('\n\n')
|
|
: null;
|
|
|
|
// 사용자 기억 조회 및 포맷팅
|
|
const { getMemories, formatMemoriesForPrompt } = await import('./services/memory-service');
|
|
const memories = await getMemories(env.DB, userId);
|
|
const memoriesSection = formatMemoriesForPrompt(memories);
|
|
|
|
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
|
|
${integratedProfile ? `
|
|
## 사용자 프로필 (${context.summaries.length}개 버전 통합)
|
|
${integratedProfile}
|
|
|
|
위 프로필들을 종합하여 사용자의 관심사, 맥락, 변화를 이해하고 개인화된 응답을 제공하세요.
|
|
최신 버전(높은 번호)의 정보를 우선시하되, 이전 버전의 맥락도 고려하세요.
|
|
` : ''}
|
|
${memoriesSection ? `
|
|
${memoriesSection}
|
|
|
|
위 배경 정보는 대화 맥락 이해용입니다. "기억", "저장된 정보" 등 직접 언급하지 마세요.
|
|
` : ''}
|
|
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
|
- 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
|
|
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
|
|
- 서버, VPS, 클라우드, 호스팅 관련 요청:
|
|
• 내 서버 목록 조회: manage_server(action="list") - 반드시 도구 호출
|
|
• 서버 추천/상담 시작: manage_server(action="start_consultation")
|
|
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
|
|
- 기술 문제, 에러, 오류, 장애 관련 요청:
|
|
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시
|
|
• manage_troubleshoot(action="start")를 호출하여 트러블슈팅 시작
|
|
• 트러블슈팅 진행 중인 메시지는 자동으로 전문가 AI에게 전달됨
|
|
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요.
|
|
- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요.
|
|
- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요.
|
|
- manage_deposit, manage_domain, manage_server, manage_troubleshoot, suggest_domains 도구 결과는 그대로 전달하세요.
|
|
- 도구 결과에 "__DIRECT__" 마커가 포함되어 있으면 해설이나 추가 설명 없이 결과를 그대로 전달하세요. 앞뒤로 텍스트를 추가하지 마세요.`;
|
|
|
|
const recentContext = context.recentMessages.slice(-10).map((m) => ({
|
|
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
|
|
content: m.message,
|
|
}));
|
|
|
|
// OpenAI 사용 (설정된 경우)
|
|
if (env.OPENAI_API_KEY) {
|
|
const { generateOpenAIResponse } = await import('./openai-service');
|
|
return generateOpenAIResponse(env, userMessage, systemPrompt, recentContext, telegramUserId, env.DB, chatId);
|
|
}
|
|
|
|
// 폴백: Workers AI
|
|
const input: WorkersAITextGenerationInput = {
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
...recentContext,
|
|
{ role: 'user', content: userMessage },
|
|
],
|
|
max_tokens: 500,
|
|
};
|
|
const response = await env.AI.run(
|
|
'@cf/meta/llama-3.1-8b-instruct' as '@cf/meta/llama-3.1-8b-instruct-fp8',
|
|
input
|
|
) as WorkersAITextGenerationOutput;
|
|
|
|
return response.response || '응답을 생성할 수 없습니다.';
|
|
}
|
|
|