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 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#[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#[cfg(feature = "extract")]
242fn balanced_slice(text: &str) -> Option<&str> {
243 let bytes = text.as_bytes();
244 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#[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#[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 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}