fleet-memory/hindsight-api/tests/test_causal_relationships.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

208 lines
8.4 KiB
Python

"""
Test suite for causal relationship extraction.
Tests that the fact extraction system correctly identifies and validates
causal relationships between facts, with valid indices.
"""
from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
class TestCausalRelationships:
"""Tests for causal relationship extraction and validation."""
@pytest.mark.asyncio
async def test_causal_chain_extraction(self):
"""
Test that a clear causal chain is extracted with valid relationships.
Story: Lost job -> couldn't pay rent -> had to move -> found new apartment
This is a 4-fact causal chain where each fact causes the next.
The extracted causal relations should have valid indices (0-3).
"""
text = """
I lost my job at the tech company in January because of layoffs.
Because I lost my job, I couldn't pay my rent anymore.
Since I couldn't afford rent, I had to move out of my apartment.
After searching for weeks, I finally found a cheaper apartment in Brooklyn.
"""
context = "Personal story about housing change"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
# Collect all causal relations from all facts
all_causal_relations = []
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
all_causal_relations.append(
{
"from_fact_index": i,
"to_fact_index": rel.target_fact_index,
"relation_type": rel.relation_type,
"strength": rel.strength,
"from_fact_text": fact.fact[:50],
}
)
# Verify that ALL causal relation indices are valid
# New constraint: target_index must be < from_fact_index (can only reference PREVIOUS facts)
num_facts = len(facts)
invalid_relations = []
for rel in all_causal_relations:
# Must be non-negative and less than the current fact's index
if rel["to_fact_index"] < 0 or rel["to_fact_index"] >= rel["from_fact_index"]:
invalid_relations.append(rel)
assert len(invalid_relations) == 0, (
f"Found {len(invalid_relations)} causal relations with invalid indices! "
f"Each target_fact_index must be < from_fact_index (can only reference previous facts). "
f"Invalid relations: {invalid_relations}"
)
# Should have at least some causal relations extracted
assert len(all_causal_relations) >= 2, (
f"Should extract at least 2 causal relationships from this clear chain. "
f"Got {len(all_causal_relations)}: {all_causal_relations}"
)
# Verify relation types are valid (passive only - facts reference PREVIOUS facts)
valid_types = {"caused_by", "enabled_by", "prevented_by"}
for rel in all_causal_relations:
assert rel["relation_type"] in valid_types, (
f"Invalid relation_type '{rel['relation_type']}'. Must be one of {valid_types}"
)
@pytest.mark.asyncio
async def test_complex_causal_web(self):
"""
Test a more complex scenario with multiple interconnected causes.
This tests the LLM's ability to identify multiple causal links and
ensure all referenced indices exist.
"""
text = """
The heavy rain caused flooding in the basement.
The flooding damaged the electrical system.
Because of the electrical damage, we had to call an electrician.
The electrician found that the wiring was old and needed replacement.
We decided to renovate the entire basement while fixing the wiring.
The renovation took three months and cost $15,000.
"""
context = "Home repair story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
# Validate all causal relation indices (must reference PREVIOUS facts only)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < i, (
f"Fact {i} has causal relation to invalid index {rel.target_fact_index}. "
f"Must reference previous facts only (valid range: 0 to {i - 1}). "
f"Fact text: {fact.fact[:80]}..."
)
@pytest.mark.asyncio
async def test_no_self_referencing_causal_relations(self):
"""
Test that facts don't have causal relations pointing to themselves.
"""
text = """
I started learning Python because I wanted to automate my work tasks.
Learning Python led me to discover machine learning.
Machine learning fascinated me so much that I changed my career to data science.
"""
context = "Career change story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Check no fact references itself
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert rel.target_fact_index != i, (
f"Fact {i} has a self-referencing causal relation! Fact text: {fact.fact}"
)
@pytest.mark.asyncio
async def test_bidirectional_causal_relationships(self):
"""
Test that bidirectional causal relationships (causes and caused_by)
are handled correctly.
"""
text = """
My promotion at work caused me to move to New York.
Moving to New York was caused by my promotion at work.
The new role enabled me to lead a team of engineers.
"""
context = "Work promotion story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Validate all indices (must reference PREVIOUS facts only)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < i, (
f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. "
f"Must reference previous facts only (valid range: 0 to {i - 1})"
)
@pytest.mark.asyncio
async def test_causal_relation_strength_values(self):
"""
Test that causal relation strength values are within valid range [0.0, 1.0].
"""
text = """
The stock market crash directly caused the company to lay off employees.
The layoffs indirectly led to reduced consumer spending in the area.
Reduced spending somewhat affected local businesses.
"""
context = "Economic impact story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0.0 <= rel.strength <= 1.0, (
f"Causal relation strength {rel.strength} is outside valid range [0.0, 1.0]. "
f"Fact {i}: {fact.fact[:50]}..."
)