refactor: move troubleshoot-agent to agents directory
- Move troubleshoot-agent.ts to src/agents/ - Update import paths in dependent files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
457
src/agents/troubleshoot-agent.ts
Normal file
457
src/agents/troubleshoot-agent.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Troubleshoot Agent - 시스템 트러블슈팅 전문가
|
||||
*
|
||||
* 기능:
|
||||
* - 대화형 문제 진단 및 해결
|
||||
* - 세션 기반 정보 수집
|
||||
* - 카테고리별 전문 솔루션 제공
|
||||
* - Brave Search / Context7 도구로 최신 해결책 검색
|
||||
*
|
||||
* Manual Test:
|
||||
* 1. User: "서버가 502 에러가 나요"
|
||||
* 2. Expected: Category detection → 1-2 questions → Solution
|
||||
* 3. User: "해결됐어요"
|
||||
* 4. Expected: Session deleted
|
||||
*/
|
||||
|
||||
import type { Env, TroubleshootSession } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { executeSearchWeb, executeLookupDocs } from '../tools/search-tool';
|
||||
import { TROUBLESHOOT_STATUS } from '../constants';
|
||||
|
||||
const logger = createLogger('troubleshoot-agent');
|
||||
|
||||
// KV Session Management
|
||||
const SESSION_TTL = 3600; // 1 hour
|
||||
const SESSION_KEY_PREFIX = 'troubleshoot_session:';
|
||||
|
||||
export async function getTroubleshootSession(
|
||||
kv: KVNamespace,
|
||||
userId: string
|
||||
): Promise<TroubleshootSession | null> {
|
||||
try {
|
||||
const key = `${SESSION_KEY_PREFIX}${userId}`;
|
||||
logger.info('세션 조회 시도', { userId, key });
|
||||
const data = await kv.get(key, 'json');
|
||||
|
||||
if (!data) {
|
||||
logger.info('세션 없음', { userId, key });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('세션 조회 성공', { userId, key, status: (data as TroubleshootSession).status });
|
||||
return data as TroubleshootSession;
|
||||
} catch (error) {
|
||||
logger.error('세션 조회 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTroubleshootSession(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
session: TroubleshootSession
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = `${SESSION_KEY_PREFIX}${userId}`;
|
||||
session.updatedAt = Date.now();
|
||||
|
||||
const sessionData = JSON.stringify(session);
|
||||
logger.info('세션 저장 시도', { userId, key, status: session.status, dataLength: sessionData.length });
|
||||
|
||||
await kv.put(key, sessionData, {
|
||||
expirationTtl: SESSION_TTL,
|
||||
});
|
||||
|
||||
logger.info('세션 저장 성공', { userId, key, status: session.status });
|
||||
} catch (error) {
|
||||
logger.error('세션 저장 실패', error as Error, { userId, key: `${SESSION_KEY_PREFIX}${userId}` });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTroubleshootSession(
|
||||
kv: KVNamespace,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = `${SESSION_KEY_PREFIX}${userId}`;
|
||||
await kv.delete(key);
|
||||
logger.info('세션 삭제 성공', { userId });
|
||||
} catch (error) {
|
||||
logger.error('세션 삭제 실패', error as Error, { userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Troubleshoot Expert AI Tools
|
||||
const troubleshootTools = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'search_solution',
|
||||
description: 'Brave Search로 에러 메시지, 해결책, Stack Overflow 답변 검색합니다. 예: "nginx 502 bad gateway solution", "mysql connection pool exhausted fix"',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '검색 쿼리 (에러 메시지, 기술 스택 포함, 영문 권장)',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'lookup_docs',
|
||||
description: '프레임워크/라이브러리 공식 문서에서 트러블슈팅 가이드, 에러 코드, 디버깅 방법을 조회합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
library: {
|
||||
type: 'string',
|
||||
description: '라이브러리/프레임워크 이름 (예: nginx, nodejs, mysql, docker)',
|
||||
},
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: '조회할 주제 (예: troubleshooting, error codes, debugging, common issues)',
|
||||
},
|
||||
},
|
||||
required: ['library', 'topic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Execute troubleshoot tool
|
||||
async function executeTroubleshootTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
logger.info('도구 실행', { toolName, args });
|
||||
|
||||
switch (toolName) {
|
||||
case 'search_solution': {
|
||||
const result = await executeSearchWeb({ query: args.query as string }, env);
|
||||
return result;
|
||||
}
|
||||
case 'lookup_docs': {
|
||||
const result = await executeLookupDocs({
|
||||
library: args.library as string,
|
||||
query: args.topic as string,
|
||||
}, env);
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return `알 수 없는 도구: ${toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API 응답 타입
|
||||
interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'assistant';
|
||||
content: string | null;
|
||||
tool_calls?: OpenAIToolCall[];
|
||||
}
|
||||
|
||||
interface OpenAIAPIResponse {
|
||||
choices: Array<{
|
||||
message: OpenAIMessage;
|
||||
finish_reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// OpenAI 호출 (트러블슈팅 전문가 AI with Function Calling)
|
||||
async function callTroubleshootExpertAI(
|
||||
env: Env,
|
||||
session: TroubleshootSession,
|
||||
userMessage: string
|
||||
): Promise<{ action: 'question' | 'diagnose' | 'solve'; message: string; collectedInfo: TroubleshootSession['collectedInfo'] }> {
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
throw new Error('OPENAI_API_KEY not configured');
|
||||
}
|
||||
|
||||
const { getOpenAIUrl } = await import('../utils/api-urls');
|
||||
|
||||
// Build conversation history
|
||||
const conversationHistory = session.messages.map(m => ({
|
||||
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const systemPrompt = `당신은 20년 경력의 시니어 DevOps/SRE 엔지니어입니다.
|
||||
|
||||
## 전문성 (20년 경력)
|
||||
- Linux/Windows Server 관리 마스터 (Apache, Nginx, IIS 등)
|
||||
- 네트워크 트러블슈팅 전문가 (DNS, SSL, 방화벽, CDN)
|
||||
- 애플리케이션 배포 및 디버깅 경험 수천 건
|
||||
- 모든 주요 기술 스택 경험 (Node.js, Python, PHP, Java, Ruby, Go 등)
|
||||
- 데이터베이스 성능 튜닝 (MySQL, PostgreSQL, MongoDB, Redis)
|
||||
- 컨테이너/오케스트레이션 (Docker, Kubernetes)
|
||||
- 클라우드 인프라 마스터 (Anvil 전문)
|
||||
|
||||
## 성격
|
||||
- 차분하고 체계적인 문제 해결
|
||||
- 비기술자도 이해하기 쉽게 설명
|
||||
- 단계별로 명확한 지시
|
||||
- 긴급 상황에서도 침착함 유지
|
||||
- 따뜻하고 격려하는 어조
|
||||
|
||||
## 금지 사항 (절대 위반 금지)
|
||||
- AWS, GCP, Azure, DigitalOcean, Vultr, Linode 등 다른 클라우드 서비스 추천 금지
|
||||
- 경쟁사 서비스 언급 금지
|
||||
- 우리 서비스(Anvil)만 언급
|
||||
|
||||
## 문제 카테고리
|
||||
1. **Server/Infrastructure**: 서버 느림, 502/503 에러, 메모리 부족, CPU 과부하, 디스크 풀
|
||||
2. **Domain/DNS**: 도메인 연결 안됨, SSL 인증서 오류, DNS 전파 지연, 네임서버 문제
|
||||
3. **Code/Deploy**: 배포 실패, 빌드 에러, 의존성 충돌, 환경변수 누락
|
||||
4. **Network**: 연결 끊김, 타임아웃, CORS 오류, 방화벽 차단
|
||||
5. **Database**: 쿼리 느림, 연결 풀 고갈, 데드락, 인덱스 누락
|
||||
|
||||
## 도구 사용 가이드 (적극 활용)
|
||||
- 에러 메시지, Stack trace 언급 시 → **반드시** search_solution 호출
|
||||
- 특정 프레임워크/라이브러리 문제 → lookup_docs 호출하여 공식 가이드 확인
|
||||
- 도구 결과를 자연스럽게 해결책에 포함 (예: "공식 문서에 따르면...", "최근 Stack Overflow 답변을 보니...")
|
||||
- 검색 쿼리는 영문으로 (더 많은 결과)
|
||||
|
||||
## 대화 흐름
|
||||
1. **문제 청취**: 사용자 증상 경청, 카테고리 자동 분류
|
||||
2. **정보 수집**: 1-2개 질문으로 환경/에러 메시지 확인
|
||||
3. **진단**: 수집된 정보 기반 원인 분석 (도구 활용)
|
||||
4. **해결**: 단계별 명확한 솔루션 제시 (명령어 포함)
|
||||
5. **확인**: 해결 여부 확인, 필요시 추가 지원
|
||||
|
||||
## 핵심 규칙 (반드시 준수)
|
||||
- 에러 메시지가 명확하면 즉시 action="diagnose" 또는 "solve"로 진단/해결 제시
|
||||
- 정보가 애매하면 action="question"으로 최대 2개 질문
|
||||
- 해결책은 구체적이고 실행 가능한 명령어/코드 포함
|
||||
- 해결 후 "해결되셨나요?" 확인
|
||||
- 해결 안되면 추가 조치 또는 상위 엔지니어 에스컬레이션 제안
|
||||
|
||||
## 현재 수집된 정보
|
||||
${JSON.stringify(session.collectedInfo, null, 2)}
|
||||
|
||||
## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지)
|
||||
{
|
||||
"action": "question" | "diagnose" | "solve",
|
||||
"message": "사용자에게 보여줄 메시지 (도구 결과 자연스럽게 포함)",
|
||||
"collectedInfo": {
|
||||
"category": "카테고리 (자동 분류)",
|
||||
"symptoms": "증상 요약",
|
||||
"environment": "환경 정보 (OS, 프레임워크 등)",
|
||||
"errorMessage": "에러 메시지 (있는 경우)"
|
||||
}
|
||||
}
|
||||
|
||||
action 선택 기준:
|
||||
- "question": 정보가 부족하여 추가 질문 필요 (최대 2회)
|
||||
- "diagnose": 정보 충분, 원인 분석 제시
|
||||
- "solve": 즉시 해결책 제시 가능
|
||||
|
||||
중요: 20년 경험으로 일반적인 문제는 즉시 solve 가능합니다.`;
|
||||
|
||||
try {
|
||||
// Messages array that we'll build up with tool results
|
||||
const messages: Array<{ role: string; content: string | null; tool_calls?: OpenAIToolCall[]; tool_call_id?: string; name?: string }> = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...conversationHistory,
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
|
||||
const MAX_TOOL_CALLS = 3;
|
||||
let toolCallCount = 0;
|
||||
|
||||
// Loop to handle tool calls
|
||||
while (toolCallCount < MAX_TOOL_CALLS) {
|
||||
const requestBody = {
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: troubleshootTools,
|
||||
tool_choice: 'auto',
|
||||
response_format: { type: 'json_object' },
|
||||
max_tokens: 1500,
|
||||
temperature: 0.5,
|
||||
};
|
||||
|
||||
const response = await fetch(getOpenAIUrl(env), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIAPIResponse;
|
||||
const assistantMessage = data.choices[0].message;
|
||||
|
||||
// Check if AI wants to call tools
|
||||
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
||||
logger.info('도구 호출 요청', {
|
||||
tools: assistantMessage.tool_calls.map(tc => tc.function.name),
|
||||
});
|
||||
|
||||
// Add assistant message with tool calls
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessage.content,
|
||||
tool_calls: assistantMessage.tool_calls,
|
||||
});
|
||||
|
||||
// Execute each tool and add results
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
const result = await executeTroubleshootTool(toolCall.function.name, args, env);
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
content: result,
|
||||
});
|
||||
|
||||
toolCallCount++;
|
||||
}
|
||||
|
||||
// Continue loop to get AI's response with tool results
|
||||
continue;
|
||||
}
|
||||
|
||||
// No tool calls - parse the final response
|
||||
const aiResponse = assistantMessage.content || '';
|
||||
logger.info('AI 응답', { response: aiResponse.slice(0, 200), toolCallCount });
|
||||
|
||||
// JSON 파싱 (마크다운 코드 블록 제거)
|
||||
const jsonMatch = aiResponse.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) ||
|
||||
aiResponse.match(/(\{[\s\S]*\})/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
logger.error('JSON 파싱 실패', new Error('No JSON found'), { response: aiResponse });
|
||||
throw new Error('AI 응답 형식 오류');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[1]);
|
||||
|
||||
// Validate response structure
|
||||
if (!parsed.action || !parsed.message) {
|
||||
throw new Error('Invalid AI response structure');
|
||||
}
|
||||
|
||||
return {
|
||||
action: parsed.action,
|
||||
message: parsed.message,
|
||||
collectedInfo: parsed.collectedInfo || session.collectedInfo,
|
||||
};
|
||||
}
|
||||
|
||||
// Max tool calls reached, force a solve
|
||||
logger.warn('최대 도구 호출 횟수 도달', { toolCallCount });
|
||||
return {
|
||||
action: 'solve',
|
||||
message: '수집한 정보를 바탕으로 해결책을 제시해드리겠습니다.',
|
||||
collectedInfo: session.collectedInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Troubleshoot Expert AI 호출 실패', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main troubleshooting processing
|
||||
export async function processTroubleshoot(
|
||||
userMessage: string,
|
||||
session: TroubleshootSession,
|
||||
env: Env
|
||||
): Promise<string> {
|
||||
try {
|
||||
logger.info('트러블슈팅 처리 시작', {
|
||||
userId: session.telegramUserId,
|
||||
message: userMessage.slice(0, 50),
|
||||
status: session.status
|
||||
});
|
||||
|
||||
// 취소 키워드 처리 (모든 상태에서 작동)
|
||||
if (/^(취소|다시|처음|리셋|초기화)/.test(userMessage.trim()) ||
|
||||
/취소할[게래]|다시\s*시작|처음부터/.test(userMessage)) {
|
||||
await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId);
|
||||
logger.info('사용자 요청으로 트러블슈팅 취소', {
|
||||
userId: session.telegramUserId,
|
||||
previousStatus: session.status,
|
||||
trigger: userMessage.slice(0, 20)
|
||||
});
|
||||
return '트러블슈팅이 취소되었습니다. 다시 시작하려면 문제를 말씀해주세요.';
|
||||
}
|
||||
|
||||
// 해결 완료 키워드
|
||||
if (/해결[됐됨했함]|고마워|감사|끝|완료/.test(userMessage)) {
|
||||
await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId);
|
||||
logger.info('문제 해결 완료', {
|
||||
userId: session.telegramUserId,
|
||||
category: session.collectedInfo.category
|
||||
});
|
||||
return '✅ 문제가 해결되어 다행입니다! 앞으로도 언제든 도움이 필요하시면 말씀해주세요. 😊';
|
||||
}
|
||||
|
||||
// 상담과 무관한 키워드 감지 (passthrough)
|
||||
const unrelatedPatterns = /서버\s*추천|날씨|계산|도메인\s*추천|입금|충전|잔액|기억|저장/;
|
||||
if (unrelatedPatterns.test(userMessage)) {
|
||||
await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId);
|
||||
logger.info('무관한 요청으로 세션 자동 종료', {
|
||||
userId: session.telegramUserId,
|
||||
message: userMessage.slice(0, 30)
|
||||
});
|
||||
return '__PASSTHROUGH__';
|
||||
}
|
||||
|
||||
// Add user message to history
|
||||
session.messages.push({ role: 'user', content: userMessage });
|
||||
|
||||
// Call Troubleshoot Expert AI
|
||||
const aiResult = await callTroubleshootExpertAI(env, session, userMessage);
|
||||
|
||||
// Update collected info
|
||||
session.collectedInfo = { ...session.collectedInfo, ...aiResult.collectedInfo };
|
||||
|
||||
// Add AI response to history
|
||||
session.messages.push({ role: 'assistant', content: aiResult.message });
|
||||
|
||||
// Update session status based on action
|
||||
if (aiResult.action === 'diagnose') {
|
||||
session.status = TROUBLESHOOT_STATUS.DIAGNOSING;
|
||||
} else if (aiResult.action === 'solve') {
|
||||
session.status = TROUBLESHOOT_STATUS.SOLVING;
|
||||
} else {
|
||||
session.status = TROUBLESHOOT_STATUS.GATHERING;
|
||||
}
|
||||
|
||||
await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session);
|
||||
|
||||
return aiResult.message;
|
||||
} catch (error) {
|
||||
logger.error('트러블슈팅 처리 실패', error as Error, { userId: session.telegramUserId });
|
||||
|
||||
// Clean up session on error
|
||||
await deleteTroubleshootSession(env.SESSION_KV, session.telegramUserId);
|
||||
|
||||
return '죄송합니다. 트러블슈팅 중 오류가 발생했습니다.\n다시 시도하려면 문제를 말씀해주세요.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user