Add The Biographer household member for user memory management: Memory Service (direct access layer): - src/core/memory_service.py for fast, LLM-free lookups - Profile, preference, and fact management - Session context with Redis caching - Steward integration via prefetch_context() The Biographer Agent: - src/agents/biographer/ package with PydanticAI agent - Discreet chronicler personality for privacy - Tools: recall_semantic, list_memories, store_insight, update_profile, update_preference, forget_memory - Registered with Household Registry on startup Steward Integration: - Memory context pre-fetch during analysis - Profile/preferences included in Butler note - Keyword-based context determination Also includes: - delegate_to_biographer() wrapper - 34 new tests (capability + memory service) - Version bump to 1.2.0 Documentation cleanup: - Removed obsolete PHASE2_COMPLETE.md, PHASE2_PLAN.md - Removed docs/library-desk-requirements.md - Moved ORCHESTRATION_SCENARIOS.md to project root 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""
|
|
Tests for the memory service (direct access layer).
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
|
|
|
from src.core.memory_service import (
|
|
MemoryService,
|
|
MemoryType,
|
|
MemoryRecord,
|
|
memory_service,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryType:
|
|
"""Tests for MemoryType enum."""
|
|
|
|
def test_user_profile_type(self):
|
|
"""Test user_profile type exists."""
|
|
assert MemoryType.USER_PROFILE.value == "user_profile"
|
|
|
|
def test_preference_type(self):
|
|
"""Test preference type exists."""
|
|
assert MemoryType.PREFERENCE.value == "preference"
|
|
|
|
def test_learned_fact_type(self):
|
|
"""Test learned_fact type exists."""
|
|
assert MemoryType.LEARNED_FACT.value == "learned_fact"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryRecord:
|
|
"""Tests for MemoryRecord model."""
|
|
|
|
def test_create_minimal_record(self):
|
|
"""Test creating record with minimal fields."""
|
|
record = MemoryRecord(
|
|
id="test_1",
|
|
type=MemoryType.USER_PROFILE,
|
|
key="location",
|
|
value="Amsterdam",
|
|
)
|
|
|
|
assert record.id == "test_1"
|
|
assert record.type == MemoryType.USER_PROFILE
|
|
assert record.key == "location"
|
|
assert record.value == "Amsterdam"
|
|
assert record.importance == 0.5 # Default
|
|
assert record.source == "explicit" # Default
|
|
|
|
def test_create_full_record(self):
|
|
"""Test creating record with all fields."""
|
|
record = MemoryRecord(
|
|
id="test_2",
|
|
type=MemoryType.LEARNED_FACT,
|
|
key="car",
|
|
value="Tesla Model 3",
|
|
keywords=["car", "vehicle", "tesla"],
|
|
importance=0.8,
|
|
source="conversation",
|
|
)
|
|
|
|
assert record.keywords == ["car", "vehicle", "tesla"]
|
|
assert record.importance == 0.8
|
|
assert record.source == "conversation"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryServiceInit:
|
|
"""Tests for MemoryService initialization."""
|
|
|
|
def test_service_has_lazy_clients(self):
|
|
"""Test service initializes with lazy client loading."""
|
|
service = MemoryService()
|
|
|
|
assert service._qdrant is None
|
|
assert service._embedding is None
|
|
assert service._cache is None
|
|
|
|
def test_global_instance_exists(self):
|
|
"""Test global memory_service instance exists."""
|
|
assert memory_service is not None
|
|
assert isinstance(memory_service, MemoryService)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryServiceProfileMethods:
|
|
"""Tests for profile-related methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_profile_uses_context(self):
|
|
"""Test get_profile uses request context for user."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = "Amsterdam"
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.get_profile("location")
|
|
|
|
mock_get.assert_called_once_with("testuser", MemoryType.USER_PROFILE, "location")
|
|
assert result == "Amsterdam"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_profile_explicit_user(self):
|
|
"""Test get_profile with explicit user parameter."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = "Berlin"
|
|
|
|
result = await service.get_profile("location", user="otheruser")
|
|
|
|
mock_get.assert_called_once_with("otheruser", MemoryType.USER_PROFILE, "location")
|
|
assert result == "Berlin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_profile_high_importance(self):
|
|
"""Test set_profile uses high importance (0.9)."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
|
mock_set.return_value = True
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.set_profile("timezone", "Europe/Amsterdam")
|
|
|
|
call_kwargs = mock_set.call_args[1]
|
|
assert call_kwargs["importance"] == 0.9
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryServicePreferenceMethods:
|
|
"""Tests for preference-related methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_preference(self):
|
|
"""Test get_preference retrieves correctly."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = "celsius"
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.get_preference("temperature_unit")
|
|
|
|
mock_get.assert_called_once_with("testuser", MemoryType.PREFERENCE, "temperature_unit")
|
|
assert result == "celsius"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_preference_medium_importance(self):
|
|
"""Test set_preference uses medium importance (0.7)."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
|
mock_set.return_value = True
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.set_preference("theme", "dark")
|
|
|
|
call_kwargs = mock_set.call_args[1]
|
|
assert call_kwargs["importance"] == 0.7
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryServiceFactMethods:
|
|
"""Tests for fact-related methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_fact_default_importance(self):
|
|
"""Test store_fact uses default importance (0.5)."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
|
mock_set.return_value = True
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.store_fact("car", "Tesla Model 3")
|
|
|
|
call_kwargs = mock_set.call_args[1]
|
|
assert call_kwargs["importance"] == 0.5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_fact_custom_importance(self):
|
|
"""Test store_fact with custom importance."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_set_memory", new_callable=AsyncMock) as mock_set:
|
|
mock_set.return_value = True
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.store_fact(
|
|
"employer",
|
|
"Acme Corp",
|
|
importance=0.8,
|
|
)
|
|
|
|
call_kwargs = mock_set.call_args[1]
|
|
assert call_kwargs["importance"] == 0.8
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_fact(self):
|
|
"""Test get_fact retrieves correctly."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "_get_memory", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = "Tesla Model 3"
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.get_fact("car")
|
|
|
|
mock_get.assert_called_once_with("testuser", MemoryType.LEARNED_FACT, "car")
|
|
assert result == "Tesla Model 3"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMemoryServicePrefetch:
|
|
"""Tests for prefetch_context method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefetch_default_keys(self):
|
|
"""Test prefetch with default profile keys."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
|
mock_profile.side_effect = [
|
|
"Amsterdam", # location
|
|
"Europe/Amsterdam", # timezone
|
|
"John", # name
|
|
]
|
|
mock_prefs.return_value = {"temperature_unit": "celsius"}
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.prefetch_context()
|
|
|
|
assert result["profile"]["location"] == "Amsterdam"
|
|
assert result["profile"]["timezone"] == "Europe/Amsterdam"
|
|
assert result["profile"]["name"] == "John"
|
|
assert result["preferences"]["temperature_unit"] == "celsius"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefetch_specific_keys(self):
|
|
"""Test prefetch with specific profile keys."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
|
mock_profile.return_value = "Amsterdam"
|
|
mock_prefs.return_value = {}
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.prefetch_context(
|
|
profile_keys=["location"],
|
|
include_preferences=False,
|
|
)
|
|
|
|
# Should only fetch location
|
|
mock_profile.assert_called_once()
|
|
mock_prefs.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prefetch_no_profile(self):
|
|
"""Test prefetch without profile data."""
|
|
service = MemoryService()
|
|
|
|
with patch.object(service, "get_profile", new_callable=AsyncMock) as mock_profile:
|
|
with patch.object(service, "get_all_preferences", new_callable=AsyncMock) as mock_prefs:
|
|
mock_prefs.return_value = {"theme": "dark"}
|
|
|
|
with patch("src.core.memory_service.get_user", return_value="testuser"):
|
|
result = await service.prefetch_context(include_profile=False)
|
|
|
|
mock_profile.assert_not_called()
|
|
assert "profile" not in result
|
|
assert result["preferences"]["theme"] == "dark"
|