feat(cli): kebab inspect ocr-stats + ocr-failures (Enhancement 3 + wire schema additive minor)

Two new wire schemas land as additive minor: ocr_stats.v1 (corpus-wide
aggregate — total_events, success_rate, p50/p90/p99/max_ms, by_engine,
top-10 by_doc by failure count) and ocr_failures.v1 (per-doc or
corpus-wide recent failures, with --doc-id + --limit). Both ship via
new CLI subcommands `kebab inspect ocr-stats` / `inspect ocr-failures`.

App gains four facade methods: inspect_ocr_stats /
inspect_ocr_failures plus their *_with_config companions — required by
CLAUDE.md "the facade rule" so `--config <path>` is honored. The CLI
dispatch arms thread cfg explicitly into the _with_config form.

Runtime introspection emit (WIRE_SCHEMAS in schema.rs) gains two
entries; the meta JSON Schema (schema.schema.json) is untouched
because its wire.schemas is pattern-based, not enum-based.

ingest_log::percentiles extended to (p50, p90, p99, max). p99 surfaces
only via inspect ocr-stats; IngestSummary (round 1) stays 3-percentile.

SKILL.md synced with the two new schemas (AC-13).

Closure r2 G2 (facade *_with_config pair) + G3 (runtime emit, not
meta schema file) + closure r1 F4 (p99) resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 06:13:08 +00:00
parent 4e451c9f7c
commit d9ec7b8dc3
9 changed files with 476 additions and 7 deletions

View File

@@ -0,0 +1,153 @@
//! Integration smoke tests for `kebab inspect ocr-stats / ocr-failures`.
//! AC-4, AC-5, AC-6, AC-11 (ocr_inspect_smoke binary), AC-13.
mod common;
use common::TestEnv;
use kebab_app::App;
use kebab_store_sqlite::SqliteStore;
/// Insert synthetic pdf_ocr_events rows directly so the test runs without
/// a live Ollama endpoint.
fn seed_ocr_events(env: &TestEnv, store: &SqliteStore) {
// Success rows
for i in 0..3u32 {
store
.record_pdf_ocr_event(
"run-aaa",
&format!("2026-05-28T0{}:00:00Z", i),
Some("doc-abc"),
"path/scanned.pdf",
i + 1,
Some(50_000),
Some(200),
Some(150),
100 + (i as u64) * 20,
42,
true,
None,
"qwen2.5vl",
)
.expect("seed success row");
}
// Failure row
store
.record_pdf_ocr_event(
"run-bbb",
"2026-05-28T10:00:00Z",
Some("doc-abc"),
"path/scanned.pdf",
4,
Some(30_000),
Some(200),
Some(150),
9999,
0,
false,
Some("ocr_error"),
"qwen2.5vl",
)
.expect("seed failure row");
// Row for different doc
store
.record_pdf_ocr_event(
"run-ccc",
"2026-05-28T11:00:00Z",
Some("doc-xyz"),
"path/other.pdf",
1,
None,
None,
None,
200,
10,
true,
None,
"qwen2.5vl",
)
.expect("seed doc-xyz row");
// Trigger migration (no-op if already done via App::open_with_config)
let _ = env;
}
fn open_app_with_seeded_events(env: &TestEnv) -> App {
let app = env.app();
let store = SqliteStore::open(&env.config).expect("open store for seed");
store.run_migrations().expect("run migrations for seed");
seed_ocr_events(env, &store);
app
}
/// AC-4: `inspect_ocr_stats` returns `schema_version = "ocr_stats.v1"`,
/// `total_events >= 1`, `0 ≤ success_rate ≤ 1`.
#[test]
fn ocr_stats_after_seeded_events() {
let env = TestEnv::lexical_only();
let app = open_app_with_seeded_events(&env);
let stats = app.inspect_ocr_stats().expect("inspect_ocr_stats");
assert_eq!(stats.schema_version, "ocr_stats.v1");
assert!(stats.total_events >= 1, "total_events should be >= 1");
assert!(
(0.0..=1.0).contains(&stats.success_rate),
"success_rate must be in [0, 1]: {}",
stats.success_rate
);
assert!(stats.total_runs >= 1, "total_runs should be >= 1");
// by_engine should have at least one entry
assert!(!stats.by_engine.is_empty(), "by_engine must be non-empty");
}
/// AC-6: `inspect_ocr_failures` (no doc_id, corpus-wide) returns failures list.
#[test]
fn ocr_failures_corpus_wide() {
let env = TestEnv::lexical_only();
let app = open_app_with_seeded_events(&env);
let result = app
.inspect_ocr_failures(None, 10)
.expect("inspect_ocr_failures");
assert_eq!(result.schema_version, "ocr_failures.v1");
assert!(result.failure_count >= 1, "expected at least 1 failure");
assert!(!result.failures.is_empty(), "failures list must be non-empty");
}
/// AC-5: `inspect_ocr_failures` with doc_id filter returns matching rows.
#[test]
fn ocr_failures_filter_by_doc_id() {
let env = TestEnv::lexical_only();
let app = open_app_with_seeded_events(&env);
let result = app
.inspect_ocr_failures(Some("doc-abc"), 10)
.expect("inspect_ocr_failures by doc_id");
assert_eq!(result.schema_version, "ocr_failures.v1");
assert_eq!(
result.doc_id.as_deref(),
Some("doc-abc"),
"doc_id must be echoed back"
);
// All rows must belong to doc-abc (no cross-doc leak)
for row in &result.failures {
// rows are failure rows for doc-abc only (reason = ocr_error)
assert_eq!(row.reason, "ocr_error");
}
}
/// AC-13: SKILL.md lists both new wire schemas.
#[test]
fn skill_md_lists_new_schemas() {
let skill_md = std::fs::read_to_string("../../integrations/claude-code/kebab/SKILL.md")
.expect("read SKILL.md");
assert!(
skill_md.contains("ocr_stats.v1"),
"SKILL.md must mention ocr_stats.v1"
);
assert!(
skill_md.contains("ocr_failures.v1"),
"SKILL.md must mention ocr_failures.v1"
);
}