Phase 5-6: API usage tracking + Calendar/Mail/DEVONthink/OmniFocus/News pipeline
- 파이프라인 42→51노드 확장 (calendar/mail/note 핸들러 추가) - 네이티브 서비스 6개: heic_converter(:8090), chat_bridge(:8091), caldav_bridge(:8092), devonthink_bridge(:8093), inbox_processor, news_digest - 분류기 v2→v3: calendar, reminder, mail, note intent 추가 - Mail Processing Pipeline (7노드, IMAP 폴링) - LaunchAgent plist 6개 + manage_services.sh - migrate-v3.sql: news_digest_log + calendar_events 확장 - 개발 문서 현행화 (CLAUDE.md, QUICK_REFERENCE.md, docs/architecture.md) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
.env.example
21
.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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
n8n/data/
|
||||
postgres/data/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
56
CLAUDE.md
56
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`로 코드 리뷰
|
||||
|
||||
|
||||
@@ -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 저장
|
||||
|
||||
269
caldav_bridge.py
Normal file
269
caldav_bridge.py
Normal file
@@ -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"""<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{dt_start.strftime('%Y%m%dT%H%M%SZ')}"
|
||||
end="{dt_end.strftime('%Y%m%dT%H%M%SZ')}"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"""
|
||||
|
||||
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'<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:displayname/></D:prop></D:propfind>',
|
||||
)
|
||||
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}
|
||||
293
chat_bridge.py
Normal file
293
chat_bridge.py
Normal file
@@ -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()
|
||||
},
|
||||
}
|
||||
25
com.syn-chat-bot.caldav-bridge.plist
Normal file
25
com.syn-chat-bot.caldav-bridge.plist
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.caldav-bridge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
|
||||
<string>-S</string>
|
||||
<string>-c</string>
|
||||
<string>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)</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/caldav-bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/caldav-bridge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
25
com.syn-chat-bot.chat-bridge.plist
Normal file
25
com.syn-chat-bot.chat-bridge.plist
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.chat-bridge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
|
||||
<string>-S</string>
|
||||
<string>-c</string>
|
||||
<string>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)</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/chat-bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/chat-bridge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
25
com.syn-chat-bot.devonthink-bridge.plist
Normal file
25
com.syn-chat-bot.devonthink-bridge.plist
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.devonthink-bridge</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
|
||||
<string>-S</string>
|
||||
<string>-c</string>
|
||||
<string>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)</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/devonthink-bridge.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/devonthink-bridge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
27
com.syn-chat-bot.heic-converter.plist
Normal file
27
com.syn-chat-bot.heic-converter.plist
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.heic-converter</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot/.venv/bin/uvicorn</string>
|
||||
<string>heic_converter:app</string>
|
||||
<string>--host</string>
|
||||
<string>127.0.0.1</string>
|
||||
<string>--port</string>
|
||||
<string>8090</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/heic-converter.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/heic-converter.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
23
com.syn-chat-bot.inbox-processor.plist
Normal file
23
com.syn-chat-bot.inbox-processor.plist
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.inbox-processor</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
|
||||
<string>-S</string>
|
||||
<string>-c</string>
|
||||
<string>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()</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>300</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/inbox-processor.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/inbox-processor.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
28
com.syn-chat-bot.news-digest.plist
Normal file
28
com.syn-chat-bot.news-digest.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.syn-chat-bot.news-digest</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/opt/python@3.14/bin/python3.14</string>
|
||||
<string>-S</string>
|
||||
<string>-c</string>
|
||||
<string>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()</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/hyungi/Documents/code/syn-chat-bot</string>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>7</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/news-digest.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/news-digest.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
125
devonthink_bridge.py
Normal file
125
devonthink_bridge.py
Normal file
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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 연동
|
||||
|
||||
25
heic_converter.py
Normal file
25
heic_converter.py
Normal file
@@ -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"})
|
||||
225
inbox_processor.py
Normal file
225
inbox_processor.py
Normal file
@@ -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()
|
||||
39
init/migrate-v3.sql
Normal file
39
init/migrate-v3.sql
Normal file
@@ -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);
|
||||
80
manage_services.sh
Executable file
80
manage_services.sh
Executable file
@@ -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
|
||||
203
n8n/workflows/mail-processing-pipeline.json
Normal file
203
n8n/workflows/mail-processing-pipeline.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
290
news_digest.py
Normal file
290
news_digest.py
Normal file
@@ -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()
|
||||
10
start-bridge.sh
Executable file
10
start-bridge.sh
Executable file
@@ -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)
|
||||
"
|
||||
Reference in New Issue
Block a user