9 Commits

Author SHA1 Message Date
hyungi
dd0d7833f6 feat: DEVONthink 전체 문서 배치 임베딩 스크립트
- 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) <noreply@anthropic.com>
2026-03-30 16:41:19 +09:00
hyungi
a4f8e56633 feat: 법령 크로스 링크 2-pass + launchd 등록 + RAG thinking 필터
- 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) <noreply@anthropic.com>
2026-03-30 15:28:36 +09:00
hyungi
c79e26e822 fix: 법령 임포트 경로 수정 — /10_Legislation/Law/{법령명}
기존: /10_Legislation/{법령명} (Law 폴더 누락)
수정: /10_Legislation/Law/{법령명} (architecture 설계 구조와 일치)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:05:07 +09:00
hyungi
4b7ddf39c1 feat: 법령 모니터링 대폭 개선 — 장 단위 MD 분할 + 크로스 링크 + Tier 분리
- 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) <noreply@anthropic.com>
2026-03-30 15:00:28 +09:00
hyungi
dc3f03b421 fix: Phase 2 버그 픽스 — JP 번역, API 서버, AppleScript 경로
- pkm_utils.py: strip_thinking() 추가 + llm_generate() no_think 옵션
  - <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) <noreply@anthropic.com>
2026-03-30 14:00:46 +09:00
hyungi
f21f950c04 docs: architecture.md 대규모 갱신 — GPU 서버 재구성 반영
- 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) <noreply@anthropic.com>
2026-03-30 13:45:16 +09:00
hyungi
5db2f4f6fa feat: RAG 파이프라인 — pkm_api_server.py에 검색/임베딩 엔드포인트 추가
- 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) <noreply@anthropic.com>
2026-03-30 13:32:49 +09:00
hyungi
5fc23e0dbd feat: DEVONthink OCR 연동 — Surya OCR 전처리 + Smart Rule Step 0
- 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) <noreply@anthropic.com>
2026-03-30 13:31:22 +09:00
hyungi
45cabc9aea refactor: GPU 서버 재구성 + ChromaDB→Qdrant 마이그레이션
- 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) <noreply@anthropic.com>
2026-03-30 13:19:31 +09:00
20 changed files with 3530 additions and 536 deletions

165
CLAUDE.md
View File

@@ -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 프리픽스 또는 후처리 필수

114
README.md
View File

@@ -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` 참조

View File

@@ -1,27 +1,51 @@
-- DEVONthink 4 Smart Rule: AI 자동 분류
-- Inbox DB 새 문서 → Ollama 분류 → 태그 + 메타데이터 + 도메인 DB 이동
-- 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
-- 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 ocrPy to pkmRoot & "/scripts/ocr_preprocess.py"
try
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 & "' >> " & quoted form of logFile
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
-- 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
-- 문서 텍스트를 프롬프트에 삽입 (특수문자 이스케이프)
@@ -70,7 +94,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
@@ -88,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_chroma.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

View File

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

View File

@@ -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=

View File

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

View File

@@ -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: 프로젝트 구조 + 설계 문서 초기 커밋
```

View File

@@ -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=<Synology Chat 웹훅>
#GPU_SERVER_IP=<Tailscale 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'
```

399
docs/dev-roadmap.md Normal file
View File

@@ -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일 분량 |

460
docs/gpu-restructure.md Normal file
View File

@@ -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`로 정식 문서화 (아키텍처 결정 근거 기록으로 보존)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.hyungi.pkm-api</string>
<key>ProgramArguments</key>
<array>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/pkm_api_server.py</string>
<string>9900</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/pkm-api.log</string>
<key>StandardErrorPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/pkm-api.error.log</string>
</dict>
</plist>

View File

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

359
scripts/batch_embed.py Normal file
View File

@@ -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)

View File

@@ -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 <DEVONthink_UUID>")
sys.exit(1)
run(sys.argv[1])

114
scripts/embed_to_qdrant.py Normal file
View File

@@ -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 <DEVONthink_UUID>")
sys.exit(1)
run(sys.argv[1])

View File

@@ -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/Law/{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/Law/{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,127 @@ 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 = []
parsed_laws = {} # 크로스 링크 2-pass용
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
# Pass 1: XML 파싱 + 장 분할 MD 저장 (내부 링크만)
try:
parsed = parse_law_xml(str(xml_path))
md_files = save_law_as_markdown(law_name, parsed, MD_OUTPUT_DIR)
# 크로스 링크용 매핑 수집
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)
failures.append({"name": law_name, "error": str(e)})
continue
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)
# 실행 결과 기록
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)
@@ -315,11 +491,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
@@ -397,4 +571,5 @@ def fetch_eu_osha(last_check: dict) -> int:
if __name__ == "__main__":
run()
tier2 = "--include-tier2" in sys.argv
run(include_tier2=tier2)

471
scripts/law_parser.py Normal file
View File

@@ -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 <xml_path> [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}")

79
scripts/ocr_preprocess.py Normal file
View File

@@ -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 <DEVONthink_UUID>")
sys.exit(1)
result = run(sys.argv[1])
print(result)

390
scripts/pkm_api_server.py Normal file
View File

@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
PKM Host API Server
DEVONthink + OmniFocus AppleScript 중계 + RAG 검색 경량 HTTP 서버.
NanoClaw 컨테이너에서 호출. LaunchAgent(GUI 세션)로 실행 필수.
범위: 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__)
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:
# DB별 문서 수만 빠르게 조회 (children 순회 대신 count 사용)
script = (
'tell application id "DNtp"\n'
' set stats to {}\n'
' repeat with db in databases\n'
' set dbName to name of db\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'
'end tell'
)
result = run_applescript(script)
stats = {}
total = 0
if result:
for item in result.split('|'):
parts = item.split(':')
if len(parts) == 2:
count = int(parts[1])
stats[parts[0]] = {'count': count}
total += count
return jsonify(success=True, data={
'databases': stats,
'total_documents': total,
'database_count': len(stats),
})
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:
# 한글 쿼리 이스케이프 (따옴표, 백슬래시)
safe_q = q.replace('\\', '\\\\').replace('"', '\\"')
script = (
'tell application id "DNtp"\n'
f' set results to search "{safe_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
# --- 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로 답변 생성 (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()
content = resp.json()["choices"][0]["message"]["content"]
return strip_thinking(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', '/devonthink/embed', '/devonthink/embed-batch',
'/omnifocus/stats', '/omnifocus/overdue', '/omnifocus/today',
'/rag/query',
])
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)

View File

@@ -105,19 +105,40 @@ def run_applescript_inline(script: str) -> str:
raise RuntimeError("AppleScript 타임아웃 (인라인)")
def strip_thinking(text: str) -> str:
"""LLM thinking 출력 제거 — <think>...</think> 태그 및 thinking 패턴 필터링"""
import re
# <think>...</think> 태그 제거
text = re.sub(r'<think>[\s\S]*?</think>\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 객체 추출