Files
kebab/tasks/HOTFIXES.md
altair823 9c644245fb review(p6-3): 회차 1 지적 반영
- 새 모듈 `crates/kebab-parse-image/src/image_prep.rs` — OCR + caption
  + 향후 PDF/video 가 공유할 단일 다운스케일 헬퍼 (`downscale_to_png`)
  추출. 기존 ocr.rs / caption.rs 의 거의 동일 알고리즘 두 벌을 한
  곳으로 통합. 1px 후행 클램프 / PNG passthrough hot path / 에러
  메시지 패턴이 한 곳에서 관리됨.
- src/ocr.rs: `downscale_to_long_edge` 제거 → `image_prep::downscale_to_png`
  호출. `image::ImageReader / ImageFormat / Cursor` import 도 정리.
- src/caption.rs:
  • `caption_image` / `apply_caption` 의 disabled 처리 비대칭 해소.
    `caption_image` 는 raw 연산 (gate 없음), `apply_caption` 만
    `cfg.image.caption.enabled` 게이트 검사. 호출자가 같은 함수에서
    같은 의미를 얻음.
  • `apply_caption` 의 caption.model / model_version `String::clone`
    2회 → 0회. caption move 전에 ProvenanceEvent.note 를 먼저 빌드.
  • 다운스케일 로직 통째로 image_prep 위임.
  • `MIN_CAPTION_LONG_EDGE` / `MAX_CAPTION_LONG_EDGE` 를 `pub const`
    로 노출 (P6-2 의 `MAX_DECODE_DIM` 가시성 컨벤션과 일관).
- tests/caption.rs:
  • `caption_image_errors_when_feature_disabled` 를
    `caption_image_runs_regardless_of_enabled_flag` 로 교체 — 새
    책임 분리 의미 검증.
  • `caption_image_clamps_oversized_max_pixels` 가 literal 1536 대신
    `kebab_parse_image::caption::MAX_CAPTION_LONG_EDGE` 상수 참조.
- tasks/HOTFIXES.md: `model_version` 형태 deviation 한 단락 추가
  (spec literal `provider` → `<provider>/<prompt_template_version>`
  확장 + 사유).

cargo test -p kebab-parse-image — 42 pass + 2 ignored
  (13 unit + 12 P6-1 + 8 P6-2 + 9 P6-3).
cargo clippy --workspace --all-targets -- -D warnings — pass.
2026-05-02 06:11:56 +00:00

12 KiB

title, date
title date
Post-merge hotfixes log 2026-05-01

Post-merge hotfixes log

Bugs discovered AFTER a phase task was merged, and the small follow-up PRs that close them. Each entry: what broke, how it surfaced, what the fix touched, and which task spec it amends.

The original task specs in tasks/p<N>/p<N>-<M>-*.md stay frozen as the historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history.

2026-05-02 — P6-3 caption: GenerateRequest.images + cargo feature dropped

Discovered: P6-3 implementation start.

Symptom 1: tasks/p6/p6-3-caption-adapter.md § Public surface declares caption_image(llm: &dyn kebab_core::LanguageModel, ...), but the frozen LanguageModel trait + GenerateRequest from p4-1 carry no vision input. The spec's behavior contract ("the adapter is responsible for rendering the prompt to wire") implicitly relied on a trait extension that p4-1 never specced.

Symptom 2: Spec § Definition of Done asks for cargo check -p kebab-parse-image --features caption — i.e. a cargo feature gate. The captioning module's only extra deps are base64 + image + the kebab-llm trait, all already pulled in by P6-2. A cargo feature would only complicate the build matrix without saving meaningful binary weight.

Root cause: Two small spec gaps that resolve cleanly together — extend the LanguageModel trait once for vision routing, and collapse compile-time + runtime gating into a single runtime gate.

Fix (PR #34, feat/p6-3-caption-adapter):

  • kebab-core::GenerateRequest gains an images: Vec<String> field (#[serde(default)] for backward compat with pre-P6 wire payloads / snapshots). Empty for the text-only RAG path; populated with one or more base64 strings by vision-aware callers.
  • kebab-llm-local::OllamaLanguageModel routes req.images onto the wire as images: [base64, ...] (Ollama's vision channel). The wire shape stays byte-identical for empty images because the field uses #[serde(skip_serializing_if = "<[String]>::is_empty")].
  • kebab-parse-image::caption module: caption_image / apply_caption build GenerateRequest { images: vec![b64], temperature: 0.0, seed: 0, ... } and accept any &dyn LanguageModel. Korean / English prompt branch picked from lang_hint.
  • Cargo feature caption is not introduced — the runtime gate config.image.caption.enabled = false (default OFF) suffices.
  • All existing GenerateRequest { ... } literals (kebab-rag, kebab-llm tests, kebab-llm-local tests) gained images: Vec::new() to satisfy the new field.

Trust note: Captions stay explicitly model-generated. ModelCaption.model_version carries "<provider>/<prompt_template_version>" (e.g. "ollama/caption-v1") so a regression in either prompt or model is auditable from the wire.

model_version shape deviation: spec literal says model_version: llm.model_ref().provider (provider as a coarse version proxy). We extend to <provider>/<prompt_template_version> because prompt template churn is a real regression vector independent of the model — pinning both axes in one string lets kebab-eval (P5) detect either drift without a schema bump. Spec already left the door open ("if a vision model exposes a stable revision, prefer that"); the prompt template version is the closest stable revision we have today. Future PaddleOCR / Apple Vision adapters that expose a real model revision string can substitute it for prompt_template_version without breaking the wire shape.

Amends:

  • tasks/p4/p4-1-llm-trait.md (GenerateRequest schema gained images: Vec<String>).
  • tasks/p4/p4-2-ollama-adapter.md (request body now optionally includes images: [...]).
  • tasks/p6/p6-3-caption-adapter.md ("Definition of Done" cargo feature caption dropped; runtime gate is the only feature gate).

2026-05-02 — P6-2 default OCR engine: Tesseract → Ollama-vision

Discovered: P6-2 implementation start.

Symptom: The original tasks/p6/p6-2-ocr-adapter.md spec lists Tesseract as the default OCR engine (tesseract = "0.13", feature tesseract, default ON). Bringing Tesseract online requires installing libtesseract-dev (and tesseract-ocr-kor for the spec-default Korean languages set) on every dev / CI host. The kebab dev environment intentionally avoids system-package installs, so the Tesseract Rust bindings can't link.

Root cause: Spec was written assuming a Linux host with apt install tesseract-ocr-* available. The reality of single-developer local-first KB is that the same box also runs the Ollama vision endpoint already wired by P4-2 — using it for OCR adds zero new system dependencies.

Fix (PR #33, feat/p6-2-ocr-adapter):

  • New OllamaVisionOcr adapter under crates/kebab-parse-image/src/ocr.rs. Implements the spec's OcrEngine trait by POSTing the image (base64) to <endpoint>/api/generate with a transcription prompt against gemma4:e4b (default) or any other vision-capable Ollama model.
  • New kebab-config::ImageCfg.ocr block (enabled, engine, model, endpoint, languages, max_pixels). enabled defaults to false because OCR adds a model call per asset; engine defaults to "ollama-vision". endpoint falls back to models.llm.endpoint when empty so the same Ollama host serves both LLM and OCR.
  • The OcrEngine trait is unchanged from the spec — Tesseract / Apple Vision / PaddleOCR engines plug in as future feature-gated alternatives without touching the extractor or chunker. The trait abstraction is the part the spec actually demanded; only the choice of default implementation changes.
  • Tests cover wiremock unit paths (200 happy / 5xx / 200 error envelope / empty response / downscale honours max_pixels), apply_ocr provenance + error handling, and an opt-in KEBAB_OCR_INTEGRATION=1 integration test that hits a real Ollama endpoint with a generated "Hello World 2026" PNG. Tesseract feature-gated tests from the original spec are deferred to whenever someone is willing to bring libtesseract to CI.

Trust note: The original spec marked OcrText as "observed text (high trust)" to distinguish it from ModelCaption. With an LLM-driven default the line blurs — vision LMs can hallucinate. We kept OcrText.engine = "ollama-vision" so consumers can decide trust by engine identity. Future Tesseract / Apple Vision adapters write a different engine string and downstream code can branch.

Amends: tasks/p6/p6-2-ocr-adapter.md (default engine; "Allowed dependencies" list — reqwest + base64 replace tesseract; "Apple Vision" feature gate deferred; min_confidence config field dropped because the LM doesn't expose per-region confidence).

2026-05-01 — --config flag silently ignored across all kebab-cli subcommands

Discovered: post-P3-5 manual smoke at /tmp/kebab-smoke/.

Symptom: kebab --config /path/to/config.toml ingest|search|list|inspect|doctor ignored the flag and fell back to ~/.config/kebab/config.toml (XDG default). Users had to use KEBAB_* env vars to point at a non-default config.

Root cause: kebab-cli read cli.config only inside Cmd::Ingest to build SourceScope, then called bare kebab_app::ingest(scope, summary_only) which internally re-loaded Config::load(None) (XDG path). Same pattern in Cmd::Search / List / Inspect / Doctor. P3-5 introduced *_with_config test seams via #[doc(hidden)] pub fn but kebab-cli never used them.

Fix (PR #20, fix/cli-config-flag-and-search-output):

  • kebab-cli now builds the Config once via Config::load(cli.config.as_deref()) at the top of every subcommand and threads it into kebab_app::*_with_config(cfg, ...) instead of kebab_app::*(...).
  • kebab_app::doctor() rewritten as doctor_with_config_path(Option<&Path>) that reports the actual path probed and hard-fails when --config <path> doesn't exist (defaults would otherwise mask user intent).
  • kebab-app module doc-comment updated: #[doc(hidden)] pub fn *_with_config is no longer "test-only seam" — it's the official "config-explicit" API consumed by CLI --config, integration tests, and TUI sessions.
  • Same PR also improved kebab search printer: {:.4} score formatting (RRF range collapses on {:.2}) and > heading_path suffix so chunks from the same document are visually distinct.

Amends: tasks/p3/p3-5-app-wiring.md (the test seam was always meant to be the config-explicit API; only the doc-comment lied).

2026-05-01 — --config regression in kebab ask (P4-3 follow-up)

Discovered: post-P4-3 manual smoke against 192.168.0.47 Ollama with gemma4:26b.

Symptom: kebab --config <path> ask returned model.id = qwen2.5:14b-instruct (XDG default model) and score_gate = 0.30 (XDG default), instead of gemma4:26b / 0.05 from the explicit config. P4-3 added the ask body but kebab-cli's Cmd::Ask arm still called bare kebab_app::ask(query, opts) — same regression class as the P3-5 fix above, just missed when ask was wired.

Fix (PR #24, fix/cli-ask-honor-config-flag):

  • kebab-cli builds Config::load(cli.config.as_deref()) once at the top of Cmd::Ask and calls kebab_app::ask_with_config(cfg, query, opts).

Amends: tasks/p4/p4-3-rag-pipeline.md.

2026-05-01 — RRF fusion_score incompatible with config.rag.score_gate default

Discovered: post-P4-3 manual smoke. Top hybrid result returned fusion_score = 0.0164 against score_gate = 0.05 → ScoreGate refusal on every hybrid query.

Root cause: RRF formula score(c) = Σ 1/(k_rrf + rank_m(c)) produces values bounded by num_retrievers / (k_rrf + 1). With num_retrievers = 2 and the default k_rrf = 60, the upper bound is 2/61 ≈ 0.0328. The default config.rag.score_gate = 0.05 was calibrated for vector / lexical scores already in [0, 1] and silently refused every hybrid query. fusion_score was also incomparable across modes — Lexical / Vector lived in [0, 1], Hybrid lived in (0, 0.033].

Fix (PR #25, fix/rrf-fusion-score-normalize-and-docs):

  • crates/kebab-search/src/hybrid.rs divides every raw RRF score by 2 / (k_rrf + 1) so fusion_score always lives in [0, 1] regardless of mode. Both retrievers contributing rank 1 normalises to 1.0; chunks present in only one retriever cap around 0.5. RRF's rank-ordering invariants are preserved (same constant divides every score), so sort + tiebreak behaviour is identical.
  • One unit test (rrf_formula_matches_known_value) updated to expect the normalised value (1/61 + 1/62) / (2/61) ≈ 0.9919.
  • The integration snapshot crates/kebab-search/tests/fixtures/search/hybrid/run-1.json already used presence checks (fusion_score_positive: true) rather than absolute values, so it didn't need regeneration.

Why not a per-mode score_gate config: separate lexical_score_gate / vector_score_gate / hybrid_score_gate would force every downstream consumer (CLI, eval, TUI) to know which mode picks which threshold. Normalising the score itself is a one-line change at the source and makes Answer.retrieval.score_gate semantically meaningful without per-mode bookkeeping.

Amends: tasks/p3/p3-4-hybrid-fusion.md (RRF formula now divides by 2/(k_rrf+1) after summation), tasks/phase-3-vector-hybrid.md (RRF section).

Verification: post-fix smoke at /tmp/kebab-smoke/ with default score_gate = 0.05 succeeded across four scenarios — Korean→Korean, English→English, cross-language, and out-of-corpus refusal.

How to add an entry

Each fix gets a dated subsection with five fields:

  • Discovered: when / how the bug surfaced (smoke, integration test, user report).
  • Symptom: what the user saw / what was wrong.
  • Root cause: the actual code or design issue.
  • Fix: PR number / branch + a one-paragraph summary of the change.
  • Amends: which tasks/p<N>/... spec docs the fix retroactively contradicts. Spec text stays frozen; this log is the live source of truth for post-merge deltas.

If a fix is large enough that the original spec is no longer a useful reference, promote the entry into a new task spec (e.g., p<N>-<M+1>-<topic>.md) and link from here.