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:
@@ -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