Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47c61c121 | ||
|
|
6a4478bfe4 | ||
|
|
5197a93163 | ||
|
|
549019507e | ||
|
|
acc8f8f7cb | ||
|
|
b8359ba83c | ||
|
|
c0dea2aacf | ||
|
|
33df25f22b | ||
|
|
2a7b96f813 |
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -3,6 +3,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from app.auth.project_dependencies import ProjectContext
|
from app.auth.project_dependencies import ProjectContext
|
||||||
from app.infra.db.project_routing import ActiveProjectRouting
|
from app.infra.db.project_routing import ActiveProjectRouting
|
||||||
@@ -18,16 +20,24 @@ from app.auth.project_dependencies import (
|
|||||||
get_project_timescale_connection,
|
get_project_timescale_connection,
|
||||||
)
|
)
|
||||||
from app.infra.db.metadb.database import get_metadata_session
|
from app.infra.db.metadb.database import get_metadata_session
|
||||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
|
||||||
from app.infra.db.project_routing import activate_project_routing
|
from app.infra.db.project_routing import activate_project_routing
|
||||||
|
|
||||||
|
|
||||||
class QueryMetadataRepository(MetadataRepository):
|
class QueryMetadataRepository(MetadataRepository):
|
||||||
async def get_project_db_routing(self, project_id, role):
|
def __init__(self, session: AsyncSession):
|
||||||
|
super().__init__(session)
|
||||||
|
self._routing: dict[tuple[UUID, str], ProjectDbRouting | None] = {}
|
||||||
|
|
||||||
|
async def get_project_db_routing(self, project_id: UUID, role: str) -> ProjectDbRouting | None:
|
||||||
|
key = (project_id, role)
|
||||||
|
if key in self._routing:
|
||||||
|
return self._routing[key]
|
||||||
routing = await super().get_project_db_routing(project_id, role)
|
routing = await super().get_project_db_routing(project_id, role)
|
||||||
# Existing connection generators obtain routing before awaiting a pool.
|
# Existing connection generators obtain routing before awaiting a pool.
|
||||||
# End that implicit SELECT transaction here, before the network wait.
|
# End that implicit SELECT transaction here, before the network wait.
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
self._routing[key] = routing
|
||||||
return routing
|
return routing
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -405,25 +405,6 @@ def _store_burst_scheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_scada_windows(
|
|
||||||
*,
|
|
||||||
scada_burst_start: datetime | str | None,
|
|
||||||
scada_burst_end: datetime | str | None,
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
values = [scada_burst_start, scada_burst_end]
|
|
||||||
if any(v is None for v in values):
|
|
||||||
raise ValueError(
|
|
||||||
"使用后端 SCADA 查询时,必须同时提供 scada_burst_start/scada_burst_end。"
|
|
||||||
)
|
|
||||||
burst_start_dt = _to_datetime(scada_burst_start)
|
|
||||||
burst_end_dt = _to_datetime(scada_burst_end)
|
|
||||||
if burst_start_dt >= burst_end_dt:
|
|
||||||
raise ValueError(
|
|
||||||
"爆管时段 SCADA 时间窗非法:scada_burst_start 必须早于 scada_burst_end。"
|
|
||||||
)
|
|
||||||
return burst_start_dt, burst_end_dt
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_time_window(
|
def _validate_time_window(
|
||||||
*,
|
*,
|
||||||
start_value: datetime | str | None,
|
start_value: datetime | str | None,
|
||||||
|
|||||||
@@ -188,74 +188,6 @@ def _build_area_map_by_topology(
|
|||||||
return area_map, areas, node_coords
|
return area_map, areas, node_coords
|
||||||
|
|
||||||
|
|
||||||
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
|
|
||||||
area_node_map: dict[str, list[str]] = {}
|
|
||||||
for node_id, area_id in area_map.items():
|
|
||||||
area_node_map.setdefault(area_id, []).append(node_id)
|
|
||||||
for area_id in list(area_node_map.keys()):
|
|
||||||
area_node_map[area_id] = sorted(area_node_map[area_id])
|
|
||||||
return area_node_map
|
|
||||||
|
|
||||||
|
|
||||||
def _build_node_visual_payload(
|
|
||||||
area_map: dict[str, str],
|
|
||||||
node_coords: dict[str, dict[str, float]],
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
area_leakage_map = _build_area_leakage_map(rows)
|
|
||||||
max_leakage = max(area_leakage_map.values(), default=0.0)
|
|
||||||
features: list[dict[str, Any]] = []
|
|
||||||
for node_id, area_id in area_map.items():
|
|
||||||
coord = node_coords.get(node_id)
|
|
||||||
if not coord:
|
|
||||||
continue
|
|
||||||
leakage_flow = float(area_leakage_map.get(area_id, 0.0))
|
|
||||||
leakage_level = _classify_leakage_level(leakage_flow, max_leakage)
|
|
||||||
features.append(
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"properties": {
|
|
||||||
"node_id": node_id,
|
|
||||||
"area_id": area_id,
|
|
||||||
"leakage_flow_m3_per_s": leakage_flow,
|
|
||||||
"leakage_level": leakage_level,
|
|
||||||
},
|
|
||||||
"geometry": {
|
|
||||||
"type": "Point",
|
|
||||||
"coordinates": [float(coord["x"]), float(coord["y"])],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"type": "FeatureCollection", "features": features}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_area_leakage_map(rows: list[dict[str, Any]]) -> dict[str, float]:
|
|
||||||
area_leakage_map: dict[str, float] = {}
|
|
||||||
for row in rows:
|
|
||||||
area_id = str(row.get("Area", "")).strip()
|
|
||||||
if not area_id:
|
|
||||||
continue
|
|
||||||
area_leakage_map[area_id] = float(row.get("LeakageFlow_m3_per_s", 0.0))
|
|
||||||
return area_leakage_map
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_leakage_level(leakage_flow: float, max_leakage: float) -> str:
|
|
||||||
if max_leakage <= 0:
|
|
||||||
return "normal"
|
|
||||||
ratio = leakage_flow / max_leakage
|
|
||||||
if ratio >= 0.75:
|
|
||||||
return "high"
|
|
||||||
if ratio >= 0.4:
|
|
||||||
return "medium"
|
|
||||||
if ratio > 0:
|
|
||||||
return "low"
|
|
||||||
return "normal"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_drawing_payload(node_visual_payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return node_visual_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _build_observed_pressure_from_scada(
|
def _build_observed_pressure_from_scada(
|
||||||
network: str,
|
network: str,
|
||||||
sensor_nodes: list[str],
|
sensor_nodes: list[str],
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||||
|
|
||||||
from app.auth.permissions import (
|
from app.auth.permissions import (
|
||||||
@@ -74,6 +74,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
|
||||||
|
|
||||||
LEASE_DURATION = timedelta(seconds=60)
|
LEASE_DURATION = timedelta(seconds=60)
|
||||||
@@ -88,11 +93,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: OperationalTaskRepository, 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()
|
||||||
@@ -111,42 +118,14 @@ class OperationalTaskService:
|
|||||||
require(owner == command.agent_instance_id,
|
require(owner == command.agent_instance_id,
|
||||||
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||||
|
|
||||||
@property
|
|
||||||
def _supports_runs(self) -> bool:
|
|
||||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
|
||||||
|
|
||||||
async def _active_run(self, task: dict) -> dict | None:
|
async def _active_run(self, task: dict) -> dict | None:
|
||||||
if self._supports_runs:
|
|
||||||
return await self.repo.get_active_run(str(task["task_id"]))
|
return await self.repo.get_active_run(str(task["task_id"]))
|
||||||
if task.get("status") not in {"queued", "running"}:
|
|
||||||
return None
|
|
||||||
now = self._now()
|
|
||||||
return _json(OperationalTaskRun(
|
|
||||||
run_id=uuid5(NAMESPACE_URL, f"legacy-run:{task['task_id']}:{task['execution_generation']}"),
|
|
||||||
task_id=task["task_id"],
|
|
||||||
generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"],
|
|
||||||
status="running" if task["status"] == "running" else "queued",
|
|
||||||
revision=task["revision"],
|
|
||||||
agent_session_id=task.get("agent_session_id"),
|
|
||||||
lease_expires_at=task.get("agent_lease_expires_at"),
|
|
||||||
heartbeat_at=task.get("updated_at") if task["status"] == "running" else None,
|
|
||||||
started_at=task.get("created_at") if task["status"] == "running" else None,
|
|
||||||
dispatch_attempts=0,
|
|
||||||
next_dispatch_at=task.get("created_at") or now,
|
|
||||||
created_at=task.get("created_at") or now,
|
|
||||||
updated_at=task.get("updated_at") or now,
|
|
||||||
))
|
|
||||||
|
|
||||||
async def _append_event(self, **kwargs) -> int | None:
|
async def _append_event(self, **kwargs) -> int:
|
||||||
append_event = getattr(self.repo, "append_event", None)
|
return await self.repo.append_event(**kwargs)
|
||||||
return await append_event(**kwargs) if append_event is not None else None
|
|
||||||
|
|
||||||
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
||||||
list_access_requests = getattr(self.repo, "list_access_requests", None)
|
return await self.repo.list_access_requests(str(task_id))
|
||||||
if list_access_requests is None:
|
|
||||||
return []
|
|
||||||
return await list_access_requests(str(task_id))
|
|
||||||
|
|
||||||
def _permission_view(self) -> OperationalTaskPermissions:
|
def _permission_view(self) -> OperationalTaskPermissions:
|
||||||
permissions = self.permissions
|
permissions = self.permissions
|
||||||
@@ -195,6 +174,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,
|
||||||
@@ -282,8 +262,7 @@ class OperationalTaskService:
|
|||||||
actions = await self.repo.list_actions(str(task_id))
|
actions = await self.repo.list_actions(str(task_id))
|
||||||
access_requests = await self._list_access_requests(task_id)
|
access_requests = await self._list_access_requests(task_id)
|
||||||
active_run = await self._active_run(task)
|
active_run = await self._active_run(task)
|
||||||
latest_run = (await self.repo.get_latest_run(str(task_id))
|
latest_run = await self.repo.get_latest_run(str(task_id))
|
||||||
if hasattr(self.repo, "get_latest_run") else active_run)
|
|
||||||
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
||||||
work_order_snapshot = None
|
work_order_snapshot = None
|
||||||
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
||||||
@@ -393,9 +372,8 @@ class OperationalTaskService:
|
|||||||
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 TaskAccessRequest.model_validate(replay["access_request"])
|
return TaskAccessRequest.model_validate(replay["access_request"])
|
||||||
availability = getattr(self.repo, "access_requests_available", None)
|
|
||||||
require(
|
require(
|
||||||
availability is None or await availability(),
|
await self.repo.access_requests_available(),
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"Task data access requests are not enabled for this project database",
|
"Task data access requests are not enabled for this project database",
|
||||||
status=503,
|
status=503,
|
||||||
@@ -489,6 +467,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 +489,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"])
|
||||||
@@ -608,7 +590,6 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
||||||
|
|
||||||
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
||||||
if hasattr(self.repo, "fail_run_messages"):
|
|
||||||
await self.repo.fail_run_messages(str(run_id), reason, now)
|
await self.repo.fail_run_messages(str(run_id), reason, now)
|
||||||
|
|
||||||
async def fail_run(
|
async def fail_run(
|
||||||
@@ -759,11 +740,12 @@ 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
|
||||||
# message/finish transactions; a busy run is checked next tick.
|
# message/finish transactions; a busy run is checked next tick.
|
||||||
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
|
if not await self.repo.try_lock(f"run:{run['run_id']}"):
|
||||||
continue
|
continue
|
||||||
task_id = UUID(str(run["task_id"]))
|
task_id = UUID(str(run["task_id"]))
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -775,6 +757,7 @@ class OperationalTaskService:
|
|||||||
timed_out = OperationalTaskRun.model_validate(run)
|
timed_out = OperationalTaskRun.model_validate(run)
|
||||||
await self.repo.update_run(_json(timed_out), expected)
|
await self.repo.update_run(_json(timed_out), expected)
|
||||||
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
await self._run_event(timed_out, now)
|
await self._run_event(timed_out, now)
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
||||||
@@ -829,6 +812,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"
|
||||||
):
|
):
|
||||||
@@ -1003,25 +991,9 @@ class OperationalTaskService:
|
|||||||
lease_expires_at=now + LEASE_DURATION,
|
lease_expires_at=now + LEASE_DURATION,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
if self._supports_runs:
|
|
||||||
await self.repo.update_run(
|
await self.repo.update_run(
|
||||||
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Compatibility for pre-015 repository adapters only.
|
|
||||||
if command.report:
|
|
||||||
if task["task_type"] == "event_investigation":
|
|
||||||
task["status"] = self._investigation_status(actions.values(), task) if command.report.stage == "final" else "running"
|
|
||||||
else:
|
|
||||||
task["status"] = self._status_from_plan(current_plan.plan.nodes, actions.values()) if current_plan else "running"
|
|
||||||
if task["status"] in {"waiting_human", "completed", "failed", "cancelled"}:
|
|
||||||
task["lifecycle_status"] = task["status"]
|
|
||||||
task["agent_lease_expires_at"] = (
|
|
||||||
now + LEASE_DURATION if task["status"] == "running" else None
|
|
||||||
)
|
|
||||||
await self.repo.update_task(
|
|
||||||
_task_json(OperationalTaskSummary.model_validate(task))
|
|
||||||
)
|
|
||||||
await self._task_event(task, now)
|
await self._task_event(task, now)
|
||||||
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
||||||
if progress_revision is not None:
|
if progress_revision is not None:
|
||||||
@@ -1087,6 +1059,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":
|
||||||
@@ -1498,11 +1471,13 @@ class OperationalTaskService:
|
|||||||
body = _json(command)
|
body = _json(command)
|
||||||
scope = f"task:{task_id}:external-feedback"
|
scope = f"task:{task_id}:external-feedback"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
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 ExternalFeedbackResult(task=await self.detail(task_id), **replay)
|
return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
|
||||||
await self.repo.lock(f"task:{task_id}")
|
|
||||||
task = await self._task(task_id)
|
|
||||||
if task["task_type"] == "event_investigation":
|
if task["task_type"] == "event_investigation":
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
own_source = task["source"].get("external_id")
|
own_source = task["source"].get("external_id")
|
||||||
@@ -1659,11 +1634,13 @@ class OperationalTaskService:
|
|||||||
body = _json(command)
|
body = _json(command)
|
||||||
scope = f"task:{task_id}:withdraw-external-source"
|
scope = f"task:{task_id}:withdraw-external-source"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
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 await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
await self.repo.lock(f"task:{task_id}")
|
|
||||||
task = await self._task(task_id)
|
|
||||||
source = task["source"]
|
source = task["source"]
|
||||||
require(
|
require(
|
||||||
task["task_type"] == "external_work_order"
|
task["task_type"] == "external_work_order"
|
||||||
@@ -1969,7 +1946,6 @@ class OperationalTaskService:
|
|||||||
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
||||||
|
|
||||||
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
||||||
if hasattr(self.repo, "pending_calculation_jobs"):
|
|
||||||
pending = await self.repo.pending_calculation_jobs(
|
pending = await self.repo.pending_calculation_jobs(
|
||||||
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
||||||
)
|
)
|
||||||
@@ -2005,7 +1981,6 @@ class OperationalTaskService:
|
|||||||
actions = []
|
actions = []
|
||||||
if outcome == "succeeded":
|
if outcome == "succeeded":
|
||||||
await self._require_no_pending_calculations(task_id, run)
|
await self._require_no_pending_calculations(task_id, run)
|
||||||
if hasattr(self.repo, "pending_run_messages"):
|
|
||||||
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
||||||
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
||||||
"Run has messages not yet entered into Agent context",
|
"Run has messages not yet entered into Agent context",
|
||||||
@@ -2139,8 +2114,6 @@ class OperationalTaskService:
|
|||||||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||||
from app.services.task_alerts import TaskAlertService
|
from app.services.task_alerts import TaskAlertService
|
||||||
|
|
||||||
if not hasattr(self.repo, "list_investigation_alerts"):
|
|
||||||
return
|
|
||||||
for alert in await self.repo.list_investigation_alerts(task_id):
|
for alert in await self.repo.list_investigation_alerts(task_id):
|
||||||
command = TaskAlertEvent.model_validate({
|
command = TaskAlertEvent.model_validate({
|
||||||
**{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}},
|
**{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}},
|
||||||
@@ -2345,7 +2318,6 @@ class OperationalTaskService:
|
|||||||
|
|
||||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||||
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||||
if self._supports_runs:
|
|
||||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
while active_run is not None:
|
while active_run is not None:
|
||||||
await self.repo.lock(f"run:{active_run['run_id']}")
|
await self.repo.lock(f"run:{active_run['run_id']}")
|
||||||
@@ -2390,7 +2362,6 @@ class OperationalTaskService:
|
|||||||
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
||||||
)
|
)
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
if self._supports_runs:
|
|
||||||
await self.repo.insert_run(_json(run))
|
await self.repo.insert_run(_json(run))
|
||||||
await self._run_event(run, now, queued=True)
|
await self._run_event(run, now, queued=True)
|
||||||
|
|
||||||
@@ -2428,10 +2399,7 @@ class OperationalTaskService:
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
reason: str,
|
reason: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
resolve = getattr(self.repo, "resolve_notifications", None)
|
notifications = await self.repo.resolve_notifications(
|
||||||
if resolve is None:
|
|
||||||
return
|
|
||||||
notifications = await resolve(
|
|
||||||
str(task_id),
|
str(task_id),
|
||||||
kinds=kinds,
|
kinds=kinds,
|
||||||
subject_type=subject_type,
|
subject_type=subject_type,
|
||||||
@@ -2516,6 +2484,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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||||
# Legacy test repositories do not implement question persistence.
|
return await service.repo.list_questions(str(task_id))
|
||||||
reader = getattr(service.repo, "list_questions", None)
|
|
||||||
return await reader(str(task_id)) if reader else []
|
|
||||||
|
|
||||||
|
|
||||||
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||||
|
|||||||
@@ -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 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
PipeHealthSurvivalPredictor,
|
PipeHealthSurvivalPredictor,
|
||||||
@@ -15,14 +14,11 @@ from app.algorithms.scada_cleaning.pressure_series import clean_pressure_data_df
|
|||||||
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
class TimeseriesAnalysisService:
|
class TimeseriesAnalysisService:
|
||||||
"""
|
"""SCADA 清洗与管道健康预测;历史查询由 TimeseriesHistoryService 负责。"""
|
||||||
复合查询类,提供跨表查询功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_project_scada_index(
|
async def _get_project_scada_index(
|
||||||
@@ -31,294 +27,6 @@ class TimeseriesAnalysisService:
|
|||||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||||
return {scada["device_id"]: scada for scada in scadas}
|
return {scada["device_id"]: scada for scada in scadas}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(link_devices.values())), "flow",
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(node_devices.values())), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link",
|
|
||||||
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node",
|
|
||||||
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(junction_ids)), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
run_id: 分析运行 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当类型无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
|
|
||||||
start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
|
|
||||||
start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_element_associated_scada_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
element_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
use_cleaned: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
获取 link/node 关联的 SCADA 监测值
|
|
||||||
|
|
||||||
根据传入的 link/node id,匹配 SCADA 信息,
|
|
||||||
如果存在关联的 SCADA device_id,获取实际的监测数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
element_id: link 或 node 的 ID
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
use_cleaned: 是否使用清洗后的数据 (True: "cleaned_value", False: "monitored_value")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SCADA 监测数据值,如果没有找到则返回 None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当元素类型无效时
|
|
||||||
"""
|
|
||||||
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
associated_scada = next(
|
|
||||||
(
|
|
||||||
scada
|
|
||||||
for scada in scada_by_id.values()
|
|
||||||
if (scada.get("node_id") or scada.get("link_id")) == element_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not associated_scada:
|
|
||||||
return None
|
|
||||||
|
|
||||||
device_id = associated_scada["device_id"]
|
|
||||||
|
|
||||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
|
||||||
|
|
||||||
res = await ScadaRepository.get_scada_field_by_id_time_range(
|
|
||||||
timescale_conn, [device_id], start_time, end_time, data_field
|
|
||||||
)
|
|
||||||
|
|
||||||
return {element_id: res.get(device_id, [])}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clean_scada_data(
|
async def clean_scada_data(
|
||||||
timescale_conn: AsyncConnection,
|
timescale_conn: AsyncConnection,
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ def _points(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _history_series(
|
||||||
|
*, element_type: HistoryElementType, element_id: str,
|
||||||
|
source: HistorySource, source_unit: str, device_id: str | None = None,
|
||||||
|
unit_inferred: bool = False, points: list[HistoryPoint] | None = None,
|
||||||
|
) -> ElementHistorySeries:
|
||||||
|
metric = _target_metric(element_type)
|
||||||
|
return ElementHistorySeries(
|
||||||
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
|
metric=metric, source=source, source_unit=source_unit,
|
||||||
|
display_unit=display_unit_for_metric(metric.value),
|
||||||
|
unit_inferred=unit_inferred, points=points if points is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_result_units(
|
||||||
|
postgres_conn: AsyncConnection, query: ElementHistoryQuery,
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
||||||
|
if query.mode.value != "analysis_comparison":
|
||||||
|
return units, False
|
||||||
|
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
||||||
|
if run is None:
|
||||||
|
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
||||||
|
recorded = (run.get("parameters") or {}).get("result_units")
|
||||||
|
return (recorded, False) if isinstance(recorded, dict) else (units, True)
|
||||||
|
|
||||||
|
|
||||||
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
||||||
descriptors = []
|
descriptors = []
|
||||||
series = []
|
series = []
|
||||||
@@ -66,16 +93,14 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
"source": source, "element_type": element_type,
|
"source": source, "element_type": element_type,
|
||||||
"id": device_id if source == "scada" else element_id, "field": field,
|
"id": device_id if source == "scada" else element_id, "field": field,
|
||||||
})
|
})
|
||||||
metric = _target_metric(element_type)
|
series.append(_history_series(
|
||||||
series.append(ElementHistorySeries(
|
|
||||||
element_id=element_id, element_type=element_type, device_id=device_id,
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
metric=metric, source=(
|
source=(
|
||||||
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
||||||
) if source == "scada" else (
|
) if source == "scada" else (
|
||||||
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
||||||
),
|
),
|
||||||
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
|
source_unit=unit, unit_inferred=unit_inferred,
|
||||||
unit_inferred=unit_inferred, points=[],
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
||||||
@@ -84,17 +109,8 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
add(source="scada", element_type=element_type, element_id=element_id,
|
add(source="scada", element_type=element_type, element_id=element_id,
|
||||||
device_id=device_id, field=field, unit=device["measurement_unit"])
|
device_id=device_id, field=field, unit=device["measurement_unit"])
|
||||||
if query.mode.value != "observed":
|
if query.mode.value != "observed":
|
||||||
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
units, inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
||||||
inferred = False
|
|
||||||
if source == "analysis":
|
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
recorded = (run.get("parameters") or {}).get("result_units")
|
|
||||||
inferred = not isinstance(recorded, dict)
|
|
||||||
if not inferred:
|
|
||||||
units = recorded
|
|
||||||
targets = {(t.element_type, t.element_id) for t in query.elements}
|
targets = {(t.element_type, t.element_id) for t in query.elements}
|
||||||
for element_type, element_id in sorted(targets):
|
for element_type, element_id in sorted(targets):
|
||||||
metric = _target_metric(element_type)
|
metric = _target_metric(element_type)
|
||||||
@@ -202,21 +218,18 @@ class TimeseriesHistoryService:
|
|||||||
rows = scada_by_device.get(device_id, [])
|
rows = scada_by_device.get(device_id, [])
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
metric = _target_metric(element_type)
|
|
||||||
unit = device["measurement_unit"]
|
unit = device["measurement_unit"]
|
||||||
for source, field in (
|
for source, field in (
|
||||||
(HistorySource.SCADA_RAW, "monitored_value"),
|
(HistorySource.SCADA_RAW, "monitored_value"),
|
||||||
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
||||||
):
|
):
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=element_id,
|
element_id=element_id,
|
||||||
element_type=element_type,
|
element_type=element_type,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=unit,
|
source_unit=unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
value_field=field,
|
value_field=field,
|
||||||
@@ -227,15 +240,8 @@ class TimeseriesHistoryService:
|
|||||||
if query.mode.value == "observed":
|
if query.mode.value == "observed":
|
||||||
return ElementHistoryResponse(series=series)
|
return ElementHistoryResponse(series=series)
|
||||||
|
|
||||||
current_units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
result_units, unit_inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
if query.mode.value == "analysis_comparison":
|
if query.mode.value == "analysis_comparison":
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
parameters = run.get("parameters") or {}
|
|
||||||
recorded_units = parameters.get("result_units")
|
|
||||||
unit_inferred = not isinstance(recorded_units, dict)
|
|
||||||
result_units = recorded_units if not unit_inferred else current_units
|
|
||||||
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.run_id,
|
query.run_id,
|
||||||
@@ -256,8 +262,6 @@ class TimeseriesHistoryService:
|
|||||||
)
|
)
|
||||||
source = HistorySource.ANALYSIS_SIMULATION
|
source = HistorySource.ANALYSIS_SIMULATION
|
||||||
else:
|
else:
|
||||||
unit_inferred = False
|
|
||||||
result_units = current_units
|
|
||||||
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.start_time,
|
query.start_time,
|
||||||
@@ -285,13 +289,11 @@ class TimeseriesHistoryService:
|
|||||||
continue
|
continue
|
||||||
source_unit = str(result_units.get(metric.value, "")).strip()
|
source_unit = str(result_units.get(metric.value, "")).strip()
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=target.element_id,
|
element_id=target.element_id,
|
||||||
element_type=target.element_type,
|
element_type=target.element_type,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=source_unit,
|
source_unit=source_unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
unit_inferred=unit_inferred,
|
unit_inferred=unit_inferred,
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -310,7 +310,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"x_axis_name": {
|
"x_axis_name": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "939f38733e943f21cc2e3d9f344fa8122ca7a7b35cfebf96ff3f00f469026ceb"
|
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -211,7 +211,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"x_axis_name": {
|
"x_axis_name": {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ flowchart TD
|
|||||||
report --> approval[人工审批与现场执行]
|
report --> approval[人工审批与现场执行]
|
||||||
approval --> feedback[接收实际处置与恢复反馈]
|
approval --> feedback[接收实际处置与恢复反馈]
|
||||||
feedback --> verify[复核影响与结案条件]
|
feedback --> verify[复核影响与结案条件]
|
||||||
verify --> close[来源系统确认事件闭环]
|
verify --> close[Server 完成任务并解除告警]
|
||||||
verify -->|新增事实或遗留事项| asset
|
verify -->|仍异常或有未完成处置| asset
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
||||||
@@ -23,7 +23,7 @@ flowchart TD
|
|||||||
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
||||||
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
|
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
|
||||||
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
||||||
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。原事件由有权限人员在来源系统确认行政结案。
|
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
||||||
|
|
||||||
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||||
|
|
||||||
@@ -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候选定位,原有报告按既有修订保留。
|
||||||
|
|||||||
@@ -190,3 +190,29 @@ 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 不会因代码更新恢复。
|
||||||
|
|||||||
@@ -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: burst-event-handling
|
name: burst-event-handling
|
||||||
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.1"
|
version: "1.1.2"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管事件处理
|
# 爆管事件处理
|
||||||
@@ -50,4 +50,4 @@ metadata:
|
|||||||
|
|
||||||
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
|
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
|
||||||
|
|
||||||
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,发布 final/concluded 报告并引用该证据。运行完成后 Server 才完成任务并解除关联告警。原事件在来源系统的行政结案仍由有权限人员确认。
|
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,工单 completed 且恢复证据 status=passed 时发布 final/concluded 恢复闭环报告并引用该证据。Server 完成运行后关闭任务并解除关联告警,phase=completed 表示已闭环。照片、阀位或复测记录未提供时仅记为资料缺项,不增设关闭前置条件。来源系统若另有明确行政结案流程,按其实际状态另记,不推断为当前任务尚未闭环。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.12"
|
version: "1.0.14"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 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的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
|
||||||
|
|
||||||
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
|
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
|
||||||
|
|
||||||
@@ -41,6 +41,14 @@ 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保持不变。
|
||||||
|
|
||||||
|
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
||||||
|
|
||||||
|
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
||||||
|
|||||||
@@ -90,11 +90,13 @@ class CredentialProvider:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._expires = 0.0
|
self._expires = 0.0
|
||||||
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
||||||
if self.settings.bearer:
|
if self.settings.bearer:
|
||||||
token = self.settings.bearer.removeprefix("Bearer ")
|
token = self.settings.bearer.removeprefix("Bearer ")
|
||||||
else:
|
else:
|
||||||
|
async with self._refresh_lock:
|
||||||
if time.monotonic() >= self._expires:
|
if time.monotonic() >= self._expires:
|
||||||
response = await client.post(self.settings.token_url, data={
|
response = await client.post(self.settings.token_url, data={
|
||||||
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""In-memory task/run fixtures exercising the production repository contract."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskSummary, OperationalTaskRun
|
||||||
|
|
||||||
|
def _context(user_id=None, *, system_role="user"):
|
||||||
|
return ProjectContext(
|
||||||
|
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
||||||
|
project_role="member", system_role=system_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _task_document(context, now, *, lease=None):
|
||||||
|
task_id = uuid4()
|
||||||
|
return OperationalTaskSummary(
|
||||||
|
execution_scope="production",
|
||||||
|
project_id=context.project_id,
|
||||||
|
task_id=task_id,
|
||||||
|
task_type="event_investigation",
|
||||||
|
workflow="water_quality_complaint",
|
||||||
|
status="running",
|
||||||
|
priority="normal",
|
||||||
|
revision=2,
|
||||||
|
execution_generation=1,
|
||||||
|
execution_request_id=uuid4(),
|
||||||
|
title="调查",
|
||||||
|
summary="验证状态机",
|
||||||
|
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||||
|
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
||||||
|
scheduled_for=None,
|
||||||
|
agent_session_id="session-1",
|
||||||
|
agent_lease_expires_at=lease,
|
||||||
|
current_plan_revision=None,
|
||||||
|
current_report_revision=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
def _active_run(task, now):
|
||||||
|
return OperationalTaskRun(
|
||||||
|
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
||||||
|
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
||||||
|
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
||||||
|
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
||||||
|
next_dispatch_at=now, created_at=now, updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
class TaskRepository:
|
||||||
|
def __init__(self, task, run=None, *, action=None):
|
||||||
|
self.task = task
|
||||||
|
self.action = action
|
||||||
|
self.requests = {}
|
||||||
|
self.evidence = {}
|
||||||
|
self.feedback = {}
|
||||||
|
self.plans = []
|
||||||
|
self.reports = []
|
||||||
|
self.progress = {}
|
||||||
|
self.notifications = []
|
||||||
|
self.locks = []
|
||||||
|
self.access_requests = {}
|
||||||
|
self.events = []
|
||||||
|
self.questions = {}
|
||||||
|
now = datetime.fromisoformat(task["created_at"])
|
||||||
|
seeded = _active_run(task, now) if run is None else run
|
||||||
|
if run is None:
|
||||||
|
seeded.update(
|
||||||
|
status=task["status"] if task["status"] in {"queued", "running"} else "succeeded",
|
||||||
|
agent_session_id=task.get("agent_session_id"),
|
||||||
|
lease_expires_at=task.get("agent_lease_expires_at"),
|
||||||
|
)
|
||||||
|
self.run = seeded
|
||||||
|
self.runs = {seeded["run_id"]: seeded}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield self
|
||||||
|
|
||||||
|
async def lock(self, key):
|
||||||
|
self.locks.append(key)
|
||||||
|
|
||||||
|
async def get_request(self, scope, request_id):
|
||||||
|
return self.requests.get((scope, request_id))
|
||||||
|
|
||||||
|
async def save_request(self, scope, request_id, request_digest, response):
|
||||||
|
self.requests[(scope, request_id)] = {
|
||||||
|
"request_digest": request_digest,
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_task(self, task_id):
|
||||||
|
return self.task if task_id == self.task["task_id"] else None
|
||||||
|
|
||||||
|
async def update_task(self, task):
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
async def get_latest_plan(self, _task_id):
|
||||||
|
return self.plans[-1] if self.plans else None
|
||||||
|
|
||||||
|
async def get_latest_report(self, _task_id):
|
||||||
|
return self.reports[-1] if self.reports else None
|
||||||
|
|
||||||
|
async def get_latest_progress(self, task_id, generation):
|
||||||
|
return self.progress.get((task_id, generation))
|
||||||
|
|
||||||
|
async def save_progress(self, task_id, snapshot):
|
||||||
|
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
||||||
|
|
||||||
|
async def get_report(self, _task_id, revision):
|
||||||
|
return next((item for item in self.reports if item["revision"] == revision), None)
|
||||||
|
|
||||||
|
async def list_reports(self, _task_id, limit, offset):
|
||||||
|
return list(reversed(self.reports))[offset:offset + limit]
|
||||||
|
|
||||||
|
async def insert_plan(self, _task_id, snapshot):
|
||||||
|
self.plans.append(snapshot)
|
||||||
|
|
||||||
|
async def insert_report(self, _task_id, snapshot):
|
||||||
|
self.reports.append(snapshot)
|
||||||
|
|
||||||
|
async def list_evidence(self, _task_id):
|
||||||
|
return list(self.evidence.values())
|
||||||
|
|
||||||
|
async def get_evidence(self, _task_id, evidence_id):
|
||||||
|
return self.evidence.get(evidence_id)
|
||||||
|
|
||||||
|
async def insert_evidence(self, _task_id, evidence):
|
||||||
|
self.evidence[evidence["evidence_id"]] = evidence
|
||||||
|
|
||||||
|
async def list_access_requests(self, _task_id):
|
||||||
|
return list(self.access_requests.values())
|
||||||
|
|
||||||
|
async def get_access_request(self, _task_id, access_request_id):
|
||||||
|
return self.access_requests.get(access_request_id)
|
||||||
|
|
||||||
|
async def insert_access_request(self, _task_id, document):
|
||||||
|
self.access_requests[document["access_request_id"]] = document
|
||||||
|
|
||||||
|
async def update_access_request(self, _task_id, document, expected_version):
|
||||||
|
assert document["version"] == expected_version + 1
|
||||||
|
self.access_requests[document["access_request_id"]] = document
|
||||||
|
|
||||||
|
async def list_actions(self, _task_id):
|
||||||
|
return [self.action] if self.action else []
|
||||||
|
|
||||||
|
async def get_action(self, _task_id, action_id):
|
||||||
|
if self.action and self.action["action_id"] == action_id:
|
||||||
|
return self.action
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_notification(self, notification):
|
||||||
|
self.notifications.append(notification)
|
||||||
|
|
||||||
|
async def resolve_notifications(
|
||||||
|
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
||||||
|
):
|
||||||
|
resolved = []
|
||||||
|
for notification in self.notifications:
|
||||||
|
if (
|
||||||
|
notification["task_id"] == task_id
|
||||||
|
and notification["kind"] in kinds
|
||||||
|
and notification.get("lifecycle_status", "active") == "active"
|
||||||
|
and notification.get("subject_type") == subject_type
|
||||||
|
and notification.get("subject_id") == subject_id
|
||||||
|
):
|
||||||
|
notification.update(
|
||||||
|
lifecycle_status="resolved",
|
||||||
|
resolved_at=resolved_at.isoformat(),
|
||||||
|
resolution_reason=resolution_reason,
|
||||||
|
)
|
||||||
|
resolved.append(notification.copy())
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
async def get_feedback(self, task_id, source_event_id):
|
||||||
|
return self.feedback.get((task_id, source_event_id))
|
||||||
|
|
||||||
|
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
||||||
|
self.feedback[(task_id, feedback["source_event_id"])] = {
|
||||||
|
"source_revision": feedback["source_revision"],
|
||||||
|
"document": feedback,
|
||||||
|
"accepted_at": accepted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_active_run(self, task_id):
|
||||||
|
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True)
|
||||||
|
if run["task_id"] == task_id and run["status"] in {"queued", "claimed", "running"}), None)
|
||||||
|
|
||||||
|
async def get_latest_run(self, task_id):
|
||||||
|
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True) if run["task_id"] == task_id), None)
|
||||||
|
|
||||||
|
async def get_run(self, run_id):
|
||||||
|
return self.runs.get(run_id)
|
||||||
|
|
||||||
|
async def require_execution_storage(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_run(self, run, expected_revision):
|
||||||
|
assert run["revision"] == expected_revision + 1
|
||||||
|
self.runs[run["run_id"]] = run
|
||||||
|
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||||
|
|
||||||
|
async def insert_run(self, run):
|
||||||
|
self.runs[run["run_id"]] = run
|
||||||
|
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||||
|
|
||||||
|
async def append_event(self, **event):
|
||||||
|
self.events.append(event)
|
||||||
|
return len(self.events)
|
||||||
|
|
||||||
|
async def current_event_cursor(self):
|
||||||
|
return len(self.events)
|
||||||
|
|
||||||
|
async def list_presentation_events(self, task_id, after, through, limit):
|
||||||
|
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
||||||
|
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
||||||
|
and event["aggregate_id"] == task_id][:limit]
|
||||||
|
async def access_requests_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fail_run_messages(self, run_id, reason, now):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def try_lock(self, key):
|
||||||
|
await self.lock(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(self, task_id, run_id, execution_request_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pending_run_messages(self, run_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def list_investigation_alerts(self, task_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def list_questions(self, task_id):
|
||||||
|
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
||||||
|
|
||||||
|
async def get_question(self, task_id, question_id):
|
||||||
|
q = self.questions.get(question_id)
|
||||||
|
return deepcopy(q) if q and q["task_id"] == task_id else None
|
||||||
|
|
||||||
|
async def save_question(self, task_id, document):
|
||||||
|
assert document["task_id"] == task_id
|
||||||
|
self.questions[document["question_request_id"]] = deepcopy(document)
|
||||||
@@ -44,6 +44,12 @@ class Repository:
|
|||||||
async def get_task(self, task_id):
|
async def get_task(self, task_id):
|
||||||
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
|
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
|
||||||
|
|
||||||
|
async def list_questions(self, task_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(self, task_id, run_id, execution_request_id):
|
||||||
|
return []
|
||||||
|
|
||||||
async def get_message(self, message_id):
|
async def get_message(self, message_id):
|
||||||
return self.messages.get(message_id)
|
return self.messages.get(message_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
|
||||||
|
from app.infra.db.project_routing import get_active_project_routing
|
||||||
|
from app.mcp import scope
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mcp_scope_reads_routing_once_and_refreshes_it_on_the_next_request(monkeypatch):
|
||||||
|
project = ProjectContext(uuid4(), "fixture", uuid4(), "member")
|
||||||
|
session = SimpleNamespace(commit=AsyncMock())
|
||||||
|
connections = []
|
||||||
|
current_dsn = ["dbname=first"]
|
||||||
|
|
||||||
|
async def metadata_session():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def read_routing(_repo, project_id, role):
|
||||||
|
assert project_id == project.project_id and role == "biz_data"
|
||||||
|
return ProjectDbRouting(project_id, role, "postgresql", current_dsn[0], 1, 4)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connection(project_id, role, dsn, minimum, maximum):
|
||||||
|
assert session.commit.await_count > 0
|
||||||
|
connections.append((project_id, role, dsn, minimum, maximum))
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
read = AsyncMock(side_effect=read_routing)
|
||||||
|
monkeypatch.setattr(scope, "get_metadata_session", metadata_session)
|
||||||
|
monkeypatch.setattr(scope, "get_current_metadata_user", AsyncMock(return_value=object()))
|
||||||
|
monkeypatch.setattr(scope, "resolve_project_context", AsyncMock(return_value=project))
|
||||||
|
# Patch at the repository boundary so both real routing resolvers are exercised.
|
||||||
|
async def repository_read(self, project_id, role):
|
||||||
|
return await read(self, project_id, role)
|
||||||
|
monkeypatch.setattr(MetadataRepository, "get_project_db_routing", repository_read)
|
||||||
|
monkeypatch.setattr("app.auth.project_dependencies.project_connection_manager.pg_connection", connection)
|
||||||
|
|
||||||
|
for dsn in ("dbname=first", "dbname=second"):
|
||||||
|
current_dsn[0] = dsn
|
||||||
|
async with scope.project_scope(SimpleNamespace(claims={"sub": "fixture"}), str(project.project_id)) as value:
|
||||||
|
assert value.routing.business_dsn == dsn
|
||||||
|
assert get_active_project_routing() is value.routing
|
||||||
|
assert get_active_project_routing() is None
|
||||||
|
|
||||||
|
assert read.await_count == 2
|
||||||
|
assert [row[2:] for row in connections] == [("dbname=first", 1, 4), ("dbname=second", 1, 4)]
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -114,11 +115,6 @@ async def test_access_request_read_uses_table_after_migration():
|
|||||||
assert connection.execute.await_count == 2
|
assert connection.execute.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
def _context(user_id=None, *, system_role="user"):
|
|
||||||
return ProjectContext(
|
|
||||||
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
|
||||||
project_role="member", system_role=system_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -232,220 +228,48 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
|
|||||||
assert exc.value.code == "PERMISSION_DENIED"
|
assert exc.value.code == "PERMISSION_DENIED"
|
||||||
|
|
||||||
|
|
||||||
def _task_document(context, now, *, lease=None):
|
|
||||||
task_id = uuid4()
|
|
||||||
return OperationalTaskSummary(
|
|
||||||
project_id=context.project_id,
|
|
||||||
task_id=task_id,
|
|
||||||
task_type="event_investigation",
|
|
||||||
workflow="water_quality_complaint",
|
|
||||||
status="running",
|
|
||||||
priority="normal",
|
|
||||||
revision=2,
|
|
||||||
execution_generation=1,
|
|
||||||
execution_request_id=uuid4(),
|
|
||||||
title="调查",
|
|
||||||
summary="验证状态机",
|
|
||||||
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
|
||||||
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
|
||||||
scheduled_for=None,
|
|
||||||
agent_session_id="session-1",
|
|
||||||
agent_lease_expires_at=lease,
|
|
||||||
current_plan_revision=None,
|
|
||||||
current_report_revision=None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskRepository:
|
|
||||||
def __init__(self, task, *, action=None):
|
|
||||||
self.task = task
|
|
||||||
self.action = action
|
|
||||||
self.requests = {}
|
|
||||||
self.evidence = {}
|
|
||||||
self.feedback = {}
|
|
||||||
self.plans = []
|
|
||||||
self.reports = []
|
|
||||||
self.progress = {}
|
|
||||||
self.notifications = []
|
|
||||||
self.locks = []
|
|
||||||
self.access_requests = {}
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def transaction(self):
|
|
||||||
yield self
|
|
||||||
|
|
||||||
async def lock(self, key):
|
|
||||||
self.locks.append(key)
|
|
||||||
|
|
||||||
async def get_request(self, scope, request_id):
|
|
||||||
return self.requests.get((scope, request_id))
|
|
||||||
|
|
||||||
async def save_request(self, scope, request_id, request_digest, response):
|
|
||||||
self.requests[(scope, request_id)] = {
|
|
||||||
"request_digest": request_digest,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_task(self, task_id):
|
|
||||||
return self.task if task_id == self.task["task_id"] else None
|
|
||||||
|
|
||||||
async def update_task(self, task):
|
|
||||||
self.task = task
|
|
||||||
|
|
||||||
async def get_latest_plan(self, _task_id):
|
|
||||||
return self.plans[-1] if self.plans else None
|
|
||||||
|
|
||||||
async def get_latest_report(self, _task_id):
|
|
||||||
return self.reports[-1] if self.reports else None
|
|
||||||
|
|
||||||
async def get_latest_progress(self, task_id, generation):
|
|
||||||
return self.progress.get((task_id, generation))
|
|
||||||
|
|
||||||
async def save_progress(self, task_id, snapshot):
|
|
||||||
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
|
||||||
|
|
||||||
async def get_report(self, _task_id, revision):
|
|
||||||
return next((item for item in self.reports if item["revision"] == revision), None)
|
|
||||||
|
|
||||||
async def list_reports(self, _task_id, limit, offset):
|
|
||||||
return list(reversed(self.reports))[offset:offset + limit]
|
|
||||||
|
|
||||||
async def insert_plan(self, _task_id, snapshot):
|
|
||||||
self.plans.append(snapshot)
|
|
||||||
|
|
||||||
async def insert_report(self, _task_id, snapshot):
|
|
||||||
self.reports.append(snapshot)
|
|
||||||
|
|
||||||
async def list_evidence(self, _task_id):
|
|
||||||
return list(self.evidence.values())
|
|
||||||
|
|
||||||
async def get_evidence(self, _task_id, evidence_id):
|
|
||||||
return self.evidence.get(evidence_id)
|
|
||||||
|
|
||||||
async def insert_evidence(self, _task_id, evidence):
|
|
||||||
self.evidence[evidence["evidence_id"]] = evidence
|
|
||||||
|
|
||||||
async def list_access_requests(self, _task_id):
|
|
||||||
return list(self.access_requests.values())
|
|
||||||
|
|
||||||
async def get_access_request(self, _task_id, access_request_id):
|
|
||||||
return self.access_requests.get(access_request_id)
|
|
||||||
|
|
||||||
async def insert_access_request(self, _task_id, document):
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def update_access_request(self, _task_id, document, expected_version):
|
|
||||||
assert document["version"] == expected_version + 1
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def list_actions(self, _task_id):
|
|
||||||
return [self.action] if self.action else []
|
|
||||||
|
|
||||||
async def get_action(self, _task_id, action_id):
|
|
||||||
if self.action and self.action["action_id"] == action_id:
|
|
||||||
return self.action
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_notification(self, notification):
|
|
||||||
self.notifications.append(notification)
|
|
||||||
|
|
||||||
async def resolve_notifications(
|
|
||||||
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
|
||||||
):
|
|
||||||
resolved = []
|
|
||||||
for notification in self.notifications:
|
|
||||||
if (
|
|
||||||
notification["task_id"] == task_id
|
|
||||||
and notification["kind"] in kinds
|
|
||||||
and notification.get("lifecycle_status", "active") == "active"
|
|
||||||
and notification.get("subject_type") == subject_type
|
|
||||||
and notification.get("subject_id") == subject_id
|
|
||||||
):
|
|
||||||
notification.update(
|
|
||||||
lifecycle_status="resolved",
|
|
||||||
resolved_at=resolved_at.isoformat(),
|
|
||||||
resolution_reason=resolution_reason,
|
|
||||||
)
|
|
||||||
resolved.append(notification.copy())
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
async def get_feedback(self, task_id, source_event_id):
|
|
||||||
return self.feedback.get((task_id, source_event_id))
|
|
||||||
|
|
||||||
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
|
||||||
self.feedback[(task_id, feedback["source_event_id"])] = {
|
|
||||||
"source_revision": feedback["source_revision"],
|
|
||||||
"document": feedback,
|
|
||||||
"accepted_at": accepted_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RunTaskRepository(TaskRepository):
|
|
||||||
def __init__(self, task, run):
|
|
||||||
super().__init__(task)
|
|
||||||
self.run = run
|
|
||||||
self.events = []
|
|
||||||
|
|
||||||
async def get_active_run(self, task_id):
|
|
||||||
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
|
||||||
return self.run
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_run(self, run_id):
|
|
||||||
return self.run if run_id == self.run["run_id"] else None
|
|
||||||
|
|
||||||
async def require_execution_storage(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_run(self, run, expected_revision):
|
|
||||||
assert run["revision"] == expected_revision + 1
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def insert_run(self, run):
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def append_event(self, **event):
|
|
||||||
self.events.append(event)
|
|
||||||
return len(self.events)
|
|
||||||
|
|
||||||
async def current_event_cursor(self):
|
|
||||||
return len(self.events)
|
|
||||||
|
|
||||||
async def list_presentation_events(self, task_id, after, through, limit):
|
|
||||||
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
|
||||||
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
|
||||||
and event["aggregate_id"] == task_id][:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def _active_run(task, now):
|
|
||||||
return OperationalTaskRun(
|
|
||||||
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
|
||||||
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
|
||||||
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
|
||||||
next_dispatch_at=now, created_at=now, updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
@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 = TaskRepository(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"],
|
||||||
@@ -464,7 +288,7 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
first = AgentArtifactsCommand.model_validate({
|
first = AgentArtifactsCommand.model_validate({
|
||||||
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
||||||
@@ -511,7 +335,7 @@ async def test_reading_publications_reject_stale_execution_ownership(kind, inval
|
|||||||
run["lease_expires_at"] = now.isoformat()
|
run["lease_expires_at"] = now.isoformat()
|
||||||
elif invalid == "finished":
|
elif invalid == "finished":
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
|
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
|
||||||
"source_session_id": "other-session" if invalid == "session" else "session-1"}
|
"source_session_id": "other-session" if invalid == "session" else "session-1"}
|
||||||
@@ -532,7 +356,7 @@ async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions()
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError):
|
with pytest.raises(DomainError):
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -558,7 +382,7 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
baseline = await service.presentation_cursor(task["task_id"])
|
baseline = await service.presentation_cursor(task["task_id"])
|
||||||
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
|
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
|
||||||
@@ -584,7 +408,7 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
||||||
@@ -593,9 +417,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 +438,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 = TaskRepository(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):
|
||||||
@@ -631,7 +515,7 @@ async def test_replay_cannot_propose_approve_or_submit_device_commands(operation
|
|||||||
required_execution_permission="command.execute", approval_status="pending",
|
required_execution_permission="command.execute", approval_status="pending",
|
||||||
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
||||||
)
|
)
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.action = action.model_dump(mode="json")
|
repository.action = action.model_dump(mode="json")
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
||||||
@@ -659,7 +543,7 @@ async def test_unified_commands_record_canonical_events_without_mutating_task(pa
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = TaskPresentationEventCommand(
|
command = TaskPresentationEventCommand(
|
||||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
||||||
@@ -678,7 +562,7 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
coverage = build_node_coverage(
|
coverage = build_node_coverage(
|
||||||
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
||||||
@@ -711,7 +595,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
|
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
|
||||||
origin="system_data", truncated=False,
|
origin="system_data", truncated=False,
|
||||||
@@ -760,7 +644,7 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
_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(
|
||||||
@@ -807,7 +691,7 @@ async def test_completed_investigation_rejects_unlinked_or_initial_child_feedbac
|
|||||||
task.update(status="completed", lifecycle_status="completed")
|
task.update(status="completed", lifecycle_status="completed")
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError) as unlinked:
|
with pytest.raises(DomainError) as unlinked:
|
||||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
@@ -839,7 +723,7 @@ async def test_original_complaint_feedback_advances_its_source_revision():
|
|||||||
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
|
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
|
||||||
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
|
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
|
||||||
@@ -856,7 +740,7 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
updated = await service.heartbeat_run(
|
updated = await service.heartbeat_run(
|
||||||
@@ -878,7 +762,7 @@ async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_sessio
|
|||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["agent_instance_id"] = "agent-a"
|
run["agent_instance_id"] = "agent-a"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
with pytest.raises(DomainError) as mismatch:
|
with pytest.raises(DomainError) as mismatch:
|
||||||
@@ -898,7 +782,7 @@ async def test_complete_without_same_generation_artifacts_fails_run_explicitly()
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
completed = await service.complete_run(
|
completed = await service.complete_run(
|
||||||
@@ -930,7 +814,7 @@ async def test_external_feedback_coalesces_into_an_already_queued_run():
|
|||||||
status="queued", revision=1, agent_session_id=None,
|
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 = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
result = await service.external_feedback(
|
result = await service.external_feedback(
|
||||||
@@ -985,7 +869,7 @@ async def test_scada_final_report_requires_complete_current_input_not_selected_d
|
|||||||
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["workflow"] = "scada_anomaly"
|
task["workflow"] = "scada_anomaly"
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
||||||
"mode": "all_scada", "query_complete": True,
|
"mode": "all_scada", "query_complete": True,
|
||||||
@@ -1040,7 +924,7 @@ async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = AgentArtifactsCommand(
|
command = AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1",
|
request_id=uuid4(), source_session_id="session-1",
|
||||||
@@ -1095,7 +979,7 @@ async def test_insufficient_report_rejects_inconsistent_context(defect, expected
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = _insufficient_report()
|
report = _insufficient_report()
|
||||||
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
||||||
@@ -1128,21 +1012,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": "爆管"})
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
if replay:
|
||||||
|
task["workflow"] = "scada_anomaly"
|
||||||
|
task["source"]["replay_context"] = {
|
||||||
|
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
||||||
|
}
|
||||||
|
repository = TaskRepository(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 +1080,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"
|
||||||
@@ -1201,13 +1108,22 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
||||||
evidence_id = revised_id
|
evidence_id = revised_id
|
||||||
|
|
||||||
repository.task.update(status="running", agent_session_id="session-1")
|
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
repository.run = _active_run(repository.task, now)
|
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"],
|
||||||
|
source_session_id="session-1",
|
||||||
|
))
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
with pytest.raises(DomainError) as rejected:
|
with pytest.raises(DomainError) as rejected:
|
||||||
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"))
|
||||||
@@ -1220,7 +1136,7 @@ async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1255,7 +1171,7 @@ async def test_interim_report_cannot_finish_even_with_a_completed_plan():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
plan = _waiting_plan()
|
plan = _waiting_plan()
|
||||||
plan["nodes"][0]["status"] = "completed"
|
plan["nodes"][0]["status"] = "completed"
|
||||||
@@ -1296,7 +1212,11 @@ async def test_report_is_not_blocked_by_a_historical_plan():
|
|||||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
))
|
))
|
||||||
assert result.task.latest_report.execution_generation == 2
|
assert result.task.latest_report.execution_generation == 2
|
||||||
assert result.task.status == "completed"
|
assert result.task.status == "running"
|
||||||
|
completed = 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",
|
||||||
|
))
|
||||||
|
assert completed.status == "completed"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -1304,7 +1224,7 @@ async def test_report_and_action_without_plan_finish_with_real_approval_pending(
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
@@ -1327,7 +1247,7 @@ async def test_insufficient_data_without_graph_or_node_ends_the_round():
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = _insufficient_report()
|
report = _insufficient_report()
|
||||||
report["data_gaps"][0].pop("node_id")
|
report["data_gaps"][0].pop("node_id")
|
||||||
@@ -1349,7 +1269,7 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
for conclusion in ["第一次判断", "更新后的判断"]:
|
for conclusion in ["第一次判断", "更新后的判断"]:
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1385,7 +1305,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
|
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
|
||||||
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
|
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
|
||||||
@@ -1412,7 +1332,7 @@ async def test_legacy_report_diagram_reads_only_matching_prepublication_progress
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
repository.reports.append({
|
repository.reports.append({
|
||||||
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
||||||
@@ -1445,7 +1365,7 @@ async def test_optional_diagram_cannot_forge_business_references(node_ref, code)
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError) as invalid:
|
with pytest.raises(DomainError) as invalid:
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1461,7 +1381,7 @@ async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_r
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||||
@@ -1510,7 +1430,7 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now).copy())
|
repository = TaskRepository(task, _active_run(task, now).copy())
|
||||||
service = OperationalTaskService(
|
service = OperationalTaskService(
|
||||||
repository,
|
repository,
|
||||||
context,
|
context,
|
||||||
@@ -1559,7 +1479,7 @@ async def test_action_decision_rejects_a_stale_expected_version():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(
|
service = OperationalTaskService(
|
||||||
repository,
|
repository,
|
||||||
context,
|
context,
|
||||||
@@ -1623,7 +1543,7 @@ async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
|
|||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
initial_revision = task["revision"]
|
initial_revision = task["revision"]
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
|
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1657,7 +1577,7 @@ async def test_task_access_request_rejects_a_second_pending_request():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1680,7 +1600,7 @@ async def test_task_access_request_fails_closed_before_project_migration():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.access_requests_available = AsyncMock(return_value=False)
|
repository.access_requests_available = AsyncMock(return_value=False)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
@@ -1699,7 +1619,7 @@ async def test_completed_gate_requires_access_request_decision():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1736,7 +1656,7 @@ async def test_work_order_draft_edit_creates_new_review_version(replay):
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
||||||
_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(
|
||||||
@@ -1767,7 +1687,7 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, {
|
service = OperationalTaskService(repository, context, {
|
||||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
}, clock=lambda: now)
|
}, clock=lambda: now)
|
||||||
@@ -2011,7 +1931,7 @@ async def test_expired_owned_lease_is_rejected_with_stable_code():
|
|||||||
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=now - timedelta(seconds=1))
|
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
@@ -2139,7 +2059,7 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
|
|||||||
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = {**rich_report(), "stage": "final"}
|
report = {**rich_report(), "stage": "final"}
|
||||||
command = AgentArtifactsCommand(
|
command = AgentArtifactsCommand(
|
||||||
@@ -2176,7 +2096,7 @@ async def test_presentation_history_is_task_scoped_paginated_and_readable_after_
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now)
|
task = _task_document(context, now)
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.run["status"] = "succeeded"
|
repository.run["status"] = "succeeded"
|
||||||
for index in range(4):
|
for index in range(4):
|
||||||
await repository.append_event(
|
await repository.append_event(
|
||||||
@@ -2208,7 +2128,7 @@ async def test_final_report_rejected_while_original_run_has_pending_calculations
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
pending_job = str(uuid4())
|
pending_job = str(uuid4())
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
@@ -2232,7 +2152,7 @@ async def test_completion_rejected_until_calculations_reach_terminal_state():
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
|
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
|
||||||
@@ -2266,7 +2186,7 @@ async def test_interim_report_can_explain_progress_while_calculation_is_pending(
|
|||||||
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=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
|
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
|
||||||
@@ -2341,3 +2261,91 @@ 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())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("operation", ["feedback", "withdrawal"])
|
||||||
|
@pytest.mark.parametrize("task_scope,server_scope,allowed", [
|
||||||
|
(None, "production", False),
|
||||||
|
(None, "development", False),
|
||||||
|
("production", "production", True),
|
||||||
|
("production", "development", False),
|
||||||
|
("development", "production", False),
|
||||||
|
("development", "development", True),
|
||||||
|
])
|
||||||
|
async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation, task_scope, server_scope, allowed):
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=None)
|
||||||
|
source_id = f"peripheral_sample:{uuid4()}"
|
||||||
|
task.update(task_type="external_work_order", source={
|
||||||
|
"kind": "external_system", "external_id": source_id,
|
||||||
|
"external_revision": 1, "event_kind": "general",
|
||||||
|
})
|
||||||
|
if task_scope is None:
|
||||||
|
task.pop("execution_scope")
|
||||||
|
else:
|
||||||
|
task["execution_scope"] = task_scope
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now, execution_scope=server_scope)
|
||||||
|
if operation == "feedback":
|
||||||
|
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=source_id,
|
||||||
|
source_revision=2, status="completed", occurred_at=now, detail="已关阀修复管道")
|
||||||
|
method = "external_feedback"
|
||||||
|
else:
|
||||||
|
command = ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id)
|
||||||
|
method = "withdraw_external_source"
|
||||||
|
before = deepcopy(repository.task)
|
||||||
|
if not allowed:
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
|
assert repository.task == before
|
||||||
|
assert repository.feedback == repository.evidence == repository.requests == {}
|
||||||
|
return
|
||||||
|
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
assert repository.task["status"] == ("completed" if operation == "feedback" else "cancelled")
|
||||||
|
# Check scope before idempotency replay as well as before the first write.
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
before = deepcopy(repository.task)
|
||||||
|
other = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
||||||
|
execution_scope="development" if server_scope == "production" else "production")
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await getattr(other, method)(task["task_id"], command)
|
||||||
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
|
assert repository.task == before
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,6 +9,32 @@ import pytest
|
|||||||
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_parallel_worker_requests_share_cold_and_expired_token_refresh(monkeypatch):
|
||||||
|
clock = [100.0]
|
||||||
|
requests = []
|
||||||
|
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60})
|
||||||
|
|
||||||
|
provider = CredentialProvider(WorkerSettings(
|
||||||
|
"http://server.test", "http://agent.test", str(uuid4()),
|
||||||
|
token_url="https://identity.test/token", client_id="fixture", client_secret="TEST_ONLY",
|
||||||
|
))
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
first = await asyncio.gather(provider.headers(client), provider.headers(client))
|
||||||
|
assert first[0] == first[1]
|
||||||
|
assert len(requests) == 1
|
||||||
|
clock[0] = 146.0
|
||||||
|
second = await asyncio.gather(provider.headers(client), provider.headers(client))
|
||||||
|
assert second[0] == second[1]
|
||||||
|
assert second[0] != first[0]
|
||||||
|
assert len(requests) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def anyio_backend():
|
def anyio_backend():
|
||||||
return "asyncio"
|
return "asyncio"
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize("targeted", [False, True])
|
@pytest.mark.parametrize("targeted", [False, True])
|
||||||
@pytest.mark.parametrize("order_revision", [1, 2])
|
@pytest.mark.parametrize("order_revision", [1, 4])
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
|
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
|
||||||
[
|
[
|
||||||
@@ -254,9 +254,13 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
|
|||||||
"updated_at": "2026-09-23T09:00:00Z",
|
"updated_at": "2026-09-23T09:00:00Z",
|
||||||
"title": "采样工单", "priority": "normal",
|
"title": "采样工单", "priority": "normal",
|
||||||
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
||||||
"scheduled_at": None,
|
"scheduled_at": "2026-09-23T08:30:00Z",
|
||||||
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
|
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
|
||||||
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}]
|
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"},
|
||||||
|
{"sequence": 3, "occurred_at": "2026-09-23T08:50:00Z", "actor": "调度员",
|
||||||
|
"kind": "updated", "title": "工单信息更新", "detail": "更新了状态。"},
|
||||||
|
{"sequence": 4, "occurred_at": "2026-09-23T09:00:00Z", "actor": "调度员",
|
||||||
|
"kind": "updated", "title": "工单信息更新", "detail": "更新了优先级。"}]
|
||||||
if order_revision > 1 else []),
|
if order_revision > 1 else []),
|
||||||
}
|
}
|
||||||
if targeted:
|
if targeted:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.api.v1.endpoints import scada as scada_endpoint
|
from app.api.v1.endpoints import scada as scada_endpoint
|
||||||
from app.services import timeseries_analysis as composite_queries
|
from app.services import timeseries_history as history
|
||||||
|
from app.domain.schemas.timeseries_history import ElementHistoryQuery
|
||||||
|
|
||||||
|
|
||||||
PROJECT_SCADA = {
|
PROJECT_SCADA = {
|
||||||
@@ -26,107 +26,22 @@ START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
|||||||
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _patch_project_scadas(monkeypatch):
|
def test_history_uses_current_project_metadata(monkeypatch):
|
||||||
monkeypatch.setattr(
|
postgres = object()
|
||||||
composite_queries.ScadaInfoRepository,
|
mappings = AsyncMock(return_value=[PROJECT_SCADA.copy()])
|
||||||
"get_scadas",
|
monkeypatch.setattr(history.ScadaInfoRepository, "get_scadas_for_elements", mappings)
|
||||||
AsyncMock(return_value=[PROJECT_SCADA.copy()]),
|
monkeypatch.setattr(history.ScadaRepository, "get_scada_by_ids_time_range", AsyncMock(return_value=[{
|
||||||
)
|
"time": START_TIME, "device_id": PROJECT_SCADA["device_id"],
|
||||||
|
"monitored_value": 26.5, "cleaned_value": 26.0,
|
||||||
|
}]))
|
||||||
def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
|
result = asyncio.run(history.TimeseriesHistoryService.query(object(), postgres, ElementHistoryQuery(
|
||||||
_patch_project_scadas(monkeypatch)
|
start_time=START_TIME, end_time=END_TIME,
|
||||||
query_mock = AsyncMock(
|
elements=[{"element_id": "J1", "element_type": "junction", "device_ids": [PROJECT_SCADA["device_id"]]}],
|
||||||
return_value={"J1": [{"time": START_TIME, "value": 26.5}]}
|
)))
|
||||||
)
|
assert mappings.await_args.args == (postgres, ["J1"], [])
|
||||||
monkeypatch.setattr(
|
assert len(result.series) == 2
|
||||||
composite_queries.RealtimeRepository,
|
assert all(item.device_id == PROJECT_SCADA["device_id"] and item.element_id == "J1" for item in result.series)
|
||||||
"get_node_fields_by_ids_time_range",
|
assert [item.points[0].value for item in result.series] == [26.5, 26.0]
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.RealtimeRepository,
|
|
||||||
"get_link_fields_by_ids_time_range",
|
|
||||||
AsyncMock(return_value={}),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 1
|
|
||||||
assert query_mock.await_args.args[1:] == (
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
["J1"],
|
|
||||||
"pressure",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
async def query_series(_conn, _run_id, element_type, element_ids, *_args):
|
|
||||||
if element_type == "node":
|
|
||||||
return {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
query_mock = AsyncMock(side_effect=query_series)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.AnalysisResultsRepository,
|
|
||||||
"get_series_by_ids",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
uuid4(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 2
|
|
||||||
node_call = next(
|
|
||||||
call for call in query_mock.await_args_list if call.args[2] == "node"
|
|
||||||
)
|
|
||||||
assert node_call.args[3:] == (["J1"], START_TIME, END_TIME, "pressure")
|
|
||||||
|
|
||||||
|
|
||||||
def test_element_scada_query_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
query_mock = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
PROJECT_SCADA["device_id"]: [{"time": START_TIME, "value": 26.5}]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.ScadaRepository,
|
|
||||||
"get_scada_field_by_id_time_range",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_element_associated_scada_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
"J1",
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailure
|
|||||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
from app.services.task_questions import create_question, answer_question
|
from app.services.task_questions import create_question, answer_question
|
||||||
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,28 +19,11 @@ def anyio_backend():
|
|||||||
return "asyncio"
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
class QuestionRepository(RunTaskRepository):
|
|
||||||
def __init__(self, task, run):
|
|
||||||
super().__init__(task, run)
|
|
||||||
self.questions = {}
|
|
||||||
|
|
||||||
async def list_questions(self, task_id):
|
|
||||||
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
|
||||||
|
|
||||||
async def get_question(self, task_id, question_id):
|
|
||||||
q = self.questions.get(question_id)
|
|
||||||
return deepcopy(q) if q and q["task_id"] == task_id else None
|
|
||||||
|
|
||||||
async def save_question(self, task_id, document):
|
|
||||||
assert document["task_id"] == task_id
|
|
||||||
self.questions[document["question_request_id"]] = deepcopy(document)
|
|
||||||
|
|
||||||
|
|
||||||
def setup():
|
def setup():
|
||||||
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repo = QuestionRepository(task, _active_run(task, now))
|
repo = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
||||||
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
||||||
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
||||||
@@ -162,6 +146,34 @@ async def test_real_execution_failure_cancels_question_instead_of_disguising_fai
|
|||||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("timeout_count", [1, 3])
|
||||||
|
async def test_lease_timeout_cancels_old_question_and_unblocks_the_next_generation(timeout_count):
|
||||||
|
service, repo, task_id, command = setup()
|
||||||
|
question = await create_question(service, task_id, command)
|
||||||
|
service.clock = lambda: question.created_at + timedelta(minutes=2)
|
||||||
|
repo.claim_expired_runs = AsyncMock(return_value=[deepcopy(repo.run)])
|
||||||
|
repo.consecutive_timed_out_runs = AsyncMock(return_value=timeout_count)
|
||||||
|
|
||||||
|
await service.recover_expired_leases(1)
|
||||||
|
|
||||||
|
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||||
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
with pytest.raises(DomainError):
|
||||||
|
await answer_question(service, task_id, question.question_request_id, reply())
|
||||||
|
if timeout_count == 3:
|
||||||
|
assert repo.task["lifecycle_status"] == "failed"
|
||||||
|
return
|
||||||
|
|
||||||
|
await service.claim_run(task_id, UUID(repo.run["run_id"]), AgentRunCommand(
|
||||||
|
request_id=UUID(repo.run["execution_request_id"]), expected_revision=1,
|
||||||
|
source_session_id="session-2"))
|
||||||
|
next_question = await create_question(service, task_id, command.model_copy(update={
|
||||||
|
"request_id": uuid4(), "run_id": UUID(repo.run["run_id"]), "source_session_id": "session-2"}))
|
||||||
|
assert next_question.status == "pending"
|
||||||
|
assert next_question.execution_generation == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multiple_selection_and_optional_question_are_preserved():
|
async def test_multiple_selection_and_optional_question_are_preserved():
|
||||||
service, repo, task_id, command = setup()
|
service, repo, task_id, command = setup()
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user