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    let mut out = String::new();
215    let mut first = true;
216    for word in text.split_whitespace() {
217        if out.len() >= 120 {
218            break;
219        }
220        if !first {
221            out.push(' ');
222        }
223        out.push_str(word);
224        first = false;
225    }
226    out.truncate(120);
227    out
228}
229
230/// Parse `text` into `T`, first slicing out the outermost JSON array/object.
231/// Local models usually honour "return only JSON" but occasionally wrap it in
232/// fences or a sentence; slicing the first balanced span tolerates that.
233#[cfg(feature = "extract")]
234fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
235    let slice = balanced_slice(text)?;
236    serde_json::from_str::<T>(slice).ok()
237}
238
239/// Return the substring spanning the first balanced `[..]` or `{..}`, honouring
240/// string literals and escapes so brackets inside quotes don't miscount.
241#[cfg(feature = "extract")]
242fn balanced_slice(text: &str) -> Option<&str> {
243    let bytes = text.as_bytes();
244    // Prefer an array: the expected reply is a JSON list, and prose before it
245    // ("Result {ok}: [...]") may carry a stray `{` that would mis-slice the
246    // object span instead of the array. Fall back to the first `{` if no `[`.
247    let start = bytes
248        .iter()
249        .position(|&b| b == b'[')
250        .or_else(|| bytes.iter().position(|&b| b == b'{'))?;
251    let open = bytes[start];
252    let close = if open == b'[' { b']' } else { b'}' };
253    let mut depth = 0u32;
254    let mut in_string = false;
255    let mut escaped = false;
256    for (offset, &byte) in bytes[start..].iter().enumerate() {
257        if in_string {
258            in_string = step_string(&mut escaped, byte);
259        } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
260            return Some(&text[start..=start + offset]);
261        }
262    }
263    None
264}
265
266/// Advance the structural scan for one out-of-string byte; returns `true` once
267/// the outermost bracket has just closed (`depth` back to zero).
268#[cfg(feature = "extract")]
269fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
270    if byte == b'"' {
271        *in_string = true;
272    } else if byte == open {
273        *depth += 1;
274    } else if byte == close {
275        *depth = depth.saturating_sub(1);
276        return *depth == 0;
277    }
278    false
279}
280
281/// Advance the in-string escape state for one byte; returns whether the scanner
282/// is still inside the string literal afterwards.
283#[cfg(feature = "extract")]
284fn step_string(escaped: &mut bool, byte: u8) -> bool {
285    match (*escaped, byte) {
286        (true, _) => {
287            *escaped = false;
288            true
289        }
290        (false, b'\\') => {
291            *escaped = true;
292            true
293        }
294        (false, b'"') => false,
295        (false, _) => true,
296    }
297}
298
299#[cfg(all(test, feature = "extract"))]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn prompt_carries_the_passage_and_json_contract() {
305        let prompt = build_prompt("Alice adopted a dog in 2021.");
306        assert!(prompt.contains("Alice adopted a dog in 2021."));
307        assert!(prompt.contains("\"fact\": string"));
308    }
309
310    #[test]
311    fn parses_facts_from_a_fenced_reply() {
312        let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
313        let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
314        let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
315        assert_eq!(facts.len(), 1);
316        assert_eq!(facts[0].text, "Alice adopted a dog.");
317        // Trimmed, lowercased, deduplicated, blanks dropped.
318        assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
319    }
320
321    #[test]
322    fn drops_a_textless_fact() {
323        let raw = RawFact {
324            fact: "   ".to_string(),
325            entities: vec!["x".to_string()],
326        };
327        assert!(raw.into_fact().is_none());
328    }
329
330    #[test]
331    fn parses_response_envelope() {
332        let text = parse_generate_response(r#"{"response":"  [] "}"#).expect("parse");
333        assert_eq!(text, "[]");
334    }
335
336    #[test]
337    fn rejects_response_without_field() {
338        assert!(matches!(
339            parse_generate_response(r#"{"oops":true}"#),
340            Err(ExtractError::Backend(_))
341        ));
342    }
343}