velesdb_memory/extract.rs
1//! Optional text → facts + entities extraction, the layer that makes the graph
2//! self-build.
3//!
4//! The Agent Memory SDK is *bring-your-own-links*: [`crate::MemoryService::remember`]
5//! only stores the links the caller supplies, so a graph is only ever as rich as
6//! what the caller wires by hand. This module adds the missing commodity on top:
7//! an [`Extractor`] turns a paragraph of raw text into atomic facts, each tagged
8//! with the salient topics it mentions. [`crate::MemoryService::remember_extracted`]
9//! then stores those facts and wires the fact↔entity graph automatically, so
10//! `why()` has something to traverse without any manual `relate()`.
11//!
12//! Mirroring the [`crate::embedder`] pattern, the plug-point is dependency-free
13//! (bring your own LLM by implementing [`Extractor`]) while a batteries-included
14//! `OllamaExtractor` backend lives behind the `extract` feature.
15
16/// One extracted, graph-ready fact: a self-contained sentence plus the salient
17/// topics it concerns. The topics become shared graph hubs, so two facts about
18/// the same topic are reachable from one another even with no textual overlap.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExtractedFact {
21 /// The atomic, standalone fact (pronouns resolved, dates absolute).
22 pub text: String,
23 /// Salient topics the fact concerns — short canonical lowercase noun
24 /// phrases (e.g. `"adoption"`, `"charity race"`). 1-4 is typical.
25 pub entities: Vec<String>,
26}
27
28/// One extracted entity→entity edge: `subject -[predicate]-> object`.
29///
30/// Where [`ExtractedFact::entities`] only says "this fact concerns these
31/// topics", a relation says *how two topics relate*. It is what turns the
32/// bipartite fact↔topic graph into a genuine knowledge graph: from
33/// "Bruno Durand is Theo Durand's father" the wiring produces the edge
34/// `bruno durand -[father of]-> theo durand`, so a later walk can answer
35/// "who is Theo's father" without any fact mentioning both names again.
36///
37/// `subject` and `object` are canonicalized exactly like
38/// [`ExtractedFact::entities`] (trimmed, lowercased), so they resolve to the
39/// SAME entity hub as the topics — the hub id is content-addressed, so this
40/// holds across separate calls and across sessions.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExtractedRelation {
43 /// Canonical lowercase entity the edge points *from*.
44 pub subject: String,
45 /// The edge label (e.g. `"father of"`, `"sister of"`, `"works at"`).
46 pub predicate: String,
47 /// Canonical lowercase entity the edge points *to*.
48 pub object: String,
49}
50
51/// One extracted entity attribute: `entity.key = value`.
52///
53/// Attributes are what make "Theo Durand is 15" answerable by a *filter*
54/// rather than a similarity search: the pair is merged into the entity hub's
55/// `ColumnStore` metadata, so `recall_where` can select on `age >= 15`.
56///
57/// `value` deliberately keeps its JSON type. `recall_where` comparisons are
58/// TYPE-STRICT with no coercion, so an age extracted as the string `"15"`
59/// would silently never match a numeric filter — the extraction contract
60/// therefore demands numbers stay numbers.
61#[derive(Debug, Clone, PartialEq)]
62pub struct ExtractedAttribute {
63 /// Canonical lowercase entity the attribute belongs to.
64 pub entity: String,
65 /// Attribute name, used verbatim as the metadata (`ColumnStore`) field.
66 pub key: String,
67 /// Attribute value, type preserved (a number stays a JSON number).
68 pub value: serde_json::Value,
69}
70
71/// Everything one passage yields: the atomic facts, the entity→entity edges
72/// between the topics they mention, and the attributes those entities carry.
73///
74/// [`Extractor::extract`] returns only the `facts` half; a backend that can
75/// also read relations and attributes overrides [`Extractor::extract_graph`].
76#[derive(Debug, Clone, Default, PartialEq)]
77pub struct Extraction {
78 /// The atomic, standalone facts, each with the topics it concerns.
79 pub facts: Vec<ExtractedFact>,
80 /// Typed edges between entities mentioned in the passage.
81 pub relations: Vec<ExtractedRelation>,
82 /// Attributes attached to entities mentioned in the passage.
83 pub attributes: Vec<ExtractedAttribute>,
84}
85
86// --- Orienting a kinship triple, whichever form states it ---------------------
87//
88// A copule ("X est le pere de Y") hangs the relation on its own grammatical
89// subject, so subject-of-sentence and subject-of-triple coincide. Three other
90// forms hang it on the OTHER one, and a model mirrors all three:
91//
92// possessive "X a une soeur, Y" → Y carries "soeur de", toward X
93// genitive "la soeur de X est Y" → the same reading, reversed order
94// "X's sister is Y"
95// plural "X a deux soeurs, Y et Z" → the same, with SEVERAL carriers
96//
97// A mirrored kinship triple is worse than a missing edge — `entity("Camille
98// Durand")` then answers, with the same confidence as any true edge, that
99// Camille is Theo's *brother*. The prompt asks for the right direction; this
100// pass guarantees it for the constructions that get it wrong, whatever backend
101// produced the triple.
102//
103// Two invariants make the pass safe to run over a backend that may be
104// hallucinating, and every rule below is bounded so as to keep them: it never
105// invents an edge and never drops one. It only ever re-points triples the
106// extractor already returned, between endpoints it already named.
107
108/// Kinship nouns a passage can hang a relation on, folded (no accents, no
109/// ligature) and singular — every matcher below tolerates a plural `s`. Doubling
110/// as the
111/// predicate whitelist: a triple is only ever re-pointed when its label is one
112/// of these, so a non-kinship edge between the same two people is left alone.
113///
114/// Alliances (`"beau-frere"`, `"godmother"`, `"petit-fils"`) sit here beside
115/// the blood ties because they behave identically: grammatically they are the
116/// same possessive, and the converse of one is simply whichever OTHER label of
117/// this table the extractor put on the same pair — see [`reorient`]. Listing
118/// the noun is therefore the whole of the work; nothing below special-cases it.
119///
120/// Deliberately NOT here: `"partner"` / `"compagnon"`. "X has a partner, Y" is
121/// a business relation as often as a family one, and a wrong re-point is worse
122/// than none at all.
123/// Every noun is paired with the CANONICAL noun of the relation it denotes.
124///
125/// The orientation pass decides direction by asking whether the predicate names
126/// the same kinship the passage named. Asking that of the SPELLING made two
127/// words for one relation read as each other's converse, and stored the edge
128/// backwards (#1754) — across languages (`"sister"` vs `"soeur"`), and just as
129/// much within one (`"mari"` vs `"epoux"`, `"gendre"` vs `"beau-fils"`, both
130/// listed here). Comparing canonical forms asks it of the MEANING instead, so a
131/// synonym orients like the word it stands for and only a real converse flips.
132///
133/// Where one French noun covers two English ones — `"beau-pere"` is both the
134/// father-in-law and the stepfather — every spelling folds onto that single
135/// canonical. Deliberate: the pass has no "unrelated" branch, so anything not
136/// judged identical is treated as the converse. Reading two step/in-law words
137/// as ONE relation leaves an edge unturned; reading them as converses points it
138/// the wrong way, and this pass already holds that a missing correction beats a
139/// wrong one.
140const KINSHIP_NOUNS: &[(&str, &str)] = &[
141 // Blood ties, fr. — each its own canonical.
142 ("pere", "pere"),
143 ("mere", "mere"),
144 ("frere", "frere"),
145 ("soeur", "soeur"),
146 ("fils", "fils"),
147 ("fille", "fille"),
148 ("oncle", "oncle"),
149 ("tante", "tante"),
150 ("cousin", "cousin"),
151 ("cousine", "cousine"),
152 ("neveu", "neveu"),
153 ("niece", "niece"),
154 ("grand-pere", "grand-pere"),
155 ("grand-mere", "grand-mere"),
156 ("grand-oncle", "grand-oncle"),
157 ("grand-tante", "grand-tante"),
158 ("arriere-grand-pere", "arriere-grand-pere"),
159 ("arriere-grand-mere", "arriere-grand-mere"),
160 ("petit-fils", "petit-fils"),
161 ("petite-fille", "petite-fille"),
162 // Alliances and step-family, fr. — `gendre`/`bru` and `mari`/`femme` are
163 // synonyms of the nouns they fold onto, not converses of them.
164 ("beau-pere", "beau-pere"),
165 ("belle-mere", "belle-mere"),
166 ("beau-frere", "beau-frere"),
167 ("belle-soeur", "belle-soeur"),
168 ("beau-fils", "beau-fils"),
169 ("belle-fille", "belle-fille"),
170 ("gendre", "beau-fils"),
171 ("bru", "belle-fille"),
172 ("demi-frere", "demi-frere"),
173 ("demi-soeur", "demi-soeur"),
174 ("parrain", "parrain"),
175 ("marraine", "marraine"),
176 ("filleul", "filleul"),
177 ("filleule", "filleule"),
178 ("epoux", "epoux"),
179 ("epouse", "epouse"),
180 ("mari", "epoux"),
181 ("femme", "epouse"),
182 // Blood ties, en. — folded onto their French twin.
183 ("father", "pere"),
184 ("mother", "mere"),
185 ("brother", "frere"),
186 ("sister", "soeur"),
187 ("son", "fils"),
188 ("daughter", "fille"),
189 ("uncle", "oncle"),
190 ("aunt", "tante"),
191 ("nephew", "neveu"),
192 ("grandfather", "grand-pere"),
193 ("grandmother", "grand-mere"),
194 ("grandson", "petit-fils"),
195 ("granddaughter", "petite-fille"),
196 // Alliances and step-family, en. — folded onto their French twin.
197 ("husband", "epoux"),
198 ("wife", "epouse"),
199 ("father-in-law", "beau-pere"),
200 ("mother-in-law", "belle-mere"),
201 ("brother-in-law", "beau-frere"),
202 ("sister-in-law", "belle-soeur"),
203 ("son-in-law", "beau-fils"),
204 ("daughter-in-law", "belle-fille"),
205 ("stepfather", "beau-pere"),
206 ("stepmother", "belle-mere"),
207 ("stepbrother", "beau-frere"),
208 ("stepsister", "belle-soeur"),
209 ("half-brother", "demi-frere"),
210 ("half-sister", "demi-soeur"),
211 ("godfather", "parrain"),
212 ("godmother", "marraine"),
213 ("godson", "filleul"),
214 ("goddaughter", "filleule"),
215];
216
217/// What precedes the kinship noun when the sentence hangs the relation on the
218/// person it introduces rather than on its own subject. The trailing space is
219/// load-bearing: without it `" a un "` would also fire on `"a une"`.
220///
221/// The counting determiners are what make a plural construction readable at
222/// all: `"a deux soeurs, Camille et Lea"` matches no singular marker.
223const POSSESSIVE_MARKERS: &[&str] = &[
224 " a un ",
225 " a une ",
226 " a pour ",
227 " a des ",
228 " a deux ",
229 " a trois ",
230 " a quatre ",
231 " has a ",
232 " has an ",
233 " has two ",
234 " has three ",
235 " has four ",
236];
237
238/// What sits between a kinship noun and the name of whoever HOLDS it in a
239/// genitive: `"la soeur DE Theo"`, `"the sister OF Theo"`.
240const GENITIVE_LINKS: &[&str] = &[" de ", " d'", " of "];
241
242/// The clitic the English genitive marks its holder with, holder first:
243/// `"Theo's sister is Camille"`.
244const SAXON_MARKER: &str = "'s ";
245
246/// The copula that closes a genitive and introduces its carrier:
247/// `"la soeur de Theo EST Camille"`.
248const GENITIVE_COPULAS: &[&str] = &[" est ", " sont ", " is ", " are "];
249
250/// Articles a copula may put in front of the name it introduces. Stepping over
251/// one is what lets the carrier still be *required* to sit right after the
252/// copula, which is the whole of the genitive's safety.
253const LEADING_ARTICLES: &[&str] = &["le ", "la ", "les ", "l'", "the "];
254
255/// What may join two carriers of one construction: `"Camille Durand ET Lea
256/// Durand"`. Longest first, so `", et "` is never read as `", "` followed by
257/// something that is not a name — which would end the walk one carrier early.
258const ENUMERATION_SEPARATORS: &[&str] = &[", et ", ", and ", " et ", " and ", " & ", ", "];
259
260/// Diacritics and ligatures folded to ASCII, so `"sœur"`, `"soeur"` and
261/// `"Sœur"` are one token — the passage and the model's label rarely agree on
262/// accents, and the whole pass hinges on matching one against the other.
263const FOLDINGS: &[(char, &str)] = &[
264 ('à', "a"),
265 ('â', "a"),
266 ('ä', "a"),
267 ('é', "e"),
268 ('è', "e"),
269 ('ê', "e"),
270 ('ë', "e"),
271 ('î', "i"),
272 ('ï', "i"),
273 ('ô', "o"),
274 ('ö', "o"),
275 ('ù', "u"),
276 ('û', "u"),
277 ('ü', "u"),
278 ('ç', "c"),
279 ('œ', "oe"),
280 ('æ', "ae"),
281 // A typographic apostrophe, so `"Theo’s"` and `"d’Theo"` reach the same
282 // matchers as their ASCII spellings — most editors substitute it silently.
283 ('\u{2019}', "'"),
284];
285
286/// Lowercase `text` and fold its diacritics away. Every offset produced from
287/// the result indexes the *folded* string, never the original.
288fn fold(text: &str) -> String {
289 let mut folded = String::with_capacity(text.len());
290 for ch in text.chars().flat_map(char::to_lowercase) {
291 match FOLDINGS.iter().find(|(from, _)| *from == ch) {
292 Some((_, to)) => folded.push_str(to),
293 None => folded.push(ch),
294 }
295 }
296 folded
297}
298
299/// A kinship relation the passage states: every one of `bearers` carries
300/// `noun`, and `holder` is the one they carry it toward.
301struct Kinship {
302 noun: &'static str,
303 holder: String,
304 bearers: Vec<String>,
305}
306
307/// How many bytes `word` occupies at the start of `rest` when it is written
308/// there as a whole word, a plural `s` included. `None` when it is not.
309///
310/// `"soeurette"` therefore never reads as `"soeur"`, and — the case that
311/// matters — `"brother-in-law"` never reads as `"brother"`: a hyphen CONTINUES
312/// a compound noun, so it bars the match exactly like a letter. Without that,
313/// the pass would recognise the bare noun, then treat the sentence's own label
314/// as the *converse* of it and point the edge precisely the wrong way. A
315/// missing edge would have been the better outcome.
316fn word_prefix_len(rest: &str, word: &str) -> Option<usize> {
317 let tail = rest.strip_prefix(word)?;
318 let (tail, plural) = match tail.strip_prefix('s') {
319 Some(shorter) => (shorter, 1),
320 None => (tail, 0),
321 };
322 let glued = |ch: char| ch.is_alphanumeric() || ch == '-';
323 (!tail.starts_with(glued)).then_some(word.len() + plural)
324}
325
326/// Whether `head` ENDS on `word` as a whole word, a plural `s` included — the
327/// mirror of [`word_prefix_len`], for the genitive, where the noun precedes its
328/// link instead of following a marker.
329fn ends_with_word(head: &str, word: &str) -> bool {
330 ends_exactly(head, word)
331 || head
332 .strip_suffix('s')
333 .is_some_and(|singular| ends_exactly(singular, word))
334}
335
336/// `head` ends on `word` with no letter and no hyphen glued in front of it, so
337/// `"la belle-soeur"` is never read as ending on `"soeur"`.
338fn ends_exactly(head: &str, word: &str) -> bool {
339 head.strip_suffix(word)
340 .is_some_and(|lead| !lead.ends_with(|ch: char| ch.is_alphanumeric() || ch == '-'))
341}
342
343/// The kinship noun written at the start of `text`, and how many bytes it
344/// occupies there.
345/// The length is that of the spelling actually written; the noun returned is
346/// its CANONICAL form, so a caller compares meanings and never spellings.
347fn noun_at(text: &str) -> Option<(&'static str, usize)> {
348 KINSHIP_NOUNS.iter().find_map(|(spelling, canonical)| {
349 word_prefix_len(text, spelling).map(|len| (*canonical, len))
350 })
351}
352
353/// The kinship noun `head` ends on, in its canonical form.
354fn noun_before(head: &str) -> Option<&'static str> {
355 KINSHIP_NOUNS
356 .iter()
357 .find(|(spelling, _)| ends_with_word(head, spelling))
358 .map(|(_, canonical)| *canonical)
359}
360
361/// The text left once the first of `prefixes` that `text` starts with is
362/// stepped over.
363fn strip_any<'a>(text: &'a str, prefixes: &[&str]) -> Option<&'a str> {
364 prefixes.iter().find_map(|prefix| text.strip_prefix(prefix))
365}
366
367/// Every distinct entity the triples name, deduplicated.
368fn endpoint_names(relations: &[ExtractedRelation]) -> Vec<String> {
369 let mut names: Vec<String> = relations
370 .iter()
371 .flat_map(|relation| [relation.subject.clone(), relation.object.clone()])
372 .collect();
373 names.sort_unstable();
374 names.dedup();
375 names
376}
377
378/// The endpoint named closest to the left of the noun: the person who HAS the
379/// relative.
380fn holder_of(before: &str, names: &[String]) -> Option<String> {
381 names
382 .iter()
383 .filter_map(|name| before.rfind(&fold(name)).map(|at| (at, name)))
384 .max_by_key(|(at, _)| *at)
385 .map(|(_, name)| name.clone())
386}
387
388/// The longest endpoint name written exactly at the start of `text`. Longest,
389/// so `"theo durand"` wins over a bare `"theo"` that also happens to be an
390/// endpoint — the shorter one would leave `" durand"` in front of the walk and
391/// silently truncate the enumeration.
392fn name_at(text: &str, names: &[String]) -> Option<String> {
393 names
394 .iter()
395 .filter(|name| text.starts_with(&fold(name)))
396 .max_by_key(|name| name.len())
397 .cloned()
398}
399
400/// The longest endpoint name `head` ENDS on — who a clitic belongs to.
401fn name_before(head: &str, names: &[String]) -> Option<String> {
402 names
403 .iter()
404 .filter(|name| head.ends_with(&fold(name)))
405 .max_by_key(|name| name.len())
406 .cloned()
407}
408
409/// The first endpoint named anywhere in `text`, and where its mention begins.
410fn first_name(text: &str, names: &[String]) -> Option<(usize, String)> {
411 names
412 .iter()
413 .filter_map(|name| text.find(&fold(name)).map(|at| (at, name)))
414 .min_by_key(|(at, name)| (*at, std::cmp::Reverse(name.len())))
415 .map(|(at, name)| (at, name.clone()))
416}
417
418/// `first`, plus every further endpoint the SAME enumeration lists after it.
419///
420/// The walk stops at the first thing that is not a separator followed by an
421/// endpoint. That bound is what keeps a following sentence from contributing a
422/// carrier — re-pointing "Bruno est le pere de Theo" as though Bruno were a
423/// sister of Theo is far worse than the edge it would have added.
424fn enumeration_from(text: &str, first: String, names: &[String]) -> Vec<String> {
425 let mut rest = &text[fold(&first).len()..];
426 let mut bearers = vec![first];
427 while let Some((name, tail)) = next_enumerated(rest, names) {
428 bearers.push(name);
429 rest = tail;
430 }
431 bearers
432}
433
434/// Verbs that mark the name before them as the SUBJECT of a new clause
435/// rather than another item in a list.
436///
437/// `", et "` and `" et "` are enumeration separators AND the way French joins
438/// two clauses, so the separator alone cannot tell "a sister, Camille, and
439/// Lea" from "a sister, Camille, and Bruno IS the father of Marie". What
440/// separates them is what follows the name: an item is followed by another
441/// separator or by the end of its clause, a subject is followed by a verb.
442const CLAUSE_VERBS: &[&str] = &[
443 " est ",
444 " sont ",
445 " etait ",
446 " etaient ",
447 " a ",
448 " ont ",
449 " avait ",
450 " avaient ",
451 " is ",
452 " are ",
453 " was ",
454 " were ",
455 " has ",
456 " have ",
457 " had ",
458];
459
460/// The next endpoint of an enumeration, and what follows its mention.
461///
462/// Returns `None` when the name opens a new clause — bounding the walk at the
463/// sentence is not enough, because a sentence holds several clauses. Letting
464/// one through re-points an edge the passage states CORRECTLY: "Bruno est le
465/// pere de Marie" became "Marie est le pere de Bruno", a confident falsehood
466/// where there had been none. Strictly worse than the edge the walk exists to
467/// add.
468fn next_enumerated<'a>(rest: &'a str, names: &[String]) -> Option<(String, &'a str)> {
469 let tail = strip_any(rest, ENUMERATION_SEPARATORS)?;
470 let name = name_at(tail, names)?;
471 let cut = fold(&name).len();
472 let after = &tail[cut..];
473 if CLAUSE_VERBS.iter().any(|verb| after.starts_with(verb)) {
474 return None;
475 }
476 Some((name, after))
477}
478
479/// The carriers a possessive introduces: the first endpoint named after the
480/// noun, plus the rest of its enumeration.
481fn bearers_after(after: &str, names: &[String]) -> Vec<String> {
482 match first_name(after, names) {
483 Some((at, first)) => enumeration_from(&after[at..], first, names),
484 None => Vec::new(),
485 }
486}
487
488/// The carriers written RIGHT at the start of `text`, one article tolerated.
489/// Requiring them there is what keeps a genitive from reaching across a clause
490/// it does not own.
491fn bearers_at(text: &str, names: &[String]) -> Vec<String> {
492 [Some(text), strip_any(text, LEADING_ARTICLES)]
493 .into_iter()
494 .flatten()
495 .find_map(|text| name_at(text, names).map(|first| enumeration_from(text, first, names)))
496 .unwrap_or_default()
497}
498
499/// The earliest possessive construction in `folded`: `"X a une soeur, Y"`.
500fn find_possessive(folded: &str, names: &[String]) -> Option<Kinship> {
501 let (start, noun, end) = POSSESSIVE_MARKERS
502 .iter()
503 .filter_map(|marker| folded.find(marker).map(|at| at + marker.len()))
504 .filter_map(|start| {
505 let (noun, len) = noun_at(folded.get(start..)?)?;
506 Some((start, noun, start + len))
507 })
508 .min_by_key(|(start, _, _)| *start)?;
509 Some(Kinship {
510 noun,
511 holder: holder_of(folded.get(..start)?, names)?,
512 bearers: bearers_after(folded.get(end..)?, names),
513 })
514}
515
516/// The earliest genitive construction in `folded`, either word order.
517fn find_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
518 find_of_genitive(folded, names).or_else(|| find_saxon_genitive(folded, names))
519}
520
521/// `"<noun> de <holder> est <bearer>"` — the French genitive and its English
522/// `"of"` twin, scanned left to right so the earliest reading wins.
523fn find_of_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
524 let mut links: Vec<(usize, usize)> = GENITIVE_LINKS
525 .iter()
526 .flat_map(|link| folded.match_indices(link).map(|(at, m)| (at, m.len())))
527 .collect();
528 links.sort_unstable();
529 links
530 .into_iter()
531 .find_map(|(at, len)| of_genitive_at(folded, at, len, names))
532}
533
534/// One `"<noun> de <holder> est <bearer>"` reading, anchored on the link at
535/// `at`.
536///
537/// Every step has to hold exactly — the noun ENDS where the link starts, the
538/// holder STARTS where it ends, and the copula follows the holder's name
539/// immediately. That is what keeps a copule out: "Camille est la soeur de Theo"
540/// carries the very same `"<noun> de <holder>"` fragment and is already right,
541/// but its "est" sits on the wrong side of the noun, so nothing follows the
542/// holder and the reading is rejected rather than mirrored.
543fn of_genitive_at(folded: &str, at: usize, len: usize, names: &[String]) -> Option<Kinship> {
544 let noun = noun_before(folded.get(..at)?)?;
545 let after_link = folded.get(at + len..)?;
546 let holder = name_at(after_link, names)?;
547 let after_holder = after_link.get(fold(&holder).len()..)?;
548 let bearers = bearers_at(strip_any(after_holder, GENITIVE_COPULAS)?, names);
549 Some(Kinship {
550 noun,
551 holder,
552 bearers,
553 })
554}
555
556/// `"<holder>'s <noun> is <bearer>"` — the English genitive, holder first.
557fn find_saxon_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
558 folded
559 .match_indices(SAXON_MARKER)
560 .find_map(|(at, marker)| saxon_genitive_at(folded, at, marker.len(), names))
561}
562
563/// One `"<holder>'s <noun> is <bearer>"` reading, anchored on the clitic at
564/// `at`. As tight as [`of_genitive_at`]: the holder's name must end on the
565/// clitic, the noun must start right after it, and the copula must follow it.
566fn saxon_genitive_at(folded: &str, at: usize, len: usize, names: &[String]) -> Option<Kinship> {
567 let holder = name_before(folded.get(..at)?, names)?;
568 let (noun, noun_len) = noun_at(folded.get(at + len..)?)?;
569 let after_noun = folded.get(at + len + noun_len..)?;
570 let bearers = bearers_at(strip_any(after_noun, GENITIVE_COPULAS)?, names);
571 Some(Kinship {
572 noun,
573 holder,
574 bearers,
575 })
576}
577
578/// The kinship relation the passage states, in whichever form states it. The
579/// possessive is tried first: its marker is the most specific, so a sentence
580/// that could be read both ways reads as the possessive it is.
581fn find_kinship(folded: &str, names: &[String]) -> Option<Kinship> {
582 find_possessive(folded, names).or_else(|| find_genitive(folded, names))
583}
584
585/// The head word of a predicate label, folded: `"sœur de"` → `"soeur"`.
586fn predicate_stem(predicate: &str) -> String {
587 fold(predicate)
588 .split_whitespace()
589 .next()
590 .unwrap_or_default()
591 .to_string()
592}
593
594/// The kinship noun a predicate label names, plural tolerated (`"sœurs de"` →
595/// `"soeur"`). `None` for any non-kinship label, which is what leaves an
596/// unrelated edge between the same two people untouched.
597fn predicate_noun(predicate: &str) -> Option<&'static str> {
598 let stem = predicate_stem(predicate);
599 KINSHIP_NOUNS
600 .iter()
601 .find(|(spelling, _)| word_prefix_len(&stem, spelling) == Some(stem.len()))
602 .map(|(_, canonical)| *canonical)
603}
604
605/// Whether the triple runs between exactly these two entities, either way round.
606fn joins(relation: &ExtractedRelation, one: &str, other: &str) -> bool {
607 (relation.subject == one && relation.object == other)
608 || (relation.subject == other && relation.object == one)
609}
610
611/// Point one triple the way the passage states it.
612///
613/// The triple built on the RELATION the passage named belongs to the person
614/// that noun introduced; any *other* kinship relation over the same pair is its
615/// converse and therefore runs the other way. That single rule is also all an
616/// alliance ever needs: list `"beau-frere"` in the table and its converse is
617/// whatever else the extractor labelled the pair with. Anything else is
618/// untouched.
619///
620/// "Same relation" is decided on the CANONICAL noun, never on the spelling —
621/// otherwise `"sister"` and `"soeur"`, or `"mari"` and `"epoux"`, read as each
622/// other's converse and the edge is stored backwards (#1754).
623fn reorient(relation: &mut ExtractedRelation, noun: &str, holder: &str, bearer: &str) {
624 let Some(stem) = predicate_noun(&relation.predicate) else {
625 return;
626 };
627 if !joins(relation, holder, bearer) {
628 return;
629 }
630 let (subject, object) = if stem == noun {
631 (bearer, holder)
632 } else {
633 (holder, bearer)
634 };
635 relation.subject = subject.to_string();
636 relation.object = object.to_string();
637}
638
639/// Re-point the kinship triples the passage states, so each label sits on the
640/// person who actually carries it.
641///
642/// A no-op unless the passage contains a possessive or a genitive naming a
643/// kinship noun AND both sides of it resolve to entities the triples already
644/// mention — the pass never invents an edge, never drops one, and never touches
645/// a copule. A construction naming several carriers re-points the triple of
646/// each; one that names a carrier no triple mentions simply has no triple to
647/// re-point, since synthesising the edge would break the never-invent rule that
648/// makes this pass safe over a hallucinating backend.
649pub(crate) fn orient_kinship(passage: &str, relations: &mut [ExtractedRelation]) {
650 let folded = fold(passage);
651 let names = endpoint_names(relations);
652 let Some(kinship) = find_kinship(&folded, &names) else {
653 return;
654 };
655 for bearer in &kinship.bearers {
656 if *bearer == kinship.holder {
657 continue;
658 }
659 for relation in relations.iter_mut() {
660 reorient(relation, kinship.noun, &kinship.holder, bearer);
661 }
662 }
663}
664
665/// Failure produced by an [`Extractor`] backend (e.g. a network-backed model
666/// that cannot be reached, or output that cannot be parsed into facts).
667#[derive(Debug, thiserror::Error)]
668pub enum ExtractError {
669 /// The extraction backend (network, subprocess, …) returned an error.
670 #[error("extraction backend error: {0}")]
671 Backend(String),
672 /// The backend produced output that could not be parsed into facts.
673 #[error("could not parse facts from extractor output: {0}")]
674 Parse(String),
675}
676
677/// Turns a passage of raw text into atomic, graph-ready facts.
678///
679/// Implement this to plug in any model — a local LLM, a hosted API, or a
680/// deterministic rule set — and feed the result straight into
681/// [`crate::MemoryService::remember_extracted`].
682pub trait Extractor {
683 /// Extract the atomic facts a reader would remember from `text`.
684 ///
685 /// # Errors
686 /// Returns [`ExtractError`] if the backend fails or its output cannot be
687 /// parsed into facts.
688 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
689
690 /// Extract the facts *and* the entity→entity edges and entity attributes
691 /// the passage states.
692 ///
693 /// Defaults to [`Self::extract`] with no relations and no attributes, so
694 /// every backend written against the fact-only contract keeps compiling
695 /// and keeps working — it simply builds the bipartite fact↔topic graph it
696 /// always did. A backend that can read structure overrides this.
697 ///
698 /// # Errors
699 /// Returns [`ExtractError`] if the backend fails or its output cannot be
700 /// parsed.
701 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
702 Ok(Extraction {
703 facts: self.extract(text)?,
704 ..Extraction::default()
705 })
706 }
707}
708
709/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
710/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
711/// [`crate::MemoryService::remember_extracted`].
712///
713/// Both methods are forwarded. Forwarding only `extract` would silently route
714/// every `Arc`-held backend — which is *every* backend the MCP server and the
715/// bindings use — through the fact-only default, discarding the relations and
716/// attributes the inner extractor actually produced.
717impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
718 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
719 (**self).extract(text)
720 }
721
722 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
723 (**self).extract_graph(text)
724 }
725}
726
727/// A shared, object-safe extractor. The MCP server and the language bindings
728/// hold one of these (an `Option`), so the extraction tool can be attached at
729/// runtime without the type being generic.
730pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
731
732// --- Always-available backend: the outline a passage states -------------------
733//
734// The twin of `HashEmbedder` on this side of the crate, and for the same
735// reason: without a dependency-free choice, every contract
736// `remember_extracted` publishes is reachable only through a network call, so
737// no binding can exercise it and no test can prove it. Deliberately NOT behind
738// `extract` — that feature exists to pull in the HTTP client, which this
739// backend does not need.
740
741/// Deterministic, network-free extractor: it reads the structure a passage
742/// STATES instead of inferring it.
743///
744/// A generative backend guesses which facts a paragraph holds; this one is
745/// told. Each non-blank line of the passage is one directive:
746///
747/// | line | yields |
748/// |---|---|
749/// | `edge: <subject> \| <predicate> \| <object>` | one [`ExtractedRelation`] |
750/// | `attr: <entity> \| <key> \| <json value>` | one [`ExtractedAttribute`] |
751/// | `fact: <text> \| <topic>, <topic>` | one [`ExtractedFact`] |
752/// | anything else | one [`ExtractedFact`], no topics |
753///
754/// Entity names are canonicalized (trimmed, lowercased) exactly as
755/// [`ExtractedFact::entities`] are, so they resolve to the SAME
756/// content-addressed hubs a generative backend's would — the two backends can
757/// write into one graph.
758///
759/// Its purpose matches [`crate::HashEmbedder`]'s: reproducible tests and
760/// offline behavior. It reads no natural language, so a caller holding only
761/// prose wants a generative backend. What it offers instead is the one thing a
762/// model cannot: the graph is exactly the one the caller wrote down — up to
763/// [`orient_kinship`], the repointing pass EVERY backend's relations go
764/// through, which can flip a triple whose predicate is a kinship noun the
765/// passage also states possessively.
766///
767/// A malformed directive is an [`ExtractError::Parse`], never a silently
768/// dropped line: a graph that quietly loses half of what it was handed is
769/// worse than one that refuses.
770#[derive(Debug, Clone, Copy, Default)]
771pub struct OutlineExtractor;
772
773/// The `|`-separated fields of one directive body, trimmed.
774fn directive_fields(rest: &str) -> Vec<&str> {
775 rest.split('|').map(str::trim).collect()
776}
777
778/// The error a directive carrying the wrong number of fields deserves.
779fn wrong_field_count(kind: &str, expected: usize, given: usize) -> ExtractError {
780 ExtractError::Parse(format!(
781 "`{kind}:` takes {expected} `|`-separated fields, {given} given"
782 ))
783}
784
785/// `edge: <subject> | <predicate> | <object>`.
786fn parse_edge(rest: &str) -> Result<ExtractedRelation, ExtractError> {
787 let fields = directive_fields(rest);
788 let [subject, predicate, object] = fields[..] else {
789 return Err(wrong_field_count("edge", 3, fields.len()));
790 };
791 if subject.is_empty() || predicate.is_empty() || object.is_empty() {
792 return Err(ExtractError::Parse(
793 "`edge:` takes a non-blank subject, predicate and object".to_owned(),
794 ));
795 }
796 Ok(ExtractedRelation {
797 subject: crate::service::canonical_entity_name(subject),
798 predicate: predicate.to_owned(),
799 object: crate::service::canonical_entity_name(object),
800 })
801}
802
803/// `attr: <entity> | <key> | <json value>`.
804fn parse_attr(rest: &str) -> Result<ExtractedAttribute, ExtractError> {
805 let fields = directive_fields(rest);
806 let [entity, key, value] = fields[..] else {
807 return Err(wrong_field_count("attr", 3, fields.len()));
808 };
809 if entity.is_empty() || key.is_empty() {
810 return Err(ExtractError::Parse(
811 "`attr:` takes a non-blank entity and key".to_owned(),
812 ));
813 }
814 // Parsed as JSON, not stored as text, because `recall_where` comparisons
815 // are type-strict: an age handed over as `"15"` would never match a
816 // numeric filter (see [`ExtractedAttribute::value`]).
817 let value = serde_json::from_str(value)
818 .map_err(|err| ExtractError::Parse(format!("`attr:` value is not JSON: {err}")))?;
819 Ok(ExtractedAttribute {
820 entity: crate::service::canonical_entity_name(entity),
821 key: key.to_owned(),
822 value,
823 })
824}
825
826/// `fact: <text> | <topic>, <topic>`, and the fallback for any other line.
827fn parse_fact(body: &str) -> Result<ExtractedFact, ExtractError> {
828 let (text, topics) = body.split_once('|').unwrap_or((body, ""));
829 let text = text.trim();
830 if text.is_empty() {
831 return Err(ExtractError::Parse(
832 "a fact line takes a non-blank text".to_owned(),
833 ));
834 }
835 Ok(ExtractedFact {
836 text: text.to_owned(),
837 entities: topics
838 .split(',')
839 .map(crate::service::canonical_entity_name)
840 .filter(|topic| !topic.is_empty())
841 .collect(),
842 })
843}
844
845impl Extractor for OutlineExtractor {
846 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
847 Ok(self.extract_graph(text)?.facts)
848 }
849
850 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
851 let mut extraction = Extraction::default();
852 for line in text.lines() {
853 let line = line.trim();
854 if line.is_empty() {
855 continue;
856 }
857 if let Some(rest) = line.strip_prefix("edge:") {
858 extraction.relations.push(parse_edge(rest)?);
859 } else if let Some(rest) = line.strip_prefix("attr:") {
860 extraction.attributes.push(parse_attr(rest)?);
861 } else {
862 extraction
863 .facts
864 .push(parse_fact(line.strip_prefix("fact:").unwrap_or(line))?);
865 }
866 }
867 Ok(extraction)
868 }
869}
870
871/// What a caller must do to honour a requested extraction backend.
872///
873/// Returned by [`select_extractor`], which is the single place that knows which
874/// backend names exist. Splitting the answer into these three shapes is what
875/// lets the dependency-free backends be selected in **any** build: only the
876/// [`Self::NeedsRemoteConfig`] arm requires an optional dependency and the URL
877/// and model that go with it, and only that arm's construction is feature-gated.
878pub enum ExtractorSelection {
879 /// No extraction. Tools that need an extractor answer "not configured".
880 Disabled,
881 /// Ready to use as-is: needs no configuration, no network, no optional
882 /// dependency. Attach it and the graph builds.
883 Ready(DynExtractor),
884 /// A network-backed backend the caller must build itself, because only the
885 /// caller knows its URL and model. Carries the backend's name so the caller
886 /// can dispatch without re-parsing the string.
887 NeedsRemoteConfig(&'static str),
888}
889
890/// Hand-written because [`DynExtractor`] is a trait object and the trait does
891/// not require `Debug` — a backend is identified by its shape here, never by
892/// dumping its innards (an HTTP-backed one holds a URL, and a panic message is
893/// not the place for it).
894impl std::fmt::Debug for ExtractorSelection {
895 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
896 match self {
897 Self::Disabled => f.write_str("Disabled"),
898 Self::Ready(_) => f.write_str("Ready(<extractor>)"),
899 Self::NeedsRemoteConfig(name) => write!(f, "NeedsRemoteConfig({name})"),
900 }
901 }
902}
903
904/// Resolve an extraction backend name to what the caller must do about it.
905///
906/// # Why this exists, and why it is in the library rather than the binary
907///
908/// The selection used to live inside the daemon's `#[cfg(feature = "extract")]`
909/// block. That gate is what made [`OutlineExtractor`] unreachable from the MCP
910/// server (#1734): the extractor needs no dependency and is linked into every
911/// build, but the only code that could *choose* it was compiled away unless an
912/// unrelated HTTP feature was on. Two of the twenty published tools were dead by
913/// default as a result — `remember_extracted` refused outright, and `entity`
914/// answered `found: false` for every name, entity hubs being born only of
915/// extraction.
916///
917/// Living here rather than in `main.rs` also means the daemon and the tests
918/// exercise the **same** function: a test can select `outline` and drive the
919/// real server with the result, instead of proving a seam written for the test.
920///
921/// # Errors
922/// A human-readable message naming the accepted forms, for an unknown backend.
923pub fn select_extractor(backend: &str) -> Result<ExtractorSelection, String> {
924 match backend {
925 // No `#[cfg]` here, and that absence IS the fix: this arm must survive
926 // in a build without any HTTP feature, which is exactly the build the
927 // published binary ships.
928 "outline" => Ok(ExtractorSelection::Ready(std::sync::Arc::new(
929 OutlineExtractor,
930 ))),
931 "ollama" => Ok(ExtractorSelection::NeedsRemoteConfig("ollama")),
932 // A protocol, not a vendor — see [`crate::select_embedder`]'s own
933 // `openai` arm. The two roles accept the same names on purpose: an
934 // operator who learned one has learned the other.
935 "openai" => Ok(ExtractorSelection::NeedsRemoteConfig("openai")),
936 "none" | "" => Ok(ExtractorSelection::Disabled),
937 other => Err(format!(
938 "unknown extraction backend '{other}' (expected 'outline' for the \
939 offline deterministic reader, 'ollama' for a local generative \
940 model, 'openai' for any OpenAI-compatible server — oMLX, \
941 llama.cpp, LM Studio, vLLM or a hosted provider, selected by URL \
942 rather than by name — or 'none')"
943 )),
944 }
945}
946
947// --- Optional batteries-included backend: a local Ollama generative model -----
948//
949// Enabled with `--features extract`. The default build omits this backend (and
950// its HTTP dependency) so the shipped binary stays tiny and fully offline. Like
951// the Ollama embedder, it calls a model the user already runs locally, so the
952// text never leaves the machine.
953
954/// Default Ollama base URL for the generative extraction endpoint.
955#[cfg(feature = "extract")]
956pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
957
958/// Per-request timeout. Generation is far slower and more stall-prone than an
959/// embedding call, so a wedged model fails the call instead of hanging forever.
960#[cfg(feature = "extract")]
961const REQUEST_TIMEOUT_SECS: u64 = 300;
962
963/// Ceiling on establishing the TCP connection to Ollama. Short on purpose: a
964/// local daemon accepts at once or is not running, and `ureq`'s 30 s default
965/// would be paid once per replay.
966#[cfg(feature = "extract")]
967const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
968
969/// Ceiling on writing the request (prompt upload). Unlike the read bound, this
970/// one is applied to the socket at connect time and is genuinely in force.
971#[cfg(feature = "extract")]
972const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
973
974/// Ceiling on how many tokens one extraction call may generate.
975///
976/// Unbounded, the real graph-extraction prompt measured 3 933 completion
977/// tokens for a twelve-word sentence — 1 min 59 s spent generating JSON to
978/// store one fact (#1846). The same call capped at 600 tokens measured
979/// 14.9 s. 512 sits just under that, comfortably above what any realistic
980/// sentence's worth of triples needs, and turns the worst case into a
981/// bounded one instead of a tuning knob callers have to discover by timing
982/// out.
983#[cfg(feature = "extract")]
984const MAX_GENERATION_TOKENS: u32 = 512;
985
986/// The knobs that actually configure the extractor, named in its failures.
987///
988/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
989/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
990/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
991/// path never consults — an "actionable" message that is actively wrong. There
992/// is no offline fallback to offer either: extraction is opt-in, and running
993/// without it is simply not passing an extractor.
994#[cfg(feature = "extract")]
995const EXTRACT_LEVERS: crate::http_retry::FailureLevers<'static> =
996 crate::http_retry::FailureLevers {
997 url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
998 model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
999 fallback: None,
1000 };
1001
1002/// How one generation attempt failed — transport and body failures may be
1003/// replayed, a complete response is the server's final word.
1004#[cfg(feature = "extract")]
1005enum GenerateCall {
1006 /// The request never completed. Boxed: `ureq::Error::Status` carries a
1007 /// whole `Response`.
1008 Transport(Box<ureq::Error>),
1009 /// Headers arrived but the body did not read back in full.
1010 Body(std::io::Error),
1011}
1012
1013/// Replay policy for one generation attempt.
1014#[cfg(feature = "extract")]
1015fn generate_is_retryable(err: &GenerateCall) -> bool {
1016 match err {
1017 GenerateCall::Transport(inner) => crate::http_retry::is_retryable(inner),
1018 GenerateCall::Body(inner) => crate::http_retry::io_is_retryable(inner),
1019 }
1020}
1021
1022/// Turn a failed generation into a message that names the endpoint, the model,
1023/// how many attempts were spent, and the variables that change the outcome.
1024#[cfg(feature = "extract")]
1025fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
1026 let cause = match err {
1027 GenerateCall::Transport(inner) => inner.to_string(),
1028 GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
1029 };
1030 crate::http_retry::actionable_ollama_failure(
1031 "generate",
1032 url,
1033 model,
1034 attempts,
1035 &cause,
1036 &EXTRACT_LEVERS,
1037 )
1038}
1039
1040/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
1041/// model — and therefore the source text — on the user's own machine.
1042///
1043/// The caller picks the generative model (Ollama has no universal default for
1044/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
1045/// reproducible output.
1046#[cfg(feature = "extract")]
1047#[derive(Debug, Clone)]
1048pub struct OllamaExtractor {
1049 base_url: String,
1050 model: String,
1051 agent: ureq::Agent,
1052}
1053
1054#[cfg(feature = "extract")]
1055impl OllamaExtractor {
1056 /// Build an extractor targeting `model` on the Ollama server at `base_url`
1057 /// (e.g. [`DEFAULT_OLLAMA_URL`]).
1058 ///
1059 /// The agent is bounded on four axes, not one. See
1060 /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
1061 /// subordinate to the global `timeout` in `ureq` and must not be read as a
1062 /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
1063 /// that actually bite. The connect bound matters most here: `ureq`'s own
1064 /// default is 30 s, which for a `localhost` daemon is 15x too long — and
1065 /// with replays, that idle wait would be paid three times over.
1066 #[must_use]
1067 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
1068 let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
1069 let agent = ureq::AgentBuilder::new()
1070 .timeout_connect(CONNECT_TIMEOUT)
1071 .timeout_write(WRITE_TIMEOUT)
1072 .timeout_read(timeout)
1073 .timeout(timeout)
1074 .build();
1075 Self {
1076 base_url: base_url.into(),
1077 model: model.into(),
1078 agent,
1079 }
1080 }
1081}
1082
1083/// Read a model's reply as the flat fact list [`Extractor::extract`] promises.
1084///
1085/// A free function because every generative backend produces the same reply
1086/// and reads it the same way — only the transport differs. Leaving a copy in
1087/// each `impl` would let two backends drift on what counts as a valid answer.
1088#[cfg(feature = "extract")]
1089fn facts_from_reply(reply: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1090 let raw =
1091 json_slice::<Vec<RawFact>>(reply).ok_or_else(|| ExtractError::Parse(truncate(reply)))?;
1092 Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
1093}
1094
1095/// [`facts_from_reply`]'s counterpart for [`Extractor::extract_graph`].
1096#[cfg(feature = "extract")]
1097fn extraction_from_reply(reply: &str) -> Result<Extraction, ExtractError> {
1098 let raw = json_slice_object::<RawExtraction>(reply)
1099 .ok_or_else(|| ExtractError::Parse(truncate(reply)))?;
1100 Ok(raw.into_extraction())
1101}
1102
1103#[cfg(feature = "extract")]
1104impl Extractor for OllamaExtractor {
1105 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1106 facts_from_reply(&self.generate(&build_prompt(text))?)
1107 }
1108
1109 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
1110 extraction_from_reply(&self.generate(&build_graph_prompt(text))?)
1111 }
1112}
1113
1114/// Extracts through any **OpenAI-compatible** `/v1/chat/completions` endpoint.
1115///
1116/// A sibling of [`OllamaExtractor`], not a layer over it — the same shape the
1117/// embedding role takes. The prompt stays here, on the role side: it is what
1118/// this crate wants said, not something the protocol knows about.
1119#[cfg(feature = "extract")]
1120#[derive(Debug)]
1121pub struct OpenAiExtractor {
1122 client: crate::http_client::HttpJsonClient,
1123 model: String,
1124}
1125
1126#[cfg(feature = "extract")]
1127impl OpenAiExtractor {
1128 /// Build an extractor targeting `model` on the server at `base_url`
1129 /// (origin and port, no path).
1130 ///
1131 /// Bounded on the same four axes as [`OllamaExtractor::new`], with the
1132 /// same generous [`REQUEST_TIMEOUT_SECS`]: generation is slow wherever it
1133 /// runs, and the ceiling belongs to the role, not to the transport.
1134 #[must_use]
1135 pub fn new(
1136 base_url: impl Into<String>,
1137 model: impl Into<String>,
1138 auth: crate::http_client::Auth,
1139 ) -> Self {
1140 let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
1141 let agent = ureq::AgentBuilder::new()
1142 .timeout_connect(CONNECT_TIMEOUT)
1143 .timeout_write(WRITE_TIMEOUT)
1144 .timeout_read(timeout)
1145 .timeout(timeout)
1146 .build();
1147 Self {
1148 client: crate::http_client::HttpJsonClient::new(
1149 crate::openai::base_url(&base_url.into()),
1150 auth,
1151 agent,
1152 ),
1153 model: model.into(),
1154 }
1155 }
1156
1157 /// POST one prompt and return the assistant's reply.
1158 fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
1159 let body = crate::openai::chat_body(&self.model, prompt, MAX_GENERATION_TOKENS);
1160 let payload = self
1161 .client
1162 .post_json(crate::openai::CHAT_COMPLETIONS_PATH, &body)
1163 .map_err(|failure| {
1164 ExtractError::Backend(crate::http_retry::actionable_openai_failure(
1165 "chat/completions",
1166 &failure.url,
1167 &self.model,
1168 failure.attempts,
1169 &failure.cause,
1170 Some(
1171 "use the offline deterministic reader with \
1172 VELESDB_MEMORY_EXTRACTOR=outline",
1173 ),
1174 ))
1175 })?;
1176 crate::openai::parse_chat_response(&payload).map_err(ExtractError::Backend)
1177 }
1178}
1179
1180#[cfg(feature = "extract")]
1181impl Extractor for OpenAiExtractor {
1182 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1183 facts_from_reply(&self.generate(&build_prompt(text))?)
1184 }
1185
1186 fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
1187 extraction_from_reply(&self.generate(&build_graph_prompt(text))?)
1188 }
1189}
1190
1191#[cfg(feature = "extract")]
1192impl OllamaExtractor {
1193 /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
1194 /// replaying the call when the failure is transient.
1195 ///
1196 /// Same defect, same repair as the embedder: this extractor also holds one
1197 /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
1198 /// Ollama may have closed, and `ureq` will not replay a POST with a body.
1199 /// The whole attempt — POST and body read — is inside the closure so a
1200 /// truncated response is replayed rather than surfacing as a parse error.
1201 fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
1202 let url = format!("{}/api/generate", self.base_url);
1203 let body = serde_json::json!({
1204 "model": self.model,
1205 "prompt": prompt,
1206 "stream": false,
1207 "think": false,
1208 // Extraction models are large — the one this crate documents as an
1209 // example is 21.9 GB — so an unload between calls is the dominant
1210 // cost, not the generation. Shares the embedder's knob so one
1211 // setting governs every Ollama call the daemon makes.
1212 "keep_alive": crate::embedder::keep_alive(),
1213 "options": { "temperature": 0, "num_predict": MAX_GENERATION_TOKENS },
1214 })
1215 .to_string();
1216 let attempt = || {
1217 let response = self
1218 .agent
1219 .post(&url)
1220 .set("Content-Type", "application/json")
1221 .send_string(&body)
1222 .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
1223 response.into_string().map_err(GenerateCall::Body)
1224 };
1225
1226 let payload = crate::http_retry::with_retry(
1227 &crate::http_retry::HTTP_RETRIES,
1228 generate_is_retryable,
1229 attempt,
1230 )
1231 .map_err(|(err, attempts)| {
1232 ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
1233 })?;
1234 parse_generate_response(&payload)
1235 }
1236}
1237
1238/// The strict JSON contract the extraction prompt asks the model to honour.
1239#[cfg(feature = "extract")]
1240#[derive(serde::Deserialize)]
1241struct RawFact {
1242 fact: String,
1243 #[serde(default)]
1244 entities: Vec<String>,
1245}
1246
1247#[cfg(feature = "extract")]
1248impl RawFact {
1249 /// Keep a fact only if it has text; trim and lowercase its topics, dropping
1250 /// blanks and duplicates so the same topic recurs as the same graph hub.
1251 fn into_fact(self) -> Option<ExtractedFact> {
1252 let text = self.fact.trim().to_string();
1253 if text.is_empty() {
1254 return None;
1255 }
1256 let mut entities: Vec<String> = self
1257 .entities
1258 .into_iter()
1259 .map(|entity| entity.trim().to_lowercase())
1260 .filter(|entity| !entity.is_empty())
1261 .collect();
1262 entities.sort_unstable();
1263 entities.dedup();
1264 Some(ExtractedFact { text, entities })
1265 }
1266}
1267
1268/// Canonical form of an entity name: trimmed and lowercased. The one place
1269/// the rule lives, so a name arriving as a topic, as a relation endpoint, or
1270/// as an attribute owner always resolves to the SAME entity hub.
1271#[cfg(feature = "extract")]
1272fn canonical_entity(name: &str) -> String {
1273 name.trim().to_lowercase()
1274}
1275
1276/// The strict JSON contract the *graph* extraction prompt asks for.
1277#[cfg(feature = "extract")]
1278#[derive(serde::Deserialize)]
1279struct RawExtraction {
1280 #[serde(default)]
1281 facts: Vec<RawFact>,
1282 #[serde(default)]
1283 relations: Vec<RawRelation>,
1284 #[serde(default)]
1285 attributes: Vec<RawAttribute>,
1286}
1287
1288#[cfg(feature = "extract")]
1289#[derive(serde::Deserialize)]
1290struct RawRelation {
1291 subject: String,
1292 predicate: String,
1293 object: String,
1294}
1295
1296#[cfg(feature = "extract")]
1297#[derive(serde::Deserialize)]
1298struct RawAttribute {
1299 entity: String,
1300 key: String,
1301 value: serde_json::Value,
1302}
1303
1304#[cfg(feature = "extract")]
1305impl RawExtraction {
1306 /// Canonicalize and drop the unusable: a relation missing an endpoint or a
1307 /// label, an attribute missing an owner or a name. A malformed item is
1308 /// skipped rather than failing the whole passage — one bad triple must not
1309 /// cost the caller every good fact in the same reply.
1310 fn into_extraction(self) -> Extraction {
1311 Extraction {
1312 facts: self
1313 .facts
1314 .into_iter()
1315 .filter_map(RawFact::into_fact)
1316 .collect(),
1317 relations: self
1318 .relations
1319 .into_iter()
1320 .filter_map(RawRelation::into_relation)
1321 .collect(),
1322 attributes: self
1323 .attributes
1324 .into_iter()
1325 .filter_map(RawAttribute::into_attribute)
1326 .collect(),
1327 }
1328 }
1329}
1330
1331#[cfg(feature = "extract")]
1332impl RawRelation {
1333 fn into_relation(self) -> Option<ExtractedRelation> {
1334 let subject = canonical_entity(&self.subject);
1335 let object = canonical_entity(&self.object);
1336 let predicate = self.predicate.trim().to_string();
1337 // A self-loop carries no information and would sit in the graph as a
1338 // permanent dead end, so it is dropped alongside the incomplete ones.
1339 if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
1340 return None;
1341 }
1342 Some(ExtractedRelation {
1343 subject,
1344 predicate,
1345 object,
1346 })
1347 }
1348}
1349
1350#[cfg(feature = "extract")]
1351impl RawAttribute {
1352 fn into_attribute(self) -> Option<ExtractedAttribute> {
1353 let entity = canonical_entity(&self.entity);
1354 let key = self.key.trim().to_string();
1355 // A null value is the model saying "not stated"; storing it would make
1356 // an absent attribute look like a known-empty one.
1357 if entity.is_empty() || key.is_empty() || self.value.is_null() {
1358 return None;
1359 }
1360 Some(ExtractedAttribute {
1361 entity,
1362 key,
1363 value: self.value,
1364 })
1365 }
1366}
1367
1368/// Build the *graph* extraction prompt: the passage plus a strict JSON
1369/// contract covering facts, entity→entity edges, and entity attributes.
1370///
1371/// The contract insists numbers stay JSON numbers. `recall_where` compares
1372/// type-strictly, so an age emitted as `"15"` would never match `age >= 15` —
1373/// no error, just a silent miss, which is the worst possible failure mode for
1374/// a memory system.
1375#[cfg(feature = "extract")]
1376fn build_graph_prompt(text: &str) -> String {
1377 format!(
1378 "You are building a knowledge graph from the passage below.\n\n\
1379Passage:\n{text}\n\n\
1380STEP 0 — Identify the passage's language. Everything you write (facts, \
1381predicates, attribute keys) MUST be in THAT language. Do not copy the language \
1382of the examples below: they are shown in several languages on purpose, and you \
1383must match the PASSAGE, never the example.\n\n\
1384Return THREE things.\n\n\
13851. \"facts\": the atomic, standalone facts a person would remember, in the \
1386passage's language. Rewrite each as a self-contained sentence (resolve \
1387pronouns to names; keep absolute dates). For each, list 1-4 key TOPICS it \
1388concerns, as short canonical lowercase noun phrases, so the same topic recurs \
1389as the SAME tag across passages.\n\n\
13902. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
1391subject/predicate/object triples. Use the entity's full name, lowercase \
1392(e.g. \"bruno durand\").\n\
1393The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
1394the passage's language. Examples of the SHAPE, each in its own language — \
1395match the passage, not these: a French passage gives \"travaille chez\", \
1396\"pere de\"; an English passage gives \"works at\", \"father of\"; a Spanish \
1397passage gives \"trabaja en\". NEVER restate the sentence — write \"surveille \
1398les fuites\", not \"est utilise pour la surveillance de fuites de donnees\". \
1399If you cannot say it in 3 words, pick the closest short label.\n\
1400DIRECTION: the subject is whoever CARRIES the relation, not the subject of the \
1401sentence. \"A a une soeur, B\" means B is A's sister, so the triple is \
1402B/\"soeur de\"/A — never A/\"soeur de\"/B. \"A has a brother, B\" means B is \
1403A's brother: B/\"brother of\"/A. Same for every possessive.\n\
1404Never emit both directions of the SAME predicate over the same pair — \
1405\"X brother of Y\" plus \"Y brother of X\" is a contradiction, not a \
1406converse: emit exactly one. But two DIFFERENT predicates the passage states \
1407separately over the same pair (\"A possede B\" then \"B appartient a A\") \
1408are two stated facts — keep both.\n\
1409Every named entity the passage RELATES to another must appear in at least one \
1410triple — an entity that only receives attributes and no edge is a dead end in \
1411the graph.\n\n\
14123. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
1413short lowercase keys in the passage's language (\"age\", \"ville\", \
1414\"employeur\"). Emit numbers as JSON NUMBERS, never strings: 15, not \"15\". \
1415Omit anything the passage does not state.\n\n\
1416Return ONLY this JSON object, no prose, no markdown fence:\n\
1417{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
1418\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
1419\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
1420 )
1421}
1422
1423/// Build the extraction prompt: the passage plus a strict JSON contract.
1424#[cfg(feature = "extract")]
1425fn build_prompt(text: &str) -> String {
1426 format!(
1427 "You are building a memory graph from the passage below.\n\n\
1428Passage:\n{text}\n\n\
1429Extract the atomic, standalone facts a person would remember. Rewrite each as a \
1430self-contained sentence (resolve pronouns to names; keep absolute dates). For \
1431each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
1432activities, events, interests, plans, places, organisations, or named people a \
1433later question might reference. Use short, canonical, lowercase noun phrases \
1434(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
1435recurs as the SAME tag across passages.\n\n\
1436Return ONLY a JSON array, no prose, each item exactly:\n\
1437{{\"fact\": string, \"entities\": [string]}}"
1438 )
1439}
1440
1441/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
1442#[cfg(feature = "extract")]
1443fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
1444 let value: serde_json::Value = serde_json::from_str(body)
1445 .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
1446 let text = value
1447 .get("response")
1448 .and_then(serde_json::Value::as_str)
1449 .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
1450 Ok(text.trim().to_string())
1451}
1452
1453/// A short, single-line preview of model output for error messages.
1454#[cfg(feature = "extract")]
1455fn truncate(text: &str) -> String {
1456 const LIMIT: usize = 120;
1457 let mut out = String::new();
1458 for word in text.split_whitespace() {
1459 // Check the budget *before* pushing so we never need a post-hoc
1460 // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
1461 let sep_len = usize::from(!out.is_empty());
1462 if out.len() + sep_len + word.len() > LIMIT {
1463 break;
1464 }
1465 if !out.is_empty() {
1466 out.push(' ');
1467 }
1468 out.push_str(word);
1469 }
1470 out
1471}
1472
1473/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
1474/// Local models usually honour "return only JSON" but occasionally wrap it in
1475/// fences or a sentence; slicing the first balanced span tolerates that.
1476#[cfg(feature = "extract")]
1477fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1478 let slice = balanced_slice(text)?;
1479 serde_json::from_str::<T>(slice).ok()
1480}
1481
1482/// [`json_slice`] for a reply whose top level is a JSON **object**.
1483///
1484/// The array-preferring form cannot be reused: the graph reply is
1485/// `{"facts": [...], ...}`, whose first `[` belongs to a *nested* field, so
1486/// preferring arrays slices out the inner facts list and then fails to read it
1487/// as the whole extraction. That failure is invisible to a stub-backed test —
1488/// only a real model reply goes through this path.
1489#[cfg(feature = "extract")]
1490fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1491 let slice = balanced_slice_preferring(text, b'{')?;
1492 serde_json::from_str::<T>(slice).ok()
1493}
1494
1495/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
1496/// string literals and escapes so brackets inside quotes don't miscount.
1497///
1498/// Prefers an array: the fact-only reply is a JSON list, and prose before it
1499/// ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
1500/// object span instead of the array.
1501#[cfg(feature = "extract")]
1502fn balanced_slice(text: &str) -> Option<&str> {
1503 balanced_slice_preferring(text, b'[')
1504}
1505
1506/// [`balanced_slice`] with the caller choosing which delimiter wins when both
1507/// appear — the shape the caller actually expects at the top level.
1508#[cfg(feature = "extract")]
1509fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
1510 let bytes = text.as_bytes();
1511 let fallback = if preferred == b'[' { b'{' } else { b'[' };
1512 let start = bytes
1513 .iter()
1514 .position(|&b| b == preferred)
1515 .or_else(|| bytes.iter().position(|&b| b == fallback))?;
1516 let open = bytes[start];
1517 let close = if open == b'[' { b']' } else { b'}' };
1518 let mut depth = 0u32;
1519 let mut in_string = false;
1520 let mut escaped = false;
1521 for (offset, &byte) in bytes[start..].iter().enumerate() {
1522 if in_string {
1523 in_string = step_string(&mut escaped, byte);
1524 } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
1525 return Some(&text[start..=start + offset]);
1526 }
1527 }
1528 None
1529}
1530
1531/// Advance the structural scan for one out-of-string byte; returns `true` once
1532/// the outermost bracket has just closed (`depth` back to zero).
1533#[cfg(feature = "extract")]
1534fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
1535 if byte == b'"' {
1536 *in_string = true;
1537 } else if byte == open {
1538 *depth += 1;
1539 } else if byte == close {
1540 *depth = depth.saturating_sub(1);
1541 return *depth == 0;
1542 }
1543 false
1544}
1545
1546/// Advance the in-string escape state for one byte; returns whether the scanner
1547/// is still inside the string literal afterwards.
1548#[cfg(feature = "extract")]
1549fn step_string(escaped: &mut bool, byte: u8) -> bool {
1550 match (*escaped, byte) {
1551 (true, _) => {
1552 *escaped = false;
1553 true
1554 }
1555 (false, b'\\') => {
1556 *escaped = true;
1557 true
1558 }
1559 (false, b'"') => false,
1560 (false, _) => true,
1561 }
1562}
1563
1564#[cfg(test)]
1565#[path = "extractor_selection_tests.rs"]
1566mod selection_tests;
1567
1568#[cfg(all(test, feature = "extract"))]
1569mod tests {
1570 use super::*;
1571
1572 /// Regression: the graph reply is an OBJECT whose first `[` belongs to the
1573 /// nested `facts` field. Slicing with the array preference grabbed that
1574 /// inner list and failed to read it as the whole extraction — a real model
1575 /// reply was rejected wholesale while every stub-backed test stayed green.
1576 #[test]
1577 fn parses_a_graph_reply_whose_first_bracket_is_nested() {
1578 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 } ] }"#;
1579 let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
1580 let extraction = raw.into_extraction();
1581 assert_eq!(extraction.facts.len(), 1);
1582 assert_eq!(extraction.relations.len(), 1);
1583 // Endpoints asserted by name: a subject↔object swap in
1584 // `into_relation` kept this test green when only the predicate was
1585 // checked (#1792).
1586 assert_eq!(extraction.relations[0].subject, "zephyrin");
1587 assert_eq!(extraction.relations[0].predicate, "pere de");
1588 assert_eq!(extraction.relations[0].object, "kaltar");
1589 assert_eq!(extraction.attributes.len(), 1);
1590 assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
1591 }
1592
1593 /// Prose (and a fenced block) around the object must not defeat slicing.
1594 #[test]
1595 fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
1596 let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
1597 let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
1598 assert_eq!(raw.into_extraction().relations.len(), 1);
1599 }
1600
1601 /// The fact-only path must keep preferring an array: prose carrying a stray
1602 /// `{` before the list is exactly what that preference exists to survive.
1603 #[test]
1604 fn fact_only_slicing_still_prefers_the_array() {
1605 let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
1606 let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
1607 assert_eq!(raw.len(), 1);
1608 }
1609
1610 #[test]
1611 fn graph_prompt_demands_numeric_values_and_the_three_sections() {
1612 let prompt = build_graph_prompt("Kaltar a 15 ans.");
1613 assert!(prompt.contains("Kaltar a 15 ans."));
1614 assert!(prompt.contains("\"relations\""));
1615 assert!(prompt.contains("\"attributes\""));
1616 assert!(prompt.contains("15, not \"15\""));
1617 }
1618
1619 #[test]
1620 fn prompt_carries_the_passage_and_json_contract() {
1621 let prompt = build_prompt("Alice adopted a dog in 2021.");
1622 assert!(prompt.contains("Alice adopted a dog in 2021."));
1623 assert!(prompt.contains("\"fact\": string"));
1624 }
1625
1626 /// The graph prompt has to bound the predicate explicitly. Asking for "a
1627 /// short label" was not enough: on real content the model answered
1628 /// "est utilise pour la surveillance de fuites de donnees" — a restated
1629 /// sentence, which makes the edge unreadable in `entity()`.
1630 #[test]
1631 fn graph_prompt_bounds_the_predicate_and_demands_edges() {
1632 let prompt = build_graph_prompt("Ahmia is an onion search engine.");
1633 assert!(prompt.contains("Ahmia is an onion search engine."));
1634 assert!(
1635 prompt.contains("at most 3 words"),
1636 "the predicate length must be a hard bound, not a suggestion"
1637 );
1638 assert!(
1639 prompt.contains("NEVER restate the sentence"),
1640 "the counter-example is what stops a restated sentence"
1641 );
1642 assert!(
1643 prompt.contains("at least one triple"),
1644 "an entity with attributes but no edge is a dead end — the prompt \
1645 must ask for the edge"
1646 );
1647 }
1648
1649 /// The prompt must make the language rule SYMMETRIC (#1846). Its previous
1650 /// form gave predicate examples in mixed languages with no rule tying the
1651 /// answer to the passage: on an English passage, `default:fast` emitted
1652 /// `frere de` (French) — and a graph holding both `works at` and
1653 /// `travaille chez` for the same relation fragments it into two
1654 /// predicates. Measured fix: with this rule the same model passes both
1655 /// languages; a one-sided rule ("answer in French") merely inverted the
1656 /// defect.
1657 #[test]
1658 fn graph_prompt_ties_every_output_to_the_passage_language() {
1659 let prompt = build_graph_prompt("Sarah Miller has a brother, Tom Miller.");
1660 assert!(
1661 prompt.contains("Identify the passage's language"),
1662 "the language rule must be an explicit first step, not an aside"
1663 );
1664 assert!(
1665 prompt.contains("match the PASSAGE, never the example"),
1666 "mixed-language examples are load-bearing — the rule must say they \
1667 are examples of SHAPE, not of language"
1668 );
1669 }
1670
1671 /// The prompt must forbid the fake converse (#1846). Its previous form
1672 /// said "add the converse ONLY if the passage states it too", and on an
1673 /// English passage `default:fast` still emitted BOTH `tom brother of
1674 /// sarah` AND `sarah brother of tom` — each the sibling of the other, a
1675 /// contradiction `orient_kinship` repairs for kinship only: `works at` /
1676 /// `manages` would ship inverted. The rule must name the failure, not
1677 /// just permit its absence.
1678 #[test]
1679 fn graph_prompt_forbids_both_directions_of_one_predicate() {
1680 let prompt = build_graph_prompt("Sarah Miller has a brother, Tom Miller.");
1681 assert!(
1682 prompt.contains("emit exactly one"),
1683 "the one-per-predicate rule must be stated as a hard bound"
1684 );
1685 assert!(
1686 prompt.contains("keep both"),
1687 "the rule must carry its POSITIVE half too: two different \
1688 predicates stated separately are two facts — without it a model \
1689 collapses a real converse pair into one edge (measured 3/3 on \
1690 the bench's converse case)"
1691 );
1692 assert!(
1693 prompt.contains("is a contradiction, not a converse"),
1694 "the counter-example is what makes the rule unambiguous — the same \
1695 device the carrier rule below relies on"
1696 );
1697 }
1698
1699 /// The prompt must state the possessive rule explicitly: asked only to
1700 /// "state the triple in the direction the passage states it", the model
1701 /// read the grammatical subject as the subject of the triple and mirrored
1702 /// every possessive.
1703 #[test]
1704 fn graph_prompt_states_which_side_carries_the_relation() {
1705 let prompt = build_graph_prompt("Theo Durand a une soeur, Camille Durand.");
1706 assert!(
1707 prompt.contains("whoever CARRIES the relation"),
1708 "the rule must name the carrier, not just \"the direction\""
1709 );
1710 assert!(
1711 prompt.contains("never A/\"soeur de\"/B"),
1712 "the counter-example is what makes the rule unambiguous"
1713 );
1714 }
1715
1716 #[test]
1717 fn parses_facts_from_a_fenced_reply() {
1718 let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
1719 let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
1720 let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
1721 assert_eq!(facts.len(), 1);
1722 assert_eq!(facts[0].text, "Alice adopted a dog.");
1723 // Trimmed, lowercased, deduplicated, blanks dropped.
1724 assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
1725 }
1726
1727 #[test]
1728 fn drops_a_textless_fact() {
1729 let raw = RawFact {
1730 fact: " ".to_string(),
1731 entities: vec!["x".to_string()],
1732 };
1733 assert!(raw.into_fact().is_none());
1734 }
1735
1736 #[test]
1737 fn parses_response_envelope() {
1738 let text = parse_generate_response(r#"{"response":" [] "}"#).expect("parse");
1739 assert_eq!(text, "[]");
1740 }
1741
1742 #[test]
1743 fn rejects_response_without_field() {
1744 assert!(matches!(
1745 parse_generate_response(r#"{"oops":true}"#),
1746 Err(ExtractError::Backend(_))
1747 ));
1748 }
1749}