From 72c227af239acaae623fe23f22095209c66bcb55 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sun, 10 May 2026 13:08:48 +0900 Subject: [PATCH] feat(cli): kebab search --trace flag + wire trace + pretty print (fb-37) Co-Authored-By: Claude Sonnet 4.6 --- crates/kebab-cli/src/main.rs | 26 +++++++++++++++++- crates/kebab-cli/src/wire.rs | 53 +++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 21ee509..305397c 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -163,6 +163,13 @@ enum Cmd { /// p9-fb-36: filter to a single doc by id. #[arg(long)] doc_id: Option, + + /// p9-fb-37: emit pre-fusion lexical / vector / RRF candidate + /// lists + per-stage timing in the response. Bypasses cache + /// (debug intent — fresh run guaranteed). Requires embeddings + /// to be enabled. + #[arg(long)] + trace: bool, }, /// Retrieval-augmented question answering. @@ -669,6 +676,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> { media, ingested_after, doc_id, + trace, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; @@ -732,7 +740,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> { max_tokens: *max_tokens, snippet_chars: *snippet_chars, cursor: cursor.clone(), - trace: false, + trace: *trace, }; // p9-fb-34: budget-aware path. --no-cache still bypasses the // App-level LRU; wire wrapper applies regardless. @@ -790,6 +798,22 @@ fn run(cli: &Cli) -> anyhow::Result<()> { let next = resp.next_cursor.as_deref().unwrap_or("(none)"); eprintln!("[truncated; use --cursor {next} for the next page]"); } + if *trace { + if let Some(t) = &resp.trace { + eprintln!(); + eprintln!("Trace:"); + eprintln!(" lexical ({} hits, {}ms):", t.lexical.len(), t.timing.lexical_ms); + for c in t.lexical.iter().take(3) { + eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0); + } + eprintln!(" vector ({} hits, {}ms):", t.vector.len(), t.timing.vector_ms); + for c in t.vector.iter().take(3) { + eprintln!(" rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0); + } + eprintln!(" fusion ({} inputs, {}ms)", t.rrf_inputs.len(), t.timing.fusion_ms); + eprintln!(" total: {}ms", t.timing.total_ms); + } + } } Ok(()) } diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index 504288d..29ab7aa 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -81,11 +81,17 @@ pub fn wire_search_hit(h: &SearchHit) -> Value { /// array (`wire_search_hits`) — see HOTFIXES / fb-34 for the /// breaking shape change. pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value { - let v = serde_json::json!({ + let mut v = serde_json::json!({ "hits": r.hits.iter().map(wire_search_hit).collect::>(), "next_cursor": r.next_cursor, "truncated": r.truncated, }); + if let Some(trace) = &r.trace { + let trace_v = serde_json::to_value(trace).expect("SearchTrace serializes"); + if let Value::Object(ref mut map) = v { + map.insert("trace".to_string(), trace_v); + } + } tag_object(v, "search_response.v1") } @@ -348,4 +354,49 @@ mod tests { assert_eq!(paths.len(), 1); assert_eq!(paths[0].as_str(), Some("/tmp/x")); } + + #[test] + fn search_response_with_trace_serializes_trace_field() { + use kebab_core::{SearchTrace, TraceCandidate, TraceFusionInput, + TraceTiming, ChunkId, DocumentId, WorkspacePath}; + let r = kebab_app::SearchResponse { + hits: vec![], + next_cursor: None, + truncated: false, + trace: Some(SearchTrace { + lexical: vec![TraceCandidate { + chunk_id: ChunkId("c1".into()), + doc_id: DocumentId("d1".into()), + doc_path: WorkspacePath::new("a.md".into()).unwrap(), + rank: 1, + score: 0.42, + }], + vector: vec![], + rrf_inputs: vec![TraceFusionInput { + chunk_id: ChunkId("c1".into()), + lexical_rank: Some(1), + vector_rank: None, + fusion_score: 0.0, + }], + timing: TraceTiming { lexical_ms: 5, vector_ms: 0, fusion_ms: 1, total_ms: 7 }, + }), + }; + let v = wire_search_response(&r); + assert_eq!(schema_of(&v), Some("search_response.v1")); + assert!(v["trace"].is_object()); + assert_eq!(v["trace"]["timing"]["lexical_ms"], 5); + assert_eq!(v["trace"]["lexical"][0]["chunk_id"], "c1"); + } + + #[test] + fn search_response_without_trace_omits_field() { + let r = kebab_app::SearchResponse { + hits: vec![], + next_cursor: None, + truncated: false, + trace: None, + }; + let v = wire_search_response(&r); + assert!(v.get("trace").is_none(), "trace field absent when None"); + } }