refactor(config): v3 경로 call-site sweep (kebab-app/kebab-eval/kebab-parse-image)

부모 경로에 .ingest 삽입(leaf 구조체 불변). src + 테스트 call-site 전부.
kebab-cli 테스트의 v2 TOML fixture 는 from_file 자동변환(T6) 경로 검증용으로 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:40:06 +00:00
parent 148c8b7040
commit d5c69f6715
19 changed files with 90 additions and 89 deletions

View File

@@ -18,7 +18,7 @@
//!
//! The original P6-3 spec asked for a cargo feature `caption` (default
//! OFF at compile time). We collapse this into a single runtime gate
//! (`config.image.caption.enabled = false`, default OFF). Reasoning:
//! (`config.ingest.image.caption.enabled = false`, default OFF). Reasoning:
//! the captioning module's only extra deps are `base64` + `image` +
//! `kebab-llm` trait — all already pulled in by the rest of the
//! crate. A cargo feature would only complicate the build matrix
@@ -50,13 +50,13 @@ const CAPTION_MAX_TOKENS: usize = 96;
/// Run a caption pass and return the resulting `ModelCaption`.
///
/// Pure raw operation — does **not** consult `config.image.caption.enabled`.
/// Pure raw operation — does **not** consult `config.ingest.image.caption.enabled`.
/// The runtime feature gate lives in [`apply_caption`]; this entry
/// always invokes the LM. Tests pinning the produced `ModelCaption`
/// shape can call this directly without flipping the config flag.
///
/// Honours the `[MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE]` clamp
/// on `config.image.caption.max_pixels` so a hostile config cannot
/// on `config.ingest.image.caption.max_pixels` so a hostile config cannot
/// blow up prompt cost.
pub fn caption_image(
llm: &dyn LanguageModel,
@@ -65,15 +65,16 @@ pub fn caption_image(
cfg: &kebab_config::Config,
) -> Result<ModelCaption> {
let max_pixels = cfg
.ingest
.image
.caption
.max_pixels
.clamp(MIN_CAPTION_LONG_EDGE, MAX_CAPTION_LONG_EDGE);
if max_pixels != cfg.image.caption.max_pixels {
if max_pixels != cfg.ingest.image.caption.max_pixels {
tracing::warn!(
target: "kebab-parse-image",
"image.caption.max_pixels = {} clamped to {} (legal range [{}, {}])",
cfg.image.caption.max_pixels,
cfg.ingest.image.caption.max_pixels,
max_pixels,
MIN_CAPTION_LONG_EDGE,
MAX_CAPTION_LONG_EDGE
@@ -129,7 +130,7 @@ pub fn caption_image(
let caption_text = text.trim().to_string();
let model_ref = llm.model_ref();
let prompt_v = &cfg.image.caption.prompt_template_version;
let prompt_v = &cfg.ingest.image.caption.prompt_template_version;
let model_version = format!(
"{provider}/{prompt}",
provider = model_ref.provider,
@@ -151,7 +152,7 @@ pub fn caption_image(
})
}
/// Pipeline entry point — gate-checks `config.image.caption.enabled`
/// Pipeline entry point — gate-checks `config.ingest.image.caption.enabled`
/// then mutates `block.caption` in place via [`caption_image`].
///
/// When `enabled = false` the function is a clean no-op (returns
@@ -167,7 +168,7 @@ pub fn apply_caption(
cfg: &kebab_config::Config,
events: &mut Vec<ProvenanceEvent>,
) -> Result<()> {
if !cfg.image.caption.enabled {
if !cfg.ingest.image.caption.enabled {
tracing::debug!(
target: "kebab-parse-image",
"captioning skipped — image.caption.enabled = false"

View File

@@ -39,7 +39,7 @@ use crate::image_prep;
/// Engine name written into `OcrText.engine` for the Ollama-vision adapter.
pub const OLLAMA_VISION_ENGINE: &str = "ollama-vision";
/// Lower bound on `config.image.ocr.max_pixels`. Anything below this is
/// Lower bound on `config.ingest.image.ocr.max_pixels`. Anything below this is
/// silently bumped to keep the model from receiving an unreadable thumbnail.
const MIN_LONG_EDGE: u32 = 256;
@@ -126,14 +126,14 @@ pub struct OllamaVisionOcr {
impl OllamaVisionOcr {
/// Build an adapter from a workspace [`kebab_config::Config`].
/// Reads `config.image.ocr.{model, endpoint, languages, max_pixels}`;
/// Reads `config.ingest.image.ocr.{model, endpoint, languages, max_pixels}`;
/// when `endpoint` is empty falls back to `config.models.llm.endpoint`
/// so the same Ollama host serves both LLM and OCR by default.
///
/// Construction does NOT touch the network — the first HTTP call
/// happens inside [`OcrEngine::recognize`].
pub fn new(config: &kebab_config::Config) -> Result<Self> {
let ocr = &config.image.ocr;
let ocr = &config.ingest.image.ocr;
let endpoint = match ocr.endpoint.as_deref() {
Some(s) if !s.is_empty() => s.to_string(),
_ => config.models.llm.endpoint.clone(),

View File

@@ -122,7 +122,7 @@ impl ModelPaths {
/// [`from_default_dir`]: ModelPaths::from_default_dir
pub fn from_config(config: &kebab_config::Config) -> Self {
let defaults = Self::from_default_dir();
let ocr = &config.image.ocr;
let ocr = &config.ingest.image.ocr;
Self {
det: ocr.det_model.as_ref().map(PathBuf::from).unwrap_or(defaults.det),
rec: ocr.rec_model.as_ref().map(PathBuf::from).unwrap_or(defaults.rec),
@@ -138,7 +138,7 @@ impl OnnxPaddleOcr {
/// here are fail-fast (matches the Ollama adapter's construction contract).
pub fn new(config: &kebab_config::Config) -> Result<Self> {
let paths = ModelPaths::from_config(config);
let ocr = &config.image.ocr;
let ocr = &config.ingest.image.ocr;
Self::from_paths(
&paths,
ocr.score_thresh,
@@ -882,8 +882,8 @@ mod tests {
assert!(def.dict.ends_with("korean_dict.txt"), "{:?}", def.dict);
// Override det + dict; rec stays bundled (partial override allowed).
cfg.image.ocr.det_model = Some("/custom/det.onnx".to_string());
cfg.image.ocr.dict = Some("/custom/dict.txt".to_string());
cfg.ingest.image.ocr.det_model = Some("/custom/det.onnx".to_string());
cfg.ingest.image.ocr.dict = Some("/custom/dict.txt".to_string());
let ov = ModelPaths::from_config(&cfg);
assert_eq!(ov.det, PathBuf::from("/custom/det.onnx"));
assert_eq!(ov.dict, PathBuf::from("/custom/dict.txt"));

View File

@@ -22,8 +22,8 @@ use crate::common::red_100x50_png;
fn cfg_with_caption_enabled() -> Config {
let mut cfg = Config::defaults();
cfg.image.caption.enabled = true;
cfg.image.caption.max_pixels = 512;
cfg.ingest.image.caption.enabled = true;
cfg.ingest.image.caption.max_pixels = 512;
cfg
}
@@ -67,7 +67,7 @@ fn mk_mock(canned: &str) -> MockLanguageModel {
#[test]
fn apply_caption_no_op_when_feature_disabled() {
let mut cfg = Config::defaults();
cfg.image.caption.enabled = false;
cfg.ingest.image.caption.enabled = false;
let mock = mk_mock("ignored");
let mut block = empty_image_block();
let mut events: Vec<ProvenanceEvent> = Vec::new();
@@ -292,8 +292,8 @@ fn caption_image_deterministic_with_identical_inputs() {
#[test]
fn caption_image_clamps_oversized_max_pixels() {
let mut cfg = Config::defaults();
cfg.image.caption.enabled = true;
cfg.image.caption.max_pixels = 99_999; // way over MAX_CAPTION_LONG_EDGE
cfg.ingest.image.caption.enabled = true;
cfg.ingest.image.caption.max_pixels = 99_999; // way over MAX_CAPTION_LONG_EDGE
let captured_images: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mock = CapturingMock {
captured_system: Arc::new(Mutex::new(None)),
@@ -339,8 +339,8 @@ fn caption_integration_real_ollama_describes_image() {
use kebab_llm_local::OllamaLanguageModel;
let mut cfg = Config::defaults();
cfg.image.caption.enabled = true;
cfg.image.caption.max_pixels = 768;
cfg.ingest.image.caption.enabled = true;
cfg.ingest.image.caption.max_pixels = 768;
if let Ok(ep) = std::env::var("KEBAB_MODELS_LLM_ENDPOINT") {
cfg.models.llm.endpoint = ep;
} else {

View File

@@ -19,10 +19,10 @@ use crate::common::red_100x50_png;
fn cfg_for_endpoint(endpoint: &str) -> Config {
let mut cfg = Config::defaults();
cfg.image.ocr.endpoint = Some(endpoint.to_string());
cfg.image.ocr.model = "gemma4:e4b".to_string();
cfg.image.ocr.languages = vec!["eng".to_string(), "kor".to_string()];
cfg.image.ocr.max_pixels = 1024;
cfg.ingest.image.ocr.endpoint = Some(endpoint.to_string());
cfg.ingest.image.ocr.model = "gemma4:e4b".to_string();
cfg.ingest.image.ocr.languages = vec!["eng".to_string(), "kor".to_string()];
cfg.ingest.image.ocr.max_pixels = 1024;
cfg
}
@@ -375,9 +375,9 @@ async fn ocr_integration_real_ollama_transcribes_text() {
};
let cfg = {
let mut c = Config::defaults();
c.image.ocr.endpoint = Some(endpoint);
c.image.ocr.model = model;
c.image.ocr.max_pixels = 1024;
c.ingest.image.ocr.endpoint = Some(endpoint);
c.ingest.image.ocr.model = model;
c.ingest.image.ocr.max_pixels = 1024;
c
};
let text = tokio::task::spawn_blocking(move || run_recognize(cfg, bytes, None))