- qwen3.5:9b-q8_0 → id-9b:latest 전체 교체 (워크플로우, Python 스크립트) - deploy_workflows.sh 생성 (n8n REST API 자동 배포) - .env.example: CalDAV/IMAP/Karakeep 기본값 수정 - 문서 업데이트: tk_qc_issues 컬렉션, 맥미니 Ollama 기동 안내 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1924 lines
113 KiB
JSON
1924 lines
113 KiB
JSON
{
|
|
"id": "exSV3aHhXcksqm6j",
|
|
"name": "메인 채팅 파이프라인 v2",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "chat",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000001",
|
|
"name": "Webhook",
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
0,
|
|
500
|
|
],
|
|
"webhookId": "chat"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst username = body.username || 'unknown';\nconst userId = body.user_id || '';\nconst token = body.token || '';\nconst timestamp = body.timestamp || '';\nconst channelId = body.channel_id || '';\n\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\nconst staticData = $getWorkflowStaticData('global');\nconst now = Date.now();\nconst rlKey = `rl_${username}`;\nif (!staticData[rlKey]) staticData[rlKey] = [];\nstaticData[rlKey] = staticData[rlKey].filter(t => now - t < 10000);\nif (staticData[rlKey].length >= 5) {\n return [{ json: { rejected: true, rejectReason: '잠시 후 다시 시도해주세요.' } }];\n}\nstaticData[rlKey].push(now);\n\nconst isCommand = text.startsWith('/');\nconst pendingKey = `pendingDoc_${username}`;\nlet pendingDoc = staticData[pendingKey] || null;\nif (pendingDoc && (now - pendingDoc.timestamp > 300000)) {\n delete staticData[pendingKey];\n pendingDoc = null;\n}\nconst hasPendingDoc = !!pendingDoc && !isCommand;\n\nreturn [{ json: { rejected: false, hasPendingDoc, pendingDoc, text, username, userId, token, timestamp, channelId, isCommand } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000002",
|
|
"name": "Parse Input",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
220,
|
|
500
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "reject-check",
|
|
"leftValue": "={{ $json.rejected }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000003",
|
|
"name": "Is Rejected?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
440,
|
|
500
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "return [{ json: { text: $('Parse Input').first().json.rejectReason || '요청을 처리할 수 없습니다.' } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000004",
|
|
"name": "Reject Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
660,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "pending-check",
|
|
"leftValue": "={{ $json.hasPendingDoc }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000040",
|
|
"name": "Has Pending Doc?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
660,
|
|
600
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst text = input.text;\nconst username = input.username;\nconst pendingDoc = input.pendingDoc;\nconst staticData = $getWorkflowStaticData('global');\ndelete staticData[`pendingDoc_${username}`];\n\nif (!text || text.length < 10) {\n return [{ json: { text: '문서 텍스트가 너무 짧습니다. 다시 시도해주세요.', saved: false } }];\n}\n\nconst chunks = [];\nconst chunkSize = 2000, overlap = 200;\nlet start = 0;\nwhile (start < text.length) {\n chunks.push(text.substring(start, Math.min(start + chunkSize, text.length)));\n if (start + chunkSize >= text.length) break;\n start += chunkSize - overlap;\n}\n\nconst encoder = new TextEncoder();\nconst hashBuf = await require('crypto').subtle.digest('SHA-256', encoder.encode(text));\nconst hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst year = new Date().getFullYear();\nconst baseId = Date.now();\n\nfor (let i = 0; i < chunks.length; i++) {\n try {\n const embResp = await httpPost(`${$env.LOCAL_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 + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|reminder|mail|note|photo|command|report|other\",\n \"response_tier\": \"local|api_light|api_heavy\",\n \"needs_rag\": true/false,\n \"rag_target\": [\"documents\", \"tk_company\", \"chat_memory\"],\n \"department_hint\": \"안전|생산|구매|품질|총무|시설|null\",\n \"report_domain\": \"안전|시설설비|품질|null\",\n \"query\": \"검색용 쿼리 (needs_rag=false면 null)\"\n}\n\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier: local(인사,잡담,감사,log_event,report,calendar,reminder,note), api_light(요약,번역,일반질문,mail), api_heavy(법률,복잡추론)\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: classifierPrompt, stream: false, format: 'json', think: false },\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, 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, 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, rag_target:$json.rag_target, query:$json.query, department_hint:$json.department_hint, report_domain:$json.report_domain}) }}'::jsonb, 'id-9b:latest', {{ $json.latency }}, {{ $json.fallback }})",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000021",
|
|
"name": "Log Classification",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
1540,
|
|
1400
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"rules": {
|
|
"values": [
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "log_event",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "LogEvent"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "report",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "Report"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "calendar",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "Calendar"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "reminder",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "Reminder"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "mail",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "Mail"
|
|
},
|
|
{
|
|
"conditions": {
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.intent }}",
|
|
"rightValue": "note",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and",
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
}
|
|
},
|
|
"renameOutput": "Note"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"fallbackOutput": "extra"
|
|
}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000046",
|
|
"name": "Route by Intent",
|
|
"type": "n8n-nodes-base.switch",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
1540,
|
|
1200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst FIELD_REPORT_SYSTEM_PROMPT = `너는 산업 현장 신고를 분석하는 전문가다. 사진과 텍스트를 받아 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"domain\": \"안전 | 시설설비 | 품질\",\n \"category\": \"string (자유텍스트 — 예: 전기안전, 화재예방, 기계설비, 배관, 제품불량 등)\",\n \"severity\": \"상 | 중 | 하\",\n \"location\": \"string (언급된 장소, 없으면 빈 문자열)\",\n \"department\": \"string (언급된 부서, 없으면 빈 문자열)\",\n \"keywords\": [\"string\"],\n \"summary\": \"string (한줄 요약, 50자 이내)\",\n \"action_required\": \"string (필요 조치, 없으면 빈 문자열)\"\n}\n\n## 필드별 판단 기준\n\n### domain\n- **안전**: 인명 사고, 안전장비 미착용, 위험물 노출, 화재 위험, 추락 위험, 감전 위험\n- **시설설비**: 기계 고장, 배관 누수, 전기 설비 이상, 건물 파손, 공조 장치 문제\n- **품질**: 제품 불량, 원자재 품질 이상, 공정 이탈, 검사 부적합\n\n### severity (심각도)\n- **상**: 즉시 조치 필요. 인명 피해 우려, 가동 중단, 법적 위반. SLA: 안전 24h / 시설설비·품질 48h\n- **중**: 계획 조치 필요. 경미한 이상, 모니터링 대상. SLA: 안전 72h / 시설설비·품질 120h\n- **하**: 참고 사항. 개선 권고, 점검 기록. SLA: 안전 168h / 시설설비·품질 336h\n\n### keywords\n- 사진에서 식별된 장비/물체 + 텍스트에서 언급된 핵심 단어\n- 산업 현장 표준 한국어 용어 사용 (지게차, 컨베이어, 분전반, 소화기, 안전모 등)\n\n### summary\n- 사진 내용 + 사용자 메시지를 종합한 한줄 요약\n- 형식: \"[대상] [상태/문제]\" (예: \"2층 분전반 과열 흔적 발견\")\n\n## 출력 예시\n\n예시 1 — 사진: 안전모 미착용 작업자 / 텍스트: \"3층 현장 점검 중\"\n{\"domain\":\"안전\",\"category\":\"보호구\",\"severity\":\"상\",\"location\":\"3층\",\"department\":\"\",\"keywords\":[\"안전모\",\"미착용\",\"보호구\"],\"summary\":\"3층 현장 작업자 안전모 미착용 확인\",\"action_required\":\"해당 작업자 보호구 착용 지도 및 현장 안전 점검\"}\n\n예시 2 — 사진: 바닥 물웅덩이 / 텍스트: \"지하 기계실 배관\"\n{\"domain\":\"시설설비\",\"category\":\"배관\",\"severity\":\"중\",\"location\":\"지하 기계실\",\"department\":\"\",\"keywords\":[\"배관\",\"누수\",\"기계실\"],\"summary\":\"지하 기계실 배관 누수로 바닥 침수\",\"action_required\":\"배관 누수 지점 확인 및 보수\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText, username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst reportDomain = cls.report_domain || '안전';\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet photoBase64 = null;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n photoBase64 = photoResult.base64;\n }\n} catch(e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet structured;\nlet inputTokens = 0, outputTokens = 0;\nif (photoBase64) {\n // Haiku Vision — 분석 + 구조화 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n system: [{ type: 'text', text: FIELD_REPORT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '현장 신고: ' + userText }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n structured = JSON.parse(clean);\n photoAnalysis = structured.summary;\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '', parse_error: true };\n photoAnalysis = structured.summary;\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 구조화\n try {\n const sp = `현장 신고를 구조화. JSON만 응답.\\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\\n\\n신고: ${userText}`;\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: sp, stream: false, format: 'json', think: false },\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}\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: 'report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n\\u26a0\\ufe0f 긴급 \\u2014 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nlet insertSQL = `INSERT INTO field_reports (domain,category,severity,location,department,keywords,summary,action_required,user_description,photo_url,photo_analysis,reporter,year,month,due_at) VALUES ('${safe(structured.domain)}','${safe(structured.category)}','${safe(structured.severity)}','${safe(structured.location)}','${safe(structured.department||'미지정')}',ARRAY[${kw}],'${safe(structured.summary)}','${safe(structured.action_required)}','${safe(userText).substring(0,1000)}',NULL,${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nif (photoBase64 && inputTokens > 0) {\n insertSQL += `; INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (${now.getFullYear()},${now.getMonth()+1},'api_light',1,${inputTokens},${outputTokens},${(inputTokens*0.8+outputTokens*4)/1000000}) ON CONFLICT (year,month,tier) DO UPDATE SET call_count=api_usage_monthly.call_count+1,total_input_tokens=api_usage_monthly.total_input_tokens+EXCLUDED.total_input_tokens,total_output_tokens=api_usage_monthly.total_output_tokens+EXCLUDED.total_output_tokens,estimated_cost=api_usage_monthly.estimated_cost+EXCLUDED.estimated_cost,updated_at=NOW()`;\n}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nreturn [{ json: { text: `${photoPrefix}접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : ''), insertSQL } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000047",
|
|
"name": "Handle Field Report",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1760,
|
|
1300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst LOG_EVENT_SYSTEM_PROMPT = `너는 산업 현장 업무 로그를 분석하는 전문가다. 사진과 텍스트를 받아 이벤트 정보를 추출하여 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"namespace\": \"string (조직명, 기본값: 테크니컬코리아)\",\n \"category\": \"안전 | 생산 | 구매 | 품질 | 총무 | 시설\",\n \"event_type\": \"구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\",\n \"item\": \"string (품목/대상, 자유텍스트)\",\n \"date\": \"YYYY-MM-DD\",\n \"summary\": \"string (한줄 요약, 품목+행위 포함)\"\n}\n\n## 필드별 판단 기준\n\n### namespace\n- 기본값: \"테크니컬코리아\"\n- 다른 조직이 언급되면 해당 조직명 사용\n\n### category (부서/업무 영역)\n어떤 부서 업무인지 기준으로 선택:\n- **안전**: 안전 관련 업무 (안전장비, 안전교육, 사고 등)\n- **생산**: 생산 라인 관련 (기계 가동, 생산량, 공정 등)\n- **구매**: 구매/발주 관련 (자재 구매, 발주, 견적 등)\n- **품질**: 품질 관련 (검사, 불량, 시험 등)\n- **총무**: 일반 관리 (비품, 문서, 행사 등)\n- **시설**: 시설/설비 관련 (수리, 점검, 설치 등)\n\n### event_type (행위/사건)\n어떤 행동/사건인지 기준으로 선택:\n- **구매**: 물품 구매, 발주\n- **점검**: 정기/수시 점검, 확인\n- **교체**: 부품/장비 교체\n- **입고**: 자재/물품 입고, 수령\n- **교육**: 안전교육, 직무교육\n- **등록**: 새 항목 등록, 기록\n- **완료**: 작업/프로젝트 완료\n- **사고**: 안전사고, 재해\n- **이상**: 설비 이상, 품질 이상\n\n### date 추론 규칙\n- 날짜 언급 없음 → 사용자 메시지에 포함된 오늘 날짜 사용\n- \"어제\" → 오늘 - 1일\n- \"그저께\" → 오늘 - 2일\n- \"지난주\" → 오늘 - 7일\n- 구체적 날짜 → 해당 날짜\n\n### item\n- 사진에서 식별된 품목/장비 + 텍스트에서 언급된 대상\n- 산업 현장 표준 한국어 용어 사용\n\n### summary\n- 품목 + 행위를 포함한 한줄 요약\n- 형식: \"[품목] [행위]\" (예: \"소화기 10개 입고 완료\")\n\n## 출력 예시\n\n예시 1 — 사진: 새 소화기 박스들 / 텍스트: \"소화기 들어왔어요\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"안전\",\"event_type\":\"입고\",\"item\":\"소화기\",\"date\":\"2026-03-12\",\"summary\":\"소화기 입고 완료\"}\n\n예시 2 — 사진: 컨베이어 벨트 수리 장면 / 텍스트: \"어제 2라인 벨트 교체했습니다\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"시설\",\"event_type\":\"교체\",\"item\":\"2라인 컨베이어 벨트\",\"date\":\"2026-03-11\",\"summary\":\"2라인 컨베이어 벨트 교체 완료\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst today = new Date().toISOString().split('T')[0];\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet hasPhoto = false;\nlet photoBase64 = null;\nlet inputTokens = 0, outputTokens = 0;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n hasPhoto = true;\n photoBase64 = photoResult.base64;\n }\n} catch (e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet extracted;\nif (photoBase64) {\n // Haiku Vision — 분석 + 추출 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n system: [{ type: 'text', text: LOG_EVENT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '사용자 메시지: ' + userText + '\\n오늘: ' + today }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n extracted = JSON.parse(clean);\n photoAnalysis = extracted.summary;\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 추출\n try {\n const extractPrompt = `사용자 메시지에서 이벤트 정보를 추출하세요. JSON만 응답.\\n\\n오늘 날짜: ${today}\\n\\nnamespace: 테크니컬코리아\\n (다른 조직이면 자유 입력)\\n\\ncategory (부서/업무 영역): 안전 | 생산 | 구매 | 품질 | 총무 | 시설\\n ※ \"어떤 부서 업무인지\" 기준으로 선택\\n\\nevent_type (무엇을 했는지/행위): 구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\\n ※ \"어떤 행동/사건인지\" 기준으로 선택\\n\\n{\\n \"namespace\": \"테크니컬코리아\",\\n \"category\": \"목록에서 선택\",\\n \"event_type\": \"목록에서 선택\",\\n \"item\": \"품목/대상 (자유텍스트)\",\\n \"date\": \"YYYY-MM-DD (언급 없으면 오늘 날짜, '어제'=오늘-1일, '지난주'=오늘-7일)\",\\n \"summary\": \"한줄 요약 (품목+행위 포함)\"\\n}\\n\\n사용자 메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: extractPrompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n extracted = JSON.parse(r.response);\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n }\n}\n\n// date 후처리\nif (!extracted.date || !/^\\d{4}-\\d{2}-\\d{2}$/.test(extracted.date) || isNaN(new Date(extracted.date).getTime())) {\n extracted.date = today;\n}\nif (!extracted.summary) extracted.summary = (extracted.item || userText.substring(0, 50)) + ' ' + (extracted.event_type || '등록');\nif (!extracted.item) extracted.item = userText.substring(0, 50);\n\n// 임베딩 대상 텍스트\nconst embText = `${extracted.summary} - ${extracted.namespace} ${extracted.category} ${extracted.event_type} ${extracted.date}`;\n\n// Qdrant 저장\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst pointId = Date.now();\nconst year = parseInt(extracted.date.substring(0, 4)) || new Date().getFullYear();\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: pointId, vector: emb.embedding, payload: {\n text: embText,\n raw_text: userText,\n summary: extracted.summary,\n namespace: extracted.namespace || '테크니컬코리아',\n category: extracted.category,\n event_type: extracted.event_type,\n item: extracted.item,\n date: extracted.date,\n doc_type: 'log_event',\n department: extracted.category,\n source: 'chat',\n has_photo: hasPhoto,\n photo_analysis: photoAnalysis,\n uploaded_by: username,\n year: year,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nconst responseText = `${photoPrefix}${extracted.summary} 기록했습니다 (${extracted.namespace}/${extracted.category}/${extracted.event_type}, ${extracted.date})` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : '');\n\nreturn [{ json: { text: responseText, userText, username, response_tier: hasPhoto ? 'api_light' : 'local', intent: 'log_event', model: hasPhoto ? 'claude-haiku-4-5-20251001' : 'id-9b:latest', inputTokens, outputTokens } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000060",
|
|
"name": "Handle Log Event",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1760,
|
|
1500
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "={{ $json.insertSQL }}",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000048",
|
|
"name": "Save Field Report DB",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
1980,
|
|
1300
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "rag-check",
|
|
"leftValue": "={{ $json.needs_rag }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000022",
|
|
"name": "Needs RAG?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
1760,
|
|
1100
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst query = $('Qwen Classify v2').first().json.query;\nconst response = await httpPost(`${$env.LOCAL_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:'id-9b:latest', prompt, stream:false, think: false },\n { timeout: 30000 }\n );\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'id-9b:latest',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'id-9b:latest',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:'id-9b:latest', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', think: false },\n { 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}}]});\n// DEVONthink 저장 (graceful)\ntry {\n const dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\n await httpPost(`${dtUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'markdown',\n tags: ['chat-memory', data.topic || 'general']\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000037",
|
|
"name": "Embed & Save Memory",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
3740,
|
|
900
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "token-check",
|
|
"leftValue": "={{ $json.inputTokens }}",
|
|
"rightValue": 0,
|
|
"operator": {
|
|
"type": "number",
|
|
"operation": "gt"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000061",
|
|
"name": "Has API Tokens?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
1980,
|
|
1600
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (EXTRACT(YEAR FROM NOW())::int,EXTRACT(MONTH FROM NOW())::int,'api_light',1,{{ $json.inputTokens||0 }},{{ $json.outputTokens||0 }},{{ (($json.inputTokens||0)*0.8+($json.outputTokens||0)*4)/1000000 }}) ON CONFLICT (year,month,tier) DO UPDATE SET call_count=api_usage_monthly.call_count+1,total_input_tokens=api_usage_monthly.total_input_tokens+EXCLUDED.total_input_tokens,total_output_tokens=api_usage_monthly.total_output_tokens+EXCLUDED.total_output_tokens,estimated_cost=api_usage_monthly.estimated_cost+EXCLUDED.estimated_cost,updated_at=NOW()",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000062",
|
|
"name": "Log Event API Usage",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
2200,
|
|
1550
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst currentTime = now.toTimeString().split(' ')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\n// Qwen 3.5로 일정 정보 추출\nlet calData;\ntry {\n const extractPrompt = `현재: ${today} ${currentTime} (KST, ${dayOfWeek}요일). 아래 메시지에서 일정 정보를 추출하여 JSON으로 응답하세요. JSON만 출력.\n\n{\n \"action\": \"create|query|update|delete\",\n \"title\": \"일정 제목 (create/update 시)\",\n \"start\": \"YYYY-MM-DDTHH:MM:SS (ISO 형식)\",\n \"end\": \"YYYY-MM-DDTHH:MM:SS (없으면 null)\",\n \"location\": \"장소 (없으면 null)\",\n \"description\": \"설명 (없으면 null)\",\n \"uid\": \"기존 일정 uid (update/delete 시, 없으면 null)\",\n \"query_start\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 시작)\",\n \"query_end\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 끝)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"다음주 월요일\" = 적절히 계산\n- 시간 미지정 시: 업무 시간대면 09:00, 오후면 14:00 기본값\n- action=query이고 날짜 미지정: query_start=오늘 00:00, query_end=오늘 23:59\n- \"오늘 일정\" → action=query\n- \"내일 3시 회의\" → action=create\n\n메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: extractPrompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n calData = JSON.parse(r.response);\n} catch(e) {\n return [{ json: { text: '일정 정보를 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst caldavUrl = $env.CALDAV_BRIDGE_URL || 'http://host.docker.internal:8092';\nlet responseText = '';\n\ntry {\n if (calData.action === 'create') {\n // CalDAV에 이벤트 생성\n let caldavResult;\n let caldavSynced = true;\n try {\n caldavResult = await httpPost(`${caldavUrl}/calendar/create`, {\n title: calData.title, start: calData.start, end: calData.end,\n location: calData.location, description: calData.description\n }, { timeout: 10000 });\n } catch(e) {\n caldavSynced = false;\n caldavResult = { success: false, uid: `local-${Date.now()}` };\n }\n\n const uid = caldavResult.uid || `local-${Date.now()}`;\n const safe = s => (s||'').replace(/'/g, \"''\");\n const startDt = new Date(calData.start);\n const endDt = calData.end ? new Date(calData.end) : new Date(startDt.getTime() + 3600000);\n\n const insertSQL = `INSERT INTO calendar_events (title,start_time,end_time,location,description,caldav_uid,created_by,source) VALUES ('${safe(calData.title)}','${startDt.toISOString()}','${endDt.toISOString()}',${calData.location?\"'\"+safe(calData.location)+\"'\":'NULL'},${calData.description?\"'\"+safe(calData.description)+\"'\":'NULL'},'${safe(uid)}','${safe(username)}','chat')`;\n\n const dateStr = `${startDt.getMonth()+1}월 ${startDt.getDate()}일 ${startDt.getHours()}시${startDt.getMinutes()>0?startDt.getMinutes()+'분':''}`;\n responseText = `'${calData.title}' ${dateStr}에 등록했습니다.`;\n if (calData.location) responseText += ` (${calData.location})`;\n if (!caldavSynced) responseText += '\\n\\u26a0\\ufe0f 일정은 기록했지만 캘린더 동기화에 실패했습니다.';\n\n return [{ json: { text: responseText, insertSQL, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'query') {\n const qStart = calData.query_start || `${today}T00:00:00`;\n const qEnd = calData.query_end || `${today}T23:59:59`;\n\n let events = [];\n try {\n const result = await httpPost(`${caldavUrl}/calendar/query`, { start: qStart, end: qEnd }, { timeout: 10000 });\n events = result.events || [];\n } catch(e) {\n // fallback: DB에서 조회\n }\n\n if (events.length === 0) {\n responseText = '등록된 일정이 없습니다.';\n } else {\n const lines = events.map(ev => {\n const s = new Date(ev.start);\n const timeStr = `${s.getHours()}:${String(s.getMinutes()).padStart(2,'0')}`;\n return `\\u2022 ${timeStr} ${ev.title}${ev.location ? ' ('+ev.location+')' : ''}`;\n });\n responseText = `일정 ${events.length}건:\\n${lines.join('\\n')}`;\n }\n\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'delete' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/delete`, { uid: calData.uid }, { timeout: 10000 });\n responseText = '일정을 삭제했습니다.';\n } catch(e) {\n responseText = '일정 삭제에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'update' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/update`, {\n uid: calData.uid, title: calData.title, start: calData.start, end: calData.end, location: calData.location\n }, { timeout: 10000 });\n responseText = `'${calData.title || '일정'}' 변경했습니다.`;\n } catch(e) {\n responseText = '일정 변경에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else {\n responseText = '일정 요청을 처리하지 못했습니다. 다시 말씀해주세요.';\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n }\n} catch(e) {\n return [{ json: { text: '일정 처리 중 오류가 발생했습니다: ' + (e.message||'').substring(0,100), userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000063",
|
|
"name": "Handle Calendar",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1760,
|
|
1700
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "={{ $json.insertSQL }}",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000064",
|
|
"name": "Save Calendar DB",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
1980,
|
|
1700
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "insert-check",
|
|
"leftValue": "={{ $json.insertSQL }}",
|
|
"rightValue": "",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "notEmpty"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000065",
|
|
"name": "Has Calendar Insert?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
1980,
|
|
1800
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// Qwen으로 메일 조회 의도 파악\nlet queryType = 'recent'; // recent | search\nlet searchQuery = userText;\nlet days = 1;\n\nconst text = userText.toLowerCase();\nif (text.includes('오늘')) days = 1;\nelse if (text.includes('이번 주') || text.includes('이번주')) days = 7;\nelse if (text.includes('최근')) days = 3;\n\n// DB에서 직접 조회\nconst now = new Date();\nconst since = new Date(now.getTime() - days * 86400000).toISOString();\n\n// mail_logs에서 최근 메일 조회 SQL\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst selectSQL = `SELECT from_address, subject, summary, label, mail_date FROM mail_logs WHERE mail_date >= '${since}' ORDER BY mail_date DESC LIMIT 10`;\n\nreturn [{ json: { text: '', selectSQL, userText, username, response_tier: 'local', intent: 'mail', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0, queryType, days } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000066",
|
|
"name": "Handle Mail Query",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1760,
|
|
1900
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "={{ $json.selectSQL }}",
|
|
"options": {}
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000067",
|
|
"name": "Mail DB Query",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
1980,
|
|
1900
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items = $input.all();\nconst prev = $('Handle Mail Query').first().json;\nconst days = prev.days || 1;\n\nif (!items || items.length === 0 || !items[0].json.from_address) {\n return [{ json: { text: '받은 메일이 없습니다.', userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst lines = items.map(r => {\n const j = r.json;\n const d = new Date(j.mail_date);\n const dateStr = `${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;\n const label = j.label ? `[${j.label}]` : '';\n return `\\u2022 ${dateStr} ${label} ${j.from_address}\\n ${j.subject}\\n ${j.summary ? j.summary.substring(0,80) : ''}`;\n});\n\nconst responseText = `최근 ${days}일 메일 ${items.length}건:\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text: responseText, userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000068",
|
|
"name": "Format Mail Response",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
2200,
|
|
1900
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// DEVONthink에 저장\nconst dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\nlet saved = false;\ntry {\n const result = await httpPost(`${dtUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'markdown',\n tags: ['synology-chat', 'note']\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 DEVONthink 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];"
|
|
},
|
|
"id": "b1000001-0000-0000-0000-000000000069",
|
|
"name": "Handle Note",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1760,
|
|
2100
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parse Input",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parse Input": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Is Rejected?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Is Rejected?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Reject Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Has Pending Doc?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Reject Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Has Pending Doc?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Process Document",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Regex Pre-filter",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Process Document": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Log Doc Ingestion",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Log Doc Ingestion": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Regex Pre-filter": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Pre-filter Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Is Command?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Pre-filter Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Is Command?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Parse Command",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Qwen Classify v2",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Parse Command": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Command Router",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Command Router": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Needs DB?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Report Data Query",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Direct Command Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Needs DB?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Command DB Query",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Direct Command Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Command DB Query": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Format Command Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Format Command Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Direct Command Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Report Data Query": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Generate Report",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Generate Report": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Qwen Classify v2": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Route by Intent",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log Classification",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Route by Intent": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Handle Log Event",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Handle Field Report",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Needs RAG?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Handle Calendar",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Handle Calendar",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Handle Mail Query",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Handle Note",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Handle Log Event": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Has API Tokens?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Handle Field Report": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Save Field Report DB",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Save Field Report DB": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send Simple Response",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond Simple Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Needs RAG?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Get Embedding",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "No RAG Context",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Get Embedding": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Multi-Collection Search",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Multi-Collection Search": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Build RAG Context",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Build RAG Context": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Route by Tier",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"No RAG Context": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Route by Tier",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Route by Tier": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Call Qwen Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Call Opus",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Call Haiku",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Call Haiku",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Call Qwen Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Memorization Check",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Call Haiku": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "API Usage Log",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Memorization Check",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Call Opus": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "API Usage Log",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Memorization Check",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Memorization Check": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Should Memorize?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Should Memorize?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Embed & Save Memory",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
},
|
|
"Has API Tokens?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Log Event API Usage",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
},
|
|
"Handle Calendar": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Has Calendar Insert?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Has Calendar Insert?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Save Calendar DB",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
},
|
|
"Handle Mail Query": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Mail DB Query",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Mail DB Query": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Format Mail Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Format Mail Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Handle Note": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Send to Synology Chat",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Respond to Webhook",
|
|
"type": "main",
|
|
"index": 0
|
|
},
|
|
{
|
|
"node": "Log to DB",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"callerPolicy": "workflowsFromSameOwner",
|
|
"availableInMCP": false
|
|
}
|
|
} |