diff --git a/.env.example b/.env.example index 3aba3a8..7f358eb 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,7 @@ IMAP_HOST=192.168.1.227 IMAP_PORT=21680 IMAP_USER=hyungi IMAP_PASSWORD=changeme +IMAP_SSL=true # DEVONthink (devonthink_bridge.py — 지식 저장소) DEVONTHINK_APP_NAME=DEVONthink @@ -61,3 +62,4 @@ HEIC_CONVERTER_URL=http://host.docker.internal:8090 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 diff --git a/CLAUDE.md b/CLAUDE.md index e67f5ec..d61e400 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,13 +42,14 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인 ⑦ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화 + DEVONthink 저장) 별도 워크플로우: - Mail Processing Pipeline (7노드) — MailPlus IMAP 폴링 → 분류 → mail_logs + Mail Processing Pipeline (9노드) — mail_bridge 날짜 기반 조회 → dedup → 분류 → mail_logs 네이티브 서비스 (맥미니): heic_converter (:8090) — HEIC→JPEG 변환 (macOS sips) chat_bridge (:8091) — DSM Chat API 브릿지 (사진 폴링/다운로드) caldav_bridge (:8092) — CalDAV REST 래퍼 (Synology Calendar) devonthink_bridge (:8093) — DEVONthink AppleScript 래퍼 + mail_bridge (:8094) — IMAP 날짜 기반 메일 조회 (MailPlus) inbox_processor (5분) — OmniFocus Inbox 폴링 (LaunchAgent) news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent) @@ -73,6 +74,7 @@ DEVONthink 4 (맥미니): | chat_bridge | 네이티브 (맥미니) | 8091 | DSM Chat API 브릿지 (사진 폴링/다운로드) | | caldav_bridge | 네이티브 (맥미니) | 8092 | CalDAV REST 래퍼 (Synology Calendar) | | devonthink_bridge | 네이티브 (맥미니) | 8093 | DEVONthink AppleScript 래퍼 | +| mail_bridge | 네이티브 (맥미니) | 8094 | IMAP 날짜 기반 메일 조회 (MailPlus) | | inbox_processor | 네이티브 (맥미니) | — | OmniFocus Inbox 폴링 (LaunchAgent, 5분) | | news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) | | Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 | diff --git a/com.syn-chat-bot.heic-converter.plist b/com.syn-chat-bot.heic-converter.plist index 6951e0a..93e8889 100644 --- a/com.syn-chat-bot.heic-converter.plist +++ b/com.syn-chat-bot.heic-converter.plist @@ -6,12 +6,10 @@ com.syn-chat-bot.heic-converter ProgramArguments - /Users/hyungi/Documents/code/syn-chat-bot/.venv/bin/uvicorn - heic_converter:app - --host - 127.0.0.1 - --port - 8090 + /opt/homebrew/opt/python@3.14/bin/python3.14 + -S + -c + import sys; sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot/.venv/lib/python3.14/site-packages'); sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot'); import uvicorn; uvicorn.run('heic_converter:app',host='127.0.0.1',port=8090) WorkingDirectory /Users/hyungi/Documents/code/syn-chat-bot diff --git a/com.syn-chat-bot.kb-writer.plist b/com.syn-chat-bot.kb-writer.plist new file mode 100644 index 0000000..4acca0d --- /dev/null +++ b/com.syn-chat-bot.kb-writer.plist @@ -0,0 +1,25 @@ + + + + + Label + com.syn-chat-bot.kb-writer + ProgramArguments + + /opt/homebrew/opt/python@3.14/bin/python3.14 + -S + -c + import sys; sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot/.venv/lib/python3.14/site-packages'); sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot'); import uvicorn; uvicorn.run('kb_writer:app',host='127.0.0.1',port=8095) + + WorkingDirectory + /Users/hyungi/Documents/code/syn-chat-bot + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/kb-writer.log + StandardErrorPath + /tmp/kb-writer.err + + diff --git a/com.syn-chat-bot.mail-bridge.plist b/com.syn-chat-bot.mail-bridge.plist new file mode 100644 index 0000000..087bfec --- /dev/null +++ b/com.syn-chat-bot.mail-bridge.plist @@ -0,0 +1,25 @@ + + + + + Label + com.syn-chat-bot.mail-bridge + ProgramArguments + + /opt/homebrew/opt/python@3.14/bin/python3.14 + -S + -c + import sys; sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot/.venv/lib/python3.14/site-packages'); sys.path.insert(0,'/Users/hyungi/Documents/code/syn-chat-bot'); import uvicorn; uvicorn.run('mail_bridge:app',host='127.0.0.1',port=8094) + + WorkingDirectory + /Users/hyungi/Documents/code/syn-chat-bot + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/mail-bridge.log + StandardErrorPath + /tmp/mail-bridge.err + + diff --git a/docker-compose.yml b/docker-compose.yml index 4c0a0fb..83fb05b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: - 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 - NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url volumes: - ./n8n/data:/home/node/.n8n diff --git a/init/migrate-v4.sql b/init/migrate-v4.sql new file mode 100644 index 0000000..640a438 --- /dev/null +++ b/init/migrate-v4.sql @@ -0,0 +1,5 @@ +-- migrate-v4.sql: 메일 파이프라인 dedup — message_id 컬럼 추가 +-- 실행: docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v4.sql + +ALTER TABLE mail_logs ADD COLUMN IF NOT EXISTS message_id VARCHAR(500); +CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_message_id ON mail_logs(message_id); diff --git a/kb_writer.py b/kb_writer.py new file mode 100644 index 0000000..9e909c2 --- /dev/null +++ b/kb_writer.py @@ -0,0 +1,106 @@ +"""Knowledge Base Writer — 마크다운 파일 저장 마이크로서비스 (port 8095) + +DEVONthink AppleScript 브릿지 대체. 순수 파일 I/O로 knowledge-base/ 에 마크다운 저장. +DEVONthink에서는 인덱스 그룹으로 읽기만 하면 됨. +""" + +import logging +import re +import unicodedata +from datetime import datetime, timezone, timedelta +from pathlib import Path + +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("kb_writer") + +KST = timezone(timedelta(hours=9)) +BASE_DIR = Path(__file__).resolve().parent / "knowledge-base" + +app = FastAPI() + + +def _slugify(text: str, max_len: int = 50) -> str: + """한글 + 영문 친화적 슬러그 생성.""" + text = unicodedata.normalize("NFC", text) + text = re.sub(r"[^\w가-힣\s-]", "", text) + text = re.sub(r"[\s_]+", "-", text).strip("-") + return text[:max_len] or "untitled" + + +@app.post("/save") +async def save(request: Request): + body = await request.json() + title = body.get("title", "Untitled") + content = body.get("content", "") + doc_type = body.get("type", "note") # note | chat-memory | news + tags = body.get("tags", []) + username = body.get("username", "unknown") + topic = body.get("topic", "general") + source = body.get("source", "synology-chat") + + now = datetime.now(KST) + month_dir = now.strftime("%Y-%m") + date_str = now.strftime("%Y-%m-%d") + iso_str = now.isoformat() + + # 파일명 생성 + slug = _slugify(title) + if doc_type == "chat-memory": + time_str = now.strftime("%H%M") + filename = f"{date_str}T{time_str}-{_slugify(topic)}.md" + elif doc_type == "news": + filename = f"{date_str}-{_slugify(source)}-{slug}.md" + else: + filename = f"{date_str}-{slug}.md" + + # 디렉토리 생성 + type_dir = BASE_DIR / doc_type / month_dir + type_dir.mkdir(parents=True, exist_ok=True) + + # 중복 파일명 처리 + filepath = type_dir / filename + counter = 1 + while filepath.exists(): + stem = filename.rsplit(".", 1)[0] + filepath = type_dir / f"{stem}-{counter}.md" + counter += 1 + + # YAML frontmatter + 본문 + tags_yaml = ", ".join(f'"{t}"' for t in tags) + qdrant_id = int(now.timestamp() * 1000) + + md_content = f"""--- +title: "{title}" +date: "{iso_str}" +source: {source} +type: {doc_type} +tags: [{tags_yaml}] +username: {username} +topic: {topic} +qdrant_id: {qdrant_id} +--- + +{content} +""" + + try: + filepath.write_text(md_content, encoding="utf-8") + logger.info(f"Saved: {filepath.relative_to(BASE_DIR)}") + return JSONResponse({ + "success": True, + "path": str(filepath.relative_to(BASE_DIR)), + "filename": filepath.name, + "qdrant_id": qdrant_id, + }) + except Exception as e: + logger.error(f"Save failed: {e}") + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + + +@app.get("/health") +async def health(): + kb_exists = BASE_DIR.is_dir() + return {"status": "ok", "knowledge_base_exists": kb_exists} diff --git a/knowledge-base/.gitignore b/knowledge-base/.gitignore new file mode 100644 index 0000000..f6287b6 --- /dev/null +++ b/knowledge-base/.gitignore @@ -0,0 +1,6 @@ +# 마크다운 콘텐츠는 로컬 생성물 — git에 포함하지 않음 +# rsync로 DS1525+에 백업 +*/*.md +*/*/*.md +!.gitignore +!*/.gitkeep diff --git a/knowledge-base/chat-memory/.gitkeep b/knowledge-base/chat-memory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/knowledge-base/news/.gitkeep b/knowledge-base/news/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/knowledge-base/note/.gitkeep b/knowledge-base/note/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mail_bridge.py b/mail_bridge.py new file mode 100644 index 0000000..17d039c --- /dev/null +++ b/mail_bridge.py @@ -0,0 +1,180 @@ +"""Mail Bridge — IMAP 날짜 기반 메일 조회 서비스 (port 8094)""" + +import email +import email.header +import email.utils +import imaplib +import logging +import os +from datetime import datetime, timedelta + +from dotenv import load_dotenv +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("mail_bridge") + +IMAP_HOST = os.getenv("IMAP_HOST", "192.168.1.227") +IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) +IMAP_USER = os.getenv("IMAP_USER", "") +IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "") +IMAP_SSL = os.getenv("IMAP_SSL", "true").lower() == "true" +IMAP_FOLDERS = [f.strip() for f in os.getenv("IMAP_FOLDERS", "INBOX,Gmail,Technicalkorea").split(",") if f.strip()] + +app = FastAPI() + + +def _connect() -> imaplib.IMAP4: + if IMAP_SSL: + conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) + else: + conn = imaplib.IMAP4(IMAP_HOST, IMAP_PORT) + conn.login(IMAP_USER, IMAP_PASSWORD) + return conn + + +def _decode_header(raw: str | None) -> str: + if not raw: + return "" + parts = email.header.decode_header(raw) + decoded = [] + for data, charset in parts: + if isinstance(data, bytes): + decoded.append(data.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(data) + return " ".join(decoded) + + +def _get_text(msg: email.message.Message) -> str: + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct == "text/plain": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + # fallback: text/html + for part in msg.walk(): + ct = part.get_content_type() + if ct == "text/html": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + + +def _dedup_key(mail: dict) -> str: + if mail["messageId"]: + return mail["messageId"] + return f"{mail['subject']}|{mail['date']}|{mail['from']}" + + +@app.get("/recent") +def recent_mails(days: int = Query(default=1, ge=1, le=30)): + try: + conn = _connect() + except Exception as e: + logger.error(f"IMAP connection failed: {e}") + return JSONResponse({"success": False, "error": f"IMAP connection failed: {e}"}, status_code=502) + + try: + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + seen_keys: set[str] = set() + mails: list[dict] = [] + + for folder in IMAP_FOLDERS: + try: + status, _ = conn.select(folder, readonly=True) + if status != "OK": + logger.warning(f"Cannot select folder '{folder}', skipping") + continue + except Exception as e: + logger.warning(f"Failed to select folder '{folder}': {e}") + continue + + _, msg_nums = conn.search(None, f"SINCE {since}") + nums = msg_nums[0].split() if msg_nums[0] else [] + + for num in nums: + _, data = conn.fetch(num, "(RFC822)") + if not data or not data[0]: + continue + raw = data[0][1] + msg = email.message_from_bytes(raw) + + message_id = msg.get("Message-ID", "").strip() + from_addr = _decode_header(msg.get("From", "")) + subject = _decode_header(msg.get("Subject", "")) + date_str = msg.get("Date", "") + text = _get_text(msg) + + parsed_date = email.utils.parsedate_to_datetime(date_str).isoformat() if date_str else "" + + mail_entry = { + "messageId": message_id, + "from": from_addr, + "subject": subject, + "text": text, + "date": parsed_date, + "folder": folder, + } + + key = _dedup_key(mail_entry) + if key not in seen_keys: + seen_keys.add(key) + mails.append(mail_entry) + + logger.info(f"Folder '{folder}': {len(nums)} messages since {since}") + + mails.sort(key=lambda m: m["date"], reverse=True) + logger.info(f"Total {len(mails)} unique mails across {len(IMAP_FOLDERS)} folders (SINCE {since})") + return {"success": True, "count": len(mails), "mails": mails} + except Exception as e: + logger.error(f"IMAP fetch error: {e}") + return JSONResponse({"success": False, "error": str(e)}, status_code=500) + finally: + try: + conn.logout() + except Exception: + pass + + +@app.get("/health") +def health(): + folders_status: dict[str, bool] = {} + try: + conn = _connect() + for folder in IMAP_FOLDERS: + try: + status, _ = conn.select(folder, readonly=True) + folders_status[folder] = status == "OK" + except Exception: + folders_status[folder] = False + conn.logout() + except Exception as e: + logger.warning(f"IMAP health check failed: {e}") + for folder in IMAP_FOLDERS: + folders_status.setdefault(folder, False) + + any_ok = any(folders_status.values()) + all_ok = all(folders_status.values()) + + if all_ok: + status = "ok" + elif any_ok: + status = "degraded" + else: + status = "error" + + return {"status": status, "imap_reachable": any_ok, "folders": folders_status} diff --git a/manage_services.sh b/manage_services.sh index ef6a294..2d204c2 100755 --- a/manage_services.sh +++ b/manage_services.sh @@ -7,6 +7,8 @@ SERVICES=( "com.syn-chat-bot.heic-converter" "com.syn-chat-bot.caldav-bridge" "com.syn-chat-bot.devonthink-bridge" + "com.syn-chat-bot.kb-writer" + "com.syn-chat-bot.mail-bridge" "com.syn-chat-bot.inbox-processor" "com.syn-chat-bot.news-digest" ) diff --git a/n8n/workflows/mail-processing-pipeline.json b/n8n/workflows/mail-processing-pipeline.json index f731914..bd8c824 100644 --- a/n8n/workflows/mail-processing-pipeline.json +++ b/n8n/workflows/mail-processing-pipeline.json @@ -4,25 +4,19 @@ "nodes": [ { "parameters": { - "mailbox": "INBOX", - "postProcessAction": "read", - "options": { - "customEmailConfig": "{ \"host\": \"{{$env.IMAP_HOST || '192.168.1.227'}}\", \"port\": {{$env.IMAP_PORT || 993}}, \"secure\": true, \"auth\": { \"user\": \"{{$env.IMAP_USER}}\", \"pass\": \"{{$env.IMAP_PASSWORD}}\" } }" - }, - "pollTimes": { - "item": [ + "rule": { + "interval": [ { - "mode": "everyX", - "value": 15, - "unit": "minutes" + "field": "minutes", + "minutesInterval": 15 } ] } }, - "id": "m1000001-0000-0000-0000-000000000001", - "name": "IMAP Trigger", - "type": "n8n-nodes-base.imapEmail", - "typeVersion": 2, + "id": "m1000001-0000-0000-0000-000000000010", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, "position": [ 0, 300 @@ -30,14 +24,52 @@ }, { "parameters": { - "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n const j = item.json;\n const from = j.from?.text || j.from || '';\n const subject = (j.subject || '').substring(0, 500);\n const body = (j.text || j.textPlain || j.html || '').substring(0, 5000)\n .replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n const mailDate = j.date || new Date().toISOString();\n results.push({ json: { from, subject, body, mailDate, messageId: j.messageId || '' } });\n}\nreturn results;" + "method": "GET", + "url": "={{ $env.MAIL_BRIDGE_URL }}/recent?days=1", + "options": { + "timeout": 15000 + } + }, + "id": "m1000001-0000-0000-0000-000000000011", + "name": "Fetch Mails", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 220, + 300 + ] + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT message_id FROM mail_logs WHERE mail_date >= NOW() - INTERVAL '2 days'", + "options": {} + }, + "id": "m1000001-0000-0000-0000-000000000012", + "name": "Get Existing IDs", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 440, + 300 + ], + "credentials": { + "postgres": { + "id": "KaxU8iKtraFfsrTF", + "name": "bot-postgres" + } + } + }, + { + "parameters": { + "jsCode": "const mailsResponse = $('Fetch Mails').first().json;\nconst mails = mailsResponse.mails || [];\nconst existingRows = $input.all();\nconst existingIds = new Set(existingRows.map(r => r.json.message_id).filter(Boolean));\n\nconst results = [];\nfor (const mail of mails) {\n const mid = mail.messageId || '';\n if (!mid || existingIds.has(mid)) continue;\n results.push({ json: {\n from: mail.from || '',\n subject: (mail.subject || '').substring(0, 500),\n body: (mail.text || '').substring(0, 5000).replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim(),\n mailDate: mail.date || new Date().toISOString(),\n messageId: mid\n }});\n}\nreturn results;" }, "id": "m1000001-0000-0000-0000-000000000002", - "name": "Parse Mail", + "name": "Parse & Filter New", "type": "n8n-nodes-base.code", "typeVersion": 1, "position": [ - 220, + 660, 300 ] }, @@ -50,14 +82,14 @@ "type": "n8n-nodes-base.code", "typeVersion": 1, "position": [ - 440, + 880, 300 ] }, { "parameters": { "operation": "executeQuery", - "query": "=INSERT INTO mail_logs (from_address,subject,summary,label,has_events,has_tasks,mail_date) VALUES ('{{ ($json.from||'').replace(/'/g,\"''\").substring(0,255) }}','{{ ($json.subject||'').replace(/'/g,\"''\").substring(0,500) }}','{{ ($json.summary||'').replace(/'/g,\"''\").substring(0,2000) }}','{{ $json.label }}',{{ $json.has_events }},{{ $json.has_tasks }},'{{ $json.mailDate }}')", + "query": "=INSERT INTO mail_logs (from_address,subject,summary,label,has_events,has_tasks,mail_date,message_id) VALUES ('{{ ($json.from||'').replace(/'/g,\"''\").substring(0,255) }}','{{ ($json.subject||'').replace(/'/g,\"''\").substring(0,500) }}','{{ ($json.summary||'').replace(/'/g,\"''\").substring(0,2000) }}','{{ $json.label }}',{{ $json.has_events }},{{ $json.has_tasks }},'{{ $json.mailDate }}','{{ ($json.messageId||'').replace(/'/g,\"''\").substring(0,500) }}') ON CONFLICT (message_id) DO NOTHING", "options": {} }, "id": "m1000001-0000-0000-0000-000000000004", @@ -65,7 +97,7 @@ "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [ - 660, + 1100, 300 ], "credentials": { @@ -84,7 +116,7 @@ "type": "n8n-nodes-base.code", "typeVersion": 1, "position": [ - 880, + 1320, 300 ] }, @@ -125,7 +157,7 @@ "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ - 1100, + 1540, 300 ] }, @@ -145,24 +177,46 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1320, + 1760, 200 ] } ], "connections": { - "IMAP Trigger": { + "Schedule Trigger": { "main": [ [ { - "node": "Parse Mail", + "node": "Fetch Mails", "type": "main", "index": 0 } ] ] }, - "Parse Mail": { + "Fetch Mails": { + "main": [ + [ + { + "node": "Get Existing IDs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Existing IDs": { + "main": [ + [ + { + "node": "Parse & Filter New", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse & Filter New": { "main": [ [ { @@ -222,4 +276,4 @@ "settings": { "executionOrder": "v1" } -} \ No newline at end of file +} diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json index 6d7248f..215bfab 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 판단:\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)) {\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\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}" }, "id": "b1000001-0000-0000-0000-000000000020", "name": "Qwen Classify v2", @@ -1005,7 +1005,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 prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\nconst pid = Date.now();\nconst qu = $env.QDRANT_URL||'http://host.docker.internal:6333';\nawait httpPut(`${qu}/collections/chat_memory/points`, { points:[{ id:pid, vector:emb.embedding, payload:{\n text:prompt, feature:'chat', intent:data.intent||'unknown',\n username:data.username||'unknown', topic:data.topic||'general', timestamp:pid\n}}]});\n// DEVONthink 저장 (graceful)\ntry {\n const dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\n await httpPost(`${dtUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'markdown',\n tags: ['chat-memory', data.topic || 'general']\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];" + "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst data = $input.first().json;\nconst prompt = `Q: ${data.userText}\\nA: ${data.aiText}`;\nconst emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model:'bge-m3', prompt });\nif (!emb.embedding||!Array.isArray(emb.embedding)) return [{json:{saved:false}}];\nconst pid = Date.now();\nconst qu = $env.QDRANT_URL||'http://host.docker.internal:6333';\nawait httpPut(`${qu}/collections/chat_memory/points`, { points:[{ id:pid, vector:emb.embedding, payload:{\n text:prompt, feature:'chat', intent:data.intent||'unknown',\n username:data.username||'unknown', topic:data.topic||'general', timestamp:pid\n}}]});\n// 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 }, { 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", @@ -1186,7 +1186,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: '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 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 }, { 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 pid = Date.now();\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 } }];" }, "id": "b1000001-0000-0000-0000-000000000069", "name": "Handle Note", @@ -1923,4 +1923,4 @@ "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false } -} +} \ No newline at end of file