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#[derive(Debug, Clone)]
13pub struct CognitiveFinding {
14 pub finding_type: CognitiveFindingType,
16 pub description: String,
18 pub related_memory_id: Option<MemoryId>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum CognitiveFindingType {
25 Contradiction,
26 Obsolescence,
27 Related,
28 ConfidenceUpdate,
29}
30
31#[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#[derive(Debug)]
43pub struct ProcessedExtractionResult {
44 pub to_store: Vec<ExtractedMemory>,
46 pub rejected_low_quality: Vec<ExtractedMemory>,
48 pub rejected_duplicate: Vec<ExtractedMemory>,
50 pub contradictions: Vec<(ExtractedMemory, Vec<CognitiveFinding>)>,
52 pub entities: Vec<ExtractedEntity>,
54 pub stats: ExtractionStats,
56}
57
58pub 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 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 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 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 fn parse_extraction_response(&self, raw: &str) -> Result<ExtractionResult, ExtractionError> {
141 let trimmed = raw.trim();
142
143 if trimmed.is_empty() {
145 return Ok(ExtractionResult {
146 memories: vec![],
147 entities: vec![],
148 });
149 }
150
151 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 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 return Ok(ExtractionResult {
199 memories: vec![],
200 entities: vec![],
201 });
202 };
203
204 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 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 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 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 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
421pub 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}