1#[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")]
88const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
89
90#[cfg(feature = "extract")]
93const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
94
95#[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#[cfg(feature = "extract")]
114enum GenerateCall {
115 Transport(Box<ureq::Error>),
118 Body(std::io::Error),
120}
121
122#[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#[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#[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 #[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 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 "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#[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 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#[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#[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#[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 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#[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#[cfg(feature = "extract")]
341fn balanced_slice(text: &str) -> Option<&str> {
342 let bytes = text.as_bytes();
343 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#[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#[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 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}