371 lines
12 KiB
Python
371 lines
12 KiB
Python
"""Failure-ordering regression coverage for final Deep Research report writes."""
|
|
|
|
import pytest
|
|
|
|
from app.gateway import deep_research_job_executor as executor_module
|
|
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
|
|
from deerflow.agents.deep_research.events import DeepResearchEvent
|
|
from deerflow.agents.deep_research.types import DeepResearchResult
|
|
|
|
|
|
class _JobStore:
|
|
def __init__(self, session_store) -> None:
|
|
self._session_store = session_store
|
|
self.progress_calls: list[dict] = []
|
|
|
|
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
|
|
assert self._session_store.report_updates, "report must be persisted before job completion"
|
|
self.progress_calls.append(fields)
|
|
return {"id": job_id, **fields}
|
|
|
|
|
|
class _SessionStore:
|
|
def __init__(self, *, available: bool = True) -> None:
|
|
self.available = available
|
|
self.report_updates: list[dict] = []
|
|
|
|
async def update(self, session_id: str, **fields): # noqa: ARG002
|
|
if not self.available:
|
|
raise RuntimeError("temporary session database failure")
|
|
self.report_updates.append(fields)
|
|
return {"id": session_id, **fields}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_is_persisted_before_job_is_marked_completed():
|
|
sessions = _SessionStore()
|
|
jobs = _JobStore(sessions)
|
|
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
|
|
|
await runner._mark_completed(
|
|
"drj_1",
|
|
"drs_1",
|
|
"worker_1",
|
|
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
|
|
user_id="user_1",
|
|
query="课题",
|
|
)
|
|
|
|
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
|
|
assert jobs.progress_calls[0]["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
|
|
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
|
|
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
|
|
sessions = _SessionStore(available=False)
|
|
jobs = _JobStore(sessions)
|
|
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
|
|
|
with pytest.raises(RuntimeError, match="研究报告未能保存"):
|
|
await runner._mark_completed(
|
|
"drj_1",
|
|
"drs_1",
|
|
"worker_1",
|
|
DeepResearchResult(report_markdown="# 报告"),
|
|
user_id="user_1",
|
|
query="课题",
|
|
)
|
|
|
|
assert jobs.progress_calls == []
|
|
|
|
|
|
def test_terminal_timeline_events_are_valid_durable_event_types():
|
|
assert DeepResearchEvent(type="job_completed", phase="done").type == "job_completed"
|
|
assert DeepResearchEvent(type="job_failed", phase="done").type == "job_failed"
|
|
|
|
|
|
def test_zero_source_fallback_is_a_valid_completed_report():
|
|
from app.gateway.deep_research_job_executor import _terminal_report_validation_error
|
|
from deerflow.agents.deep_research.runners.basic import _build_report_fallback
|
|
|
|
report = _build_report_fallback("没有命中的课题", "", 0)
|
|
assert _terminal_report_validation_error(report) is None
|
|
assert "没有命中的课题" in report
|
|
|
|
|
|
def test_fixed_outline_fallback_retains_every_selected_heading():
|
|
from deerflow.agents.deep_research.runners.basic import (
|
|
_build_report_fallback,
|
|
_outline_adherence_error,
|
|
)
|
|
|
|
outline = """# 报告标题
|
|
|
|
# 一、现状判断
|
|
## 1.1 事实基线
|
|
## 1.2 核心矛盾
|
|
# 二、实施方案
|
|
## 2.1 重点任务
|
|
# 参考资料"""
|
|
report = _build_report_fallback(
|
|
"园区升级方案",
|
|
"已确认材料 A\n已确认材料 B",
|
|
2,
|
|
custom_outline=outline,
|
|
structure_mode="fixed",
|
|
)
|
|
|
|
assert "# 园区升级方案" in report
|
|
for heading in (
|
|
"# 一、现状判断",
|
|
"## 1.1 事实基线",
|
|
"## 1.2 核心矛盾",
|
|
"# 二、实施方案",
|
|
"## 2.1 重点任务",
|
|
"# 参考资料",
|
|
):
|
|
assert heading in report
|
|
assert (
|
|
_outline_adherence_error(
|
|
report,
|
|
outline,
|
|
structure_mode="fixed",
|
|
)
|
|
is None
|
|
)
|
|
assert "# 一、现状判断" in (
|
|
_outline_adherence_error(
|
|
report.replace("# 一、现状判断", "## 一、现状判断"),
|
|
outline,
|
|
structure_mode="fixed",
|
|
)
|
|
or ""
|
|
)
|
|
|
|
|
|
def test_adaptive_outline_fallback_keeps_core_chapters_without_forcing_subsections():
|
|
from deerflow.agents.deep_research.runners.basic import _build_report_fallback
|
|
|
|
outline = """# 报告标题
|
|
## 一、事实基线
|
|
### 1.1 可选细节
|
|
## 二、行动建议
|
|
### 2.1 可选措施"""
|
|
report = _build_report_fallback(
|
|
"产业研究",
|
|
"",
|
|
0,
|
|
custom_outline=outline,
|
|
structure_mode="adaptive",
|
|
)
|
|
|
|
assert "# 产业研究" in report
|
|
assert "## 一、事实基线" in report
|
|
assert "## 二、行动建议" in report
|
|
assert "### 1.1 可选细节" not in report
|
|
assert "### 2.1 可选措施" not in report
|
|
|
|
|
|
def test_adaptive_outline_matching_ignores_generic_sections_and_chapter_prefixes():
|
|
from deerflow.agents.deep_research.runners.basic import _outline_adherence_error
|
|
|
|
outline = """# 报告标题
|
|
## 第一章 引言
|
|
## 第二章 市场态势
|
|
## 第三章 结论
|
|
## 参考资料"""
|
|
generic_report = """# 通用报告
|
|
## 引言
|
|
## 结论
|
|
## 参考资料"""
|
|
|
|
error = _outline_adherence_error(
|
|
generic_report,
|
|
outline,
|
|
structure_mode="adaptive",
|
|
)
|
|
|
|
assert error is not None
|
|
assert "第二章 市场态势" in error
|
|
assert (
|
|
_outline_adherence_error(
|
|
"# 市场研究\n\n## 二、市场态势\n\n已按材料展开。",
|
|
outline,
|
|
structure_mode="adaptive",
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
_outline_adherence_error(
|
|
"# 简报\n\n## 引言\n\n正文。",
|
|
"# 报告标题\n\n## 第一章 引言\n\n## 第二章 结论",
|
|
structure_mode="adaptive",
|
|
)
|
|
is None
|
|
)
|
|
named_generic_outline = "# 市场态势报告\n\n## 第一章 引言\n\n## 第二章 结论"
|
|
assert (
|
|
_outline_adherence_error(
|
|
"# 市场态势报告\n\n## 完全不同的通用正文",
|
|
named_generic_outline,
|
|
structure_mode="adaptive",
|
|
)
|
|
is not None
|
|
)
|
|
assert (
|
|
_outline_adherence_error(
|
|
"# 改写后的题名\n\n## 引言\n\n正文。",
|
|
named_generic_outline,
|
|
structure_mode="adaptive",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
class _ReportEvents:
|
|
def __init__(self) -> None:
|
|
self.events: list[tuple[str, dict]] = []
|
|
|
|
async def emit(self, event_type: str, *, phase: str = "initializing", payload=None):
|
|
self.events.append((event_type, {"phase": phase, "payload": payload or {}}))
|
|
return None
|
|
|
|
async def publish_live(self, event_type: str, *, phase: str = "initializing", payload=None):
|
|
self.events.append((event_type, {"phase": phase, "payload": payload or {}}))
|
|
|
|
|
|
class _IgnoringOutlineLLM:
|
|
def __init__(self) -> None:
|
|
self.calls: list[list[dict[str, str]]] = []
|
|
|
|
async def stream_complete(self, *, messages, on_delta, **kwargs): # noqa: ARG002
|
|
from deerflow.agents.deep_research.types import CompletionResult
|
|
|
|
self.calls.append(messages)
|
|
text = "# 通用报告\n\n## 摘要\n\n这是未按所选模板生成的普通报告正文。"
|
|
await on_delta(text)
|
|
return CompletionResult(text=text)
|
|
|
|
async def complete_prose(self, *, messages, **kwargs): # noqa: ARG002
|
|
from deerflow.agents.deep_research.types import CompletionResult
|
|
|
|
self.calls.append(messages)
|
|
return CompletionResult(text="# 通用报告\n\n## 结论\n\n重试仍然忽略固定大纲。")
|
|
|
|
|
|
class _PartialAdaptiveOutlineLLM(_IgnoringOutlineLLM):
|
|
async def stream_complete(self, *, messages, on_delta, **kwargs): # noqa: ARG002
|
|
from deerflow.agents.deep_research.types import CompletionResult
|
|
|
|
self.calls.append(messages)
|
|
text = "# 年度增长方案\n\n## 一、目标\n\n保留了一个模板核心章节,其余内容按材料弹性组织。"
|
|
await on_delta(text)
|
|
return CompletionResult(text=text)
|
|
|
|
async def complete_prose(self, *, messages, **kwargs): # noqa: ARG002
|
|
raise AssertionError("adaptive outline with core overlap must not retry")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fixed_outline_mismatch_uses_one_retry_then_template_shaped_final_report():
|
|
from deerflow.agents.deep_research.config import DeepResearchConfig
|
|
from deerflow.agents.deep_research.runners.basic import BasicRunner
|
|
from deerflow.agents.deep_research.types import DeepResearchRequest
|
|
|
|
outline = "# 报告标题\n\n# 一、目标\n\n# 二、路径\n\n# 三、验收"
|
|
request = DeepResearchRequest(
|
|
session_id="session-outline",
|
|
user_id="alice",
|
|
query="年度增长方案",
|
|
config=DeepResearchConfig(
|
|
custom_outline=outline,
|
|
structure_mode="fixed",
|
|
),
|
|
)
|
|
llm = _IgnoringOutlineLLM()
|
|
events = _ReportEvents()
|
|
|
|
report = await BasicRunner()._generate_report(
|
|
llm,
|
|
request,
|
|
"已确认项目目标与约束",
|
|
events,
|
|
source_count=1,
|
|
)
|
|
|
|
assert len(llm.calls) == 2
|
|
assert "固定大纲结构校验失败" in llm.calls[1][-1]["content"]
|
|
assert report.index("# 一、目标") < report.index("# 二、路径") < report.index("# 三、验收")
|
|
assert any(
|
|
event_type == "warning"
|
|
and event["payload"].get("code") == "report_template_fallback"
|
|
for event_type, event in events.events
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adaptive_outline_does_not_retry_a_material_driven_layout():
|
|
from deerflow.agents.deep_research.config import DeepResearchConfig
|
|
from deerflow.agents.deep_research.runners.basic import BasicRunner
|
|
from deerflow.agents.deep_research.types import DeepResearchRequest
|
|
|
|
request = DeepResearchRequest(
|
|
session_id="session-adaptive-outline",
|
|
user_id="alice",
|
|
query="年度增长方案",
|
|
config=DeepResearchConfig(
|
|
custom_outline="# 报告标题\n\n## 一、目标\n\n## 二、路径",
|
|
structure_mode="adaptive",
|
|
),
|
|
)
|
|
llm = _PartialAdaptiveOutlineLLM()
|
|
|
|
report = await BasicRunner()._generate_report(
|
|
llm,
|
|
request,
|
|
"已确认项目目标与约束",
|
|
_ReportEvents(),
|
|
source_count=1,
|
|
)
|
|
|
|
assert len(llm.calls) == 1
|
|
assert "## 一、目标" in report
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adaptive_outline_with_zero_core_overlap_retries_then_keeps_core_shape():
|
|
from deerflow.agents.deep_research.config import DeepResearchConfig
|
|
from deerflow.agents.deep_research.runners.basic import BasicRunner
|
|
from deerflow.agents.deep_research.types import DeepResearchRequest
|
|
|
|
request = DeepResearchRequest(
|
|
session_id="session-adaptive-outline-missed",
|
|
user_id="alice",
|
|
query="年度增长方案",
|
|
config=DeepResearchConfig(
|
|
custom_outline="# 报告标题\n\n## 一、目标\n\n### 1.1 指标\n\n## 二、路径\n\n### 2.1 动作",
|
|
structure_mode="adaptive",
|
|
),
|
|
)
|
|
llm = _IgnoringOutlineLLM()
|
|
|
|
report = await BasicRunner()._generate_report(
|
|
llm,
|
|
request,
|
|
"已确认项目目标与约束",
|
|
_ReportEvents(),
|
|
source_count=1,
|
|
)
|
|
|
|
assert len(llm.calls) == 2
|
|
assert "弹性大纲结构校验失败" in llm.calls[1][-1]["content"]
|
|
assert "## 一、目标" in report
|
|
assert "## 二、路径" in report
|
|
assert "### 1.1 指标" not in report
|
|
|
|
|
|
def test_source_table_reload_does_not_become_session_error():
|
|
from app.gateway.deep_research_job_executor import _session_diagnostic_error
|
|
|
|
assert _session_diagnostic_error(
|
|
[
|
|
{
|
|
"code": "client_materials_reloaded_before_write",
|
|
"stage": "collecting",
|
|
"detail": "写作前再次从素材表读到 15 条",
|
|
}
|
|
]
|
|
) is None
|