- 신규 모듈 `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).
76 lines
2.4 KiB
Rust
76 lines
2.4 KiB
Rust
//! Compile-only test: verifies the crate's public surface (trait re-exports
|
|
//! and the `assert_finish_chunk` helper) is reachable without the `mock`
|
|
//! feature.
|
|
//!
|
|
//! Runs under both `cargo test -p kb-llm` and
|
|
//! `cargo test -p kb-llm --features mock`.
|
|
|
|
use kebab_llm::{
|
|
FinishReason, GenerateRequest, LanguageModel, ModelRef, TokenChunk, TokenUsage,
|
|
assert_finish_chunk,
|
|
};
|
|
|
|
/// A trivial in-test impl that does NOT rely on the `mock` feature — proves
|
|
/// the trait surface alone is enough to write a `LanguageModel`. It returns a
|
|
/// stream that terminates immediately with `Done`.
|
|
struct ZeroLanguageModel;
|
|
|
|
impl LanguageModel for ZeroLanguageModel {
|
|
fn model_ref(&self) -> ModelRef {
|
|
ModelRef {
|
|
id: "zero".into(),
|
|
provider: "zero".into(),
|
|
dimensions: None,
|
|
}
|
|
}
|
|
fn context_tokens(&self) -> usize {
|
|
0
|
|
}
|
|
fn generate_stream(
|
|
&self,
|
|
_req: GenerateRequest,
|
|
) -> anyhow::Result<Box<dyn Iterator<Item = anyhow::Result<TokenChunk>> + Send>> {
|
|
let chunks = vec![TokenChunk::Done {
|
|
finish_reason: FinishReason::Stop,
|
|
usage: TokenUsage {
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
latency_ms: 0,
|
|
},
|
|
}];
|
|
Ok(Box::new(chunks.into_iter().map(Ok)))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dyn_dispatch_via_box_works() {
|
|
let m: Box<dyn LanguageModel> = Box::new(ZeroLanguageModel);
|
|
assert_eq!(m.model_ref().id, "zero");
|
|
assert_eq!(m.context_tokens(), 0);
|
|
|
|
let req = GenerateRequest {
|
|
system: "sys".into(),
|
|
user: "usr".into(),
|
|
stop: vec![],
|
|
max_tokens: 16,
|
|
temperature: 0.0,
|
|
seed: None,
|
|
images: Vec::new(),
|
|
};
|
|
let stream = m.generate_stream(req).expect("stream");
|
|
let chunks: Vec<TokenChunk> = stream.map(|r| r.expect("ok chunk")).collect();
|
|
assert_eq!(chunks.len(), 1);
|
|
assert_finish_chunk(&chunks);
|
|
}
|
|
|
|
/// Sanity: when built WITHOUT `--features mock`, the `MockLanguageModel`
|
|
/// symbol is absent. We can't usefully test `nm` from inside a unit test, but
|
|
/// we can at least confirm the cfg gate parses both ways. See PR notes for
|
|
/// the CI-side `nm`/`cargo bloat` symbol scan.
|
|
#[cfg(not(feature = "mock"))]
|
|
#[test]
|
|
fn mock_feature_off_compiles() {
|
|
// No-op — the test's existence proves the `not(feature = "mock")` gate
|
|
// compiles and the crate is usable without `MockLanguageModel`.
|
|
}
|