everruns_core/capabilities/
citation_retrieval.rs1use std::collections::HashMap;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21
22use crate::annotation_hook::{
23 AnnotationContext, AnnotationResult, PostGenerationAnnotationHook, citation_tokens,
24 token_overlap_ratio,
25};
26use crate::capabilities::Capability;
27use crate::capability_types::CapabilityStatus;
28use crate::message::{AnnotationSource, ContentPart, Message, TextAnnotation};
29
30pub const CITATION_RETRIEVAL_CAPABILITY_ID: &str = "citation_retrieval";
32
33const RETRIEVAL_TOOLS: &[&str] = &["search_index", "search_knowledge"];
35
36const DEFAULT_MIN_OVERLAP: f32 = 0.5;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct CitationRetrievalConfig {
44 #[serde(default = "default_min_overlap")]
46 pub min_overlap: f32,
47}
48
49fn default_min_overlap() -> f32 {
50 DEFAULT_MIN_OVERLAP
51}
52
53impl Default for CitationRetrievalConfig {
54 fn default() -> Self {
55 Self {
56 min_overlap: DEFAULT_MIN_OVERLAP,
57 }
58 }
59}
60
61impl CitationRetrievalConfig {
62 fn from_value(config: &serde_json::Value) -> Self {
63 if config.is_null() {
64 return Self::default();
65 }
66 serde_json::from_value(config.clone()).unwrap_or_default()
67 }
68}
69
70pub struct CitationRetrievalCapability;
72
73impl Capability for CitationRetrievalCapability {
74 fn id(&self) -> &str {
75 CITATION_RETRIEVAL_CAPABILITY_ID
76 }
77
78 fn name(&self) -> &str {
79 "Retrieval citations"
80 }
81
82 fn description(&self) -> &str {
83 "Attach claim-level citations to the answer from search_index / \
84 search_knowledge results, so each grounded sentence links to its source."
85 }
86
87 fn status(&self) -> CapabilityStatus {
88 CapabilityStatus::Available
89 }
90
91 fn icon(&self) -> Option<&str> {
92 Some("quote")
93 }
94
95 fn category(&self) -> Option<&str> {
96 Some("Knowledge")
97 }
98
99 fn features(&self) -> Vec<&'static str> {
101 vec!["citations"]
102 }
103
104 fn config_schema(&self) -> Option<serde_json::Value> {
105 Some(json!({
106 "type": "object",
107 "additionalProperties": false,
108 "properties": {
109 "min_overlap": {
110 "type": "number",
111 "minimum": 0.0,
112 "maximum": 1.0,
113 "default": DEFAULT_MIN_OVERLAP,
114 "description": "Minimum token-overlap ratio for a retrieved passage to be cited on a sentence."
115 }
116 }
117 }))
118 }
119
120 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
121 if config.is_null() {
122 return Ok(());
123 }
124 let cfg: CitationRetrievalConfig = serde_json::from_value(config.clone())
125 .map_err(|e| format!("invalid citation_retrieval config: {e}"))?;
126 if !(0.0..=1.0).contains(&cfg.min_overlap) {
127 return Err("min_overlap must be between 0.0 and 1.0".to_string());
128 }
129 Ok(())
130 }
131
132 fn post_output_annotation_hooks_with_config(
133 &self,
134 config: &serde_json::Value,
135 ) -> Vec<Arc<dyn PostGenerationAnnotationHook>> {
136 let cfg = CitationRetrievalConfig::from_value(config);
137 vec![Arc::new(RetrievalAnnotationHook {
138 min_overlap: cfg.min_overlap,
139 })]
140 }
141}
142
143struct RetrievalAnnotationHook {
146 min_overlap: f32,
147}
148
149#[async_trait]
150impl PostGenerationAnnotationHook for RetrievalAnnotationHook {
151 fn id(&self) -> &str {
152 CITATION_RETRIEVAL_CAPABILITY_ID
153 }
154
155 async fn annotate(&self, ctx: &AnnotationContext<'_>) -> AnnotationResult {
156 let citations = extract_retrieval_citations(ctx.messages);
157 if citations.is_empty() {
158 return AnnotationResult::none();
159 }
160 let annotations = align_citations(ctx.message_text, &citations, self.min_overlap);
161 AnnotationResult {
162 annotations,
163 rewritten_text: None,
164 }
165 }
166}
167
168#[derive(Debug, Clone)]
171struct RetrievalCitation {
172 external_id: Option<String>,
173 uri: String,
174 title: Option<String>,
175 snippet: String,
176 location: Option<serde_json::Value>,
177}
178
179fn extract_retrieval_citations(messages: &[Message]) -> Vec<RetrievalCitation> {
183 let mut tool_names: HashMap<&str, &str> = HashMap::new();
184 for msg in messages {
185 for part in &msg.content {
186 if let ContentPart::ToolCall(call) = part {
187 tool_names.insert(call.id.as_str(), call.name.as_str());
188 }
189 }
190 }
191
192 let mut out = Vec::new();
193 for msg in messages {
194 for part in &msg.content {
195 let ContentPart::ToolResult(result) = part else {
196 continue;
197 };
198 let Some(name) = tool_names.get(result.tool_call_id.as_str()) else {
199 continue;
200 };
201 if !RETRIEVAL_TOOLS.contains(name) {
202 continue;
203 }
204 let Some(value) = &result.result else {
205 continue;
206 };
207 let Some(results) = value.get("results").and_then(|r| r.as_array()) else {
208 continue;
209 };
210 for entry in results {
211 if let Some(citation) = normalize_entry(entry) {
212 out.push(citation);
213 }
214 }
215 }
216 }
217 out
218}
219
220fn normalize_entry(entry: &serde_json::Value) -> Option<RetrievalCitation> {
225 let snippet = entry.get("snippet").and_then(|s| s.as_str())?.trim();
226 if snippet.is_empty() {
227 return None;
228 }
229 let external_id = entry.get("id").and_then(|v| v.as_str()).map(str::to_string);
230 let title = entry
231 .get("document_title")
232 .or_else(|| entry.get("title"))
233 .and_then(|v| v.as_str())
234 .map(str::to_string);
235 let uri = entry
236 .get("source_uri")
237 .or_else(|| entry.get("resource"))
238 .or_else(|| entry.get("uri"))
239 .and_then(|v| v.as_str())
240 .map(str::to_string)
241 .or_else(|| {
242 external_id
243 .as_ref()
244 .map(|id| format!("everruns://citation/{id}"))
245 })
246 .unwrap_or_else(|| "everruns://citation".to_string());
247 Some(RetrievalCitation {
248 external_id,
249 uri,
250 title,
251 snippet: snippet.to_string(),
252 location: entry.get("location").cloned(),
253 })
254}
255
256fn align_citations(
260 text: &str,
261 citations: &[RetrievalCitation],
262 min_overlap: f32,
263) -> Vec<TextAnnotation> {
264 let sentences = split_sentences(text);
265 if sentences.is_empty() {
266 return Vec::new();
267 }
268 let sentence_tokens: Vec<Vec<String>> =
269 sentences.iter().map(|s| citation_tokens(&s.text)).collect();
270
271 let mut annotations = Vec::new();
272 for citation in citations {
273 let needle = citation_tokens(&citation.snippet);
274 if needle.is_empty() {
275 continue;
276 }
277 let mut best: Option<(usize, f32)> = None;
278 for (idx, tokens) in sentence_tokens.iter().enumerate() {
279 let ratio = token_overlap_ratio(&needle, tokens);
280 if best.map(|(_, b)| ratio > b).unwrap_or(true) {
281 best = Some((idx, ratio));
282 }
283 }
284 if let Some((idx, ratio)) = best
285 && ratio >= min_overlap
286 {
287 let sentence = &sentences[idx];
288 annotations.push(TextAnnotation {
289 start: sentence.start,
290 end: sentence.end,
291 origin: CITATION_RETRIEVAL_CAPABILITY_ID.to_string(),
292 source: AnnotationSource {
293 uri: citation.uri.clone(),
294 title: citation.title.clone(),
295 snippet: Some(citation.snippet.clone()),
296 location: citation.location.clone(),
297 },
298 external_id: citation.external_id.clone(),
299 verified: None,
300 });
301 }
302 }
303 annotations
304}
305
306struct Sentence {
308 start: usize,
309 end: usize,
310 text: String,
311}
312
313fn split_sentences(text: &str) -> Vec<Sentence> {
316 let chars: Vec<char> = text.chars().collect();
317 let mut out = Vec::new();
318 let mut start = 0usize;
319 let mut i = 0usize;
320 while i < chars.len() {
321 let c = chars[i];
322 let is_break = matches!(c, '.' | '!' | '?' | '\n');
323 let at_end = i + 1 == chars.len();
324 if is_break || at_end {
325 let end = i + 1;
326 let segment: String = chars[start..end].iter().collect();
327 if !segment.trim().is_empty() {
328 let lead_ws = segment.chars().take_while(|c| c.is_whitespace()).count();
330 out.push(Sentence {
331 start: start + lead_ws,
332 end,
333 text: segment,
334 });
335 }
336 start = end;
337 }
338 i += 1;
339 }
340 out
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::message::{MessageRole, ToolCallContentPart, ToolResultContentPart};
347
348 fn tool_call_msg(call_id: &str, tool: &str) -> Message {
349 let mut m = Message::assistant("");
350 m.role = MessageRole::Agent;
351 m.content = vec![ContentPart::ToolCall(ToolCallContentPart::new(
352 call_id,
353 tool,
354 json!({}),
355 ))];
356 m
357 }
358
359 fn tool_result_msg(call_id: &str, result: serde_json::Value) -> Message {
360 let mut m = Message::assistant("");
361 m.role = MessageRole::ToolResult;
362 m.content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
363 call_id,
364 Some(result),
365 None,
366 ))];
367 m
368 }
369
370 #[test]
371 fn extracts_index_and_knowledge_shapes() {
372 let messages = vec![
373 tool_call_msg("c1", "search_index"),
374 tool_result_msg(
375 "c1",
376 json!({"results": [{
377 "id": "kchk_1",
378 "source_uri": "github://o/r@main/a.md",
379 "document_title": "A",
380 "snippet": "Photosynthesis converts sunlight into chemical energy.",
381 "location": {"lines": [1, 4]},
382 "score": 0.9
383 }]}),
384 ),
385 tool_call_msg("c2", "search_knowledge"),
386 tool_result_msg(
387 "c2",
388 json!({"count": 1, "results": [{
389 "id": "kbe_2",
390 "kb_id": "kb_x",
391 "title": "B",
392 "kind": "note",
393 "tags": [],
394 "snippet": "The mitochondria is the powerhouse of the cell."
395 }]}),
396 ),
397 ];
398 let cites = extract_retrieval_citations(&messages);
399 assert_eq!(cites.len(), 2);
400 assert_eq!(cites[0].external_id.as_deref(), Some("kchk_1"));
401 assert_eq!(cites[0].uri, "github://o/r@main/a.md");
402 assert_eq!(cites[1].external_id.as_deref(), Some("kbe_2"));
403 assert_eq!(cites[1].uri, "everruns://citation/kbe_2");
405 }
406
407 #[test]
408 fn ignores_non_retrieval_tools() {
409 let messages = vec![
410 tool_call_msg("c1", "bash"),
411 tool_result_msg(
412 "c1",
413 json!({"results": [{"id": "x", "snippet": "hi there"}]}),
414 ),
415 ];
416 assert!(extract_retrieval_citations(&messages).is_empty());
417 }
418
419 #[test]
420 fn aligns_citation_to_best_sentence() {
421 let text = "Plants are green. Photosynthesis converts sunlight into chemical energy. \
422 Cells divide.";
423 let cites = vec![RetrievalCitation {
424 external_id: Some("kchk_1".to_string()),
425 uri: "github://o/r@main/a.md".to_string(),
426 title: Some("A".to_string()),
427 snippet: "Photosynthesis converts sunlight into chemical energy.".to_string(),
428 location: None,
429 }];
430 let anns = align_citations(text, &cites, 0.5);
431 assert_eq!(anns.len(), 1);
432 let ann = &anns[0];
433 let cited: String = text
434 .chars()
435 .skip(ann.start)
436 .take(ann.end - ann.start)
437 .collect();
438 assert!(cited.contains("Photosynthesis converts sunlight"));
439 assert_eq!(ann.external_id.as_deref(), Some("kchk_1"));
440 assert_eq!(ann.origin, CITATION_RETRIEVAL_CAPABILITY_ID);
441 }
442
443 #[test]
444 fn drops_low_overlap_citations() {
445 let text = "The weather today is sunny and warm.";
446 let cites = vec![RetrievalCitation {
447 external_id: None,
448 uri: "u".to_string(),
449 title: None,
450 snippet: "Quantum chromodynamics describes the strong interaction.".to_string(),
451 location: None,
452 }];
453 assert!(align_citations(text, &cites, 0.5).is_empty());
454 }
455
456 #[test]
457 fn config_validation_rejects_out_of_range() {
458 let cap = CitationRetrievalCapability;
459 assert!(cap.validate_config(&json!({"min_overlap": 1.5})).is_err());
460 assert!(cap.validate_config(&json!({"min_overlap": 0.3})).is_ok());
461 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
462 }
463}