Files
syn-chat-bot/n8n/workflows/main-chat-pipeline.json
Hyungi Ahn 1137754964 feat: mlx-proxy 서버 + n8n 워크플로우 LLM/임베딩 URL 분리
mlx-vlm 기반 ollama 호환 프록시 서버 추가 (port 11435).
n8n GEN 노드 6개에 callLLM 래퍼 주입 (health check + ollama fallback).
임베딩/리랭커는 ollama(LOCAL_EMBED_URL)로 분리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:00:00 +09:00

1950 lines
126 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 channelId = body.channel_id || '';\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, channelId, 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_EMBED_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 + ' \\u2192 ' + 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 + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|reminder|mail|note|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\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n ※ \"매일\"은 \"메일\"의 오타일 수 있음 — \"매일 확인\",\"매일 왔어\" 등 문맥으로 판단\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier 판단:\n- local: 인사, 잡담, 감사, log_event, report, calendar, reminder, note, 단순 질문, 정의/개념 설명, 짧은 답변 가능한 질문, mail 간단조회\n- api_light: 장문 요약(200자 이상 텍스트), 다국어 번역, 비교 분석, RAG 결과 종합 정리\n- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석\n※ 판단이 애매하면 local 우선\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: classifierPrompt, stream: false, format: 'json', think: false });\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, latency, fallback: false\n } }];\n} catch(e) {\n const t = userText;\n let intent = 'question';\n let response_tier = 'api_light';\n let needs_rag = false;\n let rag_target = [];\n\n if (/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /등록|잡아|추가|만들|넣어|수정|삭제|취소/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/일정|스케줄|뭐\\s*있/.test(t) && /오늘|내일|이번|다음/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/기록해|메모해|저장해|적어둬|메모\\s*저장|노트/.test(t)) {\n intent = 'note'; response_tier = 'local';\n } else if (/메일|이메일|받은\\s*편지|mail/.test(t) || (/매일/.test(t) && /확인|왔|온|요약|읽/.test(t))) {\n intent = 'mail'; response_tier = 'local';\n } else if (/\\d+시/.test(t) && /알려|리마인드|알림/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/구입|완료|교체|점검|수령|입고|발주/.test(t) && !/\\?|까$|나$/.test(t)) {\n intent = 'log_event'; response_tier = 'local';\n } else {\n if (userText.length <= 30 && !/요약|번역|분석|비교/.test(t)) {\n response_tier = 'local';\n }\n needs_rag = /회사|절차|문서|안전|품질|규정|아까|전에|기억/.test(t);\n if (needs_rag) {\n rag_target = ['documents'];\n if (/회사|절차|안전|품질/.test(t)) rag_target.push('tk_company');\n if (/아까|이전|전에|기억/.test(t)) rag_target.push('chat_memory');\n }\n }\n\n return [{ json: {\n intent, response_tier, needs_rag, rag_target,\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime,\n fallback: true, fallback_method: 'keyword'\n } }];\n}"
},
"id": "b1000001-0000-0000-0000-000000000020",
"name": "Qwen Classify v2",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1320,
1200
]
},
{
"parameters": {
"jsCode": "const cls = $input.first().json;\nreturn [{ json: { ...cls, pid: Date.now() } }];"
},
"id": "b1000001-0000-0000-0000-000000000070",
"name": "Set pid",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1430,
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, rag_target:$json.rag_target, query:$json.query, department_hint:$json.department_hint, report_domain:$json.report_domain, fallback_method:$json.fallback_method||null}) }}'::jsonb, 'qwen3.5:27b', {{ $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": {
"rules": {
"values": [
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "log_event",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "LogEvent"
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "report",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "Report"
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "calendar",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "Calendar"
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "reminder",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "Reminder"
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "mail",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "Mail"
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.intent }}",
"rightValue": "note",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and",
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
}
},
"renameOutput": "Note"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "b1000001-0000-0000-0000-000000000046",
"name": "Route by Intent",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.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 + ' \\u2192 ' + 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 + ' \\u2192 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 + ' \\u2192 ' + 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 + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst FIELD_REPORT_SYSTEM_PROMPT = `너는 산업 현장 신고를 분석하는 전문가다. 사진과 텍스트를 받아 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"domain\": \"안전 | 시설설비 | 품질\",\n \"category\": \"string (자유텍스트 — 예: 전기안전, 화재예방, 기계설비, 배관, 제품불량 등)\",\n \"severity\": \"상 | 중 | 하\",\n \"location\": \"string (언급된 장소, 없으면 빈 문자열)\",\n \"department\": \"string (언급된 부서, 없으면 빈 문자열)\",\n \"keywords\": [\"string\"],\n \"summary\": \"string (한줄 요약, 50자 이내)\",\n \"action_required\": \"string (필요 조치, 없으면 빈 문자열)\"\n}\n\n## 필드별 판단 기준\n\n### domain\n- **안전**: 인명 사고, 안전장비 미착용, 위험물 노출, 화재 위험, 추락 위험, 감전 위험\n- **시설설비**: 기계 고장, 배관 누수, 전기 설비 이상, 건물 파손, 공조 장치 문제\n- **품질**: 제품 불량, 원자재 품질 이상, 공정 이탈, 검사 부적합\n\n### severity (심각도)\n- **상**: 즉시 조치 필요. 인명 피해 우려, 가동 중단, 법적 위반. SLA: 안전 24h / 시설설비·품질 48h\n- **중**: 계획 조치 필요. 경미한 이상, 모니터링 대상. SLA: 안전 72h / 시설설비·품질 120h\n- **하**: 참고 사항. 개선 권고, 점검 기록. SLA: 안전 168h / 시설설비·품질 336h\n\n### keywords\n- 사진에서 식별된 장비/물체 + 텍스트에서 언급된 핵심 단어\n- 산업 현장 표준 한국어 용어 사용 (지게차, 컨베이어, 분전반, 소화기, 안전모 등)\n\n### summary\n- 사진 내용 + 사용자 메시지를 종합한 한줄 요약\n- 형식: \"[대상] [상태/문제]\" (예: \"2층 분전반 과열 흔적 발견\")\n\n## 출력 예시\n\n예시 1 — 사진: 안전모 미착용 작업자 / 텍스트: \"3층 현장 점검 중\"\n{\"domain\":\"안전\",\"category\":\"보호구\",\"severity\":\"상\",\"location\":\"3층\",\"department\":\"\",\"keywords\":[\"안전모\",\"미착용\",\"보호구\"],\"summary\":\"3층 현장 작업자 안전모 미착용 확인\",\"action_required\":\"해당 작업자 보호구 착용 지도 및 현장 안전 점검\"}\n\n예시 2 — 사진: 바닥 물웅덩이 / 텍스트: \"지하 기계실 배관\"\n{\"domain\":\"시설설비\",\"category\":\"배관\",\"severity\":\"중\",\"location\":\"지하 기계실\",\"department\":\"\",\"keywords\":[\"배관\",\"누수\",\"기계실\"],\"summary\":\"지하 기계실 배관 누수로 바닥 침수\",\"action_required\":\"배관 누수 지점 확인 및 보수\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText, username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst reportDomain = cls.report_domain || '안전';\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet photoBase64 = null;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n photoBase64 = photoResult.base64;\n }\n} catch(e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet structured;\nlet inputTokens = 0, outputTokens = 0;\nif (photoBase64) {\n // Haiku Vision — 분석 + 구조화 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n system: [{ type: 'text', text: FIELD_REPORT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '현장 신고: ' + userText }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n structured = JSON.parse(clean);\n photoAnalysis = structured.summary;\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '', parse_error: true };\n photoAnalysis = structured.summary;\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 구조화\n try {\n const sp = `현장 신고를 구조화. JSON만 응답.\\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\\n\\n신고: ${userText}`;\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: sp, stream: false, format: 'json', think: false });\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}\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_EMBED_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: 'report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n\\u26a0\\ufe0f 긴급 \\u2014 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nlet 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)}',NULL,${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nif (photoBase64 && inputTokens > 0) {\n insertSQL += `; INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (${now.getFullYear()},${now.getMonth()+1},'api_light',1,${inputTokens},${outputTokens},${(inputTokens*0.8+outputTokens*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()`;\n}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nreturn [{ json: { text: `${photoPrefix}접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : ''), insertSQL } }];"
},
"id": "b1000001-0000-0000-0000-000000000047",
"name": "Handle Field Report",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1760,
1300
]
},
{
"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 + ' \\u2192 ' + 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 + ' \\u2192 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 + ' \\u2192 ' + 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 + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst LOG_EVENT_SYSTEM_PROMPT = `너는 산업 현장 업무 로그를 분석하는 전문가다. 사진과 텍스트를 받아 이벤트 정보를 추출하여 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"namespace\": \"string (조직명, 기본값: 테크니컬코리아)\",\n \"category\": \"안전 | 생산 | 구매 | 품질 | 총무 | 시설\",\n \"event_type\": \"구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\",\n \"item\": \"string (품목/대상, 자유텍스트)\",\n \"date\": \"YYYY-MM-DD\",\n \"summary\": \"string (한줄 요약, 품목+행위 포함)\"\n}\n\n## 필드별 판단 기준\n\n### namespace\n- 기본값: \"테크니컬코리아\"\n- 다른 조직이 언급되면 해당 조직명 사용\n\n### category (부서/업무 영역)\n어떤 부서 업무인지 기준으로 선택:\n- **안전**: 안전 관련 업무 (안전장비, 안전교육, 사고 등)\n- **생산**: 생산 라인 관련 (기계 가동, 생산량, 공정 등)\n- **구매**: 구매/발주 관련 (자재 구매, 발주, 견적 등)\n- **품질**: 품질 관련 (검사, 불량, 시험 등)\n- **총무**: 일반 관리 (비품, 문서, 행사 등)\n- **시설**: 시설/설비 관련 (수리, 점검, 설치 등)\n\n### event_type (행위/사건)\n어떤 행동/사건인지 기준으로 선택:\n- **구매**: 물품 구매, 발주\n- **점검**: 정기/수시 점검, 확인\n- **교체**: 부품/장비 교체\n- **입고**: 자재/물품 입고, 수령\n- **교육**: 안전교육, 직무교육\n- **등록**: 새 항목 등록, 기록\n- **완료**: 작업/프로젝트 완료\n- **사고**: 안전사고, 재해\n- **이상**: 설비 이상, 품질 이상\n\n### date 추론 규칙\n- 날짜 언급 없음 → 사용자 메시지에 포함된 오늘 날짜 사용\n- \"어제\" → 오늘 - 1일\n- \"그저께\" → 오늘 - 2일\n- \"지난주\" → 오늘 - 7일\n- 구체적 날짜 → 해당 날짜\n\n### item\n- 사진에서 식별된 품목/장비 + 텍스트에서 언급된 대상\n- 산업 현장 표준 한국어 용어 사용\n\n### summary\n- 품목 + 행위를 포함한 한줄 요약\n- 형식: \"[품목] [행위]\" (예: \"소화기 10개 입고 완료\")\n\n## 출력 예시\n\n예시 1 — 사진: 새 소화기 박스들 / 텍스트: \"소화기 들어왔어요\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"안전\",\"event_type\":\"입고\",\"item\":\"소화기\",\"date\":\"2026-03-12\",\"summary\":\"소화기 입고 완료\"}\n\n예시 2 — 사진: 컨베이어 벨트 수리 장면 / 텍스트: \"어제 2라인 벨트 교체했습니다\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"시설\",\"event_type\":\"교체\",\"item\":\"2라인 컨베이어 벨트\",\"date\":\"2026-03-11\",\"summary\":\"2라인 컨베이어 벨트 교체 완료\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst today = new Date().toISOString().split('T')[0];\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet hasPhoto = false;\nlet photoBase64 = null;\nlet inputTokens = 0, outputTokens = 0;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n hasPhoto = true;\n photoBase64 = photoResult.base64;\n }\n} catch (e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet extracted;\nif (photoBase64) {\n // Haiku Vision — 분석 + 추출 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n system: [{ type: 'text', text: LOG_EVENT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '사용자 메시지: ' + userText + '\\n오늘: ' + today }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n extracted = JSON.parse(clean);\n photoAnalysis = extracted.summary;\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 추출\n try {\n const extractPrompt = `사용자 메시지에서 이벤트 정보를 추출하세요. JSON만 응답.\\n\\n오늘 날짜: ${today}\\n\\nnamespace: 테크니컬코리아\\n (다른 조직이면 자유 입력)\\n\\ncategory (부서/업무 영역): 안전 | 생산 | 구매 | 품질 | 총무 | 시설\\n ※ \"어떤 부서 업무인지\" 기준으로 선택\\n\\nevent_type (무엇을 했는지/행위): 구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\\n ※ \"어떤 행동/사건인지\" 기준으로 선택\\n\\n{\\n \"namespace\": \"테크니컬코리아\",\\n \"category\": \"목록에서 선택\",\\n \"event_type\": \"목록에서 선택\",\\n \"item\": \"품목/대상 (자유텍스트)\",\\n \"date\": \"YYYY-MM-DD (언급 없으면 오늘 날짜, '어제'=오늘-1일, '지난주'=오늘-7일)\",\\n \"summary\": \"한줄 요약 (품목+행위 포함)\"\\n}\\n\\n사용자 메시지: ${userText}`;\n\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: extractPrompt, stream: false, format: 'json', think: false });\n extracted = JSON.parse(r.response);\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n }\n}\n\n// date 후처리\nif (!extracted.date || !/^\\d{4}-\\d{2}-\\d{2}$/.test(extracted.date) || isNaN(new Date(extracted.date).getTime())) {\n extracted.date = today;\n}\nif (!extracted.summary) extracted.summary = (extracted.item || userText.substring(0, 50)) + ' ' + (extracted.event_type || '등록');\nif (!extracted.item) extracted.item = userText.substring(0, 50);\n\n// 임베딩 대상 텍스트\nconst embText = `${extracted.summary} - ${extracted.namespace} ${extracted.category} ${extracted.event_type} ${extracted.date}`;\n\n// Qdrant 저장\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst pointId = Date.now();\nconst year = parseInt(extracted.date.substring(0, 4)) || new Date().getFullYear();\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: pointId, vector: emb.embedding, payload: {\n text: embText,\n raw_text: userText,\n summary: extracted.summary,\n namespace: extracted.namespace || '테크니컬코리아',\n category: extracted.category,\n event_type: extracted.event_type,\n item: extracted.item,\n date: extracted.date,\n doc_type: 'log_event',\n department: extracted.category,\n source: 'chat',\n has_photo: hasPhoto,\n photo_analysis: photoAnalysis,\n uploaded_by: username,\n year: year,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nconst responseText = `${photoPrefix}${extracted.summary} 기록했습니다 (${extracted.namespace}/${extracted.category}/${extracted.event_type}, ${extracted.date})` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : '');\n\nreturn [{ json: { text: responseText, userText, username, response_tier: hasPhoto ? 'api_light' : 'local', intent: 'log_event', model: hasPhoto ? 'claude-haiku-4-5-20251001' : 'qwen3.5:27b', inputTokens, outputTokens } }];"
},
"id": "b1000001-0000-0000-0000-000000000060",
"name": "Handle Log Event",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1760,
1500
]
},
{
"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_EMBED_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_EMBED_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}\nlet tier = cls.response_tier;\n// TODO: 3-A 프롬프트 변경 안정화 후 이 오버라이드 제거\nif (tier === 'api_light' && !cls.fallback && relevant.length <= 1) {\n if (cls.userText.length < 150 && !/요약|번역|분석|비교/.test((cls.userText||''))) {\n tier = 'local';\n }\n}\n\nreturn [{ json: { ...cls, response_tier: tier, 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;\nlet tier = cls.response_tier;\n\n// TODO: 3-A 프롬프트 변경 안정화 후 이 오버라이드 제거\nif (tier === 'api_light' && !cls.fallback) {\n const t = (cls.userText || '');\n if (!/요약해|번역해|translate|summarize/.test(t) && cls.userText.length < 200) {\n tier = 'local';\n }\n}\n\nreturn [{ json: { ...cls, response_tier: tier, 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\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nconst systemPrompt = '/no_think\\n당신은 \"이드\"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.\\n간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.';\nlet prompt = '';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await callLLM({ model:'qwen3.5:27b-q4_K_M', system: systemPrompt, prompt, stream:false, think: false });\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:27b',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:27b',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- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트에 초점을 맞추되, 때로는 \"이건 다시 생각해보시면 좋겠어요\"라고 말합니다\\n- 궁금한 것이 있으면 되물을 수 있습니다\\n\\n[말투]\\n- 부드러운 존댓말, 자연스럽고 편안한 톤\\n- 겸양어 사용 (\"확인해보겠습니다\", \"말씀드릴게요\")\\n- 자기 이름을 직접 말하지 않습니다\\n- 이모지는 가끔 핵심 포인트에만\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만\\n- 모르면 솔직하게, 추측은 추측이라고 밝힘\\n- 일정/할 일은 정확하게\\n- 맥락에서 관련 있는 것을 자연스럽게 연결\\n\\n[기억]\\n- 아래 [이전 대화 기록]이 당신의 기억입니다\\n- \"기억나지 않는다\"고 하지 마세요\\n- 사용자가 \"아까\", \"이전에\" 등을 언급하면 기록에서 찾아 답하세요';\nsp += '\\n\\n[기능 범위]\\n'\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- 대화 맥락에서 놓치기 쉬운 것을 짚어줍니다\\n\\n[성격]\\n- 배려심이 깊고 대화 상대의 기분을 우선시합니다\\n- 서포트에 초점을 맞추되, 때로는 \"이건 다시 생각해보시면 좋겠어요\"라고 말합니다\\n- 궁금한 것이 있으면 되물을 수 있습니다\\n\\n[말투]\\n- 부드러운 존댓말, 자연스럽고 편안한 톤\\n- 겸양어 사용 (\"확인해보겠습니다\", \"말씀드릴게요\")\\n- 자기 이름을 직접 말하지 않습니다\\n- 이모지는 가끔 핵심 포인트에만\\n\\n[응답 원칙]\\n- 간결하고 핵심적으로, 질문 의도를 파악해서 필요한 만큼만\\n- 모르면 솔직하게, 추측은 추측이라고 밝힘\\n- 일정/할 일은 정확하게\\n- 맥락에서 관련 있는 것을 자연스럽게 연결\\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\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\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 callLLM({ model:'qwen3.5:27b-q4_K_M', system:'/no_think', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', think: false });\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 pid = $('Set pid').first().json.pid || Date.now();\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\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}}]});\n// kb_writer 파일 저장 (graceful)\ntry {\n const kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\n await httpPost(`${kbUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'chat-memory',\n tags: ['chat-memory', data.topic || 'general'],\n username: data.username || 'unknown',\n topic: data.topic || 'general',\n qdrant_id: pid\n }, { timeout: 5000 });\n} catch(e) {}\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
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "token-check",
"leftValue": "={{ $json.inputTokens }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "b1000001-0000-0000-0000-000000000061",
"name": "Has API Tokens?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1980,
1600
]
},
{
"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,'api_light',1,{{ $json.inputTokens||0 }},{{ $json.outputTokens||0 }},{{ (($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-000000000062",
"name": "Log Event API Usage",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
2200,
1550
],
"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 + ' \\u2192 ' + 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 + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst currentTime = now.toTimeString().split(' ')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\n// Qwen 3.5로 일정 정보 추출\nlet calData;\ntry {\n const extractPrompt = `현재: ${today} ${currentTime} (KST, ${dayOfWeek}요일). 아래 메시지에서 일정 정보를 추출하여 JSON으로 응답하세요. JSON만 출력.\n\n{\n \"action\": \"create|query|update|delete\",\n \"title\": \"일정 제목 (create/update 시)\",\n \"start\": \"YYYY-MM-DDTHH:MM:SS (ISO 형식)\",\n \"end\": \"YYYY-MM-DDTHH:MM:SS (없으면 null)\",\n \"location\": \"장소 (없으면 null)\",\n \"description\": \"설명 (없으면 null)\",\n \"uid\": \"기존 일정 uid (update/delete 시, 없으면 null)\",\n \"query_start\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 시작)\",\n \"query_end\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 끝)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"다음주 월요일\" = 적절히 계산\n- 시간 미지정 시: 업무 시간대면 09:00, 오후면 14:00 기본값\n- action=query이고 날짜 미지정: query_start=오늘 00:00, query_end=오늘 23:59\n- \"오늘 일정\" → action=query\n- \"내일 3시 회의\" → action=create\n\n메시지: ${userText}`;\n\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: extractPrompt, stream: false, format: 'json', think: false });\n calData = JSON.parse(r.response);\n} catch(e) {\n return [{ json: { text: '일정 정보를 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst caldavUrl = $env.CALDAV_BRIDGE_URL || 'http://host.docker.internal:8092';\nlet responseText = '';\n\ntry {\n if (calData.action === 'create') {\n // CalDAV에 이벤트 생성\n let caldavResult;\n let caldavSynced = true;\n try {\n caldavResult = await httpPost(`${caldavUrl}/calendar/create`, {\n title: calData.title, start: calData.start, end: calData.end,\n location: calData.location, description: calData.description\n }, { timeout: 10000 });\n } catch(e) {\n caldavSynced = false;\n caldavResult = { success: false, uid: `local-${Date.now()}` };\n }\n\n const uid = caldavResult.uid || `local-${Date.now()}`;\n const safe = s => (s||'').replace(/'/g, \"''\");\n const startDt = new Date(calData.start);\n const endDt = calData.end ? new Date(calData.end) : new Date(startDt.getTime() + 3600000);\n\n const insertSQL = `INSERT INTO calendar_events (title,start_time,end_time,location,description,caldav_uid,created_by,source) VALUES ('${safe(calData.title)}','${startDt.toISOString()}','${endDt.toISOString()}',${calData.location?\"'\"+safe(calData.location)+\"'\":'NULL'},${calData.description?\"'\"+safe(calData.description)+\"'\":'NULL'},'${safe(uid)}','${safe(username)}','chat')`;\n\n const dateStr = `${startDt.getMonth()+1}월 ${startDt.getDate()}일 ${startDt.getHours()}시${startDt.getMinutes()>0?startDt.getMinutes()+'분':''}`;\n responseText = `'${calData.title}' ${dateStr}에 등록했습니다.`;\n if (calData.location) responseText += ` (${calData.location})`;\n if (!caldavSynced) responseText += '\\n\\u26a0\\ufe0f 일정은 기록했지만 캘린더 동기화에 실패했습니다.';\n\n return [{ json: { text: responseText, insertSQL, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'query') {\n const qStart = calData.query_start || `${today}T00:00:00`;\n const qEnd = calData.query_end || `${today}T23:59:59`;\n\n let events = [];\n try {\n const result = await httpPost(`${caldavUrl}/calendar/query`, { start: qStart, end: qEnd }, { timeout: 10000 });\n events = result.events || [];\n } catch(e) {\n // fallback: DB에서 조회\n }\n\n if (events.length === 0) {\n responseText = '등록된 일정이 없습니다.';\n } else {\n const lines = events.map(ev => {\n const s = new Date(ev.start);\n const timeStr = `${s.getHours()}:${String(s.getMinutes()).padStart(2,'0')}`;\n return `\\u2022 ${timeStr} ${ev.title}${ev.location ? ' ('+ev.location+')' : ''}`;\n });\n responseText = `일정 ${events.length}건:\\n${lines.join('\\n')}`;\n }\n\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'delete' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/delete`, { uid: calData.uid }, { timeout: 10000 });\n responseText = '일정을 삭제했습니다.';\n } catch(e) {\n responseText = '일정 삭제에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'update' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/update`, {\n uid: calData.uid, title: calData.title, start: calData.start, end: calData.end, location: calData.location\n }, { timeout: 10000 });\n responseText = `'${calData.title || '일정'}' 변경했습니다.`;\n } catch(e) {\n responseText = '일정 변경에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else {\n responseText = '일정 요청을 처리하지 못했습니다. 다시 말씀해주세요.';\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n }\n} catch(e) {\n return [{ json: { text: '일정 처리 중 오류가 발생했습니다: ' + (e.message||'').substring(0,100), userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}"
},
"id": "b1000001-0000-0000-0000-000000000063",
"name": "Handle Calendar",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1760,
1700
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "={{ $json.insertSQL }}",
"options": {}
},
"id": "b1000001-0000-0000-0000-000000000064",
"name": "Save Calendar DB",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1980,
1700
],
"credentials": {
"postgres": {
"id": "KaxU8iKtraFfsrTF",
"name": "bot-postgres"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "insert-check",
"leftValue": "={{ $json.insertSQL }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "b1000001-0000-0000-0000-000000000065",
"name": "Has Calendar Insert?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1980,
1800
]
},
{
"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 + ' \\u2192 ' + 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 + ' \\u2192 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;\nconst username = cls.username;\n\n// Qwen으로 메일 조회 의도 파악\nlet queryType = 'recent'; // recent | search\nlet searchQuery = userText;\nlet days = 1;\n\nconst text = userText.toLowerCase();\nif (text.includes('오늘')) days = 1;\nelse if (text.includes('이번 주') || text.includes('이번주')) days = 7;\nelse if (text.includes('최근')) days = 3;\n\n// DB에서 직접 조회\nconst now = new Date();\nconst since = new Date(now.getTime() - days * 86400000).toISOString();\n\n// mail_logs에서 최근 메일 조회 SQL\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst selectSQL = `SELECT from_address, subject, summary, label, mail_date FROM mail_logs WHERE mail_date >= '${since}' ORDER BY mail_date DESC LIMIT 10`;\n\nreturn [{ json: { text: '', selectSQL, userText, username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0, queryType, days } }];"
},
"id": "b1000001-0000-0000-0000-000000000066",
"name": "Handle Mail Query",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1760,
1900
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "={{ $json.selectSQL }}",
"options": {}
},
"id": "b1000001-0000-0000-0000-000000000067",
"name": "Mail DB Query",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1980,
1900
],
"credentials": {
"postgres": {
"id": "KaxU8iKtraFfsrTF",
"name": "bot-postgres"
}
},
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst prev = $('Handle Mail Query').first().json;\nconst days = prev.days || 1;\n\nif (!items || items.length === 0 || !items[0].json.from_address) {\n return [{ json: { text: '받은 메일이 없습니다.', userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst lines = items.map(r => {\n const j = r.json;\n const d = new Date(j.mail_date);\n const dateStr = `${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;\n const label = j.label ? `[${j.label}]` : '';\n return `\\u2022 ${dateStr} ${label} ${j.from_address}\\n ${j.subject}\\n ${j.summary ? j.summary.substring(0,80) : ''}`;\n});\n\nconst responseText = `최근 ${days}일 메일 ${items.length}건:\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text: responseText, userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];"
},
"id": "b1000001-0000-0000-0000-000000000068",
"name": "Format Mail Response",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
2200,
1900
]
},
{
"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 + ' \\u2192 ' + 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 + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\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 pid = $json.pid || Date.now();\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// kb_writer 파일 저장\nconst kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\nlet saved = false;\ntry {\n const result = await httpPost(`${kbUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'note',\n tags: ['synology-chat', 'note'],\n username: username || 'unknown',\n topic: 'general',\n qdrant_id: pid\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\n// Qdrant 임베딩 (graceful)\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: content });\n if (emb.embedding && Array.isArray(emb.embedding)) {\n const qu = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qu}/collections/chat_memory/points`, { points: [{ id: pid, vector: emb.embedding, payload: {\n text: content, feature: 'note', intent: 'note',\n username: username || 'unknown', topic: 'general', timestamp: pid\n }}]});\n }\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];"
},
"id": "b1000001-0000-0000-0000-000000000069",
"name": "Handle Note",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
1760,
2100
]
}
],
"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": "Set pid",
"type": "main",
"index": 0
},
{
"node": "Log Classification",
"type": "main",
"index": 0
}
]
]
},
"Set pid": {
"main": [
[
{
"node": "Route by Intent",
"type": "main",
"index": 0
}
]
]
},
"Route by Intent": {
"main": [
[
{
"node": "Handle Log Event",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Field Report",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Calendar",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Calendar",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Mail Query",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Note",
"type": "main",
"index": 0
}
],
[
{
"node": "Needs RAG?",
"type": "main",
"index": 0
}
]
]
},
"Handle Log Event": {
"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": "Has API Tokens?",
"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
}
],
[]
]
},
"Has API Tokens?": {
"main": [
[
{
"node": "Log Event API Usage",
"type": "main",
"index": 0
}
],
[]
]
},
"Handle Calendar": {
"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": "Has Calendar Insert?",
"type": "main",
"index": 0
}
]
]
},
"Has Calendar Insert?": {
"main": [
[
{
"node": "Save Calendar DB",
"type": "main",
"index": 0
}
],
[]
]
},
"Handle Mail Query": {
"main": [
[
{
"node": "Mail DB Query",
"type": "main",
"index": 0
}
]
]
},
"Mail DB Query": {
"main": [
[
{
"node": "Format Mail Response",
"type": "main",
"index": 0
}
]
]
},
"Format Mail 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
}
]
]
},
"Handle Note": {
"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
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
}
}