diff --git a/.env.example b/.env.example index 6b607b4..3aba3a8 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ API_BUDGET_LIGHT=20.00 # Ollama (맥미니 — Docker 내부에서 접근) LOCAL_OLLAMA_URL=http://host.docker.internal:11434 -# Ollama (GPU 서버 — RTX 4070Ti Super) +# Ollama (GPU 서버 — RTX 4070Ti Super, 기본 모델: id-9b:latest) GPU_OLLAMA_URL=http://192.168.1.186:11434 # Qdrant (Docker 내부에서 접근) @@ -37,21 +37,25 @@ DSM_ACCOUNT=chatbot-api DSM_PASSWORD=changeme CHAT_CHANNEL_ID=17 -# CalDAV (caldav_bridge.py — Synology Calendar 연동) -CALDAV_BASE_URL=https://192.168.1.227:5001/caldav -CALDAV_USER=chatbot-api +# CalDAV (caldav_bridge.py — Synology Calendar, DSM HTTPS 포트=15001, 경로=caldav.php) +CALDAV_BASE_URL=https://192.168.1.227:15001/caldav.php +CALDAV_USER=hyungi CALDAV_PASSWORD=changeme -CALDAV_CALENDAR=chatbot +CALDAV_CALENDAR=home -# IMAP (메일 처리 파이프라인) +# IMAP (메일 처리 파이프라인, MailPlus 포트=21680) IMAP_HOST=192.168.1.227 -IMAP_PORT=993 -IMAP_USER=chatbot-api +IMAP_PORT=21680 +IMAP_USER=hyungi IMAP_PASSWORD=changeme # DEVONthink (devonthink_bridge.py — 지식 저장소) DEVONTHINK_APP_NAME=DEVONthink +# Karakeep (NAS Docker — 북마크/뉴스 저장) +KARAKEEP_URL=http://192.168.1.227:3000 +KARAKEEP_API_KEY=changeme + # Bridge Service URLs (n8n Docker → macOS 네이티브 서비스) HEIC_CONVERTER_URL=http://host.docker.internal:8090 CHAT_BRIDGE_URL=http://host.docker.internal:8091 diff --git a/CLAUDE.md b/CLAUDE.md index 6995bb1..4ea985a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,8 @@ DEVONthink 4 (맥미니): | bot-n8n | Docker (맥미니) | 5678 | 워크플로우 엔진 | | bot-postgres | Docker (맥미니) | 127.0.0.1:15478 | 설정/로그 DB | | Qdrant | Docker (맥미니, 기존) | 127.0.0.1:6333 | 벡터 DB (3컬렉션) | -| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3 (임베딩/리랭킹 전용) | -| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | qwen3.5:9b-q8_0 (분류+local응답) | +| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3 (임베딩/리랭킹 전용, brew services 자동기동) | +| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | id-9b:latest (이드 특화 분류+응답), qwen3.5:9b-q8_0 (레거시 백업) | | Claude Haiku Vision | Anthropic API | — | 사진 분석+구조화 (field_report, log_event) | | heic_converter | 네이티브 (맥미니) | 8090 | HEIC→JPEG 변환 (macOS sips) | | chat_bridge | 네이티브 (맥미니) | 8091 | DSM Chat API 브릿지 (사진 폴링/다운로드) | @@ -94,6 +94,7 @@ DEVONthink 4 (맥미니): |--------|------| | `documents` | 개인/일반 문서 + 메일 요약 + 뉴스 요약 | | `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 | +| `tk_qc_issues` | TechnicalKorea 품질 이슈 | | `chat_memory` | 가치 있는 대화 기억 (선택적 저장) | ## DB 스키마 (bot-postgres) @@ -160,3 +161,4 @@ DEVONthink 4 (맥미니): - response_tier는 Qwen v2 분류기 출력. 기존 complexity 기반 라우팅은 레거시 호환 - n8n v2.11+ Code 노드는 Task Runner 샌드박스(VM)에서 실행됨. `$http.request()`/`this.helpers.httpRequest()`/`fetch()`/`AbortController`/`URL` 사용 불가. `require('http')`/`require('https')`/`require('url')` 사용 (NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url). 각 Code 노드에 `httpPost`/`httpPut` 헬퍼 함수 인라인 정의 - 샌드박스 사용 가능: `Buffer`, `setTimeout`, `TextEncoder`, `FormData`, `$env`, `$input`, `$()`, `$getWorkflowStaticData()`, `$json`, `require('url').parse()` (new URL 불가), `console` +- id-9b:latest가 기본 GPU 모델. qwen3.5:9b-q8_0은 레거시 백업이며 검증 완료 후 삭제 예정 diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 12afe19..290fbb8 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -12,14 +12,15 @@ docker ps -a --filter "name=bot-" --format "table {{.Names}}\t{{.Status}}\t{{.Po docker ps --filter "name=qdrant" --format "table {{.Names}}\t{{.Status}}" curl -s http://localhost:6333/collections | python3 -c "import sys,json; [print(f' {c[\"name\"]}') for c in json.loads(sys.stdin.read())['result']['collections']]" -# 맥미니 Ollama 모델 확인 +# 맥미니 Ollama 모델 확인 (brew services로 자동기동, 임베딩/리랭킹 전용) +# 중지 상태일 때: brew services start ollama ollama list # GPU 서버 Ollama 상태 curl -s http://192.168.1.186:11434/api/tags | python3 -m json.tool -# GPU 서버 Qwen 9B 헬스체크 -curl -s http://192.168.1.186:11434/api/generate -d '{"model":"qwen3.5:9b-q8_0","prompt":"hi","stream":false}' | python3 -m json.tool +# GPU 서버 id-9b 헬스체크 +curl -s http://192.168.1.186:11434/api/generate -d '{"model":"id-9b:latest","prompt":"hi","stream":false}' | python3 -m json.tool ``` ## 접속 정보 @@ -112,6 +113,7 @@ syn-chat-bot/ ├── inbox_processor.py ← OmniFocus Inbox 폴링 (LaunchAgent, 5분) ├── news_digest.py ← 뉴스 번역·요약 (LaunchAgent, 매일 07:00) ├── manage_services.sh ← 네이티브 서비스 관리 (start/stop/status) +├── deploy_workflows.sh ← n8n 워크플로우 자동 배포 (REST API) ├── start-bridge.sh ← 브릿지 서비스 시작 헬퍼 ├── com.syn-chat-bot.*.plist ← LaunchAgent 설정 (6개) ├── docs/ @@ -140,7 +142,7 @@ curl http://<맥미니IP>:5678/webhook-test/chat curl http://localhost:11434/api/embeddings -d '{"model":"bge-m3","prompt":"test"}' # GPU 서버 분류 테스트 -curl http://192.168.1.186:11434/api/generate -d '{"model":"qwen3.5:9b-q8_0","prompt":"안녕하세요","stream":false}' +curl http://192.168.1.186:11434/api/generate -d '{"model":"id-9b:latest","prompt":"안녕하세요","stream":false}' # Qdrant 컬렉션 확인 curl http://localhost:6333/collections diff --git a/deploy_workflows.sh b/deploy_workflows.sh new file mode 100755 index 0000000..3f1acdc --- /dev/null +++ b/deploy_workflows.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# n8n REST API로 워크플로우 업데이트 +# 사용법: ./deploy_workflows.sh [--activate] +# 필요: N8N_API_KEY 환경변수 또는 .env 파일 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +N8N_URL="${N8N_URL:-http://localhost:5678}" +WORKFLOW_DIR="$SCRIPT_DIR/n8n/workflows" + +# .env에서 API 키 로드 +if [ -z "${N8N_API_KEY:-}" ] && [ -f "$SCRIPT_DIR/.env" ]; then + N8N_API_KEY=$(grep '^N8N_API_KEY=' "$SCRIPT_DIR/.env" | cut -d= -f2-) +fi +if [ -z "${N8N_API_KEY:-}" ]; then + echo "ERROR: N8N_API_KEY not set. Set it in .env or as environment variable." + exit 1 +fi + +AUTH_HEADER="X-N8N-API-KEY: $N8N_API_KEY" +ACTIVATE="${1:-}" + +# 워크플로우 이름 → ID 매핑 (n8n API에서 조회) +get_workflow_id() { + local name="$1" + curl -s -H "$AUTH_HEADER" "$N8N_URL/api/v1/workflows?limit=100" \ + | python3 -c " +import sys, json +data = json.loads(sys.stdin.read()) +for wf in data.get('data', []): + if wf['name'] == '$name': + print(wf['id']) + break +" +} + +deploy_workflow() { + local file="$1" + local name + name=$(python3 -c "import json; print(json.load(open('$file'))['name'])") + local wf_id + wf_id=$(get_workflow_id "$name") + + if [ -z "$wf_id" ]; then + echo " Creating new workflow: $name" + wf_id=$(curl -s -X POST "$N8N_URL/api/v1/workflows" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d @"$file" \ + | python3 -c "import sys, json; print(json.loads(sys.stdin.read())['id'])") + echo " Created: $wf_id" + else + echo " Updating workflow: $name (id=$wf_id)" + local http_code + http_code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "$N8N_URL/api/v1/workflows/$wf_id" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d @"$file") + if [ "$http_code" -ge 400 ]; then + echo " FAILED (HTTP $http_code)" + return 1 + fi + echo " Updated (HTTP $http_code)" + fi + + if [ "$ACTIVATE" = "--activate" ]; then + curl -s -X POST "$N8N_URL/api/v1/workflows/$wf_id/activate" \ + -H "$AUTH_HEADER" > /dev/null + echo " Activated" + fi +} + +echo "=== n8n Workflow Deploy ===" +echo "Target: $N8N_URL" +echo "" + +for file in "$WORKFLOW_DIR"/*.json; do + echo "[$(basename "$file")]" + deploy_workflow "$file" + echo "" +done + +echo "Done." diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f3b1288..7f51ae5 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -126,7 +126,7 @@ pip install caldav aiohttp ### 4. GPU 서버 (192.168.1.186) -- Ollama 설치 + `ollama pull qwen3.5:9b-q8_0` +- Ollama 설치 + `ollama pull id-9b:latest` (이드 특화 모델, 기존 qwen3.5:9b-q8_0은 레거시 백업) - 포트 11434 개방 ### 5. 환경 변수 diff --git a/inbox_processor.py b/inbox_processor.py index 4e32701..384bfe0 100644 --- a/inbox_processor.py +++ b/inbox_processor.py @@ -132,7 +132,7 @@ type 판단: try: resp = httpx.post( f"{GPU_OLLAMA_URL}/api/generate", - json={"model": "qwen3.5:9b-q8_0", "prompt": prompt, "stream": False, "format": "json", "think": False}, + json={"model": "id-9b:latest", "prompt": prompt, "stream": False, "format": "json", "think": False}, timeout=15, ) return json.loads(resp.json()["response"]) diff --git a/n8n/workflows/mail-processing-pipeline.json b/n8n/workflows/mail-processing-pipeline.json index 6de7d86..366aee5 100644 --- a/n8n/workflows/mail-processing-pipeline.json +++ b/n8n/workflows/mail-processing-pipeline.json @@ -36,7 +36,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 item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\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\nconst item = $input.first().json;\nconst prompt = `메일을 분류하고 요약하세요. JSON만 출력.\n\n{\n \"summary\": \"한국어 2~3문장 요약\",\n \"label\": \"업무|개인|광고|알림\",\n \"has_events\": true/false,\n \"has_tasks\": true/false\n}\n\n보낸 사람: ${item.from}\n제목: ${item.subject}\n본문: ${item.body.substring(0, 3000)}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n return [{ json: { ...item, summary: cls.summary || item.subject, label: cls.label || '알림', has_events: cls.has_events || false, has_tasks: cls.has_tasks || false } }];\n} catch(e) {\n return [{ json: { ...item, summary: item.subject, label: '알림', has_events: false, has_tasks: false } }];\n}" }, "id": "m1000001-0000-0000-0000-000000000003", "name": "Summarize & Classify", diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json index 9e0c225..0b484c7 100644 --- a/n8n/workflows/main-chat-pipeline.json +++ b/n8n/workflows/main-chat-pipeline.json @@ -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- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier: local(인사,잡담,감사,log_event,report,calendar,reminder,note), api_light(요약,번역,일반질문,mail), api_heavy(법률,복잡추론)\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt: classifierPrompt, stream: false, format: 'json', think: false },\n { timeout: 10000 }\n );\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, latency, fallback: false\n } }];\n} catch(e) {\n return [{ json: {\n intent: 'question', response_tier: 'api_light', needs_rag: false, rag_target: [],\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime, fallback: true\n } }];\n}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|log_event|calendar|reminder|mail|note|photo|command|report|other\",\n \"response_tier\": \"local|api_light|api_heavy\",\n \"needs_rag\": true/false,\n \"rag_target\": [\"documents\", \"tk_company\", \"chat_memory\"],\n \"department_hint\": \"안전|생산|구매|품질|총무|시설|null\",\n \"report_domain\": \"안전|시설설비|품질|null\",\n \"query\": \"검색용 쿼리 (needs_rag=false면 null)\"\n}\n\nintent 분류:\n- log_event: 사실 기록/등록 요청 (\"~구입\",\"~완료\",\"~교체\",\"~점검\",\"~수령\",\"~입고\",\"~등록\")\n- report: 긴급 사고/재해 신고만 (\"사고\",\"부상\",\"화재\",\"누수\",\"폭발\",\"붕괴\" + 즉각 대응 필요)\n- question: 정보 질문/조회\n- greeting: 인사/잡담/감사\n※ 애매하면 log_event로 분류 (기록 누락보다 안전)\n\n- calendar: 일정 등록/조회/삭제 (\"일정\",\"회의\",\"미팅\",\"약속\",\"~시에 ~등록\",\"오늘 일정\",\"내일 뭐 있어\")\n- reminder: 알림 설정 (\"~시에 알려줘\",\"리마인드\",\"~까지 알려줘\") → 현재 미지원, calendar로 처리\n- mail: 메일 관련 조회 (\"메일 확인\",\"받은 메일\",\"이메일\",\"메일 왔어?\")\n- note: 메모/기록 요청 (\"기록해\",\"메모해\",\"저장해\",\"적어둬\")\n\nresponse_tier: local(인사,잡담,감사,log_event,report,calendar,reminder,note), api_light(요약,번역,일반질문,mail), api_heavy(법률,복잡추론)\n\nneeds_rag 판단:\n- true: 회사문서/절차 질문, 이전 기록 조회(\"최근\",\"아까\",\"전에\",\"뭐였지\"), 기술질문\n- false: 인사, 잡담, 일반상식, log_event, report\nrag_target: documents(개인문서), tk_company(회사문서/구매/점검/안전/품질 조회), chat_memory(이전대화,\"아까\",\"최근\",\"기억\")\n\n사용자 메시지: ${userText}`;\n\ntry {\n const response = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: classifierPrompt, stream: false, format: 'json', think: false },\n { timeout: 10000 }\n );\n const latency = Date.now() - startTime;\n let cls = {};\n try { cls = JSON.parse(response.response); } catch(e) {}\n return [{ json: {\n intent: cls.intent || 'question', response_tier: cls.response_tier || 'api_light',\n needs_rag: cls.needs_rag || false, rag_target: Array.isArray(cls.rag_target) ? cls.rag_target : [],\n department_hint: cls.department_hint || null, report_domain: cls.report_domain || null,\n query: cls.query || userText, userText, username, latency, fallback: false\n } }];\n} catch(e) {\n return [{ json: {\n intent: 'question', response_tier: 'api_light', needs_rag: false, rag_target: [],\n department_hint: null, report_domain: null, query: userText,\n userText, username, latency: Date.now() - startTime, fallback: true\n } }];\n}" }, "id": "b1000001-0000-0000-0000-000000000020", "name": "Qwen Classify v2", @@ -431,7 +431,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}) }}'::jsonb, 'qwen3.5:9b-q8_0', {{ $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}) }}'::jsonb, 'id-9b:latest', {{ $json.latency }}, {{ $json.fallback }})", "options": {} }, "id": "b1000001-0000-0000-0000-000000000021", @@ -596,7 +596,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: 'qwen3.5:9b-q8_0', 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\nconst FIELD_REPORT_SYSTEM_PROMPT = `너는 산업 현장 신고를 분석하는 전문가다. 사진과 텍스트를 받아 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"domain\": \"안전 | 시설설비 | 품질\",\n \"category\": \"string (자유텍스트 — 예: 전기안전, 화재예방, 기계설비, 배관, 제품불량 등)\",\n \"severity\": \"상 | 중 | 하\",\n \"location\": \"string (언급된 장소, 없으면 빈 문자열)\",\n \"department\": \"string (언급된 부서, 없으면 빈 문자열)\",\n \"keywords\": [\"string\"],\n \"summary\": \"string (한줄 요약, 50자 이내)\",\n \"action_required\": \"string (필요 조치, 없으면 빈 문자열)\"\n}\n\n## 필드별 판단 기준\n\n### domain\n- **안전**: 인명 사고, 안전장비 미착용, 위험물 노출, 화재 위험, 추락 위험, 감전 위험\n- **시설설비**: 기계 고장, 배관 누수, 전기 설비 이상, 건물 파손, 공조 장치 문제\n- **품질**: 제품 불량, 원자재 품질 이상, 공정 이탈, 검사 부적합\n\n### severity (심각도)\n- **상**: 즉시 조치 필요. 인명 피해 우려, 가동 중단, 법적 위반. SLA: 안전 24h / 시설설비·품질 48h\n- **중**: 계획 조치 필요. 경미한 이상, 모니터링 대상. SLA: 안전 72h / 시설설비·품질 120h\n- **하**: 참고 사항. 개선 권고, 점검 기록. SLA: 안전 168h / 시설설비·품질 336h\n\n### keywords\n- 사진에서 식별된 장비/물체 + 텍스트에서 언급된 핵심 단어\n- 산업 현장 표준 한국어 용어 사용 (지게차, 컨베이어, 분전반, 소화기, 안전모 등)\n\n### summary\n- 사진 내용 + 사용자 메시지를 종합한 한줄 요약\n- 형식: \"[대상] [상태/문제]\" (예: \"2층 분전반 과열 흔적 발견\")\n\n## 출력 예시\n\n예시 1 — 사진: 안전모 미착용 작업자 / 텍스트: \"3층 현장 점검 중\"\n{\"domain\":\"안전\",\"category\":\"보호구\",\"severity\":\"상\",\"location\":\"3층\",\"department\":\"\",\"keywords\":[\"안전모\",\"미착용\",\"보호구\"],\"summary\":\"3층 현장 작업자 안전모 미착용 확인\",\"action_required\":\"해당 작업자 보호구 착용 지도 및 현장 안전 점검\"}\n\n예시 2 — 사진: 바닥 물웅덩이 / 텍스트: \"지하 기계실 배관\"\n{\"domain\":\"시설설비\",\"category\":\"배관\",\"severity\":\"중\",\"location\":\"지하 기계실\",\"department\":\"\",\"keywords\":[\"배관\",\"누수\",\"기계실\"],\"summary\":\"지하 기계실 배관 누수로 바닥 침수\",\"action_required\":\"배관 누수 지점 확인 및 보수\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText, username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst reportDomain = cls.report_domain || '안전';\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet photoBase64 = null;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n photoBase64 = photoResult.base64;\n }\n} catch(e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet structured;\nlet inputTokens = 0, outputTokens = 0;\nif (photoBase64) {\n // Haiku Vision — 분석 + 구조화 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n system: [{ type: 'text', text: FIELD_REPORT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '현장 신고: ' + userText }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n structured = JSON.parse(clean);\n photoAnalysis = structured.summary;\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '', parse_error: true };\n photoAnalysis = structured.summary;\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 구조화\n try {\n const sp = `현장 신고를 구조화. JSON만 응답.\\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\\n\\n신고: ${userText}`;\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: sp, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n structured = JSON.parse(r.response);\n } catch(e) {\n structured = { domain: reportDomain, category: '기타', severity: '중', location: '', department: '', keywords: [], summary: userText.substring(0,100), action_required: '' };\n }\n}\n\nconst sla = { '안전':{'상':24,'중':72,'하':168}, '시설설비':{'상':48,'중':120,'하':336}, '품질':{'상':48,'중':120,'하':336} };\nconst hours = sla[structured.domain]?.[structured.severity] || 120;\nconst dueAt = new Date(Date.now() + hours*3600000).toISOString();\n\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`,\n { model: 'bge-m3', prompt: structured.summary+' '+(structured.keywords||[]).join(' ') });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: Date.now(), vector: emb.embedding, payload: {\n text: `[현장리포트] ${structured.summary}`, department: structured.department,\n doc_type: 'report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n\\u26a0\\ufe0f 긴급 \\u2014 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nlet insertSQL = `INSERT INTO field_reports (domain,category,severity,location,department,keywords,summary,action_required,user_description,photo_url,photo_analysis,reporter,year,month,due_at) VALUES ('${safe(structured.domain)}','${safe(structured.category)}','${safe(structured.severity)}','${safe(structured.location)}','${safe(structured.department||'미지정')}',ARRAY[${kw}],'${safe(structured.summary)}','${safe(structured.action_required)}','${safe(userText).substring(0,1000)}',NULL,${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nif (photoBase64 && inputTokens > 0) {\n insertSQL += `; INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (${now.getFullYear()},${now.getMonth()+1},'api_light',1,${inputTokens},${outputTokens},${(inputTokens*0.8+outputTokens*4)/1000000}) ON CONFLICT (year,month,tier) DO UPDATE SET call_count=api_usage_monthly.call_count+1,total_input_tokens=api_usage_monthly.total_input_tokens+EXCLUDED.total_input_tokens,total_output_tokens=api_usage_monthly.total_output_tokens+EXCLUDED.total_output_tokens,estimated_cost=api_usage_monthly.estimated_cost+EXCLUDED.estimated_cost,updated_at=NOW()`;\n}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nreturn [{ json: { text: `${photoPrefix}접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : ''), insertSQL } }];" }, "id": "b1000001-0000-0000-0000-000000000047", "name": "Handle Field Report", @@ -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 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: 'qwen3.5:9b-q8_0', 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' : 'qwen3.5:9b-q8_0', 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\nconst LOG_EVENT_SYSTEM_PROMPT = `너는 산업 현장 업무 로그를 분석하는 전문가다. 사진과 텍스트를 받아 이벤트 정보를 추출하여 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"namespace\": \"string (조직명, 기본값: 테크니컬코리아)\",\n \"category\": \"안전 | 생산 | 구매 | 품질 | 총무 | 시설\",\n \"event_type\": \"구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\",\n \"item\": \"string (품목/대상, 자유텍스트)\",\n \"date\": \"YYYY-MM-DD\",\n \"summary\": \"string (한줄 요약, 품목+행위 포함)\"\n}\n\n## 필드별 판단 기준\n\n### namespace\n- 기본값: \"테크니컬코리아\"\n- 다른 조직이 언급되면 해당 조직명 사용\n\n### category (부서/업무 영역)\n어떤 부서 업무인지 기준으로 선택:\n- **안전**: 안전 관련 업무 (안전장비, 안전교육, 사고 등)\n- **생산**: 생산 라인 관련 (기계 가동, 생산량, 공정 등)\n- **구매**: 구매/발주 관련 (자재 구매, 발주, 견적 등)\n- **품질**: 품질 관련 (검사, 불량, 시험 등)\n- **총무**: 일반 관리 (비품, 문서, 행사 등)\n- **시설**: 시설/설비 관련 (수리, 점검, 설치 등)\n\n### event_type (행위/사건)\n어떤 행동/사건인지 기준으로 선택:\n- **구매**: 물품 구매, 발주\n- **점검**: 정기/수시 점검, 확인\n- **교체**: 부품/장비 교체\n- **입고**: 자재/물품 입고, 수령\n- **교육**: 안전교육, 직무교육\n- **등록**: 새 항목 등록, 기록\n- **완료**: 작업/프로젝트 완료\n- **사고**: 안전사고, 재해\n- **이상**: 설비 이상, 품질 이상\n\n### date 추론 규칙\n- 날짜 언급 없음 → 사용자 메시지에 포함된 오늘 날짜 사용\n- \"어제\" → 오늘 - 1일\n- \"그저께\" → 오늘 - 2일\n- \"지난주\" → 오늘 - 7일\n- 구체적 날짜 → 해당 날짜\n\n### item\n- 사진에서 식별된 품목/장비 + 텍스트에서 언급된 대상\n- 산업 현장 표준 한국어 용어 사용\n\n### summary\n- 품목 + 행위를 포함한 한줄 요약\n- 형식: \"[품목] [행위]\" (예: \"소화기 10개 입고 완료\")\n\n## 출력 예시\n\n예시 1 — 사진: 새 소화기 박스들 / 텍스트: \"소화기 들어왔어요\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"안전\",\"event_type\":\"입고\",\"item\":\"소화기\",\"date\":\"2026-03-12\",\"summary\":\"소화기 입고 완료\"}\n\n예시 2 — 사진: 컨베이어 벨트 수리 장면 / 텍스트: \"어제 2라인 벨트 교체했습니다\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"시설\",\"event_type\":\"교체\",\"item\":\"2라인 컨베이어 벨트\",\"date\":\"2026-03-11\",\"summary\":\"2라인 컨베이어 벨트 교체 완료\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst today = new Date().toISOString().split('T')[0];\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet hasPhoto = false;\nlet photoBase64 = null;\nlet inputTokens = 0, outputTokens = 0;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n hasPhoto = true;\n photoBase64 = photoResult.base64;\n }\n} catch (e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet extracted;\nif (photoBase64) {\n // Haiku Vision — 분석 + 추출 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n system: [{ type: 'text', text: LOG_EVENT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '사용자 메시지: ' + userText + '\\n오늘: ' + today }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n extracted = JSON.parse(clean);\n photoAnalysis = extracted.summary;\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 추출\n try {\n const extractPrompt = `사용자 메시지에서 이벤트 정보를 추출하세요. JSON만 응답.\\n\\n오늘 날짜: ${today}\\n\\nnamespace: 테크니컬코리아\\n (다른 조직이면 자유 입력)\\n\\ncategory (부서/업무 영역): 안전 | 생산 | 구매 | 품질 | 총무 | 시설\\n ※ \"어떤 부서 업무인지\" 기준으로 선택\\n\\nevent_type (무엇을 했는지/행위): 구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\\n ※ \"어떤 행동/사건인지\" 기준으로 선택\\n\\n{\\n \"namespace\": \"테크니컬코리아\",\\n \"category\": \"목록에서 선택\",\\n \"event_type\": \"목록에서 선택\",\\n \"item\": \"품목/대상 (자유텍스트)\",\\n \"date\": \"YYYY-MM-DD (언급 없으면 오늘 날짜, '어제'=오늘-1일, '지난주'=오늘-7일)\",\\n \"summary\": \"한줄 요약 (품목+행위 포함)\"\\n}\\n\\n사용자 메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: extractPrompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n extracted = JSON.parse(r.response);\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n }\n}\n\n// date 후처리\nif (!extracted.date || !/^\\d{4}-\\d{2}-\\d{2}$/.test(extracted.date) || isNaN(new Date(extracted.date).getTime())) {\n extracted.date = today;\n}\nif (!extracted.summary) extracted.summary = (extracted.item || userText.substring(0, 50)) + ' ' + (extracted.event_type || '등록');\nif (!extracted.item) extracted.item = userText.substring(0, 50);\n\n// 임베딩 대상 텍스트\nconst embText = `${extracted.summary} - ${extracted.namespace} ${extracted.category} ${extracted.event_type} ${extracted.date}`;\n\n// Qdrant 저장\nconst qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\nconst pointId = Date.now();\nconst year = parseInt(extracted.date.substring(0, 4)) || new Date().getFullYear();\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n await httpPut(`${qdrantUrl}/collections/tk_company/points`, { points: [{ id: pointId, vector: emb.embedding, payload: {\n text: embText,\n raw_text: userText,\n summary: extracted.summary,\n namespace: extracted.namespace || '테크니컬코리아',\n category: extracted.category,\n event_type: extracted.event_type,\n item: extracted.item,\n date: extracted.date,\n doc_type: 'log_event',\n department: extracted.category,\n source: 'chat',\n has_photo: hasPhoto,\n photo_analysis: photoAnalysis,\n uploaded_by: username,\n year: year,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nconst photoPrefix = photoAnalysis ? '[사진 확인] ' : '';\nconst photoSuffix = photoAnalysis ? `\\n\\u2014 사진 분석: ${photoAnalysis.substring(0, 100)}` : '';\nconst responseText = `${photoPrefix}${extracted.summary} 기록했습니다 (${extracted.namespace}/${extracted.category}/${extracted.event_type}, ${extracted.date})` + photoSuffix + (photoWarning ? '\\n\\u26a0\\ufe0f ' + photoWarning : '');\n\nreturn [{ json: { text: responseText, userText, username, response_tier: hasPhoto ? 'api_light' : 'local', intent: 'log_event', model: hasPhoto ? 'claude-haiku-4-5-20251001' : 'id-9b:latest', inputTokens, outputTokens } }];" }, "id": "b1000001-0000-0000-0000-000000000060", "name": "Handle Log Event", @@ -809,7 +809,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 || '';\nlet prompt = '당신은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.\\n간결하게 답하고, 모르면 솔직히 말하세요.\\n\\n';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model:'qwen3.5:9b-q8_0', prompt, stream:false, think: false },\n { timeout: 30000 }\n );\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'qwen3.5:9b-q8_0',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'qwen3.5:9b-q8_0',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n}" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst userText = data.userText || '';\nconst ragContext = data.ragContext || '';\nlet prompt = '당신은 \"이드\"입니다. 배려심 깊고 부드러운 존댓말을 사용하는 개인 어시스턴트입니다.\\n간결하게 답하고, 모르면 솔직히 말하세요.\\n\\n';\nif (ragContext) prompt += '[참고 자료]\\n' + ragContext + '\\n\\n';\nprompt += '사용자: ' + userText + '\\n이드:';\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model:'id-9b:latest', prompt, stream:false, think: false },\n { timeout: 30000 }\n );\n return [{json:{text:r.response||'죄송합니다, 응답을 생성하지 못했어요.',model:'id-9b:latest',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n} catch(e) {\n return [{json:{text:'잠시 응답이 어렵습니다.',model:'id-9b:latest',inputTokens:0,outputTokens:0,response_tier:'local',intent:data.intent,userText:data.userText,username:data.username}}];\n}" }, "id": "b1000001-0000-0000-0000-000000000028", "name": "Call Qwen Response", @@ -960,7 +960,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:'qwen3.5:9b-q8_0', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', 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\nconst ai = $json;\nconst userText = ai.userText || '', aiText = (ai.text||'').substring(0,500);\nif (ai.response_tier === 'local') return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}];\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model:'id-9b:latest', prompt:`대화 저장 가치 판단. JSON만.\n저장: 사실정보,결정사항,선호,지시,기술정보,구매/등록기록,일정,수량/금액\n무시: 인사,잡담,날씨,\"모른다\"고 답한것\n{\"save\":true/false,\"topic\":\"general|company|technical|personal\"}\n\nQ: ${userText}\nA: ${aiText}`, stream:false, format:'json', think: false },\n { timeout: 10000 }\n );\n let res; try{res=JSON.parse(r.response)}catch(e){res={save:false,topic:'general'}}\n return [{json:{save:res.save||false,topic:res.topic||'general',userText,aiText,username:ai.username,intent:ai.intent}}];\n} catch(e) { return [{json:{save:false,topic:'general',userText,aiText,username:ai.username,intent:ai.intent}}]; }" }, "id": "b1000001-0000-0000-0000-000000000035", "name": "Memorization Check", @@ -1071,7 +1071,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: 'qwen3.5:9b-q8_0', 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: 'qwen3.5:9b-q8_0', 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:9b-q8_0', 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:9b-q8_0', 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:9b-q8_0', 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:9b-q8_0', inputTokens: 0, outputTokens: 0 } }];\n\n } else {\n responseText = '일정 요청을 처리하지 못했습니다. 다시 말씀해주세요.';\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'qwen3.5:9b-q8_0', 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:9b-q8_0', 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\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst currentTime = now.toTimeString().split(' ')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\n// Qwen 3.5로 일정 정보 추출\nlet calData;\ntry {\n const extractPrompt = `현재: ${today} ${currentTime} (KST, ${dayOfWeek}요일). 아래 메시지에서 일정 정보를 추출하여 JSON으로 응답하세요. JSON만 출력.\n\n{\n \"action\": \"create|query|update|delete\",\n \"title\": \"일정 제목 (create/update 시)\",\n \"start\": \"YYYY-MM-DDTHH:MM:SS (ISO 형식)\",\n \"end\": \"YYYY-MM-DDTHH:MM:SS (없으면 null)\",\n \"location\": \"장소 (없으면 null)\",\n \"description\": \"설명 (없으면 null)\",\n \"uid\": \"기존 일정 uid (update/delete 시, 없으면 null)\",\n \"query_start\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 시작)\",\n \"query_end\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 끝)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"다음주 월요일\" = 적절히 계산\n- 시간 미지정 시: 업무 시간대면 09:00, 오후면 14:00 기본값\n- action=query이고 날짜 미지정: query_start=오늘 00:00, query_end=오늘 23:59\n- \"오늘 일정\" → action=query\n- \"내일 3시 회의\" → action=create\n\n메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', prompt: extractPrompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n calData = JSON.parse(r.response);\n} catch(e) {\n return [{ json: { text: '일정 정보를 파악하지 못했습니다. 다시 말씀해주세요.', userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst caldavUrl = $env.CALDAV_BRIDGE_URL || 'http://host.docker.internal:8092';\nlet responseText = '';\n\ntry {\n if (calData.action === 'create') {\n // CalDAV에 이벤트 생성\n let caldavResult;\n let caldavSynced = true;\n try {\n caldavResult = await httpPost(`${caldavUrl}/calendar/create`, {\n title: calData.title, start: calData.start, end: calData.end,\n location: calData.location, description: calData.description\n }, { timeout: 10000 });\n } catch(e) {\n caldavSynced = false;\n caldavResult = { success: false, uid: `local-${Date.now()}` };\n }\n\n const uid = caldavResult.uid || `local-${Date.now()}`;\n const safe = s => (s||'').replace(/'/g, \"''\");\n const startDt = new Date(calData.start);\n const endDt = calData.end ? new Date(calData.end) : new Date(startDt.getTime() + 3600000);\n\n const insertSQL = `INSERT INTO calendar_events (title,start_time,end_time,location,description,caldav_uid,created_by,source) VALUES ('${safe(calData.title)}','${startDt.toISOString()}','${endDt.toISOString()}',${calData.location?\"'\"+safe(calData.location)+\"'\":'NULL'},${calData.description?\"'\"+safe(calData.description)+\"'\":'NULL'},'${safe(uid)}','${safe(username)}','chat')`;\n\n const dateStr = `${startDt.getMonth()+1}월 ${startDt.getDate()}일 ${startDt.getHours()}시${startDt.getMinutes()>0?startDt.getMinutes()+'분':''}`;\n responseText = `'${calData.title}' ${dateStr}에 등록했습니다.`;\n if (calData.location) responseText += ` (${calData.location})`;\n if (!caldavSynced) responseText += '\\n\\u26a0\\ufe0f 일정은 기록했지만 캘린더 동기화에 실패했습니다.';\n\n return [{ json: { text: responseText, insertSQL, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'query') {\n const qStart = calData.query_start || `${today}T00:00:00`;\n const qEnd = calData.query_end || `${today}T23:59:59`;\n\n let events = [];\n try {\n const result = await httpPost(`${caldavUrl}/calendar/query`, { start: qStart, end: qEnd }, { timeout: 10000 });\n events = result.events || [];\n } catch(e) {\n // fallback: DB에서 조회\n }\n\n if (events.length === 0) {\n responseText = '등록된 일정이 없습니다.';\n } else {\n const lines = events.map(ev => {\n const s = new Date(ev.start);\n const timeStr = `${s.getHours()}:${String(s.getMinutes()).padStart(2,'0')}`;\n return `\\u2022 ${timeStr} ${ev.title}${ev.location ? ' ('+ev.location+')' : ''}`;\n });\n responseText = `일정 ${events.length}건:\\n${lines.join('\\n')}`;\n }\n\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'delete' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/delete`, { uid: calData.uid }, { timeout: 10000 });\n responseText = '일정을 삭제했습니다.';\n } catch(e) {\n responseText = '일정 삭제에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else if (calData.action === 'update' && calData.uid) {\n try {\n await httpPost(`${caldavUrl}/calendar/update`, {\n uid: calData.uid, title: calData.title, start: calData.start, end: calData.end, location: calData.location\n }, { timeout: 10000 });\n responseText = `'${calData.title || '일정'}' 변경했습니다.`;\n } catch(e) {\n responseText = '일정 변경에 실패했습니다: ' + (e.message||'').substring(0,100);\n }\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n\n } else {\n responseText = '일정 요청을 처리하지 못했습니다. 다시 말씀해주세요.';\n return [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n }\n} catch(e) {\n return [{ json: { text: '일정 처리 중 오류가 발생했습니다: ' + (e.message||'').substring(0,100), userText, username, response_tier: 'local', intent: 'calendar', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}" }, "id": "b1000001-0000-0000-0000-000000000063", "name": "Handle Calendar", @@ -1137,7 +1137,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: 'qwen3.5:9b-q8_0', 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: 'id-9b:latest', inputTokens: 0, outputTokens: 0, queryType, days } }];" }, "id": "b1000001-0000-0000-0000-000000000066", "name": "Handle Mail Query", @@ -1171,7 +1171,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: 'qwen3.5:9b-q8_0', 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:9b-q8_0', 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: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];\n}\n\nconst lines = items.map(r => {\n const j = r.json;\n const d = new Date(j.mail_date);\n const dateStr = `${d.getMonth()+1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`;\n const label = j.label ? `[${j.label}]` : '';\n return `\\u2022 ${dateStr} ${label} ${j.from_address}\\n ${j.subject}\\n ${j.summary ? j.summary.substring(0,80) : ''}`;\n});\n\nconst responseText = `최근 ${days}일 메일 ${items.length}건:\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text: responseText, userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];" }, "id": "b1000001-0000-0000-0000-000000000068", "name": "Format Mail Response", @@ -1184,7 +1184,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// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// DEVONthink에 저장\nconst dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\nlet saved = false;\ntry {\n const result = await httpPost(`${dtUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'markdown',\n tags: ['synology-chat', 'note']\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 DEVONthink 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:9b-q8_0', 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}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// DEVONthink에 저장\nconst dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\nlet saved = false;\ntry {\n const result = await httpPost(`${dtUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'markdown',\n tags: ['synology-chat', 'note']\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 DEVONthink 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'id-9b:latest', inputTokens: 0, outputTokens: 0 } }];" }, "id": "b1000001-0000-0000-0000-000000000069", "name": "Handle Note", diff --git a/news_digest.py b/news_digest.py index 569512b..e9f917b 100644 --- a/news_digest.py +++ b/news_digest.py @@ -104,7 +104,7 @@ def translate_and_summarize(title: str, content: str, lang: str) -> dict: resp = httpx.post( f"{GPU_OLLAMA_URL}/api/generate", json={ - "model": "qwen3.5:9b-q8_0", + "model": "id-9b:latest", "prompt": f"다음 기사를 2~3문장으로 요약하세요:\n\n제목: {title}\n본문: {content[:3000]}", "stream": False, "think": False,