feat: add memory system and troubleshoot agent
Memory System: - Add category-based memory storage (company, tech, role, location, server) - Silent background saving via saveMemorySilently() - Category-based overwrite (same category replaces old memory) - Server-related pattern detection (AWS, GCP, k8s, traffic info) - Memory management tool (list, delete) Troubleshoot Agent: - Session-based troubleshooting conversation (KV, 1h TTL) - 20-year DevOps/SRE expert persona - Support for server/infra, domain/DNS, code/deploy, network, database issues - Internal tools: search_solution (Brave), lookup_docs (Context7) - Auto-trigger on error-related keywords - Session completion and cancellation support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,126 @@ import { ERROR_MESSAGES } from './constants/messages';
|
||||
|
||||
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) {
|
||||
for (const memory of existing.results) {
|
||||
if (detectMemoryCategory(memory.content) === category) {
|
||||
await db
|
||||
.prepare('DELETE FROM user_memories WHERE id = ?')
|
||||
.bind(memory.id)
|
||||
.run();
|
||||
logger.info('Memory replaced (same category)', {
|
||||
userId: telegramUserId,
|
||||
category,
|
||||
oldContent: memory.content.slice(0, 30),
|
||||
newContent: content.slice(0, 30)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 새 메모리 저장
|
||||
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회 연속 실패 시 차단
|
||||
@@ -104,12 +224,41 @@ export async function generateOpenAIResponse(
|
||||
userId: telegramUserId,
|
||||
status: session.status
|
||||
});
|
||||
return await processServerConsultation(userMessage, session, env);
|
||||
const result = await processServerConsultation(userMessage, session, env);
|
||||
|
||||
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
||||
if (result !== '__PASSTHROUGH__') {
|
||||
return result;
|
||||
}
|
||||
// Continue to normal flow below
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Session check failed, continuing with normal flow', error as Error);
|
||||
// Continue with normal flow if session check fails
|
||||
}
|
||||
|
||||
// Check if troubleshoot session is active
|
||||
try {
|
||||
const { getTroubleshootSession, processTroubleshoot } = await import('./troubleshoot-agent');
|
||||
const troubleshootSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId);
|
||||
|
||||
if (troubleshootSession && troubleshootSession.status !== 'completed') {
|
||||
logger.info('Active troubleshoot session detected, routing to troubleshoot', {
|
||||
userId: telegramUserId,
|
||||
status: troubleshootSession.status
|
||||
});
|
||||
const result = await processTroubleshoot(userMessage, troubleshootSession, env);
|
||||
|
||||
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
||||
if (result !== '__PASSTHROUGH__') {
|
||||
return result;
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
@@ -203,16 +352,49 @@ export async function generateOpenAIResponse(
|
||||
const toolResults = results
|
||||
.filter((r): r is { early: false; message: OpenAIMessage } =>
|
||||
r !== null && r.early === false
|
||||
)
|
||||
.map(r => r.message);
|
||||
);
|
||||
|
||||
// 대화에 추가
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessage.content,
|
||||
tool_calls: assistantMessage.tool_calls,
|
||||
});
|
||||
messages.push(...toolResults);
|
||||
// 메모리 저장([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);
|
||||
@@ -221,6 +403,13 @@ export async function generateOpenAIResponse(
|
||||
|
||||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||
|
||||
// 백그라운드 메모리 저장 (AI 응답과 무관하게)
|
||||
const saveableInfo = extractSaveableInfo(userMessage);
|
||||
if (saveableInfo) {
|
||||
// 비동기로 저장, 응답 지연 없음
|
||||
saveMemorySilently(db, telegramUserId, saveableInfo).catch(() => {});
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user