Files
kebab/crates/kebab-parse-pdf/tests/text_extractor_regression.rs
altair823 2bbe2f8ace refactor(app): markdown을 extractor registry 경유로 통일 (extract stage 대칭화)
markdown ingest arm 이 그동안 유일하게 `App::extract_for` extractor
registry 를 우회하고 `kebab_parse_md::{parse_frontmatter, parse_blocks,
build_canonical_document}` free function 을 직접 호출했다 ("the single
biggest asymmetry"). 이를 image/pdf/code 와 동일하게 registry 경유로
통일.

- `kebab-parse-md` 에 `MarkdownExtractor` 신설 — 기존 free function 3종을
  동일 순서·동일 인자로 감싸 `bytes → CanonicalDocument` 생산만 담당.
  fm_span_end / count_lines_in / build_body_hints 헬퍼도 함께 이식.
- `App.extractors` registry 에 등록 (11 → 12 entry), markdown 이 `supports`
  로 발견되도록 첫 entry 로 배치.
- `ExtractContext` 에 `source_id` / `source_trust` 필드 추가 — markdown
  frontmatter 가 per-source trust 기본값을 override 하고 그 precedence 가
  `parse_frontmatter` *내부*에서 결정되므로 ctx 가 carry 해야 함. 다른
  extractor 는 None (post-extract 에서 source_id stamp 유지).
- 핸들러는 추출 stage 만 registry 로 이전 — version stamping / chunking /
  embedding / store 는 그대로. IngestItem.warnings 는 pdf/code 처럼
  `canonical.provenance` 의 Warning 이벤트에서 도출.

byte-identical 검증: parity gate-ingest (all-markdown 183 doc / 7676 chunk)
CHUNKS / SEARCH / ASK 모두 IDENTICAL. clippy 0, kebab-app + kebab-parse-md
test 전체 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-24 13:30:54 +00:00

109 lines
3.6 KiB
Rust

//! 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,
source_id: None,
source_trust: None,
};
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)"
);
}
#[test]
fn mojibake_fixture_load_yields_one_page() {
let bytes = include_bytes!("fixtures/mojibake.pdf");
let doc = lopdf::Document::load_mem(bytes).expect("load mojibake");
assert_eq!(doc.get_pages().len(), 1, "F4 must have 1 page");
}
#[test]
fn mojibake_fixture_has_no_tounicode_cmap() {
let bytes = include_bytes!("fixtures/mojibake.pdf");
let count = bytes
.windows(b"/ToUnicode".len())
.filter(|w| *w == b"/ToUnicode")
.count();
assert_eq!(count, 0, "F4 must have no /ToUnicode marker");
}
#[test]
fn pdf_text_extractor_on_mojibake_yields_one_block() {
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,
source_id: None,
source_trust: None,
};
let canonical = PdfTextExtractor::new()
.extract(&ctx, bytes)
.expect("PdfTextExtractor::extract");
assert_eq!(canonical.blocks.len(), 1, "F4 must yield 1 block");
}