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 =
210 mentedb_core::text::truncate_on_char_boundary(json_str, 200),
211 "failed to parse LLM extraction response as JSON"
212 );
213 ExtractionError::ParseError(format!("Failed to parse extraction JSON: {e}"))
214 })?;
215
216 serde_json::from_value::<ExtractionResult>(value).map_err(|e| {
217 tracing::error!(
218 error = %e,
219 "failed to deserialize extraction JSON into ExtractionResult"
220 );
221 ExtractionError::ParseError(format!("Failed to parse extraction JSON: {e}"))
222 })
223 }
224
225 pub fn filter_quality(&self, memories: &[ExtractedMemory]) -> Vec<ExtractedMemory> {
227 memories
228 .iter()
229 .filter(|m| m.confidence >= self.config.quality_threshold)
230 .cloned()
231 .collect()
232 }
233
234 pub fn check_contradictions(
237 &self,
238 new_memory: &ExtractedMemory,
239 existing: &[MemoryNode],
240 embedding_provider: &dyn EmbeddingProvider,
241 ) -> Vec<CognitiveFinding> {
242 if !self.config.enable_contradiction_check || existing.is_empty() {
243 return Vec::new();
244 }
245
246 let embedding = match embedding_provider.embed(&new_memory.content) {
247 Ok(e) => e,
248 Err(err) => {
249 tracing::warn!(error = %err, "failed to embed memory for contradiction check");
250 return Vec::new();
251 }
252 };
253
254 let memory_type = map_extraction_type_to_memory_type(&new_memory.memory_type);
255 let temp_node = MemoryNode::new(
256 AgentId::nil(),
257 memory_type,
258 new_memory.content.clone(),
259 embedding,
260 );
261
262 let engine = WriteInferenceEngine::new();
263 let actions = engine.infer_on_write(&temp_node, existing, &[]);
264
265 let mut findings = Vec::new();
266 for action in actions {
267 match action {
268 InferredAction::FlagContradiction {
269 existing: existing_id,
270 reason,
271 ..
272 } => {
273 findings.push(CognitiveFinding {
274 finding_type: CognitiveFindingType::Contradiction,
275 description: reason,
276 related_memory_id: Some(existing_id),
277 });
278 }
279 InferredAction::MarkObsolete {
280 memory,
281 superseded_by: _,
282 } => {
283 findings.push(CognitiveFinding {
284 finding_type: CognitiveFindingType::Obsolescence,
285 description: format!("Memory {memory} may be obsolete"),
286 related_memory_id: Some(memory),
287 });
288 }
289 InferredAction::UpdateConfidence {
290 memory,
291 new_confidence,
292 } => {
293 findings.push(CognitiveFinding {
294 finding_type: CognitiveFindingType::ConfidenceUpdate,
295 description: format!(
296 "Confidence for {memory} should be updated to {new_confidence:.2}"
297 ),
298 related_memory_id: Some(memory),
299 });
300 }
301 InferredAction::CreateEdge { target, .. } => {
302 findings.push(CognitiveFinding {
303 finding_type: CognitiveFindingType::Related,
304 description: format!("Related to existing memory {target}"),
305 related_memory_id: Some(target),
306 });
307 }
308 _ => {}
309 }
310 }
311
312 findings
313 }
314
315 pub fn check_duplicates(
318 &self,
319 new_memory: &ExtractedMemory,
320 existing: &[MemoryNode],
321 embedding_provider: &dyn EmbeddingProvider,
322 ) -> bool {
323 if !self.config.enable_deduplication || existing.is_empty() {
324 return false;
325 }
326
327 let new_embedding = match embedding_provider.embed(&new_memory.content) {
328 Ok(e) => e,
329 Err(err) => {
330 tracing::warn!(error = %err, "failed to embed memory for dedup check");
331 return false;
332 }
333 };
334
335 for mem in existing {
336 let sim = cosine_similarity(&new_embedding, &mem.embedding);
337 if sim >= self.config.deduplication_threshold {
338 tracing::debug!(
339 similarity = sim,
340 threshold = self.config.deduplication_threshold,
341 existing_id = %mem.id,
342 "duplicate detected"
343 );
344 return true;
345 }
346 }
347
348 false
349 }
350
351 pub async fn process(
354 &self,
355 conversation: &str,
356 existing_memories: &[MemoryNode],
357 embedding_provider: &dyn EmbeddingProvider,
358 ) -> Result<ProcessedExtractionResult, ExtractionError> {
359 let full_result = self.extract_full(conversation).await?;
360 let all_memories = full_result.memories;
361 let entities = full_result.entities;
362 let total_extracted = all_memories.len();
363
364 let quality_passed = self.filter_quality(&all_memories);
365 let rejected_low_quality: Vec<ExtractedMemory> = all_memories
366 .iter()
367 .filter(|m| m.confidence < self.config.quality_threshold)
368 .cloned()
369 .collect();
370
371 let mut to_store = Vec::new();
372 let mut rejected_duplicate = Vec::new();
373 let mut contradictions = Vec::new();
374
375 for memory in quality_passed {
376 if self.check_duplicates(&memory, existing_memories, embedding_provider) {
377 rejected_duplicate.push(memory);
378 continue;
379 }
380
381 let findings =
382 self.check_contradictions(&memory, existing_memories, embedding_provider);
383 let has_contradiction = findings
384 .iter()
385 .any(|f| f.finding_type == CognitiveFindingType::Contradiction);
386
387 if has_contradiction {
388 contradictions.push((memory, findings));
389 } else {
390 to_store.push(memory);
391 }
392 }
393
394 let stats = ExtractionStats {
395 total_extracted,
396 accepted: to_store.len(),
397 rejected_quality: rejected_low_quality.len(),
398 rejected_duplicate: rejected_duplicate.len(),
399 contradictions_found: contradictions.len(),
400 };
401
402 tracing::info!(
403 total = stats.total_extracted,
404 accepted = stats.accepted,
405 rejected_quality = stats.rejected_quality,
406 rejected_duplicate = stats.rejected_duplicate,
407 contradictions = stats.contradictions_found,
408 "extraction pipeline complete"
409 );
410
411 Ok(ProcessedExtractionResult {
412 to_store,
413 rejected_low_quality,
414 rejected_duplicate,
415 contradictions,
416 entities,
417 stats,
418 })
419 }
420}
421
422pub fn map_extraction_type_to_memory_type(
424 extraction_type: &str,
425) -> mentedb_core::memory::MemoryType {
426 use mentedb_core::memory::MemoryType;
427 match extraction_type.to_lowercase().as_str() {
428 "decision" | "preference" | "fact" | "entity" => MemoryType::Semantic,
429 "correction" => MemoryType::Correction,
430 "anti_pattern" => MemoryType::AntiPattern,
431 _ => MemoryType::Episodic,
432 }
433}
434
435fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
436 if a.len() != b.len() || a.is_empty() {
437 return 0.0;
438 }
439 let mut dot = 0.0f32;
440 let mut norm_a = 0.0f32;
441 let mut norm_b = 0.0f32;
442 for i in 0..a.len() {
443 dot += a[i] * b[i];
444 norm_a += a[i] * a[i];
445 norm_b += b[i] * b[i];
446 }
447 let denom = norm_a.sqrt() * norm_b.sqrt();
448 if denom == 0.0 { 0.0 } else { dot / denom }
449}