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");
+ }
}