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
+139
View File
@@ -0,0 +1,139 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReceipt,
AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand,
)
from app.services.operational_recovery import track_work_order
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
@pytest.mark.anyio
async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulation_runs():
from types import SimpleNamespace
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
from app.infra.integrations.operational_actions import ActionDispatchOutcome
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task["workflow"] = "water_pollution"
repository = TaskRepository(task, _active_run(task, now))
repository.pending_calculation_jobs = AsyncMock(return_value=["pollution-simulation"])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "候选源核验",
"current_conclusion": "候选节点已取得,可先派现场核查", "conclusion_boundary": "污染传播计算中",
"evidence_refs": [], "recommendations": ["采样核验"], "pending_items": ["传播模拟"]}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="先核验候选源",
payload={"title": "候选节点现场核查", "description": "核查污染进入路径并采样", "work_order_type": "general"}))
with pytest.raises(DomainError) as denied:
await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version, decision="approved"))
assert denied.value.code == "PERMISSION_DENIED"
service = OperationalTaskService(repository, context,
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
approved = await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version, decision="approved"))
dispatcher = SimpleNamespace(dispatch=AsyncMock(return_value=ActionDispatchOutcome("succeeded", "现场工单已创建",
ActionReceipt(adapter="peripheral_sample", receipt_id=str(uuid4()), submitted_at=now,
completed_at=now, external_status="open", detail="创建成功"))))
with pytest.raises(DomainError) as stale:
await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
request_id=uuid4(), expected_version=action.version), dispatcher, None)
assert stale.value.code == "VERSION_CONFLICT"
dispatched = await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
request_id=uuid4(), expected_version=approved.version), dispatcher, None)
assert dispatched.execution_status == "succeeded"
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
assert repository.run["status"] == "running" and repository.task["execution_generation"] == 1
dispatcher.dispatch.assert_awaited_once()
repository.pending_calculation_jobs.assert_not_awaited() # No simulation dependency on dispatch.
with pytest.raises(DomainError) as pending:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
assert pending.value.code == "PENDING_CALCULATIONS"
repository.pending_calculation_jobs.return_value = []
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="run_contaminant_simulation", source_ref="calculation:pollution", queried_at=now,
origin="synthetic", truncated=False, data={"result": {"impact": {"affected_node_count": 2}}}))
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**report,
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
"pending_items": [f"等待现场工单反馈,action_id={action.action_id}"]}))
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_pollution_report_waits_for_field_feedback_and_requires_latest_feedback_evidence(monkeypatch):
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task.update(workflow="water_pollution", source={"kind": "external_system", "external_id": "scada:quality:1",
"external_revision": 1, "event_kind": "water_pollution"})
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
scada, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:quality", queried_at=now,
origin="system_data", truncated=False, data={
"window": {"start": (now - timedelta(hours=3)).isoformat(), "end": now.isoformat()},
"data": {"devices": [], "readings": [], "coverage": {"mode": "all_scada", "query_complete": True}},
}))
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "水污染事件调查",
"current_conclusion": "候选分析完成,待采样与处置反馈", "conclusion_boundary": "未确诊污染源",
"evidence_refs": [scada.evidence_id], "recommendations": ["现场采样复测"], "pending_items": ["现场反馈"]}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="采样和处置核验",
payload={"title": "节点采样复测", "description": "回传位置、时间、数值和判定依据", "work_order_type": "general"}))
child = str(uuid4())
repository.action = action.model_copy(update={"approval_status": "approved", "execution_status": "succeeded"}).model_dump(mode="json")
repository.action["receipt"] = {"adapter": "peripheral_sample", "receipt_id": child,
"submitted_at": now.isoformat(), "completed_at": now.isoformat(), "external_status": "open", "detail": "创建成功"}
track_work_order(repository.task, repository.action)
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert result.status == "waiting_human"
service._resolve_investigation_alerts.assert_not_awaited()
evidence_id = "field-quality:2"
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{child}", source_revision=2,
status="completed", occurred_at=now, detail="采样、处置与复测反馈已返回",
evidence={"evidence_id": evidence_id, "kind": "external_feedback", "summary": "现场记录及复测结果",
"source_label": "现场反馈", "source_ref": f"peripheral_sample:{child}", "observed_at": now}))
assert repository.task["handling_context"]["phase"] == "field_review"
assert "last_check" not in repository.task["handling_context"] # No pressure recovery shortcut.
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"], source_session_id="session-1"))
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
with pytest.raises(DomainError) as rejected:
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert rejected.value.code == "POLLUTION_FEEDBACK_REQUIRED"
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final",
"evidence_refs": [scada.evidence_id, evidence_id], "pending_items": []}))
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert result.status == "completed" and result.handling_context["phase"] == "completed"
service._resolve_investigation_alerts.assert_awaited_once_with(task["task_id"], evidence_id)