chore: PR #238 회차 3 리뷰 반영 — 검증 안 하던 테스트 교체

3회차 리뷰가 `.ok().flatten()` 의 타당성과 `long_edge_for_dpi` 비공개화를
확인하고 머지 가능으로 결론냈다. 남은 둘을 반영한다.

1) 2회차에 넣은 테스트 하나가 아무것도 검증하지 않았다 (MEDIUM)

   "렌더러가 PDF 를 못 여는 경우" 테스트에 잘린 바이트를 썼는데 **lopdf 가
   그걸 먼저 거부**했다. 함수 초반 `load_mem(...)?` 에서 리턴되므로 pdfium
   은 호출조차 안 됐고 `(None, true)` 분기는 한 번도 실행되지 않았다.
   통과하는데 아무것도 지키지 않는 테스트였다 — 1회차에서 "렌더가 실패하지
   않으니 도그푸딩도 통과해 버렸다" 고 배운 것과 같은 함정을 테스트에서
   반복한 셈이다.

   **암호 없는 암호화 PDF** 픽스처로 교체했다. lopdf 는 객체 그래프를 읽고
   pdfium 은 `PasswordError` 로 거부하는, 정확히 원하던 불일치다. 이제
   `failure_reason` 까지 단언하고, `unopenable_pdf` 를 `no_renderer` 로
   되돌리면 실패하는 것을 확인했다.

2) `(None, true)` 분기에도 `?` 가 남아 있었다 (MEDIUM)

   `(Some, _)` 와 근거가 같고 오히려 더 강하다 — pdfium 이 문서 전체를
   열지 못한 상태라 lopdf 도 깨져 있을 상관관계가 최대인 지점이다.

3) 잔가지 (LOW)

   - 삼킨 DCT 에러를 `inspect_err` 로 debug 로그에 남긴다. 바로 아래 OCR
     캐시 GET 이 같은 규율을 쓰는데 이 자리만 비어 있었다.
   - README 의 max_pixels 하드캡 안내를 "ollama-vision 엔진" 에서 "OCR
     엔진(둘 다)" 으로 — paddle-onnx 도 동일하게 조인다.

미반영으로 **명시**: `(Some, _)` 의 `?` 수정에는 테스트가 없다. 루프가
`get_pages()` 가 나열한 페이지만 도는데 그 조회가 곧
`extract_dctdecode_page_image` 가 실패하는 조건이라, 이 arm 의 에러 경로에
닿는 픽스처를 만들 수 없다. 0-페이지 PDF 로 시도했다가 루프 자체가 안 도는
것을 확인하고 접었고, 만들었던 테스트와 픽스처는 지웠다 — 통과하지만
아무것도 안 지키는 테스트를 또 만드는 것보다 없는 편이 정직하다. 왜
못 만드는지를 코드 주석에 남겼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
2026-08-17 03:36:03 +09:00
parent 7b3057fcd6
commit df76e5e874
5 changed files with 98 additions and 27 deletions

View File

@@ -172,7 +172,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn
- **`[ingest.image.ocr]`** — 이미지 OCR. on/off 토글(`enabled`, default off / opt-in)은 미디어별이며, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 할 수 있다. `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능. engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
- **`[ingest.pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). on/off 토글(`enabled`/`always_on`)과 PDF 고유 키(`valid_ratio_threshold`/`min_char_count`/`lang_hint`)는 미디어별이고, 엔진 설정은 `[ingest.ocr]` 에서 상속하되 이 블록에서 override 한다(PDF 기본 모델은 `qwen2.5vl:3b`, 이미지의 `gemma4:e4b` 와 다름 — 미디어별 기본값 보존). 활성화 후 옛 색인분은 `kebab ingest --force-reingest` 로 재처리.
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library``libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped``--json``ocr_skipped_pages` 에 찍힌다. `kebab doctor``pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도 **요청**(기본 300)이고 실제로는 `max_pixels` 가 이긴다 — PDF 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 300 을 실제로 쓰려면 `max_pixels` 를 3500 이상으로 올려야 하고, 그만큼 큰 이미지를 OCR 엔진이 받는다. 다만 ollama-vision 엔진`max_pixels` 를 256~4096 으로 다시 조인다 — 그보다 크게 적어도 4096 이 상한이다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
**스캔본을 제대로 읽으려면 페이지 렌더러가 필요하다.** `render_library``libpdfium` 경로를 적거나 로더가 찾는 곳에 두면, 페이지 이미지가 어떤 인코딩이든(CCITTFax·JBIG2·Flate·JPX, 배경+마스크 분리 구조 포함) OCR 된다. 없으면 **단일 DCTDecode(JPEG) 이미지 페이지만** OCR 되고 나머지는 본문 없이 색인되며, 그 건수가 ingest 요약의 `ocr-skipped``--json``ocr_skipped_pages` 에 찍힌다. `kebab doctor``pdf_render` 가 지금 어느 쪽인지 알려준다. `render_dpi` 는 렌더 해상도 **요청**(기본 300)이고 실제로는 `max_pixels` 가 이긴다 — PDF 기본값 `max_pixels = 2048` 이면 A4 는 175 DPI 언저리에서 잘린다. 300 을 실제로 쓰려면 `max_pixels` 를 3500 이상으로 올려야 하고, 그만큼 큰 이미지를 OCR 엔진이 받는다. 다만 OCR 엔진(ollama-vision·paddle-onnx 둘 다)`max_pixels` 를 256~4096 으로 다시 조인다 — 그보다 크게 적어도 4096 이 상한이다. pdfium 은 공유 라이브러리로만 배포돼서 바이너리에 넣지 않았다 — kebab 자체는 단일 실행 파일 그대로다.
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI honor).
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
- **`KEBAB_*` env** — 런타임 override용 ~22개 키만 노출. 엔드포인트(`KEBAB_MODELS_LLM_ENDPOINT`, `KEBAB_MODELS_EMBEDDING_ENDPOINT`, `KEBAB_OCR_ENDPOINT`), 모델명/프로바이더(`KEBAB_MODELS_LLM_MODEL`, `KEBAB_MODELS_EMBEDDING_MODEL`, `KEBAB_MODELS_EMBEDDING_PROVIDER`, `KEBAB_MODELS_LLM_PROVIDER`, `KEBAB_MODELS_NLI_MODEL`), 경로(`KEBAB_WORKSPACE_ROOT`, `KEBAB_STORAGE_DATA_DIR`), 병렬도(`KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS`, `KEBAB_INDEXING_MAX_PARALLEL_EMBEDDINGS`), 청킹(`KEBAB_CHUNKING_TARGET_TOKENS`, `KEBAB_CHUNKING_OVERLAP_TOKENS`), OCR 토글/엔진/언어(`KEBAB_IMAGE_OCR_ENABLED`, `KEBAB_PDF_OCR_ENABLED`, `KEBAB_OCR_ENGINE`, `KEBAB_OCR_MODEL`, `KEBAB_OCR_LANGUAGES`), 기타(`KEBAB_IMAGE_CAPTION_ENABLED`, `KEBAB_SEARCH_DEFAULT_K`, `KEBAB_RAG_PROMPT_TEMPLATE_VERSION`). 나머지 세부 튜닝 키(score_gate, rrf_k, temperature 등)는 `config.toml` 전용. 특수: `KEBAB_READONLY=1`(write-path 비활성), `KEBAB_PROGRESS=plain`(non-TTY 진행 출력), `KEBAB_EVAL_GOLDEN`(eval golden set 경로).

View File

@@ -313,7 +313,19 @@ where
// one DCTDecode image, which is why rendering exists — but it is
// still the right thing to try when rendering is unavailable or
// fails for this particular page.
let dct = |doc: &LopdfDocument| extract_dctdecode_page_image(doc, page_num);
let dct = |doc: &LopdfDocument| {
extract_dctdecode_page_image(doc, page_num).inspect_err(|e| {
// Swallowed by the callers below on purpose — a page-local
// parse failure must not abort the document. Logged so the
// reason is recoverable, matching the OCR cache GET below.
tracing::debug!(
target: "kebab-app::pdf_ocr",
page = page_num,
error = %e,
"DCTDecode extraction failed; treating as no raster for this page"
);
})
};
let rasterized = match (rendered.as_ref(), renderer_configured) {
(Some(doc), _) => match doc.render_page_png(page_num, opts.render_dpi, opts.max_pixels)
@@ -330,13 +342,24 @@ where
// into an aborted document — the opposite of this loop's
// per-page `continue`-on-error discipline, and the same
// silent-total-loss shape this whole change is about.
//
// Defensive rather than demonstrated: the loop only visits
// pages `get_pages()` lists, which is the same lookup that
// makes `extract_dctdecode_page_image` fail, so no fixture
// reaches this arm's error path. An attempt at a test for
// it passed while exercising nothing and was removed
// rather than kept as false coverage.
Err(e) => match dct(&pdf_doc).ok().flatten() {
Some(b) => Ok(b),
None => Err(RasterFailure::Render(e.to_string())),
},
},
// A renderer is configured but could not open this PDF.
(None, true) => match dct(&pdf_doc)? {
// A renderer is configured but could not open this PDF. Same
// reasoning as above and stronger: reaching here means pdfium
// rejected the whole document, so the odds that lopdf also
// stumbles on it are at their highest — exactly where an `?`
// would turn one file into an aborted run.
(None, true) => match dct(&pdf_doc).ok().flatten() {
Some(b) => Ok(b),
None => Err(RasterFailure::Unopenable),
},

View File

@@ -491,39 +491,52 @@ fn a_dctdecode_page_still_works_with_a_renderer_configured() {
assert_eq!(summary.pages_skipped, 0);
}
/// A PDF that lopdf parses but pdfium refuses must degrade to the
/// DCTDecode path rather than reporting `no_renderer` — telling a user
/// who already configured a renderer to configure one is the wrong
/// instruction, and giving up on a page the old path could read would
/// make installing pdfium a downgrade.
/// A PDF that lopdf parses but pdfium refuses. The renderer is
/// configured and working — it simply cannot open *this* file — so the
/// page must fall through to the DCTDecode path and, when that also has
/// nothing, be reported as `unopenable_pdf` rather than `no_renderer`.
/// Telling a user who already configured a renderer to configure one is
/// the wrong instruction.
///
/// Constructed rather than fixtured: the case is a disagreement between
/// two parsers, which is easier to state than to find in the wild.
/// The fixture is an encrypted PDF with no password: lopdf reads the
/// object graph without decrypting, pdfium refuses outright
/// (`PasswordError`). A first attempt at this test used truncated bytes,
/// which lopdf rejected before the branch was ever reached — the test
/// passed while exercising nothing.
#[test]
#[ignore = "requires libpdfium"]
fn a_pdf_the_renderer_cannot_open_falls_back_instead_of_blaming_config() {
// Truncated after the header: lopdf's lenient path still yields a
// document object, pdfium refuses it outright.
let bytes = b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n".to_vec();
let bytes = std::fs::read("../kebab-parse-pdf/tests/fixtures/encrypted_no_password.pdf")
.expect("encrypted fixture missing");
let mut canonical = canonical_with_empty_block();
let engine = MockOcrEngine::single("SHOULD_NOT_BE_CALLED", false);
// Either lopdf also rejects it (then this test has nothing to say and
// the error surfaces) or the run completes with the page skipped —
// what must never happen is a panic or a silent success.
let result = apply_ocr_to_pdf_pages(
let mut reasons = Vec::new();
let summary = apply_ocr_to_pdf_pages(
&mut canonical,
&engine,
&bytes,
&opts_with_renderer(),
|_| {},
|p| {
if let kebab_app::pdf_ocr_apply::PdfOcrProgress::Finished {
failure_reason: Some(r),
..
} = p
{
reasons.push(r);
}
},
)
.expect("a PDF the renderer cannot open must not abort the run");
assert_eq!(summary.pages_ocrd, 0, "nothing could be rasterized");
assert_eq!(
summary.pages_skipped, 1,
"and the page is counted as skipped"
);
assert_eq!(
reasons,
vec!["unopenable_pdf".to_string()],
"the reason must name the real problem, not a missing renderer"
);
// `Err` means lopdf rejected it first, which is the pre-existing path
// and not this test's subject.
if let Ok(summary) = result {
assert_eq!(
summary.pages_ocrd, 0,
"nothing can be read from a PDF neither parser accepts"
);
}
}

View File

@@ -0,0 +1,25 @@
%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids[3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>
endobj
4 0 obj
<</Filter/Standard/V 2/R 3/Length 128/P -1/O<0000000000000000000000000000000000000000000000000000000000000000>/U<0000000000000000000000000000000000000000000000000000000000000000>>>
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000054 00000 n
0000000105 00000 n
0000000170 00000 n
trailer
<</Size 5/Root 1 0 R/Encrypt 4 0 R/ID[<01><02>]>>
startxref
366
%%EOF

View File

@@ -128,6 +128,16 @@ OCR 브렌다(메이즈 러너 시리즈) / 개요 / 원작 소설과 영화
이때 새 라벨이 제 역할을 했다. 타임아웃 페이지가 "래스터 없음" 이 아니라 **"OCR 엔진 실패"** 로 찍혀서, 렌더링 문제가 아니라 엔진 문제라는 게 로그만 보고 갈렸다.
### 리뷰가 잡은 것 — 내 테스트가 아무것도 검증하지 않고 있었다
2회차에서 새 세 분기에 테스트를 넣었는데, 그중 "렌더러가 PDF 를 못 여는 경우" 테스트는 **잘린 바이트를 썼고 lopdf 가 그걸 먼저 거부했다**. 함수 초반 `load_mem(...)?` 에서 리턴되므로 pdfium 은 호출조차 안 됐고, 검증 대상 분기는 한 번도 실행되지 않았다. 통과하는데 아무것도 지키지 않는 테스트였다.
**암호 없는 암호화 PDF** 로 픽스처를 만들어 고쳤다 — lopdf 는 객체 그래프를 읽고 pdfium 은 `PasswordError` 로 거부한다. 정확히 원하던 불일치다. `unopenable_pdf``no_renderer` 로 되돌리면 실패하는 것을 확인했다.
같은 회차에서 `(None, true)` 분기에도 `?` 가 남아 있던 것을 고쳤다. `(Some, _)` 와 근거가 같고 오히려 더 강하다 — pdfium 이 문서 전체를 열지 못한 상태라 lopdf 도 깨져 있을 상관관계가 최대인 지점이다.
`(Some, _)``?` 수정은 **테스트 없이 남긴다**. 루프가 `get_pages()` 가 나열한 페이지만 도는데 그 조회가 곧 `extract_dctdecode_page_image` 가 실패하는 조건이라, 이 arm 의 에러 경로에 닿는 픽스처를 만들 수 없다. 0-페이지 PDF 로 시도했다가 루프 자체가 안 도는 것을 확인하고 접었다 — 통과하지만 아무것도 안 지키는 테스트를 또 만드는 것보다 없는 편이 정직하다. 근거를 코드 주석에 남겼다.
### 범위 밖
이슈가 권한 "DCTDecode 고속 경로를 남기지 말 것" 은 따르지 않았다. 폴백이 곧 그 경로이고, 폴백을 두는 것이 배포 결정의 귀결이다. 다만 렌더러가 있으면 그 경로는 타지 않는다.