use std::path::Path;
use sha2::{Digest, Sha256};
use super::SCHEMA_VERSION;
use super::cache::{self, CachedNarrative};
use super::citation::check_citations;
use super::client::ChatClient;
use super::prompt::{self, Lens, PROMPT_VERSION};
use crate::Result;
use crate::quality_gates::ledger::now_utc_ts;
#[derive(Debug, Clone)]
pub struct NarrativeResult {
pub narrative: String,
pub grounded: bool,
pub unmatched: Vec<String>,
pub model: String,
pub from_cache: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct SheetFacts<'a> {
pub text: &'a str,
pub values: &'a [f64],
}
pub fn narrate(
client: &dyn ChatClient,
lens: Lens,
subject: &str,
facts: SheetFacts<'_>,
cache_root: &Path,
repo_path: &Path,
refresh: bool,
) -> Result<NarrativeResult> {
let model = client.model_id().to_string();
let key = cache::cache_key(facts.text, &model);
if !refresh && let Some(hit) = cache::read(cache_root, repo_path, &key) {
let groundedness = check_citations(&hit.narrative, facts.values);
return Ok(NarrativeResult {
narrative: hit.narrative,
grounded: groundedness.grounded,
unmatched: groundedness.unmatched,
model: hit.model,
from_cache: true,
});
}
let narrative = client.complete(
prompt::system_prompt(lens),
&prompt::user_prompt(lens, facts.text),
)?;
let groundedness = check_citations(&narrative, facts.values);
let entry = CachedNarrative {
narrative: narrative.clone(),
subject: subject.to_string(),
grounded: groundedness.grounded,
unmatched: groundedness.unmatched.clone(),
model: model.clone(),
prompt_version: PROMPT_VERSION,
schema_version: SCHEMA_VERSION,
fact_digest: fact_digest(facts.text),
created_at: now_utc_ts(),
};
cache::write(cache_root, repo_path, &key, &entry);
Ok(NarrativeResult {
narrative,
grounded: groundedness.grounded,
unmatched: groundedness.unmatched,
model,
from_cache: false,
})
}
const STAMP_UNCITED_PREVIEW: usize = 5;
#[must_use]
pub fn stamp(result: &NarrativeResult) -> String {
let model = &result.model;
if result.grounded {
format!("advisory — model {model}, grounded ✓")
} else {
let unmatched = &result.unmatched;
let list = unmatched
.iter()
.take(STAMP_UNCITED_PREVIEW)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let more = unmatched.len().saturating_sub(STAMP_UNCITED_PREVIEW);
if more > 0 {
format!("advisory — model {model}, ⚠ contains uncited claims: {list} (+{more} more)")
} else {
format!("advisory — model {model}, ⚠ contains uncited claims: {list}")
}
}
}
fn fact_digest(fact_sheet_text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(fact_sheet_text.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::{NarrativeResult, SheetFacts, narrate, stamp};
use crate::Result;
use crate::enrichment::client::ChatClient;
use crate::enrichment::prompt::Lens;
use std::cell::Cell;
struct MockChatClient {
reply: String,
model: String,
calls: Cell<usize>,
}
impl ChatClient for MockChatClient {
fn complete(&self, _system: &str, _user: &str) -> Result<String> {
self.calls.set(self.calls.get() + 1);
Ok(self.reply.clone())
}
fn model_id(&self) -> &str {
&self.model
}
}
fn result_with(grounded: bool) -> NarrativeResult {
NarrativeResult {
narrative: "text".to_string(),
grounded,
unmatched: if grounded {
Vec::new()
} else {
vec!["4200".to_string()]
},
model: "mock-model".to_string(),
from_cache: false,
}
}
#[test]
fn stamp_renders_the_grounded_verdict() {
let s = stamp(&result_with(true));
assert!(s.contains("mock-model"), "stamp names the model: {s}");
assert!(s.contains("grounded ✓"), "grounded stamp: {s}");
assert!(
!s.contains("uncited"),
"grounded stamp omits the warning: {s}"
);
}
#[test]
fn stamp_renders_the_uncited_verdict() {
let mut result = result_with(false);
result.unmatched = vec!["42.5%".to_string(), "-0.5".to_string()];
let s = stamp(&result);
assert!(s.contains("mock-model"), "stamp names the model: {s}");
assert!(
s.contains("⚠ contains uncited claims: 42.5%, -0.5"),
"uncited stamp names the tokens: {s}"
);
}
#[test]
fn stamp_truncates_the_uncited_list_after_five() {
let mut result = result_with(false);
result.unmatched = vec![
"1".to_string(),
"2".to_string(),
"3".to_string(),
"4".to_string(),
"5".to_string(),
"6".to_string(),
"7".to_string(),
];
let s = stamp(&result);
assert!(s.contains("(+2 more)"), "truncated stamp: {s}");
}
#[test]
#[cfg(feature = "test-support")]
fn narrate_misses_then_hits_then_refresh_regenerates() {
let cache_root = tempfile::tempdir().expect("cache root");
let repo = std::path::Path::new("/tmp/repo");
let sheet = "code-health\n score = 87.5\n";
let values = [87.5];
let facts = SheetFacts {
text: sheet,
values: &values,
};
let client = MockChatClient {
reply: "Diagnosis: the score is 87.5.".to_string(),
model: "mock-model".to_string(),
calls: Cell::new(0),
};
let first = narrate(
&client,
Lens::FileDiagnosis,
"src/subject.rs",
facts,
cache_root.path(),
repo,
false,
)
.expect("first narrate");
assert!(!first.from_cache, "a cold cache misses");
assert!(first.grounded, "87.5 is grounded by the fact value");
assert_eq!(client.calls.get(), 1);
let second = narrate(
&client,
Lens::FileDiagnosis,
"src/subject.rs",
facts,
cache_root.path(),
repo,
false,
)
.expect("second narrate");
assert!(second.from_cache, "a warm cache hits");
assert_eq!(second.narrative, first.narrative);
assert_eq!(client.calls.get(), 1, "a hit does not reach the model");
let refreshed = narrate(
&client,
Lens::FileDiagnosis,
"src/subject.rs",
facts,
cache_root.path(),
repo,
true,
)
.expect("refresh narrate");
assert!(!refreshed.from_cache, "refresh bypasses the cache");
assert_eq!(client.calls.get(), 2, "refresh reaches the model again");
}
#[test]
#[cfg(feature = "test-support")]
fn cache_hit_serves_a_freshly_recomputed_citation_verdict() {
use super::cache::{self, CachedNarrative};
use crate::enrichment::SCHEMA_VERSION;
use crate::enrichment::prompt::PROMPT_VERSION;
let cache_root = tempfile::tempdir().expect("cache root");
let repo = std::path::Path::new("/tmp/repo");
let sheet = "code-health\n score = 87.5\n";
let values = [87.5];
let facts = SheetFacts {
text: sheet,
values: &values,
};
let model = "mock-model";
let key = cache::cache_key(sheet, model);
let entry = CachedNarrative {
narrative: "Diagnosis: a delta of -0.5 appears.".to_string(),
subject: "src/subject.rs".to_string(),
grounded: true,
unmatched: Vec::new(),
model: model.to_string(),
prompt_version: PROMPT_VERSION,
schema_version: SCHEMA_VERSION,
fact_digest: "stale-digest".to_string(),
created_at: "2025-01-01T00:00:00Z".to_string(),
};
cache::write(cache_root.path(), repo, &key, &entry);
let client = MockChatClient {
reply: "unused — this call must hit the cache".to_string(),
model: model.to_string(),
calls: Cell::new(0),
};
let hit = narrate(
&client,
Lens::FileDiagnosis,
"src/subject.rs",
facts,
cache_root.path(),
repo,
false,
)
.expect("narrate hits the hand-written entry");
assert!(hit.from_cache, "the entry is served from the cache");
assert_eq!(client.calls.get(), 0, "a hit never reaches the model");
assert!(
!hit.grounded,
"the recomputed verdict catches the ungrounded -0.5 rather than \
trusting the stored grounded:true"
);
assert_eq!(
hit.unmatched,
vec!["-0.5".to_string()],
"the recomputed unmatched list names the signed token"
);
}
}