feat: recreate Server Agent for premium VM management
Session-based agent with OpenAI Function Calling (9 tools). Follows ddos-agent pattern: execute tools inside loop, feed results back to AI. Includes D1 migration, session routing in openai-service, and doc updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
103
CLAUDE.md
103
CLAUDE.md
@@ -203,10 +203,11 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
**Agent System (세션 기반 전문가 AI):**
|
**Agent System (세션 기반 전문가 AI):**
|
||||||
| 파일 | 역할 | 상태 관리 |
|
| 파일 | 역할 | 상태 관리 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| `agents/server-agent.ts` | 서버 추천 상담 (30년 경력 아키텍트) | KV (1시간) |
|
| `agents/server-agent.ts` | 서버 관리 전문가 (프리미엄 호스팅) | D1 (1시간) |
|
||||||
| `agents/troubleshoot-agent.ts` | 트러블슈팅 상담 | KV (1시간) |
|
| `agents/troubleshoot-agent.ts` | 트러블슈팅 상담 | D1 (1시간) |
|
||||||
| `agents/domain-agent.ts` | 도메인 추천 상담 (10년 경력 컨설턴트) | D1 (1시간) |
|
| `agents/domain-agent.ts` | 도메인 추천 상담 (10년 경력 컨설턴트) | D1 (1시간) |
|
||||||
| `agents/deposit-agent.ts` | 예치금 입금 신고 상담 (금융 상담사) | D1 (30분) |
|
| `agents/deposit-agent.ts` | 예치금 입금 신고 상담 (금융 상담사) | D1 (30분) |
|
||||||
|
| `agents/ddos-agent.ts` | DDoS 방어 보안 전문가 | D1 (1시간) |
|
||||||
|
|
||||||
**Logging & Monitoring:**
|
**Logging & Monitoring:**
|
||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
@@ -245,6 +246,9 @@ Telegram Webhook → Security Validation → Command/Message Router
|
|||||||
| `server_specs` | 서버 스펙 | name, cpu, ram, disk, price |
|
| `server_specs` | 서버 스펙 | name, cpu, ram, disk, price |
|
||||||
| `domain_sessions` | 도메인 상담 세션 | user_id, status, collected_info, messages |
|
| `domain_sessions` | 도메인 상담 세션 | user_id, status, collected_info, messages |
|
||||||
| `deposit_sessions` | 예치금 상담 세션 | user_id, status, collected_info, messages |
|
| `deposit_sessions` | 예치금 상담 세션 | user_id, status, collected_info, messages |
|
||||||
|
| `server_sessions` | 서버 관리 세션 | user_id, status, collected_info, messages |
|
||||||
|
| `ddos_sessions` | DDoS 방어 세션 | user_id, status, collected_info, messages |
|
||||||
|
| `troubleshoot_sessions` | 트러블슈팅 세션 | user_id, status, collected_info, messages |
|
||||||
|
|
||||||
**AI Fallback:** OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환
|
**AI Fallback:** OpenAI 미설정 시 Workers AI (Llama 3.1 8B) 자동 전환
|
||||||
|
|
||||||
@@ -408,73 +412,68 @@ domain-register.ts:
|
|||||||
|
|
||||||
### 4.3 Server System
|
### 4.3 Server System
|
||||||
|
|
||||||
|
**서비스 정책:**
|
||||||
|
- 프리미엄 VPS만 취급 (DDoS 방어 1Tbps+ 포함)
|
||||||
|
- 저가형 VM, VPN, 프록시 → 별도 서비스 준비 중
|
||||||
|
- 추천(recommend) 기능 없음 (성능 문제로 제거)
|
||||||
|
|
||||||
**manage_server 도구 파라미터:**
|
**manage_server 도구 파라미터:**
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
action: 'recommend' | 'order' | 'start' | 'stop' | 'delete' | 'list',
|
action: 'order' | 'list' | 'info' | 'delete' | 'images' | 'start' | 'stop' | 'reboot' | 'rename',
|
||||||
tech_stack?: string[], // recommend용
|
order_id?: number, // info/start/stop/reboot/delete/rename용
|
||||||
expected_users?: number, // recommend용
|
pricing_id?: number, // order용
|
||||||
use_case?: string, // recommend용
|
label?: string, // order용
|
||||||
traffic_pattern?: 'steady' | 'spiky' | 'growing',
|
new_label?: string, // rename용
|
||||||
region_preference?: string[],
|
image?: string // order용 (OS 이미지)
|
||||||
budget_limit?: number,
|
|
||||||
lang?: 'ko' | 'ja' | 'zh' | 'en', // 자동 감지
|
|
||||||
server_id?: string, // order/start/stop/delete용
|
|
||||||
region_code?: string, // order용
|
|
||||||
label?: string // order용
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**action별 상태:**
|
**action별 상태:**
|
||||||
| action | 설명 | 상태 |
|
| action | 설명 | 상태 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `recommend` | 서버 추천 | ✅ 구현 완료 |
|
| `order` | 서버 주문 | ✅ 구현 완료 |
|
||||||
| `order` | 서버 신청 | 🚧 준비 중 |
|
| `list` | 내 서버 목록 | ✅ 구현 완료 |
|
||||||
| `start` | 서버 시작 | 🚧 준비 중 |
|
| `info` | 서버 상세 정보 | ✅ 구현 완료 |
|
||||||
| `stop` | 서버 중지 | 🚧 준비 중 |
|
| `start` | 서버 시작 | ✅ 구현 완료 |
|
||||||
| `delete` | 서버 해지 | 🚧 준비 중 |
|
| `stop` | 서버 중지 | ✅ 구현 완료 |
|
||||||
| `list` | 내 서버 목록 | 🚧 준비 중 |
|
| `reboot` | 서버 재시작 | ✅ 구현 완료 |
|
||||||
|
| `delete` | 서버 해지 (환불 포함) | ✅ 구현 완료 |
|
||||||
|
| `rename` | 서버 이름 변경 | ✅ 구현 완료 |
|
||||||
|
| `images` | OS 이미지 목록 | ✅ 구현 완료 |
|
||||||
|
|
||||||
**Server Expert AI Flow (상담 기반 추천):**
|
**Server Agent Flow (세션 기반 관리):**
|
||||||
```
|
```
|
||||||
사용자: "서버 추천해줘"
|
사용자: "서버 목록 보여줘" / "1번 서버 시작"
|
||||||
↓
|
↓
|
||||||
메인 AI → manage_server(action="start_consultation")
|
① 메인 AI → manage_server(action="list") 호출
|
||||||
↓
|
↓
|
||||||
KV 세션 생성 (server_session:{userId}, TTL 1h)
|
② server-tool.ts: action → 자연어 변환 ("내 서버 목록 보여줘")
|
||||||
↓
|
↓
|
||||||
Server Expert AI (gpt-4o-mini + Function Calling)
|
③ Server Agent (프리미엄 호스팅 전문가 페르소나)
|
||||||
- 페르소나: 30년 경력 클라우드 아키텍트
|
- 세션 생성/조회 (D1, TTL 1시간)
|
||||||
- 용도/규모 파악 (최대 2번 질문)
|
- OpenAI Function Calling (9개 도구)
|
||||||
- [선택] search_trends (Brave Search)
|
- 도구 실행 → executeServerAction()
|
||||||
- [선택] lookup_framework_docs (Context7)
|
|
||||||
↓
|
↓
|
||||||
action="question" → 추가 정보 수집 (세션 유지)
|
④ 응답 반환 + 세션 저장
|
||||||
action="recommend" → 자동 스펙 추론 → Cloud Orchestrator API 호출 → 세션 삭제
|
|
||||||
↓
|
[세션 라우팅]
|
||||||
서버 추천 결과 반환
|
기존 세션이 있는 경우 → openai-service.ts에서 직접 Server Agent로 라우팅
|
||||||
|
세션 없는 경우 → 메인 AI가 manage_server 도구 호출 → Server Agent 위임
|
||||||
```
|
```
|
||||||
|
|
||||||
**자동 추론 (30년 경험 기반):**
|
**Server Agent Function Calling (9개 도구):**
|
||||||
| 용도 | 추론된 tech_stack | 추론된 expected_users |
|
| 함수 | 설명 | 필수 인자 |
|
||||||
|------|-------------------|---------------------|
|
|------|------|----------|
|
||||||
| 블로그 / WordPress | `['wordpress']` | 100명 |
|
| `list_servers` | 서버 목록 | - |
|
||||||
| 쇼핑몰 / 이커머스 | `['ecommerce']` | 500명 |
|
| `get_server_info` | 서버 상세 | order_id |
|
||||||
| 커뮤니티 / 게시판 | `['php', 'mysql']` | - |
|
| `order_server` | 서버 주문 | pricing_id, label |
|
||||||
| API / 백엔드 | `['nodejs', 'express']` | - |
|
| `start_server` | 서버 시작 | order_id |
|
||||||
| 기본값 | `['web']` | 100명 |
|
| `stop_server` | 서버 중지 | order_id |
|
||||||
|
| `reboot_server` | 서버 재시작 | order_id |
|
||||||
**추천 결과 포맷:**
|
| `delete_server` | 서버 삭제 | order_id |
|
||||||
```
|
| `rename_server` | 이름 변경 | order_id, new_label |
|
||||||
🖥️ 서버 추천 결과
|
| `list_images` | OS 이미지 목록 | - |
|
||||||
|
|
||||||
1️⃣ Standard 8GB (Anvil)
|
|
||||||
• 스펙: 4vCPU / 8GB / 160GB SSD
|
|
||||||
• 리전: Tokyo 3 (JP)
|
|
||||||
• 가격: ₩69,719/월 (대역폭 5TB)
|
|
||||||
• 예상 트래픽: 1.7TB (포함 범위 내)
|
|
||||||
• 점수: 95점 / 최대 7,500명
|
|
||||||
```
|
|
||||||
|
|
||||||
**서버 주문 상태 전이:**
|
**서버 주문 상태 전이:**
|
||||||
| 상태 | 설정 주체 | UI 표시 |
|
| 상태 | 설정 주체 | UI 표시 |
|
||||||
|
|||||||
14
migrations/013_recreate_server_sessions.sql
Normal file
14
migrations/013_recreate_server_sessions.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Recreate server management sessions table
|
||||||
|
-- Previous migration 012 dropped this table when recommendation feature was removed
|
||||||
|
-- Now recreated for premium VM management agent (no recommendation, management only)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_sessions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
collected_info TEXT NOT NULL DEFAULT '{}',
|
||||||
|
messages TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_sessions_expires ON server_sessions(expires_at);
|
||||||
12
schema.sql
12
schema.sql
@@ -127,6 +127,17 @@ CREATE TABLE IF NOT EXISTS user_servers (
|
|||||||
FOREIGN KEY (order_id) REFERENCES server_orders(id)
|
FOREIGN KEY (order_id) REFERENCES server_orders(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 서버 관리 세션 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS server_sessions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
collected_info TEXT NOT NULL DEFAULT '{}',
|
||||||
|
messages TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- 인덱스
|
-- 인덱스
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain);
|
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain);
|
||||||
@@ -146,3 +157,4 @@ CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_server_orders_idempotency ON server_orders(idempotency_key) WHERE idempotency_key IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_server_orders_idempotency ON server_orders(idempotency_key) WHERE idempotency_key IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_servers_status ON user_servers(status);
|
CREATE INDEX IF NOT EXISTS idx_user_servers_status ON user_servers(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_sessions_expires ON server_sessions(expires_at);
|
||||||
|
|||||||
469
src/agents/server-agent.ts
Normal file
469
src/agents/server-agent.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
/**
|
||||||
|
* Server Agent - 프리미엄 VM 관리 전문가 (세션 기반)
|
||||||
|
*
|
||||||
|
* 변경 이력:
|
||||||
|
* - 2026-01: 서버 추천(recommend) 기능 제거 (성능 문제)
|
||||||
|
* - 2026-02: 프리미엄 VM 관리 전용 Agent로 재생성
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
|
* - [세션] 서버 관리 대화 (목록, 상세, 시작/중지/재시작/삭제/이름변경)
|
||||||
|
* - [세션] 서버 주문 (pricing_id, label, image 지정)
|
||||||
|
* - [세션] OS 이미지 목록 조회
|
||||||
|
*
|
||||||
|
* 정책:
|
||||||
|
* - 프리미엄 VPS만 취급 (고급형 DDoS 방어 포함)
|
||||||
|
* - 저가형 VM, VPN, 프록시 → "준비 중" 안내
|
||||||
|
* - 추천(recommend) 기능 없음
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env, ServerSession, OpenAIToolCall, OpenAIAPIResponse } from '../types';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { SessionManager } from '../utils/session-manager';
|
||||||
|
import { getSessionConfig, AI_CONFIG } from '../constants/agent-config';
|
||||||
|
import { executeServerAction } from '../tools/server-tool';
|
||||||
|
|
||||||
|
const logger = createLogger('server-agent');
|
||||||
|
|
||||||
|
// Session manager instance
|
||||||
|
const sessionManager = new SessionManager<ServerSession>(getSessionConfig('server'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 세션 존재 여부 확인 (라우팅용)
|
||||||
|
*/
|
||||||
|
export async function hasServerSession(db: D1Database, userId: string): Promise<boolean> {
|
||||||
|
return await sessionManager.has(db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Management Expert System Prompt
|
||||||
|
const SERVER_EXPERT_PROMPT = `당신은 Anvil Hosting의 서버 관리 전문가입니다.
|
||||||
|
|
||||||
|
전문 분야:
|
||||||
|
- 프리미엄 VPS 호스팅 관리
|
||||||
|
- DDoS 방어 (1Tbps+ 보호 포함)
|
||||||
|
- 즉시 프로비저닝
|
||||||
|
- 서버 운영 (시작/중지/재시작/삭제/이름 변경)
|
||||||
|
|
||||||
|
행동 지침:
|
||||||
|
1. 명확하고 간결하게 답변
|
||||||
|
2. 삭제/중지 등 위험 작업은 확인 후 실행
|
||||||
|
3. 저가형 VM, VPN, 프록시 문의 → "프리미엄 서버 전용 서비스입니다. 저가형 서비스는 준비 중입니다."
|
||||||
|
|
||||||
|
응답 형식:
|
||||||
|
- 짧고 명확하게
|
||||||
|
- 가격은 원화(원)
|
||||||
|
- 서버 상태: 🟢 가동 중, ⛔ 중지됨, 🔄 생성 중
|
||||||
|
|
||||||
|
특수 지시:
|
||||||
|
- 서버 관리와 무관한 메시지 → "__PASSTHROUGH__"만 응답
|
||||||
|
- 세션 종료 필요 → "__SESSION_END__" 추가`;
|
||||||
|
|
||||||
|
// Server Management Tools for Function Calling
|
||||||
|
const SERVER_TOOLS = [
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'list_servers',
|
||||||
|
description: '사용자의 서버 목록 조회',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'get_server_info',
|
||||||
|
description: '특정 서버 상세 정보 조회',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
},
|
||||||
|
required: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'order_server',
|
||||||
|
description: '새 서버 주문 (pricing_id와 label 필수)',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pricing_id: { type: 'number', description: 'Pricing ID (서버 스펙 ID)' },
|
||||||
|
label: { type: 'string', description: '서버 라벨 (예: myapp-prod)' },
|
||||||
|
image: { type: 'string', description: 'OS 이미지 키 (선택, 예: ubuntu_22_04)' },
|
||||||
|
},
|
||||||
|
required: ['pricing_id', 'label'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'start_server',
|
||||||
|
description: '서버 시작',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
},
|
||||||
|
required: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'stop_server',
|
||||||
|
description: '서버 중지',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
},
|
||||||
|
required: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'reboot_server',
|
||||||
|
description: '서버 재시작',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
},
|
||||||
|
required: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'delete_server',
|
||||||
|
description: '서버 삭제 (되돌릴 수 없음, 확인 필요)',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
},
|
||||||
|
required: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'rename_server',
|
||||||
|
description: '서버 이름 변경',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
order_id: { type: 'number', description: '주문 번호' },
|
||||||
|
new_label: { type: 'string', description: '새 서버 이름' },
|
||||||
|
},
|
||||||
|
required: ['order_id', 'new_label'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'list_images',
|
||||||
|
description: '사용 가능한 OS 이미지 목록',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Expert AI 호출 (Function Calling 지원)
|
||||||
|
*
|
||||||
|
* ddos-agent 패턴: 도구 실행 결과를 AI에 다시 전달하여
|
||||||
|
* AI가 결과를 해석하고 자연어 응답을 생성
|
||||||
|
*/
|
||||||
|
async function callServerExpertAI(
|
||||||
|
session: ServerSession,
|
||||||
|
userMessage: string,
|
||||||
|
telegramUserId: string,
|
||||||
|
env: Env
|
||||||
|
): Promise<{ response: string; calledTools: string[] }> {
|
||||||
|
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 = `${SERVER_EXPERT_PROMPT}
|
||||||
|
|
||||||
|
## 현재 세션 정보
|
||||||
|
${JSON.stringify(session.collected_info, null, 2)}
|
||||||
|
|
||||||
|
## 도구 사용 가이드
|
||||||
|
- 서버 목록 문의 → list_servers
|
||||||
|
- 서버 상세 정보 → get_server_info (order_id 필요)
|
||||||
|
- 서버 주문 → order_server (pricing_id, label 필수)
|
||||||
|
- 서버 시작 → start_server (order_id 필요)
|
||||||
|
- 서버 중지 → stop_server (order_id 필요)
|
||||||
|
- 서버 재시작 → reboot_server (order_id 필요)
|
||||||
|
- 서버 삭제 → delete_server (order_id 필요, 확인 후)
|
||||||
|
- 이름 변경 → rename_server (order_id, new_label 필요)
|
||||||
|
- OS 이미지 목록 → list_images`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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_CALL_ROUNDS = AI_CONFIG.maxToolCalls;
|
||||||
|
let toolCallRound = 0;
|
||||||
|
const calledTools: string[] = [];
|
||||||
|
|
||||||
|
// Loop to handle tool calls (execute tools and feed results back to AI)
|
||||||
|
while (toolCallRound < MAX_TOOL_CALL_ROUNDS) {
|
||||||
|
const requestBody = {
|
||||||
|
model: AI_CONFIG.model,
|
||||||
|
messages,
|
||||||
|
tools: SERVER_TOOLS,
|
||||||
|
tool_choice: 'auto',
|
||||||
|
max_tokens: AI_CONFIG.maxTokens.server,
|
||||||
|
temperature: AI_CONFIG.temperature.server,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 to conversation
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantMessage.content,
|
||||||
|
tool_calls: assistantMessage.tool_calls,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute each tool and add results back to conversation
|
||||||
|
for (const toolCall of assistantMessage.tool_calls) {
|
||||||
|
let args: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
args = JSON.parse(toolCall.function.arguments);
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('도구 인자 JSON 파싱 실패', parseError as Error, {
|
||||||
|
toolName: toolCall.function.name,
|
||||||
|
arguments: toolCall.function.arguments?.slice(0, 200),
|
||||||
|
});
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
content: JSON.stringify({ error: '도구 인자 파싱 실패' }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeServerToolCall(toolCall.function.name, args, telegramUserId, env);
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
content: result,
|
||||||
|
});
|
||||||
|
|
||||||
|
calledTools.push(toolCall.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count this round and continue loop for AI to process results
|
||||||
|
toolCallRound++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tool calls - return final response
|
||||||
|
const aiResponse = assistantMessage.content || '';
|
||||||
|
logger.info('AI 응답', { response: aiResponse.slice(0, 200) });
|
||||||
|
|
||||||
|
// Check for special markers
|
||||||
|
if (aiResponse.includes('__PASSTHROUGH__')) {
|
||||||
|
return { response: '__PASSTHROUGH__', calledTools };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session end marker
|
||||||
|
const sessionEnd = aiResponse.includes('__SESSION_END__');
|
||||||
|
const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse,
|
||||||
|
calledTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max tool call rounds reached
|
||||||
|
logger.warn('최대 도구 호출 라운드 도달', { toolCallRound, totalToolsCalled: calledTools.length });
|
||||||
|
return {
|
||||||
|
response: '서버 정보를 확인했습니다.',
|
||||||
|
calledTools,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Server Expert AI 호출 실패', error as Error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 도구 실행 디스패처
|
||||||
|
*
|
||||||
|
* Server Agent AI가 호출한 tool을 실제 executeServerAction으로 매핑
|
||||||
|
*/
|
||||||
|
async function executeServerToolCall(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
telegramUserId: string,
|
||||||
|
env: Env
|
||||||
|
): Promise<string> {
|
||||||
|
logger.info('서버 도구 실행', { toolName, args });
|
||||||
|
|
||||||
|
// Map agent tool names to server-tool action names
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
list_servers: 'list',
|
||||||
|
get_server_info: 'info',
|
||||||
|
order_server: 'order',
|
||||||
|
start_server: 'start',
|
||||||
|
stop_server: 'stop',
|
||||||
|
reboot_server: 'reboot',
|
||||||
|
delete_server: 'delete',
|
||||||
|
rename_server: 'rename',
|
||||||
|
list_images: 'images',
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = actionMap[toolName];
|
||||||
|
if (!action) {
|
||||||
|
return `❌ 알 수 없는 도구: ${toolName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeServerAction(
|
||||||
|
action,
|
||||||
|
{
|
||||||
|
order_id: args.order_id as number | undefined,
|
||||||
|
pricing_id: args.pricing_id as number | undefined,
|
||||||
|
label: args.label as string | undefined,
|
||||||
|
new_label: args.new_label as string | undefined,
|
||||||
|
image: args.image as string | undefined,
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
telegramUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip __DIRECT__ marker (agent formats its own responses)
|
||||||
|
return result.replace('__DIRECT__\n', '');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('서버 도구 실행 실패', error as Error, { toolName, args });
|
||||||
|
return '❌ 처리 중 오류가 발생했습니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 관리 상담 처리 (메인 함수)
|
||||||
|
*
|
||||||
|
* @param db - D1 Database
|
||||||
|
* @param userId - Telegram User ID
|
||||||
|
* @param userMessage - 사용자 메시지
|
||||||
|
* @param env - Environment
|
||||||
|
* @returns AI 응답 메시지
|
||||||
|
*/
|
||||||
|
export async function processServerConsultation(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
userMessage: string,
|
||||||
|
env: Env
|
||||||
|
): Promise<string> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
logger.info('서버 관리 상담 시작', { userId, message: userMessage.substring(0, 100) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check for existing session
|
||||||
|
let session = await sessionManager.get(db, userId);
|
||||||
|
|
||||||
|
// 2. Create new session if none exists
|
||||||
|
if (!session) {
|
||||||
|
session = sessionManager.create(userId, 'managing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add user message to session
|
||||||
|
sessionManager.addMessage(session, 'user', userMessage);
|
||||||
|
|
||||||
|
// 4. Call AI (tools are executed inside the loop, AI interprets results)
|
||||||
|
const aiResult = await callServerExpertAI(session, userMessage, userId, env);
|
||||||
|
|
||||||
|
// 5. Handle __PASSTHROUGH__ - not server related
|
||||||
|
if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) {
|
||||||
|
logger.info('서버 관리 패스스루', { userId });
|
||||||
|
return '__PASSTHROUGH__';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Handle __SESSION_END__ - session complete
|
||||||
|
if (aiResult.response.includes('[세션 종료]')) {
|
||||||
|
logger.info('서버 관리 세션 종료', { userId });
|
||||||
|
await sessionManager.delete(db, userId);
|
||||||
|
return aiResult.response.replace('[세션 종료]', '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Add assistant response to session and save
|
||||||
|
sessionManager.addMessage(session, 'assistant', aiResult.response);
|
||||||
|
session.updated_at = Date.now();
|
||||||
|
await sessionManager.save(db, session);
|
||||||
|
|
||||||
|
logger.info('서버 관리 상담 완료', {
|
||||||
|
userId,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
calledTools: aiResult.calledTools.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return aiResult.response;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('서버 관리 상담 오류', error as Error, { userId });
|
||||||
|
return '죄송합니다. 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { processTroubleshootConsultation, hasTroubleshootSession } from './agent
|
|||||||
import { processDomainConsultation, hasDomainSession } from './agents/domain-agent';
|
import { processDomainConsultation, hasDomainSession } from './agents/domain-agent';
|
||||||
import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent';
|
import { processDepositConsultation, hasDepositSession } from './agents/deposit-agent';
|
||||||
import { processDdosConsultation, hasDdosSession } from './agents/ddos-agent';
|
import { processDdosConsultation, hasDdosSession } from './agents/ddos-agent';
|
||||||
|
import { processServerConsultation, hasServerSession } from './agents/server-agent';
|
||||||
|
|
||||||
const logger = createLogger('openai');
|
const logger = createLogger('openai');
|
||||||
|
|
||||||
@@ -296,6 +297,29 @@ export async function generateOpenAIResponse(
|
|||||||
});
|
});
|
||||||
// Continue with normal flow if session check fails
|
// Continue with normal flow if session check fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for active server management session
|
||||||
|
try {
|
||||||
|
const hasServerSess = await hasServerSession(env.DB, telegramUserId);
|
||||||
|
|
||||||
|
if (hasServerSess) {
|
||||||
|
logger.info('서버 관리 세션 감지, Server 에이전트로 라우팅', {
|
||||||
|
userId: telegramUserId
|
||||||
|
});
|
||||||
|
const serverResponse = await processServerConsultation(env.DB, telegramUserId, userMessage, env);
|
||||||
|
|
||||||
|
// PASSTHROUGH: 무관한 메시지는 일반 처리로 전환
|
||||||
|
if (serverResponse !== '__PASSTHROUGH__') {
|
||||||
|
return serverResponse;
|
||||||
|
}
|
||||||
|
// Continue to normal flow below
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Server session check failed, continuing with normal flow', error as Error, {
|
||||||
|
telegramUserId
|
||||||
|
});
|
||||||
|
// Continue with normal flow if session check fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!env.OPENAI_API_KEY) {
|
if (!env.OPENAI_API_KEY) {
|
||||||
|
|||||||
@@ -903,15 +903,15 @@ export async function executeServerDelete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Server consultation session cleanup removed (recommendation feature deprecated)
|
// Clean up server management session after deletion
|
||||||
// try {
|
try {
|
||||||
// const { ServerSessionManager } = await import('../utils/session-manager');
|
const { SessionManager } = await import('../utils/session-manager');
|
||||||
// const { getSessionConfig } = await import('../constants/agent-config');
|
const { getSessionConfig } = await import('../constants/agent-config');
|
||||||
// const sessionManager = new ServerSessionManager(getSessionConfig('server'));
|
const sm = new SessionManager(getSessionConfig('server'));
|
||||||
// await sessionManager.delete(env.DB, telegramUserId);
|
await sm.delete(env.DB, telegramUserId);
|
||||||
// } catch (error) {
|
} catch (sessionError) {
|
||||||
// provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
|
provisionLogger.error('서버 세션 삭제 실패 (무시)', sessionError as Error);
|
||||||
// }
|
}
|
||||||
|
|
||||||
provisionLogger.info('서버 삭제 완료', { orderId });
|
provisionLogger.info('서버 삭제 완료', { orderId });
|
||||||
return {
|
return {
|
||||||
@@ -994,15 +994,15 @@ export async function executeServerOrder(
|
|||||||
const order = result.order;
|
const order = result.order;
|
||||||
provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan });
|
provisionLogger.info('서버 주문 완료', { orderId: order?.id, plan: orderData.plan });
|
||||||
|
|
||||||
// NOTE: Server consultation session cleanup removed (recommendation feature deprecated)
|
// Clean up server management session after order
|
||||||
// try {
|
try {
|
||||||
// const { ServerSessionManager } = await import('../utils/session-manager');
|
const { SessionManager } = await import('../utils/session-manager');
|
||||||
// const { getSessionConfig } = await import('../constants/agent-config');
|
const { getSessionConfig } = await import('../constants/agent-config');
|
||||||
// const sessionManager = new ServerSessionManager(getSessionConfig('server'));
|
const sm = new SessionManager(getSessionConfig('server'));
|
||||||
// await sessionManager.delete(env.DB, telegramUserId);
|
await sm.delete(env.DB, telegramUserId);
|
||||||
// } catch (error) {
|
} catch (sessionError) {
|
||||||
// provisionLogger.error('서버 세션 삭제 실패 (무시)', error as Error);
|
provisionLogger.error('서버 세션 삭제 실패 (무시)', sessionError as Error);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Build success message
|
// Build success message
|
||||||
let successMessage = `✅ 서버 신청이 완료되었습니다!\n\n`;
|
let successMessage = `✅ 서버 신청이 완료되었습니다!\n\n`;
|
||||||
@@ -1026,6 +1026,41 @@ export async function executeServerOrder(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build natural language message from structured args
|
||||||
|
*/
|
||||||
|
function buildServerMessage(args: {
|
||||||
|
action: string;
|
||||||
|
order_id?: number;
|
||||||
|
pricing_id?: number;
|
||||||
|
label?: string;
|
||||||
|
new_label?: string;
|
||||||
|
image?: string;
|
||||||
|
}): string {
|
||||||
|
switch (args.action) {
|
||||||
|
case 'list':
|
||||||
|
return '내 서버 목록 보여줘';
|
||||||
|
case 'info':
|
||||||
|
return `${args.order_id}번 서버 정보 알려줘`;
|
||||||
|
case 'start':
|
||||||
|
return `${args.order_id}번 서버 시작해줘`;
|
||||||
|
case 'stop':
|
||||||
|
return `${args.order_id}번 서버 중지해줘`;
|
||||||
|
case 'reboot':
|
||||||
|
return `${args.order_id}번 서버 재시작해줘`;
|
||||||
|
case 'delete':
|
||||||
|
return `${args.order_id}번 서버 삭제해줘`;
|
||||||
|
case 'rename':
|
||||||
|
return `${args.order_id}번 서버 이름을 ${args.new_label}로 변경해줘`;
|
||||||
|
case 'order':
|
||||||
|
return `서버 주문: pricing_id=${args.pricing_id}, label=${args.label}${args.image ? `, image=${args.image}` : ''}`;
|
||||||
|
case 'images':
|
||||||
|
return 'OS 이미지 목록 보여줘';
|
||||||
|
default:
|
||||||
|
return `서버 ${args.action} 요청`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeManageServer(
|
export async function executeManageServer(
|
||||||
args: {
|
args: {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -1038,7 +1073,8 @@ export async function executeManageServer(
|
|||||||
image?: string;
|
image?: string;
|
||||||
},
|
},
|
||||||
env?: Env,
|
env?: Env,
|
||||||
telegramUserId?: string
|
telegramUserId?: string,
|
||||||
|
db?: D1Database
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { action } = args;
|
const { action } = args;
|
||||||
logger.info('시작', {
|
logger.info('시작', {
|
||||||
@@ -1046,10 +1082,28 @@ export async function executeManageServer(
|
|||||||
userId: maskUserId(telegramUserId),
|
userId: maskUserId(telegramUserId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!env || !telegramUserId || !db) {
|
||||||
|
// Fallback to direct execution if missing context
|
||||||
|
try {
|
||||||
|
const result = await executeServerAction(action, args, env, telegramUserId);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('서버 관리 오류', error as Error, { action });
|
||||||
|
return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await executeServerAction(action, args, env, telegramUserId);
|
const userMessage = buildServerMessage(args);
|
||||||
logger.info('완료', { result: result?.slice(0, 100) });
|
const { processServerConsultation } = await import('../agents/server-agent');
|
||||||
return result;
|
const response = await processServerConsultation(db, telegramUserId, userMessage, env);
|
||||||
|
|
||||||
|
if (response === '__PASSTHROUGH__') {
|
||||||
|
return '서버 관련 요청을 처리할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('완료', { result: response?.slice(0, 100) });
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('서버 관리 오류', error as Error, { action });
|
logger.error('서버 관리 오류', error as Error, { action });
|
||||||
return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
return '🚫 서버 관리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||||
|
|||||||
14
src/types.ts
14
src/types.ts
@@ -795,6 +795,20 @@ export interface DdosSession {
|
|||||||
expires_at: number;
|
expires_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server Management Session Status
|
||||||
|
export type ServerSessionStatus = 'idle' | 'managing' | 'completed';
|
||||||
|
|
||||||
|
// Server Management Session (D1)
|
||||||
|
export interface ServerSession {
|
||||||
|
user_id: string;
|
||||||
|
status: ServerSessionStatus;
|
||||||
|
collected_info: Record<string, unknown>;
|
||||||
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
// DDoS Defense Tool Args
|
// DDoS Defense Tool Args
|
||||||
export interface ManageDdosArgs {
|
export interface ManageDdosArgs {
|
||||||
action:
|
action:
|
||||||
|
|||||||
@@ -313,30 +313,5 @@ export class DomainSessionManager extends SessionManager<DomainSession> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Specialized session manager for Server Agent
|
|
||||||
* Handles last_recommendation field
|
|
||||||
*
|
|
||||||
* NOTE: Temporarily commented out during server recommendation removal refactoring.
|
|
||||||
* This class will be removed in a future commit when server-tool.ts is updated.
|
|
||||||
*/
|
|
||||||
// export class ServerSessionManager extends SessionManager<ServerSession> {
|
|
||||||
// protected parseAdditionalFields(result: Record<string, unknown>): Partial<ServerSession> {
|
|
||||||
// return {
|
|
||||||
// last_recommendation: result.last_recommendation
|
|
||||||
// ? JSON.parse(result.last_recommendation as string)
|
|
||||||
// : undefined,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// protected getAdditionalColumns(session: ServerSession): Record<string, unknown> {
|
|
||||||
// return {
|
|
||||||
// last_recommendation: session.last_recommendation
|
|
||||||
// ? JSON.stringify(session.last_recommendation)
|
|
||||||
// : null,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Import types (avoid circular dependency by importing at end)
|
// Import types (avoid circular dependency by importing at end)
|
||||||
import type { DomainSession } from '../types';
|
import type { DomainSession } from '../types';
|
||||||
|
|||||||
Reference in New Issue
Block a user