From 113775496451d3404e86fc5efea3ae388975eaa9 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 19 Mar 2026 10:00:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20mlx-proxy=20=EC=84=9C=EB=B2=84=20+=20n8?= =?UTF-8?q?n=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20LLM/?= =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=94=A9=20URL=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mlx-vlm 기반 ollama 호환 프록시 서버 추가 (port 11435). n8n GEN 노드 6개에 callLLM 래퍼 주입 (health check + ollama fallback). 임베딩/리랭커는 ollama(LOCAL_EMBED_URL)로 분리. Co-Authored-By: Claude Opus 4.6 --- .env.example | 7 ++ com.mlx-proxy.plist | 27 ++++++ manage_services.sh | 1 + mlx_proxy.py | 113 ++++++++++++++++++++++++++ n8n/workflows/main-chat-pipeline.json | 28 +++---- 5 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 com.mlx-proxy.plist create mode 100644 mlx_proxy.py diff --git a/.env.example b/.env.example index 7f358eb..568887e 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ LOCAL_OLLAMA_URL=http://host.docker.internal:11434 # Ollama (GPU 서버 — RTX 4070Ti Super, 기본 모델: id-9b:latest) GPU_OLLAMA_URL=http://192.168.1.186:11434 +# mlx-proxy (맥미니 — LLM 생성용, ollama 호환, 기본 모델: qwen3.5:27b) +LOCAL_LLM_URL=http://host.docker.internal:11435 + +# 임베딩 전용 (ollama — bge-m3, bge-reranker) +LOCAL_EMBED_URL=http://host.docker.internal:11434 + # Qdrant (Docker 내부에서 접근) QDRANT_URL=http://host.docker.internal:6333 @@ -63,3 +69,4 @@ CHAT_BRIDGE_URL=http://host.docker.internal:8091 CALDAV_BRIDGE_URL=http://host.docker.internal:8092 DEVONTHINK_BRIDGE_URL=http://host.docker.internal:8093 MAIL_BRIDGE_URL=http://host.docker.internal:8094 +KB_WRITER_URL=http://host.docker.internal:8095 diff --git a/com.mlx-proxy.plist b/com.mlx-proxy.plist new file mode 100644 index 0000000..b805ed8 --- /dev/null +++ b/com.mlx-proxy.plist @@ -0,0 +1,27 @@ + + + + + Label + com.mlx-proxy + ProgramArguments + + /Users/hyungi/mlx-env/bin/uvicorn + mlx_proxy:app + --host + 0.0.0.0 + --port + 11435 + + WorkingDirectory + /Users/hyungi/Documents/code/syn-chat-bot + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/mlx-proxy.log + StandardErrorPath + /tmp/mlx-proxy.err + + diff --git a/manage_services.sh b/manage_services.sh index 2d204c2..2a20f2b 100755 --- a/manage_services.sh +++ b/manage_services.sh @@ -11,6 +11,7 @@ SERVICES=( "com.syn-chat-bot.mail-bridge" "com.syn-chat-bot.inbox-processor" "com.syn-chat-bot.news-digest" + "com.mlx-proxy" ) PLIST_DIR="$HOME/Library/LaunchAgents" diff --git a/mlx_proxy.py b/mlx_proxy.py new file mode 100644 index 0000000..ccf75aa --- /dev/null +++ b/mlx_proxy.py @@ -0,0 +1,113 @@ +"""mlx-vlm proxy — ollama 호환 API for mlx-vlm inference (port 11435) + +ollama 호환 /api/generate 엔드포인트 제공. n8n에서 투명하게 사용 가능. +Qwen3.5-27B-4bit를 mlx-vlm으로 서빙, thinking 자동 비활성화 (prefill 방식). +""" + +import asyncio +import logging +import time +from functools import partial + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("mlx_proxy") + +MODEL_PATH = "mlx-community/Qwen3.5-27B-4bit" +DISPLAY_NAME = "qwen3.5:27b" + +app = FastAPI() + +# Global model state — loaded once at startup +model = None +processor = None + + +@app.on_event("startup") +async def startup(): + global model, processor + logger.info(f"Loading {MODEL_PATH} ...") + from mlx_vlm import load + model, processor = load(MODEL_PATH) + logger.info("Model ready") + + +def _generate_sync( + prompt: str, system: str, max_tokens: int, temperature: float, is_json: bool +) -> dict: + """Blocking generation — runs in thread pool executor.""" + from mlx_vlm import generate as mlx_generate + from mlx_vlm.utils import apply_chat_template + + messages = [] + + # System prompt — strip /no_think (thinking handled by prefill) + sys_text = (system or "").replace("/no_think", "").strip() + if is_json and sys_text and "JSON" not in sys_text: + sys_text += "\nJSON으로만 응답하세요." + elif is_json and not sys_text: + sys_text = "JSON으로만 응답하세요." + if sys_text: + messages.append({"role": "system", "content": sys_text}) + + messages.append({"role": "user", "content": prompt}) + # Prefill: thinking 비활성화 — 모델이 단계 완료 상태에서 시작 + messages.append({"role": "assistant", "content": "\n\n\n"}) + + formatted = apply_chat_template(processor, model.config, messages) + + t0 = time.perf_counter() + output = mlx_generate( + model, processor, formatted, max_tokens=max_tokens, temp=temperature + ) + elapsed = time.perf_counter() - t0 + + output = output.strip() + + # Token count (approximate via tokenizer) + try: + n_tokens = len(processor.tokenizer.encode(output)) + except Exception: + n_tokens = max(1, len(output) // 3) + + return {"text": output, "n_tokens": n_tokens, "elapsed": elapsed} + + +@app.post("/api/generate") +async def api_generate(request: Request): + body = await request.json() + + prompt = body.get("prompt", "") + system = body.get("system", "") + opts = body.get("options", {}) + max_tokens = opts.get("num_predict", 2048) + temperature = opts.get("temperature", 0.7) + is_json = body.get("format") == "json" + + if not prompt: + return JSONResponse({"error": "prompt required"}, status_code=400) + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + partial(_generate_sync, prompt, system, max_tokens, temperature, is_json), + ) + except Exception as e: + logger.error(f"Generation failed: {e}") + return JSONResponse({"error": str(e)}, status_code=500) + + return { + "model": DISPLAY_NAME, + "response": result["text"], + "done": True, + "eval_count": result["n_tokens"], + "eval_duration": int(result["elapsed"] * 1e9), + } + + +@app.get("/health") +async def health(): + return {"status": "ok" if model is not None else "loading", "model": MODEL_PATH} diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json index 389a934..f2614b7 100644 --- a/n8n/workflows/main-chat-pipeline.json +++ b/n8n/workflows/main-chat-pipeline.json @@ -111,7 +111,7 @@ }, { "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 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst text = input.text;\nconst username = input.username;\nconst pendingDoc = input.pendingDoc;\nconst staticData = $getWorkflowStaticData('global');\ndelete staticData[`pendingDoc_${username}`];\n\nif (!text || text.length < 10) {\n return [{ json: { text: '문서 텍스트가 너무 짧습니다. 다시 시도해주세요.', saved: false } }];\n}\n\nconst chunks = [];\nconst chunkSize = 2000, overlap = 200;\nlet start = 0;\nwhile (start < text.length) {\n chunks.push(text.substring(start, Math.min(start + chunkSize, text.length)));\n if (start + chunkSize >= text.length) break;\n start += chunkSize - overlap;\n}\n\nconst encoder = new TextEncoder();\nconst hashBuf = await require('crypto').subtle.digest('SHA-256', encoder.encode(text));\nconst hash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst year = new Date().getFullYear();\nconst baseId = Date.now();\n\nfor (let i = 0; i < chunks.length; i++) {\n try {\n const embResp = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: chunks[i] });\n if (!embResp.embedding) continue;\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: baseId + i, vector: embResp.embedding, payload: {\n text: chunks[i], department: pendingDoc.department, doc_type: pendingDoc.docType,\n title: pendingDoc.title, source_file: `chat_upload_${username}`,\n file_hash: hash, chunk_index: i, total_chunks: chunks.length,\n uploaded_by: username, year, created_at: new Date().toISOString()\n }}]});\n } catch(e) {}\n}\n\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst insertSQL = `INSERT INTO document_ingestion_log (collection,source_file,file_hash,chunks_count,department,doc_type,year,uploaded_by,doc_group_key,status) VALUES ('tk_company','chat_upload_${safe(username)}','${hash}',${chunks.length},'${safe(pendingDoc.department)}','${safe(pendingDoc.docType)}',${year},'${safe(username)}','${safe(pendingDoc.department)}/${safe(pendingDoc.docType)}/${safe(pendingDoc.title)}','completed') ON CONFLICT (file_hash,collection) DO NOTHING`;\n\nreturn [{ json: { text: `문서 등록 완료\\n부서: ${pendingDoc.department} / 유형: ${pendingDoc.docType}\\n제목: ${pendingDoc.title}\\n청크: ${chunks.length}개`, insertSQL } }];" }, "id": "b1000001-0000-0000-0000-000000000041", "name": "Process Document", @@ -417,7 +417,7 @@ }, { "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 ※ \"매일\"은 \"메일\"의 오타일 수 있음 — \"매일 확인\",\"매일 왔어\" 등 문맥으로 판단\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier 판단:\n- local: 인사, 잡담, 감사, log_event, report, calendar, reminder, note, 단순 질문, 정의/개념 설명, 짧은 답변 가능한 질문, mail 간단조회\n- api_light: 장문 요약(200자 이상 텍스트), 다국어 번역, 비교 분석, RAG 결과 종합 정리\n- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석\n※ 판단이 애매하면 local 우선\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', system: '/no_think', 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 const t = userText;\n let intent = 'question';\n let response_tier = 'api_light';\n let needs_rag = false;\n let rag_target = [];\n\n if (/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /등록|잡아|추가|만들|넣어|수정|삭제|취소/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/일정|스케줄|뭐\\s*있/.test(t) && /오늘|내일|이번|다음/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/기록해|메모해|저장해|적어둬|메모\\s*저장|노트/.test(t)) {\n intent = 'note'; response_tier = 'local';\n } else if (/메일|이메일|받은\\s*편지|mail/.test(t) || (/매일/.test(t) && /확인|왔|온|요약|읽/.test(t))) {\n intent = 'mail'; response_tier = 'local';\n } else if (/\\d+시/.test(t) && /알려|리마인드|알림/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/구입|완료|교체|점검|수령|입고|발주/.test(t) && !/\\?|까$|나$/.test(t)) {\n intent = 'log_event'; response_tier = 'local';\n } else {\n if (userText.length <= 30 && !/요약|번역|분석|비교/.test(t)) {\n response_tier = 'local';\n }\n needs_rag = /회사|절차|문서|안전|품질|규정|아까|전에|기억/.test(t);\n if (needs_rag) {\n rag_target = ['documents'];\n if (/회사|절차|안전|품질/.test(t)) rag_target.push('tk_company');\n if (/아까|이전|전에|기억/.test(t)) rag_target.push('chat_memory');\n }\n }\n\n return [{ json: {\n intent, response_tier, needs_rag, rag_target,\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime,\n fallback: true, fallback_method: 'keyword'\n } }];\n}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|reminder|mail|note|photo|command|report|other\",\n \"response_tier\": \"local|api_light|api_heavy\",\n \"needs_rag\": true/false,\n \"rag_target\": [\"documents\", \"tk_company\", \"chat_memory\"],\n \"department_hint\": \"안전|생산|구매|품질|총무|시설|null\",\n \"report_domain\": \"안전|시설설비|품질|null\",\n \"query\": \"검색용 쿼리 (needs_rag=false면 null)\"\n}\n\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n ※ \"매일\"은 \"메일\"의 오타일 수 있음 — \"매일 확인\",\"매일 왔어\" 등 문맥으로 판단\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier 판단:\n- local: 인사, 잡담, 감사, log_event, report, calendar, reminder, note, 단순 질문, 정의/개념 설명, 짧은 답변 가능한 질문, mail 간단조회\n- api_light: 장문 요약(200자 이상 텍스트), 다국어 번역, 비교 분석, RAG 결과 종합 정리\n- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석\n※ 판단이 애매하면 local 우선\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: classifierPrompt, stream: false, format: 'json', think: false });\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, latency, fallback: false\n } }];\n} catch(e) {\n const t = userText;\n let intent = 'question';\n let response_tier = 'api_light';\n let needs_rag = false;\n let rag_target = [];\n\n if (/일정|회의|미팅|약속|스케줄|캘린더/.test(t) && /등록|잡아|추가|만들|넣어|수정|삭제|취소/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/일정|스케줄|뭐\\s*있/.test(t) && /오늘|내일|이번|다음/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/기록해|메모해|저장해|적어둬|메모\\s*저장|노트/.test(t)) {\n intent = 'note'; response_tier = 'local';\n } else if (/메일|이메일|받은\\s*편지|mail/.test(t) || (/매일/.test(t) && /확인|왔|온|요약|읽/.test(t))) {\n intent = 'mail'; response_tier = 'local';\n } else if (/\\d+시/.test(t) && /알려|리마인드|알림/.test(t)) {\n intent = 'calendar'; response_tier = 'local';\n } else if (/구입|완료|교체|점검|수령|입고|발주/.test(t) && !/\\?|까$|나$/.test(t)) {\n intent = 'log_event'; response_tier = 'local';\n } else {\n if (userText.length <= 30 && !/요약|번역|분석|비교/.test(t)) {\n response_tier = 'local';\n }\n needs_rag = /회사|절차|문서|안전|품질|규정|아까|전에|기억/.test(t);\n if (needs_rag) {\n rag_target = ['documents'];\n if (/회사|절차|안전|품질/.test(t)) rag_target.push('tk_company');\n if (/아까|이전|전에|기억/.test(t)) rag_target.push('chat_memory');\n }\n }\n\n return [{ json: {\n intent, response_tier, needs_rag, rag_target,\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime,\n fallback: true, fallback_method: 'keyword'\n } }];\n}" }, "id": "b1000001-0000-0000-0000-000000000020", "name": "Qwen Classify v2", @@ -444,7 +444,7 @@ { "parameters": { "operation": "executeQuery", - "query": "=INSERT INTO classification_logs (input_text, output_json, model, latency_ms, fallback_used) VALUES (LEFT('{{ $json.userText.replace(/'/g, \"''\") }}', 200), '{{ JSON.stringify({intent:$json.intent, response_tier:$json.response_tier, needs_rag:$json.needs_rag, rag_target:$json.rag_target, query:$json.query, department_hint:$json.department_hint, report_domain:$json.report_domain, fallback_method:$json.fallback_method||null}) }}'::jsonb, 'id-9b:latest', {{ $json.latency }}, {{ $json.fallback }})", + "query": "=INSERT INTO classification_logs (input_text, output_json, model, latency_ms, fallback_used) VALUES (LEFT('{{ $json.userText.replace(/'/g, \"''\") }}', 200), '{{ JSON.stringify({intent:$json.intent, response_tier:$json.response_tier, needs_rag:$json.needs_rag, rag_target:$json.rag_target, query:$json.query, department_hint:$json.department_hint, report_domain:$json.report_domain, fallback_method:$json.fallback_method||null}) }}'::jsonb, 'qwen3.5:27b', {{ $json.latency }}, {{ $json.fallback }})", "options": {} }, "id": "b1000001-0000-0000-0000-000000000021", @@ -609,7 +609,7 @@ }, { "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', system: '/no_think', 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 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst FIELD_REPORT_SYSTEM_PROMPT = `너는 산업 현장 신고를 분석하는 전문가다. 사진과 텍스트를 받아 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"domain\": \"안전 | 시설설비 | 품질\",\n \"category\": \"string (자유텍스트 — 예: 전기안전, 화재예방, 기계설비, 배관, 제품불량 등)\",\n \"severity\": \"상 | 중 | 하\",\n \"location\": \"string (언급된 장소, 없으면 빈 문자열)\",\n \"department\": \"string (언급된 부서, 없으면 빈 문자열)\",\n \"keywords\": [\"string\"],\n \"summary\": \"string (한줄 요약, 50자 이내)\",\n \"action_required\": \"string (필요 조치, 없으면 빈 문자열)\"\n}\n\n## 필드별 판단 기준\n\n### domain\n- **안전**: 인명 사고, 안전장비 미착용, 위험물 노출, 화재 위험, 추락 위험, 감전 위험\n- **시설설비**: 기계 고장, 배관 누수, 전기 설비 이상, 건물 파손, 공조 장치 문제\n- **품질**: 제품 불량, 원자재 품질 이상, 공정 이탈, 검사 부적합\n\n### severity (심각도)\n- **상**: 즉시 조치 필요. 인명 피해 우려, 가동 중단, 법적 위반. SLA: 안전 24h / 시설설비·품질 48h\n- **중**: 계획 조치 필요. 경미한 이상, 모니터링 대상. SLA: 안전 72h / 시설설비·품질 120h\n- **하**: 참고 사항. 개선 권고, 점검 기록. SLA: 안전 168h / 시설설비·품질 336h\n\n### keywords\n- 사진에서 식별된 장비/물체 + 텍스트에서 언급된 핵심 단어\n- 산업 현장 표준 한국어 용어 사용 (지게차, 컨베이어, 분전반, 소화기, 안전모 등)\n\n### summary\n- 사진 내용 + 사용자 메시지를 종합한 한줄 요약\n- 형식: \"[대상] [상태/문제]\" (예: \"2층 분전반 과열 흔적 발견\")\n\n## 출력 예시\n\n예시 1 — 사진: 안전모 미착용 작업자 / 텍스트: \"3층 현장 점검 중\"\n{\"domain\":\"안전\",\"category\":\"보호구\",\"severity\":\"상\",\"location\":\"3층\",\"department\":\"\",\"keywords\":[\"안전모\",\"미착용\",\"보호구\"],\"summary\":\"3층 현장 작업자 안전모 미착용 확인\",\"action_required\":\"해당 작업자 보호구 착용 지도 및 현장 안전 점검\"}\n\n예시 2 — 사진: 바닥 물웅덩이 / 텍스트: \"지하 기계실 배관\"\n{\"domain\":\"시설설비\",\"category\":\"배관\",\"severity\":\"중\",\"location\":\"지하 기계실\",\"department\":\"\",\"keywords\":[\"배관\",\"누수\",\"기계실\"],\"summary\":\"지하 기계실 배관 누수로 바닥 침수\",\"action_required\":\"배관 누수 지점 확인 및 보수\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText, username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst reportDomain = cls.report_domain || '안전';\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet photoBase64 = null;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n photoBase64 = photoResult.base64;\n }\n} catch(e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet structured;\nlet inputTokens = 0, outputTokens = 0;\nif (photoBase64) {\n // Haiku Vision — 분석 + 구조화 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n system: [{ type: 'text', text: FIELD_REPORT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '현장 신고: ' + userText }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n structured = JSON.parse(clean);\n photoAnalysis = structured.summary;\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '', parse_error: true };\n photoAnalysis = structured.summary;\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 구조화\n try {\n const sp = `현장 신고를 구조화. JSON만 응답.\\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\\n\\n신고: ${userText}`;\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: sp, stream: false, format: 'json', think: false });\n structured = JSON.parse(r.response);\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '' };\n }\n}\n\nconst sla = { '안전':{'상':24,'중':72,'하':168}, '시설설비':{'상':48,'중':120,'하':336}, '품질':{'상':48,'중':120,'하':336} };\nconst hours = sla[structured.domain]?.[structured.severity] || 120;\nconst dueAt = new Date(Date.now() + hours*3600000).toISOString();\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`,\n { model: 'bge-m3', prompt: structured.summary+' '+(structured.keywords||[]).join(' ') });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: Date.now(), vector: emb.embedding, payload: {\n text: `[현장리포트] ${structured.summary}`, department: structured.department,\n doc_type: 'report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n\\u26a0\\ufe0f 긴급 \\u2014 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nlet insertSQL = `INSERT INTO field_reports (domain,category,severity,location,department,keywords,summary,action_required,user_description,photo_url,photo_analysis,reporter,year,month,due_at) VALUES ('${safe(structured.domain)}','${safe(structured.category)}','${safe(structured.severity)}','${safe(structured.location)}','${safe(structured.department||'미지정')}',ARRAY[${kw}],'${safe(structured.summary)}','${safe(structured.action_required)}','${safe(userText).substring(0,1000)}',NULL,${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nif (photoBase64 && inputTokens > 0) {\n insertSQL += `; INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (${now.getFullYear()},${now.getMonth()+1},'api_light',1,${inputTokens},${outputTokens},${(inputTokens*0.8+outputTokens*4)/1000000}) ON CONFLICT (year,month,tier) DO UPDATE SET call_count=api_usage_monthly.call_count+1,total_input_tokens=api_usage_monthly.total_input_tokens+EXCLUDED.total_input_tokens,total_output_tokens=api_usage_monthly.total_output_tokens+EXCLUDED.total_output_tokens,estimated_cost=api_usage_monthly.estimated_cost+EXCLUDED.estimated_cost,updated_at=NOW()`;\n}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nreturn [{ json: { text: `${photoPrefix}접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : ''), insertSQL } }];" }, "id": "b1000001-0000-0000-0000-000000000047", "name": "Handle Field Report", @@ -622,7 +622,7 @@ }, { "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', system: '/no_think', 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 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst LOG_EVENT_SYSTEM_PROMPT = `너는 산업 현장 업무 로그를 분석하는 전문가다. 사진과 텍스트를 받아 이벤트 정보를 추출하여 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"namespace\": \"string (조직명, 기본값: 테크니컬코리아)\",\n \"category\": \"안전 | 생산 | 구매 | 품질 | 총무 | 시설\",\n \"event_type\": \"구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\",\n \"item\": \"string (품목/대상, 자유텍스트)\",\n \"date\": \"YYYY-MM-DD\",\n \"summary\": \"string (한줄 요약, 품목+행위 포함)\"\n}\n\n## 필드별 판단 기준\n\n### namespace\n- 기본값: \"테크니컬코리아\"\n- 다른 조직이 언급되면 해당 조직명 사용\n\n### category (부서/업무 영역)\n어떤 부서 업무인지 기준으로 선택:\n- **안전**: 안전 관련 업무 (안전장비, 안전교육, 사고 등)\n- **생산**: 생산 라인 관련 (기계 가동, 생산량, 공정 등)\n- **구매**: 구매/발주 관련 (자재 구매, 발주, 견적 등)\n- **품질**: 품질 관련 (검사, 불량, 시험 등)\n- **총무**: 일반 관리 (비품, 문서, 행사 등)\n- **시설**: 시설/설비 관련 (수리, 점검, 설치 등)\n\n### event_type (행위/사건)\n어떤 행동/사건인지 기준으로 선택:\n- **구매**: 물품 구매, 발주\n- **점검**: 정기/수시 점검, 확인\n- **교체**: 부품/장비 교체\n- **입고**: 자재/물품 입고, 수령\n- **교육**: 안전교육, 직무교육\n- **등록**: 새 항목 등록, 기록\n- **완료**: 작업/프로젝트 완료\n- **사고**: 안전사고, 재해\n- **이상**: 설비 이상, 품질 이상\n\n### date 추론 규칙\n- 날짜 언급 없음 → 사용자 메시지에 포함된 오늘 날짜 사용\n- \"어제\" → 오늘 - 1일\n- \"그저께\" → 오늘 - 2일\n- \"지난주\" → 오늘 - 7일\n- 구체적 날짜 → 해당 날짜\n\n### item\n- 사진에서 식별된 품목/장비 + 텍스트에서 언급된 대상\n- 산업 현장 표준 한국어 용어 사용\n\n### summary\n- 품목 + 행위를 포함한 한줄 요약\n- 형식: \"[품목] [행위]\" (예: \"소화기 10개 입고 완료\")\n\n## 출력 예시\n\n예시 1 — 사진: 새 소화기 박스들 / 텍스트: \"소화기 들어왔어요\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"안전\",\"event_type\":\"입고\",\"item\":\"소화기\",\"date\":\"2026-03-12\",\"summary\":\"소화기 입고 완료\"}\n\n예시 2 — 사진: 컨베이어 벨트 수리 장면 / 텍스트: \"어제 2라인 벨트 교체했습니다\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"시설\",\"event_type\":\"교체\",\"item\":\"2라인 컨베이어 벨트\",\"date\":\"2026-03-11\",\"summary\":\"2라인 컨베이어 벨트 교체 완료\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst today = new Date().toISOString().split('T')[0];\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet hasPhoto = false;\nlet photoBase64 = null;\nlet inputTokens = 0, outputTokens = 0;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n hasPhoto = true;\n photoBase64 = photoResult.base64;\n }\n} catch (e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet extracted;\nif (photoBase64) {\n // Haiku Vision — 분석 + 추출 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n system: [{ type: 'text', text: LOG_EVENT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '사용자 메시지: ' + userText + '\\n오늘: ' + today }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n extracted = JSON.parse(clean);\n photoAnalysis = extracted.summary;\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 추출\n try {\n const extractPrompt = `사용자 메시지에서 이벤트 정보를 추출하세요. JSON만 응답.\\n\\n오늘 날짜: ${today}\\n\\nnamespace: 테크니컬코리아\\n (다른 조직이면 자유 입력)\\n\\ncategory (부서/업무 영역): 안전 | 생산 | 구매 | 품질 | 총무 | 시설\\n ※ \"어떤 부서 업무인지\" 기준으로 선택\\n\\nevent_type (무엇을 했는지/행위): 구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\\n ※ \"어떤 행동/사건인지\" 기준으로 선택\\n\\n{\\n \"namespace\": \"테크니컬코리아\",\\n \"category\": \"목록에서 선택\",\\n \"event_type\": \"목록에서 선택\",\\n \"item\": \"품목/대상 (자유텍스트)\",\\n \"date\": \"YYYY-MM-DD (언급 없으면 오늘 날짜, '어제'=오늘-1일, '지난주'=오늘-7일)\",\\n \"summary\": \"한줄 요약 (품목+행위 포함)\"\\n}\\n\\n사용자 메시지: ${userText}`;\n\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: extractPrompt, stream: false, format: 'json', think: false });\n extracted = JSON.parse(r.response);\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n }\n}\n\n// date 후처리\nif (!extracted.date || !/^\\d{4}-\\d{2}-\\d{2}$/.test(extracted.date) || isNaN(new Date(extracted.date).getTime())) {\n extracted.date = today;\n}\nif (!extracted.summary) extracted.summary = (extracted.item || userText.substring(0, 50)) + ' ' + (extracted.event_type || '등록');\nif (!extracted.item) extracted.item = userText.substring(0, 50);\n\n// 임베딩 대상 텍스트\nconst embText = `${extracted.summary} - ${extracted.namespace} ${extracted.category} ${extracted.event_type} ${extracted.date}`;\n\n// Qdrant 저장\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst pointId = Date.now();\nconst year = parseInt(extracted.date.substring(0, 4)) || new Date().getFullYear();\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: pointId, vector: emb.embedding, payload: {\n text: embText,\n raw_text: userText,\n summary: extracted.summary,\n namespace: extracted.namespace || '테크니컬코리아',\n category: extracted.category,\n event_type: extracted.event_type,\n item: extracted.item,\n date: extracted.date,\n doc_type: 'log_event',\n department: extracted.category,\n source: 'chat',\n has_photo: hasPhoto,\n photo_analysis: photoAnalysis,\n uploaded_by: username,\n year: year,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nconst responseText = `${photoPrefix}${extracted.summary} 기록했습니다 (${extracted.namespace}/${extracted.category}/${extracted.event_type}, ${extracted.date})` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : '');\n\nreturn [{ json: { text: responseText, userText, username, response_tier: hasPhoto ? 'api_light' : 'local', intent: 'log_event', model: hasPhoto ? 'claude-haiku-4-5-20251001' : 'qwen3.5:27b', inputTokens, outputTokens } }];" }, "id": "b1000001-0000-0000-0000-000000000060", "name": "Handle Log Event", @@ -688,7 +688,7 @@ }, { "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 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst query = $('Qwen Classify v2').first().json.query;\nconst response = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: query });\nreturn [{ json: { embedding: response.embedding } }];" }, "id": "b1000001-0000-0000-0000-000000000023", "name": "Get Embedding", @@ -701,7 +701,7 @@ }, { "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}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst embedding = $input.first().json.embedding;\nconst ragTargets = cls.rag_target && cls.rag_target.length > 0 ? cls.rag_target : ['documents'];\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst limitMap = {1:10,2:7,3:5};\nconst limit = limitMap[Math.min(ragTargets.length,3)] || 5;\nlet allResults = [];\nfor (const col of ragTargets) {\n let filter;\n if (col === 'tk_company' && cls.department_hint) filter = { must: [{ key: 'department', match: { value: cls.department_hint } }] };\n else if (col === 'chat_memory') filter = { must: [{ key: 'username', match: { value: cls.username } }] };\n try {\n const body = { vector: embedding, limit, with_payload: true };\n if (filter) body.filter = filter;\n const resp = await httpPost(`${qdrantUrl}/collections/${col}/points/search`, body, { timeout: 10000 });\n allResults = allResults.concat((resp.result||[]).map(r => ({ score: r.score, text: r.payload?.text||'', collection: col, payload: r.payload })));\n } catch(e) {}\n}\ntry {\n const cands = allResults.slice(0,10), reranked = [];\n for (const doc of cands) {\n const r = await httpPost(`${$env.LOCAL_EMBED_URL}/api/generate`,\n { model:'bge-reranker-v2-m3', prompt:`query: ${cls.query}\\ndocument: ${doc.text.substring(0,500)}`, stream:false },\n { timeout: 3000 }\n );\n reranked.push({...doc, rerank_score: parseFloat(r.response)||doc.score});\n }\n return [{json:{results:reranked.sort((a,b)=>b.rerank_score-a.rerank_score).slice(0,3)}}];\n} catch(e) {\n return [{json:{results:allResults.sort((a,b)=>b.score-a.score).slice(0,3)}}];\n}" }, "id": "b1000001-0000-0000-0000-000000000024", "name": "Multi-Collection Search", @@ -822,7 +822,7 @@ }, { "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 || '';\nconst systemPrompt = '/no_think\\n당신은 \"이드\"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.\\n간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.';\nlet prompt = '';\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', system: systemPrompt, 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}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nconst systemPrompt = '/no_think\\n당신은 \"이드\"입니다. 함께 생각하는 개인 어시스턴트. 배려심 깊고 부드러운 존댓말.\\n간결하게 답하고, 의견이 있으면 부드럽게 제안. 모르면 솔직히. 이모지는 핵심에만.';\nlet prompt = '';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await callLLM({ model:'qwen3.5:27b-q4_K_M', system: systemPrompt, prompt, stream:false, think: false });\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'qwen3.5:27b',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n}" }, "id": "b1000001-0000-0000-0000-000000000028", "name": "Call Qwen Response", @@ -973,7 +973,7 @@ }, { "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', system:'/no_think', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', think: false },\n { 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}}]; }" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst ai = $json;\nconst userText = ai.userText || '', aiText = (ai.text||'').substring(0,500);\nif (ai.response_tier === 'local') return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}];\ntry {\n const r = await callLLM({ model:'qwen3.5:27b-q4_K_M', system:'/no_think', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', think: false });\n let res; try{res=JSON.parse(r.response)}catch(e){res={save:false,topic:'general'}}\n return [{json:{save:res.save||false,topic:res.topic||'general',userText,aiText,username:ai.username,intent:ai.intent}}];\n} catch(e) { return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}]; }" }, "id": "b1000001-0000-0000-0000-000000000035", "name": "Memorization Check", @@ -1018,7 +1018,7 @@ }, { "parameters": { - "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst pid = $('Set pid').first().json.pid || Date.now();\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\nconst qu = $env.QDRANT_URL||'http://host.docker.internal:6333';\nawait httpPut(`${qu}/collections/chat_memory/points`, { points:[{ id:pid, vector:emb.embedding, payload:{\n text:prompt, feature:'chat', intent:data.intent||'unknown',\n username:data.username||'unknown', topic:data.topic||'general', timestamp:pid\n}}]});\n// kb_writer 파일 저장 (graceful)\ntry {\n const kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\n await httpPost(`${kbUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'chat-memory',\n tags: ['chat-memory', data.topic || 'general'],\n username: data.username || 'unknown',\n topic: data.topic || 'general',\n qdrant_id: pid\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst pid = $('Set pid').first().json.pid || Date.now();\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\nconst qu = $env.QDRANT_URL||'http://host.docker.internal:6333';\nawait httpPut(`${qu}/collections/chat_memory/points`, { points:[{ id:pid, vector:emb.embedding, payload:{\n text:prompt, feature:'chat', intent:data.intent||'unknown',\n username:data.username||'unknown', topic:data.topic||'general', timestamp:pid\n}}]});\n// kb_writer 파일 저장 (graceful)\ntry {\n const kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\n await httpPost(`${kbUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'chat-memory',\n tags: ['chat-memory', data.topic || 'general'],\n username: data.username || 'unknown',\n topic: data.topic || 'general',\n qdrant_id: pid\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];" }, "id": "b1000001-0000-0000-0000-000000000037", "name": "Embed & Save Memory", @@ -1084,7 +1084,7 @@ }, { "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', system: '/no_think', 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}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpGet(url, { timeout = 5000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.get({ hostname: u.hostname, port: u.port, path: u.path, timeout }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve(body));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n });\n}\n\nasync function callLLM(body) {\n const LLM = $env.LOCAL_LLM_URL || 'http://host.docker.internal:11435';\n const FB = $env.LOCAL_EMBED_URL || 'http://host.docker.internal:11434';\n try {\n await httpGet(LLM + '/health', { timeout: 3000 });\n return await httpPost(LLM + '/api/generate', body, { timeout: 120000 });\n } catch(e) {\n return await httpPost(FB + '/api/generate', body, { timeout: 120000 });\n }\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst currentTime = now.toTimeString().split(' ')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\n// Qwen 3.5로 일정 정보 추출\nlet calData;\ntry {\n const extractPrompt = `현재: ${today} ${currentTime} (KST, ${dayOfWeek}요일). 아래 메시지에서 일정 정보를 추출하여 JSON으로 응답하세요. JSON만 출력.\n\n{\n \"action\": \"create|query|update|delete\",\n \"title\": \"일정 제목 (create/update 시)\",\n \"start\": \"YYYY-MM-DDTHH:MM:SS (ISO 형식)\",\n \"end\": \"YYYY-MM-DDTHH:MM:SS (없으면 null)\",\n \"location\": \"장소 (없으면 null)\",\n \"description\": \"설명 (없으면 null)\",\n \"uid\": \"기존 일정 uid (update/delete 시, 없으면 null)\",\n \"query_start\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 시작)\",\n \"query_end\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 끝)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"다음주 월요일\" = 적절히 계산\n- 시간 미지정 시: 업무 시간대면 09:00, 오후면 14:00 기본값\n- action=query이고 날짜 미지정: query_start=오늘 00:00, query_end=오늘 23:59\n- \"오늘 일정\" → action=query\n- \"내일 3시 회의\" → action=create\n\n메시지: ${userText}`;\n\n const r = await callLLM({ model: 'qwen3.5:27b-q4_K_M', system: '/no_think', prompt: extractPrompt, stream: false, format: 'json', think: false });\n calData = JSON.parse(r.response);\n} catch(e) {\n return [{ json: { text: '일정 정보를 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst caldavUrl = $env.CALDAV_BRIDGE_URL || 'http://host.docker.internal:8092';\nlet responseText = '';\n\ntry {\n if (calData.action === 'create') {\n // CalDAV에 이벤트 생성\n let caldavResult;\n let caldavSynced = true;\n try {\n caldavResult = await httpPost(`${caldavUrl}/calendar/create`, {\n title: calData.title, start: calData.start, end: calData.end,\n location: calData.location, description: calData.description\n }, { timeout: 10000 });\n } catch(e) {\n caldavSynced = false;\n caldavResult = { success: false, uid: `local-${Date.now()}` };\n }\n\n const uid = caldavResult.uid || `local-${Date.now()}`;\n const safe = s => (s||'').replace(/'/g, \"''\");\n const startDt = new Date(calData.start);\n const endDt = calData.end ? new Date(calData.end) : new Date(startDt.getTime() + 3600000);\n\n const insertSQL = `INSERT INTO calendar_events (title,start_time,end_time,location,description,caldav_uid,created_by,source) VALUES ('${safe(calData.title)}','${startDt.toISOString()}','${endDt.toISOString()}',${calData.location?\"'\"+safe(calData.location)+\"'\":'NULL'},${calData.description?\"'\"+safe(calData.description)+\"'\":'NULL'},'${safe(uid)}','${safe(username)}','chat')`;\n\n const dateStr = `${startDt.getMonth()+1}월 ${startDt.getDate()}일 ${startDt.getHours()}시${startDt.getMinutes()>0?startDt.getMinutes()+'분':''}`;\n responseText = `'${calData.title}' ${dateStr}에 등록했습니다.`;\n if (calData.location) responseText += ` (${calData.location})`;\n if (!caldavSynced) responseText += '\\n\\u26a0\\ufe0f 일정은 기록했지만 캘린더 동기화에 실패했습니다.';\n\n return [{ json: { text: responseText, insertSQL, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'query') {\n const qStart = calData.query_start || `${today}T00:00:00`;\n const qEnd = calData.query_end || `${today}T23:59:59`;\n\n let events = [];\n try {\n const result = await httpPost(`${caldavUrl}/calendar/query`, { start: qStart, end: qEnd }, { timeout: 10000 });\n events = result.events || [];\n } catch(e) {\n // fallback: DB에서 조회\n }\n\n if (events.length === 0) {\n responseText = '등록된 일정이 없습니다.';\n } else {\n const lines = events.map(ev => {\n const s = new Date(ev.start);\n const timeStr = `${s.getHours()}:${String(s.getMinutes()).padStart(2,'0')}`;\n return `\\u2022 ${timeStr} ${ev.title}${ev.location ? ' ('+ev.location+')' : ''}`;\n });\n responseText = `일정 ${events.length}건:\\n${lines.join('\\n')}`;\n }\n\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'delete' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/delete`, { uid: calData.uid }, { timeout: 10000 });\n responseText = '일정을 삭제했습니다.';\n } catch(e) {\n responseText = '일정 삭제에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'update' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/update`, {\n uid: calData.uid, title: calData.title, start: calData.start, end: calData.end, location: calData.location\n }, { timeout: 10000 });\n responseText = `'${calData.title || '일정'}' 변경했습니다.`;\n } catch(e) {\n responseText = '일정 변경에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n\n } else {\n responseText = '일정 요청을 처리하지 못했습니다. 다시 말씀해주세요.';\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n }\n} catch(e) {\n return [{ json: { text: '일정 처리 중 오류가 발생했습니다: ' + (e.message||'').substring(0,100), userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}" }, "id": "b1000001-0000-0000-0000-000000000063", "name": "Handle Calendar", @@ -1150,7 +1150,7 @@ }, { "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 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// Qwen으로 메일 조회 의도 파악\nlet queryType = 'recent'; // recent | search\nlet searchQuery = userText;\nlet days = 1;\n\nconst text = userText.toLowerCase();\nif (text.includes('오늘')) days = 1;\nelse if (text.includes('이번 주') || text.includes('이번주')) days = 7;\nelse if (text.includes('최근')) days = 3;\n\n// DB에서 직접 조회\nconst now = new Date();\nconst since = new Date(now.getTime() - days * 86400000).toISOString();\n\n// mail_logs에서 최근 메일 조회 SQL\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst selectSQL = `SELECT from_address, subject, summary, label, mail_date FROM mail_logs WHERE mail_date >= '${since}' ORDER BY mail_date DESC LIMIT 10`;\n\nreturn [{ json: { text: '', selectSQL, userText, username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0, queryType, days } }];" }, "id": "b1000001-0000-0000-0000-000000000066", "name": "Handle Mail Query", @@ -1186,7 +1186,7 @@ }, { "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 } }];" + "jsCode": "const items = $input.all();\nconst prev = $('Handle Mail Query').first().json;\nconst days = prev.days || 1;\n\nif (!items || items.length === 0 || !items[0].json.from_address) {\n return [{ json: { text: '받은 메일이 없습니다.', userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst lines = items.map(r => {\n const j = r.json;\n const d = new Date(j.mail_date);\n const dateStr = `${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;\n const label = j.label ? `[${j.label}]` : '';\n return `\\u2022 ${dateStr} ${label} ${j.from_address}\\n ${j.subject}\\n ${j.summary ? j.summary.substring(0,80) : ''}`;\n});\n\nconst responseText = `최근 ${days}일 메일 ${items.length}건:\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text: responseText, userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];" }, "id": "b1000001-0000-0000-0000-000000000068", "name": "Format Mail Response", @@ -1199,7 +1199,7 @@ }, { "parameters": { - "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst pid = $json.pid || Date.now();\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// kb_writer 파일 저장\nconst kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\nlet saved = false;\ntry {\n const result = await httpPost(`${kbUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'note',\n tags: ['synology-chat', 'note'],\n username: username || 'unknown',\n topic: 'general',\n qdrant_id: pid\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\n// Qdrant 임베딩 (graceful)\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: content });\n if (emb.embedding && Array.isArray(emb.embedding)) {\n const qu = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qu}/collections/chat_memory/points`, { points: [{ id: pid, vector: emb.embedding, payload: {\n text: content, feature: 'note', intent: 'note',\n username: username || 'unknown', topic: 'general', timestamp: pid\n }}]});\n }\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst pid = $json.pid || Date.now();\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// kb_writer 파일 저장\nconst kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\nlet saved = false;\ntry {\n const result = await httpPost(`${kbUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'note',\n tags: ['synology-chat', 'note'],\n username: username || 'unknown',\n topic: 'general',\n qdrant_id: pid\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\n// Qdrant 임베딩 (graceful)\ntry {\n const emb = await httpPost(`${$env.LOCAL_EMBED_URL}/api/embeddings`, { model: 'bge-m3', prompt: content });\n if (emb.embedding && Array.isArray(emb.embedding)) {\n const qu = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qu}/collections/chat_memory/points`, { points: [{ id: pid, vector: emb.embedding, payload: {\n text: content, feature: 'note', intent: 'note',\n username: username || 'unknown', topic: 'general', timestamp: pid\n }}]});\n }\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:27b', inputTokens: 0, outputTokens: 0 } }];" }, "id": "b1000001-0000-0000-0000-000000000069", "name": "Handle Note",