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/// Failure produced by an [`Extractor`] backend (e.g. a network-backed model
29/// that cannot be reached, or output that cannot be parsed into facts).
30#[derive(Debug, thiserror::Error)]
31pub enum ExtractError {
32    /// The extraction backend (network, subprocess, …) returned an error.
33    #[error("extraction backend error: {0}")]
34    Backend(String),
35    /// The backend produced output that could not be parsed into facts.
36    #[error("could not parse facts from extractor output: {0}")]
37    Parse(String),
38}
39
40/// Turns a passage of raw text into atomic, graph-ready facts.
41///
42/// Implement this to plug in any model — a local LLM, a hosted API, or a
43/// deterministic rule set — and feed the result straight into
44/// [`crate::MemoryService::remember_extracted`].
45pub trait Extractor {
46    /// Extract the atomic facts a reader would remember from `text`.
47    ///
48    /// # Errors
49    /// Returns [`ExtractError`] if the backend fails or its output cannot be
50    /// parsed into facts.
51    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
52}
53
54/// Forward [`Extractor`] through an [`Arc`](std::sync::Arc), so a shared `Arc<dyn Extractor>`
55/// (e.g. one held by the MCP server) satisfies the `X: Extractor` bound on
56/// [`crate::MemoryService::remember_extracted`].
57impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
58    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
59        (**self).extract(text)
60    }
61}
62
63/// A shared, object-safe extractor. The MCP server and the language bindings
64/// hold one of these (an `Option`), so the extraction tool can be attached at
65/// runtime without the type being generic.
66pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
67
68// --- Optional batteries-included backend: a local Ollama generative model -----
69//
70// Enabled with `--features extract`. The default build omits this backend (and
71// its HTTP dependency) so the shipped binary stays tiny and fully offline. Like
72// the Ollama embedder, it calls a model the user already runs locally, so the
73// text never leaves the machine.
74
75/// Default Ollama base URL for the generative extraction endpoint.
76#[cfg(feature = "extract")]
77pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
78
79/// Per-request timeout. Generation is far slower and more stall-prone than an
80/// embedding call, so a wedged model fails the call instead of hanging forever.
81#[cfg(feature = "extract")]
82const REQUEST_TIMEOUT_SECS: u64 = 300;
83
84/// Ceiling on establishing the TCP connection to Ollama. Short on purpose: a
85/// local daemon accepts at once or is not running, and `ureq`'s 30 s default
86/// would be paid once per replay.
87#[cfg(feature = "extract")]
88const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
89
90/// Ceiling on writing the request (prompt upload). Unlike the read bound, this
91/// one is applied to the socket at connect time and is genuinely in force.
92#[cfg(feature = "extract")]
93const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
94
95/// The knobs that actually configure the extractor, named in its failures.
96///
97/// **Not** the embedder's variables. `main.rs`'s `build_ollama_extractor` reads
98/// `VELESDB_MEMORY_EXTRACTOR_URL`/`_MODEL`; telling a user to set
99/// `VELESDB_MEMORY_OLLAMA_URL` here would send them to edit a setting this code
100/// path never consults — an "actionable" message that is actively wrong. There
101/// is no offline fallback to offer either: extraction is opt-in, and running
102/// without it is simply not passing an extractor.
103#[cfg(feature = "extract")]
104const EXTRACT_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
105    crate::ollama_retry::OllamaLevers {
106        url_var: "VELESDB_MEMORY_EXTRACTOR_URL",
107        model_var: "VELESDB_MEMORY_EXTRACTOR_MODEL",
108        fallback: None,
109    };
110
111/// How one generation attempt failed — transport and body failures may be
112/// replayed, a complete response is the server's final word.
113#[cfg(feature = "extract")]
114enum GenerateCall {
115    /// The request never completed. Boxed: `ureq::Error::Status` carries a
116    /// whole `Response`.
117    Transport(Box<ureq::Error>),
118    /// Headers arrived but the body did not read back in full.
119    Body(std::io::Error),
120}
121
122/// Replay policy for one generation attempt.
123#[cfg(feature = "extract")]
124fn generate_is_retryable(err: &GenerateCall) -> bool {
125    match err {
126        GenerateCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
127        GenerateCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
128    }
129}
130
131/// Turn a failed generation into a message that names the endpoint, the model,
132/// how many attempts were spent, and the variables that change the outcome.
133#[cfg(feature = "extract")]
134fn describe_generate_failure(url: &str, model: &str, err: &GenerateCall, attempts: u32) -> String {
135    let cause = match err {
136        GenerateCall::Transport(inner) => inner.to_string(),
137        GenerateCall::Body(inner) => format!("reading the response failed: {inner}"),
138    };
139    crate::ollama_retry::actionable_failure(
140        "generate",
141        url,
142        model,
143        attempts,
144        &cause,
145        &EXTRACT_LEVERS,
146    )
147}
148
149/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
150/// model — and therefore the source text — on the user's own machine.
151///
152/// The caller picks the generative model (Ollama has no universal default for
153/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
154/// reproducible output.
155#[cfg(feature = "extract")]
156#[derive(Debug, Clone)]
157pub struct OllamaExtractor {
158    base_url: String,
159    model: String,
160    agent: ureq::Agent,
161}
162
163#[cfg(feature = "extract")]
164impl OllamaExtractor {
165    /// Build an extractor targeting `model` on the Ollama server at `base_url`
166    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
167    ///
168    /// The agent is bounded on four axes, not one. See
169    /// [`crate::embedder`]'s `embed_agent` for why `timeout_read` is
170    /// subordinate to the global `timeout` in `ureq` and must not be read as a
171    /// per-read guarantee; `timeout_connect` and `timeout_write` are the two
172    /// that actually bite. The connect bound matters most here: `ureq`'s own
173    /// default is 30 s, which for a `localhost` daemon is 15x too long — and
174    /// with replays, that idle wait would be paid three times over.
175    #[must_use]
176    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
177        let timeout = std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS);
178        let agent = ureq::AgentBuilder::new()
179            .timeout_connect(CONNECT_TIMEOUT)
180            .timeout_write(WRITE_TIMEOUT)
181            .timeout_read(timeout)
182            .timeout(timeout)
183            .build();
184        Self {
185            base_url: base_url.into(),
186            model: model.into(),
187            agent,
188        }
189    }
190}
191
192#[cfg(feature = "extract")]
193impl Extractor for OllamaExtractor {
194    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
195        let reply = self.generate(&build_prompt(text))?;
196        let raw = json_slice::<Vec<RawFact>>(&reply)
197            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
198        Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
199    }
200}
201
202#[cfg(feature = "extract")]
203impl OllamaExtractor {
204    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply,
205    /// replaying the call when the failure is transient.
206    ///
207    /// Same defect, same repair as the embedder: this extractor also holds one
208    /// `ureq::Agent`, so it also hands out pooled keep-alive connections that
209    /// Ollama may have closed, and `ureq` will not replay a POST with a body.
210    /// The whole attempt — POST and body read — is inside the closure so a
211    /// truncated response is replayed rather than surfacing as a parse error.
212    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
213        let url = format!("{}/api/generate", self.base_url);
214        let body = serde_json::json!({
215            "model": self.model,
216            "prompt": prompt,
217            "stream": false,
218            "think": false,
219            // Extraction models are large — the one this crate documents as an
220            // example is 21.9 GB — so an unload between calls is the dominant
221            // cost, not the generation. Shares the embedder's knob so one
222            // setting governs every Ollama call the daemon makes.
223            "keep_alive": crate::embedder::keep_alive(),
224            "options": { "temperature": 0 },
225        })
226        .to_string();
227        let attempt = || {
228            let response = self
229                .agent
230                .post(&url)
231                .set("Content-Type", "application/json")
232                .send_string(&body)
233                .map_err(|err| GenerateCall::Transport(Box::new(err)))?;
234            response.into_string().map_err(GenerateCall::Body)
235        };
236
237        let payload = crate::ollama_retry::with_retry(
238            &crate::ollama_retry::OLLAMA_RETRIES,
239            generate_is_retryable,
240            attempt,
241        )
242        .map_err(|(err, attempts)| {
243            ExtractError::Backend(describe_generate_failure(&url, &self.model, &err, attempts))
244        })?;
245        parse_generate_response(&payload)
246    }
247}
248
249/// The strict JSON contract the extraction prompt asks the model to honour.
250#[cfg(feature = "extract")]
251#[derive(serde::Deserialize)]
252struct RawFact {
253    fact: String,
254    #[serde(default)]
255    entities: Vec<String>,
256}
257
258#[cfg(feature = "extract")]
259impl RawFact {
260    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
261    /// blanks and duplicates so the same topic recurs as the same graph hub.
262    fn into_fact(self) -> Option<ExtractedFact> {
263        let text = self.fact.trim().to_string();
264        if text.is_empty() {
265            return None;
266        }
267        let mut entities: Vec<String> = self
268            .entities
269            .into_iter()
270            .map(|entity| entity.trim().to_lowercase())
271            .filter(|entity| !entity.is_empty())
272            .collect();
273        entities.sort_unstable();
274        entities.dedup();
275        Some(ExtractedFact { text, entities })
276    }
277}
278
279/// Build the extraction prompt: the passage plus a strict JSON contract.
280#[cfg(feature = "extract")]
281fn build_prompt(text: &str) -> String {
282    format!(
283        "You are building a memory graph from the passage below.\n\n\
284Passage:\n{text}\n\n\
285Extract the atomic, standalone facts a person would remember. Rewrite each as a \
286self-contained sentence (resolve pronouns to names; keep absolute dates). For \
287each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
288activities, events, interests, plans, places, organisations, or named people a \
289later question might reference. Use short, canonical, lowercase noun phrases \
290(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
291recurs as the SAME tag across passages.\n\n\
292Return ONLY a JSON array, no prose, each item exactly:\n\
293{{\"fact\": string, \"entities\": [string]}}"
294    )
295}
296
297/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
298#[cfg(feature = "extract")]
299fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
300    let value: serde_json::Value = serde_json::from_str(body)
301        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
302    let text = value
303        .get("response")
304        .and_then(serde_json::Value::as_str)
305        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
306    Ok(text.trim().to_string())
307}
308
309/// A short, single-line preview of model output for error messages.
310#[cfg(feature = "extract")]
311fn truncate(text: &str) -> String {
312    const LIMIT: usize = 120;
313    let mut out = String::new();
314    for word in text.split_whitespace() {
315        // Check the budget *before* pushing so we never need a post-hoc
316        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
317        let sep_len = usize::from(!out.is_empty());
318        if out.len() + sep_len + word.len() > LIMIT {
319            break;
320        }
321        if !out.is_empty() {
322            out.push(' ');
323        }
324        out.push_str(word);
325    }
326    out
327}
328
329/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
330/// Local models usually honour "return only JSON" but occasionally wrap it in
331/// fences or a sentence; slicing the first balanced span tolerates that.
332#[cfg(feature = "extract")]
333fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
334    let slice = balanced_slice(text)?;
335    serde_json::from_str::<T>(slice).ok()
336}
337
338/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
339/// string literals and escapes so brackets inside quotes don't miscount.
340#[cfg(feature = "extract")]
341fn balanced_slice(text: &str) -> Option<&str> {
342    let bytes = text.as_bytes();
343    // Prefer an array: the expected reply is a JSON list, and prose before it
344    // ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
345    // object span instead of the array. Fall back to the first `{` if no `[`.
346    let start = bytes
347        .iter()
348        .position(|&b| b == b'[')
349        .or_else(|| bytes.iter().position(|&b| b == b'{'))?;
350    let open = bytes[start];
351    let close = if open == b'[' { b']' } else { b'}' };
352    let mut depth = 0u32;
353    let mut in_string = false;
354    let mut escaped = false;
355    for (offset, &byte) in bytes[start..].iter().enumerate() {
356        if in_string {
357            in_string = step_string(&mut escaped, byte);
358        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
359            return Some(&text[start..=start + offset]);
360        }
361    }
362    None
363}
364
365/// Advance the structural scan for one out-of-string byte; returns `true` once
366/// the outermost bracket has just closed (`depth` back to zero).
367#[cfg(feature = "extract")]
368fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
369    if byte == b'"' {
370        *in_string = true;
371    } else if byte == open {
372        *depth += 1;
373    } else if byte == close {
374        *depth = depth.saturating_sub(1);
375        return *depth == 0;
376    }
377    false
378}
379
380/// Advance the in-string escape state for one byte; returns whether the scanner
381/// is still inside the string literal afterwards.
382#[cfg(feature = "extract")]
383fn step_string(escaped: &mut bool, byte: u8) -> bool {
384    match (*escaped, byte) {
385        (true, _) => {
386            *escaped = false;
387            true
388        }
389        (false, b'\\') => {
390            *escaped = true;
391            true
392        }
393        (false, b'"') => false,
394        (false, _) => true,
395    }
396}
397
398#[cfg(all(test, feature = "extract"))]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn prompt_carries_the_passage_and_json_contract() {
404        let prompt = build_prompt("Alice adopted a dog in 2021.");
405        assert!(prompt.contains("Alice adopted a dog in 2021."));
406        assert!(prompt.contains("\"fact\": string"));
407    }
408
409    #[test]
410    fn parses_facts_from_a_fenced_reply() {
411        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
412        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
413        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
414        assert_eq!(facts.len(), 1);
415        assert_eq!(facts[0].text, "Alice adopted a dog.");
416        // Trimmed, lowercased, deduplicated, blanks dropped.
417        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
418    }
419
420    #[test]
421    fn drops_a_textless_fact() {
422        let raw = RawFact {
423            fact: "   ".to_string(),
424            entities: vec!["x".to_string()],
425        };
426        assert!(raw.into_fact().is_none());
427    }
428
429    #[test]
430    fn parses_response_envelope() {
431        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
432        assert_eq!(text, "[]");
433    }
434
435    #[test]
436    fn rejects_response_without_field() {
437        assert!(matches!(
438            parse_generate_response(r#"{"oops":true}"#),
439            Err(ExtractError::Backend(_))
440        ));
441    }
442}