`/documents?category=law` 같은 URL 이 프론트에서 무시되던 버그 — `+page.svelte` 의 filter state 에 `category` 가 빠져 있어 API 호출 시 `?category=` 가 서버로 전달 안 됐음. 결과적으로 default 목록 (news/law 만 제외한 전체) 이 반환됐다.
Sidebar '법령 알림' 버튼 (e88640d) + API `category` 필터 (§§2A) 는 이미 반영됨 — 프론트 middleware 만 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
document-caddy 가 home-caddy 로부터 받은 X-Forwarded-Proto: https 를
신뢰하지 않고 incoming scheme (http) 로 덮어써 FastAPI 가 받은 proto 가
http 로 인식 → /api/documents 307 Location 헤더가 http:// 로 나가
HTTPS 페이지에서 mixed-content block.
private_ranges 를 trusted_proxies 로 설정해 docker bridge 내부의
home-caddy 가 전달한 X-Forwarded-* 를 보존.
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>
frontend +page.svelte:
- 4-card 메인 row 아래 새 row 추가: 자료실/오디오/비디오 (category_counts) +
자료실 제안 (library_pending_suggestions). 제안 ≥1 일 때 warning 색 + /library 링크.
- buildPipelineRows 가 pipeline_status (24h 누적) + queue_lag (현재 시점) 머지.
queue_lag.oldest_pending_age_sec 가 600초 초과면 stage 라벨 옆에 경과시간 표시.
- STAGE_ORDER/LABEL 에 stt/thumbnail 추가 (§3 신규 stage 자동 커버).
docs/categories.md (신규):
- 6 활성 + 3 유보 카테고리 정의 + 저장 경로 + 처리 파이프
- 역할 분리 원칙 (category / user_tags @library/ / facet_doctype / ai_suggestion)
- 업로드 경로 매트릭스 (web/NAS/collector/UI)
- video 채널별 정책 표 (web 거부 vs NAS quarantine)
- 업로드 한도 + error_code 7종 표
- orphan 임시파일 cleanup 정책
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>
asyncpg prepared statement 는 single-command 만 지원 (core/database.py
exec_driver_sql 경로). §1 의 143_category.sql 이 4 statement (TYPE +
ALTER + INDEX×2) 였어서 fastapi 부팅 시 asyncpg.PostgresSyntaxError
"cannot insert multiple commands into a prepared statement" 로 실패
→ 컨테이너 restart 루프.
143 을 4 개 파일로 분리:
143: CREATE TYPE doc_category
144: ALTER TABLE documents ADD category / ai_suggestion
145: CREATE INDEX idx_documents_category
146: CREATE INDEX idx_documents_has_suggestion (partial)
DB 상태는 깨끗 (migration 143 이 부분 적용 안 됨 — asyncpg 가 batch
자체를 reject). schema_migrations 에 143 도 미기록이라 재실행 안전.
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>
이전 base image (pytorch/pytorch:2.5.1-cuda12.4) 가 surya-ocr 0.17.1 설치 시
torch 2.11.0 (PyPI CPU wheel) 로 업그레이드되지만 torchvision 0.20.1+cu124 는
유지돼 ABI 불일치 (torchvision::nms does not exist) → OCR 전체 실패.
native /opt/surya-ocr/venv 에서 검증된 조합으로 복제:
- python:3.12-slim base
- torch 2.11.0+cu126 / torchvision 0.26.0+cu126 (PyTorch cu126 index 고정)
- transformers 4.57.6 (5.x 는 surya detection.processor import 에서 실패)
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>
배경: Phase 3.5 fix2 로 서버 /ask 는 X-Source=eval 을 받아들이려면
X-Eval-Token 이 EVAL_RUNNER_TOKEN 와 일치해야 함. runner 에 해당 헤더
주입 경로가 없어 eval 호출이 전부 source='document_server' 로 강등됐음.
변경:
- call_ask / call_analyze: eval_token, eval_case_id 인자 추가. 조건부 헤더 주입
- run_eval: eval_token 파라미터 추가
- CLI: --eval-token 플래그 추가 (env EVAL_RUNNER_TOKEN 자동 fallback)
- main(): --source=eval + --eval-token 미지정 조합에 warning 출력
- eval_case_id 는 item id 자동 전달 → ask_events.eval_case_id join 키로 활용
E.6 재측정의 source='eval' 정확 기록 선결 조건.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
httpx 의 h11 레이어가 Content-Length 와 body 길이 불일치를 client-side 에서
LocalProtocolError 로 거절해서, CL 헤더만 override 해 서버 pre-check 경로를
외부에서 격리 테스트하는 것이 불가능했음. 대신 body 자체가 slack 임계치를
초과하는 케이스로 변경 — multipart CL 이 자동으로 `max_bytes * slack_ratio`
를 넘어 서버 pre-check 가 먼저 catch 함.
또한 기존 case 7 (CL 위조) 는 같은 이유로 실현 불가능해 제거. 5 케이스에서
6 케이스로 조정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B 의 스트리밍 size 검증을 외부에서 확인할 수 있는 스크립트.
pytest 인프라가 Phase 0 상태이므로 full test harness 구축을 미루고,
`scripts/verify_upload_size.py` 단일 파일로 경계 케이스를 즉시 회귀 검증.
7 케이스:
- 0 bytes → 400 (정책)
- 1 byte → 201 (happy path)
- max_bytes - 1 → 201 (경계 하)
- max_bytes 정확 → 201 (경계 상)
- max_bytes + 1 → 413 (스트리밍 차단)
- CL slack 초과 (override 헤더) → 413 (사전 차단)
- CL 위조 (작은 헤더 + 큰 body) → best-effort (서버 거절 status 수용)
`/api/config/public` 에서 max_bytes 를 동적 획득. slack_ratio 는 비공개라
스크립트 상수로 1.05 하드코딩 (config.yaml 과 동기화 유지 주석 명시).
Cleanup: 파일명 prefix `__upload_boundary_test__` + ns timestamp 로
실데이터와 격리. 시작 시 pre-cleanup + 각 케이스 직후 + finally 블록 cleanup.
`docker compose exec fastapi python /app/scripts/verify_upload_size.py` 로 실행.
UPLOAD_TEST_TOKEN + DATABASE_URL 환경 변수 필요. scripts/ 는 이미 read-only
volume 으로 마운트돼 있어 배포·재빌드 불필요.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
asyncpg 이 TIMESTAMPTZ 파라미터에 문자열 대신 datetime 객체를 요구
(DataError: invalid input, expected datetime instance, got str).
argparse type=datetime.fromisoformat 로 CLI 단계에서 파싱.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
asyncpg 이 $N IS NULL 비교에서 Python None 의 타입 추론 실패
(AmbiguousParameterError: could not determine data type of parameter).
None 인 조건은 WHERE 에서 아예 제외 — clauses 동적 조립.
부수 효과: 조건 0개일 때 "TRUE" 반환으로 quiet fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SQLAlchemy text() 의 `:name` 파라미터가 PostgreSQL `::type` cast 와
토큰 경계 충돌로 치환되지 않아 `syntax error at or near ":"` 발생.
`:since::timestamptz` → `CAST(:since AS TIMESTAMPTZ)` 로 변경.
Reproduction: --since/--until 옵션 사용 시 모든 집계 쿼리 실패.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 142 ALTER COLUMN source SET NOT NULL 자동 적용 방지.
_run_migrations 의 glob('*.sql') 비재귀 → _deferred/ 무시.
활성화 절차 (D7 참조):
- 138~141 적용 + 7일 운영 후 SELECT COUNT(*) FROM ask_events
WHERE source IS NULL AND created_at > <deploy> = 0 확인
- git mv migrations/_deferred/142_*.sql migrations/142_*.sql
- docker compose restart fastapi (init_db 가 자동 적용)
이유: 새 코드의 source 누락 가능성 empirical 검증 후 lock.
NOT NULL 적용 후 NULL INSERT 시도 시 ask_events 기록 실패 (data loss).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3.5 calibration runner (scripts/run_eval_ask.py, calibrate_ask.py)
가 생성하는 jsonl/log/csv 를 repo 에서 제외. reports/ 는 이미 tracked
파일 있어서 전체 ignore 하지 않음.
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>
프론트의 `MAX_UPLOAD_BYTES = 100 * 1000 * 1000` 하드코딩 상수를 제거하고
서버 `GET /api/config/public` 응답을 단일 진실 공급원으로 사용.
pre-check 자체는 그대로 유지 (UX 개선 — 대용량 파일을 edge proxy 까지
올리기 전 클라이언트에서 즉시 차단). 값의 출처만 서버로 이동.
변경:
- frontend/src/lib/stores/config.ts 신규 — publicConfig readable store
* 첫 구독 시 `/config/public` 1회 fetch
* fetch 실패 시 fallback 100MB 유지 (서버 enforcement 가 본선이라 안전)
- +layout.svelte onMount 에서 prewarm refresh() 호출
- UploadDropzone.svelte 에서 `$derived` 로 store 값을 반응형 구독
* `maxBytes` / `maxBytesLabel` 을 파생
* 에러 토스트 문구도 동적 라벨 사용 (`100MB` 하드코딩 제거)
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>
file_watcher.py:33 이 `Path(settings.nas_mount_path) / "PKM" / "Inbox"` 만
rglob 재귀 스캔함. 그러나 UI 문구는 "NAS의 PKM 폴더" 로 넓게 안내해
사용자가 PKM 바로 아래 다른 폴더(Reports, Archive 등) 에 파일을 두면
조용히 실패하는 silent dead end 가 생기던 문제를 정정.
또한 "5분 이내 자동 인덱싱" 같은 단정적 시간 약속을 제거. watcher 주기
(5분) 와 후속 처리 큐(extract/classify/embed) backlog 는 별개이며,
감시 주기만 5분이지 처리 완료가 5분 내라는 뜻이 아님. 숫자는 운영 지식
이지 UX 계약이 아니므로 UI 에서 제거하고 "감시 주기와 처리 대기열
상황에 따라 반영 시점은 달라질 수 있습니다" 로 정직하게 표현.
주석에서 `home-caddy` 외부 인프라 이름도 제거. 추후 Phase B 에서 이
한도는 서버가 내려주는 단일 계약값으로 이동 예정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caddy `request_body max_size 100MB`가 go-humanize SI(100,000,000 바이트)로
파싱되는데 클라이언트 pre-check는 `100 * 1024 * 1024`(104,857,600 바이트, MiB)로
비교해 100,000,001–104,857,600 바이트 구간 파일이 사전 차단을 통과한 뒤
서버에서 413을 받던 문제를 수정. 표시 라벨도 `/1024/1024`로 나누고 'MB'라
적어 경계값 파일이 "100MB 초과 … (100.0MB)" 같은 모순 문구를 노출했음.
요약 토스트가 사전 차단된 파일(`tooLarge`)을 카운트에서 제외해 드롭 수량과
불일치하던 문제도 함께 정리. `N건 용량 초과 스킵`을 tail로 붙이고, 전부
스킵된 경우엔 추가 토스트 없이 기존 에러 토스트만 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a842c65 패턴과 동일. asyncpg는 prepared statement에 단일 SQL만 허용.
- 135: ALTER TABLE만, 세미콜론 제거
- 136: CREATE INDEX 별도 파일
Co-Authored-By: Claude Opus 4.6 (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>
D.6: AnalysisPanel 컴포넌트 — 기본 접힌 상태 + '이 문서 분석' 버튼
- POST /documents/{id}/analyze 호출
- docId 변경 시 state 완전 리셋 ($effect)
- 층별 렌더 (근거/해설/사례/요약, 없는 층 생략)
- 에러 통일 문구 + 재시도/재분석 버튼
D.7: 문서 상세 페이지 우측 editors stack에 Card 래핑으로 삽입
- AIClassificationEditor 다음, FileInfoView 이전
- DocumentViewer / PreviewPanel 변경 없음
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
검색바에서 Enter → submitSearch()만 실행되어야 하는데
useListKeyboardNav의 window 리스너가 Enter를 잡아 selectDoc() 호출.
stopPropagation으로 이벤트 전파 차단.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
D.1: documents route 디자인 토큰 정리 (var(--*) → 시맨틱 토큰, 잔여 0)
D.2: isQuestion 질문형 감지 유틸 (? 단일단어 허용, 한/영 6규칙)
D.3: AskAnswerCard 컴팩트 답변 카드 + analyze.ts 타입 정의
D.4: 질문형 검색 시 /search/ask 병렬 호출 + 상단 카드 배치
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>