7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
903 lines
33 KiB
Python
903 lines
33 KiB
Python
"""Comprehensive tests for report_generator.py targeting uncovered paths.
|
|
|
|
Focuses on areas with gaps in existing test suites:
|
|
- close() resource cleanup with _owns_llm flag
|
|
- _format_final_report TOC assembly, section ordering, missing sections, metadata
|
|
- _truncate_at_sentence_boundary additional edge cases
|
|
- _build_previous_context boundary conditions
|
|
- _research_and_generate_sections iteration restore on exception, pipe in section name
|
|
- _generate_error_report format
|
|
- generate_report progress callback sequencing
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
DEFAULT_MAX_CONTEXT_CHARS,
|
|
DEFAULT_MAX_CONTEXT_SECTIONS,
|
|
)
|
|
|
|
MODULE = "local_deep_research.report_generator"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_search_system():
|
|
"""Minimal mock search system with strategy."""
|
|
system = MagicMock()
|
|
system.strategy = MagicMock()
|
|
system.strategy.settings_snapshot = {"search.iterations": 3}
|
|
system.strategy.max_iterations = 3
|
|
system.all_links_of_system = []
|
|
system.analyze_topic.return_value = {"current_knowledge": "mock content"}
|
|
return system
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_model():
|
|
"""Minimal mock LLM."""
|
|
model = MagicMock()
|
|
model.invoke.return_value = MagicMock(content="STRUCTURE\nEND_STRUCTURE")
|
|
return model
|
|
|
|
|
|
@pytest.fixture
|
|
def generator(mock_search_system, mock_model):
|
|
"""IntegratedReportGenerator with all deps mocked via __new__."""
|
|
gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator)
|
|
gen.search_system = mock_search_system
|
|
gen.model = mock_model
|
|
gen.searches_per_section = 2
|
|
gen.max_context_sections = DEFAULT_MAX_CONTEXT_SECTIONS
|
|
gen.max_context_chars = DEFAULT_MAX_CONTEXT_CHARS
|
|
gen._owns_llm = False
|
|
return gen
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# close() -- resource cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClose:
|
|
"""Tests for close() method and _owns_llm lifecycle."""
|
|
|
|
def test_close_when_owns_llm_calls_close(self, generator, mock_model):
|
|
"""When _owns_llm is True, close() calls model.close()."""
|
|
generator._owns_llm = True
|
|
generator.model = mock_model
|
|
|
|
generator.close()
|
|
mock_model.close.assert_called_once()
|
|
|
|
def test_close_when_not_owns_llm_does_nothing(self, generator, mock_model):
|
|
"""When _owns_llm is False, close() must not call model.close()."""
|
|
generator._owns_llm = False
|
|
generator.model = mock_model
|
|
|
|
generator.close()
|
|
mock_model.close.assert_not_called()
|
|
|
|
def test_close_when_model_is_none(self, generator):
|
|
"""When model is None (regardless of _owns_llm), close() is safe."""
|
|
generator._owns_llm = True
|
|
generator.model = None
|
|
|
|
# Should not raise
|
|
generator.close()
|
|
|
|
def test_owns_llm_set_true_when_no_search_system_or_llm(self):
|
|
"""Constructor sets _owns_llm=True when neither search_system nor llm given."""
|
|
with (
|
|
patch(f"{MODULE}.get_llm") as mock_get_llm,
|
|
patch(f"{MODULE}.AdvancedSearchSystem"),
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
side_effect=lambda k, default=None, settings_snapshot=None: (
|
|
default
|
|
),
|
|
),
|
|
):
|
|
mock_get_llm.return_value = MagicMock()
|
|
gen = IntegratedReportGenerator()
|
|
assert gen._owns_llm is True
|
|
|
|
def test_owns_llm_set_false_when_search_system_provided(
|
|
self, mock_search_system
|
|
):
|
|
"""Constructor sets _owns_llm=False when search_system is provided."""
|
|
mock_search_system.model = MagicMock()
|
|
with patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
side_effect=lambda k, default=None, settings_snapshot=None: default,
|
|
):
|
|
gen = IntegratedReportGenerator(search_system=mock_search_system)
|
|
assert gen._owns_llm is False
|
|
|
|
def test_owns_llm_set_false_when_llm_provided(self):
|
|
"""Constructor sets _owns_llm=False when llm is provided."""
|
|
with (
|
|
patch(f"{MODULE}.AdvancedSearchSystem"),
|
|
patch(
|
|
f"{MODULE}.get_setting_from_snapshot",
|
|
side_effect=lambda k, default=None, settings_snapshot=None: (
|
|
default
|
|
),
|
|
),
|
|
):
|
|
gen = IntegratedReportGenerator(llm=MagicMock())
|
|
assert gen._owns_llm is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _format_final_report -- detailed TOC and assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatFinalReportDetailed:
|
|
"""Detailed tests for _format_final_report output structure."""
|
|
|
|
@pytest.fixture
|
|
def _patch_importlib(self):
|
|
"""Patch importlib.import_module to return a mock with format_links_to_markdown."""
|
|
with patch(f"{MODULE}.importlib") as mock_imp:
|
|
mock_utils = MagicMock()
|
|
mock_utils.search_utilities.format_links_to_markdown.return_value = "- [Link1](http://a.com)"
|
|
mock_imp.import_module.return_value = mock_utils
|
|
yield mock_imp
|
|
|
|
def test_toc_numbering_matches_structure_order(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""TOC entries use 1-based numbering matching structure list order."""
|
|
structure = [
|
|
{"name": "Alpha", "subsections": [{"name": "A1", "purpose": "p1"}]},
|
|
{"name": "Beta", "subsections": [{"name": "B1", "purpose": "p2"}]},
|
|
{"name": "Gamma", "subsections": [{"name": "G1", "purpose": "p3"}]},
|
|
]
|
|
sections = {
|
|
"Alpha": "Alpha content",
|
|
"Beta": "Beta content",
|
|
"Gamma": "Gamma content",
|
|
}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
assert "1. **Alpha**" in content
|
|
assert "2. **Beta**" in content
|
|
assert "3. **Gamma**" in content
|
|
|
|
def test_toc_subsection_numbering(self, generator, _patch_importlib):
|
|
"""Subsections use i.j numbering format."""
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [
|
|
{"name": "Sub1", "purpose": "first"},
|
|
{"name": "Sub2", "purpose": "second"},
|
|
{"name": "Sub3", "purpose": "third"},
|
|
],
|
|
}
|
|
]
|
|
sections = {"Section": "content"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
assert "1.1 Sub1" in content
|
|
assert "1.2 Sub2" in content
|
|
assert "1.3 Sub3" in content
|
|
|
|
def test_toc_subsection_includes_purpose_in_italics(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""Subsection TOC entries include purpose in italics after pipe."""
|
|
structure = [
|
|
{
|
|
"name": "S",
|
|
"subsections": [
|
|
{"name": "Detail", "purpose": "explain details"},
|
|
{"name": "Extra", "purpose": "additional info"},
|
|
],
|
|
}
|
|
]
|
|
sections = {"S": "content"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
assert "_explain details_" in content
|
|
|
|
def test_section_content_appears_in_structure_order(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""Section content is emitted in the order defined by structure, not dict order."""
|
|
structure = [
|
|
{"name": "Zebra", "subsections": []},
|
|
{"name": "Apple", "subsections": []},
|
|
]
|
|
sections = {
|
|
"Apple": "Apple body text",
|
|
"Zebra": "Zebra body text",
|
|
}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
zebra_pos = content.index("Zebra body text")
|
|
apple_pos = content.index("Apple body text")
|
|
assert zebra_pos < apple_pos, "Sections must appear in structure order"
|
|
|
|
def test_missing_section_content_omitted_gracefully(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""If a section name is in structure but not in sections dict, it is skipped."""
|
|
structure = [
|
|
{"name": "Present", "subsections": []},
|
|
{"name": "Missing", "subsections": []},
|
|
]
|
|
sections = {"Present": "Present content"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
assert "Present content" in content
|
|
|
|
def test_sources_section_appended_at_end(self, generator, _patch_importlib):
|
|
"""Final report ends with ## Sources followed by formatted links."""
|
|
structure = [{"name": "A", "subsections": []}]
|
|
sections = {"A": "body"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
content = report["content"]
|
|
|
|
assert content.rstrip().endswith("- [Link1](http://a.com)")
|
|
|
|
def test_research_summary_present(self, generator, _patch_importlib):
|
|
"""Report contains '# Research Summary' section."""
|
|
structure = [{"name": "A", "subsections": []}]
|
|
sections = {"A": "body"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
assert "# Research Summary" in report["content"]
|
|
|
|
def test_metadata_searches_per_section(self, generator, _patch_importlib):
|
|
"""Metadata includes searches_per_section value."""
|
|
generator.searches_per_section = 7
|
|
structure = [{"name": "A", "subsections": []}]
|
|
sections = {"A": "body"}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
assert report["metadata"]["searches_per_section"] == 7
|
|
|
|
def test_metadata_initial_sources_counts_links(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""Metadata initial_sources equals len(all_links_of_system)."""
|
|
generator.search_system.all_links_of_system = [
|
|
{"link": "a"},
|
|
{"link": "b"},
|
|
{"link": "c"},
|
|
]
|
|
structure = []
|
|
sections = {}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
assert report["metadata"]["initial_sources"] == 3
|
|
|
|
def test_metadata_generated_at_is_iso_format(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""generated_at is a valid ISO 8601 string."""
|
|
from datetime import datetime
|
|
|
|
structure = []
|
|
sections = {}
|
|
|
|
report = generator._format_final_report(sections, structure, "q")
|
|
# Should not raise
|
|
datetime.fromisoformat(report["metadata"]["generated_at"])
|
|
|
|
def test_empty_structure_produces_valid_report(
|
|
self, generator, _patch_importlib
|
|
):
|
|
"""Empty structure list still produces a report with TOC header and sources."""
|
|
report = generator._format_final_report({}, [], "q")
|
|
assert "Table of Contents" in report["content"]
|
|
assert "## Sources" in report["content"]
|
|
assert report["metadata"]["sections_researched"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _truncate_at_sentence_boundary -- additional edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTruncateEdgeCases:
|
|
"""Additional edge cases for _truncate_at_sentence_boundary."""
|
|
|
|
def test_text_with_only_punctuation(self, generator):
|
|
"""Text like '...' should not crash."""
|
|
result = generator._truncate_at_sentence_boundary("...", 2)
|
|
assert result == "..\n[...truncated]"
|
|
|
|
def test_sentence_ending_at_exactly_max_chars(self, generator):
|
|
"""When sentence ends exactly at max_chars, use that boundary."""
|
|
text = "Hello. World."
|
|
# max_chars=6: truncated = "Hello." -> period at index 5 followed by end
|
|
result = generator._truncate_at_sentence_boundary(text, 6)
|
|
assert result.startswith("Hello.")
|
|
assert "[...truncated]" in result
|
|
|
|
def test_max_chars_one(self, generator):
|
|
"""max_chars=1 with longer text."""
|
|
result = generator._truncate_at_sentence_boundary("ab", 1)
|
|
assert result == "a\n[...truncated]"
|
|
|
|
def test_exclamation_at_end_of_truncated_region(self, generator):
|
|
"""Exclamation mark at very end of truncated region is a valid boundary."""
|
|
text = "Stop! More text follows here and keeps going."
|
|
# "Stop!" is 5 chars, max_chars=5: truncated = "Stop!"
|
|
result = generator._truncate_at_sentence_boundary(text, 5)
|
|
assert "Stop!" in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_mixed_sentence_endings(self, generator):
|
|
"""Text with mixed .!? picks last valid one before limit."""
|
|
text = "First. Second! Third? Fourth sentence that is very very long."
|
|
# max_chars=25: truncated = "First. Second! Third? Fou"
|
|
result = generator._truncate_at_sentence_boundary(text, 25)
|
|
# Last valid boundary: "?" at index 20, followed by " ", 21 > 20 (80% of 25)
|
|
assert "Third?" in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_newline_after_period_is_valid_boundary(self, generator):
|
|
"""Period followed by newline counts as sentence boundary."""
|
|
text = (
|
|
"Done.\nMore text that extends beyond the character limit by a lot."
|
|
)
|
|
result = generator._truncate_at_sentence_boundary(text, 10)
|
|
assert "Done." in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_period_in_middle_of_word_not_boundary(self, generator):
|
|
"""Period not followed by space/newline/end is not a boundary."""
|
|
# "file.txt" has a period but it's followed by 't'
|
|
text = "file.txt is the name of the file and it is important to know."
|
|
result = generator._truncate_at_sentence_boundary(text, 12)
|
|
# "file.txt is " -> period at 4 followed by 't', not space
|
|
# No valid boundary -> hard truncation
|
|
assert result == text[:12] + "\n[...truncated]"
|
|
|
|
def test_unicode_text_truncation(self, generator):
|
|
"""Unicode characters are handled correctly in truncation."""
|
|
text = "Bonjour le monde. C'est magnifique! Plus de texte ici."
|
|
result = generator._truncate_at_sentence_boundary(text, 40)
|
|
assert "[...truncated]" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_previous_context -- boundary conditions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildPreviousContextBoundary:
|
|
"""Boundary conditions for _build_previous_context."""
|
|
|
|
def test_exactly_max_context_sections_findings(self, generator):
|
|
"""When findings count equals max_context_sections, all are included."""
|
|
generator.max_context_sections = 3
|
|
findings = ["Finding A", "Finding B", "Finding C"]
|
|
result = generator._build_previous_context(findings)
|
|
assert "Finding A" in result
|
|
assert "Finding B" in result
|
|
assert "Finding C" in result
|
|
|
|
def test_one_more_than_max_drops_oldest(self, generator):
|
|
"""When findings count is max+1, oldest is dropped."""
|
|
generator.max_context_sections = 2
|
|
findings = ["Old", "Mid", "New"]
|
|
result = generator._build_previous_context(findings)
|
|
assert "Old" not in result
|
|
assert "Mid" in result
|
|
assert "New" in result
|
|
|
|
def test_context_chars_exactly_at_limit_no_truncation(self, generator):
|
|
"""When context length equals max_context_chars, no truncation marker."""
|
|
generator.max_context_chars = 20
|
|
# Single finding exactly 20 chars
|
|
finding = "x" * 20
|
|
result = generator._build_previous_context([finding])
|
|
assert "[...truncated]" not in result
|
|
|
|
def test_context_chars_one_over_limit_triggers_truncation(self, generator):
|
|
"""When context length is max_context_chars + 1, truncation happens."""
|
|
generator.max_context_chars = 20
|
|
finding = "x" * 21
|
|
result = generator._build_previous_context([finding])
|
|
assert "[...truncated]" in result
|
|
|
|
def test_max_context_sections_zero_returns_all(self, generator):
|
|
"""max_context_sections=0 -> [-0:] is full list in Python, so all are included."""
|
|
generator.max_context_sections = 0
|
|
result = generator._build_previous_context(["A", "B"])
|
|
# [-0:] == [0:] == full list, so both findings appear
|
|
assert "A" in result
|
|
assert "B" in result
|
|
|
|
def test_single_finding_with_large_char_limit(self, generator):
|
|
"""Single small finding with huge char limit is not truncated."""
|
|
generator.max_context_chars = 100000
|
|
result = generator._build_previous_context(["Small finding."])
|
|
assert "Small finding." in result
|
|
assert "[...truncated]" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _research_and_generate_sections -- iteration override and restore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIterationOverrideRestore:
|
|
"""Tests that iteration settings are restored even on exception."""
|
|
|
|
def test_settings_restored_after_analyze_topic_exception(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""If analyze_topic raises, iteration settings are still restored."""
|
|
mock_search_system.strategy.settings_snapshot = {"search.iterations": 5}
|
|
mock_search_system.strategy.max_iterations = 5
|
|
mock_search_system.analyze_topic.side_effect = RuntimeError("boom")
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
# Settings must be restored
|
|
assert (
|
|
mock_search_system.strategy.settings_snapshot["search.iterations"]
|
|
== 5
|
|
)
|
|
assert mock_search_system.strategy.max_iterations == 5
|
|
|
|
def test_settings_restored_when_key_not_originally_present(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""If search.iterations was not in snapshot originally, it is removed after."""
|
|
mock_search_system.strategy.settings_snapshot = {} # no key
|
|
mock_search_system.strategy.max_iterations = 3
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "ok"
|
|
}
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
# Key should be removed since it was not originally present
|
|
assert (
|
|
"search.iterations"
|
|
not in mock_search_system.strategy.settings_snapshot
|
|
)
|
|
|
|
def test_iteration_set_to_one_during_analyze_topic(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""During analyze_topic, search.iterations should be 1."""
|
|
captured_value = {}
|
|
|
|
def capture_settings(query):
|
|
captured_value["iterations"] = (
|
|
mock_search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
)
|
|
captured_value["max_iterations"] = (
|
|
mock_search_system.strategy.max_iterations
|
|
)
|
|
return {"current_knowledge": "content"}
|
|
|
|
mock_search_system.analyze_topic.side_effect = capture_settings
|
|
mock_search_system.strategy.settings_snapshot = {"search.iterations": 5}
|
|
mock_search_system.strategy.max_iterations = 5
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert captured_value["iterations"] == 1
|
|
assert captured_value["max_iterations"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _research_and_generate_sections -- section with pipe in name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSectionWithPipeInName:
|
|
"""When a section has no subsections and contains a pipe, it splits into name|purpose."""
|
|
|
|
def test_pipe_in_section_name_creates_subsection_with_purpose(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Section name 'Overview | General introduction' becomes subsection."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "body"
|
|
}
|
|
|
|
structure = [
|
|
{"name": "Overview | General introduction", "subsections": []}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "Overview | General introduction" in sections
|
|
mock_search_system.analyze_topic.assert_called_once()
|
|
|
|
def test_no_pipe_in_section_name_uses_section_name_as_subsection(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Section without pipe and no subsections uses section name as subsection name."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "body"
|
|
}
|
|
|
|
structure = [{"name": "Introduction", "subsections": []}]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "Introduction" in sections
|
|
# The query should contain the section name
|
|
query_arg = mock_search_system.analyze_topic.call_args[0][0]
|
|
assert "Introduction" in query_arg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _research_and_generate_sections -- empty current_knowledge handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEmptySubsectionResults:
|
|
"""When analyze_topic returns empty/None current_knowledge."""
|
|
|
|
def test_none_current_knowledge_shows_limited_info_message(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""None current_knowledge produces 'Limited information' placeholder."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": None
|
|
}
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "Limited information" in sections["S"]
|
|
|
|
def test_empty_string_current_knowledge_shows_limited_info(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Empty string current_knowledge produces 'Limited information' placeholder."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": ""
|
|
}
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "Limited information" in sections["S"]
|
|
|
|
def test_missing_current_knowledge_key_shows_limited_info(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Missing current_knowledge key produces 'Limited information' placeholder."""
|
|
mock_search_system.analyze_topic.return_value = {}
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "Limited information" in sections["S"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _research_and_generate_sections -- subsection header logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubsectionHeaders:
|
|
"""Tests for subsection header inclusion based on subsection count."""
|
|
|
|
def test_single_subsection_no_header(self, generator, mock_search_system):
|
|
"""Section with one subsection does not add ## subsection header."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "body"
|
|
}
|
|
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "OnlySub", "purpose": "p"}]}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "## OnlySub" not in sections["S"]
|
|
|
|
def test_multiple_subsections_add_headers(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Section with multiple subsections adds ## headers for each."""
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "body"
|
|
}
|
|
|
|
structure = [
|
|
{
|
|
"name": "S",
|
|
"subsections": [
|
|
{"name": "SubA", "purpose": "pA"},
|
|
{"name": "SubB", "purpose": "pB"},
|
|
],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
{"current_knowledge": "init"}, structure, "q"
|
|
)
|
|
|
|
assert "## 1.1 SubA" in sections["S"]
|
|
assert "## 1.2 SubB" in sections["S"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _generate_error_report -- output format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateErrorReport:
|
|
"""Tests for _generate_error_report output."""
|
|
|
|
def test_contains_error_header(self, generator):
|
|
result = generator._generate_error_report("my query", "oops")
|
|
assert "=== ERROR REPORT ===" in result
|
|
|
|
def test_contains_query(self, generator):
|
|
result = generator._generate_error_report("my query", "oops")
|
|
assert "my query" in result
|
|
|
|
def test_contains_error_message(self, generator):
|
|
result = generator._generate_error_report("q", "something broke")
|
|
assert "something broke" in result
|
|
|
|
def test_empty_error_message(self, generator):
|
|
result = generator._generate_error_report("q", "")
|
|
assert "Error:" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# generate_report -- progress callback ordering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGenerateReportProgressOrdering:
|
|
"""Verify progress callback phase order in generate_report."""
|
|
|
|
def test_phases_in_correct_order(
|
|
self, generator, mock_search_system, mock_model
|
|
):
|
|
"""Phases: structure -> section_research -> formatting -> complete."""
|
|
mock_model.invoke.return_value = MagicMock(
|
|
content="STRUCTURE\n1. Intro\n - Over | purpose\nEND_STRUCTURE"
|
|
)
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "text"
|
|
}
|
|
|
|
with patch(f"{MODULE}.importlib") as mock_imp:
|
|
mock_utils = MagicMock()
|
|
mock_utils.search_utilities.format_links_to_markdown.return_value = ""
|
|
mock_imp.import_module.return_value = mock_utils
|
|
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
{"current_knowledge": "findings"},
|
|
"q",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
phases = [c.args[2]["phase"] for c in callback.call_args_list]
|
|
# First must be structure, last must be complete
|
|
assert phases[0] == "report_structure"
|
|
assert phases[-1] == "report_complete"
|
|
|
|
# formatting must come after all section research
|
|
fmt_idx = phases.index("report_formatting")
|
|
research_indices = [
|
|
i for i, p in enumerate(phases) if p == "report_section_research"
|
|
]
|
|
if research_indices:
|
|
assert fmt_idx > max(research_indices)
|
|
|
|
def test_no_callback_does_not_crash(
|
|
self, generator, mock_search_system, mock_model
|
|
):
|
|
"""generate_report works without progress_callback."""
|
|
mock_model.invoke.return_value = MagicMock(
|
|
content="STRUCTURE\n1. S\n - Sub | p\nEND_STRUCTURE"
|
|
)
|
|
mock_search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "t"
|
|
}
|
|
|
|
with patch(f"{MODULE}.importlib") as mock_imp:
|
|
mock_utils = MagicMock()
|
|
mock_utils.search_utilities.format_links_to_markdown.return_value = ""
|
|
mock_imp.import_module.return_value = mock_utils
|
|
|
|
result = generator.generate_report({"current_knowledge": "f"}, "q")
|
|
|
|
assert "content" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _generate_sections (deprecated) -- returns empty dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeprecatedGenerateSections:
|
|
"""The deprecated _generate_sections always returns {}."""
|
|
|
|
def test_returns_empty_dict(self, generator):
|
|
result = generator._generate_sections({}, {}, [], "q")
|
|
assert result == {}
|
|
|
|
def test_ignores_all_arguments(self, generator):
|
|
result = generator._generate_sections(
|
|
{"current_knowledge": "data"},
|
|
{"sec": [{"content": "c"}]},
|
|
[{"name": "S", "subsections": []}],
|
|
"query",
|
|
)
|
|
assert result == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _determine_report_structure -- think tag removal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDetermineReportStructureThinkTags:
|
|
"""Verify <think> blocks are stripped from the LLM response before parsing.
|
|
|
|
The response is normalized via ``get_llm_response_text`` (which strips think
|
|
tags), so reasoning content cannot leak into the parsed structure.
|
|
"""
|
|
|
|
def test_think_tags_stripped_before_parsing(self, generator, mock_model):
|
|
"""Numbered lines inside <think> must not become bogus sections."""
|
|
mock_model.invoke.return_value = MagicMock(
|
|
content=(
|
|
"<think>\n"
|
|
"1. Bogus reasoning section\n"
|
|
"</think>\n"
|
|
"STRUCTURE\n1. Real\nEND_STRUCTURE"
|
|
)
|
|
)
|
|
|
|
findings = {"current_knowledge": "knowledge"}
|
|
structure = generator._determine_report_structure(findings, "q")
|
|
|
|
# Without stripping, "1. Bogus reasoning section" would parse as a
|
|
# section; with stripping only the real section remains.
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "Real"
|
|
|
|
def test_string_response_does_not_crash(self, generator, mock_model):
|
|
"""A raw string model return (no .content) is handled gracefully."""
|
|
mock_model.invoke.return_value = "STRUCTURE\n1. Real\nEND_STRUCTURE"
|
|
|
|
findings = {"current_knowledge": "knowledge"}
|
|
structure = generator._determine_report_structure(findings, "q")
|
|
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "Real"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _research_and_generate_sections -- questions_by_iteration propagation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestQuestionsPropagation:
|
|
"""Tests for questions_by_iteration being set on search system and strategy."""
|
|
|
|
def test_questions_set_on_strategy_when_present(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""questions_by_iteration from initial_findings is propagated to strategy."""
|
|
mock_search_system.questions_by_iteration = {}
|
|
mock_search_system.strategy.questions_by_iteration = {}
|
|
|
|
initial = {
|
|
"current_knowledge": "init",
|
|
"questions_by_iteration": {"0": ["Q1"], "1": ["Q2", "Q3"]},
|
|
}
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
generator._research_and_generate_sections(initial, structure, "q")
|
|
|
|
assert mock_search_system.strategy.questions_by_iteration == {
|
|
"0": ["Q1"],
|
|
"1": ["Q2", "Q3"],
|
|
}
|
|
|
|
def test_no_questions_key_does_not_crash(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Missing questions_by_iteration in initial_findings is fine."""
|
|
initial = {"current_knowledge": "init"}
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
# Should not raise
|
|
generator._research_and_generate_sections(initial, structure, "q")
|
|
|
|
def test_empty_questions_dict_does_not_propagate(
|
|
self, generator, mock_search_system
|
|
):
|
|
"""Empty questions_by_iteration dict is falsy, so nothing is set."""
|
|
original_q = {"original": True}
|
|
mock_search_system.strategy.questions_by_iteration = original_q
|
|
|
|
initial = {"current_knowledge": "init", "questions_by_iteration": {}}
|
|
structure = [
|
|
{"name": "S", "subsections": [{"name": "Sub", "purpose": "p"}]}
|
|
]
|
|
|
|
generator._research_and_generate_sections(initial, structure, "q")
|
|
|
|
# Empty dict is falsy -> original should remain
|
|
assert mock_search_system.strategy.questions_by_iteration is original_q
|