Optionally populate ImageRefBlock.caption with ModelCaption { text, model, model_version } produced by a vision-capable LM (e.g., qwen2.5-vl:7b via Ollama). Feature-gated; default OFF.
Why now / why this size
Captioning closes the multimodal loop. Strict separation from OCR keeps trust levels distinct: captions are generated, OCR is observed. Adapter is small — single trait method + one prompt.
Feature gate: if config.image.caption.enabled = false (default), apply_caption is a no-op (returns Ok(()) without invoking LM).
Pre-process: downscale image to config.image.caption.max_pixels (default 768×768 long edge) preserving aspect; encode as PNG.
Build prompt:
system = "이미지를 한 문장으로 객관적으로 설명한다. 추측은 피하고, 보이는 것만 적는다."
user = [image_base64]\n\n위 이미지를 한국어로 한 문장으로 설명하라. (if lang hint == "ko") or English variant otherwise.
The base64 wrapper assumes the LM adapter routes vision inputs via Ollama's images: [base64] field (this is provider-specific; the adapter is responsible for rendering the prompt to wire). For non-vision LMs, return an error and skip.
ModelCaption { text: collected, model: llm.model_ref().id, model_version: llm.model_ref().provider } (use provider as a coarse "version" proxy; if a vision model exposes a stable revision, prefer that).
apply_caption sets block.caption = Some(...) and appends Provenance::CaptionApplied event.
Trust: caption is model-generated and labeled trust_level = TrustLevel::Generated if the caller propagates trust into chunk-level UI; this task only emits the ModelCaption.
Failure modes:
LM error → return anyhow::Error; caller may decide to skip (do not fail the entire ingest).
Empty LM output → still set block.caption = Some(ModelCaption { text: "" }) so downstream code can distinguish "captioning attempted, no result" from "captioning never attempted".
Determinism: temperature=0 + seed=0. Tests use MockLanguageModel to assert deterministic captions.
Storage / wire effects
None directly. Caller persists via kebab-store-sqlite.
Test plan
kind
description
fixture / data
unit
feature disabled → apply_caption no-op
inline (config.enabled = false)
unit
mock LM emits "사진 한 장" → block.caption.text = "사진 한 장"
Feature default OFF; only on when user opts in via config
PR links design §3.4 ImageRefBlock.caption, §9.1
Out of scope
Multimodal RAG that uses caption text in answer (P+).
CLIP / image embedding for cross-modal search (P+).
Caption translation (P+).
Risks / notes
Vision LMs hallucinate. The system prompt explicitly forbids guessing, but expect false captions; UI and RAG must always label captions as model-generated.
Ollama qwen2.5-vl accepts base64 images via images:[] — this is provider-specific; documenting the wire shape in the spec keeps adapter swaps cheap.
Large images bloat prompt costs; cap aggressively (768×768 long edge default).