feat(cli): kebab search --max-tokens / --snippet-chars / --cursor (fb-34)
JSON output wrapped in search_response.v1 (breaking — agent must adapt). Plain output unchanged + [truncated; use --cursor X] stderr hint when budget tripped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ pub const SCHEMA_V1_ID: &str = "schema.v1";
|
|||||||
const WIRE_SCHEMAS: &[&str] = &[
|
const WIRE_SCHEMAS: &[&str] = &[
|
||||||
"answer.v1",
|
"answer.v1",
|
||||||
"search_hit.v1",
|
"search_hit.v1",
|
||||||
|
"search_response.v1",
|
||||||
"doc_summary.v1",
|
"doc_summary.v1",
|
||||||
"chunk_inspection.v1",
|
"chunk_inspection.v1",
|
||||||
"doctor.v1",
|
"doctor.v1",
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ enum Cmd {
|
|||||||
/// future TUI cache-aware search and for explicit intent.
|
/// future TUI cache-aware search and for explicit intent.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
|
|
||||||
|
/// p9-fb-34: cap result wire JSON size at approximately N tokens
|
||||||
|
/// (chars/4 estimate). When set, smaller snippets and fewer hits
|
||||||
|
/// may be returned; check `truncated` in the JSON wire.
|
||||||
|
#[arg(long)]
|
||||||
|
max_tokens: Option<usize>,
|
||||||
|
|
||||||
|
/// p9-fb-34: per-hit snippet character cap, overrides
|
||||||
|
/// `config.search.snippet_chars` for this call only.
|
||||||
|
#[arg(long)]
|
||||||
|
snippet_chars: Option<usize>,
|
||||||
|
|
||||||
|
/// p9-fb-34: opaque cursor from a previous response's
|
||||||
|
/// `next_cursor` to fetch the next page. Mismatched
|
||||||
|
/// `corpus_revision` returns `error.v1.code = stale_cursor`.
|
||||||
|
#[arg(long)]
|
||||||
|
cursor: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Retrieval-augmented question answering.
|
/// Retrieval-augmented question answering.
|
||||||
@@ -515,6 +532,9 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
mode,
|
mode,
|
||||||
explain: _,
|
explain: _,
|
||||||
no_cache,
|
no_cache,
|
||||||
|
max_tokens,
|
||||||
|
snippet_chars,
|
||||||
|
cursor,
|
||||||
} => {
|
} => {
|
||||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||||
let q = kebab_core::SearchQuery {
|
let q = kebab_core::SearchQuery {
|
||||||
@@ -523,16 +543,24 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
k: *k,
|
k: *k,
|
||||||
filters: kebab_core::SearchFilters::default(),
|
filters: kebab_core::SearchFilters::default(),
|
||||||
};
|
};
|
||||||
// p9-fb-19: --no-cache routes to the uncached facade.
|
let opts = kebab_core::SearchOpts {
|
||||||
// Both calls go through the same App; only the cache
|
max_tokens: *max_tokens,
|
||||||
// lookup/insert is skipped.
|
snippet_chars: *snippet_chars,
|
||||||
let hits = if *no_cache {
|
cursor: cursor.clone(),
|
||||||
kebab_app::search_uncached_with_config(cfg, q)?
|
|
||||||
} else {
|
|
||||||
kebab_app::search_with_config(cfg, q)?
|
|
||||||
};
|
};
|
||||||
|
// p9-fb-34: budget-aware path. --no-cache still bypasses the
|
||||||
|
// App-level LRU; wire wrapper applies regardless.
|
||||||
|
let app = kebab_app::App::open_with_config(cfg)?;
|
||||||
|
if *no_cache {
|
||||||
|
app.clear_search_cache();
|
||||||
|
}
|
||||||
|
let resp = app.search_with_opts(q, opts)?;
|
||||||
|
|
||||||
if cli.json {
|
if cli.json {
|
||||||
println!("{}", serde_json::to_string(&wire::wire_search_hits(&hits))?);
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&wire::wire_search_response(&resp))?
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// p9-fb-32: prefix `[stale]` on the doc_path for hits
|
// p9-fb-32: prefix `[stale]` on the doc_path for hits
|
||||||
// whose `stale: true`. Yellow on TTY, plain otherwise —
|
// whose `stale: true`. Yellow on TTY, plain otherwise —
|
||||||
@@ -542,7 +570,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
// lands on); no new dep.
|
// lands on); no new dep.
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
let color = std::io::stdout().is_terminal();
|
let color = std::io::stdout().is_terminal();
|
||||||
for h in &hits {
|
for h in &resp.hits {
|
||||||
// Show 4-digit score so RRF fused scores (bounded
|
// Show 4-digit score so RRF fused scores (bounded
|
||||||
// ~0–0.033 for k_rrf=60) don't all collapse to "0.02".
|
// ~0–0.033 for k_rrf=60) don't all collapse to "0.02".
|
||||||
// Append heading_path so multiple chunks from the same
|
// Append heading_path so multiple chunks from the same
|
||||||
@@ -570,6 +598,12 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
heading,
|
heading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// p9-fb-34: truncation hint goes to stderr so it
|
||||||
|
// doesn't pollute the stdout hit list.
|
||||||
|
if resp.truncated {
|
||||||
|
let next = resp.next_cursor.as_deref().unwrap_or("(none)");
|
||||||
|
eprintln!("[truncated; use --cursor {next} for the next page]");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,18 @@ pub fn wire_search_hit(h: &SearchHit) -> Value {
|
|||||||
tag_object(v, "search_hit.v1")
|
tag_object(v, "search_hit.v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap a list of [`SearchHit`] values as a JSON array of `search_hit.v1`
|
/// p9-fb-34: tag a `SearchResponse` as `search_response.v1`. Wraps
|
||||||
/// objects (one tag per element, per design §2.2).
|
/// the existing `search_hit.v1[]` array with pagination + truncation
|
||||||
pub fn wire_search_hits(hits: &[SearchHit]) -> Value {
|
/// metadata. Replaces the previous bare `search_hit.v1[]` top-level
|
||||||
Value::Array(hits.iter().map(wire_search_hit).collect())
|
/// 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!({
|
||||||
|
"hits": r.hits.iter().map(wire_search_hit).collect::<Vec<_>>(),
|
||||||
|
"next_cursor": r.next_cursor,
|
||||||
|
"truncated": r.truncated,
|
||||||
|
});
|
||||||
|
tag_object(v, "search_response.v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap an [`Answer`] as `answer.v1`.
|
/// Wrap an [`Answer`] as `answer.v1`.
|
||||||
@@ -234,13 +242,6 @@ mod tests {
|
|||||||
assert_eq!(v.as_array().unwrap().len(), 0);
|
assert_eq!(v.as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn search_hits_wraps_each_element() {
|
|
||||||
let v = wire_search_hits(&[]);
|
|
||||||
assert!(v.is_array());
|
|
||||||
assert_eq!(v.as_array().unwrap().len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tag_object_inserts_into_object() {
|
fn tag_object_inserts_into_object() {
|
||||||
let v = Value::Object(serde_json::Map::new());
|
let v = Value::Object(serde_json::Map::new());
|
||||||
@@ -248,6 +249,30 @@ mod tests {
|
|||||||
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_response_carries_pagination_metadata() {
|
||||||
|
// p9-fb-34: empty-hits SearchResponse round-trips through the
|
||||||
|
// wrapper with its `next_cursor` + `truncated` fields preserved
|
||||||
|
// and the top-level `schema_version` set to `search_response.v1`.
|
||||||
|
let r = kebab_app::SearchResponse {
|
||||||
|
hits: vec![],
|
||||||
|
next_cursor: Some("opaque-cursor-abc".to_string()),
|
||||||
|
truncated: true,
|
||||||
|
};
|
||||||
|
let v = wire_search_response(&r);
|
||||||
|
assert_eq!(schema_of(&v), Some("search_response.v1"));
|
||||||
|
assert!(v.get("hits").and_then(|h| h.as_array()).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
v.get("hits").and_then(|h| h.as_array()).unwrap().len(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
v.get("next_cursor").and_then(|c| c.as_str()),
|
||||||
|
Some("opaque-cursor-abc")
|
||||||
|
);
|
||||||
|
assert_eq!(v.get("truncated").and_then(|t| t.as_bool()), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_wrapper_tags_schema_version() {
|
fn schema_wrapper_tags_schema_version() {
|
||||||
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
|
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
|
||||||
|
|||||||
@@ -45,10 +45,21 @@ fn search_json_includes_indexed_at_and_stale() {
|
|||||||
|
|
||||||
let out = run_search_lexical(&cfg, "apples", true);
|
let out = run_search_lexical(&cfg, "apples", true);
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let arr: serde_json::Value = serde_json::from_str(stdout.trim())
|
// p9-fb-34: top-level wire is now `search_response.v1` wrapping the
|
||||||
.unwrap_or_else(|e| panic!("expected JSON array, got {stdout:?}: {e}"));
|
// legacy `search_hit.v1[]` under a `hits` field (with pagination +
|
||||||
let arr = arr.as_array().unwrap_or_else(|| panic!("expected array, got {stdout}"));
|
// truncation metadata). Hit shape inside `hits` is unchanged.
|
||||||
let first = arr.first().unwrap_or_else(|| panic!("expected ≥1 hit, got empty array: {stdout}"));
|
let resp: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||||
|
.unwrap_or_else(|e| panic!("expected JSON object, got {stdout:?}: {e}"));
|
||||||
|
assert_eq!(
|
||||||
|
resp.get("schema_version").and_then(|v| v.as_str()),
|
||||||
|
Some("search_response.v1"),
|
||||||
|
"expected search_response.v1 wrapper, got {resp}"
|
||||||
|
);
|
||||||
|
let arr = resp
|
||||||
|
.get("hits")
|
||||||
|
.and_then(|h| h.as_array())
|
||||||
|
.unwrap_or_else(|| panic!("expected hits array, got {stdout}"));
|
||||||
|
let first = arr.first().unwrap_or_else(|| panic!("expected ≥1 hit, got empty hits: {stdout}"));
|
||||||
assert!(
|
assert!(
|
||||||
first.get("indexed_at").is_some(),
|
first.get("indexed_at").is_some(),
|
||||||
"missing indexed_at in {first}"
|
"missing indexed_at in {first}"
|
||||||
|
|||||||
Reference in New Issue
Block a user