refactor(config): OCR 중복 제거 — 공유 [ingest.ocr] 엔진 블록 + v4→v5 마이그레이션
Phase 2 Unit 1. OcrCfg(image) 13필드가 PdfOcrCfg(pdf) 와 전부 중복(image 고유 0, pdf 고유 4) + apply_env 에 KEBAB_IMAGE_OCR_*/KEBAB_PDF_OCR_* 27 arm 복제를 제거한다. - 신규 SharedOcrEngineCfg(13 공유 필드, 전부 Option, default None) = [ingest.ocr]. 엔진 설정 단일 출처. image/pdf 블록은 on/off 토글 + override. - load-time resolution(Config::resolve_ocr, from_file 호출): 공유 필드가 Some 이고 미디어 블록이 그 키 미명시면 concrete OcrCfg/PdfOcrCfg 로 overlay(presence 는 toml::Value 로 판정; 미디어 > 공유 > 내장 default). struct 필드는 그대로 두고 엔진 필드에 #[serde(default)] 만 추가(slim 블록 파싱) → image(gemma4:e4b/1600) vs pdf(qwen2.5vl:3b/2048) 미디어별 기본값 보존. - resolver Config::image_ocr()/pdf_ocr() 추가. consumer(kebab-parse-image, kebab-app build_*_ocr_engine·ingest gate·pdf_ocr_apply·ingest_config_signature)가 전부 경유 → god-struct 직접 read 제거. - apply_env: 27 arm → 공유 KEBAB_OCR_* 12 arm(image+pdf 동시) + pdf 고유 4 arm + 미디어별 KEBAB_IMAGE_OCR_ENABLED/KEBAB_PDF_OCR_ENABLED. - step_4_to_5: [ingest.image.ocr] 12 엔진 키를 [ingest.ocr] 로 move_table(enabled 제외). pdf 블록 무손상(reconcile 이 채워 공유 overlay 오염 X). annotated_default 도 동일 통합으로 v5 canonical 형상. CURRENT_SCHEMA_VERSION=5. - v4→v5 round-trip 테스트(비-default image engine 보존 + pdf 오염 X + 멱등). effective OCR 바이트 동일 → ingest_config_signature 불변 → 강제 재색인 없음. 검증: clippy --workspace --all-targets 0 / kebab-config·kebab-parse-image· kebab-parse-pdf·kebab-app 테스트 pass. surface: README [ingest.ocr] 절 + SMOKE config 블록 + DOGFOOD env + HOTFIXES dated entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
This commit is contained in:
@@ -406,6 +406,78 @@ fn default_nli_threshold() -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
/// v5: workspace-wide OCR **engine** defaults shared by image and PDF OCR.
|
||||
///
|
||||
/// Before v5, the 13 engine-level OCR knobs (engine, model, endpoint,
|
||||
/// languages, max_pixels, request_timeout_secs + the 6 paddle-onnx asset /
|
||||
/// tuning fields) were duplicated verbatim across `[ingest.image.ocr]` and
|
||||
/// `[ingest.pdf.ocr]`. This block holds them ONCE under `[ingest.ocr]`; the
|
||||
/// per-medium blocks (`[ingest.image.ocr]` / `[ingest.pdf.ocr]`) keep only
|
||||
/// their `enabled` toggle + any medium-specific override / unique field.
|
||||
///
|
||||
/// Resolution happens at load (`Config::resolve_ocr`, called from
|
||||
/// `from_file`): for every shared key present here but **absent** from a
|
||||
/// medium's own block, the shared value is merged down into that medium's
|
||||
/// concrete [`OcrCfg`] / [`PdfOcrCfg`]. A key the user wrote explicitly in
|
||||
/// the per-medium block always wins (precedence: medium override > shared >
|
||||
/// medium hardcoded default). Image and PDF therefore keep their distinct
|
||||
/// hardcoded defaults (`gemma4:e4b`/1600 vs `qwen2.5vl:3b`/2048) when neither
|
||||
/// block sets the field.
|
||||
///
|
||||
/// Every field is `Option` so "unset in `[ingest.ocr]`" is distinguishable
|
||||
/// from "set to the type's zero value". Default = all `None` (no shared
|
||||
/// override → each medium uses its own block / hardcoded default), so a
|
||||
/// `Config` built without a `[ingest.ocr]` section behaves exactly as before.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SharedOcrEngineCfg {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub engine: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub languages: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_pixels: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_timeout_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub det_model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rec_model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dict: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
serialize_with = "ser_opt_f32_clean"
|
||||
)]
|
||||
pub score_thresh: Option<f32>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
serialize_with = "ser_opt_f32_clean"
|
||||
)]
|
||||
pub unclip_ratio: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_boxes: Option<usize>,
|
||||
}
|
||||
|
||||
/// `Option<f32>` 직렬화 시 `Some` 값을 [`ser_f32_clean`] 과 같은 shortest
|
||||
/// round-trip 으로 출력한다(`None` 은 `skip_serializing_if` 가 처리).
|
||||
fn ser_opt_f32_clean<S>(v: &Option<f32>, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match v {
|
||||
Some(f) => ser_f32_clean(f, s),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings for the image ingest pipeline (P6). `ocr` controls OCR
|
||||
/// behaviour (P6-2); `caption` controls vision-LM captioning (P6-3).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -434,11 +506,19 @@ impl ImageCfg {
|
||||
pub struct OcrCfg {
|
||||
/// Run OCR on every image during ingest. Default `false` because
|
||||
/// OCR adds one model call per asset.
|
||||
///
|
||||
/// v5: all engine-level fields carry `#[serde(default)]` so a slim
|
||||
/// `[ingest.image.ocr]` block (e.g. only `enabled`, with the engine
|
||||
/// knobs hoisted to the shared `[ingest.ocr]`) still deserializes. The
|
||||
/// per-field defaults match [`OcrCfg::defaults`] (image medium).
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Engine identifier. v1 only ships `"ollama-vision"`.
|
||||
#[serde(default = "default_ocr_engine")]
|
||||
pub engine: String,
|
||||
/// Model id passed to the engine (e.g. `"gemma4:e4b"` for
|
||||
/// Ollama-vision).
|
||||
#[serde(default = "default_image_ocr_model")]
|
||||
pub model: String,
|
||||
/// HTTP endpoint for the OCR engine. `None` (or a missing key in
|
||||
/// TOML) means "fall back to `models.llm.endpoint`" — convenient
|
||||
@@ -447,9 +527,11 @@ pub struct OcrCfg {
|
||||
pub endpoint: Option<String>,
|
||||
/// BCP-47 language hints (e.g. `["eng", "kor"]`). The adapter
|
||||
/// renders them into the prompt; the LLM honours them probabilistically.
|
||||
#[serde(default = "default_ocr_languages")]
|
||||
pub languages: Vec<String>,
|
||||
/// Cap the long edge of the image (in pixels) before sending. Larger
|
||||
/// images bloat prompt cost. Default `1600`.
|
||||
#[serde(default = "default_image_ocr_max_pixels")]
|
||||
pub max_pixels: u32,
|
||||
/// v0.17.2 post-dogfood: Hard ceiling on a single HTTP exchange to
|
||||
/// the OCR endpoint. Sister knob to [`LlmCfg::request_timeout_secs`]
|
||||
@@ -519,6 +601,22 @@ impl OcrCfg {
|
||||
}
|
||||
}
|
||||
|
||||
/// v5: shared OCR engine-field defaults (image medium), so a slim
|
||||
/// `[ingest.image.ocr]` block with the engine knobs hoisted to
|
||||
/// `[ingest.ocr]` still deserializes. Mirror [`OcrCfg::defaults`].
|
||||
fn default_ocr_engine() -> String {
|
||||
"ollama-vision".to_string()
|
||||
}
|
||||
fn default_ocr_languages() -> Vec<String> {
|
||||
vec!["eng".to_string(), "kor".to_string()]
|
||||
}
|
||||
fn default_image_ocr_model() -> String {
|
||||
"gemma4:e4b".to_string()
|
||||
}
|
||||
fn default_image_ocr_max_pixels() -> u32 {
|
||||
1600
|
||||
}
|
||||
|
||||
/// paddle-onnx DBNet box score threshold default. See [`OcrCfg::score_thresh`].
|
||||
fn default_ocr_score_thresh() -> f32 {
|
||||
0.3
|
||||
@@ -534,7 +632,8 @@ fn default_ocr_max_boxes() -> usize {
|
||||
|
||||
/// v0.17.2 post-dogfood: matches the legacy hard-coded ceiling so
|
||||
/// existing configs that omit the field keep behaving identically.
|
||||
/// Overridable per config / `KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS`.
|
||||
/// Overridable per config / `KEBAB_OCR_REQUEST_TIMEOUT_SECS` (v5: shared
|
||||
/// engine env, applies to both image and pdf OCR).
|
||||
fn default_ocr_request_timeout_secs() -> u64 {
|
||||
300
|
||||
}
|
||||
@@ -646,24 +745,37 @@ impl Default for LoggingCfg {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PdfOcrCfg {
|
||||
/// Run OCR on scanned PDF pages. Default `false` (opt-in).
|
||||
///
|
||||
/// v5: the shared engine fields (engine/model/languages/max_pixels +
|
||||
/// the request-timeout & paddle knobs) carry `#[serde(default)]` so a
|
||||
/// slim `[ingest.pdf.ocr]` block (engine knobs hoisted to the shared
|
||||
/// `[ingest.ocr]`) still deserializes; per-field defaults match the PDF
|
||||
/// medium ([`PdfOcrCfg::defaults`]).
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// `false` (default) — text-detect first + vision fallback on
|
||||
/// scanned pages only. `true` — vision LLM 호출 on every page
|
||||
/// (vector PDF 의 dual-text confidence boost — doubles chunk count).
|
||||
#[serde(default)]
|
||||
pub always_on: bool,
|
||||
/// Engine identifier: `"ollama-vision"` or `"paddle-onnx"`. When set to
|
||||
/// `"paddle-onnx"`, model paths and tuning knobs are read from
|
||||
/// `[image.ocr]`, not `[pdf.ocr]` — PaddleOCR has no PDF-specific tuning.
|
||||
/// `"paddle-onnx"`, model paths and tuning knobs are read from the shared
|
||||
/// `[ingest.ocr]` (resolved via the image OCR block) — PaddleOCR has no
|
||||
/// PDF-specific tuning.
|
||||
#[serde(default = "default_ocr_engine")]
|
||||
pub engine: String,
|
||||
/// Vision model id. Default `"qwen2.5vl:3b"` per PoC (§3.5 family
|
||||
/// asymmetry vs image OCR's gemma4:e4b is acknowledged).
|
||||
#[serde(default = "default_pdf_ocr_model")]
|
||||
pub model: String,
|
||||
/// HTTP endpoint. `None` → fall back to `models.llm.endpoint`.
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
/// BCP-47 language hints rendered into prompt.
|
||||
#[serde(default = "default_ocr_languages")]
|
||||
pub languages: Vec<String>,
|
||||
/// Long-edge cap (px). Larger images bloat prompt cost.
|
||||
#[serde(default = "default_pdf_ocr_max_pixels")]
|
||||
pub max_pixels: u32,
|
||||
/// HTTP request timeout (sec). Same `0` = "fail immediately"
|
||||
/// semantics as `image.ocr.request_timeout_secs` (NOT a disable
|
||||
@@ -757,6 +869,15 @@ fn default_pdf_ocr_min_char_count() -> u32 {
|
||||
fn default_pdf_ocr_lang_hint() -> Option<String> {
|
||||
Some("kor".to_string())
|
||||
}
|
||||
/// v5: PDF-medium engine-field defaults (distinct from image: qwen2.5vl:3b /
|
||||
/// 2048 px), so a slim `[ingest.pdf.ocr]` block deserializes. Mirror
|
||||
/// [`PdfOcrCfg::defaults`].
|
||||
fn default_pdf_ocr_model() -> String {
|
||||
"qwen2.5vl:3b".to_string()
|
||||
}
|
||||
fn default_pdf_ocr_max_pixels() -> u32 {
|
||||
2048
|
||||
}
|
||||
|
||||
/// p9-fb-14: TUI-only configuration. Currently a single `theme`
|
||||
/// selector (`"dark"` / `"light"`); future fields (custom role
|
||||
@@ -795,6 +916,11 @@ pub struct IngestCfg {
|
||||
pub chunking: ChunkingCfg,
|
||||
#[serde(default)]
|
||||
pub code: IngestCodeCfg,
|
||||
/// v5: shared OCR engine defaults (`[ingest.ocr]`). Merged down into
|
||||
/// `image.ocr` / `pdf.ocr` at load by [`Config::resolve_ocr`]. Empty
|
||||
/// (all `None`) by default — see [`SharedOcrEngineCfg`].
|
||||
#[serde(default)]
|
||||
pub ocr: SharedOcrEngineCfg,
|
||||
#[serde(default = "ImageCfg::defaults")]
|
||||
pub image: ImageCfg,
|
||||
#[serde(default = "PdfCfg::defaults")]
|
||||
@@ -809,6 +935,7 @@ impl Default for IngestCfg {
|
||||
watch_filesystem: false,
|
||||
chunking: ChunkingCfg::defaults(),
|
||||
code: IngestCodeCfg::default(),
|
||||
ocr: SharedOcrEngineCfg::default(),
|
||||
image: ImageCfg::defaults(),
|
||||
pdf: PdfCfg::defaults(),
|
||||
}
|
||||
@@ -916,6 +1043,7 @@ impl Config {
|
||||
watch_filesystem: false,
|
||||
chunking: ChunkingCfg::defaults(),
|
||||
code: IngestCodeCfg::default(),
|
||||
ocr: SharedOcrEngineCfg::default(),
|
||||
image: ImageCfg::defaults(),
|
||||
pdf: PdfCfg::defaults(),
|
||||
},
|
||||
@@ -1174,6 +1302,13 @@ impl Config {
|
||||
cause: format!("parse_failed: {e}"),
|
||||
})
|
||||
})?;
|
||||
// v5: merge `[ingest.ocr]` shared engine defaults down into the
|
||||
// per-medium concrete OCR blocks. Driven by toml-level presence
|
||||
// (a key explicitly written in `[ingest.image.ocr]` /
|
||||
// `[ingest.pdf.ocr]` wins over the shared block) — so we hand the
|
||||
// parsed `toml::Value` (which preserves presence) to the resolver.
|
||||
let parsed_value = toml::from_str::<toml::Value>(&parse_text).ok();
|
||||
cfg.resolve_ocr(parsed_value.as_ref());
|
||||
cfg.validate_sources().map_err(|cause| {
|
||||
anyhow::Error::new(ConfigInvalid {
|
||||
path: path.to_path_buf(),
|
||||
@@ -1261,6 +1396,192 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v5: merge the shared `[ingest.ocr]` engine block down into the
|
||||
/// per-medium concrete OCR structs (`ingest.image.ocr` /
|
||||
/// `ingest.pdf.ocr`). For each shared field that is `Some`, the value is
|
||||
/// written into a medium's block **only if** that medium did not set the
|
||||
/// field explicitly in its own table. Presence is read from `parsed`
|
||||
/// (the raw `toml::Value` of the loaded file) because the typed struct
|
||||
/// has already absorbed serde defaults and can no longer distinguish
|
||||
/// "user wrote the default" from "omitted".
|
||||
///
|
||||
/// `parsed = None` (programmatic config, no source text) → only the
|
||||
/// "explicitly set" guard is unavailable, but in that path
|
||||
/// `ingest.ocr` is whatever the caller built; the common case
|
||||
/// (`SharedOcrEngineCfg::default()` = all `None`) is a no-op. Idempotent.
|
||||
pub(crate) fn resolve_ocr(&mut self, parsed: Option<&toml::Value>) {
|
||||
// No shared overrides → nothing to merge (the overwhelming common
|
||||
// case: configs without an `[ingest.ocr]` block).
|
||||
if self.ingest.ocr == SharedOcrEngineCfg::default() {
|
||||
return;
|
||||
}
|
||||
let shared = self.ingest.ocr.clone();
|
||||
|
||||
// Helper: was `key` explicitly present in `ingest.<medium>.ocr`?
|
||||
let medium_has = |medium: &str, key: &str| -> bool {
|
||||
parsed
|
||||
.and_then(|v| v.get("ingest"))
|
||||
.and_then(|v| v.get(medium))
|
||||
.and_then(|v| v.get("ocr"))
|
||||
.and_then(|v| v.get(key))
|
||||
.is_some()
|
||||
};
|
||||
|
||||
// ── image OCR ──────────────────────────────────────────────────
|
||||
let img = &mut self.ingest.image.ocr;
|
||||
if let Some(v) = &shared.enabled {
|
||||
if !medium_has("image", "enabled") {
|
||||
img.enabled = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.engine {
|
||||
if !medium_has("image", "engine") {
|
||||
img.engine = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.model {
|
||||
if !medium_has("image", "model") {
|
||||
img.model = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.endpoint {
|
||||
if !medium_has("image", "endpoint") {
|
||||
img.endpoint = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.languages {
|
||||
if !medium_has("image", "languages") {
|
||||
img.languages = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.max_pixels {
|
||||
if !medium_has("image", "max_pixels") {
|
||||
img.max_pixels = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.request_timeout_secs {
|
||||
if !medium_has("image", "request_timeout_secs") {
|
||||
img.request_timeout_secs = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.det_model {
|
||||
if !medium_has("image", "det_model") {
|
||||
img.det_model = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.rec_model {
|
||||
if !medium_has("image", "rec_model") {
|
||||
img.rec_model = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.dict {
|
||||
if !medium_has("image", "dict") {
|
||||
img.dict = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.score_thresh {
|
||||
if !medium_has("image", "score_thresh") {
|
||||
img.score_thresh = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.unclip_ratio {
|
||||
if !medium_has("image", "unclip_ratio") {
|
||||
img.unclip_ratio = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.max_boxes {
|
||||
if !medium_has("image", "max_boxes") {
|
||||
img.max_boxes = *v;
|
||||
}
|
||||
}
|
||||
|
||||
// ── pdf OCR (same shared fields; the 4 pdf-unique fields are never
|
||||
// touched by the shared block) ─────────────────────────────────
|
||||
let pdf = &mut self.ingest.pdf.ocr;
|
||||
if let Some(v) = &shared.enabled {
|
||||
if !medium_has("pdf", "enabled") {
|
||||
pdf.enabled = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.engine {
|
||||
if !medium_has("pdf", "engine") {
|
||||
pdf.engine = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.model {
|
||||
if !medium_has("pdf", "model") {
|
||||
pdf.model = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.endpoint {
|
||||
if !medium_has("pdf", "endpoint") {
|
||||
pdf.endpoint = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.languages {
|
||||
if !medium_has("pdf", "languages") {
|
||||
pdf.languages = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.max_pixels {
|
||||
if !medium_has("pdf", "max_pixels") {
|
||||
pdf.max_pixels = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.request_timeout_secs {
|
||||
if !medium_has("pdf", "request_timeout_secs") {
|
||||
pdf.request_timeout_secs = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.det_model {
|
||||
if !medium_has("pdf", "det_model") {
|
||||
pdf.det_model = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.rec_model {
|
||||
if !medium_has("pdf", "rec_model") {
|
||||
pdf.rec_model = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.dict {
|
||||
if !medium_has("pdf", "dict") {
|
||||
pdf.dict = Some(v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.score_thresh {
|
||||
if !medium_has("pdf", "score_thresh") {
|
||||
pdf.score_thresh = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.unclip_ratio {
|
||||
if !medium_has("pdf", "unclip_ratio") {
|
||||
pdf.unclip_ratio = *v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &shared.max_boxes {
|
||||
if !medium_has("pdf", "max_boxes") {
|
||||
pdf.max_boxes = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective image OCR settings (`[ingest.ocr]` shared block merged with
|
||||
/// the `[ingest.image.ocr]` override block). After [`Config::resolve_ocr`]
|
||||
/// has run at load, the concrete `ingest.image.ocr` already holds the
|
||||
/// resolved values, so this is the canonical read handle for OCR
|
||||
/// consumers. (Decouples them from the merge mechanics — they ask the
|
||||
/// `Config` for "image OCR" rather than reaching into the struct path.)
|
||||
pub fn image_ocr(&self) -> &OcrCfg {
|
||||
&self.ingest.image.ocr
|
||||
}
|
||||
|
||||
/// Effective PDF OCR settings (shared `[ingest.ocr]` merged with the
|
||||
/// `[ingest.pdf.ocr]` override + 4 pdf-unique fields). See
|
||||
/// [`Config::image_ocr`].
|
||||
pub fn pdf_ocr(&self) -> &PdfOcrCfg {
|
||||
&self.ingest.pdf.ocr
|
||||
}
|
||||
|
||||
/// Apply `KEBAB_<SECTION>_<KEY>` env overrides. Unknown keys are ignored.
|
||||
///
|
||||
/// The mapping is an explicit grep-friendly whitelist — one match arm
|
||||
@@ -1456,64 +1777,93 @@ impl Config {
|
||||
),
|
||||
},
|
||||
|
||||
// image.ocr
|
||||
"KEBAB_IMAGE_OCR_ENABLED" => {
|
||||
self.ingest.image.ocr.enabled = parse_bool(v);
|
||||
// ── shared OCR engine (v5: KEBAB_OCR_*) ──────────────────
|
||||
// The 13 engine-level knobs collapsed from the v4
|
||||
// KEBAB_IMAGE_OCR_* / KEBAB_PDF_OCR_* duplicate sets. Each
|
||||
// arm writes BOTH the image and pdf concrete blocks (env is a
|
||||
// deliberate "set the OCR engine for the whole workspace"
|
||||
// override — applied after load-time `resolve_ocr`, with no
|
||||
// toml presence to consult). Per-medium `enabled` stays
|
||||
// separately addressable below (KEBAB_IMAGE_OCR_ENABLED /
|
||||
// KEBAB_PDF_OCR_ENABLED) so a user can turn image OCR on
|
||||
// without forcing pdf OCR on.
|
||||
"KEBAB_OCR_ENGINE" => {
|
||||
self.ingest.image.ocr.engine = v.clone();
|
||||
self.ingest.pdf.ocr.engine = v.clone();
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_ENGINE" => self.ingest.image.ocr.engine = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_MODEL" => self.ingest.image.ocr.model = v.clone(),
|
||||
"KEBAB_IMAGE_OCR_ENDPOINT" => {
|
||||
// Empty env value is treated the same as "fall back
|
||||
// to models.llm.endpoint" — i.e. set None.
|
||||
self.ingest.image.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
"KEBAB_OCR_MODEL" => {
|
||||
self.ingest.image.ocr.model = v.clone();
|
||||
self.ingest.pdf.ocr.model = v.clone();
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_LANGUAGES" => {
|
||||
"KEBAB_OCR_ENDPOINT" => {
|
||||
// Empty env value → None (= fall back to models.llm.endpoint).
|
||||
let e = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.image.ocr.endpoint = e.clone();
|
||||
self.ingest.pdf.ocr.endpoint = e;
|
||||
}
|
||||
"KEBAB_OCR_LANGUAGES" => {
|
||||
// Comma-separated list, e.g. "eng,kor".
|
||||
self.ingest.image.ocr.languages = v
|
||||
let langs: Vec<String> = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
self.ingest.image.ocr.languages = langs.clone();
|
||||
self.ingest.pdf.ocr.languages = langs;
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_MAX_PIXELS" => {
|
||||
"KEBAB_OCR_MAX_PIXELS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.ingest.image.ocr.max_pixels = n;
|
||||
self.ingest.pdf.ocr.max_pixels = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS" => {
|
||||
"KEBAB_OCR_REQUEST_TIMEOUT_SECS" => {
|
||||
if let Ok(n) = v.parse::<u64>() {
|
||||
self.ingest.image.ocr.request_timeout_secs = n;
|
||||
self.ingest.pdf.ocr.request_timeout_secs = n;
|
||||
}
|
||||
}
|
||||
// paddle-onnx engine overrides (v0.27.0). Empty string → None
|
||||
// paddle-onnx engine overrides. Empty string → None
|
||||
// (fall back to bundled / KEBAB_IMAGE_OCR_MODEL_DIR).
|
||||
"KEBAB_IMAGE_OCR_DET_MODEL" => {
|
||||
self.ingest.image.ocr.det_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
"KEBAB_OCR_DET_MODEL" => {
|
||||
let m = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.image.ocr.det_model = m.clone();
|
||||
self.ingest.pdf.ocr.det_model = m;
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_REC_MODEL" => {
|
||||
self.ingest.image.ocr.rec_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
"KEBAB_OCR_REC_MODEL" => {
|
||||
let m = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.image.ocr.rec_model = m.clone();
|
||||
self.ingest.pdf.ocr.rec_model = m;
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_DICT" => {
|
||||
self.ingest.image.ocr.dict = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
"KEBAB_OCR_DICT" => {
|
||||
let m = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
self.ingest.image.ocr.dict = m.clone();
|
||||
self.ingest.pdf.ocr.dict = m;
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_SCORE_THRESH" => {
|
||||
"KEBAB_OCR_SCORE_THRESH" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.image.ocr.score_thresh = f;
|
||||
self.ingest.pdf.ocr.score_thresh = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_UNCLIP_RATIO" => {
|
||||
"KEBAB_OCR_UNCLIP_RATIO" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.image.ocr.unclip_ratio = f;
|
||||
self.ingest.pdf.ocr.unclip_ratio = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_IMAGE_OCR_MAX_BOXES" => {
|
||||
"KEBAB_OCR_MAX_BOXES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.ingest.image.ocr.max_boxes = n;
|
||||
self.ingest.pdf.ocr.max_boxes = n;
|
||||
}
|
||||
}
|
||||
|
||||
// image OCR enabled toggle (kept per-medium addressable).
|
||||
"KEBAB_IMAGE_OCR_ENABLED" => {
|
||||
self.ingest.image.ocr.enabled = parse_bool(v);
|
||||
}
|
||||
|
||||
// image.caption (P6-3)
|
||||
"KEBAB_IMAGE_CAPTION_ENABLED" => {
|
||||
self.ingest.image.caption.enabled = parse_bool(v);
|
||||
@@ -1527,31 +1877,14 @@ impl Config {
|
||||
self.ingest.image.caption.prompt_template_version = v.clone();
|
||||
}
|
||||
|
||||
// pdf.ocr (v0.20.0 sub-item 1)
|
||||
// ── pdf-only OCR knobs (v5: the 4 fields with no image
|
||||
// counterpart + the per-medium enabled toggle) ──────────
|
||||
// The engine-level pdf knobs (engine/model/endpoint/languages/
|
||||
// max_pixels/request_timeout_secs + the 6 paddle fields) moved
|
||||
// to the shared KEBAB_OCR_* set above. These five have no
|
||||
// image analogue, so they stay pdf-specific.
|
||||
"KEBAB_PDF_OCR_ENABLED" => self.ingest.pdf.ocr.enabled = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ALWAYS_ON" => self.ingest.pdf.ocr.always_on = parse_bool(v),
|
||||
"KEBAB_PDF_OCR_ENGINE" => self.ingest.pdf.ocr.engine = v.clone(),
|
||||
"KEBAB_PDF_OCR_MODEL" => self.ingest.pdf.ocr.model = v.clone(),
|
||||
"KEBAB_PDF_OCR_ENDPOINT" => {
|
||||
self.ingest.pdf.ocr.endpoint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_LANGUAGES" => {
|
||||
self.ingest.pdf.ocr.languages = v
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
"KEBAB_PDF_OCR_MAX_PIXELS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.ingest.pdf.ocr.max_pixels = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_REQUEST_TIMEOUT_SECS" => {
|
||||
if let Ok(n) = v.parse::<u64>() {
|
||||
self.ingest.pdf.ocr.request_timeout_secs = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_VALID_RATIO_THRESHOLD" => {
|
||||
if let Ok(n) = v.parse::<f32>() {
|
||||
self.ingest.pdf.ocr.valid_ratio_threshold = n.clamp(0.0, 1.0);
|
||||
@@ -1565,34 +1898,6 @@ impl Config {
|
||||
"KEBAB_PDF_OCR_LANG_HINT" => {
|
||||
self.ingest.pdf.ocr.lang_hint = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
// pdf paddle-onnx engine overrides (v3). image.ocr paddle 패턴 복제.
|
||||
// Empty string → None (fall back to bundled / KEBAB_IMAGE_OCR_MODEL_DIR).
|
||||
"KEBAB_PDF_OCR_DET_MODEL" => {
|
||||
self.ingest.pdf.ocr.det_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_REC_MODEL" => {
|
||||
self.ingest.pdf.ocr.rec_model =
|
||||
if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_DICT" => {
|
||||
self.ingest.pdf.ocr.dict = if v.is_empty() { None } else { Some(v.clone()) };
|
||||
}
|
||||
"KEBAB_PDF_OCR_SCORE_THRESH" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.pdf.ocr.score_thresh = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_UNCLIP_RATIO" => {
|
||||
if let Ok(f) = v.parse::<f32>() {
|
||||
self.ingest.pdf.ocr.unclip_ratio = f;
|
||||
}
|
||||
}
|
||||
"KEBAB_PDF_OCR_MAX_BOXES" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
self.ingest.pdf.ocr.max_boxes = n;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown KEBAB_* keys are silently ignored — see
|
||||
// `env_unknown_key_is_ignored` test.
|
||||
@@ -1874,24 +2179,31 @@ max_pixels = 1600
|
||||
env.insert("KEBAB_CHUNKING_TARGET_TOKENS".into(), "640".into());
|
||||
env.insert("KEBAB_INDEXING_MAX_PARALLEL_EXTRACTORS".into(), "6".into());
|
||||
env.insert("KEBAB_IMAGE_OCR_ENABLED".into(), "true".into());
|
||||
env.insert("KEBAB_PDF_OCR_ENGINE".into(), "paddle-onnx".into());
|
||||
// v5: shared engine knob — sets BOTH image and pdf OCR engine.
|
||||
env.insert("KEBAB_OCR_ENGINE".into(), "paddle-onnx".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.chunking.target_tokens, 640);
|
||||
assert_eq!(c.ingest.max_parallel_extractors, 6);
|
||||
assert!(c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.image.ocr.engine, "paddle-onnx");
|
||||
assert_eq!(c.ingest.pdf.ocr.engine, "paddle-onnx");
|
||||
}
|
||||
|
||||
/// v5: the paddle engine overrides moved to the shared `KEBAB_OCR_*` set
|
||||
/// and apply to BOTH mediums in one shot.
|
||||
#[test]
|
||||
fn env_pdf_paddle_symmetric_overrides() {
|
||||
fn env_shared_ocr_paddle_overrides_both_mediums() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_PDF_OCR_DET_MODEL".into(), "/d.onnx".into());
|
||||
env.insert("KEBAB_PDF_OCR_SCORE_THRESH".into(), "0.4".into());
|
||||
env.insert("KEBAB_PDF_OCR_MAX_BOXES".into(), "500".into());
|
||||
env.insert("KEBAB_OCR_DET_MODEL".into(), "/d.onnx".into());
|
||||
env.insert("KEBAB_OCR_SCORE_THRESH".into(), "0.4".into());
|
||||
env.insert("KEBAB_OCR_MAX_BOXES".into(), "500".into());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.pdf.ocr.det_model.as_deref(), Some("/d.onnx"));
|
||||
assert!((c.ingest.pdf.ocr.score_thresh - 0.4).abs() < 1e-6);
|
||||
assert_eq!(c.ingest.pdf.ocr.max_boxes, 500);
|
||||
assert_eq!(c.ingest.image.ocr.det_model.as_deref(), Some("/d.onnx"));
|
||||
assert!((c.ingest.image.ocr.score_thresh - 0.4).abs() < 1e-6);
|
||||
assert_eq!(c.ingest.image.ocr.max_boxes, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1989,12 +2301,14 @@ max_pixels = 1600
|
||||
#[test]
|
||||
fn env_overrides_image_ocr_request_timeout_secs() {
|
||||
let mut env = HashMap::new();
|
||||
// v5: shared KEBAB_OCR_REQUEST_TIMEOUT_SECS sets both mediums.
|
||||
env.insert(
|
||||
"KEBAB_IMAGE_OCR_REQUEST_TIMEOUT_SECS".to_string(),
|
||||
"KEBAB_OCR_REQUEST_TIMEOUT_SECS".to_string(),
|
||||
"900".to_string(),
|
||||
);
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.image.ocr.request_timeout_secs, 900);
|
||||
assert_eq!(c.ingest.pdf.ocr.request_timeout_secs, 900);
|
||||
}
|
||||
|
||||
/// post-v0.17.1 dogfood: a config file written before the OCR
|
||||
@@ -2136,25 +2450,24 @@ max_pixels = 1600
|
||||
);
|
||||
}
|
||||
|
||||
/// v5: the engine-level OCR knobs come from the shared `KEBAB_OCR_*` set
|
||||
/// (sets image AND pdf); only `enabled` stays per-medium.
|
||||
#[test]
|
||||
fn image_ocr_env_overrides() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_IMAGE_OCR_ENABLED".to_string(), "true".to_string());
|
||||
env.insert("KEBAB_OCR_MODEL".to_string(), "gemma4:31b".to_string());
|
||||
env.insert(
|
||||
"KEBAB_IMAGE_OCR_MODEL".to_string(),
|
||||
"gemma4:31b".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"KEBAB_IMAGE_OCR_ENDPOINT".to_string(),
|
||||
"KEBAB_OCR_ENDPOINT".to_string(),
|
||||
"http://192.168.0.47:11434".to_string(),
|
||||
);
|
||||
// Empty env value should map to None (= fall back to llm.endpoint).
|
||||
// We exercise that branch in a separate test.
|
||||
env.insert(
|
||||
"KEBAB_IMAGE_OCR_LANGUAGES".to_string(),
|
||||
"KEBAB_OCR_LANGUAGES".to_string(),
|
||||
"eng, kor, jpn".to_string(),
|
||||
);
|
||||
env.insert("KEBAB_IMAGE_OCR_MAX_PIXELS".to_string(), "2048".to_string());
|
||||
env.insert("KEBAB_OCR_MAX_PIXELS".to_string(), "2048".to_string());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert!(c.ingest.image.ocr.enabled);
|
||||
assert_eq!(c.ingest.image.ocr.model, "gemma4:31b");
|
||||
@@ -2164,6 +2477,9 @@ max_pixels = 1600
|
||||
);
|
||||
assert_eq!(c.ingest.image.ocr.languages, vec!["eng", "kor", "jpn"]);
|
||||
assert_eq!(c.ingest.image.ocr.max_pixels, 2048);
|
||||
// shared knob also reached the pdf block.
|
||||
assert_eq!(c.ingest.pdf.ocr.model, "gemma4:31b");
|
||||
assert_eq!(c.ingest.pdf.ocr.max_pixels, 2048);
|
||||
}
|
||||
|
||||
/// Pre-P6 config files don't have an `[image]` section. The
|
||||
@@ -2198,15 +2514,17 @@ max_pixels = 1600
|
||||
assert_eq!(c.ingest.image.caption.prompt_template_version, "caption-v2");
|
||||
}
|
||||
|
||||
/// `KEBAB_IMAGE_OCR_ENDPOINT=""` (empty value) should map to `None`
|
||||
/// v5: `KEBAB_OCR_ENDPOINT=""` (empty value) should map to `None`
|
||||
/// rather than to `Some("")` so the fallback to `models.llm.endpoint`
|
||||
/// kicks in. Covers the env-equivalent of a missing TOML key.
|
||||
/// kicks in (for both mediums). Covers the env-equivalent of a missing
|
||||
/// TOML key.
|
||||
#[test]
|
||||
fn image_ocr_endpoint_empty_env_value_is_none() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEBAB_IMAGE_OCR_ENDPOINT".to_string(), String::new());
|
||||
env.insert("KEBAB_OCR_ENDPOINT".to_string(), String::new());
|
||||
let c = Config::defaults().apply_env(&env);
|
||||
assert_eq!(c.ingest.image.ocr.endpoint, None);
|
||||
assert_eq!(c.ingest.pdf.ocr.endpoint, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,7 @@ use toml_edit::{DocumentMut, Item};
|
||||
|
||||
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
||||
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 4;
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 5;
|
||||
|
||||
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
@@ -80,6 +80,7 @@ fn section_comment(path: &str) -> Option<&'static str> {
|
||||
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
||||
"ui" => "# TUI 팔레트·role 스타일.",
|
||||
"ingest" => "# 모든 형식 ingest 우산: 병렬도 + chunking/code/image/pdf.",
|
||||
"ingest.ocr" => "# 공유 OCR 엔진 설정(image/pdf 공통). 각 미디어 블록이 override.",
|
||||
"ingest.chunking" => "# 청크 크기·오버랩·heading 존중(전 형식 공통).",
|
||||
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
||||
"ingest.image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||
@@ -142,6 +143,16 @@ pub fn annotated_default_document() -> DocumentMut {
|
||||
let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize");
|
||||
let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit");
|
||||
|
||||
// v5: 직렬화된 defaults 는 `[ingest.image.ocr]` 에 12개 엔진 키를 그대로
|
||||
// 담고 `[ingest.ocr]` 은 비어 있다(`SharedOcrEngineCfg::default()` = 전부
|
||||
// None). 참조 문서를 v5 canonical 형상으로 맞추기 위해 같은 통합을 적용한다
|
||||
// — 그러지 않으면 reconcile 이 마이그레이션으로 끌어올린 키를 image 블록에
|
||||
// 다시 추가해 통합을 되돌린다(그리고 image 의 effective engine 을 default 로
|
||||
// 덮어써 동작을 바꾼다). pdf 블록은 그대로 둔다(자기 default 가 image 와
|
||||
// 달라 override 로 유지).
|
||||
let mut discard = Vec::new();
|
||||
step_4_to_5(&mut doc, &mut discard);
|
||||
|
||||
// 헤더: 첫 최상위 항목의 prefix 로.
|
||||
if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() {
|
||||
first_key.leaf_decor_mut().set_prefix(format!("{HEADER}\n"));
|
||||
@@ -413,6 +424,87 @@ pub fn step_3_to_4(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// v5: `[ingest.image.ocr]` 의 12개 **엔진** 키를 새 공유 블록 `[ingest.ocr]`
|
||||
/// 로 끌어올린다(`enabled` 은 미디어별 토글이라 제외 — 끌어올리면 공유 블록이
|
||||
/// pdf 의 `enabled` 까지 켜버려 동작이 바뀐다). image 는 unique 필드가 없으므로
|
||||
/// 공유 블록의 canonical source 로 삼는다. `[ingest.pdf.ocr]` 은 손대지 않는다
|
||||
/// — reconcile 이 pdf 의 모든 키를 default 로 채워 명시 상태가 되므로(아래
|
||||
/// `resolve_ocr` 의 "미디어가 명시한 키는 공유 overlay 가 덮지 않음" 규칙에 의해)
|
||||
/// pdf 의 effective 값은 마이그레이션 전후 불변. 멱등: 키가 이미 옮겨졌으면 no-op.
|
||||
///
|
||||
/// 결과적으로 image 의 effective OCR 엔진 설정은 `[ingest.ocr]` 에서, pdf 는
|
||||
/// 자신의 (reconcile 로 완전 채워진) 블록에서 그대로 resolve 되어 양쪽 모두
|
||||
/// 마이그레이션 전 값을 유지한다 — `ingest_config_signature` 바이트도 불변.
|
||||
const SHARED_OCR_ENGINE_KEYS: [&str; 12] = [
|
||||
"engine",
|
||||
"model",
|
||||
"endpoint",
|
||||
"languages",
|
||||
"max_pixels",
|
||||
"request_timeout_secs",
|
||||
"det_model",
|
||||
"rec_model",
|
||||
"dict",
|
||||
"score_thresh",
|
||||
"unclip_ratio",
|
||||
"max_boxes",
|
||||
];
|
||||
|
||||
pub fn step_4_to_5(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
// image OCR 블록이 없으면 끌어올릴 게 없음(reconcile 이 빈 `[ingest.ocr]`
|
||||
// 를 추가). 멱등 진입점.
|
||||
let img_present = doc
|
||||
.get("ingest")
|
||||
.and_then(|i| i.get("image"))
|
||||
.and_then(|i| i.get("ocr"))
|
||||
.and_then(Item::as_table)
|
||||
.is_some();
|
||||
if !img_present {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lifted_any = false;
|
||||
for key in SHARED_OCR_ENGINE_KEYS {
|
||||
// image.ocr 에 key 가 있고, ingest.ocr 에 아직 없으면 통째(decor 포함) 이동.
|
||||
let has_in_image = doc
|
||||
.get("ingest")
|
||||
.and_then(|i| i.get("image"))
|
||||
.and_then(|i| i.get("ocr"))
|
||||
.and_then(Item::as_table)
|
||||
.is_some_and(|t| t.contains_key(key));
|
||||
if !has_in_image {
|
||||
continue;
|
||||
}
|
||||
let already_in_shared = doc
|
||||
.get("ingest")
|
||||
.and_then(|i| i.get("ocr"))
|
||||
.and_then(Item::as_table)
|
||||
.is_some_and(|t| t.contains_key(key));
|
||||
if already_in_shared {
|
||||
// 공유 블록에 이미 있으면 image 쪽 중복 키는 그냥 제거(공유가 우선).
|
||||
if let Some(img) = doc["ingest"]["image"]["ocr"].as_table_mut() {
|
||||
img.remove(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
move_table(
|
||||
doc,
|
||||
&["ingest", "image", "ocr", key],
|
||||
&["ingest", "ocr", key],
|
||||
changes,
|
||||
);
|
||||
lifted_any = true;
|
||||
}
|
||||
|
||||
if lifted_any {
|
||||
changes.push(MigrationChange {
|
||||
kind: ChangeKind::AddedSection,
|
||||
path: "ingest.ocr".to_string(),
|
||||
detail: "OCR 엔진 키를 공유 [ingest.ocr] 로 통합(image/pdf 중복 제거)".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
||||
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
||||
if from < 2 {
|
||||
@@ -424,6 +516,9 @@ fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange
|
||||
if from < 4 {
|
||||
step_3_to_4(doc, changes);
|
||||
}
|
||||
if from < 5 {
|
||||
step_4_to_5(doc, changes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
||||
@@ -477,6 +572,21 @@ pub fn migrate_document(text: &str) -> MigrationOutcome {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// v5: parse a config text and run the shared-OCR resolution the way
|
||||
/// `Config::from_file` does (overlay `[ingest.ocr]` down into the
|
||||
/// per-medium concrete blocks), then clear the now-applied shared block.
|
||||
/// The result is the canonical *effective* config — comparable against
|
||||
/// `Config::defaults()` regardless of whether the engine knobs live in
|
||||
/// the shared block (annotated default doc) or the per-medium blocks
|
||||
/// (in-memory `defaults()`).
|
||||
fn parse_effective(text: &str) -> crate::Config {
|
||||
let parsed = toml::from_str::<toml::Value>(text).ok();
|
||||
let mut cfg: crate::Config = toml::from_str(text).expect("parse config");
|
||||
cfg.resolve_ocr(parsed.as_ref());
|
||||
cfg.ingest.ocr = crate::SharedOcrEngineCfg::default();
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_default_has_per_key_comments() {
|
||||
let text = annotated_default_document().to_string();
|
||||
@@ -487,9 +597,9 @@ mod tests {
|
||||
text.contains("paddle-onnx 는 번들 모델"),
|
||||
"ocr.model 주석 누락:\n{text}"
|
||||
);
|
||||
// 주석 추가가 파싱을 깨지 않는다.
|
||||
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
|
||||
assert_eq!(back, crate::Config::defaults());
|
||||
// 주석 추가가 파싱을 깨지 않고, v5 OCR resolution 후 effective 값이
|
||||
// defaults 와 동일.
|
||||
assert_eq!(parse_effective(&text), crate::Config::defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -498,10 +608,11 @@ mod tests {
|
||||
let text = doc.to_string();
|
||||
// v3: 미디어 형식 섹션이 전부 `[ingest.*]` 하위로 통합됐다. IngestCfg
|
||||
// 는 스칼라(병렬도) 필드가 있어 bare `[ingest]` + 하위 테이블이 함께
|
||||
// 직렬화된다.
|
||||
// 직렬화된다. v5: 공유 `[ingest.ocr]` 엔진 블록이 추가됐다.
|
||||
for section in [
|
||||
"[workspace]",
|
||||
"[ingest]",
|
||||
"[ingest.ocr]",
|
||||
"[ingest.chunking]",
|
||||
"[ingest.code]",
|
||||
"[ingest.image.ocr]",
|
||||
@@ -512,8 +623,8 @@ mod tests {
|
||||
assert!(text.contains(section), "missing {section}:\n{text}");
|
||||
}
|
||||
assert!(text.contains("# "), "no comments attached");
|
||||
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
|
||||
assert_eq!(back, crate::Config::defaults());
|
||||
// v5: effective 값(공유 OCR resolution 후)이 defaults 와 동일.
|
||||
assert_eq!(parse_effective(&text), crate::Config::defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -739,7 +850,7 @@ root = \"/my/notes\"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_v3_to_v4_adds_sources_and_is_idempotent() {
|
||||
fn migrate_document_v3_to_current_adds_sources_and_is_idempotent() {
|
||||
let v3 = "\
|
||||
schema_version = 3
|
||||
|
||||
@@ -749,10 +860,10 @@ exclude = []
|
||||
";
|
||||
let outcome = migrate_document(v3);
|
||||
assert_eq!(outcome.from_schema_version, 3);
|
||||
assert_eq!(outcome.to_schema_version, 4);
|
||||
assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION);
|
||||
assert!(outcome.changed());
|
||||
assert!(outcome.new_text.contains("[[workspace.sources]]"));
|
||||
assert_eq!(read_schema_version(&outcome.new_text), 4);
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
let again = migrate_document(&outcome.new_text);
|
||||
assert!(!again.changed(), "not idempotent: {:?}", again.changes);
|
||||
assert_eq!(again.new_text, outcome.new_text);
|
||||
@@ -765,4 +876,98 @@ exclude = []
|
||||
assert_eq!(outcome.from_schema_version, 1);
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/// v4 → v5 무손실 라운드트립: `[ingest.image.ocr]` / `[ingest.pdf.ocr]` 이
|
||||
/// 채워진 v4 config 을 마이그레이션 → from_file 로 로드(공유 OCR resolution
|
||||
/// 포함) → image/pdf OCR 의 effective 값이 마이그레이션 전과 정확히 동일해야
|
||||
/// 한다. image 의 비-default engine(paddle-onnx) 이 공유 블록으로 끌어올려진
|
||||
/// 뒤에도 보존되는지(이전 버그) + pdf 의 고유 값(qwen 모델·2048px)이 공유
|
||||
/// overlay 에 오염되지 않는지를 함께 검증한다.
|
||||
#[test]
|
||||
fn migrate_v4_to_v5_preserves_effective_ocr() {
|
||||
let v4 = "\
|
||||
schema_version = 4
|
||||
|
||||
[workspace]
|
||||
root = \"/my/notes\"
|
||||
exclude = []
|
||||
|
||||
[[workspace.sources]]
|
||||
id = \"default\"
|
||||
root = \"/my/notes\"
|
||||
|
||||
[ingest.image.ocr]
|
||||
enabled = true
|
||||
engine = \"paddle-onnx\"
|
||||
model = \"gemma4:e4b\"
|
||||
languages = [\"eng\", \"kor\"]
|
||||
max_pixels = 1280
|
||||
request_timeout_secs = 450
|
||||
det_model = \"/custom/det.onnx\"
|
||||
score_thresh = 0.45
|
||||
|
||||
[ingest.pdf.ocr]
|
||||
enabled = true
|
||||
always_on = false
|
||||
engine = \"ollama-vision\"
|
||||
model = \"qwen2.5vl:7b\"
|
||||
languages = [\"eng\", \"kor\"]
|
||||
max_pixels = 2048
|
||||
request_timeout_secs = 240
|
||||
valid_ratio_threshold = 0.6
|
||||
min_char_count = 25
|
||||
lang_hint = \"kor\"
|
||||
";
|
||||
// pre-migration effective values, loaded the v4 way (no shared block).
|
||||
let dir = std::env::temp_dir().join(format!("kebab_v5_rt_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p4 = dir.join("v4.toml");
|
||||
std::fs::write(&p4, v4).unwrap();
|
||||
let before = crate::Config::from_file(&p4).expect("load v4");
|
||||
|
||||
// migrate → load the v5 text via from_file (runs resolve_ocr).
|
||||
let outcome = migrate_document(v4);
|
||||
assert_eq!(outcome.from_schema_version, 4);
|
||||
assert_eq!(outcome.to_schema_version, 5);
|
||||
assert!(outcome.changed());
|
||||
assert!(
|
||||
outcome.new_text.contains("[ingest.ocr]"),
|
||||
"shared block missing:\n{}",
|
||||
outcome.new_text
|
||||
);
|
||||
let p5 = dir.join("v5.toml");
|
||||
std::fs::write(&p5, &outcome.new_text).unwrap();
|
||||
let after = crate::Config::from_file(&p5).expect("load v5");
|
||||
|
||||
// image OCR effective 값 전부 보존(특히 비-default engine paddle-onnx).
|
||||
assert_eq!(after.image_ocr().enabled, before.image_ocr().enabled);
|
||||
assert_eq!(after.image_ocr().engine, "paddle-onnx");
|
||||
assert_eq!(after.image_ocr().engine, before.image_ocr().engine);
|
||||
assert_eq!(after.image_ocr().model, before.image_ocr().model);
|
||||
assert_eq!(after.image_ocr().languages, before.image_ocr().languages);
|
||||
assert_eq!(after.image_ocr().max_pixels, 1280);
|
||||
assert_eq!(after.image_ocr().max_pixels, before.image_ocr().max_pixels);
|
||||
assert_eq!(
|
||||
after.image_ocr().request_timeout_secs,
|
||||
before.image_ocr().request_timeout_secs
|
||||
);
|
||||
assert_eq!(
|
||||
after.image_ocr().det_model.as_deref(),
|
||||
Some("/custom/det.onnx")
|
||||
);
|
||||
assert_eq!(after.image_ocr().det_model, before.image_ocr().det_model);
|
||||
assert!((after.image_ocr().score_thresh - 0.45).abs() < 1e-6);
|
||||
assert_eq!(after.image_ocr(), before.image_ocr());
|
||||
|
||||
// pdf OCR effective 값 전부 보존(공유 overlay 가 image 값으로 오염 X).
|
||||
assert_eq!(after.pdf_ocr().engine, "ollama-vision");
|
||||
assert_eq!(after.pdf_ocr().model, "qwen2.5vl:7b");
|
||||
assert_eq!(after.pdf_ocr().max_pixels, 2048);
|
||||
assert_eq!(after.pdf_ocr(), before.pdf_ocr());
|
||||
|
||||
// 멱등.
|
||||
let again = migrate_document(&outcome.new_text);
|
||||
assert!(!again.changed(), "v5 재실행 변경: {:?}", again.changes);
|
||||
assert_eq!(again.new_text, outcome.new_text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@ const USER_V2: &str = include_str!("fixtures/user_v2_config.toml");
|
||||
fn user_v2_migrates_losslessly() {
|
||||
let out = migrate_document(USER_V2);
|
||||
assert_eq!(out.from_schema_version, 2);
|
||||
// v2 → CURRENT(=4): v3 의 [ingest.*] relocation 에 더해 v4 의
|
||||
// [[workspace.sources]] default source 미러링까지 적용된다.
|
||||
assert_eq!(out.to_schema_version, 4);
|
||||
// v2 → CURRENT(=5): v3 의 [ingest.*] relocation, v4 의
|
||||
// [[workspace.sources]] default source 미러링, v5 의 공유 [ingest.ocr]
|
||||
// 통합까지 적용된다.
|
||||
assert_eq!(
|
||||
out.to_schema_version,
|
||||
kebab_config::migrate::CURRENT_SCHEMA_VERSION
|
||||
);
|
||||
let t = &out.new_text;
|
||||
|
||||
// 사용자 값 보존.
|
||||
@@ -36,15 +40,23 @@ fn user_v2_migrates_losslessly() {
|
||||
assert!(!t.contains("\n[image.ocr]"));
|
||||
assert!(!t.contains("\n[indexing]"));
|
||||
|
||||
// v3 Config 로 parse + 값 동일.
|
||||
let cfg: kebab_config::Config = toml::from_str(t).expect("v3 parse");
|
||||
assert!(cfg.ingest.image.ocr.enabled);
|
||||
assert_eq!(cfg.ingest.image.ocr.engine, "paddle-onnx");
|
||||
// v5: 공유 [ingest.ocr] 통합 후 image 엔진 키는 공유 블록에 산다.
|
||||
assert!(t.contains("[ingest.ocr]"), "공유 OCR 블록 누락:\n{t}");
|
||||
|
||||
// effective 값은 from_file(공유 OCR resolution 포함)로 검증한다 —
|
||||
// image 의 engine=paddle-onnx 가 공유 블록으로 끌어올려진 뒤에도 보존.
|
||||
let dir = std::env::temp_dir().join(format!("kebab_mv3_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("config.toml");
|
||||
std::fs::write(&p, t).unwrap();
|
||||
let cfg = kebab_config::Config::from_file(&p).expect("v5 from_file");
|
||||
assert!(cfg.image_ocr().enabled);
|
||||
assert_eq!(cfg.image_ocr().engine, "paddle-onnx");
|
||||
assert_eq!(cfg.models.embedding.model, "snowflake-arctic-embed2");
|
||||
assert_eq!(cfg.models.llm.endpoint, "http://192.168.0.2:11943");
|
||||
// pdf paddle 값 보존(v2 비대칭 → pdf 대칭 키로 복사). user 의 pdf.ocr 는
|
||||
// engine=paddle-onnx 이고 자체 det_model 없으므로 번들(None) 유지.
|
||||
assert_eq!(cfg.ingest.pdf.ocr.engine, "paddle-onnx");
|
||||
assert_eq!(cfg.pdf_ocr().engine, "paddle-onnx");
|
||||
|
||||
// 멱등.
|
||||
let again = migrate_document(t);
|
||||
|
||||
@@ -63,15 +63,14 @@ fn pdf_ocr_defaults_off_with_qwen_3b() {
|
||||
assert_eq!(cfg.ingest.pdf.ocr.lang_hint.as_deref(), Some("kor"));
|
||||
}
|
||||
|
||||
// Test 3: env var override — 4 keys 의 typical override case.
|
||||
// Test 3: env var override — pdf-only keys + shared engine knob.
|
||||
// v5: `model` moved to the shared `KEBAB_OCR_MODEL` (sets both mediums);
|
||||
// `enabled`/`always_on`/`valid_ratio_threshold` stay pdf-specific.
|
||||
#[test]
|
||||
fn pdf_ocr_env_overrides() {
|
||||
let mut env: HashMap<String, String> = HashMap::new();
|
||||
env.insert("KEBAB_PDF_OCR_ENABLED".to_string(), "true".to_string());
|
||||
env.insert(
|
||||
"KEBAB_PDF_OCR_MODEL".to_string(),
|
||||
"qwen2.5vl:7b".to_string(),
|
||||
);
|
||||
env.insert("KEBAB_OCR_MODEL".to_string(), "qwen2.5vl:7b".to_string());
|
||||
env.insert("KEBAB_PDF_OCR_ALWAYS_ON".to_string(), "true".to_string());
|
||||
env.insert(
|
||||
"KEBAB_PDF_OCR_VALID_RATIO_THRESHOLD".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user