""" Tests for research_service synthesis and report generation. Tests cover: - Quick mode synthesis - Report generation - News search integration """ from unittest.mock import Mock, MagicMock, patch import pytest class TestQuickModeSynthesis: """Tests for quick mode synthesis.""" def test_quick_mode_synthesis_success(self): """Quick mode synthesis completes successfully.""" results = { "findings": [{"content": "Test finding", "phase": "search"}], "formatted_findings": "# Research Summary\n\nTest summary.", "iterations": 2, } # Synthesis should have formatted findings assert results.get("formatted_findings") assert not results["formatted_findings"].startswith("Error:") def test_quick_mode_synthesis_token_limit_error(self): """Quick mode synthesis detects token limit errors.""" results = { "findings": [{"content": "Finding", "phase": "search"}], "formatted_findings": "Error: context length exceeded", "iterations": 2, } error_message = results["formatted_findings"].lower() if "token limit" in error_message or "context length" in error_message: error_type = "token_limit" else: error_type = "unknown" assert error_type == "token_limit" def test_quick_mode_synthesis_timeout_error(self): """Quick mode synthesis detects timeout errors.""" results = { "formatted_findings": "Error: request timed out", "iterations": 1, } error_message = results["formatted_findings"].lower() if "timeout" in error_message or "timed out" in error_message: error_type = "timeout" else: error_type = "unknown" assert error_type == "timeout" def test_quick_mode_synthesis_rate_limit_error(self): """Quick mode synthesis detects rate limit errors.""" results = { "formatted_findings": "Error: rate limit exceeded", "iterations": 1, } error_message = results["formatted_findings"].lower() if "rate limit" in error_message: error_type = "rate_limit" else: error_type = "unknown" assert error_type == "rate_limit" def test_quick_mode_synthesis_connection_error(self): """Quick mode synthesis detects connection errors.""" results = { "formatted_findings": "Error: connection refused", "iterations": 1, } error_message = results["formatted_findings"].lower() if "connection" in error_message or "network" in error_message: error_type = "connection" else: error_type = "unknown" assert error_type == "connection" def test_quick_mode_synthesis_llm_error(self): """Quick mode synthesis detects general LLM errors.""" results = { "formatted_findings": "Error: LLM error during synthesis", "iterations": 1, } error_message = results["formatted_findings"].lower() if ( "llm error" in error_message or "final answer synthesis fail" in error_message ): error_type = "llm_error" else: error_type = "unknown" assert error_type == "llm_error" def test_quick_mode_synthesis_fallback_cascade_level_1(self): """Quick mode synthesis uses synthesized content as first fallback.""" results = { "findings": [ {"content": "Search finding", "phase": "search"}, { "content": "Good synthesis content", "phase": "Final synthesis", }, ], "formatted_findings": "Error: post-processing failed", "iterations": 2, } # Level 1 fallback: use Final synthesis content fallback_content = None for finding in results.get("findings", []): if finding.get("phase") == "Final synthesis": content = finding.get("content", "") if not content.startswith("Error:"): fallback_content = content break assert fallback_content == "Good synthesis content" def test_quick_mode_synthesis_fallback_cascade_level_2(self): """Quick mode synthesis uses current_knowledge as second fallback.""" results = { "findings": [ { "content": "Error: synthesis failed", "phase": "Final synthesis", } ], "formatted_findings": "Error: synthesis failed", "current_knowledge": "Accumulated knowledge from search", "iterations": 2, } # Level 2 fallback: use current_knowledge fallback_content = None # Try Level 1 first for finding in results.get("findings", []): if finding.get("phase") == "Final synthesis": content = finding.get("content", "") if not content.startswith("Error:"): fallback_content = content break # Level 2 if not fallback_content and results.get("current_knowledge"): fallback_content = results["current_knowledge"] assert fallback_content == "Accumulated knowledge from search" def test_quick_mode_synthesis_fallback_cascade_level_3(self): """Quick mode synthesis combines findings as last fallback.""" results = { "findings": [ {"content": "Finding 1", "phase": "search"}, {"content": "Finding 2", "phase": "analysis"}, ], "formatted_findings": "Error: all synthesis failed", "current_knowledge": "", "iterations": 2, } # Level 3 fallback: combine all non-error findings fallback_content = None # Skip Levels 1 and 2 # Level 3 valid_findings = [ f"## {f.get('phase', 'Finding')}\n\n{f.get('content', '')}" for f in results.get("findings", []) if f.get("content") and not f.get("content", "").startswith("Error:") ] if valid_findings: fallback_content = "# Research Results (Fallback Mode)\n\n" fallback_content += "\n\n".join(valid_findings) assert fallback_content is not None assert "Finding 1" in fallback_content assert "Finding 2" in fallback_content def test_quick_mode_synthesis_all_fallbacks_exhausted(self): """Quick mode synthesis handles exhausted fallbacks.""" results = { "findings": [], "formatted_findings": "Error: complete failure", "current_knowledge": "", "iterations": 0, } # All fallbacks fail fallback_content = None for finding in results.get("findings", []): if finding.get("phase") == "Final synthesis": content = finding.get("content", "") if not content.startswith("Error:"): fallback_content = content break if not fallback_content and results.get("current_knowledge"): fallback_content = results["current_knowledge"] if not fallback_content: valid_findings = [ f for f in results.get("findings", []) if f.get("content") and not f.get("content", "").startswith("Error:") ] if valid_findings: fallback_content = "Combined findings" # All fallbacks exhausted assert fallback_content is None def test_quick_mode_synthesis_partial_content_recovery(self): """Quick mode synthesis recovers partial content from errors.""" results = { "findings": [ {"content": "Complete finding 1", "phase": "search"}, {"content": "Error: partial", "phase": "synthesis"}, {"content": "Complete finding 3", "phase": "analysis"}, ], "formatted_findings": "Error: synthesis incomplete", "iterations": 2, } # Extract valid findings only valid_findings = [ f for f in results.get("findings", []) if f.get("content") and not f.get("content", "").startswith("Error:") ] assert len(valid_findings) == 2 def test_quick_mode_synthesis_context_overflow_recovery(self): """Quick mode synthesis handles context overflow.""" results = { "findings": [{"content": "Finding", "phase": "search"}], "formatted_findings": "Error: maximum context length exceeded", "iterations": 1, } error_msg = results["formatted_findings"].lower() is_overflow = ( "context length" in error_msg or "context limit" in error_msg ) assert is_overflow def test_quick_mode_synthesis_streaming_response_handling(self): """Quick mode synthesis handles streaming responses.""" # Streaming responses should be accumulated chunks = ["Part 1", " Part 2", " Part 3"] full_response = "".join(chunks) assert full_response == "Part 1 Part 2 Part 3" def test_quick_mode_synthesis_progress_callback_sequencing(self): """Quick mode synthesis calls progress callbacks in order.""" progress_calls = [] def progress_callback(message, progress, metadata): progress_calls.append((message, progress, metadata.get("phase"))) # Simulate progress sequence progress_callback( "Starting synthesis", 85, {"phase": "output_generation"} ) progress_callback( "Generating summary", 90, {"phase": "output_generation"} ) progress_callback("Saving report", 95, {"phase": "report_complete"}) progress_callback("Completed", 100, {"phase": "complete"}) assert len(progress_calls) == 4 assert progress_calls[0][1] == 85 assert progress_calls[-1][1] == 100 assert progress_calls[-1][2] == "complete" def test_quick_mode_synthesis_empty_results_handling(self): """Quick mode synthesis handles empty results.""" results = { "findings": [], "formatted_findings": "", "iterations": 0, } # No findings should raise an exception in actual code has_findings = bool( results.get("findings") or results.get("formatted_findings") ) assert not has_findings class TestReportGeneration: """Tests for report generation.""" @patch( "local_deep_research.web.services.research_service.get_user_db_session" ) @patch( "local_deep_research.web.services.research_service.get_citation_formatter" ) def test_report_generation_success(self, mock_formatter, mock_get_session): """Report generation completes successfully.""" mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_get_session.return_value = mock_session mock_fmt = Mock() mock_fmt.format_document.return_value = "# Formatted Report" mock_formatter.return_value = mock_fmt # Simulate report generation content = "# Raw Report" formatted = mock_fmt.format_document(content) assert formatted == "# Formatted Report" @patch("local_deep_research.exporters.ExporterRegistry") def test_report_generation_pdf_export_success(self, mock_registry): """Report PDF export succeeds.""" mock_exporter = Mock() mock_result = Mock() mock_result.content = b"PDF content" mock_result.filename = "report.pdf" mock_result.mimetype = "application/pdf" mock_exporter.export.return_value = mock_result mock_registry.get_exporter.return_value = mock_exporter from local_deep_research.web.services.research_service import ( export_report_to_memory, ) content, filename, mimetype = export_report_to_memory( "# Report", "pdf", title="Test" ) assert content == b"PDF content" assert filename.endswith(".pdf") assert mimetype == "application/pdf" @patch("local_deep_research.exporters.ExporterRegistry") def test_report_generation_pdf_export_failure_recovery(self, mock_registry): """Report PDF export handles failure.""" mock_exporter = Mock() mock_exporter.export.side_effect = Exception("PDF error") mock_registry.get_exporter.return_value = mock_exporter from local_deep_research.web.services.research_service import ( export_report_to_memory, ) with pytest.raises(Exception) as exc_info: export_report_to_memory("# Report", "pdf", title="Test") assert "PDF error" in str(exc_info.value) @patch( "local_deep_research.web.services.research_service.get_user_db_session" ) def test_report_generation_database_commit_success(self, mock_get_session): """Report generation commits to database.""" mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_get_session.return_value = mock_session # Simulate DB commit mock_session.commit() mock_session.commit.assert_called() @patch( "local_deep_research.web.services.research_service.get_user_db_session" ) def test_report_generation_database_commit_failure(self, mock_get_session): """Report generation handles database commit failure.""" mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.commit.side_effect = Exception("DB error") mock_get_session.return_value = mock_session with pytest.raises(Exception) as exc_info: mock_session.commit() assert "DB error" in str(exc_info.value) def test_report_generation_metadata_json_parsing(self): """Report generation parses metadata JSON.""" import json metadata_str = ( '{"iterations": 3, "generated_at": "2024-01-01T00:00:00Z"}' ) metadata = json.loads(metadata_str) assert metadata["iterations"] == 3 assert "generated_at" in metadata def test_report_generation_metadata_invalid_json(self): """Report generation handles invalid metadata JSON.""" import json metadata_str = "invalid json {" try: metadata = json.loads(metadata_str) except json.JSONDecodeError: metadata = {} assert metadata == {} def test_report_generation_storage_abstraction(self): """Report generation uses storage abstraction.""" # Simulate storage abstraction mock_storage = Mock() mock_storage.save_report.return_value = True success = mock_storage.save_report( research_id=123, content="# Report", metadata={}, username="testuser", ) assert success mock_storage.save_report.assert_called_once() def test_report_generation_file_write_error(self): """Report generation handles file write errors.""" mock_storage = Mock() mock_storage.save_report.side_effect = IOError("Disk full") with pytest.raises(IOError) as exc_info: mock_storage.save_report( research_id=123, content="# Report", metadata={}, username="testuser", ) assert "Disk full" in str(exc_info.value) def test_report_generation_path_creation(self): """Report generation creates output paths.""" from pathlib import Path output_dir = Path("/tmp/test_research") research_dir = output_dir / "research_123" # Path should be constructable assert str(research_dir) == "/tmp/test_research/research_123" def test_report_generation_existing_file_overwrite(self): """Report generation overwrites existing files.""" # New content should replace old content old_content = "# Old Report" new_content = "# New Report" # Simulating overwrite content = new_content assert content == "# New Report" assert content != old_content def test_report_generation_unicode_content_handling(self): """Report generation handles Unicode content.""" content = ( "# Report with Unicode\n\nTest: ζ—₯本θͺž, Γ©mojis πŸŽ‰, symbols βˆ‘βˆβˆ«" ) # Content should be preserved assert "ζ—₯本θͺž" in content assert "πŸŽ‰" in content assert "βˆ‘" in content class TestNewsSearchIntegration: """Tests for news search integration in research.""" def test_news_search_headline_generation(self): """News search generates headlines.""" # Mock headline generation headline = "AI Breakthroughs Reshape Technology Landscape" assert headline assert len(headline) < 100 def test_news_search_topic_extraction(self): """News search extracts topics.""" topics = ["Climate Policy", "Renewable Energy", "Global Warming"] assert len(topics) > 0 assert all(isinstance(t, str) for t in topics) def test_news_search_subscription_updates(self): """News search updates subscription status.""" subscription_id = "sub_123" metadata = {"subscription_id": subscription_id} # Should have subscription_id in metadata assert "subscription_id" in metadata assert metadata["subscription_id"] == "sub_123" def test_news_search_empty_results_handling(self): """News search handles empty results.""" results = { "findings": [], "formatted_findings": "", "iterations": 0, } # Empty results should be detected has_results = bool(results.get("findings")) assert not has_results def test_news_search_llm_failure_graceful_degradation(self): """News search degrades gracefully on LLM failure.""" # When LLM fails, headline generation should be skipped try: raise Exception("LLM unavailable") except Exception: headline = None # Headline should be None, not crash assert headline is None def test_news_search_rate_limiting_integration(self): """News search respects rate limits.""" rate_limits = { "google_news": {"max_requests": 10, "period": 60}, } # Rate limit config should be accessible assert "google_news" in rate_limits assert rate_limits["google_news"]["max_requests"] == 10 def test_news_search_cache_integration(self): """News search uses cache.""" cached_results = { "query": "test news", "results": ["result1", "result2"], "cached_at": "2024-01-01T00:00:00Z", } # Cache should have expected structure assert "results" in cached_results assert "cached_at" in cached_results def test_news_search_metadata_storage(self): """News search stores metadata correctly.""" metadata = { "is_news_search": True, "search_type": "news_analysis", "category": "Technology", } assert metadata.get("is_news_search") is True assert metadata.get("search_type") == "news_analysis" class TestCitationFormatting: """Tests for citation formatting in reports.""" @patch("local_deep_research.config.search_config.get_setting_from_snapshot") def test_citation_formatter_domain_id_hyperlinks(self, mock_get_setting): """Citation formatter handles domain_id_hyperlinks mode.""" from local_deep_research.web.services.research_service import ( get_citation_formatter, ) from local_deep_research.text_optimization import CitationMode mock_get_setting.return_value = "domain_id_hyperlinks" formatter = get_citation_formatter() assert formatter.mode == CitationMode.DOMAIN_ID_HYPERLINKS @patch("local_deep_research.config.search_config.get_setting_from_snapshot") def test_citation_formatter_domain_id_always_hyperlinks( self, mock_get_setting ): """Citation formatter handles domain_id_always_hyperlinks mode.""" from local_deep_research.web.services.research_service import ( get_citation_formatter, ) from local_deep_research.text_optimization import CitationMode mock_get_setting.return_value = "domain_id_always_hyperlinks" formatter = get_citation_formatter() assert formatter.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS class TestSourceExtraction: """Tests for source extraction from search results.""" def test_source_extraction_from_findings(self): """Sources are extracted from findings correctly.""" from local_deep_research.utilities.search_utilities import ( extract_links_from_search_results, ) search_results = [ {"link": "https://example.com/1", "title": "Result 1"}, {"link": "https://example.com/2", "title": "Result 2"}, ] links = extract_links_from_search_results(search_results) assert len(links) == 2 def test_source_extraction_empty_results(self): """Source extraction handles empty results.""" from local_deep_research.utilities.search_utilities import ( extract_links_from_search_results, ) search_results = [] links = extract_links_from_search_results(search_results) assert links == [] def test_source_extraction_duplicate_links(self): """Source extraction handles duplicate links.""" search_results = [ {"link": "https://example.com", "title": "Result 1"}, {"link": "https://example.com", "title": "Result 2"}, ] # Extract unique links links = list( set(r.get("link") for r in search_results if r.get("link")) ) assert len(links) == 1 class TestExtractSynthesizedAnswer: """Pin the contract for _extract_synthesized_answer. Regression: quick-mode research used to save the full format_findings blob (synthesized + sources + iteration questions + detailed findings + ALL SOURCES) into research.report_content because the quick-mode save site assigned ``clean_markdown = raw_formatted_findings``. format_document_split only knows ## Sources headers, so the blob's ## ALL SOURCES / ## DETAILED FINDINGS sections survived into the chat view (visible in UI test screenshots after page reload). """ def test_prefers_final_synthesis_finding(self): from local_deep_research.web.services.research_service import ( _extract_synthesized_answer, ) results = { "findings": [ {"phase": "search", "content": "raw search hits"}, {"phase": "Final synthesis", "content": "the answer"}, ], "current_knowledge": "wider context", } assert _extract_synthesized_answer(results) == "the answer" def test_falls_back_to_current_knowledge(self): from local_deep_research.web.services.research_service import ( _extract_synthesized_answer, ) # No Final synthesis finding (e.g. standard_strategy returns # current_knowledge as the synthesized content) results = { "findings": [{"phase": "search", "content": "raw"}], "current_knowledge": "the answer", } assert _extract_synthesized_answer(results) == "the answer" def test_returns_empty_when_nothing_available(self): from local_deep_research.web.services.research_service import ( _extract_synthesized_answer, ) assert _extract_synthesized_answer({}) == "" assert _extract_synthesized_answer({"findings": []}) == "" assert ( _extract_synthesized_answer( {"findings": [{"phase": "Final synthesis", "content": ""}]} ) == "" ) def test_does_not_return_format_findings_blob(self): """The whole point: never return the format_findings blob.""" from local_deep_research.web.services.research_service import ( _extract_synthesized_answer, ) # Simulate the kind of blob format_findings produces β€” a # synthesized answer followed by [N] (source nr: N) URL lines # and the ## ALL SOURCES section. The function should ignore # this if a clean Final synthesis content is also present. formatted_blob = ( "Photosynthesis is the process by which plants...\n\n" "[1] A History of Plant Sci [Q1 *****]\n" " URL: https://example.com/1\n\n" "## ALL SOURCES:\n" "[1] A History of Plant Sci\n" " URL: https://example.com/1\n\n" ) results = { "findings": [ { "phase": "Final synthesis", "content": ( "Photosynthesis is the process by which plants..." ), } ], "formatted_findings": formatted_blob, "current_knowledge": ( "Photosynthesis is the process by which plants..." ), } out = _extract_synthesized_answer(results) assert "## ALL SOURCES" not in out assert "URL: https://example.com" not in out assert "[1] A History of Plant Sci" not in out assert out == "Photosynthesis is the process by which plants..."