# 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: ```