Files
syn-chat-bot/n8n/workflows/main-chat-pipeline.json
hyungi f7cccc9c5e n8n Task Runner 샌드박스 대응 및 워크플로우 업데이트
- CLAUDE.md: Task Runner VM 샌드박스 제약사항 문서화
- docker-compose.yml: NODE_FUNCTION_ALLOW_BUILTIN 환경변수 추가
- main-chat-pipeline.json: require('http/https/url') 방식으로 전환된 워크플로우 반영

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:45:39 +09:00

1445 lines
70 KiB
JSON
Raw Blame History

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