Files
telegram-bot-workers/src/server-agent.ts
kappa 8815654137 feat: distinguish DAU from concurrent users in Server Expert AI
- Add expectedDau and expectedConcurrent fields to ServerSession
- Update system prompts to explain DAU vs concurrent users concept
- AI now asks for clarification when users mention visitor counts
- Use concurrent users (5-10% of DAU) for server recommendations
- Update inference rules: personal=10, business=50 concurrent users

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:15:22 +09:00

662 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Server Expert Agent - 서버 전문가 AI 상담 시스템
*
* 기능:
* - 대화형 서버 추천 상담
* - 세션 기반 정보 수집
* - 충분한 정보 수집 시 자동 추천
* - 추천 후 사용자 선택 및 주문 흐름
* - Brave Search / Context7 도구로 최신 트렌드 반영
*/
import type { Env, ServerSession } from './types';
import { createLogger } from './utils/logger';
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
const logger = createLogger('server-agent');
// KV Session Management
const SESSION_TTL = 3600; // 1 hour
const SESSION_KEY_PREFIX = 'server_session:';
export async function getServerSession(
kv: KVNamespace,
userId: string
): Promise<ServerSession | null> {
try {
const key = `${SESSION_KEY_PREFIX}${userId}`;
const data = await kv.get(key, 'json');
if (!data) {
logger.info('세션 없음', { userId });
return null;
}
logger.info('세션 조회 성공', { userId, status: (data as ServerSession).status });
return data as ServerSession;
} catch (error) {
logger.error('세션 조회 실패', error as Error, { userId });
return null;
}
}
export async function saveServerSession(
kv: KVNamespace,
userId: string,
session: ServerSession
): Promise<void> {
try {
const key = `${SESSION_KEY_PREFIX}${userId}`;
session.updatedAt = Date.now();
await kv.put(key, JSON.stringify(session), {
expirationTtl: SESSION_TTL,
});
logger.info('세션 저장 성공', { userId, status: session.status });
} catch (error) {
logger.error('세션 저장 실패', error as Error, { userId });
throw error;
}
}
export async function deleteServerSession(
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;
}
}
// Server Expert AI Tools
const serverExpertTools = [
{
type: 'function' as const,
function: {
name: 'search_trends',
description: '최신 기술 트렌드, 서버 요구사항, 프레임워크 인기도를 검색합니다. 예: "2024 WordPress server requirements", "Next.js hosting best practices"',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '검색 쿼리 (영문 권장, 기술 키워드 포함)',
},
},
required: ['query'],
},
},
},
{
type: 'function' as const,
function: {
name: 'lookup_framework_docs',
description: '프레임워크/라이브러리 공식 문서에서 서버 요구사항, 배포 가이드, 권장 환경을 조회합니다.',
parameters: {
type: 'object',
properties: {
library: {
type: 'string',
description: '라이브러리/프레임워크 이름 (예: nextjs, laravel, django, wordpress)',
},
topic: {
type: 'string',
description: '조회할 주제 (예: deployment requirements, production setup, server specs)',
},
},
required: ['library', 'topic'],
},
},
},
];
// Execute server expert tool
async function executeServerExpertTool(
toolName: string,
args: Record<string, unknown>,
env: Env
): Promise<string> {
logger.info('도구 실행', { toolName, args });
switch (toolName) {
case 'search_trends': {
const result = await executeSearchWeb({ query: args.query as string }, env);
return result;
}
case 'lookup_framework_docs': {
const result = await executeLookupDocs({
library: args.library as string,
query: args.topic as string,
}, env);
return result;
}
default:
return `알 수 없는 도구: ${toolName}`;
}
}
// Tech stack inference from use case
function inferTechStack(useCase: string): string[] {
const useCaseLower = useCase.toLowerCase();
if (/블로그|blog|wordpress/.test(useCaseLower)) {
return ['wordpress'];
}
if (/쇼핑몰|이커머스|ecommerce|shop|store/.test(useCaseLower)) {
return ['ecommerce'];
}
if (/커뮤니티|게시판|forum|community/.test(useCaseLower)) {
return ['php', 'mysql'];
}
if (/api|백엔드|backend/.test(useCaseLower)) {
return ['nodejs', 'express'];
}
return ['web']; // Default
}
// Expected users inference from scale
// Returns concurrent users (not DAU)
function inferExpectedUsers(scale: string): number {
// DAU → 동시접속자 변환 (5-10% 비율 적용)
if (scale === 'personal') return 10; // DAU 100명 → 동접 10명
if (scale === 'business') return 50; // DAU 500명 → 동접 50명
return 10; // Default to personal
}
// 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;
}>;
}
// RecommendResponse 타입 (server-tool.ts와 동일)
interface RecommendResponse {
recommendations: Array<{
server: {
instance_name: string;
vcpu: number;
memory_gb: number;
storage_gb: number;
transfer_tb: number;
monthly_price: number;
provider_name: string;
region_code: string;
region_name: string;
};
score: number;
estimated_capacity?: {
max_concurrent_users?: number;
};
bandwidth_analysis?: {
estimated_monthly_tb?: number;
overage_tb?: number;
overage_cost_krw?: number;
};
}>;
}
// OpenAI 호출 (서버 전문가 AI with Function Calling)
async function callServerExpertAI(
env: Env,
session: ServerSession,
userMessage: string,
recommendationData?: RecommendResponse
): Promise<{ action: 'question' | 'recommend'; message: string; collectedInfo: ServerSession['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 isReviewMode = !!recommendationData;
const systemPrompt = isReviewMode
? `당신은 Cloud Orchestrator가 추천한 서버를 검토하는 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험
## 검토 대상 추천 결과
${JSON.stringify(recommendationData?.recommendations, null, 2)}
## 사용자 요구사항
- 용도: ${session.collectedInfo.useCase || '웹 서비스'}
- 규모: ${session.collectedInfo.scale === 'business' ? '사업용' : '개인용'}
${session.collectedInfo.expectedDau ? `- 일일 방문자(DAU): ${session.collectedInfo.expectedDau}` : ''}
${session.collectedInfo.expectedConcurrent ? `- 동시접속자: ${session.collectedInfo.expectedConcurrent}` : ''}
${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetLimit}` : ''}
## 사용자 수 관련 참고사항
- DAU(일일 활성 사용자)와 동시접속자는 다른 개념입니다
- 일반적으로 동시접속자는 DAU의 5-10% 수준입니다
- 서버 스펙은 동시접속자 기준으로 계산됩니다
## 검토 작업
다음을 검토하고 간결하게 2-3문장으로 코멘트해주세요:
1. 추천된 서버가 용도와 규모에 적합한지
2. 스펙이 충분한지 (RAM, CPU, 스토리지)
3. DAU/동시접속자 기준이 적절한지
4. 대역폭 경고(overage)가 있다면 언급
5. 더 적합한 스펙이 필요하다면 제안
## 응답 형식 (반드시 JSON만 반환)
{
"action": "recommend",
"message": "검토 코멘트 (자연스럽고 친근한 어조, 2-3문장)",
"collectedInfo": ${JSON.stringify(session.collectedInfo)}
}
중요: 검토 코멘트만 작성하세요. 추천 결과 나열은 하지 마세요.`
: `당신은 30년 경력의 시니어 클라우드 아키텍트입니다.
## 전문성 (30년 경력)
- 서버 엔지니어: Linux, Windows Server, 가상화, 컨테이너 마스터
- 네트워크 엔지니어: 로드밸런싱, CDN, DNS, 보안 설계 전문
- 클라우드 아키텍트: 모든 클라우드 플랫폼 경험
- 수천 개의 서버 구축 경험으로 용도만 들으면 최적 스펙을 바로 판단 가능
## 성격
- 따뜻하고 친근하지만 전문적인 어조
- 비기술자도 이해하기 쉽게 설명
- 고객의 예산과 상황을 항상 배려
- 불필요한 기술 용어 사용 자제
## 금지 사항 (절대 위반 금지)
- AWS, GCP, Azure, Vultr, Linode, DigitalOcean 등 다른 클라우드 프로바이더 언급 금지
- 경쟁사 서비스 추천 금지
- 우리 서비스(Anvil)만 추천
- "다른 곳도 고려해보세요" 같은 멘트 금지
## 도구 사용 가이드 (적극적으로 활용할 것)
- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인
- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출
- 블로그, 쇼핑몰 같은 일반적 용도는 경험으로 바로 답변
- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...")
## 대화 흐름
1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: 블로그, 쇼핑몰, 커뮤니티)"
2. 규모 파악: "개인용인가요, 사업용인가요?"
3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?"
4. 정보가 충분하면 즉시 추천 (추가 질문 없이)
## 핵심 규칙 (반드시 준수)
- 기술 스택, 트래픽 패턴은 절대 묻지 않음 (30년 경험으로 알아서 추론)
- 사용자 수를 언급하면 DAU인지 동시접속자인지 반드시 한 번 확인
- "방문자 1000명", "유저 500명" 등 언급 시 → "말씀하신 방문자는 일일 방문자(DAU)인가요, 동시접속자인가요?"
- DAU와 동시접속자를 구분해서 설명: "일반적으로 동시접속자는 일일 방문자의 5-10% 정도입니다"
- "모르겠어요", "아무거나", "글쎄요" → 즉시 action="recommend" (기본값: 개인용 웹서비스)
- 용도+규모 한번에 말하면 → 즉시 action="recommend"
- 용도만 말해도 → 개인용으로 가정하고 action="recommend" 가능
- 질문은 최대 2번까지, 그 이후는 무조건 action="recommend"
## 사용자 수 관련 용어 정리
- **DAU (일일 활성 사용자)**: 하루 동안 서비스를 사용하는 전체 사용자 수
- **동시접속자 (Concurrent Users)**: 같은 시간에 동시에 접속해 있는 사용자 수
- **중요**: 서버 스펙은 동시접속자를 기준으로 계산해야 합니다
- **일반 공식**: 동시접속자 = DAU × 5-10%
예시:
- "하루 방문자 1000명" → DAU 1000명 → 동시접속자 50-100명
- "동시 접속 100명" → 그대로 동시접속자 100명 사용
## 추론 규칙 (30년 경험 기반)
- 블로그 → WordPress, 1GB RAM이면 충분, DAU 100명 (동시접속자 10명)
- 쇼핑몰 → 2GB+ RAM, DB 분리 고려, DAU 500명 (동시접속자 50명)
- 커뮤니티 → PHP+MySQL, 트래픽에 따라 2~4GB
- 게임서버 → 고사양 CPU, 낮은 레이턴시 리전
- 규모: personal→DAU 100명 (동접 10명), business→DAU 500명 (동접 50명)
## 현재 수집된 정보
${JSON.stringify(session.collectedInfo, null, 2)}
## 응답 형식 (반드시 JSON만 반환, 다른 텍스트 절대 금지)
{
"action": "question" | "recommend",
"message": "사용자에게 보여줄 메시지 (도구에서 얻은 정보를 자연스럽게 포함)",
"collectedInfo": {
"useCase": "용도 (없으면 '웹서비스')",
"scale": "personal 또는 business (없으면 'personal')",
"expectedDau": "일일 방문자 수 (사용자가 명시한 경우)",
"expectedConcurrent": "동시접속자 수 (사용자가 명시하거나 DAU에서 계산)"
}
}
중요: 정보가 부족해도 기본값으로 action="recommend" 하세요. 30년 경험이면 충분합니다.`;
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 response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
tools: serverExpertTools,
tool_choice: 'auto',
max_tokens: 800,
temperature: 0.7,
}),
});
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 executeServerExpertTool(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 recommendation
logger.warn('최대 도구 호출 횟수 도달', { toolCallCount });
return {
action: 'recommend',
message: '분석이 완료되었습니다. 최적의 서버를 추천해 드리겠습니다.',
collectedInfo: session.collectedInfo,
};
} catch (error) {
logger.error('Server Expert AI 호출 실패', error as Error);
throw error;
}
}
// Main consultation processing
export async function processServerConsultation(
userMessage: string,
session: ServerSession,
env: Env
): Promise<string> {
try {
logger.info('상담 처리 시작', {
userId: session.telegramUserId,
message: userMessage.slice(0, 50),
status: session.status
});
// 취소 키워드 처리 (selecting 또는 ordering 상태에서)
if ((session.status === 'selecting' || session.status === 'ordering') &&
/취소|다시|처음/.test(userMessage)) {
await deleteServerSession(env.SESSION_KV, session.telegramUserId);
logger.info('사용자 요청으로 상담 취소', { userId: session.telegramUserId });
return '상담이 취소되었습니다. 다시 시작하려면 "서버 추천"이라고 말씀해주세요.';
}
// 선택 단계 처리
if (session.status === 'selecting' && session.lastRecommendation) {
const selectionMatch = userMessage.match(/(\d+)(?:번|번째)?|첫\s*번째|두\s*번째|세\s*번째/);
if (selectionMatch) {
let selectedIndex = -1;
// 숫자 추출
if (selectionMatch[1]) {
selectedIndex = parseInt(selectionMatch[1], 10) - 1;
} else if (userMessage.includes('첫')) {
selectedIndex = 0;
} else if (userMessage.includes('두')) {
selectedIndex = 1;
} else if (userMessage.includes('세')) {
selectedIndex = 2;
}
// 유효성 검증
if (selectedIndex >= 0 && selectedIndex < session.lastRecommendation.recommendations.length) {
const selected = session.lastRecommendation.recommendations[selectedIndex];
// Mark session as ordering
session.status = 'ordering';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session);
// 주문 확인 메시지 생성 (인라인 버튼 포함)
const keyboardData = JSON.stringify({
type: 'server_order',
userId: session.telegramUserId,
index: selectedIndex,
plan: selected.plan_name
});
return `🖥️ ${selected.plan_name} 신청 확인\n\n` +
`• 제공사: ${selected.provider}\n` +
`• 스펙: ${selected.specs.vcpu}vCPU / ${selected.specs.ram_gb}GB / ${selected.specs.storage_gb}GB\n` +
`• 리전: ${selected.region.name} (${selected.region.code})\n` +
`• 가격: ₩${selected.price.monthly_krw.toLocaleString()}/월\n\n` +
`신청하시겠습니까?\n\n` +
`__KEYBOARD__${keyboardData}__END__`;
} else {
return `번호를 다시 확인해주세요. 1번부터 ${session.lastRecommendation.recommendations.length}번 중에서 선택해주세요.`;
}
}
// 선택하지 않고 다른 질문을 한 경우
return '서버 번호를 선택해주세요. (예: 1번)\n또는 "취소"라고 말씀하시면 처음부터 다시 시작합니다.';
}
// Add user message to history
session.messages.push({ role: 'user', content: userMessage });
// Call Server Expert AI
const aiResult = await callServerExpertAI(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 });
if (aiResult.action === 'recommend') {
// Mark session as recommending
session.status = 'recommending';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session);
// 1. Call recommendation API (추천 먼저 받기)
logger.info('추천 API 호출', { collectedInfo: session.collectedInfo });
const { executeServerAction, getRecommendationData } = await import('./tools/server-tool');
const techStack = session.collectedInfo.useCase
? inferTechStack(session.collectedInfo.useCase)
: ['web'];
// 동시접속자 우선 사용, 없으면 scale 기반 추론
let expectedUsers = 10; // Default
if (session.collectedInfo.expectedConcurrent) {
expectedUsers = session.collectedInfo.expectedConcurrent;
} else if (session.collectedInfo.expectedDau) {
// DAU가 있으면 10% 비율로 동시접속자 계산
expectedUsers = Math.ceil(session.collectedInfo.expectedDau * 0.1);
} else if (session.collectedInfo.scale) {
expectedUsers = inferExpectedUsers(session.collectedInfo.scale);
}
const recommendationData = await getRecommendationData(
{
tech_stack: techStack,
expected_users: expectedUsers,
use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: session.collectedInfo.regionPreference,
budget_limit: session.collectedInfo.budgetLimit,
lang: 'ko',
},
env
);
// 추천 결과를 세션에 저장
if (recommendationData && recommendationData.recommendations && recommendationData.recommendations.length > 0) {
session.lastRecommendation = {
recommendations: recommendationData.recommendations.slice(0, 3).map(rec => ({
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
},
score: rec.score,
max_users: rec.estimated_capacity?.max_concurrent_users || 0
})),
createdAt: Date.now()
};
// 2. AI에게 추천 결과 전달하여 검토 요청
logger.info('AI 검토 요청', { recommendationCount: recommendationData.recommendations.length });
const reviewResult = await callServerExpertAI(env, session, userMessage, recommendationData);
// 3. 포맷팅된 추천 결과 생성
const formattedRecommendation = await executeServerAction(
'recommend',
{
tech_stack: techStack,
expected_users: expectedUsers,
use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: session.collectedInfo.regionPreference,
budget_limit: session.collectedInfo.budgetLimit,
lang: 'ko',
},
env,
session.telegramUserId
);
// Mark session as selecting (사용자 선택 대기)
session.status = 'selecting';
await saveServerSession(env.SESSION_KV, session.telegramUserId, session);
// 4. AI 검토 코멘트 + 추천 결과 함께 반환
return `${reviewResult.message}\n\n${formattedRecommendation}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
} else {
// 추천 결과 없음 - 세션 삭제
session.status = 'completed';
await deleteServerSession(env.SESSION_KV, session.telegramUserId);
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
}
} else {
// Continue gathering information
session.status = 'gathering';
await saveServerSession(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 deleteServerSession(env.SESSION_KV, session.telegramUserId);
return '죄송합니다. 서버 추천 중 오류가 발생했습니다.\n다시 시도하려면 "서버 추천"이라고 말씀해주세요.';
}
}