docs(tasks): P7-3 pdf ingest wiring task spec #39
Reference in New Issue
Block a user
Delete Branch "spec/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가MediaType::Pdf를 dispatch 하지 않아 CLI 에서 PDF 가 보이지 않는 상태. P7-3 가 그 와이어링을 다룸. P6-4 image wiring 패턴과 평행 — 두 가지 구조적 차이만 존재:pdf-page-v1) 사용. P6-4 가md-heading-v1안에 image-only branch 를 추가했던 것과 다름.핵심 결정 (spec 본문 추출)
ingest_one_asset에MediaType::Pdfarm 추가 → 새 private fningest_one_pdf_asset(P6-4 의ingest_one_image_asset평행).PdfPageV1Chunker하드코딩, md 는MdHeadingV1Chunker그대로. compile-timematch기반 — 추후 chunker 가 늘면 registry 화 (P+).config.chunking.chunker_versiondeviation: 현재 단일값 필드라 markdown 만 represents. PDF 는 무시 + 하드코딩 — implementation 시점에 HOTFIXES entry. 사용자 혼란 방지용 ingest 시작 시tracing::info!또는 향후 chunker registry task 까지 TODO.errors+=1. P7-1 의qpdf --decrypt안내 메시지 verbatim 보존.now()추가 호출 금지 (P6-4 와 동일).테스트 plan (11개, implementation PR 에서)
scanned == new + updated + skipped + errors)디자인 매핑
§3.4
SourceSpan::Page, §3.5 Chunk, §6.1 ingest pipeline, §7.2 Extractor/Chunker traits, §9.2 PDF text extraction.INDEX 반영
P7 — 2 components→P7 — 3 components.Out of scope (spec 본문)
chunker_versionconfig 분리 (P+ chunker registry task)Test plan
feat/p7-3-pdf-ingest-wiring) 으로 follow-up회차 1 — spec scope / 모호성 / coverage 갭 4건. 가장 중요한 건 RAG ask 테스트 scope 이탈 (P4-3 책임 영역 침범 + wiremock+RAG 인프라 신설 비용). 그 외 byte-edit re-ingest 케이스 추가, embedding-fail 행 모호성 해소, 사전 운영 jump (skipped → 이동) 명시. 전체 contract 자체는 견고 — determinism stress / chunker dispatch / SourceSpan::Page 첫 저장 노출 / chunker_version deviation 정직성 모두 좋음.
@@ -0,0 +137,4 @@| Per-page text extraction Err (panic absorbed by `catch_unwind`) | unchanged | yes | `Provenance::Warning` (page N "scanned candidate") — emitted by P7-1, propagated through || Empty page (no `/Contents` stream) | unchanged | yes | `Provenance::Warning` (page N "empty (scanned candidate)") — emitted by P7-1 || Embedding call fails for a chunk | bubbles up via existing markdown path's error handling | partial — depends on existing behaviour | n/a |(nit / 명시성) Dispatch 절이 "new PDF branch (this task)" 만 적고, 이전에는 PDF 가
_arm 에서Skipped으로 처리됐다는 사실 을 명시하지 않습니다. 본 PR 머지 후IngestReport.skipped카운터가 PDF 자산 수만큼 줄어들고 (MediaType::Audio | MediaType::Other만 남음)scanned/new/updated가 그만큼 늘어나는 운영 변화가 있습니다.Why: P5 eval / 통합 테스트 / 사용자가 ingest 횟수 비교 시 "왜 갑자기 skipped 가 줄었지?" 의 원인을 spec 에서 바로 찾을 수 있어야 함.
How to apply: 첫 번째 bullet 직후 한 줄 추가:
@@ -0,0 +153,4 @@## Test plan| kind | description | fixture / data |(칭찬)
config.chunking.chunker_versiondeviation 을 spec 에 미리 못 박은 게 정직합니다. 본 task 가 머지된 직후 사용자가config.toml의chunker_version = "md-heading-v1"을 보고 "PDF 도 같이 바뀌나?" 라고 묻을 때 정확한 답 ("PDF 는 무시, hard-codedpdf-page-v1") 가 spec 에 박혀 있고, HOTFIXES entry + 향후 chunker registry task 까지 명확히 path 가 그려진 상태. 이런 "문서화 가능한 작은 거짓말" 을 task 단위에서 받아들이는 자세 + path 명시가 워크스페이스의 실용주의 ("Ship working software, document deviations honestly") 와 정렬.@@ -0,0 +158,4 @@| integration | TempDir KB + 1 small text PDF (3 pages) → `kebab ingest` produces 1 doc + 3 chunks; each chunk's `source_spans[0]` is `Page { page: i, .. }`; chunks stored + embedded | `kebab-app/tests/pdf_pipeline.rs` (new), in-memory PDF via `lopdf` builder (mirrors `kebab-parse-pdf::tests::common`) || integration | Re-ingest same PDF → identical `doc_id` and identical `chunk_id` set (P1 idempotency contract) | inline || integration | Encrypted PDF → asset NOT stored; `errors+=1`; `IngestItem.error` mentions `qpdf` / `decrypt` | inline (lopdf builder + fake `/Encrypt` trailer) || integration | Corrupt header PDF → asset NOT stored; `errors+=1`; error message mentions PDF parse failure | inline |(칭찬) Determinism stress 절이 P6-4 와 동일한 invariant (
now()추가 호출 금지, extract → chunk 사이) 를 명시적으로 못 박은 게 좋습니다. "chunking 은 pure function of CanonicalDocument 이므로 structural 한 제약" 라는 표현이 "왜 now() 호출이 추가되면 안 되는지" 의 근본 원인 (Provenance event 의at타임스탬프가 group 단위로 공유되는 invariant) 을 정확히 짚습니다. 미래 reader 가 "이 invariant 가 왜 있더라" 를 spec 만 읽고도 알 수 있는 상태.@@ -0,0 +166,4 @@| integration | `IngestReport` invariant `scanned == new + updated + skipped + errors` holds when ingesting a mixed corpus including a corrupt PDF | inline || integration | Long PDF (50 pages × ~1.5 KB body each = ~75 KB) produces ≥50 chunks (≥1 per page); embedding loop completes; storage round-trips | inline || smoke | Update `docs/SMOKE.md` so the runbook ingests at least one PDF fixture and verifies search-by-page-text works + `inspect doc` shows `SourceSpan::Page` | docs change |(nit)
PdfPageV1Chunker::chunk Err행이 "would only fire on P7-1 contract drift" 만 표현. 사실은 defensive validation 이라 미래에 누군가 잘못된 doc 을 넘겨도 (예: P+ chunker registry task 의 routing bug) 즉시 잡힙니다. 이 측면도 한 줄 명시하면 spec 가 frozen 된 후에도 chunker 의 가드 의도가 정확히 전달됩니다.How to apply: 행의 비고를 "P7-1 contract drift OR future routing bug — defensive validation either way" 로 보강.
@@ -0,0 +169,4 @@The opt-in real-Ollama integration tests stay in `kebab-llm-local` / `kebab-parse-image`. P7-3 adds zero LM dependency — PDF text extraction is local-only, so there is no equivalent hermetic-vs-real split to manage.## Definition of Done(nit / 모호성) Failure semantics 표의 마지막 행 "Embedding call fails for a chunk → bubbles up via existing markdown path's error handling / partial — depends on existing behaviour" 가 implementation 시점에 결정을 미루는 모호한 표현입니다. md path 의 현재 동작을 한 줄로 명시하거나, "P7-3 implementation 이 결정 + HOTFIXES 기록" 식으로 명시.
Why: spec 은 frozen contract 라 "depends on existing behaviour" 같은 회피 표현은 미래 reader 가 디버깅 시 무엇을 기대해야 할지 결정 못 하게 만듭니다. md path 가 어떻게 동작하는지 (PR diff 본문에는 fail-fast 인지 partial 인지) 한 줄 명시 권장.
How to apply:
crates/kebab-app/src/lib.rs의 markdown path 를 한 번 읽고 (embed_chunks또는 유사 호출 부근) 다음 형식으로 채우기 — 예: "chunk 단위 embedding fail → 해당 chunk 의 vector 만 누락, doc 는 stored, IngestItemKind::Error 가 아닌 Updated 또는 New 로 분류;IngestReport.errors는 unchanged. 정상 동작."@@ -0,0 +175,4 @@- [ ] `tasks/p7/p7-3-pdf-ingest-wiring.md` 작성 + self-review (placeholder / 모순 / 모호성 / scope)- [ ] `tasks/INDEX.md` "P7 — 3 components" 반영- [ ] PR 본문에 design §3.4, §3.5, §6.1, §9.2 링크(칭찬) Storage / wire effects 절이 "
SourceSpan::Page가 store 에 처음 저장된다" 는 사실을 명시한 게 중요합니다. P1-6 시점에 polymorphic source_span_kind/payload 컬럼이 만들어졌지만Pagevariant 가 실제로 그 컬럼을 사용하는 건 P7-3 가 처음 — 즉 P1-6 의 polymorphic 약속이 실전에서 검증되는 첫 PR 이라는 의미입니다. "any downstream reader that did not handle this variant is exposed" 한 줄이 미래에inspect/searchJSON 출력 디버깅 시 정확한 출발점을 알려줍니다.@@ -0,0 +189,4 @@- [ ] `kebab ask "<query about PDF body>"` produces an `Answer` whose `Citation` includes a `Page` span- [ ] HOTFIXES entry written if any spec deviation surfaces during implementation- [ ] `docs/SMOKE.md` includes a PDF-fixture step(coverage gap) Re-ingest 테스트는 "동일 byte" 케이스만 명시. md / image path 가 모두 검증하는 "수정된 PDF re-ingest → updated 카운터 증가" 케이스가 빠져있습니다. P1 idempotency contract 의 다른 axis (
updated == 1for edited content) 가 PDF path 에서도 동작하는지 implementation 수준에서 한 번 잠가줘야 합니다.Why: PDF doc_id 는
(workspace_path, asset_id, parser_version)의 함수인데 asset_id 는id_for_asset(blake3_full_hex)로 byte-기반. byte 가 바뀌면 asset_id 도 바뀌고 따라서 doc_id 도 바뀝니다 → 새 row,new+=1. 만약 그렇지 않다면 (workspace_path 만 바뀌어도 asset_id 보존되는 path 가 있다면) updated 분기가 동작해야 함. 본 invariant 가 PDF 에서도 깨지지 않는지 시각화하는 게 중요.How to apply: Test plan 표 한 줄 추가:
또는 "동일 path 같은 asset_id 다른 parser_version" 같은 P+ 시나리오는 이번 task 에 포함시키지 않아도 OK — byte 수정 케이스만 추가.
@@ -0,0 +195,4 @@- **Scanned PDF OCR fallback** — empty/extract-failed pages stay searchable=false in v1. A future task ("P+ scanned-PDF-ocr") routes those pages through P6-2's `OllamaVisionOcr` after rasterising the page via a PDF renderer (e.g. `mupdf-rs`, `pdfium-render`). Excluded here because (a) it requires a new system / Rust dep we don't have yet, and (b) v1 user scenario is text-embed PDFs (papers, exported reports).- **Multi-column reading order / table extraction / formula detection / form-field extraction / bookmark or outline ingestion** — all deferred to future PDF-layout task. P7-1 already lists these as out of scope and the wiring inherits.- **Body multilingual via CID font support** — handled at the parser layer (P7-1). UTF-16BE Title metadata works today; non-Latin body text depends on the PDF's font CID mapping.- **Per-medium `chunker_version` config** — current `Config::chunking.chunker_version` is single-valued and serves markdown only. PDF ingest ignores it (hard-codes `pdf-page-v1`). A future P+ task either splits the field per medium or introduces a chunker registry. Logged as a deviation in `tasks/HOTFIXES.md` once implementation lands.(suggestion / scope) RAG
kebab ask+ mock LLM (wiremock) 테스트가 P7-3 scope 안에서 가장 비용이 큰 항목입니다. 새로운 테스트 인프라 (wiremock 으로 LLM 통신 모킹) 가kebab-app통합 테스트에 처음 들어오는 것이고, 이건 P4-3 (RAG pipeline) 의 책임 영역을 P7-3 가 떠안는 형국. 본 task 의 진짜 invariant 는 "검색 결과 chunk 의source_spans[0]가Page인지" 이지 "RAG 응답이 그걸 cite 하는지" 가 아닙니다 — RAG 가 chunk citation 을 잘 propagate 하는 건 P4-3 의 계약.Why: P7-3 implementation PR 의 surface area 를 키워서 review 비용 + RAG 모킹 디버깅 (P6 / P7 implementation 동안 wiremock + RAG 의 multi-turn 행동을 한 번도 fixture 화 한 적 없음) 가 같이 늘어납니다. 단순
kebab search --mode hybrid가 PDF chunk + Page span 을 돌려준다는 검증으로 본 task 의 invariant 는 충분.How to apply: 해당 행을 두 옵션 중 하나로 변경:
결정 후 hotfixes 추적 부담 없이 갈 수 있는 (A) 가 P6-4 실제 구현 PR 의 scope 와 더 평행.
회차 2 — 회차 1 지적 5건 (RAG 테스트 out-of-scope 이동, byte-edit re-ingest 케이스 추가, embedding-fail 행 명시, dispatch 운영 jump 표시, chunker validate err 비고 정확화) 모두 반영. spec 머지 가능.
@@ -0,0 +139,4 @@| Empty page (no `/Contents` stream) | unchanged | yes | `Provenance::Warning` (page N "empty (scanned candidate)") — emitted by P7-1 || `Embedder::embed(...)` Err (any chunk) | `errors+=1` | yes (doc + chunk rows already written before embed call — see below) | n/a |The embedding call sits *after* `put_document` / `put_blocks` / `put_chunks` in `kebab-app::ingest_one_asset` (markdown path, lines 615+), so a failed embed leaves doc + chunk rows on disk while no vector exists. This is consistent with the markdown path and accepted as v1 behaviour — re-running `kebab ingest` re-attempts the embed for any chunk whose `embedding_id` is missing from the vector store. Whole-asset rollback on embed-fail is a P+ task (atomic ingest transaction).(칭찬) Dispatch 절에 "이전엔 PDF 가 Skipped 였음 → 머지 후 skipped→scanned 이동" 의 운영 jump 한 줄을 정확히 수치화 ("N PDF files reports
skippeddecreasing by N") 한 게 좋습니다. P5 eval / smoke / 사용자가 머지 직후 ingest 통계 변화를 보고 "왜 갑자기 숫자가 바뀌지" 의 답을 spec 만 봐도 즉시 찾을 수 있는 상태.@@ -0,0 +166,4 @@| integration | Mixed page PDF (page 1 text, page 2 empty / scanned, page 3 text) → asset stored; 2 chunks (pages 1 + 3); `doc.provenance.events` contains exactly 1 `Warning` for page 2 marked "scanned candidate" | inline || integration | `kebab inspect doc <pdf_doc_id>` returns the PDF `CanonicalDocument` with per-page `Block::Paragraph` and `SourceSpan::Page` intact | inline || integration | Hybrid search across mixed corpus (1 markdown + 1 PDF) returns the PDF chunk for a query whose terms appear only in the PDF body | inline (real `multilingual-e5-small` embedding) || integration | `IngestReport` invariant `scanned == new + updated + skipped + errors` holds when ingesting a mixed corpus including a corrupt PDF | inline |(칭찬)
PdfPageV1Chunker::chunk Err비고를 "P7-1 contract drift OR future routing bug — defensive validation either way" 로 정확화. 미래 chunker registry task 가 routing bug 를 만들 때 "왜 chunker 가 거부 했지?" 를 spec 에서 즉시 찾을 수 있게 됐습니다.@@ -0,0 +172,4 @@The opt-in real-Ollama integration tests stay in `kebab-llm-local` / `kebab-parse-image`. P7-3 adds zero LM dependency — PDF text extraction is local-only, so there is no equivalent hermetic-vs-real split to manage.## Definition of Done(칭찬) Embedding fail 행을 "
Embedder::embed Err → errors+=1, doc + chunks rows 는 이미 저장됨, 재실행 시 재시도" 로 정확화 + md path 코드 위치 (lib.rs` 615+) 명시. "depends on existing behaviour" 같은 회피 표현이 사라지고 spec 가 frozen 후에도 미래 reader 가 "부분 저장 + 재실행 retry" 라는 v1 정책의 정확한 의도를 알 수 있는 상태. 추가로 "Whole-asset rollback on embed-fail 은 P+ task" 라는 향후 path 까지 표시한 게 정직.@@ -0,0 +193,4 @@- [ ] `docs/SMOKE.md` includes a PDF-fixture step## Out of scope(칭찬) byte-edit re-ingest 케이스를 P1 idempotency contract 의 다른 axis 로 명시한 게 좋습니다. "identical bytes → identical IDs" 와 "different bytes → different IDs" 두 invariant 가 한 표 안에 나란히 있어야 P1 의 byte-기반 ID 약속이 PDF path 에서도 유지되는 게 확인됩니다. "orphan handling 은 P+" 한 줄까지 단 게 정직 — 이번 PR 의 scope 가 어디서 끝나는지 명확히 표시.
@@ -0,0 +212,4 @@- **Encrypted-PDF error message is the only operator-visible signal.** The `kebab ingest` summary shows `errors=1` and the `IngestItem.error` message; a user who didn't read the full output may miss the `qpdf --decrypt` hint. Acceptable in v1 — a future `kebab inspect ingest <run_id>` (P9) renders structured per-asset errors. For now, ensure the test asserts the hint is preserved verbatim from P7-1's bail string.- **Determinism stress with `now()` calls.** Same constraint as P6-4 — extract → chunk pipeline must not insert wall-clock reads between steps. P7-1 emits its own `now()` once for all per-page Provenance events; the PDF wiring branch must add no further `now()`s inside the per-asset path.- **`pdf-page-v1` per-chunk hash variant `#c{char_start}` is opaque.** Downstream tools comparing `chunk_id`s by exact match work fine (it's still a deterministic blake3 input). Tools attempting to derive a stable position from the `chunk_id` alone would fail — they must read `chunk.source_spans[0].char_start`. Documented in P7-2's HOTFIXES entry; cross-referenced here for findability.- **HEIC / RAW image and PDF/A subspecies share `MediaType::Other`.** PDF/A is detected by `kebab-source-fs` as `MediaType::Pdf` (header is still `%PDF-`); PDF subtype variants do not branch separately. Acceptable for v1.(칭찬) RAG ask 검증을 out-of-scope 절에 "P4-3 책임 영역, PDF chunk 도 Citation shape 동일, wiremock+RAG 인프라 신설은 P7-3 invariant 와 비례하지 않음" 으로 옮긴 게 정확한 추론입니다. 본 task 가 검증해야 하는 invariant 는 "PDF chunk 가 search 에서 Page span 으로 나온다" 까지이고, RAG citation 라운드트립은 어떤 chunk 든 변하지 않는 P4-3 의 계약 — out-of-scope 절에 captured 한 추론이 미래 reviewer 가 "왜 P7-3 가 RAG 까지 가지 않았지" 를 묻을 때 답이 됩니다.