- config: rename env-silent-ignore test + add file-load negative test asserting ConfigInvalid for negative TOML stale_threshold_days - rag: add 5 boundary unit tests pinning compute_stale mirror equivalence - search: rewrite "Task 6" plan refs in lexical/vector to point at actual function names (mark_stale_in_place / RagPipeline::ask) - cli: dedupe write_config / ingest / backdate_updated_at helpers from wire_search_stale + wire_ask_stale into tests/common/mod.rs - tui: clarify inspect.rs uses same source-of-truth as SearchHit - rag: PackedCitation.stale invariant doc comment - HOTFIXES: log conscious decision on wire-schema required-field expansion (strict-validator concern) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
4.2 KiB
Rust
103 lines
4.2 KiB
Rust
//! p9-fb-32: CLI ask output — JSON path emits `indexed_at` + `stale`
|
|
//! on each citation; plain output prefixes stale citations with
|
|
//! `[stale]` (yellow on TTY).
|
|
//!
|
|
//! These end-to-end checks exercise `kebab ask`, which requires a real
|
|
//! Ollama on `127.0.0.1:11434` (same constraint as
|
|
//! `kebab-app/tests/ask_smoke.rs`). Both tests are therefore
|
|
//! `#[ignore]` by default — run with
|
|
//! `cargo test -p kebab-cli --test wire_ask_stale -- --ignored`
|
|
//! against a live Ollama.
|
|
//!
|
|
//! The `[stale]` rendering logic itself is also covered by a unit test
|
|
//! in `kebab-cli/src/main.rs` (`tests::plain_marks_stale_citation_*`)
|
|
//! that constructs a synthetic `Answer` and pipes it through
|
|
//! `render_ask_plain_citations` — that path is the always-on guard.
|
|
//!
|
|
//! Shared TempDir / ingest / backdate helpers live in
|
|
//! `tests/common/mod.rs`; see also `wire_search_stale.rs`.
|
|
|
|
mod common;
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
/// Run `kebab ask` in lexical mode (no embedding required). `json`
|
|
/// toggles `--json`. The caller asserts on the resulting stdout.
|
|
fn run_ask_lexical(cfg: &Path, query: &str, json: bool) -> std::process::Output {
|
|
let bin = env!("CARGO_BIN_EXE_kebab");
|
|
let mut cmd = Command::new(bin);
|
|
cmd.arg("--config").arg(cfg);
|
|
if json {
|
|
cmd.arg("--json");
|
|
}
|
|
cmd.args(["ask", "--mode", "lexical", query]);
|
|
cmd.output().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires real Ollama on 127.0.0.1:11434"]
|
|
fn ask_json_citations_include_indexed_at_and_stale() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (cfg, workspace, data) = common::write_config_with_llm_model(dir.path(), 30, "gemma4:e4b");
|
|
fs::write(workspace.join("a.md"), "# T\n\napples are fruit\n").unwrap();
|
|
common::ingest(&cfg, &workspace);
|
|
common::backdate_updated_at(&data, "a.md", 60);
|
|
|
|
// ask returns exit 1 on refusal; the JSON envelope still goes to
|
|
// stdout. Don't assert on `status.success()` — accept either path
|
|
// and require the citations array to be present + structurally valid.
|
|
let out = run_ask_lexical(&cfg, "what about apples", true);
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
let answer: serde_json::Value = serde_json::from_str(stdout.trim())
|
|
.unwrap_or_else(|e| panic!("expected JSON answer, got {stdout:?}: {e}"));
|
|
let cits = answer["citations"]
|
|
.as_array()
|
|
.unwrap_or_else(|| panic!("expected citations array, got {answer}"));
|
|
if let Some(cit) = cits.first() {
|
|
// Schema fields are always present on a structurally-valid
|
|
// AnswerCitation (serde-derived per Task 2 + Task 8).
|
|
assert!(
|
|
cit.get("indexed_at").is_some(),
|
|
"missing indexed_at on citation: {cit}"
|
|
);
|
|
assert!(
|
|
cit.get("stale").is_some(),
|
|
"missing stale on citation: {cit}"
|
|
);
|
|
assert_eq!(
|
|
cit["stale"], true,
|
|
"doc backdated 60d at threshold 30d must be stale: {cit}"
|
|
);
|
|
}
|
|
// If the model refused with zero citations the schema-shape claim
|
|
// is vacuously true; the unit-test path
|
|
// (`tests::plain_marks_stale_citation_*` in main.rs) is the
|
|
// always-on guard.
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires real Ollama on 127.0.0.1:11434"]
|
|
fn ask_plain_marks_stale_citation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (cfg, workspace, data) = common::write_config_with_llm_model(dir.path(), 30, "gemma4:e4b");
|
|
fs::write(workspace.join("a.md"), "# T\n\napples are fruit\n").unwrap();
|
|
common::ingest(&cfg, &workspace);
|
|
common::backdate_updated_at(&data, "a.md", 60);
|
|
|
|
// Refusal exits 1 — that's still fine here, the renderer prints
|
|
// the citation block before the refusal exit when citations exist.
|
|
// If the model refused with zero citations, this test is
|
|
// best-effort (skip the assert): the unit-test path in main.rs
|
|
// (`tests::plain_marks_stale_citation_*`) is the always-on guard.
|
|
let out = run_ask_lexical(&cfg, "what about apples", false);
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
if stdout.contains("근거:") {
|
|
assert!(
|
|
stdout.contains("[stale]"),
|
|
"stale tag missing in plain ask output:\n{stdout}"
|
|
);
|
|
}
|
|
}
|