feat(kebab-app): P7-3 PDF ingest wiring #40
Reference in New Issue
Block a user
Delete Branch "feat/p7-3-pdf-ingest-wiring"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
P7-1 (
PdfTextExtractor) + P7-2 (PdfPageV1Chunker) 라이브러리를kebab-app::ingest_with_config에 와이어링.kebab-source-fs가 이미*.pdf를MediaType::Pdf로 분류하던 자산이 이제 검색 가능한 doc 으로 색인됨. P7 phase 완성 — 머지 후kebab ingest가 markdown / image / PDF 세 가지 미디어 모두 처리.핵심 결정
ingest_one_asset에MediaType::Pdfarm 추가 → 새 private fningest_one_pdf_asset. P6-4 의 image branch 와 평행 구조.PdfPageV1Chunker하드코딩.config.chunking.chunker_version은 markdown 만 represent — PDF 는 항상pdf-page-v1. HOTFIXES2026-05-02 P7-3deviation entry. P+ chunker registry task 까지 유지.errors+=1+ P7-1 의qpdf --decrypthint 를IngestItem.error에 verbatim 보존.now()추가 호출 없음. P6-4 invariant 계승.통합 테스트 (
tests/pdf_pipeline.rs)8 passed + 1 ignored:
ingest_3_page_pdf_produces_one_doc_and_per_page_chunks— 1 doc + 3 chunk + Page span 검증re_ingest_identical_pdf_produces_updated_with_same_doc_id— P1 idempotencyre_ingest_edited_pdf_produces_new_doc_id(ignored) — storage UNIQUE bug 노출 (HOTFIXES 참조)encrypted_pdf_fails_with_qpdf_hint—IngestItem.error의 hint verbatimcorrupt_pdf_fails_without_storing— Error + 미저장 검증 (list_docs부재)mixed_page_pdf_stores_asset_with_scanned_candidate_warning— 2 chunk + Warning 1개ingest_report_arithmetic_invariant_holds_with_corrupt_pdf—scanned == new + updated + skipped + errorslong_pdf_round_trips_through_lexical_pipeline— 50 페이지 → ≥50 chunkinspect_doc_surfaces_page_spans— SourceSpan::Page round-trip추가 발견 — storage 레이어 bug (out-of-scope, HOTFIXES 등록)
P7-3 의 edited-bytes re-ingest 테스트가
sqlite error: UNIQUE constraint failed: assets.workspace_path: Error code 2067노출.assets.workspace_path에 UNIQUE 제약,upsert_asset_row가ON CONFLICT(asset_id)만 처리.asset_id(INSERT 분기) → 그러나 같은workspace_path→ secondary UNIQUE 충돌.re_ingest_edited_pdf_produces_new_doc_id테스트#[ignore]. fix 는 P+ storage task. HOTFIXES 의 옵션 (B) 두 개 명시 (ON CONFLICT(workspace_path)추가 / 제약 제거 + 앱 레벨 unique).운영 jump (spec 의 약속 그대로)
머지 직후 동일 워크스페이스 ingest 시 PDF 자산 N 개당:
skipped카운터: -Nscanned/new카운터: +NP5 eval / smoke 재실행 시 통계 변화 예상.
SMOKE 갱신
docs/SMOKE.md에 P7-3 섹션 추가:[workspace] include예시, 페이지 인용 검증 단계 (inspect doc <pdf_doc_id>의SourceSpan::Page), 알려진 동작 4건 (chunker_version deviation / 큰 PDF embedding 비용 / storage UNIQUE bug 우회 / scanned 페이지 미검색).검증
cargo test -p kebab-app --test pdf_pipeline8 passed + 1 ignoredcargo clippy --workspace --all-targets -- -D warningscleancargo check --workspacecleanSpec 매핑
tasks/p7/p7-3-pdf-ingest-wiring.md(status: completed)Test plan
-D warnings통과kebab ingest가 markdown + image + PDF 한 번에 처리kebab search --mode hybrid가 PDF chunk 의source_span = Page반환회차 1 — 가장 큰 갭은 비어있음 (PDF 의 scanned candidate / extract panic Warning 이 doc.provenance 깊이에만 존재). md path 와 평행하게 IngestItem.warnings 로 노출해 사용자가 ingest summary 만 봐도 부분 실패 알 수 있게 할 것. 그 외 nit 2 건 (encrypted/corrupt errors strict 단정, idempotency chunk_count 비교). 전체 디자인 견고 — storage bug 노출 + HOTFIXES 정직, dispatch 패턴 P6-4 계승, SMOKE 의 "알려진 동작" 4건 명시.
@@ -938,6 +949,156 @@ fn record_image_analysis_failure(warning_notes.push(note);(칭찬)
ingest_one_pdf_asset의 doc comment 가 spec contract 의 5 포인트 (read bytes / extract / chunker validate / persist / embed) 를 정확히 압축한 게 좋습니다. 특히 "config.chunking.chunker_versionis single-valued today" 라는 제약을 코드 옆 한 줄로 박아둔 부분이 deviation 의 출처를 코드만 읽고도 재구성 가능하게 만듭니다 — HOTFIXES 와 cross-reference 까지 안 가도 reviewer 가 바로 이해.@@ -941,0 +1094,4 @@chunk_count: u32::try_from(chunks.len()).ok(),parser_version: Some(canonical.parser_version.clone()),chunker_version: Some(chunker.chunker_version()),warnings: Vec::new(),(issue / 사용자 visibility 갭)
IngestItem.warnings = Vec::new()가 PDF path 에서 항상 빈 vec 입니다. 하지만 P7-1 의 PdfTextExtractor 가doc.provenance.events에 페이지마다Warning을 emit (scanned candidate / 추출 panic 흡수) 할 수 있고, mixed-page 테스트가 그 Warning 을 doc.provenance 에서 정상 확인합니다. md path 가fm_warns + blk_warns를IngestItem.warnings로 propagate 하는 것과 평행하게, PDF path 도Provenance::Warning이벤트의note들을IngestItem.warnings로 노출하면 사용자가kebab inspect doc <id>까지 안 가도 ingest summary 에서 즉시 "이 PDF 는 page 2 가 스캔이라 검색 불가" 를 확인할 수 있습니다.Why: spec § Failure semantics summary 에서 "per-page text extraction Err / 빈 페이지 → unchanged counter, doc stored, Provenance Warning" 으로 적혀 있는데 "unchanged counter" 가 "사용자가 cli 출력에서 알 수 있는 신호 0" 으로 해석되면 Lenient policy 의 의도 (운영자가 부분 실패를 안다) 가 Provenance 깊은 곳에 묻힘. md path 의 frontmatter warning 도 같은 이유로 IngestItem.warnings 에 surfacing 됩니다.
How to apply:
ingest_one_pdf_asset의 마지막Ok(IngestItem { ... })직전에 한 줄 추가:그리고
IngestItem { warnings, ... }로 채움.mixed_page_pdf_stores_asset_with_scanned_candidate_warning테스트도pdf_item.warnings가 정확히["page2 empty (scanned candidate)"]이 되는지 추가 단정.@@ -0,0 +227,4 @@/// **Currently `#[ignore]`** — exposes a storage-layer bug discovered/// by this PR: `assets.workspace_path` carries a UNIQUE constraint and/// `upsert_asset_row` only handles `ON CONFLICT(asset_id)`, so the/// second insert (new `asset_id` for the edited bytes, same(nit)
re_ingest_identical_pdf_produces_updated_with_same_doc_id는 first ingest 의 New 와 second ingest 의 Updated 만 단정하고, chunk_id 집합이 동일 한지는 확인하지 않습니다. P1 idempotency contract 의 핵심은 "동일 입력 → 동일 chunk_id 집합" 인데 doc_id 만 비교하면 chunker 의 per-chunk hash variant (#c{char_start}) 가 잘못 동작해 chunk_id 가 달라져도 통과합니다.How to apply: 추가 단정 한 줄 — first / second 두 번 모두
inspect_doc_with_config로 doc 가져와 chunk_count 일치, 또는 sqlite 에서 chunk_ids 직접 조회하여 set 비교. 가장 간단한 form 은assert_eq!(item1.chunk_count, item2.chunk_count)한 줄 추가 (현재 수준에서 chunk_id 까지 보려면 sqlite 직접 조회 필요).@@ -0,0 +249,4 @@.iter().find(|i| i.doc_path.0.ends_with("evolving.pdf")).unwrap().doc_id(칭찬)
re_ingest_edited_pdf_produces_new_doc_id테스트의#[ignore]doc-comment 가 정확히HOTFIXES 2026-05-02 P7-3entry 를 가리킵니다. 향후 누군가 storage fix 를 작업할 때 "이 테스트가 왜 ignored 됐지?" 의 답을 한 번에 찾을 수 있고, fix 후#[ignore]만 떼면 즉시 회귀 테스트로 동작. 발견 → 격리 → 추적 의 순서가 깔끔합니다.@@ -0,0 +270,4 @@item_v2.kind,IngestItemKind::New,"edited PDF gets a new asset_id → new doc_id → counted as New");(nit)
assert!(report.errors >= 1, ...)보다assert_eq!(report.errors, 1, ...)가 더 strict 합니다. 본 fixture (encrypted PDF + 워크스페이스 fixture markdown 들) 에서 errors=1 외 다른 source 가 없으므로 정확한 값을 단정하면 미래에 "왜 errors=2 가 됐지?" 를 한 번 빨개진 테스트가 즉시 알려줍니다.How to apply:
>= 1→== 1. corrupt_pdf_fails_without_storing 도 동일.(칭찬) SMOKE 의 P7-3 섹션이 sing-along 형식 (config 예시 → 명령 시퀀스 → 알려진 동작) + "알려진 동작" 4건 (chunker_version deviation / 큰 PDF embedding 비용 / storage UNIQUE bug 우회 / scanned 페이지 미검색) 을 모두 명시합니다. 사용자가 처음 PDF 를 ingest 했을 때 마주칠 모든 "왜 이래?" 케이스가 같은 페이지에서 답을 찾을 수 있는 상태.
@@ -14,6 +14,26 @@ historical contract that was implemented; this file accumulates thedeltas so phase 5+ readers can find the live behavior without diffinggit history.## 2026-05-02 — P7-3 PDF ingest wiring: chunker_version deviation + storage UNIQUE bug(칭찬) storage UNIQUE bug 발견을 spec out-of-scope 로 처리하면서도 HOTFIXES 에 정확한 fix 옵션 두 개 (
ON CONFLICT(workspace_path) DO UPDATEvs UNIQUE 제거 + 앱 레벨 unique) 를 미리 적어둔 게 정직합니다. "노출했지만 안 고침" 은 자칫 backlog에 묻히는데 본 entry 는 "왜 노출됐는지" + "왜 P7-3 가 fix 안 했는지" + "P+ task 가 어떤 결정을 내려야 하는지" 까지 한 번에 적혀있어 다음 reviewer 가 바로 들어갈 수 있는 상태.회차 2 — 회차 1 지적 3건 (Provenance Warning → IngestItem.warnings propagation, errors strict 단정, chunk_count 동일성 단정) 모두 반영. 8 passed + 1 ignored, clippy clean. 머지 가능. P7 phase 완성.
@@ -941,0 +1099,4 @@Ok(kebab_core::IngestItem {kind,doc_id: Some(canonical.doc_id.clone()),(칭찬)
Provenance::Warning→IngestItem.warningspropagation 이 정확히 한 줄짜리 filter chain 으로 들어왔고, 코멘트가 "왜" (사용자가 inspect doc 까지 안 가도 partial-success 확인 가능) + "누가" (md path 의 frontmatter / block warning 과 평행) 두 축으로 표현됐습니다. md / image / pdf 세 path 가 같은 IngestItem.warnings 시맨틱을 공유하면서 source 만 다른 모양 — 미래 reader 가 "PDF 의 warning 은 어디서 오지?" 를 코드만 읽고도 즉시 추적 가능.@@ -0,0 +232,4 @@/// Edit a PDF (replace bytes) → different blake3 → different asset_id/// → different doc_id → `new+=1` for the new doc_id; first-pass row/// remains untouched.///(칭찬)
chunk_count동일성 단정 + 코멘트로 "chunk_id 전체 set 은pdf-page-v1::deterministic_chunk_ids_1000가 잠그고 있어 여기는 가벼운 proxy 로 충분" 을 명시한 게 좋습니다. 테스트 책임 경계를 코멘트로 표시하면 미래 reviewer 가 "왜 chunk_id 전체를 비교 안 하지?" 를 묻지 않고 바로 chunk_id 1000회 회귀 테스트로 이동 가능.@@ -0,0 +411,4 @@assert!(pdf_item.warnings[0].contains("page2")&& pdf_item.warnings[0].contains("scanned candidate"),"IngestItem.warnings preserves the Provenance Warning note: {:?}",(칭찬)
IngestItem.warnings단정이 단순히 "len == 1" 만 보지 않고 "page2" + "scanned candidate" 두 키워드를 모두 검증합니다. P7-1 의 Warning note 형식이 미래에 변경되면 이 테스트가 정확한 위치를 가리키며 빨개지는 안전망.회차 3 — 회차 1 에서 out-of-scope 처리했던 storage UNIQUE bug 를 같은 PR 안에서 fix. 새 helper purge_orphan_at_workspace_path 가 documents → assets 순서로 sweep + 단위 회귀 테스트 + 이전 ignored 통합 테스트 활성화. 추가로 binary smoke (markdown 2 + image 1 + PDF 2 + corrupt PDF + edited PDF + RAG ask) 모두 통과 — citations[].citation.kind='page' 까지 검증. SMOKE.md 가 두 example bin 사용법 + 갱신된 동작 반영. 머지 가능. P7 phase 완성.
@@ -0,0 +237,4 @@#[test]fn re_ingest_edited_pdf_produces_new_doc_id() {let env = TestEnv::lexical_only();let path = env.workspace_root.join("evolving.pdf");(칭찬)
re_ingest_edited_pdf_produces_new_doc_id의#[ignore]해제 + doc-comment 가 "sweep bypurge_orphan_at_workspace_path" 로 갱신. 회차 1 에서 ignored 처리한 테스트가 동일 PR 안에서 storage fix 와 함께 default 로 통과하는 형태 — discovery → fix → 회귀 테스트의 chain 이 한 PR 안에 깔끔히 닫힘.@@ -0,0 +1,83 @@//! `cargo run --example gen_smoke_pdf -p kebab-parse-pdf -- <out.pdf> <text-page-1> [<text-page-N> ...]`//!//! Tiny generator used by the SMOKE runbook to produce text PDFs without//! adding `reportlab` / `qpdf` to the dev-machine prerequisites. Mirrors(칭찬) 운영 fixture 보조 example 추가. SMOKE 작성자가 reportlab / qpdf / pdfium 같은 시스템 dep 없이
cargo run --release --example gen_smoke_pdf -p kebab-parse-pdf한 줄로 N 페이지 PDF 를 만들 수 있는 게 실용적입니다. 기존tests/common::build_text_pdf와 동일 패턴이라 fixture 모양이 통합 테스트와 운영 사이에 갈라지지 않음.@@ -301,0 +337,4 @@/// SQLite, so an orphan vector is never surfaced) but they consume/// disk in `data_dir/lancedb/`. A future task should reconcile by/// `chunk_id` set diff. Tracked alongside this entry in HOTFIXES.pub(crate) fn purge_orphan_at_workspace_path((칭찬)
purge_orphan_at_workspace_path가 정확히 세 단계 invariant 를 코드로 표현합니다 — (1) documents 부터 (RESTRICT 회피), (2) CASCADE 가 blocks/chunks/embedding_records 흡수, (3) assets DELETE + copied 모드면 byte 파일 best-effort 정리. doc-comment 가 "왜 이 순서" 를 4 단계 enumeration 으로 박아둬서 미래 reader 가 schema 를 다시 안 읽어도 invariant 재구성 가능. "vector store orphan" caveat 까지 정직하게 적힌 게 좋고, 그게 검색 결과에 영향 없는 이유 ("search joins through SQLite") 를 한 줄로 명시한 부분이 미래 디버깅 시 정확한 출발점.@@ -142,3 +143,3 @@let err = storestore.put_asset_with_bytes(&asset, bytes)(칭찬) 이전 "UPSERT 실패 시 orphan 청소" 테스트가 새 동작에서는 도달 불가능해 deletable 인 상황에서, 단순 삭제 대신 같은 fixture 를 재사용해 새 invariant ("orphan sweep + INSERT 성공") 를 검증하도록 rewrite. 테스트 이름까지
_sweeps_workspace_path_orphan으로 의도 표현 갱신. 통합 테스트의 documents-cascade flavour 와 단위 테스트의 raw-asset flavour 가 분담되어 fix 의 두 entry point 가 모두 잠긴 상태.@@ -155,0 +175,4 @@``````bashkebab --config /tmp/kebab-smoke/config.toml ingest(칭찬) SMOKE 의 P7-3 섹션이 두 example bin 사용법 + RAG ask citations[].citation.kind="page" 까지 반영. 사용자가 markdown + image + PDF 5 자산 워크스페이스를 처음부터 만들고 ingest → search → inspect → ask 까지 가는 데 막힘이 없어졌습니다. 직전 회차의 "storage UNIQUE bug 우회" 항목도 "fix 적용 후 동작" 으로 정직하게 교체.
@@ -17,0 +22,4 @@**Fix 1**: `ingest_one_pdf_asset` (in `kebab-app::lib.rs`) instantiates `PdfPageV1Chunker` directly. The `Chunk.chunker_version` field on emitted PDF chunks records `pdf-page-v1` truthfully. A future P+ task (chunker registry) either splits `Config::chunking.chunker_version` per medium or replaces the dispatch with a runtime registry. No HOTFIX entry needed once that happens — this entry is the cross-reference.**Symptom 2 (storage-layer bug, fixed in same PR)**: P7-3's edited-bytes re-ingest test (`re_ingest_edited_pdf_produces_new_doc_id`) tripped on `sqlite error: UNIQUE constraint failed: assets.workspace_path: Error code 2067`. The assets table has a UNIQUE constraint on `workspace_path`, but `upsert_asset_row` (in `kebab-store-sqlite::store.rs`) only handles `ON CONFLICT(asset_id)`. When a file's bytes change, the new BLAKE3 produces a new `asset_id` while the `workspace_path` stays the same — INSERT picks the new asset_id branch, then trips the secondary UNIQUE on `workspace_path`.(칭찬) HOTFIXES entry 가 "deferred" ���서 "fixed in same PR" 로 정직하게 갱신됨. 이전 회차의 "out-of-scope, P+ 에서 fix" 약속을 같은 PR 안에서 자기 손으로 닫고 entry 를 갱신한 게 좋습니다. caveat ("vector store orphan") 만 남겨 향후 reviewer 가 "이 fix 가 어디까지 갔지?" 의 답을 한 번에 찾을 수 있는 상태.