test(pdf): integration smoke (w/ search + cancel) + vector regression + alnum e2e (#[ignore]) for v0.20 sub-item 1

Step 9 (Group I) of v0.20.0 sub-item 1 (scanned PDF OCR) plan.

I3 — crates/kebab-app/tests/ingest_pdf_ocr_smoke.rs (신규):
- ingest_with_mock_ocr_yields_pdf_ocr_summary — `#[ignore]` real Ollama,
  ingest_with_config production path + IngestItem.pdf_ocr_pages verify.
- ocr_text_indexed_and_searchable — `#[ignore]` real Ollama, app.search
  의 OCR text indexed verify (§ Acceptance #2).
- ingest_with_cancel_aborts_mid_pdf — production cancel chain (pre-set
  cancel=true + dummy endpoint, no panic/deadlock verify).

I4 — crates/kebab-parse-pdf/tests/text_extractor_regression.rs (신규):
- vector_pdf_extract_byte_identical_to_baseline — F4 mojibake.pdf 의 vector
  PDF path canonical 의 byte-identical 보존 (Step 1-8 모든 변경 전후 invariant).
- baseline 신규 = tests/snapshots/vector_pdf_canonical.json (first run create).
- normalize_provenance_timestamps inline helper (R-3 mitigation, workspace
  전체 부재 — 신규 12-line).

I5 — crates/kebab-parse-pdf/tests/ocr_e2e.rs (신규):
- f1_alnum_accuracy_ge_85 / f2_alnum_accuracy_ge_70 — `#[ignore]` real
  Ollama qwen2.5vl:3b, § Acceptance §9 #3 의 implementation.
- alnum metric = strsim::levenshtein (dev-dep 추가).
- truth file copy from PoC scratch (page1.txt + page2-batchim.txt) →
  scanned_page1_truth.txt + scanned_page2_truth.txt.
- kebab-parse-image dev-dep 추가 (OllamaVisionOcr::from_parts 호출용).
  parser isolation invariant 의 dev-dep exception (spec §3.1, dep graph
  baseline -e normal 보존).

spec:  docs/superpowers/specs/2026-05-27-pdf-scanned-ocr-spec.md
plan:  docs/superpowers/plans/2026-05-27-pdf-scanned-ocr-plan.md (Step 9 I3+I4+I5)
prior: c9e0594 (Step 8 CLI printer)
contract: §9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 10:10:58 +00:00
parent c9e05941c5
commit 48197687b7
7 changed files with 384 additions and 1 deletions

View File

@@ -0,0 +1,120 @@
//! Integration smoke tests for the PDF OCR pipeline (§ Acceptance §9 #1 + #2).
//!
//! Tests 1 and 2 require a live Ollama endpoint — `#[ignore]` by default.
//! Manual invoke:
//! KEBAB_PDF_OCR_ENDPOINT=http://192.168.0.47:11434 \
//! cargo test -p kebab-app --test ingest_pdf_ocr_smoke --ignored -j 4
//!
//! Test 3 (cancel) uses a dummy endpoint + pre-set cancel — runs by default
//! to verify the cancel wiring doesn't panic/deadlock.
mod common;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use common::TestEnv;
fn ollama_endpoint() -> String {
std::env::var("KEBAB_PDF_OCR_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:11434".to_string())
}
fn make_ocr_env_real() -> TestEnv {
let mut env = TestEnv::lexical_only();
env.config.pdf.ocr.enabled = true;
env.config.pdf.ocr.endpoint = Some(ollama_endpoint());
env.config.models.embedding.provider = "none".to_string();
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("kebab-parse-pdf/tests/fixtures/scanned_page1.pdf");
let dest = env.workspace_root.join("scanned_page1.pdf");
std::fs::copy(&src, &dest).expect("copy scanned_page1.pdf to workspace");
env
}
/// § Acceptance §9 #1 — real Ollama OCR + IngestItem.pdf_ocr_pages = Some(1).
#[test]
#[ignore = "real Ollama qwen2.5vl:3b dependency"]
fn ingest_with_mock_ocr_yields_pdf_ocr_summary() {
let env = make_ocr_env_real();
let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), false)
.expect("ingest");
assert!(report.new >= 1, "at least one PDF ingested: {report:?}");
let items = report.items.unwrap_or_default();
let pdf_item = items.iter().find(|i| i.doc_path.0.ends_with(".pdf"));
assert!(
pdf_item.is_some(),
"PDF item must appear in ingest report items: {items:?}"
);
let pdf_item = pdf_item.unwrap();
assert!(
pdf_item.pdf_ocr_pages.is_some(),
"pdf_ocr_pages must be set for scanned PDF: {pdf_item:?}"
);
assert_eq!(
pdf_item.pdf_ocr_pages.unwrap(),
1,
"scanned_page1.pdf has exactly 1 page"
);
}
/// § Acceptance §9 #2 — OCR text indexed and retrievable via lexical search.
#[test]
#[ignore = "real Ollama qwen2.5vl:3b dependency"]
fn ocr_text_indexed_and_searchable() {
let env = make_ocr_env_real();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), false)
.expect("ingest");
// Search for a Korean morpheme expected to appear in qwen2.5vl:3b OCR
// output of the PoC ground-truth page. "다음" is a high-frequency token
// in page1.txt truth file.
let query = common::lexical_query("다음");
let hits =
kebab_app::search_with_config(env.config.clone(), query).expect("search");
assert!(
!hits.is_empty(),
"OCR-indexed text must surface in lexical search results"
);
}
/// Production cancel wiring smoke — pre-set cancel exits before any OCR call.
/// Dummy endpoint (port 1 = connection-refused) means OCR HTTP calls would
/// fail, but cancel=true prevents the loop from reaching OCR at all.
/// Verifies no panic/deadlock regardless of Ok/Err outcome.
#[test]
fn ingest_with_cancel_aborts_mid_pdf() {
let mut env = TestEnv::lexical_only();
env.config.pdf.ocr.enabled = true;
env.config.pdf.ocr.endpoint = Some("http://127.0.0.1:1".to_string());
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("kebab-parse-pdf/tests/fixtures/scanned_page1.pdf");
let dest = env.workspace_root.join("scanned_page1.pdf");
std::fs::copy(&src, &dest).expect("copy scanned_page1.pdf to workspace");
let cancel = Arc::new(AtomicBool::new(true)); // pre-set — abort immediately
let result = kebab_app::ingest_with_config_cancellable(
env.config.clone(),
env.scope(),
false,
None,
Some(cancel),
);
// Both Ok (pre-cancel exit) and Err (eager OCR engine fail) are acceptable —
// key assertion is no panic/deadlock.
let _ = result;
}

View File

@@ -23,7 +23,10 @@ tracing = { workspace = true }
lopdf = { workspace = true }
[dev-dependencies]
blake3 = { workspace = true }
anyhow = { workspace = true }
blake3 = { workspace = true }
kebab-parse-image = { path = "../kebab-parse-image" }
strsim = "0.11"
[lints]
workspace = true

View File

@@ -0,0 +1,32 @@
한국어 OCR 품질 검증을 위한 합성 문서
본 문서는 Tesseract OCR 의 한국어 인식 정확도를 측정하기 위한 ground truth 입니다. 다양한 한국어 문자 유형을 포함합니다.
1. 일반 한국어 문장
오늘은 2026년 5월 27일 화요일이다. 날씨가 맑고 기온은 섭씨 22도이다. 봄의 끝자락에서 여름이 다가오고 있다.
2. 한자 혼용
大韓民國은 동아시아의 한 국가이다. 首都는 서울特別市이며 인구는 약 5천만 명이다. 國語는 한국어이고 文字는 한글을 사용한다.
3. 영문 혼용
RAG (Retrieval-Augmented Generation) 는 검색 기반 생성 모델이다. Tesseract 는 Google 이 후원하는 OCR 엔진이다. Apache 2.0 라이선스로 배포된다.
4. 숫자와 기호 혼합
- 가격: 12,345원 (USD $9.99)
- 좌표: (37.5665, 126.9780)
- 비율: 95.7% 정확도 달성
- 날짜: 2026-05-27, 14:30:00
5. 기술 용어
RAM 16GB, CPU 8코어, GPU 없음. 디스크 사용량은 250GB 중 51GB. PostgreSQL 와 SQLite 데이터베이스 모두 지원한다.
6. 짧은 문장 모음
- 빛의 속도는 약 30만 km/s 이다.
- 지구는 태양 주위를 1년에 한 번 돈다.
- 사람의 평균 체온은 36.5도이다.
- 한 시간은 60분, 1분은 60초이다.
7. 어려운 글자
의사: 의의있는 의미를 의문스럽게 의도한다.
받침이 복잡한 글자: 깎, 닭, 흙, 읊, 닮, 값.
이중 모음: 의자, 예의, 외출, 위험, 왜냐하면.

View File

@@ -0,0 +1,53 @@
한국어 OCR 의 받침 처리 정확도 측정용 page
본 페이지는 Tesseract 의 "Detected diacritics" 경고가 가리키는 받침 (종성)
인식 약점을 isolate 측정하기 위한 fixture 입니다.
1. 단순 받침 모음
- 단일 자음: 각, 간, 갈, 감, 갑, 갓, 강, 낙, 난, 날, 남, 납, 낫, 낭
- 받침 ㄱ: 학생, 식당, 약속, 작품, 음악, 책방, 박사, 직업, 목표, 적극적
- 받침 ㄴ: 친구, 인생, 운명, 분야, 단어, 관심, 인간, 진리, 안녕, 인사
- 받침 ㄷ: 받침, 듣다, 닫다, 굳다, 곧, 뜻, 멋, 옷, 짓다, 맺다
- 받침 ㄹ: 알다, 살다, 일상, 길이, 발음, 별명, 졸업, 길거리, 일요일
- 받침 ㅁ: 그림, 사람, 마음, 점심, 봄, 새벽, 검사, 점점, 다음, 처음
- 받침 ㅂ: 입학, 답안, 합격, 잡지, 컵, 밥, 십대, 압력, 깁스, 갈증
- 받침 ㅅ: 옷, 곳, 멋, 짓, 벗, 깃, 갓, 빗, 솟, 첫째, 갓길, 옷장
- 받침 ㅇ: 영화, 강의, 정답, 평균, 동물, 풍경, 송별, 응원, 항상, 통화
- 받침 ㅈ: 낮, 빛, 옻, 갖다, 빚, 짓, 맞다, 잊다, 좇다, 갖춤, 짖다
- 받침 ㅊ: 꽃, 빛, 낯, 숯, 옻, 닻, 빚, 갖, 닻, 쫓다, 옻나무, 닻줄
- 받침 ㅋ: 들녘, 부엌, 들녘길, 부엌일, 들녘 풍경
- 받침 ㅌ: 밭, 솥, 끝, 곁, 팥, 받, 솥뚜껑, 밭일, 끝없다, 팥죽
- 받침 ㅍ: 잎, 숲, 옆, 짚, 늪, 깊다, 갚다, 높다, 옆자리, 무릎
- 받침 ㅎ: 좋다, 많다, 끊다, 닿다, 옳다, 잃다, 싫다, 뚫다, 놓다, 쌓다
2. 겹받침 (이중 자음)
- ㄳ: 몫, 삯, 넋, 몫이, 삯을, 넋이
- ㄵ: 앉다, 얹다, 앉아, 얹어, 앉으니
- ㄶ: 많다, 끊다, 괜찮다, 않다, 많이, 끊임없이
- ㄺ: 닭, 흙, 칡, 읽다, 굵다, 늙다, 닭고기, 흙길
- ㄻ: 삶, 닮다, 굶다, 옮기다, 곪다, 삶의, 닮은
- ㄼ: 넓다, 짧다, 얇다, 떫다, 넓이, 짧게, 얇은
- ㄽ: 외곬, 외곬으로 (드문 받침)
- ㄾ: 핥다, 훑다, 핥아, 훑어, 핥는
- ㄿ: 읊다, 읊어, 읊는 (드문)
- ㅀ: 잃다, 싫다, 끓다, 닳다, 뚫다, 잃어, 싫어
- ㅄ: 값, 없다, 가엾다, 값이, 없이, 가엾은
3. 한자 + 한글 받침 혼용
大韓民國의 韓國語는 表音文字 한글로 表記한다. 學生은 學校에서 工夫하고
先生님은 講義하신다. 古代 朝鮮의 文化는 中國과 日本에 影響을 주었다.
經濟學者들은 物價 上昇을 警告한다. 國民은 政府의 政策을 信賴해야 한다.
이 문장의 받침이 한자와 함께 정확히 인식되는지가 중요한 측정 지표다.
4. 받침 의미 변화 예시
- 산다 (live) vs 산 (mountain) vs 살다 (live) vs 살 (flesh)
- 가다 (go) vs 간 (liver) vs 갔다 (went) vs 강 (river)
- 먹다 (eat) vs 먹 (ink stick) vs 먹었다 (ate) vs 멍 (bruise)
- 보다 (see) vs 본 (origin) vs 봤다 (saw) vs 봄 (spring)
받침 한 글자 차이로 의미 완전 변경 → OCR 정확도가 검색 결과 직결.
5. 외래어 받침
- 마이크, 컴퓨터, 인터넷, 텔레비전, 라디오, 카메라, 스피커
- 시스템, 프로그램, 데이터, 알고리즘, 인공지능, 머신러닝
- 박테리아, 바이러스, 백신, 안티바이러스, 방화벽
- 파일, 폴더, 디렉토리, 다운로드, 업로드, 백업

View File

@@ -0,0 +1,62 @@
// § Acceptance §9 #3: real Ollama qwen2.5vl:3b 의 alnum accuracy.
// F1 ≥ 0.85, F2 ≥ 0.70. real Ollama 의존 — `#[ignore]` default.
//
// Manual invoke:
// KEBAB_PDF_OCR_ENDPOINT=http://192.168.0.47:11434 \
// cargo test -p kebab-parse-pdf --test ocr_e2e --ignored -j 4
use kebab_core::Lang;
use kebab_parse_image::{OcrEngine, OllamaVisionOcr};
use kebab_parse_pdf::extract_dctdecode_page_image;
use lopdf::Document;
fn run_real_ollama_ocr(pdf: &[u8], page: u32) -> anyhow::Result<String> {
let endpoint = std::env::var("KEBAB_PDF_OCR_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:11434".to_string());
let doc = Document::load_mem(pdf)?;
let jpeg = extract_dctdecode_page_image(&doc, page)?
.ok_or_else(|| anyhow::anyhow!("page {page} 의 DCTDecode image XObject 부재"))?;
let engine = OllamaVisionOcr::from_parts(
endpoint,
"qwen2.5vl:3b".to_string(),
vec!["eng".to_string(), "kor".to_string()],
2048,
600,
)?;
let result = engine.recognize(&jpeg, Some(&Lang("kor".into())))?;
Ok(result.joined)
}
fn alnum_accuracy(actual: &str, expected: &str) -> f32 {
let a: String = actual.chars().filter(|c| c.is_alphanumeric()).collect();
let e: String = expected.chars().filter(|c| c.is_alphanumeric()).collect();
if e.is_empty() {
return 0.0;
}
let dist = strsim::levenshtein(&a, &e) as f32;
((e.chars().count() as f32 - dist) / e.chars().count() as f32).max(0.0)
}
#[test]
#[ignore = "real Ollama qwen2.5vl:3b dependency"]
fn f1_alnum_accuracy_ge_85() {
let pdf = include_bytes!("fixtures/scanned_page1.pdf");
let ocr = run_real_ollama_ocr(pdf, 1).expect("OCR");
let expected = include_str!("fixtures/scanned_page1_truth.txt");
let accuracy = alnum_accuracy(&ocr, expected);
println!("F1 alnum accuracy = {accuracy:.4}");
assert!(accuracy >= 0.85, "F1 alnum accuracy {accuracy:.4} < 0.85");
}
#[test]
#[ignore = "real Ollama qwen2.5vl:3b dependency"]
fn f2_alnum_accuracy_ge_70() {
let pdf = include_bytes!("fixtures/scanned_page2.pdf");
let ocr = run_real_ollama_ocr(pdf, 1).expect("OCR");
let expected = include_str!("fixtures/scanned_page2_truth.txt");
let accuracy = alnum_accuracy(&ocr, expected);
println!("F2 alnum accuracy = {accuracy:.4}");
assert!(accuracy >= 0.70, "F2 alnum accuracy {accuracy:.4} < 0.70");
}

View File

@@ -0,0 +1,43 @@
{
"doc_id": "c90fae7576fe514fb08190cb29d1ef5d",
"source_asset_id": "babe9824b6b28237c0898575a40ba48d",
"workspace_path": "mojibake.pdf",
"title": "mojibake",
"lang": "und",
"blocks": [],
"metadata": {
"aliases": [],
"tags": [],
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"source_type": "paper",
"trust_level": "primary",
"user_id_alias": null,
"user": {
"pdf": {
"page_count": 0
}
}
},
"provenance": {
"events": [
{
"at": "1970-01-01T00:00:00Z",
"agent": "kb-source-fs",
"kind": "discovered",
"note": null
},
{
"at": "1970-01-01T00:00:00Z",
"agent": "kb-parse-pdf",
"kind": "parsed",
"note": "parser_version=pdf-text-v1; page_count=0"
}
]
},
"parser_version": "pdf-text-v1",
"schema_version": 1,
"doc_version": 1,
"last_chunker_version": null,
"last_embedding_version": null
}

View File

@@ -0,0 +1,70 @@
//! Byte-identical regression for the vector PDF extraction path (spec §5.4).
//! Uses F4 (mojibake.pdf) — the only fixture with extractable text content.
//! First invocation creates the baseline snapshot; subsequent runs verify
//! identity to detect silent regressions across all Step 1-8 changes.
use std::path::Path;
use kebab_core::{
AssetStorage, Checksum, ExtractConfig, ExtractContext, Extractor, MediaType, RawAsset,
SourceUri, WorkspacePath, id_for_asset,
};
use kebab_parse_pdf::PdfTextExtractor;
use time::OffsetDateTime;
/// Normalize all provenance timestamps to UNIX_EPOCH so the snapshot is
/// byte-stable across runs (R-3 mitigation — no workspace helper exists).
fn normalize_provenance_timestamps(doc: &mut kebab_core::CanonicalDocument) {
for event in &mut doc.provenance.events {
event.at = OffsetDateTime::UNIX_EPOCH;
}
}
fn make_raw_asset(path: &str) -> RawAsset {
let fake_hash = "0".repeat(64);
let asset_id = id_for_asset(&fake_hash);
RawAsset {
asset_id,
source_uri: SourceUri::File(std::path::PathBuf::from(path)),
workspace_path: WorkspacePath::new(path.to_string()).unwrap(),
media_type: MediaType::Pdf,
byte_len: 0,
checksum: Checksum(fake_hash),
discovered_at: OffsetDateTime::UNIX_EPOCH,
stored: AssetStorage::Copied {
path: std::path::PathBuf::from(path),
},
}
}
#[test]
fn vector_pdf_extract_byte_identical_to_baseline() {
let bytes = include_bytes!("fixtures/mojibake.pdf");
let asset = make_raw_asset("mojibake.pdf");
let workspace_root = Path::new("/");
let config = ExtractConfig::default();
let ctx = ExtractContext {
asset: &asset,
workspace_root,
config: &config,
};
let mut canonical = PdfTextExtractor::new()
.extract(&ctx, bytes)
.expect("PdfTextExtractor::extract");
normalize_provenance_timestamps(&mut canonical);
let actual = serde_json::to_string_pretty(&canonical).expect("serialize canonical");
let baseline_path = "tests/snapshots/vector_pdf_canonical.json";
let baseline = std::fs::read_to_string(baseline_path).unwrap_or_else(|_| {
std::fs::create_dir_all("tests/snapshots").ok();
std::fs::write(baseline_path, &actual).expect("write baseline snapshot");
actual.clone()
});
assert_eq!(
actual, baseline,
"vector PDF canonical must be byte-identical to baseline (Step 1-8 regression)"
);
}