feat(kebab-parse-image): P6-3 caption adapter — vision LM via trait #34
Reference in New Issue
Block a user
Delete Branch "feat/p6-3-caption-adapter"
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-3 —
caption_image/apply_caption어댑터. 비전 LM 으로 이미지에 한 문장 객관 설명 생성.&dyn LanguageModel트레이트 위에서 동작 — Tesseract/Apple Vision/PaddleOCR 등 OCR 엔진 분리 (P6-2) 와 똑같이 "교환 가능한 어댑터" 패턴.contract:
docs/superpowers/specs/2026-04-27-kebab-final-form-design.md§3.4 (ImageRefBlock.caption), §3.7a (ModelCaption), §9.1 (caption = model-generated / low trust).Spec deviations — 2건
tasks/HOTFIXES.md2026-05-02 항목.GenerateRequest.images: Vec<String>추가 — spec p6-3 시그니처가&dyn LanguageModel인데 frozen p4-1 trait 에 vision 입력이 없었음.#[serde(default)]로 pre-P6 wire 호환.kebab-llm-local::OllamaLanguageModel가images: [base64, ...]와이어 필드로 라우팅,#[serde(skip_serializing_if = is_empty)]로 텍스트 전용 경로 wire shape 가 pre-P6-3 와 byte-identical.caption드롭 — runtimeconfig.image.caption.enabled = false(default) 한 게이트로 통합. caption 모듈 추가 deps 가base64 + image + kebab-llm trait뿐이라 cargo feature 의 binary 절감 가치 미미.동작 계약
caption_image(llm, bytes, lang_hint, cfg) -> Result<ModelCaption>:cfg.image.caption.enabled = false→ Err ("disabled").[128, 1536]클램프 후 PNG 재인코딩 (PNG passthrough hot path 보존).GenerateRequest { images: vec![b64], temperature: 0.0, seed: 0, max_tokens: 96, stop: [\"\\n\\n\"] }으로 LM 호출.ModelCaption.text = trim.model_version = \"<provider>/<prompt_template_version>\"(예:\"ollama/caption-v1\") — prompt + 모델 회귀 감사 가능.apply_caption(llm, bytes, &mut block, lang_hint, cfg, &mut events):enabled = false→ 클린 no-op (Ok(()), 이벤트 미기록).block.caption = Some(...)+ProvenanceKind::CaptionApplied1건 (note 에 model + model_version).block.caption = None유지 + events 미기록 — 부분 상태 누출 차단.lang_hint = \"ko\"/\"kor\"→ 한국어 ("이미지를 한 문장으로 객관적으로 설명한다").\"und\"→ 영어 ("Describe the image in one objective sentence").kebab-config
신규
ImageCfg.caption: CaptionCfg:enabledfalsemax_pixels768prompt_template_version\"caption-v1\"ModelCaption.model_version에 인코딩되어 회귀 감사 가능.env:
KEBAB_IMAGE_CAPTION_{ENABLED,MAX_PIXELS,PROMPT_TEMPLATE_VERSION}.의존성 경계 (§8)
kebab-llm(trait 재내보내기 —LanguageModel만),base64,image.kebab-llm-local은 dev-dep 에만 — 런타임에서 forbidden 위반 없음.kebab-source-fs / kebab-parse-md / kebab-normalize / kebab-chunk / kebab-store-* / kebab-embed* / kebab-search / kebab-rag / UI crate모두.테스트
cargo test -p kebab-parse-image --test caption— 9건 통과 + 1건#[ignore]:apply_captionno-op /caption_imageErrblock.caption Some + ProvenanceKind::CaptionAppliedCapturingMock으로req.images라우팅 검증 (base64 1개, decode 가능)req.system캡처)block.caption None유지 + events 미기록max_pixels = 99999→ 1536 클램프 (4000×3000 PNG 다운스케일 검증)KEBAB_MODELS_LLM_ENDPOINT=http://192.168.0.47:11434 KEBAB_MODELS_LLM_MODEL=gemma4:e4b cargo test --test caption caption_integration -- --ignored— 실 Ollama 가 100×50 빨간 PNG 를 "The image is a solid red color." 로 캡션 (4.3초).전체:
cargo test --workspace --no-fail-fast -j 1pass (모든 crate).cargo clippy --workspace --all-targets -- -D warningspass.Test plan
images: vec![]갱신)images: Vec::new()갱신)🤖 Generated with Claude Code
- 신규 모듈 `crates/kebab-parse-image/src/caption.rs` 추가: • `caption_image(llm, bytes, lang_hint, cfg)` — `&dyn LanguageModel` 위에서 동작. 비전 LM (예: gemma4:e4b) 이 한 문장 객관 설명 출력. temperature=0 / seed=0 결정성. • `apply_caption(llm, bytes, block, lang_hint, cfg, events)` — `block.caption = Some(...)` 으로 채우고 ProvenanceKind::CaptionApplied 이벤트 1건 추가. `image.caption.enabled = false` 면 클린 no-op (Ok(())). LM 실패 시 block.caption None 그대로 + events 미기록. • 다운스케일 long-edge `[128, 1536]` 클램프. PNG passthrough hot path 보존, 그 외는 단일 디코드 + PNG 재인코딩. • 한국어 / 영어 프롬프트 분기 (lang_hint=\"ko\"/\"kor\" → 한국어). • `ModelCaption.model_version = \"<provider>/<prompt_template_version>\"` (예: \"ollama/caption-v1\") — prompt 또는 모델 회귀 감사 가능. ## kebab-core / kebab-llm-local 변경 - `kebab_core::GenerateRequest` 에 `images: Vec<String>` 필드 추가. `#[serde(default)]` 으로 기존 wire 페이로드 / snapshot 호환. - `kebab-llm-local::OllamaLanguageModel` 가 req.images 를 Ollama `images: [base64, ...]` 와이어 필드로 라우팅. `#[serde(skip_serializing_if = is_empty)]` 로 비어 있을 때 wire shape 가 pre-P6-3 와 byte-identical. ## kebab-config - 신규 `ImageCfg.caption: CaptionCfg`: - `enabled: bool` (default false) - `max_pixels: u32` (default 768, 클램프 [128, 1536]) - `prompt_template_version: String` (default \"caption-v1\") - `KEBAB_IMAGE_CAPTION_{ENABLED,MAX_PIXELS,PROMPT_TEMPLATE_VERSION}` 3종 환경변수 추가. ## Spec deviations `tasks/HOTFIXES.md` 2026-05-02 항목 추가: - Symptom 1: spec p6-3 시그니처가 `&dyn LanguageModel` 인데 frozen trait + GenerateRequest 가 vision 미지원. → trait 확장. - Symptom 2: spec 의 cargo feature `caption` (default OFF at compile time) → runtime gate 1개로 통합. base64/image/kebab-llm 외 추가 deps 없어 cargo feature 의 binary 절감 가치 미미. p4-1 / p4-2 / p6-3 spec 의 amends 명시. ## 테스트 `cargo test -p kebab-parse-image --test caption` — 9건 + 1 ignored: - feature gate (disabled → no-op / Err on direct call) - happy path (block.caption Some + Provenance CaptionApplied) - 빈 토큰 stream → empty text + caption.is_some() - CapturingMock 으로 req.images 라우팅 검증 (base64 1개, decode 가능) - 한국어 / 영어 프롬프트 분기 (CapturingMock 의 system 캡처) - LM Err → block.caption None 유지 + events 미기록 - 결정성 (동일 mock 입력 → 동일 caption) - max_pixels 클램프 (99999 → 1536, 4000×3000 PNG 다운스케일 검증) - opt-in 통합 (실 192.168.0.47 Ollama / gemma4:e4b → \"The image is a solid red color.\" 검증 완료, 4.3초) `cargo test --workspace --no-fail-fast -j 1` 전체 pass. `cargo clippy --workspace --all-targets -- -D warnings` pass. ## 의존성 경계 - 추가 deps: `kebab-llm` (trait 만), `base64` (이미 P6-2 에서 추가). - dev-deps: `kebab-llm/mock` 으로 `MockLanguageModel`, `kebab-llm-local` (통합 테스트 전용 — 런타임 deps 에는 없음). - forbidden 침범 없음: `kebab-source-fs / parse-md / normalize / chunk / store-* / embed* / search / rag / UI` 미참조. contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md sections: §3.4 ImageRefBlock.caption, §3.7a ModelCaption, §9.1 caption (model-generated, low trust).회차 1 — 큰 그림 견고합니다.
LanguageModeltrait 확장 (GenerateRequest.images) 으로 vision 라우팅을 한 곳에 박은 것,apply_caption의 "실패 시 block.caption 미설정 + 이벤트 미기록" 부분 상태 누출 차단, runtime gate 단일화 (cargo feature 드롭) + HOTFIXES 기록, 한국어 / 영어 프롬프트 분기,CapturingMock으로req.images라우팅 직접 검증한 테스트 — 모두 깔끔합니다. 실 Ollama 로 "The image is a solid red color." 캡션 검증까지 마친 것도 좋습니다.머지 전에 정리하면 좋을 항목 5건:
downscale_to_png(caption) 와downscale_to_long_edge(ocr) 가 거의 동일 알고리즘. 공용 helper 모듈 (image_prep.rs) 로 추출 권장.caption_image는 disabled 시 Err,apply_caption은 Ok(()) no-op. 책임 분리:caption_image는 raw 연산 (gate 없음),apply_caption만 gate 검사.apply_caption의 String clone 2회 — caption move 전에 note 포맷팅으로 0회 가능.model_versiondeviation 미기록 —<provider>/<prompt_template_version>확장이 spec literal 과 다르지만 HOTFIXES.md 에 한 줄 audit 미작성.MAX_DECODE_DIM이pub const인데 P6-3 caption 측 두 상수는 module-private. 일관성 권장.칭찬 —
kebab-llm-local을 dev-dep 으로만 끌어들여 "runtime forbidden, test-time OK" 를 깔끔하게 분리한 점이 §8 경계를 영리하게 우회했습니다. P+ 에서 PDF / audio caption 어댑터가 같은 패턴을 따라가면 통합 테스트 매트릭스가 일관된 형태로 쌓입니다.@@ -0,0 +41,4 @@/// `[256, 4096]` because vision LMs charge proportionally to input/// dimension — captions tolerate aggressive downscale better than/// OCR.const MIN_CAPTION_LONG_EDGE: u32 = 128;(작은 권장)
MIN_CAPTION_LONG_EDGE/MAX_CAPTION_LONG_EDGE가const인데 visibility 가 module-private 입니다. P6-2 의MAX_DECODE_DIM은pub const. 같은 crate 내 다른 모듈이 "caption 의 cap 이 1536 이라" 가정하고 싶을 때 (또는 외부 test/eval 이 정책을 검증할 때) 두 상수도pub으로 노출하는 게 일관됩니다.P6-2 의
MAX_DECODE_DIM과 시각적 일관성. tests/caption.rs 의99_999 → 1536검증도 literal 1536 대신 상수 참조로 갱신할 수 있습니다.@@ -0,0 +57,4 @@/// Direct callers should prefer [`apply_caption`] for end-to-end/// pipeline integration; this lower-level entry exists so tests can/// pin the produced `ModelCaption` independent of block mutation.pub fn caption_image(caption_image와apply_caption의 "disabled 처리" 가 비대칭입니다:caption_image(...)→enabled = false면 Err ("disabled" 메시지).apply_caption(...)→enabled = false면 Ok(()) (no-op).같은 config 상태에서 두 entry 가 다른 결과를 내는 건 호출자 입장에서 헷갈립니다. 함수의 책임 분리 관점에서 더 깔끔한 형태:
caption_image= "이미지를 캡션해 줘" 의 raw 연산. 항상 LM 호출, gate 검사 없음.apply_caption= pipeline 진입점. config 게이트 + 결과 반영 + provenance.이렇게 하면 disabled 인데 caption_image 를 직접 부르는 caller (현재로선 테스트뿐) 가 "왜 Err 반환?" 에 헷갈릴 일이 없고, doc-comment 의 "this lower-level entry exists so tests can pin the produced ModelCaption" 도 더 정직해집니다 — 테스트는 enabled flag 없이 caption 동작 자체를 검증할 수 있어야 자연스러움.
@@ -0,0 +135,4 @@let model_ref = llm.model_ref();let prompt_v = &cfg.image.caption.prompt_template_version;let model_version = format!(model_version = "<provider>/<prompt_template_version>"(예:"ollama/caption-v1") 는 spec 의 literalmodel_version: llm.model_ref().provider와 다릅니다. spec 자체가 "if a vision model exposes a stable revision, prefer that" 로 유연하게 열어 둔 부분이라 합리적 deviation 이지만, HOTFIXES.md 의 P6-3 항목에서 이 쪽 결정은 명시되지 않았습니다.둘 중 하나로 정리:
model_version을provider단독에서<provider>/<prompt_template_version>으로 확장. prompt 회귀와 모델 회귀를 별도 축으로 추적 가능."provider만), prompt_template_version 은 별도 필드 (예: provenance note) 에 박기.현재 구현이 분명히 더 유용해서 (1번) 으로 가는 게 맞아 보이지만, 결정을 audit log 에 박아 두면 P+ 에서 다른 어댑터 (PaddleOCR / Apple Vision 등) 가 model_version 을 어떻게 채울지 컨벤션이 한 줄로 잡힙니다.
@@ -0,0 +181,4 @@}let caption = caption_image(llm, image_bytes, lang_hint, cfg)?;let model_label = caption.model.clone();let model_version_label = caption.model_version.clone();caption.model.clone()+caption.model_version.clone()두 번의 String 알로케이션이 발생합니다 (ProvenanceEvent.note 포맷팅 용). caption 을 block.caption 으로 move 하기 전에 note 를 먼저 빌드하면 clone 0회 가능:사소하지만 hot ingest path (수천 장 이미지 캡션) 에선 미세 차이가 남습니다.
@@ -0,0 +220,4 @@/// PNG. Mirrors the OCR pipeline's pattern but with the caption-side/// long-edge bounds. PNG sources within the cap pass through without/// re-encode.fn downscale_to_png(bytes: &[u8], max_long_edge: u32) -> Result<Vec<u8>> {downscale_to_png(caption.rs) 와 P6-2 의downscale_to_long_edge(ocr.rs) 가 거의 동일 알고리즘입니다 — 헤더 sniff → PNG passthrough hot path → 단일 디코드 → resize → PNG re-encode. 차이는 단 두 가지: (a) caption 은(w, h)를 버리고Vec<u8>만 반환, (b) 클램프 범위 상수 (caption: [128, 1536], OCR: [256, 4096]).중복은 두 모듈 사이에 cross-module helper 를 만들 만합니다. 예:
crates/kebab-parse-image/src/image_prep.rs같은 private 모듈에:그러면 ocr.rs 도 caption.rs 도 같은 함수를 호출하고, 1px 후행 클램프 / PNG passthrough / 에러 메시지 패턴이 한 곳에서 관리됩니다. 향후 PDF / video thumbnail 등 같은 다운스케일이 필요한 모듈이 합류해도 같은 helper 를 재사용 가능.
본 PR scope 가 P6-3 라 강제는 아니지���, 머지 전에 정리하면 P6-3 와 P6-2 에서 발견될 다운스케일 회귀 (예: 1px 클램프 미적용) 가 한 번에 해결됩니다.
- 새 모듈 `crates/kebab-parse-image/src/image_prep.rs` — OCR + caption + 향후 PDF/video 가 공유할 단일 다운스케일 헬퍼 (`downscale_to_png`) 추출. 기존 ocr.rs / caption.rs 의 거의 동일 알고리즘 두 벌을 한 곳으로 통합. 1px 후행 클램프 / PNG passthrough hot path / 에러 메시지 패턴이 한 곳에서 관리됨. - src/ocr.rs: `downscale_to_long_edge` 제거 → `image_prep::downscale_to_png` 호출. `image::ImageReader / ImageFormat / Cursor` import 도 정리. - src/caption.rs: • `caption_image` / `apply_caption` 의 disabled 처리 비대칭 해소. `caption_image` 는 raw 연산 (gate 없음), `apply_caption` 만 `cfg.image.caption.enabled` 게이트 검사. 호출자가 같은 함수에서 같은 의미를 얻음. • `apply_caption` 의 caption.model / model_version `String::clone` 2회 → 0회. caption move 전에 ProvenanceEvent.note 를 먼저 빌드. • 다운스케일 로직 통째로 image_prep 위임. • `MIN_CAPTION_LONG_EDGE` / `MAX_CAPTION_LONG_EDGE` 를 `pub const` 로 노출 (P6-2 의 `MAX_DECODE_DIM` 가시성 컨벤션과 일관). - tests/caption.rs: • `caption_image_errors_when_feature_disabled` 를 `caption_image_runs_regardless_of_enabled_flag` 로 교체 — 새 책임 분리 의미 검증. • `caption_image_clamps_oversized_max_pixels` 가 literal 1536 대신 `kebab_parse_image::caption::MAX_CAPTION_LONG_EDGE` 상수 참조. - tasks/HOTFIXES.md: `model_version` 형태 deviation 한 단락 추가 (spec literal `provider` → `<provider>/<prompt_template_version>` 확장 + 사유). cargo test -p kebab-parse-image — 42 pass + 2 ignored (13 unit + 12 P6-1 + 8 P6-2 + 9 P6-3). cargo clippy --workspace --all-targets -- -D warnings — pass.회차 2 — 회차 1 의 다섯 항목이 정확히 반영되었습니다 (
image_prep.rs추출 + 양 모듈 위임,caption_image게이트 제거, clone 0회, 상수 pub, HOTFIXES 의 model_version deviation 단락). 책임 분리 + 코드 중복 해소가 깔끔합니다.남은 actionable 1건 + cosmetic 1건:
image_prep::downscale_to_png가 회차 1 에서 신규 추출된 핵심 helper 인데 자체 unit 테스트가 비어 있음 — PNG passthrough / 클램프 / aspect ratio / 손상 입력 4축 회귀 테스트 추가 권장.칭찬 —
apply_caption의 clone 정리 (caption move 전에 note 미리 빌드) 는 ingest hot path 에서 자산당 두 알로케이션을 절약하는 자연스러운 정리였고,MIN/MAX_CAPTION_LONG_EDGE를pub const로 노출하면서 테스트가 literal 1536 대신 상수 참조로 갱신된 점도 좋습니다 (P6-2 측MAX_DECODE_DIM와 컨벤션 동일).@@ -0,0 +8,4 @@//! messages in one place — future modules (PDF page thumbnails,//! video keyframes, …) plug in without re-deriving the algorithm.use std::io::Cursor;(작은 doc 권장) 모듈 doc 의 "send to vision models" 표현이 caption / OCR 만 시야에 둔 톤입니다. doc-comment 자체가 "future modules (PDF page thumbnails, video keyframes, …) plug in" 까지 약속하고 있으니 지금부터 "vision pipelines" / "image-to-LM channel" 정도로 일반화해 두면 미래 호출자가 doc 만 보고 호출 의도를 파악합니다. 사소합니다.
@@ -0,0 +25,4 @@/// the same `decode → optionally resize → re-encode` tail.pub(crate) fn downscale_to_png(bytes: &[u8],max_long_edge: u32,회차 1 에서 추출된 공용 helper 인데, 자체 회귀 테스트가 비어 있습니다. caption / ocr integration test 가 간접 검증을 하긴 하지만, helper 시그니처가 변경되거나 1px 후행 클램프가 무심코 사라져도 두 호출처 모두 그린 머지가 가능 (예: 다운스케일이 1px 초과해도 caption 측 wire 는 그대로 동작).
간단한 unit 테스트 4건 추가 권장:
공용 helper 가 워크스페이스의 다음 다운스케일 사용처 (PDF / video) 에도 같은 invariant 를 보장한다는 신호가 됩니다.
- src/image_prep.rs: • 신규 unit 테스트 6건 — PNG passthrough (zero-decode + 바이트 동일성), JPEG → PNG 재인코딩, 1px 후행 클램프 (max=1601 / long=4001 irrational scale), aspect ratio (4:3 보존, 2% 이내), 손상 PNG Err, 인식 불가 바이트 Err. • 모듈 doc-comment 의 \"send to vision models\" 표현을 \"image-to-LM pipeline / channel\" 으로 일반화. 미래 PDF / video keyframe 등 호출자가 doc 만 보고 호출 의도 파악 가능. cargo test -p kebab-parse-image — 48 pass + 2 ignored (19 unit (+6 image_prep) + 12 P6-1 + 8 P6-2 + 9 P6-3). cargo clippy -p kebab-parse-image --all-targets -- -D warnings — pass.회차 3 — 회차 2 의 두 항목이 정확히 반영되었습니다 (
image_prepunit 테스트 6건 + 모듈 doc 일반화). actionable 한 추가 지적이 보이지 않습니다.전체 회차 요약:
48 hermetic tests + 2 ignored (실 Ollama 통합) 통과,
cargo clippy --workspace --all-targets -- -D warnings통과.GenerateRequest.images확장은 wire shape 호환 (skip_serializing_if + serde default) 으로 pre-P6-3 페이로드 / snapshot 영향 없음.머지 가능합니다. 사용자가 Gitea UI 에서 머지해 주세요:
#34
"Delete branch after merge" 체크 권장. 로컬 브랜치는 머지 후
git fetch -p+git branch -d feat/p6-3-caption-adapter로 정리.P6 phase epic 완료 — P6-1 (extractor) / P6-2 (OCR) / P6-3 (caption) 세 컴포넌트 모두 머지되면 image ingestion 파이프라인이 contract 단위로 종결됩니다. 다음 task 후보: P7 (PDF) 또는 P8 (audio) — 둘 다 P5 이후 병렬 가능 phase.
@@ -0,0 +57,4 @@////// Honours the `[MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE]` clamp/// on `config.image.caption.max_pixels` so a hostile config cannot/// blow up prompt cost.(칭찬)
caption_image(raw 연산) /apply_caption(gate + provenance) 의 책임 분리가 코드 양 자체를 줄였고, 더 중요하게는 "같은 config 에서 두 함수가 다른 결과" 라는 인지 부담을 제거했습니다. P+ 에서 caption 외 다른 vision adapter (예: alt-text generator) 가 비슷한 책임 분리를 따라가는 패턴이 됩니다.@@ -0,0 +8,4 @@//! source bytes when they already satisfy both constraints.//! Centralising this here keeps the 1px-rounding fix, the PNG//! passthrough hot path, and the error messages in one place —//! future image-to-LM channels (PDF page thumbnails, video(칭찬) 모듈 doc 의 "image-to-LM pipeline / channel" 일반화가 좋습니다. 향후 PDF page thumbnail / video keyframe 등 같은 다운스케일을 필요로 하는 모듈이 합류할 때, 새 호출자가 doc 만 보고 "이 helper 가 OCR/caption 전용이 아니구나" 를 즉시 파악할 수 있게 됐습니다.
(칭찬)
long_edge_clamped_strictly_to_max_for_irrational_scale가max=1601, long=4001이라는 정확히 까다로운 코너 케이스를 박았습니다 — round-to-nearest 가 long-axis 를 1px 초과시키는 정확한 시나리오. 회차 2 의 1px 클램프 후행 처리가 무심코 사라지면 이 테스트가 즉시 빨간색이 됩니다. 회귀 신호로 가장 정확한 fixture 선택입니다.