feat(rag): AnswerCitation inherits indexed_at + stale from hit (fb-32)
pack_context widened to carry indexed_at + stale alongside marker and Citation. LLM-citation construction site now plumbs real values from upstream SearchHit instead of the Task 6 UNIX_EPOCH placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,11 +44,24 @@ use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// One entry in the packed context returned by
|
||||
/// [`RagPipeline::pack_context`]. Carries the marker number, the
|
||||
/// upstream `Citation`, and the per-hit `indexed_at` + `stale` so the
|
||||
/// LLM-citation construction site can build a complete
|
||||
/// [`kebab_core::AnswerCitation`] (p9-fb-32).
|
||||
#[derive(Clone, Debug)]
|
||||
struct PackedCitation {
|
||||
marker: u32,
|
||||
citation: Citation,
|
||||
indexed_at: OffsetDateTime,
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
/// Tuple returned by [`RagPipeline::pack_context`]: the packed
|
||||
/// `[#n] doc=… heading=… span=…\n<text>` block, the marker→Citation
|
||||
/// `[#n] doc=… heading=… span=…\n<text>` block, the marker→PackedCitation
|
||||
/// mapping (in packed order), and an estimated token count for the
|
||||
/// prompt section the LLM will see (system + query + packed context).
|
||||
type PackedContext = (String, Vec<(u32, Citation)>, usize);
|
||||
type PackedContext = (String, Vec<PackedCitation>, usize);
|
||||
|
||||
// ── AskOpts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,10 +185,20 @@ impl RagPipeline {
|
||||
k: k_effective,
|
||||
filters: SearchFilters::default(),
|
||||
};
|
||||
let hits = self
|
||||
let mut hits = self
|
||||
.retriever
|
||||
.search(&search_query)
|
||||
.context("kb-rag: retriever.search")?;
|
||||
// p9-fb-32: stamp `stale` on every hit against `now_utc()` and
|
||||
// the configured threshold. Cheap (per-hit comparison). Both
|
||||
// the score-gate refusal path and the LLM-citation path read
|
||||
// `hit.stale` downstream, so stamping once here keeps both
|
||||
// call sites aligned with the App-level `search` post-process.
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let stale_threshold_days = self.config.search.stale_threshold_days;
|
||||
for h in &mut hits {
|
||||
h.stale = compute_stale(h.indexed_at, now, stale_threshold_days);
|
||||
}
|
||||
let chunks_returned = u32::try_from(hits.len()).unwrap_or(u32::MAX);
|
||||
let top_score = hits.first().map(|h| h.retrieval.fusion_score).unwrap_or(0.0);
|
||||
|
||||
@@ -302,7 +325,7 @@ impl RagPipeline {
|
||||
|
||||
// ── 7. Citation validate ───────────────────────────────────────────
|
||||
let valid_markers: std::collections::BTreeSet<u32> =
|
||||
packed_entries.iter().map(|(n, _)| *n).collect();
|
||||
packed_entries.iter().map(|p| p.marker).collect();
|
||||
let unknown_markers: Vec<u32> = extracted
|
||||
.iter()
|
||||
.copied()
|
||||
@@ -335,18 +358,19 @@ impl RagPipeline {
|
||||
let cited_set: std::collections::BTreeSet<u32> = extracted.iter().copied().collect();
|
||||
let citations: Vec<AnswerCitation> = packed_entries
|
||||
.iter()
|
||||
.filter(|(n, _)| cited_set.contains(n))
|
||||
.map(|(n, c)| AnswerCitation {
|
||||
.filter(|p| cited_set.contains(&p.marker))
|
||||
.map(|p| AnswerCitation {
|
||||
// Wire-format marker per design §2.3: bare bracketed form
|
||||
// `[1]`. The `[#1]` form is the *prompt-side* citation
|
||||
// grammar (what the LLM emits in its text); the wire-side
|
||||
// `AnswerCitation.marker` strips the `#`.
|
||||
marker: Some(format!("[{n}]")),
|
||||
citation: c.clone(),
|
||||
// p9-fb-32: placeholder — Task 7 owns wiring real
|
||||
// indexed_at / stale here from the underlying SearchHit.
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
stale: false,
|
||||
marker: Some(format!("[{}]", p.marker)),
|
||||
citation: p.citation.clone(),
|
||||
// p9-fb-32: real values from the upstream SearchHit
|
||||
// (post-processed for `stale` against the configured
|
||||
// threshold at retrieval time — see `ask` body).
|
||||
indexed_at: p.indexed_at,
|
||||
stale: p.stale,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -411,10 +435,10 @@ impl RagPipeline {
|
||||
// `kb explain` can reconstruct what was sent to the LLM.
|
||||
let v: Vec<_> = packed_entries
|
||||
.iter()
|
||||
.map(|(n, c)| {
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"marker": n,
|
||||
"citation": c,
|
||||
"marker": p.marker,
|
||||
"citation": p.citation,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -446,7 +470,7 @@ impl RagPipeline {
|
||||
let budget_tokens = cap.saturating_sub(prompt_overhead_tokens);
|
||||
|
||||
let mut text = String::new();
|
||||
let mut entries: Vec<(u32, Citation)> = Vec::new();
|
||||
let mut entries: Vec<PackedCitation> = Vec::new();
|
||||
let mut tokens_so_far: usize = 0;
|
||||
let mut n: u32 = 1;
|
||||
|
||||
@@ -479,7 +503,19 @@ impl RagPipeline {
|
||||
break;
|
||||
}
|
||||
text.push_str(&block);
|
||||
entries.push((n, hit.citation.clone()));
|
||||
// p9-fb-32: forward indexed_at + stale from the upstream
|
||||
// SearchHit so the LLM-citation construction site can build
|
||||
// a complete AnswerCitation (replaces Task 6's UNIX_EPOCH
|
||||
// placeholder). `hit.stale` is stamped by the pipeline
|
||||
// entry (`ask`) right after `retriever.search`, so by the
|
||||
// time this method runs it already reflects the
|
||||
// configured threshold.
|
||||
entries.push(PackedCitation {
|
||||
marker: n,
|
||||
citation: hit.citation.clone(),
|
||||
indexed_at: hit.indexed_at,
|
||||
stale: hit.stale,
|
||||
});
|
||||
tokens_so_far = next_total;
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
@@ -623,6 +659,25 @@ impl RagPipeline {
|
||||
/// paths attach the configured embedding model so `kb explain` can
|
||||
/// later identify which embedder shaped the retrieval (even on
|
||||
/// refusals — see `refuse_score_gate`).
|
||||
/// p9-fb-32: pipeline-local mirror of `kebab_app::staleness::compute_stale`.
|
||||
/// Duplicated here (rather than imported) because `kebab-rag` cannot
|
||||
/// depend on `kebab-app` — that would invert the crate-stack dependency
|
||||
/// direction. The `App::search` post-process and this helper share a
|
||||
/// behavioral contract: `now - indexed_at > threshold_days * 24h`,
|
||||
/// strict `>` so exactly-threshold hits stay fresh, and
|
||||
/// `threshold_days = 0` short-circuits to `false` (feature off).
|
||||
fn compute_stale(
|
||||
indexed_at: OffsetDateTime,
|
||||
now: OffsetDateTime,
|
||||
threshold_days: u32,
|
||||
) -> bool {
|
||||
if threshold_days == 0 {
|
||||
return false;
|
||||
}
|
||||
let threshold = time::Duration::days(i64::from(threshold_days));
|
||||
(now - indexed_at) > threshold
|
||||
}
|
||||
|
||||
fn embedding_ref_for(mode: SearchMode, cfg: &kebab_config::Config) -> Option<ModelRef> {
|
||||
match mode {
|
||||
SearchMode::Lexical => None,
|
||||
|
||||
@@ -116,6 +116,29 @@ pub fn mk_hit(
|
||||
workspace_path: &str,
|
||||
fusion_score: f32,
|
||||
heading: &[&str],
|
||||
) -> SearchHit {
|
||||
mk_hit_with_indexed_at(
|
||||
rank,
|
||||
chunk_id,
|
||||
doc_id,
|
||||
workspace_path,
|
||||
fusion_score,
|
||||
heading,
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a `SearchHit` with an explicit `indexed_at` timestamp. Used by
|
||||
/// p9-fb-32 staleness tests so the pipeline sees realistic per-hit
|
||||
/// indexed_at values flowing through to `AnswerCitation`.
|
||||
pub fn mk_hit_with_indexed_at(
|
||||
rank: u32,
|
||||
chunk_id: &str,
|
||||
doc_id: &str,
|
||||
workspace_path: &str,
|
||||
fusion_score: f32,
|
||||
heading: &[&str],
|
||||
indexed_at: time::OffsetDateTime,
|
||||
) -> SearchHit {
|
||||
let p = WorkspacePath::new(workspace_path.to_string()).expect("workspace path valid");
|
||||
SearchHit {
|
||||
@@ -143,8 +166,9 @@ pub fn mk_hit(
|
||||
index_version: IndexVersion("test-iv".to_string()),
|
||||
embedding_model: None,
|
||||
chunker_version: ChunkerVersion("v1".to_string()),
|
||||
// p9-fb-32: synthetic test mock — staleness not exercised here.
|
||||
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
// p9-fb-32: pipeline post-processes `stale` from `indexed_at`
|
||||
// + cfg threshold; tests configure both via this helper.
|
||||
indexed_at,
|
||||
stale: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ mod common;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use common::{MockRetriever, RagEnv, id32, mk_hit};
|
||||
use common::{MockRetriever, RagEnv, id32, mk_hit, mk_hit_with_indexed_at};
|
||||
use kebab_core::{
|
||||
FinishReason, LanguageModel, Retriever, SearchMode, TokenChunk, TokenUsage,
|
||||
};
|
||||
@@ -421,6 +421,73 @@ fn unfetchable_chunks_fall_back_to_no_chunks() {
|
||||
assert_eq!(env.count_answers(), 1, "answers row written for refusal");
|
||||
}
|
||||
|
||||
// ── 16. p9-fb-32: AnswerCitation carries indexed_at + stale ──────────────
|
||||
//
|
||||
// Previously the LLM-citation construction site stamped `UNIX_EPOCH` +
|
||||
// `false` as a Task-7 placeholder. Task 7 plumbs real values from the
|
||||
// upstream `SearchHit` through `pack_context` so the wire-side
|
||||
// `AnswerCitation` reflects the document's actual age.
|
||||
|
||||
#[test]
|
||||
fn grounded_citations_inherit_indexed_at_and_stale_from_hit() {
|
||||
let env = RagEnv::new();
|
||||
let cid = id32("c1");
|
||||
let did = id32("d1");
|
||||
env.seed_chunk(&cid, &did, "notes/a.md", "Apples are fruit.", &["Intro"]);
|
||||
// 60 days old vs. the default 30-day threshold → stale.
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let sixty_days_ago = now - time::Duration::days(60);
|
||||
let hits = vec![mk_hit_with_indexed_at(
|
||||
1, &cid, &did, "notes/a.md", 0.85, &["Intro"], sixty_days_ago,
|
||||
)];
|
||||
let retriever: Arc<dyn Retriever> = Arc::new(MockRetriever::new(hits));
|
||||
let lm: Arc<dyn LanguageModel> = Arc::new(CountingLm::new("apples are fruit. [#1]"));
|
||||
let pipeline = RagPipeline::new(env.config.clone(), retriever, lm, env.sqlite.clone());
|
||||
|
||||
let answer = pipeline.ask("apples", default_opts()).unwrap();
|
||||
assert!(answer.grounded);
|
||||
assert_eq!(answer.citations.len(), 1, "one cited marker [#1]");
|
||||
let c = &answer.citations[0];
|
||||
// indexed_at must be the value the retriever produced — NOT the
|
||||
// UNIX_EPOCH placeholder the Task 6 cross-task patch left behind.
|
||||
assert_eq!(
|
||||
c.indexed_at, sixty_days_ago,
|
||||
"AnswerCitation.indexed_at must inherit from SearchHit.indexed_at"
|
||||
);
|
||||
// 60d > default 30d threshold → stale.
|
||||
assert!(
|
||||
c.stale,
|
||||
"60-day-old hit must surface stale=true on the AnswerCitation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grounded_citations_not_stale_for_fresh_hit() {
|
||||
let env = RagEnv::new();
|
||||
let cid = id32("c1");
|
||||
let did = id32("d1");
|
||||
env.seed_chunk(&cid, &did, "notes/a.md", "Apples are fruit.", &["Intro"]);
|
||||
// 1 day old vs. the default 30-day threshold → fresh.
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let one_day_ago = now - time::Duration::days(1);
|
||||
let hits = vec![mk_hit_with_indexed_at(
|
||||
1, &cid, &did, "notes/a.md", 0.85, &["Intro"], one_day_ago,
|
||||
)];
|
||||
let retriever: Arc<dyn Retriever> = Arc::new(MockRetriever::new(hits));
|
||||
let lm: Arc<dyn LanguageModel> = Arc::new(CountingLm::new("apples are fruit. [#1]"));
|
||||
let pipeline = RagPipeline::new(env.config.clone(), retriever, lm, env.sqlite.clone());
|
||||
|
||||
let answer = pipeline.ask("apples", default_opts()).unwrap();
|
||||
assert!(answer.grounded);
|
||||
assert_eq!(answer.citations.len(), 1);
|
||||
let c = &answer.citations[0];
|
||||
assert_eq!(c.indexed_at, one_day_ago);
|
||||
assert!(
|
||||
!c.stale,
|
||||
"1-day-old hit must NOT be stale at default 30d threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 15. snapshot Answer JSON stable ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user