#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedFact {
pub text: String,
pub entities: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExtractError {
#[error("extraction backend error: {0}")]
Backend(String),
#[error("could not parse facts from extractor output: {0}")]
Parse(String),
}
pub trait Extractor {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError>;
}
impl<T: Extractor + ?Sized> Extractor for std::sync::Arc<T> {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
(**self).extract(text)
}
}
pub type DynExtractor = std::sync::Arc<dyn Extractor + Send + Sync>;
#[cfg(feature = "extract")]
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
#[cfg(feature = "extract")]
const REQUEST_TIMEOUT_SECS: u64 = 300;
#[cfg(feature = "extract")]
#[derive(Debug, Clone)]
pub struct OllamaExtractor {
base_url: String,
model: String,
agent: ureq::Agent,
}
#[cfg(feature = "extract")]
impl OllamaExtractor {
#[must_use]
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build();
Self {
base_url: base_url.into(),
model: model.into(),
agent,
}
}
}
#[cfg(feature = "extract")]
impl Extractor for OllamaExtractor {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, ExtractError> {
let reply = self.generate(&build_prompt(text))?;
let raw = json_slice::<Vec<RawFact>>(&reply)
.ok_or_else(|| ExtractError::Parse(truncate(&reply)))?;
Ok(raw.into_iter().filter_map(RawFact::into_fact).collect())
}
}
#[cfg(feature = "extract")]
impl OllamaExtractor {
fn generate(&self, prompt: &str) -> Result<String, ExtractError> {
let url = format!("{}/api/generate", self.base_url);
let body = serde_json::json!({
"model": self.model,
"prompt": prompt,
"stream": false,
"think": false,
"options": { "temperature": 0 },
})
.to_string();
let response = self
.agent
.post(&url)
.set("Content-Type", "application/json")
.send_string(&body)
.map_err(|err| ExtractError::Backend(format!("ollama request failed: {err}")))?;
let payload = response.into_string().map_err(|err| {
ExtractError::Backend(format!("reading ollama response failed: {err}"))
})?;
parse_generate_response(&payload)
}
}
#[cfg(feature = "extract")]
#[derive(serde::Deserialize)]
struct RawFact {
fact: String,
#[serde(default)]
entities: Vec<String>,
}
#[cfg(feature = "extract")]
impl RawFact {
fn into_fact(self) -> Option<ExtractedFact> {
let text = self.fact.trim().to_string();
if text.is_empty() {
return None;
}
let mut entities: Vec<String> = self
.entities
.into_iter()
.map(|entity| entity.trim().to_lowercase())
.filter(|entity| !entity.is_empty())
.collect();
entities.sort_unstable();
entities.dedup();
Some(ExtractedFact { text, entities })
}
}
#[cfg(feature = "extract")]
fn build_prompt(text: &str) -> String {
format!(
"You are building a memory graph from the passage below.\n\n\
Passage:\n{text}\n\n\
Extract the atomic, standalone facts a person would remember. Rewrite each as a \
self-contained sentence (resolve pronouns to names; keep absolute dates). For \
each fact also list 1-4 key TOPICS it concerns: the recurring subjects, \
activities, events, interests, plans, places, organisations, or named people a \
later question might reference. Use short, canonical, lowercase noun phrases \
(e.g. \"adoption\", \"charity race\", \"therapy\", \"new job\") so the same topic \
recurs as the SAME tag across passages.\n\n\
Return ONLY a JSON array, no prose, each item exactly:\n\
{{\"fact\": string, \"entities\": [string]}}"
)
}
#[cfg(feature = "extract")]
fn parse_generate_response(body: &str) -> Result<String, ExtractError> {
let value: serde_json::Value = serde_json::from_str(body)
.map_err(|err| ExtractError::Backend(format!("invalid generate response: {err}")))?;
let text = value
.get("response")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ExtractError::Backend("ollama reply had no `response` field".to_string()))?;
Ok(text.trim().to_string())
}
#[cfg(feature = "extract")]
fn truncate(text: &str) -> String {
let mut out = String::new();
let mut first = true;
for word in text.split_whitespace() {
if out.len() >= 120 {
break;
}
if !first {
out.push(' ');
}
out.push_str(word);
first = false;
}
out.truncate(120);
out
}
#[cfg(feature = "extract")]
fn json_slice<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
let slice = balanced_slice(text)?;
serde_json::from_str::<T>(slice).ok()
}
#[cfg(feature = "extract")]
fn balanced_slice(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
let start = bytes
.iter()
.position(|&b| b == b'[')
.or_else(|| bytes.iter().position(|&b| b == b'{'))?;
let open = bytes[start];
let close = if open == b'[' { b']' } else { b'}' };
let mut depth = 0u32;
let mut in_string = false;
let mut escaped = false;
for (offset, &byte) in bytes[start..].iter().enumerate() {
if in_string {
in_string = step_string(&mut escaped, byte);
} else if scan_structural(byte, open, close, &mut in_string, &mut depth) {
return Some(&text[start..=start + offset]);
}
}
None
}
#[cfg(feature = "extract")]
fn scan_structural(byte: u8, open: u8, close: u8, in_string: &mut bool, depth: &mut u32) -> bool {
if byte == b'"' {
*in_string = true;
} else if byte == open {
*depth += 1;
} else if byte == close {
*depth = depth.saturating_sub(1);
return *depth == 0;
}
false
}
#[cfg(feature = "extract")]
fn step_string(escaped: &mut bool, byte: u8) -> bool {
match (*escaped, byte) {
(true, _) => {
*escaped = false;
true
}
(false, b'\\') => {
*escaped = true;
true
}
(false, b'"') => false,
(false, _) => true,
}
}
#[cfg(all(test, feature = "extract"))]
mod tests {
use super::*;
#[test]
fn prompt_carries_the_passage_and_json_contract() {
let prompt = build_prompt("Alice adopted a dog in 2021.");
assert!(prompt.contains("Alice adopted a dog in 2021."));
assert!(prompt.contains("\"fact\": string"));
}
#[test]
fn parses_facts_from_a_fenced_reply() {
let reply = "Sure!\n```json\n[{\"fact\":\"Alice adopted a dog.\",\"entities\":[\"Adoption\",\"adoption\",\"\"]}]\n```";
let facts: Vec<RawFact> = json_slice(reply).expect("slice json");
let facts: Vec<ExtractedFact> = facts.into_iter().filter_map(RawFact::into_fact).collect();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].text, "Alice adopted a dog.");
assert_eq!(facts[0].entities, vec!["adoption".to_string()]);
}
#[test]
fn drops_a_textless_fact() {
let raw = RawFact {
fact: " ".to_string(),
entities: vec!["x".to_string()],
};
assert!(raw.into_fact().is_none());
}
#[test]
fn parses_response_envelope() {
let text = parse_generate_response(r#"{"response":" [] "}"#).expect("parse");
assert_eq!(text, "[]");
}
#[test]
fn rejects_response_without_field() {
assert!(matches!(
parse_generate_response(r#"{"oops":true}"#),
Err(ExtractError::Backend(_))
));
}
}