515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
import type { Env, OpenAIMessage, ToolCall } from './types';
|
|
import { tools, selectToolsForMessage, executeTool } from './tools';
|
|
import { retryWithBackoff, RetryError } from './utils/retry';
|
|
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
|
|
import { createLogger } from './utils/logger';
|
|
import { metrics } from './utils/metrics';
|
|
import { getOpenAIUrl } from './utils/api-urls';
|
|
import { ERROR_MESSAGES } from './constants/messages';
|
|
import { processTroubleshootConsultation, hasTroubleshootSession } from './agents/troubleshoot-agent';
|
|
import { processDomainConsultation, hasDomainSession } from './agents/domain-agent';
|
|
import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent';
|
|
import { processDdosConsultation, hasDdosSession } from './agents/ddos-agent';
|
|
import { sendMessage } from './telegram';
|
|
|
|
const logger = createLogger('openai');
|
|
|
|
// 사용자 메시지에서 저장할 정보 추출 (패턴 기반)
|
|
const SAVEABLE_PATTERNS = [
|
|
// 회사/직장
|
|
/(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z0-9]+)(?:에서|에)\s*(?:일해|일하고|근무|다녀)/,
|
|
// 기술/언어 공부
|
|
/(?:요즘|지금|현재)?\s*([가-힣A-Za-z0-9+#]+)(?:로|을|를)?\s*(?:공부|개발|작업|배우)/,
|
|
// 직무/역할
|
|
/(?:나|저)?\s*(?:는|은)?\s*([가-힣A-Za-z]+)\s*(?:개발자|엔지니어|디자이너|기획자)/,
|
|
// 해외 거주
|
|
/([가-힣A-Za-z]+)(?:에서|에)\s*(?:살아|거주|있어)/,
|
|
// 서버/인프라 - 클라우드 제공자
|
|
/(?:나|저|우리)?\s*(?:는|은)?\s*(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24)\s*(?:사용|쓰|이용)/,
|
|
// 서버/인프라 - 서버 수량
|
|
/서버\s*(\d+)\s*(?:대|개)|(\d+)\s*(?:대|개)\s*서버/,
|
|
// 서버/인프라 - 트래픽/사용자 규모
|
|
/(?:트래픽|DAU|MAU|동시접속|사용자|유저)\s*(?:가|이)?\s*(?:약|대략|월|일)?\s*(\d+[\d,]*)\s*(?:명|만|천)?/,
|
|
// 서버/인프라 - 컨테이너/오케스트레이션
|
|
/(?:쿠버네티스|k8s|도커|docker|컨테이너)\s*(?:사용|쓰|운영|돌려)/,
|
|
];
|
|
|
|
function extractSaveableInfo(message: string): string | null {
|
|
// 제외 패턴 (이름, 생일, 국내 지역)
|
|
if (/(?:이름|생일|서울|부산|대전|대구|광주|인천)/.test(message)) {
|
|
return null;
|
|
}
|
|
|
|
for (const pattern of SAVEABLE_PATTERNS) {
|
|
if (pattern.test(message)) {
|
|
return message.trim();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 메모리 카테고리 감지
|
|
type MemoryCategory = 'company' | 'tech' | 'role' | 'location' | 'server' | null;
|
|
|
|
function detectMemoryCategory(content: string): MemoryCategory {
|
|
// 회사/직장: ~에서 일해, 근무, 다녀
|
|
if (/(?:에서|에)\s*(?:일해|일하고|근무|다녀)/.test(content)) {
|
|
return 'company';
|
|
}
|
|
// 기술/공부: ~공부, 배워, 개발
|
|
if (/(?:공부|개발|작업|배우)/.test(content)) {
|
|
return 'tech';
|
|
}
|
|
// 직무: 개발자, 엔지니어, 디자이너, 기획자
|
|
if (/(?:개발자|엔지니어|디자이너|기획자)/.test(content)) {
|
|
return 'role';
|
|
}
|
|
// 해외거주: ~에서 살아, 거주
|
|
if (/(?:에서|에)\s*(?:살아|거주|있어)/.test(content)) {
|
|
return 'location';
|
|
}
|
|
// 서버/인프라: 클라우드, 서버 수량, 트래픽, 컨테이너
|
|
if (/(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24|서버\s*\d|트래픽|DAU|MAU|동시접속|쿠버네티스|k8s|도커|docker|컨테이너)/i.test(content)) {
|
|
return 'server';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 백그라운드에서 메모리 저장 (응답에 영향 없음, 카테고리별 덮어쓰기)
|
|
async function saveMemorySilently(
|
|
db: D1Database | undefined,
|
|
telegramUserId: string | undefined,
|
|
content: string
|
|
): Promise<void> {
|
|
if (!db || !telegramUserId) return;
|
|
|
|
try {
|
|
const user = await db
|
|
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
|
.bind(telegramUserId)
|
|
.first<{ id: number }>();
|
|
|
|
if (!user) return;
|
|
|
|
const category = detectMemoryCategory(content);
|
|
|
|
// 카테고리가 감지되면, 동일 카테고리의 기존 메모리 삭제
|
|
if (category) {
|
|
const existing = await db
|
|
.prepare('SELECT id, content FROM user_memories WHERE user_id = ?')
|
|
.bind(user.id)
|
|
.all<{ id: number; content: string }>();
|
|
|
|
if (existing.results && existing.results.length > 0) {
|
|
// Collect IDs to delete
|
|
const idsToDelete = existing.results
|
|
.filter(memory => detectMemoryCategory(memory.content) === category)
|
|
.map(memory => memory.id);
|
|
|
|
if (idsToDelete.length > 0) {
|
|
// Single batch delete instead of N individual deletes
|
|
const placeholders = idsToDelete.map(() => '?').join(',');
|
|
await db.prepare(
|
|
`DELETE FROM user_memories WHERE id IN (${placeholders})`
|
|
).bind(...idsToDelete).run();
|
|
|
|
logger.info('Deleted existing memories of same category', {
|
|
userId: telegramUserId,
|
|
category,
|
|
deletedCount: idsToDelete.length
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 새 메모리 저장
|
|
await db
|
|
.prepare('INSERT INTO user_memories (user_id, content) VALUES (?, ?)')
|
|
.bind(user.id, content)
|
|
.run();
|
|
|
|
logger.info('Silent memory save', {
|
|
userId: telegramUserId,
|
|
category: category || 'uncategorized',
|
|
contentLength: content.length
|
|
});
|
|
} catch (error) {
|
|
logger.error('Silent memory save failed', error as Error);
|
|
}
|
|
}
|
|
|
|
// Circuit Breaker 인스턴스 (전역 공유)
|
|
export const openaiCircuitBreaker = new CircuitBreaker({
|
|
failureThreshold: 3, // 3회 연속 실패 시 차단
|
|
resetTimeoutMs: 30000, // 30초 후 복구 시도
|
|
monitoringWindowMs: 60000 // 1분 윈도우
|
|
});
|
|
|
|
interface OpenAIResponse {
|
|
choices: {
|
|
message: OpenAIMessage;
|
|
finish_reason: string;
|
|
}[];
|
|
}
|
|
|
|
// OpenAI API 호출 (retry + circuit breaker 적용)
|
|
async function callOpenAI(
|
|
env: Env,
|
|
apiKey: string,
|
|
messages: OpenAIMessage[],
|
|
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
|
): Promise<OpenAIResponse> {
|
|
const timer = metrics.startTimer('api_call_duration', { service: 'openai' });
|
|
|
|
try {
|
|
return await retryWithBackoff(
|
|
async () => {
|
|
const response = await fetch(getOpenAIUrl(env), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
messages,
|
|
tools: selectedTools?.length ? selectedTools : undefined,
|
|
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
|
max_tokens: 1000,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`OpenAI API 오류: ${response.status} - ${error}`);
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
{
|
|
maxRetries: 3,
|
|
initialDelayMs: 1000,
|
|
maxDelayMs: 10000,
|
|
}
|
|
);
|
|
} finally {
|
|
timer(); // duration 자동 기록 (성공/실패 관계없이)
|
|
}
|
|
}
|
|
|
|
// 메인 응답 생성 함수
|
|
export async function generateOpenAIResponse(
|
|
env: Env,
|
|
userMessage: string,
|
|
systemPrompt: string,
|
|
recentContext: { role: 'user' | 'assistant'; content: string }[],
|
|
telegramUserId?: string,
|
|
db?: D1Database,
|
|
chatIdStr?: string
|
|
): Promise<string> {
|
|
// Check if troubleshoot session is active
|
|
if (telegramUserId && env.DB) {
|
|
try {
|
|
const hasTroubleshootSess = await hasTroubleshootSession(env.DB, telegramUserId);
|
|
|
|
if (hasTroubleshootSess) {
|
|
logger.info('트러블슈팅 세션 감지, 트러블슈팅 에이전트로 라우팅', {
|
|
userId: telegramUserId
|
|
});
|
|
const troubleshootResponse = await processTroubleshootConsultation(env.DB, telegramUserId, userMessage, env);
|
|
|
|
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
|
if (troubleshootResponse !== '__PASSTHROUGH__') {
|
|
return troubleshootResponse;
|
|
}
|
|
// Continue to normal flow below
|
|
}
|
|
} catch (error) {
|
|
logger.error('Troubleshoot session check failed, continuing with normal flow', error as Error);
|
|
// Continue with normal flow if session check fails
|
|
}
|
|
|
|
// Check if domain consultation session is active
|
|
try {
|
|
const hasDomainSess = await hasDomainSession(env.DB, telegramUserId);
|
|
|
|
if (hasDomainSess) {
|
|
logger.info('도메인 세션 감지, 도메인 에이전트로 라우팅', {
|
|
userId: telegramUserId
|
|
});
|
|
const domainResponse = await processDomainConsultation(env.DB, telegramUserId, userMessage, env);
|
|
|
|
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
|
if (domainResponse !== '__PASSTHROUGH__') {
|
|
return domainResponse;
|
|
}
|
|
// Continue to normal flow below
|
|
}
|
|
} catch (error) {
|
|
logger.error('Domain session check failed, continuing with normal flow', error as Error, {
|
|
telegramUserId
|
|
});
|
|
// Continue with normal flow if session check fails
|
|
}
|
|
|
|
// Check for active deposit session
|
|
try {
|
|
const hasDepositSess = await hasDepositSession(env.DB, telegramUserId);
|
|
|
|
if (hasDepositSess) {
|
|
logger.info('예치금 세션 감지, 예치금 에이전트로 라우팅', {
|
|
userId: telegramUserId
|
|
});
|
|
const depositResponse = await processDepositConsultation(env.DB, telegramUserId, userMessage, env);
|
|
|
|
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
|
if (depositResponse !== '__PASSTHROUGH__') {
|
|
return depositResponse;
|
|
}
|
|
// Continue to normal flow below
|
|
}
|
|
} catch (error) {
|
|
logger.error('Deposit session check failed, continuing with normal flow', error as Error, {
|
|
telegramUserId
|
|
});
|
|
// Continue with normal flow if session check fails
|
|
}
|
|
|
|
// Check for active DDoS defense session
|
|
try {
|
|
const hasDdosSess = await hasDdosSession(env.DB, telegramUserId);
|
|
|
|
if (hasDdosSess) {
|
|
logger.info('DDoS 방어 세션 감지, DDoS 에이전트로 라우팅', {
|
|
userId: telegramUserId
|
|
});
|
|
const ddosResponse = await processDdosConsultation(env.DB, telegramUserId, userMessage, env);
|
|
|
|
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
|
if (ddosResponse !== '__PASSTHROUGH__') {
|
|
return ddosResponse;
|
|
}
|
|
// Continue to normal flow below
|
|
}
|
|
} catch (error) {
|
|
logger.error('DDoS session check failed, continuing with normal flow', error as Error, {
|
|
telegramUserId
|
|
});
|
|
// Continue with normal flow if session check fails
|
|
}
|
|
}
|
|
|
|
if (!env.OPENAI_API_KEY) {
|
|
throw new Error('OPENAI_API_KEY not configured');
|
|
}
|
|
|
|
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
|
|
|
try {
|
|
// Circuit Breaker로 전체 실행 감싸기
|
|
return await openaiCircuitBreaker.execute(async () => {
|
|
const messages: OpenAIMessage[] = [
|
|
{ role: 'system', content: systemPrompt },
|
|
...recentContext.map((m) => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
})),
|
|
{ role: 'user', content: userMessage },
|
|
];
|
|
|
|
// 동적 도구 선택
|
|
const selectedTools = selectToolsForMessage(userMessage);
|
|
|
|
// 첫 번째 호출
|
|
let response = await callOpenAI(env, apiKey, messages, selectedTools);
|
|
let assistantMessage = response.choices[0].message;
|
|
|
|
logger.info('tool_calls', {
|
|
calls: assistantMessage.tool_calls ? assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments })) : 'none'
|
|
});
|
|
logger.info('content', { preview: assistantMessage.content?.slice(0, 100) });
|
|
|
|
// Function Calling 처리 (최대 3회 반복)
|
|
let iterations = 0;
|
|
while (assistantMessage.tool_calls && iterations < 3) {
|
|
iterations++;
|
|
|
|
// 도구 호출을 병렬 실행
|
|
type ToolResult = {
|
|
early: true;
|
|
result: string;
|
|
toolCall: ToolCall;
|
|
} | {
|
|
early: false;
|
|
message: OpenAIMessage;
|
|
} | null;
|
|
|
|
const toolPromises = assistantMessage.tool_calls.map(async (toolCall): Promise<ToolResult> => {
|
|
let args: Record<string, unknown>;
|
|
try {
|
|
args = JSON.parse(toolCall.function.arguments);
|
|
} catch (parseError) {
|
|
logger.error('Failed to parse tool arguments', parseError as Error, {
|
|
toolName: toolCall.function.name,
|
|
raw: toolCall.function.arguments.slice(0, 200) // 일부만 로깅
|
|
});
|
|
return null; // 파싱 실패 시 null 반환
|
|
}
|
|
|
|
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
|
|
|
|
// Early return 체크 (__KEYBOARD__, __DIRECT__)
|
|
if (result.includes('__KEYBOARD__') || result.includes('__DIRECT__')) {
|
|
return { early: true as const, result, toolCall };
|
|
}
|
|
|
|
return {
|
|
early: false as const,
|
|
message: {
|
|
role: 'tool' as const,
|
|
tool_call_id: toolCall.id,
|
|
content: result,
|
|
}
|
|
};
|
|
});
|
|
|
|
const results = await Promise.all(toolPromises);
|
|
|
|
// Early return 처리
|
|
const earlyResult = results.find((r): r is { early: true; result: string; toolCall: ToolCall } =>
|
|
r !== null && r.early === true
|
|
);
|
|
if (earlyResult) {
|
|
if (earlyResult.result.includes('__DIRECT__')) {
|
|
// Remove __DIRECT__ marker and everything before it (AI commentary)
|
|
const directIndex = earlyResult.result.indexOf('__DIRECT__');
|
|
return earlyResult.result.slice(directIndex + '__DIRECT__'.length).trim();
|
|
}
|
|
return earlyResult.result;
|
|
}
|
|
|
|
// 정상 결과 처리 (null 제외)
|
|
const toolResults = results
|
|
.filter((r): r is { early: false; message: OpenAIMessage } =>
|
|
r !== null && r.early === false
|
|
);
|
|
|
|
// 메모리 저장([SAVED])이 포함되어 있으면 모든 메모리 관련 결과를 숨김
|
|
const hasSaveResult = toolResults.some(r => r.message.content === '[SAVED]');
|
|
const isMemoryResult = (content: string | null) =>
|
|
content === '[SAVED]' || content?.startsWith('📋 저장된 기억');
|
|
|
|
if (hasSaveResult) {
|
|
// 메모리 저장이 있으면: 메모리 관련 도구 호출을 모두 숨기고 다시 호출
|
|
const nonMemoryResults = toolResults.filter(r => !isMemoryResult(r.message.content));
|
|
|
|
if (nonMemoryResults.length === 0) {
|
|
// 메모리 작업만 했으면 도구 호출 없이 다시 호출
|
|
response = await callOpenAI(env, apiKey, messages, undefined);
|
|
assistantMessage = response.choices[0].message;
|
|
break;
|
|
}
|
|
|
|
// 메모리 외 다른 도구도 있으면 그것만 포함
|
|
const filteredToolCalls = assistantMessage.tool_calls?.filter(tc => {
|
|
const matchingResult = toolResults.find(r => r.message.tool_call_id === tc.id);
|
|
return matchingResult && !isMemoryResult(matchingResult.message.content);
|
|
});
|
|
|
|
if (filteredToolCalls && filteredToolCalls.length > 0) {
|
|
messages.push({
|
|
role: 'assistant',
|
|
content: assistantMessage.content,
|
|
tool_calls: filteredToolCalls,
|
|
});
|
|
messages.push(...nonMemoryResults.map(r => r.message));
|
|
}
|
|
} else {
|
|
// 메모리 저장이 없으면 모든 결과 포함
|
|
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
messages.push({
|
|
role: 'assistant',
|
|
content: assistantMessage.content,
|
|
tool_calls: assistantMessage.tool_calls,
|
|
});
|
|
messages.push(...toolResults.map(r => r.message));
|
|
}
|
|
}
|
|
|
|
// 다시 호출 (도구 없이 응답 생성)
|
|
response = await callOpenAI(env, apiKey, messages, undefined);
|
|
assistantMessage = response.choices[0].message;
|
|
}
|
|
|
|
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
|
|
|
// 백그라운드 메모리 저장 (AI 응답과 무관하게)
|
|
const saveableInfo = extractSaveableInfo(userMessage);
|
|
if (saveableInfo) {
|
|
// 비동기로 저장, 응답 지연 없음
|
|
saveMemorySilently(db, telegramUserId, saveableInfo).catch(err => {
|
|
logger.debug('Memory save failed (non-critical)', { error: (err as Error).message });
|
|
});
|
|
}
|
|
|
|
return finalResponse;
|
|
});
|
|
} catch (error) {
|
|
// 에러 처리
|
|
if (error instanceof CircuitBreakerError) {
|
|
logger.error('Circuit breaker open', error as Error);
|
|
return ERROR_MESSAGES.SERVICE_UNAVAILABLE;
|
|
}
|
|
|
|
if (error instanceof RetryError) {
|
|
logger.error('All retry attempts failed', error as Error);
|
|
return ERROR_MESSAGES.AI_RESPONSE_FAILED;
|
|
}
|
|
|
|
// 기타 에러
|
|
logger.error('Unexpected error', error as Error);
|
|
return ERROR_MESSAGES.UNEXPECTED_ERROR;
|
|
}
|
|
}
|
|
|
|
// 프로필 생성용 (도구 없이)
|
|
export async function generateProfileWithOpenAI(
|
|
env: Env,
|
|
prompt: string
|
|
): Promise<string> {
|
|
if (!env.OPENAI_API_KEY) {
|
|
throw new Error('OPENAI_API_KEY not configured');
|
|
}
|
|
|
|
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
|
|
|
try {
|
|
// Circuit Breaker로 실행 감싸기
|
|
return await openaiCircuitBreaker.execute(async () => {
|
|
const response = await callOpenAI(
|
|
env,
|
|
apiKey,
|
|
[{ role: 'user', content: prompt }],
|
|
undefined // 도구 없이 호출
|
|
);
|
|
|
|
return response.choices[0].message.content || '프로필 생성 실패';
|
|
});
|
|
} catch (error) {
|
|
// 에러 처리
|
|
if (error instanceof CircuitBreakerError) {
|
|
logger.error('Profile - Circuit breaker open', error as Error);
|
|
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
|
|
}
|
|
|
|
if (error instanceof RetryError) {
|
|
logger.error('Profile - All retry attempts failed', error as Error);
|
|
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
|
|
}
|
|
|
|
// 기타 에러
|
|
logger.error('Profile - Unexpected error', error as Error);
|
|
return ERROR_MESSAGES.PROFILE_GENERATION_UNEXPECTED;
|
|
}
|
|
}
|