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 seen = std::collections::HashSet::new();
170 let entities = self
171 .entities
172 .into_iter()
173 .map(|entity| entity.trim().to_lowercase())
174 .filter(|entity| !entity.is_empty() && seen.insert(entity.clone()))
175 .collect();
176 Some(ExtractedFact { text, entities })
177 }
178}
179
180#[cfg(feature = "extract")]
182fn build_prompt(text: &str) -> String {
183 format!(
184 "You are building a memory graph from the passage below.\n\n\
185Passage:\n{text}\n\n\
186Extract the atomic, standalone facts a person would remember. Rewrite each as a \
187self-contained sentence (resolve pronouns to names; keep absolute dates). For \
188each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
189activities, events, interests, plans, places, organisations, or named people a \
190later question might reference. Use short, canonical, lowercase noun phrases \
191(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
192recurs as the SAME tag across passages.\n\n\
193Return ONLY a JSON array, no prose, each item exactly:\n\
194{{\"fact\": string, \"entities\": [string]}}"
195 )
196}
197
198#[cfg(feature = "extract")]
200fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
201 let value: serde_json::Value = serde_json::from_str(body)
202 .map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
203 let text = value
204 .get("response")
205 .and_then(serde_json::Value::as_str)
206 .ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
207 Ok(text.trim().to_string())
208}
209
210#[cfg(feature = "extract")]
212fn truncate(text: &str) -> String {
213 let oneline = text.split_whitespace().collect::<Vec<_>>().join(" ");
214 oneline.chars().take(120).collect()
215}
216
217#[cfg(feature = "extract")]
221fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
222 let slice = balanced_slice(text)?;
223 serde_json::from_str::<T>(slice).ok()
224}
225
226#[cfg(feature = "extract")]
229fn balanced_slice(text: &str) -> Option<&str> {
230 let bytes = text.as_bytes();
231 let start = bytes
235 .iter()
236 .position(|&b| b == b'[')
237 .or_else(|| bytes.iter().position(|&b| b == b'{'))?;
238 let open = bytes[start];
239 let close = if open == b'[' { b']' } else { b'}' };
240 let mut depth = 0u32;
241 let mut in_string = false;
242 let mut escaped = false;
243 for (offset, &byte) in bytes[start..].iter().enumerate() {
244 if in_string {
245 in_string = step_string(&mut escaped, byte);
246 } else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
247 return Some(&text[start..=start + offset]);
248 }
249 }
250 None
251}
252
253#[cfg(feature = "extract")]
256fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
257 if byte == b'"' {
258 *in_string = true;
259 } else if byte == open {
260 *depth += 1;
261 } else if byte == close {
262 *depth = depth.saturating_sub(1);
263 return *depth == 0;
264 }
265 false
266}
267
268#[cfg(feature = "extract")]
271fn step_string(escaped: &mut bool, byte: u8) -> bool {
272 match (*escaped, byte) {
273 (true, _) => {
274 *escaped = false;
275 true
276 }
277 (false, b'\\') => {
278 *escaped = true;
279 true
280 }
281 (false, b'"') => false,
282 (false, _) => true,
283 }
284}
285
286#[cfg(all(test, feature = "extract"))]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn prompt_carries_the_passage_and_json_contract() {
292 let prompt = build_prompt("Alice adopted a dog in 2021.");
293 assert!(prompt.contains("Alice adopted a dog in 2021."));
294 assert!(prompt.contains("\"fact\": string"));
295 }
296
297 #[test]
298 fn parses_facts_from_a_fenced_reply() {
299 let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
300 let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
301 let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
302 assert_eq!(facts.len(), 1);
303 assert_eq!(facts[0].text, "Alice adopted a dog.");
304 assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
306 }
307
308 #[test]
309 fn drops_a_textless_fact() {
310 let raw = RawFact {
311 fact: " ".to_string(),
312 entities: vec!["x".to_string()],
313 };
314 assert!(raw.into_fact().is_none());
315 }
316
317 #[test]
318 fn parses_response_envelope() {
319 let text = parse_generate_response(r#"{"response":" [] "}"#).expect("parse");
320 assert_eq!(text, "[]");
321 }
322
323 #[test]
324 fn rejects_response_without_field() {
325 assert!(matches!(
326 parse_generate_response(r#"{"oops":true}"#),
327 Err(ExtractError::Backend(_))
328 ));
329 }
330}