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
@@ -0,0 +1,68 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.mcp import analysis_service, query_service
from app.mcp.analysis_models import ContaminantSimulationInput
from app.mcp.calculation_jobs import should_queue
from app.mcp.catalog import TOOLS
def args(**extra):
return ContaminantSimulationInput(context_handle="a" * 43, **({
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
"output_node_ids": ["Q1"], "source": "Q1", "concentration": 1,
"include_impact_coverage": True, "impact_threshold_mg_l": 0.01,
} | extra))
@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")},
{"include_impact_coverage": "true"}])
def test_impact_inputs_reject_invalid_thresholds_and_flags(extra):
with pytest.raises(ValidationError): args(**extra)
def context(monkeypatch):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
@asynccontextmanager
async def routing():
yield
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_code="project", project_role="member"), user=SimpleNamespace(username="actor"),
simulation_routing=routing)
binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], permission_cap=["simulation.run", "scada.view"],
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
return scope, binding
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch):
scope, binding = context(monkeypatch)
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args()))
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
assert "预测" in result.data_gaps[0]
scope.connection.commit.assert_awaited_once()
worker.assert_awaited_once()
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
assert should_queue(TOOLS["run_contaminant_simulation"], args())
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"])
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
scope, binding = context(monkeypatch)
parameters = args()
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
if problem == "no_impact": parameters = args(include_impact_coverage=False)
if problem == "reader": binding.purpose = "task_reader"
if problem == "permission": binding.permission_cap.remove("scada.view")
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403)