- CLAUDE.md: Task Runner VM 샌드박스 제약사항 문서화
- docker-compose.yml: NODE_FUNCTION_ALLOW_BUILTIN 환경변수 추가
- main-chat-pipeline.json: require('http/https/url') 방식으로 전환된 워크플로우 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1445 lines
70 KiB
JSON
1445 lines
70 KiB
JSON
{
|
||
"id": "exSV3aHhXcksqm6j",
|
||
"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": "const 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\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\nconst staticData = $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\nconst isCommand = text.startsWith('/');\nconst pendingKey = `pendingDoc_${username}`;\nlet pendingDoc = staticData[pendingKey] || null;\nif (pendingDoc && (now - pendingDoc.timestamp > 300000)) {\n delete staticData[pendingKey];\n pendingDoc = null;\n}\nconst hasPendingDoc = !!pendingDoc && !isCommand;\n\nreturn [{ json: { rejected: false, hasPendingDoc, pendingDoc, text, username, userId, token, timestamp, fileUrl, isCommand } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000002",
|
||
"name": "Parse Input",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"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": "return [{ json: { text: $('Parse Input').first().json.rejectReason || '요청을 처리할 수 없습니다.' } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000004",
|
||
"name": "Reject Response",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
660,
|
||
300
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict"
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "pending-check",
|
||
"leftValue": "={{ $json.hasPendingDoc }}",
|
||
"rightValue": true,
|
||
"operator": {
|
||
"type": "boolean",
|
||
"operation": "true"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000040",
|
||
"name": "Has Pending Doc?",
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
660,
|
||
600
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst text = input.text;\nconst username = input.username;\nconst pendingDoc = input.pendingDoc;\nconst staticData = $getWorkflowStaticData('global');\ndelete staticData[`pendingDoc_${username}`];\n\nif (!text || text.length < 10) {\n return [{ json: { text: '문서 텍스트가 너무 짧습니다. 다시 시도해주세요.', saved: false } }];\n}\n\nconst chunks = [];\nconst chunkSize = 2000, overlap = 200;\nlet start = 0;\nwhile (start < text.length) {\n chunks.push(text.substring(start, Math.min(start + chunkSize, text.length)));\n if (start + chunkSize >= text.length) break;\n start += chunkSize - overlap;\n}\n\nconst encoder = new TextEncoder();\nconst hashBuf = await require('crypto').subtle.digest('SHA-256', encoder.encode(text));\nconst hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst year = new Date().getFullYear();\nconst baseId = Date.now();\n\nfor (let i = 0; i < chunks.length; i++) {\n try {\n const embResp = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: chunks[i] });\n if (!embResp.embedding) continue;\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: baseId + i, vector: embResp.embedding, payload: {\n text: chunks[i], department: pendingDoc.department, doc_type: pendingDoc.docType,\n title: pendingDoc.title, source_file: `chat_upload_${username}`,\n file_hash: hash, chunk_index: i, total_chunks: chunks.length,\n uploaded_by: username, year, created_at: new Date().toISOString()\n }}]});\n } catch(e) {}\n}\n\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst insertSQL = `INSERT INTO document_ingestion_log (collection,source_file,file_hash,chunks_count,department,doc_type,year,uploaded_by,doc_group_key,status) VALUES ('tk_company','chat_upload_${safe(username)}','${hash}',${chunks.length},'${safe(pendingDoc.department)}','${safe(pendingDoc.docType)}',${year},'${safe(username)}','${safe(pendingDoc.department)}/${safe(pendingDoc.docType)}/${safe(pendingDoc.title)}','completed') ON CONFLICT (file_hash,collection) DO NOTHING`;\n\nreturn [{ json: { text: `문서 등록 완료\\n부서: ${pendingDoc.department} / 유형: ${pendingDoc.docType}\\n제목: ${pendingDoc.title}\\n청크: ${chunks.length}개`, insertSQL } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000041",
|
||
"name": "Process Document",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
880,
|
||
400
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "={{ $json.insertSQL }}",
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000042",
|
||
"name": "Log Doc Ingestion",
|
||
"type": "n8n-nodes-base.postgres",
|
||
"typeVersion": 2.5,
|
||
"position": [
|
||
1100,
|
||
400
|
||
],
|
||
"credentials": {
|
||
"postgres": {
|
||
"id": "KaxU8iKtraFfsrTF",
|
||
"name": "bot-postgres"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"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": [
|
||
880,
|
||
700
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const text = $('Parse Input').first().json.text.toLowerCase();\nconst responses = { '안녕': '안녕하세요 😊 무엇을 도와드릴까요?', '안녕하세요': '안녕하세요! 편하게 말씀해주세요 😊', 'hi': '안녕하세요! 무엇을 도와드릴까요?', 'hello': '안녕하세요! 편하게 말씀해주세요.', '고마워': '도움이 되셨다니 다행이에요 😊', '고맙': '별말씀을요, 언제든 말씀해주세요.', '감사합니다': '도움이 되셨다니 다행이에요 😊', '감사해': '별말씀을요 😊', '잘 자': '편안한 밤 되세요 🌙', '수고': '수고 많으셨어요. 편히 쉬세요!' };\nlet reply = '안녕하세요! 무엇을 도와드릴까요? 😊';\nfor (const [key, val] of Object.entries(responses)) { if (text.includes(key)) { reply = val; break; } }\nreturn [{ json: { text: reply } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000008",
|
||
"name": "Pre-filter Response",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 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": [
|
||
1100,
|
||
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\nconst admins = ($env.ADMIN_USERNAMES || '').split(',').map(s => s.trim()).filter(Boolean);\nif (['/문서등록','/보고서','/설정'].includes(cmd) && admins.length > 0 && !admins.includes(username)) {\n return [{ json: { commandType: 'direct', responseText: `권한이 없습니다. (${cmd})`, needsDb: false, sqlQuery: '' } }];\n}\n\nconst safe = s => (s||'').replace(/'/g, \"''\");\nlet responseText = '', sqlQuery = '', commandType = 'direct';\nlet reportDomain = '', reportYear = 0, reportMonth = 0;\n\nswitch(cmd) {\n case '/설정':\n commandType = 'db';\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 else { commandType = 'db'; sqlQuery = `UPDATE ai_configs SET model = '${safe(arg)}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, model`; }\n break;\n case '/성격':\n if (!arg) { responseText = '사용법: /성격 <시스템 프롬프트 설명>'; }\n else { commandType = 'db'; sqlQuery = `UPDATE ai_configs SET system_prompt = '${safe(arg)}', updated_at = NOW() WHERE feature = 'chat' RETURNING feature, LEFT(system_prompt, 50) as prompt_preview`; }\n break;\n case '/문서등록': {\n const ap = arg.split(/\\s+/);\n if (ap.length < 3 || !ap[0]) {\n responseText = '사용법: /문서등록 [부서] [문서유형] [제목]\\n예: /문서등록 안전 절차서 고소작업안전절차';\n } else {\n const staticData = $getWorkflowStaticData('global');\n staticData[`pendingDoc_${username}`] = { department: ap[0], docType: ap[1], title: ap.slice(2).join(' '), timestamp: Date.now() };\n responseText = `문서 등록 준비됨\\n부서: ${ap[0]} / 유형: ${ap[1]} / 제목: ${ap.slice(2).join(' ')}\\n\\n다음 메시지로 문서 텍스트를 보내주세요.`;\n }\n break;\n }\n case '/보고서': {\n const ap = arg.split(/\\s+/);\n if (ap.length < 2 || !ap[0]) {\n responseText = '사용법: /보고서 [영역] [년월]\\n예: /보고서 안전 2026-03\\n영역: 안전, 시설설비, 품질';\n } else {\n commandType = 'report';\n reportDomain = ap[0];\n const ym = ap[1].split('-');\n reportYear = parseInt(ym[0]) || new Date().getFullYear();\n reportMonth = parseInt(ym[1]) || (new Date().getMonth() + 1);\n }\n break;\n }\n default:\n responseText = `알 수 없는 명령어입니다: ${cmd}\\n사용 가능: /설정, /모델, /성격, /문서등록, /보고서`;\n}\n\nreturn [{ json: { cmd, arg, commandType, responseText, sqlQuery, needsDb: sqlQuery !== '', reportDomain, reportYear, reportMonth, username } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000012",
|
||
"name": "Parse Command",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1320,
|
||
800
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"rules": {
|
||
"values": [
|
||
{
|
||
"conditions": {
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.commandType }}",
|
||
"rightValue": "db",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and",
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict"
|
||
}
|
||
},
|
||
"renameOutput": "DB"
|
||
},
|
||
{
|
||
"conditions": {
|
||
"conditions": [
|
||
{
|
||
"leftValue": "={{ $json.commandType }}",
|
||
"rightValue": "report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and",
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict"
|
||
}
|
||
},
|
||
"renameOutput": "Report"
|
||
}
|
||
]
|
||
},
|
||
"options": {
|
||
"fallbackOutput": "extra"
|
||
}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000043",
|
||
"name": "Command Router",
|
||
"type": "n8n-nodes-base.switch",
|
||
"typeVersion": 3.2,
|
||
"position": [
|
||
1540,
|
||
800
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
1760,
|
||
600
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
1980,
|
||
500
|
||
],
|
||
"credentials": {
|
||
"postgres": {
|
||
"id": "KaxU8iKtraFfsrTF",
|
||
"name": "bot-postgres"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const items = $input.all();\nconst cmd = $('Parse Command').first().json.cmd;\nswitch(cmd) {\n case '/설정': { const lines = items.map(r => `• ${r.json.feature}: ${r.json.model} (temp=${r.json.temperature}, max=${r.json.max_tokens})`); return [{ json: { text: '현재 설정:\\n' + lines.join('\\n') } }]; }\n case '/모델': { return [{ json: { text: `모델이 ${items[0]?.json?.model || 'unknown'}(으)로 변경되었습니다.` } }]; }\n case '/성격': { return [{ json: { text: `성격이 변경되었습니다: \"${items[0]?.json?.prompt_preview || ''}...\"` } }]; }\n default: return [{ json: { text: '처리되었습니다.' } }];\n}"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000015",
|
||
"name": "Format Command Response",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2200,
|
||
500
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "return [{ json: { text: $('Parse Command').first().json.responseText || '처리되었습니다.' } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000016",
|
||
"name": "Direct Command Response",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1760,
|
||
900
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "=SELECT domain, category, severity, department, COUNT(*) as total, SUM(CASE WHEN status='open' THEN 1 ELSE 0 END) as open_count, SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved_count FROM field_reports WHERE domain = '{{ $('Parse Command').first().json.reportDomain }}' AND year = {{ $('Parse Command').first().json.reportYear }} AND month = {{ $('Parse Command').first().json.reportMonth }} GROUP BY domain, category, severity, department ORDER BY severity, category",
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000044",
|
||
"name": "Report Data Query",
|
||
"type": "n8n-nodes-base.postgres",
|
||
"typeVersion": 2.5,
|
||
"position": [
|
||
1760,
|
||
1000
|
||
],
|
||
"credentials": {
|
||
"postgres": {
|
||
"id": "KaxU8iKtraFfsrTF",
|
||
"name": "bot-postgres"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.all();\nconst cmd = $('Parse Command').first().json;\nconst domain = cmd.reportDomain;\nconst ym = `${cmd.reportYear}-${String(cmd.reportMonth).padStart(2,'0')}`;\n\nif (!data || data.length === 0 || !data[0].json.domain) {\n return [{ json: { text: `[${domain} ${ym}] 해당 기간 데이터가 없습니다.` } }];\n}\n\nconst summary = data.map(r => {\n const j = r.json;\n return `- ${j.category}(${j.severity}): ${j.department} — 총${j.total}건 (미처리:${j.open_count}, 완료:${j.resolved_count})`;\n}).join('\\n');\n\ntry {\n const resp = await httpPost('https://api.anthropic.com/v1/messages',\n { model: 'claude-haiku-4-5-20251001', max_tokens: 2048,\n system: '현장 리포트 데이터를 기반으로 월간 보고서를 작성하세요. 한국어, 간결하게.',\n messages: [{ role: 'user', content: `${domain} 영역 ${ym} 월간 보고서:\\n\\n${summary}` }] },\n { timeout: 30000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } }\n );\n return [{ json: { text: `[${domain} ${ym} 월간 보고서]\\n\\n${resp.content?.[0]?.text || '생성 실패'}` } }];\n} catch(e) {\n return [{ json: { text: `보고서 생성 중 오류: ${e.message}` } }];\n}"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000045",
|
||
"name": "Generate Report",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1980,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\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: local(인사,잡담,감사), api_light(요약,번역,일반질문), api_heavy(법률,복잡추론)\nrag_target: documents(개인문서), tk_company(회사문서), chat_memory(이전대화)\nintent=report: 현장신고, 사진+\"~발생/고장/파손\"\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt: classifierPrompt, stream: false, format: 'json' },\n { timeout: 10000 }\n );\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, fileUrl, latency, fallback: false\n } }];\n} catch(e) {\n return [{ json: {\n intent: 'question', response_tier: 'api_light', needs_rag: false, rag_target: [],\n department_hint: null, report_domain: null, query: userText,\n userText, username, fileUrl, latency: Date.now() - startTime, fallback: true\n } }];\n}"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000020",
|
||
"name": "Qwen Classify v2",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1320,
|
||
1200
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "=INSERT INTO classification_logs (input_text, output_json, model, latency_ms, fallback_used) VALUES (LEFT('{{ $json.userText.replace(/'/g, \"''\") }}', 200), '{{ JSON.stringify({intent:$json.intent, response_tier:$json.response_tier, needs_rag:$json.needs_rag}) }}'::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": [
|
||
1540,
|
||
1400
|
||
],
|
||
"credentials": {
|
||
"postgres": {
|
||
"id": "KaxU8iKtraFfsrTF",
|
||
"name": "bot-postgres"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"conditions": {
|
||
"options": {
|
||
"caseSensitive": true,
|
||
"leftValue": "",
|
||
"typeValidation": "strict"
|
||
},
|
||
"conditions": [
|
||
{
|
||
"id": "report-check",
|
||
"leftValue": "={{ $json.intent }}",
|
||
"rightValue": "report",
|
||
"operator": {
|
||
"type": "string",
|
||
"operation": "equals"
|
||
}
|
||
}
|
||
],
|
||
"combinator": "and"
|
||
},
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000046",
|
||
"name": "Is Report Intent?",
|
||
"type": "n8n-nodes-base.if",
|
||
"typeVersion": 2.2,
|
||
"position": [
|
||
1540,
|
||
1200
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText, username = cls.username, fileUrl = cls.fileUrl;\nconst reportDomain = cls.report_domain || '안전';\n\nlet photoAnalysis = null;\nif (fileUrl) {\n try {\n const vr = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/generate`,\n { model: 'minicpm-v:8b', prompt: '이 사진에서 안전/시설/품질 문제점을 설명. 한국어 간결하게.', images: [fileUrl], stream: false },\n { timeout: 30000 }\n );\n photoAnalysis = vr.response || null;\n } catch(e) {}\n}\n\nlet structured;\ntry {\n const sp = `현장 신고를 구조화. JSON만 응답.\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\n\n신고: ${userText}${photoAnalysis ? '\\n사진분석: '+photoAnalysis : ''}`;\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt: sp, stream: false, format: 'json' },\n { timeout: 15000 }\n );\n structured = JSON.parse(r.response);\n} catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '' };\n}\n\nconst sla = { '안전':{'상':24,'중':72,'하':168}, '시설설비':{'상':48,'중':120,'하':336}, '품질':{'상':48,'중':120,'하':336} };\nconst hours = sla[structured.domain]?.[structured.severity] || 120;\nconst dueAt = new Date(Date.now() + hours*3600000).toISOString();\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n { model: 'bge-m3', prompt: structured.summary+' '+(structured.keywords||[]).join(' ') });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: Date.now(), vector: emb.embedding, payload: {\n text: `[현장리포트] ${structured.summary}`, department: structured.department,\n doc_type: 'field_report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n⚠️ 긴급 — 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nconst insertSQL = `INSERT INTO field_reports (domain,category,severity,location,department,keywords,summary,action_required,user_description,photo_url,photo_analysis,reporter,year,month,due_at) VALUES ('${safe(structured.domain)}','${safe(structured.category)}','${safe(structured.severity)}','${safe(structured.location)}','${safe(structured.department||'미지정')}',ARRAY[${kw}],'${safe(structured.summary)}','${safe(structured.action_required)}','${safe(userText).substring(0,1000)}',${fileUrl?\"'\"+safe(fileUrl)+\"'\":'NULL'},${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nreturn [{ json: { text: `접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}`, insertSQL } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000047",
|
||
"name": "Handle Field Report",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1760,
|
||
1300
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"operation": "executeQuery",
|
||
"query": "={{ $json.insertSQL }}",
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000048",
|
||
"name": "Save Field Report DB",
|
||
"type": "n8n-nodes-base.postgres",
|
||
"typeVersion": 2.5,
|
||
"position": [
|
||
1980,
|
||
1300
|
||
],
|
||
"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": [
|
||
1760,
|
||
1100
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst query = $('Qwen Classify v2').first().json.query;\nconst response = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: query });\nreturn [{ json: { embedding: response.embedding } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000023",
|
||
"name": "Get Embedding",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1980,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst embedding = $input.first().json.embedding;\nconst ragTargets = cls.rag_target && cls.rag_target.length > 0 ? cls.rag_target : ['documents'];\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst limitMap = {1:10,2:7,3:5};\nconst limit = limitMap[Math.min(ragTargets.length,3)] || 5;\nlet allResults = [];\nfor (const col of ragTargets) {\n let filter;\n if (col === 'tk_company' && cls.department_hint) filter = { must: [{ key: 'department', match: { value: cls.department_hint } }] };\n else if (col === 'chat_memory') filter = { must: [{ key: 'username', match: { value: cls.username } }] };\n try {\n const body = { vector: embedding, limit, with_payload: true };\n if (filter) body.filter = filter;\n const resp = await httpPost(`${qdrantUrl}/collections/${col}/points/search`, body, { timeout: 10000 });\n allResults = allResults.concat((resp.result||[]).map(r => ({ score: r.score, text: r.payload?.text||'', collection: col, payload: r.payload })));\n } catch(e) {}\n}\ntry {\n const cands = allResults.slice(0,10), reranked = [];\n for (const doc of cands) {\n const r = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/generate`,\n { model:'bge-reranker-v2-m3', prompt:`query: ${cls.query}\\ndocument: ${doc.text.substring(0,500)}`, stream:false },\n { timeout: 3000 }\n );\n reranked.push({...doc, rerank_score: parseFloat(r.response)||doc.score});\n }\n return [{json:{results:reranked.sort((a,b)=>b.rerank_score-a.rerank_score).slice(0,3)}}];\n} catch(e) {\n return [{json:{results:allResults.sort((a,b)=>b.score-a.score).slice(0,3)}}];\n}"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000024",
|
||
"name": "Multi-Collection Search",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2200,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const results = $input.first().json.results || [];\nconst cls = $('Qwen Classify v2').first().json;\nconst relevant = results.filter(r => (r.rerank_score||r.score) >= 0.3);\nlet ragContext = '';\nif (relevant.length > 0) {\n const labels = {'documents':'개인문서','tk_company':'회사','chat_memory':'이전대화'};\n ragContext = relevant.map(r => {\n const src = labels[r.collection]||r.collection;\n const dept = r.payload?.department ? '/'+r.payload.department : '';\n const dt = r.payload?.doc_type ? '/'+r.payload.doc_type : '';\n return `[${src}${dept}${dt}] ${r.text.substring(0,500)}`;\n }).join('\\n\\n');\n}\nreturn [{ json: { ...cls, ragContext } }];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000025",
|
||
"name": "Build RAG Context",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2420,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "const 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": 1,
|
||
"position": [
|
||
1980,
|
||
1200
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
2640,
|
||
1100
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet prompt = '당신은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.\\n간결하게 답하고, 모르면 솔직히 말하세요.\\n\\n';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model:'qwen3.5:9b-q8_0', prompt, stream:false },\n { timeout: 30000 }\n );\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:9b-q8_0',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'qwen3.5:9b-q8_0',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n}"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000028",
|
||
"name": "Call Qwen Response",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2860,
|
||
900
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet sp = '당신의 이름은 \"이드\"입니다.\\n\\n[성격] 배려심 깊고, 부드럽게 서포트. 독선적이지 않음.\\n[말투] 부드러운 존댓말, 겸양어, 자기이름 안 말함, 이모지 가끔.\\n[원칙] 간결 핵심, 모르면 솔직, 일정은 정확히.\\n[기억] 아래 기록이 당신의 기억. \"기억 안 난다\" 하지 마세요.';\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model:'claude-haiku-4-5-20251001',max_tokens:2048,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:30000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||'claude-haiku-4-5-20251001',inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:'api_light',intent:data.intent,userText:data.userText,username:data.username}}];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000029",
|
||
"name": "Call Haiku",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2860,
|
||
1200
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet useOpus = true;\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n if ((sd[ck]||0) >= (parseFloat($env.API_BUDGET_HEAVY)||50)) useOpus = false;\n} catch(e) {}\nconst model = useOpus ? 'claude-opus-4-6' : 'claude-haiku-4-5-20251001';\nconst tier = useOpus ? 'api_heavy' : 'api_light';\nlet sp = '당신의 이름은 \"이드\"입니다.\\n\\n[성격] 배려심 깊고, 부드럽게 서포트. 독선적이지 않음.\\n[말투] 부드러운 존댓말, 겸양어, 자기이름 안 말함, 이모지 가끔.\\n[원칙] 간결 핵심, 모르면 솔직, 일정은 정확히.\\n[기억] 아래 기록이 당신의 기억. \"기억 안 난다\" 하지 마세요.';\nif (ragContext) sp += '\\n\\n[참고 자료]\\n' + ragContext;\nconst r = await httpPost('https://api.anthropic.com/v1/messages',\n {model,max_tokens:4096,system:sp,messages:[{role:'user',content:userText}]},\n { timeout:60000, headers:{'x-api-key':$env.ANTHROPIC_API_KEY,'anthropic-version':'2023-06-01'} }\n);\ntry {\n const sd = $getWorkflowStaticData('global');\n const now = new Date();\n const ck = `opus_cost_${now.getFullYear()}_${now.getMonth()+1}`;\n const it=r.usage?.input_tokens||0, ot=r.usage?.output_tokens||0;\n sd[ck] = (sd[ck]||0) + (useOpus ? (it*15+ot*75)/1e6 : (it*0.8+ot*4)/1e6);\n} catch(e) {}\nreturn [{json:{text:r.content?.[0]?.text||'응답 처리 불가',model:r.model||model,inputTokens:r.usage?.input_tokens||0,outputTokens:r.usage?.output_tokens||0,response_tier:tier,intent:data.intent,userText:data.userText,username:data.username,downgraded:!useOpus}}];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000030",
|
||
"name": "Call Opus",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
2860,
|
||
1400
|
||
]
|
||
},
|
||
{
|
||
"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-000000000050",
|
||
"name": "Send Simple Response",
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.2,
|
||
"position": [
|
||
2640,
|
||
600
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"respondWith": "json",
|
||
"responseBody": "={ \"text\": {{ JSON.stringify($json.text) }} }",
|
||
"options": {}
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000051",
|
||
"name": "Respond Simple Webhook",
|
||
"type": "n8n-nodes-base.respondToWebhook",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
2640,
|
||
400
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
3080,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
3080,
|
||
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.username||'').replace(/'/g,\"''\") }}','{{ ($json.userText||'').replace(/'/g,\"''\").substring(0,2000) }}','{{ ($json.text||'').replace(/'/g,\"''\").substring(0,4000) }}','{{ ($json.model||'').replace(/'/g,\"''\") }}','{{ $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": [
|
||
3080,
|
||
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.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": [
|
||
3300,
|
||
1300
|
||
],
|
||
"credentials": {
|
||
"postgres": {
|
||
"id": "KaxU8iKtraFfsrTF",
|
||
"name": "bot-postgres"
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst ai = $json;\nconst userText = ai.userText || '', aiText = (ai.text||'').substring(0,500);\nif (ai.response_tier === 'local') return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}];\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model:'qwen3.5:9b-q8_0', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실,결정,선호,지시,기술정보\n무시: 인사,잡담,날씨,모른다고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json' },\n { timeout: 10000 }\n );\n let res; try{res=JSON.parse(r.response)}catch(e){res={save:false,topic:'general'}}\n return [{json:{save:res.save||false,topic:res.topic||'general',userText,aiText,username:ai.username,intent:ai.intent}}];\n} catch(e) { return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}]; }"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000035",
|
||
"name": "Memorization Check",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
3300,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"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": [
|
||
3520,
|
||
1000
|
||
]
|
||
},
|
||
{
|
||
"parameters": {
|
||
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\nconst pid = Date.now();\nconst qu = $env.QDRANT_URL||'http://host.docker.internal:6333';\nawait httpPut(`${qu}/collections/chat_memory/points`, { points:[{ id:pid, vector:emb.embedding, payload:{\n text:prompt, feature:'chat', intent:data.intent||'unknown',\n username:data.username||'unknown', topic:data.topic||'general', timestamp:pid\n}}]});\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];"
|
||
},
|
||
"id": "b1000001-0000-0000-0000-000000000037",
|
||
"name": "Embed & Save Memory",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
3740,
|
||
900
|
||
]
|
||
}
|
||
],
|
||
"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": "Has Pending Doc?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Reject Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Has Pending Doc?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Process Document",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Regex Pre-filter",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Process Document": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Log Doc Ingestion",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Log Doc Ingestion": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"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 Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"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": "Command Router",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Command Router": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Needs DB?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Report Data Query",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Direct Command Response",
|
||
"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 Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Direct Command Response": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Report Data Query": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Generate Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Generate Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Qwen Classify v2": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Is Report Intent?",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Log Classification",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Is Report Intent?": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Handle Field Report",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
],
|
||
[
|
||
{
|
||
"node": "Needs RAG?",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Handle Field Report": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Save Field Report DB",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Save Field Report DB": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Send Simple Response",
|
||
"type": "main",
|
||
"index": 0
|
||
},
|
||
{
|
||
"node": "Respond Simple Webhook",
|
||
"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
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"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
|
||
}
|
||
}
|