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:
hyungi
2026-03-14 17:09:04 +09:00
parent f7cccc9c5e
commit 612933c2d3
23 changed files with 2492 additions and 67 deletions

View File

@@ -30,3 +30,24 @@ GPU_OLLAMA_URL=http://192.168.1.186:11434
# Qdrant (Docker 내부에서 접근) # Qdrant (Docker 내부에서 접근)
QDRANT_URL=http://host.docker.internal:6333 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
View File

@@ -2,3 +2,4 @@
n8n/data/ n8n/data/
postgres/data/ postgres/data/
.DS_Store .DS_Store
__pycache__/

View File

@@ -8,20 +8,29 @@ Synology Chat + n8n + Claude API 기반 RAG 챗봇 시스템.
``` ```
Synology Chat (NAS 192.168.1.227) Synology Chat (NAS 192.168.1.227)
↕ 웹훅 ↕ 웹훅
bot-n8n (맥미니 Docker) bot-n8n (맥미니 Docker) — 51노드 파이프라인
├─⓪ 토큰 검증 + Rate Limit (10초/5건) ├─⓪ 토큰 검증 + Rate Limit (10초/5건)
├─① 규칙 기반 프리필터 (인사/감사 → 하드코딩 local 응답) ├─① 규칙 기반 프리필터 (인사/감사 → 하드코딩 local 응답)
├─② GPU Qwen 9B (192.168.1.186) — 분류 v2 ├─② GPU Qwen 9B (192.168.1.186) — 분류 v3
│ 출력: {intent, response_tier, needs_rag, rag_target, ...} │ 출력: {intent, response_tier, needs_rag, rag_target, ...}
│ 타임아웃 10초 → fallback: api_light │ 타임아웃 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 │ documents + tk_company + chat_memory
│ bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 │ bge-m3 임베딩 → Qdrant 검색 → reranker → top-3
├─ response_tier 기반 3단계 라우팅 ├─ response_tier 기반 3단계 라우팅
│ ├─ local → Qwen 9B 직접 답변 (무료) │ ├─ local → Qwen 9B 직접 답변 (무료)
│ ├─ api_light → Claude Haiku (저비용) │ ├─ api_light → Claude Haiku (저비용)
│ └─ api_heavy → Claude Opus (예산 초과 시 → Haiku 다운그레이드) │ └─ api_heavy → Claude Opus (예산 초과 시 → Haiku 다운그레이드)
@@ -29,8 +38,25 @@ bot-n8n (맥미니 Docker)
├── bot-postgres (설정/로그/라우팅/분류기로그/API사용량) ├── bot-postgres (설정/로그/라우팅/분류기로그/API사용량)
└── Qdrant (벡터 검색, 3컬렉션) └── Qdrant (벡터 검색, 3컬렉션)
응답 전송 + chat_logs 저장 + API 사용량 UPSERT 응답 전송 + chat_logs 저장 + API 사용량 UPSERT
[비동기] 선택적 메모리 (Qwen 판단 → 가치 있으면 벡터화) [비동기] 선택적 메모리 (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-n8n | Docker (맥미니) | 5678 | 워크플로우 엔진 |
| bot-postgres | Docker (맥미니) | 127.0.0.1:15478 | 설정/로그 DB | | bot-postgres | Docker (맥미니) | 127.0.0.1:15478 | 설정/로그 DB |
| Qdrant | Docker (맥미니, 기존) | 127.0.0.1:6333 | 벡터 DB (3컬렉션) | | 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응답) | | 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 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단계 라우팅 ## 3단계 라우팅
@@ -56,14 +92,14 @@ bot-n8n (맥미니 Docker)
| 컬렉션 | 용도 | | 컬렉션 | 용도 |
|--------|------| |--------|------|
| `documents` | 개인/일반 문서 + 메일 요약 | | `documents` | 개인/일반 문서 + 메일 요약 + 뉴스 요약 |
| `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 | | `tk_company` | TechnicalKorea 회사 문서 + 현장 리포트 |
| `chat_memory` | 가치 있는 대화 기억 (선택적 저장) | | `chat_memory` | 가치 있는 대화 기억 (선택적 저장) |
## DB 스키마 (bot-postgres) ## DB 스키마 (bot-postgres)
**기존**: `ai_configs`, `routing_rules`, `prompts`, `chat_logs`, `mail_accounts` **기존**: `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) 참조. 상세 스키마는 [docs/architecture.md](docs/architecture.md) 참조.
@@ -100,6 +136,8 @@ bot-n8n (맥미니 Docker)
- DB 포트는 127.0.0.1로 바인딩 (외부 노출 금지) - DB 포트는 127.0.0.1로 바인딩 (외부 노출 금지)
- 시크릿(API 키 등)은 .env 파일로 관리, git에 포함하지 않음 - 시크릿(API 키 등)은 .env 파일로 관리, git에 포함하지 않음
- 커밋 전 docker-compose config로 문법 검증 - 커밋 전 docker-compose config로 문법 검증
- 네이티브 서비스는 LaunchAgent로 관리 (manage_services.sh)
- pip install은 .venv에서 실행
- 세션 시작 시 Plan 모드로 계획 → 확정 후 구현 - 세션 시작 시 Plan 모드로 계획 → 확정 후 구현
- 구현 완료 후 `/verify`로 검증, `/simplify`로 코드 리뷰 - 구현 완료 후 `/verify`로 검증, `/simplify`로 코드 리뷰

View File

@@ -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 (맥미니) | http://localhost:11434 |
| Ollama API (GPU) | http://192.168.1.186:11434 | | Ollama API (GPU) | http://192.168.1.186:11434 |
| Synology Chat | NAS (192.168.1.227) | | 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 명령어 ## Docker 명령어
@@ -61,6 +65,9 @@ docker exec -i bot-postgres psql -U bot -d chatbot < init/migrate-v2.sql
# Qdrant tk_company 컬렉션 + 인덱스 설정 # Qdrant tk_company 컬렉션 + 인덱스 설정
bash init/setup-qdrant.sh 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']]" && \ 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 ===" && \ 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 " (미생성)" && \ 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 ===" && \ echo "=== n8n ===" && \
curl -s -o /dev/null -w ' HTTP %{http_code}' http://localhost:5678 && echo && \ curl -s -o /dev/null -w ' HTTP %{http_code}' http://localhost:5678 && echo && \
echo "=== API 사용량 ===" && \ echo "=== API 사용량 ===" && \
@@ -92,16 +105,27 @@ syn-chat-bot/
├── .env.example ← 환경변수 템플릿 ├── .env.example ← 환경변수 템플릿
├── CLAUDE.md ← 프로젝트 문서 ├── CLAUDE.md ← 프로젝트 문서
├── QUICK_REFERENCE.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/ ├── docs/
│ ├── architecture.md ← 아키텍처, DB 스키마, 파이프라인 상세 │ ├── architecture.md ← 아키텍처, DB 스키마, 파이프라인 상세
│ └── claude-code-playbook.md │ └── claude-code-playbook.md
├── n8n/ ├── n8n/
│ ├── data/ ← n8n 런타임 데이터 │ ├── data/ ← n8n 런타임 데이터
│ └── workflows/ │ └── workflows/
── main-chat-pipeline.json ← 메인 워크플로우 (37노드) ── main-chat-pipeline.json ← 메인 워크플로우 (51노드)
│ └── mail-processing-pipeline.json ← 메일 처리 파이프라인 (7노드)
├── init/ ├── init/
│ ├── init.sql ← DB 초기 스키마 v2 (12테이블) │ ├── init.sql ← DB 초기 스키마 v2 (12테이블)
│ ├── migrate-v2.sql ← 기존 DB 마이그레이션 │ ├── migrate-v2.sql ← 기존 DB 마이그레이션
│ ├── migrate-v3.sql ← v3 마이그레이션 (Phase 5-6 테이블)
│ └── setup-qdrant.sh ← Qdrant 컬렉션/인덱스 설정 │ └── setup-qdrant.sh ← Qdrant 컬렉션/인덱스 설정
└── postgres/data/ ← DB 데이터 └── postgres/data/ ← DB 데이터
``` ```
@@ -138,6 +162,30 @@ docker exec bot-postgres psql -U bot -d chatbot -c "SELECT * FROM api_usage_mont
# GPU 서버 연결 안 될 때 # GPU 서버 연결 안 될 때
ping 192.168.1.186 ping 192.168.1.186
curl -s http://192.168.1.186:11434/api/tags 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 접속 정보 ## n8n 접속 정보
@@ -145,7 +193,8 @@ curl -s http://192.168.1.186:11434/api/tags
- URL: http://localhost:5678 - URL: http://localhost:5678
- 이메일: ahn@hyungi.net - 이메일: ahn@hyungi.net
- 비밀번호: .env의 N8N_BASIC_AUTH_PASSWORD와 동일 - 비밀번호: .env의 N8N_BASIC_AUTH_PASSWORD와 동일
- 워크플로우: "메인 채팅 파이프라인 v2" (37 노드, 활성 상태) - 워크플로우: "메인 채팅 파이프라인 v3" (51 노드, 활성 상태)
- 메일 처리 워크플로우: "메일 처리 파이프라인" (7 노드)
- 웹훅 엔드포인트: POST http://localhost:5678/webhook/chat - 웹훅 엔드포인트: POST http://localhost:5678/webhook/chat
## Synology Chat 연동 ## Synology Chat 연동
@@ -163,7 +212,6 @@ NAS에서 Outgoing Webhook 설정 필요:
### Phase 0: 맥미니 정리 ### Phase 0: 맥미니 정리
- [ ] ollama rm qwen3.5:35b-a3b (삭제) - [ ] ollama rm qwen3.5:35b-a3b (삭제)
- [ ] ollama rm qwen3.5:35b-a3b-think (삭제) - [ ] ollama rm qwen3.5:35b-a3b-think (삭제)
- [ ] ollama pull minicpm-v:8b (비전 모델 설치, Phase 5용)
### Phase 1: 기반 (Qdrant + DB) ### Phase 1: 기반 (Qdrant + DB)
- [x] init.sql v2 (12테이블 + 분류기 v2 프롬프트 + 메모리 판단 프롬프트) - [x] init.sql v2 (12테이블 + 분류기 v2 프롬프트 + 메모리 판단 프롬프트)
@@ -173,7 +221,7 @@ NAS에서 Outgoing Webhook 설정 필요:
- [ ] Qdrant 설정 실행 - [ ] Qdrant 설정 실행
### Phase 2: 3단계 라우팅 + 검색 라우팅 ### 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 환경변수 추가 - [x] .env + docker-compose.yml 환경변수 추가
- [ ] n8n에 워크플로우 임포트 + 활성화 - [ ] n8n에 워크플로우 임포트 + 활성화
- [ ] 테스트: "안녕" → local, "요약해줘" → Haiku, "법률 해석" → Opus - [ ] 테스트: "안녕" → local, "요약해줘" → Haiku, "법률 해석" → Opus
@@ -188,16 +236,34 @@ NAS에서 Outgoing Webhook 설정 필요:
- [ ] 텍스트 청킹 + 임베딩 + tk_company 저장 구현 - [ ] 텍스트 청킹 + 임베딩 + tk_company 저장 구현
- [ ] 문서 버전 관리 (deprecated + version++) - [ ] 문서 버전 관리 (deprecated + version++)
### Phase 5: 현장 리포팅 ### Phase 5: 현장 리포팅 + API 사용량 추적
- [x] field_reports 테이블 + SLA 인덱스 - [x] field_reports 테이블 + SLA 인덱스
- [ ] 비전 모델 설치 + 사진 분석 노드 - [x] 비전 모델 사진 분석 (base64 변환 + HEIC 자동 변환)
- [ ] /보고서 월간 보고서 생성 구현 - [x] HEIC→JPEG 변환 서비스 (heic_converter.py, macOS sips)
- [ ] SLA 트래킹 스케줄 워크플로우 - [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 테이블 - [x] mail_logs, calendar_events 테이블
- [ ] IMAP 폴링 워크플로우 - [x] 분류기 v3 (calendar, reminder, mail, note intent 추가)
- [ ] CalDAV 연동 - [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 답변 8. GPU 서버 다운 → fallback Haiku 답변
9. 잘못된 토큰 → reject 9. 잘못된 토큰 → reject
10. 10초 내 6건 → rate limit 10. 10초 내 6건 → rate limit
11. "내일 회의 잡아줘" → calendar intent → CalDAV 이벤트 생성
12. "최근 메일 확인" → mail intent → 메일 요약 반환
13. "이거 메모해둬" → note intent → DEVONthink 저장

269
caldav_bridge.py Normal file
View 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
View 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()
},
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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}

View File

@@ -28,6 +28,10 @@ services:
- LOCAL_OLLAMA_URL=${LOCAL_OLLAMA_URL} - LOCAL_OLLAMA_URL=${LOCAL_OLLAMA_URL}
- GPU_OLLAMA_URL=${GPU_OLLAMA_URL} - GPU_OLLAMA_URL=${GPU_OLLAMA_URL}
- QDRANT_URL=${QDRANT_URL:-http://host.docker.internal:6333} - 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 - NODE_FUNCTION_ALLOW_BUILTIN=crypto,http,https,url
volumes: volumes:
- ./n8n/data:/home/node/.n8n - ./n8n/data:/home/node/.n8n

View File

@@ -10,7 +10,7 @@
│ Outgoing Webhook │ Outgoing Webhook
┌────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────┐
│ bot-n8n (맥미니 Docker :5678) — 37노드 파이프라인 │ │ bot-n8n (맥미니 Docker :5678) — 51노드 파이프라인 │
│ │ │ │
│ ⓪ 토큰 검증 + Rate Limit (username별 10초/5건) │ │ ⓪ 토큰 검증 + Rate Limit (username별 10초/5건) │
│ │ │ │
@@ -20,10 +20,19 @@
│ ② 명령어 체크 (/설정, /모델, /성격, /문서등록, /보고서) │ │ ② 명령어 체크 (/설정, /모델, /성격, /문서등록, /보고서) │
│ └─ 권한 체크 (ADMIN_USERNAMES allowlist) │ │ └─ 권한 체크 (ADMIN_USERNAMES allowlist) │
│ │ │ │
│ ③ GPU Qwen 9B 분류 v2 (10초 타임아웃) │ │ ③ GPU Qwen 9B 분류 v3 (10초 타임아웃) │
│ → {intent, response_tier, needs_rag, rag_target, ...} │ │ → {intent, response_tier, needs_rag, rag_target, ...} │
│ └─ 실패 시 fallback → api_light │ │ └─ 실패 시 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 검색 │ │ ④ [needs_rag=true] 멀티-컬렉션 RAG 검색 │
│ documents + tk_company + chat_memory │ │ documents + tk_company + chat_memory │
│ → bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 │ │ → bge-m3 임베딩 → Qdrant 검색 → reranker → top-3 │
@@ -34,7 +43,7 @@
│ └─ api_heavy → 예산 체크 → Claude Opus (or 다운그레이드) │ │ └─ api_heavy → 예산 체크 → Claude Opus (or 다운그레이드) │
│ │ │ │
│ ⑥ 응답 전송 + chat_logs + api_usage_monthly │ │ ⑥ 응답 전송 + chat_logs + api_usage_monthly │
│ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 │ ⑦ [비동기] Qwen 메모리 판단 → 가치 있으면 벡터화 + DEVONthink
│ └─ classification_logs 기록 │ │ └─ classification_logs 기록 │
└──┬──────────┬───────────┬───────────┬──────────────────────┘ └──┬──────────┬───────────┬───────────┬──────────────────────┘
│ │ │ │ │ │ │ │
@@ -47,6 +56,46 @@
│ │ │tk_company││비전모델 │ │(분류+응답) │ │ │ │tk_company││비전모델 │ │(분류+응답) │
│ │ │chat_memory│ │ │ │ │ │ │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단계 라우팅 상세 ## 3단계 라우팅 상세
@@ -64,11 +113,11 @@
- ~50% → Haiku (저비용) - ~50% → Haiku (저비용)
- ~10% → Opus (복잡한 질문만) - ~10% → Opus (복잡한 질문만)
### 분류기 v2 출력 스키마 ### 분류기 v3 출력 스키마
```json ```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", "response_tier": "local|api_light|api_heavy",
"needs_rag": true, "needs_rag": true,
"rag_target": ["documents", "tk_company", "chat_memory"], "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 사용량 + 예산 상한 -- api_usage_monthly: API 사용량 + 예산 상한
-- year + month + tier UNIQUE -- year + month + tier UNIQUE
-- estimated_cost vs budget_limit 비교 → 다운그레이드 -- 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 기준표 ### SLA 기준표
@@ -232,18 +301,26 @@ Should Memorize?
- **분류기 fallback**: Qwen 10초 타임아웃 → {response_tier: "api_light"} - **분류기 fallback**: Qwen 10초 타임아웃 → {response_tier: "api_light"}
- **리랭커 fallback**: bge-reranker 실패 → Qdrant score 정렬 - **리랭커 fallback**: bge-reranker 실패 → Qdrant score 정렬
- **비전 모델 fallback**: 사진 분석 실패 → 사용자 설명만으로 구조화 - **비전 모델 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 Webhook POST /chat
[Parse Input] — 토큰 검증 + Rate Limit [Parse Input] — 토큰 검증 + Rate Limit + channelId/userId 추출
├─ rejected → [Reject Response] → Send + Respond ├─ rejected → [Reject Response] → Send + Respond
[Has Pending Doc?] — 문서 등록 대기
├─ pending → [Process Document] → [Log Doc Ingestion] → Send + Respond
[Regex Pre-filter] — 인사/감사 정규식 [Regex Pre-filter] — 인사/감사 정규식
├─ match → [Pre-filter Response] → Send + Respond ├─ match → [Pre-filter Response] → Send + Respond
@@ -255,10 +332,19 @@ Webhook POST /chat
│ ├─ DB 필요 → [Command DB Query] → [Format] → Send + Respond │ ├─ DB 필요 → [Command DB Query] → [Format] → Send + Respond
│ └─ 직접 → [Direct Response] → Send + Respond │ └─ 직접 → [Direct Response] → Send + Respond
└─ false → [Qwen Classify v2] (10초 타임아웃) └─ false → [Qwen Classify v3] (10초 타임아웃)
├─ [Log Classification] (비동기, PostgreSQL) ├─ [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 ├─ needs_rag=true
│ → [Get Embedding] → [Multi-Collection Search] │ → [Get Embedding] → [Multi-Collection Search]
│ → [Build RAG Context] (출처 표시) │ → [Build RAG Context] (출처 표시)
@@ -278,7 +364,7 @@ Webhook POST /chat
▼ [비동기] ▼ [비동기]
[Memorization Check] → [Should Memorize?] [Memorization Check] → [Should Memorize?]
├─ true → [Embed & Save Memory] ├─ true → [Embed & Save Memory] + [DEVONthink 저장]
└─ false → (끝) └─ false → (끝)
``` ```
@@ -315,17 +401,31 @@ Webhook POST /chat
간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만. 간결하게 답하고, 모르면 솔직히 말하세요. 이모지는 핵심에만.
``` ```
## 향후 기능 (Phase 4-6) ## 구현 완료
### Phase 4: 회사 문서 등록 ### Phase 4: 회사 문서 등록
- `/문서등록 [부서] [유형] [제목]` → 청킹 → tk_company 저장 - `/문서등록 [부서] [유형] [제목]` → 청킹 → tk_company 저장
- hash 중복 체크, 문서 버전 관리 - hash 중복 체크, 문서 버전 관리
### Phase 5: 현장 리포팅 ### Phase 5: 현장 리포팅 + API 사용량 추적
- 사진 + 텍스트 → 비전 모델 → 구조화 → field_reports + tk_company - 사진 + 텍스트 → Claude Haiku Vision → 구조화 → field_reports + tk_company
- `/보고서 [영역] [년월]` → 월간 보고서 생성 - `/보고서 [영역] [년월]` → 월간 보고서 생성 (report_cache)
- SLA 트래킹 + 긴급 에스컬레이션 - API 사용량 추적 (api_usage_monthly UPSERT)
- HEIC→JPEG 변환 (heic_converter.py) + chat_bridge.py (DSM Chat API 브릿지)
### Phase 6: 메일 + 캘린더 ### Phase 6: 캘린더·메일·DEVONthink·OmniFocus·뉴스
- IMAP 폴링 → Qwen 분석 → mail_logs + Qdrant - 분류기 v3: calendar, reminder, mail, note intent 추가
- CalDAV 연동 → calendar_events - 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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)
"