feat(cli): kebab search --trace flag + wire trace + pretty print (fb-37)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-10 13:08:48 +09:00
parent 69037c313a
commit 72c227af23
2 changed files with 77 additions and 2 deletions

View File

@@ -163,6 +163,13 @@ enum Cmd {
/// p9-fb-36: filter to a single doc by id.
#[arg(long)]
doc_id: Option<String>,
/// 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(())
}

View File

@@ -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::<Vec<_>>(),
"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");
}
}