7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
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
|