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
1595 lines
52 KiB
Python
1595 lines
52 KiB
Python
"""Tests for report generator progress tracking, cancellation, and iteration override.
|
|
|
|
Covers three fixes to IntegratedReportGenerator:
|
|
1. Iteration override fix — settings_snapshot is now modified instead of the
|
|
dead-code search_system.max_iterations attribute.
|
|
2. Progress callbacks — subsection-level progress reported during report generation.
|
|
3. Cancellation — progress_callback fires before iteration override so termination
|
|
propagates cleanly without corrupting settings.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.report_generator import IntegratedReportGenerator
|
|
from local_deep_research.web.services.research_service import (
|
|
_DETAILED_REPORT_PROGRESS_END,
|
|
_DETAILED_REPORT_PROGRESS_START,
|
|
)
|
|
|
|
|
|
# ── Fixtures ──
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_search_system():
|
|
"""Create a mock search system with a strategy that has settings_snapshot."""
|
|
system = MagicMock()
|
|
system.strategy = MagicMock()
|
|
system.strategy.settings_snapshot = {"search.iterations": 3}
|
|
system.strategy.max_iterations = 3
|
|
system.all_links_of_system = []
|
|
system.analyze_topic.return_value = {
|
|
"current_knowledge": "Test research content"
|
|
}
|
|
return system
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_model():
|
|
"""Create a mock LLM model."""
|
|
model = MagicMock()
|
|
model.invoke.return_value = MagicMock(
|
|
content=(
|
|
"STRUCTURE\n"
|
|
"1. Introduction\n"
|
|
" - Overview | Provide an overview\n"
|
|
" - Background | Historical context\n"
|
|
"2. Analysis\n"
|
|
" - Data | Present the data\n"
|
|
"END_STRUCTURE"
|
|
)
|
|
)
|
|
return model
|
|
|
|
|
|
@pytest.fixture
|
|
def generator(mock_search_system, mock_model):
|
|
"""Create an IntegratedReportGenerator with mocked dependencies."""
|
|
gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator)
|
|
gen.search_system = mock_search_system
|
|
gen.model = mock_model
|
|
gen.searches_per_section = 2
|
|
gen.max_context_sections = 3
|
|
gen.max_context_chars = 4000
|
|
return gen
|
|
|
|
|
|
@pytest.fixture
|
|
def simple_structure():
|
|
"""A simple report structure with known subsection counts."""
|
|
return [
|
|
{
|
|
"name": "Introduction",
|
|
"subsections": [
|
|
{"name": "Overview", "purpose": "Provide an overview"},
|
|
{"name": "Background", "purpose": "Historical context"},
|
|
],
|
|
},
|
|
{
|
|
"name": "Analysis",
|
|
"subsections": [
|
|
{"name": "Data", "purpose": "Present the data"},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def initial_findings():
|
|
"""Standard initial findings dict."""
|
|
return {"current_knowledge": "Initial research findings about the topic."}
|
|
|
|
|
|
# ── Progress callback tests ──
|
|
|
|
|
|
class TestProgressCallbackInGenerateReport:
|
|
"""Tests for progress_callback parameter in generate_report()."""
|
|
|
|
def test_generate_report_without_callback_works(
|
|
self, generator, initial_findings
|
|
):
|
|
"""generate_report() still works when no progress_callback is passed."""
|
|
result = generator.generate_report(initial_findings, "test query")
|
|
assert "content" in result
|
|
assert "metadata" in result
|
|
|
|
def test_generate_report_calls_callback_for_all_phases(
|
|
self, generator, initial_findings
|
|
):
|
|
"""progress_callback is called for structure, section research, formatting, and completion."""
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
initial_findings, "test query", progress_callback=callback
|
|
)
|
|
|
|
# Extract all phases reported
|
|
phases = [c.args[2]["phase"] for c in callback.call_args_list]
|
|
assert "report_structure" in phases
|
|
assert "report_section_research" in phases
|
|
assert "report_formatting" in phases
|
|
assert "report_complete" in phases
|
|
|
|
def test_progress_starts_at_zero_and_ends_at_100(
|
|
self, generator, initial_findings
|
|
):
|
|
"""First callback is 0%, last is 100%."""
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
initial_findings, "test query", progress_callback=callback
|
|
)
|
|
|
|
first_pct = callback.call_args_list[0].args[1]
|
|
last_pct = callback.call_args_list[-1].args[1]
|
|
assert first_pct == 0
|
|
assert last_pct == 100
|
|
|
|
def test_progress_is_monotonically_nondecreasing(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Progress percentages never decrease."""
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
initial_findings, "test query", progress_callback=callback
|
|
)
|
|
|
|
percentages = [c.args[1] for c in callback.call_args_list]
|
|
for i in range(1, len(percentages)):
|
|
assert percentages[i] >= percentages[i - 1], (
|
|
f"Progress went backwards: {percentages[i - 1]} -> {percentages[i]}"
|
|
)
|
|
|
|
|
|
class TestProgressCallbackInSectionResearch:
|
|
"""Tests for subsection-level progress tracking."""
|
|
|
|
def test_progress_reported_for_each_subsection(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Each subsection triggers a progress callback."""
|
|
callback = MagicMock()
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
# 3 subsections total (2 in Introduction + 1 in Analysis)
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
assert len(section_calls) == 3
|
|
|
|
def test_progress_message_includes_section_and_subsection_names(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Progress messages reference section > subsection."""
|
|
callback = MagicMock()
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
messages = [c.args[0] for c in callback.call_args_list]
|
|
assert any("Introduction" in m and "Overview" in m for m in messages)
|
|
assert any("Introduction" in m and "Background" in m for m in messages)
|
|
assert any("Analysis" in m and "Data" in m for m in messages)
|
|
|
|
def test_progress_percentage_scales_with_subsections(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Progress ranges from 10% to 90% across subsections."""
|
|
callback = MagicMock()
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
percentages = [c.args[1] for c in section_calls]
|
|
|
|
# First subsection: 10 + (0/3)*80 = 10
|
|
assert percentages[0] == 10
|
|
# Second subsection: 10 + (1/3)*80 ≈ 36
|
|
assert percentages[1] == 36
|
|
# Third subsection: 10 + (2/3)*80 ≈ 63
|
|
assert percentages[2] == 63
|
|
|
|
def test_empty_subsections_section_gets_one_progress_call(
|
|
self, generator, initial_findings
|
|
):
|
|
"""A section with no subsections auto-creates one and gets one progress call."""
|
|
structure = [{"name": "Solo Section", "subsections": []}]
|
|
callback = MagicMock()
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
assert len(section_calls) == 1
|
|
|
|
def test_no_callback_still_works(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""_research_and_generate_sections works without progress_callback."""
|
|
result = generator._research_and_generate_sections(
|
|
initial_findings, simple_structure, "test query"
|
|
)
|
|
assert "Introduction" in result
|
|
assert "Analysis" in result
|
|
|
|
|
|
# ── Cancellation tests ──
|
|
|
|
|
|
class TestCancellation:
|
|
"""Tests for cancellation via progress_callback exception."""
|
|
|
|
def test_cancellation_stops_processing(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""When progress_callback raises, processing stops immediately."""
|
|
call_count = 0
|
|
|
|
def cancelling_callback(message, pct, metadata):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 2: # Cancel on second subsection
|
|
raise Exception("Research was terminated by user")
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=cancelling_callback,
|
|
)
|
|
|
|
# Only 1 subsection should have been fully searched
|
|
# (first callback fires, search happens, second callback fires and raises)
|
|
assert generator.search_system.analyze_topic.call_count == 1
|
|
|
|
def test_cancelled_before_iteration_override(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Cancellation fires before iteration override so settings_snapshot is never modified."""
|
|
original_value = generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
|
|
def cancel_immediately(message, pct, metadata):
|
|
raise Exception("Research was terminated by user")
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=cancel_immediately,
|
|
)
|
|
|
|
# Settings should be untouched since cancellation was before override
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== original_value
|
|
)
|
|
|
|
def test_terminated_propagates_and_iterations_not_corrupted(
|
|
self, generator, initial_findings
|
|
):
|
|
"""If analyze_topic raises after override, settings are still restored."""
|
|
generator.search_system.analyze_topic.side_effect = Exception(
|
|
"Research was terminated by user"
|
|
)
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Test purpose"}],
|
|
}
|
|
]
|
|
|
|
callback = MagicMock() # Non-cancelling callback
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
# Despite the error, iterations should be restored
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
|
|
|
|
# ── Iteration override tests ──
|
|
|
|
|
|
class TestIterationOverride:
|
|
"""Tests for the iteration override fix."""
|
|
|
|
def test_settings_snapshot_set_to_1_during_search(
|
|
self, generator, initial_findings
|
|
):
|
|
"""During analyze_topic, strategy.settings_snapshot['search.iterations'] is 1."""
|
|
captured_values = []
|
|
|
|
def capture_iterations(query):
|
|
captured_values.append(
|
|
generator.search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_iterations
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert captured_values == [1]
|
|
|
|
def test_settings_snapshot_restored_after_search(
|
|
self, generator, initial_findings
|
|
):
|
|
"""After search completes, settings_snapshot is restored to original value."""
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
|
|
def test_settings_snapshot_restored_after_error(
|
|
self, generator, initial_findings
|
|
):
|
|
"""After search error, settings_snapshot is still restored."""
|
|
generator.search_system.analyze_topic.side_effect = RuntimeError(
|
|
"Search failed"
|
|
)
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
with pytest.raises(RuntimeError, match="Search failed"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
|
|
def test_absent_key_removed_after_search(self, generator, initial_findings):
|
|
"""If search.iterations wasn't in snapshot before, it's removed after."""
|
|
del generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert (
|
|
"search.iterations"
|
|
not in generator.search_system.strategy.settings_snapshot
|
|
)
|
|
|
|
def test_absent_key_removed_after_error(self, generator, initial_findings):
|
|
"""If search.iterations wasn't in snapshot, it's removed even after error."""
|
|
del generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
generator.search_system.analyze_topic.side_effect = RuntimeError("fail")
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
with pytest.raises(RuntimeError):
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert (
|
|
"search.iterations"
|
|
not in generator.search_system.strategy.settings_snapshot
|
|
)
|
|
|
|
def test_max_iterations_belt_and_suspenders(
|
|
self, generator, initial_findings
|
|
):
|
|
"""strategy.max_iterations is also set to 1 during search."""
|
|
captured_values = []
|
|
|
|
def capture_max_iter(query):
|
|
captured_values.append(
|
|
generator.search_system.strategy.max_iterations
|
|
)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_max_iter
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert captured_values == [1]
|
|
# Restored after
|
|
assert generator.search_system.strategy.max_iterations == 3
|
|
|
|
def test_max_iterations_restored_after_error(
|
|
self, generator, initial_findings
|
|
):
|
|
"""strategy.max_iterations is restored even after error."""
|
|
generator.search_system.analyze_topic.side_effect = RuntimeError("fail")
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
with pytest.raises(RuntimeError):
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert generator.search_system.strategy.max_iterations == 3
|
|
|
|
def test_multiple_subsections_each_override_and_restore(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Each subsection independently overrides and restores iterations."""
|
|
captured_values = []
|
|
|
|
def capture_iterations(query):
|
|
captured_values.append(
|
|
generator.search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_iterations
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, simple_structure, "test query"
|
|
)
|
|
|
|
# Each search should see iterations=1
|
|
assert captured_values == [1, 1, 1]
|
|
# After all, should be restored
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
|
|
|
|
# ── Completed subsections counter tests ──
|
|
|
|
|
|
class TestCompletedSubsectionsCounter:
|
|
"""Tests that completed_subsections only increments after success."""
|
|
|
|
def test_counter_not_incremented_on_error(
|
|
self, generator, initial_findings
|
|
):
|
|
"""completed_subsections doesn't increment when analyze_topic raises."""
|
|
call_count = 0
|
|
|
|
def fail_on_first(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise RuntimeError("First subsection failed")
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = fail_on_first
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [
|
|
{"name": "Sub1", "purpose": "P1"},
|
|
{"name": "Sub2", "purpose": "P2"},
|
|
],
|
|
}
|
|
]
|
|
|
|
progress_pcts = []
|
|
callback = MagicMock(
|
|
side_effect=lambda msg, pct, meta: progress_pcts.append(pct)
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="First subsection failed"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
# Only 1 progress call happened (for the first subsection, before it failed)
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
assert len(section_calls) == 1
|
|
|
|
|
|
# ── Report progress callback wrapper tests ──
|
|
|
|
|
|
class TestReportProgressCallbackWrapper:
|
|
"""Tests for the report_progress_callback wrapper in research_service."""
|
|
|
|
def test_maps_0_to_range_start(self):
|
|
"""0% internal maps to start of report range (10%)."""
|
|
outer_callback = MagicMock()
|
|
start = _DETAILED_REPORT_PROGRESS_START
|
|
end = _DETAILED_REPORT_PROGRESS_END
|
|
report_range = end - start
|
|
|
|
# Simulate the wrapper logic from research_service.py
|
|
def report_progress_callback(message, progress_percent, metadata):
|
|
if progress_percent is not None:
|
|
adjusted = start + (progress_percent / 100) * report_range
|
|
else:
|
|
adjusted = progress_percent
|
|
outer_callback(message, adjusted, metadata)
|
|
|
|
report_progress_callback("test", 0, {"phase": "report_structure"})
|
|
outer_callback.assert_called_with(
|
|
"test", 10.0, {"phase": "report_structure"}
|
|
)
|
|
|
|
def test_maps_50_to_range_midpoint(self):
|
|
"""50% internal maps to midpoint of report range (55%)."""
|
|
outer_callback = MagicMock()
|
|
start = _DETAILED_REPORT_PROGRESS_START
|
|
end = _DETAILED_REPORT_PROGRESS_END
|
|
report_range = end - start
|
|
|
|
def report_progress_callback(message, progress_percent, metadata):
|
|
if progress_percent is not None:
|
|
adjusted = start + (progress_percent / 100) * report_range
|
|
else:
|
|
adjusted = progress_percent
|
|
outer_callback(message, adjusted, metadata)
|
|
|
|
report_progress_callback(
|
|
"test", 50, {"phase": "report_section_research"}
|
|
)
|
|
outer_callback.assert_called_with(
|
|
"test", 55.0, {"phase": "report_section_research"}
|
|
)
|
|
|
|
def test_maps_100_to_range_end(self):
|
|
"""100% internal maps to end of report range (100%)."""
|
|
outer_callback = MagicMock()
|
|
start = _DETAILED_REPORT_PROGRESS_START
|
|
end = _DETAILED_REPORT_PROGRESS_END
|
|
report_range = end - start
|
|
|
|
def report_progress_callback(message, progress_percent, metadata):
|
|
if progress_percent is not None:
|
|
adjusted = start + (progress_percent / 100) * report_range
|
|
else:
|
|
adjusted = progress_percent
|
|
outer_callback(message, adjusted, metadata)
|
|
|
|
report_progress_callback("test", 100, {"phase": "report_complete"})
|
|
outer_callback.assert_called_with(
|
|
"test", 100.0, {"phase": "report_complete"}
|
|
)
|
|
|
|
def test_none_progress_passed_through(self):
|
|
"""None progress is passed through unchanged."""
|
|
outer_callback = MagicMock()
|
|
start = _DETAILED_REPORT_PROGRESS_START
|
|
end = _DETAILED_REPORT_PROGRESS_END
|
|
report_range = end - start
|
|
|
|
def report_progress_callback(message, progress_percent, metadata):
|
|
if progress_percent is not None:
|
|
adjusted = start + (progress_percent / 100) * report_range
|
|
else:
|
|
adjusted = progress_percent
|
|
outer_callback(message, adjusted, metadata)
|
|
|
|
report_progress_callback("test", None, {"phase": "error"})
|
|
outer_callback.assert_called_with("test", None, {"phase": "error"})
|
|
|
|
def test_no_double_mapping_with_outer_phases(self):
|
|
"""Report phases don't match outer callback's report_generation check."""
|
|
# The phases used by report generator (report_structure,
|
|
# report_section_research, report_formatting) must NOT match
|
|
# the outer callback's "report_generation" phase check to avoid
|
|
# double-mapping.
|
|
report_phases = [
|
|
"report_structure",
|
|
"report_section_research",
|
|
"report_formatting",
|
|
"report_complete",
|
|
]
|
|
for phase in report_phases:
|
|
assert phase != "report_generation"
|
|
|
|
|
|
# ── Empty subsections handling ──
|
|
|
|
|
|
class TestEmptySubsectionsHandling:
|
|
"""Tests for consistent handling of sections with no subsections."""
|
|
|
|
def test_empty_subsections_auto_created(self, generator, initial_findings):
|
|
"""Sections with empty subsections get one auto-created subsection."""
|
|
structure = [{"name": "Empty Section", "subsections": []}]
|
|
|
|
result = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Should have generated content for the auto-created subsection
|
|
assert "Empty Section" in result
|
|
assert generator.search_system.analyze_topic.call_count == 1
|
|
|
|
def test_section_with_pipe_in_name_parsed_correctly(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Sections with '|' in name get purpose parsed from the name."""
|
|
structure = [{"name": "My Section | Custom purpose", "subsections": []}]
|
|
|
|
result = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert "My Section | Custom purpose" in result
|
|
|
|
def test_progress_count_consistent_for_empty_subsections(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Progress counting uses max(len(subsections), 1) for empty sections."""
|
|
structure = [
|
|
{"name": "Section A", "subsections": []},
|
|
{
|
|
"name": "Section B",
|
|
"subsections": [
|
|
{"name": "Sub1", "purpose": "P1"},
|
|
{"name": "Sub2", "purpose": "P2"},
|
|
],
|
|
},
|
|
]
|
|
|
|
callback = MagicMock()
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
# Total: 1 (auto-created) + 2 = 3 subsections
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
assert len(section_calls) == 3
|
|
|
|
|
|
# ── Integration test ──
|
|
|
|
|
|
class TestEndToEndProgress:
|
|
"""Integration tests for the full generate_report flow with progress."""
|
|
|
|
def test_full_report_generation_with_progress(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Full generate_report flow calls progress for all phases in order."""
|
|
phases_seen = []
|
|
|
|
def tracking_callback(message, pct, metadata):
|
|
phases_seen.append(metadata.get("phase"))
|
|
|
|
generator.generate_report(
|
|
initial_findings,
|
|
"test query",
|
|
progress_callback=tracking_callback,
|
|
)
|
|
|
|
# Verify phase ordering
|
|
assert phases_seen[0] == "report_structure"
|
|
assert phases_seen[-2] == "report_formatting"
|
|
assert phases_seen[-1] == "report_complete"
|
|
|
|
# Section research phases should be in the middle
|
|
research_phases = [
|
|
p for p in phases_seen if p == "report_section_research"
|
|
]
|
|
assert len(research_phases) >= 1
|
|
|
|
def test_full_report_cancellation_at_structure_phase(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Cancellation during structure determination propagates cleanly."""
|
|
|
|
def cancel_at_structure(message, pct, metadata):
|
|
if metadata.get("phase") == "report_structure":
|
|
raise Exception("Research was terminated by user")
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator.generate_report(
|
|
initial_findings,
|
|
"test query",
|
|
progress_callback=cancel_at_structure,
|
|
)
|
|
|
|
def test_full_report_cancellation_during_section_research(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Cancellation during section research propagates cleanly."""
|
|
call_count = 0
|
|
|
|
def cancel_after_first_section(message, pct, metadata):
|
|
nonlocal call_count
|
|
if metadata.get("phase") == "report_section_research":
|
|
call_count += 1
|
|
if call_count >= 2:
|
|
raise Exception("Research was terminated by user")
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator.generate_report(
|
|
initial_findings,
|
|
"test query",
|
|
progress_callback=cancel_after_first_section,
|
|
)
|
|
|
|
# Settings should be restored after cancellation
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
|
|
|
|
# ── Content accumulation tests ──
|
|
|
|
|
|
class TestContentAccumulation:
|
|
"""Tests for context passing between subsections to avoid repetition."""
|
|
|
|
def test_later_subsections_receive_earlier_content_in_query(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""Queries for later subsections include content from earlier ones."""
|
|
captured_queries = []
|
|
|
|
def capture_query(query):
|
|
captured_queries.append(query)
|
|
return {
|
|
"current_knowledge": f"Content for call {len(captured_queries)}"
|
|
}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_query
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, simple_structure, "test query"
|
|
)
|
|
|
|
# First subsection query should NOT contain previous content marker
|
|
assert "CONTENT ALREADY WRITTEN" not in captured_queries[0]
|
|
|
|
# Second subsection (same section) should contain first subsection's content
|
|
assert "CONTENT ALREADY WRITTEN" in captured_queries[1]
|
|
assert "Content for call 1" in captured_queries[1]
|
|
|
|
# Third subsection (different section) should contain earlier content
|
|
assert "CONTENT ALREADY WRITTEN" in captured_queries[2]
|
|
|
|
def test_accumulated_findings_formatted_with_section_labels(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Accumulated findings include [Section > Subsection] labels."""
|
|
captured_queries = []
|
|
|
|
def capture_query(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "Some content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_query
|
|
|
|
structure = [
|
|
{
|
|
"name": "First Section",
|
|
"subsections": [
|
|
{"name": "Sub A", "purpose": "Purpose A"},
|
|
{"name": "Sub B", "purpose": "Purpose B"},
|
|
],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Second query should reference the first subsection's label
|
|
assert "[First Section > Sub A]" in captured_queries[1]
|
|
|
|
def test_empty_content_not_accumulated(self, generator, initial_findings):
|
|
"""Subsections with empty content are not added to accumulated_findings."""
|
|
call_count = 0
|
|
|
|
def alternating_content(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return {"current_knowledge": ""} # Empty
|
|
return {"current_knowledge": "Real content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = alternating_content
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [
|
|
{"name": "Empty Sub", "purpose": "P1"},
|
|
{"name": "Real Sub", "purpose": "P2"},
|
|
],
|
|
}
|
|
]
|
|
|
|
captured_queries = []
|
|
original_side_effect = generator.search_system.analyze_topic.side_effect
|
|
|
|
def capture_and_call(query):
|
|
captured_queries.append(query)
|
|
return original_side_effect(query)
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_and_call
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Second query should NOT contain previous context since first was empty
|
|
assert "CONTENT ALREADY WRITTEN" not in captured_queries[1]
|
|
|
|
def test_context_limited_to_max_context_sections(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Only the last max_context_sections are included in context."""
|
|
generator.max_context_sections = 2
|
|
captured_queries = []
|
|
|
|
def capture_query(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": f"Content #{len(captured_queries)}"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture_query
|
|
|
|
structure = [
|
|
{
|
|
"name": f"Section {i}",
|
|
"subsections": [{"name": f"Sub {i}", "purpose": f"P{i}"}],
|
|
}
|
|
for i in range(4)
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Fourth query (index 3) should contain sections 2 and 3, not section 1
|
|
# (max_context_sections=2, so only last 2 are included)
|
|
last_query = captured_queries[3]
|
|
assert "Content #3" in last_query # Most recent
|
|
assert "Content #2" in last_query # Second most recent
|
|
assert "Content #1" not in last_query # Dropped due to limit
|
|
|
|
|
|
# ── Query construction tests ──
|
|
|
|
|
|
class TestQueryConstruction:
|
|
"""Tests for verifying queries passed to analyze_topic."""
|
|
|
|
def test_subsection_query_contains_purpose(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Query includes the subsection's purpose."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [
|
|
{"name": "Details", "purpose": "Explain the mechanism"}
|
|
],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert "Explain the mechanism" in captured_queries[0]
|
|
|
|
def test_subsection_query_contains_original_query(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Query includes the user's original research query."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "my specific research topic"
|
|
)
|
|
|
|
assert "my specific research topic" in captured_queries[0]
|
|
|
|
def test_subsection_query_lists_other_sections(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Query includes names of other sections for context."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Introduction",
|
|
"subsections": [{"name": "Overview", "purpose": "P1"}],
|
|
},
|
|
{
|
|
"name": "Methodology",
|
|
"subsections": [{"name": "Approach", "purpose": "P2"}],
|
|
},
|
|
{
|
|
"name": "Results",
|
|
"subsections": [{"name": "Findings", "purpose": "P3"}],
|
|
},
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# First section's query should mention the other sections
|
|
assert "Methodology" in captured_queries[0]
|
|
assert "Results" in captured_queries[0]
|
|
|
|
# Second section's query should mention its siblings
|
|
assert "Introduction" in captured_queries[1]
|
|
assert "Results" in captured_queries[1]
|
|
|
|
def test_multi_subsection_query_lists_sibling_subsections(
|
|
self, generator, initial_findings
|
|
):
|
|
"""When a section has multiple subsections, each query lists its siblings."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Analysis",
|
|
"subsections": [
|
|
{"name": "Quantitative", "purpose": "Numbers and stats"},
|
|
{"name": "Qualitative", "purpose": "Themes and patterns"},
|
|
{"name": "Mixed", "purpose": "Combined methods"},
|
|
],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# First subsection query should reference the other two
|
|
assert "Qualitative" in captured_queries[0]
|
|
assert "Mixed" in captured_queries[0]
|
|
|
|
# Second subsection should reference its siblings
|
|
assert "Quantitative" in captured_queries[1]
|
|
assert "Mixed" in captured_queries[1]
|
|
|
|
def test_single_subsection_uses_section_level_prompt(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Single-subsection sections use the standalone section prompt."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Conclusion",
|
|
"subsections": [
|
|
{"name": "Conclusion", "purpose": "Summarize findings"}
|
|
],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Single-subsection uses "standalone section" language
|
|
assert "standalone section" in captured_queries[0].lower()
|
|
|
|
def test_multi_subsection_uses_focused_prompt(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Multi-subsection sections use the focused subsection prompt."""
|
|
captured_queries = []
|
|
|
|
def capture(query):
|
|
captured_queries.append(query)
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = capture
|
|
|
|
structure = [
|
|
{
|
|
"name": "Analysis",
|
|
"subsections": [
|
|
{"name": "Sub1", "purpose": "P1"},
|
|
{"name": "Sub2", "purpose": "P2"},
|
|
],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
# Multi-subsection uses "Focus ONLY" language
|
|
assert "Focus ONLY" in captured_queries[0]
|
|
|
|
|
|
# ── Section content assembly tests ──
|
|
|
|
|
|
class TestSectionContentAssembly:
|
|
"""Tests for how subsection content is assembled into section output."""
|
|
|
|
def test_multi_subsection_section_includes_subsection_headers(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Sections with multiple subsections get ## headers for each."""
|
|
generator.search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "Content here"
|
|
}
|
|
|
|
structure = [
|
|
{
|
|
"name": "Analysis",
|
|
"subsections": [
|
|
{"name": "Data Review", "purpose": "Review the data"},
|
|
{"name": "Interpretation", "purpose": "Interpret results"},
|
|
],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
content = sections["Analysis"]
|
|
assert "## 1.1 Data Review" in content
|
|
assert "## 1.2 Interpretation" in content
|
|
|
|
def test_single_subsection_section_omits_subsection_header(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Sections with one subsection do NOT get a ## header."""
|
|
generator.search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "Content here"
|
|
}
|
|
|
|
structure = [
|
|
{
|
|
"name": "Conclusion",
|
|
"subsections": [
|
|
{"name": "Summary", "purpose": "Summarize findings"}
|
|
],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
content = sections["Conclusion"]
|
|
assert "## Summary" not in content
|
|
|
|
def test_section_starts_with_section_heading(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Each section's content starts with a # heading."""
|
|
generator.search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "Content"
|
|
}
|
|
|
|
structure = [
|
|
{
|
|
"name": "My Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert sections["My Section"].startswith("# 1. My Section")
|
|
|
|
def test_subsection_content_from_analyze_topic_included(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Content returned by analyze_topic appears in the section output."""
|
|
generator.search_system.analyze_topic.return_value = {
|
|
"current_knowledge": "Specific content about the mechanism of action"
|
|
}
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
assert (
|
|
"Specific content about the mechanism of action"
|
|
in sections["Section"]
|
|
)
|
|
|
|
def test_multiple_subsections_content_combined(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Content from all subsections in a section is combined."""
|
|
call_count = 0
|
|
|
|
def unique_content(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return {"current_knowledge": f"UNIQUE_CONTENT_{call_count}"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = unique_content
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [
|
|
{"name": "Part A", "purpose": "First part"},
|
|
{"name": "Part B", "purpose": "Second part"},
|
|
],
|
|
}
|
|
]
|
|
|
|
sections = generator._research_and_generate_sections(
|
|
initial_findings, structure, "test query"
|
|
)
|
|
|
|
content = sections["Section"]
|
|
assert "UNIQUE_CONTENT_1" in content
|
|
assert "UNIQUE_CONTENT_2" in content
|
|
|
|
|
|
# ── Progress metadata completeness tests ──
|
|
|
|
|
|
class TestProgressMetadataCompleteness:
|
|
"""Tests for metadata fields in progress callbacks."""
|
|
|
|
def test_section_research_metadata_includes_subsection_name(
|
|
self, generator, simple_structure, initial_findings
|
|
):
|
|
"""report_section_research callbacks include subsection name in metadata."""
|
|
callback = MagicMock()
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
simple_structure,
|
|
"test query",
|
|
progress_callback=callback,
|
|
)
|
|
|
|
section_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_section_research"
|
|
]
|
|
|
|
subsection_names = [c.args[2]["subsection"] for c in section_calls]
|
|
assert "Overview" in subsection_names
|
|
assert "Background" in subsection_names
|
|
assert "Data" in subsection_names
|
|
|
|
def test_structure_phase_metadata_has_phase_key(
|
|
self, generator, initial_findings
|
|
):
|
|
"""report_structure phase callback has 'phase' in metadata."""
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
initial_findings, "test query", progress_callback=callback
|
|
)
|
|
|
|
first_call = callback.call_args_list[0]
|
|
assert "phase" in first_call.args[2]
|
|
assert first_call.args[2]["phase"] == "report_structure"
|
|
|
|
def test_formatting_phase_metadata_has_phase_key(
|
|
self, generator, initial_findings
|
|
):
|
|
"""report_formatting phase callback has 'phase' in metadata."""
|
|
callback = MagicMock()
|
|
generator.generate_report(
|
|
initial_findings, "test query", progress_callback=callback
|
|
)
|
|
|
|
formatting_calls = [
|
|
c
|
|
for c in callback.call_args_list
|
|
if c.args[2].get("phase") == "report_formatting"
|
|
]
|
|
assert len(formatting_calls) == 1
|
|
|
|
|
|
# ── Progress-before-override ordering test ──
|
|
|
|
|
|
class TestProgressBeforeOverrideOrdering:
|
|
"""Tests that progress callback fires BEFORE iteration override."""
|
|
|
|
def test_iterations_unchanged_when_progress_callback_fires(
|
|
self, generator, initial_findings
|
|
):
|
|
"""At the moment progress_callback fires, search.iterations is still original."""
|
|
iterations_at_callback_time = []
|
|
|
|
def check_iterations(message, pct, metadata):
|
|
if metadata.get("phase") == "report_section_research":
|
|
iterations_at_callback_time.append(
|
|
generator.search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
)
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=check_iterations,
|
|
)
|
|
|
|
# At callback time, iterations should still be original (3), not overridden (1)
|
|
assert iterations_at_callback_time == [3]
|
|
|
|
def test_iterations_overridden_only_during_analyze_topic(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Iteration override is active only during analyze_topic, not during callback."""
|
|
timeline = []
|
|
|
|
def tracking_callback(message, pct, metadata):
|
|
if metadata.get("phase") == "report_section_research":
|
|
val = generator.search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
timeline.append(("callback", val))
|
|
|
|
def tracking_analyze(query):
|
|
val = generator.search_system.strategy.settings_snapshot.get(
|
|
"search.iterations"
|
|
)
|
|
timeline.append(("analyze", val))
|
|
return {"current_knowledge": "content"}
|
|
|
|
generator.search_system.analyze_topic.side_effect = tracking_analyze
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section",
|
|
"subsections": [{"name": "Sub", "purpose": "Purpose"}],
|
|
}
|
|
]
|
|
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=tracking_callback,
|
|
)
|
|
|
|
# Timeline: callback fires with original (3), then analyze sees override (1)
|
|
assert timeline == [("callback", 3), ("analyze", 1)]
|
|
|
|
|
|
# ── Cancellation between sections ──
|
|
|
|
|
|
class TestCancellationBetweenSections:
|
|
"""Tests for cancellation that occurs between sections."""
|
|
|
|
def test_cancel_after_first_section_before_second(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Cancelling after all subsections of section 1 prevents section 2."""
|
|
sections_started = []
|
|
|
|
def cancel_on_second_section(message, pct, metadata):
|
|
if metadata.get("phase") == "report_section_research":
|
|
sub = metadata.get("subsection", "")
|
|
sections_started.append(sub)
|
|
# Cancel when we reach section 2's subsection
|
|
if sub == "Analysis Sub":
|
|
raise Exception("Research was terminated by user")
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section 1",
|
|
"subsections": [{"name": "S1 Sub", "purpose": "P1"}],
|
|
},
|
|
{
|
|
"name": "Section 2",
|
|
"subsections": [{"name": "Analysis Sub", "purpose": "P2"}],
|
|
},
|
|
]
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=cancel_on_second_section,
|
|
)
|
|
|
|
# Only section 1's subsection was fully searched
|
|
assert generator.search_system.analyze_topic.call_count == 1
|
|
|
|
def test_settings_restored_after_cancel_between_sections(
|
|
self, generator, initial_findings
|
|
):
|
|
"""Settings are clean after cancellation between sections."""
|
|
call_count = 0
|
|
|
|
def cancel_on_third(message, pct, metadata):
|
|
nonlocal call_count
|
|
if metadata.get("phase") == "report_section_research":
|
|
call_count += 1
|
|
if call_count == 3:
|
|
raise Exception("Research was terminated by user")
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section 1",
|
|
"subsections": [
|
|
{"name": "Sub1", "purpose": "P1"},
|
|
{"name": "Sub2", "purpose": "P2"},
|
|
],
|
|
},
|
|
{
|
|
"name": "Section 2",
|
|
"subsections": [{"name": "Sub3", "purpose": "P3"}],
|
|
},
|
|
]
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=cancel_on_third,
|
|
)
|
|
|
|
# Iterations restored: cancel fires before override for sub3
|
|
assert (
|
|
generator.search_system.strategy.settings_snapshot[
|
|
"search.iterations"
|
|
]
|
|
== 3
|
|
)
|
|
assert generator.search_system.strategy.max_iterations == 3
|
|
|
|
def test_partial_results_available_after_cancel(
|
|
self, generator, initial_findings
|
|
):
|
|
"""After cancellation, the exception propagates - no partial result returned."""
|
|
call_count = 0
|
|
|
|
def cancel_on_second_section(message, pct, metadata):
|
|
nonlocal call_count
|
|
if metadata.get("phase") == "report_section_research":
|
|
call_count += 1
|
|
if call_count == 2:
|
|
raise Exception("Research was terminated by user")
|
|
|
|
structure = [
|
|
{
|
|
"name": "Section 1",
|
|
"subsections": [{"name": "Sub1", "purpose": "P1"}],
|
|
},
|
|
{
|
|
"name": "Section 2",
|
|
"subsections": [{"name": "Sub2", "purpose": "P2"}],
|
|
},
|
|
]
|
|
|
|
with pytest.raises(Exception, match="terminated by user"):
|
|
generator._research_and_generate_sections(
|
|
initial_findings,
|
|
structure,
|
|
"test query",
|
|
progress_callback=cancel_on_second_section,
|
|
)
|
|
|
|
# The exception propagates — caller must handle it
|
|
# Verify section 1 was fully processed (1 analyze_topic call)
|
|
assert generator.search_system.analyze_topic.call_count == 1
|
|
|
|
|
|
# ── Research service wrapper integration ──
|
|
|
|
|
|
class TestResearchServiceWrapperIntegration:
|
|
"""Tests that verify the actual research_service.py wrapper code path."""
|
|
|
|
def test_wrapper_defined_in_run_research_process(self):
|
|
"""research_service defines report_progress_callback with correct formula."""
|
|
import inspect
|
|
|
|
from local_deep_research.web.services.research_service import (
|
|
run_research_process,
|
|
)
|
|
|
|
source = inspect.getsource(run_research_process)
|
|
|
|
# Verify the wrapper is defined and used
|
|
assert "report_progress_callback" in source
|
|
assert "progress_callback=report_progress_callback" in source
|
|
assert "_DETAILED_REPORT_PROGRESS_START" in source
|
|
|
|
def test_wrapper_handles_none_progress(self):
|
|
"""The wrapper code handles None progress_percent."""
|
|
import inspect
|
|
|
|
from local_deep_research.web.services.research_service import (
|
|
run_research_process,
|
|
)
|
|
|
|
source = inspect.getsource(run_research_process)
|
|
|
|
# Verify None handling exists
|
|
assert "if progress_percent is not None" in source
|