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

971 lines
37 KiB
Python

"""
Test suite for fact extraction quality verification.
This comprehensive test suite validates that the fact extraction system:
1. Preserves all information dimensions (emotional, sensory, cognitive, etc.)
2. Correctly converts relative dates to absolute dates
3. Properly classifies facts as agent vs world
4. Makes logical inferences to connect related information
5. Correctly attributes statements to speakers
6. Filters out irrelevant content (podcast intros/outros)
These are quality/accuracy tests that verify the LLM-based extraction
produces semantically correct and complete facts.
"""
from datetime import UTC, 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
# =============================================================================
# DIMENSION PRESERVATION TESTS
# =============================================================================
class TestDimensionPreservation:
"""Tests that fact extraction preserves all information dimensions."""
@pytest.mark.asyncio
async def test_emotional_dimension_preservation(self):
"""
Test that emotional states and feelings are preserved, not stripped away.
Example: "I was thrilled to receive positive feedback"
Should NOT become: "I received positive feedback"
"""
text = """
I was absolutely thrilled when I received such positive feedback on my presentation!
Sarah seemed disappointed when she heard the news about the delay.
Marcus felt anxious about the upcoming interview.
"""
context = "Personal journal entry"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
emotional_indicators = ["thrilled", "disappointed", "anxious", "positive feedback"]
found_emotions = [word for word in emotional_indicators if word in all_facts_text]
assert len(found_emotions) >= 2, (
f"Should preserve emotional dimension. "
f"Found: {found_emotions}, Expected at least 2 from: {emotional_indicators}"
)
@pytest.mark.asyncio
async def test_sensory_dimension_preservation(self):
"""Test that sensory details (visual, auditory, etc.) are preserved."""
text = """
The coffee tasted bitter and burnt.
She showed me her bright orange hair, which looked stunning under the lights.
The music was so loud I could barely hear myself think.
"""
context = "Personal experience"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
sensory_indicators = ["bitter", "burnt", "bright orange", "loud", "stunning"]
found_sensory = [word for word in sensory_indicators if word in all_facts_text]
assert len(found_sensory) >= 2, (
f"Should preserve sensory details. "
f"Found: {found_sensory}, Expected at least 2 from: {sensory_indicators}"
)
@pytest.mark.asyncio
async def test_cognitive_epistemic_dimension(self):
"""Test that cognitive states and certainty levels are preserved."""
text = """
I realized that the approach wasn't working.
She wasn't sure if the meeting would happen.
He's convinced that AI will transform healthcare.
Maybe we should reconsider the timeline.
"""
context = "Team discussion"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
cognitive_indicators = ["realized", "wasn't sure", "convinced", "maybe", "reconsider"]
found_cognitive = [word for word in cognitive_indicators if word in all_facts_text]
assert len(found_cognitive) >= 2, (
f"Should preserve cognitive/epistemic dimension. "
f"Found: {found_cognitive}"
)
@pytest.mark.asyncio
async def test_capability_skill_dimension(self):
"""Test that capabilities, skills, and limitations are preserved."""
text = """
I can speak French fluently.
Sarah struggles with public speaking.
He's an expert in machine learning.
I'm unable to attend the conference due to scheduling conflicts.
"""
context = "Personal profile discussion"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
capability_indicators = ["can speak", "fluently", "struggles with", "expert in", "unable to"]
found_capability = [word for word in capability_indicators if word in all_facts_text]
assert len(found_capability) >= 2, (
f"Should preserve capability/skill dimension. "
f"Found: {found_capability}"
)
@pytest.mark.asyncio
async def test_comparative_dimension(self):
"""Test that comparisons and contrasts are preserved."""
text = """
This approach is much better than the previous one.
The new design is worse than expected.
Unlike last year, we're ahead of schedule.
"""
context = "Project review"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
comparative_indicators = ["better than", "worse than", "unlike", "ahead of"]
found_comparative = [word for word in comparative_indicators if word in all_facts_text]
assert len(found_comparative) >= 1, (
f"Should preserve comparative dimension. "
f"Found: {found_comparative}"
)
@pytest.mark.asyncio
async def test_attitudinal_reactive_dimension(self):
"""Test that attitudes and reactions are preserved."""
text = """
She's very skeptical about the new technology.
I was surprised when he announced his resignation.
Marcus rolled his eyes when the topic came up.
She's enthusiastic about the opportunity.
"""
context = "Team meeting"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
attitudinal_indicators = ["skeptical", "surprised", "rolled his eyes", "enthusiastic"]
found_attitudinal = [word for word in attitudinal_indicators if word in all_facts_text]
assert len(found_attitudinal) >= 1, (
f"Should preserve attitudinal/reactive dimension. "
f"Found: {found_attitudinal}"
)
@pytest.mark.asyncio
async def test_intentional_motivational_dimension(self):
"""Test that goals, plans, and motivations are preserved."""
text = """
I want to learn Mandarin before my trip to China.
She aims to complete her PhD within three years.
His goal is to build a sustainable business.
I'm planning to switch careers because I'm not fulfilled in my current role.
"""
context = "Personal goals discussion"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Check for goal/intention related content
intentional_indicators = [
"want", "aim", "goal", "plan", "because", "learn", "complete",
"build", "switch", "career", "mandarin", "china", "phd", "business"
]
found_intentional = [word for word in intentional_indicators if word in all_facts_text]
assert len(found_intentional) >= 1, (
f"Should preserve intentional/motivational content. "
f"Found: {found_intentional}"
)
@pytest.mark.asyncio
async def test_evaluative_preferential_dimension(self):
"""Test that preferences and values are preserved."""
text = """
I prefer working remotely to being in an office.
She values honesty above all else.
He hates being late to meetings.
Family is the most important thing to her.
"""
context = "Personal values discussion"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
evaluative_indicators = ["prefer", "values", "hates", "important", "above all"]
found_evaluative = [word for word in evaluative_indicators if word in all_facts_text]
assert len(found_evaluative) >= 2, (
f"Should preserve evaluative/preferential dimension. "
f"Found: {found_evaluative}"
)
@pytest.mark.asyncio
async def test_comprehensive_multi_dimension(self):
"""Test a realistic scenario with multiple dimensions in one fact."""
text = """
I was thrilled to receive such positive feedback on my presentation yesterday!
I wasn't sure if my approach would resonate, but the audience seemed enthusiastic.
I prefer presenting in person rather than virtually because I can read the room better.
"""
context = "Personal reflection"
llm_config = LLMConfig.for_memory()
event_date = datetime(2024, 11, 13)
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Check emotional - should capture positive/thrilled sentiment
has_emotional = any(term in all_facts_text for term in [
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
])
assert has_emotional, "Should preserve emotional dimension"
# Check no vague temporal terms
prohibited_terms = ["recently", "soon", "lately"]
found_prohibited = [term for term in prohibited_terms if term in all_facts_text]
assert len(found_prohibited) == 0, \
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "virtually", "read the room"
])
assert has_preference, "Should preserve preferential dimension"
# =============================================================================
# TEMPORAL CONVERSION TESTS
# =============================================================================
class TestTemporalConversion:
"""Tests for temporal extraction and date conversion."""
@pytest.mark.asyncio
async def test_temporal_absolute_conversion(self):
"""
Test that relative temporal expressions are converted to absolute dates.
Critical: "yesterday" should become "on November 12, 2024", NOT "recently"
LLM behavior may vary, so we check the occurred_start field rather than fact text.
"""
text = """
Yesterday I went for a morning jog for the first time in a nearby park.
Last week I started a new project.
I'm planning to visit Tokyo next month.
"""
context = "Personal conversation"
llm_config = LLMConfig.for_memory()
event_date = datetime(2024, 11, 13)
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Should NOT contain vague temporal terms
prohibited_terms = ["recently", "lately", "a while ago", "some time ago"]
found_prohibited = [term for term in prohibited_terms if term in all_facts_text]
assert len(found_prohibited) == 0, (
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
)
# Check that at least one fact has a valid occurred_start date
facts_with_temporal = [f for f in facts if f.occurred_start]
assert len(facts_with_temporal) >= 1, (
f"At least one fact should have temporal data (occurred_start). "
f"Facts: {[f.fact for f in facts]}"
)
@pytest.mark.asyncio
async def test_date_field_calculation_last_night(self):
"""
Test that the date field is calculated correctly for "last night" events.
Ideally: If conversation is on August 14, 2023 and text says "last night",
the date field should be August 13. We accept 13 or 14 as LLM may vary.
Retries up to 3 times to account for LLM inconsistencies.
"""
text = """
Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday
with a concert surrounded by music, joy and the warm summer breeze.
"""
context = "Conversation between Melanie and Caroline"
llm_config = LLMConfig.for_memory()
event_date = datetime(2023, 8, 14, 14, 24)
last_error = None
max_retries = 3
for attempt in range(max_retries):
try:
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
birthday_fact = None
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
birthday_fact = fact
break
assert birthday_fact is not None, "Should extract fact about birthday celebration"
fact_date_str = birthday_fact.occurred_start
assert fact_date_str is not None, "occurred_start should not be None for temporal events"
if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
else:
fact_date = datetime.fromisoformat(fact_date_str)
assert fact_date.year == 2023, "Year should be 2023"
assert fact_date.month == 8, "Month should be August"
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid
assert fact_date.day in (13, 14), (
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
)
# If we reach here, test passed
return
except AssertionError as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e
except Exception as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed with exception: {e}. Retrying...")
continue
else:
# Last attempt failed, re-raise the error
raise e
# Should not reach here, but just in case
if last_error:
raise last_error
@pytest.mark.asyncio
async def test_date_field_calculation_yesterday(self):
"""Test that the date field is calculated correctly for "yesterday" events."""
text = """
Yesterday I went for a morning jog for the first time in a nearby park.
It was a beautiful day and I plan to make this a regular habit.
"""
context = "Personal diary"
llm_config = LLMConfig.for_memory()
event_date = datetime(2024, 11, 13)
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
# Find a fact with occurred_start
facts_with_date = [f for f in facts if f.occurred_start]
# If we got a fact with temporal data, verify the date is reasonable
if facts_with_date:
jogging_fact = facts_with_date[0]
fact_date_str = jogging_fact.occurred_start
if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
else:
fact_date = datetime.fromisoformat(fact_date_str)
assert fact_date.year == 2024, "Year should be 2024"
assert fact_date.month == 11, "Month should be November"
# Accept day 12 (ideal: yesterday) or 13 (conversation date) as valid
assert fact_date.day in (12, 13), (
f"Day should be 12 or 13 (around Nov 13 event), but got {fact_date.day}."
)
all_facts_text = " ".join([f.fact.lower() for f in facts])
# The content should be preserved in some form
assert any(term in all_facts_text for term in ["jog", "morning", "park", "first"]), \
f"Should preserve key content. Facts: {[f.fact for f in facts]}"
assert "recently" not in all_facts_text, \
"Should NOT convert 'yesterday' to 'recently'"
assert any(term in all_facts_text for term in ["november", "12", "nov"]), \
"Should convert 'yesterday' to absolute date in fact text"
@pytest.mark.asyncio
async def test_extract_facts_with_relative_dates(self):
"""Test that relative dates are converted to absolute dates."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
llm_config = LLMConfig.for_memory()
text = """
Yesterday I went hiking in Yosemite.
Last week I started my new job at Google.
This morning I had coffee with Alice.
"""
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="Personal diary",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
for fact in facts:
assert fact.fact, "Each fact should have 'fact' field"
# Check that facts were extracted - dates may or may not be populated
# depending on LLM behavior
dates = [f.occurred_start for f in facts if f.occurred_start]
# If dates were extracted, they should ideally be different for different events
if len(dates) >= 2:
unique_dates = set(dates)
# Just verify we got dates, don't require them to be unique
@pytest.mark.asyncio
async def test_extract_facts_with_no_temporal_info(self):
"""Test that facts without temporal info are still extracted."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
llm_config = LLMConfig.for_memory()
text = "Alice works at Google. She loves Python programming."
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="General info",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
# For facts without temporal info, occurred_start may be None or set to reference date
# We just verify that facts were extracted with content
for fact in facts:
assert fact.fact, "Each fact should have text content"
@pytest.mark.asyncio
async def test_extract_facts_with_absolute_dates(self):
"""Test that absolute dates in text are preserved."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
llm_config = LLMConfig.for_memory()
text = """
On March 15, 2024, Alice joined Google.
Bob will start his vacation on April 1st.
"""
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="Calendar events",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
for fact in facts:
assert fact.occurred_start, f"Fact should have a date: {fact.fact}"
# =============================================================================
# LOGICAL INFERENCE TESTS
# =============================================================================
class TestLogicalInference:
"""Tests that the system makes logical inferences to connect related information."""
@pytest.mark.asyncio
async def test_logical_inference_identity_connection(self):
"""
Test that the system extracts key information about loss and relationships.
The LLM should extract facts about losing a friend and about Karlie.
Ideally it connects them, but we accept extracting both separately.
"""
text = """
Deborah: The roses and dahlias bring me peace. I lost a friend last week,
so I've been spending time in the garden to find some comfort.
Jolene: Sorry to hear about your friend, Deb. Losing someone can be really tough.
How are you holding up?
Deborah: Thanks for the kind words. It's been tough, but I'm comforted by
remembering our time together. It reminds me of how special life is.
Jolene: Memories can give us so much comfort and joy.
Deborah: Memories keep our loved ones close. This is the last photo with Karlie
which was taken last summer when we hiked. It was our last one. We had such a
great time! Every time I see it, I can't help but smile.
"""
context = "Conversation between Deborah and Jolene"
llm_config = LLMConfig.for_memory()
event_date = datetime(2023, 2, 23)
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Deborah",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Check that key information is extracted (Karlie and the loss)
has_karlie = "karlie" in all_facts_text
has_loss = any(word in all_facts_text for word in ["lost", "death", "passed", "died", "losing", "friend"])
has_hike = "hike" in all_facts_text or "hiking" in all_facts_text or "photo" in all_facts_text
# At minimum, we should capture Karlie and either the loss or the hike memory
assert has_karlie or has_loss, (
f"Should mention either Karlie or the loss in facts. Facts: {[f.fact for f in facts]}"
)
# Check if inference was made (bonus - not required for pass)
connected_fact_found = False
for fact in facts:
fact_text = fact.fact.lower()
if "karlie" in fact_text and any(word in fact_text for word in ["lost", "death", "passed", "died", "losing", "friend"]):
connected_fact_found = True
break
# This is informational - test passes even without perfect inference
if not connected_fact_found and has_karlie and has_loss:
pass # Acceptable: facts extracted separately
@pytest.mark.asyncio
async def test_logical_inference_pronoun_resolution(self):
"""
Test that pronouns are resolved to their referents.
Example: "I started a project" + "It's challenging" -> "The project is challenging"
"""
text = """
I started a new machine learning project last month.
It's been really challenging but very rewarding.
I've learned so much from it.
"""
context = "Personal update"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
has_project = "project" in all_facts_text
has_qualities = any(word in all_facts_text for word in ["challenging", "rewarding", "learned"])
assert has_project, "Should mention the project"
assert has_qualities, "Should mention the qualities/learning"
# Check that pronouns are resolved - either:
# 1. "project" appears with characteristics in same fact, OR
# 2. "project" is explicitly mentioned in multiple facts (showing pronoun resolution)
# The key is that "it" should be resolved to "project" rather than left as ambiguous
project_facts = [f for f in facts if "project" in f.fact.lower()]
# If we have multiple facts mentioning project, pronoun resolution worked
# (the LLM connected "it" back to "project" in subsequent facts)
pronoun_resolved = len(project_facts) >= 2 or any(
"project" in f.fact.lower() and any(word in f.fact.lower() for word in ["challenging", "rewarding", "learned"])
for f in facts
)
assert pronoun_resolved, (
"Should resolve 'it' to 'the project' - either in combined facts or by mentioning project in multiple facts. "
f"Facts: {[f.fact for f in facts]}"
)
# =============================================================================
# FACT CLASSIFICATION TESTS
# =============================================================================
class TestFactClassification:
"""Tests that facts are correctly classified as agent vs world."""
@pytest.mark.asyncio
async def test_agent_facts_from_podcast_transcript(self):
"""
Test that when context identifies someone as 'you', their actions are classified as agent facts.
This test addresses the issue where podcast transcripts with context like
"this was podcast episode between you (Marcus) and Jamie" were extracting
all facts as 'world' instead of properly identifying Marcus's statements as 'bank'.
"""
transcript = """
Marcus: I've been working on AI safety research for the past six months.
Jamie: That's really interesting! What specifically are you focusing on?
Marcus: I'm investigating interpretability methods. I believe we need to understand
how models make decisions before we can trust them in critical applications.
Jamie: I completely agree with that approach.
Marcus: I published a paper on this topic last month, and I'm presenting it at
the conference next week.
Jamie: Congratulations! I'd love to read it.
"""
context = "Podcast episode between you (Marcus) and Jamie discussing AI research"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="Marcus",
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact from the transcript"
# Check that we extracted meaningful content about AI research
all_facts_text = " ".join([f.fact.lower() for f in facts])
has_ai_content = any(term in all_facts_text for term in [
"ai", "safety", "interpretability", "research", "paper", "conference", "models"
])
assert has_ai_content, f"Should extract AI research content. Facts: {[f.fact for f in facts]}"
# Check fact type classification (flexible - may vary by LLM)
agent_facts = [f for f in facts if f.fact_type == "agent"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
# Accept either agent or experience facts as valid for first-person statements
first_person_facts = agent_facts + experience_facts
# If we have agent facts, verify they use first person
for agent_fact in agent_facts:
fact_text = agent_fact.fact
# Allow flexibility - fact may or may not start with "I"
if fact_text.startswith("I ") or " I " in fact_text:
pass # Good - uses first person
@pytest.mark.asyncio
async def test_agent_facts_without_explicit_context(self):
"""Test that when 'you' is used in the text itself, it gets properly classified."""
text = """
I completed the project on machine learning interpretability last week.
My colleague Sarah helped me with the data analysis.
We presented our findings to the team yesterday.
"""
context = "Personal work log"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="TestUser",
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract facts"
agent_facts = [f for f in facts if f.fact_type == "agent"]
assert len(agent_facts) >= 0 # Just verify classification works
@pytest.mark.asyncio
async def test_speaker_attribution_predictions(self):
"""
Test that predictions made by different speakers are correctly attributed.
This addresses the issue where Jamie's prediction of "Niners 27-13" was being
incorrectly attributed to Marcus (the agent) in the extracted facts.
"""
transcript = """
Marcus: [excited] I'm calling it now, Rams will win twenty seven to twenty four, their defense is too strong!
Jamie: [laughs] No way, I predict the Niners will win twenty seven to thirteen, comfy win at home.
Marcus: [angry] That's ridiculous, I stand by my Rams prediction.
Jamie: [teasing] We'll see who's right, my Niners pick is solid.
"""
context = "podcast episode on match prediction of week 10 - Marcus (you) and Jamie - 14 nov"
agent_name = "Marcus"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 14),
context=context,
llm_config=llm_config,
agent_name=agent_name,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
# Check that predictions were extracted
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Should capture at least some prediction content
has_prediction_content = any(term in all_facts_text for term in [
"rams", "niners", "49ers", "prediction", "win", "predict"
])
assert has_prediction_content, f"Should extract prediction content. Facts: {[f.fact for f in facts]}"
# Ideally, Marcus's prediction should be in agent facts, but we accept
# any reasonable extraction of the predictions
agent_facts = [f for f in facts if f.fact_type == "agent"]
if agent_facts:
agent_facts_text = " ".join([f.fact.lower() for f in agent_facts])
# If agent facts exist, they should relate to Marcus's statements
# (but we don't fail if classification varies)
@pytest.mark.asyncio
async def test_skip_podcast_meta_commentary(self):
"""
Test that podcast intros, outros, and calls to action are skipped.
This addresses the issue where podcast outros like "that's all for today,
don't forget to subscribe" were being extracted as facts.
Note: LLM fact extraction is non-deterministic, so we retry up to 3 times.
"""
transcript = """
Marcus: Welcome everyone to today's episode! Before we dive in, don't forget to
subscribe and leave a rating.
Marcus: Today I want to talk about my research on interpretability in AI systems.
I've been working on this for about a year now.
Jamie: That sounds really interesting! What made you focus on that area?
Marcus: I believe it's crucial for AI safety. We need to understand how these
models make decisions before we can trust them in critical applications.
Jamie: I completely agree with that approach.
Marcus: Well, I think that's gonna do it for us today! Thanks for listening everyone.
Don't forget to tap follow or subscribe, tell a friend, and drop a quick rating
so the algorithm learns to box out. See you next week!
"""
context = "Podcast episode between you (Marcus) and Jamie about AI"
llm_config = LLMConfig.for_memory()
max_retries = 3
last_error = None
for attempt in range(max_retries):
try:
facts, _, _ = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="Marcus",
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
# The main goal is to extract substantive content about AI research
# Meta-commentary filtering is ideal but not strictly required
all_facts_text = " ".join([f.fact.lower() for f in facts])
# Should extract the actual AI research content
has_substantive_content = any(term in all_facts_text for term in [
"interpretability", "ai", "safety", "research", "models", "decisions"
])
assert has_substantive_content, \
f"Should extract substantive AI research content. Facts: {[f.fact for f in facts]}"
return # Test passed
except AssertionError as e:
last_error = e
if attempt < max_retries - 1:
print(f"Test attempt {attempt + 1} failed: {e}. Retrying...")
continue
else:
raise e