fleet-memory/hindsight-api/tests/test_observations.py
Nicolò Boschi 8d731f2e5f
feat: implement hierarchical configuration (system, tenant, bank) (#329)
* feat: implement hierarchical configuration (system, tenant, bank)

* feat: implement hierarchical configuration (system, tenant, bank)

* docs: add instructions for hierarchical config in CLAUDE.md

* feat: add ENABLE_BANK_CONFIG_API flag (disabled by default)

- Add HINDSIGHT_API_ENABLE_BANK_CONFIG_API env var (default: false)
- Return 403 Forbidden from bank config endpoints when disabled
- Update tests to enable the flag
- Update CLAUDE.md documentation

This provides security control over the bank configuration API,
ensuring it's only accessible when explicitly enabled.

* docs: add hierarchical configuration section

* feat(cli): add bank config commands (config, set-config, reset-config)

- Add 'hindsight bank config' to view bank configuration
- Add 'hindsight bank set-config' to update LLM settings per bank
- Add 'hindsight bank reset-config' to reset to defaults
- Implements client API calls to new bank config endpoints

* fix(cli): fix compilation errors in bank config commands

- Fix type signature: use ApiClient instead of api::Client
- Fix confirmation: use ui::prompt_confirmation instead of ui::confirm
- Fix error handling: use anyhow! macro instead of errors::Error
- Fix type conversion: convert HashMap to serde_json::Map for API call

* feat: implement type-safe hierarchical config with bank overrides

Implements a production-ready hierarchical configuration system that prevents
accidentally using global defaults when bank-specific overrides exist.

- Created StaticConfigProxy that wraps HindsightConfig
- get_config() now returns proxy that blocks access to bank-configurable fields
- Raises ConfigFieldAccessError with clear message when accessing configurable fields
- Added _get_raw_config() for internal use only
- Forces developers to use resolve_full_config(bank_id, context) for bank settings

- Added resolve_full_config() method that returns complete HindsightConfig
- Resolves hierarchy: Global (env) → Tenant → Bank
- No caching to support multi-server deployments (always fresh from DB)
- LLM provider pooling handles expensive operations separately

- Updated entire retain pipeline to pass resolved config through call chain
- memory_engine.py: Resolves config at top level where bank_id/context available
- orchestrator.py: Accepts and passes config to fact_extraction
- fact_extraction.py: Uses passed config instead of get_config()
- utils.py: Added optional config param for backward compatibility

- consolidator.py: Uses resolve_full_config() for enable_observations check
- memory_engine.py: Resolves config before triggering consolidation

- Renamed "Memory Bank" to "Bank Configuration" with tabs
- Combined Stats and Operations into "General" tab
- Consolidated Profile and Configuration into "Configuration" tab
- Moved Actions dropdown to page level (outside tabs)

- Created new component for managing bank-specific config
- Displays configurable fields: retain_chunk_size, retain_extraction_mode, etc.
- Edit via dialog with form validation
- Reset to defaults via AlertDialog confirmation
- Shows field IDs in monospace for clarity
- Visual separation with borders and hover effects

- Removed inline edit mode, switched to dialog-based editing
- Separate dialogs for Disposition and Mission editing
- Read-only display with clear edit buttons
- Removed duplicate stats cards and operations

- bank-stats-view.tsx: Overview statistics (memories, links, documents, pending ops)
- bank-operations-view.tsx: Background operations table with filtering

**Problem**: Consolidation always used global enable_observations, ignoring bank overrides
**Root Cause**: consolidator.py called get_config() instead of resolving bank-specific config
**Solution**: Pass resolved config through the entire pipeline

**Problem**: asyncpg returning JSONB as JSON string instead of parsed dict
**Solution**: Explicit JSON parsing in config_resolver.py with type checking

- All 19 API integration tests pass
- All 10 hierarchical config tests pass
- Retain operations work correctly with bank-specific config
- Consolidation respects bank-specific enable_observations setting

- Updated developer/configuration.md with type-safe config access pattern
- Added examples showing correct usage patterns
- Documented ConfigFieldAccessError and resolution methods

- get_config() now returns StaticConfigProxy (blocks configurable field access)
- Code accessing bank-configurable fields must use resolve_full_config()
- Clear migration path with helpful error messages

Fixes hierarchical configuration to be production-ready with proper type safety.

* refactor: remove LLM client pool and simplify config resolver

Since LLM config (provider, model, api_key) is now static and not
bank-configurable, the LLMClientPool is no longer needed.

Changes:
- Remove hindsight_api/llm_client_pool.py (no longer needed)
- Remove memory_engine._get_bank_llm_config() (dead code, never called)
- Simplify config_resolver.py by eliminating duplication between
  resolve_full_config() and get_bank_config()
- get_bank_config() now calls resolve_full_config() and filters results
- Remove outdated "LLM provider pooling" comments from docstrings

All tests pass (10 hierarchical config tests, 19 API integration tests)

* fix: update tests to use _get_raw_config() for configurable fields

Fixed test fixtures that were accessing configurable fields (like
enable_observations) from get_config(), which now raises
ConfigFieldAccessError due to type-safe config access.

Changes:
- test_consolidation.py: Changed enable_observations fixture to use
  _get_raw_config() instead of get_config()
- test_consolidation.py: Updated test_consolidation_returns_disabled_status
  to set bank config instead of mocking get_config()
- test_link_expansion_retrieval.py: Changed fixture to use _get_raw_config()
- test_observations.py: Changed disable_observations fixture to use
  _get_raw_config()
- Regenerated OpenAPI spec and clients

All 39 previously failing tests now pass.

* fix: add missing config parameter to test calls of extract_facts_from_text()

Fixed 45 test failures where tests were calling extract_facts_from_text()
without the new required config parameter.

Changes:
- Added config=_get_raw_config() to all extract_facts_from_text() calls
- Fixed test_main_module.py to patch _get_raw_config instead of get_config
- Updated 6 test files with 37 function call sites

All tests should now pass.

* fix: add missing config parameter to test_skip_podcast_meta_commentary

One more test was missing the config parameter for extract_facts_from_text().
2026-02-12 13:14:57 +01:00

443 lines
17 KiB
Python

"""
Test observation generation and entity state functionality.
NOTE: Observations are now stored as summaries on the entities table,
not as separate memory_units. The observations list in EntityState is
populated from the summary for backwards compatibility.
"""
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
from hindsight_api.config import _get_raw_config
from datetime import datetime, timezone
@pytest.fixture
def disable_observations():
"""Disable observations for a specific test."""
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = False
yield
config.enable_observations = original_value
@pytest.mark.asyncio
async def test_entity_extraction_on_retain(memory, request_context):
"""
Test that entities are extracted when new facts are added.
This test stores multiple facts and verifies entities are extracted.
"""
bank_id = f"test_entity_extraction_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple facts about John
contents = [
"John is a software engineer at Google.",
"John is detail-oriented and methodical in his work.",
"John has been working on the AI team for 3 years.",
"John specializes in machine learning and deep learning.",
"John presented at the company conference last week.",
"John mentors junior engineers on the team.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the John entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
entity_row = await conn.fetchrow(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%john%'
LIMIT 1
""",
bank_id
)
# Check the fact count for this entity
if entity_row:
fact_count = await conn.fetchval(
"""
SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1
""",
entity_row['id']
)
print(f"\n=== Entity Facts ===")
print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts")
assert entity_row is not None, "John entity should have been extracted"
print(f"\n=== Found Entity ===")
print(f"Entity: {entity_row['canonical_name']} (id: {entity_row['id']})")
print(f"Entity was successfully extracted")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_search_with_include_entities(memory, request_context):
"""
Test that recall accepts include_entities parameter for backwards compatibility.
Note: Entity observations have been deprecated. This test verifies the parameter
is still accepted without errors.
"""
bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}"
try:
# Store facts about Alice
contents = [
"Alice is a data scientist who works on recommendation systems at Netflix.",
"Alice presented her research at the ML conference last month.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Search with include_entities=True (should be accepted for backwards compatibility)
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
fact_type=["world", "experience"],
budget=Budget.LOW,
max_tokens=2000,
include_entities=True,
max_entity_tokens=5000,
request_context=request_context,
)
# Verify recall works
assert len(result.results) > 0, "Should find some facts"
print(f"Found {len(result.results)} facts")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context, disable_observations):
"""
Test that when observations are disabled, no observation records are created.
When enable_observations=False, consolidation does not run and no
memory_units with fact_type='observation' should exist.
"""
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
try:
# Store facts
await memory.retain_async(
bank_id=bank_id,
content="Charlie is a DevOps engineer who manages the Kubernetes infrastructure.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Check that NO observations exist in memory_units
pool = await memory._get_pool()
async with pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, fact_type, context
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id
)
print(f"\n=== Observation Records in memory_units ===")
print(f"Found {len(observations)} observation records (should be 0)")
# Observations are no longer stored as memory_units
assert len(observations) == 0, "Observations should NOT be stored as memory_units"
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_entity_mention_counts(memory, request_context):
"""
Test that entity mention counts are tracked correctly.
This test creates entities with varying mention counts and verifies
that the counts are accurate.
"""
bank_id = f"test_mention_counts_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content with varying entity mention counts:
# - "HighMention Corp" mentioned 10+ times
# - "LowMention Ltd" mentioned 1 time
contents = [
# High mentions - HighMention Corp
"HighMention Corp is a tech company based in San Francisco.",
"HighMention Corp was founded in 2010 by experienced entrepreneurs.",
"HighMention Corp has over 500 employees worldwide.",
"HighMention Corp specializes in cloud computing solutions.",
"HighMention Corp recently raised $50 million in Series C funding.",
"HighMention Corp has partnerships with major tech companies.",
"HighMention Corp is known for its innovative culture.",
"HighMention Corp offers competitive salaries and benefits.",
"HighMention Corp has offices in 5 countries.",
"HighMention Corp won the best workplace award last year.",
# Low mentions - LowMention Ltd
"LowMention Ltd is a small consulting firm.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="company info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Check entity mention counts
pool = await memory._get_pool()
async with pool.acquire() as conn:
entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name, e.mention_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY e.mention_count DESC
""",
bank_id
)
print(f"\n=== Entity Mention Counts Test ===")
print(f"Total entities: {len(entities)}")
high_mention_entity = None
low_mention_entity = None
for entity in entities:
name = entity['canonical_name'].lower()
mention_count = entity['mention_count']
print(f" {entity['canonical_name']}: mentions={mention_count}")
if "highmention" in name:
high_mention_entity = entity
elif "lowmention" in name:
low_mention_entity = entity
# Verify HighMention Corp has higher mention count
if high_mention_entity and low_mention_entity:
assert high_mention_entity['mention_count'] > low_mention_entity['mention_count'], \
"HighMention Corp should have more mentions than LowMention Ltd"
print("PASS: Entity mention counts are tracked correctly")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_entity_mention_ranking(memory, request_context):
"""
Test that entity mention counts correctly rank entities.
This test:
1. Creates an entity with 6 mentions
2. Adds more entities with higher mention counts
3. Verifies entities are ranked correctly by mention count
"""
bank_id = f"test_ranking_{datetime.now(timezone.utc).timestamp()}"
try:
# Phase 1: Create "OriginalEntity" with 6 mentions
print("\n=== Phase 1: Create OriginalEntity with 6 mentions ===")
for i in range(6):
await memory.retain_async(
bank_id=bank_id,
content=f"OriginalEntity is mentioned here in fact {i+1}.",
context="test",
event_date=datetime(2024, 1, 1 + i, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 2: Add more entities with MORE mentions
print("\n=== Phase 2: Add entities with 10+ mentions each ===")
for entity_num in range(3): # Reduced from 10 to 3 to speed up test
entity_name = f"NewEntity{entity_num}"
for mention in range(10):
await memory.retain_async(
bank_id=bank_id,
content=f"{entity_name} is a very important entity, mention {mention+1}.",
context="test",
event_date=datetime(2024, 2, 1 + mention, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Phase 3: Verify entities are ranked by mention count
print("\n=== Phase 3: Check entity ranking ===")
pool = await memory._get_pool()
async with pool.acquire() as conn:
all_entities = await conn.fetch(
"""
SELECT canonical_name, mention_count
FROM entities
WHERE bank_id = $1
ORDER BY mention_count DESC
""",
bank_id
)
print(f"\nAll entities by mention count:")
for e in all_entities:
print(f" {e['canonical_name']}: mentions={e['mention_count']}")
# Verify new entities have higher counts than OriginalEntity
original = next((e for e in all_entities if 'originalentity' in e['canonical_name'].lower()), None)
new_entities = [e for e in all_entities if 'newentity' in e['canonical_name'].lower()]
assert original is not None, "OriginalEntity should exist"
assert len(new_entities) > 0, "NewEntity entities should exist"
# Verify entities are created and have mention counts
# Note: LLM may merge mentions, so we just check that new entities exist
print(f"OriginalEntity mentions: {original['mention_count']}")
for new_entity in new_entities:
print(f"{new_entity['canonical_name']} mentions: {new_entity['mention_count']}")
print("PASS: Entities are created with mention counts tracked")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_user_entity_extraction(memory, request_context):
"""
Test that the 'user' entity is correctly extracted when mentioned frequently.
"""
bank_id = f"test_user_entity_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' is mentioned many times
contents = [
"The user loves hiking in the mountains during summer.",
"The user works as a software engineer at Microsoft.",
"The user has a dog named Max who is a golden retriever.",
"The user enjoys cooking Italian food, especially pasta.",
"The user graduated from MIT with a Computer Science degree.",
"The user's favorite book is 'Dune' by Frank Herbert.",
# Other entities mentioned fewer times
"Sarah is a friend who works at Google.",
"Bob is a colleague from the data science team.",
]
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="personal info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
)
# Wait for background tasks
await memory.wait_for_background_tasks()
# Find the 'user' entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
user_entity = await conn.fetchrow(
"""
SELECT e.id, e.canonical_name,
(SELECT COUNT(*) FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count
FROM entities e
WHERE e.bank_id = $1
AND LOWER(e.canonical_name) LIKE '%user%'
LIMIT 1
""",
bank_id
)
# Get all entities with their fact counts
all_entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name,
(SELECT COUNT(*) FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY fact_count DESC
""",
bank_id
)
print(f"\n=== Entities by Mention Count ===")
for entity in all_entities:
print(f" {entity['canonical_name']}: {entity['fact_count']} mentions")
# Verify user entity exists
assert user_entity is not None, "User entity should have been extracted"
print(f"\n=== User Entity ===")
print(f"Entity: {user_entity['canonical_name']} (id: {user_entity['id']})")
print(f"Fact count: {user_entity['fact_count']}")
print(f"User entity was successfully extracted")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)