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;