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
745 lines
27 KiB
Python
745 lines
27 KiB
Python
"""
|
|
Tests for report_generator.py - Structure Parsing Edge Cases
|
|
|
|
Tests cover the parsing of LLM-generated report structures, including:
|
|
- Subsection parsing with pipes
|
|
- Malformed structure handling
|
|
- Source section filtering
|
|
|
|
These tests address real bugs like the one fixed in commit 5128c1d6 for pipes in purpose.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestSubsectionParsingEdgeCases:
|
|
"""Tests for edge cases in subsection parsing."""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
"""Create a report generator with mocked dependencies."""
|
|
with patch("local_deep_research.report_generator.AdvancedSearchSystem"):
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
yield generator
|
|
|
|
def test_multiple_pipes_in_purpose(self, report_generator):
|
|
"""'Overview | What is x | How it works' preserves all after first pipe."""
|
|
# Simulate LLM response with multiple pipes
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | What is x | How it works
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert len(structure[0]["subsections"]) == 1
|
|
# Should preserve everything after the first pipe
|
|
assert structure[0]["subsections"][0]["name"] == "Overview"
|
|
assert (
|
|
"What is x | How it works"
|
|
in structure[0]["subsections"][0]["purpose"]
|
|
)
|
|
|
|
def test_pipe_at_start_of_purpose(self, report_generator):
|
|
"""'|| double pipe' handles empty before first pipe."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Section
|
|
- || double pipe content
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
# Empty name before first pipe - should handle gracefully
|
|
if structure[0]["subsections"]:
|
|
subsection = structure[0]["subsections"][0]
|
|
# The name might be empty or trimmed
|
|
assert "name" in subsection
|
|
# The name might be empty or the whole thing might be skipped
|
|
|
|
def test_empty_purpose_after_pipe(self, report_generator):
|
|
"""'Overview |' uses default purpose."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Section
|
|
- Overview |
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
subsection = structure[0]["subsections"][0]
|
|
assert subsection["name"] == "Overview"
|
|
# Empty purpose after pipe should be empty string
|
|
assert subsection["purpose"] == ""
|
|
|
|
def test_whitespace_only_after_pipe(self, report_generator):
|
|
"""'Overview | ' strips to empty, uses default."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Section
|
|
- Overview |
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
subsection = structure[0]["subsections"][0]
|
|
assert subsection["name"] == "Overview"
|
|
# Whitespace should be stripped
|
|
assert subsection["purpose"].strip() == ""
|
|
|
|
def test_special_chars_in_section_name(self, report_generator):
|
|
"""[Section 1] (Important) parsed correctly."""
|
|
response = """
|
|
STRUCTURE
|
|
1. [Section 1] (Important)
|
|
- Details | Explanation
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert "[Section 1] (Important)" in structure[0]["name"]
|
|
|
|
def test_unicode_in_section_names(self, report_generator):
|
|
"""Non-ASCII characters handled."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Introducción
|
|
- Resumen | Descripción general
|
|
2. 日本語セクション
|
|
- 詳細 | 説明
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 2
|
|
assert "Introducción" in structure[0]["name"]
|
|
assert "日本語セクション" in structure[1]["name"]
|
|
|
|
def test_very_long_section_names(self, report_generator):
|
|
"""Names over 200 chars."""
|
|
long_name = "A" * 250
|
|
response = f"""
|
|
STRUCTURE
|
|
1. {long_name}
|
|
- Subsection | Purpose
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert len(structure[0]["name"]) > 200
|
|
|
|
def test_numbered_section_with_leading_whitespace(self, report_generator):
|
|
"""' 1. Intro' parsed correctly."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert "Introduction" in structure[0]["name"]
|
|
|
|
def test_subsection_without_dash(self, report_generator):
|
|
"""Missing dash marker handled."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Section
|
|
Subsection without dash | Purpose
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Subsection without dash should not be parsed
|
|
assert len(structure) == 1
|
|
# May have empty subsections list or no subsections
|
|
assert len(structure[0]["subsections"]) == 0
|
|
|
|
def test_consecutive_pipe_characters(self, report_generator):
|
|
"""'Name|||Purpose' handles multiple pipes."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Section
|
|
- Name|||Purpose with pipes
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
subsection = structure[0]["subsections"][0]
|
|
assert subsection["name"] == "Name"
|
|
# Everything after first pipe preserved
|
|
assert "||Purpose with pipes" in subsection["purpose"]
|
|
|
|
|
|
class TestMalformedStructureHandling:
|
|
"""Tests for handling malformed LLM responses."""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
"""Create a report generator with mocked dependencies."""
|
|
with patch("local_deep_research.report_generator.AdvancedSearchSystem"):
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
yield generator
|
|
|
|
def test_missing_structure_keyword(self, report_generator):
|
|
"""No STRUCTURE marker in response."""
|
|
response = """
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
2. Main Content
|
|
- Details | Information
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Should still parse numbered sections
|
|
assert len(structure) >= 0 # Might be empty or partially parsed
|
|
|
|
def test_missing_end_structure(self, report_generator):
|
|
"""No END_STRUCTURE marker."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
2. Main Content
|
|
- Details | Information
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Should still parse content after STRUCTURE
|
|
assert len(structure) == 2
|
|
|
|
def test_empty_structure_block(self, report_generator):
|
|
"""STRUCTURE...END_STRUCTURE with nothing between."""
|
|
response = """
|
|
STRUCTURE
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Should return empty structure
|
|
assert structure == []
|
|
|
|
def test_invalid_json_like_structure(self, report_generator):
|
|
"""LLM returns JSON instead of expected format."""
|
|
response = """
|
|
STRUCTURE
|
|
{
|
|
"sections": [
|
|
{"name": "Introduction", "subsections": []}
|
|
]
|
|
}
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Should not crash, might return empty
|
|
assert isinstance(structure, list)
|
|
|
|
def test_partial_section_definition(self, report_generator):
|
|
"""Incomplete section definition."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Complete Section
|
|
- Subsection | Purpose
|
|
2.
|
|
3. Another Complete
|
|
- Sub | Purpose
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Should parse complete sections, skip incomplete
|
|
# Section 2 has no name after the dot
|
|
assert len(structure) >= 2
|
|
|
|
def test_determine_structure_handles_string_response(
|
|
self, report_generator
|
|
):
|
|
"""A raw string LLM return (no .content) is handled without AttributeError."""
|
|
report_generator.model.invoke.return_value = (
|
|
"STRUCTURE\n1. Introduction\n - Overview | Purpose\nEND_STRUCTURE"
|
|
)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "Introduction"
|
|
|
|
def test_determine_structure_strips_think_tags(self, report_generator):
|
|
"""<think> reasoning is stripped before parsing so it can't create bogus sections."""
|
|
response = (
|
|
"<think>\n"
|
|
"1. First I should consider the topic\n"
|
|
"2. Then organize by theme\n"
|
|
"</think>\n"
|
|
"STRUCTURE\n"
|
|
"1. Introduction\n"
|
|
" - Overview | Purpose\n"
|
|
"END_STRUCTURE"
|
|
)
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Without stripping, the numbered lines inside <think> would parse as
|
|
# bogus sections; with stripping only the real section remains.
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "Introduction"
|
|
|
|
|
|
class TestSourceSectionFiltering:
|
|
"""Tests for filtering source-related sections."""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
"""Create a report generator with mocked dependencies."""
|
|
with patch("local_deep_research.report_generator.AdvancedSearchSystem"):
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
yield generator
|
|
|
|
def test_references_section_removed(self, report_generator):
|
|
"""'References' as last section removed."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
2. Main Content
|
|
- Details | Information
|
|
3. References
|
|
- List | Sources used
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# References should be removed
|
|
assert len(structure) == 2
|
|
assert all("References" not in s["name"] for s in structure)
|
|
|
|
def test_bibliography_section_removed(self, report_generator):
|
|
"""'Bibliography' as last section removed."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
2. Bibliography
|
|
- Sources | References
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert "Bibliography" not in structure[0]["name"]
|
|
|
|
def test_sources_section_not_last_preserved(self, report_generator):
|
|
"""'Sources' not at end preserved."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Data Sources
|
|
- Overview | Where data comes from
|
|
2. Analysis
|
|
- Details | Information
|
|
3. Conclusion
|
|
- Summary | Final thoughts
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Data Sources is not last, should be preserved
|
|
# Only the LAST section is checked for source keywords
|
|
assert len(structure) == 3
|
|
assert any("Sources" in s["name"] for s in structure)
|
|
|
|
def test_multiple_source_sections(self, report_generator):
|
|
"""Only last source-related section removed."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Data Sources Overview
|
|
- Types | Different sources
|
|
2. Main Content
|
|
- Details | Information
|
|
3. Citation Sources
|
|
- List | All citations
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
# Only last section (Citation Sources) should be removed
|
|
assert len(structure) == 2
|
|
# First sources section should still be there
|
|
assert "Data Sources Overview" in structure[0]["name"]
|
|
|
|
def test_case_insensitive_source_detection(self, report_generator):
|
|
"""'REFERENCES', 'references' both detected."""
|
|
# Test uppercase
|
|
response = """
|
|
STRUCTURE
|
|
1. Introduction
|
|
- Overview | Purpose
|
|
2. REFERENCES
|
|
- List | Sources
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert "REFERENCES" not in structure[0]["name"]
|
|
|
|
|
|
class TestReportGenerationIntegration:
|
|
"""Integration tests for report generation flow."""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
"""Create a report generator with mocked dependencies."""
|
|
with patch(
|
|
"local_deep_research.report_generator.AdvancedSearchSystem"
|
|
) as mock_search:
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
|
|
# Mock search system
|
|
mock_search_instance = MagicMock()
|
|
mock_search_instance.all_links_of_system = []
|
|
mock_search_instance.analyze_topic.return_value = {
|
|
"current_knowledge": "Section content"
|
|
}
|
|
mock_search.return_value = mock_search_instance
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
generator.search_system = mock_search_instance
|
|
yield generator
|
|
|
|
def test_section_without_subsections_creates_default(
|
|
self, report_generator
|
|
):
|
|
"""Section with no subsections gets default subsection."""
|
|
response = """
|
|
STRUCTURE
|
|
1. Standalone Section
|
|
END_STRUCTURE
|
|
"""
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
|
|
findings = {"current_knowledge": "Test content " * 100}
|
|
structure = report_generator._determine_report_structure(
|
|
findings, "test query"
|
|
)
|
|
|
|
assert len(structure) == 1
|
|
assert structure[0]["subsections"] == [] # No subsections in structure
|
|
|
|
def test_research_and_generate_handles_empty_subsections(
|
|
self, report_generator
|
|
):
|
|
"""_research_and_generate_sections handles sections with no subsections."""
|
|
structure = [{"name": "Standalone", "subsections": []}]
|
|
|
|
# This should create default subsections during generation
|
|
sections = report_generator._research_and_generate_sections(
|
|
{"current_knowledge": "test", "questions_by_iteration": {}},
|
|
structure,
|
|
"test query",
|
|
)
|
|
|
|
assert "Standalone" in sections
|
|
|
|
|
|
class TestSplitDotBug:
|
|
"""Section-name parsing splits only on the first period (split('.', 1)).
|
|
|
|
The parser does: section_name = line.split(".", 1)[1].strip(), so section
|
|
names containing periods (e.g. "1. U.S. Policy") are preserved intact.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
with patch("local_deep_research.report_generator.AdvancedSearchSystem"):
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
yield generator
|
|
|
|
def test_period_in_section_name_preserved(self, report_generator):
|
|
"""'1. U.S. Policy' keeps periods after the section number (split('.', 1))."""
|
|
response = "STRUCTURE\n1. U.S. Policy\nEND_STRUCTURE"
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "U.S. Policy"
|
|
|
|
def test_abbreviation_in_section_name_preserved(self, report_generator):
|
|
"""'1. IPv4 vs. IPv6' keeps the trailing abbreviation (split('.', 1))."""
|
|
response = "STRUCTURE\n1. IPv4 vs. IPv6\nEND_STRUCTURE"
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert structure[0]["name"] == "IPv4 vs. IPv6"
|
|
|
|
def test_double_digit_section_number(self, report_generator):
|
|
"""'10. Conclusion' parses correctly since split('.')[1] = ' Conclusion'."""
|
|
response = "STRUCTURE\n10. Conclusion\nEND_STRUCTURE"
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert structure[0]["name"] == "Conclusion"
|
|
|
|
def test_orphan_subsection_before_section(self, report_generator):
|
|
"""Subsection before any section header is skipped."""
|
|
response = (
|
|
"STRUCTURE\n - Orphan | purpose\n1. Real Section\nEND_STRUCTURE"
|
|
)
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert len(structure) == 1
|
|
assert structure[0]["name"] == "Real Section"
|
|
assert structure[0]["subsections"] == []
|
|
|
|
def test_only_source_section_returns_empty(self, report_generator):
|
|
"""If only section is source-related, result is empty."""
|
|
response = "STRUCTURE\n1. References\nEND_STRUCTURE"
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert len(structure) == 0
|
|
|
|
def test_empty_lines_between_sections(self, report_generator):
|
|
"""Empty lines between sections are ignored."""
|
|
response = "STRUCTURE\n1. Intro\n\n\n2. Methods\nEND_STRUCTURE"
|
|
report_generator.model.invoke.return_value = MagicMock(content=response)
|
|
findings = {"current_knowledge": "Test " * 100}
|
|
structure = report_generator._determine_report_structure(findings, "q")
|
|
assert len(structure) == 2
|
|
|
|
|
|
class TestFormatFinalReport:
|
|
"""Tests for final report formatting."""
|
|
|
|
@pytest.fixture
|
|
def report_generator(self):
|
|
"""Create a report generator with mocked dependencies."""
|
|
with patch("local_deep_research.report_generator.AdvancedSearchSystem"):
|
|
with patch(
|
|
"local_deep_research.report_generator.get_llm"
|
|
) as mock_get_llm:
|
|
mock_llm = MagicMock()
|
|
mock_get_llm.return_value = mock_llm
|
|
|
|
mock_search_instance = MagicMock()
|
|
mock_search_instance.all_links_of_system = []
|
|
|
|
from local_deep_research.report_generator import (
|
|
IntegratedReportGenerator,
|
|
)
|
|
|
|
generator = IntegratedReportGenerator(llm=mock_llm)
|
|
generator.search_system = mock_search_instance
|
|
yield generator
|
|
|
|
def test_format_includes_toc(self, report_generator):
|
|
"""Final report includes table of contents."""
|
|
structure = [
|
|
{
|
|
"name": "Introduction",
|
|
"subsections": [
|
|
{"name": "Overview", "purpose": "General intro"}
|
|
],
|
|
}
|
|
]
|
|
sections = {"Introduction": "# Introduction\n\nContent here"}
|
|
|
|
with patch(
|
|
"local_deep_research.report_generator.importlib"
|
|
) as mock_import:
|
|
mock_utils = MagicMock()
|
|
mock_utils.search_utilities.format_links_to_markdown.return_value = ""
|
|
mock_import.import_module.return_value = mock_utils
|
|
|
|
report = report_generator._format_final_report(
|
|
sections, structure, "test query"
|
|
)
|
|
|
|
assert "Table of Contents" in report["content"]
|
|
assert "Introduction" in report["content"]
|
|
|
|
def test_format_includes_metadata(self, report_generator):
|
|
"""Final report includes metadata."""
|
|
structure = []
|
|
sections = {}
|
|
|
|
with patch(
|
|
"local_deep_research.report_generator.importlib"
|
|
) as mock_import:
|
|
mock_utils = MagicMock()
|
|
mock_utils.search_utilities.format_links_to_markdown.return_value = ""
|
|
mock_import.import_module.return_value = mock_utils
|
|
|
|
report = report_generator._format_final_report(
|
|
sections, structure, "test query"
|
|
)
|
|
|
|
assert "metadata" in report
|
|
assert "generated_at" in report["metadata"]
|
|
assert "query" in report["metadata"]
|
|
assert report["metadata"]["query"] == "test query"
|