use anyhow::Result;
use serde::{Deserialize, Serialize};
#[cfg(feature = "neural-embed")]
use serde_json::Value;
use super::{AtheneumGraph, EdgeType, ProvenanceData};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtractedTriple {
pub subject: String,
pub predicate: String,
pub object: String,
}
#[derive(Debug, Clone)]
pub struct ExtractionResult {
pub triples: Vec<ExtractedTriple>,
pub model: String,
pub source_preview: String,
}
#[derive(Debug, Clone)]
pub struct ExtractionConfig {
pub model: String,
pub url: String,
}
impl Default for ExtractionConfig {
fn default() -> Self {
Self {
model: "gemma4:e2b".to_string(),
url: "http://localhost:11434".to_string(),
}
}
}
#[cfg(feature = "neural-embed")]
const EXTRACTION_PROMPT: &str = r#"Extract knowledge triples from the following text. Return a JSON array of objects with keys "subject", "predicate", "object". Each triple should capture a factual relationship. Only include clear, explicit relationships. Return ONLY the JSON array, no other text.
Text:
"#;
#[cfg(feature = "neural-embed")]
pub fn extract_triples(text: &str, config: &ExtractionConfig) -> Result<ExtractionResult> {
let prompt = format!("{}{}", EXTRACTION_PROMPT, text);
let response: Value = ureq::post(&format!("{}/api/generate", config.url))
.send_json(ureq::json!({
"model": config.model,
"prompt": prompt,
"stream": false,
"format": "json",
"options": {
"temperature": 0.1,
"num_predict": 2048,
}
}))?
.into_json()?;
let raw_response = response
.get("response")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing response field from ollama"))?;
let triples: Vec<ExtractedTriple> = if raw_response.trim_start().starts_with('[') {
serde_json::from_str(raw_response)?
} else {
let wrapped: Value = serde_json::from_str(raw_response)?;
if wrapped.is_array() {
serde_json::from_value(wrapped)?
} else {
let arr = wrapped
.get("triples")
.or_else(|| wrapped.get("results"))
.or_else(|| wrapped.get("data"))
.cloned()
.unwrap_or(wrapped);
serde_json::from_value(arr)?
}
};
let source_preview = if text.len() > 80 {
format!("{}...", &text[..80])
} else {
text.to_string()
};
Ok(ExtractionResult {
triples,
model: config.model.clone(),
source_preview,
})
}
pub fn ingest_triples(
graph: &AtheneumGraph,
result: &ExtractionResult,
project_id: Option<&str>,
) -> Result<Vec<(i64, i64, i64)>> {
let mut inserted = Vec::with_capacity(result.triples.len());
for triple in &result.triples {
let subject_data = if let Some(pid) = project_id {
serde_json::json!({"project_id": pid})
} else {
serde_json::json!({})
};
let subject_id = graph.upsert_concept(&triple.subject, &subject_data)?;
let object_data = if let Some(pid) = project_id {
serde_json::json!({"project_id": pid})
} else {
serde_json::json!({})
};
let object_id = graph.upsert_concept(&triple.object, &object_data)?;
let provenance = ProvenanceData::new("ai_triple_extraction")
.with_actor(&format!("ollama:{}", result.model))
.with_extraction_mode("ai_triple")
.with_source_text(&result.source_preview)
.to_value();
let edge_data = serde_json::json!({
"predicate": triple.predicate,
"provenance": provenance,
});
let edge_id = graph.insert_edge(subject_id, object_id, EdgeType::RelatedTo, edge_data)?;
inserted.push((subject_id, edge_id, object_id));
}
Ok(inserted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracted_triple_serialization_roundtrip() {
let triple = ExtractedTriple {
subject: "Rust".to_string(),
predicate: "has_feature".to_string(),
object: "ownership".to_string(),
};
let json = serde_json::to_string(&triple).unwrap();
let back: ExtractedTriple = serde_json::from_str(&json).unwrap();
assert_eq!(triple, back);
}
#[test]
fn extracted_triple_parses_from_json_array() {
let json = r#"[{"subject":"Rust","predicate":"has","object":"ownership"},{"subject":"ownership","predicate":"prevents","object":"data races"}]"#;
let triples: Vec<ExtractedTriple> = serde_json::from_str(json).unwrap();
assert_eq!(triples.len(), 2);
assert_eq!(triples[0].subject, "Rust");
assert_eq!(triples[1].object, "data races");
}
#[test]
fn extraction_result_source_preview_truncates() {
let text = "A".repeat(200);
let result = ExtractionResult {
triples: vec![],
model: "test".to_string(),
source_preview: if text.len() > 80 {
format!("{}...", &text[..80])
} else {
text.clone()
},
};
assert!(result.source_preview.len() <= 83); assert!(result.source_preview.ends_with("..."));
}
#[test]
fn extraction_config_default_uses_gemma4() {
let config = ExtractionConfig::default();
assert_eq!(config.model, "gemma4:e2b");
assert_eq!(config.url, "http://localhost:11434");
}
#[test]
fn ingest_triples_creates_entities_and_edges() {
let graph = AtheneumGraph::open_in_memory().unwrap();
let result = ExtractionResult {
triples: vec![
ExtractedTriple {
subject: "Rust".to_string(),
predicate: "has_feature".to_string(),
object: "ownership".to_string(),
},
ExtractedTriple {
subject: "ownership".to_string(),
predicate: "prevents".to_string(),
object: "data races".to_string(),
},
],
model: "test-model".to_string(),
source_preview: "Test text...".to_string(),
};
let inserted = ingest_triples(&graph, &result, Some("test")).unwrap();
assert_eq!(inserted.len(), 2);
let (s1, e1, o1) = inserted[0];
assert!(s1 > 0);
assert!(e1 > 0);
assert!(o1 > 0);
let subject = graph.get_entity(s1).unwrap();
assert_eq!(subject.name, "Rust");
let object = graph.get_entity(o1).unwrap();
assert_eq!(object.name, "ownership");
let edge = graph.get_edge(e1).unwrap();
assert_eq!(edge.edge_type, "related_to");
assert_eq!(edge.data["predicate"], "has_feature");
let prov = edge.data.get("provenance").unwrap();
assert_eq!(prov["method"], "ai_triple_extraction");
assert_eq!(prov["extraction_mode"], "ai_triple");
}
}