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:
kappa
2026-01-27 14:28:22 +09:00
parent 6392a17d4f
commit 860e36a688
13 changed files with 1328 additions and 58 deletions

View File

@@ -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) {