Skip to main content

mentedb_extraction/
pipeline.rs

1use mentedb_cognitive::write_inference::{InferredAction, WriteInferenceEngine};
2use mentedb_core::MemoryNode;
3use mentedb_core::types::{AgentId, MemoryId};
4use mentedb_embedding::provider::EmbeddingProvider;
5
6use crate::config::ExtractionConfig;
7use crate::error::ExtractionError;
8use crate::provider::ExtractionProvider;
9use crate::schema::{ExtractedEntity, ExtractedMemory, ExtractionResult};
10
11/// Findings from cognitive checks (contradiction detection).
12#[derive(Debug, Clone)]
13pub struct CognitiveFinding {
14    /// What type of issue was found.
15    pub finding_type: CognitiveFindingType,
16    /// Human-readable description of the finding.
17    pub description: String,
18    /// ID of the existing memory involved, if any.
19    pub related_memory_id: Option<MemoryId>,
20}
21
22/// Types of cognitive findings.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum CognitiveFindingType {
25    Contradiction,
26    Obsolescence,
27    Related,
28    ConfidenceUpdate,
29}
30
31/// Statistics from a full extraction pipeline run.
32#[derive(Debug, Clone, Default)]
33pub struct ExtractionStats {
34    pub total_extracted: usize,
35    pub accepted: usize,
36    pub rejected_quality: usize,
37    pub rejected_duplicate: usize,
38    pub contradictions_found: usize,
39}
40
41/// The complete result of running the extraction pipeline.
42#[derive(Debug)]
43pub struct ProcessedExtractionResult {
44    /// Memories that passed all checks and should be stored.
45    pub to_store: Vec<ExtractedMemory>,
46    /// Memories rejected for low confidence scores.
47    pub rejected_low_quality: Vec<ExtractedMemory>,
48    /// Memories rejected as duplicates of existing memories.
49    pub rejected_duplicate: Vec<ExtractedMemory>,
50    /// Memories that contradict existing ones (stored anyway, with findings).
51    pub contradictions: Vec<(ExtractedMemory, Vec<CognitiveFinding>)>,
52    /// Entities extracted from the conversation.
53    pub entities: Vec<ExtractedEntity>,
54    /// Summary statistics.
55    pub stats: ExtractionStats,
56}
57
58/// The main extraction engine. Takes raw conversations, extracts structured
59/// memories via an LLM, then filters and validates them before storage.
60pub struct ExtractionPipeline<P: ExtractionProvider> {
61    provider: P,
62    config: ExtractionConfig,
63}
64
65impl<P: ExtractionProvider> ExtractionPipeline<P> {
66    pub fn new(provider: P, config: ExtractionConfig) -> Self {
67        Self { provider, config }
68    }
69
70    /// Call the LLM to extract memories from a conversation, then parse the
71    /// response and filter by quality threshold.
72    pub async fn extract_from_conversation(
73        &self,
74        conversation: &str,
75    ) -> Result<Vec<ExtractedMemory>, ExtractionError> {
76        let result = self.extract_full(conversation).await?;
77        Ok(result.memories)
78    }
79
80    /// Extract memories AND entities from a conversation.
81    /// Returns the full ExtractionResult including structured entities.
82    pub async fn extract_full(
83        &self,
84        conversation: &str,
85    ) -> Result<ExtractionResult, ExtractionError> {
86        use crate::prompts::{extraction_system_prompt, extraction_verification_prompt};
87
88        let system_prompt = extraction_system_prompt();
89        let raw_response = self.provider.extract(conversation, system_prompt).await?;
90
91        let mut result = self.parse_extraction_response(&raw_response)?;
92
93        // Verification pass: re-read conversation to find what the first pass missed
94        if self.config.extraction_passes >= 2 && !result.memories.is_empty() {
95            let first_pass_facts: String = result
96                .memories
97                .iter()
98                .map(|m| format!("- {}", m.content))
99                .collect::<Vec<_>>()
100                .join("\n");
101            let verify_prompt = extraction_verification_prompt(&first_pass_facts);
102            match self.provider.extract(conversation, &verify_prompt).await {
103                Ok(verify_response) => {
104                    if let Ok(verify_result) = self.parse_extraction_response(&verify_response) {
105                        let new_memories = verify_result.memories.len();
106                        let new_entities = verify_result.entities.len();
107                        result.memories.extend(verify_result.memories);
108                        result.entities.extend(verify_result.entities);
109                        if new_memories > 0 || new_entities > 0 {
110                            tracing::info!(
111                                new_memories,
112                                new_entities,
113                                "verification pass found additional extractions"
114                            );
115                        }
116                    }
117                }
118                Err(e) => {
119                    tracing::warn!("verification pass failed, using first pass only: {}", e);
120                }
121            }
122        }
123
124        if result.memories.len() > self.config.max_extractions_per_conversation {
125            tracing::warn!(
126                extracted = result.memories.len(),
127                max = self.config.max_extractions_per_conversation,
128                "truncating extractions to configured maximum"
129            );
130            result
131                .memories
132                .truncate(self.config.max_extractions_per_conversation);
133        }
134
135        Ok(result)
136    }
137
138    /// Parse the raw JSON response from the LLM into an ExtractionResult.
139    /// Handles edge cases like markdown fences around JSON and preamble text.
140    fn parse_extraction_response(&self, raw: &str) -> Result<ExtractionResult, ExtractionError> {
141        let trimmed = raw.trim();
142
143        // Empty response = no memories to extract
144        if trimmed.is_empty() {
145            return Ok(ExtractionResult {
146                memories: vec![],
147                entities: vec![],
148            });
149        }
150
151        // Strip markdown code fences if present
152        let stripped = if trimmed.starts_with("```") {
153            let without_prefix = trimmed
154                .trim_start_matches("```json")
155                .trim_start_matches("```");
156            without_prefix.trim_end_matches("```").trim()
157        } else {
158            trimmed
159        };
160
161        // Find the outermost JSON object using brace-depth matching
162        // that respects quoted strings (handles braces inside string values)
163        let json_str = if let Some(start) = stripped.find('{') {
164            let candidate = &stripped[start..];
165            let mut depth = 0i32;
166            let mut in_string = false;
167            let mut escape_next = false;
168            let mut end = candidate.len();
169            for (i, ch) in candidate.char_indices() {
170                if escape_next {
171                    escape_next = false;
172                    continue;
173                }
174                if in_string {
175                    match ch {
176                        '\\' => escape_next = true,
177                        '"' => in_string = false,
178                        _ => {}
179                    }
180                    continue;
181                }
182                match ch {
183                    '"' => in_string = true,
184                    '{' => depth += 1,
185                    '}' => {
186                        depth -= 1;
187                        if depth == 0 {
188                            end = i + 1;
189                            break;
190                        }
191                    }
192                    _ => {}
193                }
194            }
195            &candidate[..end]
196        } else {
197            // No JSON object found — LLM returned plain text (e.g. "No memories to extract")
198            return Ok(ExtractionResult {
199                memories: vec![],
200                entities: vec![],
201            });
202        };
203
204        // Parse with serde_json::Value first (tolerates duplicate keys — last one wins)
205        // then convert to ExtractionResult. LLMs sometimes emit duplicate fields.
206        let value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
207            tracing::error!(
208                error = %e,
209                response_preview = &json_str[..json_str.len().min(200)],
210                "failed to parse LLM extraction response as JSON"
211            );
212            ExtractionError::ParseError(format!("Failed to parse extraction JSON: {e}"))
213        })?;
214
215        serde_json::from_value::<ExtractionResult>(value).map_err(|e| {
216            tracing::error!(
217                error = %e,
218                "failed to deserialize extraction JSON into ExtractionResult"
219            );
220            ExtractionError::ParseError(format!("Failed to parse extraction JSON: {e}"))
221        })
222    }
223
224    /// Remove memories below the configured confidence threshold.
225    pub fn filter_quality(&self, memories: &[ExtractedMemory]) -> Vec<ExtractedMemory> {
226        memories
227            .iter()
228            .filter(|m| m.confidence >= self.config.quality_threshold)
229            .cloned()
230            .collect()
231    }
232
233    /// Check a new extracted memory against existing memories for contradictions
234    /// using the WriteInferenceEngine.
235    pub fn check_contradictions(
236        &self,
237        new_memory: &ExtractedMemory,
238        existing: &[MemoryNode],
239        embedding_provider: &dyn EmbeddingProvider,
240    ) -> Vec<CognitiveFinding> {
241        if !self.config.enable_contradiction_check || existing.is_empty() {
242            return Vec::new();
243        }
244
245        let embedding = match embedding_provider.embed(&new_memory.content) {
246            Ok(e) => e,
247            Err(err) => {
248                tracing::warn!(error = %err, "failed to embed memory for contradiction check");
249                return Vec::new();
250            }
251        };
252
253        let memory_type = map_extraction_type_to_memory_type(&new_memory.memory_type);
254        let temp_node = MemoryNode::new(
255            AgentId::nil(),
256            memory_type,
257            new_memory.content.clone(),
258            embedding,
259        );
260
261        let engine = WriteInferenceEngine::new();
262        let actions = engine.infer_on_write(&temp_node, existing, &[]);
263
264        let mut findings = Vec::new();
265        for action in actions {
266            match action {
267                InferredAction::FlagContradiction {
268                    existing: existing_id,
269                    reason,
270                    ..
271                } => {
272                    findings.push(CognitiveFinding {
273                        finding_type: CognitiveFindingType::Contradiction,
274                        description: reason,
275                        related_memory_id: Some(existing_id),
276                    });
277                }
278                InferredAction::MarkObsolete {
279                    memory,
280                    superseded_by: _,
281                } => {
282                    findings.push(CognitiveFinding {
283                        finding_type: CognitiveFindingType::Obsolescence,
284                        description: format!("Memory {memory} may be obsolete"),
285                        related_memory_id: Some(memory),
286                    });
287                }
288                InferredAction::UpdateConfidence {
289                    memory,
290                    new_confidence,
291                } => {
292                    findings.push(CognitiveFinding {
293                        finding_type: CognitiveFindingType::ConfidenceUpdate,
294                        description: format!(
295                            "Confidence for {memory} should be updated to {new_confidence:.2}"
296                        ),
297                        related_memory_id: Some(memory),
298                    });
299                }
300                InferredAction::CreateEdge { target, .. } => {
301                    findings.push(CognitiveFinding {
302                        finding_type: CognitiveFindingType::Related,
303                        description: format!("Related to existing memory {target}"),
304                        related_memory_id: Some(target),
305                    });
306                }
307                _ => {}
308            }
309        }
310
311        findings
312    }
313
314    /// Check if a new memory is too similar to any existing memory
315    /// (above deduplication_threshold).
316    pub fn check_duplicates(
317        &self,
318        new_memory: &ExtractedMemory,
319        existing: &[MemoryNode],
320        embedding_provider: &dyn EmbeddingProvider,
321    ) -> bool {
322        if !self.config.enable_deduplication || existing.is_empty() {
323            return false;
324        }
325
326        let new_embedding = match embedding_provider.embed(&new_memory.content) {
327            Ok(e) => e,
328            Err(err) => {
329                tracing::warn!(error = %err, "failed to embed memory for dedup check");
330                return false;
331            }
332        };
333
334        for mem in existing {
335            let sim = cosine_similarity(&new_embedding, &mem.embedding);
336            if sim >= self.config.deduplication_threshold {
337                tracing::debug!(
338                    similarity = sim,
339                    threshold = self.config.deduplication_threshold,
340                    existing_id = %mem.id,
341                    "duplicate detected"
342                );
343                return true;
344            }
345        }
346
347        false
348    }
349
350    /// Run the full extraction pipeline: extract -> filter quality ->
351    /// check duplicates -> check contradictions.
352    pub async fn process(
353        &self,
354        conversation: &str,
355        existing_memories: &[MemoryNode],
356        embedding_provider: &dyn EmbeddingProvider,
357    ) -> Result<ProcessedExtractionResult, ExtractionError> {
358        let full_result = self.extract_full(conversation).await?;
359        let all_memories = full_result.memories;
360        let entities = full_result.entities;
361        let total_extracted = all_memories.len();
362
363        let quality_passed = self.filter_quality(&all_memories);
364        let rejected_low_quality: Vec<ExtractedMemory> = all_memories
365            .iter()
366            .filter(|m| m.confidence < self.config.quality_threshold)
367            .cloned()
368            .collect();
369
370        let mut to_store = Vec::new();
371        let mut rejected_duplicate = Vec::new();
372        let mut contradictions = Vec::new();
373
374        for memory in quality_passed {
375            if self.check_duplicates(&memory, existing_memories, embedding_provider) {
376                rejected_duplicate.push(memory);
377                continue;
378            }
379
380            let findings =
381                self.check_contradictions(&memory, existing_memories, embedding_provider);
382            let has_contradiction = findings
383                .iter()
384                .any(|f| f.finding_type == CognitiveFindingType::Contradiction);
385
386            if has_contradiction {
387                contradictions.push((memory, findings));
388            } else {
389                to_store.push(memory);
390            }
391        }
392
393        let stats = ExtractionStats {
394            total_extracted,
395            accepted: to_store.len(),
396            rejected_quality: rejected_low_quality.len(),
397            rejected_duplicate: rejected_duplicate.len(),
398            contradictions_found: contradictions.len(),
399        };
400
401        tracing::info!(
402            total = stats.total_extracted,
403            accepted = stats.accepted,
404            rejected_quality = stats.rejected_quality,
405            rejected_duplicate = stats.rejected_duplicate,
406            contradictions = stats.contradictions_found,
407            "extraction pipeline complete"
408        );
409
410        Ok(ProcessedExtractionResult {
411            to_store,
412            rejected_low_quality,
413            rejected_duplicate,
414            contradictions,
415            entities,
416            stats,
417        })
418    }
419}
420
421/// Map extraction type strings to MemoryType enum variants.
422pub fn map_extraction_type_to_memory_type(
423    extraction_type: &str,
424) -> mentedb_core::memory::MemoryType {
425    use mentedb_core::memory::MemoryType;
426    match extraction_type.to_lowercase().as_str() {
427        "decision" | "preference" | "fact" | "entity" => MemoryType::Semantic,
428        "correction" => MemoryType::Correction,
429        "anti_pattern" => MemoryType::AntiPattern,
430        _ => MemoryType::Episodic,
431    }
432}
433
434fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
435    if a.len() != b.len() || a.is_empty() {
436        return 0.0;
437    }
438    let mut dot = 0.0f32;
439    let mut norm_a = 0.0f32;
440    let mut norm_b = 0.0f32;
441    for i in 0..a.len() {
442        dot += a[i] * b[i];
443        norm_a += a[i] * a[i];
444        norm_b += b[i] * b[i];
445    }
446    let denom = norm_a.sqrt() * norm_b.sqrt();
447    if denom == 0.0 { 0.0 } else { dot / denom }
448}