Skip to main content

paladin_memory/services/
memory_extraction_service.rs

1/// Memory Extraction Service
2///
3/// Extracts meaningful memories from conversation history and stores them
4/// in long-term memory (Sanctum) for later retrieval via RAG.
5///
6/// This service:
7/// - Analyzes conversation history using LLM
8/// - Identifies important facts, preferences, and context
9/// - Generates embeddings for semantic search
10/// - Detects and prevents duplicate memories
11/// - Stores memories with proper metadata
12use 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
24/// Strategy for when to extract memories from conversations.
25// MemoryExtractionStrategy moved to crates/paladin-memory/src/config/rag.rs (Task 6.0)
26pub use crate::config::rag::MemoryExtractionStrategy;
27
28/// Intermediate representation of extracted memory before storage.
29#[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
37/// Memory Extraction Service.
38///
39/// Coordinates LLM-based memory extraction and storage via [`SanctumPort`].
40/// Depends only on port traits — contains no concrete adapter references.
41pub struct MemoryExtractionService {
42    llm: Arc<dyn LlmPort>,
43    embedding: Arc<dyn EmbeddingPort>,
44    sanctum: Arc<dyn SanctumPort>,
45}
46
47impl MemoryExtractionService {
48    /// Create a new memory extraction service.
49    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    /// Extract memories from conversation history.
62    ///
63    /// Analyzes the conversation, extracts important information, and stores it
64    /// in long-term memory (Sanctum) for later retrieval.
65    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        // Build extraction prompt
84        let prompt = self.build_extraction_prompt(conversation);
85
86        // Call LLM to extract memories
87        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        // Parse LLM response to get extracted memories
114        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        // Convert to Memory objects and generate embeddings
131        let mut memories_to_store = Vec::new();
132        for ext_mem in extracted {
133            // Generate embedding
134            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            // Check for duplicates
143            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            // Convert HashMap<String, String> to HashMap<String, Value>
155            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            // Create Memory object
162            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            // Create SanctumEntry
170            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        // Store memories in Sanctum
177        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    /// Build extraction prompt from conversation history.
199    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    /// Parse LLM extraction response into structured memories.
212    fn parse_extraction_response(&self, response: &str) -> Result<Vec<ExtractedMemory>, String> {
213        // Try to extract JSON from response (might be wrapped in markdown)
214        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    /// Check if a similar memory already exists (>0.95 similarity).
229    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    /// Store memories in batch via Sanctum.
243    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
265/// LLM prompt template for memory extraction.
266const 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    // ── Strategy / serialization tests ────────────────────────────────────────
305
306    #[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    // ── Mock helpers ──────────────────────────────────────────────────────────
364
365    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                // Default: threshold above 1.0 → duplicates never triggered
484                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    // ── Async service tests ───────────────────────────────────────────────────
547
548    #[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}