fix: enforce SCADA chart device coverage

This commit is contained in:
2026-11-12 10:38:55 +08:00
parent b853b61a0b
commit 2e8fe86f3f
17 changed files with 1220 additions and 13 deletions
+63 -2
View File
@@ -77,6 +77,7 @@ from app.services.investigation_graph import adopt_legacy_investigation_graph, u
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
from app.services import scada_presentation_coverage
from app.services.time_comparison_chart import validate_time_comparison_chart
@@ -955,6 +956,7 @@ class OperationalTaskService:
require_complete_scada_evidence(
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
)
await self._verify_scada_report_presentation(task, active_run, _json(report_input), now)
for gap in report_input.data_gaps:
require(set(gap.evidence_refs) <= set(report_input.evidence_refs),
"REPORT_GAP_EVIDENCE_INVALID",
@@ -994,7 +996,7 @@ class OperationalTaskService:
or previous_result.get("stage") != "final"
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != effective_report.get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks", "scada_device_ids"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
@@ -1092,8 +1094,11 @@ class OperationalTaskService:
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
await self._record_scada_presentation(task, run, payload, now)
if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"])
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
payload["chart"].get("scada") and scada_presentation_coverage.applies(task),
))
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay":
@@ -1131,6 +1136,61 @@ class OperationalTaskService:
await self._save_request(scope, command.request_id, body, _json(result))
return result
async def _record_scada_presentation(self, task: dict, run: dict, payload: dict, now: datetime) -> None:
if not scada_presentation_coverage.applies(task):
return
action = payload["action"]
targets = []
if action == "render_scada_analysis":
targets = [item["device_id"] for item in payload["items"]]
elif action in {"locate_features", "highlight_features"} and payload["layer"] == "scada":
targets = payload["feature_ids"]
elif action != "open_chart":
return
evidence = await self.repo.list_evidence(task["task_id"])
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
if action == "open_chart":
if payload["chart"].get("scada"):
known = {item["evidence_id"] for item in evidence}
self._require_evidence_refs(payload["evidence_refs"], known)
chart = scada_presentation_coverage.verify_chart(task, payload["chart"], evidence, payload["evidence_refs"])
targets = chart["device_ids"]
duplicates = [chart_id for chart_id, stored in (document or {}).get("charts", {}).items()
if chart_id != payload["chart_id"] and stored["role"] == chart["role"] == "comparison"
and any(set(stored.get("coverage", {}).get(device, {}).get("covered", []))
& set(chart["coverage"][device]["covered"])
for device in set(stored["device_ids"]) & set(targets))]
require(not duplicates, "SCADA_CHART_DUPLICATE_DEVICE",
f"设备同期图已存在,请更新原chart_id:{', '.join(duplicates)}", refs=duplicates)
document = scada_presentation_coverage.add_targets(document, targets, observations)
document["charts"][payload["chart_id"]] = chart
elif document and payload["chart_id"] in document["charts"]:
# Replacing a typed chart with a generic one removes its coverage,
# while cumulative targets prevent shrinking the required set.
del document["charts"][payload["chart_id"]]
else:
return
else:
document = scada_presentation_coverage.add_targets(document, targets, observations)
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
async def _verify_scada_report_presentation(self, task: dict, run: dict, report: dict, now: datetime) -> None:
if not scada_presentation_coverage.applies(task):
return
if report.get("stage") != "final" and not report.get("scada_device_ids"):
return
evidence = await self.repo.list_evidence(task["task_id"])
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
targets = list(report.get("scada_device_ids") or [])
if targets:
document = scada_presentation_coverage.add_targets(document, targets, observations)
if report.get("stage") == "final":
scada_presentation_coverage.require_complete(task, document, observations)
if document:
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
async def propose_action(
self, task_id: UUID, command: ActionProposalCommand
) -> HumanApprovalAction:
@@ -2049,6 +2109,7 @@ class OperationalTaskService:
"Run requires a final report from the same generation"
)
else:
await self._verify_scada_report_presentation(task, run, _json(report.report), now)
actions = [
HumanApprovalAction.model_validate(item)
for item in await self.repo.list_actions(str(task_id))