diff --git a/crates/kebab-app/tests/common/mock_ocr.rs b/crates/kebab-app/tests/common/mock_ocr.rs index 4dda829..f687271 100644 --- a/crates/kebab-app/tests/common/mock_ocr.rs +++ b/crates/kebab-app/tests/common/mock_ocr.rs @@ -57,8 +57,7 @@ impl OcrEngine for MockOcrEngine { // plain Display shows only the outer layer. A single-layer error // would render identically under `{}` and `{:#}`, so it could not // tell whether the provenance note keeps the cause (issue #239). - return Err(anyhow::anyhow!("mock inner cause")) - .context("mock failure"); + return Err(anyhow::anyhow!("mock inner cause")).context("mock failure"); } let mut idx = self.call_index.lock().unwrap(); let text = self diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d83a1a..12c2380 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab- | 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 | | LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) | | 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 | -| OCR (image) | `OcrEngine` trait, 2 백엔드: **`ollama-vision`** (default, `gemma4:e4b`) / **`paddle-onnx`** (v0.27.0 — PP-OCRv5 ONNX in-process via `ort` =2.0.0-rc.9, DBNet det + CTC rec, 후처리 min-area rect/unclip pure-Rust, Python 런타임 0). engine 선택은 `[image.ocr] engine`, 팩토리는 `kebab-app::build_image_ocr_engine`. e2e CER 0.005 / 큰 페이지 <4초. (HOTFIXES P6-2, 2026-06-04) **불변식**: rec 세션 입력 폭은 `REC_MIN_WIDTH = 5` 이상이어야 한다 — 백본이 `T = ceil((w-4)/8)` 개의 CTC 타임스텝을 내므로 `w <= 4` 는 특징맵을 0 열로 접어 ORT 가 세션 전체를 실패시킨다. 그 실패는 `recognize` 의 박스 루프를 뚫고 나가 이미 인식한 박스까지 전부 버리므로, 얇은 크롭은 세션에 넣지 말고 빈 문자열로 돌려보내야 한다 (issue #239). `parser_version = "image-meta-v2"` (issue #239 에서 v1 → v2, 기존 색인 이미지 재처리 유발). | +| OCR (image) | `OcrEngine` trait, 2 백엔드: **`ollama-vision`** (default, `gemma4:e4b`) / **`paddle-onnx`** (v0.27.0 — PP-OCRv5 ONNX in-process via `ort` =2.0.0-rc.9, DBNet det + CTC rec, 후처리 min-area rect/unclip pure-Rust, Python 런타임 0). engine 선택은 `[ingest.image.ocr] engine`, 팩토리는 `kebab-app::build_image_ocr_engine`. e2e CER 0.005 / 큰 페이지 <4초. (HOTFIXES P6-2, 2026-06-04) **불변식**: rec 세션 입력 폭은 `REC_MIN_WIDTH = 5` 이상이어야 한다 — 백본이 `T = ceil((w-4)/8)` 개의 CTC 타임스텝을 내므로 `w <= 4` 는 특징맵을 0 열로 접어 ORT 가 세션 전체를 실패시킨다. 그 실패는 `recognize` 의 박스 루프를 뚫고 나가 이미 인식한 박스까지 전부 버리므로, 얇은 크롭은 세션에 넣지 말고 빈 문자열로 돌려보내야 한다 (issue #239). `parser_version = "image-meta-v2"` (issue #239 에서 v1 → v2, 기존 색인 이미지 재처리 유발). | | OCR (PDF, v0.20.0+) | Ollama vision LM (default `qwen2.5vl:3b`) — post-extract enrichment via `kebab-app::pdf_ocr_apply` (H-1 resolution). DCTDecode-only v1 (FlateDecode/CCITTFax skip + warning). family asymmetry vs image OCR: PoC alnum 94.79% (qwen2.5vl) >> 27% (gemma4:e4b 받침), 본 단계에서 PDF OCR 만 qwen2.5vl. | | Image caption | Ollama vision LM, runtime gate `image.caption.enabled` (default OFF) | | RAG groundedness 검증 | `kebab-nli` 의 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 (fb-41). `[rag] nli_threshold > 0` (default 0 = disabled, production 권장 0.5) 일 때 활성 — 미달 시 `refusal_reason = nli_verification_failed` (LLM self-judge ceiling 보완). 첫 호출 시 ~280 MB ONNX 자동 다운로드 | diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 74daad4..f261100 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -79,9 +79,9 @@ PDF 노트의 `{e:#}` 도 `ocr_engine_failure_surfaces_as_warning` 이 고정한 36 장 중 4 장은 수정 후에도 0 자인데, 글자가 없는 사진(예: `photos/Aphid_2007_1.jpg`, 진딧물 접사)이라 정상이다. 이 이슈로 인한 손실과 "원래 글자가 없어서 비는 문서"를 혼동하면 안 된다 — 성공한 204 장 중에서도 31 장은 det 가 박스를 못 찾아 정상적으로 0 자다. KB 쪽에서 영향 문서를 셀 때는 본문 길이가 아니라 `provenance_json LIKE '%Invalid input shape%'` 로 걸러야 한다. 다만 이 쿼리에는 조건이 둘 붙는다. -**언제 세느냐.** 새 바이너리로 `kebab ingest` 를 돌리기 **전에** 세야 한다. 아래 §재색인 의 `parser_version` bump 가 해당 경로의 documents 행을 지우고 다시 쓰므로, 한 번 색인한 뒤에는 이 쿼리가 0 을 돌려준다. "업그레이드 → 색인 → 릴리스 노트 읽기" 순서로 가면 안 당한 것처럼 보인다. 이미 색인해 버렸다면 PDF 쪽은 `SELECT count(*) FROM pdf_ocr_events WHERE success = 0 AND reason = 'ocr_error'` 로 아직 셀 수 있다 — `pdf_ocr_events` 는 documents 에 FK 가 없어 purge 를 넘겨 살아남는다(`logging.retention_days` 기본 30 일 prune 만 받는다). +**언제 세느냐.** 새 바이너리로 `kebab ingest` 를 돌리기 **전에** 세야 한다. 아래 §재색인 의 `parser_version` bump 가 해당 경로의 documents 행을 지우고 다시 쓰므로, 한 번 색인한 뒤에는 이 쿼리가 0 을 돌려준다. "업그레이드 → 색인 → 릴리스 노트 읽기" 순서로 가면 안 당한 것처럼 보인다. 이미 색인해 버렸다면 PDF 쪽은 `SELECT count(DISTINCT doc_id) FROM pdf_ocr_events WHERE success = 0 AND reason = 'ocr_error' AND ocr_engine = 'paddle-onnx'` 로 아직 셀 수 있다 — `pdf_ocr_events` 는 documents 에 FK 가 없어 purge 를 넘겨 살아남는다(`logging.retention_days` 기본 30 일 prune 만 받는다). `count(*)` 가 아니라 `count(DISTINCT doc_id)` 인 이유는 이 표가 **페이지마다** 한 행이고 유니크 제약도 없어서, 40 쪽을 잃은 문서 하나가 40 을 더하고 수정 전 색인을 두 번 돌렸으면 또 두 배가 되기 때문이다. `ocr_engine` 을 거는 이유는 `'ocr_error'` 가 `recognize()` 의 모든 실패를 받는 통칭이라, 기본 엔진인 ollama-vision 을 쓰는 KB 에서도 #239 와 무관한 행이 쌓이기 때문이다. 이렇게 걸러도 paddle-onnx 의 다른 실패까지 포함하는 상한이라는 점은 남는다. -**스캔 PDF 는 이 필터로 안 걸린다.** 이번 수정 이전에 나간 **모든** 릴리스에서 — v0.33.0 을 포함해서 — PDF 경로는 노트를 `err={}` 로 찍었고, anyhow 의 기본 Display 는 가장 바깥 context 하나(`rec session run`)만 내보낸다. 하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 그래서 스캔본까지 세려면 `OR provenance_json LIKE '%err=rec session run%'` 을 반드시 함께 걸어야 한다. 이미지 경로는 처음부터 `{err:#}` 라 체인 전체가 들어갔고, 이번에 PDF 쪽도 `{e:#}` 로 맞췄으므로 앞으로 색인되는 것은 양쪽 다 첫 필터에 걸린다. +**스캔 PDF 는 이 필터로 안 걸린다.** 이번 수정 이전에 나간 **모든** 릴리스에서 — v0.33.0 을 포함해서 — PDF 경로는 노트를 `err={}` 로 찍었고, anyhow 의 기본 Display 는 가장 바깥 context 하나(`rec session run`)만 내보낸다. 노출 구간은 이렇다 — PDF OCR 엔진으로 paddle-onnx 를 고를 수 있게 된 것이 v0.31.0 이므로 그때부터 스캔 PDF 도 같은 손실을 겪을 수 있었고, 다만 v0.32.0 까지는 단일 DCTDecode 이미지 페이지만 OCR 대상이었다. 페이지 렌더링(#232)이 임의 인코딩까지 넓힌 v0.33.0 에서 대상이 크게 늘었으니 기록의 대부분은 그 릴리스가 만든 것이겠지만, 전부는 아니다. 그래서 스캔본까지 세려면 `OR provenance_json LIKE '%err=rec session run%'` 을 반드시 함께 걸어야 한다. 이미지 경로는 처음부터 `{err:#}` 라 체인 전체가 들어갔고, 이번에 PDF 쪽도 `{e:#}` 로 맞췄으므로 앞으로 색인되는 것은 양쪽 다 첫 필터에 걸린다. ### 재색인: 버전 두 개를 올렸다