1#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExtractedFact {
21 pub text: String,
23 pub entities: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExtractedRelation {
43 pub subject: String,
45 pub predicate: String,
47 pub object: String,
49}
50
51#[derive(Debug, Clone, PartialEq)]
62pub struct ExtractedAttribute {
63 pub entity: String,
65 pub key: String,
67 pub value: serde_json::Value,
69}
70
71#[derive(Debug, Clone, Default, PartialEq)]
77pub struct Extraction {
78 pub facts: Vec<ExtractedFact>,
80 pub relations: Vec<ExtractedRelation>,
82 pub attributes: Vec<ExtractedAttribute>,
84}
85
86#[derive(Debug, thiserror::Error)]
89pub enum ExtractError {
90 #[error("extraction backend error: {0}")]
92 Backend(String),
93 #[error("could not parse facts from extractor output: {0}")]
95 Parse(String),
96}
97
98pub trait Extractor {
104 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
110
111 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
123 Ok(Extraction {
124 facts: self.extract(text)?,
125 ..Extraction::default()
126 })
127 }
128}
129
130impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
139 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
140 (**self).extract(text)
141 }
142
143 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
144 (**self).extract_graph(text)
145 }
146}
147
148pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
152
153#[cfg(feature = "extract")]
162pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
163
164#[cfg(feature = "extract")]
167const REQUEST_TIMEOUT_SECS: u64 = 300;
168
169#[cfg(feature = "extract")]
173const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
174
175#[cfg(feature = "extract")]
178const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
179
180#[cfg(feature = "extract")]
189const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
190 crate::ollama_retry::OllamaLevers {
191 url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
192 model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
193 fallback: None,
194 };
195
196#[cfg(feature = "extract")]
199enum GenerateCall {
200 Transport(Box<ureq::Error>),
203 Body(std::io::Error),
205}
206
207#[cfg(feature = "extract")]
209fn generate_is_retryable(err: &GenerateCall) -> bool {
210 match err {
211 GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
212 GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
213 }
214}
215
216#[cfg(feature = "extract")]
219fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
220 let cause = match err {
221 GenerateCall::Transport(inner) => inner.to_string(),
222 GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
223 };
224 crate::ollama_retry::actionable_failure(
225 "generate",
226 url,
227 model,
228 attempts,
229 &cause,
230 &EXTRACT_LEVERS,
231 )
232}
233
234#[cfg(feature = "extract")]
241#[derive(Debug, Clone)]
242pub struct OllamaExtractor {
243 base_url: String,
244 model: String,
245 agent: ureq::Agent,
246}
247
248#[cfg(feature = "extract")]
249impl OllamaExtractor {
250 #[must_use]
261 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
262 let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
263 let agent = ureq::AgentBuilder::new()
264 .timeout_connect(CONNECT_TIMEOUT)
265 .timeout_write(WRITE_TIMEOUT)
266 .timeout_read(timeout)
267 .timeout(timeout)
268 .build();
269 Self {
270 base_url: base_url.into(),
271 model: model.into(),
272 agent,
273 }
274 }
275}
276
277#[cfg(feature = "extract")]
278impl Extractor for OllamaExtractor {
279 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
280 let reply = self.generate(&build_prompt(text))?;
281 let raw = json_slice::<Vec<RawFact>>(&reply)
282 .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
283 Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
284 }
285
286 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
287 let reply = self.generate(&build_graph_prompt(text))?;
288 let raw = json_slice_object::<RawExtraction>(&reply)
289 .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
290 Ok(raw.into_extraction())
291 }
292}
293
294#[cfg(feature = "extract")]
295impl OllamaExtractor {
296 fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
305 let url = format!("{}/api/generate", self.base_url);
306 let body = serde_json::json!({
307 "model": self.model,
308 "prompt": prompt,
309 "stream": false,
310 "think": false,
311 "keep_alive": crate::embedder::keep_alive(),
316 "options": { "temperature": 0 },
317 })
318 .to_string();
319 let attempt = || {
320 let response = self
321 .agent
322 .post(&url)
323 .set("Content-Type", "application/json")
324 .send_string(&body)
325 .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
326 response.into_string().map_err(GenerateCall::Body)
327 };
328
329 let payload = crate::ollama_retry::with_retry(
330 &crate::ollama_retry::OLLAMA_RETRIES,
331 generate_is_retryable,
332 attempt,
333 )
334 .map_err(|(err, attempts)| {
335 ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
336 })?;
337 parse_generate_response(&payload)
338 }
339}
340
341#[cfg(feature = "extract")]
343#[derive(serde::Deserialize)]
344struct RawFact {
345 fact: String,
346 #[serde(default)]
347 entities: Vec<String>,
348}
349
350#[cfg(feature = "extract")]
351impl RawFact {
352 fn into_fact(self) -> Option<ExtractedFact> {
355 let text = self.fact.trim().to_string();
356 if text.is_empty() {
357 return None;
358 }
359 let mut entities: Vec<String> = self
360 .entities
361 .into_iter()
362 .map(|entity| entity.trim().to_lowercase())
363 .filter(|entity| !entity.is_empty())
364 .collect();
365 entities.sort_unstable();
366 entities.dedup();
367 Some(ExtractedFact { text, entities })
368 }
369}
370
371#[cfg(feature = "extract")]
375fn canonical_entity(name: &str) -> String {
376 name.trim().to_lowercase()
377}
378
379#[cfg(feature = "extract")]
381#[derive(serde::Deserialize)]
382struct RawExtraction {
383 #[serde(default)]
384 facts: Vec<RawFact>,
385 #[serde(default)]
386 relations: Vec<RawRelation>,
387 #[serde(default)]
388 attributes: Vec<RawAttribute>,
389}
390
391#[cfg(feature = "extract")]
392#[derive(serde::Deserialize)]
393struct RawRelation {
394 subject: String,
395 predicate: String,
396 object: String,
397}
398
399#[cfg(feature = "extract")]
400#[derive(serde::Deserialize)]
401struct RawAttribute {
402 entity: String,
403 key: String,
404 value: serde_json::Value,
405}
406
407#[cfg(feature = "extract")]
408impl RawExtraction {
409 fn into_extraction(self) -> Extraction {
414 Extraction {
415 facts: self
416 .facts
417 .into_iter()
418 .filter_map(RawFact::into_fact)
419 .collect(),
420 relations: self
421 .relations
422 .into_iter()
423 .filter_map(RawRelation::into_relation)
424 .collect(),
425 attributes: self
426 .attributes
427 .into_iter()
428 .filter_map(RawAttribute::into_attribute)
429 .collect(),
430 }
431 }
432}
433
434#[cfg(feature = "extract")]
435impl RawRelation {
436 fn into_relation(self) -> Option<ExtractedRelation> {
437 let subject = canonical_entity(&self.subject);
438 let object = canonical_entity(&self.object);
439 let predicate = self.predicate.trim().to_string();
440 if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
443 return None;
444 }
445 Some(ExtractedRelation {
446 subject,
447 predicate,
448 object,
449 })
450 }
451}
452
453#[cfg(feature = "extract")]
454impl RawAttribute {
455 fn into_attribute(self) -> Option<ExtractedAttribute> {
456 let entity = canonical_entity(&self.entity);
457 let key = self.key.trim().to_string();
458 if entity.is_empty() || key.is_empty() || self.value.is_null() {
461 return None;
462 }
463 Some(ExtractedAttribute {
464 entity,
465 key,
466 value: self.value,
467 })
468 }
469}
470
471#[cfg(feature = "extract")]
479fn build_graph_prompt(text: &str) -> String {
480 format!(
481 "You are building a knowledge graph from the passage below.\n\n\
482Passage:\n{text}\n\n\
483Return THREE things.\n\n\
4841. \"facts\": the atomic, standalone facts a person would remember. Rewrite each \
485as a self-contained sentence (resolve pronouns to names; keep absolute dates). \
486For each, list 1-4 key TOPICS it concerns, as short canonical lowercase noun \
487phrases, so the same topic recurs as the SAME tag across passages.\n\n\
4882. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
489subject/predicate/object triples. Use the entity's full name, lowercase \
490(e.g. \"julien lange\").\n\
491The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
492the passage's own language (e.g. \"pere de\", \"soeur de\", \"works at\", \
493\"moteur de recherche\"). NEVER restate the sentence — write \"surveille les \
494fuites\", not \"est utilise pour la surveillance de fuites de donnees\". If you \
495cannot say it in 3 words, pick the closest short label.\n\
496State the triple in the direction the passage states it, and add the converse \
497ONLY if the passage states it too.\n\
498Every named entity the passage RELATES to another must appear in at least one \
499triple — an entity that only receives attributes and no edge is a dead end in \
500the graph.\n\n\
5013. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
502short lowercase keys (\"age\", \"ville\", \"employeur\"). Emit numbers as JSON \
503NUMBERS, never strings: 15, not \"15\". Omit anything the passage does not state.\n\n\
504Return ONLY this JSON object, no prose:\n\
505{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
506\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
507\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
508 )
509}
510
511#[cfg(feature = "extract")]
513fn build_prompt(text: &str) -> String {
514 format!(
515 "You are building a memory graph from the passage below.\n\n\
516Passage:\n{text}\n\n\
517Extract the atomic, standalone facts a person would remember. Rewrite each as a \
518self-contained sentence (resolve pronouns to names; keep absolute dates). For \
519each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
520activities, events, interests, plans, places, organisations, or named people a \
521later question might reference. Use short, canonical, lowercase noun phrases \
522(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
523recurs as the SAME tag across passages.\n\n\
524Return ONLY a JSON array, no prose, each item exactly:\n\
525{{\"fact\": string, \"entities\": [string]}}"
526 )
527}
528
529#[cfg(feature = "extract")]
531fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
532 let value: serde_json::Value = serde_json::from_str(body)
533 .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
534 let text = value
535 .get("response")
536 .and_then(serde_json::Value::as_str)
537 .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
538 Ok(text.trim().to_string())
539}
540
541#[cfg(feature = "extract")]
543fn truncate(text: &str) -> String {
544 const LIMIT: usize = 120;
545 let mut out = String::new();
546 for word in text.split_whitespace() {
547 let sep_len = usize::from(!out.is_empty());
550 if out.len() + sep_len + word.len() > LIMIT {
551 break;
552 }
553 if !out.is_empty() {
554 out.push(' ');
555 }
556 out.push_str(word);
557 }
558 out
559}
560
561#[cfg(feature = "extract")]
565fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
566 let slice = balanced_slice(text)?;
567 serde_json::from_str::<T>(slice).ok()
568}
569
570#[cfg(feature = "extract")]
578fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
579 let slice = balanced_slice_preferring(text, b'{')?;
580 serde_json::from_str::<T>(slice).ok()
581}
582
583#[cfg(feature = "extract")]
590fn balanced_slice(text: &str) -> Option<&str> {
591 balanced_slice_preferring(text, b'[')
592}
593
594#[cfg(feature = "extract")]
597fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
598 let bytes = text.as_bytes();
599 let fallback = if preferred == b'[' { b'{' } else { b'[' };
600 let start = bytes
601 .iter()
602 .position(|&b| b == preferred)
603 .or_else(|| bytes.iter().position(|&b| b == fallback))?;
604 let open = bytes[start];
605 let close = if open == b'[' { b']' } else { b'}' };
606 let mut depth = 0u32;
607 let mut in_string = false;
608 let mut escaped = false;
609 for (offset, &byte) in bytes[start..].iter().enumerate() {
610 if in_string {
611 in_string = step_string(&mut escaped, byte);
612 } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
613 return Some(&text[start..=start + offset]);
614 }
615 }
616 None
617}
618
619#[cfg(feature = "extract")]
622fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
623 if byte == b'"' {
624 *in_string = true;
625 } else if byte == open {
626 *depth += 1;
627 } else if byte == close {
628 *depth = depth.saturating_sub(1);
629 return *depth == 0;
630 }
631 false
632}
633
634#[cfg(feature = "extract")]
637fn step_string(escaped: &mut bool, byte: u8) -> bool {
638 match (*escaped, byte) {
639 (true, _) => {
640 *escaped = false;
641 true
642 }
643 (false, b'\\') => {
644 *escaped = true;
645 true
646 }
647 (false, b'"') => false,
648 (false, _) => true,
649 }
650}
651
652#[cfg(all(test, feature = "extract"))]
653mod tests {
654 use super::*;
655
656 #[test]
661 fn parses_a_graph_reply_whose_first_bracket_is_nested() {
662 let reply = r#"{ "facts": [ { "fact": "Zephyrin is the father of Kaltar.", "entities": ["zephyrin", "kaltar"] } ], "relations": [ { "subject": "zephyrin", "predicate": "pere de", "object": "kaltar" } ], "attributes": [ { "entity": "kaltar", "key": "age", "value": 15 } ] }"#;
663 let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
664 let extraction = raw.into_extraction();
665 assert_eq!(extraction.facts.len(), 1);
666 assert_eq!(extraction.relations.len(), 1);
667 assert_eq!(extraction.relations[0].predicate, "pere de");
668 assert_eq!(extraction.attributes.len(), 1);
669 assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
670 }
671
672 #[test]
674 fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
675 let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
676 let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
677 assert_eq!(raw.into_extraction().relations.len(), 1);
678 }
679
680 #[test]
683 fn fact_only_slicing_still_prefers_the_array() {
684 let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
685 let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
686 assert_eq!(raw.len(), 1);
687 }
688
689 #[test]
690 fn graph_prompt_demands_numeric_values_and_the_three_sections() {
691 let prompt = build_graph_prompt("Kaltar a 15 ans.");
692 assert!(prompt.contains("Kaltar a 15 ans."));
693 assert!(prompt.contains("\"relations\""));
694 assert!(prompt.contains("\"attributes\""));
695 assert!(prompt.contains("15, not \"15\""));
696 }
697
698 #[test]
699 fn prompt_carries_the_passage_and_json_contract() {
700 let prompt = build_prompt("Alice adopted a dog in 2021.");
701 assert!(prompt.contains("Alice adopted a dog in 2021."));
702 assert!(prompt.contains("\"fact\": string"));
703 }
704
705 #[test]
710 fn graph_prompt_bounds_the_predicate_and_demands_edges() {
711 let prompt = build_graph_prompt("Ahmia is an onion search engine.");
712 assert!(prompt.contains("Ahmia is an onion search engine."));
713 assert!(
714 prompt.contains("at most 3 words"),
715 "the predicate length must be a hard bound, not a suggestion"
716 );
717 assert!(
718 prompt.contains("NEVER restate the sentence"),
719 "the counter-example is what stops a restated sentence"
720 );
721 assert!(
722 prompt.contains("at least one triple"),
723 "an entity with attributes but no edge is a dead end — the prompt \
724 must ask for the edge"
725 );
726 }
727
728 #[test]
729 fn parses_facts_from_a_fenced_reply() {
730 let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
731 let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
732 let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
733 assert_eq!(facts.len(), 1);
734 assert_eq!(facts[0].text, "Alice adopted a dog.");
735 assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
737 }
738
739 #[test]
740 fn drops_a_textless_fact() {
741 let raw = RawFact {
742 fact: " ".to_string(),
743 entities: vec!["x".to_string()],
744 };
745 assert!(raw.into_fact().is_none());
746 }
747
748 #[test]
749 fn parses_response_envelope() {
750 let text = parse_generate_response(r#"{"response":" [] "}"#).expect("parse");
751 assert_eq!(text, "[]");
752 }
753
754 #[test]
755 fn rejects_response_without_field() {
756 assert!(matches!(
757 parse_generate_response(r#"{"oops":true}"#),
758 Err(ExtractError::Backend(_))
759 ));
760 }
761}