7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
717 lines
26 KiB
Python
717 lines
26 KiB
Python
"""
|
|
Tests for FollowUpContextHandler.
|
|
|
|
Tests cover:
|
|
- Initialization with model and settings
|
|
- Context building from research data
|
|
- Findings extraction from various data formats
|
|
- Source extraction and deduplication
|
|
- Entity extraction via LLM
|
|
- Summary generation for context and prompts
|
|
- Gap identification
|
|
- Settings snapshot formatting
|
|
- LLM context formatting
|
|
"""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.advanced_search_system.knowledge.followup_context_manager import (
|
|
FollowUpContextHandler,
|
|
)
|
|
|
|
|
|
class TestFollowUpContextHandlerInit:
|
|
"""Tests for FollowUpContextHandler initialization."""
|
|
|
|
def test_init_stores_model(self):
|
|
"""Handler stores the model reference."""
|
|
mock_model = Mock()
|
|
handler = FollowUpContextHandler(mock_model)
|
|
assert handler.model is mock_model
|
|
|
|
def test_init_with_settings_snapshot(self):
|
|
"""Handler stores settings snapshot."""
|
|
mock_model = Mock()
|
|
settings = {"key": "value"}
|
|
handler = FollowUpContextHandler(mock_model, settings_snapshot=settings)
|
|
assert handler.settings_snapshot == settings
|
|
|
|
def test_init_without_settings_snapshot(self):
|
|
"""Handler initializes empty settings snapshot when None."""
|
|
mock_model = Mock()
|
|
handler = FollowUpContextHandler(mock_model, settings_snapshot=None)
|
|
assert handler.settings_snapshot == {}
|
|
|
|
def test_init_creates_empty_cache(self):
|
|
"""Handler initializes empty research cache."""
|
|
mock_model = Mock()
|
|
handler = FollowUpContextHandler(mock_model)
|
|
assert handler.past_research_cache == {}
|
|
|
|
|
|
class TestBuildContext:
|
|
"""Tests for build_context method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(
|
|
content="Entity1\nEntity2\nSummary text"
|
|
)
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler instance."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
@pytest.fixture
|
|
def sample_research_data(self):
|
|
"""Create sample research data."""
|
|
return {
|
|
"research_id": "research_123",
|
|
"query": "What is machine learning?",
|
|
"formatted_findings": "ML is a field of AI...",
|
|
"report_content": "Detailed report about ML...",
|
|
"resources": [
|
|
{"url": "https://example.com/1", "title": "Source 1"}
|
|
],
|
|
"all_links_of_system": [
|
|
{"url": "https://example.com/2", "title": "Source 2"}
|
|
],
|
|
"strategy": "detailed",
|
|
"mode": "research",
|
|
"created_at": "2024-01-14",
|
|
"research_meta": {"duration": 120},
|
|
}
|
|
|
|
def test_build_context_returns_dict(self, handler, sample_research_data):
|
|
"""build_context returns a dictionary."""
|
|
result = handler.build_context(
|
|
sample_research_data, "What are ML applications?"
|
|
)
|
|
assert isinstance(result, dict)
|
|
|
|
def test_build_context_surfaces_chat_past_findings(self, handler):
|
|
"""End-to-end of the fix: a chat-style context (only past_findings,
|
|
no formatted_findings/report_content) surfaces its summary as
|
|
past_findings, so it reaches generate_contextualized_query instead of
|
|
being replaced by the 'No previous findings available' sentinel."""
|
|
chat_context = {
|
|
"research_id": "r1",
|
|
"original_query": "What is machine learning?",
|
|
"past_findings": "Summary of the conversation, focused on cost.",
|
|
}
|
|
result = handler.build_context(chat_context, "How much does it cost?")
|
|
assert (
|
|
result["past_findings"]
|
|
== "Summary of the conversation, focused on cost."
|
|
)
|
|
|
|
def test_build_context_includes_parent_research_id(
|
|
self, handler, sample_research_data
|
|
):
|
|
"""Context includes parent_research_id."""
|
|
result = handler.build_context(
|
|
sample_research_data, "Follow-up question?"
|
|
)
|
|
assert result["parent_research_id"] == "research_123"
|
|
|
|
def test_build_context_includes_original_query(
|
|
self, handler, sample_research_data
|
|
):
|
|
"""Context includes original_query."""
|
|
result = handler.build_context(
|
|
sample_research_data, "Follow-up question?"
|
|
)
|
|
assert result["original_query"] == "What is machine learning?"
|
|
|
|
def test_build_context_includes_follow_up_query(
|
|
self, handler, sample_research_data
|
|
):
|
|
"""Context includes follow_up_query."""
|
|
result = handler.build_context(
|
|
sample_research_data, "What are ML applications?"
|
|
)
|
|
assert result["follow_up_query"] == "What are ML applications?"
|
|
|
|
def test_build_context_includes_all_keys(
|
|
self, handler, sample_research_data
|
|
):
|
|
"""Context includes all expected keys."""
|
|
result = handler.build_context(sample_research_data, "Follow-up?")
|
|
expected_keys = [
|
|
"parent_research_id",
|
|
"original_query",
|
|
"follow_up_query",
|
|
"past_findings",
|
|
"past_sources",
|
|
"key_entities",
|
|
"summary",
|
|
"report_content",
|
|
"formatted_findings",
|
|
"all_links_of_system",
|
|
"metadata",
|
|
]
|
|
for key in expected_keys:
|
|
assert key in result
|
|
|
|
|
|
class TestExtractFindings:
|
|
"""Tests for _extract_findings method."""
|
|
|
|
@pytest.fixture
|
|
def handler(self):
|
|
"""Create handler without model."""
|
|
return FollowUpContextHandler(None)
|
|
|
|
def test_extract_from_formatted_findings(self, handler):
|
|
"""Extract from formatted_findings field."""
|
|
data = {"formatted_findings": "Formatted findings text"}
|
|
result = handler._extract_findings(data)
|
|
assert "Formatted findings text" in result
|
|
|
|
def test_extract_from_report_content_when_no_formatted(self, handler):
|
|
"""Extract from report_content when no formatted_findings."""
|
|
data = {"report_content": "Report content text here"}
|
|
result = handler._extract_findings(data)
|
|
assert "Report content text" in result
|
|
|
|
def test_report_content_truncated_to_2000(self, handler):
|
|
"""Report content is truncated to 2000 characters."""
|
|
long_content = "A" * 5000
|
|
data = {"report_content": long_content}
|
|
result = handler._extract_findings(data)
|
|
assert len(result) == 2000
|
|
|
|
def test_returns_default_when_no_findings(self, handler):
|
|
"""Returns default message when no findings available."""
|
|
data = {}
|
|
result = handler._extract_findings(data)
|
|
assert result == "No previous findings available"
|
|
|
|
def test_honors_chat_past_findings_when_no_report(self, handler):
|
|
"""Multi-turn chat supplies prior findings under ``past_findings``
|
|
(it has no formatted_findings/report_content); that must be used as
|
|
the findings rather than the "nothing available" sentinel."""
|
|
data = {"past_findings": "Condensed summary of the conversation."}
|
|
result = handler._extract_findings(data)
|
|
assert result == "Condensed summary of the conversation."
|
|
|
|
def test_report_content_takes_precedence_over_past_findings(self, handler):
|
|
"""Non-chat callers populate report_content; past_findings must not
|
|
override it (keeps the standalone follow-up path unchanged)."""
|
|
data = {
|
|
"report_content": "Full parent report.",
|
|
"past_findings": "should not be used",
|
|
}
|
|
result = handler._extract_findings(data)
|
|
assert "Full parent report." in result
|
|
assert "should not be used" not in result
|
|
|
|
def test_prefers_formatted_over_report(self, handler):
|
|
"""Prefers formatted_findings over report_content."""
|
|
data = {
|
|
"formatted_findings": "Formatted text",
|
|
"report_content": "Report text",
|
|
}
|
|
result = handler._extract_findings(data)
|
|
assert "Formatted text" in result
|
|
|
|
|
|
class TestExtractSources:
|
|
"""Tests for _extract_sources method."""
|
|
|
|
@pytest.fixture
|
|
def handler(self):
|
|
"""Create handler without model."""
|
|
return FollowUpContextHandler(None)
|
|
|
|
def test_extract_from_resources(self, handler):
|
|
"""Extract sources from resources field."""
|
|
data = {"resources": [{"url": "https://a.com", "title": "A"}]}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 1
|
|
assert result[0]["url"] == "https://a.com"
|
|
|
|
def test_extract_from_all_links_of_system(self, handler):
|
|
"""Extract sources from all_links_of_system field."""
|
|
data = {"all_links_of_system": [{"url": "https://b.com", "title": "B"}]}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 1
|
|
|
|
def test_extract_from_past_links(self, handler):
|
|
"""Extract sources from past_links field."""
|
|
data = {"past_links": [{"url": "https://c.com", "title": "C"}]}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 1
|
|
|
|
def test_deduplicates_by_url(self, handler):
|
|
"""Deduplicates sources by URL."""
|
|
data = {
|
|
"resources": [{"url": "https://a.com", "title": "A1"}],
|
|
"all_links_of_system": [{"url": "https://a.com", "title": "A2"}],
|
|
}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 1
|
|
|
|
def test_includes_sources_without_url(self, handler):
|
|
"""Includes sources that have no URL."""
|
|
data = {"resources": [{"title": "No URL source"}]}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 1
|
|
|
|
def test_combines_all_source_fields(self, handler):
|
|
"""Combines sources from all fields."""
|
|
data = {
|
|
"resources": [{"url": "https://a.com", "title": "A"}],
|
|
"all_links_of_system": [{"url": "https://b.com", "title": "B"}],
|
|
"past_links": [{"url": "https://c.com", "title": "C"}],
|
|
}
|
|
result = handler._extract_sources(data)
|
|
assert len(result) == 3
|
|
|
|
def test_returns_empty_list_when_no_sources(self, handler):
|
|
"""Returns empty list when no sources available."""
|
|
data = {}
|
|
result = handler._extract_sources(data)
|
|
assert result == []
|
|
|
|
|
|
class TestExtractEntities:
|
|
"""Tests for _extract_entities method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(content="Entity1\nEntity2\nEntity3")
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_extracts_entities_from_findings(self, handler):
|
|
"""Extracts entities using LLM."""
|
|
data = {"formatted_findings": "Research findings about Python and ML"}
|
|
result = handler._extract_entities(data)
|
|
assert len(result) == 3
|
|
assert "Entity1" in result
|
|
|
|
def test_limits_to_10_entities(self, handler, mock_model):
|
|
"""Limits entities to 10."""
|
|
mock_model.invoke.return_value = Mock(
|
|
content="\n".join([f"Entity{i}" for i in range(15)])
|
|
)
|
|
data = {"formatted_findings": "Some findings"}
|
|
result = handler._extract_entities(data)
|
|
assert len(result) <= 10
|
|
|
|
def test_returns_empty_when_no_findings_and_no_model(self):
|
|
"""Returns empty list when no findings and no model."""
|
|
handler = FollowUpContextHandler(None)
|
|
data = {}
|
|
result = handler._extract_entities(data)
|
|
assert result == []
|
|
|
|
def test_returns_empty_when_no_model(self):
|
|
"""Returns empty list when no model available."""
|
|
handler = FollowUpContextHandler(None)
|
|
data = {"formatted_findings": "Some findings"}
|
|
result = handler._extract_entities(data)
|
|
assert result == []
|
|
|
|
def test_handles_llm_exception(self, handler, mock_model):
|
|
"""Handles LLM exception gracefully."""
|
|
mock_model.invoke.side_effect = RuntimeError("Connection failed")
|
|
data = {"formatted_findings": "Some findings"}
|
|
result = handler._extract_entities(data)
|
|
assert result == []
|
|
|
|
|
|
class TestCreateSummary:
|
|
"""Tests for _create_summary method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(content="Summary of findings")
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_creates_summary_with_llm(self, handler):
|
|
"""Creates summary using LLM."""
|
|
data = {
|
|
"formatted_findings": "Research findings",
|
|
"query": "Original question?",
|
|
}
|
|
handler._create_summary(data, "Follow-up question?")
|
|
assert handler.model.invoke.called
|
|
|
|
def test_includes_original_query_in_prompt(self, handler):
|
|
"""Includes original query in prompt."""
|
|
data = {
|
|
"formatted_findings": "Research findings",
|
|
"query": "Original question?",
|
|
}
|
|
handler._create_summary(data, "Follow-up?")
|
|
call_args = handler.model.invoke.call_args[0][0]
|
|
assert "Original question?" in call_args
|
|
|
|
|
|
class TestSummarizeForFollowup:
|
|
"""Tests for summarize_for_followup method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(content="Concise summary")
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_summarize_returns_string(self, handler):
|
|
"""summarize_for_followup returns a string."""
|
|
result = handler.summarize_for_followup(
|
|
"Long findings text...", "Follow-up question?"
|
|
)
|
|
assert isinstance(result, str)
|
|
|
|
def test_summarize_respects_max_length(self, handler, mock_model):
|
|
"""Result respects max_length constraint."""
|
|
mock_model.invoke.return_value = Mock(content="A" * 2000)
|
|
result = handler.summarize_for_followup(
|
|
"Findings", "Question?", max_length=500
|
|
)
|
|
assert len(result) <= 503 # 500 + "..."
|
|
|
|
def test_short_findings_returned_as_is(self, handler):
|
|
"""Short findings are returned as-is without LLM call."""
|
|
result = handler.summarize_for_followup(
|
|
"Short text", "Question?", max_length=1000
|
|
)
|
|
# Model should not be called for short text
|
|
assert result == "Short text"
|
|
|
|
|
|
class TestGenerateSummary:
|
|
"""Tests for _generate_summary method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(content="Generated summary")
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_returns_empty_for_empty_findings(self, handler):
|
|
"""Returns empty string for empty findings."""
|
|
result = handler._generate_summary("", "Query")
|
|
assert result == ""
|
|
|
|
def test_returns_findings_when_short_enough(self, handler):
|
|
"""Returns findings as-is when within max_length."""
|
|
result = handler._generate_summary(
|
|
"Short findings", "Query", max_length=1000
|
|
)
|
|
assert result == "Short findings"
|
|
|
|
def test_context_purpose_includes_original_query(self, handler):
|
|
"""Context purpose prompt includes original query."""
|
|
handler._generate_summary(
|
|
"Long " * 500, # Force LLM call
|
|
"Follow-up?",
|
|
original_query="Original?",
|
|
purpose="context",
|
|
)
|
|
call_args = handler.model.invoke.call_args[0][0]
|
|
assert "Original?" in call_args
|
|
|
|
def test_prompt_purpose_omits_original_query(self, handler):
|
|
"""Prompt purpose omits original query reference."""
|
|
handler._generate_summary(
|
|
"Long " * 500,
|
|
"Follow-up?",
|
|
original_query="Original?",
|
|
purpose="prompt",
|
|
)
|
|
call_args = handler.model.invoke.call_args[0][0]
|
|
# Prompt purpose uses simpler format
|
|
assert "Follow-up?" in call_args
|
|
|
|
def test_fallback_without_model(self):
|
|
"""Fallback truncation when no model available."""
|
|
handler = FollowUpContextHandler(None)
|
|
result = handler._generate_summary("A" * 1000, "Query", max_length=100)
|
|
assert len(result) <= 103 # 100 + "..."
|
|
|
|
def test_handles_llm_exception(self, handler, mock_model):
|
|
"""Handles LLM exception with truncation fallback."""
|
|
mock_model.invoke.side_effect = RuntimeError("Error")
|
|
result = handler._generate_summary("A" * 1000, "Query", max_length=100)
|
|
assert len(result) <= 103
|
|
|
|
|
|
class TestIdentifyGaps:
|
|
"""Tests for identify_gaps method."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model."""
|
|
mock = Mock()
|
|
mock.invoke.return_value = Mock(content="Gap 1\nGap 2\nGap 3")
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_identifies_gaps(self, handler):
|
|
"""Identifies gaps using LLM."""
|
|
data = {"formatted_findings": "Some research findings"}
|
|
result = handler.identify_gaps(data, "Follow-up question?")
|
|
assert len(result) == 3
|
|
assert "Gap 1" in result
|
|
|
|
def test_limits_to_5_gaps(self, handler, mock_model):
|
|
"""Limits gaps to 5."""
|
|
mock_model.invoke.return_value = Mock(
|
|
content="\n".join([f"Gap {i}" for i in range(10)])
|
|
)
|
|
data = {"formatted_findings": "Findings"}
|
|
result = handler.identify_gaps(data, "Question?")
|
|
assert len(result) <= 5
|
|
|
|
def test_returns_empty_when_no_findings_and_no_model(self):
|
|
"""Returns empty list when no findings and no model."""
|
|
handler = FollowUpContextHandler(None)
|
|
data = {}
|
|
result = handler.identify_gaps(data, "Question?")
|
|
assert result == []
|
|
|
|
def test_returns_empty_when_no_model(self):
|
|
"""Returns empty list when no model."""
|
|
handler = FollowUpContextHandler(None)
|
|
data = {"formatted_findings": "Findings"}
|
|
result = handler.identify_gaps(data, "Question?")
|
|
assert result == []
|
|
|
|
def test_handles_llm_exception(self, handler, mock_model):
|
|
"""Handles LLM exception gracefully."""
|
|
mock_model.invoke.side_effect = RuntimeError("Error")
|
|
data = {"formatted_findings": "Findings"}
|
|
result = handler.identify_gaps(data, "Question?")
|
|
assert result == []
|
|
|
|
|
|
class TestFormatForSettingsSnapshot:
|
|
"""Tests for format_for_settings_snapshot method."""
|
|
|
|
@pytest.fixture
|
|
def handler(self):
|
|
"""Create handler without model."""
|
|
return FollowUpContextHandler(None)
|
|
|
|
def test_returns_minimal_metadata(self, handler):
|
|
"""Returns minimal metadata dictionary."""
|
|
context = {
|
|
"parent_research_id": "research_123",
|
|
"past_findings": "Some findings",
|
|
"extra_data": "Should not be included",
|
|
}
|
|
result = handler.format_for_settings_snapshot(context)
|
|
assert "followup_metadata" in result
|
|
assert (
|
|
result["followup_metadata"]["parent_research_id"] == "research_123"
|
|
)
|
|
assert result["followup_metadata"]["is_followup"] is True
|
|
|
|
def test_has_context_true_when_findings_present(self, handler):
|
|
"""has_context is True when past_findings present."""
|
|
context = {"past_findings": "Some findings"}
|
|
result = handler.format_for_settings_snapshot(context)
|
|
assert result["followup_metadata"]["has_context"] is True
|
|
|
|
def test_has_context_false_when_no_findings(self, handler):
|
|
"""has_context is False when no past_findings."""
|
|
context = {}
|
|
result = handler.format_for_settings_snapshot(context)
|
|
assert result["followup_metadata"]["has_context"] is False
|
|
|
|
|
|
class TestGetRelevantContextForLLM:
|
|
"""Tests for get_relevant_context_for_llm method."""
|
|
|
|
@pytest.fixture
|
|
def handler(self):
|
|
"""Create handler without model."""
|
|
return FollowUpContextHandler(None)
|
|
|
|
def test_includes_original_query(self, handler):
|
|
"""Includes original query in output."""
|
|
context = {"original_query": "Original question?"}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "Original question?" in result
|
|
|
|
def test_includes_follow_up_query(self, handler):
|
|
"""Includes follow-up query in output."""
|
|
context = {"follow_up_query": "Follow-up question?"}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "Follow-up question?" in result
|
|
|
|
def test_includes_summary(self, handler):
|
|
"""Includes summary in output."""
|
|
context = {"summary": "Summary of findings"}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "Summary of findings" in result
|
|
|
|
def test_includes_key_entities(self, handler):
|
|
"""Includes key entities in output."""
|
|
context = {"key_entities": ["Entity1", "Entity2", "Entity3"]}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "Entity1" in result
|
|
|
|
def test_limits_entities_to_5(self, handler):
|
|
"""Limits entities to first 5."""
|
|
context = {"key_entities": [f"Entity{i}" for i in range(10)]}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "Entity4" in result
|
|
assert "Entity5" not in result or result.count("Entity") <= 5
|
|
|
|
def test_includes_source_count(self, handler):
|
|
"""Includes source count in output."""
|
|
context = {"past_sources": [{"url": "a"}, {"url": "b"}, {"url": "c"}]}
|
|
result = handler.get_relevant_context_for_llm(context)
|
|
assert "3" in result
|
|
|
|
def test_truncates_to_max_tokens(self, handler):
|
|
"""Truncates output to approximate max_tokens."""
|
|
context = {
|
|
"original_query": "A" * 1000,
|
|
"follow_up_query": "B" * 1000,
|
|
"summary": "C" * 10000,
|
|
}
|
|
result = handler.get_relevant_context_for_llm(context, max_tokens=500)
|
|
# max_tokens * 4 = 2000 chars
|
|
assert len(result) <= 2003 # 2000 + "..."
|
|
|
|
|
|
class TestExtractMetadata:
|
|
"""Tests for _extract_metadata method."""
|
|
|
|
@pytest.fixture
|
|
def handler(self):
|
|
"""Create handler without model."""
|
|
return FollowUpContextHandler(None)
|
|
|
|
def test_extracts_strategy(self, handler):
|
|
"""Extracts strategy from research data."""
|
|
data = {"strategy": "detailed"}
|
|
result = handler._extract_metadata(data)
|
|
assert result["strategy"] == "detailed"
|
|
|
|
def test_extracts_mode(self, handler):
|
|
"""Extracts mode from research data."""
|
|
data = {"mode": "research"}
|
|
result = handler._extract_metadata(data)
|
|
assert result["mode"] == "research"
|
|
|
|
def test_extracts_created_at(self, handler):
|
|
"""Extracts created_at from research data."""
|
|
data = {"created_at": "2024-01-14"}
|
|
result = handler._extract_metadata(data)
|
|
assert result["created_at"] == "2024-01-14"
|
|
|
|
def test_extracts_research_meta(self, handler):
|
|
"""Extracts research_meta from research data."""
|
|
data = {"research_meta": {"duration": 120}}
|
|
result = handler._extract_metadata(data)
|
|
assert result["research_meta"]["duration"] == 120
|
|
|
|
def test_returns_empty_strings_for_missing_fields(self, handler):
|
|
"""Returns empty strings for missing fields."""
|
|
data = {}
|
|
result = handler._extract_metadata(data)
|
|
assert result["strategy"] == ""
|
|
assert result["mode"] == ""
|
|
assert result["created_at"] == ""
|
|
assert result["research_meta"] == {}
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for complete workflows."""
|
|
|
|
@pytest.fixture
|
|
def mock_model(self):
|
|
"""Create mock model with realistic responses."""
|
|
mock = Mock()
|
|
|
|
def invoke_side_effect(prompt):
|
|
if "entities" in prompt.lower():
|
|
return Mock(content="Python\nMachine Learning\nAI")
|
|
if "summary" in prompt.lower() or "summarize" in prompt.lower():
|
|
return Mock(content="A concise summary of the research.")
|
|
if "gaps" in prompt.lower():
|
|
return Mock(content="Gap 1\nGap 2")
|
|
return Mock(content="Default response")
|
|
|
|
mock.invoke.side_effect = invoke_side_effect
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def handler(self, mock_model):
|
|
"""Create handler with mock model."""
|
|
return FollowUpContextHandler(mock_model)
|
|
|
|
def test_full_workflow(self, handler):
|
|
"""Test complete workflow from research data to context."""
|
|
research_data = {
|
|
"research_id": "research_123",
|
|
"query": "What is machine learning?",
|
|
"formatted_findings": "Machine learning is a branch of AI that enables computers to learn from data.",
|
|
"resources": [{"url": "https://example.com", "title": "ML Guide"}],
|
|
"strategy": "detailed",
|
|
"mode": "research",
|
|
}
|
|
|
|
context = handler.build_context(
|
|
research_data, "How is ML used in healthcare?"
|
|
)
|
|
|
|
# Verify context structure
|
|
assert context["parent_research_id"] == "research_123"
|
|
assert context["original_query"] == "What is machine learning?"
|
|
assert context["follow_up_query"] == "How is ML used in healthcare?"
|
|
assert len(context["past_sources"]) == 1
|
|
assert len(context["key_entities"]) > 0
|
|
|
|
def test_full_workflow_with_gaps(self, handler):
|
|
"""Test gap identification in complete workflow."""
|
|
research_data = {
|
|
"formatted_findings": "Some research findings about ML."
|
|
}
|
|
|
|
gaps = handler.identify_gaps(research_data, "What about deep learning?")
|
|
assert isinstance(gaps, list)
|
|
assert len(gaps) <= 5
|