Skip to main content

everruns_core/
annotation_hook.rs

1//! End-of-message citation annotation seam.
2//!
3//! A mutating sibling of the post-generation guardrail family in
4//! [`crate::output_guardrail`]. Where a guardrail runs once on the finalized
5//! assistant message and returns a block/allow decision, an annotation hook
6//! runs at the same point and returns citation [`TextAnnotation`]s to attach to
7//! the message text — optionally rewriting the text first (e.g. to strip
8//! citation markers the model emitted). Contributed by citation capabilities
9//! via `Capability::post_output_annotation_hooks_with_config()`. See
10//! `knowledge/runtime-resources/citations.md`.
11//!
12//! Contract: implementations MUST be internally time-bounded and **fail open** —
13//! any error must yield an empty [`AnnotationResult`] so an annotator outage
14//! never wedges a turn. Returned spans MUST fall within the char bounds of the
15//! text the hook received; the runner discards out-of-range spans.
16
17use std::sync::Arc;
18
19use async_trait::async_trait;
20
21use crate::message::{Message, TextAnnotation};
22
23/// Async, end-of-message hook that attaches citation annotations to the
24/// finalized assistant text.
25#[async_trait]
26pub trait PostGenerationAnnotationHook: Send + Sync {
27    /// Stable identifier, usually the contributing capability id.
28    fn id(&self) -> &str;
29
30    /// Produce annotations (and optional text rewrite) for the finalized
31    /// message. Must fail open (empty result) on any error.
32    async fn annotate(&self, ctx: &AnnotationContext<'_>) -> AnnotationResult;
33}
34
35/// Runtime context handed to a [`PostGenerationAnnotationHook`]. Borrowed for
36/// the duration of the `annotate` call.
37pub struct AnnotationContext<'a> {
38    /// The fully assembled system prompt for this turn.
39    pub system_prompt: &'a str,
40    /// The current assistant message text (possibly already rewritten by an
41    /// earlier hook). Annotation spans are relative to this.
42    pub message_text: &'a str,
43    /// The assembled conversation context sent to the model this turn. Feeds
44    /// like `citation_retrieval` scan it for the tool-result citations they
45    /// align to claim spans.
46    pub messages: &'a [Message],
47    /// Utility LLM service for model-backed alignment/verification. `None` when
48    /// the deployment has no utility model configured.
49    pub utility_llm_service: Option<&'a Arc<dyn crate::UtilityLlmService>>,
50}
51
52/// What a hook returns: annotations to attach, plus an optional replacement for
53/// the message text.
54#[derive(Debug, Default, Clone)]
55pub struct AnnotationResult {
56    pub annotations: Vec<TextAnnotation>,
57    /// When set, replaces the message text before annotations are applied (e.g.
58    /// to strip inline citation markers). Spans in `annotations` are relative
59    /// to this rewritten text.
60    pub rewritten_text: Option<String>,
61}
62
63impl AnnotationResult {
64    /// An empty result (no annotations, no rewrite) — the fail-open value.
65    pub fn none() -> Self {
66        Self::default()
67    }
68}
69
70/// An annotation hook paired with its contributing capability id.
71pub struct AnnotationProvider {
72    pub capability_id: String,
73    pub provider: Arc<dyn PostGenerationAnnotationHook>,
74}
75
76/// Outcome of running all annotation providers over a message.
77#[derive(Debug, Default, Clone)]
78pub struct CollectedAnnotations {
79    /// Final message text after any hook rewrites.
80    pub text: String,
81    /// All valid annotations, in provider-registration order.
82    pub annotations: Vec<TextAnnotation>,
83}
84
85/// Run annotation providers in registration order, threading the (possibly
86/// rewritten) text through each so a later hook sees an earlier hook's rewrite.
87///
88/// Spans that fall outside the current text's char bounds are dropped. When a
89/// provider rewrites the text, previously-collected annotations are discarded
90/// (their offsets are no longer valid against the new text) — in practice a
91/// single citation feed is active per agent, so the common path is one
92/// provider. Pure orchestration: each provider owns its own fail-open behavior.
93pub async fn collect_annotations(
94    providers: &[AnnotationProvider],
95    system_prompt: &str,
96    message_text: &str,
97    messages: &[Message],
98    utility_llm_service: Option<&Arc<dyn crate::UtilityLlmService>>,
99) -> CollectedAnnotations {
100    let mut text = message_text.to_string();
101    let mut annotations: Vec<TextAnnotation> = Vec::new();
102
103    for p in providers {
104        let ctx = AnnotationContext {
105            system_prompt,
106            message_text: &text,
107            messages,
108            utility_llm_service,
109        };
110        let result = p.provider.annotate(&ctx).await;
111
112        if let Some(rewritten) = result.rewritten_text {
113            // A rewrite invalidates earlier annotations' offsets.
114            if rewritten != text {
115                annotations.clear();
116            }
117            text = rewritten;
118        }
119
120        let char_len = text.chars().count();
121        for ann in result.annotations {
122            if ann.start < ann.end && ann.end <= char_len {
123                annotations.push(ann);
124            }
125        }
126    }
127
128    CollectedAnnotations { text, annotations }
129}
130
131// ---------------------------------------------------------------------------
132// Citation verification seam
133// ---------------------------------------------------------------------------
134
135/// Async verifier that stamps `verified` verdicts onto citations produced by
136/// any feed.
137///
138/// Contributed by the `citation_verification` capability via
139/// `Capability::citation_verifier_with_config`. Runs once after all annotation
140/// feeds, over the collected annotations — decoupled from the feeds so any feed
141/// can be paired with any verifier (see `knowledge/runtime-resources/citations.md`).
142///
143/// Contract: **fail open** — on any error, return the annotations unchanged
144/// (unverified) rather than dropping them. Implementations must preserve order
145/// and count.
146#[async_trait]
147pub trait CitationVerifier: Send + Sync {
148    /// Stable identifier, usually the contributing capability id.
149    fn id(&self) -> &str;
150
151    /// Return `annotations` with `verified` stamped where a verdict could be
152    /// produced.
153    async fn verify(
154        &self,
155        ctx: &VerificationContext<'_>,
156        annotations: Vec<TextAnnotation>,
157    ) -> Vec<TextAnnotation>;
158}
159
160/// Runtime context handed to a [`CitationVerifier`].
161pub struct VerificationContext<'a> {
162    /// The finalized assistant message text; annotation spans index into it.
163    pub message_text: &'a str,
164    /// Utility LLM service for model-backed verification. `None` when the
165    /// deployment has no utility model configured.
166    pub utility_llm_service: Option<&'a Arc<dyn crate::UtilityLlmService>>,
167}
168
169/// A verifier paired with its contributing capability id.
170pub struct VerifierProvider {
171    pub capability_id: String,
172    pub provider: Arc<dyn CitationVerifier>,
173}
174
175/// Run the configured verifiers, in registration order, over the annotations.
176/// Typically zero or one verifier is active. Pure orchestration; each verifier
177/// owns its fail-open behavior.
178pub async fn verify_annotations(
179    verifiers: &[VerifierProvider],
180    message_text: &str,
181    utility_llm_service: Option<&Arc<dyn crate::UtilityLlmService>>,
182    mut annotations: Vec<TextAnnotation>,
183) -> Vec<TextAnnotation> {
184    for v in verifiers {
185        let ctx = VerificationContext {
186            message_text,
187            utility_llm_service,
188        };
189        annotations = v.provider.verify(&ctx, annotations).await;
190    }
191    annotations
192}
193
194// ---------------------------------------------------------------------------
195// Shared citation text helpers (used by feeds and verifiers)
196// ---------------------------------------------------------------------------
197
198/// Lowercase word tokens of length ≥ 3, dropping a small stopword set so
199/// lexical overlap reflects distinctive content rather than filler.
200pub fn citation_tokens(text: &str) -> Vec<String> {
201    text.split(|c: char| !c.is_alphanumeric())
202        .filter(|w| w.len() >= 3)
203        .map(|w| w.to_lowercase())
204        .filter(|w| !is_stopword(w))
205        .collect()
206}
207
208fn is_stopword(word: &str) -> bool {
209    const STOPWORDS: &[&str] = &[
210        "the", "and", "for", "are", "was", "were", "this", "that", "with", "from", "have", "has",
211        "not", "but", "you", "your", "its", "their", "they", "them", "then", "than", "which",
212        "into", "onto", "over", "under", "about", "there", "here", "what", "when", "where",
213    ];
214    STOPWORDS.contains(&word)
215}
216
217/// Fraction of `needle`'s distinct tokens that also appear in `haystack`.
218/// Returns 0.0 when `needle` is empty.
219pub fn token_overlap_ratio(needle: &[String], haystack: &[String]) -> f32 {
220    if needle.is_empty() {
221        return 0.0;
222    }
223    let hay: std::collections::HashSet<&String> = haystack.iter().collect();
224    let distinct: std::collections::HashSet<&String> = needle.iter().collect();
225    let shared = distinct.iter().filter(|t| hay.contains(**t)).count();
226    shared as f32 / distinct.len() as f32
227}
228
229/// The substring of `text` covered by a char span `[start, end)`.
230pub fn span_text(text: &str, start: usize, end: usize) -> String {
231    text.chars()
232        .skip(start)
233        .take(end.saturating_sub(start))
234        .collect()
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::message::AnnotationSource;
241
242    fn ann(start: usize, end: usize) -> TextAnnotation {
243        TextAnnotation {
244            start,
245            end,
246            origin: "test".to_string(),
247            source: AnnotationSource {
248                uri: "https://example.com".to_string(),
249                title: None,
250                snippet: None,
251                location: None,
252            },
253            external_id: None,
254            verified: None,
255        }
256    }
257
258    struct FixedHook {
259        annotations: Vec<TextAnnotation>,
260        rewritten: Option<String>,
261    }
262
263    #[async_trait]
264    impl PostGenerationAnnotationHook for FixedHook {
265        fn id(&self) -> &str {
266            "fixed"
267        }
268        async fn annotate(&self, _ctx: &AnnotationContext<'_>) -> AnnotationResult {
269            AnnotationResult {
270                annotations: self.annotations.clone(),
271                rewritten_text: self.rewritten.clone(),
272            }
273        }
274    }
275
276    fn provider(hook: FixedHook) -> AnnotationProvider {
277        AnnotationProvider {
278            capability_id: "test".to_string(),
279            provider: Arc::new(hook),
280        }
281    }
282
283    #[tokio::test]
284    async fn keeps_in_bounds_and_drops_out_of_bounds_spans() {
285        let providers = vec![provider(FixedHook {
286            annotations: vec![ann(0, 5), ann(3, 100)],
287            rewritten: None,
288        })];
289        let out = collect_annotations(&providers, "", "hello world", &[], None).await;
290        assert_eq!(out.text, "hello world");
291        assert_eq!(out.annotations.len(), 1);
292        assert_eq!((out.annotations[0].start, out.annotations[0].end), (0, 5));
293    }
294
295    #[tokio::test]
296    async fn rewrite_replaces_text_and_resets_prior_annotations() {
297        let providers = vec![
298            provider(FixedHook {
299                annotations: vec![ann(0, 4)],
300                rewritten: None,
301            }),
302            provider(FixedHook {
303                annotations: vec![ann(0, 2)],
304                rewritten: Some("hi".to_string()),
305            }),
306        ];
307        let out = collect_annotations(&providers, "", "hello", &[], None).await;
308        assert_eq!(out.text, "hi");
309        // First hook's (0,4) annotation is discarded by the rewrite; only the
310        // rewriting hook's own (0,2) survives.
311        assert_eq!(out.annotations.len(), 1);
312        assert_eq!((out.annotations[0].start, out.annotations[0].end), (0, 2));
313    }
314
315    #[tokio::test]
316    async fn drops_span_out_of_bounds_after_rewrite() {
317        let providers = vec![provider(FixedHook {
318            annotations: vec![ann(0, 5)],
319            rewritten: Some("hi".to_string()),
320        })];
321        let out = collect_annotations(&providers, "", "hello", &[], None).await;
322        assert_eq!(out.text, "hi");
323        assert!(out.annotations.is_empty());
324    }
325}