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.
123const KINSHIP_NOUNS: &[&str] = &[
124    // Blood ties, fr.
125    "pere",
126    "mere",
127    "frere",
128    "soeur",
129    "fils",
130    "fille",
131    "oncle",
132    "tante",
133    "cousin",
134    "cousine",
135    "neveu",
136    "niece",
137    "grand-pere",
138    "grand-mere",
139    "grand-oncle",
140    "grand-tante",
141    "arriere-grand-pere",
142    "arriere-grand-mere",
143    "petit-fils",
144    "petite-fille",
145    // Alliances and step-family, fr.
146    "beau-pere",
147    "belle-mere",
148    "beau-frere",
149    "belle-soeur",
150    "beau-fils",
151    "belle-fille",
152    "gendre",
153    "bru",
154    "demi-frere",
155    "demi-soeur",
156    "parrain",
157    "marraine",
158    "filleul",
159    "filleule",
160    "epoux",
161    "epouse",
162    "mari",
163    "femme",
164    // Blood ties, en.
165    "father",
166    "mother",
167    "brother",
168    "sister",
169    "son",
170    "daughter",
171    "uncle",
172    "aunt",
173    "nephew",
174    "grandfather",
175    "grandmother",
176    "grandson",
177    "granddaughter",
178    // Alliances and step-family, en.
179    "husband",
180    "wife",
181    "father-in-law",
182    "mother-in-law",
183    "brother-in-law",
184    "sister-in-law",
185    "son-in-law",
186    "daughter-in-law",
187    "stepfather",
188    "stepmother",
189    "stepbrother",
190    "stepsister",
191    "half-brother",
192    "half-sister",
193    "godfather",
194    "godmother",
195    "godson",
196    "goddaughter",
197];
198
199/// What precedes the kinship noun when the sentence hangs the relation on the
200/// person it introduces rather than on its own subject. The trailing space is
201/// load-bearing: without it `" a un "` would also fire on `"a une"`.
202///
203/// The counting determiners are what make a plural construction readable at
204/// all: `"a deux soeurs, Camille et Lea"` matches no singular marker.
205const POSSESSIVE_MARKERS: &[&str] = &[
206    " a un ",
207    " a une ",
208    " a pour ",
209    " a des ",
210    " a deux ",
211    " a trois ",
212    " a quatre ",
213    " has a ",
214    " has an ",
215    " has two ",
216    " has three ",
217    " has four ",
218];
219
220/// What sits between a kinship noun and the name of whoever HOLDS it in a
221/// genitive: `"la soeur DE Theo"`, `"the sister OF Theo"`.
222const GENITIVE_LINKS: &[&str] = &[" de ", " d'", " of "];
223
224/// The clitic the English genitive marks its holder with, holder first:
225/// `"Theo's sister is Camille"`.
226const SAXON_MARKER: &str = "'s ";
227
228/// The copula that closes a genitive and introduces its carrier:
229/// `"la soeur de Theo EST Camille"`.
230const GENITIVE_COPULAS: &[&str] = &[" est ", " sont ", " is ", " are "];
231
232/// Articles a copula may put in front of the name it introduces. Stepping over
233/// one is what lets the carrier still be *required* to sit right after the
234/// copula, which is the whole of the genitive's safety.
235const LEADING_ARTICLES: &[&str] = &["le ", "la ", "les ", "l'", "the "];
236
237/// What may join two carriers of one construction: `"Camille Durand ET Lea
238/// Durand"`. Longest first, so `", et "` is never read as `", "` followed by
239/// something that is not a name — which would end the walk one carrier early.
240const ENUMERATION_SEPARATORS: &[&str] = &[", et ", ", and ", " et ", " and ", " & ", ", "];
241
242/// Diacritics and ligatures folded to ASCII, so `"sœur"`, `"soeur"` and
243/// `"Sœur"` are one token — the passage and the model's label rarely agree on
244/// accents, and the whole pass hinges on matching one against the other.
245const FOLDINGS: &[(char, &str)] = &[
246    ('à', "a"),
247    ('â', "a"),
248    ('ä', "a"),
249    ('é', "e"),
250    ('è', "e"),
251    ('ê', "e"),
252    ('ë', "e"),
253    ('î', "i"),
254    ('ï', "i"),
255    ('ô', "o"),
256    ('ö', "o"),
257    ('ù', "u"),
258    ('û', "u"),
259    ('ü', "u"),
260    ('ç', "c"),
261    ('œ', "oe"),
262    ('æ', "ae"),
263    // A typographic apostrophe, so `"Theo’s"` and `"d’Theo"` reach the same
264    // matchers as their ASCII spellings — most editors substitute it silently.
265    ('\u{2019}', "'"),
266];
267
268/// Lowercase `text` and fold its diacritics away. Every offset produced from
269/// the result indexes the *folded* string, never the original.
270fn fold(text: &str) -> String {
271    let mut folded = String::with_capacity(text.len());
272    for ch in text.chars().flat_map(char::to_lowercase) {
273        match FOLDINGS.iter().find(|(from, _)| *from == ch) {
274            Some((_, to)) => folded.push_str(to),
275            None => folded.push(ch),
276        }
277    }
278    folded
279}
280
281/// A kinship relation the passage states: every one of `bearers` carries
282/// `noun`, and `holder` is the one they carry it toward.
283struct Kinship {
284    noun: &'static str,
285    holder: String,
286    bearers: Vec<String>,
287}
288
289/// How many bytes `word` occupies at the start of `rest` when it is written
290/// there as a whole word, a plural `s` included. `None` when it is not.
291///
292/// `"soeurette"` therefore never reads as `"soeur"`, and — the case that
293/// matters — `"brother-in-law"` never reads as `"brother"`: a hyphen CONTINUES
294/// a compound noun, so it bars the match exactly like a letter. Without that,
295/// the pass would recognise the bare noun, then treat the sentence's own label
296/// as the *converse* of it and point the edge precisely the wrong way. A
297/// missing edge would have been the better outcome.
298fn word_prefix_len(rest: &str, word: &str) -> Option<usize> {
299    let tail = rest.strip_prefix(word)?;
300    let (tail, plural) = match tail.strip_prefix('s') {
301        Some(shorter) => (shorter, 1),
302        None => (tail, 0),
303    };
304    let glued = |ch: char| ch.is_alphanumeric() || ch == '-';
305    (!tail.starts_with(glued)).then_some(word.len() + plural)
306}
307
308/// Whether `head` ENDS on `word` as a whole word, a plural `s` included — the
309/// mirror of [`word_prefix_len`], for the genitive, where the noun precedes its
310/// link instead of following a marker.
311fn ends_with_word(head: &str, word: &str) -> bool {
312    ends_exactly(head, word)
313        || head
314            .strip_suffix('s')
315            .is_some_and(|singular| ends_exactly(singular, word))
316}
317
318/// `head` ends on `word` with no letter and no hyphen glued in front of it, so
319/// `"la belle-soeur"` is never read as ending on `"soeur"`.
320fn ends_exactly(head: &str, word: &str) -> bool {
321    head.strip_suffix(word)
322        .is_some_and(|lead| !lead.ends_with(|ch: char| ch.is_alphanumeric() || ch == '-'))
323}
324
325/// The kinship noun written at the start of `text`, and how many bytes it
326/// occupies there.
327fn noun_at(text: &str) -> Option<(&'static str, usize)> {
328    KINSHIP_NOUNS
329        .iter()
330        .find_map(|noun| word_prefix_len(text, noun).map(|len| (*noun, len)))
331}
332
333/// The kinship noun `head` ends on.
334fn noun_before(head: &str) -> Option<&'static str> {
335    KINSHIP_NOUNS
336        .iter()
337        .copied()
338        .find(|noun| ends_with_word(head, noun))
339}
340
341/// The text left once the first of `prefixes` that `text` starts with is
342/// stepped over.
343fn strip_any<'a>(text: &'a str, prefixes: &[&str]) -> Option<&'a str> {
344    prefixes.iter().find_map(|prefix| text.strip_prefix(prefix))
345}
346
347/// Every distinct entity the triples name, deduplicated.
348fn endpoint_names(relations: &[ExtractedRelation]) -> Vec<String> {
349    let mut names: Vec<String> = relations
350        .iter()
351        .flat_map(|relation| [relation.subject.clone(), relation.object.clone()])
352        .collect();
353    names.sort_unstable();
354    names.dedup();
355    names
356}
357
358/// The endpoint named closest to the left of the noun: the person who HAS the
359/// relative.
360fn holder_of(before: &str, names: &[String]) -> Option<String> {
361    names
362        .iter()
363        .filter_map(|name| before.rfind(&fold(name)).map(|at| (at, name)))
364        .max_by_key(|(at, _)| *at)
365        .map(|(_, name)| name.clone())
366}
367
368/// The longest endpoint name written exactly at the start of `text`. Longest,
369/// so `"theo durand"` wins over a bare `"theo"` that also happens to be an
370/// endpoint — the shorter one would leave `" durand"` in front of the walk and
371/// silently truncate the enumeration.
372fn name_at(text: &str, names: &[String]) -> Option<String> {
373    names
374        .iter()
375        .filter(|name| text.starts_with(&fold(name)))
376        .max_by_key(|name| name.len())
377        .cloned()
378}
379
380/// The longest endpoint name `head` ENDS on — who a clitic belongs to.
381fn name_before(head: &str, names: &[String]) -> Option<String> {
382    names
383        .iter()
384        .filter(|name| head.ends_with(&fold(name)))
385        .max_by_key(|name| name.len())
386        .cloned()
387}
388
389/// The first endpoint named anywhere in `text`, and where its mention begins.
390fn first_name(text: &str, names: &[String]) -> Option<(usize, String)> {
391    names
392        .iter()
393        .filter_map(|name| text.find(&fold(name)).map(|at| (at, name)))
394        .min_by_key(|(at, name)| (*at, std::cmp::Reverse(name.len())))
395        .map(|(at, name)| (at, name.clone()))
396}
397
398/// `first`, plus every further endpoint the SAME enumeration lists after it.
399///
400/// The walk stops at the first thing that is not a separator followed by an
401/// endpoint. That bound is what keeps a following sentence from contributing a
402/// carrier — re-pointing "Bruno est le pere de Theo" as though Bruno were a
403/// sister of Theo is far worse than the edge it would have added.
404fn enumeration_from(text: &str, first: String, names: &[String]) -> Vec<String> {
405    let mut rest = &text[fold(&first).len()..];
406    let mut bearers = vec![first];
407    while let Some((name, tail)) = next_enumerated(rest, names) {
408        bearers.push(name);
409        rest = tail;
410    }
411    bearers
412}
413
414/// Verbs that mark the name before them as the SUBJECT of a new clause
415/// rather than another item in a list.
416///
417/// `", et "` and `" et "` are enumeration separators AND the way French joins
418/// two clauses, so the separator alone cannot tell "a sister, Camille, and
419/// Lea" from "a sister, Camille, and Bruno IS the father of Marie". What
420/// separates them is what follows the name: an item is followed by another
421/// separator or by the end of its clause, a subject is followed by a verb.
422const CLAUSE_VERBS: &[&str] = &[
423    " est ",
424    " sont ",
425    " etait ",
426    " etaient ",
427    " a ",
428    " ont ",
429    " avait ",
430    " avaient ",
431    " is ",
432    " are ",
433    " was ",
434    " were ",
435    " has ",
436    " have ",
437    " had ",
438];
439
440/// The next endpoint of an enumeration, and what follows its mention.
441///
442/// Returns `None` when the name opens a new clause — bounding the walk at the
443/// sentence is not enough, because a sentence holds several clauses. Letting
444/// one through re-points an edge the passage states CORRECTLY: "Bruno est le
445/// pere de Marie" became "Marie est le pere de Bruno", a confident falsehood
446/// where there had been none. Strictly worse than the edge the walk exists to
447/// add.
448fn next_enumerated<'a>(rest: &'a str, names: &[String]) -> Option<(String, &'a str)> {
449    let tail = strip_any(rest, ENUMERATION_SEPARATORS)?;
450    let name = name_at(tail, names)?;
451    let cut = fold(&name).len();
452    let after = &tail[cut..];
453    if CLAUSE_VERBS.iter().any(|verb| after.starts_with(verb)) {
454        return None;
455    }
456    Some((name, after))
457}
458
459/// The carriers a possessive introduces: the first endpoint named after the
460/// noun, plus the rest of its enumeration.
461fn bearers_after(after: &str, names: &[String]) -> Vec<String> {
462    match first_name(after, names) {
463        Some((at, first)) => enumeration_from(&after[at..], first, names),
464        None => Vec::new(),
465    }
466}
467
468/// The carriers written RIGHT at the start of `text`, one article tolerated.
469/// Requiring them there is what keeps a genitive from reaching across a clause
470/// it does not own.
471fn bearers_at(text: &str, names: &[String]) -> Vec<String> {
472    [Some(text), strip_any(text, LEADING_ARTICLES)]
473        .into_iter()
474        .flatten()
475        .find_map(|text| name_at(text, names).map(|first| enumeration_from(text, first, names)))
476        .unwrap_or_default()
477}
478
479/// The earliest possessive construction in `folded`: `"X a une soeur, Y"`.
480fn find_possessive(folded: &str, names: &[String]) -> Option<Kinship> {
481    let (start, noun, end) = POSSESSIVE_MARKERS
482        .iter()
483        .filter_map(|marker| folded.find(marker).map(|at| at + marker.len()))
484        .filter_map(|start| {
485            let (noun, len) = noun_at(folded.get(start..)?)?;
486            Some((start, noun, start + len))
487        })
488        .min_by_key(|(start, _, _)| *start)?;
489    Some(Kinship {
490        noun,
491        holder: holder_of(folded.get(..start)?, names)?,
492        bearers: bearers_after(folded.get(end..)?, names),
493    })
494}
495
496/// The earliest genitive construction in `folded`, either word order.
497fn find_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
498    find_of_genitive(folded, names).or_else(|| find_saxon_genitive(folded, names))
499}
500
501/// `"<noun> de <holder> est <bearer>"` — the French genitive and its English
502/// `"of"` twin, scanned left to right so the earliest reading wins.
503fn find_of_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
504    let mut links: Vec<(usize, usize)> = GENITIVE_LINKS
505        .iter()
506        .flat_map(|link| folded.match_indices(link).map(|(at, m)| (at, m.len())))
507        .collect();
508    links.sort_unstable();
509    links
510        .into_iter()
511        .find_map(|(at, len)| of_genitive_at(folded, at, len, names))
512}
513
514/// One `"<noun> de <holder> est <bearer>"` reading, anchored on the link at
515/// `at`.
516///
517/// Every step has to hold exactly — the noun ENDS where the link starts, the
518/// holder STARTS where it ends, and the copula follows the holder's name
519/// immediately. That is what keeps a copule out: "Camille est la soeur de Theo"
520/// carries the very same `"<noun> de <holder>"` fragment and is already right,
521/// but its "est" sits on the wrong side of the noun, so nothing follows the
522/// holder and the reading is rejected rather than mirrored.
523fn of_genitive_at(folded: &str, at: usize, len: usize, names: &[String]) -> Option<Kinship> {
524    let noun = noun_before(folded.get(..at)?)?;
525    let after_link = folded.get(at + len..)?;
526    let holder = name_at(after_link, names)?;
527    let after_holder = after_link.get(fold(&holder).len()..)?;
528    let bearers = bearers_at(strip_any(after_holder, GENITIVE_COPULAS)?, names);
529    Some(Kinship {
530        noun,
531        holder,
532        bearers,
533    })
534}
535
536/// `"<holder>'s <noun> is <bearer>"` — the English genitive, holder first.
537fn find_saxon_genitive(folded: &str, names: &[String]) -> Option<Kinship> {
538    folded
539        .match_indices(SAXON_MARKER)
540        .find_map(|(at, marker)| saxon_genitive_at(folded, at, marker.len(), names))
541}
542
543/// One `"<holder>'s <noun> is <bearer>"` reading, anchored on the clitic at
544/// `at`. As tight as [`of_genitive_at`]: the holder's name must end on the
545/// clitic, the noun must start right after it, and the copula must follow it.
546fn saxon_genitive_at(folded: &str, at: usize, len: usize, names: &[String]) -> Option<Kinship> {
547    let holder = name_before(folded.get(..at)?, names)?;
548    let (noun, noun_len) = noun_at(folded.get(at + len..)?)?;
549    let after_noun = folded.get(at + len + noun_len..)?;
550    let bearers = bearers_at(strip_any(after_noun, GENITIVE_COPULAS)?, names);
551    Some(Kinship {
552        noun,
553        holder,
554        bearers,
555    })
556}
557
558/// The kinship relation the passage states, in whichever form states it. The
559/// possessive is tried first: its marker is the most specific, so a sentence
560/// that could be read both ways reads as the possessive it is.
561fn find_kinship(folded: &str, names: &[String]) -> Option<Kinship> {
562    find_possessive(folded, names).or_else(|| find_genitive(folded, names))
563}
564
565/// The head word of a predicate label, folded: `"sœur de"` → `"soeur"`.
566fn predicate_stem(predicate: &str) -> String {
567    fold(predicate)
568        .split_whitespace()
569        .next()
570        .unwrap_or_default()
571        .to_string()
572}
573
574/// The kinship noun a predicate label names, plural tolerated (`"sœurs de"` →
575/// `"soeur"`). `None` for any non-kinship label, which is what leaves an
576/// unrelated edge between the same two people untouched.
577fn predicate_noun(predicate: &str) -> Option<&'static str> {
578    let stem = predicate_stem(predicate);
579    KINSHIP_NOUNS
580        .iter()
581        .copied()
582        .find(|noun| word_prefix_len(&stem, noun) == Some(stem.len()))
583}
584
585/// Whether the triple runs between exactly these two entities, either way round.
586fn joins(relation: &ExtractedRelation, one: &str, other: &str) -> bool {
587    (relation.subject == one && relation.object == other)
588        || (relation.subject == other && relation.object == one)
589}
590
591/// Point one triple the way the passage states it.
592///
593/// The triple built on the noun the passage used belongs to the person that
594/// noun introduced; any *other* kinship label over the same pair is its
595/// converse and therefore runs the other way. That single rule is also all an
596/// alliance ever needs: list `"beau-frere"` in the table and its converse is
597/// whatever else the extractor labelled the pair with. Anything else is
598/// untouched.
599fn reorient(relation: &mut ExtractedRelation, noun: &str, holder: &str, bearer: &str) {
600    let Some(stem) = predicate_noun(&relation.predicate) else {
601        return;
602    };
603    if !joins(relation, holder, bearer) {
604        return;
605    }
606    let (subject, object) = if stem == noun {
607        (bearer, holder)
608    } else {
609        (holder, bearer)
610    };
611    relation.subject = subject.to_string();
612    relation.object = object.to_string();
613}
614
615/// Re-point the kinship triples the passage states, so each label sits on the
616/// person who actually carries it.
617///
618/// A no-op unless the passage contains a possessive or a genitive naming a
619/// kinship noun AND both sides of it resolve to entities the triples already
620/// mention — the pass never invents an edge, never drops one, and never touches
621/// a copule. A construction naming several carriers re-points the triple of
622/// each; one that names a carrier no triple mentions simply has no triple to
623/// re-point, since synthesising the edge would break the never-invent rule that
624/// makes this pass safe over a hallucinating backend.
625pub(crate) fn orient_kinship(passage: &str, relations: &mut [ExtractedRelation]) {
626    let folded = fold(passage);
627    let names = endpoint_names(relations);
628    let Some(kinship) = find_kinship(&folded, &names) else {
629        return;
630    };
631    for bearer in &kinship.bearers {
632        if *bearer == kinship.holder {
633            continue;
634        }
635        for relation in relations.iter_mut() {
636            reorient(relation, kinship.noun, &kinship.holder, bearer);
637        }
638    }
639}
640
641/// Failure produced by an [`Extractor`] backend (e.g. a network-backed model
642/// that cannot be reached, or output that cannot be parsed into facts).
643#[derive(Debug, thiserror::Error)]
644pub enum ExtractError {
645    /// The extraction backend (network, subprocess, …) returned an error.
646    #[error("extraction backend error: {0}")]
647    Backend(String),
648    /// The backend produced output that could not be parsed into facts.
649    #[error("could not parse facts from extractor output: {0}")]
650    Parse(String),
651}
652
653/// Turns a passage of raw text into atomic, graph-ready facts.
654///
655/// Implement this to plug in any model — a local LLM, a hosted API, or a
656/// deterministic rule set — and feed the result straight into
657/// [`crate::MemoryService::remember_extracted`].
658pub trait Extractor {
659    /// Extract the atomic facts a reader would remember from `text`.
660    ///
661    /// # Errors
662    /// Returns [`ExtractError`] if the backend fails or its output cannot be
663    /// parsed into facts.
664    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
665
666    /// Extract the facts *and* the entity→entity edges and entity attributes
667    /// the passage states.
668    ///
669    /// Defaults to [`Self::extract`] with no relations and no attributes, so
670    /// every backend written against the fact-only contract keeps compiling
671    /// and keeps working — it simply builds the bipartite fact↔topic graph it
672    /// always did. A backend that can read structure overrides this.
673    ///
674    /// # Errors
675    /// Returns [`ExtractError`] if the backend fails or its output cannot be
676    /// parsed.
677    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
678        Ok(Extraction {
679            facts: self.extract(text)?,
680            ..Extraction::default()
681        })
682    }
683}
684
685/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
686/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
687/// [`crate::MemoryService::remember_extracted`].
688///
689/// Both methods are forwarded. Forwarding only `extract` would silently route
690/// every `Arc`-held backend — which is *every* backend the MCP server and the
691/// bindings use — through the fact-only default, discarding the relations and
692/// attributes the inner extractor actually produced.
693impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
694    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
695        (**self).extract(text)
696    }
697
698    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
699        (**self).extract_graph(text)
700    }
701}
702
703/// A shared, object-safe extractor. The MCP server and the language bindings
704/// hold one of these (an `Option`), so the extraction tool can be attached at
705/// runtime without the type being generic.
706pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
707
708// --- Optional batteries-included backend: a local Ollama generative model -----
709//
710// Enabled with `--features extract`. The default build omits this backend (and
711// its HTTP dependency) so the shipped binary stays tiny and fully offline. Like
712// the Ollama embedder, it calls a model the user already runs locally, so the
713// text never leaves the machine.
714
715/// Default Ollama base URL for the generative extraction endpoint.
716#[cfg(feature = "extract")]
717pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
718
719/// Per-request timeout. Generation is far slower and more stall-prone than an
720/// embedding call, so a wedged model fails the call instead of hanging forever.
721#[cfg(feature = "extract")]
722const REQUEST_TIMEOUT_SECS: u64 = 300;
723
724/// Ceiling on establishing the TCP connection to Ollama. Short on purpose: a
725/// local daemon accepts at once or is not running, and `ureq`'s 30 s default
726/// would be paid once per replay.
727#[cfg(feature = "extract")]
728const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
729
730/// Ceiling on writing the request (prompt upload). Unlike the read bound, this
731/// one is applied to the socket at connect time and is genuinely in force.
732#[cfg(feature = "extract")]
733const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
734
735/// The knobs that actually configure the extractor, named in its failures.
736///
737/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
738/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
739/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
740/// path never consults — an "actionable" message that is actively wrong. There
741/// is no offline fallback to offer either: extraction is opt-in, and running
742/// without it is simply not passing an extractor.
743#[cfg(feature = "extract")]
744const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
745    crate::ollama_retry::OllamaLevers {
746        url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
747        model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
748        fallback: None,
749    };
750
751/// How one generation attempt failed — transport and body failures may be
752/// replayed, a complete response is the server's final word.
753#[cfg(feature = "extract")]
754enum GenerateCall {
755    /// The request never completed. Boxed: `ureq::Error::Status` carries a
756    /// whole `Response`.
757    Transport(Box<ureq::Error>),
758    /// Headers arrived but the body did not read back in full.
759    Body(std::io::Error),
760}
761
762/// Replay policy for one generation attempt.
763#[cfg(feature = "extract")]
764fn generate_is_retryable(err: &GenerateCall) -> bool {
765    match err {
766        GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
767        GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
768    }
769}
770
771/// Turn a failed generation into a message that names the endpoint, the model,
772/// how many attempts were spent, and the variables that change the outcome.
773#[cfg(feature = "extract")]
774fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
775    let cause = match err {
776        GenerateCall::Transport(inner) => inner.to_string(),
777        GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
778    };
779    crate::ollama_retry::actionable_failure(
780        "generate",
781        url,
782        model,
783        attempts,
784        &cause,
785        &EXTRACT_LEVERS,
786    )
787}
788
789/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
790/// model — and therefore the source text — on the user's own machine.
791///
792/// The caller picks the generative model (Ollama has no universal default for
793/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
794/// reproducible output.
795#[cfg(feature = "extract")]
796#[derive(Debug, Clone)]
797pub struct OllamaExtractor {
798    base_url: String,
799    model: String,
800    agent: ureq::Agent,
801}
802
803#[cfg(feature = "extract")]
804impl OllamaExtractor {
805    /// Build an extractor targeting `model` on the Ollama server at `base_url`
806    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
807    ///
808    /// The agent is bounded on four axes, not one. See
809    /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
810    /// subordinate to the global `timeout` in `ureq` and must not be read as a
811    /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
812    /// that actually bite. The connect bound matters most here: `ureq`'s own
813    /// default is 30 s, which for a `localhost` daemon is 15x too long — and
814    /// with replays, that idle wait would be paid three times over.
815    #[must_use]
816    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
817        let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
818        let agent = ureq::AgentBuilder::new()
819            .timeout_connect(CONNECT_TIMEOUT)
820            .timeout_write(WRITE_TIMEOUT)
821            .timeout_read(timeout)
822            .timeout(timeout)
823            .build();
824        Self {
825            base_url: base_url.into(),
826            model: model.into(),
827            agent,
828        }
829    }
830}
831
832#[cfg(feature = "extract")]
833impl Extractor for OllamaExtractor {
834    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
835        let reply = self.generate(&build_prompt(text))?;
836        let raw = json_slice::<Vec<RawFact>>(&reply)
837            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
838        Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
839    }
840
841    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
842        let reply = self.generate(&build_graph_prompt(text))?;
843        let raw = json_slice_object::<RawExtraction>(&reply)
844            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
845        Ok(raw.into_extraction())
846    }
847}
848
849#[cfg(feature = "extract")]
850impl OllamaExtractor {
851    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
852    /// replaying the call when the failure is transient.
853    ///
854    /// Same defect, same repair as the embedder: this extractor also holds one
855    /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
856    /// Ollama may have closed, and `ureq` will not replay a POST with a body.
857    /// The whole attempt — POST and body read — is inside the closure so a
858    /// truncated response is replayed rather than surfacing as a parse error.
859    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
860        let url = format!("{}/api/generate", self.base_url);
861        let body = serde_json::json!({
862            "model": self.model,
863            "prompt": prompt,
864            "stream": false,
865            "think": false,
866            // Extraction models are large — the one this crate documents as an
867            // example is 21.9 GB — so an unload between calls is the dominant
868            // cost, not the generation. Shares the embedder's knob so one
869            // setting governs every Ollama call the daemon makes.
870            "keep_alive": crate::embedder::keep_alive(),
871            "options": { "temperature": 0 },
872        })
873        .to_string();
874        let attempt = || {
875            let response = self
876                .agent
877                .post(&url)
878                .set("Content-Type", "application/json")
879                .send_string(&body)
880                .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
881            response.into_string().map_err(GenerateCall::Body)
882        };
883
884        let payload = crate::ollama_retry::with_retry(
885            &crate::ollama_retry::OLLAMA_RETRIES,
886            generate_is_retryable,
887            attempt,
888        )
889        .map_err(|(err, attempts)| {
890            ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
891        })?;
892        parse_generate_response(&payload)
893    }
894}
895
896/// The strict JSON contract the extraction prompt asks the model to honour.
897#[cfg(feature = "extract")]
898#[derive(serde::Deserialize)]
899struct RawFact {
900    fact: String,
901    #[serde(default)]
902    entities: Vec<String>,
903}
904
905#[cfg(feature = "extract")]
906impl RawFact {
907    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
908    /// blanks and duplicates so the same topic recurs as the same graph hub.
909    fn into_fact(self) -> Option<ExtractedFact> {
910        let text = self.fact.trim().to_string();
911        if text.is_empty() {
912            return None;
913        }
914        let mut entities: Vec<String> = self
915            .entities
916            .into_iter()
917            .map(|entity| entity.trim().to_lowercase())
918            .filter(|entity| !entity.is_empty())
919            .collect();
920        entities.sort_unstable();
921        entities.dedup();
922        Some(ExtractedFact { text, entities })
923    }
924}
925
926/// Canonical form of an entity name: trimmed and lowercased. The one place
927/// the rule lives, so a name arriving as a topic, as a relation endpoint, or
928/// as an attribute owner always resolves to the SAME entity hub.
929#[cfg(feature = "extract")]
930fn canonical_entity(name: &str) -> String {
931    name.trim().to_lowercase()
932}
933
934/// The strict JSON contract the *graph* extraction prompt asks for.
935#[cfg(feature = "extract")]
936#[derive(serde::Deserialize)]
937struct RawExtraction {
938    #[serde(default)]
939    facts: Vec<RawFact>,
940    #[serde(default)]
941    relations: Vec<RawRelation>,
942    #[serde(default)]
943    attributes: Vec<RawAttribute>,
944}
945
946#[cfg(feature = "extract")]
947#[derive(serde::Deserialize)]
948struct RawRelation {
949    subject: String,
950    predicate: String,
951    object: String,
952}
953
954#[cfg(feature = "extract")]
955#[derive(serde::Deserialize)]
956struct RawAttribute {
957    entity: String,
958    key: String,
959    value: serde_json::Value,
960}
961
962#[cfg(feature = "extract")]
963impl RawExtraction {
964    /// Canonicalize and drop the unusable: a relation missing an endpoint or a
965    /// label, an attribute missing an owner or a name. A malformed item is
966    /// skipped rather than failing the whole passage — one bad triple must not
967    /// cost the caller every good fact in the same reply.
968    fn into_extraction(self) -> Extraction {
969        Extraction {
970            facts: self
971                .facts
972                .into_iter()
973                .filter_map(RawFact::into_fact)
974                .collect(),
975            relations: self
976                .relations
977                .into_iter()
978                .filter_map(RawRelation::into_relation)
979                .collect(),
980            attributes: self
981                .attributes
982                .into_iter()
983                .filter_map(RawAttribute::into_attribute)
984                .collect(),
985        }
986    }
987}
988
989#[cfg(feature = "extract")]
990impl RawRelation {
991    fn into_relation(self) -> Option<ExtractedRelation> {
992        let subject = canonical_entity(&self.subject);
993        let object = canonical_entity(&self.object);
994        let predicate = self.predicate.trim().to_string();
995        // A self-loop carries no information and would sit in the graph as a
996        // permanent dead end, so it is dropped alongside the incomplete ones.
997        if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
998            return None;
999        }
1000        Some(ExtractedRelation {
1001            subject,
1002            predicate,
1003            object,
1004        })
1005    }
1006}
1007
1008#[cfg(feature = "extract")]
1009impl RawAttribute {
1010    fn into_attribute(self) -> Option<ExtractedAttribute> {
1011        let entity = canonical_entity(&self.entity);
1012        let key = self.key.trim().to_string();
1013        // A null value is the model saying "not stated"; storing it would make
1014        // an absent attribute look like a known-empty one.
1015        if entity.is_empty() || key.is_empty() || self.value.is_null() {
1016            return None;
1017        }
1018        Some(ExtractedAttribute {
1019            entity,
1020            key,
1021            value: self.value,
1022        })
1023    }
1024}
1025
1026/// Build the *graph* extraction prompt: the passage plus a strict JSON
1027/// contract covering facts, entity→entity edges, and entity attributes.
1028///
1029/// The contract insists numbers stay JSON numbers. `recall_where` compares
1030/// type-strictly, so an age emitted as `"15"` would never match `age >= 15` —
1031/// no error, just a silent miss, which is the worst possible failure mode for
1032/// a memory system.
1033#[cfg(feature = "extract")]
1034fn build_graph_prompt(text: &str) -> String {
1035    format!(
1036        "You are building a knowledge graph from the passage below.\n\n\
1037Passage:\n{text}\n\n\
1038Return THREE things.\n\n\
10391. \"facts\": the atomic, standalone facts a person would remember. Rewrite each \
1040as a self-contained sentence (resolve pronouns to names; keep absolute dates). \
1041For each, list 1-4 key TOPICS it concerns, as short canonical lowercase noun \
1042phrases, so the same topic recurs as the SAME tag across passages.\n\n\
10432. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
1044subject/predicate/object triples. Use the entity's full name, lowercase \
1045(e.g. \"bruno durand\").\n\
1046The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
1047the passage's own language (e.g. \"pere de\", \"soeur de\", \"works at\", \
1048\"moteur de recherche\"). NEVER restate the sentence — write \"surveille les \
1049fuites\", not \"est utilise pour la surveillance de fuites de donnees\". If you \
1050cannot say it in 3 words, pick the closest short label.\n\
1051State the triple in the direction the passage states it, and add the converse \
1052ONLY if the passage states it too.\n\
1053DIRECTION: the subject is whoever CARRIES the relation, not the subject of the \
1054sentence. \"A a une soeur, B\" means B is A's sister, so the triple is \
1055B/\"soeur de\"/A — never A/\"soeur de\"/B. Same for every possessive \
1056(\"a un frere\", \"a une fille\", \"has a brother\").\n\
1057Every named entity the passage RELATES to another must appear in at least one \
1058triple — an entity that only receives attributes and no edge is a dead end in \
1059the graph.\n\n\
10603. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
1061short lowercase keys (\"age\", \"ville\", \"employeur\"). Emit numbers as JSON \
1062NUMBERS, never strings: 15, not \"15\". Omit anything the passage does not state.\n\n\
1063Return ONLY this JSON object, no prose:\n\
1064{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
1065\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
1066\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
1067    )
1068}
1069
1070/// Build the extraction prompt: the passage plus a strict JSON contract.
1071#[cfg(feature = "extract")]
1072fn build_prompt(text: &str) -> String {
1073    format!(
1074        "You are building a memory graph from the passage below.\n\n\
1075Passage:\n{text}\n\n\
1076Extract the atomic, standalone facts a person would remember. Rewrite each as a \
1077self-contained sentence (resolve pronouns to names; keep absolute dates). For \
1078each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
1079activities, events, interests, plans, places, organisations, or named people a \
1080later question might reference. Use short, canonical, lowercase noun phrases \
1081(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
1082recurs as the SAME tag across passages.\n\n\
1083Return ONLY a JSON array, no prose, each item exactly:\n\
1084{{\"fact\": string, \"entities\": [string]}}"
1085    )
1086}
1087
1088/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
1089#[cfg(feature = "extract")]
1090fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
1091    let value: serde_json::Value = serde_json::from_str(body)
1092        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
1093    let text = value
1094        .get("response")
1095        .and_then(serde_json::Value::as_str)
1096        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
1097    Ok(text.trim().to_string())
1098}
1099
1100/// A short, single-line preview of model output for error messages.
1101#[cfg(feature = "extract")]
1102fn truncate(text: &str) -> String {
1103    const LIMIT: usize = 120;
1104    let mut out = String::new();
1105    for word in text.split_whitespace() {
1106        // Check the budget *before* pushing so we never need a post-hoc
1107        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
1108        let sep_len = usize::from(!out.is_empty());
1109        if out.len() + sep_len + word.len() > LIMIT {
1110            break;
1111        }
1112        if !out.is_empty() {
1113            out.push(' ');
1114        }
1115        out.push_str(word);
1116    }
1117    out
1118}
1119
1120/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
1121/// Local models usually honour "return only JSON" but occasionally wrap it in
1122/// fences or a sentence; slicing the first balanced span tolerates that.
1123#[cfg(feature = "extract")]
1124fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1125    let slice = balanced_slice(text)?;
1126    serde_json::from_str::<T>(slice).ok()
1127}
1128
1129/// [`json_slice`] for a reply whose top level is a JSON **object**.
1130///
1131/// The array-preferring form cannot be reused: the graph reply is
1132/// `{"facts": [...], ...}`, whose first `[` belongs to a *nested* field, so
1133/// preferring arrays slices out the inner facts list and then fails to read it
1134/// as the whole extraction. That failure is invisible to a stub-backed test —
1135/// only a real model reply goes through this path.
1136#[cfg(feature = "extract")]
1137fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1138    let slice = balanced_slice_preferring(text, b'{')?;
1139    serde_json::from_str::<T>(slice).ok()
1140}
1141
1142/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
1143/// string literals and escapes so brackets inside quotes don't miscount.
1144///
1145/// Prefers an array: the fact-only reply is a JSON list, and prose before it
1146/// ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
1147/// object span instead of the array.
1148#[cfg(feature = "extract")]
1149fn balanced_slice(text: &str) -> Option<&str> {
1150    balanced_slice_preferring(text, b'[')
1151}
1152
1153/// [`balanced_slice`] with the caller choosing which delimiter wins when both
1154/// appear — the shape the caller actually expects at the top level.
1155#[cfg(feature = "extract")]
1156fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
1157    let bytes = text.as_bytes();
1158    let fallback = if preferred == b'[' { b'{' } else { b'[' };
1159    let start = bytes
1160        .iter()
1161        .position(|&b| b == preferred)
1162        .or_else(|| bytes.iter().position(|&b| b == fallback))?;
1163    let open = bytes[start];
1164    let close = if open == b'[' { b']' } else { b'}' };
1165    let mut depth = 0u32;
1166    let mut in_string = false;
1167    let mut escaped = false;
1168    for (offset, &byte) in bytes[start..].iter().enumerate() {
1169        if in_string {
1170            in_string = step_string(&mut escaped, byte);
1171        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
1172            return Some(&text[start..=start + offset]);
1173        }
1174    }
1175    None
1176}
1177
1178/// Advance the structural scan for one out-of-string byte; returns `true` once
1179/// the outermost bracket has just closed (`depth` back to zero).
1180#[cfg(feature = "extract")]
1181fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
1182    if byte == b'"' {
1183        *in_string = true;
1184    } else if byte == open {
1185        *depth += 1;
1186    } else if byte == close {
1187        *depth = depth.saturating_sub(1);
1188        return *depth == 0;
1189    }
1190    false
1191}
1192
1193/// Advance the in-string escape state for one byte; returns whether the scanner
1194/// is still inside the string literal afterwards.
1195#[cfg(feature = "extract")]
1196fn step_string(escaped: &mut bool, byte: u8) -> bool {
1197    match (*escaped, byte) {
1198        (true, _) => {
1199            *escaped = false;
1200            true
1201        }
1202        (false, b'\\') => {
1203            *escaped = true;
1204            true
1205        }
1206        (false, b'"') => false,
1207        (false, _) => true,
1208    }
1209}
1210
1211#[cfg(all(test, feature = "extract"))]
1212mod tests {
1213    use super::*;
1214
1215    /// Regression: the graph reply is an OBJECT whose first `[` belongs to the
1216    /// nested `facts` field. Slicing with the array preference grabbed that
1217    /// inner list and failed to read it as the whole extraction — a real model
1218    /// reply was rejected wholesale while every stub-backed test stayed green.
1219    #[test]
1220    fn parses_a_graph_reply_whose_first_bracket_is_nested() {
1221        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 } ] }"#;
1222        let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
1223        let extraction = raw.into_extraction();
1224        assert_eq!(extraction.facts.len(), 1);
1225        assert_eq!(extraction.relations.len(), 1);
1226        assert_eq!(extraction.relations[0].predicate, "pere de");
1227        assert_eq!(extraction.attributes.len(), 1);
1228        assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
1229    }
1230
1231    /// Prose (and a fenced block) around the object must not defeat slicing.
1232    #[test]
1233    fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
1234        let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
1235        let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
1236        assert_eq!(raw.into_extraction().relations.len(), 1);
1237    }
1238
1239    /// The fact-only path must keep preferring an array: prose carrying a stray
1240    /// `{` before the list is exactly what that preference exists to survive.
1241    #[test]
1242    fn fact_only_slicing_still_prefers_the_array() {
1243        let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
1244        let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
1245        assert_eq!(raw.len(), 1);
1246    }
1247
1248    #[test]
1249    fn graph_prompt_demands_numeric_values_and_the_three_sections() {
1250        let prompt = build_graph_prompt("Kaltar a 15 ans.");
1251        assert!(prompt.contains("Kaltar a 15 ans."));
1252        assert!(prompt.contains("\"relations\""));
1253        assert!(prompt.contains("\"attributes\""));
1254        assert!(prompt.contains("15, not \"15\""));
1255    }
1256
1257    #[test]
1258    fn prompt_carries_the_passage_and_json_contract() {
1259        let prompt = build_prompt("Alice adopted a dog in 2021.");
1260        assert!(prompt.contains("Alice adopted a dog in 2021."));
1261        assert!(prompt.contains("\"fact\": string"));
1262    }
1263
1264    /// The graph prompt has to bound the predicate explicitly. Asking for "a
1265    /// short label" was not enough: on real content the model answered
1266    /// "est utilise pour la surveillance de fuites de donnees" — a restated
1267    /// sentence, which makes the edge unreadable in `entity()`.
1268    #[test]
1269    fn graph_prompt_bounds_the_predicate_and_demands_edges() {
1270        let prompt = build_graph_prompt("Ahmia is an onion search engine.");
1271        assert!(prompt.contains("Ahmia is an onion search engine."));
1272        assert!(
1273            prompt.contains("at most 3 words"),
1274            "the predicate length must be a hard bound, not a suggestion"
1275        );
1276        assert!(
1277            prompt.contains("NEVER restate the sentence"),
1278            "the counter-example is what stops a restated sentence"
1279        );
1280        assert!(
1281            prompt.contains("at least one triple"),
1282            "an entity with attributes but no edge is a dead end — the prompt \
1283             must ask for the edge"
1284        );
1285    }
1286
1287    /// The prompt must state the possessive rule explicitly: asked only to
1288    /// "state the triple in the direction the passage states it", the model
1289    /// read the grammatical subject as the subject of the triple and mirrored
1290    /// every possessive.
1291    #[test]
1292    fn graph_prompt_states_which_side_carries_the_relation() {
1293        let prompt = build_graph_prompt("Theo Durand a une soeur, Camille Durand.");
1294        assert!(
1295            prompt.contains("whoever CARRIES the relation"),
1296            "the rule must name the carrier, not just \"the direction\""
1297        );
1298        assert!(
1299            prompt.contains("never A/\"soeur de\"/B"),
1300            "the counter-example is what makes the rule unambiguous"
1301        );
1302    }
1303
1304    #[test]
1305    fn parses_facts_from_a_fenced_reply() {
1306        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
1307        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
1308        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
1309        assert_eq!(facts.len(), 1);
1310        assert_eq!(facts[0].text, "Alice adopted a dog.");
1311        // Trimmed, lowercased, deduplicated, blanks dropped.
1312        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
1313    }
1314
1315    #[test]
1316    fn drops_a_textless_fact() {
1317        let raw = RawFact {
1318            fact: "   ".to_string(),
1319            entities: vec!["x".to_string()],
1320        };
1321        assert!(raw.into_fact().is_none());
1322    }
1323
1324    #[test]
1325    fn parses_response_envelope() {
1326        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
1327        assert_eq!(text, "[]");
1328    }
1329
1330    #[test]
1331    fn rejects_response_without_field() {
1332        assert!(matches!(
1333            parse_generate_response(r#"{"oops":true}"#),
1334            Err(ExtractError::Backend(_))
1335        ));
1336    }
1337}