82 Commits

Author SHA1 Message Date
Hyungi Ahn
6c92e375c2 feat: Markdown 뷰어/편집기 개선
- startEdit(): extracted_text || rawMarkdown fallback
- split editor → 편집/미리보기 탭 전환 방식
- GitHub Dark 스타일 markdown-body CSS (테이블/코드/인용/리스트)
- prose 클래스 → markdown-body로 교체

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:48:41 +09:00
Hyungi Ahn
06da098eab fix: 법령 분할 — 조문키 000 기반 장(章) 단위 분할로 변경
국가법령 XML은 <편>/<장> 태그가 아닌 <조문단위 조문키="xxxx000">에
"제X장 ..." 형태로 장 구분자가 포함됨. 이를 파싱하여 분할.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:05:48 +09:00
Hyungi Ahn
749ed51dd7 fix: Markdown 뷰어 — extracted_text 없으면 원본 파일 직접 렌더링
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:51 +09:00
Hyungi Ahn
1668be0a75 fix: 법령 저장 후 즉시 commit — 알림 실패가 DB 롤백하지 않도록
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:50:39 +09:00
Hyungi Ahn
93c5805060 feat: 법령 API 전면 개편 — 26개 법령, 분할 저장, 변경 이력 추적
- 모니터링 법령 12개 → 26개 (산업안전/건설/위험물/소방/전기/가스/근로/환경)
- lawSearch.do로 검색, lawService.do로 본문 조회
- 대형 법령 편/장 단위 분할 저장 (fallback: 편→장→전체)
- 저장 경로: PKM/Inbox/ (AI 자동 분류 연계)
- 변경 감지 시 user_note에 이력 자동 기록
- CalDAV + SMTP 알림

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:47:08 +09:00
Hyungi Ahn
b4ca918125 fix: 벡터 검색 asyncpg 캐스트 — ::vector → cast(:embedding AS vector)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:35:14 +09:00
Hyungi Ahn
e23c4feaa0 feat: 검색 전면 개편 — 필드별 가중치 + 벡터 합산 + match reason
검색 대상: title > ai_tags > user_note > ai_summary > extracted_text
- 필드별 가중치: title(3.0), tags(2.5), note(2.0), summary(1.5), text(1.0)
- 벡터 검색: 별도 쿼리로 분리, 결과 합산 (asyncpg 충돌 방지)
- match_reason: 어떤 필드에서 매칭됐는지 반환
- 중복 제거 + 점수 합산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:33:34 +09:00
Hyungi Ahn
e7cd710e69 fix: hybrid 검색 단순화 — FTS + ILIKE (vector/trgm 복잡 쿼리 제거)
asyncpg 파라미터 바인딩 충돌 문제 근본 해결.
한국어 검색: ILIKE fallback으로 안정 동작.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:16:36 +09:00
Hyungi Ahn
3236b8d812 fix: 검색 500 에러 (ILIKE % 이스케이프) + 한글 조합 중 Enter 방지
- ILIKE '%' → '%%' (SQLAlchemy text() 파라미터 충돌 해결)
- e.isComposing 체크로 한글 조합 완료 전 Enter 무시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:14:07 +09:00
Hyungi Ahn
4d205b67c2 fix: 검색 UX 개선 — Enter 키 기반 + 한국어 검색 ILIKE fallback
- 프론트: debounce 자동검색 제거 → Enter 키로만 검색 (한글 조합 문제 해결)
- 백엔드: trgm threshold 0.1로 낮춤 + ILIKE '%검색어%' fallback 추가
- hybrid 검색 score threshold 0.01 → 0.001로 낮춤

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:10:47 +09:00
Hyungi Ahn
b54cc25650 fix: 미분류 판단 기준 변경 — file_path 기반 → ai_domain 없음 기준
파일을 물리적으로 이동하지 않으므로 file_path로 미분류 판단 불가.
ai_domain이 NULL 또는 빈 문자열인 문서를 미분류로 취급.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:05:41 +09:00
Hyungi Ahn
d63a6b85e1 feat: 사이드바 3단계 재귀 트리 + 너비 확장 (320px)
- tree API: domain 경로를 파싱하여 계층 구조로 반환
  (Industrial_Safety → Practice → Patrol_Inspection)
- Sidebar: 재귀 snippet으로 N단계 트리 렌더링
- domain 필터: prefix 매칭 (상위 클릭 시 하위 전부 포함)
- 사이드바 너비: 260px → 320px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:03:36 +09:00
Hyungi Ahn
bf0506023c feat: 정보 패널 — taxonomy 기반 분류 표시 (breadcrumb + type/confidence 배지)
- domain 경로를 breadcrumb으로 표시 (Industrial_Safety › Practice › Patrol_Inspection)
- document_type 배지 (파란색)
- confidence 배지 (85%+ 초록, 60~85% 주황, <60% 빨강)
- importance 배지 (high만 표시)
- 원본 포맷 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:56:37 +09:00
Hyungi Ahn
7f5e09096a fix: 문서 삭제 시 processing_queue FK 제약 해결 + 변환본/preview 함께 삭제
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:51:35 +09:00
Hyungi Ahn
5153169d5d fix: 검색바 상단 고정 — 문서 목록만 스크롤
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:49:42 +09:00
Hyungi Ahn
9b0705b79f config: fallback 모델 qwen3.5:35b → qwen3.5:9b-q8_0 (GPU VRAM 제한)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:40:25 +09:00
Hyungi Ahn
63f75de89d fix: Qwen3.5 thinking 모드 비활성화 (enable_thinking: false)
JSON 응답에 Thinking Process 텍스트가 섞이는 문제 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:38:10 +09:00
Hyungi Ahn
6d73e7ee12 feat: 분류 체계 전면 개편 — taxonomy + document_type + confidence
- config.yaml: 6개 domain × 3단계 taxonomy + 13개 document_types 정의
- classify.txt: 영문 프롬프트, taxonomy 경로 기반 분류 + 분류 규칙 주입
- classify_worker: taxonomy 검증, confidence 기반 분류, document_type 저장
- migration 008: document_type, importance, ai_confidence 컬럼
- API: DocumentResponse에 document_type, importance, ai_confidence 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:32:20 +09:00
Hyungi Ahn
770d38b72c feat: 문서 삭제 기능 — 정보 패널에서 확인 후 삭제 (파일+DB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:14:20 +09:00
Hyungi Ahn
1b5fa95a9f feat: 오피스 → ODF 변환 + 원본/편집본 분리 아키텍처
- original_path/format/hash + conversion_status 필드 추가 (migration 007)
- extract_worker: 텍스트 추출 후 xlsx→ods, docx→odt 등 ODF 변환
  - 변환본은 .derived/{doc_id}.ods 에 저장
  - 원본 메타 보존 (original_path/format/hash)
- file_watcher: .derived/ .preview/ 디렉토리 제외
- DocumentViewer: ODF 포맷이면 편집 버튼 자동 표시
  - edit_url 있으면 "편집", 없으면 "Synology Drive에서 열기"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:11:43 +09:00
Hyungi Ahn
b937eb948b feat: Noto 다국어 폰트 추가 (fonts-noto-core/extra — 라틴/아랍/태국 등)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:45:02 +09:00
Hyungi Ahn
1030bffc82 fix: LibreOffice 한글/CJK 폰트 추가 (fonts-noto-cjk, fonts-nanum)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:43:13 +09:00
Hyungi Ahn
733f730e16 fix: preview enum 누락 + AI summary thinking 제거 + CLAUDE.md 전면 갱신
- queue.py: process_stage enum에 'preview' 추가
- classify_worker: ai_summary에 strip_thinking() 적용
- CLAUDE.md: 현재 아키텍처 전면 반영 (파이프라인, UI, 인프라, 코딩규칙)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:38:59 +09:00
Hyungi Ahn
6893ea132d refactor: preview 병렬 트리거 + 파일 이동 제거 + domain 색상 바
- queue_consumer: extract 완료 시 classify + preview 동시 등록
- classify_worker: _move_to_knowledge() 제거, 파일 원본 위치 유지
- DocumentCard: 좌측 domain별 색상 바 (4px) 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:31:57 +09:00
Hyungi Ahn
47e9981660 fix: Qwen3.5 Thinking Process 텍스트 제거 — JSON 파싱 개선
첫 번째 { 이전의 모든 비-JSON 텍스트를 제거하여
thinking/reasoning preamble이 있어도 JSON 추출 가능.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:21 +09:00
Hyungi Ahn
03b0612aa2 fix: extract_worker OFFICE_FORMATS 블록에 return 누락 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:28:09 +09:00
Hyungi Ahn
a5186bf4aa fix: 스프레드시트 텍스트 추출 — csv 필터 사용 (txt:Text는 Calc 미지원)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:21:29 +09:00
Hyungi Ahn
b37043d651 fix: LibreOffice 한글 파일명 호환 — 영문 임시파일로 복사 후 변환
extract_worker, preview_worker 모두 적용.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:18:06 +09:00
Hyungi Ahn
45448b4036 feat: extract_worker에 LibreOffice 텍스트 추출 추가 (오피스 포맷)
- xlsx, docx, pptx, odt, ods, odp, odoc, osheet 지원
- LibreOffice --convert-to txt로 텍스트 추출 (60s timeout)
- 추가 의존성 없음 (Docker에 이미 설치된 LibreOffice 사용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:12:19 +09:00
Hyungi Ahn
9fd44ab268 fix: 드래그 앤 드롭 — window 이벤트로 브라우저 기본 동작 차단
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:06:18 +09:00
Hyungi Ahn
87bdd8003c feat: 드래그 앤 드롭 업로드 (UploadDropzone)
- 파일 드래그 시 전체 페이지 오버레이
- 순차 업로드 + 파일별 진행 상태
- 성공/실패 토스트 + 목록 자동 새로고침
- documents 페이지에 통합

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:02:42 +09:00
Hyungi Ahn
41072a2e6d feat: 수동 편집 URL — 정보 패널에서 Synology Drive 링크 입력/관리
- edit_url 컬럼 추가 (migration 006)
- PreviewPanel: 편집 링크 입력/수정/표시 UI
- DocumentViewer: edit_url 있으면 편집 버튼에서 해당 URL로 새 탭
- API: DocumentResponse/DocumentUpdate에 edit_url 필드

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:37:44 +09:00
Hyungi Ahn
4bea408bbd feat: Markdown 편집기 + PDF 변환 파이프라인 + 뷰어 포맷 분기
- Markdown split editor: textarea + marked preview, Ctrl+S 저장
- PUT /api/documents/{id}/content: 원본 파일 저장 + extracted_text 갱신
- GET /api/documents/{id}/preview: PDF 미리보기 캐시 서빙
- preview_worker: LibreOffice headless → PDF 변환 (timeout 60s, retry 1회)
- queue_consumer: preview stage 추가 (embed 후 자동 트리거)
- DocumentViewer: 포맷별 분기 (markdown/pdf/preview-pdf/image/text/cad)
- 오피스/CAD 문서: 새 탭 편집 버튼
- Dockerfile: LibreOffice headless 설치
- migration 005: preview_status, preview_hash, preview_at 컬럼

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:10:03 +09:00
Hyungi Ahn
3546c8cefb refactor: 레이아웃 개선 — 30:70 비율, 사이드바 접힘, 정보 패널 drawer
- 사이드바: 데스크톱도 기본 접힘, ☰로 오버레이, localStorage 상태 기억
- 상단 30%: 문서 목록 + 검색 (문서 미선택 시 100%)
- 하단 70%: 뷰어 전체 너비 (우측 패널 제거)
- 정보 패널: ℹ 버튼 → 우측 전체 높이 drawer (ESC/외부 클릭 닫기)
- nav 높이 축소, 폰트 크기 최적화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:05:47 +09:00
Hyungi Ahn
17d41a8526 feat: Phase 1D+2 — 모바일 대응, 스마트 그룹, 메모, 태그 편집
- 모바일: 카드 클릭 시 detail 페이지로 이동 (뷰어 패널 미표시)
- 스마트 그룹: 사이드바에 최근 7일/법령 알림/이메일 프리셋 필터
- 메모: user_note 컬럼 추가 (migration 004), PATCH API, PreviewPanel 인라인 편집
- 태그 편집: PreviewPanel에서 태그 추가(+)/삭제(×) 기능
- DB 모델 + API 스키마 user_note 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:27:18 +09:00
Hyungi Ahn
47abf40bf1 feat: 하단 문서 뷰어 + 우측 정보 패널 (DEVONthink 레이아웃)
- DocumentViewer: 문서 선택 시 하단에 본문 미리보기/편집
  (Markdown 렌더링, PDF iframe, 이미지, Synology Office iframe)
- 레이아웃 변경: 상단(목록 45%) + 하단(뷰어+정보 55%)
- 우측 패널은 문서 정보/태그/처리상태 (메모/태그 편집은 Phase 2)
- 문서 선택 해제 시 목록 전체 표시로 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:17:45 +09:00
Hyungi Ahn
9239e9c1d5 fix: DocumentCard svelte:element → button (Svelte 5 호환)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:12:08 +09:00
Hyungi Ahn
a15208f0cf feat: Phase 1C — 프리뷰 패널 (문서 선택 시 우측 표시)
- PreviewPanel: AI 요약, 태그, 메타 정보, 처리 상태 표시
- DocumentCard: 선택 모드 지원 (클릭→프리뷰, 더블클릭 불필요)
- 3-pane 완성: sidebar | document list | preview panel
- 필터 변경 시 선택 자동 해제
- 데스크톱만 표시 (모바일은 detail 페이지로 이동)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:11:13 +09:00
Hyungi Ahn
f4a0229f15 fix: detail 페이지 태그를 TagPill 컴포넌트로 교체 (클릭→필터)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:07:51 +09:00
Hyungi Ahn
cb8a846773 feat: Phase 1B — DocumentCard/TagPill/FormatIcon 컴포넌트
- DocumentCard: 포맷 아이콘, 제목+요약, domain 경로, 태그 pill,
  data_origin 배지, 날짜, 파일 크기
- TagPill: 계층별 색상 (@amber, #blue, $green, !red), 클릭→필터
- FormatIcon: 파일 포맷별 lucide 아이콘 매핑
- documents 페이지에서 DocumentCard 컴포넌트 사용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:05:40 +09:00
Hyungi Ahn
1a207be261 fix: authChecked를 $state로 변경 (반응성 복원)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:03:06 +09:00
Hyungi Ahn
b04e1de8a6 fix: Svelte 5 runes mode 호환 ($: → $derived/$effect)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:01:00 +09:00
Hyungi Ahn
1a2b3b49af refactor: 사이드바를 전역 레이아웃으로 이동
- +layout.svelte: 사이드바 + 상단 nav 통합 (로그인/셋업 제외)
- 각 페이지 중복 nav 제거 (dashboard, documents, detail, inbox, settings)
- 모바일 drawer + ESC 닫기 전역 처리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:00:20 +09:00
Hyungi Ahn
87747866b6 feat: Phase 1A — 사이드바 트리 네비게이션 + domain/sub_group 필터
- Sidebar.svelte: /api/documents/tree 기반 domain→sub_group 트리,
  접기/펼치기, active highlight, 모바일 drawer
- documents/+page.svelte: 2-pane 레이아웃, URL params 기반 필터,
  빈 상태 개선, 카드 정보 밀도 향상 (domain 경로, 태그, origin 배지)
- documents.py: sub_group 필터 파라미터 추가
- app.css: domain 7색 + sidebar CSS 변수

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:54:09 +09:00
Hyungi Ahn
faf9bda77a fix: set correct Content-Type and inline disposition for file serving
PDF was downloading instead of displaying because media_type was None
(defaulting to octet-stream). Now maps file extensions to proper MIME
types and sets Content-Disposition: inline for in-browser viewing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:38:10 +09:00
Hyungi Ahn
1affcb1afd fix: add query param token auth for file serving (iframe compat)
iframe/img tags can't send Bearer headers. File endpoint now accepts
?token= query parameter for authentication. Frontend passes access
token in URL for PDF/image viewers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:34:45 +09:00
Hyungi Ahn
e14084d5cd feat: add file serving endpoint GET /api/documents/{id}/file
Returns original document file from NAS. Fixes 404 on PDF/image
viewer in frontend. Updated frontend iframe/img src to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:32:51 +09:00
Hyungi Ahn
62f5eccb96 fix: isolate each worker call in independent async session
Shared session between queue consumer and workers caused
MissingGreenlet errors in APScheduler context. Each worker
call now gets its own session with explicit commit/rollback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:29:14 +09:00
Hyungi Ahn
87683ca000 security: NAS 마운트 검증 + AI 서비스 포트 제한 + deploy 문서 갱신
- NAS fail-fast: 시작 시 /documents/PKM 존재 확인, NFS 미마운트 방지
- ollama/ai-gateway 포트를 127.0.0.1로 제한 (외부 무인증 접근 차단)
- deploy.md: Caddy HTTPS 자동발급 → 앞단 프록시 HTTPS 종료 구조 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:25:07 +09:00
Hyungi Ahn
7cdeac20cf fix: update migration script to read .dtBase2/Files.noindex directly
Instead of requiring DEVONthink export, reads files directly from
.dtBase2 bundle's Files.noindex/ directory structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:17:44 +09:00
Hyungi Ahn
3df03134ff fix: bind Caddy to 0.0.0.0:8080 for external proxy access
Mac mini nginx proxies to GPU server Caddy. localhost-only binding
blocked external connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:14:56 +09:00
Hyungi Ahn
0ca78640ee infra: migrate application from Mac mini to GPU server
- Integrate ollama + ai-gateway into root docker-compose.yml
  (NVIDIA GPU runtime, single compose for all services)
- Change NAS mount from SMB (NAS_SMB_PATH) to NFS (NAS_NFS_PATH)
  Default: /mnt/nas/Document_Server (fstab registered on GPU server)
- Update config.yaml AI endpoints:
  primary → Mac mini MLX via Tailscale (100.76.254.116:8800)
  fallback/embedding/vision/rerank → ollama (same Docker network)
  gateway → ai-gateway (same Docker network)
- Update credentials.env.example (remove GPU_SERVER_IP, add NFS path)
- Mark gpu-server/docker-compose.yml as deprecated
- Update CLAUDE.md network diagram and AI model config
- Update architecture.md, deploy.md, devlog.md for GPU server as main
- Caddyfile: auto_https off, HTTP only (TLS at upstream proxy)
- Caddy port: 127.0.0.1:8080:80 (localhost only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:47:09 +09:00
Hyungi Ahn
8afa3c401f fix: wait for auth refresh check before redirecting to login
The $: reactive statement was firing before onMount's tryRefresh()
completed, immediately redirecting to /login on every page refresh.
Added authChecked flag to gate the redirect logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:05:20 +09:00
Hyungi Ahn
aebfa14984 fix: don't intercept 401 on login/refresh endpoints for token refresh
Login 401 (TOTP required) was being caught by the refresh interceptor,
masking the actual error detail with "인증이 만료되었습니다".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:58:36 +09:00
Hyungi Ahn
17c1b7cf30 fix: set refresh cookie secure=False, samesite=lax for reverse proxy chain
Nginx terminates TLS and forwards HTTP internally. Secure=True cookies
don't get sent when the backend sees HTTP connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:53:59 +09:00
Hyungi Ahn
4ef27fc51c fix: use :80 instead of domain in Caddyfile (nginx handles TLS)
Nginx home-service-proxy terminates TLS and forwards plain HTTP to
Caddy on port 8080. Caddy doesn't need to match the domain name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:51:03 +09:00
Hyungi Ahn
a872dfc10f fix: guard goto() with browser check to prevent SSR crash
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:47:48 +09:00
Hyungi Ahn
fce9124c28 fix: add type:module to frontend package.json for ESM vite config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:46:59 +09:00
Hyungi Ahn
cfa95ff031 feat: implement Phase 4 SvelteKit frontend + backend enhancements
Backend:
- Add dashboard API (today stats, inbox count, law alerts, pipeline status)
- Add /api/documents/tree endpoint for sidebar domain/sub_group tree
- Migrate auth to HttpOnly cookie for refresh token (XSS defense)
- Add /api/auth/logout endpoint (cookie cleanup)
- Register dashboard router in main.py

Frontend (SvelteKit + Tailwind CSS v4):
- api.ts: fetch wrapper with refresh queue pattern, 401 single retry,
  forced logout on refresh failure
- Auth store: login/logout/refresh with memory-based access token
- UI store: toast system, sidebar state
- Login page with TOTP support
- Dashboard with 4 stat widgets + recent documents
- Document list with hybrid search (debounce, URL query state, mode select)
- Document detail with format-aware viewer (markdown/PDF/HWP/Synology/fallback)
- Metadata panel (AI summary, tags, processing history)
- Inbox triage UI (batch select, confirm dialog, domain override)
- Settings page (password change, TOTP status)

Infrastructure:
- Enable frontend service in docker-compose
- Caddy path routing (/api/* → fastapi, / → frontend) + gzip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:46:19 +09:00
Hyungi Ahn
46537ee11a fix: Codex 리뷰 P1/P2 버그 4건 수정
- [P1] migration runner 도입: schema_migrations 추적, advisory lock,
  단일 트랜잭션 실행, SQL 검증 (기존 DB 업그레이드 대응)
- [P1] eml extract 큐 조건 분기: extract_worker 미지원 포맷 큐 스킵
- [P2] iCalendar escape_ical_text() 추가: RFC 5545 준수
- [P2] 이메일 charset 감지: get_content_charset() 사용 + payload None 방어

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:55:38 +09:00
Hyungi Ahn
d93e50b55c security: fix 5 review findings (2 high, 3 medium)
HIGH:
- Lock setup TOTP/NAS endpoints behind _require_setup() guard
  (prevented unauthenticated admin 2FA takeover after setup)
- Sanitize upload filename with Path().name + resolve() validation
  (prevented path traversal writing outside Inbox)

MEDIUM:
- Add score > 0.01 filter to hybrid search via subquery
  (prevented returning irrelevant documents with zero score)
- Implement Inbox → Knowledge file move after classification
  (classify_worker now moves files based on ai_domain)
- Add Anthropic Messages API support in _request()
  (premium/Claude path now sends correct format and parses
  content[0].text instead of choices[0].message.content)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:33:31 +09:00
Hyungi Ahn
31d5498f8d feat: implement Phase 3 automation workers
- Add automation_state table for incremental sync (last UID, last check)
- Add law_monitor worker: 국가법령정보센터 API → NAS/DB/CalDAV VTODO
  (LAW_OC 승인 대기 중, 코드 완성)
- Add mailplus_archive worker: IMAP(993) → .eml NAS save + DB + SMTP
  notification (imaplib via asyncio.to_thread, timeout=30)
- Add daily_digest worker: PostgreSQL/pipeline stats → Markdown + SMTP
  (documents, law changes, email, queue errors, inbox backlog)
- Add CalDAV VTODO helper and SMTP email helper to core/utils.py
- Wire 3 cron jobs in APScheduler (law@07:00, mail@07:00+18:00,
  digest@20:00) with timezone=Asia/Seoul

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:24:50 +09:00
Hyungi Ahn
a5312c044b fix: replace deprecated regex with pattern in search Query param
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:02:44 +09:00
Hyungi Ahn
4b695332b9 feat: implement Phase 2 core features
- Add document CRUD API (list/get/upload/update/delete with auth)
  - Upload saves to Inbox + auto-enqueues processing pipeline
  - Delete defaults to DB-only, explicit flag for file deletion
- Add hybrid search API (FTS 0.4 + trigram 0.2 + vector 0.4 weighted)
  - Modes: fts, trgm, vector, hybrid (default)
  - Vector search gracefully degrades if GPU unavailable
- Add Inbox file watcher (5min interval, new file + hash change detection)
- Register documents/search routers and file_watcher scheduler in main.py
- Add IVFFLAT vector index migration (lists=50, with tuning guide)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:49:12 +09:00
Hyungi Ahn
2dfb05e653 fix: convert kordoc service to ESM (kordoc requires ESM import)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:38:34 +09:00
Hyungi Ahn
299fac3904 feat: implement Phase 1 data pipeline and migration
- Implement kordoc /parse endpoint (HWP/HWPX/PDF via kordoc lib,
  text files direct read, images flagged for OCR)
- Add queue consumer with APScheduler (1min interval, stage chaining
  extract→classify→embed, stale item recovery, retry logic)
- Add extract worker (kordoc HTTP call + direct text read)
- Add classify worker (Qwen3.5 AI classification with think-tag
  stripping and robust JSON extraction from AI responses)
- Add embed worker (GPU server nomic-embed-text, graceful failure)
- Add DEVONthink migration script with folder mapping for 16 DBs,
  dry-run mode, batch commits, and idempotent file_path UNIQUE
- Enhance ai/client.py with strip_thinking() and parse_json_response()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:35:36 +09:00
Hyungi Ahn
23ee055357 fix: replace passlib with bcrypt directly (passlib+bcrypt 5.0 incompatible)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:00:43 +09:00
Hyungi Ahn
e63d2971a9 fix: update TemplateResponse call for Starlette 1.0 API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:58:16 +09:00
Hyungi Ahn
b7c3040f1a chore: add .env to gitignore (docker-compose variable substitution)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:53:23 +09:00
Hyungi Ahn
d8fbe187bf fix: use port 9443 for Caddy HTTPS (8443 also taken by OrbStack)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:52:35 +09:00
Hyungi Ahn
0290dad923 fix: remap Caddy ports to 8080/8443 to avoid OrbStack conflict
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:52:08 +09:00
Hyungi Ahn
629fe37790 fix: use node fetch for kordoc healthcheck (wget/curl missing in slim)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:51:29 +09:00
Hyungi Ahn
8484389086 fix: change postgres external port to 15432 to avoid OrbStack conflict
Internal container communication still uses 5432.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:50:57 +09:00
Hyungi Ahn
16d99011db fix: disable frontend service until Phase 4, simplify Caddy proxy
Frontend SvelteKit build has dependency conflicts (Svelte 5 + Vite 8).
Phase 0 setup wizard is served by FastAPI/Jinja2, no frontend needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:28:03 +09:00
Hyungi Ahn
99821df5c9 fix: bump vite to ^8.0.0 for @sveltejs/vite-plugin-svelte 7 compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:27:04 +09:00
Hyungi Ahn
5a13b83e4d fix: upgrade frontend to Svelte 5 + Vite 6 for dependency compatibility
Svelte 4 conflicts with latest @sveltejs/kit peer requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:26:37 +09:00
Hyungi Ahn
a601991f48 feat: implement Phase 0 auth system, setup wizard, and Docker config
- Add users table to migration, User ORM model
- Implement JWT+TOTP auth API (login, refresh, me, change-password)
- Add first-run setup wizard with rate-limited admin creation,
  TOTP QR enrollment (secret saved only after verification), and
  NAS path verification — served as Jinja2 single-page HTML
- Add setup redirect middleware (bypasses /health, /docs, /openapi.json)
- Mount config.yaml, scripts, logs volumes in docker-compose
- Route API vs frontend traffic in Caddyfile
- Include admin seed script as CLI fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:21:45 +09:00
Hyungi Ahn
0a01e17ea1 merge: integrate remote v1 changes and resolve to v2
remote의 v1 업데이트(GPU 재구성, RAG, 버그 픽스 등 9커밋)를 merge.
v1 파일은 모두 삭제 (v1-final 태그에 보존됨).
v2 문서(CLAUDE.md, README.md, deploy.md, architecture.md)는 우리 버전 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:23:20 +09:00
Hyungi Ahn
131dbd7b7c feat: scaffold v2 project structure with Docker, FastAPI, and config
동작하는 최소 코드 수준의 v2 스캐폴딩:

- docker-compose.yml: postgres, fastapi, kordoc, frontend, caddy
- app/: FastAPI 백엔드 (main, core, models, ai, prompts)
- services/kordoc/: Node.js 문서 파싱 마이크로서비스
- gpu-server/: AI Gateway + GPU docker-compose
- frontend/: SvelteKit 기본 구조
- migrations/: PostgreSQL 초기 스키마 (documents, tasks, processing_queue)
- tests/: pytest conftest 기본 설정
- config.yaml, Caddyfile, credentials.env.example 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:20:15 +09:00
Hyungi Ahn
b338e6e424 docs: rewrite all documentation for v2 architecture
- CLAUDE.md: FastAPI + Docker 기반으로 전면 재작성
- README.md: v2 기술 스택 및 Quick Start
- deploy.md: Docker Compose 배포 가이드 (launchd 제거)
- development-stages.md: Phase 0~5 개발 단계 (claude-code-commands.md 대체)
- architecture-v2.md → architecture.md 승격

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:39:36 +09:00
Hyungi Ahn
e48b6a2bb4 chore: remove v1 files from main branch
v1 코드는 v1-archive 브랜치 + v1-final 태그로 보존.
필요시 git show v1-final:<파일경로>로 참조 가능.

삭제: applescript/, launchd/, v1 scripts, v1 docs, requirements.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:35:09 +09:00
Hyungi Ahn
852b7da797 docs: add v2 architecture design document
DEVONthink 탈피 후 FastAPI + PostgreSQL + SvelteKit + Docker 기반
자체 PKM 웹앱으로의 전환 설계 문서.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:27:08 +09:00
110 changed files with 9406 additions and 7121 deletions

9
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# 인증 정보 (절대 커밋 금지)
credentials.env
.env
# Python
venv/
@@ -23,3 +24,11 @@ data/
# IDE
.vscode/
.idea/
# Node.js (frontend, kordoc)
node_modules/
.svelte-kit/
# Docker volumes
pgdata/
caddy_data/

359
CLAUDE.md
View File

@@ -1,254 +1,207 @@
# DEVONThink PKM 시스템 — Claude Code 작업 가이드
> 마지막 업데이트: 2026-03-29
> 개발 현황: Phase 1 초기 구축 완료 → Phase 1.5 GPU 서버 재구성 + Phase 2 인프라 수정 병행 중
# hyungi_Document_Server — Claude Code 작업 가이드
## 프로젝트 개요
Mac mini M4 Pro(64GB, 4TB) 기반 개인 지식관리(PKM) 시스템.
DEVONthink 4를 중앙 허브로, MLX AI 자동 분류 + 법령 모니터링 + 일일 다이제스트를 자동화한다.
Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션.
FastAPI + PostgreSQL(pgvector) + SvelteKit + Docker Compose 기반.
GPU 서버를 메인 서버, Mac mini를 AI 추론, Synology NAS를 파일 저장소로 사용.
## 핵심 문서 (반드시 먼저 읽을 것)
## 핵심 문서
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 구조, 태그, AI, 자동화 전체)
2. `docs/industrial-safety-blueprint.md` — 04_Industrial Safety DB 상세 설계
3. `docs/claude-code-commands.md` — 단계별 작업 지시서 (현재 진행 상황 포함)
4. `docs/dev-roadmap.md` — 개발 로드맵 (Phase 1.5~6)
5. `docs/deploy.md` — Mac mini 배포 가이드 + 트러블슈팅
6. `docs/gpu-restructure.md` — GPU 서버 재구성 상세 계획 (Phase 1.5)
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 스키마, AI 전략, 인프라, UI 설계)
2. `docs/deploy.md` — Docker Compose 배포 가이드
3. `docs/development-stages.md` — Phase 0~5 개발 단계별 가이드
## 개발 현황 요약
## 기술 스택
```
[Phase 1: 초기 구축] ██████████████████░░ 90% — 코드 작성 완료, 인프라 일부 미해결
✅ 1단계: 프로젝트 구조 — 완료
✅ 2단계: AI 분류 프롬프트 — MLX Qwen3.5 OpenAI 호환 전환 완료
✅ 3단계: AppleScript — auto_classify + omnifocus_sync 완료
⚠️ 4단계: 법령 모니터링 — 외국(US/JP/EU) OK, 한국 API IP 미등록
❌ 5단계: MailPlus 수집 — IMAP Connection refused
⚠️ 6단계: Daily Digest — 코드 완성, 실행 테스트 미진행
✅ 7단계: DEVONagent 가이드 — 완료
❌ 8단계: 전체 테스트 — 미진행
✅ 추가: PKM API 서버 — 기본 동작, 개선 필요
[Phase 1.5: GPU 서버 재구성] ░░░░░░░░░░░░░░░░░░░░ 0% — 계획 완료, 실행 대기
→ docs/gpu-restructure.md 참조
→ GPU 모델 교체, Surya OCR, Komga 이전, Qdrant 통합, RAG 파이프라인
[Phase 2: 인프라 + 버그 픽스] ░░░░░░░░░░░░░░░░░░░░ 0% — Phase 1.5와 병행 착수 대기
→ docs/dev-roadmap.md 참조
```
## 알려진 이슈 (현재)
```
[P1 — 인프라]
- 한국 법령 API: open.law.go.kr에 Mac mini 공인IP 등록 필요
- MailPlus IMAP: 993 포트 Connection refused — Synology DSM에서 IMAP 활성화 확인
- requirements.txt: flask 누락, chromadb→qdrant-client 교체, schedule 미사용
- launchd: plist 파일은 있으나 실제 등록 여부 미확인
- GPU 서버: 중복 LLM 모델 제거 + Surya OCR/Komga 이전 필요 → gpu-restructure.md
[P2 — 코드 버그]
- JP 번역: MLX thinking 출력이 번역 결과에 오염 ("Wait, I'll check...")
- API 서버: /devonthink/stats → 500 에러, 한글 쿼리 인코딩 400 에러
- AppleScript: 경로 하드코딩 + sourceChannel 이중 설정 버그 (73행)
- embed_to_chroma.py: GPU_SERVER_IP 미설정으로 미작동 → Qdrant 리라이트 예정
```
| 영역 | 기술 |
|------|------|
| 백엔드 | FastAPI (Python 3.11+) |
| 데이터베이스 | PostgreSQL 16 + pgvector + pg_trgm |
| 프론트엔드 | SvelteKit 5 (runes mode) + Tailwind CSS 4 |
| 문서 파싱 | kordoc (HWP/HWPX/PDF → Markdown) + LibreOffice (오피스 → 텍스트/PDF) |
| 리버스 프록시 | Caddy (HTTP only, 앞단 프록시에서 HTTPS 처리) |
| 인증 | JWT + TOTP 2FA |
| 컨테이너 | Docker Compose |
## 네트워크 환경
```
Mac mini (운영 서버):
- MLX 서버: http://localhost:8800/v1/chat/completions (Qwen3.5-35B-A3B)
- PKM API: http://127.0.0.1:9900 (Flask, GUI 세션 필수)
- DEVONthink: 로컬 실행 중
- OmniFocus: 로컬 실행 중
GPU 서버 (RTX 4070 Ti Super, Ubuntu, 메인 서버):
- Docker Compose: FastAPI(:8000), PostgreSQL(:5432), kordoc(:3100),
Caddy(:8080 HTTP only), Ollama(127.0.0.1:11434), AI Gateway(127.0.0.1:8081), frontend(:3000)
- NFS 마운트: /mnt/nas/Document_Server → NAS /volume4/Document_Server
- 외부 접근: document.hyungi.net (Mac mini nginx → Caddy)
- 로컬 IP: 192.168.1.186
Mac mini M4 Pro (AI 서버 + 앞단 프록시):
- MLX Server: http://100.76.254.116:8800/v1/chat/completions (Qwen3.5-35B-A3B)
- nginx: HTTPS 종료 → GPU 서버 Caddy(:8080)로 프록시
- Tailscale IP: 100.76.254.116
Synology NAS (DS1525+):
- 도메인: ds1525.hyungi.net
- LAN IP: 192.168.1.227
- Tailscale IP: 100.101.79.37
- 포트: 15001
- WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/
- MailPlus IMAP: mailplus.hyungi.net:993 (SSL) ← 현재 연결 불가
GPU 서버 (RTX 4070 Ti Super, 192.168.1.186):
- 현재: Ollama(11434) + qwen3.5:9b + id-9b, Plex(32400)
- 계획: bge-m3(임베딩) + bge-reranker(리랭킹) + Surya OCR(:8400) + Komga(:25600)
- → docs/gpu-restructure.md 참조
TKSafety: tksafety.technicalkorea.net (설정만, 나중에 활성화)
- 파일 원본: /volume4/Document_Server/PKM/
- NFS export → GPU 서버
- Synology Drive: https://link.hyungi.net (문서 편집)
- Synology Calendar: CalDAV 태스크 관리
- MailPlus: IMAP(993) + SMTP(465)
```
## 인증 정보
- 위치: `~/.config/pkm/credentials.env`
- 템플릿: `./credentials.env.example`
- 스크립트에서 python-dotenv로 로딩
- 필수 키: LAW_OC, MAILPLUS_HOST/PORT/USER/PASS, NAS_DOMAIN, GPU_SERVER_IP
## DEVONthink DB 구조 (13개)
```
운영 DB (신규 생성 완료):
Inbox — 모든 자료 최초 진입점
Archive — 이메일, 채팅 로그
Projects — 진행 중 프로젝트
도메인 DB (기존, 유지):
00_Note_BOX, 01_Philosophie, 02_Language, 03_Engineering,
04_Industrial safety, 05_Programming, 07_General Book,
97_Production drawing, 99_Reference Data, 99_Technicalkorea
```
## 커스텀 메타데이터 필드 (DEVONthink에 등록 완료)
```
omnifocusTaskID — Single-Line Text — OmniFocus 역링크
sourceURL — URL — 원본 출처
synologyPath — Single-Line Text — NAS 원본 경로
lastAIProcess — Date — 마지막 AI 처리 일시
sourceChannel — Single-Line Text — 유입 경로 (아래 값 중 하나)
dataOrigin — Single-Line Text — work 또는 external
```
## sourceChannel 값 (유입 경로 추적)
```
tksafety — TKSafety API (업무 실적) → dataOrigin = work
devonagent — DEVONagent 자동 수집 (뉴스) → dataOrigin = external
law_monitor — 법령 API (법령 변경) → dataOrigin = external
inbox_route — Inbox → AI 분류 → AI 판별
email — MailPlus 이메일 → AI 판별
web_clip — Web Clipper 스크랩 → dataOrigin = external
manual — 직접 추가 → dataOrigin = work (기본)
```
- 위치: `credentials.env` (프로젝트 루트, .gitignore에 포함)
- 템플릿: `credentials.env.example`
- 스크립트에서 python-dotenv 또는 Docker env_file로 로딩
## AI 모델 구성
```
Tier 1 (Mac mini, 상시):
mlx-community/Qwen3.5-35B-A3B-4bit — 태그 생성, 문서 분류, 요약, JP 번역
→ http://localhost:8800/v1/chat/completions (OpenAI 호환 API)
→ MLX 서버로 실행 중 (Ollama 아님)
※ thinking 모드 주의: /nothink 명시 또는 JSON 추출 후처리 필요
Primary (Mac mini MLX, Tailscale 경유, 상시, 무료):
mlx-community/Qwen3.5-35B-A3B-4bit — 분류, 태그, 요약
→ http://100.76.254.116:8800/v1/chat/completions
Tier 2 (Claude API, 필요시):
Fallback (GPU Ollama, 같은 Docker 네트워크, MLX 장애 시):
qwen3.5:35b-a3b
→ http://ollama:11434/v1/chat/completions
Premium (Claude API, 종량제, 수동 트리거만):
claude-sonnet — 복잡한 분석, 장문 처리
CLAUDE_API_KEY 사용 (아직 미연동)
일일 한도 $5, require_explicit_trigger: true
Tier 3 (GPU 서버, 특수) — ※ 재구성 예정 (gpu-restructure.md 참조):
현재: qwen3.5:9b-q8_0, id-9b (제거 예정)
변경 후:
bge-m3 — 벡터 임베딩 (1024차원, Ollama)
bge-reranker-v2-m3 — RAG 리랭킹 (Ollama)
Surya OCR — 이미지/스캔 문서 OCR (FastAPI, 포트 8400)
Embedding (GPU Ollama, 같은 Docker 네트워크):
nomic-embed-text → 벡터 임베딩
Qwen2.5-VL-7B → 이미지/도면 OCR
bge-reranker-v2-m3 → RAG 리랭킹
```
## 파일 구조 (현재)
## 프로젝트 구조
```
./
├── CLAUDE.md ← 이 파일 (Claude Code 작업 가이드)
├── README.md ← 프로젝트 설명
├── requirements.txtPython 패키지 (flask 추가 필요!)
├── .gitignore
├── credentials.env.example ← 인증 정보 템플릿
hyungi_Document_Server/
├── docker-compose.yml
├── Caddyfile ← HTTP only, auto_https off
├── config.yaml AI 엔드포인트, NAS 경로, 스케줄
├── credentials.env.example
├── app/ ← FastAPI 백엔드
│ ├── main.py ← 엔트리포인트 + APScheduler (watcher/consumer 포함)
│ ├── Dockerfile ← LibreOffice headless 포함
│ ├── core/ (config, database, auth, utils)
│ ├── models/ (document, task, queue)
│ ├── api/ (documents, search, dashboard, auth, setup)
│ ├── workers/ (file_watcher, extract, classify, embed, preview, law_monitor, mailplus, digest, queue_consumer)
│ ├── prompts/classify.txt
│ └── ai/client.py ← AIClient + parse_json_response (Qwen3.5 thinking 처리)
├── services/kordoc/ ← Node.js 마이크로서비스 (HWP/PDF 파싱)
├── gpu-server/ ← AI Gateway (deprecated, 통합됨)
├── frontend/ ← SvelteKit 5
│ └── src/
│ ├── routes/ ← 페이지 (documents, inbox, settings, login)
│ └── lib/
│ ├── components/ ← Sidebar, DocumentCard, DocumentViewer, PreviewPanel,
│ │ TagPill, FormatIcon, UploadDropzone
│ ├── stores/ ← auth, ui
│ └── api.ts ← fetch wrapper (JWT 토큰 관리)
├── migrations/ ← PostgreSQL 스키마 (schema_migrations로 추적)
├── scripts/
│ ├── pkm_utils.py ← 공통 유틸 (로깅, 인증, LLM, AppleScript)
│ ├── law_monitor.py ← 법령 모니터링 (한국+US/JP/EU)
│ ├── mailplus_archive.py ← MailPlus 이메일 수집
│ ├── pkm_daily_digest.py ← 일일 다이제스트 생성
│ ├── pkm_api_server.py ← REST API 서버 (Flask, 포트 9900)
│ ├── embed_to_chroma.py ← ChromaDB 벡터 임베딩 (→ embed_to_qdrant.py로 교체 예정)
│ └── prompts/
│ └── classify_document.txt ← AI 분류 프롬프트 템플릿
├── applescript/
│ ├── auto_classify.scpt ← Inbox 자동 분류 Smart Rule
│ └── omnifocus_sync.scpt ← OmniFocus 연동 Smart Rule
├── launchd/
│ ├── net.hyungi.pkm.law-monitor.plist
│ ├── net.hyungi.pkm.mailplus.plist
│ └── net.hyungi.pkm.daily-digest.plist
├── data/
│ ├── law_last_check.json ← 법령 마지막 확인 시점
│ └── laws/ ← 수집된 법령 문서 (16건 수집 완료)
├── logs/ ← 실행 로그
├── docs/
│ ├── architecture.md ← 시스템 아키텍처
│ ├── industrial-safety-blueprint.md
│ ├── claude-code-commands.md ← 단계별 작업 지시서
│ ├── deploy.md ← Mac mini 배포 가이드
│ ├── devonagent-setup.md ← DEVONagent 검색 세트 가이드
│ ├── dev-roadmap.md ← 개발 로드맵 (Phase 1.5~6)
│ └── gpu-restructure.md ← GPU 서버 재구성 상세 계획
├── tests/
│ └── test_classify.py ← AI 분류 테스트 (5종 문서)
└── venv/ ← Python 가상환경
└── tests/
```
## 작업 순서
## 문서 처리 파이프라인
### Phase 1 (완료): 초기 구축
docs/claude-code-commands.md의 1~7단계 → 코드 작성 완료
```
파일 업로드 (드래그 앤 드롭 or file_watcher)
extract (텍스트 추출)
- kordoc: HWP, HWPX, PDF → Markdown
- LibreOffice: xlsx, docx, pptx, odt 등 → txt/csv
- 직접 읽기: md, txt, csv, json, xml, html
↓ ↓
classify (AI 분류) preview (PDF 미리보기 생성)
- Qwen3.5 → domain - LibreOffice → PDF 변환
- tags, summary - 캐시: PKM/.preview/{id}.pdf
embed (벡터 임베딩)
- nomic-embed-text (768차원)
```
### Phase 1.5 (계획 완료): GPU 서버 재구성
docs/gpu-restructure.md 참조:
1. GPU 모델 교체 (LLM 제거, bge-m3/reranker 설치)
2. Docker + NFS + Komga 이전
3. Surya OCR 설치
4. PKM 코드 갱신 (Qdrant 통합, embed 스크립트, AppleScript)
5. RAG 파이프라인 구축 (후순위)
**핵심 원칙:**
- 파일은 업로드 위치에 그대로 유지 (물리적 이동 없음)
- 분류(domain/sub_group/tags)는 DB 메타데이터로만 관리
- preview는 classify와 병렬로 실행 (AI 결과 불필요)
### Phase 2 (진행 중): 인프라 수정 + 버그 픽스
docs/dev-roadmap.md 참조 (Phase 1.5와 병행):
1. requirements.txt 수정 ← Phase 1.5와 합산 (qdrant-client, flask)
2. 한국 법령 API IP 등록
3. MailPlus IMAP 연결 수정
4. JP 번역 thinking 오염 필터링
5. API 서버 한글 인코딩 + stats 500 에러 수정
6. AppleScript 하드코딩 경로 변수화 ← Phase 1.5와 합산
7. launchd 등록 및 확인
## UI 구조
### Phase 3~4: API 서버 개선 + 테스트
- gunicorn 전환 + launchd plist 추가
- 엔드포인트 추가 (/law-monitor/status, /digest/latest)
- 모듈별 + E2E 통합 테스트 → docs/test-report.md
```
┌──────────────────────────────────────────────────┐
│ [☰ 사이드바] [PKM / 문서] [ 정보] 버튼│ ← 상단 nav
├──────────────────────────────────────────────────┤
│ [검색바] [모드] [] │
│ 문서 목록 (30%) — 드래그 업로드 지원 │ ← 상단 영역
│ █ 문서카드 (domain 색상 바 + 포맷 아이콘) │
├──────────────────────────────────────────────────┤
│ 하단 뷰어/편집 (70%) — 전체 너비 │ ← 하단 영역
│ Markdown: split editor (textarea + preview) │
│ PDF: 브라우저 내장 뷰어 │
│ 오피스: PDF 변환 미리보기 + [편집] 새 탭 버튼 │
│ 이미지: img 태그 │
└──────────────────────────────────────────────────┘
### Phase 5~6: 운영 안정화
- 로그 로테이션, Synology Chat 알림, 문서 보완
사이드바: 평소 접힘, ☰로 오버레이 (domain 트리 + 스마트 그룹 + Inbox)
정보 패널: 버튼 → 우측 전체 높이 drawer (메모/태그 편집/메타/처리상태/편집 URL)
```
## 데이터 계층
1. **원본 파일** (NAS `/volume4/Document_Server/PKM/`) — 유일한 원본, 위치 변경 없음
2. **가공 데이터** (PostgreSQL) — 텍스트 추출, AI 분류, 검색 인덱스, 메모, 태그
3. **파생물** — 벡터 임베딩 (pgvector), PDF 미리보기 캐시 (`.preview/`)
## 코딩 규칙
- Python 3.11+ (Mac mini 기본, 현재 3.14 확인됨)
- 인증 정보는 반드시 credentials.env에서 로딩 (하드코딩 금지)
- AppleScript는 DEVONthink/OmniFocus와 연동 (osascript로 호출)
- 로그는 ~/Documents/code/DEVONThink_my\ server/logs/에 저장
- launchd plist는 launchd/ 디렉토리에 생성, Mac mini에서 심볼릭 링크로 등록
- LLM 호출 시 pkm_utils.llm_generate() 사용 (thinking 후처리 포함)
- Python 3.11+, asyncio, type hints
- SQLAlchemy 2.0+ async 세션
- Svelte 5 runes mode ($state, $derived, $effect — $: 사용 금지)
- 인증 정보는 credentials.env에서 로딩 (하드코딩 금지)
- 로그는 `logs/`에 저장 (Docker 볼륨)
- AI 호출은 반드시 `app/ai/client.py``AIClient`를 통해 (직접 HTTP 호출 금지)
- 한글 주석 사용
- Migration: `migrations/*.sql`에 작성, `init_db()`가 자동 실행 (schema_migrations 추적)
- SQL에 BEGIN/COMMIT 금지 (외부 트랜잭션 깨짐)
- 기존 DB에서는 schema_migrations에 수동 이력 등록 필요할 수 있음
## 배포 방법
## 개발/배포 워크플로우
```
MacBook Pro (개발) → Gitea push → Mac mini에서 git pull
또는 Cowork 모드에서 직접 파일 수정 → git push
MacBook Pro (개발) → Gitea push → GPU 서버에서 pull
Mac mini에서:
cd ~/Documents/code/DEVONThink_my\ server/
개발:
cd ~/Documents/code/hyungi_Document_Server/
# 코드 작성 → git commit & push
GPU 서버 배포 (메인):
ssh hyungi@100.111.160.84
cd ~/Documents/code/hyungi_Document_Server/
git pull
source venv/bin/activate
pip install -r requirements.txt
# launchd 등록은 deploy.md 참조
docker compose up -d --build fastapi frontend
```
## v1 코드 참조
v1(DEVONthink 기반) 코드는 `v1-final` 태그로 보존:
```bash
git show v1-final:scripts/law_monitor.py
git show v1-final:scripts/pkm_utils.py
```
## 주의사항
- credentials.env는 git에 올리지 않음 (.gitignore에 포함)
- DEVONthink, OmniFocus는 Mac mini에서 GUI로 실행 중이어야 AppleScript 작동
- PKM API 서버도 GUI 세션에서 실행 필수 (AppleScript 중계)
- 법령 API (LAW_OC): 키 발급 완료, Mac mini 공인IP 등록 필요
- TKSafety 연동은 설계만 완료, 구현은 나중에
- GPU 서버 Tailscale IP는 별도 확인 후 credentials.env에 추가
- MLX 서버 thinking 모드: 번역/분류 시 /nothink 프리픽스 또는 후처리 필수
- credentials.env는 git에 올리지 않음 (.gitignore)
- NAS NFS 마운트 경로: Docker 컨테이너 내 `/documents`
- FastAPI 시작 시 `/documents/PKM` 존재 확인 (NFS 미마운트 방지)
- 법령 API (LAW_OC)는 승인 대기 중
- Ollama/AI Gateway 포트는 127.0.0.1 바인딩 (외부 접근 차단)
- Caddy는 `auto_https off` + `http://` only (HTTPS는 Mac mini nginx에서 처리)
- Synology Office 편집은 새 탭 열기 방식 (iframe 미사용, edit_url 수동 등록)

39
Caddyfile Normal file
View File

@@ -0,0 +1,39 @@
{
auto_https off
}
http://document.hyungi.net {
encode gzip
# API + 문서 → FastAPI
handle /api/* {
reverse_proxy fastapi:8000
}
handle /docs {
reverse_proxy fastapi:8000
}
handle /openapi.json {
reverse_proxy fastapi:8000
}
handle /health {
reverse_proxy fastapi:8000
}
handle /setup {
reverse_proxy fastapi:8000
}
# 프론트엔드
handle {
reverse_proxy frontend:3000
}
}
# Synology Office 프록시
http://office.hyungi.net {
reverse_proxy https://ds1525.hyungi.net:5001 {
header_up Host {upstream_hostport}
transport http {
tls_insecure_skip_verify
}
}
}

154
README.md
View File

@@ -1,118 +1,64 @@
# DEVONThink PKM System
# hyungi_Document_Server
Mac mini M4 Pro 기반 개인 지식관리(PKM) 자동화 시스템
Self-hosted 개인 지식관리(PKM) 웹 애플리케이션
## 시스템 구성
## 기술 스택
```
┌─────────────────── Mac mini M4 Pro (허브) ───────────────────┐
│ │
│ DEVONthink 4 ◄── DEVONagent Pro │
│ (13개 DB) (자동 검색) │
│ │ │
│ ┌────┴─────── 자동화 레이어 ────────────────────────┐ │
│ │ auto_classify.scpt 법령 모니터링 이메일 수집 │ │
│ │ omnifocus_sync.scpt 일일 다이제스트 PKM API │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ OmniFocus 4 MLX Qwen3.5-35B-A3B (AI 분류/번역) │
│ (작업 관리) localhost:8800 │
│ │
└──────────────────────────┬─────────────────────────────────────┘
│ Tailscale VPN
┌────────────────┼────────────────┐
┌─────────▼──────────┐ ┌────────────▼─────────────┐
│ Synology DS1525+ │ │ GPU 서버 (RTX 4070 Ti S) │
│ Gitea · MailPlus │ │ 임베딩 · OCR · 리랭킹 │
│ WebDAV 동기화 │ │ Plex │
└────────────────────┘ └──────────────────────────┘
- **백엔드**: FastAPI + SQLAlchemy (async)
- **데이터베이스**: PostgreSQL 16 + pgvector + pg_trgm
- **프론트엔드**: SvelteKit
- **문서 파싱**: kordoc (HWP/HWPX/PDF → Markdown)
- **AI**: Qwen3.5-35B-A3B (MLX), nomic-embed-text, Claude API (폴백)
- **인프라**: Docker Compose, Caddy, Synology NAS
## 주요 기능
- 문서 자동 분류/태그/요약 (AI 기반)
- 전문검색 + 벡터 유사도 검색
- HWP/PDF/Markdown 문서 뷰어
- 법령 변경 모니터링 (산업안전보건법 등)
- 이메일 자동 수집 (MailPlus IMAP)
- 일일 다이제스트
- CalDAV 태스크 연동 (Synology Calendar)
## Quick Start
```bash
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git hyungi_Document_Server
cd hyungi_Document_Server
# 인증 정보 설정
cp credentials.env.example credentials.env
nano credentials.env # 실제 값 입력
# 실행
docker compose up -d
```
## 핵심 기능
**AI 자동 분류** — DEVONthink Inbox에 들어온 문서를 MLX Qwen3.5가 분석하여 13개 DB 중 적합한 곳으로 자동 이동, 태그와 메타데이터를 자동 부여
**법령 모니터링** — 산업안전보건법, 중대재해처벌법 등 7개 한국 법령 + US OSHA, JP 厚労省, EU-OSHA 해외 법령 변경을 매일 자동 추적
**이메일 아카이브** — Synology MailPlus에서 IMAP으로 이메일을 수집하여 DEVONthink Archive DB에 자동 보관
**일일 다이제스트** — DEVONthink 변화, OmniFocus 진행 상황, 법령 변경 등을 종합한 일일 보고서 자동 생성
**OmniFocus 연동** — Projects DB의 TODO 패턴을 감지하여 OmniFocus에 작업 자동 생성, DEVONthink 역링크 포함
**REST API** — DEVONthink/OmniFocus 상태를 HTTP로 조회 (내부 모니터링용)
`http://localhost:8000/docs` 에서 API 문서 확인
## 디렉토리 구조
```
scripts/ Python 스크립트
pkm_utils.py 공통 유틸 (로깅, 인증, LLM 호출)
law_monitor.py 법령 변경 모니터링 (한국+US/JP/EU)
mailplus_archive.py MailPlus 이메일 수집
pkm_daily_digest.py 일일 다이제스트 생성
pkm_api_server.py REST API 서버 (Flask, 포트 9900)
embed_to_chroma.py ChromaDB 벡터 임베딩
prompts/ AI 프롬프트 템플릿
applescript/ DEVONthink/OmniFocus 연동
auto_classify.scpt Inbox 자동 분류 Smart Rule
omnifocus_sync.scpt OmniFocus 작업 생성 Smart Rule
launchd/ macOS 스케줄 실행
net.hyungi.pkm.law-monitor.plist 매일 07:00
net.hyungi.pkm.mailplus.plist 매일 07:00, 18:00
net.hyungi.pkm.daily-digest.plist 매일 20:00
docs/ 문서
architecture.md 시스템 아키텍처
deploy.md 배포 가이드 + 트러블슈팅
claude-code-commands.md 개발 작업 지시서
dev-roadmap.md 개발 로드맵
devonagent-setup.md DEVONagent 검색 세트 가이드
industrial-safety-blueprint.md 산업안전 DB 설계
data/ 데이터
laws/ 수집된 법령 문서
law_last_check.json 마지막 확인 시점
tests/ 테스트
test_classify.py AI 분류 정확도 테스트
├── app/ FastAPI 백엔드 (API, 워커, AI 클라이언트)
├── frontend/ SvelteKit 프론트엔드
├── services/kordoc/ 문서 파싱 마이크로서비스 (Node.js)
├── gpu-server/ GPU 서버 배포 (AI Gateway)
├── migrations/ PostgreSQL 스키마
├── docs/ 설계 문서, 배포 가이드
└── tests/ 테스트 코드
```
## 빠른 시작
## 인프라 구성
```bash
# Mac mini에서
git clone https://git.hyungi.net/hyungi/devonthink_home.git "DEVONThink_my server"
cd "DEVONThink_my server"
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
| 서버 | 역할 |
|------|------|
| Mac mini M4 Pro | Docker Compose (FastAPI, PostgreSQL, kordoc, Caddy) + MLX AI |
| Synology NAS | 파일 원본 저장, Synology Office/Drive/Calendar/MailPlus |
| GPU 서버 | AI Gateway, 벡터 임베딩, OCR, 리랭킹 |
# 인증 정보 설정
mkdir -p ~/.config/pkm
cp credentials.env.example ~/.config/pkm/credentials.env
nano ~/.config/pkm/credentials.env # 실제 값 입력
chmod 600 ~/.config/pkm/credentials.env
```
## 문서
자세한 배포 방법은 `docs/deploy.md` 참조
## 실행 환경
| 구성 요소 | 요구사항 |
|-----------|---------|
| macOS | 14+ (Sonoma) |
| Python | 3.11+ |
| DEVONthink | 4.x, GUI 실행 중 |
| OmniFocus | 4.x, GUI 실행 중 |
| MLX 서버 | Qwen3.5-35B-A3B, localhost:8800 |
| Tailscale | NAS/GPU 서버 접근용 |
## 개발
```
개발 흐름:
MacBook Pro (또는 Cowork) → git push → Gitea (NAS) → Mac mini에서 git pull
```
개발 현황과 다음 작업은 `docs/dev-roadmap.md` 참조
- [아키텍처](docs/architecture.md) — 전체 시스템 설계
- [배포 가이드](docs/deploy.md) — Docker Compose 배포 방법
- [개발 단계](docs/development-stages.md) — Phase 0~5 개발 계획

18
app/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
# LibreOffice headless (PDF 변환용) + 한글/CJK 폰트
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libreoffice-core libreoffice-calc libreoffice-writer libreoffice-impress \
fonts-noto-cjk fonts-noto-cjk-extra fonts-nanum \
fonts-noto-core fonts-noto-extra && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
app/ai/__init__.py Normal file
View File

137
app/ai/client.py Normal file
View File

@@ -0,0 +1,137 @@
"""AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5."""
import json
import re
from pathlib import Path
import httpx
from core.config import settings
def strip_thinking(text: str) -> str:
"""Qwen3.5의 <think>...</think> 블록 및 Thinking Process 텍스트 제거"""
# <think> 태그 제거
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
# "Thinking Process:" 등 사고 과정 텍스트 제거 (첫 번째 { 이전의 모든 텍스트)
json_start = text.find("{")
if json_start > 0:
text = text[json_start:]
return text.strip()
def parse_json_response(raw: str) -> dict | None:
"""AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거)"""
cleaned = strip_thinking(raw)
# 코드블록 내부 JSON 추출
code_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, re.DOTALL)
if code_match:
cleaned = code_match.group(1)
# 마지막 유효 JSON 객체 찾기
matches = list(re.finditer(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", cleaned, re.DOTALL))
for m in reversed(matches):
try:
return json.loads(m.group())
except json.JSONDecodeError:
continue
# 최후 시도: 전체 텍스트를 JSON으로
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return None
# 프롬프트 로딩
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
def _load_prompt(name: str) -> str:
return (PROMPTS_DIR / name).read_text(encoding="utf-8")
CLASSIFY_PROMPT = _load_prompt("classify.txt") if (PROMPTS_DIR / "classify.txt").exists() else ""
class AIClient:
"""AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5."""
def __init__(self):
self.ai = settings.ai
self._http = httpx.AsyncClient(timeout=120)
async def classify(self, text: str) -> dict:
"""문서 분류 — 항상 primary(Qwen3.5) 사용"""
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
response = await self._call_chat(self.ai.primary, prompt)
return response
async def summarize(self, text: str, force_premium: bool = False) -> str:
"""문서 요약 — 기본 Qwen3.5, 장문이거나 명시적 요청 시만 Claude"""
model = self.ai.primary
if force_premium or len(text) > 15000:
model = self.ai.premium
return await self._call_chat(model, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
async def embed(self, text: str) -> list[float]:
"""벡터 임베딩 — GPU 서버 전용"""
response = await self._http.post(
self.ai.embedding.endpoint,
json={"model": self.ai.embedding.model, "prompt": text},
)
response.raise_for_status()
return response.json()["embedding"]
async def ocr(self, image_bytes: bytes) -> str:
"""이미지 OCR — GPU 서버 전용"""
# TODO: Qwen2.5-VL-7B 비전 모델 호출 구현
raise NotImplementedError("OCR는 Phase 1에서 구현")
async def _call_chat(self, model_config, prompt: str) -> str:
"""OpenAI 호환 API 호출 + 자동 폴백"""
try:
return await self._request(model_config, prompt)
except (httpx.TimeoutException, httpx.ConnectError):
if model_config == self.ai.primary:
return await self._request(self.ai.fallback, prompt)
raise
async def _request(self, model_config, prompt: str) -> str:
"""단일 모델 API 호출 (OpenAI 호환 + Anthropic Messages API)"""
is_anthropic = "anthropic.com" in model_config.endpoint
if is_anthropic:
import os
headers = {
"x-api-key": os.getenv("CLAUDE_API_KEY", ""),
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
response = await self._http.post(
model_config.endpoint,
headers=headers,
json={
"model": model_config.model,
"max_tokens": model_config.max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
timeout=model_config.timeout,
)
response.raise_for_status()
data = response.json()
return data["content"][0]["text"]
else:
response = await self._http.post(
model_config.endpoint,
json={
"model": model_config.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": model_config.max_tokens,
"chat_template_kwargs": {"enable_thinking": False},
},
timeout=model_config.timeout,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def close(self):
await self._http.aclose()

0
app/api/__init__.py Normal file
View File

201
app/api/auth.py Normal file
View File

@@ -0,0 +1,201 @@
"""인증 API — 로그인, 토큰 갱신, TOTP 검증
access token: 응답 body (프론트에서 메모리 보관)
refresh token: HttpOnly cookie (XSS 방어)
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import (
REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
hash_password,
verify_password,
verify_totp,
)
from core.database import get_session
from models.user import User
router = APIRouter()
# ─── 요청/응답 스키마 ───
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str | None = None
class AccessTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: int
username: str
is_active: bool
totp_enabled: bool
last_login_at: datetime | None
class Config:
from_attributes = True
# ─── 헬퍼 ───
def _set_refresh_cookie(response: Response, token: str):
"""refresh token을 HttpOnly cookie로 설정"""
response.set_cookie(
key="refresh_token",
value=token,
httponly=True,
secure=False, # Nginx가 TLS 종료, 내부 트래픽은 HTTP
samesite="lax",
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 86400,
path="/api/auth",
)
# ─── 엔드포인트 ───
@router.post("/login", response_model=AccessTokenResponse)
async def login(
body: LoginRequest,
response: Response,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""로그인 → access token(body) + refresh token(cookie)"""
result = await session.execute(
select(User).where(User.username == body.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="아이디 또는 비밀번호가 올바르지 않습니다",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="비활성화된 계정입니다",
)
# TOTP 검증 (설정된 경우)
if user.totp_secret:
if not body.totp_code:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="TOTP 코드가 필요합니다",
)
if not verify_totp(body.totp_code, user.totp_secret):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="TOTP 코드가 올바르지 않습니다",
)
# 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
# refresh token → HttpOnly cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
return AccessTokenResponse(
access_token=create_access_token(user.username),
)
@router.post("/refresh", response_model=AccessTokenResponse)
async def refresh_token(
response: Response,
session: Annotated[AsyncSession, Depends(get_session)],
refresh_token: str | None = Cookie(None),
):
"""cookie의 refresh token으로 새 토큰 쌍 발급"""
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰이 없습니다",
)
payload = decode_token(refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 리프레시 토큰",
)
username = payload.get("sub")
result = await session.execute(
select(User).where(User.username == username, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
# 새 refresh token → cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
return AccessTokenResponse(
access_token=create_access_token(user.username),
)
@router.post("/logout")
async def logout(response: Response):
"""로그아웃 — refresh cookie 삭제"""
response.delete_cookie("refresh_token", path="/api/auth")
return {"message": "로그아웃 완료"}
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
"""현재 로그인한 유저 정보"""
return UserResponse(
id=user.id,
username=user.username,
is_active=user.is_active,
totp_enabled=bool(user.totp_secret),
last_login_at=user.last_login_at,
)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""비밀번호 변경"""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="현재 비밀번호가 올바르지 않습니다",
)
user.password_hash = hash_password(body.new_password)
await session.commit()
return {"message": "비밀번호가 변경되었습니다"}

137
app/api/dashboard.py Normal file
View File

@@ -0,0 +1,137 @@
"""대시보드 위젯 데이터 API"""
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.queue import ProcessingQueue
from models.user import User
router = APIRouter()
class DomainCount(BaseModel):
domain: str | None
count: int
class RecentDocument(BaseModel):
id: int
title: str | None
file_format: str
ai_domain: str | None
created_at: str
class PipelineStatus(BaseModel):
stage: str
status: str
count: int
class DashboardResponse(BaseModel):
today_added: int
today_by_domain: list[DomainCount]
inbox_count: int
law_alerts: int
recent_documents: list[RecentDocument]
pipeline_status: list[PipelineStatus]
failed_count: int
total_documents: int
@router.get("/", response_model=DashboardResponse)
async def get_dashboard(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""대시보드 위젯 데이터 집계"""
# 오늘 추가된 문서
today_result = await session.execute(
select(Document.ai_domain, func.count(Document.id))
.where(func.date(Document.created_at) == func.current_date())
.group_by(Document.ai_domain)
)
today_rows = today_result.all()
today_added = sum(row[1] for row in today_rows)
# Inbox 미분류 수 (ai_domain이 없는 문서 = 미분류)
inbox_result = await session.execute(
select(func.count(Document.id))
.where(
(Document.ai_domain == None) | (Document.ai_domain == "")
)
)
inbox_count = inbox_result.scalar() or 0
# 법령 알림 (오늘)
law_result = await session.execute(
select(func.count(Document.id))
.where(
Document.source_channel == "law_monitor",
func.date(Document.created_at) == func.current_date(),
)
)
law_alerts = law_result.scalar() or 0
# 최근 문서 5건
recent_result = await session.execute(
select(Document)
.order_by(Document.created_at.desc())
.limit(5)
)
recent_docs = recent_result.scalars().all()
# 파이프라인 상태 (24h)
pipeline_result = await session.execute(
text("""
SELECT stage, status, COUNT(*)
FROM processing_queue
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY stage, status
""")
)
# 실패 건수
failed_result = await session.execute(
select(func.count())
.select_from(ProcessingQueue)
.where(ProcessingQueue.status == "failed")
)
failed_count = failed_result.scalar() or 0
# 전체 문서 수
total_result = await session.execute(select(func.count(Document.id)))
total_documents = total_result.scalar() or 0
return DashboardResponse(
today_added=today_added,
today_by_domain=[
DomainCount(domain=row[0], count=row[1]) for row in today_rows
],
inbox_count=inbox_count,
law_alerts=law_alerts,
recent_documents=[
RecentDocument(
id=doc.id,
title=doc.title,
file_format=doc.file_format,
ai_domain=doc.ai_domain,
created_at=doc.created_at.isoformat() if doc.created_at else "",
)
for doc in recent_docs
],
pipeline_status=[
PipelineStatus(stage=row[0], status=row[1], count=row[2])
for row in pipeline_result
],
failed_count=failed_count,
total_documents=total_documents,
)

416
app/api/documents.py Normal file
View File

@@ -0,0 +1,416 @@
"""문서 CRUD API"""
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.config import settings
from core.database import get_session
from core.utils import file_hash
from models.document import Document
from models.queue import ProcessingQueue
from models.user import User
router = APIRouter()
# ─── 스키마 ───
class DocumentResponse(BaseModel):
id: int
file_path: str
file_format: str
file_size: int | None
file_type: str
title: str | None
ai_domain: str | None
ai_sub_group: str | None
ai_tags: list | None
ai_summary: str | None
document_type: str | None
importance: str | None
ai_confidence: float | None
user_note: str | None
original_path: str | None
original_format: str | None
conversion_status: str | None
edit_url: str | None
preview_status: str | None
source_channel: str | None
data_origin: str | None
extracted_at: datetime | None
ai_processed_at: datetime | None
embedded_at: datetime | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DocumentListResponse(BaseModel):
items: list[DocumentResponse]
total: int
page: int
page_size: int
class DocumentUpdate(BaseModel):
title: str | None = None
ai_domain: str | None = None
ai_sub_group: str | None = None
ai_tags: list | None = None
user_note: str | None = None
edit_url: str | None = None
source_channel: str | None = None
data_origin: str | None = None
# ─── 스키마 (트리) ───
class TreeNode(BaseModel):
name: str
path: str
count: int
children: list["TreeNode"]
# ─── 엔드포인트 ───
@router.get("/tree")
async def get_document_tree(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""도메인 트리 (3단계 경로 파싱, 사이드바용)"""
from sqlalchemy import text as sql_text
result = await session.execute(
sql_text("""
SELECT ai_domain, COUNT(*)
FROM documents
WHERE ai_domain IS NOT NULL AND ai_domain != ''
GROUP BY ai_domain
ORDER BY ai_domain
""")
)
# 경로를 트리로 파싱
root: dict = {}
for domain_path, count in result:
parts = domain_path.split("/")
node = root
for part in parts:
if part not in node:
node[part] = {"_count": 0, "_children": {}}
node[part]["_count"] += count
node = node[part]["_children"]
def build_tree(d: dict, prefix: str = "") -> list[dict]:
nodes = []
for name, data in sorted(d.items()):
path = f"{prefix}/{name}" if prefix else name
children = build_tree(data["_children"], path)
nodes.append({
"name": name,
"path": path,
"count": data["_count"],
"children": children,
})
return nodes
return build_tree(root)
@router.get("/", response_model=DocumentListResponse)
async def list_documents(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
domain: str | None = None,
sub_group: str | None = None,
source: str | None = None,
format: str | None = None,
):
"""문서 목록 조회 (페이지네이션 + 필터)"""
query = select(Document)
if domain:
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
query = query.where(Document.ai_domain.startswith(domain))
if source:
query = query.where(Document.source_channel == source)
if format:
query = query.where(Document.file_format == format)
# 전체 건수
count_query = select(func.count()).select_from(query.subquery())
total = (await session.execute(count_query)).scalar()
# 페이지네이션
query = query.order_by(Document.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(query)
items = result.scalars().all()
return DocumentListResponse(
items=[DocumentResponse.model_validate(doc) for doc in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""문서 단건 조회"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return DocumentResponse.model_validate(doc)
@router.get("/{doc_id}/file")
async def get_document_file(
doc_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (iframe용)"),
user: User | None = Depends(lambda: None),
):
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
from core.auth import decode_token
# 쿼리 파라미터 토큰 검증
if token:
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
else:
# 일반 Bearer 헤더 인증 시도
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
file_path = Path(settings.nas_mount_path) / doc.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# 미디어 타입 매핑
media_types = {
".pdf": "application/pdf",
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif",
".bmp": "image/bmp", ".tiff": "image/tiff",
".svg": "image/svg+xml",
".txt": "text/plain", ".md": "text/plain",
".html": "text/html", ".csv": "text/csv",
".json": "application/json", ".xml": "application/xml",
}
suffix = file_path.suffix.lower()
media_type = media_types.get(suffix, "application/octet-stream")
return FileResponse(
path=str(file_path),
media_type=media_type,
headers={"Content-Disposition": "inline"},
)
@router.post("/", response_model=DocumentResponse, status_code=201)
async def upload_document(
file: UploadFile,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록"""
if not file.filename:
raise HTTPException(status_code=400, detail="파일명이 필요합니다")
# 파일명 정규화 (경로 이탈 방지)
safe_name = Path(file.filename).name
if not safe_name or safe_name.startswith("."):
raise HTTPException(status_code=400, detail="유효하지 않은 파일명")
# Inbox에 파일 저장
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
inbox_dir.mkdir(parents=True, exist_ok=True)
target = (inbox_dir / safe_name).resolve()
# Inbox 하위 경로 검증
if not str(target).startswith(str(inbox_dir.resolve())):
raise HTTPException(status_code=400, detail="잘못된 파일 경로")
# 중복 파일명 처리
counter = 1
stem, suffix = target.stem, target.suffix
while target.exists():
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
counter += 1
content = await file.read()
target.write_bytes(content)
# 상대 경로 (NAS 루트 기준)
rel_path = str(target.relative_to(Path(settings.nas_mount_path)))
fhash = file_hash(target)
ext = target.suffix.lstrip(".").lower() or "unknown"
# DB 등록
doc = Document(
file_path=rel_path,
file_hash=fhash,
file_format=ext,
file_size=len(content),
file_type="immutable",
title=target.stem,
source_channel="manual",
)
session.add(doc)
await session.flush()
# 처리 큐 등록
session.add(ProcessingQueue(
document_id=doc.id,
stage="extract",
status="pending",
))
await session.commit()
return DocumentResponse.model_validate(doc)
@router.patch("/{doc_id}", response_model=DocumentResponse)
async def update_document(
doc_id: int,
body: DocumentUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""문서 메타데이터 수정 (수동 오버라이드)"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(doc, field, value)
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
return DocumentResponse.model_validate(doc)
@router.put("/{doc_id}/content")
async def save_document_content(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
body: dict = None,
):
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
if doc.file_format not in ("md", "txt"):
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
content = body.get("content", "") if body else ""
file_path = Path(settings.nas_mount_path) / doc.file_path
file_path.write_text(content, encoding="utf-8")
# 메타 갱신
doc.file_size = len(content.encode("utf-8"))
doc.file_hash = file_hash(file_path)
doc.extracted_text = content[:15000]
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
return DocumentResponse.model_validate(doc)
@router.get("/{doc_id}/preview")
async def get_document_preview(
doc_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (iframe용)"),
):
"""PDF 미리보기 캐시 서빙"""
from core.auth import decode_token
if token:
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
else:
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
if not preview_path.exists():
raise HTTPException(status_code=404, detail="미리보기가 아직 생성되지 않았습니다")
return FileResponse(
path=str(preview_path),
media_type="application/pdf",
headers={"Content-Disposition": "inline"},
)
@router.delete("/{doc_id}")
async def delete_document(
doc_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
):
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
if delete_file:
# 원본 파일 삭제
file_path = Path(settings.nas_mount_path) / doc.file_path
if file_path.exists():
file_path.unlink()
# 변환본 삭제
if doc.original_path:
orig = Path(settings.nas_mount_path) / doc.original_path
if orig.exists():
orig.unlink()
# preview 캐시 삭제
preview = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
if preview.exists():
preview.unlink()
# 관련 processing_queue 먼저 삭제 (FK 제약)
from sqlalchemy import delete as sql_delete
await session.execute(
sql_delete(ProcessingQueue).where(ProcessingQueue.document_id == doc_id)
)
await session.delete(doc)
await session.commit()
return {"message": f"문서 {doc_id} 삭제됨", "file_deleted": delete_file}

165
app/api/search.py Normal file
View File

@@ -0,0 +1,165 @@
"""하이브리드 검색 API — FTS + ILIKE + 벡터 (필드별 가중치)"""
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient
from core.auth import get_current_user
from core.database import get_session
from models.user import User
router = APIRouter()
class SearchResult(BaseModel):
id: int
title: str | None
ai_domain: str | None
ai_summary: str | None
file_format: str
score: float
snippet: str | None
match_reason: str | None = None
class SearchResponse(BaseModel):
results: list[SearchResult]
total: int
query: str
mode: str
@router.get("/", response_model=SearchResponse)
async def search(
q: str,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
limit: int = Query(20, ge=1, le=100),
):
"""문서 검색 — FTS + ILIKE + 벡터 결합"""
if mode == "vector":
results = await _search_vector(session, q, limit)
else:
results = await _search_text(session, q, limit)
# hybrid: 벡터 결과도 합산
if mode == "hybrid":
vector_results = await _search_vector(session, q, limit)
results = _merge_results(results, vector_results, limit)
return SearchResponse(
results=results,
total=len(results),
query=q,
mode=mode,
)
async def _search_text(session: AsyncSession, query: str, limit: int) -> list[SearchResult]:
"""FTS + ILIKE — 필드별 가중치 적용"""
result = await session.execute(
text("""
SELECT id, title, ai_domain, ai_summary, file_format,
left(extracted_text, 200) AS snippet,
(
-- title 매칭 (가중치 최고)
CASE WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 3.0 ELSE 0 END
-- ai_tags 매칭 (가중치 높음)
+ CASE WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 2.5 ELSE 0 END
-- user_note 매칭 (가중치 높음)
+ CASE WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 2.0 ELSE 0 END
-- ai_summary 매칭 (가중치 중상)
+ CASE WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 1.5 ELSE 0 END
-- extracted_text 매칭 (가중치 중간)
+ CASE WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 1.0 ELSE 0 END
-- FTS 점수 (보너스)
+ coalesce(ts_rank(
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')),
plainto_tsquery('simple', :q)
), 0) * 2.0
) AS score,
-- match reason
CASE
WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 'title'
WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 'tags'
WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 'note'
WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 'summary'
WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 'content'
ELSE 'fts'
END AS match_reason
FROM documents
WHERE coalesce(title, '') ILIKE '%%' || :q || '%%'
OR coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%'
OR coalesce(user_note, '') ILIKE '%%' || :q || '%%'
OR coalesce(ai_summary, '') ILIKE '%%' || :q || '%%'
OR coalesce(extracted_text, '') ILIKE '%%' || :q || '%%'
OR to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, ''))
@@ plainto_tsquery('simple', :q)
ORDER BY score DESC
LIMIT :limit
"""),
{"q": query, "limit": limit},
)
return [SearchResult(**row._mapping) for row in result]
async def _search_vector(session: AsyncSession, query: str, limit: int) -> list[SearchResult]:
"""벡터 유사도 검색 (코사인 거리)"""
try:
client = AIClient()
query_embedding = await client.embed(query)
await client.close()
except Exception:
return []
result = await session.execute(
text("""
SELECT id, title, ai_domain, ai_summary, file_format,
(1 - (embedding <=> cast(:embedding AS vector))) AS score,
left(extracted_text, 200) AS snippet,
'vector' AS match_reason
FROM documents
WHERE embedding IS NOT NULL
ORDER BY embedding <=> cast(:embedding AS vector)
LIMIT :limit
"""),
{"embedding": str(query_embedding), "limit": limit},
)
return [SearchResult(**row._mapping) for row in result]
def _merge_results(
text_results: list[SearchResult],
vector_results: list[SearchResult],
limit: int,
) -> list[SearchResult]:
"""텍스트 + 벡터 결과 합산 (중복 제거, 점수 합산)"""
merged: dict[int, SearchResult] = {}
for r in text_results:
merged[r.id] = r
for r in vector_results:
if r.id in merged:
# 이미 텍스트로 잡힌 문서 — 벡터 점수 가산
existing = merged[r.id]
merged[r.id] = SearchResult(
id=existing.id,
title=existing.title,
ai_domain=existing.ai_domain,
ai_summary=existing.ai_summary,
file_format=existing.file_format,
score=existing.score + r.score * 0.5,
snippet=existing.snippet,
match_reason=f"{existing.match_reason}+vector",
)
elif r.score > 0.3: # 벡터 유사도 최소 threshold
merged[r.id] = r
results = sorted(merged.values(), key=lambda x: x.score, reverse=True)
return results[:limit]

234
app/api/setup.py Normal file
View File

@@ -0,0 +1,234 @@
"""첫 접속 셋업 위자드 API
유저가 0명일 때만 동작. 셋업 완료 후 자동 비활성화.
"""
import time
from pathlib import Path
from typing import Annotated
import pyotp
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import create_access_token, create_refresh_token, hash_password
from core.config import settings
from core.database import get_session
from models.user import User
router = APIRouter()
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
# ─── Rate Limiting (인메모리, 단일 프로세스) ───
_failed_attempts: dict[str, list[float]] = {}
RATE_LIMIT_MAX = 5
RATE_LIMIT_WINDOW = 300 # 5분
def _check_rate_limit(client_ip: str):
"""5분 내 5회 실패 시 차단"""
now = time.time()
attempts = _failed_attempts.get(client_ip, [])
# 윈도우 밖의 기록 제거
attempts = [t for t in attempts if now - t < RATE_LIMIT_WINDOW]
_failed_attempts[client_ip] = attempts
if len(attempts) >= RATE_LIMIT_MAX:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"너무 많은 시도입니다. {RATE_LIMIT_WINDOW // 60}분 후 다시 시도하세요.",
)
def _record_failure(client_ip: str):
_failed_attempts.setdefault(client_ip, []).append(time.time())
# ─── 헬퍼: 셋업 필요 여부 ───
async def _needs_setup(session: AsyncSession) -> bool:
result = await session.execute(select(func.count(User.id)))
return result.scalar() == 0
async def _require_setup(session: AsyncSession):
if not await _needs_setup(session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="셋업이 이미 완료되었습니다",
)
# ─── 스키마 ───
class SetupStatusResponse(BaseModel):
needs_setup: bool
class CreateAdminRequest(BaseModel):
username: str
password: str
class CreateAdminResponse(BaseModel):
message: str
access_token: str
refresh_token: str
class TOTPInitResponse(BaseModel):
secret: str
otpauth_uri: str
class TOTPVerifyRequest(BaseModel):
secret: str
code: str
class VerifyNASRequest(BaseModel):
path: str
class VerifyNASResponse(BaseModel):
exists: bool
readable: bool
writable: bool
path: str
# ─── 엔드포인트 ───
@router.get("/status", response_model=SetupStatusResponse)
async def setup_status(session: Annotated[AsyncSession, Depends(get_session)]):
"""셋업 필요 여부 확인"""
return SetupStatusResponse(needs_setup=await _needs_setup(session))
@router.post("/admin", response_model=CreateAdminResponse)
async def create_admin(
body: CreateAdminRequest,
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""관리자 계정 생성 (유저 0명일 때만)"""
await _require_setup(session)
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
# 유효성 검사
if len(body.username) < 2:
_record_failure(client_ip)
raise HTTPException(status_code=400, detail="아이디는 2자 이상이어야 합니다")
if len(body.password) < 8:
_record_failure(client_ip)
raise HTTPException(status_code=400, detail="비밀번호는 8자 이상이어야 합니다")
user = User(
username=body.username,
password_hash=hash_password(body.password),
is_active=True,
)
session.add(user)
await session.commit()
return CreateAdminResponse(
message=f"관리자 '{body.username}' 계정이 생성되었습니다",
access_token=create_access_token(body.username),
refresh_token=create_refresh_token(body.username),
)
@router.post("/totp/init", response_model=TOTPInitResponse)
async def totp_init(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""TOTP 시크릿 생성 + otpauth URI 반환 (DB에 저장하지 않음)"""
await _require_setup(session)
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name="admin",
issuer_name="hyungi Document Server",
)
return TOTPInitResponse(secret=secret, otpauth_uri=uri)
@router.post("/totp/verify")
async def totp_verify(
body: TOTPVerifyRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""TOTP 코드 검증 후 DB에 시크릿 저장"""
await _require_setup(session)
totp = pyotp.TOTP(body.secret)
if not totp.verify(body.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP 코드가 올바르지 않습니다. 다시 시도하세요.",
)
# 가장 최근 생성된 유저에 저장 (셋업 직후이므로 유저 1명)
result = await session.execute(
select(User).order_by(User.id.desc()).limit(1)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="유저를 찾을 수 없습니다")
user.totp_secret = body.secret
await session.commit()
return {"message": "TOTP 2FA가 활성화되었습니다"}
@router.post("/verify-nas", response_model=VerifyNASResponse)
async def verify_nas(
body: VerifyNASRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""NAS 마운트 경로 읽기/쓰기 테스트"""
await _require_setup(session)
path = Path(body.path)
exists = path.exists()
readable = path.is_dir() and any(True for _ in path.iterdir()) if exists else False
writable = False
if exists:
test_file = path / ".pkm_write_test"
try:
test_file.write_text("test")
test_file.unlink()
writable = True
except OSError:
pass
return VerifyNASResponse(
exists=exists,
readable=readable,
writable=writable,
path=str(path),
)
@router.get("/", response_class=HTMLResponse)
async def setup_page(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""셋업 위자드 HTML 페이지"""
if not await _needs_setup(session):
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/docs")
return templates.TemplateResponse(request, "setup.html")

0
app/core/__init__.py Normal file
View File

85
app/core/auth.py Normal file
View File

@@ -0,0 +1,85 @@
"""JWT + TOTP 2FA 인증"""
from datetime import datetime, timedelta, timezone
from typing import Annotated
import bcrypt
import pyotp
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.database import get_session
security = HTTPBearer()
# JWT 설정
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": subject, "exp": expire, "type": "access"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def create_refresh_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except JWTError:
return None
def verify_totp(code: str, secret: str | None = None) -> bool:
"""TOTP 코드 검증 (유저별 secret 또는 글로벌 설정)"""
totp_secret = secret or settings.totp_secret
if not totp_secret:
return True # TOTP 미설정 시 스킵
totp = pyotp.TOTP(totp_secret)
return totp.verify(code)
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Bearer 토큰에서 현재 유저 조회"""
from models.user import User
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰",
)
username = payload.get("sub")
result = await session.execute(
select(User).where(User.username == username, User.is_active.is_(True))
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유저를 찾을 수 없음",
)
return user

95
app/core/config.py Normal file
View File

@@ -0,0 +1,95 @@
"""설정 로딩 — config.yaml + credentials.env"""
import os
from pathlib import Path
import yaml
from pydantic import BaseModel
class AIModelConfig(BaseModel):
endpoint: str
model: str
max_tokens: int = 4096
timeout: int = 60
daily_budget_usd: float | None = None
require_explicit_trigger: bool = False
class AIConfig(BaseModel):
gateway_endpoint: str
primary: AIModelConfig
fallback: AIModelConfig
premium: AIModelConfig
embedding: AIModelConfig
vision: AIModelConfig
rerank: AIModelConfig
class Settings(BaseModel):
# DB
database_url: str = ""
# AI
ai: AIConfig | None = None
# NAS
nas_mount_path: str = "/documents"
nas_pkm_root: str = "/documents/PKM"
# 인증
jwt_secret: str = ""
totp_secret: str = ""
# kordoc
kordoc_endpoint: str = "http://kordoc-service:3100"
def load_settings() -> Settings:
"""config.yaml + 환경변수에서 설정 로딩"""
# 환경변수 (docker-compose에서 주입)
database_url = os.getenv("DATABASE_URL", "")
jwt_secret = os.getenv("JWT_SECRET", "")
totp_secret = os.getenv("TOTP_SECRET", "")
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
config_path = Path("/app/config.yaml")
if not config_path.exists():
config_path = Path(__file__).parent.parent.parent / "config.yaml"
ai_config = None
nas_mount = "/documents"
nas_pkm = "/documents/PKM"
if config_path.exists():
with open(config_path) as f:
raw = yaml.safe_load(f)
if "ai" in raw:
ai_raw = raw["ai"]
ai_config = AIConfig(
gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""),
primary=AIModelConfig(**ai_raw["models"]["primary"]),
fallback=AIModelConfig(**ai_raw["models"]["fallback"]),
premium=AIModelConfig(**ai_raw["models"]["premium"]),
embedding=AIModelConfig(**ai_raw["models"]["embedding"]),
vision=AIModelConfig(**ai_raw["models"]["vision"]),
rerank=AIModelConfig(**ai_raw["models"]["rerank"]),
)
if "nas" in raw:
nas_mount = raw["nas"].get("mount_path", nas_mount)
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
return Settings(
database_url=database_url,
ai=ai_config,
nas_mount_path=nas_mount,
nas_pkm_root=nas_pkm,
jwt_secret=jwt_secret,
totp_secret=totp_secret,
kordoc_endpoint=kordoc_endpoint,
)
settings = load_settings()

144
app/core/database.py Normal file
View File

@@ -0,0 +1,144 @@
"""PostgreSQL 연결 — SQLAlchemy async engine + session factory"""
import logging
import re
import time
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from core.config import settings
logger = logging.getLogger("migration")
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
# NOTE: 모든 pending migration은 단일 트랜잭션으로 실행됨.
# DDL이 많거나 대량 데이터 변경이 포함된 migration은 장시간 lock을 유발할 수 있음.
_MIGRATION_VERSION_RE = re.compile(r"^(\d+)_")
_MIGRATION_LOCK_KEY = 938475
def _parse_migration_files(migrations_dir: Path) -> list[tuple[int, str, Path]]:
"""migration 파일 스캔 → (version, name, path) 리스트, 버전순 정렬"""
files = []
for p in sorted(migrations_dir.glob("*.sql")):
m = _MIGRATION_VERSION_RE.match(p.name)
if not m:
continue
version = int(m.group(1))
files.append((version, p.name, p))
# 중복 버전 검사
seen: dict[int, str] = {}
for version, name, _ in files:
if version in seen:
raise RuntimeError(
f"migration 버전 중복: {seen[version]} vs {name} (version={version})"
)
seen[version] = name
files.sort(key=lambda x: x[0])
return files
def _validate_sql_content(name: str, sql: str) -> None:
"""migration SQL에 BEGIN/COMMIT이 포함되어 있으면 에러 (외부 트랜잭션 깨짐 방지)"""
# 주석(-- ...) 라인 제거 후 검사
lines = [
line for line in sql.splitlines()
if not line.strip().startswith("--")
]
stripped = "\n".join(lines).upper()
for keyword in ("BEGIN", "COMMIT", "ROLLBACK"):
# 단어 경계로 매칭 (예: BEGIN_SOMETHING은 제외)
if re.search(rf"\b{keyword}\b", stripped):
raise RuntimeError(
f"migration {name}{keyword} 포함됨 — "
f"migration SQL에는 트랜잭션 제어문을 넣지 마세요"
)
async def _run_migrations(conn) -> None:
"""미적용 migration 실행 (호출자가 트랜잭션 관리)"""
from sqlalchemy import text
# schema_migrations 테이블 생성
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
"""))
# advisory lock 획득 (트랜잭션 끝나면 자동 해제)
await conn.execute(text(
f"SELECT pg_advisory_xact_lock({_MIGRATION_LOCK_KEY})"
))
# 적용 이력 조회
result = await conn.execute(text("SELECT version FROM schema_migrations"))
applied = {row[0] for row in result}
# migration 파일 스캔
migrations_dir = Path(__file__).resolve().parent.parent.parent / "migrations"
if not migrations_dir.is_dir():
logger.info("[migration] migrations/ 디렉토리 없음, 스킵")
return
files = _parse_migration_files(migrations_dir)
pending = [(v, name, path) for v, name, path in files if v not in applied]
if not pending:
logger.info("[migration] 미적용 migration 없음")
return
start = time.monotonic()
logger.info(f"[migration] {len(pending)}건 적용 시작")
for version, name, path in pending:
sql = path.read_text(encoding="utf-8")
_validate_sql_content(name, sql)
logger.info(f"[migration] {name} 실행 중...")
await conn.execute(text(sql))
await conn.execute(
text("INSERT INTO schema_migrations (version, name) VALUES (:v, :n)"),
{"v": version, "n": name},
)
logger.info(f"[migration] {name} 완료")
elapsed = time.monotonic() - start
logger.info(f"[migration] 전체 {len(pending)}건 완료 ({elapsed:.1f}s)")
async def init_db():
"""DB 연결 확인 + pending migration 실행"""
from sqlalchemy import text
async with engine.begin() as conn:
await conn.execute(text("SELECT 1"))
try:
await _run_migrations(conn)
except Exception as e:
logger.error(f"[migration] 실패: {e} — 전체 트랜잭션 롤백")
raise
async def get_session() -> AsyncSession:
"""FastAPI Depends용 세션 제공"""
async with async_session() as session:
yield session

138
app/core/utils.py Normal file
View File

@@ -0,0 +1,138 @@
"""공통 유틸리티 — v1 pkm_utils.py에서 AppleScript 제거, 나머지 포팅"""
import hashlib
import logging
from pathlib import Path
def setup_logger(name: str, log_dir: str = "logs") -> logging.Logger:
"""로거 설정"""
Path(log_dir).mkdir(exist_ok=True)
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
if not logger.handlers:
# 파일 핸들러
fh = logging.FileHandler(f"{log_dir}/{name}.log", encoding="utf-8")
fh.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
))
logger.addHandler(fh)
# 콘솔 핸들러
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
logger.addHandler(ch)
return logger
def file_hash(path: str | Path) -> str:
"""파일 SHA-256 해시 계산"""
sha256 = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def count_log_errors(log_path: str) -> int:
"""로그 파일에서 ERROR 건수 카운트"""
try:
with open(log_path, encoding="utf-8") as f:
return sum(1 for line in f if "[ERROR]" in line)
except FileNotFoundError:
return 0
# ─── CalDAV 헬퍼 ───
def escape_ical_text(text: str | None) -> str:
"""iCalendar TEXT 값 이스케이프 (RFC 5545 §3.3.11).
SUMMARY, DESCRIPTION, LOCATION 등 TEXT 프로퍼티에 사용.
"""
if not text:
return ""
text = text.replace("\r\n", "\n").replace("\r", "\n") # CRLF 정규화
text = text.replace("\\", "\\\\") # 백슬래시 먼저
text = text.replace("\n", "\\n")
text = text.replace(",", "\\,")
text = text.replace(";", "\\;")
return text
def create_caldav_todo(
caldav_url: str,
username: str,
password: str,
title: str,
description: str = "",
due_days: int = 7,
) -> str | None:
"""Synology Calendar에 VTODO 생성, UID 반환"""
import uuid
from datetime import datetime, timedelta, timezone
import caldav
try:
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
return None
calendar = calendars[0]
uid = str(uuid.uuid4())
due = datetime.now(timezone.utc) + timedelta(days=due_days)
due_str = due.strftime("%Y%m%dT%H%M%SZ")
vtodo = f"""BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
UID:{uid}
SUMMARY:{escape_ical_text(title)}
DESCRIPTION:{escape_ical_text(description)}
DUE:{due_str}
STATUS:NEEDS-ACTION
PRIORITY:5
END:VTODO
END:VCALENDAR"""
calendar.save_event(vtodo)
return uid
except Exception as e:
logging.getLogger("caldav").error(f"CalDAV VTODO 생성 실패: {e}")
return None
# ─── SMTP 헬퍼 ───
def send_smtp_email(
host: str,
port: int,
username: str,
password: str,
subject: str,
body: str,
to_addr: str | None = None,
):
"""Synology MailPlus SMTP로 이메일 발송"""
import smtplib
from email.mime.text import MIMEText
to_addr = to_addr or username
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = username
msg["To"] = to_addr
try:
with smtplib.SMTP_SSL(host, port, timeout=30) as server:
server.login(username, password)
server.send_message(msg)
except Exception as e:
logging.getLogger("smtp").error(f"SMTP 발송 실패: {e}")

133
app/main.py Normal file
View File

@@ -0,0 +1,133 @@
"""hyungi_Document_Server — FastAPI 엔트리포인트"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, text
from api.auth import router as auth_router
from api.dashboard import router as dashboard_router
from api.documents import router as documents_router
from api.search import router as search_router
from api.setup import router as setup_router
from core.config import settings
from core.database import async_session, engine, init_db
from models.user import User
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from workers.daily_digest import run as daily_digest_run
from workers.file_watcher import watch_inbox
from workers.law_monitor import run as law_monitor_run
from workers.mailplus_archive import run as mailplus_run
from workers.queue_consumer import consume_queue
# 시작: DB 연결 확인
await init_db()
# NAS 마운트 확인 (NFS 미마운트 시 로컬 빈 디렉토리에 쓰는 것 방지)
from pathlib import Path
nas_check = Path(settings.nas_mount_path) / "PKM"
if not nas_check.is_dir():
raise RuntimeError(
f"NAS 마운트 확인 실패: {nas_check} 디렉토리 없음. "
f"NFS 마운트 상태를 확인하세요."
)
# APScheduler: 백그라운드 작업
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# 상시 실행
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
# 일일 스케줄 (KST)
scheduler.add_job(law_monitor_run, CronTrigger(hour=7), id="law_monitor")
scheduler.add_job(mailplus_run, CronTrigger(hour=7), id="mailplus_morning")
scheduler.add_job(mailplus_run, CronTrigger(hour=18), id="mailplus_evening")
scheduler.add_job(daily_digest_run, CronTrigger(hour=20), id="daily_digest")
scheduler.start()
yield
# 종료: 스케줄러 → DB 순서로 정리
scheduler.shutdown(wait=False)
await engine.dispose()
app = FastAPI(
title="hyungi_Document_Server",
description="Self-hosted PKM 웹 애플리케이션 API",
version="2.0.0",
lifespan=lifespan,
)
# ─── 라우터 등록 ───
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
# app.include_router(export.router, prefix="/api/export", tags=["export"])
# ─── 셋업 미들웨어: 유저 0명이면 /setup으로 리다이렉트 ───
SETUP_BYPASS_PREFIXES = (
"/api/setup", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
)
@app.middleware("http")
async def setup_redirect_middleware(request: Request, call_next):
path = request.url.path
# 바이패스 경로는 항상 통과
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
return await call_next(request)
# 유저 존재 여부 확인
try:
async with async_session() as session:
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar()
if user_count == 0:
return RedirectResponse(url="/setup")
except Exception:
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
return await call_next(request)
# ─── 셋업 페이지 라우트 (API가 아닌 HTML 페이지) ───
@app.get("/setup")
async def setup_page_redirect(request: Request):
"""셋업 위자드 페이지로 포워딩"""
from api.setup import setup_page
from core.database import get_session
async for session in get_session():
return await setup_page(request, session)
@app.get("/health")
async def health_check():
"""헬스체크 — DB 연결 상태 포함"""
db_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
return {
"status": "ok" if db_ok else "degraded",
"version": "2.0.0",
"database": "connected" if db_ok else "disconnected",
}

0
app/models/__init__.py Normal file
View File

20
app/models/automation.py Normal file
View File

@@ -0,0 +1,20 @@
"""automation_state 테이블 ORM — 자동화 워커 증분 동기화 상태"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class AutomationState(Base):
__tablename__ = "automation_state"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
job_name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
last_check_value: Mapped[str | None] = mapped_column(Text)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
)

84
app/models/document.py Normal file
View File

@@ -0,0 +1,84 @@
"""documents 테이블 ORM"""
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class Document(Base):
__tablename__ = "documents"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# 1계층: 원본 파일
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
file_size: Mapped[int | None] = mapped_column(BigInteger)
file_type: Mapped[str] = mapped_column(
Enum("immutable", "editable", "note", name="doc_type"),
default="immutable"
)
import_source: Mapped[str | None] = mapped_column(Text)
# 2계층: 텍스트 추출
extracted_text: Mapped[str | None] = mapped_column(Text)
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
extractor_version: Mapped[str | None] = mapped_column(String(50))
# 2계층: AI 가공
ai_summary: Mapped[str | None] = mapped_column(Text)
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
ai_domain: Mapped[str | None] = mapped_column(String(100))
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
ai_model_version: Mapped[str | None] = mapped_column(String(50))
ai_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
document_type: Mapped[str | None] = mapped_column(String(50))
importance: Mapped[str | None] = mapped_column(String(20), default="medium")
ai_confidence: Mapped[float | None] = mapped_column()
# 3계층: 벡터 임베딩
embedding = mapped_column(Vector(768), nullable=True)
embed_model_version: Mapped[str | None] = mapped_column(String(50))
embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 사용자 메모
user_note: Mapped[str | None] = mapped_column(Text)
# 원본 보존 (변환 전)
original_path: Mapped[str | None] = mapped_column(Text)
original_format: Mapped[str | None] = mapped_column(String(20))
original_hash: Mapped[str | None] = mapped_column(String(64))
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
# 외부 편집 URL
edit_url: Mapped[str | None] = mapped_column(Text)
# 미리보기
preview_status: Mapped[str | None] = mapped_column(String(20), default="none")
preview_hash: Mapped[str | None] = mapped_column(String(64))
preview_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 메타데이터
source_channel: Mapped[str | None] = mapped_column(
Enum("law_monitor", "devonagent", "email", "web_clip",
"tksafety", "inbox_route", "manual", "drive_sync",
name="source_channel")
)
data_origin: Mapped[str | None] = mapped_column(
Enum("work", "external", name="data_origin")
)
title: Mapped[str | None] = mapped_column(Text)
# 타임스탬프
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
)

34
app/models/queue.py Normal file
View File

@@ -0,0 +1,34 @@
"""processing_queue 테이블 ORM (비동기 가공 큐)"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class ProcessingQueue(Base):
__tablename__ = "processing_queue"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
document_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("documents.id"), nullable=False)
stage: Mapped[str] = mapped_column(
Enum("extract", "classify", "embed", "preview", name="process_stage"), nullable=False
)
status: Mapped[str] = mapped_column(
Enum("pending", "processing", "completed", "failed", name="process_status"),
default="pending"
)
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
max_attempts: Mapped[int] = mapped_column(SmallInteger, default=3)
error_message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("document_id", "stage", "status"),
)

29
app/models/task.py Normal file
View File

@@ -0,0 +1,29 @@
"""tasks 테이블 ORM (CalDAV 캐시)"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, SmallInteger, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
caldav_uid: Mapped[str | None] = mapped_column(Text, unique=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
priority: Mapped[int] = mapped_column(SmallInteger, default=0)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
document_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("documents.id"))
source: Mapped[str | None] = mapped_column(String(50))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
)

22
app/models/user.py Normal file
View File

@@ -0,0 +1,22 @@
"""users 테이블 ORM"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret: Mapped[str | None] = mapped_column(String(64))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

93
app/prompts/classify.txt Normal file
View File

@@ -0,0 +1,93 @@
You are a document classification AI. Analyze the document below and respond ONLY in JSON format. No other text.
## Response Format
{
"domain": "Level1/Level2/Level3",
"document_type": "one of document_types",
"confidence": 0.85,
"tags": ["tag1", "tag2"],
"importance": "medium",
"sourceChannel": "inbox_route",
"dataOrigin": "work or external"
}
## Domain Taxonomy (select the most specific leaf node)
Philosophy/
Ethics, Metaphysics, Epistemology, Logic, Aesthetics, Eastern_Philosophy, Western_Philosophy
Language/
Korean, English, Japanese, Translation, Linguistics
Engineering/
Mechanical/ Piping, HVAC, Equipment
Electrical/ Power, Instrumentation
Chemical/ Process, Material
Civil
Network/ Server, Security, Infrastructure
Industrial_Safety/
Legislation/ Act, Decree, Foreign_Law, Korea_Law_Archive, Enforcement_Rule, Public_Notice, SAPA
Theory/ Industrial_Safety_General, Safety_Health_Fundamentals
Academic_Papers/ Safety_General, Risk_Assessment_Research
Cases/ Domestic, International
Practice/ Checklist, Contractor_Management, Safety_Education, Emergency_Plan, Patrol_Inspection, Permit_to_Work, PPE, Safety_Plan
Risk_Assessment/ KRAS, JSA, Checklist_Method
Safety_Manager/ Appointment, Duty_Record, Improvement, Inspection, Meeting
Health_Manager/ Appointment, Duty_Record, Ergonomics, Health_Checkup, Mental_Health, MSDS, Work_Environment
Programming/
Programming_Language/ Python, JavaScript, Go, Rust
Framework/ FastAPI, SvelteKit, React
DevOps/ Docker, CI_CD, Linux_Administration
AI_ML/ Large_Language_Model, Computer_Vision, Data_Science
Database
Software_Architecture
General/
Reading_Notes, Self_Development, Business, Science, History
## Classification Rules
- domain MUST be the most specific leaf node (e.g., Industrial_Safety/Practice/Patrol_Inspection, NOT Industrial_Safety/Practice)
- domain MUST be exactly ONE path
- If content spans multiple domains, choose by PRIMARY purpose
- If safety content is >30%, prefer Industrial_Safety
- If code is included, prefer Programming
- 2-level paths allowed ONLY when no leaf exists (e.g., Engineering/Civil)
## Document Types (select exactly ONE)
Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Document, Report, Memo, Checklist, Meeting_Minutes, Specification
### Document Type Detection Rules
- Step-by-step instructions → Manual
- Legal clauses/regulations → Law_Document
- Technical requirements → Specification
- Meeting discussion → Meeting_Minutes
- Checklist format → Checklist
- Academic/research format → Academic_Paper
- Technical drawings → Drawing
- If unclear → Note
## Confidence (0.0 ~ 1.0)
- How confident are you in the domain classification?
- 0.85+ = high confidence, 0.6~0.85 = moderate, <0.6 = uncertain
## Tags
- Free-form tags (Korean or English)
- Include: person names, technology names, concepts, project names
- Maximum 5 tags
## Importance
- high: urgent or critical documents
- medium: normal working documents
- low: reference or archive material
## sourceChannel
- inbox_route (this classification)
## dataOrigin
- work: company-related (TK, Technicalkorea, factory, production)
- external: external reference (news, papers, laws, general info)
## Document to classify
{document_text}

17
app/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
sqlalchemy[asyncio]>=2.0.0
asyncpg>=0.29.0
pgvector>=0.3.0
python-dotenv>=1.0.0
pyyaml>=6.0
httpx>=0.27.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
pyotp>=2.9.0
caldav>=1.3.0
apscheduler>=3.10.0
anthropic>=0.40.0
markdown>=3.5.0
python-multipart>=0.0.9
jinja2>=3.1.0

405
app/templates/setup.html Normal file
View File

@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hyungi Document Server — 초기 설정</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--text-dim: #8b8d98;
--accent: #6c8aff;
--accent-hover: #859dff;
--error: #f5564e;
--success: #4ade80;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 100%;
max-width: 480px;
padding: 2rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--text-dim);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.steps {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.step-dot {
width: 2.5rem;
height: 4px;
border-radius: 2px;
background: var(--border);
transition: background 0.3s;
}
.step-dot.active { background: var(--accent); }
.step-dot.done { background: var(--success); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 0.3rem;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: var(--accent); }
.btn {
display: inline-block;
padding: 0.65rem 1.5rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s;
width: 100%;
margin-top: 0.5rem;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-skip {
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
margin-top: 0.5rem;
}
.btn-skip:hover { border-color: var(--text-dim); }
.error-msg {
color: var(--error);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.success-msg {
color: var(--success);
font-size: 0.85rem;
margin-top: 0.5rem;
display: none;
}
.qr-wrap {
display: flex;
justify-content: center;
margin: 1rem 0;
background: #fff;
border-radius: 8px;
padding: 1rem;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.secret-text {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-dim);
word-break: break-all;
text-align: center;
margin-bottom: 1rem;
}
.nas-result {
font-size: 0.85rem;
margin-top: 0.5rem;
}
.nas-result span { margin-right: 1rem; }
.check { color: var(--success); }
.cross { color: var(--error); }
.hidden { display: none; }
.done-icon {
font-size: 3rem;
text-align: center;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h1>hyungi Document Server</h1>
<p class="subtitle">초기 설정 위자드</p>
<div class="steps">
<div class="step-dot active" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
</div>
<!-- Step 0: 관리자 계정 -->
<div class="card" id="step-0">
<h2>1. 관리자 계정 생성</h2>
<div class="field">
<label for="username">아이디</label>
<input type="text" id="username" placeholder="admin" autocomplete="username">
</div>
<div class="field">
<label for="password">비밀번호 (8자 이상)</label>
<input type="password" id="password" autocomplete="new-password">
</div>
<div class="field">
<label for="password2">비밀번호 확인</label>
<input type="password" id="password2" autocomplete="new-password">
</div>
<div class="error-msg" id="admin-error"></div>
<button class="btn" onclick="createAdmin()">계정 생성</button>
</div>
<!-- Step 1: TOTP 2FA -->
<div class="card hidden" id="step-1">
<h2>2. 2단계 인증 (TOTP)</h2>
<p style="color: var(--text-dim); font-size: 0.85rem; margin-bottom: 1rem;">
Google Authenticator 등 인증 앱으로 QR 코드를 스캔하세요.
</p>
<div class="qr-wrap" id="qr-container"></div>
<p class="secret-text" id="totp-secret-text"></p>
<div class="field">
<label for="totp-code">인증 코드 6자리</label>
<input type="text" id="totp-code" maxlength="6" placeholder="000000" inputmode="numeric" pattern="[0-9]*">
</div>
<div class="error-msg" id="totp-error"></div>
<div class="success-msg" id="totp-success"></div>
<button class="btn" onclick="verifyTOTP()">인증 확인</button>
<button class="btn btn-skip" onclick="skipTOTP()">건너뛰기</button>
</div>
<!-- Step 2: NAS 경로 확인 -->
<div class="card hidden" id="step-2">
<h2>3. NAS 저장소 경로 확인</h2>
<div class="field">
<label for="nas-path">NAS 마운트 경로</label>
<input type="text" id="nas-path" value="/documents">
</div>
<div class="nas-result hidden" id="nas-result"></div>
<div class="error-msg" id="nas-error"></div>
<button class="btn" onclick="verifyNAS()">경로 확인</button>
<button class="btn btn-skip" onclick="finishSetup()">건너뛰기</button>
</div>
<!-- Step 3: 완료 -->
<div class="card hidden" id="step-3">
<div class="done-icon">&#10003;</div>
<h2 style="text-align:center;">설정 완료</h2>
<p style="color: var(--text-dim); text-align: center; margin: 1rem 0;">
관리자 계정이 생성되었습니다. API 문서에서 엔드포인트를 확인하세요.
</p>
<button class="btn" onclick="location.href='/docs'">API 문서 열기</button>
</div>
</div>
<script>
const API = '/api/setup';
let currentStep = 0;
let authToken = '';
let totpSecret = '';
function showStep(n) {
for (let i = 0; i < 4; i++) {
const el = document.getElementById('step-' + i);
if (el) el.classList.toggle('hidden', i !== n);
}
for (let i = 0; i < 3; i++) {
const dot = document.getElementById('dot-' + i);
dot.classList.remove('active', 'done');
if (i < n) dot.classList.add('done');
else if (i === n) dot.classList.add('active');
}
currentStep = n;
}
function showError(id, msg) {
const el = document.getElementById(id);
el.textContent = msg;
el.style.display = 'block';
}
function hideError(id) {
document.getElementById(id).style.display = 'none';
}
async function createAdmin() {
hideError('admin-error');
const username = document.getElementById('username').value.trim() || 'admin';
const password = document.getElementById('password').value;
const password2 = document.getElementById('password2').value;
if (password !== password2) {
showError('admin-error', '비밀번호가 일치하지 않습니다');
return;
}
if (password.length < 8) {
showError('admin-error', '비밀번호는 8자 이상이어야 합니다');
return;
}
try {
const res = await fetch(API + '/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
showError('admin-error', data.detail || '계정 생성 실패');
return;
}
authToken = data.access_token;
await initTOTP();
showStep(1);
} catch (e) {
showError('admin-error', '서버 연결 실패: ' + e.message);
}
}
async function initTOTP() {
try {
const res = await fetch(API + '/totp/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken,
},
});
const data = await res.json();
totpSecret = data.secret;
document.getElementById('totp-secret-text').textContent = '수동 입력: ' + data.secret;
const container = document.getElementById('qr-container');
container.innerHTML = '';
QRCode.toCanvas(document.createElement('canvas'), data.otpauth_uri, {
width: 200,
margin: 0,
}, function(err, canvas) {
if (!err) container.appendChild(canvas);
});
} catch (e) {
console.error('TOTP init failed:', e);
}
}
async function verifyTOTP() {
hideError('totp-error');
const code = document.getElementById('totp-code').value.trim();
if (code.length !== 6) {
showError('totp-error', '6자리 코드를 입력하세요');
return;
}
try {
const res = await fetch(API + '/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: totpSecret, code }),
});
const data = await res.json();
if (!res.ok) {
showError('totp-error', data.detail || 'TOTP 검증 실패');
return;
}
const el = document.getElementById('totp-success');
el.textContent = '2단계 인증이 활성화되었습니다';
el.style.display = 'block';
setTimeout(() => showStep(2), 1000);
} catch (e) {
showError('totp-error', '서버 연결 실패');
}
}
function skipTOTP() {
showStep(2);
}
async function verifyNAS() {
hideError('nas-error');
const path = document.getElementById('nas-path').value.trim();
if (!path) {
showError('nas-error', '경로를 입력하세요');
return;
}
try {
const res = await fetch(API + '/verify-nas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) {
showError('nas-error', data.detail || '경로 확인 실패');
return;
}
const result = document.getElementById('nas-result');
result.innerHTML = `
<span class="${data.exists ? 'check' : 'cross'}">${data.exists ? '&#10003;' : '&#10007;'} 존재</span>
<span class="${data.readable ? 'check' : 'cross'}">${data.readable ? '&#10003;' : '&#10007;'} 읽기</span>
<span class="${data.writable ? 'check' : 'cross'}">${data.writable ? '&#10003;' : '&#10007;'} 쓰기</span>
`;
result.classList.remove('hidden');
if (data.exists && data.readable) {
setTimeout(() => finishSetup(), 1500);
}
} catch (e) {
showError('nas-error', '서버 연결 실패');
}
}
function finishSetup() {
showStep(3);
}
// 초기화: 이미 셋업 완료 상태인지 확인
(async () => {
try {
const res = await fetch(API + '/status');
const data = await res.json();
if (!data.needs_setup) {
location.href = '/docs';
}
} catch (e) {
// 서버 연결 실패 시 그냥 위자드 표시
}
})();
</script>
</body>
</html>

0
app/workers/__init__.py Normal file
View File

View File

@@ -0,0 +1,132 @@
"""AI 분류 워커 — taxonomy 기반 도메인/문서타입/태그/요약 생성"""
import yaml
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient, parse_json_response, strip_thinking
from core.config import settings
from core.utils import setup_logger
from models.document import Document
logger = setup_logger("classify_worker")
MAX_CLASSIFY_TEXT = 8000
# config.yaml에서 taxonomy 로딩
_config_path = Path(__file__).resolve().parent.parent / "config.yaml"
_config = yaml.safe_load(_config_path.read_text(encoding="utf-8"))
DOCUMENT_TYPES = set(_config.get("document_types", []))
def _get_taxonomy_leaf_paths(taxonomy: dict, prefix: str = "") -> set[str]:
"""taxonomy dict에서 모든 유효한 경로를 추출"""
paths = set()
for key, value in taxonomy.items():
current = f"{prefix}/{key}" if prefix else key
if isinstance(value, dict):
if not value:
paths.add(current)
else:
paths.update(_get_taxonomy_leaf_paths(value, current))
elif isinstance(value, list):
if not value:
paths.add(current)
else:
for leaf in value:
paths.add(f"{current}/{leaf}")
paths.add(current) # 2단계도 허용 (leaf가 없는 경우용)
else:
paths.add(current)
return paths
VALID_DOMAIN_PATHS = _get_taxonomy_leaf_paths(_config.get("taxonomy", {}))
def _validate_domain(domain: str) -> str:
"""domain이 taxonomy에 존재하는지 검증, 없으면 최대한 가까운 경로 찾기"""
if domain in VALID_DOMAIN_PATHS:
return domain
# 부분 매칭 시도 (2단계까지)
parts = domain.split("/")
for i in range(len(parts), 0, -1):
partial = "/".join(parts[:i])
if partial in VALID_DOMAIN_PATHS:
logger.warning(f"[분류] domain '{domain}''{partial}' (부분 매칭)")
return partial
logger.warning(f"[분류] domain '{domain}' taxonomy에 없음, General/Reading_Notes로 대체")
return "General/Reading_Notes"
async def process(document_id: int, session: AsyncSession) -> None:
"""문서 AI 분류 + 요약"""
doc = await session.get(Document, document_id)
if not doc:
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
if not doc.extracted_text:
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
client = AIClient()
try:
# ─── 분류 ───
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
raw_response = await client.classify(truncated)
parsed = parse_json_response(raw_response)
if not parsed:
raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}")
# domain 검증
domain = _validate_domain(parsed.get("domain", ""))
doc.ai_domain = domain
# sub_group은 domain 경로에서 추출 (호환성)
parts = domain.split("/")
doc.ai_sub_group = parts[1] if len(parts) > 1 else ""
# document_type 검증
doc_type = parsed.get("document_type", "")
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
# confidence
confidence = parsed.get("confidence", 0.5)
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
# importance
importance = parsed.get("importance", "medium")
doc.importance = importance if importance in ("high", "medium", "low") else "medium"
# tags
doc.ai_tags = parsed.get("tags", [])[:5]
# source/origin
if parsed.get("sourceChannel") and not doc.source_channel:
doc.source_channel = parsed["sourceChannel"]
if parsed.get("dataOrigin") and not doc.data_origin:
doc.data_origin = parsed["dataOrigin"]
# ─── 요약 ───
summary = await client.summarize(doc.extracted_text[:15000])
doc.ai_summary = strip_thinking(summary)
# ─── 메타데이터 ───
doc.ai_model_version = "qwen3.5-35b-a3b"
doc.ai_processed_at = datetime.now(timezone.utc)
logger.info(
f"[분류] document_id={document_id}: "
f"domain={domain}, type={doc.document_type}, "
f"confidence={doc.ai_confidence:.2f}, tags={doc.ai_tags}"
)
finally:
await client.close()
# _move_to_knowledge 제거됨 — 파일은 원본 위치 유지, 분류는 DB 메타데이터만

146
app/workers/daily_digest.py Normal file
View File

@@ -0,0 +1,146 @@
"""일일 다이제스트 워커 — PostgreSQL/CalDAV 쿼리 → Markdown + SMTP
v1 scripts/pkm_daily_digest.py에서 포팅.
DEVONthink/OmniFocus → PostgreSQL/CalDAV 쿼리로 전환.
"""
import os
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import func, select, text
from core.config import settings
from core.database import async_session
from core.utils import send_smtp_email, setup_logger
from models.document import Document
from models.queue import ProcessingQueue
logger = setup_logger("daily_digest")
async def run():
"""일일 다이제스트 생성 + 저장 + 발송"""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
sections = []
async with async_session() as session:
# ─── 1. 오늘 추가된 문서 ───
added = await session.execute(
select(Document.ai_domain, func.count(Document.id))
.where(func.date(Document.created_at) == today)
.group_by(Document.ai_domain)
)
added_rows = added.all()
total_added = sum(row[1] for row in added_rows)
section = f"## 오늘 추가된 문서 ({total_added}건)\n"
if added_rows:
for domain, count in added_rows:
section += f"- {domain or '미분류'}: {count}\n"
else:
section += "- 없음\n"
sections.append(section)
# ─── 2. 법령 변경 ───
law_docs = await session.execute(
select(Document.title)
.where(
Document.source_channel == "law_monitor",
func.date(Document.created_at) == today,
)
)
law_rows = law_docs.scalars().all()
section = f"## 법령 변경 ({len(law_rows)}건)\n"
if law_rows:
for title in law_rows:
section += f"- {title}\n"
else:
section += "- 변경 없음\n"
sections.append(section)
# ─── 3. 이메일 수집 ───
email_count = await session.execute(
select(func.count(Document.id))
.where(
Document.source_channel == "email",
func.date(Document.created_at) == today,
)
)
email_total = email_count.scalar() or 0
sections.append(f"## 이메일 수집\n- {email_total}건 아카이브\n")
# ─── 4. 처리 파이프라인 상태 ───
queue_stats = await session.execute(
text("""
SELECT stage, status, COUNT(*)
FROM processing_queue
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY stage, status
ORDER BY stage, status
""")
)
queue_rows = queue_stats.all()
section = "## 파이프라인 상태 (24h)\n"
if queue_rows:
for stage, status, count in queue_rows:
section += f"- {stage}/{status}: {count}\n"
else:
section += "- 처리 항목 없음\n"
# 실패 건수 강조
failed = await session.execute(
select(func.count())
.select_from(ProcessingQueue)
.where(
ProcessingQueue.status == "failed",
ProcessingQueue.created_at > text("NOW() - INTERVAL '24 hours'"),
)
)
failed_count = failed.scalar() or 0
if failed_count > 0:
section += f"\n⚠️ **실패 {failed_count}건** — 수동 확인 필요\n"
sections.append(section)
# ─── 5. Inbox 미분류 ───
inbox_count = await session.execute(
select(func.count(Document.id))
.where(Document.file_path.like("PKM/Inbox/%"))
)
inbox_total = inbox_count.scalar() or 0
if inbox_total > 0:
sections.append(f"## Inbox 미분류\n- {inbox_total}건 대기 중\n")
# ─── Markdown 조합 ───
date_display = datetime.now(timezone.utc).strftime("%Y년 %m월 %d")
markdown = f"# PKM 일일 다이제스트 — {date_display}\n\n"
markdown += "\n".join(sections)
markdown += f"\n---\n*생성: {datetime.now(timezone.utc).isoformat()}*\n"
# ─── NAS 저장 ───
digest_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "digests"
digest_dir.mkdir(parents=True, exist_ok=True)
digest_path = digest_dir / f"{today}_digest.md"
digest_path.write_text(markdown, encoding="utf-8")
# ─── 90일 초과 아카이브 ───
archive_dir = digest_dir / "archive"
archive_dir.mkdir(exist_ok=True)
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
for old in digest_dir.glob("*_digest.md"):
if old.stat().st_mtime < cutoff:
old.rename(archive_dir / old.name)
# ─── SMTP 발송 ───
smtp_host = os.getenv("MAILPLUS_HOST", "")
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
smtp_user = os.getenv("MAILPLUS_USER", "")
smtp_pass = os.getenv("MAILPLUS_PASS", "")
if smtp_host and smtp_user:
send_smtp_email(
smtp_host, smtp_port, smtp_user, smtp_pass,
f"PKM 다이제스트 — {date_display}",
markdown,
)
logger.info(f"다이제스트 생성 완료: {digest_path}")

View File

@@ -0,0 +1,44 @@
"""벡터 임베딩 워커 — GPU 서버 nomic-embed-text 호출"""
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient
from core.utils import setup_logger
from models.document import Document
logger = setup_logger("embed_worker")
# 임베딩용 텍스트 최대 길이 (nomic-embed-text: 8192 토큰)
MAX_EMBED_TEXT = 6000
EMBED_MODEL_VERSION = "nomic-embed-text-v1.5"
async def process(document_id: int, session: AsyncSession) -> None:
"""문서 벡터 임베딩 생성"""
doc = await session.get(Document, document_id)
if not doc:
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
if not doc.extracted_text:
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
# title + 본문 앞부분을 결합하여 임베딩 입력 생성
title_part = doc.title or ""
text_part = doc.extracted_text[:MAX_EMBED_TEXT]
embed_input = f"{title_part}\n\n{text_part}".strip()
if not embed_input:
logger.warning(f"[임베딩] document_id={document_id}: 빈 텍스트, 스킵")
return
client = AIClient()
try:
vector = await client.embed(embed_input)
doc.embedding = vector
doc.embed_model_version = EMBED_MODEL_VERSION
doc.embedded_at = datetime.now(timezone.utc)
logger.info(f"[임베딩] document_id={document_id}: {len(vector)}차원 벡터 생성")
finally:
await client.close()

View File

@@ -0,0 +1,175 @@
"""텍스트 추출 워커 — kordoc / LibreOffice / 직접 읽기"""
import subprocess
from datetime import datetime, timezone
from pathlib import Path
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.utils import setup_logger
from models.document import Document
logger = setup_logger("extract_worker")
# kordoc으로 파싱 가능한 포맷
KORDOC_FORMATS = {"hwp", "hwpx", "pdf"}
# 직접 읽기 가능한 텍스트 포맷
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
# LibreOffice로 텍스트 추출 가능한 포맷
OFFICE_FORMATS = {"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "odoc", "osheet"}
# OCR 필요 이미지 포맷 (Phase 2)
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif"}
EXTRACTOR_VERSION = "kordoc@1.7"
async def process(document_id: int, session: AsyncSession) -> None:
"""문서 텍스트 추출"""
doc = await session.get(Document, document_id)
if not doc:
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
fmt = doc.file_format.lower()
full_path = Path(settings.nas_mount_path) / doc.file_path
# 텍스트 파일 — 직접 읽기
if fmt in TEXT_FORMATS:
if not full_path.exists():
raise FileNotFoundError(f"파일 없음: {full_path}")
text = full_path.read_text(encoding="utf-8", errors="replace")
doc.extracted_text = text
doc.extracted_at = datetime.now(timezone.utc)
doc.extractor_version = "direct_read"
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
return
# 이미지 — 스킵 (Phase 2 OCR)
if fmt in IMAGE_FORMATS:
doc.extracted_text = ""
doc.extracted_at = datetime.now(timezone.utc)
doc.extractor_version = "skip_image"
logger.info(f"[이미지] {doc.file_path} — OCR 미구현, 스킵")
return
# kordoc 파싱 (HWP/HWPX/PDF)
if fmt in KORDOC_FORMATS:
# 컨테이너 내부 경로: /documents/{file_path}
container_path = f"/documents/{doc.file_path}"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{settings.kordoc_endpoint}/parse",
json={"filePath": container_path},
)
if resp.status_code == 404:
raise FileNotFoundError(f"kordoc: 파일 없음 — {container_path}")
if resp.status_code == 422:
raise ValueError(f"kordoc: 파싱 실패 — {resp.json().get('error', 'unknown')}")
resp.raise_for_status()
data = resp.json()
doc.extracted_text = data.get("markdown", "")
doc.extracted_at = datetime.now(timezone.utc)
doc.extractor_version = EXTRACTOR_VERSION
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
return
# 오피스 포맷 — LibreOffice 텍스트 변환
if fmt in OFFICE_FORMATS:
if not full_path.exists():
raise FileNotFoundError(f"파일 없음: {full_path}")
import shutil
tmp_dir = Path("/tmp/extract_work")
tmp_dir.mkdir(exist_ok=True)
# 한글 파일명 문제 방지 — 영문 임시 파일로 복사
tmp_input = tmp_dir / f"input_{document_id}.{fmt}"
shutil.copy2(str(full_path), str(tmp_input))
# 스프레드시트는 csv, 나머지는 txt
CALC_FORMATS = {"xlsx", "xls", "ods", "osheet"}
if fmt in CALC_FORMATS:
convert_to = "csv:Text - txt - csv (StarCalc):44,34,76,1"
out_ext = "csv"
else:
convert_to = "txt:Text"
out_ext = "txt"
try:
result = subprocess.run(
["libreoffice", "--headless", "--convert-to", convert_to, "--outdir", str(tmp_dir), str(tmp_input)],
capture_output=True, text=True, timeout=60,
)
out_file = tmp_dir / f"input_{document_id}.{out_ext}"
if out_file.exists():
text = out_file.read_text(encoding="utf-8", errors="replace")
doc.extracted_text = text[:15000]
doc.extracted_at = datetime.now(timezone.utc)
doc.extractor_version = "libreoffice"
out_file.unlink()
logger.info(f"[LibreOffice] {doc.file_path} ({len(text)}자)")
else:
raise RuntimeError(f"LibreOffice 변환 실패: {result.stderr[:300]}")
except subprocess.TimeoutExpired:
raise RuntimeError(f"LibreOffice 텍스트 추출 timeout (60s)")
finally:
tmp_input.unlink(missing_ok=True)
# ─── ODF 변환 (편집용) ───
CONVERT_MAP = {
'xlsx': 'ods', 'xls': 'ods',
'docx': 'odt', 'doc': 'odt',
'pptx': 'odp', 'ppt': 'odp',
}
target_fmt = CONVERT_MAP.get(fmt)
if target_fmt:
try:
from core.utils import file_hash as calc_hash
# 원본 메타 보존
doc.original_path = doc.file_path
doc.original_format = doc.file_format
doc.original_hash = doc.file_hash
# .derived 디렉토리에 변환
derived_dir = full_path.parent / ".derived"
derived_dir.mkdir(exist_ok=True)
tmp_input2 = tmp_dir / f"convert_{document_id}.{fmt}"
shutil.copy2(str(full_path), str(tmp_input2))
conv_result = subprocess.run(
["libreoffice", "--headless", "--convert-to", target_fmt, "--outdir", str(tmp_dir), str(tmp_input2)],
capture_output=True, text=True, timeout=60,
)
tmp_input2.unlink(missing_ok=True)
conv_file = tmp_dir / f"convert_{document_id}.{target_fmt}"
if conv_file.exists():
final_path = derived_dir / f"{document_id}.{target_fmt}"
shutil.move(str(conv_file), str(final_path))
# DB 업데이트: current → ODF
nas_root = Path(settings.nas_mount_path)
doc.file_path = str(final_path.relative_to(nas_root))
doc.file_format = target_fmt
doc.file_hash = calc_hash(final_path)
doc.conversion_status = "done"
logger.info(f"[ODF변환] {doc.original_path}{doc.file_path}")
else:
doc.conversion_status = "failed"
logger.warning(f"[ODF변환] 실패: {conv_result.stderr[:200]}")
except Exception as e:
doc.conversion_status = "failed"
logger.error(f"[ODF변환] {doc.file_path} 에러: {e}")
else:
doc.conversion_status = "none"
return
# 미지원 포맷
doc.extracted_text = ""
doc.extracted_at = datetime.now(timezone.utc)
doc.extractor_version = f"unsupported_{fmt}"
logger.warning(f"[미지원] {doc.file_path} (format={fmt})")

100
app/workers/file_watcher.py Normal file
View File

@@ -0,0 +1,100 @@
"""파일 감시 워커 — Inbox 디렉토리 스캔, 새 파일/변경 파일 자동 등록"""
from pathlib import Path
from sqlalchemy import select
from core.config import settings
from core.database import async_session
from core.utils import file_hash, setup_logger
from models.document import Document
from models.queue import ProcessingQueue
logger = setup_logger("file_watcher")
# 무시할 파일
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "Icon\r"}
SKIP_EXTENSIONS = {".tmp", ".part", ".crdownload"}
def should_skip(path: Path) -> bool:
if path.name in SKIP_NAMES or path.name.startswith("._"):
return True
if path.suffix.lower() in SKIP_EXTENSIONS:
return True
# .derived/ 및 .preview/ 디렉토리 내 파일 제외
if ".derived" in path.parts or ".preview" in path.parts:
return True
return False
async def watch_inbox():
"""Inbox 디렉토리를 스캔하여 새/변경 파일을 DB에 등록"""
inbox_path = Path(settings.nas_mount_path) / "PKM" / "Inbox"
if not inbox_path.exists():
return
files = [f for f in inbox_path.rglob("*") if f.is_file() and not should_skip(f)]
if not files:
return
new_count = 0
changed_count = 0
async with async_session() as session:
for file_path in files:
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
fhash = file_hash(file_path)
# DB에서 기존 문서 확인
result = await session.execute(
select(Document).where(Document.file_path == rel_path)
)
existing = result.scalar_one_or_none()
if existing is None:
# 새 파일 → 등록
ext = file_path.suffix.lstrip(".").lower() or "unknown"
doc = Document(
file_path=rel_path,
file_hash=fhash,
file_format=ext,
file_size=file_path.stat().st_size,
file_type="immutable",
title=file_path.stem,
source_channel="drive_sync",
)
session.add(doc)
await session.flush()
session.add(ProcessingQueue(
document_id=doc.id,
stage="extract",
status="pending",
))
new_count += 1
elif existing.file_hash != fhash:
# 해시 변경 → 재가공
existing.file_hash = fhash
existing.file_size = file_path.stat().st_size
# 기존 pending/processing 큐 항목이 없으면 extract부터 재시작
queue_check = await session.execute(
select(ProcessingQueue).where(
ProcessingQueue.document_id == existing.id,
ProcessingQueue.status.in_(["pending", "processing"]),
)
)
if not queue_check.scalar_one_or_none():
session.add(ProcessingQueue(
document_id=existing.id,
stage="extract",
status="pending",
))
changed_count += 1
await session.commit()
if new_count or changed_count:
logger.info(f"[Inbox] 새 파일 {new_count}건, 변경 파일 {changed_count}건 등록")

362
app/workers/law_monitor.py Normal file
View File

@@ -0,0 +1,362 @@
"""법령 모니터 워커 — 국가법령정보센터 API 연동
26개 법령 모니터링, 편/장 단위 분할 저장, 변경 이력 추적.
매일 07:00 실행 (APScheduler).
"""
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from xml.etree import ElementTree as ET
import httpx
from sqlalchemy import select
from core.config import settings
from core.database import async_session
from core.utils import create_caldav_todo, escape_ical_text, file_hash, send_smtp_email, setup_logger
from models.automation import AutomationState
from models.document import Document
from models.queue import ProcessingQueue
logger = setup_logger("law_monitor")
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
# 모니터링 대상 법령 (26개)
MONITORED_LAWS = [
# 산업안전보건 핵심
"산업안전보건법",
"산업안전보건법 시행령",
"산업안전보건법 시행규칙",
"산업안전보건기준에 관한 규칙",
"유해위험작업의 취업 제한에 관한 규칙",
"중대재해 처벌 등에 관한 법률",
"중대재해 처벌 등에 관한 법률 시행령",
# 건설안전
"건설기술 진흥법",
"건설기술 진흥법 시행령",
"건설기술 진흥법 시행규칙",
"시설물의 안전 및 유지관리에 관한 특별법",
# 위험물/화학
"위험물안전관리법",
"위험물안전관리법 시행령",
"위험물안전관리법 시행규칙",
"화학물질관리법",
"화학물질관리법 시행령",
"화학물질의 등록 및 평가 등에 관한 법률",
# 소방/전기/가스
"소방시설 설치 및 관리에 관한 법률",
"소방시설 설치 및 관리에 관한 법률 시행령",
"전기사업법",
"전기안전관리법",
"고압가스 안전관리법",
"고압가스 안전관리법 시행령",
"액화석유가스의 안전관리 및 사업법",
# 근로/환경
"근로기준법",
"환경영향평가법",
]
async def run():
"""법령 변경 모니터링 실행"""
law_oc = os.getenv("LAW_OC", "")
if not law_oc:
logger.warning("LAW_OC 미설정 — 법령 API 승인 대기 중")
return
async with async_session() as session:
state = await session.execute(
select(AutomationState).where(AutomationState.job_name == "law_monitor")
)
state_row = state.scalar_one_or_none()
last_check = state_row.last_check_value if state_row else None
today = datetime.now(timezone.utc).strftime("%Y%m%d")
if last_check == today:
logger.info("오늘 이미 체크 완료")
return
new_count = 0
async with httpx.AsyncClient(timeout=30) as client:
for law_name in MONITORED_LAWS:
try:
count = await _check_law(client, law_oc, law_name, session)
new_count += count
except Exception as e:
logger.error(f"[{law_name}] 체크 실패: {e}")
# 상태 업데이트
if state_row:
state_row.last_check_value = today
state_row.last_run_at = datetime.now(timezone.utc)
else:
session.add(AutomationState(
job_name="law_monitor",
last_check_value=today,
last_run_at=datetime.now(timezone.utc),
))
await session.commit()
logger.info(f"법령 모니터 완료: {new_count}건 신규/변경 감지")
async def _check_law(
client: httpx.AsyncClient,
law_oc: str,
law_name: str,
session,
) -> int:
"""단일 법령 검색 → 변경 감지 → 분할 저장"""
# 법령 검색 (lawSearch.do)
resp = await client.get(
LAW_SEARCH_URL,
params={"OC": law_oc, "target": "law", "type": "XML", "query": law_name},
)
resp.raise_for_status()
root = ET.fromstring(resp.text)
total = root.findtext(".//totalCnt", "0")
if total == "0":
logger.debug(f"[{law_name}] 검색 결과 없음")
return 0
# 정확히 일치하는 법령 찾기
for law_elem in root.findall(".//law"):
found_name = law_elem.findtext("법령명한글", "").strip()
if found_name != law_name:
continue
mst = law_elem.findtext("법령일련번호", "")
proclamation_date = law_elem.findtext("공포일자", "")
revision_type = law_elem.findtext("제개정구분명", "")
if not mst:
continue
# 이미 등록된 법령인지 확인 (같은 법령명 + 공포일자)
existing = await session.execute(
select(Document).where(
Document.title.like(f"{law_name}%"),
Document.source_channel == "law_monitor",
)
)
existing_docs = existing.scalars().all()
# 같은 공포일자 이미 있으면 skip
for doc in existing_docs:
if proclamation_date in (doc.title or ""):
return 0
# 이전 공포일 찾기 (변경 이력용)
prev_date = ""
if existing_docs:
prev_date = max(
(re.search(r'\d{8}', doc.title or "").group() for doc in existing_docs
if re.search(r'\d{8}', doc.title or "")),
default=""
)
# 본문 조회 (lawService.do)
text_resp = await client.get(
LAW_SERVICE_URL,
params={"OC": law_oc, "target": "law", "MST": mst, "type": "XML"},
)
text_resp.raise_for_status()
# 분할 저장
count = await _save_law_split(
session, text_resp.text, law_name, proclamation_date,
revision_type, prev_date,
)
# DB 먼저 커밋 (알림 실패가 저장을 막지 않도록)
await session.commit()
# CalDAV + SMTP 알림 (실패해도 무시)
try:
_send_notifications(law_name, proclamation_date, revision_type)
except Exception as e:
logger.warning(f"[{law_name}] 알림 발송 실패 (무시): {e}")
return count
return 0
async def _save_law_split(
session, xml_text: str, law_name: str, proclamation_date: str,
revision_type: str, prev_date: str,
) -> int:
"""법령 XML → 장(章) 단위 Markdown 분할 저장"""
root = ET.fromstring(xml_text)
# 조문단위에서 장 구분자 찾기 (조문키가 000으로 끝나는 조문)
units = root.findall(".//조문단위")
chapters = [] # [(장제목, [조문들])]
current_chapter = None
current_articles = []
for unit in units:
key = unit.attrib.get("조문키", "")
content = (unit.findtext("조문내용", "") or "").strip()
# 장 구분자: 키가 000으로 끝나고 내용에 "제X장" 포함
if key.endswith("000") and re.search(r"\d+장", content):
# 이전 장 저장
if current_chapter and current_articles:
chapters.append((current_chapter, current_articles))
chapter_match = re.search(r"(제\d+장\s*.+)", content)
current_chapter = chapter_match.group(1).strip() if chapter_match else content.strip()
current_articles = []
else:
current_articles.append(unit)
# 마지막 장 저장
if current_chapter and current_articles:
chapters.append((current_chapter, current_articles))
# 장 분할 성공
sections = []
if chapters:
for chapter_title, articles in chapters:
md_lines = [f"# {law_name}\n", f"## {chapter_title}\n"]
for article in articles:
title = article.findtext("조문제목", "")
content = article.findtext("조문내용", "")
if title:
md_lines.append(f"\n### {title}\n")
if content:
md_lines.append(content.strip())
section_name = _safe_name(chapter_title)
sections.append((section_name, "\n".join(md_lines)))
else:
# 장 분할 실패 → 전체 1파일
full_md = _law_xml_to_markdown(xml_text, law_name)
sections.append(("전문", full_md))
# 각 섹션 저장
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
inbox_dir.mkdir(parents=True, exist_ok=True)
count = 0
for section_name, content in sections:
filename = f"{law_name}_{proclamation_date}_{section_name}.md"
file_path = inbox_dir / filename
file_path.write_text(content, encoding="utf-8")
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
# 변경 이력 메모
note = ""
if prev_date:
note = (
f"[자동] 법령 개정 감지\n"
f"이전 공포일: {prev_date}\n"
f"현재 공포일: {proclamation_date}\n"
f"개정구분: {revision_type}"
)
doc = Document(
file_path=rel_path,
file_hash=file_hash(file_path),
file_format="md",
file_size=len(content.encode()),
file_type="immutable",
title=f"{law_name} ({proclamation_date}) {section_name}",
source_channel="law_monitor",
data_origin="work",
user_note=note or None,
)
session.add(doc)
await session.flush()
session.add(ProcessingQueue(
document_id=doc.id, stage="extract", status="pending",
))
count += 1
logger.info(f"[법령] {law_name} ({proclamation_date}) → {count}개 섹션 저장")
return count
def _xml_section_to_markdown(elem) -> str:
"""XML 섹션(편/장)을 Markdown으로 변환"""
lines = []
for article in elem.iter():
tag = article.tag
text = (article.text or "").strip()
if not text:
continue
if "" in tag:
lines.append(f"\n### {text}\n")
elif "" in tag:
lines.append(f"\n{text}\n")
elif "" in tag:
lines.append(f"- {text}")
elif "" in tag:
lines.append(f" - {text}")
else:
lines.append(text)
return "\n".join(lines)
def _law_xml_to_markdown(xml_text: str, law_name: str) -> str:
"""법령 XML 전체를 Markdown으로 변환"""
root = ET.fromstring(xml_text)
lines = [f"# {law_name}\n"]
for elem in root.iter():
tag = elem.tag
text = (elem.text or "").strip()
if not text:
continue
if "" in tag and "제목" not in tag:
lines.append(f"\n## {text}\n")
elif "" in tag and "제목" not in tag:
lines.append(f"\n## {text}\n")
elif "" in tag:
lines.append(f"\n### {text}\n")
elif "" in tag:
lines.append(f"\n{text}\n")
elif "" in tag:
lines.append(f"- {text}")
elif "" in tag:
lines.append(f" - {text}")
return "\n".join(lines)
def _safe_name(name: str) -> str:
"""파일명 안전 변환"""
return re.sub(r'[^\w가-힣-]', '_', name).strip("_")
def _send_notifications(law_name: str, proclamation_date: str, revision_type: str):
"""CalDAV + SMTP 알림"""
# CalDAV
caldav_url = os.getenv("CALDAV_URL", "")
caldav_user = os.getenv("CALDAV_USER", "")
caldav_pass = os.getenv("CALDAV_PASS", "")
if caldav_url and caldav_user:
create_caldav_todo(
caldav_url, caldav_user, caldav_pass,
title=f"법령 검토: {law_name}",
description=f"공포일자: {proclamation_date}, 개정구분: {revision_type}",
due_days=7,
)
# SMTP
smtp_host = os.getenv("MAILPLUS_HOST", "")
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
smtp_user = os.getenv("MAILPLUS_USER", "")
smtp_pass = os.getenv("MAILPLUS_PASS", "")
if smtp_host and smtp_user:
send_smtp_email(
smtp_host, smtp_port, smtp_user, smtp_pass,
subject=f"[법령 변경] {law_name} ({revision_type})",
body=f"법령명: {law_name}\n공포일자: {proclamation_date}\n개정구분: {revision_type}",
)

View File

@@ -0,0 +1,213 @@
"""이메일 수집 워커 — Synology MailPlus IMAP → NAS 저장 + DB 등록
v1 scripts/mailplus_archive.py에서 포팅.
imaplib (동기)를 asyncio.to_thread()로 래핑.
"""
import asyncio
import email
import imaplib
import os
import re
from datetime import datetime, timedelta, timezone
from email.header import decode_header
from pathlib import Path
from sqlalchemy import select
from core.config import settings
from core.database import async_session
from core.utils import file_hash, send_smtp_email, setup_logger
from models.automation import AutomationState
from models.document import Document
from models.queue import ProcessingQueue
logger = setup_logger("mailplus_archive")
# 업무 키워드 (data_origin 자동 감지)
WORK_KEYWORDS = {"테크니컬코리아", "TK", "공장", "생산", "사내", "안전", "점검"}
def _decode_mime_header(raw: str) -> str:
"""MIME 헤더 디코딩"""
parts = decode_header(raw)
decoded = []
for data, charset in parts:
if isinstance(data, bytes):
decoded.append(data.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(data)
return "".join(decoded)
def _sanitize_filename(name: str, max_len: int = 80) -> str:
"""파일명에 사용 불가한 문자 제거"""
clean = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
return clean[:max_len].strip()
def _detect_origin(subject: str, body: str) -> str:
"""work/external 자동 감지"""
text = f"{subject} {body[:500]}".lower()
for kw in WORK_KEYWORDS:
if kw.lower() in text:
return "work"
return "external"
def _fetch_emails_sync(host: str, port: int, user: str, password: str, last_uid: int | None):
"""동기 IMAP 메일 가져오기 (asyncio.to_thread에서 실행)"""
results = []
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
try:
conn.login(user, password)
conn.select("INBOX")
if last_uid:
# 증분 동기화: last_uid 이후
_, data = conn.uid("search", None, f"UID {last_uid + 1}:*")
else:
# 최초 실행: 최근 7일
since = (datetime.now() - timedelta(days=7)).strftime("%d-%b-%Y")
_, data = conn.uid("search", None, f"SINCE {since}")
uids = data[0].split()
for uid_bytes in uids:
uid = int(uid_bytes)
_, msg_data = conn.uid("fetch", uid_bytes, "(RFC822)")
if msg_data[0] is None:
continue
raw = msg_data[0][1]
results.append((uid, raw))
finally:
conn.logout()
return results
async def run():
"""이메일 수집 실행"""
host = os.getenv("MAILPLUS_HOST", "")
port = int(os.getenv("MAILPLUS_PORT", "993"))
user = os.getenv("MAILPLUS_USER", "")
password = os.getenv("MAILPLUS_PASS", "")
if not all([host, user, password]):
logger.warning("MailPlus 인증 정보 미설정")
return
async with async_session() as session:
# 마지막 UID 조회
state = await session.execute(
select(AutomationState).where(AutomationState.job_name == "mailplus")
)
state_row = state.scalar_one_or_none()
last_uid = int(state_row.last_check_value) if state_row and state_row.last_check_value else None
# IMAP 동기 호출을 비동기로 래핑
try:
emails = await asyncio.to_thread(
_fetch_emails_sync, host, port, user, password, last_uid,
)
except Exception as e:
logger.error(f"IMAP 연결 실패: {e}")
return
if not emails:
logger.info("새 이메일 없음")
return
# 이메일 저장 디렉토리
email_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "emails"
email_dir.mkdir(parents=True, exist_ok=True)
max_uid = last_uid or 0
archived = []
for uid, raw_bytes in emails:
try:
msg = email.message_from_bytes(raw_bytes)
subject = _decode_mime_header(msg.get("Subject", "제목없음"))
date_str = msg.get("Date", "")
date = datetime.now().strftime("%Y%m%d")
# .eml 파일 저장
safe_subject = _sanitize_filename(subject)
filename = f"{date}_{uid}_{safe_subject}.eml"
eml_path = email_dir / filename
eml_path.write_bytes(raw_bytes)
# 본문 추출 (텍스트 파트)
body = ""
charset = None
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload is not None:
charset = part.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
break
else:
payload = msg.get_payload(decode=True)
if payload is not None:
charset = msg.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
if "\ufffd" in body[:1000]:
logger.debug(f"[메일] charset={charset or 'unknown'} 디코딩 중 replacement 발생")
# DB 등록
rel_path = str(eml_path.relative_to(Path(settings.nas_mount_path)))
origin = _detect_origin(subject, body)
doc = Document(
file_path=rel_path,
file_hash=file_hash(eml_path),
file_format="eml",
file_size=len(raw_bytes),
file_type="immutable",
title=subject,
source_channel="email",
data_origin=origin,
)
session.add(doc)
await session.flush()
safe_subj = subject.replace("\n", " ").replace("\r", " ")[:200]
# TODO: extract_worker가 eml 본문/첨부 파싱 지원 시 이 조건 제거
if doc.file_format != "eml":
session.add(ProcessingQueue(
document_id=doc.id, stage="extract", status="pending",
))
else:
logger.debug(f"[메일] {safe_subj} — eml extract 미지원, 큐 스킵")
archived.append(safe_subj)
max_uid = max(max_uid, uid)
except Exception as e:
logger.error(f"UID {uid} 처리 실패: {e}")
# 상태 업데이트
if state_row:
state_row.last_check_value = str(max_uid)
state_row.last_run_at = datetime.now(timezone.utc)
else:
session.add(AutomationState(
job_name="mailplus",
last_check_value=str(max_uid),
last_run_at=datetime.now(timezone.utc),
))
await session.commit()
# SMTP 알림
smtp_host = os.getenv("MAILPLUS_HOST", "")
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
if archived and smtp_host:
body = f"이메일 {len(archived)}건 수집 완료:\n\n" + "\n".join(f"- {s}" for s in archived)
send_smtp_email(smtp_host, smtp_port, user, password, "PKM 이메일 수집 알림", body)
logger.info(f"이메일 {len(archived)}건 수집 완료 (max_uid={max_uid})")

View File

@@ -0,0 +1,116 @@
"""PDF 미리보기 생성 워커 — LibreOffice Headless로 문서→PDF 변환"""
import subprocess
import shutil
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.utils import setup_logger
logger = setup_logger("preview_worker")
# PDF 변환 대상 포맷
CONVERTIBLE_FORMATS = {
"docx", "xlsx", "pptx", "odt", "ods", "odp", # 안정 지원
"odoc", "osheet", "hwp", "hwpx", # 검증 필요
}
# 이미 PDF이거나 변환 불필요한 포맷
NATIVE_PDF = {"pdf"}
NATIVE_IMAGE = {"jpg", "jpeg", "png", "gif", "bmp", "tiff"}
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
PREVIEW_DIR_NAME = "PKM/.preview"
TIMEOUT_SECONDS = 60
async def process(document_id: int, session: AsyncSession) -> None:
"""문서 PDF 미리보기 생성"""
from models.document import Document
doc = await session.get(Document, document_id)
if not doc:
logger.error(f"[preview] document_id={document_id} 없음")
return
fmt = doc.file_format.lower()
# PDF/이미지/텍스트는 변환 불필요
if fmt in NATIVE_PDF or fmt in NATIVE_IMAGE or fmt in TEXT_FORMATS:
doc.preview_status = "ready" if fmt in NATIVE_PDF else "none"
doc.preview_at = datetime.now(timezone.utc)
await session.commit()
return
if fmt not in CONVERTIBLE_FORMATS:
doc.preview_status = "none"
await session.commit()
logger.info(f"[preview] {doc.title} — 변환 불가 포맷: {fmt}")
return
# 원본 파일 경로
source = Path(settings.nas_mount_path) / doc.file_path
if not source.exists():
doc.preview_status = "failed"
await session.commit()
logger.error(f"[preview] 원본 없음: {source}")
return
# 미리보기 디렉토리
preview_dir = Path(settings.nas_mount_path) / PREVIEW_DIR_NAME
preview_dir.mkdir(parents=True, exist_ok=True)
output_path = preview_dir / f"{document_id}.pdf"
doc.preview_status = "processing"
await session.commit()
# LibreOffice 변환
try:
tmp_dir = Path("/tmp/preview_work")
tmp_dir.mkdir(exist_ok=True)
# 한글 파일명 문제 방지 — 영문 임시 파일로 복사
tmp_input = tmp_dir / f"input_{document_id}{source.suffix}"
shutil.copy2(str(source), str(tmp_input))
result = subprocess.run(
[
"libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", str(tmp_dir),
str(tmp_input),
],
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
)
tmp_input.unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(f"LibreOffice 변환 실패: {result.stderr[:200]}")
# 변환 결과 찾기
converted = tmp_dir / f"input_{document_id}.pdf"
if not converted.exists():
raise RuntimeError(f"변환 결과물 없음: {converted}")
# 캐시로 이동
shutil.move(str(converted), str(output_path))
doc.preview_status = "ready"
doc.preview_hash = doc.file_hash
doc.preview_at = datetime.now(timezone.utc)
await session.commit()
logger.info(f"[preview] {doc.title} → PDF 변환 완료")
except subprocess.TimeoutExpired:
doc.preview_status = "failed"
await session.commit()
logger.error(f"[preview] {doc.title} — 변환 timeout ({TIMEOUT_SECONDS}s)")
except Exception as e:
doc.preview_status = "failed"
await session.commit()
logger.error(f"[preview] {doc.title} — 변환 실패: {e}")

View File

@@ -0,0 +1,134 @@
"""처리 큐 소비자 — APScheduler에서 1분 간격으로 호출"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from core.database import async_session
from core.utils import setup_logger
from models.queue import ProcessingQueue
logger = setup_logger("queue_consumer")
# stage별 배치 크기
BATCH_SIZE = {"extract": 5, "classify": 3, "embed": 1, "preview": 2}
STALE_THRESHOLD_MINUTES = 10
async def reset_stale_items():
"""processing 상태로 10분 이상 방치된 항목 복구"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=STALE_THRESHOLD_MINUTES)
async with async_session() as session:
result = await session.execute(
update(ProcessingQueue)
.where(
ProcessingQueue.status == "processing",
ProcessingQueue.started_at < cutoff,
)
.values(status="pending", started_at=None)
)
if result.rowcount > 0:
await session.commit()
logger.warning(f"stale 항목 {result.rowcount}건 복구")
async def enqueue_next_stage(document_id: int, current_stage: str):
"""현재 stage 완료 후 다음 stage를 pending으로 등록"""
next_stages = {"extract": ["classify", "preview"], "classify": ["embed"]}
stages = next_stages.get(current_stage, [])
if not stages:
return
async with async_session() as session:
for next_stage in stages:
existing = await session.execute(
select(ProcessingQueue).where(
ProcessingQueue.document_id == document_id,
ProcessingQueue.stage == next_stage,
ProcessingQueue.status.in_(["pending", "processing"]),
)
)
if existing.scalar_one_or_none():
continue
session.add(ProcessingQueue(
document_id=document_id,
stage=next_stage,
status="pending",
))
await session.commit()
async def consume_queue():
"""큐에서 pending 항목을 가져와 stage별 워커 실행"""
from workers.classify_worker import process as classify_process
from workers.embed_worker import process as embed_process
from workers.extract_worker import process as extract_process
from workers.preview_worker import process as preview_process
workers = {
"extract": extract_process,
"classify": classify_process,
"embed": embed_process,
"preview": preview_process,
}
await reset_stale_items()
for stage, worker_fn in workers.items():
batch_size = BATCH_SIZE.get(stage, 3)
# pending 항목 조회
async with async_session() as session:
result = await session.execute(
select(ProcessingQueue.id, ProcessingQueue.document_id)
.where(
ProcessingQueue.stage == stage,
ProcessingQueue.status == "pending",
)
.order_by(ProcessingQueue.created_at)
.limit(batch_size)
)
pending_items = result.all()
# 각 항목을 독립 세션에서 처리
for queue_id, document_id in pending_items:
# 상태를 processing으로 변경
async with async_session() as session:
item = await session.get(ProcessingQueue, queue_id)
if not item or item.status != "pending":
continue
item.status = "processing"
item.started_at = datetime.now(timezone.utc)
item.attempts += 1
await session.commit()
# 워커 실행 (독립 세션)
try:
async with async_session() as worker_session:
await worker_fn(document_id, worker_session)
await worker_session.commit()
# 완료 처리
async with async_session() as session:
item = await session.get(ProcessingQueue, queue_id)
item.status = "completed"
item.completed_at = datetime.now(timezone.utc)
await session.commit()
await enqueue_next_stage(document_id, stage)
logger.info(f"[{stage}] document_id={document_id} 완료")
except Exception as e:
# 실패 처리
async with async_session() as session:
item = await session.get(ProcessingQueue, queue_id)
item.error_message = str(e)[:500]
if item.attempts >= item.max_attempts:
item.status = "failed"
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
else:
item.status = "pending"
item.started_at = None
logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
await session.commit()

View File

@@ -1,124 +0,0 @@
-- DEVONthink 4 Smart Rule: AI 자동 분류
-- Inbox DB 새 문서 → OCR 전처리 → MLX 분류 → 태그 + 메타데이터 + 도메인 DB 이동 → Qdrant 임베딩
-- Smart Rule 설정: Event = On Import, 조건 = Tags is empty
property baseDir : "Documents/code/DEVONThink_my server"
on performSmartRule(theRecords)
set homeDir to POSIX path of (path to home folder)
set pkmRoot to homeDir & baseDir
set venvPython to pkmRoot & "/venv/bin/python3"
set logFile to pkmRoot & "/logs/auto_classify.log"
tell application id "DNtp"
repeat with theRecord in theRecords
try
-- 0. OCR 전처리: 텍스트 없는 PDF/이미지 → Surya OCR
set docText to plain text of theRecord
set docUUID to uuid of theRecord
set docType to type of theRecord as string
if docText is "" then
if docType is in {"PDF Document", "JPEG image", "PNG image", "TIFF image"} then
set ocrPy to pkmRoot & "/scripts/ocr_preprocess.py"
try
set ocrText to do shell script venvPython & " " & quoted form of ocrPy & " " & quoted form of docUUID
if length of ocrText > 0 then
set plain text of theRecord to ocrText
set docText to ocrText
end if
on error ocrErr
do shell script "echo '[OCR ERROR] " & ocrErr & "' >> " & quoted form of logFile
end try
end if
end if
-- 1. 문서 텍스트 추출 (최대 4000자)
if length of docText > 4000 then
set docText to text 1 thru 4000 of docText
end if
if length of docText < 10 then
-- OCR 후에도 텍스트가 부족하면 검토필요 태그
set tags of theRecord to {"@상태/검토필요"}
continue repeat
end if
-- 2. 분류 프롬프트 로딩
set promptPath to pkmRoot & "/scripts/prompts/classify_document.txt"
set promptTemplate to do shell script "cat " & quoted form of promptPath
-- 문서 텍스트를 프롬프트에 삽입 (특수문자 이스케이프)
set escapedText to do shell script "echo " & quoted form of docText & " | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g; s/\\n/\\\\n/g' | head -c 4000"
-- 3. MLX 서버 API 호출 (OpenAI 호환)
set curlCmd to "curl -s --max-time 120 http://localhost:8800/v1/chat/completions -H 'Content-Type: application/json' -d '{\"model\": \"mlx-community/Qwen3.5-35B-A3B-4bit\", \"messages\": [{\"role\": \"user\", \"content\": " & quoted form of escapedText & "}], \"temperature\": 0.3, \"max_tokens\": 1024}'"
set jsonResult to do shell script curlCmd
-- 4. JSON 파싱 (Python 사용)
set parseCmd to "echo " & quoted form of jsonResult & " | python3 -c \"
import sys, json
try:
r = json.loads(sys.stdin.read())
content = r['choices'][0]['message']['content']
d = json.loads(content)
tags = ','.join(d.get('tags', []))
db = d.get('domain_db', '00_Note_BOX')
grp = d.get('sub_group', '00_Inbox')
ch = d.get('sourceChannel', 'inbox_route')
origin = d.get('dataOrigin', 'external')
print(f'{db}|{grp}|{tags}|{ch}|{origin}')
except:
print('00_Note_BOX|00_Inbox||inbox_route|external')
\""
set classResult to do shell script parseCmd
set AppleScript's text item delimiters to "|"
set resultParts to text items of classResult
set targetDB to item 1 of resultParts
set targetGroup to item 2 of resultParts
set tagString to item 3 of resultParts
set sourceChannel to item 4 of resultParts
set dataOrigin to item 5 of resultParts
set AppleScript's text item delimiters to ""
-- 5. 태그 설정
if tagString is not "" then
set AppleScript's text item delimiters to ","
set tagList to text items of tagString
set AppleScript's text item delimiters to ""
set tags of theRecord to tagList
end if
-- 6. 커스텀 메타데이터 설정
add custom meta data sourceChannel for "sourceChannel" to theRecord
add custom meta data dataOrigin for "dataOrigin" to theRecord
add custom meta data (current date) for "lastAIProcess" to theRecord
-- 7. 대상 도메인 DB로 이동
set targetDatabase to missing value
repeat with db in databases
if name of db is targetDB then
set targetDatabase to db
exit repeat
end if
end repeat
if targetDatabase is not missing value then
set groupPath to "/" & targetGroup
set targetLocation to create location groupPath in targetDatabase
move record theRecord to targetLocation
end if
-- 8. GPU 서버 벡터 임베딩 비동기 전송
set embedPy to pkmRoot & "/scripts/embed_to_qdrant.py"
do shell script venvPython & " " & quoted form of embedPy & " " & quoted form of docUUID & " &> /dev/null &"
on error errMsg
-- 에러 시 로그 기록 + 검토필요 태그
set tags of theRecord to {"@상태/검토필요", "AI분류실패"}
do shell script "echo '[" & (current date) & "] [auto_classify] [ERROR] " & errMsg & "' >> " & quoted form of logFile
end try
end repeat
end tell
end performSmartRule

View File

@@ -1,75 +0,0 @@
-- DEVONthink 4 Smart Rule: OmniFocus 연동
-- Projects DB 새 문서에서 TODO 패턴 감지 → OmniFocus 작업 생성
-- Smart Rule 설정: Event = On Import, DB = Projects
property baseDir : "Documents/code/DEVONThink_my server"
on performSmartRule(theRecords)
set homeDir to POSIX path of (path to home folder)
set logFile to homeDir & baseDir & "/logs/omnifocus_sync.log"
tell application id "DNtp"
repeat with theRecord in theRecords
try
set docText to plain text of theRecord
set docTitle to name of theRecord
set docUUID to uuid of theRecord
set docLink to reference URL of theRecord -- x-devonthink-item://UUID
-- TODO 패턴 감지: "TODO", "할일", "□", "[ ]", "FIXME"
set hasAction to false
if docText contains "TODO" or docText contains "할일" or docText contains "□" or docText contains "[ ]" or docText contains "FIXME" then
set hasAction to true
end if
if not hasAction then continue repeat
-- 액션 아이템 추출 (Python으로 파싱)
set extractCmd to "echo " & quoted form of docText & " | python3 -c \"
import sys, re
text = sys.stdin.read()
patterns = [
r'(?:TODO|FIXME|할일)[:\\s]*(.+?)(?:\\n|$)',
r'(?:□|\\[ \\])\\s*(.+?)(?:\\n|$)',
]
items = []
for p in patterns:
items.extend(re.findall(p, text, re.MULTILINE))
# 최대 5개, 중복 제거
seen = set()
for item in items[:10]:
item = item.strip()
if item and item not in seen:
seen.add(item)
print(item)
if len(seen) >= 5:
break
\""
set actionItems to paragraphs of (do shell script extractCmd)
if (count of actionItems) = 0 then continue repeat
-- OmniFocus에 작업 생성
tell application "OmniFocus"
tell default document
set taskIDs to {}
repeat with actionItem in actionItems
set taskName to docTitle & " — " & (contents of actionItem)
set newTask to make new inbox task with properties {name:taskName, note:"DEVONthink 문서: " & docLink}
set end of taskIDs to id of newTask
end repeat
end tell
end tell
-- DEVONthink 메타데이터에 OmniFocus Task ID 저장
set AppleScript's text item delimiters to ","
set taskIDString to taskIDs as text
set AppleScript's text item delimiters to ""
add custom meta data taskIDString for "omnifocusTaskID" to theRecord
on error errMsg
do shell script "echo '[" & (current date) & "] [omnifocus_sync] [ERROR] " & errMsg & "' >> " & quoted form of logFile
end try
end repeat
end tell
end performSmartRule

108
config.yaml Normal file
View File

@@ -0,0 +1,108 @@
# hyungi_Document_Server 설정
ai:
gateway:
endpoint: "http://ai-gateway:8080"
models:
primary:
endpoint: "http://100.76.254.116:8800/v1/chat/completions"
model: "mlx-community/Qwen3.5-35B-A3B-4bit"
max_tokens: 4096
timeout: 60
fallback:
endpoint: "http://ollama:11434/v1/chat/completions"
model: "qwen3.5:9b-q8_0"
max_tokens: 4096
timeout: 120
premium:
endpoint: "https://api.anthropic.com/v1/messages"
model: "claude-sonnet-4-20250514"
max_tokens: 8192
daily_budget_usd: 5.00
require_explicit_trigger: true
embedding:
endpoint: "http://ollama:11434/api/embeddings"
model: "nomic-embed-text"
vision:
endpoint: "http://ollama:11434/api/generate"
model: "Qwen2.5-VL-7B"
rerank:
endpoint: "http://ollama:11434/api/rerank"
model: "bge-reranker-v2-m3"
nas:
mount_path: "/documents"
pkm_root: "/documents/PKM"
# ─── 문서 분류 체계 ───
taxonomy:
Philosophy:
Ethics: []
Metaphysics: []
Epistemology: []
Logic: []
Aesthetics: []
Eastern_Philosophy: []
Western_Philosophy: []
Language:
Korean: []
English: []
Japanese: []
Translation: []
Linguistics: []
Engineering:
Mechanical: [Piping, HVAC, Equipment]
Electrical: [Power, Instrumentation]
Chemical: [Process, Material]
Civil: []
Network: [Server, Security, Infrastructure]
Industrial_Safety:
Legislation: [Act, Decree, Foreign_Law, Korea_Law_Archive, Enforcement_Rule, Public_Notice, SAPA]
Theory: [Industrial_Safety_General, Safety_Health_Fundamentals]
Academic_Papers: [Safety_General, Risk_Assessment_Research]
Cases: [Domestic, International]
Practice: [Checklist, Contractor_Management, Safety_Education, Emergency_Plan, Patrol_Inspection, Permit_to_Work, PPE, Safety_Plan]
Risk_Assessment: [KRAS, JSA, Checklist_Method]
Safety_Manager: [Appointment, Duty_Record, Improvement, Inspection, Meeting]
Health_Manager: [Appointment, Duty_Record, Ergonomics, Health_Checkup, Mental_Health, MSDS, Work_Environment]
Programming:
Programming_Language: [Python, JavaScript, Go, Rust]
Framework: [FastAPI, SvelteKit, React]
DevOps: [Docker, CI_CD, Linux_Administration]
AI_ML: [Large_Language_Model, Computer_Vision, Data_Science]
Database: []
Software_Architecture: []
General:
Reading_Notes: []
Self_Development: []
Business: []
Science: []
History: []
document_types:
- Reference
- Standard
- Manual
- Drawing
- Template
- Note
- Academic_Paper
- Law_Document
- Report
- Memo
- Checklist
- Meeting_Minutes
- Specification
schedule:
law_monitor: "07:00"
mailplus_archive: ["07:00", "18:00"]
daily_digest: "20:00"
file_watcher_interval_minutes: 5
queue_consumer_interval_minutes: 10

View File

@@ -1,32 +1,49 @@
# ═══════════════════════════════════════════════════
# PKM 시스템 인증 정보
# 이 파일은 템플릿입니다. 실제 값은 Mac mini의
# ~/.config/pkm/credentials.env 에 별도 관리합니다.
# hyungi_Document_Server — 인증 정보 템플릿
# 실제 값을 채워서 credentials.env로 저장
# ═══════════════════════════════════════════════════
# ─── Claude API (AI 고급 처리용) ───
# ─── PostgreSQL ───
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=pkm
POSTGRES_USER=pkm
POSTGRES_PASSWORD=
# ─── AI: Mac mini MLX (Tailscale 경유, Qwen3.5 기본 모델) ───
MLX_ENDPOINT=http://100.76.254.116:8800/v1/chat/completions
MLX_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit
# ─── AI: Claude API (종량제, 복잡한 분석 전용) ───
CLAUDE_API_KEY=
# ─── AI Gateway (같은 Docker 네트워크) ───
AI_GATEWAY_ENDPOINT=http://ai-gateway:8080
# ─── NAS (NFS 마운트) ───
NAS_NFS_PATH=/mnt/nas/Document_Server
NAS_DOMAIN=ds1525.hyungi.net
NAS_TAILSCALE_IP=100.101.79.37
NAS_PORT=15001
# ─── Synology MailPlus (이메일 수집 + SMTP 알림) ───
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 + TOTP) ───
JWT_SECRET=
TOTP_SECRET=
# ─── 국가법령정보센터 (법령 모니터링) ───
LAW_OC=
# ─── Synology NAS 접속 ───
NAS_DOMAIN=
NAS_TAILSCALE_IP=
NAS_PORT=15001
# ─── MailPlus IMAP (이메일 수집용) ───
MAILPLUS_HOST=
MAILPLUS_PORT=993
MAILPLUS_USER=
MAILPLUS_PASS=
# ─── Synology Chat 웹훅 (나중에 추가) ───
#CHAT_WEBHOOK_URL=
# ─── GPU 서버 (임베딩/OCR) ───
GPU_SERVER_IP=192.168.1.xxx
# ─── TKSafety API (나중에 활성화) ───
#TKSAFETY_HOST=
#TKSAFETY_PORT=

105
docker-compose.yml Normal file
View File

@@ -0,0 +1,105 @@
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:
- "15432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pkm"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
kordoc-service:
build: ./services/kordoc
ports:
- "3100:3100"
volumes:
- ${NAS_NFS_PATH:-/mnt/nas/Document_Server}:/documents:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3100/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
ollama:
image: ollama/ollama
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "127.0.0.1:11434:11434"
restart: unless-stopped
ai-gateway:
build: ./gpu-server/services/ai-gateway
ports:
- "127.0.0.1:8081:8080"
environment:
- PRIMARY_ENDPOINT=http://100.76.254.116:8800/v1/chat/completions
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
depends_on:
- ollama
restart: unless-stopped
fastapi:
build: ./app
ports:
- "8000:8000"
volumes:
- ${NAS_NFS_PATH:-/mnt/nas/Document_Server}:/documents
- ./config.yaml:/app/config.yaml:ro
- ./scripts:/app/scripts:ro
- ./logs:/app/logs
depends_on:
postgres:
condition: service_healthy
kordoc-service:
condition: service_healthy
env_file:
- credentials.env
environment:
- DATABASE_URL=postgresql+asyncpg://pkm:${POSTGRES_PASSWORD}@postgres:5432/pkm
- KORDOC_ENDPOINT=http://kordoc-service:3100
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- fastapi
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "8080:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
depends_on:
- fastapi
- frontend
restart: unless-stopped
volumes:
pgdata:
caddy_data:
ollama_data:

File diff suppressed because it is too large Load Diff

View File

@@ -1,530 +0,0 @@
# Claude Code 실행 명령어 — PKM 시스템 구축
> 작업 위치: MacBook Pro ~/Documents/code/DEVONThink_my server/
> 또는 Cowork 모드에서 마운트된 폴더
> 완성 후 Gitea에 push → Mac mini에서 pull
```
개발/배포 흐름:
MacBook Pro (Claude Code / Cowork)
~/Documents/code/DEVONThink_my server/
→ 스크립트/설정 파일 작성
→ git commit & push
Gitea (Synology NAS)
https://git.hyungi.net/hyungi/devonthink_home.git
Mac mini (git pull → 실행)
```
---
# Phase 1: 초기 구축 (완료)
> 2026-03-26 ~ 03-27 작업. 총 15 커밋.
## 0단계: 프로젝트 구조 생성 + credentials.env ✅ 완료
```bash
# Mac mini에서
mkdir -p ~/.config/pkm
nano ~/.config/pkm/credentials.env
chmod 600 ~/.config/pkm/credentials.env
```
## 1단계: 프로젝트 구조 + requirements.txt ✅ 완료
생성된 파일: README.md, requirements.txt, .gitignore, credentials.env.example, 전체 디렉토리 구조
## 2단계: AI 분류 프롬프트 ✅ 완료
- MLX 서버(localhost:8800) OpenAI 호환 API로 전환됨
- Qwen3.5 thinking 모드 대응 완료 (JSON 추출 후처리)
- 프롬프트: scripts/prompts/classify_document.txt
## 3단계: AppleScript ✅ 완료
- applescript/auto_classify.scpt — Inbox 자동 분류
- applescript/omnifocus_sync.scpt — OmniFocus 작업 생성
## 4단계: 법령 모니터링 ⚠️ 부분 완료
- scripts/law_monitor.py 작성 완료
- 외국 법령 (US OSHA 1건, JP 厚労省 10건, EU-OSHA 5건) 수집 성공
- ❌ 한국 법령 API: IP 등록 미완 → Phase 2에서 해결
## 5단계: MailPlus 이메일 수집 ❌ 연결 실패
- scripts/mailplus_archive.py 코드 완성
- ❌ IMAP 접속 실패 (Connection refused) → Phase 2에서 해결
## 6단계: Daily Digest ⚠️ 미테스트
- scripts/pkm_daily_digest.py 코드 완성
- 실행 테스트 미진행 → Phase 2 이후 테스트
## 7단계: DEVONagent 가이드 ✅ 완료
- docs/devonagent-setup.md — 9개 검색 세트 설정 가이드
## 8단계: 전체 테스트 ❌ 미진행
- tests/test_classify.py 작성 완료
- docs/test-report.md 미생성 → Phase 4에서 진행
## 추가: PKM API 서버 (계획 외 생성)
- scripts/pkm_api_server.py — Flask REST API (포트 9900)
- DEVONthink + OmniFocus 상태 조회용
- 기본 동작 확인됨, 버그 있음 → Phase 2에서 수정
---
# Phase 1.5: GPU 서버 재구성 (Phase 2와 병행)
> 상세 계획: docs/gpu-restructure.md 참조
> GPU 서버 SSH 작업 + PKM 프로젝트 코드 수정
## GPU-1단계: GPU 서버 Ollama 모델 교체
```bash
# GPU 서버에서 실행 (ssh 192.168.1.186)
# 1. 기존 LLM 모델 제거
ollama rm qwen3.5:9b-q8_0
ollama rm id-9b
# 2. 임베딩/리랭킹 모델 설치
ollama pull bge-m3
ollama pull bge-reranker-v2-m3
# 3. no-think proxy 비활성화
sudo systemctl disable --now ollama-proxy
# 4. Ollama systemd 환경 조정
sudo systemctl edit ollama
# Environment="OLLAMA_MAX_LOADED_MODELS=2"
# Environment="OLLAMA_KEEP_ALIVE=30m"
sudo systemctl daemon-reload && sudo systemctl restart ollama
# 5. 검증
ollama list # → bge-m3, bge-reranker만 존재
curl localhost:11434/api/embed -d '{"model":"bge-m3","input":["테스트"]}' # → 1024차원 벡터
```
## GPU-2단계: tk-ai-service 코드 수정 (Mac Mini)
```
tk-ai-service의 ollama_client.py를 OpenAI API 호환으로 리팩터링해줘.
변경 대상:
~/docker/tk-factory-services/ai-service/services/ollama_client.py
→ generate_text(): /api/chat → /v1/chat/completions
→ check_health(): /api/tags → /v1/models
→ generate_embedding(): 변경 없음 (GPU Ollama 유지)
docker-compose.yml 환경변수:
OLLAMA_BASE_URL=http://host.internal:8800 (MLX)
OLLAMA_TEXT_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit
OLLAMA_EMBED_URL=http://192.168.1.186:11434 (GPU)
검증: docker compose build && docker compose up -d
curl http://localhost:30400/health → 모두 connected
```
## GPU-3단계: Docker + NFS + Komga 이전
```bash
# GPU 서버에서 실행 (ssh 192.168.1.186)
# 1. Docker 설치
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker hyungi
# 2. NFS 마운트
sudo apt install nfs-common
sudo mkdir -p /mnt/comic
echo '192.168.1.227:/volume1/Comic /mnt/comic nfs4 ro,nosuid,noexec,nodev,soft,timeo=15 0 0' | sudo tee -a /etc/fstab
sudo mount -a
ls /mnt/comic # 확인
# 3. Komga 컨테이너 시작
sudo mkdir -p /opt/komga && cd /opt/komga
# docker-compose.yml 생성 (gpu-restructure.md Phase 1.5-3 참조)
docker compose up -d
# Mac Mini에서:
docker stop komga && docker rm komga
# nginx-ssl.conf: komga_backend upstream → 192.168.1.186:25600
docker restart home-service-proxy
# 검증
curl https://komga.hyungi.net # → GPU 서버 경유 접근
```
## GPU-4단계: Surya OCR 설치
```bash
# GPU 서버에서 실행 (ssh 192.168.1.186)
# 1. PyTorch CUDA 확인
python3 -c "import torch; print(torch.cuda.is_available())"
# False면: pip install torch --index-url https://download.pytorch.org/whl/cu124
# 2. Surya OCR 설치
sudo mkdir -p /opt/surya-ocr && cd /opt/surya-ocr
python3 -m venv venv
source venv/bin/activate
pip install surya-ocr fastapi uvicorn python-multipart
# 3. server.py 작성 (FastAPI 래퍼, gpu-restructure.md Phase 2-2 참조)
# 4. systemd 등록
# /etc/systemd/system/surya-ocr.service (gpu-restructure.md Phase 2-3 참조)
sudo systemctl daemon-reload
sudo systemctl enable --now surya-ocr
# 검증
curl -F "file=@test.pdf" http://localhost:8400/ocr
```
## GPU-5단계: Qdrant 통합 + PKM 코드 갱신
```
PKM 프로젝트 코드를 GPU 서버 재구성에 맞게 갱신해줘.
1. scripts/embed_to_chroma.py → scripts/embed_to_qdrant.py 리라이트
- chromadb → qdrant-client
- nomic-embed-text → bge-m3 (GPU 서버 192.168.1.186:11434)
- /api/embed 사용 (배치 지원)
- 텍스트 청킹 (500토큰, 50오버랩)
- 기존 embed_to_chroma.py는 git rm
2. applescript/auto_classify.scpt 수정
- Step 0: OCR 감지 + Surya OCR 호출 추가
- Step 4: embed_to_qdrant.py 호출로 변경
- 버그 픽스: 73행 sourceChannel 이중 설정 삭제
- baseDir 변수 사용 (12단계와 합산)
3. requirements.txt 업데이트 (9단계와 합산)
- chromadb, schedule 제거
+ qdrant-client, flask, gunicorn 추가
4. credentials.env.example: GPU_SERVER_IP 추가
5. Qdrant에 pkm_documents 컬렉션 생성 (1024차원, cosine)
검증: python3 scripts/embed_to_qdrant.py <테스트UUID> → Qdrant 벡터 저장
```
## GPU-6단계: architecture.md 대규모 갱신
```
docs/architecture.md를 GPU 서버 재구성에 맞게 전체 갱신해줘.
변경 규모: ChromaDB 28건, nomic-embed 12건, VL-7B 5건 — 문맥별 수정 필요
주요 변경:
- Tier 3 모델: nomic-embed-text → bge-m3, Qwen2.5-VL-7B → Surya OCR
- 벡터 DB: ChromaDB → Qdrant (모든 언급)
- VRAM 다이어그램: ~11.3GB → ~7-8GB
- Smart Rule 설계: embed_to_chroma → embed_to_qdrant, OCR 단계 추가
- 3-Tier AI 라우팅 전략 표 갱신
- 코드 예시 내 경로/모델명
※ 단순 치환 불가, 전체 문서를 통독하며 문맥에 맞게 수정할 것
※ 별도 커밋으로 분리
```
## GPU-7단계: RAG 파이프라인 구축 (후순위)
```
pkm_api_server.py에 RAG 엔드포인트를 추가해줘.
추가 엔드포인트:
POST /rag/query — 질문 → bge-m3 임베딩 → Qdrant 검색 → 리랭킹 → MLX 답변
POST /devonthink/embed — 단일 문서 임베딩 트리거
POST /devonthink/embed-batch — 배치 임베딩
docstring 갱신: "범위: DEVONthink + OmniFocus + RAG 검색"
scripts/ocr_preprocess.py 신규 작성:
DEVONthink UUID → AppleScript로 파일 경로 추출 → Surya API(GPU:8400) 호출
검증: curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}'
```
---
# Phase 2: 인프라 수정 + 버그 픽스 (Phase 1.5와 병행)
> dev-roadmap.md Phase 2~3 해당
> ※ requirements.txt, AppleScript 경로, credentials.env는 GPU-5단계와 합산 진행
## 9단계: requirements.txt 수정 ← GPU-5단계와 합산
```
requirements.txt에 flask가 빠져있어. pkm_api_server.py에서 사용 중이니까 추가해줘.
GPU 서버 재구성에 따라 chromadb→qdrant-client 교체도 함께 진행.
gunicorn도 추가해 (프로덕션 WSGI 서버용).
schedule 패키지는 현재 미사용 — 제거할지 유지할지 판단해줘.
anthropic 패키지는 향후 Tier 2 연동용이니 유지.
수정할 파일: requirements.txt
추가:
+ flask>=3.0.0
+ gunicorn>=21.2.0
확인:
- schedule>=1.2.0 → 사용처 없으면 제거
```
## 10단계: JP 번역 thinking 오염 수정
```
법령 모니터링에서 일본어 번역 시 MLX Qwen3.5의 thinking 출력이 결과에 섞이는 문제를 수정해줘.
현재 문제:
로그에서 "Wait, I'll check if..." 같은 thinking 텍스트가 번역 결과에 포함됨.
위치: scripts/law_monitor.py의 JP 번역 호출부
수정 방향:
1. 번역 프롬프트에 /nothink 모드 명시 강화
2. llm_generate() 응답에서 thinking 패턴 필터링 추가
- "Wait,", "Let me", "I'll check", "Hmm," 등으로 시작하는 줄 제거
- "Final Output:" 이후 텍스트만 추출하는 로직
3. 또는 pkm_utils.py의 llm_generate()에 strip_thinking=True 옵션 추가
테스트: JP RSS 항목 하나로 번역 테스트하여 깨끗한 한글 출력 확인
```
## 11단계: API 서버 버그 수정
```
PKM API 서버의 두 가지 버그를 수정해줘.
위치: scripts/pkm_api_server.py
버그 1: /devonthink/stats → 500 Internal Server Error
- AppleScript 쿼리가 실패하는 것으로 추정
- 에러 로그 확인: logs/pkm-api.error.log
- AppleScript에서 DB 이름이나 property 접근 방식 수정 필요
버그 2: 한글 쿼리 파라미터 인코딩 에러
- /devonthink/search?q=산업안전 → 400 Bad request syntax
- Flask의 request.args는 UTF-8을 지원하므로, 클라이언트 측 문제일 가능성
- 서버 측에서도 방어 코드 추가: URL 디코딩 처리
추가 개선:
- /omnifocus/overdue 엔드포인트가 404였다가 나중에 추가됨 — 코드 확인
- / (루트) 접근 시 404 → /health로 리다이렉트 또는 엔드포인트 목록 반환
```
## 12단계: AppleScript 경로 변수화 ← GPU-5단계와 합산
```
AppleScript 파일들의 하드코딩된 경로를 변수로 교체해줘.
GPU-5단계의 embed_to_qdrant.py 변경, OCR 단계 추가, sourceChannel 버그 픽스도 함께 진행.
현재 하드코딩:
~/Documents/code/DEVONThink_my server/scripts/prompts/classify_document.txt
~/Documents/code/DEVONThink_my server/venv/bin/python3
~/Documents/code/DEVONThink_my server/scripts/embed_to_chroma.py
수정 대상:
applescript/auto_classify.scpt
applescript/omnifocus_sync.scpt
수정 방향:
- 스크립트 상단에 property baseDir 정의
- 모든 경로를 baseDir 기반으로 조합
- 또는 환경변수 PKM_HOME을 읽어서 사용
※ AppleScript에서 환경변수 읽기:
set pkmHome to do shell script "echo $PKM_HOME"
또는 property로 직접 지정하는 게 안정적
```
---
# Phase 3: API 서버 개선
## 13단계: gunicorn 전환 + launchd 등록
```
PKM API 서버를 Flask development server에서 gunicorn으로 전환하고,
launchd plist를 만들어 Mac mini 로그인 시 자동 시작되도록 해줘.
1. gunicorn 설정:
gunicorn -w 2 -b 127.0.0.1:9900 pkm_api_server:app
※ AppleScript 실행 때문에 GUI 세션에서 실행해야 함
※ launchd의 LimitLoadToSessionType = Aqua 또는 LoginwindowUI
2. launchd plist 생성:
launchd/net.hyungi.pkm.api-server.plist
- 로그인 시 자동 시작
- KeepAlive: true (크래시 시 재시작)
- WorkingDirectory: ~/Documents/code/DEVONThink_my server/
- StandardOutPath/StandardErrorPath: logs/pkm-api.log, logs/pkm-api.error.log
3. deploy.md에 API 서버 관련 내용 추가
```
## 14단계: API 엔드포인트 추가
```
PKM API 서버에 모니터링/상태 확인용 엔드포인트를 추가해줘.
추가할 엔드포인트:
1. GET /law-monitor/status
- data/law_last_check.json 읽어서 마지막 확인 시간 반환
- logs/law_monitor.log 최근 에러 건수
2. GET /digest/latest
- DEVONthink 00_Note_BOX/Daily_Digest/에서 최신 다이제스트 조회
- 또는 data/ 아래에 최근 다이제스트 캐시
3. GET / → 전체 엔드포인트 목록 반환 (현재 404)
4. GET /health 확장
- MLX 서버 상태 (localhost:8800 연결 가능 여부)
- DEVONthink 실행 상태
- 각 launchd job 상태
```
---
# Phase 4: 테스트
## 15단계: 모듈별 테스트 실행
```
Mac mini에서 각 모듈의 동작을 확인해줘.
1. AI 분류 테스트 (tests/test_classify.py)
cd ~/Documents/code/DEVONThink_my\ server/
source venv/bin/activate
python tests/test_classify.py
→ 5종 문서 분류 정확도 확인
2. 법령 모니터링 (Phase 2 인프라 수정 후)
python scripts/law_monitor.py
→ 한국 법령 API 정상 응답 확인
→ 외국 법령 수집 재확인
3. MailPlus 이메일 수집 (Phase 2 연결 수정 후)
python scripts/mailplus_archive.py
→ IMAP 접속 + 이메일 가져오기 확인
4. Daily Digest
python scripts/pkm_daily_digest.py
→ 다이제스트 MD 파일 생성 확인
5. API 서버 (Phase 2 버그 수정 후)
python scripts/pkm_api_server.py &
curl http://localhost:9900/health
curl http://localhost:9900/devonthink/stats
curl http://localhost:9900/devonthink/inbox-count
curl "http://localhost:9900/devonthink/search?q=safety&limit=3"
curl http://localhost:9900/omnifocus/stats
```
## 16단계: E2E 통합 테스트
```
PKM 시스템 End-to-End 테스트를 진행해줘.
시나리오 1: Inbox → 자동분류 플로우
1. DEVONthink Inbox에 테스트 문서 추가
2. Smart Rule 트리거 → auto_classify.scpt 실행 확인
3. 태그, 커스텀 메타데이터(sourceChannel, dataOrigin, lastAIProcess) 확인
4. 올바른 도메인 DB + 하위 그룹으로 이동 확인
시나리오 2: 법령 → 다이제스트 플로우
1. law_monitor.py 수동 실행
2. data/laws/에 파일 생성 + DEVONthink 04_Industrial Safety 임포트 확인
3. pkm_daily_digest.py 실행 → 법령 변경 건 포함 확인
시나리오 3: OmniFocus 연동
1. Projects DB에 TODO 패턴 문서 추가
2. omnifocus_sync.scpt 트리거 확인
3. OmniFocus Inbox에 작업 생성 + DEVONthink 링크 확인
4. 커스텀 메타데이터 omnifocusTaskID 확인
시나리오 4: launchd 스케줄 확인
launchctl list | grep pkm
→ 3개(+API 서버) 등록 확인
각 항목 pass/fail 리포트 → docs/test-report.md
```
---
# Phase 5: 운영 안정화 (나중에)
## 17단계: 로그 로테이션 + 알림
```
운영 안정성을 위한 설정을 추가해줘.
1. Python 로그 로테이션
- pkm_utils.py의 setup_logger()에 RotatingFileHandler 적용
- maxBytes=10MB, backupCount=5
2. Synology Chat 알림 (CHAT_WEBHOOK_URL 설정 후)
- 법령 변경 감지 시 알림
- 에러 발생 시 알림
- Daily Digest 요약 알림
3. 에러 모니터링
- pkm_daily_digest.py에 이미 에러 카운트 로직 있음
- 임계값 초과 시 Chat 알림 추가
```
## 18단계: 문서 보완
```
프로젝트 문서를 보완해줘.
1. README.md — 아키텍처 다이어그램, 기능 목록, 시작 가이드 확장
2. deploy.md — API 서버 배포, 트러블슈팅 섹션, macOS 요구사항 추가
3. docs/troubleshooting.md — 자주 발생하는 문제와 해결 방법
```
---
## 참고: 네트워크 환경
```
Mac mini 접속: SSH (MacBook Pro → Mac mini)
NAS 도메인: ds1525.hyungi.net (Tailscale: 100.101.79.37, 포트: 15001)
MailPlus: mailplus.hyungi.net:993 (IMAP SSL) ← 현재 연결 불가, 확인 필요
WebDAV: webdav.hyungi.net/Document_Server/DEVONThink/
TKSafety: tksafety.technicalkorea.net (나중에 활성화)
내부 네트워크: Tailscale VPN 연결됨
Gitea: https://git.hyungi.net/hyungi/devonthink_home.git
```
## 참고: Git 커밋 히스토리
```
3506214 fix(law_monitor): US 타입 필터 제거 + JP RDF 네임스페이스 수정
c8e30b5 fix: AppleScript POSIX path 변수 방식 + 단일 -e 실행으로 따옴표 문제 해결
f13b998 fix: AppleScript 행별 -e 분할 실행 — 파일 방식 인코딩 문제 회피
735c072 fix: AppleScript를 임시 파일로 실행 — osascript -e 이스케이프 문제 해결
446963c fix(law_monitor): AppleScript f-string 제거 + EU 파일명 고유화
0b950a4 fix(law_monitor): AppleScript 따옴표 이스케이프 수정
6a44b10 fix(law_monitor): JP/EU RSS URL 수정 — news.rdf + rss.xml, RDF 네임스페이스 대응
9dc0694 feat(law_monitor): 외국 법령 지원 추가 — US OSHA, JP 厚労省(MLX 번역), EU-OSHA
ec6074d fix(law_monitor): API 에러 응답 로깅 추가 — 인증 실패 시 조용히 넘어가던 문제
aca4a02 fix: LLM thinking 허용 + 마지막 유효 JSON 추출 방식으로 변경
49c39a1 fix: LLM thinking 출력 대응 — max_tokens 증가 + JSON 추출 강화
948be16 fix: Qwen3.5 /nothink 모드 + json_mode 파라미터 추가
a774771 fix: MLX 서버(localhost:8800) 대응 — Ollama API → OpenAI 호환 변경
084d3a8 feat: 전체 PKM 스크립트 일괄 작성 — 분류/법령/메일/다이제스트/임베딩
bec9579 chore: 프로젝트 구조 + 설계 문서 초기 커밋
```

View File

@@ -1,249 +1,154 @@
# Mac mini 배포 가이드
# 배포 가이드
> 마지막 업데이트: 2026-03-29
> 대상: Mac mini M4 Pro (macOS, Python 3.11+)
## 1. 사전 요구사항
## 요구사항
- Docker & Docker Compose (Mac mini)
- NAS SMB 마운트 (`/Volumes/Document_Server`)
- Tailscale VPN 연결 (Mac mini ↔ GPU 서버 ↔ NAS)
- macOS 14+ (Sonoma 이상)
- Python 3.11+ (Homebrew 설치 권장)
- DEVONthink 4 — 실행 중이어야 AppleScript 동작
- OmniFocus 4 — 실행 중이어야 AppleScript 동작
- MLX 서버 — Qwen3.5-35B-A3B, localhost:8800에서 실행 중
- Tailscale — NAS 및 GPU 서버 접근용
## 2. Mac mini 배포
## 1. 초기 설치
### 2-1. 코드 가져오기
```bash
# Mac mini에서
cd ~/Documents/code/
git clone https://git.hyungi.net/hyungi/devonthink_home.git "DEVONThink_my server"
cd "DEVONThink_my server"
# Python 가상환경
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git hyungi_Document_Server
cd hyungi_Document_Server
```
## 2. 인증 정보 설정
### 2-2. 인증 정보 설정
```bash
mkdir -p ~/.config/pkm
nano ~/.config/pkm/credentials.env
chmod 600 ~/.config/pkm/credentials.env
cp credentials.env.example credentials.env
nano credentials.env # 실제 값 입력
chmod 600 credentials.env
```
credentials.env.example을 참고하여 실제 값 입력:
필수 값: `POSTGRES_PASSWORD`, `JWT_SECRET`, `TOTP_SECRET`, `MLX_ENDPOINT`
선택 값: `CLAUDE_API_KEY`, `LAW_OC` (법령 API 승인 후)
```
# 필수
LAW_OC=<법령API키>
MAILPLUS_HOST=mailplus.hyungi.net
MAILPLUS_PORT=993
MAILPLUS_USER=hyungi
MAILPLUS_PASS=<비밀번호>
NAS_DOMAIN=ds1525.hyungi.net
NAS_TAILSCALE_IP=100.101.79.37
NAS_PORT=15001
# 선택 (향후)
CLAUDE_API_KEY=<키>
#CHAT_WEBHOOK_URL=<Synology Chat 웹훅>
#GPU_SERVER_IP=<Tailscale IP>
```
## 3. 한국 법령 API IP 등록
법령 API 호출 전에 Mac mini의 공인 IP를 등록해야 합니다.
### 2-3. NAS SMB 마운트 확인
```bash
# Mac mini에서 공인 IP 확인
curl -s ifconfig.me
# open.law.go.kr → 로그인 → 마이페이지 → 인증키 관리
# 위에서 확인한 IP를 서버 IP로 등록
# ※ Tailscale IP(100.x.x.x)가 아니라 실제 공인 IP
# 등록 후 테스트
source venv/bin/activate
python scripts/law_monitor.py
# → "법령 API 에러" 없이 정상 동작 확인
```
## 4. launchd 스케줄 등록
```bash
# 심볼릭 링크 생성
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.law-monitor.plist ~/Library/LaunchAgents/
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.mailplus.plist ~/Library/LaunchAgents/
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.daily-digest.plist ~/Library/LaunchAgents/
# 등록
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.mailplus.plist
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.daily-digest.plist
# macOS에서 SMB 마운트 (Finder 또는 CLI)
mount -t smbfs //hyungi@ds1525.hyungi.net/Document_Server /Volumes/Document_Server
# 확인
launchctl list | grep pkm
ls /Volumes/Document_Server/PKM/
```
## 5. PKM API 서버 실행
Docker 컨테이너에서 이 경로를 `/documents`로 바인드 마운트한다.
### 2-4. 서비스 시작
```bash
# 개발 모드 (수동 실행)
cd ~/Documents/code/DEVONThink_my\ server/
source venv/bin/activate
python scripts/pkm_api_server.py
docker compose up -d
# 프로덕션 모드 (gunicorn, Phase 3 이후)
# gunicorn -w 2 -b 127.0.0.1:9900 scripts.pkm_api_server:app
# 동작 확인
curl http://localhost:9900/health
curl http://localhost:9900/devonthink/inbox-count
# 상태 확인
docker compose ps
docker compose logs -f fastapi
```
API 서버는 GUI 세션에서 실행해야 합니다 (AppleScript가 DEVONthink/OmniFocus GUI에 접근).
## 6. DEVONthink Smart Rule 설정
1. DEVONthink → Preferences → Smart Rules
2. 새 Rule: **"AI Auto Classify"**
- Event: On Import
- Database: Inbox
- Condition: Tags is empty
- Action: Execute Script → External → `applescript/auto_classify.scpt`
3. 새 Rule: **"OmniFocus Sync"**
- Event: On Import
- Database: Projects
- Action: Execute Script → External → `applescript/omnifocus_sync.scpt`
## 7. 수동 테스트
### 2-5. 확인
```bash
cd ~/Documents/code/DEVONThink_my\ server/
source venv/bin/activate
# FastAPI OpenAPI 문서
curl http://localhost:8000/docs
# 각 스크립트 수동 실행
python3 scripts/law_monitor.py
python3 scripts/mailplus_archive.py
python3 scripts/pkm_daily_digest.py
# PostgreSQL 테이블 확인
docker compose exec postgres psql -U pkm -d pkm -c '\dt'
# AI 분류 테스트
python3 tests/test_classify.py
# kordoc 헬스체크
curl http://localhost:3100/health
```
## 8. 업데이트
### 2-6. 외부 접근 (Caddy)
HTTPS는 앞단 프록시(Mac mini nginx)에서 처리하고, Caddy는 HTTP only로 동작한다.
- `document.hyungi.net` → Mac mini nginx (HTTPS 종료) → GPU 서버 Caddy (:8080) → FastAPI/Frontend
- `office.hyungi.net` → Synology Office (NAS 프록시)
DNS 레코드가 Mac mini의 공인 IP를 가리켜야 한다. Caddy는 `auto_https off` 설정.
## 3. GPU 서버 배포
### 3-1. AI Gateway + Ollama
```bash
cd ~/Documents/code/DEVONThink_my\ server/
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
cp ../credentials.env .env # 필요한 값만 복사
docker compose up -d
```
### 3-2. 모델 확인
```bash
# Ollama 모델 목록
docker compose exec ollama ollama list
# 필요 모델 pull
docker compose exec ollama ollama pull nomic-embed-text
docker compose exec ollama ollama pull qwen2.5-vl:7b
docker compose exec ollama ollama pull bge-reranker-v2-m3
```
### 3-3. AI Gateway 확인
```bash
curl http://localhost:8080/health
```
## 4. 업데이트
```bash
# Mac mini
cd ~/Documents/code/hyungi_Document_Server/
git pull
source venv/bin/activate
pip install -r requirements.txt
docker compose up -d --build
# launchd 재로드 (plist가 변경된 경우만)
launchctl unload ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist
# (나머지도 동일)
# GPU 서버
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
git pull
docker compose up -d --build
```
## 9. 로그 확인
## 5. 로그 확인
```bash
# 스크립트 로그
tail -f logs/law_monitor.log
tail -f logs/mailplus.log
tail -f logs/digest.log
# FastAPI 로그
docker compose logs -f fastapi
# API 서버 로그
tail -f logs/pkm-api.log
tail -f logs/pkm-api.error.log
# 특정 워커 로그
docker compose logs -f fastapi | grep law_monitor
docker compose logs -f fastapi | grep mailplus
docker compose logs -f fastapi | grep digest
# launchd 시스템 로그
log show --predicate 'process == "python3"' --last 1h
# PostgreSQL 로그
docker compose logs -f postgres
```
## 10. 일일 운영 점검
## 6. 자동화 스케줄 (APScheduler)
Docker 내부에서 APScheduler로 관리 (launchd 대체):
| 시간 | 작업 | 주기 |
|------|------|------|
| 07:00 | law_monitor | 매일 |
| 07:00, 18:00 | mailplus_archive | 매일 2회 |
| 20:00 | daily_digest | 매일 |
| */5분 | file_watcher | 상시 |
| */10분 | processing_queue consumer | 상시 |
## 7. 백업
### 우선순위
1. **NAS 원본 파일** — Synology Drive 버전 이력 + Hyper Backup
2. **PostgreSQL**`pg_dump` 정기 백업
3. **Docker volumes** — pgdata, caddy_data
### PostgreSQL 백업
```bash
# 1. launchd 작업 상태
launchctl list | grep pkm
# 2. 오늘의 로그 에러 확인
grep -c ERROR logs/law_monitor.log
grep -c ERROR logs/mailplus.log
# 3. 법령 마지막 확인 시간
cat data/law_last_check.json | python3 -m json.tool
# 4. DEVONthink Inbox 미처리 건수 (API 서버 실행 중이면)
curl -s http://localhost:9900/devonthink/inbox-count
# 5. MLX 서버 상태
curl -s http://localhost:8800/v1/models | python3 -m json.tool
```
## 실행 스케줄
| 스크립트 | 시간 | 용도 |
|---------|------|------|
| law_monitor.py | 매일 07:00 | 법령 변경 모니터링 (한국+US/JP/EU) |
| mailplus_archive.py | 매일 07:00, 18:00 | MailPlus 이메일 수집 |
| pkm_daily_digest.py | 매일 20:00 | 일일 다이제스트 생성 |
| pkm_api_server.py | 상시 (수동/launchd) | REST API (포트 9900) |
## 트러블슈팅
### 법령 API "사용자 정보 검증 실패"
```
원인: Mac mini 공인 IP가 open.law.go.kr에 등록되지 않음
해결:
1. curl -s ifconfig.me 로 현재 공인 IP 확인
2. open.law.go.kr → 마이페이지 → 인증키 관리 → IP 등록
3. IP가 변경되면 다시 등록 필요 (고정 IP 아닌 경우)
```
### MailPlus IMAP Connection refused
```
확인 순서:
1. Synology DSM → MailPlus Server → 서비스 상태 확인
2. IMAP 활성화: DSM → MailPlus Server → 메일 전송 → IMAP 탭
3. 포트: 993(SSL) vs 143(STARTTLS)
4. 방화벽: Synology 방화벽에서 993 포트 확인
5. Tailscale 직접:
python3 -c "import imaplib; m=imaplib.IMAP4_SSL('100.101.79.37', 993); print('OK')"
```
### AppleScript 실행 오류
```
확인:
1. DEVONthink, OmniFocus가 GUI로 실행 중인지 확인
2. 접근성 권한: 시스템 설정 → 개인정보 보호 → 접근성 → python/osascript 허용
3. 수동 테스트:
osascript -e 'tell application "DEVONthink 3" to get name of databases'
```
### MLX 서버 응답 없음
```
확인:
1. MLX 서버 프로세스 확인: ps aux | grep mlx
2. 포트 확인: lsof -i :8800
3. 모델 로드 확인: curl http://localhost:8800/v1/models
4. 재시작 필요 시: (MLX 서버 시작 명령어 실행)
```
### Daily Digest에 데이터가 비어있음
```
확인:
1. DEVONthink이 실행 중인지 확인
2. OmniFocus가 실행 중인지 확인
3. 로그 확인: tail -20 logs/digest.log
4. AppleScript 직접 테스트:
osascript -e 'tell application "DEVONthink 3" to get count of databases'
docker compose exec postgres pg_dump -U pkm pkm > backup_$(date +%Y%m%d).sql
```

View File

@@ -1,399 +0,0 @@
# PKM 시스템 개발 로드맵
> 작성일: 2026-03-29 (GPU 서버 재구성 계획 통합: 2026-03-29)
> 현재 상태: Phase 1 코드 작성 완료(90%), 인프라 일부 미해결 → Phase 1.5(GPU 재구성) + Phase 2 진행 중
---
## 현재 완료된 것
| 단계 | 항목 | 상태 | 비고 |
|------|------|------|------|
| 1 | 프로젝트 구조 | ✅ 완료 | README, deploy.md, .gitignore |
| 2 | AI 분류 프롬프트 | ✅ 완료 | MLX(Qwen3.5) OpenAI 호환 API 전환 완료 |
| 3 | AppleScript | ✅ 완료 | auto_classify + omnifocus_sync |
| 4 | 법령 모니터링 | ⚠️ 부분 | 외국(US/JP/EU) OK, 한국 API 인증 실패 |
| 5 | MailPlus 수집 | ❌ 연결 실패 | IMAP Connection refused |
| 6 | Daily Digest | ⚠️ 미테스트 | 코드 완성, 실행 기록 없음 |
| 7 | DEVONagent 가이드 | ✅ 완료 | docs/devonagent-setup.md |
| 8 | 전체 테스트 | ❌ 미진행 | test_classify.py만 존재 |
| 추가 | PKM API 서버 | ⚠️ 부분 | 한글 인코딩, stats 500 에러 |
---
## Phase 1.5: GPU 서버 재구성 (Phase 2와 병행)
> 상세 계획: docs/gpu-restructure.md 참조
GPU 서버(RTX 4070Ti Super)의 역할을 LLM 추론에서 임베딩/OCR 특화로 전환한다.
Mac Mini와 중복되는 LLM 모델을 제거하고, Surya OCR + bge-m3를 배치한다.
### 1.5-A. GPU 서버 정리 (SSH 작업)
```
작업 내용:
1. Ollama 모델 제거: qwen3.5:9b-q8_0, id-9b
2. 새 모델 설치: bge-m3 (임베딩), bge-reranker-v2-m3 (리랭킹)
3. no-think proxy(11435) 비활성화
4. paperless-gpt 처리 방침 결정
5. tk-ai-service 코드 수정 (Ollama API → OpenAI API 전환)
검증: ollama list → bge-m3, bge-reranker만 존재
검증: tk-ai-service /health → MLX(text), GPU(embed) 모두 connected
```
### 1.5-B. Docker + NFS + Komga 이전
```
작업 내용:
1. GPU 서버에 Docker Engine 설치
2. NAS NFS 마운트 설정 (192.168.1.227:/volume1/Comic → /mnt/comic, ro)
3. Komga Docker 컨테이너를 GPU 서버로 이전 (포트 25600 유지)
4. Mac Mini nginx upstream 변경 → GPU 서버
5. Mac Mini Komga 제거 (Docker VM 메모리 1.23GB 회수)
검증: curl https://komga.hyungi.net → GPU 서버 경유 접근 확인
```
### 1.5-C. Surya OCR 설치
```
작업 내용:
1. PyTorch CUDA 런타임 확인/설치
2. Surya OCR FastAPI 서버 구성 (/opt/surya-ocr/, 포트 8400)
3. systemd 서비스 등록
검증: curl -F "file=@test.pdf" http://192.168.1.186:8400/ocr → OCR 텍스트 반환
```
### 1.5-D. PKM 코드 갱신 (Phase 2와 겹치는 항목 포함)
```
작업 내용:
1. embed_to_chroma.py → embed_to_qdrant.py 리라이트 (Qdrant + bge-m3)
2. auto_classify.scpt: Step 0(OCR) 추가 + Step 4 Qdrant + sourceChannel 버그 픽스
3. requirements.txt: chromadb→qdrant-client, flask/gunicorn 추가 ← Phase 2 1-1과 합산
4. credentials.env: GPU_SERVER_IP=192.168.1.186 추가
5. architecture.md 대규모 갱신 (ChromaDB 28건, nomic 12건, VL-7B 5건)
검증: python3 scripts/embed_to_qdrant.py <테스트UUID> → Qdrant 벡터 저장 확인
```
### 1.5-E. RAG 파이프라인 + OCR 연동 (후순위)
```
작업 내용:
1. pkm_api_server.py에 RAG 엔드포인트 추가 (/rag/query, /devonthink/embed)
2. DEVONthink Smart Rule에 OCR 전처리 단계 추가
3. ocr_preprocess.py 신규 작성
검증: curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}' → 답변 반환
```
---
## Phase 2: 인프라 수정 (Phase 1.5와 병행)
Mac mini에서 직접 확인/수정이 필요한 항목들.
※ Phase 1.5(GPU 재구성)과 병행하여 진행. 겹치는 항목(requirements.txt, credentials.env, AppleScript)은 합산.
### 1-1. requirements.txt 수정 ← Phase 1.5-D와 합산 진행
```
현재 문제:
- flask 누락 (pkm_api_server.py에서 사용 중)
- schedule 패키지 미사용 (제거 고려)
- chromadb → qdrant-client 교체 (GPU 재구성에 따라)
수정 내용:
+ flask>=3.0.0
+ gunicorn>=21.2.0 (프로덕션 WSGI)
+ qdrant-client>=1.7.0
- chromadb>=0.4.0
- schedule>=1.2.0 (미사용 확인 후 제거)
```
### 1-2. 한국 법령 API 인증 해결
```
현재 에러:
"사용자 정보 검증에 실패하였습니다.
OPEN API 호출 시 사용자 검증을 위하여 정확한 서버장비의 IP주소 및 도메인주소를 등록해 주세요."
조치:
1. open.law.go.kr 접속 → 마이페이지 → 인증키 관리
2. Mac mini의 외부 IP 확인: curl ifconfig.me
3. 해당 IP를 API 호출 서버 IP로 등록
4. Tailscale IP(100.x.x.x)가 아니라 실제 공인 IP여야 함
5. 등록 후 law_monitor.py 재실행하여 확인
```
### 1-3. MailPlus IMAP 연결 수정
```
현재 에러: [Errno 61] Connection refused (mailplus.hyungi.net:993)
확인 순서:
1. Synology DSM → MailPlus Server → 서비스 상태 확인
2. IMAP 활성화 여부: DSM → MailPlus Server → 메일 전송 → IMAP 탭
3. 포트 확인: 993(SSL) vs 143(STARTTLS)
4. 방화벽: Synology 방화벽에서 993 포트 개방 확인
5. DNS 확인: nslookup mailplus.hyungi.net → 올바른 IP?
6. Tailscale 경유 시: 100.101.79.37:993으로 직접 테스트
python3 -c "import imaplib; m=imaplib.IMAP4_SSL('100.101.79.37', 993); print('OK')"
7. credentials.env에 MAILPLUS_HOST 값 확인
```
### 1-4. launchd 등록 상태 확인 및 등록
```bash
# Mac mini에서 확인
launchctl list | grep pkm
# 미등록 시 심볼릭 링크 생성
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.law-monitor.plist ~/Library/LaunchAgents/
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.mailplus.plist ~/Library/LaunchAgents/
ln -sf ~/Documents/code/DEVONThink_my\ server/launchd/net.hyungi.pkm.daily-digest.plist ~/Library/LaunchAgents/
# 로드
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.law-monitor.plist
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.mailplus.plist
launchctl load ~/Library/LaunchAgents/net.hyungi.pkm.daily-digest.plist
```
---
## Phase 2: 버그 픽스 (코드 수정)
### 2-1. JP 번역 thinking 오염 문제
```
현재: MLX Qwen3.5가 번역 시 "Wait, I'll check if..." 같은 thinking을 출력에 포함
위치: scripts/law_monitor.py의 JP 번역 호출부
수정 방향:
- 번역 프롬프트에 /nothink 모드 명시 강화
- llm_generate() 호출 시 thinking 출력 후처리 추가
- "Wait,", "Let me", "I'll check" 등 패턴 필터링
- 또는 번역 결과에서 첫 번째 유효 문장만 추출
```
### 2-2. PKM API 서버 한글 인코딩
```
현재: /devonthink/search?q=산업안전 → 400 Bad request syntax
위치: scripts/pkm_api_server.py
수정:
- Flask 자체는 UTF-8 지원하므로, 클라이언트 측 URL 인코딩 문제일 가능성
- curl 호출 시 --data-urlencode 사용 또는 퍼센트 인코딩 필요
- 서버 측에서도 request.args.get('q', '') 기본 인코딩 확인
```
### 2-3. /devonthink/stats 500 에러 수정
```
현재: GET /devonthink/stats → 500 Internal Server Error
위치: scripts/pkm_api_server.py
원인 추정: AppleScript 실행 시 DB 이름이나 경로 문제
수정: 에러 로그 확인 후 AppleScript 쿼리 수정
```
### 2-4. AppleScript 하드코딩 경로 개선
```
현재:
- ~/Documents/code/DEVONThink_my server/scripts/prompts/ 하드코딩
- venv 경로 하드코딩
수정:
- 스크립트 상단에 BASE_DIR 변수 정의
- 또는 환경변수 PKM_HOME으로 통일
```
### 2-5. requirements.txt 정리
```
추가: flask>=3.0.0, gunicorn>=21.2.0
유지: anthropic (향후 Tier 2용)
검토: schedule (미사용이면 제거)
```
---
## Phase 3: API 서버 개선
### 3-1. 프로덕션 서빙
```
현재: Flask development server
변경: gunicorn + launchd로 안정 운영
launchd plist 추가:
net.hyungi.pkm.api-server.plist
→ gunicorn -w 2 -b 127.0.0.1:9900 pkm_api_server:app
```
### 3-2. 엔드포인트 보완
```
현재 엔드포인트:
GET /health
GET /devonthink/stats ← 500 에러 수정 필요
GET /devonthink/search
GET /devonthink/inbox-count
GET /omnifocus/stats
GET /omnifocus/today
GET /omnifocus/overdue
추가 고려:
GET /law-monitor/status ← 마지막 실행 결과, 다음 실행 시간
GET /digest/latest ← 최근 다이제스트 조회
POST /classify ← 수동 분류 요청 (테스트용)
```
### 3-3. 간단한 인증 추가 (선택)
```
localhost 전용이면 불필요하지만, Tailscale 내부에서 접근할 경우:
- Bearer token 방식 (credentials.env에 API_TOKEN 추가)
- 또는 IP 화이트리스트 (127.0.0.1 + Tailscale 대역)
```
---
## Phase 4: 테스트 & 검증
### 4-1. 개별 모듈 테스트
```bash
# Mac mini에서 실행
# 1. AI 분류 테스트 (5종 문서)
cd ~/Documents/code/DEVONThink_my\ server/
source venv/bin/activate
python tests/test_classify.py
# 2. 법령 모니터링 (한국 API 인증 후)
python scripts/law_monitor.py
# 3. MailPlus (IMAP 수정 후)
python scripts/mailplus_archive.py
# 4. Daily Digest
python scripts/pkm_daily_digest.py
# 5. API 서버
python scripts/pkm_api_server.py &
curl http://localhost:9900/health
curl http://localhost:9900/devonthink/stats
curl http://localhost:9900/devonthink/inbox-count
curl "http://localhost:9900/devonthink/search?q=safety&limit=3"
```
### 4-2. E2E 통합 테스트
```
시나리오 1: Inbox → 자동분류 플로우
1. DEVONthink Inbox에 테스트 문서 추가
2. Smart Rule 트리거 → auto_classify.scpt 실행 확인
3. 태그, 메타데이터, DB 이동 확인
시나리오 2: 법령 → 다이제스트 플로우
1. law_monitor.py 수동 실행
2. data/laws/에 파일 생성 확인
3. DEVONthink 04_Industrial Safety 확인
4. pkm_daily_digest.py 실행 → 법령 변경 건 포함 확인
시나리오 3: OmniFocus 연동
1. Projects DB에 TODO 패턴 문서 추가
2. omnifocus_sync.scpt 트리거 확인
3. OmniFocus Inbox에 작업 생성 확인
```
### 4-3. 테스트 리포트 작성
```
→ docs/test-report.md
각 항목별 pass/fail + 스크린샷/로그 첨부
```
---
## Phase 5: 운영 안정화 (선택)
### 5-1. 모니터링
```
- 로그 로테이션 (logrotate 또는 Python RotatingFileHandler)
- Synology Chat 웹훅 알림 연동 (CHAT_WEBHOOK_URL 설정 후)
- 에러 발생 시 즉시 알림
```
### 5-2. 백업
```
- Gitea 리포지토리 자동 백업 (이미 NAS에 있으므로 OK)
- credentials.env 백업 (Vaultwarden에 보관?)
- Qdrant 데이터 백업 (pkm_documents + tk_qc_issues 컬렉션)
```
### 5-3. 문서 보완
```
- README.md 상세화 (아키텍처 다이어그램, 기능 목록)
- 트러블슈팅 가이드 추가
- deploy.md에 API 서버 + 업데이트 절차 추가
```
---
## 작업 순서 요약
```
[GPU] Phase 1.5: GPU 서버 재구성 (Phase 2와 병행)
1.5-A. GPU 서버 정리 (모델 교체, proxy 제거) ← SSH 작업
1.5-B. Docker + NFS + Komga 이전 ← SSH 작업
1.5-C. Surya OCR 설치 ← SSH 작업
1.5-D. PKM 코드 갱신 (Qdrant, 임베딩) ← 코드 수정 후 push
1.5-E. RAG + OCR 연동 ← 후순위
[즉시] Phase 2: 인프라 수정
1-1. requirements.txt 수정 ← Phase 1.5-D와 합산
1-2. 한국 법령 API IP 등록 ← Mac mini에서 공인IP 확인
1-3. MailPlus IMAP 확인 ← Synology DSM 확인
1-4. launchd 등록 ← Mac mini에서 실행
[코드] Phase 3: 버그 픽스
2-1. JP 번역 thinking 필터링 ← 코드 수정 후 push
2-2~3. API 서버 수정 ← 코드 수정 후 push
2-4. AppleScript 경로 변수화 ← Phase 1.5-D와 합산
2-5. requirements.txt 정리 ← Phase 1.5-D와 합산
[개선] Phase 4: API 서버 개선
3-1. gunicorn 전환 + launchd plist ← 코드 작성 후 push
3-2~3. 엔드포인트 추가 ← 필요시
[검증] Phase 5: 테스트
4-1~2. 모듈별 + E2E 테스트 ← Mac mini에서 실행
4-3. 테스트 리포트 ← 결과 기반 작성
[안정] Phase 6: 운영 안정화 ← 여유 있을 때
```
---
## 예상 소요 시간
| Phase | 예상 시간 | 비고 |
|-------|-----------|------|
| Phase 1.5-A~C | 3~4시간 | GPU 서버 SSH 작업 (모델 교체, Docker, Surya) |
| Phase 1.5-D | 3~4시간 | PKM 코드 갱신 (Qdrant, architecture.md 대규모 수정) |
| Phase 1.5-E | 2~3시간 | RAG + OCR 연동 (후순위) |
| Phase 2 | 1~2시간 | 인프라 설정 확인 작업 |
| Phase 3 | 2~3시간 | 버그 픽스 코드 수정 |
| Phase 4 | 1~2시간 | gunicorn 전환 중심 |
| Phase 5 | 2~3시간 | Mac mini에서 테스트 실행 |
| Phase 6 | 필요시 | 운영하면서 점진적 |
| **합계** | **~18시간** | 4~5일 분량 |

142
docs/development-stages.md Normal file
View File

@@ -0,0 +1,142 @@
# 개발 단계 가이드
> 작업 위치: MacBook Pro `~/Documents/code/hyungi_Document_Server/`
> 개발/배포: MacBook Pro (Claude Code) → Gitea push → 서버에서 pull
> 설계 원본: `docs/architecture.md`
---
## Phase 0: 기반 구축 (1~2주)
### 산출물
- `docker compose up -d` → postgres, fastapi, kordoc, caddy 구동
- DB 스키마 자동 생성 (`migrations/001_initial_schema.sql`)
- JWT + TOTP 인증 작동 (로그인, 토큰 갱신)
- NAS SMB 마운트 검증 (Docker 컨테이너에서 `/documents` 읽기/쓰기)
- `config.yaml` 로딩 검증
### 핵심 파일
- `app/main.py` — FastAPI 앱 엔트리포인트 + lifespan + APScheduler
- `app/core/config.py` — Pydantic settings (config.yaml + credentials.env 로딩)
- `app/core/database.py` — SQLAlchemy async engine + session factory
- `app/core/auth.py` — JWT 발급/검증 + TOTP 2FA
- `migrations/001_initial_schema.sql` — documents, tasks, processing_queue 테이블
### 완료 기준
- [ ] `curl localhost:8000/docs` → OpenAPI 문서 반환
- [ ] 로그인 플로우 성공 (JWT 발급 + TOTP 검증)
- [ ] `psql`로 DB 테이블 3개 존재 확인 (documents, tasks, processing_queue)
- [ ] Docker 컨테이너에서 NAS 파일 읽기/쓰기 정상
---
## Phase 1: 데이터 마이그레이션 (1~2주)
### 산출물
- `scripts/migrate_from_devonthink.py` — DEVONthink → NAS 폴더 구조 생성 + 파일 이동 + DB 등록
- kordoc-service 컨테이너 구동, 텍스트 추출 작동
- 배치: 전 문서 텍스트 추출 → AI 분류 → 벡터 임베딩
### 핵심 파일
- `scripts/migrate_from_devonthink.py` — 마이그레이션 스크립트
- `services/kordoc/server.js` — HWP/PDF 파싱 HTTP API
- `app/workers/extract_worker.py` — kordoc 호출, DB에 extracted_text 저장
- `app/workers/classify_worker.py` — MLX로 AI 분류/태그/요약
- `app/workers/embed_worker.py` — GPU 서버로 벡터 임베딩
### 완료 기준
- [ ] PostgreSQL 문서 수 = DEVONthink 문서 수
- [ ] 텍스트 추출 성공률 >95%
- [ ] 20건 분류 spot-check 통과 (도메인, 태그 정확도)
- [ ] 벡터 임베딩 정상 생성 (embedding 컬럼 NOT NULL 비율)
---
## Phase 2: 핵심 기능 (2~3주)
### 산출물
- 문서 CRUD API (`/api/documents/`)
- 전문검색 + 벡터검색 API (`/api/search/`)
- 문서 뷰어: PDF(pdf.js), Markdown, Synology Office iframe, HWP(kordoc Markdown)
- Inbox 자동분류 파이프라인 (감지→추출→분류→임베딩→폴더 이동)
- 파일 변경 감지 (해시 비교 → 재가공)
### 핵심 파일
- `app/api/documents.py` — 문서 CRUD
- `app/api/search.py` — GIN/pg_trgm + pgvector 검색
- `app/workers/file_watcher.py` — NAS 파일 변경 감지
- `frontend/src/routes/documents/+page.svelte` — 문서 탐색
- `frontend/src/lib/components/DocumentViewer.svelte` — 포맷별 뷰어
### 완료 기준
- [ ] 검색 API가 ranked 결과 반환
- [ ] Inbox에 파일 업로드 → 자동 분류 + Knowledge 폴더 이동 확인
- [ ] PDF, Markdown, HWP 뷰어 정상 렌더링
- [ ] 파일 수정 후 해시 변경 감지 → 재가공 큐 등록
---
## Phase 3: 자동화 이전 (1~2주)
### 산출물
- `app/workers/law_monitor.py` — 법령 변경 → NAS 저장 + DB 등록 + CalDAV 태스크
- `app/workers/mailplus_archive.py` — IMAP 수집 → NAS 저장 + DB 등록 + SMTP 알림
- `app/workers/daily_digest.py` — PostgreSQL/CalDAV 쿼리 → Markdown 생성 + SMTP 발송
- APScheduler 스케줄 등록 (07:00, 07:00+18:00, 20:00)
- CalDAV 태스크 연동 (Synology Calendar)
### v1→v2 코드 재활용
v1 코드 참조: `git show v1-final:scripts/<파일명>`
| v1 | v2 | 변경 |
|-----|-----|------|
| `scripts/law_monitor.py` | `app/workers/law_monitor.py` | `import_to_devonthink()``save_to_nas()` + `register_in_db()` + `create_caldav_task()` |
| `scripts/mailplus_archive.py` | `app/workers/mailplus_archive.py` | `import_to_devonthink()``save_to_nas()` + `register_in_db()` + `send_smtp_notification()` |
| `scripts/pkm_daily_digest.py` | `app/workers/daily_digest.py` | DEVONthink/OmniFocus 쿼리 → PostgreSQL/CalDAV 쿼리 |
| `scripts/pkm_utils.py` | `app/core/utils.py` | `run_applescript*()` 제거, 나머지 유지 |
### 완료 기준
- [ ] 법령 모니터 실행 → NAS 파일 + DB 레코드 + CalDAV VTODO 생성
- [ ] 이메일 수집 → NAS 저장 + DB 등록 정상
- [ ] 다이제스트 → Markdown 생성 + SMTP 발송 확인
- [ ] APScheduler 스케줄 3개 정상 등록 확인
---
## Phase 4: UI 완성 (2~3주)
### 산출물
- 대시보드 위젯: 오늘 할일, Inbox 미분류, 법령 알림, 최근 문서, 시스템 상태
- 태그/폴더 탐색 사이드바
- 메타데이터 패널 (AI 요약, pgvector 관련 문서 5건, 가공 이력)
- Inbox 분류 UI (수동 오버라이드 + 배치 승인)
- 반응형 모바일 대응
- 내보내기 API (Markdown → DOCX/HWPX via kordoc)
### 핵심 파일
- `app/api/dashboard.py`, `tasks.py`, `export.py`
- `frontend/src/lib/components/Sidebar.svelte`, `MetadataPanel.svelte`, `TaskWidget.svelte`
- `frontend/src/routes/inbox/+page.svelte`
- `frontend/src/routes/settings/+page.svelte`
### 완료 기준
- [ ] 전체 워크플로우: 로그인 → 대시보드 → 검색 → 문서 조회 → 태스크 → Inbox 분류
- [ ] 모바일 브라우저에서 정상 표시
- [ ] 내보내기 API로 DOCX 생성 확인
---
## Phase 5: DEVONthink 퇴역 (2주)
### 산출물
- 2주간 v1+v2 병행 운영
- 비교 리포트: 문서 수, 검색 품질, 자동화 안정성
- Mac mini: main 브랜치 전환 + `docker compose up -d`
- 기존 launchd plist 해제 (`launchctl unload`)
- DEVONthink DB 종료
- NAS `Document_Server/DEVONThink/` 아카이브
### 완료 기준
- [ ] Mac mini `docker compose up -d` 후 전체 기능 정상
- [ ] DEVONthink 없이 1주 운영 안정
- [ ] 모든 자동화(법령, 이메일, 다이제스트) 정상 실행

181
docs/devlog.md Normal file
View File

@@ -0,0 +1,181 @@
# 개발 로그
## 2026-04-02 — v2 전환 설계 완료
### 결정 사항
- DEVONthink 탈피 결정. v1의 구조적 한계(AppleScript 취약성, macOS GUI 의존, 13개 DB 복잡성)를 더 이상 감수하지 않기로 함
- 자체 웹앱 방향 확정. 기술 스택: FastAPI + PostgreSQL/pgvector + SvelteKit + Docker
- OmniFocus 탈피 → Synology Calendar (CalDAV VTODO)로 대체
- Synology 서비스 활용 극대화: Office(문서 편집/미리보기), Drive(파일 관리), Calendar(태스크), MailPlus(이메일+알림)
- Document_Server 전체를 Synology Drive가 관리. PKM 하위 폴더로 자동분류 영역 분리
- 문서 "원본" 정의 확정: immutable(PDF, 수신 HWP 등) / editable(Synology Office 포맷) / note(Markdown)
- .docx/.xlsx는 교환 형식으로 취급. 서버에 영구 보관하지 않음
- 데이터 3계층: 원본(NAS) → 가공(PostgreSQL) → 파생(pgvector+캐시)
- kordoc 통합 결정 (HWP/HWPX/PDF → Markdown 파싱, Node.js 마이크로서비스)
- AI 전략: Qwen3.5-35B-A3B(MLX) 우선, Claude API는 종량제로 최후 수단. GPU 서버에 AI Gateway 배치
- Anthropic 약관 확인: 구독 OAuth의 서드파티 사용은 약관상 금지(2026.01~). 자동화에는 API 키만 사용
- NanoClaw는 선택적 확장(대화형 인터페이스)으로 위치, 핵심 파이프라인 비의존
- 장기 로드맵: GPU 서버 확장 → 메인 서버 승격, Mac mini → Roon Core 전용, Synology 장기 유지
### 산출물
- `docs/architecture-v2.md` — 17개 섹션 + 부록 2개 (전체 시스템 설계)
- 마이그레이션 계획서 — Step 1~5 (프로젝트 리네임+정리) + Phase 0~5 (v2 개발)
- 프로젝트 리네임: DEVONThink_my server → hyungi_Document_Server
### 배경 논의 (Cowork 세션)
- v1에서 16개 커밋 중 절반 이상이 AppleScript 버그 수정이었던 점이 전환의 직접적 계기
- Synology Office iframe 임베드로 DEVONthink 미리보기 대체 가능성 논의
- HWP 대응으로 kordoc(광진구청 류승인 주무관 제작, MIT 라이선스) 조사 및 채택
- 편집 가능 문서의 "원본이 뭐냐" 논의 → Synology Office 포맷이 원본, .docx/.xlsx는 교환용
- 가공 데이터 보관 전략 논의 → 파일로 저장하지 않고 PostgreSQL에만 저장, 버전 추적으로 선택적 재가공
## 2026-04-02 — 프로젝트 리네임 + v2 전환 실행
### Step 1: 사전 정리 ✅
- architecture-v2.md 커밋 (`852b7da`)
- v1-archive 브랜치 + v1-final 태그 생성 (v1 상태 완벽 보존)
### Step 2: v1 파일 정리 ✅
- v1 전용 파일 git rm 완료 (`e48b6a2`)
- 삭제: applescript/, launchd/, v1 scripts, v1 docs, tests/test_classify.py, requirements.txt
- 유지: scripts/prompts/classify_document.txt, credentials.env.example (v2 필드로 갱신)
### Step 3: Gitea 리포 리네임 + 로컬 폴더 리네임 ✅
- Gitea: devonthink_home → hyungi_document_server
- 로컬 폴더: DEVONThink_my server → hyungi_Document_Server
- git remote set-url + git ls-remote 검증 + push 완료
### Step 4: 문서 전면 재작성 ✅
- CLAUDE.md — v2 기준으로 전면 재작성
- README.md — 프로젝트명, 기술 스택, 디렉토리 구조 갱신
- docs/deploy.md — Docker Compose 기반 배포 가이드로 교체
- docs/claude-code-commands.md → docs/development-stages.md 변환
- docs/architecture-v2.md → docs/architecture.md 승격
### Step 5: v2 프로젝트 스캐폴딩 ✅
- 전체 디렉토리 구조 생성 (app/, services/kordoc/, gpu-server/, frontend/, migrations/, tests/)
- 동작하는 최소 코드 수준: FastAPI main.py, PostgreSQL 스키마, kordoc server.js, config.yaml 등
- docker-compose.yml, Caddyfile, credentials.env.example 생성
- tests/__init__.py + conftest.py 포함
### Step 1~5 전체 완료.
---
## 2026-04-02 — Phase 0: 기반 구축 시작
### users 테이블 + ORM 모델 추가 ✅
- `migrations/001_initial_schema.sql`에 users 테이블 포함 (username, password_hash, totp_secret, is_active)
- `app/models/user.py` — SQLAlchemy 2.0 Mapped 스타일 ORM 모델
- architecture.md 섹션 6 스키마와 일치
### Auth API 엔드포인트 구현 ✅
- `app/api/auth.py` — 4개 엔드포인트: POST /login (JWT발급+TOTP), POST /refresh, GET /me, POST /change-password
- `app/core/auth.py` — bcrypt 비밀번호 해싱, JWT 발급/검증, TOTP 검증, get_current_user 의존성
- Pydantic 스키마: LoginRequest, TokenResponse, RefreshRequest, ChangePasswordRequest, UserResponse
### main.py 라우터 등록 + health 강화 ✅
- auth 라우터 등록: `/api/auth` prefix
- health 엔드포인트에 DB 연결 상태 포함 (connected/disconnected)
- lifespan 핸들러로 DB 초기화/정리
### Docker 설정 수정 ✅
### 초기 admin 유저 시드 스크립트 ✅
### 셋업 위자드 구현 ✅ (`a601991`)
- `app/api/setup.py` — 6개 엔드포인트: GET /status, POST /admin, POST /totp/init, POST /totp/verify, POST /verify-nas, GET / (HTML)
- `app/templates/setup.html` — Jinja2 단일 HTML, Vanilla JS + qrcode.js CDN, 3단계 위자드
- `app/main.py` — setup 라우터 등록 + 셋업 미들웨어 (유저 0명 시 /setup 리다이렉트, /health /docs 등 바이패스)
- Rate Limiting: IP당 5분 내 5회 실패 시 차단
- TOTP 흐름: init에서 secret 반환(DB 미저장) → verify에서 코드 검증 후 DB 저장
- scripts/seed_admin.py CLI 백업 수단 유지
- requirements.txt에 jinja2 추가
### Phase 0 완료 기준 달성 상태
- ✅ docker compose up → FastAPI 구동
- ✅ DB 스키마 (users, documents, tasks, processing_queue)
- ✅ JWT + TOTP 인증 (로그인, 토큰 갱신, 비밀번호 변경)
- ✅ 셋업 위자드 (관리자 생성 + TOTP + NAS 확인)
- ✅ /health — DB 연결 상태 포함
- ✅ /docs — OpenAPI 문서
- ⬜ NAS SMB 마운트 실제 검증 (Mac mini 배포 시)
- ⬜ config.yaml 로딩 검증 (Mac mini 배포 시)
---
## 2026-04-02 — Phase 1: 데이터 마이그레이션 파이프라인 구현 완료
### Step 1: kordoc /parse 실제 구현 ✅
- `services/kordoc/server.js` — stub → 실제 파싱 구현 (kordoc ^1.7.0 + pdfjs-dist ^4.0.0)
- HWP/HWPX/PDF → Markdown 변환, .md/.txt 직접 읽기, 이미지는 requires_ocr 플래그 반환
- 타임아웃 30초, 파일 미존재 404, 파싱 실패 422
### Step 2: 큐 소비자 인프라 ✅
- `app/workers/queue_consumer.py` — APScheduler AsyncIOScheduler 1분 간격 실행
- 배치 처리: extract=5, classify=3, embed=1
- stage 체이닝: extract → classify → embed 자동 enqueue
- stale 항목 자동 복구 (processing 상태 10분 초과)
- `app/main.py` — lifespan에 APScheduler 연결, yield 후 shutdown 보장
### Step 3: 텍스트 추출 워커 ✅
- `app/workers/extract_worker.py` — 포맷별 분기 처리
- KORDOC_FORMATS (hwp, hwpx, pdf) → kordoc HTTP POST
- TEXT_FORMATS (md, txt, csv, json, xml, html) → 직접 파일 읽기
- IMAGE_FORMATS → Phase 2 OCR로 연기
### Step 4: AI 분류 워커 ✅
- `app/workers/classify_worker.py` — extracted_text 8000자 → AIClient.classify() 호출
- `app/ai/client.py` — strip_thinking(), parse_json_response() 추가 (v1 pkm_utils.py에서 포팅)
- Qwen3.5의 <think> 태그 제거 + 비정형 JSON 파싱 로직
### Step 5: 벡터 임베딩 워커 ✅
- `app/workers/embed_worker.py` — nomic-embed-text-v1.5 (GPU 서버), 6000자 제한
- GPU 서버 불가 시 graceful fail → 재시도
### Step 6: DEVONthink 마이그레이션 스크립트 ✅
- `scripts/migrate_from_devonthink.py` — --dry-run, --source-dir, --target-dir, --database-url 지원
- DEVONthink 내보내기 → NAS PKM 구조 복사 + documents/processing_queue DB 등록
### Phase 1 완료 기준 달성 상태
- ✅ kordoc 파싱 (HWP/HWPX/PDF → Markdown)
- ✅ 큐 소비자 + APScheduler 연동
- ✅ extract → classify → embed 워커 3개
- ✅ AI 클라이언트 think 태그 / JSON 파싱 보강
- ✅ 마이그레이션 스크립트
- ⬜ Step 7: 통합 테스트 + 배치 실행 (Mac mini 배포 후)
---
## 2026-04-02 — Phase 2: 핵심 기능 구현 완료 (`4b69533`)
### 문서 CRUD API ✅
- `app/api/documents.py` — 5개 엔드포인트
- POST /api/documents/ — 파일 업로드 (Inbox 저장 + extract 큐 등록)
- GET /api/documents/ — 목록 조회 (페이징 + domain/source/format 필터)
- GET /api/documents/{id} — 단건 조회
- PATCH /api/documents/{id} — 메타데이터 수동 수정
- DELETE /api/documents/{id} — DB 삭제 (기본), ?delete_file=true로 파일도 삭제
### 하이브리드 검색 API ✅
- `app/api/search.py` — GET /api/search/?q={query}&mode={mode}
- 4가지 모드: fts, trgm, vector, hybrid (기본)
- hybrid 가중치: FTS 0.4 + Trigram 0.2 + Vector 0.4
- 벡터 불가 시 FTS 0.6 + Trigram 0.4 폴백
- 결과에 snippet(200자) 포함
### 파일 워처 ✅
- `app/workers/file_watcher.py` — Inbox 디렉토리 5분 간격 스캔
- 신규 파일: Document 생성 + extract 큐 등록
- 변경 파일: 해시 비교 → 재추출 큐 등록
- .DS_Store, .tmp, .part 등 무시 파일 처리
### 벡터 인덱스 마이그레이션 ✅
- `migrations/002_vector_index.sql` — IVFFlat 인덱스 (cosine, lists=50)
- 문서 수 증가 시 lists 값 조정 필요
### Phase 2 완료 기준 달성 상태
- ✅ 문서 CRUD API (업로드, 목록, 조회, 수정, 삭제)
- ✅ 하이브리드 검색 (FTS + Trigram + Vector)
- ✅ Inbox 파일 워처 (신규/변경 자동 감지 → 파이프라인 등록)
- ✅ 처리 파이프라인 전체 동작 (upload/watch → extract → classify → embed → search)
- ⬜ 문서 뷰어 UI (Phase 4로 이관)
- ⬜ SvelteKit 프론트엔드 (Phase 4로 이관)

View File

@@ -1,103 +0,0 @@
# DEVONagent Pro — 검색 세트 설정 가이드
DEVONagent Pro에서 안전 분야 + 기술 분야 자동 검색 세트를 설정합니다.
주간 합계 50~85건 수준으로 양을 조절합니다.
## 공통 설정
- **Schedule**: 각 세트별 지정 (매일/주간)
- **Action**: Import to DEVONthink → Inbox DB
- **Max Results per Run**: 각 세트별 지정
- **Language**: 해당 언어
---
## 검색 세트 1: 국내 산업안전 뉴스 (매일)
- **키워드**: `산업안전 OR 중대재해 OR 위험성평가 OR 안전사고`
- **사이트**: kosha.or.kr, moel.go.kr, safetynews.co.kr, dailysafety.com
- **Max Results**: 5/일
- **Schedule**: 매일 08:00
## 검색 세트 2: 국내 중대재해 뉴스 (매일)
- **키워드**: `중대재해 OR 산업재해 OR 작업장사고 -주식 -부동산`
- **사이트**: 뉴스 전체
- **Max Results**: 3/일
- **Schedule**: 매일 08:30
## 검색 세트 3: KOSHA 가이드/지침 (주간)
- **키워드**: `site:kosha.or.kr 가이드 OR 지침 OR 기술자료`
- **Max Results**: 5/주
- **Schedule**: 매주 월요일 09:00
## 검색 세트 4: 국내 산업안전 학술/논문 (주간)
- **키워드**: `산업안전 OR 위험성평가 OR occupational safety site:kci.go.kr OR site:dbpia.co.kr`
- **Max Results**: 3/주
- **Schedule**: 매주 수요일 09:00
## 검색 세트 5: US OSHA / Safety+Health Magazine (주간)
- **키워드**: `occupational safety OR workplace hazard OR OSHA regulation`
- **사이트**: osha.gov, safetyandhealthmagazine.com, ehstoday.com
- **Max Results**: 5/주
- **Language**: English
- **Schedule**: 매주 화요일 09:00
## 검색 세트 6: JP 厚生労働省 / 安全衛生 (주간)
- **키워드**: `労働安全 OR 安全衛生 OR 労災`
- **사이트**: mhlw.go.jp, jisha.or.jp
- **Max Results**: 3/주
- **Language**: Japanese
- **Schedule**: 매주 목요일 09:00
## 검색 세트 7: EU-OSHA (월간)
- **키워드**: `occupational safety health EU regulation`
- **사이트**: osha.europa.eu
- **Max Results**: 5/월
- **Language**: English
- **Schedule**: 매월 1일 09:00
## 검색 세트 8: 기술 뉴스 — AI/서버/네트워크 (매일)
- **키워드**: `AI model release OR server infrastructure OR homelab OR self-hosted`
- **사이트**: news.ycombinator.com, arstechnica.com, theregister.com
- **Max Results**: 5/일
- **Schedule**: 매일 12:00
## 검색 세트 9: 프로그래밍 기술 동향 (주간)
- **키워드**: `Python release OR Node.js update OR Docker best practice OR FastAPI`
- **사이트**: dev.to, blog.python.org, nodejs.org
- **Max Results**: 5/주
- **Schedule**: 매주 금요일 12:00
---
## 주간 예상 건수
| 세트 | 빈도 | 건/주 |
|------|------|-------|
| 1. 국내 안전 뉴스 | 매일 5 | ~35 |
| 2. 중대재해 뉴스 | 매일 3 | ~21 |
| 3. KOSHA 가이드 | 주간 5 | 5 |
| 4. 학술/논문 | 주간 3 | 3 |
| 5. US OSHA | 주간 5 | 5 |
| 6. JP 안전위생 | 주간 3 | 3 |
| 7. EU-OSHA | 월간 5 | ~1 |
| 8. 기술 뉴스 | 매일 5 | ~35 |
| 9. 프로그래밍 | 주간 5 | 5 |
| **합계** | | **~113** |
> 양이 너무 많으면 세트 1, 2, 8의 Max Results를 3으로 줄이면 주간 ~65건 수준으로 조절 가능.
## DEVONthink 전송 설정
1. DEVONagent → Preferences → DEVONthink
2. Target Database: **Inbox**
3. Auto-Tag: 검색 세트 이름으로 자동 태그 (`devonagent-검색세트명`)
4. DEVONthink Smart Rule이 Inbox에서 자동 분류 처리

174
docs/gpu-migration-plan.md Normal file
View File

@@ -0,0 +1,174 @@
# GPU 서버 이전 + NFS 전환 — Claude Code 작업 지시서
## 배경
Phase 0~4 완료. 현재 Mac mini에서 Docker 전체 구동 중.
GPU 서버(Ubuntu, RTX 4070 Ti Super)로 애플리케이션 이전.
NAS NFS 마운트로 Synology Drive 데드락 해결.
## 완료된 수동 작업
- ✅ Step 1: NAS NFS 서버 설정 (DSM에서 NFS 활성화, NFSv4.1)
- ✅ Step 2: GPU 서버 NFS 마운트 (`/mnt/nas/Document_Server`, fstab 등록 완료)
- ✅ Step 6: Mac mini MLX 서버 외부 접근 확인 (100.76.254.116:8800 응답 확인)
## 확정된 정보
- Mac mini Tailscale IP: `100.76.254.116`
- NAS 로컬 IP: `192.168.1.227`
- GPU 서버 로컬 IP: `192.168.1.186`
- NFS 마운트 경로: `/mnt/nas/Document_Server`
- MLX 모델: `mlx-community/Qwen3.5-35B-A3B-4bit` (Mac mini에서 계속 서빙)
## 목표 구조
```
GPU 서버 (Ubuntu, 메인 서버):
Docker Compose 단일 파일:
- postgres, fastapi, kordoc-service, frontend, caddy
- ollama (NVIDIA GPU), ai-gateway
NFS → NAS /volume4/Document_Server (/mnt/nas/Document_Server)
Mac mini M4 Pro (AI 서버만):
MLX Server: http://100.76.254.116:8800 (Qwen3.5-35B-A3B)
NAS DS1525+ (파일 저장소):
NFS export → GPU 서버
Synology Office/Calendar/MailPlus 유지
```
---
## Claude Code 작업 목록
### 작업 1: docker-compose.yml 통합
현재 루트 `docker-compose.yml` (Mac mini용)에 `gpu-server/docker-compose.yml`의 서비스를 통합.
변경 사항:
- `version: '3.8'` 제거 (Docker Compose V2 기준)
- NAS 볼륨 변수: `NAS_SMB_PATH``NAS_NFS_PATH`, 기본값 `/mnt/nas/Document_Server`
- Ollama 서비스 추가 (NVIDIA GPU runtime, ollama_data 볼륨)
- AI Gateway 서비스 추가 (Ollama depends_on)
- AI Gateway 환경변수: PRIMARY_ENDPOINT=http://100.76.254.116:8800/v1/chat/completions
- Caddy 포트: `127.0.0.1:8080:80` 유지 (HTTPS는 앞단 프록시(UCG-Fiber)에서 처리, Caddy는 HTTP only)
- ollama_data 볼륨 추가
참고 — 현재 파일:
- 루트 docker-compose.yml: postgres, kordoc-service, fastapi, frontend, caddy
- gpu-server/docker-compose.yml: ollama, ai-gateway
### 작업 2: config.yaml AI 엔드포인트 변경
현재 → 변경:
```yaml
ai:
gateway:
endpoint: "http://ai-gateway:8080" # gpu-server → ai-gateway (같은 Docker 네트워크)
models:
primary:
endpoint: "http://100.76.254.116:8800/v1/chat/completions" # host.docker.internal → Mac mini Tailscale IP
# 나머지 동일
fallback:
endpoint: "http://ollama:11434/v1/chat/completions" # gpu-server → ollama (같은 Docker 네트워크)
# 나머지 동일
embedding:
endpoint: "http://ollama:11434/api/embeddings" # gpu-server → ollama
vision:
endpoint: "http://ollama:11434/api/generate" # gpu-server → ollama
rerank:
endpoint: "http://ollama:11434/api/rerank" # gpu-server → ollama
```
핵심: `gpu-server` 호스트명이 전부 `ollama` 또는 `ai-gateway`로 변경 (같은 Docker 네트워크).
primary만 Mac mini Tailscale IP `100.76.254.116`으로 외부 호출.
### 작업 3: credentials.env.example 갱신
변경 사항:
- `NAS_SMB_PATH``NAS_NFS_PATH=/mnt/nas/Document_Server`
- `MLX_ENDPOINT``http://100.76.254.116:8800/v1/chat/completions`
- `GPU_SERVER_IP` 항목 제거 (로컬이 됨)
- `AI_GATEWAY_ENDPOINT``http://ai-gateway:8080` (같은 Docker 네트워크)
- 주석 업데이트: "Mac mini MLX" → "Mac mini MLX (Tailscale 경유)"
### 작업 4: Caddyfile 확인
변경 불필요. 현재 상태 유지:
- `auto_https off` + `http://document.hyungi.net` (HTTPS는 앞단 프록시 UCG-Fiber에서 처리)
- Caddy 포트: `127.0.0.1:8080:80` (localhost 바인딩, 443 불필요)
### 작업 5: 문서 업데이트
#### CLAUDE.md — 네트워크 환경 섹션 갱신
현재:
```
Mac mini M4 Pro (애플리케이션 서버):
- Docker Compose: FastAPI(:8000), PostgreSQL(:5432), kordoc(:3100), Caddy(:80,:443)
- MLX Server: http://localhost:8800/v1/chat/completions (Qwen3.5-35B-A3B)
- 외부 접근: document.hyungi.net (Caddy 프록시)
```
변경:
```
GPU 서버 (RTX 4070 Ti Super, Ubuntu, 메인 서버):
- Docker Compose: FastAPI(:8000), PostgreSQL(:5432), kordoc(:3100), Caddy(:8080, HTTP only), Ollama(:11434), AI Gateway(:8080), frontend(:3000)
- NFS 마운트: /mnt/nas/Document_Server → NAS /volume4/Document_Server
- 외부 접근: document.hyungi.net (Caddy 프록시)
Mac mini M4 Pro (AI 서버):
- MLX Server: http://100.76.254.116:8800/v1/chat/completions (Qwen3.5-35B-A3B)
- Roon Core
```
GPU 서버 Tailscale IP도 추가. AI 모델 구성 섹션에서 primary endpoint 변경 반영.
#### docs/architecture.md — 섹션 3 (인프라 역할 분담) 갱신
Mac mini가 애플리케이션 서버 → GPU 서버가 메인 서버로 변경.
Mac mini는 AI 서버(MLX)만 담당하는 것으로 변경.
아스키 다이어그램 업데이트.
#### docs/deploy.md — GPU 서버 기준 배포 가이드로 변경
- 전제조건: NFS 마운트 (/mnt/nas/Document_Server)
- clone 경로, docker compose 명령 등 GPU 서버 기준으로 변경
- pg_dump/pg_restore 마이그레이션 절차 추가
#### docs/devlog.md — GPU 이전 기록 추가
Phase 1~2는 이미 기록됨. 아래 추가:
1. Phase 3 완료 기록 (자동화 이전: law_monitor, mailplus_archive, daily_digest, automation_state, APScheduler cron) — 기록 안 되어 있으면 추가
2. Phase 4 완료 기록 (SvelteKit UI: 로그인, 대시보드, 문서 탐색/검색, Inbox, 설정, Docker 통합) — 기록 안 되어 있으면 추가
3. GPU 서버 이전 기록 (NFS 전환, docker-compose 통합, AI 엔드포인트 변경, Caddy HTTP only 구조)
### 작업 6: gpu-server/docker-compose.yml 비활성화
- 파일 상단에 주석 추가: "# 이 파일은 더 이상 사용하지 않음. 루트 docker-compose.yml로 통합됨."
- 또는 gpu-server/docker-compose.yml.bak으로 리네임
---
## 작업 순서 (추천)
1. docker-compose.yml 통합 (작업 1)
2. config.yaml 변경 (작업 2)
3. credentials.env.example 갱신 (작업 3)
4. gpu-server/docker-compose.yml 비활성화 (작업 6)
5. 문서 업데이트 (작업 5) — CLAUDE.md, architecture.md, deploy.md, devlog.md
6. Caddyfile 확인 (작업 4)
## 주의사항
- credentials.env 자체는 git에 올리지 않음 (.gitignore). example만 수정.
- Mac mini Tailscale IP `100.76.254.116`은 config.yaml에 직접 기입 (credentials.env에서 변수로 관리해도 됨)
- NAS 경로: Docker 컨테이너 내부에서는 `/documents`로 접근 (기존과 동일)
- GPU 서버 로컬 IP `192.168.1.186`은 NFS 마운트에만 사용, Docker 설정에는 불필요

View File

@@ -1,460 +0,0 @@
# GPU 서버 재구성 + PKM 프로젝트 연계 계획
## Context
GPU 서버(RTX 4070Ti Super)에서 Mac Mini와 중복되는 LLM 모델(qwen3.5:9b, id-9b)을 제거하고,
대신 Surya OCR + bge-m3 임베딩 서비스를 배치하여 역할을 명확히 분리한다.
추가로 Komga(만화 서버)를 Mac Mini에서 GPU 서버로 이전하여 Surya OCR과 로컬 연동 가능하게 한다.
기존 PKM 프로젝트(`~/Documents/code/DEVONThink_my server/`)와 연계하여:
- ChromaDB → Qdrant 마이그레이션
- nomic-embed-text → bge-m3 통일
- Qwen2.5-VL-7B 비전 OCR → Surya OCR 전용 대체
- architecture.md, embed 스크립트, AppleScript 등 관련 코드/문서 일괄 갱신
PKM 프로젝트는 현재 Phase 1 완료(90%), Phase 2(인프라 수정+버그 픽스) 착수 대기 상태.
이번 GPU 서버 재구성은 Phase 2와 병행하여 인프라 변경을 반영한다.
---
## 현재 상태 요약
### GPU 서버 (192.168.1.186)
- Ryzen 7 7800X3D / 30GB RAM / RTX 4070Ti Super 16GB VRAM
- **서비스**: Ollama(11434) + no-think proxy(11435), Plex(32400)
- **Ollama 모델**: qwen3.5:9b-q8_0(10GB), id-9b(10GB) ← **제거 확정**
- CUDA 드라이버 580.x 설치됨, nvcc(CUDA toolkit) 미설치
- Docker 미설치, Python 3.12
- NAS SMB/NFS 마운트 미설정
### Mac Mini (192.168.1.122)
- M4 Pro / 64GB / MLX Qwen3.5-35B(8800), Ollama bge-m3(11434)
- **Docker**: Qdrant(6333), Komga(25600), NanoClaw(9801), tk-ai-service(30400) 등
- **PKM 프로젝트**: `~/Documents/code/DEVONThink_my server/`
- `embed_to_chroma.py` → GPU 서버 nomic-embed-text + ChromaDB ← **Qdrant + bge-m3로 변경**
- `auto_classify.scpt` → MLX localhost:8800으로 분류, Step 4에서 embed_to_chroma.py 호출
- `pkm_api_server.py` → Flask 9900번 포트 (stats 500 에러, 한글 인코딩 버그 있음)
- `architecture.md` → GPU Tier 3에 nomic-embed + VL-7B + reranker 계획 ← **갱신 필요**
- **DEVONthink 4**: 13개 DB, Smart Rule 3개 설계 완료
### 영향받는 외부 서비스
- `tk-ai-service` docker-compose.yml: `OLLAMA_TEXT_MODEL=qwen3.5:9b-q8_0`**변경 필요**
- `paperless-gpt`: `qwen3:8b` 참조 → docker-compose 존재하나 **현재 미실행** (docker ps에 없음), Phase 1에서 처리 방침 결정 필요
### NAS IP 참고
- NAS LAN IP: `192.168.1.227` (nginx-ssl.conf upstream에서 확인됨)
- NAS Tailscale IP: `100.101.79.37`
- NFS 마운트는 **LAN 직결 (192.168.1.227)** 사용 (성능상 최적)
---
## 변경 계획
### Phase 1: GPU 서버 정리 (선행)
**1-1. Ollama 모델 제거**
```bash
ssh 192.168.1.186
ollama rm qwen3.5:9b-q8_0
ollama rm id-9b
```
**1-2. 새 모델 설치**
```bash
ollama pull bge-m3 # 임베딩 (1024차원, 한국어 우수)
ollama pull bge-reranker-v2-m3 # RAG 리랭킹
```
- 임베딩 모델: `nomic-embed-text`(768차원) 대신 `bge-m3`(1024차원)으로 통일
- 이유: Mac Mini Ollama에서 이미 bge-m3 사용 중, 한국어 성능 우수, Qdrant tk_qc_issues 컬렉션도 1024차원
**1-3. Ollama no-think proxy(11435) 비활성화**
- LLM 모델 제거 후 think:false 주입이 불필요
- `sudo systemctl disable --now ollama-proxy`
**1-4. Ollama systemd 환경 조정**
```ini
# /etc/systemd/system/ollama.service [Service] 섹션에 추가
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_KEEP_ALIVE=30m"
```
**1-5. paperless-gpt 처리**
- 현재 미실행 상태 (docker ps에 없음)
- docker-compose.yml에 `qwen3:8b` 참조 → GPU 서버 모델 제거 시 사용 불가
- 옵션: (a) MLX 35B로 전환 (b) 당분간 비활성 유지 (c) 폐기
- Paperless-ngx 자체가 활발히 사용 중인지 확인 후 결정
**1-6. tk-ai-service 코드 + 설정 변경** (Mac Mini 유지, 코드 수정 필수)
tk-ai-service는 Ollama 네이티브 API(`/api/chat`, `/api/embeddings`)를 사용 중.
MLX 서버는 OpenAI API(`/v1/chat/completions`)만 지원하므로 코드 수정 필요.
**a) `ollama_client.py` → `llm_client.py` 리팩터링**
- `generate_text()`: `/api/chat``/v1/chat/completions` (OpenAI 형식)
- 요청: `{"model":"...","messages":[...],"stream":false}`
- 응답: `response.json()["choices"][0]["message"]["content"]`
- `generate_embedding()`: 변경 없음 (GPU Ollama `/api/embeddings` 그대로)
- `check_health()`: text URL `/api/tags``/v1/models` 또는 단순 GET 체크
- 클래스명/파일명 변경 고려 (OllamaClient → LLMClient)
**b) docker-compose.yml 환경변수 변경**
```yaml
# ~/docker/tk-ai-service/docker-compose.yml
- OLLAMA_BASE_URL=http://host.internal:8800 # Mac Mini MLX 서버 (OpenAI API)
- OLLAMA_TEXT_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit
- OLLAMA_EMBED_URL=http://192.168.1.186:11434 # GPU 서버 Ollama (임베딩)
- OLLAMA_EMBED_MODEL=bge-m3 # 변경 없음
```
**c) config.py 주석 갱신**
```python
# Mac Mini MLX (텍스트 생성) — OpenAI 호환 API
OLLAMA_BASE_URL: str = "http://host.internal:8800"
# GPU 서버 Ollama (임베딩)
OLLAMA_EMBED_URL: str = "http://192.168.1.186:11434"
```
### Phase 1.5: GPU 서버 Docker + NFS + Komga 이전
**1.5-1. Docker 설치** (GPU 서버)
```bash
# Docker Engine (Ubuntu)
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker hyungi
# nvidia-container-toolkit (Surya OCR Docker化 시 필요, 당장은 불필요)
```
**1.5-2. NAS NFS 마운트 설정**
Synology NAS 측:
- DSM → 제어판 → 파일 서비스 → NFS 활성화 (v4.1)
- 공유 폴더(Comic) → NFS 권한: `192.168.1.186` 단일 IP, 읽기 전용, root_squash
GPU 서버 측:
```bash
sudo apt install nfs-common
sudo mkdir -p /mnt/comic
# /etc/fstab 추가:
192.168.1.227:/volume1/Comic /mnt/comic nfs4 ro,nosuid,noexec,nodev,soft,timeo=15 0 0
sudo mount -a
```
**1.5-3. Komga Docker 이전**
GPU 서버에 docker-compose.yml 생성:
```yaml
# /opt/komga/docker-compose.yml
services:
komga:
image: gotson/komga
container_name: komga
ports:
- "25600:25600"
volumes:
- /mnt/comic:/data/comics:ro # NFS 마운트 (읽기 전용)
- ./config:/config # Komga 설정/DB
environment:
- TZ=Asia/Seoul
restart: unless-stopped
```
**1.5-4. Mac Mini 측 변경**
- Mac Mini Komga 컨테이너 중지: `docker stop komga && docker rm komga`
- nginx upstream 변경:
```
# nginx-ssl.conf
upstream komga_backend {
server 192.168.1.186:25600; # GPU 서버로 변경
}
```
- nginx 재시작: `docker restart home-service-proxy`
- Mac Mini Docker VM 메모리 **1.23GB 회수**
**1.5-5. Komga 설정 마이그레이션**
- Mac Mini의 Komga config/DB를 GPU 서버로 복사 (라이브러리 메타데이터, 사용자 설정 유지)
- 경로: Mac Mini `~/docker/Komga/` → GPU `scp`로 전송
- **주의**: Komga 내부 DB(H2)에 라이브러리 절대경로가 저장되어 있음
- Mac Mini: `/data/comics` (Docker 내부 마운트 경로)
- GPU 서버: `/data/comics` (동일하게 Docker 마운트하면 경로 변경 불필요)
- Docker 내부 경로를 동일하게 맞추면 DB 마이그레이션 문제 없음
- 만약 경로가 달라지면 Komga UI에서 라이브러리 경로 재설정 또는 전체 재스캔 필요
### Phase 2: Surya OCR 설치
**2-1. PyTorch CUDA 런타임 확인** (GPU 서버)
- Surya OCR은 PyTorch에 의존 → PyTorch 설치 시 CUDA 런타임이 번들됨
- 별도 `nvidia-cuda-toolkit` 설치가 **불필요할 수 있음** (nvcc는 직접 CUDA 코드 컴파일 시만 필요)
- GPU 서버에서 확인:
```bash
# PyTorch CUDA 지원 확인
python3 -c "import torch; print(torch.cuda.is_available())"
# 안 되면 CUDA 번들 포함 PyTorch 설치
pip install torch --index-url https://download.pytorch.org/whl/cu124
```
**2-2. Surya OCR 서비스 구성**
```
/opt/surya-ocr/
venv/ # Python venv (surya-ocr, fastapi, uvicorn, python-multipart)
server.py # FastAPI 래퍼
```
서버 엔드포인트:
- `POST /ocr` — 파일 업로드 → OCR 텍스트 + 바운딩박스 반환
- `POST /ocr/layout` — 레이아웃 분석 포함
- `GET /health` — 상태 확인
**2-3. systemd 서비스 등록**
```ini
# /etc/systemd/system/surya-ocr.service
[Unit]
Description=Surya OCR Service
After=network-online.target
[Service]
ExecStart=/opt/surya-ocr/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8400
WorkingDirectory=/opt/surya-ocr
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**VRAM 예산 (변경 후)**
| 컴포넌트 | VRAM | 비고 |
|----------|------|------|
| Plex HW 트랜스코드 | ~1-2GB | 활성 시 |
| Surya OCR | ~2-3GB | 활성 시 |
| bge-m3 임베딩 | ~1.5GB | 상시 |
| bge-reranker | ~1GB | 온디맨드 |
| **합계 (피크)** | **~7-8GB / 16GB** | 여유 충분 |
### Phase 3: 벡터 DB 통합 (ChromaDB → Qdrant) + PKM 코드 갱신
**결정: Qdrant로 통일, ChromaDB 폐기**
- Qdrant가 이미 Mac Mini Docker에서 운영 중 (tk_qc_issues 컬렉션)
- ChromaDB는 embed_to_chroma.py에서만 계획/사용
- 2개 벡터 DB 운영은 불필요한 복잡성
**3-1. Qdrant에 pkm_documents 컬렉션 생성**
```
dimension: 1024 (bge-m3)
distance: cosine
payload 필드: uuid, title, database, tags, source_channel
```
**3-2. `scripts/embed_to_chroma.py` → `scripts/embed_to_qdrant.py` 리라이트**
- `chromadb` → `qdrant-client` 교체
- GPU 서버 임베딩 모델: `nomic-embed-text` → `bge-m3`
- Ollama API: `/api/embed` 사용 (배치 지원, `{"model":"bge-m3","input":["텍스트"]}`)
- 현재 auto_classify.scpt에서 문서 단건 호출이므로 단일/배치 모두 가능
- 향후 embed-batch 엔드포인트에서 배치 활용 가능
- 텍스트 청킹 추가 (500토큰, 50토큰 오버랩)
- `pkm_utils.load_credentials()`에서 GPU_SERVER_IP 로드
- **기존 `embed_to_chroma.py`는 `git rm`으로 삭제** (GPU_SERVER_IP 미설정으로 실행된 적 없음, 실 데이터 없음)
**3-3. `applescript/auto_classify.scpt` Step 4 수정 + 버그 픽스**
- 현재: `do shell script "python3 ~/scripts/embed_to_chroma.py " & docUUID & " &"`
- 변경: `embed_to_qdrant.py` 호출로 교체 + baseDir 변수 사용
- **버그 픽스**: 73행 `add custom meta data "inbox_route" for "sourceChannel"` 삭제
- 70행에서 AI 분류 결과로 설정한 sourceChannel을 "inbox_route"로 덮어쓰는 버그
**3-4. requirements.txt 업데이트** (dev-roadmap 9단계와 합산, 단일 커밋)
```
- chromadb>=0.4.0
- schedule>=1.2.0 # 미사용 확인 후 제거
+ qdrant-client>=1.7.0
+ flask>=3.0.0 # dev-roadmap 9단계
+ gunicorn>=21.2.0 # dev-roadmap 9단계
anthropic>=0.40.0 # 유지 (향후 Tier 2용)
```
**3-5. credentials.env + credentials.env.example 업데이트**
```
# credentials.env (Mac mini)
GPU_SERVER_IP=192.168.1.186
# credentials.env.example (git 추적)
GPU_SERVER_IP=192.168.1.xxx
```
**3-6. `docs/architecture.md` 대규모 갱신** (별도 커밋)
- **변경 규모**: ChromaDB 28건, nomic-embed 12건, VL-7B 5건 — 문맥별 수정 필요 (단순 치환 불가)
- 주요 변경 영역:
- AI 통합 아키텍처 다이어그램 (Tier 3 모델 목록: nomic→bge-m3, VL-7B→Surya OCR)
- VRAM 배분 다이어그램 (~11.3GB → ~7-8GB)
- 자동화 파이프라인 / Smart Rule 설계 (ChromaDB→Qdrant, embed 스크립트 경로)
- AI 결과물 저장 전략 표 (ChromaDB→Qdrant)
- 임베딩 이전 근거 테이블 (nomic→bge-m3 반영)
- 3-Tier AI 라우팅 전략 표
- 코드 예시 내 경로/모델명
- 이 작업은 **별도 시간을 잡아서** 전체 문서를 통독하며 진행
### Phase 4: DEVONthink OCR 연동
**4-1. `scripts/ocr_preprocess.py` 신규 작성**
- DEVONthink UUID → AppleScript로 파일 경로 추출 → Surya API(GPU:8400) 호출 → OCR 텍스트 반환
- `pkm_utils.run_applescript_inline()` 재사용
- 반환값: OCR 텍스트 (plain text) — DEVONthink 본문에 병합용
**4-2. `applescript/auto_classify.scpt` Smart Rule 수정**
- architecture.md의 Rule 1 설계에 따라 Step 0(OCR) 추가:
```
현재: Step 1(MLX 분류) → Step 2(태그 파싱) → Step 3(DB 이동) → Step 4(임베딩) → Step 5(메타)
변경: Step 0(OCR 감지+처리) → Step 1(MLX 분류) → ... → Step 4(Qdrant 임베딩)
```
- OCR 대상 감지 조건 (단순 텍스트 길이 < 50 대신 정교한 판별):
- `type of theRecord` = PDF **AND** `plain text of theRecord` = "" (텍스트 레이어 없음)
- 또는 `type of theRecord` ∈ {JPEG, PNG, TIFF} (이미지 파일)
- DEVONthink 자체가 PDF 텍스트 레이어를 읽으므로, OCR이 필요한 건 **텍스트가 완전히 비어있는 경우**뿐
- Surya OCR 호출 → `set plain text of theRecord to ocrText` 로 본문 병합
- 기존 Qwen2.5-VL-7B 비전 OCR 계획 → Surya 전용 OCR로 대체 (정확도 + ABBYY 대체)
**4-3. `docs/architecture.md` Rule 1 갱신**
- "이미지/스캔 문서 → GPU 서버 VL-7B로 OCR" → "Surya OCR(:8400)으로 OCR"
### Phase 5: RAG 파이프라인 (PKM API 확장)
**5-1. pkm_api_server.py에 RAG 엔드포인트 추가**
- 현재 docstring(7행): "범위: DEVONthink + OmniFocus 전용. 이 이상 확장하지 않을 것."
- RAG는 DEVONthink 문서 검색 기반이므로 동일 범위의 확장으로 간주
- docstring을 "범위: DEVONthink + OmniFocus + RAG 검색" 으로 갱신
```
POST /rag/query # 질문 → 임베딩 → Qdrant 검색 → 리랭킹 → LLM 답변
POST /devonthink/embed # 단일 문서 임베딩 트리거
POST /devonthink/embed-batch # 배치 임베딩
```
**5-2. RAG 쿼리 플로우**
```
질문 텍스트
→ GPU 서버 bge-m3로 쿼리 임베딩 (192.168.1.186:11434)
→ Mac Mini Qdrant에서 유사도 검색 (localhost:6333, top-20)
→ GPU 서버 bge-reranker로 리랭킹 (top-5)
→ Mac Mini MLX Qwen3.5-35B로 답변 생성 (localhost:8800)
→ DEVONthink 링크(x-devonthink-item://UUID) 포함 응답
```
### Phase 6: NanoClaw + Komga 연동 (후순위, 별도 계획)
Phase 5 완료 후 별도 문서로 상세 계획 수립. 현재는 방향만 기록:
- **NanoClaw RAG**: PKM API `/rag/query` 엔드포인트 호출 → 시놀로지 Chat에서 "@이드 [질문]" → 문서 기반 답변
- **Komga OCR**: Komga REST API → Surya OCR → Qdrant `komga_manga` 컬렉션
- dev-roadmap.md에는 "향후 계획" 수준으로만 언급
---
## 변경 후 아키텍처
```
┌─ Mac Mini M4 Pro ─────────────────────┐ ┌─ GPU 서버 (4070Ti) ─────────┐
│ │ │ │
│ MLX Qwen3.5-35B (:8800) — LLM 추론 │ │ Ollama (:11434) │
│ MLX Proxy (:8801) — Synology 연동 │◄───►│ ├─ bge-m3 (임베딩) │
│ Ollama (:11434) — bge-m3 로컬 폴백 │ │ └─ bge-reranker (리랭킹) │
│ Qdrant (:6333) — 벡터 검색 │ │ │
│ PKM API (:9900) — RAG 오케스트레이션 │ │ Surya OCR (:8400) │
│ NanoClaw (:9801) — AI 어시스턴트 │ │ Plex (:32400) — HW 트랜스코드│
│ DEVONthink — 문서 허브 │ │ Komga (:25600) — 만화 서버 │
│ nginx proxy (:443) — 리버스 프록시 │ │ └─ NFS → NAS /Comic (ro) │
│ │ │ │
└────────────────────────────────────────┘ │ [제거됨] │
▲ │ ├─ qwen3.5:9b-q8_0 │
│ ┌─ NAS ──────┐ │ ├─ id-9b │
└──────────────│ 문서/미디어 │────────│ └─ no-think proxy (:11435)│
└────────────┘ └──────────────────────────────┘
```
## 수정 대상 파일 목록
### GPU 서버
| 파일 | 변경 |
|------|------|
| Ollama 모델 | `rm qwen3.5:9b-q8_0`, `rm id-9b` / `pull bge-m3`, `pull bge-reranker-v2-m3` |
| `/etc/systemd/system/ollama.service` | 환경변수 추가 (MAX_LOADED_MODELS, KEEP_ALIVE) |
| `/etc/systemd/system/ollama-proxy.service` | **비활성화** (disable --now) |
| `/opt/surya-ocr/server.py` | **신규** — FastAPI OCR 서버 |
| `/etc/systemd/system/surya-ocr.service` | **신규** — systemd 유닛 |
| Docker Engine | **신규 설치** |
| `/etc/fstab` | NFS 마운트 추가 (NAS Comic → /mnt/comic, ro) |
| `/opt/komga/docker-compose.yml` | **신규** — Komga 컨테이너 |
### PKM 프로젝트 (`~/Documents/code/DEVONThink_my server/`)
| 파일 | 변경 |
|------|------|
| `scripts/embed_to_chroma.py` | → `scripts/embed_to_qdrant.py` 리라이트 (chromadb→qdrant-client, nomic→bge-m3) |
| `scripts/ocr_preprocess.py` | **신규** — Surya OCR 호출 헬퍼 |
| `scripts/pkm_api_server.py` | RAG 엔드포인트 추가 (/rag/query, /devonthink/embed) |
| `scripts/pkm_utils.py` | 변경 없음 (`load_credentials()`에 이미 GPU_SERVER_IP 지원, 74행) |
| `applescript/auto_classify.scpt` | Step 0(OCR 감지) 추가 + Step 4 embed_to_qdrant.py로 변경 |
| `requirements.txt` | `chromadb` → `qdrant-client`, `flask` 추가 (dev-roadmap 9단계 합산) |
| `docs/architecture.md` | Tier 3 모델, VRAM 다이어그램, ChromaDB→Qdrant, Smart Rule 전체 갱신 |
| `docs/dev-roadmap.md` | GPU 서버 재구성 Phase 반영 |
| `docs/claude-code-commands.md` | GPU 서버 관련 단계 추가 |
| `credentials.env` (Mac mini) | `GPU_SERVER_IP=192.168.1.186` 추가 |
| `credentials.env.example` | GPU_SERVER_IP 항목 추가 |
### Mac Mini 기타
| 파일 | 변경 |
|------|------|
| `~/docker/tk-ai-service/docker-compose.yml` | BASE_URL → MLX :8800, EMBED_URL → GPU :11434 |
| `~/docker/tk-factory-services/ai-service/services/ollama_client.py` | Ollama API → OpenAI API 전환 (generate_text, check_health) |
| `~/docker/tk-factory-services/ai-service/config.py` | 주석 갱신 |
| `~/docker/home-service-proxy/nginx-ssl.conf` | `komga_backend` upstream → `192.168.1.186:25600` |
| Mac Mini Komga 컨테이너 | **중지 및 제거** (1.23GB Docker VM 메모리 회수) |
## 검증 방법
1. **Phase 1.5 검증**:
- `ssh GPU "docker ps"` → komga 컨테이너 실행 중
- `ssh GPU "ls /mnt/comic"` → NAS 만화 파일 목록 확인
- `curl http://192.168.1.186:25600` → Komga 웹 UI 접근
- `curl https://komga.hyungi.net` → nginx 프록시 경유 접근 확인
- Mac Mini: `docker ps | grep komga` → 없음 (제거 완료)
2. **Phase 1 검증**:
- `ssh GPU "ollama list"` → bge-m3, bge-reranker만 존재
- `ssh GPU "systemctl status ollama-proxy"` → inactive
- `ssh GPU "curl localhost:11434/api/embed -d '{\"model\":\"bge-m3\",\"input\":[\"test\"]}'` → 1024차원 벡터 반환
- Qdrant 차원 확인: `curl localhost:6333/collections/tk_qc_issues` → vector size=1024 확인 (bge-m3와 일치)
- tk-ai-service 코드 수정 후: `docker compose build && docker compose up -d`
- `curl http://localhost:30400/health` → ollama_text(MLX), ollama_embed(GPU) 모두 connected
- `curl -X POST http://localhost:30400/api/chat -d '{"message":"테스트"}'` → MLX 35B 응답 확인
2. **Phase 2 검증**: `curl -F "file=@test.pdf" http://192.168.1.186:8400/ocr` → OCR 텍스트 반환
3. **Phase 3 검증**:
- `curl http://localhost:6333/collections/pkm_documents` → 컬렉션 존재
- `python3 scripts/embed_to_qdrant.py <테스트UUID>` → Qdrant에 벡터 저장 확인
- `git diff` → embed_to_chroma.py 삭제, embed_to_qdrant.py 생성 확인
4. **Phase 4 검증**: DEVONthink Inbox에 스캔 PDF(텍스트 없는) 추가 → Smart Rule → OCR 텍스트 병합 → 분류 완료
5. **Phase 5 검증**: `curl -X POST localhost:9900/rag/query -d '{"q":"산업안전 법령"}'` → 관련 문서 + 답변 반환
6. **Phase 6**: 후순위, 별도 계획 시 검증 방법 수립
## 실행 순서
```
Phase 1 (GPU 정리 + 모델 교체) ──┐
Phase 1.5(Docker + NFS + Komga) ──┼── GPU 서버 작업 (SSH)
Phase 2 (Surya OCR 설치) ────────┘
Phase 3 (Qdrant 통합 + PKM 코드) ──┐── PKM 프로젝트 코드 수정
Phase 4 (DEVONthink OCR 연동) ─────┘ → git commit & push
→ Mac mini에서 git pull
Phase 5 (RAG 파이프라인) ──────── PKM API 확장
Phase 6 (NanoClaw/Komga OCR) ─── 후순위
```
Phase 1~2는 GPU 서버 SSH 작업, Phase 3~4는 PKM 프로젝트 코드 수정.
Phase 1 완료 시점에 tk-ai-service docker-compose도 함께 변경.
각 Phase는 독립적으로 검증 가능.
**PKM dev-roadmap과의 관계:**
- 이 계획의 Phase 3~4 = dev-roadmap Phase 2의 일부 (인프라 수정)
- requirements.txt, AppleScript 경로, credentials.env 변경이 겹침 → 합쳐서 진행
**문서 통합 전략:**
- `docs/dev-roadmap.md`: GPU 서버 재구성 Phase를 기존 Phase 2 앞에 "Phase 1.5: GPU 서버 재구성" 으로 삽입, Phase 2 항목에서 겹치는 변경(requirements.txt, credentials.env) 연결
- `docs/claude-code-commands.md`: Phase 2 섹션에 GPU 서버 관련 실행 단계 추가 (SSH 명령, Surya 설치, Qdrant 컬렉션 생성 등)
- 이 계획서는 `docs/gpu-restructure.md`로 정식 문서화 (아키텍처 결정 근거 기록으로 보존)

View File

@@ -1,684 +0,0 @@
# 04_Industrial Safety — DEVONthink DB 상세 설계서
> 메인 아키텍처: [mac-mini-pkm-architecture.md](computer:///sessions/amazing-vigilant-hypatia/mnt/outputs/mac-mini-pkm-architecture.md) 참조
---
## 1. DB 그룹 구조
```
04_Industrial Safety/
├── 00_Inbox ← 2차 분류 대기
├── 10_Legislation ← 법령, 고시, 행정규칙
│ ├── Act ← 산업안전보건법 등 법률 원문
│ ├── Decree ← 시행령, 시행규칙
│ ├── Notice ← 고시, 지침, 예규, 가이드라인
│ ├── SAPA ← 중대재해처벌법 (별도 법 체계)
│ ├── KR_Archive ← 개정 이력 자동 수집 (법령 API)
│ └── Foreign ← 해외 법령 (참고용)
│ ├── US ← OSHA Standards, CFR Title 29
│ ├── JP ← 労働安全衛生法
│ └── EU ← EU-OSHA Directives, REACH
├── 20_Theory ← 이론서, 교과서, 학습 자료
├── 30_Papers ← 학술 논문, 연구 보고서
├── 40_Cases ← 사고 사례, 재해 분석
│ ├── Domestic ← 국내 사례
│ └── International ← 해외 사례
├── 50_Practice ← 실무 문서 (현장 업무)
│ ├── Risk_Assessment ← 위험성평가
│ ├── Patrol_Inspection ← 순회점검
│ ├── Safety_Plan ← 안전관리계획서
│ ├── Education ← 안전교육 자료
│ ├── Checklist ← 점검표, 체크리스트
│ ├── Contractor_Management ← 도급/수급업체 안전관리
│ ├── Permit_to_Work ← 작업허가서 (화기, 밀폐, 고소 등)
│ ├── Emergency_Plan ← 비상조치계획, 대피/소방훈련
│ └── PPE ← 보호구 관리, 선정 기준, 지급 대장
├── 60_Compliance ← 신고, 보고, 감독 (실제 행정 문서)
│ ├── Report ← 산재 신고, 중대재해 보고
│ ├── Audit ← 감독 결과, 시정명령
│ └── Certification ← 자격증, 인증 관련
├── 70_Safety_Manager ← 안전관리자 직무 전용
│ ├── Appointment ← 선임 서류, 자격 관련
│ ├── Duty_Record ← 직무수행 기록, 월간/분기 보고
│ ├── Meeting ← 산업안전보건위원회, 회의록
│ ├── Inspection ← 안전관리자 점검 기록
│ └── Improvement ← 개선 요청, 시정 조치 이력
├── 75_Health_Manager ← 보건관리자 직무 전용
│ ├── Appointment ← 선임 서류, 자격 관련
│ ├── Duty_Record ← 직무수행 기록, 월간/분기 보고
│ ├── Work_Environment ← 작업환경측정, 유해인자 관리
│ ├── Health_Checkup ← 건강검진 관리, 사후관리
│ ├── MSDS ← 물질안전보건자료 관리
│ ├── Ergonomics ← 근골격계 유해요인조사, 직업병 예방
│ └── Mental_Health ← 직무스트레스 평가, 감정노동, 심리상담
├── 80_Reference ← 규격, 기준, 매뉴얼
│ ├── Standards ← KS, ISO, KOSHA Guide
│ └── Manual ← 장비 매뉴얼, 작업 지침서
└── 90_Archive ← 폐기 법령, 구버전 자료
```
---
## 2. AI 2차 분류 라우팅 (태그 → 그룹 매핑)
Inbox에서 1차 분류로 이 DB에 도착한 문서를, AI가 태그와 본문 키워드를 보고 하위 그룹까지 자동 이동시킵니다.
```
태그 조합 → 이동 대상 그룹
──────────────────────────────────────────────────────────
$유형/법령 → 10_Legislation/
$유형/법령 + #주제/산업안전/법령 → 10_Legislation/
├── 텍스트에 "법률" "법" 포함 → Act/
├── 텍스트에 "시행령" "시행규칙" 포함 → Decree/
├── 텍스트에 "고시" "지침" "예규" 포함 → Notice/
└── 텍스트에 "중대재해처벌" 포함 → SAPA/
$유형/논문 → 30_Papers/
#주제/산업안전/사고사례 → 40_Cases/
├── sourceURL에 kosha.or.kr 포함 → Domestic/
└── sourceURL에 osha.gov 등 포함 → International/
#주제/산업안전/위험성평가 → 50_Practice/Risk_Assessment/
#주제/산업안전/순회점검 → 50_Practice/Patrol_Inspection/
#주제/산업안전/안전교육 → 50_Practice/Education/
$유형/체크리스트 → 50_Practice/Checklist/
키워드: "도급" "수급" "협력업체" → 50_Practice/Contractor_Management/
키워드: "작업허가" "화기" "밀폐" → 50_Practice/Permit_to_Work/
키워드: "비상" "대피" "소방" → 50_Practice/Emergency_Plan/
키워드: "보호구" "안전화" "안전모" → 50_Practice/PPE/
#주제/산업안전/신고보고 → 60_Compliance/Report/
키워드: "감독" "시정명령" → 60_Compliance/Audit/
키워드: "자격증" "인증" "면허" → 60_Compliance/Certification/
#주제/산업안전/안전관리자 → 70_Safety_Manager/
├── "선임" "자격" → Appointment/
├── "직무수행" "월간보고" → Duty_Record/
├── "위원회" "회의록" → Meeting/
├── "점검" "순회" → Inspection/
└── "개선" "시정" → Improvement/
#주제/산업안전/보건관리자 → 75_Health_Manager/
├── "선임" "자격" → Appointment/
├── "작업환경측정" "유해인자" → Work_Environment/
├── "건강검진" "사후관리" → Health_Checkup/
├── "MSDS" "물질안전" → MSDS/
├── "근골격계" "직업병" → Ergonomics/
└── "스트레스" "감정노동" → Mental_Health/
#주제/산업안전/규격기준 → 80_Reference/Standards/
분류 불가 → 00_Inbox/ (수동 리뷰 대기)
```
---
## 3. 법령 자동 수집 및 변경 알림 시스템
### 3.1 모니터링 대상 법령
```
🇰🇷 한국 (필수) — 국가법령정보센터 Open API (open.law.go.kr)
─────────────────────────────────────────
· 산업안전보건법 (법률/시행령/시행규칙)
· 중대재해 처벌 등에 관한 법률 (법률/시행령)
· 건설기술 진흥법
· 화학물질관리법 / 화학물질의 등록 및 평가 등에 관한 법률
· 위험물안전관리법
· KOSHA Guide (한국산업안전보건공단 기술지침)
· 고용노동부 고시/지침 (관련 행정규칙)
🇺🇸 미국 (참고) — Federal Register API + OSHA
─────────────────────────────────────────
· OSHA Standards (29 CFR 1910 일반산업, 1926 건설)
· Federal Register: OSHA 관련 규칙 제정/개정 공지
· NIOSH 권고사항 (새 출판물)
🇯🇵 일본 (참고) — e-Gov 法令API (laws.e-gov.go.jp)
─────────────────────────────────────────
· 労働安全衛生法 (노동안전위생법)
· 労働安全衛生法施行令
· 労働安全衛生規則
🇪🇺 EU (참고) — EUR-Lex SPARQL / REST
─────────────────────────────────────────
· Framework Directive 89/391/EEC (산업안전 기본지침)
· REACH Regulation (화학물질 규정)
· CLP Regulation (분류/표시 규정)
· Machinery Directive 2006/42/EC
```
### 3.2 시스템 아키텍처
```
┌──────────────────────────────────────────────────────────────┐
│ 법령 모니터링 시스템 (Mac mini, launchd) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ law_monitor.py │ │ 수집 스케줄 │ │
│ │ · KR: law.go.kr │ │ · 한국: 매일 06:00 │ │
│ │ · US: FedReg │ │ · 미국: 주 1회 (월) │ │
│ │ · JP: e-Gov │ │ · 일본: 주 1회 (수) │ │
│ │ · EU: EUR-Lex │ │ · EU: 월 1회 (1일) │ │
│ └───────┬─────────┘ └──────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 변경 감지: API → SQLite 비교 → diff 생성 │ │
│ └───────┬──────────────────────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 알림 + 저장 │ │
│ │ · DEVONthink: 10_Legislation/ 자동 분류 │ │
│ │ · Synology Chat 웹훅 즉시 알림 │ │
│ │ · OmniFocus 작업 생성 ("법령 변경 검토 필요") │ │
│ │ · Ollama 35B: 변경 요약 + 실무 영향 브리핑 │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### 3.3 법적 근거 — 수집에 문제 없음
```
한국: 저작권법 제7조 — 법령, 조약, 판결 등은 저작권 보호 대상 아님
+ 국가법령정보센터 Open API 공공데이터 자유이용 허용
미국: 연방법/규정은 Public Domain (17 U.S.C. §105)
일본: 著作権法 第13条 — 법령은 저작권 대상 제외
EU: EUR-Lex 자유 재사용 정책 (Decision 2011/833/EU)
```
### 3.4 저장 구조 예시
```
04_Industrial Safety/10_Legislation/
├── Act/
│ └── 산업안전보건법_2026-03-01_시행.pdf
├── Decree/
│ └── 산업안전보건법_시행령_2026-01-01.pdf
├── SAPA/
│ └── 중대재해처벌법_2026-01-01_시행.pdf
├── KR_Archive/
│ ├── 2026-03-24_산업안전보건법_개정_diff.md ← 변경점 요약
│ └── 2026-03-24_산업안전보건법_개정_원문.pdf
└── Foreign/
├── US/
│ └── 29CFR1910_General_Industry.pdf
├── JP/
│ └── 労働安全衛生法_2026.pdf
└── EU/
└── Directive_89_391_EEC_Framework.pdf
```
---
## 4. DEVONagent 검색 세트 — 안전 분야
### 4.1 전체 구성
```
Mac mini 자동 스케줄
├── [SS-01] 🇰🇷 한국 산업안전 뉴스 매일 06:00 15~25건/주
├── [SS-02] 🇰🇷 중대재해·판례 매일 06:15 5~10건/주
├── [SS-04] 🇺🇸 미국 안전 동향 주 1회 (월) 10~15건/주
├── [SS-05] 🇯🇵 일본 안전 동향 주 1회 (수) 5~10건/주
├── [SS-06] 🇪🇺 유럽 안전 동향 월 2회 2~4건/주
├── [SS-07] 🌐 국제 안전 전문지 주 1회 (금) 5~10건/주
└── [SS-08] 📚 학술 논문 (안전공학) 주 1회 (토) 5~10건/주
안전 분야 주간 합계: ~50~85건 (하루 평균 ~8~12건)
```
### 4.2 [SS-01] 한국 산업안전 뉴스 (매일)
```
검색 세트: KR_Safety_News
스케줄: 매일 06:00 / 새 결과만 수집
소스:
· kosha.or.kr — 공단 공지, 가이드, 재해사례
· portal.kosha.or.kr — 산재예방 포털
· moel.go.kr — 고용노동부 보도자료, 정책
· labor.moel.go.kr — 중대재해 알림e
· safety.or.kr — 대한산업안전협회
· safetyin.co.kr — 안전저널
· Google News — "산업안전" OR "산재" OR "안전보건" -채용 -구인
→ Inbox → AI 태깅 → 04_Industrial Safety 하위 그룹 자동 분류
```
### 4.3 [SS-02] 중대재해·판례 (매일)
```
검색 세트: KR_SAPA_Cases
스케줄: 매일 06:15 / 새 결과만 수집
소스:
· labor.moel.go.kr — 중대재해 알림e 공시
· nosanjae.kr — 중대재해 기업 검색
· law.go.kr — 판례 검색 (산업안전 관련)
· Google News — "중대재해" OR "중대재해처벌" OR "산재 사망" -채용
→ Inbox → AI 태깅 → 40_Cases/Domestic/ 또는 60_Compliance/
```
### 4.4 [SS-04] 🇺🇸 미국 안전 동향 (주 1회)
```
검색 세트: US_Safety
스케줄: 월요일 07:00 / 최대 15건
소스:
· osha.gov/rss — OSHA 보도자료, 벌금 부과, 규칙 (주 3~5건)
· osha.gov/quicktakes — OSHA QuickTakes 뉴스레터 (격주 1건)
· federalregister.gov — OSHA final rule / proposed (주 1~3건)
· ehstoday.com — EHS Today 산업안전 전문지 (주 3~5건)
쿼리: ("OSHA" OR "workplace fatality" OR "safety violation") -job -hiring
→ Inbox → 10_Legislation/Foreign/US/ 또는 40_Cases/International/
```
### 4.5 [SS-05] 🇯🇵 일본 안전 동향 (주 1회)
```
검색 세트: JP_Safety
스케줄: 수요일 07:00 / 최대 10건
소스:
· mhlw.go.jp/rss — 厚生労働省 보도자료 (주 2~4건)
· anzeninfo.mhlw.go.jp — 職場のあんぜんサイト (재해사례) (주 2~3건)
· jaish.gr.jp — 安全衛生情報センター (통달/지침) (주 1~2건)
쿼리: ("労働安全" OR "労働災害" OR "安全衛生" OR "重大災害")
→ Inbox → 10_Legislation/Foreign/JP/
→ AI 자동 처리: Ollama로 일본어 → 한국어 1줄 요약 생성
```
### 4.6 [SS-06] 🇪🇺 유럽 안전 동향 (월 2회)
```
검색 세트: EU_Safety
스케줄: 1일·15일 07:00 / 최대 10건
소스:
· osha.europa.eu RSS — EU-OSHA 발간물, 뉴스, 지침 (월 3~5건)
· eur-lex.europa.eu — 산업안전 관련 신규 지침/규정 (월 1~3건)
· hse.gov.uk — UK Health & Safety Executive (월 2~3건)
쿼리: ("EU-OSHA" OR "workplace safety directive" OR "REACH" OR "safety at work")
-vacancy -recruitment
→ Inbox → 10_Legislation/Foreign/EU/
```
### 4.7 [SS-07] 🌐 국제 안전 전문지 (주 1회)
```
검색 세트: Global_Safety_Magazines
스케줄: 금요일 07:00 / 최대 10건
소스:
· ishn.com/rss — Industrial Safety & Hygiene News (주 3~5건)
· ohsonline.com — Occupational Health & Safety (주 2~3건)
· safetyandhealthmagazine.com — NSC Safety+Health Magazine (주 1~2건)
쿼리: ("industrial safety" OR "process safety" OR "workplace accident"
OR "safety management" OR "risk assessment")
→ Inbox → AI 태깅 후 주제별 자동 분류
```
### 4.8 [SS-08] 학술 논문 — 안전공학 (주 1회)
```
검색 세트: Safety_Academic
스케줄: 토요일 08:00 / 최대 10건
소스:
· Google Scholar — 한국어: "산업안전" "위험성평가" "안전공학"
· Google Scholar — 영어: "occupational safety" "risk assessment"
· oshri.kosha.or.kr — 산업안전보건연구원 발간물
· dbpia.co.kr — 한국 학술논문
· sciencedirect.com — Safety Science 저널
→ Inbox → 30_Papers/
```
---
## 5. 양 조절 전략
### 5.1 주간 예상 유입량
```
검색 세트 주간 예상 빈도
──────────────────────────────────────────
SS-01 한국 안전뉴스 15~25건 매일
SS-02 중대재해/판례 5~10건 매일
SS-04 🇺🇸 미국 10~15건 주 1회
SS-05 🇯🇵 일본 5~10건 주 1회
SS-06 🇪🇺 유럽 2~4건 월 2회
SS-07 🌐 전문지 5~10건 주 1회
SS-08 학술 논문 5~10건 주 1회
──────────────────────────────────────────
안전 분야 합계 ~50~85건 /주
하루 평균 ~8~12건
```
### 5.2 과다 유입 방지 장치
```
1단계: DEVONagent "새 결과만" — 이전 수집분 자동 제외
2단계: 검색 세트별 최대 수집량 캡 (Max Results)
3단계: AI 관련도 필터 — Ollama가 관련도 판단
→ 낮으면 @상태/아카이브 → 90_Archive 이동
→ 높으면 @상태/검토필요 → 해당 그룹에 유지
4단계: 주간 다이제스트 — 금요일 Claude API가 주간 요약
→ "이번 주 꼭 봐야 할 5건" 브리핑 자동 생성
5단계: 30일 이상 미열람 → Smart Rule로 자동 90_Archive 이동
```
### 5.3 일본어 자료 자동 처리
```
수집 → Smart Rule: 일본 태그 감지
→ Ollama 35B: 일본어 → 한국어 1줄 요약
→ DEVONthink 커스텀 메타데이터 "summaryKR" 필드에 저장
→ 원문은 그대로 보존
※ 일본 산업안전 용어는 한자어 공통으로 번역 정확도 높음
```
---
## 6. 기존 자료 마이그레이션
```
현재 → 이동 대상
───────────────────────────────────────────────
0_Theory/ (72건) → 20_Theory/
8_Reference/ (1건) → 80_Reference/
9_일반자료_산업안전/ (33건) → 내용별 분산:
사고사례 → 40_Cases/Domestic/
실무서식 → 50_Practice/
신고관련 → 60_Compliance/
지게차 관련규칙 개정... (PDF) → 10_Legislation/Notice/
Industrial Safety... (HTML) → 20_Theory/ 또는 80_Reference/
```
---
## 7. 관련 태그 체계 (산업안전 영역)
```
#주제/산업안전/
├── 법령 ← 10_Legislation
├── 위험성평가 ← 50_Practice/Risk_Assessment
├── 순회점검 ← 50_Practice/Patrol_Inspection
├── 안전교육 ← 50_Practice/Education
├── 사고사례 ← 40_Cases
├── 신고보고 ← 60_Compliance
├── 안전관리자 ← 70_Safety_Manager
├── 보건관리자 ← 75_Health_Manager
└── 규격기준 ← 80_Reference
```
---
## 8. 유입 경로 추적 체계 (Source Tracking)
모든 문서에 유입 경로를 기록하여 "이 자료가 어디서 왔는지"를 즉시 파악할 수 있게 합니다.
실제 업무 데이터와 외부 참고자료를 명확히 구분하는 것이 핵심입니다.
### 8.1 유입 경로 분류
```
커스텀 메타데이터: sourceChannel (텍스트, 필수)
┌──────────────────────────────────────────────────────────────────┐
│ 자동 유입 (시스템) │
├────────────────┬─────────────────────────────────────────────────┤
│ tksafety │ TKSafety API 연동 — 업무 실적 (위험성평가, 점검 등) │
│ devonagent │ DEVONagent 검색 세트 — 뉴스/업계 동향 자동 수집 │
│ law_monitor │ 법령 모니터링 API — 법령 제·개정 추적 │
├──────────────────────────────────────────────────────────────────┤
│ 수동/반자동 유입 │
├────────────────┬─────────────────────────────────────────────────┤
│ inbox_route │ Inbox DB → AI 분류 → 이 DB로 라우팅된 문서 │
│ email │ MailPlus → Archive DB → 안전 관련 메일 전달 │
│ web_clip │ DEVONthink Web Clipper로 직접 스크랩 │
│ manual │ 드래그&드롭, 스캔, 파일 직접 추가 │
└────────────────┴─────────────────────────────────────────────────┘
```
### 8.2 메타데이터 자동 설정 규칙
```
유입 경로별 자동 태그:
tksafety → @출처/TKSafety + sourceURL = tksafety.technicalkorea.net/...
devonagent → @출처/뉴스수집 + sourceURL = 원본 기사 URL
law_monitor → @출처/법령API + sourceURL = law.go.kr/... 또는 해외 법령 URL
inbox_route → @출처/자동분류 + (원본 sourceURL 유지)
email → @출처/이메일 + sourceURL = mailplus 메시지 링크
web_clip → @출처/웹스크랩 + sourceURL = 스크랩 원본 URL
manual → @출처/수동입력 + sourceURL = 없음 (직접 기입 가능)
```
### 8.3 업무 데이터 vs 참고자료 구분
```
커스텀 메타데이터: dataOrigin (드롭다운, 필수)
work — 우리 회사 실제 업무에서 발생한 데이터
(TKSafety 연동, 직접 작성한 보고서, 내부 회의록 등)
external — 외부에서 수집한 참고/학습 자료
(뉴스, 법령 원문, 타사 사례, 학술 논문 등)
자동 판별 규칙:
· sourceChannel = tksafety → dataOrigin = work (항상)
· sourceChannel = law_monitor → dataOrigin = external (항상)
· sourceChannel = devonagent → dataOrigin = external (항상)
· sourceChannel = manual → dataOrigin = work (기본값, 수동 변경 가능)
· sourceChannel = inbox_route → AI가 내용 기반으로 판별
· sourceChannel = email → AI가 발신자/내용 기반으로 판별
· sourceChannel = web_clip → dataOrigin = external (기본값)
```
### 8.4 Smart Rule 적용
```
DEVONthink Smart Rule: "소스 채널 누락 검출"
조건: custom metadata "sourceChannel" is empty
AND database is "04_Industrial Safety"
AND NOT in group "00_Inbox"
동작:
1. @상태/미분류출처 태그 추가
2. 00_Inbox으로 이동 (출처 확인 후 재분류)
→ 어떤 경로로든 출처 없이 들어온 문서는 자동 포착
→ 주간 리뷰에서 정리 (수동 입력 자료 대부분 여기 해당)
```
### 8.5 활용 시나리오
```
검색/필터 예시:
"올해 우리 회사가 실시한 위험성평가만 보기"
→ 50_Practice/Risk_Assessment/ + dataOrigin = work
"외부 위험성평가 사례/참고자료"
→ 50_Practice/Risk_Assessment/ + dataOrigin = external
"TKSafety에서 자동 수집된 문서 전체"
→ sourceChannel = tksafety
"직접 스크랩한 자료 중 미정리 건"
→ sourceChannel = web_clip + @상태/미분류출처
Smart Group으로 상시 모니터링:
· "출처 미기입 문서" → sourceChannel is empty
· "이번 주 업무 문서" → dataOrigin = work + 최근 7일
· "외부 수집 미읽음" → dataOrigin = external + unread
```
---
## 9. TKSafety 시스템 연동 (설정 대기)
> **현재 상태: 설계 완료, 구현 대기**
> API 엔드포인트 명세와 연동 구조만 확정해두고, 실제 활성화는 PKM 기본 체계가 안정된 후 진행합니다.
> TKSafety는 자체 개발 시스템이므로 필요 시점에 API를 추가하면 됩니다.
### 9.1 시스템 정보
```
· URL: tksafety.technicalkorea.net (Cloudflare Tunnel)
· 호스팅: Synology DS1525+ Docker
· 내부 접근: Tailscale VPN
· 개발/수정: 직접 가능
· sourceChannel 값: tksafety
· dataOrigin 값: work (항상)
```
### 9.2 연동 아키텍처 (예정)
```
┌──────────────────────┐ ┌────────────────────────────┐
│ TKSafety │ │ Mac mini (PKM 허브) │
│ (Synology Docker) │ │ │
│ │ API │ tksafety_sync.py │
│ /api/v1/ │◄──────►│ (launchd 스케줄) │
│ risk-assessments │ Tailscale│ │
│ patrol-inspections │ │ ┌─────────────────────┐ │
│ corrective-actions │ │ │ 데이터 가공 │ │
│ incidents │ │ │ · JSON → PDF/MD 변환 │ │
│ education-records │ │ │ · sourceChannel 설정 │ │
│ meeting-minutes │ │ │ · dataOrigin = work │ │
│ │ │ └──────────┬──────────┘ │
└──────────────────────┘ │ ▼ │
│ DEVONthink 자동 임포트 │
│ → 04_Industrial Safety/ │
│ 하위 그룹 자동 라우팅 │
│ │
│ ChromaDB 벡터 인덱싱 │
│ → RAG 검색 가능 │
└────────────────────────────┘
```
### 9.3 API 엔드포인트 명세 (TKSafety에 추가 예정)
```
GET /api/v1/risk-assessments
?since=2026-03-01&status=completed → 위험성평가 결과 목록
GET /api/v1/risk-assessments/{id}/report → 상세 (PDF/JSON)
GET /api/v1/patrol-inspections
?since=2026-03-01 → 순회점검 결과 목록
GET /api/v1/patrol-inspections/{id}/report → 상세 + 사진
GET /api/v1/corrective-actions
?since=2026-03-01&status=open|completed|overdue → 시정조치 내역
GET /api/v1/incidents?since=2026-03-01 → 사고/아차사고 보고서
GET /api/v1/education-records?since=2026-03-01 → 안전교육 기록
GET /api/v1/meetings?type=safety-committee&since=2026-03-01 → 회의록
GET /api/v1/sync-status → 마지막 동기화 시점, 대기 건수
```
### 9.4 라우팅 매핑 (활성화 시 적용)
```
TKSafety 데이터 → DEVONthink 그룹 → 파일 형식
────────────────────────────────────────────────────────────────
risk-assessments → 50_Practice/Risk_Assessment/ → PDF
patrol-inspections → 50_Practice/Patrol_Inspection/ → MD + 사진
corrective-actions → 70_Safety_Manager/Improvement/ → MD
incidents → 40_Cases/Domestic/ → PDF
education-records → 50_Practice/Education/ → MD
meetings (safety-comm) → 70_Safety_Manager/Meeting/ → MD
파일명 규칙:
RA_2026-03-24_[작업명]_[위험등급].pdf
PI_2026-03-24_[구역명].md
CA_2026-03-24_[조치내용]_[상태].md
INC_2026-03-24_[사고유형]_[심각도].pdf
```
### 9.5 동기화 스케줄 (활성화 시 적용)
```
· 위험성평가, 순회점검 → 매일 07:00
· 시정조치 → 매일 07:00 + 18:00
· 사고/아차사고 → 1시간마다 (긴급성)
· 교육기록, 회의록 → 주 1회 (월요일 07:00)
· overdue 시정조치 → OmniFocus 작업 자동 생성
```
### 9.6 활성화 단계
```
지금 할 것:
✓ API 명세 확정 (이 문서)
✓ sourceChannel/dataOrigin 체계 설계
○ TKSafety에 /api/v1/ 엔드포인트 뼈대만 추가 (빈 응답 OK)
PKM 안정화 후:
Phase 1: API 실제 데이터 응답 구현
Phase 2: tksafety_sync.py 개발 + DEVONthink 임포트
Phase 3: 시정조치 → OmniFocus 연동
Phase 4: 양방향 확장 (DEVONthink → TKSafety 상태 업데이트)
```
---
## 10. 산업안전 Daily Digest 기여 항목
Daily Digest는 전체 PKM 차원에서 운영되지만 (메인 아키텍처 참조),
이 DB는 특히 다음 항목을 다이제스트에 공급합니다.
```
04_Industrial Safety → Daily Digest 공급 항목:
■ 문서 변동
· 오늘 추가된 문서 수 (sourceChannel별 구분)
예: "산업안전 +5 (뉴스3, 법령1, 업무1)"
· 분류 실패 → 00_Inbox 잔류 건수
■ 법령 변경 (law_monitor 연동)
· 한국 법령 제·개정 감지 → ⚠ 마크로 강조
· 해외 법령 변경 → 참고 표시
· OmniFocus 액션: "법령 변경 검토: [법령명]" 자동 생성
■ 뉴스/동향 (DEVONagent 연동)
· 오늘 수집된 안전 뉴스 건수 (국내/해외 구분)
· 상위 3건 자동 요약 (Ollama 35B)
■ 업무 데이터 (TKSafety 연동, 활성화 시)
· 위험성평가/순회점검 신규 건수
· 시정조치 overdue → ⚠ OmniFocus 긴급 액션
■ OmniFocus 액션 자동 생성 조건 (이 DB 관련):
· 법령 변경 감지 → "법령 변경 검토: [법령명]"
· 시정조치 기한초과 → "시정조치 기한초과: [내용]" (긴급)
· 안전 뉴스 중대 키워드 → "뉴스 확인: [제목]"
(키워드: 중대재해, 사망, 작업중지, 과태료)
· Inbox 미처리 5건 이상 → "산업안전 Inbox 정리 필요"
```
---
## 11. 향후 확장 계획
- 나머지 도메인 DB(03_Engineering, 05_Programming 등)도 동일한 넘버링 패턴으로 그룹 구조 설계 예정
- 각 DB별 DEVONagent 검색 세트 추가
- DB 간 크로스 레퍼런스 (예: 산업안전 + 공학 문서 연결)
- TKSafety 양방향 연동 확장 (Section 9.6 참조)
- sourceChannel/dataOrigin 체계를 다른 도메인 DB에도 확장 적용

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-slim AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/build build/
COPY --from=build /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
CMD ["node", "build"]

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "hyungi-document-server-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"dependencies": {
"lucide-svelte": "^0.400.0",
"marked": "^15.0.0"
}
}

67
frontend/src/app.css Normal file
View File

@@ -0,0 +1,67 @@
@import "tailwindcss";
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--text-dim: #8b8d98;
--accent: #6c8aff;
--accent-hover: #859dff;
--error: #f5564e;
--success: #4ade80;
--warning: #fbbf24;
/* domain 색상 */
--domain-philosophy: #a78bfa;
--domain-language: #f472b6;
--domain-engineering: #38bdf8;
--domain-safety: #fb923c;
--domain-programming: #34d399;
--domain-general: #94a3b8;
--domain-reference: #fbbf24;
/* sidebar */
--sidebar-w: 320px;
--sidebar-bg: #141720;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
}
/* 스크롤바 */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* Markdown 렌더링 (GitHub Dark 스타일) */
.markdown-body {
color: var(--text);
line-height: 1.7;
font-size: 14px;
}
.markdown-body h1 { font-size: 1.6em; font-weight: 700; margin: 1.5em 0 0.5em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); }
.markdown-body h2 { font-size: 1.3em; font-weight: 600; margin: 1.3em 0 0.4em; padding-bottom: 0.2em; border-bottom: 1px solid var(--border); }
.markdown-body h3 { font-size: 1.1em; font-weight: 600; margin: 1.2em 0 0.3em; }
.markdown-body h4 { font-size: 1em; font-weight: 600; margin: 1em 0 0.2em; }
.markdown-body p { margin: 0.6em 0; }
.markdown-body ul, .markdown-body ol { padding-left: 1.5em; margin: 0.5em 0; }
.markdown-body li { margin: 0.2em 0; }
.markdown-body li > ul, .markdown-body li > ol { margin: 0.1em 0; }
.markdown-body blockquote { border-left: 3px solid var(--accent); padding: 0.5em 1em; margin: 0.8em 0; color: var(--text-dim); background: var(--surface); border-radius: 0 4px 4px 0; }
.markdown-body code { background: var(--surface); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; font-family: 'SF Mono', Menlo, monospace; }
.markdown-body pre { background: var(--surface); padding: 1em; border-radius: 6px; overflow-x: auto; margin: 0.8em 0; border: 1px solid var(--border); }
.markdown-body pre code { background: none; padding: 0; }
.markdown-body table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
.markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 0.5em 0.8em; text-align: left; font-size: 0.9em; }
.markdown-body th { background: var(--surface); font-weight: 600; }
.markdown-body tr:nth-child(even) { background: rgba(255,255,255,0.02); }
.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
.markdown-body a { color: var(--accent); text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; }
.markdown-body strong { font-weight: 600; }
.markdown-body img { max-width: 100%; border-radius: 4px; }

12
frontend/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyungi Document Server</title>
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

135
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* API fetch 래퍼
*
* - access token: 메모리 변수
* - refresh token: HttpOnly cookie (서버가 관리)
* - refresh 중복 방지: isRefreshing 플래그 + 대기 큐
* - 401 retry: 1회만, 실패 시 강제 logout
*/
const API_BASE = '/api';
let accessToken: string | null = null;
// refresh 큐
let isRefreshing = false;
let refreshQueue: Array<{
resolve: (token: string) => void;
reject: (err: Error) => void;
}> = [];
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
async function refreshAccessToken(): Promise<string> {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include', // cookie 전송
});
if (!res.ok) {
throw new Error('refresh failed');
}
const data = await res.json();
accessToken = data.access_token;
return data.access_token;
}
function processRefreshQueue(error: Error | null, token: string | null) {
refreshQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
refreshQueue = [];
}
async function handleTokenRefresh(): Promise<string> {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
const token = await refreshAccessToken();
processRefreshQueue(null, token);
return token;
} catch (err) {
const error = err instanceof Error ? err : new Error('refresh failed');
processRefreshQueue(error, null);
// 강제 logout
accessToken = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw error;
} finally {
isRefreshing = false;
}
}
export type ApiError = {
status: number;
detail: string;
};
export async function api<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
// FormData일 때는 Content-Type 자동 설정
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
// 401 → refresh 1회 시도 (로그인/리프레시 엔드포인트는 제외)
const isAuthEndpoint = path.startsWith('/auth/login') || path.startsWith('/auth/refresh');
if (res.status === 401 && accessToken && !isAuthEndpoint) {
try {
await handleTokenRefresh();
headers['Authorization'] = `Bearer ${accessToken}`;
const retryRes = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!retryRes.ok) {
const err = await retryRes.json().catch(() => ({ detail: 'Unknown error' }));
throw { status: retryRes.status, detail: err.detail || retryRes.statusText } as ApiError;
}
return retryRes.json();
} catch (e) {
if ((e as ApiError).detail) throw e;
throw { status: 401, detail: '인증이 만료되었습니다' } as ApiError;
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw { status: res.status, detail: err.detail || res.statusText } as ApiError;
}
// 204 No Content
if (res.status === 204) return {} as T;
return res.json();
}

View File

@@ -0,0 +1,107 @@
<script>
import { goto } from '$app/navigation';
import FormatIcon from './FormatIcon.svelte';
import TagPill from './TagPill.svelte';
let { doc, showDomain = true, selected = false, onselect = null } = $props();
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diff = now - d;
if (diff < 86400000) return '오늘';
if (diff < 172800000) return '어제';
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
function formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
const DOMAIN_COLORS = {
'Knowledge/Philosophy': 'var(--domain-philosophy)',
'Knowledge/Language': 'var(--domain-language)',
'Knowledge/Engineering': 'var(--domain-engineering)',
'Knowledge/Industrial_Safety': 'var(--domain-safety)',
'Knowledge/Programming': 'var(--domain-programming)',
'Knowledge/General': 'var(--domain-general)',
'Reference': 'var(--domain-reference)',
};
let domainColor = $derived(DOMAIN_COLORS[doc.ai_domain] || 'var(--border)');
function handleClick() {
// 모바일에서는 항상 detail 페이지로 이동
if (window.innerWidth < 1024) {
goto(`/documents/${doc.id}`);
return;
}
if (onselect) {
onselect(doc);
} else {
goto(`/documents/${doc.id}`);
}
}
</script>
<button
onclick={handleClick}
class="flex items-stretch bg-[var(--surface)] border rounded-lg hover:border-[var(--accent)] transition-colors group w-full text-left overflow-hidden
{selected ? 'border-[var(--accent)] bg-[var(--accent)]/5' : 'border-[var(--border)]'}"
>
<!-- domain 색상 바 -->
<div class="w-1 shrink-0 rounded-l-lg" style="background: {domainColor}"></div>
<!-- 콘텐츠 -->
<div class="flex items-start gap-3 p-3 flex-1 min-w-0">
<!-- 포맷 아이콘 -->
<div class="shrink-0 mt-0.5 text-[var(--text-dim)] group-hover:text-[var(--accent)]">
<FormatIcon format={doc.file_format} size={18} />
</div>
<!-- 메인 콘텐츠 -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate group-hover:text-[var(--accent)]">
{doc.title || '제목 없음'}
</p>
{#if doc.ai_summary}
<p class="text-xs text-[var(--text-dim)] truncate mt-0.5">{doc.ai_summary.slice(0, 100)}</p>
{/if}
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
{#if showDomain && doc.ai_domain}
<span class="text-[10px] text-[var(--text-dim)]">
{doc.ai_domain.replace('Knowledge/', '')}{doc.ai_sub_group ? ` / ${doc.ai_sub_group}` : ''}
</span>
{/if}
{#if doc.ai_tags?.length}
<div class="flex gap-1">
{#each doc.ai_tags.slice(0, 3) as tag}
<TagPill {tag} />
{/each}
</div>
{/if}
</div>
</div>
<!-- 우측 메타 -->
<div class="shrink-0 flex flex-col items-end gap-1 text-[10px]">
{#if doc.score !== undefined}
<span class="text-[var(--accent)] font-medium">{(doc.score * 100).toFixed(0)}%</span>
{/if}
{#if doc.data_origin}
<span class="px-1.5 py-0.5 rounded {doc.data_origin === 'work' ? 'bg-blue-900/30 text-blue-400' : 'bg-gray-800 text-gray-400'}">
{doc.data_origin}
</span>
{/if}
<span class="text-[var(--text-dim)]">{formatDate(doc.created_at)}</span>
{#if doc.file_size}
<span class="text-[var(--text-dim)]">{formatSize(doc.file_size)}</span>
{/if}
</div>
</div>
</button>

View File

@@ -0,0 +1,231 @@
<script>
import { api, getAccessToken } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
let { doc } = $props();
let fullDoc = $state(null);
let loading = $state(true);
let viewerType = $state('none');
// Markdown 편집
let editMode = $state(false);
let editContent = $state('');
let saving = $state(false);
let rawMarkdown = $state('');
function getViewerType(format) {
if (['md', 'txt'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
if (['hwp', 'hwpx'].includes(format)) return 'preview-pdf';
if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(format)) return 'preview-pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'].includes(format)) return 'image';
if (['csv', 'json', 'xml', 'html'].includes(format)) return 'text';
if (['dwg', 'dxf'].includes(format)) return 'cad';
return 'unsupported';
}
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
function getEditInfo(doc) {
// DB에 저장된 편집 URL 우선
if (doc.edit_url) return { url: doc.edit_url, label: '편집' };
// ODF 포맷 → Synology Drive
if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
// CAD
if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
return null;
}
$effect(() => {
if (doc?.id) {
loadFullDoc(doc.id);
editMode = false;
}
});
async function loadFullDoc(id) {
loading = true;
try {
fullDoc = await api(`/documents/${id}`);
viewerType = getViewerType(fullDoc.file_format);
// Markdown: extracted_text 없으면 원본 파일 직접 가져오기
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${id}/file?token=${getAccessToken()}`);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) { rawMarkdown = ''; }
} else {
rawMarkdown = '';
}
} catch (err) {
fullDoc = null;
viewerType = 'none';
} finally {
loading = false;
}
}
function startEdit() {
editContent = fullDoc?.extracted_text || rawMarkdown || '';
editMode = true;
editTab = 'edit';
}
let editTab = $state('edit'); // 'edit' | 'preview'
async function saveContent() {
saving = true;
try {
await api(`/documents/${fullDoc.id}/content`, {
method: 'PUT',
body: JSON.stringify({ content: editContent }),
});
fullDoc.extracted_text = editContent;
editMode = false;
addToast('success', '저장됨');
} catch (err) {
addToast('error', '저장 실패');
} finally {
saving = false;
}
}
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 's' && editMode) {
e.preventDefault();
saveContent();
}
}
let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="h-full flex flex-col bg-[var(--surface)] border-t border-[var(--border)]">
<!-- 뷰어 툴바 -->
{#if fullDoc && !loading}
<div class="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] bg-[var(--sidebar-bg)] shrink-0">
<span class="text-xs text-[var(--text-dim)] truncate">{fullDoc.title || '제목 없음'}</span>
<div class="flex items-center gap-2">
{#if viewerType === 'markdown'}
{#if editMode}
<button
onclick={saveContent}
disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
<Save size={12} /> {saving ? '저장 중...' : '저장'}
</button>
<button
onclick={() => editMode = false}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
>취소</button>
{:else}
<button
onclick={startEdit}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>편집</button>
{/if}
{/if}
{#if editInfo}
<a
href={editInfo.url}
target="_blank"
rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>
<ExternalLink size={12} /> {editInfo.label}
</a>
{/if}
<a
href="/documents/{fullDoc.id}"
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)] border border-[var(--border)] rounded"
>전체 보기</a>
</div>
</div>
{/if}
<!-- 뷰어 본문 -->
<div class="flex-1 overflow-auto min-h-0">
{#if loading}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-[var(--text-dim)]">로딩 중...</p>
</div>
{:else if fullDoc}
{#if viewerType === 'markdown'}
{#if editMode}
<!-- Markdown 편집 (탭 전환) -->
<div class="flex flex-col h-full">
<div class="flex gap-1 px-3 py-1 border-b border-[var(--border)] shrink-0">
<button
onclick={() => editTab = 'edit'}
class="px-3 py-1 text-xs rounded-t {editTab === 'edit' ? 'bg-[var(--surface)] text-[var(--text)]' : 'text-[var(--text-dim)]'}"
>편집</button>
<button
onclick={() => editTab = 'preview'}
class="px-3 py-1 text-xs rounded-t {editTab === 'preview' ? 'bg-[var(--surface)] text-[var(--text)]' : 'text-[var(--text-dim)]'}"
>미리보기</button>
</div>
{#if editTab === 'edit'}
<textarea
bind:value={editContent}
class="flex-1 w-full p-4 bg-[var(--bg)] text-[var(--text)] text-sm font-mono resize-none outline-none"
spellcheck="false"
></textarea>
{:else}
<div class="flex-1 overflow-auto p-4 markdown-body">
{@html marked(editContent)}
</div>
{/if}
</div>
{:else}
<div class="p-4 markdown-body">
{@html marked(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
</div>
{/if}
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
></iframe>
{:else if viewerType === 'preview-pdf'}
<iframe
src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
onerror={() => {}}
></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
alt={fullDoc.title}
class="max-w-full max-h-full object-contain rounded"
/>
</div>
{:else if viewerType === 'text'}
<div class="p-4">
<pre class="text-sm text-[var(--text)] whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre>
</div>
{:else if viewerType === 'cad'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-[var(--text-dim)]">CAD 미리보기 (향후 지원 예정)</p>
<a
href="https://web.autocad.com"
target="_blank"
class="px-3 py-1.5 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)]"
>AutoCAD Web에서 열기</a>
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-[var(--text-dim)]">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
</div>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,30 @@
<script>
import { FileText, File, Image, FileSpreadsheet, Presentation, Mail, FileCode, FileQuestion } from 'lucide-svelte';
let { format = '', size = 16 } = $props();
const ICON_MAP = {
pdf: FileText,
hwp: FileText,
hwpx: FileText,
md: FileCode,
txt: File,
csv: FileSpreadsheet,
json: FileCode,
xml: FileCode,
html: FileCode,
jpg: Image,
jpeg: Image,
png: Image,
gif: Image,
bmp: Image,
tiff: Image,
eml: Mail,
odoc: FileText,
osheet: FileSpreadsheet,
};
let Icon = $derived(ICON_MAP[format?.toLowerCase()] || FileQuestion);
</script>
<svelte:component this={Icon} {size} />

View File

@@ -0,0 +1,344 @@
<script>
import { X, ExternalLink, Plus, Save, Trash2 } from 'lucide-svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import FormatIcon from './FormatIcon.svelte';
import TagPill from './TagPill.svelte';
let { doc, onclose, ondelete = () => {} } = $props();
// 메모 상태
let noteText = $state('');
let noteEditing = $state(false);
let noteSaving = $state(false);
// 태그 편집
let newTag = $state('');
let tagEditing = $state(false);
// 삭제
let deleteConfirm = $state(false);
let deleting = $state(false);
async function deleteDoc() {
deleting = true;
try {
await api(`/documents/${doc.id}?delete_file=true`, { method: 'DELETE' });
addToast('success', '문서 삭제됨');
ondelete();
} catch (err) {
addToast('error', '삭제 실패');
} finally {
deleting = false;
deleteConfirm = false;
}
}
// 편집 URL
let editUrlText = $state('');
let editUrlEditing = $state(false);
// doc 변경 시 초기화
$effect(() => {
if (doc) {
noteText = doc.user_note || '';
editUrlText = doc.edit_url || '';
noteEditing = false;
tagEditing = false;
editUrlEditing = false;
newTag = '';
}
});
async function saveNote() {
noteSaving = true;
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ user_note: noteText }),
});
doc.user_note = noteText;
noteEditing = false;
addToast('success', '메모 저장됨');
} catch (err) {
addToast('error', '메모 저장 실패');
} finally {
noteSaving = false;
}
}
async function saveEditUrl() {
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ edit_url: editUrlText.trim() || null }),
});
doc.edit_url = editUrlText.trim() || null;
editUrlEditing = false;
addToast('success', '편집 URL 저장됨');
} catch (err) {
addToast('error', '편집 URL 저장 실패');
}
}
async function addTag() {
const tag = newTag.trim();
if (!tag) return;
const updatedTags = [...(doc.ai_tags || []), tag];
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
newTag = '';
addToast('success', '태그 추가됨');
} catch (err) {
addToast('error', '태그 추가 실패');
}
}
async function removeTag(tagToRemove) {
const updatedTags = (doc.ai_tags || []).filter(t => t !== tagToRemove);
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
addToast('success', '태그 삭제됨');
} catch (err) {
addToast('error', '태그 삭제 실패');
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' });
}
function formatSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
</script>
<aside class="h-full flex flex-col bg-[var(--sidebar-bg)] border-l border-[var(--border)] overflow-y-auto">
<!-- 헤더 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] shrink-0">
<div class="flex items-center gap-2 min-w-0">
<FormatIcon format={doc.file_format} size={16} />
<span class="text-sm font-medium truncate">{doc.title || '제목 없음'}</span>
</div>
<div class="flex items-center gap-1">
<a href="/documents/{doc.id}" class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" title="전체 보기">
<ExternalLink size={14} />
</a>
<button onclick={onclose} class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" aria-label="닫기">
<X size={16} />
</button>
</div>
</div>
<div class="flex-1 p-4 space-y-4">
<!-- 메모 -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">메모</h4>
{#if noteEditing}
<textarea
bind:value={noteText}
class="w-full h-24 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-none outline-none focus:border-[var(--accent)]"
placeholder="메모 입력..."
></textarea>
<div class="flex gap-2 mt-1.5">
<button
onclick={saveNote}
disabled={noteSaving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
<Save size={12} /> 저장
</button>
<button
onclick={() => { noteEditing = false; noteText = doc.user_note || ''; }}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
>취소</button>
</div>
{:else}
<button
onclick={() => noteEditing = true}
class="w-full text-left px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm min-h-[40px]
{noteText ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}"
>
{noteText || '메모 추가...'}
</button>
{/if}
</div>
<!-- 편집 URL -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">편집 링크</h4>
{#if editUrlEditing}
<div class="flex gap-1">
<input
bind:value={editUrlText}
placeholder="Synology Drive URL 붙여넣기..."
class="flex-1 px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs text-[var(--text)] outline-none focus:border-[var(--accent)]"
/>
<button onclick={saveEditUrl} class="px-2 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button>
<button onclick={() => { editUrlEditing = false; editUrlText = doc.edit_url || ''; }} class="px-2 py-1 text-xs text-[var(--text-dim)]">취소</button>
</div>
{:else if doc.edit_url}
<div class="flex items-center gap-1">
<a href={doc.edit_url} target="_blank" class="text-xs text-[var(--accent)] truncate hover:underline">{doc.edit_url}</a>
<button onclick={() => editUrlEditing = true} class="text-[10px] text-[var(--text-dim)] hover:text-[var(--text)]">수정</button>
</div>
{:else}
<button
onclick={() => editUrlEditing = true}
class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]"
>+ URL 추가</button>
{/if}
</div>
<!-- 태그 -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">태그</h4>
<div class="flex flex-wrap gap-1 mb-2">
{#each doc.ai_tags || [] as tag}
<span class="inline-flex items-center gap-0.5">
<TagPill {tag} clickable={false} />
<button
onclick={() => removeTag(tag)}
class="text-[var(--text-dim)] hover:text-[var(--error)] text-[10px]"
title="삭제"
>×</button>
</span>
{/each}
</div>
{#if tagEditing}
<form onsubmit={(e) => { e.preventDefault(); addTag(); }} class="flex gap-1">
<input
bind:value={newTag}
placeholder="태그 입력..."
class="flex-1 px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs text-[var(--text)] outline-none focus:border-[var(--accent)]"
/>
<button type="submit" class="px-2 py-1 text-xs bg-[var(--accent)] text-white rounded">추가</button>
</form>
{:else}
<button
onclick={() => tagEditing = true}
class="flex items-center gap-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)]"
>
<Plus size={12} /> 태그 추가
</button>
{/if}
</div>
<!-- AI 분류 -->
{#if doc.ai_domain}
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">분류</h4>
<!-- domain breadcrumb -->
<div class="flex flex-wrap gap-1 mb-2">
{#each doc.ai_domain.split('/') as part, i}
{#if i > 0}<span class="text-[10px] text-[var(--text-dim)]"></span>{/if}
<span class="text-xs text-[var(--accent)]">{part}</span>
{/each}
</div>
<!-- document_type + confidence -->
<div class="flex items-center gap-2">
{#if doc.document_type}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{doc.document_type}</span>
{/if}
{#if doc.ai_confidence}
<span class="text-[10px] px-1.5 py-0.5 rounded {doc.ai_confidence >= 0.85 ? 'bg-green-900/30 text-green-400' : doc.ai_confidence >= 0.6 ? 'bg-amber-900/30 text-amber-400' : 'bg-red-900/30 text-red-400'}">
{(doc.ai_confidence * 100).toFixed(0)}%
</span>
{/if}
{#if doc.importance && doc.importance !== 'medium'}
<span class="text-[10px] px-1.5 py-0.5 rounded {doc.importance === 'high' ? 'bg-red-900/30 text-red-400' : 'bg-gray-800 text-gray-400'}">
{doc.importance}
</span>
{/if}
</div>
</div>
{/if}
<!-- 파일 정보 -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">정보</h4>
<dl class="space-y-1.5 text-xs">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">포맷</dt>
<dd class="uppercase">{doc.file_format}{doc.original_format ? ` (원본: ${doc.original_format})` : ''}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">크기</dt>
<dd>{formatSize(doc.file_size)}</dd>
</div>
{#if doc.source_channel}
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">출처</dt>
<dd>{doc.source_channel}</dd>
</div>
{/if}
{#if doc.data_origin}
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">구분</dt>
<dd>{doc.data_origin}</dd>
</div>
{/if}
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">등록일</dt>
<dd>{formatDate(doc.created_at)}</dd>
</div>
</dl>
</div>
<!-- 처리 상태 -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">처리</h4>
<dl class="space-y-1 text-xs">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">추출</dt>
<dd class={doc.extracted_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.extracted_at ? '완료' : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">분류</dt>
<dd class={doc.ai_processed_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.ai_processed_at ? '완료' : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">임베딩</dt>
<dd class={doc.embedded_at ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>{doc.embedded_at ? '완료' : '대기'}</dd>
</div>
</dl>
</div>
<!-- 삭제 -->
<div class="pt-2 border-t border-[var(--border)]">
{#if deleteConfirm}
<div class="flex items-center gap-2">
<span class="text-xs text-[var(--error)]">정말 삭제?</span>
<button
onclick={deleteDoc}
disabled={deleting}
class="px-2 py-1 text-xs bg-[var(--error)] text-white rounded disabled:opacity-50"
>{deleting ? '삭제 중...' : '확인'}</button>
<button
onclick={() => deleteConfirm = false}
class="px-2 py-1 text-xs text-[var(--text-dim)]"
>취소</button>
</div>
{:else}
<button
onclick={() => deleteConfirm = true}
class="flex items-center gap-1 text-xs text-[var(--text-dim)] hover:text-[var(--error)]"
>
<Trash2 size={12} /> 문서 삭제
</button>
{/if}
</div>
</div>
</aside>

View File

@@ -0,0 +1,185 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale } from 'lucide-svelte';
let tree = $state([]);
let loading = $state(true);
let expanded = $state({});
let activeDomain = $derived($page.url.searchParams.get('domain'));
const DOMAIN_COLORS = {
'Philosophy': 'var(--domain-philosophy)',
'Language': 'var(--domain-language)',
'Engineering': 'var(--domain-engineering)',
'Industrial_Safety': 'var(--domain-safety)',
'Programming': 'var(--domain-programming)',
'General': 'var(--domain-general)',
'Reference': 'var(--domain-reference)',
};
async function loadTree() {
loading = true;
try {
tree = await api('/documents/tree');
} catch (err) {
console.error('트리 로딩 실패:', err);
} finally {
loading = false;
}
}
function toggleExpand(path) {
expanded[path] = !expanded[path];
}
function navigate(path) {
const params = new URLSearchParams($page.url.searchParams);
params.delete('page');
if (path) {
params.set('domain', path);
} else {
params.delete('domain');
}
params.delete('sub_group');
for (const [key, val] of [...params.entries()]) {
if (!val) params.delete(key);
}
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
$effect(() => { loadTree(); });
$effect(() => {
if (activeDomain) {
// 선택된 경로의 부모들 자동 펼치기
const parts = activeDomain.split('/');
let path = '';
for (const part of parts) {
path = path ? `${path}/${part}` : part;
expanded[path] = true;
}
}
});
let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));
</script>
<aside class="h-full flex flex-col bg-[var(--sidebar-bg)] border-r border-[var(--border)] overflow-y-auto">
<div class="px-4 py-3 border-b border-[var(--border)]">
<h2 class="text-sm font-semibold text-[var(--text-dim)] uppercase tracking-wider">분류</h2>
</div>
<!-- 전체 문서 -->
<div class="px-2 pt-2">
<button
onclick={() => navigate(null)}
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
{!activeDomain ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text)] hover:bg-[var(--surface)]'}"
>
<span class="flex items-center gap-2">
<FolderOpen size={16} />
전체 문서
</span>
{#if totalCount > 0}
<span class="text-xs text-[var(--text-dim)]">{totalCount}</span>
{/if}
</button>
</div>
<!-- 트리 -->
<nav class="flex-1 px-2 py-2">
{#if loading}
{#each Array(5) as _}
<div class="h-8 bg-[var(--surface)] rounded-md animate-pulse mx-1 mb-1"></div>
{/each}
{:else}
{#each tree as node}
{@const color = DOMAIN_COLORS[node.name] || 'var(--text-dim)'}
{#snippet treeNode(n, depth)}
{@const isActive = activeDomain === n.path}
{@const isParent = activeDomain?.startsWith(n.path + '/')}
{@const hasChildren = n.children.length > 0}
{@const isExpanded = expanded[n.path]}
<div class="flex items-center" style="padding-left: {depth * 16}px">
{#if hasChildren}
<button
onclick={() => toggleExpand(n.path)}
class="p-0.5 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]"
>
{#if isExpanded}
<ChevronDown size={14} />
{:else}
<ChevronRight size={14} />
{/if}
</button>
{:else}
<span class="w-5"></span>
{/if}
<button
onclick={() => navigate(n.path)}
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
{isActive ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : isParent ? 'text-[var(--text)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]'}"
>
<span class="flex items-center gap-2">
{#if depth === 0}
<span class="w-2 h-2 rounded-full shrink-0" style="background: {color}"></span>
{/if}
<span class="truncate">{n.name}</span>
</span>
<span class="text-xs text-[var(--text-dim)] shrink-0 ml-2">{n.count}</span>
</button>
</div>
{#if hasChildren && isExpanded}
{#each n.children as child}
{@render treeNode(child, depth + 1)}
{/each}
{/if}
{/snippet}
{@render treeNode(node, 0)}
{/each}
{/if}
</nav>
<!-- 스마트 그룹 -->
<div class="px-2 py-2 border-t border-[var(--border)]">
<h3 class="px-3 py-1 text-[10px] font-semibold text-[var(--text-dim)] uppercase tracking-wider">스마트 그룹</h3>
<button
onclick={() => goto('/documents', { noScroll: true })}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Clock size={14} /> 최근 7일
</button>
<button
onclick={() => { const p = new URLSearchParams(); p.set('source', 'law_monitor'); goto(`/documents?${p}`, { noScroll: true }); }}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Scale size={14} /> 법령 알림
</button>
<button
onclick={() => { const p = new URLSearchParams(); p.set('source', 'email'); goto(`/documents?${p}`, { noScroll: true }); }}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Mail size={14} /> 이메일
</button>
</div>
<!-- Inbox -->
<div class="px-2 py-2 border-t border-[var(--border)]">
<a
href="/inbox"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-[var(--text)] hover:bg-[var(--surface)] transition-colors"
>
<span class="flex items-center gap-2">
<Inbox size={16} />
받은편지함
</span>
</a>
</div>
</aside>

View File

@@ -0,0 +1,40 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { tag = '', clickable = true } = $props();
// 계층별 색상
function getColor(t) {
if (t.startsWith('@상태/') || t.startsWith('@')) return { bg: 'bg-amber-900/30', text: 'text-amber-400' };
if (t.startsWith('#주제/') || t.startsWith('#')) return { bg: 'bg-blue-900/30', text: 'text-blue-400' };
if (t.startsWith('$유형/') || t.startsWith('$')) return { bg: 'bg-emerald-900/30', text: 'text-emerald-400' };
if (t.startsWith('!우선순위/') || t.startsWith('!')) return { bg: 'bg-red-900/30', text: 'text-red-400' };
return { bg: 'bg-[var(--border)]', text: 'text-[var(--text-dim)]' };
}
function handleClick(e) {
if (!clickable) return;
e.preventDefault();
e.stopPropagation();
const params = new URLSearchParams($page.url.searchParams);
params.set('tag', tag);
params.delete('page');
goto(`/documents?${params}`, { noScroll: true });
}
let color = $derived(getColor(tag));
</script>
{#if clickable}
<button
onclick={handleClick}
class="inline-flex text-[10px] px-1.5 py-0.5 rounded {color.bg} {color.text} hover:opacity-80 transition-opacity"
>
{tag}
</button>
{:else}
<span class="inline-flex text-[10px] px-1.5 py-0.5 rounded {color.bg} {color.text}">
{tag}
</span>
{/if}

View File

@@ -0,0 +1,129 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { Upload } from 'lucide-svelte';
let { onupload = () => {} } = $props();
let dragging = $state(false);
let uploading = $state(false);
let uploadFiles = $state([]);
let dragCounter = 0;
onMount(() => {
function onDragEnter(e) {
e.preventDefault();
e.stopPropagation();
dragCounter++;
dragging = true;
}
function onDragOver(e) {
e.preventDefault();
e.stopPropagation();
}
function onDragLeave(e) {
e.preventDefault();
e.stopPropagation();
dragCounter--;
if (dragCounter <= 0) {
dragging = false;
dragCounter = 0;
}
}
function onDrop(e) {
e.preventDefault();
e.stopPropagation();
dragging = false;
dragCounter = 0;
handleFiles(e.dataTransfer?.files);
}
window.addEventListener('dragenter', onDragEnter);
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragenter', onDragEnter);
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
};
});
async function handleFiles(fileList) {
const files = Array.from(fileList || []);
if (files.length === 0) return;
uploading = true;
uploadFiles = files.map(f => ({ name: f.name, status: 'pending' }));
let success = 0;
let failed = 0;
for (let i = 0; i < files.length; i++) {
uploadFiles[i].status = 'uploading';
uploadFiles = [...uploadFiles];
try {
const formData = new FormData();
formData.append('file', files[i]);
await api('/documents/', { method: 'POST', body: formData });
uploadFiles[i].status = 'done';
success++;
} catch (err) {
uploadFiles[i].status = 'failed';
failed++;
}
uploadFiles = [...uploadFiles];
}
if (success > 0) {
addToast('success', `${success}건 업로드 완료${failed > 0 ? `, ${failed}건 실패` : ''}`);
onupload();
} else {
addToast('error', `업로드 실패 (${failed}건)`);
}
setTimeout(() => {
uploading = false;
uploadFiles = [];
}, 3000);
}
</script>
<!-- 전체 페이지 드래그 오버레이 -->
{#if dragging}
<div class="fixed inset-0 z-50 bg-[var(--accent)]/10 border-2 border-dashed border-[var(--accent)] flex items-center justify-center">
<div class="bg-[var(--surface)] rounded-xl px-8 py-6 shadow-xl text-center">
<Upload size={32} class="mx-auto mb-2 text-[var(--accent)]" />
<p class="text-sm font-medium text-[var(--accent)]">여기에 파일을 놓으세요</p>
</div>
</div>
{/if}
<!-- 업로드 진행 상태 -->
{#if uploading && uploadFiles.length > 0}
<div class="mb-3 bg-[var(--surface)] border border-[var(--border)] rounded-lg p-3">
<p class="text-xs text-[var(--text-dim)] mb-2">업로드 중...</p>
<div class="space-y-1 max-h-32 overflow-y-auto">
{#each uploadFiles as f}
<div class="flex items-center justify-between text-xs">
<span class="truncate">{f.name}</span>
<span class={
f.status === 'done' ? 'text-[var(--success)]' :
f.status === 'failed' ? 'text-[var(--error)]' :
f.status === 'uploading' ? 'text-[var(--accent)]' :
'text-[var(--text-dim)]'
}>
{f.status === 'done' ? '✓' : f.status === 'failed' ? '✗' : f.status === 'uploading' ? '↑' : '…'}
</span>
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
import { api, setAccessToken } from '$lib/api';
interface User {
id: number;
username: string;
is_active: boolean;
totp_enabled: boolean;
last_login_at: string | null;
}
export const user = writable<User | null>(null);
export const isAuthenticated = writable(false);
export async function login(username: string, password: string, totp_code?: string) {
const data = await api<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password, totp_code: totp_code || undefined }),
});
setAccessToken(data.access_token);
await fetchUser();
}
export async function fetchUser() {
try {
const data = await api<User>('/auth/me');
user.set(data);
isAuthenticated.set(true);
} catch {
user.set(null);
isAuthenticated.set(false);
}
}
export async function logout() {
try {
await api('/auth/logout', { method: 'POST' });
} catch { /* ignore */ }
setAccessToken(null);
user.set(null);
isAuthenticated.set(false);
}
export async function tryRefresh() {
try {
const data = await api<{ access_token: string }>('/auth/refresh', {
method: 'POST',
});
setAccessToken(data.access_token);
await fetchUser();
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
export const sidebarOpen = writable(true);
export const selectedDocId = writable<number | null>(null);
// Toast 시스템
interface Toast {
id: number;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
}
let toastId = 0;
export const toasts = writable<Toast[]>([]);
export function addToast(type: Toast['type'], message: string, duration = 5000) {
const id = ++toastId;
toasts.update(t => [...t, { id, type, message }]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}
export function removeToast(id: number) {
toasts.update(t => t.filter(toast => toast.id !== id));
}

View File

@@ -0,0 +1,133 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/ui';
import Sidebar from '$lib/components/Sidebar.svelte';
import '../app.css';
const PUBLIC_PATHS = ['/login', '/setup'];
const NO_CHROME_PATHS = ['/login', '/setup'];
let authChecked = $state(false);
let sidebarOpen = $state(false);
onMount(async () => {
// localStorage에서 사이드바 상태 복원
const saved = localStorage.getItem('sidebarOpen');
if (saved === 'true') sidebarOpen = true;
if (!$isAuthenticated) {
await tryRefresh();
}
authChecked = true;
});
// 사이드바 상태 저장
$effect(() => {
if (browser) {
localStorage.setItem('sidebarOpen', String(sidebarOpen));
}
});
$effect(() => {
if (browser && authChecked && !$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
goto('/login');
}
});
let showChrome = $derived($isAuthenticated && !NO_CHROME_PATHS.some(p => $page.url.pathname.startsWith(p)));
function handleKeydown(e) {
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault();
document.querySelector('[data-search-input]')?.focus();
}
if (e.key === 'Escape' && sidebarOpen) {
sidebarOpen = false;
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if !authChecked}
<div class="min-h-screen flex items-center justify-center">
<p class="text-[var(--text-dim)]">로딩 중...</p>
</div>
{:else if $isAuthenticated || PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))}
{#if showChrome}
<div class="h-screen flex flex-col">
<!-- 상단 nav -->
<nav class="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] bg-[var(--surface)] shrink-0">
<div class="flex items-center gap-3">
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="p-1.5 rounded-md hover:bg-[var(--border)] text-[var(--text-dim)]"
aria-label="사이드바 토글"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
<a href="/" class="text-sm font-semibold hover:text-[var(--accent)]">PKM</a>
<span class="text-[var(--text-dim)] text-xs">/</span>
<a href="/documents" class="text-xs hover:text-[var(--accent)]">문서</a>
</div>
<div class="flex items-center gap-3">
<a href="/inbox" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
<a href="/settings" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">설정</a>
<button
onclick={() => logout()}
class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
>로그아웃</button>
</div>
</nav>
<!-- 메인 -->
<div class="flex-1 min-h-0 relative">
<!-- 사이드바 오버레이 (모든 화면에서 동일) -->
{#if sidebarOpen}
<div class="fixed inset-0 z-40">
<button
onclick={() => sidebarOpen = false}
class="absolute inset-0 bg-black/40"
aria-label="사이드바 닫기"
></button>
<div class="absolute left-0 top-0 bottom-0 z-50" style="width: var(--sidebar-w)">
<Sidebar />
</div>
</div>
{/if}
<!-- 콘텐츠 -->
<main class="h-full overflow-hidden">
<slot />
</main>
</div>
</div>
{:else}
<slot />
{/if}
{/if}
<!-- Toast -->
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{#each $toasts as toast (toast.id)}
<button
class="px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 cursor-pointer text-left"
class:bg-green-900={toast.type === 'success'}
class:bg-red-900={toast.type === 'error'}
class:bg-yellow-900={toast.type === 'warning'}
class:bg-blue-900={toast.type === 'info'}
class:text-green-200={toast.type === 'success'}
class:text-red-200={toast.type === 'error'}
class:text-yellow-200={toast.type === 'warning'}
class:text-blue-200={toast.type === 'info'}
onclick={() => removeToast(toast.id)}
>
{toast.message}
</button>
{/each}
</div>

View File

@@ -0,0 +1,89 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
let dashboard = null;
let loading = true;
onMount(async () => {
try {
dashboard = await api('/dashboard/');
} catch (err) {
addToast('error', '대시보드 로딩 실패');
} finally {
loading = false;
}
});
</script>
<div class="p-4 lg:p-6">
<div class="max-w-6xl mx-auto">
<h2 class="text-xl font-bold mb-6">대시보드</h2>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-pulse h-28"></div>
{/each}
</div>
{:else if dashboard}
<!-- 위젯 그리드 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- 전체 문서 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">전체 문서</p>
<p class="text-3xl font-bold mt-1">{dashboard.total_documents}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 +{dashboard.today_added}</p>
</div>
<!-- Inbox -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">Inbox 미분류</p>
<p class="text-3xl font-bold mt-1" class:text-[var(--warning)]={dashboard.inbox_count > 0}>{dashboard.inbox_count}</p>
{#if dashboard.inbox_count > 0}
<a href="/inbox" class="text-xs text-[var(--accent)] hover:underline mt-1 inline-block">분류하기</a>
{/if}
</div>
<!-- 법령 알림 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">법령 알림</p>
<p class="text-3xl font-bold mt-1">{dashboard.law_alerts}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 변경</p>
</div>
<!-- 파이프라인 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">파이프라인</p>
{#if dashboard.failed_count > 0}
<p class="text-3xl font-bold mt-1 text-[var(--error)]">{dashboard.failed_count} 실패</p>
{:else}
<p class="text-3xl font-bold mt-1 text-[var(--success)]">정상</p>
{/if}
</div>
</div>
<!-- 최근 문서 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">최근 문서</h3>
{#if dashboard.recent_documents.length > 0}
<div class="space-y-2">
{#each dashboard.recent_documents as doc}
<a href="/documents/{doc.id}" class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[var(--bg)] transition-colors">
<div class="flex items-center gap-3">
<span class="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
<span class="text-sm truncate max-w-md">{doc.title || '제목 없음'}</span>
</div>
<span class="text-xs text-[var(--text-dim)]">{doc.ai_domain || ''}</span>
</a>
{/each}
</div>
{:else}
<p class="text-sm text-[var(--text-dim)]">문서가 없습니다</p>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,271 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { Info } from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
let documents = $state([]);
let total = $state(0);
let loading = $state(true);
let searchQuery = $state('');
let searchMode = $state('hybrid');
let searchResults = $state(null);
let selectedDoc = $state(null);
let infoPanelOpen = $state(false);
let debounceTimer;
// URL params → filter
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
let filterDomain = $derived($page.url.searchParams.get('domain') || '');
let filterSubGroup = $derived($page.url.searchParams.get('sub_group') || '');
$effect(() => {
const _p = currentPage;
const _d = filterDomain;
const _s = filterSubGroup;
const urlQ = $page.url.searchParams.get('q') || '';
const urlMode = $page.url.searchParams.get('mode') || 'hybrid';
searchQuery = urlQ;
searchMode = urlMode;
selectedDoc = null;
infoPanelOpen = false;
if (urlQ) {
doSearch(urlQ, urlMode);
} else {
loadDocuments();
}
});
async function loadDocuments() {
loading = true;
searchResults = null;
try {
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('page_size', '20');
if (filterDomain) params.set('domain', filterDomain);
if (filterSubGroup) params.set('sub_group', filterSubGroup);
const data = await api(`/documents/?${params}`);
documents = data.items;
total = data.total;
} catch (err) {
addToast('error', '문서 목록 로딩 실패');
} finally {
loading = false;
}
}
function submitSearch() {
const params = new URLSearchParams($page.url.searchParams);
params.delete('page');
if (searchQuery.trim()) {
params.set('q', searchQuery.trim());
} else {
params.delete('q');
}
if (searchMode !== 'hybrid') {
params.set('mode', searchMode);
} else {
params.delete('mode');
}
for (const [key, val] of [...params.entries()]) {
if (!val) params.delete(key);
}
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
function handleSearchKeydown(e) {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
submitSearch();
}
}
async function doSearch(q, mode) {
loading = true;
try {
const data = await api(`/search/?q=${encodeURIComponent(q)}&mode=${mode}&limit=50`);
searchResults = data.results;
total = data.total;
} catch (err) {
addToast('error', '검색 실패');
searchResults = [];
} finally {
loading = false;
}
}
function changePage(p) {
const params = new URLSearchParams($page.url.searchParams);
if (p > 1) {
params.set('page', String(p));
} else {
params.delete('page');
}
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
function clearAllFilters() {
goto('/documents', { noScroll: true });
searchQuery = '';
}
function selectDoc(doc) {
selectedDoc = selectedDoc?.id === doc.id ? null : doc;
}
function handleKeydown(e) {
if (e.key === 'Escape' && infoPanelOpen) {
infoPanelOpen = false;
}
}
let totalPages = $derived(Math.ceil(total / 20));
let items = $derived(searchResults || documents);
let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!searchQuery);
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="flex flex-col h-full">
<!-- 상단 영역 -->
<div class="{selectedDoc ? 'h-[30%] shrink-0 border-b border-[var(--border)]' : 'flex-1'} flex flex-col min-h-0">
<!-- 업로드 드롭존 -->
<UploadDropzone onupload={loadDocuments} />
<!-- 검색바 + 정보 버튼 (고정) -->
<div class="flex gap-2 px-4 py-2 shrink-0">
<input
data-search-input
type="text"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
placeholder="검색어 입력 후 Enter (/ 키로 포커스)"
class="flex-1 px-3 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-sm focus:border-[var(--accent)] outline-none"
/>
<select
bind:value={searchMode}
onchange={() => { if (searchQuery) submitSearch(); }}
class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-xs"
>
<option value="hybrid">하이브리드</option>
<option value="fts">전문검색</option>
<option value="trgm">부분매칭</option>
<option value="vector">의미검색</option>
</select>
{#if selectedDoc}
<button
onclick={() => infoPanelOpen = !infoPanelOpen}
class="p-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--accent)] text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors
{infoPanelOpen ? 'bg-[var(--accent)]/10 border-[var(--accent)] text-[var(--accent)]' : ''}"
aria-label="문서 정보"
title="문서 정보"
>
<Info size={16} />
</button>
{/if}
</div>
<!-- 스크롤 영역 (목록) -->
<div class="flex-1 overflow-y-auto px-4">
<!-- 결과 헤더 -->
{#if !loading}
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-[10px] text-[var(--text-dim)]">{total}</span>
{#if filterDomain}
<span class="text-[10px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">
{filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''}
</span>
{/if}
</div>
{#if hasActiveFilters}
<button
onclick={clearAllFilters}
class="text-[10px] text-[var(--text-dim)] hover:text-[var(--text)] px-1.5 py-0.5 rounded border border-[var(--border)]"
>
초기화
</button>
{/if}
</div>
{/if}
<!-- 결과 -->
{#if loading}
<div class="space-y-1.5">
{#each Array(5) as _}
<div class="bg-[var(--surface)] rounded-lg p-3 border border-[var(--border)] animate-pulse h-14"></div>
{/each}
</div>
{:else if items.length === 0}
<div class="text-center py-12 text-[var(--text-dim)]">
{#if searchQuery}
<p class="text-sm mb-2">'{searchQuery}'에 대한 결과가 없습니다</p>
<button onclick={clearAllFilters} class="text-xs text-[var(--accent)] hover:underline">필터 초기화</button>
{:else if hasActiveFilters}
<p class="text-sm mb-2">이 분류에 문서가 없습니다</p>
<button onclick={clearAllFilters} class="text-xs text-[var(--accent)] hover:underline">필터 초기화</button>
{:else}
<p class="text-sm">등록된 문서가 없습니다</p>
{/if}
</div>
{:else}
<div class="space-y-1">
{#each items as doc}
<DocumentCard
{doc}
showDomain={!filterDomain}
selected={selectedDoc?.id === doc.id}
onselect={selectDoc}
/>
{/each}
</div>
{#if !searchResults && totalPages > 1}
<div class="flex justify-center gap-1 mt-4">
{#each Array(totalPages) as _, i}
<button
onclick={() => changePage(i + 1)}
class="px-2.5 py-0.5 rounded text-xs transition-colors
{currentPage === i + 1 ? 'bg-[var(--accent)] text-white' : 'bg-[var(--surface)] text-[var(--text-dim)] hover:text-[var(--text)]'}"
>
{i + 1}
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>
<!-- 하단: 뷰어 (70%, 전체 너비) -->
{#if selectedDoc}
<div class="flex-1 min-h-0">
<DocumentViewer doc={selectedDoc} />
</div>
{/if}
</div>
<!-- 정보 패널: 우측 전체 높이 drawer -->
{#if infoPanelOpen && selectedDoc}
<div class="fixed inset-0 z-40">
<button
onclick={() => infoPanelOpen = false}
class="absolute inset-0 bg-black/40"
aria-label="정보 패널 닫기"
></button>
<div class="absolute right-0 top-0 bottom-0 z-50 w-[320px] shadow-xl">
<PreviewPanel doc={selectedDoc} onclose={() => infoPanelOpen = false} ondelete={() => { selectedDoc = null; infoPanelOpen = false; loadDocuments(); }} />
</div>
</div>
{/if}

View File

@@ -0,0 +1,150 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api, getAccessToken } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import TagPill from '$lib/components/TagPill.svelte';
let doc = null;
let loading = true;
$: docId = $page.params.id;
onMount(async () => {
try {
doc = await api(`/documents/${docId}`);
} catch (err) {
addToast('error', '문서를 찾을 수 없습니다');
} finally {
loading = false;
}
});
// 포맷별 뷰어 타입
$: viewerType = doc ? getViewerType(doc.file_format) : 'none';
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
if (['hwp', 'hwpx'].includes(format)) return 'hwp-markdown';
if (['odoc', 'osheet'].includes(format)) return 'synology';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
return 'unsupported';
}
</script>
<div class="p-4 lg:p-6">
<!-- breadcrumb -->
<div class="flex items-center gap-2 text-sm mb-4 text-[var(--text-dim)]">
<a href="/documents" class="hover:text-[var(--text)]">문서</a>
<span>/</span>
<span class="truncate max-w-md text-[var(--text)]">{doc?.title || '로딩...'}</span>
</div>
{#if loading}
<div class="max-w-6xl mx-auto p-6">
<div class="bg-[var(--surface)] rounded-xl p-6 border border-[var(--border)] animate-pulse h-96"></div>
</div>
{:else if doc}
<div class="max-w-6xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 뷰어 (2/3) -->
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
<div class="prose prose-invert prose-sm max-w-none">
{@html marked(doc.extracted_text || '*텍스트 추출 대기 중*')}
</div>
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
class="w-full h-[80vh] rounded"
title={doc.title}
></iframe>
{:else if viewerType === 'image'}
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
{:else if viewerType === 'synology'}
<div class="text-center py-10">
<p class="text-[var(--text-dim)] mb-4">Synology Office 문서</p>
<a
href="https://ds1525.hyungi.net:15001/oo/r/{doc.id}"
target="_blank"
class="px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
>
새 창에서 열기
</a>
</div>
{:else}
<div class="text-center py-10">
<p class="text-[var(--text-dim)] mb-2">이 문서 형식은 인앱 미리보기를 지원하지 않습니다</p>
<p class="text-xs text-[var(--text-dim)]">포맷: {doc.file_format}</p>
</div>
{/if}
</div>
<!-- 메타데이터 패널 (1/3) -->
<div class="space-y-4">
<!-- 기본 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">문서 정보</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">포맷</dt>
<dd class="uppercase">{doc.file_format}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">크기</dt>
<dd>{doc.file_size ? (doc.file_size / 1024).toFixed(1) + ' KB' : '-'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">도메인</dt>
<dd>{doc.ai_domain || '미분류'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">출처</dt>
<dd>{doc.source_channel || '-'}</dd>
</div>
</dl>
</div>
<!-- AI 요약 -->
{#if doc.ai_summary}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">AI 요약</h3>
<p class="text-sm leading-relaxed">{doc.ai_summary}</p>
</div>
{/if}
<!-- 태그 -->
{#if doc.ai_tags?.length > 0}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">태그</h3>
<div class="flex flex-wrap gap-1.5">
{#each doc.ai_tags as tag}
<TagPill {tag} />
{/each}
</div>
</div>
{/if}
<!-- 가공 이력 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">가공 이력</h3>
<dl class="space-y-2 text-xs">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">텍스트 추출</dt>
<dd>{doc.extracted_at ? new Date(doc.extracted_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">AI 분류</dt>
<dd>{doc.ai_processed_at ? new Date(doc.ai_processed_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">벡터 임베딩</dt>
<dd>{doc.embedded_at ? new Date(doc.embedded_at).toLocaleDateString('ko') : '대기'}</dd>
</div>
</dl>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,190 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
let documents = [];
let loading = true;
let selected = new Set();
onMount(loadInbox);
async function loadInbox() {
loading = true;
try {
// Inbox 파일만 필터
const data = await api('/documents/?page_size=100');
documents = data.items.filter(d => !d.ai_domain);
} catch (err) {
addToast('error', 'Inbox 로딩 실패');
} finally {
loading = false;
}
}
function toggleSelect(id) {
if (selected.has(id)) selected.delete(id);
else selected.add(id);
selected = selected; // 반응성 트리거
}
function toggleAll() {
if (selected.size === documents.length) {
selected = new Set();
} else {
selected = new Set(documents.map(d => d.id));
}
}
let approving = false;
let showConfirm = false;
function startApprove() {
if (selected.size === 0) {
addToast('warning', '선택된 문서가 없습니다');
return;
}
showConfirm = true;
}
async function confirmApprove() {
showConfirm = false;
approving = true;
let success = 0;
const ids = [...selected];
for (const id of ids) {
try {
// AI 분류 결과 그대로 승인 (Inbox에서 이동은 classify_worker가 처리)
const doc = documents.find(d => d.id === id);
if (doc?.ai_domain) {
await api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ source_channel: 'inbox_route' }),
});
success++;
}
} catch { /* skip */ }
}
addToast('success', `${success}건 승인 완료`);
selected = new Set();
approving = false;
loadInbox();
}
async function updateDomain(id, domain) {
try {
await api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_domain: domain }),
});
documents = documents.map(d => d.id === id ? { ...d, ai_domain: domain } : d);
addToast('success', '도메인 변경됨');
} catch {
addToast('error', '변경 실패');
}
}
const DOMAINS = [
'Knowledge/Philosophy',
'Knowledge/Language',
'Knowledge/Engineering',
'Knowledge/Industrial_Safety',
'Knowledge/Programming',
'Knowledge/General',
'Reference',
];
</script>
<div class="p-4 lg:p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Inbox</h2>
<span class="text-xs px-2 py-0.5 rounded-full bg-[var(--warning)] text-black">{documents.length}</span>
</div>
<div class="flex items-center gap-2">
<button onclick={toggleAll} class="px-3 py-1.5 text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg">
{selected.size === documents.length ? '전체 해제' : '전체 선택'}
</button>
<button
onclick={startApprove}
disabled={approving || selected.size === 0}
class="px-4 py-1.5 text-xs bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
>
{approving ? '처리 중...' : `선택 승인 (${selected.size})`}
</button>
</div>
</div>
<div class="max-w-6xl mx-auto p-6">
{#if loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="bg-[var(--surface)] rounded-lg p-4 border border-[var(--border)] animate-pulse h-24"></div>
{/each}
</div>
{:else if documents.length === 0}
<div class="text-center py-20 text-[var(--text-dim)]">
<p class="text-lg">Inbox가 비어 있습니다</p>
<p class="text-sm mt-1">새 파일이 들어오면 자동으로 표시됩니다</p>
</div>
{:else}
<div class="space-y-3">
{#each documents as doc}
<div class="flex items-start gap-3 p-4 bg-[var(--surface)] border border-[var(--border)] rounded-lg" class:border-[var(--accent)]={selected.has(doc.id)}>
<input
type="checkbox"
checked={selected.has(doc.id)}
onchange={() => toggleSelect(doc.id)}
class="mt-1 accent-[var(--accent)]"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
<a href="/documents/{doc.id}" class="text-sm font-medium hover:text-[var(--accent)] truncate">{doc.title || '제목 없음'}</a>
</div>
{#if doc.ai_summary}
<p class="text-xs text-[var(--text-dim)] truncate">{doc.ai_summary.slice(0, 120)}</p>
{/if}
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-[var(--text-dim)]">AI 분류:</span>
<select
value={doc.ai_domain || ''}
onchange={(e) => updateDomain(doc.id, e.target.value)}
class="text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--text)]"
>
<option value="">미분류</option>
{#each DOMAINS as d}
<option value={d}>{d.replace('Knowledge/', '')}</option>
{/each}
</select>
{#if doc.ai_tags?.length > 0}
<div class="flex gap-1 ml-2">
{#each doc.ai_tags.slice(0, 3) as tag}
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg)] rounded text-[var(--accent)]">{tag}</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- 확인 다이얼로그 -->
{#if showConfirm}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold mb-2">{selected.size}건을 승인합니다</h3>
<p class="text-sm text-[var(--text-dim)] mb-4">AI 분류 결과를 확정하고 Inbox에서 이동합니다.</p>
<div class="flex gap-2 justify-end">
<button onclick={() => showConfirm = false} class="px-4 py-2 text-sm bg-[var(--bg)] border border-[var(--border)] rounded-lg">취소</button>
<button onclick={confirmApprove} class="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded-lg">승인</button>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,89 @@
<script>
import { goto } from '$app/navigation';
import { login } from '$lib/stores/auth';
import { addToast } from '$lib/stores/ui';
let username = '';
let password = '';
let totpCode = '';
let needsTotp = false;
let loading = false;
let error = '';
async function handleLogin() {
error = '';
loading = true;
try {
await login(username, password, needsTotp ? totpCode : undefined);
goto('/');
} catch (err) {
if (err.detail?.includes('TOTP')) {
needsTotp = true;
error = 'TOTP 코드를 입력하세요';
} else {
error = err.detail || '로그인 실패';
}
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<h1 class="text-2xl font-bold mb-1">hyungi Document Server</h1>
<p class="text-[var(--text-dim)] text-sm mb-8">로그인</p>
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
<div>
<label for="username" class="block text-sm text-[var(--text-dim)] mb-1">아이디</label>
<input
id="username"
type="text"
bind:value={username}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none"
autocomplete="username"
/>
</div>
<div>
<label for="password" class="block text-sm text-[var(--text-dim)] mb-1">비밀번호</label>
<input
id="password"
type="password"
bind:value={password}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none"
autocomplete="current-password"
/>
</div>
{#if needsTotp}
<div>
<label for="totp" class="block text-sm text-[var(--text-dim)] mb-1">TOTP 코드</label>
<input
id="totp"
type="text"
bind:value={totpCode}
maxlength="6"
inputmode="numeric"
pattern="[0-9]*"
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none tracking-widest text-center text-lg"
autocomplete="one-time-code"
/>
</div>
{/if}
{#if error}
<p class="text-[var(--error)] text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full py-2.5 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded-lg font-medium disabled:opacity-50 transition-colors"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,83 @@
<script>
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { user } from '$lib/stores/auth';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let changing = false;
async function changePassword() {
if (newPassword !== confirmPassword) {
addToast('error', '새 비밀번호가 일치하지 않습니다');
return;
}
if (newPassword.length < 8) {
addToast('error', '비밀번호는 8자 이상이어야 합니다');
return;
}
changing = true;
try {
await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
addToast('success', '비밀번호가 변경되었습니다');
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (err) {
addToast('error', err.detail || '비밀번호 변경 실패');
} finally {
changing = false;
}
}
</script>
<div class="p-4 lg:p-6">
<div class="max-w-lg mx-auto">
<!-- 계정 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 mb-6">
<h2 class="text-lg font-semibold mb-3">계정 정보</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">아이디</dt>
<dd>{$user?.username}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">2FA (TOTP)</dt>
<dd class={$user?.totp_enabled ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>
{$user?.totp_enabled ? '활성' : '비활성'}
</dd>
</div>
</dl>
</div>
<!-- 비밀번호 변경 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h2 class="text-lg font-semibold mb-3">비밀번호 변경</h2>
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">현재 비밀번호</label>
<input type="password" bind:value={currentPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호</label>
<input type="password" bind:value={newPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호 확인</label>
<input type="password" bind:value={confirmPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<button type="submit" disabled={changing} class="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg disabled:opacity-50">
{changing ? '변경 중...' : '비밀번호 변경'}
</button>
</form>
</div>
</div>
</div>

10
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
});

View File

@@ -0,0 +1,36 @@
# ═══════════════════════════════════════════════════
# 이 파일은 더 이상 사용하지 않음.
# 루트 docker-compose.yml로 통합됨 (2026-04-03).
# ═══════════════════════════════════════════════════
services:
ollama:
image: ollama/ollama
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "11434:11434"
restart: unless-stopped
ai-gateway:
build: ./services/ai-gateway
ports:
- "8080:8080"
environment:
- PRIMARY_ENDPOINT=${PRIMARY_ENDPOINT:-http://mac-mini:8800/v1/chat/completions}
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
depends_on:
- ollama
restart: unless-stopped
volumes:
ollama_data:

View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
httpx>=0.27.0

View File

@@ -0,0 +1,58 @@
"""AI Gateway — 모델 라우팅, 폴백, 비용 제어, 요청 로깅"""
import os
from datetime import date
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import httpx
app = FastAPI(title="AI Gateway", version="1.0.0")
PRIMARY = os.getenv("PRIMARY_ENDPOINT", "http://localhost:8800/v1/chat/completions")
FALLBACK = os.getenv("FALLBACK_ENDPOINT", "http://localhost:11434/v1/chat/completions")
CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY", "")
DAILY_BUDGET = float(os.getenv("DAILY_BUDGET_USD", "5.00"))
# 일일 비용 추적 (메모리, 재시작 시 리셋)
_daily_cost: dict[str, float] = {}
_http = httpx.AsyncClient(timeout=120)
@app.get("/health")
async def health():
return {"status": "ok", "service": "ai-gateway"}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
"""OpenAI 호환 채팅 엔드포인트 — 자동 폴백"""
body = await request.json()
tier = request.headers.get("x-model-tier", "primary")
if tier == "premium":
return await _call_premium(body)
# Primary → Fallback 폴백
try:
resp = await _http.post(PRIMARY, json=body, timeout=60)
resp.raise_for_status()
return JSONResponse(content=resp.json())
except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPStatusError):
# 폴백
resp = await _http.post(FALLBACK, json=body, timeout=120)
resp.raise_for_status()
return JSONResponse(content=resp.json())
async def _call_premium(body: dict):
"""Claude API 호출 — 비용 제어"""
today = date.today().isoformat()
if _daily_cost.get(today, 0) >= DAILY_BUDGET:
raise HTTPException(429, f"일일 예산 초과: ${DAILY_BUDGET}")
if not CLAUDE_API_KEY:
raise HTTPException(503, "CLAUDE_API_KEY 미설정")
# TODO: Anthropic API 호출 + 비용 계산 (Phase 3에서 구현)
raise HTTPException(501, "Premium 모델 호출은 Phase 3에서 구현")

View File

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

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.hyungi.pkm.daily-digest</string>
<key>ProgramArguments</key>
<array>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/pkm_daily_digest.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>20</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/digest_launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/digest_launchd_err.log</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.hyungi.pkm.law-monitor</string>
<key>ProgramArguments</key>
<array>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/law_monitor.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/law_monitor_launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/law_monitor_launchd_err.log</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.hyungi.pkm.mailplus</string>
<key>ProgramArguments</key>
<array>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/venv/bin/python3</string>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/scripts/mailplus_archive.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server</string>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>18</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
<key>StandardOutPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/mailplus_launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/hyungi/Documents/code/DEVONThink_my server/logs/mailplus_launchd_err.log</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,117 @@
-- hyungi_Document_Server 초기 스키마
-- PostgreSQL 16 + pgvector + pg_trgm
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- users 테이블 (단일 관리자)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
totp_secret VARCHAR(64),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
-- ENUM 타입
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 TYPE process_stage AS ENUM ('extract', 'classify', 'embed');
CREATE TYPE process_status AS ENUM ('pending', 'processing', 'completed', 'failed');
-- documents 테이블
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
-- 1계층: 원본 파일 참조
file_path TEXT NOT NULL UNIQUE,
file_hash CHAR(64) NOT NULL,
file_format VARCHAR(20) NOT NULL,
file_size BIGINT,
file_type doc_type NOT NULL DEFAULT 'immutable',
import_source TEXT,
-- 2계층: 텍스트 추출
extracted_text TEXT,
extracted_at TIMESTAMPTZ,
extractor_version VARCHAR(50),
-- 2계층: AI 가공
ai_summary TEXT,
ai_tags JSONB DEFAULT '[]',
ai_domain VARCHAR(100),
ai_sub_group VARCHAR(100),
ai_model_version VARCHAR(50),
ai_processed_at TIMESTAMPTZ,
-- 3계층: 벡터 임베딩
embedding vector(768),
embed_model_version VARCHAR(50),
embedded_at TIMESTAMPTZ,
-- 메타데이터
source_channel source_channel,
data_origin data_origin,
title TEXT,
-- 타임스탬프
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_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 캐시)
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
caldav_uid TEXT UNIQUE,
title TEXT NOT NULL,
description TEXT,
due_date TIMESTAMPTZ,
priority SMALLINT DEFAULT 0,
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMPTZ,
document_id BIGINT REFERENCES documents(id),
source VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- processing_queue 테이블 (비동기 가공 큐)
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';

View File

@@ -0,0 +1,11 @@
-- 벡터 유사도 인덱스 (코사인 거리)
-- 주의: lists 값은 문서 수에 따라 조정 필요
-- 문서 수 < 1,000: 인덱스 불필요 (seq scan이 더 빠름)
-- 문서 수 1,000~10,000: lists = 문서수 / 50
-- 문서 수 10,000+: lists = 문서수 / 100
-- 초기 마이그레이션 후 문서 수 확인하여 lists 값 조정할 것
-- 최초 실행 시 lists=50으로 시작 (500~2,500건 최적)
CREATE INDEX IF NOT EXISTS idx_documents_embedding
ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 50);

View File

@@ -0,0 +1,8 @@
-- 자동화 워커 상태 저장 (증분 동기화용)
CREATE TABLE automation_state (
id BIGSERIAL PRIMARY KEY,
job_name VARCHAR(50) NOT NULL UNIQUE,
last_check_value TEXT,
last_run_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);

View File

@@ -0,0 +1,2 @@
-- 사용자 메모 컬럼 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_note TEXT;

View File

@@ -0,0 +1,4 @@
-- 문서 미리보기 상태 필드 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS preview_status VARCHAR(20) DEFAULT 'none';
ALTER TABLE documents ADD COLUMN IF NOT EXISTS preview_hash VARCHAR(64);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS preview_at TIMESTAMPTZ;

View File

@@ -0,0 +1,2 @@
-- 외부 편집 URL (Synology Drive 공유 링크 등)
ALTER TABLE documents ADD COLUMN IF NOT EXISTS edit_url TEXT;

View File

@@ -0,0 +1,5 @@
-- 원본/변환 분리 필드 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_path TEXT;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_format VARCHAR(20);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS original_hash VARCHAR(64);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS conversion_status VARCHAR(20) DEFAULT 'none';

View File

@@ -0,0 +1,4 @@
-- 분류 체계 확장 필드
ALTER TABLE documents ADD COLUMN IF NOT EXISTS document_type VARCHAR(50);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS importance VARCHAR(20) DEFAULT 'medium';
ALTER TABLE documents ADD COLUMN IF NOT EXISTS ai_confidence FLOAT;

View File

@@ -1,7 +0,0 @@
qdrant-client>=1.7.0
requests>=2.31.0
python-dotenv>=1.0.0
markdown>=3.5.0
anthropic>=0.40.0
flask>=3.0.0
gunicorn>=21.2.0

View File

@@ -1,359 +0,0 @@
#!/usr/bin/env python3
"""
DEVONthink 전체 문서 배치 임베딩
- DB별 순차 처리, 500건씩 AppleScript 배치 텍스트 추출
- GPU bge-m3 배치 임베딩 (32건/호출)
- Qdrant 배치 upsert (100건/호출)
- --sync: 삭제된 문서 Qdrant 정리
- --force: 전체 재임베딩
- --db: 특정 DB만 처리
사용법:
python3 batch_embed.py # 신규 문서만
python3 batch_embed.py --sync # 신규 + 삭제 동기화
python3 batch_embed.py --force # 전체 재임베딩
python3 batch_embed.py --db "04_Industrial safety"
"""
import argparse
import sys
import uuid as uuid_mod
import time
import requests
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger, load_credentials, run_applescript_inline
logger = setup_logger("batch_embed")
QDRANT_URL = "http://localhost:6333"
COLLECTION = "pkm_documents"
EMBED_BATCH_SIZE = 32
QDRANT_BATCH_SIZE = 100
APPLESCRIPT_CHUNK = 500
# --- GPU 헬스체크 ---
def check_gpu_health(gpu_ip: str) -> bool:
"""GPU bge-m3 API ping"""
try:
resp = requests.post(
f"http://{gpu_ip}:11434/api/embed",
json={"model": "bge-m3", "input": ["test"]},
timeout=10,
)
return resp.status_code == 200
except Exception:
return False
# --- Qdrant ---
def get_existing_uuids_from_qdrant() -> set[str]:
"""Qdrant에 이미 저장된 UUID 집합 조회"""
uuids = set()
offset = None
while True:
body = {"limit": 1000, "with_payload": {"include": ["uuid"]}}
if offset:
body["offset"] = offset
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
json=body, timeout=30,
)
resp.raise_for_status()
result = resp.json()["result"]
points = result.get("points", [])
for p in points:
uuid_val = p.get("payload", {}).get("uuid")
if uuid_val:
uuids.add(uuid_val)
offset = result.get("next_page_offset")
if not offset or not points:
break
return uuids
def delete_from_qdrant(point_ids: list[int]):
"""Qdrant에서 포인트 삭제"""
if not point_ids:
return
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/delete",
json={"points": point_ids},
timeout=30,
)
resp.raise_for_status()
def uuid_to_point_id(doc_uuid: str) -> int:
return uuid_mod.uuid5(uuid_mod.NAMESPACE_URL, doc_uuid).int >> 64
def store_batch_in_qdrant(docs: list[dict]):
"""Qdrant 배치 upsert"""
if not docs:
return
points = []
for doc in docs:
points.append({
"id": uuid_to_point_id(doc["uuid"]),
"vector": doc["embedding"],
"payload": {
"uuid": doc["uuid"],
"title": doc["title"],
"db_name": doc.get("db_name", ""),
"text_preview": doc.get("text", "")[:200],
"source": "devonthink",
"embedded_at": datetime.now().isoformat(),
},
})
for i in range(0, len(points), QDRANT_BATCH_SIZE):
batch = points[i:i + QDRANT_BATCH_SIZE]
resp = requests.put(
f"{QDRANT_URL}/collections/{COLLECTION}/points",
json={"points": batch},
timeout=60,
)
resp.raise_for_status()
# --- GPU 임베딩 ---
def get_embeddings_batch(texts: list[str], gpu_ip: str) -> list[list[float]]:
"""GPU bge-m3 배치 임베딩 (4000자 제한 — bge-m3 토큰 한도 고려)"""
truncated = [t[:4000] for t in texts]
resp = requests.post(
f"http://{gpu_ip}:11434/api/embed",
json={"model": "bge-m3", "input": truncated},
timeout=120,
)
resp.raise_for_status()
return resp.json().get("embeddings", [])
# --- DEVONthink 텍스트 추출 ---
def get_db_names() -> list[str]:
"""DEVONthink DB 이름 목록"""
script = '''
tell application id "DNtp"
set dbNames to {}
repeat with db in databases
set end of dbNames to name of db
end repeat
set AppleScript's text item delimiters to linefeed
return dbNames as text
end tell
'''
result = run_applescript_inline(script)
return [n.strip() for n in result.split("\n") if n.strip()]
def get_db_document_uuids(db_name: str) -> list[str]:
"""특정 DB의 임베딩 대상 UUID 목록 (그룹 제외, 텍스트 10자 이상)"""
script = f'''
tell application id "DNtp"
set theDB to database "{db_name}"
set allDocs to contents of theDB
set output to {{}}
repeat with rec in allDocs
try
set recType to type of rec as string
if recType is not "group" then
set recText to plain text of rec
if length of recText > 10 then
set end of output to uuid of rec
end if
end if
end try
end repeat
set AppleScript's text item delimiters to linefeed
return output as text
end tell
'''
try:
result = run_applescript_inline(script)
return [u.strip() for u in result.split("\n") if u.strip()]
except Exception as e:
logger.error(f"UUID 수집 실패 [{db_name}]: {e}")
return []
def get_documents_batch(uuids: list[str]) -> list[dict]:
"""UUID 리스트로 배치 텍스트 추출 (AppleScript 1회 호출)"""
if not uuids:
return []
# UUID를 AppleScript 리스트로 변환
uuid_list = ", ".join(f'"{u}"' for u in uuids)
script = f'''
tell application id "DNtp"
set uuidList to {{{uuid_list}}}
set output to {{}}
repeat with u in uuidList
try
set theRecord to get record with uuid u
set recText to plain text of theRecord
set recTitle to name of theRecord
set recDB to name of database of theRecord
if length of recText > 8000 then
set recText to text 1 thru 8000 of recText
end if
set end of output to u & "|||" & recTitle & "|||" & recDB & "|||" & recText
on error
set end of output to u & "|||ERROR|||||||"
end try
end repeat
set AppleScript's text item delimiters to linefeed & "<<<>>>" & linefeed
return output as text
end tell
'''
try:
result = run_applescript_inline(script)
except Exception as e:
logger.error(f"배치 텍스트 추출 실패: {e}")
return []
docs = []
for entry in result.split("\n<<<>>>\n"):
entry = entry.strip()
if not entry or "|||ERROR|||" in entry:
continue
parts = entry.split("|||", 3)
if len(parts) >= 4:
text = parts[3].strip()
if len(text) >= 10:
docs.append({
"uuid": parts[0].strip(),
"title": parts[1].strip(),
"db_name": parts[2].strip(),
"text": text,
})
return docs
# --- 메인 배치 ---
def run_batch(gpu_ip: str, target_db: str = None, force: bool = False, sync: bool = False):
"""배치 임베딩 실행"""
# GPU 헬스체크
if not check_gpu_health(gpu_ip):
logger.error(f"GPU 서버 연결 실패 ({gpu_ip}) — 종료")
sys.exit(1)
logger.info(f"GPU 서버 연결 확인: {gpu_ip}")
# 기존 임베딩 UUID 조회
existing_uuids = set()
if not force:
existing_uuids = get_existing_uuids_from_qdrant()
logger.info(f"Qdrant 기존 임베딩: {len(existing_uuids)}")
# DB 목록
db_names = [target_db] if target_db else get_db_names()
logger.info(f"처리 대상 DB: {db_names}")
total_embedded = 0
total_skipped = 0
total_failed = 0
all_dt_uuids = set()
for db_name in db_names:
logger.info(f"--- DB: {db_name} ---")
# UUID 수집
uuids = get_db_document_uuids(db_name)
all_dt_uuids.update(uuids)
logger.info(f" 문서: {len(uuids)}")
# 기존 스킵
if not force:
new_uuids = [u for u in uuids if u not in existing_uuids]
skipped = len(uuids) - len(new_uuids)
total_skipped += skipped
if skipped > 0:
logger.info(f" 스킵: {skipped}건 (이미 임베딩)")
uuids = new_uuids
if not uuids:
continue
# 500건씩 AppleScript 배치 텍스트 추출
for chunk_start in range(0, len(uuids), APPLESCRIPT_CHUNK):
chunk_uuids = uuids[chunk_start:chunk_start + APPLESCRIPT_CHUNK]
docs = get_documents_batch(chunk_uuids)
if not docs:
continue
# 32건씩 GPU 임베딩
for batch_start in range(0, len(docs), EMBED_BATCH_SIZE):
batch = docs[batch_start:batch_start + EMBED_BATCH_SIZE]
texts = [d["text"] for d in batch]
try:
embeddings = get_embeddings_batch(texts, gpu_ip)
if len(embeddings) != len(batch):
logger.warning(f"임베딩 수 불일치: {len(embeddings)} != {len(batch)}")
total_failed += len(batch)
continue
for doc, emb in zip(batch, embeddings):
doc["embedding"] = emb
store_batch_in_qdrant(batch)
total_embedded += len(batch)
except Exception as e:
logger.error(f"배치 임베딩 실패: {e}")
total_failed += len(batch)
progress = chunk_start + len(chunk_uuids)
logger.info(f" 진행: {progress}/{len(uuids)}")
# --sync: 고아 포인트 삭제
orphan_deleted = 0
if sync and all_dt_uuids:
orphan_uuids = existing_uuids - all_dt_uuids
if orphan_uuids:
orphan_ids = [uuid_to_point_id(u) for u in orphan_uuids]
delete_from_qdrant(orphan_ids)
orphan_deleted = len(orphan_uuids)
logger.info(f"고아 포인트 삭제: {orphan_deleted}")
# 통계
logger.info("=== 배치 임베딩 완료 ===")
logger.info(f" 임베딩: {total_embedded}")
logger.info(f" 스킵: {total_skipped}")
logger.info(f" 실패: {total_failed}")
if orphan_deleted:
logger.info(f" 고아 삭제: {orphan_deleted}")
# Qdrant 최종 카운트
try:
resp = requests.get(f"{QDRANT_URL}/collections/{COLLECTION}", timeout=10)
count = resp.json()["result"]["points_count"]
logger.info(f" Qdrant 총 포인트: {count}")
except Exception:
pass
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="DEVONthink 배치 임베딩")
parser.add_argument("--force", action="store_true", help="전체 재임베딩")
parser.add_argument("--sync", action="store_true", help="삭제 동기화 포함")
parser.add_argument("--db", type=str, help="특정 DB만 처리")
args = parser.parse_args()
creds = load_credentials()
gpu_ip = creds.get("GPU_SERVER_IP")
if not gpu_ip:
logger.error("GPU_SERVER_IP 미설정")
sys.exit(1)
run_batch(gpu_ip, target_db=args.db, force=args.force, sync=args.sync)

View File

@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""
벡터 임베딩 스크립트
- DEVONthink 문서 UUID로 텍스트 추출
- GPU 서버(bge-m3)로 임베딩 생성
- Qdrant에 저장
"""
import sys
import uuid as uuid_mod
import requests
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger, load_credentials, run_applescript_inline
logger = setup_logger("embed")
QDRANT_URL = "http://localhost:6333"
COLLECTION = "pkm_documents"
def get_document_text(doc_uuid: str) -> tuple[str, str]:
"""DEVONthink에서 UUID로 문서 텍스트 + 제목 추출"""
script = f'''
tell application id "DNtp"
set theRecord to get record with uuid "{doc_uuid}"
set docText to plain text of theRecord
set docTitle to name of theRecord
return docTitle & "|||" & docText
end tell
'''
result = run_applescript_inline(script)
parts = result.split("|||", 1)
title = parts[0] if len(parts) > 0 else ""
text = parts[1] if len(parts) > 1 else ""
return title, text
def get_embedding(text: str, gpu_server_ip: str) -> list[float] | None:
"""GPU 서버의 bge-m3로 임베딩 생성"""
url = f"http://{gpu_server_ip}:11434/api/embed"
try:
resp = requests.post(url, json={
"model": "bge-m3",
"input": [text[:8000]]
}, timeout=60)
resp.raise_for_status()
embeddings = resp.json().get("embeddings")
return embeddings[0] if embeddings else None
except Exception as e:
logger.error(f"임베딩 생성 실패: {e}")
return None
def store_in_qdrant(doc_uuid: str, title: str, text: str, embedding: list[float]):
"""Qdrant에 저장"""
# UUID 문자열을 정수 ID로 변환 (Qdrant point ID)
point_id = uuid_mod.uuid5(uuid_mod.NAMESPACE_URL, doc_uuid).int >> 64
payload = {
"uuid": doc_uuid,
"title": title,
"text_preview": text[:500],
"source": "devonthink",
}
resp = requests.put(
f"{QDRANT_URL}/collections/{COLLECTION}/points",
json={
"points": [{
"id": point_id,
"vector": embedding,
"payload": payload,
}]
},
timeout=30,
)
resp.raise_for_status()
logger.info(f"Qdrant 저장: {doc_uuid} ({title[:30]})")
def run(doc_uuid: str):
"""단일 문서 임베딩 처리"""
logger.info(f"임베딩 처리 시작: {doc_uuid}")
creds = load_credentials()
gpu_ip = creds.get("GPU_SERVER_IP")
if not gpu_ip:
logger.warning("GPU_SERVER_IP 미설정 — 임베딩 건너뜀")
return
try:
title, text = get_document_text(doc_uuid)
if not text or len(text) < 10:
logger.warning(f"텍스트 부족 [{doc_uuid}]: {len(text)}")
return
embedding = get_embedding(text, gpu_ip)
if embedding:
store_in_qdrant(doc_uuid, title, text, embedding)
logger.info(f"임베딩 완료: {doc_uuid}")
else:
logger.error(f"임베딩 실패: {doc_uuid}")
except Exception as e:
logger.error(f"임베딩 처리 에러 [{doc_uuid}]: {e}", exc_info=True)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("사용법: python3 embed_to_qdrant.py <DEVONthink_UUID>")
sys.exit(1)
run(sys.argv[1])

View File

@@ -1,575 +0,0 @@
#!/usr/bin/env python3
"""
법령 모니터링 스크립트
- 국가법령정보센터 OpenAPI (open.law.go.kr) 폴링
- 산업안전보건법, 중대재해처벌법 등 변경 추적
- 변경 감지 시 DEVONthink 04_Industrial Safety 자동 임포트
※ API 승인 대기중 — 스크립트만 작성, 실제 호출은 승인 후
"""
import os
import sys
import json
import requests
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger, load_credentials, run_applescript_inline, llm_generate, PROJECT_ROOT, DATA_DIR
from law_parser import (
parse_law_xml, save_law_as_markdown, build_article_chapter_map,
add_cross_law_links, lookup_current_mst, atomic_write_json,
)
logger = setup_logger("law_monitor")
MST_CACHE_FILE = DATA_DIR / "law_mst_cache.json"
MD_OUTPUT_DIR = DATA_DIR / "laws" / "md"
MD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Tier 1 — 필수 모니터링 (업무 직접 관련, 매일 확인)
TIER1_LAWS = [
# 산업안전 핵심
{"name": "산업안전보건법", "category": "법률"},
{"name": "산업안전보건법 시행령", "category": "대통령령"},
{"name": "산업안전보건법 시행규칙", "category": "부령"},
{"name": "중대재해 처벌 등에 관한 법률", "category": "법률"},
{"name": "중대재해 처벌 등에 관한 법률 시행령", "category": "대통령령"},
# 화학/위험물
{"name": "화학물질관리법", "category": "법률"},
{"name": "위험물안전관리법", "category": "법률"},
{"name": "고압가스 안전관리법", "category": "법률"},
# 전기/소방/건설
{"name": "전기안전관리법", "category": "법률"},
{"name": "소방시설 설치 및 관리에 관한 법률", "category": "법률"},
{"name": "건설기술 진흥법", "category": "법률"},
# 시설물/노동
{"name": "시설물의 안전 및 유지관리에 관한 특별법", "category": "법률"},
{"name": "근로기준법", "category": "법률"},
{"name": "산업재해보상보험법", "category": "법률"},
{"name": "근로자참여 및 협력증진에 관한 법률", "category": "법률"},
]
# Tier 2 — 참고 (기본 비활성, --include-tier2 또는 설정으로 활성화)
TIER2_LAWS = [
{"name": "원자력안전법", "category": "법률"},
{"name": "방사선안전관리법", "category": "법률"},
{"name": "환경영향평가법", "category": "법률"},
{"name": "석면안전관리법", "category": "법률"},
{"name": "승강기 안전관리법", "category": "법률"},
{"name": "연구실 안전환경 조성에 관한 법률", "category": "법률"},
{"name": "재난 및 안전관리 기본법", "category": "법률"},
{"name": "고용보험법", "category": "법률"},
]
# 마지막 확인 일자 저장 파일
LAST_CHECK_FILE = DATA_DIR / "law_last_check.json"
LAWS_DIR = DATA_DIR / "laws"
LAWS_DIR.mkdir(exist_ok=True)
def load_last_check() -> dict:
"""마지막 확인 일자 로딩"""
if LAST_CHECK_FILE.exists():
with open(LAST_CHECK_FILE, "r") as f:
return json.load(f)
return {}
def save_last_check(data: dict):
"""마지막 확인 일자 저장 (원자적 쓰기)"""
atomic_write_json(LAST_CHECK_FILE, data)
def fetch_law_info(law_oc: str, mst: str) -> dict | None:
"""법령 정보 조회 — lawService.do로 MST 직접 조회 (XML → 기본정보 추출)"""
url = "https://www.law.go.kr/DRF/lawService.do"
params = {
"OC": law_oc,
"target": "law",
"type": "XML",
"MST": mst,
}
try:
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
root = ET.fromstring(resp.content)
info_el = root.find(".//기본정보")
if info_el is None:
logger.warning(f"기본정보 없음 [MST={mst}]")
return None
return {
"법령명한글": (info_el.findtext("법령명_한글", "") or "").strip(),
"공포일자": (info_el.findtext("공포일자", "") or "").strip(),
"시행일자": (info_el.findtext("시행일자", "") or "").strip(),
"법령ID": (info_el.findtext("법령ID", "") or "").strip(),
"소관부처": (info_el.findtext("소관부처", "") or "").strip(),
}
except Exception as e:
logger.error(f"법령 조회 실패 [MST={mst}]: {e}")
return None
def fetch_law_text(law_oc: str, law_mst: str) -> str | None:
"""법령 본문 XML 다운로드"""
url = "https://www.law.go.kr/DRF/lawService.do"
params = {
"OC": law_oc,
"target": "law",
"type": "XML",
"MST": law_mst,
}
try:
resp = requests.get(url, params=params, timeout=60)
resp.raise_for_status()
return resp.text
except Exception as e:
logger.error(f"법령 본문 다운로드 실패 [{law_mst}]: {e}")
return None
def save_law_file(law_name: str, content: str) -> Path:
"""법령 XML 저장"""
today = datetime.now().strftime("%Y%m%d")
safe_name = law_name.replace(" ", "_").replace("/", "_")
filepath = LAWS_DIR / f"{safe_name}_{today}.xml"
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
logger.info(f"법령 저장: {filepath}")
return filepath
def import_law_to_devonthink(law_name: str, md_files: list[Path], category: str):
"""DEVONthink 04_Industrial Safety로 장별 MD 파일 임포트
3단계 교체: 기존 폴더 이동 → 신규 생성 → 구 폴더 삭제 (wiki-link 끊김 최소화)
"""
safe_name = law_name.replace(" ", "_")
group_path = f"/10_Legislation/Law/{safe_name}"
# 1단계: 기존 폴더 이동 (있으면)
rename_script = (
'tell application id "DNtp"\n'
' repeat with db in databases\n'
' if name of db is "04_Industrial safety" then\n'
f' set oldGroup to get record at "{group_path}" in db\n'
' if oldGroup is not missing value then\n'
f' set name of oldGroup to "{safe_name}_old"\n'
' end if\n'
' exit repeat\n'
' end if\n'
' end repeat\n'
'end tell'
)
try:
run_applescript_inline(rename_script)
except Exception:
pass # 기존 폴더 없으면 무시
# 2단계: 신규 폴더 생성 + 파일 임포트
for filepath in md_files:
fp = str(filepath)
script = f'set fp to "{fp}"\n'
script += 'tell application id "DNtp"\n'
script += ' repeat with db in databases\n'
script += ' if name of db is "04_Industrial safety" then\n'
script += f' set targetGroup to create location "{group_path}" in db\n'
script += ' set theRecord to import fp to targetGroup\n'
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n'
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
script += ' exit repeat\n'
script += ' end if\n'
script += ' end repeat\n'
script += 'end tell'
try:
run_applescript_inline(script)
except Exception as e:
logger.error(f"DEVONthink 임포트 실패 [{filepath.name}]: {e}")
# 3단계: 구 폴더 삭제
delete_script = (
'tell application id "DNtp"\n'
' repeat with db in databases\n'
' if name of db is "04_Industrial safety" then\n'
f' set oldGroup to get record at "/10_Legislation/Law/{safe_name}_old" in db\n'
' if oldGroup is not missing value then\n'
' delete record oldGroup\n'
' end if\n'
' exit repeat\n'
' end if\n'
' end repeat\n'
'end tell'
)
try:
run_applescript_inline(delete_script)
except Exception:
pass
logger.info(f"DEVONthink 임포트 완료: {law_name} ({len(md_files)}개 파일)")
def _fetch_with_retry(func, *args, retries=3, backoff=(5, 15, 30)):
"""API 호출 재시도 래퍼"""
import time
for i in range(retries):
result = func(*args)
if result is not None:
return result
if i < retries - 1:
logger.warning(f"재시도 {i+2}/{retries} ({backoff[i]}초 후)")
time.sleep(backoff[i])
return None
def run(include_tier2: bool = False):
"""메인 실행 — MST 자동 조회 + 장 단위 MD 분할 + DEVONthink 임포트"""
logger.info("=== 법령 모니터링 시작 ===")
creds = load_credentials()
law_oc = creds.get("LAW_OC")
if not law_oc:
logger.error("LAW_OC 인증키가 설정되지 않았습니다. credentials.env를 확인하세요.")
sys.exit(1)
laws = TIER1_LAWS + (TIER2_LAWS if include_tier2 else [])
last_check = load_last_check()
changes_found = 0
failures = []
parsed_laws = {} # 크로스 링크 2-pass용
for law in laws:
law_name = law["name"]
category = law["category"]
# MST 자동 조회 (캐시 TTL 7일)
mst = lookup_current_mst(law_oc, law_name, category, cache_path=MST_CACHE_FILE)
if not mst:
failures.append({"name": law_name, "error": "MST 조회 실패"})
continue
logger.info(f"확인 중: {law_name} (MST={mst})")
# XML 한 번에 다운로드 (정보 추출 + 파싱 겸용)
xml_text = _fetch_with_retry(fetch_law_text, law_oc, mst)
if not xml_text:
failures.append({"name": law_name, "error": "XML 다운로드 실패"})
continue
# XML에서 기본정보 추출
try:
root = ET.fromstring(xml_text)
info_el = root.find(".//기본정보")
returned_name = (info_el.findtext("법령명_한글", "") or "").strip() if info_el else ""
except Exception:
failures.append({"name": law_name, "error": "XML 파싱 실패"})
continue
# 법령명 검증
if law_name not in returned_name and returned_name not in law_name:
logger.warning(f"법령명 불일치: 요청='{law_name}' 응답='{returned_name}' — 스킵")
failures.append({"name": law_name, "error": f"법령명 불일치: {returned_name}"})
continue
# 공포일자로 변경 감지
announce_date = (info_el.findtext("공포일자", "") or "").strip() if info_el else ""
prev_date = last_check.get(law_name, "")
if announce_date and announce_date != prev_date:
logger.info(f"변경 감지: {law_name} — 공포일자 {announce_date} (이전: {prev_date or '없음'})")
# XML 저장
xml_path = save_law_file(law_name, xml_text)
# Pass 1: XML 파싱 + 장 분할 MD 저장 (내부 링크만)
try:
parsed = parse_law_xml(str(xml_path))
md_files = save_law_as_markdown(law_name, parsed, MD_OUTPUT_DIR)
# 크로스 링크용 매핑 수집
parsed_laws[law_name] = {
"parsed": parsed,
"md_files": md_files,
"category": category,
"article_map": build_article_chapter_map(law_name, parsed),
}
changes_found += 1
except Exception as e:
logger.error(f"법령 파싱/임포트 실패 [{law_name}]: {e}", exc_info=True)
failures.append({"name": law_name, "error": str(e)})
continue
last_check[law_name] = announce_date
else:
# 변경 없어도 기존 파싱 데이터로 매핑 수집 (크로스 링크용)
xml_path = LAWS_DIR / f"{law_name.replace(' ', '_').replace('/', '_')}_{datetime.now().strftime('%Y%m%d')}.xml"
if not xml_path.exists():
# 오늘 날짜 파일이 없으면 가장 최근 파일 찾기
candidates = sorted(LAWS_DIR.glob(f"{law_name.replace(' ', '_').replace('/', '_')}_*.xml"))
xml_path = candidates[-1] if candidates else None
if xml_path and xml_path.exists():
try:
parsed = parse_law_xml(str(xml_path))
parsed_laws[law_name] = {
"parsed": parsed,
"md_files": [],
"category": category,
"article_map": build_article_chapter_map(law_name, parsed),
}
except Exception:
pass
# Pass 2: 크로스 링크 일괄 적용 (변경된 법령만)
if parsed_laws:
# 전체 조문-장 매핑 테이블
global_article_map = {name: data["article_map"] for name, data in parsed_laws.items()}
changed_laws = {name: data for name, data in parsed_laws.items() if data["md_files"]}
if changed_laws and len(global_article_map) > 1:
logger.info(f"크로스 링크 적용: {len(changed_laws)}개 법령, 매핑 {len(global_article_map)}")
for law_name, data in changed_laws.items():
for md_file in data["md_files"]:
if md_file.name == "00_기본정보.md" or md_file.name == "부칙.md":
continue
content = md_file.read_text(encoding="utf-8")
updated = add_cross_law_links(content, law_name, global_article_map)
if updated != content:
md_file.write_text(updated, encoding="utf-8")
# DEVONthink 임포트 (크로스 링크 적용 후)
for law_name, data in changed_laws.items():
if data["md_files"]:
import_law_to_devonthink(law_name, data["md_files"], data["category"])
save_last_check(last_check)
# 실행 결과 기록
run_result = {
"timestamp": datetime.now().isoformat(),
"total": len(laws),
"changes": changes_found,
"failures": failures,
}
atomic_write_json(DATA_DIR / "law_last_run.json", run_result)
if failures:
logger.warning(f"실패 {len(failures)}건: {[f['name'] for f in failures]}")
# ─── 외국 법령 (빈도 체크 후 실행) ───
us_count = fetch_us_osha(last_check)
jp_count = fetch_jp_mhlw(last_check)
eu_count = fetch_eu_osha(last_check)
changes_found += us_count + jp_count + eu_count
save_last_check(last_check)
logger.info(f"=== 법령 모니터링 완료 — {changes_found}건 변경 감지 (한국+외국) ===")
# ═══════════════════════════════════════════════
# 외국 법령 모니터링
# ═══════════════════════════════════════════════
def _should_run(last_check: dict, key: str, interval_days: int) -> bool:
"""빈도 체크: 마지막 실행일로부터 interval_days 경과 여부"""
last_run = last_check.get(key, "")
if not last_run:
return True
try:
last_date = datetime.strptime(last_run, "%Y-%m-%d")
return (datetime.now() - last_date).days >= interval_days
except ValueError:
return True
def _import_foreign_to_devonthink(filepath: Path, title: str, country: str):
"""외국 법령 DEVONthink 임포트 — 변수 방식 (POSIX path 따옴표 문제 회피)"""
folder = {"US": "US", "JP": "JP", "EU": "EU"}.get(country, country)
fp = str(filepath)
script = f'set fp to "{fp}"\n'
script += 'tell application id "DNtp"\n'
script += ' repeat with db in databases\n'
script += ' if name of db is "04_Industrial safety" then\n'
script += f' set targetGroup to create location "/10_Legislation/Foreign/{folder}" in db\n'
script += ' set theRecord to import fp to targetGroup\n'
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{country}"}}\n'
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
script += ' exit repeat\n'
script += ' end if\n'
script += ' end repeat\n'
script += 'end tell'
try:
run_applescript_inline(script)
safe_title = title[:40].replace('\n', ' ')
logger.info(f"DEVONthink 임포트 [{country}]: {safe_title}")
except Exception as e:
logger.error(f"DEVONthink 임포트 실패 [{country}]: {e}")
def fetch_us_osha(last_check: dict) -> int:
"""US OSHA — Federal Register API (주 1회)"""
if not _should_run(last_check, "_us_osha_last", 7):
logger.debug("US OSHA: 이번 주 이미 실행됨, 건너뜀")
return 0
logger.info("=== US OSHA 확인 ===")
try:
from_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
resp = requests.get("https://www.federalregister.gov/api/v1/documents.json", params={
"conditions[agencies][]": "occupational-safety-and-health-administration",
"conditions[publication_date][gte]": from_date,
"per_page": 10,
"order": "newest",
}, timeout=30)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
count = 0
for doc in results:
doc_id = doc.get("document_number", "")
title = doc.get("title", "")
pub_date = doc.get("publication_date", "")
abstract = doc.get("abstract", "")
doc_url = doc.get("html_url", "")
# 마크다운으로 저장
content = f"# {title}\n\n"
content += f"- **Document**: {doc_id}\n"
content += f"- **Date**: {pub_date}\n"
content += f"- **URL**: {doc_url}\n\n"
if abstract:
content += f"## Abstract\n\n{abstract}\n"
safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in title)[:50]
filepath = LAWS_DIR / f"US_OSHA_{pub_date}_{safe_title}.md"
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
_import_foreign_to_devonthink(filepath, title, "US")
count += 1
last_check["_us_osha_last"] = datetime.now().strftime("%Y-%m-%d")
logger.info(f"US OSHA: {count}")
return count
except Exception as e:
logger.error(f"US OSHA 에러: {e}", exc_info=True)
return 0
def fetch_jp_mhlw(last_check: dict) -> int:
"""JP 厚生労働省 — RSS 파싱 + MLX 번역 (주 1회)"""
if not _should_run(last_check, "_jp_mhlw_last", 7):
logger.debug("JP 厚労省: 이번 주 이미 실행됨, 건너뜀")
return 0
logger.info("=== JP 厚生労働省 확인 ===")
try:
import xml.etree.ElementTree as ET
resp = requests.get("https://www.mhlw.go.jp/stf/news.rdf", timeout=30)
resp.raise_for_status()
root = ET.fromstring(resp.content)
safety_keywords = ["労働安全", "安全衛生", "労災", "化学物質", "石綿", "安全管理", "労働", "安全", "衛生"]
rss_ns = "http://purl.org/rss/1.0/"
count = 0
# RDF 1.0 형식: {http://purl.org/rss/1.0/}item
items = root.findall(f"{{{rss_ns}}}item")
logger.info(f"JP RSS 항목: {len(items)}")
for item in items:
title = item.findtext(f"{{{rss_ns}}}title", "")
link = item.findtext(f"{{{rss_ns}}}link", "")
pub_date = item.findtext("pubDate", "")
# 안전위생 키워드 필터
if not any(kw in title for kw in safety_keywords):
continue
# MLX 35B로 한국어 번역
translated = ""
try:
translated = llm_generate(
f"다음 일본어 제목을 한국어로 번역해줘. 번역만 출력하고 다른 말은 하지 마.\n\n{title}",
no_think=True
)
except Exception:
translated = title
content = f"# {title}\n\n"
content += f"**한국어**: {translated}\n\n"
content += f"- **URL**: {link}\n"
content += f"- **Date**: {pub_date}\n"
safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in title)[:40]
today = datetime.now().strftime("%Y%m%d")
filepath = LAWS_DIR / f"JP_{today}_{safe_title}.md"
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
_import_foreign_to_devonthink(filepath, f"{translated} ({title})", "JP")
count += 1
if count >= 10:
break
last_check["_jp_mhlw_last"] = datetime.now().strftime("%Y-%m-%d")
logger.info(f"JP 厚労省: {count}")
return count
except Exception as e:
logger.error(f"JP 厚労省 에러: {e}", exc_info=True)
return 0
def fetch_eu_osha(last_check: dict) -> int:
"""EU-OSHA — RSS 파싱 (월 1회)"""
if not _should_run(last_check, "_eu_osha_last", 30):
logger.debug("EU-OSHA: 이번 달 이미 실행됨, 건너뜀")
return 0
logger.info("=== EU-OSHA 확인 ===")
try:
import xml.etree.ElementTree as ET
resp = requests.get("https://osha.europa.eu/en/rss.xml", timeout=30)
resp.raise_for_status()
root = ET.fromstring(resp.content)
count = 0
for item in root.iter("item"):
title = item.findtext("title", "")
link = item.findtext("link", "")
description = item.findtext("description", "")
pub_date = item.findtext("pubDate", "")
content = f"# {title}\n\n"
content += f"- **URL**: {link}\n"
content += f"- **Date**: {pub_date}\n\n"
if description:
content += f"## Summary\n\n{description}\n"
safe_title = "".join(c if c.isalnum() or c in " _-" else "" for c in title)[:50].strip() or f"item{count+1}"
today = datetime.now().strftime("%Y%m%d")
filepath = LAWS_DIR / f"EU_{today}_{count+1:02d}_{safe_title}.md"
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
_import_foreign_to_devonthink(filepath, title, "EU")
count += 1
if count >= 5:
break
last_check["_eu_osha_last"] = datetime.now().strftime("%Y-%m-%d")
logger.info(f"EU-OSHA: {count}")
return count
except Exception as e:
logger.error(f"EU-OSHA 에러: {e}", exc_info=True)
return 0
if __name__ == "__main__":
tier2 = "--include-tier2" in sys.argv
run(include_tier2=tier2)

View File

@@ -1,471 +0,0 @@
#!/usr/bin/env python3
"""
법령 XML → Markdown 장 단위 분할 파서
- law.go.kr XML 파싱 → 장/절 구조 식별
- 장별 Markdown 파일 생성 (앵커 + 크로스 링크)
- 부칙 별도 파일 저장
"""
import re
import json
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime, timedelta
import sys
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger
logger = setup_logger("law_parser")
# 법령 약칭 매핑 (조문 내 참조 → 정식명칭)
LAW_ALIASES = {
"산안법": "산업안전보건법",
"산업안전보건법": "산업안전보건법",
"중대재해법": "중대재해 처벌 등에 관한 법률",
"중대재해처벌법": "중대재해 처벌 등에 관한 법률",
"화관법": "화학물질관리법",
"위안법": "위험물안전관리법",
"고압가스법": "고압가스 안전관리법",
"건설기술진흥법": "건설기술 진흥법",
"산재보험법": "산업재해보상보험법",
}
def atomic_write_json(filepath: Path, data: dict):
"""원자적 JSON 파일 쓰기 (경합 방지)"""
tmp = filepath.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(str(tmp), str(filepath))
# --- XML 파싱 ---
def parse_law_xml(xml_path: str) -> dict:
"""XML 파싱 → 법령 구조 추출"""
tree = ET.parse(xml_path)
root = tree.getroot()
# 기본정보
info_el = root.find(".//기본정보")
info = {
"name": (info_el.findtext("법령명_한글", "") or "").strip(),
"law_id": (info_el.findtext("법령ID", "") or "").strip(),
"announce_date": (info_el.findtext("공포일자", "") or "").strip(),
"enforce_date": (info_el.findtext("시행일자", "") or "").strip(),
"ministry": (info_el.findtext("소관부처", "") or "").strip(),
"category": (info_el.findtext("법종구분", "") or "").strip(),
}
# 조문 추출
articles = []
for el in root.findall(".//조문단위"):
kind = (el.findtext("조문여부", "") or "").strip()
num = (el.findtext("조문번호", "") or "").strip()
title = (el.findtext("조문제목", "") or "").strip()
content = (el.findtext("조문내용", "") or "").strip()
# 항 추출
paragraphs = []
for p_el in el.findall(""):
p_num = (p_el.findtext("항번호", "") or "").strip()
p_content = (p_el.findtext("항내용", "") or "").strip()
# 호 추출
sub_items = []
for h_el in p_el.findall(""):
h_num = (h_el.findtext("호번호", "") or "").strip()
h_content = (h_el.findtext("호내용", "") or "").strip()
sub_items.append({"num": h_num, "content": h_content})
paragraphs.append({"num": p_num, "content": p_content, "sub_items": sub_items})
articles.append({
"kind": kind,
"num": num,
"title": title,
"content": content,
"paragraphs": paragraphs,
})
# 부칙 추출
appendices = []
for el in root.findall(".//부칙단위"):
date = (el.findtext("부칙공포일자", "") or "").strip()
num = (el.findtext("부칙공포번호", "") or "").strip()
content = (el.findtext("부칙내용", "") or "").strip()
appendices.append({"date": date, "num": num, "content": content})
return {"info": info, "articles": articles, "appendices": appendices}
# --- 장 분할 ---
def split_by_chapter(articles: list) -> list[dict]:
"""조문 목록을 장 단위로 그룹핑
Returns: [{"chapter": "제1장 총칙", "sections": [...], "articles": [...]}]
"""
chapters = []
current_chapter = {"chapter": "", "sections": [], "articles": []}
current_section = ""
for article in articles:
content_stripped = article["content"].strip()
if article["kind"] == "전문":
# 장/절/편 구분자
if re.match(r"\d+장", content_stripped):
# 새 장 시작
if current_chapter["chapter"] or current_chapter["articles"]:
chapters.append(current_chapter)
current_chapter = {"chapter": content_stripped, "sections": [], "articles": []}
current_section = ""
elif re.match(r"\d+절", content_stripped):
current_section = content_stripped
current_chapter["sections"].append(current_section)
elif re.match(r"\d+편", content_stripped):
# 편은 장보다 상위 — 별도 처리 없이 장 파일 내 표시
if current_chapter["articles"]:
chapters.append(current_chapter)
current_chapter = {"chapter": content_stripped, "sections": [], "articles": []}
current_section = ""
continue
if article["kind"] == "조문":
article["_section"] = current_section
current_chapter["articles"].append(article)
# 마지막 장
if current_chapter["chapter"] or current_chapter["articles"]:
chapters.append(current_chapter)
# 장이 없는 법령 (fallback)
if not chapters and articles:
chapters = [{"chapter": "", "sections": [], "articles": [
a for a in articles if a["kind"] == "조문"
]}]
return chapters
# --- Markdown 변환 ---
def _format_article_num(article: dict) -> str:
"""조문번호 + 제목 → 앵커용 ID 생성"""
num = article["num"]
title = article["title"]
# "제38조" 또는 "제38조의2" 형태 추출
content = article["content"]
match = re.match(r"(제\d+조(?:의\d+)*)\s*", content)
if match:
return match.group(1)
return f"{num}"
def article_to_markdown(article: dict) -> str:
"""단일 조문 → Markdown"""
article_id = _format_article_num(article)
title = article["title"]
# 제목 정리 (한자 괄호 등)
if title:
header = f"## {article_id} ({title})" + " {#" + article_id + "}"
else:
header = f"## {article_id}" + " {#" + article_id + "}"
lines = [header]
# 본문 내용
content = article["content"].strip()
# 조문번호 접두사 제거 (예: "제38조 (안전조치)" → 본문만)
content = re.sub(r"^제\d+조(?:의\d+)*\s*(?:\([^)]*\))?\s*", "", content)
if content:
lines.append(content)
# 항
for p in article.get("paragraphs", []):
p_content = p["content"].strip()
if p_content:
lines.append(f"\n{p_content}")
for si in p.get("sub_items", []):
si_content = si["content"].strip()
if si_content:
lines.append(f" {si_content}")
return "\n".join(lines)
def chapter_to_markdown(law_name: str, info: dict, chapter: dict) -> str:
"""장 → Markdown 파일 내용"""
chapter_name = chapter["chapter"] or law_name
enforce = info.get("enforce_date", "")
if len(enforce) == 8:
enforce = f"{enforce[:4]}-{enforce[4:6]}-{enforce[6:]}"
ministry = info.get("ministry", "")
lines = [
f"# {chapter_name}",
f"> {law_name} | 시행 {enforce} | {ministry}",
"",
]
# 절 표시
current_section = ""
for article in chapter["articles"]:
section = article.get("_section", "")
if section and section != current_section:
current_section = section
lines.append(f"\n### {section}\n")
lines.append(article_to_markdown(article))
lines.append("")
return "\n".join(lines)
def info_to_markdown(info: dict) -> str:
"""기본정보 → Markdown"""
enforce = info.get("enforce_date", "")
if len(enforce) == 8:
enforce = f"{enforce[:4]}-{enforce[4:6]}-{enforce[6:]}"
announce = info.get("announce_date", "")
if len(announce) == 8:
announce = f"{announce[:4]}-{announce[4:6]}-{announce[6:]}"
return f"""# {info['name']} — 기본정보
| 항목 | 내용 |
|------|------|
| **법령명** | {info['name']} |
| **법령구분** | {info.get('category', '')} |
| **소관부처** | {info.get('ministry', '')} |
| **공포일자** | {announce} |
| **시행일자** | {enforce} |
| **법령ID** | {info.get('law_id', '')} |
> 이 문서는 law.go.kr API에서 자동 생성되었습니다.
> 마지막 업데이트: {datetime.now().strftime('%Y-%m-%d')}
"""
def appendices_to_markdown(law_name: str, appendices: list) -> str:
"""부칙 → Markdown"""
lines = [f"# {law_name} — 부칙", ""]
for ap in appendices:
date = ap["date"]
if len(date) == 8:
date = f"{date[:4]}-{date[4:6]}-{date[6:]}"
lines.append(f"## 부칙 (공포 {date}, 제{ap['num']}호)")
lines.append(ap["content"])
lines.append("")
return "\n".join(lines)
# --- 크로스 링크 ---
def add_internal_links(text: str, article_ids: set[str]) -> str:
"""같은 법률 내 조문 참조 → Markdown 앵커 링크
{#...} 앵커 내부와 이미 링크된 부분은 스킵
"""
def replace_ref(m):
full = m.group(0)
article_ref = m.group(1) # "제38조" or "제38조의2"
if article_ref in article_ids:
return f"[{full}](#{article_ref})"
return full
# {#...} 앵커와 [...](...) 링크 내부는 보호
protected = re.sub(r'\{#[^}]+\}|\[[^\]]*\]\([^)]*\)', lambda m: '\x00' * len(m.group()), text)
# "제N조(의N)*" 패턴 매칭 (항/호 부분은 링크에 포함하지 않음)
pattern = r"(제\d+조(?:의\d+)*)(?:제\d+항)?(?:제\d+호)?"
result = []
last = 0
for m in re.finditer(pattern, protected):
result.append(text[last:m.start()])
if '\x00' in protected[m.start():m.end()]:
result.append(text[m.start():m.end()]) # 보호 영역 — 원문 유지
else:
orig = text[m.start():m.end()]
article_ref = re.match(r"(제\d+조(?:의\d+)*)", orig)
if article_ref and article_ref.group(1) in article_ids:
result.append(f"[{orig}](#{article_ref.group(1)})")
else:
result.append(orig)
last = m.end()
result.append(text[last:])
return "".join(result)
def add_cross_law_links(text: str, law_name: str, article_chapter_map: dict) -> str:
"""다른 법률 참조 → DEVONthink wiki-link
article_chapter_map: {법령명: {제X조: 파일명}}
"""
# 「법령명」 제X조 패턴
def replace_cross_ref(m):
raw_name = m.group(1).strip()
article_ref = m.group(2)
# 약칭 → 정식명칭
resolved = LAW_ALIASES.get(raw_name, raw_name)
if resolved == law_name:
return m.group(0) # 같은 법률이면 스킵 (내부 링크로 처리)
# 장 매핑 조회
law_map = article_chapter_map.get(resolved, {})
chapter_file = law_map.get(article_ref)
if chapter_file:
return f"[[{chapter_file}#{article_ref}|{m.group(0)}]]"
return m.group(0)
pattern = r"「([^」]+)」\s*(제\d+조(?:의\d+)*)"
return re.sub(pattern, replace_cross_ref, text)
# --- 파일 저장 ---
def save_law_as_markdown(law_name: str, parsed: dict, output_dir: Path) -> list[Path]:
"""파싱된 법령 → 장별 MD 파일 저장. 생성된 파일 경로 리스트 반환."""
law_dir = output_dir / law_name.replace(" ", "_")
law_dir.mkdir(parents=True, exist_ok=True)
info = parsed["info"]
chapters = split_by_chapter(parsed["articles"])
files = []
# 기본정보
info_path = law_dir / "00_기본정보.md"
info_path.write_text(info_to_markdown(info), encoding="utf-8")
files.append(info_path)
# 같은 법률 내 조문 ID 수집 (내부 링크용)
all_article_ids = set()
for ch in chapters:
for a in ch["articles"]:
all_article_ids.add(_format_article_num(a))
# 장별 파일
for i, chapter in enumerate(chapters, 1):
ch_name = chapter["chapter"] or law_name
# 파일명 안전화
safe_name = re.sub(r"[·ㆍ\s]+", "_", ch_name)
safe_name = re.sub(r"[^\w가-힣]", "", safe_name)
filename = f"{safe_name}.md"
md_content = chapter_to_markdown(law_name, info, chapter)
# 내부 링크 적용
md_content = add_internal_links(md_content, all_article_ids)
filepath = law_dir / filename
filepath.write_text(md_content, encoding="utf-8")
files.append(filepath)
# 부칙
if parsed["appendices"]:
ap_path = law_dir / "부칙.md"
ap_path.write_text(appendices_to_markdown(law_name, parsed["appendices"]), encoding="utf-8")
files.append(ap_path)
logger.info(f"{law_name}: {len(files)}개 파일 생성 → {law_dir}")
return files
def build_article_chapter_map(law_name: str, parsed: dict) -> dict:
"""조문→장 파일명 매핑 생성 (크로스 링크용)
Returns: {제X조: 파일명(확장자 없음)}
"""
chapters = split_by_chapter(parsed["articles"])
mapping = {}
for chapter in chapters:
ch_name = chapter["chapter"] or law_name
safe_name = re.sub(r"[·ㆍ\s]+", "_", ch_name)
safe_name = re.sub(r"[^\w가-힣]", "", safe_name)
file_stem = f"{law_name.replace(' ', '_')}_{safe_name}" if chapter["chapter"] else law_name.replace(" ", "_")
for article in chapter["articles"]:
article_id = _format_article_num(article)
mapping[article_id] = file_stem
return mapping
# --- MST 캐시 ---
def load_mst_cache(cache_path: Path) -> dict:
if cache_path.exists():
with open(cache_path, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_mst_cache(cache_path: Path, data: dict):
atomic_write_json(cache_path, data)
def lookup_current_mst(law_oc: str, law_name: str, category: str = "법률",
cache_path: Path = None, cache_ttl_days: int = 7) -> str | None:
"""법령명으로 현행 MST 검색 (캐시 TTL 적용)
- category → API 법령구분코드 매핑으로 검색 정확도 향상
"""
import requests
# 캐시 확인
if cache_path:
cache = load_mst_cache(cache_path)
entry = cache.get(law_name)
if entry:
cached_at = datetime.fromisoformat(entry["cached_at"])
if datetime.now() - cached_at < timedelta(days=cache_ttl_days):
return entry["mst"]
try:
resp = requests.get("https://www.law.go.kr/DRF/lawSearch.do", params={
"OC": law_oc, "target": "law", "type": "JSON",
"query": law_name, "display": "5",
}, timeout=15)
resp.raise_for_status()
data = resp.json().get("LawSearch", {})
laws = data.get("law", [])
if isinstance(laws, dict):
laws = [laws]
# 현행 필터 + 법령명 정확 매칭
current = [l for l in laws
if l.get("현행연혁코드") == "현행"
and law_name in l.get("법령명한글", "")]
if not current:
logger.warning(f"MST 검색 실패: {law_name} — 현행 법령 없음")
return None
mst = current[0]["법령일련번호"]
# 캐시 저장
if cache_path:
cache = load_mst_cache(cache_path)
cache[law_name] = {"mst": mst, "cached_at": datetime.now().isoformat()}
save_mst_cache(cache_path, cache)
return mst
except Exception as e:
logger.error(f"MST 조회 에러 [{law_name}]: {e}")
return None
if __name__ == "__main__":
# 단독 실행: XML 파일을 MD로 변환
if len(sys.argv) < 2:
print("사용법: python3 law_parser.py <xml_path> [output_dir]")
sys.exit(1)
xml_path = sys.argv[1]
output_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("data/laws/md")
parsed = parse_law_xml(xml_path)
print(f"법령: {parsed['info']['name']}")
print(f"조문: {len(parsed['articles'])}개, 부칙: {len(parsed['appendices'])}")
files = save_law_as_markdown(parsed["info"]["name"], parsed, output_dir)
print(f"생성된 파일: {len(files)}")
for f in files:
print(f" {f}")

View File

@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""
MailPlus → DEVONthink Archive DB 이메일 수집
- Synology MailPlus IMAP 접속
- 마지막 동기화 이후 새 메일 가져오기
- DEVONthink Archive DB 임포트
"""
import os
import sys
import imaplib
import email
from email.header import decode_header
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger, load_credentials, run_applescript_inline, DATA_DIR
logger = setup_logger("mailplus")
LAST_UID_FILE = DATA_DIR / "mailplus_last_uid.txt"
MAIL_TMP_DIR = DATA_DIR / "mail_tmp"
MAIL_TMP_DIR.mkdir(exist_ok=True)
# 안전 관련 키워드 (dataOrigin 판별용)
SAFETY_KEYWORDS = [
"안전", "위험", "사고", "재해", "점검", "보건", "화학물질",
"OSHA", "safety", "hazard", "incident", "KOSHA"
]
def decode_mime_header(value: str) -> str:
"""MIME 헤더 디코딩"""
if not value:
return ""
decoded_parts = decode_header(value)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or "utf-8", errors="replace"))
else:
result.append(part)
return " ".join(result)
def load_last_uid() -> int:
"""마지막 처리 UID 로딩"""
if LAST_UID_FILE.exists():
return int(LAST_UID_FILE.read_text().strip())
return 0
def save_last_uid(uid: int):
"""마지막 처리 UID 저장"""
LAST_UID_FILE.write_text(str(uid))
def detect_data_origin(subject: str, body: str) -> str:
"""안전 키워드 감지로 dataOrigin 판별"""
text = (subject + " " + body).lower()
for kw in SAFETY_KEYWORDS:
if kw.lower() in text:
return "work"
return "external"
def save_email_file(msg: email.message.Message, uid: int) -> Path:
"""이메일을 .eml 파일로 저장"""
subject = decode_mime_header(msg.get("Subject", ""))
safe_subject = "".join(c if c.isalnum() or c in " _-" else "_" for c in subject)[:50]
date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{date_str}_{uid}_{safe_subject}.eml"
filepath = MAIL_TMP_DIR / filename
with open(filepath, "wb") as f:
f.write(msg.as_bytes())
return filepath
def get_email_body(msg: email.message.Message) -> str:
"""이메일 본문 추출"""
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
body += payload.decode(charset, errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
return body[:2000]
def import_to_devonthink(filepath: Path, subject: str, data_origin: str):
"""DEVONthink Archive DB로 임포트"""
escaped_path = str(filepath).replace('"', '\\"')
escaped_subject = subject.replace('"', '\\"').replace("'", "\\'")
script = f'''
tell application id "DNtp"
set targetDB to missing value
repeat with db in databases
if name of db is "Archive" then
set targetDB to db
exit repeat
end if
end repeat
if targetDB is not missing value then
set targetGroup to create location "/Email" in targetDB
set theRecord to import POSIX path "{escaped_path}" to targetGroup
add custom meta data "email" for "sourceChannel" to theRecord
add custom meta data "{data_origin}" for "dataOrigin" to theRecord
add custom meta data (current date) for "lastAIProcess" to theRecord
end if
end tell
'''
try:
run_applescript_inline(script)
logger.info(f"DEVONthink 임포트: {subject[:40]}")
except Exception as e:
logger.error(f"DEVONthink 임포트 실패: {e}")
def run():
"""메인 실행"""
logger.info("=== MailPlus 이메일 수집 시작 ===")
creds = load_credentials()
host = creds.get("MAILPLUS_HOST")
port = int(creds.get("MAILPLUS_PORT", "993"))
user = creds.get("MAILPLUS_USER")
password = creds.get("MAILPLUS_PASS")
if not all([host, user, password]):
logger.error("MAILPLUS 접속 정보가 불완전합니다. credentials.env를 확인하세요.")
sys.exit(1)
last_uid = load_last_uid()
logger.info(f"마지막 처리 UID: {last_uid}")
try:
# IMAP SSL 접속
mail = imaplib.IMAP4_SSL(host, port)
mail.login(user, password)
mail.select("INBOX")
logger.info("IMAP 접속 성공")
# 마지막 UID 이후 메일 검색
if last_uid > 0:
status, data = mail.uid("search", None, f"UID {last_uid + 1}:*")
else:
# 최초 실행: 최근 7일치만
from datetime import timedelta
since = (datetime.now() - timedelta(days=7)).strftime("%d-%b-%Y")
status, data = mail.uid("search", None, f"SINCE {since}")
if status != "OK":
logger.error(f"메일 검색 실패: {status}")
mail.logout()
sys.exit(1)
uids = data[0].split()
logger.info(f"새 메일: {len(uids)}")
max_uid = last_uid
imported = 0
for uid_bytes in uids:
uid = int(uid_bytes)
if uid <= last_uid:
continue
status, msg_data = mail.uid("fetch", uid_bytes, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = decode_mime_header(msg.get("Subject", "(제목 없음)"))
body = get_email_body(msg)
data_origin = detect_data_origin(subject, body)
filepath = save_email_file(msg, uid)
import_to_devonthink(filepath, subject, data_origin)
max_uid = max(max_uid, uid)
imported += 1
if max_uid > last_uid:
save_last_uid(max_uid)
mail.logout()
logger.info(f"=== MailPlus 수집 완료 — {imported}건 임포트 ===")
except imaplib.IMAP4.error as e:
logger.error(f"IMAP 에러: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"예상치 못한 에러: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,235 @@
"""DEVONthink → NAS PKM 마이그레이션 스크립트
.dtBase2 번들의 Files.noindex/ 디렉토리에서 직접 파일을 추출하여
NAS PKM 폴더 구조로 복사하고 DB에 등록합니다.
사용법:
# Dry-run (실제 복사/DB 등록 없이 시뮬레이션)
python scripts/migrate_from_devonthink.py --source-dir ~/Documents/Databases --dry-run
# 실제 실행
python scripts/migrate_from_devonthink.py \
--source-dir ~/Documents/Databases \
--target-dir /mnt/nas/Document_Server \
--database-url postgresql+asyncpg://pkm:PASSWORD@localhost:15432/pkm
"""
import argparse
import asyncio
import os
import shutil
import sys
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from core.utils import file_hash, setup_logger
logger = setup_logger("migrate")
# ─── DEVONthink DB → NAS PKM 폴더 매핑 ───
FOLDER_MAPPING = {
"00_Inbox DB": "PKM/Inbox",
"Inbox": "PKM/Inbox",
"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/References",
"99_Reference Data": "PKM/References",
"99_Home File": "PKM/References",
"Archive": "PKM/Archive",
"Projects": "PKM/Knowledge",
"99_Technicalkorea": "Technicalkorea",
# 스킵 대상
"98_명일방주 엔드필드": None,
}
# 무시할 파일
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "Icon\r", "Icon"}
SKIP_EXTENSIONS = {".dtMeta", ".dtBase2", ".sparseimage", ".dtStore", ".dtCloud"}
def should_skip(path: Path) -> bool:
if path.name in SKIP_NAMES or path.name.startswith("._"):
return True
if path.suffix.lower() in SKIP_EXTENSIONS:
return True
if path.stat().st_size == 0:
return True
return False
async def migrate(
source_dir: str,
target_dir: str,
database_url: str,
dry_run: bool = False,
batch_size: int = 100,
):
"""마이그레이션 실행"""
source = Path(source_dir)
target = Path(target_dir)
if not source.exists():
logger.error(f"소스 디렉토리 없음: {source}")
return
engine = create_async_engine(database_url)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
stats = {"total": 0, "copied": 0, "skipped": 0, "duplicates": 0, "errors": 0}
batch = []
# .dtBase2 번들 탐색
for dtbase in sorted(source.glob("*.dtBase2")):
db_name = dtbase.stem # "04_Industrial safety"
target_prefix = FOLDER_MAPPING.get(db_name)
if target_prefix is None:
logger.info(f"[스킵] {db_name} (매핑: None)")
continue
files_dir = dtbase / "Files.noindex"
if not files_dir.exists():
logger.warning(f"[스킵] {db_name}: Files.noindex 없음")
continue
logger.info(f"[DB] {db_name}{target_prefix}")
# Files.noindex 하위의 모든 파일 (format/hash/filename.ext 구조)
files = [f for f in files_dir.rglob("*") if f.is_file() and not should_skip(f)]
for source_file in files:
stats["total"] += 1
# 대상 경로: PKM/{domain}/{파일명}
dest_rel = f"{target_prefix}/{source_file.name}"
dest_path = target / dest_rel
if dry_run:
logger.info(f"[DRY-RUN] {source_file.name}{dest_rel}")
stats["copied"] += 1
continue
try:
# 파일 복사
dest_path.parent.mkdir(parents=True, exist_ok=True)
# 중복 파일명 처리
counter = 1
stem, suffix = dest_path.stem, dest_path.suffix
while dest_path.exists():
dest_path = dest_path.parent / f"{stem}_{counter}{suffix}"
dest_rel = str(dest_path.relative_to(target))
counter += 1
shutil.copy2(source_file, dest_path)
ext = source_file.suffix.lstrip(".").lower() or "unknown"
fhash = file_hash(dest_path)
fsize = dest_path.stat().st_size
batch.append({
"file_path": dest_rel,
"file_hash": fhash,
"file_format": ext,
"file_size": fsize,
"file_type": "immutable",
"import_source": f"devonthink:{db_name}",
"title": source_file.stem,
"source_channel": "manual",
})
stats["copied"] += 1
except Exception as e:
logger.error(f"[오류] {source_file}: {e}")
stats["errors"] += 1
# 배치 커밋
if len(batch) >= batch_size:
dups = await _insert_batch(async_session_factory, batch)
stats["duplicates"] += dups
batch.clear()
logger.info(f" 진행: {stats['copied']}건 처리됨")
# 남은 배치 처리
if batch and not dry_run:
dups = await _insert_batch(async_session_factory, batch)
stats["duplicates"] += dups
await engine.dispose()
logger.info("=" * 50)
logger.info(f"마이그레이션 {'시뮬레이션' if dry_run else '완료'}")
logger.info(f" 전체 파일: {stats['total']}")
logger.info(f" 복사/등록: {stats['copied']}")
logger.info(f" 스킵: {stats['skipped']}")
logger.info(f" 중복: {stats['duplicates']}")
logger.info(f" 오류: {stats['errors']}")
async def _insert_batch(async_session_factory, batch: list[dict]) -> int:
"""배치 단위로 documents + processing_queue 삽입"""
duplicates = 0
async with async_session_factory() as session:
for item in batch:
try:
result = await session.execute(
text("""
INSERT INTO documents (file_path, file_hash, file_format, file_size,
file_type, import_source, title, source_channel)
VALUES (:file_path, :file_hash, :file_format, :file_size,
:file_type, :import_source, :title, :source_channel)
ON CONFLICT (file_path) DO NOTHING
RETURNING id
"""),
item,
)
row = result.fetchone()
if row is None:
duplicates += 1
continue
doc_id = row[0]
await session.execute(
text("""
INSERT INTO processing_queue (document_id, stage, status)
VALUES (:doc_id, 'extract', 'pending')
ON CONFLICT DO NOTHING
"""),
{"doc_id": doc_id},
)
except Exception as e:
logger.error(f"등록 실패: {item['file_path']}: {e}")
await session.commit()
return duplicates
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="DEVONthink → NAS PKM 마이그레이션")
parser.add_argument("--source-dir", required=True, help="DEVONthink Databases 디렉토리")
parser.add_argument("--target-dir", default="/mnt/nas/Document_Server", help="NAS 루트 경로")
parser.add_argument(
"--database-url",
default="postgresql+asyncpg://pkm:pkm@localhost:15432/pkm",
help="PostgreSQL 연결 URL",
)
parser.add_argument("--dry-run", action="store_true", help="시뮬레이션만 실행")
parser.add_argument("--batch-size", type=int, default=100, help="배치 커밋 크기")
args = parser.parse_args()
asyncio.run(migrate(
source_dir=args.source_dir,
target_dir=args.target_dir,
database_url=args.database_url,
dry_run=args.dry_run,
batch_size=args.batch_size,
))

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""
OCR 전처리 스크립트
- DEVONthink 문서 UUID로 파일 경로 추출
- GPU 서버 Surya OCR API 호출
- OCR 텍스트 반환 (auto_classify.scpt에서 호출)
"""
import sys
import requests
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from pkm_utils import setup_logger, load_credentials, run_applescript_inline
logger = setup_logger("ocr")
def get_document_path(doc_uuid: str) -> str | None:
"""DEVONthink에서 UUID로 문서 파일 경로 추출"""
script = f'''
tell application id "DNtp"
set theRecord to get record with uuid "{doc_uuid}"
return POSIX path of (path of theRecord as POSIX file)
end tell
'''
try:
return run_applescript_inline(script)
except Exception as e:
logger.error(f"파일 경로 추출 실패 [{doc_uuid}]: {e}")
return None
def run_ocr(file_path: str, gpu_server_ip: str, langs: str = "ko,en,ja") -> str | None:
"""GPU 서버 Surya OCR API 호출"""
url = f"http://{gpu_server_ip}:8400/ocr"
try:
with open(file_path, "rb") as f:
resp = requests.post(
url,
files={"file": (Path(file_path).name, f)},
data={"langs": langs},
timeout=300,
)
resp.raise_for_status()
result = resp.json()
text = result.get("text", "")
pages = result.get("total_pages", 0)
logger.info(f"OCR 완료: {pages}페이지, {len(text)}")
return text
except Exception as e:
logger.error(f"OCR 실패 [{file_path}]: {e}")
return None
def run(doc_uuid: str) -> str:
"""단일 문서 OCR 처리 — 텍스트 반환"""
logger.info(f"OCR 처리 시작: {doc_uuid}")
creds = load_credentials()
gpu_ip = creds.get("GPU_SERVER_IP")
if not gpu_ip:
logger.warning("GPU_SERVER_IP 미설정 — OCR 건너뜀")
return ""
file_path = get_document_path(doc_uuid)
if not file_path:
return ""
text = run_ocr(file_path, gpu_ip)
return text or ""
if __name__ == "__main__":
if len(sys.argv) < 2:
print("사용법: python3 ocr_preprocess.py <DEVONthink_UUID>")
sys.exit(1)
result = run(sys.argv[1])
print(result)

Some files were not shown because too many files have changed in this diff Show More