Phase 1-3 구현: - init.sql v2: 12테이블 (기존 5 + 신규 7) + 분류기 v2 프롬프트 - migrate-v2.sql: 기존 DB 마이그레이션 스크립트 - setup-qdrant.sh: tk_company 컬렉션 + payload 인덱스 설정 - 워크플로우 v2 (37노드): 토큰검증, Rate Limit, 프리필터, 분류기v2(response_tier), 3-tier 라우팅(local/Haiku/Opus), 멀티-컬렉션 RAG, 예산 체크, 선택적 메모리 - .env.example + docker-compose.yml: 새 환경변수 추가 - CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md 전면 갱신 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
682 lines
44 KiB
JSON
682 lines
44 KiB
JSON
{
|
|
"name": "메인 채팅 파이프라인 v2",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "chat",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000001",
|
|
"name": "Webhook",
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2,
|
|
"position": [0, 500],
|
|
"webhookId": "chat"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Synology Chat outgoing webhook 파싱 + 토큰 검증 + Rate Limit\nconst body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst username = body.username || 'unknown';\nconst userId = body.user_id || '';\nconst token = body.token || '';\nconst timestamp = body.timestamp || '';\nconst fileUrl = body.file_url || '';\n\n// 토큰 검증\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\n// Rate Limit (in-memory, 10초 내 5건)\nconst staticData = this.getWorkflowStaticData('global');\nconst now = Date.now();\nconst rlKey = `rl_${username}`;\nif (!staticData[rlKey]) staticData[rlKey] = [];\nstaticData[rlKey] = staticData[rlKey].filter(t => now - t < 10000);\nif (staticData[rlKey].length >= 5) {\n return [{ json: { rejected: true, rejectReason: '잠시 후 다시 시도해주세요.' } }];\n}\nstaticData[rlKey].push(now);\n\nreturn [{\n json: {\n rejected: false,\n text,\n username,\n userId,\n token,\n timestamp,\n fileUrl,\n isCommand: text.startsWith('/')\n }\n}];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000002",
|
|
"name": "Parse Input",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [220, 500]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
|
"conditions": [
|
|
{
|
|
"id": "reject-check",
|
|
"leftValue": "={{ $json.rejected }}",
|
|
"rightValue": true,
|
|
"operator": { "type": "boolean", "operation": "true" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000003",
|
|
"name": "Is Rejected?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [440, 500]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const reason = $('Parse Input').first().json.rejectReason || '요청을 처리할 수 없습니다.';\nreturn [{ json: { text: reason } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000004",
|
|
"name": "Reject Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [660, 300]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
|
"options": { "timeout": 10000 }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000005",
|
|
"name": "Send Reject",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [880, 200]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"text\": {{ JSON.stringify($('Reject Response').first().json.text) }} }",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000006",
|
|
"name": "Respond Reject",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [880, 400]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"rules": {
|
|
"values": [
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.text }}",
|
|
"rightValue": "^(안녕|안녕하세요|하이|ㅎㅇ|hi|hello|hey|좋은\\s*(아침|저녁|오후)|반갑|반가워|고마워|고맙|감사합니다|감사해|ㄱㅅ|ㅎㅎ|ㅋㅋ|ㄷㄷ|잘\\s*자|잘\\s*가|수고|ㄱㄴ|굿\\s*나잇|good\\s*(morning|night)).*$",
|
|
"operator": { "type": "string", "operation": "regex" }
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" }
|
|
},
|
|
"renameOutput": "Greeting"
|
|
}
|
|
]
|
|
},
|
|
"options": { "fallbackOutput": "extra" }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000007",
|
|
"name": "Regex Pre-filter",
|
|
"type": "n8n-nodes-base.switch",
|
|
"typeVersion": 3.2,
|
|
"position": [660, 600]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const text = $('Parse Input').first().json.text.toLowerCase();\nconst responses = {\n '안녕': '안녕하세요 😊 무엇을 도와드릴까요?',\n '안녕하세요': '안녕하세요! 편하게 말씀해주세요 😊',\n 'hi': '안녕하세요! 무엇을 도와드릴까요?',\n 'hello': '안녕하세요! 편하게 말씀해주세요.',\n '고마워': '도움이 되셨다니 다행이에요 😊',\n '고맙': '별말씀을요, 언제든 말씀해주세요.',\n '감사합니다': '도움이 되셨다니 다행이에요 😊',\n '감사해': '별말씀을요 😊',\n '잘 자': '편안한 밤 되세요 🌙',\n '수고': '수고 많으셨어요. 편히 쉬세요!',\n};\n\nlet reply = '안녕하세요! 무엇을 도와드릴까요? 😊';\nfor (const [key, val] of Object.entries(responses)) {\n if (text.includes(key)) { reply = val; break; }\n}\n\nreturn [{ json: { text: reply } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000008",
|
|
"name": "Pre-filter Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [880, 500]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
|
"options": { "timeout": 10000 }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000009",
|
|
"name": "Send Pre-filter",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [1100, 400]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"text\": {{ JSON.stringify($('Pre-filter Response').first().json.text) }} }",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000010",
|
|
"name": "Respond Pre-filter",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [1100, 600]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
|
"conditions": [
|
|
{
|
|
"id": "cmd-check",
|
|
"leftValue": "={{ $('Parse Input').first().json.isCommand }}",
|
|
"rightValue": true,
|
|
"operator": { "type": "boolean", "operation": "true" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000011",
|
|
"name": "Is Command?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [880, 800]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const input = $('Parse Input').first().json;\nconst text = input.text;\nconst username = input.username;\nconst parts = text.split(/\\s+/);\nconst cmd = parts[0];\nconst arg = parts.slice(1).join(' ');\n\n// 권한 체크\nconst admins = ($env.ADMIN_USERNAMES || '').split(',').map(s => s.trim()).filter(Boolean);\nconst restrictedCmds = ['/문서등록', '/보고서', '/설정'];\nconst needsPermission = restrictedCmds.includes(cmd);\nif (needsPermission && admins.length > 0 && !admins.includes(username)) {\n return [{ json: { text: `권한이 없습니다. (${cmd})`, needsDb: false, sqlQuery: '' } }];\n}\n\nconst safeArg = arg.replace(/'/g, \"''\");\nlet responseText = '';\nlet sqlQuery = '';\n\nswitch(cmd) {\n case '/설정':\n sqlQuery = `SELECT feature, model, temperature, max_tokens FROM ai_configs WHERE enabled = true ORDER BY feature`;\n break;\n case '/모델':\n if (!arg) { responseText = '사용법: /모델 <모델명>\\n예: /모델 claude-haiku-4-5-20251001'; }\n else { sqlQuery = `UPDATE ai_configs SET model = '${safeArg}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, model`; }\n break;\n case '/성격':\n if (!arg) { responseText = '사용법: /성격 <시스템 프롬프트 설명>'; }\n else { sqlQuery = `UPDATE ai_configs SET system_prompt = '${safeArg}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, LEFT(system_prompt, 50) as prompt_preview`; }\n break;\n case '/문서등록':\n responseText = '문서 등록 기능은 준비 중입니다.\\n사용법: /문서등록 [부서] [문서유형] [제목]\\n(다음 메시지에 텍스트 붙여넣기)';\n break;\n case '/보고서':\n responseText = '보고서 생성 기능은 준비 중입니다.\\n사용법: /보고서 [영역] [년월]\\n예: /보고서 안전 2026-03';\n break;\n default:\n responseText = `알 수 없는 명령어입니다: ${cmd}\\n사용 가능: /설정, /모델, /성격, /문서등록, /보고서`;\n}\n\nreturn [{ json: { cmd, arg, responseText, sqlQuery, needsDb: sqlQuery !== '' } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000012",
|
|
"name": "Parse Command",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1100, 700]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
|
"conditions": [
|
|
{
|
|
"id": "db-check",
|
|
"leftValue": "={{ $json.needsDb }}",
|
|
"rightValue": true,
|
|
"operator": { "type": "boolean", "operation": "true" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000013",
|
|
"name": "Needs DB?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [1320, 700]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "={{ $json.sqlQuery }}",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000014",
|
|
"name": "Command DB Query",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [1540, 600],
|
|
"credentials": {
|
|
"postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" }
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items = $input.all();\nconst cmd = $('Parse Command').first().json.cmd;\n\nswitch(cmd) {\n case '/설정': {\n const lines = items.map(r =>\n `• ${r.json.feature}: ${r.json.model} (temp=${r.json.temperature}, max=${r.json.max_tokens})`\n );\n return [{ json: { text: '현재 설정:\\n' + lines.join('\\n') } }];\n }\n case '/모델': {\n const first = items[0]?.json;\n return [{ json: { text: `모델이 ${first?.model || 'unknown'}(으)로 변경되었습니다.` } }];\n }\n case '/성격': {\n const first = items[0]?.json;\n return [{ json: { text: `성격이 변경되었습니다: \"${first?.prompt_preview || ''}...\"` } }];\n }\n default:\n return [{ json: { text: '처리되었습니다.' } }];\n}"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000015",
|
|
"name": "Format Command Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1760, 600]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const directText = $('Parse Command').first().json.responseText;\nreturn [{ json: { text: directText || '처리되었습니다.' } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000016",
|
|
"name": "Direct Command Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1540, 800]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
|
"options": { "timeout": 10000 }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000017",
|
|
"name": "Send Command Response",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [1980, 600]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"text\": {{ JSON.stringify($('Format Command Response').item.json.text || $('Direct Command Response').item.json.text || '처리되었습니다.') }} }",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000018",
|
|
"name": "Respond Command Webhook",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [1980, 800]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Qwen 9B 분류 v2: response_tier + rag_target\n// 10초 타임아웃, 실패 시 fallback → api_light\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst fileUrl = input.fileUrl;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|calendar|reminder|mail|photo|command|report|other\",\n \"response_tier\": \"local|api_light|api_heavy\",\n \"needs_rag\": true/false,\n \"rag_target\": [\"documents\", \"tk_company\", \"chat_memory\"],\n \"department_hint\": \"안전|생산|구매|품질|null\",\n \"report_domain\": \"안전|시설설비|품질|null\",\n \"query\": \"검색용 쿼리 (needs_rag=false면 null)\"\n}\n\nresponse_tier 판단 기준:\n- local: 인사, 잡담, 단순 확인, 감사, 짧은 반응, 시간/날씨\n- api_light: 요약, 번역, RAG 정리, 비교 분석, 일반 질문\n- api_heavy: 법률 해석, 다중 문서 분석, 보고서 작성, 복잡한 추론\n\nrag_target 기준 (needs_rag=true일 때만):\n- documents: 개인 문서, 기술 지식, 메일 요약\n- tk_company: 회사 관련 (절차서, 규정, 현장 리포트)\n- chat_memory: 이전 대화 참조 (\"아까 말한\", \"전에 물어본\")\n- 복수 선택 가능. needs_rag=false면 빈 배열 []\n\nintent=report 판단:\n- 현장 신고: 사진 포함 또는 \"~에서 ~가 발생/고장/파손/누수\"\n- report_domain: 안전/시설설비/품질\n\nquery 작성법:\n- needs_rag=true일 때 핵심 키워드를 추출하여 검색 쿼리로 변환\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: classifierPrompt,\n stream: false,\n format: 'json'\n },\n timeout: 10000\n });\n\n const latency = Date.now() - startTime;\n let cls;\n try {\n cls = JSON.parse(response.response);\n } catch(e) {\n cls = {};\n }\n\n return [{\n json: {\n intent: cls.intent || 'question',\n response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false,\n rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null,\n report_domain: cls.report_domain || null,\n query: cls.query || userText,\n userText,\n username,\n fileUrl,\n latency,\n fallback: false\n }\n }];\n} catch(e) {\n // Fallback: 분류기 실패 → api_light\n const latency = Date.now() - startTime;\n return [{\n json: {\n intent: 'question',\n response_tier: 'api_light',\n needs_rag: false,\n rag_target: [],\n department_hint: null,\n report_domain: null,\n query: userText,\n userText,\n username,\n fileUrl,\n latency,\n fallback: true\n }\n }];\n}"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000020",
|
|
"name": "Qwen Classify v2",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1100, 1000]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO classification_logs (input_text, output_json, model, latency_ms, fallback_used) VALUES (LEFT('{{ $json.userText }}', 200), '{{ JSON.stringify({intent: $json.intent, response_tier: $json.response_tier, needs_rag: $json.needs_rag, rag_target: $json.rag_target}) }}'::jsonb, 'qwen3.5:9b-q8_0', {{ $json.latency }}, {{ $json.fallback }})",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000021",
|
|
"name": "Log Classification",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [1320, 1200],
|
|
"credentials": {
|
|
"postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" }
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
|
"conditions": [
|
|
{
|
|
"id": "rag-check",
|
|
"leftValue": "={{ $json.needs_rag }}",
|
|
"rightValue": true,
|
|
"operator": { "type": "boolean", "operation": "true" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000022",
|
|
"name": "Needs RAG?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [1320, 1000]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// bge-m3 임베딩\nconst query = $('Qwen Classify v2').first().json.query;\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n body: { model: 'bge-m3', prompt: query },\n headers: { 'Content-Type': 'application/json' },\n timeout: 15000\n});\n\nreturn [{ json: { embedding: response.embedding } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000023",
|
|
"name": "Get Embedding",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1540, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// 멀티-컬렉션 검색 + Reranker (fallback: Qdrant score 정렬)\nconst cls = $('Qwen Classify v2').first().json;\nconst embedding = $input.first().json.embedding;\nconst ragTargets = cls.rag_target && cls.rag_target.length > 0\n ? cls.rag_target : ['documents'];\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n\n// 동적 limit: 1컬렉션→10, 2개→각7, 3개→각5\nconst limitMap = { 1: 10, 2: 7, 3: 5 };\nconst limit = limitMap[Math.min(ragTargets.length, 3)] || 5;\n\nlet allResults = [];\n\nfor (const collection of ragTargets) {\n let filter = undefined;\n\n if (collection === 'tk_company' && cls.department_hint) {\n filter = {\n must: [{ key: 'department', match: { value: cls.department_hint } }]\n };\n } else if (collection === 'chat_memory') {\n filter = {\n must: [{ key: 'username', match: { value: cls.username } }]\n };\n }\n\n try {\n const body = { vector: embedding, limit, with_payload: true };\n if (filter) body.filter = filter;\n\n const resp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${qdrantUrl}/collections/${collection}/points/search`,\n body,\n headers: { 'Content-Type': 'application/json' },\n timeout: 10000\n });\n\n const results = (resp.result || []).map(r => ({\n score: r.score,\n text: r.payload?.text || '',\n collection,\n payload: r.payload\n }));\n allResults = allResults.concat(results);\n } catch(e) {\n // 컬렉션 검색 실패 → 스킵\n }\n}\n\n// Reranker 시도 (bge-reranker-v2-m3)\nlet top3;\ntry {\n const candidates = allResults.slice(0, 10);\n const reranked = [];\n for (const doc of candidates) {\n const resp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/generate`,\n body: {\n model: 'bge-reranker-v2-m3',\n prompt: `query: ${cls.query}\\ndocument: ${doc.text.substring(0, 500)}`,\n stream: false\n },\n timeout: 3000\n });\n const score = parseFloat(resp.response) || doc.score;\n reranked.push({ ...doc, rerank_score: score });\n }\n top3 = reranked.sort((a, b) => b.rerank_score - a.rerank_score).slice(0, 3);\n} catch(e) {\n // Reranker 실패 → Qdrant score 기반 정렬\n top3 = allResults.sort((a, b) => b.score - a.score).slice(0, 3);\n}\n\nreturn [{ json: { results: top3 } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000024",
|
|
"name": "Multi-Collection Search",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1760, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// RAG 컨텍스트 빌드 (출처 표시)\nconst searchResults = $input.first().json.results || [];\nconst cls = $('Qwen Classify v2').first().json;\n\nconst SCORE_THRESHOLD = 0.3;\nconst relevant = searchResults.filter(r => (r.rerank_score || r.score) >= SCORE_THRESHOLD);\n\nlet ragContext = '';\nif (relevant.length > 0) {\n const sourceLabels = {\n 'documents': '개인문서',\n 'tk_company': '회사',\n 'chat_memory': '이전대화'\n };\n\n ragContext = relevant.map((r, i) => {\n const src = sourceLabels[r.collection] || r.collection;\n const dept = r.payload?.department ? `/${r.payload.department}` : '';\n const docType = r.payload?.doc_type ? `/${r.payload.doc_type}` : '';\n const date = r.payload?.timestamp ? `/${new Date(r.payload.timestamp).toISOString().slice(0,10)}` : '';\n return `[${src}${dept}${docType}${date}] ${r.text.substring(0, 500)}`;\n }).join('\\n\\n');\n}\n\nreturn [{\n json: {\n ...cls,\n ragContext\n }\n}];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000025",
|
|
"name": "Build RAG Context",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1980, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// RAG 불필요 → 빈 컨텍스트로 통과\nconst cls = $('Qwen Classify v2').first().json;\nreturn [{ json: { ...cls, ragContext: '' } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000026",
|
|
"name": "No RAG Context",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [1540, 1100]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"rules": {
|
|
"values": [
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.response_tier }}",
|
|
"rightValue": "local",
|
|
"operator": { "type": "string", "operation": "equals" }
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }
|
|
},
|
|
"renameOutput": "Local"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.response_tier }}",
|
|
"rightValue": "api_heavy",
|
|
"operator": { "type": "string", "operation": "equals" }
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }
|
|
},
|
|
"renameOutput": "Opus"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.response_tier }}",
|
|
"rightValue": "api_light",
|
|
"operator": { "type": "string", "operation": "equals" }
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }
|
|
},
|
|
"renameOutput": "Haiku"
|
|
}
|
|
]
|
|
},
|
|
"options": { "fallbackOutput": "extra" }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000027",
|
|
"name": "Route by Tier",
|
|
"type": "n8n-nodes-base.switch",
|
|
"typeVersion": 3.2,
|
|
"position": [2200, 1000]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Local tier: Qwen 9B 직접 답변 (무료)\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\nconst systemPrompt = '당신은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.\\n간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.';\n\nlet prompt = systemPrompt + '\\n\\n';\nif (ragContext) {\n prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\n}\nprompt += '사용자: ' + userText + '\\n이드:';\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: prompt,\n stream: false\n },\n timeout: 30000\n });\n\n return [{\n json: {\n text: response.response || '죄송합니다, 응답을 생성하지 못했어요.',\n model: 'qwen3.5:9b-q8_0',\n inputTokens: 0,\n outputTokens: 0,\n response_tier: 'local',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n }];\n} catch(e) {\n return [{\n json: {\n text: '잠시 응답이 어렵습니다. 다시 시도해주세요.',\n model: 'qwen3.5:9b-q8_0',\n inputTokens: 0,\n outputTokens: 0,\n response_tier: 'local',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n }];\n}"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000028",
|
|
"name": "Call Qwen Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [2420, 800]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// API Light: Claude Haiku + Budget Check\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\nlet systemPrompt = '당신의 이름은 \"이드\"입니다.\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다\\n- 의견을 제시할 때는 부드럽게, 강요하지 않습니다\\n- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다\\n\\n[말투]\\n- 부드러운 존댓말을 사용합니다\\n- 자신을 지칭할 때 겸양어를 씁니다\\n- 자기 이름을 직접 말하지 않습니다\\n- 자연스럽고 편안한 톤\\n- 이모지는 가끔 핵심 포인트에만 사용합니다\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로 답합니다\\n- 질문의 의도를 파악해서 필요한 만큼만 답합니다\\n- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다\\n- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다\\n\\n[기억]\\n- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다\\n- 이 내용을 자연스럽게 참고하여 답변하세요\\n- \"기억나지 않는다\"고 하지 마세요';\n\nif (ragContext) {\n systemPrompt += '\\n\\n[이전 대화 기록 / 참고 자료]\\n' + ragContext;\n}\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://api.anthropic.com/v1/messages',\n headers: {\n 'x-api-key': $env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n body: {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 2048,\n system: systemPrompt,\n messages: [{ role: 'user', content: userText }]\n },\n timeout: 30000\n});\n\nreturn [{\n json: {\n text: response.content?.[0]?.text || '응답을 처리할 수 없습니다.',\n model: response.model || 'claude-haiku-4-5-20251001',\n inputTokens: response.usage?.input_tokens || 0,\n outputTokens: response.usage?.output_tokens || 0,\n response_tier: 'api_light',\n intent: data.intent,\n userText: data.userText,\n username: data.username\n }\n}];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000029",
|
|
"name": "Call Haiku",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [2420, 1100]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// API Heavy: Claude Opus + Budget Check (초과 시 Haiku로 다운그레이드)\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\n\n// 예산 체크: api_usage_monthly에서 현재 월 estimated_cost 조회\nlet useOpus = true;\ntry {\n const now = new Date();\n const year = now.getFullYear();\n const month = now.getMonth() + 1;\n const budgetLimit = parseFloat($env.API_BUDGET_HEAVY) || 50;\n\n // n8n에서 DB 직접 쿼리 불가 → 환경변수 기반 간이 체크\n // (실제 비용 추적은 API Usage Log 노드에서 수행)\n // 여기서는 static data로 간이 트래킹\n const staticData = this.getWorkflowStaticData('global');\n const costKey = `opus_cost_${year}_${month}`;\n const currentCost = staticData[costKey] || 0;\n\n if (currentCost >= budgetLimit) {\n useOpus = false; // 다운그레이드\n }\n} catch(e) {\n // 체크 실패 → Opus 허용\n}\n\nconst modelToUse = useOpus ? 'claude-opus-4-6' : 'claude-haiku-4-5-20251001';\nconst tier = useOpus ? 'api_heavy' : 'api_light';\n\nlet systemPrompt = '당신의 이름은 \"이드\"입니다.\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트하는 데 초점을 맞추며, 독선적이지 않습니다\\n- 의견을 제시할 때는 부드럽게, 강요하지 않습니다\\n- 틀린 것을 바로잡을 때도 상대방이 기분 나쁘지 않게 합니다\\n\\n[말투]\\n- 부드러운 존댓말을 사용합니다\\n- 자신을 지칭할 때 겸양어를 씁니다\\n- 자기 이름을 직접 말하지 않습니다\\n- 자연스럽고 편안한 톤\\n- 이모지는 가끔 핵심 포인트에만 사용합니다\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로 답합니다\\n- 질문의 의도를 파악해서 필요한 만큼만 답합니다\\n- 모르는 것은 솔직하게, 추측은 추측이라고 밝힙니다\\n- 일정이나 할 일은 정확하게, 빠뜨리지 않습니다\\n\\n[기억]\\n- 아래 [이전 대화 기록]은 사용자와 당신이 과거에 나눈 대화입니다\\n- 이 내용을 자연스럽게 참고하여 답변하세요\\n- \"기억나지 않는다\"고 하지 마세요';\n\nif (ragContext) {\n systemPrompt += '\\n\\n[이전 대화 기록 / 참고 자료]\\n' + ragContext;\n}\n\nconst response = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://api.anthropic.com/v1/messages',\n headers: {\n 'x-api-key': $env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json'\n },\n body: {\n model: modelToUse,\n max_tokens: 4096,\n system: systemPrompt,\n messages: [{ role: 'user', content: userText }]\n },\n timeout: 60000\n});\n\n// 비용 간이 추적 (static data)\ntry {\n const staticData = this.getWorkflowStaticData('global');\n const now = new Date();\n const costKey = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n const inTok = response.usage?.input_tokens || 0;\n const outTok = response.usage?.output_tokens || 0;\n // Opus 가격: $15/1M input, $75/1M output (대략)\n const cost = useOpus\n ? (inTok * 15 + outTok * 75) / 1000000\n : (inTok * 0.8 + outTok * 4) / 1000000;\n staticData[costKey] = (staticData[costKey] || 0) + cost;\n} catch(e) {}\n\nreturn [{\n json: {\n text: response.content?.[0]?.text || '응답을 처리할 수 없습니다.',\n model: response.model || modelToUse,\n inputTokens: response.usage?.input_tokens || 0,\n outputTokens: response.usage?.output_tokens || 0,\n response_tier: tier,\n intent: data.intent,\n userText: data.userText,\n username: data.username,\n downgraded: !useOpus\n }\n}];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000030",
|
|
"name": "Call Opus",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [2420, 1300]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
|
"options": { "timeout": 10000 }
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000031",
|
|
"name": "Send to Synology Chat",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [2640, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000032",
|
|
"name": "Respond to Webhook",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [2640, 1100]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO chat_logs (feature, username, user_message, assistant_message, model_used, response_tier, input_tokens, output_tokens) VALUES ('chat', ${{ JSON.stringify($json.username) }}, ${{ JSON.stringify($json.userText) }}, ${{ JSON.stringify(($json.text || '').substring(0, 4000)) }}, ${{ JSON.stringify($json.model) }}, ${{ JSON.stringify($json.response_tier) }}, {{ $json.inputTokens || 0 }}, {{ $json.outputTokens || 0 }})",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000033",
|
|
"name": "Log to DB",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [2640, 1300],
|
|
"credentials": {
|
|
"postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" }
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO api_usage_monthly (year, month, tier, call_count, total_input_tokens, total_output_tokens, estimated_cost) VALUES (EXTRACT(YEAR FROM NOW())::int, EXTRACT(MONTH FROM NOW())::int, ${{ JSON.stringify($json.response_tier) }}, 1, {{ $json.inputTokens || 0 }}, {{ $json.outputTokens || 0 }}, {{ $json.response_tier === 'api_heavy' ? (($json.inputTokens || 0) * 15 + ($json.outputTokens || 0) * 75) / 1000000 : (($json.inputTokens || 0) * 0.8 + ($json.outputTokens || 0) * 4) / 1000000 }}) ON CONFLICT (year, month, tier) DO UPDATE SET call_count = api_usage_monthly.call_count + 1, total_input_tokens = api_usage_monthly.total_input_tokens + EXCLUDED.total_input_tokens, total_output_tokens = api_usage_monthly.total_output_tokens + EXCLUDED.total_output_tokens, estimated_cost = api_usage_monthly.estimated_cost + EXCLUDED.estimated_cost, updated_at = NOW()",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000034",
|
|
"name": "API Usage Log",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [2860, 1300],
|
|
"credentials": {
|
|
"postgres": { "id": "KaxU8iKtraFfsrTF", "name": "bot-postgres" }
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// 선택적 메모리: Qwen이 저장 가치 판단\nconst ai = $json;\nconst userText = ai.userText || '';\nconst aiText = (ai.text || '').substring(0, 500);\n\n// local tier (인사 등)은 메모리 체크 스킵\nif (ai.response_tier === 'local') {\n return [{ json: { save: false, topic: 'general', userText, aiText, username: ai.username, intent: ai.intent } }];\n}\n\ntry {\n const response = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.GPU_OLLAMA_URL}/api/generate`,\n body: {\n model: 'qwen3.5:9b-q8_0',\n prompt: `아래 대화를 보고 장기 기억으로 저장할 가치가 있는지 판단하세요.\\nJSON으로만 응답하세요.\\n\\n저장: 사실 정보, 결정사항, 선호도, 지시사항, 기술 정보\\n무시: 인사, 잡담, 날씨, 봇이 모른다고 답한 것\\n\\n{\"save\": true/false, \"topic\": \"general|company|technical|personal\"}\\n\\nQ: ${userText}\\nA: ${aiText}`,\n stream: false,\n format: 'json'\n },\n timeout: 10000\n });\n\n let result;\n try {\n result = JSON.parse(response.response);\n } catch(e) {\n result = { save: false, topic: 'general' };\n }\n\n return [{\n json: {\n save: result.save || false,\n topic: result.topic || 'general',\n userText,\n aiText,\n username: ai.username,\n intent: ai.intent\n }\n }];\n} catch(e) {\n // 메모리 체크 실패 → 저장 안 함\n return [{ json: { save: false, topic: 'general', userText, aiText, username: ai.username, intent: ai.intent } }];\n}"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000035",
|
|
"name": "Memorization Check",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [2860, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
|
"conditions": [
|
|
{
|
|
"id": "save-check",
|
|
"leftValue": "={{ $json.save }}",
|
|
"rightValue": true,
|
|
"operator": { "type": "boolean", "operation": "true" }
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000036",
|
|
"name": "Should Memorize?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [3080, 900]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// 가치 있는 대화 → 임베딩 + chat_memory 저장\nconst data = $input.first().json;\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\n\n// 임베딩\nconst embResp = await this.helpers.httpRequest({\n method: 'POST',\n url: `${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n body: { model: 'bge-m3', prompt },\n headers: { 'Content-Type': 'application/json' },\n timeout: 15000\n});\n\nif (!embResp.embedding || !Array.isArray(embResp.embedding)) {\n return [{ json: { saved: false, error: 'no embedding' } }];\n}\n\nconst pointId = Date.now();\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n\n// Qdrant 저장\nconst saveResp = await this.helpers.httpRequest({\n method: 'PUT',\n url: `${qdrantUrl}/collections/chat_memory/points`,\n body: {\n points: [{\n id: pointId,\n vector: embResp.embedding,\n payload: {\n text: prompt,\n feature: 'chat',\n intent: data.intent || 'unknown',\n username: data.username || 'unknown',\n topic: data.topic || 'general',\n timestamp: pointId\n }\n }]\n },\n headers: { 'Content-Type': 'application/json' },\n timeout: 10000\n});\n\nreturn [{ json: { saved: true, pointId, topic: data.topic } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000037",
|
|
"name": "Embed & Save Memory",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [3300, 800]
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [[{ "node": "Parse Input", "type": "main", "index": 0 }]]
|
|
},
|
|
"Parse Input": {
|
|
"main": [[{ "node": "Is Rejected?", "type": "main", "index": 0 }]]
|
|
},
|
|
"Is Rejected?": {
|
|
"main": [
|
|
[{ "node": "Reject Response", "type": "main", "index": 0 }],
|
|
[{ "node": "Regex Pre-filter", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Reject Response": {
|
|
"main": [[
|
|
{ "node": "Send Reject", "type": "main", "index": 0 },
|
|
{ "node": "Respond Reject", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Regex Pre-filter": {
|
|
"main": [
|
|
[{ "node": "Pre-filter Response", "type": "main", "index": 0 }],
|
|
[{ "node": "Is Command?", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Pre-filter Response": {
|
|
"main": [[
|
|
{ "node": "Send Pre-filter", "type": "main", "index": 0 },
|
|
{ "node": "Respond Pre-filter", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Is Command?": {
|
|
"main": [
|
|
[{ "node": "Parse Command", "type": "main", "index": 0 }],
|
|
[{ "node": "Qwen Classify v2", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Parse Command": {
|
|
"main": [[{ "node": "Needs DB?", "type": "main", "index": 0 }]]
|
|
},
|
|
"Needs DB?": {
|
|
"main": [
|
|
[{ "node": "Command DB Query", "type": "main", "index": 0 }],
|
|
[{ "node": "Direct Command Response", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Command DB Query": {
|
|
"main": [[{ "node": "Format Command Response", "type": "main", "index": 0 }]]
|
|
},
|
|
"Format Command Response": {
|
|
"main": [[
|
|
{ "node": "Send Command Response", "type": "main", "index": 0 },
|
|
{ "node": "Respond Command Webhook", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Direct Command Response": {
|
|
"main": [[
|
|
{ "node": "Send Command Response", "type": "main", "index": 0 },
|
|
{ "node": "Respond Command Webhook", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Qwen Classify v2": {
|
|
"main": [[
|
|
{ "node": "Needs RAG?", "type": "main", "index": 0 },
|
|
{ "node": "Log Classification", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Needs RAG?": {
|
|
"main": [
|
|
[{ "node": "Get Embedding", "type": "main", "index": 0 }],
|
|
[{ "node": "No RAG Context", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Get Embedding": {
|
|
"main": [[{ "node": "Multi-Collection Search", "type": "main", "index": 0 }]]
|
|
},
|
|
"Multi-Collection Search": {
|
|
"main": [[{ "node": "Build RAG Context", "type": "main", "index": 0 }]]
|
|
},
|
|
"Build RAG Context": {
|
|
"main": [[{ "node": "Route by Tier", "type": "main", "index": 0 }]]
|
|
},
|
|
"No RAG Context": {
|
|
"main": [[{ "node": "Route by Tier", "type": "main", "index": 0 }]]
|
|
},
|
|
"Route by Tier": {
|
|
"main": [
|
|
[{ "node": "Call Qwen Response", "type": "main", "index": 0 }],
|
|
[{ "node": "Call Opus", "type": "main", "index": 0 }],
|
|
[{ "node": "Call Haiku", "type": "main", "index": 0 }],
|
|
[{ "node": "Call Haiku", "type": "main", "index": 0 }]
|
|
]
|
|
},
|
|
"Call Qwen Response": {
|
|
"main": [[
|
|
{ "node": "Send to Synology Chat", "type": "main", "index": 0 },
|
|
{ "node": "Respond to Webhook", "type": "main", "index": 0 },
|
|
{ "node": "Log to DB", "type": "main", "index": 0 },
|
|
{ "node": "Memorization Check", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Call Haiku": {
|
|
"main": [[
|
|
{ "node": "Send to Synology Chat", "type": "main", "index": 0 },
|
|
{ "node": "Respond to Webhook", "type": "main", "index": 0 },
|
|
{ "node": "Log to DB", "type": "main", "index": 0 },
|
|
{ "node": "API Usage Log", "type": "main", "index": 0 },
|
|
{ "node": "Memorization Check", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Call Opus": {
|
|
"main": [[
|
|
{ "node": "Send to Synology Chat", "type": "main", "index": 0 },
|
|
{ "node": "Respond to Webhook", "type": "main", "index": 0 },
|
|
{ "node": "Log to DB", "type": "main", "index": 0 },
|
|
{ "node": "API Usage Log", "type": "main", "index": 0 },
|
|
{ "node": "Memorization Check", "type": "main", "index": 0 }
|
|
]]
|
|
},
|
|
"Log to DB": {
|
|
"main": []
|
|
},
|
|
"API Usage Log": {
|
|
"main": []
|
|
},
|
|
"Memorization Check": {
|
|
"main": [[{ "node": "Should Memorize?", "type": "main", "index": 0 }]]
|
|
},
|
|
"Should Memorize?": {
|
|
"main": [
|
|
[{ "node": "Embed & Save Memory", "type": "main", "index": 0 }],
|
|
[]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"callerPolicy": "workflowsFromSameOwner",
|
|
"availableInMCP": false
|
|
}
|
|
}
|