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
+59 -6
View File
@@ -75,16 +75,26 @@ async def authorize_query(
if replay_context:
from app.services.scada_replay_queries import check_replay_window
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
"query_scada_readings", "get_network_schema", "run_burst_location",
"query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace",
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings")
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
if tool.name == "run_burst_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "run_contaminant_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_burst_location":
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
@@ -107,6 +117,8 @@ async def authorize_query(
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
required.add("simulation.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
@@ -182,7 +194,15 @@ async def execute_query(
source_ref = "mcp:" + str(uuid4())
evidence_id = None
replay_context = getattr(binding, "replay_context", None)
if name == "query_scada_readings" and args.device_ids is None:
if name == "run_contaminant_source_trace":
from app.services.contaminant_source_trace import trace_contaminant_source
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": await trace_contaminant_source(conn, args)}
origin = "synthetic"
if data["result"]["located_node"] is None:
gaps.append(data["result"]["notice"])
elif name == "query_scada_readings" and args.device_ids is None:
from app.mcp.scada_readings import read_complete_scada
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
elif name == "query_scada_readings" and replay_context:
@@ -191,6 +211,28 @@ async def execute_query(
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
data, page, truncated = page_result(rows, args)
origin = "synthetic"
elif name == "run_burst_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_burst_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"]
elif name == "run_contaminant_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_contaminant_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
elif name == "run_burst_location" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_location
from app.services.scada_replay_queries import replay_burst_location_inputs
@@ -241,7 +283,7 @@ async def execute_query(
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
@@ -252,7 +294,7 @@ async def execute_query(
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
}[name]]
if name == "calculate_demand_allocation":
truncated = data["result"]["has_more"] or args.offset > 0
@@ -491,6 +533,17 @@ async def execute_query(
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
if skill.get("name") == "water-pollution-event":
data["source_trace_capability"] = {
"available": "run_contaminant_source_trace" in binding.tool_cap,
"tool": "run_contaminant_source_trace",
"algorithm_status": "provisional",
"required_inputs": ["candidate_node_ids", "start", "end"],
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
"analyze_valve_isolation", "generate_node_coverage", "report"],
"work_order_wait_blocks_analysis": False,
"simulation_parameters": {"include_impact_coverage": True},
}
if (skill.get("name") == "scada-anomaly" and replay_context
and "run_burst_location" in binding.tool_cap):
from app.services.burst_location_demo import location_demo_context