feat(scada): require complete authorized investigation evidence
Generic Container CI/CD / test-build-publish (push) Successful in 5m52s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m52s

This commit is contained in:
2026-11-07 21:15:01 +08:00
parent 2552a9b40d
commit 06d8539aa8
12 changed files with 498 additions and 30 deletions
+4
View File
@@ -429,6 +429,9 @@ class McpQueryApplication:
output = display_query_result(result.model_dump(mode="json"))
content = []
from app.mcp.extended_service import ARTIFACT_TOOLS
# Complete SCADA inputs must reach the model intact, not as a first-page preview.
# The shared 8 MiB result ceiling above still applies before persistence and output.
complete_scada = name == "query_scada_readings" and args.device_ids is None
if (not include_full and name == "list_scada_devices"
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000):
preview = compact_scada_catalog(output, budget=12000)
@@ -436,6 +439,7 @@ class McpQueryApplication:
output = preview
if (
not include_full
and not complete_scada
and name not in ARTIFACT_TOOLS
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
):
+6
View File
@@ -35,6 +35,12 @@ class QueryTool:
"。默认每页100个,最多500个;page.has_more=true时继续递增offset读取后续页面。"
"大目录内联保留本页全部设备的编号、类型和单位,模型映射及坐标用get_scada_device核验。"
)
elif self.name == "query_scada_readings":
suffix = (
"。首次调查省略device_ids,Server读取完整设备属性并自动翻页读取窗口内全部授权观测,"
"返回devices、readings和coverage,包括缺测及冻结范围外设备;全量结果完整内联。"
"显式device_ids仅用于后续局部复核,不能替代全量核查。"
)
else:
suffix = (
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
+14 -2
View File
@@ -106,8 +106,20 @@ class WindowInput(PageInput):
class ScadaInput(WindowInput):
device_ids: list[Id] = Field(min_length=1, max_length=20)
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
device_ids: list[Id] | None = Field(
default=None, min_length=1, max_length=20,
description="Omit to read the complete SCADA catalogue and all authorized readings in the window; explicit IDs select a paginated detail query.",
)
field: Literal["monitored_value", "cleaned_value"] = Field(
default="monitored_value",
description="Field for explicit-device detail queries; complete all-device reads return both raw and cleaned values.",
)
@model_validator(mode="after")
def complete_query_starts_at_zero(self):
if self.device_ids is None and self.offset:
raise ValueError("All-device SCADA queries read every page internally; offset must be zero")
return self
class BaselineSeriesInput(WindowInput):
+9 -1
View File
@@ -129,6 +129,11 @@ async def authorize_query(
if not granted and binding.task_id and source in binding.task_scope_cap:
async with scope.connection.transaction():
granted = await task_service(scope).can_read_source(binding.task_id, source)
if granted and tool.name == "query_scada_readings" and args.device_ids is None:
granted = (
"scada_devices" in binding.task_scope_cap
and await task_service(scope).can_read_source(binding.task_id, "scada_devices")
)
if not granted:
raise HTTPException(403, "Data source permission is required")
if tool.name == "run_burst_location" and args.burst_leakage is None:
@@ -175,7 +180,10 @@ 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 replay_context:
if 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:
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)
+121
View File
@@ -0,0 +1,121 @@
"""Complete project SCADA reads with explicit frozen-scope and missing-data accounting."""
import json
from collections import Counter
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from app.mcp.models import BoundContext, ScadaInput
from app.mcp.scope import QueryScope
from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
from app.services.scada_replay_queries import check_replay_window, check_snapshot_start
MAX_COMPLETE_SCADA_BYTES = 8 * 1024 * 1024
async def read_complete_scada(
scope: QueryScope, binding: BoundContext, args: ScadaInput,
) -> tuple[dict, dict, list[str], str]:
"""Exhaust source pages; never treat a first page or selected device subset as a full scan."""
devices = []
offset = 0
size = 0
def account(items):
nonlocal size
size += len(json.dumps(jsonable_encoder(items), ensure_ascii=False).encode())
if size > MAX_COMPLETE_SCADA_BYTES:
raise HTTPException(413, "Complete SCADA result exceeds 8 MiB; split the time window and retain every device")
while True:
page = await reads.scada_devices(scope.connection, limit=501, offset=offset)
batch = page[:500]
account(batch)
devices.extend(batch)
if len(page) <= 500:
break
offset += len(batch)
project_device_count = len(devices)
context = getattr(binding, "replay_context", None)
catalog = {str(device["device_id"]): device for device in devices}
outside_scope = []
if context:
check_replay_window(context, args.start, args.end)
repository = ScadaReplayRepository(scope.connection, scope.project.project_id)
run = await repository.get(context["replay_id"])
if not run:
raise HTTPException(404, "SCADA replay was not found in the current project")
check_snapshot_start(run, args.start)
frozen = {str(device["device_id"]): device for device in run.get("devices", [])}
selected = list(dict.fromkeys(run["config"]["device_ids"]))
outside_scope = sorted(set(catalog) - set(selected))
# Retain devices removed from the current catalogue but present in the frozen source.
for device in selected:
if device not in catalog:
catalog[device] = frozen.get(device, {"device_id": device})
devices = list(catalog.values())
else:
selected = list(catalog)
rows = []
offset = 0
# The public limit controls source page size, not completeness of this operation.
page_size = min(max(args.limit, 1000), 10000)
async def read_pages(connection=None):
nonlocal offset
while selected:
if context:
page = await repository.detected_outputs(
context["replay_id"], selected, args.start, args.end, context["batch_sequence"],
limit=page_size + 1, offset=offset,
)
else:
page = await reads.series_rows(
connection, source="scada", element_type="junction", ids=selected,
start=args.start, end=args.end, field=None,
limit=page_size + 1, offset=offset,
)
batch = page[:page_size]
if context:
# Match the existing frozen-reading contract; internal source hashes are not observations.
fields = ("time", "device_id", "monitored_value", "cleaned_value", "quality", "reason")
batch = [{key: row[key] for key in fields if key in row} for row in batch]
account(batch)
rows.extend(batch)
if len(page) <= page_size:
break
offset += len(batch)
if context:
await read_pages()
elif selected:
async with scope.timescale() as connection:
await read_pages(connection)
counts = Counter(str(row["device_id"]) for row in rows)
missing = sorted(set(selected) - counts.keys())
coverage = {
"mode": "all_scada", "query_complete": True,
"project_device_count": project_device_count, "queried_device_count": len(selected),
"devices_with_readings": len(counts), "reading_count": len(rows),
"queried_device_ids": selected, "missing_device_ids": missing,
"outside_frozen_scope_device_ids": outside_scope,
"readings_per_device": {device: counts[device] for device in selected},
"replay_id": context["replay_id"] if context else None,
"batch_sequence": context["batch_sequence"] if context else None,
}
data = {"devices": devices, "readings": rows, "coverage": coverage}
account([coverage])
gaps = []
if missing:
gaps.append(f"{len(missing)} queried SCADA devices have no readings in this window; see coverage.missing_device_ids")
if outside_scope:
gaps.append(f"{len(outside_scope)} project devices are outside this frozen replay; see coverage.outside_frozen_scope_device_ids")
units = {device: catalog[device]["measurement_unit"] for device in selected
if catalog[device].get("measurement_unit")}
return data, units, gaps, "synthetic" if context else "system_data"
@@ -0,0 +1,40 @@
"""Require persisted complete SCADA input before final investigation conclusions."""
from datetime import datetime
from app.domain.domain_errors import require
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
replay = task.get("source", {}).get("replay_context")
if task.get("workflow") != "scada_anomaly" and not replay:
return
target = datetime.fromisoformat(
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
)
for item in evidence:
if item["evidence_id"] not in refs:
continue
snapshot = item.get("data_snapshot") or {}
if snapshot.get("source") != "query_scada_readings" or snapshot.get("truncated"):
continue
result = snapshot.get("data") or {}
data = result.get("data") if isinstance(result, dict) else None
coverage = data.get("coverage") if isinstance(data, dict) else None
if not coverage or coverage.get("mode") != "all_scada" or coverage.get("query_complete") is not True:
continue
window = result.get("window") or {}
if not window.get("start") or not window.get("end"):
continue
start, end = (datetime.fromisoformat(window[key]) for key in ("start", "end"))
if not start <= target <= end:
continue
if replay and (coverage.get("replay_id") != replay["replay_id"]
or coverage.get("batch_sequence") != replay["batch_sequence"]
or end != target):
continue
return
require(False, "SCADA_COVERAGE_REQUIRED",
"最终SCADA报告必须引用本轮全量读取证据。调用query_scada_readings并省略device_ids,"
"在任务时间窗内读取全部授权设备、完整属性与观测,核对coverage和缺测清单后再发布。",
status=409)
+13 -7
View File
@@ -73,6 +73,7 @@ from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
from app.services.operational_presentation import resolve_overlay
from app.services.operational_recovery import accept_completion, track_work_order
from app.services.operational_scada_coverage import require_complete_scada_evidence
LEASE_DURATION = timedelta(seconds=60)
@@ -929,6 +930,10 @@ class OperationalTaskService:
current_plan = snapshot
if command.report:
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
if command.report.stage == "final":
require_complete_scada_evidence(
task, await self.repo.list_evidence(str(task_id)), command.report.evidence_refs,
)
for gap in command.report.data_gaps:
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
"REPORT_GAP_EVIDENCE_INVALID",
@@ -1132,7 +1137,7 @@ class OperationalTaskService:
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
self._require_real_action(task)
self._require_real_action(task, command.kind)
self._validate_action_payload(command)
if task["task_type"] == "event_investigation":
report = await self.repo.get_latest_report(str(task_id))
@@ -1203,8 +1208,8 @@ class OperationalTaskService:
await self.repo.lock(f"action:{action_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
self._require_real_action(task)
action = await self._action(task_id, action_id)
self._require_real_action(task, action.kind)
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
"Only work-order drafts can be edited", status=422)
require(action.version == command.expected_version, "VERSION_CONFLICT",
@@ -1259,8 +1264,8 @@ class OperationalTaskService:
await self.repo.lock(f"action:{action_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
self._require_real_action(task)
action = await self._action(task_id, action_id)
self._require_real_action(task, action.kind)
require(action.version == command.expected_version, "VERSION_CONFLICT",
"Action version changed", status=409)
require(action.approval_status == "pending", "INVALID_TRANSITION",
@@ -1306,8 +1311,8 @@ class OperationalTaskService:
await self.repo.lock(f"action:{action_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
self._require_real_action(task)
action = await self._action(task_id, action_id)
self._require_real_action(task, action.kind)
require(action.version == command.expected_version, "VERSION_CONFLICT",
"Action version changed", status=409)
require(action.approval_status == "approved", "INVALID_TRANSITION",
@@ -2151,9 +2156,10 @@ class OperationalTaskService:
return task
@staticmethod
def _require_real_action(task: dict) -> None:
require(not (task.get("source") or {}).get("replay_context"), "REPLAY_ACTION_DISABLED",
"当前任务的工单和设备动作未开放;请继续完成已授权的定位、地图展示、隔离影响分析与报告", status=409)
def _require_real_action(task: dict, kind: str) -> None:
require(kind == "create_work_order" or not (task.get("source") or {}).get("replay_context"),
"REPLAY_ACTION_DISABLED",
"回放任务不支持设备命令;可生成工单草稿,由有权限的用户确认发布", status=409)
@staticmethod
def _require_actionable_task(task: dict) -> None: