Compare commits

...
Author SHA1 Message Date
jiang acc8f8f7cb fix(skills): keep comparison guidance within inline response budget
Generic Container CI/CD / test-build-publish (push) Successful in 4m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m11s
2026-11-08 03:10:22 +08:00
jiang b8359ba83c fix(charts): validate same-time comparison publications
Generic Container CI/CD / test-build-publish (push) Failing after 2m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m10s
Prompt-only projection rules allowed historical samples to be appended to the current axis. Enforce typed comparison semantics at presentation and report publication, preserving historical read contracts and returning actionable validation errors.
2026-11-08 03:04:14 +08:00
jiang c0dea2aacf fix(contracts): export required task execution scope 2026-11-08 02:00:29 +08:00
jiang 33df25f22b fix(tasks): isolate worker execution scopes
Persist a required Server-owned execution scope and enforce it for listing, claiming, lease recovery and SCADA verification. Backfill existing tasks explicitly through migration 034 so local development workers no longer claim production tasks from the shared database.
2026-11-08 01:54:48 +08:00
jiang 2a7b96f813 fix(scada): verify recovery after replay work-order completion
Generic Container CI/CD / test-build-publish (push) Successful in 3m58s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m58s
2026-11-07 23:12:42 +08:00
24 changed files with 490 additions and 34 deletions
+2
View File
@@ -6,6 +6,8 @@
# 基础运行 # 基础运行
PROJECT_NAME="TJWater Server" PROJECT_NAME="TJWater Server"
ENVIRONMENT="production" ENVIRONMENT="production"
# 任务领取范围;留空时沿用 ENVIRONMENT。历史任务需先执行 034 迁移。
OPERATIONAL_TASK_EXECUTION_SCOPE=""
API_V1_STR="/api/v1" API_V1_STR="/api/v1"
NETWORK_NAME="tjwater" NETWORK_NAME="tjwater"
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from psycopg import AsyncConnection from psycopg import AsyncConnection
from app.auth.metadata_dependencies import get_metadata_repository from app.auth.metadata_dependencies import get_metadata_repository
from app.core.config import settings
from app.auth.permissions import ( from app.auth.permissions import (
OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC, OPERATIONAL_TASK_EXTERNAL_SYNC,
@@ -122,6 +123,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
OperationalTaskRepository(conn, ctx.project_id), OperationalTaskRepository(conn, ctx.project_id),
ctx, ctx,
_task_permissions(ctx), _task_permissions(ctx),
execution_scope=settings.operational_task_execution_scope,
) )
+1
View File
@@ -36,6 +36,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
OperationalTaskRepository(conn, ctx.project_id), OperationalTaskRepository(conn, ctx.project_id),
ctx, ctx,
permissions_for_context(ctx), permissions_for_context(ctx),
execution_scope=settings.operational_task_execution_scope,
) )
+6
View File
@@ -8,6 +8,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
PROJECT_NAME: str = "TJWater Server" PROJECT_NAME: str = "TJWater Server"
ENVIRONMENT: str = "production" ENVIRONMENT: str = "production"
OPERATIONAL_TASK_EXECUTION_SCOPE: str = ""
@property
def operational_task_execution_scope(self) -> str:
return self.OPERATIONAL_TASK_EXECUTION_SCOPE.strip() or self.ENVIRONMENT
API_V1_STR: str = "/api/v1" API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network" NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet) # 敏感配置加密密钥 (Fernet)
+2 -1
View File
@@ -574,6 +574,7 @@ class OperationalTaskRun(StrictModel):
class OperationalTaskSummary(StrictModel): class OperationalTaskSummary(StrictModel):
execution_scope: NonEmptyStr
project_id: UUID project_id: UUID
task_id: UUID task_id: UUID
task_type: TaskType task_type: TaskType
@@ -721,7 +722,7 @@ class TaskSafeChartSeries(StrictModel):
class TaskSafeChart(StrictModel): class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)] title: Annotated[str, Field(max_length=200)]
chart_type: Literal["line", "bar"] chart_type: Literal["line", "bar", "time_comparison"]
x_axis_name: Annotated[str, Field(max_length=128)] | None = None x_axis_name: Annotated[str, Field(max_length=128)] | None = None
y_axis_name: Annotated[str, Field(max_length=128)] | None = None y_axis_name: Annotated[str, Field(max_length=128)] | None = None
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
+9 -5
View File
@@ -264,6 +264,7 @@ class OperationalTaskRepository:
async def list_runs( async def list_runs(
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None, self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None, task_type: str | None = None, task_created_from: datetime | None = None,
execution_scope: str = "production",
) -> list[dict]: ) -> list[dict]:
result = await self.connection.execute( result = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision, """SELECT run_id,task_id,generation,execution_request_id,status,revision,
@@ -282,18 +283,20 @@ class OperationalTaskRepository:
AND t.task_id=operational_task_runs.task_id AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text) AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
AND t.document->>'execution_scope'=%s
) )
ORDER BY created_at,run_id LIMIT %s""", ORDER BY created_at,run_id LIMIT %s""",
( (
self.project_id, status, status, cursor_created_at, self.project_id, status, status, cursor_created_at,
cursor_created_at, cursor_run_id, cursor_created_at, cursor_run_id,
task_type, task_type, task_created_from, task_created_from, limit, task_type, task_type, task_created_from, task_created_from, execution_scope, limit,
), ),
) )
return [dict(row) for row in await result.fetchall()] return [dict(row) for row in await result.fetchall()]
async def claim_expired_runs( async def claim_expired_runs(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None, self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
execution_scope: str = "production",
) -> list[dict]: ) -> list[dict]:
cursor = await self.connection.execute( cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision, """SELECT run_id,task_id,generation,execution_request_id,status,revision,
@@ -310,9 +313,10 @@ class OperationalTaskRepository:
AND t.task_id=operational_task_runs.task_id AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text) AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
AND t.document->>'execution_scope'=%s
) )
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""", ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit), (self.project_id, task_type, task_type, task_created_from, task_created_from, execution_scope, limit),
) )
return [dict(row) for row in await cursor.fetchall()] return [dict(row) for row in await cursor.fetchall()]
@@ -711,16 +715,16 @@ class OperationalTaskRepository:
row = await cursor.fetchone() row = await cursor.fetchone()
return dict(row) if row else None return dict(row) if row else None
async def recovery_candidates(self, limit: int, created_from=None) -> list[dict]: async def recovery_candidates(self, limit: int, created_from=None, execution_scope: str = "production") -> list[dict]:
cursor = await self.connection.execute( cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks """SELECT document FROM workbench.operational_tasks
WHERE project_id=%s AND task_type='event_investigation' WHERE project_id=%s AND task_type='event_investigation'
AND status NOT IN ('cancelled','failed') AND status NOT IN ('cancelled','failed')
AND document#>>'{handling_context,phase}'='waiting_scada' AND document#>>'{handling_context,phase}'='waiting_scada'
AND document#>>'{source,replay_context}' IS NULL AND document->>'execution_scope'=%s
AND (%s::timestamptz IS NULL OR created_at>=%s) AND (%s::timestamptz IS NULL OR created_at>=%s)
ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""", ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""",
(self.project_id, created_from, created_from, limit), (self.project_id, execution_scope, created_from, created_from, limit),
) )
return [row["document"] for row in await cursor.fetchall()] return [row["document"] for row in await cursor.fetchall()]
+2
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from app.core.config import settings
from app.auth.permissions import permissions_for_context from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements from app.infra.db.postgresql.spatial_queries import nearby_elements
@@ -52,6 +53,7 @@ def task_service(scope: QueryScope) -> OperationalTaskService:
OperationalTaskRepository(scope.connection, scope.project.project_id), OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project, scope.project,
set(permissions_for_context(scope.project)), set(permissions_for_context(scope.project)),
execution_scope=settings.operational_task_execution_scope,
) )
+7 -3
View File
@@ -20,8 +20,7 @@ RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
def track_work_order(task: dict, action: dict) -> None: def track_work_order(task: dict, action: dict) -> None:
if (task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order" if task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order":
or task.get("source", {}).get("replay_context")):
return return
receipt = action.get("receipt") or {} receipt = action.get("receipt") or {}
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample": if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
@@ -132,9 +131,11 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
from app.infra.db.timescaledb.repositories.scada import ScadaRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository
async with service.repo.transaction(): async with service.repo.transaction():
candidates = await service.repo.recovery_candidates(limit, created_from) candidates = await service.repo.recovery_candidates(limit, created_from, service.execution_scope)
resumed = 0 resumed = 0
for task in candidates: for task in candidates:
if task.get("execution_scope") != service.execution_scope:
continue
context = task["handling_context"] context = task["handling_context"]
returned_at = datetime.fromisoformat(context["returned_at"]) returned_at = datetime.fromisoformat(context["returned_at"])
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"]) incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
@@ -160,6 +161,9 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str)) devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str))
devices = list(dict.fromkeys(devices)) devices = list(dict.fromkeys(devices))
async with timescale.transaction(): async with timescale.transaction():
# Field completion authorizes a separate recovery observation even
# when the incident was found in a frozen replay. Keep that replay's
# query window and batch boundary unchanged.
rows = await ScadaRepository.get_recovery_scada_readings( rows = await ScadaRepository.get_recovery_scada_readings(
timescale, devices, incident_at, returned_at, through, baseline_limit=100_001, timescale, devices, incident_at, returned_at, through, baseline_limit=100_001,
) if through > returned_at and devices else [] ) if through > returned_at and devices else []
+17 -1
View File
@@ -74,6 +74,7 @@ from app.services.content_digest import digest
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay
from app.services.operational_recovery import accept_completion, track_work_order 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.operational_scada_coverage import require_complete_scada_evidence
from app.services.time_comparison_chart import validate_time_comparison_chart
LEASE_DURATION = timedelta(seconds=60) LEASE_DURATION = timedelta(seconds=60)
@@ -88,11 +89,13 @@ def _task_json(model: OperationalTaskSummary) -> dict:
class OperationalTaskService: class OperationalTaskService:
def __init__(self, repository, context, permissions: Iterable[str], *, clock=None): def __init__(self, repository, context, permissions: Iterable[str], *, clock=None,
execution_scope: str = "production"):
self.repo = repository self.repo = repository
self.context = context self.context = context
self.permissions = frozenset(permissions) self.permissions = frozenset(permissions)
self.clock = clock or (lambda: datetime.now(timezone.utc)) self.clock = clock or (lambda: datetime.now(timezone.utc))
self.execution_scope = execution_scope
def _now(self) -> datetime: def _now(self) -> datetime:
now = self.clock() now = self.clock()
@@ -195,6 +198,7 @@ class OperationalTaskService:
now = self._now() now = self._now()
task_id = uuid4() task_id = uuid4()
task = OperationalTaskSummary( task = OperationalTaskSummary(
execution_scope=self.execution_scope,
project_id=self.context.project_id, project_id=self.context.project_id,
task_id=task_id, task_id=task_id,
task_type=command.task_type, task_type=command.task_type,
@@ -489,6 +493,7 @@ class OperationalTaskService:
task_type: str | None = None, task_created_from: datetime | None = None, task_type: str | None = None, task_created_from: datetime | None = None,
) -> tuple[list[OperationalTaskRun], str | None]: ) -> tuple[list[OperationalTaskRun], str | None]:
rows = await self.repo.list_runs( rows = await self.repo.list_runs(
execution_scope=self.execution_scope,
status=status, status=status,
limit=limit + 1, limit=limit + 1,
cursor_created_at=cursor_created_at, cursor_created_at=cursor_created_at,
@@ -510,6 +515,9 @@ class OperationalTaskService:
scope = f"run:{run_id}:claim" scope = f"run:{run_id}:claim"
async with self.repo.transaction(): async with self.repo.transaction():
await self.repo.require_execution_storage() await self.repo.require_execution_storage()
task = await self._task(task_id)
require(task.get("execution_scope") == self.execution_scope,
"EXECUTION_SCOPE_MISMATCH", "当前环境不能领取其他执行范围的任务", status=403)
replay = await self._request_replay(scope, command.request_id, body) replay = await self._request_replay(scope, command.request_id, body)
if replay: if replay:
return OperationalTaskRun.model_validate(replay["run"]) return OperationalTaskRun.model_validate(replay["run"])
@@ -759,6 +767,7 @@ class OperationalTaskService:
async with self.repo.transaction(): async with self.repo.transaction():
for run in await self.repo.claim_expired_runs( for run in await self.repo.claim_expired_runs(
limit, task_type=task_type, task_created_from=task_created_from, limit, task_type=task_type, task_created_from=task_created_from,
execution_scope=self.execution_scope,
): ):
# Lease recovery already holds a row lock. Try rather than wait # Lease recovery already holds a row lock. Try rather than wait
# for the shared run lock to avoid reversing the lock order of # for the shared run lock to avoid reversing the lock order of
@@ -829,6 +838,11 @@ class OperationalTaskService:
"Only a running run accepts Agent artifacts", status=409) "Only a running run accepts Agent artifacts", status=409)
now = self._now() now = self._now()
self._require_active_run_lease(active_run, now) self._require_active_run_lease(active_run, now)
if command.report:
for item in command.report.ui_blocks:
for element in item.block.spec.elements.values():
if element.type == "Chart":
validate_time_comparison_chart(element.props)
if command.report and ( if command.report and (
command.report.stage == "final" or command.report.outcome == "insufficient_data" command.report.stage == "final" or command.report.outcome == "insufficient_data"
): ):
@@ -1087,6 +1101,7 @@ class OperationalTaskService:
self._require_active_run_lease(run, now) self._require_active_run_lease(run, now)
payload = _json(command.command) payload = _json(command.command)
if command.command.action == "open_chart": if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"])
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))} known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known) self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay": elif command.command.action == "set_overlay":
@@ -2516,6 +2531,7 @@ class OperationalTaskService:
due_at: datetime, now: datetime, due_at: datetime, now: datetime,
) -> OperationalTaskSummary: ) -> OperationalTaskSummary:
return OperationalTaskSummary( return OperationalTaskSummary(
execution_scope=self.execution_scope,
project_id=self.context.project_id, task_id=task_id, project_id=self.context.project_id, task_id=task_id,
task_type="scheduled_flow", workflow=schedule.schedule_key, task_type="scheduled_flow", workflow=schedule.schedule_key,
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1, lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
+5 -1
View File
@@ -6,6 +6,7 @@ import json
from uuid import NAMESPACE_URL, uuid4, uuid5 from uuid import NAMESPACE_URL, uuid4, uuid5
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings
from app.auth.permissions import permissions_for_context from app.auth.permissions import permissions_for_context
from app.domain.schemas.project_settings import ScadaMonitoringSettings from app.domain.schemas.project_settings import ScadaMonitoringSettings
@@ -439,7 +440,8 @@ class ScadaReplayService:
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id), alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
self.context, internal_producer_id=producer) self.context, internal_producer_id=producer)
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id), tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, permissions_for_context(self.context)) self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope)
for event in changes: for event in changes:
# Period diagnosis owns measurement notifications during manual steps. # Period diagnosis owns measurement notifications during manual steps.
# Retain rule events as evidence; quality alerts remain independent. # Retain rule events as evidence; quality alerts remain independent.
@@ -503,6 +505,7 @@ class ScadaReplayService:
tasks = OperationalTaskService( tasks = OperationalTaskService(
OperationalTaskRepository(self.repo.connection, self.context.project_id), OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, permissions_for_context(self.context), self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope,
) )
await tasks.advance_replay_investigation(UUID(incident["task_id"]), { await tasks.advance_replay_investigation(UUID(incident["task_id"]), {
"replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"], "replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"],
@@ -520,6 +523,7 @@ class ScadaReplayService:
OperationalTaskRepository(self.repo.connection, self.context.project_id), OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, self.context,
permissions_for_context(self.context), permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope,
) )
external = incident["external_id"] external = incident["external_id"]
pressure = monitoring["pressure"] pressure = monitoring["pressure"]
+78
View File
@@ -0,0 +1,78 @@
"""Publication-only validation for same-time-of-day line/scatter comparisons."""
from __future__ import annotations
import re
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import Any
from app.domain.domain_errors import require
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
DAY_SECONDS = 86400
def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
"""Validate new publications; persisted historical charts keep their read contract."""
chart_type = chart["chart_type"]
series = chart["series"]
kinds = {item.get("type") or chart_type for item in series}
if chart_type != "time_comparison":
require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID",
"曲线与历史散点复合图须使用 chart_type=time_comparison", status=422)
return
def check(condition: bool, message: str) -> None:
require(condition, "TIME_COMPARISON_INVALID", message, status=422)
kinds = {item.get("type") or "line" for item in series}
check(kinds == {"line", "scatter"},
"time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar")
labels = chart["x_data"]
check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label)
for label in labels),
"time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴")
check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度")
seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1)))
for label in labels]
offsets = [0]
for index in range(1, len(seconds)):
step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS
check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位")
offsets.append(offsets[-1] + step)
check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位")
current_bases: set[datetime] = set()
historical_bases: list[datetime] = []
for series_index, item in enumerate(series):
times = item.get("sample_times")
path = f"series[{series_index}]"
check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长")
bases: set[datetime] = set()
for index, (value, at) in enumerate(zip(item["data"], times, strict=True)):
check((value is None) == (at is None),
f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null")
if at is None:
continue
try:
local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE)
base = local - timedelta(seconds=offsets[index])
except OverflowError:
check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围")
label = labels[index]
expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M")
check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0),
f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐")
bases.add(base)
check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组")
if (item.get("type") or "line") == "line":
current_bases.update(bases)
else:
historical_bases.extend(bases)
check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
current = next(iter(current_bases))
for historical in historical_bases:
delta = (current - historical).total_seconds()
check(delta > 0 and delta % DAY_SECONDS == 0,
"历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
+2 -1
View File
@@ -310,7 +310,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "939f38733e943f21cc2e3d9f344fa8122ca7a7b35cfebf96ff3f00f469026ceb" "sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
} }
} }
} }
+14 -1
View File
@@ -4683,6 +4683,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -4846,6 +4851,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -5660,6 +5666,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -5773,6 +5784,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -10211,7 +10223,8 @@
"chart_type": { "chart_type": {
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
], ],
"title": "Chart Type", "title": "Chart Type",
"type": "string" "type": "string"
+2 -1
View File
@@ -211,7 +211,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+4
View File
@@ -55,6 +55,10 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。 `POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。
这条恢复链路同样跟踪带 `replay_context` 的调查工单。原异常快照的 `knowledge_cutoff` 和 `batch_sequence` 保留,普通回放查询继续受其限制;工单完工反馈单独触发 Server 的 `ScadaRepository.get_recovery_scada_readings()`,以实际完工业务时间为 T,读取严格晚于 T 的最近一帧。例如 2026-07-16 13:00 完工,15 分钟采样时复核 13:15,而非原异常截止的 08:00。Agent 从 `handling_context.verification_evidence_id` 读取保存的恢复证据,并与原异常证据共同引用,不需放开回放查询或重建复测工单。
已有调查在收到新的有效完工反馈时会补登记成功派发的关联工单。旧回报误用了电脑当前时间时,需在来源工单更正现场反馈业务时间并提交新版本;系统不猜测完工时刻,也不改写原报告或旧证据。
## 验证 ## 验证
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。 检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
+24
View File
@@ -190,3 +190,27 @@ OPERATIONAL_TASKS_STORAGE_TEST=1 conda run -n server python -m pytest tests/inte
``` ```
第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。 第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
### 共用数据库时的执行范围
任务创建时,Server 将 `OPERATIONAL_TASK_EXECUTION_SCOPE`(留空则使用
`ENVIRONMENT`)写入 `workbench.operational_tasks.document.execution_scope`。
本地配置 `development`,线上配置 `production`;浏览器不提交此字段,
重试保留原范围。`execution_scope` 为必填字段,运行时代码不为缺失值兜底。
现有任务通过 `resources/sql/034_operational_task_execution_scope.sql` 一次性补为
`production`;已有明确范围的任务保持原值。此回填是历史任务的迁移策略,
不代表通过旧记录推断出了创建环境。
迁移需要与新版本切换协调执行:暂停任务写入,执行每个业务库的 SQL,
启动新版本并恢复任务写入。旧版本的严格模型不接受新增 JSON 字段,
新版本也拒绝未迁移数据;不能在旧版本仍服务任务请求时单独回填。
SQL 同时增加必填范围约束,不设置数据库默认值。回滚版本前需同步处理该约束
与 JSON 字段,不得直接切回旧镜像。
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
`403 EXECUTION_SCOPE_MISMATCH`。这属于配置隔离,不能替代服务凭据授权:
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
+2
View File
@@ -18,6 +18,8 @@
Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 和设备集合约束。新回放首次监测以只读一致快照冻结开始前 15 天历史,负序号静默基线按最多 2,000 行分块暂存为 `clean_prepared`;全部读取完成后,在一个 BizDB 短事务中把批次转为 `detected` 并原子发布基线与初始化标记,历史查询和定位对照才能读取。 Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 和设备集合约束。新回放首次监测以只读一致快照冻结开始前 15 天历史,负序号静默基线按最多 2,000 行分块暂存为 `clean_prepared`;全部读取完成后,在一个 BizDB 短事务中把批次转为 `detected` 并原子发布基线与初始化标记,历史查询和定位对照才能读取。
关联现场工单完工后的恢复复核采用独立证据链:Server 以现场反馈业务时间 T 读取项目时序库中严格晚于 T 的最近一帧及事件前对照,保存恢复监测证据后接续原调查。此路径不依赖回放推进,也不修改原异常快照的知识上限或批次;Agent 读取保存的恢复证据,不通过普通回放查询越界取数。详见[恢复复核流程](burst-response-workflow.md)。
读取各页期间不持有 BizDB 事务,内存只保留当前页和设备短历史。固定准备预算为 1,000,000 行、512 MiB 序列化批次,覆盖 500 设备十五天每 15 分钟一条所需的 720,000 行并留有余量。超限不保存超限页,状态明确返回 `BASELINE_BUDGET_EXCEEDED` 及中文原因;未发布批次不进入观测、监测或待处理批次。失败重试只清理当前项目、本轮回放的未发布基线,从新的一致源快照重新准备,已发布基线保持不变。 读取各页期间不持有 BizDB 事务,内存只保留当前页和设备短历史。固定准备预算为 1,000,000 行、512 MiB 序列化批次,覆盖 500 设备十五天每 15 分钟一条所需的 720,000 行并留有余量。超限不保存超限页,状态明确返回 `BASELINE_BUDGET_EXCEEDED` 及中文原因;未发布批次不进入观测、监测或待处理批次。失败重试只清理当前项目、本轮回放的未发布基线,从新的一致源快照重新准备,已发布基线保持不变。
旧回放没有冻结基线时明确拒绝回放前查询,不回落到实时 TSDB。爆管定位支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。[SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明证据限制。运行异常自动建立调查,数据质量异常仅建立告警;恢复时推进调查代次。真实模型推理与报告效果尚未联调。 旧回放没有冻结基线时明确拒绝回放前查询,不回落到实时 TSDB。爆管定位支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。[SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明证据限制。运行异常自动建立调查,数据质量异常仅建立告警;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
@@ -0,0 +1,26 @@
-- Run against each project business database during the coordinated Server cutover.
-- Historical tasks are explicitly assigned to production; retain existing scopes.
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';
UPDATE workbench.operational_tasks
SET document = jsonb_set(document, '{execution_scope}', '"production"'::jsonb)
WHERE NOT (document ? 'execution_scope');
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'workbench.operational_tasks'::regclass
AND conname = 'operational_tasks_execution_scope_required'
) THEN
ALTER TABLE workbench.operational_tasks
ADD CONSTRAINT operational_tasks_execution_scope_required CHECK (
(jsonb_typeof(document->'execution_scope') = 'string'
AND length(btrim(document->>'execution_scope')) > 0) IS TRUE
);
END IF;
END $$;
COMMIT;
@@ -2,7 +2,7 @@
name: scada-anomaly name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata: metadata:
version: "1.0.12" version: "1.0.13"
--- ---
# SCADA 数据异常调查 # SCADA 数据异常调查
@@ -33,7 +33,7 @@ metadata:
先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。 先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。
定位输入具备后继续推进定位。压力、流量均需发布按设备分图的七天历史复合对照,作为全量当前趋势总览之外的必要展示;不能因历史散点占用序列额度而遗漏其他设备的当前趋势。复合图采用 [t-3小时,t] 当前折线和前7天每天 t-30分钟、t-15分钟、t 的21个参考位置。15分钟数据用13槽,当前一个line、历史七个scatter;历史散点只填末3槽,其他为null。sample_times保留真实带时区时刻,坐标轴注明单位。前7天按t转换后的当地日期逐日回退,不包含当天;按实际采样周期保留缺口,按需翻页并报告实际参考点数。基线未冻结、读取失败或缺测时明确说明并保留已有三小时趋势,不能静默退化为前一天或相邻两个时刻。 定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。 影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
@@ -44,3 +44,9 @@ metadata:
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修列为后续待办。现场工单与设备操作遵守服务端动作权限,本轮分析完成与现场事件闭环分别说明。 报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修列为后续待办。现场工单与设备操作遵守服务端动作权限,本轮分析完成与现场事件闭环分别说明。
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。 需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
## 完工后的恢复复核
收到关联工单的completed反馈后,先读取get_task_context摘要中的handling_context,核对实际完工业务时间returned_at,不以工单创建、预约或电脑当前时间代替。Server通过现有恢复监测读取项目时序库中严格晚于returned_at的最近一帧及事件前15天同相位对照,独立保存恢复证据;例如13:00完工、15分钟采样时核对13:15。这条恢复链路适用于回放来源工单,不要求推进回放,原异常knowledge_cutoff与batch_sequence保持不变。
phase=waiting_work_order时等待现场完工;phase=waiting_scada时查看last_check,分别说明尚无后续采样、基线或测点缺项、实际仍异常,等待监测复核,不将原回放查询越界解释为恢复数据不存在。phase=verification_ready时,用get_task_evidence读取verification_evidence_id的完整恢复证据,比较实际样本、正常范围与现场回报,最终报告同时引用原异常全量证据与当前恢复证据。恢复数据已由Server保存,不用query_scada_readings越过原冻结上限重复查询,也不因该上限再建复测工单;确有独立现场工作时才提议新动作。完工时间修订后使用新返回的证据编号,旧证据仅作历史。正常读数不替代阀位、漏点照片等现场事实,报告分别说明监测恢复、现场待核验项与事件闭环。
+9 -1
View File
@@ -97,8 +97,13 @@ def test_off_grid_feedback_uses_first_later_sample_even_without_monitoring_inter
assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat() assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat()
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time(): @pytest.mark.parametrize("replay", [False, True])
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time(replay):
task = {"workflow": "burst_report"} task = {"workflow": "burst_report"}
if replay:
task["source"] = {"replay_context": {
"replay_id": str(uuid4()), "knowledge_cutoff": INCIDENT.isoformat(), "batch_sequence": 3,
}}
for order_id in ("one", "two"): for order_id in ("one", "two"):
track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded", track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded",
"receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}}) "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
@@ -120,6 +125,9 @@ def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_ti
assert task["handling_context"]["returned_at"] == corrected.isoformat() assert task["handling_context"]["returned_at"] == corrected.isoformat()
assert task["handling_context"]["phase"] == "waiting_scada" assert task["handling_context"]["phase"] == "waiting_scada"
assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"] assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"]
if replay:
assert task["source"]["replay_context"]["knowledge_cutoff"] == INCIDENT.isoformat()
assert task["source"]["replay_context"]["batch_sequence"] == 3
def test_field_feedback_time_requires_a_real_note_and_explicit_timezone(): def test_field_feedback_time_requires_a_real_note_and_explicit_timezone():
+154 -8
View File
@@ -235,6 +235,7 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
def _task_document(context, now, *, lease=None): def _task_document(context, now, *, lease=None):
task_id = uuid4() task_id = uuid4()
return OperationalTaskSummary( return OperationalTaskSummary(
execution_scope="production",
project_id=context.project_id, project_id=context.project_id,
task_id=task_id, task_id=task_id,
task_type="event_investigation", task_type="event_investigation",
@@ -436,16 +437,39 @@ def _active_run(task, now):
@pytest.mark.anyio @pytest.mark.anyio
async def test_run_claim_persists_agent_instance_ownership(): @pytest.mark.parametrize("task_scope,worker_scope,allowed", [
(None, "production", False),
(None, "development", False),
("production", "production", True),
("production", "development", False),
("development", "production", False),
("development", "development", True),
])
async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_scope, allowed):
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context() context = _context()
task = _task_document(context, now, lease=None) task = _task_document(context, now, lease=None)
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None) task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
if task_scope is None:
task.pop("execution_scope", None)
else:
task["execution_scope"] = task_scope
run = _active_run(task, now) run = _active_run(task, now)
run.update(status="queued", revision=1, agent_session_id=None, run.update(status="queued", revision=1, agent_session_id=None,
lease_expires_at=None, heartbeat_at=None, started_at=None) lease_expires_at=None, heartbeat_at=None, started_at=None)
repository = RunTaskRepository(task, run) repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now) service = OperationalTaskService(repository, context, set(), clock=lambda: now,
execution_scope=worker_scope)
if not allowed:
with pytest.raises(DomainError) as failure:
await service.claim_run(task["task_id"], run["run_id"], AgentRunCommand(
request_id=run["execution_request_id"], expected_revision=1,
source_session_id="session-1", agent_instance_id="agent-a"))
assert failure.value.code == "EXECUTION_SCOPE_MISMATCH"
assert repository.run["status"] == "queued"
assert repository.task["status"] == "queued"
return
claimed = await service.claim_run( claimed = await service.claim_run(
task["task_id"], run["run_id"], task["task_id"], run["run_id"],
@@ -593,9 +617,10 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id], command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id],
"chart": {"title": "压力", "chart_type": "line", "x_data": ["19:45", "20:00"], "chart": {"title": "压力", "chart_type": "time_comparison", "x_data": ["19:45", "20:00"],
"y_axis_name": "压力 m", "series": [ "y_axis_name": "压力 m", "series": [
{"name": "当前", "type": "line", "data": [27., 26.]}, {"name": "当前", "type": "line", "data": [27., 26.],
"sample_times": ["2026-10-10T19:45:00+08:00", "2026-10-10T20:00:00+08:00"]},
{"name": "前一天", "type": "scatter", "data": [None, 28.], {"name": "前一天", "type": "scatter", "data": [None, 28.],
"sample_times": [None, "2026-10-09T20:00:00+08:00"]}, "sample_times": [None, "2026-10-09T20:00:00+08:00"]},
]}}, ]}},
@@ -613,6 +638,65 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
assert repository.action["action_id"] == str(action.action_id) assert repository.action["action_id"] == str(action.action_id)
@pytest.mark.anyio
@pytest.mark.parametrize("destination", ["presentation", "report"])
@pytest.mark.parametrize("defect", [None, "legacy_type", "dates_on_axis", "missing_times", "wrong_slot", "future_day"])
async def test_comparison_chart_publication_is_validated_at_both_write_boundaries(destination, defect):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
origin="synthetic", data={"items": []}, truncated=False,
))
chart = {"title": "同期对照", "chart_type": "time_comparison", "x_data": ["08:00"], "series": [
{"name": "当前", "data": [25.], "sample_times": ["2026-10-10T08:00:00+08:00"]},
{"name": "历史", "type": "scatter", "data": [26.], "sample_times": ["2026-10-09T08:00:00+08:00"]},
]}
if defect == "legacy_type":
chart["chart_type"] = "line"
elif defect == "dates_on_axis":
chart["x_data"] = ["10-10 08:00"]
elif defect == "missing_times":
del chart["series"][1]["sample_times"]
elif defect == "wrong_slot":
chart["series"][1]["sample_times"] = ["2026-10-09T07:45:00+08:00"]
elif defect == "future_day":
chart["series"][1]["sample_times"] = ["2026-10-11T08:00:00+08:00"]
events_before = len(repository.events)
async def publish():
if destination == "presentation":
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": "comparison", "evidence_refs": [evidence.evidence_id],
"chart": chart},
))
else:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
**_current_report(), "evidence_refs": [evidence.evidence_id], "ui_blocks": [{
"evidence_refs": [evidence.evidence_id], "block": {
"version": "answer-ui@1", "block_id": "comparison", "fallback_text": "同期对照",
"spec": {"root": "chart", "elements": {"chart": {
"type": "Chart", "children": [], "props": {**chart, "source": "合成数据"},
}}},
},
}],
},
))
if defect is None:
await publish()
assert len(repository.events) > events_before
else:
with pytest.raises(DomainError) as error:
await publish()
assert error.value.code == "TIME_COMPARISON_INVALID"
assert len(repository.events) == events_before
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("operation", ["propose", "approve", "submit"]) @pytest.mark.parametrize("operation", ["propose", "approve", "submit"])
async def test_replay_cannot_propose_approve_or_submit_device_commands(operation): async def test_replay_cannot_propose_approve_or_submit_device_commands(operation):
@@ -1128,21 +1212,39 @@ def _seed_concluded_report(repository, now):
@pytest.mark.anyio @pytest.mark.anyio
async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(monkeypatch): @pytest.mark.parametrize("replay", [False, True])
async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(monkeypatch, replay):
from copy import deepcopy from copy import deepcopy
from app.domain.schemas.project_settings import ProjectSettings from app.domain.schemas.project_settings import ProjectSettings
from app.services.operational_recovery import reconcile_recoveries, track_work_order from app.services.operational_recovery import reconcile_recoveries, track_work_order
now = datetime(2026, 7, 16, 4, tzinfo=timezone.utc) now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
incident = now - timedelta(hours=4) incident = now - timedelta(hours=6)
returned = incident + timedelta(hours=1) returned = datetime.fromisoformat("2026-07-16T13:00:00+08:00")
context = _context() context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1)) task = _task_document(context, now, lease=now + timedelta(minutes=1))
task.update(workflow="burst_report", trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "爆管"}) task.update(workflow="burst_report", trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "爆管"})
if replay:
task["workflow"] = "scada_anomaly"
task["source"]["replay_context"] = {
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
}
repository = RunTaskRepository(task, _active_run(task, now)) repository = RunTaskRepository(task, _active_run(task, now))
repository.connection = object() repository.connection = object()
service = OperationalTaskService(repository, context, set(), clock=lambda: now) service = OperationalTaskService(repository, context, set(), clock=lambda: now)
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock()) monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
incident_evidence_id = None
if replay:
frozen = task["source"]["replay_context"]
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:frozen-incident", queried_at=now,
origin="synthetic", truncated=False,
data={"data": {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
"mode": "all_scada", "query_complete": True,
"replay_id": frozen["replay_id"], "batch_sequence": 3,
}}, "window": {"start": (incident - timedelta(hours=3)).isoformat(), "end": incident.isoformat()}},
))
incident_evidence_id = evidence.evidence_id
_seed_concluded_report(repository, now) _seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand( action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="隔离抢修与恢复供水", request_id=uuid4(), kind="create_work_order", reason="隔离抢修与恢复供水",
@@ -1178,6 +1280,11 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
reader.return_value = rows reader.return_value = rows
assert await reconcile_recoveries(service, timescale, settings) == 1 assert await reconcile_recoveries(service, timescale, settings) == 1
assert await reconcile_recoveries(service, timescale, settings) == 0 assert await reconcile_recoveries(service, timescale, settings) == 0
reader.assert_awaited_with(timescale, ["P1"], incident, returned, now, baseline_limit=100_001)
if replay:
assert repository.task["source"]["replay_context"]["replay_id"] == task["source"]["replay_context"]["replay_id"]
assert datetime.fromisoformat(repository.task["source"]["replay_context"]["knowledge_cutoff"]) == incident
assert repository.task["source"]["replay_context"]["batch_sequence"] == 3
recovery = repository.task["handling_context"] recovery = repository.task["handling_context"]
evidence_id = recovery["verification_evidence_id"] evidence_id = recovery["verification_evidence_id"]
assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed" assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed"
@@ -1208,6 +1315,13 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert rejected.value.code == "RECOVERY_EVIDENCE_REQUIRED" assert rejected.value.code == "RECOVERY_EVIDENCE_REQUIRED"
if replay:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
report={**_current_report(), "evidence_refs": [incident_evidence_id, evidence_id]},
))
assert repository.reports[-1]["report"]["evidence_refs"] == [incident_evidence_id, evidence_id]
else:
repository.reports[-1]["report"]["evidence_refs"] = [evidence_id] repository.reports[-1]["report"]["evidence_refs"] = [evidence_id]
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
@@ -2341,3 +2455,35 @@ async def test_recovery_resolves_alerts_with_original_facts_and_stable_event_id(
assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1") assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1")
assert command.source.model_dump(mode="json") == alert["source"] assert command.source.model_dump(mode="json") == alert["source"]
assert command.title == alert["title"] and command.description == alert["description"] assert command.title == alert["title"] and command.description == alert["description"]
@pytest.mark.anyio
@pytest.mark.parametrize("scope", ["production", "development"])
async def test_task_creation_persists_server_execution_scope(monkeypatch, scope):
from app.domain.schemas.operational_task import OperationalTaskCreate
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
context = _context()
document = _task_document(context, now)
repository = TaskRepository(document)
repository.insert_task = AsyncMock()
repository.insert_run = AsyncMock()
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
execution_scope=scope)
monkeypatch.setattr(service, "_task_event", AsyncMock())
monkeypatch.setattr(service, "_run_event", AsyncMock())
monkeypatch.setattr(service, "detail", AsyncMock())
command = OperationalTaskCreate(request_id=uuid4(), **{
key: document[key] for key in ("task_type", "workflow", "title", "summary", "source", "trigger")
})
await service.create(command)
assert repository.insert_task.await_args.args[0]["execution_scope"] == scope
def test_task_scope_is_required_in_persisted_contract():
task = _task_document(_context(), datetime(2026, 11, 8, tzinfo=timezone.utc))
task.pop("execution_scope")
with pytest.raises(ValidationError) as error:
OperationalTaskSummary.model_validate(task)
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
for item in error.value.errors())
+7 -7
View File
@@ -579,7 +579,7 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr( monkeypatch.setattr(
"app.services.operational_tasks.OperationalTaskService", "app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task), lambda *_, **_kwargs: SimpleNamespace(create=create_task),
) )
monitoring = { monitoring = {
"status": "suspected_burst", "status": "suspected_burst",
@@ -618,7 +618,7 @@ def test_monitoring_summary_counts_signals_without_copying_reading_details(monke
_, service, *_ = setup(monkeypatch) _, service, *_ = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
monitoring = incident_monitoring(0) monitoring = incident_monitoring(0)
monitoring["anomaly_signals"] = [ monitoring["anomaly_signals"] = [
{"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"} {"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"}
@@ -641,7 +641,7 @@ def test_continuing_burst_reuses_task_across_persisted_periods(monkeypatch):
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task)) lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task))
document = {"replay_id": str(uuid4())} document = {"replay_id": str(uuid4())}
async def run(): async def run():
@@ -683,7 +683,7 @@ def test_incident_recovers_once_then_allows_new_accident(monkeypatch):
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task)) lambda *_, **_kwargs: SimpleNamespace(create=create_task))
document = {"replay_id": str(uuid4())} document = {"replay_id": str(uuid4())}
async def run(): async def run():
@@ -784,7 +784,7 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push)) monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=AsyncMock())) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=AsyncMock()))
def event(rule, category=None): def event(rule, category=None):
return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(), return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(),
"detail": "检测信号", "category": category, "task_id": None} "detail": "检测信号", "category": category, "task_id": None}
@@ -812,7 +812,7 @@ def test_failed_incident_publication_rolls_back_state_and_retries_same_task_requ
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence): def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence):
return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(), return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(),
"knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence} "knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence}
@@ -843,7 +843,7 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
_, service, *_ = setup(monkeypatch) _, service, *_ = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
monitoring = incident_monitoring(0, status="attention") monitoring = incident_monitoring(0, status="attention")
for key in ("pressure", "outlet_flow", "burst_detection"): for key in ("pressure", "outlet_flow", "burst_detection"):
monitoring[key]["hit"] = key == indicator monitoring[key]["hit"] = key == indicator
+105
View File
@@ -0,0 +1,105 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import TaskSafeChart
from app.services.time_comparison_chart import validate_time_comparison_chart
def comparison_chart(start="2026-07-16T05:00:00+08:00", interval=15):
times = [datetime.fromisoformat(start) + timedelta(minutes=index * interval) for index in range(13)]
return {"title": "七天同期对比", "chart_type": "time_comparison",
"x_data": [at.strftime("%H:%M") for at in times], "series": [
{"name": "当前", "type": "line", "data": [25.] * 13,
"sample_times": [at.isoformat() for at in times]},
*[{"name": f"前{day}天", "type": "scatter", "data": [None] * 10 + [26.] * 3,
"sample_times": [None] * 10 + [(at - timedelta(days=day)).isoformat() for at in times[10:]]}
for day in range(1, 8)],
]}
@pytest.mark.parametrize("start,interval", [
("2026-07-16T05:00:00+08:00", 15),
("2026-07-16T23:00:00+08:00", 15),
("2026-07-16T05:00:00+08:00", 5),
])
def test_same_time_comparison_preserves_seven_sparse_days_and_sampling_intervals(start, interval):
chart = comparison_chart(start, interval)
# A missing current observation is still a real empty slot.
chart["series"][0]["data"][11] = None
chart["series"][0]["sample_times"][11] = None
# UTC and local-offset spellings refer to the same real historical instant.
chart["series"][1]["sample_times"][12] = (
datetime.fromisoformat(chart["series"][1]["sample_times"][12])
.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
)
before = deepcopy(chart)
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert chart == before
@pytest.mark.parametrize("defect", [
"appended_history", "duplicate_slots", "wrong_slot", "missing_times", "missing_point_time",
"mixed_dates", "same_day", "future_day", "bar_series", "only_line", "no_current_observations",
])
def test_special_comparison_rejects_misleading_projections(defect):
chart = comparison_chart()
history = chart["series"][1]
if defect == "appended_history":
chart["x_data"] = [f"07-16 {slot}" for slot in chart["x_data"]] + ["07-15 07:30", "07-15 07:45", "07-15 08:00"]
for series in chart["series"]:
series["data"] += [None] * 3
series["sample_times"] += [None] * 3
elif defect == "duplicate_slots":
chart["x_data"][11] = chart["x_data"][10]
elif defect == "wrong_slot":
history["sample_times"][10] = "2026-07-15T07:45:00+08:00"
elif defect == "missing_times":
del history["sample_times"]
elif defect == "missing_point_time":
history["sample_times"][10] = None
elif defect == "mixed_dates":
history["sample_times"][10] = "2026-07-14T07:30:00+08:00"
elif defect in {"same_day", "future_day"}:
day = 16 if defect == "same_day" else 17
history["sample_times"] = [at.replace("07-15", f"07-{day}") if at else None for at in history["sample_times"]]
elif defect == "bar_series":
history["type"] = "bar"
elif defect == "only_line":
chart["series"] = chart["series"][:1]
elif defect == "no_current_observations":
chart["series"][0]["data"] = chart["series"][0]["sample_times"] = [None] * 13
with pytest.raises(DomainError) as error:
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert error.value.code == "TIME_COMPARISON_INVALID"
def test_ordinary_charts_skip_comparison_validation_and_old_mixed_charts_remain_readable():
chart = {"title": "普通趋势", "chart_type": "line", "x_data": ["07-16 08:00"],
"series": [{"name": "当前", "data": [25.]}]}
validate_time_comparison_chart(chart)
chart["chart_type"] = "bar"
validate_time_comparison_chart(chart)
chart["chart_type"] = "line"
chart["series"].append({"name": "旧版散点", "type": "scatter", "data": [26.]})
assert TaskSafeChart.model_validate(chart).model_dump(mode="json") == chart
with pytest.raises(DomainError, match="time_comparison"):
validate_time_comparison_chart(chart)
@pytest.mark.parametrize("labels,times", [
(["07:45"], ["9999-12-31T23:45:00Z"]),
(["08:00"], ["0001-01-01T00:00:00+23:59"]),
(["23:45", "00:15"], [None, "0001-01-01T00:15:00+08:00"]),
])
def test_calendar_range_overflow_returns_a_domain_rejection(labels, times):
chart = {"title": "日期边界", "chart_type": "time_comparison", "x_data": labels, "series": [
{"name": "当前", "type": "line", "data": [1. if at else None for at in times], "sample_times": times},
{"name": "历史缺测", "type": "scatter", "data": [None] * len(labels), "sample_times": [None] * len(labels)},
]}
with pytest.raises(DomainError, match="日期范围") as error:
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert error.value.code == "TIME_COMPARISON_INVALID"
assert error.value.status == 422