Skip to main content

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}