feat: LLM entity + fact extraction pipeline (Zep paper alignment) #48
@@ -3,7 +3,7 @@ use mem_store::{MemoryL1, VectorStore, ChunkL0, EntityRepoOps, EdgeRepoOps};
|
|||||||
use mem_llm::EmbeddingsClient;
|
use mem_llm::EmbeddingsClient;
|
||||||
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
||||||
use mem_ingest::entity_extractor::{WikiLinkFallbackExtractor, LlmEntityExtractor};
|
use mem_ingest::entity_extractor::{WikiLinkFallbackExtractor, LlmEntityExtractor};
|
||||||
use mem_ingest::fact_extractor::SimpleFactExtractor;
|
use mem_ingest::fact_extractor::{SimpleFactExtractor, LlmFactExtractor};
|
||||||
use mem_ingest::contradiction_detector::ContradictionHandler;
|
use mem_ingest::contradiction_detector::ContradictionHandler;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -37,7 +37,14 @@ impl IngestWorker {
|
|||||||
Arc::new(WikiLinkFallbackExtractor)
|
Arc::new(WikiLinkFallbackExtractor)
|
||||||
};
|
};
|
||||||
let fact_extractor: Arc<dyn mem_ingest::fact_extractor::FactExtractor> =
|
let fact_extractor: Arc<dyn mem_ingest::fact_extractor::FactExtractor> =
|
||||||
Arc::new(SimpleFactExtractor);
|
if std::env::var("LLM_ENDPOINT").is_ok() {
|
||||||
|
let model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "qwen2.5:3b-instruct".to_string());
|
||||||
|
tracing::info!("Using LLM fact extractor: model={}", model);
|
||||||
|
Arc::new(LlmFactExtractor::new(&model))
|
||||||
|
} else {
|
||||||
|
tracing::info!("LLM_ENDPOINT not set, using simple pattern fact extractor");
|
||||||
|
Arc::new(SimpleFactExtractor)
|
||||||
|
};
|
||||||
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
||||||
let pipeline = Arc::new(IngestPipeline::new(
|
let pipeline = Arc::new(IngestPipeline::new(
|
||||||
entity_extractor,
|
entity_extractor,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use time::OffsetDateTime;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Entity type classification (extensible enum).
|
/// Entity type classification (extensible enum).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Hash)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EntityType {
|
pub enum EntityType {
|
||||||
Person,
|
Person,
|
||||||
@@ -59,6 +59,16 @@ impl EntityType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for EntityType {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::from_str(&s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for EntityType {
|
impl fmt::Display for EntityType {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.as_str())
|
write!(f, "{}", self.as_str())
|
||||||
|
|||||||
@@ -66,22 +66,32 @@ impl LlmEntityExtractor {
|
|||||||
|
|
||||||
/// Parse extraction response JSON
|
/// Parse extraction response JSON
|
||||||
/// Format: { "entities": [{ "name": "...", "type": "...", "summary": "..." }, ...] }
|
/// Format: { "entities": [{ "name": "...", "type": "...", "summary": "..." }, ...] }
|
||||||
/// Strip <think>...</think> tags from reasoning model output and extract JSON
|
/// Clean LLM response: strip thinking tags, markdown fences, extract JSON
|
||||||
fn strip_thinking_tags(text: &str) -> String {
|
fn clean_llm_response(text: &str) -> String {
|
||||||
let mut result = text.to_string();
|
let mut result = text.to_string();
|
||||||
// Remove <think>...</think> blocks
|
// Remove <think>...</think> blocks
|
||||||
if let Some(start) = result.find("<think>") {
|
while let Some(start) = result.find("<think>") {
|
||||||
if let Some(end) = result.find("</think>") {
|
if let Some(end) = result.find("</think>") {
|
||||||
result = format!("{}{}", &result[..start], &result[end + 8..]);
|
result = format!("{}{}", &result[..start], &result[end + 8..]);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try to find JSON object in remaining text
|
// Remove markdown code fences
|
||||||
|
result = result.replace("```json", "").replace("```", "");
|
||||||
|
// Find JSON object
|
||||||
let trimmed = result.trim();
|
let trimmed = result.trim();
|
||||||
if let Some(start) = trimmed.find('{') {
|
if let Some(start) = trimmed.find('{') {
|
||||||
if let Some(end) = trimmed.rfind('}') {
|
if let Some(end) = trimmed.rfind('}') {
|
||||||
return trimmed[start..=end].to_string();
|
return trimmed[start..=end].to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Maybe it's a JSON array — wrap in object
|
||||||
|
if let Some(start) = trimmed.find('[') {
|
||||||
|
if let Some(end) = trimmed.rfind(']') {
|
||||||
|
return format!("{{\"entities\": {}}}", &trimmed[start..=end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
trimmed.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +156,7 @@ impl LlmEntityExtractor {
|
|||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 500
|
"max_tokens": 1500
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -154,7 +164,7 @@ impl LlmEntityExtractor {
|
|||||||
.header("Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(90))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -175,7 +185,7 @@ impl LlmEntityExtractor {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Strip <think>...</think> tags from reasoning models
|
// Strip <think>...</think> tags from reasoning models
|
||||||
let content = Self::strip_thinking_tags(&raw_content);
|
let content = Self::clean_llm_response(&raw_content);
|
||||||
|
|
||||||
tracing::debug!("LLM raw response length={}, cleaned length={}", raw_content.len(), content.len());
|
tracing::debug!("LLM raw response length={}, cleaned length={}", raw_content.len(), content.len());
|
||||||
tracing::debug!("LLM cleaned content: {}", content);
|
tracing::debug!("LLM cleaned content: {}", content);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! Fact extraction: Identify relationships between entities
|
//! Fact extraction: Identify relationships between entities
|
||||||
//!
|
//!
|
||||||
//! Two implementations:
|
//! Three implementations:
|
||||||
//! 1. SimpleFactExtractor: Pattern-based (verbs + wiki links)
|
//! 1. SimpleFactExtractor: Pattern-based (verbs + wiki links)
|
||||||
//! 2. LlmFactExtractor: LLM-based (placeholder for production)
|
//! 2. LlmFactExtractor: LLM-based extraction with entity context
|
||||||
|
//! 3. Fallback chain: LLM → Simple pattern matching
|
||||||
//!
|
//!
|
||||||
//! CRAP: 12 (Simple pattern matching + LLM placeholder)
|
//! Aligned with Zep paper §2.2.2: Facts as edges between entity pairs,
|
||||||
//! SOLID: Trait-based (Open/Closed)
|
//! with temporal extraction and dedup against existing edges.
|
||||||
//! DRY: Reuses EntityExtractor pattern
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -27,20 +27,18 @@ pub struct ExtractedFact {
|
|||||||
pub trait FactExtractor: Send + Sync {
|
pub trait FactExtractor: Send + Sync {
|
||||||
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>>;
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>>;
|
||||||
|
|
||||||
/// Extract facts with GRM context (optional, defaults to extract())
|
/// Extract facts with entity context (Zep §2.2.2: facts between known entities)
|
||||||
async fn extract_with_context(
|
async fn extract_with_context(
|
||||||
&self,
|
&self,
|
||||||
text: &str,
|
text: &str,
|
||||||
_entity_contexts: &[crate::grm_retriever::EntityContext],
|
_entity_contexts: &[crate::grm_retriever::EntityContext],
|
||||||
) -> Result<Vec<ExtractedFact>> {
|
) -> Result<Vec<ExtractedFact>> {
|
||||||
// Default: ignore context, use plain extraction
|
|
||||||
self.extract(text).await
|
self.extract(text).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple fact extractor based on verb patterns
|
/// Simple fact extractor based on verb patterns
|
||||||
/// Pattern: [[Entity1]] verb [[Entity2]]
|
/// Pattern: [[Entity1]] verb [[Entity2]]
|
||||||
/// Common verbs: uses, manages, runs, deployed_to, works_with
|
|
||||||
pub struct SimpleFactExtractor;
|
pub struct SimpleFactExtractor;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -48,17 +46,15 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
||||||
let mut facts = vec![];
|
let mut facts = vec![];
|
||||||
|
|
||||||
// Extract [[Entity]] patterns
|
|
||||||
let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?;
|
let entity_pattern = Regex::new(r"\[\[([^\]]+)\]\]")?;
|
||||||
let entities: Vec<String> = entity_pattern
|
let _entities: Vec<String> = entity_pattern
|
||||||
.captures_iter(text)
|
.captures_iter(text)
|
||||||
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Common relationship verbs
|
let verbs = ["uses", "manages", "runs", "deployed_to", "works_with",
|
||||||
let verbs = ["uses", "manages", "runs", "deployed_to", "works_with"];
|
"depends_on", "contains", "extends", "implements", "connects_to"];
|
||||||
|
|
||||||
// Simple heuristic: if two entities appear close together with a verb between them
|
|
||||||
for verb in &verbs {
|
for verb in &verbs {
|
||||||
let pattern = format!(
|
let pattern = format!(
|
||||||
r"\[\[([^\]]+)\]\].*?{}.*?\[\[([^\]]+)\]\]",
|
r"\[\[([^\]]+)\]\].*?{}.*?\[\[([^\]]+)\]\]",
|
||||||
@@ -71,12 +67,7 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
source_entity_id: src.as_str().to_string(),
|
source_entity_id: src.as_str().to_string(),
|
||||||
target_entity_id: tgt.as_str().to_string(),
|
target_entity_id: tgt.as_str().to_string(),
|
||||||
relation_type: verb.to_uppercase(),
|
relation_type: verb.to_uppercase(),
|
||||||
fact: format!(
|
fact: format!("{} {} {}", src.as_str(), verb, tgt.as_str()),
|
||||||
"{} {} {}",
|
|
||||||
src.as_str(),
|
|
||||||
verb,
|
|
||||||
tgt.as_str()
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,18 +78,193 @@ impl FactExtractor for SimpleFactExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM-based fact extractor (placeholder for production)
|
/// LLM-based fact extractor (Zep §2.2.2 alignment)
|
||||||
/// TODO (Phase 2.6): Implement with real LLM API
|
/// Extracts relationships between entity pairs using LLM
|
||||||
/// TODO (Phase 2.6): Support complex relationships (3-way, temporal, conditional)
|
pub struct LlmFactExtractor {
|
||||||
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]
|
#[async_trait]
|
||||||
impl FactExtractor for LlmFactExtractor {
|
impl FactExtractor for LlmFactExtractor {
|
||||||
async fn extract(&self, _text: &str) -> Result<Vec<ExtractedFact>> {
|
async fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>> {
|
||||||
// TODO (Phase 2.6): Implement LLM-based extraction
|
self.extract_with_context(text, &[]).await
|
||||||
// Pattern: Send text to api.riotpiao.com with prompt
|
}
|
||||||
// Parse response for [source, relation, target] tuples
|
|
||||||
Ok(vec![])
|
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![])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +276,38 @@ mod tests {
|
|||||||
async fn test_simple_fact_extraction() {
|
async fn test_simple_fact_extraction() {
|
||||||
let extractor = SimpleFactExtractor;
|
let extractor = SimpleFactExtractor;
|
||||||
let text = "[[Rock]] uses [[Kubernetes]] and [[ArgoCD]]";
|
let text = "[[Rock]] uses [[Kubernetes]] and [[ArgoCD]]";
|
||||||
|
|
||||||
let facts = extractor.extract(text).await.unwrap();
|
let facts = extractor.extract(text).await.unwrap();
|
||||||
assert!(facts.len() > 0);
|
assert!(!facts.is_empty());
|
||||||
assert!(facts.iter().any(|f| f.relation_type == "USES"));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: poimen-memory-secrets
|
name: poimen-memory-secrets
|
||||||
key: llm-api-key
|
key: llm-api-key
|
||||||
|
# LLM config (in-cluster, no auth needed)
|
||||||
|
- name: LLM_ENDPOINT
|
||||||
|
value: "http://reasoning-predictor.llm-serving.svc.cluster.local/v1/chat/completions"
|
||||||
|
- name: LLM_API_BASE
|
||||||
|
value: "http://reasoning-predictor.llm-serving.svc.cluster.local/v1"
|
||||||
|
- name: LLM_MODEL
|
||||||
|
value: "reasoning"
|
||||||
# Server config (from ConfigMap)
|
# Server config (from ConfigMap)
|
||||||
- name: MEM_PORT
|
- name: MEM_PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
|
|||||||
Reference in New Issue
Block a user