Replace D2 rendering with DiagramAgent (Mermaid + Kroki)

- Delete d2-tool.ts and D2_RENDER_URL references
- Add diagram-agent.ts: OpenAI generates Mermaid → Kroki renders PNG → Telegram sendPhoto + R2 cache
- Update onboarding-agent to use generate_diagram tool with DiagramAgent
- Switch wrangler.toml from D2_RENDER_URL to KROKI_URL
- Remove D2 from env-validation, api-urls, tools/index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-12 09:32:46 +09:00
parent 25e56d3f58
commit 7dda198f47
7 changed files with 258 additions and 168 deletions

206
src/agents/diagram-agent.ts Normal file
View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
await sendMessage(env.BOT_TOKEN, chatId,
`다이어그램 렌더링 서비스에 연결할 수 없습니다.\n\n<b>Mermaid 코드:</b>\n<pre>${escaped}</pre>`,
{ 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
await sendMessage(env.BOT_TOKEN, chatId,
`다이어그램 이미지 전송에 실패했습니다.\n\n<b>Mermaid 코드:</b>\n<pre>${escaped}</pre>`,
{ 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<string | null> {
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<ArrayBuffer | null> {
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<void> {
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);
}
}
}

View File

@@ -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 type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent'; import { BaseAgent } from './base-agent';
import { DiagramAgent } from './diagram-agent';
import { SessionManager } from '../utils/session-manager'; import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config'; import { getSessionConfig } from '../constants/agent-config';
import { getD2RenderUrl } from '../utils/api-urls';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
const config = getSessionConfig('onboarding'); const config = getSessionConfig('onboarding');
@@ -54,7 +54,7 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항) 3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항)
4. 예산 범위 확인 4. 예산 범위 확인
5. 적절한 서비스 조합 추천 5. 적절한 서비스 조합 추천
6. 필요 시 D2 다이어그램으로 아키텍처 시각화 6. 필요 시 Mermaid 다이어그램으로 아키텍처 시각화
## 현재 세션 상태: ${session.status} ## 현재 세션 상태: ${session.status}
@@ -67,7 +67,7 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
- 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요. - 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용 ## 도구 사용
- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다. - 아키텍처 설명 시 generate_diagram으로 Mermaid 다이어그램을 생성하여 고객에게 전송할 수 있습니다.
- 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`; - 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`;
} }
@@ -76,22 +76,22 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
{ {
type: 'function', type: 'function',
function: { function: {
name: 'render_d2', name: 'generate_diagram',
description: '아키텍처 다이어그램을 D2 언어로 렌더링합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.', description: '아키텍처 다이어그램을 Mermaid로 생성하여 고객에게 전송합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
source: { description: {
type: 'string', type: 'string',
description: 'D2 다이어그램 소스 코드', description: '다이어그램에 표현할 아키텍처 또는 상황 설명',
}, },
format: { diagram_type: {
type: 'string', type: 'string',
enum: ['svg', 'png'], enum: ['flow', 'sequence', 'architecture'],
description: '출력 형식 (기본값: svg)', description: '다이어그램 유형 (선택, 기본값: architecture)',
}, },
}, },
required: ['source'], required: ['description'],
}, },
}, },
}, },
@@ -127,8 +127,12 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
context: AgentToolContext context: AgentToolContext
): Promise<string> { ): Promise<string> {
switch (name) { switch (name) {
case 'render_d2': case 'generate_diagram':
return this.handleRenderD2(args as unknown as RenderD2Args, context.env); return this.handleGenerateDiagram(
args.description as string,
args.diagram_type as string | undefined,
context
);
case 'search_knowledge': case 'search_knowledge':
return this.handleSearchKnowledge( return this.handleSearchKnowledge(
args as { query: string; category?: string }, args as { query: string; category?: string },
@@ -139,32 +143,28 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
} }
} }
private async handleRenderD2(args: RenderD2Args, env: Env): Promise<string> { private async handleGenerateDiagram(
try { description: string,
const renderUrl = getD2RenderUrl(env); diagramType: string | undefined,
const response = await fetch(`${renderUrl}/render`, { context: AgentToolContext
method: 'POST', ): Promise<string> {
headers: { 'Content-Type': 'application/json' }, if (!context.chatId) {
body: JSON.stringify({ return JSON.stringify({ error: '채팅 정보가 없어 다이어그램을 전송할 수 없습니다.' });
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 렌더링 서비스에 연결할 수 없습니다.' });
} }
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( private async handleSearchKnowledge(

View File

@@ -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<string> {
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<string> {
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('');
}

View File

@@ -7,7 +7,6 @@ import { manageDomainTool, executeManageDomain } from './domain-tool';
import { manageWalletTool, executeManageWallet } from './wallet-tool'; import { manageWalletTool, executeManageWallet } from './wallet-tool';
import { manageServerTool, executeManageServer } from './server-tool'; import { manageServerTool, executeManageServer } from './server-tool';
import { checkServiceTool, executeCheckService } from './service-tool'; import { checkServiceTool, executeCheckService } from './service-tool';
import { renderD2Tool, executeRenderD2 } from './d2-tool';
import { adminTool, executeAdmin } from './admin-tool'; import { adminTool, executeAdmin } from './admin-tool';
import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool'; import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool';
@@ -45,11 +44,6 @@ const CheckServiceArgsSchema = z.object({
service_id: z.number().int().positive().optional(), 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({ const AdminArgsSchema = z.object({
action: z.enum([ action: z.enum([
'block_user', 'unblock_user', 'set_role', 'broadcast', 'block_user', 'unblock_user', 'set_role', 'broadcast',
@@ -109,7 +103,6 @@ const toolExecutors: Record<
manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'), manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'),
manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'), manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'),
check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'), check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'),
render_d2: createValidatedExecutor(RenderD2ArgsSchema, executeRenderD2, 'd2'),
admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'), admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'),
search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'), search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'),
}; };
@@ -123,7 +116,6 @@ export const tools: ToolDefinition[] = [
manageWalletTool, manageWalletTool,
manageServerTool, manageServerTool,
checkServiceTool, checkServiceTool,
renderD2Tool,
adminTool, adminTool,
searchKnowledgeTool, searchKnowledgeTool,
]; ];
@@ -205,6 +197,5 @@ export { manageDomainTool } from './domain-tool';
export { manageWalletTool } from './wallet-tool'; export { manageWalletTool } from './wallet-tool';
export { manageServerTool } from './server-tool'; export { manageServerTool } from './server-tool';
export { checkServiceTool } from './service-tool'; export { checkServiceTool } from './service-tool';
export { renderD2Tool } from './d2-tool';
export { adminTool } from './admin-tool'; export { adminTool } from './admin-tool';
export { searchKnowledgeTool } from './knowledge-tool'; export { searchKnowledgeTool } from './knowledge-tool';

View File

@@ -1,8 +1,6 @@
import type { Env } from '../types'; import type { Env } from '../types';
const DEFAULT_OPENAI_GATEWAY = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai'; 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 경유) * 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; 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;
}

View File

@@ -25,7 +25,6 @@ export const EnvSchema = z.object({
// API URLs (optional, have defaults in wrangler.toml) // API URLs (optional, have defaults in wrangler.toml)
OPENAI_API_BASE: z.string().url().optional(), OPENAI_API_BASE: z.string().url().optional(),
D2_RENDER_URL: z.string().url().optional(),
NAMECHEAP_API_URL: z.string().url().optional(), NAMECHEAP_API_URL: z.string().url().optional(),
WHOIS_API_URL: z.string().url().optional(), WHOIS_API_URL: z.string().url().optional(),
CLOUD_ORCHESTRATOR_URL: z.string().url().optional(), CLOUD_ORCHESTRATOR_URL: z.string().url().optional(),

View File

@@ -9,8 +9,8 @@ binding = "AI"
ENVIRONMENT = "production" ENVIRONMENT = "production"
# AI Gateway 경유 # AI Gateway 경유
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai" OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai"
# D2 렌더링 서비스 (jp1 Incus) # Kroki 다이어그램 서비스 (Mermaid/D2 등 → HAProxy)
D2_RENDER_URL = "http://10.253.100.107:8080" KROKI_URL = "https://kroki.anvil.it.com"
# 외부 API # 외부 API
NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com" NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com"
WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app" WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app"
@@ -58,6 +58,17 @@ index_name = "knowledge-embeddings"
[triggers] [triggers]
crons = ["0 15 * * *", "*/5 * * * *", "0 * * * *"] 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): # Secrets (wrangler secret put):
# - BOT_TOKEN: Telegram Bot Token # - BOT_TOKEN: Telegram Bot Token
# - WEBHOOK_SECRET: Webhook 검증용 시크릿 # - WEBHOOK_SECRET: Webhook 검증용 시크릿