1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use paladin_core::platform::container::garrison::GarrisonEntry;
17use paladin_core::platform::container::prompt::{PromptItem, PromptRole, PromptType, TextPrompt};
18use paladin_core::platform::container::sanctum::{MemoryBuilder, MemoryType, SanctumEntry};
19use paladin_ports::output::embedding_port::EmbeddingPort;
20use paladin_ports::output::llm_port::{LlmPort, LlmRequest};
21use paladin_ports::output::sanctum_port::{SanctumError, SanctumFilter, SanctumPort, SanctumQuery};
22use serde_json::Value;
23
24pub use crate::config::rag::MemoryExtractionStrategy;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ExtractedMemory {
31 pub content: String,
32 pub memory_type: MemoryType,
33 pub importance: f32,
34 pub metadata: HashMap<String, String>,
35}
36
37pub struct MemoryExtractionService {
42 llm: Arc<dyn LlmPort>,
43 embedding: Arc<dyn EmbeddingPort>,
44 sanctum: Arc<dyn SanctumPort>,
45}
46
47impl MemoryExtractionService {
48 pub fn new(
50 llm: Arc<dyn LlmPort>,
51 embedding: Arc<dyn EmbeddingPort>,
52 sanctum: Arc<dyn SanctumPort>,
53 ) -> Self {
54 Self {
55 llm,
56 embedding,
57 sanctum,
58 }
59 }
60
61 pub async fn extract_memories(
66 &self,
67 paladin_id: &str,
68 conversation: &[GarrisonEntry],
69 ) -> Result<Vec<SanctumEntry>, SanctumError> {
70 let start = std::time::Instant::now();
71
72 if conversation.is_empty() {
73 log::debug!("No conversation history to extract memories from");
74 return Ok(Vec::new());
75 }
76
77 log::info!(
78 "Extracting memories for paladin={}, turns={}",
79 paladin_id,
80 conversation.len()
81 );
82
83 let prompt = self.build_extraction_prompt(conversation);
85
86 let prompt_item = PromptItem::new(PromptType::Text(TextPrompt {
88 content: prompt,
89 role: PromptRole::User,
90 }))
91 .map_err(|e| SanctumError::StorageError(format!("Failed to create prompt: {}", e)))?;
92
93 let request = LlmRequest {
94 id: uuid::Uuid::new_v4(),
95 model: "gpt-4".to_string(),
96 prompt: prompt_item,
97 attachments: Vec::new(),
98 stream: false,
99 metadata: HashMap::new(),
100 };
101
102 let response = match self.llm.generate(request).await {
103 Ok(resp) => resp.content,
104 Err(e) => {
105 log::warn!(
106 "Memory extraction LLM call failed: {}, continuing without extraction",
107 e
108 );
109 return Ok(Vec::new());
110 }
111 };
112
113 let extracted = match self.parse_extraction_response(&response) {
115 Ok(memories) => memories,
116 Err(e) => {
117 log::warn!("Failed to parse extraction response: {}, continuing", e);
118 return Ok(Vec::new());
119 }
120 };
121
122 if extracted.is_empty() {
123 log::debug!("No memories extracted from conversation");
124 return Ok(Vec::new());
125 }
126
127 let extracted_count = extracted.len();
128 log::debug!("Extracted {} potential memories", extracted_count);
129
130 let mut memories_to_store = Vec::new();
132 for ext_mem in extracted {
133 let embedding = match self.embedding.embed_text(&ext_mem.content).await {
135 Ok(emb) => emb,
136 Err(e) => {
137 log::warn!("Failed to generate embedding for memory: {}, skipping", e);
138 continue;
139 }
140 };
141
142 if self
144 .check_for_duplicates(paladin_id, &embedding.vector)
145 .await?
146 {
147 log::debug!(
148 "Duplicate memory detected, skipping: {:?}",
149 &ext_mem.content[..50.min(ext_mem.content.len())]
150 );
151 continue;
152 }
153
154 let metadata_values: HashMap<String, Value> = ext_mem
156 .metadata
157 .into_iter()
158 .map(|(k, v)| (k, Value::String(v)))
159 .collect();
160
161 let memory = MemoryBuilder::new(paladin_id.to_string(), ext_mem.content)
163 .memory_type(ext_mem.memory_type)
164 .importance(ext_mem.importance)
165 .metadata(metadata_values)
166 .build()
167 .map_err(|e| SanctumError::StorageError(e.to_string()))?;
168
169 let entry = SanctumEntry::new(memory, embedding.vector)
171 .map_err(|e| SanctumError::StorageError(e.to_string()))?;
172
173 memories_to_store.push(entry);
174 }
175
176 let stored = self.store_memories(paladin_id, &memories_to_store).await?;
178
179 let duration = start.elapsed();
180 let avg_importance = if !stored.is_empty() {
181 stored.iter().map(|e| e.memory.importance).sum::<f32>() / stored.len() as f32
182 } else {
183 0.0
184 };
185
186 log::info!(
187 "Memory extraction complete: paladin={}, extracted={}, stored={}, avg_importance={:.2}, duration_ms={}",
188 paladin_id,
189 extracted_count,
190 stored.len(),
191 avg_importance,
192 duration.as_millis()
193 );
194
195 Ok(stored)
196 }
197
198 fn build_extraction_prompt(&self, conversation: &[GarrisonEntry]) -> String {
200 let mut prompt = String::from(EXTRACTION_PROMPT);
201 prompt.push_str("\n\nConversation:\n");
202
203 for entry in conversation {
204 prompt.push_str(&format!("{:?}: {}\n", entry.role, entry.content));
205 }
206
207 prompt.push_str("\n\nExtract important memories as JSON array:");
208 prompt
209 }
210
211 fn parse_extraction_response(&self, response: &str) -> Result<Vec<ExtractedMemory>, String> {
213 let json_str = if let Some(start) = response.find('[') {
215 if let Some(end) = response.rfind(']') {
216 &response[start..=end]
217 } else {
218 response
219 }
220 } else {
221 response
222 };
223
224 serde_json::from_str::<Vec<ExtractedMemory>>(json_str)
225 .map_err(|e| format!("Failed to parse JSON: {}", e))
226 }
227
228 async fn check_for_duplicates(
230 &self,
231 paladin_id: &str,
232 embedding: &[f32],
233 ) -> Result<bool, SanctumError> {
234 let query = SanctumQuery::new(embedding.to_vec(), 1)
235 .with_filter(SanctumFilter::new().paladin_id(paladin_id.to_string()))
236 .with_min_score(0.95);
237
238 let results = self.sanctum.search(query).await?;
239 Ok(!results.is_empty())
240 }
241
242 async fn store_memories(
244 &self,
245 _paladin_id: &str,
246 memories: &[SanctumEntry],
247 ) -> Result<Vec<SanctumEntry>, SanctumError> {
248 let mut stored: Vec<SanctumEntry> = Vec::new();
249
250 for entry in memories {
251 match self.sanctum.store(entry.clone()).await {
252 Ok(_) => {
253 stored.push(entry.clone());
254 }
255 Err(e) => {
256 log::warn!("Failed to store memory: {}, continuing", e);
257 }
258 }
259 }
260
261 Ok(stored)
262 }
263}
264
265const EXTRACTION_PROMPT: &str = r#"You are a memory extraction assistant. Analyze the following conversation and extract important memories.
267
268For each memory, provide:
2691. content: The actual information to remember (be specific and complete)
2702. memory_type: One of "Episodic", "Semantic", "Procedural"
2713. importance: 0.0 to 1.0 score indicating how important this information is
2724. metadata: Optional key-value pairs for additional context (as an object)
273
274Memory Types:
275- Episodic: Specific events, conversations, or experiences
276- Semantic: Facts, knowledge, preferences, and general information
277- Procedural: How-to instructions, procedures, and workflows
278
279Rules:
280- Extract only genuinely important information worth remembering long-term
281- Be specific and include relevant details
282- Avoid extracting trivial or transient information
283- Combine related information into single memories when appropriate
284- Use proper memory types
285
286Return ONLY a JSON array of memories, no additional text."#;
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use async_trait::async_trait;
292 use paladin_core::platform::container::garrison::ConversationRole;
293 use paladin_core::platform::container::sanctum::MemoryBuilder;
294 use paladin_ports::output::embedding_port::{Embedding, EmbeddingError, EmbeddingPort};
295 use paladin_ports::output::llm_port::{
296 FinishReason, LlmError, LlmPort, LlmRequest, LlmResponse, ProviderCapabilities,
297 StreamingResponse, TokenUsage,
298 };
299 use paladin_ports::output::sanctum_port::{
300 SanctumError, SanctumFilter, SanctumPort, SanctumQuery, SanctumSearchResult,
301 };
302 use std::sync::Arc;
303
304 #[test]
307 fn test_extraction_strategy_default() {
308 assert_eq!(
309 MemoryExtractionStrategy::default(),
310 MemoryExtractionStrategy::OnCompletion
311 );
312 }
313
314 #[test]
315 fn test_extraction_strategy_equality() {
316 assert_eq!(
317 MemoryExtractionStrategy::EveryTurn,
318 MemoryExtractionStrategy::EveryTurn
319 );
320 assert_ne!(
321 MemoryExtractionStrategy::EveryTurn,
322 MemoryExtractionStrategy::OnCompletion
323 );
324 }
325
326 #[test]
327 fn test_extraction_strategy_threshold() {
328 let strategy = MemoryExtractionStrategy::Threshold { importance: 7 };
329 if let MemoryExtractionStrategy::Threshold { importance } = strategy {
330 assert_eq!(importance, 7);
331 } else {
332 panic!("Expected Threshold variant");
333 }
334 }
335
336 #[test]
337 fn test_extracted_memory_serialization() {
338 let mut metadata = HashMap::new();
339 metadata.insert("source".to_string(), "conversation".to_string());
340
341 let memory = ExtractedMemory {
342 content: "User prefers dark mode".to_string(),
343 memory_type: MemoryType::Semantic,
344 importance: 0.8,
345 metadata,
346 };
347
348 let json = serde_json::to_string(&memory).unwrap();
349 let deserialized: ExtractedMemory = serde_json::from_str(&json).unwrap();
350
351 assert_eq!(deserialized.content, memory.content);
352 assert_eq!(deserialized.importance, memory.importance);
353 }
354
355 #[test]
356 fn test_extraction_strategy_serialization() {
357 let strategy = MemoryExtractionStrategy::Threshold { importance: 8 };
358 let json = serde_json::to_string(&strategy).unwrap();
359 let deserialized: MemoryExtractionStrategy = serde_json::from_str(&json).unwrap();
360 assert_eq!(strategy, deserialized);
361 }
362
363 struct MockLlmPort {
366 response: String,
367 should_fail: bool,
368 }
369
370 #[async_trait]
371 impl LlmPort for MockLlmPort {
372 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
373 if self.should_fail {
374 Err(LlmError::ProcessingError("Mock LLM failure".to_string()))
375 } else {
376 Ok(LlmResponse {
377 id: uuid::Uuid::new_v4(),
378 request_id: request.id,
379 model: "mock-model".to_string(),
380 content: self.response.clone(),
381 finish_reason: FinishReason::Stop,
382 usage: TokenUsage {
383 prompt_tokens: 10,
384 completion_tokens: 20,
385 total_tokens: 30,
386 },
387 created_at: chrono::Utc::now(),
388 metadata: HashMap::new(),
389 function_call: None,
390 })
391 }
392 }
393
394 async fn generate_stream(
395 &self,
396 _request: LlmRequest,
397 ) -> Result<
398 Box<dyn futures::Stream<Item = Result<StreamingResponse, LlmError>> + Send>,
399 LlmError,
400 > {
401 Err(LlmError::ProcessingError(
402 "Streaming not supported in mock".to_string(),
403 ))
404 }
405
406 async fn validate_model(&self, _model: &str) -> Result<bool, LlmError> {
407 Ok(true)
408 }
409
410 async fn get_available_models(&self) -> Result<Vec<String>, LlmError> {
411 Ok(vec!["mock-model".to_string()])
412 }
413
414 fn get_provider_name(&self) -> &'static str {
415 "mock-provider"
416 }
417
418 fn get_capabilities(&self) -> ProviderCapabilities {
419 ProviderCapabilities::default()
420 }
421 }
422
423 struct MockEmbeddingPort {
424 dimension: usize,
425 should_fail: bool,
426 }
427
428 #[async_trait]
429 impl EmbeddingPort for MockEmbeddingPort {
430 async fn embed_text(&self, _text: &str) -> Result<Embedding, EmbeddingError> {
431 if self.should_fail {
432 Err(EmbeddingError::NetworkError(
433 "Mock embedding failure".to_string(),
434 ))
435 } else {
436 Ok(Embedding {
437 vector: vec![0.1; self.dimension],
438 model: "mock-embedding-model".to_string(),
439 dimension: self.dimension,
440 token_count: None,
441 })
442 }
443 }
444
445 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Embedding>, EmbeddingError> {
446 if self.should_fail {
447 Err(EmbeddingError::NetworkError(
448 "Mock embedding failure".to_string(),
449 ))
450 } else {
451 Ok(texts
452 .iter()
453 .map(|_| Embedding {
454 vector: vec![0.1; self.dimension],
455 model: "mock-embedding-model".to_string(),
456 dimension: self.dimension,
457 token_count: None,
458 })
459 .collect())
460 }
461 }
462
463 fn dimension(&self) -> usize {
464 self.dimension
465 }
466
467 fn model_name(&self) -> &str {
468 "mock-embedding-model"
469 }
470 }
471
472 struct MockSanctumPort {
473 stored_entries: std::sync::Mutex<Vec<SanctumEntry>>,
474 should_fail_store: bool,
475 duplicate_threshold: f32,
476 }
477
478 impl MockSanctumPort {
479 fn new() -> Self {
480 Self {
481 stored_entries: std::sync::Mutex::new(Vec::new()),
482 should_fail_store: false,
483 duplicate_threshold: 1.1,
485 }
486 }
487
488 fn with_duplicate_threshold(mut self, threshold: f32) -> Self {
489 self.duplicate_threshold = threshold;
490 self
491 }
492 }
493
494 #[async_trait]
495 impl SanctumPort for MockSanctumPort {
496 async fn store(&self, entry: SanctumEntry) -> Result<(), SanctumError> {
497 if self.should_fail_store {
498 Err(SanctumError::StorageError(
499 "Mock storage failure".to_string(),
500 ))
501 } else {
502 self.stored_entries.lock().unwrap().push(entry);
503 Ok(())
504 }
505 }
506
507 async fn store_batch(&self, entries: Vec<SanctumEntry>) -> Result<(), SanctumError> {
508 for entry in entries {
509 self.store(entry).await?;
510 }
511 Ok(())
512 }
513
514 async fn search(
515 &self,
516 query: SanctumQuery,
517 ) -> Result<Vec<SanctumSearchResult>, SanctumError> {
518 if query.min_score.unwrap_or(0.0) >= self.duplicate_threshold {
519 let mock_memory = MemoryBuilder::new(
520 "test-paladin".to_string(),
521 "Existing duplicate memory".to_string(),
522 )
523 .memory_type(MemoryType::Semantic)
524 .build()
525 .unwrap();
526 let mock_entry = SanctumEntry::new(mock_memory, vec![0.1; 1536]).unwrap();
527 Ok(vec![SanctumSearchResult::new(mock_entry, 0.96)])
528 } else {
529 Ok(Vec::new())
530 }
531 }
532
533 async fn delete(&self, _id: &str) -> Result<bool, SanctumError> {
534 Ok(true)
535 }
536
537 async fn update(&self, _entry: SanctumEntry) -> Result<(), SanctumError> {
538 Ok(())
539 }
540
541 async fn count(&self, _filter: Option<SanctumFilter>) -> Result<usize, SanctumError> {
542 Ok(self.stored_entries.lock().unwrap().len())
543 }
544 }
545
546 #[tokio::test]
549 async fn test_successful_extraction_with_multiple_memory_types() {
550 let llm_response = r#"[
551 {
552 "content": "User prefers dark mode in all applications",
553 "memory_type": "Semantic",
554 "importance": 0.8,
555 "metadata": {"category": "ui"}
556 },
557 {
558 "content": "User is learning Rust programming language",
559 "memory_type": "Semantic",
560 "importance": 0.9,
561 "metadata": {"topic": "programming"}
562 },
563 {
564 "content": "User wants to build a web application",
565 "memory_type": "Episodic",
566 "importance": 0.85,
567 "metadata": {}
568 }
569 ]"#;
570
571 let llm = Arc::new(MockLlmPort {
572 response: llm_response.to_string(),
573 should_fail: false,
574 });
575 let embedding = Arc::new(MockEmbeddingPort {
576 dimension: 1536,
577 should_fail: false,
578 });
579 let sanctum = Arc::new(MockSanctumPort::new());
580
581 let service = MemoryExtractionService::new(llm, embedding, sanctum.clone());
582
583 let conversation = vec![
584 GarrisonEntry::new(ConversationRole::User, "I prefer dark mode".to_string()),
585 GarrisonEntry::new(
586 ConversationRole::Assistant,
587 "Noted! I'll remember your preference".to_string(),
588 ),
589 ];
590
591 let result = service
592 .extract_memories("test-paladin", &conversation)
593 .await
594 .unwrap();
595
596 assert_eq!(result.len(), 3);
597 assert_eq!(sanctum.stored_entries.lock().unwrap().len(), 3);
598 }
599
600 #[tokio::test]
601 async fn test_importance_scoring_correctly_assigned() {
602 let llm_response = r#"[
603 {
604 "content": "High importance memory",
605 "memory_type": "Semantic",
606 "importance": 0.95,
607 "metadata": {}
608 },
609 {
610 "content": "Low importance memory",
611 "memory_type": "Episodic",
612 "importance": 0.3,
613 "metadata": {}
614 }
615 ]"#;
616
617 let llm = Arc::new(MockLlmPort {
618 response: llm_response.to_string(),
619 should_fail: false,
620 });
621 let embedding = Arc::new(MockEmbeddingPort {
622 dimension: 1536,
623 should_fail: false,
624 });
625 let sanctum = Arc::new(MockSanctumPort::new());
626
627 let service = MemoryExtractionService::new(llm, embedding, sanctum.clone());
628
629 let conversation = vec![GarrisonEntry::new(
630 ConversationRole::User,
631 "Test content".to_string(),
632 )];
633
634 let result = service
635 .extract_memories("test-paladin", &conversation)
636 .await
637 .unwrap();
638
639 assert_eq!(result.len(), 2);
640 assert_eq!(result[0].memory.importance, 0.95);
641 assert_eq!(result[1].memory.importance, 0.3);
642 }
643
644 #[tokio::test]
645 async fn test_duplicate_detection_prevents_restorage() {
646 let llm_response = r#"[
647 {
648 "content": "Duplicate memory content",
649 "memory_type": "Semantic",
650 "importance": 0.8,
651 "metadata": {}
652 }
653 ]"#;
654
655 let llm = Arc::new(MockLlmPort {
656 response: llm_response.to_string(),
657 should_fail: false,
658 });
659 let embedding = Arc::new(MockEmbeddingPort {
660 dimension: 1536,
661 should_fail: false,
662 });
663 let sanctum = Arc::new(MockSanctumPort::new().with_duplicate_threshold(0.95));
664
665 let service = MemoryExtractionService::new(llm, embedding, sanctum.clone());
666
667 let conversation = vec![GarrisonEntry::new(
668 ConversationRole::User,
669 "Duplicate content".to_string(),
670 )];
671
672 let result = service
673 .extract_memories("test-paladin", &conversation)
674 .await
675 .unwrap();
676
677 assert_eq!(result.len(), 0);
678 assert_eq!(sanctum.stored_entries.lock().unwrap().len(), 0);
679 }
680
681 #[tokio::test]
682 async fn test_llm_failure_handled_gracefully() {
683 let llm = Arc::new(MockLlmPort {
684 response: String::new(),
685 should_fail: true,
686 });
687 let embedding = Arc::new(MockEmbeddingPort {
688 dimension: 1536,
689 should_fail: false,
690 });
691 let sanctum = Arc::new(MockSanctumPort::new());
692
693 let service = MemoryExtractionService::new(llm, embedding, sanctum);
694
695 let conversation = vec![GarrisonEntry::new(
696 ConversationRole::User,
697 "Test".to_string(),
698 )];
699
700 let result = service
701 .extract_memories("test-paladin", &conversation)
702 .await
703 .unwrap();
704
705 assert_eq!(result.len(), 0);
706 }
707
708 #[tokio::test]
709 async fn test_empty_conversation_returns_empty() {
710 let llm = Arc::new(MockLlmPort {
711 response: String::new(),
712 should_fail: false,
713 });
714 let embedding = Arc::new(MockEmbeddingPort {
715 dimension: 1536,
716 should_fail: false,
717 });
718 let sanctum = Arc::new(MockSanctumPort::new());
719
720 let service = MemoryExtractionService::new(llm, embedding, sanctum);
721
722 let result = service.extract_memories("test-paladin", &[]).await.unwrap();
723
724 assert_eq!(result.len(), 0);
725 }
726
727 #[tokio::test]
728 async fn test_malformed_json_response_handled() {
729 let llm = Arc::new(MockLlmPort {
730 response: "This is not valid JSON".to_string(),
731 should_fail: false,
732 });
733 let embedding = Arc::new(MockEmbeddingPort {
734 dimension: 1536,
735 should_fail: false,
736 });
737 let sanctum = Arc::new(MockSanctumPort::new());
738
739 let service = MemoryExtractionService::new(llm, embedding, sanctum);
740
741 let conversation = vec![GarrisonEntry::new(
742 ConversationRole::User,
743 "Test".to_string(),
744 )];
745
746 let result = service
747 .extract_memories("test-paladin", &conversation)
748 .await
749 .unwrap();
750
751 assert_eq!(result.len(), 0);
752 }
753
754 #[tokio::test]
755 async fn test_embedding_failure_skips_memory() {
756 let llm_response = r#"[
757 {
758 "content": "Test memory",
759 "memory_type": "Semantic",
760 "importance": 0.8,
761 "metadata": {}
762 }
763 ]"#;
764
765 let llm = Arc::new(MockLlmPort {
766 response: llm_response.to_string(),
767 should_fail: false,
768 });
769 let embedding = Arc::new(MockEmbeddingPort {
770 dimension: 1536,
771 should_fail: true,
772 });
773 let sanctum = Arc::new(MockSanctumPort::new());
774
775 let service = MemoryExtractionService::new(llm, embedding, sanctum);
776
777 let conversation = vec![GarrisonEntry::new(
778 ConversationRole::User,
779 "Test".to_string(),
780 )];
781
782 let result = service
783 .extract_memories("test-paladin", &conversation)
784 .await
785 .unwrap();
786
787 assert_eq!(result.len(), 0);
788 }
789}