everruns_core/capabilities/
citation_verification.rs1use std::sync::Arc;
20
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24
25use crate::annotation_hook::{
26 CitationVerifier, VerificationContext, citation_tokens, span_text, token_overlap_ratio,
27};
28use crate::capabilities::Capability;
29use crate::capability_types::CapabilityStatus;
30use crate::message::{TextAnnotation, VerificationStatus, VerificationVerdict};
31use crate::utility_llm::UtilityLlmRequest;
32
33pub const CITATION_VERIFICATION_CAPABILITY_ID: &str = "citation_verification";
35
36const DEFAULT_THRESHOLD: f32 = 0.5;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum VerificationMode {
44 #[default]
46 Heuristic,
47 Llm,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CitationVerificationConfig {
54 #[serde(default)]
55 pub mode: VerificationMode,
56 #[serde(default = "default_threshold")]
57 pub threshold: f32,
58}
59
60fn default_threshold() -> f32 {
61 DEFAULT_THRESHOLD
62}
63
64impl Default for CitationVerificationConfig {
65 fn default() -> Self {
66 Self {
67 mode: VerificationMode::default(),
68 threshold: DEFAULT_THRESHOLD,
69 }
70 }
71}
72
73impl CitationVerificationConfig {
74 fn from_value(config: &serde_json::Value) -> Self {
75 if config.is_null() {
76 return Self::default();
77 }
78 serde_json::from_value(config.clone()).unwrap_or_default()
79 }
80}
81
82pub struct CitationVerificationCapability;
84
85impl Capability for CitationVerificationCapability {
86 fn id(&self) -> &str {
87 CITATION_VERIFICATION_CAPABILITY_ID
88 }
89
90 fn name(&self) -> &str {
91 "Citation verification"
92 }
93
94 fn description(&self) -> &str {
95 "Verify that each cited source actually supports the claim it is \
96 attached to, stamping a faithfulness verdict on every citation."
97 }
98
99 fn status(&self) -> CapabilityStatus {
100 CapabilityStatus::Available
101 }
102
103 fn icon(&self) -> Option<&str> {
104 Some("shield-check")
105 }
106
107 fn category(&self) -> Option<&str> {
108 Some("Knowledge")
109 }
110
111 fn is_guardrail(&self) -> bool {
113 true
114 }
115
116 fn features(&self) -> Vec<&'static str> {
117 vec!["citations"]
118 }
119
120 fn config_schema(&self) -> Option<serde_json::Value> {
121 Some(json!({
122 "type": "object",
123 "additionalProperties": false,
124 "properties": {
125 "mode": {
126 "type": "string",
127 "enum": ["heuristic", "llm"],
128 "default": "heuristic",
129 "description": "heuristic = lexical overlap (no model call); llm = utility-model judgement with heuristic fallback."
130 },
131 "threshold": {
132 "type": "number",
133 "minimum": 0.0,
134 "maximum": 1.0,
135 "default": DEFAULT_THRESHOLD,
136 "description": "Entailment threshold for the heuristic verdict."
137 }
138 }
139 }))
140 }
141
142 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
143 if config.is_null() {
144 return Ok(());
145 }
146 let cfg: CitationVerificationConfig = serde_json::from_value(config.clone())
147 .map_err(|e| format!("invalid citation_verification config: {e}"))?;
148 if !(0.0..=1.0).contains(&cfg.threshold) {
149 return Err("threshold must be between 0.0 and 1.0".to_string());
150 }
151 Ok(())
152 }
153
154 fn citation_verifier_with_config(
155 &self,
156 config: &serde_json::Value,
157 ) -> Option<Arc<dyn CitationVerifier>> {
158 let cfg = CitationVerificationConfig::from_value(config);
159 Some(Arc::new(CitationVerificationVerifier {
160 mode: cfg.mode,
161 threshold: cfg.threshold,
162 }))
163 }
164}
165
166struct CitationVerificationVerifier {
167 mode: VerificationMode,
168 threshold: f32,
169}
170
171#[async_trait]
172impl CitationVerifier for CitationVerificationVerifier {
173 fn id(&self) -> &str {
174 CITATION_VERIFICATION_CAPABILITY_ID
175 }
176
177 async fn verify(
178 &self,
179 ctx: &VerificationContext<'_>,
180 mut annotations: Vec<TextAnnotation>,
181 ) -> Vec<TextAnnotation> {
182 if annotations.is_empty() {
183 return annotations;
184 }
185
186 if self.mode == VerificationMode::Llm
189 && let Some(svc) = ctx.utility_llm_service
190 && let Some(verdicts) = llm_verdicts(svc, ctx.message_text, &annotations).await
191 && verdicts.len() == annotations.len()
192 {
193 for (ann, verdict) in annotations.iter_mut().zip(verdicts) {
194 ann.verified = Some(verdict);
195 }
196 return annotations;
197 }
198
199 for ann in annotations.iter_mut() {
200 ann.verified = Some(heuristic_verdict(ctx.message_text, ann, self.threshold));
201 }
202 annotations
203 }
204}
205
206fn heuristic_verdict(text: &str, ann: &TextAnnotation, threshold: f32) -> VerificationVerdict {
209 let claim = span_text(text, ann.start, ann.end);
210 let snippet = ann.source.snippet.as_deref().unwrap_or("");
211 let claim_tokens = citation_tokens(&claim);
212 let snippet_tokens = citation_tokens(snippet);
213 let ratio = token_overlap_ratio(&claim_tokens, &snippet_tokens);
214 let status = if ratio >= threshold {
215 VerificationStatus::Entailed
216 } else if ratio >= threshold * 0.5 {
217 VerificationStatus::Uncertain
218 } else {
219 VerificationStatus::Unsupported
220 };
221 VerificationVerdict {
222 status,
223 score: Some(ratio),
224 }
225}
226
227async fn llm_verdicts(
230 svc: &Arc<dyn crate::UtilityLlmService>,
231 text: &str,
232 annotations: &[TextAnnotation],
233) -> Option<Vec<VerificationVerdict>> {
234 let prompt = build_verification_prompt(text, annotations);
235 let request = UtilityLlmRequest::user_text(prompt)
236 .with_temperature(0.0)
237 .with_max_tokens(700)
238 .with_metadata("purpose", "citation_verification");
239 let response = svc.chat_completion(request).await.ok()?;
240 parse_verification_response(&response.text, annotations.len())
241}
242
243fn build_verification_prompt(text: &str, annotations: &[TextAnnotation]) -> String {
245 let mut pairs = String::new();
246 for (i, ann) in annotations.iter().enumerate() {
247 let claim = span_text(text, ann.start, ann.end);
248 let snippet = ann.source.snippet.as_deref().unwrap_or("");
249 pairs.push_str(&format!(
250 "[{i}]\nCLAIM: {}\nSOURCE: {}\n\n",
251 claim.trim(),
252 snippet.trim()
253 ));
254 }
255 format!(
256 "You verify citations. For each numbered pair, decide whether the SOURCE \
257 supports the CLAIM.\n\
258 Reply with ONLY a JSON array; one object per pair, in order:\n\
259 [{{\"index\": 0, \"status\": \"entailed|unsupported|uncertain\", \"score\": 0.0}}]\n\
260 `entailed` = the source clearly supports the claim; `unsupported` = it does \
261 not; `uncertain` = cannot tell. `score` is your confidence in [0,1].\n\n\
262 {pairs}"
263 )
264}
265
266fn parse_verification_response(raw: &str, expected: usize) -> Option<Vec<VerificationVerdict>> {
270 let json = extract_json_array(raw)?;
271 let array = json.as_array()?;
272 if array.len() != expected {
273 return None;
274 }
275 let mut out = Vec::with_capacity(expected);
276 for entry in array {
277 let status = match entry.get("status").and_then(|s| s.as_str())? {
278 "entailed" => VerificationStatus::Entailed,
279 "unsupported" => VerificationStatus::Unsupported,
280 _ => VerificationStatus::Uncertain,
281 };
282 let score = entry
283 .get("score")
284 .and_then(|s| s.as_f64())
285 .map(|s| s.clamp(0.0, 1.0) as f32);
286 out.push(VerificationVerdict { status, score });
287 }
288 Some(out)
289}
290
291fn extract_json_array(raw: &str) -> Option<serde_json::Value> {
293 let start = raw.find('[')?;
294 let end = raw.rfind(']')?;
295 if end <= start {
296 return None;
297 }
298 serde_json::from_str(&raw[start..=end]).ok()
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::message::AnnotationSource;
305
306 fn annotation(start: usize, end: usize, snippet: &str) -> TextAnnotation {
307 TextAnnotation {
308 start,
309 end,
310 origin: "citation_retrieval".to_string(),
311 source: AnnotationSource {
312 uri: "u".to_string(),
313 title: None,
314 snippet: Some(snippet.to_string()),
315 location: None,
316 },
317 external_id: None,
318 verified: None,
319 }
320 }
321
322 #[test]
323 fn heuristic_marks_supported_claim_entailed() {
324 let text = "Photosynthesis converts sunlight into chemical energy.";
325 let ann = annotation(
326 0,
327 text.chars().count(),
328 "Photosynthesis converts sunlight into chemical energy in plants.",
329 );
330 let verdict = heuristic_verdict(text, &ann, 0.5);
331 assert_eq!(verdict.status, VerificationStatus::Entailed);
332 }
333
334 #[test]
335 fn heuristic_marks_unrelated_claim_unsupported() {
336 let text = "The stock market rallied on strong earnings today.";
337 let ann = annotation(
338 0,
339 text.chars().count(),
340 "Mitochondria produce ATP through cellular respiration.",
341 );
342 let verdict = heuristic_verdict(text, &ann, 0.5);
343 assert_eq!(verdict.status, VerificationStatus::Unsupported);
344 }
345
346 #[test]
347 fn parses_verdict_array_with_code_fence() {
348 let raw = "```json\n[{\"index\":0,\"status\":\"entailed\",\"score\":0.9},\
349 {\"index\":1,\"status\":\"unsupported\",\"score\":0.1}]\n```";
350 let verdicts = parse_verification_response(raw, 2).expect("parsed");
351 assert_eq!(verdicts[0].status, VerificationStatus::Entailed);
352 assert_eq!(verdicts[1].status, VerificationStatus::Unsupported);
353 assert!((verdicts[0].score.unwrap() - 0.9).abs() < 1e-6);
354 }
355
356 #[test]
357 fn parse_rejects_wrong_count() {
358 let raw = "[{\"index\":0,\"status\":\"entailed\"}]";
359 assert!(parse_verification_response(raw, 2).is_none());
360 }
361
362 #[test]
363 fn config_validation_rejects_bad_threshold() {
364 let cap = CitationVerificationCapability;
365 assert!(cap.validate_config(&json!({"threshold": 2.0})).is_err());
366 assert!(
367 cap.validate_config(&json!({"mode": "llm", "threshold": 0.4}))
368 .is_ok()
369 );
370 }
371
372 #[tokio::test]
373 async fn verifier_stamps_all_annotations_heuristically() {
374 let cap = CitationVerificationCapability;
375 let verifier = cap
376 .citation_verifier_with_config(&json!({"mode": "heuristic"}))
377 .expect("verifier");
378 let text = "Water boils at 100 degrees Celsius at sea level.";
379 let anns = vec![annotation(
380 0,
381 text.chars().count(),
382 "At sea level water boils at 100 degrees Celsius.",
383 )];
384 let ctx = VerificationContext {
385 message_text: text,
386 utility_llm_service: None,
387 };
388 let out = verifier.verify(&ctx, anns).await;
389 assert_eq!(out.len(), 1);
390 assert_eq!(
391 out[0].verified.as_ref().unwrap().status,
392 VerificationStatus::Entailed
393 );
394 }
395}