diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 797d419..4614bee 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -614,26 +614,12 @@ fn run(cli: &Cli) -> anyhow::Result<()> { // `근거:` header. let print_citations = *show_citations && !*hide_citations; if print_citations && !ans.citations.is_empty() { - println!(); - println!("근거:"); - for (idx, c) in ans.citations.iter().enumerate() { - let marker = c - .marker - .clone() - .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, - ); + // p9-fb-32: yellow `[stale]` prefix on TTY (mirrors + // the search renderer's pattern in `Cmd::Search`). + use std::io::IsTerminal; + let color = std::io::stdout().is_terminal(); + let mut out = std::io::stdout().lock(); + render_ask_plain_citations(&mut out, &ans, color)?; } } // 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) { println!("kebab v{}", s.kebab_version); println!(); @@ -955,3 +989,107 @@ fn confirm_destructive( 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) -> 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}" + ); + } +} + diff --git a/crates/kebab-cli/tests/wire_ask_stale.rs b/crates/kebab-cli/tests/wire_ask_stale.rs new file mode 100644 index 0000000..50ad7dc --- /dev/null +++ b/crates/kebab-cli/tests/wire_ask_stale.rs @@ -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}" + ); + } +}