diff --git a/.env.example b/.env.example
index 40bc01b..7e4574c 100644
--- a/.env.example
+++ b/.env.example
@@ -30,3 +30,24 @@ GPU_OLLAMA_URL=http://192.168.1.186:11434
# Qdrant (Docker 내부에서 접근)
QDRANT_URL=http://host.docker.internal:6333
+
+# DSM Chat API (chat_bridge.py — 사진 폴링/다운로드)
+DSM_HOST=http://192.168.1.227:5000
+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_PASSWORD=changeme
+CALDAV_CALENDAR=chatbot
+
+# IMAP (메일 처리 파이프라인)
+IMAP_HOST=192.168.1.227
+IMAP_PORT=993
+IMAP_USER=chatbot-api
+IMAP_PASSWORD=changeme
+
+# DEVONthink (devonthink_bridge.py — 지식 저장소)
+DEVONTHINK_APP_NAME=DEVONthink
diff --git a/.gitignore b/.gitignore
index 28f6566..0bb9cc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
n8n/data/
postgres/data/
.DS_Store
+__pycache__/
diff --git a/CLAUDE.md b/CLAUDE.md
index 152c7e2..6995bb1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,20 +8,29 @@ Synology Chat + n8n + Claude API 기반 RAG 챗봇 시스템.
```
Synology Chat (NAS 192.168.1.227)
↕ 웹훅
-bot-n8n (맥미니 Docker)
+bot-n8n (맥미니 Docker) — 51노드 파이프라인
│
├─⓪ 토큰 검증 + Rate Limit (10초/5건)
├─① 규칙 기반 프리필터 (인사/감사 → 하드코딩 local 응답)
│
- ├─② GPU Qwen 9B (192.168.1.186) — 분류 v2
+ ├─② GPU Qwen 9B (192.168.1.186) — 분류 v3
│ 출력: {intent, response_tier, needs_rag, rag_target, ...}
│ 타임아웃 10초 → fallback: api_light
│
- ├─③ [needs_rag=true] 멀티-컬렉션 RAG
+ ├─③ Route by Intent
+ │ ├─ log_event → Qwen 추출 → tk_company 저장
+ │ ├─ report → 현장 리포트 DB 저장
+ │ ├─ calendar → CalDAV Bridge → Synology Calendar
+ │ ├─ reminder → calendar로 통합
+ │ ├─ mail → 메일 요약 조회
+ │ ├─ note → DEVONthink 저장
+ │ └─ fallback → 일반 대화 (RAG + 3단계 라우팅)
+ │
+ ├─④ [needs_rag=true] 멀티-컬렉션 RAG
│ documents + tk_company + chat_memory
│ bge-m3 임베딩 → Qdrant 검색 → reranker → top-3
│
- ├─④ response_tier 기반 3단계 라우팅
+ ├─⑤ response_tier 기반 3단계 라우팅
│ ├─ local → Qwen 9B 직접 답변 (무료)
│ ├─ api_light → Claude Haiku (저비용)
│ └─ api_heavy → Claude Opus (예산 초과 시 → Haiku 다운그레이드)
@@ -29,8 +38,25 @@ bot-n8n (맥미니 Docker)
├── bot-postgres (설정/로그/라우팅/분류기로그/API사용량)
└── Qdrant (벡터 검색, 3컬렉션)
- ⑤ 응답 전송 + chat_logs 저장 + API 사용량 UPSERT
- ⑥ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화)
+ ⑥ 응답 전송 + chat_logs 저장 + API 사용량 UPSERT
+ ⑦ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화 + DEVONthink 저장)
+
+별도 워크플로우:
+ Mail Processing Pipeline (7노드) — MailPlus IMAP 폴링 → 분류 → 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 래퍼
+ inbox_processor (5분) — OmniFocus Inbox 폴링 (LaunchAgent)
+ news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent)
+
+NAS (192.168.1.227):
+ Synology Chat / Synology Calendar (CalDAV) / MailPlus (IMAP)
+
+DEVONthink 4 (맥미니):
+ AppleScript 경유 문서 저장·검색
```
## 인프라
@@ -40,9 +66,19 @@ bot-n8n (맥미니 Docker)
| 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, minicpm-v:8b(Phase 5) |
+| Ollama (맥미니) | 네이티브 (기존) | 11434 | bge-m3, bge-reranker-v2-m3 (임베딩/리랭킹 전용) |
| Ollama (GPU) | 192.168.1.186 (RTX 4070Ti Super) | 11434 | qwen3.5:9b-q8_0 (분류+local응답) |
+| Claude Haiku Vision | Anthropic API | — | 사진 분석+구조화 (field_report, log_event) |
+| 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 래퍼 |
+| inbox_processor | 네이티브 (맥미니) | — | OmniFocus Inbox 폴링 (LaunchAgent, 5분) |
+| news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) |
| Synology Chat | NAS (192.168.1.227) | — | 사용자 인터페이스 |
+| Synology Calendar | NAS (192.168.1.227) | CalDAV | 캘린더 서비스 |
+| MailPlus | NAS (192.168.1.227) | IMAP | 메일 서비스 |
+| DEVONthink 4 | 네이티브 (맥미니) | — | 문서 저장·검색 (AppleScript) |
## 3단계 라우팅
@@ -56,14 +92,14 @@ bot-n8n (맥미니 Docker)
| 컬렉션 | 용도 |
|--------|------|
-| `documents` | 개인/일반 문서 + 메일 요약 |
+| `documents` | 개인/일반 문서 + 메일 요약 + 뉴스 요약 |
| `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 |
| `chat_memory` | 가치 있는 대화 기억 (선택적 저장) |
## DB 스키마 (bot-postgres)
**기존**: `ai_configs`, `routing_rules`, `prompts`, `chat_logs`, `mail_accounts`
-**신규**: `document_ingestion_log`, `field_reports`, `classification_logs`, `mail_logs`, `calendar_events`, `report_cache`, `api_usage_monthly`
+**신규**: `document_ingestion_log`, `field_reports`, `classification_logs`, `mail_logs`, `calendar_events` (+caldav_uid, +description, +created_by), `report_cache`, `api_usage_monthly`, `news_digest_log`
상세 스키마는 [docs/architecture.md](docs/architecture.md) 참조.
@@ -100,6 +136,8 @@ bot-n8n (맥미니 Docker)
- DB 포트는 127.0.0.1로 바인딩 (외부 노출 금지)
- 시크릿(API 키 등)은 .env 파일로 관리, git에 포함하지 않음
- 커밋 전 docker-compose config로 문법 검증
+- 네이티브 서비스는 LaunchAgent로 관리 (manage_services.sh)
+- pip install은 .venv에서 실행
- 세션 시작 시 Plan 모드로 계획 → 확정 후 구현
- 구현 완료 후 `/verify`로 검증, `/simplify`로 코드 리뷰
diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md
index 0992233..12afe19 100644
--- a/QUICK_REFERENCE.md
+++ b/QUICK_REFERENCE.md
@@ -32,6 +32,10 @@ curl -s http://192.168.1.186:11434/api/generate -d '{"model":"qwen3.5:9b-q8_0","
| Ollama API (맥미니) | http://localhost:11434 |
| Ollama API (GPU) | http://192.168.1.186:11434 |
| Synology Chat | NAS (192.168.1.227) |
+| chat_bridge | http://localhost:8091 |
+| HEIC converter | http://localhost:8090 |
+| caldav_bridge | http://localhost:8092 |
+| devonthink_bridge | http://localhost:8093 |
## Docker 명령어
@@ -61,6 +65,9 @@ docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v2.sql
# Qdrant tk_company 컬렉션 + 인덱스 설정
bash init/setup-qdrant.sh
+
+# v3 마이그레이션 실행 (Phase 5-6 테이블 추가)
+docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v3.sql
```
## 헬스체크 (전체)
@@ -77,6 +84,12 @@ echo "=== Qdrant 컬렉션 ===" && \
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']]" && \
echo "=== tk_company ===" && \
curl -s http://localhost:6333/collections/tk_company | python3 -c "import sys,json; r=json.loads(sys.stdin.read())['result']; print(f' 벡터수: {r[\"points_count\"]}, 상태: {r[\"status\"]}')" 2>/dev/null || echo " (미생성)" && \
+echo "=== 네이티브 서비스 ===" && \
+curl -s http://localhost:8090/health && echo && \
+curl -s http://localhost:8091/health && echo && \
+curl -s http://localhost:8092/health && echo && \
+curl -s http://localhost:8093/health && echo && \
+./manage_services.sh status && \
echo "=== n8n ===" && \
curl -s -o /dev/null -w ' HTTP %{http_code}' http://localhost:5678 && echo && \
echo "=== API 사용량 ===" && \
@@ -92,16 +105,27 @@ syn-chat-bot/
├── .env.example ← 환경변수 템플릿
├── CLAUDE.md ← 프로젝트 문서
├── QUICK_REFERENCE.md ← 이 파일
+├── heic_converter.py ← HEIC→JPEG 변환 API (macOS sips, port 8090)
+├── chat_bridge.py ← DSM Chat API 브릿지 (사진 폴링/다운로드, port 8091)
+├── caldav_bridge.py ← CalDAV REST 래퍼 (Synology Calendar, port 8092)
+├── devonthink_bridge.py ← DEVONthink AppleScript 래퍼 (port 8093)
+├── inbox_processor.py ← OmniFocus Inbox 폴링 (LaunchAgent, 5분)
+├── news_digest.py ← 뉴스 번역·요약 (LaunchAgent, 매일 07:00)
+├── manage_services.sh ← 네이티브 서비스 관리 (start/stop/status)
+├── start-bridge.sh ← 브릿지 서비스 시작 헬퍼
+├── com.syn-chat-bot.*.plist ← LaunchAgent 설정 (6개)
├── docs/
│ ├── architecture.md ← 아키텍처, DB 스키마, 파이프라인 상세
│ └── claude-code-playbook.md
├── n8n/
│ ├── data/ ← n8n 런타임 데이터
│ └── workflows/
-│ └── main-chat-pipeline.json ← 메인 워크플로우 (37노드)
+│ ├── main-chat-pipeline.json ← 메인 워크플로우 (51노드)
+│ └── mail-processing-pipeline.json ← 메일 처리 파이프라인 (7노드)
├── init/
│ ├── init.sql ← DB 초기 스키마 v2 (12테이블)
│ ├── migrate-v2.sql ← 기존 DB 마이그레이션
+│ ├── migrate-v3.sql ← v3 마이그레이션 (Phase 5-6 테이블)
│ └── setup-qdrant.sh ← Qdrant 컬렉션/인덱스 설정
└── postgres/data/ ← DB 데이터
```
@@ -138,6 +162,30 @@ docker exec bot-postgres psql -U bot -d chatbot -c "SELECT * FROM api_usage_mont
# GPU 서버 연결 안 될 때
ping 192.168.1.186
curl -s http://192.168.1.186:11434/api/tags
+
+# chat_bridge 상태 확인
+curl -s http://localhost:8091/health | python3 -m json.tool
+
+# chat_bridge 사진 조회 테스트
+curl -s -X POST http://localhost:8091/chat/recent-photo \
+ -H 'Content-Type: application/json' \
+ -d '{"channel_id":17,"user_id":6,"before_timestamp":9999999999999}' | python3 -m json.tool
+
+# chat_bridge 로그
+tail -50 /tmp/chat-bridge.log
+tail -50 /tmp/chat-bridge.err
+
+# caldav_bridge 상태 확인
+curl -s http://localhost:8092/health | python3 -m json.tool
+
+# devonthink_bridge 상태 확인
+curl -s http://localhost:8093/health | python3 -m json.tool
+
+# inbox_processor 로그
+tail -50 /tmp/inbox-processor.log
+
+# news_digest 로그
+tail -50 /tmp/news-digest.log
```
## n8n 접속 정보
@@ -145,7 +193,8 @@ curl -s http://192.168.1.186:11434/api/tags
- URL: http://localhost:5678
- 이메일: ahn@hyungi.net
- 비밀번호: .env의 N8N_BASIC_AUTH_PASSWORD와 동일
-- 워크플로우: "메인 채팅 파이프라인 v2" (37 노드, 활성 상태)
+- 워크플로우: "메인 채팅 파이프라인 v3" (51 노드, 활성 상태)
+- 메일 처리 워크플로우: "메일 처리 파이프라인" (7 노드)
- 웹훅 엔드포인트: POST http://localhost:5678/webhook/chat
## Synology Chat 연동
@@ -163,7 +212,6 @@ NAS에서 Outgoing Webhook 설정 필요:
### Phase 0: 맥미니 정리
- [ ] ollama rm qwen3.5:35b-a3b (삭제)
- [ ] ollama rm qwen3.5:35b-a3b-think (삭제)
-- [ ] ollama pull minicpm-v:8b (비전 모델 설치, Phase 5용)
### Phase 1: 기반 (Qdrant + DB)
- [x] init.sql v2 (12테이블 + 분류기 v2 프롬프트 + 메모리 판단 프롬프트)
@@ -173,7 +221,7 @@ NAS에서 Outgoing Webhook 설정 필요:
- [ ] Qdrant 설정 실행
### Phase 2: 3단계 라우팅 + 검색 라우팅
-- [x] 워크플로우 v2 (37노드): 토큰검증, Rate Limit, 프리필터, 분류기v2, 3-tier, 멀티-컬렉션 RAG
+- [x] 워크플로우 v2 (42노드): 토큰검증, Rate Limit, 프리필터, 분류기v2, 3-tier, 멀티-컬렉션 RAG
- [x] .env + docker-compose.yml 환경변수 추가
- [ ] n8n에 워크플로우 임포트 + 활성화
- [ ] 테스트: "안녕" → local, "요약해줘" → Haiku, "법률 해석" → Opus
@@ -188,16 +236,34 @@ NAS에서 Outgoing Webhook 설정 필요:
- [ ] 텍스트 청킹 + 임베딩 + tk_company 저장 구현
- [ ] 문서 버전 관리 (deprecated + version++)
-### Phase 5: 현장 리포팅
+### Phase 5: 현장 리포팅 + API 사용량 추적
- [x] field_reports 테이블 + SLA 인덱스
-- [ ] 비전 모델 설치 + 사진 분석 노드
-- [ ] /보고서 월간 보고서 생성 구현
-- [ ] SLA 트래킹 스케줄 워크플로우
+- [x] 비전 모델 사진 분석 (base64 변환 + HEIC 자동 변환)
+- [x] HEIC→JPEG 변환 서비스 (heic_converter.py, macOS sips)
+- [x] chat_bridge.py — DSM Chat API 브릿지 (사진 폴링 + 다운로드 + ack)
+- [x] n8n Handle Log Event / Handle Field Report → bridge 연동
+- [x] API 사용량 추적 (api_usage_monthly UPSERT)
+- [x] /보고서 월간 보고서 생성 구현
+- [x] report_cache 캐시 + --force 재생성
-### Phase 6: 메일 + 캘린더
+### Phase 6: 캘린더·메일·DEVONthink·OmniFocus·뉴스
- [x] mail_logs, calendar_events 테이블
-- [ ] IMAP 폴링 워크플로우
-- [ ] CalDAV 연동
+- [x] 분류기 v3 (calendar, reminder, mail, note intent 추가)
+- [x] caldav_bridge.py — CalDAV REST 래퍼 (Synology Calendar)
+- [x] devonthink_bridge.py — DEVONthink AppleScript 래퍼
+- [x] inbox_processor.py — OmniFocus Inbox 폴링 (LaunchAgent, 5분)
+- [x] news_digest.py — 뉴스 번역·요약 (LaunchAgent, 매일 07:00)
+- [x] manage_services.sh — 네이티브 서비스 관리
+- [x] LaunchAgent plist 6개
+- [x] n8n 파이프라인 51노드 (calendar/mail/note 핸들러 추가)
+- [x] Mail Processing Pipeline (7노드, IMAP 폴링)
+- [x] migrate-v3.sql (news_digest_log + calendar_events 확장)
+
+### 서비스 기동 전제조건
+- Synology Calendar (CalDAV) — NAS에서 활성화 필요
+- Synology MailPlus — NAS에서 활성화 + 계정 설정 필요
+- DEVONthink 4 — 맥미니에 설치 필요 (AppleScript 접근)
+- OmniFocus — 맥미니에 설치 필요 (Inbox 폴링)
## 검증 체크리스트
@@ -211,3 +277,6 @@ NAS에서 Outgoing Webhook 설정 필요:
8. GPU 서버 다운 → fallback Haiku 답변
9. 잘못된 토큰 → reject
10. 10초 내 6건 → rate limit
+11. "내일 회의 잡아줘" → calendar intent → CalDAV 이벤트 생성
+12. "최근 메일 확인" → mail intent → 메일 요약 반환
+13. "이거 메모해둬" → note intent → DEVONthink 저장
diff --git a/caldav_bridge.py b/caldav_bridge.py
new file mode 100644
index 0000000..774e756
--- /dev/null
+++ b/caldav_bridge.py
@@ -0,0 +1,269 @@
+"""CalDAV Bridge — Synology Calendar REST API 래퍼 (port 8092)"""
+
+import logging
+import os
+import uuid
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+
+import httpx
+from dotenv import load_dotenv
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+from icalendar import Calendar, Event, vText
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("caldav_bridge")
+
+CALDAV_BASE_URL = os.getenv("CALDAV_BASE_URL", "https://192.168.1.227:5001/caldav")
+CALDAV_USER = os.getenv("CALDAV_USER", "chatbot-api")
+CALDAV_PASSWORD = os.getenv("CALDAV_PASSWORD", "")
+CALDAV_CALENDAR = os.getenv("CALDAV_CALENDAR", "chatbot")
+KST = ZoneInfo("Asia/Seoul")
+
+CALENDAR_URL = f"{CALDAV_BASE_URL}/{CALDAV_USER}/{CALDAV_CALENDAR}/"
+
+app = FastAPI()
+
+
+def _client() -> httpx.AsyncClient:
+ return httpx.AsyncClient(
+ verify=False,
+ auth=(CALDAV_USER, CALDAV_PASSWORD),
+ timeout=15,
+ )
+
+
+def _make_ical(title: str, start: str, end: str | None, location: str | None,
+ description: str | None, uid: str) -> bytes:
+ """iCalendar 이벤트 생성."""
+ cal = Calendar()
+ cal.add("prodid", "-//SynChatBot//CalDAV Bridge//KO")
+ cal.add("version", "2.0")
+
+ evt = Event()
+ evt.add("uid", uid)
+ evt.add("dtstamp", datetime.now(KST))
+ evt.add("summary", title)
+
+ dt_start = datetime.fromisoformat(start)
+ if dt_start.tzinfo is None:
+ dt_start = dt_start.replace(tzinfo=KST)
+ evt.add("dtstart", dt_start)
+
+ if end:
+ dt_end = datetime.fromisoformat(end)
+ if dt_end.tzinfo is None:
+ dt_end = dt_end.replace(tzinfo=KST)
+ evt.add("dtend", dt_end)
+ else:
+ evt.add("dtend", dt_start + timedelta(hours=1))
+
+ if location:
+ evt["location"] = vText(location)
+ if description:
+ evt["description"] = vText(description)
+
+ cal.add_component(evt)
+ return cal.to_ical()
+
+
+def _parse_events(ical_data: bytes) -> list[dict]:
+ """iCalendar 데이터에서 이벤트 목록 추출."""
+ events = []
+ try:
+ cals = Calendar.from_ical(ical_data, multiple=True) if hasattr(Calendar, 'from_ical') else [Calendar.from_ical(ical_data)]
+ except Exception:
+ cals = [Calendar.from_ical(ical_data)]
+ for cal in cals:
+ for component in cal.walk():
+ if component.name == "VEVENT":
+ dt_start = component.get("dtstart")
+ dt_end = component.get("dtend")
+ events.append({
+ "uid": str(component.get("uid", "")),
+ "title": str(component.get("summary", "")),
+ "start": dt_start.dt.isoformat() if dt_start else None,
+ "end": dt_end.dt.isoformat() if dt_end else None,
+ "location": str(component.get("location", "")),
+ "description": str(component.get("description", "")),
+ })
+ return events
+
+
+@app.post("/calendar/create")
+async def create_event(request: Request):
+ body = await request.json()
+ title = body.get("title", "")
+ start = body.get("start", "")
+ end = body.get("end")
+ location = body.get("location")
+ description = body.get("description")
+
+ if not title or not start:
+ return JSONResponse({"success": False, "error": "title and start required"}, status_code=400)
+
+ uid = f"{uuid.uuid4()}@syn-chat-bot"
+ ical = _make_ical(title, start, end, location, description, uid)
+
+ async with _client() as client:
+ resp = await client.put(
+ f"{CALENDAR_URL}{uid}.ics",
+ content=ical,
+ headers={"Content-Type": "text/calendar; charset=utf-8"},
+ )
+ if resp.status_code in (200, 201, 204):
+ logger.info(f"Event created: {uid} '{title}'")
+ return JSONResponse({"success": True, "uid": uid})
+ logger.error(f"CalDAV PUT failed: {resp.status_code} {resp.text[:200]}")
+ return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}"}, status_code=502)
+
+
+@app.post("/calendar/query")
+async def query_events(request: Request):
+ body = await request.json()
+ start = body.get("start", "")
+ end = body.get("end", "")
+
+ if not start or not end:
+ return JSONResponse({"success": False, "error": "start and end required"}, status_code=400)
+
+ dt_start = datetime.fromisoformat(start)
+ dt_end = datetime.fromisoformat(end)
+ if dt_start.tzinfo is None:
+ dt_start = dt_start.replace(tzinfo=KST)
+ if dt_end.tzinfo is None:
+ dt_end = dt_end.replace(tzinfo=KST)
+
+ xml_body = f"""
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+ async with _client() as client:
+ resp = await client.request(
+ "REPORT",
+ CALENDAR_URL,
+ content=xml_body.encode("utf-8"),
+ headers={
+ "Content-Type": "application/xml; charset=utf-8",
+ "Depth": "1",
+ },
+ )
+ if resp.status_code not in (200, 207):
+ logger.error(f"CalDAV REPORT failed: {resp.status_code}")
+ return JSONResponse({"success": False, "error": f"CalDAV REPORT {resp.status_code}"}, status_code=502)
+
+ # Parse multistatus XML for calendar-data
+ events = []
+ import xml.etree.ElementTree as ET
+ try:
+ root = ET.fromstring(resp.text)
+ ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
+ for response_elem in root.findall(".//D:response", ns):
+ cal_data_elem = response_elem.find(".//C:calendar-data", ns)
+ if cal_data_elem is not None and cal_data_elem.text:
+ events.extend(_parse_events(cal_data_elem.text.encode("utf-8")))
+ except ET.ParseError as e:
+ logger.error(f"XML parse error: {e}")
+
+ return JSONResponse({"success": True, "events": events})
+
+
+@app.post("/calendar/update")
+async def update_event(request: Request):
+ body = await request.json()
+ uid = body.get("uid", "")
+ if not uid:
+ return JSONResponse({"success": False, "error": "uid required"}, status_code=400)
+
+ ics_url = f"{CALENDAR_URL}{uid}.ics"
+
+ async with _client() as client:
+ # Fetch existing event
+ resp = await client.get(ics_url)
+ if resp.status_code != 200:
+ return JSONResponse({"success": False, "error": f"Event not found: {resp.status_code}"}, status_code=404)
+
+ # Parse and modify
+ try:
+ cal = Calendar.from_ical(resp.content)
+ except Exception as e:
+ return JSONResponse({"success": False, "error": f"Parse error: {e}"}, status_code=500)
+
+ for component in cal.walk():
+ if component.name == "VEVENT":
+ if "title" in body and body["title"]:
+ component["summary"] = vText(body["title"])
+ if "start" in body and body["start"]:
+ dt = datetime.fromisoformat(body["start"])
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=KST)
+ component["dtstart"].dt = dt
+ if "end" in body and body["end"]:
+ dt = datetime.fromisoformat(body["end"])
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=KST)
+ component["dtend"].dt = dt
+ if "location" in body:
+ component["location"] = vText(body["location"]) if body["location"] else ""
+ if "description" in body:
+ component["description"] = vText(body["description"]) if body["description"] else ""
+ break
+
+ # PUT back
+ resp = await client.put(
+ ics_url,
+ content=cal.to_ical(),
+ headers={"Content-Type": "text/calendar; charset=utf-8"},
+ )
+ if resp.status_code in (200, 201, 204):
+ logger.info(f"Event updated: {uid}")
+ return JSONResponse({"success": True, "uid": uid})
+ return JSONResponse({"success": False, "error": f"CalDAV PUT {resp.status_code}"}, status_code=502)
+
+
+@app.post("/calendar/delete")
+async def delete_event(request: Request):
+ body = await request.json()
+ uid = body.get("uid", "")
+ if not uid:
+ return JSONResponse({"success": False, "error": "uid required"}, status_code=400)
+
+ async with _client() as client:
+ resp = await client.delete(f"{CALENDAR_URL}{uid}.ics")
+ if resp.status_code in (200, 204):
+ logger.info(f"Event deleted: {uid}")
+ return JSONResponse({"success": True})
+ return JSONResponse({"success": False, "error": f"CalDAV DELETE {resp.status_code}"}, status_code=502)
+
+
+@app.get("/health")
+async def health():
+ caldav_reachable = False
+ try:
+ async with _client() as client:
+ resp = await client.request(
+ "PROPFIND",
+ CALENDAR_URL,
+ headers={"Depth": "0", "Content-Type": "application/xml"},
+ content=b'',
+ )
+ caldav_reachable = resp.status_code in (200, 207)
+ except Exception as e:
+ logger.warning(f"CalDAV health check failed: {e}")
+
+ return {"status": "ok", "caldav_reachable": caldav_reachable}
diff --git a/chat_bridge.py b/chat_bridge.py
new file mode 100644
index 0000000..248e9c5
--- /dev/null
+++ b/chat_bridge.py
@@ -0,0 +1,293 @@
+"""DSM Chat API Bridge — 사진 폴링 + 다운로드 서비스 (port 8091)"""
+
+import asyncio
+import base64
+import json
+import logging
+import os
+import time
+from contextlib import asynccontextmanager
+
+import httpx
+from dotenv import load_dotenv
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("chat_bridge")
+
+DSM_HOST = os.getenv("DSM_HOST", "http://192.168.1.227:5000")
+DSM_ACCOUNT = os.getenv("DSM_ACCOUNT", "chatbot-api")
+DSM_PASSWORD = os.getenv("DSM_PASSWORD", "")
+CHAT_CHANNEL_ID = int(os.getenv("CHAT_CHANNEL_ID", "17"))
+SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "")
+HEIC_CONVERTER_URL = os.getenv("HEIC_CONVERTER_URL", "http://127.0.0.1:8090")
+POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "5"))
+
+# State
+sid: str = ""
+last_seen_post_id: int = 0
+pending_photos: dict[int, dict] = {} # user_id -> {post_id, create_at, filename}
+
+
+async def dsm_login(client: httpx.AsyncClient) -> str:
+ global sid
+ resp = await client.get(f"{DSM_HOST}/webapi/entry.cgi", params={
+ "api": "SYNO.API.Auth", "version": 7, "method": "login",
+ "account": DSM_ACCOUNT, "passwd": DSM_PASSWORD,
+ }, timeout=15)
+ data = resp.json()
+ if data.get("success"):
+ sid = data["data"]["sid"]
+ logger.info("DSM login successful")
+ return sid
+ raise RuntimeError(f"DSM login failed: {data}")
+
+
+async def api_call(client: httpx.AsyncClient, api: str, version: int,
+ method: str, params: dict | None = None, retries: int = 1) -> dict:
+ global sid
+ all_params = {"api": api, "version": version, "method": method, "_sid": sid}
+ if params:
+ all_params.update(params)
+ resp = await client.get(f"{DSM_HOST}/webapi/entry.cgi",
+ params=all_params, timeout=15)
+ data = resp.json()
+ if data.get("success"):
+ return data["data"]
+ err_code = data.get("error", {}).get("code")
+ if err_code == 119 and retries > 0:
+ logger.warning("Session expired, re-logging in...")
+ await dsm_login(client)
+ return await api_call(client, api, version, method, params, retries - 1)
+ raise RuntimeError(f"API {api}.{method} failed: {data}")
+
+
+async def download_file(client: httpx.AsyncClient, post_id: int) -> bytes:
+ global sid
+ resp = await client.get(f"{DSM_HOST}/webapi/entry.cgi", params={
+ "api": "SYNO.Chat.Post.File", "version": 2, "method": "get",
+ "post_id": post_id, "_sid": sid,
+ }, timeout=30)
+ if resp.status_code != 200:
+ raise RuntimeError(f"File download failed: HTTP {resp.status_code}")
+ ct = resp.headers.get("content-type", "")
+ if "json" in ct:
+ data = resp.json()
+ if not data.get("success"):
+ raise RuntimeError(f"File download API error: {data}")
+ return resp.content
+
+
+def extract_posts(data) -> list:
+ if isinstance(data, list):
+ return data
+ if isinstance(data, dict):
+ for key in ("posts", "data"):
+ if key in data and isinstance(data[key], list):
+ return data[key]
+ return []
+
+
+async def send_chat_ack():
+ if not SYNOLOGY_CHAT_WEBHOOK_URL:
+ logger.warning("No SYNOLOGY_CHAT_WEBHOOK_URL, skipping ack")
+ return
+ async with httpx.AsyncClient(verify=False) as client:
+ payload = json.dumps(
+ {"text": "\U0001f4f7 사진이 확인되었습니다. 설명을 입력해주세요."})
+ await client.post(SYNOLOGY_CHAT_WEBHOOK_URL,
+ data={"payload": payload}, timeout=10)
+
+
+async def poll_channel(client: httpx.AsyncClient):
+ global last_seen_post_id
+ try:
+ data = await api_call(client, "SYNO.Chat.Post", 8, "list",
+ {"channel_id": CHAT_CHANNEL_ID, "limit": 10})
+ posts = extract_posts(data)
+
+ now_ms = int(time.time() * 1000)
+ expired = [uid for uid, info in pending_photos.items()
+ if now_ms - info["create_at"] > 300_000]
+ for uid in expired:
+ del pending_photos[uid]
+ logger.info(f"Expired pending photo for user_id={uid}")
+
+ for post in posts:
+ post_id = post.get("post_id", 0)
+ if post_id <= last_seen_post_id:
+ continue
+ if (post.get("type") == "file"
+ and post.get("file_props", {}).get("is_image")):
+ user_id = post.get("creator_id", 0)
+ pending_photos[user_id] = {
+ "post_id": post_id,
+ "create_at": post.get("create_at", now_ms),
+ "filename": post.get("file_props", {}).get("name", "unknown"),
+ }
+ logger.info(f"Photo detected: post_id={post_id} user_id={user_id} "
+ f"file={pending_photos[user_id]['filename']}")
+ await send_chat_ack()
+
+ if posts:
+ max_id = max(p.get("post_id", 0) for p in posts)
+ if max_id > last_seen_post_id:
+ last_seen_post_id = max_id
+ except Exception as e:
+ logger.error(f"Poll error: {e}")
+
+
+async def polling_loop():
+ async with httpx.AsyncClient(verify=False) as client:
+ # Login
+ for attempt in range(3):
+ try:
+ await dsm_login(client)
+ break
+ except Exception as e:
+ logger.error(f"DSM login attempt {attempt+1} failed: {e}")
+ if attempt == 2:
+ logger.error("All login attempts failed, polling disabled")
+ return
+ await asyncio.sleep(5)
+
+ # Initialize last_seen_post_id
+ global last_seen_post_id
+ try:
+ data = await api_call(client, "SYNO.Chat.Post", 8, "list",
+ {"channel_id": CHAT_CHANNEL_ID, "limit": 5})
+ posts = extract_posts(data)
+ if posts:
+ last_seen_post_id = max(p.get("post_id", 0) for p in posts)
+ logger.info(f"Initialized last_seen_post_id={last_seen_post_id}")
+ except Exception as e:
+ logger.warning(f"Failed to init last_seen_post_id: {e}")
+
+ # Poll loop
+ while True:
+ await poll_channel(client)
+ await asyncio.sleep(POLL_INTERVAL)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ task = asyncio.create_task(polling_loop())
+ yield
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+def is_heic(data: bytes, filename: str) -> bool:
+ if filename.lower().endswith((".heic", ".heif")):
+ return True
+ if len(data) >= 12:
+ ftyp = data[4:12].decode("ascii", errors="ignore")
+ if "ftyp" in ftyp and any(x in ftyp for x in ["heic", "heix", "mif1"]):
+ return True
+ return False
+
+
+@app.post("/chat/recent-photo")
+async def recent_photo(request: Request):
+ body = await request.json()
+ channel_id = body.get("channel_id", CHAT_CHANNEL_ID)
+ user_id = body.get("user_id", 0)
+ before_timestamp = body.get("before_timestamp", int(time.time() * 1000))
+
+ # 1. Check pending_photos (polling already detected)
+ photo_info = pending_photos.get(user_id)
+
+ # 2. Fallback: search via API
+ if not photo_info:
+ try:
+ async with httpx.AsyncClient(verify=False) as client:
+ if not sid:
+ await dsm_login(client)
+ data = await api_call(client, "SYNO.Chat.Post", 8, "list",
+ {"channel_id": channel_id, "limit": 20})
+ posts = extract_posts(data)
+ now_ms = int(time.time() * 1000)
+ for post in sorted(posts,
+ key=lambda p: p.get("create_at", 0),
+ reverse=True):
+ if (post.get("type") == "file"
+ and post.get("file_props", {}).get("is_image")
+ and post.get("creator_id") == user_id
+ and post.get("create_at", 0) < before_timestamp
+ and now_ms - post.get("create_at", 0) < 300_000):
+ photo_info = {
+ "post_id": post["post_id"],
+ "create_at": post["create_at"],
+ "filename": post.get("file_props", {}).get("name",
+ "unknown"),
+ }
+ logger.info(f"Fallback found photo: post_id={post['post_id']}")
+ break
+ except Exception as e:
+ logger.error(f"Fallback search error: {e}")
+
+ if not photo_info:
+ return JSONResponse({"found": False})
+
+ # 3. Download
+ try:
+ async with httpx.AsyncClient(verify=False) as client:
+ if not sid:
+ await dsm_login(client)
+ file_data = await download_file(client, photo_info["post_id"])
+ except Exception as e:
+ logger.error(f"File download error: {e}")
+ return JSONResponse({"found": False, "error": str(e)})
+
+ # 4. HEIC conversion if needed
+ filename = photo_info.get("filename", "unknown")
+ fmt = "jpeg"
+ b64 = base64.b64encode(file_data).decode()
+
+ if is_heic(file_data, filename):
+ try:
+ async with httpx.AsyncClient() as client:
+ conv_resp = await client.post(
+ f"{HEIC_CONVERTER_URL}/convert/heic-to-jpeg",
+ json={"base64": b64}, timeout=30)
+ conv_data = conv_resp.json()
+ b64 = conv_data["base64"]
+ fmt = "jpeg"
+ logger.info(f"HEIC converted: {filename}")
+ except Exception as e:
+ logger.warning(f"HEIC conversion failed: {e}, returning raw")
+ fmt = "heic"
+
+ # 5. Consume pending photo
+ if user_id in pending_photos:
+ del pending_photos[user_id]
+
+ return JSONResponse({
+ "found": True,
+ "base64": b64,
+ "format": fmt,
+ "filename": filename,
+ "size": len(file_data),
+ })
+
+
+@app.get("/health")
+async def health():
+ return {
+ "status": "ok",
+ "sid_active": bool(sid),
+ "last_seen_post_id": last_seen_post_id,
+ "pending_photos": {
+ str(uid): info["filename"]
+ for uid, info in pending_photos.items()
+ },
+ }
diff --git a/com.syn-chat-bot.caldav-bridge.plist b/com.syn-chat-bot.caldav-bridge.plist
new file mode 100644
index 0000000..0740808
--- /dev/null
+++ b/com.syn-chat-bot.caldav-bridge.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.caldav-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('caldav_bridge:app',host='127.0.0.1',port=8092)
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/caldav-bridge.log
+ StandardErrorPath
+ /tmp/caldav-bridge.err
+
+
diff --git a/com.syn-chat-bot.chat-bridge.plist b/com.syn-chat-bot.chat-bridge.plist
new file mode 100644
index 0000000..dc93e80
--- /dev/null
+++ b/com.syn-chat-bot.chat-bridge.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.chat-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('chat_bridge:app',host='127.0.0.1',port=8091)
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/chat-bridge.log
+ StandardErrorPath
+ /tmp/chat-bridge.err
+
+
diff --git a/com.syn-chat-bot.devonthink-bridge.plist b/com.syn-chat-bot.devonthink-bridge.plist
new file mode 100644
index 0000000..f916d01
--- /dev/null
+++ b/com.syn-chat-bot.devonthink-bridge.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.devonthink-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('devonthink_bridge:app',host='127.0.0.1',port=8093)
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/devonthink-bridge.log
+ StandardErrorPath
+ /tmp/devonthink-bridge.err
+
+
diff --git a/com.syn-chat-bot.heic-converter.plist b/com.syn-chat-bot.heic-converter.plist
new file mode 100644
index 0000000..6951e0a
--- /dev/null
+++ b/com.syn-chat-bot.heic-converter.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ Label
+ 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
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/heic-converter.log
+ StandardErrorPath
+ /tmp/heic-converter.err
+
+
diff --git a/com.syn-chat-bot.inbox-processor.plist b/com.syn-chat-bot.inbox-processor.plist
new file mode 100644
index 0000000..8cf5c6e
--- /dev/null
+++ b/com.syn-chat-bot.inbox-processor.plist
@@ -0,0 +1,23 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.inbox-processor
+ 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'); from inbox_processor import main; main()
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ StartInterval
+ 300
+ StandardOutPath
+ /tmp/inbox-processor.log
+ StandardErrorPath
+ /tmp/inbox-processor.err
+
+
diff --git a/com.syn-chat-bot.news-digest.plist b/com.syn-chat-bot.news-digest.plist
new file mode 100644
index 0000000..2deb091
--- /dev/null
+++ b/com.syn-chat-bot.news-digest.plist
@@ -0,0 +1,28 @@
+
+
+
+
+ Label
+ com.syn-chat-bot.news-digest
+ 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'); from news_digest import main; main()
+
+ WorkingDirectory
+ /Users/hyungi/Documents/code/syn-chat-bot
+ StartCalendarInterval
+
+ Hour
+ 7
+ Minute
+ 0
+
+ StandardOutPath
+ /tmp/news-digest.log
+ StandardErrorPath
+ /tmp/news-digest.err
+
+
diff --git a/devonthink_bridge.py b/devonthink_bridge.py
new file mode 100644
index 0000000..dcc0e23
--- /dev/null
+++ b/devonthink_bridge.py
@@ -0,0 +1,125 @@
+"""DEVONthink Bridge — AppleScript REST API 래퍼 (port 8093)"""
+
+import json
+import logging
+import os
+import subprocess
+
+from dotenv import load_dotenv
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("devonthink_bridge")
+
+DT_APP = os.getenv("DEVONTHINK_APP_NAME", "DEVONthink")
+
+app = FastAPI()
+
+
+def _run_applescript(script: str, timeout: int = 15) -> str:
+ """AppleScript 실행."""
+ result = subprocess.run(
+ ["osascript", "-e", script],
+ capture_output=True, text=True, timeout=timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"AppleScript error: {result.stderr.strip()}")
+ return result.stdout.strip()
+
+
+def _escape_as(s: str) -> str:
+ """AppleScript 문자열 이스케이프."""
+ return s.replace("\\", "\\\\").replace('"', '\\"')
+
+
+@app.post("/save")
+async def save_record(request: Request):
+ body = await request.json()
+ title = body.get("title", "Untitled")
+ content = body.get("content", "")
+ record_type = body.get("type", "markdown")
+ database = body.get("database")
+ group = body.get("group")
+ tags = body.get("tags", [])
+
+ # Map type to DEVONthink record type
+ type_map = {"markdown": "markdown", "text": "txt", "html": "html"}
+ dt_type = type_map.get(record_type, "markdown")
+
+ # Build AppleScript
+ esc_title = _escape_as(title)
+ esc_content = _escape_as(content)
+ tags_str = ", ".join(f'"{_escape_as(t)}"' for t in tags) if tags else ""
+
+ if database:
+ db_line = f'set theDB to open database "{_escape_as(database)}"'
+ else:
+ db_line = "set theDB to first database"
+
+ if group:
+ group_line = f'set theGroup to get record at "/{_escape_as(group)}" in theDB'
+ else:
+ group_line = "set theGroup to incoming group of theDB"
+
+ script = f'''tell application "{DT_APP}"
+ {db_line}
+ {group_line}
+ set theRecord to create record with {{name:"{esc_title}", type:{dt_type}, plain text:"{esc_content}"}} in theGroup
+ {f'set tags of theRecord to {{{tags_str}}}' if tags_str else ''}
+ set theUUID to uuid of theRecord
+ set theName to name of theRecord
+ return theUUID & "|" & theName
+end tell'''
+
+ try:
+ result = _run_applescript(script)
+ parts = result.split("|", 1)
+ uuid_val = parts[0] if parts else result
+ name_val = parts[1] if len(parts) > 1 else title
+ logger.info(f"Record saved: {uuid_val} '{name_val}'")
+ return JSONResponse({"success": True, "uuid": uuid_val, "name": name_val})
+ except Exception as e:
+ logger.error(f"Save failed: {e}")
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
+
+
+@app.get("/databases")
+async def list_databases():
+ script = f'''tell application "{DT_APP}"
+ set dbList to {{}}
+ repeat with theDB in databases
+ set end of dbList to (name of theDB) & "|" & (uuid of theDB)
+ end repeat
+ set AppleScript's text item delimiters to "\\n"
+ return dbList as text
+end tell'''
+
+ try:
+ result = _run_applescript(script)
+ databases = []
+ for line in result.strip().split("\n"):
+ if "|" in line:
+ parts = line.split("|", 1)
+ databases.append({"name": parts[0], "uuid": parts[1]})
+ return JSONResponse({"databases": databases})
+ except Exception as e:
+ logger.error(f"List databases failed: {e}")
+ return JSONResponse({"databases": [], "error": str(e)}, status_code=500)
+
+
+@app.get("/health")
+async def health():
+ devonthink_running = False
+ try:
+ result = _run_applescript(
+ f'tell application "System Events" to return (name of processes) contains "{DT_APP}"',
+ timeout=5,
+ )
+ devonthink_running = result.lower() == "true"
+ except Exception:
+ pass
+
+ return {"status": "ok", "devonthink_running": devonthink_running}
diff --git a/docker-compose.yml b/docker-compose.yml
index 5db5a12..4c0a0fb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,6 +28,10 @@ services:
- LOCAL_OLLAMA_URL=${LOCAL_OLLAMA_URL}
- GPU_OLLAMA_URL=${GPU_OLLAMA_URL}
- QDRANT_URL=${QDRANT_URL:-http://host.docker.internal:6333}
+ - 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
- NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url
volumes:
- ./n8n/data:/home/node/.n8n
diff --git a/docs/architecture.md b/docs/architecture.md
index 4f6c0ed..1b57188 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -10,7 +10,7 @@
│ Outgoing Webhook
▼
┌────────────────────────────────────────────────────────────┐
-│ bot-n8n (맥미니 Docker :5678) — 37노드 파이프라인 │
+│ bot-n8n (맥미니 Docker :5678) — 51노드 파이프라인 │
│ │
│ ⓪ 토큰 검증 + Rate Limit (username별 10초/5건) │
│ │
@@ -20,10 +20,19 @@
│ ② 명령어 체크 (/설정, /모델, /성격, /문서등록, /보고서) │
│ └─ 권한 체크 (ADMIN_USERNAMES allowlist) │
│ │
-│ ③ GPU Qwen 9B 분류 v2 (10초 타임아웃) │
+│ ③ GPU Qwen 9B 분류 v3 (10초 타임아웃) │
│ → {intent, response_tier, needs_rag, rag_target, ...} │
│ └─ 실패 시 fallback → api_light │
│ │
+│ ③-1 Route by Intent │
+│ ├─ log_event → Qwen 추출 → bge-m3 → tk_company 저장 │
+│ ├─ report → 현장 리포트 → field_reports DB 저장 │
+│ ├─ calendar → CalDAV Bridge → Synology Calendar │
+│ ├─ reminder → calendar로 통합 처리 │
+│ ├─ mail → 메일 요약 조회 (mail_logs) │
+│ ├─ note → DEVONthink Bridge → 문서 저장 │
+│ └─ fallback → 일반 대화 (RAG + 3단계 라우팅) │
+│ │
│ ④ [needs_rag=true] 멀티-컬렉션 RAG 검색 │
│ documents + tk_company + chat_memory │
│ → bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 │
@@ -34,7 +43,7 @@
│ └─ api_heavy → 예산 체크 → Claude Opus (or 다운그레이드) │
│ │
│ ⑥ 응답 전송 + chat_logs + api_usage_monthly │
-│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 │
+│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 + DEVONthink│
│ └─ classification_logs 기록 │
└──┬──────────┬───────────┬───────────┬──────────────────────┘
│ │ │ │
@@ -47,6 +56,46 @@
│ │ │tk_company││비전모델 │ │(분류+응답) │
│ │ │chat_memory│ │ │ │
└──────┘ └────────┘ └─────────┘ └──────────────┘
+
+┌────────────────────────────────────────────────────────────┐
+│ 별도 워크플로우 │
+│ Mail Processing Pipeline (7노드) │
+│ └─ MailPlus IMAP 폴링 → Qwen 분류 → mail_logs 저장 │
+└────────────────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────────────────┐
+│ 네이티브 서비스 (맥미니) │
+│ │
+│ heic_converter.py (:8090) │
+│ └─ HEIC→JPEG 변환 (macOS sips) │
+│ │
+│ chat_bridge.py (:8091) │
+│ ├─ DSM Chat API 폴링 (5초) → 사진 감지 + ack │
+│ ├─ POST /chat/recent-photo → 사진 다운+변환 │
+│ └─ HEIC 자동 변환 (→ heic_converter :8090) │
+│ │
+│ caldav_bridge.py (:8092) │
+│ └─ CalDAV REST 래퍼 (Synology Calendar CRUD) │
+│ │
+│ devonthink_bridge.py (:8093) │
+│ └─ DEVONthink AppleScript 래퍼 (문서 저장·검색) │
+│ │
+│ inbox_processor.py (LaunchAgent, 5분) │
+│ └─ OmniFocus Inbox 폴링 → Qwen 분류 → 자동 정리 │
+│ │
+│ news_digest.py (LaunchAgent, 매일 07:00) │
+│ └─ RSS 뉴스 수집 → Qwen 번역·요약 → Qdrant + Synology Chat│
+└────────────────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────┐
+│ NAS 서비스 (192.168.1.227) │
+│ Synology Chat / Calendar (CalDAV) / MailPlus │
+└────────────────────────────────────────────────┘
+
+┌────────────────────────────────────────────────┐
+│ DEVONthink 4 (맥미니) │
+│ AppleScript 경유 문서 저장·검색 │
+└────────────────────────────────────────────────┘
```
## 3단계 라우팅 상세
@@ -64,11 +113,11 @@
- ~50% → Haiku (저비용)
- ~10% → Opus (복잡한 질문만)
-### 분류기 v2 출력 스키마
+### 분류기 v3 출력 스키마
```json
{
- "intent": "greeting|question|calendar|reminder|mail|photo|command|report|other",
+ "intent": "greeting|question|log_event|calendar|reminder|mail|note|photo|command|report|other",
"response_tier": "local|api_light|api_heavy",
"needs_rag": true,
"rag_target": ["documents", "tk_company", "chat_memory"],
@@ -78,6 +127,13 @@
}
```
+**Intent별 처리:**
+- `calendar` — CalDAV Bridge로 일정 생성/조회 (Synology Calendar)
+- `reminder` — calendar로 통합 (알림 시간 포함 일정 생성)
+- `mail` — mail_logs에서 최근 메일 요약 조회
+- `note` — DEVONthink Bridge로 문서 저장
+```
+
### 프리필터 → 분류기 → 모델 라우팅 흐름
```
@@ -199,6 +255,19 @@ Should Memorize?
-- api_usage_monthly: API 사용량 + 예산 상한
-- year + month + tier UNIQUE
-- estimated_cost vs budget_limit 비교 → 다운그레이드
+
+-- news_digest_log: 뉴스 요약 이력
+-- source_url UNIQUE, translated_title, summary
+-- published_at, processed_at, qdrant_point_id
+```
+
+### calendar_events 확장 (v3)
+
+```sql
+-- v3에서 추가된 컬럼:
+-- caldav_uid: CalDAV 이벤트 UID (Synology Calendar 연동)
+-- description: 이벤트 상세 설명
+-- created_by: 생성 출처 (chat, caldav_sync, manual)
```
### SLA 기준표
@@ -232,18 +301,26 @@ Should Memorize?
- **분류기 fallback**: Qwen 10초 타임아웃 → {response_tier: "api_light"}
- **리랭커 fallback**: bge-reranker 실패 → Qdrant score 정렬
- **비전 모델 fallback**: 사진 분석 실패 → 사용자 설명만으로 구조화
+- **HEIC 자동 변환**: macOS sips 기반 HEIC→JPEG 변환 (heic_converter.py, port 8090)
+- **사진 브릿지**: chat_bridge.py가 DSM Chat API 폴링 → 사진 감지 → ack → n8n 요청 시 다운로드+변환+base64 반환
+- **이미지 base64 변환**: Ollama API는 URL 미지원, chat_bridge가 자동 다운로드 후 base64 전달
-## 메인 채팅 파이프라인 v2 (37노드)
+## 메인 채팅 파이프라인 v3 (51노드)
```
Webhook POST /chat
│
▼
-[Parse Input] — 토큰 검증 + Rate Limit
+[Parse Input] — 토큰 검증 + Rate Limit + channelId/userId 추출
│
├─ rejected → [Reject Response] → Send + Respond
│
▼
+[Has Pending Doc?] — 문서 등록 대기
+ │
+ ├─ pending → [Process Document] → [Log Doc Ingestion] → Send + Respond
+ │
+ ▼
[Regex Pre-filter] — 인사/감사 정규식
│
├─ match → [Pre-filter Response] → Send + Respond
@@ -255,10 +332,19 @@ Webhook POST /chat
│ ├─ DB 필요 → [Command DB Query] → [Format] → Send + Respond
│ └─ 직접 → [Direct Response] → Send + Respond
│
- └─ false → [Qwen Classify v2] (10초 타임아웃)
+ └─ false → [Qwen Classify v3] (10초 타임아웃)
│
├─ [Log Classification] (비동기, PostgreSQL)
│
+ ├─ [Route by Intent]
+ │ ├─ log_event → [Handle Log Event] (Qwen 추출→임베딩→tk_company 저장→확인응답)
+ │ ├─ report → [Handle Field Report] → [Save Field Report DB]
+ │ ├─ calendar → [Handle Calendar] → CalDAV Bridge → 확인응답
+ │ ├─ reminder → [Handle Calendar] (calendar로 통합)
+ │ ├─ mail → [Handle Mail] → mail_logs 조회 → 요약응답
+ │ ├─ note → [Handle Note] → DEVONthink Bridge → 확인응답
+ │ └─ fallback → [Needs RAG?]
+ │
├─ needs_rag=true
│ → [Get Embedding] → [Multi-Collection Search]
│ → [Build RAG Context] (출처 표시)
@@ -278,7 +364,7 @@ Webhook POST /chat
│
▼ [비동기]
[Memorization Check] → [Should Memorize?]
- ├─ true → [Embed & Save Memory]
+ ├─ true → [Embed & Save Memory] + [DEVONthink 저장]
└─ false → (끝)
```
@@ -315,17 +401,31 @@ Webhook POST /chat
간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.
```
-## 향후 기능 (Phase 4-6)
+## 구현 완료
### Phase 4: 회사 문서 등록
- `/문서등록 [부서] [유형] [제목]` → 청킹 → tk_company 저장
- hash 중복 체크, 문서 버전 관리
-### Phase 5: 현장 리포팅
-- 사진 + 텍스트 → 비전 모델 → 구조화 → field_reports + tk_company
-- `/보고서 [영역] [년월]` → 월간 보고서 생성
-- SLA 트래킹 + 긴급 에스컬레이션
+### Phase 5: 현장 리포팅 + API 사용량 추적
+- 사진 + 텍스트 → Claude Haiku Vision → 구조화 → field_reports + tk_company
+- `/보고서 [영역] [년월]` → 월간 보고서 생성 (report_cache)
+- API 사용량 추적 (api_usage_monthly UPSERT)
+- HEIC→JPEG 변환 (heic_converter.py) + chat_bridge.py (DSM Chat API 브릿지)
-### Phase 6: 메일 + 캘린더
-- IMAP 폴링 → Qwen 분석 → mail_logs + Qdrant
-- CalDAV 연동 → calendar_events
+### Phase 6: 캘린더·메일·DEVONthink·OmniFocus·뉴스
+- 분류기 v3: calendar, reminder, mail, note intent 추가
+- caldav_bridge.py: CalDAV REST 래퍼 (Synology Calendar CRUD)
+- devonthink_bridge.py: DEVONthink AppleScript 래퍼
+- inbox_processor.py: OmniFocus Inbox 폴링 (LaunchAgent, 5분)
+- news_digest.py: 뉴스 번역·요약 (LaunchAgent, 매일 07:00)
+- Mail Processing Pipeline (7노드): IMAP 폴링 → 분류 → mail_logs
+- 51노드 파이프라인: calendar/mail/note 핸들러 추가
+
+## 향후 기능 (Phase 7+)
+
+- SLA 트래킹 스케줄 워크플로우 + 긴급 에스컬레이션
+- CalDAV 양방향 동기화 (Synology Calendar → bot-postgres)
+- 메일 발송 (SMTP via MailPlus)
+- reminder 실구현 (알림 시간에 Synology Chat 푸시)
+- DEVONthink 검색 결과 RAG 연동
diff --git a/heic_converter.py b/heic_converter.py
new file mode 100644
index 0000000..669fb83
--- /dev/null
+++ b/heic_converter.py
@@ -0,0 +1,25 @@
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+import subprocess, tempfile, base64, os
+
+app = FastAPI()
+
+@app.post("/convert/heic-to-jpeg")
+async def convert(request: Request):
+ body = await request.json()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ heic_path = os.path.join(tmpdir, "input.heic")
+ jpeg_path = os.path.join(tmpdir, "output.jpg")
+
+ with open(heic_path, "wb") as f:
+ f.write(base64.b64decode(body["base64"]))
+
+ subprocess.run(
+ ["sips", "-s", "format", "jpeg", heic_path, "--out", jpeg_path],
+ capture_output=True, timeout=30, check=True
+ )
+
+ with open(jpeg_path, "rb") as f:
+ jpeg_b64 = base64.b64encode(f.read()).decode()
+
+ return JSONResponse({"base64": jpeg_b64, "format": "jpeg"})
diff --git a/inbox_processor.py b/inbox_processor.py
new file mode 100644
index 0000000..4e32701
--- /dev/null
+++ b/inbox_processor.py
@@ -0,0 +1,225 @@
+"""OmniFocus Inbox Processor — Inbox 항목 분류 + 라우팅 (LaunchAgent, 5분 주기)"""
+
+import json
+import logging
+import os
+import subprocess
+import sys
+
+import httpx
+from dotenv import load_dotenv
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("inbox_processor")
+
+GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434")
+CALDAV_BRIDGE_URL = os.getenv("CALDAV_BRIDGE_URL", "http://127.0.0.1:8092")
+DEVONTHINK_BRIDGE_URL = os.getenv("DEVONTHINK_BRIDGE_URL", "http://127.0.0.1:8093")
+
+
+def run_applescript(script: str, timeout: int = 15) -> str:
+ result = subprocess.run(
+ ["osascript", "-e", script],
+ capture_output=True, text=True, timeout=timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"AppleScript error: {result.stderr.strip()}")
+ return result.stdout.strip()
+
+
+def get_inbox_items() -> list[dict]:
+ """OmniFocus Inbox에서 bot-processed 태그가 없는 항목 조회."""
+ script = '''
+tell application "OmniFocus"
+ tell default document
+ set output to ""
+ set inboxTasks to every inbox task
+ repeat with t in inboxTasks
+ set tagNames to {}
+ repeat with tg in (tags of t)
+ set end of tagNames to name of tg
+ end repeat
+ set AppleScript's text item delimiters to ","
+ set tagStr to tagNames as text
+ if tagStr does not contain "bot-processed" then
+ set taskId to id of t
+ set taskName to name of t
+ set taskNote to note of t
+ set output to output & taskId & "|||" & taskName & "|||" & taskNote & "\\n"
+ end if
+ end repeat
+ return output
+ end tell
+end tell'''
+ try:
+ result = run_applescript(script)
+ items = []
+ for line in result.strip().split("\n"):
+ if "|||" not in line:
+ continue
+ parts = line.split("|||", 2)
+ items.append({
+ "id": parts[0].strip(),
+ "name": parts[1].strip() if len(parts) > 1 else "",
+ "note": parts[2].strip() if len(parts) > 2 else "",
+ })
+ return items
+ except Exception as e:
+ logger.error(f"Failed to get inbox items: {e}")
+ return []
+
+
+def mark_processed(task_id: str) -> None:
+ """항목에 bot-processed 태그 추가."""
+ script = f'''
+tell application "OmniFocus"
+ tell default document
+ try
+ set theTag to first tag whose name is "bot-processed"
+ on error
+ set theTag to make new tag with properties {{name:"bot-processed"}}
+ end try
+ set theTask to first flattened task whose id is "{task_id}"
+ add theTag to tags of theTask
+ end tell
+end tell'''
+ try:
+ run_applescript(script)
+ logger.info(f"Marked as processed: {task_id}")
+ except Exception as e:
+ logger.error(f"Failed to mark processed {task_id}: {e}")
+
+
+def complete_task(task_id: str) -> None:
+ """OmniFocus 항목 완료 처리."""
+ script = f'''
+tell application "OmniFocus"
+ tell default document
+ set theTask to first flattened task whose id is "{task_id}"
+ mark complete theTask
+ end tell
+end tell'''
+ try:
+ run_applescript(script)
+ logger.info(f"Completed: {task_id}")
+ except Exception as e:
+ logger.error(f"Failed to complete {task_id}: {e}")
+
+
+def classify_item(name: str, note: str) -> dict:
+ """Qwen 3.5로 항목 분류."""
+ prompt = f"""OmniFocus Inbox 항목을 분류하세요. JSON만 출력.
+
+{{
+ "type": "calendar|note|task|reminder",
+ "title": "제목/일정 이름",
+ "start": "YYYY-MM-DDTHH:MM:SS (calendar/reminder일 때)",
+ "location": "장소 (calendar일 때, 없으면 null)",
+ "content": "메모 내용 (note일 때)"
+}}
+
+type 판단:
+- calendar: 시간이 명시된 일정/약속/회의
+- reminder: 알림/리마인드 (시간 기반)
+- note: 메모/기록/아이디어
+- task: 할 일/업무 (OmniFocus에 유지)
+
+항목: {name}
+{f'메모: {note}' if note else ''}"""
+
+ 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},
+ timeout=15,
+ )
+ return json.loads(resp.json()["response"])
+ except Exception as e:
+ logger.error(f"Classification failed: {e}")
+ return {"type": "task", "title": name, "content": note}
+
+
+def route_calendar(cls: dict, task_id: str) -> None:
+ """CalDAV 브릿지로 일정 생성."""
+ try:
+ resp = httpx.post(
+ f"{CALDAV_BRIDGE_URL}/calendar/create",
+ json={
+ "title": cls.get("title", ""),
+ "start": cls.get("start", ""),
+ "location": cls.get("location"),
+ },
+ timeout=10,
+ )
+ if resp.json().get("success"):
+ logger.info(f"Calendar event created: {cls.get('title')}")
+ mark_processed(task_id)
+ complete_task(task_id)
+ else:
+ logger.error(f"Calendar create failed: {resp.text[:200]}")
+ mark_processed(task_id)
+ except Exception as e:
+ logger.error(f"Calendar routing failed: {e}")
+ mark_processed(task_id)
+
+
+def route_note(cls: dict, task_id: str) -> None:
+ """DEVONthink 브릿지로 메모 저장."""
+ content = cls.get("content") or cls.get("title", "")
+ title = cls.get("title", "OmniFocus 메모")
+
+ try:
+ resp = httpx.post(
+ f"{DEVONTHINK_BRIDGE_URL}/save",
+ json={
+ "title": title,
+ "content": content,
+ "type": "markdown",
+ "tags": ["omnifocus", "inbox"],
+ },
+ timeout=10,
+ )
+ if resp.json().get("success"):
+ logger.info(f"Note saved to DEVONthink: {title}")
+ mark_processed(task_id)
+ complete_task(task_id)
+ except Exception as e:
+ logger.error(f"Note routing failed: {e}")
+ mark_processed(task_id)
+
+
+def route_task(cls: dict, task_id: str) -> None:
+ """Task는 OmniFocus에 유지. 태그만 추가."""
+ mark_processed(task_id)
+ logger.info(f"Task kept in OmniFocus: {cls.get('title', '')}")
+
+
+def main():
+ logger.info("Inbox processor started")
+
+ items = get_inbox_items()
+ if not items:
+ logger.info("No unprocessed inbox items")
+ return
+
+ logger.info(f"Processing {len(items)} inbox items")
+
+ for item in items:
+ logger.info(f"Processing: {item['name']}")
+ cls = classify_item(item["name"], item["note"])
+ item_type = cls.get("type", "task")
+
+ if item_type == "calendar" or item_type == "reminder":
+ route_calendar(cls, item["id"])
+ elif item_type == "note":
+ route_note(cls, item["id"])
+ else: # task
+ route_task(cls, item["id"])
+
+ logger.info("Inbox processing complete")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/init/migrate-v3.sql b/init/migrate-v3.sql
new file mode 100644
index 0000000..4bc7f71
--- /dev/null
+++ b/init/migrate-v3.sql
@@ -0,0 +1,39 @@
+-- migrate-v3.sql: Phase 6 확장 — 캘린더/메일/뉴스 마이그레이션
+-- 실행: docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v3.sql
+
+-- ========================
+-- 캘린더 확장
+-- ========================
+
+ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS caldav_uid VARCHAR(200);
+ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS description TEXT;
+ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS created_by VARCHAR(100);
+
+CREATE INDEX IF NOT EXISTS idx_cal_start ON calendar_events(start_time);
+CREATE INDEX IF NOT EXISTS idx_cal_caldav_uid ON calendar_events(caldav_uid);
+CREATE INDEX IF NOT EXISTS idx_cal_created_by ON calendar_events(created_by);
+
+-- ========================
+-- 메일 인덱스 (Stage C용)
+-- ========================
+
+CREATE INDEX IF NOT EXISTS idx_mail_date ON mail_logs(mail_date);
+CREATE INDEX IF NOT EXISTS idx_mail_label ON mail_logs(label);
+CREATE INDEX IF NOT EXISTS idx_mail_account ON mail_logs(account_id);
+
+-- ========================
+-- 뉴스 다이제스트 로그 (Stage E용)
+-- ========================
+
+CREATE TABLE IF NOT EXISTS news_digest_log (
+ id SERIAL PRIMARY KEY,
+ article_url TEXT UNIQUE,
+ source VARCHAR(50),
+ original_lang VARCHAR(10),
+ title_ko TEXT,
+ summary_ko TEXT,
+ processed_at TIMESTAMPTZ DEFAULT NOW(),
+ qdrant_id VARCHAR(100),
+ devonthink_uuid VARCHAR(100)
+);
+CREATE INDEX IF NOT EXISTS idx_news_processed ON news_digest_log(processed_at);
diff --git a/manage_services.sh b/manage_services.sh
new file mode 100755
index 0000000..ef6a294
--- /dev/null
+++ b/manage_services.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+# manage_services.sh — LaunchAgent 일괄 관리
+# 사용법: ./manage_services.sh status | start | stop | restart
+
+SERVICES=(
+ "com.syn-chat-bot.chat-bridge"
+ "com.syn-chat-bot.heic-converter"
+ "com.syn-chat-bot.caldav-bridge"
+ "com.syn-chat-bot.devonthink-bridge"
+ "com.syn-chat-bot.inbox-processor"
+ "com.syn-chat-bot.news-digest"
+)
+
+PLIST_DIR="$HOME/Library/LaunchAgents"
+SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+case "$1" in
+ status)
+ for s in "${SERVICES[@]}"; do
+ if launchctl list "$s" &>/dev/null; then
+ pid=$(launchctl list "$s" 2>/dev/null | awk 'NR==2{print $1}')
+ echo "✓ $s: LOADED (PID: ${pid:-?})"
+ else
+ echo "✗ $s: NOT LOADED"
+ fi
+ done
+ ;;
+ start)
+ for s in "${SERVICES[@]}"; do
+ plist="$PLIST_DIR/${s}.plist"
+ src="$SRC_DIR/${s}.plist"
+ if [ ! -f "$plist" ] && [ -f "$src" ]; then
+ cp "$src" "$plist"
+ echo " Copied $s.plist to LaunchAgents"
+ fi
+ if [ -f "$plist" ]; then
+ launchctl load "$plist" 2>/dev/null
+ echo "✓ $s: started"
+ else
+ echo "✗ $s: plist not found"
+ fi
+ done
+ ;;
+ stop)
+ for s in "${SERVICES[@]}"; do
+ plist="$PLIST_DIR/${s}.plist"
+ if [ -f "$plist" ]; then
+ launchctl unload "$plist" 2>/dev/null
+ echo "✓ $s: stopped"
+ fi
+ done
+ ;;
+ restart)
+ "$0" stop
+ sleep 1
+ "$0" start
+ ;;
+ install)
+ echo "Installing plist files to $PLIST_DIR..."
+ for s in "${SERVICES[@]}"; do
+ src="$SRC_DIR/${s}.plist"
+ if [ -f "$src" ]; then
+ cp "$src" "$PLIST_DIR/"
+ echo " ✓ $s"
+ else
+ echo " ✗ $s: source plist not found"
+ fi
+ done
+ echo "Done. Run '$0 start' to start services."
+ ;;
+ *)
+ echo "Usage: $0 {status|start|stop|restart|install}"
+ echo ""
+ echo " status - Show service status"
+ echo " start - Load and start all services"
+ echo " stop - Unload all services"
+ echo " restart - Stop then start"
+ echo " install - Copy plist files to ~/Library/LaunchAgents"
+ ;;
+esac
diff --git a/n8n/workflows/mail-processing-pipeline.json b/n8n/workflows/mail-processing-pipeline.json
new file mode 100644
index 0000000..6de7d86
--- /dev/null
+++ b/n8n/workflows/mail-processing-pipeline.json
@@ -0,0 +1,203 @@
+{
+ "name": "메일 처리 파이프라인",
+ "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": [
+ {
+ "mode": "everyX",
+ "value": 15,
+ "unit": "minutes"
+ }
+ ]
+ }
+ },
+ "id": "m1000001-0000-0000-0000-000000000001",
+ "name": "IMAP Trigger",
+ "type": "n8n-nodes-base.imapEmail",
+ "typeVersion": 2,
+ "position": [0, 300]
+ },
+ {
+ "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;"
+ },
+ "id": "m1000001-0000-0000-0000-000000000002",
+ "name": "Parse Mail",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [220, 300]
+ },
+ {
+ "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}"
+ },
+ "id": "m1000001-0000-0000-0000-000000000003",
+ "name": "Summarize & Classify",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [440, 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 }}')",
+ "options": {}
+ },
+ "id": "m1000001-0000-0000-0000-000000000004",
+ "name": "Save to mail_logs",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [660, 300],
+ "credentials": {
+ "postgres": {
+ "id": "KaxU8iKtraFfsrTF",
+ "name": "bot-postgres"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\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 item = $input.first().json;\nconst embText = `${item.subject} ${item.summary}`;\n\ntry {\n const emb = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/embeddings`, { model: 'bge-m3', prompt: embText });\n if (emb.embedding) {\n const qdrantUrl = $env.QDRANT_URL || 'http://host.docker.internal:6333';\n await httpPut(`${qdrantUrl}/collections/documents/points`, { points: [{ id: Date.now(), vector: emb.embedding, payload: {\n text: embText, source: 'mail', from_address: item.from,\n mail_date: item.mailDate, label: item.label,\n created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\n// DEVONthink 저장 (graceful)\ntry {\n const dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\n await httpPost(`${dtUrl}/save`, {\n title: item.subject, content: item.summary,\n type: 'markdown', tags: ['mail', item.label]\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{ json: { ...item, embedded: true } }];"
+ },
+ "id": "m1000001-0000-0000-0000-000000000005",
+ "name": "Embed & Save",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [880, 300]
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "important-check",
+ "leftValue": "={{ $json.label }}",
+ "rightValue": "업무",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ },
+ {
+ "id": "tasks-check",
+ "leftValue": "={{ $json.has_tasks }}",
+ "rightValue": true,
+ "operator": {
+ "type": "boolean",
+ "operation": "true"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "m1000001-0000-0000-0000-000000000006",
+ "name": "Is Important?",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [1100, 300]
+ },
+ {
+ "parameters": {
+ "method": "POST",
+ "url": "={{ $env.SYNOLOGY_CHAT_WEBHOOK_URL }}",
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={ \"text\": {{ JSON.stringify('[메일 알림] ' + $json.from + ': ' + $json.subject + '\\n' + $json.summary) }} }",
+ "options": {
+ "timeout": 10000
+ }
+ },
+ "id": "m1000001-0000-0000-0000-000000000007",
+ "name": "Notify Chat",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [1320, 200]
+ }
+ ],
+ "connections": {
+ "IMAP Trigger": {
+ "main": [
+ [
+ {
+ "node": "Parse Mail",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Parse Mail": {
+ "main": [
+ [
+ {
+ "node": "Summarize & Classify",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Summarize & Classify": {
+ "main": [
+ [
+ {
+ "node": "Save to mail_logs",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Save to mail_logs": {
+ "main": [
+ [
+ {
+ "node": "Embed & Save",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Embed & Save": {
+ "main": [
+ [
+ {
+ "node": "Is Important?",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Is Important?": {
+ "main": [
+ [
+ {
+ "node": "Notify Chat",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ []
+ ]
+ }
+ },
+ "settings": {
+ "executionOrder": "v1"
+ }
+}
diff --git a/n8n/workflows/main-chat-pipeline.json b/n8n/workflows/main-chat-pipeline.json
index 1772f2d..9e0c225 100644
--- a/n8n/workflows/main-chat-pipeline.json
+++ b/n8n/workflows/main-chat-pipeline.json
@@ -21,7 +21,7 @@
},
{
"parameters": {
- "jsCode": "const body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst username = body.username || 'unknown';\nconst userId = body.user_id || '';\nconst token = body.token || '';\nconst timestamp = body.timestamp || '';\nconst fileUrl = body.file_url || '';\n\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\nconst staticData = $getWorkflowStaticData('global');\nconst now = Date.now();\nconst rlKey = `rl_${username}`;\nif (!staticData[rlKey]) staticData[rlKey] = [];\nstaticData[rlKey] = staticData[rlKey].filter(t => now - t < 10000);\nif (staticData[rlKey].length >= 5) {\n return [{ json: { rejected: true, rejectReason: '잠시 후 다시 시도해주세요.' } }];\n}\nstaticData[rlKey].push(now);\n\nconst isCommand = text.startsWith('/');\nconst pendingKey = `pendingDoc_${username}`;\nlet pendingDoc = staticData[pendingKey] || null;\nif (pendingDoc && (now - pendingDoc.timestamp > 300000)) {\n delete staticData[pendingKey];\n pendingDoc = null;\n}\nconst hasPendingDoc = !!pendingDoc && !isCommand;\n\nreturn [{ json: { rejected: false, hasPendingDoc, pendingDoc, text, username, userId, token, timestamp, fileUrl, isCommand } }];"
+ "jsCode": "const body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst username = body.username || 'unknown';\nconst userId = body.user_id || '';\nconst token = body.token || '';\nconst timestamp = body.timestamp || '';\nconst channelId = body.channel_id || '';\n\nconst expectedToken = $env.SYNOLOGY_CHAT_TOKEN || '';\nif (expectedToken && token !== expectedToken) {\n return [{ json: { rejected: true, rejectReason: '인증 실패' } }];\n}\n\nconst staticData = $getWorkflowStaticData('global');\nconst now = Date.now();\nconst rlKey = `rl_${username}`;\nif (!staticData[rlKey]) staticData[rlKey] = [];\nstaticData[rlKey] = staticData[rlKey].filter(t => now - t < 10000);\nif (staticData[rlKey].length >= 5) {\n return [{ json: { rejected: true, rejectReason: '잠시 후 다시 시도해주세요.' } }];\n}\nstaticData[rlKey].push(now);\n\nconst isCommand = text.startsWith('/');\nconst pendingKey = `pendingDoc_${username}`;\nlet pendingDoc = staticData[pendingKey] || null;\nif (pendingDoc && (now - pendingDoc.timestamp > 300000)) {\n delete staticData[pendingKey];\n pendingDoc = null;\n}\nconst hasPendingDoc = !!pendingDoc && !isCommand;\n\nreturn [{ json: { rejected: false, hasPendingDoc, pendingDoc, text, username, userId, token, timestamp, channelId, isCommand } }];"
},
"id": "b1000001-0000-0000-0000-000000000002",
"name": "Parse Input",
@@ -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 + ' → ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' → timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst input = $('Parse Input').first().json;\nconst userText = input.text;\nconst username = input.username;\nconst fileUrl = input.fileUrl;\nconst startTime = Date.now();\n\nconst classifierPrompt = `사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.\n\n{\n \"intent\": \"greeting|question|calendar|reminder|mail|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\nresponse_tier: local(인사,잡담,감사), api_light(요약,번역,일반질문), api_heavy(법률,복잡추론)\nrag_target: documents(개인문서), tk_company(회사문서), chat_memory(이전대화)\nintent=report: 현장신고, 사진+\"~발생/고장/파손\"\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' },\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, fileUrl, 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, fileUrl, 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: '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}"
},
"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}) }}'::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, 'qwen3.5:9b-q8_0', {{ $json.latency }}, {{ $json.fallback }})",
"options": {}
},
"id": "b1000001-0000-0000-0000-000000000021",
@@ -451,31 +451,144 @@
},
{
"parameters": {
- "conditions": {
- "options": {
- "caseSensitive": true,
- "leftValue": "",
- "typeValidation": "strict"
- },
- "conditions": [
+ "rules": {
+ "values": [
{
- "id": "report-check",
- "leftValue": "={{ $json.intent }}",
- "rightValue": "report",
- "operator": {
- "type": "string",
- "operation": "equals"
- }
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "log_event",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "LogEvent"
+ },
+ {
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "report",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "Report"
+ },
+ {
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "calendar",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "Calendar"
+ },
+ {
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "reminder",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "Reminder"
+ },
+ {
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "mail",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "Mail"
+ },
+ {
+ "conditions": {
+ "conditions": [
+ {
+ "leftValue": "={{ $json.intent }}",
+ "rightValue": "note",
+ "operator": {
+ "type": "string",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and",
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ }
+ },
+ "renameOutput": "Note"
}
- ],
- "combinator": "and"
+ ]
},
- "options": {}
+ "options": {
+ "fallbackOutput": "extra"
+ }
},
"id": "b1000001-0000-0000-0000-000000000046",
- "name": "Is Report Intent?",
- "type": "n8n-nodes-base.if",
- "typeVersion": 2.2,
+ "name": "Route by Intent",
+ "type": "n8n-nodes-base.switch",
+ "typeVersion": 3.2,
"position": [
1540,
1200
@@ -483,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 + ' → ' + 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 cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText, username = cls.username, fileUrl = cls.fileUrl;\nconst reportDomain = cls.report_domain || '안전';\n\nlet photoAnalysis = null;\nif (fileUrl) {\n try {\n const vr = await httpPost(`${$env.LOCAL_OLLAMA_URL}/api/generate`,\n { model: 'minicpm-v:8b', prompt: '이 사진에서 안전/시설/품질 문제점을 설명. 한국어 간결하게.', images: [fileUrl], stream: false },\n { timeout: 30000 }\n );\n photoAnalysis = vr.response || null;\n } catch(e) {}\n}\n\nlet structured;\ntry {\n const sp = `현장 신고를 구조화. JSON만 응답.\n{\"domain\":\"안전|시설설비|품질\",\"category\":\"분류\",\"severity\":\"상|중|하\",\"location\":\"\",\"department\":\"\",\"keywords\":[],\"summary\":\"\",\"action_required\":\"\"}\n\n신고: ${userText}${photoAnalysis ? '\\n사진분석: '+photoAnalysis : ''}`;\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'qwen3.5:9b-q8_0', prompt: sp, stream: false, format: 'json' },\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\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: 'field_report', year: new Date().getFullYear(), created_at: new Date().toISOString()\n }}]});\n }\n} catch(e) {}\n\nlet esc = structured.severity === '상' ? '\\n⚠️ 긴급 — 관리자 에스컬레이션' : '';\nconst now = new Date();\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst kw = (structured.keywords||[]).map(k=>\"'\"+safe(k)+\"'\").join(',') || \"'기타'\";\nconst 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)}',${fileUrl?\"'\"+safe(fileUrl)+\"'\":'NULL'},${photoAnalysis?\"'\"+safe(photoAnalysis).substring(0,2000)+\"'\":'NULL'},'${safe(username)}',${now.getFullYear()},${now.getMonth()+1},'${dueAt}')`;\n\nreturn [{ json: { text: `접수됨. [${structured.domain}/${structured.category}/${structured.severity}] ${structured.summary}${esc}`, 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: '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 } }];"
},
"id": "b1000001-0000-0000-0000-000000000047",
"name": "Handle Field Report",
@@ -494,6 +607,19 @@
1300
]
},
+ {
+ "parameters": {
+ "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst LOG_EVENT_SYSTEM_PROMPT = `너는 산업 현장 업무 로그를 분석하는 전문가다. 사진과 텍스트를 받아 이벤트 정보를 추출하여 구조화된 JSON으로 응답한다.\n\n## 출력 스키마\n\n반드시 아래 JSON 스키마를 따라 응답하라. JSON만 출력하고, 다른 텍스트는 포함하지 마라.\n\n{\n \"namespace\": \"string (조직명, 기본값: 테크니컬코리아)\",\n \"category\": \"안전 | 생산 | 구매 | 품질 | 총무 | 시설\",\n \"event_type\": \"구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\",\n \"item\": \"string (품목/대상, 자유텍스트)\",\n \"date\": \"YYYY-MM-DD\",\n \"summary\": \"string (한줄 요약, 품목+행위 포함)\"\n}\n\n## 필드별 판단 기준\n\n### namespace\n- 기본값: \"테크니컬코리아\"\n- 다른 조직이 언급되면 해당 조직명 사용\n\n### category (부서/업무 영역)\n어떤 부서 업무인지 기준으로 선택:\n- **안전**: 안전 관련 업무 (안전장비, 안전교육, 사고 등)\n- **생산**: 생산 라인 관련 (기계 가동, 생산량, 공정 등)\n- **구매**: 구매/발주 관련 (자재 구매, 발주, 견적 등)\n- **품질**: 품질 관련 (검사, 불량, 시험 등)\n- **총무**: 일반 관리 (비품, 문서, 행사 등)\n- **시설**: 시설/설비 관련 (수리, 점검, 설치 등)\n\n### event_type (행위/사건)\n어떤 행동/사건인지 기준으로 선택:\n- **구매**: 물품 구매, 발주\n- **점검**: 정기/수시 점검, 확인\n- **교체**: 부품/장비 교체\n- **입고**: 자재/물품 입고, 수령\n- **교육**: 안전교육, 직무교육\n- **등록**: 새 항목 등록, 기록\n- **완료**: 작업/프로젝트 완료\n- **사고**: 안전사고, 재해\n- **이상**: 설비 이상, 품질 이상\n\n### date 추론 규칙\n- 날짜 언급 없음 → 사용자 메시지에 포함된 오늘 날짜 사용\n- \"어제\" → 오늘 - 1일\n- \"그저께\" → 오늘 - 2일\n- \"지난주\" → 오늘 - 7일\n- 구체적 날짜 → 해당 날짜\n\n### item\n- 사진에서 식별된 품목/장비 + 텍스트에서 언급된 대상\n- 산업 현장 표준 한국어 용어 사용\n\n### summary\n- 품목 + 행위를 포함한 한줄 요약\n- 형식: \"[품목] [행위]\" (예: \"소화기 10개 입고 완료\")\n\n## 출력 예시\n\n예시 1 — 사진: 새 소화기 박스들 / 텍스트: \"소화기 들어왔어요\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"안전\",\"event_type\":\"입고\",\"item\":\"소화기\",\"date\":\"2026-03-12\",\"summary\":\"소화기 입고 완료\"}\n\n예시 2 — 사진: 컨베이어 벨트 수리 장면 / 텍스트: \"어제 2라인 벨트 교체했습니다\" / 오늘: 2026-03-12\n{\"namespace\":\"테크니컬코리아\",\"category\":\"시설\",\"event_type\":\"교체\",\"item\":\"2라인 컨베이어 벨트\",\"date\":\"2026-03-11\",\"summary\":\"2라인 컨베이어 벨트 교체 완료\"}`;\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\nconst channelId = input.channelId;\nconst userId = input.userId;\nconst timestamp = input.timestamp;\nconst today = new Date().toISOString().split('T')[0];\n\n// 사진 조회 (bridge)\nlet photoAnalysis = null;\nlet photoWarning = '';\nlet hasPhoto = false;\nlet photoBase64 = null;\nlet inputTokens = 0, outputTokens = 0;\ntry {\n const photoResult = await httpPost(\n `${$env.CHAT_BRIDGE_URL}/chat/recent-photo`,\n {\n channel_id: parseInt(channelId) || 17,\n user_id: parseInt(userId) || 0,\n before_timestamp: parseInt(timestamp) || Date.now()\n },\n { timeout: 30000 }\n );\n if (photoResult.found && photoResult.base64) {\n hasPhoto = true;\n photoBase64 = photoResult.base64;\n }\n} catch (e) {\n photoWarning = '사진 조회 실패: ' + (e.message || '').substring(0, 100);\n}\n\nlet extracted;\nif (photoBase64) {\n // Haiku Vision — 분석 + 추출 1회 호출\n try {\n const mimeType = photoBase64.startsWith('/9j/') ? 'image/jpeg'\n : photoBase64.startsWith('iVBOR') ? 'image/png' : 'image/jpeg';\n const r = await httpPost('https://api.anthropic.com/v1/messages', {\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 512,\n system: [{ type: 'text', text: LOG_EVENT_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],\n messages: [{\n role: 'user',\n content: [\n { type: 'image', source: { type: 'base64', media_type: mimeType, data: photoBase64 } },\n { type: 'text', text: '사용자 메시지: ' + userText + '\\n오늘: ' + today }\n ]\n }]\n }, { timeout: 15000, headers: { 'x-api-key': $env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' } });\n\n inputTokens = r.usage ? r.usage.input_tokens : 0;\n outputTokens = r.usage ? r.usage.output_tokens : 0;\n const raw = r.content && r.content[0] && r.content[0].text ? r.content[0].text.trim() : '';\n const clean = raw.replace(/^```(?:json)?\\n?|\\n?```$/g, '').trim();\n extracted = JSON.parse(clean);\n photoAnalysis = extracted.summary;\n } catch(e) {\n extracted = { namespace: '테크니컬코리아', category: '총무', event_type: '등록', item: userText.substring(0, 50), date: today, summary: userText.substring(0, 80) };\n photoWarning += (photoWarning ? ' / ' : '') + '사진 분석 결과 자동 구조화 실패 — 수동 확인 필요';\n }\n} else {\n // 사진 없음 — 기존 Qwen 3.5 텍스트 추출\n try {\n const extractPrompt = `사용자 메시지에서 이벤트 정보를 추출하세요. JSON만 응답.\\n\\n오늘 날짜: ${today}\\n\\nnamespace: 테크니컬코리아\\n (다른 조직이면 자유 입력)\\n\\ncategory (부서/업무 영역): 안전 | 생산 | 구매 | 품질 | 총무 | 시설\\n ※ \"어떤 부서 업무인지\" 기준으로 선택\\n\\nevent_type (무엇을 했는지/행위): 구매 | 점검 | 교체 | 입고 | 교육 | 등록 | 완료 | 사고 | 이상\\n ※ \"어떤 행동/사건인지\" 기준으로 선택\\n\\n{\\n \"namespace\": \"테크니컬코리아\",\\n \"category\": \"목록에서 선택\",\\n \"event_type\": \"목록에서 선택\",\\n \"item\": \"품목/대상 (자유텍스트)\",\\n \"date\": \"YYYY-MM-DD (언급 없으면 오늘 날짜, '어제'=오늘-1일, '지난주'=오늘-7일)\",\\n \"summary\": \"한줄 요약 (품목+행위 포함)\"\\n}\\n\\n사용자 메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: '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 } }];"
+ },
+ "id": "b1000001-0000-0000-0000-000000000060",
+ "name": "Handle Log Event",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [
+ 1760,
+ 1500
+ ]
+ },
{
"parameters": {
"operation": "executeQuery",
@@ -683,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 },\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:'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}"
},
"id": "b1000001-0000-0000-0000-000000000028",
"name": "Call Qwen Response",
@@ -834,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' },\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:'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}}]; }"
},
"id": "b1000001-0000-0000-0000-000000000035",
"name": "Memorization Check",
@@ -879,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}}]});\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// DEVONthink 저장 (graceful)\ntry {\n const dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\n await httpPost(`${dtUrl}/save`, {\n title: `${new Date().toISOString().split('T')[0]} 대화 메모`,\n content: prompt,\n type: 'markdown',\n tags: ['chat-memory', data.topic || 'general']\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{json:{saved:true,pointId:pid,topic:data.topic}}];"
},
"id": "b1000001-0000-0000-0000-000000000037",
"name": "Embed & Save Memory",
@@ -889,6 +1015,185 @@
3740,
900
]
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "token-check",
+ "leftValue": "={{ $json.inputTokens }}",
+ "rightValue": 0,
+ "operator": {
+ "type": "number",
+ "operation": "gt"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "b1000001-0000-0000-0000-000000000061",
+ "name": "Has API Tokens?",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 1980,
+ 1600
+ ]
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "=INSERT INTO api_usage_monthly (year,month,tier,call_count,total_input_tokens,total_output_tokens,estimated_cost) VALUES (EXTRACT(YEAR FROM NOW())::int,EXTRACT(MONTH FROM NOW())::int,'api_light',1,{{ $json.inputTokens||0 }},{{ $json.outputTokens||0 }},{{ (($json.inputTokens||0)*0.8+($json.outputTokens||0)*4)/1000000 }}) ON CONFLICT (year,month,tier) DO UPDATE SET call_count=api_usage_monthly.call_count+1,total_input_tokens=api_usage_monthly.total_input_tokens+EXCLUDED.total_input_tokens,total_output_tokens=api_usage_monthly.total_output_tokens+EXCLUDED.total_output_tokens,estimated_cost=api_usage_monthly.estimated_cost+EXCLUDED.estimated_cost,updated_at=NOW()",
+ "options": {}
+ },
+ "id": "b1000001-0000-0000-0000-000000000062",
+ "name": "Log Event API Usage",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 2200,
+ 1550
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "KaxU8iKtraFfsrTF",
+ "name": "bot-postgres"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst input = $('Parse Input').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\nconst now = new Date();\nconst today = now.toISOString().split('T')[0];\nconst currentTime = now.toTimeString().split(' ')[0];\nconst dayNames = ['일','월','화','수','목','금','토'];\nconst dayOfWeek = dayNames[now.getDay()];\n\n// Qwen 3.5로 일정 정보 추출\nlet calData;\ntry {\n const extractPrompt = `현재: ${today} ${currentTime} (KST, ${dayOfWeek}요일). 아래 메시지에서 일정 정보를 추출하여 JSON으로 응답하세요. JSON만 출력.\n\n{\n \"action\": \"create|query|update|delete\",\n \"title\": \"일정 제목 (create/update 시)\",\n \"start\": \"YYYY-MM-DDTHH:MM:SS (ISO 형식)\",\n \"end\": \"YYYY-MM-DDTHH:MM:SS (없으면 null)\",\n \"location\": \"장소 (없으면 null)\",\n \"description\": \"설명 (없으면 null)\",\n \"uid\": \"기존 일정 uid (update/delete 시, 없으면 null)\",\n \"query_start\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 시작)\",\n \"query_end\": \"YYYY-MM-DDTHH:MM:SS (query 시 검색 끝)\"\n}\n\n규칙:\n- \"내일\" = 오늘+1일, \"모레\" = 오늘+2일, \"다음주 월요일\" = 적절히 계산\n- 시간 미지정 시: 업무 시간대면 09:00, 오후면 14:00 기본값\n- action=query이고 날짜 미지정: query_start=오늘 00:00, query_end=오늘 23:59\n- \"오늘 일정\" → action=query\n- \"내일 3시 회의\" → action=create\n\n메시지: ${userText}`;\n\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: '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}"
+ },
+ "id": "b1000001-0000-0000-0000-000000000063",
+ "name": "Handle Calendar",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [
+ 1760,
+ 1700
+ ]
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "={{ $json.insertSQL }}",
+ "options": {}
+ },
+ "id": "b1000001-0000-0000-0000-000000000064",
+ "name": "Save Calendar DB",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 1980,
+ 1700
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "KaxU8iKtraFfsrTF",
+ "name": "bot-postgres"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "insert-check",
+ "leftValue": "={{ $json.insertSQL }}",
+ "rightValue": "",
+ "operator": {
+ "type": "string",
+ "operation": "notEmpty"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "options": {}
+ },
+ "id": "b1000001-0000-0000-0000-000000000065",
+ "name": "Has Calendar Insert?",
+ "type": "n8n-nodes-base.if",
+ "typeVersion": 2.2,
+ "position": [
+ 1980,
+ 1800
+ ]
+ },
+ {
+ "parameters": {
+ "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// Qwen으로 메일 조회 의도 파악\nlet queryType = 'recent'; // recent | search\nlet searchQuery = userText;\nlet days = 1;\n\nconst text = userText.toLowerCase();\nif (text.includes('오늘')) days = 1;\nelse if (text.includes('이번 주') || text.includes('이번주')) days = 7;\nelse if (text.includes('최근')) days = 3;\n\n// DB에서 직접 조회\nconst now = new Date();\nconst since = new Date(now.getTime() - days * 86400000).toISOString();\n\n// mail_logs에서 최근 메일 조회 SQL\nconst safe = s => (s||'').replace(/'/g, \"''\");\nconst selectSQL = `SELECT from_address, subject, summary, label, mail_date FROM mail_logs WHERE mail_date >= '${since}' ORDER BY mail_date DESC LIMIT 10`;\n\nreturn [{ json: { text: '', selectSQL, userText, username, response_tier: 'local', intent: 'mail', model: 'qwen3.5:9b-q8_0', inputTokens: 0, outputTokens: 0, queryType, days } }];"
+ },
+ "id": "b1000001-0000-0000-0000-000000000066",
+ "name": "Handle Mail Query",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [
+ 1760,
+ 1900
+ ]
+ },
+ {
+ "parameters": {
+ "operation": "executeQuery",
+ "query": "={{ $json.selectSQL }}",
+ "options": {}
+ },
+ "id": "b1000001-0000-0000-0000-000000000067",
+ "name": "Mail DB Query",
+ "type": "n8n-nodes-base.postgres",
+ "typeVersion": 2.5,
+ "position": [
+ 1980,
+ 1900
+ ],
+ "credentials": {
+ "postgres": {
+ "id": "KaxU8iKtraFfsrTF",
+ "name": "bot-postgres"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const items = $input.all();\nconst prev = $('Handle Mail Query').first().json;\nconst days = prev.days || 1;\n\nif (!items || items.length === 0 || !items[0].json.from_address) {\n return [{ json: { text: '받은 메일이 없습니다.', userText: prev.userText, username: prev.username, response_tier: 'local', intent: 'mail', model: '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 } }];"
+ },
+ "id": "b1000001-0000-0000-0000-000000000068",
+ "name": "Format Mail Response",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [
+ 2200,
+ 1900
+ ]
+ },
+ {
+ "parameters": {
+ "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst cls = $('Qwen Classify v2').first().json;\nconst userText = cls.userText;\nconst username = cls.username;\n\n// \"기록해\", \"메모해\", \"저장해\", \"적어둬\" 등 제거\nconst content = userText\n .replace(/[.]\\s*(기록해|메모해|저장해|적어둬|기록|메모|저장)[.]?\\s*$/g, '')\n .replace(/^\\s*(기록해|메모해|저장해|적어둬)[.:]\\s*/g, '')\n .trim() || userText;\n\n// 제목: 앞 30자\nconst title = content.substring(0, 30).replace(/\\n/g, ' ') + (content.length > 30 ? '...' : '');\nconst now = new Date();\nconst dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;\nconst fullTitle = `${dateStr} ${title}`;\n\n// DEVONthink에 저장\nconst dtUrl = $env.DEVONTHINK_BRIDGE_URL || 'http://host.docker.internal:8093';\nlet saved = false;\ntry {\n const result = await httpPost(`${dtUrl}/save`, {\n title: fullTitle,\n content: content,\n type: 'markdown',\n tags: ['synology-chat', 'note']\n }, { timeout: 10000 });\n saved = result.success === true;\n} catch(e) {}\n\nconst responseText = saved\n ? `기록했습니다: ${title}`\n : `기록을 시도했지만 DEVONthink 저장에 실패했습니다. 내용: ${title}`;\n\nreturn [{ json: { text: responseText, userText, username, response_tier: 'local', intent: 'note', model: 'qwen3.5:9b-q8_0', inputTokens: 0, outputTokens: 0 } }];"
+ },
+ "id": "b1000001-0000-0000-0000-000000000069",
+ "name": "Handle Note",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 1,
+ "position": [
+ 1760,
+ 2100
+ ]
}
],
"connections": {
@@ -1173,7 +1478,7 @@
"main": [
[
{
- "node": "Is Report Intent?",
+ "node": "Route by Intent",
"type": "main",
"index": 0
},
@@ -1185,8 +1490,15 @@
]
]
},
- "Is Report Intent?": {
+ "Route by Intent": {
"main": [
+ [
+ {
+ "node": "Handle Log Event",
+ "type": "main",
+ "index": 0
+ }
+ ],
[
{
"node": "Handle Field Report",
@@ -1200,6 +1512,60 @@
"type": "main",
"index": 0
}
+ ],
+ [
+ {
+ "node": "Handle Calendar",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Handle Calendar",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Handle Mail Query",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Handle Note",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Handle Log Event": {
+ "main": [
+ [
+ {
+ "node": "Send to Synology Chat",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Respond to Webhook",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Log to DB",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Has API Tokens?",
+ "type": "main",
+ "index": 0
+ }
]
]
},
@@ -1434,6 +1800,120 @@
],
[]
]
+ },
+ "Has API Tokens?": {
+ "main": [
+ [
+ {
+ "node": "Log Event API Usage",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ []
+ ]
+ },
+ "Handle Calendar": {
+ "main": [
+ [
+ {
+ "node": "Send to Synology Chat",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Respond to Webhook",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Log to DB",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Has Calendar Insert?",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Has Calendar Insert?": {
+ "main": [
+ [
+ {
+ "node": "Save Calendar DB",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ []
+ ]
+ },
+ "Handle Mail Query": {
+ "main": [
+ [
+ {
+ "node": "Mail DB Query",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Mail DB Query": {
+ "main": [
+ [
+ {
+ "node": "Format Mail Response",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Format Mail Response": {
+ "main": [
+ [
+ {
+ "node": "Send to Synology Chat",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Respond to Webhook",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Log to DB",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Handle Note": {
+ "main": [
+ [
+ {
+ "node": "Send to Synology Chat",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Respond to Webhook",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Log to DB",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
}
},
"settings": {
@@ -1441,4 +1921,4 @@
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
}
-}
+}
\ No newline at end of file
diff --git a/news_digest.py b/news_digest.py
new file mode 100644
index 0000000..569512b
--- /dev/null
+++ b/news_digest.py
@@ -0,0 +1,290 @@
+"""뉴스 다이제스트 — Karakeep → 번역·요약 → 전달 (LaunchAgent, 매일 07:00)"""
+
+import json
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+
+import httpx
+from dotenv import load_dotenv
+
+load_dotenv()
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("news_digest")
+
+KARAKEEP_URL = os.getenv("KARAKEEP_URL", "http://localhost:3000")
+KARAKEEP_API_KEY = os.getenv("KARAKEEP_API_KEY", "")
+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434")
+LOCAL_OLLAMA_URL = os.getenv("LOCAL_OLLAMA_URL", "http://127.0.0.1:11434")
+QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333")
+SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "")
+DEVONTHINK_BRIDGE_URL = os.getenv("DEVONTHINK_BRIDGE_URL", "http://127.0.0.1:8093")
+
+# Postgres 연결 (직접 접속)
+PG_HOST = os.getenv("PG_HOST", "127.0.0.1")
+PG_PORT = int(os.getenv("PG_PORT", "15478"))
+PG_USER = os.getenv("POSTGRES_USER", "bot")
+PG_PASS = os.getenv("POSTGRES_PASSWORD", "")
+PG_DB = os.getenv("POSTGRES_DB", "chatbot")
+
+KST = timezone(timedelta(hours=9))
+
+
+def get_db_connection():
+ import psycopg2
+ return psycopg2.connect(
+ host=PG_HOST, port=PG_PORT,
+ user=PG_USER, password=PG_PASS, dbname=PG_DB,
+ )
+
+
+def fetch_new_bookmarks(since: datetime) -> list[dict]:
+ """Karakeep API에서 최근 북마크 가져오기."""
+ headers = {"Authorization": f"Bearer {KARAKEEP_API_KEY}"} if KARAKEEP_API_KEY else {}
+
+ try:
+ resp = httpx.get(
+ f"{KARAKEEP_URL}/api/v1/bookmarks",
+ params={"limit": 50},
+ headers=headers,
+ timeout=15,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ bookmarks = data.get("bookmarks", data if isinstance(data, list) else [])
+ new_items = []
+ for bm in bookmarks:
+ created = bm.get("createdAt") or bm.get("created_at") or ""
+ if created:
+ try:
+ dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
+ if dt < since:
+ continue
+ except ValueError:
+ pass
+
+ url = bm.get("url") or bm.get("content", {}).get("url", "")
+ title = bm.get("title") or bm.get("content", {}).get("title", "")
+ content = bm.get("content", {}).get("text", "") or bm.get("summary", "") or ""
+ source = bm.get("source", "")
+
+ if url:
+ new_items.append({
+ "url": url,
+ "title": title,
+ "content": content[:5000],
+ "source": source,
+ })
+
+ return new_items
+ except Exception as e:
+ logger.error(f"Karakeep fetch failed: {e}")
+ return []
+
+
+def detect_language(text: str) -> str:
+ """간단한 언어 감지."""
+ if any('\u3040' <= c <= '\u309f' or '\u30a0' <= c <= '\u30ff' for c in text[:200]):
+ return "ja"
+ if any('\u00c0' <= c <= '\u024f' for c in text[:200]) and any(w in text.lower() for w in ["le ", "la ", "les ", "de ", "des ", "un ", "une "]):
+ return "fr"
+ if any('\uac00' <= c <= '\ud7af' for c in text[:200]):
+ return "ko"
+ return "en"
+
+
+def translate_and_summarize(title: str, content: str, lang: str) -> dict:
+ """Haiku로 번역 + 요약."""
+ if lang == "ko":
+ # 한국어는 번역 불필요, 요약만
+ try:
+ resp = httpx.post(
+ f"{GPU_OLLAMA_URL}/api/generate",
+ json={
+ "model": "qwen3.5:9b-q8_0",
+ "prompt": f"다음 기사를 2~3문장으로 요약하세요:\n\n제목: {title}\n본문: {content[:3000]}",
+ "stream": False,
+ "think": False,
+ },
+ timeout=15,
+ )
+ summary = resp.json().get("response", title)
+ return {"title_ko": title, "summary_ko": summary}
+ except Exception:
+ return {"title_ko": title, "summary_ko": title}
+
+ # 외국어: Haiku로 번역+요약
+ lang_names = {"en": "영어", "fr": "프랑스어", "ja": "일본어"}
+ lang_name = lang_names.get(lang, "외국어")
+
+ try:
+ resp = httpx.post(
+ "https://api.anthropic.com/v1/messages",
+ json={
+ "model": "claude-haiku-4-5-20251001",
+ "max_tokens": 512,
+ "messages": [{
+ "role": "user",
+ "content": f"다음 {lang_name} 기사를 한국어로 번역·요약해주세요.\n\n제목: {title}\n본문: {content[:3000]}\n\nJSON으로 응답:\n{{\"title_ko\": \"한국어 제목\", \"summary_ko\": \"2~3문장 한국어 요약\"}}"
+ }],
+ },
+ headers={
+ "x-api-key": ANTHROPIC_API_KEY,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ },
+ timeout=30,
+ )
+ text = resp.json()["content"][0]["text"]
+ clean = text.strip().removeprefix("```json").removesuffix("```").strip()
+ return json.loads(clean)
+ except Exception as e:
+ logger.error(f"Translation failed: {e}")
+ return {"title_ko": title, "summary_ko": title}
+
+
+def embed_to_qdrant(text: str) -> str | None:
+ """Qdrant documents 컬렉션에 임베딩."""
+ try:
+ emb_resp = httpx.post(
+ f"{LOCAL_OLLAMA_URL}/api/embeddings",
+ json={"model": "bge-m3", "prompt": text},
+ timeout=30,
+ )
+ embedding = emb_resp.json().get("embedding")
+ if not embedding:
+ return None
+
+ point_id = int(datetime.now().timestamp() * 1000)
+ httpx.put(
+ f"{QDRANT_URL}/collections/documents/points",
+ json={"points": [{
+ "id": point_id,
+ "vector": embedding,
+ "payload": {
+ "text": text,
+ "source": "news",
+ "created_at": datetime.now(KST).isoformat(),
+ },
+ }]},
+ timeout=10,
+ )
+ return str(point_id)
+ except Exception as e:
+ logger.error(f"Qdrant embed failed: {e}")
+ return None
+
+
+def save_to_devonthink(title: str, content: str) -> str | None:
+ """DEVONthink에 저장."""
+ try:
+ resp = httpx.post(
+ f"{DEVONTHINK_BRIDGE_URL}/save",
+ json={
+ "title": title,
+ "content": content,
+ "type": "markdown",
+ "tags": ["news", "digest"],
+ },
+ timeout=10,
+ )
+ data = resp.json()
+ return data.get("uuid") if data.get("success") else None
+ except Exception:
+ return None
+
+
+def send_digest(articles: list[dict]) -> None:
+ """Synology Chat으로 다이제스트 전송."""
+ if not articles or not SYNOLOGY_CHAT_WEBHOOK_URL:
+ return
+
+ lines = []
+ for i, a in enumerate(articles[:10], 1):
+ lines.append(f"{i}. {a['title_ko']}\n {a['summary_ko'][:100]}")
+
+ text = f"[뉴스 다이제스트] {len(articles)}건\n\n" + "\n\n".join(lines)
+
+ try:
+ httpx.post(
+ SYNOLOGY_CHAT_WEBHOOK_URL,
+ data={"payload": json.dumps({"text": text})},
+ verify=False,
+ timeout=10,
+ )
+ logger.info("Digest sent to Synology Chat")
+ except Exception as e:
+ logger.error(f"Chat notification failed: {e}")
+
+
+def main():
+ logger.info("News digest started")
+
+ since = datetime.now(KST) - timedelta(hours=24)
+ bookmarks = fetch_new_bookmarks(since)
+
+ if not bookmarks:
+ logger.info("No new bookmarks")
+ return
+
+ logger.info(f"Processing {len(bookmarks)} bookmarks")
+
+ conn = None
+ try:
+ conn = get_db_connection()
+ except Exception as e:
+ logger.error(f"DB connection failed: {e}")
+
+ processed = []
+
+ for bm in bookmarks:
+ # 중복 체크
+ if conn:
+ try:
+ with conn.cursor() as cur:
+ cur.execute("SELECT id FROM news_digest_log WHERE article_url = %s", (bm["url"],))
+ if cur.fetchone():
+ logger.info(f"Already processed: {bm['url']}")
+ continue
+ except Exception:
+ pass
+
+ lang = detect_language(bm["title"] + " " + bm["content"][:200])
+ result = translate_and_summarize(bm["title"], bm["content"], lang)
+
+ emb_text = f"{result['title_ko']} {result['summary_ko']}"
+ qdrant_id = embed_to_qdrant(emb_text)
+ dt_uuid = save_to_devonthink(
+ result["title_ko"],
+ f"**원문**: {bm['url']}\n**출처**: {bm.get('source', '')}\n\n{result['summary_ko']}",
+ )
+
+ # DB에 기록
+ if conn:
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "INSERT INTO news_digest_log (article_url,source,original_lang,title_ko,summary_ko,qdrant_id,devonthink_uuid) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (article_url) DO NOTHING",
+ (bm["url"], bm.get("source", ""), lang, result["title_ko"], result["summary_ko"], qdrant_id, dt_uuid),
+ )
+ conn.commit()
+ except Exception as e:
+ logger.error(f"DB insert failed: {e}")
+
+ processed.append(result)
+ logger.info(f"Processed: {result['title_ko']}")
+
+ if conn:
+ conn.close()
+
+ # 다이제스트 전송
+ send_digest(processed)
+ logger.info(f"News digest complete: {len(processed)} articles")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/start-bridge.sh b/start-bridge.sh
new file mode 100755
index 0000000..efe0ff2
--- /dev/null
+++ b/start-bridge.sh
@@ -0,0 +1,10 @@
+#!/bin/zsh
+# Start chat_bridge.py bypassing venv TCC restriction
+cd /Users/hyungi/Documents/code/syn-chat-bot
+exec /opt/homebrew/opt/python@3.14/bin/python3.14 -S -c "
+import sys
+sys.path.insert(0, '.venv/lib/python3.14/site-packages')
+sys.path.insert(0, '.')
+import uvicorn
+uvicorn.run('chat_bridge:app', host='127.0.0.1', port=8091)
+"