//! Fact extraction: Identify relationships between entities //! //! Three implementations: //! 1. SimpleFactExtractor: Pattern-based (verbs + wiki links) //! 2. LlmFactExtractor: LLM-based extraction with entity context //! 3. Fallback chain: LLM → Simple pattern matching //! //! Aligned with Zep paper §2.2.2: Facts as edges between entity pairs, //! with temporal extraction and dedup against existing edges. use anyhow::Result; use async_trait::async_trait; use regex::Regex; use serde::{Deserialize, Serialize}; /// Extracted fact (relationship) from text #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtractedFact { pub source_entity_id: String, pub target_entity_id: String, pub relation_type: String, pub fact: String, } /// Fact extractor trait - pluggable implementations #[async_trait] pub trait FactExtractor: Send + Sync { async fn extract(&self, text: &str) -> Result>; /// Extract facts with entity context (Zep §2.2.2: facts between known entities) async fn extract_with_context( &self, text: &str, _entity_contexts: &[crate::grm_retriever::EntityContext], ) -> Result> { self.extract(text).await } } /// Simple fact extractor based on verb patterns /// Pattern: [[Entity1]] verb [[Entity2]] pub struct SimpleFactExtractor; #[async_trait] impl FactExtractor for SimpleFactExtractor { async fn extract(&self, text: &str) -> Result> { let mut facts = vec![]; let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?; let _entities: Vec = entity_pattern .captures_iter(text) .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) .collect(); let verbs = ["uses", "manages", "runs", "deployed_to", "works_with", "depends_on", "contains", "extends", "implements", "connects_to"]; for verb in &verbs { let pattern = format!( r"\[\[([^\]]+)\]\].*?{}.*?\[\[([^\]]+)\]\]", verb.to_lowercase() ); if let Ok(re) = Regex::new(&pattern) { for cap in re.captures_iter(text) { if let (Some(src), Some(tgt)) = (cap.get(1), cap.get(2)) { facts.push(ExtractedFact { source_entity_id: src.as_str().to_string(), target_entity_id: tgt.as_str().to_string(), relation_type: verb.to_uppercase(), fact: format!("{} {} {}", src.as_str(), verb, tgt.as_str()), }); } } } } Ok(facts) } } /// LLM-based fact extractor (Zep §2.2.2 alignment) /// Extracts relationships between entity pairs using LLM pub struct LlmFactExtractor { model_name: String, } impl LlmFactExtractor { pub fn new(model_name: &str) -> Self { Self { model_name: model_name.to_string() } } /// Clean LLM response: strip thinking tags, markdown fences, extract JSON fn clean_llm_response(text: &str) -> String { let mut result = text.to_string(); while let Some(start) = result.find("") { if let Some(end) = result.find("") { result = format!("{}{}", &result[..start], &result[end + 8..]); } else { break; } } result = result.replace("```json", "").replace("```", ""); let trimmed = result.trim(); if let Some(start) = trimmed.find('{') { if let Some(end) = trimmed.rfind('}') { return trimmed[start..=end].to_string(); } } if let Some(start) = trimmed.find('[') { if let Some(end) = trimmed.rfind(']') { return format!("{{\"facts\": {}}}", &trimmed[start..=end]); } } trimmed.to_string() } async fn call_llm(&self, prompt: &str) -> Result { let endpoint = std::env::var("LLM_ENDPOINT") .unwrap_or_else(|_| "http://localhost:8081/v1/chat/completions".to_string()); let api_key = std::env::var("LLM_API_KEY") .or_else(|_| std::env::var("MEM_API_KEY")) .unwrap_or_else(|_| "default-key".to_string()); let client = reqwest::Client::new(); let payload = serde_json::json!({ "model": self.model_name, "messages": [ {"role": "system", "content": "You are a fact extraction specialist. Extract relationships between entities from text. Output ONLY valid JSON."}, {"role": "user", "content": prompt} ], "max_tokens": 1500, "temperature": 0.1 }); let response = client .post(&endpoint) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .json(&payload) .timeout(std::time::Duration::from_secs(90)) .send() .await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); tracing::warn!("Fact extraction LLM error: {} - {}", status, body); return Err(anyhow::anyhow!("LLM API error: {}", status)); } let data: serde_json::Value = response.json().await?; let raw = data["choices"][0]["message"]["content"] .as_str() .unwrap_or("{}") .to_string(); let cleaned = Self::clean_llm_response(&raw); tracing::debug!("Fact LLM response: raw_len={}, cleaned_len={}", raw.len(), cleaned.len()); Ok(cleaned) } } #[async_trait] impl FactExtractor for LlmFactExtractor { async fn extract(&self, text: &str) -> Result> { self.extract_with_context(text, &[]).await } async fn extract_with_context( &self, text: &str, entity_contexts: &[crate::grm_retriever::EntityContext], ) -> Result> { // Build entity list for prompt let entity_names: Vec<&str> = entity_contexts .iter() .map(|e| e.entity_name.as_str()) .collect(); if entity_names.is_empty() { tracing::debug!("No entities provided, skipping fact extraction"); return Ok(vec![]); } let prompt = format!( r#"Extract relationships (facts) between these entities from the text. Entities: {:?} Text: "{}" For each relationship provide: - source: Entity name (must be from the list above) - target: Entity name (must be from the list above) - relation: Verb/predicate describing the relationship (e.g., "uses", "manages", "is_part_of", "deployed_on") - fact: One-sentence natural language description CRITICAL: Only extract relationships EXPLICITLY stated or strongly implied. Source and target must both be from the entity list. Respond in JSON: {{"facts": [{{"source": "...", "target": "...", "relation": "...", "fact": "..."}}, ...]}} "#, entity_names, text ); let llm_ok = std::env::var("LLM_ENDPOINT").is_ok(); let response = if llm_ok { match self.call_llm(&prompt).await { Ok(r) => r, Err(e) => { tracing::warn!("Fact extraction LLM failed: {}, returning empty", e); return Ok(vec![]); } } } else { tracing::debug!("LLM_ENDPOINT not set, skipping LLM fact extraction"); return Ok(vec![]); }; // Parse response #[derive(Deserialize)] struct FactResponse { facts: Vec, } #[derive(Deserialize)] struct RawFact { source: String, target: String, relation: String, fact: String, } match serde_json::from_str::(&response) { Ok(parsed) => { let facts: Vec = parsed.facts .into_iter() .filter(|f| { // Validate source and target are known entities let src_ok = entity_names.iter().any(|e| e.eq_ignore_ascii_case(&f.source)); let tgt_ok = entity_names.iter().any(|e| e.eq_ignore_ascii_case(&f.target)); if !src_ok || !tgt_ok { tracing::debug!( "Dropping fact with unknown entity: {} -> {}", f.source, f.target ); } src_ok && tgt_ok && f.source != f.target }) .map(|f| ExtractedFact { source_entity_id: f.source, target_entity_id: f.target, relation_type: f.relation.to_uppercase(), fact: f.fact, }) .collect(); tracing::info!( "LLM fact extraction: {} facts from {} entities", facts.len(), entity_names.len() ); Ok(facts) } Err(e) => { tracing::warn!("Fact extraction JSON parse failed: {}, response: {}", e, &response[..response.len().min(200)]); Ok(vec![]) } } } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_simple_fact_extraction() { let extractor = SimpleFactExtractor; let text = "[[Rock]] uses [[Kubernetes]] and [[ArgoCD]]"; let facts = extractor.extract(text).await.unwrap(); assert!(!facts.is_empty()); assert!(facts.iter().any(|f| f.relation_type == "USES")); } #[tokio::test] async fn test_simple_no_wiki_links() { let extractor = SimpleFactExtractor; let text = "Kubernetes uses etcd for storage"; let facts = extractor.extract(text).await.unwrap(); assert!(facts.is_empty()); // No [[wiki links]] } #[test] fn test_clean_llm_response() { let input = r#"reasoning here{"facts": [{"source": "A", "target": "B", "relation": "uses", "fact": "A uses B"}]}"#; let cleaned = LlmFactExtractor::clean_llm_response(input); assert!(cleaned.starts_with("{")); assert!(cleaned.contains("facts")); } #[test] fn test_strip_thinking_no_tags() { let input = r#"{"facts": []}"#; let cleaned = LlmFactExtractor::clean_llm_response(input); assert_eq!(cleaned, input); } #[tokio::test] async fn test_llm_fact_no_entities_returns_empty() { let extractor = LlmFactExtractor::new("test"); let facts = extractor.extract_with_context("some text", &[]).await.unwrap(); assert!(facts.is_empty()); } }