use std::sync::Arc;
use async_trait::async_trait;
use crate::message::{Message, TextAnnotation};
#[async_trait]
pub trait PostGenerationAnnotationHook: Send + Sync {
fn id(&self) -> &str;
async fn annotate(&self, ctx: &AnnotationContext<'_>) -> AnnotationResult;
}
pub struct AnnotationContext<'a> {
pub system_prompt: &'a str,
pub message_text: &'a str,
pub messages: &'a [Message],
pub utility_llm_service: Option<&'a Arc<dyn crate::UtilityLlmService>>,
}
#[derive(Debug, Default, Clone)]
pub struct AnnotationResult {
pub annotations: Vec<TextAnnotation>,
pub rewritten_text: Option<String>,
}
impl AnnotationResult {
pub fn none() -> Self {
Self::default()
}
}
pub struct AnnotationProvider {
pub capability_id: String,
pub provider: Arc<dyn PostGenerationAnnotationHook>,
}
#[derive(Debug, Default, Clone)]
pub struct CollectedAnnotations {
pub text: String,
pub annotations: Vec<TextAnnotation>,
}
pub async fn collect_annotations(
providers: &[AnnotationProvider],
system_prompt: &str,
message_text: &str,
messages: &[Message],
utility_llm_service: Option<&Arc<dyn crate::UtilityLlmService>>,
) -> CollectedAnnotations {
let mut text = message_text.to_string();
let mut annotations: Vec<TextAnnotation> = Vec::new();
for p in providers {
let ctx = AnnotationContext {
system_prompt,
message_text: &text,
messages,
utility_llm_service,
};
let result = p.provider.annotate(&ctx).await;
if let Some(rewritten) = result.rewritten_text {
if rewritten != text {
annotations.clear();
}
text = rewritten;
}
let char_len = text.chars().count();
for ann in result.annotations {
if ann.start < ann.end && ann.end <= char_len {
annotations.push(ann);
}
}
}
CollectedAnnotations { text, annotations }
}
#[async_trait]
pub trait CitationVerifier: Send + Sync {
fn id(&self) -> &str;
async fn verify(
&self,
ctx: &VerificationContext<'_>,
annotations: Vec<TextAnnotation>,
) -> Vec<TextAnnotation>;
}
pub struct VerificationContext<'a> {
pub message_text: &'a str,
pub utility_llm_service: Option<&'a Arc<dyn crate::UtilityLlmService>>,
}
pub struct VerifierProvider {
pub capability_id: String,
pub provider: Arc<dyn CitationVerifier>,
}
pub async fn verify_annotations(
verifiers: &[VerifierProvider],
message_text: &str,
utility_llm_service: Option<&Arc<dyn crate::UtilityLlmService>>,
mut annotations: Vec<TextAnnotation>,
) -> Vec<TextAnnotation> {
for v in verifiers {
let ctx = VerificationContext {
message_text,
utility_llm_service,
};
annotations = v.provider.verify(&ctx, annotations).await;
}
annotations
}
pub fn citation_tokens(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 3)
.map(|w| w.to_lowercase())
.filter(|w| !is_stopword(w))
.collect()
}
fn is_stopword(word: &str) -> bool {
const STOPWORDS: &[&str] = &[
"the", "and", "for", "are", "was", "were", "this", "that", "with", "from", "have", "has",
"not", "but", "you", "your", "its", "their", "they", "them", "then", "than", "which",
"into", "onto", "over", "under", "about", "there", "here", "what", "when", "where",
];
STOPWORDS.contains(&word)
}
pub fn token_overlap_ratio(needle: &[String], haystack: &[String]) -> f32 {
if needle.is_empty() {
return 0.0;
}
let hay: std::collections::HashSet<&String> = haystack.iter().collect();
let distinct: std::collections::HashSet<&String> = needle.iter().collect();
let shared = distinct.iter().filter(|t| hay.contains(**t)).count();
shared as f32 / distinct.len() as f32
}
pub fn span_text(text: &str, start: usize, end: usize) -> String {
text.chars()
.skip(start)
.take(end.saturating_sub(start))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::AnnotationSource;
fn ann(start: usize, end: usize) -> TextAnnotation {
TextAnnotation {
start,
end,
origin: "test".to_string(),
source: AnnotationSource {
uri: "https://example.com".to_string(),
title: None,
snippet: None,
location: None,
},
external_id: None,
verified: None,
}
}
struct FixedHook {
annotations: Vec<TextAnnotation>,
rewritten: Option<String>,
}
#[async_trait]
impl PostGenerationAnnotationHook for FixedHook {
fn id(&self) -> &str {
"fixed"
}
async fn annotate(&self, _ctx: &AnnotationContext<'_>) -> AnnotationResult {
AnnotationResult {
annotations: self.annotations.clone(),
rewritten_text: self.rewritten.clone(),
}
}
}
fn provider(hook: FixedHook) -> AnnotationProvider {
AnnotationProvider {
capability_id: "test".to_string(),
provider: Arc::new(hook),
}
}
#[tokio::test]
async fn keeps_in_bounds_and_drops_out_of_bounds_spans() {
let providers = vec![provider(FixedHook {
annotations: vec![ann(0, 5), ann(3, 100)],
rewritten: None,
})];
let out = collect_annotations(&providers, "", "hello world", &[], None).await;
assert_eq!(out.text, "hello world");
assert_eq!(out.annotations.len(), 1);
assert_eq!((out.annotations[0].start, out.annotations[0].end), (0, 5));
}
#[tokio::test]
async fn rewrite_replaces_text_and_resets_prior_annotations() {
let providers = vec![
provider(FixedHook {
annotations: vec![ann(0, 4)],
rewritten: None,
}),
provider(FixedHook {
annotations: vec![ann(0, 2)],
rewritten: Some("hi".to_string()),
}),
];
let out = collect_annotations(&providers, "", "hello", &[], None).await;
assert_eq!(out.text, "hi");
assert_eq!(out.annotations.len(), 1);
assert_eq!((out.annotations[0].start, out.annotations[0].end), (0, 2));
}
#[tokio::test]
async fn drops_span_out_of_bounds_after_rewrite() {
let providers = vec![provider(FixedHook {
annotations: vec![ann(0, 5)],
rewritten: Some("hi".to_string()),
})];
let out = collect_annotations(&providers, "", "hello", &[], None).await;
assert_eq!(out.text, "hi");
assert!(out.annotations.is_empty());
}
}