From 782caf5130dce97e4e9650eceb311fda201eaa7d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 19 Mar 2026 14:12:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DEVONthink=20=EC=A0=9C=EA=B1=B0=20+=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=9D=20=EB=B8=8C=EB=A6=AC=ED=95=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEVONthink 의존성 제거 → kb_writer 전환 (news_digest, inbox_processor, mail pipeline) - devonthink_bridge.py, plist 삭제 - morning_briefing.py 신규 (매일 07:30, 일정·메일·보고·뉴스 → Synology Chat) - intent_service.py 분류기 프롬프트 개선 + 키워드 fallback - migrate-v5.sql (news_digest_log kb_path 컬럼) Co-Authored-By: Claude Opus 4.6 --- .env.example | 4 - CLAUDE.md | 12 +- QUICK_REFERENCE.md | 16 +- ...=> com.syn-chat-bot.morning-briefing.plist | 19 +- devonthink_bridge.py | 125 -------- docker-compose.yml | 1 - docs/PROGRESS.md | 38 ++- docs/architecture.md | 26 +- inbox_processor.py | 13 +- init/migrate-v5.sql | 4 + intent_service.py | 156 +++++++--- manage_services.sh | 2 +- morning_briefing.py | 280 ++++++++++++++++++ n8n/workflows/mail-processing-pipeline.json | 2 +- news_digest.py | 21 +- 15 files changed, 479 insertions(+), 240 deletions(-) rename com.syn-chat-bot.devonthink-bridge.plist => com.syn-chat-bot.morning-briefing.plist (61%) delete mode 100644 devonthink_bridge.py create mode 100644 init/migrate-v5.sql create mode 100644 morning_briefing.py diff --git a/.env.example b/.env.example index 5cf1d71..ac652be 100644 --- a/.env.example +++ b/.env.example @@ -56,9 +56,6 @@ IMAP_USER=hyungi IMAP_PASSWORD=changeme IMAP_SSL=true -# DEVONthink (devonthink_bridge.py — 지식 저장소) -DEVONTHINK_APP_NAME=DEVONthink - # Karakeep (NAS Docker — 북마크/뉴스 저장) KARAKEEP_URL=http://192.168.1.227:3000 KARAKEEP_API_KEY=changeme @@ -70,7 +67,6 @@ API_MONTHLY_LIMIT=10.00 HEIC_CONVERTER_URL=http://host.docker.internal:8090 CHAT_BRIDGE_URL=http://host.docker.internal:8091 CALDAV_BRIDGE_URL=http://host.docker.internal:8092 -DEVONTHINK_BRIDGE_URL=http://host.docker.internal:8093 MAIL_BRIDGE_URL=http://host.docker.internal:8094 KB_WRITER_URL=http://host.docker.internal:8095 NOTE_BRIDGE_URL=http://host.docker.internal:8098 diff --git a/CLAUDE.md b/CLAUDE.md index 36d5d67..70bfc49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인 │ ├─ calendar → CalDAV Bridge → Synology Calendar │ ├─ reminder → calendar로 통합 │ ├─ mail → 메일 요약 조회 - │ ├─ note → DEVONthink 저장 + │ ├─ note → KB Writer 저장 │ └─ fallback → 일반 대화 (RAG + 3단계 라우팅) │ ├─④ [needs_rag=true] 멀티-컬렉션 RAG @@ -39,7 +39,7 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인 └── Qdrant (벡터 검색, 3컬렉션) ⑥ 응답 전송 + chat_logs 저장 + API 사용량 UPSERT - ⑦ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화 + DEVONthink 저장) + ⑦ [비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화 + KB 저장) 별도 워크플로우: Mail Processing Pipeline (9노드) — mail_bridge 날짜 기반 조회 → dedup → 분류 → mail_logs @@ -48,19 +48,16 @@ bot-n8n (맥미니 Docker) — 51노드 파이프라인 heic_converter (:8090) — HEIC→JPEG 변환 (macOS sips) chat_bridge (:8091) — DSM Chat API 브릿지 (사진 폴링/다운로드) caldav_bridge (:8092) — CalDAV REST 래퍼 (Synology Calendar, VEVENT+VTODO) - devonthink_bridge (:8093) — DEVONthink AppleScript 래퍼 mail_bridge (:8094) — IMAP 날짜 기반 메일 조회 (MailPlus) kb_writer (:8095) — 마크다운 KB 저장 note_bridge (:8098) — Note Station REST 래퍼 (메모 생성/추가) intent_service (:8099) — 의도 분류 + 날짜 파싱 + Claude fallback inbox_processor (5분) — OmniFocus Inbox 폴링 (LaunchAgent) news_digest (매일 07:00) — 뉴스 번역·요약 (LaunchAgent) + morning_briefing (매일 07:30) — 일정·메일·보고·뉴스 모닝 브리핑 (LaunchAgent) NAS (192.168.1.227): Synology Chat / Synology Calendar (CalDAV) / MailPlus (IMAP) - -DEVONthink 4 (맥미니): - AppleScript 경유 문서 저장·검색 ``` ## 인프라 @@ -76,17 +73,16 @@ DEVONthink 4 (맥미니): | heic_converter | 네이티브 (맥미니) | 8090 | HEIC→JPEG 변환 (macOS sips) | | chat_bridge | 네이티브 (맥미니) | 8091 | DSM Chat API 브릿지 (사진 폴링/다운로드) | | caldav_bridge | 네이티브 (맥미니) | 8092 | CalDAV REST 래퍼 (Synology Calendar) | -| devonthink_bridge | 네이티브 (맥미니) | 8093 | DEVONthink AppleScript 래퍼 | | mail_bridge | 네이티브 (맥미니) | 8094 | IMAP 날짜 기반 메일 조회 (MailPlus) | | kb_writer | 네이티브 (맥미니) | 8095 | 마크다운 KB 저장 | | note_bridge | 네이티브 (맥미니) | 8098 | Note Station REST 래퍼 (메모 생성/추가) | | intent_service | 네이티브 (맥미니) | 8099 | 의도 분류 + 날짜 파싱 + Claude fallback | | inbox_processor | 네이티브 (맥미니) | — | OmniFocus Inbox 폴링 (LaunchAgent, 5분) | | news_digest | 네이티브 (맥미니) | — | 뉴스 번역·요약 (LaunchAgent, 매일 07:00) | +| morning_briefing | 네이티브 (맥미니) | — | 모닝 브리핑 (LaunchAgent, 매일 07:30) | | 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단계 라우팅 diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 290fbb8..0f7d5e7 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -36,7 +36,6 @@ curl -s http://192.168.1.186:11434/api/generate -d '{"model":"id-9b:latest","pro | chat_bridge | http://localhost:8091 | | HEIC converter | http://localhost:8090 | | caldav_bridge | http://localhost:8092 | -| devonthink_bridge | http://localhost:8093 | ## Docker 명령어 @@ -89,7 +88,6 @@ 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 && \ @@ -109,9 +107,9 @@ syn-chat-bot/ ├── 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) +├── morning_briefing.py ← 모닝 브리핑 (LaunchAgent, 매일 07:30) ├── manage_services.sh ← 네이티브 서비스 관리 (start/stop/status) ├── deploy_workflows.sh ← n8n 워크플로우 자동 배포 (REST API) ├── start-bridge.sh ← 브릿지 서비스 시작 헬퍼 @@ -180,14 +178,14 @@ 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 + +# morning_briefing 로그 +tail -50 /tmp/morning-briefing.log ``` ## n8n 접속 정보 @@ -248,11 +246,10 @@ NAS에서 Outgoing Webhook 설정 필요: - [x] /보고서 월간 보고서 생성 구현 - [x] report_cache 캐시 + --force 재생성 -### Phase 6: 캘린더·메일·DEVONthink·OmniFocus·뉴스 +### Phase 6: 캘린더·메일·OmniFocus·뉴스 - [x] mail_logs, calendar_events 테이블 - [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 — 네이티브 서비스 관리 @@ -264,7 +261,6 @@ NAS에서 Outgoing Webhook 설정 필요: ### 서비스 기동 전제조건 - Synology Calendar (CalDAV) — NAS에서 활성화 필요 - Synology MailPlus — NAS에서 활성화 + 계정 설정 필요 -- DEVONthink 4 — 맥미니에 설치 필요 (AppleScript 접근) - OmniFocus — 맥미니에 설치 필요 (Inbox 폴링) ## 검증 체크리스트 @@ -281,4 +277,4 @@ NAS에서 Outgoing Webhook 설정 필요: 10. 10초 내 6건 → rate limit 11. "내일 회의 잡아줘" → calendar intent → CalDAV 이벤트 생성 12. "최근 메일 확인" → mail intent → 메일 요약 반환 -13. "이거 메모해둬" → note intent → DEVONthink 저장 +13. "이거 메모해둬" → note intent → KB Writer 저장 diff --git a/com.syn-chat-bot.devonthink-bridge.plist b/com.syn-chat-bot.morning-briefing.plist similarity index 61% rename from com.syn-chat-bot.devonthink-bridge.plist rename to com.syn-chat-bot.morning-briefing.plist index f916d01..2656cfc 100644 --- a/com.syn-chat-bot.devonthink-bridge.plist +++ b/com.syn-chat-bot.morning-briefing.plist @@ -3,23 +3,26 @@ Label - com.syn-chat-bot.devonthink-bridge + com.syn-chat-bot.morning-briefing 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) + 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 morning_briefing import main; main() WorkingDirectory /Users/hyungi/Documents/code/syn-chat-bot - RunAtLoad - - KeepAlive - + StartCalendarInterval + + Hour + 7 + Minute + 30 + StandardOutPath - /tmp/devonthink-bridge.log + /tmp/morning-briefing.log StandardErrorPath - /tmp/devonthink-bridge.err + /tmp/morning-briefing.err diff --git a/devonthink_bridge.py b/devonthink_bridge.py deleted file mode 100644 index dcc0e23..0000000 --- a/devonthink_bridge.py +++ /dev/null @@ -1,125 +0,0 @@ -"""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 83fb05b..74887e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,6 @@ services: - HEIC_CONVERTER_URL=http://host.docker.internal:8090 - CHAT_BRIDGE_URL=http://host.docker.internal:8091 - CALDAV_BRIDGE_URL=http://host.docker.internal:8092 - - DEVONTHINK_BRIDGE_URL=http://host.docker.internal:8093 - MAIL_BRIDGE_URL=http://host.docker.internal:8094 - KB_WRITER_URL=http://host.docker.internal:8095 - NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7f51ae5..961c537 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Progress — syn-chat-bot -> 최종 업데이트: 2026-03-17 +> 최종 업데이트: 2026-03-19 ## Phase별 구현 현황 @@ -30,30 +30,37 @@ - `fetch()`/`$http.request()` → `require('http')`/`require('https')` 패턴 전환 - `NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url` 설정 -### Phase 5-6: Calendar/Mail/DEVONthink/OmniFocus/News (커밋 `612933c`, 2026-03-14) +### Phase 5-6: Calendar/Mail/OmniFocus/News (커밋 `612933c`, 2026-03-14) - 분류기 v3: calendar, reminder, mail, note intent 추가 - caldav_bridge.py (:8092) — CalDAV REST 래퍼 (Synology Calendar CRUD) -- devonthink_bridge.py (:8093) — DEVONthink AppleScript 래퍼 - inbox_processor.py — OmniFocus Inbox 폴링 (LaunchAgent, 5분 간격) - news_digest.py — RSS 뉴스 번역·요약 (LaunchAgent, 매일 07:00) - Mail Processing Pipeline (7노드) — IMAP 폴링 → Qwen 분류 → mail_logs - 메인 파이프라인 51노드로 확장 (calendar/mail/note 핸들러) +### DEVONthink 제거 + 모닝 브리핑 (2026-03-19) + +- DEVONthink 의존성 제거 → kb_writer로 전환 (news_digest, inbox_processor, mail pipeline) +- devonthink_bridge.py 삭제 (macOS AppleScript 의존성 제거) +- morning_briefing.py — 모닝 브리핑 (LaunchAgent, 매일 07:30) + - 일정(CalDAV), 메일(mail_logs), 보고(field_reports), 뉴스(Karakeep) → Synology Chat +- migrate-v5.sql — news_digest_log에 kb_path 컬럼 추가 + --- ## 파일 인벤토리 -### Python 서비스 (6개, 1,227줄) +### Python 서비스 (6개) -| 파일 | 줄수 | 포트/실행 | 역할 | -|------|------|----------|------| -| `caldav_bridge.py` | 269 | :8092 | CalDAV REST 래퍼 | -| `chat_bridge.py` | 293 | :8091 | DSM Chat API 브릿지 | -| `devonthink_bridge.py` | 125 | :8093 | DEVONthink AppleScript 래퍼 | -| `heic_converter.py` | 25 | :8090 | HEIC→JPEG 변환 | -| `inbox_processor.py` | 225 | LaunchAgent 5분 | OmniFocus Inbox 폴링 | -| `news_digest.py` | 290 | LaunchAgent 07:00 | 뉴스 번역·요약 | +| 파일 | 포트/실행 | 역할 | +|------|----------|------| +| `caldav_bridge.py` | :8092 | CalDAV REST 래퍼 | +| `chat_bridge.py` | :8091 | DSM Chat API 브릿지 | +| `heic_converter.py` | :8090 | HEIC→JPEG 변환 | +| `inbox_processor.py` | LaunchAgent 5분 | OmniFocus Inbox 폴링 | +| `news_digest.py` | LaunchAgent 07:00 | 뉴스 번역·요약 | +| `morning_briefing.py` | LaunchAgent 07:30 | 모닝 브리핑 | ### LaunchAgent plist (6개) @@ -61,10 +68,10 @@ |------|--------| | `com.syn-chat-bot.caldav-bridge.plist` | caldav_bridge | | `com.syn-chat-bot.chat-bridge.plist` | chat_bridge | -| `com.syn-chat-bot.devonthink-bridge.plist` | devonthink_bridge | | `com.syn-chat-bot.heic-converter.plist` | heic_converter | | `com.syn-chat-bot.inbox-processor.plist` | inbox_processor | | `com.syn-chat-bot.news-digest.plist` | news_digest | +| `com.syn-chat-bot.morning-briefing.plist` | morning_briefing | ### n8n 워크플로우 (2개) @@ -80,6 +87,7 @@ | `init/init.sql` | 초기 스키마 (5테이블) | | `init/migrate-v2.sql` | v2 마이그레이션 (7테이블 추가) | | `init/migrate-v3.sql` | v3 마이그레이션 (calendar_events 확장) | +| `init/migrate-v5.sql` | v5 마이그레이션 (DEVONthink→kb_writer 전환) | ### 기타 @@ -120,7 +128,6 @@ pip install caldav aiohttp ### 3. macOS 앱 설정 -- **DEVONthink 4**: 설치 + 데이터베이스 열기 (AppleScript 접근 허용) - **OmniFocus**: 설치 + Inbox 사용 설정 - **Ollama (맥미니)**: `ollama pull bge-m3 && ollama pull bge-reranker-v2-m3` @@ -175,7 +182,6 @@ bash manage_services.sh load curl http://localhost:8090/health # heic_converter curl http://localhost:8091/health # chat_bridge curl http://localhost:8092/health # caldav_bridge -curl http://localhost:8093/health # devonthink_bridge # Docker 상태 docker compose ps @@ -195,4 +201,4 @@ docker exec bot-postgres psql -U bot chatbot -c '\dt' - [ ] CalDAV 양방향 동기화 - [ ] 메일 발송 (SMTP via MailPlus) - [ ] reminder 실구현 (알림 시간에 Synology Chat 푸시) -- [ ] DEVONthink 검색 결과 RAG 연동 +- [ ] 모닝 브리핑 고도화 (주간 요약, 커스텀 섹션) diff --git a/docs/architecture.md b/docs/architecture.md index 1b57188..05dbe8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ │ ├─ calendar → CalDAV Bridge → Synology Calendar │ │ ├─ reminder → calendar로 통합 처리 │ │ ├─ mail → 메일 요약 조회 (mail_logs) │ -│ ├─ note → DEVONthink Bridge → 문서 저장 │ +│ ├─ note → KB Writer → 문서 저장 │ │ └─ fallback → 일반 대화 (RAG + 3단계 라우팅) │ │ │ │ ④ [needs_rag=true] 멀티-컬렉션 RAG 검색 │ @@ -43,7 +43,7 @@ │ └─ api_heavy → 예산 체크 → Claude Opus (or 다운그레이드) │ │ │ │ ⑥ 응답 전송 + chat_logs + api_usage_monthly │ -│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 + DEVONthink│ +│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 + KB 저장 │ │ └─ classification_logs 기록 │ └──┬──────────┬───────────┬───────────┬──────────────────────┘ │ │ │ │ @@ -77,25 +77,20 @@ │ 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│ +│ │ +│ morning_briefing.py (LaunchAgent, 매일 07:30) │ +│ └─ 일정·메일·보고·뉴스 → 요약 → Synology Chat 전송 │ └────────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────┐ │ NAS 서비스 (192.168.1.227) │ │ Synology Chat / Calendar (CalDAV) / MailPlus │ └────────────────────────────────────────────────┘ - -┌────────────────────────────────────────────────┐ -│ DEVONthink 4 (맥미니) │ -│ AppleScript 경유 문서 저장·검색 │ -└────────────────────────────────────────────────┘ ``` ## 3단계 라우팅 상세 @@ -131,7 +126,7 @@ - `calendar` — CalDAV Bridge로 일정 생성/조회 (Synology Calendar) - `reminder` — calendar로 통합 (알림 시간 포함 일정 생성) - `mail` — mail_logs에서 최근 메일 요약 조회 -- `note` — DEVONthink Bridge로 문서 저장 +- `note` — KB Writer로 문서 저장 ``` ### 프리필터 → 분류기 → 모델 라우팅 흐름 @@ -342,7 +337,7 @@ Webhook POST /chat │ ├─ calendar → [Handle Calendar] → CalDAV Bridge → 확인응답 │ ├─ reminder → [Handle Calendar] (calendar로 통합) │ ├─ mail → [Handle Mail] → mail_logs 조회 → 요약응답 - │ ├─ note → [Handle Note] → DEVONthink Bridge → 확인응답 + │ ├─ note → [Handle Note] → KB Writer → 확인응답 │ └─ fallback → [Needs RAG?] │ ├─ needs_rag=true @@ -364,7 +359,7 @@ Webhook POST /chat │ ▼ [비동기] [Memorization Check] → [Should Memorize?] - ├─ true → [Embed & Save Memory] + [DEVONthink 저장] + ├─ true → [Embed & Save Memory] + [KB 저장] └─ false → (끝) ``` @@ -413,10 +408,9 @@ Webhook POST /chat - API 사용량 추적 (api_usage_monthly UPSERT) - HEIC→JPEG 변환 (heic_converter.py) + chat_bridge.py (DSM Chat API 브릿지) -### Phase 6: 캘린더·메일·DEVONthink·OmniFocus·뉴스 +### Phase 6: 캘린더·메일·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 @@ -428,4 +422,4 @@ Webhook POST /chat - CalDAV 양방향 동기화 (Synology Calendar → bot-postgres) - 메일 발송 (SMTP via MailPlus) - reminder 실구현 (알림 시간에 Synology Chat 푸시) -- DEVONthink 검색 결과 RAG 연동 +- 모닝 브리핑 고도화 (주간 요약, 커스텀 섹션) diff --git a/inbox_processor.py b/inbox_processor.py index d456c2a..cb02b2d 100644 --- a/inbox_processor.py +++ b/inbox_processor.py @@ -16,7 +16,7 @@ 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") +KB_WRITER_URL = os.getenv("KB_WRITER_URL", "http://127.0.0.1:8095") def run_applescript(script: str, timeout: int = 15) -> str: @@ -166,23 +166,26 @@ def route_calendar(cls: dict, task_id: str) -> None: def route_note(cls: dict, task_id: str) -> None: - """DEVONthink 브릿지로 메모 저장.""" + """kb_writer로 메모 저장.""" content = cls.get("content") or cls.get("title", "") title = cls.get("title", "OmniFocus 메모") try: resp = httpx.post( - f"{DEVONTHINK_BRIDGE_URL}/save", + f"{KB_WRITER_URL}/save", json={ "title": title, "content": content, - "type": "markdown", + "type": "note", "tags": ["omnifocus", "inbox"], + "username": "inbox-processor", + "source": "omnifocus", + "topic": "omnifocus", }, timeout=10, ) if resp.json().get("success"): - logger.info(f"Note saved to DEVONthink: {title}") + logger.info(f"Note saved to KB: {title}") mark_processed(task_id) complete_task(task_id) except Exception as e: diff --git a/init/migrate-v5.sql b/init/migrate-v5.sql new file mode 100644 index 0000000..a37ae60 --- /dev/null +++ b/init/migrate-v5.sql @@ -0,0 +1,4 @@ +-- migrate-v5.sql: DEVONthink → kb_writer 전환 +-- 실행: docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v5.sql +ALTER TABLE news_digest_log ADD COLUMN IF NOT EXISTS kb_path VARCHAR(200); +-- devonthink_uuid 컬럼은 기존 데이터 유지를 위해 삭제하지 않음 diff --git a/intent_service.py b/intent_service.py index da69caf..0977ab2 100644 --- a/intent_service.py +++ b/intent_service.py @@ -48,26 +48,51 @@ ID_SYSTEM_PROMPT = """너는 '이드'라는 이름의 AI 비서야. 한국어로 간결하고 실용적으로 답변하되, 친근한 톤을 유지해. 불필요한 인사나 꾸밈말은 생략하고 핵심만 전달해.""" -# 의도 분류 프롬프트 -CLASSIFY_PROMPT = """사용자 메시지를 분석하여 JSON으로 응답하라. +# 의도 분류 프롬프트 (n8n 파이프라인 호환) +def _build_classify_prompt(user_text: str) -> str: + now = datetime.now(KST) + today = now.strftime("%Y-%m-%d") + current_time = now.strftime("%H:%M:%S") + day_names = ["월", "화", "수", "목", "금", "토", "일"] + day_of_week = day_names[now.weekday()] -분류 기준: -- calendar: 일정/약속/회의 등 시간이 정해진 이벤트 -- todo: 작업/할일/과제 등 기한이 있는 태스크 -- note: 메모/기록/저장 요청 -- chat: 일반 대화, 질문, 인사 + return f"""현재: {today} {current_time} (KST, {day_of_week}요일). 사용자 메시지를 분석하고 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요. -반드시 아래 JSON 형식만 출력: -{"intent": "calendar|todo|note|chat", "confidence": 0.0~1.0, "title": "추출된 제목", "raw_datetime": "원문의 날짜/시간 표현"} +{{ + "intent": "greeting|question|log_event|calendar|todo|reminder|mail|note|photo|command|report|other", + "response_tier": "local|api_light|api_heavy", + "needs_rag": true/false, + "rag_target": ["documents", "tk_company", "chat_memory"], + "department_hint": "안전|생산|구매|품질|총무|시설|null", + "report_domain": "안전|시설설비|품질|null", + "query": "검색용 쿼리 (needs_rag=false면 null)", + "title": "추출된 제목 (calendar/todo/note 시)", + "raw_datetime": "원문의 날짜/시간 표현 (calendar/todo 시)" +}} -예시: -- "내일 3시 회의" → {"intent": "calendar", "confidence": 0.95, "title": "회의", "raw_datetime": "내일 3시"} -- "이번주까지 보고서 작성" → {"intent": "todo", "confidence": 0.9, "title": "보고서 작성", "raw_datetime": "이번주까지"} -- "메모해둬: 부품 발주 필요" → {"intent": "note", "confidence": 0.95, "title": "부품 발주 필요", "raw_datetime": ""} -- "안녕" → {"intent": "chat", "confidence": 0.99, "title": "", "raw_datetime": ""} -- "내일 자료 정리" → {"intent": "todo", "confidence": 0.6, "title": "자료 정리", "raw_datetime": "내일"} +intent 분류: +- log_event: 사실 기록/등록 요청 ("~구입","~완료","~교체","~점검","~수령","~입고","~등록") +- report: 긴급 사고/재해 신고만 ("사고","부상","화재","누수","폭발","붕괴" + 즉각 대응 필요) +- question: 정보 질문/조회 +- greeting: 인사/잡담/감사 +- calendar: 일정 등록/조회/삭제 ("일정","회의","미팅","약속","~시에 ~등록","오늘 일정","내일 뭐 있어") +- todo: 작업/할일/과제 ("~까지 ~작성","~해야 해","할 일","작업") +- reminder: 알림 설정 ("~시에 알려줘","리마인드") → calendar로 처리 +- mail: 메일 관련 조회 ("메일 확인","받은 메일","이메일","메일 왔어?") +- note: 메모/기록 요청 ("기록해","메모해","저장해","적어둬") +※ 애매하면 log_event로 분류 (기록 누락보다 안전) -사용자 메시지: """ +response_tier 판단: +- local: 인사, 잡담, log_event, report, calendar, todo, reminder, note, 단순 질문, mail 간단조회 +- api_light: 장문 요약(200자+), 다국어 번역, 비교 분석, RAG 결과 종합 +- api_heavy: 법률 해석, 복잡한 다단계 추론, 다중 문서 교차 분석 +※ 판단이 애매하면 local 우선 + +needs_rag 판단: +- true: 회사문서/절차 질문, 이전 기록 조회("최근","아까","전에"), 기술질문 +- false: 인사, 잡담, 일반상식, log_event, report, calendar, todo, note + +사용자 메시지: {user_text}""" app = FastAPI(title="Intent Service") @@ -369,18 +394,60 @@ async def _call_claude(prompt: str, system: str | None = None, # ==================== 엔드포인트 ==================== +def _keyword_fallback(text: str) -> dict: + """AI 실패 시 키워드 기반 분류 (ultimate safety net).""" + t = text + intent = "question" + response_tier = "api_light" + needs_rag = False + rag_target = [] + + if re.search(r'일정|회의|미팅|약속|스케줄|캘린더', t) and re.search(r'등록|잡아|추가|만들|넣어|수정|삭제|취소', t): + intent, response_tier = "calendar", "local" + elif re.search(r'일정|스케줄|뭐\s*있', t) and re.search(r'오늘|내일|이번|다음', t): + intent, response_tier = "calendar", "local" + elif re.search(r'까지|해야|할\s*일|작업', t) and re.search(r'작성|보고서|정리|준비|제출', t): + intent, response_tier = "todo", "local" + elif re.search(r'기록해|메모해|저장해|적어둬|메모\s*저장|노트', t): + intent, response_tier = "note", "local" + elif re.search(r'메일|이메일|받은\s*편지|mail', t) or (re.search(r'매일', t) and re.search(r'확인|왔|온|요약|읽', t)): + intent, response_tier = "mail", "local" + elif re.search(r'\d+시', t) and re.search(r'알려|리마인드|알림', t): + intent, response_tier = "calendar", "local" + elif re.search(r'구입|완료|교체|점검|수령|입고|발주', t) and not re.search(r'\?|까$|나$', t): + intent, response_tier = "log_event", "local" + else: + if len(text) <= 30 and not re.search(r'요약|번역|분석|비교', t): + response_tier = "local" + needs_rag = bool(re.search(r'회사|절차|문서|안전|품질|규정|아까|전에|기억', t)) + if needs_rag: + rag_target = ["documents"] + if re.search(r'회사|절차|안전|품질', t): + rag_target.append("tk_company") + if re.search(r'아까|이전|전에|기억', t): + rag_target.append("chat_memory") + + return { + "intent": intent, "response_tier": response_tier, + "needs_rag": needs_rag, "rag_target": rag_target, + "department_hint": None, "report_domain": None, + "query": text, "title": "", "raw_datetime": "", + "fallback": True, "fallback_method": "keyword", + } + + @app.post("/classify") async def classify_intent(request: Request): """의도 분류. body: {message: str} - Returns: {intent, confidence, title, raw_datetime, source: "ollama"|"claude"} + n8n 호환 출력: {intent, response_tier, needs_rag, rag_target, ..., title, raw_datetime, source} """ body = await request.json() message = body.get("message", "").strip() if not message: return JSONResponse({"success": False, "error": "message required"}, status_code=400) - prompt = CLASSIFY_PROMPT + message + prompt = _build_classify_prompt(message) # 1차: Ollama result_text = await _call_ollama(prompt, system="/no_think") @@ -392,40 +459,50 @@ async def classify_intent(request: Request): result_text, _, _ = await _call_claude(prompt, system="JSON만 출력하라. 다른 텍스트 없이.") source = "claude" - # 완전 실패 + # 완전 실패 → 키워드 fallback if not result_text: - return JSONResponse({"success": False, - "error": "AI 서비스 일시 중단. 잠시 후 다시 시도해주세요."}) + logger.warning("All AI classification failed, using keyword fallback") + fb = _keyword_fallback(message) + fb["source"] = "keyword" + fb["success"] = True + return fb # JSON 파싱 try: - # Ollama가 JSON 외 텍스트를 붙일 수 있으므로 추출 - json_match = re.search(r'\{[^}]+\}', result_text) + json_match = re.search(r'\{[^}]+\}', result_text, re.DOTALL) if json_match: parsed = json.loads(json_match.group()) else: parsed = json.loads(result_text) except json.JSONDecodeError: logger.warning(f"JSON parse failed: {result_text[:200]}") - # 파싱 실패 → chat으로 폴백 - parsed = {"intent": "chat", "confidence": 0.5, "title": "", "raw_datetime": ""} + fb = _keyword_fallback(message) + fb["source"] = source + fb["success"] = True + return fb - intent = parsed.get("intent", "chat") - confidence = float(parsed.get("confidence", 0.5)) + intent = parsed.get("intent", "question") + response_tier = parsed.get("response_tier", "api_light") + needs_rag = parsed.get("needs_rag", False) + rag_target = parsed.get("rag_target", []) + if not isinstance(rag_target, list): + rag_target = [] title = parsed.get("title", "") raw_datetime = parsed.get("raw_datetime", "") - # confidence 낮으면 재질문 신호 - needs_clarification = confidence < 0.7 - return { "success": True, "intent": intent, - "confidence": confidence, + "response_tier": response_tier, + "needs_rag": needs_rag, + "rag_target": rag_target, + "department_hint": parsed.get("department_hint"), + "report_domain": parsed.get("report_domain"), + "query": parsed.get("query", message), "title": title, "raw_datetime": raw_datetime, - "needs_clarification": needs_clarification, "source": source, + "fallback": False, } @@ -439,25 +516,32 @@ async def parse_date(request: Request): @app.post("/chat") async def chat(request: Request): - """자유 대화. body: {message: str, system?: str} + """자유 대화. body: {message: str, system?: str, rag_context?: str} - 1차 Ollama → 실패 시 Claude API (응답에 source 표시). + 1차 Ollama → 실패 시 Claude API (응답에 ☁️ 표시). """ body = await request.json() message = body.get("message", "").strip() system = body.get("system", ID_SYSTEM_PROMPT) + rag_context = body.get("rag_context", "") if not message: return JSONResponse({"success": False, "error": "message required"}, status_code=400) + # RAG 컨텍스트가 있으면 프롬프트에 추가 + prompt = "" + if rag_context: + prompt += f"[참고 자료]\n{rag_context}\n\n" + prompt += f"사용자: {message}\n이드:" + # 1차: Ollama (id-9b, 대화 모델) - response = await _call_ollama(message, system=system, model=OLLAMA_CHAT_MODEL, timeout=30) + response = await _call_ollama(prompt, system=system, model=OLLAMA_CHAT_MODEL, timeout=30) source = "ollama" # 2차: Claude fallback if response is None: logger.info("Chat fallback to Claude API") - response, _, _ = await _call_claude(message, system=system) + response, _, _ = await _call_claude(prompt, system=system) source = "claude" # 완전 실패 diff --git a/manage_services.sh b/manage_services.sh index 2a20f2b..37f9392 100755 --- a/manage_services.sh +++ b/manage_services.sh @@ -6,8 +6,8 @@ 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.kb-writer" + "com.syn-chat-bot.morning-briefing" "com.syn-chat-bot.mail-bridge" "com.syn-chat-bot.inbox-processor" "com.syn-chat-bot.news-digest" diff --git a/morning_briefing.py b/morning_briefing.py new file mode 100644 index 0000000..31634be --- /dev/null +++ b/morning_briefing.py @@ -0,0 +1,280 @@ +"""모닝 브리핑 — 일정·메일·보고·뉴스 요약 → Synology Chat (LaunchAgent, 매일 07:30)""" + +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("morning_briefing") + +CALDAV_BRIDGE_URL = os.getenv("CALDAV_BRIDGE_URL", "http://127.0.0.1:8092") +KARAKEEP_URL = os.getenv("KARAKEEP_URL", "http://localhost:3000") +KARAKEEP_API_KEY = os.getenv("KARAKEEP_API_KEY", "") +GPU_OLLAMA_URL = os.getenv("GPU_OLLAMA_URL", "http://192.168.1.186:11434") +SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "") + +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)) +WEEKDAYS = ["월", "화", "수", "목", "금", "토", "일"] + + +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_today_events() -> list[dict]: + """CalDAV 브릿지에서 오늘 일정 조회.""" + now = datetime.now(KST) + start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + end = now.replace(hour=23, minute=59, second=59, microsecond=0).isoformat() + + try: + resp = httpx.post( + f"{CALDAV_BRIDGE_URL}/calendar/query", + json={"start": start, "end": end}, + timeout=10, + ) + data = resp.json() + if not data.get("success"): + return [] + events = data.get("events", []) + events.sort(key=lambda e: e.get("start", "")) + return events + except Exception as e: + logger.error(f"CalDAV fetch failed: {e}") + return [] + + +def fetch_important_mails() -> list[dict]: + """DB에서 최근 24시간 업무 메일 조회.""" + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + "SELECT from_address, subject, summary FROM mail_logs " + "WHERE label = '업무' AND mail_date > NOW() - INTERVAL '24 hours' " + "ORDER BY mail_date DESC LIMIT 10" + ) + rows = cur.fetchall() + conn.close() + return [{"from": r[0], "subject": r[1], "summary": r[2]} for r in rows] + except Exception as e: + logger.error(f"Mail fetch failed: {e}") + return [] + + +def fetch_open_reports() -> list[dict]: + """DB에서 미해결 현장보고 조회.""" + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + "SELECT category, description, created_at FROM field_reports " + "WHERE status = 'open' " + "ORDER BY created_at DESC LIMIT 10" + ) + rows = cur.fetchall() + conn.close() + return [{"category": r[0], "description": r[1], "created_at": r[2]} for r in rows] + except Exception as e: + logger.error(f"Reports fetch failed: {e}") + return [] + + +def fetch_news(since: datetime) -> list[dict]: + """Karakeep에서 최근 24시간 뉴스 조회.""" + 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 []) + articles = [] + 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 + + title = bm.get("title") or bm.get("content", {}).get("title", "") + if title: + articles.append({"title": title}) + + return articles + except Exception as e: + logger.error(f"Karakeep fetch failed: {e}") + return [] + + +def summarize_news(articles: list[dict]) -> list[str]: + """LLM으로 뉴스 한줄 요약. 실패 시 원본 제목 fallback.""" + if not articles: + return [] + + titles = [a["title"] for a in articles[:5]] + prompt = "다음 뉴스 제목들을 각각 한 줄로 짧게 한국어 요약하세요. 번호 없이 줄바꿈으로 구분.\n\n" + "\n".join(titles) + + try: + resp = httpx.post( + f"{GPU_OLLAMA_URL}/api/generate", + json={ + "model": "id-9b:latest", + "system": "/no_think", + "prompt": prompt, + "stream": False, + "think": False, + }, + timeout=15, + ) + lines = [l.strip() for l in resp.json().get("response", "").strip().split("\n") if l.strip()] + if lines: + return lines[:5] + except Exception as e: + logger.error(f"News summarize failed: {e}") + + return titles[:5] + + +def format_briefing(events: list | None, mails: list | None, + reports: list | None, news: list[str] | None) -> str | None: + """브리핑 텍스트 조립. 전체 데이터 없으면 None.""" + now = datetime.now(KST) + weekday = WEEKDAYS[now.weekday()] + header = f"[모닝 브리핑] {now.strftime('%Y-%m-%d')} ({weekday})" + + sections = [] + + # 일정 + if events: + limit = 10 + lines = [] + for e in events[:limit]: + start = e.get("start", "") + time_str = start[11:16] if len(start) >= 16 else "" + title = e.get("summary") or e.get("title", "") + location = e.get("location", "") + entry = f"- {time_str} {title}" if time_str else f"- {title}" + if location: + entry += f" ({location})" + lines.append(entry) + count_str = f"{len(events)}건" + if len(events) > limit: + count_str = f"{limit}건, 외 {len(events) - limit}건" + sections.append(f"[일정] 오늘 ({count_str})\n" + "\n".join(lines)) + + # 메일 + if mails: + limit = 5 + lines = [] + for m in mails[:limit]: + sender = m.get("from", "").split("<")[0].strip().strip('"') + lines.append(f"- {sender}: {m.get('subject', '')}") + count_str = f"{len(mails)}건" + if len(mails) > limit: + count_str = f"{limit}건, 외 {len(mails) - limit}건" + sections.append(f"[메일] 주요 ({count_str})\n" + "\n".join(lines)) + + # 보고 + if reports: + limit = 5 + lines = [] + for r in reports[:limit]: + cat = r.get("category", "") + desc = (r.get("description") or "")[:50] + created = r.get("created_at") + date_str = "" + if created: + if isinstance(created, datetime): + date_str = created.strftime("%m/%d") + else: + date_str = str(created)[:10] + entry = f"- [{cat}] {desc}" + if date_str: + entry += f" -- {date_str} 접수" + lines.append(entry) + count_str = f"{len(reports)}건" + if len(reports) > limit: + count_str = f"{limit}건, 외 {len(reports) - limit}건" + sections.append(f"[보고] 미해결 ({count_str})\n" + "\n".join(lines)) + + # 뉴스 + if news: + lines = [f"- {n}" for n in news[:5]] + count_str = f"{len(news)}건" + sections.append(f"[뉴스] ({count_str})\n" + "\n".join(lines)) + + if not sections: + return None + + return header + "\n\n" + "\n\n".join(sections) + + +def send_briefing(text: str) -> None: + """Synology Chat 웹훅으로 브리핑 전송.""" + if not SYNOLOGY_CHAT_WEBHOOK_URL: + logger.warning("SYNOLOGY_CHAT_WEBHOOK_URL not set") + return + + try: + httpx.post( + SYNOLOGY_CHAT_WEBHOOK_URL, + data={"payload": json.dumps({"text": text})}, + verify=False, + timeout=10, + ) + logger.info("Briefing sent to Synology Chat") + except Exception as e: + logger.error(f"Chat send failed: {e}") + + +def main(): + logger.info("Morning briefing started") + + since = datetime.now(KST) - timedelta(hours=24) + + # 데이터 수집 (각각 독립, 실패해도 계속) + events = fetch_today_events() or None + mails = fetch_important_mails() or None + reports = fetch_open_reports() or None + + news_articles = fetch_news(since) + news_lines = summarize_news(news_articles) if news_articles else None + + text = format_briefing(events, mails, reports, news_lines) + + if not text: + logger.info("No data for briefing — skipping") + return + + send_briefing(text) + logger.info("Morning briefing complete") + + +if __name__ == "__main__": + main() diff --git a/n8n/workflows/mail-processing-pipeline.json b/n8n/workflows/mail-processing-pipeline.json index bd8c824..c1d0356 100644 --- a/n8n/workflows/mail-processing-pipeline.json +++ b/n8n/workflows/mail-processing-pipeline.json @@ -109,7 +109,7 @@ }, { "parameters": { - "jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nfunction httpPut(url, body, { timeout = 10000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst 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 } }];" + "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// KB 저장 (graceful)\ntry {\n const kbUrl = $env.KB_WRITER_URL || 'http://host.docker.internal:8095';\n await httpPost(`${kbUrl}/save`, {\n title: item.subject, content: item.summary,\n type: 'note', tags: ['mail', item.label],\n username: 'mail-pipeline', source: 'mailplus', topic: item.label || 'mail'\n }, { timeout: 5000 });\n} catch(e) {}\n\nreturn [{ json: { ...item, embedded: true } }];" }, "id": "m1000001-0000-0000-0000-000000000005", "name": "Embed & Save", diff --git a/news_digest.py b/news_digest.py index d7bb8ca..d2f729a 100644 --- a/news_digest.py +++ b/news_digest.py @@ -20,7 +20,7 @@ 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") +KB_WRITER_URL = os.getenv("KB_WRITER_URL", "http://127.0.0.1:8095") # Postgres 연결 (직접 접속) PG_HOST = os.getenv("PG_HOST", "127.0.0.1") @@ -179,21 +179,24 @@ def embed_to_qdrant(text: str) -> str | None: return None -def save_to_devonthink(title: str, content: str) -> str | None: - """DEVONthink에 저장.""" +def save_to_kb(title: str, content: str) -> str | None: + """kb_writer에 저장.""" try: resp = httpx.post( - f"{DEVONTHINK_BRIDGE_URL}/save", + f"{KB_WRITER_URL}/save", json={ "title": title, "content": content, - "type": "markdown", + "type": "news", "tags": ["news", "digest"], + "username": "news-digest", + "source": "karakeep", + "topic": "news", }, timeout=10, ) data = resp.json() - return data.get("uuid") if data.get("success") else None + return data.get("path") if data.get("success") else None except Exception: return None @@ -258,7 +261,7 @@ def main(): emb_text = f"{result['title_ko']} {result['summary_ko']}" qdrant_id = embed_to_qdrant(emb_text) - dt_uuid = save_to_devonthink( + kb_path = save_to_kb( result["title_ko"], f"**원문**: {bm['url']}\n**출처**: {bm.get('source', '')}\n\n{result['summary_ko']}", ) @@ -268,9 +271,9 @@ def main(): 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) " + "INSERT INTO news_digest_log (article_url,source,original_lang,title_ko,summary_ko,qdrant_id,kb_path) " "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), + (bm["url"], bm.get("source", ""), lang, result["title_ko"], result["summary_ko"], qdrant_id, kb_path), ) conn.commit() except Exception as e: