"""Tests for mental models (formerly reflections), observations, and learnings functionality.""" import uuid import pytest import pytest_asyncio import httpx from hindsight_api.api import create_app from hindsight_api.engine.memory_engine import MemoryEngine @pytest_asyncio.fixture async def api_client(memory): """Create an async test client for the FastAPI app.""" app = create_app(memory, initialize_memory=False) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: yield client @pytest.fixture def test_bank_id(): """Provide a unique bank ID for this test run.""" return f"test_mental_models_{uuid.uuid4().hex[:8]}" class TestMentalModelsCRUD: """Test mental models CRUD operations via memory engine.""" @pytest.mark.asyncio async def test_create_and_get_mental_model(self, memory: MemoryEngine, request_context): """Test creating and retrieving a mental model.""" bank_id = f"test-mental-model-{uuid.uuid4().hex[:8]}" # Create the bank first await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model mental_model = await memory.create_mental_model( bank_id=bank_id, name="Team Preferences", source_query="What are the team's communication preferences?", content="The team prefers async communication via Slack", tags=["team"], request_context=request_context, ) assert mental_model["name"] == "Team Preferences" assert mental_model["source_query"] == "What are the team's communication preferences?" assert mental_model["content"] == "The team prefers async communication via Slack" assert mental_model["tags"] == ["team"] assert "id" in mental_model # Get the mental model fetched = await memory.get_mental_model( bank_id=bank_id, mental_model_id=mental_model["id"], request_context=request_context, ) assert fetched["id"] == mental_model["id"] assert fetched["name"] == "Team Preferences" # Cleanup await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_list_mental_models(self, memory: MemoryEngine, request_context): """Test listing mental models with filters.""" bank_id = f"test-mental-model-list-{uuid.uuid4().hex[:8]}" # Create the bank first await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create multiple mental models await memory.create_mental_model( bank_id=bank_id, name="Mental Model 1", source_query="Query 1", content="Content 1", tags=["tag1"], request_context=request_context, ) await memory.create_mental_model( bank_id=bank_id, name="Mental Model 2", source_query="Query 2", content="Content 2", tags=["tag2"], request_context=request_context, ) # List all all_mental_models = await memory.list_mental_models( bank_id=bank_id, request_context=request_context, ) assert len(all_mental_models) == 2 # List with tag filter tag1_mental_models = await memory.list_mental_models( bank_id=bank_id, tags=["tag1"], request_context=request_context, ) assert len(tag1_mental_models) == 1 # Cleanup await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_update_mental_model(self, memory: MemoryEngine, request_context): """Test updating a mental model.""" bank_id = f"test-mental-model-update-{uuid.uuid4().hex[:8]}" # Create the bank first await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model mental_model = await memory.create_mental_model( bank_id=bank_id, name="Original Name", source_query="Original Query", content="Original Content", request_context=request_context, ) # Update the mental model updated = await memory.update_mental_model( bank_id=bank_id, mental_model_id=mental_model["id"], name="Updated Name", content="Updated Content", request_context=request_context, ) assert updated["name"] == "Updated Name" assert updated["content"] == "Updated Content" # Cleanup await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_delete_mental_model(self, memory: MemoryEngine, request_context): """Test deleting a mental model.""" bank_id = f"test-mental-model-delete-{uuid.uuid4().hex[:8]}" # Create the bank first await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model mental_model = await memory.create_mental_model( bank_id=bank_id, name="To Delete", source_query="Query", content="Content", request_context=request_context, ) # Delete the mental model await memory.delete_mental_model( bank_id=bank_id, mental_model_id=mental_model["id"], request_context=request_context, ) # Verify deletion - should return None fetched = await memory.get_mental_model( bank_id=bank_id, mental_model_id=mental_model["id"], request_context=request_context, ) assert fetched is None # Cleanup await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_create_mental_model_with_custom_id(self, memory: MemoryEngine, request_context): """Test creating a mental model with a custom ID.""" bank_id = f"test-mental-model-custom-id-{uuid.uuid4().hex[:8]}" # Create the bank first await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model with a custom ID custom_id = "team-communication-preferences" mental_model = await memory.create_mental_model( bank_id=bank_id, mental_model_id=custom_id, name="Team Communication Preferences", source_query="How does the team prefer to communicate?", content="The team prefers async communication via Slack", tags=["team", "communication"], request_context=request_context, ) # Verify the custom ID was used assert mental_model["id"] == custom_id assert mental_model["name"] == "Team Communication Preferences" assert mental_model["tags"] == ["team", "communication"] # Verify we can retrieve it with the custom ID fetched = await memory.get_mental_model( bank_id=bank_id, mental_model_id=custom_id, request_context=request_context, ) assert fetched is not None assert fetched["id"] == custom_id assert fetched["name"] == "Team Communication Preferences" # Cleanup await memory.delete_bank(bank_id, request_context=request_context) class TestObservationsAPI: """Test observations API endpoints. NOTE: Observations are now stored in memory_units with fact_type='observation' and accessed via recall with fact_type=["observation"]. The old /observations endpoint was removed. These tests are skipped. """ @pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']") @pytest.mark.asyncio async def test_list_observations_empty(self, api_client, test_bank_id): """Test listing observations when none exist.""" pass @pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']") @pytest.mark.asyncio async def test_get_observation_not_found(self, api_client, test_bank_id): """Test getting a non-existent observation.""" pass class TestMentalModelsAPI: """Test mental models API endpoints.""" @pytest.mark.asyncio async def test_mental_models_api_crud(self, api_client, test_bank_id): """Test full CRUD cycle through API.""" import asyncio # Create bank first via profile endpoint await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") # Create a mental model (async operation) response = await api_client.post( f"/v1/default/banks/{test_bank_id}/mental-models", json={ "name": "API Test Mental Model", "source_query": "What is the API test about?", "content": "This is an API test mental model", "tags": ["api-test"], }, ) assert response.status_code == 200 create_result = response.json() assert "operation_id" in create_result operation_id = create_result["operation_id"] # Wait for the async operation to complete for _ in range(30): # Wait up to 30 seconds response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}") if response.status_code == 200: op_status = response.json() if op_status.get("status") == "completed": break await asyncio.sleep(1) # List mental models to get the created mental model response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models") assert response.status_code == 200 mental_models = response.json()["items"] assert len(mental_models) >= 1 # Find our mental model mental_model = next((m for m in mental_models if m["name"] == "API Test Mental Model"), None) assert mental_model is not None, f"Mental model not found. Items: {mental_models}" mental_model_id = mental_model["id"] # Get the mental model response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}") assert response.status_code == 200 assert response.json()["name"] == "API Test Mental Model" # Update the mental model response = await api_client.patch( f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}", json={"name": "Updated API Test Mental Model"}, ) assert response.status_code == 200 assert response.json()["name"] == "Updated API Test Mental Model" # Delete the mental model response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}") assert response.status_code == 200 # Verify deletion response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}") assert response.status_code == 404 # Cleanup await api_client.delete(f"/v1/default/banks/{test_bank_id}") class TestRecallWithObservationsAndMentalModels: """Test recall integration with observations and mental models.""" @pytest.mark.asyncio async def test_recall_includes_observations(self, api_client, test_bank_id): """Test that recall can include observations in the response.""" # Create bank first via profile endpoint await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") # Note: Observations are auto-created via consolidation, not manually # This test just verifies the include parameter works # Recall with observations included response = await api_client.post( f"/v1/default/banks/{test_bank_id}/memories/recall", json={ "query": "What is machine learning?", "include": { "observations": {"max_results": 5}, }, }, ) assert response.status_code == 200 result = response.json() # Should have observations field in response (may be empty) assert "observations" in result or result.get("observations") is None # Cleanup await api_client.delete(f"/v1/default/banks/{test_bank_id}") @pytest.mark.asyncio async def test_recall_includes_mental_models(self, api_client, test_bank_id): """Test that recall can include mental models in the response.""" # Create bank first via profile endpoint await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") # Create a mental model first response = await api_client.post( f"/v1/default/banks/{test_bank_id}/mental-models", json={ "name": "AI Overview", "source_query": "What is AI?", "content": "Artificial intelligence is the simulation of human intelligence", "tags": [], }, ) assert response.status_code == 200 # Recall with mental models included response = await api_client.post( f"/v1/default/banks/{test_bank_id}/memories/recall", json={ "query": "What is artificial intelligence?", "include": { "mental_models": {"max_results": 5}, }, }, ) assert response.status_code == 200 result = response.json() # Should have mental_models in response (may be empty if embedding not generated yet) assert "mental_models" in result or result.get("mental_models") is None # Cleanup await api_client.delete(f"/v1/default/banks/{test_bank_id}") @pytest.mark.asyncio async def test_recall_without_observations_by_default(self, api_client, test_bank_id): """Test that recall does not include observations by default.""" # Create bank first via profile endpoint await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") # Recall without specifying observations response = await api_client.post( f"/v1/default/banks/{test_bank_id}/memories/recall", json={ "query": "Test query", }, ) assert response.status_code == 200 result = response.json() # Observations should not be in response assert result.get("observations") is None # Cleanup await api_client.delete(f"/v1/default/banks/{test_bank_id}") class TestReflectUsesMentalModels: """Test that reflect searches and uses mental models when available.""" @pytest.mark.asyncio async def test_reflect_searches_mental_models_when_available(self, memory: MemoryEngine, request_context): """Test that reflect uses search_mental_models when the bank has mental models. Given: - A bank with a mental model about "team collaboration" Expected: - Reflect should call search_mental_models tool - The mental model content should influence the response """ bank_id = f"test-reflect-mm-{uuid.uuid4().hex[:8]}" # Create the bank await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model about team collaboration mental_model = await memory.create_mental_model( bank_id=bank_id, mental_model_id=str(uuid.uuid4()), name="Team Collaboration Practices", source_query="How does the team collaborate?", content="The team uses async communication via Slack and holds daily standups at 9am. " "Code reviews are required before merging. The team values documentation and " "prefers written communication for complex decisions.", tags=["team"], request_context=request_context, ) # Run reflect with a query about team collaboration result = await memory.reflect_async( bank_id=bank_id, query="How does the team work together?", request_context=request_context, ) # Check that mental models were searched tool_calls = result.tool_trace search_mm_calls = [tc for tc in tool_calls if tc.tool == "search_mental_models"] assert len(search_mm_calls) > 0, ( f"Expected search_mental_models to be called when bank has mental models. " f"Tool calls: {[tc.tool for tc in tool_calls]}" ) # Check that the reason field is populated for debugging for tc in search_mm_calls: assert tc.reason is not None, "Tool call should have a reason for debugging" # The response should mention concepts from the mental model response_text = result.text.lower() has_relevant_content = any( keyword in response_text for keyword in ["slack", "async", "standup", "code review", "documentation", "communication"] ) assert has_relevant_content, ( f"Expected response to reference mental model content. Got: {result.text[:500]}" ) # Cleanup await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_reflect_tool_trace_includes_reason(self, memory: MemoryEngine, request_context): """Test that tool traces include the reason field for debugging.""" bank_id = f"test-reflect-reason-{uuid.uuid4().hex[:8]}" # Create the bank await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Run reflect - it should use observations or recall result = await memory.reflect_async( bank_id=bank_id, query="What is the weather like?", request_context=request_context, ) # All tool calls should have a reason for tc in result.tool_trace: if tc.tool != "done": # done doesn't need a reason assert tc.reason is not None, f"Tool {tc.tool} should have a reason for debugging" # Cleanup await memory.delete_bank(bank_id, request_context=request_context) class TestMentalModelReflectOptions: """Tests for fact_types and exclude_mental_models options stored in the trigger field.""" @pytest.mark.asyncio async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context): """Trigger field persists fact_types and returns them via get_mental_model.""" bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) mm = await memory.create_mental_model( bank_id=bank_id, name="Observations only", source_query="Summarize observations", content="content", trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]}, request_context=request_context, ) fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) assert fetched["trigger"]["fact_types"] == ["observation"] assert fetched["trigger"]["refresh_after_consolidation"] is False await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context): """Trigger field persists exclude_mental_models flag.""" bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) mm = await memory.create_mental_model( bank_id=bank_id, name="No mental models", source_query="Summarize raw facts", content="content", trigger={"refresh_after_consolidation": False, "exclude_mental_models": True}, request_context=request_context, ) fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) assert fetched["trigger"]["exclude_mental_models"] is True await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context): """Trigger field persists exclude_mental_model_ids list.""" bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) excluded_ids = ["mm-abc", "mm-xyz"] mm = await memory.create_mental_model( bank_id=bank_id, name="Exclude some models", source_query="Summarize", content="content", trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids}, request_context=request_context, ) fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context): """update_mental_model persists updated trigger reflect options.""" bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) mm = await memory.create_mental_model( bank_id=bank_id, name="Initially no filter", source_query="Summarize", content="content", trigger={"refresh_after_consolidation": False}, request_context=request_context, ) updated = await memory.update_mental_model( bank_id=bank_id, mental_model_id=mm["id"], trigger={ "refresh_after_consolidation": True, "fact_types": ["world", "experience"], "exclude_mental_models": False, "exclude_mental_model_ids": ["mm-skip"], }, request_context=request_context, ) assert updated["trigger"]["refresh_after_consolidation"] is True assert updated["trigger"]["fact_types"] == ["world", "experience"] assert updated["trigger"]["exclude_mental_models"] is False assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"] await memory.delete_bank(bank_id, request_context=request_context) class TestReflectFactTypeFiltering: """Tests for fact_types and exclude_mental_models filtering in reflect_async.""" @pytest.mark.asyncio async def test_exclude_mental_models_skips_search_mental_models_tool( self, memory: MemoryEngine, request_context ): """When exclude_mental_models=True, search_mental_models is never called.""" bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) # Create a mental model so the bank has one await memory.create_mental_model( bank_id=bank_id, name="Existing Model", source_query="Q", content="Some content about the team", request_context=request_context, ) result = await memory.reflect_async( bank_id=bank_id, query="Tell me about the team", request_context=request_context, exclude_mental_models=True, ) tool_names = [tc.tool for tc in result.tool_trace] assert "search_mental_models" not in tool_names, ( f"search_mental_models should be excluded but found in: {tool_names}" ) await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context): """When fact_types excludes observation, search_observations is never called.""" bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) result = await memory.reflect_async( bank_id=bank_id, query="Tell me something", request_context=request_context, fact_types=["world", "experience"], ) tool_names = [tc.tool for tc in result.tool_trace] assert "search_observations" not in tool_names, ( f"search_observations should be excluded but found in: {tool_names}" ) await memory.delete_bank(bank_id, request_context=request_context) @pytest.mark.asyncio async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context): """When fact_types=['observation'], recall is never called.""" bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) result = await memory.reflect_async( bank_id=bank_id, query="Tell me something", request_context=request_context, fact_types=["observation"], ) tool_names = [tc.tool for tc in result.tool_trace] assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}" await memory.delete_bank(bank_id, request_context=request_context) class TestReflectRequestValidation: """Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API.""" @pytest.mark.asyncio async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id): """Passing fact_types=[] to reflect must return 422.""" await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") response = await api_client.post( f"/v1/default/banks/{test_bank_id}/reflect", json={"query": "test", "fact_types": []}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id): """Passing fact_types=[] inside trigger must return 422.""" await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") response = await api_client.post( f"/v1/default/banks/{test_bank_id}/mental-models", json={ "name": "Test", "source_query": "Q", "trigger": {"refresh_after_consolidation": False, "fact_types": []}, }, ) assert response.status_code == 422