- migrations/152: ALTER TYPE doc_category ADD VALUE 'law' (DDL only; PG16 단일-트랜잭션 제약상 backfill 은 별도)
- models/document.py: Enum 에 'law' 추가 (7 활성 + 3 유보)
- workers/law_monitor.py: Document(..., category='law') — 신규 유입부터 세팅
- workers/classify_worker.py: source_channel='law_monitor' early-return + 최소 필드 (ai_domain='법령', ai_tags=['법령'], importance='medium'). AI classify skip — 법령 구조 고정/외부 source of truth/자동 재수집
- scripts/backfill_category.py: law 분기 + WHERE re-target ((source_channel='law_monitor' AND category='document')) + VERIFY cat_law/law_source_count + fail 조건
- api/documents.py: default 목록 제외에 law_monitor 추가 (news 와 동일 패턴)
- api/dashboard.py: documents count FILTER 에 law_monitor 제외 (category_counts.law 는 기존 GROUP BY category 로 자동 노출)
- frontend/Sidebar.svelte: '법령 알림' 버튼 ?source=law_monitor → ?category=law (explicit category 경로가 default exclusion 을 skip)
plan: ~/.claude/plans/stateless-churning-raccoon.md
axis 원칙: category=UI 축, policy/telemetry=source_channel+ai_domain 축 (feedback_category_vs_ai_domain_axis.md)
배포 순서: push → GPU pull → compose up --build fastapi frontend → backfill --dry-run → --apply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- +layout.svelte 상단 nav 에 오디오/비디오 추가 (문서/자료실 옆,
카테고리 계열 그룹). Sidebar 는 §2 에서 추가했던 카테고리
블록 제거하고 기존 도메인 트리 전용으로 복구 — 상단 nav 와
중복되고, 사이드바가 카테고리 탐색 1차 진입점으로 적합하지
않다는 피드백 반영.
- app/Dockerfile uvicorn 에 --proxy-headers --forwarded-allow-ips=*
추가. FastAPI 의 trailing-slash 307 리다이렉트가 X-Forwarded-Proto
를 무시해 Location 헤더를 http:// 로 생성 → HTTPS 페이지에서
mixed-content block (/video 에서 목격). home-caddy → document-caddy
→ fastapi 체인에서 scheme 복구.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- accept-suggestion: documents.updated_at != expected stale 검사 제거.
classify_worker 가 source_updated_at 을 pre-commit 값으로 저장하는데
SQLAlchemy onupdate 가 commit 에서 updated_at 을 bump → 항상 불일치 →
승인 영구 불가. payload 교체 검사 하나만으로 core race 는 막힘.
사용자 직접 편집 감지는 별도 user_updated_at 컬럼 도입 시 재논의.
- docker-compose.yml: postgres/kordoc/fastapi/frontend 포트 127.0.0.1
바인딩. GPU 서버 로컬에만 있던 drift 를 main 으로 승격. UFW-Docker
우회 컨텍스트에서 불필요한 LAN 노출 축소.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stt:
- services/stt/server.py: lazy → eager preload in FastAPI lifespan.
STT_PRELOAD=0 으로 lazy 강제 가능 (개발/테스트). preload 실패해도
프로세스는 살아 있고 /ready false 로 남아 healthcheck 가 unhealthy 처리.
- docker-compose.yml: healthcheck /health → /ready. /health 는 단순
liveness 라 모델 미적재 상태도 healthy 로 잡혀 운영 신호 부적합.
queue ORM:
- app/models/queue.py: process_stage enum 에 'stt'/'thumbnail' 추가 +
create_type=False (migration 150/151 가 DB enum 확장 담당). 이게
없으면 stt_worker INSERT 시 SQLAlchemy 가 enum value 를 거부.
dashboard 강화 (§4 선제, §3 신규 stage 까지 자동 커버):
- app/api/dashboard.py: category_counts + library_pending_suggestions +
queue_lag (stage 별 pending/processing/failed + oldest_pending_age_sec).
- frontend/src/lib/stores/system.ts: QueueLag 타입 + DashboardSummary 확장.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
plan: ~/.claude/plans/luminous-sprouting-hamster.md §2
- GET /api/documents/stats/category-counts — Sidebar/Dashboard 용
카테고리별 문서 건수 + library_pending_suggestions
- DocumentResponse 에 category / ai_suggestion 필드 노출 (§1 과 동일
수정, rebase 시 합쳐짐)
- SuggestionReview.svelte 신규 — ai_suggestion.proposed_category='library'
제안 카드 리스트. 단건 승인/반려 + 체크박스 대량 승인. 409 stale 시
warning toast + 자동 refetch
- /library 상단에 SuggestionReview 배치 (자료실 + 승인 대기함 겸).
승인/반려 후 tree/docs/facet 재조회
- Sidebar 재구성: 카테고리 내비(문서/자료실/뉴스/메모/검색) + 자료실
pending 배지. /api/documents/stats/category-counts 바인딩. audio/video
자리는 §3 주석 예약
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- services/ocr/server.py: surya 0.17.x predictors 기반으로 재작성
(구 `from surya.ocr import run_ocr` 제거됨 → import error → 빈 텍스트 반환)
- NFC(DB 경로) vs NFD(NFS 파일시스템) 한글 정규화 mismatch 보정
- surya-ocr 버전 0.17.1 고정 (0.6~1.0 범위는 breaking change 노출)
- AIClient.ocr() NotImplementedError 제거 (호출처 0건, extract_worker 가
ocr-service HTTP 호출을 직접 사용)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
한도 400 → 600 자. baseline 관찰(partial avg 168자 / full 10%)에서
길이 제약이 실제 출력 제약이 되는 현상 확인, 절차·비교 카테고리
답변 깊이 확보 목적.
변경 4 라인:
- search_synthesis.txt:17 answer 400→600 characters max
- prompt_versions.py:20 v1-400char → v2-600char (telemetry)
- synthesis_service.py:42 PROMPT_VERSION v1→v2 (cache key 의미론 동기화)
- synthesis_service.py:46 MAX_ANSWER_CHARS 400→600 (hard clip 동기화)
v1 post-tier0 baseline: 225 rows, partial 51% / insufficient 49% / full 0%
(Tier 0 fix 로 full+refused=True 모순 0 건). E.6 는 이 clean baseline 을
compare-against 로 사용.
향후 티켓: PROMPT_VERSION 과 ASK_PROMPT_VERSION 단일 소스 통합.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VERIFIER_NUMERIC_PROMOTE 환경변수로 numeric_conflict severity 승격 실험.
verifier_service.py:
- _NUMERIC_PROMOTE = os.getenv('VERIFIER_NUMERIC_PROMOTE', '0') == '1'
(import time 평가 — env 변경 시 process restart 필수)
- _SEVERITY_MAP['numeric_conflict']: env=1 → critical=strong / minor=medium,
env=0 (기본) → 둘 다 medium (기존 동작 유지)
- direct_negation 은 env 무관 항상 strong (안전장치)
verifier.txt:
- numeric_conflict 정의에 critical/minor 분리 명시 (core quantity vs peripheral)
- "Range values satisfy any answer within range" rule 추가
- severity mapping 갱신: numeric_conflict 분기 명시
search.py re-gate (Tier 1~7 재번호, B2 신규 Tier 4):
- v_strong_numeric = sum(1 for f in v_strong
if f.startswith('verifier_numeric_conflict'))
- Tier 4 (신규): g_strong + v_strong_numeric >= 1 + low_conf → refuse
re_gate value: 'refuse(grounding+verifier_numeric)'
- 원칙 유지: verifier strong 단독 refuse 금지 — g_strong 교차 필수
- 호환성: 기존 re_gate string literals 그대로 유지, 신규 1개만 추가
credentials.env.example: VERIFIER_NUMERIC_PROMOTE=0 (off, B3 통과 후 production 전환)
tests/test_verifier_numeric_promote.py: 4 케이스 (env off / on / explicit 0 /
direct_negation invariant). monkeypatch.setenv + importlib.reload 패턴.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex adversarial review (no-ship) 반영:
fix1: unit-aware numeric clearing
- _extract_numeric_corpus(): 단위별 bucket dict (exact_by_unit) +
ranges_by_unit (양방향 + 단방향 bound 통합)
- _within_unit_range / _close_to_unit_pool: 같은 unit 안에서만 매칭
bare answer 는 보수적으로 range/tolerance 패스 X
- 2-pass cleared_pairs (unit, digits): cross-unit cleared 절대 skip 안 함.
bare(None) 답변은 unit-anchored cleared 시 duplicate 로 skip
(콤마 normalize 부산물 보호 — Codex 케이스는 그대로 flag)
fix3: 최대/최소 bound semantics
- _APPROX_PREFIX_RE 에서 최대/최소 제거 (약/대략/거의/얼추 만 strip)
- _BOUND_PATTERN_RE: 최대 N → range (0, N-1), 최소 N → range (N+1, 1e18)
- 경계값 자체는 cleared 대상 아님 ("최대 100명" + answer "100명" → flag)
- bound span 내 숫자는 exact pool 에서 제외
기존 prefix strip / 콤마 / 부터 separator / 단위 동의어 / tolerance 4자리+ /
식별자성 단위 1자리 flag 동작 모두 유지.
tests/test_grounding_fabricated_number.py: 25 케이스 — 기존 17 + Codex
unit-mismatch 3 (won_vs_myeong_range/tol, pct_vs_myeong_range) + bound 5
(최대/최소 boundary/inner/outer).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 `await file.read()` 는 임의 크기 파일을 메모리에 전부 적재한 후 저장해
디스크 고갈 / OOM 공격 벡터 였음. Caddy/home-caddy 프록시 한도에만 의존했고
FastAPI 측 policy enforcement 가 전무했음. 이 커밋으로 서버가 authoritative
으로 강제 집행.
변경:
- `Request` DI 추가 → Content-Length 사전 차단 (max_bytes * slack_ratio 초과 시 413)
- `await file.read()` → 청크 루프 스트리밍 (stream_chunk_bytes 단위)
- 누적 size > max_bytes 시 스트리밍 중 413 (Content-Length 위조 방어)
- 0바이트 파일 → 400 reject (정책: 유의미한 문서 ingest 대상 아님)
- 파일 저장 완료 + close 이후 에만 file_hash 및 DB 레코드 생성
- Document 레코드 와 processing_queue 는 단일 트랜잭션으로 묶고,
DB 예외 시 session rollback + partial file unlink 로 원자적 정리
- 예외 시 `except Exception` 으로 cleanup (BaseException 계열은 의도적으로 패스)
설정 값: config.yaml `upload.{max_bytes, content_length_slack_ratio, stream_chunk_bytes}`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
업로드 크기 한도를 프론트 하드코딩이 아닌 서버 config 의 단일 진실 공급원
으로 이동. 프론트는 Phase B 후속 커밋에서 이 값을 읽어 pre-check UX 에 사용.
- config.yaml 에 `upload` 블록 추가:
* max_bytes (authoritative policy)
* content_length_slack_ratio (multipart 오버헤드 여유)
* stream_chunk_bytes (스트리밍 IO 단위)
- app/core/config.py 에 UploadConfig pydantic 모델 + Settings.upload 필드
- app/api/config.py 신규 — GET /api/config/public 엔드포인트
* 민감정보 없는 프론트 필수 설정만 노출
* 범용 서버 설정 공개 창구로 확대 금지 (docstring 명시)
- /api/config 를 setup redirect bypass 에 추가 (초기 setup 전에도 조회 가능)
이 커밋 자체는 기존 upload 동작에 영향 없음. 후속 커밋에서 enforcement +
프론트 구독을 연결.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
E.3 400→600자 튜닝 전후 비교 + 단계 5 failure mode 분석의 기준 필드 추가.
- migrations/135: answer_length/covered_aspects/missing_aspects/model_name/prompt_version 컬럼 + prompt_version 인덱스
- ORM: ask_event.py에 동일 5개 필드 매핑
- prompt_versions.py: ASK_PROMPT_VERSION="search_synthesis.v1-400char" 상수 + resolve_primary_model() helper
- search_telemetry.record_ask_event: 시그니처에 keyword-only 필드 5개 추가 (하위 호환)
- search.py: refused + success 두 호출사이트에서 새 필드 전달. answer_length는 len(sr.answer or ""), model_name/prompt_version은 상수 모듈 기반
기존 호출 구조(이미 search_telemetry+background_tasks로 DB insert 중)는 유지. 순수 확장 커밋.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
스캔 PDF/이미지 자동 OCR 트리거 + 결과 품질 검증 + 1회 제한.
- extract_meta JSONB 컬럼 추가 (migration 134)
ocr_attempted, ocr_reason, ocr_skip_reason, ocr_terminal, ocr_chars
- PDF OCR 트리거: total_chars < 300 또는 avg < 80 && total < 3000
- 이미지 자동 OCR: jpg/png/tiff/webp 등
- 품질 차등: 이미지 50자, PDF 200자 또는 페이지당 30자
- 상한: pages > 200 또는 file_size > 150MB → 스킵
- OCR 1회 제한: extract_meta.ocr_attempted로 재시도 방지
- extractor_version은 도구명만 (surya_ocr/pymupdf/kordoc)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
file_watcher.watch_inbox()에서 select(Document)를 사용하지만
sqlalchemy import가 빠져있어 NameError 발생.
이로 인해 큐 컨슈머가 max_instances 도달로 실행 스킵되어
embed(45건) + chunk(8건)이 pending 상태로 정체됨.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kordoc은 PDF 전체를 메모리에 올려 파싱 → 이미지 PDF에서 OOM.
PyMuPDF는 페이지 단위 스트리밍으로 40MB+ PDF도 수백 MB 내 처리.
- kordoc 시도 → 실패(OOM/timeout/422) → PDF면 PyMuPDF fallback
- PyMuPDF도 텍스트 레이어 없으면 로그 경고 (스캔 전용 PDF)
- HWP/HWPX는 kordoc 전용 (fallback 없음)
- extractor_version으로 어떤 경로로 추출됐는지 추적
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25MB 파일 크기 제한은 텍스트 PDF(18MB 성공)까지 차단하는 문제.
실제 원인은 이미지 스캔 PDF의 in-memory 파싱 시 메모리 폭발.
- extract_worker: 25MB 파일 크기 제한 삭제
- docker-compose: kordoc-service mem_limit 4g + memswap_limit 4g
- 텍스트 PDF → 크기 무관 정상 파싱
- 이미지 PDF → 4GiB 초과 시 Docker OOM-kill → 재시작 → 3회 실패 후 failed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
38.2MB PDF에서 kordoc이 22.8GiB 메모리 사용 후 OOM 크래시 확인.
컨테이너 재시작으로 다른 문서 처리까지 차단되는 문제 방지.
- 25MB 초과 파일: kordoc 호출 없이 스킵 (extractor_version에 크기 기록)
- 25MB 이하 파일: 기존 adaptive timeout으로 정상 처리
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- extract_worker: LibreOffice 15000자 절단 제거 (full text 저장 원칙)
- classify_worker/summarize_worker: 요약 입력 15000→50000자 확대
- client.py: 길이 기반 Claude 자동전환 제거 (require_explicit_trigger 정책 준수)
_call_chat의 primary→fallback(exaone3.5) 체인은 유지
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FileInfoView에 회사/주제/연도/문서유형 select 4개 추가.
facet 옵션은 /api/library/facets에서 로드, 세션 캐시.
업로드 엔드포인트에 facet Form 파라미터 4개 추가.
업로드 시 현재 선택 facet 자동 전달 + 미리보기 텍스트.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
library_categories 테이블 추가로 빈 카테고리 생성 가능.
CRUD API (생성/leaf rename/leaf delete) + 트리 머지 엔드포인트.
사이드바 트리에 컨텍스트 메뉴 (추가/이름변경/삭제).
LibraryPathEditor를 카테고리 기반 flat selector로 전환.
미분류는 시스템 분류로 보호 (삭제/이름변경 불가).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
기존 UNIQUE(document_id, stage, status)는 pending+processing 동시 존재를
허용해서 stale 복구 시 충돌 발생. 2-layer 방어로 근본 차단:
1) DB: partial unique index uq_queue_active — 활성 행(pending/processing)은
(document_id, stage)당 최대 1개만 허용
2) App: enqueue_stage() 중앙 함수 — INSERT ON CONFLICT DO NOTHING으로
모든 9개 경로의 check-then-insert TOCTOU race 제거
migration 117은 guard check 포함 — 활성 중복이 남아있으면 RAISE EXCEPTION
으로 중단, 수동 정리 유도.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
stale processing 행을 pending으로 bulk UPDATE 시 이미 같은
(document_id, stage, pending) 행이 존재하면 unique constraint 위반으로
APScheduler consume_queue 잡 전체가 크래시. 2-step 접근으로 변경:
1) pending 중복 있는 stale processing 행은 DELETE
2) 나머지만 pending으로 UPDATE
+ 예외 삼키기로 stale reset 실패가 전체 큐 소비를 죽이지 않게 방어
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
폴더 미선택 상태에서 업로드하면 doc_purpose='business'만 설정되고
@library/ 태그가 빠져서 자료실에 문서가 표시되지 않던 버그 수정.
백엔드: business 업로드에 library_path 없으면 @library/미분류 자동 태깅.
프론트: activePath 없을 때 기본값 '미분류' 전송.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
메모 입력/편집:
- 선택적 제목 토글 (기본 숨김, "제목" 버튼으로 활성화)
- 툴바 버튼: 체크리스트/굵게/제목 (모바일에서 마크다운 수동 입력 불필요)
- 편집 모드에도 동일 툴바
대시보드 핀 메모:
- 클릭 시 /memos 이동 대신 인라인 펼침/접힘 (details)
- 제목이 있으면 제목 표시, 없으면 첫 줄
- 펼치면 마크다운 렌더링된 본문 + "메모함에서 보기" 링크
Backend:
- MemoCreate/MemoUpdate에 선택적 title 파라미터 복원
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
대시보드를 통계판에서 상황판으로 전환:
- 헤더 + 시스템 상태 인라인 (비클릭)
- 핀 메모 최상단 조건부 (컴팩트 띠, 최대 3개)
- 카드 4개 (문서함/메모/뉴스/승인대기) 모바일 2×2
- 최근 활동 전체 너비 7건, 2줄 스캔형 + 법령 배지
- 파이프라인 details 접힘 (실패 시 자동 open)
- 제거: 도메인 분포, 법령/시스템 별도 카드, 8:4 분할
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
전체 문서 1개 카드를 6개로 분리: 문서함, 메모, 뉴스, 승인대기,
법령알림, 시스템. 단일 FILTER 쿼리로 효율적 카운트.
각 카드 클릭 시 해당 페이지로 이동.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
대형 PDF(14~40MB)에서 kordoc 파싱 timeout(60초) 실패하던 문제.
10MB당 60초 추가, 최소 60초 최대 300초로 조정.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SCMP(www.scmp.com)처럼 HTTPS 원본이 HTTP로 301 redirect하는 소스에서
redirect target이 차단되던 문제 수정. allow_http를 원본 스킴이 아닌
소스 도메인의 allowlist 등록 여부로 판단하도록 변경.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>