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:
162
MEMORY_FEATURE.md
Normal file
162
MEMORY_FEATURE.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Memory Feature Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
사용자가 명시적으로 기억해달라고 요청한 정보를 저장하고 관리하는 기능입니다.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
### 1. Migration (NEW)
|
||||||
|
- `migrations/003_add_user_memories.sql`
|
||||||
|
- user_memories 테이블 생성
|
||||||
|
- user_id, content, created_at 컬럼
|
||||||
|
- 인덱스: idx_memories_user (user_id, created_at DESC)
|
||||||
|
|
||||||
|
### 2. Memory Service (NEW)
|
||||||
|
- `src/services/memory-service.ts`
|
||||||
|
- `saveMemory(db, userId, content)` - 기억 저장
|
||||||
|
- `getMemories(db, userId)` - 모든 기억 조회 (최신순)
|
||||||
|
- `deleteMemory(db, userId, memoryId)` - ID로 삭제
|
||||||
|
- `deleteMemoryByContent(db, userId, searchText)` - LIKE 검색으로 삭제
|
||||||
|
- `formatMemoriesForPrompt(memories)` - 시스템 프롬프트용 포맷팅
|
||||||
|
|
||||||
|
### 3. Memory Tool (NEW)
|
||||||
|
- `src/tools/memory-tool.ts`
|
||||||
|
- Function Calling 도구 정의
|
||||||
|
- 키워드: 기억해줘, 기억해, 잊어줘, 지워줘, 내 기억, 저장된 정보, remember, forget
|
||||||
|
- Actions: save, list, delete
|
||||||
|
- 고정 형식 응답:
|
||||||
|
- save: "✅ 기억했습니다: {content}"
|
||||||
|
- list: "📋 저장된 기억 (N개)\n\n1. ... (ID: X)"
|
||||||
|
- delete: "✅ N개의 기억을 삭제했습니다:\n\n1. ..."
|
||||||
|
|
||||||
|
### 4. Types (MODIFIED)
|
||||||
|
- `src/types.ts`
|
||||||
|
- `Memory` 인터페이스 추가
|
||||||
|
- `ManageMemoryArgs` 인터페이스 추가
|
||||||
|
|
||||||
|
### 5. Tools Index (MODIFIED)
|
||||||
|
- `src/tools/index.ts`
|
||||||
|
- manageMemoryTool import
|
||||||
|
- ManageMemoryArgsSchema 추가 (Zod validation)
|
||||||
|
- tools 배열에 manageMemoryTool 추가
|
||||||
|
- TOOL_CATEGORIES에 memory 카테고리 추가
|
||||||
|
- CATEGORY_PATTERNS에 memory 패턴 추가
|
||||||
|
- executeTool에 manage_memory 케이스 추가
|
||||||
|
|
||||||
|
### 6. Summary Service (MODIFIED)
|
||||||
|
- `src/summary-service.ts`
|
||||||
|
- generateAIResponse 함수 수정:
|
||||||
|
- getMemories 호출로 사용자 기억 조회
|
||||||
|
- formatMemoriesForPrompt로 포맷팅
|
||||||
|
- 시스템 프롬프트에 "사용자가 기억해달라고 한 정보" 섹션 추가
|
||||||
|
- manage_memory 도구 사용 안내 추가
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS user_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Usage Examples
|
||||||
|
|
||||||
|
### 1. 기억 저장
|
||||||
|
**사용자**: "나는 Go 개발자야. 기억해줘."
|
||||||
|
**AI**: manage_memory(action="save", content="Go 개발자")
|
||||||
|
**응답**: "✅ 기억했습니다: Go 개발자"
|
||||||
|
|
||||||
|
### 2. 기억 조회
|
||||||
|
**사용자**: "내가 뭐라고 했지?"
|
||||||
|
**AI**: manage_memory(action="list")
|
||||||
|
**응답**:
|
||||||
|
```
|
||||||
|
📋 저장된 기억 (2개)
|
||||||
|
|
||||||
|
1. Go 개발자 (ID: 1)
|
||||||
|
2. 서울 거주 (ID: 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 기억 삭제 (ID)
|
||||||
|
**사용자**: "1번 지워줘"
|
||||||
|
**AI**: manage_memory(action="delete", memory_id=1)
|
||||||
|
**응답**: "✅ 기억을 삭제했습니다 (ID: 1)"
|
||||||
|
|
||||||
|
### 4. 기억 삭제 (내용 검색)
|
||||||
|
**사용자**: "Go 관련된 거 잊어줘"
|
||||||
|
**AI**: manage_memory(action="delete", content="Go")
|
||||||
|
**응답**:
|
||||||
|
```
|
||||||
|
✅ 1개의 기억을 삭제했습니다:
|
||||||
|
|
||||||
|
1. Go 개발자
|
||||||
|
```
|
||||||
|
|
||||||
|
## System Prompt Integration
|
||||||
|
|
||||||
|
기억이 있는 경우 시스템 프롬프트에 다음과 같이 추가됩니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
## 사용자가 기억해달라고 한 정보
|
||||||
|
- Go 개발자
|
||||||
|
- 서울 거주
|
||||||
|
- 반려견 이름은 뭉치
|
||||||
|
|
||||||
|
위 정보들은 사용자가 명시적으로 기억해달라고 요청한 중요한 정보입니다. 대화 시 이를 참고하세요.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
1. **D1 마이그레이션 실행** (로컬 테스트)
|
||||||
|
```bash
|
||||||
|
wrangler d1 execute telegram-conversations --local --file=migrations/003_add_user_memories.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **프로덕션 배포** (⚠️ 주의: 스키마 변경)
|
||||||
|
```bash
|
||||||
|
wrangler d1 execute telegram-conversations --file=migrations/003_add_user_memories.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **TypeScript 검증**
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **로컬 테스트**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **배포**
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] "기억해줘" → save action 호출 확인
|
||||||
|
- [ ] "내 기억" → list action 호출 확인
|
||||||
|
- [ ] "잊어줘" → delete action 호출 확인
|
||||||
|
- [ ] 저장된 기억이 시스템 프롬프트에 포함되는지 확인
|
||||||
|
- [ ] AI가 기억된 정보를 참고하여 응답하는지 확인
|
||||||
|
- [ ] 삭제 시 해당 기억이 더 이상 프롬프트에 나타나지 않는지 확인
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **인덱스**: user_id + created_at DESC 복합 인덱스로 조회 최적화
|
||||||
|
- **캐싱**: getMemories는 매 AI 응답마다 호출되므로 향후 KV 캐싱 고려 가능
|
||||||
|
- **제한**: content 최대 1000자 (Zod validation)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] 기억 수 제한 (사용자당 최대 N개)
|
||||||
|
- [ ] 기억 중요도/우선순위 설정
|
||||||
|
- [ ] 기억 카테고리/태그 기능
|
||||||
|
- [ ] 기억 검색 개선 (전문 검색, 유사도 검색)
|
||||||
|
- [ ] 기억 자동 만료 (TTL)
|
||||||
13
migrations/003_add_user_memories.sql
Normal file
13
migrations/003_add_user_memories.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 003: Add User Memories Table
|
||||||
|
-- 사용자가 기억해달라고 요청한 정보를 저장하는 테이블
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 사용자별 기억 조회 성능 최적화
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id, created_at DESC);
|
||||||
@@ -21,14 +21,19 @@ export async function handleCommand(
|
|||||||
|
|
||||||
<b>명령어:</b>
|
<b>명령어:</b>
|
||||||
/profile - 내 프로필 보기
|
/profile - 내 프로필 보기
|
||||||
/reset - 대화 초기화
|
/help - 도움말
|
||||||
/help - 도움말`;
|
|
||||||
|
💡 중요한 정보는 "기억해줘"로 저장하세요!`;
|
||||||
|
|
||||||
case '/help':
|
case '/help':
|
||||||
return `📖 <b>도움말</b>
|
return `📖 <b>도움말</b>
|
||||||
|
|
||||||
/profile - 내 프로필 보기
|
/profile - 내 프로필 보기
|
||||||
/reset - 대화 초기화
|
|
||||||
|
<b>기억 기능:</b>
|
||||||
|
• "OOO 기억해줘" - 정보 저장
|
||||||
|
• "내 기억 보여줘" - 저장 목록
|
||||||
|
• "OOO 잊어줘" - 삭제
|
||||||
|
|
||||||
대화할수록 당신을 더 잘 이해합니다 💡`;
|
대화할수록 당신을 더 잘 이해합니다 💡`;
|
||||||
|
|
||||||
@@ -78,46 +83,6 @@ ${summary.summary}
|
|||||||
버퍼 대기: ${ctx.recentMessages.length}개`;
|
버퍼 대기: ${ctx.recentMessages.length}개`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '/reset': {
|
|
||||||
const ctx = await getConversationContext(env.DB, userId, chatId);
|
|
||||||
const msgCount = ctx.totalMessages;
|
|
||||||
const profileGen = ctx.previousSummary?.generation || 0;
|
|
||||||
|
|
||||||
if (msgCount === 0 && profileGen === 0) {
|
|
||||||
return '📭 삭제할 데이터가 없습니다.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `⚠️ <b>정말 초기화할까요?</b>
|
|
||||||
|
|
||||||
삭제될 데이터:
|
|
||||||
• 메시지 버퍼: ${ctx.recentMessages.length}개
|
|
||||||
• 프로필: v${profileGen}
|
|
||||||
• 총 메시지 기록: ${msgCount}개
|
|
||||||
|
|
||||||
<b>이 작업은 되돌릴 수 없습니다!</b>
|
|
||||||
|
|
||||||
확인하려면 /reset-confirm 을 입력하세요.
|
|
||||||
취소하려면 아무 메시지나 보내세요.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case '/reset-confirm': {
|
|
||||||
const result = await env.DB.batch([
|
|
||||||
env.DB.prepare('DELETE FROM message_buffer WHERE user_id = ?').bind(userId),
|
|
||||||
env.DB.prepare('DELETE FROM summaries WHERE user_id = ?').bind(userId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const deletedMessages = result[0].meta.changes || 0;
|
|
||||||
const deletedSummaries = result[1].meta.changes || 0;
|
|
||||||
|
|
||||||
return `🗑️ 초기화 완료!
|
|
||||||
|
|
||||||
삭제됨:
|
|
||||||
• 메시지: ${deletedMessages}개
|
|
||||||
• 프로필: ${deletedSummaries}개
|
|
||||||
|
|
||||||
새로운 대화를 시작하세요!`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case '/debug': {
|
case '/debug': {
|
||||||
// 디버그용 명령어 (개발 시 유용)
|
// 디버그용 명령어 (개발 시 유용)
|
||||||
const ctx = await getConversationContext(env.DB, userId, chatId);
|
const ctx = await getConversationContext(env.DB, userId, chatId);
|
||||||
|
|||||||
@@ -9,6 +9,126 @@ import { ERROR_MESSAGES } from './constants/messages';
|
|||||||
|
|
||||||
const logger = createLogger('openai');
|
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 인스턴스 (전역 공유)
|
// Circuit Breaker 인스턴스 (전역 공유)
|
||||||
export const openaiCircuitBreaker = new CircuitBreaker({
|
export const openaiCircuitBreaker = new CircuitBreaker({
|
||||||
failureThreshold: 3, // 3회 연속 실패 시 차단
|
failureThreshold: 3, // 3회 연속 실패 시 차단
|
||||||
@@ -104,12 +224,41 @@ export async function generateOpenAIResponse(
|
|||||||
userId: telegramUserId,
|
userId: telegramUserId,
|
||||||
status: session.status
|
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) {
|
} catch (error) {
|
||||||
logger.error('Session check failed, continuing with normal flow', error as Error);
|
logger.error('Session check failed, continuing with normal flow', error as Error);
|
||||||
// Continue with normal flow if session check fails
|
// 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) {
|
if (!env.OPENAI_API_KEY) {
|
||||||
@@ -203,16 +352,49 @@ export async function generateOpenAIResponse(
|
|||||||
const toolResults = results
|
const toolResults = results
|
||||||
.filter((r): r is { early: false; message: OpenAIMessage } =>
|
.filter((r): r is { early: false; message: OpenAIMessage } =>
|
||||||
r !== null && r.early === false
|
r !== null && r.early === false
|
||||||
)
|
);
|
||||||
.map(r => r.message);
|
|
||||||
|
|
||||||
// 대화에 추가
|
// 메모리 저장([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({
|
messages.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantMessage.content,
|
content: assistantMessage.content,
|
||||||
tool_calls: assistantMessage.tool_calls,
|
tool_calls: assistantMessage.tool_calls,
|
||||||
});
|
});
|
||||||
messages.push(...toolResults);
|
messages.push(...toolResults.map(r => r.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 다시 호출 (도구 없이 응답 생성)
|
// 다시 호출 (도구 없이 응답 생성)
|
||||||
response = await callOpenAI(env, apiKey, messages, undefined);
|
response = await callOpenAI(env, apiKey, messages, undefined);
|
||||||
@@ -221,6 +403,13 @@ export async function generateOpenAIResponse(
|
|||||||
|
|
||||||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||||
|
|
||||||
|
// 백그라운드 메모리 저장 (AI 응답과 무관하게)
|
||||||
|
const saveableInfo = extractSaveableInfo(userMessage);
|
||||||
|
if (saveableInfo) {
|
||||||
|
// 비동기로 저장, 응답 지연 없음
|
||||||
|
saveMemorySilently(db, telegramUserId, saveableInfo).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return finalResponse;
|
return finalResponse;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -526,7 +526,20 @@ export async function processServerConsultation(
|
|||||||
|
|
||||||
// 선택 단계 처리
|
// 선택 단계 처리
|
||||||
if (session.status === 'selecting' && session.lastRecommendation) {
|
if (session.status === 'selecting' && session.lastRecommendation) {
|
||||||
const selectionMatch = userMessage.match(/(\d+)(?:번|번째)?|첫\s*번째|두\s*번째|세\s*번째/);
|
// 상담과 무관한 키워드 감지 (selecting 상태에서만)
|
||||||
|
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
|
||||||
|
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
|
||||||
|
if (unrelatedPatterns.test(userMessage)) {
|
||||||
|
await deleteServerSession(env.SESSION_KV, session.telegramUserId);
|
||||||
|
logger.info('무관한 요청으로 세션 자동 종료', {
|
||||||
|
userId: session.telegramUserId,
|
||||||
|
message: userMessage.slice(0, 30)
|
||||||
|
});
|
||||||
|
// 'PASSTHROUGH' 반환하여 상위에서 일반 처리로 전환
|
||||||
|
return '__PASSTHROUGH__';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionMatch = userMessage.match(/^(\d+)\s*(?:번|번째)?$|^(첫|두|세)\s*번째$/);
|
||||||
|
|
||||||
if (selectionMatch) {
|
if (selectionMatch) {
|
||||||
let selectedIndex = -1;
|
let selectedIndex = -1;
|
||||||
|
|||||||
198
src/services/memory-service.ts
Normal file
198
src/services/memory-service.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Memory } from '../types';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('memory-service');
|
||||||
|
|
||||||
|
// D1 query result type guard
|
||||||
|
interface D1MemoryRow {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryRow(item: unknown): item is D1MemoryRow {
|
||||||
|
if (typeof item !== 'object' || item === null) return false;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof row.id === 'number' &&
|
||||||
|
typeof row.user_id === 'number' &&
|
||||||
|
typeof row.content === 'string' &&
|
||||||
|
typeof row.created_at === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryArray(data: unknown): data is D1MemoryRow[] {
|
||||||
|
return Array.isArray(data) && data.every(isMemoryRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 기억 저장
|
||||||
|
* @param db D1 Database
|
||||||
|
* @param userId 사용자 ID (users.id)
|
||||||
|
* @param content 기억할 내용
|
||||||
|
* @returns 저장된 기억 ID
|
||||||
|
*/
|
||||||
|
export async function saveMemory(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
content: string
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.prepare('INSERT INTO user_memories (user_id, content) VALUES (?, ?)')
|
||||||
|
.bind(userId, content)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to save memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('기억 저장 완료', { userId, contentLength: content.length });
|
||||||
|
|
||||||
|
// D1 returns meta.last_row_id for insert operations
|
||||||
|
return result.meta.last_row_id as number;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기억 저장 실패', error as Error, { userId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 모든 기억 조회
|
||||||
|
* @param db D1 Database
|
||||||
|
* @param userId 사용자 ID (users.id)
|
||||||
|
* @returns 기억 목록 (최신순)
|
||||||
|
*/
|
||||||
|
export async function getMemories(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number
|
||||||
|
): Promise<Memory[]> {
|
||||||
|
try {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT id, user_id, content, created_at
|
||||||
|
FROM user_memories
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
.bind(userId)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (!isMemoryArray(results)) {
|
||||||
|
logger.warn('Invalid memory data format', { userId });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('기억 조회 완료', { userId, count: results.length });
|
||||||
|
|
||||||
|
return results as Memory[];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기억 조회 실패', error as Error, { userId });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기억 삭제 (ID로)
|
||||||
|
* @param db D1 Database
|
||||||
|
* @param userId 사용자 ID (users.id)
|
||||||
|
* @param memoryId 기억 ID
|
||||||
|
* @returns 삭제된 행 수
|
||||||
|
*/
|
||||||
|
export async function deleteMemory(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
memoryId: number
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM user_memories WHERE id = ? AND user_id = ?')
|
||||||
|
.bind(memoryId, userId)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedCount = result.meta.changes || 0;
|
||||||
|
logger.info('기억 삭제 완료', { userId, memoryId, deletedCount });
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기억 삭제 실패', error as Error, { userId, memoryId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기억 삭제 (내용 검색으로)
|
||||||
|
* @param db D1 Database
|
||||||
|
* @param userId 사용자 ID (users.id)
|
||||||
|
* @param searchText 검색할 텍스트
|
||||||
|
* @returns 삭제된 기억 내용 배열
|
||||||
|
*/
|
||||||
|
export async function deleteMemoryByContent(
|
||||||
|
db: D1Database,
|
||||||
|
userId: number,
|
||||||
|
searchText: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// 먼저 삭제될 항목들을 조회
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT content
|
||||||
|
FROM user_memories
|
||||||
|
WHERE user_id = ? AND content LIKE ?
|
||||||
|
`)
|
||||||
|
.bind(userId, `%${searchText}%`)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (!isMemoryArray(results)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = (results as D1MemoryRow[]).map((row) => row.content);
|
||||||
|
|
||||||
|
if (contents.length === 0) {
|
||||||
|
logger.info('삭제할 기억 없음', { userId, searchText });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM user_memories WHERE user_id = ? AND content LIKE ?')
|
||||||
|
.bind(userId, `%${searchText}%`)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete memories by content');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('기억 삭제 완료', {
|
||||||
|
userId,
|
||||||
|
searchText,
|
||||||
|
deletedCount: contents.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기억 삭제 실패', error as Error, { userId, searchText });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기억 목록을 시스템 프롬프트용으로 포맷팅
|
||||||
|
* @param memories 기억 배열
|
||||||
|
* @returns 포맷된 문자열
|
||||||
|
*/
|
||||||
|
export function formatMemoriesForPrompt(memories: Memory[]): string {
|
||||||
|
if (memories.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryLines = memories.map((m) => `- ${m.content}`).join('\n');
|
||||||
|
|
||||||
|
return `## 사용자 배경 정보 (내부 참고용 - 직접 언급 금지)\n${memoryLines}`;
|
||||||
|
}
|
||||||
@@ -372,6 +372,11 @@ export async function generateAIResponse(
|
|||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 사용자 기억 조회 및 포맷팅
|
||||||
|
const { getMemories, formatMemoriesForPrompt } = await import('./services/memory-service');
|
||||||
|
const memories = await getMemories(env.DB, userId);
|
||||||
|
const memoriesSection = formatMemoriesForPrompt(memories);
|
||||||
|
|
||||||
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
|
const systemPrompt = `당신은 친절하고 유능한 AI 어시스턴트입니다.
|
||||||
${integratedProfile ? `
|
${integratedProfile ? `
|
||||||
## 사용자 프로필 (${context.summaries.length}개 버전 통합)
|
## 사용자 프로필 (${context.summaries.length}개 버전 통합)
|
||||||
@@ -380,16 +385,25 @@ ${integratedProfile}
|
|||||||
위 프로필들을 종합하여 사용자의 관심사, 맥락, 변화를 이해하고 개인화된 응답을 제공하세요.
|
위 프로필들을 종합하여 사용자의 관심사, 맥락, 변화를 이해하고 개인화된 응답을 제공하세요.
|
||||||
최신 버전(높은 번호)의 정보를 우선시하되, 이전 버전의 맥락도 고려하세요.
|
최신 버전(높은 번호)의 정보를 우선시하되, 이전 버전의 맥락도 고려하세요.
|
||||||
` : ''}
|
` : ''}
|
||||||
|
${memoriesSection ? `
|
||||||
|
${memoriesSection}
|
||||||
|
|
||||||
|
위 배경 정보는 대화 맥락 이해용입니다. "기억", "저장된 정보" 등 직접 언급하지 마세요.
|
||||||
|
` : ''}
|
||||||
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
- 날씨, 시간, 계산 요청은 제공된 도구를 사용하세요.
|
||||||
- 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
|
- 최신 정보, 실시간 데이터, 뉴스, 특정 사실 확인이 필요한 질문은 반드시 search_web 도구로 검색하세요. 자체 지식으로 답변하지 마세요.
|
||||||
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
|
- 예치금, 입금, 충전, 잔액, 계좌 관련 요청은 반드시 manage_deposit 도구를 사용하세요. 금액 제한이나 규칙을 직접 판단하지 마세요.
|
||||||
- 서버, VPS, 클라우드, 호스팅 관련 요청:
|
- 서버, VPS, 클라우드, 호스팅 관련 요청:
|
||||||
• 첫 요청: manage_server(action="start_consultation")을 호출하여 상담 시작
|
• 첫 요청: manage_server(action="start_consultation")을 호출하여 상담 시작
|
||||||
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
|
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
|
||||||
|
- 기술 문제, 에러, 오류, 장애 관련 요청:
|
||||||
|
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시
|
||||||
|
• manage_troubleshoot(action="start")를 호출하여 트러블슈팅 시작
|
||||||
|
• 트러블슈팅 진행 중인 메시지는 자동으로 전문가 AI에게 전달됨
|
||||||
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요.
|
- 도메인 추천, 도메인 제안, 도메인 아이디어 요청은 반드시 suggest_domains 도구를 사용하세요. 직접 도메인을 나열하지 마세요.
|
||||||
- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요.
|
- 도메인/TLD 가격 조회(".com 가격", ".io 가격" 등)는 manage_domain 도구의 action=price를 사용하세요.
|
||||||
- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요.
|
- 기타 도메인 관련 요청(조회, 등록, 네임서버, WHOIS 등)은 manage_domain 도구를 사용하세요.
|
||||||
- manage_deposit, manage_domain, manage_server, suggest_domains 도구 결과는 그대로 전달하세요. 추가 질문이나 "도움이 필요하시면~" 같은 멘트를 붙이지 마세요.`;
|
- manage_deposit, manage_domain, manage_server, manage_troubleshoot, suggest_domains 도구 결과는 그대로 전달하세요.`;
|
||||||
|
|
||||||
const recentContext = context.recentMessages.slice(-10).map((m) => ({
|
const recentContext = context.recentMessages.slice(-10).map((m) => ({
|
||||||
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
|
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { searchWebTool, lookupDocsTool, executeSearchWeb, executeLookupDocs } fr
|
|||||||
import { manageDomainTool, suggestDomainsTool, executeManageDomain, executeSuggestDomains } from './domain-tool';
|
import { manageDomainTool, suggestDomainsTool, executeManageDomain, executeSuggestDomains } from './domain-tool';
|
||||||
import { manageDepositTool, executeManageDeposit } from './deposit-tool';
|
import { manageDepositTool, executeManageDeposit } from './deposit-tool';
|
||||||
import { manageServerTool, executeManageServer } from './server-tool';
|
import { manageServerTool, executeManageServer } from './server-tool';
|
||||||
|
import { manageTroubleshootTool, executeManageTroubleshoot } from './troubleshoot-tool';
|
||||||
import { getCurrentTimeTool, calculateTool, executeGetCurrentTime, executeCalculate } from './utility-tools';
|
import { getCurrentTimeTool, calculateTool, executeGetCurrentTime, executeCalculate } from './utility-tools';
|
||||||
import { redditSearchTool, executeRedditSearch } from './reddit-tool';
|
import { redditSearchTool, executeRedditSearch } from './reddit-tool';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
@@ -78,6 +79,10 @@ const ManageServerArgsSchema = z.object({
|
|||||||
message: z.string().min(1).max(500).optional(), // For continue_consultation
|
message: z.string().min(1).max(500).optional(), // For continue_consultation
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ManageTroubleshootArgsSchema = z.object({
|
||||||
|
action: z.enum(['start', 'cancel']),
|
||||||
|
});
|
||||||
|
|
||||||
// All tools array (used by OpenAI API)
|
// All tools array (used by OpenAI API)
|
||||||
export const tools = [
|
export const tools = [
|
||||||
weatherTool,
|
weatherTool,
|
||||||
@@ -88,6 +93,7 @@ export const tools = [
|
|||||||
manageDomainTool,
|
manageDomainTool,
|
||||||
manageDepositTool,
|
manageDepositTool,
|
||||||
manageServerTool,
|
manageServerTool,
|
||||||
|
manageTroubleshootTool,
|
||||||
suggestDomainsTool,
|
suggestDomainsTool,
|
||||||
redditSearchTool,
|
redditSearchTool,
|
||||||
];
|
];
|
||||||
@@ -97,6 +103,7 @@ export const TOOL_CATEGORIES: Record<string, string[]> = {
|
|||||||
domain: [manageDomainTool.function.name, suggestDomainsTool.function.name],
|
domain: [manageDomainTool.function.name, suggestDomainsTool.function.name],
|
||||||
deposit: [manageDepositTool.function.name],
|
deposit: [manageDepositTool.function.name],
|
||||||
server: [manageServerTool.function.name],
|
server: [manageServerTool.function.name],
|
||||||
|
troubleshoot: [manageTroubleshootTool.function.name],
|
||||||
weather: [weatherTool.function.name],
|
weather: [weatherTool.function.name],
|
||||||
search: [searchWebTool.function.name, lookupDocsTool.function.name],
|
search: [searchWebTool.function.name, lookupDocsTool.function.name],
|
||||||
reddit: [redditSearchTool.function.name],
|
reddit: [redditSearchTool.function.name],
|
||||||
@@ -108,6 +115,7 @@ export const CATEGORY_PATTERNS: Record<string, RegExp> = {
|
|||||||
domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i,
|
domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i,
|
||||||
deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i,
|
deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i,
|
||||||
server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i,
|
server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i,
|
||||||
|
troubleshoot: /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i,
|
||||||
weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i,
|
weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i,
|
||||||
search: /검색|찾아|뭐야|뉴스|최신/i,
|
search: /검색|찾아|뭐야|뉴스|최신/i,
|
||||||
reddit: /레딧|reddit|서브레딧|subreddit/i,
|
reddit: /레딧|reddit|서브레딧|subreddit/i,
|
||||||
@@ -124,7 +132,7 @@ export function selectToolsForMessage(message: string): typeof tools {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 패턴 매칭 없으면 전체 도구 사용 (폴백)
|
// 패턴 매칭 없으면 전체 도구 사용 (폴백)
|
||||||
if (selectedCategories.size === 1) {
|
if (selectedCategories.size === 1) { // utility만 있으면 폴백
|
||||||
logger.info('패턴 매칭 없음 → 전체 도구 사용');
|
logger.info('패턴 매칭 없음 → 전체 도구 사용');
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
@@ -244,6 +252,15 @@ export async function executeTool(
|
|||||||
return executeRedditSearch(result.data, env);
|
return executeRedditSearch(result.data, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'manage_troubleshoot': {
|
||||||
|
const result = ManageTroubleshootArgsSchema.safeParse(args);
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error('Invalid troubleshoot args', new Error(result.error.message), { args });
|
||||||
|
return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`;
|
||||||
|
}
|
||||||
|
return executeManageTroubleshoot(result.data, env, telegramUserId);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return `알 수 없는 도구: ${name}`;
|
return `알 수 없는 도구: ${name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
140
src/tools/memory-tool.ts
Normal file
140
src/tools/memory-tool.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Env, ManageMemoryArgs } from '../types';
|
||||||
|
import {
|
||||||
|
saveMemory,
|
||||||
|
getMemories,
|
||||||
|
deleteMemory,
|
||||||
|
deleteMemoryByContent,
|
||||||
|
} from '../services/memory-service';
|
||||||
|
import { createLogger, maskUserId } from '../utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('memory-tool');
|
||||||
|
|
||||||
|
export const manageMemoryTool = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'manage_memory',
|
||||||
|
description:
|
||||||
|
'사용자 기억 조회/삭제 전용. list: 사용자가 "내 기억", "뭘 기억해?" 요청 시. delete: 사용자가 "잊어줘" 요청 시. save는 시스템이 자동 처리하므로 호출하지 마세요.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['save', 'list', 'delete'],
|
||||||
|
description: 'save=저장, list=조회, delete=삭제',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: '저장할 내용 또는 삭제할 내용 (검색어)',
|
||||||
|
},
|
||||||
|
memory_id: {
|
||||||
|
type: 'number',
|
||||||
|
description: '삭제할 기억의 ID (선택)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기억 관리 도구 실행
|
||||||
|
*/
|
||||||
|
export async function executeManageMemory(
|
||||||
|
args: ManageMemoryArgs,
|
||||||
|
_env?: Env,
|
||||||
|
telegramUserId?: string,
|
||||||
|
db?: D1Database
|
||||||
|
): Promise<string> {
|
||||||
|
const { action, content, memory_id } = args;
|
||||||
|
logger.info('시작', {
|
||||||
|
action,
|
||||||
|
hasContent: !!content,
|
||||||
|
memoryId: memory_id,
|
||||||
|
userId: maskUserId(telegramUserId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!telegramUserId || !db) {
|
||||||
|
return '🚫 기억 기능을 사용할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 조회
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT id FROM users WHERE telegram_id = ?')
|
||||||
|
.bind(telegramUserId)
|
||||||
|
.first<{ id: number }>();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return '🚫 사용자 정보를 찾을 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'save': {
|
||||||
|
if (!content) {
|
||||||
|
return '🚫 저장할 내용을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMemory(db, userId, content);
|
||||||
|
// 자동 저장 시 AI가 이 결과를 사용자에게 전달하지 않도록 내부 마커 사용
|
||||||
|
return '[SAVED]';
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list': {
|
||||||
|
const memories = await getMemories(db, userId);
|
||||||
|
|
||||||
|
if (memories.length === 0) {
|
||||||
|
return '📋 저장된 기억이 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryList = memories
|
||||||
|
.map((m, index) => `${index + 1}. ${m.content} (ID: ${m.id})`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `📋 저장된 기억 (${memories.length}개)\n\n${memoryList}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
// ID로 삭제
|
||||||
|
if (memory_id) {
|
||||||
|
const deletedCount = await deleteMemory(db, userId, memory_id);
|
||||||
|
|
||||||
|
if (deletedCount === 0) {
|
||||||
|
return `🚫 ID ${memory_id}에 해당하는 기억을 찾을 수 없습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `✅ 기억을 삭제했습니다 (ID: ${memory_id})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내용 검색으로 삭제
|
||||||
|
if (content) {
|
||||||
|
const deletedContents = await deleteMemoryByContent(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deletedContents.length === 0) {
|
||||||
|
return `🚫 "${content}"와 일치하는 기억이 없습니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedList = deletedContents
|
||||||
|
.map((c, i) => `${i + 1}. ${c}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `✅ ${deletedContents.length}개의 기억을 삭제했습니다:\n\n${deletedList}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '🚫 삭제할 기억의 ID 또는 검색어를 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `🚫 알 수 없는 작업: ${action}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('기억 처리 오류', error as Error, { action });
|
||||||
|
return '🚫 기억 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/tools/troubleshoot-tool.ts
Normal file
75
src/tools/troubleshoot-tool.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('troubleshoot-tool');
|
||||||
|
|
||||||
|
export const manageTroubleshootTool = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'manage_troubleshoot',
|
||||||
|
description: '기술 문제 해결 도우미. 서버 에러, 도메인 문제, 배포 실패, 네트워크 오류, 데이터베이스 이슈 등을 진단하고 해결합니다. 사용자가 "에러", "문제", "안돼", "느려", "오류" 등을 언급하면 이 도구를 사용하세요.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['start', 'cancel'],
|
||||||
|
description: 'start=문제 해결 시작, cancel=세션 취소',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeManageTroubleshoot(
|
||||||
|
args: { action: 'start' | 'cancel' },
|
||||||
|
env?: Env,
|
||||||
|
telegramUserId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const { action } = args;
|
||||||
|
|
||||||
|
logger.info('트러블슈팅 도구 호출', { action, userId: telegramUserId });
|
||||||
|
|
||||||
|
if (!env?.SESSION_KV || !telegramUserId) {
|
||||||
|
return '🚫 트러블슈팅 기능을 사용할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getTroubleshootSession, saveTroubleshootSession, deleteTroubleshootSession } = await import('../troubleshoot-agent');
|
||||||
|
|
||||||
|
if (action === 'cancel') {
|
||||||
|
await deleteTroubleshootSession(env.SESSION_KV, telegramUserId);
|
||||||
|
return '✅ 트러블슈팅 세션이 취소되었습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === 'start'
|
||||||
|
const existingSession = await getTroubleshootSession(env.SESSION_KV, telegramUserId);
|
||||||
|
|
||||||
|
if (existingSession && existingSession.status !== 'completed') {
|
||||||
|
return '이미 진행 중인 트러블슈팅 세션이 있습니다. 계속 진행해주세요.\n\n현재까지 파악된 정보:\n' +
|
||||||
|
(existingSession.collectedInfo.category ? `• 분류: ${existingSession.collectedInfo.category}\n` : '') +
|
||||||
|
(existingSession.collectedInfo.symptoms ? `• 증상: ${existingSession.collectedInfo.symptoms}\n` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
const newSession = {
|
||||||
|
telegramUserId,
|
||||||
|
status: 'gathering' as const,
|
||||||
|
collectedInfo: {},
|
||||||
|
messages: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTroubleshootSession(env.SESSION_KV, telegramUserId, newSession);
|
||||||
|
|
||||||
|
logger.info('트러블슈팅 세션 시작', { userId: telegramUserId });
|
||||||
|
|
||||||
|
return '__DIRECT__🔧 기술 문제 해결 도우미입니다.\n\n' +
|
||||||
|
'어떤 문제가 발생했나요? 최대한 자세히 설명해주세요.\n\n' +
|
||||||
|
'💡 예시:\n' +
|
||||||
|
'• "서버가 502 에러를 반환해요"\n' +
|
||||||
|
'• "도메인이 연결이 안돼요"\n' +
|
||||||
|
'• "배포가 실패해요"\n' +
|
||||||
|
'• "데이터베이스 연결이 느려요"';
|
||||||
|
}
|
||||||
456
src/troubleshoot-agent.ts
Normal file
456
src/troubleshoot-agent.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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 = 'diagnosing';
|
||||||
|
} else if (aiResult.action === 'solve') {
|
||||||
|
session.status = 'solving';
|
||||||
|
} else {
|
||||||
|
session.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다시 시도하려면 문제를 말씀해주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/types.ts
28
src/types.ts
@@ -95,6 +95,13 @@ export interface ConversationContext {
|
|||||||
totalMessages: number;
|
totalMessages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Memory {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Cloudflare Email Workers 타입
|
// Cloudflare Email Workers 타입
|
||||||
export interface EmailMessage {
|
export interface EmailMessage {
|
||||||
from: string;
|
from: string;
|
||||||
@@ -221,6 +228,12 @@ export interface ManageServerArgs {
|
|||||||
message?: string; // For continue_consultation
|
message?: string; // For continue_consultation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManageMemoryArgs {
|
||||||
|
action: "save" | "list" | "delete";
|
||||||
|
content?: string;
|
||||||
|
memory_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Server Consultation Session
|
// Server Consultation Session
|
||||||
export interface ServerSession {
|
export interface ServerSession {
|
||||||
telegramUserId: string;
|
telegramUserId: string;
|
||||||
@@ -258,6 +271,21 @@ export interface ServerSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Troubleshooting Session
|
||||||
|
export interface TroubleshootSession {
|
||||||
|
telegramUserId: string;
|
||||||
|
status: 'gathering' | 'diagnosing' | 'solving' | 'completed';
|
||||||
|
collectedInfo: {
|
||||||
|
category?: string; // Server/Infrastructure, Domain/DNS, Code/Deploy, Network, Database
|
||||||
|
symptoms?: string; // 증상 요약
|
||||||
|
environment?: string; // OS, 프레임워크, 버전 등
|
||||||
|
errorMessage?: string; // 에러 메시지
|
||||||
|
};
|
||||||
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Deposit Agent 결과 타입
|
// Deposit Agent 결과 타입
|
||||||
export interface DepositBalanceResult {
|
export interface DepositBalanceResult {
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ binding = "AI"
|
|||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
ENVIRONMENT = "production"
|
ENVIRONMENT = "production"
|
||||||
SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수)
|
SUMMARY_THRESHOLD = "50" # 프로필 업데이트 주기 (메시지 수)
|
||||||
MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우)
|
MAX_SUMMARIES_PER_USER = "1" # 유지할 프로필 버전 수
|
||||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
||||||
# Admin IDs moved to secrets (see bottom of file)
|
# Admin IDs moved to secrets (see bottom of file)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user