use crate::store::ScoredChunk;
use anyhow::Result;
use async_trait::async_trait;
use dyn_clone::DynClone;
#[async_trait]
pub trait CitationFormatter: Send + Sync + std::fmt::Debug + DynClone {
async fn format(&self, chunks: &[ScoredChunk], budget_chars: usize) -> Result<String>;
}
dyn_clone::clone_trait_object!(CitationFormatter);
#[derive(Clone, Debug, Default)]
pub struct DefaultCitationFormatter;
#[async_trait]
impl CitationFormatter for DefaultCitationFormatter {
async fn format(&self, chunks: &[ScoredChunk], budget_chars: usize) -> Result<String> {
let mut ctx = String::new();
for sc in chunks {
let snippet = format!(
"[Document: {} | Corpus: {} | Score: {:.4}]\n{}\n---\n",
sc.chunk.document, sc.chunk.provenance.corpus, sc.score, sc.chunk.text
);
if ctx.len() + snippet.len() > budget_chars {
break;
}
ctx.push_str(&snippet);
}
Ok(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DocumentChunk, DocumentId, PipelineProvenance, RankScore};
fn chunk(text: &str, document: &str, corpus: &str, score: f64) -> ScoredChunk {
ScoredChunk {
score: RankScore::rrf(score),
chunk: DocumentChunk {
text: text.to_string(),
document: DocumentId::from(document),
meta: Default::default(),
provenance: PipelineProvenance::new_with_corpus(
corpus, "unpdf", "markdown", "test",
),
},
}
}
#[tokio::test]
async fn default_formatter_matches_legacy_snippet_format() {
let chunks = vec![
chunk("first chunk", "a.pdf", "papers", 0.0123),
chunk("second chunk", "b.pdf", "papers", 0.0089),
];
let ctx = DefaultCitationFormatter
.format(&chunks, 10_000)
.await
.unwrap();
let expected = "[Document: a.pdf | Corpus: papers | Score: 0.0123]\nfirst chunk\n---\n\
[Document: b.pdf | Corpus: papers | Score: 0.0089]\nsecond chunk\n---\n";
assert_eq!(ctx, expected);
}
#[tokio::test]
async fn default_formatter_respects_budget() {
let chunks = vec![
chunk("first chunk", "a.pdf", "papers", 0.01),
chunk("second chunk", "b.pdf", "papers", 0.02),
];
let one = DefaultCitationFormatter
.format(&chunks[..1], 10_000)
.await
.unwrap();
let budget = one.len() + 5;
let ctx = DefaultCitationFormatter
.format(&chunks, budget)
.await
.unwrap();
assert_eq!(ctx, one);
assert!(!ctx.contains("second chunk"));
}
#[test]
fn trait_is_object_safe_and_cloneable() {
fn accepts(f: &dyn CitationFormatter) -> &'static str {
let _ = f;
"ok"
}
let default = DefaultCitationFormatter;
assert_eq!(accepts(&default), "ok");
let _cloned: Box<dyn CitationFormatter> = Box::new(default.clone());
}
}