Phase 4-5: 회사 문서 등록 + 현장 리포팅 + 보고서 생성

워크플로우 37→42노드 확장:
- /문서등록: 2단계 플로우 (메타데이터 → 텍스트 청킹+임베딩+tk_company 저장)
- /보고서: field_reports DB 집계 → Claude Haiku 보고서 생성
- 현장 리포트: 비전 모델 분석 → Qwen 구조화 → SLA 계산 → DB+벡터 저장
- 응답 경로 통합: 6개 Send/Respond 쌍 → 2개 공유 노드로 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-03-11 13:11:17 +09:00
parent 6e6ffaa04b
commit f42096fb24
3 changed files with 308 additions and 534 deletions

View File

@@ -40,7 +40,7 @@ bot-n8n (맥미니 Docker)
| bot-n8n | Docker (맥미니) | 5678 | 워크플로우 엔진 |
| bot-postgres | Docker (맥미니) | 127.0.0.1:15478 | 설정/로그 DB |
| Qdrant | Docker (맥미니, 기존) | 127.0.0.1:6333 | 벡터 DB (3컬렉션) |
| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3, 비전모델 |
| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3, minicpm-v:8b(Phase 5) |
| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | qwen3.5:9b-q8_0 (분류+local응답) |
| Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 |

View File

@@ -162,7 +162,8 @@ NAS에서 Outgoing Webhook 설정 필요:
### Phase 0: 맥미니 정리
- [ ] ollama rm qwen3.5:35b-a3b (삭제)
- [ ] ollama pull minicpm-v:8b (비전 모델 설치)
- [ ] ollama rm qwen3.5:35b-a3b-think (삭제)
- [ ] ollama pull minicpm-v:8b (비전 모델 설치, Phase 5용)
### Phase 1: 기반 (Qdrant + DB)
- [x] init.sql v2 (12테이블 + 분류기 v2 프롬프트 + 메모리 판단 프롬프트)

View File

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