마지막 commit. 모든 .md 안의 `kb` 단어 일괄 갱신. - 19 개 crate 이름 (`kb-core`, `kb-app`, …) → `kebab-*` (Rust 모듈 path 표기 `kb_*` → `kebab_*` 포함). - 미래 component (`kb-tui`, `kb-desktop`, `kb-asr-whisper`, `kb-ocr`, `kb-mcp`, `kb-vlm`, `kb-rerank`, `kb-vision-ocr`, `kb-index`, `kb-smoke`, `kb-architecture`) → `kebab-*` (P6+ 가 시작될 때 같은 prefix 사용). - CLI 명령 예제: `kb ingest` / `kb search` / `kb ask` / `kb init` / `kb doctor` / `kb inspect` / `kb list` / `kb eval` → `kebab <verb>`. fenced code block + 인라인 backtick 모두. - XDG paths + env vars + binary 경로 (`target/release/kb` → `target/release/kebab`) 동기화. - design doc / 최초 보고서 / SMOKE / HOTFIXES / phase epic / task spec 모든 reference 통일. - task-decomposition.md 의 `git -c user.name=kb` 는 과거 git history 기록용 author 정보라 그대로 유지 (실제 git history 의 author 는 변경 불가). - `tasks/phase-5-evaluation.md` 의 `status: planned` → `completed` 도 같이 (P5-1 + P5-2 PR 머지 후 미반영분). ## 검증 - `grep -rEn "\bkb-[a-z]|\bkb_[a-z]|\.config/kb\b|kb\.sqlite|\bKB_[A-Z]" --include="*.md"` 0 hits (task-decomposition.md 의 git author 제외). - 모든 file path reference 살아있음 (renamed file 들 모두 새 path 로 update). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
5.4 KiB
Markdown
115 lines
5.4 KiB
Markdown
---
|
||
phase: P6
|
||
component: kebab-parse-image (image extractor + EXIF)
|
||
task_id: p6-1
|
||
title: "Image Extractor producing single-block CanonicalDocument + EXIF metadata"
|
||
status: planned
|
||
depends_on: [p0-1, p1-6]
|
||
unblocks: [p6-2, p6-3]
|
||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||
contract_sections: [§3.4 Block::ImageRef + ImageRefBlock, §3.7a OcrText/ModelCaption stubs, §9.1 image extraction policy, §9 versioning]
|
||
---
|
||
|
||
# p6-1 — Image extractor (EXIF + structure)
|
||
|
||
## Goal
|
||
|
||
Implement `Extractor` for `MediaType::Image(_)` that produces a `CanonicalDocument` whose body is exactly one `ImageRefBlock`. EXIF is captured into `metadata.user.exif`. OCR and caption are intentionally left `None`; later tasks (p6-2, p6-3) populate them.
|
||
|
||
## Why now / why this size
|
||
|
||
Establishes the image-as-document contract and decouples extraction (asset → ImageRefBlock) from analysis (OCR / caption). Keeps the multimodal merge surface small.
|
||
|
||
## Allowed dependencies
|
||
|
||
- `kebab-core`
|
||
- `kebab-config`
|
||
- `image = "0.25"` (decoding for size + format detect)
|
||
- `kamadak-exif` for EXIF
|
||
- `serde`, `serde_json`
|
||
- `time`
|
||
- `tracing`
|
||
- `thiserror`
|
||
|
||
## Forbidden dependencies
|
||
|
||
- `kebab-source-fs`, `kebab-parse-md`, `kebab-normalize`, `kebab-chunk`, `kebab-store-*`, `kebab-embed*`, `kebab-search`, `kebab-llm*`, `kebab-rag`, `kebab-tui`, `kebab-desktop`, OCR libs, LLM libs
|
||
|
||
## Inputs
|
||
|
||
| input | type | source |
|
||
|-------|------|--------|
|
||
| `RawAsset` | `kebab_core::RawAsset` | from `kebab-source-fs` |
|
||
| image bytes | `&[u8]` | filesystem |
|
||
| `parser_version` | `kebab_core::ParserVersion` | constant in this crate (`"image-meta-v1"`) |
|
||
|
||
## Outputs
|
||
|
||
| output | type | downstream |
|
||
|--------|------|------------|
|
||
| `CanonicalDocument` | `kebab_core::CanonicalDocument` | `kebab-chunk` (image-region chunker) → `kebab-store-sqlite` |
|
||
|
||
## Public surface (signatures only — no new types)
|
||
|
||
```rust
|
||
pub struct ImageExtractor;
|
||
|
||
impl kebab_core::Extractor for ImageExtractor {
|
||
fn supports(&self, m: &kebab_core::MediaType) -> bool { matches!(m, kebab_core::MediaType::Image(_)) }
|
||
fn parser_version(&self) -> kebab_core::ParserVersion { kebab_core::ParserVersion("image-meta-v1".into()) }
|
||
fn extract(&self, ctx: &kebab_core::ExtractContext, bytes: &[u8]) -> anyhow::Result<kebab_core::CanonicalDocument>;
|
||
}
|
||
```
|
||
|
||
## Behavior contract
|
||
|
||
- One asset → one document. `title` = filename without extension; `lang = Lang("und")`.
|
||
- `blocks` contains exactly one entry: `Block::ImageRef(ImageRefBlock { common, asset_id: Some(asset.asset_id), src: workspace_path, alt: filename, ocr: None, caption: None })`.
|
||
- `common.source_span` = `SourceSpan::Region { x:0, y:0, w: width, h: height }` covering the entire image (width/height obtained from `image::ImageReader::without_guessed_format().with_guessed_format()?.into_dimensions()`).
|
||
- `metadata.source_type = SourceType::Reference` (per design enum); `trust_level = TrustLevel::Primary`; `tags`/`aliases` empty.
|
||
- `metadata.user["exif"]` = JSON object with whitelisted EXIF tags (DateTimeOriginal, GPS lat/lon, Make, Model, Orientation, Software). Missing tags omitted.
|
||
- `metadata.user["dimensions"] = { "w": <u32>, "h": <u32>, "format": "<png|jpeg|...>" }`.
|
||
- `provenance` includes `Discovered`, `Parsed` events (no Normalized — ID assignment happens here directly per §3.4 stub from p1-4 logic, OR pipe through `kebab-normalize` if available; this task's choice: emit a fully formed CanonicalDocument with deterministic IDs by calling `kebab_core::id_for_doc` and `kebab_core::id_for_block` directly).
|
||
- Failure modes:
|
||
- Truncated/corrupt image → still emits a CanonicalDocument with `dimensions = null`, EXIF empty, `Provenance` warning event with the decoder error message.
|
||
- Unsupported format → `anyhow::Error` (caller skips).
|
||
- Determinism: identical bytes + identical parser_version → identical `doc_id` and `block_id`.
|
||
|
||
## Storage / wire effects
|
||
|
||
- None directly (the caller persists via `kebab-store-sqlite`).
|
||
|
||
## Test plan
|
||
|
||
| kind | description | fixture / data |
|
||
|------|-------------|----------------|
|
||
| unit | PNG decode produces correct dimensions in `metadata.user.dimensions` | `fixtures/image/red-100x50.png` |
|
||
| unit | JPEG with EXIF GPS captured into `metadata.user.exif` | `fixtures/image/exif-with-gps.jpg` |
|
||
| unit | image with no EXIF produces `metadata.user.exif = {}` | `fixtures/image/no-exif.png` |
|
||
| unit | corrupt image: warning provenance, no panic | `fixtures/image/corrupt.png` |
|
||
| determinism | identical bytes → identical `doc_id`, `block_id` across two runs | inline |
|
||
| snapshot | `CanonicalDocument` JSON stable for fixture | `fixtures/image/red-100x50.png` |
|
||
|
||
All tests under `cargo test -p kebab-parse-image`.
|
||
|
||
## Definition of Done
|
||
|
||
- [ ] `cargo check -p kebab-parse-image` passes
|
||
- [ ] `cargo test -p kebab-parse-image` passes
|
||
- [ ] No OCR/caption/embedding code present
|
||
- [ ] No imports outside Allowed dependencies
|
||
- [ ] PR links design §3.4, §9.1
|
||
|
||
## Out of scope
|
||
|
||
- OCR text (p6-2).
|
||
- Captioning (p6-3).
|
||
- CLIP / visual embedding (P+).
|
||
- HEIC / RAW formats (out of scope; record as Other and accept failure for v1).
|
||
|
||
## Risks / notes
|
||
|
||
- `image` crate doesn't decode HEIC; document and accept skip. Apple Vision sidecar (P+) can fill this gap.
|
||
- EXIF whitelist keeps PII surface small (no thumbnails, no maker notes). Document the list in the spec section.
|
||
- Cap decode dimensions to ~16k×16k; oversized → warning + null dimensions instead of attempted decode.
|