147 Commits

Author SHA1 Message Date
Hyungi Ahn
e3ebbe105b merge: origin/main (search Phase 1.2-G + TEI reranker) → design-system
- 백엔드 hybrid retrieval (doc + chunks) + embedding 입력 강화
- TEI reranker 1.7 배포 수정
- frontend 무관, z-index hotfix 와 충돌 없음
2026-04-08 13:32:26 +09:00
Hyungi Ahn
7cf7662ba5 fix(ui): Tailwind v4 z-index 유틸리티 누락 — @utility 로 등록
## 증상
/documents 페이지에서 사이드바 drawer 를 열면 뒤의 필터 칩 row
(`+ 태그`, `+ 형식`) 와 sticky 선택 toolbar 가 사이드바 위로 **그대로
비쳐 보이는** 시각 버그. 사이드바 tree 내용과 섞여 완전히 사용 불가.

## 근본 원인
`@theme { --z-dropdown: 30; --z-drawer: 40; --z-toast: 60 }` 로 정의했지만,
Tailwind v4 는 `--z-*` 를 utility namespace 로 인식하지 않음. 그래서 Drawer
및 페이지의 `class="... z-drawer"`, `class="... z-dropdown"` 이
컴파일 CSS 에 **아예 없는 클래스 (.z-drawer 등 생성 안 됨)** → `z-index:
auto` 로 fallback.

CSS 2.1 stacking 규칙상 positioned z-auto 끼리는 **DOM order** 로 paint 됨.
layout.svelte 의 Drawer 가 먼저 렌더되고 페이지 `<slot/>` 의 `.relative`
필터 칩 popover 컨테이너가 나중에 렌더 → 필터 칩이 사이드바 위에 그려짐.

`--z-modal` 만 살아남은 이유: Modal.svelte 가 `calc(var(--z-modal) + ...)`
로 inline style 에서 실제 var() 참조해서 Tailwind 가 tree-shaking 에서
제외함.

## 수정
`frontend/src/app.css` 의 `@theme` 블록 바로 아래에 Tailwind v4
`@utility` directive 로 4개 유틸리티 명시 등록:

```css
@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); }
```

var() 참조 덕분에 `--z-*` 변수도 tree-shaking 에서 제외됨.

## 다른 파일 변경 없음
Drawer.svelte, documents/+page.svelte, inbox/+page.svelte, Modal.svelte
의 기존 클래스 사용부는 **한 글자도 수정 안 함**. @utility 등록만으로
자동 재활성.

## 검증
- npm run build 통과
- 컴파일 CSS 에 .z-drawer/.z-dropdown/.z-modal 클래스 실제 생성 확인
  (.z-toast 는 소스 사용부가 없어 JIT 제외, 필요 시 자동 생성)
- --z-dropdown/--z-drawer/--z-modal/--z-toast 4개 모두 :root 에 emit
- lint:tokens 168 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:28:54 +09:00
Hyungi Ahn
3bf6193337 fix(deploy): TEI 1.5 → 1.7 (1.5는 reranker 모델 다운로드 버그)
TEI 1.5 첫 시도 시 'builder error: relative URL without a base' 에러로
BAAI/bge-reranker-v2-m3 metadata 다운로드 실패. TEI 1.5의 알려진 버그.

해결: TEI 1.7로 업그레이드 (sequence-classification reranker 모델 지원 개선).
2026-04-08 13:18:37 +09:00
Hyungi Ahn
e0f928f429 feat(deploy): Phase 1.3 reranker (TEI bge-reranker-v2-m3) 서비스 추가
docker-compose.yml에 reranker 서비스 추가:
- image: ghcr.io/huggingface/text-embeddings-inference:1.5
- MODEL_ID=BAAI/bge-reranker-v2-m3
- MAX_BATCH_TOKENS=8192, MAX_CONCURRENT_REQUESTS=4
- GPU 1개 할당 (RTX 4070 Ti Super, CUDA 13.0)
- expose 80만 (host 노출 X, internal network 전용)
- reranker_cache volume으로 모델 영속화
- fastapi가 depends_on 안 함 → 단독 시작 가능, reranker 없어도 fastapi 동작
  (rerank_service가 RRF fallback)

다음 단계:
- GPU에서 docker pull로 호환성 확인
- docker compose up -d reranker → warmup
- config.yaml의 rerank.endpoint를 http://reranker:80/rerank로 갱신 (GPU 직접)
- fastapi rebuild + 평가셋 측정 (rerank=true)
2026-04-08 13:16:37 +09:00
Hyungi Ahn
25ef3996ec feat(chunk): Phase 1.2-G embedding 입력 강화 (title + section + text)
Phase 1.2-G hybrid retrieval 측정 결과 Recall 0.66 정체 + 진단:

직접 nl 쿼리 시도 결과 일부 정답 doc(3854, 3981, 3982, 3920, 3921)이
top-100에도 못 들어옴. doc은 corpus + chunks + embedding 모두 정상.

진짜 원인: 자연어 query ↔ 법령 조항 의미 거리 + 짧은 본문 embedding signal 약함.
- query: '유해화학물질을 다루는 회사가 지켜야 할 안전 의무'
- 본문: '화학물질관리법 제4장 유해화학물질 영업자'
- bge-m3 입장: chunk text만으로는 같은 의미인지 못 알아봄

해결: chunks embedding 입력에 doc.title + section_title 포함.
- before: embed(c['text'])
- after:  embed('[제목] {title}\n[섹션] {section}\n[본문] {text}')

기대 효과:
- 짧은 조항 문서 매칭 회복 (3920/3921 등 300자대)
- 자연어 query → 법령 조항 의미 매칭 개선
- Recall 0.66 → 0.72~0.78

영향: chunks embedding 차원/구조 변경 X — 입력 텍스트 prefix만 다름.
재인덱싱 1회로 모든 chunks 재생성 필요.
2026-04-08 13:08:23 +09:00
Hyungi Ahn
2ca67dacea feat(search): Phase 1.2-G hybrid retrieval (doc + chunks)
Phase 1.2-C 평가셋: chunks-only Recall 0.788 → 0.660 catastrophic.
ivfflat probes 1 → 10 → 20 진단 결과 잔여 차이는 chunks vs docs embedding의
본질적 차이 (segment 매칭 vs 전체 본문 평균).

해결: doc + chunks hybrid retrieval (정석).

신규 구조:
- search_vector(): 두 SQL을 asyncio.gather로 병렬 호출
- _search_vector_docs(): documents.embedding cosine top N (recall robust)
- _search_vector_chunks(): document_chunks.embedding window partition
  (doc당 top 2 chunks, ivfflat top inner_k 후 ROW_NUMBER PARTITION)
- _merge_doc_and_chunk_vectors(): 가중치 + dedup
  - chunk score * 1.2 (segment 매칭 더 정확)
  - doc score * 1.0 (recall 보완)
  - doc_id 기준 dedup, chunks 우선

데이터 흐름:
  1. query embedding 1번 (bge-m3)
  2. asyncio.gather([_docs_call(), _chunks_call()])
  3. _merge_doc_and_chunk_vectors → list[SearchResult]
  4. compress_chunks_to_docs (그대로 사용)
  5. fusion (그대로)
  6. (Phase 1.3) chunks_by_doc 회수 → reranker

검증 게이트 (회복 목표):
- Recall@10 ≥ 0.75 (baseline 0.788 - 0.04 이내)
- unique_docs per query ≥ 8
- natural_language_ko Recall ≥ 0.65
- latency p95 < 250ms
2026-04-08 13:02:23 +09:00
Hyungi Ahn
2cfe4b126a merge: origin/main (search Phase 1.2-C) → design-system
- 백엔드 search/chunk 개선 (Phase 1.2-AB → 1.2-C) 통합
- frontend와 충돌 없음 (backend만 변경)
- Phase C/D/F/E 프런트엔드 작업 유지
2026-04-08 12:52:59 +09:00
Hyungi Ahn
4938b25d12 feat(ui): Phase E — PreviewPanel 분할 + detail inline + viewer Tabs
E.1 PreviewPanel 7개 editors/* 분할:
- frontend/src/lib/components/editors/ 신설 (7개 컴포넌트):
  * NoteEditor — 사용자 메모 편집
  * EditUrlEditor — 외부 편집 URL (Synology Drive 등)
  * TagsEditor — 태그 추가/삭제
  * AIClassificationEditor — AI 분류 read-only 표시
    (breadcrumb + document_type + confidence tone Badge + importance)
  * FileInfoView — 파일 메타 dl
  * ProcessingStatusView — 파이프라인 단계 status dl
  * DocumentDangerZone — 삭제 (ConfirmDialog 프리미티브 + id 고유화)
- PreviewPanel.svelte 344줄 → 60줄 얇은 wrapper로 축소
  (header + 7개 editors 조합만)
- DocumentMetaRail (D.1)과 detail 페이지(E.2)가 동일 editors 재사용

E.2 detail 페이지 inline 편집:
- documents/[id]/+page.svelte: 기존 read-only 메타 패널 전면 교체
- 오른쪽 aside = 7개 editors 스택 (Card 프리미티브로 감쌈)
- 왼쪽 affordance row: Synology 편집 / 다운로드 / 링크 복사
- 삭제는 DocumentDangerZone이 담당 (ondelete → goto /documents)
- loading/error 상태도 EmptyState 프리미티브로 교체
- marked/DOMPurify renderer 유지, viewer 분기 그대로

E.3 관련 문서 stub:
- detail 페이지 오른쪽 aside에 "관련 문서" Card
- EmptyState "추후 지원" + TODO(backend) GET /documents/{id}/related

E.4 DocumentViewer Tabs 프리미티브:
- Markdown 편집 모드의 편집/미리보기 토글 → Tabs 프리미티브
- 키보드 nav (←→/Home/End), ARIA tablist/tab/tabpanel 자동 적용

검증:
- npm run build 통과 (editors/* 7개 모두 clean, $state 초기값
  warning은 빈 문자열로 초기화하고 $effect로 doc 동기화해 해결)
- npm run lint:tokens 204 → 168 (detail 페이지 + PreviewPanel 전면
  token 기반 재작성으로 -36)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:51:10 +09:00
Hyungi Ahn
f4f9de4402 fix(search): Phase 1.2-C doc-level aggregation으로 다양성 회복
Phase 1.2-C 평가셋: Recall 0.788 → 0.531, natural_language 0.73 → 0.07.

진단:
  단순 chunk top-N(limit*5=25)으로 raw chunks 가져왔는데 같은 doc의
  여러 chunks가 상위에 몰림 → unique doc 다양성 붕괴.
  warm test debug: 'chunks raw=16 compressed=5 unique_docs=10'

해결 (사용자 추천 C):
  Window function ROW_NUMBER() PARTITION BY doc_id로 doc당 top 2 chunks만 반환.

SQL 흐름:
  1. inner CTE topk: ivfflat 인덱스로 top inner_k chunks 빠르게
     (inner_k = max(limit*10, 200))
  2. ranked CTE: PARTITION BY doc_id ORDER BY dist ROW_NUMBER
  3. outer: rn <= 2 (doc당 max 2 chunks) + JOIN documents
  4. limit = limit * 4 (chunks 단위, ~limit*2 unique docs)

reranker 호환:
  doc당 max 2 chunks 그대로 반환 → chunks_by_doc 보존
  compress_chunks_to_docs는 그대로 동작 (best chunk per doc)
  Phase 1.3 reranker가 chunks_by_doc에서 raw chunks 회수 가능

핵심 원칙: vector retrieval은 chunk로 찾고 doc으로 선택해야 한다.
2026-04-08 12:47:22 +09:00
Hyungi Ahn
7a38c95f3f feat(ui): Phase F — Inbox 분류 UX + review_status hotfix
F.1 review_status 버그 fix + 승인 UX 가드:
- PATCH body에 review_status: 'approved' 누락 버그 수정 (hotfix)
  → 기존에는 승인해도 문서가 inbox에서 사라지지 않던 증상 해결
- isApprovable(doc): effective domain(override or original)이 비어 있으면 false
- 미분류 행: 체크박스 disabled + ⚠ "도메인 선택 필요" Badge 인라인 표시
  + 카드 border-warning 강조. 클릭 자체가 막힘 (toast 경고 아님)

F.2 runes 마이그레이션 + 프리미티브 전환:
- let → $state/$derived/$derived.by, onMount 유지
- Card/Button/Select/TextInput/Badge/EmptyState/Skeleton/Modal/
  ConfirmDialog/FormatIcon/TagPill 프리미티브로 전면 재작성
- 기존 bg-[var(--*)] 클러스터 전부 제거

F.3 필터 row:
- source / format Select 드롭다운 (현재 documents에서 동적 집계)
- confidence는 백엔드 ai_confidence 필드 추가 대기 — 주석 TODO(backend)

F.4 처리 단계 가시성:
- extracted_at / ai_processed_at / embedded_at 3개 Badge
  (success tone = 완료, neutral = 대기) + source_channel 표시
- backend 전용 endpoint 없이 기존 응답 필드만으로 stop-gap

F.5 행별 override:
- Map<id, { domain?, tags? }> 로컬 state
- 도메인 select 변경 시 overrides에 기록, 원복 버튼으로 clear
- 승인(approveOne) 시점에 override를 PATCH body에 병합
- 도메인 override로 미분류 → 분류 전환 가능 (바로 승인 가능해짐)

F.6 배치 override + 재시도 stub:
- 선택 toolbar: 일괄 도메인 / 일괄 태그 modal
- 배치 override는 로컬 Map만 갱신, 실제 PATCH는 승인 시 1회
- 재시도 버튼: disabled stub (TODO backend POST /queue/retry)
- 선택 상한 50건, pLimit(5) + Promise.allSettled 일괄 승인

검증:
- npm run build 통과 (a11y 경고 fix: label → span + aria-label)
- npm run lint:tokens 229 → 204 (inbox 레거시 var() 토큰 전부 제거, -25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:46:03 +09:00
Hyungi Ahn
d83842ccd8 feat(ui): Phase D.4/D.5 — keyboard nav + density toggle
D.4 useListKeyboardNav:
- 신규: frontend/src/lib/composables/useListKeyboardNav.svelte.ts
  j/k/Arrow/Enter/Esc, isTypingTarget 가드 (input/textarea/select/
  contenteditable 포커스 시 비활성)
- documents/+page.svelte: kbIndex $state, kbSelectedId $derived,
  items 변경 시 clamp, URL 변경 시 0 리셋
- DocumentTable/DocumentCard: kbSelectedId prop → data-kb-selected
  속성 + ring-accent-ring 시각 표시
- scrollSelectedIntoView: queueMicrotask + querySelector로 현재
  커서를 뷰포트 내로 스크롤 (block: nearest)

D.5 Table density:
- DocumentTable: density prop (compact/comfortable), rowPaddingClass
  ($derived: py-1 | py-2.5), rowTextClass (text-[10px] | text-xs)
- documents/+page.svelte: tableDensity $state, toggleDensity 헬퍼,
  localStorage.tableDensity persistent, 테이블 뷰에서만 토글 버튼
  노출 (Rows2/Rows3 아이콘)
- 뷰 모드 버튼도 token 기반으로 리팩토링

검증:
- npm run build 통과
- npm run lint:tokens 231 → 229 (뷰 모드 버튼 token swap으로 -2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:42:03 +09:00
Hyungi Ahn
76e723cdb1 feat(search): Phase 1.3 TEI reranker 통합 (코드 골격)
데이터 흐름 원칙: fusion=doc 기준 / reranker=chunk 기준 — 절대 섞지 말 것.

신규/수정:
- ai/client.py: rerank() 메서드 추가 (TEI POST /rerank API)
- services/search/rerank_service.py:
    - rerank_chunks() — asyncio.Semaphore(2) + 5s soft timeout + RRF fallback
    - _make_snippet/_extract_window — title + query 중심 200~400 토큰
      (keyword 매치 없으면 첫 800자 fallback)
    - apply_diversity() — max_per_doc=2, top score>=0.90 unlimited
    - warmup_reranker() — 10회 retry + 3초 간격 (TEI 모델 로딩 대기)
    - MAX_RERANK_INPUT=200, MAX_CHUNKS_PER_DOC=2 hard cap
- services/search_telemetry.py: compute_confidence_reranked() — sigmoid score 임계값
- api/search.py:
    - ?rerank=true|false 파라미터 (기본 true, hybrid 모드만)
    - 흐름: fused_docs(limit*5) → chunks_by_doc 회수 → rerank_chunks → apply_diversity
    - text-only 매치 doc은 doc 자체를 chunk처럼 wrap (fallback)
    - rerank 활성 시 confidence는 reranker score 기반
- tests/search_eval/run_eval.py: --rerank true|false 플래그

GPU 적용 보류:
- TEI 컨테이너 추가 (docker-compose.yml) — 별도 작업
- config.yaml rerank.endpoint 갱신 — GPU 직접 (commit 없음)
- 재인덱싱 완료 후 build + warmup + 평가셋 측정
2026-04-08 12:41:47 +09:00
Hyungi Ahn
b80116243f feat(search): Phase 1.2-C chunks 기반 vector retrieval + raw chunks 보존
retrieval_service.search_vector를 documents.embedding → document_chunks.embedding로 전환.
fetch_limit = limit*5로 raw chunks를 넓게 가져온 후 doc 기준 압축.

신규: compress_chunks_to_docs(chunks, limit) → (doc_results, chunks_by_doc)
- doc_id 별 best score chunk만 doc_results (fusion 입력)
- 모든 raw chunks는 chunks_by_doc dict에 보존 (Phase 1.3 reranker용)
- '같은 doc 중복으로 RRF가 false boost' 방지

SearchResult: chunk_id / chunk_index / section_title optional 필드 추가.
- text 검색 결과는 None (doc-level)
- vector 검색 결과는 채워짐 (chunk-level)

search.py 흐름:
1. raw_chunks = await search_vector(...)
2. vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
3. fusion(text_results, vector_results) — doc 기준
4. (Phase 1.3) chunks_by_doc → reranker — chunk 기준

debug notes: raw=N compressed=M unique_docs=K로 흐름 검증.

데이터 의존: 재인덱싱(reindex_all_chunks.py 진행 중) 완료 후 평가셋으로 검증.
2026-04-08 12:36:47 +09:00
Hyungi Ahn
3375a5f1b1 feat(ui): Phase D.3 — multi-select + batch actions (pLimit)
- DocumentTable/DocumentCard: selectable/selectedIds/onselectionchange props
  * Table: 왼쪽 6px 너비 체크박스 컬럼
  * Card: 좌상단 absolute 체크박스 (hover 또는 selected 시 표시)
  * 체크박스 onclick stopPropagation으로 행 select와 분리
- documents/+page.svelte:
  * selectedIds = $state(new Set()), URL/필터 변경 시 자동 초기화
  * sticky 선택 toolbar (selection > 0): N건 / 전체 선택 / 선택 해제 /
    일괄 도메인 / 일괄 태그 / 일괄 삭제
  * 50건 상한 UI 가드 (초과 시 경고 + 모든 bulk 버튼 disabled)
  * Bulk modals:
    - 일괄 도메인: Select (Knowledge/* 6종 + Reference)
    - 일괄 태그: TextInput (기존 ai_tags에 추가, 중복 skip)
    - 일괄 삭제: ConfirmDialog (delete_file=true)
  * runBulk 헬퍼: pLimit(5) + Promise.allSettled로 concurrency 제한,
    성공/실패 카운트 toast (5대 원칙 #4)
  * TODO(backend): POST /documents/batch-update — 단일 트랜잭션으로 교체

검증:
- npm run build 통과 (새 경고 없음, label → span 교체로 a11y clean)
- npm run lint:tokens 231 유지 (신규 코드 위반 0)
- 기존 pLimit.ts (Phase A 머지) 재사용, 외부 의존성 없음

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:34:02 +09:00
Hyungi Ahn
42dfe82c9b feat(chunk): Phase 1.2-E reindex 스크립트 추가
tests/scripts/reindex_all_chunks.py — 전체 documents chunk 재인덱싱 도구.

핵심 요건 (사용자 정의):
- asyncio.Semaphore(N) — 동시 처리 수 제한 (기본 3, Ollama bge-m3 부하 조절)
- checkpoint resume — JSON 파일 atomic swap, 중간 실패/중단 후 재시작 가능
- rate limiting — 작업 간 sleep 0.1초 (Ollama API 보호)
- 진행 로그 — [REINDEX] N/total (P%) ETA: ... fails: N (~2% 단위)

CLI:
- --concurrency, --checkpoint, --rate-limit, --limit (dry-run), --skip-existing

야간 배치 (00:00~06:00):
  PYTHONPATH=app .venv/bin/python tests/scripts/reindex_all_chunks.py \
    --concurrency 3 --checkpoint checkpoints/reindex.json \
    > logs/reindex.log 2>&1 &
2026-04-08 12:31:29 +09:00
Hyungi Ahn
8f312f50a7 feat(ui): Phase D.2 — filter chips + URL sync
- 검색바 아래 새 필터 칩 row: domain/tag/format/source 활성 필터를
  인라인 칩으로 렌더, 각 칩에 X 버튼으로 제거.
- `+ 태그` popover: 현재 결과의 상위 20개 태그 클라이언트 집계
  (items.flatMap(d => d.ai_tags).counts + sort) → 선택 시 ?tag=...
- `+ 형식` popover: FORMATS 화이트리스트 (pdf/hwp/hwpx/md/docx/xlsx/png/jpg)
  → 선택 시 ?format=...
- 바깥 클릭으로 popover 자동 close ($effect + document listener)
- filterFormat $derived + loadDocuments params 확장 + hasActiveFilters 확장
- 결과 헤더는 카운트만 남기고 필터 표시/초기화는 칩 row로 이전 (중복 제거)
- addFilter/removeFilter 헬퍼로 URL 라운드트립 관리 (domain 제거 시 sub_group 함께)
- 백엔드 변경 없음 (GET /documents/가 이미 tag/format 지원)

검증:
- npm run build 통과
- npm run lint:tokens 236 → 231 (신규 코드 0 위반, 결과 헤더 리팩토링으로
  5건 organically 감소)
- popover 키보드 a11y (role=listbox/option, aria-expanded, aria-selected)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:27:13 +09:00
Hyungi Ahn
731d1396e8 fix(chunk): _chunk_legal 영어 법령 sliding window fallback
영어/외국 법령(ai_domain Foreign_Law 등)은 '제N조' 패턴이 없어 split 결과가
1개 element만 나옴 → 서문 chunk(첫 1500자)만 생성되고 본문 대부분 손실.

발견: doc 3759 (Industrial Safety, 93KB 영어) → 1개 chunk만 생성.

수정: parts split 결과가 1개 이하면 _chunk_sliding fallback 호출.
한국어 법령(제N조 패턴 있음)은 기존 분할 로직 그대로 작동.

Phase 1.2-D smoke test에서 발견. 재인덱싱 전 fix 필수.
2026-04-08 12:26:38 +09:00
Hyungi Ahn
ffac4975b9 feat(ui): Phase D.1 — 3-panel layout + DocumentMetaRail + useMedia
- 가로 flex 최상위 + 가운데 flex-1 (기존 list/viewer 세로 split 그대로 보존)
- xl+ (≥1280px): 우측 320px persistent rail, 접기 시 40px sliver.
  localStorage.metaRailOpen 으로 상태 유지.
- < xl : 기존 수동 drawer 제거하고 ui/Drawer primitive + uiState 사용.
- 리사이즈 시 xl+ 진입하면 drawer 자동 close (rail로 승계).
- handleKeydown → ui.handleEscape() 로 중앙화.
- ℹ 버튼 token 기반 재작성 (isXl 분기로 rail/drawer 토글).
- PreviewPanel.svelte 한 글자도 수정 없음 (Phase E 영역).

신규:
- frontend/src/lib/composables/useMedia.svelte.ts — matchMedia runes 컴포저블
- frontend/src/lib/components/DocumentMetaRail.svelte — PreviewPanel wrapper

검증:
- npm run build 통과
- npm run lint:tokens 241 → 236 (신규 코드 0 위반, 레거시 drawer/ℹ 버튼
  제거로 5건 organically 감소)
- PreviewPanel diff 0줄

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:18:06 +09:00
Hyungi Ahn
b3124928a6 feat(ui): Phase C — 대시보드 위젯 그리드 + dashboardSummary 공유 fetch
- dashboardSummary store 구독으로 SystemStatusDot과 fetch 1회 공유 (60초 폴링)
- Svelte 5 runes + Card/EmptyState/Skeleton/FormatIcon 프리미티브
- 12-col 그리드 (sm 1열 / md 2열 / lg 표 그대로):
  * 행1: stat 4장 (전체/Inbox/법령/시스템 상태)
  * 행2: 파이프라인 가로 막대 차트(8) + 오늘 도메인 누적바(4)
  * 행3: 최근 문서(8) + CalDAV stub(4)
- 신규 util: domainSlug.ts — ai_domain → bg-domain-{slug} + 라벨 매핑
- 새 코드에 bg-[var(--*)] 0건 (lint:tokens 통과)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:36 +09:00
Hyungi Ahn
f9af8dd355 fix(search): trigram threshold 0.3 → 0.15 (set_limit)
Phase 1.2-B 평가셋 결과 recall 0.788 → 0.750 회귀.
원인: trigram default threshold 0.3이 multi-token 쿼리에서 너무 엄격.

예: '이란 미국 전쟁 글로벌 반응' 같은 5단어 한국어 뉴스 쿼리는
title/ai_summary trigram 매칭이 거의 안 됨.

해결: search_text 시작 시 set_limit(0.15) 호출.
- trigram 매칭 더 관대 (recall ↑)
- precision은 ORDER BY similarity 가중 합산이 보정
- p95 latency 169ms 여유 충분 (목표 500ms)
2026-04-08 11:58:41 +09:00
Hyungi Ahn
ca3e1952d2 fix(search): trigram % operator escape 수정 (%% → %)
SQLAlchemy text() + asyncpg dialect에서 trigram operator 위치의 %%는
unescape 안 되어 'text %% unknown' 에러 발생. 단일 %로 변경.
ILIKE의 string literal 안의 %%는 PostgreSQL에서 두 wildcard로 동작했으나,
operator 위치는 escape 처리 경로가 다름.
2026-04-08 11:53:24 +09:00
Hyungi Ahn
fab3c81a0f fix(search): Phase 1.2-B UNION 분해로 trigram/FTS 인덱스 강제 활용
EXPLAIN 진단: OR 통합 WHERE는 PostgreSQL planner가 인덱스 결합 못 함
(small table 765 docs라 Seq Scan 선택). Filter 524ms.

해결: WHERE OR을 CTE candidates UNION으로 분해.
- title trigram     → idx_documents_title_trgm        (0.5ms)
- ai_summary trigram → idx_documents_ai_summary_trgm  (length>0 매치 추가)
- FTS @@            → idx_documents_fts_full          (0.05ms)

EXPLAIN 측정: 525ms → 26ms (95% 감소).
본 SELECT(similarity 가중 합산 + ORDER BY) 추가하면 100~150ms 예상.
2026-04-08 11:51:06 +09:00
Hyungi Ahn
22117a2a6d feat(search): Phase 1.2-AB — migration 016 + trigram retrieval
migration 016: documents FTS 확장 + trigram 인덱스 (1.5초 빌드)
- idx_documents_fts_full — title+ai_tags+ai_summary+user_note+extracted_text 통합 FTS
- idx_documents_title_trgm — title 단독 trigram
- idx_documents_extracted_text_trgm — 본문 trigram (NULL 제외)
- idx_documents_ai_summary_trgm — AI 요약 trigram
- CONCURRENTLY 불필요 (765 docs / 6.5MB)

retrieval_service.search_text: ILIKE 완전 제거 → trigram % + similarity()
- WHERE: title %, ai_summary %, FTS @@ (모두 인덱스 활용)
- ORDER BY: 5컬럼 similarity 가중 합산 + ts_rank * 2.0
- 가중치 그대로 (title 3.0 / tags 2.5 / note 2.0 / summary 1.5 / extracted 1.0)
- threshold default 0.3 (필요 시 set_limit으로 조정)

목표: text_ms 470ms → 100~200ms (ILIKE 풀스캔 제거 효과)
2026-04-07 14:36:22 +09:00
Hyungi Ahn
0c63c0b6ab feat(ui): Phase B — sidebar drawer + SystemStatusDot + 키보드 nav
- +layout.svelte: 햄버거 → IconButton, 우측 nav → Button ghost,
  sidebar overlay → Drawer (uiState 단일 slot),
  Esc 글로벌 핸들러 ui.handleEscape() 위임 (5대 원칙 #2)
- lib/stores/system.ts (신규): dashboardSummary writable + 60s 폴링,
  단일 fetch를 SystemStatusDot(B)와 dashboard(C)가 공유
- SystemStatusDot.svelte (신규): 8px 도트 + tooltip,
  failed > 0 → error / pending > 10 → warning / 그 외 → success
- Sidebar.svelte: 트리에 ArrowUp/Down 키보드 nav,
  활성 도메인 row에 aria-current="page"
2026-04-07 13:52:24 +09:00
Hyungi Ahn
a4eb71d368 feat(search): Phase 1.1a 모듈 분리 — services/search/ 디렉토리
검색 로직을 services/search/* 모듈로 분리. trigram 도입은 Phase 1.2 인덱스와 함께.

신규:
- services/search/{__init__,retrieval_service,rerank_service,query_analyzer,evidence_service,synthesis_service}.py
- retrieval_service는 search_text/search_vector 이전 (ILIKE 동작 그대로)
- 나머지는 Phase 1.3/2/3 placeholder

이동:
- services/search_fusion.py → services/search/fusion_service.py (R100)

수정:
- api/search.py — thin orchestrator로 축소 (251줄 → 178줄)

동작 변경 없음 — 구조만 분리. 회귀 검증 후 Phase 1.2 진입.
2026-04-07 13:46:04 +09:00
Hyungi Ahn
e0f45f9ce0 fix(deploy): primary endpoint 8801로 갱신
ai-gateway 환경 변수 PRIMARY_ENDPOINT를 8800 → 8801로 갱신.
mlx-proxy 경유 라우팅에 맞춰 정합성 확보.
2026-04-07 13:26:55 +09:00
Hyungi Ahn
378fbc7845 feat(chunk): Phase 0.1 chunk 인덱싱 — ORM/worker/migration 정리
GPU 서버에 untracked로만 존재하던 Phase 0.1 코드를 정식 commit:
- app/models/chunk.py — DocumentChunk ORM (country/source/domain 메타 포함)
- app/workers/chunk_worker.py — 6가지 chunking 전략 (legal/news/markdown/email/long_pdf/default)
- migrations/014_document_chunks.sql — pgvector + FTS + trigram 인덱스
- app/models/queue.py — ProcessingQueue enum에 'chunk' stage 추가
- app/workers/queue_consumer.py — chunk stage 등록, classify→[embed,chunk] 자동 연결

Phase 1 reranker 통합 작업의 전제 조건. document_chunks 테이블 기반 retrieval에 사용.
2026-04-07 13:26:37 +09:00
Hyungi Ahn
a2941487fe fix(documents): detail view에 raw markdown fetch fallback 추가
A-8 작전 후 사용자 보고: 마크다운 전체보기에서 "텍스트 추출 대기 중"
fallback이 뜨는 문서가 있음.

원인: split view의 DocumentViewer는 extracted_text 없으면 원본 .md
파일을 fetch해서 보여주는데, detail view (routes/documents/[id]/+page.svelte)
는 fetch fallback이 없어 즉시 fallback 메시지로 떨어짐. 두 view의 동작
불일치가 A-8 작업 중 사용자 시각 검증 과정에서 드러남.

A-8 회귀 아님 — 이 페이지는 routes 잔존 그룹(36 hits)이라 A-8 batch에서
한 줄도 변경 안 됨 (git diff fcce764..c294df5로 검증).

해결: DocumentViewer와 동일한 fetch fallback 로직을 detail view에도 추가.
fallback 우선순위:
  1. doc.extracted_text 있으면 사용
  2. 없으면 raw markdown fetch 시도
  3. 둘 다 없으면 "*텍스트 추출 대기 중*" 메시지

scope:
- script onMount: vt가 markdown/hwp-markdown이고 extracted_text 없으면
  /api/documents/{id}/file fetch
- template: renderMd fallback chain에 rawMarkdown 추가

routes 색상 토큰 swap (이 페이지의 36 hits)은 별도 이슈 — Phase D에서
정식 처리. 본 hotfix는 콘텐츠 표시 문제만 해결.
2026-04-07 12:55:59 +09:00
Hyungi Ahn
c294df5987 refactor(tokens): A-8 Batch 3 — PreviewPanel / DocumentViewer / +layout(toast)
가장 큰 위험 batch. Phase A 디자인 시스템 정착 마지막 mechanical refactor
(8 파일 8/8 누적 — core components 0 hit 달성).

PreviewPanel (53 hits → 0):
- bg-[var(--sidebar-bg)] → bg-sidebar (메인 aside)
- bg-[var(--bg)]         → bg-bg (input 배경)
- bg-[var(--surface)]    → bg-surface (hover)
- bg-[var(--accent)]     → bg-accent + hover:bg-accent-hover (저장 버튼)
- bg-[var(--error)]      → bg-error (삭제 확인)
- text/border 토큰 일괄 swap
- focus:border-accent (input)
- confidence 색상 (green/amber/red palette)은 plan B3 명시 없어 그대로

DocumentViewer (28 hits → 0):
- 뷰어 본체 bg-surface border-default
- 툴바 bg-sidebar
- 마크다운 편집 탭 bg-surface, edit textarea bg-bg
- 상태별 hover 토큰 swap
- 뉴스 article 태그 blue-900/30 그대로 (lint:tokens 미검출)

+layout.svelte (10 hits → 0):
- nav 잔여 var() (햄버거, 로고, 메뉴 링크) 토큰 swap
- 로딩 텍스트 text-dim
- toast 영역 의미 swap (plan B3 명시):
  * green-900/200  → bg-success/10 + text-success + border-success/30
  * red-900/200    → bg-error/10 + text-error + border-error/30
  * yellow-900/200 → bg-warning/10 + text-warning + border-warning/30
  * blue-900/200   → bg-accent/10 + text-accent + border-accent/30
- class:* 디렉티브 8개 → script TOAST_CLASS dict + dynamic class binding
  (svelte 5에서 슬래시 포함 클래스명을 class: 디렉티브로 못 씀)

검증:
- npm run lint:tokens : 360 → 269 (-91, B3 파일 0 hit)
- 누적 진행: 421 → 269 (-152 / 8 파일 완료, plan 정정 목표 정확 달성)
- npm run build       : 
- npx svelte-check    :  0 errors
- ⚠ 3-risk grep       : hover/border-border/var() 잔여 0건

A-8 종료 시점 상태:
- core components 8 파일: lint:tokens 0 hit 
- routes 7 파일 잔존 (~269): news 92, settings 47, documents/[id] 36,
  +page 28, documents 26, inbox 25, login 15
- lint:tokens 강제화 (pre-commit hook)는 Phase D + F 완료 후 별도 commit

플랜: ~/.claude/plans/compressed-churning-dragon.md §A.4 Batch 3
2026-04-07 12:14:48 +09:00
Hyungi Ahn
8ec89517ee refactor(tokens): A-8 Batch 2 — Sidebar / DocumentCard / DocumentTable
목록/사이드바 영역의 var() 토큰을 의미 토큰으로 swap. Phase A 디자인
시스템 정착의 두 번째 mechanical refactor batch (8 파일 중 5/8 누적).

Sidebar:
- bg-[var(--sidebar-bg)]  → bg-sidebar  (이름 변경)
- border-[var(--border)]  → border-default
- text-[var(--text)]      → text-text
- text-[var(--text-dim)]  → text-dim
- bg-[var(--accent)]/15   → bg-accent/15
- hover:bg-[var(--surface)] → hover:bg-surface
- domain 색상 inline style (DOMAIN_COLORS)은 그대로 유지

DocumentCard:
- bg/border/text/hover 토큰 일괄 swap
- DOMAIN_COLORS의 var(--domain-*) 유지 (plan B2 비고)
- blue-400/blue-900/30 (news icon, data_origin work) 그대로
  (lint:tokens 미검출 + plan 명시 없음)

DocumentTable:
- 헤더 + 행 + selected 상태 + 컬럼 텍스트 일괄 swap
- border-l-[var(--accent)] → border-l-accent
- border-default/30 opacity suffix (행 구분선) v4 시각 검증 필요

검증:
- npm run lint:tokens : 407 → 360 (-47, B2 파일 0 hit)
- npm run build       : 
- npx svelte-check    :  0 errors
- ⚠ 3-risk grep       : hover/border-border/var() 잔여 0건

플랜: ~/.claude/plans/compressed-churning-dragon.md §A.4 Batch 2
2026-04-07 12:04:37 +09:00
Hyungi Ahn
451c2181a0 refactor(tokens): A-8 Batch 1 — TagPill / UploadDropzone
색상 시스템을 의미 토큰으로 swap. Phase A 디자인 시스템 정착의 첫
mechanical refactor batch (8 파일 중 2 파일).

TagPill: 4가지 prefix별 색상을 의미 토큰화
- @상태/  amber → warning
- #주제/  blue  → accent
- $유형/  green → success
- !우선순위/ red → error
- fallback bg-[var(--border)] → bg-default,
           text-[var(--text-dim)] → text-dim

UploadDropzone: 드래그 오버레이 + 업로드 진행 영역
- bg-[var(--accent)]/10 → bg-accent/10
- bg-[var(--surface)]   → bg-surface
- border-[var(--border)] → border-default
- text-[var(--text-dim)] → text-dim
- 상태별 텍스트: text-success / text-error / text-accent / text-dim

검증:
- npm run lint:tokens : 421 → 407 (-14, B1 파일 0 hit)
- npm run build       : 
- npx svelte-check    :  0 errors
- ⚠ 3-risk grep       : hover/border-border/var() 잔여 0건

플랜: ~/.claude/plans/compressed-churning-dragon.md §A.4 Batch 1
참고: 본 plan은 161ff18(search Phase 0.5 commit)에 styleguide 2개 파일이
의도와 다르게 묶여 main에 들어왔음. 기능 영향 0 — Option A 결정으로
commit history 미수정.
2026-04-07 11:44:29 +09:00
Hyungi Ahn
fcce764e9d chore: pre A-8 token swap snapshot 2026-04-07 09:39:45 +09:00
Hyungi Ahn
6b2747de96 chore: allow /__styleguide in dev public paths
A-9 styleguide 라우트가 dev 환경에서 auth gate를 우회할 수 있도록
PUBLIC_PATHS / NO_CHROME_PATHS에 /__styleguide 추가.

production 영향 0 — +page.ts의 dev 가드가 비-dev 환경에서는 /로
redirect하므로 styleguide 라우트 자체에 도달 못 함.

A-8 토큰 swap 작전과 의미적으로 무관한 dev-only 변경이라
revert 단위 분리를 위해 단독 commit.
2026-04-07 09:39:39 +09:00
Hyungi Ahn
8021a1debd test(search): Phase 0.5 fusion 전략 A/B 비교 결과
23개 평가셋 × 3 전략(legacy/rrf/rrf_boost) 측정 + 분석.

핵심 발견:
- 전체 NDCG: legacy 0.705 → rrf 0.699 → rrf_boost 0.700 (미세 차이)
- RRF가 약간 나쁜 이유: kw_001(산업안전보건법 제6장)에서 RRF가 4041
  (근로기준법 안전과 보건)을 false positive로 promotion. NDCG 1.000→0.906.
- boost가 가치 입증한 사례: news_004(guerre en Iran)에서 RRF의 미스를
  완벽 보정해 legacy NDCG 복원.
- RRF의 진짜 가치는 Phase 1+ 다중 신호(trigram, reranker, multi-query)
  통합 시 발휘됨. 현 평가셋은 너무 단순해서 차이가 noise에 묻힘.

결정: rrf_boost를 default로 유지. Phase 1 후 재측정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:25:49 +09:00
Hyungi Ahn
161ff18a31 feat(search): Phase 0.5 RRF fusion + 강한 신호 boost
기존 weighted-sum merge를 Reciprocal Rank Fusion으로 교체.
정확 키워드 매치에서 RRF가 평탄화되는 문제는 boost로 보완.

신규 모듈 app/services/search_fusion.py:
- FusionStrategy ABC
- LegacyWeightedSum  : 기존 _merge_results 동작 (A/B 비교용)
- RRFOnly            : 순수 RRF, k=60
- RRFWithBoost       : RRF + title/tags/법령조문/high-text-score boost (default)
- normalize_display_scores: SearchResult.score를 [0..1] 랭크 기반 정규화
  (프론트엔드가 score*100을 % 표시하므로 RRF 원본 점수 노출 시 표시 깨짐)

search.py:
- ?fusion=legacy|rrf|rrf_boost 파라미터 (default rrf_boost)
- _merge_results 제거 (LegacyWeightedSum에 흡수)
- pre-fusion confidence: hybrid는 raw text/vector 신호로 계산
  (fused score는 fusion 전략마다 스케일이 달라 일관 비교 불가)
- timing에 fusion_ms 추가
- debug notes에 fusion 전략 표시

telemetry:
- compute_confidence_hybrid(text_results, vector_results) 헬퍼
- record_search_event에 confidence override 파라미터

run_eval.py:
- --fusion CLI 옵션, call_search 쿼리 파라미터에 전달

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:58:33 +09:00
Hyungi Ahn
1af94d1004 fix(search): timing 로그를 setup_logger로 출력
logging.getLogger("search")만 사용하면 uvicorn 기본 설정에서 INFO가
stdout에 안 나옴. 기존 core.utils.setup_logger 패턴 사용:
- logs/search.log 파일 핸들러
- stdout 콘솔 핸들러

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:43:26 +09:00
Hyungi Ahn
473e7e2e6d feat(search): Phase 0.4 debug 응답 옵션 + timing 로그
?debug=true로 호출 시 단계별 candidates + timing을 응답에 포함.
디버그 옵션과 별개로 모든 검색에 timing 라인을 구조화 로그로 출력
(사용자 feedback: 운영 관찰엔 debug 응답만으론 부족).

신규 응답 필드 (debug=true 시):
- timing_ms: text_ms / vector_ms / merge_ms / total_ms
- text_candidates / vector_candidates / fused_candidates (top 20)
- confidence (telemetry와 동일 휴리스틱)
- notes (예: vector 검색 실패 시 fallback 표시)
- query_analysis / reranker_scores: Phase 1/2용 placeholder

기본 응답(debug=false)은 변화 없음 (results, total, query, mode).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:41:33 +09:00
Hyungi Ahn
e104d1b47c feat: Layer 프리미티브 (Drawer / Modal / ConfirmDialog / Tabs)
UX/UI 개편 Phase A-7. uiState와 결합한 layer/dialog 컴포넌트.

신규 컴포넌트 (lib/components/ui/)
- Drawer.svelte: 단일 slot drawer (id: 'sidebar' | 'meta').
  ui.isDrawerOpen(id)로 표시 여부 결정. 새 drawer 열면 이전 drawer 자동 close.
  side(left/right) + width(sidebar/rail). backdrop 클릭으로 close.
  z-drawer 사용. 8대 원칙 #2.

- Modal.svelte: stack 지원 modal (5대 원칙 #2 — confirm 위에 nested 가능).
  native <dialog> 대신 div 기반 — top-layer가 단일이라 <dialog>로는 stack 불가.
  z-index = z-modal + (stackIndex * 2): backdrop과 panel을 별개의 stacking
  context로 두기 위해 *2. 최상단 modal만 focus trap + 키보드 nav 활성,
  아래는 inert 처리. 수동 Tab/Shift+Tab cycling.
  closable + IconButton(X) 헤더, footer snippet 지원.

- ConfirmDialog.svelte: Modal 위 얇은 wrapper. 삭제/되돌릴 수 없는 작업에
  사용. tone(danger/primary), confirmLabel/cancelLabel, onconfirm 콜백.
  ui.openModal(id)로 호출.

- Tabs.svelte: ARIA tablist + tab + tabpanel.
  좌우 화살표 / Home / End 키 nav, \$props.id() 기반 SSR-safe ID.
  tabs: { id, label, disabled? }[], value \$bindable.
  children snippet은 (activeId) => UI 시그니처 — DocumentViewer 편집/미리보기
  토글 등 단일 컨테이너 레이아웃에 쓰기 좋게 설계.

이로써 Phase A 프리미티브 13종 완비:
  Button, IconButton, Card, Badge, Skeleton, EmptyState,
  TextInput, Textarea, Select,
  Drawer, Modal, ConfirmDialog, Tabs.

모든 컴포넌트는 Svelte 5 runes mode strict, @theme 토큰만 사용,
focus-visible ring 통일, slot은 {@render children?.()}로 작성.

svelte-check: 0 errors / 8 warnings (전부 기존 latent, 새 코드 무관)
build: 2.07s 무경고

남은 Phase A:
- A-8 토큰 swap (Sidebar/TagPill/UploadDropzone/PreviewPanel/DocumentCard/
  DocumentTable/+layout toast — baseline 421건 → 0건)
- A-9 __styleguide 라우트 (전체 시각 검증 + Modal stack 데모)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:40:08 +09:00
Hyungi Ahn
ad23925ed5 feat: 입력 프리미티브 (TextInput / Textarea / Select) + tsconfig 보정
UX/UI 개편 Phase A-6.

신규 컴포넌트 (lib/components/ui/)
- TextInput.svelte: \$bindable value, label/error/hint, leading/trailing icon,
  \$props.id() 기반 SSR-safe 자동 id, aria-describedby 자동 연결.
- Textarea.svelte: TextInput과 동일 구조 + autoGrow 옵션
  (\$effect로 scrollHeight 동기화, maxRows 지원).
- Select.svelte: 네이티브 <select> 래퍼, ChevronDown 표시.
  options: { value, label, disabled? }[]

빌드 환경 보정
- frontend/tsconfig.json 신규: svelte-kit 자동 생성 .svelte-kit/tsconfig.json을
  extends. 이게 없으면 svelte-check가 \$lib path mapping과 .svelte.ts 모듈
  resolution을 못 잡아 "Cannot find module" 에러 발생. SvelteKit 표준 패턴.
  strict는 false로 시작 (기존 코드 implicit any 다수 — 점진적 정리 예정).
- Button/IconButton/EmptyState/TextInput의 icon prop 타입을 IconComponent(any)로
  완화. lucide-svelte v0.400은 legacy SvelteComponentTyped 기반이라 Svelte 5의
  Component<P, E, B> 시그니처와 호환 안 됨. v0.469+ 업그레이드 후 좁힐 예정.

svelte-check: 0 errors / 8 warnings (전부 기존 latent, 새 코드 무관)
build: 2.07s 무경고

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:44 +09:00
Hyungi Ahn
70b27d4a51 fix(search): confidence 임계값 완화 + hybrid +vector boost 가산
baseline 평가셋 실행 시 'summary+vector' top_score 2.39가 임계값 2.5에
미달해 정답 쿼리(산업안전보건법 제6장)가 low_confidence로 잘못 잡힘.

- 텍스트 매치 임계값 0.5씩 완화 (실측 분포 반영)
- '+vector' 접미사가 있으면 hybrid 합성 매치이므로 confidence +0.10 가산
- 정답률 5/5 → 4/5 false-positive 1건 제거 기대

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:37:13 +09:00
Hyungi Ahn
50e6b5ad90 fix(search): confidence 휴리스틱 vector-only amplify 버그 수정
vector-only 매치(match_reason == 'vector')에서 raw 코사인 0.43이
0.6으로 잘못 amplify되어 low_confidence threshold(0.5)를 못 넘기던 문제.

- vector-only 분기: amplify 제거, _cosine_to_confidence로 일관 환산
- _cosine_to_confidence: bge-m3 코사인 분포 (무관 텍스트 ~0.4) 반영
- 코사인 0.55 = threshold 경계(0.50), 0.45 미만은 명확히 low

smoke test 결과 zzzqxywvkpqxnj1234 같은 무의미 쿼리(top cosine 0.43)가
low_confidence로 잡히지 않던 문제 해결.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:33:25 +09:00
Hyungi Ahn
f005922483 feat(search): Phase 0.3 검색 실패 자동 로깅
검색 실패 케이스를 자동 수집해 gold dataset 시드로 활용.
wiggly-weaving-puppy 플랜 Phase 0.3 산출물.

자동 수집 트리거 (3가지):
- result_count == 0           → no_result
- confidence < 0.5            → low_confidence
- 60초 내 동일 사용자 재쿼리   → user_reformulated (이전 쿼리 기록)

confidence는 Phase 0.3 휴리스틱 (top score + match_reason).
Phase 2 QueryAnalyzer 도입 후 LLM 기반으로 교체 예정.

구현:
- migrations/015_search_failure_logs.sql: 테이블 + 3개 인덱스
- app/models/search_failure.py: ORM
- app/services/search_telemetry.py: confidence 계산 + recent 트래커 + INSERT
- app/api/search.py: BackgroundTasks로 dispatch (응답 latency 영향 X)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:29:12 +09:00
Hyungi Ahn
7fa7dc1510 feat: 디자인 시스템 기반 — 유틸 헬퍼 + CI rule + 첫 6개 프리미티브
UX/UI 개편 Phase A-3 / A-4 / A-5. 후속 phase가 곧바로 소비할 수 있도록
디자인 시스템의 코어 자산을 한꺼번에 도입한다.

A-3 — 유틸 헬퍼 (lib/utils/)
- pLimit.ts: 동시 실행 N개 제한 (5대 원칙 #4 — 일괄 PATCH/DELETE에서
  GPU 서버/SSE 부하 방지). 외부 의존성 없음.
- mergeDoc.ts: PATCH/SSE 응답을 로컬 cache에 머지할 때 updated_at으로
  stale 갱신 차단 (5대 원칙 #6 — optimistic update conflict resolution).
  dropDoc 헬퍼 포함.

A-4 — CI 토큰 차단 (5대 원칙 #1)
- scripts/check-tokens.sh: bg-[var(--*)] 등 임의값 토큰 우회 grep 차단.
- npm run lint:tokens 등록.
- 현재 baseline 421건 — A-8 토큰 swap에서 0으로 떨어진 후 pre-commit 강제화.

A-5 — 첫 6개 프리미티브 (lib/components/ui/)
- Button.svelte: variant(primary/secondary/ghost/danger) × size(sm/md),
  loading/disabled, icon 슬롯, href 자동 a 변환, focus-visible ring.
- IconButton.svelte: 정사각형, aria-label 필수, Button과 동일 variant 체계.
- Card.svelte: bg-surface + rounded-card + border-default 패턴 1군데화.
  padded/interactive 옵션, interactive면 button 시맨틱.
- Badge.svelte: 의미적 tone(neutral/success/warning/error/accent) 표시.
  TagPill과 별개 (TagPill은 도메인 prefix 코드 전용).
- Skeleton.svelte: ad-hoc animate-pulse div 통합. w/h/rounded prop.
- EmptyState.svelte: icon + title + description + action slot.

모든 프리미티브는 Svelte 5 runes mode strict (\$props/\$derived/\$bindable),
@theme 토큰만 사용 (bg-surface, text-dim, border-default 등 — bg-[var(--*)] 미사용),
focus-visible ring 통일, slot은 {@render children?.()}로 작성.

svelte-check: 0 errors, 8 warnings (모두 기존 latent 이슈, 새 코드 무관).
build: 1.95s 무경고.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:26:35 +09:00
Hyungi Ahn
8742367bc2 refactor: stores 분리 — toast / uiState 단일 책임화
UX/UI 개편 Phase A-2. lib/stores/ui.ts에 섞여 있던 toast 시스템과
UI layer 상태(미사용 dead export 포함)를 의미 단위로 분리한다.
한 파일이 비대해지는 시나리오를 처음부터 차단(plan 8대 원칙 #7).

- lib/stores/toast.ts 신규 — toasts/addToast/removeToast (Toast interface export)
- lib/stores/uiState.svelte.ts 신규 — drawer 단일 slot + modal stack 클래스 (5대 원칙 #2)
  · openDrawer/closeDrawer/isDrawerOpen
  · openModal/closeTopModal/isModalOpen/modalIndex/topModal
  · handleEscape (modal stack 우선 → drawer)
- lib/stores/ui.ts 삭제 — sidebarOpen/selectedDocId는 어디서도 import되지 않은 dead export였음
- 11개 파일 import 경로 갱신: \$lib/stores/ui → \$lib/stores/toast

uiState는 아직 어디서도 사용 안 함 — Phase B에서 sidebar/meta drawer가 전환될 때
ui.openDrawer('sidebar') 형태로 채택. 동작 변경 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:26:11 +09:00
Hyungi Ahn
ec36ea3d6d test(search): Phase 0.2 baseline 측정 결과
23개 쿼리에 대한 현재 검색(FTS+ILIKE+Vector hybrid) baseline.
Phase 1+ 개선 비교 기준점으로 보존.

전체: Recall@10 0.788 / NDCG@10 0.705 / Top-3 0.95 / p95 1695ms

핵심 약점 (Phase 1+ 타겟):
- news_crosslingual catastrophic (Recall 0.14) → domain-aware 필수
- failure-case precision 0/3 → confidence threshold 부재
- p95 1695ms (목표 500ms의 3배) → trigram/parallel retrieval
- nl 쿼리 top-3 ordering 약함 → chunk-level + reranker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:22:53 +09:00
Hyungi Ahn
8490cfed10 test(search): Phase 0.2 평가셋 + 평가 스크립트
22개 쿼리(6개 카테고리)와 Recall/MRR/NDCG@10 + latency p50/p95
측정 스크립트 추가. wiggly-weaving-puppy 플랜 Phase 0.2 산출물.

- queries.yaml: 정확키워드/한국어자연어/crosslingual/뉴스/실패 케이스
  실제 코퍼스(2026-04-07, 753 docs) 기반 정답 doc_id 매핑
- run_eval.py: 단일 평가 + A/B 비교 모드, CSV 저장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:19:38 +09:00
Hyungi Ahn
f523752971 feat: Tailwind v4 @theme 토큰 도입 — 디자인 시스템 기반 마련
UX/UI 개편 Phase A-1. CSS 변수를 Tailwind 유틸리티로 노출해서
이후 컴포넌트가 bg-surface / text-dim / border-default 형태로 작성될
수 있도록 한다. bg-[var(--*)] 임의값 패턴은 후속 lint 규칙으로 차단 예정.

- app.css에 @theme 블록 추가 (color/radius/z/spacing/domain 토큰)
- 기존 :root 변수는 .markdown-body 호환 위해 공존 유지
- +layout.svelte nav 한 줄 swap으로 v4 빌드/HMR 인식 검증 (동일 색상값)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:15:33 +09:00
Hyungi Ahn
fc50008843 feat: 뉴스 페이지 모바일 최적화 — 데스크톱/모바일 공존
데스크톱: 사이드바 필터 + 하단 미리보기 (기존 유지)
모바일: 드롭다운 필터 + 전체화면 미리보기 + 하단 원문 버튼
- body scroll lock (모바일 전체화면 시)
- 스크롤 위치 복원
- active 터치 피드백
- 안읽음 건수 표시
- 페이지네이션 10개 제한

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:56:33 +09:00
Hyungi Ahn
db34a06243 feat: 뉴스 미리보기 — AI 요약 상단 + 본문/메모 분리
- AI 요약: 파란 박스로 상단에 별도 표시
- 본문 입력: extracted_text에 추가 (기사 전문 붙여넣기)
- 메모: user_note에 저장 (개인 메모)
- 기사 선택 시 편집 상태 초기화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:42:33 +09:00
Hyungi Ahn
e10b0f2883 fix: 뉴스 분야 필터 — file_path 폴더명 기반 매칭
경향신문/문화 → file_path LIKE 'news/경향신문 문화/%'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:27:01 +09:00
Hyungi Ahn
8bb2ea4f29 fix: 뉴스 필터 트리 — 신문사명 정확 추출 + API datetime 수정
- PAPER_NAMES 매핑으로 'Le Monde', 'Der Spiegel' 등 정확 분리
- NewsSourceResponse datetime 타입 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:18:50 +09:00
Hyungi Ahn
3cd65e4c26 fix: 사이드바 트리에서 News 제외 + 뉴스 페이지 ☰ 숨김
- tree API: ai_domain != 'News' 필터
- +layout: /news 경로에서 사이드바 토글 버튼 숨김
- DB: 뉴스 ai_sub_group을 신문사명으로 재설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:15:15 +09:00
Hyungi Ahn
557165db11 feat: 뉴스 필터 트리 (신문사 → 분야) + ai_summary 우선 표시
- 좌측 필터: 신문사 펼침 → 분야별 필터 (News/경향신문/문화)
- API: source 파라미터 '신문사' 또는 '신문사/분야' 지원
- 리스트: ai_summary 있으면 우선, 없으면 extracted_text fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:08:50 +09:00
Hyungi Ahn
2eeed41f5c fix: @const 위치 에러 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:06:29 +09:00
Hyungi Ahn
be20edd0cd fix: 뉴스 리스트 — ai_summary 우선 표시 (없으면 extracted_text fallback)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:05:46 +09:00
Hyungi Ahn
49cc86db80 feat: summarize 전용 stage — 뉴스 AI 요약 (classify 없이)
- summarize_worker: 요약만 생성 (분류 안 함)
- queue_consumer: summarize stage 추가 (batch 3)
- news_collector: summarize + embed 큐 등록
- process_stage enum에 'summarize' 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:00:14 +09:00
Hyungi Ahn
4f7cd437f5 feat: 뉴스 리스트에 RSS 요약 1줄 표시 + 상세 링크 현재 탭
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:52:57 +09:00
Hyungi Ahn
7d6b5b92c0 fix: 뉴스 페이지네이션 리셋 버그 + 상세 링크 새 탭
- $effect에서 필터 변경 시에만 page 리셋 (페이지 클릭과 충돌 방지)
- 상세 링크 → 새 탭으로 열기

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:47:27 +09:00
Hyungi Ahn
ef6f857a6d fix: 뉴스 페이지 — 닫기 버튼 + 페이지네이션 + 상세 링크 + 본문 입력
- 미리보기 닫기 버튼 추가
- 페이지네이션 (30건 단위)
- "상세" 링크 → /documents/{id}
- "본문/메모 입력" → user_note 저장
- DocumentUpdate에 is_read 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:38:42 +09:00
Hyungi Ahn
7ca3abf17c feat: 뉴스 전용 페이지 + 분류 격리 + 읽음 상태
- /news 전용 페이지: 신문사 필터, 읽지않음 필터, 시간순 리스트, 미리보기
- 뉴스 분류 격리: ai_domain='News', classify 제거, embed만 등록
- is_read: 클릭 시 자동 읽음, 전체 읽음 API
- documents 목록에서 뉴스 제외 (source_channel != 'news')
- nav에 뉴스 링크 추가
- GET /api/news/articles, POST /api/news/mark-all-read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:16:00 +09:00
Hyungi Ahn
cd5f1c526d fix: 상세 페이지에도 뉴스 전용 뷰어 적용 (source_channel=news → article)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:01:39 +09:00
Hyungi Ahn
2b457a8305 feat: 뉴스 전용 뷰어 + 카드 구분 + 설정 UI
- DocumentViewer: source_channel=news → article 전용 뷰어
  (제목/소스/날짜/요약/원문 링크 rel=noopener)
- DocumentCard: 뉴스 카드에 📰 아이콘
- settings: 뉴스 소스 관리 (목록/추가/삭제/토글/수집/마지막 시간)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:55:49 +09:00
Hyungi Ahn
d03fa0df37 fix: source_channel enum에 'news' 추가 (ORM 누락)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:20 +09:00
Hyungi Ahn
a6c19ef76c feat: 뉴스 자동 수집 시스템 — 6개국 신문 RSS/API
- news_sources 테이블 (소스 관리, UI 동적 제어)
- news_collector 워커: RSS(feedparser) + NYT API
  - 중복 체크: hash(title+date+source) + URL normalize
  - category 표준화, summary HTML 정제, timezone UTC
  - 30일 이내만 embed, source별 try/catch
- News API: 소스 CRUD + 수동 수집 트리거
- APScheduler: 6시간 간격 자동 수집
- 대상: 경향/아사히/NYT/르몽드/신화/슈피겔

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:38:07 +09:00
Hyungi Ahn
bf8efd1cd3 feat: 임베딩 모델 변경 — nomic-embed-text → bge-m3 (1024차원, 다국어)
- config.yaml: embedding model → bge-m3
- document.py: Vector(768) → Vector(1024)
- embed_worker.py: 모델 버전 업데이트
- migration 011: 벡터 컬럼 재생성 (기존 임베딩 초기화)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:49:45 +09:00
Hyungi Ahn
204c5ca99f fix: AI 요약 마크다운 렌더링 — 상세페이지는 렌더링, 카드는 기호 제거
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:36:29 +09:00
Hyungi Ahn
c885b5be27 fix: 3+4단계 — 반응형/에러분기/a11y/Synology URL
- DocumentCard: window.innerWidth → matchMedia (반응형 정확)
- documents/[id]: 로딩 상태 3분기 (loading/not_found/network)
- documents/[id]: Synology URL 하드코딩 → edit_url fallback
- DocumentCard: aria-label 추가
- Toast: aria-live 이미 적용 (1단계)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:24:32 +09:00
Hyungi Ahn
1b21d9bb53 feat: 2단계 — DEVONthink 스타일 테이블 뷰 + 카드/테이블 토글
- DocumentTable.svelte: 컬럼 정렬(stable sort), domain 색상 바, 포맷 아이콘
- 뷰 모드 토글 버튼 (카드 ↔ 테이블)
- localStorage로 뷰 모드 + 정렬 상태 기억

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:16:45 +09:00
Hyungi Ahn
3374eebfc6 fix: 프론트엔드 1단계 — XSS 수정 + Svelte 5 변환 + 필터/아이콘/a11y
- [critical] DOMPurify 적용 (FORBID_TAGS/ATTR, ALLOW_UNKNOWN_PROTOCOLS)
- [high] $: → $derived 변환 (documents/[id])
- [high] 태그/소스 필터 구현 (filterTag, filterSource)
- FormatIcon: docx/xlsx/pptx/odt/ods/odp/dwg/dxf 추가
- editTab 선언 순서 수정
- debounceTimer 미사용 변수 제거
- Toast role="status" aria-live 추가
- marked 옵션: mangle/headerIds false

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:15:02 +09:00
Hyungi Ahn
24142ea605 fix: Codex 리뷰 5건 수정 (critical 1 + high 4)
1. [critical] config.yaml → settings 객체에서 taxonomy 로드 (import crash 방지)
2. [high] ODF 변환: file_path 유지, derived_path 별도 필드 (무한 중복 방지)
3. [high] 법령 분할: 첫 장 이전 조문을 "서문"으로 보존
4. [high] Inbox: review_status 필드 분리 (pending/approved/rejected)
5. [high] 삭제: soft-delete (deleted_at) + worker 방어 + active_documents 뷰
   - 모든 조회에 deleted_at IS NULL 일관 적용
   - queue_consumer: row 없으면 gracefully skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:15:13 +09:00
Hyungi Ahn
6c92e375c2 feat: Markdown 뷰어/편집기 개선
- startEdit(): extracted_text || rawMarkdown fallback
- split editor → 편집/미리보기 탭 전환 방식
- GitHub Dark 스타일 markdown-body CSS (테이블/코드/인용/리스트)
- prose 클래스 → markdown-body로 교체

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:21:45 +09:00
142 changed files with 17213 additions and 209 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# 인증 정보 (절대 커밋 금지)
credentials.env
.env
# Python
venv/

157
CLAUDE.md
View File

@@ -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 수동 등록)

View File

@@ -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 {

View File

@@ -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
# 인증 정보 설정

View File

@@ -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

View File

@@ -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
View File

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

138
app/api/dashboard.py Normal file
View 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
View 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
View 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
View 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
View File

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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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
View File

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

46
app/models/chunk.py Normal file
View 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"),
)

View File

@@ -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
View 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
)

View File

@@ -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"),

View 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
View File

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

View File

@@ -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}

View File

@@ -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
View File

View 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)
"""

View File

@@ -0,0 +1,5 @@
"""Evidence extraction 서비스 (Phase 3).
reranked chunks에서 query-relevant span을 rule + LLM hybrid로 추출.
구현은 Phase 3에서 채움.
"""

View 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)

View File

@@ -0,0 +1,5 @@
"""Query analyzer — 자연어 쿼리 분석 (Phase 2).
domain_hint, intent, hard/soft filter, normalized_queries 등 추출.
구현은 Phase 2에서 채움.
"""

View 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

View 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

View File

@@ -0,0 +1,6 @@
"""Grounded answer synthesis 서비스 (Phase 3).
evidence span을 Gemma 4에 전달해 인용 기반 답변 생성.
3~4초 soft timeout, 타임아웃 시 결과만 반환 fallback.
구현은 Phase 3에서 채움.
"""

View 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
View File

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

354
app/workers/chunk_worker.py Normal file
View 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()

View 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
View File

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

View File

@@ -0,0 +1,44 @@
"""벡터 임베딩 워커 — GPU 서버 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()

View 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
View File

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

364
app/workers/law_monitor.py Normal file
View 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}",
)

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -0,0 +1,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()

View 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()

View File

@@ -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"]

View File

@@ -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=

View File

@@ -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:

View File

@@ -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/

View File

@@ -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
View File

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

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

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

2245
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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
View 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
View File

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

View File

@@ -0,0 +1,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>

View 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>

View 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>

View 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>

View 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} />

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,40 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { tag = '', clickable = true } = $props();
// 계층별 색상 (의미 토큰)
function getColor(t) {
if (t.startsWith('@상태/') || t.startsWith('@')) return { bg: 'bg-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}

View 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}

View File

@@ -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}

View File

@@ -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}
/>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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}

View 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}

View 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>

View 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}

View 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>

View 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}

View 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}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
});
}

View 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)');

View File

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

View File

@@ -0,0 +1,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;
}

View 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));
}

View 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();

View 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];
}

View 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);
}

View 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();
}
};
}

View 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