diff --git a/src/agents/diagram-agent.ts b/src/agents/diagram-agent.ts new file mode 100644 index 0000000..4fa62e2 --- /dev/null +++ b/src/agents/diagram-agent.ts @@ -0,0 +1,206 @@ +/** + * Diagram Agent - Mermaid 다이어그램 생성 서브 에이전트 + * + * 세션/멀티라운드 불필요. 호출 시: + * 1. OpenAI로 Mermaid 코드 생성 + * 2. Kroki로 PNG 렌더링 + * 3. Telegram으로 직접 전송 (+ R2 캐시) + */ + +import type { Env, OpenAIAPIResponse } from '../types'; +import { sendPhoto, sendMessage, sendChatAction } from '../telegram'; +import { createLogger } from '../utils/logger'; +import { getOpenAIUrl } from '../utils/api-urls'; + +const logger = createLogger('diagram-agent'); + +const MERMAID_SYSTEM_PROMPT = `You are a technical diagram generator for customer support at a cloud hosting service. +The customer describes a problem they are experiencing. Analyze the problem and create a Mermaid diagram that visualizes: +- The system architecture involved +- Where the problem might be occurring +- The flow of data/requests that is failing + +IMPORTANT RULES: +1. Output ONLY the Mermaid diagram code, nothing else +2. Do NOT wrap in \`\`\`mermaid\`\`\` code blocks +3. Use 'graph TD' for flow diagrams or 'sequenceDiagram' for request flows +4. Use Korean labels since the customer speaks Korean +5. Mark the problem area with style: style NodeName fill:#f66,stroke:#333 +6. Keep the diagram clear and focused (max 15 nodes) +7. CRITICAL: Always wrap node labels in double quotes to escape special characters. + CORRECT: A["웹서버 web-prod-01"] --> B["DB서버 db-prod-01"] + WRONG: A[웹서버(web-prod-01)] --> B[DB서버(db-prod-01)] + Never use parentheses (), curly braces {}, or other special chars inside labels without double quotes.`; + +export interface DiagramContext { + env: Env; + chatId: number; + telegramUserId: string; + messageId?: number; +} + +export class DiagramAgent { + /** + * Mermaid 코드 생성 → Kroki 렌더 → Telegram 전송 + */ + async generateAndSend( + context: DiagramContext, + description: string, + diagramType?: string + ): Promise<{ success: boolean; message: string }> { + const { env, chatId, messageId } = context; + + // 1. Generate Mermaid code + const mermaidCode = await this.generateMermaidCode(env, description, diagramType); + if (!mermaidCode) { + return { success: false, message: 'Mermaid 다이어그램 코드 생성에 실패했습니다.' }; + } + + // 2. Send typing indicator + await sendChatAction(env.BOT_TOKEN, chatId, 'upload_photo').catch(() => {}); + + // 3. Render via Kroki + const imageData = await this.renderWithKroki(env, mermaidCode); + if (!imageData) { + // Fallback: send mermaid code as text + const escaped = mermaidCode.replace(/&/g, '&').replace(//g, '>'); + await sendMessage(env.BOT_TOKEN, chatId, + `다이어그램 렌더링 서비스에 연결할 수 없습니다.\n\nMermaid 코드:\n
${escaped}
`, + { parse_mode: 'HTML' }); + return { success: true, message: '렌더링 실패로 Mermaid 코드를 텍스트로 전송했습니다.' }; + } + + // 4. Cache in R2 + await this.cacheInR2(env, mermaidCode, imageData); + + // 5. Send photo to Telegram (retry without reply_to_message_id on failure) + try { + await sendPhoto(env.BOT_TOKEN, chatId, imageData, { + caption: '다이어그램입니다. 추가 설명이 필요하시면 말씀해주세요.', + reply_to_message_id: messageId, + }); + } catch (firstError) { + logger.warn('sendPhoto failed, retrying without reply_to_message_id', { + error: (firstError as Error).message, + }); + try { + await sendPhoto(env.BOT_TOKEN, chatId, imageData, { + caption: '다이어그램입니다. 추가 설명이 필요하시면 말씀해주세요.', + }); + } catch (retryError) { + // Final fallback: send mermaid code as text + logger.error('sendPhoto retry failed', retryError as Error); + const escaped = mermaidCode.replace(/&/g, '&').replace(//g, '>'); + await sendMessage(env.BOT_TOKEN, chatId, + `다이어그램 이미지 전송에 실패했습니다.\n\nMermaid 코드:\n
${escaped}
`, + { parse_mode: 'HTML' }); + return { success: true, message: '이미지 전송 실패로 Mermaid 코드를 텍스트로 전송했습니다.' }; + } + } + + logger.info('Diagram sent', { chatId }); + return { success: true, message: '다이어그램이 고객에게 전송되었습니다.' }; + } + + private async generateMermaidCode( + env: Env, + description: string, + diagramType?: string + ): Promise { + if (!env.OPENAI_API_KEY) { + logger.error('OPENAI_API_KEY not set', new Error('Missing key')); + return null; + } + + const typeHint = diagramType + ? `\n\nPreferred diagram type: ${diagramType}. Use 'graph TD' for flow/architecture, 'sequenceDiagram' for sequence.` + : ''; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + try { + const response = await fetch(getOpenAIUrl(env), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.OPENAI_API_KEY}`, + }, + signal: controller.signal, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: MERMAID_SYSTEM_PROMPT + typeHint }, + { role: 'user', content: description }, + ], + max_tokens: 1000, + temperature: 0.3, + }), + }); + + if (!response.ok) { + logger.error('OpenAI API error', new Error(`HTTP ${response.status}`)); + return null; + } + + const data = (await response.json()) as OpenAIAPIResponse; + let code = data.choices[0]?.message?.content?.trim(); + if (!code) return null; + + code = code.replace(/```mermaid\n?/g, '').replace(/```\n?/g, '').trim(); + return code; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + logger.error('Mermaid generation failed', error as Error); + return null; + } + } + + private async renderWithKroki(env: Env, mermaidCode: string): Promise { + const krokiUrl = env.KROKI_URL; + if (!krokiUrl) { + logger.error('KROKI_URL not set', new Error('Missing KROKI_URL')); + return null; + } + + try { + const response = await fetch(`${krokiUrl}/mermaid/png`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: mermaidCode, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Kroki render failed', new Error(errorText), { status: response.status }); + return null; + } + + return await response.arrayBuffer(); + } catch (error) { + logger.error('Kroki connection error', error as Error); + return null; + } + } + + private async cacheInR2(env: Env, mermaidCode: string, imageData: ArrayBuffer): Promise { + if (!env.R2_BUCKET) return; + + try { + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(mermaidCode)); + const hash = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + await env.R2_BUCKET.put(`diagrams/${hash}.png`, imageData, { + httpMetadata: { contentType: 'image/png' }, + }); + } catch (error) { + logger.error('R2 cache failed', error as Error); + } + } +} diff --git a/src/agents/onboarding-agent.ts b/src/agents/onboarding-agent.ts index 6c83952..4e9b039 100644 --- a/src/agents/onboarding-agent.ts +++ b/src/agents/onboarding-agent.ts @@ -8,12 +8,12 @@ * - 지식 베이스 검색 */ -import type { Env, ToolDefinition, OnboardingSession, RenderD2Args } from '../types'; +import type { ToolDefinition, OnboardingSession } from '../types'; import type { AgentToolContext } from './base-agent'; import { BaseAgent } from './base-agent'; +import { DiagramAgent } from './diagram-agent'; import { SessionManager } from '../utils/session-manager'; import { getSessionConfig } from '../constants/agent-config'; -import { getD2RenderUrl } from '../utils/api-urls'; import { createLogger } from '../utils/logger'; const config = getSessionConfig('onboarding'); @@ -54,7 +54,7 @@ export class OnboardingAgent extends BaseAgent { 3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항) 4. 예산 범위 확인 5. 적절한 서비스 조합 추천 -6. 필요 시 D2 다이어그램으로 아키텍처 시각화 +6. 필요 시 Mermaid 다이어그램으로 아키텍처 시각화 ## 현재 세션 상태: ${session.status} @@ -67,7 +67,7 @@ export class OnboardingAgent extends BaseAgent { - 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요. ## 도구 사용 -- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다. +- 아키텍처 설명 시 generate_diagram으로 Mermaid 다이어그램을 생성하여 고객에게 전송할 수 있습니다. - 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`; } @@ -76,22 +76,22 @@ export class OnboardingAgent extends BaseAgent { { type: 'function', function: { - name: 'render_d2', - description: '아키텍처 다이어그램을 D2 언어로 렌더링합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.', + name: 'generate_diagram', + description: '아키텍처 다이어그램을 Mermaid로 생성하여 고객에게 전송합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.', parameters: { type: 'object', properties: { - source: { + description: { type: 'string', - description: 'D2 다이어그램 소스 코드', + description: '다이어그램에 표현할 아키텍처 또는 상황 설명', }, - format: { + diagram_type: { type: 'string', - enum: ['svg', 'png'], - description: '출력 형식 (기본값: svg)', + enum: ['flow', 'sequence', 'architecture'], + description: '다이어그램 유형 (선택, 기본값: architecture)', }, }, - required: ['source'], + required: ['description'], }, }, }, @@ -127,8 +127,12 @@ export class OnboardingAgent extends BaseAgent { context: AgentToolContext ): Promise { switch (name) { - case 'render_d2': - return this.handleRenderD2(args as unknown as RenderD2Args, context.env); + case 'generate_diagram': + return this.handleGenerateDiagram( + args.description as string, + args.diagram_type as string | undefined, + context + ); case 'search_knowledge': return this.handleSearchKnowledge( args as { query: string; category?: string }, @@ -139,32 +143,28 @@ export class OnboardingAgent extends BaseAgent { } } - private async handleRenderD2(args: RenderD2Args, env: Env): Promise { - try { - const renderUrl = getD2RenderUrl(env); - const response = await fetch(`${renderUrl}/render`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - source: args.source, - format: args.format || 'svg', - }), - }); - - if (!response.ok) { - logger.error('D2 렌더링 실패', new Error(`HTTP ${response.status}`)); - return JSON.stringify({ error: 'D2 다이어그램 렌더링에 실패했습니다.' }); - } - - return JSON.stringify({ - success: true, - message: '아키텍처 다이어그램이 생성되었습니다.', - format: args.format || 'svg', - }); - } catch (error) { - logger.error('D2 렌더링 오류', error as Error); - return JSON.stringify({ error: 'D2 렌더링 서비스에 연결할 수 없습니다.' }); + private async handleGenerateDiagram( + description: string, + diagramType: string | undefined, + context: AgentToolContext + ): Promise { + if (!context.chatId) { + return JSON.stringify({ error: '채팅 정보가 없어 다이어그램을 전송할 수 없습니다.' }); } + + const diagramAgent = new DiagramAgent(); + const result = await diagramAgent.generateAndSend( + { + env: context.env, + chatId: context.chatId, + telegramUserId: context.userId, + messageId: context.messageId, + }, + description, + diagramType + ); + + return JSON.stringify(result); } private async handleSearchKnowledge( diff --git a/src/tools/d2-tool.ts b/src/tools/d2-tool.ts deleted file mode 100644 index 26a7ab4..0000000 --- a/src/tools/d2-tool.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createLogger } from '../utils/logger'; -import type { Env, ToolDefinition, RenderD2Args } from '../types'; - -const logger = createLogger('d2-tool'); - -export const renderD2Tool: ToolDefinition = { - type: 'function', - function: { - name: 'render_d2', - description: - 'D2 다이어그램 렌더링: D2 소스 코드를 이미지(SVG/PNG)로 변환합니다.', - parameters: { - type: 'object', - properties: { - source: { - type: 'string', - description: 'D2 다이어그램 소스 코드', - }, - format: { - type: 'string', - enum: ['svg', 'png'], - description: '출력 포맷 (기본: svg)', - }, - }, - required: ['source'], - }, - }, -}; - -export async function executeRenderD2( - args: RenderD2Args, - env?: Env, - _userId?: string, - db?: D1Database -): Promise { - try { - if (!env?.D2_RENDER_URL) { - return 'D2 렌더링 서비스가 설정되지 않았습니다.'; - } - if (!env.R2_BUCKET) { - return 'R2 스토리지가 설정되지 않았습니다.'; - } - - const format = args.format ?? 'svg'; - const sourceHash = await hashSource(args.source + format); - - // Check cache - if (db) { - const cached = await db - .prepare('SELECT r2_key FROM d2_cache WHERE source_hash = ?') - .bind(sourceHash) - .first<{ r2_key: string }>(); - - if (cached) { - logger.info('D2 cache hit', { hash: sourceHash }); - return `다이어그램이 준비되었습니다: /d2/${cached.r2_key}`; - } - } - - // Render via external service - const response = await fetch(env.D2_RENDER_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: args.source, format }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error('D2 render API failed', undefined, { - status: response.status, - error: errorText, - }); - return `다이어그램 렌더링 실패: ${response.statusText}`; - } - - const imageData = await response.arrayBuffer(); - const contentType = format === 'svg' ? 'image/svg+xml' : 'image/png'; - const r2Key = `d2/${sourceHash}.${format}`; - - // Store in R2 - await env.R2_BUCKET.put(r2Key, imageData, { - httpMetadata: { contentType }, - }); - - // Cache metadata - if (db) { - await db - .prepare( - 'INSERT INTO d2_cache (source_hash, r2_key, format) VALUES (?, ?, ?)' - ) - .bind(sourceHash, r2Key, format) - .run(); - } - - logger.info('D2 diagram rendered and cached', { hash: sourceHash, format }); - return `다이어그램이 준비되었습니다: /d2/${r2Key}`; - } catch (error) { - logger.error('D2 tool error', error as Error); - return '다이어그램 렌더링 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; - } -} - -async function hashSource(source: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(source); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); -} diff --git a/src/tools/index.ts b/src/tools/index.ts index d03d75b..b6944c5 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -7,7 +7,6 @@ import { manageDomainTool, executeManageDomain } from './domain-tool'; import { manageWalletTool, executeManageWallet } from './wallet-tool'; import { manageServerTool, executeManageServer } from './server-tool'; import { checkServiceTool, executeCheckService } from './service-tool'; -import { renderD2Tool, executeRenderD2 } from './d2-tool'; import { adminTool, executeAdmin } from './admin-tool'; import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool'; @@ -45,11 +44,6 @@ const CheckServiceArgsSchema = z.object({ service_id: z.number().int().positive().optional(), }); -const RenderD2ArgsSchema = z.object({ - source: z.string().min(1).max(10000), - format: z.enum(['svg', 'png']).optional(), -}); - const AdminArgsSchema = z.object({ action: z.enum([ 'block_user', 'unblock_user', 'set_role', 'broadcast', @@ -109,7 +103,6 @@ const toolExecutors: Record< manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'), manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'), check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'), - render_d2: createValidatedExecutor(RenderD2ArgsSchema, executeRenderD2, 'd2'), admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'), search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'), }; @@ -123,7 +116,6 @@ export const tools: ToolDefinition[] = [ manageWalletTool, manageServerTool, checkServiceTool, - renderD2Tool, adminTool, searchKnowledgeTool, ]; @@ -205,6 +197,5 @@ export { manageDomainTool } from './domain-tool'; export { manageWalletTool } from './wallet-tool'; export { manageServerTool } from './server-tool'; export { checkServiceTool } from './service-tool'; -export { renderD2Tool } from './d2-tool'; export { adminTool } from './admin-tool'; export { searchKnowledgeTool } from './knowledge-tool'; diff --git a/src/utils/api-urls.ts b/src/utils/api-urls.ts index 139ee74..3b3e68b 100644 --- a/src/utils/api-urls.ts +++ b/src/utils/api-urls.ts @@ -1,8 +1,6 @@ import type { Env } from '../types'; const DEFAULT_OPENAI_GATEWAY = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai'; -const DEFAULT_D2_RENDER_URL = 'http://10.253.100.107:8080'; - /** * OpenAI Chat Completions API URL (AI Gateway 경유) */ @@ -18,9 +16,3 @@ export function getOpenAIBaseUrl(env: Env): string { return env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY; } -/** - * D2 diagram rendering service URL - */ -export function getD2RenderUrl(env: Env): string { - return env.D2_RENDER_URL || DEFAULT_D2_RENDER_URL; -} diff --git a/src/utils/env-validation.ts b/src/utils/env-validation.ts index 2fc233c..6cfc217 100644 --- a/src/utils/env-validation.ts +++ b/src/utils/env-validation.ts @@ -25,7 +25,6 @@ export const EnvSchema = z.object({ // API URLs (optional, have defaults in wrangler.toml) OPENAI_API_BASE: z.string().url().optional(), - D2_RENDER_URL: z.string().url().optional(), NAMECHEAP_API_URL: z.string().url().optional(), WHOIS_API_URL: z.string().url().optional(), CLOUD_ORCHESTRATOR_URL: z.string().url().optional(), diff --git a/wrangler.toml b/wrangler.toml index 2d46495..6800002 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,8 +9,8 @@ binding = "AI" ENVIRONMENT = "production" # AI Gateway 경유 OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai" -# D2 렌더링 서비스 (jp1 Incus) -D2_RENDER_URL = "http://10.253.100.107:8080" +# Kroki 다이어그램 서비스 (Mermaid/D2 등 → HAProxy) +KROKI_URL = "https://kroki.anvil.it.com" # 외부 API NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com" WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app" @@ -58,6 +58,17 @@ index_name = "knowledge-embeddings" [triggers] crons = ["0 15 * * *", "*/5 * * * *", "0 * * * *"] +# Cloudflare Queues: 태그 기반 범용 비동기 작업 큐 +[[queues.producers]] +queue = "work-queue" +binding = "WORK_QUEUE" + +[[queues.consumers]] +queue = "work-queue" +max_batch_size = 1 +max_retries = 2 +dead_letter_queue = "work-queue-dlq" + # Secrets (wrangler secret put): # - BOT_TOKEN: Telegram Bot Token # - WEBHOOK_SECRET: Webhook 검증용 시크릿