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}