Skip to main content

velesdb_memory/
extract.rs

1//! Optional text → facts + entities extraction, the layer that makes the graph
2//! self-build.
3//!
4//! The Agent Memory SDK is *bring-your-own-links*: [`crate::MemoryService::remember`]
5//! only stores the links the caller supplies, so a graph is only ever as rich as
6//! what the caller wires by hand. This module adds the missing commodity on top:
7//! an [`Extractor`] turns a paragraph of raw text into atomic facts, each tagged
8//! with the salient topics it mentions. [`crate::MemoryService::remember_extracted`]
9//! then stores those facts and wires the fact↔entity graph automatically, so
10//! `why()` has something to traverse without any manual `relate()`.
11//!
12//! Mirroring the [`crate::embedder`] pattern, the plug-point is dependency-free
13//! (bring your own LLM by implementing [`Extractor`]) while a batteries-included
14//! `OllamaExtractor` backend lives behind the `extract` feature.
15
16/// One extracted, graph-ready fact: a self-contained sentence plus the salient
17/// topics it concerns. The topics become shared graph hubs, so two facts about
18/// the same topic are reachable from one another even with no textual overlap.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExtractedFact {
21    /// The atomic, standalone fact (pronouns resolved, dates absolute).
22    pub text: String,
23    /// Salient topics the fact concerns — short canonical lowercase noun
24    /// phrases (e.g. `"adoption"`, `"charity race"`). 1-4 is typical.
25    pub entities: Vec<String>,
26}
27
28/// One extracted entity→entity edge: `subject -[predicate]-> object`.
29///
30/// Where [`ExtractedFact::entities`] only says "this fact concerns these
31/// topics", a relation says *how two topics relate*. It is what turns the
32/// bipartite fact↔topic graph into a genuine knowledge graph: from
33/// "Bruno Durand is Theo Durand's father" the wiring produces the edge
34/// `bruno durand -[father of]-> theo durand`, so a later walk can answer
35/// "who is Theo's father" without any fact mentioning both names again.
36///
37/// `subject` and `object` are canonicalized exactly like
38/// [`ExtractedFact::entities`] (trimmed, lowercased), so they resolve to the
39/// SAME entity hub as the topics — the hub id is content-addressed, so this
40/// holds across separate calls and across sessions.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ExtractedRelation {
43    /// Canonical lowercase entity the edge points *from*.
44    pub subject: String,
45    /// The edge label (e.g. `"father of"`, `"sister of"`, `"works at"`).
46    pub predicate: String,
47    /// Canonical lowercase entity the edge points *to*.
48    pub object: String,
49}
50
51/// One extracted entity attribute: `entity.key = value`.
52///
53/// Attributes are what make "Theo Durand is 15" answerable by a *filter*
54/// rather than a similarity search: the pair is merged into the entity hub's
55/// `ColumnStore` metadata, so `recall_where` can select on `age >= 15`.
56///
57/// `value` deliberately keeps its JSON type. `recall_where` comparisons are
58/// TYPE-STRICT with no coercion, so an age extracted as the string `"15"`
59/// would silently never match a numeric filter — the extraction contract
60/// therefore demands numbers stay numbers.
61#[derive(Debug, Clone, PartialEq)]
62pub struct ExtractedAttribute {
63    /// Canonical lowercase entity the attribute belongs to.
64    pub entity: String,
65    /// Attribute name, used verbatim as the metadata (`ColumnStore`) field.
66    pub key: String,
67    /// Attribute value, type preserved (a number stays a JSON number).
68    pub value: serde_json::Value,
69}
70
71/// Everything one passage yields: the atomic facts, the entity→entity edges
72/// between the topics they mention, and the attributes those entities carry.
73///
74/// [`Extractor::extract`] returns only the `facts` half; a backend that can
75/// also read relations and attributes overrides [`Extractor::extract_graph`].
76#[derive(Debug, Clone, Default, PartialEq)]
77pub struct Extraction {
78    /// The atomic, standalone facts, each with the topics it concerns.
79    pub facts: Vec<ExtractedFact>,
80    /// Typed edges between entities mentioned in the passage.
81    pub relations: Vec<ExtractedRelation>,
82    /// Attributes attached to entities mentioned in the passage.
83    pub attributes: Vec<ExtractedAttribute>,
84}
85
86// --- Orienting a kinship triple stated possessively ---------------------------
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. A possessive
90// ("X a une soeur, Y") hangs it on the OTHER one: it is Y who is X's sister.
91// Models reliably read the first and reliably mirror the second, and a mirrored
92// kinship triple is worse than a missing edge — `entity("Camille Durand")` then
93// answers, with the same confidence as any true edge, that Camille is Theo's
94// *brother*. The prompt asks for the right direction; this pass guarantees it
95// for the construction that gets it wrong, whatever backend produced the triple.
96
97/// Kinship nouns a possessive can introduce, folded (no accents, no ligature)
98/// and singular, since the markers below are singular too. Doubling as the
99/// predicate whitelist: a triple is only ever re-pointed when its label is one
100/// of these, so a non-kinship edge between the same two people is left alone.
101const KINSHIP_NOUNS: &[&str] = &[
102    "pere",
103    "mere",
104    "frere",
105    "soeur",
106    "fils",
107    "fille",
108    "oncle",
109    "tante",
110    "cousin",
111    "cousine",
112    "neveu",
113    "niece",
114    "grand-pere",
115    "grand-mere",
116    "beau-pere",
117    "belle-mere",
118    "demi-frere",
119    "demi-soeur",
120    "epoux",
121    "epouse",
122    "mari",
123    "femme",
124    "father",
125    "mother",
126    "brother",
127    "sister",
128    "son",
129    "daughter",
130    "uncle",
131    "aunt",
132    "husband",
133    "wife",
134];
135
136/// What precedes the kinship noun when the sentence hangs the relation on the
137/// person it introduces rather than on its own subject. The trailing space is
138/// load-bearing: without it `" a un "` would also fire on `"a une"`.
139const POSSESSIVE_MARKERS: &[&str] = &[" a un ", " a une ", " a pour ", " has a ", " has an "];
140
141/// Diacritics and ligatures folded to ASCII, so `"sœur"`, `"soeur"` and
142/// `"Sœur"` are one token — the passage and the model's label rarely agree on
143/// accents, and the whole pass hinges on matching one against the other.
144const FOLDINGS: &[(char, &str)] = &[
145    ('à', "a"),
146    ('â', "a"),
147    ('ä', "a"),
148    ('é', "e"),
149    ('è', "e"),
150    ('ê', "e"),
151    ('ë', "e"),
152    ('î', "i"),
153    ('ï', "i"),
154    ('ô', "o"),
155    ('ö', "o"),
156    ('ù', "u"),
157    ('û', "u"),
158    ('ü', "u"),
159    ('ç', "c"),
160    ('œ', "oe"),
161    ('æ', "ae"),
162];
163
164/// Lowercase `text` and fold its diacritics away. Every offset produced from
165/// the result indexes the *folded* string, never the original.
166fn fold(text: &str) -> String {
167    let mut folded = String::with_capacity(text.len());
168    for ch in text.chars().flat_map(char::to_lowercase) {
169        match FOLDINGS.iter().find(|(from, _)| *from == ch) {
170            Some((_, to)) => folded.push_str(to),
171            None => folded.push(ch),
172        }
173    }
174    folded
175}
176
177/// A possessive construction located in a folded passage. `start`/`end` bracket
178/// the kinship noun itself: the person who *has* the relative is named before
179/// it, the one it introduces after it.
180struct Possessive {
181    noun: &'static str,
182    start: usize,
183    end: usize,
184}
185
186/// The earliest possessive construction in `folded`, if any.
187fn find_possessive(folded: &str) -> Option<Possessive> {
188    POSSESSIVE_MARKERS
189        .iter()
190        .filter_map(|marker| folded.find(marker).map(|at| at + marker.len()))
191        .filter_map(|start| noun_at(folded, start))
192        .min_by_key(|possessive| possessive.start)
193}
194
195/// The kinship noun sitting at `start`, if the marker introduces one.
196fn noun_at(folded: &str, start: usize) -> Option<Possessive> {
197    let rest = folded.get(start..)?;
198    let noun = KINSHIP_NOUNS
199        .iter()
200        .find(|noun| starts_with_word(rest, noun))?;
201    Some(Possessive {
202        noun,
203        start,
204        end: start + noun.len(),
205    })
206}
207
208/// `rest` begins with `word` as a whole word, so `"soeurette"` never reads as
209/// `"soeur"`.
210fn starts_with_word(rest: &str, word: &str) -> bool {
211    match rest.strip_prefix(word) {
212        Some(tail) => !tail.starts_with(char::is_alphanumeric),
213        None => false,
214    }
215}
216
217/// Every distinct entity the triples name, deduplicated.
218fn endpoint_names(relations: &[ExtractedRelation]) -> Vec<String> {
219    let mut names: Vec<String> = relations
220        .iter()
221        .flat_map(|relation| [relation.subject.clone(), relation.object.clone()])
222        .collect();
223    names.sort_unstable();
224    names.dedup();
225    names
226}
227
228/// The endpoint named closest to the left of the noun: the person who HAS the
229/// relative.
230fn holder_of(before: &str, names: &[String]) -> Option<String> {
231    names
232        .iter()
233        .filter_map(|name| before.rfind(&fold(name)).map(|at| (at, name)))
234        .max_by_key(|(at, _)| *at)
235        .map(|(_, name)| name.clone())
236}
237
238/// The endpoint the noun introduces: the first one named after it.
239fn bearer_of(after: &str, names: &[String]) -> Option<String> {
240    names
241        .iter()
242        .filter_map(|name| after.find(&fold(name)).map(|at| (at, name)))
243        .min_by_key(|(at, _)| *at)
244        .map(|(_, name)| name.clone())
245}
246
247/// The head word of a predicate label, folded: `"sœur de"` → `"soeur"`.
248fn predicate_stem(predicate: &str) -> String {
249    fold(predicate)
250        .split_whitespace()
251        .next()
252        .unwrap_or_default()
253        .to_string()
254}
255
256/// Whether the triple runs between exactly these two entities, either way round.
257fn joins(relation: &ExtractedRelation, one: &str, other: &str) -> bool {
258    (relation.subject == one && relation.object == other)
259        || (relation.subject == other && relation.object == one)
260}
261
262/// Point one triple the way the passage states it.
263///
264/// The triple built on the noun the passage used belongs to the person that
265/// noun introduced; any *other* kinship label over the same pair is its
266/// converse and therefore runs the other way. Anything else is untouched.
267fn reorient(relation: &mut ExtractedRelation, noun: &str, holder: &str, bearer: &str) {
268    let stem = predicate_stem(&relation.predicate);
269    if !KINSHIP_NOUNS.contains(&stem.as_str()) || !joins(relation, holder, bearer) {
270        return;
271    }
272    let (subject, object) = if stem == noun {
273        (bearer, holder)
274    } else {
275        (holder, bearer)
276    };
277    relation.subject = subject.to_string();
278    relation.object = object.to_string();
279}
280
281/// Re-point the kinship triples a possessive construction states, so the label
282/// sits on the person who actually carries it.
283///
284/// A no-op unless the passage contains a possessive naming a kinship noun AND
285/// both sides of it resolve to entities the triples already mention — the pass
286/// never invents an edge, never drops one, and never touches a copule.
287pub(crate) fn orient_possessive_kinship(passage: &str, relations: &mut [ExtractedRelation]) {
288    let folded = fold(passage);
289    let Some(possessive) = find_possessive(&folded) else {
290        return;
291    };
292    let names = endpoint_names(relations);
293    let Some(holder) = holder_of(&folded[..possessive.start], &names) else {
294        return;
295    };
296    let Some(bearer) = bearer_of(&folded[possessive.end..], &names) else {
297        return;
298    };
299    if holder == bearer {
300        return;
301    }
302    for relation in relations.iter_mut() {
303        reorient(relation, possessive.noun, &holder, &bearer);
304    }
305}
306
307/// Failure produced by an [`Extractor`] backend (e.g. a network-backed model
308/// that cannot be reached, or output that cannot be parsed into facts).
309#[derive(Debug, thiserror::Error)]
310pub enum ExtractError {
311    /// The extraction backend (network, subprocess, …) returned an error.
312    #[error("extraction backend error: {0}")]
313    Backend(String),
314    /// The backend produced output that could not be parsed into facts.
315    #[error("could not parse facts from extractor output: {0}")]
316    Parse(String),
317}
318
319/// Turns a passage of raw text into atomic, graph-ready facts.
320///
321/// Implement this to plug in any model — a local LLM, a hosted API, or a
322/// deterministic rule set — and feed the result straight into
323/// [`crate::MemoryService::remember_extracted`].
324pub trait Extractor {
325    /// Extract the atomic facts a reader would remember from `text`.
326    ///
327    /// # Errors
328    /// Returns [`ExtractError`] if the backend fails or its output cannot be
329    /// parsed into facts.
330    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
331
332    /// Extract the facts *and* the entity→entity edges and entity attributes
333    /// the passage states.
334    ///
335    /// Defaults to [`Self::extract`] with no relations and no attributes, so
336    /// every backend written against the fact-only contract keeps compiling
337    /// and keeps working — it simply builds the bipartite fact↔topic graph it
338    /// always did. A backend that can read structure overrides this.
339    ///
340    /// # Errors
341    /// Returns [`ExtractError`] if the backend fails or its output cannot be
342    /// parsed.
343    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
344        Ok(Extraction {
345            facts: self.extract(text)?,
346            ..Extraction::default()
347        })
348    }
349}
350
351/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
352/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
353/// [`crate::MemoryService::remember_extracted`].
354///
355/// Both methods are forwarded. Forwarding only `extract` would silently route
356/// every `Arc`-held backend — which is *every* backend the MCP server and the
357/// bindings use — through the fact-only default, discarding the relations and
358/// attributes the inner extractor actually produced.
359impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
360    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
361        (**self).extract(text)
362    }
363
364    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
365        (**self).extract_graph(text)
366    }
367}
368
369/// A shared, object-safe extractor. The MCP server and the language bindings
370/// hold one of these (an `Option`), so the extraction tool can be attached at
371/// runtime without the type being generic.
372pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
373
374// --- Optional batteries-included backend: a local Ollama generative model -----
375//
376// Enabled with `--features extract`. The default build omits this backend (and
377// its HTTP dependency) so the shipped binary stays tiny and fully offline. Like
378// the Ollama embedder, it calls a model the user already runs locally, so the
379// text never leaves the machine.
380
381/// Default Ollama base URL for the generative extraction endpoint.
382#[cfg(feature = "extract")]
383pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
384
385/// Per-request timeout. Generation is far slower and more stall-prone than an
386/// embedding call, so a wedged model fails the call instead of hanging forever.
387#[cfg(feature = "extract")]
388const REQUEST_TIMEOUT_SECS: u64 = 300;
389
390/// Ceiling on establishing the TCP connection to Ollama. Short on purpose: a
391/// local daemon accepts at once or is not running, and `ureq`'s 30 s default
392/// would be paid once per replay.
393#[cfg(feature = "extract")]
394const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
395
396/// Ceiling on writing the request (prompt upload). Unlike the read bound, this
397/// one is applied to the socket at connect time and is genuinely in force.
398#[cfg(feature = "extract")]
399const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
400
401/// The knobs that actually configure the extractor, named in its failures.
402///
403/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
404/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
405/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
406/// path never consults — an "actionable" message that is actively wrong. There
407/// is no offline fallback to offer either: extraction is opt-in, and running
408/// without it is simply not passing an extractor.
409#[cfg(feature = "extract")]
410const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
411    crate::ollama_retry::OllamaLevers {
412        url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
413        model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
414        fallback: None,
415    };
416
417/// How one generation attempt failed — transport and body failures may be
418/// replayed, a complete response is the server's final word.
419#[cfg(feature = "extract")]
420enum GenerateCall {
421    /// The request never completed. Boxed: `ureq::Error::Status` carries a
422    /// whole `Response`.
423    Transport(Box<ureq::Error>),
424    /// Headers arrived but the body did not read back in full.
425    Body(std::io::Error),
426}
427
428/// Replay policy for one generation attempt.
429#[cfg(feature = "extract")]
430fn generate_is_retryable(err: &GenerateCall) -> bool {
431    match err {
432        GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
433        GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
434    }
435}
436
437/// Turn a failed generation into a message that names the endpoint, the model,
438/// how many attempts were spent, and the variables that change the outcome.
439#[cfg(feature = "extract")]
440fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
441    let cause = match err {
442        GenerateCall::Transport(inner) => inner.to_string(),
443        GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
444    };
445    crate::ollama_retry::actionable_failure(
446        "generate",
447        url,
448        model,
449        attempts,
450        &cause,
451        &EXTRACT_LEVERS,
452    )
453}
454
455/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
456/// model — and therefore the source text — on the user's own machine.
457///
458/// The caller picks the generative model (Ollama has no universal default for
459/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
460/// reproducible output.
461#[cfg(feature = "extract")]
462#[derive(Debug, Clone)]
463pub struct OllamaExtractor {
464    base_url: String,
465    model: String,
466    agent: ureq::Agent,
467}
468
469#[cfg(feature = "extract")]
470impl OllamaExtractor {
471    /// Build an extractor targeting `model` on the Ollama server at `base_url`
472    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
473    ///
474    /// The agent is bounded on four axes, not one. See
475    /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
476    /// subordinate to the global `timeout` in `ureq` and must not be read as a
477    /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
478    /// that actually bite. The connect bound matters most here: `ureq`'s own
479    /// default is 30 s, which for a `localhost` daemon is 15x too long — and
480    /// with replays, that idle wait would be paid three times over.
481    #[must_use]
482    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
483        let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
484        let agent = ureq::AgentBuilder::new()
485            .timeout_connect(CONNECT_TIMEOUT)
486            .timeout_write(WRITE_TIMEOUT)
487            .timeout_read(timeout)
488            .timeout(timeout)
489            .build();
490        Self {
491            base_url: base_url.into(),
492            model: model.into(),
493            agent,
494        }
495    }
496}
497
498#[cfg(feature = "extract")]
499impl Extractor for OllamaExtractor {
500    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
501        let reply = self.generate(&build_prompt(text))?;
502        let raw = json_slice::<Vec<RawFact>>(&reply)
503            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
504        Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
505    }
506
507    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
508        let reply = self.generate(&build_graph_prompt(text))?;
509        let raw = json_slice_object::<RawExtraction>(&reply)
510            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
511        Ok(raw.into_extraction())
512    }
513}
514
515#[cfg(feature = "extract")]
516impl OllamaExtractor {
517    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
518    /// replaying the call when the failure is transient.
519    ///
520    /// Same defect, same repair as the embedder: this extractor also holds one
521    /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
522    /// Ollama may have closed, and `ureq` will not replay a POST with a body.
523    /// The whole attempt — POST and body read — is inside the closure so a
524    /// truncated response is replayed rather than surfacing as a parse error.
525    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
526        let url = format!("{}/api/generate", self.base_url);
527        let body = serde_json::json!({
528            "model": self.model,
529            "prompt": prompt,
530            "stream": false,
531            "think": false,
532            // Extraction models are large — the one this crate documents as an
533            // example is 21.9 GB — so an unload between calls is the dominant
534            // cost, not the generation. Shares the embedder's knob so one
535            // setting governs every Ollama call the daemon makes.
536            "keep_alive": crate::embedder::keep_alive(),
537            "options": { "temperature": 0 },
538        })
539        .to_string();
540        let attempt = || {
541            let response = self
542                .agent
543                .post(&url)
544                .set("Content-Type", "application/json")
545                .send_string(&body)
546                .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
547            response.into_string().map_err(GenerateCall::Body)
548        };
549
550        let payload = crate::ollama_retry::with_retry(
551            &crate::ollama_retry::OLLAMA_RETRIES,
552            generate_is_retryable,
553            attempt,
554        )
555        .map_err(|(err, attempts)| {
556            ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
557        })?;
558        parse_generate_response(&payload)
559    }
560}
561
562/// The strict JSON contract the extraction prompt asks the model to honour.
563#[cfg(feature = "extract")]
564#[derive(serde::Deserialize)]
565struct RawFact {
566    fact: String,
567    #[serde(default)]
568    entities: Vec<String>,
569}
570
571#[cfg(feature = "extract")]
572impl RawFact {
573    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
574    /// blanks and duplicates so the same topic recurs as the same graph hub.
575    fn into_fact(self) -> Option<ExtractedFact> {
576        let text = self.fact.trim().to_string();
577        if text.is_empty() {
578            return None;
579        }
580        let mut entities: Vec<String> = self
581            .entities
582            .into_iter()
583            .map(|entity| entity.trim().to_lowercase())
584            .filter(|entity| !entity.is_empty())
585            .collect();
586        entities.sort_unstable();
587        entities.dedup();
588        Some(ExtractedFact { text, entities })
589    }
590}
591
592/// Canonical form of an entity name: trimmed and lowercased. The one place
593/// the rule lives, so a name arriving as a topic, as a relation endpoint, or
594/// as an attribute owner always resolves to the SAME entity hub.
595#[cfg(feature = "extract")]
596fn canonical_entity(name: &str) -> String {
597    name.trim().to_lowercase()
598}
599
600/// The strict JSON contract the *graph* extraction prompt asks for.
601#[cfg(feature = "extract")]
602#[derive(serde::Deserialize)]
603struct RawExtraction {
604    #[serde(default)]
605    facts: Vec<RawFact>,
606    #[serde(default)]
607    relations: Vec<RawRelation>,
608    #[serde(default)]
609    attributes: Vec<RawAttribute>,
610}
611
612#[cfg(feature = "extract")]
613#[derive(serde::Deserialize)]
614struct RawRelation {
615    subject: String,
616    predicate: String,
617    object: String,
618}
619
620#[cfg(feature = "extract")]
621#[derive(serde::Deserialize)]
622struct RawAttribute {
623    entity: String,
624    key: String,
625    value: serde_json::Value,
626}
627
628#[cfg(feature = "extract")]
629impl RawExtraction {
630    /// Canonicalize and drop the unusable: a relation missing an endpoint or a
631    /// label, an attribute missing an owner or a name. A malformed item is
632    /// skipped rather than failing the whole passage — one bad triple must not
633    /// cost the caller every good fact in the same reply.
634    fn into_extraction(self) -> Extraction {
635        Extraction {
636            facts: self
637                .facts
638                .into_iter()
639                .filter_map(RawFact::into_fact)
640                .collect(),
641            relations: self
642                .relations
643                .into_iter()
644                .filter_map(RawRelation::into_relation)
645                .collect(),
646            attributes: self
647                .attributes
648                .into_iter()
649                .filter_map(RawAttribute::into_attribute)
650                .collect(),
651        }
652    }
653}
654
655#[cfg(feature = "extract")]
656impl RawRelation {
657    fn into_relation(self) -> Option<ExtractedRelation> {
658        let subject = canonical_entity(&self.subject);
659        let object = canonical_entity(&self.object);
660        let predicate = self.predicate.trim().to_string();
661        // A self-loop carries no information and would sit in the graph as a
662        // permanent dead end, so it is dropped alongside the incomplete ones.
663        if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
664            return None;
665        }
666        Some(ExtractedRelation {
667            subject,
668            predicate,
669            object,
670        })
671    }
672}
673
674#[cfg(feature = "extract")]
675impl RawAttribute {
676    fn into_attribute(self) -> Option<ExtractedAttribute> {
677        let entity = canonical_entity(&self.entity);
678        let key = self.key.trim().to_string();
679        // A null value is the model saying "not stated"; storing it would make
680        // an absent attribute look like a known-empty one.
681        if entity.is_empty() || key.is_empty() || self.value.is_null() {
682            return None;
683        }
684        Some(ExtractedAttribute {
685            entity,
686            key,
687            value: self.value,
688        })
689    }
690}
691
692/// Build the *graph* extraction prompt: the passage plus a strict JSON
693/// contract covering facts, entity→entity edges, and entity attributes.
694///
695/// The contract insists numbers stay JSON numbers. `recall_where` compares
696/// type-strictly, so an age emitted as `"15"` would never match `age >= 15` —
697/// no error, just a silent miss, which is the worst possible failure mode for
698/// a memory system.
699#[cfg(feature = "extract")]
700fn build_graph_prompt(text: &str) -> String {
701    format!(
702        "You are building a knowledge graph from the passage below.\n\n\
703Passage:\n{text}\n\n\
704Return THREE things.\n\n\
7051. \"facts\": the atomic, standalone facts a person would remember. Rewrite each \
706as a self-contained sentence (resolve pronouns to names; keep absolute dates). \
707For each, list 1-4 key TOPICS it concerns, as short canonical lowercase noun \
708phrases, so the same topic recurs as the SAME tag across passages.\n\n\
7092. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
710subject/predicate/object triples. Use the entity's full name, lowercase \
711(e.g. \"bruno durand\").\n\
712The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
713the passage's own language (e.g. \"pere de\", \"soeur de\", \"works at\", \
714\"moteur de recherche\"). NEVER restate the sentence — write \"surveille les \
715fuites\", not \"est utilise pour la surveillance de fuites de donnees\". If you \
716cannot say it in 3 words, pick the closest short label.\n\
717State the triple in the direction the passage states it, and add the converse \
718ONLY if the passage states it too.\n\
719DIRECTION: the subject is whoever CARRIES the relation, not the subject of the \
720sentence. \"A a une soeur, B\" means B is A's sister, so the triple is \
721B/\"soeur de\"/A — never A/\"soeur de\"/B. Same for every possessive \
722(\"a un frere\", \"a une fille\", \"has a brother\").\n\
723Every named entity the passage RELATES to another must appear in at least one \
724triple — an entity that only receives attributes and no edge is a dead end in \
725the graph.\n\n\
7263. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
727short lowercase keys (\"age\", \"ville\", \"employeur\"). Emit numbers as JSON \
728NUMBERS, never strings: 15, not \"15\". Omit anything the passage does not state.\n\n\
729Return ONLY this JSON object, no prose:\n\
730{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
731\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
732\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
733    )
734}
735
736/// Build the extraction prompt: the passage plus a strict JSON contract.
737#[cfg(feature = "extract")]
738fn build_prompt(text: &str) -> String {
739    format!(
740        "You are building a memory graph from the passage below.\n\n\
741Passage:\n{text}\n\n\
742Extract the atomic, standalone facts a person would remember. Rewrite each as a \
743self-contained sentence (resolve pronouns to names; keep absolute dates). For \
744each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
745activities, events, interests, plans, places, organisations, or named people a \
746later question might reference. Use short, canonical, lowercase noun phrases \
747(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
748recurs as the SAME tag across passages.\n\n\
749Return ONLY a JSON array, no prose, each item exactly:\n\
750{{\"fact\": string, \"entities\": [string]}}"
751    )
752}
753
754/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
755#[cfg(feature = "extract")]
756fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
757    let value: serde_json::Value = serde_json::from_str(body)
758        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
759    let text = value
760        .get("response")
761        .and_then(serde_json::Value::as_str)
762        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
763    Ok(text.trim().to_string())
764}
765
766/// A short, single-line preview of model output for error messages.
767#[cfg(feature = "extract")]
768fn truncate(text: &str) -> String {
769    const LIMIT: usize = 120;
770    let mut out = String::new();
771    for word in text.split_whitespace() {
772        // Check the budget *before* pushing so we never need a post-hoc
773        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
774        let sep_len = usize::from(!out.is_empty());
775        if out.len() + sep_len + word.len() > LIMIT {
776            break;
777        }
778        if !out.is_empty() {
779            out.push(' ');
780        }
781        out.push_str(word);
782    }
783    out
784}
785
786/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
787/// Local models usually honour "return only JSON" but occasionally wrap it in
788/// fences or a sentence; slicing the first balanced span tolerates that.
789#[cfg(feature = "extract")]
790fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
791    let slice = balanced_slice(text)?;
792    serde_json::from_str::<T>(slice).ok()
793}
794
795/// [`json_slice`] for a reply whose top level is a JSON **object**.
796///
797/// The array-preferring form cannot be reused: the graph reply is
798/// `{"facts": [...], ...}`, whose first `[` belongs to a *nested* field, so
799/// preferring arrays slices out the inner facts list and then fails to read it
800/// as the whole extraction. That failure is invisible to a stub-backed test —
801/// only a real model reply goes through this path.
802#[cfg(feature = "extract")]
803fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
804    let slice = balanced_slice_preferring(text, b'{')?;
805    serde_json::from_str::<T>(slice).ok()
806}
807
808/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
809/// string literals and escapes so brackets inside quotes don't miscount.
810///
811/// Prefers an array: the fact-only reply is a JSON list, and prose before it
812/// ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
813/// object span instead of the array.
814#[cfg(feature = "extract")]
815fn balanced_slice(text: &str) -> Option<&str> {
816    balanced_slice_preferring(text, b'[')
817}
818
819/// [`balanced_slice`] with the caller choosing which delimiter wins when both
820/// appear — the shape the caller actually expects at the top level.
821#[cfg(feature = "extract")]
822fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
823    let bytes = text.as_bytes();
824    let fallback = if preferred == b'[' { b'{' } else { b'[' };
825    let start = bytes
826        .iter()
827        .position(|&b| b == preferred)
828        .or_else(|| bytes.iter().position(|&b| b == fallback))?;
829    let open = bytes[start];
830    let close = if open == b'[' { b']' } else { b'}' };
831    let mut depth = 0u32;
832    let mut in_string = false;
833    let mut escaped = false;
834    for (offset, &byte) in bytes[start..].iter().enumerate() {
835        if in_string {
836            in_string = step_string(&mut escaped, byte);
837        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
838            return Some(&text[start..=start + offset]);
839        }
840    }
841    None
842}
843
844/// Advance the structural scan for one out-of-string byte; returns `true` once
845/// the outermost bracket has just closed (`depth` back to zero).
846#[cfg(feature = "extract")]
847fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
848    if byte == b'"' {
849        *in_string = true;
850    } else if byte == open {
851        *depth += 1;
852    } else if byte == close {
853        *depth = depth.saturating_sub(1);
854        return *depth == 0;
855    }
856    false
857}
858
859/// Advance the in-string escape state for one byte; returns whether the scanner
860/// is still inside the string literal afterwards.
861#[cfg(feature = "extract")]
862fn step_string(escaped: &mut bool, byte: u8) -> bool {
863    match (*escaped, byte) {
864        (true, _) => {
865            *escaped = false;
866            true
867        }
868        (false, b'\\') => {
869            *escaped = true;
870            true
871        }
872        (false, b'"') => false,
873        (false, _) => true,
874    }
875}
876
877#[cfg(all(test, feature = "extract"))]
878mod tests {
879    use super::*;
880
881    /// Regression: the graph reply is an OBJECT whose first `[` belongs to the
882    /// nested `facts` field. Slicing with the array preference grabbed that
883    /// inner list and failed to read it as the whole extraction — a real model
884    /// reply was rejected wholesale while every stub-backed test stayed green.
885    #[test]
886    fn parses_a_graph_reply_whose_first_bracket_is_nested() {
887        let reply = r#"{ "facts": [ { "fact": "Zephyrin is the father of Kaltar.", "entities": ["zephyrin", "kaltar"] } ], "relations": [ { "subject": "zephyrin", "predicate": "pere de", "object": "kaltar" } ], "attributes": [ { "entity": "kaltar", "key": "age", "value": 15 } ] }"#;
888        let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
889        let extraction = raw.into_extraction();
890        assert_eq!(extraction.facts.len(), 1);
891        assert_eq!(extraction.relations.len(), 1);
892        assert_eq!(extraction.relations[0].predicate, "pere de");
893        assert_eq!(extraction.attributes.len(), 1);
894        assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
895    }
896
897    /// Prose (and a fenced block) around the object must not defeat slicing.
898    #[test]
899    fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
900        let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
901        let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
902        assert_eq!(raw.into_extraction().relations.len(), 1);
903    }
904
905    /// The fact-only path must keep preferring an array: prose carrying a stray
906    /// `{` before the list is exactly what that preference exists to survive.
907    #[test]
908    fn fact_only_slicing_still_prefers_the_array() {
909        let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
910        let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
911        assert_eq!(raw.len(), 1);
912    }
913
914    #[test]
915    fn graph_prompt_demands_numeric_values_and_the_three_sections() {
916        let prompt = build_graph_prompt("Kaltar a 15 ans.");
917        assert!(prompt.contains("Kaltar a 15 ans."));
918        assert!(prompt.contains("\"relations\""));
919        assert!(prompt.contains("\"attributes\""));
920        assert!(prompt.contains("15, not \"15\""));
921    }
922
923    #[test]
924    fn prompt_carries_the_passage_and_json_contract() {
925        let prompt = build_prompt("Alice adopted a dog in 2021.");
926        assert!(prompt.contains("Alice adopted a dog in 2021."));
927        assert!(prompt.contains("\"fact\": string"));
928    }
929
930    /// The graph prompt has to bound the predicate explicitly. Asking for "a
931    /// short label" was not enough: on real content the model answered
932    /// "est utilise pour la surveillance de fuites de donnees" — a restated
933    /// sentence, which makes the edge unreadable in `entity()`.
934    #[test]
935    fn graph_prompt_bounds_the_predicate_and_demands_edges() {
936        let prompt = build_graph_prompt("Ahmia is an onion search engine.");
937        assert!(prompt.contains("Ahmia is an onion search engine."));
938        assert!(
939            prompt.contains("at most 3 words"),
940            "the predicate length must be a hard bound, not a suggestion"
941        );
942        assert!(
943            prompt.contains("NEVER restate the sentence"),
944            "the counter-example is what stops a restated sentence"
945        );
946        assert!(
947            prompt.contains("at least one triple"),
948            "an entity with attributes but no edge is a dead end — the prompt \
949             must ask for the edge"
950        );
951    }
952
953    /// The prompt must state the possessive rule explicitly: asked only to
954    /// "state the triple in the direction the passage states it", the model
955    /// read the grammatical subject as the subject of the triple and mirrored
956    /// every possessive.
957    #[test]
958    fn graph_prompt_states_which_side_carries_the_relation() {
959        let prompt = build_graph_prompt("Theo Durand a une soeur, Camille Durand.");
960        assert!(
961            prompt.contains("whoever CARRIES the relation"),
962            "the rule must name the carrier, not just \"the direction\""
963        );
964        assert!(
965            prompt.contains("never A/\"soeur de\"/B"),
966            "the counter-example is what makes the rule unambiguous"
967        );
968    }
969
970    #[test]
971    fn parses_facts_from_a_fenced_reply() {
972        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
973        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
974        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
975        assert_eq!(facts.len(), 1);
976        assert_eq!(facts[0].text, "Alice adopted a dog.");
977        // Trimmed, lowercased, deduplicated, blanks dropped.
978        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
979    }
980
981    #[test]
982    fn drops_a_textless_fact() {
983        let raw = RawFact {
984            fact: "   ".to_string(),
985            entities: vec!["x".to_string()],
986        };
987        assert!(raw.into_fact().is_none());
988    }
989
990    #[test]
991    fn parses_response_envelope() {
992        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
993        assert_eq!(text, "[]");
994    }
995
996    #[test]
997    fn rejects_response_without_field() {
998        assert!(matches!(
999            parse_generate_response(r#"{"oops":true}"#),
1000            Err(ExtractError::Backend(_))
1001        ));
1002    }
1003}