Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24142ea605 | ||
|
|
6c92e375c2 | ||
|
|
06da098eab | ||
|
|
749ed51dd7 | ||
|
|
1668be0a75 | ||
|
|
93c5805060 | ||
|
|
b4ca918125 | ||
|
|
e23c4feaa0 | ||
|
|
e7cd710e69 | ||
|
|
3236b8d812 | ||
|
|
4d205b67c2 | ||
|
|
b54cc25650 | ||
|
|
d63a6b85e1 | ||
|
|
bf0506023c | ||
|
|
7f5e09096a | ||
|
|
5153169d5d | ||
|
|
9b0705b79f | ||
|
|
63f75de89d | ||
|
|
6d73e7ee12 | ||
|
|
770d38b72c | ||
|
|
1b5fa95a9f | ||
|
|
b937eb948b | ||
|
|
1030bffc82 | ||
|
|
733f730e16 | ||
|
|
6893ea132d | ||
|
|
47e9981660 | ||
|
|
03b0612aa2 | ||
|
|
a5186bf4aa | ||
|
|
b37043d651 | ||
|
|
45448b4036 | ||
|
|
9fd44ab268 | ||
|
|
87bdd8003c | ||
|
|
41072a2e6d | ||
|
|
4bea408bbd | ||
|
|
3546c8cefb | ||
|
|
17d41a8526 | ||
|
|
47abf40bf1 | ||
|
|
9239e9c1d5 | ||
|
|
a15208f0cf | ||
|
|
f4a0229f15 | ||
|
|
cb8a846773 | ||
|
|
1a207be261 | ||
|
|
b04e1de8a6 | ||
|
|
1a2b3b49af | ||
|
|
87747866b6 | ||
|
|
faf9bda77a | ||
|
|
1affcb1afd | ||
|
|
e14084d5cd | ||
|
|
62f5eccb96 | ||
|
|
87683ca000 | ||
|
|
7cdeac20cf | ||
|
|
3df03134ff | ||
|
|
0ca78640ee | ||
|
|
8afa3c401f | ||
|
|
aebfa14984 | ||
|
|
17c1b7cf30 | ||
|
|
4ef27fc51c | ||
|
|
a872dfc10f | ||
|
|
fce9124c28 | ||
|
|
cfa95ff031 | ||
|
|
46537ee11a | ||
|
|
d93e50b55c | ||
|
|
31d5498f8d | ||
|
|
a5312c044b | ||
|
|
4b695332b9 | ||
|
|
2dfb05e653 | ||
|
|
299fac3904 | ||
|
|
23ee055357 | ||
|
|
e63d2971a9 | ||
|
|
b7c3040f1a | ||
|
|
d8fbe187bf | ||
|
|
0290dad923 | ||
|
|
629fe37790 | ||
|
|
8484389086 | ||
|
|
16d99011db | ||
|
|
99821df5c9 | ||
|
|
5a13b83e4d | ||
|
|
a601991f48 | ||
|
|
0a01e17ea1 | ||
|
|
131dbd7b7c | ||
|
|
b338e6e424 | ||
|
|
e48b6a2bb4 | ||
|
|
852b7da797 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
359
CLAUDE.md
@@ -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.txt ← Python 패키지 (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
39
Caddyfile
Normal 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
154
README.md
@@ -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
18
app/Dockerfile
Normal 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
0
app/ai/__init__.py
Normal file
137
app/ai/client.py
Normal file
137
app/ai/client.py
Normal 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
0
app/api/__init__.py
Normal file
201
app/api/auth.py
Normal file
201
app/api/auth.py
Normal 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": "비밀번호가 변경되었습니다"}
|
||||
138
app/api/dashboard.py
Normal file
138
app/api/dashboard.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""대시보드 위젯 데이터 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 미분류 수 (review_status = pending)
|
||||
inbox_result = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.review_status == "pending",
|
||||
Document.deleted_at == None,
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
399
app/api/documents.py
Normal file
399
app/api/documents.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""문서 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
|
||||
derived_path: str | None
|
||||
original_format: str | None
|
||||
conversion_status: str | None
|
||||
review_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 != ''
|
||||
AND deleted_at IS NULL
|
||||
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).where(Document.deleted_at == None)
|
||||
|
||||
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 or doc.deleted_at is not None:
|
||||
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="문서를 찾을 수 없습니다")
|
||||
|
||||
# soft-delete (물리 파일은 cleanup job에서 나중에 정리)
|
||||
doc.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료"}
|
||||
166
app/api/search.py
Normal file
166
app/api/search.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""하이브리드 검색 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 deleted_at IS NULL
|
||||
AND (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 AND deleted_at IS 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
234
app/api/setup.py
Normal 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
0
app/core/__init__.py
Normal file
85
app/core/auth.py
Normal file
85
app/core/auth.py
Normal 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
|
||||
104
app/core/config.py
Normal file
104
app/core/config.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""설정 로딩 — 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"
|
||||
|
||||
# 분류 체계
|
||||
taxonomy: dict = {}
|
||||
document_types: list[str] = []
|
||||
|
||||
|
||||
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)
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
|
||||
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,
|
||||
taxonomy=taxonomy,
|
||||
document_types=document_types,
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
144
app/core/database.py
Normal file
144
app/core/database.py
Normal 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
138
app/core/utils.py
Normal 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
133
app/main.py
Normal 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
0
app/models/__init__.py
Normal file
20
app/models/automation.py
Normal file
20
app/models/automation.py
Normal 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
|
||||
)
|
||||
87
app/models/document.py
Normal file
87
app/models/document.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""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)
|
||||
|
||||
# ODF 변환
|
||||
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
|
||||
# 승인/삭제
|
||||
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 외부 편집 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
34
app/models/queue.py
Normal 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
29
app/models/task.py
Normal 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
22
app/models/user.py
Normal 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
93
app/prompts/classify.txt
Normal 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
17
app/requirements.txt
Normal 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
405
app/templates/setup.html
Normal 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">✓</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 ? '✓' : '✗'} 존재</span>
|
||||
<span class="${data.readable ? 'check' : 'cross'}">${data.readable ? '✓' : '✗'} 읽기</span>
|
||||
<span class="${data.writable ? 'check' : 'cross'}">${data.writable ? '✓' : '✗'} 쓰기</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
0
app/workers/__init__.py
Normal file
127
app/workers/classify_worker.py
Normal file
127
app/workers/classify_worker.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""AI 분류 워커 — taxonomy 기반 도메인/문서타입/태그/요약 생성"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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
|
||||
|
||||
# settings에서 taxonomy/document_types 로딩
|
||||
DOCUMENT_TYPES = set(settings.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(settings.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
146
app/workers/daily_digest.py
Normal 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}")
|
||||
44
app/workers/embed_worker.py
Normal file
44
app/workers/embed_worker.py
Normal 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()
|
||||
167
app/workers/extract_worker.py
Normal file
167
app/workers/extract_worker.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""텍스트 추출 워커 — 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:
|
||||
# .derived 디렉토리에 변환 (file_path는 원본 유지!)
|
||||
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))
|
||||
|
||||
nas_root = Path(settings.nas_mount_path)
|
||||
doc.derived_path = str(final_path.relative_to(nas_root))
|
||||
doc.original_format = doc.file_format
|
||||
doc.conversion_status = "done"
|
||||
logger.info(f"[ODF변환] {doc.file_path} → derived: {doc.derived_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
100
app/workers/file_watcher.py
Normal 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}건 등록")
|
||||
364
app/workers/law_monitor.py
Normal file
364
app/workers/law_monitor.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""법령 모니터 워커 — 국가법령정보센터 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_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, 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_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, 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}",
|
||||
)
|
||||
213
app/workers/mailplus_archive.py
Normal file
213
app/workers/mailplus_archive.py
Normal 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})")
|
||||
116
app/workers/preview_worker.py
Normal file
116
app/workers/preview_worker.py
Normal 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}")
|
||||
140
app/workers/queue_consumer.py
Normal file
140
app/workers/queue_consumer.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""처리 큐 소비자 — 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)
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
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)
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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
108
config.yaml
Normal 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
|
||||
@@ -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
105
docker-compose.yml
Normal 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:
|
||||
2375
docs/architecture.md
2375
docs/architecture.md
File diff suppressed because it is too large
Load Diff
@@ -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: 프로젝트 구조 + 설계 문서 초기 커밋
|
||||
```
|
||||
311
docs/deploy.md
311
docs/deploy.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
142
docs/development-stages.md
Normal 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
181
docs/devlog.md
Normal 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로 이관)
|
||||
@@ -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
174
docs/gpu-migration-plan.md
Normal 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 설정에는 불필요
|
||||
@@ -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`로 정식 문서화 (아키텍처 결정 근거 기록으로 보존)
|
||||
@@ -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
16
frontend/Dockerfile
Normal 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
23
frontend/package.json
Normal 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
67
frontend/src/app.css
Normal 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
12
frontend/src/app.html
Normal 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
135
frontend/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
107
frontend/src/lib/components/DocumentCard.svelte
Normal file
107
frontend/src/lib/components/DocumentCard.svelte
Normal 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>
|
||||
231
frontend/src/lib/components/DocumentViewer.svelte
Normal file
231
frontend/src/lib/components/DocumentViewer.svelte
Normal 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>
|
||||
30
frontend/src/lib/components/FormatIcon.svelte
Normal file
30
frontend/src/lib/components/FormatIcon.svelte
Normal 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} />
|
||||
344
frontend/src/lib/components/PreviewPanel.svelte
Normal file
344
frontend/src/lib/components/PreviewPanel.svelte
Normal 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>
|
||||
185
frontend/src/lib/components/Sidebar.svelte
Normal file
185
frontend/src/lib/components/Sidebar.svelte
Normal 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>
|
||||
40
frontend/src/lib/components/TagPill.svelte
Normal file
40
frontend/src/lib/components/TagPill.svelte
Normal 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}
|
||||
129
frontend/src/lib/components/UploadDropzone.svelte
Normal file
129
frontend/src/lib/components/UploadDropzone.svelte
Normal 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}
|
||||
55
frontend/src/lib/stores/auth.ts
Normal file
55
frontend/src/lib/stores/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
frontend/src/lib/stores/ui.ts
Normal file
27
frontend/src/lib/stores/ui.ts
Normal 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));
|
||||
}
|
||||
133
frontend/src/routes/+layout.svelte
Normal file
133
frontend/src/routes/+layout.svelte
Normal 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>
|
||||
89
frontend/src/routes/+page.svelte
Normal file
89
frontend/src/routes/+page.svelte
Normal 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>
|
||||
271
frontend/src/routes/documents/+page.svelte
Normal file
271
frontend/src/routes/documents/+page.svelte
Normal 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}
|
||||
150
frontend/src/routes/documents/[id]/+page.svelte
Normal file
150
frontend/src/routes/documents/[id]/+page.svelte
Normal 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>
|
||||
190
frontend/src/routes/inbox/+page.svelte
Normal file
190
frontend/src/routes/inbox/+page.svelte
Normal 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.review_status === 'pending');
|
||||
} 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>
|
||||
89
frontend/src/routes/login/+page.svelte
Normal file
89
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
83
frontend/src/routes/settings/+page.svelte
Normal file
83
frontend/src/routes/settings/+page.svelte
Normal 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
10
frontend/svelte.config.js
Normal 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
7
frontend/vite.config.js
Normal 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()],
|
||||
});
|
||||
36
gpu-server/docker-compose.yml
Normal file
36
gpu-server/docker-compose.yml
Normal 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:
|
||||
10
gpu-server/services/ai-gateway/Dockerfile
Normal file
10
gpu-server/services/ai-gateway/Dockerfile
Normal 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"]
|
||||
3
gpu-server/services/ai-gateway/requirements.txt
Normal file
3
gpu-server/services/ai-gateway/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
httpx>=0.27.0
|
||||
58
gpu-server/services/ai-gateway/server.py
Normal file
58
gpu-server/services/ai-gateway/server.py
Normal 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에서 구현")
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
117
migrations/001_initial_schema.sql
Normal file
117
migrations/001_initial_schema.sql
Normal 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';
|
||||
11
migrations/002_vector_index.sql
Normal file
11
migrations/002_vector_index.sql
Normal 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);
|
||||
8
migrations/003_automation_state.sql
Normal file
8
migrations/003_automation_state.sql
Normal 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()
|
||||
);
|
||||
2
migrations/004_user_note.sql
Normal file
2
migrations/004_user_note.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 사용자 메모 컬럼 추가
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_note TEXT;
|
||||
4
migrations/005_preview_fields.sql
Normal file
4
migrations/005_preview_fields.sql
Normal 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;
|
||||
2
migrations/006_edit_url.sql
Normal file
2
migrations/006_edit_url.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 외부 편집 URL (Synology Drive 공유 링크 등)
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS edit_url TEXT;
|
||||
5
migrations/007_original_fields.sql
Normal file
5
migrations/007_original_fields.sql
Normal 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';
|
||||
4
migrations/008_classify_fields.sql
Normal file
4
migrations/008_classify_fields.sql
Normal 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;
|
||||
8
migrations/009_review_status.sql
Normal file
8
migrations/009_review_status.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Inbox 승인 상태 분리 + derived_path
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS review_status VARCHAR(20) DEFAULT 'pending';
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS derived_path TEXT;
|
||||
|
||||
-- 기존 문서는 전부 approved (마이그레이션 이후 신규만 pending)
|
||||
UPDATE documents SET review_status = 'approved';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_review_status ON documents(review_status);
|
||||
7
migrations/010_soft_delete.sql
Normal file
7
migrations/010_soft_delete.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Soft-delete 지원
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_not_deleted ON documents(deleted_at) WHERE deleted_at IS NULL;
|
||||
|
||||
-- active documents 뷰 (raw SQL 누락 방지)
|
||||
CREATE OR REPLACE VIEW active_documents AS SELECT * FROM documents WHERE deleted_at IS NULL;
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user