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