Files
next-tjwater-server/tests/unit/test_investigation_graph.py
T
jiang b853b61a0b feat(investigation): preserve cumulative reference graphs
Accumulate investigation paths and preserve failed attempts across rounds. Keep graph annotations separate from execution state, synchronize the API contract, and consolidate documentation.
2026-11-11 19:10:41 +08:00

316 lines
16 KiB
Python

from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import AgentArtifactsCommand
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationGraphUpdate
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.services.investigation_graph import update_investigation_graph
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import (
TaskRepository, _active_run, _context, _task_document,
)
@pytest.fixture
def anyio_backend():
return "asyncio"
def setup_service():
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repo = TaskRepository(task, _active_run(task, now))
return task, repo, OperationalTaskService(repo, context, set(), clock=lambda: now)
async def publish(service, repo, task_id, *, nodes=(), edges=(), **progress):
command = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"],
"progress": {"summary": "更新调查路线", "graph_update": {
"nodes": list(nodes), "edges": list(edges),
}, **progress},
})
return await service.publish_artifacts(task_id, command)
@pytest.mark.anyio
async def test_failed_path_is_retained_when_a_new_route_is_added():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "read", "label": "读取全量 SCADA", "status": "recorded"},
{"node_id": "locate_1", "label": "第一次候选定位", "status": "reference"},
], edges=[{"source": "read", "target": "locate_1"}])
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "locate_1", "label": "第一次候选定位", "status": "failed", "detail": "计算超时"},
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "reference"},
], edges=[{"source": "locate_1", "target": "locate_2", "label": "调整后重试"}])
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "recorded"},
])
diagram = result.task.latest_progress.progress.diagram
assert [(n.node_id, n.status) for n in diagram.node_refs] == [
("read", "recorded"), ("locate_1", "failed"), ("locate_2", "recorded"),
]
assert diagram.node_refs[1].detail == "计算超时"
assert len(diagram.edges) == 2
assert "第一次候选定位(失败)" in diagram.mermaid
assert 'locate_1 -->|"调整后重试"| locate_2' in diagram.mermaid
# A text-only update cannot clear the accumulated graph.
summary = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "progress": {"summary": "等待人工", "diagram": None},
})
assert (await service.publish_artifacts(task["task_id"], summary)).task.latest_progress.progress.diagram == diagram
@pytest.mark.anyio
async def test_failed_attempt_cannot_be_relabelled_as_a_success():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "attempt_1", "label": "候选定位", "status": "failed", "detail": "超时"},
])
with pytest.raises(DomainError, match="new node"):
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "attempt_1", "label": "候选定位", "status": "recorded"},
])
@pytest.mark.anyio
async def test_new_execution_round_continues_the_same_task_graph():
task, repo, service = setup_service()
first = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "first", "label": "首次查询", "status": "failed"},
])
archived = first.task.latest_progress.progress.diagram.model_dump(mode="json")
repo.task["execution_generation"] = 2
repo.task["execution_request_id"] = str(uuid4())
repo.run["status"] = "succeeded"
repo.run = _active_run(repo.task, datetime(2026, 11, 10, tzinfo=timezone.utc))
repo.runs[repo.run["run_id"]] = repo.run
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs[0].status == "failed"
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "second", "label": "补充信息后查询", "status": "recorded"},
], edges=[{"source": "first", "target": "second"}])
assert result.progress_revision == 1
assert result.task.latest_progress.execution_generation == 2
assert len(result.task.latest_progress.progress.diagram.node_refs) == 2
assert repo.progress[(task["task_id"], 1)]["progress"]["diagram"] == archived
@pytest.mark.anyio
async def test_reports_archive_the_host_graph_and_keep_older_versions_unchanged():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "failed", "label": "数据读取", "status": "failed", "detail": "接口超时"},
])
command = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "report": {
"title": "调查", "current_conclusion": "已有阶段判断", "conclusion_boundary": "限于现有资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
"diagram": {"title": "重新生成的图", "mermaid": "flowchart TD\nNEW[新图]"},
},
})
first = await service.publish_artifacts(task["task_id"], command)
assert first.task.latest_report.report.diagram.node_refs[0].status == "failed"
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "retry", "label": "重新读取", "status": "recorded"},
], edges=[{"source": "failed", "target": "retry"}])
assert len((await service.report(task["task_id"], 1)).report.diagram.node_refs) == 1
assert len((await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs) == 2
# Idempotent replays do not append nodes or change the archived report.
assert (await service.publish_artifacts(task["task_id"], command)).report_revision == 1
@pytest.mark.anyio
async def test_graph_updates_validate_references_and_cannot_replace_a_managed_graph():
task, repo, service = setup_service()
with pytest.raises(DomainError, match="unknown evidence"):
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "unknown", "label": "未知依据", "evidence_refs": ["forged"]},
])
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "kept", "label": "实际步骤", "status": "recorded"},
])
with pytest.raises(DomainError, match="graph_update"):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "progress": {
"summary": "重画", "diagram": {"title": "替换", "mermaid": "flowchart TD\nNEW[新图]"},
},
}))
def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_paths():
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [
{"node_id": "start", "label": '核查 "地点" <script> ``` %%{init: {}}'},
{"node_id": "left", "label": "监测", "status": "failed"},
{"node_id": "right", "label": "工单", "status": "reference"},
], "edges": [{"source": "start", "target": target} for target in ["left", "right"]],
}))
assert "<script>" not in graph.mermaid
assert "#lt;script#gt;" in graph.mermaid
assert "#quot;地点#quot;" in graph.mermaid
assert "```" not in graph.mermaid and "%%{" not in graph.mermaid
assert "监测(失败)" in graph.mermaid
updated = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "right", "label": "工单", "status": "recorded"}],
"edges": [{"source": "start", "target": "right"}],
}))
assert len(updated.edges) == 2
assert graph.node_refs[2].status == "reference"
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
original = InvestigationDiagram.model_validate({
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
"node_refs": [{"node_id": "B", "label": "原路径", "status": "failed", "detail": "查询超时"}],
})
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "reference"}],
"edges": [{"source": "B", "target": "retry"}],
}))
assert updated.mermaid.startswith(original.mermaid + "\n")
assert updated.source_mermaid == original.mermaid
assert updated.node_refs[0].status == "failed"
final = update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "recorded"}],
}))
assert final.mermaid.count("A{核查条件}") == 1
assert "B --> retry" in final.mermaid
with pytest.raises(DomainError, match="original Mermaid"):
update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "C", "label": "与旧图冲突的新步骤"}],
}))
@pytest.mark.anyio
async def test_first_update_uses_the_last_saved_legacy_report_graph():
task, repo, service = setup_service()
repo.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
"published_at": "2026-11-09T00:00:00Z", "published_by": "agent", "report": {
"title": "旧报告", "current_conclusion": "已有判断", "conclusion_boundary": "已有证据",
"diagram": {"title": "旧路线", "mermaid": "flowchart TD\nold[此前查询]",
"node_refs": [{"node_id": "old", "label": "此前查询", "status": "failed"}]},
},
})
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "retry", "label": "接续查询", "status": "reference"},
], edges=[{"source": "old", "target": "retry"}])
diagram = result.task.latest_progress.progress.diagram
assert diagram.source_mermaid == "flowchart TD\nold[此前查询]"
assert [n.node_id for n in diagram.node_refs] == ["old", "retry"]
@pytest.mark.parametrize("update,code", [
({"nodes": []}, "GRAPH_NODE_REQUIRED"),
({"nodes": [{"node_id": "end", "label": "保留字"}]}, "GRAPH_NODE_ID_INVALID"),
({"nodes": [{"node_id": "n", "label": "一"}, {"node_id": "n", "label": "二"}]}, "GRAPH_NODE_DUPLICATE"),
({"nodes": [{"node_id": "n", "label": "一"}], "edges": [{"source": "n", "target": "missing"}]}, "GRAPH_EDGE_NODE_NOT_FOUND"),
])
def test_graph_updates_reject_invalid_structure_without_erasing_history(update, code):
with pytest.raises(DomainError) as error:
update_investigation_graph(None, InvestigationGraphUpdate.model_validate(update))
assert error.value.code == code
@pytest.mark.anyio
@pytest.mark.parametrize("node_refs", [[], [{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "查询超时"}]])
async def test_summary_only_adopts_legacy_graph_before_the_first_incremental_update(node_refs):
task, repo, service = setup_service()
original = {"title": "旧路线", "mermaid": "flowchart LR\nold{旧尝试} --> other[另一分支]", "node_refs": node_refs}
for progress in [{"summary": "此前调查", "diagram": original}, {"summary": "继续调查"}]:
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"], progress=progress,
))
diagram = result.task.latest_progress.progress.diagram
assert diagram is not None
assert diagram.mermaid == diagram.source_mermaid == original["mermaid"]
assert diagram.edges == []
assert diagram.node_refs == InvestigationDiagram.model_validate(original).node_refs
@pytest.mark.anyio
async def test_identical_effective_reports_do_not_repeat_notifications_for_inherited_graph_evidence():
task, repo, service = setup_service()
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="probe", queried_at=service._now(),
origin="system_data", truncated=False, data={"data": []},
))
await publish(service, repo, task["task_id"], nodes=[{
"node_id": "finding", "label": "已有结果", "status": "recorded", "evidence_refs": [evidence.evidence_id],
}])
saved = []
for _ in range(2):
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
},
))
saved.append(result.task.latest_report.report)
assert saved[0] == saved[1]
assert saved[0].evidence_refs == [evidence.evidence_id]
assert len([notice for notice in repo.notifications if notice["kind"] == "report_updated"]) == 1
@pytest.mark.anyio
async def test_final_scada_report_validates_the_effective_inherited_evidence():
task, repo, service = setup_service()
task["workflow"] = "scada_anomaly"
now = service._now()
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="complete-scada", queried_at=now,
origin="system_data", truncated=False, data={
"data": {"coverage": {"mode": "all_scada", "query_complete": True}},
"window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()},
},
))
await publish(service, repo, task["task_id"], nodes=[{
"node_id": "all_scada", "label": "全量读取", "evidence_refs": [evidence.evidence_id],
}])
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
},
))
assert result.task.latest_report.report.evidence_refs == [evidence.evidence_id]
@pytest.mark.anyio
async def test_evidence_only_publication_does_not_load_the_graph_baseline(monkeypatch):
task, repo, service = setup_service()
calls = []
original = repo.get_latest_graph_progress
async def load_graph(task_id):
calls.append(task_id)
return await original(task_id)
# The returned task detail can read progress; count separately from publication.
original_detail = service.detail
async def detail(task_id):
before_detail = len(calls)
result = await original_detail(task_id)
del calls[before_detail:]
return result
monkeypatch.setattr(repo, "get_latest_graph_progress", load_graph)
monkeypatch.setattr(service, "detail", detail)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", evidence=[{
"evidence_id": "demo", "kind": "synthetic", "summary": "演练依据",
"source_label": "fixture", "observed_at": service._now(),
}],
))
assert calls == []