feat(kebab-app): P6-4 image ingest wiring — kebab ingest 가 PNG/JPEG 처리 #36
Reference in New Issue
Block a user
Delete Branch "feat/p6-4-image-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?
요약
P6-4 spec (PR #35) 의 implementation. P6-1/P6-2/P6-3 의 라이브러리가 그동안
kebab ingestCLI 에서 보이지 않던 미완 구간을 완성. 이제kebab ingest가 markdown 외에 이미지 자산을 end-to-end 색인하고,kebab search/kebab ask가 OCR 텍스트 + caption 으로 이미지 매칭/인용.contract:
tasks/p6/p6-4-image-ingest-wiring.md(spec PR #35 머지본).동작 변경
kebab-app::ingest_with_config가MediaType::Image(_)분기를 가진다.image.ocr.enabled/image.caption.enabled플래그에 따라OllamaVisionOcr/OllamaLanguageModel을 ingest 세션당 1회 빌드, trait object 로 자산 루프 공유.ImageExtractor::extract→ 옵션apply_ocr→ 옵션apply_caption→ 기존MdHeadingV1Chunker(image-only 분기는 P1-5 부터 존재) → 기존 persist + embed.kebab-chunk::md_heading_v1::render_block_text의Block::ImageRef분기가 (β) plain concat 정책으로 갱신 —[alt, ocr.joined, caption.text]를\\n\\n로 join, 빈 부분 drop.Lenient 실패 정책 (spec 의 (b))
ImageExtractor::extractErr →errors+=1, 자산 미저장.apply_ocrErr →block.ocrNone 유지,ProvenanceKind::Warning(agent=\"kb-app\",note=\"ocr_failed: ...\"),IngestItem.warnings에 노트 1건.errors미증가.apply_captionErr → 동일.errors미증가.kebab inspect doc <id>또는--debug트레이스.IngestReport의errors카운터로는 보이지 않음 (의도).변경 파일
kebab-parse-imagedeps + dev-deps (wiremock, tokio, image) 추가ingest_one_image_asset신규,ImagePipeline묶음 구조체,lang_hint_from_doc헬퍼render_block_text(Block::ImageRef)(β) 정책으로 교체 + unit 테스트 5케이스 추가[image.*]config 예시 + 시간 추정planned → completed테스트
cargo test --workspace --no-fail-fast -j 1— 전부 pass. 핵심 신규:cargo test -p kebab-app --test image_pipeline— 5 pass:cargo test -p kebab-chunk image_ref— 2 pass (P1-5 회귀 + (β) plain concat unit)cargo clippy --workspace --all-targets -- -D warnings— pass.실 Ollama 통합 검증
config.toml에[image.ocr] enabled=true+[image.caption] enabled=true켜고192.168.0.47Ollama /gemma4:e4b:의존성 경계
kebab-app이kebab-parse-image추가 — spec Allowed dep.kebab-parse-image에 위임.P6 phase epic 완료
P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 완료. image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio).
Test plan
cargo test --workspace --no-fail-fast -j 1cargo clippy --workspace --all-targets -- -D warningskebab ingestagainst TempDir (markdown + PNG) →scanned 2 / new 2 / errors 0kebab search --mode vector \"Hello World\"→ image chunk top-1kebab ask→ grounded=true + citation marker 가 image 자산kebab inspect doc <image_doc_id>→ block.ocr / block.caption 둘 다 채워짐🤖 Generated with Claude Code
P6-1/P6-2/P6-3 의 라이브러리 (`ImageExtractor`, `OllamaVisionOcr`, `apply_caption`) 가 그동안 CLI 에서 보이지 않던 미완 구간을 완성. 이제 `kebab ingest` 가 markdown 외에 이미지 자산을 end-to-end 로 색인하고, `kebab search` / `kebab ask` 가 OCR 텍스트 + caption 으로 이미지를 매칭/인용한다. ## kebab-app - `[dependencies]` 에 `kebab-parse-image` 추가. - `ingest_with_config` 진입 시 `image.ocr.enabled` / `image.caption.enabled` 플래그에 따라 `OllamaVisionOcr` / `OllamaLanguageModel` 을 **ingest 세션당 1회** 빌드. 자산 루프에서 trait object 로 공유. reqwest::blocking::Client 의 내부 Arc 덕분에 알로케이션 비용은 자산 수와 무관. - 두 어댑터 + ImageExtractor 를 한 묶음으로 `ImagePipeline` 구조체에 담아 `ingest_one_asset` 매개변수 폭증 차단 (clippy::too_many_arguments 대응). - `ingest_one_asset` 의 markdown-only 가드를 `match media_type` 으로 교체 — Markdown 은 기존 경로, Image(_) 는 새 `ingest_one_image_asset` 로 분기, PDF/Audio/Other 는 종전대로 skipped. - 신규 `ingest_one_image_asset`: - bytes 읽기 → `ImageExtractor::extract` (실패 시 caller 가 errors+=1) - `apply_ocr` (Lenient — 실패 시 ProvenanceKind::Warning 이벤트 + `IngestItem.warnings` 에 \"ocr_failed: ...\", `block.ocr` 는 None 유지) - `apply_caption` (동일 Lenient 정책) - 기존 `MdHeadingV1Chunker` 호출 — 청커는 이미 `Block::ImageRef` 를 단일 청크로 emit - 기존 persist + embed 시퀀스 그대로 (markdown 과 byte-identical) - `lang_hint_from_doc` — `Lang(\"und\")` 또는 빈 문자열을 None 으로 매핑 (image-pipeline 어댑터의 build_prompt 가 \"und\" 를 silent drop 하지 않도록 caller 측에서 미리). ## kebab-chunk - `render_block_text` 의 `Block::ImageRef` 분기를 P6-4 (β) plain concat 정책으로 교체 — `[alt, ocr.joined, caption.text]` 를 `\\n\\n` 로 join, 빈 부분은 drop. alt 가 비면 `src` 의 basename 으로 fallback (P6-1 contract 의 defensive guard). - 신규 unit 테스트 `image_ref_p6_4_plain_concat_drops_empty_parts` — alt-only / alt+ocr / alt+caption / alt+ocr+caption / 빈 alt → src fallback 다섯 케이스 모두 검증. - 기존 `image_ref_emits_own_chunk_zero_tokens` 그대로 통과 — 청커의 per-block dispatch 는 변경 없음, text 렌더링만 갱신. ## 통합 테스트 (kebab-app/tests/image_pipeline.rs) wiremock 으로 Ollama 를 stub. 5건: 1. OCR-only happy path — 1 PNG + ocr.enabled → 1 doc + 1 chunk emit, `block.ocr.joined` 가 mock 의 \"Hello World 2026\". 2. OCR + caption 동시 활성 — 두 필드 모두 채워지고 chunk text 에 alt + ocr + caption 세 부분 모두 포함. 3. Lenient 실패 검증 — OCR 503 시 자산은 indexed (kind=New), `errors=0`, ProvenanceKind::Warning attributed to \"kb-app\", `IngestItem.warnings` 에 \"ocr_failed:\" 노트. 4. 양쪽 비활성 — `image.ocr.enabled=false && image.caption.enabled=false` 여도 자산은 chunk 1개로 indexed (chunk text=filename), EXIF + dimensions 그대로 채워짐. 5. 결정성 (re-ingest) — 동일 PNG 두 번 ingest 시 두 번째는 `Updated` + 동일 `doc_id`. ## SMOKE.md `kebab search --mode lexical \"Hello World\"` 단계를 명령 시퀀스에 추가. `[image.ocr]` / `[image.caption]` config 절 예시 + ingest 시간 추정 (자산당 ~5-10초) 추가. \"책은 P7 PDF 라인으로\" 가이드를 검증 체크리스트 와 \"알려진 동작\" 양쪽에 박음. ## 실 Ollama 통합 검증 192.168.0.47 + gemma4:e4b 기준: ``` $ kebab --config /tmp/kebab-smoke/config.toml ingest scanned 2 new 2 updated 0 skipped 0 errors 0 (18395 ms) $ kebab inspect doc <image_doc_id> parser_version: image-meta-v1 blocks: [{ alt: \"hello.png\", ocr: \"Hello World 2026\", caption: \"The image displays the text \\\"Hello World 2026\\\" in a large, black, sans-serif font.\" }] $ kebab --json ask \"Hello World 텍스트가 어디에 있나?\" --mode hybrid grounded: true citations: [{marker: \"[1]\", doc_path: \"hello.png\"}] ``` ## 검증 - `cargo test --workspace --no-fail-fast -j 1` — 전부 pass - `cargo clippy --workspace --all-targets -- -D warnings` — pass - `cargo test -p kebab-chunk image_ref` — 2 pass (P1-5 회귀 + P6-4 신규 unit) - `cargo test -p kebab-app --test image_pipeline` — 5 pass ## 의존성 경계 - `kebab-app` 이 `kebab-parse-image` 추가 — spec Allowed dep 그대로. - 새 forbidden 침범 없음 (기존 `kebab-tui` / `kebab-desktop` / `kebab-eval` 미참조 유지). - 본 task 가 신설하는 image-specific 비즈니스 로직 0줄 — 모두 `kebab-parse-image` 에 위임. `tasks/p6/p6-4-image-ingest-wiring.md` status: planned → completed. contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md sections: §3.4 ImageRefBlock, §6.1 ingest pipeline, §7.2 Extractor/Chunker traits, §9.1 image extraction policy.회차 1 — 큰 그림은 spec 의 의도를 정확히 따라갑니다.
ImagePipeline묶음 구조체로 매개변수 폭증 회피, ingest session 당 1회 어댑터 빌드, Lenient 실패 정책 +ProvenanceKind::Warning이벤트, P6-1 의 "image-only document = single ImageRef" 분기 그대로 활용, 통합 테스트 5건이 spec 의 Implementation PR DoD 항목을 모두 커버. 실 Ollama 로 "Hello World 텍스트가 어디에 있나?" → grounded=true + citation hello.png 까지 검증한 점도 좋습니다.머지 전에 정리할 actionable 항목 5건:
구조적 결함:
ingest_one_asset의 doc-comment 가 새ImagePipelinestruct 위로 잘못 합쳐짐 (빈 줄 누락 → rustdoc 가 두 doc 을 struct 의 것으로 합침). fn 의 doc 이 통째로 사라진 상태.if let Some(Block::ImageRef(block))의 else 분기가 silent skip — P6-1 contract 가 미래에 깨질 때 "OCR 안 돌고 끝" 이라는 해석 불가능한 동작이 됨. tracing::warn 권장.중복 / spec 약속 위반:
now()call between extract and apply_ocr/caption" 약속 위반 — 현재 두 분기가 각자OffsetDateTime::now_utc()호출. fn 진입 시 하나 캐시.일관성:
IngestItem.warnings형식 (\"ocr_failed: <err>\") 이 markdown 경로의 (\"<WarningKind>: <note>\") 와 다름. WarningKind 에OcrFailed/CaptionFailed변형 추가하거나 prefix 형식 통일.칭찬 —
ImagePipeline구조체 도입 +lang_hint_from_doc헬퍼로 "매개변수 10개" 안티패턴을 의식적으로 회피한 점이 좋습니다. clippy::too_many_arguments allow 는 두 함수에 정확히 최소 적용. embedder + vector_store 까지 한 묶음으로 더 줄일 여지는 있지만 본 PR scope 외로 둬도 OK.@@ -441,0 +474,4 @@/// P6-4: borrowed bundle of the three image-pipeline components built/// once per ingest invocation. Threaded through `ingest_one_asset` so/// the dispatch does not need ten separate parameters.struct ImagePipeline<'a> {doc-comment 가 잘못 합쳐졌습니다. line 470-473 의 4 줄 ("Process a single asset: read bytes, parse, normalize, chunk, persist, embed...") 는 원래
fn ingest_one_asset의 doc 인데, 그 사이에 새ImagePipelinestruct 를 끼워 넣으면서 빈 줄 없이 line 474-476 의 struct doc 과 이어졌습니다. 결과적으로 rustdoc 은 두 doc-comment 를 합쳐서ImagePipeline의 문서로 인식하고,ingest_one_asset자체는 doc 이 없는 상태가 됩니다.수정:
원래 doc 을 fn 위치로 되돌리고 struct 의 P6-4 doc 만 struct 위에 두는 형태.
@@ -615,0 +747,4 @@if !canonical.blocks.is_empty() {// P6-1 contract: image documents always have exactly one// `Block::ImageRef`. Defensive match keeps us forward-compatible.if let Some(Block::ImageRef(block)) = canonical.blocks.first_mut() {if let Some(Block::ImageRef(block)) = canonical.blocks.first_mut()가 None 이거나 다른Blockvariant 면 silent skip — Provenance event 도 warning 도 안 남기고 OCR/caption 단계가 통째로 무력화됩니다.P6-1 contract 가 "image document 는 항상 단일 ImageRef block" 이라 현재 production 에선 unreachable 이지만, 미래 task 가 multi-block image document 를 도입하면 이 guard 가 silent failure 로 바뀝니다. 최소한
else분기에서tracing::warn!또는 panic-with-context 를 권장합니다:Provenance Warning 까지 추가하면 운영에서 잡기 더 쉽습니다.
@@ -615,0 +762,4 @@path = %asset.workspace_path.0,"{}",note);OCR 실패 분기와 caption 실패 분기 (line 754-773 + 776-799) 가 거의 동일 boilerplate 입니다 — variable 이름 (
ocr_failed:vscaption_failed:) 만 다르고, 나머지 (tracing::warn / push ProvenanceEvent / push warning_notes) 는 동일.중복 ~25줄을 헬퍼로 뽑으면 두 호출이 한 줄로 줄고, 미래에 "Warning event 에 다른 필드 추가" 같은 변경이 한 곳만 손보면 끝납니다:
호출부:
@@ -615,0 +764,4 @@note);canonical.provenance.events.push(kebab_core::ProvenanceEvent {at: time::OffsetDateTime::now_utc(),OCR 실패 분기와 caption 실패 분기가 각각 자기
time::OffsetDateTime::now_utc()를 호출합니다 (line 767, 792). spec p6-4 의 Risks/notes 섹션 "Determinism stress" 항목이 명시한 대로, P6-1 의ImageExtractor::extract가 이미Discovered+Parsed두 이벤트에 단일 now() 를 공유하므로, OCR/caption Warning 이벤트도 같은 처리를 해야 "한 자산 안의 Provenance timestamp 들이 자연스럽게 같이 묶임" 이 됩니다.fn 진입 시 한 번
let now = OffsetDateTime::now_utc();캐시 후 두 분기에서 재사용 권장 (위에서 제안한 헬퍼 함수와 결합하면 자연스럽게 한 곳).spec 본문이 명시적으로 "this task's wiring must not introduce a second
now()call between extract and apply_ocr/caption" 라고 했는데 두 번 호출되고 있어 spec 약속과 어긋납니다.@@ -615,0 +769,4 @@kind: kebab_core::ProvenanceKind::Warning,note: Some(note.clone()),});warning_notes.push(note);이미지 경로의
IngestItem.warnings형식 ("ocr_failed: <err>","caption_failed: <err>") 이 markdown 경로의 형식 (format!("{:?}: {}", w.kind, w.note)— 예:"MalformedFrontmatter: missing closing fence") 과 다릅니다. 같은 wire 필드를 두 갈래의 다른 컨벤션이 채우면 downstream consumer (예:kebab inspect doc --json의 reader) 가 "warnings 안의 prefix 가 무엇을 뜻하는지" 조건문을 두 번 작성해야 합니다.두 가지 정리 방향:
format!("{:?}: {}", warning_kind, note)같은 pseudo-WarningKind 변형, 또는 이미지용WarningKind변형을kebab-parse-types::WarningKind에 추가해 정식 사유 코드화.format!("{prefix}: {note}"). 다만 이 쪽은 기존 frozen contract 변경이라 위험.1번이 자연스러우며,
WarningKind::OcrFailed/WarningKind::CaptionFailed두 enum 추가는 P6-4 hotfix 또는 본 PR scope 안에서도 가능. 어느 쪽이든 "같은 필드는 같은 형식" invariant 회복 권장.- src/lib.rs: • `ingest_one_asset` 의 doc-comment 가 새 `ImagePipeline` struct 와 합쳐지던 (rustdoc 가 두 doc 을 struct 의 것으로 합치던) 문제 해소 — 두 doc-comment 위치 교환 + 빈 줄 분리. • `if let Some(Block::ImageRef(...)) = blocks.first_mut()` 의 silent-skip 분기를 `match` 의 `other` arm 으로 명시 — 미래에 P6-1 contract 가 깨지면 `tracing::warn!` + Provenance Warning + `IngestItem.warnings` 에 \"ImageDispatchAnomaly\" 노트로 즉시 가시화. 운영 디버깅 단서 제공. • OCR 실패 분기 + caption 실패 분기의 ~25줄 boilerplate 를 `record_image_analysis_failure` 헬퍼로 추출 — 두 호출이 한 줄로 줄고 미래 ProvenanceEvent 필드 변경이 한 곳에서 끝남. • 분석 단계 Warning 이벤트가 fn 진입 시 캡처한 단일 `OffsetDateTime::now_utc()` 를 공유 — spec Risks/notes 의 \"Determinism stress: must not introduce a second `now()` call between extract and apply_ocr/caption\" 약속 회복. • 경고 라벨을 markdown 경로의 `WarningKind` 컨벤션 (`{kind}: {note}`) 에 맞춤 — `\"ocr_failed: ...\"` → `\"OcrFailed: ...\"`, `\"caption_failed: ...\"` → `\"CaptionFailed: ...\"`. 같은 wire 필드 (`IngestItem.warnings`) 가 두 갈래의 다른 형식을 갖던 inconsistency 해소. - tests/image_pipeline.rs: • 회귀 테스트의 \"ocr_failed\" assertion 을 \"OcrFailed\" 로 갱신. cargo test -p kebab-app -p kebab-chunk — 전부 pass. cargo clippy --workspace --all-targets -- -D warnings — pass.회차 2 — 회차 1 의 5개 항목 (doc-comment 위치, silent-skip 분기 가시화, OCR/caption boilerplate 추출, 단일 now 공유, Warning 형식 markdown 컨벤션 통일) 모두 정확히 반영되었습니다. 추가 actionable 한 지적이 보이지 않습니다.
전체 회차 요약:
P6-4 implementation 머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요:
#36
"Delete branch after merge" 체크 권장. 머지 후
git fetch -p+git branch -d feat/p6-4-image-ingest-wiring로 정리.P6 phase epic 완료 — P6-1 (extractor + EXIF) / P6-2 (OCR) / P6-3 (caption) / P6-4 (ingest wiring) 4 컴포넌트 모두 머지 시 image ingestion 파이프라인 종결. 다음 후보 — P7 (PDF) 또는 P8 (audio).
(칭찬)
let now = OffsetDateTime::now_utc();한 줄을 fn 진입부에 캐시해서 OCR / caption / Anomaly 세 Warning 이벤트가 모두 같은 timestamp 를 공유하도록 만든 점이 spec 의 "Determinism stress" 약속을 정확히 회복했습니다.kb-normalize::build_canonical_document의now공유 패턴과 동일한 톤이라 reader 가 두 모듈 사이에서 같은 invariant 를 인지하기 쉬워졌습니다.(칭찬) silent-skip 이던 다른-Block-variant 분기를
match의otherarm 으로 명시하고tracing::warn!+ Provenance Warning +IngestItem.warnings셋 모두에 "ImageDispatchAnomaly" 흔적을 남기도록 정리한 게 좋습니다. P6-1 contract 가 미래에 깨질 때 운영자가 즉시 단서를 잡습니다.(칭찬)
record_image_analysis_failure가tracing::warn!+ Provenance Warning +IngestItem.warnings세 채널에 동시에 기록을 흩뿌리던 두 분기의 boilerplate 를 한 곳에 모았습니다. 매개변수에now: OffsetDateTime를 명시적으로 받음으로써 spec Risks 의 "한 자산의 분석-단계 Warning 들은 같은 timestamp 공유" 의도를 호출자가 잊을 수 없게 강제하는 게 좋은 디자인입니다.match item.kind { Error => ... }arm 6e4884aff8수동 스모크 검증 (12 PNG + 손상 PNG) 중 발견. `IngestReport.errors` 가 자산 한 장당 2회 증가해서 `scanned = new + updated + skipped + errors` invariant 가 깨짐: - `garbage.png` (이미지 아닌 바이트, .png 확장자만) 1장 + 정상 자산 3장 → 기대 `scanned=4 errors=1`, 실제 `scanned=4 errors=2`. - 원인: `match item { Err(e) => { error_count += 1; IngestItem {...} } }` 에서 1회 증가 후, 직후 `match item.kind { Error => { error_count += 1 } }` arm 에서 또 1회 증가. - markdown 경로의 `ingest_one_asset` Err 가 거의 발생 안 해서 P6-4 머지 전까지 표면화 안 됐던 기존 결함. 이미지 dispatch 가 garbage bytes 를 Err 로 흘려보내며 처음으로 노출. 수정: `Err(e)` 분기의 `error_count.saturating_add(1)` 제거. 단일 증가 지점은 `match item.kind { Error => ... }` arm. 코멘트로 의도 명시. 회귀 테스트 추가 (`tests/image_pipeline.rs`): - `garbage_png_increments_errors_counter_exactly_once` — 정확히 1 증가 + `scanned == new + updated + skipped + errors` invariant 검증. 검증 — release binary + 실 Ollama (192.168.0.47 / gemma4:e4b): ``` $ kebab --json ingest scanned=4 new=3 updated=0 skipped=0 errors=1 error garbage.png (extract Err — unrecognised format) new intro.md new normal.png (OCR success) new truncated.png (OcrFailed warning, asset still indexed) ``` cargo test --workspace --no-fail-fast -j 1 — 전부 pass. cargo clippy --workspace --all-targets -- -D warnings — pass. cargo test -p kebab-app --test image_pipeline — 6 pass (5 기존 + 1 회귀).회차 3 — 사용자 요청 수동 스모크 검증 (시나리오 1: 12 PNG 동시 ingest, 시나리오 2: 손상 PNG errors) 진행 중 기존 코드의 IngestReport.errors double-count 결함 발견. 같은 PR 안에서 fix + 회귀 테스트.
시나리오 1 (release binary + 실 Ollama):
시나리오 2 (release binary):
테스트 6 pass + workspace pass + clippy pass. 머지 가능.
#36
@@ -242,3 +275,3 @@"kb-app::ingest: per-file fatal");error_count = error_count.saturating_add(1);// Note: `error_count += 1` happens below in the(칭찬) 사용자가 요청한 "다중 이미지 + 손상 PNG" 수동 검증 중 발견된
IngestReport.errorsdouble-count 결함을 같은 PR 안에서 fix + 회귀 테스트화 했습니다. P6-4 본 task scope 외였지만 image dispatch 가Err분기를 처음으로 자주 trigger 시키면서 표면화한 기존 결함이라 같은 PR 에서 처리하는 게 정직합니다. 회귀 테스트의scanned == new + updated + skipped + errorsinvariant 검증은 향후 카운터 컨벤션이 다시 깨지는 걸 방지합니다.@@ -0,0 +305,4 @@dims.get("h").and_then(|v: &serde_json::Value| v.as_u64()),Some(50));}(칭찬)
garbage_png_increments_errors_counter_exactly_once가 단순 "errors == 1" 검증을 넘어 IngestReport 의 산술 invariant (scanned = new + updated + skipped + errors) 까지 박은 점이 좋습니다. 이 invariant 는 카운터 종류가 늘어나도 (예: 미래에image_ocr_failed카운터 추가 시) 자연스럽게 회귀를 잡아 줍니다.