diff --git a/docs/architecture-v2.md b/docs/architecture-v2.md new file mode 100644 index 0000000..4a06690 --- /dev/null +++ b/docs/architecture-v2.md @@ -0,0 +1,1151 @@ +# PKM 웹앱 아키텍처 v2 — DEVONthink 탈피, 자체 시스템 구축 + +## 1. 설계 배경 및 동기 + +### 왜 DEVONthink을 버리는가 + +v1 시스템(DEVONthink 중심)의 6단계 개발 과정에서 드러난 구조적 한계: + +- AppleScript가 유일한 프로그래밍 인터페이스. 16개 커밋 중 절반 이상이 AppleScript 실행 관련 버그 수정(POSIX path, f-string 이스케이프, -e 분할 등). +- DEVONthink의 실제 역할은 "파일 저장소 + Smart Rule 트리거"뿐. 분류/태그/요약/임베딩 등 지능은 전부 외부(MLX, GPU 서버)에 존재. +- macOS GUI 의존성으로 서버 이전 불가. OmniFocus도 동일한 문제. +- 13개 DB 관리의 복잡성, 웹 접근 불가, 모바일 접근 제한. + +### 설계 원칙 + +1. **macOS 의존성 제거** — AppleScript, DEVONthink, OmniFocus 전부 탈피 +2. **컨테이너 기반** — 모든 서비스를 Docker로. 서버 간 이동이 `docker compose up` 한 번 +3. **AI 추상화** — OpenAI 호환 API 인터페이스로 통일. MLX/vLLM/Ollama 무관 +4. **Synology 서비스 활용 극대화** — 직접 만들지 않아도 되는 것은 만들지 않는다 +5. **원본 단일 원칙** — 같은 정보가 두 곳에 존재하지 않는다 + +--- + +## 2. 기술 스택 + +| 영역 | 기술 | 선정 근거 | +|------|------|-----------| +| 백엔드 | FastAPI (Python) | 기존 Python 코드 1,500줄 재활용. 비동기 지원. OpenAPI 자동 문서화 | +| 데이터베이스 | PostgreSQL + pgvector | 메타데이터 + 전문검색 + 벡터 검색 통합. JSONB로 유연한 스키마 | +| 프론트엔드 | SvelteKit | 1인 개발에 적합한 낮은 보일러플레이트. 가벼운 빌드. SSR 지원 | +| 문서 파싱 | kordoc (Node.js) | HWP/HWPX/PDF → Markdown. MIT 라이선스. MCP 서버 지원 | +| 리버스 프록시 | Caddy | 자동 HTTPS, 설정 단순 | +| 인증 | JWT + TOTP 2FA | 1인 사용에 적합한 경량 인증. FastAPI 내장 | +| 검색 강화 | Meilisearch (선택) | 한국어 검색 품질 향상 필요시 Docker로 추가 | +| 컨테이너 | Docker Compose | 전 서비스 컨테이너화. 서버 이전 용이 | + +--- + +## 3. 인프라 역할 분담 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Mac mini M4 Pro (애플리케이션 서버) │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Docker Compose │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────┐ │ │ +│ │ │ FastAPI │ │ PostgreSQL │ │ kordoc-service │ │ │ +│ │ │ (백엔드) │ │ + pgvector │ │ (문서 파싱) │ │ │ +│ │ └─────┬─────┘ └────────────┘ └──────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────▼─────┐ ┌────────────┐ │ │ +│ │ │ SvelteKit │ │ Caddy │ │ │ +│ │ │ (프론트) │ │ (프록시) │ │ │ +│ │ └───────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────┐ ┌────────────────┐ │ +│ │ MLX Server │ │ Roon Core │ │ +│ │ Qwen3.5-35B-A3B │ │ (음악 서버) │ │ +│ │ localhost:8800 │ │ │ │ +│ └──────────────────────┘ └────────────────┘ │ +└───────────────────────────┬─────────────────────────────────┘ + │ Tailscale + ┌───────────────────┼───────────────────┐ +┌───────▼─────────────┐ ┌──────────▼──────────────┐ +│ Synology DS1525+ │ │ GPU 서버 (리눅스) │ +│ │ │ │ +│ ▸ 파일 원본 저장 │ │ ▸ nomic-embed-text │ +│ (Document_Server) │ │ (벡터 임베딩) │ +│ ▸ Synology Office │ │ ▸ Qwen2.5-VL-7B │ +│ (문서 편집/미리보기)│ │ (이미지/도면 OCR) │ +│ ▸ Synology Drive │ │ ▸ bge-reranker-v2-m3 │ +│ (파일 동기화) │ │ (RAG 리랭킹) │ +│ ▸ Synology Calendar │ │ ▸ Plex, Komga │ +│ (태스크 - CalDAV) │ │ │ +│ ▸ MailPlus │ │ │ +│ (이메일 + 알림) │ │ │ +│ ▸ Synology Chat │ │ │ +└─────────────────────┘ └──────────────────────────┘ +``` + +### Mac mini 역할 + +- FastAPI 웹앱 서버 (API + 프론트엔드 서빙) +- PostgreSQL (메타데이터, 전문검색 인덱스, 벡터 임베딩) +- kordoc 마이크로서비스 (HWP/PDF 텍스트 추출) +- AI 추론 (MLX Qwen3.5-35B-A3B, 분류/태그/요약) +- Caddy 리버스 프록시 (외부 접근 + 자동 HTTPS) +- Roon Core (음악, PKM과 무관) + +### Synology NAS 역할 + +- 파일 원본 저장소 (`/volume4/Document_Server/`) +- Synology Office → 문서 편집 및 미리보기 (DOCX/XLSX/PPTX 대체) +- Synology Drive → `Document_Server/` 전체 관리 + 버전 이력 + `Technicalkorea Document/` 동기화 +- Synology Calendar → 태스크/일정 관리 (CalDAV VTODO, OmniFocus 대체) +- MailPlus → 이메일 수집 (IMAP) + 알림 발송 (SMTP) + +### GPU 서버 역할 + +- **AI Gateway** (모델 라우팅, 폴백 처리, 비용 제어, 요청 로깅) +- 벡터 임베딩 (nomic-embed-text) +- 이미지/도면 OCR (Qwen2.5-VL-7B) +- RAG 리랭킹 (bge-reranker-v2-m3) +- NanoClaw (선택적, 대화형 PKM 인터페이스 — Synology Chat/Telegram 연동) +- Plex, Komga (미디어, PKM과 무관) + +--- + +## 4. NAS 파일 구조 + +``` +/volume4/ + ├── Document_Server/ ← Synology Drive 전체 관리 + │ ├── PKM/ ← FastAPI가 관리하는 영역 + │ │ ├── Inbox/ ← 미분류 자료 진입점 + │ │ ├── Archive/ ← 이메일, 채팅, 수신 문서 원본 + │ │ │ └── imports/ ← 변환 전 교환 형식 원본 (일정 기간 보관) + │ │ ├── Projects/ ← 진행 중 프로젝트 문서 + │ │ ├── Knowledge/ ← 도메인별 지식 (v1의 13개 DB 매핑) + │ │ │ ├── Philosophy/ + │ │ │ ├── Language/ + │ │ │ ├── Engineering/ + │ │ │ ├── Industrial_Safety/ + │ │ │ │ ├── Legislation/ ← 법령, 고시, 지침 + │ │ │ │ ├── Standards/ ← 안전보건 기준 + │ │ │ │ └── Cases/ ← 사고 사례, 판례 + │ │ │ ├── Programming/ + │ │ │ └── General/ + │ │ └── Reference/ ← 도면, 참고자료 + │ │ + │ └── DEVONThink/ ← 기존 WebDAV 폴더 (마이그레이션 후 정리) + │ + └── Technicalkorea Document/ ← Synology Drive 동기화 (회사 문서) + └── (회사 문서들) ← PKM이 읽기 전용으로 인덱싱 +``` + +### DEVONthink 13개 DB → 폴더 매핑 + +| v1 (DEVONthink DB) | v2 (NAS 폴더) | +|---------------------|----------------| +| Inbox | `PKM/Inbox/` | +| Archive | `PKM/Archive/` | +| Projects | `PKM/Projects/` | +| 00_Note_BOX | `PKM/Knowledge/` (루트의 노트 파일) | +| 01_Philosophie | `PKM/Knowledge/Philosophy/` | +| 02_Language | `PKM/Knowledge/Language/` | +| 03_Engineering | `PKM/Knowledge/Engineering/` | +| 04_Industrial safety | `PKM/Knowledge/Industrial_Safety/` | +| 05_Programming | `PKM/Knowledge/Programming/` | +| 07_General Book | `PKM/Knowledge/General/` | +| 97_Production drawing | `PKM/Reference/` | +| 99_Reference Data | `PKM/Reference/` | +| 99_Technicalkorea | `/volume4/Technicalkorea Document/` (별도 경로) | + +--- + +## 5. 문서 생애주기 및 "원본" 정의 + +### 문서 분류 체계 + +모든 문서는 성격에 따라 세 가지로 나뉜다: + +| 유형 | 정의 | 원본 = | 예시 | +|------|------|--------|------| +| **immutable** | 수신/생성 후 변경하지 않는 문서 | 수신한 그 파일 | PDF, 수신 HWP, 법령, 스캔 이미지, 계약서 | +| **editable** | 내가 편집하는 살아있는 문서 | Synology Office 포맷 (.odoc, .osheet) | 작성 중인 보고서, 스프레드시트 | +| **note** | 직접 작성하는 텍스트 | Markdown 파일 | 노트, 메모, 일지 | + +### 교환 형식 처리 원칙 + +`.docx`, `.xlsx`, `.pptx`는 **교환 형식**이지, 원본이 아니다. + +- **수신 시**: 교환 형식으로 들어오면 → Synology Office 포맷으로 변환 (이것이 원본이 됨) → 교환 형식 원본은 `Archive/imports/`에 일정 기간 보관 후 정리 +- **발신 시**: Synology Office 원본 → `.docx`/`.xlsx`로 내보내기 → 임시 파일로 생성, 다운로드 후 자동 삭제 +- **예외**: 외부에서 받은 문서를 편집 없이 보관만 해야 하는 경우 (예: 계약서 원본) → `immutable`로 분류, 원본 형식 그대로 보관 + +### 데이터 3계층 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1계층: 원본 파일 (NAS) │ +│ 위치: /volume4/Document_Server/PKM/ │ +│ 내용: 사용자가 보고 편집하는 실제 파일 │ +│ 관리: Synology Drive (버전 이력 자동) │ +│ 원칙: 포맷별 단일 원본만 존재 │ +└──────────────────────────┬──────────────────────────────┘ + │ 텍스트 추출 (kordoc 등) +┌──────────────────────────▼──────────────────────────────┐ +│ 2계층: 가공 데이터 (PostgreSQL) │ +│ 내용: │ +│ - extracted_text (Markdown 변환 텍스트) │ +│ - AI 메타데이터 (태그, 요약, 분류) │ +│ - 전문검색 인덱스 (GIN + pg_trgm) │ +│ 원칙: 원본에서 파생, 언제든 재생성 가능 │ +│ 버전 추적: extractor_version, ai_model_version │ +└──────────────────────────┬──────────────────────────────┘ + │ 벡터 변환 (GPU 서버) +┌──────────────────────────▼──────────────────────────────┐ +│ 3계층: 파생물 (pgvector + 캐시) │ +│ 내용: │ +│ - 벡터 임베딩 (vector(768)) │ +│ - 미리보기 캐시 (Markdown 렌더링 결과) │ +│ - 썸네일 (이미지/PDF 첫 페이지) │ +│ 원칙: 2계층에서 파생, 캐시 성격, 삭제해도 무관 │ +└─────────────────────────────────────────────────────────┘ +``` + +**핵심 원칙**: 1계층(NAS)이 삭제되면 복구 불가. 2~3계층은 1계층에서 언제든 재생성 가능. 따라서 백업 우선순위는 1계층 > 2계층 >> 3계층. + +--- + +## 6. 데이터 모델 (PostgreSQL 스키마) + +### documents 테이블 + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TYPE doc_type AS ENUM ('immutable', 'editable', 'note'); +CREATE TYPE source_channel AS ENUM ( + 'law_monitor', 'devonagent', 'email', 'web_clip', + 'tksafety', 'inbox_route', 'manual', 'drive_sync' +); +CREATE TYPE data_origin AS ENUM ('work', 'external'); + +CREATE TABLE documents ( + id BIGSERIAL PRIMARY KEY, + + -- 1계층: 원본 파일 참조 + file_path TEXT NOT NULL UNIQUE, -- NAS 상의 원본 경로 + file_hash CHAR(64) NOT NULL, -- SHA-256 (변경 감지 + 중복 방지) + file_format VARCHAR(20) NOT NULL, -- hwp, pdf, odoc, osheet, md, ... + file_size BIGINT, + file_type doc_type NOT NULL DEFAULT 'immutable', + import_source TEXT, -- 교환 형식 유입 기록 (nullable) + -- 예: "email:msg-id-xxx, 원본:보고서.docx" + + -- 2계층: 텍스트 추출 + extracted_text TEXT, -- Markdown 변환 전문 + extracted_at TIMESTAMPTZ, + extractor_version VARCHAR(50), -- "kordoc@1.7.1" + + -- 2계층: AI 가공 + ai_summary TEXT, -- AI 요약 (500자 이내) + ai_tags JSONB DEFAULT '[]', -- ["산업안전", "법령개정", ...] + ai_domain VARCHAR(100), -- Knowledge/Industrial_Safety + ai_sub_group VARCHAR(100), -- Legislation + ai_model_version VARCHAR(50), -- "qwen3.5-35b-a3b" + ai_processed_at TIMESTAMPTZ, + + -- 3계층: 벡터 임베딩 + embedding vector(768), -- nomic-embed-text 출력 + embed_model_version VARCHAR(50), -- "nomic-embed-text-v1.5" + embedded_at TIMESTAMPTZ, + + -- 메타데이터 + source_channel source_channel, + data_origin data_origin, + title TEXT, -- 문서 제목 (AI 추출 또는 파일명) + + -- 타임스탬프 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 전문검색 인덱스 +CREATE INDEX idx_documents_fts ON documents + USING GIN (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, ''))); + +-- 트리그램 인덱스 (한국어 부분 매칭) +CREATE INDEX idx_documents_trgm ON documents + USING GIN ((coalesce(title, '') || ' ' || coalesce(extracted_text, '')) gin_trgm_ops); + +-- 벡터 유사도 검색 인덱스 +CREATE INDEX idx_documents_embedding ON documents + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- 해시 기반 중복 검색 +CREATE INDEX idx_documents_hash ON documents (file_hash); + +-- 재가공 대상 필터링 +CREATE INDEX idx_documents_ai_version ON documents (ai_model_version); +CREATE INDEX idx_documents_extractor_version ON documents (extractor_version); +CREATE INDEX idx_documents_embed_version ON documents (embed_model_version); +``` + +### tasks 테이블 (CalDAV 캐시) + +```sql +-- Synology Calendar가 원본, 이 테이블은 웹 UI 표시용 캐시 +CREATE TABLE tasks ( + id BIGSERIAL PRIMARY KEY, + caldav_uid TEXT UNIQUE, -- CalDAV VTODO UID + title TEXT NOT NULL, + description TEXT, + due_date TIMESTAMPTZ, + priority SMALLINT DEFAULT 0, -- 0=없음, 1=높음, 5=중간, 9=낮음 + completed BOOLEAN DEFAULT FALSE, + completed_at TIMESTAMPTZ, + document_id BIGINT REFERENCES documents(id), -- 관련 문서 연결 + source VARCHAR(50), -- 'law_change', 'inbox_overflow', 'manual' + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### processing_queue 테이블 (비동기 가공 큐) + +```sql +CREATE TYPE process_stage AS ENUM ('extract', 'classify', 'embed'); +CREATE TYPE process_status AS ENUM ('pending', 'processing', 'completed', 'failed'); + +CREATE TABLE processing_queue ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT REFERENCES documents(id) NOT NULL, + stage process_stage NOT NULL, + status process_status DEFAULT 'pending', + attempts SMALLINT DEFAULT 0, + max_attempts SMALLINT DEFAULT 3, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + UNIQUE (document_id, stage, status) -- 같은 단계 중복 방지 +); + +CREATE INDEX idx_queue_pending ON processing_queue (stage, status) + WHERE status = 'pending'; +``` + +--- + +## 7. 문서 가공 파이프라인 + +### 전체 흐름 + +``` +파일 유입 + │ (이메일 첨부 / 웹 클립 / 법령 모니터 / 직접 업로드 / Drive 동기화) + │ + ▼ +① NAS에 원본 저장 + DB에 메타데이터 등록 + │ file_path, file_hash, file_format, source_channel + │ file_hash로 중복 검사 → 이미 있으면 가공 스킵 + │ + ▼ +② kordoc-service로 텍스트 추출 (비동기) + │ HWP/HWPX/PDF → Markdown + │ Synology Office 포맷 → Synology API로 텍스트 추출 + │ → DB.extracted_text 저장 + │ → DB.extractor_version 기록 + │ + ▼ +③ MLX로 AI 분류/태그/요약 (비동기) + │ extracted_text → Qwen3.5-35B-A3B + │ → DB.ai_summary, ai_tags, ai_domain, ai_sub_group 저장 + │ → DB.ai_model_version 기록 + │ → 결과에 따라 NAS 폴더 이동 (Inbox → Knowledge/...) + │ + ▼ +④ GPU 서버로 벡터 임베딩 (비동기) + │ extracted_text → nomic-embed-text + │ → DB.embedding 저장 + │ → DB.embed_model_version 기록 + │ + ▼ +⑤ 관련 태스크 생성 (조건부) + 법령 변경 → CalDAV VTODO 생성 + MailPlus 알림 + Inbox 적체(>3건) → 태스크 생성 +``` + +### 재가공 전략 + +모델 업그레이드 시 전체를 다시 돌리지 않는다: + +```sql +-- kordoc 업그레이드 시: 이전 버전으로 추출된 문서만 재처리 +INSERT INTO processing_queue (document_id, stage) +SELECT id, 'extract' FROM documents +WHERE extractor_version != 'kordoc@2.0.0' + AND extracted_text IS NOT NULL; + +-- AI 모델 변경 시: 텍스트 재추출 없이 분류만 재실행 +INSERT INTO processing_queue (document_id, stage) +SELECT id, 'classify' FROM documents +WHERE ai_model_version != 'qwen3.5-35b-a3b-v2' + AND extracted_text IS NOT NULL; + +-- 임베딩 모델 변경 시: 임베딩만 재생성 +INSERT INTO processing_queue (document_id, stage) +SELECT id, 'embed' FROM documents +WHERE embed_model_version != 'nomic-embed-text-v2' + AND extracted_text IS NOT NULL; +``` + +### 파일 변경 감지 + +편집 가능 문서(editable)는 Synology Office에서 수정될 수 있다. +FastAPI의 백그라운드 워커가 주기적으로(또는 Synology Drive webhook으로) 파일 해시를 비교: + +``` +file_hash 변경 감지 + → extracted_text 무효화 + → processing_queue에 extract → classify → embed 순서로 등록 + → 파이프라인 재실행 +``` + +--- + +## 8. kordoc 통합 + +### 마이크로서비스 구성 + +kordoc은 Node.js 기반이므로, FastAPI(Python)와 분리하여 Docker 컨테이너로 운영: + +```yaml +# docker-compose.yml (발췌) +kordoc-service: + build: + context: ./services/kordoc + dockerfile: Dockerfile + ports: + - "3100:3100" + volumes: + - /volume4/Document_Server:/documents:ro # NAS 마운트 (읽기 전용) + environment: + - MAX_FILE_SIZE=500MB + - TIMEOUT=30000 +``` + +### API 엔드포인트 (kordoc-service 내부) + +``` +POST /parse + body: { filePath: string } 또는 multipart/form-data + response: { markdown: string, metadata: object, format: string } + +POST /compare + body: { filePathA: string, filePathB: string } + response: { diffs: BlockDiff[] } + +POST /to-hwpx + body: { markdown: string } + response: Buffer (HWPX 파일) +``` + +### 지원 포맷별 처리 + +| 포맷 | 엔진 | 처리 방식 | +|------|------|-----------| +| HWP 5.x | kordoc (OLE2 + CFB) | 21종 제어문자 처리, zlib 압축해제 | +| HWPX | kordoc (ZIP + XML) | 매니페스트 파싱, 병합 셀, 중첩 테이블 | +| PDF | kordoc (pdfjs-dist) | 라인 그룹핑, 테이블 감지, 머리글/바닥글 필터링 | +| .odoc/.osheet | Synology Office API | 텍스트 추출 후 Markdown 변환 | +| Markdown | 직접 읽기 | 추가 변환 불필요 | +| 이미지/도면 | GPU 서버 (Qwen2.5-VL-7B) | OCR → 텍스트 추출 | + +--- + +## 9. UI 구성 + +### 3-패널 레이아웃 + +``` +┌─────────────────┬───────────────────────────────────────┐ +│ │ │ +│ 사이드바 (280px) │ 문서 뷰어 (가변) │ +│ │ │ +│ ┌─────────────┐│ 포맷별 렌더링: │ +│ │ 🔍 검색 ││ PDF → pdf.js 내장 뷰어 │ +│ └─────────────┘│ 검색어 하이라이팅, 페이지 점프 │ +│ │ DOCX등 → Synology Office iframe │ +│ ┌─────────────┐│ 편집 가능한 실시간 미리보기 │ +│ │ 📥 Inbox(3) ││ HWP → kordoc Markdown 렌더링 │ +│ │ 📁 폴더 탐색 ││ 테이블/이미지 포함 │ +│ │ 🏷️ 태그 트리 ││ MD → 렌더링 뷰 + 편집 토글 │ +│ │ 🕐 최근 문서 ││ 코드 하이라이팅, KaTeX, Mermaid│ +│ │ ⭐ 즐겨찾기 ││ 이미지 → 직접 렌더링 │ +│ └─────────────┘│ │ +│ ├───────────────────────────────────────┤ +│ │ 메타데이터 패널 │ +│ ┌─────────────┐│ │ +│ │ 📋 오늘 할일 ││ 태그 | 분류 | AI 요약 │ +│ │ 📊 통계 ││ 관련 문서 5건 (pgvector 유사도) │ +│ └─────────────┘│ 유입 경로 | 원본 형식 | 가공 이력 │ +│ │ [Synology에서 열기] [내보내기] [편집] │ +└─────────────────┴───────────────────────────────────────┘ +``` + +### DEVONthink 대비 개선 포인트 + +| 기능 | DEVONthink | PKM 웹앱 | +|------|-----------|----------| +| HWP 미리보기 | Quick Look (불안정) | kordoc → Markdown 렌더링 (테이블/이미지 포함) | +| Office 편집 | 외부 앱 실행 필요 | Synology Office iframe 내 편집 | +| AI 요약 | 없음 | 문서 열 때 자동 표시 | +| 관련 문서 | See Also (불투명한 알고리즘) | pgvector 코사인 유사도 (투명, 조정 가능) | +| 검색 하이라이팅 | 제한적 | pdf.js / Markdown 렌더러에서 정확한 위치 점프 | +| 법령 비교 | 없음 | kordoc compare → 조항별 diff 표시 | +| 접근성 | macOS만 | 웹 브라우저 어디서든 | +| 모바일 | DEVONthink To Go (유료) | 반응형 웹 UI | + +### 대시보드 위젯 + +로그인 시 첫 화면: + +- **오늘의 태스크**: Synology Calendar에서 CalDAV로 가져온 VTODO 목록 +- **Inbox 미분류**: 분류 대기 중인 문서 수 + 빠른 분류 UI +- **법령 변경 알림**: 최근 감지된 법령 변경사항 +- **최근 문서**: 최근 추가/수정된 문서 5건 +- **시스템 상태**: 각 서비스 가동 상태 (FastAPI, kordoc, MLX, GPU, NAS) + +--- + +## 10. 인증 및 외부 접근 + +### 구조 + +``` +인터넷 → Caddy (Mac mini, 자동 HTTPS) + │ + ├── pkm.도메인.net → FastAPI (JWT 인증 필요) + ├── office.도메인.net → Synology Office (프록시) + └── cal.도메인.net → Synology Calendar (프록시) +``` + +### 인증 방식: JWT + TOTP 2FA + +1인 사용 시스템이므로 경량하게 구현: + +- 로그인: 아이디/비밀번호 → TOTP 2단계 인증 → JWT 발급 +- JWT: Access Token (15분) + Refresh Token (7일) +- 비밀번호: bcrypt 해싱, DB 저장 +- TOTP: Vaultwarden에 시드 저장, 앱(Google Authenticator 등)으로 생성 +- Rate Limiting: 로그인 시도 5회 실패 시 15분 잠금 + +### Caddy 설정 예시 + +``` +pkm.hyungi.net { + reverse_proxy localhost:8000 # FastAPI +} + +# Synology Office는 NAS로 프록시 +office.hyungi.net { + reverse_proxy https://ds1525.hyungi.net:5001 { + header_up Host {upstream_hostport} + } +} +``` + +--- + +## 11. AI Gateway 및 모델 전략 + +### 설계 원칙: Qwen3.5 35B 우선, 외부 API는 최후 수단 + +모든 자동화 파이프라인은 **로컬 모델(Qwen3.5-35B-A3B)을 기본**으로 사용한다. +Claude API(종량제)는 로컬 모델로 처리 불가능한 복잡한 분석에만 제한적으로 사용한다. +이를 통해 운영 비용을 0원에 가깝게 유지하면서, 필요시에만 외부 API 비용이 발생하는 구조를 만든다. + +> **참고: Claude 구독 vs API 인증** +> Anthropic은 2026년 1월부터 구독(Pro/Max) OAuth 토큰의 서드파티 사용을 약관상 금지했다. +> 자동화 파이프라인에서 Claude를 사용하려면 반드시 **API 키(종량제)**를 사용해야 한다. +> 개인 실험 목적의 NanoClaw 등은 현재 작동하지만 회색 지대이므로, +> PKM 핵심 파이프라인은 이에 의존하지 않는다. + +### AI Gateway (GPU 서버) + +GPU 서버에 AI 중계 서버(Gateway)를 두어, 모든 AI 요청을 단일 진입점으로 관리한다. +FastAPI에서 직접 각 모델 엔드포인트를 호출하는 대신, Gateway가 라우팅/폴백/로깅을 담당한다. + +``` +FastAPI (Mac mini) + │ + ▼ +AI Gateway (GPU 서버, OpenAI 호환 API) + │ + ├── /v1/chat/completions (텍스트 생성) + │ ├── 1순위: Mac mini MLX → Qwen3.5-35B-A3B (상시, 무료) + │ ├── 2순위: GPU Ollama → Qwen3.5 또는 동급 모델 (MLX 장애 시 폴백) + │ └── 3순위: Claude API → claude-sonnet (복잡한 분석 전용, 종량제) + │ + ├── /v1/embeddings (벡터 임베딩) + │ └── GPU Ollama → nomic-embed-text (로컬, 무료) + │ + ├── /v1/vision (OCR/비전) + │ └── GPU Ollama → Qwen2.5-VL-7B (로컬, 무료) + │ + └── /v1/rerank (리랭킹) + └── GPU Ollama → bge-reranker-v2-m3 (로컬, 무료) +``` + +### 모델 라우팅 규칙 + +| 작업 | 모델 | 위치 | 비용 | 비고 | +|------|------|------|------|------| +| 문서 분류/태그 | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | **기본값**, 전체 처리량의 95%+ | +| 문서 요약 (4,000자 이내) | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | 대부분의 문서 커버 | +| 문서 요약 (장문/다국어) | Claude Sonnet | API | 종량제 | 로컬 모델 한계 초과 시만 | +| 법령 비교/분석 | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | 우선 시도, 품질 미달 시 Claude | +| 벡터 임베딩 | nomic-embed-text | GPU 서버 | 무료 | GPU 전용 | +| 이미지/도면 OCR | Qwen2.5-VL-7B | GPU 서버 | 무료 | GPU 전용 | +| RAG 리랭킹 | bge-reranker-v2-m3 | GPU 서버 | 무료 | GPU 전용 | +| 복잡한 분석 (장문, 전문가 수준) | Claude Sonnet/Opus | API | 종량제 | 수동 트리거 또는 명시적 요청 | + +### 폴백 체인 및 비용 제어 + +```python +# config.yaml +ai: + gateway: + endpoint: "http://gpu-server:8080" # AI Gateway 단일 진입점 + + models: + primary: # 기본 모델 (자동화 전체) + endpoint: "http://localhost:8800/v1/chat/completions" # Mac mini MLX + model: "mlx-community/Qwen3.5-35B-A3B-4bit" + max_tokens: 4096 + timeout: 60 + + fallback: # MLX 장애 시 폴백 + endpoint: "http://gpu-server:11434/v1/chat/completions" # GPU Ollama + model: "qwen3.5:35b-a3b" + max_tokens: 4096 + timeout: 120 + + premium: # 복잡한 분석 전용 (수동 트리거) + endpoint: "https://api.anthropic.com/v1/messages" + model: "claude-sonnet-4-20250514" + api_key: "${CLAUDE_API_KEY}" + max_tokens: 8192 + # 비용 제어 + daily_budget_usd: 5.00 # 일일 한도 + require_explicit_trigger: true # 자동 파이프라인에서 자동 호출 금지 + + embedding: + endpoint: "http://gpu-server:11434/api/embeddings" + model: "nomic-embed-text" + + vision: + endpoint: "http://gpu-server:11434/api/generate" + model: "Qwen2.5-VL-7B" + + rerank: + endpoint: "http://gpu-server:11434/api/rerank" + model: "bge-reranker-v2-m3" +``` + +### AI Gateway 구현 + +GPU 서버에 경량 프록시 서비스를 Docker로 운영: + +```yaml +# GPU 서버 docker-compose.yml +ai-gateway: + build: ./services/ai-gateway + ports: + - "8080:8080" + environment: + - PRIMARY_ENDPOINT=http://mac-mini:8800/v1/chat/completions + - FALLBACK_ENDPOINT=http://localhost:11434/v1/chat/completions + - CLAUDE_API_KEY=${CLAUDE_API_KEY} + - DAILY_BUDGET_USD=5.00 + restart: unless-stopped +``` + +Gateway의 핵심 기능: + +- **자동 폴백**: Primary(MLX) 실패/타임아웃 시 Fallback(GPU Ollama)으로 전환 +- **비용 제어**: Claude API 호출 시 일일 예산 확인, 초과 시 거부 +- **요청 로깅**: 모든 AI 요청의 모델/토큰수/응답시간/비용 기록 +- **헬스체크**: 각 모델 엔드포인트의 가용성 주기적 확인 +- **라우팅 태그**: 요청에 `x-model-tier: primary|fallback|premium` 태그로 명시적 라우팅 가능 + +### AI 추상화 레이어 (FastAPI 측) + +```python +# ai/client.py — 통합 클라이언트 +class AIClient: + """AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5.""" + + def __init__(self, config: dict): + self.gateway = config["gateway"]["endpoint"] + self.models = config["models"] + + async def classify(self, text: str) -> dict: + """문서 분류 — 항상 primary(Qwen3.5) 사용""" + return await self._call(self.models["primary"], prompt=CLASSIFY_PROMPT.format(text=text)) + + async def summarize(self, text: str, force_premium: bool = False) -> str: + """문서 요약 — 기본 Qwen3.5, 장문이거나 명시적 요청 시만 Claude""" + if force_premium or len(text) > 15000: + return await self._call(self.models["premium"], prompt=SUMMARIZE_PROMPT.format(text=text)) + return await self._call(self.models["primary"], prompt=SUMMARIZE_PROMPT.format(text=text)) + + async def embed(self, text: str) -> list[float]: + """벡터 임베딩 — GPU 서버 전용""" + return await self._call_embed(self.models["embedding"], text=text) + + async def ocr(self, image_bytes: bytes) -> str: + """이미지 OCR — GPU 서버 전용""" + return await self._call_vision(self.models["vision"], image=image_bytes) + + async def _call(self, model_config: dict, prompt: str) -> str: + """OpenAI 호환 API 호출 + 자동 폴백""" + try: + return await self._request(model_config, prompt) + except (TimeoutError, ConnectionError): + # Primary 실패 시 Fallback 시도 + if model_config == self.models["primary"]: + return await self._request(self.models["fallback"], prompt) + raise +``` + +### NanoClaw 통합 (선택적 확장) + +NanoClaw는 PKM 핵심 파이프라인이 아닌, **대화형 인터페이스**로서 선택적으로 통합: + +``` +NanoClaw (GPU 서버, Docker) + │ + ├── Synology Chat / Telegram 연동 + │ → "지난주 법령 변경사항 알려줘" → PKM API 호출 → 답변 + │ → "이 문서 분류해줘" (파일 전송) → 가공 파이프라인 트리거 + │ + ├── 인증: Claude API 키 (종량제) — 구독 OAuth 의존 안 함 + │ + └── 스케줄링: 일일 다이제스트를 채팅으로 전송 (MailPlus 보완) +``` + +NanoClaw의 `ANTHROPIC_BASE_URL`을 AI Gateway로 설정하면, +NanoClaw도 동일한 라우팅 규칙(Qwen3.5 우선)을 따르게 된다: + +```env +# NanoClaw .env +ANTHROPIC_BASE_URL=http://localhost:8080/v1 # AI Gateway +ANTHROPIC_AUTH_TOKEN=${CLAUDE_API_KEY} # API 키 (구독 OAuth 아님) +``` + +### 장기 전환 시 변경 사항 + +| 전환 | config.yaml 변경 | 코드 수정 | +|------|------------------|-----------| +| MLX → GPU vLLM (Mac mini 퇴역) | primary.endpoint URL 변경 | 없음 | +| Qwen3.5 → 더 좋은 모델 | primary.model 변경 | 없음 | +| GPU 확장 (RTX Pro 6000) | 더 큰 모델 추가, 라우팅 규칙 조정 | 없음 | +| Claude API 불필요해질 때 | premium 섹션 제거 | 없음 | +| AI Gateway를 NAS로 이전 | gateway.endpoint URL 변경 | 없음 | + +--- + +## 12. Synology 서비스 연동 + +### Synology Calendar (CalDAV) + +OmniFocus를 대체하는 태스크 관리: + +```python +# caldav 라이브러리로 연동 +import caldav + +client = caldav.DAVClient( + url="https://ds1525.hyungi.net/caldav/", + username="hyungi", + password=CALDAV_PASSWORD +) + +# 태스크 생성 (법령 변경 감지 시) +calendar = client.calendar(name="PKM") +calendar.save_todo( + summary="[법령검토] 산업안전보건법 시행규칙 개정안 확인", + description="2026-04-01 관보 게재. 변경 내용 검토 필요.", + priority=1, # 높음 + due=datetime.now() + timedelta(days=3) +) +``` + +### MailPlus (IMAP 수집 + SMTP 알림) + +기존 mailplus_archive.py에서 AppleScript 의존성만 제거: + +```python +# 수집: 기존 IMAP 로직 유지 (이미 AppleScript 미사용) +# 알림: SMTP 발송 추가 +import smtplib +from email.mime.text import MIMEText + +def send_notification(subject: str, body: str): + msg = MIMEText(body, 'html') + msg['Subject'] = subject + msg['From'] = 'pkm@hyungi.net' + msg['To'] = 'hyungi@hyungi.net' + + with smtplib.SMTP_SSL(MAILPLUS_HOST, 465) as server: + server.login(MAILPLUS_USER, MAILPLUS_PASS) + server.send_message(msg) +``` + +### Synology Office (문서 뷰어) + +웹앱에서 문서 클릭 시 파일 유형에 따라 분기: + +```typescript +// SvelteKit 프론트엔드 +function openDocument(doc: Document) { + switch (doc.file_format) { + case 'odoc': + case 'osheet': + case 'odp': + // Synology Office iframe으로 열기 + viewerUrl = `https://office.hyungi.net/oo/r/${doc.synology_file_id}`; + break; + case 'pdf': + // pdf.js 내장 뷰어 + viewerUrl = `/viewer/pdf?path=${doc.file_path}`; + break; + case 'hwp': + case 'hwpx': + // kordoc 변환된 Markdown 렌더링 + viewerContent = doc.extracted_text; + viewerMode = 'markdown'; + break; + case 'md': + // Markdown 렌더링 + 편집 토글 + viewerContent = doc.extracted_text; + viewerMode = 'markdown-editable'; + break; + default: + // 이미지 직접 렌더링 또는 다운로드 + viewerUrl = `/api/files/${doc.id}/raw`; + } +} +``` + +--- + +## 13. v1 코드 재활용 계획 + +### 재활용 가능 (70%+) + +| v1 파일 | v2 용도 | 변경 사항 | +|---------|---------|-----------| +| `scripts/pkm_utils.py` | `app/core/utils.py` | `run_applescript()` 제거, 나머지 유지 | +| `scripts/law_monitor.py` | `app/workers/law_monitor.py` | AppleScript → FastAPI DB 직접 저장 | +| `scripts/mailplus_archive.py` | `app/workers/mailplus_archive.py` | AppleScript → DB 저장 + SMTP 알림 추가 | +| `scripts/pkm_daily_digest.py` | `app/workers/daily_digest.py` | OmniFocus 쿼리 → CalDAV 쿼리, AppleScript → DB 쿼리 | +| `scripts/prompts/classify_document.txt` | `app/prompts/classify.txt` | 그대로 사용 | +| `scripts/embed_to_chroma.py` | `app/workers/embed_worker.py` | ChromaDB → pgvector | + +### 폐기 + +| v1 파일 | 이유 | +|---------|------| +| `applescript/auto_classify.scpt` | AppleScript 전체 폐기 | +| `applescript/omnifocus_sync.scpt` | OmniFocus → CalDAV로 대체 | +| `launchd/*.plist` | Docker 내부 스케줄러(APScheduler)로 대체 | + +--- + +## 14. 프로젝트 구조 (v2) + +``` +pkm-web/ +├── docker-compose.yml ← 전체 서비스 정의 +├── config.yaml ← AI 엔드포인트, NAS 경로 등 +├── credentials.env ← 인증 정보 (.gitignore) +│ +├── app/ ← FastAPI 백엔드 +│ ├── main.py ← FastAPI 앱 엔트리포인트 +│ ├── core/ +│ │ ├── config.py ← 설정 로딩 +│ │ ├── database.py ← PostgreSQL 연결 +│ │ ├── auth.py ← JWT + TOTP 인증 +│ │ └── utils.py ← 공통 유틸리티 +│ ├── models/ +│ │ ├── document.py ← documents 테이블 ORM +│ │ ├── task.py ← tasks 테이블 ORM +│ │ └── queue.py ← processing_queue ORM +│ ├── api/ +│ │ ├── documents.py ← 문서 CRUD API +│ │ ├── search.py ← 전문검색 + 벡터검색 API +│ │ ├── tasks.py ← CalDAV 연동 태스크 API +│ │ ├── dashboard.py ← 대시보드 데이터 API +│ │ └── export.py ← 내보내기 (Markdown → DOCX/HWPX) +│ ├── workers/ +│ │ ├── file_watcher.py ← NAS 파일 변경 감지 +│ │ ├── extract_worker.py ← kordoc 호출, 텍스트 추출 +│ │ ├── classify_worker.py ← AI 분류/태그/요약 +│ │ ├── embed_worker.py ← 벡터 임베딩 +│ │ ├── law_monitor.py ← 법령 변경 모니터링 +│ │ ├── mailplus_archive.py ← 이메일 수집 +│ │ └── daily_digest.py ← 일일 다이제스트 +│ ├── prompts/ +│ │ └── classify.txt ← AI 분류 프롬프트 +│ └── ai/ +│ └── client.py ← AI 추상화 레이어 +│ +├── services/ +│ └── kordoc/ +│ ├── Dockerfile ← kordoc Node.js 컨테이너 +│ ├── package.json +│ └── server.js ← HTTP API 래퍼 +│ +├── frontend/ ← SvelteKit 프론트엔드 +│ ├── src/ +│ │ ├── routes/ +│ │ │ ├── +page.svelte ← 대시보드 (메인) +│ │ │ ├── documents/ ← 문서 탐색/검색/뷰어 +│ │ │ ├── inbox/ ← Inbox 분류 UI +│ │ │ └── settings/ ← 설정 +│ │ ├── lib/ +│ │ │ ├── components/ ← 공통 컴포넌트 +│ │ │ │ ├── DocumentViewer.svelte +│ │ │ │ ├── MetadataPanel.svelte +│ │ │ │ ├── Sidebar.svelte +│ │ │ │ └── TaskWidget.svelte +│ │ │ └── stores/ ← 상태 관리 +│ │ └── app.html +│ └── package.json +│ +├── migrations/ ← PostgreSQL 마이그레이션 +│ └── 001_initial_schema.sql +│ +├── scripts/ +│ └── migrate_from_devonthink.py ← v1 → v2 마이그레이션 스크립트 +│ +├── docs/ +│ ├── architecture-v2.md ← 이 문서 +│ └── deploy.md ← 배포 가이드 +│ +└── tests/ + ├── test_api.py + ├── test_classify.py + └── test_pipeline.py +``` + +--- + +## 15. 마이그레이션 전략 (v1 → v2) + +### 단계별 전환 + +기존 DEVONthink 시스템을 운영하면서 점진적으로 전환: + +**Phase 0: 기반 구축 (1~2주)** +- Docker Compose 구성 (FastAPI + PostgreSQL + kordoc + Caddy) +- DB 스키마 생성 +- 인증 시스템 구현 +- NAS SMB 마운트 확인 + +**Phase 1: 데이터 마이그레이션 (1~2주)** +- DEVONthink 13개 DB에서 파일 목록 + 메타데이터 추출 +- NAS의 `Document_Server/PKM/` 폴더 구조 생성 +- 파일 복사 + DB 등록 스크립트 (`migrate_from_devonthink.py`) +- kordoc으로 전문 텍스트 추출 (배치) +- AI 분류 재실행 (배치) +- 벡터 임베딩 생성 (배치) + +**Phase 2: 핵심 기능 구현 (2~3주)** +- 문서 검색 API + UI +- 문서 뷰어 (pdf.js, Markdown, Synology Office) +- Inbox 자동 분류 파이프라인 +- 파일 변경 감지 + 재가공 + +**Phase 3: 자동화 이전 (1~2주)** +- law_monitor → FastAPI 워커로 이전 +- mailplus_archive → FastAPI 워커로 이전 +- daily_digest → FastAPI 워커로 이전 + MailPlus 알림 +- CalDAV 태스크 연동 + +**Phase 4: UI 완성 + 최적화 (2~3주)** +- 대시보드 위젯 +- 태그/폴더 탐색 UI +- 메타데이터 패널 (AI 요약, 관련 문서) +- 반응형 모바일 대응 +- 성능 최적화 (캐싱, 인덱스 튜닝) + +**Phase 5: DEVONthink 퇴역** +- 2주간 병행 운영 후 DEVONthink 완전 종료 +- NAS의 `Document_Server/DEVONThink/` 폴더 아카이브 후 정리 + +### 예상 소요: 약 8~12주 + +--- + +## 16. 장기 로드맵 + +### 서버 전환 계획 + +``` +현재 (2026): + Mac mini → Docker (FastAPI, PostgreSQL, kordoc, Caddy) + MLX + Roon Core + GPU 서버 → 임베딩, OCR, 리랭킹 + Plex, Komga + Synology → 파일 저장, Office, Calendar, MailPlus, Drive + +중기 (GPU 서버 확장 시): + Mac mini → Roon Core 전용 + GPU 서버 → Docker 전체 이전 + AI 추론 통합 (vLLM/Ollama CUDA) + config.yaml의 endpoint URL만 변경 + Synology → 변동 없음 + +장기 (Mac mini 퇴역 시): + GPU 서버 → 모든 컴퓨팅 (또는 Roon도 Linux 지원 시 통합) + Synology → 변동 없음 (교체 시에도 Synology 유지) + +궁극 (Synology GPU 지원 시): + Synology 단일 서버 가능성 (파일 + 컴퓨팅 + AI 통합) +``` + +### 전환 시 변경 사항 + +| 전환 | 변경 | 코드 수정 | +|------|------|-----------| +| Mac mini → GPU 서버 | docker-compose.yml 이동, config.yaml 수정 | 없음 | +| MLX → vLLM/Ollama | config.yaml의 endpoint URL 변경 | 없음 | +| GPU 확장 (RTX Pro 6000) | 더 큰 모델 사용 가능, config.yaml만 변경 | 없음 | +| Synology 교체 | NAS IP/도메인 변경, credentials.env 수정 | 없음 | + +--- + +## 17. 자동화 스케줄 + +Docker 내부에서 APScheduler로 관리 (launchd 대체): + +| 시간 | 작업 | 주기 | +|------|------|------| +| 07:00 | law_monitor | 매일 | +| 07:00, 18:00 | mailplus_archive | 매일 2회 | +| 20:00 | daily_digest | 매일 | +| */5분 | file_watcher | 상시 (NAS 변경 감지) | +| */10분 | processing_queue consumer | 상시 (가공 큐 처리) | + +--- + +## 부록 A: credentials.env 템플릿 + +```env +# PostgreSQL +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=pkm +POSTGRES_USER=pkm +POSTGRES_PASSWORD= + +# AI +MLX_ENDPOINT=http://localhost:8800/v1/chat/completions +MLX_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit +GPU_SERVER_IP= +GPU_EMBED_PORT=11434 +CLAUDE_API_KEY= + +# Synology NAS +NAS_SMB_PATH=/Volumes/Document_Server # macOS SMB 마운트 경로 +NAS_DOMAIN=ds1525.hyungi.net +NAS_TAILSCALE_IP=100.101.79.37 +NAS_PORT=15001 + +# Synology MailPlus +MAILPLUS_HOST=mailplus.hyungi.net +MAILPLUS_PORT=993 +MAILPLUS_SMTP_PORT=465 +MAILPLUS_USER=hyungi +MAILPLUS_PASS= + +# Synology Calendar (CalDAV) +CALDAV_URL=https://ds1525.hyungi.net/caldav/ +CALDAV_USER=hyungi +CALDAV_PASS= + +# kordoc +KORDOC_ENDPOINT=http://kordoc-service:3100 + +# 인증 +JWT_SECRET= +TOTP_SECRET= + +# 법령 API +LAW_OC= +``` + +## 부록 B: docker-compose.yml 골격 + +```yaml +version: '3.8' + +services: + postgres: + image: pgvector/pgvector:pg16 + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d + environment: + POSTGRES_DB: pkm + POSTGRES_USER: pkm + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" + restart: unless-stopped + + kordoc-service: + build: ./services/kordoc + ports: + - "3100:3100" + volumes: + - ${NAS_SMB_PATH}:/documents:ro + restart: unless-stopped + + fastapi: + build: ./app + ports: + - "8000:8000" + volumes: + - ${NAS_SMB_PATH}:/documents + depends_on: + - postgres + - kordoc-service + env_file: + - credentials.env + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "3000:3000" + depends_on: + - fastapi + restart: unless-stopped + + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + depends_on: + - fastapi + - frontend + restart: unless-stopped + +volumes: + pgdata: + caddy_data: +```