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
+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"