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
754 lines
26 KiB
Python
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..."
|