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/// "Julien Lange is Axel Lange's father" the wiring produces the edge
34/// `julien lange -[father of]-> axel lange`, so a later walk can answer
35/// "who is Axel'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 "Axel Lange 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/// Failure produced by an [`Extractor`] backend (e.g. a network-backed model
87/// that cannot be reached, or output that cannot be parsed into facts).
88#[derive(Debug, thiserror::Error)]
89pub enum ExtractError {
90    /// The extraction backend (network, subprocess, …) returned an error.
91    #[error("extraction backend error: {0}")]
92    Backend(String),
93    /// The backend produced output that could not be parsed into facts.
94    #[error("could not parse facts from extractor output: {0}")]
95    Parse(String),
96}
97
98/// Turns a passage of raw text into atomic, graph-ready facts.
99///
100/// Implement this to plug in any model — a local LLM, a hosted API, or a
101/// deterministic rule set — and feed the result straight into
102/// [`crate::MemoryService::remember_extracted`].
103pub trait Extractor {
104    /// Extract the atomic facts a reader would remember from `text`.
105    ///
106    /// # Errors
107    /// Returns [`ExtractError`] if the backend fails or its output cannot be
108    /// parsed into facts.
109    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
110
111    /// Extract the facts *and* the entity→entity edges and entity attributes
112    /// the passage states.
113    ///
114    /// Defaults to [`Self::extract`] with no relations and no attributes, so
115    /// every backend written against the fact-only contract keeps compiling
116    /// and keeps working — it simply builds the bipartite fact↔topic graph it
117    /// always did. A backend that can read structure overrides this.
118    ///
119    /// # Errors
120    /// Returns [`ExtractError`] if the backend fails or its output cannot be
121    /// parsed.
122    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
123        Ok(Extraction {
124            facts: self.extract(text)?,
125            ..Extraction::default()
126        })
127    }
128}
129
130/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
131/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
132/// [`crate::MemoryService::remember_extracted`].
133///
134/// Both methods are forwarded. Forwarding only `extract` would silently route
135/// every `Arc`-held backend — which is *every* backend the MCP server and the
136/// bindings use — through the fact-only default, discarding the relations and
137/// attributes the inner extractor actually produced.
138impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
139    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
140        (**self).extract(text)
141    }
142
143    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
144        (**self).extract_graph(text)
145    }
146}
147
148/// A shared, object-safe extractor. The MCP server and the language bindings
149/// hold one of these (an `Option`), so the extraction tool can be attached at
150/// runtime without the type being generic.
151pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
152
153// --- Optional batteries-included backend: a local Ollama generative model -----
154//
155// Enabled with `--features extract`. The default build omits this backend (and
156// its HTTP dependency) so the shipped binary stays tiny and fully offline. Like
157// the Ollama embedder, it calls a model the user already runs locally, so the
158// text never leaves the machine.
159
160/// Default Ollama base URL for the generative extraction endpoint.
161#[cfg(feature = "extract")]
162pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
163
164/// Per-request timeout. Generation is far slower and more stall-prone than an
165/// embedding call, so a wedged model fails the call instead of hanging forever.
166#[cfg(feature = "extract")]
167const REQUEST_TIMEOUT_SECS: u64 = 300;
168
169/// Ceiling on establishing the TCP connection to Ollama. Short on purpose: a
170/// local daemon accepts at once or is not running, and `ureq`'s 30 s default
171/// would be paid once per replay.
172#[cfg(feature = "extract")]
173const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
174
175/// Ceiling on writing the request (prompt upload). Unlike the read bound, this
176/// one is applied to the socket at connect time and is genuinely in force.
177#[cfg(feature = "extract")]
178const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
179
180/// The knobs that actually configure the extractor, named in its failures.
181///
182/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
183/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
184/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
185/// path never consults — an "actionable" message that is actively wrong. There
186/// is no offline fallback to offer either: extraction is opt-in, and running
187/// without it is simply not passing an extractor.
188#[cfg(feature = "extract")]
189const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
190    crate::ollama_retry::OllamaLevers {
191        url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
192        model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
193        fallback: None,
194    };
195
196/// How one generation attempt failed — transport and body failures may be
197/// replayed, a complete response is the server's final word.
198#[cfg(feature = "extract")]
199enum GenerateCall {
200    /// The request never completed. Boxed: `ureq::Error::Status` carries a
201    /// whole `Response`.
202    Transport(Box<ureq::Error>),
203    /// Headers arrived but the body did not read back in full.
204    Body(std::io::Error),
205}
206
207/// Replay policy for one generation attempt.
208#[cfg(feature = "extract")]
209fn generate_is_retryable(err: &GenerateCall) -> bool {
210    match err {
211        GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
212        GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
213    }
214}
215
216/// Turn a failed generation into a message that names the endpoint, the model,
217/// how many attempts were spent, and the variables that change the outcome.
218#[cfg(feature = "extract")]
219fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
220    let cause = match err {
221        GenerateCall::Transport(inner) => inner.to_string(),
222        GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
223    };
224    crate::ollama_retry::actionable_failure(
225        "generate",
226        url,
227        model,
228        attempts,
229        &cause,
230        &EXTRACT_LEVERS,
231    )
232}
233
234/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
235/// model — and therefore the source text — on the user's own machine.
236///
237/// The caller picks the generative model (Ollama has no universal default for
238/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
239/// reproducible output.
240#[cfg(feature = "extract")]
241#[derive(Debug, Clone)]
242pub struct OllamaExtractor {
243    base_url: String,
244    model: String,
245    agent: ureq::Agent,
246}
247
248#[cfg(feature = "extract")]
249impl OllamaExtractor {
250    /// Build an extractor targeting `model` on the Ollama server at `base_url`
251    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
252    ///
253    /// The agent is bounded on four axes, not one. See
254    /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
255    /// subordinate to the global `timeout` in `ureq` and must not be read as a
256    /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
257    /// that actually bite. The connect bound matters most here: `ureq`'s own
258    /// default is 30 s, which for a `localhost` daemon is 15x too long — and
259    /// with replays, that idle wait would be paid three times over.
260    #[must_use]
261    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
262        let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
263        let agent = ureq::AgentBuilder::new()
264            .timeout_connect(CONNECT_TIMEOUT)
265            .timeout_write(WRITE_TIMEOUT)
266            .timeout_read(timeout)
267            .timeout(timeout)
268            .build();
269        Self {
270            base_url: base_url.into(),
271            model: model.into(),
272            agent,
273        }
274    }
275}
276
277#[cfg(feature = "extract")]
278impl Extractor for OllamaExtractor {
279    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
280        let reply = self.generate(&build_prompt(text))?;
281        let raw = json_slice::<Vec<RawFact>>(&reply)
282            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
283        Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
284    }
285
286    fn extract_graph(&self, text: &str) -> Result<Extraction, ExtractError> {
287        let reply = self.generate(&build_graph_prompt(text))?;
288        let raw = json_slice_object::<RawExtraction>(&reply)
289            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
290        Ok(raw.into_extraction())
291    }
292}
293
294#[cfg(feature = "extract")]
295impl OllamaExtractor {
296    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
297    /// replaying the call when the failure is transient.
298    ///
299    /// Same defect, same repair as the embedder: this extractor also holds one
300    /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
301    /// Ollama may have closed, and `ureq` will not replay a POST with a body.
302    /// The whole attempt — POST and body read — is inside the closure so a
303    /// truncated response is replayed rather than surfacing as a parse error.
304    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
305        let url = format!("{}/api/generate", self.base_url);
306        let body = serde_json::json!({
307            "model": self.model,
308            "prompt": prompt,
309            "stream": false,
310            "think": false,
311            // Extraction models are large — the one this crate documents as an
312            // example is 21.9 GB — so an unload between calls is the dominant
313            // cost, not the generation. Shares the embedder's knob so one
314            // setting governs every Ollama call the daemon makes.
315            "keep_alive": crate::embedder::keep_alive(),
316            "options": { "temperature": 0 },
317        })
318        .to_string();
319        let attempt = || {
320            let response = self
321                .agent
322                .post(&url)
323                .set("Content-Type", "application/json")
324                .send_string(&body)
325                .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
326            response.into_string().map_err(GenerateCall::Body)
327        };
328
329        let payload = crate::ollama_retry::with_retry(
330            &crate::ollama_retry::OLLAMA_RETRIES,
331            generate_is_retryable,
332            attempt,
333        )
334        .map_err(|(err, attempts)| {
335            ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
336        })?;
337        parse_generate_response(&payload)
338    }
339}
340
341/// The strict JSON contract the extraction prompt asks the model to honour.
342#[cfg(feature = "extract")]
343#[derive(serde::Deserialize)]
344struct RawFact {
345    fact: String,
346    #[serde(default)]
347    entities: Vec<String>,
348}
349
350#[cfg(feature = "extract")]
351impl RawFact {
352    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
353    /// blanks and duplicates so the same topic recurs as the same graph hub.
354    fn into_fact(self) -> Option<ExtractedFact> {
355        let text = self.fact.trim().to_string();
356        if text.is_empty() {
357            return None;
358        }
359        let mut entities: Vec<String> = self
360            .entities
361            .into_iter()
362            .map(|entity| entity.trim().to_lowercase())
363            .filter(|entity| !entity.is_empty())
364            .collect();
365        entities.sort_unstable();
366        entities.dedup();
367        Some(ExtractedFact { text, entities })
368    }
369}
370
371/// Canonical form of an entity name: trimmed and lowercased. The one place
372/// the rule lives, so a name arriving as a topic, as a relation endpoint, or
373/// as an attribute owner always resolves to the SAME entity hub.
374#[cfg(feature = "extract")]
375fn canonical_entity(name: &str) -> String {
376    name.trim().to_lowercase()
377}
378
379/// The strict JSON contract the *graph* extraction prompt asks for.
380#[cfg(feature = "extract")]
381#[derive(serde::Deserialize)]
382struct RawExtraction {
383    #[serde(default)]
384    facts: Vec<RawFact>,
385    #[serde(default)]
386    relations: Vec<RawRelation>,
387    #[serde(default)]
388    attributes: Vec<RawAttribute>,
389}
390
391#[cfg(feature = "extract")]
392#[derive(serde::Deserialize)]
393struct RawRelation {
394    subject: String,
395    predicate: String,
396    object: String,
397}
398
399#[cfg(feature = "extract")]
400#[derive(serde::Deserialize)]
401struct RawAttribute {
402    entity: String,
403    key: String,
404    value: serde_json::Value,
405}
406
407#[cfg(feature = "extract")]
408impl RawExtraction {
409    /// Canonicalize and drop the unusable: a relation missing an endpoint or a
410    /// label, an attribute missing an owner or a name. A malformed item is
411    /// skipped rather than failing the whole passage — one bad triple must not
412    /// cost the caller every good fact in the same reply.
413    fn into_extraction(self) -> Extraction {
414        Extraction {
415            facts: self
416                .facts
417                .into_iter()
418                .filter_map(RawFact::into_fact)
419                .collect(),
420            relations: self
421                .relations
422                .into_iter()
423                .filter_map(RawRelation::into_relation)
424                .collect(),
425            attributes: self
426                .attributes
427                .into_iter()
428                .filter_map(RawAttribute::into_attribute)
429                .collect(),
430        }
431    }
432}
433
434#[cfg(feature = "extract")]
435impl RawRelation {
436    fn into_relation(self) -> Option<ExtractedRelation> {
437        let subject = canonical_entity(&self.subject);
438        let object = canonical_entity(&self.object);
439        let predicate = self.predicate.trim().to_string();
440        // A self-loop carries no information and would sit in the graph as a
441        // permanent dead end, so it is dropped alongside the incomplete ones.
442        if subject.is_empty() || object.is_empty() || predicate.is_empty() || subject == object {
443            return None;
444        }
445        Some(ExtractedRelation {
446            subject,
447            predicate,
448            object,
449        })
450    }
451}
452
453#[cfg(feature = "extract")]
454impl RawAttribute {
455    fn into_attribute(self) -> Option<ExtractedAttribute> {
456        let entity = canonical_entity(&self.entity);
457        let key = self.key.trim().to_string();
458        // A null value is the model saying "not stated"; storing it would make
459        // an absent attribute look like a known-empty one.
460        if entity.is_empty() || key.is_empty() || self.value.is_null() {
461            return None;
462        }
463        Some(ExtractedAttribute {
464            entity,
465            key,
466            value: self.value,
467        })
468    }
469}
470
471/// Build the *graph* extraction prompt: the passage plus a strict JSON
472/// contract covering facts, entity→entity edges, and entity attributes.
473///
474/// The contract insists numbers stay JSON numbers. `recall_where` compares
475/// type-strictly, so an age emitted as `"15"` would never match `age >= 15` —
476/// no error, just a silent miss, which is the worst possible failure mode for
477/// a memory system.
478#[cfg(feature = "extract")]
479fn build_graph_prompt(text: &str) -> String {
480    format!(
481        "You are building a knowledge graph from the passage below.\n\n\
482Passage:\n{text}\n\n\
483Return THREE things.\n\n\
4841. \"facts\": the atomic, standalone facts a person would remember. Rewrite each \
485as a self-contained sentence (resolve pronouns to names; keep absolute dates). \
486For each, list 1-4 key TOPICS it concerns, as short canonical lowercase noun \
487phrases, so the same topic recurs as the SAME tag across passages.\n\n\
4882. \"relations\": every explicit relationship BETWEEN TWO NAMED ENTITIES, as \
489subject/predicate/object triples. Use the entity's full name, lowercase \
490(e.g. \"julien lange\").\n\
491The predicate is a LABEL, not a sentence: **at most 3 words**, lowercase, in \
492the passage's own language (e.g. \"pere de\", \"soeur de\", \"works at\", \
493\"moteur de recherche\"). NEVER restate the sentence — write \"surveille les \
494fuites\", not \"est utilise pour la surveillance de fuites de donnees\". If you \
495cannot say it in 3 words, pick the closest short label.\n\
496State the triple in the direction the passage states it, and add the converse \
497ONLY if the passage states it too.\n\
498Every named entity the passage RELATES to another must appear in at least one \
499triple — an entity that only receives attributes and no edge is a dead end in \
500the graph.\n\n\
5013. \"attributes\": every property a named entity HAS, as entity/key/value. Use \
502short lowercase keys (\"age\", \"ville\", \"employeur\"). Emit numbers as JSON \
503NUMBERS, never strings: 15, not \"15\". Omit anything the passage does not state.\n\n\
504Return ONLY this JSON object, no prose:\n\
505{{\"facts\": [{{\"fact\": string, \"entities\": [string]}}], \
506\"relations\": [{{\"subject\": string, \"predicate\": string, \"object\": string}}], \
507\"attributes\": [{{\"entity\": string, \"key\": string, \"value\": string|number|boolean}}]}}"
508    )
509}
510
511/// Build the extraction prompt: the passage plus a strict JSON contract.
512#[cfg(feature = "extract")]
513fn build_prompt(text: &str) -> String {
514    format!(
515        "You are building a memory graph from the passage below.\n\n\
516Passage:\n{text}\n\n\
517Extract the atomic, standalone facts a person would remember. Rewrite each as a \
518self-contained sentence (resolve pronouns to names; keep absolute dates). For \
519each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
520activities, events, interests, plans, places, organisations, or named people a \
521later question might reference. Use short, canonical, lowercase noun phrases \
522(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
523recurs as the SAME tag across passages.\n\n\
524Return ONLY a JSON array, no prose, each item exactly:\n\
525{{\"fact\": string, \"entities\": [string]}}"
526    )
527}
528
529/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
530#[cfg(feature = "extract")]
531fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
532    let value: serde_json::Value = serde_json::from_str(body)
533        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
534    let text = value
535        .get("response")
536        .and_then(serde_json::Value::as_str)
537        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
538    Ok(text.trim().to_string())
539}
540
541/// A short, single-line preview of model output for error messages.
542#[cfg(feature = "extract")]
543fn truncate(text: &str) -> String {
544    const LIMIT: usize = 120;
545    let mut out = String::new();
546    for word in text.split_whitespace() {
547        // Check the budget *before* pushing so we never need a post-hoc
548        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
549        let sep_len = usize::from(!out.is_empty());
550        if out.len() + sep_len + word.len() > LIMIT {
551            break;
552        }
553        if !out.is_empty() {
554            out.push(' ');
555        }
556        out.push_str(word);
557    }
558    out
559}
560
561/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
562/// Local models usually honour "return only JSON" but occasionally wrap it in
563/// fences or a sentence; slicing the first balanced span tolerates that.
564#[cfg(feature = "extract")]
565fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
566    let slice = balanced_slice(text)?;
567    serde_json::from_str::<T>(slice).ok()
568}
569
570/// [`json_slice`] for a reply whose top level is a JSON **object**.
571///
572/// The array-preferring form cannot be reused: the graph reply is
573/// `{"facts": [...], ...}`, whose first `[` belongs to a *nested* field, so
574/// preferring arrays slices out the inner facts list and then fails to read it
575/// as the whole extraction. That failure is invisible to a stub-backed test —
576/// only a real model reply goes through this path.
577#[cfg(feature = "extract")]
578fn json_slice_object<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
579    let slice = balanced_slice_preferring(text, b'{')?;
580    serde_json::from_str::<T>(slice).ok()
581}
582
583/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
584/// string literals and escapes so brackets inside quotes don't miscount.
585///
586/// Prefers an array: the fact-only reply is a JSON list, and prose before it
587/// ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
588/// object span instead of the array.
589#[cfg(feature = "extract")]
590fn balanced_slice(text: &str) -> Option<&str> {
591    balanced_slice_preferring(text, b'[')
592}
593
594/// [`balanced_slice`] with the caller choosing which delimiter wins when both
595/// appear — the shape the caller actually expects at the top level.
596#[cfg(feature = "extract")]
597fn balanced_slice_preferring(text: &str, preferred: u8) -> Option<&str> {
598    let bytes = text.as_bytes();
599    let fallback = if preferred == b'[' { b'{' } else { b'[' };
600    let start = bytes
601        .iter()
602        .position(|&b| b == preferred)
603        .or_else(|| bytes.iter().position(|&b| b == fallback))?;
604    let open = bytes[start];
605    let close = if open == b'[' { b']' } else { b'}' };
606    let mut depth = 0u32;
607    let mut in_string = false;
608    let mut escaped = false;
609    for (offset, &byte) in bytes[start..].iter().enumerate() {
610        if in_string {
611            in_string = step_string(&mut escaped, byte);
612        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
613            return Some(&text[start..=start + offset]);
614        }
615    }
616    None
617}
618
619/// Advance the structural scan for one out-of-string byte; returns `true` once
620/// the outermost bracket has just closed (`depth` back to zero).
621#[cfg(feature = "extract")]
622fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
623    if byte == b'"' {
624        *in_string = true;
625    } else if byte == open {
626        *depth += 1;
627    } else if byte == close {
628        *depth = depth.saturating_sub(1);
629        return *depth == 0;
630    }
631    false
632}
633
634/// Advance the in-string escape state for one byte; returns whether the scanner
635/// is still inside the string literal afterwards.
636#[cfg(feature = "extract")]
637fn step_string(escaped: &mut bool, byte: u8) -> bool {
638    match (*escaped, byte) {
639        (true, _) => {
640            *escaped = false;
641            true
642        }
643        (false, b'\\') => {
644            *escaped = true;
645            true
646        }
647        (false, b'"') => false,
648        (false, _) => true,
649    }
650}
651
652#[cfg(all(test, feature = "extract"))]
653mod tests {
654    use super::*;
655
656    /// Regression: the graph reply is an OBJECT whose first `[` belongs to the
657    /// nested `facts` field. Slicing with the array preference grabbed that
658    /// inner list and failed to read it as the whole extraction — a real model
659    /// reply was rejected wholesale while every stub-backed test stayed green.
660    #[test]
661    fn parses_a_graph_reply_whose_first_bracket_is_nested() {
662        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 } ] }"#;
663        let raw: RawExtraction = json_slice_object(reply).expect("the object is sliced whole");
664        let extraction = raw.into_extraction();
665        assert_eq!(extraction.facts.len(), 1);
666        assert_eq!(extraction.relations.len(), 1);
667        assert_eq!(extraction.relations[0].predicate, "pere de");
668        assert_eq!(extraction.attributes.len(), 1);
669        assert_eq!(extraction.attributes[0].value, serde_json::json!(15));
670    }
671
672    /// Prose (and a fenced block) around the object must not defeat slicing.
673    #[test]
674    fn parses_a_graph_reply_wrapped_in_prose_and_fences() {
675        let reply = "Here you go:\n```json\n{\"facts\": [], \"relations\": [{\"subject\": \"a\", \"predicate\": \"knows\", \"object\": \"b\"}], \"attributes\": []}\n```";
676        let raw: RawExtraction = json_slice_object(reply).expect("sliced past the fence");
677        assert_eq!(raw.into_extraction().relations.len(), 1);
678    }
679
680    /// The fact-only path must keep preferring an array: prose carrying a stray
681    /// `{` before the list is exactly what that preference exists to survive.
682    #[test]
683    fn fact_only_slicing_still_prefers_the_array() {
684        let reply = "Result {ok}: [{\"fact\": \"A ships B.\", \"entities\": [\"b\"]}]";
685        let raw: Vec<RawFact> = json_slice(reply).expect("array sliced despite the stray brace");
686        assert_eq!(raw.len(), 1);
687    }
688
689    #[test]
690    fn graph_prompt_demands_numeric_values_and_the_three_sections() {
691        let prompt = build_graph_prompt("Kaltar a 15 ans.");
692        assert!(prompt.contains("Kaltar a 15 ans."));
693        assert!(prompt.contains("\"relations\""));
694        assert!(prompt.contains("\"attributes\""));
695        assert!(prompt.contains("15, not \"15\""));
696    }
697
698    #[test]
699    fn prompt_carries_the_passage_and_json_contract() {
700        let prompt = build_prompt("Alice adopted a dog in 2021.");
701        assert!(prompt.contains("Alice adopted a dog in 2021."));
702        assert!(prompt.contains("\"fact\": string"));
703    }
704
705    /// The graph prompt has to bound the predicate explicitly. Asking for "a
706    /// short label" was not enough: on real content the model answered
707    /// "est utilise pour la surveillance de fuites de donnees" — a restated
708    /// sentence, which makes the edge unreadable in `entity()`.
709    #[test]
710    fn graph_prompt_bounds_the_predicate_and_demands_edges() {
711        let prompt = build_graph_prompt("Ahmia is an onion search engine.");
712        assert!(prompt.contains("Ahmia is an onion search engine."));
713        assert!(
714            prompt.contains("at most 3 words"),
715            "the predicate length must be a hard bound, not a suggestion"
716        );
717        assert!(
718            prompt.contains("NEVER restate the sentence"),
719            "the counter-example is what stops a restated sentence"
720        );
721        assert!(
722            prompt.contains("at least one triple"),
723            "an entity with attributes but no edge is a dead end — the prompt \
724             must ask for the edge"
725        );
726    }
727
728    #[test]
729    fn parses_facts_from_a_fenced_reply() {
730        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
731        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
732        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
733        assert_eq!(facts.len(), 1);
734        assert_eq!(facts[0].text, "Alice adopted a dog.");
735        // Trimmed, lowercased, deduplicated, blanks dropped.
736        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
737    }
738
739    #[test]
740    fn drops_a_textless_fact() {
741        let raw = RawFact {
742            fact: "   ".to_string(),
743            entities: vec!["x".to_string()],
744        };
745        assert!(raw.into_fact().is_none());
746    }
747
748    #[test]
749    fn parses_response_envelope() {
750        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
751        assert_eq!(text, "[]");
752    }
753
754    #[test]
755    fn rejects_response_without_field() {
756        assert!(matches!(
757            parse_generate_response(r#"{"oops":true}"#),
758            Err(ExtractError::Backend(_))
759        ));
760    }
761}