diff --git a/docs/INTELLIGENT_MEMORY.md b/docs/INTELLIGENT_MEMORY.md new file mode 100644 index 0000000..f1d69a8 --- /dev/null +++ b/docs/INTELLIGENT_MEMORY.md @@ -0,0 +1,149 @@ +# Intelligent Memory Management + +## Overview + +The `add` method in Mem4j now features intelligent memory management that automatically decides whether to INSERT, UPDATE, DELETE, or SKIP memories based on their similarity to existing memories and LLM-driven analysis. + +## How It Works + +When adding new memories, the system: + +1. **Extracts memories** from conversations using LLM +2. **Searches for similar existing memories** in the vector database (similarity threshold: 0.7) +3. **Makes intelligent decisions** based on similarity scores: + - **Score > 0.95**: SKIP (near-duplicate) + - **Score 0.85-0.95**: Consult LLM to decide UPDATE/DELETE/SKIP/INSERT + - **Score 0.7-0.85**: INSERT as separate memory + - **No similar memories**: INSERT new memory + +## Decision Logic + +### INSERT +- New memory is sufficiently different from existing memories +- Adds complementary information that should be kept separate +- Default action when no similar memories exist + +### UPDATE +- New memory contains more recent or accurate information +- LLM merges the new and existing memory content +- Preserves the memory ID and updates the content +- Example: "User likes coffee" → "User prefers tea over coffee" + +### DELETE +- New memory contradicts or makes existing memory obsolete +- Old information is removed completely +- Example: "User is a developer" → "User is no longer a developer" + +### SKIP +- New memory is essentially a duplicate +- No new information would be added +- Avoids redundancy in the memory store + +## LLM Decision Prompt + +The system uses a structured prompt to ask the LLM to analyze memory pairs: + +``` +You are a memory management system. Compare these two memories and decide what action to take: + +EXISTING MEMORY: [existing content] +NEW MEMORY: [new content] + +Analyze and decide: +1. UPDATE: [merged content] - if new memory contains updated information +2. DELETE - if new memory makes existing memory obsolete +3. SKIP - if new memory adds no value +4. INSERT - if they are complementary but distinct + +Consider: +- Temporal context (newer information may supersede older) +- Specificity (more specific information may update general information) +- Contradictions (direct contradictions should trigger DELETE + INSERT) +- Redundancy (avoid storing the same information twice) +``` + +## Benefits + +1. **Reduces redundancy**: Avoids storing duplicate or near-duplicate information +2. **Keeps information current**: Updates memories when newer information arrives +3. **Maintains coherence**: Removes contradictory or obsolete memories +4. **Saves storage**: Only stores valuable, distinct memories +5. **Improves search quality**: Cleaner memory store leads to better retrieval + +## Usage Example + +```java +Memory memory = new Memory(config, vectorStore, llmService, embeddingService); + +// Initial conversation +List messages1 = Arrays.asList( + new Message("user", "I love drinking coffee"), + new Message("assistant", "I'll remember that!") +); +memory.add(messages1, userId); +// Result: 1 inserted + +// Similar conversation (will skip) +List messages2 = Arrays.asList( + new Message("user", "I really enjoy coffee"), + new Message("assistant", "Yes, you mentioned that!") +); +memory.add(messages2, userId); +// Result: 1 skipped (duplicate) + +// Updated preference (will update) +List messages3 = Arrays.asList( + new Message("user", "I've switched to tea instead of coffee"), + new Message("assistant", "I'll update that!") +); +memory.add(messages3, userId); +// Result: 1 updated (coffee → tea) +``` + +## Configuration + +The intelligent decision-making uses the standard similarity threshold from the configuration: + +```yaml +mem4j: + similarity-threshold: 0.7 # Used for finding similar memories +``` + +## Performance Considerations + +- **Search overhead**: Each new memory triggers a similarity search (5 results max) +- **LLM calls**: Only for memories with similarity 0.85-0.95 (most cases use rule-based decisions) +- **Fallback behavior**: If LLM fails, defaults to INSERT to avoid data loss + +## Monitoring + +The system logs detailed information about memory operations: + +``` +INFO: Memory operations for user john: 2 inserted, 1 updated, 0 deleted, 1 skipped (total extracted: 4) +DEBUG: Inserting new memory: 'User lives in Seattle' - Reason: No similar memories found +DEBUG: Updating existing memory 'User likes coffee' -> 'User prefers tea' - Reason: LLM decided to merge +DEBUG: Skipping memory: 'User enjoys coffee' - Reason: Memory is nearly identical (score: 0.96) +``` + +## Testing + +Comprehensive test coverage includes: +- Insert new memory when no similar exists +- Skip near-duplicates (score > 0.95) +- Update memory when LLM decides +- Delete memory when LLM decides +- Insert separate memory for moderate similarity +- Multiple memories with different actions +- LLM error fallback to INSERT + +See `IntelligentMemoryTest.java` for full test suite. + +## Future Enhancements + +Potential improvements for future versions: +- Configurable similarity thresholds for each action +- Batch processing for multiple memories +- User-configurable decision strategies +- Memory merge history tracking +- Async LLM decision-making diff --git a/mem4j-core/src/main/java/io/github/mem4j/memory/Memory.java b/mem4j-core/src/main/java/io/github/mem4j/memory/Memory.java index 3d456e8..2cc8646 100644 --- a/mem4j-core/src/main/java/io/github/mem4j/memory/Memory.java +++ b/mem4j-core/src/main/java/io/github/mem4j/memory/Memory.java @@ -68,42 +68,60 @@ public void add(List messages, String userId, Map metad .map(memory -> createMemoryItem(memory, userId, metadata, memoryType)) .collect(Collectors.toList()); - // Filter out duplicate memories before adding - List newMemories = new ArrayList<>(); - int duplicateCount = 0; + // Process each memory intelligently + int insertCount = 0; + int updateCount = 0; + int deleteCount = 0; + int skipCount = 0; for (MemoryItem item : memoryItems) { Double[] embedding = embeddingService.embed(item.getContent()); item.setEmbedding(embedding); - // Check for similar existing memories (high similarity threshold for - // deduplication) + // Search for similar existing memories with moderate threshold List similarMemories = vectorStoreService.search(embedding, - buildSearchFilters(userId, null), 5, 0.85); - - boolean isDuplicate = false; - for (MemoryItem existing : similarMemories) { - if (existing.getScore() > 0.85) { - logger.debug("Skipping duplicate memory: '{}' (similar to existing: '{}')", item.getContent(), - existing.getContent()); - isDuplicate = true; - duplicateCount++; + buildSearchFilters(userId, null), 5, 0.7); + + // Make intelligent decision about what to do with this memory + MemoryDecision decision = decideMemoryAction(item, similarMemories); + + // Execute the decision + switch (decision.getAction()) { + case INSERT: + vectorStoreService.add(item); + insertCount++; + logger.debug("Inserting new memory: '{}' - Reason: {}", item.getContent(), + decision.getReason()); break; - } - } - if (!isDuplicate) { - newMemories.add(item); - } - } + case UPDATE: + MemoryItem existingItem = decision.getExistingMemory(); + existingItem.setContent(decision.getNewContent()); + existingItem.setEmbedding(embeddingService.embed(decision.getNewContent())); + existingItem.setUpdatedAt(java.time.Instant.now()); + vectorStoreService.update(existingItem); + updateCount++; + logger.debug("Updating existing memory '{}' -> '{}' - Reason: {}", existingItem.getContent(), + decision.getNewContent(), decision.getReason()); + break; - // Store only new memories - for (MemoryItem item : newMemories) { - vectorStoreService.add(item); + case DELETE: + vectorStoreService.delete(decision.getExistingMemory().getId()); + deleteCount++; + logger.debug("Deleting obsolete memory: '{}' - Reason: {}", + decision.getExistingMemory().getContent(), decision.getReason()); + break; + + case SKIP: + skipCount++; + logger.debug("Skipping memory: '{}' - Reason: {}", item.getContent(), decision.getReason()); + break; + } } - logger.info("Added {} new memories for user {} (skipped {} duplicates)", newMemories.size(), userId, - duplicateCount); + logger.info( + "Memory operations for user {}: {} inserted, {} updated, {} deleted, {} skipped (total extracted: {})", + userId, insertCount, updateCount, deleteCount, skipCount, memoryItems.size()); } catch (Exception e) { logger.error("Error adding memories for user {}", userId, e); @@ -111,6 +129,118 @@ public void add(List messages, String userId, Map metad } } + /** + * Intelligently decide what action to take on a memory by comparing with similar + * existing memories Uses LLM to make nuanced decisions about insert/update/delete + */ + private MemoryDecision decideMemoryAction(MemoryItem newMemory, List similarMemories) { + + // If no similar memories exist, insert the new memory + if (similarMemories.isEmpty()) { + return new MemoryDecision(MemoryAction.INSERT, newMemory.getContent(), null, + "No similar memories found, inserting new memory"); + } + + // Find the most similar memory + MemoryItem mostSimilar = similarMemories.get(0); + double highestScore = mostSimilar.getScore(); + + // If similarity is very high (>0.95), consider it a duplicate and skip + if (highestScore > 0.95) { + return new MemoryDecision(MemoryAction.SKIP, null, mostSimilar, + "Memory is nearly identical to existing memory (score: " + String.format("%.2f", highestScore) + + ")"); + } + + // If similarity is high (0.85-0.95), use LLM to decide if update is needed + if (highestScore > 0.85) { + String llmDecision = getLLMMemoryDecision(newMemory.getContent(), mostSimilar.getContent()); + + if (llmDecision.contains("UPDATE")) { + // Extract merged content from LLM response + String mergedContent = extractMergedContent(llmDecision); + if (mergedContent != null && !mergedContent.isEmpty()) { + return new MemoryDecision(MemoryAction.UPDATE, mergedContent, mostSimilar, + "LLM decided to merge memories with updated information"); + } + } + else if (llmDecision.contains("DELETE")) { + return new MemoryDecision(MemoryAction.DELETE, null, mostSimilar, + "LLM decided old memory is obsolete or contradicted"); + } + else if (llmDecision.contains("SKIP")) { + return new MemoryDecision(MemoryAction.SKIP, null, mostSimilar, "LLM decided new memory adds no value"); + } + } + + // If similarity is moderate (0.7-0.85), insert as separate memory + if (highestScore > 0.7) { + return new MemoryDecision(MemoryAction.INSERT, newMemory.getContent(), null, + "Memory is related but sufficiently different to keep separate (score: " + + String.format("%.2f", highestScore) + ")"); + } + + // Default: insert as new memory + return new MemoryDecision(MemoryAction.INSERT, newMemory.getContent(), null, + "Memory is distinct enough to insert separately"); + } + + /** + * Use LLM to decide whether to update, delete, or skip a memory + */ + private String getLLMMemoryDecision(String newMemory, String existingMemory) { + + String prompt = String.format( + """ + You are a memory management system. Compare these two memories and decide what action to take: + + EXISTING MEMORY: %s + NEW MEMORY: %s + + Analyze and decide: + 1. If the new memory contains UPDATED information that contradicts or improves the existing memory, respond with: + UPDATE: [merged content combining both memories with the most accurate/recent information] + + 2. If the new memory makes the existing memory OBSOLETE or CONTRADICTS it completely, respond with: + DELETE + + 3. If the new memory adds NO NEW value (it's essentially the same), respond with: + SKIP + + 4. If they are COMPLEMENTARY but distinct enough to keep separate, respond with: + INSERT + + Consider: + - Temporal context (newer information may supersede older) + - Specificity (more specific information may update general information) + - Contradictions (direct contradictions should trigger DELETE of old + INSERT of new) + - Redundancy (avoid storing the same information twice) + + Respond with ONLY one of: UPDATE: [content], DELETE, SKIP, or INSERT + """, + existingMemory, newMemory); + + try { + return llmService.generate(prompt); + } + catch (Exception e) { + logger.warn("Error getting LLM decision for memory action, defaulting to INSERT", e); + return "INSERT"; + } + } + + /** + * Extract merged content from LLM response + */ + private String extractMergedContent(String llmResponse) { + // Look for content after "UPDATE:" + String[] parts = llmResponse.split("UPDATE:", 2); + if (parts.length > 1) { + return parts[1].trim(); + } + return null; + } + /** Search for relevant memories */ public List search(String query, String userId) { return search(query, userId, null, 10, null); diff --git a/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryAction.java b/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryAction.java new file mode 100644 index 0000000..606e57a --- /dev/null +++ b/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryAction.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.mem4j.memory; + +/** + * Enumeration of actions that can be taken on memories + */ +public enum MemoryAction { + + /** + * Insert a new memory + */ + INSERT, + + /** + * Update an existing memory + */ + UPDATE, + + /** + * Delete an existing memory + */ + DELETE, + + /** + * Skip - no action needed + */ + SKIP + +} diff --git a/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryDecision.java b/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryDecision.java new file mode 100644 index 0000000..b87e674 --- /dev/null +++ b/mem4j-core/src/main/java/io/github/mem4j/memory/MemoryDecision.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.mem4j.memory; + +/** + * Represents a decision about what action to take on a memory + */ +public class MemoryDecision { + + private final MemoryAction action; + + private final String newContent; + + private final MemoryItem existingMemory; + + private final String reason; + + public MemoryDecision(MemoryAction action, String newContent, MemoryItem existingMemory, String reason) { + this.action = action; + this.newContent = newContent; + this.existingMemory = existingMemory; + this.reason = reason; + } + + public MemoryAction getAction() { + return action; + } + + public String getNewContent() { + return newContent; + } + + public MemoryItem getExistingMemory() { + return existingMemory; + } + + public String getReason() { + return reason; + } + + @Override + public String toString() { + return "MemoryDecision{" + "action=" + action + ", newContent='" + newContent + '\'' + ", existingMemory=" + + (existingMemory != null ? existingMemory.getId() : "null") + ", reason='" + reason + '\'' + '}'; + } + +} diff --git a/mem4j-core/src/test/java/io/github/mem4j/IntelligentMemoryTest.java b/mem4j-core/src/test/java/io/github/mem4j/IntelligentMemoryTest.java new file mode 100644 index 0000000..d6c477c --- /dev/null +++ b/mem4j-core/src/test/java/io/github/mem4j/IntelligentMemoryTest.java @@ -0,0 +1,282 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.mem4j; + +import io.github.mem4j.config.MemoryConfigurable; +import io.github.mem4j.embeddings.EmbeddingService; +import io.github.mem4j.llms.LLMService; +import io.github.mem4j.memory.Memory; +import io.github.mem4j.memory.MemoryItem; +import io.github.mem4j.memory.MemoryType; +import io.github.mem4j.memory.Message; +import io.github.mem4j.vectorstores.VectorStoreService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for intelligent memory management features This tests the new + * INSERT/UPDATE/DELETE decision-making logic + */ +@ExtendWith(MockitoExtension.class) +public class IntelligentMemoryTest { + + @Mock + private MemoryConfigurable memoryConfig; + + @Mock + private VectorStoreService vectorStoreService; + + @Mock + private LLMService llmService; + + @Mock + private EmbeddingService embeddingService; + + private Memory memory; + + private final String testUserId = "test_user"; + + @BeforeEach + void setUp() { + // Configure mock behavior + lenient().when(memoryConfig.getSimilarityThreshold()).thenReturn(0.7); + lenient().when(embeddingService.embed(anyString())).thenReturn(createMockEmbedding()); + lenient().when(embeddingService.getDimension()).thenReturn(1536); + lenient().when(embeddingService.isAvailable()).thenReturn(true); + lenient().when(llmService.isAvailable()).thenReturn(true); + + // Create Memory instance with mocked dependencies + memory = new Memory(memoryConfig, vectorStoreService, llmService, embeddingService); + } + + @Test + void testIntelligentAdd_InsertNewMemory_WhenNoSimilarExists() { + // Arrange + List messages = Arrays.asList(new Message("user", "I love hiking in the mountains"), + new Message("assistant", "That's great!")); + + when(llmService.generate(anyString())).thenReturn("- User loves hiking in the mountains"); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.emptyList()); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert + verify(vectorStoreService, times(1)).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).update(any(MemoryItem.class)); + verify(vectorStoreService, never()).delete(anyString()); + } + + @Test + void testIntelligentAdd_SkipDuplicate_WhenHighSimilarity() { + // Arrange + List messages = Arrays.asList(new Message("user", "I like pizza"), + new Message("assistant", "Got it!")); + + when(llmService.generate(anyString())).thenReturn("- User likes pizza"); + + // Create a very similar existing memory (score > 0.95) + MemoryItem existingMemory = createMockMemoryItem("existing-1", "User likes pizza"); + existingMemory.setScore(0.96); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingMemory)); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert - should skip (not insert, update, or delete) + verify(vectorStoreService, never()).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).update(any(MemoryItem.class)); + verify(vectorStoreService, never()).delete(anyString()); + } + + @Test + void testIntelligentAdd_UpdateMemory_WhenLLMDecides() { + // Arrange + List messages = Arrays.asList(new Message("user", "I now prefer tea over coffee"), + new Message("assistant", "I'll remember that!")); + + when(llmService.generate(contains("Extract key memories"))).thenReturn("- User prefers tea over coffee"); + + // Create existing memory with similarity 0.9 (should trigger LLM decision) + MemoryItem existingMemory = createMockMemoryItem("existing-1", "User likes coffee"); + existingMemory.setScore(0.9); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingMemory)); + + // LLM decides to update with merged content + when(llmService.generate(contains("memory management system"))) + .thenReturn("UPDATE: User prefers tea over coffee (changed from coffee preference)"); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(MemoryItem.class); + verify(vectorStoreService, times(1)).update(captor.capture()); + verify(vectorStoreService, never()).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).delete(anyString()); + + MemoryItem updatedItem = captor.getValue(); + assertTrue(updatedItem.getContent().contains("prefers tea")); + } + + @Test + void testIntelligentAdd_DeleteMemory_WhenLLMDecides() { + // Arrange + List messages = Arrays.asList(new Message("user", "Actually, I'm not a developer anymore"), + new Message("assistant", "Thanks for letting me know!")); + + when(llmService.generate(contains("Extract key memories"))).thenReturn("- User is no longer a developer"); + + // Create existing memory with similarity 0.9 + MemoryItem existingMemory = createMockMemoryItem("existing-1", "User is a software developer"); + existingMemory.setScore(0.9); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingMemory)); + + // LLM decides to delete old memory + when(llmService.generate(contains("memory management system"))).thenReturn("DELETE"); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert + verify(vectorStoreService, times(1)).delete("existing-1"); + verify(vectorStoreService, never()).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).update(any(MemoryItem.class)); + } + + @Test + void testIntelligentAdd_InsertSeparate_WhenModerateSimilarity() { + // Arrange + List messages = Arrays.asList(new Message("user", "I also enjoy swimming"), + new Message("assistant", "Great!")); + + when(llmService.generate(anyString())).thenReturn("- User enjoys swimming"); + + // Create existing memory with moderate similarity (0.75) + MemoryItem existingMemory = createMockMemoryItem("existing-1", "User loves hiking"); + existingMemory.setScore(0.75); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingMemory)); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert - should insert as separate memory + verify(vectorStoreService, times(1)).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).update(any(MemoryItem.class)); + verify(vectorStoreService, never()).delete(anyString()); + } + + @Test + void testIntelligentAdd_MultipleMemories_DifferentActions() { + // Arrange + List messages = Arrays.asList(new Message("user", "I like pizza and I moved to New York"), + new Message("assistant", "Interesting updates!")); + + // Extract two memories + when(llmService.generate(contains("Extract key memories"))) + .thenReturn("- User likes pizza\n- User moved to New York"); + + // First memory - very similar existing (score > 0.95, should skip) + MemoryItem existingPizza = createMockMemoryItem("existing-1", "User likes pizza"); + existingPizza.setScore(0.96); + + // Second memory - moderately similar existing (score 0.85-0.95, should consult + // LLM) + MemoryItem existingLocation = createMockMemoryItem("existing-2", "User lives in Boston"); + existingLocation.setScore(0.9); + + // Mock search to return different results for different embeddings + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingPizza)) + .thenReturn(Collections.singletonList(existingLocation)); + + // LLM decides to update the location + when(llmService.generate(contains("memory management system"))) + .thenReturn("UPDATE: User moved to New York (previously in Boston)"); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert + verify(vectorStoreService, times(1)).update(any(MemoryItem.class)); // Location + // updated + verify(vectorStoreService, never()).add(any(MemoryItem.class)); // Pizza skipped + verify(vectorStoreService, never()).delete(anyString()); // Nothing deleted + } + + @Test + void testIntelligentAdd_LLMError_FallsBackToInsert() { + // Arrange + List messages = Arrays.asList(new Message("user", "I like reading books"), + new Message("assistant", "Nice!")); + + when(llmService.generate(contains("Extract key memories"))).thenReturn("- User likes reading books"); + + MemoryItem existingMemory = createMockMemoryItem("existing-1", "User enjoys reading"); + existingMemory.setScore(0.9); + when(vectorStoreService.search(any(Double[].class), any(), anyInt(), anyDouble())) + .thenReturn(Collections.singletonList(existingMemory)); + + // LLM throws error when making decision + when(llmService.generate(contains("memory management system"))) + .thenThrow(new RuntimeException("LLM API error")); + + // Act + memory.add(messages, testUserId, null, true, MemoryType.FACTUAL); + + // Assert - should fall back to INSERT + verify(vectorStoreService, times(1)).add(any(MemoryItem.class)); + verify(vectorStoreService, never()).update(any(MemoryItem.class)); + verify(vectorStoreService, never()).delete(anyString()); + } + + // Helper methods + private Double[] createMockEmbedding() { + Double[] embedding = new Double[1536]; + for (int i = 0; i < embedding.length; i++) { + embedding[i] = Math.random(); + } + return embedding; + } + + private MemoryItem createMockMemoryItem(String id, String content) { + MemoryItem item = new MemoryItem(content, MemoryType.FACTUAL.getValue()); + item.setId(id); + item.setUserId(testUserId); + item.setScore(0.9); + item.setEmbedding(createMockEmbedding()); + return item; + } + +}