velesdb_memory/
extract.rs1#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExtractedFact {
21 pub text: String,
23 pub entities: Vec<String>,
26}
27
28#[derive(Debug, thiserror::Error)]
31pub enum ExtractError {
32 #[error("extraction backend error: {0}")]
34 Backend(String),
35 #[error("could not parse facts from extractor output: {0}")]
37 Parse(String),
38}
39
40pub trait Extractor {
46 fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
52}
53
54impl<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
63pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
67
68#[cfg(feature = "extract")]
77pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
78
79#[cfg(feature = "extract")]
82const REQUEST_TIMEOUT_SECS: u64 = 300;
83
84#[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 #[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 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#[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 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#[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#[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#[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 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#[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#[cfg(feature = "extract")]
243fn balanced_slice(text: &str) -> Option<&str> {
244 let bytes = text.as_bytes();
245 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#[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#[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 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}