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`], 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/// Extracts facts through a local Ollama `/api/generate` endpoint, keeping the
85/// model — and therefore the source text — on the user's own machine.
86///
87/// The caller picks the generative model (Ollama has no universal default for
88/// generation); `temperature` is pinned to `0` and `think` disabled for stable,
89/// reproducible output.
90#[cfg(feature = "extract")]
91#[derive(Debug, Clone)]
92pub struct OllamaExtractor {
93    base_url: String,
94    model: String,
95    agent: ureq::Agent,
96}
97
98#[cfg(feature = "extract")]
99impl OllamaExtractor {
100    /// Build an extractor targeting `model` on the Ollama server at `base_url`
101    /// (e.g. [`DEFAULT_OLLAMA_URL`]).
102    #[must_use]
103    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
104        let agent = ureq::AgentBuilder::new()
105            .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
106            .build();
107        Self {
108            base_url: base_url.into(),
109            model: model.into(),
110            agent,
111        }
112    }
113}
114
115#[cfg(feature = "extract")]
116impl Extractor for OllamaExtractor {
117    fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
118        let reply = self.generate(&build_prompt(text))?;
119        let raw = json_slice::<Vec<RawFact>>(&reply)
120            .ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
121        Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
122    }
123}
124
125#[cfg(feature = "extract")]
126impl OllamaExtractor {
127    /// POST one prompt to Ollama's `/api/generate` and return the trimmed reply.
128    fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
129        let url = format!("{}/api/generate", self.base_url);
130        let body = serde_json::json!({
131            "model": self.model,
132            "prompt": prompt,
133            "stream": false,
134            "think": false,
135            "options": { "temperature": 0 },
136        })
137        .to_string();
138        let response = self
139            .agent
140            .post(&url)
141            .set("Content-Type", "application/json")
142            .send_string(&body)
143            .map_err(|err| ExtractError::Backend(format!("ollama request failed: {err}")))?;
144        let payload = response.into_string().map_err(|err| {
145            ExtractError::Backend(format!("reading ollama response failed: {err}"))
146        })?;
147        parse_generate_response(&payload)
148    }
149}
150
151/// The strict JSON contract the extraction prompt asks the model to honour.
152#[cfg(feature = "extract")]
153#[derive(serde::Deserialize)]
154struct RawFact {
155    fact: String,
156    #[serde(default)]
157    entities: Vec<String>,
158}
159
160#[cfg(feature = "extract")]
161impl RawFact {
162    /// Keep a fact only if it has text; trim and lowercase its topics, dropping
163    /// blanks and duplicates so the same topic recurs as the same graph hub.
164    fn into_fact(self) -> Option<ExtractedFact> {
165        let text = self.fact.trim().to_string();
166        if text.is_empty() {
167            return None;
168        }
169        let mut entities: Vec<String> = self
170            .entities
171            .into_iter()
172            .map(|entity| entity.trim().to_lowercase())
173            .filter(|entity| !entity.is_empty())
174            .collect();
175        entities.sort_unstable();
176        entities.dedup();
177        Some(ExtractedFact { text, entities })
178    }
179}
180
181/// Build the extraction prompt: the passage plus a strict JSON contract.
182#[cfg(feature = "extract")]
183fn build_prompt(text: &str) -> String {
184    format!(
185        "You are building a memory graph from the passage below.\n\n\
186Passage:\n{text}\n\n\
187Extract the atomic, standalone facts a person would remember. Rewrite each as a \
188self-contained sentence (resolve pronouns to names; keep absolute dates). For \
189each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
190activities, events, interests, plans, places, organisations, or named people a \
191later question might reference. Use short, canonical, lowercase noun phrases \
192(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
193recurs as the SAME tag across passages.\n\n\
194Return ONLY a JSON array, no prose, each item exactly:\n\
195{{\"fact\": string, \"entities\": [string]}}"
196    )
197}
198
199/// Pull the `response` string out of Ollama's `/api/generate` JSON envelope.
200#[cfg(feature = "extract")]
201fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
202    let value: serde_json::Value = serde_json::from_str(body)
203        .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
204    let text = value
205        .get("response")
206        .and_then(serde_json::Value::as_str)
207        .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
208    Ok(text.trim().to_string())
209}
210
211/// A short, single-line preview of model output for error messages.
212#[cfg(feature = "extract")]
213fn truncate(text: &str) -> String {
214    const LIMIT: usize = 120;
215    let mut out = String::new();
216    for word in text.split_whitespace() {
217        // Check the budget *before* pushing so we never need a post-hoc
218        // `String::truncate`, which would panic if the limit fell mid-UTF-8 char.
219        let sep_len = usize::from(!out.is_empty());
220        if out.len() + sep_len + word.len() > LIMIT {
221            break;
222        }
223        if !out.is_empty() {
224            out.push(' ');
225        }
226        out.push_str(word);
227    }
228    out
229}
230
231/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
232/// Local models usually honour "return only JSON" but occasionally wrap it in
233/// fences or a sentence; slicing the first balanced span tolerates that.
234#[cfg(feature = "extract")]
235fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
236    let slice = balanced_slice(text)?;
237    serde_json::from_str::<T>(slice).ok()
238}
239
240/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
241/// string literals and escapes so brackets inside quotes don't miscount.
242#[cfg(feature = "extract")]
243fn balanced_slice(text: &str) -> Option<&str> {
244    let bytes = text.as_bytes();
245    // Prefer an array: the expected reply is a JSON list, and prose before it
246    // ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
247    // object span instead of the array. Fall back to the first `{` if no `[`.
248    let start = bytes
249        .iter()
250        .position(|&b| b == b'[')
251        .or_else(|| bytes.iter().position(|&b| b == b'{'))?;
252    let open = bytes[start];
253    let close = if open == b'[' { b']' } else { b'}' };
254    let mut depth = 0u32;
255    let mut in_string = false;
256    let mut escaped = false;
257    for (offset, &byte) in bytes[start..].iter().enumerate() {
258        if in_string {
259            in_string = step_string(&mut escaped, byte);
260        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
261            return Some(&text[start..=start + offset]);
262        }
263    }
264    None
265}
266
267/// Advance the structural scan for one out-of-string byte; returns `true` once
268/// the outermost bracket has just closed (`depth` back to zero).
269#[cfg(feature = "extract")]
270fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
271    if byte == b'"' {
272        *in_string = true;
273    } else if byte == open {
274        *depth += 1;
275    } else if byte == close {
276        *depth = depth.saturating_sub(1);
277        return *depth == 0;
278    }
279    false
280}
281
282/// Advance the in-string escape state for one byte; returns whether the scanner
283/// is still inside the string literal afterwards.
284#[cfg(feature = "extract")]
285fn step_string(escaped: &mut bool, byte: u8) -> bool {
286    match (*escaped, byte) {
287        (true, _) => {
288            *escaped = false;
289            true
290        }
291        (false, b'\\') => {
292            *escaped = true;
293            true
294        }
295        (false, b'"') => false,
296        (false, _) => true,
297    }
298}
299
300#[cfg(all(test, feature = "extract"))]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn prompt_carries_the_passage_and_json_contract() {
306        let prompt = build_prompt("Alice adopted a dog in 2021.");
307        assert!(prompt.contains("Alice adopted a dog in 2021."));
308        assert!(prompt.contains("\"fact\": string"));
309    }
310
311    #[test]
312    fn parses_facts_from_a_fenced_reply() {
313        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
314        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
315        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
316        assert_eq!(facts.len(), 1);
317        assert_eq!(facts[0].text, "Alice adopted a dog.");
318        // Trimmed, lowercased, deduplicated, blanks dropped.
319        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
320    }
321
322    #[test]
323    fn drops_a_textless_fact() {
324        let raw = RawFact {
325            fact: "   ".to_string(),
326            entities: vec!["x".to_string()],
327        };
328        assert!(raw.into_fact().is_none());
329    }
330
331    #[test]
332    fn parses_response_envelope() {
333        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
334        assert_eq!(text, "[]");
335    }
336
337    #[test]
338    fn rejects_response_without_field() {
339        assert!(matches!(
340            parse_generate_response(r#"{"oops":true}"#),
341            Err(ExtractError::Backend(_))
342        ));
343    }
344}