chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Tests for citation_handler.py
|
||||
|
||||
Tests cover:
|
||||
- CitationHandler initialization
|
||||
- Handler type selection
|
||||
- Method delegation
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
class TestCitationHandlerInit:
|
||||
"""Tests for CitationHandler initialization."""
|
||||
|
||||
def test_default_handler_type(self):
|
||||
"""Test default handler type is standard."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
assert handler._handler == mock_handler
|
||||
|
||||
def test_explicit_standard_handler(self):
|
||||
"""Test explicit standard handler type."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="standard")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_forced_handler_type(self):
|
||||
"""Test forced handler type."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="forced")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_browsecomp_handler_type(self):
|
||||
"""Test browsecomp handler type maps to forced."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="browsecomp")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_precision_handler_type(self):
|
||||
"""Test precision handler type."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="precision")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_simpleqa_handler_type(self):
|
||||
"""Test simpleqa handler type maps to precision."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="simpleqa")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_unknown_handler_type_fallback(self):
|
||||
"""Test unknown handler type falls back to standard."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="unknown_type")
|
||||
|
||||
# Called twice - once for unknown fallback
|
||||
assert mock_handler_class.call_count >= 1
|
||||
|
||||
def test_handler_type_from_settings_snapshot(self):
|
||||
"""Test handler type from settings snapshot."""
|
||||
mock_llm = Mock()
|
||||
settings_snapshot = {"citation.handler_type": "forced"}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_handler_type_from_settings_snapshot_dict_value(self):
|
||||
"""Test handler type from settings snapshot with dict value."""
|
||||
mock_llm = Mock()
|
||||
settings_snapshot = {"citation.handler_type": {"value": "precision"}}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_handler_type_case_insensitive(self):
|
||||
"""Test handler type is case insensitive."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="FORCED")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
|
||||
class TestCitationHandlerMethods:
|
||||
"""Tests for CitationHandler method delegation."""
|
||||
|
||||
def test_analyze_initial_delegation(self):
|
||||
"""Test analyze_initial delegates to handler."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler.analyze_initial.return_value = {"result": "test"}
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
result = handler.analyze_initial("test query", [])
|
||||
|
||||
mock_handler.analyze_initial.assert_called_once_with(
|
||||
"test query", []
|
||||
)
|
||||
assert result == {"result": "test"}
|
||||
|
||||
def test_analyze_followup_delegation(self):
|
||||
"""Test analyze_followup delegates to handler."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler.analyze_followup.return_value = {"result": "followup"}
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
result = handler.analyze_followup(
|
||||
"test question", [], "previous knowledge", 5
|
||||
)
|
||||
|
||||
mock_handler.analyze_followup.assert_called_once_with(
|
||||
"test question", [], "previous knowledge", 5
|
||||
)
|
||||
assert result == {"result": "followup"}
|
||||
|
||||
def test_backward_compatibility_methods_exposed(self):
|
||||
"""Test backward compatibility methods are exposed."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_create_docs = Mock()
|
||||
mock_format_sources = Mock()
|
||||
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = mock_create_docs
|
||||
mock_handler._format_sources = mock_format_sources
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
assert handler._create_documents == mock_create_docs
|
||||
assert handler._format_sources == mock_format_sources
|
||||
|
||||
|
||||
class TestCitationHandlerSettings:
|
||||
"""Tests for CitationHandler settings handling."""
|
||||
|
||||
def test_empty_settings_snapshot(self):
|
||||
"""Test with empty settings snapshot."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot={})
|
||||
|
||||
# Should use standard handler as default
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_settings_passed_to_handler(self):
|
||||
"""Test settings are passed to underlying handler."""
|
||||
mock_llm = Mock()
|
||||
settings_snapshot = {"some_setting": "value"}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings_snapshot)
|
||||
|
||||
call_kwargs = mock_handler_class.call_args[1]
|
||||
assert call_kwargs["settings_snapshot"] == settings_snapshot
|
||||
|
||||
def test_llm_passed_to_handler(self):
|
||||
"""Test LLM is passed to underlying handler."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = Mock()
|
||||
mock_handler._create_documents = Mock()
|
||||
mock_handler._format_sources = Mock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm)
|
||||
|
||||
call_args = mock_handler_class.call_args[0]
|
||||
assert call_args[0] == mock_llm
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for CitationHandler factory logic, settings extraction, and delegation."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch targets -- the handler classes are lazily imported inside
|
||||
# _create_handler(), so we patch them at their source modules.
|
||||
# ---------------------------------------------------------------------------
|
||||
STANDARD_PATH = (
|
||||
"local_deep_research.citation_handlers."
|
||||
"standard_citation_handler.StandardCitationHandler"
|
||||
)
|
||||
FORCED_PATH = (
|
||||
"local_deep_research.citation_handlers."
|
||||
"forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
)
|
||||
PRECISION_PATH = (
|
||||
"local_deep_research.citation_handlers."
|
||||
"precision_extraction_handler.PrecisionExtractionHandler"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
return MagicMock(name="mock_llm")
|
||||
|
||||
|
||||
# ── Handler type selection ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHandlerTypeSelection:
|
||||
"""Verify that the correct handler implementation is instantiated."""
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_default_no_handler_type_no_settings(self, mock_cls, mock_llm):
|
||||
"""No handler_type and no settings -> StandardCitationHandler."""
|
||||
handler = CitationHandler(mock_llm)
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_handler_type_standard(self, mock_cls, mock_llm):
|
||||
"""Explicit handler_type='standard' -> StandardCitationHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="standard")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(FORCED_PATH)
|
||||
def test_handler_type_forced(self, mock_cls, mock_llm):
|
||||
"""handler_type='forced' -> ForcedAnswerCitationHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="forced")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(FORCED_PATH)
|
||||
def test_handler_type_forced_answer(self, mock_cls, mock_llm):
|
||||
"""handler_type='forced_answer' -> ForcedAnswerCitationHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="forced_answer")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(FORCED_PATH)
|
||||
def test_handler_type_browsecomp(self, mock_cls, mock_llm):
|
||||
"""handler_type='browsecomp' -> ForcedAnswerCitationHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="browsecomp")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(PRECISION_PATH)
|
||||
def test_handler_type_precision(self, mock_cls, mock_llm):
|
||||
"""handler_type='precision' -> PrecisionExtractionHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="precision")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(PRECISION_PATH)
|
||||
def test_handler_type_precision_extraction(self, mock_cls, mock_llm):
|
||||
"""handler_type='precision_extraction' -> PrecisionExtractionHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="precision_extraction")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(PRECISION_PATH)
|
||||
def test_handler_type_simpleqa(self, mock_cls, mock_llm):
|
||||
"""handler_type='simpleqa' -> PrecisionExtractionHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="simpleqa")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_unknown_handler_type_falls_back_to_standard(
|
||||
self, mock_cls, mock_llm
|
||||
):
|
||||
"""Unknown handler_type -> StandardCitationHandler with a warning."""
|
||||
handler = CitationHandler(mock_llm, handler_type="unknown_type")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_handler_type_is_case_insensitive(self, mock_cls, mock_llm):
|
||||
"""handler_type='STANDARD' (uppercase) -> StandardCitationHandler."""
|
||||
handler = CitationHandler(mock_llm, handler_type="STANDARD")
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
|
||||
# ── Settings snapshot extraction ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSettingsSnapshotExtraction:
|
||||
"""Verify that handler_type is correctly extracted from settings_snapshot."""
|
||||
|
||||
@patch(FORCED_PATH)
|
||||
def test_handler_type_from_settings_string_value(self, mock_cls, mock_llm):
|
||||
"""settings_snapshot with plain string value for citation.handler_type."""
|
||||
snapshot = {"citation.handler_type": "forced"}
|
||||
handler = CitationHandler(mock_llm, settings_snapshot=snapshot)
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(FORCED_PATH)
|
||||
def test_handler_type_from_settings_dict_with_value_key(
|
||||
self, mock_cls, mock_llm
|
||||
):
|
||||
"""settings_snapshot with dict {'value': 'forced'} structure."""
|
||||
snapshot = {"citation.handler_type": {"value": "forced"}}
|
||||
handler = CitationHandler(mock_llm, settings_snapshot=snapshot)
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(PRECISION_PATH)
|
||||
def test_explicit_handler_type_overrides_settings(self, mock_cls, mock_llm):
|
||||
"""Explicit handler_type takes precedence over settings_snapshot."""
|
||||
snapshot = {"citation.handler_type": "standard"}
|
||||
handler = CitationHandler(
|
||||
mock_llm, handler_type="precision", settings_snapshot=snapshot
|
||||
)
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot=snapshot)
|
||||
assert handler._handler is mock_cls.return_value
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_settings_snapshot_none_defaults_to_empty_dict(
|
||||
self, mock_cls, mock_llm
|
||||
):
|
||||
"""settings_snapshot=None is treated as {}."""
|
||||
handler = CitationHandler(mock_llm, settings_snapshot=None)
|
||||
mock_cls.assert_called_once_with(mock_llm, settings_snapshot={})
|
||||
assert handler.settings_snapshot == {}
|
||||
|
||||
|
||||
# ── Delegation ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDelegation:
|
||||
"""Verify that public methods and attributes delegate to the inner handler."""
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_analyze_initial_delegates(self, mock_cls, mock_llm):
|
||||
"""analyze_initial forwards to _handler.analyze_initial."""
|
||||
mock_inner = mock_cls.return_value
|
||||
mock_inner.analyze_initial.return_value = {"result": "initial"}
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
result = handler.analyze_initial(
|
||||
"test query", [{"url": "http://example.com"}]
|
||||
)
|
||||
|
||||
mock_inner.analyze_initial.assert_called_once_with(
|
||||
"test query", [{"url": "http://example.com"}]
|
||||
)
|
||||
assert result == {"result": "initial"}
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_analyze_followup_delegates_with_all_args(self, mock_cls, mock_llm):
|
||||
"""analyze_followup forwards all arguments to _handler.analyze_followup."""
|
||||
mock_inner = mock_cls.return_value
|
||||
mock_inner.analyze_followup.return_value = {"result": "followup"}
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
result = handler.analyze_followup(
|
||||
"follow-up question",
|
||||
[{"url": "http://example.com"}],
|
||||
"previous knowledge text",
|
||||
5,
|
||||
)
|
||||
|
||||
mock_inner.analyze_followup.assert_called_once_with(
|
||||
"follow-up question",
|
||||
[{"url": "http://example.com"}],
|
||||
"previous knowledge text",
|
||||
5,
|
||||
)
|
||||
assert result == {"result": "followup"}
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_create_documents_exposed_from_handler(self, mock_cls, mock_llm):
|
||||
"""_create_documents is exposed from the inner handler."""
|
||||
mock_inner = mock_cls.return_value
|
||||
handler = CitationHandler(mock_llm)
|
||||
assert handler._create_documents is mock_inner._create_documents
|
||||
|
||||
@patch(STANDARD_PATH)
|
||||
def test_format_sources_exposed_from_handler(self, mock_cls, mock_llm):
|
||||
"""_format_sources is exposed from the inner handler."""
|
||||
mock_inner = mock_cls.return_value
|
||||
handler = CitationHandler(mock_llm)
|
||||
assert handler._format_sources is mock_inner._format_sources
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
Tests for citation_handler.py - Strategy Selection and Handler Delegation
|
||||
|
||||
Tests cover:
|
||||
- Handler instantiation based on type
|
||||
- Alias mappings (browsecomp -> forced, simpleqa -> precision)
|
||||
- Fallback behavior for unknown types
|
||||
- Proper delegation to underlying handlers
|
||||
|
||||
These tests ensure the correct citation handler is selected for different use cases.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestHandlerInstantiation:
|
||||
"""Tests for handler instantiation based on type."""
|
||||
|
||||
def test_standard_handler_creates(self):
|
||||
"""'standard' creates StandardCitationHandler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm, handler_type="standard")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
assert handler._handler == mock_handler
|
||||
|
||||
def test_forced_handler_creates(self):
|
||||
"""'forced' creates ForcedAnswerCitationHandler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm, handler_type="forced")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
assert handler._handler == mock_handler
|
||||
|
||||
def test_precision_handler_creates(self):
|
||||
"""'precision' creates PrecisionExtractionHandler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm, handler_type="precision")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
assert handler._handler == mock_handler
|
||||
|
||||
def test_browsecomp_alias(self):
|
||||
"""'browsecomp' maps to ForcedAnswerCitationHandler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="browsecomp")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_simpleqa_alias(self):
|
||||
"""'simpleqa' maps to PrecisionExtractionHandler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="simpleqa")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_unknown_handler_fallback(self):
|
||||
"""Unknown type falls back to standard."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="completely_unknown_type")
|
||||
|
||||
# Should fall back to standard handler
|
||||
assert mock_handler_class.call_count >= 1
|
||||
|
||||
def test_handler_type_case_insensitive(self):
|
||||
"""'STANDARD', 'Standard' work."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
# Test uppercase
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="FORCED")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
# Test mixed case
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="Precision")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
|
||||
class TestHandlerDelegation:
|
||||
"""Tests for method delegation to underlying handlers."""
|
||||
|
||||
def test_analyze_initial_string_input(self):
|
||||
"""String search_results handled."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler.analyze_initial.return_value = {"answer": "test"}
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
# Call with string input
|
||||
result = handler.analyze_initial(
|
||||
"test query", "string search results"
|
||||
)
|
||||
|
||||
mock_handler.analyze_initial.assert_called_once_with(
|
||||
"test query", "string search results"
|
||||
)
|
||||
assert result == {"answer": "test"}
|
||||
|
||||
def test_analyze_initial_list_input(self):
|
||||
"""List of dicts handled."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler.analyze_initial.return_value = {
|
||||
"answer": "list result"
|
||||
}
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
search_results = [
|
||||
{"title": "Result 1", "link": "http://example.com"},
|
||||
{"title": "Result 2", "link": "http://example2.com"},
|
||||
]
|
||||
|
||||
handler.analyze_initial("test query", search_results)
|
||||
|
||||
mock_handler.analyze_initial.assert_called_once_with(
|
||||
"test query", search_results
|
||||
)
|
||||
|
||||
def test_analyze_followup_params_passed(self):
|
||||
"""All params passed through."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler.analyze_followup.return_value = {"followup": "result"}
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
handler.analyze_followup(
|
||||
"followup question",
|
||||
[{"title": "Result", "link": "http://example.com"}],
|
||||
"previous knowledge text",
|
||||
5,
|
||||
)
|
||||
|
||||
mock_handler.analyze_followup.assert_called_once_with(
|
||||
"followup question",
|
||||
[{"title": "Result", "link": "http://example.com"}],
|
||||
"previous knowledge text",
|
||||
5,
|
||||
)
|
||||
|
||||
def test_handler_receives_settings_snapshot(self):
|
||||
"""Settings propagated to handler."""
|
||||
mock_llm = MagicMock()
|
||||
settings = {
|
||||
"some_setting": "value",
|
||||
"another_setting": {"nested": True},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings)
|
||||
|
||||
# Check that settings were passed to handler
|
||||
call_kwargs = mock_handler_class.call_args[1]
|
||||
assert call_kwargs["settings_snapshot"] == settings
|
||||
|
||||
def test_handler_llm_instance_passed(self):
|
||||
"""LLM instance correctly passed."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.model_name = "test-model"
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm)
|
||||
|
||||
# LLM should be passed as first positional arg
|
||||
call_args = mock_handler_class.call_args[0]
|
||||
assert call_args[0] == mock_llm
|
||||
|
||||
|
||||
class TestHandlerTypeAliases:
|
||||
"""Tests for all handler type aliases."""
|
||||
|
||||
def test_forced_answer_alias(self):
|
||||
"""'forced_answer' works."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="forced_answer")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_precision_extraction_alias(self):
|
||||
"""'precision_extraction' works."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, handler_type="precision_extraction")
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Tests for backward compatibility."""
|
||||
|
||||
def test_internal_methods_exposed(self):
|
||||
"""_create_documents and _format_sources exposed on handler."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_create_docs = MagicMock()
|
||||
mock_format_sources = MagicMock()
|
||||
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = mock_create_docs
|
||||
mock_handler._format_sources = mock_format_sources
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
handler = CitationHandler(mock_llm)
|
||||
|
||||
# These should be exposed for backward compatibility
|
||||
assert handler._create_documents == mock_create_docs
|
||||
assert handler._format_sources == mock_format_sources
|
||||
|
||||
def test_default_handler_without_type(self):
|
||||
"""No handler_type defaults to standard."""
|
||||
mock_llm = MagicMock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm) # No handler_type specified
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
|
||||
class TestSettingsSnapshotHandlerType:
|
||||
"""Tests for handler type from settings snapshot."""
|
||||
|
||||
def test_handler_from_settings_direct_value(self):
|
||||
"""Handler type from settings as direct value."""
|
||||
mock_llm = MagicMock()
|
||||
settings = {"citation.handler_type": "forced"}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.forced_answer_citation_handler.ForcedAnswerCitationHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_handler_from_settings_dict_value(self):
|
||||
"""Handler type from settings as dict with value key."""
|
||||
mock_llm = MagicMock()
|
||||
settings = {"citation.handler_type": {"value": "precision"}}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
CitationHandler(mock_llm, settings_snapshot=settings)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
|
||||
def test_explicit_type_overrides_settings(self):
|
||||
"""Explicit handler_type overrides settings snapshot."""
|
||||
mock_llm = MagicMock()
|
||||
settings = {"citation.handler_type": "forced"}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.precision_extraction_handler.PrecisionExtractionHandler"
|
||||
) as mock_handler_class:
|
||||
mock_handler = MagicMock()
|
||||
mock_handler._create_documents = MagicMock()
|
||||
mock_handler._format_sources = MagicMock()
|
||||
mock_handler_class.return_value = mock_handler
|
||||
|
||||
from local_deep_research.citation_handler import CitationHandler
|
||||
|
||||
# Explicit type should override settings
|
||||
CitationHandler(
|
||||
mock_llm, handler_type="precision", settings_snapshot=settings
|
||||
)
|
||||
|
||||
mock_handler_class.assert_called_once()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Tests for constants.py
|
||||
|
||||
Tests cover:
|
||||
- USER_AGENT constant
|
||||
- BROWSER_USER_AGENT constant
|
||||
- ResearchStatus StrEnum
|
||||
- Rate limiting constants
|
||||
- Snippet length constants
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""Tests for USER_AGENT constant."""
|
||||
|
||||
def test_user_agent_exists(self):
|
||||
"""Test USER_AGENT constant exists."""
|
||||
from local_deep_research.constants import USER_AGENT
|
||||
|
||||
assert USER_AGENT is not None
|
||||
assert isinstance(USER_AGENT, str)
|
||||
|
||||
def test_user_agent_contains_project_name(self):
|
||||
"""Test USER_AGENT contains project name."""
|
||||
from local_deep_research.constants import USER_AGENT
|
||||
|
||||
assert "Local-Deep-Research" in USER_AGENT
|
||||
|
||||
def test_user_agent_contains_version(self):
|
||||
"""Test USER_AGENT contains version."""
|
||||
from local_deep_research.constants import USER_AGENT
|
||||
from local_deep_research.__version__ import __version__
|
||||
|
||||
assert __version__ in USER_AGENT
|
||||
|
||||
def test_user_agent_contains_github_url(self):
|
||||
"""Test USER_AGENT contains GitHub URL."""
|
||||
from local_deep_research.constants import USER_AGENT
|
||||
|
||||
assert "github.com/LearningCircuit/local-deep-research" in USER_AGENT
|
||||
|
||||
def test_user_agent_contains_description(self):
|
||||
"""Test USER_AGENT contains description."""
|
||||
from local_deep_research.constants import USER_AGENT
|
||||
|
||||
assert "Academic Research Tool" in USER_AGENT
|
||||
|
||||
|
||||
class TestBrowserUserAgent:
|
||||
"""Tests for BROWSER_USER_AGENT constant."""
|
||||
|
||||
def test_browser_user_agent_exists(self):
|
||||
"""Test BROWSER_USER_AGENT constant exists."""
|
||||
from local_deep_research.constants import BROWSER_USER_AGENT
|
||||
|
||||
assert BROWSER_USER_AGENT is not None
|
||||
assert isinstance(BROWSER_USER_AGENT, str)
|
||||
|
||||
def test_browser_user_agent_looks_like_browser(self):
|
||||
"""Test BROWSER_USER_AGENT looks like a browser."""
|
||||
from local_deep_research.constants import BROWSER_USER_AGENT
|
||||
|
||||
assert "Mozilla" in BROWSER_USER_AGENT
|
||||
assert "AppleWebKit" in BROWSER_USER_AGENT
|
||||
|
||||
def test_browser_user_agent_contains_chrome(self):
|
||||
"""Test BROWSER_USER_AGENT contains Chrome."""
|
||||
from local_deep_research.constants import BROWSER_USER_AGENT
|
||||
|
||||
assert "Chrome" in BROWSER_USER_AGENT
|
||||
|
||||
def test_browser_user_agent_contains_windows(self):
|
||||
"""Test BROWSER_USER_AGENT contains Windows."""
|
||||
from local_deep_research.constants import BROWSER_USER_AGENT
|
||||
|
||||
assert "Windows" in BROWSER_USER_AGENT
|
||||
|
||||
|
||||
class TestVersionImport:
|
||||
"""Tests for version import."""
|
||||
|
||||
def test_version_exists(self):
|
||||
"""Test __version__ exists."""
|
||||
from local_deep_research.__version__ import __version__
|
||||
|
||||
assert __version__ is not None
|
||||
assert isinstance(__version__, str)
|
||||
|
||||
def test_version_not_empty(self):
|
||||
"""Test __version__ is not empty."""
|
||||
from local_deep_research.__version__ import __version__
|
||||
|
||||
assert len(__version__) > 0
|
||||
|
||||
|
||||
class TestResearchStatus:
|
||||
"""Tests for ResearchStatus StrEnum."""
|
||||
|
||||
def test_research_status_is_strenum(self):
|
||||
"""ResearchStatus is a StrEnum."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert issubclass(ResearchStatus, StrEnum)
|
||||
|
||||
def test_completed_status(self):
|
||||
"""COMPLETED is 'completed'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.COMPLETED == "completed"
|
||||
|
||||
def test_suspended_status(self):
|
||||
"""SUSPENDED is 'suspended'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.SUSPENDED == "suspended"
|
||||
|
||||
def test_failed_status(self):
|
||||
"""FAILED is 'failed'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.FAILED == "failed"
|
||||
|
||||
def test_in_progress_status(self):
|
||||
"""IN_PROGRESS is 'in_progress'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.IN_PROGRESS == "in_progress"
|
||||
|
||||
def test_pending_status(self):
|
||||
"""PENDING is 'pending'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.PENDING == "pending"
|
||||
|
||||
def test_error_status(self):
|
||||
"""ERROR is 'error'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.ERROR == "error"
|
||||
|
||||
def test_queued_status(self):
|
||||
"""QUEUED is 'queued'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.QUEUED == "queued"
|
||||
|
||||
def test_cancelled_status(self):
|
||||
"""CANCELLED is 'cancelled'."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_string_comparison(self):
|
||||
"""StrEnum values compare equal to plain strings."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert ResearchStatus.COMPLETED == "completed"
|
||||
assert "completed" == ResearchStatus.COMPLETED
|
||||
assert ResearchStatus.IN_PROGRESS != "completed"
|
||||
|
||||
def test_membership_check(self):
|
||||
"""StrEnum supports 'in' membership testing."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
assert "completed" in ResearchStatus
|
||||
assert "in_progress" in ResearchStatus
|
||||
assert "nonexistent" not in ResearchStatus
|
||||
|
||||
def test_all_statuses_are_strings(self):
|
||||
"""All status values are strings."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
for member in ResearchStatus:
|
||||
assert isinstance(member, str)
|
||||
assert isinstance(member.value, str)
|
||||
|
||||
def test_all_statuses_are_unique(self):
|
||||
"""All status values are distinct."""
|
||||
from local_deep_research.constants import ResearchStatus
|
||||
|
||||
values = [member.value for member in ResearchStatus]
|
||||
assert len(values) == len(set(values))
|
||||
|
||||
def test_importable_from_database_models(self):
|
||||
"""ResearchStatus is importable from database.models (re-export)."""
|
||||
from local_deep_research.constants import ResearchStatus as RS1
|
||||
from local_deep_research.database.models.research import (
|
||||
ResearchStatus as RS2,
|
||||
)
|
||||
|
||||
assert RS1 is RS2
|
||||
|
||||
|
||||
class TestSnippetLengthConstants:
|
||||
"""Tests for snippet length constants."""
|
||||
|
||||
def test_snippet_length_short(self):
|
||||
"""SNIPPET_LENGTH_SHORT is a positive integer."""
|
||||
from local_deep_research.constants import SNIPPET_LENGTH_SHORT
|
||||
|
||||
assert isinstance(SNIPPET_LENGTH_SHORT, int)
|
||||
assert SNIPPET_LENGTH_SHORT > 0
|
||||
|
||||
def test_snippet_length_long(self):
|
||||
"""SNIPPET_LENGTH_LONG is a positive integer."""
|
||||
from local_deep_research.constants import SNIPPET_LENGTH_LONG
|
||||
|
||||
assert isinstance(SNIPPET_LENGTH_LONG, int)
|
||||
assert SNIPPET_LENGTH_LONG > 0
|
||||
|
||||
def test_long_greater_than_short(self):
|
||||
"""SNIPPET_LENGTH_LONG > SNIPPET_LENGTH_SHORT."""
|
||||
from local_deep_research.constants import (
|
||||
SNIPPET_LENGTH_LONG,
|
||||
SNIPPET_LENGTH_SHORT,
|
||||
)
|
||||
|
||||
assert SNIPPET_LENGTH_LONG > SNIPPET_LENGTH_SHORT
|
||||
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
Tests for report_generator.py
|
||||
|
||||
Tests cover:
|
||||
- IntegratedReportGenerator initialization
|
||||
- get_report_generator function
|
||||
- Report structure determination
|
||||
- Section generation
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
class TestGetReportGenerator:
|
||||
"""Tests for get_report_generator function."""
|
||||
|
||||
def test_get_report_generator_default(self):
|
||||
"""Test get_report_generator returns IntegratedReportGenerator."""
|
||||
with patch(
|
||||
"local_deep_research.report_generator.IntegratedReportGenerator"
|
||||
) as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
get_report_generator,
|
||||
)
|
||||
|
||||
result = get_report_generator()
|
||||
|
||||
mock_class.assert_called_once_with(search_system=None)
|
||||
assert result == mock_instance
|
||||
|
||||
def test_get_report_generator_with_search_system(self):
|
||||
"""Test get_report_generator with search system."""
|
||||
mock_search_system = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.report_generator.IntegratedReportGenerator"
|
||||
) as mock_class:
|
||||
mock_instance = Mock()
|
||||
mock_class.return_value = mock_instance
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
get_report_generator,
|
||||
)
|
||||
|
||||
get_report_generator(search_system=mock_search_system)
|
||||
|
||||
mock_class.assert_called_once_with(search_system=mock_search_system)
|
||||
|
||||
|
||||
class TestIntegratedReportGeneratorInit:
|
||||
"""Tests for IntegratedReportGenerator initialization."""
|
||||
|
||||
def test_init_with_search_system(self):
|
||||
"""Test initialization with search system."""
|
||||
mock_search_system = Mock()
|
||||
mock_search_system.model = Mock()
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
assert generator.search_system == mock_search_system
|
||||
assert generator.model == mock_search_system.model
|
||||
assert generator.searches_per_section == 2
|
||||
|
||||
def test_init_with_llm(self):
|
||||
"""Test initialization with LLM only."""
|
||||
mock_llm = Mock()
|
||||
|
||||
with patch(
|
||||
"local_deep_research.report_generator.AdvancedSearchSystem"
|
||||
) as mock_search_class:
|
||||
mock_search = Mock()
|
||||
mock_search_class.return_value = mock_search
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(llm=mock_llm)
|
||||
|
||||
assert generator.model == mock_llm
|
||||
mock_search_class.assert_called_once_with(llm=mock_llm)
|
||||
|
||||
def test_init_with_custom_searches_per_section(self):
|
||||
"""Test initialization with custom searches per section."""
|
||||
mock_search_system = Mock()
|
||||
mock_search_system.model = Mock()
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(
|
||||
search_system=mock_search_system, searches_per_section=5
|
||||
)
|
||||
|
||||
assert generator.searches_per_section == 5
|
||||
|
||||
def test_init_search_system_overrides_llm(self):
|
||||
"""Test search system's LLM is used when both provided."""
|
||||
mock_search_system = Mock()
|
||||
mock_search_system.model = Mock(name="search_system_model")
|
||||
mock_external_llm = Mock(name="external_llm")
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(
|
||||
search_system=mock_search_system, llm=mock_external_llm
|
||||
)
|
||||
|
||||
# When both provided, external llm is used if specified
|
||||
assert generator.model == mock_external_llm
|
||||
|
||||
|
||||
class TestDetermineReportStructure:
|
||||
"""Tests for _determine_report_structure method."""
|
||||
|
||||
def test_determine_structure_parses_response(self):
|
||||
"""Test structure is parsed from LLM response."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = """
|
||||
STRUCTURE
|
||||
1. Introduction
|
||||
- Overview | Provide context
|
||||
- Background | Historical context
|
||||
2. Main Analysis
|
||||
- Data | Present findings
|
||||
END_STRUCTURE
|
||||
"""
|
||||
mock_llm.invoke.return_value = mock_response
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
findings = {"current_knowledge": "Test content " * 100}
|
||||
structure = generator._determine_report_structure(
|
||||
findings, "test query"
|
||||
)
|
||||
|
||||
assert len(structure) == 2
|
||||
assert structure[0]["name"] == "Introduction"
|
||||
assert len(structure[0]["subsections"]) == 2
|
||||
assert structure[0]["subsections"][0]["name"] == "Overview"
|
||||
assert structure[0]["subsections"][0]["purpose"] == "Provide context"
|
||||
|
||||
def test_determine_structure_removes_source_sections(self):
|
||||
"""Test source-related sections are removed."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = """
|
||||
STRUCTURE
|
||||
1. Introduction
|
||||
- Overview | Provide context
|
||||
2. Sources and References
|
||||
- Citations | List sources
|
||||
END_STRUCTURE
|
||||
"""
|
||||
mock_llm.invoke.return_value = mock_response
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
findings = {"current_knowledge": "Test content " * 100}
|
||||
structure = generator._determine_report_structure(
|
||||
findings, "test query"
|
||||
)
|
||||
|
||||
# Should remove the Sources section
|
||||
assert len(structure) == 1
|
||||
assert structure[0]["name"] == "Introduction"
|
||||
|
||||
def test_determine_structure_handles_subsection_without_purpose(self):
|
||||
"""Test subsections without purpose get default."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = """
|
||||
STRUCTURE
|
||||
1. Introduction
|
||||
- Overview
|
||||
END_STRUCTURE
|
||||
"""
|
||||
mock_llm.invoke.return_value = mock_response
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
findings = {"current_knowledge": "Test content " * 100}
|
||||
structure = generator._determine_report_structure(
|
||||
findings, "test query"
|
||||
)
|
||||
|
||||
assert structure[0]["subsections"][0]["name"] == "Overview"
|
||||
assert "Overview" in structure[0]["subsections"][0]["purpose"]
|
||||
|
||||
|
||||
class TestResearchAndGenerateSections:
|
||||
"""Tests for _research_and_generate_sections method."""
|
||||
|
||||
def test_research_sections_basic(self):
|
||||
"""Test basic section research and generation."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.max_iterations = 3
|
||||
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
|
||||
mock_search_system.strategy.max_iterations = 3
|
||||
mock_search_system.analyze_topic.return_value = {
|
||||
"current_knowledge": "Section content here"
|
||||
}
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
structure = [
|
||||
{
|
||||
"name": "Introduction",
|
||||
"subsections": [
|
||||
{"name": "Overview", "purpose": "Provide overview"}
|
||||
],
|
||||
}
|
||||
]
|
||||
initial_findings = {"questions_by_iteration": {}}
|
||||
|
||||
sections = generator._research_and_generate_sections(
|
||||
initial_findings, structure, "test query"
|
||||
)
|
||||
|
||||
assert "Introduction" in sections
|
||||
assert "Section content here" in sections["Introduction"]
|
||||
|
||||
def test_research_sections_preserves_questions(self):
|
||||
"""Test that questions from initial research are preserved."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.max_iterations = 3
|
||||
mock_search_system.analyze_topic.return_value = {
|
||||
"current_knowledge": "Content"
|
||||
}
|
||||
mock_search_system.questions_by_iteration = {}
|
||||
|
||||
mock_strategy = Mock()
|
||||
mock_strategy.questions_by_iteration = {}
|
||||
mock_strategy.settings_snapshot = {"search.iterations": 3}
|
||||
mock_strategy.max_iterations = 3
|
||||
mock_search_system.strategy = mock_strategy
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
initial_findings = {"questions_by_iteration": {"0": ["Q1", "Q2"]}}
|
||||
structure = [
|
||||
{
|
||||
"name": "Section",
|
||||
"subsections": [{"name": "Sub", "purpose": "Test"}],
|
||||
}
|
||||
]
|
||||
|
||||
generator._research_and_generate_sections(
|
||||
initial_findings, structure, "test query"
|
||||
)
|
||||
|
||||
# Questions should be set on strategy
|
||||
assert mock_strategy.questions_by_iteration == {"0": ["Q1", "Q2"]}
|
||||
|
||||
def test_research_sections_creates_subsection_for_empty(self):
|
||||
"""Test subsection is created if none provided."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.max_iterations = 3
|
||||
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
|
||||
mock_search_system.strategy.max_iterations = 3
|
||||
mock_search_system.analyze_topic.return_value = {
|
||||
"current_knowledge": "Content"
|
||||
}
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
structure = [
|
||||
{
|
||||
"name": "Introduction",
|
||||
"subsections": [], # Empty!
|
||||
}
|
||||
]
|
||||
initial_findings = {}
|
||||
|
||||
generator._research_and_generate_sections(
|
||||
initial_findings, structure, "test query"
|
||||
)
|
||||
|
||||
# analyze_topic should still be called
|
||||
mock_search_system.analyze_topic.assert_called()
|
||||
|
||||
|
||||
class TestFormatFinalReport:
|
||||
"""Tests for _format_final_report method."""
|
||||
|
||||
def test_format_final_report_structure(self):
|
||||
"""Test final report has correct structure."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.all_links_of_system = []
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
sections = {"Introduction": "# Introduction\nContent here"}
|
||||
structure = [
|
||||
{
|
||||
"name": "Introduction",
|
||||
"subsections": [
|
||||
{"name": "Overview", "purpose": "Provide overview"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
with patch(
|
||||
"local_deep_research.report_generator.importlib.import_module"
|
||||
) as mock_import:
|
||||
mock_utilities = Mock()
|
||||
mock_utilities.search_utilities.format_links_to_markdown.return_value = "- [Link](url)"
|
||||
mock_import.return_value = mock_utilities
|
||||
|
||||
result = generator._format_final_report(
|
||||
sections, structure, "test query"
|
||||
)
|
||||
|
||||
assert "content" in result
|
||||
assert "metadata" in result
|
||||
assert "Table of Contents" in result["content"]
|
||||
assert "Introduction" in result["content"]
|
||||
assert "Sources" in result["content"]
|
||||
|
||||
def test_format_final_report_metadata(self):
|
||||
"""Test final report metadata."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.all_links_of_system = [
|
||||
{"link": "url1"},
|
||||
{"link": "url2"},
|
||||
]
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
sections = {"Section1": "Content"}
|
||||
structure = [{"name": "Section1", "subsections": []}]
|
||||
|
||||
with patch(
|
||||
"local_deep_research.report_generator.importlib.import_module"
|
||||
) as mock_import:
|
||||
mock_utilities = Mock()
|
||||
mock_utilities.search_utilities.format_links_to_markdown.return_value = ""
|
||||
mock_import.return_value = mock_utilities
|
||||
|
||||
result = generator._format_final_report(
|
||||
sections, structure, "test query"
|
||||
)
|
||||
|
||||
assert result["metadata"]["initial_sources"] == 2
|
||||
assert result["metadata"]["sections_researched"] == 1
|
||||
assert result["metadata"]["query"] == "test query"
|
||||
assert "generated_at" in result["metadata"]
|
||||
|
||||
|
||||
class TestGenerateReport:
|
||||
"""Tests for generate_report method."""
|
||||
|
||||
def test_generate_report_full_flow(self):
|
||||
"""Test full report generation flow."""
|
||||
mock_search_system = Mock()
|
||||
mock_llm = Mock()
|
||||
mock_search_system.model = mock_llm
|
||||
mock_search_system.max_iterations = 3
|
||||
mock_search_system.strategy.settings_snapshot = {"search.iterations": 3}
|
||||
mock_search_system.strategy.max_iterations = 3
|
||||
mock_search_system.all_links_of_system = []
|
||||
|
||||
# Mock LLM response for structure
|
||||
mock_response = Mock()
|
||||
mock_response.content = """
|
||||
STRUCTURE
|
||||
1. Introduction
|
||||
- Overview | Provide overview
|
||||
END_STRUCTURE
|
||||
"""
|
||||
mock_llm.invoke.return_value = mock_response
|
||||
|
||||
# Mock search results
|
||||
mock_search_system.analyze_topic.return_value = {
|
||||
"current_knowledge": "Test content"
|
||||
}
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
initial_findings = {"current_knowledge": "Initial research"}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.report_generator.importlib.import_module"
|
||||
) as mock_import:
|
||||
mock_utilities = Mock()
|
||||
mock_utilities.search_utilities.format_links_to_markdown.return_value = ""
|
||||
mock_import.return_value = mock_utilities
|
||||
|
||||
result = generator.generate_report(initial_findings, "test query")
|
||||
|
||||
assert "content" in result
|
||||
assert "metadata" in result
|
||||
|
||||
|
||||
class TestGenerateErrorReport:
|
||||
"""Tests for _generate_error_report method."""
|
||||
|
||||
def test_generate_error_report(self):
|
||||
"""Test error report generation."""
|
||||
mock_search_system = Mock()
|
||||
mock_search_system.model = Mock()
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
result = generator._generate_error_report(
|
||||
"test query", "Something went wrong"
|
||||
)
|
||||
|
||||
assert "ERROR REPORT" in result
|
||||
assert "test query" in result
|
||||
assert "Something went wrong" in result
|
||||
|
||||
|
||||
class TestGenerateSectionsDeprecated:
|
||||
"""Tests for deprecated _generate_sections method."""
|
||||
|
||||
def test_generate_sections_returns_empty(self):
|
||||
"""Test deprecated _generate_sections returns empty dict."""
|
||||
mock_search_system = Mock()
|
||||
mock_search_system.model = Mock()
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
)
|
||||
|
||||
generator = IntegratedReportGenerator(search_system=mock_search_system)
|
||||
|
||||
result = generator._generate_sections({}, {}, [], "query")
|
||||
|
||||
assert result == {}
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for IntegratedReportGenerator.__init__ and get_report_generator factory."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.report_generator import (
|
||||
IntegratedReportGenerator,
|
||||
get_report_generator,
|
||||
)
|
||||
|
||||
|
||||
MODULE = "local_deep_research.report_generator"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_get_llm():
|
||||
with patch(f"{MODULE}.get_llm") as m:
|
||||
m.return_value = MagicMock(name="default_llm")
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_advanced_search_system():
|
||||
with patch(f"{MODULE}.AdvancedSearchSystem") as m:
|
||||
m.return_value = MagicMock(name="constructed_search_system")
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_get_setting():
|
||||
with patch(f"{MODULE}.get_setting_from_snapshot") as m:
|
||||
# By default, return the default value passed to it
|
||||
m.side_effect = lambda key, default=None, settings_snapshot=None: (
|
||||
default
|
||||
)
|
||||
yield m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_report_generator factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetReportGenerator:
|
||||
"""Tests for the get_report_generator factory function."""
|
||||
|
||||
def test_returns_integrated_report_generator_instance(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
result = get_report_generator()
|
||||
assert isinstance(result, IntegratedReportGenerator)
|
||||
|
||||
def test_passes_search_system_to_constructor(self, mock_get_setting):
|
||||
search_system = MagicMock(name="my_search_system")
|
||||
search_system.model = MagicMock(name="ss_model")
|
||||
|
||||
result = get_report_generator(search_system=search_system)
|
||||
|
||||
assert result.search_system is search_system
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__ with search_system provided
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitWithSearchSystem:
|
||||
"""Tests for __init__ when search_system is provided."""
|
||||
|
||||
def test_uses_provided_search_system(self, mock_get_setting):
|
||||
search_system = MagicMock(name="my_search_system")
|
||||
search_system.model = MagicMock(name="ss_model")
|
||||
|
||||
gen = IntegratedReportGenerator(search_system=search_system)
|
||||
|
||||
assert gen.search_system is search_system
|
||||
|
||||
def test_uses_search_system_model_when_no_llm(self, mock_get_setting):
|
||||
search_system = MagicMock(name="my_search_system")
|
||||
ss_model = MagicMock(name="ss_model")
|
||||
search_system.model = ss_model
|
||||
|
||||
gen = IntegratedReportGenerator(search_system=search_system)
|
||||
|
||||
assert gen.model is ss_model
|
||||
|
||||
def test_uses_provided_llm_over_search_system_model(self, mock_get_setting):
|
||||
search_system = MagicMock(name="my_search_system")
|
||||
search_system.model = MagicMock(name="ss_model")
|
||||
custom_llm = MagicMock(name="custom_llm")
|
||||
|
||||
gen = IntegratedReportGenerator(
|
||||
search_system=search_system, llm=custom_llm
|
||||
)
|
||||
|
||||
assert gen.model is custom_llm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__ with llm only (no search_system)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitWithLlmOnly:
|
||||
"""Tests for __init__ when only llm is provided."""
|
||||
|
||||
def test_creates_advanced_search_system_with_llm(
|
||||
self, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
custom_llm = MagicMock(name="custom_llm")
|
||||
|
||||
IntegratedReportGenerator(llm=custom_llm)
|
||||
|
||||
mock_advanced_search_system.assert_called_once_with(llm=custom_llm)
|
||||
|
||||
def test_sets_model_to_provided_llm(
|
||||
self, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
custom_llm = MagicMock(name="custom_llm")
|
||||
|
||||
gen = IntegratedReportGenerator(llm=custom_llm)
|
||||
|
||||
assert gen.model is custom_llm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__ with neither search_system nor llm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitWithNeither:
|
||||
"""Tests for __init__ when neither search_system nor llm is provided."""
|
||||
|
||||
def test_calls_get_llm(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
IntegratedReportGenerator()
|
||||
|
||||
mock_get_llm.assert_called_once()
|
||||
|
||||
def test_creates_advanced_search_system_with_default_model(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
IntegratedReportGenerator()
|
||||
|
||||
mock_advanced_search_system.assert_called_once_with(
|
||||
llm=mock_get_llm.return_value
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings / attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Tests for configurable attributes set during __init__."""
|
||||
|
||||
def test_searches_per_section_defaults_to_2(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
gen = IntegratedReportGenerator()
|
||||
|
||||
assert gen.searches_per_section == 2
|
||||
|
||||
def test_custom_searches_per_section_is_stored(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
gen = IntegratedReportGenerator(searches_per_section=5)
|
||||
|
||||
assert gen.searches_per_section == 5
|
||||
|
||||
def test_max_context_sections_reads_from_snapshot(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
snapshot = {"report.max_context_sections": 10}
|
||||
mock_get_setting.side_effect = None
|
||||
mock_get_setting.return_value = 10
|
||||
|
||||
gen = IntegratedReportGenerator(settings_snapshot=snapshot)
|
||||
|
||||
# Verify get_setting_from_snapshot was called with correct key
|
||||
calls = mock_get_setting.call_args_list
|
||||
section_call = [
|
||||
c for c in calls if c[0][0] == "report.max_context_sections"
|
||||
]
|
||||
assert len(section_call) == 1
|
||||
assert section_call[0].kwargs["settings_snapshot"] is snapshot
|
||||
assert gen.max_context_sections == 10
|
||||
|
||||
def test_max_context_chars_reads_from_snapshot(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
snapshot = {"report.max_context_chars": 8000}
|
||||
mock_get_setting.side_effect = None
|
||||
mock_get_setting.return_value = 8000
|
||||
|
||||
gen = IntegratedReportGenerator(settings_snapshot=snapshot)
|
||||
|
||||
calls = mock_get_setting.call_args_list
|
||||
chars_call = [c for c in calls if c[0][0] == "report.max_context_chars"]
|
||||
assert len(chars_call) == 1
|
||||
assert chars_call[0].kwargs["settings_snapshot"] is snapshot
|
||||
assert gen.max_context_chars == 8000
|
||||
|
||||
def test_defaults_for_max_context_sections_and_chars(
|
||||
self, mock_get_llm, mock_advanced_search_system, mock_get_setting
|
||||
):
|
||||
gen = IntegratedReportGenerator()
|
||||
|
||||
# mock_get_setting side_effect returns the default kwarg,
|
||||
# so we expect the source-code defaults: 3 and 4000
|
||||
assert gen.max_context_sections == 3
|
||||
assert gen.max_context_chars == 4000
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Tests for search_system.py - AdvancedSearchSystem class.
|
||||
|
||||
Tests cover:
|
||||
- __init__ settings extraction, defaults, factory wiring, follow-up delegation, logging
|
||||
- set_progress_callback forwarding to strategy
|
||||
- analyze_topic search_id generation and delegation
|
||||
- _perform_search settings extraction, progress callbacks, result assembly
|
||||
- Duplicate link avoidance (id check)
|
||||
- NewsSearchCallback error resilience
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_strategy_mock():
|
||||
"""Return a MagicMock that behaves like a strategy returned by create_strategy."""
|
||||
strategy = MagicMock()
|
||||
strategy.analyze_topic.return_value = {"current_knowledge": "test"}
|
||||
strategy.questions_by_iteration = []
|
||||
strategy.all_links_of_system = []
|
||||
strategy.set_progress_callback = MagicMock()
|
||||
return strategy
|
||||
|
||||
|
||||
# Patch paths: top-level imports can be patched on search_system directly,
|
||||
# but create_strategy and NewsSearchCallback are lazily imported inside method
|
||||
# bodies, so they must be patched at their source modules.
|
||||
PATCH_CITATION = "local_deep_research.search_system.CitationHandler"
|
||||
PATCH_QUESTION_GEN = (
|
||||
"local_deep_research.search_system.StandardQuestionGenerator"
|
||||
)
|
||||
PATCH_FINDINGS = "local_deep_research.search_system.FindingsRepository"
|
||||
PATCH_CREATE_STRATEGY = (
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
)
|
||||
PATCH_ENHANCED_FOLLOWUP = (
|
||||
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
|
||||
)
|
||||
PATCH_NEWS_CALLBACK = (
|
||||
"local_deep_research.news.core.search_integration.NewsSearchCallback"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_deps():
|
||||
"""Patch CitationHandler, StandardQuestionGenerator, FindingsRepository,
|
||||
and create_strategy so that AdvancedSearchSystem can be instantiated
|
||||
without real dependencies. Yields a dict of mock objects."""
|
||||
with (
|
||||
patch(PATCH_CITATION) as m_citation,
|
||||
patch(PATCH_QUESTION_GEN) as m_qgen,
|
||||
patch(PATCH_FINDINGS) as m_findings,
|
||||
patch(PATCH_CREATE_STRATEGY) as m_create,
|
||||
):
|
||||
strategy = _make_strategy_mock()
|
||||
m_create.return_value = strategy
|
||||
|
||||
yield {
|
||||
"citation": m_citation,
|
||||
"question_gen": m_qgen,
|
||||
"findings": m_findings,
|
||||
"create_strategy": m_create,
|
||||
"strategy": strategy,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_model():
|
||||
return MagicMock(name="BaseChatModel")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_search():
|
||||
return MagicMock(name="BaseSearchEngine")
|
||||
|
||||
|
||||
def _build_system(mock_model, mock_search, mock_deps, **kwargs):
|
||||
"""Convenience: build an AdvancedSearchSystem with the standard mocks."""
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
return AdvancedSearchSystem(llm=mock_model, search=mock_search, **kwargs)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# __init__ tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""Tests for AdvancedSearchSystem.__init__."""
|
||||
|
||||
def test_settings_snapshot_defaults_to_empty_dict(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model, mock_search, mock_deps, settings_snapshot=None
|
||||
)
|
||||
assert system.settings_snapshot == {}
|
||||
|
||||
# -- max_iterations -------------------------------------------------
|
||||
|
||||
def test_max_iterations_default_is_one(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
assert system.max_iterations == 1
|
||||
|
||||
def test_max_iterations_from_settings_plain_value(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
settings_snapshot={"search.iterations": 5},
|
||||
)
|
||||
assert system.max_iterations == 5
|
||||
|
||||
def test_max_iterations_extracts_value_from_dict(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
settings_snapshot={"search.iterations": {"value": 8}},
|
||||
)
|
||||
assert system.max_iterations == 8
|
||||
|
||||
def test_max_iterations_explicit_overrides_snapshot(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
max_iterations=12,
|
||||
settings_snapshot={"search.iterations": {"value": 8}},
|
||||
)
|
||||
assert system.max_iterations == 12
|
||||
|
||||
# -- questions_per_iteration ----------------------------------------
|
||||
|
||||
def test_questions_per_iteration_default_is_three(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
assert system.questions_per_iteration == 3
|
||||
|
||||
def test_questions_per_iteration_from_settings_dict(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
settings_snapshot={"search.questions_per_iteration": {"value": 7}},
|
||||
)
|
||||
assert system.questions_per_iteration == 7
|
||||
|
||||
# -- strategy creation via factory ----------------------------------
|
||||
|
||||
def test_calls_create_strategy_factory(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
_build_system(mock_model, mock_search, mock_deps)
|
||||
mock_deps["create_strategy"].assert_called_once()
|
||||
|
||||
# -- enhanced-contextual-followup strategy --------------------------
|
||||
|
||||
def test_enhanced_contextual_followup_creates_delegate(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
with patch(PATCH_ENHANCED_FOLLOWUP) as m_followup:
|
||||
m_followup.return_value = _make_strategy_mock()
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
strategy_name="enhanced-contextual-followup",
|
||||
)
|
||||
# create_strategy should have been called for the delegate
|
||||
mock_deps["create_strategy"].assert_called_once()
|
||||
delegate_kwargs = mock_deps["create_strategy"].call_args[1]
|
||||
assert delegate_kwargs["strategy_name"] == "source-based"
|
||||
# EnhancedContextualFollowUpStrategy should have been instantiated
|
||||
m_followup.assert_called_once()
|
||||
assert system.strategy is m_followup.return_value
|
||||
|
||||
# -- programmatic_mode warning --------------------------------------
|
||||
|
||||
def test_programmatic_mode_logs_warning(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
with patch("local_deep_research.search_system.logger") as m_logger:
|
||||
_build_system(
|
||||
mock_model, mock_search, mock_deps, programmatic_mode=True
|
||||
)
|
||||
m_logger.warning.assert_called_once()
|
||||
assert (
|
||||
"programmatic mode" in m_logger.warning.call_args[0][0].lower()
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# set_progress_callback tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSetProgressCallback:
|
||||
"""Tests for set_progress_callback."""
|
||||
|
||||
def test_forwards_callback_to_strategy(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
cb = MagicMock(name="my_callback")
|
||||
system.set_progress_callback(cb)
|
||||
mock_deps["strategy"].set_progress_callback.assert_called_with(cb)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# analyze_topic tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAnalyzeTopic:
|
||||
"""Tests for analyze_topic."""
|
||||
|
||||
def test_generates_search_id_when_not_provided(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
with patch.object(system, "_perform_search", return_value={}) as m_ps:
|
||||
system.analyze_topic("quantum computing")
|
||||
# search_id (2nd positional arg) should be a non-empty string
|
||||
search_id = m_ps.call_args[0][1]
|
||||
assert isinstance(search_id, str) and len(search_id) > 0
|
||||
|
||||
def test_passes_explicit_search_id(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
with patch.object(system, "_perform_search", return_value={}) as m_ps:
|
||||
system.analyze_topic("q", search_id="my-id-99")
|
||||
assert m_ps.call_args[0][1] == "my-id-99"
|
||||
|
||||
def test_returns_perform_search_result(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
expected = {"current_knowledge": "deep"}
|
||||
with patch.object(system, "_perform_search", return_value=expected):
|
||||
result = system.analyze_topic("topic")
|
||||
assert result is expected
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# _perform_search tests
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestPerformSearch:
|
||||
"""Tests for _perform_search."""
|
||||
|
||||
def _run_perform_search(
|
||||
self,
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
settings=None,
|
||||
query="test q",
|
||||
):
|
||||
system = _build_system(
|
||||
mock_model,
|
||||
mock_search,
|
||||
mock_deps,
|
||||
settings_snapshot=settings or {},
|
||||
)
|
||||
progress_calls = []
|
||||
system.progress_callback = lambda m, p, md: progress_calls.append(
|
||||
(m, p, md)
|
||||
)
|
||||
with patch(PATCH_NEWS_CALLBACK):
|
||||
result = system._perform_search(
|
||||
query, "sid-1", True, False, "user1"
|
||||
)
|
||||
return system, result, progress_calls
|
||||
|
||||
def test_extracts_llm_provider_from_settings(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
settings = {"llm.provider": {"value": "anthropic"}}
|
||||
_, _, calls = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps, settings=settings
|
||||
)
|
||||
assert any("anthropic" in str(c) for c in calls)
|
||||
|
||||
def test_extracts_llm_model_from_settings(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
settings = {"llm.model": "claude-3-opus"}
|
||||
_, _, calls = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps, settings=settings
|
||||
)
|
||||
assert any("claude-3-opus" in str(c) for c in calls)
|
||||
|
||||
def test_extracts_search_tool_from_settings(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
settings = {"search.tool": {"value": "google"}}
|
||||
_, _, calls = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps, settings=settings
|
||||
)
|
||||
assert any("google" in str(c) for c in calls)
|
||||
|
||||
def test_calls_progress_callback_with_setup_info(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
_, _, calls = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps
|
||||
)
|
||||
# At least two calls: one for LLM info, one for search tool info
|
||||
assert len(calls) >= 2
|
||||
phases = [c[2].get("phase") for c in calls]
|
||||
assert "setup" in phases
|
||||
|
||||
def test_calls_strategy_analyze_topic(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps, query="my query"
|
||||
)
|
||||
mock_deps["strategy"].analyze_topic.assert_called_once_with("my query")
|
||||
|
||||
def test_updates_questions_by_iteration(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
mock_deps["strategy"].questions_by_iteration = ["q1", "q2"]
|
||||
system, result, _ = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps
|
||||
)
|
||||
assert system.questions_by_iteration == ["q1", "q2"]
|
||||
assert result["questions_by_iteration"] == ["q1", "q2"]
|
||||
|
||||
def test_result_contains_search_system(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system, result, _ = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps
|
||||
)
|
||||
assert result["search_system"] is system
|
||||
|
||||
def test_result_contains_all_links_of_system(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
mock_deps["strategy"].all_links_of_system = [{"url": "http://a.com"}]
|
||||
_, result, _ = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps
|
||||
)
|
||||
assert "all_links_of_system" in result
|
||||
|
||||
def test_result_contains_query(self, mock_model, mock_search, mock_deps):
|
||||
_, result, _ = self._run_perform_search(
|
||||
mock_model, mock_search, mock_deps, query="deep research"
|
||||
)
|
||||
assert result["query"] == "deep research"
|
||||
|
||||
def test_avoids_duplicate_links_when_same_object(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
"""When strategy.all_links_of_system is the SAME object as
|
||||
system.all_links_of_system, no extend should happen (id check)."""
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
# Make the strategy share the exact same list object
|
||||
mock_deps["strategy"].all_links_of_system = system.all_links_of_system
|
||||
system.all_links_of_system.extend(
|
||||
[{"url": "http://a.com"}, {"url": "http://b.com"}]
|
||||
)
|
||||
|
||||
system.progress_callback = lambda m, p, md: None
|
||||
with patch(PATCH_NEWS_CALLBACK):
|
||||
system._perform_search("q", "sid", True, False, "u")
|
||||
|
||||
# If id-check were removed, self-extend would double to 4
|
||||
assert len(system.all_links_of_system) == 2
|
||||
|
||||
def test_extends_links_when_different_objects(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
"""When strategy.all_links_of_system is a different object,
|
||||
the links should be extended."""
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
strategy_links = [{"url": "http://new.com"}]
|
||||
mock_deps["strategy"].all_links_of_system = strategy_links
|
||||
|
||||
system.progress_callback = lambda m, p, md: None
|
||||
with patch(PATCH_NEWS_CALLBACK):
|
||||
system._perform_search("q", "sid", True, False, "u")
|
||||
|
||||
assert {"url": "http://new.com"} in system.all_links_of_system
|
||||
|
||||
def test_news_callback_error_does_not_break_search(
|
||||
self, mock_model, mock_search, mock_deps
|
||||
):
|
||||
system = _build_system(mock_model, mock_search, mock_deps)
|
||||
system.progress_callback = lambda m, p, md: None
|
||||
|
||||
with patch(PATCH_NEWS_CALLBACK, side_effect=Exception("boom")):
|
||||
# Should not raise
|
||||
result = system._perform_search("q", "sid", True, False, "u")
|
||||
assert isinstance(result, dict)
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Extended tests for search_system.py - covering settings processing, programmatic mode,
|
||||
strategy selection, and result processing."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
def _make_strategy_mock():
|
||||
"""Create a mock strategy with required attributes."""
|
||||
mock_strategy = Mock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
return mock_strategy
|
||||
|
||||
|
||||
def _patch_system():
|
||||
"""Return nested context managers for search system patching.
|
||||
|
||||
Usage:
|
||||
with _create_patched():
|
||||
...
|
||||
|
||||
Returns a helper that patches both create_strategy and StandardCitationHandler.
|
||||
"""
|
||||
|
||||
class PatchContext:
|
||||
def __init__(self):
|
||||
self.strategy = _make_strategy_mock()
|
||||
self._p1 = patch(
|
||||
"local_deep_research.search_system_factory.create_strategy",
|
||||
return_value=self.strategy,
|
||||
)
|
||||
self._p2 = patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler",
|
||||
return_value=Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
),
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self.mock_create = self._p1.__enter__()
|
||||
self.mock_citation = self._p2.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self._p2.__exit__(*args)
|
||||
self._p1.__exit__(*args)
|
||||
|
||||
return PatchContext()
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemSettingsProcessing:
|
||||
"""Tests for settings snapshot processing in AdvancedSearchSystem.__init__."""
|
||||
|
||||
def test_max_iterations_from_dict_settings(self):
|
||||
"""max_iterations should be extracted from dict-wrapped settings."""
|
||||
settings = {
|
||||
"search.iterations": {"value": 5, "ui_element": "number"},
|
||||
"search.questions_per_iteration": {
|
||||
"value": 4,
|
||||
"ui_element": "number",
|
||||
},
|
||||
}
|
||||
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), settings_snapshot=settings
|
||||
)
|
||||
|
||||
assert system.max_iterations == 5
|
||||
assert system.questions_per_iteration == 4
|
||||
|
||||
def test_max_iterations_from_direct_value_settings(self):
|
||||
"""max_iterations should work with direct (non-dict) settings values."""
|
||||
settings = {
|
||||
"search.iterations": 3,
|
||||
"search.questions_per_iteration": 2,
|
||||
}
|
||||
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), settings_snapshot=settings
|
||||
)
|
||||
|
||||
assert system.max_iterations == 3
|
||||
assert system.questions_per_iteration == 2
|
||||
|
||||
def test_max_iterations_defaults_when_missing(self):
|
||||
"""max_iterations should default to 1 when not in settings."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), settings_snapshot={}
|
||||
)
|
||||
|
||||
assert system.max_iterations == 1
|
||||
assert system.questions_per_iteration == 3
|
||||
|
||||
def test_explicit_max_iterations_overrides_settings(self):
|
||||
"""Explicit max_iterations parameter should override settings snapshot."""
|
||||
settings = {"search.iterations": {"value": 10}}
|
||||
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
max_iterations=7,
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 7
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemProgrammaticMode:
|
||||
"""Tests for programmatic mode behavior."""
|
||||
|
||||
def test_programmatic_mode_sets_flag(self):
|
||||
"""programmatic_mode flag should be stored."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), programmatic_mode=True
|
||||
)
|
||||
|
||||
assert system.programmatic_mode is True
|
||||
|
||||
def test_non_programmatic_mode_default(self):
|
||||
"""programmatic_mode should default to False."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
assert system.programmatic_mode is False
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemResearchContext:
|
||||
"""Tests for research context handling."""
|
||||
|
||||
def test_stores_research_context(self):
|
||||
"""research_context should be stored on the system."""
|
||||
context = {"delegate_strategy": "rapid", "some_key": "some_value"}
|
||||
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), research_context=context
|
||||
)
|
||||
|
||||
assert system.research_context == context
|
||||
|
||||
def test_none_research_context(self):
|
||||
"""None research_context should not cause errors."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), research_context=None
|
||||
)
|
||||
|
||||
assert system.research_context is None
|
||||
|
||||
def test_research_id_stored(self):
|
||||
"""research_id should be stored on the system."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), research_id="test-123"
|
||||
)
|
||||
|
||||
assert system.research_id == "test-123"
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemContextualFollowUp:
|
||||
"""Tests for contextual follow-up strategy initialization."""
|
||||
|
||||
def test_contextual_followup_uses_delegate(self):
|
||||
"""Contextual follow-up strategy should create a delegate strategy."""
|
||||
context = {"delegate_strategy": "rapid"}
|
||||
|
||||
with _patch_system() as ctx:
|
||||
with patch(
|
||||
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
|
||||
) as mock_followup_cls:
|
||||
mock_followup_cls.return_value = Mock()
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
strategy_name="enhanced-contextual-followup",
|
||||
research_context=context,
|
||||
)
|
||||
|
||||
# Verify delegate strategy was created with rapid
|
||||
ctx.mock_create.assert_called()
|
||||
call_kwargs = ctx.mock_create.call_args
|
||||
assert call_kwargs.kwargs.get("strategy_name") == "rapid" or (
|
||||
call_kwargs[1].get("strategy_name") == "rapid"
|
||||
)
|
||||
|
||||
def test_contextual_followup_defaults_delegate_to_source_based(self):
|
||||
"""Contextual follow-up without delegate_strategy should default to source-based."""
|
||||
with _patch_system() as ctx:
|
||||
with patch(
|
||||
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
|
||||
) as mock_followup_cls:
|
||||
mock_followup_cls.return_value = Mock()
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
strategy_name="contextual-followup",
|
||||
research_context={},
|
||||
)
|
||||
|
||||
call_kwargs = ctx.mock_create.call_args
|
||||
strategy_name = call_kwargs.kwargs.get(
|
||||
"strategy_name"
|
||||
) or call_kwargs[1].get("strategy_name")
|
||||
assert strategy_name == "source-based"
|
||||
|
||||
def test_contextual_followup_name_variants(self):
|
||||
"""All contextual follow-up name variants should be recognized."""
|
||||
variants = [
|
||||
"enhanced-contextual-followup",
|
||||
"enhanced_contextual_followup",
|
||||
"contextual-followup",
|
||||
"contextual_followup",
|
||||
]
|
||||
|
||||
for variant in variants:
|
||||
with _patch_system():
|
||||
with patch(
|
||||
"local_deep_research.search_system.EnhancedContextualFollowUpStrategy"
|
||||
) as mock_followup_cls:
|
||||
mock_followup_cls.return_value = Mock()
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
strategy_name=variant,
|
||||
)
|
||||
# Should use EnhancedContextualFollowUpStrategy
|
||||
mock_followup_cls.assert_called_once()
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemSearchOriginalQuery:
|
||||
"""Tests for search_original_query parameter."""
|
||||
|
||||
def test_search_original_query_default_true(self):
|
||||
"""search_original_query should default to True."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
assert system.search_original_query is True
|
||||
|
||||
def test_search_original_query_can_be_disabled(self):
|
||||
"""search_original_query can be set to False for news searches."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), search_original_query=False
|
||||
)
|
||||
|
||||
assert system.search_original_query is False
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemPerformSearch:
|
||||
"""Tests for _perform_search result processing."""
|
||||
|
||||
def test_result_includes_query(self):
|
||||
"""Result should include the query if not returned by strategy."""
|
||||
with _patch_system() as ctx:
|
||||
ctx.strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "some results"
|
||||
}
|
||||
ctx.strategy.questions_by_iteration = {1: ["q1"]}
|
||||
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
with patch(
|
||||
"local_deep_research.news.core.search_integration.NewsSearchCallback"
|
||||
):
|
||||
result = system.analyze_topic("test query")
|
||||
|
||||
assert result["query"] == "test query"
|
||||
|
||||
def test_result_includes_search_system_reference(self):
|
||||
"""Result should include reference to the search system."""
|
||||
with _patch_system() as ctx:
|
||||
ctx.strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "results"
|
||||
}
|
||||
ctx.strategy.questions_by_iteration = {}
|
||||
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
with patch(
|
||||
"local_deep_research.news.core.search_integration.NewsSearchCallback"
|
||||
):
|
||||
result = system.analyze_topic("test query")
|
||||
|
||||
assert result["search_system"] is system
|
||||
|
||||
def test_settings_snapshot_extraction_in_perform_search(self):
|
||||
"""_perform_search should extract LLM/search info from settings snapshot."""
|
||||
settings = {
|
||||
"llm.provider": {"value": "openai"},
|
||||
"llm.model": {"value": "gpt-4"},
|
||||
"search.tool": {"value": "searxng"},
|
||||
}
|
||||
|
||||
progress_calls = []
|
||||
|
||||
def capture_progress(msg, progress, metadata):
|
||||
progress_calls.append((msg, progress, metadata))
|
||||
|
||||
with _patch_system() as ctx:
|
||||
ctx.strategy.analyze_topic.return_value = {"current_knowledge": "r"}
|
||||
ctx.strategy.questions_by_iteration = {}
|
||||
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(), search=Mock(), settings_snapshot=settings
|
||||
)
|
||||
system.set_progress_callback(capture_progress)
|
||||
|
||||
with patch(
|
||||
"local_deep_research.news.core.search_integration.NewsSearchCallback"
|
||||
):
|
||||
system.analyze_topic("test")
|
||||
|
||||
# Should have progress callbacks with LLM and search info
|
||||
assert len(progress_calls) >= 2
|
||||
assert "openai" in progress_calls[0][0]
|
||||
assert "gpt-4" in progress_calls[0][0]
|
||||
|
||||
def test_links_deduplication(self):
|
||||
"""all_links_of_system should not be duplicated when same object."""
|
||||
shared_links = [{"title": "link1", "url": "http://example.com"}]
|
||||
|
||||
with _patch_system() as ctx:
|
||||
ctx.strategy.analyze_topic.return_value = {"current_knowledge": "r"}
|
||||
ctx.strategy.questions_by_iteration = {}
|
||||
ctx.strategy.all_links_of_system = shared_links
|
||||
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
system.all_links_of_system = shared_links
|
||||
|
||||
with patch(
|
||||
"local_deep_research.news.core.search_integration.NewsSearchCallback"
|
||||
):
|
||||
system.analyze_topic("test")
|
||||
|
||||
# Should NOT have duplicated the links
|
||||
assert len(system.all_links_of_system) == 1
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemProgressCallback:
|
||||
"""Tests for progress callback mechanism."""
|
||||
|
||||
def test_set_progress_callback_propagates_to_strategy(self):
|
||||
"""set_progress_callback should propagate to the strategy."""
|
||||
with _patch_system() as ctx:
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
def noop_callback(msg, pct, meta):
|
||||
return None
|
||||
|
||||
system.set_progress_callback(noop_callback)
|
||||
|
||||
ctx.strategy.set_progress_callback.assert_called_with(noop_callback)
|
||||
|
||||
def test_progress_callback_before_strategy_exists(self):
|
||||
"""Setting callback before strategy is created should not crash."""
|
||||
with _patch_system():
|
||||
from local_deep_research.search_system import AdvancedSearchSystem
|
||||
|
||||
system = AdvancedSearchSystem(llm=Mock(), search=Mock())
|
||||
|
||||
# Default callback should be a no-op lambda
|
||||
system.progress_callback("test", 50, {})
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Tests for search_system_factory.py
|
||||
|
||||
Tests cover:
|
||||
- _get_setting helper function (all edge cases)
|
||||
- create_strategy factory function (all strategy types)
|
||||
- Strategy name normalization (case-insensitive, alternative forms)
|
||||
- kwargs pass-through and settings_snapshot forwarding
|
||||
- Focused-iteration special behaviors (zero-to-None, flexible generator)
|
||||
- Iterative-refinement recursive create_strategy call
|
||||
- Unknown strategy fallback with warning
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from local_deep_research.search_system_factory import (
|
||||
_get_setting,
|
||||
create_strategy,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model():
|
||||
"""Return a MagicMock with BaseChatModel spec."""
|
||||
return MagicMock(spec=BaseChatModel)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search():
|
||||
"""Return a plain MagicMock for the search engine."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _get_setting tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestGetSetting:
|
||||
"""Tests for _get_setting helper function."""
|
||||
|
||||
def test_returns_default_when_snapshot_is_none(self):
|
||||
assert _get_setting(None, "any.key", "default_val") == "default_val"
|
||||
|
||||
def test_returns_default_when_snapshot_is_empty_dict(self):
|
||||
# Empty dict is falsy; the guard `if not settings_snapshot` catches it.
|
||||
assert _get_setting({}, "missing.key", 42) == 42
|
||||
|
||||
def test_returns_default_when_key_not_in_snapshot(self):
|
||||
snapshot = {"other.key": "other_val"}
|
||||
assert _get_setting(snapshot, "missing.key", "fallback") == "fallback"
|
||||
|
||||
def test_returns_raw_value_when_value_is_not_dict(self):
|
||||
snapshot = {"my.key": "plain_string"}
|
||||
assert _get_setting(snapshot, "my.key", "default") == "plain_string"
|
||||
|
||||
def test_returns_integer_value_directly(self):
|
||||
snapshot = {"iterations": 7}
|
||||
assert _get_setting(snapshot, "iterations", 10) == 7
|
||||
|
||||
def test_returns_boolean_value_directly(self):
|
||||
snapshot = {"enabled": True}
|
||||
assert _get_setting(snapshot, "enabled", False) is True
|
||||
|
||||
def test_extracts_value_from_dict_with_value_key(self):
|
||||
snapshot = {"my.key": {"value": "nested_result", "type": "string"}}
|
||||
assert _get_setting(snapshot, "my.key", "default") == "nested_result"
|
||||
|
||||
def test_returns_dict_itself_when_no_value_key(self):
|
||||
inner = {"type": "string", "description": "no value key here"}
|
||||
snapshot = {"my.key": inner}
|
||||
assert _get_setting(snapshot, "my.key", "default") == inner
|
||||
|
||||
def test_extracts_none_from_value_key(self):
|
||||
"""Even if value['value'] is None, it should be returned (not default)."""
|
||||
snapshot = {"my.key": {"value": None}}
|
||||
assert _get_setting(snapshot, "my.key", "default") is None
|
||||
|
||||
def test_extracts_zero_from_value_key(self):
|
||||
"""Zero is a valid value and should not be replaced by default."""
|
||||
snapshot = {"limit": {"value": 0}}
|
||||
assert _get_setting(snapshot, "limit", 10) == 0
|
||||
|
||||
def test_returns_list_value_directly(self):
|
||||
snapshot = {"tags": ["a", "b", "c"]}
|
||||
assert _get_setting(snapshot, "tags", []) == ["a", "b", "c"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# create_strategy tests – individual strategies
|
||||
# ===========================================================================
|
||||
|
||||
# Helpers: common patch paths
|
||||
_STRAT_BASE = "local_deep_research.advanced_search_system.strategies"
|
||||
|
||||
|
||||
class TestCreateStrategySourceBased:
|
||||
"""Tests for source-based strategy and its name variants."""
|
||||
|
||||
PATCH_PATH = (
|
||||
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["source-based", "source_based", "source_based_search"],
|
||||
)
|
||||
def test_all_name_variants_create_source_based(
|
||||
self, name, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name=name, model=mock_model, search=mock_search
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
|
||||
def test_kwargs_passed_through(self, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
include_text_content=False,
|
||||
use_cross_engine_filter=False,
|
||||
use_atomic_facts=True,
|
||||
search_original_query=False,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["include_text_content"] is False
|
||||
assert kw["use_cross_engine_filter"] is False
|
||||
assert kw["use_atomic_facts"] is True
|
||||
assert kw["search_original_query"] is False
|
||||
|
||||
def test_settings_snapshot_forwarded(self, mock_model, mock_search):
|
||||
snapshot = {"some.setting": 123}
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
settings_snapshot=snapshot,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["settings_snapshot"] is snapshot
|
||||
|
||||
|
||||
class TestCreateStrategyFocusedIteration:
|
||||
"""Tests for focused-iteration strategy and its special behaviors."""
|
||||
|
||||
PATCH_PATH = (
|
||||
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name", ["focused-iteration", "focused_iteration"])
|
||||
def test_name_variants(self, name, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name=name, model=mock_model, search=mock_search
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
|
||||
def test_knowledge_limit_zero_converts_to_none(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
knowledge_summary_limit=0,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["knowledge_summary_limit"] is None
|
||||
|
||||
def test_snippet_truncate_zero_converts_to_none(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
knowledge_snippet_truncate=0,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["knowledge_snippet_truncate"] is None
|
||||
|
||||
def test_prompt_knowledge_truncate_zero_converts_to_none(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
prompt_knowledge_truncate=0,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["prompt_knowledge_truncate"] is None
|
||||
|
||||
def test_previous_searches_limit_zero_converts_to_none(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
previous_searches_limit=0,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["previous_searches_limit"] is None
|
||||
|
||||
def test_nonzero_limits_stay_unchanged(self, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
knowledge_summary_limit=5,
|
||||
knowledge_snippet_truncate=200,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["knowledge_summary_limit"] == 5
|
||||
assert kw["knowledge_snippet_truncate"] == 200
|
||||
|
||||
def test_flexible_question_generator_overrides(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
flex_gen_path = "local_deep_research.advanced_search_system.questions.flexible_browsecomp_question.FlexibleBrowseCompQuestionGenerator"
|
||||
with patch(self.PATCH_PATH) as cls, patch(flex_gen_path) as flex_cls:
|
||||
strategy_instance = Mock()
|
||||
cls.return_value = strategy_instance
|
||||
flex_cls.return_value = Mock()
|
||||
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
question_generator="flexible",
|
||||
)
|
||||
|
||||
flex_cls.assert_called_once()
|
||||
# The generator is assigned to the strategy's question_generator
|
||||
assert strategy_instance.question_generator == flex_cls.return_value
|
||||
|
||||
def test_non_flexible_generator_does_not_override(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
flex_gen_path = "local_deep_research.advanced_search_system.questions.flexible_browsecomp_question.FlexibleBrowseCompQuestionGenerator"
|
||||
with patch(self.PATCH_PATH) as cls, patch(flex_gen_path) as flex_cls:
|
||||
strategy_instance = Mock()
|
||||
cls.return_value = strategy_instance
|
||||
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
question_generator="browsecomp",
|
||||
)
|
||||
|
||||
flex_cls.assert_not_called()
|
||||
|
||||
def test_settings_snapshot_read_for_focused_iteration(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
settings = {
|
||||
"focused_iteration.adaptive_questions": 1,
|
||||
"focused_iteration.knowledge_summary_limit": 20,
|
||||
"focused_iteration.snippet_truncate": 300,
|
||||
}
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["enable_adaptive_questions"] is True
|
||||
assert kw["knowledge_summary_limit"] == 20
|
||||
assert kw["knowledge_snippet_truncate"] == 300
|
||||
|
||||
def test_kwargs_override_settings_snapshot(self, mock_model, mock_search):
|
||||
settings = {
|
||||
"focused_iteration.knowledge_summary_limit": 20,
|
||||
}
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
settings_snapshot=settings,
|
||||
knowledge_summary_limit=99,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["knowledge_summary_limit"] == 99
|
||||
|
||||
|
||||
class TestCreateStrategyFocusedIterationStandard:
|
||||
"""Tests for focused-iteration-standard (with standard citation handler)."""
|
||||
|
||||
PATCH_PATH = (
|
||||
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
|
||||
)
|
||||
CITATION_PATH = "local_deep_research.citation_handler.CitationHandler"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["focused-iteration-standard", "focused_iteration_standard"],
|
||||
)
|
||||
def test_name_variants(self, name, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls, patch(self.CITATION_PATH):
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name=name, model=mock_model, search=mock_search
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
|
||||
def test_citation_handler_created_with_standard_type(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with (
|
||||
patch(self.PATCH_PATH) as cls,
|
||||
patch(self.CITATION_PATH) as cite_cls,
|
||||
):
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="focused-iteration-standard",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cite_cls.assert_called_once()
|
||||
cite_kw = cite_cls.call_args
|
||||
assert cite_kw[1]["handler_type"] == "standard"
|
||||
|
||||
|
||||
class TestCreateStrategyNews:
|
||||
"""Tests for news strategy."""
|
||||
|
||||
PATCH_PATH = f"{_STRAT_BASE}.news_strategy.NewsAggregationStrategy"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["news", "news_aggregation", "news-aggregation"]
|
||||
)
|
||||
def test_name_variants(self, name, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name=name, model=mock_model, search=mock_search
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Case-insensitivity tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCaseInsensitivity:
|
||||
"""Strategy name matching is case-insensitive via .lower()."""
|
||||
|
||||
def test_uppercase_topic_organization(self, mock_model, mock_search):
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.topic_organization_strategy.TopicOrganizationStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="TOPIC-ORGANIZATION",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
|
||||
def test_mixed_case_source_based(self, mock_model, mock_search):
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="Source-Based",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
|
||||
def test_uppercase_news(self, mock_model, mock_search):
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.news_strategy.NewsAggregationStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="NEWS",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
|
||||
def test_mixed_case_focused_iteration(self, mock_model, mock_search):
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.focused_iteration_strategy.FocusedIterationStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="Focused-Iteration",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Unknown strategy fallback
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestUnknownStrategyFallback:
|
||||
"""Unknown strategy names fall back to SourceBasedSearchStrategy."""
|
||||
|
||||
PATCH_PATH = (
|
||||
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
|
||||
)
|
||||
|
||||
def test_unknown_strategy_falls_back_to_source_based(
|
||||
self, mock_model, mock_search
|
||||
):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name="unknown-strategy-xyz",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
|
||||
def test_unknown_strategy_logs_warning(self, mock_model, mock_search):
|
||||
with (
|
||||
patch(self.PATCH_PATH) as cls,
|
||||
patch(
|
||||
"local_deep_research.search_system_factory.logger"
|
||||
) as mock_logger,
|
||||
):
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="unknown-strategy-xyz",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
assert "unknown-strategy-xyz" in warning_msg.lower()
|
||||
|
||||
def test_another_unknown_name(self, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="totally-made-up",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
cls.assert_called_once()
|
||||
|
||||
def test_fallback_uses_hardcoded_defaults(self, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="nonexistent",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["include_text_content"] is True
|
||||
assert kw["use_cross_engine_filter"] is True
|
||||
assert kw["use_atomic_facts"] is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# all_links_of_system handling
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAllLinksHandling:
|
||||
"""Tests for all_links_of_system parameter normalization."""
|
||||
|
||||
def test_none_becomes_empty_list(self, mock_model, mock_search):
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
all_links_of_system=None,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["all_links_of_system"] == []
|
||||
|
||||
def test_existing_links_passed_through(self, mock_model, mock_search):
|
||||
links = [{"link": "http://a.com"}, {"link": "http://b.com"}]
|
||||
with patch(
|
||||
f"{_STRAT_BASE}.source_based_strategy.SourceBasedSearchStrategy"
|
||||
) as cls:
|
||||
cls.return_value = Mock()
|
||||
create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=mock_model,
|
||||
search=mock_search,
|
||||
all_links_of_system=links,
|
||||
)
|
||||
kw = cls.call_args[1]
|
||||
assert kw["all_links_of_system"] is links
|
||||
|
||||
|
||||
class TestCreateStrategyTopicOrganization:
|
||||
"""Tests for topic-organization strategy."""
|
||||
|
||||
PATCH_PATH = (
|
||||
f"{_STRAT_BASE}.topic_organization_strategy.TopicOrganizationStrategy"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["topic-organization", "topic_organization", "topic"]
|
||||
)
|
||||
def test_name_variants(self, name, mock_model, mock_search):
|
||||
with patch(self.PATCH_PATH) as cls:
|
||||
cls.return_value = Mock()
|
||||
result = create_strategy(
|
||||
strategy_name=name, model=mock_model, search=mock_search
|
||||
)
|
||||
cls.assert_called_once()
|
||||
assert result == cls.return_value
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Extended tests for search_system_factory.py - covering _get_setting helper,
|
||||
strategy creation edge cases, and unknown strategy fallback."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
class TestGetSettingHelper:
|
||||
"""Tests for _get_setting utility function."""
|
||||
|
||||
def test_returns_default_when_no_snapshot(self):
|
||||
"""Should return default when settings_snapshot is None."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
result = _get_setting(None, "key", "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
def test_returns_default_when_empty_snapshot(self):
|
||||
"""Should return default when settings_snapshot is empty dict."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
result = _get_setting({}, "key", "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
def test_returns_default_when_key_missing(self):
|
||||
"""Should return default when key is not in snapshot."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
result = _get_setting({"other_key": "value"}, "key", "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
def test_extracts_value_from_dict(self):
|
||||
"""Should extract value from dict-wrapped setting."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"value": 42, "ui_element": "number"}}
|
||||
result = _get_setting(snapshot, "key", 0)
|
||||
assert result == 42
|
||||
|
||||
def test_returns_direct_value(self):
|
||||
"""Should return direct (non-dict) value."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": "direct_string"}
|
||||
result = _get_setting(snapshot, "key", "default")
|
||||
assert result == "direct_string"
|
||||
|
||||
def test_returns_dict_without_value_key_as_is(self):
|
||||
"""Dict without 'value' key should be returned as-is."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"other": "data"}}
|
||||
result = _get_setting(snapshot, "key", "default")
|
||||
assert result == {"other": "data"}
|
||||
|
||||
def test_handles_boolean_values(self):
|
||||
"""Should handle boolean values in settings."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"value": True}}
|
||||
result = _get_setting(snapshot, "key", False)
|
||||
assert result is True
|
||||
|
||||
def test_handles_none_value_in_dict(self):
|
||||
"""Should handle None as value in dict-wrapped setting."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"value": None}}
|
||||
result = _get_setting(snapshot, "key", "default")
|
||||
assert result is None
|
||||
|
||||
def test_handles_zero_value(self):
|
||||
"""Should handle 0 value correctly (not confuse with None)."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"value": 0}}
|
||||
result = _get_setting(snapshot, "key", 5)
|
||||
assert result == 0
|
||||
|
||||
def test_handles_empty_string_value(self):
|
||||
"""Should handle empty string value."""
|
||||
from local_deep_research.search_system_factory import _get_setting
|
||||
|
||||
snapshot = {"key": {"value": ""}}
|
||||
result = _get_setting(snapshot, "key", "default")
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestCreateStrategyNames:
|
||||
"""Tests for strategy name handling in create_strategy."""
|
||||
|
||||
def test_source_based_variants(self):
|
||||
"""All source-based name variants should create SourceBasedSearchStrategy."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
variants = ["source-based", "source_based", "source_based_search"]
|
||||
|
||||
for variant in variants:
|
||||
strategy = create_strategy(
|
||||
strategy_name=variant,
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "SourceBasedSearchStrategy", (
|
||||
f"Variant '{variant}' did not create SourceBasedSearchStrategy"
|
||||
)
|
||||
|
||||
def test_topic_organization_creates_correct_strategy(self):
|
||||
"""topic-organization name should create TopicOrganizationStrategy."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="topic-organization",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "TopicOrganizationStrategy"
|
||||
|
||||
|
||||
class TestCreateStrategyUnknown:
|
||||
"""Tests for unknown strategy name handling."""
|
||||
|
||||
def test_unknown_strategy_falls_back_to_source_based(self):
|
||||
"""Unknown strategy name should fall back to SourceBasedSearchStrategy."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="nonexistent_strategy_xyz",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
|
||||
|
||||
def test_empty_string_strategy_falls_back(self):
|
||||
"""Empty string strategy should fall back to source-based."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
|
||||
|
||||
|
||||
class TestCreateStrategyKwargsPassthrough:
|
||||
"""Tests for kwargs passthrough to strategies."""
|
||||
|
||||
def test_source_based_receives_kwargs(self):
|
||||
"""Source-based strategy should receive kwargs."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
include_text_content=False,
|
||||
use_cross_engine_filter=False,
|
||||
use_atomic_facts=True,
|
||||
)
|
||||
|
||||
assert strategy.include_text_content is False
|
||||
assert strategy.use_cross_engine_filter is False
|
||||
|
||||
def test_focused_iteration_receives_iteration_settings(self):
|
||||
"""Focused-iteration strategy should receive iteration settings from kwargs."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
max_iterations=15,
|
||||
questions_per_iteration=5,
|
||||
)
|
||||
|
||||
assert strategy.max_iterations == 15
|
||||
|
||||
|
||||
class TestCreateStrategySettingsSnapshot:
|
||||
"""Tests for settings snapshot integration in create_strategy."""
|
||||
|
||||
def test_all_links_initialized_when_none(self):
|
||||
"""all_links_of_system should be initialized to empty list when None."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="topic-organization",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
all_links_of_system=None,
|
||||
)
|
||||
|
||||
assert strategy.all_links_of_system == []
|
||||
|
||||
def test_all_links_passed_through(self):
|
||||
"""Provided all_links_of_system should be passed to strategy."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
existing_links = [{"url": "http://example.com", "title": "Test"}]
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="topic-organization",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
all_links_of_system=existing_links,
|
||||
)
|
||||
|
||||
assert strategy.all_links_of_system is existing_links
|
||||
|
||||
def test_settings_snapshot_passed_to_strategy(self):
|
||||
"""Settings snapshot should be passed to strategy."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
settings = {"some.setting": "value"}
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="source-based",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert strategy.settings_snapshot is settings
|
||||
|
||||
|
||||
class TestCreateStrategyFocusedIteration:
|
||||
"""Tests for focused-iteration strategy creation with settings."""
|
||||
|
||||
def test_focused_iteration_reads_settings(self):
|
||||
"""Focused iteration should read settings from snapshot."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
settings = {
|
||||
"focused_iteration.adaptive_questions": {"value": 1},
|
||||
"focused_iteration.knowledge_summary_limit": {"value": 5},
|
||||
"focused_iteration.snippet_truncate": {"value": 100},
|
||||
}
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert strategy.enable_adaptive_questions is True
|
||||
assert strategy.knowledge_summary_limit == 5
|
||||
assert strategy.knowledge_snippet_truncate == 100
|
||||
|
||||
def test_focused_iteration_zero_converts_to_none(self):
|
||||
"""Knowledge limit of 0 should be converted to None (unlimited)."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
settings = {
|
||||
"focused_iteration.knowledge_summary_limit": {"value": 0},
|
||||
"focused_iteration.snippet_truncate": {"value": 0},
|
||||
}
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="focused-iteration",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert strategy.knowledge_summary_limit is None
|
||||
assert strategy.knowledge_snippet_truncate is None
|
||||
|
||||
def test_focused_iteration_name_variants(self):
|
||||
"""Both focused-iteration name variants should work."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
for variant in ["focused-iteration", "focused_iteration"]:
|
||||
strategy = create_strategy(
|
||||
strategy_name=variant,
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "FocusedIterationStrategy"
|
||||
|
||||
|
||||
class TestCreateStrategyCaseInsensitivity:
|
||||
"""Tests for case handling in strategy names."""
|
||||
|
||||
def test_lowercase_matching(self):
|
||||
"""Strategy names should be matched case-insensitively."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
# The function calls .lower() on strategy_name
|
||||
strategy = create_strategy(
|
||||
strategy_name="TOPIC-ORGANIZATION",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "TopicOrganizationStrategy"
|
||||
|
||||
def test_mixed_case_matching(self):
|
||||
"""Mixed case strategy names should work."""
|
||||
from local_deep_research.search_system_factory import create_strategy
|
||||
|
||||
strategy = create_strategy(
|
||||
strategy_name="Source-Based",
|
||||
model=Mock(),
|
||||
search=Mock(),
|
||||
)
|
||||
assert type(strategy).__name__ == "SourceBasedSearchStrategy"
|
||||
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
Tests for search_system.py - Link Deduplication and Settings Extraction
|
||||
|
||||
Tests cover:
|
||||
- Link deduplication using object identity (id())
|
||||
- Settings extraction from snapshot dictionaries
|
||||
|
||||
These tests address issue #301: "too many links in detailed report mode"
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLinkDeduplication:
|
||||
"""Tests for link deduplication behavior."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm(self):
|
||||
"""Create a mock LLM."""
|
||||
mock = MagicMock()
|
||||
mock.invoke.return_value = MagicMock(content="test response")
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_engine(self):
|
||||
"""Create a mock search engine."""
|
||||
mock = MagicMock()
|
||||
mock.run.return_value = []
|
||||
return mock
|
||||
|
||||
def _create_system(
|
||||
self, mock_llm, mock_search_engine, mock_strategy, **kwargs
|
||||
):
|
||||
"""Helper to create an AdvancedSearchSystem with mocked dependencies."""
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
**kwargs,
|
||||
)
|
||||
return system
|
||||
|
||||
def test_same_list_object_not_duplicated(
|
||||
self, mock_llm, mock_search_engine
|
||||
):
|
||||
"""When lists are same object, don't extend."""
|
||||
mock_strategy = MagicMock()
|
||||
shared_links = [{"title": "Link1", "url": "http://example.com"}]
|
||||
mock_strategy.all_links_of_system = shared_links
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
# Make the system's list the SAME object as strategy's list
|
||||
system.all_links_of_system = shared_links
|
||||
|
||||
# Perform search
|
||||
system.analyze_topic("test query")
|
||||
|
||||
# Links should NOT be duplicated
|
||||
# Before the fix, this would double the list
|
||||
assert len(system.all_links_of_system) == 1
|
||||
|
||||
def test_different_list_objects_extended(
|
||||
self, mock_llm, mock_search_engine
|
||||
):
|
||||
"""Different objects get extended."""
|
||||
mock_strategy = MagicMock()
|
||||
strategy_links = [{"title": "Link1", "url": "http://example.com"}]
|
||||
mock_strategy.all_links_of_system = strategy_links
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
# System has a DIFFERENT list object
|
||||
system.all_links_of_system = []
|
||||
|
||||
system.analyze_topic("test query")
|
||||
|
||||
# Links should be extended from strategy to system
|
||||
assert len(system.all_links_of_system) == 1
|
||||
|
||||
def test_empty_strategy_links(self, mock_llm, mock_search_engine):
|
||||
"""Empty strategy links don't cause errors."""
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
initial_links = [
|
||||
{"title": "Existing", "url": "http://existing.com"}
|
||||
]
|
||||
system.all_links_of_system = initial_links
|
||||
|
||||
system.analyze_topic("test query")
|
||||
|
||||
# Existing links should remain, nothing added
|
||||
assert len(system.all_links_of_system) == 1
|
||||
assert system.all_links_of_system[0]["title"] == "Existing"
|
||||
|
||||
def test_large_link_list_performance(self, mock_llm, mock_search_engine):
|
||||
"""1000+ links don't cause memory issues."""
|
||||
mock_strategy = MagicMock()
|
||||
large_links = [
|
||||
{"title": f"Link{i}", "url": f"http://example{i}.com"}
|
||||
for i in range(1000)
|
||||
]
|
||||
mock_strategy.all_links_of_system = large_links
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
system.all_links_of_system = []
|
||||
|
||||
system.analyze_topic("test query")
|
||||
|
||||
assert len(system.all_links_of_system) == 1000
|
||||
|
||||
def test_link_dedup_preserves_order(self, mock_llm, mock_search_engine):
|
||||
"""Link order preserved after dedup."""
|
||||
mock_strategy = MagicMock()
|
||||
ordered_links = [
|
||||
{"title": "First", "url": "http://first.com"},
|
||||
{"title": "Second", "url": "http://second.com"},
|
||||
{"title": "Third", "url": "http://third.com"},
|
||||
]
|
||||
mock_strategy.all_links_of_system = ordered_links
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
system.all_links_of_system = []
|
||||
|
||||
system.analyze_topic("test query")
|
||||
|
||||
assert system.all_links_of_system[0]["title"] == "First"
|
||||
assert system.all_links_of_system[1]["title"] == "Second"
|
||||
assert system.all_links_of_system[2]["title"] == "Third"
|
||||
|
||||
|
||||
class TestSettingsExtraction:
|
||||
"""Tests for settings extraction from snapshot."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm(self):
|
||||
"""Create a mock LLM."""
|
||||
mock = MagicMock()
|
||||
mock.invoke.return_value = MagicMock(content="test response")
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_engine(self):
|
||||
"""Create a mock search engine."""
|
||||
mock = MagicMock()
|
||||
mock.run.return_value = []
|
||||
return mock
|
||||
|
||||
def test_settings_dict_value_format(self, mock_llm, mock_search_engine):
|
||||
"""{'value': 'actual'} extracts correctly."""
|
||||
settings = {
|
||||
"search.iterations": {"value": 5},
|
||||
"search.questions_per_iteration": {"value": 4},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 5
|
||||
assert system.questions_per_iteration == 4
|
||||
|
||||
def test_settings_direct_value_format(self, mock_llm, mock_search_engine):
|
||||
"""Direct values work."""
|
||||
settings = {
|
||||
"search.iterations": 3,
|
||||
"search.questions_per_iteration": 2,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 3
|
||||
assert system.questions_per_iteration == 2
|
||||
|
||||
def test_missing_settings_use_defaults(self, mock_llm, mock_search_engine):
|
||||
"""Missing settings get defaults."""
|
||||
settings = {} # Empty settings
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
# Defaults: iterations=1, questions_per_iteration=3
|
||||
assert system.max_iterations == 1
|
||||
assert system.questions_per_iteration == 3
|
||||
|
||||
def test_partial_settings_snapshot(self, mock_llm, mock_search_engine):
|
||||
"""Some present, some missing."""
|
||||
settings = {
|
||||
"search.iterations": {"value": 7},
|
||||
# questions_per_iteration is missing
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 7
|
||||
assert system.questions_per_iteration == 3 # Default
|
||||
|
||||
def test_nested_settings_structure(self, mock_llm, mock_search_engine):
|
||||
"""Deeply nested dicts."""
|
||||
# The code only checks for {'value': ...} at one level
|
||||
settings = {
|
||||
"search.iterations": {"value": 2, "extra": {"nested": "data"}},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=settings,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 2
|
||||
|
||||
def test_none_settings_snapshot(self, mock_llm, mock_search_engine):
|
||||
"""None snapshot uses all defaults."""
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot=None,
|
||||
)
|
||||
|
||||
assert system.max_iterations == 1
|
||||
assert system.questions_per_iteration == 3
|
||||
|
||||
def test_empty_settings_snapshot(self, mock_llm, mock_search_engine):
|
||||
"""Empty dict uses all defaults."""
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot={},
|
||||
)
|
||||
|
||||
assert system.max_iterations == 1
|
||||
assert system.questions_per_iteration == 3
|
||||
|
||||
|
||||
class TestProgressCallback:
|
||||
"""Tests for progress callback functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm(self):
|
||||
"""Create a mock LLM."""
|
||||
mock = MagicMock()
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_engine(self):
|
||||
"""Create a mock search engine."""
|
||||
return MagicMock()
|
||||
|
||||
def test_progress_callback_set_on_strategy(
|
||||
self, mock_llm, mock_search_engine
|
||||
):
|
||||
"""Progress callback is set on strategy."""
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
)
|
||||
|
||||
callback = MagicMock()
|
||||
system.set_progress_callback(callback)
|
||||
|
||||
mock_strategy.set_progress_callback.assert_called_with(callback)
|
||||
|
||||
def test_progress_callback_receives_updates(
|
||||
self, mock_llm, mock_search_engine
|
||||
):
|
||||
"""Progress callback receives progress updates during search."""
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy"
|
||||
) as mock_create:
|
||||
mock_strategy = MagicMock()
|
||||
mock_strategy.all_links_of_system = []
|
||||
mock_strategy.questions_by_iteration = []
|
||||
mock_strategy.analyze_topic.return_value = {
|
||||
"current_knowledge": "test",
|
||||
"query": "test query",
|
||||
}
|
||||
mock_create.return_value = mock_strategy
|
||||
|
||||
with patch(
|
||||
"local_deep_research.citation_handlers.standard_citation_handler.StandardCitationHandler"
|
||||
) as mock_citation:
|
||||
mock_citation.return_value = Mock(
|
||||
_create_documents=Mock(), _format_sources=Mock()
|
||||
)
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
)
|
||||
|
||||
system = AdvancedSearchSystem(
|
||||
llm=mock_llm,
|
||||
search=mock_search_engine,
|
||||
strategy_name="standard",
|
||||
settings_snapshot={
|
||||
"llm.provider": {"value": "test_provider"},
|
||||
"llm.model": {"value": "test_model"},
|
||||
"search.tool": {"value": "test_tool"},
|
||||
},
|
||||
)
|
||||
|
||||
callback = MagicMock()
|
||||
system.set_progress_callback(callback)
|
||||
|
||||
system.analyze_topic("test query")
|
||||
|
||||
# Callback should have been called during search
|
||||
assert callback.called
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the run-snapshot username injection in AdvancedSearchSystem.
|
||||
|
||||
The LangGraph agent re-instantiates the search engine *per tool call* from the
|
||||
run's ``settings_snapshot``; registering a user's document collections needs
|
||||
the username, which collection registration reads only from
|
||||
``settings_snapshot["_username"]``. ``AdvancedSearchSystem.__init__`` injects it
|
||||
— it is the single consumer of every strategy-running path (web run,
|
||||
programmatic API, benchmarks) — so a collection/library primary works for all
|
||||
of them. Regression: a collection primary previously failed inside the agent
|
||||
with "Unknown search engine 'collection_…'".
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from local_deep_research.search_system import (
|
||||
AdvancedSearchSystem,
|
||||
_ensure_snapshot_username,
|
||||
)
|
||||
|
||||
|
||||
class TestEnsureSnapshotUsername:
|
||||
"""Unit contract of the injection helper."""
|
||||
|
||||
def test_injects_username_when_missing(self):
|
||||
snap = {"search.tool": "collection_abc"}
|
||||
result = _ensure_snapshot_username(snap, "alice")
|
||||
assert result["_username"] == "alice"
|
||||
# search.tool (and everything else) is preserved.
|
||||
assert result["search.tool"] == "collection_abc"
|
||||
|
||||
def test_does_not_mutate_caller_dict(self):
|
||||
snap = {"search.tool": "collection_abc"}
|
||||
result = _ensure_snapshot_username(snap, "alice")
|
||||
assert "_username" not in snap # original untouched
|
||||
assert result is not snap
|
||||
|
||||
def test_preserves_existing_username(self):
|
||||
snap = {"_username": "already", "search.tool": "x"}
|
||||
result = _ensure_snapshot_username(snap, "alice")
|
||||
# An explicit value is never overwritten, and no copy is made.
|
||||
assert result is snap
|
||||
assert result["_username"] == "already"
|
||||
|
||||
def test_noop_when_username_none(self):
|
||||
snap = {"search.tool": "x"}
|
||||
result = _ensure_snapshot_username(snap, None)
|
||||
assert result is snap
|
||||
assert "_username" not in result
|
||||
|
||||
def test_noop_when_username_empty_string(self):
|
||||
snap = {"search.tool": "x"}
|
||||
result = _ensure_snapshot_username(snap, "")
|
||||
assert result is snap
|
||||
assert "_username" not in result
|
||||
|
||||
def test_noop_when_snapshot_not_dict(self):
|
||||
# Defensive: a non-dict snapshot is returned unchanged, never crashes.
|
||||
assert _ensure_snapshot_username(None, "alice") is None
|
||||
|
||||
|
||||
class TestAdvancedSearchSystemInjectsUsername:
|
||||
"""Locks the call site: constructing the system with a username must put it
|
||||
into the snapshot the strategy (and its per-call engine creation) reads.
|
||||
``create_strategy`` is patched so the assertion is about the injection, not
|
||||
strategy construction details.
|
||||
"""
|
||||
|
||||
def test_init_injects_username_into_snapshot(self):
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy",
|
||||
return_value=Mock(),
|
||||
):
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
settings_snapshot={"search.tool": "collection_abc"},
|
||||
username="alice",
|
||||
)
|
||||
assert system.settings_snapshot["_username"] == "alice"
|
||||
# The configured primary is preserved alongside the injected username.
|
||||
assert system.settings_snapshot["search.tool"] == "collection_abc"
|
||||
|
||||
def test_init_without_username_leaves_snapshot_clean(self):
|
||||
with patch(
|
||||
"local_deep_research.search_system_factory.create_strategy",
|
||||
return_value=Mock(),
|
||||
):
|
||||
system = AdvancedSearchSystem(
|
||||
llm=Mock(),
|
||||
search=Mock(),
|
||||
settings_snapshot={"search.tool": "searxng"},
|
||||
)
|
||||
assert "_username" not in system.settings_snapshot
|
||||
Reference in New Issue
Block a user