Compare commits
90 Commits
v1-archive
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dd0d7833f6 | ||
|
|
a4f8e56633 | ||
|
|
c79e26e822 | ||
|
|
4b7ddf39c1 | ||
|
|
dc3f03b421 | ||
|
|
f21f950c04 | ||
|
|
5db2f4f6fa | ||
|
|
5fc23e0dbd | ||
|
|
45cabc9aea |
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/
|
||||
|
||||
266
CLAUDE.md
266
CLAUDE.md
@@ -1,137 +1,207 @@
|
||||
# DEVONThink PKM 시스템 — Claude Code 작업 가이드
|
||||
# hyungi_Document_Server — Claude Code 작업 가이드
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
Mac mini M4 Pro(64GB, 4TB) 기반 개인 지식관리(PKM) 시스템.
|
||||
DEVONthink 4를 중앙 허브로, Ollama 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` — 단계별 작업 지시서
|
||||
1. `docs/architecture.md` — 전체 시스템 아키텍처 (DB 스키마, AI 전략, 인프라, UI 설계)
|
||||
2. `docs/deploy.md` — Docker Compose 배포 가이드
|
||||
3. `docs/development-stages.md` — Phase 0~5 개발 단계별 가이드
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| 백엔드 | 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 (운영 서버):
|
||||
- Ollama: http://localhost:11434
|
||||
- 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):
|
||||
- 역할: 임베딩(nomic-embed-text), 비전(Qwen2.5-VL-7B), 리랭킹(bge-reranker)
|
||||
- Tailscale IP: 별도 확인 필요
|
||||
|
||||
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로 로딩
|
||||
|
||||
## 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 — 태그 생성, 문서 분류, 요약
|
||||
→ http://localhost:8800/v1/chat/completions (OpenAI 호환 API)
|
||||
→ MLX 서버로 실행 중 (Ollama 아님)
|
||||
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 서버, 특수):
|
||||
nomic-embed-text — 벡터 임베딩
|
||||
Qwen2.5-VL-7B — 이미지/도면 OCR
|
||||
bge-reranker-v2-m3 — RAG 리랭킹
|
||||
Embedding (GPU Ollama, 같은 Docker 네트워크):
|
||||
nomic-embed-text → 벡터 임베딩
|
||||
Qwen2.5-VL-7B → 이미지/도면 OCR
|
||||
bge-reranker-v2-m3 → RAG 리랭킹
|
||||
```
|
||||
|
||||
## 작업 순서
|
||||
## 프로젝트 구조
|
||||
|
||||
docs/claude-code-commands.md의 단계를 순서대로 진행:
|
||||
```
|
||||
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/
|
||||
├── docs/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
1. **프로젝트 구조** — README.md, deploy.md 작성 (구조는 이미 생성됨)
|
||||
2. **Ollama 테스트** — 분류 프롬프트 최적화 → scripts/prompts/에 저장
|
||||
3. **AppleScript** — auto_classify.scpt, omnifocus_sync.scpt
|
||||
4. **법령 모니터링** — scripts/law_monitor.py + launchd plist
|
||||
5. **이메일 수집** — scripts/mailplus_archive.py + launchd plist
|
||||
6. **Daily Digest** — scripts/pkm_daily_digest.py + launchd plist
|
||||
7. **DEVONagent 가이드** — docs/devonagent-setup.md (수동 설정 가이드)
|
||||
8. **테스트** — tests/ + docs/test-report.md
|
||||
## 문서 처리 파이프라인
|
||||
|
||||
```
|
||||
파일 업로드 (드래그 앤 드롭 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차원)
|
||||
```
|
||||
|
||||
**핵심 원칙:**
|
||||
- 파일은 업로드 위치에 그대로 유지 (물리적 이동 없음)
|
||||
- 분류(domain/sub_group/tags)는 DB 메타데이터로만 관리
|
||||
- preview는 classify와 병렬로 실행 (AI 결과 불필요)
|
||||
|
||||
## UI 구조
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ [☰ 사이드바] [PKM / 문서] [ℹ 정보] 버튼│ ← 상단 nav
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ [검색바] [모드] [ℹ] │
|
||||
│ 문서 목록 (30%) — 드래그 업로드 지원 │ ← 상단 영역
|
||||
│ █ 문서카드 (domain 색상 바 + 포맷 아이콘) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 하단 뷰어/편집 (70%) — 전체 너비 │ ← 하단 영역
|
||||
│ Markdown: split editor (textarea + preview) │
|
||||
│ PDF: 브라우저 내장 뷰어 │
|
||||
│ 오피스: PDF 변환 미리보기 + [편집] 새 탭 버튼 │
|
||||
│ 이미지: img 태그 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
사이드바: 평소 접힘, ☰로 오버레이 (domain 트리 + 스마트 그룹 + Inbox)
|
||||
정보 패널: ℹ 버튼 → 우측 전체 높이 drawer (메모/태그 편집/메타/처리상태/편집 URL)
|
||||
```
|
||||
|
||||
## 데이터 계층
|
||||
|
||||
1. **원본 파일** (NAS `/volume4/Document_Server/PKM/`) — 유일한 원본, 위치 변경 없음
|
||||
2. **가공 데이터** (PostgreSQL) — 텍스트 추출, AI 분류, 검색 인덱스, 메모, 태그
|
||||
3. **파생물** — 벡터 임베딩 (pgvector), PDF 미리보기 캐시 (`.preview/`)
|
||||
|
||||
## 코딩 규칙
|
||||
|
||||
- Python 3.11+ (Mac mini 기본)
|
||||
- 인증 정보는 반드시 credentials.env에서 로딩 (하드코딩 금지)
|
||||
- AppleScript는 DEVONthink/OmniFocus와 연동 (osascript로 호출)
|
||||
- 로그는 ~/Documents/code/DEVONThink_my\ server/logs/에 저장
|
||||
- launchd plist는 launchd/ 디렉토리에 생성, Mac mini에서 심볼릭 링크로 등록
|
||||
- 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
|
||||
Mac mini에서:
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
MacBook Pro (개발) → Gitea push → GPU 서버에서 pull
|
||||
|
||||
개발:
|
||||
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 작동
|
||||
- 법령 API (LAW_OC)는 승인 대기 중 — 스크립트만 만들고 실제 호출은 승인 후
|
||||
- TKSafety 연동은 설계만 완료, 구현은 나중에
|
||||
- GPU 서버 Tailscale IP는 별도 확인 후 credentials.env에 추가
|
||||
- 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
|
||||
}
|
||||
}
|
||||
}
|
||||
74
README.md
74
README.md
@@ -1,40 +1,64 @@
|
||||
# DEVONThink PKM System
|
||||
# hyungi_Document_Server
|
||||
|
||||
Mac mini M4 Pro 기반 개인 지식관리 자동화 시스템
|
||||
Self-hosted 개인 지식관리(PKM) 웹 애플리케이션
|
||||
|
||||
## 구성 요소
|
||||
## 기술 스택
|
||||
|
||||
- **DEVONthink 4** — 중앙 지식 허브 (13개 DB)
|
||||
- **Ollama** — AI 자동 분류/태깅 (Qwen3.5-35B-A3B)
|
||||
- **법령 모니터링** — 산업안전보건법 등 변경 추적
|
||||
- **일일 다이제스트** — PKM 전체 변화 요약
|
||||
- **OmniFocus 연동** — 액션 아이템 자동 생성
|
||||
- **백엔드**: 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
|
||||
# Mac mini에서
|
||||
git clone [gitea-repo-url]
|
||||
cd DEVONThink_my\ server
|
||||
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
|
||||
|
||||
# 인증 정보 설정
|
||||
mkdir -p ~/.config/pkm
|
||||
cp credentials.env.example ~/.config/pkm/credentials.env
|
||||
nano ~/.config/pkm/credentials.env # 실제 값 입력
|
||||
chmod 600 ~/.config/pkm/credentials.env
|
||||
cp credentials.env.example credentials.env
|
||||
nano credentials.env # 실제 값 입력
|
||||
|
||||
# 실행
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
자세한 배포 방법은 `docs/deploy.md` 참조
|
||||
`http://localhost:8000/docs` 에서 API 문서 확인
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
scripts/ Python 스크립트 (법령모니터, 메일수집, 다이제스트)
|
||||
applescript/ DEVONthink/OmniFocus 연동 AppleScript
|
||||
launchd/ macOS 스케줄 실행 plist
|
||||
docs/ 설계 문서, 가이드
|
||||
tests/ 테스트 코드
|
||||
├── app/ FastAPI 백엔드 (API, 워커, AI 클라이언트)
|
||||
├── frontend/ SvelteKit 프론트엔드
|
||||
├── services/kordoc/ 문서 파싱 마이크로서비스 (Node.js)
|
||||
├── gpu-server/ GPU 서버 배포 (AI Gateway)
|
||||
├── migrations/ PostgreSQL 스키마
|
||||
├── docs/ 설계 문서, 배포 가이드
|
||||
└── tests/ 테스트 코드
|
||||
```
|
||||
|
||||
## 인프라 구성
|
||||
|
||||
| 서버 | 역할 |
|
||||
|------|------|
|
||||
| Mac mini M4 Pro | Docker Compose (FastAPI, PostgreSQL, kordoc, Caddy) + MLX AI |
|
||||
| Synology NAS | 파일 원본 저장, Synology Office/Drive/Calendar/MailPlus |
|
||||
| GPU 서버 | AI Gateway, 벡터 임베딩, OCR, 리랭킹 |
|
||||
|
||||
## 문서
|
||||
|
||||
- [아키텍처](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": "비밀번호가 변경되었습니다"}
|
||||
137
app/api/dashboard.py
Normal file
137
app/api/dashboard.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""대시보드 위젯 데이터 API"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DomainCount(BaseModel):
|
||||
domain: str | None
|
||||
count: int
|
||||
|
||||
|
||||
class RecentDocument(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
file_format: str
|
||||
ai_domain: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class PipelineStatus(BaseModel):
|
||||
stage: str
|
||||
status: str
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
today_added: int
|
||||
today_by_domain: list[DomainCount]
|
||||
inbox_count: int
|
||||
law_alerts: int
|
||||
recent_documents: list[RecentDocument]
|
||||
pipeline_status: list[PipelineStatus]
|
||||
failed_count: int
|
||||
total_documents: int
|
||||
|
||||
|
||||
@router.get("/", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""대시보드 위젯 데이터 집계"""
|
||||
|
||||
# 오늘 추가된 문서
|
||||
today_result = await session.execute(
|
||||
select(Document.ai_domain, func.count(Document.id))
|
||||
.where(func.date(Document.created_at) == func.current_date())
|
||||
.group_by(Document.ai_domain)
|
||||
)
|
||||
today_rows = today_result.all()
|
||||
today_added = sum(row[1] for row in today_rows)
|
||||
|
||||
# Inbox 미분류 수 (ai_domain이 없는 문서 = 미분류)
|
||||
inbox_result = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
(Document.ai_domain == None) | (Document.ai_domain == "")
|
||||
)
|
||||
)
|
||||
inbox_count = inbox_result.scalar() or 0
|
||||
|
||||
# 법령 알림 (오늘)
|
||||
law_result = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.source_channel == "law_monitor",
|
||||
func.date(Document.created_at) == func.current_date(),
|
||||
)
|
||||
)
|
||||
law_alerts = law_result.scalar() or 0
|
||||
|
||||
# 최근 문서 5건
|
||||
recent_result = await session.execute(
|
||||
select(Document)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_docs = recent_result.scalars().all()
|
||||
|
||||
# 파이프라인 상태 (24h)
|
||||
pipeline_result = await session.execute(
|
||||
text("""
|
||||
SELECT stage, status, COUNT(*)
|
||||
FROM processing_queue
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY stage, status
|
||||
""")
|
||||
)
|
||||
|
||||
# 실패 건수
|
||||
failed_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProcessingQueue)
|
||||
.where(ProcessingQueue.status == "failed")
|
||||
)
|
||||
failed_count = failed_result.scalar() or 0
|
||||
|
||||
# 전체 문서 수
|
||||
total_result = await session.execute(select(func.count(Document.id)))
|
||||
total_documents = total_result.scalar() or 0
|
||||
|
||||
return DashboardResponse(
|
||||
today_added=today_added,
|
||||
today_by_domain=[
|
||||
DomainCount(domain=row[0], count=row[1]) for row in today_rows
|
||||
],
|
||||
inbox_count=inbox_count,
|
||||
law_alerts=law_alerts,
|
||||
recent_documents=[
|
||||
RecentDocument(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
file_format=doc.file_format,
|
||||
ai_domain=doc.ai_domain,
|
||||
created_at=doc.created_at.isoformat() if doc.created_at else "",
|
||||
)
|
||||
for doc in recent_docs
|
||||
],
|
||||
pipeline_status=[
|
||||
PipelineStatus(stage=row[0], status=row[1], count=row[2])
|
||||
for row in pipeline_result
|
||||
],
|
||||
failed_count=failed_count,
|
||||
total_documents=total_documents,
|
||||
)
|
||||
416
app/api/documents.py
Normal file
416
app/api/documents.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""문서 CRUD API"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from core.utils import file_hash
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── 스키마 ───
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: int
|
||||
file_path: str
|
||||
file_format: str
|
||||
file_size: int | None
|
||||
file_type: str
|
||||
title: str | None
|
||||
ai_domain: str | None
|
||||
ai_sub_group: str | None
|
||||
ai_tags: list | None
|
||||
ai_summary: str | None
|
||||
document_type: str | None
|
||||
importance: str | None
|
||||
ai_confidence: float | None
|
||||
user_note: str | None
|
||||
original_path: str | None
|
||||
original_format: str | None
|
||||
conversion_status: str | None
|
||||
edit_url: str | None
|
||||
preview_status: str | None
|
||||
source_channel: str | None
|
||||
data_origin: str | None
|
||||
extracted_at: datetime | None
|
||||
ai_processed_at: datetime | None
|
||||
embedded_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
items: list[DocumentResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
ai_domain: str | None = None
|
||||
ai_sub_group: str | None = None
|
||||
ai_tags: list | None = None
|
||||
user_note: str | None = None
|
||||
edit_url: str | None = None
|
||||
source_channel: str | None = None
|
||||
data_origin: str | None = None
|
||||
|
||||
|
||||
# ─── 스키마 (트리) ───
|
||||
|
||||
|
||||
class TreeNode(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
count: int
|
||||
children: list["TreeNode"]
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_document_tree(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""도메인 트리 (3단계 경로 파싱, 사이드바용)"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
result = await session.execute(
|
||||
sql_text("""
|
||||
SELECT ai_domain, COUNT(*)
|
||||
FROM documents
|
||||
WHERE ai_domain IS NOT NULL AND ai_domain != ''
|
||||
GROUP BY ai_domain
|
||||
ORDER BY ai_domain
|
||||
""")
|
||||
)
|
||||
|
||||
# 경로를 트리로 파싱
|
||||
root: dict = {}
|
||||
for domain_path, count in result:
|
||||
parts = domain_path.split("/")
|
||||
node = root
|
||||
for part in parts:
|
||||
if part not in node:
|
||||
node[part] = {"_count": 0, "_children": {}}
|
||||
node[part]["_count"] += count
|
||||
node = node[part]["_children"]
|
||||
|
||||
def build_tree(d: dict, prefix: str = "") -> list[dict]:
|
||||
nodes = []
|
||||
for name, data in sorted(d.items()):
|
||||
path = f"{prefix}/{name}" if prefix else name
|
||||
children = build_tree(data["_children"], path)
|
||||
nodes.append({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"count": data["_count"],
|
||||
"children": children,
|
||||
})
|
||||
return nodes
|
||||
|
||||
return build_tree(root)
|
||||
|
||||
|
||||
@router.get("/", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
domain: str | None = None,
|
||||
sub_group: str | None = None,
|
||||
source: str | None = None,
|
||||
format: str | None = None,
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터)"""
|
||||
query = select(Document)
|
||||
|
||||
if domain:
|
||||
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
|
||||
query = query.where(Document.ai_domain.startswith(domain))
|
||||
if source:
|
||||
query = query.where(Document.source_channel == source)
|
||||
if format:
|
||||
query = query.where(Document.file_format == format)
|
||||
|
||||
# 전체 건수
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await session.execute(count_query)).scalar()
|
||||
|
||||
# 페이지네이션
|
||||
query = query.order_by(Document.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return DocumentListResponse(
|
||||
items=[DocumentResponse.model_validate(doc) for doc in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 단건 조회"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/file")
|
||||
async def get_document_file(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
user: User | None = Depends(lambda: None),
|
||||
):
|
||||
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
|
||||
from core.auth import decode_token
|
||||
|
||||
# 쿼리 파라미터 토큰 검증
|
||||
if token:
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
else:
|
||||
# 일반 Bearer 헤더 인증 시도
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 미디어 타입 매핑
|
||||
media_types = {
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".gif": "image/gif",
|
||||
".bmp": "image/bmp", ".tiff": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain", ".md": "text/plain",
|
||||
".html": "text/html", ".csv": "text/csv",
|
||||
".json": "application/json", ".xml": "application/xml",
|
||||
}
|
||||
suffix = file_path.suffix.lower()
|
||||
media_type = media_types.get(suffix, "application/octet-stream")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": "inline"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentResponse, status_code=201)
|
||||
async def upload_document(
|
||||
file: UploadFile,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="파일명이 필요합니다")
|
||||
|
||||
# 파일명 정규화 (경로 이탈 방지)
|
||||
safe_name = Path(file.filename).name
|
||||
if not safe_name or safe_name.startswith("."):
|
||||
raise HTTPException(status_code=400, detail="유효하지 않은 파일명")
|
||||
|
||||
# Inbox에 파일 저장
|
||||
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = (inbox_dir / safe_name).resolve()
|
||||
|
||||
# Inbox 하위 경로 검증
|
||||
if not str(target).startswith(str(inbox_dir.resolve())):
|
||||
raise HTTPException(status_code=400, detail="잘못된 파일 경로")
|
||||
|
||||
# 중복 파일명 처리
|
||||
counter = 1
|
||||
stem, suffix = target.stem, target.suffix
|
||||
while target.exists():
|
||||
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await file.read()
|
||||
target.write_bytes(content)
|
||||
|
||||
# 상대 경로 (NAS 루트 기준)
|
||||
rel_path = str(target.relative_to(Path(settings.nas_mount_path)))
|
||||
fhash = file_hash(target)
|
||||
ext = target.suffix.lstrip(".").lower() or "unknown"
|
||||
|
||||
# DB 등록
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=fhash,
|
||||
file_format=ext,
|
||||
file_size=len(content),
|
||||
file_type="immutable",
|
||||
title=target.stem,
|
||||
source_channel="manual",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# 처리 큐 등록
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id,
|
||||
stage="extract",
|
||||
status="pending",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.patch("/{doc_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
doc_id: int,
|
||||
body: DocumentUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 메타데이터 수정 (수동 오버라이드)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.put("/{doc_id}/content")
|
||||
async def save_document_content(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: dict = None,
|
||||
):
|
||||
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
if doc.file_format not in ("md", "txt"):
|
||||
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
|
||||
|
||||
content = body.get("content", "") if body else ""
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# 메타 갱신
|
||||
doc.file_size = len(content.encode("utf-8"))
|
||||
doc.file_hash = file_hash(file_path)
|
||||
doc.extracted_text = content[:15000]
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/preview")
|
||||
async def get_document_preview(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
):
|
||||
"""PDF 미리보기 캐시 서빙"""
|
||||
from core.auth import decode_token
|
||||
|
||||
if token:
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
|
||||
if not preview_path.exists():
|
||||
raise HTTPException(status_code=404, detail="미리보기가 아직 생성되지 않았습니다")
|
||||
|
||||
return FileResponse(
|
||||
path=str(preview_path),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "inline"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{doc_id}")
|
||||
async def delete_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
|
||||
):
|
||||
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
if delete_file:
|
||||
# 원본 파일 삭제
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
# 변환본 삭제
|
||||
if doc.original_path:
|
||||
orig = Path(settings.nas_mount_path) / doc.original_path
|
||||
if orig.exists():
|
||||
orig.unlink()
|
||||
# preview 캐시 삭제
|
||||
preview = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
|
||||
if preview.exists():
|
||||
preview.unlink()
|
||||
|
||||
# 관련 processing_queue 먼저 삭제 (FK 제약)
|
||||
from sqlalchemy import delete as sql_delete
|
||||
await session.execute(
|
||||
sql_delete(ProcessingQueue).where(ProcessingQueue.document_id == doc_id)
|
||||
)
|
||||
await session.delete(doc)
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"문서 {doc_id} 삭제됨", "file_deleted": delete_file}
|
||||
165
app/api/search.py
Normal file
165
app/api/search.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""하이브리드 검색 API — FTS + ILIKE + 벡터 (필드별 가중치)"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
ai_domain: str | None
|
||||
ai_summary: str | None
|
||||
file_format: str
|
||||
score: float
|
||||
snippet: str | None
|
||||
match_reason: str | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[SearchResult]
|
||||
total: int
|
||||
query: str
|
||||
mode: str
|
||||
|
||||
|
||||
@router.get("/", response_model=SearchResponse)
|
||||
async def search(
|
||||
q: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합"""
|
||||
if mode == "vector":
|
||||
results = await _search_vector(session, q, limit)
|
||||
else:
|
||||
results = await _search_text(session, q, limit)
|
||||
|
||||
# hybrid: 벡터 결과도 합산
|
||||
if mode == "hybrid":
|
||||
vector_results = await _search_vector(session, q, limit)
|
||||
results = _merge_results(results, vector_results, limit)
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
total=len(results),
|
||||
query=q,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
async def _search_text(session: AsyncSession, query: str, limit: int) -> list[SearchResult]:
|
||||
"""FTS + ILIKE — 필드별 가중치 적용"""
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT id, title, ai_domain, ai_summary, file_format,
|
||||
left(extracted_text, 200) AS snippet,
|
||||
(
|
||||
-- title 매칭 (가중치 최고)
|
||||
CASE WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 3.0 ELSE 0 END
|
||||
-- ai_tags 매칭 (가중치 높음)
|
||||
+ CASE WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 2.5 ELSE 0 END
|
||||
-- user_note 매칭 (가중치 높음)
|
||||
+ CASE WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 2.0 ELSE 0 END
|
||||
-- ai_summary 매칭 (가중치 중상)
|
||||
+ CASE WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 1.5 ELSE 0 END
|
||||
-- extracted_text 매칭 (가중치 중간)
|
||||
+ CASE WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 1.0 ELSE 0 END
|
||||
-- FTS 점수 (보너스)
|
||||
+ coalesce(ts_rank(
|
||||
to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')),
|
||||
plainto_tsquery('simple', :q)
|
||||
), 0) * 2.0
|
||||
) AS score,
|
||||
-- match reason
|
||||
CASE
|
||||
WHEN coalesce(title, '') ILIKE '%%' || :q || '%%' THEN 'title'
|
||||
WHEN coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%' THEN 'tags'
|
||||
WHEN coalesce(user_note, '') ILIKE '%%' || :q || '%%' THEN 'note'
|
||||
WHEN coalesce(ai_summary, '') ILIKE '%%' || :q || '%%' THEN 'summary'
|
||||
WHEN coalesce(extracted_text, '') ILIKE '%%' || :q || '%%' THEN 'content'
|
||||
ELSE 'fts'
|
||||
END AS match_reason
|
||||
FROM documents
|
||||
WHERE coalesce(title, '') ILIKE '%%' || :q || '%%'
|
||||
OR coalesce(ai_tags::text, '') ILIKE '%%' || :q || '%%'
|
||||
OR coalesce(user_note, '') ILIKE '%%' || :q || '%%'
|
||||
OR coalesce(ai_summary, '') ILIKE '%%' || :q || '%%'
|
||||
OR coalesce(extracted_text, '') ILIKE '%%' || :q || '%%'
|
||||
OR to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, ''))
|
||||
@@ plainto_tsquery('simple', :q)
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"q": query, "limit": limit},
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
async def _search_vector(session: AsyncSession, query: str, limit: int) -> list[SearchResult]:
|
||||
"""벡터 유사도 검색 (코사인 거리)"""
|
||||
try:
|
||||
client = AIClient()
|
||||
query_embedding = await client.embed(query)
|
||||
await client.close()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT id, title, ai_domain, ai_summary, file_format,
|
||||
(1 - (embedding <=> cast(:embedding AS vector))) AS score,
|
||||
left(extracted_text, 200) AS snippet,
|
||||
'vector' AS match_reason
|
||||
FROM documents
|
||||
WHERE embedding IS NOT NULL
|
||||
ORDER BY embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"embedding": str(query_embedding), "limit": limit},
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
def _merge_results(
|
||||
text_results: list[SearchResult],
|
||||
vector_results: list[SearchResult],
|
||||
limit: int,
|
||||
) -> list[SearchResult]:
|
||||
"""텍스트 + 벡터 결과 합산 (중복 제거, 점수 합산)"""
|
||||
merged: dict[int, SearchResult] = {}
|
||||
|
||||
for r in text_results:
|
||||
merged[r.id] = r
|
||||
|
||||
for r in vector_results:
|
||||
if r.id in merged:
|
||||
# 이미 텍스트로 잡힌 문서 — 벡터 점수 가산
|
||||
existing = merged[r.id]
|
||||
merged[r.id] = SearchResult(
|
||||
id=existing.id,
|
||||
title=existing.title,
|
||||
ai_domain=existing.ai_domain,
|
||||
ai_summary=existing.ai_summary,
|
||||
file_format=existing.file_format,
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
)
|
||||
elif r.score > 0.3: # 벡터 유사도 최소 threshold
|
||||
merged[r.id] = r
|
||||
|
||||
results = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||||
return results[:limit]
|
||||
234
app/api/setup.py
Normal file
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
|
||||
95
app/core/config.py
Normal file
95
app/core/config.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""설정 로딩 — config.yaml + credentials.env"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AIModelConfig(BaseModel):
|
||||
endpoint: str
|
||||
model: str
|
||||
max_tokens: int = 4096
|
||||
timeout: int = 60
|
||||
daily_budget_usd: float | None = None
|
||||
require_explicit_trigger: bool = False
|
||||
|
||||
|
||||
class AIConfig(BaseModel):
|
||||
gateway_endpoint: str
|
||||
primary: AIModelConfig
|
||||
fallback: AIModelConfig
|
||||
premium: AIModelConfig
|
||||
embedding: AIModelConfig
|
||||
vision: AIModelConfig
|
||||
rerank: AIModelConfig
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
# DB
|
||||
database_url: str = ""
|
||||
|
||||
# AI
|
||||
ai: AIConfig | None = None
|
||||
|
||||
# NAS
|
||||
nas_mount_path: str = "/documents"
|
||||
nas_pkm_root: str = "/documents/PKM"
|
||||
|
||||
# 인증
|
||||
jwt_secret: str = ""
|
||||
totp_secret: str = ""
|
||||
|
||||
# kordoc
|
||||
kordoc_endpoint: str = "http://kordoc-service:3100"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""config.yaml + 환경변수에서 설정 로딩"""
|
||||
# 환경변수 (docker-compose에서 주입)
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
jwt_secret = os.getenv("JWT_SECRET", "")
|
||||
totp_secret = os.getenv("TOTP_SECRET", "")
|
||||
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
|
||||
|
||||
# config.yaml — Docker 컨테이너 내부(/app/config.yaml) 또는 프로젝트 루트
|
||||
config_path = Path("/app/config.yaml")
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent.parent.parent / "config.yaml"
|
||||
ai_config = None
|
||||
nas_mount = "/documents"
|
||||
nas_pkm = "/documents/PKM"
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if "ai" in raw:
|
||||
ai_raw = raw["ai"]
|
||||
ai_config = AIConfig(
|
||||
gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""),
|
||||
primary=AIModelConfig(**ai_raw["models"]["primary"]),
|
||||
fallback=AIModelConfig(**ai_raw["models"]["fallback"]),
|
||||
premium=AIModelConfig(**ai_raw["models"]["premium"]),
|
||||
embedding=AIModelConfig(**ai_raw["models"]["embedding"]),
|
||||
vision=AIModelConfig(**ai_raw["models"]["vision"]),
|
||||
rerank=AIModelConfig(**ai_raw["models"]["rerank"]),
|
||||
)
|
||||
|
||||
if "nas" in raw:
|
||||
nas_mount = raw["nas"].get("mount_path", nas_mount)
|
||||
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
|
||||
|
||||
return Settings(
|
||||
database_url=database_url,
|
||||
ai=ai_config,
|
||||
nas_mount_path=nas_mount,
|
||||
nas_pkm_root=nas_pkm,
|
||||
jwt_secret=jwt_secret,
|
||||
totp_secret=totp_secret,
|
||||
kordoc_endpoint=kordoc_endpoint,
|
||||
)
|
||||
|
||||
|
||||
settings = load_settings()
|
||||
144
app/core/database.py
Normal file
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
|
||||
)
|
||||
84
app/models/document.py
Normal file
84
app/models/document.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""documents 테이블 ORM"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
|
||||
# 1계층: 원본 파일
|
||||
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
file_size: Mapped[int | None] = mapped_column(BigInteger)
|
||||
file_type: Mapped[str] = mapped_column(
|
||||
Enum("immutable", "editable", "note", name="doc_type"),
|
||||
default="immutable"
|
||||
)
|
||||
import_source: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 2계층: 텍스트 추출
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text)
|
||||
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
extractor_version: Mapped[str | None] = mapped_column(String(50))
|
||||
|
||||
# 2계층: AI 가공
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text)
|
||||
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
|
||||
ai_domain: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
ai_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
document_type: Mapped[str | None] = mapped_column(String(50))
|
||||
importance: Mapped[str | None] = mapped_column(String(20), default="medium")
|
||||
ai_confidence: Mapped[float | None] = mapped_column()
|
||||
|
||||
# 3계층: 벡터 임베딩
|
||||
embedding = mapped_column(Vector(768), nullable=True)
|
||||
embed_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 사용자 메모
|
||||
user_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 원본 보존 (변환 전)
|
||||
original_path: Mapped[str | None] = mapped_column(Text)
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
original_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
|
||||
# 외부 편집 URL
|
||||
edit_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 미리보기
|
||||
preview_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
preview_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
preview_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 메타데이터
|
||||
source_channel: Mapped[str | None] = mapped_column(
|
||||
Enum("law_monitor", "devonagent", "email", "web_clip",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync",
|
||||
name="source_channel")
|
||||
)
|
||||
data_origin: Mapped[str | None] = mapped_column(
|
||||
Enum("work", "external", name="data_origin")
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 타임스탬프
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
34
app/models/queue.py
Normal file
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
132
app/workers/classify_worker.py
Normal file
132
app/workers/classify_worker.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""AI 분류 워커 — taxonomy 기반 도메인/문서타입/태그/요약 생성"""
|
||||
|
||||
import yaml
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response, strip_thinking
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
|
||||
logger = setup_logger("classify_worker")
|
||||
|
||||
MAX_CLASSIFY_TEXT = 8000
|
||||
|
||||
# config.yaml에서 taxonomy 로딩
|
||||
_config_path = Path(__file__).resolve().parent.parent / "config.yaml"
|
||||
_config = yaml.safe_load(_config_path.read_text(encoding="utf-8"))
|
||||
|
||||
DOCUMENT_TYPES = set(_config.get("document_types", []))
|
||||
|
||||
|
||||
def _get_taxonomy_leaf_paths(taxonomy: dict, prefix: str = "") -> set[str]:
|
||||
"""taxonomy dict에서 모든 유효한 경로를 추출"""
|
||||
paths = set()
|
||||
for key, value in taxonomy.items():
|
||||
current = f"{prefix}/{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
paths.add(current)
|
||||
else:
|
||||
paths.update(_get_taxonomy_leaf_paths(value, current))
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
paths.add(current)
|
||||
else:
|
||||
for leaf in value:
|
||||
paths.add(f"{current}/{leaf}")
|
||||
paths.add(current) # 2단계도 허용 (leaf가 없는 경우용)
|
||||
else:
|
||||
paths.add(current)
|
||||
return paths
|
||||
|
||||
|
||||
VALID_DOMAIN_PATHS = _get_taxonomy_leaf_paths(_config.get("taxonomy", {}))
|
||||
|
||||
|
||||
def _validate_domain(domain: str) -> str:
|
||||
"""domain이 taxonomy에 존재하는지 검증, 없으면 최대한 가까운 경로 찾기"""
|
||||
if domain in VALID_DOMAIN_PATHS:
|
||||
return domain
|
||||
|
||||
# 부분 매칭 시도 (2단계까지)
|
||||
parts = domain.split("/")
|
||||
for i in range(len(parts), 0, -1):
|
||||
partial = "/".join(parts[:i])
|
||||
if partial in VALID_DOMAIN_PATHS:
|
||||
logger.warning(f"[분류] domain '{domain}' → '{partial}' (부분 매칭)")
|
||||
return partial
|
||||
|
||||
logger.warning(f"[분류] domain '{domain}' taxonomy에 없음, General/Reading_Notes로 대체")
|
||||
return "General/Reading_Notes"
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 AI 분류 + 요약"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
client = AIClient()
|
||||
try:
|
||||
# ─── 분류 ───
|
||||
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
|
||||
raw_response = await client.classify(truncated)
|
||||
parsed = parse_json_response(raw_response)
|
||||
|
||||
if not parsed:
|
||||
raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}")
|
||||
|
||||
# domain 검증
|
||||
domain = _validate_domain(parsed.get("domain", ""))
|
||||
doc.ai_domain = domain
|
||||
|
||||
# sub_group은 domain 경로에서 추출 (호환성)
|
||||
parts = domain.split("/")
|
||||
doc.ai_sub_group = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# document_type 검증
|
||||
doc_type = parsed.get("document_type", "")
|
||||
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
|
||||
|
||||
# confidence
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
|
||||
|
||||
# importance
|
||||
importance = parsed.get("importance", "medium")
|
||||
doc.importance = importance if importance in ("high", "medium", "low") else "medium"
|
||||
|
||||
# tags
|
||||
doc.ai_tags = parsed.get("tags", [])[:5]
|
||||
|
||||
# source/origin
|
||||
if parsed.get("sourceChannel") and not doc.source_channel:
|
||||
doc.source_channel = parsed["sourceChannel"]
|
||||
if parsed.get("dataOrigin") and not doc.data_origin:
|
||||
doc.data_origin = parsed["dataOrigin"]
|
||||
|
||||
# ─── 요약 ───
|
||||
summary = await client.summarize(doc.extracted_text[:15000])
|
||||
doc.ai_summary = strip_thinking(summary)
|
||||
|
||||
# ─── 메타데이터 ───
|
||||
doc.ai_model_version = "qwen3.5-35b-a3b"
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
f"[분류] document_id={document_id}: "
|
||||
f"domain={domain}, type={doc.document_type}, "
|
||||
f"confidence={doc.ai_confidence:.2f}, tags={doc.ai_tags}"
|
||||
)
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
# _move_to_knowledge 제거됨 — 파일은 원본 위치 유지, 분류는 DB 메타데이터만
|
||||
146
app/workers/daily_digest.py
Normal file
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()
|
||||
175
app/workers/extract_worker.py
Normal file
175
app/workers/extract_worker.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""텍스트 추출 워커 — kordoc / LibreOffice / 직접 읽기"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
|
||||
logger = setup_logger("extract_worker")
|
||||
|
||||
# kordoc으로 파싱 가능한 포맷
|
||||
KORDOC_FORMATS = {"hwp", "hwpx", "pdf"}
|
||||
# 직접 읽기 가능한 텍스트 포맷
|
||||
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
|
||||
# LibreOffice로 텍스트 추출 가능한 포맷
|
||||
OFFICE_FORMATS = {"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "odoc", "osheet"}
|
||||
# OCR 필요 이미지 포맷 (Phase 2)
|
||||
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif"}
|
||||
|
||||
EXTRACTOR_VERSION = "kordoc@1.7"
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 텍스트 추출"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
fmt = doc.file_format.lower()
|
||||
full_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
|
||||
# 텍스트 파일 — 직접 읽기
|
||||
if fmt in TEXT_FORMATS:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
text = full_path.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "direct_read"
|
||||
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
|
||||
return
|
||||
|
||||
# 이미지 — 스킵 (Phase 2 OCR)
|
||||
if fmt in IMAGE_FORMATS:
|
||||
doc.extracted_text = ""
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "skip_image"
|
||||
logger.info(f"[이미지] {doc.file_path} — OCR 미구현, 스킵")
|
||||
return
|
||||
|
||||
# kordoc 파싱 (HWP/HWPX/PDF)
|
||||
if fmt in KORDOC_FORMATS:
|
||||
# 컨테이너 내부 경로: /documents/{file_path}
|
||||
container_path = f"/documents/{doc.file_path}"
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.kordoc_endpoint}/parse",
|
||||
json={"filePath": container_path},
|
||||
)
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(f"kordoc: 파일 없음 — {container_path}")
|
||||
if resp.status_code == 422:
|
||||
raise ValueError(f"kordoc: 파싱 실패 — {resp.json().get('error', 'unknown')}")
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
doc.extracted_text = data.get("markdown", "")
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = EXTRACTOR_VERSION
|
||||
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
|
||||
return
|
||||
|
||||
# 오피스 포맷 — LibreOffice 텍스트 변환
|
||||
if fmt in OFFICE_FORMATS:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
|
||||
import shutil
|
||||
tmp_dir = Path("/tmp/extract_work")
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 한글 파일명 문제 방지 — 영문 임시 파일로 복사
|
||||
tmp_input = tmp_dir / f"input_{document_id}.{fmt}"
|
||||
shutil.copy2(str(full_path), str(tmp_input))
|
||||
|
||||
# 스프레드시트는 csv, 나머지는 txt
|
||||
CALC_FORMATS = {"xlsx", "xls", "ods", "osheet"}
|
||||
if fmt in CALC_FORMATS:
|
||||
convert_to = "csv:Text - txt - csv (StarCalc):44,34,76,1"
|
||||
out_ext = "csv"
|
||||
else:
|
||||
convert_to = "txt:Text"
|
||||
out_ext = "txt"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", convert_to, "--outdir", str(tmp_dir), str(tmp_input)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
out_file = tmp_dir / f"input_{document_id}.{out_ext}"
|
||||
if out_file.exists():
|
||||
text = out_file.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text[:15000]
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "libreoffice"
|
||||
out_file.unlink()
|
||||
logger.info(f"[LibreOffice] {doc.file_path} ({len(text)}자)")
|
||||
else:
|
||||
raise RuntimeError(f"LibreOffice 변환 실패: {result.stderr[:300]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"LibreOffice 텍스트 추출 timeout (60s)")
|
||||
finally:
|
||||
tmp_input.unlink(missing_ok=True)
|
||||
|
||||
# ─── ODF 변환 (편집용) ───
|
||||
CONVERT_MAP = {
|
||||
'xlsx': 'ods', 'xls': 'ods',
|
||||
'docx': 'odt', 'doc': 'odt',
|
||||
'pptx': 'odp', 'ppt': 'odp',
|
||||
}
|
||||
target_fmt = CONVERT_MAP.get(fmt)
|
||||
if target_fmt:
|
||||
try:
|
||||
from core.utils import file_hash as calc_hash
|
||||
# 원본 메타 보존
|
||||
doc.original_path = doc.file_path
|
||||
doc.original_format = doc.file_format
|
||||
doc.original_hash = doc.file_hash
|
||||
|
||||
# .derived 디렉토리에 변환
|
||||
derived_dir = full_path.parent / ".derived"
|
||||
derived_dir.mkdir(exist_ok=True)
|
||||
tmp_input2 = tmp_dir / f"convert_{document_id}.{fmt}"
|
||||
shutil.copy2(str(full_path), str(tmp_input2))
|
||||
|
||||
conv_result = subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", target_fmt, "--outdir", str(tmp_dir), str(tmp_input2)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
tmp_input2.unlink(missing_ok=True)
|
||||
|
||||
conv_file = tmp_dir / f"convert_{document_id}.{target_fmt}"
|
||||
if conv_file.exists():
|
||||
final_path = derived_dir / f"{document_id}.{target_fmt}"
|
||||
shutil.move(str(conv_file), str(final_path))
|
||||
|
||||
# DB 업데이트: current → ODF
|
||||
nas_root = Path(settings.nas_mount_path)
|
||||
doc.file_path = str(final_path.relative_to(nas_root))
|
||||
doc.file_format = target_fmt
|
||||
doc.file_hash = calc_hash(final_path)
|
||||
doc.conversion_status = "done"
|
||||
logger.info(f"[ODF변환] {doc.original_path} → {doc.file_path}")
|
||||
else:
|
||||
doc.conversion_status = "failed"
|
||||
logger.warning(f"[ODF변환] 실패: {conv_result.stderr[:200]}")
|
||||
except Exception as e:
|
||||
doc.conversion_status = "failed"
|
||||
logger.error(f"[ODF변환] {doc.file_path} 에러: {e}")
|
||||
else:
|
||||
doc.conversion_status = "none"
|
||||
|
||||
return
|
||||
|
||||
# 미지원 포맷
|
||||
doc.extracted_text = ""
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = f"unsupported_{fmt}"
|
||||
logger.warning(f"[미지원] {doc.file_path} (format={fmt})")
|
||||
100
app/workers/file_watcher.py
Normal file
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}건 등록")
|
||||
362
app/workers/law_monitor.py
Normal file
362
app/workers/law_monitor.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""법령 모니터 워커 — 국가법령정보센터 API 연동
|
||||
|
||||
26개 법령 모니터링, 편/장 단위 분할 저장, 변경 이력 추적.
|
||||
매일 07:00 실행 (APScheduler).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import create_caldav_todo, escape_ical_text, file_hash, send_smtp_email, setup_logger
|
||||
from models.automation import AutomationState
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
|
||||
|
||||
# 모니터링 대상 법령 (26개)
|
||||
MONITORED_LAWS = [
|
||||
# 산업안전보건 핵심
|
||||
"산업안전보건법",
|
||||
"산업안전보건법 시행령",
|
||||
"산업안전보건법 시행규칙",
|
||||
"산업안전보건기준에 관한 규칙",
|
||||
"유해위험작업의 취업 제한에 관한 규칙",
|
||||
"중대재해 처벌 등에 관한 법률",
|
||||
"중대재해 처벌 등에 관한 법률 시행령",
|
||||
# 건설안전
|
||||
"건설기술 진흥법",
|
||||
"건설기술 진흥법 시행령",
|
||||
"건설기술 진흥법 시행규칙",
|
||||
"시설물의 안전 및 유지관리에 관한 특별법",
|
||||
# 위험물/화학
|
||||
"위험물안전관리법",
|
||||
"위험물안전관리법 시행령",
|
||||
"위험물안전관리법 시행규칙",
|
||||
"화학물질관리법",
|
||||
"화학물질관리법 시행령",
|
||||
"화학물질의 등록 및 평가 등에 관한 법률",
|
||||
# 소방/전기/가스
|
||||
"소방시설 설치 및 관리에 관한 법률",
|
||||
"소방시설 설치 및 관리에 관한 법률 시행령",
|
||||
"전기사업법",
|
||||
"전기안전관리법",
|
||||
"고압가스 안전관리법",
|
||||
"고압가스 안전관리법 시행령",
|
||||
"액화석유가스의 안전관리 및 사업법",
|
||||
# 근로/환경
|
||||
"근로기준법",
|
||||
"환경영향평가법",
|
||||
]
|
||||
|
||||
|
||||
async def run():
|
||||
"""법령 변경 모니터링 실행"""
|
||||
law_oc = os.getenv("LAW_OC", "")
|
||||
if not law_oc:
|
||||
logger.warning("LAW_OC 미설정 — 법령 API 승인 대기 중")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
state = await session.execute(
|
||||
select(AutomationState).where(AutomationState.job_name == "law_monitor")
|
||||
)
|
||||
state_row = state.scalar_one_or_none()
|
||||
last_check = state_row.last_check_value if state_row else None
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
if last_check == today:
|
||||
logger.info("오늘 이미 체크 완료")
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for law_name in MONITORED_LAWS:
|
||||
try:
|
||||
count = await _check_law(client, law_oc, law_name, session)
|
||||
new_count += count
|
||||
except Exception as e:
|
||||
logger.error(f"[{law_name}] 체크 실패: {e}")
|
||||
|
||||
# 상태 업데이트
|
||||
if state_row:
|
||||
state_row.last_check_value = today
|
||||
state_row.last_run_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
session.add(AutomationState(
|
||||
job_name="law_monitor",
|
||||
last_check_value=today,
|
||||
last_run_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"법령 모니터 완료: {new_count}건 신규/변경 감지")
|
||||
|
||||
|
||||
async def _check_law(
|
||||
client: httpx.AsyncClient,
|
||||
law_oc: str,
|
||||
law_name: str,
|
||||
session,
|
||||
) -> int:
|
||||
"""단일 법령 검색 → 변경 감지 → 분할 저장"""
|
||||
# 법령 검색 (lawSearch.do)
|
||||
resp = await client.get(
|
||||
LAW_SEARCH_URL,
|
||||
params={"OC": law_oc, "target": "law", "type": "XML", "query": law_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.text)
|
||||
total = root.findtext(".//totalCnt", "0")
|
||||
if total == "0":
|
||||
logger.debug(f"[{law_name}] 검색 결과 없음")
|
||||
return 0
|
||||
|
||||
# 정확히 일치하는 법령 찾기
|
||||
for law_elem in root.findall(".//law"):
|
||||
found_name = law_elem.findtext("법령명한글", "").strip()
|
||||
if found_name != law_name:
|
||||
continue
|
||||
|
||||
mst = law_elem.findtext("법령일련번호", "")
|
||||
proclamation_date = law_elem.findtext("공포일자", "")
|
||||
revision_type = law_elem.findtext("제개정구분명", "")
|
||||
|
||||
if not mst:
|
||||
continue
|
||||
|
||||
# 이미 등록된 법령인지 확인 (같은 법령명 + 공포일자)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
Document.title.like(f"{law_name}%"),
|
||||
Document.source_channel == "law_monitor",
|
||||
)
|
||||
)
|
||||
existing_docs = existing.scalars().all()
|
||||
|
||||
# 같은 공포일자 이미 있으면 skip
|
||||
for doc in existing_docs:
|
||||
if proclamation_date in (doc.title or ""):
|
||||
return 0
|
||||
|
||||
# 이전 공포일 찾기 (변경 이력용)
|
||||
prev_date = ""
|
||||
if existing_docs:
|
||||
prev_date = max(
|
||||
(re.search(r'\d{8}', doc.title or "").group() for doc in existing_docs
|
||||
if re.search(r'\d{8}', doc.title or "")),
|
||||
default=""
|
||||
)
|
||||
|
||||
# 본문 조회 (lawService.do)
|
||||
text_resp = await client.get(
|
||||
LAW_SERVICE_URL,
|
||||
params={"OC": law_oc, "target": "law", "MST": mst, "type": "XML"},
|
||||
)
|
||||
text_resp.raise_for_status()
|
||||
|
||||
# 분할 저장
|
||||
count = await _save_law_split(
|
||||
session, text_resp.text, law_name, proclamation_date,
|
||||
revision_type, prev_date,
|
||||
)
|
||||
|
||||
# DB 먼저 커밋 (알림 실패가 저장을 막지 않도록)
|
||||
await session.commit()
|
||||
|
||||
# CalDAV + SMTP 알림 (실패해도 무시)
|
||||
try:
|
||||
_send_notifications(law_name, proclamation_date, revision_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{law_name}] 알림 발송 실패 (무시): {e}")
|
||||
|
||||
return count
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def _save_law_split(
|
||||
session, xml_text: str, law_name: str, proclamation_date: str,
|
||||
revision_type: str, prev_date: str,
|
||||
) -> int:
|
||||
"""법령 XML → 장(章) 단위 Markdown 분할 저장"""
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
# 조문단위에서 장 구분자 찾기 (조문키가 000으로 끝나는 조문)
|
||||
units = root.findall(".//조문단위")
|
||||
chapters = [] # [(장제목, [조문들])]
|
||||
current_chapter = None
|
||||
current_articles = []
|
||||
|
||||
for unit in units:
|
||||
key = unit.attrib.get("조문키", "")
|
||||
content = (unit.findtext("조문내용", "") or "").strip()
|
||||
|
||||
# 장 구분자: 키가 000으로 끝나고 내용에 "제X장" 포함
|
||||
if key.endswith("000") and re.search(r"제\d+장", content):
|
||||
# 이전 장 저장
|
||||
if current_chapter and current_articles:
|
||||
chapters.append((current_chapter, current_articles))
|
||||
chapter_match = re.search(r"(제\d+장\s*.+)", content)
|
||||
current_chapter = chapter_match.group(1).strip() if chapter_match else content.strip()
|
||||
current_articles = []
|
||||
else:
|
||||
current_articles.append(unit)
|
||||
|
||||
# 마지막 장 저장
|
||||
if current_chapter and current_articles:
|
||||
chapters.append((current_chapter, current_articles))
|
||||
|
||||
# 장 분할 성공
|
||||
sections = []
|
||||
if chapters:
|
||||
for chapter_title, articles in chapters:
|
||||
md_lines = [f"# {law_name}\n", f"## {chapter_title}\n"]
|
||||
for article in articles:
|
||||
title = article.findtext("조문제목", "")
|
||||
content = article.findtext("조문내용", "")
|
||||
if title:
|
||||
md_lines.append(f"\n### {title}\n")
|
||||
if content:
|
||||
md_lines.append(content.strip())
|
||||
section_name = _safe_name(chapter_title)
|
||||
sections.append((section_name, "\n".join(md_lines)))
|
||||
else:
|
||||
# 장 분할 실패 → 전체 1파일
|
||||
full_md = _law_xml_to_markdown(xml_text, law_name)
|
||||
sections.append(("전문", full_md))
|
||||
|
||||
# 각 섹션 저장
|
||||
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for section_name, content in sections:
|
||||
filename = f"{law_name}_{proclamation_date}_{section_name}.md"
|
||||
file_path = inbox_dir / filename
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
|
||||
|
||||
# 변경 이력 메모
|
||||
note = ""
|
||||
if prev_date:
|
||||
note = (
|
||||
f"[자동] 법령 개정 감지\n"
|
||||
f"이전 공포일: {prev_date}\n"
|
||||
f"현재 공포일: {proclamation_date}\n"
|
||||
f"개정구분: {revision_type}"
|
||||
)
|
||||
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=file_hash(file_path),
|
||||
file_format="md",
|
||||
file_size=len(content.encode()),
|
||||
file_type="immutable",
|
||||
title=f"{law_name} ({proclamation_date}) {section_name}",
|
||||
source_channel="law_monitor",
|
||||
data_origin="work",
|
||||
user_note=note or None,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id, stage="extract", status="pending",
|
||||
))
|
||||
count += 1
|
||||
|
||||
logger.info(f"[법령] {law_name} ({proclamation_date}) → {count}개 섹션 저장")
|
||||
return count
|
||||
|
||||
|
||||
def _xml_section_to_markdown(elem) -> str:
|
||||
"""XML 섹션(편/장)을 Markdown으로 변환"""
|
||||
lines = []
|
||||
for article in elem.iter():
|
||||
tag = article.tag
|
||||
text = (article.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
else:
|
||||
lines.append(text)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _law_xml_to_markdown(xml_text: str, law_name: str) -> str:
|
||||
"""법령 XML 전체를 Markdown으로 변환"""
|
||||
root = ET.fromstring(xml_text)
|
||||
lines = [f"# {law_name}\n"]
|
||||
|
||||
for elem in root.iter():
|
||||
tag = elem.tag
|
||||
text = (elem.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "편" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "장" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""파일명 안전 변환"""
|
||||
return re.sub(r'[^\w가-힣-]', '_', name).strip("_")
|
||||
|
||||
|
||||
def _send_notifications(law_name: str, proclamation_date: str, revision_type: str):
|
||||
"""CalDAV + SMTP 알림"""
|
||||
# CalDAV
|
||||
caldav_url = os.getenv("CALDAV_URL", "")
|
||||
caldav_user = os.getenv("CALDAV_USER", "")
|
||||
caldav_pass = os.getenv("CALDAV_PASS", "")
|
||||
if caldav_url and caldav_user:
|
||||
create_caldav_todo(
|
||||
caldav_url, caldav_user, caldav_pass,
|
||||
title=f"법령 검토: {law_name}",
|
||||
description=f"공포일자: {proclamation_date}, 개정구분: {revision_type}",
|
||||
due_days=7,
|
||||
)
|
||||
|
||||
# SMTP
|
||||
smtp_host = os.getenv("MAILPLUS_HOST", "")
|
||||
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
|
||||
smtp_user = os.getenv("MAILPLUS_USER", "")
|
||||
smtp_pass = os.getenv("MAILPLUS_PASS", "")
|
||||
if smtp_host and smtp_user:
|
||||
send_smtp_email(
|
||||
smtp_host, smtp_port, smtp_user, smtp_pass,
|
||||
subject=f"[법령 변경] {law_name} ({revision_type})",
|
||||
body=f"법령명: {law_name}\n공포일자: {proclamation_date}\n개정구분: {revision_type}",
|
||||
)
|
||||
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}")
|
||||
134
app/workers/queue_consumer.py
Normal file
134
app/workers/queue_consumer.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""처리 큐 소비자 — APScheduler에서 1분 간격으로 호출"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("queue_consumer")
|
||||
|
||||
# stage별 배치 크기
|
||||
BATCH_SIZE = {"extract": 5, "classify": 3, "embed": 1, "preview": 2}
|
||||
STALE_THRESHOLD_MINUTES = 10
|
||||
|
||||
|
||||
async def reset_stale_items():
|
||||
"""processing 상태로 10분 이상 방치된 항목 복구"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=STALE_THRESHOLD_MINUTES)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
update(ProcessingQueue)
|
||||
.where(
|
||||
ProcessingQueue.status == "processing",
|
||||
ProcessingQueue.started_at < cutoff,
|
||||
)
|
||||
.values(status="pending", started_at=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
await session.commit()
|
||||
logger.warning(f"stale 항목 {result.rowcount}건 복구")
|
||||
|
||||
|
||||
async def enqueue_next_stage(document_id: int, current_stage: str):
|
||||
"""현재 stage 완료 후 다음 stage를 pending으로 등록"""
|
||||
next_stages = {"extract": ["classify", "preview"], "classify": ["embed"]}
|
||||
stages = next_stages.get(current_stage, [])
|
||||
if not stages:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
for next_stage in stages:
|
||||
existing = await session.execute(
|
||||
select(ProcessingQueue).where(
|
||||
ProcessingQueue.document_id == document_id,
|
||||
ProcessingQueue.stage == next_stage,
|
||||
ProcessingQueue.status.in_(["pending", "processing"]),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
session.add(ProcessingQueue(
|
||||
document_id=document_id,
|
||||
stage=next_stage,
|
||||
status="pending",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def consume_queue():
|
||||
"""큐에서 pending 항목을 가져와 stage별 워커 실행"""
|
||||
from workers.classify_worker import process as classify_process
|
||||
from workers.embed_worker import process as embed_process
|
||||
from workers.extract_worker import process as extract_process
|
||||
from workers.preview_worker import process as preview_process
|
||||
|
||||
workers = {
|
||||
"extract": extract_process,
|
||||
"classify": classify_process,
|
||||
"embed": embed_process,
|
||||
"preview": preview_process,
|
||||
}
|
||||
|
||||
await reset_stale_items()
|
||||
|
||||
for stage, worker_fn in workers.items():
|
||||
batch_size = BATCH_SIZE.get(stage, 3)
|
||||
|
||||
# pending 항목 조회
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(ProcessingQueue.id, ProcessingQueue.document_id)
|
||||
.where(
|
||||
ProcessingQueue.stage == stage,
|
||||
ProcessingQueue.status == "pending",
|
||||
)
|
||||
.order_by(ProcessingQueue.created_at)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pending_items = result.all()
|
||||
|
||||
# 각 항목을 독립 세션에서 처리
|
||||
for queue_id, document_id in pending_items:
|
||||
# 상태를 processing으로 변경
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if not item or item.status != "pending":
|
||||
continue
|
||||
item.status = "processing"
|
||||
item.started_at = datetime.now(timezone.utc)
|
||||
item.attempts += 1
|
||||
await session.commit()
|
||||
|
||||
# 워커 실행 (독립 세션)
|
||||
try:
|
||||
async with async_session() as worker_session:
|
||||
await worker_fn(document_id, worker_session)
|
||||
await worker_session.commit()
|
||||
|
||||
# 완료 처리
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
logger.info(f"[{stage}] document_id={document_id} 완료")
|
||||
|
||||
except Exception as e:
|
||||
# 실패 처리
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
item.error_message = str(e)[:500]
|
||||
if item.attempts >= item.max_attempts:
|
||||
item.status = "failed"
|
||||
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
|
||||
else:
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
|
||||
await session.commit()
|
||||
@@ -1,102 +0,0 @@
|
||||
-- DEVONthink 4 Smart Rule: AI 자동 분류
|
||||
-- Inbox DB 새 문서 → Ollama 분류 → 태그 + 메타데이터 + 도메인 DB 이동
|
||||
-- Smart Rule 설정: Event = On Import, 조건 = Tags is empty
|
||||
|
||||
on performSmartRule(theRecords)
|
||||
tell application id "DNtp"
|
||||
repeat with theRecord in theRecords
|
||||
try
|
||||
-- 1. 문서 텍스트 추출 (최대 4000자)
|
||||
set docText to plain text of theRecord
|
||||
set docUUID to uuid of theRecord
|
||||
|
||||
if length of docText > 4000 then
|
||||
set docText to text 1 thru 4000 of docText
|
||||
end if
|
||||
|
||||
if length of docText < 10 then
|
||||
-- 텍스트가 너무 짧으면 건너뜀
|
||||
set tags of theRecord to {"@상태/검토필요"}
|
||||
continue repeat
|
||||
end if
|
||||
|
||||
-- 2. 분류 프롬프트 로딩
|
||||
set promptPath to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/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
|
||||
add custom meta data "inbox_route" for "sourceChannel" 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 embedScript to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/venv/bin/python3"
|
||||
set embedPy to (POSIX path of (path to home folder)) & "Documents/code/DEVONThink_my server/scripts/embed_to_chroma.py"
|
||||
do shell script embedScript & " " & 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 & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/auto_classify.log"
|
||||
end try
|
||||
end repeat
|
||||
end tell
|
||||
end performSmartRule
|
||||
@@ -1,71 +0,0 @@
|
||||
-- DEVONthink 4 Smart Rule: OmniFocus 연동
|
||||
-- Projects DB 새 문서에서 TODO 패턴 감지 → OmniFocus 작업 생성
|
||||
-- Smart Rule 설정: Event = On Import, DB = Projects
|
||||
|
||||
on performSmartRule(theRecords)
|
||||
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 & "' >> ~/Documents/code/DEVONThink_my\\ server/logs/omnifocus_sync.log"
|
||||
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,29 +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=
|
||||
|
||||
# ─── 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:
|
||||
File diff suppressed because it is too large
Load Diff
2375
docs/architecture.md
2375
docs/architecture.md
File diff suppressed because it is too large
Load Diff
@@ -1,302 +0,0 @@
|
||||
# Claude Code 실행 명령어 — PKM 시스템 구축
|
||||
|
||||
> 작업 위치: MacBook Pro ~/Documents/code/DEVONThink_my server/
|
||||
> Claude Code를 이 디렉토리에서 실행
|
||||
> 완성 후 Gitea에 push → Mac mini에서 pull
|
||||
|
||||
```
|
||||
개발/배포 흐름:
|
||||
MacBook Pro (Claude Code)
|
||||
~/Documents/code/DEVONThink_my server/
|
||||
→ 스크립트/설정 파일 작성
|
||||
→ git commit & push
|
||||
│
|
||||
▼
|
||||
Gitea (Synology NAS)
|
||||
│
|
||||
▼
|
||||
Mac mini (git pull → 실행)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0단계: 프로젝트 구조 생성 + credentials.env 복사
|
||||
|
||||
Claude Code 실행 전에 먼저:
|
||||
|
||||
```bash
|
||||
# MacBook Pro에서
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
|
||||
# credentials.env를 프로젝트에 복사 (gitignore 필수!)
|
||||
cp ~/.config/pkm/credentials.env ./credentials.env.example
|
||||
# example은 값을 비운 템플릿용, 실제 파일은 Mac mini에서 직접 생성
|
||||
|
||||
# Mac mini에서 (SSH 접속 후)
|
||||
mkdir -p ~/.config/pkm
|
||||
nano ~/.config/pkm/credentials.env
|
||||
# → 실제 인증 정보 입력
|
||||
chmod 600 ~/.config/pkm/credentials.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1단계: 프로젝트 구조 + requirements.txt
|
||||
|
||||
```
|
||||
이 프로젝트의 디렉토리 구조를 만들고 기본 설정 파일들을 생성해줘.
|
||||
작업 디렉토리: 현재 디렉토리 (~/Documents/code/DEVONThink_my server/)
|
||||
|
||||
프로젝트 구조:
|
||||
./
|
||||
├── README.md ← 프로젝트 설명
|
||||
├── requirements.txt ← Python 패키지 목록
|
||||
├── .gitignore ← credentials.env, venv, logs, __pycache__ 등 제외
|
||||
├── credentials.env.example ← 인증 정보 템플릿 (값은 비움)
|
||||
├── scripts/
|
||||
│ ├── law_monitor.py
|
||||
│ ├── mailplus_archive.py
|
||||
│ ├── pkm_daily_digest.py
|
||||
│ ├── embed_to_chroma.py
|
||||
│ └── prompts/
|
||||
│ └── classify_document.txt
|
||||
├── applescript/
|
||||
│ ├── auto_classify.scpt
|
||||
│ └── omnifocus_sync.scpt
|
||||
├── launchd/
|
||||
│ ├── net.hyungi.pkm.law-monitor.plist
|
||||
│ ├── net.hyungi.pkm.mailplus.plist
|
||||
│ └── net.hyungi.pkm.daily-digest.plist
|
||||
├── docs/
|
||||
│ ├── devonagent-setup.md
|
||||
│ └── deploy.md ← Mac mini 배포 방법
|
||||
└── tests/
|
||||
└── test_classify.py
|
||||
|
||||
requirements.txt에 넣을 패키지:
|
||||
- chromadb
|
||||
- requests
|
||||
- python-dotenv
|
||||
- schedule
|
||||
- markdown
|
||||
|
||||
.gitignore에 반드시 포함:
|
||||
- credentials.env
|
||||
- venv/
|
||||
- logs/
|
||||
- __pycache__/
|
||||
- *.pyc
|
||||
- .DS_Store
|
||||
|
||||
deploy.md에는 Mac mini에서의 설치 절차 작성:
|
||||
1. git pull
|
||||
2. python3 -m venv venv && source venv/bin/activate
|
||||
3. pip install -r requirements.txt
|
||||
4. credentials.env는 ~/.config/pkm/credentials.env에 별도 관리
|
||||
5. launchd plist 심볼릭 링크 등록 방법
|
||||
|
||||
네트워크 환경:
|
||||
- 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 (나중에 활성화)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2단계: Ollama 모델 확인 + 분류 프롬프트 테스트
|
||||
|
||||
```
|
||||
Ollama가 정상 동작하는지 확인하고, PKM 문서 분류용 프롬프트를 테스트해줘.
|
||||
|
||||
1. ollama list로 현재 모델 확인
|
||||
2. qwen3.5:35b-a3b 계열 모델이 있는지 확인 (없으면 알려줘)
|
||||
3. 테스트 프롬프트 실행 — 아래 내용으로 분류 테스트:
|
||||
|
||||
테스트 문서: "산업안전보건법 시행규칙 일부개정령안 입법예고 - 고용노동부는 위험성평가에 관한 지침을 개정하여..."
|
||||
|
||||
기대 응답 JSON:
|
||||
{
|
||||
"tags": ["위험성평가", "법령개정", "고용노동부"],
|
||||
"domain_db": "04_Industrial safety",
|
||||
"sub_group": "10_Legislation/Notice",
|
||||
"sourceChannel": "inbox_route",
|
||||
"dataOrigin": "external"
|
||||
}
|
||||
|
||||
도메인 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
|
||||
|
||||
sourceChannel 값: tksafety, devonagent, law_monitor, inbox_route, email, web_clip, manual
|
||||
dataOrigin 값: work (자사 업무), external (외부 참고)
|
||||
|
||||
프롬프트를 최적화해서 ~/Documents/code/DEVONThink_my server/scripts/prompts/ 디렉토리에 저장해줘.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3단계: DEVONthink Smart Rule + AppleScript 배포
|
||||
|
||||
```
|
||||
DEVONthink 4 Smart Rule용 AppleScript 모듈들을 만들어줘.
|
||||
Mac mini에서 DEVONthink가 실행 중이야.
|
||||
|
||||
모듈 A: Ollama 연동 자동 분류 (~/Documents/code/DEVONThink_my server/applescript/auto_classify.scpt)
|
||||
- DEVONthink Inbox DB에 새 문서가 들어오면 실행
|
||||
- Ollama qwen3.5 35B에 문서 텍스트 전송
|
||||
- 응답에서 tags, domain_db, sub_group, sourceChannel, dataOrigin 파싱
|
||||
- DEVONthink 태그 설정 + 커스텀 메타데이터(sourceChannel, dataOrigin, lastAIProcess) 설정
|
||||
- 해당 도메인 DB의 하위 그룹으로 문서 이동
|
||||
- GPU 서버(Tailscale IP)로 벡터 임베딩 비동기 전송
|
||||
|
||||
모듈 B: OmniFocus 연동 (~/Documents/code/DEVONThink_my server/applescript/omnifocus_sync.scpt)
|
||||
- Projects DB에 새 문서 추가 시 TODO 패턴 감지
|
||||
- OmniFocus에 작업 생성 (DEVONthink 링크 포함)
|
||||
- 커스텀 메타데이터에 omnifocusTaskID 저장
|
||||
|
||||
프롬프트 파일 위치: ~/Documents/code/DEVONThink_my server/scripts/prompts/
|
||||
인증 정보: ~/.config/pkm/credentials.env
|
||||
GPU 서버 Tailscale IP는 별도 확인 필요 (나중에 추가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4단계: 법령 모니터링 스크립트
|
||||
|
||||
```
|
||||
한국 법령 변경 모니터링 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/law_monitor.py
|
||||
인증: ~/.config/pkm/credentials.env의 LAW_OC 값 사용
|
||||
API: open.law.go.kr OpenAPI
|
||||
|
||||
기능:
|
||||
1. 산업안전보건법, 중대재해처벌법, 관련 시행령/시행규칙/고시 변경 추적
|
||||
2. 변경 감지 시:
|
||||
- 법령 본문(XML) 다운로드 → ~/Documents/code/DEVONThink_my server/data/laws/에 저장
|
||||
- DEVONthink 04_Industrial Safety/10_Legislation/ 하위에 자동 임포트 (AppleScript 호출)
|
||||
- 커스텀 메타데이터: sourceChannel=law_monitor, dataOrigin=external
|
||||
- 로그: ~/Documents/code/DEVONThink_my server/logs/law_monitor.log
|
||||
3. launchd plist 생성: 매일 07:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.law-monitor.plist
|
||||
→ ~/Library/LaunchAgents/에 심볼릭 링크
|
||||
|
||||
※ 법령 API 승인 대기중이라 스크립트만 만들고 실제 테스트는 승인 후에
|
||||
※ 해외 법령(US OSHA, JP, EU)은 나중에 추가 예정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: MailPlus → DEVONthink 이메일 수집
|
||||
|
||||
```
|
||||
MailPlus 이메일을 DEVONthink Archive DB로 자동 수집하는 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/mailplus_archive.py
|
||||
인증: ~/.config/pkm/credentials.env
|
||||
|
||||
접속 정보:
|
||||
- IMAP 서버: mailplus.hyungi.net:993 (SSL)
|
||||
- 계정: hyungi
|
||||
|
||||
기능:
|
||||
1. IMAP으로 MailPlus 접속
|
||||
2. 마지막 동기화 이후 새 메일 가져오기
|
||||
3. DEVONthink Archive DB에 임포트 (AppleScript 호출)
|
||||
4. 커스텀 메타데이터: sourceChannel=email
|
||||
5. 안전 관련 키워드 감지 시 dataOrigin 자동 판별
|
||||
|
||||
launchd: 매일 07:00 + 18:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.mailplus.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6단계: Daily Digest 시스템
|
||||
|
||||
```
|
||||
PKM 일일 다이제스트를 자동 생성하는 스크립트를 만들어줘.
|
||||
|
||||
스크립트: ~/Documents/code/DEVONThink_my server/scripts/pkm_daily_digest.py
|
||||
인증: ~/.config/pkm/credentials.env
|
||||
|
||||
기능:
|
||||
1. DEVONthink에서 오늘 추가/수정된 문서 집계 (AppleScript로 쿼리)
|
||||
- DB별 신규 건수
|
||||
- sourceChannel별 구분
|
||||
2. law_monitor 로그에서 법령 변경 건 파싱
|
||||
3. OmniFocus 오늘 완료/추가/기한초과 집계 (AppleScript)
|
||||
4. 상위 뉴스 3건 요약 (Ollama 35B 호출)
|
||||
5. MD 파일 생성 → DEVONthink 00_Note_BOX/Daily_Digest/에 저장
|
||||
파일명: YYYY-MM-DD_digest.md
|
||||
6. OmniFocus 액션 자동 생성 (법령변경, overdue, Inbox 미처리 등)
|
||||
7. 90일 지난 다이제스트 → 90_Archive 이동 (Smart Rule 대체)
|
||||
|
||||
launchd: 매일 20:00 실행
|
||||
~/Documents/code/DEVONThink_my server/launchd/net.hyungi.pkm.daily-digest.plist
|
||||
|
||||
※ Synology Chat 웹훅 알림은 나중에 추가 (CHAT_WEBHOOK_URL 설정 후)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7단계: DEVONagent 검색 세트 (수동 가이드)
|
||||
|
||||
```
|
||||
DEVONagent Pro에서 안전 분야 자동 검색 세트를 설정하는 가이드를 만들어줘.
|
||||
|
||||
총 9개 검색 세트 (7 안전 + 2 기술):
|
||||
1. 국내 산업안전 뉴스 (kosha, moel, safetynews 등)
|
||||
2. 국내 중대재해 뉴스
|
||||
3. KOSHA 가이드/지침
|
||||
4. 국내 산업안전 학술/논문
|
||||
5. US OSHA / Safety+Health Magazine
|
||||
6. JP 厚生労働省 / 安全衛生
|
||||
7. EU-OSHA
|
||||
8. 기술 뉴스 (AI/서버/네트워크)
|
||||
9. 프로그래밍 기술 동향
|
||||
|
||||
각 세트별로:
|
||||
- 검색 키워드/연산자
|
||||
- 사이트 제한
|
||||
- 스케줄 (매일/주간)
|
||||
- 수량 제한 (주간 합계 50~85건 수준)
|
||||
- 결과 → DEVONthink Inbox로 전송 설정 방법
|
||||
|
||||
이건 DEVONagent GUI에서 수동 설정해야 하니까,
|
||||
단계별 가이드 문서를 ~/Documents/code/DEVONThink_my server/docs/devonagent-setup.md로 만들어줘.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8단계: 전체 테스트
|
||||
|
||||
```
|
||||
PKM 시스템 전체 End-to-End 테스트를 진행해줘.
|
||||
|
||||
테스트 항목:
|
||||
1. Ollama 분류 테스트 — 5종 문서(법령, 뉴스, 논문, 메모, 이메일) 분류 정확도
|
||||
2. DEVONthink Smart Rule — Inbox에 테스트 문서 추가 → 자동 분류 확인
|
||||
3. sourceChannel/dataOrigin 메타데이터가 정상 설정되는지
|
||||
4. OmniFocus 연동 — TODO 패턴 문서 → 작업 자동 생성
|
||||
5. MailPlus IMAP 접속 테스트
|
||||
6. launchd 스케줄 등록 확인 (launchctl list | grep pkm)
|
||||
7. Daily Digest 수동 실행 테스트
|
||||
|
||||
각 항목 pass/fail 리포트를 ~/Documents/code/DEVONThink_my server/docs/test-report.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 연결됨
|
||||
```
|
||||
184
docs/deploy.md
184
docs/deploy.md
@@ -1,96 +1,154 @@
|
||||
# Mac mini 배포 가이드
|
||||
# 배포 가이드
|
||||
|
||||
## 1. 초기 설치
|
||||
## 1. 사전 요구사항
|
||||
|
||||
- Docker & Docker Compose (Mac mini)
|
||||
- NAS SMB 마운트 (`/Volumes/Document_Server`)
|
||||
- Tailscale VPN 연결 (Mac mini ↔ GPU 서버 ↔ NAS)
|
||||
|
||||
## 2. Mac mini 배포
|
||||
|
||||
### 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 승인 후)
|
||||
|
||||
## 3. launchd 스케줄 등록
|
||||
### 2-3. NAS SMB 마운트 확인
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
## 4. 수동 테스트
|
||||
Docker 컨테이너에서 이 경로를 `/documents`로 바인드 마운트한다.
|
||||
|
||||
### 2-4. 서비스 시작
|
||||
|
||||
```bash
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
source venv/bin/activate
|
||||
docker compose up -d
|
||||
|
||||
# 각 스크립트 수동 실행
|
||||
python3 scripts/law_monitor.py
|
||||
python3 scripts/mailplus_archive.py
|
||||
python3 scripts/pkm_daily_digest.py
|
||||
# 상태 확인
|
||||
docker compose ps
|
||||
docker compose logs -f fastapi
|
||||
```
|
||||
|
||||
## 5. 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`
|
||||
|
||||
## 6. 업데이트
|
||||
### 2-5. 확인
|
||||
|
||||
```bash
|
||||
cd ~/Documents/code/DEVONThink_my\ server/
|
||||
# FastAPI OpenAPI 문서
|
||||
curl http://localhost:8000/docs
|
||||
|
||||
# PostgreSQL 테이블 확인
|
||||
docker compose exec postgres psql -U pkm -d pkm -c '\dt'
|
||||
|
||||
# kordoc 헬스체크
|
||||
curl http://localhost:3100/health
|
||||
```
|
||||
|
||||
### 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/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
|
||||
|
||||
# GPU 서버
|
||||
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 7. 로그 확인
|
||||
## 5. 로그 확인
|
||||
|
||||
```bash
|
||||
# 스크립트 로그
|
||||
tail -f logs/law_monitor.log
|
||||
tail -f logs/mailplus.log
|
||||
tail -f logs/digest.log
|
||||
# FastAPI 로그
|
||||
docker compose logs -f fastapi
|
||||
|
||||
# launchd 로그
|
||||
tail -f logs/law_monitor_launchd.log
|
||||
# 특정 워커 로그
|
||||
docker compose logs -f fastapi | grep law_monitor
|
||||
docker compose logs -f fastapi | grep mailplus
|
||||
docker compose logs -f fastapi | grep digest
|
||||
|
||||
# PostgreSQL 로그
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
## 실행 스케줄
|
||||
## 6. 자동화 스케줄 (APScheduler)
|
||||
|
||||
| 스크립트 | 시간 | 용도 |
|
||||
|---------|------|------|
|
||||
| law_monitor.py | 매일 07:00 | 법령 변경 모니터링 |
|
||||
| mailplus_archive.py | 매일 07:00, 18:00 | 이메일 수집 |
|
||||
| pkm_daily_digest.py | 매일 20:00 | 일일 다이제스트 |
|
||||
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
|
||||
docker compose exec postgres pg_dump -U pkm pkm > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
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,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.ai_domain);
|
||||
} catch (err) {
|
||||
addToast('error', 'Inbox 로딩 실패');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
selected = selected; // 반응성 트리거
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === documents.length) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(documents.map(d => d.id));
|
||||
}
|
||||
}
|
||||
|
||||
let approving = false;
|
||||
let showConfirm = false;
|
||||
|
||||
function startApprove() {
|
||||
if (selected.size === 0) {
|
||||
addToast('warning', '선택된 문서가 없습니다');
|
||||
return;
|
||||
}
|
||||
showConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
showConfirm = false;
|
||||
approving = true;
|
||||
let success = 0;
|
||||
const ids = [...selected];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
// AI 분류 결과 그대로 승인 (Inbox에서 이동은 classify_worker가 처리)
|
||||
const doc = documents.find(d => d.id === id);
|
||||
if (doc?.ai_domain) {
|
||||
await api(`/documents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source_channel: 'inbox_route' }),
|
||||
});
|
||||
success++;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
addToast('success', `${success}건 승인 완료`);
|
||||
selected = new Set();
|
||||
approving = false;
|
||||
loadInbox();
|
||||
}
|
||||
|
||||
async function updateDomain(id, domain) {
|
||||
try {
|
||||
await api(`/documents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ai_domain: domain }),
|
||||
});
|
||||
documents = documents.map(d => d.id === id ? { ...d, ai_domain: domain } : d);
|
||||
addToast('success', '도메인 변경됨');
|
||||
} catch {
|
||||
addToast('error', '변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
const DOMAINS = [
|
||||
'Knowledge/Philosophy',
|
||||
'Knowledge/Language',
|
||||
'Knowledge/Engineering',
|
||||
'Knowledge/Industrial_Safety',
|
||||
'Knowledge/Programming',
|
||||
'Knowledge/General',
|
||||
'Reference',
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">Inbox</h2>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-[var(--warning)] text-black">{documents.length}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick={toggleAll} class="px-3 py-1.5 text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg">
|
||||
{selected.size === documents.length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
<button
|
||||
onclick={startApprove}
|
||||
disabled={approving || selected.size === 0}
|
||||
class="px-4 py-1.5 text-xs bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{approving ? '처리 중...' : `선택 승인 (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="bg-[var(--surface)] rounded-lg p-4 border border-[var(--border)] animate-pulse h-24"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
<div class="text-center py-20 text-[var(--text-dim)]">
|
||||
<p class="text-lg">Inbox가 비어 있습니다</p>
|
||||
<p class="text-sm mt-1">새 파일이 들어오면 자동으로 표시됩니다</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each documents as doc}
|
||||
<div class="flex items-start gap-3 p-4 bg-[var(--surface)] border border-[var(--border)] rounded-lg" class:border-[var(--accent)]={selected.has(doc.id)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(doc.id)}
|
||||
onchange={() => toggleSelect(doc.id)}
|
||||
class="mt-1 accent-[var(--accent)]"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
|
||||
<a href="/documents/{doc.id}" class="text-sm font-medium hover:text-[var(--accent)] truncate">{doc.title || '제목 없음'}</a>
|
||||
</div>
|
||||
{#if doc.ai_summary}
|
||||
<p class="text-xs text-[var(--text-dim)] truncate">{doc.ai_summary.slice(0, 120)}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-[var(--text-dim)]">AI 분류:</span>
|
||||
<select
|
||||
value={doc.ai_domain || ''}
|
||||
onchange={(e) => updateDomain(doc.id, e.target.value)}
|
||||
class="text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--text)]"
|
||||
>
|
||||
<option value="">미분류</option>
|
||||
{#each DOMAINS as d}
|
||||
<option value={d}>{d.replace('Knowledge/', '')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if doc.ai_tags?.length > 0}
|
||||
<div class="flex gap-1 ml-2">
|
||||
{#each doc.ai_tags.slice(0, 3) as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg)] rounded text-[var(--accent)]">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 확인 다이얼로그 -->
|
||||
{#if showConfirm}
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-2">{selected.size}건을 승인합니다</h3>
|
||||
<p class="text-sm text-[var(--text-dim)] mb-4">AI 분류 결과를 확정하고 Inbox에서 이동합니다.</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick={() => showConfirm = false} class="px-4 py-2 text-sm bg-[var(--bg)] border border-[var(--border)] rounded-lg">취소</button>
|
||||
<button onclick={confirmApprove} class="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded-lg">승인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
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,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;
|
||||
@@ -1,6 +0,0 @@
|
||||
chromadb>=0.4.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
schedule>=1.2.0
|
||||
markdown>=3.5.0
|
||||
anthropic>=0.40.0
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
벡터 임베딩 스크립트
|
||||
- DEVONthink 문서 UUID로 텍스트 추출
|
||||
- GPU 서버(nomic-embed-text)로 임베딩 생성
|
||||
- ChromaDB에 저장
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import setup_logger, load_credentials, run_applescript_inline
|
||||
|
||||
logger = setup_logger("embed")
|
||||
|
||||
# ChromaDB 저장 경로
|
||||
CHROMA_DIR = Path.home() / ".local" / "share" / "pkm" / "chromadb"
|
||||
CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_document_text(uuid: str) -> tuple[str, str]:
|
||||
"""DEVONthink에서 UUID로 문서 텍스트 + 제목 추출"""
|
||||
script = f'''
|
||||
tell application id "DNtp"
|
||||
set theRecord to get record with uuid "{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 서버의 nomic-embed-text로 임베딩 생성"""
|
||||
url = f"http://{gpu_server_ip}:11434/api/embeddings"
|
||||
try:
|
||||
resp = requests.post(url, json={
|
||||
"model": "nomic-embed-text",
|
||||
"prompt": text[:8000] # 토큰 제한
|
||||
}, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("embedding")
|
||||
except Exception as e:
|
||||
logger.error(f"임베딩 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def store_in_chromadb(doc_id: str, title: str, text: str, embedding: list[float]):
|
||||
"""ChromaDB에 저장"""
|
||||
import chromadb
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
collection = client.get_or_create_collection(
|
||||
name="pkm_documents",
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
collection.upsert(
|
||||
ids=[doc_id],
|
||||
embeddings=[embedding],
|
||||
documents=[text[:2000]],
|
||||
metadatas=[{"title": title, "source": "devonthink"}]
|
||||
)
|
||||
logger.info(f"ChromaDB 저장: {doc_id} ({title[:30]})")
|
||||
|
||||
|
||||
def run(uuid: str):
|
||||
"""단일 문서 임베딩 처리"""
|
||||
logger.info(f"임베딩 처리 시작: {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(uuid)
|
||||
if not text or len(text) < 10:
|
||||
logger.warning(f"텍스트 부족 [{uuid}]: {len(text)}자")
|
||||
return
|
||||
|
||||
embedding = get_embedding(text, gpu_ip)
|
||||
if embedding:
|
||||
store_in_chromadb(uuid, title, text, embedding)
|
||||
logger.info(f"임베딩 완료: {uuid}")
|
||||
else:
|
||||
logger.error(f"임베딩 실패: {uuid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"임베딩 처리 에러 [{uuid}]: {e}", exc_info=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("사용법: python3 embed_to_chroma.py <DEVONthink_UUID>")
|
||||
sys.exit(1)
|
||||
run(sys.argv[1])
|
||||
@@ -1,400 +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
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
# 모니터링 대상 법령
|
||||
MONITORED_LAWS = [
|
||||
{"name": "산업안전보건법", "law_id": "001789", "category": "법률"},
|
||||
{"name": "산업안전보건법 시행령", "law_id": "001790", "category": "대통령령"},
|
||||
{"name": "산업안전보건법 시행규칙", "law_id": "001791", "category": "부령"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률", "law_id": "019005", "category": "법률"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률 시행령", "law_id": "019006", "category": "대통령령"},
|
||||
{"name": "화학물질관리법", "law_id": "012354", "category": "법률"},
|
||||
{"name": "위험물안전관리법", "law_id": "001478", "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):
|
||||
"""마지막 확인 일자 저장"""
|
||||
with open(LAST_CHECK_FILE, "w") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def fetch_law_info(law_oc: str, law_id: str) -> dict | None:
|
||||
"""법령 정보 조회 (법령 API)"""
|
||||
url = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
params = {
|
||||
"OC": law_oc,
|
||||
"target": "law",
|
||||
"type": "JSON",
|
||||
"MST": law_id,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# API 에러 응답 감지
|
||||
if "result" in data and "실패" in str(data.get("result", "")):
|
||||
logger.error(f"법령 API 에러 [{law_id}]: {data.get('result')} — {data.get('msg')}")
|
||||
return None
|
||||
if "LawSearch" in data and "law" in data["LawSearch"]:
|
||||
laws = data["LawSearch"]["law"]
|
||||
if isinstance(laws, list):
|
||||
return laws[0] if laws else None
|
||||
return laws
|
||||
logger.warning(f"법령 응답에 데이터 없음 [{law_id}]: {list(data.keys())}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"법령 조회 실패 [{law_id}]: {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_to_devonthink(filepath: Path, law_name: str, category: str):
|
||||
"""DEVONthink 04_Industrial Safety로 임포트 — 변수 방식"""
|
||||
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 += ' set targetGroup to create location "/10_Legislation/Law" 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)
|
||||
logger.info(f"DEVONthink 임포트 완료: {law_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패 [{law_name}]: {e}")
|
||||
|
||||
|
||||
def run():
|
||||
"""메인 실행"""
|
||||
logger.info("=== 법령 모니터링 시작 ===")
|
||||
|
||||
creds = load_credentials()
|
||||
law_oc = creds.get("LAW_OC")
|
||||
if not law_oc:
|
||||
logger.error("LAW_OC 인증키가 설정되지 않았습니다. credentials.env를 확인하세요.")
|
||||
sys.exit(1)
|
||||
|
||||
last_check = load_last_check()
|
||||
changes_found = 0
|
||||
|
||||
for law in MONITORED_LAWS:
|
||||
law_name = law["name"]
|
||||
law_id = law["law_id"]
|
||||
category = law["category"]
|
||||
|
||||
logger.info(f"확인 중: {law_name} ({law_id})")
|
||||
|
||||
info = fetch_law_info(law_oc, law_id)
|
||||
if not info:
|
||||
continue
|
||||
|
||||
# 시행일자 또는 공포일자로 변경 감지
|
||||
announce_date = info.get("공포일자", info.get("시행일자", ""))
|
||||
prev_date = last_check.get(law_id, "")
|
||||
|
||||
if announce_date and announce_date != prev_date:
|
||||
logger.info(f"변경 감지: {law_name} — 공포일자 {announce_date} (이전: {prev_date or '없음'})")
|
||||
|
||||
# 법령 본문 다운로드
|
||||
law_mst = info.get("법령MST", law_id)
|
||||
text = fetch_law_text(law_oc, law_mst)
|
||||
if text:
|
||||
filepath = save_law_file(law_name, text)
|
||||
import_to_devonthink(filepath, law_name, category)
|
||||
changes_found += 1
|
||||
|
||||
last_check[law_id] = announce_date
|
||||
else:
|
||||
logger.debug(f"변경 없음: {law_name}")
|
||||
|
||||
save_last_check(last_check)
|
||||
|
||||
# ─── 외국 법령 (빈도 체크 후 실행) ───
|
||||
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}"
|
||||
)
|
||||
# thinking 출력 제거 — 마지막 줄만 사용
|
||||
lines = [l.strip() for l in translated.strip().split("\n") if l.strip()]
|
||||
translated = lines[-1] if lines else title
|
||||
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__":
|
||||
run()
|
||||
@@ -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()
|
||||
235
scripts/migrate_from_devonthink.py
Normal file
235
scripts/migrate_from_devonthink.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""DEVONthink → NAS PKM 마이그레이션 스크립트
|
||||
|
||||
.dtBase2 번들의 Files.noindex/ 디렉토리에서 직접 파일을 추출하여
|
||||
NAS PKM 폴더 구조로 복사하고 DB에 등록합니다.
|
||||
|
||||
사용법:
|
||||
# Dry-run (실제 복사/DB 등록 없이 시뮬레이션)
|
||||
python scripts/migrate_from_devonthink.py --source-dir ~/Documents/Databases --dry-run
|
||||
|
||||
# 실제 실행
|
||||
python scripts/migrate_from_devonthink.py \
|
||||
--source-dir ~/Documents/Databases \
|
||||
--target-dir /mnt/nas/Document_Server \
|
||||
--database-url postgresql+asyncpg://pkm:PASSWORD@localhost:15432/pkm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from core.utils import file_hash, setup_logger
|
||||
|
||||
logger = setup_logger("migrate")
|
||||
|
||||
# ─── DEVONthink DB → NAS PKM 폴더 매핑 ───
|
||||
FOLDER_MAPPING = {
|
||||
"00_Inbox DB": "PKM/Inbox",
|
||||
"Inbox": "PKM/Inbox",
|
||||
"00_Note_BOX": "PKM/Knowledge",
|
||||
"01_Philosophie": "PKM/Knowledge/Philosophy",
|
||||
"02_Language": "PKM/Knowledge/Language",
|
||||
"03_Engineering": "PKM/Knowledge/Engineering",
|
||||
"04_Industrial safety": "PKM/Knowledge/Industrial_Safety",
|
||||
"05_Programming": "PKM/Knowledge/Programming",
|
||||
"07_General Book": "PKM/Knowledge/General",
|
||||
"97_Production drawing": "PKM/References",
|
||||
"99_Reference Data": "PKM/References",
|
||||
"99_Home File": "PKM/References",
|
||||
"Archive": "PKM/Archive",
|
||||
"Projects": "PKM/Knowledge",
|
||||
"99_Technicalkorea": "Technicalkorea",
|
||||
# 스킵 대상
|
||||
"98_명일방주 엔드필드": None,
|
||||
}
|
||||
|
||||
# 무시할 파일
|
||||
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "Icon\r", "Icon"}
|
||||
SKIP_EXTENSIONS = {".dtMeta", ".dtBase2", ".sparseimage", ".dtStore", ".dtCloud"}
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP_NAMES or path.name.startswith("._"):
|
||||
return True
|
||||
if path.suffix.lower() in SKIP_EXTENSIONS:
|
||||
return True
|
||||
if path.stat().st_size == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def migrate(
|
||||
source_dir: str,
|
||||
target_dir: str,
|
||||
database_url: str,
|
||||
dry_run: bool = False,
|
||||
batch_size: int = 100,
|
||||
):
|
||||
"""마이그레이션 실행"""
|
||||
source = Path(source_dir)
|
||||
target = Path(target_dir)
|
||||
|
||||
if not source.exists():
|
||||
logger.error(f"소스 디렉토리 없음: {source}")
|
||||
return
|
||||
|
||||
engine = create_async_engine(database_url)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
stats = {"total": 0, "copied": 0, "skipped": 0, "duplicates": 0, "errors": 0}
|
||||
batch = []
|
||||
|
||||
# .dtBase2 번들 탐색
|
||||
for dtbase in sorted(source.glob("*.dtBase2")):
|
||||
db_name = dtbase.stem # "04_Industrial safety"
|
||||
target_prefix = FOLDER_MAPPING.get(db_name)
|
||||
|
||||
if target_prefix is None:
|
||||
logger.info(f"[스킵] {db_name} (매핑: None)")
|
||||
continue
|
||||
|
||||
files_dir = dtbase / "Files.noindex"
|
||||
if not files_dir.exists():
|
||||
logger.warning(f"[스킵] {db_name}: Files.noindex 없음")
|
||||
continue
|
||||
|
||||
logger.info(f"[DB] {db_name} → {target_prefix}")
|
||||
|
||||
# Files.noindex 하위의 모든 파일 (format/hash/filename.ext 구조)
|
||||
files = [f for f in files_dir.rglob("*") if f.is_file() and not should_skip(f)]
|
||||
|
||||
for source_file in files:
|
||||
stats["total"] += 1
|
||||
|
||||
# 대상 경로: PKM/{domain}/{파일명}
|
||||
dest_rel = f"{target_prefix}/{source_file.name}"
|
||||
dest_path = target / dest_rel
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[DRY-RUN] {source_file.name} → {dest_rel}")
|
||||
stats["copied"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# 파일 복사
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 중복 파일명 처리
|
||||
counter = 1
|
||||
stem, suffix = dest_path.stem, dest_path.suffix
|
||||
while dest_path.exists():
|
||||
dest_path = dest_path.parent / f"{stem}_{counter}{suffix}"
|
||||
dest_rel = str(dest_path.relative_to(target))
|
||||
counter += 1
|
||||
|
||||
shutil.copy2(source_file, dest_path)
|
||||
|
||||
ext = source_file.suffix.lstrip(".").lower() or "unknown"
|
||||
fhash = file_hash(dest_path)
|
||||
fsize = dest_path.stat().st_size
|
||||
|
||||
batch.append({
|
||||
"file_path": dest_rel,
|
||||
"file_hash": fhash,
|
||||
"file_format": ext,
|
||||
"file_size": fsize,
|
||||
"file_type": "immutable",
|
||||
"import_source": f"devonthink:{db_name}",
|
||||
"title": source_file.stem,
|
||||
"source_channel": "manual",
|
||||
})
|
||||
stats["copied"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[오류] {source_file}: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
# 배치 커밋
|
||||
if len(batch) >= batch_size:
|
||||
dups = await _insert_batch(async_session_factory, batch)
|
||||
stats["duplicates"] += dups
|
||||
batch.clear()
|
||||
logger.info(f" 진행: {stats['copied']}건 처리됨")
|
||||
|
||||
# 남은 배치 처리
|
||||
if batch and not dry_run:
|
||||
dups = await _insert_batch(async_session_factory, batch)
|
||||
stats["duplicates"] += dups
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"마이그레이션 {'시뮬레이션' if dry_run else '완료'}")
|
||||
logger.info(f" 전체 파일: {stats['total']}")
|
||||
logger.info(f" 복사/등록: {stats['copied']}")
|
||||
logger.info(f" 스킵: {stats['skipped']}")
|
||||
logger.info(f" 중복: {stats['duplicates']}")
|
||||
logger.info(f" 오류: {stats['errors']}")
|
||||
|
||||
|
||||
async def _insert_batch(async_session_factory, batch: list[dict]) -> int:
|
||||
"""배치 단위로 documents + processing_queue 삽입"""
|
||||
duplicates = 0
|
||||
async with async_session_factory() as session:
|
||||
for item in batch:
|
||||
try:
|
||||
result = await session.execute(
|
||||
text("""
|
||||
INSERT INTO documents (file_path, file_hash, file_format, file_size,
|
||||
file_type, import_source, title, source_channel)
|
||||
VALUES (:file_path, :file_hash, :file_format, :file_size,
|
||||
:file_type, :import_source, :title, :source_channel)
|
||||
ON CONFLICT (file_path) DO NOTHING
|
||||
RETURNING id
|
||||
"""),
|
||||
item,
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None:
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
doc_id = row[0]
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO processing_queue (document_id, stage, status)
|
||||
VALUES (:doc_id, 'extract', 'pending')
|
||||
ON CONFLICT DO NOTHING
|
||||
"""),
|
||||
{"doc_id": doc_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"등록 실패: {item['file_path']}: {e}")
|
||||
|
||||
await session.commit()
|
||||
return duplicates
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="DEVONthink → NAS PKM 마이그레이션")
|
||||
parser.add_argument("--source-dir", required=True, help="DEVONthink Databases 디렉토리")
|
||||
parser.add_argument("--target-dir", default="/mnt/nas/Document_Server", help="NAS 루트 경로")
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default="postgresql+asyncpg://pkm:pkm@localhost:15432/pkm",
|
||||
help="PostgreSQL 연결 URL",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="시뮬레이션만 실행")
|
||||
parser.add_argument("--batch-size", type=int, default=100, help="배치 커밋 크기")
|
||||
|
||||
args = parser.parse_args()
|
||||
asyncio.run(migrate(
|
||||
source_dir=args.source_dir,
|
||||
target_dir=args.target_dir,
|
||||
database_url=args.database_url,
|
||||
dry_run=args.dry_run,
|
||||
batch_size=args.batch_size,
|
||||
))
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PKM 일일 다이제스트
|
||||
- DEVONthink 오늘 추가/수정 집계
|
||||
- law_monitor 법령 변경 건 파싱
|
||||
- OmniFocus 완료/추가/기한초과 집계
|
||||
- 상위 뉴스 Ollama 요약
|
||||
- OmniFocus 액션 자동 생성
|
||||
- 90일 지난 다이제스트 아카이브
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
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,
|
||||
ollama_generate, count_log_errors, PROJECT_ROOT, LOGS_DIR, DATA_DIR
|
||||
)
|
||||
|
||||
logger = setup_logger("digest")
|
||||
|
||||
DIGEST_DIR = DATA_DIR / "digests"
|
||||
DIGEST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def get_devonthink_stats() -> dict:
|
||||
"""DEVONthink 오늘 추가/수정 문서 집계"""
|
||||
script = '''
|
||||
tell application id "DNtp"
|
||||
set today to current date
|
||||
set time of today to 0
|
||||
set stats to {}
|
||||
|
||||
repeat with db in databases
|
||||
set dbName to name of db
|
||||
set addedCount to count of (search "date:today" in db)
|
||||
set modifiedCount to count of (search "modified:today" in db)
|
||||
|
||||
if addedCount > 0 or modifiedCount > 0 then
|
||||
set end of stats to dbName & ":" & addedCount & ":" & modifiedCount
|
||||
end if
|
||||
end repeat
|
||||
|
||||
set AppleScript's text item delimiters to "|"
|
||||
return stats as text
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
result = run_applescript_inline(script)
|
||||
stats = {}
|
||||
if result:
|
||||
for item in result.split("|"):
|
||||
parts = item.split(":")
|
||||
if len(parts) == 3:
|
||||
stats[parts[0]] = {"added": int(parts[1]), "modified": int(parts[2])}
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 집계 실패: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def get_omnifocus_stats() -> dict:
|
||||
"""OmniFocus 오늘 완료/추가/기한초과 집계"""
|
||||
script = '''
|
||||
tell application "OmniFocus"
|
||||
tell default document
|
||||
set today to current date
|
||||
set time of today to 0
|
||||
set tomorrow to today + 1 * days
|
||||
|
||||
set completedCount to count of (every flattened task whose completed is true and completion date ≥ today)
|
||||
set addedCount to count of (every flattened task whose creation date ≥ today)
|
||||
set overdueCount to count of (every flattened task whose completed is false and due date < today and due date is not missing value)
|
||||
|
||||
return (completedCount as text) & "|" & (addedCount as text) & "|" & (overdueCount as text)
|
||||
end tell
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
result = run_applescript_inline(script)
|
||||
parts = result.split("|")
|
||||
return {
|
||||
"completed": int(parts[0]) if len(parts) > 0 else 0,
|
||||
"added": int(parts[1]) if len(parts) > 1 else 0,
|
||||
"overdue": int(parts[2]) if len(parts) > 2 else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"OmniFocus 집계 실패: {e}")
|
||||
return {"completed": 0, "added": 0, "overdue": 0}
|
||||
|
||||
|
||||
def parse_law_changes() -> list:
|
||||
"""law_monitor 로그에서 오늘 법령 변경 건 파싱"""
|
||||
log_file = LOGS_DIR / "law_monitor.log"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
changes = []
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if today in line and "변경 감지" in line:
|
||||
# "[2026-03-26 07:00:15] [law_monitor] [INFO] 변경 감지: 산업안전보건법 — 공포일자 ..."
|
||||
match = re.search(r"변경 감지: (.+?)$", line)
|
||||
if match:
|
||||
changes.append(match.group(1).strip())
|
||||
return changes
|
||||
|
||||
|
||||
def get_inbox_count() -> int:
|
||||
"""DEVONthink Inbox 미처리 문서 수"""
|
||||
script = '''
|
||||
tell application id "DNtp"
|
||||
repeat with db in databases
|
||||
if name of db is "Inbox" then
|
||||
return count of children of root group of db
|
||||
end if
|
||||
end repeat
|
||||
return 0
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
return int(run_applescript_inline(script))
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def create_omnifocus_task(task_name: str, note: str = "", flagged: bool = False):
|
||||
"""OmniFocus 작업 생성"""
|
||||
flag_str = "true" if flagged else "false"
|
||||
escaped_name = task_name.replace('"', '\\"')
|
||||
escaped_note = note.replace('"', '\\"')
|
||||
script = f'''
|
||||
tell application "OmniFocus"
|
||||
tell default document
|
||||
make new inbox task with properties {{name:"{escaped_name}", note:"{escaped_note}", flagged:{flag_str}}}
|
||||
end tell
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
logger.info(f"OmniFocus 작업 생성: {task_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"OmniFocus 작업 생성 실패: {e}")
|
||||
|
||||
|
||||
def get_system_health() -> dict:
|
||||
"""각 모듈 로그의 최근 24시간 ERROR 카운트"""
|
||||
modules = ["law_monitor", "mailplus", "digest", "embed", "auto_classify"]
|
||||
health = {}
|
||||
for mod in modules:
|
||||
log_file = LOGS_DIR / f"{mod}.log"
|
||||
health[mod] = count_log_errors(log_file, since_hours=24)
|
||||
return health
|
||||
|
||||
|
||||
def generate_digest():
|
||||
"""다이제스트 생성"""
|
||||
logger.info("=== Daily Digest 생성 시작 ===")
|
||||
today = datetime.now()
|
||||
date_str = today.strftime("%Y-%m-%d")
|
||||
|
||||
# 데이터 수집
|
||||
dt_stats = get_devonthink_stats()
|
||||
of_stats = get_omnifocus_stats()
|
||||
law_changes = parse_law_changes()
|
||||
inbox_count = get_inbox_count()
|
||||
system_health = get_system_health()
|
||||
|
||||
# 마크다운 생성
|
||||
md = f"# PKM Daily Digest — {date_str}\n\n"
|
||||
|
||||
# DEVONthink 현황
|
||||
md += "## DEVONthink 변화\n\n"
|
||||
if dt_stats:
|
||||
md += "| DB | 신규 | 수정 |\n|---|---|---|\n"
|
||||
total_added = 0
|
||||
total_modified = 0
|
||||
for db_name, counts in dt_stats.items():
|
||||
md += f"| {db_name} | {counts['added']} | {counts['modified']} |\n"
|
||||
total_added += counts["added"]
|
||||
total_modified += counts["modified"]
|
||||
md += f"| **합계** | **{total_added}** | **{total_modified}** |\n\n"
|
||||
else:
|
||||
md += "변화 없음\n\n"
|
||||
|
||||
# 법령 변경
|
||||
md += "## 법령 변경\n\n"
|
||||
if law_changes:
|
||||
for change in law_changes:
|
||||
md += f"- {change}\n"
|
||||
md += "\n"
|
||||
else:
|
||||
md += "변경 없음\n\n"
|
||||
|
||||
# OmniFocus 현황
|
||||
md += "## OmniFocus 현황\n\n"
|
||||
md += f"- 완료: {of_stats['completed']}건\n"
|
||||
md += f"- 신규: {of_stats['added']}건\n"
|
||||
md += f"- 기한초과: {of_stats['overdue']}건\n\n"
|
||||
|
||||
# Inbox 상태
|
||||
md += f"## Inbox 미처리: {inbox_count}건\n\n"
|
||||
|
||||
# 시스템 상태
|
||||
md += "## 시스템 상태\n\n"
|
||||
total_errors = sum(system_health.values())
|
||||
if total_errors == 0:
|
||||
md += "모든 모듈 정상\n\n"
|
||||
else:
|
||||
md += "| 모듈 | 에러 수 |\n|---|---|\n"
|
||||
for mod, cnt in system_health.items():
|
||||
status = f"**{cnt}**" if cnt > 0 else "0"
|
||||
md += f"| {mod} | {status} |\n"
|
||||
md += "\n"
|
||||
|
||||
# 파일 저장
|
||||
digest_file = DIGEST_DIR / f"{date_str}_digest.md"
|
||||
with open(digest_file, "w", encoding="utf-8") as f:
|
||||
f.write(md)
|
||||
logger.info(f"다이제스트 저장: {digest_file}")
|
||||
|
||||
# DEVONthink 저장
|
||||
import_digest_to_devonthink(digest_file, date_str)
|
||||
|
||||
# OmniFocus 액션 자동 생성
|
||||
if law_changes:
|
||||
for change in law_changes:
|
||||
create_omnifocus_task(f"법령 변경 검토: {change[:30]}", note=change)
|
||||
|
||||
if inbox_count >= 3:
|
||||
create_omnifocus_task(f"Inbox 정리 ({inbox_count}건 미처리)", note="DEVONthink Inbox에 미분류 문서가 쌓여있습니다.")
|
||||
|
||||
if of_stats["overdue"] > 0:
|
||||
create_omnifocus_task(f"기한초과 작업 처리 ({of_stats['overdue']}건)", flagged=True)
|
||||
|
||||
# 90일 지난 다이제스트 아카이브
|
||||
archive_old_digests()
|
||||
|
||||
logger.info("=== Daily Digest 완료 ===")
|
||||
|
||||
|
||||
def import_digest_to_devonthink(filepath: Path, date_str: str):
|
||||
"""다이제스트를 DEVONthink에 저장"""
|
||||
escaped_path = str(filepath).replace('"', '\\"')
|
||||
script = f'''
|
||||
tell application id "DNtp"
|
||||
repeat with db in databases
|
||||
if name of db is "00_Note_BOX" then
|
||||
set targetGroup to create location "/Daily_Digest" in db
|
||||
import POSIX path "{escaped_path}" to targetGroup
|
||||
exit repeat
|
||||
end if
|
||||
end repeat
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 다이제스트 임포트 실패: {e}")
|
||||
|
||||
|
||||
def archive_old_digests():
|
||||
"""90일 지난 다이제스트 이동"""
|
||||
cutoff = datetime.now() - timedelta(days=90)
|
||||
for f in DIGEST_DIR.glob("*_digest.md"):
|
||||
try:
|
||||
date_part = f.stem.split("_digest")[0]
|
||||
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
||||
if file_date < cutoff:
|
||||
archive_dir = DIGEST_DIR / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
f.rename(archive_dir / f.name)
|
||||
logger.info(f"아카이브: {f.name}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_digest()
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
PKM 시스템 공통 유틸리티
|
||||
- 로거 설정 (파일 + 콘솔)
|
||||
- credentials.env 로딩
|
||||
- osascript 호출 래퍼
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 프로젝트 루트 디렉토리
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
LOGS_DIR = PROJECT_ROOT / "logs"
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
||||
APPLESCRIPT_DIR = PROJECT_ROOT / "applescript"
|
||||
|
||||
# 디렉토리 생성
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def setup_logger(name: str) -> logging.Logger:
|
||||
"""모듈별 로거 설정 — 파일 + 콘솔 핸들러"""
|
||||
logger = logging.getLogger(name)
|
||||
if logger.handlers:
|
||||
return logger # 중복 핸들러 방지
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 파일 핸들러
|
||||
fh = logging.FileHandler(LOGS_DIR / f"{name}.log", encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
|
||||
# 콘솔 핸들러
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def load_credentials() -> dict:
|
||||
"""~/.config/pkm/credentials.env 로딩 + 누락 키 경고"""
|
||||
cred_path = Path.home() / ".config" / "pkm" / "credentials.env"
|
||||
if not cred_path.exists():
|
||||
# 폴백: 프로젝트 내 credentials.env (개발용)
|
||||
cred_path = PROJECT_ROOT / "credentials.env"
|
||||
|
||||
if cred_path.exists():
|
||||
load_dotenv(cred_path)
|
||||
else:
|
||||
print(f"[경고] credentials.env를 찾을 수 없습니다: {cred_path}")
|
||||
|
||||
keys = {
|
||||
"CLAUDE_API_KEY": os.getenv("CLAUDE_API_KEY"),
|
||||
"LAW_OC": os.getenv("LAW_OC"),
|
||||
"NAS_DOMAIN": os.getenv("NAS_DOMAIN"),
|
||||
"NAS_TAILSCALE_IP": os.getenv("NAS_TAILSCALE_IP"),
|
||||
"NAS_PORT": os.getenv("NAS_PORT", "15001"),
|
||||
"MAILPLUS_HOST": os.getenv("MAILPLUS_HOST"),
|
||||
"MAILPLUS_PORT": os.getenv("MAILPLUS_PORT", "993"),
|
||||
"MAILPLUS_USER": os.getenv("MAILPLUS_USER"),
|
||||
"MAILPLUS_PASS": os.getenv("MAILPLUS_PASS"),
|
||||
"GPU_SERVER_IP": os.getenv("GPU_SERVER_IP"),
|
||||
}
|
||||
|
||||
missing = [k for k, v in keys.items() if not v and k not in ("GPU_SERVER_IP", "CLAUDE_API_KEY")]
|
||||
if missing:
|
||||
print(f"[경고] 누락된 인증 키: {', '.join(missing)}")
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def run_applescript(script_path: str, *args) -> str:
|
||||
"""osascript 호출 래퍼 + 에러 캡처"""
|
||||
cmd = ["osascript", str(script_path)] + [str(a) for a in args]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"AppleScript 에러: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"AppleScript 타임아웃: {script_path}")
|
||||
|
||||
|
||||
def run_applescript_inline(script: str) -> str:
|
||||
"""인라인 AppleScript 실행 — 단일 -e 방식"""
|
||||
cmd = ["osascript", "-e", script]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"AppleScript 에러: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("AppleScript 타임아웃 (인라인)")
|
||||
|
||||
|
||||
def llm_generate(prompt: str, model: str = "mlx-community/Qwen3.5-35B-A3B-4bit",
|
||||
host: str = "http://localhost:8800", json_mode: bool = False) -> str:
|
||||
"""MLX 서버 API 호출 (OpenAI 호환)"""
|
||||
import requests
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
resp = requests.post(f"{host}/v1/chat/completions", json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
}, timeout=300)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
if not json_mode:
|
||||
return content
|
||||
# JSON 모드: thinking 허용 → 마지막 유효 JSON 객체 추출
|
||||
import re
|
||||
import json as _json
|
||||
# 배열이 포함된 JSON 객체 매칭
|
||||
all_jsons = re.findall(r'\{[^{}]*(?:\[[^\]]*\])?[^{}]*\}', content)
|
||||
for j in reversed(all_jsons):
|
||||
try:
|
||||
parsed = _json.loads(j)
|
||||
if any(k in parsed for k in ("domain_db", "tags", "domain", "classification")):
|
||||
return j
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
# 폴백: 전체에서 가장 큰 JSON 추출
|
||||
json_match = re.search(r'\{[\s\S]*\}', content)
|
||||
return json_match.group(0) if json_match else content
|
||||
|
||||
|
||||
# 하위호환 별칭
|
||||
ollama_generate = llm_generate
|
||||
|
||||
|
||||
def count_log_errors(log_file: Path, since_hours: int = 24) -> int:
|
||||
"""로그 파일에서 최근 N시간 ERROR 카운트"""
|
||||
from datetime import datetime, timedelta
|
||||
if not log_file.exists():
|
||||
return 0
|
||||
cutoff = datetime.now() - timedelta(hours=since_hours)
|
||||
count = 0
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if "[ERROR]" in line:
|
||||
try:
|
||||
ts_str = line[1:20] # [YYYY-MM-DD HH:MM:SS]
|
||||
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||
if ts >= cutoff:
|
||||
count += 1
|
||||
except (ValueError, IndexError):
|
||||
count += 1
|
||||
return count
|
||||
78
scripts/seed_admin.py
Normal file
78
scripts/seed_admin.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""초기 관리자 계정 생성 스크립트
|
||||
|
||||
사용법:
|
||||
# Docker 컨테이너 내부에서 실행
|
||||
docker compose exec fastapi python /app/scripts/seed_admin.py
|
||||
|
||||
# 로컬에서 실행 (DATABASE_URL 환경변수 필요)
|
||||
python scripts/seed_admin.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 프로젝트 루트의 app/ 디렉토리를 경로에 추가
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from core.auth import hash_password
|
||||
|
||||
|
||||
async def seed_admin():
|
||||
database_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://pkm:pkm@localhost:5432/pkm",
|
||||
)
|
||||
|
||||
engine = create_async_engine(database_url)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
print("=== hyungi_Document_Server 관리자 계정 생성 ===\n")
|
||||
|
||||
username = input("관리자 아이디 [admin]: ").strip() or "admin"
|
||||
password = getpass.getpass("비밀번호: ")
|
||||
if not password:
|
||||
print("비밀번호는 필수입니다.")
|
||||
return
|
||||
|
||||
password_confirm = getpass.getpass("비밀번호 확인: ")
|
||||
if password != password_confirm:
|
||||
print("비밀번호가 일치하지 않습니다.")
|
||||
return
|
||||
|
||||
password_hash = hash_password(password)
|
||||
|
||||
async with async_session() as session:
|
||||
# 이미 존재하는지 확인
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": username},
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
print(f"'{username}' 계정이 이미 존재합니다. 비밀번호를 업데이트합니다.")
|
||||
await session.execute(
|
||||
text("UPDATE users SET password_hash = :hash WHERE username = :username"),
|
||||
{"hash": password_hash, "username": username},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO users (username, password_hash, is_active) "
|
||||
"VALUES (:username, :hash, TRUE)"
|
||||
),
|
||||
{"username": username, "hash": password_hash},
|
||||
)
|
||||
print(f"'{username}' 계정이 생성되었습니다.")
|
||||
|
||||
await session.commit()
|
||||
|
||||
await engine.dispose()
|
||||
print("\n완료!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_admin())
|
||||
12
services/kordoc/Dockerfile
Normal file
12
services/kordoc/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js .
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
15
services/kordoc/package.json
Normal file
15
services/kordoc/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "kordoc-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "HWP/HWPX/PDF 문서 파싱 마이크로서비스",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"kordoc": "^1.7.0",
|
||||
"pdfjs-dist": "^4.0.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user