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 `extractor-http` 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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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)]
668#[non_exhaustive] // error enum, grows by nature; matching externally requires a wildcard arm
669pub enum ExtractError {
670    /// The extraction backend (network, subprocess, …) returned an error.
671    #[error("extraction backend error: {0}")]
672    Backend(String),
673    /// The backend produced output that could not be parsed into facts.
674    #[error("could not parse facts from extractor output: {0}")]
675    Parse(String),
676}
677
678/// Turns a passage of raw text into atomic, graph-ready facts.
679///
680/// Implement this to plug in any model — a local LLM, a hosted API, or a
681/// deterministic rule set — and feed the result straight into
682/// [`crate::MemoryService::remember_extracted`].
683pub trait Extractor {
684    /// Extract the atomic facts a reader would remember from `text`.
685    ///
686    /// # Errors
687    /// Returns [`ExtractError`] if the backend fails or its output cannot be
688    /// parsed into facts.
689    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
690
691    /// Extract the facts *and* the entity→entity edges and entity attributes
692    /// the passage states.
693    ///
694    /// Defaults to [`Self::extract`] with no relations and no attributes, so
695    /// every backend written against the fact-only contract keeps compiling
696    /// and keeps working — it simply builds the bipartite fact↔topic graph it
697    /// always did. A backend that can read structure overrides this.
698    ///
699    /// # Errors
700    /// Returns [`ExtractError`] if the backend fails or its output cannot be
701    /// parsed.
702    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
703        Ok(Extraction {
704            facts: self.extract(text)?,
705            ..Extraction::default()
706        })
707    }
708}
709
710/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
711/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
712/// [`crate::MemoryService::remember_extracted`].
713///
714/// Both methods are forwarded. Forwarding only `extract` would silently route
715/// every `Arc`-held backend — which is *every* backend the MCP server and the
716/// bindings use — through the fact-only default, discarding the relations and
717/// attributes the inner extractor actually produced.
718impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
719    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
720        (**self).extract(text)
721    }
722
723    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
724        (**self).extract_graph(text)
725    }
726}
727
728/// A shared, object-safe extractor. The MCP server and the language bindings
729/// hold one of these (an `Option`), so the extraction tool can be attached at
730/// runtime without the type being generic.
731pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
732
733// --- Always-available backend: the outline a passage states -------------------
734//
735// The twin of `HashEmbedder` on this side of the crate, and for the same
736// reason: without a dependency-free choice, every contract
737// `remember_extracted` publishes is reachable only through a network call, so
738// no binding can exercise it and no test can prove it. Deliberately NOT behind
739// `extractor-http` — that feature exists to pull in the HTTP client, which this
740// backend does not need.
741
742/// Deterministic, network-free extractor: it reads the structure a passage
743/// STATES instead of inferring it.
744///
745/// A generative backend guesses which facts a paragraph holds; this one is
746/// told. Each non-blank line of the passage is one directive:
747///
748/// | line | yields |
749/// |---|---|
750/// | `edge: <subject> \| <predicate> \| <object>` | one [`ExtractedRelation`] |
751/// | `attr: <entity> \| <key> \| <json value>` | one [`ExtractedAttribute`] |
752/// | `fact: <text> \| <topic>, <topic>` | one [`ExtractedFact`] |
753/// | anything else | one [`ExtractedFact`], no topics |
754///
755/// Entity names are canonicalized (trimmed, lowercased) exactly as
756/// [`ExtractedFact::entities`] are, so they resolve to the SAME
757/// content-addressed hubs a generative backend's would — the two backends can
758/// write into one graph.
759///
760/// Its purpose matches [`crate::HashEmbedder`]'s: reproducible tests and
761/// offline behavior. It reads no natural language, so a caller holding only
762/// prose wants a generative backend. What it offers instead is the one thing a
763/// model cannot: the graph is exactly the one the caller wrote down — up to
764/// [`orient_kinship`], the repointing pass EVERY backend's relations go
765/// through, which can flip a triple whose predicate is a kinship noun the
766/// passage also states possessively.
767///
768/// A malformed directive is an [`ExtractError::Parse`], never a silently
769/// dropped line: a graph that quietly loses half of what it was handed is
770/// worse than one that refuses.
771#[derive(Debug, Clone, Copy, Default)]
772pub struct OutlineExtractor;
773
774/// The `|`-separated fields of one directive body, trimmed.
775fn directive_fields(rest: &str) -> Vec<&str> {
776    rest.split('|').map(str::trim).collect()
777}
778
779/// The error a directive carrying the wrong number of fields deserves.
780fn wrong_field_count(kind: &str, expected: usize, given: usize) -> ExtractError {
781    ExtractError::Parse(format!(
782        "`{kind}:` takes {expected} `|`-separated fields, {given} given"
783    ))
784}
785
786/// `edge: <subject> | <predicate> | <object>`.
787fn parse_edge(rest: &str) -> Result<ExtractedRelation, ExtractError> {
788    let fields = directive_fields(rest);
789    let [subject, predicate, object] = fields[..] else {
790        return Err(wrong_field_count("edge", 3, fields.len()));
791    };
792    if subject.is_empty() || predicate.is_empty() || object.is_empty() {
793        return Err(ExtractError::Parse(
794            "`edge:` takes a non-blank subject, predicate and object".to_owned(),
795        ));
796    }
797    Ok(ExtractedRelation {
798        subject: crate::service::canonical_entity_name(subject),
799        predicate: predicate.to_owned(),
800        object: crate::service::canonical_entity_name(object),
801    })
802}
803
804/// `attr: <entity> | <key> | <json value>`.
805fn parse_attr(rest: &str) -> Result<ExtractedAttribute, ExtractError> {
806    let fields = directive_fields(rest);
807    let [entity, key, value] = fields[..] else {
808        return Err(wrong_field_count("attr", 3, fields.len()));
809    };
810    if entity.is_empty() || key.is_empty() {
811        return Err(ExtractError::Parse(
812            "`attr:` takes a non-blank entity and key".to_owned(),
813        ));
814    }
815    // Parsed as JSON, not stored as text, because `recall_where` comparisons
816    // are type-strict: an age handed over as `"15"` would never match a
817    // numeric filter (see [`ExtractedAttribute::value`]).
818    let value = serde_json::from_str(value)
819        .map_err(|err| ExtractError::Parse(format!("`attr:` value is not JSON: {err}")))?;
820    Ok(ExtractedAttribute {
821        entity: crate::service::canonical_entity_name(entity),
822        key: key.to_owned(),
823        value,
824    })
825}
826
827/// `fact: <text> | <topic>, <topic>`, and the fallback for any other line.
828fn parse_fact(body: &str) -> Result<ExtractedFact, ExtractError> {
829    let (text, topics) = body.split_once('|').unwrap_or((body, ""));
830    let text = text.trim();
831    if text.is_empty() {
832        return Err(ExtractError::Parse(
833            "a fact line takes a non-blank text".to_owned(),
834        ));
835    }
836    Ok(ExtractedFact {
837        text: text.to_owned(),
838        entities: topics
839            .split(',')
840            .map(crate::service::canonical_entity_name)
841            .filter(|topic| !topic.is_empty())
842            .collect(),
843    })
844}
845
846impl Extractor for OutlineExtractor {
847    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
848        Ok(self.extract_graph(text)?.facts)
849    }
850
851    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
852        let mut extraction = Extraction::default();
853        for line in text.lines() {
854            let line = line.trim();
855            if line.is_empty() {
856                continue;
857            }
858            if let Some(rest) = line.strip_prefix("edge:") {
859                extraction.relations.push(parse_edge(rest)?);
860            } else if let Some(rest) = line.strip_prefix("attr:") {
861                extraction.attributes.push(parse_attr(rest)?);
862            } else {
863                extraction
864                    .facts
865                    .push(parse_fact(line.strip_prefix("fact:").unwrap_or(line))?);
866            }
867        }
868        Ok(extraction)
869    }
870}
871
872/// What a caller must do to honour a requested extraction backend.
873///
874/// Returned by [`select_extractor`], which is the single place that knows which
875/// backend names exist. Splitting the answer into these three shapes is what
876/// lets the dependency-free backends be selected in **any** build: only the
877/// [`Self::NeedsRemoteConfig`] arm requires an optional dependency and the URL
878/// and model that go with it, and only that arm's construction is feature-gated.
879/// **Deliberately exhaustive** (no `non_exhaustive`): every variant demands
880/// caller wiring — construct a backend, ask for configuration, run nothing —
881/// and a wildcard arm would silently ignore a new capability instead of
882/// failing to compile where it must be handled. Adding a variant is therefore
883/// a breaking change, made on purpose, in a minor bump while the crate is 0.x.
884pub enum ExtractorSelection {
885    /// No extraction. Tools that need an extractor answer "not configured".
886    Disabled,
887    /// Ready to use as-is: needs no configuration, no network, no optional
888    /// dependency. Attach it and the graph builds.
889    Ready(DynExtractor),
890    /// A network-backed backend the caller must build itself, because only the
891    /// caller knows its URL and model. Carries the backend's name so the caller
892    /// can dispatch without re-parsing the string.
893    NeedsRemoteConfig(&'static str),
894}
895
896/// Hand-written because [`DynExtractor`] is a trait object and the trait does
897/// not require `Debug` — a backend is identified by its shape here, never by
898/// dumping its innards (an HTTP-backed one holds a URL, and a panic message is
899/// not the place for it).
900impl std::fmt::Debug for ExtractorSelection {
901    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902        match self {
903            Self::Disabled => f.write_str("Disabled"),
904            Self::Ready(_) => f.write_str("Ready(<extractor>)"),
905            Self::NeedsRemoteConfig(name) => write!(f, "NeedsRemoteConfig({name})"),
906        }
907    }
908}
909
910/// Resolve an extraction backend name to what the caller must do about it.
911///
912/// # Why this exists, and why it is in the library rather than the binary
913///
914/// The selection used to live inside the daemon's `#[cfg(feature = "extractor-http")]`
915/// block. That gate is what made [`OutlineExtractor`] unreachable from the MCP
916/// server (#1734): the extractor needs no dependency and is linked into every
917/// build, but the only code that could *choose* it was compiled away unless an
918/// unrelated HTTP feature was on. Two of the twenty published tools were dead by
919/// default as a result — `remember_extracted` refused outright, and `entity`
920/// answered `found: false` for every name, entity hubs being born only of
921/// extraction.
922///
923/// Living here rather than in `main.rs` also means the daemon and the tests
924/// exercise the **same** function: a test can select `outline` and drive the
925/// real server with the result, instead of proving a seam written for the test.
926///
927/// # Errors
928/// A human-readable message naming the accepted forms, for an unknown backend.
929pub fn select_extractor(backend: &str) -> Result<ExtractorSelection, String> {
930    match backend {
931        // No `#[cfg]` here, and that absence IS the fix: this arm must survive
932        // in a build without any HTTP feature, which is exactly the build the
933        // published binary ships.
934        "outline" => Ok(ExtractorSelection::Ready(std::sync::Arc::new(
935            OutlineExtractor,
936        ))),
937        "ollama" => Ok(ExtractorSelection::NeedsRemoteConfig("ollama")),
938        // A protocol, not a vendor — see [`crate::select_embedder`]'s own
939        // `openai` arm. The two roles accept the same names on purpose: an
940        // operator who learned one has learned the other.
941        "openai" => Ok(ExtractorSelection::NeedsRemoteConfig("openai")),
942        "none" | "" => Ok(ExtractorSelection::Disabled),
943        other => Err(format!(
944            "unknown extraction backend '{other}' (expected 'outline' for the \
945             offline deterministic reader, 'ollama' for a local generative \
946             model, 'openai' for any OpenAI-compatible server — oMLX, \
947             llama.cpp, LM Studio, vLLM or a hosted provider, selected by URL \
948             rather than by name — or 'none')"
949        )),
950    }
951}
952
953// --- Optional batteries-included backend: a local Ollama generative model -----
954//
955// Enabled with `--features extractor-http`. A minimal `--no-default-features`
956// build omits this backend and its HTTP dependency. Like the Ollama embedder,
957// it calls a model the user already runs locally, so the text never leaves the
958// machine.
959
960/// Default Ollama base URL for the generative extraction endpoint.
961#[cfg(feature = "extractor-http")]
962pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
963
964/// Per-request timeout. Generation is far slower and more stall-prone than an
965/// embedding call, so a wedged model fails the call instead of hanging forever.
966#[cfg(feature = "extractor-http")]
967const REQUEST_TIMEOUT_SECS: u64 = 300;
968
969/// Ceiling on how many tokens one extraction call may generate.
970///
971/// Unbounded, the real graph-extraction prompt measured 3 933 completion
972/// tokens for a twelve-word sentence — 1 min 59 s spent generating JSON to
973/// store one fact (#1846). The same call capped at 600 tokens measured
974/// 14.9 s. 512 sits just under that, comfortably above what any realistic
975/// sentence's worth of triples needs, and turns the worst case into a
976/// bounded one instead of a tuning knob callers have to discover by timing
977/// out.
978#[cfg(feature = "extractor-http")]
979const MAX_GENERATION_TOKENS: u32 = 512;
980
981/// The knobs that actually configure the extractor, named in its failures.
982///
983/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
984/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
985/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
986/// path never consults — an "actionable" message that is actively wrong. There
987/// is no offline fallback to offer either: extraction is opt-in, and running
988/// without it is simply not passing an extractor.
989#[cfg(feature = "extractor-http")]
990const EXTRACT_LEVERS: crate::http_retry::FailureLevers<'static> =
991    crate::http_retry::FailureLevers {
992        url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
993        model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
994        fallback: None,
995    };
996
997/// How one generation attempt failed — transport and body failures may be
998/// replayed, a complete response is the server's final word.
999#[cfg(feature = "extractor-http")]
1000enum GenerateCall {
1001    /// The request never completed. Boxed: `ureq::Error::Status` carries a
1002    /// whole `Response`.
1003    Transport(Box<ureq::Error>),
1004    /// Headers arrived but the body did not read back in full.
1005    Body(std::io::Error),
1006}
1007
1008/// Replay policy for one generation attempt.
1009#[cfg(feature = "extractor-http")]
1010fn generate_is_retryable(err: &GenerateCall) -> bool {
1011    match err {
1012        GenerateCall::Transport(inner) => crate::http_retry::is_retryable(inner),
1013        GenerateCall::Body(inner) => crate::http_retry::io_is_retryable(inner),
1014    }
1015}
1016
1017/// Turn a failed generation into a message that names the endpoint, the model,
1018/// how many attempts were spent, and the variables that change the outcome.
1019#[cfg(feature = "extractor-http")]
1020fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
1021    let cause = match err {
1022        GenerateCall::Transport(inner) => inner.to_string(),
1023        GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
1024    };
1025    crate::http_retry::actionable_ollama_failure(
1026        "generate",
1027        url,
1028        model,
1029        attempts,
1030        &cause,
1031        &EXTRACT_LEVERS,
1032    )
1033}
1034
1035/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
1036/// model — and therefore the source text — on the user's own machine.
1037///
1038/// The caller picks the generative model (Ollama has no universal default for
1039/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
1040/// reproducible output.
1041#[cfg(feature = "extractor-http")]
1042#[derive(Debug, Clone)]
1043pub struct OllamaExtractor {
1044    base_url: String,
1045    model: String,
1046    agent: ureq::Agent,
1047}
1048
1049#[cfg(feature = "extractor-http")]
1050impl OllamaExtractor {
1051    /// Build an extractor targeting `model` on the Ollama server at `base_url`
1052    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
1053    ///
1054    /// The agent is bounded on four axes, not one. See
1055    /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
1056    /// subordinate to the global `timeout` in `ureq` and must not be read as a
1057    /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
1058    /// that actually bite. The connect bound matters most here: `ureq`'s own
1059    /// default is 30 s, which for a `localhost` daemon is 15x too long — and
1060    /// with replays, that idle wait would be paid three times over.
1061    #[must_use]
1062    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
1063        let agent =
1064            crate::http_client::bounded_agent(crate::http_client::AgentBudget::local_daemon(
1065                std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS),
1066            ));
1067        Self {
1068            base_url: base_url.into(),
1069            model: model.into(),
1070            agent,
1071        }
1072    }
1073}
1074
1075/// Read a model's reply as the flat fact list [`Extractor::extract`] promises.
1076///
1077/// A free function because every generative backend produces the same reply
1078/// and reads it the same way — only the transport differs. Leaving a copy in
1079/// each `impl` would let two backends drift on what counts as a valid answer.
1080#[cfg(feature = "extractor-http")]
1081fn facts_from_reply(reply: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1082    let raw =
1083        json_slice::<Vec<RawFact>>(reply).ok_or_else(|| ExtractError::Parse(truncate(reply)))?;
1084    Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
1085}
1086
1087/// [`facts_from_reply`]'s counterpart for [`Extractor::extract_graph`].
1088#[cfg(feature = "extractor-http")]
1089fn extraction_from_reply(reply: &str) -> Result<Extraction, ExtractError> {
1090    let raw = json_slice_object::<RawExtraction>(reply)
1091        .ok_or_else(|| ExtractError::Parse(truncate(reply)))?;
1092    Ok(raw.into_extraction())
1093}
1094
1095#[cfg(feature = "extractor-http")]
1096impl Extractor for OllamaExtractor {
1097    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1098        facts_from_reply(&self.generate(&build_prompt(text))?)
1099    }
1100
1101    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
1102        extraction_from_reply(&self.generate(&build_graph_prompt(text))?)
1103    }
1104}
1105
1106/// Extracts through any **OpenAI-compatible** `/v1/chat/completions` endpoint.
1107///
1108/// A sibling of [`OllamaExtractor`], not a layer over it — the same shape the
1109/// embedding role takes. The prompt stays here, on the role side: it is what
1110/// this crate wants said, not something the protocol knows about.
1111#[cfg(feature = "extractor-http")]
1112#[derive(Debug)]
1113pub struct OpenAiExtractor {
1114    client: crate::http_client::HttpJsonClient,
1115    model: String,
1116}
1117
1118#[cfg(feature = "extractor-http")]
1119impl OpenAiExtractor {
1120    /// Build an extractor targeting `model` on the server at `base_url`
1121    /// (origin and port, no path).
1122    ///
1123    /// Bounded on the same four axes as [`OllamaExtractor::new`], with the
1124    /// same generous [`REQUEST_TIMEOUT_SECS`]: generation is slow wherever it
1125    /// runs, and the ceiling belongs to the role, not to the transport.
1126    #[must_use]
1127    pub fn new(
1128        base_url: impl Into<String>,
1129        model: impl Into<String>,
1130        auth: crate::http_client::Auth,
1131    ) -> Self {
1132        let agent =
1133            crate::http_client::bounded_agent(crate::http_client::AgentBudget::local_daemon(
1134                std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS),
1135            ));
1136        Self {
1137            client: crate::http_client::HttpJsonClient::new(
1138                crate::openai::base_url(&base_url.into()),
1139                auth,
1140                agent,
1141            ),
1142            model: model.into(),
1143        }
1144    }
1145
1146    /// POST one prompt and return the assistant's reply.
1147    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
1148        let body = crate::openai::chat_body(&self.model, prompt, MAX_GENERATION_TOKENS);
1149        let payload = self
1150            .client
1151            .post_json(crate::openai::CHAT_COMPLETIONS_PATH, &body)
1152            .map_err(|failure| {
1153                ExtractError::Backend(crate::http_retry::actionable_openai_failure(
1154                    "chat/completions",
1155                    &failure.url,
1156                    &self.model,
1157                    failure.attempts,
1158                    &failure.cause,
1159                    Some(
1160                        "use the offline deterministic reader with \
1161                         VELESDB_MEMORY_EXTRACTOR=outline",
1162                    ),
1163                ))
1164            })?;
1165        crate::openai::parse_chat_response(&payload).map_err(ExtractError::Backend)
1166    }
1167}
1168
1169#[cfg(feature = "extractor-http")]
1170impl Extractor for OpenAiExtractor {
1171    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
1172        facts_from_reply(&self.generate(&build_prompt(text))?)
1173    }
1174
1175    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
1176        extraction_from_reply(&self.generate(&build_graph_prompt(text))?)
1177    }
1178}
1179
1180#[cfg(feature = "extractor-http")]
1181impl OllamaExtractor {
1182    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
1183    /// replaying the call when the failure is transient.
1184    ///
1185    /// Same defect, same repair as the embedder: this extractor also holds one
1186    /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
1187    /// Ollama may have closed, and `ureq` will not replay a POST with a body.
1188    /// The whole attempt — POST and body read — is inside the closure so a
1189    /// truncated response is replayed rather than surfacing as a parse error.
1190    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
1191        let url = format!("{}/api/generate", self.base_url);
1192        let body = serde_json::json!({
1193            "model": self.model,
1194            "prompt": prompt,
1195            "stream": false,
1196            "think": false,
1197            // Extraction models are large — the one this crate documents as an
1198            // example is 21.9 GB — so an unload between calls is the dominant
1199            // cost, not the generation. Shares the embedder's knob so one
1200            // setting governs every Ollama call the daemon makes.
1201            "keep_alive": crate::embedder::keep_alive(),
1202            "options": { "temperature": 0, "num_predict": MAX_GENERATION_TOKENS },
1203        })
1204        .to_string();
1205        let attempt = || {
1206            let response = self
1207                .agent
1208                .post(&url)
1209                .set("Content-Type", "application/json")
1210                .send_string(&body)
1211                .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
1212            response.into_string().map_err(GenerateCall::Body)
1213        };
1214
1215        let payload = crate::http_retry::with_retry(
1216            &crate::http_retry::HTTP_RETRIES,
1217            generate_is_retryable,
1218            attempt,
1219        )
1220        .map_err(|(err, attempts)| {
1221            ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
1222        })?;
1223        parse_generate_response(&payload)
1224    }
1225}
1226
1227/// The strict JSON contract the extraction prompt asks the model to honour.
1228#[cfg(feature = "extractor-http")]
1229#[derive(serde::Deserialize)]
1230struct RawFact {
1231    fact: String,
1232    #[serde(default)]
1233    entities: Vec<String>,
1234}
1235
1236#[cfg(feature = "extractor-http")]
1237impl RawFact {
1238    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
1239    /// blanks and duplicates so the same topic recurs as the same graph hub.
1240    fn into_fact(self) -> Option<ExtractedFact> {
1241        let text = self.fact.trim().to_string();
1242        if text.is_empty() {
1243            return None;
1244        }
1245        let mut entities: Vec<String> = self
1246            .entities
1247            .into_iter()
1248            .map(|entity| entity.trim().to_lowercase())
1249            .filter(|entity| !entity.is_empty())
1250            .collect();
1251        entities.sort_unstable();
1252        entities.dedup();
1253        Some(ExtractedFact { text, entities })
1254    }
1255}
1256
1257/// Canonical form of an entity name: trimmed and lowercased. The one place
1258/// the rule lives, so a name arriving as a topic, as a relation endpoint, or
1259/// as an attribute owner always resolves to the SAME entity hub.
1260#[cfg(feature = "extractor-http")]
1261fn canonical_entity(name: &str) -> String {
1262    name.trim().to_lowercase()
1263}
1264
1265/// The strict JSON contract the *graph* extraction prompt asks for.
1266#[cfg(feature = "extractor-http")]
1267#[derive(serde::Deserialize)]
1268struct RawExtraction {
1269    #[serde(default)]
1270    facts: Vec<RawFact>,
1271    #[serde(default)]
1272    relations: Vec<RawRelation>,
1273    #[serde(default)]
1274    attributes: Vec<RawAttribute>,
1275}
1276
1277#[cfg(feature = "extractor-http")]
1278#[derive(serde::Deserialize)]
1279struct RawRelation {
1280    subject: String,
1281    predicate: String,
1282    object: String,
1283}
1284
1285#[cfg(feature = "extractor-http")]
1286#[derive(serde::Deserialize)]
1287struct RawAttribute {
1288    entity: String,
1289    key: String,
1290    value: serde_json::Value,
1291}
1292
1293#[cfg(feature = "extractor-http")]
1294impl RawExtraction {
1295    /// Canonicalize and drop the unusable: a relation missing an endpoint or a
1296    /// label, an attribute missing an owner or a name. A malformed item is
1297    /// skipped rather than failing the whole passage — one bad triple must not
1298    /// cost the caller every good fact in the same reply.
1299    fn into_extraction(self) -> Extraction {
1300        Extraction {
1301            facts: self
1302                .facts
1303                .into_iter()
1304                .filter_map(RawFact::into_fact)
1305                .collect(),
1306            relations: self
1307                .relations
1308                .into_iter()
1309                .filter_map(RawRelation::into_relation)
1310                .collect(),
1311            attributes: self
1312                .attributes
1313                .into_iter()
1314                .filter_map(RawAttribute::into_attribute)
1315                .collect(),
1316        }
1317    }
1318}
1319
1320#[cfg(feature = "extractor-http")]
1321impl RawRelation {
1322    fn into_relation(self) -> Option<ExtractedRelation> {
1323        let subject = canonical_entity(&self.subject);
1324        let object = canonical_entity(&self.object);
1325        let predicate = self.predicate.trim().to_string();
1326        // A self-loop carries no information and would sit in the graph as a
1327        // permanent dead end, so it is dropped alongside the incomplete ones.
1328        if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
1329            return None;
1330        }
1331        Some(ExtractedRelation {
1332            subject,
1333            predicate,
1334            object,
1335        })
1336    }
1337}
1338
1339#[cfg(feature = "extractor-http")]
1340impl RawAttribute {
1341    fn into_attribute(self) -> Option<ExtractedAttribute> {
1342        let entity = canonical_entity(&self.entity);
1343        let key = self.key.trim().to_string();
1344        // A null value is the model saying "not stated"; storing it would make
1345        // an absent attribute look like a known-empty one.
1346        if entity.is_empty() || key.is_empty() || self.value.is_null() {
1347            return None;
1348        }
1349        Some(ExtractedAttribute {
1350            entity,
1351            key,
1352            value: self.value,
1353        })
1354    }
1355}
1356
1357/// Build the *graph* extraction prompt: the passage plus a strict JSON
1358/// contract covering facts, entity→entity edges, and entity attributes.
1359///
1360/// The contract insists numbers stay JSON numbers. `recall_where` compares
1361/// type-strictly, so an age emitted as `"15"` would never match `age >= 15` —
1362/// no error, just a silent miss, which is the worst possible failure mode for
1363/// a memory system.
1364#[cfg(feature = "extractor-http")]
1365fn build_graph_prompt(text: &str) -> String {
1366    format!(
1367        "You are building a knowledge graph from the passage below.\n\n\
1368Passage:\n{text}\n\n\
1369STEP 0 — Identify the passage's language. Everything you write (facts, \
1370predicates, attribute keys) MUST be in THAT language. Do not copy the language \
1371of the examples below: they are shown in several languages on purpose, and you \
1372must match the PASSAGE, never the example.\n\n\
1373Return THREE things.\n\n\
13741. \"facts\": the atomic, standalone facts a person would remember, in the \
1375passage's language. Rewrite each as a self-contained sentence (resolve \
1376pronouns to names; keep absolute dates). For each, list 1-4 key TOPICS it \
1377concerns, as short canonical lowercase noun phrases, so the same topic recurs \
1378as the SAME tag across passages.\n\n\
13792. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
1380subject/predicate/object triples. Use the entity's full name, lowercase \
1381(e.g. \"bruno durand\").\n\
1382The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
1383the passage's language. Examples of the SHAPE, each in its own language — \
1384match the passage, not these: a French passage gives \"travaille chez\", \
1385\"pere de\"; an English passage gives \"works at\", \"father of\"; a Spanish \
1386passage gives \"trabaja en\". NEVER restate the sentence — write \"surveille \
1387les fuites\", not \"est utilise pour la surveillance de fuites de donnees\". \
1388If you cannot say it in 3 words, pick the closest short label.\n\
1389DIRECTION: the subject is whoever CARRIES the relation, not the subject of the \
1390sentence. \"A a une soeur, B\" means B is A's sister, so the triple is \
1391B/\"soeur de\"/A — never A/\"soeur de\"/B. \"A has a brother, B\" means B is \
1392A's brother: B/\"brother of\"/A. Same for every possessive.\n\
1393Never emit both directions of the SAME predicate over the same pair — \
1394\"X brother of Y\" plus \"Y brother of X\" is a contradiction, not a \
1395converse: emit exactly one. But two DIFFERENT predicates the passage states \
1396separately over the same pair (\"A possede B\" then \"B appartient a A\") \
1397are two stated facts — keep both.\n\
1398Every named entity the passage RELATES to another must appear in at least one \
1399triple — an entity that only receives attributes and no edge is a dead end in \
1400the graph.\n\n\
14013. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
1402short lowercase keys in the passage's language (\"age\", \"ville\", \
1403\"employeur\"). Emit numbers as JSON NUMBERS, never strings: 15, not \"15\". \
1404Omit anything the passage does not state.\n\n\
1405Return ONLY this JSON object, no prose, no markdown fence:\n\
1406{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
1407\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
1408\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
1409    )
1410}
1411
1412/// Build the extraction prompt: the passage plus a strict JSON contract.
1413#[cfg(feature = "extractor-http")]
1414fn build_prompt(text: &str) -> String {
1415    format!(
1416        "You are building a memory graph from the passage below.\n\n\
1417Passage:\n{text}\n\n\
1418Extract the atomic, standalone facts a person would remember. Rewrite each as a \
1419self-contained sentence (resolve pronouns to names; keep absolute dates). For \
1420each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
1421activities, events, interests, plans, places, organisations, or named people a \
1422later question might reference. Use short, canonical, lowercase noun phrases \
1423(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
1424recurs as the SAME tag across passages.\n\n\
1425Return ONLY a JSON array, no prose, each item exactly:\n\
1426{{\"fact\": string, \"entities\": [string]}}"
1427    )
1428}
1429
1430/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
1431#[cfg(feature = "extractor-http")]
1432fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
1433    let value: serde_json::Value = serde_json::from_str(body)
1434        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
1435    let text = value
1436        .get("response")
1437        .and_then(serde_json::Value::as_str)
1438        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
1439    Ok(text.trim().to_string())
1440}
1441
1442/// A short, single-line preview of model output for error messages.
1443#[cfg(feature = "extractor-http")]
1444fn truncate(text: &str) -> String {
1445    const LIMIT: usize = 120;
1446    let mut out = String::new();
1447    for word in text.split_whitespace() {
1448        // Check the budget *before* pushing so we never need a post-hoc
1449        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
1450        let sep_len = usize::from(!out.is_empty());
1451        if out.len() + sep_len + word.len() > LIMIT {
1452            break;
1453        }
1454        if !out.is_empty() {
1455            out.push(' ');
1456        }
1457        out.push_str(word);
1458    }
1459    out
1460}
1461
1462/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
1463/// Local models usually honour "return only JSON" but occasionally wrap it in
1464/// fences or a sentence; slicing the first balanced span tolerates that.
1465#[cfg(feature = "extractor-http")]
1466fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1467    let slice = balanced_slice(text)?;
1468    serde_json::from_str::<T>(slice).ok()
1469}
1470
1471/// [`json_slice`] for a reply whose top level is a JSON **object**.
1472///
1473/// The array-preferring form cannot be reused: the graph reply is
1474/// `{"facts": [...], ...}`, whose first `[` belongs to a *nested* field, so
1475/// preferring arrays slices out the inner facts list and then fails to read it
1476/// as the whole extraction. That failure is invisible to a stub-backed test —
1477/// only a real model reply goes through this path.
1478#[cfg(feature = "extractor-http")]
1479fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
1480    let slice = balanced_slice_preferring(text, b'{')?;
1481    serde_json::from_str::<T>(slice).ok()
1482}
1483
1484/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
1485/// string literals and escapes so brackets inside quotes don't miscount.
1486///
1487/// Prefers an array: the fact-only reply is a JSON list, and prose before it
1488/// ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
1489/// object span instead of the array.
1490#[cfg(feature = "extractor-http")]
1491fn balanced_slice(text: &str) -> Option<&str> {
1492    balanced_slice_preferring(text, b'[')
1493}
1494
1495/// [`balanced_slice`] with the caller choosing which delimiter wins when both
1496/// appear — the shape the caller actually expects at the top level.
1497#[cfg(feature = "extractor-http")]
1498fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
1499    let bytes = text.as_bytes();
1500    let fallback = if preferred == b'[' { b'{' } else { b'[' };
1501    let start = bytes
1502        .iter()
1503        .position(|&b| b == preferred)
1504        .or_else(|| bytes.iter().position(|&b| b == fallback))?;
1505    let open = bytes[start];
1506    let close = if open == b'[' { b']' } else { b'}' };
1507    let mut depth = 0u32;
1508    let mut in_string = false;
1509    let mut escaped = false;
1510    for (offset, &byte) in bytes[start..].iter().enumerate() {
1511        if in_string {
1512            in_string = step_string(&mut escaped, byte);
1513        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
1514            return Some(&text[start..=start + offset]);
1515        }
1516    }
1517    None
1518}
1519
1520/// Advance the structural scan for one out-of-string byte; returns `true` once
1521/// the outermost bracket has just closed (`depth` back to zero).
1522#[cfg(feature = "extractor-http")]
1523fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
1524    if byte == b'"' {
1525        *in_string = true;
1526    } else if byte == open {
1527        *depth += 1;
1528    } else if byte == close {
1529        *depth = depth.saturating_sub(1);
1530        return *depth == 0;
1531    }
1532    false
1533}
1534
1535/// Advance the in-string escape state for one byte; returns whether the scanner
1536/// is still inside the string literal afterwards.
1537#[cfg(feature = "extractor-http")]
1538fn step_string(escaped: &mut bool, byte: u8) -> bool {
1539    match (*escaped, byte) {
1540        (true, _) => {
1541            *escaped = false;
1542            true
1543        }
1544        (false, b'\\') => {
1545            *escaped = true;
1546            true
1547        }
1548        (false, b'"') => false,
1549        (false, _) => true,
1550    }
1551}
1552
1553#[cfg(test)]
1554#[path = "extractor_selection_tests.rs"]
1555mod selection_tests;
1556
1557#[cfg(all(test, feature = "extractor-http"))]
1558#[path = "extract_tests.rs"]
1559mod tests;