feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+16
View File
@@ -98,3 +98,19 @@ async def bounded_calculation(function, *args, **kwargs):
finally:
_slots.release()
return await asyncio.to_thread(run)
async def calculate_replay_burst_location(
parameters: dict[str, Any], *, network: str, username: str,
) -> dict[str, Any]:
"""Run location with Server-built frozen replay series and no live SCADA query."""
from app.services.burst_location import run_burst_location_by_network
return await bounded_calculation(
run_burst_location_by_network,
network=network,
username=username,
scheme_name=None,
final_candidates_csv_path=None,
**parameters,
)
+1
View File
@@ -30,6 +30,7 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
task = await repo.get_task(str(binding.task_id))
if not task:
raise HTTPException(404, "Task is not in this project")
binding.replay_context = task.get("source", {}).get("replay_context")
if binding.purpose == "task_collaboration":
return
if OPERATIONAL_TASK_EXECUTE not in permissions:
+1
View File
@@ -291,5 +291,6 @@ class BoundContext(BaseModel):
task_id: UUID | None = None
run_id: UUID | None = None
execution_request_id: UUID | None = None
replay_context: dict | None = None
expires_at: datetime
revoked_at: datetime | None = None
+46 -1
View File
@@ -66,8 +66,23 @@ def page_result(rows, args):
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
replay_context = getattr(binding, "replay_context", None)
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
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",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查当前仅开放冻结 SCADA 证据与模型目录查询;请使用 query_scada_readings")
if tool.name == "query_scada_readings":
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 输入")
check_replay_window(replay_context, args.scada_burst_start, args.scada_burst_end)
check_replay_window(replay_context, args.scada_normal_start, args.scada_normal_end)
permissions = permissions_for_context(scope.project)
if tool.mutating:
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
@@ -149,7 +164,37 @@ async def execute_query(
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
if tool.computation:
replay_context = getattr(binding, "replay_context", None)
if name == "query_scada_readings" and replay_context:
from app.services.scada_replay_queries import replay_snapshot_rows
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
data, page, truncated = page_result(rows, args)
origin = "synthetic"
gaps.append("历史数据回放证据,仅覆盖该轮调查已提交的批次,不表示现场实时观测。")
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
parameters = await replay_burst_location_inputs(
conn, scope.project.project_id, replay_context, args,
)
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_burst_location(
parameters,
network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": result,
}
origin = "synthetic"
gaps = [
"定位只使用任务知识截止前已固化的回放清洗输出;候选排序依赖漏量假设、模型和测点覆盖,仍需现场核实。"
]
elif tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
+1
View File
@@ -9,6 +9,7 @@ import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
"scada_anomaly": "scada-anomaly",
"burst_report": "burst-report",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",