feat(cli): [stale] tag on plain ask citations (fb-32)
Mirror of Task 9's search-output rendering: yellow [stale] on TTY, plain text otherwise. JSON path inherits via serde on AnswerCitation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -614,26 +614,12 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
// `근거:` header.
|
// `근거:` header.
|
||||||
let print_citations = *show_citations && !*hide_citations;
|
let print_citations = *show_citations && !*hide_citations;
|
||||||
if print_citations && !ans.citations.is_empty() {
|
if print_citations && !ans.citations.is_empty() {
|
||||||
println!();
|
// p9-fb-32: yellow `[stale]` prefix on TTY (mirrors
|
||||||
println!("근거:");
|
// the search renderer's pattern in `Cmd::Search`).
|
||||||
for (idx, c) in ans.citations.iter().enumerate() {
|
use std::io::IsTerminal;
|
||||||
let marker = c
|
let color = std::io::stdout().is_terminal();
|
||||||
.marker
|
let mut out = std::io::stdout().lock();
|
||||||
.clone()
|
render_ask_plain_citations(&mut out, &ans, color)?;
|
||||||
.unwrap_or_else(|| format!("{}", idx + 1));
|
|
||||||
println!(" [{}] {}", marker, c.citation.to_uri());
|
|
||||||
}
|
|
||||||
// p9-fb-20: retrieval 메타는 citation 별 점수가
|
|
||||||
// AnswerCitation 에 없는 (`top_score` 만 retrieval-
|
|
||||||
// 전체 max) 한계상 한 줄로 분리. per-citation score
|
|
||||||
// 노출은 facade + AnswerCitation 의 미래 확장 후.
|
|
||||||
println!(
|
|
||||||
"(retrieval: top_score={:.2}, k={}, used={}/{})",
|
|
||||||
ans.retrieval.top_score,
|
|
||||||
ans.retrieval.k,
|
|
||||||
ans.retrieval.chunks_used,
|
|
||||||
ans.retrieval.chunks_returned,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refusal → exit 1.
|
// Refusal → exit 1.
|
||||||
@@ -878,6 +864,54 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// p9-fb-32: render the plain (non-JSON) citation block for `kebab ask`.
|
||||||
|
/// Mirrors the `Cmd::Search` plain renderer's `[stale]` convention —
|
||||||
|
/// yellow ANSI on TTY, plain text otherwise. Detection uses stdlib
|
||||||
|
/// `IsTerminal` at the call site; this function takes the resolved
|
||||||
|
/// `color` boolean so tests can pin both branches deterministically.
|
||||||
|
///
|
||||||
|
/// Skipping the empty / no-citation path is the caller's responsibility
|
||||||
|
/// (matches the original inline guard at the call site).
|
||||||
|
fn render_ask_plain_citations(
|
||||||
|
w: &mut impl std::io::Write,
|
||||||
|
ans: &kebab_core::Answer,
|
||||||
|
color: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
writeln!(w)?;
|
||||||
|
writeln!(w, "근거:")?;
|
||||||
|
for (idx, c) in ans.citations.iter().enumerate() {
|
||||||
|
let marker = c
|
||||||
|
.marker
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}", idx + 1));
|
||||||
|
// p9-fb-32: `[stale]` prefix on the URI for citations whose
|
||||||
|
// `stale: true`. Yellow on TTY, plain otherwise — mirrors the
|
||||||
|
// search-plain renderer in `Cmd::Search`.
|
||||||
|
let stale_tag = if c.stale {
|
||||||
|
if color {
|
||||||
|
"\x1b[33m[stale]\x1b[0m "
|
||||||
|
} else {
|
||||||
|
"[stale] "
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
writeln!(w, " [{}] {}{}", marker, stale_tag, c.citation.to_uri())?;
|
||||||
|
}
|
||||||
|
// p9-fb-20: retrieval 메타는 citation 별 점수가 AnswerCitation 에
|
||||||
|
// 없는 (`top_score` 만 retrieval-전체 max) 한계상 한 줄로 분리.
|
||||||
|
// per-citation score 노출은 facade + AnswerCitation 의 미래 확장 후.
|
||||||
|
writeln!(
|
||||||
|
w,
|
||||||
|
"(retrieval: top_score={:.2}, k={}, used={}/{})",
|
||||||
|
ans.retrieval.top_score,
|
||||||
|
ans.retrieval.k,
|
||||||
|
ans.retrieval.chunks_used,
|
||||||
|
ans.retrieval.chunks_returned,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn print_schema_text(s: &kebab_app::SchemaV1) {
|
fn print_schema_text(s: &kebab_app::SchemaV1) {
|
||||||
println!("kebab v{}", s.kebab_version);
|
println!("kebab v{}", s.kebab_version);
|
||||||
println!();
|
println!();
|
||||||
@@ -955,3 +989,107 @@ fn confirm_destructive(
|
|||||||
Ok(matches!(s.as_str(), "y" | "yes"))
|
Ok(matches!(s.as_str(), "y" | "yes"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! p9-fb-32: unit tests for `render_ask_plain_citations`. The
|
||||||
|
//! integration end-to-end (`tests/wire_ask_stale.rs`) is gated on
|
||||||
|
//! a real Ollama, so we cover the renderer's `[stale]` logic here
|
||||||
|
//! against a synthetic `Answer` instead.
|
||||||
|
use super::*;
|
||||||
|
use kebab_core::{
|
||||||
|
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef,
|
||||||
|
PromptTemplateVersion, SearchMode, TokenUsage, TraceId, WorkspacePath,
|
||||||
|
};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
fn mk_answer(citations: Vec<AnswerCitation>) -> Answer {
|
||||||
|
Answer {
|
||||||
|
answer: "ans".into(),
|
||||||
|
citations,
|
||||||
|
grounded: true,
|
||||||
|
refusal_reason: None,
|
||||||
|
model: ModelRef {
|
||||||
|
id: "test".into(),
|
||||||
|
provider: "test".into(),
|
||||||
|
dimensions: None,
|
||||||
|
},
|
||||||
|
embedding: None,
|
||||||
|
prompt_template_version: PromptTemplateVersion("rag-v1".into()),
|
||||||
|
retrieval: AnswerRetrievalSummary {
|
||||||
|
trace_id: TraceId("ret_test".into()),
|
||||||
|
mode: SearchMode::Lexical,
|
||||||
|
k: 5,
|
||||||
|
score_gate: 0.30,
|
||||||
|
top_score: 0.80,
|
||||||
|
chunks_returned: 1,
|
||||||
|
chunks_used: 1,
|
||||||
|
},
|
||||||
|
usage: TokenUsage {
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
latency_ms: 0,
|
||||||
|
},
|
||||||
|
created_at: OffsetDateTime::now_utc(),
|
||||||
|
conversation_id: None,
|
||||||
|
turn_index: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mk_citation(path: &str, stale: bool) -> AnswerCitation {
|
||||||
|
AnswerCitation {
|
||||||
|
marker: Some("1".into()),
|
||||||
|
citation: Citation::Line {
|
||||||
|
path: WorkspacePath::new(path.into()).unwrap(),
|
||||||
|
start: 1,
|
||||||
|
end: 1,
|
||||||
|
section: None,
|
||||||
|
},
|
||||||
|
indexed_at: OffsetDateTime::now_utc(),
|
||||||
|
stale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_marks_stale_citation_no_color() {
|
||||||
|
let ans = mk_answer(vec![mk_citation("a.md", true)]);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
render_ask_plain_citations(&mut buf, &ans, false).unwrap();
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.contains("[stale]"),
|
||||||
|
"expected `[stale]` marker in plain output, got:\n{out}"
|
||||||
|
);
|
||||||
|
// No ANSI when color = false.
|
||||||
|
assert!(
|
||||||
|
!out.contains("\x1b["),
|
||||||
|
"unexpected ANSI escape in non-color output:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_marks_stale_citation_color_uses_yellow_ansi() {
|
||||||
|
let ans = mk_answer(vec![mk_citation("a.md", true)]);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
render_ask_plain_citations(&mut buf, &ans, true).unwrap();
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
// Yellow ANSI + reset around the `[stale]` token, mirroring the
|
||||||
|
// search-plain renderer in `Cmd::Search`.
|
||||||
|
assert!(
|
||||||
|
out.contains("\x1b[33m[stale]\x1b[0m"),
|
||||||
|
"expected yellow [stale] ANSI sequence in color output, got:\n{out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_no_stale_tag_for_fresh_citation() {
|
||||||
|
let ans = mk_answer(vec![mk_citation("a.md", false)]);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
render_ask_plain_citations(&mut buf, &ans, true).unwrap();
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(
|
||||||
|
!out.contains("[stale]"),
|
||||||
|
"unexpected `[stale]` marker for fresh citation:\n{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
214
crates/kebab-cli/tests/wire_ask_stale.rs
Normal file
214
crates/kebab-cli/tests/wire_ask_stale.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Build a `config.toml` text under `dir`. `workspace_root` and
|
||||||
|
/// `data_dir` live inside `dir`. `stale_threshold_days` is plumbed
|
||||||
|
/// into `[search]` so the staleness post-process can fire.
|
||||||
|
fn write_config(dir: &Path, stale_threshold_days: u32) -> (PathBuf, PathBuf, PathBuf) {
|
||||||
|
let workspace = dir.join("workspace");
|
||||||
|
let data = dir.join("data");
|
||||||
|
fs::create_dir_all(&workspace).unwrap();
|
||||||
|
fs::create_dir_all(&data).unwrap();
|
||||||
|
|
||||||
|
let cfg_path = dir.join("config.toml");
|
||||||
|
fs::write(
|
||||||
|
&cfg_path,
|
||||||
|
format!(
|
||||||
|
r#"schema_version = 1
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
root = "{workspace}"
|
||||||
|
exclude = [".git/**"]
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data_dir = "{data}"
|
||||||
|
sqlite = "{{data_dir}}/kebab.sqlite"
|
||||||
|
vector_dir = "{{data_dir}}/lancedb"
|
||||||
|
asset_dir = "{{data_dir}}/assets"
|
||||||
|
artifact_dir = "{{data_dir}}/artifacts"
|
||||||
|
model_dir = "{{data_dir}}/models"
|
||||||
|
runs_dir = "{{data_dir}}/runs"
|
||||||
|
copy_threshold_mb = 100
|
||||||
|
|
||||||
|
[indexing]
|
||||||
|
max_parallel_extractors = 2
|
||||||
|
max_parallel_embeddings = 1
|
||||||
|
watch_filesystem = false
|
||||||
|
|
||||||
|
[chunking]
|
||||||
|
target_tokens = 80
|
||||||
|
overlap_tokens = 20
|
||||||
|
respect_markdown_headings = true
|
||||||
|
chunker_version = "md-heading-v1"
|
||||||
|
|
||||||
|
[models.embedding]
|
||||||
|
provider = "none"
|
||||||
|
model = "none"
|
||||||
|
version = "v0"
|
||||||
|
dimensions = 0
|
||||||
|
batch_size = 1
|
||||||
|
|
||||||
|
[models.llm]
|
||||||
|
provider = "ollama"
|
||||||
|
model = "gemma4:e4b"
|
||||||
|
context_tokens = 4096
|
||||||
|
endpoint = "http://127.0.0.1:11434"
|
||||||
|
temperature = 0.0
|
||||||
|
seed = 0
|
||||||
|
|
||||||
|
[search]
|
||||||
|
default_k = 10
|
||||||
|
hybrid_fusion = "rrf"
|
||||||
|
rrf_k = 60
|
||||||
|
snippet_chars = 220
|
||||||
|
stale_threshold_days = {stale_threshold_days}
|
||||||
|
|
||||||
|
[rag]
|
||||||
|
prompt_template_version = "rag-v1"
|
||||||
|
score_gate = 0.30
|
||||||
|
explain_default = false
|
||||||
|
max_context_tokens = 8000
|
||||||
|
"#,
|
||||||
|
workspace = workspace.display(),
|
||||||
|
data = data.display(),
|
||||||
|
stale_threshold_days = stale_threshold_days,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(cfg_path, workspace, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ingest(cfg: &Path, workspace: &Path) {
|
||||||
|
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||||
|
let out = Command::new(bin)
|
||||||
|
.args([
|
||||||
|
"--config",
|
||||||
|
cfg.to_str().unwrap(),
|
||||||
|
"ingest",
|
||||||
|
"--root",
|
||||||
|
workspace.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"ingest failed: stderr={}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite `documents.updated_at` for one workspace path to
|
||||||
|
/// `now - days_ago` (RFC3339 UTC). Mirrors
|
||||||
|
/// `kebab-app/tests/common/mod.rs::backdate_document_updated_at`.
|
||||||
|
fn backdate_updated_at(data_dir: &Path, workspace_path: &str, days_ago: i64) {
|
||||||
|
let backdated = (time::OffsetDateTime::now_utc() - time::Duration::days(days_ago))
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.expect("format backdated updated_at");
|
||||||
|
let db_path = data_dir.join("kebab.sqlite");
|
||||||
|
let conn = rusqlite::Connection::open(&db_path).expect("open kebab.sqlite");
|
||||||
|
let updated = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE documents SET updated_at = ?1 WHERE workspace_path = ?2",
|
||||||
|
rusqlite::params![backdated, workspace_path],
|
||||||
|
)
|
||||||
|
.expect("UPDATE documents.updated_at");
|
||||||
|
assert_eq!(
|
||||||
|
updated, 1,
|
||||||
|
"backdate_updated_at: expected to update exactly 1 row for {workspace_path}, got {updated}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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) = write_config(dir.path(), 30);
|
||||||
|
fs::write(workspace.join("a.md"), "# T\n\napples are fruit\n").unwrap();
|
||||||
|
ingest(&cfg, &workspace);
|
||||||
|
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) = write_config(dir.path(), 30);
|
||||||
|
fs::write(workspace.join("a.md"), "# T\n\napples are fruit\n").unwrap();
|
||||||
|
ingest(&cfg, &workspace);
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user