fleet-memory/hindsight-api/tests/test_mental_models.py
Nicolò Boschi 63a65d0723
feat: improve mental model refresh and add directives (#166)
* feat: improve mental model refresh and add directives

* feat: improve mental model refresh and add directives

* tags

* ui

* fix

* fix

* update

* update
2026-01-19 11:38:35 +01:00

1434 lines
51 KiB
Python

"""Tests for mental model functionality (v4 system)."""
import uuid
import pytest
from hindsight_api.engine.memory_engine import MemoryEngine
@pytest.fixture
async def memory_with_mission(memory: MemoryEngine, request_context):
"""Memory engine with a bank that has a mission set.
Uses a unique bank_id to avoid conflicts between parallel tests.
"""
# Use unique bank_id to avoid conflicts between parallel tests
bank_id = f"test-mental-models-{uuid.uuid4().hex[:8]}"
# Set up the bank with a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Be a PM for the engineering team",
request_context=request_context,
)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "The team has daily standups at 9am where everyone shares their progress."},
{"content": "Alice is the frontend engineer and specializes in React."},
{"content": "Bob is the backend engineer and owns the API services."},
{"content": "Sprint retrospectives happen every two weeks to discuss improvements."},
{"content": "John is the tech lead and makes final decisions on architecture."},
],
request_context=request_context,
)
# Wait for any background tasks from retain to complete
await memory.wait_for_background_tasks()
yield memory, bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestBankMission:
"""Test bank mission operations."""
async def test_set_and_get_mission(self, memory: MemoryEngine, request_context):
"""Test setting and getting a bank's mission."""
bank_id = f"test-mission-{uuid.uuid4().hex[:8]}"
# Set mission
result = await memory.set_bank_mission(
bank_id=bank_id,
mission="Track customer feedback",
request_context=request_context,
)
assert result["bank_id"] == bank_id
assert result["mission"] == "Track customer feedback"
# Get mission via profile
profile = await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
assert profile["mission"] == "Track customer feedback"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestRefreshMentalModels:
"""Test the main refresh_mental_models flow."""
async def test_refresh_creates_structural_models(self, memory_with_mission, request_context):
"""Test that refresh creates structural models from the mission."""
memory, bank_id = memory_with_mission
# Refresh mental models (async - returns operation_id)
result = await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Check that we got an operation ID back
assert "operation_id" in result
assert result["status"] == "queued"
# Wait for background task to complete
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# Check that structural models were created
structural_models = [m for m in models if m["subtype"] == "structural"]
assert len(structural_models) > 0
# Check that models have the expected structure
for model in models:
assert "id" in model
assert "name" in model
assert "description" in model
assert model["subtype"] in ["structural", "emergent"]
async def test_refresh_without_mission_fails(self, memory: MemoryEngine, request_context):
"""Test that refresh fails when no mission is set."""
bank_id = f"test-no-mission-refresh-{uuid.uuid4().hex[:8]}"
# Add some data but don't set a mission
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the frontend engineer."},
{"content": "Bob is the backend engineer."},
],
request_context=request_context,
)
# Wait for any background tasks from retain to complete
await memory.wait_for_background_tasks()
# Refresh mental models should fail without a mission
with pytest.raises(ValueError) as exc_info:
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert "no mission is set" in str(exc_info.value).lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelCRUD:
"""Test basic CRUD operations for mental models."""
async def test_list_mental_models(self, memory_with_mission, request_context):
"""Test listing mental models."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# List all models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# Test filtering by subtype
structural_models = await memory.list_mental_models(
bank_id=bank_id,
subtype="structural",
request_context=request_context,
)
assert all(m["subtype"] == "structural" for m in structural_models)
async def test_get_mental_model(self, memory_with_mission, request_context):
"""Test getting a mental model by ID."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Get one by ID
model_id = models[0]["id"]
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert model is not None
assert model["id"] == model_id
# Test non-existent
not_found = await memory.get_mental_model(
bank_id=bank_id,
model_id="non-existent",
request_context=request_context,
)
assert not_found is None
async def test_delete_mental_model(self, memory_with_mission, request_context):
"""Test deleting a mental model."""
memory, bank_id = memory_with_mission
# Refresh to create models (async)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Delete one
model_id = models[0]["id"]
deleted = await memory.delete_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert deleted is True
# Verify it's gone
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert model is None
# Delete non-existent returns False
deleted_again = await memory.delete_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert deleted_again is False
async def test_create_pinned_mental_model(self, memory: MemoryEngine, request_context):
"""Test creating a pinned mental model."""
bank_id = f"test-pinned-{uuid.uuid4().hex[:8]}"
# Ensure bank exists by getting its profile (auto-creates if needed)
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a pinned mental model
model = await memory.create_mental_model(
bank_id=bank_id,
name="Product Roadmap",
description="Key product priorities and upcoming features",
tags=["project-x"],
request_context=request_context,
)
assert model["name"] == "Product Roadmap"
assert model["description"] == "Key product priorities and upcoming features"
assert model["subtype"] == "pinned"
assert model["tags"] == ["project-x"]
assert model["id"] == "pinned-product-roadmap"
# Verify it can be retrieved
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=model["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "pinned"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_create_pinned_model_duplicate_fails(self, memory: MemoryEngine, request_context):
"""Test that creating a duplicate pinned model fails."""
bank_id = f"test-pinned-dup-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create first model
await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="First model",
request_context=request_context,
)
# Try to create duplicate
with pytest.raises(ValueError) as exc_info:
await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="Second model",
request_context=request_context,
)
assert "already exists" in str(exc_info.value).lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_pinned_models_survive_refresh(self, memory: MemoryEngine, request_context):
"""Test that pinned models are not deleted during refresh."""
bank_id = f"test-pinned-refresh-{uuid.uuid4().hex[:8]}"
# Set a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track customer feedback",
request_context=request_context,
)
# Create a pinned model
pinned_model = await memory.create_mental_model(
bank_id=bank_id,
name="Key Customers",
description="Important customers to track",
request_context=request_context,
)
# Refresh mental models
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Verify pinned model still exists
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=pinned_model["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "pinned"
assert retrieved["name"] == "Key Customers"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefresh:
"""Test mental model summary refresh functionality."""
async def test_refresh_creates_models_with_summaries(self, memory_with_mission, request_context):
"""Test that refresh_mental_models creates models and generates summaries."""
memory, bank_id = memory_with_mission
# Refresh mental models (async - creates models and generates summaries)
result = await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert "operation_id" in result
assert result["status"] == "queued"
# Wait for background task to complete (includes summary generation)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# After async refresh completes, models should have summaries generated
for model in models:
assert "id" in model
assert "name" in model
# Summaries should be generated now (unless no relevant facts found)
# We don't strictly assert on summary presence since it depends on data
async def test_refresh_nonexistent_mental_model(self, memory: MemoryEngine, request_context):
"""Test refreshing a non-existent mental model returns None."""
bank_id = f"test-refresh-noexist-{uuid.uuid4().hex[:8]}"
result = await memory.refresh_mental_model(
bank_id=bank_id,
model_id="does-not-exist",
request_context=request_context,
)
assert result is None
class TestReflect:
"""Test reflect endpoint with mental models."""
async def test_reflect_basic(self, memory_with_mission, request_context):
"""Test basic reflect query - reflect works even without mental models."""
memory, bank_id = memory_with_mission
# Run a reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="Who are the team members?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
class TestMentalModelLearnTool:
"""Test mental model learn tool - creates placeholders with background generation."""
async def test_learn_creates_placeholder(self, memory: MemoryEngine, request_context):
"""Test that learn tool creates a placeholder mental model without observations."""
bank_id = f"test-source-facts-{uuid.uuid4().hex[:8]}"
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the team lead."},
{"content": "Bob is the engineer."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Directly use the learn tool to create a mental model placeholder
from hindsight_api.engine.reflect.models import MentalModelInput
from hindsight_api.engine.reflect.tools import tool_learn
input_model = MentalModelInput(
name="Team Members",
description="Key team members and their roles",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result = await tool_learn(conn, bank_id, input_model)
assert result["status"] == "created"
assert result["model_id"] == "team-members"
assert result["name"] == "Team Members"
assert result["pending_generation"] is True
# Verify placeholder was stored in database with empty observations
pool = await memory._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT subtype, name, description, observations FROM mental_models WHERE id = $1 AND bank_id = $2",
result["model_id"],
bank_id,
)
assert row is not None
assert row["subtype"] == "learned"
assert row["name"] == "Team Members"
assert row["description"] == "Key team members and their roles"
# Observations should be empty - will be generated in background
observations_data = row["observations"]
# Handle both string and dict representations
if isinstance(observations_data, str):
import json
observations_data = json.loads(observations_data) if observations_data else {}
assert observations_data == {} or observations_data is None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_learn_update_description(self, memory: MemoryEngine, request_context):
"""Test that updating a mental model updates the description."""
bank_id = f"test-merge-facts-{uuid.uuid4().hex[:8]}"
# Create bank by retaining some data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Test data"}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
from hindsight_api.engine.reflect.models import MentalModelInput
from hindsight_api.engine.reflect.tools import tool_learn
# First create a placeholder
input_model = MentalModelInput(
name="Team Members",
description="Initial description",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result1 = await tool_learn(conn, bank_id, input_model)
assert result1["status"] == "created"
assert result1["pending_generation"] is True
# Now update with new description
input_model2 = MentalModelInput(
name="Team Members", # Same name = same ID
description="Updated description with more context",
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
result2 = await tool_learn(conn, bank_id, input_model2)
assert result2["status"] == "updated"
assert result2["model_id"] == "team-members"
# Verify description was updated in database
pool = await memory._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT description FROM mental_models WHERE id = $1 AND bank_id = $2",
result1["model_id"],
bank_id,
)
assert row is not None
assert row["description"] == "Updated description with more context"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelTags:
"""Test mental model tags functionality."""
@pytest.fixture
async def memory_with_mission_and_tags(self, memory: MemoryEngine, request_context):
"""Memory engine with a bank that has a mission set and tagged content."""
bank_id = f"test-mm-tags-{uuid.uuid4().hex[:8]}"
# Set up the bank with a mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Be a PM for the engineering team",
request_context=request_context,
)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is the frontend engineer."},
{"content": "Bob is the backend engineer."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
yield memory, bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_refresh_creates_models_with_tags(self, memory_with_mission_and_tags, request_context):
"""Test that refresh_mental_models creates models with specified tags."""
memory, bank_id = memory_with_mission_and_tags
# Refresh mental models with tags
result = await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-alpha", "sprint-1"],
request_context=request_context,
)
assert "operation_id" in result
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
# All models should have the tags we specified
for model in models:
assert "tags" in model
assert "project-alpha" in model["tags"]
assert "sprint-1" in model["tags"]
async def test_list_mental_models_filters_by_tags(self, memory_with_mission_and_tags, request_context):
"""Test that list_mental_models correctly filters by tags."""
memory, bank_id = memory_with_mission_and_tags
# Create models with different tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-alpha"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get all models
all_models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_models) > 0
# Filter by tags - should return models with matching tags
filtered_models = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-alpha"],
request_context=request_context,
)
assert len(filtered_models) == len(all_models) # All models have this tag
# Filter by non-existent tag - should only return untagged models (none here)
# But since all models have tags, and the filter includes untagged,
# we need to test with a mix
empty_filtered = await memory.list_mental_models(
bank_id=bank_id,
tags=["non-existent-tag"],
request_context=request_context,
)
# Should return empty since no models are untagged and none match
# Actually, the logic includes untagged models, so let's verify the behavior
# All our models have tags, so only checking for non-existent tag
# should return nothing (since none match and none are untagged)
async def test_untagged_models_included_in_filter(self, memory: MemoryEngine, request_context):
"""Test that untagged mental models are always included when filtering."""
bank_id = f"test-untagged-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track projects",
request_context=request_context,
)
# Add some data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Project Alpha is important."}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# First refresh without tags (creates untagged models)
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context, # No tags
)
await memory.wait_for_background_tasks()
# Get all models (should be untagged)
all_models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
if len(all_models) > 0:
# Verify models are untagged
for model in all_models:
assert model.get("tags", []) == []
# Filter by any tag - untagged models should still be included
filtered_models = await memory.list_mental_models(
bank_id=bank_id,
tags=["some-tag"],
request_context=request_context,
)
# Untagged models should be included in the results
assert len(filtered_models) == len(all_models)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_tags_match_any(self, memory: MemoryEngine, request_context):
"""Test tags_match='any' returns models with at least one matching tag."""
bank_id = f"test-tags-any-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Track projects",
request_context=request_context,
)
# Add data and create models with tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice works on frontend."}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["tag-a", "tag-b"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Filter with tags_match='any' - should match if any tag matches
models = await memory.list_mental_models(
bank_id=bank_id,
tags=["tag-a", "tag-c"], # tag-a matches, tag-c doesn't
tags_match="any",
request_context=request_context,
)
# Models with tag-a should be included
for model in models:
if model.get("tags"):
# At least one of the filter tags should be in the model tags
# OR model is untagged
assert (
any(t in model["tags"] for t in ["tag-a", "tag-c"])
or model["tags"] == []
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_reflect_with_tags_filter(self, memory_with_mission_and_tags, request_context):
"""Test that reflect filters memories by tags."""
memory, bank_id = memory_with_mission_and_tags
# Create mental models with tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["project-x"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Reflect with matching tags
result = await memory.reflect_async(
bank_id=bank_id,
query="Who are the engineers?",
tags=["project-x"],
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Reflect with non-matching tags - should still work
result2 = await memory.reflect_async(
bank_id=bank_id,
query="Who are the engineers?",
tags=["different-project"],
request_context=request_context,
)
assert result2.text is not None
async def test_mental_model_response_includes_tags(self, memory_with_mission_and_tags, request_context):
"""Test that mental model responses include the tags field."""
memory, bank_id = memory_with_mission_and_tags
# Create models with tags
await memory.refresh_mental_models(
bank_id=bank_id,
tags=["test-tag"],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Verify tags field is present in response
for model in models:
assert "tags" in model
assert isinstance(model["tags"], list)
# Get single model
if models:
model = await memory.get_mental_model(
bank_id=bank_id,
model_id=models[0]["id"],
request_context=request_context,
)
assert "tags" in model
assert isinstance(model["tags"], list)
class TestDirectives:
"""Test directive mental model functionality."""
async def test_create_directive(self, memory: MemoryEngine, request_context):
"""Test creating a directive mental model with user-provided observations."""
bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a directive with observations
model = await memory.create_mental_model(
bank_id=bank_id,
name="Competitor Policy",
description="Rules about mentioning competitors",
subtype="directive",
observations=[
{"title": "Never mention", "content": "Never mention competitor product names directly"},
{"title": "Redirect", "content": "If asked about competitors, redirect to our features"},
],
request_context=request_context,
)
assert model["name"] == "Competitor Policy"
assert model["description"] == "Rules about mentioning competitors"
assert model["subtype"] == "directive"
assert len(model["observations"]) == 2
assert model["observations"][0].title == "Never mention"
assert model["observations"][0].content == "Never mention competitor product names directly"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_included_in_list(self, memory: MemoryEngine, request_context):
"""Test that directives are included in list_mental_models for admin visibility."""
bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Test mission",
request_context=request_context,
)
# Create a directive
directive = await memory.create_mental_model(
bank_id=bank_id,
name="Test Directive",
description="A test directive",
subtype="directive",
observations=[{"title": "Rule", "content": "Follow this rule"}],
request_context=request_context,
)
# Create a pinned model
pinned = await memory.create_mental_model(
bank_id=bank_id,
name="Test Pinned",
description="A test pinned model",
request_context=request_context,
)
# List without subtype filter - both should appear
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
# Both should appear (directives included in API listing for admin visibility)
model_ids = [m["id"] for m in models]
assert pinned["id"] in model_ids
assert directive["id"] in model_ids
# List with directive subtype filter - should find only directive
directives = await memory.list_mental_models(
bank_id=bank_id,
subtype="directive",
request_context=request_context,
)
assert len(directives) == 1
assert directives[0]["id"] == directive["id"]
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context):
"""Test that getting a directive returns its user-provided observations."""
bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a directive with observations
created = await memory.create_mental_model(
bank_id=bank_id,
name="Meeting Rules",
description="Rules for scheduling meetings",
subtype="directive",
observations=[
{"title": "No mornings", "content": "Never schedule meetings before noon"},
{"title": "Max duration", "content": "Meetings should be 30 minutes max"},
],
request_context=request_context,
)
# Get the directive
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=created["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "directive"
assert len(retrieved["observations"]) == 2
assert retrieved["observations"][0].title == "No mornings"
assert retrieved["observations"][1].title == "Max duration"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context):
"""Test that directives are not modified during refresh_mental_models."""
bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}"
# Set up bank with mission
await memory.set_bank_mission(
bank_id=bank_id,
mission="Test mission",
request_context=request_context,
)
# Add some test data
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice is the engineer."}],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Create a directive
directive = await memory.create_mental_model(
bank_id=bank_id,
name="Important Rule",
description="A critical rule",
subtype="directive",
observations=[{"title": "Rule 1", "content": "Always follow this rule"}],
request_context=request_context,
)
# Refresh mental models
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Directive should still exist with same observations
retrieved = await memory.get_mental_model(
bank_id=bank_id,
model_id=directive["id"],
request_context=request_context,
)
assert retrieved is not None
assert retrieved["subtype"] == "directive"
assert len(retrieved["observations"]) == 1
assert retrieved["observations"][0].title == "Rule 1"
assert retrieved["observations"][0].content == "Always follow this rule"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_directive_requires_observations(self, memory: MemoryEngine, request_context):
"""Test that creating a directive without observations fails."""
bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Try to create directive without observations
with pytest.raises(ValueError) as exc_info:
await memory.create_mental_model(
bank_id=bank_id,
name="Bad Directive",
description="A directive without observations",
subtype="directive",
# No observations provided
request_context=request_context,
)
assert "observations" in str(exc_info.value).lower()
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectivesInReflect:
"""Test that directives are followed during reflect operations."""
async def test_reflect_follows_language_directive(self, memory: MemoryEngine, request_context):
"""Test that reflect follows a directive to respond in a specific language."""
bank_id = f"test-directive-reflect-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some content in English
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is a software engineer who works at Google."},
{"content": "Alice enjoys hiking on weekends and has been to Yosemite."},
{"content": "Alice is currently working on a machine learning project."},
],
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Create a directive to always respond in French
await memory.create_mental_model(
bank_id=bank_id,
name="Language Policy",
description="Rules about language usage",
subtype="directive",
observations=[
{
"title": "French Only",
"content": "ALWAYS respond in French language. Never respond in English.",
},
],
request_context=request_context,
)
# Run reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="What does Alice do for work?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Check that the response contains French words/patterns
# Common French words that would appear when talking about someone's job
french_indicators = [
"elle",
"travaille",
"est",
"une",
"le",
"la",
"qui",
"chez",
"logiciel",
"ingénieur",
"ingénieure",
"développeur",
"développeuse",
]
response_lower = result.text.lower()
# At least some French words should appear in the response
french_word_count = sum(1 for word in french_indicators if word in response_lower)
assert (
french_word_count >= 2
), f"Expected French response, but got: {result.text[:200]}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelTagsFiltering:
"""Test tags filtering for mental models (all types)."""
async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context):
"""Test that 'any' tags_match mode includes untagged mental models."""
bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create an UNTAGGED pinned model
await memory.create_mental_model(
bank_id=bank_id,
name="Global Model",
description="A global mental model",
subtype="pinned",
tags=[], # No tags - should be included with "any" mode
request_context=request_context,
)
# Test 1: list_mental_models with tags and tags_match="any" should include untagged
models_any = await memory.list_mental_models(
bank_id=bank_id,
tags=["some-tag"],
tags_match="any", # Should include untagged
request_context=request_context,
)
assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}"
# Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged
models_strict = await memory.list_mental_models(
bank_id=bank_id,
tags=["some-tag"],
tags_match="any_strict", # Should exclude untagged
request_context=request_context,
)
assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context):
"""Test that strict modes only include mental models with matching tags."""
bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a TAGGED pinned model
await memory.create_mental_model(
bank_id=bank_id,
name="Tagged Model",
description="A tagged mental model",
subtype="pinned",
tags=["project-a"],
request_context=request_context,
)
# Create an UNTAGGED pinned model
await memory.create_mental_model(
bank_id=bank_id,
name="Untagged Model",
description="An untagged mental model",
subtype="pinned",
tags=[], # No tags
request_context=request_context,
)
# Test 1: any_strict with matching tag - should get ONLY the tagged model
models_match = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-a"],
tags_match="any_strict",
request_context=request_context,
)
assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}"
assert models_match[0]["name"] == "Tagged Model"
# Test 2: any_strict with different tag - should get NO models
models_no_match = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-b"],
tags_match="any_strict",
request_context=request_context,
)
assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}"
# Test 3: any (non-strict) with any tag - should get BOTH models
models_any = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-a"],
tags_match="any",
request_context=request_context,
)
assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context):
"""Test that 'all_strict' requires ALL tags to be present."""
bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create a model with multiple tags
await memory.create_mental_model(
bank_id=bank_id,
name="Multi-Tag Model",
description="Has project-a and project-b tags",
subtype="pinned",
tags=["project-a", "project-b"],
request_context=request_context,
)
# Create a model with only one tag
await memory.create_mental_model(
bank_id=bank_id,
name="Single-Tag Model",
description="Has only project-a tag",
subtype="pinned",
tags=["project-a"],
request_context=request_context,
)
# Test 1: all_strict with both tags - should get ONLY the multi-tag model
models_all = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-a", "project-b"],
tags_match="all_strict",
request_context=request_context,
)
assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}"
assert models_all[0]["name"] == "Multi-Tag Model"
# Test 2: all (non-strict) with both tags - should include untagged too
# Add an untagged model
await memory.create_mental_model(
bank_id=bank_id,
name="Untagged Model",
description="No tags",
subtype="pinned",
tags=[],
request_context=request_context,
)
models_all_non_strict = await memory.list_mental_models(
bank_id=bank_id,
tags=["project-a", "project-b"],
tags_match="all",
request_context=request_context,
)
# Should get Multi-Tag Model + Untagged Model
assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestDirectivesPromptInjection:
"""Test that directives are properly injected into the system prompt."""
def test_build_directives_section_empty(self):
"""Test that empty directives returns empty string."""
from hindsight_api.engine.reflect.prompts import build_directives_section
result = build_directives_section([])
assert result == ""
def test_build_directives_section_with_observations(self):
"""Test that directives with observations are formatted correctly."""
from hindsight_api.engine.reflect.prompts import build_directives_section
directives = [
{
"name": "Competitor Policy",
"observations": [
{"title": "Never mention", "content": "Never mention competitor names"},
{"title": "Redirect", "content": "Redirect to our features"},
],
}
]
result = build_directives_section(directives)
assert "## DIRECTIVES (MANDATORY)" in result
assert "**Never mention**: Never mention competitor names" in result
assert "**Redirect**: Redirect to our features" in result
assert "NEVER violate these directives" in result
def test_build_directives_section_fallback_to_description(self):
"""Test that directives without observations fall back to description."""
from hindsight_api.engine.reflect.prompts import build_directives_section
directives = [
{
"name": "Simple Rule",
"description": "Just a simple rule description",
"observations": [],
}
]
result = build_directives_section(directives)
assert "**Simple Rule**: Just a simple rule description" in result
def test_system_prompt_includes_directives(self):
"""Test that build_system_prompt_for_tools includes directives."""
from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools
bank_profile = {"name": "Test Bank", "mission": "Test mission"}
directives = [
{
"name": "Test Directive",
"observations": [{"title": "Rule", "content": "Follow this rule"}],
}
]
prompt = build_system_prompt_for_tools(
bank_profile=bank_profile,
directives=directives,
)
assert "## DIRECTIVES (MANDATORY)" in prompt
assert "**Rule**: Follow this rule" in prompt
# Directives should appear before CRITICAL RULES
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
class TestMentalModelVersioning:
"""Test mental model versioning functionality."""
async def test_refresh_creates_version(self, memory_with_mission, request_context):
"""Test that refreshing a mental model creates a version entry."""
memory, bank_id = memory_with_mission
# First create a mental model via refresh_mental_models
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Get the created models
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
model_id = models[0]["id"]
# Refresh the specific model to trigger versioning
result = await memory.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert result is not None
# Version should be incremented
assert result.get("version", 0) >= 1
# Check version history
versions = await memory.get_mental_model_versions(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert len(versions) >= 1
assert versions[0]["version"] >= 1
assert "created_at" in versions[0]
assert "observation_count" in versions[0]
async def test_get_specific_version(self, memory_with_mission, request_context):
"""Test retrieving a specific version of a mental model."""
memory, bank_id = memory_with_mission
# Create and refresh a mental model
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
model_id = models[0]["id"]
# Refresh to create version
await memory.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
# Get versions
versions = await memory.get_mental_model_versions(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
assert len(versions) >= 1
# Get specific version
version_num = versions[0]["version"]
version_data = await memory.get_mental_model_version(
bank_id=bank_id,
model_id=model_id,
version=version_num,
request_context=request_context,
)
assert version_data is not None
assert version_data["version"] == version_num
assert "observations" in version_data
async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context):
"""Test that old versions are cleaned up when max is exceeded."""
memory, bank_id = memory_with_mission
# Create a mental model
await memory.refresh_mental_models(
bank_id=bank_id,
request_context=request_context,
)
await memory.wait_for_background_tasks()
models = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
assert len(models) > 0
model_id = models[0]["id"]
# Refresh multiple times to create versions
for _ in range(3):
await memory.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
# Get versions - should have multiple but within max limit
versions = await memory.get_mental_model_versions(
bank_id=bank_id,
model_id=model_id,
request_context=request_context,
)
# Should have versions (exact count depends on config, but at least some)
assert len(versions) >= 1
# Versions should be in descending order
if len(versions) > 1:
assert versions[0]["version"] > versions[1]["version"]