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
109 lines
3.6 KiB
Rust
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");
|
|
}
|