Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

754 lines
26 KiB
Python

"""
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..."