Compare commits
147 Commits
0a01e17ea1
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ebbe105b | ||
|
|
7cf7662ba5 | ||
|
|
3bf6193337 | ||
|
|
e0f928f429 | ||
|
|
25ef3996ec | ||
|
|
2ca67dacea | ||
|
|
2cfe4b126a | ||
|
|
4938b25d12 | ||
|
|
f4f9de4402 | ||
|
|
7a38c95f3f | ||
|
|
d83842ccd8 | ||
|
|
76e723cdb1 | ||
|
|
b80116243f | ||
|
|
3375a5f1b1 | ||
|
|
42dfe82c9b | ||
|
|
8f312f50a7 | ||
|
|
731d1396e8 | ||
|
|
ffac4975b9 | ||
|
|
b3124928a6 | ||
|
|
f9af8dd355 | ||
|
|
ca3e1952d2 | ||
|
|
fab3c81a0f | ||
|
|
22117a2a6d | ||
|
|
0c63c0b6ab | ||
|
|
a4eb71d368 | ||
|
|
e0f45f9ce0 | ||
|
|
378fbc7845 | ||
|
|
a2941487fe | ||
|
|
c294df5987 | ||
|
|
8ec89517ee | ||
|
|
451c2181a0 | ||
|
|
fcce764e9d | ||
|
|
6b2747de96 | ||
|
|
8021a1debd | ||
|
|
161ff18a31 | ||
|
|
1af94d1004 | ||
|
|
473e7e2e6d | ||
|
|
e104d1b47c | ||
|
|
ad23925ed5 | ||
|
|
70b27d4a51 | ||
|
|
50e6b5ad90 | ||
|
|
f005922483 | ||
|
|
7fa7dc1510 | ||
|
|
8742367bc2 | ||
|
|
ec36ea3d6d | ||
|
|
8490cfed10 | ||
|
|
f523752971 | ||
|
|
fc50008843 | ||
|
|
db34a06243 | ||
|
|
e10b0f2883 | ||
|
|
8bb2ea4f29 | ||
|
|
3cd65e4c26 | ||
|
|
557165db11 | ||
|
|
2eeed41f5c | ||
|
|
be20edd0cd | ||
|
|
49cc86db80 | ||
|
|
4f7cd437f5 | ||
|
|
7d6b5b92c0 | ||
|
|
ef6f857a6d | ||
|
|
7ca3abf17c | ||
|
|
cd5f1c526d | ||
|
|
2b457a8305 | ||
|
|
d03fa0df37 | ||
|
|
a6c19ef76c | ||
|
|
bf8efd1cd3 | ||
|
|
204c5ca99f | ||
|
|
c885b5be27 | ||
|
|
1b21d9bb53 | ||
|
|
3374eebfc6 | ||
|
|
24142ea605 | ||
|
|
6c92e375c2 | ||
|
|
06da098eab | ||
|
|
749ed51dd7 | ||
|
|
1668be0a75 | ||
|
|
93c5805060 | ||
|
|
b4ca918125 | ||
|
|
e23c4feaa0 | ||
|
|
e7cd710e69 | ||
|
|
3236b8d812 | ||
|
|
4d205b67c2 | ||
|
|
b54cc25650 | ||
|
|
d63a6b85e1 | ||
|
|
bf0506023c | ||
|
|
7f5e09096a | ||
|
|
5153169d5d | ||
|
|
9b0705b79f | ||
|
|
63f75de89d | ||
|
|
6d73e7ee12 | ||
|
|
770d38b72c | ||
|
|
1b5fa95a9f | ||
|
|
b937eb948b | ||
|
|
1030bffc82 | ||
|
|
733f730e16 | ||
|
|
6893ea132d | ||
|
|
47e9981660 | ||
|
|
03b0612aa2 | ||
|
|
a5186bf4aa | ||
|
|
b37043d651 | ||
|
|
45448b4036 | ||
|
|
9fd44ab268 | ||
|
|
87bdd8003c | ||
|
|
41072a2e6d | ||
|
|
4bea408bbd | ||
|
|
3546c8cefb | ||
|
|
17d41a8526 | ||
|
|
47abf40bf1 | ||
|
|
9239e9c1d5 | ||
|
|
a15208f0cf | ||
|
|
f4a0229f15 | ||
|
|
cb8a846773 | ||
|
|
1a207be261 | ||
|
|
b04e1de8a6 | ||
|
|
1a2b3b49af | ||
|
|
87747866b6 | ||
|
|
faf9bda77a | ||
|
|
1affcb1afd | ||
|
|
e14084d5cd | ||
|
|
62f5eccb96 | ||
|
|
87683ca000 | ||
|
|
7cdeac20cf | ||
|
|
3df03134ff | ||
|
|
0ca78640ee | ||
|
|
8afa3c401f | ||
|
|
aebfa14984 | ||
|
|
17c1b7cf30 | ||
|
|
4ef27fc51c | ||
|
|
a872dfc10f | ||
|
|
fce9124c28 | ||
|
|
cfa95ff031 | ||
|
|
46537ee11a | ||
|
|
d93e50b55c | ||
|
|
31d5498f8d | ||
|
|
a5312c044b | ||
|
|
4b695332b9 | ||
|
|
2dfb05e653 | ||
|
|
299fac3904 | ||
|
|
23ee055357 | ||
|
|
e63d2971a9 | ||
|
|
b7c3040f1a | ||
|
|
d8fbe187bf | ||
|
|
0290dad923 | ||
|
|
629fe37790 | ||
|
|
8484389086 | ||
|
|
16d99011db | ||
|
|
99821df5c9 | ||
|
|
5a13b83e4d | ||
|
|
a601991f48 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# 인증 정보 (절대 커밋 금지)
|
||||
credentials.env
|
||||
.env
|
||||
|
||||
# Python
|
||||
venv/
|
||||
|
||||
157
CLAUDE.md
157
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Self-hosted PKM(Personal Knowledge Management) 웹 애플리케이션.
|
||||
FastAPI + PostgreSQL(pgvector) + SvelteKit + Docker Compose 기반.
|
||||
Mac mini M4 Pro를 애플리케이션 서버, Synology NAS를 파일 저장소, GPU 서버를 AI 추론에 사용한다.
|
||||
GPU 서버를 메인 서버, Mac mini를 AI 추론, Synology NAS를 파일 저장소로 사용.
|
||||
|
||||
## 핵심 문서
|
||||
|
||||
@@ -18,34 +18,35 @@ Mac mini M4 Pro를 애플리케이션 서버, Synology NAS를 파일 저장소,
|
||||
|------|------|
|
||||
| 백엔드 | FastAPI (Python 3.11+) |
|
||||
| 데이터베이스 | PostgreSQL 16 + pgvector + pg_trgm |
|
||||
| 프론트엔드 | SvelteKit |
|
||||
| 문서 파싱 | kordoc (Node.js, HWP/HWPX/PDF → Markdown) |
|
||||
| 리버스 프록시 | Caddy (자동 HTTPS) |
|
||||
| 프론트엔드 | 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 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)
|
||||
- 외부 접근: pkm.hyungi.net (Caddy 프록시)
|
||||
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
|
||||
- 파일 원본: /volume4/Document_Server/PKM/
|
||||
- Synology Office: 문서 편집/미리보기
|
||||
- Synology Calendar: CalDAV 태스크 관리 (OmniFocus 대체)
|
||||
- NFS export → GPU 서버
|
||||
- Synology Drive: https://link.hyungi.net (문서 편집)
|
||||
- Synology Calendar: CalDAV 태스크 관리
|
||||
- MailPlus: IMAP(993) + SMTP(465)
|
||||
|
||||
GPU 서버 (RTX 4070 Ti Super):
|
||||
- AI Gateway: http://gpu-server:8080 (모델 라우팅, 폴백, 비용 제어)
|
||||
- nomic-embed-text: 벡터 임베딩
|
||||
- Qwen2.5-VL-7B: 이미지/도면 OCR
|
||||
- bge-reranker-v2-m3: RAG 리랭킹
|
||||
```
|
||||
|
||||
## 인증 정보
|
||||
@@ -57,19 +58,19 @@ GPU 서버 (RTX 4070 Ti Super):
|
||||
## AI 모델 구성
|
||||
|
||||
```
|
||||
Primary (Mac mini MLX, 상시, 무료):
|
||||
Primary (Mac mini MLX, Tailscale 경유, 상시, 무료):
|
||||
mlx-community/Qwen3.5-35B-A3B-4bit — 분류, 태그, 요약
|
||||
→ http://localhost:8800/v1/chat/completions
|
||||
→ http://100.76.254.116:8800/v1/chat/completions
|
||||
|
||||
Fallback (GPU Ollama, MLX 장애 시):
|
||||
Fallback (GPU Ollama, 같은 Docker 네트워크, MLX 장애 시):
|
||||
qwen3.5:35b-a3b
|
||||
→ http://gpu-server:11434/v1/chat/completions
|
||||
→ http://ollama:11434/v1/chat/completions
|
||||
|
||||
Premium (Claude API, 종량제, 수동 트리거만):
|
||||
claude-sonnet — 복잡한 분석, 장문 처리
|
||||
→ 일일 한도 $5, require_explicit_trigger: true
|
||||
|
||||
Embedding (GPU 서버 전용):
|
||||
Embedding (GPU Ollama, 같은 Docker 네트워크):
|
||||
nomic-embed-text → 벡터 임베딩
|
||||
Qwen2.5-VL-7B → 이미지/도면 OCR
|
||||
bge-reranker-v2-m3 → RAG 리랭킹
|
||||
@@ -79,62 +80,112 @@ Embedding (GPU 서버 전용):
|
||||
|
||||
```
|
||||
hyungi_Document_Server/
|
||||
├── docker-compose.yml ← Mac mini용
|
||||
├── Caddyfile
|
||||
├── docker-compose.yml
|
||||
├── Caddyfile ← HTTP only, auto_https off
|
||||
├── config.yaml ← AI 엔드포인트, NAS 경로, 스케줄
|
||||
├── credentials.env.example
|
||||
├── app/ ← FastAPI 백엔드
|
||||
│ ├── main.py
|
||||
│ ├── main.py ← 엔트리포인트 + APScheduler (watcher/consumer 포함)
|
||||
│ ├── Dockerfile ← LibreOffice headless 포함
|
||||
│ ├── core/ (config, database, auth, utils)
|
||||
│ ├── models/ (document, task, queue)
|
||||
│ ├── api/ (documents, search, tasks, dashboard, export)
|
||||
│ ├── workers/(file_watcher, extract, classify, embed, law_monitor, mailplus, digest)
|
||||
│ ├── 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
|
||||
├── services/kordoc/ ← Node.js 마이크로서비스
|
||||
├── gpu-server/ ← GPU 서버용 (별도 배포)
|
||||
│ ├── docker-compose.yml
|
||||
│ └── services/ai-gateway/
|
||||
├── frontend/ ← SvelteKit
|
||||
├── migrations/ ← PostgreSQL 스키마
|
||||
├── scripts/migrate_from_devonthink.py
|
||||
│ └── 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/
|
||||
```
|
||||
|
||||
## 데이터 3계층
|
||||
## 문서 처리 파이프라인
|
||||
|
||||
1. **원본 파일** (NAS `/volume4/Document_Server/PKM/`) — 유일한 진짜 원본
|
||||
2. **가공 데이터** (PostgreSQL) — 텍스트 추출, AI 메타데이터, 검색 인덱스
|
||||
3. **파생물** (pgvector + 캐시) — 벡터 임베딩, 썸네일
|
||||
```
|
||||
파일 업로드 (드래그 앤 드롭 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+, 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 → 서버에서 pull
|
||||
MacBook Pro (개발) → Gitea push → GPU 서버에서 pull
|
||||
|
||||
개발:
|
||||
cd ~/Documents/code/hyungi_Document_Server/
|
||||
# 코드 작성 → git commit & push
|
||||
|
||||
Mac mini 배포:
|
||||
GPU 서버 배포 (메인):
|
||||
ssh hyungi@100.111.160.84
|
||||
cd ~/Documents/code/hyungi_Document_Server/
|
||||
git pull
|
||||
docker compose up -d
|
||||
|
||||
GPU 서버 배포:
|
||||
cd ~/Documents/code/hyungi_Document_Server/gpu-server/
|
||||
git pull
|
||||
docker compose up -d
|
||||
docker compose up -d --build fastapi frontend
|
||||
```
|
||||
|
||||
## v1 코드 참조
|
||||
@@ -143,12 +194,14 @@ v1(DEVONthink 기반) 코드는 `v1-final` 태그로 보존:
|
||||
```bash
|
||||
git show v1-final:scripts/law_monitor.py
|
||||
git show v1-final:scripts/pkm_utils.py
|
||||
git show v1-final:scripts/prompts/classify_document.txt
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
- credentials.env는 git에 올리지 않음 (.gitignore)
|
||||
- NAS SMB 마운트 경로: Docker 컨테이너 내 `/documents`
|
||||
- 법령 API (LAW_OC)는 승인 대기 중 — 스크립트만 만들고 실제 호출은 승인 후
|
||||
- GPU 서버 Tailscale IP는 credentials.env에서 관리
|
||||
- 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 수동 등록)
|
||||
|
||||
32
Caddyfile
32
Caddyfile
@@ -1,9 +1,35 @@
|
||||
pkm.hyungi.net {
|
||||
reverse_proxy fastapi:8000
|
||||
{
|
||||
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 프록시
|
||||
office.hyungi.net {
|
||||
http://office.hyungi.net {
|
||||
reverse_proxy https://ds1525.hyungi.net:5001 {
|
||||
header_up Host {upstream_hostport}
|
||||
transport http {
|
||||
|
||||
@@ -24,7 +24,7 @@ Self-hosted 개인 지식관리(PKM) 웹 애플리케이션
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git
|
||||
git clone https://git.hyungi.net/hyungi/hyungi_document_server.git hyungi_Document_Server
|
||||
cd hyungi_Document_Server
|
||||
|
||||
# 인증 정보 설정
|
||||
|
||||
@@ -2,6 +2,14 @@ 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
|
||||
|
||||
|
||||
103
app/ai/client.py
103
app/ai/client.py
@@ -1,11 +1,45 @@
|
||||
"""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"
|
||||
|
||||
@@ -51,6 +85,25 @@ class AIClient:
|
||||
# TODO: Qwen2.5-VL-7B 비전 모델 호출 구현
|
||||
raise NotImplementedError("OCR는 Phase 1에서 구현")
|
||||
|
||||
async def rerank(self, query: str, texts: list[str]) -> list[dict]:
|
||||
"""TEI bge-reranker-v2-m3 호출 (Phase 1.3).
|
||||
|
||||
TEI POST /rerank API:
|
||||
request: {"query": str, "texts": [str, ...]}
|
||||
response: [{"index": int, "score": float}, ...] (정렬됨)
|
||||
|
||||
timeout은 self.ai.rerank.timeout (config.yaml).
|
||||
호출자(rerank_service)가 asyncio.Semaphore + try/except로 감쌈.
|
||||
"""
|
||||
timeout = float(self.ai.rerank.timeout) if self.ai.rerank.timeout else 5.0
|
||||
response = await self._http.post(
|
||||
self.ai.rerank.endpoint,
|
||||
json={"query": query, "texts": texts},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _call_chat(self, model_config, prompt: str) -> str:
|
||||
"""OpenAI 호환 API 호출 + 자동 폴백"""
|
||||
try:
|
||||
@@ -61,19 +114,43 @@ class AIClient:
|
||||
raise
|
||||
|
||||
async def _request(self, model_config, prompt: str) -> str:
|
||||
"""단일 모델 API 호출"""
|
||||
response = await self._http.post(
|
||||
model_config.endpoint,
|
||||
json={
|
||||
"model": model_config.model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": model_config.max_tokens,
|
||||
},
|
||||
timeout=model_config.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
"""단일 모델 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()
|
||||
|
||||
201
app/api/auth.py
Normal file
201
app/api/auth.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""인증 API — 로그인, 토큰 갱신, TOTP 검증
|
||||
|
||||
access token: 응답 body (프론트에서 메모리 보관)
|
||||
refresh token: HttpOnly cookie (XSS 방어)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import (
|
||||
REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_totp,
|
||||
)
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── 요청/응답 스키마 ───
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: str | None = None
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_active: bool
|
||||
totp_enabled: bool
|
||||
last_login_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ─── 헬퍼 ───
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str):
|
||||
"""refresh token을 HttpOnly cookie로 설정"""
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=False, # Nginx가 TLS 종료, 내부 트래픽은 HTTP
|
||||
samesite="lax",
|
||||
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 86400,
|
||||
path="/api/auth",
|
||||
)
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.post("/login", response_model=AccessTokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
response: Response,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""로그인 → access token(body) + refresh token(cookie)"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == body.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="아이디 또는 비밀번호가 올바르지 않습니다",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="비활성화된 계정입니다",
|
||||
)
|
||||
|
||||
# TOTP 검증 (설정된 경우)
|
||||
if user.totp_secret:
|
||||
if not body.totp_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="TOTP 코드가 필요합니다",
|
||||
)
|
||||
if not verify_totp(body.totp_code, user.totp_secret):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="TOTP 코드가 올바르지 않습니다",
|
||||
)
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
# refresh token → HttpOnly cookie
|
||||
_set_refresh_cookie(response, create_refresh_token(user.username))
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=create_access_token(user.username),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AccessTokenResponse)
|
||||
async def refresh_token(
|
||||
response: Response,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
refresh_token: str | None = Cookie(None),
|
||||
):
|
||||
"""cookie의 refresh token으로 새 토큰 쌍 발급"""
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="리프레시 토큰이 없습니다",
|
||||
)
|
||||
|
||||
payload = decode_token(refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유효하지 않은 리프레시 토큰",
|
||||
)
|
||||
|
||||
username = payload.get("sub")
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == username, User.is_active.is_(True))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="유저를 찾을 수 없음",
|
||||
)
|
||||
|
||||
# 새 refresh token → cookie
|
||||
_set_refresh_cookie(response, create_refresh_token(user.username))
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=create_access_token(user.username),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
"""로그아웃 — refresh cookie 삭제"""
|
||||
response.delete_cookie("refresh_token", path="/api/auth")
|
||||
return {"message": "로그아웃 완료"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
"""현재 로그인한 유저 정보"""
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
is_active=user.is_active,
|
||||
totp_enabled=bool(user.totp_secret),
|
||||
last_login_at=user.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""비밀번호 변경"""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="현재 비밀번호가 올바르지 않습니다",
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
await session.commit()
|
||||
return {"message": "비밀번호가 변경되었습니다"}
|
||||
138
app/api/dashboard.py
Normal file
138
app/api/dashboard.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""대시보드 위젯 데이터 API"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DomainCount(BaseModel):
|
||||
domain: str | None
|
||||
count: int
|
||||
|
||||
|
||||
class RecentDocument(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
file_format: str
|
||||
ai_domain: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class PipelineStatus(BaseModel):
|
||||
stage: str
|
||||
status: str
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
today_added: int
|
||||
today_by_domain: list[DomainCount]
|
||||
inbox_count: int
|
||||
law_alerts: int
|
||||
recent_documents: list[RecentDocument]
|
||||
pipeline_status: list[PipelineStatus]
|
||||
failed_count: int
|
||||
total_documents: int
|
||||
|
||||
|
||||
@router.get("/", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""대시보드 위젯 데이터 집계"""
|
||||
|
||||
# 오늘 추가된 문서
|
||||
today_result = await session.execute(
|
||||
select(Document.ai_domain, func.count(Document.id))
|
||||
.where(func.date(Document.created_at) == func.current_date())
|
||||
.group_by(Document.ai_domain)
|
||||
)
|
||||
today_rows = today_result.all()
|
||||
today_added = sum(row[1] for row in today_rows)
|
||||
|
||||
# Inbox 미분류 수 (review_status = pending)
|
||||
inbox_result = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.review_status == "pending",
|
||||
Document.deleted_at == None,
|
||||
)
|
||||
)
|
||||
inbox_count = inbox_result.scalar() or 0
|
||||
|
||||
# 법령 알림 (오늘)
|
||||
law_result = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.source_channel == "law_monitor",
|
||||
func.date(Document.created_at) == func.current_date(),
|
||||
)
|
||||
)
|
||||
law_alerts = law_result.scalar() or 0
|
||||
|
||||
# 최근 문서 5건
|
||||
recent_result = await session.execute(
|
||||
select(Document)
|
||||
.order_by(Document.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_docs = recent_result.scalars().all()
|
||||
|
||||
# 파이프라인 상태 (24h)
|
||||
pipeline_result = await session.execute(
|
||||
text("""
|
||||
SELECT stage, status, COUNT(*)
|
||||
FROM processing_queue
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY stage, status
|
||||
""")
|
||||
)
|
||||
|
||||
# 실패 건수
|
||||
failed_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProcessingQueue)
|
||||
.where(ProcessingQueue.status == "failed")
|
||||
)
|
||||
failed_count = failed_result.scalar() or 0
|
||||
|
||||
# 전체 문서 수
|
||||
total_result = await session.execute(select(func.count(Document.id)))
|
||||
total_documents = total_result.scalar() or 0
|
||||
|
||||
return DashboardResponse(
|
||||
today_added=today_added,
|
||||
today_by_domain=[
|
||||
DomainCount(domain=row[0], count=row[1]) for row in today_rows
|
||||
],
|
||||
inbox_count=inbox_count,
|
||||
law_alerts=law_alerts,
|
||||
recent_documents=[
|
||||
RecentDocument(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
file_format=doc.file_format,
|
||||
ai_domain=doc.ai_domain,
|
||||
created_at=doc.created_at.isoformat() if doc.created_at else "",
|
||||
)
|
||||
for doc in recent_docs
|
||||
],
|
||||
pipeline_status=[
|
||||
PipelineStatus(stage=row[0], status=row[1], count=row[2])
|
||||
for row in pipeline_result
|
||||
],
|
||||
failed_count=failed_count,
|
||||
total_documents=total_documents,
|
||||
)
|
||||
401
app/api/documents.py
Normal file
401
app/api/documents.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""문서 CRUD API"""
|
||||
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from core.utils import file_hash
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── 스키마 ───
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: int
|
||||
file_path: str
|
||||
file_format: str
|
||||
file_size: int | None
|
||||
file_type: str
|
||||
title: str | None
|
||||
ai_domain: str | None
|
||||
ai_sub_group: str | None
|
||||
ai_tags: list | None
|
||||
ai_summary: str | None
|
||||
document_type: str | None
|
||||
importance: str | None
|
||||
ai_confidence: float | None
|
||||
user_note: str | None
|
||||
derived_path: str | None
|
||||
original_format: str | None
|
||||
conversion_status: str | None
|
||||
is_read: bool | None
|
||||
review_status: str | None
|
||||
edit_url: str | None
|
||||
preview_status: str | None
|
||||
source_channel: str | None
|
||||
data_origin: str | None
|
||||
extracted_at: datetime | None
|
||||
ai_processed_at: datetime | None
|
||||
embedded_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
items: list[DocumentResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
ai_domain: str | None = None
|
||||
ai_sub_group: str | None = None
|
||||
ai_tags: list | None = None
|
||||
user_note: str | None = None
|
||||
is_read: bool | None = None
|
||||
edit_url: str | None = None
|
||||
source_channel: str | None = None
|
||||
data_origin: str | None = None
|
||||
|
||||
|
||||
# ─── 스키마 (트리) ───
|
||||
|
||||
|
||||
class TreeNode(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
count: int
|
||||
children: list["TreeNode"]
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_document_tree(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""도메인 트리 (3단계 경로 파싱, 사이드바용)"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
result = await session.execute(
|
||||
sql_text("""
|
||||
SELECT ai_domain, COUNT(*)
|
||||
FROM documents
|
||||
WHERE ai_domain IS NOT NULL AND ai_domain != '' AND ai_domain != 'News'
|
||||
AND deleted_at IS NULL
|
||||
GROUP BY ai_domain
|
||||
ORDER BY ai_domain
|
||||
""")
|
||||
)
|
||||
|
||||
# 경로를 트리로 파싱
|
||||
root: dict = {}
|
||||
for domain_path, count in result:
|
||||
parts = domain_path.split("/")
|
||||
node = root
|
||||
for part in parts:
|
||||
if part not in node:
|
||||
node[part] = {"_count": 0, "_children": {}}
|
||||
node[part]["_count"] += count
|
||||
node = node[part]["_children"]
|
||||
|
||||
def build_tree(d: dict, prefix: str = "") -> list[dict]:
|
||||
nodes = []
|
||||
for name, data in sorted(d.items()):
|
||||
path = f"{prefix}/{name}" if prefix else name
|
||||
children = build_tree(data["_children"], path)
|
||||
nodes.append({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"count": data["_count"],
|
||||
"children": children,
|
||||
})
|
||||
return nodes
|
||||
|
||||
return build_tree(root)
|
||||
|
||||
|
||||
@router.get("/", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
domain: str | None = None,
|
||||
sub_group: str | None = None,
|
||||
source: str | None = None,
|
||||
format: str | None = None,
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)"""
|
||||
query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news")
|
||||
|
||||
if domain:
|
||||
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
|
||||
query = query.where(Document.ai_domain.startswith(domain))
|
||||
if source:
|
||||
query = query.where(Document.source_channel == source)
|
||||
if format:
|
||||
query = query.where(Document.file_format == format)
|
||||
|
||||
# 전체 건수
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await session.execute(count_query)).scalar()
|
||||
|
||||
# 페이지네이션
|
||||
query = query.order_by(Document.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return DocumentListResponse(
|
||||
items=[DocumentResponse.model_validate(doc) for doc in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 단건 조회"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/file")
|
||||
async def get_document_file(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
user: User | None = Depends(lambda: None),
|
||||
):
|
||||
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
|
||||
from core.auth import decode_token
|
||||
|
||||
# 쿼리 파라미터 토큰 검증
|
||||
if token:
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
else:
|
||||
# 일반 Bearer 헤더 인증 시도
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 미디어 타입 매핑
|
||||
media_types = {
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".gif": "image/gif",
|
||||
".bmp": "image/bmp", ".tiff": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain", ".md": "text/plain",
|
||||
".html": "text/html", ".csv": "text/csv",
|
||||
".json": "application/json", ".xml": "application/xml",
|
||||
}
|
||||
suffix = file_path.suffix.lower()
|
||||
media_type = media_types.get(suffix, "application/octet-stream")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": "inline"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentResponse, status_code=201)
|
||||
async def upload_document(
|
||||
file: UploadFile,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="파일명이 필요합니다")
|
||||
|
||||
# 파일명 정규화 (경로 이탈 방지)
|
||||
safe_name = Path(file.filename).name
|
||||
if not safe_name or safe_name.startswith("."):
|
||||
raise HTTPException(status_code=400, detail="유효하지 않은 파일명")
|
||||
|
||||
# Inbox에 파일 저장
|
||||
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = (inbox_dir / safe_name).resolve()
|
||||
|
||||
# Inbox 하위 경로 검증
|
||||
if not str(target).startswith(str(inbox_dir.resolve())):
|
||||
raise HTTPException(status_code=400, detail="잘못된 파일 경로")
|
||||
|
||||
# 중복 파일명 처리
|
||||
counter = 1
|
||||
stem, suffix = target.stem, target.suffix
|
||||
while target.exists():
|
||||
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
content = await file.read()
|
||||
target.write_bytes(content)
|
||||
|
||||
# 상대 경로 (NAS 루트 기준)
|
||||
rel_path = str(target.relative_to(Path(settings.nas_mount_path)))
|
||||
fhash = file_hash(target)
|
||||
ext = target.suffix.lstrip(".").lower() or "unknown"
|
||||
|
||||
# DB 등록
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=fhash,
|
||||
file_format=ext,
|
||||
file_size=len(content),
|
||||
file_type="immutable",
|
||||
title=target.stem,
|
||||
source_channel="manual",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# 처리 큐 등록
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id,
|
||||
stage="extract",
|
||||
status="pending",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.patch("/{doc_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
doc_id: int,
|
||||
body: DocumentUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 메타데이터 수정 (수동 오버라이드)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.put("/{doc_id}/content")
|
||||
async def save_document_content(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: dict = None,
|
||||
):
|
||||
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
if doc.file_format not in ("md", "txt"):
|
||||
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
|
||||
|
||||
content = body.get("content", "") if body else ""
|
||||
file_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# 메타 갱신
|
||||
doc.file_size = len(content.encode("utf-8"))
|
||||
doc.file_hash = file_hash(file_path)
|
||||
doc.extracted_text = content[:15000]
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return DocumentResponse.model_validate(doc)
|
||||
|
||||
|
||||
@router.get("/{doc_id}/preview")
|
||||
async def get_document_preview(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
):
|
||||
"""PDF 미리보기 캐시 서빙"""
|
||||
from core.auth import decode_token
|
||||
|
||||
if token:
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
|
||||
if not preview_path.exists():
|
||||
raise HTTPException(status_code=404, detail="미리보기가 아직 생성되지 않았습니다")
|
||||
|
||||
return FileResponse(
|
||||
path=str(preview_path),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "inline"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{doc_id}")
|
||||
async def delete_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
|
||||
):
|
||||
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
# soft-delete (물리 파일은 cleanup job에서 나중에 정리)
|
||||
doc.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료"}
|
||||
173
app/api/news.py
Normal file
173
app/api/news.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""뉴스 소스 관리 API"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.news_source import NewsSource
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NewsSourceResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
country: str | None
|
||||
feed_url: str
|
||||
feed_type: str
|
||||
category: str | None
|
||||
language: str | None
|
||||
enabled: bool
|
||||
last_fetched_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NewsSourceCreate(BaseModel):
|
||||
name: str
|
||||
country: str | None = None
|
||||
feed_url: str
|
||||
feed_type: str = "rss"
|
||||
category: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class NewsSourceUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
feed_url: str | None = None
|
||||
category: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def list_sources(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
result = await session.execute(select(NewsSource).order_by(NewsSource.id))
|
||||
return [NewsSourceResponse.model_validate(s) for s in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/sources")
|
||||
async def create_source(
|
||||
body: NewsSourceCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
source = NewsSource(**body.model_dump())
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
return NewsSourceResponse.model_validate(source)
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}")
|
||||
async def update_source(
|
||||
source_id: int,
|
||||
body: NewsSourceUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
source = await session.get(NewsSource, source_id)
|
||||
if not source:
|
||||
raise HTTPException(status_code=404)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(source, field, value)
|
||||
await session.commit()
|
||||
return NewsSourceResponse.model_validate(source)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}")
|
||||
async def delete_source(
|
||||
source_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
source = await session.get(NewsSource, source_id)
|
||||
if not source:
|
||||
raise HTTPException(status_code=404)
|
||||
await session.delete(source)
|
||||
await session.commit()
|
||||
return {"message": f"소스 {source_id} 삭제됨"}
|
||||
|
||||
|
||||
@router.get("/articles")
|
||||
async def list_articles(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
source: str | None = None,
|
||||
unread_only: bool = False,
|
||||
page: int = 1,
|
||||
page_size: int = 30,
|
||||
):
|
||||
"""뉴스 기사 목록"""
|
||||
from sqlalchemy import func
|
||||
from models.document import Document
|
||||
|
||||
query = select(Document).where(
|
||||
Document.source_channel == "news",
|
||||
Document.deleted_at == None,
|
||||
)
|
||||
if source:
|
||||
if '/' in source:
|
||||
# 신문사/분야 형태 → file_path에서 폴더명 매칭
|
||||
# source = "경향신문/문화" → file_path LIKE 'news/경향신문 문화/%'
|
||||
folder = source.replace('/', ' ')
|
||||
query = query.where(Document.file_path.like(f"news/{folder}/%"))
|
||||
else:
|
||||
# 신문사만 → ai_sub_group
|
||||
query = query.where(Document.ai_sub_group == source)
|
||||
if unread_only:
|
||||
query = query.where(Document.is_read == False)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await session.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(Document.is_read.asc(), Document.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
from api.documents import DocumentResponse
|
||||
return {
|
||||
"items": [DocumentResponse.model_validate(doc) for doc in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
async def mark_all_read(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""전체 읽음 처리"""
|
||||
from sqlalchemy import update
|
||||
from models.document import Document
|
||||
|
||||
result = await session.execute(
|
||||
update(Document)
|
||||
.where(Document.source_channel == "news", Document.is_read == False)
|
||||
.values(is_read=True)
|
||||
)
|
||||
await session.commit()
|
||||
return {"marked": result.rowcount}
|
||||
|
||||
|
||||
@router.post("/collect")
|
||||
async def trigger_collect(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""수동 수집 트리거"""
|
||||
from workers.news_collector import run
|
||||
import asyncio
|
||||
asyncio.create_task(run())
|
||||
return {"message": "뉴스 수집 시작됨"}
|
||||
248
app/api/search.py
Normal file
248
app/api/search.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""하이브리드 검색 API — orchestrator (Phase 1.1: thin endpoint).
|
||||
|
||||
retrieval / fusion / rerank 등 실제 로직은 services/search/* 모듈로 분리.
|
||||
이 파일은 mode 분기, 응답 직렬화, debug 응답 구성, BackgroundTask dispatch만 담당.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from models.user import User
|
||||
from services.search.fusion_service import DEFAULT_FUSION, get_strategy, normalize_display_scores
|
||||
from services.search.rerank_service import (
|
||||
MAX_CHUNKS_PER_DOC,
|
||||
MAX_RERANK_INPUT,
|
||||
apply_diversity,
|
||||
rerank_chunks,
|
||||
)
|
||||
from services.search.retrieval_service import compress_chunks_to_docs, search_text, search_vector
|
||||
from services.search_telemetry import (
|
||||
compute_confidence,
|
||||
compute_confidence_hybrid,
|
||||
compute_confidence_reranked,
|
||||
record_search_event,
|
||||
)
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""검색 결과 단일 행.
|
||||
|
||||
Phase 1.2-C: chunk-level vector retrieval 도입으로 chunk 메타 필드 추가.
|
||||
text 검색 결과는 chunk_id 등이 None (doc-level).
|
||||
vector 검색 결과는 chunk_id 등이 채워짐 (chunk-level).
|
||||
"""
|
||||
|
||||
id: int # doc_id (text/vector 공통)
|
||||
title: str | None
|
||||
ai_domain: str | None
|
||||
ai_summary: str | None
|
||||
file_format: str
|
||||
score: float
|
||||
snippet: str | None
|
||||
match_reason: str | None = None
|
||||
# Phase 1.2-C: chunk 메타 (vector 검색 시 채워짐)
|
||||
chunk_id: int | None = None
|
||||
chunk_index: int | None = None
|
||||
section_title: str | None = None
|
||||
|
||||
|
||||
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
|
||||
|
||||
|
||||
class DebugCandidate(BaseModel):
|
||||
"""단계별 후보 (debug=true 응답에서만 노출)."""
|
||||
id: int
|
||||
rank: int
|
||||
score: float
|
||||
match_reason: str | None = None
|
||||
|
||||
|
||||
class SearchDebug(BaseModel):
|
||||
timing_ms: dict[str, float]
|
||||
text_candidates: list[DebugCandidate] | None = None
|
||||
vector_candidates: list[DebugCandidate] | None = None
|
||||
fused_candidates: list[DebugCandidate] | None = None
|
||||
confidence: float
|
||||
notes: list[str] = []
|
||||
# Phase 1/2 도입 후 채워질 placeholder
|
||||
query_analysis: dict | None = None
|
||||
reranker_scores: list[DebugCandidate] | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[SearchResult]
|
||||
total: int
|
||||
query: str
|
||||
mode: str
|
||||
debug: SearchDebug | None = None
|
||||
|
||||
|
||||
def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCandidate]:
|
||||
return [
|
||||
DebugCandidate(
|
||||
id=r.id, rank=i + 1, score=r.score, match_reason=r.match_reason
|
||||
)
|
||||
for i, r in enumerate(rows[:n])
|
||||
]
|
||||
|
||||
|
||||
@router.get("/", response_model=SearchResponse)
|
||||
async def search(
|
||||
q: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
background_tasks: BackgroundTasks,
|
||||
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
fusion: str = Query(
|
||||
DEFAULT_FUSION,
|
||||
pattern="^(legacy|rrf|rrf_boost)$",
|
||||
description="hybrid 모드 fusion 전략 (legacy=기존 가중합, rrf=RRF k=60, rrf_boost=RRF+강한신호 boost)",
|
||||
),
|
||||
rerank: bool = Query(
|
||||
True,
|
||||
description="bge-reranker-v2-m3 활성화 (Phase 1.3, hybrid 모드만 동작)",
|
||||
),
|
||||
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
|
||||
timing: dict[str, float] = {}
|
||||
notes: list[str] = []
|
||||
text_results: list[SearchResult] = []
|
||||
vector_results: list[SearchResult] = [] # doc-level (압축 후, fusion 입력)
|
||||
raw_chunks: list[SearchResult] = [] # chunk-level (raw, Phase 1.3 reranker용)
|
||||
chunks_by_doc: dict[int, list[SearchResult]] = {} # Phase 1.3 reranker용 보존
|
||||
|
||||
t_total = time.perf_counter()
|
||||
|
||||
if mode == "vector":
|
||||
t0 = time.perf_counter()
|
||||
raw_chunks = await search_vector(session, q, limit)
|
||||
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
|
||||
if not raw_chunks:
|
||||
notes.append("vector_search_returned_empty (AI client error or no embeddings)")
|
||||
# vector 단독 모드도 doc 압축해서 다양성 확보 (chunk 중복 방지)
|
||||
vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
|
||||
results = vector_results
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
text_results = await search_text(session, q, limit)
|
||||
timing["text_ms"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if mode == "hybrid":
|
||||
t1 = time.perf_counter()
|
||||
raw_chunks = await search_vector(session, q, limit)
|
||||
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
|
||||
|
||||
# chunk-level → doc-level 압축 (raw chunks는 chunks_by_doc에 보존)
|
||||
t1b = time.perf_counter()
|
||||
vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
|
||||
timing["compress_ms"] = (time.perf_counter() - t1b) * 1000
|
||||
|
||||
if not vector_results:
|
||||
notes.append("vector_search_returned_empty — text-only fallback")
|
||||
|
||||
t2 = time.perf_counter()
|
||||
strategy = get_strategy(fusion)
|
||||
# fusion은 doc 기준 — 더 넓게 가져옴 (rerank 후보용)
|
||||
fusion_limit = max(limit * 5, 100) if rerank else limit
|
||||
fused_docs = strategy.fuse(text_results, vector_results, q, fusion_limit)
|
||||
timing["fusion_ms"] = (time.perf_counter() - t2) * 1000
|
||||
notes.append(f"fusion={strategy.name}")
|
||||
notes.append(
|
||||
f"chunks raw={len(raw_chunks)} compressed={len(vector_results)} "
|
||||
f"unique_docs={len(chunks_by_doc)}"
|
||||
)
|
||||
|
||||
if rerank:
|
||||
# Phase 1.3: reranker — chunk 기준 입력
|
||||
# fusion 결과 doc_id로 chunks_by_doc에서 raw chunks 회수
|
||||
t3 = time.perf_counter()
|
||||
rerank_input: list[SearchResult] = []
|
||||
for doc in fused_docs:
|
||||
chunks = chunks_by_doc.get(doc.id, [])
|
||||
if chunks:
|
||||
# doc당 max 2 chunk (latency/VRAM 보호)
|
||||
rerank_input.extend(chunks[:MAX_CHUNKS_PER_DOC])
|
||||
else:
|
||||
# text-only 매치 doc → doc 자체를 chunk처럼 wrap
|
||||
rerank_input.append(doc)
|
||||
if len(rerank_input) >= MAX_RERANK_INPUT:
|
||||
break
|
||||
rerank_input = rerank_input[:MAX_RERANK_INPUT]
|
||||
notes.append(f"rerank input={len(rerank_input)}")
|
||||
|
||||
reranked = await rerank_chunks(q, rerank_input, limit * 3)
|
||||
timing["rerank_ms"] = (time.perf_counter() - t3) * 1000
|
||||
|
||||
# diversity (chunk → doc 압축, max_per_doc=2, top score>0.90 unlimited)
|
||||
t4 = time.perf_counter()
|
||||
results = apply_diversity(reranked, max_per_doc=MAX_CHUNKS_PER_DOC)[:limit]
|
||||
timing["diversity_ms"] = (time.perf_counter() - t4) * 1000
|
||||
else:
|
||||
# rerank 비활성: fused_docs를 그대로 (limit 적용)
|
||||
results = fused_docs[:limit]
|
||||
else:
|
||||
results = text_results
|
||||
|
||||
# display score 정규화 — 프론트엔드는 score*100을 % 표시.
|
||||
# fusion 내부 score(RRF는 0.01~0.05 범위)를 그대로 노출하면 표시가 깨짐.
|
||||
normalize_display_scores(results)
|
||||
|
||||
timing["total_ms"] = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# confidence는 fusion 적용 전 raw 신호로 계산 (Phase 0.5 이후 fused score는 절대값 의미 없음)
|
||||
# rerank 활성 시 reranker score가 가장 신뢰할 수 있는 신호 → 우선 사용
|
||||
if mode == "hybrid":
|
||||
if rerank and "rerank_ms" in timing:
|
||||
confidence_signal = compute_confidence_reranked(results)
|
||||
else:
|
||||
confidence_signal = compute_confidence_hybrid(text_results, vector_results)
|
||||
elif mode == "vector":
|
||||
confidence_signal = compute_confidence(vector_results, "vector")
|
||||
else:
|
||||
confidence_signal = compute_confidence(text_results, mode)
|
||||
|
||||
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
|
||||
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
|
||||
logger.info(
|
||||
"search query=%r mode=%s%s results=%d conf=%.2f %s",
|
||||
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str,
|
||||
)
|
||||
|
||||
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, results, mode, confidence_signal
|
||||
)
|
||||
|
||||
debug_obj: SearchDebug | None = None
|
||||
if debug:
|
||||
debug_obj = SearchDebug(
|
||||
timing_ms=timing,
|
||||
text_candidates=_to_debug_candidates(text_results) if text_results or mode != "vector" else None,
|
||||
vector_candidates=_to_debug_candidates(vector_results) if vector_results or mode in ("vector", "hybrid") else None,
|
||||
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
|
||||
confidence=confidence_signal,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
results=results,
|
||||
total=len(results),
|
||||
query=q,
|
||||
mode=mode,
|
||||
debug=debug_obj,
|
||||
)
|
||||
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")
|
||||
@@ -1,14 +1,20 @@
|
||||
"""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 passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer()
|
||||
|
||||
# JWT 설정
|
||||
ALGORITHM = "HS256"
|
||||
@@ -17,11 +23,11 @@ REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
@@ -43,9 +49,37 @@ def decode_token(token: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def verify_totp(code: str) -> bool:
|
||||
"""TOTP 코드 검증"""
|
||||
if not settings.totp_secret:
|
||||
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(settings.totp_secret)
|
||||
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
|
||||
|
||||
@@ -44,6 +44,10 @@ class Settings(BaseModel):
|
||||
# kordoc
|
||||
kordoc_endpoint: str = "http://kordoc-service:3100"
|
||||
|
||||
# 분류 체계
|
||||
taxonomy: dict = {}
|
||||
document_types: list[str] = []
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""config.yaml + 환경변수에서 설정 로딩"""
|
||||
@@ -53,8 +57,10 @@ def load_settings() -> Settings:
|
||||
totp_secret = os.getenv("TOTP_SECRET", "")
|
||||
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
|
||||
|
||||
# config.yaml
|
||||
config_path = Path(__file__).parent.parent.parent / "config.yaml"
|
||||
# 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"
|
||||
@@ -79,6 +85,9 @@ def load_settings() -> Settings:
|
||||
nas_mount = raw["nas"].get("mount_path", nas_mount)
|
||||
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
|
||||
return Settings(
|
||||
database_url=database_url,
|
||||
ai=ai_config,
|
||||
@@ -87,6 +96,8 @@ def load_settings() -> Settings:
|
||||
jwt_secret=jwt_secret,
|
||||
totp_secret=totp_secret,
|
||||
kordoc_endpoint=kordoc_endpoint,
|
||||
taxonomy=taxonomy,
|
||||
document_types=document_types,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""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,
|
||||
@@ -19,13 +26,116 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""DB 연결 확인 (스키마는 migrations/로 관리)"""
|
||||
async with engine.begin() as conn:
|
||||
# 연결 테스트
|
||||
await conn.execute(
|
||||
__import__("sqlalchemy").text("SELECT 1")
|
||||
# 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:
|
||||
|
||||
@@ -44,3 +44,95 @@ def count_log_errors(log_path: str) -> int:
|
||||
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}")
|
||||
|
||||
126
app/main.py
126
app/main.py
@@ -2,21 +2,63 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
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.news import router as news_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 init_db
|
||||
from core.database import async_session, engine, init_db
|
||||
from models.user import User
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
|
||||
# 시작: DB 연결, 스케줄러 등록
|
||||
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.news_collector import run as news_collector_run
|
||||
from workers.queue_consumer import consume_queue
|
||||
|
||||
# 시작: DB 연결 확인
|
||||
await init_db()
|
||||
# TODO: APScheduler 시작 (Phase 3)
|
||||
|
||||
# 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.add_job(news_collector_run, "interval", hours=6, id="news_collector")
|
||||
scheduler.start()
|
||||
|
||||
yield
|
||||
# 종료: 리소스 정리
|
||||
# TODO: 스케줄러 종료, DB 연결 해제
|
||||
|
||||
# 종료: 스케줄러 → DB 순서로 정리
|
||||
scheduler.shutdown(wait=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -26,16 +68,70 @@ app = FastAPI(
|
||||
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"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
|
||||
# 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():
|
||||
return {"status": "ok", "version": "2.0.0"}
|
||||
"""헬스체크 — DB 연결 상태 포함"""
|
||||
db_ok = False
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# TODO: 라우터 등록 (Phase 0~2)
|
||||
# from api import documents, search, tasks, dashboard, export
|
||||
# app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
|
||||
# app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
|
||||
# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
# app.include_router(export.router, prefix="/api/export", tags=["export"])
|
||||
return {
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"version": "2.0.0",
|
||||
"database": "connected" if db_ok else "disconnected",
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
46
app/models/chunk.py
Normal file
46
app/models/chunk.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""document_chunks 테이블 ORM — chunk 단위 검색 (Phase 0.1)"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class DocumentChunk(Base):
|
||||
__tablename__ = "document_chunks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
doc_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# chunking 전략 메타
|
||||
chunk_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
section_title: Mapped[str | None] = mapped_column(Text)
|
||||
heading_path: Mapped[str | None] = mapped_column(Text)
|
||||
page: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# 다국어/domain 메타
|
||||
language: Mapped[str | None] = mapped_column(String(10))
|
||||
country: Mapped[str | None] = mapped_column(String(10))
|
||||
source: Mapped[str | None] = mapped_column(String(100))
|
||||
domain_category: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# 본문 + 임베딩
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
embedding = mapped_column(Vector(1024), nullable=True)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("doc_id", "chunk_index", name="uq_chunks_doc_index"),
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -38,16 +38,42 @@ class Document(Base):
|
||||
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)
|
||||
embedding = mapped_column(Vector(1024), nullable=True)
|
||||
embed_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 사용자 메모
|
||||
user_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# ODF 변환
|
||||
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
|
||||
# 읽음 상태 (뉴스용)
|
||||
is_read: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 승인/삭제
|
||||
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 외부 편집 URL
|
||||
edit_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 미리보기
|
||||
preview_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
preview_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
preview_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 메타데이터
|
||||
source_channel: Mapped[str | None] = mapped_column(
|
||||
Enum("law_monitor", "devonagent", "email", "web_clip",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync",
|
||||
"tksafety", "inbox_route", "manual", "drive_sync", "news",
|
||||
name="source_channel")
|
||||
)
|
||||
data_origin: Mapped[str | None] = mapped_column(
|
||||
|
||||
25
app/models/news_source.py
Normal file
25
app/models/news_source.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""news_sources 테이블 ORM"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class NewsSource(Base):
|
||||
__tablename__ = "news_sources"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
country: Mapped[str | None] = mapped_column(String(10))
|
||||
feed_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
feed_type: Mapped[str] = mapped_column(String(20), default="rss")
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
language: Mapped[str | None] = mapped_column(String(10))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
@@ -14,7 +14,7 @@ class ProcessingQueue(Base):
|
||||
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", name="process_stage"), nullable=False
|
||||
Enum("extract", "classify", "summarize", "embed", "chunk", "preview", name="process_stage"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum("pending", "processing", "completed", "failed", name="process_status"),
|
||||
|
||||
28
app/models/search_failure.py
Normal file
28
app/models/search_failure.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""search_failure_logs 테이블 ORM — 검색 실패 자동 수집 (Phase 0.3)"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class SearchFailureLog(Base):
|
||||
__tablename__ = "search_failure_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
query: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
result_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
confidence: Mapped[float | None] = mapped_column(Float)
|
||||
failure_reason: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
context: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
reviewed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
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))
|
||||
@@ -1,51 +1,93 @@
|
||||
당신은 문서 분류 AI입니다. 아래 문서를 분석하고 반드시 JSON 형식으로만 응답하세요. 다른 텍스트는 출력하지 마세요.
|
||||
You are a document classification AI. Analyze the document below and respond ONLY in JSON format. No other text.
|
||||
|
||||
## 응답 형식
|
||||
## Response Format
|
||||
{
|
||||
"tags": ["태그1", "태그2", "태그3"],
|
||||
"domain": "도메인경로",
|
||||
"sub_group": "하위그룹",
|
||||
"sourceChannel": "유입경로",
|
||||
"dataOrigin": "work 또는 external"
|
||||
"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"
|
||||
}
|
||||
|
||||
## 도메인 선택지 (NAS 폴더 경로)
|
||||
- Knowledge/Philosophy — 철학, 사상, 인문학
|
||||
- Knowledge/Language — 어학, 번역, 언어학
|
||||
- Knowledge/Engineering — 공학 전반 기술 문서
|
||||
- Knowledge/Industrial_Safety — 산업안전, 규정, 인증
|
||||
- Knowledge/Programming — 개발, 코드, IT 기술
|
||||
- Knowledge/General — 일반 도서, 독서 노트, 메모
|
||||
- Reference — 도면, 참고자료, 규격표
|
||||
## Domain Taxonomy (select the most specific leaf node)
|
||||
|
||||
## 하위 그룹 예시 (도메인별)
|
||||
- Knowledge/Industrial_Safety: Legislation, Standards, Cases
|
||||
- Knowledge/Programming: Language, Framework, DevOps, AI_ML
|
||||
- Knowledge/Engineering: Mechanical, Electrical, Network
|
||||
- 잘 모르겠으면: (비워둠)
|
||||
Philosophy/
|
||||
Ethics, Metaphysics, Epistemology, Logic, Aesthetics, Eastern_Philosophy, Western_Philosophy
|
||||
|
||||
## 태그 체계
|
||||
태그는 최대 5개, 한글 사용. 아래 계층 구조 중에서 선택:
|
||||
- @상태/: 처리중, 검토필요, 완료, 아카이브
|
||||
- #주제/기술/: 서버관리, 네트워크, AI-ML
|
||||
- #주제/산업안전/: 법령, 위험성평가, 순회점검, 안전교육, 사고사례, 신고보고, 안전관리자, 보건관리자
|
||||
- #주제/업무/: 프로젝트, 회의, 보고서
|
||||
- $유형/: 논문, 법령, 기사, 메모, 이메일, 채팅로그, 도면, 체크리스트
|
||||
- !우선순위/: 긴급, 중요, 참고
|
||||
Language/
|
||||
Korean, English, Japanese, Translation, Linguistics
|
||||
|
||||
## sourceChannel 값
|
||||
- tksafety: TKSafety API 업무 실적
|
||||
- devonagent: 자동 수집 뉴스
|
||||
- law_monitor: 법령 API 법령 변경
|
||||
- inbox_route: Inbox AI 분류 (이 프롬프트에 의한 분류)
|
||||
- email: MailPlus 이메일
|
||||
- web_clip: Web Clipper 스크랩
|
||||
- manual: 직접 추가
|
||||
- drive_sync: Synology Drive 동기화
|
||||
Engineering/
|
||||
Mechanical/ Piping, HVAC, Equipment
|
||||
Electrical/ Power, Instrumentation
|
||||
Chemical/ Process, Material
|
||||
Civil
|
||||
Network/ Server, Security, Infrastructure
|
||||
|
||||
## dataOrigin 값
|
||||
- work: 자사 업무 관련 (TK, 테크니컬코리아, 공장, 생산, 사내)
|
||||
- external: 외부 참고 자료 (뉴스, 논문, 법령, 일반 정보)
|
||||
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}
|
||||
|
||||
@@ -7,10 +7,12 @@ python-dotenv>=1.0.0
|
||||
pyyaml>=6.0
|
||||
httpx>=0.27.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
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
|
||||
feedparser>=6.0.0
|
||||
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
11
app/services/search/__init__.py
Normal file
11
app/services/search/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Search service 모듈 — Phase 1.1 분리.
|
||||
|
||||
검색 파이프라인의 각 단계를 모듈로 분리해 디버깅/테스트/병목 추적을 용이하게 한다.
|
||||
|
||||
- retrieval_service: text/vector/trigram 후보 수집
|
||||
- fusion_service: RRF / weighted-sum / boost (Phase 0.5에서 이동)
|
||||
- rerank_service: bge-reranker-v2-m3 통합 (Phase 1.3)
|
||||
- query_analyzer: 자연어 쿼리 분석 (Phase 2)
|
||||
- evidence_service: evidence extraction (Phase 3)
|
||||
- synthesis_service: grounded answer synthesis (Phase 3)
|
||||
"""
|
||||
5
app/services/search/evidence_service.py
Normal file
5
app/services/search/evidence_service.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Evidence extraction 서비스 (Phase 3).
|
||||
|
||||
reranked chunks에서 query-relevant span을 rule + LLM hybrid로 추출.
|
||||
구현은 Phase 3에서 채움.
|
||||
"""
|
||||
239
app/services/search/fusion_service.py
Normal file
239
app/services/search/fusion_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""검색 결과 fusion 전략 (Phase 0.5)
|
||||
|
||||
기존 가중합 → Reciprocal Rank Fusion 기본 + 강한 시그널 boost.
|
||||
|
||||
전략 비교:
|
||||
- LegacyWeightedSum : 기존 _merge_results (text 가중치 + 0.5*벡터 합산). A/B 비교용.
|
||||
- RRFOnly : 순수 RRF, k=60. 안정적이지만 강한 키워드 신호 약화 가능.
|
||||
- RRFWithBoost : RRF + 강한 시그널 boost (title/tags/법령조문/high text score).
|
||||
정확 키워드 케이스에서 RRF 한계를 보완. **default**.
|
||||
|
||||
fuse() 결과의 .score는 fusion 내부 점수(RRF는 1/60 단위로 작음).
|
||||
사용자에게 노출되는 SearchResult.score는 search.py에서 normalize_display_scores로
|
||||
[0..1] 랭크 기반 정규화 후 반환된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
|
||||
# ─── 추상 인터페이스 ─────────────────────────────────────
|
||||
|
||||
|
||||
class FusionStrategy(ABC):
|
||||
name: str = "abstract"
|
||||
|
||||
@abstractmethod
|
||||
def fuse(
|
||||
self,
|
||||
text_results: list["SearchResult"],
|
||||
vector_results: list["SearchResult"],
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> list["SearchResult"]:
|
||||
...
|
||||
|
||||
|
||||
# ─── 1) 기존 가중합 (legacy) ─────────────────────────────
|
||||
|
||||
|
||||
class LegacyWeightedSum(FusionStrategy):
|
||||
"""기존 _merge_results 동작.
|
||||
|
||||
텍스트 점수에 벡터 cosine * 0.5 가산. 벡터 단독 결과는 cosine > 0.3만 채택.
|
||||
Phase 0.5 RRF로 교체 전 baseline. A/B 비교용으로 보존.
|
||||
"""
|
||||
|
||||
name = "legacy"
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
from api.search import SearchResult # 순환 import 회피
|
||||
|
||||
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:
|
||||
merged[r.id] = r
|
||||
|
||||
ordered = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||||
return ordered[:limit]
|
||||
|
||||
|
||||
# ─── 2) Reciprocal Rank Fusion ──────────────────────────
|
||||
|
||||
|
||||
class RRFOnly(FusionStrategy):
|
||||
"""순수 RRF.
|
||||
|
||||
RRF_score(doc) = Σ (1 / (k + rank_i))
|
||||
k=60 (TREC 표준값). 점수 절대값을 무시하고 랭크만 사용 → 다른 retriever 간
|
||||
스케일 차이에 강하지만, FTS의 압도적 신호도 평탄화되는 단점.
|
||||
"""
|
||||
|
||||
name = "rrf"
|
||||
K = 60
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
from api.search import SearchResult
|
||||
|
||||
scores: dict[int, float] = {}
|
||||
sources: dict[int, dict[str, SearchResult]] = {}
|
||||
|
||||
for rank, r in enumerate(text_results, start=1):
|
||||
scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
|
||||
sources.setdefault(r.id, {})["text"] = r
|
||||
|
||||
for rank, r in enumerate(vector_results, start=1):
|
||||
scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
|
||||
sources.setdefault(r.id, {})["vector"] = r
|
||||
|
||||
merged: list[SearchResult] = []
|
||||
for doc_id, rrf_score in sorted(scores.items(), key=lambda kv: -kv[1]):
|
||||
srcs = sources[doc_id]
|
||||
base = srcs.get("text") or srcs.get("vector")
|
||||
assert base is not None
|
||||
reasons: list[str] = []
|
||||
if "text" in srcs:
|
||||
reasons.append(srcs["text"].match_reason or "text")
|
||||
if "vector" in srcs:
|
||||
reasons.append("vector")
|
||||
merged.append(
|
||||
SearchResult(
|
||||
id=base.id,
|
||||
title=base.title,
|
||||
ai_domain=base.ai_domain,
|
||||
ai_summary=base.ai_summary,
|
||||
file_format=base.file_format,
|
||||
score=rrf_score,
|
||||
snippet=base.snippet,
|
||||
match_reason="+".join(reasons),
|
||||
)
|
||||
)
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
# ─── 3) RRF + 강한 시그널 boost ─────────────────────────
|
||||
|
||||
|
||||
class RRFWithBoost(RRFOnly):
|
||||
"""RRF + 강한 시그널 boost.
|
||||
|
||||
RRF의 점수 평탄화를 보완하기 위해 다음 케이스에 score를 추가 가산:
|
||||
- title 정확 substring 매치 : +0.020
|
||||
- tags 매치 : +0.015
|
||||
- 법령 조문 정확 매치(예 제80조): +0.050 (가장 강한 override)
|
||||
- text score >= 5.0 : +0.010
|
||||
|
||||
Boost 크기는 의도적으로 적당히. RRF의 안정성은 유지하되 강한 신호는 끌어올림.
|
||||
Phase 0.5 default 전략.
|
||||
"""
|
||||
|
||||
name = "rrf_boost"
|
||||
|
||||
BOOST_TITLE = 0.020
|
||||
BOOST_TAGS = 0.015
|
||||
BOOST_LEGAL_ARTICLE = 0.050
|
||||
BOOST_HIGH_TEXT_SCORE = 0.010
|
||||
|
||||
LEGAL_ARTICLE_RE = re.compile(r"제\s*\d+\s*조")
|
||||
HIGH_TEXT_SCORE_THRESHOLD = 5.0
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
# 일단 RRF로 후보 충분히 확보 (boost 후 재정렬되도록 limit 넓게)
|
||||
candidates = super().fuse(text_results, vector_results, query, max(limit * 3, 30))
|
||||
|
||||
# 원본 text 신호 lookup
|
||||
text_score_by_id = {r.id: r.score for r in text_results}
|
||||
text_reason_by_id = {r.id: (r.match_reason or "") for r in text_results}
|
||||
|
||||
# 쿼리에 법령 조문이 있으면 그 조문 추출
|
||||
legal_articles_in_query = set(
|
||||
re.sub(r"\s+", "", a) for a in self.LEGAL_ARTICLE_RE.findall(query)
|
||||
)
|
||||
|
||||
for result in candidates:
|
||||
boost = 0.0
|
||||
text_reason = text_reason_by_id.get(result.id, "")
|
||||
|
||||
if "title" in text_reason:
|
||||
boost += self.BOOST_TITLE
|
||||
elif "tags" in text_reason:
|
||||
boost += self.BOOST_TAGS
|
||||
|
||||
if text_score_by_id.get(result.id, 0.0) >= self.HIGH_TEXT_SCORE_THRESHOLD:
|
||||
boost += self.BOOST_HIGH_TEXT_SCORE
|
||||
|
||||
if legal_articles_in_query and result.title:
|
||||
title_articles = set(
|
||||
re.sub(r"\s+", "", a)
|
||||
for a in self.LEGAL_ARTICLE_RE.findall(result.title)
|
||||
)
|
||||
if legal_articles_in_query & title_articles:
|
||||
boost += self.BOOST_LEGAL_ARTICLE
|
||||
|
||||
if boost > 0:
|
||||
# pydantic v2에서도 mutate 가능
|
||||
result.score = result.score + boost
|
||||
|
||||
candidates.sort(key=lambda r: r.score, reverse=True)
|
||||
return candidates[:limit]
|
||||
|
||||
|
||||
# ─── factory ─────────────────────────────────────────────
|
||||
|
||||
|
||||
_STRATEGIES: dict[str, type[FusionStrategy]] = {
|
||||
"legacy": LegacyWeightedSum,
|
||||
"rrf": RRFOnly,
|
||||
"rrf_boost": RRFWithBoost,
|
||||
}
|
||||
|
||||
DEFAULT_FUSION = "rrf_boost"
|
||||
|
||||
|
||||
def get_strategy(name: str) -> FusionStrategy:
|
||||
cls = _STRATEGIES.get(name)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown fusion strategy: {name}")
|
||||
return cls()
|
||||
|
||||
|
||||
# ─── display score 정규화 ────────────────────────────────
|
||||
|
||||
|
||||
def normalize_display_scores(results: list["SearchResult"]) -> None:
|
||||
"""SearchResult.score를 [0.05..1.0] 랭크 기반 값으로 in-place 갱신.
|
||||
|
||||
프론트엔드는 score*100을 % 표시하므로 [0..1] 범위가 적절.
|
||||
fusion 내부 score는 상대적 순서만 의미가 있으므로 절대값 노출 없이 랭크만 표시.
|
||||
|
||||
랭크 1 → 1.0 / 랭크 2 → 0.95 / ... / 랭크 20 → 0.05 (균등 분포)
|
||||
"""
|
||||
n = len(results)
|
||||
if n == 0:
|
||||
return
|
||||
for i, r in enumerate(results):
|
||||
# 1.0 → 0.05 사이 균등 분포
|
||||
rank_score = 1.0 - (i / max(n - 1, 1)) * 0.95
|
||||
r.score = round(rank_score, 4)
|
||||
5
app/services/search/query_analyzer.py
Normal file
5
app/services/search/query_analyzer.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Query analyzer — 자연어 쿼리 분석 (Phase 2).
|
||||
|
||||
domain_hint, intent, hard/soft filter, normalized_queries 등 추출.
|
||||
구현은 Phase 2에서 채움.
|
||||
"""
|
||||
199
app/services/search/rerank_service.py
Normal file
199
app/services/search/rerank_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Reranker 서비스 — bge-reranker-v2-m3 통합 (Phase 1.3).
|
||||
|
||||
TEI 컨테이너 호출 + asyncio.Semaphore(2) + soft timeout fallback.
|
||||
|
||||
데이터 흐름 원칙:
|
||||
- fusion = doc 기준 / reranker = chunk 기준 — 절대 섞지 말 것
|
||||
- raw chunks를 끝까지 보존, fusion은 압축본만 사용
|
||||
- reranker는 chunks_by_doc dict에서 raw chunks 회수해서 chunk 단위로 호출
|
||||
- diversity는 reranker 직후 마지막 단계에서만 적용
|
||||
|
||||
snippet 생성:
|
||||
- 200~400 토큰(800~1500자) 기준
|
||||
- query keyword 위치 중심 ±target_chars/2 윈도우
|
||||
- keyword 매치 없으면 첫 target_chars 문자 fallback (성능 손실 방지)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.utils import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
logger = setup_logger("rerank")
|
||||
|
||||
# 동시 rerank 호출 제한 (GPU saturation 방지)
|
||||
RERANK_SEMAPHORE = asyncio.Semaphore(2)
|
||||
|
||||
# rerank input 크기 제한 (latency / VRAM hard cap)
|
||||
MAX_RERANK_INPUT = 200
|
||||
MAX_CHUNKS_PER_DOC = 2
|
||||
|
||||
# Soft timeout (초)
|
||||
RERANK_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _extract_window(text: str, query: str, target_chars: int = 800) -> str:
|
||||
"""query keyword 위치 중심으로 ±target_chars/2 윈도우 추출.
|
||||
|
||||
fallback: keyword 매치 없으면 첫 target_chars 문자 그대로.
|
||||
이게 없으면 reranker가 무관한 텍스트만 보고 점수 매겨 성능 급락.
|
||||
"""
|
||||
keywords = [k for k in re.split(r"\s+", query) if len(k) >= 2]
|
||||
best_pos = -1
|
||||
for kw in keywords:
|
||||
pos = text.lower().find(kw.lower())
|
||||
if pos >= 0:
|
||||
best_pos = pos
|
||||
break
|
||||
|
||||
if best_pos < 0:
|
||||
# Fallback: 첫 target_chars 문자
|
||||
return text[:target_chars]
|
||||
|
||||
half = target_chars // 2
|
||||
start = max(0, best_pos - half)
|
||||
end = min(len(text), start + target_chars)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def _make_snippet(c: "SearchResult", query: str, max_chars: int = 1500) -> str:
|
||||
"""Reranker input snippet — title + query 중심 본문 윈도우.
|
||||
|
||||
feedback_search_phase1_implementation.md 3번 항목 강제:
|
||||
snippet 200~400 토큰(800~1500자), full document 절대 안 됨.
|
||||
"""
|
||||
title = c.title or ""
|
||||
text = c.snippet or ""
|
||||
|
||||
# snippet은 chunk text 앞 200자 또는 doc text 앞 200자
|
||||
# 더 긴 chunk text가 필요하면 호출자가 따로 채워서 넘김
|
||||
if len(text) > max_chars:
|
||||
text = _extract_window(text, query, target_chars=max_chars - 100)
|
||||
|
||||
return f"{title}\n\n{text}"
|
||||
|
||||
|
||||
def _wrap_doc_as_chunk(doc: "SearchResult") -> "SearchResult":
|
||||
"""text-only 매치 doc(chunks_by_doc에 없는 doc)을 ChunkResult 형태로 변환.
|
||||
|
||||
Phase 1.3 reranker 입력에 doc 자체가 들어가야 하는 경우.
|
||||
snippet은 documents.extracted_text 앞 200자 (이미 SearchResult.snippet에 채워짐).
|
||||
chunk_id 등은 None 그대로.
|
||||
"""
|
||||
return doc
|
||||
|
||||
|
||||
async def rerank_chunks(
|
||||
query: str,
|
||||
candidates: list["SearchResult"],
|
||||
limit: int,
|
||||
) -> list["SearchResult"]:
|
||||
"""RRF 결과 candidates를 bge-reranker로 재정렬.
|
||||
|
||||
Args:
|
||||
query: 사용자 쿼리
|
||||
candidates: chunk-level SearchResult 리스트 (이미 chunks_by_doc에서 회수)
|
||||
limit: 반환할 결과 수
|
||||
|
||||
Returns:
|
||||
reranked SearchResult 리스트 (rerank score로 score 필드 업데이트)
|
||||
|
||||
Fallback (timeout/HTTPError): RRF 순서 그대로 candidates[:limit] 반환.
|
||||
"""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# input 크기 제한 (latency/VRAM hard cap)
|
||||
if len(candidates) > MAX_RERANK_INPUT:
|
||||
logger.warning(
|
||||
f"rerank input {len(candidates)} > MAX {MAX_RERANK_INPUT}, 자름"
|
||||
)
|
||||
candidates = candidates[:MAX_RERANK_INPUT]
|
||||
|
||||
snippets = [_make_snippet(c, query) for c in candidates]
|
||||
client = AIClient()
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(RERANK_TIMEOUT):
|
||||
async with RERANK_SEMAPHORE:
|
||||
results = await client.rerank(query, snippets)
|
||||
# results: [{"index": int, "score": float}, ...] (이미 정렬됨)
|
||||
reranked: list["SearchResult"] = []
|
||||
for r in results:
|
||||
idx = r.get("index")
|
||||
sc = r.get("score")
|
||||
if idx is None or sc is None or idx >= len(candidates):
|
||||
continue
|
||||
chunk = candidates[idx]
|
||||
chunk.score = float(sc)
|
||||
chunk.match_reason = (chunk.match_reason or "") + "+rerank"
|
||||
reranked.append(chunk)
|
||||
return reranked[:limit]
|
||||
except (asyncio.TimeoutError, httpx.HTTPError) as e:
|
||||
logger.warning(f"rerank failed → RRF fallback: {type(e).__name__}: {e}")
|
||||
return candidates[:limit]
|
||||
except Exception as e:
|
||||
logger.warning(f"rerank unexpected error → RRF fallback: {type(e).__name__}: {e}")
|
||||
return candidates[:limit]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def warmup_reranker() -> bool:
|
||||
"""TEI 부팅 후 모델 로딩 완료 대기 (10회 retry).
|
||||
|
||||
TEI는 health 200을 빠르게 반환하지만 첫 모델 로딩(10~30초) 전에는
|
||||
rerank 요청이 실패하거나 매우 느림. FastAPI startup 또는 첫 요청 전 호출.
|
||||
"""
|
||||
client = AIClient()
|
||||
try:
|
||||
for attempt in range(10):
|
||||
try:
|
||||
await client.rerank("warmup", ["dummy text for model load"])
|
||||
logger.info(f"reranker warmup OK (attempt {attempt + 1})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.info(f"reranker warmup retry {attempt + 1}: {e}")
|
||||
await asyncio.sleep(3)
|
||||
logger.error("reranker warmup failed after 10 attempts")
|
||||
return False
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def apply_diversity(
|
||||
results: list["SearchResult"],
|
||||
max_per_doc: int = MAX_CHUNKS_PER_DOC,
|
||||
top_score_threshold: float = 0.90,
|
||||
) -> list["SearchResult"]:
|
||||
"""chunk-level 결과를 doc 기준으로 압축 (max_per_doc).
|
||||
|
||||
조건부 완화: 가장 상위 결과 score가 threshold 이상이면 unlimited
|
||||
(high confidence relevance > diversity).
|
||||
"""
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# 가장 상위 score가 threshold 이상이면 diversity 제약 해제
|
||||
top_score = results[0].score if results else 0.0
|
||||
if top_score >= top_score_threshold:
|
||||
return results
|
||||
|
||||
seen: dict[int, int] = {}
|
||||
out: list["SearchResult"] = []
|
||||
for r in results:
|
||||
doc_id = r.id
|
||||
if seen.get(doc_id, 0) >= max_per_doc:
|
||||
continue
|
||||
out.append(r)
|
||||
seen[doc_id] = seen.get(doc_id, 0) + 1
|
||||
return out
|
||||
342
app/services/search/retrieval_service.py
Normal file
342
app/services/search/retrieval_service.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""검색 후보 수집 서비스 (Phase 1.2).
|
||||
|
||||
text(documents FTS + trigram) + vector(documents.embedding + chunks.embedding hybrid) 후보를
|
||||
SearchResult 리스트로 반환.
|
||||
|
||||
Phase 1.1a: search.py의 _search_text/_search_vector를 이전 (ILIKE 그대로).
|
||||
Phase 1.2-B: ILIKE → trigram `%` + `similarity()`. ILIKE 풀 스캔 제거.
|
||||
Phase 1.2-C: vector retrieval을 document_chunks 테이블로 전환 → catastrophic recall 손실.
|
||||
Phase 1.2-G: doc + chunks hybrid retrieval 보강.
|
||||
- documents.embedding (recall robust, 자연어 매칭 강함)
|
||||
- document_chunks.embedding (precision, segment 매칭)
|
||||
- 두 SQL 동시 호출 후 doc_id 기준 merge (chunk 가중치 1.2, doc 1.0)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.database import engine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
|
||||
# Hybrid merge 가중치 (1.2-G)
|
||||
DOC_VECTOR_WEIGHT = 1.0
|
||||
CHUNK_VECTOR_WEIGHT = 1.2
|
||||
|
||||
|
||||
async def search_text(
|
||||
session: AsyncSession, query: str, limit: int
|
||||
) -> list["SearchResult"]:
|
||||
"""FTS + trigram 필드별 가중치 검색 (Phase 1.2-B UNION 분해).
|
||||
|
||||
Phase 1.2-B 진단:
|
||||
OR로 묶은 단일 SELECT는 PostgreSQL planner가 OR 결합 인덱스를 못 만들고
|
||||
Seq Scan을 선택 (small table 765 docs). EXPLAIN으로 측정 시 525ms.
|
||||
→ CTE + UNION으로 분해하면 각 branch가 자기 인덱스 활용 → 26ms (95% 감소).
|
||||
|
||||
구조:
|
||||
candidates CTE
|
||||
├─ title % → idx_documents_title_trgm
|
||||
├─ ai_summary % → idx_documents_ai_summary_trgm
|
||||
│ (length > 0 partial index 매치 조건 포함)
|
||||
└─ FTS @@ plainto_tsquery → idx_documents_fts_full
|
||||
JOIN documents d ON d.id = c.id
|
||||
ORDER BY 5컬럼 similarity 가중 합산 + ts_rank * 2.0
|
||||
가중치: title 3.0 / ai_tags 2.5 / user_note 2.0 / ai_summary 1.5 / extracted_text 1.0
|
||||
|
||||
threshold:
|
||||
pg_trgm.similarity_threshold default = 0.3
|
||||
→ multi-token 한국어 뉴스 쿼리(예: "이란 미국 전쟁 글로벌 반응")에서
|
||||
candidates를 못 모음 → recall 감소 (0.788 → 0.750)
|
||||
→ set_limit(0.15)으로 낮춰 recall 회복. precision은 ORDER BY similarity 합산이 보정.
|
||||
"""
|
||||
from api.search import SearchResult # 순환 import 회피
|
||||
|
||||
# trigram threshold를 0.15로 낮춰 multi-token query recall 회복
|
||||
# SQLAlchemy async session 내 두 execute는 같은 connection 사용
|
||||
await session.execute(text("SELECT set_limit(0.15)"))
|
||||
|
||||
result = await session.execute(
|
||||
text("""
|
||||
WITH candidates AS (
|
||||
-- title trigram (idx_documents_title_trgm)
|
||||
SELECT id FROM documents
|
||||
WHERE deleted_at IS NULL AND title % :q
|
||||
UNION
|
||||
-- ai_summary trigram (idx_documents_ai_summary_trgm 부분 인덱스 매치)
|
||||
SELECT id FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND ai_summary IS NOT NULL
|
||||
AND length(ai_summary) > 0
|
||||
AND ai_summary % :q
|
||||
UNION
|
||||
-- FTS 통합 인덱스 (idx_documents_fts_full)
|
||||
SELECT id FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND to_tsvector('simple',
|
||||
coalesce(title, '') || ' ' ||
|
||||
coalesce(ai_tags::text, '') || ' ' ||
|
||||
coalesce(ai_summary, '') || ' ' ||
|
||||
coalesce(user_note, '') || ' ' ||
|
||||
coalesce(extracted_text, '')
|
||||
) @@ plainto_tsquery('simple', :q)
|
||||
)
|
||||
SELECT d.id, d.title, d.ai_domain, d.ai_summary, d.file_format,
|
||||
left(d.extracted_text, 200) AS snippet,
|
||||
(
|
||||
-- 컬럼별 trigram similarity 가중 합산
|
||||
similarity(coalesce(d.title, ''), :q) * 3.0
|
||||
+ similarity(coalesce(d.ai_tags::text, ''), :q) * 2.5
|
||||
+ similarity(coalesce(d.user_note, ''), :q) * 2.0
|
||||
+ similarity(coalesce(d.ai_summary, ''), :q) * 1.5
|
||||
+ similarity(coalesce(d.extracted_text, ''), :q) * 1.0
|
||||
-- FTS 보너스 (idx_documents_fts_full 활용)
|
||||
+ coalesce(ts_rank(
|
||||
to_tsvector('simple',
|
||||
coalesce(d.title, '') || ' ' ||
|
||||
coalesce(d.ai_tags::text, '') || ' ' ||
|
||||
coalesce(d.ai_summary, '') || ' ' ||
|
||||
coalesce(d.user_note, '') || ' ' ||
|
||||
coalesce(d.extracted_text, '')
|
||||
),
|
||||
plainto_tsquery('simple', :q)
|
||||
), 0) * 2.0
|
||||
) AS score,
|
||||
-- match_reason: similarity 가장 큰 컬럼 또는 FTS
|
||||
CASE
|
||||
WHEN similarity(coalesce(d.title, ''), :q) >= 0.3 THEN 'title'
|
||||
WHEN similarity(coalesce(d.ai_tags::text, ''), :q) >= 0.3 THEN 'tags'
|
||||
WHEN similarity(coalesce(d.user_note, ''), :q) >= 0.3 THEN 'note'
|
||||
WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary'
|
||||
WHEN similarity(coalesce(d.extracted_text, ''), :q) >= 0.3 THEN 'content'
|
||||
ELSE 'fts'
|
||||
END AS match_reason
|
||||
FROM documents d
|
||||
JOIN candidates c ON d.id = c.id
|
||||
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"]:
|
||||
"""Hybrid 벡터 검색 — doc + chunks 동시 retrieval (Phase 1.2-G).
|
||||
|
||||
Phase 1.2-C 진단:
|
||||
chunks-only는 segment 의미 손실로 자연어 query에서 catastrophic recall.
|
||||
doc embedding은 전체 본문 평균 → recall robust.
|
||||
→ 두 retrieval 동시 사용이 정석.
|
||||
|
||||
데이터 흐름:
|
||||
1. query embedding 1번 (bge-m3)
|
||||
2. asyncio.gather로 두 SQL 동시 호출:
|
||||
- _search_vector_docs: documents.embedding cosine top N
|
||||
- _search_vector_chunks: document_chunks.embedding window partition (doc당 top 2)
|
||||
3. _merge_doc_and_chunk_vectors로 가중치 + dedup:
|
||||
- chunk score * 1.2 (precision)
|
||||
- doc score * 1.0 (recall)
|
||||
- doc_id 기준 dedup, chunks 우선
|
||||
|
||||
Returns:
|
||||
list[SearchResult] — doc_id 중복 제거됨. compress_chunks_to_docs는 그대로 동작.
|
||||
chunks_by_doc은 search.py에서 group_by_doc으로 보존.
|
||||
"""
|
||||
try:
|
||||
client = AIClient()
|
||||
query_embedding = await client.embed(query)
|
||||
await client.close()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
embedding_str = str(query_embedding)
|
||||
|
||||
# 두 SQL 병렬 호출 — 각각 별도 session 사용 (asyncpg connection은 statement 단위 직렬)
|
||||
Session = async_sessionmaker(engine)
|
||||
|
||||
async def _docs_call() -> list["SearchResult"]:
|
||||
async with Session() as s:
|
||||
return await _search_vector_docs(s, embedding_str, limit * 4)
|
||||
|
||||
async def _chunks_call() -> list["SearchResult"]:
|
||||
async with Session() as s:
|
||||
return await _search_vector_chunks(s, embedding_str, limit * 4)
|
||||
|
||||
doc_results, chunk_results = await asyncio.gather(_docs_call(), _chunks_call())
|
||||
|
||||
return _merge_doc_and_chunk_vectors(doc_results, chunk_results)
|
||||
|
||||
|
||||
async def _search_vector_docs(
|
||||
session: AsyncSession, embedding_str: str, limit: int
|
||||
) -> list["SearchResult"]:
|
||||
"""documents.embedding 직접 검색 — recall robust (자연어 매칭).
|
||||
|
||||
chunks가 없는 doc도 매칭 가능. score는 cosine similarity (1 - distance).
|
||||
chunk_id/chunk_index/section_title은 None.
|
||||
"""
|
||||
from api.search import SearchResult # 순환 import 회피
|
||||
|
||||
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_doc' AS match_reason,
|
||||
NULL::bigint AS chunk_id,
|
||||
NULL::integer AS chunk_index,
|
||||
NULL::text AS section_title
|
||||
FROM documents
|
||||
WHERE embedding IS NOT NULL AND deleted_at IS NULL
|
||||
ORDER BY embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"embedding": embedding_str, "limit": limit},
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
async def _search_vector_chunks(
|
||||
session: AsyncSession, embedding_str: str, limit: int
|
||||
) -> list["SearchResult"]:
|
||||
"""document_chunks.embedding 검색 + window partition (doc당 top 2 chunks).
|
||||
|
||||
SQL 흐름:
|
||||
1. inner CTE topk: ivfflat 인덱스로 top-K chunks 추출
|
||||
2. ranked CTE: doc_id PARTITION + ROW_NUMBER (score 내림차순)
|
||||
3. outer: rn <= 2 (doc당 max 2 chunks) + JOIN documents
|
||||
"""
|
||||
from api.search import SearchResult # 순환 import 회피
|
||||
|
||||
inner_k = max(limit * 5, 500)
|
||||
result = await session.execute(
|
||||
text("""
|
||||
WITH topk AS (
|
||||
SELECT
|
||||
c.id AS chunk_id,
|
||||
c.doc_id,
|
||||
c.chunk_index,
|
||||
c.section_title,
|
||||
c.text,
|
||||
c.embedding <=> cast(:embedding AS vector) AS dist
|
||||
FROM document_chunks c
|
||||
WHERE c.embedding IS NOT NULL
|
||||
ORDER BY c.embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :inner_k
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
chunk_id, doc_id, chunk_index, section_title, text, dist,
|
||||
ROW_NUMBER() OVER (PARTITION BY doc_id ORDER BY dist ASC) AS rn
|
||||
FROM topk
|
||||
)
|
||||
SELECT
|
||||
d.id AS id,
|
||||
d.title AS title,
|
||||
d.ai_domain AS ai_domain,
|
||||
d.ai_summary AS ai_summary,
|
||||
d.file_format AS file_format,
|
||||
(1 - r.dist) AS score,
|
||||
left(r.text, 200) AS snippet,
|
||||
'vector_chunk' AS match_reason,
|
||||
r.chunk_id AS chunk_id,
|
||||
r.chunk_index AS chunk_index,
|
||||
r.section_title AS section_title
|
||||
FROM ranked r
|
||||
JOIN documents d ON d.id = r.doc_id
|
||||
WHERE r.rn <= 2 AND d.deleted_at IS NULL
|
||||
ORDER BY r.dist
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"embedding": embedding_str, "inner_k": inner_k, "limit": limit},
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
def _merge_doc_and_chunk_vectors(
|
||||
doc_results: list["SearchResult"],
|
||||
chunk_results: list["SearchResult"],
|
||||
) -> list["SearchResult"]:
|
||||
"""doc + chunks vector 결과 merge (Phase 1.2-G).
|
||||
|
||||
가중치:
|
||||
- chunk score * 1.2 (segment 매칭이 더 정확)
|
||||
- doc score * 1.0 (전체 본문 평균, recall 보완)
|
||||
|
||||
Dedup:
|
||||
- doc_id 기준
|
||||
- chunks가 있으면 chunks 우선 (segment 정보 + chunk_id 보존)
|
||||
- chunks에 없는 doc은 doc-wrap으로 추가
|
||||
|
||||
Returns:
|
||||
score 내림차순 정렬된 SearchResult 리스트.
|
||||
chunk_id가 None이면 doc-wrap 결과(text-only 매치 doc 처리에 사용).
|
||||
"""
|
||||
by_doc_id: dict[int, "SearchResult"] = {}
|
||||
|
||||
# chunks 먼저 (가중치 적용 + chunk_id 보존)
|
||||
for c in chunk_results:
|
||||
c.score = c.score * CHUNK_VECTOR_WEIGHT
|
||||
prev = by_doc_id.get(c.id)
|
||||
if prev is None or c.score > prev.score:
|
||||
by_doc_id[c.id] = c
|
||||
|
||||
# doc 매치는 chunks에 없는 doc만 추가 (chunks 우선 원칙)
|
||||
for d in doc_results:
|
||||
d.score = d.score * DOC_VECTOR_WEIGHT
|
||||
if d.id not in by_doc_id:
|
||||
by_doc_id[d.id] = d
|
||||
|
||||
# score 내림차순 정렬
|
||||
return sorted(by_doc_id.values(), key=lambda r: r.score, reverse=True)
|
||||
|
||||
|
||||
def compress_chunks_to_docs(
|
||||
chunks: list["SearchResult"], limit: int
|
||||
) -> tuple[list["SearchResult"], dict[int, list["SearchResult"]]]:
|
||||
"""chunk-level 결과를 doc-level로 압축하면서 raw chunks를 보존.
|
||||
|
||||
fusion은 doc 기준이어야 하지만(같은 doc 중복 방지), Phase 1.3 reranker는
|
||||
chunk 기준 raw 데이터가 필요함. 따라서 압축본과 raw를 동시 반환.
|
||||
|
||||
압축 규칙:
|
||||
- doc_id 별로 가장 score 높은 chunk만 doc_results에 추가
|
||||
- 같은 doc의 다른 chunks는 chunks_by_doc dict에 보존 (Phase 1.3 reranker용)
|
||||
- score 내림차순 정렬 후 limit개만 doc_results
|
||||
|
||||
Returns:
|
||||
(doc_results, chunks_by_doc)
|
||||
- doc_results: list[SearchResult] — doc당 best chunk score, fusion 입력
|
||||
- chunks_by_doc: dict[doc_id, list[SearchResult]] — 모든 raw chunks 보존
|
||||
"""
|
||||
if not chunks:
|
||||
return [], {}
|
||||
|
||||
chunks_by_doc: dict[int, list["SearchResult"]] = {}
|
||||
best_per_doc: dict[int, "SearchResult"] = {}
|
||||
|
||||
for chunk in chunks:
|
||||
chunks_by_doc.setdefault(chunk.id, []).append(chunk)
|
||||
prev_best = best_per_doc.get(chunk.id)
|
||||
if prev_best is None or chunk.score > prev_best.score:
|
||||
best_per_doc[chunk.id] = chunk
|
||||
|
||||
# doc 단위 best score 정렬, 상위 limit개
|
||||
doc_results = sorted(best_per_doc.values(), key=lambda r: r.score, reverse=True)
|
||||
return doc_results[:limit], chunks_by_doc
|
||||
6
app/services/search/synthesis_service.py
Normal file
6
app/services/search/synthesis_service.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Grounded answer synthesis 서비스 (Phase 3).
|
||||
|
||||
evidence span을 Gemma 4에 전달해 인용 기반 답변 생성.
|
||||
3~4초 soft timeout, 타임아웃 시 결과만 반환 fallback.
|
||||
구현은 Phase 3에서 채움.
|
||||
"""
|
||||
297
app/services/search_telemetry.py
Normal file
297
app/services/search_telemetry.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""검색 실패 자동 로깅 (Phase 0.3)
|
||||
|
||||
목적: gold dataset 시드 수집. 평가셋 확장의 재료.
|
||||
|
||||
자동 수집 트리거:
|
||||
1) result_count == 0 → no_result
|
||||
2) confidence < THRESHOLD → low_confidence
|
||||
3) 60초 내 동일 사용자 재쿼리 → user_reformulated (이전 쿼리 기록)
|
||||
|
||||
confidence는 Phase 0.3 시점엔 휴리스틱(top score + match_reason 기반).
|
||||
Phase 2 QueryAnalyzer 도입 후 LLM 기반 confidence로 교체될 예정.
|
||||
|
||||
⚠ 단일 fastapi 워커 가정: recent_searches 트래커는 in-memory dict.
|
||||
멀티 워커로 확장 시 user_reformulated 신호가 일부 손실되지만 정확성에는 영향 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.database import async_session
|
||||
from models.search_failure import SearchFailureLog
|
||||
|
||||
logger = logging.getLogger("search_telemetry")
|
||||
|
||||
# ─── 튜닝 파라미터 ─────────────────────────────────────
|
||||
LOW_CONFIDENCE_THRESHOLD = 0.5
|
||||
REFORMULATION_WINDOW_SEC = 60.0
|
||||
TRACKER_MAX_USERS = 1000 # 인메모리 트래커 상한 (LRU-ish 정리)
|
||||
|
||||
|
||||
# ─── 인메모리 최근 쿼리 트래커 ─────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecentSearch:
|
||||
query: str
|
||||
normalized: str
|
||||
ts: float # monotonic seconds
|
||||
|
||||
|
||||
_recent: dict[int, _RecentSearch] = {}
|
||||
_recent_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _normalize(query: str) -> str:
|
||||
return " ".join(query.lower().strip().split())
|
||||
|
||||
|
||||
async def _record_and_get_prior(
|
||||
user_id: int, query: str
|
||||
) -> _RecentSearch | None:
|
||||
"""현재 쿼리를 트래커에 기록하고, 60초 이내 직전 쿼리(있으면)를 반환."""
|
||||
now = time.monotonic()
|
||||
normalized = _normalize(query)
|
||||
async with _recent_lock:
|
||||
prior = _recent.get(user_id)
|
||||
# 60초 초과한 prior는 무효
|
||||
if prior and (now - prior.ts) > REFORMULATION_WINDOW_SEC:
|
||||
prior = None
|
||||
_recent[user_id] = _RecentSearch(query=query, normalized=normalized, ts=now)
|
||||
# 단순 상한 정리 (oldest 절반 제거)
|
||||
if len(_recent) > TRACKER_MAX_USERS:
|
||||
stale = sorted(_recent.items(), key=lambda kv: kv[1].ts)[: TRACKER_MAX_USERS // 2]
|
||||
for uid, _ in stale:
|
||||
_recent.pop(uid, None)
|
||||
return prior
|
||||
|
||||
|
||||
# ─── confidence 휴리스틱 ─────────────────────────────────
|
||||
|
||||
|
||||
def compute_confidence(results: list[Any], mode: str) -> float:
|
||||
"""검색 결과로부터 confidence(0..1)를 휴리스틱으로 산정.
|
||||
|
||||
Phase 0.3 임시 구현. Phase 2에서 QueryAnalyzer 결과 + reranker score로 교체.
|
||||
|
||||
score 의미 정리 (search.py 기준):
|
||||
- mode=vector → score = 코사인 유사도 [0..1]
|
||||
- mode=fts/trgm/hybrid에서 텍스트 매치 → score = 가중치 합산 (unbounded)
|
||||
가중치: title=3.0 / tags=2.5 / note=2.0 / summary=1.5 / content=1.0 / fts bonus≈2.0
|
||||
- mode=hybrid에서 텍스트 0건 → 벡터 결과만, score는 코사인 그대로
|
||||
- mode=hybrid 텍스트+벡터 동시 매치 → score = 텍스트가중치 + 0.5*코사인,
|
||||
match_reason = "<텍스트reason>+vector"
|
||||
|
||||
핵심: match_reason이 정확히 'vector'(=문자열 "vector")면 텍스트 매치 0건인 vector-only.
|
||||
이 경우 score는 raw 코사인이므로 amplify 금지.
|
||||
"""
|
||||
if not results:
|
||||
return 0.0
|
||||
|
||||
top = results[0]
|
||||
top_score = float(getattr(top, "score", 0.0) or 0.0)
|
||||
reason = (getattr(top, "match_reason", "") or "").lower()
|
||||
|
||||
if mode == "vector":
|
||||
# 코사인 유사도 그대로
|
||||
return _cosine_to_confidence(top_score)
|
||||
|
||||
# hybrid에서 텍스트+벡터 합성 매치는 reason에 "+vector" 접미. 신뢰 가산.
|
||||
has_vector_boost = "+vector" in reason
|
||||
boost = 0.10 if has_vector_boost else 0.0
|
||||
|
||||
# text / hybrid: 강한 텍스트 매치 우선 판정.
|
||||
# 임계값은 search.py의 가중치 합산 분포(텍스트base + FTS bonus + 0.5*cosine)를 반영.
|
||||
if "title" in reason and top_score >= 3.5:
|
||||
return min(1.0, 0.95 + boost)
|
||||
if any(k in reason for k in ("tags", "note")) and top_score >= 2.5:
|
||||
return min(1.0, 0.85 + boost)
|
||||
if "summary" in reason and top_score >= 2.0:
|
||||
return min(1.0, 0.75 + boost)
|
||||
if "content" in reason and top_score >= 1.5:
|
||||
return min(1.0, 0.65 + boost)
|
||||
if "fts" in reason and top_score >= 1.0:
|
||||
return min(1.0, 0.55 + boost)
|
||||
|
||||
# vector-only hit (텍스트 0건 → 코사인 raw, amplify 금지)
|
||||
if reason == "vector":
|
||||
return _cosine_to_confidence(top_score)
|
||||
|
||||
# 그 외(약한 매치 또는 알 수 없는 reason)
|
||||
return 0.3
|
||||
|
||||
|
||||
def _cosine_to_confidence(cosine: float) -> float:
|
||||
"""bge-m3 임베딩 코사인 유사도 → confidence 환산.
|
||||
|
||||
bge-m3는 무관한 텍스트도 보통 0.3~0.5 정도 코사인을 만든다.
|
||||
따라서 0.5는 "약하게 닮음", 0.7+는 "꽤 관련", 0.85+는 "매우 관련"으로 본다.
|
||||
"""
|
||||
if cosine >= 0.85:
|
||||
return 0.95
|
||||
if cosine >= 0.75:
|
||||
return 0.80
|
||||
if cosine >= 0.65:
|
||||
return 0.65
|
||||
if cosine >= 0.55:
|
||||
return 0.50 # threshold 경계
|
||||
if cosine >= 0.45:
|
||||
return 0.35
|
||||
if cosine >= 0.35:
|
||||
return 0.20
|
||||
return 0.10
|
||||
|
||||
|
||||
def compute_confidence_reranked(reranked_results: list[Any]) -> float:
|
||||
"""Phase 1.3 reranker score 기반 confidence.
|
||||
|
||||
bge-reranker-v2-m3는 sigmoid score (0~1 범위)를 반환.
|
||||
rerank 활성 시 fusion score보다 reranker score가 가장 신뢰할 수 있는 신호.
|
||||
|
||||
임계값(초안, 실측 후 조정 가능):
|
||||
>= 0.95 → high
|
||||
>= 0.80 → med-high
|
||||
>= 0.60 → med
|
||||
>= 0.40 → low-med
|
||||
else → low
|
||||
"""
|
||||
if not reranked_results:
|
||||
return 0.0
|
||||
top_score = float(getattr(reranked_results[0], "score", 0.0) or 0.0)
|
||||
if top_score >= 0.95:
|
||||
return 0.95
|
||||
if top_score >= 0.80:
|
||||
return 0.80
|
||||
if top_score >= 0.60:
|
||||
return 0.65
|
||||
if top_score >= 0.40:
|
||||
return 0.50
|
||||
return 0.35
|
||||
|
||||
|
||||
def compute_confidence_hybrid(
|
||||
text_results: list[Any],
|
||||
vector_results: list[Any],
|
||||
) -> float:
|
||||
"""hybrid 모드 confidence — fusion 적용 *전*의 raw text/vector 결과로 계산.
|
||||
|
||||
Phase 0.5에서 RRF 도입 후 fused score는 절대값 의미가 사라지므로,
|
||||
원본 retrieval 신호의 더 강한 쪽을 confidence로 채택.
|
||||
"""
|
||||
text_conf = compute_confidence(text_results, "fts") if text_results else 0.0
|
||||
vector_conf = (
|
||||
compute_confidence(vector_results, "vector") if vector_results else 0.0
|
||||
)
|
||||
return max(text_conf, vector_conf)
|
||||
|
||||
|
||||
# ─── 로깅 진입점 ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def _insert_log(
|
||||
query: str,
|
||||
user_id: int | None,
|
||||
result_count: int,
|
||||
confidence: float | None,
|
||||
failure_reason: str,
|
||||
context: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""단독 세션으로 INSERT (background task에서 호출되므로 request 세션 사용 불가)."""
|
||||
try:
|
||||
async with async_session() as session:
|
||||
row = SearchFailureLog(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
result_count=result_count,
|
||||
confidence=confidence,
|
||||
failure_reason=failure_reason,
|
||||
context=context,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
except SQLAlchemyError as exc:
|
||||
# 로깅 실패가 검색 자체를 깨뜨리지 않도록 흡수
|
||||
logger.warning(f"failure log insert failed: {exc}")
|
||||
|
||||
|
||||
def _build_context(
|
||||
results: list[Any],
|
||||
mode: str,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ctx: dict[str, Any] = {
|
||||
"mode": mode,
|
||||
"result_count": len(results),
|
||||
"top_score": float(results[0].score) if results else None,
|
||||
"top_match_reason": (results[0].match_reason if results else None),
|
||||
"returned_ids": [r.id for r in results[:10]],
|
||||
}
|
||||
if extra:
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
async def record_search_event(
|
||||
query: str,
|
||||
user_id: int | None,
|
||||
results: list[Any],
|
||||
mode: str,
|
||||
confidence: float | None = None,
|
||||
) -> None:
|
||||
"""검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT.
|
||||
|
||||
background task에서 await로 호출. request 세션과 분리.
|
||||
user_id가 None이면 reformulation 추적 + 로깅 모두 스킵 (시스템 호출 등).
|
||||
|
||||
confidence 파라미터:
|
||||
- None이면 results 기준으로 자체 계산 (legacy 호출용).
|
||||
- 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준).
|
||||
"""
|
||||
if user_id is None:
|
||||
return
|
||||
|
||||
if confidence is None:
|
||||
confidence = compute_confidence(results, mode)
|
||||
result_count = len(results)
|
||||
base_ctx = _build_context(results, mode, extra={"confidence": confidence})
|
||||
|
||||
# ── 1) reformulation 체크 (이전 쿼리가 있으면 그걸 로깅) ──
|
||||
prior = await _record_and_get_prior(user_id, query)
|
||||
if prior and prior.normalized != _normalize(query):
|
||||
await _insert_log(
|
||||
query=prior.query,
|
||||
user_id=user_id,
|
||||
result_count=-1, # prior의 result_count는 알 수 없음(요청 세션 끝남)
|
||||
confidence=None,
|
||||
failure_reason="user_reformulated",
|
||||
context={"reformulated_to": query, "elapsed_sec": time.monotonic() - prior.ts},
|
||||
)
|
||||
|
||||
# ── 2) 현재 쿼리에 대한 실패 트리거 ──
|
||||
if result_count == 0:
|
||||
await _insert_log(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
result_count=0,
|
||||
confidence=0.0,
|
||||
failure_reason="no_result",
|
||||
context=base_ctx,
|
||||
)
|
||||
return
|
||||
|
||||
if confidence < LOW_CONFIDENCE_THRESHOLD:
|
||||
await _insert_log(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
result_count=result_count,
|
||||
confidence=confidence,
|
||||
failure_reason="low_confidence",
|
||||
context=base_ctx,
|
||||
)
|
||||
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>
|
||||
354
app/workers/chunk_worker.py
Normal file
354
app/workers/chunk_worker.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""Chunk 워커 — 문서 유형별 chunking + bge-m3 임베딩 (Phase 0.1)
|
||||
|
||||
승부처는 chunk 품질. 문서 유형별로 다른 전략:
|
||||
- 법령: 조/항 단위 (구조적, overlap 불필요)
|
||||
- 뉴스: 문단 단위 (overlap ~15%)
|
||||
- 일반 문서: 슬라이딩 윈도우 (overlap 15-25%)
|
||||
- 긴 PDF: 슬라이딩 윈도우 (overlap 20-30%)
|
||||
- 마크다운: heading section 단위 (overlap 없음)
|
||||
- 이메일: 본문 전체 (대부분 짧음)
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.utils import setup_logger
|
||||
from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
|
||||
logger = setup_logger("chunk_worker")
|
||||
|
||||
# ─── 상수 ───
|
||||
# 문자 기준(bge-m3는 8192 토큰 여유 있음, 한국어 1토큰≈2.5자)
|
||||
DEFAULT_WINDOW_CHARS = 1500 # ~600 tokens (ko 기준)
|
||||
DEFAULT_OVERLAP_CHARS = 300 # ~20% overlap
|
||||
LONG_PDF_WINDOW_CHARS = 2000 # ~800 tokens
|
||||
LONG_PDF_OVERLAP_CHARS = 500 # ~25% overlap
|
||||
NEWS_OVERLAP_CHARS = 150 # ~15%
|
||||
MIN_CHUNK_CHARS = 50 # 너무 짧은 chunk는 버림
|
||||
|
||||
|
||||
# ─── 언어 감지 (간단한 휴리스틱) ───
|
||||
def _detect_language(text: str) -> str:
|
||||
"""문자 비율 기반 언어 감지"""
|
||||
if not text:
|
||||
return "unknown"
|
||||
sample = text[:2000]
|
||||
ko = sum(1 for c in sample if "\uac00" <= c <= "\ud7a3")
|
||||
ja = sum(1 for c in sample if "\u3040" <= c <= "\u30ff")
|
||||
zh = sum(1 for c in sample if "\u4e00" <= c <= "\u9fff")
|
||||
en = sum(1 for c in sample if c.isascii() and c.isalpha())
|
||||
total = ko + ja + zh + en
|
||||
if total == 0:
|
||||
return "unknown"
|
||||
# CJK 우선 (한중일은 한자 overlap이 있으므로 순서 중요)
|
||||
if ja / total > 0.1:
|
||||
return "ja"
|
||||
if ko / total > 0.2:
|
||||
return "ko"
|
||||
if zh / total > 0.2:
|
||||
return "zh"
|
||||
if en / total > 0.5:
|
||||
return "en"
|
||||
return "ko" # 기본값
|
||||
|
||||
|
||||
# ─── 문서 유형 판별 ───
|
||||
def _classify_chunk_strategy(doc: Document) -> str:
|
||||
"""문서 유형에 따라 chunking 전략 선택"""
|
||||
if doc.source_channel == "news":
|
||||
return "news"
|
||||
if doc.ai_domain and "Legislation" in doc.ai_domain:
|
||||
return "legal"
|
||||
if doc.file_format == "md" or doc.file_format == "markdown":
|
||||
return "markdown"
|
||||
if doc.file_format in ("eml", "msg"):
|
||||
return "email"
|
||||
if doc.file_format == "pdf":
|
||||
# 본문 길이로 긴 PDF 구분
|
||||
if doc.extracted_text and len(doc.extracted_text) > 20000:
|
||||
return "long_pdf"
|
||||
return "pdf"
|
||||
return "default"
|
||||
|
||||
|
||||
# ─── Chunking 전략 ───
|
||||
def _chunk_legal(text: str) -> list[dict]:
|
||||
"""법령: 제N조 단위로 분할 (상위 조문 컨텍스트 보존).
|
||||
|
||||
영어/외국 법령(ai_domain Foreign_Law 등)은 "제N조" 패턴이 없어 split 결과가
|
||||
1개 element만 나옴 → 서문 chunk 1개만 생성되고 본문 대부분이 손실되는 버그.
|
||||
조문 패턴 미검출 시 sliding window fallback으로 처리.
|
||||
"""
|
||||
# "제 1 조", "제1조", "제 1 조(제목)" 등 매칭
|
||||
pattern = re.compile(r"(제\s*\d+\s*조(?:의\s*\d+)?(?:\([^)]*\))?)")
|
||||
parts = pattern.split(text)
|
||||
|
||||
# 조문 패턴 미검출 (영어/외국 법령 등) → sliding window fallback
|
||||
if len(parts) <= 1:
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "section")
|
||||
|
||||
chunks = []
|
||||
# parts[0] = 조 이전 서문, parts[1], parts[2] = (마커, 본문) pairs
|
||||
if parts[0].strip() and len(parts[0]) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({
|
||||
"text": parts[0].strip()[:DEFAULT_WINDOW_CHARS],
|
||||
"chunk_type": "section",
|
||||
"section_title": "서문",
|
||||
})
|
||||
|
||||
i = 1
|
||||
while i < len(parts):
|
||||
marker = parts[i]
|
||||
body = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
full = f"{marker} {body}".strip()
|
||||
if len(full) >= MIN_CHUNK_CHARS:
|
||||
# 너무 길면 슬라이싱 (조문이 매우 긴 경우)
|
||||
if len(full) <= DEFAULT_WINDOW_CHARS:
|
||||
chunks.append({
|
||||
"text": full,
|
||||
"chunk_type": "legal_article",
|
||||
"section_title": marker.strip(),
|
||||
})
|
||||
else:
|
||||
# 긴 조문은 윈도우로 추가 분할
|
||||
for offset in range(0, len(full), DEFAULT_WINDOW_CHARS - 200):
|
||||
sub = full[offset : offset + DEFAULT_WINDOW_CHARS]
|
||||
if len(sub) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({
|
||||
"text": sub,
|
||||
"chunk_type": "legal_article",
|
||||
"section_title": marker.strip(),
|
||||
})
|
||||
i += 2
|
||||
|
||||
# 법령이지만 조문 패턴이 없으면 기본 슬라이딩 윈도우로 fallback
|
||||
if not chunks:
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "section")
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_news(text: str) -> list[dict]:
|
||||
"""뉴스: 문단 단위 (빈 줄 기준), 너무 짧으면 병합"""
|
||||
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
chunks = []
|
||||
buffer = ""
|
||||
for p in paragraphs:
|
||||
if len(buffer) + len(p) < DEFAULT_WINDOW_CHARS:
|
||||
buffer = f"{buffer}\n\n{p}".strip() if buffer else p
|
||||
else:
|
||||
if len(buffer) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({"text": buffer, "chunk_type": "paragraph", "section_title": None})
|
||||
buffer = p
|
||||
if buffer and len(buffer) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({"text": buffer, "chunk_type": "paragraph", "section_title": None})
|
||||
if not chunks:
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, NEWS_OVERLAP_CHARS, "paragraph")
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_markdown(text: str) -> list[dict]:
|
||||
"""마크다운: heading section 단위"""
|
||||
# '#', '##', '###' 기준 분할
|
||||
pattern = re.compile(r"^(#{1,6}\s+.+)$", re.MULTILINE)
|
||||
matches = list(pattern.finditer(text))
|
||||
|
||||
chunks = []
|
||||
if not matches:
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "section")
|
||||
|
||||
# 첫 heading 이전 서문
|
||||
if matches[0].start() > 0:
|
||||
preface = text[: matches[0].start()].strip()
|
||||
if len(preface) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({"text": preface, "chunk_type": "section", "section_title": "서문"})
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
start = m.start()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
section_text = text[start:end].strip()
|
||||
heading = m.group(1).strip("# ").strip()
|
||||
if len(section_text) < MIN_CHUNK_CHARS:
|
||||
continue
|
||||
# 긴 섹션은 추가 분할
|
||||
if len(section_text) <= DEFAULT_WINDOW_CHARS:
|
||||
chunks.append({
|
||||
"text": section_text,
|
||||
"chunk_type": "section",
|
||||
"section_title": heading,
|
||||
})
|
||||
else:
|
||||
sub_chunks = _chunk_sliding(
|
||||
section_text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "section"
|
||||
)
|
||||
for sc in sub_chunks:
|
||||
sc["section_title"] = heading
|
||||
chunks.append(sc)
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_email(text: str) -> list[dict]:
|
||||
"""이메일: 본문 전체 (짧음)"""
|
||||
text = text.strip()
|
||||
if len(text) < MIN_CHUNK_CHARS:
|
||||
return []
|
||||
# 너무 길면 슬라이딩으로 분할
|
||||
if len(text) > DEFAULT_WINDOW_CHARS * 2:
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "email_body")
|
||||
return [{"text": text, "chunk_type": "email_body", "section_title": None}]
|
||||
|
||||
|
||||
def _chunk_sliding(
|
||||
text: str, window: int, overlap: int, chunk_type: str
|
||||
) -> list[dict]:
|
||||
"""슬라이딩 윈도우 분할 (문장 경계 가능한 한 보존)"""
|
||||
chunks = []
|
||||
stride = window - overlap
|
||||
if stride <= 0:
|
||||
stride = window
|
||||
|
||||
i = 0
|
||||
while i < len(text):
|
||||
end = min(i + window, len(text))
|
||||
# 문장 경계에 맞춰 조정 (끝에 가까운 마침표/줄바꿈)
|
||||
if end < len(text):
|
||||
for punct in [". ", ".\n", "。", "\n\n", "\n"]:
|
||||
cut = text.rfind(punct, max(i + window - 300, i), end)
|
||||
if cut > i:
|
||||
end = cut + len(punct)
|
||||
break
|
||||
chunk_text = text[i:end].strip()
|
||||
if len(chunk_text) >= MIN_CHUNK_CHARS:
|
||||
chunks.append({
|
||||
"text": chunk_text,
|
||||
"chunk_type": chunk_type,
|
||||
"section_title": None,
|
||||
})
|
||||
if end >= len(text):
|
||||
break
|
||||
i = max(end - overlap, i + 1)
|
||||
return chunks
|
||||
|
||||
|
||||
def _chunk_document(doc: Document) -> list[dict]:
|
||||
"""문서 유형별 chunking 디스패처"""
|
||||
text = doc.extracted_text or ""
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
strategy = _classify_chunk_strategy(doc)
|
||||
|
||||
if strategy == "legal":
|
||||
return _chunk_legal(text)
|
||||
if strategy == "news":
|
||||
return _chunk_news(text)
|
||||
if strategy == "markdown":
|
||||
return _chunk_markdown(text)
|
||||
if strategy == "email":
|
||||
return _chunk_email(text)
|
||||
if strategy == "long_pdf":
|
||||
return _chunk_sliding(text, LONG_PDF_WINDOW_CHARS, LONG_PDF_OVERLAP_CHARS, "window")
|
||||
# default (pdf, general)
|
||||
return _chunk_sliding(text, DEFAULT_WINDOW_CHARS, DEFAULT_OVERLAP_CHARS, "window")
|
||||
|
||||
|
||||
# ─── 뉴스 소스 메타데이터 조회 ───
|
||||
async def _lookup_news_source(
|
||||
session: AsyncSession, doc: Document
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""뉴스 문서의 country/source/language를 news_sources에서 조회
|
||||
|
||||
매칭 방식: doc.ai_sub_group = source.name.split(' ')[0]
|
||||
"""
|
||||
if doc.source_channel != "news":
|
||||
return None, None, None
|
||||
|
||||
source_name = doc.ai_sub_group or ""
|
||||
if not source_name:
|
||||
return None, None, None
|
||||
|
||||
# news_sources에서 이름이 일치하는 레코드 찾기 (prefix match)
|
||||
result = await session.execute(select(NewsSource))
|
||||
sources = result.scalars().all()
|
||||
for src in sources:
|
||||
if src.name.split(" ")[0] == source_name:
|
||||
return src.country, src.name, src.language
|
||||
|
||||
return None, source_name, None
|
||||
|
||||
|
||||
# ─── 메인 워커 함수 ───
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서를 chunks로 분할하고 bge-m3로 임베딩"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
if not doc.extracted_text:
|
||||
logger.warning(f"[chunk] document_id={document_id}: extracted_text 없음, 스킵")
|
||||
return
|
||||
|
||||
# chunking
|
||||
chunk_dicts = _chunk_document(doc)
|
||||
if not chunk_dicts:
|
||||
logger.warning(f"[chunk] document_id={document_id}: chunks 생성 실패")
|
||||
return
|
||||
|
||||
# 메타데이터 준비
|
||||
language = _detect_language(doc.extracted_text)
|
||||
country, source, src_lang = await _lookup_news_source(session, doc)
|
||||
if src_lang:
|
||||
language = src_lang
|
||||
domain_category = "news" if doc.source_channel == "news" else "document"
|
||||
|
||||
# 기존 chunks 삭제 (재처리)
|
||||
await session.execute(delete(DocumentChunk).where(DocumentChunk.doc_id == document_id))
|
||||
|
||||
# 임베딩 + 저장
|
||||
client = AIClient()
|
||||
try:
|
||||
for idx, c in enumerate(chunk_dicts):
|
||||
# Phase 1.2-G: embedding 입력 강화 (자연어 query ↔ 법령 조항 의미 매칭 개선)
|
||||
# 짧은 본문이나 segment-only chunk는 임베딩 signal이 약함 → title/section 포함.
|
||||
section = c.get("section_title") or ""
|
||||
embed_input = (
|
||||
f"[제목] {doc.title or ''}\n"
|
||||
f"[섹션] {section}\n"
|
||||
f"[본문] {c['text']}"
|
||||
)
|
||||
try:
|
||||
embedding = await client.embed(embed_input)
|
||||
except Exception as e:
|
||||
logger.warning(f"[chunk] document_id={document_id} chunk {idx} 임베딩 실패: {e}")
|
||||
embedding = None
|
||||
|
||||
chunk = DocumentChunk(
|
||||
doc_id=document_id,
|
||||
chunk_index=idx,
|
||||
chunk_type=c["chunk_type"],
|
||||
section_title=c.get("section_title"),
|
||||
heading_path=None, # 추후 마크다운 tree에서 채움
|
||||
page=None, # 추후 PDF 파서에서 채움
|
||||
language=language,
|
||||
country=country,
|
||||
source=source,
|
||||
domain_category=domain_category,
|
||||
text=c["text"],
|
||||
embedding=embedding,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(chunk)
|
||||
|
||||
logger.info(
|
||||
f"[chunk] document_id={document_id}: {len(chunk_dicts)}개 chunks 생성 "
|
||||
f"(strategy={_classify_chunk_strategy(doc)}, lang={language}, "
|
||||
f"domain={domain_category}, country={country})"
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
127
app/workers/classify_worker.py
Normal file
127
app/workers/classify_worker.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""AI 분류 워커 — taxonomy 기반 도메인/문서타입/태그/요약 생성"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response, strip_thinking
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
|
||||
logger = setup_logger("classify_worker")
|
||||
|
||||
MAX_CLASSIFY_TEXT = 8000
|
||||
|
||||
# settings에서 taxonomy/document_types 로딩
|
||||
DOCUMENT_TYPES = set(settings.document_types)
|
||||
|
||||
|
||||
def _get_taxonomy_leaf_paths(taxonomy: dict, prefix: str = "") -> set[str]:
|
||||
"""taxonomy dict에서 모든 유효한 경로를 추출"""
|
||||
paths = set()
|
||||
for key, value in taxonomy.items():
|
||||
current = f"{prefix}/{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
paths.add(current)
|
||||
else:
|
||||
paths.update(_get_taxonomy_leaf_paths(value, current))
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
paths.add(current)
|
||||
else:
|
||||
for leaf in value:
|
||||
paths.add(f"{current}/{leaf}")
|
||||
paths.add(current) # 2단계도 허용 (leaf가 없는 경우용)
|
||||
else:
|
||||
paths.add(current)
|
||||
return paths
|
||||
|
||||
|
||||
VALID_DOMAIN_PATHS = _get_taxonomy_leaf_paths(settings.taxonomy)
|
||||
|
||||
|
||||
def _validate_domain(domain: str) -> str:
|
||||
"""domain이 taxonomy에 존재하는지 검증, 없으면 최대한 가까운 경로 찾기"""
|
||||
if domain in VALID_DOMAIN_PATHS:
|
||||
return domain
|
||||
|
||||
# 부분 매칭 시도 (2단계까지)
|
||||
parts = domain.split("/")
|
||||
for i in range(len(parts), 0, -1):
|
||||
partial = "/".join(parts[:i])
|
||||
if partial in VALID_DOMAIN_PATHS:
|
||||
logger.warning(f"[분류] domain '{domain}' → '{partial}' (부분 매칭)")
|
||||
return partial
|
||||
|
||||
logger.warning(f"[분류] domain '{domain}' taxonomy에 없음, General/Reading_Notes로 대체")
|
||||
return "General/Reading_Notes"
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 AI 분류 + 요약"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
client = AIClient()
|
||||
try:
|
||||
# ─── 분류 ───
|
||||
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
|
||||
raw_response = await client.classify(truncated)
|
||||
parsed = parse_json_response(raw_response)
|
||||
|
||||
if not parsed:
|
||||
raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}")
|
||||
|
||||
# domain 검증
|
||||
domain = _validate_domain(parsed.get("domain", ""))
|
||||
doc.ai_domain = domain
|
||||
|
||||
# sub_group은 domain 경로에서 추출 (호환성)
|
||||
parts = domain.split("/")
|
||||
doc.ai_sub_group = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# document_type 검증
|
||||
doc_type = parsed.get("document_type", "")
|
||||
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
|
||||
|
||||
# confidence
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
|
||||
|
||||
# importance
|
||||
importance = parsed.get("importance", "medium")
|
||||
doc.importance = importance if importance in ("high", "medium", "low") else "medium"
|
||||
|
||||
# tags
|
||||
doc.ai_tags = parsed.get("tags", [])[:5]
|
||||
|
||||
# source/origin
|
||||
if parsed.get("sourceChannel") and not doc.source_channel:
|
||||
doc.source_channel = parsed["sourceChannel"]
|
||||
if parsed.get("dataOrigin") and not doc.data_origin:
|
||||
doc.data_origin = parsed["dataOrigin"]
|
||||
|
||||
# ─── 요약 ───
|
||||
summary = await client.summarize(doc.extracted_text[:15000])
|
||||
doc.ai_summary = strip_thinking(summary)
|
||||
|
||||
# ─── 메타데이터 ───
|
||||
doc.ai_model_version = "qwen3.5-35b-a3b"
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
f"[분류] document_id={document_id}: "
|
||||
f"domain={domain}, type={doc.document_type}, "
|
||||
f"confidence={doc.ai_confidence:.2f}, tags={doc.ai_tags}"
|
||||
)
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
# _move_to_knowledge 제거됨 — 파일은 원본 위치 유지, 분류는 DB 메타데이터만
|
||||
146
app/workers/daily_digest.py
Normal file
146
app/workers/daily_digest.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""일일 다이제스트 워커 — PostgreSQL/CalDAV 쿼리 → Markdown + SMTP
|
||||
|
||||
v1 scripts/pkm_daily_digest.py에서 포팅.
|
||||
DEVONthink/OmniFocus → PostgreSQL/CalDAV 쿼리로 전환.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import send_smtp_email, setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("daily_digest")
|
||||
|
||||
|
||||
async def run():
|
||||
"""일일 다이제스트 생성 + 저장 + 발송"""
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
sections = []
|
||||
|
||||
async with async_session() as session:
|
||||
# ─── 1. 오늘 추가된 문서 ───
|
||||
added = await session.execute(
|
||||
select(Document.ai_domain, func.count(Document.id))
|
||||
.where(func.date(Document.created_at) == today)
|
||||
.group_by(Document.ai_domain)
|
||||
)
|
||||
added_rows = added.all()
|
||||
total_added = sum(row[1] for row in added_rows)
|
||||
|
||||
section = f"## 오늘 추가된 문서 ({total_added}건)\n"
|
||||
if added_rows:
|
||||
for domain, count in added_rows:
|
||||
section += f"- {domain or '미분류'}: {count}건\n"
|
||||
else:
|
||||
section += "- 없음\n"
|
||||
sections.append(section)
|
||||
|
||||
# ─── 2. 법령 변경 ───
|
||||
law_docs = await session.execute(
|
||||
select(Document.title)
|
||||
.where(
|
||||
Document.source_channel == "law_monitor",
|
||||
func.date(Document.created_at) == today,
|
||||
)
|
||||
)
|
||||
law_rows = law_docs.scalars().all()
|
||||
section = f"## 법령 변경 ({len(law_rows)}건)\n"
|
||||
if law_rows:
|
||||
for title in law_rows:
|
||||
section += f"- {title}\n"
|
||||
else:
|
||||
section += "- 변경 없음\n"
|
||||
sections.append(section)
|
||||
|
||||
# ─── 3. 이메일 수집 ───
|
||||
email_count = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.source_channel == "email",
|
||||
func.date(Document.created_at) == today,
|
||||
)
|
||||
)
|
||||
email_total = email_count.scalar() or 0
|
||||
sections.append(f"## 이메일 수집\n- {email_total}건 아카이브\n")
|
||||
|
||||
# ─── 4. 처리 파이프라인 상태 ───
|
||||
queue_stats = await session.execute(
|
||||
text("""
|
||||
SELECT stage, status, COUNT(*)
|
||||
FROM processing_queue
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY stage, status
|
||||
ORDER BY stage, status
|
||||
""")
|
||||
)
|
||||
queue_rows = queue_stats.all()
|
||||
section = "## 파이프라인 상태 (24h)\n"
|
||||
if queue_rows:
|
||||
for stage, status, count in queue_rows:
|
||||
section += f"- {stage}/{status}: {count}건\n"
|
||||
else:
|
||||
section += "- 처리 항목 없음\n"
|
||||
|
||||
# 실패 건수 강조
|
||||
failed = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProcessingQueue)
|
||||
.where(
|
||||
ProcessingQueue.status == "failed",
|
||||
ProcessingQueue.created_at > text("NOW() - INTERVAL '24 hours'"),
|
||||
)
|
||||
)
|
||||
failed_count = failed.scalar() or 0
|
||||
if failed_count > 0:
|
||||
section += f"\n⚠️ **실패 {failed_count}건** — 수동 확인 필요\n"
|
||||
sections.append(section)
|
||||
|
||||
# ─── 5. Inbox 미분류 ───
|
||||
inbox_count = await session.execute(
|
||||
select(func.count(Document.id))
|
||||
.where(Document.file_path.like("PKM/Inbox/%"))
|
||||
)
|
||||
inbox_total = inbox_count.scalar() or 0
|
||||
if inbox_total > 0:
|
||||
sections.append(f"## Inbox 미분류\n- {inbox_total}건 대기 중\n")
|
||||
|
||||
# ─── Markdown 조합 ───
|
||||
date_display = datetime.now(timezone.utc).strftime("%Y년 %m월 %d일")
|
||||
markdown = f"# PKM 일일 다이제스트 — {date_display}\n\n"
|
||||
markdown += "\n".join(sections)
|
||||
markdown += f"\n---\n*생성: {datetime.now(timezone.utc).isoformat()}*\n"
|
||||
|
||||
# ─── NAS 저장 ───
|
||||
digest_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "digests"
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
digest_path = digest_dir / f"{today}_digest.md"
|
||||
digest_path.write_text(markdown, encoding="utf-8")
|
||||
|
||||
# ─── 90일 초과 아카이브 ───
|
||||
archive_dir = digest_dir / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
|
||||
for old in digest_dir.glob("*_digest.md"):
|
||||
if old.stat().st_mtime < cutoff:
|
||||
old.rename(archive_dir / old.name)
|
||||
|
||||
# ─── SMTP 발송 ───
|
||||
smtp_host = os.getenv("MAILPLUS_HOST", "")
|
||||
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
|
||||
smtp_user = os.getenv("MAILPLUS_USER", "")
|
||||
smtp_pass = os.getenv("MAILPLUS_PASS", "")
|
||||
if smtp_host and smtp_user:
|
||||
send_smtp_email(
|
||||
smtp_host, smtp_port, smtp_user, smtp_pass,
|
||||
f"PKM 다이제스트 — {date_display}",
|
||||
markdown,
|
||||
)
|
||||
|
||||
logger.info(f"다이제스트 생성 완료: {digest_path}")
|
||||
44
app/workers/embed_worker.py
Normal file
44
app/workers/embed_worker.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""벡터 임베딩 워커 — GPU 서버 bge-m3 호출"""
|
||||
|
||||
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")
|
||||
|
||||
# 임베딩용 텍스트 최대 길이 (bge-m3: 8192 토큰)
|
||||
MAX_EMBED_TEXT = 6000
|
||||
EMBED_MODEL_VERSION = "bge-m3"
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 벡터 임베딩 생성"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
# title + 본문 앞부분을 결합하여 임베딩 입력 생성
|
||||
title_part = doc.title or ""
|
||||
text_part = doc.extracted_text[:MAX_EMBED_TEXT]
|
||||
embed_input = f"{title_part}\n\n{text_part}".strip()
|
||||
|
||||
if not embed_input:
|
||||
logger.warning(f"[임베딩] document_id={document_id}: 빈 텍스트, 스킵")
|
||||
return
|
||||
|
||||
client = AIClient()
|
||||
try:
|
||||
vector = await client.embed(embed_input)
|
||||
doc.embedding = vector
|
||||
doc.embed_model_version = EMBED_MODEL_VERSION
|
||||
doc.embedded_at = datetime.now(timezone.utc)
|
||||
logger.info(f"[임베딩] document_id={document_id}: {len(vector)}차원 벡터 생성")
|
||||
finally:
|
||||
await client.close()
|
||||
167
app/workers/extract_worker.py
Normal file
167
app/workers/extract_worker.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""텍스트 추출 워커 — kordoc / LibreOffice / 직접 읽기"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
|
||||
logger = setup_logger("extract_worker")
|
||||
|
||||
# kordoc으로 파싱 가능한 포맷
|
||||
KORDOC_FORMATS = {"hwp", "hwpx", "pdf"}
|
||||
# 직접 읽기 가능한 텍스트 포맷
|
||||
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
|
||||
# LibreOffice로 텍스트 추출 가능한 포맷
|
||||
OFFICE_FORMATS = {"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "odoc", "osheet"}
|
||||
# OCR 필요 이미지 포맷 (Phase 2)
|
||||
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif"}
|
||||
|
||||
EXTRACTOR_VERSION = "kordoc@1.7"
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 텍스트 추출"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
fmt = doc.file_format.lower()
|
||||
full_path = Path(settings.nas_mount_path) / doc.file_path
|
||||
|
||||
# 텍스트 파일 — 직접 읽기
|
||||
if fmt in TEXT_FORMATS:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
text = full_path.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "direct_read"
|
||||
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
|
||||
return
|
||||
|
||||
# 이미지 — 스킵 (Phase 2 OCR)
|
||||
if fmt in IMAGE_FORMATS:
|
||||
doc.extracted_text = ""
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "skip_image"
|
||||
logger.info(f"[이미지] {doc.file_path} — OCR 미구현, 스킵")
|
||||
return
|
||||
|
||||
# kordoc 파싱 (HWP/HWPX/PDF)
|
||||
if fmt in KORDOC_FORMATS:
|
||||
# 컨테이너 내부 경로: /documents/{file_path}
|
||||
container_path = f"/documents/{doc.file_path}"
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.kordoc_endpoint}/parse",
|
||||
json={"filePath": container_path},
|
||||
)
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(f"kordoc: 파일 없음 — {container_path}")
|
||||
if resp.status_code == 422:
|
||||
raise ValueError(f"kordoc: 파싱 실패 — {resp.json().get('error', 'unknown')}")
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
doc.extracted_text = data.get("markdown", "")
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = EXTRACTOR_VERSION
|
||||
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
|
||||
return
|
||||
|
||||
# 오피스 포맷 — LibreOffice 텍스트 변환
|
||||
if fmt in OFFICE_FORMATS:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
|
||||
import shutil
|
||||
tmp_dir = Path("/tmp/extract_work")
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 한글 파일명 문제 방지 — 영문 임시 파일로 복사
|
||||
tmp_input = tmp_dir / f"input_{document_id}.{fmt}"
|
||||
shutil.copy2(str(full_path), str(tmp_input))
|
||||
|
||||
# 스프레드시트는 csv, 나머지는 txt
|
||||
CALC_FORMATS = {"xlsx", "xls", "ods", "osheet"}
|
||||
if fmt in CALC_FORMATS:
|
||||
convert_to = "csv:Text - txt - csv (StarCalc):44,34,76,1"
|
||||
out_ext = "csv"
|
||||
else:
|
||||
convert_to = "txt:Text"
|
||||
out_ext = "txt"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", convert_to, "--outdir", str(tmp_dir), str(tmp_input)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
out_file = tmp_dir / f"input_{document_id}.{out_ext}"
|
||||
if out_file.exists():
|
||||
text = out_file.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text[:15000]
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "libreoffice"
|
||||
out_file.unlink()
|
||||
logger.info(f"[LibreOffice] {doc.file_path} ({len(text)}자)")
|
||||
else:
|
||||
raise RuntimeError(f"LibreOffice 변환 실패: {result.stderr[:300]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"LibreOffice 텍스트 추출 timeout (60s)")
|
||||
finally:
|
||||
tmp_input.unlink(missing_ok=True)
|
||||
|
||||
# ─── ODF 변환 (편집용) ───
|
||||
CONVERT_MAP = {
|
||||
'xlsx': 'ods', 'xls': 'ods',
|
||||
'docx': 'odt', 'doc': 'odt',
|
||||
'pptx': 'odp', 'ppt': 'odp',
|
||||
}
|
||||
target_fmt = CONVERT_MAP.get(fmt)
|
||||
if target_fmt:
|
||||
try:
|
||||
# .derived 디렉토리에 변환 (file_path는 원본 유지!)
|
||||
derived_dir = full_path.parent / ".derived"
|
||||
derived_dir.mkdir(exist_ok=True)
|
||||
tmp_input2 = tmp_dir / f"convert_{document_id}.{fmt}"
|
||||
shutil.copy2(str(full_path), str(tmp_input2))
|
||||
|
||||
conv_result = subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", target_fmt, "--outdir", str(tmp_dir), str(tmp_input2)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
tmp_input2.unlink(missing_ok=True)
|
||||
|
||||
conv_file = tmp_dir / f"convert_{document_id}.{target_fmt}"
|
||||
if conv_file.exists():
|
||||
final_path = derived_dir / f"{document_id}.{target_fmt}"
|
||||
shutil.move(str(conv_file), str(final_path))
|
||||
|
||||
nas_root = Path(settings.nas_mount_path)
|
||||
doc.derived_path = str(final_path.relative_to(nas_root))
|
||||
doc.original_format = doc.file_format
|
||||
doc.conversion_status = "done"
|
||||
logger.info(f"[ODF변환] {doc.file_path} → derived: {doc.derived_path}")
|
||||
else:
|
||||
doc.conversion_status = "failed"
|
||||
logger.warning(f"[ODF변환] 실패: {conv_result.stderr[:200]}")
|
||||
except Exception as e:
|
||||
doc.conversion_status = "failed"
|
||||
logger.error(f"[ODF변환] {doc.file_path} 에러: {e}")
|
||||
else:
|
||||
doc.conversion_status = "none"
|
||||
|
||||
return
|
||||
|
||||
# 미지원 포맷
|
||||
doc.extracted_text = ""
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = f"unsupported_{fmt}"
|
||||
logger.warning(f"[미지원] {doc.file_path} (format={fmt})")
|
||||
100
app/workers/file_watcher.py
Normal file
100
app/workers/file_watcher.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""파일 감시 워커 — Inbox 디렉토리 스캔, 새 파일/변경 파일 자동 등록"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import file_hash, setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("file_watcher")
|
||||
|
||||
# 무시할 파일
|
||||
SKIP_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", "Icon\r"}
|
||||
SKIP_EXTENSIONS = {".tmp", ".part", ".crdownload"}
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP_NAMES or path.name.startswith("._"):
|
||||
return True
|
||||
if path.suffix.lower() in SKIP_EXTENSIONS:
|
||||
return True
|
||||
# .derived/ 및 .preview/ 디렉토리 내 파일 제외
|
||||
if ".derived" in path.parts or ".preview" in path.parts:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def watch_inbox():
|
||||
"""Inbox 디렉토리를 스캔하여 새/변경 파일을 DB에 등록"""
|
||||
inbox_path = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
if not inbox_path.exists():
|
||||
return
|
||||
|
||||
files = [f for f in inbox_path.rglob("*") if f.is_file() and not should_skip(f)]
|
||||
if not files:
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
changed_count = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for file_path in files:
|
||||
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
|
||||
fhash = file_hash(file_path)
|
||||
|
||||
# DB에서 기존 문서 확인
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
# 새 파일 → 등록
|
||||
ext = file_path.suffix.lstrip(".").lower() or "unknown"
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=fhash,
|
||||
file_format=ext,
|
||||
file_size=file_path.stat().st_size,
|
||||
file_type="immutable",
|
||||
title=file_path.stem,
|
||||
source_channel="drive_sync",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id,
|
||||
stage="extract",
|
||||
status="pending",
|
||||
))
|
||||
new_count += 1
|
||||
|
||||
elif existing.file_hash != fhash:
|
||||
# 해시 변경 → 재가공
|
||||
existing.file_hash = fhash
|
||||
existing.file_size = file_path.stat().st_size
|
||||
|
||||
# 기존 pending/processing 큐 항목이 없으면 extract부터 재시작
|
||||
queue_check = await session.execute(
|
||||
select(ProcessingQueue).where(
|
||||
ProcessingQueue.document_id == existing.id,
|
||||
ProcessingQueue.status.in_(["pending", "processing"]),
|
||||
)
|
||||
)
|
||||
if not queue_check.scalar_one_or_none():
|
||||
session.add(ProcessingQueue(
|
||||
document_id=existing.id,
|
||||
stage="extract",
|
||||
status="pending",
|
||||
))
|
||||
changed_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
if new_count or changed_count:
|
||||
logger.info(f"[Inbox] 새 파일 {new_count}건, 변경 파일 {changed_count}건 등록")
|
||||
364
app/workers/law_monitor.py
Normal file
364
app/workers/law_monitor.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""법령 모니터 워커 — 국가법령정보센터 API 연동
|
||||
|
||||
26개 법령 모니터링, 편/장 단위 분할 저장, 변경 이력 추적.
|
||||
매일 07:00 실행 (APScheduler).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import create_caldav_todo, escape_ical_text, file_hash, send_smtp_email, setup_logger
|
||||
from models.automation import AutomationState
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
|
||||
|
||||
# 모니터링 대상 법령 (26개)
|
||||
MONITORED_LAWS = [
|
||||
# 산업안전보건 핵심
|
||||
"산업안전보건법",
|
||||
"산업안전보건법 시행령",
|
||||
"산업안전보건법 시행규칙",
|
||||
"산업안전보건기준에 관한 규칙",
|
||||
"유해위험작업의 취업 제한에 관한 규칙",
|
||||
"중대재해 처벌 등에 관한 법률",
|
||||
"중대재해 처벌 등에 관한 법률 시행령",
|
||||
# 건설안전
|
||||
"건설기술 진흥법",
|
||||
"건설기술 진흥법 시행령",
|
||||
"건설기술 진흥법 시행규칙",
|
||||
"시설물의 안전 및 유지관리에 관한 특별법",
|
||||
# 위험물/화학
|
||||
"위험물안전관리법",
|
||||
"위험물안전관리법 시행령",
|
||||
"위험물안전관리법 시행규칙",
|
||||
"화학물질관리법",
|
||||
"화학물질관리법 시행령",
|
||||
"화학물질의 등록 및 평가 등에 관한 법률",
|
||||
# 소방/전기/가스
|
||||
"소방시설 설치 및 관리에 관한 법률",
|
||||
"소방시설 설치 및 관리에 관한 법률 시행령",
|
||||
"전기사업법",
|
||||
"전기안전관리법",
|
||||
"고압가스 안전관리법",
|
||||
"고압가스 안전관리법 시행령",
|
||||
"액화석유가스의 안전관리 및 사업법",
|
||||
# 근로/환경
|
||||
"근로기준법",
|
||||
"환경영향평가법",
|
||||
]
|
||||
|
||||
|
||||
async def run():
|
||||
"""법령 변경 모니터링 실행"""
|
||||
law_oc = os.getenv("LAW_OC", "")
|
||||
if not law_oc:
|
||||
logger.warning("LAW_OC 미설정 — 법령 API 승인 대기 중")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
state = await session.execute(
|
||||
select(AutomationState).where(AutomationState.job_name == "law_monitor")
|
||||
)
|
||||
state_row = state.scalar_one_or_none()
|
||||
last_check = state_row.last_check_value if state_row else None
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
if last_check == today:
|
||||
logger.info("오늘 이미 체크 완료")
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for law_name in MONITORED_LAWS:
|
||||
try:
|
||||
count = await _check_law(client, law_oc, law_name, session)
|
||||
new_count += count
|
||||
except Exception as e:
|
||||
logger.error(f"[{law_name}] 체크 실패: {e}")
|
||||
|
||||
# 상태 업데이트
|
||||
if state_row:
|
||||
state_row.last_check_value = today
|
||||
state_row.last_run_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
session.add(AutomationState(
|
||||
job_name="law_monitor",
|
||||
last_check_value=today,
|
||||
last_run_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"법령 모니터 완료: {new_count}건 신규/변경 감지")
|
||||
|
||||
|
||||
async def _check_law(
|
||||
client: httpx.AsyncClient,
|
||||
law_oc: str,
|
||||
law_name: str,
|
||||
session,
|
||||
) -> int:
|
||||
"""단일 법령 검색 → 변경 감지 → 분할 저장"""
|
||||
# 법령 검색 (lawSearch.do)
|
||||
resp = await client.get(
|
||||
LAW_SEARCH_URL,
|
||||
params={"OC": law_oc, "target": "law", "type": "XML", "query": law_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.text)
|
||||
total = root.findtext(".//totalCnt", "0")
|
||||
if total == "0":
|
||||
logger.debug(f"[{law_name}] 검색 결과 없음")
|
||||
return 0
|
||||
|
||||
# 정확히 일치하는 법령 찾기
|
||||
for law_elem in root.findall(".//law"):
|
||||
found_name = law_elem.findtext("법령명한글", "").strip()
|
||||
if found_name != law_name:
|
||||
continue
|
||||
|
||||
mst = law_elem.findtext("법령일련번호", "")
|
||||
proclamation_date = law_elem.findtext("공포일자", "")
|
||||
revision_type = law_elem.findtext("제개정구분명", "")
|
||||
|
||||
if not mst:
|
||||
continue
|
||||
|
||||
# 이미 등록된 법령인지 확인 (같은 법령명 + 공포일자)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
Document.title.like(f"{law_name}%"),
|
||||
Document.source_channel == "law_monitor",
|
||||
)
|
||||
)
|
||||
existing_docs = existing.scalars().all()
|
||||
|
||||
# 같은 공포일자 이미 있으면 skip
|
||||
for doc in existing_docs:
|
||||
if proclamation_date in (doc.title or ""):
|
||||
return 0
|
||||
|
||||
# 이전 공포일 찾기 (변경 이력용)
|
||||
prev_date = ""
|
||||
if existing_docs:
|
||||
prev_date = max(
|
||||
(re.search(r'\d{8}', doc.title or "").group() for doc in existing_docs
|
||||
if re.search(r'\d{8}', doc.title or "")),
|
||||
default=""
|
||||
)
|
||||
|
||||
# 본문 조회 (lawService.do)
|
||||
text_resp = await client.get(
|
||||
LAW_SERVICE_URL,
|
||||
params={"OC": law_oc, "target": "law", "MST": mst, "type": "XML"},
|
||||
)
|
||||
text_resp.raise_for_status()
|
||||
|
||||
# 분할 저장
|
||||
count = await _save_law_split(
|
||||
session, text_resp.text, law_name, proclamation_date,
|
||||
revision_type, prev_date,
|
||||
)
|
||||
|
||||
# DB 먼저 커밋 (알림 실패가 저장을 막지 않도록)
|
||||
await session.commit()
|
||||
|
||||
# CalDAV + SMTP 알림 (실패해도 무시)
|
||||
try:
|
||||
_send_notifications(law_name, proclamation_date, revision_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{law_name}] 알림 발송 실패 (무시): {e}")
|
||||
|
||||
return count
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def _save_law_split(
|
||||
session, xml_text: str, law_name: str, proclamation_date: str,
|
||||
revision_type: str, prev_date: str,
|
||||
) -> int:
|
||||
"""법령 XML → 장(章) 단위 Markdown 분할 저장"""
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
# 조문단위에서 장 구분자 찾기 (조문키가 000으로 끝나는 조문)
|
||||
units = root.findall(".//조문단위")
|
||||
chapters = [] # [(장제목, [조문들])]
|
||||
current_chapter = None
|
||||
current_articles = []
|
||||
|
||||
for unit in units:
|
||||
key = unit.attrib.get("조문키", "")
|
||||
content = (unit.findtext("조문내용", "") or "").strip()
|
||||
|
||||
# 장 구분자: 키가 000으로 끝나고 내용에 "제X장" 포함
|
||||
if key.endswith("000") and re.search(r"제\d+장", content):
|
||||
# 이전 장/서문 저장
|
||||
if current_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, current_articles))
|
||||
chapter_match = re.search(r"(제\d+장\s*.+)", content)
|
||||
current_chapter = chapter_match.group(1).strip() if chapter_match else content.strip()
|
||||
current_articles = []
|
||||
else:
|
||||
current_articles.append(unit)
|
||||
|
||||
# 마지막 장 저장
|
||||
if current_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, current_articles))
|
||||
|
||||
# 장 분할 성공
|
||||
sections = []
|
||||
if chapters:
|
||||
for chapter_title, articles in chapters:
|
||||
md_lines = [f"# {law_name}\n", f"## {chapter_title}\n"]
|
||||
for article in articles:
|
||||
title = article.findtext("조문제목", "")
|
||||
content = article.findtext("조문내용", "")
|
||||
if title:
|
||||
md_lines.append(f"\n### {title}\n")
|
||||
if content:
|
||||
md_lines.append(content.strip())
|
||||
section_name = _safe_name(chapter_title)
|
||||
sections.append((section_name, "\n".join(md_lines)))
|
||||
else:
|
||||
# 장 분할 실패 → 전체 1파일
|
||||
full_md = _law_xml_to_markdown(xml_text, law_name)
|
||||
sections.append(("전문", full_md))
|
||||
|
||||
# 각 섹션 저장
|
||||
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for section_name, content in sections:
|
||||
filename = f"{law_name}_{proclamation_date}_{section_name}.md"
|
||||
file_path = inbox_dir / filename
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
|
||||
|
||||
# 변경 이력 메모
|
||||
note = ""
|
||||
if prev_date:
|
||||
note = (
|
||||
f"[자동] 법령 개정 감지\n"
|
||||
f"이전 공포일: {prev_date}\n"
|
||||
f"현재 공포일: {proclamation_date}\n"
|
||||
f"개정구분: {revision_type}"
|
||||
)
|
||||
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=file_hash(file_path),
|
||||
file_format="md",
|
||||
file_size=len(content.encode()),
|
||||
file_type="immutable",
|
||||
title=f"{law_name} ({proclamation_date}) {section_name}",
|
||||
source_channel="law_monitor",
|
||||
data_origin="work",
|
||||
user_note=note or None,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id, stage="extract", status="pending",
|
||||
))
|
||||
count += 1
|
||||
|
||||
logger.info(f"[법령] {law_name} ({proclamation_date}) → {count}개 섹션 저장")
|
||||
return count
|
||||
|
||||
|
||||
def _xml_section_to_markdown(elem) -> str:
|
||||
"""XML 섹션(편/장)을 Markdown으로 변환"""
|
||||
lines = []
|
||||
for article in elem.iter():
|
||||
tag = article.tag
|
||||
text = (article.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
else:
|
||||
lines.append(text)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _law_xml_to_markdown(xml_text: str, law_name: str) -> str:
|
||||
"""법령 XML 전체를 Markdown으로 변환"""
|
||||
root = ET.fromstring(xml_text)
|
||||
lines = [f"# {law_name}\n"]
|
||||
|
||||
for elem in root.iter():
|
||||
tag = elem.tag
|
||||
text = (elem.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "편" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "장" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""파일명 안전 변환"""
|
||||
return re.sub(r'[^\w가-힣-]', '_', name).strip("_")
|
||||
|
||||
|
||||
def _send_notifications(law_name: str, proclamation_date: str, revision_type: str):
|
||||
"""CalDAV + SMTP 알림"""
|
||||
# CalDAV
|
||||
caldav_url = os.getenv("CALDAV_URL", "")
|
||||
caldav_user = os.getenv("CALDAV_USER", "")
|
||||
caldav_pass = os.getenv("CALDAV_PASS", "")
|
||||
if caldav_url and caldav_user:
|
||||
create_caldav_todo(
|
||||
caldav_url, caldav_user, caldav_pass,
|
||||
title=f"법령 검토: {law_name}",
|
||||
description=f"공포일자: {proclamation_date}, 개정구분: {revision_type}",
|
||||
due_days=7,
|
||||
)
|
||||
|
||||
# SMTP
|
||||
smtp_host = os.getenv("MAILPLUS_HOST", "")
|
||||
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
|
||||
smtp_user = os.getenv("MAILPLUS_USER", "")
|
||||
smtp_pass = os.getenv("MAILPLUS_PASS", "")
|
||||
if smtp_host and smtp_user:
|
||||
send_smtp_email(
|
||||
smtp_host, smtp_port, smtp_user, smtp_pass,
|
||||
subject=f"[법령 변경] {law_name} ({revision_type})",
|
||||
body=f"법령명: {law_name}\n공포일자: {proclamation_date}\n개정구분: {revision_type}",
|
||||
)
|
||||
213
app/workers/mailplus_archive.py
Normal file
213
app/workers/mailplus_archive.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""이메일 수집 워커 — Synology MailPlus IMAP → NAS 저장 + DB 등록
|
||||
|
||||
v1 scripts/mailplus_archive.py에서 포팅.
|
||||
imaplib (동기)를 asyncio.to_thread()로 래핑.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import email
|
||||
import imaplib
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.header import decode_header
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import file_hash, send_smtp_email, setup_logger
|
||||
from models.automation import AutomationState
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("mailplus_archive")
|
||||
|
||||
# 업무 키워드 (data_origin 자동 감지)
|
||||
WORK_KEYWORDS = {"테크니컬코리아", "TK", "공장", "생산", "사내", "안전", "점검"}
|
||||
|
||||
|
||||
def _decode_mime_header(raw: str) -> str:
|
||||
"""MIME 헤더 디코딩"""
|
||||
parts = decode_header(raw)
|
||||
decoded = []
|
||||
for data, charset in parts:
|
||||
if isinstance(data, bytes):
|
||||
decoded.append(data.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded.append(data)
|
||||
return "".join(decoded)
|
||||
|
||||
|
||||
def _sanitize_filename(name: str, max_len: int = 80) -> str:
|
||||
"""파일명에 사용 불가한 문자 제거"""
|
||||
clean = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
||||
return clean[:max_len].strip()
|
||||
|
||||
|
||||
def _detect_origin(subject: str, body: str) -> str:
|
||||
"""work/external 자동 감지"""
|
||||
text = f"{subject} {body[:500]}".lower()
|
||||
for kw in WORK_KEYWORDS:
|
||||
if kw.lower() in text:
|
||||
return "work"
|
||||
return "external"
|
||||
|
||||
|
||||
def _fetch_emails_sync(host: str, port: int, user: str, password: str, last_uid: int | None):
|
||||
"""동기 IMAP 메일 가져오기 (asyncio.to_thread에서 실행)"""
|
||||
results = []
|
||||
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
|
||||
try:
|
||||
conn.login(user, password)
|
||||
conn.select("INBOX")
|
||||
|
||||
if last_uid:
|
||||
# 증분 동기화: last_uid 이후
|
||||
_, data = conn.uid("search", None, f"UID {last_uid + 1}:*")
|
||||
else:
|
||||
# 최초 실행: 최근 7일
|
||||
since = (datetime.now() - timedelta(days=7)).strftime("%d-%b-%Y")
|
||||
_, data = conn.uid("search", None, f"SINCE {since}")
|
||||
|
||||
uids = data[0].split()
|
||||
for uid_bytes in uids:
|
||||
uid = int(uid_bytes)
|
||||
_, msg_data = conn.uid("fetch", uid_bytes, "(RFC822)")
|
||||
if msg_data[0] is None:
|
||||
continue
|
||||
raw = msg_data[0][1]
|
||||
results.append((uid, raw))
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def run():
|
||||
"""이메일 수집 실행"""
|
||||
host = os.getenv("MAILPLUS_HOST", "")
|
||||
port = int(os.getenv("MAILPLUS_PORT", "993"))
|
||||
user = os.getenv("MAILPLUS_USER", "")
|
||||
password = os.getenv("MAILPLUS_PASS", "")
|
||||
|
||||
if not all([host, user, password]):
|
||||
logger.warning("MailPlus 인증 정보 미설정")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
# 마지막 UID 조회
|
||||
state = await session.execute(
|
||||
select(AutomationState).where(AutomationState.job_name == "mailplus")
|
||||
)
|
||||
state_row = state.scalar_one_or_none()
|
||||
last_uid = int(state_row.last_check_value) if state_row and state_row.last_check_value else None
|
||||
|
||||
# IMAP 동기 호출을 비동기로 래핑
|
||||
try:
|
||||
emails = await asyncio.to_thread(
|
||||
_fetch_emails_sync, host, port, user, password, last_uid,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP 연결 실패: {e}")
|
||||
return
|
||||
|
||||
if not emails:
|
||||
logger.info("새 이메일 없음")
|
||||
return
|
||||
|
||||
# 이메일 저장 디렉토리
|
||||
email_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "emails"
|
||||
email_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
max_uid = last_uid or 0
|
||||
archived = []
|
||||
|
||||
for uid, raw_bytes in emails:
|
||||
try:
|
||||
msg = email.message_from_bytes(raw_bytes)
|
||||
subject = _decode_mime_header(msg.get("Subject", "제목없음"))
|
||||
date_str = msg.get("Date", "")
|
||||
date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# .eml 파일 저장
|
||||
safe_subject = _sanitize_filename(subject)
|
||||
filename = f"{date}_{uid}_{safe_subject}.eml"
|
||||
eml_path = email_dir / filename
|
||||
eml_path.write_bytes(raw_bytes)
|
||||
|
||||
# 본문 추출 (텍스트 파트)
|
||||
body = ""
|
||||
charset = None
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
body = payload.decode(charset, errors="replace")
|
||||
break
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload is not None:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
body = payload.decode(charset, errors="replace")
|
||||
|
||||
if "\ufffd" in body[:1000]:
|
||||
logger.debug(f"[메일] charset={charset or 'unknown'} 디코딩 중 replacement 발생")
|
||||
|
||||
# DB 등록
|
||||
rel_path = str(eml_path.relative_to(Path(settings.nas_mount_path)))
|
||||
origin = _detect_origin(subject, body)
|
||||
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=file_hash(eml_path),
|
||||
file_format="eml",
|
||||
file_size=len(raw_bytes),
|
||||
file_type="immutable",
|
||||
title=subject,
|
||||
source_channel="email",
|
||||
data_origin=origin,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
safe_subj = subject.replace("\n", " ").replace("\r", " ")[:200]
|
||||
|
||||
# TODO: extract_worker가 eml 본문/첨부 파싱 지원 시 이 조건 제거
|
||||
if doc.file_format != "eml":
|
||||
session.add(ProcessingQueue(
|
||||
document_id=doc.id, stage="extract", status="pending",
|
||||
))
|
||||
else:
|
||||
logger.debug(f"[메일] {safe_subj} — eml extract 미지원, 큐 스킵")
|
||||
|
||||
archived.append(safe_subj)
|
||||
max_uid = max(max_uid, uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"UID {uid} 처리 실패: {e}")
|
||||
|
||||
# 상태 업데이트
|
||||
if state_row:
|
||||
state_row.last_check_value = str(max_uid)
|
||||
state_row.last_run_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
session.add(AutomationState(
|
||||
job_name="mailplus",
|
||||
last_check_value=str(max_uid),
|
||||
last_run_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
|
||||
# SMTP 알림
|
||||
smtp_host = os.getenv("MAILPLUS_HOST", "")
|
||||
smtp_port = int(os.getenv("MAILPLUS_SMTP_PORT", "465"))
|
||||
if archived and smtp_host:
|
||||
body = f"이메일 {len(archived)}건 수집 완료:\n\n" + "\n".join(f"- {s}" for s in archived)
|
||||
send_smtp_email(smtp_host, smtp_port, user, password, "PKM 이메일 수집 알림", body)
|
||||
|
||||
logger.info(f"이메일 {len(archived)}건 수집 완료 (max_uid={max_uid})")
|
||||
255
app/workers/news_collector.py
Normal file
255
app/workers/news_collector.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""뉴스 수집 워커 — RSS/API에서 기사 수집, documents에 저장"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import ProcessingQueue
|
||||
|
||||
logger = setup_logger("news_collector")
|
||||
|
||||
# 카테고리 표준화 매핑
|
||||
CATEGORY_MAP = {
|
||||
# 한국어
|
||||
"국제": "International", "정치": "Politics", "경제": "Economy",
|
||||
"사회": "Society", "문화": "Culture", "산업": "Industry",
|
||||
"환경": "Environment", "기술": "Technology",
|
||||
# 영어
|
||||
"World": "International", "International": "International",
|
||||
"Technology": "Technology", "Tech": "Technology", "Sci-Tech": "Technology",
|
||||
"Arts": "Culture", "Culture": "Culture",
|
||||
"Climate": "Environment", "Environment": "Environment",
|
||||
# 일본어
|
||||
"国際": "International", "文化": "Culture", "科学": "Technology",
|
||||
# 독일어
|
||||
"Kultur": "Culture", "Wissenschaft": "Technology",
|
||||
# 프랑스어
|
||||
"Environnement": "Environment",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_category(raw: str) -> str:
|
||||
"""카테고리 표준화"""
|
||||
return CATEGORY_MAP.get(raw, CATEGORY_MAP.get(raw.strip(), "Other"))
|
||||
|
||||
|
||||
def _clean_html(text: str) -> str:
|
||||
"""HTML 태그 제거 + 정제"""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = unescape(text)
|
||||
return text.strip()[:1000]
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""URL 정규화 (tracking params 제거)"""
|
||||
parsed = urlparse(url)
|
||||
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", ""))
|
||||
|
||||
|
||||
def _article_hash(title: str, published: str, source_name: str) -> str:
|
||||
"""기사 고유 해시 (중복 체크용)"""
|
||||
key = f"{title}|{published}|{source_name}"
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def _normalize_to_utc(dt) -> datetime:
|
||||
"""다양한 시간 형식을 UTC로 정규화"""
|
||||
if isinstance(dt, datetime):
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def run():
|
||||
"""뉴스 수집 실행"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.enabled == True)
|
||||
)
|
||||
sources = result.scalars().all()
|
||||
|
||||
if not sources:
|
||||
logger.info("활성화된 뉴스 소스 없음")
|
||||
return
|
||||
|
||||
total = 0
|
||||
for source in sources:
|
||||
try:
|
||||
if source.feed_type == "api":
|
||||
count = await _fetch_api(session, source)
|
||||
else:
|
||||
count = await _fetch_rss(session, source)
|
||||
|
||||
source.last_fetched_at = datetime.now(timezone.utc)
|
||||
total += count
|
||||
except Exception as e:
|
||||
logger.error(f"[{source.name}] 수집 실패: {e}")
|
||||
source.last_fetched_at = datetime.now(timezone.utc)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"뉴스 수집 완료: {total}건 신규")
|
||||
|
||||
|
||||
async def _fetch_rss(session, source: NewsSource) -> int:
|
||||
"""RSS 피드 수집"""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(source.feed_url)
|
||||
resp.raise_for_status()
|
||||
|
||||
feed = feedparser.parse(resp.text)
|
||||
count = 0
|
||||
|
||||
for entry in feed.entries:
|
||||
title = entry.get("title", "").strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
summary = _clean_html(entry.get("summary", "") or entry.get("description", ""))
|
||||
if not summary:
|
||||
summary = title
|
||||
|
||||
link = entry.get("link", "")
|
||||
published = entry.get("published_parsed") or entry.get("updated_parsed")
|
||||
pub_dt = datetime(*published[:6], tzinfo=timezone.utc) if published else datetime.now(timezone.utc)
|
||||
|
||||
# 중복 체크
|
||||
article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name)
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url == normalized_url)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
category = _normalize_category(source.category or "")
|
||||
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
|
||||
|
||||
doc = Document(
|
||||
file_path=f"news/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(summary.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{summary}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="rss",
|
||||
source_channel="news",
|
||||
data_origin="external",
|
||||
edit_url=link,
|
||||
review_status="approved",
|
||||
ai_domain="News",
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=[f"News/{source_short}/{category}"],
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# summarize + embed 등록 (classify 불필요)
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="summarize", status="pending"))
|
||||
days_old = (datetime.now(timezone.utc) - pub_dt).days
|
||||
if days_old <= 30:
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))
|
||||
|
||||
count += 1
|
||||
|
||||
logger.info(f"[{source.name}] RSS → {count}건 수집")
|
||||
return count
|
||||
|
||||
|
||||
async def _fetch_api(session, source: NewsSource) -> int:
|
||||
"""NYT API 수집"""
|
||||
import os
|
||||
nyt_key = os.getenv("NYT_API_KEY", "")
|
||||
if not nyt_key:
|
||||
logger.warning("NYT_API_KEY 미설정")
|
||||
return 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"https://api.nytimes.com/svc/topstories/v2/{source.category or 'world'}.json",
|
||||
params={"api-key": nyt_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
count = 0
|
||||
|
||||
for article in data.get("results", []):
|
||||
title = article.get("title", "").strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
summary = _clean_html(article.get("abstract", ""))
|
||||
if not summary:
|
||||
summary = title
|
||||
|
||||
link = article.get("url", "")
|
||||
pub_str = article.get("published_date", "")
|
||||
try:
|
||||
pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
pub_dt = datetime.now(timezone.utc)
|
||||
|
||||
article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name)
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url == normalized_url)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
category = _normalize_category(article.get("section", source.category or ""))
|
||||
source_short = source.name.split(" ")[0]
|
||||
|
||||
doc = Document(
|
||||
file_path=f"news/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(summary.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{summary}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="nyt_api",
|
||||
source_channel="news",
|
||||
data_origin="external",
|
||||
edit_url=link,
|
||||
review_status="approved",
|
||||
ai_domain="News",
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=[f"News/{source_short}/{category}"],
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="summarize", status="pending"))
|
||||
days_old = (datetime.now(timezone.utc) - pub_dt).days
|
||||
if days_old <= 30:
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))
|
||||
|
||||
count += 1
|
||||
|
||||
logger.info(f"[{source.name}] API → {count}건 수집")
|
||||
return count
|
||||
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}")
|
||||
144
app/workers/queue_consumer.py
Normal file
144
app/workers/queue_consumer.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""처리 큐 소비자 — 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, "summarize": 3, "embed": 1, "chunk": 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", "chunk"]}
|
||||
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.chunk_worker import process as chunk_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
|
||||
from workers.summarize_worker import process as summarize_process
|
||||
|
||||
workers = {
|
||||
"extract": extract_process,
|
||||
"classify": classify_process,
|
||||
"summarize": summarize_process,
|
||||
"embed": embed_process,
|
||||
"chunk": chunk_process,
|
||||
"preview": preview_process,
|
||||
}
|
||||
|
||||
await reset_stale_items()
|
||||
|
||||
for stage, worker_fn in workers.items():
|
||||
batch_size = BATCH_SIZE.get(stage, 3)
|
||||
|
||||
# pending 항목 조회
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(ProcessingQueue.id, ProcessingQueue.document_id)
|
||||
.where(
|
||||
ProcessingQueue.stage == stage,
|
||||
ProcessingQueue.status == "pending",
|
||||
)
|
||||
.order_by(ProcessingQueue.created_at)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pending_items = result.all()
|
||||
|
||||
# 각 항목을 독립 세션에서 처리
|
||||
for queue_id, document_id in pending_items:
|
||||
# 상태를 processing으로 변경
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if not item or item.status != "pending":
|
||||
continue
|
||||
item.status = "processing"
|
||||
item.started_at = datetime.now(timezone.utc)
|
||||
item.attempts += 1
|
||||
await session.commit()
|
||||
|
||||
# 워커 실행 (독립 세션)
|
||||
try:
|
||||
async with async_session() as worker_session:
|
||||
await worker_fn(document_id, worker_session)
|
||||
await worker_session.commit()
|
||||
|
||||
# 완료 처리
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
logger.info(f"[{stage}] document_id={document_id} 완료")
|
||||
|
||||
except Exception as e:
|
||||
# 실패 처리
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
item.error_message = str(e)[:500]
|
||||
if item.attempts >= item.max_attempts:
|
||||
item.status = "failed"
|
||||
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
|
||||
else:
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
|
||||
await session.commit()
|
||||
35
app/workers/summarize_worker.py
Normal file
35
app/workers/summarize_worker.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""요약 전용 워커 — 뉴스 등 classify 불필요한 문서의 AI 요약만 생성"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, strip_thinking
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
|
||||
logger = setup_logger("summarize_worker")
|
||||
|
||||
|
||||
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가 비어있음")
|
||||
|
||||
if doc.ai_summary:
|
||||
logger.info(f"[요약] document_id={document_id}: 이미 요약 있음, skip")
|
||||
return
|
||||
|
||||
client = AIClient()
|
||||
try:
|
||||
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}: {len(doc.ai_summary)}자")
|
||||
finally:
|
||||
await client.close()
|
||||
76
config.yaml
76
config.yaml
@@ -2,18 +2,18 @@
|
||||
|
||||
ai:
|
||||
gateway:
|
||||
endpoint: "http://gpu-server:8080"
|
||||
endpoint: "http://ai-gateway:8080"
|
||||
|
||||
models:
|
||||
primary:
|
||||
endpoint: "http://host.docker.internal:8800/v1/chat/completions"
|
||||
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://gpu-server:11434/v1/chat/completions"
|
||||
model: "qwen3.5:35b-a3b"
|
||||
endpoint: "http://ollama:11434/v1/chat/completions"
|
||||
model: "qwen3.5:9b-q8_0"
|
||||
max_tokens: 4096
|
||||
timeout: 120
|
||||
|
||||
@@ -25,21 +25,81 @@ ai:
|
||||
require_explicit_trigger: true
|
||||
|
||||
embedding:
|
||||
endpoint: "http://gpu-server:11434/api/embeddings"
|
||||
model: "nomic-embed-text"
|
||||
endpoint: "http://ollama:11434/api/embeddings"
|
||||
model: "bge-m3"
|
||||
|
||||
vision:
|
||||
endpoint: "http://gpu-server:11434/api/generate"
|
||||
endpoint: "http://ollama:11434/api/generate"
|
||||
model: "Qwen2.5-VL-7B"
|
||||
|
||||
rerank:
|
||||
endpoint: "http://gpu-server:11434/api/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"]
|
||||
|
||||
@@ -10,22 +10,18 @@ POSTGRES_DB=pkm
|
||||
POSTGRES_USER=pkm
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# ─── AI: Mac mini MLX (Qwen3.5, 기본 모델) ───
|
||||
MLX_ENDPOINT=http://localhost:8800/v1/chat/completions
|
||||
# ─── 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: GPU 서버 ───
|
||||
GPU_SERVER_IP=
|
||||
GPU_EMBED_PORT=11434
|
||||
|
||||
# ─── AI: Claude API (종량제, 복잡한 분석 전용) ───
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
# ─── AI Gateway (GPU 서버) ───
|
||||
AI_GATEWAY_ENDPOINT=http://gpu-server:8080
|
||||
# ─── AI Gateway (같은 Docker 네트워크) ───
|
||||
AI_GATEWAY_ENDPOINT=http://ai-gateway:8080
|
||||
|
||||
# ─── Synology NAS ───
|
||||
NAS_SMB_PATH=/Volumes/Document_Server
|
||||
# ─── NAS (NFS 마운트) ───
|
||||
NAS_NFS_PATH=/mnt/nas/Document_Server
|
||||
NAS_DOMAIN=ds1525.hyungi.net
|
||||
NAS_TAILSCALE_IP=100.101.79.37
|
||||
NAS_PORT=15001
|
||||
@@ -51,7 +47,3 @@ TOTP_SECRET=
|
||||
|
||||
# ─── 국가법령정보센터 (법령 모니터링) ───
|
||||
LAW_OC=
|
||||
|
||||
# ─── TKSafety API (나중에 활성화) ───
|
||||
#TKSAFETY_HOST=tksafety.technicalkorea.net
|
||||
#TKSAFETY_PORT=
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
@@ -11,7 +9,7 @@ services:
|
||||
POSTGRES_USER: pkm
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pkm"]
|
||||
interval: 5s
|
||||
@@ -24,20 +22,73 @@ services:
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ${NAS_SMB_PATH:-/Volumes/Document_Server}:/documents:ro
|
||||
- ${NAS_NFS_PATH:-/mnt/nas/Document_Server}:/documents:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/health"]
|
||||
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
|
||||
|
||||
# Phase 1.3: bge-reranker-v2-m3 (TEI) — internal only, fastapi에서 reranker:80으로 호출
|
||||
# fastapi가 depends_on 안 함 → 단독 시작 가능, 없어도 fastapi 동작 (rerank=false fallback)
|
||||
reranker:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
container_name: hyungi_document_server-reranker-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=BAAI/bge-reranker-v2-m3
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- reranker_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
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:8801/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_SMB_PATH:-/Volumes/Document_Server}:/documents
|
||||
- ${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
|
||||
@@ -61,8 +112,7 @@ services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
@@ -74,3 +124,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
ollama_data:
|
||||
reranker_cache:
|
||||
|
||||
@@ -542,7 +542,7 @@ POST /to-hwpx
|
||||
### Caddy 설정 예시
|
||||
|
||||
```
|
||||
pkm.hyungi.net {
|
||||
document.hyungi.net {
|
||||
reverse_proxy localhost:8000 # FastAPI
|
||||
}
|
||||
|
||||
@@ -931,7 +931,7 @@ pkm-web/
|
||||
│ └── migrate_from_devonthink.py ← v1 → v2 마이그레이션 스크립트
|
||||
│
|
||||
├── docs/
|
||||
│ ├── architecture-v2.md ← 이 문서
|
||||
│ ├── architecture.md ← 이 문서
|
||||
│ └── deploy.md ← 배포 가이드
|
||||
│
|
||||
└── tests/
|
||||
|
||||
@@ -64,11 +64,11 @@ curl http://localhost:3100/health
|
||||
|
||||
### 2-6. 외부 접근 (Caddy)
|
||||
|
||||
Caddy가 자동으로 HTTPS 인증서를 발급한다.
|
||||
- `pkm.hyungi.net` → FastAPI (:8000)
|
||||
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를 가리켜야 한다.
|
||||
DNS 레코드가 Mac mini의 공인 IP를 가리켜야 한다. Caddy는 `auto_https off` 설정.
|
||||
|
||||
## 3. GPU 서버 배포
|
||||
|
||||
|
||||
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로 이관)
|
||||
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 설정에는 불필요
|
||||
2245
frontend/package-lock.json
generated
Normal file
2245
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,24 @@
|
||||
"name": "hyungi-document-server-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint:tokens": "bash ./scripts/check-tokens.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^2.0.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"svelte": "^4.0.0",
|
||||
"vite": "^5.0.0"
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-svelte": "^0.400.0",
|
||||
"marked": "^15.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
66
frontend/scripts/check-tokens.sh
Executable file
66
frontend/scripts/check-tokens.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tailwind 임의값 토큰 우회 차단 — plan 5대 원칙 #1.
|
||||
#
|
||||
# 새 코드에서 bg-[var(--*)] / text-[var(--*)] / border-[var(--*)] 등을 grep으로 차단.
|
||||
# @theme 토큰을 우회해 임의값을 작성하면 이 스크립트가 fail.
|
||||
#
|
||||
# 예외:
|
||||
# - frontend/src/lib/components/ui/ (프리미티브 정의 자체는 토큰 매핑이 필요할 수 있음)
|
||||
# - frontend/src/app.css (@theme/:root 선언이므로 grep 대상 아님)
|
||||
#
|
||||
# 사용:
|
||||
# npm run -C frontend lint:tokens
|
||||
#
|
||||
# 향후 pre-commit hook에 포함:
|
||||
# .git/hooks/pre-commit → npm run -C frontend lint:tokens
|
||||
|
||||
set -e
|
||||
|
||||
# 스크립트 위치 기준으로 frontend 루트로 이동
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
PATTERNS=(
|
||||
'bg-\[var\(--'
|
||||
'text-\[var\(--'
|
||||
'border-\[var\(--'
|
||||
'ring-\[var\(--'
|
||||
'fill-\[var\(--'
|
||||
'stroke-\[var\(--'
|
||||
)
|
||||
|
||||
EXIT=0
|
||||
TOTAL=0
|
||||
|
||||
for p in "${PATTERNS[@]}"; do
|
||||
HITS=$(grep -rEn \
|
||||
--include='*.svelte' \
|
||||
--include='*.ts' \
|
||||
--include='*.js' \
|
||||
--exclude-dir=node_modules \
|
||||
--exclude-dir=.svelte-kit \
|
||||
--exclude-dir=ui \
|
||||
"$p" src 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
COUNT=$(echo "$HITS" | wc -l | tr -d ' ')
|
||||
TOTAL=$((TOTAL + COUNT))
|
||||
echo ""
|
||||
echo "❌ 금지 패턴 발견: $p ($COUNT 건)"
|
||||
echo "$HITS" | head -20
|
||||
if [ "$COUNT" -gt 20 ]; then
|
||||
echo "... (이하 $((COUNT - 20)) 건 생략)"
|
||||
fi
|
||||
EXIT=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $EXIT -eq 0 ]; then
|
||||
echo "✅ 토큰 우회 패턴 없음."
|
||||
else
|
||||
echo ""
|
||||
echo "총 $TOTAL 건의 토큰 우회 패턴이 발견되었습니다."
|
||||
echo "→ @theme 유틸리티 (bg-surface, text-dim, border-default 등)로 교체하세요."
|
||||
fi
|
||||
|
||||
exit $EXIT
|
||||
133
frontend/src/app.css
Normal file
133
frontend/src/app.css
Normal file
@@ -0,0 +1,133 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Tailwind v4 theme tokens — 모든 color/radius/z/spacing은 여기서 노출 */
|
||||
/* 이후 컴포넌트는 bg-surface, text-dim, border-default, bg-success/10, */
|
||||
/* bg-domain-engineering, rounded-card, z-modal, w-rail 형태로 작성한다. */
|
||||
/* 새 코드에서 bg-[var(--*)] 작성 금지 (lint:tokens 으로 차단). */
|
||||
@theme {
|
||||
--color-bg: #0f1117;
|
||||
--color-surface: #1a1d27;
|
||||
--color-surface-hover: #222636;
|
||||
--color-surface-active: #2a2f42;
|
||||
--color-sidebar: #141720;
|
||||
--color-default: #2a2d3a;
|
||||
--color-border-strong: #3a3e52;
|
||||
--color-text: #e4e4e7;
|
||||
--color-dim: #8b8d98;
|
||||
--color-faint: #5e616c;
|
||||
--color-accent: #6c8aff;
|
||||
--color-accent-hover: #859dff;
|
||||
--color-accent-ring: #6c8aff80;
|
||||
--color-error: #f5564e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #fbbf24;
|
||||
--color-scrim: #00000099;
|
||||
|
||||
--color-domain-philosophy: #a78bfa;
|
||||
--color-domain-language: #f472b6;
|
||||
--color-domain-engineering: #38bdf8;
|
||||
--color-domain-safety: #fb923c;
|
||||
--color-domain-programming: #34d399;
|
||||
--color-domain-general: #94a3b8;
|
||||
--color-domain-reference: #fbbf24;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-card: 12px;
|
||||
|
||||
--z-dropdown: 30;
|
||||
--z-drawer: 40;
|
||||
--z-modal: 50;
|
||||
--z-toast: 60;
|
||||
|
||||
--spacing-sidebar: 320px;
|
||||
--spacing-rail: 320px;
|
||||
}
|
||||
|
||||
/* Tailwind v4 는 --z-* 를 utility namespace 로 인식하지 않으므로
|
||||
@utility 로 명시적으로 등록해야 z-drawer / z-dropdown / z-toast 가
|
||||
실제 클래스로 생성된다. --z-modal 은 Modal.svelte 가 inline style 로
|
||||
var() 참조해서 이미 보존되고 있지만, z-modal 클래스도 일관성 차원에서
|
||||
함께 등록한다. var() 참조 덕분에 Tailwind v4 가 --z-* 변수도 tree-shaking
|
||||
에서 제외하고 :root 에 emit 한다. */
|
||||
@utility z-dropdown {
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
@utility z-drawer {
|
||||
z-index: var(--z-drawer);
|
||||
}
|
||||
@utility z-modal {
|
||||
z-index: var(--z-modal);
|
||||
}
|
||||
@utility z-toast {
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
/* 기존 :root 변수는 .markdown-body와의 호환을 위해 유지 (공존 layer). */
|
||||
/* 후속 phase에서 markdown-body도 토큰 마이그레이션 검토 가능. */
|
||||
: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; }
|
||||
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();
|
||||
}
|
||||
157
frontend/src/lib/components/DocumentCard.svelte
Normal file
157
frontend/src/lib/components/DocumentCard.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
import TagPill from './TagPill.svelte';
|
||||
|
||||
let {
|
||||
doc,
|
||||
showDomain = true,
|
||||
selected = false,
|
||||
onselect = null,
|
||||
// D.3 다중 선택
|
||||
selectable = false,
|
||||
selectedIds = new Set(),
|
||||
onselectionchange = null,
|
||||
} = $props();
|
||||
|
||||
let isChecked = $derived(selectedIds.has(doc.id));
|
||||
|
||||
function toggleSelection(e) {
|
||||
e?.stopPropagation?.();
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(doc.id)) next.delete(doc.id);
|
||||
else next.add(doc.id);
|
||||
onselectionchange?.(next);
|
||||
}
|
||||
|
||||
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)');
|
||||
|
||||
// 반응형: CSS media query matchMedia 사용
|
||||
let isDesktop = $state(typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.matchMedia('(min-width: 1024px)').addEventListener('change', (e) => isDesktop = e.matches);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!isDesktop) {
|
||||
goto(`/documents/${doc.id}`);
|
||||
return;
|
||||
}
|
||||
if (onselect) {
|
||||
onselect(doc);
|
||||
} else {
|
||||
goto(`/documents/${doc.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex items-stretch bg-surface border rounded-lg hover:border-accent transition-colors group overflow-hidden
|
||||
{selected ? 'border-accent bg-accent/5' : 'border-default'}
|
||||
{isChecked ? 'border-accent bg-accent/10' : ''}"
|
||||
>
|
||||
{#if selectable}
|
||||
<span
|
||||
class="absolute top-2 left-2 z-10 flex items-center justify-center transition-opacity
|
||||
{isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onchange={toggleSelection}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="h-4 w-4 accent-accent cursor-pointer"
|
||||
aria-label="{doc.title || '문서'} 선택"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
aria-label={doc.title || '문서 선택'}
|
||||
class="flex items-stretch w-full text-left"
|
||||
>
|
||||
<!-- 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 {selectable ? 'pl-8' : ''}">
|
||||
<!-- 포맷 아이콘 -->
|
||||
<div class="shrink-0 mt-0.5 text-dim group-hover:text-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-accent">
|
||||
{doc.title || '제목 없음'}
|
||||
</p>
|
||||
{#if doc.ai_summary}
|
||||
<p class="text-xs text-dim truncate mt-0.5">{doc.ai_summary.replace(/[*#_`~]/g, '').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-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.source_channel === 'news' && doc.edit_url}
|
||||
<span class="text-blue-400">📰</span>
|
||||
{/if}
|
||||
{#if doc.score !== undefined}
|
||||
<span class="text-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-dim">{formatDate(doc.created_at)}</span>
|
||||
{#if doc.file_size}
|
||||
<span class="text-dim">{formatSize(doc.file_size)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
27
frontend/src/lib/components/DocumentMetaRail.svelte
Normal file
27
frontend/src/lib/components/DocumentMetaRail.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
// Phase D.1 신규 — 얇은 wrapper.
|
||||
// - PreviewPanel을 그대로 import해서 rail/drawer 양쪽 컨텍스트에서 재사용.
|
||||
// - Phase E에서 editors/* 로 분할될 때 이 wrapper 자체는 유지되고
|
||||
// 내부만 <NoteEditor/>, <TagsEditor/>... 조합으로 교체된다.
|
||||
// - rail 모드(xl+ inline)와 drawer 모드(< xl)에서 똑같이 사용되며,
|
||||
// onclose 콜백만 부모가 다르게 준다:
|
||||
// rail → metaRailOpen = false + localStorage 저장
|
||||
// drawer → ui.closeDrawer()
|
||||
//
|
||||
// PreviewPanel 은 자기 <aside> 내부에 bg-sidebar/border-l 스타일을 이미
|
||||
// 갖고 있으므로, 여기서는 최소 <div> flex wrapper만 씌운다. border 중복
|
||||
// 방지.
|
||||
import PreviewPanel from './PreviewPanel.svelte';
|
||||
|
||||
interface Props {
|
||||
doc: unknown;
|
||||
onclose: () => void;
|
||||
ondelete?: () => void;
|
||||
}
|
||||
|
||||
let { doc, onclose, ondelete = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full flex flex-col">
|
||||
<PreviewPanel {doc} {onclose} {ondelete} />
|
||||
</div>
|
||||
190
frontend/src/lib/components/DocumentTable.svelte
Normal file
190
frontend/src/lib/components/DocumentTable.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
|
||||
let {
|
||||
items = [],
|
||||
selectedId = null,
|
||||
onselect = null,
|
||||
// D.3 다중 선택
|
||||
selectable = false,
|
||||
selectedIds = new Set(),
|
||||
onselectionchange = null,
|
||||
// D.4 키보드 네비게이션 커서
|
||||
kbSelectedId = null,
|
||||
// D.5 행 밀도
|
||||
density = 'comfortable', // 'compact' | 'comfortable'
|
||||
} = $props();
|
||||
|
||||
let rowPaddingClass = $derived(density === 'compact' ? 'py-1' : 'py-2.5');
|
||||
let rowTextClass = $derived(density === 'compact' ? 'text-[10px]' : 'text-xs');
|
||||
|
||||
function toggleSelection(id, e) {
|
||||
e?.stopPropagation?.();
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
onselectionchange?.(next);
|
||||
}
|
||||
let sortKey = $state('created_at');
|
||||
let sortOrder = $state('desc');
|
||||
|
||||
// localStorage에서 정렬 상태 복원
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('tableSort');
|
||||
if (saved) {
|
||||
try {
|
||||
const { key, order } = JSON.parse(saved);
|
||||
sortKey = key;
|
||||
sortOrder = order;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(key) {
|
||||
if (sortKey === key) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortOrder = 'asc';
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('tableSort', JSON.stringify({ key: sortKey, order: sortOrder }));
|
||||
}
|
||||
}
|
||||
|
||||
// stable sort
|
||||
let sortedItems = $derived(() => {
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
let va = a[sortKey] ?? '';
|
||||
let vb = b[sortKey] ?? '';
|
||||
if (typeof va === 'string') va = va.toLowerCase();
|
||||
if (typeof vb === 'string') vb = vb.toLowerCase();
|
||||
if (va === vb) return a.id - b.id;
|
||||
if (sortOrder === 'asc') return va > vb ? 1 : -1;
|
||||
return va < vb ? 1 : -1;
|
||||
});
|
||||
return arr;
|
||||
});
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
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`;
|
||||
}
|
||||
|
||||
function handleClick(doc) {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||
goto(`/documents/${doc.id}`);
|
||||
return;
|
||||
}
|
||||
if (onselect) onselect(doc);
|
||||
else goto(`/documents/${doc.id}`);
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
|
||||
function getDomainColor(domain) {
|
||||
if (!domain) return 'var(--border)';
|
||||
const top = domain.split('/')[0];
|
||||
return DOMAIN_COLORS[top] || 'var(--border)';
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: '이름', flex: 'flex-1' },
|
||||
{ key: 'ai_domain', label: '분류', width: 'w-48' },
|
||||
{ key: 'document_type', label: '타입', width: 'w-24' },
|
||||
{ key: 'file_size', label: '크기', width: 'w-20' },
|
||||
{ key: 'created_at', label: '등록일', width: 'w-20' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-default text-[10px] text-dim uppercase tracking-wider">
|
||||
{#if selectable}
|
||||
<div class="w-6 shrink-0" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{#each columns as col}
|
||||
<button
|
||||
onclick={() => toggleSort(col.key)}
|
||||
class="flex items-center gap-1 {col.flex || col.width || ''} px-1 hover:text-text transition-colors text-left"
|
||||
>
|
||||
{col.label}
|
||||
{#if sortKey === col.key}
|
||||
<span class="text-accent">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 행 -->
|
||||
{#each sortedItems() as doc}
|
||||
{@const isChecked = selectedIds.has(doc.id)}
|
||||
{@const isKbCursor = doc.id === kbSelectedId}
|
||||
<div
|
||||
data-kb-selected={isKbCursor}
|
||||
class="flex items-center gap-1 px-2 {rowPaddingClass} w-full border-b border-default/30 hover:bg-surface transition-colors group
|
||||
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}
|
||||
{isChecked ? 'bg-accent/10' : ''}
|
||||
{isKbCursor ? 'ring-1 ring-accent-ring ring-inset' : ''}"
|
||||
>
|
||||
{#if selectable}
|
||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onchange={(e) => toggleSelection(doc.id, e)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="h-3.5 w-3.5 accent-accent cursor-pointer"
|
||||
aria-label="{doc.title || '문서'} 선택"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleClick(doc)}
|
||||
class="flex-1 flex items-center gap-1 text-left min-w-0"
|
||||
>
|
||||
<!-- 이름 -->
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="w-1 h-4 rounded-full shrink-0" style="background: {getDomainColor(doc.ai_domain)}"></span>
|
||||
<FormatIcon format={doc.file_format} size={14} />
|
||||
<span class="{rowTextClass} truncate group-hover:text-accent">{doc.title || '제목 없음'}</span>
|
||||
</div>
|
||||
<!-- 분류 -->
|
||||
<div class="w-48 text-[10px] text-dim truncate">
|
||||
{doc.ai_domain?.replace('Industrial_Safety/', 'IS/') || '-'}
|
||||
</div>
|
||||
<!-- 타입 -->
|
||||
<div class="w-24 text-[10px] text-dim">
|
||||
{doc.document_type || doc.file_format?.toUpperCase() || '-'}
|
||||
</div>
|
||||
<!-- 크기 -->
|
||||
<div class="w-20 text-[10px] text-dim text-right">
|
||||
{formatSize(doc.file_size)}
|
||||
</div>
|
||||
<!-- 등록일 -->
|
||||
<div class="w-20 text-[10px] text-dim text-right">
|
||||
{formatDate(doc.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
273
frontend/src/lib/components/DocumentViewer.svelte
Normal file
273
frontend/src/lib/components/DocumentViewer.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script>
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
|
||||
// marked + sanitize
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let { doc } = $props();
|
||||
let fullDoc = $state(null);
|
||||
let loading = $state(true);
|
||||
let viewerType = $state('none');
|
||||
|
||||
// Markdown 편집
|
||||
let editMode = $state(false);
|
||||
let editContent = $state('');
|
||||
let editTab = $state('edit');
|
||||
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 = fullDoc.source_channel === 'news' ? 'article' : 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';
|
||||
}
|
||||
|
||||
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-surface border-t border-default">
|
||||
<!-- 뷰어 툴바 -->
|
||||
{#if fullDoc && !loading}
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-default bg-sidebar shrink-0">
|
||||
<span class="text-xs 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-accent text-white rounded hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Save size={12} /> {saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editMode = false}
|
||||
class="px-2 py-1 text-xs text-dim hover:text-text"
|
||||
>취소</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default 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-dim hover:text-accent border border-default rounded"
|
||||
>
|
||||
<ExternalLink size={12} /> {editInfo.label}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/{fullDoc.id}"
|
||||
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default 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-dim">로딩 중...</p>
|
||||
</div>
|
||||
{:else if fullDoc}
|
||||
{#if viewerType === 'markdown'}
|
||||
{#if editMode}
|
||||
<!-- Markdown 편집 (Tabs 프리미티브 — E.4) -->
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'edit', label: '편집' },
|
||||
{ id: 'preview', label: '미리보기' },
|
||||
]}
|
||||
bind:value={editTab}
|
||||
class="flex flex-col h-full"
|
||||
>
|
||||
{#snippet children(activeId)}
|
||||
{#if activeId === 'edit'}
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
|
||||
spellcheck="false"
|
||||
aria-label="마크다운 편집"
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-auto p-4 markdown-body">
|
||||
{@html renderMd(editContent)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 markdown-body">
|
||||
{@html renderMd(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-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-dim">CAD 미리보기 (향후 지원 예정)</p>
|
||||
<a
|
||||
href="https://web.autocad.com"
|
||||
target="_blank"
|
||||
class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover"
|
||||
>AutoCAD Web에서 열기</a>
|
||||
</div>
|
||||
{:else if viewerType === 'article'}
|
||||
<!-- 뉴스 전용 뷰어 -->
|
||||
<div class="p-5 max-w-3xl mx-auto">
|
||||
<h1 class="text-lg font-bold mb-2">{fullDoc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
{#if fullDoc.ai_tags?.length}
|
||||
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
|
||||
<span class="px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{tag.replace('News/', '')}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div class="markdown-body mb-6">
|
||||
{@html renderMd(fullDoc.extracted_text || '')}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-default">
|
||||
{#if fullDoc.edit_url}
|
||||
<a
|
||||
href={fullDoc.edit_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover"
|
||||
>
|
||||
<ExternalLink size={14} /> 원문 보기
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
41
frontend/src/lib/components/FormatIcon.svelte
Normal file
41
frontend/src/lib/components/FormatIcon.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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,
|
||||
docx: FileText,
|
||||
doc: FileText,
|
||||
xlsx: FileSpreadsheet,
|
||||
xls: FileSpreadsheet,
|
||||
pptx: Presentation,
|
||||
ppt: Presentation,
|
||||
odt: FileText,
|
||||
ods: FileSpreadsheet,
|
||||
odp: Presentation,
|
||||
dwg: FileCode,
|
||||
dxf: FileCode,
|
||||
};
|
||||
|
||||
let Icon = $derived(ICON_MAP[format?.toLowerCase()] || FileQuestion);
|
||||
</script>
|
||||
|
||||
<svelte:component this={Icon} {size} />
|
||||
58
frontend/src/lib/components/PreviewPanel.svelte
Normal file
58
frontend/src/lib/components/PreviewPanel.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
// Phase E.1 — 얇은 wrapper로 축소.
|
||||
// 기존 344줄 → editors/* 7개로 분할. 이 파일은 header + 조합만 담당.
|
||||
// DocumentMetaRail (D.1) 과 documents/[id]/+page.svelte (E.2) 둘 다
|
||||
// 같은 editors/* 를 재사용한다.
|
||||
import { X, ExternalLink } from 'lucide-svelte';
|
||||
import FormatIcon from './FormatIcon.svelte';
|
||||
import NoteEditor from './editors/NoteEditor.svelte';
|
||||
import EditUrlEditor from './editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from './editors/TagsEditor.svelte';
|
||||
import AIClassificationEditor from './editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from './editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from './editors/ProcessingStatusView.svelte';
|
||||
import DocumentDangerZone from './editors/DocumentDangerZone.svelte';
|
||||
|
||||
let { doc, onclose, ondelete = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<aside class="h-full w-full flex flex-col bg-sidebar border-l border-default overflow-y-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-default 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 text-text truncate">{doc.title || '제목 없음'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="p-1 rounded hover:bg-surface text-dim hover:text-text"
|
||||
title="전체 보기"
|
||||
aria-label="전체 보기"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="p-1 rounded hover:bg-surface text-dim hover:text-text"
|
||||
aria-label="닫기"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 조합 -->
|
||||
<div class="flex-1 p-4 space-y-4">
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<AIClassificationEditor {doc} />
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
<div class="pt-2 border-t border-default">
|
||||
<DocumentDangerZone {doc} {ondelete} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
205
frontend/src/lib/components/Sidebar.svelte
Normal file
205
frontend/src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<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));
|
||||
|
||||
// ArrowUp/Down 키보드 nav — 현재 펼쳐진 tree-row만 traverse
|
||||
function handleTreeKeydown(e) {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const root = e.currentTarget;
|
||||
const rows = Array.from(root.querySelectorAll('[data-tree-row]'));
|
||||
if (rows.length === 0) return;
|
||||
const active = document.activeElement;
|
||||
const idx = active ? rows.indexOf(active) : -1;
|
||||
let next;
|
||||
if (e.key === 'ArrowDown') {
|
||||
next = idx < 0 ? 0 : Math.min(idx + 1, rows.length - 1);
|
||||
} else {
|
||||
next = idx <= 0 ? 0 : idx - 1;
|
||||
}
|
||||
e.preventDefault();
|
||||
rows[next].focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="h-full flex flex-col bg-sidebar border-r border-default overflow-y-auto">
|
||||
<div class="px-4 py-3 border-b border-default">
|
||||
<h2 class="text-sm font-semibold 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-accent/15 text-accent' : 'text-text hover:bg-surface'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
전체 문서
|
||||
</span>
|
||||
{#if totalCount > 0}
|
||||
<span class="text-xs text-dim">{totalCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 트리 -->
|
||||
<nav class="flex-1 px-2 py-2" onkeydown={handleTreeKeydown}>
|
||||
{#if loading}
|
||||
{#each Array(5) as _}
|
||||
<div class="h-8 bg-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-surface 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)}
|
||||
data-tree-row
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
|
||||
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-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-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-default">
|
||||
<h3 class="px-3 py-1 text-[10px] font-semibold 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-dim hover:bg-surface hover:text-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-dim hover:bg-surface hover:text-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-dim hover:bg-surface hover:text-text"
|
||||
>
|
||||
<Mail size={14} /> 이메일
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inbox -->
|
||||
<div class="px-2 py-2 border-t border-default">
|
||||
<a
|
||||
href="/inbox"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Inbox size={16} />
|
||||
받은편지함
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
89
frontend/src/lib/components/SystemStatusDot.svelte
Normal file
89
frontend/src/lib/components/SystemStatusDot.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// 시스템 상태 도트 — Phase B 신규.
|
||||
// dashboardSummary store를 구독해 한 색상 + tooltip을 표시한다.
|
||||
// 색상 규칙(우선순위 순):
|
||||
// 1) failed_count > 0 → bg-error
|
||||
// 2) 어떤 stage라도 pending > 10 → bg-warning
|
||||
// 3) 그 외 (failed_count === 0) → bg-success
|
||||
// 첫 fetch 전(null)에는 dim 회색 표시.
|
||||
|
||||
import {
|
||||
dashboardSummary,
|
||||
type DashboardSummary,
|
||||
type PipelineStatus,
|
||||
} from '$lib/stores/system';
|
||||
|
||||
type Tone = 'success' | 'error' | 'warning' | 'idle';
|
||||
|
||||
function pickTone(failedCount: number, pipeline: PipelineStatus[]): Tone {
|
||||
if (failedCount > 0) return 'error';
|
||||
const hasPendingBacklog = pipeline.some(
|
||||
(p) => p.status === 'pending' && p.count > 10,
|
||||
);
|
||||
if (hasPendingBacklog) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<Tone, string> = {
|
||||
success: 'bg-success',
|
||||
error: 'bg-error',
|
||||
warning: 'bg-warning',
|
||||
idle: 'bg-default',
|
||||
};
|
||||
|
||||
const TONE_LABEL: Record<Tone, string> = {
|
||||
success: '정상',
|
||||
error: '실패 있음',
|
||||
warning: '대기열 적체',
|
||||
idle: '확인 중',
|
||||
};
|
||||
|
||||
let tone: Tone = $derived(
|
||||
$dashboardSummary
|
||||
? pickTone($dashboardSummary.failed_count, $dashboardSummary.pipeline_status)
|
||||
: 'idle',
|
||||
);
|
||||
|
||||
function buildStageRows(pipeline: PipelineStatus[]) {
|
||||
// stage별로 status 카운트 합산 (extract/classify/embed/preview 등)
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const p of pipeline) {
|
||||
const cur = grouped.get(p.stage) ?? {};
|
||||
cur[p.status] = (cur[p.status] ?? 0) + p.count;
|
||||
grouped.set(p.stage, cur);
|
||||
}
|
||||
return [...grouped.entries()].map(([stage, counts]) => ({
|
||||
stage,
|
||||
pending: counts.pending ?? 0,
|
||||
processing: counts.processing ?? 0,
|
||||
failed: counts.failed ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let stageRows = $derived(
|
||||
$dashboardSummary ? buildStageRows($dashboardSummary.pipeline_status) : [],
|
||||
);
|
||||
|
||||
function buildTooltip(
|
||||
summary: DashboardSummary | null,
|
||||
rows: ReturnType<typeof buildStageRows>,
|
||||
currentTone: Tone,
|
||||
): string {
|
||||
if (!summary) return '시스템 상태 확인 중';
|
||||
const head = `시스템: ${TONE_LABEL[currentTone]} (실패 ${summary.failed_count})`;
|
||||
if (rows.length === 0) return head;
|
||||
const lines = rows.map(
|
||||
(r) => `${r.stage}: 대기 ${r.pending} · 처리 ${r.processing} · 실패 ${r.failed}`,
|
||||
);
|
||||
return [head, ...lines].join('\n');
|
||||
}
|
||||
|
||||
let tooltipText = $derived(buildTooltip($dashboardSummary, stageRows, tone));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex h-2 w-2 rounded-full {TONE_CLASS[tone]}"
|
||||
role="img"
|
||||
aria-label={TONE_LABEL[tone]}
|
||||
title={tooltipText}
|
||||
></span>
|
||||
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-warning/30', text: 'text-warning' };
|
||||
if (t.startsWith('#주제/') || t.startsWith('#')) return { bg: 'bg-accent/30', text: 'text-accent' };
|
||||
if (t.startsWith('$유형/') || t.startsWith('$')) return { bg: 'bg-success/30', text: 'text-success' };
|
||||
if (t.startsWith('!우선순위/') || t.startsWith('!')) return { bg: 'bg-error/30', text: 'text-error' };
|
||||
return { bg: 'bg-default', text: '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/toast';
|
||||
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-accent/10 border-2 border-dashed border-accent flex items-center justify-center">
|
||||
<div class="bg-surface rounded-xl px-8 py-6 shadow-xl text-center">
|
||||
<Upload size={32} class="mx-auto mb-2 text-accent" />
|
||||
<p class="text-sm font-medium text-accent">여기에 파일을 놓으세요</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 업로드 진행 상태 -->
|
||||
{#if uploading && uploadFiles.length > 0}
|
||||
<div class="mb-3 bg-surface border border-default rounded-lg p-3">
|
||||
<p class="text-xs 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-success' :
|
||||
f.status === 'failed' ? 'text-error' :
|
||||
f.status === 'uploading' ? 'text-accent' :
|
||||
'text-dim'
|
||||
}>
|
||||
{f.status === 'done' ? '✓' : f.status === 'failed' ? '✗' : f.status === 'uploading' ? '↑' : '…'}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
// Phase E.1 — AI 분류 결과 표시.
|
||||
// 현재는 read-only. 향후 phase에서 inline select로 override 가능하게 확장.
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
let parts = $derived(doc?.ai_domain ? doc.ai_domain.split('/') : []);
|
||||
let confidenceTone = $derived.by(() => {
|
||||
const c = doc?.ai_confidence ?? 0;
|
||||
if (c >= 0.85) return 'success';
|
||||
if (c >= 0.6) return 'warning';
|
||||
return 'error';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if doc?.ai_domain}
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">분류</h4>
|
||||
<!-- domain breadcrumb -->
|
||||
<div class="flex flex-wrap items-center gap-1 mb-2">
|
||||
{#each parts as part, i}
|
||||
{#if i > 0}<span class="text-[10px] text-faint">›</span>{/if}
|
||||
<span class="text-xs text-accent">{part}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
{#if doc.document_type}
|
||||
<Badge tone="accent" size="sm">{doc.document_type}</Badge>
|
||||
{/if}
|
||||
{#if doc.ai_confidence}
|
||||
<Badge tone={confidenceTone} size="sm">
|
||||
{(doc.ai_confidence * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if doc.importance && doc.importance !== 'medium'}
|
||||
<Badge tone={doc.importance === 'high' ? 'error' : 'neutral'} size="sm">
|
||||
{doc.importance}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
// Phase E.1 — 문서 삭제 영역. ConfirmDialog 프리미티브 사용.
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
|
||||
let { doc, ondelete = () => {} } = $props();
|
||||
|
||||
let deleting = $state(false);
|
||||
|
||||
// doc id 기반으로 modal id를 고유하게 해서, 여러 danger zone이 동시에
|
||||
// 있어도 충돌하지 않게 한다 (예: detail 페이지 + rail).
|
||||
let modalId = $derived(`doc-delete-${doc?.id ?? 'unknown'}`);
|
||||
|
||||
async function confirm() {
|
||||
if (!doc) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await api(`/documents/${doc.id}?delete_file=true`, { method: 'DELETE' });
|
||||
addToast('success', '문서 삭제됨');
|
||||
ondelete();
|
||||
} catch (err) {
|
||||
addToast('error', '삭제 실패');
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={Trash2}
|
||||
onclick={() => ui.openModal(modalId)}
|
||||
class="text-dim hover:text-error"
|
||||
>
|
||||
문서 삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
id={modalId}
|
||||
title="문서 삭제"
|
||||
message="원본 파일과 모든 메타데이터(메모/태그/분류)가 함께 삭제됩니다. 되돌릴 수 없습니다."
|
||||
confirmLabel="삭제"
|
||||
tone="danger"
|
||||
loading={deleting}
|
||||
onconfirm={confirm}
|
||||
/>
|
||||
84
frontend/src/lib/components/editors/EditUrlEditor.svelte
Normal file
84
frontend/src/lib/components/editors/EditUrlEditor.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script>
|
||||
// Phase E.1 — 편집 URL(외부 Synology Drive 등) 등록.
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
let urlText = $state('');
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (doc) {
|
||||
urlText = doc.edit_url || '';
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
const next = urlText.trim() || null;
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ edit_url: next }),
|
||||
});
|
||||
doc.edit_url = next;
|
||||
editing = false;
|
||||
addToast('success', '편집 URL 저장됨');
|
||||
} catch (err) {
|
||||
addToast('error', '편집 URL 저장 실패');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing = false;
|
||||
urlText = doc.edit_url || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">편집 링크</h4>
|
||||
{#if editing}
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
bind:value={urlText}
|
||||
aria-label="편집 URL"
|
||||
placeholder="Synology Drive URL..."
|
||||
class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent"
|
||||
/>
|
||||
<Button variant="primary" size="sm" loading={saving} onclick={save}>저장</Button>
|
||||
<Button variant="ghost" size="sm" onclick={cancel}>취소</Button>
|
||||
</div>
|
||||
{:else if doc.edit_url}
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-xs text-accent truncate hover:underline flex-1 min-w-0"
|
||||
>
|
||||
{doc.edit_url}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="text-[10px] text-dim hover:text-text shrink-0"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="text-xs text-dim hover:text-accent"
|
||||
>
|
||||
+ URL 추가
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
52
frontend/src/lib/components/editors/FileInfoView.svelte
Normal file
52
frontend/src/lib/components/editors/FileInfoView.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
// Phase E.1 — 파일 메타 정보 read-only 표시.
|
||||
let { doc } = $props();
|
||||
|
||||
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>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">정보</h4>
|
||||
<dl class="space-y-1.5 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-dim">포맷</dt>
|
||||
<dd class="text-text uppercase">
|
||||
{doc.file_format}{doc.original_format ? ` (원본: ${doc.original_format})` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-dim">크기</dt>
|
||||
<dd class="text-text">{formatSize(doc.file_size)}</dd>
|
||||
</div>
|
||||
{#if doc.source_channel}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-dim">출처</dt>
|
||||
<dd class="text-text">{doc.source_channel}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.data_origin}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-dim">구분</dt>
|
||||
<dd class="text-text">{doc.data_origin}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-dim">등록일</dt>
|
||||
<dd class="text-text">{formatDate(doc.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
70
frontend/src/lib/components/editors/NoteEditor.svelte
Normal file
70
frontend/src/lib/components/editors/NoteEditor.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
// Phase E.1 — PreviewPanel 분할. 사용자 메모 편집.
|
||||
import { Save } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
// 초기값은 빈 문자열, 아래 $effect가 doc 변경 시 동기화.
|
||||
let noteText = $state('');
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (doc) {
|
||||
noteText = doc.user_note || '';
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ user_note: noteText }),
|
||||
});
|
||||
doc.user_note = noteText;
|
||||
editing = false;
|
||||
addToast('success', '메모 저장됨');
|
||||
} catch (err) {
|
||||
addToast('error', '메모 저장 실패');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing = false;
|
||||
noteText = doc.user_note || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">메모</h4>
|
||||
{#if editing}
|
||||
<textarea
|
||||
bind:value={noteText}
|
||||
aria-label="메모"
|
||||
class="w-full h-24 px-3 py-2 bg-bg border border-default rounded-md text-sm text-text resize-none outline-none focus:border-accent"
|
||||
placeholder="메모 입력..."
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-1.5">
|
||||
<Button variant="primary" size="sm" icon={Save} loading={saving} onclick={save}>
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={cancel}>취소</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="w-full text-left px-3 py-2 bg-bg border border-default rounded-md text-sm min-h-[40px] hover:border-accent transition-colors
|
||||
{noteText ? 'text-text' : 'text-dim'}"
|
||||
>
|
||||
{noteText || '메모 추가...'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
// Phase E.1 — 파이프라인 처리 단계 상태 표시.
|
||||
import { CheckCircle2, Clock } from 'lucide-svelte';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'extracted_at', label: '추출' },
|
||||
{ key: 'ai_processed_at', label: '분류' },
|
||||
{ key: 'embedded_at', label: '임베딩' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">처리</h4>
|
||||
<dl class="space-y-1 text-xs">
|
||||
{#each STAGES as stage}
|
||||
{@const done = !!doc[stage.key]}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-dim flex items-center gap-1">
|
||||
{#if done}
|
||||
<CheckCircle2 size={10} class="text-success" />
|
||||
{:else}
|
||||
<Clock size={10} class="text-faint" />
|
||||
{/if}
|
||||
{stage.label}
|
||||
</dt>
|
||||
<dd class={done ? 'text-success' : 'text-dim'}>
|
||||
{done ? '완료' : '대기'}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
101
frontend/src/lib/components/editors/TagsEditor.svelte
Normal file
101
frontend/src/lib/components/editors/TagsEditor.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script>
|
||||
// Phase E.1 — 태그 추가/삭제.
|
||||
// Phase F의 inbox override TagsEditor도 향후 이 컴포넌트로 통일 가능.
|
||||
import { Plus, X } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import TagPill from '$lib/components/TagPill.svelte';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
let newTag = $state('');
|
||||
let editing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (doc) {
|
||||
newTag = '';
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function addTag() {
|
||||
const tag = newTag.trim();
|
||||
if (!tag) return;
|
||||
const updated = [...(doc.ai_tags || []), tag];
|
||||
try {
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ai_tags: updated }),
|
||||
});
|
||||
doc.ai_tags = updated;
|
||||
newTag = '';
|
||||
addToast('success', '태그 추가됨');
|
||||
} catch (err) {
|
||||
addToast('error', '태그 추가 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTag(tagToRemove) {
|
||||
const updated = (doc.ai_tags || []).filter((t) => t !== tagToRemove);
|
||||
try {
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ai_tags: updated }),
|
||||
});
|
||||
doc.ai_tags = updated;
|
||||
addToast('success', '태그 삭제됨');
|
||||
} catch (err) {
|
||||
addToast('error', '태그 삭제 실패');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold 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
|
||||
type="button"
|
||||
onclick={() => removeTag(tag)}
|
||||
class="text-dim hover:text-error"
|
||||
aria-label="{tag} 삭제"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#if editing}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}}
|
||||
class="flex gap-1"
|
||||
>
|
||||
<input
|
||||
bind:value={newTag}
|
||||
aria-label="새 태그"
|
||||
placeholder="태그 입력..."
|
||||
class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="flex items-center gap-1 text-xs text-dim hover:text-accent"
|
||||
>
|
||||
<Plus size={12} /> 태그 추가
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
49
frontend/src/lib/components/ui/Badge.svelte
Normal file
49
frontend/src/lib/components/ui/Badge.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
// 공용 status pill (TagPill과 별개 — TagPill은 도메인 prefix 코드,
|
||||
// Badge는 의미적 tone 표시).
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Tone = 'neutral' | 'success' | 'warning' | 'error' | 'accent';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
interface Props {
|
||||
tone?: Tone;
|
||||
size?: Size;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
tone = 'neutral',
|
||||
size = 'md',
|
||||
children,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const toneClass: Record<Tone, string> = {
|
||||
neutral: 'bg-surface text-dim border border-default',
|
||||
success: 'bg-success/15 text-success border border-success/30',
|
||||
warning: 'bg-warning/15 text-warning border border-warning/30',
|
||||
error: 'bg-error/15 text-error border border-error/30',
|
||||
accent: 'bg-accent/15 text-accent border border-accent/30',
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: 'text-[10px] px-1.5 py-0.5',
|
||||
md: 'text-xs px-2 py-0.5',
|
||||
};
|
||||
|
||||
let baseClass = $derived(
|
||||
[
|
||||
'inline-flex items-center gap-1 rounded font-medium',
|
||||
sizeClass[size],
|
||||
toneClass[tone],
|
||||
className,
|
||||
].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class={baseClass} {...rest}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
118
frontend/src/lib/components/ui/Button.svelte
Normal file
118
frontend/src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
// 공용 Button 프리미티브.
|
||||
// - variant: 시각적 강도 (primary/secondary/ghost/danger)
|
||||
// - size: sm/md
|
||||
// - href가 있으면 <a>로, 없으면 <button>으로 렌더
|
||||
// - icon은 lucide 컴포넌트 참조 (예: import { Trash2 } 후 icon={Trash2})
|
||||
// - loading이면 disabled + 회전 아이콘
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// lucide-svelte v0.400은 아직 legacy SvelteComponentTyped 기반이라 Svelte 5의
|
||||
// Component 타입과 호환되지 않는다. 향후 lucide v0.469+ 업그레이드 시 정식 타입으로 좁히기.
|
||||
// 우리는 size prop만 넘기므로 any로 받아도 충분히 안전.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = any;
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
interface Props {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
icon?: IconComponent;
|
||||
iconPosition?: 'left' | 'right';
|
||||
href?: string;
|
||||
target?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
icon: Icon,
|
||||
iconPosition = 'left',
|
||||
href,
|
||||
target,
|
||||
onclick,
|
||||
children,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
primary: 'bg-accent text-white hover:bg-accent-hover',
|
||||
secondary: 'bg-surface border border-default text-text hover:bg-surface-hover',
|
||||
ghost: 'text-dim hover:bg-surface hover:text-text',
|
||||
danger: 'bg-error/10 text-error border border-error/30 hover:bg-error/20',
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: 'h-7 px-2.5 text-xs gap-1.5',
|
||||
md: 'h-9 px-3.5 text-sm gap-2',
|
||||
};
|
||||
|
||||
let iconSize = $derived(size === 'sm' ? 14 : 16);
|
||||
|
||||
let baseClass = $derived(
|
||||
[
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
'transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent-ring focus-visible:outline-none',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
sizeClass[size],
|
||||
variantClass[variant],
|
||||
className,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
let isDisabled = $derived(disabled || loading);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
{target}
|
||||
class={baseClass}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
tabindex={isDisabled ? -1 : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={iconSize} class="animate-spin" />
|
||||
{:else if Icon && iconPosition === 'left'}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if !loading && Icon && iconPosition === 'right'}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
{type}
|
||||
class={baseClass}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading || undefined}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={iconSize} class="animate-spin" />
|
||||
{:else if Icon && iconPosition === 'left'}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if !loading && Icon && iconPosition === 'right'}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
45
frontend/src/lib/components/ui/Card.svelte
Normal file
45
frontend/src/lib/components/ui/Card.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
// 공용 Card 컨테이너.
|
||||
// 기존 코드의 `bg-surface rounded-card border border-default` 패턴 1군데화.
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
padded?: boolean;
|
||||
interactive?: boolean;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
padded = true,
|
||||
interactive = false,
|
||||
onclick,
|
||||
children,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
let baseClass = $derived(
|
||||
[
|
||||
'bg-surface border border-default rounded-card',
|
||||
padded ? 'p-5' : '',
|
||||
interactive
|
||||
? 'cursor-pointer hover:bg-surface-hover transition-colors focus-visible:ring-2 focus-visible:ring-accent-ring focus-visible:outline-none'
|
||||
: '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if interactive}
|
||||
<button type="button" class={baseClass} {onclick} {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{:else}
|
||||
<div class={baseClass} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
52
frontend/src/lib/components/ui/ConfirmDialog.svelte
Normal file
52
frontend/src/lib/components/ui/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
// 삭제/되돌릴 수 없는 작업의 확인 dialog. Modal 위 얇은 wrapper.
|
||||
// 사용처에서 ui.openModal(id) 호출하면 표시됨.
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
type Tone = 'danger' | 'primary';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: Tone;
|
||||
loading?: boolean;
|
||||
onconfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '확인',
|
||||
cancelLabel = '취소',
|
||||
tone = 'danger',
|
||||
loading = false,
|
||||
onconfirm,
|
||||
}: Props = $props();
|
||||
|
||||
function cancel() {
|
||||
ui.closeModal(id);
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
await onconfirm();
|
||||
// onconfirm이 닫지 않으면 우리가 닫는다 (멱등하게 처리됨)
|
||||
if (ui.isModalOpen(id)) ui.closeModal(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {id} {title} size="sm">
|
||||
<p class="text-sm text-dim leading-relaxed">{message}</p>
|
||||
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={cancel}>{cancelLabel}</Button>
|
||||
<Button variant={tone === 'danger' ? 'danger' : 'primary'} size="sm" {loading} onclick={confirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
62
frontend/src/lib/components/ui/Drawer.svelte
Normal file
62
frontend/src/lib/components/ui/Drawer.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// 사이드 슬라이드 패널. uiState의 단일 drawer slot과 결합.
|
||||
// - id: 'sidebar' | 'meta' (동시에 둘 다 열리지 않음)
|
||||
// - 새 drawer 열면 ui.openDrawer(id)가 자동으로 이전 drawer를 치움
|
||||
// - 모바일/태블릿에서 메타 패널을 표시하는 폴백 경로 (xl+에서는 inline rail 사용)
|
||||
import type { Snippet } from 'svelte';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
|
||||
type Side = 'left' | 'right';
|
||||
type Width = 'sidebar' | 'rail';
|
||||
|
||||
interface Props {
|
||||
id: 'sidebar' | 'meta';
|
||||
side?: Side;
|
||||
width?: Width;
|
||||
children?: Snippet;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
side = 'left',
|
||||
width = 'sidebar',
|
||||
children,
|
||||
'aria-label': ariaLabel = '드로어',
|
||||
}: Props = $props();
|
||||
|
||||
let open = $derived(ui.isDrawerOpen(id));
|
||||
|
||||
let widthClass = $derived(width === 'sidebar' ? 'w-sidebar' : 'w-rail');
|
||||
let sideClass = $derived(side === 'left' ? 'left-0' : 'right-0');
|
||||
let translateClosed = $derived(side === 'left' ? '-translate-x-full' : 'translate-x-full');
|
||||
|
||||
function close() {
|
||||
ui.closeDrawer();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- backdrop -->
|
||||
<div class="fixed inset-0 z-drawer">
|
||||
<button
|
||||
type="button"
|
||||
onclick={close}
|
||||
class="absolute inset-0 bg-scrim transition-opacity"
|
||||
aria-label="드로어 닫기"
|
||||
></button>
|
||||
|
||||
<!-- panel -->
|
||||
<aside
|
||||
class={[
|
||||
'absolute top-0 bottom-0 z-drawer bg-sidebar shadow-xl transition-transform',
|
||||
sideClass,
|
||||
widthClass,
|
||||
open ? 'translate-x-0' : translateClosed,
|
||||
].join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{@render children?.()}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
39
frontend/src/lib/components/ui/EmptyState.svelte
Normal file
39
frontend/src/lib/components/ui/EmptyState.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
// 빈 상태/추후 지원/검색 결과 없음 등에 사용.
|
||||
// children은 액션 슬롯 (Button 등).
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// lucide-svelte v0.400 legacy 타입 호환을 위한 임시 IconComponent.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = any;
|
||||
|
||||
interface Props {
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { icon: Icon, title, description, children, class: className = '', ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={'flex flex-col items-center justify-center text-center py-12 px-4 ' + className}
|
||||
{...rest}
|
||||
>
|
||||
{#if Icon}
|
||||
<div class="text-faint mb-3">
|
||||
<Icon size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-sm font-medium text-text">{title}</p>
|
||||
{#if description}
|
||||
<p class="text-xs text-dim mt-1 max-w-sm">{description}</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
<div class="mt-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
103
frontend/src/lib/components/ui/IconButton.svelte
Normal file
103
frontend/src/lib/components/ui/IconButton.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
// 정사각형 아이콘 전용 버튼. nav/toolbar에서 사용.
|
||||
// aria-label 필수 (스크린 리더 라벨링).
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
// lucide-svelte v0.400은 legacy 타입. 향후 v0.469+ 업그레이드 후 정식 타입으로 좁히기.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = any;
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
interface Props {
|
||||
icon: IconComponent;
|
||||
'aria-label': string;
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
href?: string;
|
||||
target?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
'aria-label': ariaLabel,
|
||||
variant = 'ghost',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
href,
|
||||
target,
|
||||
onclick,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
primary: 'bg-accent text-white hover:bg-accent-hover',
|
||||
secondary: 'bg-surface border border-default text-text hover:bg-surface-hover',
|
||||
ghost: 'text-dim hover:bg-surface hover:text-text',
|
||||
danger: 'text-error hover:bg-error/10',
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: 'h-7 w-7',
|
||||
md: 'h-9 w-9',
|
||||
};
|
||||
|
||||
let iconSize = $derived(size === 'sm' ? 14 : 16);
|
||||
|
||||
let baseClass = $derived(
|
||||
[
|
||||
'inline-flex items-center justify-center rounded-md',
|
||||
'transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent-ring focus-visible:outline-none',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
sizeClass[size],
|
||||
variantClass[variant],
|
||||
className,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
let isDisabled = $derived(disabled || loading);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
{target}
|
||||
class={baseClass}
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
tabindex={isDisabled ? -1 : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={iconSize} class="animate-spin" />
|
||||
{:else}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
{type}
|
||||
class={baseClass}
|
||||
disabled={isDisabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading || undefined}
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<Loader2 size={iconSize} class="animate-spin" />
|
||||
{:else}
|
||||
<Icon size={iconSize} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
137
frontend/src/lib/components/ui/Modal.svelte
Normal file
137
frontend/src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
// Modal stack 지원 — confirm 위에 nested 모달을 쌓을 수 있다.
|
||||
// z-index = z-modal + (stack 인덱스 * 2). backdrop / panel 둘 다 한 칸씩.
|
||||
// 최상단 modal만 focus trap 활성, 아래는 inert 처리.
|
||||
// native <dialog>를 쓰지 않는 이유: top-layer가 단일이라 stack을 지원하지 않음.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title?: string;
|
||||
size?: Size;
|
||||
closable?: boolean;
|
||||
children?: Snippet;
|
||||
footer?: Snippet;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
size = 'md',
|
||||
closable = true,
|
||||
children,
|
||||
footer,
|
||||
'aria-label': ariaLabel,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $derived(ui.isModalOpen(id));
|
||||
let stackIndex = $derived(ui.modalIndex(id));
|
||||
let isTop = $derived(stackIndex === ui.modalStack.length - 1);
|
||||
|
||||
// z-index 계산: backdrop과 panel을 별개의 stacking context로 두기 위해 *2
|
||||
let backdropZ = $derived(`calc(var(--z-modal) + ${stackIndex * 2})`);
|
||||
let panelZ = $derived(`calc(var(--z-modal) + ${stackIndex * 2 + 1})`);
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
};
|
||||
|
||||
// 패널 ref + focus trap
|
||||
let panelEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// open 토글 시: 열릴 때 패널 안 첫 focusable로 포커스 이동
|
||||
$effect(() => {
|
||||
if (!open || !isTop || !panelEl) return;
|
||||
const focusables = panelEl.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const first = focusables[0];
|
||||
if (first) first.focus();
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (closable) ui.closeModal(id);
|
||||
}
|
||||
|
||||
// 최상단 modal 안에서만 Tab 사이클을 가둔다
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!isTop || !panelEl) return;
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusables = Array.from(
|
||||
panelEl.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-scrim transition-opacity"
|
||||
style="z-index: {backdropZ}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- panel container (centers modal) -->
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center p-4"
|
||||
style="z-index: {panelZ}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel ?? title}
|
||||
inert={!isTop ? true : undefined}
|
||||
onkeydown={onKeydown}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
bind:this={panelEl}
|
||||
class={[
|
||||
'w-full bg-surface border border-default rounded-card shadow-xl',
|
||||
'flex flex-col max-h-[90vh]',
|
||||
sizeClass[size],
|
||||
].join(' ')}
|
||||
>
|
||||
{#if title || closable}
|
||||
<header class="flex items-center justify-between px-5 py-3 border-b border-default shrink-0">
|
||||
{#if title}
|
||||
<h2 class="text-sm font-semibold text-text">{title}</h2>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if closable}
|
||||
<IconButton icon={X} aria-label="닫기" onclick={close} size="sm" />
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-text">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<footer class="flex items-center justify-end gap-2 px-5 py-3 border-t border-default shrink-0">
|
||||
{@render footer()}
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
100
frontend/src/lib/components/ui/Select.svelte
Normal file
100
frontend/src/lib/components/ui/Select.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
// 네이티브 <select> 래퍼. TextInput과 동일 시각 시스템.
|
||||
// options 배열을 그룹 없이 단순 평면 리스트로 받는다.
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
options: SelectOption[];
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
options,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
placeholder,
|
||||
id: idProp,
|
||||
name,
|
||||
disabled = false,
|
||||
required = false,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const autoId = $props.id();
|
||||
let inputId = $derived(idProp ?? `select-${autoId}`);
|
||||
let hintId = $derived(`${inputId}-hint`);
|
||||
let errorId = $derived(`${inputId}-error`);
|
||||
let describedBy = $derived(
|
||||
[error ? errorId : null, hint && !error ? hintId : null].filter(Boolean).join(' ') || undefined
|
||||
);
|
||||
|
||||
let selectClass = $derived(
|
||||
[
|
||||
'w-full h-9 pl-3 pr-9 rounded-md text-sm bg-bg text-text appearance-none',
|
||||
'border outline-none transition-colors',
|
||||
error
|
||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/30'
|
||||
: 'border-default focus:border-accent focus:ring-2 focus:ring-accent-ring',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={'flex flex-col gap-1 ' + className}>
|
||||
{#if label}
|
||||
<label for={inputId} class="text-xs font-medium text-dim">
|
||||
{label}
|
||||
{#if required}<span class="text-error">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
id={inputId}
|
||||
bind:value
|
||||
{name}
|
||||
{disabled}
|
||||
{required}
|
||||
class={selectClass}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
>
|
||||
{#if placeholder}
|
||||
<option value="" disabled selected={value === ''}>{placeholder}</option>
|
||||
{/if}
|
||||
{#each options as opt (opt.value)}
|
||||
<option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2.5 text-faint pointer-events-none"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-xs text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p id={hintId} class="text-xs text-faint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
29
frontend/src/lib/components/ui/Skeleton.svelte
Normal file
29
frontend/src/lib/components/ui/Skeleton.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
// 로딩 placeholder. 기존의 ad-hoc `animate-pulse h-N` div 패턴 통합.
|
||||
type Rounded = 'sm' | 'md' | 'lg' | 'card' | 'full';
|
||||
|
||||
interface Props {
|
||||
/** Tailwind width 클래스 (예: 'w-full', 'w-32') 또는 임의값 */
|
||||
w?: string;
|
||||
/** Tailwind height 클래스 (예: 'h-4', 'h-28') */
|
||||
h?: string;
|
||||
rounded?: Rounded;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { w = 'w-full', h = 'h-4', rounded = 'md', class: className = '', ...rest }: Props = $props();
|
||||
|
||||
const roundedClass: Record<Rounded, string> = {
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
card: 'rounded-card',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
let baseClass = $derived(
|
||||
['animate-pulse bg-surface', w, h, roundedClass[rounded], className].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={baseClass} {...rest}></div>
|
||||
111
frontend/src/lib/components/ui/Tabs.svelte
Normal file
111
frontend/src/lib/components/ui/Tabs.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
// ARIA tablist + tab + tabpanel. 좌우 화살표 키 nav.
|
||||
// children snippet은 (activeId: string) => UI 시그니처로 받는다.
|
||||
// 사용처:
|
||||
// <Tabs tabs={[{id:'edit',label:'편집'},{id:'preview',label:'미리보기'}]} bind:value={mode}>
|
||||
// {#snippet children(activeId)}
|
||||
// {#if activeId === 'edit'}<EditPanel />{/if}
|
||||
// {#if activeId === 'preview'}<PreviewPanel />{/if}
|
||||
// {/snippet}
|
||||
// </Tabs>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
value?: string;
|
||||
children?: Snippet<[string]>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { tabs, value = $bindable(tabs[0]?.id ?? ''), children, class: className = '' }: Props = $props();
|
||||
|
||||
const autoId = $props.id();
|
||||
|
||||
function tabId(id: string) {
|
||||
return `tab-${autoId}-${id}`;
|
||||
}
|
||||
function panelId(id: string) {
|
||||
return `panel-${autoId}-${id}`;
|
||||
}
|
||||
|
||||
function select(id: string) {
|
||||
if (tabs.find((t) => t.id === id)?.disabled) return;
|
||||
value = id;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const enabled = tabs.filter((t) => !t.disabled);
|
||||
const idx = enabled.findIndex((t) => t.id === value);
|
||||
if (idx === -1) return;
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = enabled[(idx + 1) % enabled.length];
|
||||
select(next.id);
|
||||
document.getElementById(tabId(next.id))?.focus();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = enabled[(idx - 1 + enabled.length) % enabled.length];
|
||||
select(prev.id);
|
||||
document.getElementById(tabId(prev.id))?.focus();
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
const first = enabled[0];
|
||||
select(first.id);
|
||||
document.getElementById(tabId(first.id))?.focus();
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
const last = enabled[enabled.length - 1];
|
||||
select(last.id);
|
||||
document.getElementById(tabId(last.id))?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<div
|
||||
role="tablist"
|
||||
tabindex="-1"
|
||||
class="flex items-center gap-1 border-b border-default"
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
{#each tabs as tab (tab.id)}
|
||||
{@const active = tab.id === value}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={tabId(tab.id)}
|
||||
aria-selected={active}
|
||||
aria-controls={panelId(tab.id)}
|
||||
tabindex={active ? 0 : -1}
|
||||
disabled={tab.disabled}
|
||||
onclick={() => select(tab.id)}
|
||||
class={[
|
||||
'px-3 h-9 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring rounded-t',
|
||||
active
|
||||
? 'text-accent border-accent'
|
||||
: 'text-dim border-transparent hover:text-text',
|
||||
tab.disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={panelId(value)}
|
||||
aria-labelledby={tabId(value)}
|
||||
tabindex="0"
|
||||
class="focus-visible:outline-none"
|
||||
>
|
||||
{@render children?.(value)}
|
||||
</div>
|
||||
</div>
|
||||
115
frontend/src/lib/components/ui/TextInput.svelte
Normal file
115
frontend/src/lib/components/ui/TextInput.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
// 공용 텍스트 입력. label/error/hint를 SSR-safe id로 ARIA 연결.
|
||||
// - value는 $bindable
|
||||
// - error 전달 시 빨간 보더 + 메시지 + aria-invalid
|
||||
// - leading/trailing 아이콘은 lucide 컴포넌트 참조로 전달
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
// lucide-svelte v0.400 legacy 타입 호환을 위한 임시 IconComponent.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = any;
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
leadingIcon?: IconComponent;
|
||||
trailingIcon?: IconComponent;
|
||||
type?: 'text' | 'password' | 'email' | 'url' | 'tel' | 'number' | 'search';
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
autocomplete?: HTMLInputAttributes['autocomplete'];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
leadingIcon: LeadingIcon,
|
||||
trailingIcon: TrailingIcon,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
id: idProp,
|
||||
name,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
required = false,
|
||||
autocomplete,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
// SSR-safe 자동 id 생성 (Svelte 5.20+)
|
||||
const autoId = $props.id();
|
||||
let inputId = $derived(idProp ?? `input-${autoId}`);
|
||||
let hintId = $derived(`${inputId}-hint`);
|
||||
let errorId = $derived(`${inputId}-error`);
|
||||
let describedBy = $derived(
|
||||
[error ? errorId : null, hint && !error ? hintId : null].filter(Boolean).join(' ') || undefined
|
||||
);
|
||||
|
||||
let inputClass = $derived(
|
||||
[
|
||||
'w-full h-9 rounded-md text-sm bg-bg text-text placeholder:text-faint',
|
||||
'border outline-none transition-colors',
|
||||
error
|
||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/30'
|
||||
: 'border-default focus:border-accent focus:ring-2 focus:ring-accent-ring',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
LeadingIcon ? 'pl-9' : 'px-3',
|
||||
TrailingIcon ? 'pr-9' : LeadingIcon ? 'pr-3' : '',
|
||||
].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={'flex flex-col gap-1 ' + className}>
|
||||
{#if label}
|
||||
<label for={inputId} class="text-xs font-medium text-dim">
|
||||
{label}
|
||||
{#if required}<span class="text-error">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
{#if LeadingIcon}
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-2.5 text-faint pointer-events-none">
|
||||
<LeadingIcon size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
{type}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{autocomplete}
|
||||
class={inputClass}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{#if TrailingIcon}
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2.5 text-faint pointer-events-none">
|
||||
<TrailingIcon size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-xs text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p id={hintId} class="text-xs text-faint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
105
frontend/src/lib/components/ui/Textarea.svelte
Normal file
105
frontend/src/lib/components/ui/Textarea.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
// 공용 textarea. TextInput과 같은 구조 + autoGrow 옵션.
|
||||
interface Props {
|
||||
value?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
autoGrow?: boolean;
|
||||
maxRows?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
placeholder,
|
||||
id: idProp,
|
||||
name,
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
required = false,
|
||||
autoGrow = false,
|
||||
maxRows,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const autoId = $props.id();
|
||||
let inputId = $derived(idProp ?? `textarea-${autoId}`);
|
||||
let hintId = $derived(`${inputId}-hint`);
|
||||
let errorId = $derived(`${inputId}-error`);
|
||||
let describedBy = $derived(
|
||||
[error ? errorId : null, hint && !error ? hintId : null].filter(Boolean).join(' ') || undefined
|
||||
);
|
||||
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
// autoGrow: value 변경 시마다 height를 scrollHeight로 동기화
|
||||
$effect(() => {
|
||||
if (!autoGrow || !textareaEl) return;
|
||||
// value를 reactivity 의존성으로 등록
|
||||
void value;
|
||||
textareaEl.style.height = 'auto';
|
||||
let next = textareaEl.scrollHeight;
|
||||
if (maxRows) {
|
||||
const lineHeight = parseFloat(getComputedStyle(textareaEl).lineHeight) || 20;
|
||||
const maxHeight = lineHeight * maxRows;
|
||||
if (next > maxHeight) next = maxHeight;
|
||||
}
|
||||
textareaEl.style.height = next + 'px';
|
||||
});
|
||||
|
||||
let textareaClass = $derived(
|
||||
[
|
||||
'w-full px-3 py-2 rounded-md text-sm bg-bg text-text placeholder:text-faint',
|
||||
'border outline-none transition-colors',
|
||||
error
|
||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/30'
|
||||
: 'border-default focus:border-accent focus:ring-2 focus:ring-accent-ring',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
autoGrow ? 'resize-none overflow-hidden' : 'resize-y',
|
||||
].join(' ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={'flex flex-col gap-1 ' + className}>
|
||||
{#if label}
|
||||
<label for={inputId} class="text-xs font-medium text-dim">
|
||||
{label}
|
||||
{#if required}<span class="text-error">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
id={inputId}
|
||||
bind:this={textareaEl}
|
||||
bind:value
|
||||
{name}
|
||||
{rows}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
class={textareaClass}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
></textarea>
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-xs text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p id={hintId} class="text-xs text-faint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
62
frontend/src/lib/composables/useListKeyboardNav.svelte.ts
Normal file
62
frontend/src/lib/composables/useListKeyboardNav.svelte.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// 리스트 키보드 네비게이션 runes 컴포저블 — Phase D.4 신규.
|
||||
// - j / ArrowDown → next, k / ArrowUp → prev
|
||||
// - Enter → onenter(items[index])
|
||||
// - Escape → onescape?.()
|
||||
// - input/textarea/select/contenteditable 포커스 중이면 완전히 비활성
|
||||
// → '/' 키 검색 포커스(+layout.svelte)와 충돌 없음
|
||||
//
|
||||
// 사용 (documents/+page.svelte):
|
||||
// let selectedIndex = $state(0);
|
||||
// useListKeyboardNav({
|
||||
// get items() { return items; },
|
||||
// get index() { return selectedIndex; },
|
||||
// setIndex: (i) => { selectedIndex = i; scrollSelectedIntoView(); },
|
||||
// onenter: (doc) => selectDoc(doc),
|
||||
// });
|
||||
|
||||
interface Options {
|
||||
readonly items: unknown[];
|
||||
readonly index: number;
|
||||
setIndex(i: number): void;
|
||||
onenter?: (item: unknown) => void;
|
||||
onescape?: () => void;
|
||||
}
|
||||
|
||||
function isTypingTarget(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
export function useListKeyboardNav(opts: Options): void {
|
||||
$effect(() => {
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
const len = opts.items.length;
|
||||
if (len === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
opts.setIndex(Math.min(opts.index + 1, len - 1));
|
||||
break;
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
opts.setIndex(Math.max(opts.index - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
opts.onenter?.(opts.items[opts.index]);
|
||||
break;
|
||||
case 'Escape':
|
||||
opts.onescape?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
}
|
||||
38
frontend/src/lib/composables/useMedia.svelte.ts
Normal file
38
frontend/src/lib/composables/useMedia.svelte.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// matchMedia 기반 runes 반응형 flag. 컴포넌트 script 최상위에서만 호출해야
|
||||
// $effect가 owner를 찾는다. SSR에서는 초기값 false로 가정하고, 클라이언트
|
||||
// mount 후 실제 값으로 업데이트.
|
||||
//
|
||||
// Phase D.1 신규 — documents/+page.svelte 에서 xl 브레이크포인트 기반으로
|
||||
// 메타 rail(persistent) 과 Drawer(폴백) 중 어느 쪽을 쓸지 분기.
|
||||
//
|
||||
// 사용:
|
||||
// import { useIsXl } from '$lib/composables/useMedia.svelte';
|
||||
// const isXl = useIsXl();
|
||||
// {#if isXl.current} ... {/if}
|
||||
//
|
||||
// Tailwind v4 xl breakpoint = 1280px (app.css @theme 기본값과 일치).
|
||||
|
||||
export interface MediaFlag {
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
export function useMedia(query: string): MediaFlag {
|
||||
let matches = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mql = window.matchMedia(query);
|
||||
matches = mql.matches;
|
||||
const handler = (e: MediaQueryListEvent) => (matches = e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
});
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return matches;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useIsXl = (): MediaFlag => useMedia('(min-width: 1280px)');
|
||||
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;
|
||||
}
|
||||
}
|
||||
79
frontend/src/lib/stores/system.ts
Normal file
79
frontend/src/lib/stores/system.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 시스템 상태 store — Phase B에서 신규.
|
||||
// /dashboard/ API 응답을 60초 주기로 폴링하고, 첫 subscribe 시 자동 시작한다.
|
||||
// SystemStatusDot(B)과 dashboard(C)가 같은 fetch를 공유해 중복 호출을 방지.
|
||||
//
|
||||
// API 응답 shape: app/api/dashboard.py DashboardResponse 참조
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface DomainCount {
|
||||
domain: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RecentDocument {
|
||||
id: number;
|
||||
title: string | null;
|
||||
file_format: string;
|
||||
ai_domain: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PipelineStatus {
|
||||
stage: string;
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
today_added: number;
|
||||
today_by_domain: DomainCount[];
|
||||
inbox_count: number;
|
||||
law_alerts: number;
|
||||
recent_documents: RecentDocument[];
|
||||
pipeline_status: PipelineStatus[];
|
||||
failed_count: number;
|
||||
total_documents: number;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscriberCount = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
const internal = writable<DashboardSummary | null>(null, (_set) => {
|
||||
// svelte writable의 두 번째 인자는 첫 구독 시 호출되는 start fn,
|
||||
// 반환값은 마지막 unsubscribe 시 호출되는 stop fn.
|
||||
subscriberCount += 1;
|
||||
if (subscriberCount === 1) {
|
||||
void refresh();
|
||||
pollHandle = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||
}
|
||||
return () => {
|
||||
subscriberCount -= 1;
|
||||
if (subscriberCount === 0 && pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const dashboardSummary = { subscribe: internal.subscribe };
|
||||
|
||||
export async function refresh(): Promise<void> {
|
||||
// 동시 fetch 합치기 — 폴링 + 수동 새로고침이 겹쳐도 1회만
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const data = await api<DashboardSummary>('/dashboard/');
|
||||
internal.set(data);
|
||||
} catch (err) {
|
||||
console.error('대시보드 폴링 실패:', err);
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
24
frontend/src/lib/stores/toast.ts
Normal file
24
frontend/src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// Toast 시스템 — UI state(drawer/modal)와 분리된 알림 전용 store
|
||||
export 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));
|
||||
}
|
||||
57
frontend/src/lib/stores/uiState.svelte.ts
Normal file
57
frontend/src/lib/stores/uiState.svelte.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 중앙 UI layer 상태 — drawer는 단일 slot, modal은 stack.
|
||||
// drawer/modal/toast 동시 오픈 시 focus/scroll/Esc 충돌을 차단하기 위한 single source.
|
||||
// (toast는 별도 store. drawer가 persistent inline panel(예: xl+ meta rail)일 때는
|
||||
// 여기 시스템 밖이다 — 그저 레이아웃의 일부.)
|
||||
|
||||
type Drawer = { id: 'sidebar' | 'meta' } | null;
|
||||
type Modal = { id: string };
|
||||
|
||||
class UIState {
|
||||
drawer = $state<Drawer>(null);
|
||||
modalStack = $state<Modal[]>([]);
|
||||
|
||||
// ── Drawer (단일 slot) ──────────────────────────────
|
||||
openDrawer(id: 'sidebar' | 'meta') {
|
||||
// 새 drawer 열면 이전 drawer는 자동으로 사라진다 (단일 slot)
|
||||
this.drawer = { id };
|
||||
}
|
||||
closeDrawer() {
|
||||
this.drawer = null;
|
||||
}
|
||||
isDrawerOpen(id: 'sidebar' | 'meta') {
|
||||
return this.drawer?.id === id;
|
||||
}
|
||||
|
||||
// ── Modal (stack — confirm 위에 nested 가능) ─────────
|
||||
openModal(id: string) {
|
||||
this.modalStack = [...this.modalStack, { id }];
|
||||
}
|
||||
closeTopModal() {
|
||||
this.modalStack = this.modalStack.slice(0, -1);
|
||||
}
|
||||
closeModal(id: string) {
|
||||
this.modalStack = this.modalStack.filter((m) => m.id !== id);
|
||||
}
|
||||
isModalOpen(id: string) {
|
||||
return this.modalStack.some((m) => m.id === id);
|
||||
}
|
||||
// 특정 modal의 stack 인덱스 (z-index 계산용; 없으면 -1)
|
||||
modalIndex(id: string) {
|
||||
return this.modalStack.findIndex((m) => m.id === id);
|
||||
}
|
||||
get topModal() {
|
||||
return this.modalStack[this.modalStack.length - 1] ?? null;
|
||||
}
|
||||
|
||||
// ── 글로벌 Esc 핸들러 ────────────────────────────────
|
||||
// 우선순위: 가장 위 modal → drawer
|
||||
handleEscape() {
|
||||
if (this.modalStack.length > 0) {
|
||||
this.closeTopModal();
|
||||
} else if (this.drawer) {
|
||||
this.closeDrawer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new UIState();
|
||||
66
frontend/src/lib/utils/domainSlug.ts
Normal file
66
frontend/src/lib/utils/domainSlug.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ai_domain 값을 @theme의 bg-domain-{slug} 토큰 슬러그로 매핑.
|
||||
// 백엔드는 'Knowledge/Philosophy', 'Knowledge/Industrial_Safety', 'Reference', null
|
||||
// 형태를 모두 보낼 수 있으므로 leaf만 잘라 lowercase하고 industrial_safety→safety로 재매핑.
|
||||
//
|
||||
// Phase C: 대시보드 today_by_domain 누적바, recent_documents accent에서 사용.
|
||||
// 추후 phase에서도 도메인 색상 칩이 필요해지면 여기로 일원화.
|
||||
|
||||
export type DomainSlug =
|
||||
| 'philosophy'
|
||||
| 'language'
|
||||
| 'engineering'
|
||||
| 'safety'
|
||||
| 'programming'
|
||||
| 'general'
|
||||
| 'reference';
|
||||
|
||||
const LEAF_TO_SLUG: Record<string, DomainSlug> = {
|
||||
philosophy: 'philosophy',
|
||||
language: 'language',
|
||||
engineering: 'engineering',
|
||||
industrial_safety: 'safety',
|
||||
safety: 'safety',
|
||||
programming: 'programming',
|
||||
general: 'general',
|
||||
reference: 'reference',
|
||||
};
|
||||
|
||||
/** 'Knowledge/Philosophy' → 'philosophy', 'Reference' → 'reference', null → null */
|
||||
export function domainSlug(domain: string | null | undefined): DomainSlug | null {
|
||||
if (!domain) return null;
|
||||
const leaf = domain.includes('/') ? domain.split('/').pop()! : domain;
|
||||
return LEAF_TO_SLUG[leaf.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
const SLUG_BG_CLASS: Record<DomainSlug, string> = {
|
||||
philosophy: 'bg-domain-philosophy',
|
||||
language: 'bg-domain-language',
|
||||
engineering: 'bg-domain-engineering',
|
||||
safety: 'bg-domain-safety',
|
||||
programming: 'bg-domain-programming',
|
||||
general: 'bg-domain-general',
|
||||
reference: 'bg-domain-reference',
|
||||
};
|
||||
|
||||
/** bg-domain-{slug} 클래스. 미지정/미매핑은 bg-default. */
|
||||
export function domainBgClass(domain: string | null | undefined): string {
|
||||
const slug = domainSlug(domain);
|
||||
return slug ? SLUG_BG_CLASS[slug] : 'bg-default';
|
||||
}
|
||||
|
||||
const SLUG_LABEL: Record<DomainSlug, string> = {
|
||||
philosophy: 'Philosophy',
|
||||
language: 'Language',
|
||||
engineering: 'Engineering',
|
||||
safety: 'Industrial Safety',
|
||||
programming: 'Programming',
|
||||
general: 'General',
|
||||
reference: 'Reference',
|
||||
};
|
||||
|
||||
/** 표시용 라벨. domain이 null이면 '미분류'. */
|
||||
export function domainLabel(domain: string | null | undefined): string {
|
||||
const slug = domainSlug(domain);
|
||||
if (!slug) return domain ?? '미분류';
|
||||
return SLUG_LABEL[slug];
|
||||
}
|
||||
35
frontend/src/lib/utils/mergeDoc.ts
Normal file
35
frontend/src/lib/utils/mergeDoc.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// PATCH 응답 또는 (향후 도입 시) SSE 푸시로 들어온 doc을 로컬 cache의 doc과
|
||||
// 합칠 때 사용. updated_at 비교로 stale 갱신을 무시 → optimistic update가
|
||||
// 더 fresh한 데이터 위에 덮어쓰는 race를 차단. plan 5대 원칙 #6.
|
||||
//
|
||||
// 더 강력한 보호는 backend ETag/If-Match 도입이 필요(plan 부록 #8).
|
||||
// 이 헬퍼는 그 사이의 가장 흔한 race(stale PATCH가 fresh push 위에 덮어쓰기)를 막는다.
|
||||
//
|
||||
// 사용 예:
|
||||
// documents = mergeDoc(documents, response); // PATCH 응답
|
||||
// documents = dropDoc(documents, id); // 삭제 / Inbox 승인 후 제거
|
||||
|
||||
export interface DocLike {
|
||||
id: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function mergeDoc<T extends DocLike>(list: T[], incoming: T): T[] {
|
||||
const idx = list.findIndex((d) => d.id === incoming.id);
|
||||
if (idx === -1) {
|
||||
// 새 doc — 맨 앞에 삽입
|
||||
return [incoming, ...list];
|
||||
}
|
||||
const current = list[idx];
|
||||
if (new Date(incoming.updated_at) < new Date(current.updated_at)) {
|
||||
// stale — 무시
|
||||
return list;
|
||||
}
|
||||
const next = [...list];
|
||||
next[idx] = incoming;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function dropDoc<T extends { id: number }>(list: T[], id: number): T[] {
|
||||
return list.filter((d) => d.id !== id);
|
||||
}
|
||||
33
frontend/src/lib/utils/pLimit.ts
Normal file
33
frontend/src/lib/utils/pLimit.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 동시 실행 N개로 제한하는 작은 헬퍼 (외부 의존성 추가 없음).
|
||||
// 일괄 PATCH/DELETE 같은 batch 작업에서 GPU 서버 / API / SSE에 부하 폭탄을
|
||||
// 던지지 않도록 사용. plan 5대 원칙 #4.
|
||||
//
|
||||
// 사용 예:
|
||||
// const limit = pLimit(5);
|
||||
// const results = await Promise.allSettled(
|
||||
// ids.map((id) => limit(() => api(`/documents/${id}`, { method: 'PATCH', body })))
|
||||
// );
|
||||
|
||||
export function pLimit(concurrency: number) {
|
||||
let active = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
const next = () => {
|
||||
active--;
|
||||
if (queue.length > 0) {
|
||||
queue.shift()!();
|
||||
}
|
||||
};
|
||||
|
||||
return async function run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (active >= concurrency) {
|
||||
await new Promise<void>((resolve) => queue.push(resolve));
|
||||
}
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
118
frontend/src/routes/+layout.svelte
Normal file
118
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import '../app.css';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/setup', '/__styleguide'];
|
||||
const NO_CHROME_PATHS = ['/login', '/setup', '/__styleguide'];
|
||||
|
||||
// toast 의미 토큰 매핑 (A-8 B3)
|
||||
const TOAST_CLASS = {
|
||||
success: 'bg-success/10 text-success border-success/30',
|
||||
error: 'bg-error/10 text-error border-error/30',
|
||||
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||
info: 'bg-accent/10 text-accent border-accent/30',
|
||||
};
|
||||
|
||||
let authChecked = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isAuthenticated) {
|
||||
await tryRefresh();
|
||||
}
|
||||
authChecked = true;
|
||||
});
|
||||
|
||||
$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') {
|
||||
// 5대 원칙 #2 — 글로벌 Esc는 uiState에 위임 (modal stack → drawer 우선순위 자동 처리)
|
||||
ui.handleEscape();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if !authChecked}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="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-default bg-surface shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if !$page.url.pathname.startsWith('/news')}
|
||||
<IconButton
|
||||
icon={Menu}
|
||||
size="sm"
|
||||
aria-label="사이드바 토글"
|
||||
onclick={() => ui.openDrawer('sidebar')}
|
||||
/>
|
||||
{/if}
|
||||
<a href="/" class="text-sm font-semibold hover:text-accent">PKM</a>
|
||||
<span class="text-dim text-xs">/</span>
|
||||
<a href="/documents" class="text-xs hover:text-accent">문서</a>
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" href="/news">뉴스</Button>
|
||||
<Button variant="ghost" size="sm" href="/inbox">Inbox</Button>
|
||||
<Button variant="ghost" size="sm" href="/settings">설정</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => logout()}>로그아웃</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<!-- 사이드바 드로어 (모든 화면에서 동일, uiState 단일 slot 관리) -->
|
||||
<Drawer id="sidebar" side="left" width="sidebar" aria-label="사이드바">
|
||||
<Sidebar />
|
||||
</Drawer>
|
||||
|
||||
<!-- 콘텐츠 -->
|
||||
<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" role="status" aria-live="polite">
|
||||
{#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 border {TOAST_CLASS[toast.type] || TOAST_CLASS.info}"
|
||||
onclick={() => removeToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user