Files
kebab/crates/kebab-llm/tests/mock.rs
altair823 cd2213e48d feat(kebab-parse-image): P6-3 caption adapter — vision LM via trait
- 신규 모듈 `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).
2026-05-02 06:05:39 +00:00

212 lines
6.0 KiB
Rust

//! Integration tests for `MockLanguageModel`. Gated behind the `mock` feature.
//!
//! Canonical invocation: `cargo test -p kb-llm --features mock`.
#![cfg(feature = "mock")]
use kebab_llm::{
FinishReason, GenerateRequest, LanguageModel, MockLanguageModel, TokenChunk, TokenUsage,
assert_finish_chunk,
};
use proptest::prelude::*;
fn usage() -> TokenUsage {
TokenUsage {
prompt_tokens: 10,
completion_tokens: 20,
latency_ms: 30,
}
}
fn req_with_stop(stop: Vec<&str>) -> GenerateRequest {
GenerateRequest {
system: "sys".into(),
user: "usr".into(),
stop: stop.into_iter().map(String::from).collect(),
max_tokens: 64,
temperature: 0.0,
seed: None,
images: Vec::new(),
}
}
fn mk(canned: &str, finish: FinishReason) -> MockLanguageModel {
MockLanguageModel {
model_id: "mock-test".into(),
provider: "mock".into(),
context_tokens: 4096,
canned_response: canned.into(),
canned_finish: finish,
canned_usage: usage(),
}
}
fn drain(m: &dyn LanguageModel, req: GenerateRequest) -> Vec<TokenChunk> {
m.generate_stream(req)
.expect("generate_stream")
.map(|r| r.expect("ok chunk"))
.collect()
}
#[test]
fn streams_then_done() {
let m = mk("hello", FinishReason::Stop);
let chunks = drain(&m, req_with_stop(vec![]));
// 5 Token chunks ("h", "e", "l", "l", "o") + Done.
assert_eq!(chunks.len(), 6);
assert_finish_chunk(&chunks);
let tokens: Vec<&str> = chunks
.iter()
.filter_map(|c| match c {
TokenChunk::Token(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(tokens, vec!["h", "e", "l", "l", "o"]);
match chunks.last().unwrap() {
TokenChunk::Done {
finish_reason,
usage: u,
} => {
assert_eq!(*finish_reason, FinishReason::Stop);
assert_eq!(*u, usage());
}
_ => unreachable!(),
}
}
#[test]
fn honors_stop_strings() {
// canned has "STOP" embedded; req.stop=["STOP"] truncates before it.
let m = mk("abc STOP defg", FinishReason::Length);
let chunks = drain(&m, req_with_stop(vec!["STOP"]));
let concat: String = chunks
.iter()
.filter_map(|c| match c {
TokenChunk::Token(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(concat, "abc ");
// Stop-string truncation forces FinishReason::Stop, overriding the
// configured `canned_finish` (Length here).
match chunks.last().unwrap() {
TokenChunk::Done { finish_reason, .. } => {
assert_eq!(*finish_reason, FinishReason::Stop);
}
_ => panic!("last chunk must be Done"),
}
}
#[test]
fn honors_first_stop_match() {
// Two stop strings; "BAR" appears at byte 4, "FOO" at byte 12. Earliest
// wins regardless of order in req.stop.
let m = mk("abc BAR xyz FOO end", FinishReason::Stop);
let chunks = drain(&m, req_with_stop(vec!["FOO", "BAR"]));
let concat: String = chunks
.iter()
.filter_map(|c| match c {
TokenChunk::Token(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(concat, "abc ");
}
#[test]
fn dyn_dispatch_via_box() {
let m: Box<dyn LanguageModel> = Box::new(mk("xy", FinishReason::Stop));
assert_eq!(m.model_ref().id, "mock-test");
assert_eq!(m.model_ref().provider, "mock");
assert!(m.model_ref().dimensions.is_none());
assert_eq!(m.context_tokens(), 4096);
let chunks: Vec<TokenChunk> = m
.generate_stream(req_with_stop(vec![]))
.expect("stream")
.map(|r| r.unwrap())
.collect();
assert_eq!(chunks.len(), 3); // x, y, Done
assert_finish_chunk(&chunks);
}
#[test]
fn concat_equals_canned() {
let canned = "the quick brown fox";
let m = mk(canned, FinishReason::Stop);
let chunks = drain(&m, req_with_stop(vec![]));
let concat: String = chunks
.iter()
.filter_map(|c| match c {
TokenChunk::Token(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(concat, canned);
}
#[test]
fn model_ref_has_no_dimensions() {
let m = mk("anything", FinishReason::Stop);
let r = m.model_ref();
assert_eq!(r.id, "mock-test");
assert_eq!(r.provider, "mock");
assert!(r.dimensions.is_none());
}
#[test]
fn finish_reason_passes_through_when_no_stop_match() {
// No stop hit → `canned_finish` is preserved verbatim.
let m = mk("hi", FinishReason::Length);
let chunks = drain(&m, req_with_stop(vec!["NEVER_MATCHES"]));
match chunks.last().unwrap() {
TokenChunk::Done { finish_reason, .. } => {
assert_eq!(*finish_reason, FinishReason::Length);
}
_ => panic!("last chunk must be Done"),
}
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 100,
..ProptestConfig::default()
})]
/// 100 random Unicode canned strings: with no stop strings configured,
/// the stream MUST end in Done, contain exactly `canned.chars().count()`
/// Token chunks, and concatenate back to the canned text byte-equal.
#[test]
fn proptest_random_canned_strings(canned in ".{0,256}") {
let m = mk(&canned, FinishReason::Stop);
let chunks = drain(&m, req_with_stop(vec![]));
// Last chunk must be Done.
assert_finish_chunk(&chunks);
// Token-chunk count == canned.chars().count().
let token_count = chunks
.iter()
.filter(|c| matches!(c, TokenChunk::Token(_)))
.count();
prop_assert_eq!(token_count, canned.chars().count());
// Concatenation == canned (byte-equal).
let concat: String = chunks
.iter()
.filter_map(|c| match c {
TokenChunk::Token(s) => Some(s.as_str()),
_ => None,
})
.collect();
prop_assert_eq!(concat, canned);
}
}