# 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 설정 예시

```
document.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.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:
```
