From 45cabc9aea17a5d6f012812cfd3ee915f109f434 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 13:19:31 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20GPU=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=20+=20ChromaDB=E2=86=92Qdrant=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - embed_to_chroma.py → embed_to_qdrant.py 리라이트 (bge-m3 + Qdrant REST API) - auto_classify.scpt: embed_to_qdrant.py 경로 변경 + sourceChannel 덮어쓰기 버그 수정 - requirements.txt: chromadb/schedule 제거, qdrant-client/flask/gunicorn 추가 - credentials.env.example: GPU_SERVER_IP 항목 추가 - GPU 서버 재구성 계획서 (docs/gpu-restructure.md) + dev-roadmap/commands 통합 - CLAUDE.md, README.md, deploy.md 현행화 GPU 서버 변경사항 (이미 적용됨): - Ollama: qwen3.5:9b, id-9b 제거 → bge-m3 + bge-reranker-v2-m3 - Surya OCR 서비스 (:8400, systemd) - Docker + NFS + Komga 이전 (:25600) - tk-ai-service: Ollama API → OpenAI API 전환 (MLX 35B) Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 165 +++++++-- README.md | 114 +++++- applescript/auto_classify.scpt | 3 +- credentials.env.example | 3 + docs/claude-code-commands.md | 644 ++++++++++++++++++++++----------- docs/deploy.md | 199 ++++++++-- docs/dev-roadmap.md | 399 ++++++++++++++++++++ docs/gpu-restructure.md | 460 +++++++++++++++++++++++ requirements.txt | 5 +- scripts/embed_to_qdrant.py | 114 ++++++ scripts/pkm_api_server.py | 239 ++++++++++++ 11 files changed, 2068 insertions(+), 277 deletions(-) create mode 100644 docs/dev-roadmap.md create mode 100644 docs/gpu-restructure.md create mode 100644 scripts/embed_to_qdrant.py create mode 100644 scripts/pkm_api_server.py diff --git a/CLAUDE.md b/CLAUDE.md index ca33844..51b4b90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,21 +1,68 @@ # DEVONThink PKM 시스템 — Claude Code 작업 가이드 +> 마지막 업데이트: 2026-03-29 +> 개발 현황: Phase 1 초기 구축 완료 → Phase 1.5 GPU 서버 재구성 + Phase 2 인프라 수정 병행 중 + ## 프로젝트 개요 Mac mini M4 Pro(64GB, 4TB) 기반 개인 지식관리(PKM) 시스템. -DEVONthink 4를 중앙 허브로, Ollama AI 자동 분류 + 법령 모니터링 + 일일 다이제스트를 자동화한다. +DEVONthink 4를 중앙 허브로, MLX AI 자동 분류 + 법령 모니터링 + 일일 다이제스트를 자동화한다. ## 핵심 문서 (반드시 먼저 읽을 것) 1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 구조, 태그, AI, 자동화 전체) 2. `docs/industrial-safety-blueprint.md` — 04_Industrial Safety DB 상세 설계 -3. `docs/claude-code-commands.md` — 단계별 작업 지시서 +3. `docs/claude-code-commands.md` — 단계별 작업 지시서 (현재 진행 상황 포함) +4. `docs/dev-roadmap.md` — 개발 로드맵 (Phase 1.5~6) +5. `docs/deploy.md` — Mac mini 배포 가이드 + 트러블슈팅 +6. `docs/gpu-restructure.md` — GPU 서버 재구성 상세 계획 (Phase 1.5) + +## 개발 현황 요약 + +``` +[Phase 1: 초기 구축] ██████████████████░░ 90% — 코드 작성 완료, 인프라 일부 미해결 + + ✅ 1단계: 프로젝트 구조 — 완료 + ✅ 2단계: AI 분류 프롬프트 — MLX Qwen3.5 OpenAI 호환 전환 완료 + ✅ 3단계: AppleScript — auto_classify + omnifocus_sync 완료 + ⚠️ 4단계: 법령 모니터링 — 외국(US/JP/EU) OK, 한국 API IP 미등록 + ❌ 5단계: MailPlus 수집 — IMAP Connection refused + ⚠️ 6단계: Daily Digest — 코드 완성, 실행 테스트 미진행 + ✅ 7단계: DEVONagent 가이드 — 완료 + ❌ 8단계: 전체 테스트 — 미진행 + ✅ 추가: PKM API 서버 — 기본 동작, 개선 필요 + +[Phase 1.5: GPU 서버 재구성] ░░░░░░░░░░░░░░░░░░░░ 0% — 계획 완료, 실행 대기 + → docs/gpu-restructure.md 참조 + → GPU 모델 교체, Surya OCR, Komga 이전, Qdrant 통합, RAG 파이프라인 + +[Phase 2: 인프라 + 버그 픽스] ░░░░░░░░░░░░░░░░░░░░ 0% — Phase 1.5와 병행 착수 대기 + → docs/dev-roadmap.md 참조 +``` + +## 알려진 이슈 (현재) + +``` +[P1 — 인프라] +- 한국 법령 API: open.law.go.kr에 Mac mini 공인IP 등록 필요 +- MailPlus IMAP: 993 포트 Connection refused — Synology DSM에서 IMAP 활성화 확인 +- requirements.txt: flask 누락, chromadb→qdrant-client 교체, schedule 미사용 +- launchd: plist 파일은 있으나 실제 등록 여부 미확인 +- GPU 서버: 중복 LLM 모델 제거 + Surya OCR/Komga 이전 필요 → gpu-restructure.md + +[P2 — 코드 버그] +- JP 번역: MLX thinking 출력이 번역 결과에 오염 ("Wait, I'll check...") +- API 서버: /devonthink/stats → 500 에러, 한글 쿼리 인코딩 400 에러 +- AppleScript: 경로 하드코딩 + sourceChannel 이중 설정 버그 (73행) +- embed_to_chroma.py: GPU_SERVER_IP 미설정으로 미작동 → Qdrant 리라이트 예정 +``` ## 네트워크 환경 ``` Mac mini (운영 서버): - - Ollama: http://localhost:11434 + - MLX 서버: http://localhost:8800/v1/chat/completions (Qwen3.5-35B-A3B) + - PKM API: http://127.0.0.1:9900 (Flask, GUI 세션 필수) - DEVONthink: 로컬 실행 중 - OmniFocus: 로컬 실행 중 @@ -24,11 +71,12 @@ Synology NAS (DS1525+): - Tailscale IP: 100.101.79.37 - 포트: 15001 - WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/ - - MailPlus IMAP: mailplus.hyungi.net:993 (SSL) + - MailPlus IMAP: mailplus.hyungi.net:993 (SSL) ← 현재 연결 불가 -GPU 서버 (RTX 4070 Ti Super): - - 역할: 임베딩(nomic-embed-text), 비전(Qwen2.5-VL-7B), 리랭킹(bge-reranker) - - Tailscale IP: 별도 확인 필요 +GPU 서버 (RTX 4070 Ti Super, 192.168.1.186): + - 현재: Ollama(11434) + qwen3.5:9b + id-9b, Plex(32400) + - 계획: bge-m3(임베딩) + bge-reranker(리랭킹) + Surya OCR(:8400) + Komga(:25600) + - → docs/gpu-restructure.md 참조 TKSafety: tksafety.technicalkorea.net (설정만, 나중에 활성화) ``` @@ -38,6 +86,7 @@ TKSafety: tksafety.technicalkorea.net (설정만, 나중에 활성화) - 위치: `~/.config/pkm/credentials.env` - 템플릿: `./credentials.env.example` - 스크립트에서 python-dotenv로 로딩 +- 필수 키: LAW_OC, MAILPLUS_HOST/PORT/USER/PASS, NAS_DOMAIN, GPU_SERVER_IP ## DEVONthink DB 구조 (13개) @@ -80,46 +129,112 @@ manual — 직접 추가 → dataOrigin = work (기본 ``` Tier 1 (Mac mini, 상시): - mlx-community/Qwen3.5-35B-A3B-4bit — 태그 생성, 문서 분류, 요약 + mlx-community/Qwen3.5-35B-A3B-4bit — 태그 생성, 문서 분류, 요약, JP 번역 → http://localhost:8800/v1/chat/completions (OpenAI 호환 API) → MLX 서버로 실행 중 (Ollama 아님) + ※ thinking 모드 주의: /nothink 명시 또는 JSON 추출 후처리 필요 Tier 2 (Claude API, 필요시): claude-sonnet — 복잡한 분석, 장문 처리 - → CLAUDE_API_KEY 사용 + → CLAUDE_API_KEY 사용 (아직 미연동) -Tier 3 (GPU 서버, 특수): - nomic-embed-text — 벡터 임베딩 - Qwen2.5-VL-7B — 이미지/도면 OCR - bge-reranker-v2-m3 — RAG 리랭킹 +Tier 3 (GPU 서버, 특수) — ※ 재구성 예정 (gpu-restructure.md 참조): + 현재: qwen3.5:9b-q8_0, id-9b (제거 예정) + 변경 후: + bge-m3 — 벡터 임베딩 (1024차원, Ollama) + bge-reranker-v2-m3 — RAG 리랭킹 (Ollama) + Surya OCR — 이미지/스캔 문서 OCR (FastAPI, 포트 8400) +``` + +## 파일 구조 (현재) + +``` +./ +├── CLAUDE.md ← 이 파일 (Claude Code 작업 가이드) +├── README.md ← 프로젝트 설명 +├── requirements.txt ← Python 패키지 (flask 추가 필요!) +├── .gitignore +├── credentials.env.example ← 인증 정보 템플릿 +├── scripts/ +│ ├── pkm_utils.py ← 공통 유틸 (로깅, 인증, LLM, AppleScript) +│ ├── law_monitor.py ← 법령 모니터링 (한국+US/JP/EU) +│ ├── mailplus_archive.py ← MailPlus 이메일 수집 +│ ├── pkm_daily_digest.py ← 일일 다이제스트 생성 +│ ├── pkm_api_server.py ← REST API 서버 (Flask, 포트 9900) +│ ├── embed_to_chroma.py ← ChromaDB 벡터 임베딩 (→ embed_to_qdrant.py로 교체 예정) +│ └── prompts/ +│ └── classify_document.txt ← AI 분류 프롬프트 템플릿 +├── applescript/ +│ ├── auto_classify.scpt ← Inbox 자동 분류 Smart Rule +│ └── omnifocus_sync.scpt ← OmniFocus 연동 Smart Rule +├── launchd/ +│ ├── net.hyungi.pkm.law-monitor.plist +│ ├── net.hyungi.pkm.mailplus.plist +│ └── net.hyungi.pkm.daily-digest.plist +├── data/ +│ ├── law_last_check.json ← 법령 마지막 확인 시점 +│ └── laws/ ← 수집된 법령 문서 (16건 수집 완료) +├── logs/ ← 실행 로그 +├── docs/ +│ ├── architecture.md ← 시스템 아키텍처 +│ ├── industrial-safety-blueprint.md +│ ├── claude-code-commands.md ← 단계별 작업 지시서 +│ ├── deploy.md ← Mac mini 배포 가이드 +│ ├── devonagent-setup.md ← DEVONagent 검색 세트 가이드 +│ ├── dev-roadmap.md ← 개발 로드맵 (Phase 1.5~6) +│ └── gpu-restructure.md ← GPU 서버 재구성 상세 계획 +├── tests/ +│ └── test_classify.py ← AI 분류 테스트 (5종 문서) +└── venv/ ← Python 가상환경 ``` ## 작업 순서 -docs/claude-code-commands.md의 단계를 순서대로 진행: +### Phase 1 (완료): 초기 구축 +docs/claude-code-commands.md의 1~7단계 → 코드 작성 완료 -1. **프로젝트 구조** — README.md, deploy.md 작성 (구조는 이미 생성됨) -2. **Ollama 테스트** — 분류 프롬프트 최적화 → scripts/prompts/에 저장 -3. **AppleScript** — auto_classify.scpt, omnifocus_sync.scpt -4. **법령 모니터링** — scripts/law_monitor.py + launchd plist -5. **이메일 수집** — scripts/mailplus_archive.py + launchd plist -6. **Daily Digest** — scripts/pkm_daily_digest.py + launchd plist -7. **DEVONagent 가이드** — docs/devonagent-setup.md (수동 설정 가이드) -8. **테스트** — tests/ + docs/test-report.md +### Phase 1.5 (계획 완료): GPU 서버 재구성 +docs/gpu-restructure.md 참조: +1. GPU 모델 교체 (LLM 제거, bge-m3/reranker 설치) +2. Docker + NFS + Komga 이전 +3. Surya OCR 설치 +4. PKM 코드 갱신 (Qdrant 통합, embed 스크립트, AppleScript) +5. RAG 파이프라인 구축 (후순위) + +### Phase 2 (진행 중): 인프라 수정 + 버그 픽스 +docs/dev-roadmap.md 참조 (Phase 1.5와 병행): +1. requirements.txt 수정 ← Phase 1.5와 합산 (qdrant-client, flask) +2. 한국 법령 API IP 등록 +3. MailPlus IMAP 연결 수정 +4. JP 번역 thinking 오염 필터링 +5. API 서버 한글 인코딩 + stats 500 에러 수정 +6. AppleScript 하드코딩 경로 변수화 ← Phase 1.5와 합산 +7. launchd 등록 및 확인 + +### Phase 3~4: API 서버 개선 + 테스트 +- gunicorn 전환 + launchd plist 추가 +- 엔드포인트 추가 (/law-monitor/status, /digest/latest) +- 모듈별 + E2E 통합 테스트 → docs/test-report.md + +### Phase 5~6: 운영 안정화 +- 로그 로테이션, Synology Chat 알림, 문서 보완 ## 코딩 규칙 -- Python 3.11+ (Mac mini 기본) +- Python 3.11+ (Mac mini 기본, 현재 3.14 확인됨) - 인증 정보는 반드시 credentials.env에서 로딩 (하드코딩 금지) - AppleScript는 DEVONthink/OmniFocus와 연동 (osascript로 호출) - 로그는 ~/Documents/code/DEVONThink_my\ server/logs/에 저장 - launchd plist는 launchd/ 디렉토리에 생성, Mac mini에서 심볼릭 링크로 등록 +- LLM 호출 시 pkm_utils.llm_generate() 사용 (thinking 후처리 포함) - 한글 주석 사용 ## 배포 방법 ``` MacBook Pro (개발) → Gitea push → Mac mini에서 git pull +또는 Cowork 모드에서 직접 파일 수정 → git push + Mac mini에서: cd ~/Documents/code/DEVONThink_my\ server/ git pull @@ -132,6 +247,8 @@ Mac mini에서: - credentials.env는 git에 올리지 않음 (.gitignore에 포함) - DEVONthink, OmniFocus는 Mac mini에서 GUI로 실행 중이어야 AppleScript 작동 -- 법령 API (LAW_OC)는 승인 대기 중 — 스크립트만 만들고 실제 호출은 승인 후 +- PKM API 서버도 GUI 세션에서 실행 필수 (AppleScript 중계) +- 법령 API (LAW_OC): 키 발급 완료, Mac mini 공인IP 등록 필요 - TKSafety 연동은 설계만 완료, 구현은 나중에 - GPU 서버 Tailscale IP는 별도 확인 후 credentials.env에 추가 +- MLX 서버 thinking 모드: 번역/분류 시 /nothink 프리픽스 또는 후처리 필수 diff --git a/README.md b/README.md index 4325e0b..9f71565 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,91 @@ # DEVONThink PKM System -Mac mini M4 Pro 기반 개인 지식관리 자동화 시스템 +Mac mini M4 Pro 기반 개인 지식관리(PKM) 자동화 시스템 -## 구성 요소 +## 시스템 구성 -- **DEVONthink 4** — 중앙 지식 허브 (13개 DB) -- **Ollama** — AI 자동 분류/태깅 (Qwen3.5-35B-A3B) -- **법령 모니터링** — 산업안전보건법 등 변경 추적 -- **일일 다이제스트** — PKM 전체 변화 요약 -- **OmniFocus 연동** — 액션 아이템 자동 생성 +``` +┌─────────────────── Mac mini M4 Pro (허브) ───────────────────┐ +│ │ +│ DEVONthink 4 ◄── DEVONagent Pro │ +│ (13개 DB) (자동 검색) │ +│ │ │ +│ ┌────┴─────── 자동화 레이어 ────────────────────────┐ │ +│ │ auto_classify.scpt 법령 모니터링 이메일 수집 │ │ +│ │ omnifocus_sync.scpt 일일 다이제스트 PKM API │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ OmniFocus 4 MLX Qwen3.5-35B-A3B (AI 분류/번역) │ +│ (작업 관리) localhost:8800 │ +│ │ +└──────────────────────────┬─────────────────────────────────────┘ + │ Tailscale VPN + ┌────────────────┼────────────────┐ +┌─────────▼──────────┐ ┌────────────▼─────────────┐ +│ Synology DS1525+ │ │ GPU 서버 (RTX 4070 Ti S) │ +│ Gitea · MailPlus │ │ 임베딩 · OCR · 리랭킹 │ +│ WebDAV 동기화 │ │ Plex │ +└────────────────────┘ └──────────────────────────┘ +``` -## 설치 +## 핵심 기능 + +**AI 자동 분류** — DEVONthink Inbox에 들어온 문서를 MLX Qwen3.5가 분석하여 13개 DB 중 적합한 곳으로 자동 이동, 태그와 메타데이터를 자동 부여 + +**법령 모니터링** — 산업안전보건법, 중대재해처벌법 등 7개 한국 법령 + US OSHA, JP 厚労省, EU-OSHA 해외 법령 변경을 매일 자동 추적 + +**이메일 아카이브** — Synology MailPlus에서 IMAP으로 이메일을 수집하여 DEVONthink Archive DB에 자동 보관 + +**일일 다이제스트** — DEVONthink 변화, OmniFocus 진행 상황, 법령 변경 등을 종합한 일일 보고서 자동 생성 + +**OmniFocus 연동** — Projects DB의 TODO 패턴을 감지하여 OmniFocus에 작업 자동 생성, DEVONthink 역링크 포함 + +**REST API** — DEVONthink/OmniFocus 상태를 HTTP로 조회 (내부 모니터링용) + +## 디렉토리 구조 + +``` +scripts/ Python 스크립트 + pkm_utils.py 공통 유틸 (로깅, 인증, LLM 호출) + law_monitor.py 법령 변경 모니터링 (한국+US/JP/EU) + mailplus_archive.py MailPlus 이메일 수집 + pkm_daily_digest.py 일일 다이제스트 생성 + pkm_api_server.py REST API 서버 (Flask, 포트 9900) + embed_to_chroma.py ChromaDB 벡터 임베딩 + prompts/ AI 프롬프트 템플릿 + +applescript/ DEVONthink/OmniFocus 연동 + auto_classify.scpt Inbox 자동 분류 Smart Rule + omnifocus_sync.scpt OmniFocus 작업 생성 Smart Rule + +launchd/ macOS 스케줄 실행 + net.hyungi.pkm.law-monitor.plist 매일 07:00 + net.hyungi.pkm.mailplus.plist 매일 07:00, 18:00 + net.hyungi.pkm.daily-digest.plist 매일 20:00 + +docs/ 문서 + architecture.md 시스템 아키텍처 + deploy.md 배포 가이드 + 트러블슈팅 + claude-code-commands.md 개발 작업 지시서 + dev-roadmap.md 개발 로드맵 + devonagent-setup.md DEVONagent 검색 세트 가이드 + industrial-safety-blueprint.md 산업안전 DB 설계 + +data/ 데이터 + laws/ 수집된 법령 문서 + law_last_check.json 마지막 확인 시점 + +tests/ 테스트 + test_classify.py AI 분류 정확도 테스트 +``` + +## 빠른 시작 ```bash # Mac mini에서 -git clone [gitea-repo-url] -cd DEVONThink_my\ server -python3 -m venv venv -source venv/bin/activate +git clone https://git.hyungi.net/hyungi/devonthink_home.git "DEVONThink_my server" +cd "DEVONThink_my server" +python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt # 인증 정보 설정 @@ -29,12 +97,22 @@ chmod 600 ~/.config/pkm/credentials.env 자세한 배포 방법은 `docs/deploy.md` 참조 -## 디렉토리 구조 +## 실행 환경 + +| 구성 요소 | 요구사항 | +|-----------|---------| +| macOS | 14+ (Sonoma) | +| Python | 3.11+ | +| DEVONthink | 4.x, GUI 실행 중 | +| OmniFocus | 4.x, GUI 실행 중 | +| MLX 서버 | Qwen3.5-35B-A3B, localhost:8800 | +| Tailscale | NAS/GPU 서버 접근용 | + +## 개발 ``` -scripts/ Python 스크립트 (법령모니터, 메일수집, 다이제스트) -applescript/ DEVONthink/OmniFocus 연동 AppleScript -launchd/ macOS 스케줄 실행 plist -docs/ 설계 문서, 가이드 -tests/ 테스트 코드 +개발 흐름: +MacBook Pro (또는 Cowork) → git push → Gitea (NAS) → Mac mini에서 git pull ``` + +개발 현황과 다음 작업은 `docs/dev-roadmap.md` 참조 diff --git a/applescript/auto_classify.scpt b/applescript/auto_classify.scpt index 66a6e6e..057a977 100644 --- a/applescript/auto_classify.scpt +++ b/applescript/auto_classify.scpt @@ -70,7 +70,6 @@ except: add custom meta data sourceChannel for "sourceChannel" to theRecord add custom meta data dataOrigin for "dataOrigin" to theRecord add custom meta data (current date) for "lastAIProcess" to theRecord - add custom meta data "inbox_route" for "sourceChannel" to theRecord -- 7. 대상 도메인 DB로 이동 set targetDatabase to missing value @@ -89,7 +88,7 @@ except: -- 8. GPU 서버 벡터 임베딩 비동기 전송 set embedScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3" - set embedPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/embed_to_chroma.py" + set embedPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/embed_to_qdrant.py" do shell script embedScript & " " & quoted form of embedPy & " " & quoted form of docUUID & " &> /dev/null &" on error errMsg diff --git a/credentials.env.example b/credentials.env.example index ee7ecb0..ac0633a 100644 --- a/credentials.env.example +++ b/credentials.env.example @@ -24,6 +24,9 @@ MAILPLUS_PASS= # ─── Synology Chat 웹훅 (나중에 추가) ─── #CHAT_WEBHOOK_URL= +# ─── GPU 서버 (임베딩/OCR) ─── +GPU_SERVER_IP=192.168.1.xxx + # ─── TKSafety API (나중에 활성화) ─── #TKSAFETY_HOST= #TKSAFETY_PORT= diff --git a/docs/claude-code-commands.md b/docs/claude-code-commands.md index c9a8b54..24f3b07 100644 --- a/docs/claude-code-commands.md +++ b/docs/claude-code-commands.md @@ -1,18 +1,19 @@ # Claude Code 실행 명령어 — PKM 시스템 구축 > 작업 위치: MacBook Pro ~/Documents/code/DEVONThink_my server/ -> Claude Code를 이 디렉토리에서 실행 +> 또는 Cowork 모드에서 마운트된 폴더 > 완성 후 Gitea에 push → Mac mini에서 pull ``` 개발/배포 흐름: -MacBook Pro (Claude Code) +MacBook Pro (Claude Code / Cowork) ~/Documents/code/DEVONThink_my server/ → 스크립트/설정 파일 작성 → git commit & push │ ▼ Gitea (Synology NAS) + https://git.hyungi.net/hyungi/devonthink_home.git │ ▼ Mac mini (git pull → 실행) @@ -20,272 +21,478 @@ MacBook Pro (Claude Code) --- -## 0단계: 프로젝트 구조 생성 + credentials.env 복사 +# Phase 1: 초기 구축 (완료) -Claude Code 실행 전에 먼저: +> 2026-03-26 ~ 03-27 작업. 총 15 커밋. + +## 0단계: 프로젝트 구조 생성 + credentials.env ✅ 완료 ```bash -# MacBook Pro에서 -cd ~/Documents/code/DEVONThink_my\ server/ - -# credentials.env를 프로젝트에 복사 (gitignore 필수!) -cp ~/.config/pkm/credentials.env ./credentials.env.example -# example은 값을 비운 템플릿용, 실제 파일은 Mac mini에서 직접 생성 - -# Mac mini에서 (SSH 접속 후) +# Mac mini에서 mkdir -p ~/.config/pkm nano ~/.config/pkm/credentials.env -# → 실제 인증 정보 입력 chmod 600 ~/.config/pkm/credentials.env ``` +## 1단계: 프로젝트 구조 + requirements.txt ✅ 완료 + +생성된 파일: README.md, requirements.txt, .gitignore, credentials.env.example, 전체 디렉토리 구조 + +## 2단계: AI 분류 프롬프트 ✅ 완료 + +- MLX 서버(localhost:8800) OpenAI 호환 API로 전환됨 +- Qwen3.5 thinking 모드 대응 완료 (JSON 추출 후처리) +- 프롬프트: scripts/prompts/classify_document.txt + +## 3단계: AppleScript ✅ 완료 + +- applescript/auto_classify.scpt — Inbox 자동 분류 +- applescript/omnifocus_sync.scpt — OmniFocus 작업 생성 + +## 4단계: 법령 모니터링 ⚠️ 부분 완료 + +- scripts/law_monitor.py 작성 완료 +- 외국 법령 (US OSHA 1건, JP 厚労省 10건, EU-OSHA 5건) 수집 성공 +- ❌ 한국 법령 API: IP 등록 미완 → Phase 2에서 해결 + +## 5단계: MailPlus 이메일 수집 ❌ 연결 실패 + +- scripts/mailplus_archive.py 코드 완성 +- ❌ IMAP 접속 실패 (Connection refused) → Phase 2에서 해결 + +## 6단계: Daily Digest ⚠️ 미테스트 + +- scripts/pkm_daily_digest.py 코드 완성 +- 실행 테스트 미진행 → Phase 2 이후 테스트 + +## 7단계: DEVONagent 가이드 ✅ 완료 + +- docs/devonagent-setup.md — 9개 검색 세트 설정 가이드 + +## 8단계: 전체 테스트 ❌ 미진행 + +- tests/test_classify.py 작성 완료 +- docs/test-report.md 미생성 → Phase 4에서 진행 + +## 추가: PKM API 서버 (계획 외 생성) + +- scripts/pkm_api_server.py — Flask REST API (포트 9900) +- DEVONthink + OmniFocus 상태 조회용 +- 기본 동작 확인됨, 버그 있음 → Phase 2에서 수정 + --- -## 1단계: 프로젝트 구조 + requirements.txt +# Phase 1.5: GPU 서버 재구성 (Phase 2와 병행) + +> 상세 계획: docs/gpu-restructure.md 참조 +> GPU 서버 SSH 작업 + PKM 프로젝트 코드 수정 + +## GPU-1단계: GPU 서버 Ollama 모델 교체 + +```bash +# GPU 서버에서 실행 (ssh 192.168.1.186) + +# 1. 기존 LLM 모델 제거 +ollama rm qwen3.5:9b-q8_0 +ollama rm id-9b + +# 2. 임베딩/리랭킹 모델 설치 +ollama pull bge-m3 +ollama pull bge-reranker-v2-m3 + +# 3. no-think proxy 비활성화 +sudo systemctl disable --now ollama-proxy + +# 4. Ollama systemd 환경 조정 +sudo systemctl edit ollama +# Environment="OLLAMA_MAX_LOADED_MODELS=2" +# Environment="OLLAMA_KEEP_ALIVE=30m" +sudo systemctl daemon-reload && sudo systemctl restart ollama + +# 5. 검증 +ollama list # → bge-m3, bge-reranker만 존재 +curl localhost:11434/api/embed -d '{"model":"bge-m3","input":["테스트"]}' # → 1024차원 벡터 +``` + +## GPU-2단계: tk-ai-service 코드 수정 (Mac Mini) ``` -이 프로젝트의 디렉토리 구조를 만들고 기본 설정 파일들을 생성해줘. -작업 디렉토리: 현재 디렉토리 (~/Documents/code/DEVONThink_my server/) +tk-ai-service의 ollama_client.py를 OpenAI API 호환으로 리팩터링해줘. -프로젝트 구조: -./ -├── README.md ← 프로젝트 설명 -├── requirements.txt ← Python 패키지 목록 -├── .gitignore ← credentials.env, venv, logs, __pycache__ 등 제외 -├── credentials.env.example ← 인증 정보 템플릿 (값은 비움) -├── scripts/ -│ ├── law_monitor.py -│ ├── mailplus_archive.py -│ ├── pkm_daily_digest.py -│ ├── embed_to_chroma.py -│ └── prompts/ -│ └── classify_document.txt -├── applescript/ -│ ├── auto_classify.scpt -│ └── omnifocus_sync.scpt -├── launchd/ -│ ├── net.hyungi.pkm.law-monitor.plist -│ ├── net.hyungi.pkm.mailplus.plist -│ └── net.hyungi.pkm.daily-digest.plist -├── docs/ -│ ├── devonagent-setup.md -│ └── deploy.md ← Mac mini 배포 방법 -└── tests/ - └── test_classify.py +변경 대상: + ~/docker/tk-factory-services/ai-service/services/ollama_client.py + → generate_text(): /api/chat → /v1/chat/completions + → check_health(): /api/tags → /v1/models + → generate_embedding(): 변경 없음 (GPU Ollama 유지) -requirements.txt에 넣을 패키지: -- chromadb -- requests -- python-dotenv -- schedule -- markdown +docker-compose.yml 환경변수: + OLLAMA_BASE_URL=http://host.internal:8800 (MLX) + OLLAMA_TEXT_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit + OLLAMA_EMBED_URL=http://192.168.1.186:11434 (GPU) -.gitignore에 반드시 포함: -- credentials.env -- venv/ -- logs/ -- __pycache__/ -- *.pyc -- .DS_Store +검증: docker compose build && docker compose up -d + curl http://localhost:30400/health → 모두 connected +``` -deploy.md에는 Mac mini에서의 설치 절차 작성: -1. git pull -2. python3 -m venv venv && source venv/bin/activate -3. pip install -r requirements.txt -4. credentials.env는 ~/.config/pkm/credentials.env에 별도 관리 -5. launchd plist 심볼릭 링크 등록 방법 +## GPU-3단계: Docker + NFS + Komga 이전 -네트워크 환경: -- NAS 도메인: ds1525.hyungi.net (Tailscale: 100.101.79.37, 포트: 15001) -- MailPlus: mailplus.hyungi.net:993 (IMAP SSL) -- WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/ -- TKSafety: tksafety.technicalkorea.net (나중에 활성화) +```bash +# GPU 서버에서 실행 (ssh 192.168.1.186) + +# 1. Docker 설치 +sudo apt-get update +sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin +sudo usermod -aG docker hyungi + +# 2. NFS 마운트 +sudo apt install nfs-common +sudo mkdir -p /mnt/comic +echo '192.168.1.227:/volume1/Comic /mnt/comic nfs4 ro,nosuid,noexec,nodev,soft,timeo=15 0 0' | sudo tee -a /etc/fstab +sudo mount -a +ls /mnt/comic # 확인 + +# 3. Komga 컨테이너 시작 +sudo mkdir -p /opt/komga && cd /opt/komga +# docker-compose.yml 생성 (gpu-restructure.md Phase 1.5-3 참조) +docker compose up -d + +# Mac Mini에서: +docker stop komga && docker rm komga +# nginx-ssl.conf: komga_backend upstream → 192.168.1.186:25600 +docker restart home-service-proxy + +# 검증 +curl https://komga.hyungi.net # → GPU 서버 경유 접근 +``` + +## GPU-4단계: Surya OCR 설치 + +```bash +# GPU 서버에서 실행 (ssh 192.168.1.186) + +# 1. PyTorch CUDA 확인 +python3 -c "import torch; print(torch.cuda.is_available())" +# False면: pip install torch --index-url https://download.pytorch.org/whl/cu124 + +# 2. Surya OCR 설치 +sudo mkdir -p /opt/surya-ocr && cd /opt/surya-ocr +python3 -m venv venv +source venv/bin/activate +pip install surya-ocr fastapi uvicorn python-multipart + +# 3. server.py 작성 (FastAPI 래퍼, gpu-restructure.md Phase 2-2 참조) + +# 4. systemd 등록 +# /etc/systemd/system/surya-ocr.service (gpu-restructure.md Phase 2-3 참조) +sudo systemctl daemon-reload +sudo systemctl enable --now surya-ocr + +# 검증 +curl -F "file=@test.pdf" http://localhost:8400/ocr +``` + +## GPU-5단계: Qdrant 통합 + PKM 코드 갱신 + +``` +PKM 프로젝트 코드를 GPU 서버 재구성에 맞게 갱신해줘. + +1. scripts/embed_to_chroma.py → scripts/embed_to_qdrant.py 리라이트 + - chromadb → qdrant-client + - nomic-embed-text → bge-m3 (GPU 서버 192.168.1.186:11434) + - /api/embed 사용 (배치 지원) + - 텍스트 청킹 (500토큰, 50오버랩) + - 기존 embed_to_chroma.py는 git rm + +2. applescript/auto_classify.scpt 수정 + - Step 0: OCR 감지 + Surya OCR 호출 추가 + - Step 4: embed_to_qdrant.py 호출로 변경 + - 버그 픽스: 73행 sourceChannel 이중 설정 삭제 + - baseDir 변수 사용 (12단계와 합산) + +3. requirements.txt 업데이트 (9단계와 합산) + - chromadb, schedule 제거 + + qdrant-client, flask, gunicorn 추가 + +4. credentials.env.example: GPU_SERVER_IP 추가 +5. Qdrant에 pkm_documents 컬렉션 생성 (1024차원, cosine) + +검증: python3 scripts/embed_to_qdrant.py <테스트UUID> → Qdrant 벡터 저장 +``` + +## GPU-6단계: architecture.md 대규모 갱신 + +``` +docs/architecture.md를 GPU 서버 재구성에 맞게 전체 갱신해줘. + +변경 규모: ChromaDB 28건, nomic-embed 12건, VL-7B 5건 — 문맥별 수정 필요 + +주요 변경: +- Tier 3 모델: nomic-embed-text → bge-m3, Qwen2.5-VL-7B → Surya OCR +- 벡터 DB: ChromaDB → Qdrant (모든 언급) +- VRAM 다이어그램: ~11.3GB → ~7-8GB +- Smart Rule 설계: embed_to_chroma → embed_to_qdrant, OCR 단계 추가 +- 3-Tier AI 라우팅 전략 표 갱신 +- 코드 예시 내 경로/모델명 + +※ 단순 치환 불가, 전체 문서를 통독하며 문맥에 맞게 수정할 것 +※ 별도 커밋으로 분리 +``` + +## GPU-7단계: RAG 파이프라인 구축 (후순위) + +``` +pkm_api_server.py에 RAG 엔드포인트를 추가해줘. + +추가 엔드포인트: + POST /rag/query — 질문 → bge-m3 임베딩 → Qdrant 검색 → 리랭킹 → MLX 답변 + POST /devonthink/embed — 단일 문서 임베딩 트리거 + POST /devonthink/embed-batch — 배치 임베딩 + +docstring 갱신: "범위: DEVONthink + OmniFocus + RAG 검색" + +scripts/ocr_preprocess.py 신규 작성: + DEVONthink UUID → AppleScript로 파일 경로 추출 → Surya API(GPU:8400) 호출 + +검증: curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}' ``` --- -## 2단계: Ollama 모델 확인 + 분류 프롬프트 테스트 +# Phase 2: 인프라 수정 + 버그 픽스 (Phase 1.5와 병행) + +> dev-roadmap.md Phase 2~3 해당 +> ※ requirements.txt, AppleScript 경로, credentials.env는 GPU-5단계와 합산 진행 + +## 9단계: requirements.txt 수정 ← GPU-5단계와 합산 ``` -Ollama가 정상 동작하는지 확인하고, PKM 문서 분류용 프롬프트를 테스트해줘. +requirements.txt에 flask가 빠져있어. pkm_api_server.py에서 사용 중이니까 추가해줘. +GPU 서버 재구성에 따라 chromadb→qdrant-client 교체도 함께 진행. +gunicorn도 추가해 (프로덕션 WSGI 서버용). +schedule 패키지는 현재 미사용 — 제거할지 유지할지 판단해줘. +anthropic 패키지는 향후 Tier 2 연동용이니 유지. -1. ollama list로 현재 모델 확인 -2. qwen3.5:35b-a3b 계열 모델이 있는지 확인 (없으면 알려줘) -3. 테스트 프롬프트 실행 — 아래 내용으로 분류 테스트: +수정할 파일: requirements.txt -테스트 문서: "산업안전보건법 시행규칙 일부개정령안 입법예고 - 고용노동부는 위험성평가에 관한 지침을 개정하여..." +추가: ++ flask>=3.0.0 ++ gunicorn>=21.2.0 -기대 응답 JSON: -{ - "tags": ["위험성평가", "법령개정", "고용노동부"], - "domain_db": "04_Industrial safety", - "sub_group": "10_Legislation/Notice", - "sourceChannel": "inbox_route", - "dataOrigin": "external" -} +확인: +- schedule>=1.2.0 → 사용처 없으면 제거 +``` -도메인 DB 선택지: -00_Note_BOX, 01_Philosophie, 02_Language, 03_Engineering, -04_Industrial safety, 05_Programming, 07_General Book, -97_Production drawing, 99_Reference Data, 99_Technicalkorea +## 10단계: JP 번역 thinking 오염 수정 -sourceChannel 값: tksafety, devonagent, law_monitor, inbox_route, email, web_clip, manual -dataOrigin 값: work (자사 업무), external (외부 참고) +``` +법령 모니터링에서 일본어 번역 시 MLX Qwen3.5의 thinking 출력이 결과에 섞이는 문제를 수정해줘. -프롬프트를 최적화해서 ~/Documents/code/DEVONThink_my server/scripts/prompts/ 디렉토리에 저장해줘. +현재 문제: +로그에서 "Wait, I'll check if..." 같은 thinking 텍스트가 번역 결과에 포함됨. + +위치: scripts/law_monitor.py의 JP 번역 호출부 + +수정 방향: +1. 번역 프롬프트에 /nothink 모드 명시 강화 +2. llm_generate() 응답에서 thinking 패턴 필터링 추가 + - "Wait,", "Let me", "I'll check", "Hmm," 등으로 시작하는 줄 제거 + - "Final Output:" 이후 텍스트만 추출하는 로직 +3. 또는 pkm_utils.py의 llm_generate()에 strip_thinking=True 옵션 추가 + +테스트: JP RSS 항목 하나로 번역 테스트하여 깨끗한 한글 출력 확인 +``` + +## 11단계: API 서버 버그 수정 + +``` +PKM API 서버의 두 가지 버그를 수정해줘. + +위치: scripts/pkm_api_server.py + +버그 1: /devonthink/stats → 500 Internal Server Error +- AppleScript 쿼리가 실패하는 것으로 추정 +- 에러 로그 확인: logs/pkm-api.error.log +- AppleScript에서 DB 이름이나 property 접근 방식 수정 필요 + +버그 2: 한글 쿼리 파라미터 인코딩 에러 +- /devonthink/search?q=산업안전 → 400 Bad request syntax +- Flask의 request.args는 UTF-8을 지원하므로, 클라이언트 측 문제일 가능성 +- 서버 측에서도 방어 코드 추가: URL 디코딩 처리 + +추가 개선: +- /omnifocus/overdue 엔드포인트가 404였다가 나중에 추가됨 — 코드 확인 +- / (루트) 접근 시 404 → /health로 리다이렉트 또는 엔드포인트 목록 반환 +``` + +## 12단계: AppleScript 경로 변수화 ← GPU-5단계와 합산 + +``` +AppleScript 파일들의 하드코딩된 경로를 변수로 교체해줘. +GPU-5단계의 embed_to_qdrant.py 변경, OCR 단계 추가, sourceChannel 버그 픽스도 함께 진행. + +현재 하드코딩: + ~/Documents/code/DEVONThink_my server/scripts/prompts/classify_document.txt + ~/Documents/code/DEVONThink_my server/venv/bin/python3 + ~/Documents/code/DEVONThink_my server/scripts/embed_to_chroma.py + +수정 대상: + applescript/auto_classify.scpt + applescript/omnifocus_sync.scpt + +수정 방향: +- 스크립트 상단에 property baseDir 정의 +- 모든 경로를 baseDir 기반으로 조합 +- 또는 환경변수 PKM_HOME을 읽어서 사용 + +※ AppleScript에서 환경변수 읽기: + set pkmHome to do shell script "echo $PKM_HOME" + 또는 property로 직접 지정하는 게 안정적 ``` --- -## 3단계: DEVONthink Smart Rule + AppleScript 배포 +# Phase 3: API 서버 개선 + +## 13단계: gunicorn 전환 + launchd 등록 ``` -DEVONthink 4 Smart Rule용 AppleScript 모듈들을 만들어줘. -Mac mini에서 DEVONthink가 실행 중이야. +PKM API 서버를 Flask development server에서 gunicorn으로 전환하고, +launchd plist를 만들어 Mac mini 로그인 시 자동 시작되도록 해줘. -모듈 A: Ollama 연동 자동 분류 (~/Documents/code/DEVONThink_my server/applescript/auto_classify.scpt) -- DEVONthink Inbox DB에 새 문서가 들어오면 실행 -- Ollama qwen3.5 35B에 문서 텍스트 전송 -- 응답에서 tags, domain_db, sub_group, sourceChannel, dataOrigin 파싱 -- DEVONthink 태그 설정 + 커스텀 메타데이터(sourceChannel, dataOrigin, lastAIProcess) 설정 -- 해당 도메인 DB의 하위 그룹으로 문서 이동 -- GPU 서버(Tailscale IP)로 벡터 임베딩 비동기 전송 +1. gunicorn 설정: + gunicorn -w 2 -b 127.0.0.1:9900 pkm_api_server:app + ※ AppleScript 실행 때문에 GUI 세션에서 실행해야 함 + ※ launchd의 LimitLoadToSessionType = Aqua 또는 LoginwindowUI -모듈 B: OmniFocus 연동 (~/Documents/code/DEVONThink_my server/applescript/omnifocus_sync.scpt) -- Projects DB에 새 문서 추가 시 TODO 패턴 감지 -- OmniFocus에 작업 생성 (DEVONthink 링크 포함) -- 커스텀 메타데이터에 omnifocusTaskID 저장 +2. launchd plist 생성: + launchd/net.hyungi.pkm.api-server.plist + - 로그인 시 자동 시작 + - KeepAlive: true (크래시 시 재시작) + - WorkingDirectory: ~/Documents/code/DEVONThink_my server/ + - StandardOutPath/StandardErrorPath: logs/pkm-api.log, logs/pkm-api.error.log -프롬프트 파일 위치: ~/Documents/code/DEVONThink_my server/scripts/prompts/ -인증 정보: ~/.config/pkm/credentials.env -GPU 서버 Tailscale IP는 별도 확인 필요 (나중에 추가) +3. deploy.md에 API 서버 관련 내용 추가 +``` + +## 14단계: API 엔드포인트 추가 + +``` +PKM API 서버에 모니터링/상태 확인용 엔드포인트를 추가해줘. + +추가할 엔드포인트: +1. GET /law-monitor/status + - data/law_last_check.json 읽어서 마지막 확인 시간 반환 + - logs/law_monitor.log 최근 에러 건수 + +2. GET /digest/latest + - DEVONthink 00_Note_BOX/Daily_Digest/에서 최신 다이제스트 조회 + - 또는 data/ 아래에 최근 다이제스트 캐시 + +3. GET / → 전체 엔드포인트 목록 반환 (현재 404) + +4. GET /health 확장 + - MLX 서버 상태 (localhost:8800 연결 가능 여부) + - DEVONthink 실행 상태 + - 각 launchd job 상태 ``` --- -## 4단계: 법령 모니터링 스크립트 +# Phase 4: 테스트 + +## 15단계: 모듈별 테스트 실행 ``` -한국 법령 변경 모니터링 스크립트를 만들어줘. +Mac mini에서 각 모듈의 동작을 확인해줘. -스크립트: ~/Documents/code/DEVONThink_my server/scripts/law_monitor.py -인증: ~/.config/pkm/credentials.env의 LAW_OC 값 사용 -API: open.law.go.kr OpenAPI +1. AI 분류 테스트 (tests/test_classify.py) + cd ~/Documents/code/DEVONThink_my\ server/ + source venv/bin/activate + python tests/test_classify.py + → 5종 문서 분류 정확도 확인 -기능: -1. 산업안전보건법, 중대재해처벌법, 관련 시행령/시행규칙/고시 변경 추적 -2. 변경 감지 시: - - 법령 본문(XML) 다운로드 → ~/Documents/code/DEVONThink_my server/data/laws/에 저장 - - DEVONthink 04_Industrial Safety/10_Legislation/ 하위에 자동 임포트 (AppleScript 호출) - - 커스텀 메타데이터: sourceChannel=law_monitor, dataOrigin=external - - 로그: ~/Documents/code/DEVONThink_my server/logs/law_monitor.log -3. launchd plist 생성: 매일 07:00 실행 - ~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.law-monitor.plist - → ~/Library/LaunchAgents/에 심볼릭 링크 +2. 법령 모니터링 (Phase 2 인프라 수정 후) + python scripts/law_monitor.py + → 한국 법령 API 정상 응답 확인 + → 외국 법령 수집 재확인 -※ 법령 API 승인 대기중이라 스크립트만 만들고 실제 테스트는 승인 후에 -※ 해외 법령(US OSHA, JP, EU)은 나중에 추가 예정 +3. MailPlus 이메일 수집 (Phase 2 연결 수정 후) + python scripts/mailplus_archive.py + → IMAP 접속 + 이메일 가져오기 확인 + +4. Daily Digest + python scripts/pkm_daily_digest.py + → 다이제스트 MD 파일 생성 확인 + +5. API 서버 (Phase 2 버그 수정 후) + python scripts/pkm_api_server.py & + curl http://localhost:9900/health + curl http://localhost:9900/devonthink/stats + curl http://localhost:9900/devonthink/inbox-count + curl "http://localhost:9900/devonthink/search?q=safety&limit=3" + curl http://localhost:9900/omnifocus/stats +``` + +## 16단계: E2E 통합 테스트 + +``` +PKM 시스템 End-to-End 테스트를 진행해줘. + +시나리오 1: Inbox → 자동분류 플로우 + 1. DEVONthink Inbox에 테스트 문서 추가 + 2. Smart Rule 트리거 → auto_classify.scpt 실행 확인 + 3. 태그, 커스텀 메타데이터(sourceChannel, dataOrigin, lastAIProcess) 확인 + 4. 올바른 도메인 DB + 하위 그룹으로 이동 확인 + +시나리오 2: 법령 → 다이제스트 플로우 + 1. law_monitor.py 수동 실행 + 2. data/laws/에 파일 생성 + DEVONthink 04_Industrial Safety 임포트 확인 + 3. pkm_daily_digest.py 실행 → 법령 변경 건 포함 확인 + +시나리오 3: OmniFocus 연동 + 1. Projects DB에 TODO 패턴 문서 추가 + 2. omnifocus_sync.scpt 트리거 확인 + 3. OmniFocus Inbox에 작업 생성 + DEVONthink 링크 확인 + 4. 커스텀 메타데이터 omnifocusTaskID 확인 + +시나리오 4: launchd 스케줄 확인 + launchctl list | grep pkm + → 3개(+API 서버) 등록 확인 + +각 항목 pass/fail 리포트 → docs/test-report.md ``` --- -## 5단계: MailPlus → DEVONthink 이메일 수집 +# Phase 5: 운영 안정화 (나중에) + +## 17단계: 로그 로테이션 + 알림 ``` -MailPlus 이메일을 DEVONthink Archive DB로 자동 수집하는 스크립트를 만들어줘. +운영 안정성을 위한 설정을 추가해줘. -스크립트: ~/Documents/code/DEVONThink_my server/scripts/mailplus_archive.py -인증: ~/.config/pkm/credentials.env +1. Python 로그 로테이션 + - pkm_utils.py의 setup_logger()에 RotatingFileHandler 적용 + - maxBytes=10MB, backupCount=5 -접속 정보: -- IMAP 서버: mailplus.hyungi.net:993 (SSL) -- 계정: hyungi +2. Synology Chat 알림 (CHAT_WEBHOOK_URL 설정 후) + - 법령 변경 감지 시 알림 + - 에러 발생 시 알림 + - Daily Digest 요약 알림 -기능: -1. IMAP으로 MailPlus 접속 -2. 마지막 동기화 이후 새 메일 가져오기 -3. DEVONthink Archive DB에 임포트 (AppleScript 호출) -4. 커스텀 메타데이터: sourceChannel=email -5. 안전 관련 키워드 감지 시 dataOrigin 자동 판별 - -launchd: 매일 07:00 + 18:00 실행 -~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.mailplus.plist +3. 에러 모니터링 + - pkm_daily_digest.py에 이미 에러 카운트 로직 있음 + - 임계값 초과 시 Chat 알림 추가 ``` ---- - -## 6단계: Daily Digest 시스템 +## 18단계: 문서 보완 ``` -PKM 일일 다이제스트를 자동 생성하는 스크립트를 만들어줘. +프로젝트 문서를 보완해줘. -스크립트: ~/Documents/code/DEVONThink_my server/scripts/pkm_daily_digest.py -인증: ~/.config/pkm/credentials.env - -기능: -1. DEVONthink에서 오늘 추가/수정된 문서 집계 (AppleScript로 쿼리) - - DB별 신규 건수 - - sourceChannel별 구분 -2. law_monitor 로그에서 법령 변경 건 파싱 -3. OmniFocus 오늘 완료/추가/기한초과 집계 (AppleScript) -4. 상위 뉴스 3건 요약 (Ollama 35B 호출) -5. MD 파일 생성 → DEVONthink 00_Note_BOX/Daily_Digest/에 저장 - 파일명: YYYY-MM-DD_digest.md -6. OmniFocus 액션 자동 생성 (법령변경, overdue, Inbox 미처리 등) -7. 90일 지난 다이제스트 → 90_Archive 이동 (Smart Rule 대체) - -launchd: 매일 20:00 실행 -~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.daily-digest.plist - -※ Synology Chat 웹훅 알림은 나중에 추가 (CHAT_WEBHOOK_URL 설정 후) -``` - ---- - -## 7단계: DEVONagent 검색 세트 (수동 가이드) - -``` -DEVONagent Pro에서 안전 분야 자동 검색 세트를 설정하는 가이드를 만들어줘. - -총 9개 검색 세트 (7 안전 + 2 기술): -1. 국내 산업안전 뉴스 (kosha, moel, safetynews 등) -2. 국내 중대재해 뉴스 -3. KOSHA 가이드/지침 -4. 국내 산업안전 학술/논문 -5. US OSHA / Safety+Health Magazine -6. JP 厚生労働省 / 安全衛生 -7. EU-OSHA -8. 기술 뉴스 (AI/서버/네트워크) -9. 프로그래밍 기술 동향 - -각 세트별로: -- 검색 키워드/연산자 -- 사이트 제한 -- 스케줄 (매일/주간) -- 수량 제한 (주간 합계 50~85건 수준) -- 결과 → DEVONthink Inbox로 전송 설정 방법 - -이건 DEVONagent GUI에서 수동 설정해야 하니까, -단계별 가이드 문서를 ~/Documents/code/DEVONThink_my server/docs/devonagent-setup.md로 만들어줘. -``` - ---- - -## 8단계: 전체 테스트 - -``` -PKM 시스템 전체 End-to-End 테스트를 진행해줘. - -테스트 항목: -1. Ollama 분류 테스트 — 5종 문서(법령, 뉴스, 논문, 메모, 이메일) 분류 정확도 -2. DEVONthink Smart Rule — Inbox에 테스트 문서 추가 → 자동 분류 확인 -3. sourceChannel/dataOrigin 메타데이터가 정상 설정되는지 -4. OmniFocus 연동 — TODO 패턴 문서 → 작업 자동 생성 -5. MailPlus IMAP 접속 테스트 -6. launchd 스케줄 등록 확인 (launchctl list | grep pkm) -7. Daily Digest 수동 실행 테스트 - -각 항목 pass/fail 리포트를 ~/Documents/code/DEVONThink_my server/docs/test-report.md로 저장해줘. +1. README.md — 아키텍처 다이어그램, 기능 목록, 시작 가이드 확장 +2. deploy.md — API 서버 배포, 트러블슈팅 섹션, macOS 요구사항 추가 +3. docs/troubleshooting.md — 자주 발생하는 문제와 해결 방법 ``` --- @@ -295,8 +502,29 @@ PKM 시스템 전체 End-to-End 테스트를 진행해줘. ``` Mac mini 접속: SSH (MacBook Pro → Mac mini) NAS 도메인: ds1525.hyungi.net (Tailscale: 100.101.79.37, 포트: 15001) -MailPlus: mailplus.hyungi.net:993 (IMAP SSL) +MailPlus: mailplus.hyungi.net:993 (IMAP SSL) ← 현재 연결 불가, 확인 필요 WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/ TKSafety: tksafety.technicalkorea.net (나중에 활성화) 내부 네트워크: Tailscale VPN 연결됨 +Gitea: https://git.hyungi.net/hyungi/devonthink_home.git +``` + +## 참고: Git 커밋 히스토리 + +``` +3506214 fix(law_monitor): US 타입 필터 제거 + JP RDF 네임스페이스 수정 +c8e30b5 fix: AppleScript POSIX path 변수 방식 + 단일 -e 실행으로 따옴표 문제 해결 +f13b998 fix: AppleScript 행별 -e 분할 실행 — 파일 방식 인코딩 문제 회피 +735c072 fix: AppleScript를 임시 파일로 실행 — osascript -e 이스케이프 문제 해결 +446963c fix(law_monitor): AppleScript f-string 제거 + EU 파일명 고유화 +0b950a4 fix(law_monitor): AppleScript 따옴표 이스케이프 수정 +6a44b10 fix(law_monitor): JP/EU RSS URL 수정 — news.rdf + rss.xml, RDF 네임스페이스 대응 +9dc0694 feat(law_monitor): 외국 법령 지원 추가 — US OSHA, JP 厚労省(MLX 번역), EU-OSHA +ec6074d fix(law_monitor): API 에러 응답 로깅 추가 — 인증 실패 시 조용히 넘어가던 문제 +aca4a02 fix: LLM thinking 허용 + 마지막 유효 JSON 추출 방식으로 변경 +49c39a1 fix: LLM thinking 출력 대응 — max_tokens 증가 + JSON 추출 강화 +948be16 fix: Qwen3.5 /nothink 모드 + json_mode 파라미터 추가 +a774771 fix: MLX 서버(localhost:8800) 대응 — Ollama API → OpenAI 호환 변경 +084d3a8 feat: 전체 PKM 스크립트 일괄 작성 — 분류/법령/메일/다이제스트/임베딩 +bec9579 chore: 프로젝트 구조 + 설계 문서 초기 커밋 ``` diff --git a/docs/deploy.md b/docs/deploy.md index beeac4e..c0e65c1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,5 +1,17 @@ # Mac mini 배포 가이드 +> 마지막 업데이트: 2026-03-29 +> 대상: Mac mini M4 Pro (macOS, Python 3.11+) + +## 요구사항 + +- macOS 14+ (Sonoma 이상) +- Python 3.11+ (Homebrew 설치 권장) +- DEVONthink 4 — 실행 중이어야 AppleScript 동작 +- OmniFocus 4 — 실행 중이어야 AppleScript 동작 +- MLX 서버 — Qwen3.5-35B-A3B, localhost:8800에서 실행 중 +- Tailscale — NAS 및 GPU 서버 접근용 + ## 1. 초기 설치 ```bash @@ -22,9 +34,44 @@ nano ~/.config/pkm/credentials.env chmod 600 ~/.config/pkm/credentials.env ``` -credentials.env.example을 참고하여 실제 값 입력. +credentials.env.example을 참고하여 실제 값 입력: -## 3. launchd 스케줄 등록 +``` +# 필수 +LAW_OC=<법령API키> +MAILPLUS_HOST=mailplus.hyungi.net +MAILPLUS_PORT=993 +MAILPLUS_USER=hyungi +MAILPLUS_PASS=<비밀번호> +NAS_DOMAIN=ds1525.hyungi.net +NAS_TAILSCALE_IP=100.101.79.37 +NAS_PORT=15001 + +# 선택 (향후) +CLAUDE_API_KEY=<키> +#CHAT_WEBHOOK_URL= +#GPU_SERVER_IP= +``` + +## 3. 한국 법령 API IP 등록 + +법령 API 호출 전에 Mac mini의 공인 IP를 등록해야 합니다. + +```bash +# Mac mini에서 공인 IP 확인 +curl -s ifconfig.me + +# open.law.go.kr → 로그인 → 마이페이지 → 인증키 관리 +# 위에서 확인한 IP를 서버 IP로 등록 +# ※ Tailscale IP(100.x.x.x)가 아니라 실제 공인 IP + +# 등록 후 테스트 +source venv/bin/activate +python scripts/law_monitor.py +# → "법령 API 에러" 없이 정상 동작 확인 +``` + +## 4. launchd 스케줄 등록 ```bash # 심볼릭 링크 생성 @@ -41,7 +88,38 @@ launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.daily-digest.plist launchctl list | grep pkm ``` -## 4. 수동 테스트 +## 5. PKM API 서버 실행 + +```bash +# 개발 모드 (수동 실행) +cd ~/Documents/code/DEVONThink_my\ server/ +source venv/bin/activate +python scripts/pkm_api_server.py + +# 프로덕션 모드 (gunicorn, Phase 3 이후) +# gunicorn -w 2 -b 127.0.0.1:9900 scripts.pkm_api_server:app + +# 동작 확인 +curl http://localhost:9900/health +curl http://localhost:9900/devonthink/inbox-count +``` + +API 서버는 GUI 세션에서 실행해야 합니다 (AppleScript가 DEVONthink/OmniFocus GUI에 접근). + +## 6. DEVONthink Smart Rule 설정 + +1. DEVONthink → Preferences → Smart Rules +2. 새 Rule: **"AI Auto Classify"** + - Event: On Import + - Database: Inbox + - Condition: Tags is empty + - Action: Execute Script → External → `applescript/auto_classify.scpt` +3. 새 Rule: **"OmniFocus Sync"** + - Event: On Import + - Database: Projects + - Action: Execute Script → External → `applescript/omnifocus_sync.scpt` + +## 7. 수동 테스트 ```bash cd ~/Documents/code/DEVONThink_my\ server/ @@ -51,31 +129,26 @@ source venv/bin/activate python3 scripts/law_monitor.py python3 scripts/mailplus_archive.py python3 scripts/pkm_daily_digest.py + +# AI 분류 테스트 +python3 tests/test_classify.py ``` -## 5. DEVONthink Smart Rule 설정 - -1. DEVONthink → Preferences → Smart Rules -2. 새 Rule: "AI Auto Classify" - - Event: On Import - - Database: Inbox - - Condition: Tags is empty - - Action: Execute Script → External → `applescript/auto_classify.scpt` -3. 새 Rule: "OmniFocus Sync" - - Event: On Import - - Database: Projects - - Action: Execute Script → External → `applescript/omnifocus_sync.scpt` - -## 6. 업데이트 +## 8. 업데이트 ```bash cd ~/Documents/code/DEVONThink_my\ server/ git pull source venv/bin/activate pip install -r requirements.txt + +# launchd 재로드 (plist가 변경된 경우만) +launchctl unload ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist +launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist +# (나머지도 동일) ``` -## 7. 로그 확인 +## 9. 로그 확인 ```bash # 스크립트 로그 @@ -83,14 +156,94 @@ tail -f logs/law_monitor.log tail -f logs/mailplus.log tail -f logs/digest.log -# launchd 로그 -tail -f logs/law_monitor_launchd.log +# API 서버 로그 +tail -f logs/pkm-api.log +tail -f logs/pkm-api.error.log + +# launchd 시스템 로그 +log show --predicate 'process == "python3"' --last 1h +``` + +## 10. 일일 운영 점검 + +```bash +# 1. launchd 작업 상태 +launchctl list | grep pkm + +# 2. 오늘의 로그 에러 확인 +grep -c ERROR logs/law_monitor.log +grep -c ERROR logs/mailplus.log + +# 3. 법령 마지막 확인 시간 +cat data/law_last_check.json | python3 -m json.tool + +# 4. DEVONthink Inbox 미처리 건수 (API 서버 실행 중이면) +curl -s http://localhost:9900/devonthink/inbox-count + +# 5. MLX 서버 상태 +curl -s http://localhost:8800/v1/models | python3 -m json.tool ``` ## 실행 스케줄 | 스크립트 | 시간 | 용도 | |---------|------|------| -| law_monitor.py | 매일 07:00 | 법령 변경 모니터링 | -| mailplus_archive.py | 매일 07:00, 18:00 | 이메일 수집 | -| pkm_daily_digest.py | 매일 20:00 | 일일 다이제스트 | +| law_monitor.py | 매일 07:00 | 법령 변경 모니터링 (한국+US/JP/EU) | +| mailplus_archive.py | 매일 07:00, 18:00 | MailPlus 이메일 수집 | +| pkm_daily_digest.py | 매일 20:00 | 일일 다이제스트 생성 | +| pkm_api_server.py | 상시 (수동/launchd) | REST API (포트 9900) | + +## 트러블슈팅 + +### 법령 API "사용자 정보 검증 실패" + +``` +원인: Mac mini 공인 IP가 open.law.go.kr에 등록되지 않음 +해결: +1. curl -s ifconfig.me 로 현재 공인 IP 확인 +2. open.law.go.kr → 마이페이지 → 인증키 관리 → IP 등록 +3. IP가 변경되면 다시 등록 필요 (고정 IP 아닌 경우) +``` + +### MailPlus IMAP Connection refused + +``` +확인 순서: +1. Synology DSM → MailPlus Server → 서비스 상태 확인 +2. IMAP 활성화: DSM → MailPlus Server → 메일 전송 → IMAP 탭 +3. 포트: 993(SSL) vs 143(STARTTLS) +4. 방화벽: Synology 방화벽에서 993 포트 확인 +5. Tailscale 직접: + python3 -c "import imaplib; m=imaplib.IMAP4_SSL('100.101.79.37', 993); print('OK')" +``` + +### AppleScript 실행 오류 + +``` +확인: +1. DEVONthink, OmniFocus가 GUI로 실행 중인지 확인 +2. 접근성 권한: 시스템 설정 → 개인정보 보호 → 접근성 → python/osascript 허용 +3. 수동 테스트: + osascript -e 'tell application "DEVONthink 3" to get name of databases' +``` + +### MLX 서버 응답 없음 + +``` +확인: +1. MLX 서버 프로세스 확인: ps aux | grep mlx +2. 포트 확인: lsof -i :8800 +3. 모델 로드 확인: curl http://localhost:8800/v1/models +4. 재시작 필요 시: (MLX 서버 시작 명령어 실행) +``` + +### Daily Digest에 데이터가 비어있음 + +``` +확인: +1. DEVONthink이 실행 중인지 확인 +2. OmniFocus가 실행 중인지 확인 +3. 로그 확인: tail -20 logs/digest.log +4. AppleScript 직접 테스트: + osascript -e 'tell application "DEVONthink 3" to get count of databases' +``` diff --git a/docs/dev-roadmap.md b/docs/dev-roadmap.md new file mode 100644 index 0000000..bb2e372 --- /dev/null +++ b/docs/dev-roadmap.md @@ -0,0 +1,399 @@ +# PKM 시스템 개발 로드맵 + +> 작성일: 2026-03-29 (GPU 서버 재구성 계획 통합: 2026-03-29) +> 현재 상태: Phase 1 코드 작성 완료(90%), 인프라 일부 미해결 → Phase 1.5(GPU 재구성) + Phase 2 진행 중 + +--- + +## 현재 완료된 것 + +| 단계 | 항목 | 상태 | 비고 | +|------|------|------|------| +| 1 | 프로젝트 구조 | ✅ 완료 | README, deploy.md, .gitignore | +| 2 | AI 분류 프롬프트 | ✅ 완료 | MLX(Qwen3.5) OpenAI 호환 API 전환 완료 | +| 3 | AppleScript | ✅ 완료 | auto_classify + omnifocus_sync | +| 4 | 법령 모니터링 | ⚠️ 부분 | 외국(US/JP/EU) OK, 한국 API 인증 실패 | +| 5 | MailPlus 수집 | ❌ 연결 실패 | IMAP Connection refused | +| 6 | Daily Digest | ⚠️ 미테스트 | 코드 완성, 실행 기록 없음 | +| 7 | DEVONagent 가이드 | ✅ 완료 | docs/devonagent-setup.md | +| 8 | 전체 테스트 | ❌ 미진행 | test_classify.py만 존재 | +| 추가 | PKM API 서버 | ⚠️ 부분 | 한글 인코딩, stats 500 에러 | + +--- + +## Phase 1.5: GPU 서버 재구성 (Phase 2와 병행) + +> 상세 계획: docs/gpu-restructure.md 참조 + +GPU 서버(RTX 4070Ti Super)의 역할을 LLM 추론에서 임베딩/OCR 특화로 전환한다. +Mac Mini와 중복되는 LLM 모델을 제거하고, Surya OCR + bge-m3를 배치한다. + +### 1.5-A. GPU 서버 정리 (SSH 작업) + +``` +작업 내용: +1. Ollama 모델 제거: qwen3.5:9b-q8_0, id-9b +2. 새 모델 설치: bge-m3 (임베딩), bge-reranker-v2-m3 (리랭킹) +3. no-think proxy(11435) 비활성화 +4. paperless-gpt 처리 방침 결정 +5. tk-ai-service 코드 수정 (Ollama API → OpenAI API 전환) + +검증: ollama list → bge-m3, bge-reranker만 존재 +검증: tk-ai-service /health → MLX(text), GPU(embed) 모두 connected +``` + +### 1.5-B. Docker + NFS + Komga 이전 + +``` +작업 내용: +1. GPU 서버에 Docker Engine 설치 +2. NAS NFS 마운트 설정 (192.168.1.227:/volume1/Comic → /mnt/comic, ro) +3. Komga Docker 컨테이너를 GPU 서버로 이전 (포트 25600 유지) +4. Mac Mini nginx upstream 변경 → GPU 서버 +5. Mac Mini Komga 제거 (Docker VM 메모리 1.23GB 회수) + +검증: curl https://komga.hyungi.net → GPU 서버 경유 접근 확인 +``` + +### 1.5-C. Surya OCR 설치 + +``` +작업 내용: +1. PyTorch CUDA 런타임 확인/설치 +2. Surya OCR FastAPI 서버 구성 (/opt/surya-ocr/, 포트 8400) +3. systemd 서비스 등록 + +검증: curl -F "file=@test.pdf" http://192.168.1.186:8400/ocr → OCR 텍스트 반환 +``` + +### 1.5-D. PKM 코드 갱신 (Phase 2와 겹치는 항목 포함) + +``` +작업 내용: +1. embed_to_chroma.py → embed_to_qdrant.py 리라이트 (Qdrant + bge-m3) +2. auto_classify.scpt: Step 0(OCR) 추가 + Step 4 Qdrant + sourceChannel 버그 픽스 +3. requirements.txt: chromadb→qdrant-client, flask/gunicorn 추가 ← Phase 2 1-1과 합산 +4. credentials.env: GPU_SERVER_IP=192.168.1.186 추가 +5. architecture.md 대규모 갱신 (ChromaDB 28건, nomic 12건, VL-7B 5건) + +검증: python3 scripts/embed_to_qdrant.py <테스트UUID> → Qdrant 벡터 저장 확인 +``` + +### 1.5-E. RAG 파이프라인 + OCR 연동 (후순위) + +``` +작업 내용: +1. pkm_api_server.py에 RAG 엔드포인트 추가 (/rag/query, /devonthink/embed) +2. DEVONthink Smart Rule에 OCR 전처리 단계 추가 +3. ocr_preprocess.py 신규 작성 + +검증: curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}' → 답변 반환 +``` + +--- + +## Phase 2: 인프라 수정 (Phase 1.5와 병행) + +Mac mini에서 직접 확인/수정이 필요한 항목들. +※ Phase 1.5(GPU 재구성)과 병행하여 진행. 겹치는 항목(requirements.txt, credentials.env, AppleScript)은 합산. + +### 1-1. requirements.txt 수정 ← Phase 1.5-D와 합산 진행 + +``` +현재 문제: +- flask 누락 (pkm_api_server.py에서 사용 중) +- schedule 패키지 미사용 (제거 고려) +- chromadb → qdrant-client 교체 (GPU 재구성에 따라) + +수정 내용: ++ flask>=3.0.0 ++ gunicorn>=21.2.0 (프로덕션 WSGI) ++ qdrant-client>=1.7.0 +- chromadb>=0.4.0 +- schedule>=1.2.0 (미사용 확인 후 제거) +``` + +### 1-2. 한국 법령 API 인증 해결 + +``` +현재 에러: +"사용자 정보 검증에 실패하였습니다. + OPEN API 호출 시 사용자 검증을 위하여 정확한 서버장비의 IP주소 및 도메인주소를 등록해 주세요." + +조치: +1. open.law.go.kr 접속 → 마이페이지 → 인증키 관리 +2. Mac mini의 외부 IP 확인: curl ifconfig.me +3. 해당 IP를 API 호출 서버 IP로 등록 +4. Tailscale IP(100.x.x.x)가 아니라 실제 공인 IP여야 함 +5. 등록 후 law_monitor.py 재실행하여 확인 +``` + +### 1-3. MailPlus IMAP 연결 수정 + +``` +현재 에러: [Errno 61] Connection refused (mailplus.hyungi.net:993) + +확인 순서: +1. Synology DSM → MailPlus Server → 서비스 상태 확인 +2. IMAP 활성화 여부: DSM → MailPlus Server → 메일 전송 → IMAP 탭 +3. 포트 확인: 993(SSL) vs 143(STARTTLS) +4. 방화벽: Synology 방화벽에서 993 포트 개방 확인 +5. DNS 확인: nslookup mailplus.hyungi.net → 올바른 IP? +6. Tailscale 경유 시: 100.101.79.37:993으로 직접 테스트 + python3 -c "import imaplib; m=imaplib.IMAP4_SSL('100.101.79.37', 993); print('OK')" +7. credentials.env에 MAILPLUS_HOST 값 확인 +``` + +### 1-4. launchd 등록 상태 확인 및 등록 + +```bash +# Mac mini에서 확인 +launchctl list | grep pkm + +# 미등록 시 심볼릭 링크 생성 +ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.law-monitor.plist ~/Library/LaunchAgents/ +ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.mailplus.plist ~/Library/LaunchAgents/ +ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.daily-digest.plist ~/Library/LaunchAgents/ + +# 로드 +launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist +launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.mailplus.plist +launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.daily-digest.plist +``` + +--- + +## Phase 2: 버그 픽스 (코드 수정) + +### 2-1. JP 번역 thinking 오염 문제 + +``` +현재: MLX Qwen3.5가 번역 시 "Wait, I'll check if..." 같은 thinking을 출력에 포함 +위치: scripts/law_monitor.py의 JP 번역 호출부 + +수정 방향: +- 번역 프롬프트에 /nothink 모드 명시 강화 +- llm_generate() 호출 시 thinking 출력 후처리 추가 +- "Wait,", "Let me", "I'll check" 등 패턴 필터링 +- 또는 번역 결과에서 첫 번째 유효 문장만 추출 +``` + +### 2-2. PKM API 서버 한글 인코딩 + +``` +현재: /devonthink/search?q=산업안전 → 400 Bad request syntax +위치: scripts/pkm_api_server.py + +수정: +- Flask 자체는 UTF-8 지원하므로, 클라이언트 측 URL 인코딩 문제일 가능성 +- curl 호출 시 --data-urlencode 사용 또는 퍼센트 인코딩 필요 +- 서버 측에서도 request.args.get('q', '') 기본 인코딩 확인 +``` + +### 2-3. /devonthink/stats 500 에러 수정 + +``` +현재: GET /devonthink/stats → 500 Internal Server Error +위치: scripts/pkm_api_server.py + +원인 추정: AppleScript 실행 시 DB 이름이나 경로 문제 +수정: 에러 로그 확인 후 AppleScript 쿼리 수정 +``` + +### 2-4. AppleScript 하드코딩 경로 개선 + +``` +현재: +- ~/Documents/code/DEVONThink_my server/scripts/prompts/ 하드코딩 +- venv 경로 하드코딩 + +수정: +- 스크립트 상단에 BASE_DIR 변수 정의 +- 또는 환경변수 PKM_HOME으로 통일 +``` + +### 2-5. requirements.txt 정리 + +``` +추가: flask>=3.0.0, gunicorn>=21.2.0 +유지: anthropic (향후 Tier 2용) +검토: schedule (미사용이면 제거) +``` + +--- + +## Phase 3: API 서버 개선 + +### 3-1. 프로덕션 서빙 + +``` +현재: Flask development server +변경: gunicorn + launchd로 안정 운영 + +launchd plist 추가: + net.hyungi.pkm.api-server.plist + → gunicorn -w 2 -b 127.0.0.1:9900 pkm_api_server:app +``` + +### 3-2. 엔드포인트 보완 + +``` +현재 엔드포인트: + GET /health + GET /devonthink/stats ← 500 에러 수정 필요 + GET /devonthink/search + GET /devonthink/inbox-count + GET /omnifocus/stats + GET /omnifocus/today + GET /omnifocus/overdue + +추가 고려: + GET /law-monitor/status ← 마지막 실행 결과, 다음 실행 시간 + GET /digest/latest ← 최근 다이제스트 조회 + POST /classify ← 수동 분류 요청 (테스트용) +``` + +### 3-3. 간단한 인증 추가 (선택) + +``` +localhost 전용이면 불필요하지만, Tailscale 내부에서 접근할 경우: +- Bearer token 방식 (credentials.env에 API_TOKEN 추가) +- 또는 IP 화이트리스트 (127.0.0.1 + Tailscale 대역) +``` + +--- + +## Phase 4: 테스트 & 검증 + +### 4-1. 개별 모듈 테스트 + +```bash +# Mac mini에서 실행 + +# 1. AI 분류 테스트 (5종 문서) +cd ~/Documents/code/DEVONThink_my\ server/ +source venv/bin/activate +python tests/test_classify.py + +# 2. 법령 모니터링 (한국 API 인증 후) +python scripts/law_monitor.py + +# 3. MailPlus (IMAP 수정 후) +python scripts/mailplus_archive.py + +# 4. Daily Digest +python scripts/pkm_daily_digest.py + +# 5. API 서버 +python scripts/pkm_api_server.py & +curl http://localhost:9900/health +curl http://localhost:9900/devonthink/stats +curl http://localhost:9900/devonthink/inbox-count +curl "http://localhost:9900/devonthink/search?q=safety&limit=3" +``` + +### 4-2. E2E 통합 테스트 + +``` +시나리오 1: Inbox → 자동분류 플로우 + 1. DEVONthink Inbox에 테스트 문서 추가 + 2. Smart Rule 트리거 → auto_classify.scpt 실행 확인 + 3. 태그, 메타데이터, DB 이동 확인 + +시나리오 2: 법령 → 다이제스트 플로우 + 1. law_monitor.py 수동 실행 + 2. data/laws/에 파일 생성 확인 + 3. DEVONthink 04_Industrial Safety 확인 + 4. pkm_daily_digest.py 실행 → 법령 변경 건 포함 확인 + +시나리오 3: OmniFocus 연동 + 1. Projects DB에 TODO 패턴 문서 추가 + 2. omnifocus_sync.scpt 트리거 확인 + 3. OmniFocus Inbox에 작업 생성 확인 +``` + +### 4-3. 테스트 리포트 작성 + +``` +→ docs/test-report.md +각 항목별 pass/fail + 스크린샷/로그 첨부 +``` + +--- + +## Phase 5: 운영 안정화 (선택) + +### 5-1. 모니터링 + +``` +- 로그 로테이션 (logrotate 또는 Python RotatingFileHandler) +- Synology Chat 웹훅 알림 연동 (CHAT_WEBHOOK_URL 설정 후) +- 에러 발생 시 즉시 알림 +``` + +### 5-2. 백업 + +``` +- Gitea 리포지토리 자동 백업 (이미 NAS에 있으므로 OK) +- credentials.env 백업 (Vaultwarden에 보관?) +- Qdrant 데이터 백업 (pkm_documents + tk_qc_issues 컬렉션) +``` + +### 5-3. 문서 보완 + +``` +- README.md 상세화 (아키텍처 다이어그램, 기능 목록) +- 트러블슈팅 가이드 추가 +- deploy.md에 API 서버 + 업데이트 절차 추가 +``` + +--- + +## 작업 순서 요약 + +``` +[GPU] Phase 1.5: GPU 서버 재구성 (Phase 2와 병행) + 1.5-A. GPU 서버 정리 (모델 교체, proxy 제거) ← SSH 작업 + 1.5-B. Docker + NFS + Komga 이전 ← SSH 작업 + 1.5-C. Surya OCR 설치 ← SSH 작업 + 1.5-D. PKM 코드 갱신 (Qdrant, 임베딩) ← 코드 수정 후 push + 1.5-E. RAG + OCR 연동 ← 후순위 + +[즉시] Phase 2: 인프라 수정 + 1-1. requirements.txt 수정 ← Phase 1.5-D와 합산 + 1-2. 한국 법령 API IP 등록 ← Mac mini에서 공인IP 확인 + 1-3. MailPlus IMAP 확인 ← Synology DSM 확인 + 1-4. launchd 등록 ← Mac mini에서 실행 + +[코드] Phase 3: 버그 픽스 + 2-1. JP 번역 thinking 필터링 ← 코드 수정 후 push + 2-2~3. API 서버 수정 ← 코드 수정 후 push + 2-4. AppleScript 경로 변수화 ← Phase 1.5-D와 합산 + 2-5. requirements.txt 정리 ← Phase 1.5-D와 합산 + +[개선] Phase 4: API 서버 개선 + 3-1. gunicorn 전환 + launchd plist ← 코드 작성 후 push + 3-2~3. 엔드포인트 추가 ← 필요시 + +[검증] Phase 5: 테스트 + 4-1~2. 모듈별 + E2E 테스트 ← Mac mini에서 실행 + 4-3. 테스트 리포트 ← 결과 기반 작성 + +[안정] Phase 6: 운영 안정화 ← 여유 있을 때 +``` + +--- + +## 예상 소요 시간 + +| Phase | 예상 시간 | 비고 | +|-------|-----------|------| +| Phase 1.5-A~C | 3~4시간 | GPU 서버 SSH 작업 (모델 교체, Docker, Surya) | +| Phase 1.5-D | 3~4시간 | PKM 코드 갱신 (Qdrant, architecture.md 대규모 수정) | +| Phase 1.5-E | 2~3시간 | RAG + OCR 연동 (후순위) | +| Phase 2 | 1~2시간 | 인프라 설정 확인 작업 | +| Phase 3 | 2~3시간 | 버그 픽스 코드 수정 | +| Phase 4 | 1~2시간 | gunicorn 전환 중심 | +| Phase 5 | 2~3시간 | Mac mini에서 테스트 실행 | +| Phase 6 | 필요시 | 운영하면서 점진적 | +| **합계** | **~18시간** | 4~5일 분량 | diff --git a/docs/gpu-restructure.md b/docs/gpu-restructure.md new file mode 100644 index 0000000..60a18d7 --- /dev/null +++ b/docs/gpu-restructure.md @@ -0,0 +1,460 @@ +# GPU 서버 재구성 + PKM 프로젝트 연계 계획 + +## Context + +GPU 서버(RTX 4070Ti Super)에서 Mac Mini와 중복되는 LLM 모델(qwen3.5:9b, id-9b)을 제거하고, +대신 Surya OCR + bge-m3 임베딩 서비스를 배치하여 역할을 명확히 분리한다. +추가로 Komga(만화 서버)를 Mac Mini에서 GPU 서버로 이전하여 Surya OCR과 로컬 연동 가능하게 한다. + +기존 PKM 프로젝트(`~/Documents/code/DEVONThink_my server/`)와 연계하여: +- ChromaDB → Qdrant 마이그레이션 +- nomic-embed-text → bge-m3 통일 +- Qwen2.5-VL-7B 비전 OCR → Surya OCR 전용 대체 +- architecture.md, embed 스크립트, AppleScript 등 관련 코드/문서 일괄 갱신 + +PKM 프로젝트는 현재 Phase 1 완료(90%), Phase 2(인프라 수정+버그 픽스) 착수 대기 상태. +이번 GPU 서버 재구성은 Phase 2와 병행하여 인프라 변경을 반영한다. + +--- + +## 현재 상태 요약 + +### GPU 서버 (192.168.1.186) +- Ryzen 7 7800X3D / 30GB RAM / RTX 4070Ti Super 16GB VRAM +- **서비스**: Ollama(11434) + no-think proxy(11435), Plex(32400) +- **Ollama 모델**: qwen3.5:9b-q8_0(10GB), id-9b(10GB) ← **제거 확정** +- CUDA 드라이버 580.x 설치됨, nvcc(CUDA toolkit) 미설치 +- Docker 미설치, Python 3.12 +- NAS SMB/NFS 마운트 미설정 + +### Mac Mini (192.168.1.122) +- M4 Pro / 64GB / MLX Qwen3.5-35B(8800), Ollama bge-m3(11434) +- **Docker**: Qdrant(6333), Komga(25600), NanoClaw(9801), tk-ai-service(30400) 등 +- **PKM 프로젝트**: `~/Documents/code/DEVONThink_my server/` + - `embed_to_chroma.py` → GPU 서버 nomic-embed-text + ChromaDB ← **Qdrant + bge-m3로 변경** + - `auto_classify.scpt` → MLX localhost:8800으로 분류, Step 4에서 embed_to_chroma.py 호출 + - `pkm_api_server.py` → Flask 9900번 포트 (stats 500 에러, 한글 인코딩 버그 있음) + - `architecture.md` → GPU Tier 3에 nomic-embed + VL-7B + reranker 계획 ← **갱신 필요** +- **DEVONthink 4**: 13개 DB, Smart Rule 3개 설계 완료 + +### 영향받는 외부 서비스 +- `tk-ai-service` docker-compose.yml: `OLLAMA_TEXT_MODEL=qwen3.5:9b-q8_0` → **변경 필요** +- `paperless-gpt`: `qwen3:8b` 참조 → docker-compose 존재하나 **현재 미실행** (docker ps에 없음), Phase 1에서 처리 방침 결정 필요 + +### NAS IP 참고 +- NAS LAN IP: `192.168.1.227` (nginx-ssl.conf upstream에서 확인됨) +- NAS Tailscale IP: `100.101.79.37` +- NFS 마운트는 **LAN 직결 (192.168.1.227)** 사용 (성능상 최적) + +--- + +## 변경 계획 + +### Phase 1: GPU 서버 정리 (선행) + +**1-1. Ollama 모델 제거** +```bash +ssh 192.168.1.186 +ollama rm qwen3.5:9b-q8_0 +ollama rm id-9b +``` + +**1-2. 새 모델 설치** +```bash +ollama pull bge-m3 # 임베딩 (1024차원, 한국어 우수) +ollama pull bge-reranker-v2-m3 # RAG 리랭킹 +``` +- 임베딩 모델: `nomic-embed-text`(768차원) 대신 `bge-m3`(1024차원)으로 통일 + - 이유: Mac Mini Ollama에서 이미 bge-m3 사용 중, 한국어 성능 우수, Qdrant tk_qc_issues 컬렉션도 1024차원 + +**1-3. Ollama no-think proxy(11435) 비활성화** +- LLM 모델 제거 후 think:false 주입이 불필요 +- `sudo systemctl disable --now ollama-proxy` + +**1-4. Ollama systemd 환경 조정** +```ini +# /etc/systemd/system/ollama.service [Service] 섹션에 추가 +Environment="OLLAMA_MAX_LOADED_MODELS=2" +Environment="OLLAMA_KEEP_ALIVE=30m" +``` + +**1-5. paperless-gpt 처리** +- 현재 미실행 상태 (docker ps에 없음) +- docker-compose.yml에 `qwen3:8b` 참조 → GPU 서버 모델 제거 시 사용 불가 +- 옵션: (a) MLX 35B로 전환 (b) 당분간 비활성 유지 (c) 폐기 +- Paperless-ngx 자체가 활발히 사용 중인지 확인 후 결정 + +**1-6. tk-ai-service 코드 + 설정 변경** (Mac Mini 유지, 코드 수정 필수) + +tk-ai-service는 Ollama 네이티브 API(`/api/chat`, `/api/embeddings`)를 사용 중. +MLX 서버는 OpenAI API(`/v1/chat/completions`)만 지원하므로 코드 수정 필요. + +**a) `ollama_client.py` → `llm_client.py` 리팩터링** +- `generate_text()`: `/api/chat` → `/v1/chat/completions` (OpenAI 형식) + - 요청: `{"model":"...","messages":[...],"stream":false}` + - 응답: `response.json()["choices"][0]["message"]["content"]` +- `generate_embedding()`: 변경 없음 (GPU Ollama `/api/embeddings` 그대로) +- `check_health()`: text URL `/api/tags` → `/v1/models` 또는 단순 GET 체크 +- 클래스명/파일명 변경 고려 (OllamaClient → LLMClient) + +**b) docker-compose.yml 환경변수 변경** +```yaml +# ~/docker/tk-ai-service/docker-compose.yml +- OLLAMA_BASE_URL=http://host.internal:8800 # Mac Mini MLX 서버 (OpenAI API) +- OLLAMA_TEXT_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit +- OLLAMA_EMBED_URL=http://192.168.1.186:11434 # GPU 서버 Ollama (임베딩) +- OLLAMA_EMBED_MODEL=bge-m3 # 변경 없음 +``` + +**c) config.py 주석 갱신** +```python +# Mac Mini MLX (텍스트 생성) — OpenAI 호환 API +OLLAMA_BASE_URL: str = "http://host.internal:8800" +# GPU 서버 Ollama (임베딩) +OLLAMA_EMBED_URL: str = "http://192.168.1.186:11434" +``` + +### Phase 1.5: GPU 서버 Docker + NFS + Komga 이전 + +**1.5-1. Docker 설치** (GPU 서버) +```bash +# Docker Engine (Ubuntu) +sudo apt-get update +sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin +sudo usermod -aG docker hyungi +# nvidia-container-toolkit (Surya OCR Docker化 시 필요, 당장은 불필요) +``` + +**1.5-2. NAS NFS 마운트 설정** + +Synology NAS 측: +- DSM → 제어판 → 파일 서비스 → NFS 활성화 (v4.1) +- 공유 폴더(Comic) → NFS 권한: `192.168.1.186` 단일 IP, 읽기 전용, root_squash + +GPU 서버 측: +```bash +sudo apt install nfs-common +sudo mkdir -p /mnt/comic +# /etc/fstab 추가: +192.168.1.227:/volume1/Comic /mnt/comic nfs4 ro,nosuid,noexec,nodev,soft,timeo=15 0 0 +sudo mount -a +``` + +**1.5-3. Komga Docker 이전** + +GPU 서버에 docker-compose.yml 생성: +```yaml +# /opt/komga/docker-compose.yml +services: + komga: + image: gotson/komga + container_name: komga + ports: + - "25600:25600" + volumes: + - /mnt/comic:/data/comics:ro # NFS 마운트 (읽기 전용) + - ./config:/config # Komga 설정/DB + environment: + - TZ=Asia/Seoul + restart: unless-stopped +``` + +**1.5-4. Mac Mini 측 변경** +- Mac Mini Komga 컨테이너 중지: `docker stop komga && docker rm komga` +- nginx upstream 변경: + ``` + # nginx-ssl.conf + upstream komga_backend { + server 192.168.1.186:25600; # GPU 서버로 변경 + } + ``` +- nginx 재시작: `docker restart home-service-proxy` +- Mac Mini Docker VM 메모리 **1.23GB 회수** + +**1.5-5. Komga 설정 마이그레이션** +- Mac Mini의 Komga config/DB를 GPU 서버로 복사 (라이브러리 메타데이터, 사용자 설정 유지) +- 경로: Mac Mini `~/docker/Komga/` → GPU `scp`로 전송 +- **주의**: Komga 내부 DB(H2)에 라이브러리 절대경로가 저장되어 있음 + - Mac Mini: `/data/comics` (Docker 내부 마운트 경로) + - GPU 서버: `/data/comics` (동일하게 Docker 마운트하면 경로 변경 불필요) + - Docker 내부 경로를 동일하게 맞추면 DB 마이그레이션 문제 없음 + - 만약 경로가 달라지면 Komga UI에서 라이브러리 경로 재설정 또는 전체 재스캔 필요 + +### Phase 2: Surya OCR 설치 + +**2-1. PyTorch CUDA 런타임 확인** (GPU 서버) +- Surya OCR은 PyTorch에 의존 → PyTorch 설치 시 CUDA 런타임이 번들됨 +- 별도 `nvidia-cuda-toolkit` 설치가 **불필요할 수 있음** (nvcc는 직접 CUDA 코드 컴파일 시만 필요) +- GPU 서버에서 확인: +```bash +# PyTorch CUDA 지원 확인 +python3 -c "import torch; print(torch.cuda.is_available())" +# 안 되면 CUDA 번들 포함 PyTorch 설치 +pip install torch --index-url https://download.pytorch.org/whl/cu124 +``` + +**2-2. Surya OCR 서비스 구성** +``` +/opt/surya-ocr/ + venv/ # Python venv (surya-ocr, fastapi, uvicorn, python-multipart) + server.py # FastAPI 래퍼 +``` + +서버 엔드포인트: +- `POST /ocr` — 파일 업로드 → OCR 텍스트 + 바운딩박스 반환 +- `POST /ocr/layout` — 레이아웃 분석 포함 +- `GET /health` — 상태 확인 + +**2-3. systemd 서비스 등록** +```ini +# /etc/systemd/system/surya-ocr.service +[Unit] +Description=Surya OCR Service +After=network-online.target + +[Service] +ExecStart=/opt/surya-ocr/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8400 +WorkingDirectory=/opt/surya-ocr +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**VRAM 예산 (변경 후)** + +| 컴포넌트 | VRAM | 비고 | +|----------|------|------| +| Plex HW 트랜스코드 | ~1-2GB | 활성 시 | +| Surya OCR | ~2-3GB | 활성 시 | +| bge-m3 임베딩 | ~1.5GB | 상시 | +| bge-reranker | ~1GB | 온디맨드 | +| **합계 (피크)** | **~7-8GB / 16GB** | 여유 충분 | + +### Phase 3: 벡터 DB 통합 (ChromaDB → Qdrant) + PKM 코드 갱신 + +**결정: Qdrant로 통일, ChromaDB 폐기** +- Qdrant가 이미 Mac Mini Docker에서 운영 중 (tk_qc_issues 컬렉션) +- ChromaDB는 embed_to_chroma.py에서만 계획/사용 +- 2개 벡터 DB 운영은 불필요한 복잡성 + +**3-1. Qdrant에 pkm_documents 컬렉션 생성** +``` +dimension: 1024 (bge-m3) +distance: cosine +payload 필드: uuid, title, database, tags, source_channel +``` + +**3-2. `scripts/embed_to_chroma.py` → `scripts/embed_to_qdrant.py` 리라이트** +- `chromadb` → `qdrant-client` 교체 +- GPU 서버 임베딩 모델: `nomic-embed-text` → `bge-m3` +- Ollama API: `/api/embed` 사용 (배치 지원, `{"model":"bge-m3","input":["텍스트"]}`) + - 현재 auto_classify.scpt에서 문서 단건 호출이므로 단일/배치 모두 가능 + - 향후 embed-batch 엔드포인트에서 배치 활용 가능 +- 텍스트 청킹 추가 (500토큰, 50토큰 오버랩) +- `pkm_utils.load_credentials()`에서 GPU_SERVER_IP 로드 +- **기존 `embed_to_chroma.py`는 `git rm`으로 삭제** (GPU_SERVER_IP 미설정으로 실행된 적 없음, 실 데이터 없음) + +**3-3. `applescript/auto_classify.scpt` Step 4 수정 + 버그 픽스** +- 현재: `do shell script "python3 ~/scripts/embed_to_chroma.py " & docUUID & " &"` +- 변경: `embed_to_qdrant.py` 호출로 교체 + baseDir 변수 사용 +- **버그 픽스**: 73행 `add custom meta data "inbox_route" for "sourceChannel"` 삭제 + - 70행에서 AI 분류 결과로 설정한 sourceChannel을 "inbox_route"로 덮어쓰는 버그 + +**3-4. requirements.txt 업데이트** (dev-roadmap 9단계와 합산, 단일 커밋) +``` +- chromadb>=0.4.0 +- schedule>=1.2.0 # 미사용 확인 후 제거 ++ qdrant-client>=1.7.0 ++ flask>=3.0.0 # dev-roadmap 9단계 ++ gunicorn>=21.2.0 # dev-roadmap 9단계 + anthropic>=0.40.0 # 유지 (향후 Tier 2용) +``` + +**3-5. credentials.env + credentials.env.example 업데이트** +``` +# credentials.env (Mac mini) +GPU_SERVER_IP=192.168.1.186 + +# credentials.env.example (git 추적) +GPU_SERVER_IP=192.168.1.xxx +``` + +**3-6. `docs/architecture.md` 대규모 갱신** (별도 커밋) +- **변경 규모**: ChromaDB 28건, nomic-embed 12건, VL-7B 5건 — 문맥별 수정 필요 (단순 치환 불가) +- 주요 변경 영역: + - AI 통합 아키텍처 다이어그램 (Tier 3 모델 목록: nomic→bge-m3, VL-7B→Surya OCR) + - VRAM 배분 다이어그램 (~11.3GB → ~7-8GB) + - 자동화 파이프라인 / Smart Rule 설계 (ChromaDB→Qdrant, embed 스크립트 경로) + - AI 결과물 저장 전략 표 (ChromaDB→Qdrant) + - 임베딩 이전 근거 테이블 (nomic→bge-m3 반영) + - 3-Tier AI 라우팅 전략 표 + - 코드 예시 내 경로/모델명 +- 이 작업은 **별도 시간을 잡아서** 전체 문서를 통독하며 진행 + +### Phase 4: DEVONthink OCR 연동 + +**4-1. `scripts/ocr_preprocess.py` 신규 작성** +- DEVONthink UUID → AppleScript로 파일 경로 추출 → Surya API(GPU:8400) 호출 → OCR 텍스트 반환 +- `pkm_utils.run_applescript_inline()` 재사용 +- 반환값: OCR 텍스트 (plain text) — DEVONthink 본문에 병합용 + +**4-2. `applescript/auto_classify.scpt` Smart Rule 수정** +- architecture.md의 Rule 1 설계에 따라 Step 0(OCR) 추가: + ``` + 현재: Step 1(MLX 분류) → Step 2(태그 파싱) → Step 3(DB 이동) → Step 4(임베딩) → Step 5(메타) + 변경: Step 0(OCR 감지+처리) → Step 1(MLX 분류) → ... → Step 4(Qdrant 임베딩) + ``` +- OCR 대상 감지 조건 (단순 텍스트 길이 < 50 대신 정교한 판별): + - `type of theRecord` = PDF **AND** `plain text of theRecord` = "" (텍스트 레이어 없음) + - 또는 `type of theRecord` ∈ {JPEG, PNG, TIFF} (이미지 파일) + - DEVONthink 자체가 PDF 텍스트 레이어를 읽으므로, OCR이 필요한 건 **텍스트가 완전히 비어있는 경우**뿐 +- Surya OCR 호출 → `set plain text of theRecord to ocrText` 로 본문 병합 +- 기존 Qwen2.5-VL-7B 비전 OCR 계획 → Surya 전용 OCR로 대체 (정확도 + ABBYY 대체) + +**4-3. `docs/architecture.md` Rule 1 갱신** +- "이미지/스캔 문서 → GPU 서버 VL-7B로 OCR" → "Surya OCR(:8400)으로 OCR" + +### Phase 5: RAG 파이프라인 (PKM API 확장) + +**5-1. pkm_api_server.py에 RAG 엔드포인트 추가** +- 현재 docstring(7행): "범위: DEVONthink + OmniFocus 전용. 이 이상 확장하지 않을 것." +- RAG는 DEVONthink 문서 검색 기반이므로 동일 범위의 확장으로 간주 +- docstring을 "범위: DEVONthink + OmniFocus + RAG 검색" 으로 갱신 +``` +POST /rag/query # 질문 → 임베딩 → Qdrant 검색 → 리랭킹 → LLM 답변 +POST /devonthink/embed # 단일 문서 임베딩 트리거 +POST /devonthink/embed-batch # 배치 임베딩 +``` + +**5-2. RAG 쿼리 플로우** +``` +질문 텍스트 + → GPU 서버 bge-m3로 쿼리 임베딩 (192.168.1.186:11434) + → Mac Mini Qdrant에서 유사도 검색 (localhost:6333, top-20) + → GPU 서버 bge-reranker로 리랭킹 (top-5) + → Mac Mini MLX Qwen3.5-35B로 답변 생성 (localhost:8800) + → DEVONthink 링크(x-devonthink-item://UUID) 포함 응답 +``` + +### Phase 6: NanoClaw + Komga 연동 (후순위, 별도 계획) + +Phase 5 완료 후 별도 문서로 상세 계획 수립. 현재는 방향만 기록: + +- **NanoClaw RAG**: PKM API `/rag/query` 엔드포인트 호출 → 시놀로지 Chat에서 "@이드 [질문]" → 문서 기반 답변 +- **Komga OCR**: Komga REST API → Surya OCR → Qdrant `komga_manga` 컬렉션 +- dev-roadmap.md에는 "향후 계획" 수준으로만 언급 + +--- + +## 변경 후 아키텍처 + +``` +┌─ Mac Mini M4 Pro ─────────────────────┐ ┌─ GPU 서버 (4070Ti) ─────────┐ +│ │ │ │ +│ MLX Qwen3.5-35B (:8800) — LLM 추론 │ │ Ollama (:11434) │ +│ MLX Proxy (:8801) — Synology 연동 │◄───►│ ├─ bge-m3 (임베딩) │ +│ Ollama (:11434) — bge-m3 로컬 폴백 │ │ └─ bge-reranker (리랭킹) │ +│ Qdrant (:6333) — 벡터 검색 │ │ │ +│ PKM API (:9900) — RAG 오케스트레이션 │ │ Surya OCR (:8400) │ +│ NanoClaw (:9801) — AI 어시스턴트 │ │ Plex (:32400) — HW 트랜스코드│ +│ DEVONthink — 문서 허브 │ │ Komga (:25600) — 만화 서버 │ +│ nginx proxy (:443) — 리버스 프록시 │ │ └─ NFS → NAS /Comic (ro) │ +│ │ │ │ +└────────────────────────────────────────┘ │ [제거됨] │ + ▲ │ ├─ qwen3.5:9b-q8_0 │ + │ ┌─ NAS ──────┐ │ ├─ id-9b │ + └──────────────│ 문서/미디어 │────────│ └─ no-think proxy (:11435)│ + └────────────┘ └──────────────────────────────┘ +``` + +## 수정 대상 파일 목록 + +### GPU 서버 +| 파일 | 변경 | +|------|------| +| Ollama 모델 | `rm qwen3.5:9b-q8_0`, `rm id-9b` / `pull bge-m3`, `pull bge-reranker-v2-m3` | +| `/etc/systemd/system/ollama.service` | 환경변수 추가 (MAX_LOADED_MODELS, KEEP_ALIVE) | +| `/etc/systemd/system/ollama-proxy.service` | **비활성화** (disable --now) | +| `/opt/surya-ocr/server.py` | **신규** — FastAPI OCR 서버 | +| `/etc/systemd/system/surya-ocr.service` | **신규** — systemd 유닛 | +| Docker Engine | **신규 설치** | +| `/etc/fstab` | NFS 마운트 추가 (NAS Comic → /mnt/comic, ro) | +| `/opt/komga/docker-compose.yml` | **신규** — Komga 컨테이너 | + +### PKM 프로젝트 (`~/Documents/code/DEVONThink_my server/`) +| 파일 | 변경 | +|------|------| +| `scripts/embed_to_chroma.py` | → `scripts/embed_to_qdrant.py` 리라이트 (chromadb→qdrant-client, nomic→bge-m3) | +| `scripts/ocr_preprocess.py` | **신규** — Surya OCR 호출 헬퍼 | +| `scripts/pkm_api_server.py` | RAG 엔드포인트 추가 (/rag/query, /devonthink/embed) | +| `scripts/pkm_utils.py` | 변경 없음 (`load_credentials()`에 이미 GPU_SERVER_IP 지원, 74행) | +| `applescript/auto_classify.scpt` | Step 0(OCR 감지) 추가 + Step 4 embed_to_qdrant.py로 변경 | +| `requirements.txt` | `chromadb` → `qdrant-client`, `flask` 추가 (dev-roadmap 9단계 합산) | +| `docs/architecture.md` | Tier 3 모델, VRAM 다이어그램, ChromaDB→Qdrant, Smart Rule 전체 갱신 | +| `docs/dev-roadmap.md` | GPU 서버 재구성 Phase 반영 | +| `docs/claude-code-commands.md` | GPU 서버 관련 단계 추가 | +| `credentials.env` (Mac mini) | `GPU_SERVER_IP=192.168.1.186` 추가 | +| `credentials.env.example` | GPU_SERVER_IP 항목 추가 | + +### Mac Mini 기타 +| 파일 | 변경 | +|------|------| +| `~/docker/tk-ai-service/docker-compose.yml` | BASE_URL → MLX :8800, EMBED_URL → GPU :11434 | +| `~/docker/tk-factory-services/ai-service/services/ollama_client.py` | Ollama API → OpenAI API 전환 (generate_text, check_health) | +| `~/docker/tk-factory-services/ai-service/config.py` | 주석 갱신 | +| `~/docker/home-service-proxy/nginx-ssl.conf` | `komga_backend` upstream → `192.168.1.186:25600` | +| Mac Mini Komga 컨테이너 | **중지 및 제거** (1.23GB Docker VM 메모리 회수) | + +## 검증 방법 + +1. **Phase 1.5 검증**: + - `ssh GPU "docker ps"` → komga 컨테이너 실행 중 + - `ssh GPU "ls /mnt/comic"` → NAS 만화 파일 목록 확인 + - `curl http://192.168.1.186:25600` → Komga 웹 UI 접근 + - `curl https://komga.hyungi.net` → nginx 프록시 경유 접근 확인 + - Mac Mini: `docker ps | grep komga` → 없음 (제거 완료) +2. **Phase 1 검증**: + - `ssh GPU "ollama list"` → bge-m3, bge-reranker만 존재 + - `ssh GPU "systemctl status ollama-proxy"` → inactive + - `ssh GPU "curl localhost:11434/api/embed -d '{\"model\":\"bge-m3\",\"input\":[\"test\"]}'` → 1024차원 벡터 반환 + - Qdrant 차원 확인: `curl localhost:6333/collections/tk_qc_issues` → vector size=1024 확인 (bge-m3와 일치) + - tk-ai-service 코드 수정 후: `docker compose build && docker compose up -d` + - `curl http://localhost:30400/health` → ollama_text(MLX), ollama_embed(GPU) 모두 connected + - `curl -X POST http://localhost:30400/api/chat -d '{"message":"테스트"}'` → MLX 35B 응답 확인 +2. **Phase 2 검증**: `curl -F "file=@test.pdf" http://192.168.1.186:8400/ocr` → OCR 텍스트 반환 +3. **Phase 3 검증**: + - `curl http://localhost:6333/collections/pkm_documents` → 컬렉션 존재 + - `python3 scripts/embed_to_qdrant.py <테스트UUID>` → Qdrant에 벡터 저장 확인 + - `git diff` → embed_to_chroma.py 삭제, embed_to_qdrant.py 생성 확인 +4. **Phase 4 검증**: DEVONthink Inbox에 스캔 PDF(텍스트 없는) 추가 → Smart Rule → OCR 텍스트 병합 → 분류 완료 +5. **Phase 5 검증**: `curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}'` → 관련 문서 + 답변 반환 +6. **Phase 6**: 후순위, 별도 계획 시 검증 방법 수립 + +## 실행 순서 + +``` +Phase 1 (GPU 정리 + 모델 교체) ──┐ +Phase 1.5(Docker + NFS + Komga) ──┼── GPU 서버 작업 (SSH) +Phase 2 (Surya OCR 설치) ────────┘ + +Phase 3 (Qdrant 통합 + PKM 코드) ──┐── PKM 프로젝트 코드 수정 +Phase 4 (DEVONthink OCR 연동) ─────┘ → git commit & push + → Mac mini에서 git pull +Phase 5 (RAG 파이프라인) ──────── PKM API 확장 +Phase 6 (NanoClaw/Komga OCR) ─── 후순위 +``` + +Phase 1~2는 GPU 서버 SSH 작업, Phase 3~4는 PKM 프로젝트 코드 수정. +Phase 1 완료 시점에 tk-ai-service docker-compose도 함께 변경. +각 Phase는 독립적으로 검증 가능. + +**PKM dev-roadmap과의 관계:** +- 이 계획의 Phase 3~4 = dev-roadmap Phase 2의 일부 (인프라 수정) +- requirements.txt, AppleScript 경로, credentials.env 변경이 겹침 → 합쳐서 진행 + +**문서 통합 전략:** +- `docs/dev-roadmap.md`: GPU 서버 재구성 Phase를 기존 Phase 2 앞에 "Phase 1.5: GPU 서버 재구성" 으로 삽입, Phase 2 항목에서 겹치는 변경(requirements.txt, credentials.env) 연결 +- `docs/claude-code-commands.md`: Phase 2 섹션에 GPU 서버 관련 실행 단계 추가 (SSH 명령, Surya 설치, Qdrant 컬렉션 생성 등) +- 이 계획서는 `docs/gpu-restructure.md`로 정식 문서화 (아키텍처 결정 근거 기록으로 보존) diff --git a/requirements.txt b/requirements.txt index de77c32..703488c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -chromadb>=0.4.0 +qdrant-client>=1.7.0 requests>=2.31.0 python-dotenv>=1.0.0 -schedule>=1.2.0 markdown>=3.5.0 anthropic>=0.40.0 +flask>=3.0.0 +gunicorn>=21.2.0 diff --git a/scripts/embed_to_qdrant.py b/scripts/embed_to_qdrant.py new file mode 100644 index 0000000..d06112e --- /dev/null +++ b/scripts/embed_to_qdrant.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +벡터 임베딩 스크립트 +- DEVONthink 문서 UUID로 텍스트 추출 +- GPU 서버(bge-m3)로 임베딩 생성 +- Qdrant에 저장 +""" + +import sys +import uuid as uuid_mod +import requests +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from pkm_utils import setup_logger, load_credentials, run_applescript_inline + +logger = setup_logger("embed") + +QDRANT_URL = "http://localhost:6333" +COLLECTION = "pkm_documents" + + +def get_document_text(doc_uuid: str) -> tuple[str, str]: + """DEVONthink에서 UUID로 문서 텍스트 + 제목 추출""" + script = f''' + tell application id "DNtp" + set theRecord to get record with uuid "{doc_uuid}" + set docText to plain text of theRecord + set docTitle to name of theRecord + return docTitle & "|||" & docText + end tell + ''' + result = run_applescript_inline(script) + parts = result.split("|||", 1) + title = parts[0] if len(parts) > 0 else "" + text = parts[1] if len(parts) > 1 else "" + return title, text + + +def get_embedding(text: str, gpu_server_ip: str) -> list[float] | None: + """GPU 서버의 bge-m3로 임베딩 생성""" + url = f"http://{gpu_server_ip}:11434/api/embed" + try: + resp = requests.post(url, json={ + "model": "bge-m3", + "input": [text[:8000]] + }, timeout=60) + resp.raise_for_status() + embeddings = resp.json().get("embeddings") + return embeddings[0] if embeddings else None + except Exception as e: + logger.error(f"임베딩 생성 실패: {e}") + return None + + +def store_in_qdrant(doc_uuid: str, title: str, text: str, embedding: list[float]): + """Qdrant에 저장""" + # UUID 문자열을 정수 ID로 변환 (Qdrant point ID) + point_id = uuid_mod.uuid5(uuid_mod.NAMESPACE_URL, doc_uuid).int >> 64 + + payload = { + "uuid": doc_uuid, + "title": title, + "text_preview": text[:500], + "source": "devonthink", + } + + resp = requests.put( + f"{QDRANT_URL}/collections/{COLLECTION}/points", + json={ + "points": [{ + "id": point_id, + "vector": embedding, + "payload": payload, + }] + }, + timeout=30, + ) + resp.raise_for_status() + logger.info(f"Qdrant 저장: {doc_uuid} ({title[:30]})") + + +def run(doc_uuid: str): + """단일 문서 임베딩 처리""" + logger.info(f"임베딩 처리 시작: {doc_uuid}") + + creds = load_credentials() + gpu_ip = creds.get("GPU_SERVER_IP") + if not gpu_ip: + logger.warning("GPU_SERVER_IP 미설정 — 임베딩 건너뜀") + return + + try: + title, text = get_document_text(doc_uuid) + if not text or len(text) < 10: + logger.warning(f"텍스트 부족 [{doc_uuid}]: {len(text)}자") + return + + embedding = get_embedding(text, gpu_ip) + if embedding: + store_in_qdrant(doc_uuid, title, text, embedding) + logger.info(f"임베딩 완료: {doc_uuid}") + else: + logger.error(f"임베딩 실패: {doc_uuid}") + + except Exception as e: + logger.error(f"임베딩 처리 에러 [{doc_uuid}]: {e}", exc_info=True) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("사용법: python3 embed_to_qdrant.py ") + sys.exit(1) + run(sys.argv[1]) diff --git a/scripts/pkm_api_server.py b/scripts/pkm_api_server.py new file mode 100644 index 0000000..95c7136 --- /dev/null +++ b/scripts/pkm_api_server.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +PKM Host API Server +DEVONthink + OmniFocus AppleScript 중계용 경량 HTTP 서버. +NanoClaw 컨테이너에서 호출. LaunchAgent(GUI 세션)로 실행 필수. + +범위: DEVONthink + OmniFocus 전용. 이 이상 확장하지 않을 것. +""" + +import json +import subprocess +import sys +from flask import Flask, request, jsonify + +app = Flask(__name__) + + +def run_applescript(script: str, timeout: int = 120) -> str: + result = subprocess.run( + ['osascript', '-e', script], + capture_output=True, text=True, timeout=timeout + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip()) + return result.stdout.strip() + + +# --- DEVONthink --- + +@app.route('/devonthink/stats') +def devonthink_stats(): + try: + script = ( + 'tell application id "DNtp"\n' + ' set today to current date\n' + ' set time of today to 0\n' + ' set stats to {}\n' + ' repeat with db in databases\n' + ' set dbName to name of db\n' + ' set addedCount to 0\n' + ' set modifiedCount to 0\n' + ' repeat with rec in children of root of db\n' + ' try\n' + ' if creation date of rec >= today then set addedCount to addedCount + 1\n' + ' if modification date of rec >= today then set modifiedCount to modifiedCount + 1\n' + ' end try\n' + ' end repeat\n' + ' if addedCount > 0 or modifiedCount > 0 then\n' + ' set end of stats to dbName & ":" & addedCount & ":" & modifiedCount\n' + ' end if\n' + ' end repeat\n' + ' set AppleScript\'s text item delimiters to "|"\n' + ' return stats as text\n' + 'end tell' + ) + result = run_applescript(script) + stats = {} + if result: + for item in result.split('|'): + parts = item.split(':') + if len(parts) == 3: + stats[parts[0]] = {'added': int(parts[1]), 'modified': int(parts[2])} + total_added = sum(s['added'] for s in stats.values()) + total_modified = sum(s['modified'] for s in stats.values()) + return jsonify(success=True, data={ + 'databases': stats, + 'total_added': total_added, + 'total_modified': total_modified + }) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/devonthink/search') +def devonthink_search(): + q = request.args.get('q', '') + limit = int(request.args.get('limit', '10')) + if not q: + return jsonify(success=False, error='q parameter required'), 400 + try: + script = ( + 'tell application id "DNtp"\n' + f' set results to search "{q}"\n' + ' set output to {}\n' + f' set maxCount to {limit}\n' + ' set i to 0\n' + ' repeat with rec in results\n' + ' if i >= maxCount then exit repeat\n' + ' set recName to name of rec\n' + ' set recDB to name of database of rec\n' + ' set recDate to modification date of rec as text\n' + ' set end of output to recName & "||" & recDB & "||" & recDate\n' + ' set i to i + 1\n' + ' end repeat\n' + ' set AppleScript\'s text item delimiters to linefeed\n' + ' return output as text\n' + 'end tell' + ) + result = run_applescript(script) + items = [] + if result: + for line in result.split('\n'): + parts = line.split('||') + if len(parts) == 3: + items.append({'name': parts[0], 'database': parts[1], 'modified': parts[2]}) + return jsonify(success=True, data=items, count=len(items)) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/devonthink/inbox-count') +def devonthink_inbox_count(): + try: + script = ( + 'tell application id "DNtp"\n' + ' set inboxDB to database "Inbox"\n' + ' return count of children of root of inboxDB\n' + 'end tell' + ) + count = int(run_applescript(script)) + return jsonify(success=True, data={'inbox_count': count}) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +# --- OmniFocus --- + +@app.route('/omnifocus/stats') +def omnifocus_stats(): + try: + script = ( + 'tell application "OmniFocus"\n' + ' tell default document\n' + ' set today to current date\n' + ' set time of today to 0\n' + ' set completedCount to count of (every flattened task whose completed is true and completion date >= today)\n' + ' set addedCount to count of (every flattened task whose creation date >= today)\n' + ' set overdueCount to count of (every flattened task whose completed is false and due date < today and due date is not missing value)\n' + ' return (completedCount as text) & "|" & (addedCount as text) & "|" & (overdueCount as text)\n' + ' end tell\n' + 'end tell' + ) + result = run_applescript(script) + parts = result.split('|') + return jsonify(success=True, data={ + 'completed': int(parts[0]) if len(parts) > 0 else 0, + 'added': int(parts[1]) if len(parts) > 1 else 0, + 'overdue': int(parts[2]) if len(parts) > 2 else 0 + }) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/omnifocus/overdue') +def omnifocus_overdue(): + try: + script = ( + 'tell application "OmniFocus"\n' + ' tell default document\n' + ' set today to current date\n' + ' set time of today to 0\n' + ' set overdueTasks to every flattened task whose completed is false and due date < today and due date is not missing value\n' + ' set output to {}\n' + ' repeat with t in overdueTasks\n' + ' set taskName to name of t\n' + ' set dueDate to due date of t as text\n' + ' set projName to ""\n' + ' try\n' + ' set projName to name of containing project of t\n' + ' end try\n' + ' set end of output to taskName & "||" & projName & "||" & dueDate\n' + ' end repeat\n' + ' set AppleScript\'s text item delimiters to linefeed\n' + ' return output as text\n' + ' end tell\n' + 'end tell' + ) + result = run_applescript(script) + tasks = [] + if result: + for line in result.split('\n'): + parts = line.split('||') + tasks.append({ + 'name': parts[0], + 'project': parts[1] if len(parts) > 1 else '', + 'due_date': parts[2] if len(parts) > 2 else '' + }) + return jsonify(success=True, data=tasks, count=len(tasks)) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/omnifocus/today') +def omnifocus_today(): + try: + script = ( + 'tell application "OmniFocus"\n' + ' tell default document\n' + ' set today to current date\n' + ' set time of today to 0\n' + ' set tomorrow to today + 1 * days\n' + ' set todayTasks to every flattened task whose completed is false and ((due date >= today and due date < tomorrow) or (defer date >= today and defer date < tomorrow))\n' + ' set output to {}\n' + ' repeat with t in todayTasks\n' + ' set taskName to name of t\n' + ' set projName to ""\n' + ' try\n' + ' set projName to name of containing project of t\n' + ' end try\n' + ' set end of output to taskName & "||" & projName\n' + ' end repeat\n' + ' set AppleScript\'s text item delimiters to linefeed\n' + ' return output as text\n' + ' end tell\n' + 'end tell' + ) + result = run_applescript(script) + tasks = [] + if result: + for line in result.split('\n'): + parts = line.split('||') + tasks.append({'name': parts[0], 'project': parts[1] if len(parts) > 1 else ''}) + return jsonify(success=True, data=tasks, count=len(tasks)) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/health') +def health(): + return jsonify(success=True, service='pkm-api', endpoints=[ + '/devonthink/stats', '/devonthink/search?q=', + '/devonthink/inbox-count', '/omnifocus/stats', '/omnifocus/overdue', '/omnifocus/today' + ]) + + +if __name__ == '__main__': + port = int(sys.argv[1]) if len(sys.argv) > 1 else 9900 + print(f'PKM API Server starting on port {port}') + app.run(host='127.0.0.1', port=port) From 5fc23e0dbd8642d8e18befad163c96b6e8069d98 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 13:31:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20DEVONthink=20OCR=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=E2=80=94=20Surya=20OCR=20=EC=A0=84=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20+=20Smart=20Rule=20Step=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ocr_preprocess.py: DEVONthink UUID → 파일 추출 → GPU Surya OCR(:8400) 호출 → 텍스트 반환 - auto_classify.scpt: Step 0 OCR 감지 추가 (텍스트 없는 PDF/이미지 → Surya OCR → 본문 병합) - 이미지/스캔 PDF 자동 감지: docType이 PDF/JPEG/PNG/TIFF이고 텍스트가 비어있는 경우 - OCR 실패 시 로그 기록 후 분류 진행 (graceful degradation) Co-Authored-By: Claude Opus 4.6 (1M context) --- applescript/auto_classify.scpt | 24 +++++++++-- scripts/ocr_preprocess.py | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 scripts/ocr_preprocess.py diff --git a/applescript/auto_classify.scpt b/applescript/auto_classify.scpt index 057a977..49c2820 100644 --- a/applescript/auto_classify.scpt +++ b/applescript/auto_classify.scpt @@ -1,21 +1,39 @@ -- DEVONthink 4 Smart Rule: AI 자동 분류 --- Inbox DB 새 문서 → Ollama 분류 → 태그 + 메타데이터 + 도메인 DB 이동 +-- Inbox DB 새 문서 → OCR 전처리 → MLX 분류 → 태그 + 메타데이터 + 도메인 DB 이동 → Qdrant 임베딩 -- Smart Rule 설정: Event = On Import, 조건 = Tags is empty on performSmartRule(theRecords) tell application id "DNtp" repeat with theRecord in theRecords try - -- 1. 문서 텍스트 추출 (최대 4000자) + -- 0. OCR 전처리: 텍스트 없는 PDF/이미지 → Surya OCR set docText to plain text of theRecord set docUUID to uuid of theRecord + set docType to type of theRecord as string + if docText is "" then + if docType is in {"PDF Document", "JPEG image", "PNG image", "TIFF image"} then + set ocrScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3" + set ocrPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/ocr_preprocess.py" + try + set ocrText to do shell script ocrScript & " " & quoted form of ocrPy & " " & quoted form of docUUID + if length of ocrText > 0 then + set plain text of theRecord to ocrText + set docText to ocrText + end if + on error ocrErr + do shell script "echo '[OCR ERROR] " & ocrErr & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/auto_classify.log" + end try + end if + end if + + -- 1. 문서 텍스트 추출 (최대 4000자) if length of docText > 4000 then set docText to text 1 thru 4000 of docText end if if length of docText < 10 then - -- 텍스트가 너무 짧으면 건너뜀 + -- OCR 후에도 텍스트가 부족하면 검토필요 태그 set tags of theRecord to {"@상태/검토필요"} continue repeat end if diff --git a/scripts/ocr_preprocess.py b/scripts/ocr_preprocess.py new file mode 100644 index 0000000..63cb4d4 --- /dev/null +++ b/scripts/ocr_preprocess.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +OCR 전처리 스크립트 +- DEVONthink 문서 UUID로 파일 경로 추출 +- GPU 서버 Surya OCR API 호출 +- OCR 텍스트 반환 (auto_classify.scpt에서 호출) +""" + +import sys +import requests +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from pkm_utils import setup_logger, load_credentials, run_applescript_inline + +logger = setup_logger("ocr") + + +def get_document_path(doc_uuid: str) -> str | None: + """DEVONthink에서 UUID로 문서 파일 경로 추출""" + script = f''' + tell application id "DNtp" + set theRecord to get record with uuid "{doc_uuid}" + return POSIX path of (path of theRecord as POSIX file) + end tell + ''' + try: + return run_applescript_inline(script) + except Exception as e: + logger.error(f"파일 경로 추출 실패 [{doc_uuid}]: {e}") + return None + + +def run_ocr(file_path: str, gpu_server_ip: str, langs: str = "ko,en,ja") -> str | None: + """GPU 서버 Surya OCR API 호출""" + url = f"http://{gpu_server_ip}:8400/ocr" + try: + with open(file_path, "rb") as f: + resp = requests.post( + url, + files={"file": (Path(file_path).name, f)}, + data={"langs": langs}, + timeout=300, + ) + resp.raise_for_status() + result = resp.json() + text = result.get("text", "") + pages = result.get("total_pages", 0) + logger.info(f"OCR 완료: {pages}페이지, {len(text)}자") + return text + except Exception as e: + logger.error(f"OCR 실패 [{file_path}]: {e}") + return None + + +def run(doc_uuid: str) -> str: + """단일 문서 OCR 처리 — 텍스트 반환""" + logger.info(f"OCR 처리 시작: {doc_uuid}") + + creds = load_credentials() + gpu_ip = creds.get("GPU_SERVER_IP") + if not gpu_ip: + logger.warning("GPU_SERVER_IP 미설정 — OCR 건너뜀") + return "" + + file_path = get_document_path(doc_uuid) + if not file_path: + return "" + + text = run_ocr(file_path, gpu_ip) + return text or "" + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("사용법: python3 ocr_preprocess.py ") + sys.exit(1) + result = run(sys.argv[1]) + print(result) From 5db2f4f6fa7cd60143879ebc16fb2c2c2ca86e00 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 13:32:49 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20RAG=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=E2=80=94=20pkm=5Fapi=5Fserver.py=EC=97=90?= =?UTF-8?q?=20=EA=B2=80=EC=83=89/=EC=9E=84=EB=B2=A0=EB=94=A9=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /rag/query: 질문 → GPU bge-m3 임베딩 → Qdrant 검색 → MLX 35B 답변 생성 - DEVONthink 링크(x-devonthink-item://UUID) 포함 응답 - POST /devonthink/embed: 단일 문서 UUID → Qdrant 임베딩 트리거 - POST /devonthink/embed-batch: 배치 문서 임베딩 - docstring 범위 갱신: DEVONthink + OmniFocus + RAG 검색 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/pkm_api_server.py | 161 +++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/scripts/pkm_api_server.py b/scripts/pkm_api_server.py index 95c7136..8a301f5 100644 --- a/scripts/pkm_api_server.py +++ b/scripts/pkm_api_server.py @@ -1,17 +1,22 @@ #!/usr/bin/env python3 """ PKM Host API Server -DEVONthink + OmniFocus AppleScript 중계용 경량 HTTP 서버. +DEVONthink + OmniFocus AppleScript 중계 + RAG 검색 경량 HTTP 서버. NanoClaw 컨테이너에서 호출. LaunchAgent(GUI 세션)로 실행 필수. -범위: DEVONthink + OmniFocus 전용. 이 이상 확장하지 않을 것. +범위: DEVONthink + OmniFocus + RAG 검색. """ import json +import os import subprocess import sys +from pathlib import Path from flask import Flask, request, jsonify +sys.path.insert(0, str(Path(__file__).parent)) +from pkm_utils import load_credentials + app = Flask(__name__) @@ -225,11 +230,161 @@ def omnifocus_today(): return jsonify(success=False, error=str(e)), 500 +# --- RAG --- + +def _get_gpu_ip(): + creds = load_credentials() + return creds.get("GPU_SERVER_IP") + + +def _embed_text(text: str, gpu_ip: str) -> list[float] | None: + """GPU 서버 bge-m3로 텍스트 임베딩""" + import requests as req + try: + resp = req.post(f"http://{gpu_ip}:11434/api/embed", + json={"model": "bge-m3", "input": [text[:8000]]}, timeout=60) + resp.raise_for_status() + return resp.json().get("embeddings", [[]])[0] + except Exception: + return None + + +def _search_qdrant(vector: list[float], limit: int = 20) -> list[dict]: + """Qdrant에서 유사도 검색""" + import requests as req + resp = req.post("http://localhost:6333/collections/pkm_documents/points/search", + json={"vector": vector, "limit": limit, "with_payload": True}, timeout=10) + resp.raise_for_status() + return resp.json().get("result", []) + + +def _llm_generate(prompt: str) -> str: + """Mac Mini MLX로 답변 생성""" + import requests as req + resp = req.post("http://localhost:8800/v1/chat/completions", json={ + "model": "mlx-community/Qwen3.5-35B-A3B-4bit", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.3, + "max_tokens": 2048, + }, timeout=120) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + +@app.route('/rag/query', methods=['POST']) +def rag_query(): + """RAG 질의: 임베딩 → Qdrant 검색 → LLM 답변 생성""" + data = request.get_json(silent=True) or {} + q = data.get('q', '') + limit = data.get('limit', 10) + if not q: + return jsonify(success=False, error='q parameter required'), 400 + + gpu_ip = _get_gpu_ip() + if not gpu_ip: + return jsonify(success=False, error='GPU_SERVER_IP not configured'), 500 + + try: + # 1. 쿼리 임베딩 + query_vec = _embed_text(q, gpu_ip) + if not query_vec: + return jsonify(success=False, error='embedding failed'), 500 + + # 2. Qdrant 검색 + results = _search_qdrant(query_vec, limit=limit) + if not results: + return jsonify(success=True, answer="관련 문서를 찾지 못했습니다.", sources=[]) + + # 3. 컨텍스트 조립 + sources = [] + context_parts = [] + for r in results[:5]: + payload = r.get("payload", {}) + title = payload.get("title", "") + preview = payload.get("text_preview", "") + doc_uuid = payload.get("uuid", "") + sources.append({ + "title": title, + "uuid": doc_uuid, + "score": round(r.get("score", 0), 3), + "link": f"x-devonthink-item://{doc_uuid}" if doc_uuid else None, + }) + context_parts.append(f"[{title}]\n{preview}") + + context = "\n\n---\n\n".join(context_parts) + + # 4. LLM 답변 생성 + prompt = f"""다음 문서들을 참고하여 질문에 답변해주세요. + +## 참고 문서 +{context} + +## 질문 +{q} + +답변은 한국어로, 참고한 문서 제목을 언급해주세요.""" + + answer = _llm_generate(prompt) + + return jsonify(success=True, answer=answer, sources=sources, query=q) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/devonthink/embed', methods=['POST']) +def devonthink_embed(): + """단일 문서 임베딩 트리거""" + data = request.get_json(silent=True) or {} + doc_uuid = data.get('uuid', '') + if not doc_uuid: + return jsonify(success=False, error='uuid parameter required'), 400 + + try: + venv_python = str(Path(__file__).parent.parent / "venv" / "bin" / "python3") + embed_script = str(Path(__file__).parent / "embed_to_qdrant.py") + result = subprocess.run( + [venv_python, embed_script, doc_uuid], + capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + return jsonify(success=False, error=result.stderr.strip()), 500 + return jsonify(success=True, uuid=doc_uuid) + except Exception as e: + return jsonify(success=False, error=str(e)), 500 + + +@app.route('/devonthink/embed-batch', methods=['POST']) +def devonthink_embed_batch(): + """배치 문서 임베딩 트리거""" + data = request.get_json(silent=True) or {} + uuids = data.get('uuids', []) + if not uuids: + return jsonify(success=False, error='uuids array required'), 400 + + results = [] + venv_python = str(Path(__file__).parent.parent / "venv" / "bin" / "python3") + embed_script = str(Path(__file__).parent / "embed_to_qdrant.py") + for doc_uuid in uuids: + try: + result = subprocess.run( + [venv_python, embed_script, doc_uuid], + capture_output=True, text=True, timeout=120 + ) + results.append({"uuid": doc_uuid, "success": result.returncode == 0}) + except Exception as e: + results.append({"uuid": doc_uuid, "success": False, "error": str(e)}) + + succeeded = sum(1 for r in results if r["success"]) + return jsonify(success=True, total=len(uuids), succeeded=succeeded, results=results) + + @app.route('/health') def health(): return jsonify(success=True, service='pkm-api', endpoints=[ '/devonthink/stats', '/devonthink/search?q=', - '/devonthink/inbox-count', '/omnifocus/stats', '/omnifocus/overdue', '/omnifocus/today' + '/devonthink/inbox-count', '/devonthink/embed', '/devonthink/embed-batch', + '/omnifocus/stats', '/omnifocus/overdue', '/omnifocus/today', + '/rag/query', ]) From f21f950c04b33b3c7d4b8021330fea7054be8c54 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 13:45:16 +0900 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20architecture.md=20=EB=8C=80?= =?UTF-8?q?=EA=B7=9C=EB=AA=A8=20=EA=B0=B1=EC=8B=A0=20=E2=80=94=20GPU=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChromaDB → Qdrant 전체 치환 (28건) - nomic-embed-text → bge-m3 (1024차원) 전체 치환 (12건) - Qwen2.5-VL-7B → Surya OCR (:8400) 전체 치환 (5건) - VRAM 다이어그램 갱신 (~11.3GB → ~7-8GB) - 3-Tier 라우팅 전략, 모델 협업 파이프라인 갱신 - Komga 만화 서버 GPU 서버 이전 반영 - embed_to_chroma.py 삭제 (embed_to_qdrant.py로 대체) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/architecture.md | 136 ++++++++++++++++++------------------- scripts/embed_to_chroma.py | 104 ---------------------------- 2 files changed, 68 insertions(+), 172 deletions(-) delete mode 100644 scripts/embed_to_chroma.py diff --git a/docs/architecture.md b/docs/architecture.md index 0417d16..ac07648 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,12 +169,12 @@ DEVONthink 4의 커스텀 메타데이터 필드를 활용합니다. ### AI 결과물 저장 전략 — 중복 저장 금지 GPU 서버에서 처리된 AI 결과물은 **각자 목적에 맞는 곳에만** 저장합니다. -DEVONthink와 ChromaDB에 같은 정보를 이중으로 넣지 않습니다. +DEVONthink와 Qdrant에 같은 정보를 이중으로 넣지 않습니다. ``` 처리 결과 저장 위치 이유 ─────────────────────────────────────────────────────── -벡터 임베딩 ChromaDB만 시맨틱 검색 전용, DEVONthink에선 쓸모없음 +벡터 임베딩 Qdrant만 시맨틱 검색 전용, DEVONthink에선 쓸모없음 비전 OCR 텍스트 DEVONthink 본문에 병합 검색 가능한 텍스트가 되어야 하므로 필수 리랭킹 점수 저장 안 함 (휘발) 쿼리 시점에만 의미 있는 일회성 데이터 태그/분류 DEVONthink 태그만 Smart Group, 브라우징에 활용 @@ -183,10 +183,10 @@ OmniFocus 역링크 DEVONthink 메타데이터 양방향 참조에 필요 ``` **핵심 원칙:** -- ChromaDB = 벡터 검색 엔진. 여기엔 임베딩만 들어감 +- Qdrant = 벡터 검색 엔진. 여기엔 임베딩만 들어감 - DEVONthink = 원본 문서 + 사람이 읽는 메타데이터(태그, 링크) - 요약/분석은 RAG로 실시간 생성하면 되므로 별도 캐싱 불필요 -- 비전 모델의 OCR 결과만 DEVONthink 본문에 반드시 병합 (검색성 확보) +- Surya OCR 결과만 DEVONthink 본문에 반드시 병합 (검색성 확보) --- @@ -211,7 +211,7 @@ OmniFocus 역링크 DEVONthink 메타데이터 양방향 참조에 필요 DEVONagent ────┤ ┌──────────────┐ 스캔 문서 ──────┼──► Inbox ──►│ Smart Rule │──► 자동 태깅 이메일 ────────┤ │ + Ollama API │ + 적절한 DB로 이동 -파일 드롭 ──────┘ │ + GPU 서버 │ + 벡터 인덱싱 (ChromaDB) +파일 드롭 ──────┘ │ + GPU 서버 │ + 벡터 인덱싱 (Qdrant) └──────────────┘ + OCR 텍스트 병합 (스캔 시) ▼ OmniFocus 작업 생성 @@ -225,9 +225,9 @@ DEVONagent ────┤ ┌───────────── 트리거: Inbox DB에 새 문서 추가 조건: 태그가 비어있음 동작: - 1. 이미지/스캔 문서 → GPU 서버 VL-7B로 OCR → 본문에 병합 + 1. 이미지/스캔 문서 → GPU 서버 Surya OCR(:8400)로 OCR → 본문에 병합 2. Mac mini 35B → 태그 + 분류 대상 DB 생성 → DEVONthink 태그에만 저장 - 3. GPU 서버 nomic-embed → 벡터화 → ChromaDB에만 저장 + 3. GPU 서버 bge-m3 → 벡터화 → Qdrant에만 저장 4. 태그 기반 도메인 DB 자동 이동: #주제/프로그래밍, #주제/AI-ML → 05_Programming #주제/공학, #주제/네트워크 → 03_Engineering @@ -249,7 +249,7 @@ DEVONagent ────┤ ┌───────────── 동작: 1. 발신자 기준 그룹 자동 생성/분류 2. 첨부파일 추출 → 태그 기반 도메인 DB로 복제 (기술문서→03, 도면→97 등) - 3. GPU 서버에서 벡터 임베딩 → ChromaDB 인덱싱 + 3. GPU 서버에서 벡터 임베딩 → Qdrant 인덱싱 ※ 이메일 요약은 저장하지 않음 (RAG로 검색 시 생성) ``` @@ -336,8 +336,8 @@ on performSmartRule(theRecords) end if end try - -- Step 4: GPU 서버 → 벡터 임베딩 → ChromaDB 인덱싱 (비동기) - do shell script "python3 ~/scripts/embed_to_chroma.py " & ¬ + -- Step 4: GPU 서버 → 벡터 임베딩 → Qdrant 인덱싱 (비동기) + do shell script "python3 ~/scripts/embed_to_qdrant.py " & ¬ quoted form of docUUID & " &" -- Step 5: 처리 완료 표시 @@ -567,59 +567,59 @@ if __name__ == "__main__": │ RTX 4070 Ti Super 16GB VRAM │ │ │ │ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ -│ │ 👁️ 비전 모델 │ │ 🔍 리랭커 (Reranker) │ │ -│ │ Qwen2.5-VL-7B (8Q) │ │ bge-reranker-v2-m3 │ │ -│ │ VRAM: ~8GB │ │ VRAM: ~1GB │ │ -│ │ │ │ │ │ -│ │ 용도: │ │ 용도: │ │ -│ │ · 스캔 문서 분석 │ │ · RAG 검색 품질 극대화 │ │ -│ │ · 이미지 캡션/태깅 │ │ · 임베딩 검색 후 정밀 재정렬 │ │ -│ │ · 차트/그래프 해석 │ │ · Top-K → Top-N 정확도 향상 │ │ -│ │ · 사진 자동 분류 │ │ │ │ -│ │ · OCR 보완 │ │ │ │ -│ └──────────────────────┘ └──────────────────────────────────┘ │ +│ │ 📄 Surya OCR │ │ 🔍 리랭커 (Reranker) │ │ +│ │ FastAPI :8400 │ │ bge-reranker-v2-m3 │ │ +│ │ VRAM: ~2-3GB │ │ VRAM: ~1GB │ │ +│ │ │ │ │ │ +│ │ 용도: │ │ 용도: │ │ +│ │ · 스캔 문서 OCR │ │ · RAG 검색 품질 극대화 │ │ +│ │ · 이미지 텍스트 추출 │ │ · 임베딩 검색 후 정밀 재정렬 │ │ +│ │ · 만화 말풍선 OCR │ │ · Top-K → Top-N 정확도 향상 │ │ +│ │ · 한/영/일 다국어 │ │ │ │ +│ └───────────────────────┘ └──────────────────────────────────┘ │ │ │ │ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ │ │ 🔗 임베딩 모델 │ │ 📊 VRAM 배분 │ │ -│ │ nomic-embed-text │ │ │ │ -│ │ VRAM: ~0.3GB │ │ 비전 모델 (8Q): ~8GB │ │ +│ │ bge-m3 (1024차원) │ │ │ │ +│ │ VRAM: ~1.5GB │ │ Surya OCR: ~2-3GB │ │ │ │ │ │ 리랭커: ~1GB │ │ -│ │ 용도: │ │ 임베딩: ~0.3GB │ │ -│ │ · 문서 벡터 임베딩 │ │ 시스템: ~2GB │ │ +│ │ 용도: │ │ 임베딩: ~1.5GB │ │ +│ │ · 문서 벡터 임베딩 │ │ Plex HW 트랜스: ~1-2GB │ │ │ │ · RAG 인덱싱 │ │ ───────────────────── │ │ -│ │ · 쿼리 임베딩 │ │ 합계: ~11.3GB / 16GB │ │ -│ │ │ │ 여유: ~4.7GB ✅ │ │ +│ │ · 쿼리 임베딩 │ │ 합계: ~7-8GB / 16GB │ │ +│ │ │ │ 여유: ~8-9GB ✅ │ │ │ │ ※ GPU 가속으로 │ │ │ │ │ │ 대량 임베딩 시 유리 │ │ │ │ │ └──────────────────────┘ └──────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 🎬 미디어 서비스 │ │ -│ │ Plex Media Server — GPU 하드웨어 트랜스코딩 활용 │ │ +│ │ 🎬 미디어 + 만화 서비스 │ │ +│ │ Plex Media Server — GPU 하드웨어 트랜스코딩 │ │ +│ │ Komga — 만화 서버 (Docker, NFS → NAS /Comic) │ │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 임베딩을 GPU 서버로 이전하는 이유 -임베딩 모델(nomic-embed-text)을 Mac mini에서 GPU 서버로 이전하는 것을 **권장**합니다: +임베딩 모델(bge-m3)을 Mac mini에서 GPU 서버로 이전하는 것을 **권장**합니다: | 비교 항목 | Mac mini에서 실행 | GPU 서버에서 실행 | |---|---|---| | **대량 인덱싱 속도** | CPU 기반, 느림 | CUDA 가속, 5-10배 빠름 | | **Mac mini 부하** | 35B 모델 + 임베딩 동시 시 경합 | 35B 모델 전용, 쾌적 | -| **VRAM 영향** | 해당 없음 | +0.3GB (무시할 수준) | +| **VRAM 영향** | 해당 없음 | +1.5GB (bge-m3, 1024차원) | | **네트워크 레이턴시** | 없음 | 2.5G 네트워크, 1ms 미만 | | **배치 처리** | 문서 100개 인덱싱 시 수분 | 문서 100개 인덱싱 시 수십초 | -| **ChromaDB 위치** | Mac mini 유지 | Mac mini 유지 (변동 없음) | +| **Qdrant 위치** | Mac mini 유지 | Mac mini 유지 (변동 없음) | **결론:** 임베딩 모델은 단일 요청 레이턴시보다 **배치 처리량**이 중요합니다. GPU 서버의 CUDA 가속을 활용하면 대량 문서 인덱싱이 훨씬 빨라지고, Mac mini의 통합메모리를 35B 모델에 온전히 할당할 수 있습니다. -nomic-embed-text는 0.3GB에 불과해 GPU 서버 VRAM에 거의 영향이 없고, +bge-m3는 ~1.5GB로 GPU 서버 VRAM 16GB 대비 여유 충분하고, 2.5G 네트워크 환경이라 API 호출 레이턴시도 무시할 수준입니다. -다만 **ChromaDB는 Mac mini에 유지**합니다. RAG 질의 시 벡터 검색 → +다만 **Qdrant는 Mac mini에 유지**합니다. RAG 질의 시 벡터 검색 → 리랭킹 → 35B 응답 생성이 연속으로 일어나는데, 벡터 DB가 로컬에 있어야 이 파이프라인이 가장 빠릅니다. @@ -638,15 +638,15 @@ nomic-embed-text는 0.3GB에 불과해 GPU 서버 VRAM에 거의 영향이 없 │ Mac mini │ │ Claude │ │ GPU 서버 │ │ (메인) │ │ (클라우드) │ │ (보조) │ ├─────────────────┤ ├──────────────┤ ├────────────────────┤ - │ Qwen3.5-35B-A3B │ │ Sonnet 4.6 │ │ Qwen2.5-VL-7B (8Q)│ + │ Qwen3.5-35B-A3B │ │ Sonnet 4.6 │ │ Surya OCR (:8400) │ │ 4Q / ~80 tok/s │ │ │ │ bge-reranker-v2-m3 │ - │ │ │ │ │ nomic-embed-text │ + │ │ │ │ │ bge-m3 (1024차원) │ ├─────────────────┤ ├──────────────┤ ├────────────────────┤ - │ · 자동 태깅/분류 │ │ · 심층 분석 │ │ · 이미지/스캔 분석 │ + │ · 자동 태깅/분류 │ │ · 심층 분석 │ │ · 스캔/이미지 OCR │ │ · 문서 요약 │ │ · 리서치 합성 │ │ · RAG 리랭킹 │ │ · 메타데이터 │ │ · 보고서 생성 │ │ · 문서 임베딩/인덱싱│ - │ · 액션아이템추출 │ │ · 복잡한 추론 │ │ · 사진 자동 분류 │ - │ · RAG 응답생성 │ │ · 다국어 번역 │ │ · OCR 후처리 │ + │ · 액션아이템추출 │ │ · 복잡한 추론 │ │ · 만화 텍스트 추출 │ + │ · RAG 응답생성 │ │ · 다국어 번역 │ │ · 한/영/일 다국어 │ ├─────────────────┤ ├──────────────┤ ├────────────────────┤ │ 속도: ~80 tok/s │ │ 속도: ~3초 │ │ 속도: GPU 가속 │ │ 비용: 무료 │ │ 비용: 과금 │ │ 비용: 무료 │ @@ -659,13 +659,13 @@ nomic-embed-text는 0.3GB에 불과해 GPU 서버 VRAM에 거의 영향이 없 | 조건 | 라우팅 | 이유 | |---|---|---| | 텍스트 문서 + 태깅/분류/요약 | Tier 1 (Mac mini 35B) | 메인 범용, 품질 충분 | -| 이미지 포함 문서 / 스캔 PDF | Tier 3 → Tier 1 | 비전 모델로 텍스트 추출 후 35B로 분석 | +| 이미지 포함 문서 / 스캔 PDF | Tier 3 → Tier 1 | Surya OCR로 텍스트 추출 후 35B로 분석 | | 심층 분석 / 긴 보고서 생성 | Tier 2 (Claude API) | 최고 품질 필요 시 | | RAG 검색 결과 리랭킹 | Tier 3 (GPU reranker) | 검색 정확도 극대화 | | RAG 최종 응답 생성 | Tier 1 (Mac mini 35B) | 컨텍스트 기반 응답 | | 새 문서 벡터 인덱싱 | Tier 3 (GPU embed) | CUDA 가속 배치 처리 | | 대량 배치 (100+ 문서) | Tier 1 + Tier 3 병렬 | 양쪽 분산 처리 | -| Synology Photos 자동 태깅 | Tier 3 (GPU vision) | 이미지 분석 특화 | +| 만화 OCR (Komga 연동) | Tier 3 (GPU Surya OCR) | GPU 서버 로컬 처리 | ### 모델 간 협업 파이프라인 @@ -674,26 +674,26 @@ nomic-embed-text는 0.3GB에 불과해 GPU 서버 VRAM에 거의 영향이 없 1. [Smart Rule 트리거] 새 PDF 감지, 이미지 기반 문서로 판단 │ -2. [GPU 서버 · Qwen2.5-VL-7B 8Q] - 이미지 분석 → 텍스트 추출 (OCR) → DEVONthink 본문에 병합 +2. [GPU 서버 · Surya OCR :8400] + 이미지/스캔 PDF → OCR 텍스트 추출 → DEVONthink 본문에 병합 │ 3. [Mac mini · Qwen3.5-35B-A3B] 추출된 텍스트로 태그 생성 → DEVONthink 태그에만 저장 │ -4. [GPU 서버 · nomic-embed-text] - 문서 벡터 임베딩 → ChromaDB에만 저장 +4. [GPU 서버 · bge-m3] + 문서 벡터 임베딩 → Qdrant에만 저장 │ 5. [결과] DEVONthink에는 본문(OCR)+태그+처리일시만 - ChromaDB에는 벡터만. 요약은 저장하지 않음 (RAG로 실시간 생성) + Qdrant에는 벡터만. 요약은 저장하지 않음 (RAG로 실시간 생성) 예시: RAG 질의 시 1. [사용자 질문] "서버 마이그레이션 관련 자료 정리해줘" │ -2. [GPU 서버 · nomic-embed-text] 쿼리 임베딩 +2. [GPU 서버 · bge-m3] 쿼리 임베딩 │ -3. [Mac mini · ChromaDB] 벡터 유사도 검색 → Top-20 후보 +3. [Mac mini · Qdrant] 벡터 유사도 검색 → Top-20 후보 │ 4. [GPU 서버 · bge-reranker-v2-m3] Top-20 → 정밀 리랭킹 → Top-5 선정 @@ -714,9 +714,9 @@ OLLAMA_MAX_LOADED_MODELS=3 # 동시 로드 모델 3개 (비전+리랭커+ OLLAMA_KEEP_ALIVE=10m # 미사용 시 10분 후 언로드 # 모델 다운로드 -ollama pull qwen2.5-vl:7b-instruct-q8_0 # 비전 모델 8Q (~8GB) +# Surya OCR은 별도 systemd 서비스로 운영 (:8400) ollama pull bge-reranker-v2-m3 # 리랭커 (~1GB) -ollama pull nomic-embed-text # 임베딩 (~0.3GB) +ollama pull bge-m3 # 임베딩 (~1.5GB, 1024차원) # Mac mini에서 GPU 서버 호출 예시 # 비전 분석 @@ -725,11 +725,11 @@ curl http://gpu-server:11434/api/generate \ # 임베딩 (배치) curl http://gpu-server:11434/api/embed \ - -d '{"model":"nomic-embed-text", "input":["문서1 텍스트", "문서2 텍스트", ...]}' + -d '{"model":"bge-m3", "input":["문서1 텍스트", "문서2 텍스트", ...]}' ``` **`keep_alive` 활용 전략:** -- 비전 모델 (8Q): `keep_alive: "30m"` — 자주 사용, 항상 대기 +- Surya OCR: systemd 서비스로 상시 구동 (포트 8400) - 리랭커: `keep_alive: "10m"` — RAG 쿼리 시 활성 - 임베딩: `keep_alive: "30m"` — 새 문서 인덱싱 빈도에 맞춰 @@ -750,20 +750,20 @@ curl http://gpu-server:11434/api/embed \ │ [청킹] → 의미 단위로 텍스트 분할 (500토큰) │ │ │ │ │ ▼ │ -│ [임베딩] → GPU 서버 Ollama (nomic-embed-text, CUDA) │ +│ [임베딩] → GPU 서버 Ollama (bge-m3, CUDA) │ │ │ │ │ ▼ │ -│ [벡터 저장] → ChromaDB (Mac mini 로컬) │ +│ [벡터 저장] → Qdrant (Mac mini 로컬) │ │ │ │ │ ─ ─ ─ ─ ─ ─ 쿼리 시 ─ ─ ─ ─ ─ ─ │ │ │ │ │ [질문 입력] │ │ │ │ │ ▼ │ -│ [쿼리 임베딩] → GPU 서버 (nomic-embed-text) │ +│ [쿼리 임베딩] → GPU 서버 (bge-m3) │ │ │ │ │ ▼ │ -│ [유사도 검색] → ChromaDB (Mac mini, Top-20) │ +│ [유사도 검색] → Qdrant (Mac mini, Top-20) │ │ │ │ │ ▼ │ │ [리랭킹] → GPU 서버 (bge-reranker, Top-5 선정) │ @@ -841,7 +841,7 @@ Smart Rule 2차: 하위 그룹 라우팅 → 80_Reference/Standards/ │ ▼ -ChromaDB 벡터 인덱싱 (비동기) +Qdrant 벡터 인덱싱 (비동기) → RAG 검색에 즉시 반영 │ ▼ @@ -1023,7 +1023,7 @@ Mac mini에서는 **자동 스케줄 리서치**, 맥북에서는 **현장 수 │ 배치 + 자동화 중심 │ 인터랙티브 + 즉시성 중심 │ ├────────────────────────┴────────────────────────────────┤ │ 공통: 결과는 모두 DEVONthink Inbox → CloudKit 동기화 │ -│ → Mac mini Smart Rule이 자동 태깅 + ChromaDB 인덱싱 │ +│ → Mac mini Smart Rule이 자동 태깅 + Qdrant 인덱싱 │ └─────────────────────────────────────────────────────────┘ ``` @@ -1095,7 +1095,7 @@ DEVONthink에서 자료 검색/열람 (동기화된 DB) [RAG 질의 시] Tailscale 연결 → RAG API에 자연어 질문 -→ Mac mini에서 GPU 임베딩 → ChromaDB 검색 → 리랭킹 → 35B 응답 +→ Mac mini에서 GPU 임베딩 → Qdrant 검색 → 리랭킹 → 35B 응답 → 결과에 x-devonthink-item:// 링크 포함 → 맥북 DEVONthink에서 해당 문서 바로 열기 @@ -1183,7 +1183,7 @@ RAG 시스템으로 내 지식베이스에 질문 │ 완료 5건 | 신규 3건 | 기한초과 1건 │ │ │ │ ■ 시스템 상태 │ - │ ChromaDB 벡터: 12,847개 (+15) │ + │ Qdrant 벡터: 12,847개 (+15) │ │ Inbox 잔여: 2건 │ │ NAS 동기화: 정상 │ └─────────────────────────────────────────────┘ @@ -1194,7 +1194,7 @@ RAG 시스템으로 내 지식베이스에 질문 · Inbox 미처리 3건 이상 → "Inbox 정리 필요 (N건 미분류)" · 시정조치 overdue → "시정조치 기한초과: [내용]" (긴급 플래그) · 분류 실패 문서 존재 → "수동 분류 필요 (N건)" - · ChromaDB 인덱싱 실패 → "벡터 인덱싱 오류 점검" + · Qdrant 인덱싱 실패 → "벡터 인덱싱 오류 점검" 출력 3 — Synology Chat 알림 (선택, 한 줄 요약): "📋 오늘 다이제스트: 신규 12건, 법령변경 2건, overdue 1건 ⚠" @@ -1222,7 +1222,7 @@ RAG 시스템으로 내 지식베이스에 질문 end tell 5. 시스템 상태 — Python - ChromaDB collection.count(), NAS ping, sync 로그 확인 + Qdrant collection.count(), NAS ping, sync 로그 확인 6. 상위 뉴스 요약 — Ollama 35B 오늘 수집된 뉴스 중 상위 3건을 2-3문장으로 요약 @@ -1259,8 +1259,8 @@ OmniFocus 리뷰 → 완료 작업의 DEVONthink 메타데이터 업데이트 □ DEVONsphere Express 설치 □ OmniFocus, OmniOutliner, OmniGraffle, OmniPlan 설치 □ Ollama 확인 (이미 설치됨) -□ GPU 서버에 nomic-embed-text, Qwen2.5-VL-7B 8Q, bge-reranker 다운로드 -□ ChromaDB 설치 (pip install chromadb) — Mac mini +□ GPU 서버에 bge-m3, bge-reranker 다운로드 + Surya OCR 서비스 설치 +□ Qdrant (Docker, Mac mini) — pkm_documents 컬렉션 (1024차원, Cosine) □ Python 환경 설정 (venv 권장) □ Plex Media Server를 GPU 서버로 이전 ``` @@ -1288,7 +1288,7 @@ OmniFocus 리뷰 → 완료 작업의 DEVONthink 메타데이터 업데이트 ``` □ Ollama 태깅/분류 프롬프트 최적화 □ Claude API 키 Keychain 등록 -□ RAG 파이프라인 구축 (GPU 서버 임베딩 + Mac mini ChromaDB) +□ RAG 파이프라인 구축 (GPU bge-m3 임베딩 + Mac mini Qdrant + MLX 35B 응답) □ DEVONthink Smart Rule과 AI 연동 테스트 □ DEVONagent 자동 검색 스케줄 설정 ``` @@ -1325,7 +1325,7 @@ OmniPlan 0.5GB 낮음 OmniOutliner 0.3GB 낮음 OmniGraffle 0.5GB 낮음 MLX (Qwen3.5-35B-A3B 4bit) ~20GB 중간 MoE: 3B만 활성 -ChromaDB 1-2GB 낮음 +Qdrant (Docker) 1-2GB 낮음 Roon Core 2-4GB 낮음 Komga 0.5GB 낮음 기타 시스템 4-6GB - @@ -1347,9 +1347,9 @@ Plex를 GPU 서버로 이전하고 임베딩도 GPU로 넘김으로써, Mac mini ``` 서비스 VRAM 상태 비고 ───────────────────────────────────────────────────────────── -Qwen2.5-VL-7B (8Q) ~8GB 상주 비전/이미지 분석 +Surya OCR (systemd) ~2-3GB 상주 문서/만화 OCR bge-reranker-v2-m3 ~1GB 상주 RAG 리랭킹 -nomic-embed-text ~0.3GB 상주 임베딩 (CUDA 가속) +bge-m3 (1024차원) ~1.5GB 상주 임베딩 (CUDA 가속) Plex HW Transcoding ~1-2GB 간헐적 NVENC/NVDEC 활용 시스템 오버헤드 ~2GB - diff --git a/scripts/embed_to_chroma.py b/scripts/embed_to_chroma.py deleted file mode 100644 index 503060f..0000000 --- a/scripts/embed_to_chroma.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -벡터 임베딩 스크립트 -- DEVONthink 문서 UUID로 텍스트 추출 -- GPU 서버(nomic-embed-text)로 임베딩 생성 -- ChromaDB에 저장 -""" - -import os -import sys -import requests -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from pkm_utils import setup_logger, load_credentials, run_applescript_inline - -logger = setup_logger("embed") - -# ChromaDB 저장 경로 -CHROMA_DIR = Path.home() / ".local" / "share" / "pkm" / "chromadb" -CHROMA_DIR.mkdir(parents=True, exist_ok=True) - - -def get_document_text(uuid: str) -> tuple[str, str]: - """DEVONthink에서 UUID로 문서 텍스트 + 제목 추출""" - script = f''' - tell application id "DNtp" - set theRecord to get record with uuid "{uuid}" - set docText to plain text of theRecord - set docTitle to name of theRecord - return docTitle & "|||" & docText - end tell - ''' - result = run_applescript_inline(script) - parts = result.split("|||", 1) - title = parts[0] if len(parts) > 0 else "" - text = parts[1] if len(parts) > 1 else "" - return title, text - - -def get_embedding(text: str, gpu_server_ip: str) -> list[float] | None: - """GPU 서버의 nomic-embed-text로 임베딩 생성""" - url = f"http://{gpu_server_ip}:11434/api/embeddings" - try: - resp = requests.post(url, json={ - "model": "nomic-embed-text", - "prompt": text[:8000] # 토큰 제한 - }, timeout=60) - resp.raise_for_status() - return resp.json().get("embedding") - except Exception as e: - logger.error(f"임베딩 생성 실패: {e}") - return None - - -def store_in_chromadb(doc_id: str, title: str, text: str, embedding: list[float]): - """ChromaDB에 저장""" - import chromadb - client = chromadb.PersistentClient(path=str(CHROMA_DIR)) - collection = client.get_or_create_collection( - name="pkm_documents", - metadata={"hnsw:space": "cosine"} - ) - collection.upsert( - ids=[doc_id], - embeddings=[embedding], - documents=[text[:2000]], - metadatas=[{"title": title, "source": "devonthink"}] - ) - logger.info(f"ChromaDB 저장: {doc_id} ({title[:30]})") - - -def run(uuid: str): - """단일 문서 임베딩 처리""" - logger.info(f"임베딩 처리 시작: {uuid}") - - creds = load_credentials() - gpu_ip = creds.get("GPU_SERVER_IP") - if not gpu_ip: - logger.warning("GPU_SERVER_IP 미설정 — 임베딩 건너뜀") - return - - try: - title, text = get_document_text(uuid) - if not text or len(text) < 10: - logger.warning(f"텍스트 부족 [{uuid}]: {len(text)}자") - return - - embedding = get_embedding(text, gpu_ip) - if embedding: - store_in_chromadb(uuid, title, text, embedding) - logger.info(f"임베딩 완료: {uuid}") - else: - logger.error(f"임베딩 실패: {uuid}") - - except Exception as e: - logger.error(f"임베딩 처리 에러 [{uuid}]: {e}", exc_info=True) - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("사용법: python3 embed_to_chroma.py ") - sys.exit(1) - run(sys.argv[1]) From dc3f03b42102fd0b02c27a07130d6e3fc611b4cf Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 14:00:46 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20Phase=202=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=94=BD=EC=8A=A4=20=E2=80=94=20JP=20=EB=B2=88=EC=97=AD,=20API?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84,=20AppleScript=20=EA=B2=BD=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkm_utils.py: strip_thinking() 추가 + llm_generate() no_think 옵션 - 태그 제거 + thinking 패턴("Wait,", "Let me" 등) 필터링 - enable_thinking: false 파라미터 지원 - law_monitor.py: JP 번역 호출에 no_think=True 적용 - pkm_api_server.py: /devonthink/stats 최적화 (children 순회 → count 사용) + /devonthink/search 한글 쿼리 이스케이프 수정 - auto_classify.scpt: baseDir property로 경로 변수화 - omnifocus_sync.scpt: 로그 경로 변수화 인프라: MailPlus IMAP HOST → LAN IP(192.168.1.227)로 변경 참고: 한국 법령 API IP(122.153.226.74) open.law.go.kr 등록 필요 Co-Authored-By: Claude Opus 4.6 (1M context) --- applescript/auto_classify.scpt | 23 ++++++++++++++--------- applescript/omnifocus_sync.scpt | 6 +++++- scripts/law_monitor.py | 6 ++---- scripts/pkm_api_server.py | 33 +++++++++++++-------------------- scripts/pkm_utils.py | 29 +++++++++++++++++++++++++---- 5 files changed, 59 insertions(+), 38 deletions(-) diff --git a/applescript/auto_classify.scpt b/applescript/auto_classify.scpt index 49c2820..c119463 100644 --- a/applescript/auto_classify.scpt +++ b/applescript/auto_classify.scpt @@ -2,7 +2,14 @@ -- Inbox DB 새 문서 → OCR 전처리 → MLX 분류 → 태그 + 메타데이터 + 도메인 DB 이동 → Qdrant 임베딩 -- Smart Rule 설정: Event = On Import, 조건 = Tags is empty +property baseDir : "Documents/code/DEVONThink_my server" + on performSmartRule(theRecords) + set homeDir to POSIX path of (path to home folder) + set pkmRoot to homeDir & baseDir + set venvPython to pkmRoot & "/venv/bin/python3" + set logFile to pkmRoot & "/logs/auto_classify.log" + tell application id "DNtp" repeat with theRecord in theRecords try @@ -13,16 +20,15 @@ on performSmartRule(theRecords) if docText is "" then if docType is in {"PDF Document", "JPEG image", "PNG image", "TIFF image"} then - set ocrScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3" - set ocrPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/ocr_preprocess.py" + set ocrPy to pkmRoot & "/scripts/ocr_preprocess.py" try - set ocrText to do shell script ocrScript & " " & quoted form of ocrPy & " " & quoted form of docUUID + set ocrText to do shell script venvPython & " " & quoted form of ocrPy & " " & quoted form of docUUID if length of ocrText > 0 then set plain text of theRecord to ocrText set docText to ocrText end if on error ocrErr - do shell script "echo '[OCR ERROR] " & ocrErr & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/auto_classify.log" + do shell script "echo '[OCR ERROR] " & ocrErr & "' >> " & quoted form of logFile end try end if end if @@ -39,7 +45,7 @@ on performSmartRule(theRecords) end if -- 2. 분류 프롬프트 로딩 - set promptPath to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/prompts/classify_document.txt" + set promptPath to pkmRoot & "/scripts/prompts/classify_document.txt" set promptTemplate to do shell script "cat " & quoted form of promptPath -- 문서 텍스트를 프롬프트에 삽입 (특수문자 이스케이프) @@ -105,14 +111,13 @@ except: end if -- 8. GPU 서버 벡터 임베딩 비동기 전송 - set embedScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3" - set embedPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/embed_to_qdrant.py" - do shell script embedScript & " " & quoted form of embedPy & " " & quoted form of docUUID & " &> /dev/null &" + set embedPy to pkmRoot & "/scripts/embed_to_qdrant.py" + do shell script venvPython & " " & quoted form of embedPy & " " & quoted form of docUUID & " &> /dev/null &" on error errMsg -- 에러 시 로그 기록 + 검토필요 태그 set tags of theRecord to {"@상태/검토필요", "AI분류실패"} - do shell script "echo '[" & (current date) & "] [auto_classify] [ERROR] " & errMsg & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/auto_classify.log" + do shell script "echo '[" & (current date) & "] [auto_classify] [ERROR] " & errMsg & "' >> " & quoted form of logFile end try end repeat end tell diff --git a/applescript/omnifocus_sync.scpt b/applescript/omnifocus_sync.scpt index dfa32d6..036ab65 100644 --- a/applescript/omnifocus_sync.scpt +++ b/applescript/omnifocus_sync.scpt @@ -2,7 +2,11 @@ -- Projects DB 새 문서에서 TODO 패턴 감지 → OmniFocus 작업 생성 -- Smart Rule 설정: Event = On Import, DB = Projects +property baseDir : "Documents/code/DEVONThink_my server" + on performSmartRule(theRecords) + set homeDir to POSIX path of (path to home folder) + set logFile to homeDir & baseDir & "/logs/omnifocus_sync.log" tell application id "DNtp" repeat with theRecord in theRecords try @@ -64,7 +68,7 @@ for item in items[:10]: add custom meta data taskIDString for "omnifocusTaskID" to theRecord on error errMsg - do shell script "echo '[" & (current date) & "] [omnifocus_sync] [ERROR] " & errMsg & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/omnifocus_sync.log" + do shell script "echo '[" & (current date) & "] [omnifocus_sync] [ERROR] " & errMsg & "' >> " & quoted form of logFile end try end repeat end tell diff --git a/scripts/law_monitor.py b/scripts/law_monitor.py index 39e5d74..d8d94b3 100644 --- a/scripts/law_monitor.py +++ b/scripts/law_monitor.py @@ -315,11 +315,9 @@ def fetch_jp_mhlw(last_check: dict) -> int: translated = "" try: translated = llm_generate( - f"다음 일본어 제목을 한국어로 번역해줘. 번역만 출력하고 다른 말은 하지 마.\n\n{title}" + f"다음 일본어 제목을 한국어로 번역해줘. 번역만 출력하고 다른 말은 하지 마.\n\n{title}", + no_think=True ) - # thinking 출력 제거 — 마지막 줄만 사용 - lines = [l.strip() for l in translated.strip().split("\n") if l.strip()] - translated = lines[-1] if lines else title except Exception: translated = title diff --git a/scripts/pkm_api_server.py b/scripts/pkm_api_server.py index 8a301f5..a820398 100644 --- a/scripts/pkm_api_server.py +++ b/scripts/pkm_api_server.py @@ -35,24 +35,14 @@ def run_applescript(script: str, timeout: int = 120) -> str: @app.route('/devonthink/stats') def devonthink_stats(): try: + # DB별 문서 수만 빠르게 조회 (children 순회 대신 count 사용) script = ( 'tell application id "DNtp"\n' - ' set today to current date\n' - ' set time of today to 0\n' ' set stats to {}\n' ' repeat with db in databases\n' ' set dbName to name of db\n' - ' set addedCount to 0\n' - ' set modifiedCount to 0\n' - ' repeat with rec in children of root of db\n' - ' try\n' - ' if creation date of rec >= today then set addedCount to addedCount + 1\n' - ' if modification date of rec >= today then set modifiedCount to modifiedCount + 1\n' - ' end try\n' - ' end repeat\n' - ' if addedCount > 0 or modifiedCount > 0 then\n' - ' set end of stats to dbName & ":" & addedCount & ":" & modifiedCount\n' - ' end if\n' + ' set docCount to count of contents of db\n' + ' set end of stats to dbName & ":" & docCount\n' ' end repeat\n' ' set AppleScript\'s text item delimiters to "|"\n' ' return stats as text\n' @@ -60,17 +50,18 @@ def devonthink_stats(): ) result = run_applescript(script) stats = {} + total = 0 if result: for item in result.split('|'): parts = item.split(':') - if len(parts) == 3: - stats[parts[0]] = {'added': int(parts[1]), 'modified': int(parts[2])} - total_added = sum(s['added'] for s in stats.values()) - total_modified = sum(s['modified'] for s in stats.values()) + if len(parts) == 2: + count = int(parts[1]) + stats[parts[0]] = {'count': count} + total += count return jsonify(success=True, data={ 'databases': stats, - 'total_added': total_added, - 'total_modified': total_modified + 'total_documents': total, + 'database_count': len(stats), }) except Exception as e: return jsonify(success=False, error=str(e)), 500 @@ -83,9 +74,11 @@ def devonthink_search(): if not q: return jsonify(success=False, error='q parameter required'), 400 try: + # 한글 쿼리 이스케이프 (따옴표, 백슬래시) + safe_q = q.replace('\\', '\\\\').replace('"', '\\"') script = ( 'tell application id "DNtp"\n' - f' set results to search "{q}"\n' + f' set results to search "{safe_q}"\n' ' set output to {}\n' f' set maxCount to {limit}\n' ' set i to 0\n' diff --git a/scripts/pkm_utils.py b/scripts/pkm_utils.py index 66c5284..fdf706e 100644 --- a/scripts/pkm_utils.py +++ b/scripts/pkm_utils.py @@ -105,19 +105,40 @@ def run_applescript_inline(script: str) -> str: raise RuntimeError("AppleScript 타임아웃 (인라인)") +def strip_thinking(text: str) -> str: + """LLM thinking 출력 제거 — ... 태그 및 thinking 패턴 필터링""" + import re + # ... 태그 제거 + text = re.sub(r'[\s\S]*?\s*', '', text) + # "Wait,", "Let me", "I'll check" 등으로 시작하는 thinking 줄 제거 + lines = text.strip().split('\n') + filtered = [l for l in lines if not re.match( + r'^\s*(Wait|Let me|I\'ll|Hmm|OK,|Okay|Let\'s|Actually|So,|First)', l, re.IGNORECASE + )] + return '\n'.join(filtered).strip() if filtered else text.strip() + + def llm_generate(prompt: str, model: str = "mlx-community/Qwen3.5-35B-A3B-4bit", - host: str = "http://localhost:8800", json_mode: bool = False) -> str: - """MLX 서버 API 호출 (OpenAI 호환)""" + host: str = "http://localhost:8800", json_mode: bool = False, + no_think: bool = False) -> str: + """MLX 서버 API 호출 (OpenAI 호환) + no_think=True: thinking 비활성화 + 응답 필터링 (번역 등 단순 작업용) + """ import requests messages = [{"role": "user", "content": prompt}] - resp = requests.post(f"{host}/v1/chat/completions", json={ + payload = { "model": model, "messages": messages, "temperature": 0.3, "max_tokens": 4096, - }, timeout=300) + } + if no_think: + payload["enable_thinking"] = False + resp = requests.post(f"{host}/v1/chat/completions", json=payload, timeout=300) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"] + if no_think: + content = strip_thinking(content) if not json_mode: return content # JSON 모드: thinking 허용 → 마지막 유효 JSON 객체 추출 From 4b7ddf39c1df80fbdd8fa1e1bb850359e6a9fdb0 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 15:00:28 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EB=B2=95=EB=A0=B9=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=88=ED=84=B0=EB=A7=81=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=E2=80=94=20=EC=9E=A5=20=EB=8B=A8=EC=9C=84=20MD=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20+=20=ED=81=AC=EB=A1=9C=EC=8A=A4=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20+=20Tier=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - law_parser.py 신규: XML→MD 장 단위 분할, 조문 앵커 링크, 부칙 분리 - 장/절/편 자동 식별 (<조문여부>=전문), 장 없는 법령 fallback - DEVONthink wiki-link 크로스 링크 (같은 법률 내 + 다른 법률 간) - MST 자동 조회 + 7일 TTL 캐시 + 원자적 파일 쓰기 - 법령 약칭 매핑 (산안법→산업안전보건법 등) - law_monitor.py 리팩터링: - MONITORED_LAWS → Tier 1(15개 필수) / Tier 2(8개 참고, 비활성) - law_id → MST 방식 (현행 법령 자동 조회) - XML 통짜 저장 → 장별 Markdown 분할 저장 - DEVONthink 3단계 교체 (이동→생성→삭제, wiki-link 보존) - 에러 핸들링: 재시도 3회/백오프 + 부분 실패 허용 + 법령명 검증 - 실행 결과 law_last_run.json 기록 테스트: 15개 법령 전체 성공 (148개 MD 파일 생성) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/law_monitor.py | 272 ++++++++++++++++++------ scripts/law_parser.py | 471 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 673 insertions(+), 70 deletions(-) create mode 100644 scripts/law_parser.py diff --git a/scripts/law_monitor.py b/scripts/law_monitor.py index d8d94b3..bc378aa 100644 --- a/scripts/law_monitor.py +++ b/scripts/law_monitor.py @@ -17,18 +17,50 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from pkm_utils import setup_logger, load_credentials, run_applescript_inline, llm_generate, PROJECT_ROOT, DATA_DIR +from law_parser import ( + parse_law_xml, save_law_as_markdown, build_article_chapter_map, + add_cross_law_links, lookup_current_mst, atomic_write_json, +) logger = setup_logger("law_monitor") -# 모니터링 대상 법령 -MONITORED_LAWS = [ - {"name": "산업안전보건법", "law_id": "001789", "category": "법률"}, - {"name": "산업안전보건법 시행령", "law_id": "001790", "category": "대통령령"}, - {"name": "산업안전보건법 시행규칙", "law_id": "001791", "category": "부령"}, - {"name": "중대재해 처벌 등에 관한 법률", "law_id": "019005", "category": "법률"}, - {"name": "중대재해 처벌 등에 관한 법률 시행령", "law_id": "019006", "category": "대통령령"}, - {"name": "화학물질관리법", "law_id": "012354", "category": "법률"}, - {"name": "위험물안전관리법", "law_id": "001478", "category": "법률"}, +MST_CACHE_FILE = DATA_DIR / "law_mst_cache.json" +MD_OUTPUT_DIR = DATA_DIR / "laws" / "md" +MD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +# Tier 1 — 필수 모니터링 (업무 직접 관련, 매일 확인) +TIER1_LAWS = [ + # 산업안전 핵심 + {"name": "산업안전보건법", "category": "법률"}, + {"name": "산업안전보건법 시행령", "category": "대통령령"}, + {"name": "산업안전보건법 시행규칙", "category": "부령"}, + {"name": "중대재해 처벌 등에 관한 법률", "category": "법률"}, + {"name": "중대재해 처벌 등에 관한 법률 시행령", "category": "대통령령"}, + # 화학/위험물 + {"name": "화학물질관리법", "category": "법률"}, + {"name": "위험물안전관리법", "category": "법률"}, + {"name": "고압가스 안전관리법", "category": "법률"}, + # 전기/소방/건설 + {"name": "전기안전관리법", "category": "법률"}, + {"name": "소방시설 설치 및 관리에 관한 법률", "category": "법률"}, + {"name": "건설기술 진흥법", "category": "법률"}, + # 시설물/노동 + {"name": "시설물의 안전 및 유지관리에 관한 특별법", "category": "법률"}, + {"name": "근로기준법", "category": "법률"}, + {"name": "산업재해보상보험법", "category": "법률"}, + {"name": "근로자참여 및 협력증진에 관한 법률", "category": "법률"}, +] + +# Tier 2 — 참고 (기본 비활성, --include-tier2 또는 설정으로 활성화) +TIER2_LAWS = [ + {"name": "원자력안전법", "category": "법률"}, + {"name": "방사선안전관리법", "category": "법률"}, + {"name": "환경영향평가법", "category": "법률"}, + {"name": "석면안전관리법", "category": "법률"}, + {"name": "승강기 안전관리법", "category": "법률"}, + {"name": "연구실 안전환경 조성에 관한 법률", "category": "법률"}, + {"name": "재난 및 안전관리 기본법", "category": "법률"}, + {"name": "고용보험법", "category": "법률"}, ] # 마지막 확인 일자 저장 파일 @@ -46,37 +78,36 @@ def load_last_check() -> dict: def save_last_check(data: dict): - """마지막 확인 일자 저장""" - with open(LAST_CHECK_FILE, "w") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + """마지막 확인 일자 저장 (원자적 쓰기)""" + atomic_write_json(LAST_CHECK_FILE, data) -def fetch_law_info(law_oc: str, law_id: str) -> dict | None: - """법령 정보 조회 (법령 API)""" - url = "https://www.law.go.kr/DRF/lawSearch.do" +def fetch_law_info(law_oc: str, mst: str) -> dict | None: + """법령 정보 조회 — lawService.do로 MST 직접 조회 (XML → 기본정보 추출)""" + url = "https://www.law.go.kr/DRF/lawService.do" params = { "OC": law_oc, "target": "law", - "type": "JSON", - "MST": law_id, + "type": "XML", + "MST": mst, } try: resp = requests.get(url, params=params, timeout=30) resp.raise_for_status() - data = resp.json() - # API 에러 응답 감지 - if "result" in data and "실패" in str(data.get("result", "")): - logger.error(f"법령 API 에러 [{law_id}]: {data.get('result')} — {data.get('msg')}") + root = ET.fromstring(resp.content) + info_el = root.find(".//기본정보") + if info_el is None: + logger.warning(f"기본정보 없음 [MST={mst}]") return None - if "LawSearch" in data and "law" in data["LawSearch"]: - laws = data["LawSearch"]["law"] - if isinstance(laws, list): - return laws[0] if laws else None - return laws - logger.warning(f"법령 응답에 데이터 없음 [{law_id}]: {list(data.keys())}") - return None + return { + "법령명한글": (info_el.findtext("법령명_한글", "") or "").strip(), + "공포일자": (info_el.findtext("공포일자", "") or "").strip(), + "시행일자": (info_el.findtext("시행일자", "") or "").strip(), + "법령ID": (info_el.findtext("법령ID", "") or "").strip(), + "소관부처": (info_el.findtext("소관부처", "") or "").strip(), + } except Exception as e: - logger.error(f"법령 조회 실패 [{law_id}]: {e}") + logger.error(f"법령 조회 실패 [MST={mst}]: {e}") return None @@ -109,32 +140,91 @@ def save_law_file(law_name: str, content: str) -> Path: return filepath -def import_to_devonthink(filepath: Path, law_name: str, category: str): - """DEVONthink 04_Industrial Safety로 임포트 — 변수 방식""" - fp = str(filepath) - script = f'set fp to "{fp}"\n' - script += 'tell application id "DNtp"\n' - script += ' repeat with db in databases\n' - script += ' if name of db is "04_Industrial safety" then\n' - script += ' set targetGroup to create location "/10_Legislation/Law" in db\n' - script += ' set theRecord to import fp to targetGroup\n' - script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n' - script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n' - script += ' add custom meta data "external" for "dataOrigin" to theRecord\n' - script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n' - script += ' exit repeat\n' - script += ' end if\n' - script += ' end repeat\n' - script += 'end tell' +def import_law_to_devonthink(law_name: str, md_files: list[Path], category: str): + """DEVONthink 04_Industrial Safety로 장별 MD 파일 임포트 + 3단계 교체: 기존 폴더 이동 → 신규 생성 → 구 폴더 삭제 (wiki-link 끊김 최소화) + """ + safe_name = law_name.replace(" ", "_") + group_path = f"/10_Legislation/{safe_name}" + + # 1단계: 기존 폴더 이동 (있으면) + rename_script = ( + 'tell application id "DNtp"\n' + ' repeat with db in databases\n' + ' if name of db is "04_Industrial safety" then\n' + f' set oldGroup to get record at "{group_path}" in db\n' + ' if oldGroup is not missing value then\n' + f' set name of oldGroup to "{safe_name}_old"\n' + ' end if\n' + ' exit repeat\n' + ' end if\n' + ' end repeat\n' + 'end tell' + ) try: - run_applescript_inline(script) - logger.info(f"DEVONthink 임포트 완료: {law_name}") - except Exception as e: - logger.error(f"DEVONthink 임포트 실패 [{law_name}]: {e}") + run_applescript_inline(rename_script) + except Exception: + pass # 기존 폴더 없으면 무시 + + # 2단계: 신규 폴더 생성 + 파일 임포트 + for filepath in md_files: + fp = str(filepath) + script = f'set fp to "{fp}"\n' + script += 'tell application id "DNtp"\n' + script += ' repeat with db in databases\n' + script += ' if name of db is "04_Industrial safety" then\n' + script += f' set targetGroup to create location "{group_path}" in db\n' + script += ' set theRecord to import fp to targetGroup\n' + script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n' + script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n' + script += ' add custom meta data "external" for "dataOrigin" to theRecord\n' + script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n' + script += ' exit repeat\n' + script += ' end if\n' + script += ' end repeat\n' + script += 'end tell' + try: + run_applescript_inline(script) + except Exception as e: + logger.error(f"DEVONthink 임포트 실패 [{filepath.name}]: {e}") + + # 3단계: 구 폴더 삭제 + delete_script = ( + 'tell application id "DNtp"\n' + ' repeat with db in databases\n' + ' if name of db is "04_Industrial safety" then\n' + f' set oldGroup to get record at "/10_Legislation/{safe_name}_old" in db\n' + ' if oldGroup is not missing value then\n' + ' delete record oldGroup\n' + ' end if\n' + ' exit repeat\n' + ' end if\n' + ' end repeat\n' + 'end tell' + ) + try: + run_applescript_inline(delete_script) + except Exception: + pass + + logger.info(f"DEVONthink 임포트 완료: {law_name} ({len(md_files)}개 파일)") -def run(): - """메인 실행""" +def _fetch_with_retry(func, *args, retries=3, backoff=(5, 15, 30)): + """API 호출 재시도 래퍼""" + import time + for i in range(retries): + result = func(*args) + if result is not None: + return result + if i < retries - 1: + logger.warning(f"재시도 {i+2}/{retries} ({backoff[i]}초 후)") + time.sleep(backoff[i]) + return None + + +def run(include_tier2: bool = False): + """메인 실행 — MST 자동 조회 + 장 단위 MD 분할 + DEVONthink 임포트""" logger.info("=== 법령 모니터링 시작 ===") creds = load_credentials() @@ -143,41 +233,82 @@ def run(): logger.error("LAW_OC 인증키가 설정되지 않았습니다. credentials.env를 확인하세요.") sys.exit(1) + laws = TIER1_LAWS + (TIER2_LAWS if include_tier2 else []) last_check = load_last_check() changes_found = 0 + failures = [] - for law in MONITORED_LAWS: + for law in laws: law_name = law["name"] - law_id = law["law_id"] category = law["category"] - logger.info(f"확인 중: {law_name} ({law_id})") - - info = fetch_law_info(law_oc, law_id) - if not info: + # MST 자동 조회 (캐시 TTL 7일) + mst = lookup_current_mst(law_oc, law_name, category, cache_path=MST_CACHE_FILE) + if not mst: + failures.append({"name": law_name, "error": "MST 조회 실패"}) continue - # 시행일자 또는 공포일자로 변경 감지 - announce_date = info.get("공포일자", info.get("시행일자", "")) - prev_date = last_check.get(law_id, "") + logger.info(f"확인 중: {law_name} (MST={mst})") + + # XML 한 번에 다운로드 (정보 추출 + 파싱 겸용) + xml_text = _fetch_with_retry(fetch_law_text, law_oc, mst) + if not xml_text: + failures.append({"name": law_name, "error": "XML 다운로드 실패"}) + continue + + # XML에서 기본정보 추출 + try: + root = ET.fromstring(xml_text) + info_el = root.find(".//기본정보") + returned_name = (info_el.findtext("법령명_한글", "") or "").strip() if info_el else "" + except Exception: + failures.append({"name": law_name, "error": "XML 파싱 실패"}) + continue + + # 법령명 검증 + if law_name not in returned_name and returned_name not in law_name: + logger.warning(f"법령명 불일치: 요청='{law_name}' 응답='{returned_name}' — 스킵") + failures.append({"name": law_name, "error": f"법령명 불일치: {returned_name}"}) + continue + + # 공포일자로 변경 감지 + announce_date = (info_el.findtext("공포일자", "") or "").strip() if info_el else "" + prev_date = last_check.get(law_name, "") if announce_date and announce_date != prev_date: logger.info(f"변경 감지: {law_name} — 공포일자 {announce_date} (이전: {prev_date or '없음'})") - # 법령 본문 다운로드 - law_mst = info.get("법령MST", law_id) - text = fetch_law_text(law_oc, law_mst) - if text: - filepath = save_law_file(law_name, text) - import_to_devonthink(filepath, law_name, category) - changes_found += 1 + # XML 저장 + xml_path = save_law_file(law_name, xml_text) - last_check[law_id] = announce_date + # XML → MD 장 분할 + try: + parsed = parse_law_xml(str(xml_path)) + md_files = save_law_as_markdown(law_name, parsed, MD_OUTPUT_DIR) + import_law_to_devonthink(law_name, md_files, category) + changes_found += 1 + except Exception as e: + logger.error(f"법령 파싱/임포트 실패 [{law_name}]: {e}", exc_info=True) + failures.append({"name": law_name, "error": str(e)}) + continue + + last_check[law_name] = announce_date else: logger.debug(f"변경 없음: {law_name}") save_last_check(last_check) + # 실행 결과 기록 + run_result = { + "timestamp": datetime.now().isoformat(), + "total": len(laws), + "changes": changes_found, + "failures": failures, + } + atomic_write_json(DATA_DIR / "law_last_run.json", run_result) + if failures: + logger.warning(f"실패 {len(failures)}건: {[f['name'] for f in failures]}") + # ─── 외국 법령 (빈도 체크 후 실행) ─── us_count = fetch_us_osha(last_check) jp_count = fetch_jp_mhlw(last_check) @@ -395,4 +526,5 @@ def fetch_eu_osha(last_check: dict) -> int: if __name__ == "__main__": - run() + tier2 = "--include-tier2" in sys.argv + run(include_tier2=tier2) diff --git a/scripts/law_parser.py b/scripts/law_parser.py new file mode 100644 index 0000000..e996e94 --- /dev/null +++ b/scripts/law_parser.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +""" +법령 XML → Markdown 장 단위 분할 파서 +- law.go.kr XML 파싱 → 장/절 구조 식별 +- 장별 Markdown 파일 생성 (앵커 + 크로스 링크) +- 부칙 별도 파일 저장 +""" + +import re +import json +import os +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime, timedelta + +import sys +sys.path.insert(0, str(Path(__file__).parent)) +from pkm_utils import setup_logger + +logger = setup_logger("law_parser") + +# 법령 약칭 매핑 (조문 내 참조 → 정식명칭) +LAW_ALIASES = { + "산안법": "산업안전보건법", + "산업안전보건법": "산업안전보건법", + "중대재해법": "중대재해 처벌 등에 관한 법률", + "중대재해처벌법": "중대재해 처벌 등에 관한 법률", + "화관법": "화학물질관리법", + "위안법": "위험물안전관리법", + "고압가스법": "고압가스 안전관리법", + "건설기술진흥법": "건설기술 진흥법", + "산재보험법": "산업재해보상보험법", +} + + +def atomic_write_json(filepath: Path, data: dict): + """원자적 JSON 파일 쓰기 (경합 방지)""" + tmp = filepath.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(str(tmp), str(filepath)) + + +# --- XML 파싱 --- + +def parse_law_xml(xml_path: str) -> dict: + """XML 파싱 → 법령 구조 추출""" + tree = ET.parse(xml_path) + root = tree.getroot() + + # 기본정보 + info_el = root.find(".//기본정보") + info = { + "name": (info_el.findtext("법령명_한글", "") or "").strip(), + "law_id": (info_el.findtext("법령ID", "") or "").strip(), + "announce_date": (info_el.findtext("공포일자", "") or "").strip(), + "enforce_date": (info_el.findtext("시행일자", "") or "").strip(), + "ministry": (info_el.findtext("소관부처", "") or "").strip(), + "category": (info_el.findtext("법종구분", "") or "").strip(), + } + + # 조문 추출 + articles = [] + for el in root.findall(".//조문단위"): + kind = (el.findtext("조문여부", "") or "").strip() + num = (el.findtext("조문번호", "") or "").strip() + title = (el.findtext("조문제목", "") or "").strip() + content = (el.findtext("조문내용", "") or "").strip() + + # 항 추출 + paragraphs = [] + for p_el in el.findall("항"): + p_num = (p_el.findtext("항번호", "") or "").strip() + p_content = (p_el.findtext("항내용", "") or "").strip() + # 호 추출 + sub_items = [] + for h_el in p_el.findall("호"): + h_num = (h_el.findtext("호번호", "") or "").strip() + h_content = (h_el.findtext("호내용", "") or "").strip() + sub_items.append({"num": h_num, "content": h_content}) + paragraphs.append({"num": p_num, "content": p_content, "sub_items": sub_items}) + + articles.append({ + "kind": kind, + "num": num, + "title": title, + "content": content, + "paragraphs": paragraphs, + }) + + # 부칙 추출 + appendices = [] + for el in root.findall(".//부칙단위"): + date = (el.findtext("부칙공포일자", "") or "").strip() + num = (el.findtext("부칙공포번호", "") or "").strip() + content = (el.findtext("부칙내용", "") or "").strip() + appendices.append({"date": date, "num": num, "content": content}) + + return {"info": info, "articles": articles, "appendices": appendices} + + +# --- 장 분할 --- + +def split_by_chapter(articles: list) -> list[dict]: + """조문 목록을 장 단위로 그룹핑 + Returns: [{"chapter": "제1장 총칙", "sections": [...], "articles": [...]}] + """ + chapters = [] + current_chapter = {"chapter": "", "sections": [], "articles": []} + current_section = "" + + for article in articles: + content_stripped = article["content"].strip() + + if article["kind"] == "전문": + # 장/절/편 구분자 + if re.match(r"제\d+장", content_stripped): + # 새 장 시작 + if current_chapter["chapter"] or current_chapter["articles"]: + chapters.append(current_chapter) + current_chapter = {"chapter": content_stripped, "sections": [], "articles": []} + current_section = "" + elif re.match(r"제\d+절", content_stripped): + current_section = content_stripped + current_chapter["sections"].append(current_section) + elif re.match(r"제\d+편", content_stripped): + # 편은 장보다 상위 — 별도 처리 없이 장 파일 내 표시 + if current_chapter["articles"]: + chapters.append(current_chapter) + current_chapter = {"chapter": content_stripped, "sections": [], "articles": []} + current_section = "" + continue + + if article["kind"] == "조문": + article["_section"] = current_section + current_chapter["articles"].append(article) + + # 마지막 장 + if current_chapter["chapter"] or current_chapter["articles"]: + chapters.append(current_chapter) + + # 장이 없는 법령 (fallback) + if not chapters and articles: + chapters = [{"chapter": "", "sections": [], "articles": [ + a for a in articles if a["kind"] == "조문" + ]}] + + return chapters + + +# --- Markdown 변환 --- + +def _format_article_num(article: dict) -> str: + """조문번호 + 제목 → 앵커용 ID 생성""" + num = article["num"] + title = article["title"] + # "제38조" 또는 "제38조의2" 형태 추출 + content = article["content"] + match = re.match(r"(제\d+조(?:의\d+)*)\s*", content) + if match: + return match.group(1) + return f"제{num}조" + + +def article_to_markdown(article: dict) -> str: + """단일 조문 → Markdown""" + article_id = _format_article_num(article) + title = article["title"] + + # 제목 정리 (한자 괄호 등) + if title: + header = f"## {article_id} ({title})" + " {#" + article_id + "}" + else: + header = f"## {article_id}" + " {#" + article_id + "}" + + lines = [header] + + # 본문 내용 + content = article["content"].strip() + # 조문번호 접두사 제거 (예: "제38조 (안전조치)" → 본문만) + content = re.sub(r"^제\d+조(?:의\d+)*\s*(?:\([^)]*\))?\s*", "", content) + if content: + lines.append(content) + + # 항 + for p in article.get("paragraphs", []): + p_content = p["content"].strip() + if p_content: + lines.append(f"\n{p_content}") + for si in p.get("sub_items", []): + si_content = si["content"].strip() + if si_content: + lines.append(f" {si_content}") + + return "\n".join(lines) + + +def chapter_to_markdown(law_name: str, info: dict, chapter: dict) -> str: + """장 → Markdown 파일 내용""" + chapter_name = chapter["chapter"] or law_name + enforce = info.get("enforce_date", "") + if len(enforce) == 8: + enforce = f"{enforce[:4]}-{enforce[4:6]}-{enforce[6:]}" + ministry = info.get("ministry", "") + + lines = [ + f"# {chapter_name}", + f"> {law_name} | 시행 {enforce} | {ministry}", + "", + ] + + # 절 표시 + current_section = "" + for article in chapter["articles"]: + section = article.get("_section", "") + if section and section != current_section: + current_section = section + lines.append(f"\n### {section}\n") + + lines.append(article_to_markdown(article)) + lines.append("") + + return "\n".join(lines) + + +def info_to_markdown(info: dict) -> str: + """기본정보 → Markdown""" + enforce = info.get("enforce_date", "") + if len(enforce) == 8: + enforce = f"{enforce[:4]}-{enforce[4:6]}-{enforce[6:]}" + announce = info.get("announce_date", "") + if len(announce) == 8: + announce = f"{announce[:4]}-{announce[4:6]}-{announce[6:]}" + + return f"""# {info['name']} — 기본정보 + +| 항목 | 내용 | +|------|------| +| **법령명** | {info['name']} | +| **법령구분** | {info.get('category', '')} | +| **소관부처** | {info.get('ministry', '')} | +| **공포일자** | {announce} | +| **시행일자** | {enforce} | +| **법령ID** | {info.get('law_id', '')} | + +> 이 문서는 law.go.kr API에서 자동 생성되었습니다. +> 마지막 업데이트: {datetime.now().strftime('%Y-%m-%d')} +""" + + +def appendices_to_markdown(law_name: str, appendices: list) -> str: + """부칙 → Markdown""" + lines = [f"# {law_name} — 부칙", ""] + for ap in appendices: + date = ap["date"] + if len(date) == 8: + date = f"{date[:4]}-{date[4:6]}-{date[6:]}" + lines.append(f"## 부칙 (공포 {date}, 제{ap['num']}호)") + lines.append(ap["content"]) + lines.append("") + return "\n".join(lines) + + +# --- 크로스 링크 --- + +def add_internal_links(text: str, article_ids: set[str]) -> str: + """같은 법률 내 조문 참조 → Markdown 앵커 링크 + {#...} 앵커 내부와 이미 링크된 부분은 스킵 + """ + def replace_ref(m): + full = m.group(0) + article_ref = m.group(1) # "제38조" or "제38조의2" + if article_ref in article_ids: + return f"[{full}](#{article_ref})" + return full + + # {#...} 앵커와 [...](...) 링크 내부는 보호 + protected = re.sub(r'\{#[^}]+\}|\[[^\]]*\]\([^)]*\)', lambda m: '\x00' * len(m.group()), text) + # "제N조(의N)*" 패턴 매칭 (항/호 부분은 링크에 포함하지 않음) + pattern = r"(제\d+조(?:의\d+)*)(?:제\d+항)?(?:제\d+호)?" + result = [] + last = 0 + for m in re.finditer(pattern, protected): + result.append(text[last:m.start()]) + if '\x00' in protected[m.start():m.end()]: + result.append(text[m.start():m.end()]) # 보호 영역 — 원문 유지 + else: + orig = text[m.start():m.end()] + article_ref = re.match(r"(제\d+조(?:의\d+)*)", orig) + if article_ref and article_ref.group(1) in article_ids: + result.append(f"[{orig}](#{article_ref.group(1)})") + else: + result.append(orig) + last = m.end() + result.append(text[last:]) + return "".join(result) + + +def add_cross_law_links(text: str, law_name: str, article_chapter_map: dict) -> str: + """다른 법률 참조 → DEVONthink wiki-link + article_chapter_map: {법령명: {제X조: 파일명}} + """ + # 「법령명」 제X조 패턴 + def replace_cross_ref(m): + raw_name = m.group(1).strip() + article_ref = m.group(2) + + # 약칭 → 정식명칭 + resolved = LAW_ALIASES.get(raw_name, raw_name) + + if resolved == law_name: + return m.group(0) # 같은 법률이면 스킵 (내부 링크로 처리) + + # 장 매핑 조회 + law_map = article_chapter_map.get(resolved, {}) + chapter_file = law_map.get(article_ref) + if chapter_file: + return f"[[{chapter_file}#{article_ref}|{m.group(0)}]]" + return m.group(0) + + pattern = r"「([^」]+)」\s*(제\d+조(?:의\d+)*)" + return re.sub(pattern, replace_cross_ref, text) + + +# --- 파일 저장 --- + +def save_law_as_markdown(law_name: str, parsed: dict, output_dir: Path) -> list[Path]: + """파싱된 법령 → 장별 MD 파일 저장. 생성된 파일 경로 리스트 반환.""" + law_dir = output_dir / law_name.replace(" ", "_") + law_dir.mkdir(parents=True, exist_ok=True) + + info = parsed["info"] + chapters = split_by_chapter(parsed["articles"]) + files = [] + + # 기본정보 + info_path = law_dir / "00_기본정보.md" + info_path.write_text(info_to_markdown(info), encoding="utf-8") + files.append(info_path) + + # 같은 법률 내 조문 ID 수집 (내부 링크용) + all_article_ids = set() + for ch in chapters: + for a in ch["articles"]: + all_article_ids.add(_format_article_num(a)) + + # 장별 파일 + for i, chapter in enumerate(chapters, 1): + ch_name = chapter["chapter"] or law_name + # 파일명 안전화 + safe_name = re.sub(r"[·ㆍ\s]+", "_", ch_name) + safe_name = re.sub(r"[^\w가-힣]", "", safe_name) + filename = f"{safe_name}.md" + + md_content = chapter_to_markdown(law_name, info, chapter) + # 내부 링크 적용 + md_content = add_internal_links(md_content, all_article_ids) + + filepath = law_dir / filename + filepath.write_text(md_content, encoding="utf-8") + files.append(filepath) + + # 부칙 + if parsed["appendices"]: + ap_path = law_dir / "부칙.md" + ap_path.write_text(appendices_to_markdown(law_name, parsed["appendices"]), encoding="utf-8") + files.append(ap_path) + + logger.info(f"{law_name}: {len(files)}개 파일 생성 → {law_dir}") + return files + + +def build_article_chapter_map(law_name: str, parsed: dict) -> dict: + """조문→장 파일명 매핑 생성 (크로스 링크용) + Returns: {제X조: 파일명(확장자 없음)} + """ + chapters = split_by_chapter(parsed["articles"]) + mapping = {} + for chapter in chapters: + ch_name = chapter["chapter"] or law_name + safe_name = re.sub(r"[·ㆍ\s]+", "_", ch_name) + safe_name = re.sub(r"[^\w가-힣]", "", safe_name) + file_stem = f"{law_name.replace(' ', '_')}_{safe_name}" if chapter["chapter"] else law_name.replace(" ", "_") + + for article in chapter["articles"]: + article_id = _format_article_num(article) + mapping[article_id] = file_stem + + return mapping + + +# --- MST 캐시 --- + +def load_mst_cache(cache_path: Path) -> dict: + if cache_path.exists(): + with open(cache_path, "r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def save_mst_cache(cache_path: Path, data: dict): + atomic_write_json(cache_path, data) + + +def lookup_current_mst(law_oc: str, law_name: str, category: str = "법률", + cache_path: Path = None, cache_ttl_days: int = 7) -> str | None: + """법령명으로 현행 MST 검색 (캐시 TTL 적용) + - category → API 법령구분코드 매핑으로 검색 정확도 향상 + """ + import requests + + # 캐시 확인 + if cache_path: + cache = load_mst_cache(cache_path) + entry = cache.get(law_name) + if entry: + cached_at = datetime.fromisoformat(entry["cached_at"]) + if datetime.now() - cached_at < timedelta(days=cache_ttl_days): + return entry["mst"] + + try: + resp = requests.get("https://www.law.go.kr/DRF/lawSearch.do", params={ + "OC": law_oc, "target": "law", "type": "JSON", + "query": law_name, "display": "5", + }, timeout=15) + resp.raise_for_status() + data = resp.json().get("LawSearch", {}) + laws = data.get("law", []) + if isinstance(laws, dict): + laws = [laws] + + # 현행 필터 + 법령명 정확 매칭 + current = [l for l in laws + if l.get("현행연혁코드") == "현행" + and law_name in l.get("법령명한글", "")] + + if not current: + logger.warning(f"MST 검색 실패: {law_name} — 현행 법령 없음") + return None + + mst = current[0]["법령일련번호"] + + # 캐시 저장 + if cache_path: + cache = load_mst_cache(cache_path) + cache[law_name] = {"mst": mst, "cached_at": datetime.now().isoformat()} + save_mst_cache(cache_path, cache) + + return mst + except Exception as e: + logger.error(f"MST 조회 에러 [{law_name}]: {e}") + return None + + +if __name__ == "__main__": + # 단독 실행: XML 파일을 MD로 변환 + if len(sys.argv) < 2: + print("사용법: python3 law_parser.py [output_dir]") + sys.exit(1) + + xml_path = sys.argv[1] + output_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("data/laws/md") + + parsed = parse_law_xml(xml_path) + print(f"법령: {parsed['info']['name']}") + print(f"조문: {len(parsed['articles'])}개, 부칙: {len(parsed['appendices'])}개") + + files = save_law_as_markdown(parsed["info"]["name"], parsed, output_dir) + print(f"생성된 파일: {len(files)}개") + for f in files: + print(f" {f}") From c79e26e822722a75d745d19abb47c8d095875e80 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 15:05:07 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EB=B2=95=EB=A0=B9=20=EC=9E=84?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20/10=5FLegislation/Law/{=EB=B2=95=EB=A0=B9=EB=AA=85}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존: /10_Legislation/{법령명} (Law 폴더 누락) 수정: /10_Legislation/Law/{법령명} (architecture 설계 구조와 일치) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/law_monitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/law_monitor.py b/scripts/law_monitor.py index bc378aa..10f17b5 100644 --- a/scripts/law_monitor.py +++ b/scripts/law_monitor.py @@ -145,7 +145,7 @@ def import_law_to_devonthink(law_name: str, md_files: list[Path], category: str) 3단계 교체: 기존 폴더 이동 → 신규 생성 → 구 폴더 삭제 (wiki-link 끊김 최소화) """ safe_name = law_name.replace(" ", "_") - group_path = f"/10_Legislation/{safe_name}" + group_path = f"/10_Legislation/Law/{safe_name}" # 1단계: 기존 폴더 이동 (있으면) rename_script = ( @@ -193,7 +193,7 @@ def import_law_to_devonthink(law_name: str, md_files: list[Path], category: str) 'tell application id "DNtp"\n' ' repeat with db in databases\n' ' if name of db is "04_Industrial safety" then\n' - f' set oldGroup to get record at "/10_Legislation/{safe_name}_old" in db\n' + f' set oldGroup to get record at "/10_Legislation/Law/{safe_name}_old" in db\n' ' if oldGroup is not missing value then\n' ' delete record oldGroup\n' ' end if\n' From a4f8e566330cab515b63f1197e76b1dac488b018 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 15:28:36 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EB=B2=95=EB=A0=B9=20=ED=81=AC?= =?UTF-8?q?=EB=A1=9C=EC=8A=A4=20=EB=A7=81=ED=81=AC=202-pass=20+=20launchd?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20+=20RAG=20thinking=20=ED=95=84=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - law_monitor.py: 2-pass 크로스 링크 적용 - Pass 1: 전체 법령 파싱 + 조문-장 매핑 테이블 생성 - Pass 2: 「법령명」 제X조 → [[법명_제N장#제X조]] wiki-link 일괄 적용 - 변경된 법령에만 크로스 링크 적용 후 DEVONthink 임포트 - pkm_api_server.py: RAG 응답에 enable_thinking=false + strip_thinking 적용 - launchd: pkm-api(Flask), law-monitor(07:00), mailplus(07:00+18:00), digest(20:00) plist Co-Authored-By: Claude Opus 4.6 (1M context) --- launchd/net.hyungi.pkm-api.plist | 24 +++++++++++++++ scripts/law_monitor.py | 51 ++++++++++++++++++++++++++++++-- scripts/pkm_api_server.py | 7 +++-- 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 launchd/net.hyungi.pkm-api.plist diff --git a/launchd/net.hyungi.pkm-api.plist b/launchd/net.hyungi.pkm-api.plist new file mode 100644 index 0000000..44950af --- /dev/null +++ b/launchd/net.hyungi.pkm-api.plist @@ -0,0 +1,24 @@ + + + + + Label + net.hyungi.pkm-api + ProgramArguments + + /Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3 + /Users/hyungi/Documents/code/DEVONThink_my server/scripts/pkm_api_server.py + 9900 + + WorkingDirectory + /Users/hyungi/Documents/code/DEVONThink_my server + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/hyungi/Documents/code/DEVONThink_my server/logs/pkm-api.log + StandardErrorPath + /Users/hyungi/Documents/code/DEVONThink_my server/logs/pkm-api.error.log + + diff --git a/scripts/law_monitor.py b/scripts/law_monitor.py index 10f17b5..3cfa08b 100644 --- a/scripts/law_monitor.py +++ b/scripts/law_monitor.py @@ -237,6 +237,7 @@ def run(include_tier2: bool = False): last_check = load_last_check() changes_found = 0 failures = [] + parsed_laws = {} # 크로스 링크 2-pass용 for law in laws: law_name = law["name"] @@ -281,11 +282,17 @@ def run(include_tier2: bool = False): # XML 저장 xml_path = save_law_file(law_name, xml_text) - # XML → MD 장 분할 + # Pass 1: XML 파싱 + 장 분할 MD 저장 (내부 링크만) try: parsed = parse_law_xml(str(xml_path)) md_files = save_law_as_markdown(law_name, parsed, MD_OUTPUT_DIR) - import_law_to_devonthink(law_name, md_files, category) + # 크로스 링크용 매핑 수집 + parsed_laws[law_name] = { + "parsed": parsed, + "md_files": md_files, + "category": category, + "article_map": build_article_chapter_map(law_name, parsed), + } changes_found += 1 except Exception as e: logger.error(f"법령 파싱/임포트 실패 [{law_name}]: {e}", exc_info=True) @@ -294,7 +301,45 @@ def run(include_tier2: bool = False): last_check[law_name] = announce_date else: - logger.debug(f"변경 없음: {law_name}") + # 변경 없어도 기존 파싱 데이터로 매핑 수집 (크로스 링크용) + xml_path = LAWS_DIR / f"{law_name.replace(' ', '_').replace('/', '_')}_{datetime.now().strftime('%Y%m%d')}.xml" + if not xml_path.exists(): + # 오늘 날짜 파일이 없으면 가장 최근 파일 찾기 + candidates = sorted(LAWS_DIR.glob(f"{law_name.replace(' ', '_').replace('/', '_')}_*.xml")) + xml_path = candidates[-1] if candidates else None + if xml_path and xml_path.exists(): + try: + parsed = parse_law_xml(str(xml_path)) + parsed_laws[law_name] = { + "parsed": parsed, + "md_files": [], + "category": category, + "article_map": build_article_chapter_map(law_name, parsed), + } + except Exception: + pass + + # Pass 2: 크로스 링크 일괄 적용 (변경된 법령만) + if parsed_laws: + # 전체 조문-장 매핑 테이블 + global_article_map = {name: data["article_map"] for name, data in parsed_laws.items()} + changed_laws = {name: data for name, data in parsed_laws.items() if data["md_files"]} + + if changed_laws and len(global_article_map) > 1: + logger.info(f"크로스 링크 적용: {len(changed_laws)}개 법령, 매핑 {len(global_article_map)}개") + for law_name, data in changed_laws.items(): + for md_file in data["md_files"]: + if md_file.name == "00_기본정보.md" or md_file.name == "부칙.md": + continue + content = md_file.read_text(encoding="utf-8") + updated = add_cross_law_links(content, law_name, global_article_map) + if updated != content: + md_file.write_text(updated, encoding="utf-8") + + # DEVONthink 임포트 (크로스 링크 적용 후) + for law_name, data in changed_laws.items(): + if data["md_files"]: + import_law_to_devonthink(law_name, data["md_files"], data["category"]) save_last_check(last_check) diff --git a/scripts/pkm_api_server.py b/scripts/pkm_api_server.py index a820398..8393240 100644 --- a/scripts/pkm_api_server.py +++ b/scripts/pkm_api_server.py @@ -252,16 +252,19 @@ def _search_qdrant(vector: list[float], limit: int = 20) -> list[dict]: def _llm_generate(prompt: str) -> str: - """Mac Mini MLX로 답변 생성""" + """Mac Mini MLX로 답변 생성 (thinking 필터링 포함)""" import requests as req + from pkm_utils import strip_thinking resp = req.post("http://localhost:8800/v1/chat/completions", json={ "model": "mlx-community/Qwen3.5-35B-A3B-4bit", "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, "max_tokens": 2048, + "enable_thinking": False, }, timeout=120) resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"] + content = resp.json()["choices"][0]["message"]["content"] + return strip_thinking(content) @app.route('/rag/query', methods=['POST']) From dd0d7833f657ecab123f52045fb45a93b6148fa4 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 30 Mar 2026 16:41:19 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20DEVONthink=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=B0=B0=EC=B9=98=20=EC=9E=84=EB=B2=A0?= =?UTF-8?q?=EB=94=A9=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch_embed.py: 9,000+ 문서 배치 임베딩 - DB별 순차 처리, 500건씩 AppleScript 배치 텍스트 추출 - GPU bge-m3 배치 임베딩 (32건/호출) - Qdrant 배치 upsert (100건/호출) - --sync: 삭제된 문서 Qdrant 정리 (고아 포인트 제거) - --force: 전체 재임베딩 - --db: 특정 DB만 처리 - GPU 헬스체크 + Qdrant UUID 중복 스킵 - 페이로드: uuid, title, db_name, text_preview, embedded_at Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/batch_embed.py | 359 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 scripts/batch_embed.py diff --git a/scripts/batch_embed.py b/scripts/batch_embed.py new file mode 100644 index 0000000..c5910c9 --- /dev/null +++ b/scripts/batch_embed.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +DEVONthink 전체 문서 배치 임베딩 +- DB별 순차 처리, 500건씩 AppleScript 배치 텍스트 추출 +- GPU bge-m3 배치 임베딩 (32건/호출) +- Qdrant 배치 upsert (100건/호출) +- --sync: 삭제된 문서 Qdrant 정리 +- --force: 전체 재임베딩 +- --db: 특정 DB만 처리 + +사용법: + python3 batch_embed.py # 신규 문서만 + python3 batch_embed.py --sync # 신규 + 삭제 동기화 + python3 batch_embed.py --force # 전체 재임베딩 + python3 batch_embed.py --db "04_Industrial safety" +""" + +import argparse +import sys +import uuid as uuid_mod +import time +import requests +from pathlib import Path +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) +from pkm_utils import setup_logger, load_credentials, run_applescript_inline + +logger = setup_logger("batch_embed") + +QDRANT_URL = "http://localhost:6333" +COLLECTION = "pkm_documents" +EMBED_BATCH_SIZE = 32 +QDRANT_BATCH_SIZE = 100 +APPLESCRIPT_CHUNK = 500 + + +# --- GPU 헬스체크 --- + +def check_gpu_health(gpu_ip: str) -> bool: + """GPU bge-m3 API ping""" + try: + resp = requests.post( + f"http://{gpu_ip}:11434/api/embed", + json={"model": "bge-m3", "input": ["test"]}, + timeout=10, + ) + return resp.status_code == 200 + except Exception: + return False + + +# --- Qdrant --- + +def get_existing_uuids_from_qdrant() -> set[str]: + """Qdrant에 이미 저장된 UUID 집합 조회""" + uuids = set() + offset = None + while True: + body = {"limit": 1000, "with_payload": {"include": ["uuid"]}} + if offset: + body["offset"] = offset + resp = requests.post( + f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll", + json=body, timeout=30, + ) + resp.raise_for_status() + result = resp.json()["result"] + points = result.get("points", []) + for p in points: + uuid_val = p.get("payload", {}).get("uuid") + if uuid_val: + uuids.add(uuid_val) + offset = result.get("next_page_offset") + if not offset or not points: + break + return uuids + + +def delete_from_qdrant(point_ids: list[int]): + """Qdrant에서 포인트 삭제""" + if not point_ids: + return + resp = requests.post( + f"{QDRANT_URL}/collections/{COLLECTION}/points/delete", + json={"points": point_ids}, + timeout=30, + ) + resp.raise_for_status() + + +def uuid_to_point_id(doc_uuid: str) -> int: + return uuid_mod.uuid5(uuid_mod.NAMESPACE_URL, doc_uuid).int >> 64 + + +def store_batch_in_qdrant(docs: list[dict]): + """Qdrant 배치 upsert""" + if not docs: + return + points = [] + for doc in docs: + points.append({ + "id": uuid_to_point_id(doc["uuid"]), + "vector": doc["embedding"], + "payload": { + "uuid": doc["uuid"], + "title": doc["title"], + "db_name": doc.get("db_name", ""), + "text_preview": doc.get("text", "")[:200], + "source": "devonthink", + "embedded_at": datetime.now().isoformat(), + }, + }) + + for i in range(0, len(points), QDRANT_BATCH_SIZE): + batch = points[i:i + QDRANT_BATCH_SIZE] + resp = requests.put( + f"{QDRANT_URL}/collections/{COLLECTION}/points", + json={"points": batch}, + timeout=60, + ) + resp.raise_for_status() + + +# --- GPU 임베딩 --- + +def get_embeddings_batch(texts: list[str], gpu_ip: str) -> list[list[float]]: + """GPU bge-m3 배치 임베딩 (4000자 제한 — bge-m3 토큰 한도 고려)""" + truncated = [t[:4000] for t in texts] + resp = requests.post( + f"http://{gpu_ip}:11434/api/embed", + json={"model": "bge-m3", "input": truncated}, + timeout=120, + ) + resp.raise_for_status() + return resp.json().get("embeddings", []) + + +# --- DEVONthink 텍스트 추출 --- + +def get_db_names() -> list[str]: + """DEVONthink DB 이름 목록""" + script = ''' + tell application id "DNtp" + set dbNames to {} + repeat with db in databases + set end of dbNames to name of db + end repeat + set AppleScript's text item delimiters to linefeed + return dbNames as text + end tell + ''' + result = run_applescript_inline(script) + return [n.strip() for n in result.split("\n") if n.strip()] + + +def get_db_document_uuids(db_name: str) -> list[str]: + """특정 DB의 임베딩 대상 UUID 목록 (그룹 제외, 텍스트 10자 이상)""" + script = f''' + tell application id "DNtp" + set theDB to database "{db_name}" + set allDocs to contents of theDB + set output to {{}} + repeat with rec in allDocs + try + set recType to type of rec as string + if recType is not "group" then + set recText to plain text of rec + if length of recText > 10 then + set end of output to uuid of rec + end if + end if + end try + end repeat + set AppleScript's text item delimiters to linefeed + return output as text + end tell + ''' + try: + result = run_applescript_inline(script) + return [u.strip() for u in result.split("\n") if u.strip()] + except Exception as e: + logger.error(f"UUID 수집 실패 [{db_name}]: {e}") + return [] + + +def get_documents_batch(uuids: list[str]) -> list[dict]: + """UUID 리스트로 배치 텍스트 추출 (AppleScript 1회 호출)""" + if not uuids: + return [] + + # UUID를 AppleScript 리스트로 변환 + uuid_list = ", ".join(f'"{u}"' for u in uuids) + script = f''' + tell application id "DNtp" + set uuidList to {{{uuid_list}}} + set output to {{}} + repeat with u in uuidList + try + set theRecord to get record with uuid u + set recText to plain text of theRecord + set recTitle to name of theRecord + set recDB to name of database of theRecord + if length of recText > 8000 then + set recText to text 1 thru 8000 of recText + end if + set end of output to u & "|||" & recTitle & "|||" & recDB & "|||" & recText + on error + set end of output to u & "|||ERROR|||||||" + end try + end repeat + set AppleScript's text item delimiters to linefeed & "<<<>>>" & linefeed + return output as text + end tell + ''' + try: + result = run_applescript_inline(script) + except Exception as e: + logger.error(f"배치 텍스트 추출 실패: {e}") + return [] + + docs = [] + for entry in result.split("\n<<<>>>\n"): + entry = entry.strip() + if not entry or "|||ERROR|||" in entry: + continue + parts = entry.split("|||", 3) + if len(parts) >= 4: + text = parts[3].strip() + if len(text) >= 10: + docs.append({ + "uuid": parts[0].strip(), + "title": parts[1].strip(), + "db_name": parts[2].strip(), + "text": text, + }) + return docs + + +# --- 메인 배치 --- + +def run_batch(gpu_ip: str, target_db: str = None, force: bool = False, sync: bool = False): + """배치 임베딩 실행""" + + # GPU 헬스체크 + if not check_gpu_health(gpu_ip): + logger.error(f"GPU 서버 연결 실패 ({gpu_ip}) — 종료") + sys.exit(1) + logger.info(f"GPU 서버 연결 확인: {gpu_ip}") + + # 기존 임베딩 UUID 조회 + existing_uuids = set() + if not force: + existing_uuids = get_existing_uuids_from_qdrant() + logger.info(f"Qdrant 기존 임베딩: {len(existing_uuids)}건") + + # DB 목록 + db_names = [target_db] if target_db else get_db_names() + logger.info(f"처리 대상 DB: {db_names}") + + total_embedded = 0 + total_skipped = 0 + total_failed = 0 + all_dt_uuids = set() + + for db_name in db_names: + logger.info(f"--- DB: {db_name} ---") + + # UUID 수집 + uuids = get_db_document_uuids(db_name) + all_dt_uuids.update(uuids) + logger.info(f" 문서: {len(uuids)}건") + + # 기존 스킵 + if not force: + new_uuids = [u for u in uuids if u not in existing_uuids] + skipped = len(uuids) - len(new_uuids) + total_skipped += skipped + if skipped > 0: + logger.info(f" 스킵: {skipped}건 (이미 임베딩)") + uuids = new_uuids + + if not uuids: + continue + + # 500건씩 AppleScript 배치 텍스트 추출 + for chunk_start in range(0, len(uuids), APPLESCRIPT_CHUNK): + chunk_uuids = uuids[chunk_start:chunk_start + APPLESCRIPT_CHUNK] + docs = get_documents_batch(chunk_uuids) + + if not docs: + continue + + # 32건씩 GPU 임베딩 + for batch_start in range(0, len(docs), EMBED_BATCH_SIZE): + batch = docs[batch_start:batch_start + EMBED_BATCH_SIZE] + texts = [d["text"] for d in batch] + + try: + embeddings = get_embeddings_batch(texts, gpu_ip) + if len(embeddings) != len(batch): + logger.warning(f"임베딩 수 불일치: {len(embeddings)} != {len(batch)}") + total_failed += len(batch) + continue + + for doc, emb in zip(batch, embeddings): + doc["embedding"] = emb + + store_batch_in_qdrant(batch) + total_embedded += len(batch) + + except Exception as e: + logger.error(f"배치 임베딩 실패: {e}") + total_failed += len(batch) + + progress = chunk_start + len(chunk_uuids) + logger.info(f" 진행: {progress}/{len(uuids)}") + + # --sync: 고아 포인트 삭제 + orphan_deleted = 0 + if sync and all_dt_uuids: + orphan_uuids = existing_uuids - all_dt_uuids + if orphan_uuids: + orphan_ids = [uuid_to_point_id(u) for u in orphan_uuids] + delete_from_qdrant(orphan_ids) + orphan_deleted = len(orphan_uuids) + logger.info(f"고아 포인트 삭제: {orphan_deleted}건") + + # 통계 + logger.info("=== 배치 임베딩 완료 ===") + logger.info(f" 임베딩: {total_embedded}건") + logger.info(f" 스킵: {total_skipped}건") + logger.info(f" 실패: {total_failed}건") + if orphan_deleted: + logger.info(f" 고아 삭제: {orphan_deleted}건") + + # Qdrant 최종 카운트 + try: + resp = requests.get(f"{QDRANT_URL}/collections/{COLLECTION}", timeout=10) + count = resp.json()["result"]["points_count"] + logger.info(f" Qdrant 총 포인트: {count}건") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="DEVONthink 배치 임베딩") + parser.add_argument("--force", action="store_true", help="전체 재임베딩") + parser.add_argument("--sync", action="store_true", help="삭제 동기화 포함") + parser.add_argument("--db", type=str, help="특정 DB만 처리") + args = parser.parse_args() + + creds = load_credentials() + gpu_ip = creds.get("GPU_SERVER_IP") + if not gpu_ip: + logger.error("GPU_SERVER_IP 미설정") + sys.exit(1) + + run_batch(gpu_ip, target_db=args.db, force=args.force, sync=args.sync)