feat(kebab-app): P7-3 PDF ingest wiring #40

Merged
altair823 merged 3 commits from feat/p7-3-pdf-ingest-wiring into main 2026-05-02 12:18:16 +00:00
Owner

요약

P7-1 (PdfTextExtractor) + P7-2 (PdfPageV1Chunker) 라이브러리를 kebab-app::ingest_with_config 에 와이어링. kebab-source-fs 가 이미 *.pdfMediaType::Pdf 로 분류하던 자산이 이제 검색 가능한 doc 으로 색인됨. P7 phase 완성 — 머지 후 kebab ingest 가 markdown / image / PDF 세 가지 미디어 모두 처리.

핵심 결정

  • Dispatch: ingest_one_assetMediaType::Pdf arm 추가 → 새 private fn ingest_one_pdf_asset. P6-4 의 image branch 와 평행 구조.
  • Chunker selection (compile-time match): PDF → PdfPageV1Chunker 하드코딩. config.chunking.chunker_version 은 markdown 만 represent — PDF 는 항상 pdf-page-v1. HOTFIXES 2026-05-02 P7-3 deviation entry. P+ chunker registry task 까지 유지.
  • Encrypted / corrupt PDF: errors+=1 + P7-1 의 qpdf --decrypt hint 를 IngestItem.error 에 verbatim 보존.
  • 빈/scanned candidate 페이지: 0 chunk + P7-1 emit Warning 그대로 통과. v1 검색 불가 — P+ scanned-PDF OCR fallback 대기.
  • Determinism stress: extract → chunk 사이 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 idempotency
  • re_ingest_edited_pdf_produces_new_doc_id (ignored) — storage UNIQUE bug 노출 (HOTFIXES 참조)
  • encrypted_pdf_fails_with_qpdf_hintIngestItem.error 의 hint verbatim
  • corrupt_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_pdfscanned == new + updated + skipped + errors
  • long_pdf_round_trips_through_lexical_pipeline — 50 페이지 → ≥50 chunk
  • inspect_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_rowON CONFLICT(asset_id) 만 처리.
  • byte 변경 시 새 BLAKE3 → 새 asset_id (INSERT 분기) → 그러나 같은 workspace_path → secondary UNIQUE 충돌.
  • md / image / pdf 모두 영향. md / image 테스트가 byte 수정 케이스를 운용하지 않아 P7-3 의 통합 테스트가 처음 노출.
  • 본 PR 은 fix 안 함 — 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 카운터: -N
  • scanned / new 카운터: +N

P5 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_pipeline 8 passed + 1 ignored
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo check --workspace clean

Spec 매핑

  • spec: tasks/p7/p7-3-pdf-ingest-wiring.md (status: completed)
  • 디자인 §3.4 SourceSpan::Page, §3.5 Chunk, §6.1 ingest pipeline, §7.2 Extractor/Chunker traits, §9.2 PDF text extraction
  • HOTFIXES: 2026-05-02 P7-3 (deviation + storage bug)

Test plan

  • 통합 8개 통과 + 1 ignored (storage bug 노출)
  • clippy -D warnings 통과
  • workspace check 통과
  • HOTFIXES entry 작성
  • SMOKE.md 업데이트
  • 사용자 수동 검증: kebab ingest 가 markdown + image + PDF 한 번에 처리
  • 사용자 수동 검증: kebab search --mode hybrid 가 PDF chunk 의 source_span = Page 반환
## 요약 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 세 가지 미디어 모두 처리. ## 핵심 결정 - **Dispatch**: `ingest_one_asset` 에 `MediaType::Pdf` arm 추가 → 새 private fn `ingest_one_pdf_asset`. P6-4 의 image branch 와 평행 구조. - **Chunker selection (compile-time match)**: PDF → `PdfPageV1Chunker` 하드코딩. `config.chunking.chunker_version` 은 markdown 만 represent — PDF 는 항상 `pdf-page-v1`. HOTFIXES `2026-05-02 P7-3` deviation entry. P+ chunker registry task 까지 유지. - **Encrypted / corrupt PDF**: `errors+=1` + P7-1 의 `qpdf --decrypt` hint 를 `IngestItem.error` 에 verbatim 보존. - **빈/scanned candidate 페이지**: 0 chunk + P7-1 emit Warning 그대로 통과. v1 검색 불가 — P+ scanned-PDF OCR fallback 대기. - **Determinism stress**: extract → chunk 사이 `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 idempotency - `re_ingest_edited_pdf_produces_new_doc_id` (**ignored**) — storage UNIQUE bug 노출 (HOTFIXES 참조) - `encrypted_pdf_fails_with_qpdf_hint` — `IngestItem.error` 의 hint verbatim - `corrupt_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 + errors` - `long_pdf_round_trips_through_lexical_pipeline` — 50 페이지 → ≥50 chunk - `inspect_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)` 만 처리. - byte 변경 시 새 BLAKE3 → 새 `asset_id` (INSERT 분기) → 그러나 같은 `workspace_path` → secondary UNIQUE 충돌. - md / image / pdf 모두 영향. md / image 테스트가 byte 수정 케이스를 운용하지 않아 P7-3 의 통합 테스트가 처음 노출. - 본 PR 은 fix 안 함 — `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` 카운터: -N - `scanned` / `new` 카운터: +N P5 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_pipeline` 8 passed + 1 ignored - `cargo clippy --workspace --all-targets -- -D warnings` clean - `cargo check --workspace` clean ## Spec 매핑 - spec: `tasks/p7/p7-3-pdf-ingest-wiring.md` (status: completed) - 디자인 §3.4 SourceSpan::Page, §3.5 Chunk, §6.1 ingest pipeline, §7.2 Extractor/Chunker traits, §9.2 PDF text extraction - HOTFIXES: 2026-05-02 P7-3 (deviation + storage bug) ## Test plan - [x] 통합 8개 통과 + 1 ignored (storage bug 노출) - [x] clippy `-D warnings` 통과 - [x] workspace check 통과 - [x] HOTFIXES entry 작성 - [x] SMOKE.md 업데이트 - [ ] 사용자 수동 검증: `kebab ingest` 가 markdown + image + PDF 한 번에 처리 - [ ] 사용자 수동 검증: `kebab search --mode hybrid` 가 PDF chunk 의 `source_span = Page` 반환
altair823 added 1 commit 2026-05-02 09:28:49 +00:00
P7-1 (`PdfTextExtractor`) + P7-2 (`PdfPageV1Chunker`) 의 라이브러리를
`kebab-app::ingest_with_config` 에 와이어링. `kebab-source-fs` 가 이미
`*.pdf` 를 `MediaType::Pdf` 로 분류하던 자산이 이제 검색 가능한 doc 으로
색인됨. P6-4 image wiring 패턴과 평행 — `ingest_one_asset` 에 `MediaType::Pdf`
arm 추가, 새 private fn `ingest_one_pdf_asset` 로 분기.

핵심 동작:
- per-medium chunker 선택: PDF 자산은 `PdfPageV1Chunker` 하드코딩 (compile-time
  match 기반). `config.chunking.chunker_version` 은 markdown 만 represent —
  PDF 는 항상 `pdf-page-v1`. HOTFIXES entry `2026-05-02 P7-3` 에 deviation 기록.
- encrypted PDF / corrupt PDF → `errors+=1` + P7-1 의 `qpdf --decrypt` hint
  를 `IngestItem.error` 에 verbatim 보존.
- 빈/scanned candidate 페이지 → 0 chunk, P7-1 의 `Provenance::Warning` 그대로
  통과. v1 에서는 검색 불가, P+ scanned-PDF OCR fallback 대기.
- determinism stress: extract → chunk 사이 `now()` 추가 호출 없음 (P6-4 invariant
  계승). PDF doc/chunk_id 모두 결정적.

통합 테스트 (`tests/pdf_pipeline.rs`, 8 passed + 1 ignored):
- 3-page text PDF → 1 doc + 3 chunk + Page span 검증
- identical re-ingest → Updated, doc_id 동일
- encrypted PDF → Error + `qpdf` hint 보존
- corrupt header PDF → Error + 미저장
- mixed page (page 2 빈) → 2 chunk + Warning 1개
- IngestReport 산술 invariant
- 50-page 긴 PDF → ≥50 chunk
- inspect doc → SourceSpan::Page round-trip
- (ignored) edited bytes re-ingest → storage UNIQUE bug 노출, P+ fix 대기

추가 발견 (HOTFIXES `2026-05-02 P7-3`): `assets.workspace_path` 의 UNIQUE
제약과 `upsert_asset_row` 의 `ON CONFLICT(asset_id)` 만 처리하는 부분 사이에
gap 존재. byte 변경 시 새 asset_id → 같은 workspace_path 충돌. md / image / pdf
모두 영향. P7-3 통합 테스트가 처음 노출. 본 PR 은 fix 안 함 — P+ storage task.

`docs/SMOKE.md` 에 PDF 섹션 + 검증 체크리스트 + 알려진 동작 4건 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-02 09:30:27 +00:00
claude-reviewer-01 left a comment
Member

회차 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건 명시.

회차 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_version is single-valued today" 라는 제약을 코드 옆 한 줄로 박아둔 부분이 deviation 의 출처를 코드만 읽고도 재구성 가능하게 만듭니다 — HOTFIXES 와 cross-reference 까지 안 가도 reviewer 가 바로 이해.

(칭찬) `ingest_one_pdf_asset` 의 doc comment 가 spec contract 의 5 포인트 (read bytes / extract / chunker validate / persist / embed) 를 정확히 압축한 게 좋습니다. 특히 "`config.chunking.chunker_version` is 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_warnsIngestItem.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 { ... }) 직전에 한 줄 추가:

let warnings: Vec<String> = canonical
    .provenance
    .events
    .iter()
    .filter(|e| e.kind == kebab_core::ProvenanceKind::Warning)
    .filter_map(|e| e.note.clone())
    .collect();

그리고 IngestItem { warnings, ... } 로 채움. mixed_page_pdf_stores_asset_with_scanned_candidate_warning 테스트도 pdf_item.warnings 가 정확히 ["page2 empty (scanned candidate)"] 이 되는지 추가 단정.

(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 { ... })` 직전에 한 줄 추가: ```rust let warnings: Vec<String> = canonical .provenance .events .iter() .filter(|e| e.kind == kebab_core::ProvenanceKind::Warning) .filter_map(|e| e.note.clone()) .collect(); ``` 그리고 `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 직접 조회 필요).

(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-3 entry 를 가리킵니다. 향후 누군가 storage fix 를 작업할 때 "이 테스트가 왜 ignored 됐지?" 의 답을 한 번에 찾을 수 있고, fix 후 #[ignore] 만 떼면 즉시 회귀 테스트로 동작. 발견 → 격리 → 추적 의 순서가 깔끔합니다.

(칭찬) `re_ingest_edited_pdf_produces_new_doc_id` 테스트의 `#[ignore]` doc-comment 가 정확히 `HOTFIXES 2026-05-02 P7-3` entry 를 가리킵니다. 향후 누군가 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 도 동일.

(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 했을 때 마주칠 모든 "왜 이래?" 케이스가 같은 페이지에서 답을 찾을 수 있는 상태.

(칭찬) 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 the
deltas so phase 5+ readers can find the live behavior without diffing
git 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 UPDATE vs UNIQUE 제거 + 앱 레벨 unique) 를 미리 적어둔 게 정직합니다. "노출했지만 안 고침" 은 자칫 backlog에 묻히는데 본 entry 는 "왜 노출됐는지" + "왜 P7-3 가 fix 안 했는지" + "P+ task 가 어떤 결정을 내려야 하는지" 까지 한 번에 적혀있어 다음 reviewer 가 바로 들어갈 수 있는 상태.

(칭찬) storage UNIQUE bug 발견을 spec out-of-scope 로 처리하면서도 **HOTFIXES 에 정확한 fix 옵션 두 개** (`ON CONFLICT(workspace_path) DO UPDATE` vs UNIQUE 제거 + 앱 레벨 unique) 를 미리 적어둔 게 정직합니다. "노출했지만 안 고침" 은 자칫 backlog에 묻히는데 본 entry 는 "왜 노출됐는지" + "왜 P7-3 가 fix 안 했는지" + "P+ task 가 어떤 결정을 내려야 하는지" 까지 한 번에 적혀있어 다음 reviewer 가 바로 들어갈 수 있는 상태.
altair823 added 1 commit 2026-05-02 09:31:58 +00:00
- `IngestItem.warnings` 가 PDF path 에서 빈 vec 였던 갭 해소. P7-1 의
  Provenance Warning (scanned candidate / extract panic 흡수) 노트들을
  `IngestItem.warnings` 로 surface — md path 의 `fm_warns + blk_warns`
  patten 과 평행. 사용자가 ingest summary 에서 "이 PDF page 2 가 스캔
  이라 검색 불가" 를 즉시 확인 가능.
- `mixed_page_pdf_stores_asset_with_scanned_candidate_warning` 에
  `IngestItem.warnings` 단정 추가 (정확히 1건 + 노트 내용 검증).
- `encrypted_pdf` / `corrupt_pdf` 테스트의 `errors >= 1` → `errors == 1`
  strict 단정. 미래에 다른 source 가 errors 늘리면 즉시 빨개짐.
- `re_ingest_identical_pdf` 에 `chunk_count` 동일성 단정 추가. P1
  idempotency contract 의 chunk-단위 axis 검증 (chunk_id 전체 set 비교는
  pdf-page-v1 의 `deterministic_chunk_ids_1000` 가 잠그고 있어 chunk_count
  가 가벼운 proxy 로 충분).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-02 09:32:23 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 지적 3건 (Provenance Warning → IngestItem.warnings propagation, errors strict 단정, chunk_count 동일성 단정) 모두 반영. 8 passed + 1 ignored, clippy clean. 머지 가능. P7 phase 완성.

회차 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::WarningIngestItem.warnings propagation 이 정확히 한 줄짜리 filter chain 으로 들어왔고, 코멘트가 "왜" (사용자가 inspect doc 까지 안 가도 partial-success 확인 가능) + "누가" (md path 의 frontmatter / block warning 과 평행) 두 축으로 표현됐습니다. md / image / pdf 세 path 가 같은 IngestItem.warnings 시맨틱을 공유하면서 source 만 다른 모양 — 미래 reader 가 "PDF 의 warning 은 어디서 오지?" 를 코드만 읽고도 즉시 추적 가능.

(칭찬) `Provenance::Warning` → `IngestItem.warnings` propagation 이 정확히 한 줄짜리 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회 회귀 테스트로 이동 가능.

(칭찬) `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 형식이 미래에 변경되면 이 테스트가 정확한 위치를 가리키며 빨개지는 안전망.

(칭찬) `IngestItem.warnings` 단정이 단순히 "len == 1" 만 보지 않고 "page2" + "scanned candidate" 두 키워드를 모두 검증합니다. P7-1 의 Warning note 형식이 미래에 변경되면 이 테스트가 정확한 위치를 가리키며 빨개지는 안전망.
altair823 added 1 commit 2026-05-02 11:41:28 +00:00
P7-3 통합 테스트가 노출한 storage 레이어 버그 fix.
`assets.workspace_path` 의 UNIQUE 제약과 `upsert_asset_row` 의
`ON CONFLICT(asset_id)` 만 처리하던 gap 사이 — byte 가 변경된 자산
re-ingest 시 새 asset_id 가 같은 workspace_path 에서 secondary UNIQUE
충돌. md / image / pdf 모두 영향.

Fix:
- 새 helper `purge_orphan_at_workspace_path` 가 같은 `workspace_path`
  의 *다른* `asset_id` 를 발견하면 documents → assets 순서로 sweep.
  documents 의 ON DELETE RESTRICT 회피 + CASCADE 로 blocks / chunks /
  embedding_records 정리. copied 모드면 storage_path 의 byte 파일도
  best-effort 삭제.
- `put_asset_with_bytes` 의 두 분기 (copy / reference) + `DocumentStore
  ::put_asset` 모두 호출.
- 회귀 테스트 `put_asset_with_bytes_sweeps_workspace_path_orphan` (이전
  의 "UPSERT 실패시 orphan 청소" 테스트가 더 이상 doable 하지 않으므로
  대체).
- `re_ingest_edited_pdf_produces_new_doc_id` integration `#[ignore]` 해제 →
  9 통합 테스트 모두 default 로 통과.

Vector store orphan 은 별도 P+ task — LanceDB 가 SQLite cascade 와 무관하게
운영되므로 stale chunk_id vector 가 디스크에 남음. 검색에는 영향 없음 (search 가
SQLite join 통해 surface).

Smoke 검증 (release binary, markdown 2 + image 1 + PDF 2):
- doctor pass
- 첫 ingest: 5 new
- list docs: 5 docs all media types
- search lexical "pdf-page-v1 chunker" → whitepaper.pdf hit
- search hybrid → cross-media 결과
- inspect doc PDF: parser_version=pdf-text-v1, blocks 가 SourceSpan::Page
- 동일 byte re-ingest: 5 updated, 0 errors (P1 idempotency)
- byte 수정 후 re-ingest: 1 new (해당 PDF) + 4 updated, 0 errors (storage fix)
- corrupt PDF 추가: errors+=1 + IngestItem.error 메시지 정확, 다른 자산 영향 0
- 정리 후 다시 ingest: errors=0
- RAG ask: PDF 인용 + `citations[].citation` 에 `kind: "page"` + `page: <N>` +
  `path: <pdf_path>` 정확히 노출

운영 fixture 보조:
- `crates/kebab-parse-pdf/examples/gen_smoke_pdf.rs` — `cargo run --release
  --example gen_smoke_pdf -p kebab-parse-pdf -- <out.pdf> <text-pages>` 로
  reportlab/qpdf 없이 in-tree PDF 생성.
- `crates/kebab-parse-image/examples/gen_smoke_png.rs` — 동일 방식의 PNG
  fixture 생성.
- SMOKE.md 가 두 example 사용법 + 갱신된 HOTFIXES 동작 (byte 수정 시
  errors+=1 → new+=1) 반영.

HOTFIXES `2026-05-02 P7-3` entry 가 \"deferred\" → \"fixed in same PR\" 로
업데이트, vector store orphan caveat 만 남음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-02 11:42:36 +00:00
claude-reviewer-01 left a comment
Member

회차 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 완성.

회차 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 by purge_orphan_at_workspace_path" 로 갱신. 회차 1 에서 ignored 처리한 테스트가 동일 PR 안에서 storage fix 와 함께 default 로 통과하는 형태 — discovery → fix → 회귀 테스트의 chain 이 한 PR 안에 깔끔히 닫힘.

(칭찬) `re_ingest_edited_pdf_produces_new_doc_id` 의 `#[ignore]` 해제 + doc-comment 가 "sweep by `purge_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 모양이 통합 테스트와 운영 사이에 갈라지지 않음.

(칭찬) 운영 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") 를 한 줄로 명시한 부분이 미래 디버깅 시 정확한 출발점.

(칭찬) `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 = store
store
.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 가 모두 잠긴 상태.

(칭찬) 이전 "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 @@
```
```bash
kebab --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 적용 후 동작" 으로 정직하게 교체.

(칭찬) 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 가 어디까지 갔지?" 의 답을 한 번에 찾을 수 있는 상태.

(칭찬) HOTFIXES entry 가 "deferred" ���서 "fixed in same PR" 로 정직하게 갱신됨. 이전 회차의 "out-of-scope, P+ 에서 fix" 약속을 같은 PR 안에서 자기 손으로 닫고 entry 를 갱신한 게 좋습니다. caveat ("vector store orphan") 만 남겨 향후 reviewer 가 "이 fix 가 어디까지 갔지?" 의 답을 한 번에 찾을 수 있는 상태.
altair823 merged commit 91ae624a92 into main 2026-05-02 12:18:16 +00:00
altair823 deleted branch feat/p7-3-pdf-ingest-wiring 2026-05-02 12:18:17 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#40