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
86const KINSHIP_NOUNS: &[&str] = &[
102 "pere",
103 "mere",
104 "frere",
105 "soeur",
106 "fils",
107 "fille",
108 "oncle",
109 "tante",
110 "cousin",
111 "cousine",
112 "neveu",
113 "niece",
114 "grand-pere",
115 "grand-mere",
116 "beau-pere",
117 "belle-mere",
118 "demi-frere",
119 "demi-soeur",
120 "epoux",
121 "epouse",
122 "mari",
123 "femme",
124 "father",
125 "mother",
126 "brother",
127 "sister",
128 "son",
129 "daughter",
130 "uncle",
131 "aunt",
132 "husband",
133 "wife",
134];
135
136const POSSESSIVE_MARKERS: &[&str] = &[" a un ", " a une ", " a pour ", " has a ", " has an "];
140
141const FOLDINGS: &[(char, &str)] = &[
145 ('à', "a"),
146 ('â', "a"),
147 ('ä', "a"),
148 ('é', "e"),
149 ('è', "e"),
150 ('ê', "e"),
151 ('ë', "e"),
152 ('î', "i"),
153 ('ï', "i"),
154 ('ô', "o"),
155 ('ö', "o"),
156 ('ù', "u"),
157 ('û', "u"),
158 ('ü', "u"),
159 ('ç', "c"),
160 ('œ', "oe"),
161 ('æ', "ae"),
162];
163
164fn fold(text: &str) -> String {
167 let mut folded = String::with_capacity(text.len());
168 for ch in text.chars().flat_map(char::to_lowercase) {
169 match FOLDINGS.iter().find(|(from, _)| *from == ch) {
170 Some((_, to)) => folded.push_str(to),
171 None => folded.push(ch),
172 }
173 }
174 folded
175}
176
177struct Possessive {
181 noun: &'static str,
182 start: usize,
183 end: usize,
184}
185
186fn find_possessive(folded: &str) -> Option<Possessive> {
188 POSSESSIVE_MARKERS
189 .iter()
190 .filter_map(|marker| folded.find(marker).map(|at| at + marker.len()))
191 .filter_map(|start| noun_at(folded, start))
192 .min_by_key(|possessive| possessive.start)
193}
194
195fn noun_at(folded: &str, start: usize) -> Option<Possessive> {
197 let rest = folded.get(start..)?;
198 let noun = KINSHIP_NOUNS
199 .iter()
200 .find(|noun| starts_with_word(rest, noun))?;
201 Some(Possessive {
202 noun,
203 start,
204 end: start + noun.len(),
205 })
206}
207
208fn starts_with_word(rest: &str, word: &str) -> bool {
211 match rest.strip_prefix(word) {
212 Some(tail) => !tail.starts_with(char::is_alphanumeric),
213 None => false,
214 }
215}
216
217fn endpoint_names(relations: &[ExtractedRelation]) -> Vec<String> {
219 let mut names: Vec<String> = relations
220 .iter()
221 .flat_map(|relation| [relation.subject.clone(), relation.object.clone()])
222 .collect();
223 names.sort_unstable();
224 names.dedup();
225 names
226}
227
228fn holder_of(before: &str, names: &[String]) -> Option<String> {
231 names
232 .iter()
233 .filter_map(|name| before.rfind(&fold(name)).map(|at| (at, name)))
234 .max_by_key(|(at, _)| *at)
235 .map(|(_, name)| name.clone())
236}
237
238fn bearer_of(after: &str, names: &[String]) -> Option<String> {
240 names
241 .iter()
242 .filter_map(|name| after.find(&fold(name)).map(|at| (at, name)))
243 .min_by_key(|(at, _)| *at)
244 .map(|(_, name)| name.clone())
245}
246
247fn predicate_stem(predicate: &str) -> String {
249 fold(predicate)
250 .split_whitespace()
251 .next()
252 .unwrap_or_default()
253 .to_string()
254}
255
256fn joins(relation: &ExtractedRelation, one: &str, other: &str) -> bool {
258 (relation.subject == one && relation.object == other)
259 || (relation.subject == other && relation.object == one)
260}
261
262fn reorient(relation: &mut ExtractedRelation, noun: &str, holder: &str, bearer: &str) {
268 let stem = predicate_stem(&relation.predicate);
269 if !KINSHIP_NOUNS.contains(&stem.as_str()) || !joins(relation, holder, bearer) {
270 return;
271 }
272 let (subject, object) = if stem == noun {
273 (bearer, holder)
274 } else {
275 (holder, bearer)
276 };
277 relation.subject = subject.to_string();
278 relation.object = object.to_string();
279}
280
281pub(crate) fn orient_possessive_kinship(passage: &str, relations: &mut [ExtractedRelation]) {
288 let folded = fold(passage);
289 let Some(possessive) = find_possessive(&folded) else {
290 return;
291 };
292 let names = endpoint_names(relations);
293 let Some(holder) = holder_of(&folded[..possessive.start], &names) else {
294 return;
295 };
296 let Some(bearer) = bearer_of(&folded[possessive.end..], &names) else {
297 return;
298 };
299 if holder == bearer {
300 return;
301 }
302 for relation in relations.iter_mut() {
303 reorient(relation, possessive.noun, &holder, &bearer);
304 }
305}
306
307#[derive(Debug, thiserror::Error)]
310pub enum ExtractError {
311 #[error("extraction backend error: {0}")]
313 Backend(String),
314 #[error("could not parse facts from extractor output: {0}")]
316 Parse(String),
317}
318
319pub trait Extractor {
325 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
331
332 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
344 Ok(Extraction {
345 facts: self.extract(text)?,
346 ..Extraction::default()
347 })
348 }
349}
350
351impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
360 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
361 (**self).extract(text)
362 }
363
364 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
365 (**self).extract_graph(text)
366 }
367}
368
369pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
373
374#[cfg(feature = "extract")]
383pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
384
385#[cfg(feature = "extract")]
388const REQUEST_TIMEOUT_SECS: u64 = 300;
389
390#[cfg(feature = "extract")]
394const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
395
396#[cfg(feature = "extract")]
399const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
400
401#[cfg(feature = "extract")]
410const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
411 crate::ollama_retry::OllamaLevers {
412 url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
413 model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
414 fallback: None,
415 };
416
417#[cfg(feature = "extract")]
420enum GenerateCall {
421 Transport(Box<ureq::Error>),
424 Body(std::io::Error),
426}
427
428#[cfg(feature = "extract")]
430fn generate_is_retryable(err: &GenerateCall) -> bool {
431 match err {
432 GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
433 GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
434 }
435}
436
437#[cfg(feature = "extract")]
440fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
441 let cause = match err {
442 GenerateCall::Transport(inner) => inner.to_string(),
443 GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
444 };
445 crate::ollama_retry::actionable_failure(
446 "generate",
447 url,
448 model,
449 attempts,
450 &cause,
451 &EXTRACT_LEVERS,
452 )
453}
454
455#[cfg(feature = "extract")]
462#[derive(Debug, Clone)]
463pub struct OllamaExtractor {
464 base_url: String,
465 model: String,
466 agent: ureq::Agent,
467}
468
469#[cfg(feature = "extract")]
470impl OllamaExtractor {
471 #[must_use]
482 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
483 let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
484 let agent = ureq::AgentBuilder::new()
485 .timeout_connect(CONNECT_TIMEOUT)
486 .timeout_write(WRITE_TIMEOUT)
487 .timeout_read(timeout)
488 .timeout(timeout)
489 .build();
490 Self {
491 base_url: base_url.into(),
492 model: model.into(),
493 agent,
494 }
495 }
496}
497
498#[cfg(feature = "extract")]
499impl Extractor for OllamaExtractor {
500 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
501 let reply = self.generate(&build_prompt(text))?;
502 let raw = json_slice::<Vec<RawFact>>(&reply)
503 .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
504 Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
505 }
506
507 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
508 let reply = self.generate(&build_graph_prompt(text))?;
509 let raw = json_slice_object::<RawExtraction>(&reply)
510 .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
511 Ok(raw.into_extraction())
512 }
513}
514
515#[cfg(feature = "extract")]
516impl OllamaExtractor {
517 fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
526 let url = format!("{}/api/generate", self.base_url);
527 let body = serde_json::json!({
528 "model": self.model,
529 "prompt": prompt,
530 "stream": false,
531 "think": false,
532 "keep_alive": crate::embedder::keep_alive(),
537 "options": { "temperature": 0 },
538 })
539 .to_string();
540 let attempt = || {
541 let response = self
542 .agent
543 .post(&url)
544 .set("Content-Type", "application/json")
545 .send_string(&body)
546 .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
547 response.into_string().map_err(GenerateCall::Body)
548 };
549
550 let payload = crate::ollama_retry::with_retry(
551 &crate::ollama_retry::OLLAMA_RETRIES,
552 generate_is_retryable,
553 attempt,
554 )
555 .map_err(|(err, attempts)| {
556 ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
557 })?;
558 parse_generate_response(&payload)
559 }
560}
561
562#[cfg(feature = "extract")]
564#[derive(serde::Deserialize)]
565struct RawFact {
566 fact: String,
567 #[serde(default)]
568 entities: Vec<String>,
569}
570
571#[cfg(feature = "extract")]
572impl RawFact {
573 fn into_fact(self) -> Option<ExtractedFact> {
576 let text = self.fact.trim().to_string();
577 if text.is_empty() {
578 return None;
579 }
580 let mut entities: Vec<String> = self
581 .entities
582 .into_iter()
583 .map(|entity| entity.trim().to_lowercase())
584 .filter(|entity| !entity.is_empty())
585 .collect();
586 entities.sort_unstable();
587 entities.dedup();
588 Some(ExtractedFact { text, entities })
589 }
590}
591
592#[cfg(feature = "extract")]
596fn canonical_entity(name: &str) -> String {
597 name.trim().to_lowercase()
598}
599
600#[cfg(feature = "extract")]
602#[derive(serde::Deserialize)]
603struct RawExtraction {
604 #[serde(default)]
605 facts: Vec<RawFact>,
606 #[serde(default)]
607 relations: Vec<RawRelation>,
608 #[serde(default)]
609 attributes: Vec<RawAttribute>,
610}
611
612#[cfg(feature = "extract")]
613#[derive(serde::Deserialize)]
614struct RawRelation {
615 subject: String,
616 predicate: String,
617 object: String,
618}
619
620#[cfg(feature = "extract")]
621#[derive(serde::Deserialize)]
622struct RawAttribute {
623 entity: String,
624 key: String,
625 value: serde_json::Value,
626}
627
628#[cfg(feature = "extract")]
629impl RawExtraction {
630 fn into_extraction(self) -> Extraction {
635 Extraction {
636 facts: self
637 .facts
638 .into_iter()
639 .filter_map(RawFact::into_fact)
640 .collect(),
641 relations: self
642 .relations
643 .into_iter()
644 .filter_map(RawRelation::into_relation)
645 .collect(),
646 attributes: self
647 .attributes
648 .into_iter()
649 .filter_map(RawAttribute::into_attribute)
650 .collect(),
651 }
652 }
653}
654
655#[cfg(feature = "extract")]
656impl RawRelation {
657 fn into_relation(self) -> Option<ExtractedRelation> {
658 let subject = canonical_entity(&self.subject);
659 let object = canonical_entity(&self.object);
660 let predicate = self.predicate.trim().to_string();
661 if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
664 return None;
665 }
666 Some(ExtractedRelation {
667 subject,
668 predicate,
669 object,
670 })
671 }
672}
673
674#[cfg(feature = "extract")]
675impl RawAttribute {
676 fn into_attribute(self) -> Option<ExtractedAttribute> {
677 let entity = canonical_entity(&self.entity);
678 let key = self.key.trim().to_string();
679 if entity.is_empty() || key.is_empty() || self.value.is_null() {
682 return None;
683 }
684 Some(ExtractedAttribute {
685 entity,
686 key,
687 value: self.value,
688 })
689 }
690}
691
692#[cfg(feature = "extract")]
700fn build_graph_prompt(text: &str) -> String {
701 format!(
702 "You are building a knowledge graph from the passage below.\n\n\
703Passage:\n{text}\n\n\
704Return THREE things.\n\n\
7051. \"facts\": the atomic, standalone facts a person would remember. Rewrite each \
706as a self-contained sentence (resolve pronouns to names; keep absolute dates). \
707For each, list 1-4 key TOPICS it concerns, as short canonical lowercase noun \
708phrases, so the same topic recurs as the SAME tag across passages.\n\n\
7092. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
710subject/predicate/object triples. Use the entity's full name, lowercase \
711(e.g. \"bruno durand\").\n\
712The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
713the passage's own language (e.g. \"pere de\", \"soeur de\", \"works at\", \
714\"moteur de recherche\"). NEVER restate the sentence — write \"surveille les \
715fuites\", not \"est utilise pour la surveillance de fuites de donnees\". If you \
716cannot say it in 3 words, pick the closest short label.\n\
717State the triple in the direction the passage states it, and add the converse \
718ONLY if the passage states it too.\n\
719DIRECTION: the subject is whoever CARRIES the relation, not the subject of the \
720sentence. \"A a une soeur, B\" means B is A's sister, so the triple is \
721B/\"soeur de\"/A — never A/\"soeur de\"/B. Same for every possessive \
722(\"a un frere\", \"a une fille\", \"has a brother\").\n\
723Every named entity the passage RELATES to another must appear in at least one \
724triple — an entity that only receives attributes and no edge is a dead end in \
725the graph.\n\n\
7263. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
727short lowercase keys (\"age\", \"ville\", \"employeur\"). Emit numbers as JSON \
728NUMBERS, never strings: 15, not \"15\". Omit anything the passage does not state.\n\n\
729Return ONLY this JSON object, no prose:\n\
730{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
731\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
732\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
733 )
734}
735
736#[cfg(feature = "extract")]
738fn build_prompt(text: &str) -> String {
739 format!(
740 "You are building a memory graph from the passage below.\n\n\
741Passage:\n{text}\n\n\
742Extract the atomic, standalone facts a person would remember. Rewrite each as a \
743self-contained sentence (resolve pronouns to names; keep absolute dates). For \
744each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
745activities, events, interests, plans, places, organisations, or named people a \
746later question might reference. Use short, canonical, lowercase noun phrases \
747(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
748recurs as the SAME tag across passages.\n\n\
749Return ONLY a JSON array, no prose, each item exactly:\n\
750{{\"fact\": string, \"entities\": [string]}}"
751 )
752}
753
754#[cfg(feature = "extract")]
756fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
757 let value: serde_json::Value = serde_json::from_str(body)
758 .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
759 let text = value
760 .get("response")
761 .and_then(serde_json::Value::as_str)
762 .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
763 Ok(text.trim().to_string())
764}
765
766#[cfg(feature = "extract")]
768fn truncate(text: &str) -> String {
769 const LIMIT: usize = 120;
770 let mut out = String::new();
771 for word in text.split_whitespace() {
772 let sep_len = usize::from(!out.is_empty());
775 if out.len() + sep_len + word.len() > LIMIT {
776 break;
777 }
778 if !out.is_empty() {
779 out.push(' ');
780 }
781 out.push_str(word);
782 }
783 out
784}
785
786#[cfg(feature = "extract")]
790fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
791 let slice = balanced_slice(text)?;
792 serde_json::from_str::<T>(slice).ok()
793}
794
795#[cfg(feature = "extract")]
803fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
804 let slice = balanced_slice_preferring(text, b'{')?;
805 serde_json::from_str::<T>(slice).ok()
806}
807
808#[cfg(feature = "extract")]
815fn balanced_slice(text: &str) -> Option<&str> {
816 balanced_slice_preferring(text, b'[')
817}
818
819#[cfg(feature = "extract")]
822fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
823 let bytes = text.as_bytes();
824 let fallback = if preferred == b'[' { b'{' } else { b'[' };
825 let start = bytes
826 .iter()
827 .position(|&b| b == preferred)
828 .or_else(|| bytes.iter().position(|&b| b == fallback))?;
829 let open = bytes[start];
830 let close = if open == b'[' { b']' } else { b'}' };
831 let mut depth = 0u32;
832 let mut in_string = false;
833 let mut escaped = false;
834 for (offset, &byte) in bytes[start..].iter().enumerate() {
835 if in_string {
836 in_string = step_string(&mut escaped, byte);
837 } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
838 return Some(&text[start..=start + offset]);
839 }
840 }
841 None
842}
843
844#[cfg(feature = "extract")]
847fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
848 if byte == b'"' {
849 *in_string = true;
850 } else if byte == open {
851 *depth += 1;
852 } else if byte == close {
853 *depth = depth.saturating_sub(1);
854 return *depth == 0;
855 }
856 false
857}
858
859#[cfg(feature = "extract")]
862fn step_string(escaped: &mut bool, byte: u8) -> bool {
863 match (*escaped, byte) {
864 (true, _) => {
865 *escaped = false;
866 true
867 }
868 (false, b'\\') => {
869 *escaped = true;
870 true
871 }
872 (false, b'"') => false,
873 (false, _) => true,
874 }
875}
876
877#[cfg(all(test, feature = "extract"))]
878mod tests {
879 use super::*;
880
881 #[test]
886 fn parses_a_graph_reply_whose_first_bracket_is_nested() {
887 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 } ] }"#;
888 let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
889 let extraction = raw.into_extraction();
890 assert_eq!(extraction.facts.len(), 1);
891 assert_eq!(extraction.relations.len(), 1);
892 assert_eq!(extraction.relations[0].predicate, "pere de");
893 assert_eq!(extraction.attributes.len(), 1);
894 assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
895 }
896
897 #[test]
899 fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
900 let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
901 let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
902 assert_eq!(raw.into_extraction().relations.len(), 1);
903 }
904
905 #[test]
908 fn fact_only_slicing_still_prefers_the_array() {
909 let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
910 let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
911 assert_eq!(raw.len(), 1);
912 }
913
914 #[test]
915 fn graph_prompt_demands_numeric_values_and_the_three_sections() {
916 let prompt = build_graph_prompt("Kaltar a 15 ans.");
917 assert!(prompt.contains("Kaltar a 15 ans."));
918 assert!(prompt.contains("\"relations\""));
919 assert!(prompt.contains("\"attributes\""));
920 assert!(prompt.contains("15, not \"15\""));
921 }
922
923 #[test]
924 fn prompt_carries_the_passage_and_json_contract() {
925 let prompt = build_prompt("Alice adopted a dog in 2021.");
926 assert!(prompt.contains("Alice adopted a dog in 2021."));
927 assert!(prompt.contains("\"fact\": string"));
928 }
929
930 #[test]
935 fn graph_prompt_bounds_the_predicate_and_demands_edges() {
936 let prompt = build_graph_prompt("Ahmia is an onion search engine.");
937 assert!(prompt.contains("Ahmia is an onion search engine."));
938 assert!(
939 prompt.contains("at most 3 words"),
940 "the predicate length must be a hard bound, not a suggestion"
941 );
942 assert!(
943 prompt.contains("NEVER restate the sentence"),
944 "the counter-example is what stops a restated sentence"
945 );
946 assert!(
947 prompt.contains("at least one triple"),
948 "an entity with attributes but no edge is a dead end — the prompt \
949 must ask for the edge"
950 );
951 }
952
953 #[test]
958 fn graph_prompt_states_which_side_carries_the_relation() {
959 let prompt = build_graph_prompt("Theo Durand a une soeur, Camille Durand.");
960 assert!(
961 prompt.contains("whoever CARRIES the relation"),
962 "the rule must name the carrier, not just \"the direction\""
963 );
964 assert!(
965 prompt.contains("never A/\"soeur de\"/B"),
966 "the counter-example is what makes the rule unambiguous"
967 );
968 }
969
970 #[test]
971 fn parses_facts_from_a_fenced_reply() {
972 let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
973 let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
974 let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
975 assert_eq!(facts.len(), 1);
976 assert_eq!(facts[0].text, "Alice adopted a dog.");
977 assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
979 }
980
981 #[test]
982 fn drops_a_textless_fact() {
983 let raw = RawFact {
984 fact: " ".to_string(),
985 entities: vec!["x".to_string()],
986 };
987 assert!(raw.into_fact().is_none());
988 }
989
990 #[test]
991 fn parses_response_envelope() {
992 let text = parse_generate_response(r#"{"response":" [] "}"#).expect("parse");
993 assert_eq!(text, "[]");
994 }
995
996 #[test]
997 fn rejects_response_without_field() {
998 assert!(matches!(
999 parse_generate_response(r#"{"oops":true}"#),
1000 Err(ExtractError::Backend(_))
1001 ));
1002 }
1003}