Files
poimen-memory/crates/mem-ingest/src/fact_extractor.rs
T
rock 721589d251
CI / CI (pull_request) Successful in 11m41s
feat: LLM-based fact extraction + robust entity parsing
Entity extraction fixes:
- clean_llm_response() strips <think> tags, markdown fences, extracts JSON
- Handle array responses (wrap in {"entities": [...]})
- EntityType custom Deserialize: unknown variants map to Unknown (not crash)
- Increase timeout to 90s for reasoning models
- Increase max_tokens to 1500 for reasoning model overhead

Fact extraction (new):
- LlmFactExtractor: LLM-based relationship extraction between entities
- Validates source/target against known entity list (no hallucinated edges)
- Same clean_llm_response() for reasoning model + Ollama compatibility
- Graceful fallback: returns empty on LLM error (no pipeline crash)
- IngestWorker uses LlmFactExtractor when LLM_ENDPOINT set

K8s deployment:
- Add LLM_ENDPOINT, LLM_API_BASE, LLM_MODEL env vars
- Points to in-cluster reasoning-predictor service

Tested E2E with local Ollama (qwen2.5:3b):
- 12 entities extracted (person, tool, concept, organization)
- 5 edges with meaningful relationships and facts
- 781 tests pass
2026-09-09 17:53:44 +09:00

314 lines
11 KiB
Rust

//! 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<Vec<ExtractedFact>>;
/// 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<Vec<ExtractedFact>> {
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<Vec<ExtractedFact>> {
let mut facts = vec![];
let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?;
let _entities: Vec<String> = 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("<think>") {
if let Some(end) = result.find("</think>") {
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<String> {
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<Vec<ExtractedFact>> {
self.extract_with_context(text, &[]).await
}
async fn extract_with_context(
&self,
text: &str,
entity_contexts: &[crate::grm_retriever::EntityContext],
) -> Result<Vec<ExtractedFact>> {
// 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<RawFact>,
}
#[derive(Deserialize)]
struct RawFact {
source: String,
target: String,
relation: String,
fact: String,
}
match serde_json::from_str::<FactResponse>(&response) {
Ok(parsed) => {
let facts: Vec<ExtractedFact> = 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#"<think>reasoning here</think>{"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());
}
}