diff --git a/crates/kebab-app/tests/ingest_pdf_ocr_smoke.rs b/crates/kebab-app/tests/ingest_pdf_ocr_smoke.rs new file mode 100644 index 0000000..5927e94 --- /dev/null +++ b/crates/kebab-app/tests/ingest_pdf_ocr_smoke.rs @@ -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; +} diff --git a/crates/kebab-parse-pdf/Cargo.toml b/crates/kebab-parse-pdf/Cargo.toml index 836d421..e122918 100644 --- a/crates/kebab-parse-pdf/Cargo.toml +++ b/crates/kebab-parse-pdf/Cargo.toml @@ -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 diff --git a/crates/kebab-parse-pdf/tests/fixtures/scanned_page1_truth.txt b/crates/kebab-parse-pdf/tests/fixtures/scanned_page1_truth.txt new file mode 100644 index 0000000..58bcd34 --- /dev/null +++ b/crates/kebab-parse-pdf/tests/fixtures/scanned_page1_truth.txt @@ -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. 어려운 글자 +의사: 의의있는 의미를 의문스럽게 의도한다. +받침이 복잡한 글자: 깎, 닭, 흙, 읊, 닮, 값. +이중 모음: 의자, 예의, 외출, 위험, 왜냐하면. diff --git a/crates/kebab-parse-pdf/tests/fixtures/scanned_page2_truth.txt b/crates/kebab-parse-pdf/tests/fixtures/scanned_page2_truth.txt new file mode 100644 index 0000000..d0ec19b --- /dev/null +++ b/crates/kebab-parse-pdf/tests/fixtures/scanned_page2_truth.txt @@ -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. 외래어 받침 +- 마이크, 컴퓨터, 인터넷, 텔레비전, 라디오, 카메라, 스피커 +- 시스템, 프로그램, 데이터, 알고리즘, 인공지능, 머신러닝 +- 박테리아, 바이러스, 백신, 안티바이러스, 방화벽 +- 파일, 폴더, 디렉토리, 다운로드, 업로드, 백업 diff --git a/crates/kebab-parse-pdf/tests/ocr_e2e.rs b/crates/kebab-parse-pdf/tests/ocr_e2e.rs new file mode 100644 index 0000000..010a69b --- /dev/null +++ b/crates/kebab-parse-pdf/tests/ocr_e2e.rs @@ -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 { + 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"); +} diff --git a/crates/kebab-parse-pdf/tests/snapshots/vector_pdf_canonical.json b/crates/kebab-parse-pdf/tests/snapshots/vector_pdf_canonical.json new file mode 100644 index 0000000..4829c39 --- /dev/null +++ b/crates/kebab-parse-pdf/tests/snapshots/vector_pdf_canonical.json @@ -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 +} \ No newline at end of file diff --git a/crates/kebab-parse-pdf/tests/text_extractor_regression.rs b/crates/kebab-parse-pdf/tests/text_extractor_regression.rs new file mode 100644 index 0000000..9e5379b --- /dev/null +++ b/crates/kebab-parse-pdf/tests/text_extractor_regression.rs @@ -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)" + ); +}