""" 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): """ reasoning is stripped before parsing so it can't create bogus sections.""" response = ( "\n" "1. First I should consider the topic\n" "2. Then organize by theme\n" "\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 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"