feat(investigations): add event impact and work-order feedback flows

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.
This commit is contained in:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+72
View File
@@ -80,6 +80,78 @@ async def test_failed_attempt_cannot_be_relabelled_as_a_success():
])
@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()