Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
388 lines
20 KiB
Python
388 lines
20 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_existing_waiting_node_accepts_a_status_only_update_without_extending_the_graph():
|
|
task, repo, service = setup_service()
|
|
first = await publish(service, repo, task["task_id"], nodes=[
|
|
{"node_id": "read", "label": "读取监测", "status": "recorded"},
|
|
{"node_id": "review", "label": "核对补充资料", "status": "needs_input", "detail": "等待现场记录"},
|
|
], edges=[{"source": "read", "target": "review"}])
|
|
original = first.task.latest_progress.progress.diagram
|
|
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"], "report": {
|
|
"title": "阶段报告", "current_conclusion": "仍需核对现场资料", "conclusion_boundary": "现有资料范围",
|
|
"stage": "interim", "outcome": "concluded", "data_gaps": [],
|
|
},
|
|
}))
|
|
result = await publish(service, repo, task["task_id"], nodes=[
|
|
{"node_id": "review", "status": "recorded", "detail": "已核对收到的现场记录"},
|
|
])
|
|
updated = result.task.latest_progress.progress.diagram
|
|
assert [node.node_id for node in updated.node_refs] == ["read", "review"]
|
|
assert updated.node_refs[1].label == "核对补充资料"
|
|
assert updated.node_refs[1].status == "recorded"
|
|
assert updated.node_refs[1].detail == "已核对收到的现场记录"
|
|
assert updated.edges == original.edges
|
|
assert updated.mermaid == original.mermaid
|
|
assert original.node_refs[1].status == "needs_input"
|
|
archived = (await service.report(task["task_id"], 1)).report.diagram
|
|
assert archived.node_refs[1].status == "needs_input"
|
|
assert archived.node_refs[1].detail == "等待现场记录"
|
|
|
|
|
|
@pytest.mark.parametrize("label_patch", [{}, {"label": None}])
|
|
def test_partial_updates_keep_labels_and_accumulated_references_including_legacy_nodes(label_patch):
|
|
evidence_id = "saved-reading"
|
|
action_id = uuid4()
|
|
original = InvestigationDiagram.model_validate({
|
|
"title": "旧调查图", "mermaid": "flowchart LR\nold{核对资料} --> next[继续调查]",
|
|
"node_refs": [{"node_id": "old", "label": "核对资料", "status": "waiting_human",
|
|
"detail": "等待资料", "evidence_refs": [evidence_id], "action_refs": [action_id],
|
|
"map_targets": [{"source_id": "scada", "feature_id": "P1"}]}],
|
|
})
|
|
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
|
"nodes": [{"node_id": "old", "status": "recorded", **label_patch}],
|
|
}))
|
|
assert updated.mermaid == updated.source_mermaid == original.mermaid
|
|
assert updated.node_refs[0] == original.node_refs[0].model_copy(update={"status": "recorded"})
|
|
assert original.node_refs[0].status == "needs_input"
|
|
|
|
|
|
def test_status_only_updates_cannot_create_an_unlabelled_node_or_rewrite_a_failed_attempt():
|
|
with pytest.raises(DomainError) as error:
|
|
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
|
"nodes": [{"node_id": "missing", "status": "recorded"}],
|
|
}))
|
|
assert error.value.code == "GRAPH_NODE_LABEL_REQUIRED"
|
|
failed = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
|
"nodes": [{"node_id": "attempt", "label": "失败尝试", "status": "failed", "detail": "接口超时"}],
|
|
}))
|
|
enriched = update_investigation_graph(failed, InvestigationGraphUpdate.model_validate({
|
|
"nodes": [{"node_id": "attempt", "evidence_refs": ["timeout-record"]}],
|
|
}))
|
|
assert enriched.node_refs[0].label == "失败尝试"
|
|
assert enriched.node_refs[0].detail == "接口超时"
|
|
assert enriched.node_refs[0].status == "failed"
|
|
assert enriched.node_refs[0].evidence_refs == ["timeout-record"]
|
|
with pytest.raises(DomainError) as error:
|
|
update_investigation_graph(enriched, InvestigationGraphUpdate.model_validate({
|
|
"nodes": [{"node_id": "attempt", "status": "recorded"}],
|
|
}))
|
|
assert error.value.code == "GRAPH_FAILED_ATTEMPT_IMMUTABLE"
|
|
|
|
|
|
@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 == []
|