refactor: remove recommendation actions from server-tool

Removed:
- start_consultation, continue_consultation, cancel_consultation, recommend actions
- getRecommendationData(), formatRecommendations()
- CDN cache hit rate estimation (CDN_CACHE_HIT_RATES, estimateCdnCacheHitRate)
- Language detection (detectLanguage)
- callCloudOrchestratorApi() function
- isErrorResult() type guard
- Recommendation-related parameters (tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang, message)
- RecommendResponse type import
- formatTrafficInfo import

Retained:
- order, list, info, delete, images, start, stop, reboot, rename actions
- callProvisionAPI() for provision operations
- Server management core functionality

File size reduced from 1484 to 1057 lines (427 lines removed).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-05 18:30:08 +09:00
parent 7d43db3054
commit 98002473f9

View File

@@ -3,12 +3,10 @@ import type {
ProvisionResponse,
ProvisionOrder,
OSImage,
RecommendResponse
} from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger, maskUserId } from '../utils/logger';
import { ERROR_MESSAGES } from '../constants/messages';
import { formatTrafficInfo } from '../utils/formatters';
const logger = createLogger('server-tool');
const provisionLogger = createLogger('provision');
@@ -21,55 +19,6 @@ function generateIdempotencyKey(userId: string): string {
return `tg-order-${userId}-${timestamp}-${random}`;
}
// CDN 캐시 히트율 상수
const CDN_CACHE_HIT_RATES = {
VIDEO_STREAMING: 0.92,
STATIC_SITE: 0.95,
API: 0.30,
ECOMMERCE: 0.70,
DEFAULT: 0.85,
} as const;
// 언어 감지 (한글/일본어/중국어/영어)
function detectLanguage(text: string): 'ko' | 'ja' | 'zh' | 'en' {
if (/[가-힣]/.test(text)) return 'ko';
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'ja'; // 히라가나/가타카나
if (/[\u4e00-\u9fff]/.test(text)) return 'zh'; // 한자 (일본어 감지 후)
return 'en';
}
// CDN 캐시 히트율 추정 (tech_stack + use_case 기반)
function estimateCdnCacheHitRate(techStack: string[], useCase: string): number | null {
const stackLower = techStack.map(s => s.toLowerCase());
const useCaseLower = useCase.toLowerCase();
// CDN 키워드 감지
const hasCdn = stackLower.some(s =>
['cloudflare', 'cdn', 'fastly', 'akamai', 'bunny', 'cf'].includes(s)
);
if (!hasCdn) return null; // CDN 없으면 null 반환
// use_case 기반 히트율 조정
const isVideoStreaming = /video|streaming|vod|media|동영상|스트리밍|미디어/.test(useCaseLower);
const isStaticSite = /static|blog|portfolio|landing|정적|블로그/.test(useCaseLower);
const isApi = /api|backend|서버|백엔드/.test(useCaseLower);
const isEcommerce = /shop|store|commerce|쇼핑|이커머스/.test(useCaseLower);
// 콘텐츠 타입별 예상 캐시 히트율
if (isVideoStreaming) return CDN_CACHE_HIT_RATES.VIDEO_STREAMING;
if (isStaticSite) return CDN_CACHE_HIT_RATES.STATIC_SITE;
if (isApi) return CDN_CACHE_HIT_RATES.API;
if (isEcommerce) return CDN_CACHE_HIT_RATES.ECOMMERCE;
return CDN_CACHE_HIT_RATES.DEFAULT;
}
// Type guards
function isErrorResult(result: unknown): result is { error: string } {
return typeof result === 'object' && result !== null && 'error' in result;
}
// 진행 중인 주문 확인 (중복 주문 방지)
async function checkExistingOrder(
db: D1Database,
@@ -89,47 +38,14 @@ export const manageServerTool = {
type: 'function',
function: {
name: 'manage_server',
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 추천(action="start_consultation"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot',
'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'],
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, start_consultation: 상담 시작, rename: 이름 변경',
},
tech_stack: {
type: 'array',
items: { type: 'string' },
description: '기술 스택. 용도에서 추론 (블로그→wordpress, 쇼핑몰→ecommerce, 커뮤니티→php,mysql). 모르면 ["web"]',
},
expected_users: {
type: 'number',
description: '예상 사용자 수. 모르면 개인용=100, 사업용=500 사용',
},
use_case: {
type: 'string',
description: '용도 (예: "블로그", "쇼핑몰", "커뮤니티")',
},
traffic_pattern: {
type: 'string',
enum: ['steady', 'spiky', 'growing'],
description: '생략 가능. 기본값: steady',
},
region_preference: {
type: 'array',
items: { type: 'string' },
description: '선호 리전 (예: ["tokyo", "seoul"]). recommend action에서 선택',
},
budget_limit: {
type: 'number',
description: '월 예산 한도 (원). recommend action에서 선택',
},
lang: {
type: 'string',
enum: ['ko', 'ja', 'zh', 'en'],
description: '응답 언어. 자동 감지됨',
enum: ['order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot', 'rename'],
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, order: 서버 주문, rename: 이름 변경, images: OS 이미지 목록',
},
server_id: {
type: 'string',
@@ -143,17 +59,13 @@ export const manageServerTool = {
type: 'string',
description: '서버 라벨 (예: "myapp-prod"). order action에서 필수',
},
message: {
type: 'string',
description: '사용자 메시지. continue_consultation action에서 필수',
},
pricing_id: {
type: 'number',
description: 'Pricing ID (서버 스펙 ID). order action에서 필수',
},
order_id: {
type: 'number',
description: '주문 번호. info, delete, rename action에서 필수',
description: '주문 번호. info, delete, rename, start, stop, reboot action에서 필수',
},
new_label: {
type: 'string',
@@ -169,65 +81,6 @@ export const manageServerTool = {
},
};
// Cloud Orchestrator API 호출
async function callCloudOrchestratorApi(
endpoint: string,
method: string,
body?: Record<string, unknown>,
env?: Env
): Promise<unknown> {
logger.info('API 호출 시작', {
endpoint,
method,
useServiceBinding: !!env?.CLOUD_ORCHESTRATOR
});
try {
const response = await retryWithBackoff(
() => {
const requestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
};
// Service Binding 우선, fallback: URL
if (env?.CLOUD_ORCHESTRATOR) {
logger.info('Service Binding 사용', { endpoint });
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${endpoint}`, requestInit);
} else {
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
const url = `${apiUrl}${endpoint}`;
logger.info('HTTP 요청 사용', { url });
return fetch(url, requestInit);
}
},
{ maxRetries: 3, serviceName: 'cloud-orchestrator' }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('API 호출 실패', new Error(errorText), {
endpoint,
status: response.status,
});
return { error: `서버 API 호출 실패: HTTP ${response.status}` };
}
const data = await response.json();
logger.info('API 호출 성공', { endpoint });
return data;
} catch (error) {
logger.error('API 호출 에러', error as Error, { endpoint });
if (error instanceof RetryError) {
return { error: ERROR_MESSAGES.SERVER_SERVICE_UNAVAILABLE };
}
return { error: '서버 API 호출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' };
}
}
// Cloud Orchestrator Provision API 호출
async function callProvisionAPI(
endpoint: string,
@@ -328,107 +181,6 @@ async function callProvisionAPI(
}
}
// 추천 데이터 조회 (포맷팅 없이 원본 반환)
export async function getRecommendationData(
args: {
tech_stack: string[];
expected_users: number;
use_case: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
},
env?: Env
): Promise<RecommendResponse | null> {
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
// 언어 자동 감지
const detectedLang = lang || detectLanguage(use_case);
// CDN 캐시 히트율 추정
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
// API 요청 body 구성
const requestBody: Record<string, unknown> = {
tech_stack,
expected_users,
use_case,
lang: detectedLang,
};
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
if (region_preference) requestBody.region_preference = region_preference;
if (budget_limit) requestBody.budget_limit = budget_limit;
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
// API 호출
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
if (isErrorResult(result)) {
logger.error('추천 데이터 조회 실패', new Error(result.error));
return null;
}
return result as RecommendResponse;
}
// 서버 추천 결과 포맷팅 (필수 정보만)
// __DIRECT__ 마커로 AI 재해석 없이 바로 반환
function formatRecommendations(data: RecommendResponse): string {
if (!data.recommendations || data.recommendations.length === 0) {
return '🔍 조건에 맞는 서버를 찾지 못했습니다.\n다른 조건으로 다시 시도해주세요.';
}
const { recommendations } = data;
// __DIRECT__ 마커 추가 - AI 재해석 방지
let response = '__DIRECT__\n🖥 서버 추천 결과\n\n';
recommendations.slice(0, 3).forEach((rec, index) => {
const server = rec.server;
// 가격 포맷팅 (항상 KRW로 표시)
const price = `${Math.round(server.monthly_price).toLocaleString()}`;
// 대역폭 포맷팅
const bandwidth = server.transfer_tb ? `${server.transfer_tb}TB` : '무제한';
response += `${index + 1}️⃣ ${server.instance_name} (${server.provider_name})\n`;
response += ` • 스펙: ${server.vcpu}vCPU / ${server.memory_gb}GB / ${server.storage_gb}GB SSD\n`;
response += ` • 리전: ${server.region_name} (${server.country_code})\n`;
response += ` • 가격: ${price}/월 (대역폭 ${bandwidth})\n`;
// 대역폭 정보 (항상 표시)
if (rec.bandwidth_info) {
const trafficInfo = formatTrafficInfo(rec.bandwidth_info);
response += `${trafficInfo}\n`;
// 총 예상 비용 (초과 있을 때만 표시, 항상 KRW로 표시)
if (rec.bandwidth_info.estimated_overage_tb > 0 && rec.bandwidth_info.estimated_overage_cost > 0) {
const totalCost = `${Math.round(rec.bandwidth_info.total_estimated_cost).toLocaleString()}`;
response += ` • 총 예상 비용: ${totalCost}/월\n`;
}
}
response += ` • 점수: ${rec.score}`;
if (rec.estimated_capacity) {
response += ` / 최대 ${rec.estimated_capacity.max_concurrent_users.toLocaleString()}\n`;
} else {
response += '\n';
}
response += '\n';
});
// 환불 정책 안내 추가
response += '\n💡 환불 정책: 720시간 = 1개월 기준\n';
response += ' 해지 시 미사용 시간은 시간당 요금으로 환불\n\n';
// 선택 가이드 추가
response += '💡 원하는 서버를 선택하려면 번호를 입력하세요 (예: 1번)';
return response.trim();
}
// 서버 상태 이모지
function getStatusEmoji(status: string): string {
@@ -636,17 +388,9 @@ const DEPOSIT_ACCOUNT_INFO = `💳 입금 계좌
export async function executeServerAction(
action: string,
args: {
tech_stack?: string[];
expected_users?: number;
use_case?: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
server_id?: string;
region_code?: string;
label?: string;
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
@@ -662,168 +406,6 @@ export async function executeServerAction(
});
switch (action) {
case 'start_consultation': {
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
// Note: Session is created automatically in processServerConsultation when first message arrives
logger.info('상담 시작 요청', { userId: maskUserId(telegramUserId) });
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
case 'continue_consultation': {
const { processServerConsultation } = await import('../agents/server-agent');
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
if (!args.message) {
return '🚫 메시지가 필요합니다.';
}
const result = await processServerConsultation(env.DB, telegramUserId, args.message, env);
return result;
}
case 'cancel_consultation': {
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env?.DB) {
return '🚫 세션 저장소가 설정되지 않았습니다.';
}
// Import sessionManager to delete session
const { ServerSessionManager } = await import('../utils/session-manager');
const { getSessionConfig } = await import('../constants/agent-config');
const sessionManager = new ServerSessionManager(getSessionConfig('server'));
await sessionManager.delete(env.DB, telegramUserId);
logger.info('상담 세션 취소', { userId: maskUserId(telegramUserId) });
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
}
case 'recommend': {
const { tech_stack, expected_users, use_case, traffic_pattern, region_preference, budget_limit, lang } = args;
// 필수 파라미터 검증
if (!tech_stack || !expected_users || !use_case) {
return '🚫 서버 추천에는 tech_stack, expected_users, use_case가 필요합니다.';
}
// 언어 자동 감지 (use_case 기반)
const detectedLang = lang || detectLanguage(use_case);
// CDN 캐시 히트율 추정
const cdnCacheHitRate = estimateCdnCacheHitRate(tech_stack, use_case);
// API 요청 body 구성
const requestBody: Record<string, unknown> = {
tech_stack,
expected_users,
use_case,
lang: detectedLang,
};
if (traffic_pattern) requestBody.traffic_pattern = traffic_pattern;
if (region_preference) requestBody.region_preference = region_preference;
if (budget_limit) requestBody.budget_limit = budget_limit;
if (cdnCacheHitRate !== null) requestBody.cdn_cache_hit_rate = cdnCacheHitRate;
// API 호출
const result = await callCloudOrchestratorApi('/api/recommend', 'POST', requestBody, env);
if (isErrorResult(result)) {
return `🚫 ${result.error}`;
}
const recommendationData = result as RecommendResponse;
// 세션에 추천 결과 저장 (선택 기능 활성화)
if (telegramUserId && env?.DB && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
try {
const { ServerSessionManager } = await import('../utils/session-manager');
const { getSessionConfig } = await import('../constants/agent-config');
const sessionManager = new ServerSessionManager(getSessionConfig('server'));
// 기존 세션 조회 또는 새로 생성
let session = await sessionManager.get(env.DB, telegramUserId);
if (!session) {
// 세션이 없으면 새로 생성
session = sessionManager.create(telegramUserId, 'selecting');
session.collected_info = {
useCase: use_case,
scale: expected_users <= 50 ? 'personal' : 'business',
expectedConcurrent: expected_users,
};
logger.info('새 세션 생성 (추천 결과 저장용)', { userId: telegramUserId });
}
// last_recommendation 저장
session.last_recommendation = {
recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({
pricing_id: rec.server.id,
plan_name: rec.server.instance_name,
provider: rec.server.provider_name,
specs: {
vcpu: rec.server.vcpu,
ram_gb: rec.server.memory_gb,
storage_gb: rec.server.storage_gb
},
region: {
code: rec.server.region_code,
name: rec.server.region_name
},
price: {
monthly_krw: Math.round(rec.server.monthly_price),
bandwidth_tb: rec.server.transfer_tb,
estimated_monthly_tb: rec.bandwidth_info?.estimated_monthly_tb,
gross_monthly_tb: rec.bandwidth_info?.gross_monthly_tb,
cdn_cache_hit_rate: rec.bandwidth_info?.cdn_cache_hit_rate,
overage_tb: rec.bandwidth_info?.estimated_overage_tb,
overage_cost_krw: rec.bandwidth_info?.estimated_overage_cost,
currency: rec.server.currency,
},
score: rec.score,
max_users: rec.estimated_capacity?.max_concurrent_users || 0
})),
created_at: Date.now()
};
// status를 'selecting'으로 변경
session.status = 'selecting';
session.updated_at = Date.now();
await sessionManager.save(env.DB, session);
logger.info('추천 결과 세션 저장 완료', {
userId: telegramUserId,
recommendationCount: session.last_recommendation.recommendations.length,
status: session.status
});
} catch (sessionError) {
logger.error('세션 저장 실패 (무시)', sessionError as Error, { userId: telegramUserId });
// 세션 저장 실패해도 추천 결과는 반환
}
}
// 결과 포맷팅
return formatRecommendations(recommendationData);
}
case 'order': {
const { pricing_id, label, image } = args;
@@ -1447,17 +1029,9 @@ export async function executeServerOrder(
export async function executeManageServer(
args: {
action: string;
tech_stack?: string[];
expected_users?: number;
use_case?: string;
traffic_pattern?: string;
region_preference?: string[];
budget_limit?: number;
lang?: string;
server_id?: string;
region_code?: string;
label?: string;
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;