Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
471ad92a47 | ||
|
|
1892b1d1f0 | ||
|
|
2e8fe86f3f | ||
|
|
b853b61a0b | ||
|
|
e47c61c121 | ||
|
|
6a4478bfe4 | ||
|
|
5197a93163 | ||
|
|
549019507e |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
|
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
|
||||||
|
|
||||||
|
协议、工作进程、数据库、SCADA 与历史资料索引见 [文档目录](docs/README.md)。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Python 3.12
|
- Python 3.12
|
||||||
@@ -213,7 +215,7 @@ Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发
|
|||||||
|
|
||||||
## 产品 MCP 查询
|
## 产品 MCP 查询
|
||||||
|
|
||||||
Server 可选启用 MCP 2026-07-28,默认关闭。当前目录包含 67 项类型化工具,其中 42 项查询、12 项计算、13 项预览式写入。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
|
Server 可选启用 MCP 2026-07-28,默认关闭。目录包含查询、分析计算、预览式写入和计算任务管理工具;可用项以 `app/mcp/catalog.py` 及当前授权目录为准。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
|
||||||
|
|
||||||
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
|
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, JsonValue, model_serializer, model_validator
|
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
|
||||||
from app.domain.schemas.answer_ui import (
|
from app.domain.schemas.answer_ui import (
|
||||||
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
||||||
)
|
)
|
||||||
@@ -222,25 +222,61 @@ class InvestigationMapTarget(StrictModel):
|
|||||||
feature_id: TaskFeatureId
|
feature_id: TaskFeatureId
|
||||||
|
|
||||||
|
|
||||||
class InvestigationDiagramNodeRef(StrictModel):
|
InvestigationNodeAnnotation = Literal["reference", "recorded", "needs_input", "failed", "not_taken"]
|
||||||
|
LEGACY_INVESTIGATION_NODE_ANNOTATIONS = {
|
||||||
|
"pending": "reference", "running": "reference", "completed": "recorded",
|
||||||
|
"waiting": "needs_input", "waiting_human": "needs_input",
|
||||||
|
"skipped": "not_taken", "cancelled": "not_taken",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _InvestigationDiagramNode(StrictModel):
|
||||||
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
label: NonEmptyStr
|
label: NonEmptyStr
|
||||||
|
detail: Annotated[str, Field(min_length=1, max_length=2000)] | None = None
|
||||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
action_refs: list[UUID] = Field(default_factory=list)
|
action_refs: list[UUID] = Field(default_factory=list)
|
||||||
status: Literal[
|
|
||||||
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
|
|
||||||
] | None = Field(default=None, description=(
|
|
||||||
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
|
|
||||||
"waiting_human requires manual authorization/approval/feedback. "
|
|
||||||
"Unknown stays omitted/null; host controls colors."
|
|
||||||
))
|
|
||||||
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
|
||||||
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
|
"A saved process-reference annotation, not live execution state. "
|
||||||
|
"recorded means a finding exists, not that a task or field action completed."
|
||||||
|
))
|
||||||
|
|
||||||
|
@field_validator("status", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def read_legacy_annotation(cls, value: object) -> object:
|
||||||
|
return LEGACY_INVESTIGATION_NODE_ANNOTATIONS.get(value, value) if isinstance(value, str) else value
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphNodeUpdate(_InvestigationDiagramNode):
|
||||||
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
|
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
||||||
|
"No live node/task binding or per-step synchronization is required."
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphEdge(StrictModel):
|
||||||
|
source: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
|
target: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||||
|
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationGraphUpdate(StrictModel):
|
||||||
|
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
|
||||||
|
nodes: list[InvestigationGraphNodeUpdate] = Field(default_factory=list, max_length=100)
|
||||||
|
edges: list[InvestigationGraphEdge] = Field(default_factory=list, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class InvestigationDiagram(StrictModel):
|
class InvestigationDiagram(StrictModel):
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
|
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
|
||||||
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
|
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
|
||||||
|
# Present only on a graph maintained by the host. Legacy Mermaid stays readable.
|
||||||
|
edges: list[InvestigationGraphEdge] | None = Field(default=None, max_length=200)
|
||||||
|
source_mermaid: Annotated[str, Field(min_length=1, max_length=20000)] | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_description(self):
|
def validate_description(self):
|
||||||
@@ -284,6 +320,7 @@ class InvestigationReport(StrictModel):
|
|||||||
data_gaps: list[InvestigationDataGap] | None = None
|
data_gaps: list[InvestigationDataGap] | None = None
|
||||||
diagram: InvestigationDiagram | None = None
|
diagram: InvestigationDiagram | None = None
|
||||||
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
||||||
|
scada_device_ids: list[TaskFeatureId] | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_result(self):
|
def validate_result(self):
|
||||||
@@ -323,6 +360,16 @@ class InvestigationProgress(StrictModel):
|
|||||||
diagram: InvestigationDiagram | None = None
|
diagram: InvestigationDiagram | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationProgressInput(InvestigationProgress):
|
||||||
|
graph_update: InvestigationGraphUpdate | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_graph_input(self):
|
||||||
|
if self.graph_update is not None and self.diagram is not None:
|
||||||
|
raise ValueError("use graph_update without a generated Mermaid diagram")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class InvestigationProgressSnapshot(StrictModel):
|
class InvestigationProgressSnapshot(StrictModel):
|
||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
run_id: UUID
|
run_id: UUID
|
||||||
@@ -367,7 +414,7 @@ class AgentArtifactsCommand(StrictModel):
|
|||||||
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
||||||
plan: ExecutionPlanInput | None = None
|
plan: ExecutionPlanInput | None = None
|
||||||
report: InvestigationReportInput | None = None
|
report: InvestigationReportInput | None = None
|
||||||
progress: InvestigationProgress | None = None
|
progress: InvestigationProgressInput | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_artifacts(self):
|
def validate_artifacts(self):
|
||||||
@@ -377,6 +424,8 @@ class AgentArtifactsCommand(StrictModel):
|
|||||||
self.report.diagram if self.report is not None else None,
|
self.report.diagram if self.report is not None else None,
|
||||||
self.progress.diagram if self.progress is not None else None,
|
self.progress.diagram if self.progress is not None else None,
|
||||||
):
|
):
|
||||||
|
if diagram is not None and (diagram.edges is not None or diagram.source_mermaid is not None):
|
||||||
|
raise ValueError("structured graph metadata is host-owned; use progress.graph_update")
|
||||||
missing = _undeclared_diagram_node_refs(diagram)
|
missing = _undeclared_diagram_node_refs(diagram)
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -694,8 +743,27 @@ class AgentArtifactsResult(StrictModel):
|
|||||||
progress_revision: PositiveInt | None = None
|
progress_revision: PositiveInt | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaChartGap(StrictModel):
|
||||||
|
device_id: TaskFeatureId
|
||||||
|
day_offset: Annotated[int, Field(ge=0, le=7)]
|
||||||
|
missing_count: Annotated[int, Field(ge=1, le=100)]
|
||||||
|
reason: Literal["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"]
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaChartContext(StrictModel):
|
||||||
|
role: Literal["current", "comparison"]
|
||||||
|
metric: Literal["pressure", "flow"]
|
||||||
|
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
|
window_start: WorkbenchDateTime
|
||||||
|
window_end: WorkbenchDateTime
|
||||||
|
value_field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
|
||||||
|
gaps: list[ScadaChartGap] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
class TaskSafeChartSeries(StrictModel):
|
class TaskSafeChartSeries(StrictModel):
|
||||||
name: Annotated[str, Field(max_length=128)]
|
name: Annotated[str, Field(max_length=128)]
|
||||||
|
device_id: TaskFeatureId | None = None
|
||||||
|
day_offset: Annotated[int, Field(ge=0, le=7)] | None = None
|
||||||
type: Literal["line", "bar", "scatter"] | None = None
|
type: Literal["line", "bar", "scatter"] | None = None
|
||||||
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)]
|
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)]
|
||||||
sample_times: Annotated[list[Annotated[str, Field(
|
sample_times: Annotated[list[Annotated[str, Field(
|
||||||
@@ -722,6 +790,7 @@ class TaskSafeChartSeries(StrictModel):
|
|||||||
|
|
||||||
class TaskSafeChart(StrictModel):
|
class TaskSafeChart(StrictModel):
|
||||||
title: Annotated[str, Field(max_length=200)]
|
title: Annotated[str, Field(max_length=200)]
|
||||||
|
scada: ScadaChartContext | None = None
|
||||||
chart_type: Literal["line", "bar", "time_comparison"]
|
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
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class OperationalTaskRepository:
|
|||||||
ORDER BY generation DESC LIMIT %s OFFSET %s""",
|
ORDER BY generation DESC LIMIT %s OFFSET %s""",
|
||||||
(self.project_id, task_id, limit, offset),
|
(self.project_id, task_id, limit, offset),
|
||||||
)
|
)
|
||||||
return [{key: value for key, value in row.items() if key != "project_id"}
|
return [{key: value for key, value in row.items() if key not in {"project_id", "artifact_manifest"}}
|
||||||
for row in await cursor.fetchall()]
|
for row in await cursor.fetchall()]
|
||||||
|
|
||||||
async def get_latest_run(self, task_id: str) -> dict | None:
|
async def get_latest_run(self, task_id: str) -> dict | None:
|
||||||
@@ -371,6 +371,34 @@ class OperationalTaskRepository:
|
|||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_scada_presentation(self, task_id: str, run_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='artifact_manifest' AND NOT attisdropped) AS available"""
|
||||||
|
)
|
||||||
|
if not (await cursor.fetchone())["available"]:
|
||||||
|
raise DomainError("DEPENDENCY_UNAVAILABLE",
|
||||||
|
"SCADA display coverage requires resources/sql/035_scada_presentation_coverage.sql in this project database",
|
||||||
|
status=503)
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT artifact_manifest->'scada_presentation' AS document FROM workbench.operational_task_runs
|
||||||
|
WHERE project_id=%s AND task_id=%s AND run_id=%s""",
|
||||||
|
(self.project_id, task_id, run_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_scada_presentation(self, task_id: str, run_id: str, document: dict, now: datetime) -> None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.operational_task_runs
|
||||||
|
SET artifact_manifest=jsonb_set(artifact_manifest,'{scada_presentation}',%s::jsonb,true)
|
||||||
|
WHERE project_id=%s AND task_id=%s AND run_id=%s""",
|
||||||
|
(Jsonb({**document, "updated_at": now.isoformat()}), self.project_id, task_id, run_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise DomainError("RUN_NOT_FOUND", "SCADA display coverage targets an unknown run", status=409)
|
||||||
|
|
||||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
@@ -405,9 +433,9 @@ class OperationalTaskRepository:
|
|||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT task.document,
|
"""SELECT task.document,
|
||||||
CASE WHEN active_run.run_id IS NULL THEN NULL
|
CASE WHEN active_run.run_id IS NULL THEN NULL
|
||||||
ELSE to_jsonb(active_run) - 'project_id' END AS active_run,
|
ELSE to_jsonb(active_run) - 'project_id' - 'artifact_manifest' END AS active_run,
|
||||||
CASE WHEN latest_run.run_id IS NULL THEN NULL
|
CASE WHEN latest_run.run_id IS NULL THEN NULL
|
||||||
ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run
|
ELSE to_jsonb(latest_run) - 'project_id' - 'artifact_manifest' END AS latest_run
|
||||||
FROM workbench.operational_tasks AS task
|
FROM workbench.operational_tasks AS task
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT run.* FROM workbench.operational_task_runs AS run
|
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||||
@@ -628,6 +656,37 @@ class OperationalTaskRepository:
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return row["document"] if row else None
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_latest_graph_progress(self, task_id: str) -> dict | None:
|
||||||
|
availability = await self.connection.execute(
|
||||||
|
"SELECT to_regclass('workbench.operational_task_progress') AS relation"
|
||||||
|
)
|
||||||
|
if not (await availability.fetchone())["relation"]:
|
||||||
|
return None
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_progress
|
||||||
|
WHERE project_id=%s AND task_id=%s
|
||||||
|
AND jsonb_typeof(document#>'{progress,diagram,edges}')='array'
|
||||||
|
ORDER BY (document->>'execution_generation')::bigint DESC, revision DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def get_latest_diagram(self, task_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT diagram FROM (
|
||||||
|
SELECT document#>'{progress,diagram}' AS diagram, published_at AS occurred_at
|
||||||
|
FROM workbench.operational_task_progress WHERE project_id=%s AND task_id=%s
|
||||||
|
UNION ALL
|
||||||
|
SELECT document#>'{report,diagram}', created_at
|
||||||
|
FROM workbench.operational_task_reports WHERE project_id=%s AND task_id=%s
|
||||||
|
) AS diagrams WHERE jsonb_typeof(diagram)='object'
|
||||||
|
ORDER BY occurred_at DESC LIMIT 1""",
|
||||||
|
(self.project_id, task_id, self.project_id, task_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["diagram"] if row else None
|
||||||
|
|
||||||
async def save_progress(self, task_id: str, snapshot: dict) -> None:
|
async def save_progress(self, task_id: str, snapshot: dict) -> None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""INSERT INTO workbench.operational_task_progress
|
"""INSERT INTO workbench.operational_task_progress
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from uuid import UUID
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from psycopg import sql
|
from psycopg import sql
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from app.domain.schemas.operational_task import LEGACY_INVESTIGATION_NODE_ANNOTATIONS
|
||||||
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
|
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
|
||||||
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.analysis import AnalysisResultsRepository
|
||||||
@@ -329,6 +331,29 @@ async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_diagram_projection(stored_sql: str, stored_params: tuple, section: str) -> tuple[str, tuple]:
|
||||||
|
"""Normalize only saved diagram annotations before any data_path or page projection."""
|
||||||
|
if section not in {"progress", "report"}:
|
||||||
|
raise ValueError("reference diagrams only belong to progress or report sections")
|
||||||
|
path = "{data," + section + ",diagram,node_refs}"
|
||||||
|
statement = f"""
|
||||||
|
WITH reference_documents AS (
|
||||||
|
{stored_sql}
|
||||||
|
), reference_aliases AS (
|
||||||
|
SELECT %s::jsonb AS annotations
|
||||||
|
)
|
||||||
|
SELECT CASE WHEN jsonb_typeof(snapshot#>'{path}')='array' THEN
|
||||||
|
jsonb_set(snapshot,'{path}',COALESCE((
|
||||||
|
SELECT jsonb_agg(CASE WHEN annotations ? (node->>'status')
|
||||||
|
THEN jsonb_set(node,'{{status}}',annotations->(node->>'status'))
|
||||||
|
ELSE node END ORDER BY position)
|
||||||
|
FROM jsonb_array_elements(snapshot#>'{path}') WITH ORDINALITY AS nodes(node,position)
|
||||||
|
),'[]'::jsonb)) ELSE snapshot END AS snapshot
|
||||||
|
FROM reference_documents CROSS JOIN reference_aliases
|
||||||
|
"""
|
||||||
|
return statement, (*stored_params, Jsonb(LEGACY_INVESTIGATION_NODE_ANNOTATIONS))
|
||||||
|
|
||||||
|
|
||||||
async def task_context_page(
|
async def task_context_page(
|
||||||
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
|
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
|
||||||
report_revision: int | None, data_path: list[str], limit: int, offset: int,
|
report_revision: int | None, data_path: list[str], limit: int, offset: int,
|
||||||
@@ -379,18 +404,23 @@ async def task_context_page(
|
|||||||
where = " ORDER BY revision DESC LIMIT 1"
|
where = " ORDER BY revision DESC LIMIT 1"
|
||||||
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
|
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
|
||||||
elif section == "run":
|
elif section == "run":
|
||||||
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
|
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id'-'artifact_manifest') AS snapshot
|
||||||
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
|
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
|
||||||
params = (project_id, task_id)
|
params = (project_id, task_id)
|
||||||
elif section == "progress":
|
elif section == "progress":
|
||||||
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
|
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
|
||||||
FROM workbench.operational_task_progress p
|
FROM workbench.operational_task_progress p
|
||||||
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
|
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
|
||||||
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
|
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id
|
||||||
WHERE p.project_id=%s AND p.task_id=%s"""
|
WHERE p.project_id=%s AND p.task_id=%s AND r.generation<=t.execution_generation
|
||||||
|
AND (t.execution_generation=r.generation
|
||||||
|
OR jsonb_typeof(p.document#>'{progress,diagram,edges}')='array')
|
||||||
|
ORDER BY (t.execution_generation=r.generation) DESC,r.generation DESC,p.revision DESC LIMIT 1"""
|
||||||
params = (project_id, task_id)
|
params = (project_id, task_id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, "Unknown task context section")
|
raise HTTPException(422, "Unknown task context section")
|
||||||
|
if section in {"progress", "report"}:
|
||||||
|
stored, params = _reference_diagram_projection(stored, params, section)
|
||||||
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
|
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
|
||||||
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
|
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"get_task_context",
|
"get_task_context",
|
||||||
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本",
|
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;progress/report图节点仅为流程参考标记,旧执行态兼容转换,不代表实时进度;实际执行状态读run、动作状态读actions;report_revision保留选定报告版本",
|
||||||
m.TaskContextInput,
|
m.TaskContextInput,
|
||||||
"operational_task.view",
|
"operational_task.view",
|
||||||
"task",
|
"task",
|
||||||
|
|||||||
@@ -394,6 +394,9 @@ async def execute_query(
|
|||||||
if hasattr(args, "field") and args.field not in units:
|
if hasattr(args, "field") and args.field not in units:
|
||||||
gaps.append(f"Unit metadata is unavailable for field {args.field}")
|
gaps.append(f"Unit metadata is unavailable for field {args.field}")
|
||||||
else:
|
else:
|
||||||
|
# Keep the selected field in durable evidence; `value` alone cannot
|
||||||
|
# distinguish raw observations from cleaned observations.
|
||||||
|
data = [{**row, args.field: row["value"]} if "value" in row else row for row in data]
|
||||||
metadata = [
|
metadata = [
|
||||||
r
|
r
|
||||||
for device in ids
|
for device in ids
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ async def read_complete_scada(
|
|||||||
"replay_id": context["replay_id"] if context else None,
|
"replay_id": context["replay_id"] if context else None,
|
||||||
"batch_sequence": context["batch_sequence"] if context else None,
|
"batch_sequence": context["batch_sequence"] if context else None,
|
||||||
}
|
}
|
||||||
|
if context:
|
||||||
|
coverage["frozen_start"] = (run.get("baseline") or {}).get("start_time") or run["start_time"]
|
||||||
data = {"devices": devices, "readings": rows, "coverage": coverage}
|
data = {"devices": devices, "readings": rows, "coverage": coverage}
|
||||||
account([coverage])
|
account([coverage])
|
||||||
gaps = []
|
gaps = []
|
||||||
|
|||||||
+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],
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Accumulate explanatory investigation paths and generate Mermaid deterministically."""
|
||||||
|
import re
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
InvestigationDiagram, InvestigationDiagramNodeRef, InvestigationGraphUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _label(value: str) -> str:
|
||||||
|
# Encode Mermaid delimiters and HTML before placing untrusted text in quotes.
|
||||||
|
replacements = {"#": "#35;", "&": "#38;", '"': "#quot;", "<": "#lt;",
|
||||||
|
">": "#gt;", "`": "#96;", "%": "#37;", "|": "#124;", "\\": "#92;"}
|
||||||
|
return "".join(replacements.get(c, " " if c in "\r\n" else c) for c in value)
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> InvestigationDiagram:
|
||||||
|
"""Make a saved legacy graph cumulative without parsing or changing its source."""
|
||||||
|
if diagram.edges is not None:
|
||||||
|
return diagram
|
||||||
|
return diagram.model_copy(deep=True, update={"edges": [], "source_mermaid": diagram.mermaid})
|
||||||
|
|
||||||
|
|
||||||
|
def update_investigation_graph(
|
||||||
|
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
||||||
|
) -> InvestigationDiagram:
|
||||||
|
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
|
||||||
|
|
||||||
|
A retry uses a new node id. Relationships describe activity for readers and
|
||||||
|
impose no ordering or completion requirements on the Agent.
|
||||||
|
"""
|
||||||
|
if previous is not None:
|
||||||
|
previous = adopt_legacy_investigation_graph(previous)
|
||||||
|
source = previous.source_mermaid if previous else None
|
||||||
|
def in_source(node_id: str) -> bool:
|
||||||
|
return bool(source and re.search(
|
||||||
|
rf"(?<![A-Za-z0-9_-]){re.escape(node_id)}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
||||||
|
source, re.MULTILINE,
|
||||||
|
))
|
||||||
|
|
||||||
|
nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {}
|
||||||
|
require(len({node.node_id for node in update.nodes}) == len(update.nodes),
|
||||||
|
"GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422)
|
||||||
|
for node in update.nodes:
|
||||||
|
require(node.node_id.lower() not in {"end", "graph", "flowchart", "subgraph"},
|
||||||
|
"GRAPH_NODE_ID_INVALID", "Use a descriptive graph node id", status=422)
|
||||||
|
existing = nodes.get(node.node_id)
|
||||||
|
if existing is None:
|
||||||
|
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
|
||||||
|
"This id belongs to the original Mermaid; use a new node id", status=409)
|
||||||
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||||
|
continue
|
||||||
|
require(not in_source(node.node_id) or existing.label == node.label,
|
||||||
|
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
|
||||||
|
require(existing.status != "failed" or (
|
||||||
|
("status" not in node.model_fields_set or node.status == "failed")
|
||||||
|
and existing.label == node.label
|
||||||
|
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
|
||||||
|
changes = node.model_dump(exclude_unset=True)
|
||||||
|
if existing.status == "failed" and existing.detail and not changes.get("detail"):
|
||||||
|
changes.pop("detail", None)
|
||||||
|
for field in ("evidence_refs", "action_refs", "map_targets"):
|
||||||
|
combined = list(getattr(existing, field))
|
||||||
|
for value in getattr(node, field):
|
||||||
|
if value not in combined:
|
||||||
|
combined.append(value)
|
||||||
|
changes[field] = combined
|
||||||
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate({
|
||||||
|
**existing.model_dump(), **changes,
|
||||||
|
})
|
||||||
|
require(bool(nodes), "GRAPH_NODE_REQUIRED", "Add a node before publishing a graph", status=422)
|
||||||
|
edges = list(previous.edges or []) if previous else []
|
||||||
|
for edge in update.edges:
|
||||||
|
require(edge.source in nodes and edge.target in nodes, "GRAPH_EDGE_NODE_NOT_FOUND",
|
||||||
|
"Every graph edge must reference saved or newly added nodes", status=422)
|
||||||
|
if edge not in edges:
|
||||||
|
edges.append(edge.model_copy(deep=True))
|
||||||
|
require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED",
|
||||||
|
"Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422)
|
||||||
|
lines = [source or "flowchart TD"]
|
||||||
|
for node in nodes.values():
|
||||||
|
if in_source(node.node_id):
|
||||||
|
continue # Preserve original branches, shapes and labels byte for byte.
|
||||||
|
label = node.label + ("(失败)" if node.status == "failed" else "")
|
||||||
|
lines.append(f' {node.node_id}["{_label(label)}"]')
|
||||||
|
for edge in edges:
|
||||||
|
connector = f' -->|"{_label(edge.label)}"| ' if edge.label else " --> "
|
||||||
|
lines.append(f" {edge.source}{connector}{edge.target}")
|
||||||
|
mermaid = "\n".join(lines)
|
||||||
|
require(len(mermaid) <= 20000, "GRAPH_CAPACITY_EXCEEDED",
|
||||||
|
"Investigation Mermaid exceeds 20000 characters; existing paths were retained", status=422)
|
||||||
|
return InvestigationDiagram(
|
||||||
|
title=update.title or (previous.title if previous else "调查脉络"),
|
||||||
|
mermaid=mermaid, node_refs=list(nodes.values()), edges=edges, source_mermaid=source,
|
||||||
|
)
|
||||||
@@ -19,36 +19,53 @@ if TYPE_CHECKING:
|
|||||||
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
|
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
|
||||||
|
|
||||||
|
|
||||||
def track_work_order(task: dict, action: dict) -> None:
|
def track_work_order(task: dict, action: dict) -> bool:
|
||||||
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":
|
||||||
return
|
return False
|
||||||
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":
|
||||||
return
|
return False
|
||||||
context = task.setdefault("handling_context", {})
|
context = task.setdefault("handling_context", {})
|
||||||
orders = context.setdefault("work_orders", {})
|
orders = context.setdefault("work_orders", {})
|
||||||
order_id = receipt["receipt_id"]
|
order_id = receipt["receipt_id"]
|
||||||
if order_id not in orders:
|
if order_id not in orders:
|
||||||
|
proposed_at = action.get("proposed_at")
|
||||||
|
for prior in orders.values():
|
||||||
|
terminal_at = prior.get("terminal_recorded_at")
|
||||||
|
# Only a new decision after the failed field attempt replaces it.
|
||||||
|
# A delayed receipt for an older independent dispatch does not.
|
||||||
|
if (not prior.get("superseded_by") and prior["status"] in {"failed", "cancelled"}
|
||||||
|
and proposed_at and terminal_at
|
||||||
|
and datetime.fromisoformat(proposed_at) > datetime.fromisoformat(terminal_at)):
|
||||||
|
prior["superseded_by"] = order_id
|
||||||
orders[order_id] = {"status": "awaiting_completion", "action_id": str(action["action_id"])}
|
orders[order_id] = {"status": "awaiting_completion", "action_id": str(action["action_id"])}
|
||||||
context.update(phase="waiting_work_order", title="等待现场工单返回")
|
context.update(phase="waiting_work_order", title="等待现场工单返回")
|
||||||
context.pop("verification_evidence_id", None)
|
context.pop("verification_evidence_id", None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def accept_completion(task: dict, feedback: dict) -> bool:
|
def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | None = None) -> bool:
|
||||||
context = task.get("handling_context") or {}
|
context = task.get("handling_context") or {}
|
||||||
order_id = feedback["source_event_id"].removeprefix("peripheral_sample:")
|
order_id = feedback["source_event_id"].removeprefix("peripheral_sample:")
|
||||||
order = context.get("work_orders", {}).get(order_id)
|
order = context.get("work_orders", {}).get(order_id)
|
||||||
if order is None:
|
if order is None:
|
||||||
return False
|
return False
|
||||||
order.update(status=feedback["status"], source_revision=feedback["source_revision"])
|
order.update(status=feedback["status"], source_revision=feedback["source_revision"])
|
||||||
|
if feedback["status"] in {"failed", "cancelled"}:
|
||||||
|
if recorded_at is not None and not order.get("terminal_recorded_at"):
|
||||||
|
order["terminal_recorded_at"] = recorded_at.isoformat()
|
||||||
|
else:
|
||||||
|
order.pop("terminal_recorded_at", None)
|
||||||
# The synchronizer preserves the field activity's business time on ordinary
|
# The synchronizer preserves the field activity's business time on ordinary
|
||||||
# edits. A revised field receipt can explicitly correct the verification start.
|
# edits. A revised field receipt can explicitly correct the verification start.
|
||||||
if feedback["status"] == "completed":
|
if feedback["status"] == "completed":
|
||||||
order["returned_at"] = feedback["occurred_at"]
|
order["returned_at"] = feedback["occurred_at"]
|
||||||
else:
|
else:
|
||||||
order.pop("returned_at", None)
|
order.pop("returned_at", None)
|
||||||
orders = list(context["work_orders"].values())
|
if order.get("superseded_by"):
|
||||||
if all(item["status"] == "completed" for item in orders):
|
return True
|
||||||
|
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
|
||||||
|
if orders and all(item["status"] == "completed" for item in orders):
|
||||||
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
||||||
previous = context.get("returned_at")
|
previous = context.get("returned_at")
|
||||||
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
||||||
|
|||||||
+252
-150
@@ -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 (
|
||||||
@@ -49,6 +49,8 @@ from app.domain.schemas.operational_task import (
|
|||||||
InvestigationReportRevision,
|
InvestigationReportRevision,
|
||||||
InvestigationReportReference,
|
InvestigationReportReference,
|
||||||
InvestigationProgressSnapshot,
|
InvestigationProgressSnapshot,
|
||||||
|
InvestigationProgress,
|
||||||
|
InvestigationDiagram,
|
||||||
IssueCommandPayload,
|
IssueCommandPayload,
|
||||||
NotificationAcknowledgeCommand,
|
NotificationAcknowledgeCommand,
|
||||||
OperationalTaskCreate,
|
OperationalTaskCreate,
|
||||||
@@ -71,12 +73,18 @@ from app.domain.schemas.operational_task import (
|
|||||||
)
|
)
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.services.content_digest import digest
|
from app.services.content_digest import digest
|
||||||
|
from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph
|
||||||
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 import scada_presentation_coverage
|
||||||
from app.services.time_comparison_chart import validate_time_comparison_chart
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +97,7 @@ 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"):
|
execution_scope: str = "production"):
|
||||||
self.repo = repository
|
self.repo = repository
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -114,42 +122,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
|
||||||
@@ -286,9 +266,10 @@ 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"])
|
||||||
|
if progress is None:
|
||||||
|
progress = await self.repo.get_latest_graph_progress(str(task_id))
|
||||||
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"):
|
||||||
feedback = await self.repo.get_feedback(
|
feedback = await self.repo.get_feedback(
|
||||||
@@ -397,9 +378,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,
|
||||||
@@ -580,6 +560,8 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(replay["run"])
|
return OperationalTaskRun.model_validate(replay["run"])
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
|
require(run.get("failure_code") != "SUPERSEDED_BY_REQUEUE", "RUN_SUPERSEDED",
|
||||||
|
"New task input superseded this run; the replacement run will continue", status=409)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
"Run revision changed", status=409)
|
"Run revision changed", status=409)
|
||||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||||
@@ -616,8 +598,7 @@ 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(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
|
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
|
||||||
@@ -772,7 +753,7 @@ class OperationalTaskService:
|
|||||||
# 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"]
|
||||||
@@ -784,6 +765,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))
|
||||||
@@ -876,11 +858,27 @@ class OperationalTaskService:
|
|||||||
for item in await self.repo.list_actions(str(task_id))
|
for item in await self.repo.list_actions(str(task_id))
|
||||||
}
|
}
|
||||||
progress_revision = None
|
progress_revision = None
|
||||||
|
saved_diagram = None
|
||||||
|
if command.progress is not None or command.report is not None:
|
||||||
|
stored = await self.repo.get_latest_graph_progress(str(task_id))
|
||||||
|
if stored is not None:
|
||||||
|
saved_diagram = InvestigationProgressSnapshot.model_validate(stored).progress.diagram
|
||||||
|
elif command.progress is not None:
|
||||||
|
legacy = await self.repo.get_latest_diagram(str(task_id))
|
||||||
|
saved_diagram = InvestigationDiagram.model_validate(legacy) if legacy else None
|
||||||
if command.progress is not None:
|
if command.progress is not None:
|
||||||
require(command.run_id is not None, "RUN_REQUIRED",
|
require(command.run_id is not None, "RUN_REQUIRED",
|
||||||
"Progress must identify its current execution run", status=422)
|
"Progress must identify its current execution run", status=422)
|
||||||
if command.progress.diagram:
|
diagram = command.progress.diagram
|
||||||
for node in command.progress.diagram.node_refs:
|
if command.progress.graph_update is not None:
|
||||||
|
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
||||||
|
elif saved_diagram is not None:
|
||||||
|
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
|
||||||
|
"Use graph_update to extend the saved investigation graph", status=422)
|
||||||
|
if diagram is None:
|
||||||
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
|
if diagram:
|
||||||
|
for node in diagram.node_refs:
|
||||||
self._require_evidence_refs(node.evidence_refs, known_evidence)
|
self._require_evidence_refs(node.evidence_refs, known_evidence)
|
||||||
require(all(str(ref) in actions for ref in node.action_refs),
|
require(all(str(ref) in actions for ref in node.action_refs),
|
||||||
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
|
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
|
||||||
@@ -889,9 +887,11 @@ class OperationalTaskService:
|
|||||||
snapshot = InvestigationProgressSnapshot(
|
snapshot = InvestigationProgressSnapshot(
|
||||||
revision=progress_revision, run_id=active_run["run_id"],
|
revision=progress_revision, run_id=active_run["run_id"],
|
||||||
execution_generation=task["execution_generation"],
|
execution_generation=task["execution_generation"],
|
||||||
source_session_id=command.source_session_id, published_at=now, progress=command.progress,
|
source_session_id=command.source_session_id, published_at=now,
|
||||||
|
progress=InvestigationProgress(summary=command.progress.summary, diagram=diagram),
|
||||||
)
|
)
|
||||||
await self.repo.save_progress(str(task_id), _json(snapshot))
|
await self.repo.save_progress(str(task_id), _json(snapshot))
|
||||||
|
saved_diagram = diagram
|
||||||
await self._append_event(
|
await self._append_event(
|
||||||
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
||||||
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
|
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
|
||||||
@@ -943,21 +943,32 @@ class OperationalTaskService:
|
|||||||
await self.repo.insert_plan(str(task_id), _json(snapshot))
|
await self.repo.insert_plan(str(task_id), _json(snapshot))
|
||||||
current_plan = snapshot
|
current_plan = snapshot
|
||||||
if command.report:
|
if command.report:
|
||||||
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
|
report_input = command.report
|
||||||
if command.report.stage == "final":
|
if saved_diagram is not None:
|
||||||
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
|
report_input = report_input.model_copy(update={
|
||||||
|
"diagram": diagram,
|
||||||
|
"evidence_refs": list(dict.fromkeys([
|
||||||
|
*report_input.evidence_refs,
|
||||||
|
*(ref for node in diagram.node_refs for ref in node.evidence_refs),
|
||||||
|
])),
|
||||||
|
})
|
||||||
|
self._require_evidence_refs(report_input.evidence_refs, known_evidence)
|
||||||
|
if report_input.stage == "final":
|
||||||
require_complete_scada_evidence(
|
require_complete_scada_evidence(
|
||||||
task, await self.repo.list_evidence(str(task_id)), command.report.evidence_refs,
|
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
|
||||||
)
|
)
|
||||||
for gap in command.report.data_gaps:
|
await self._verify_scada_report_presentation(task, active_run, _json(report_input), now)
|
||||||
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
|
for gap in report_input.data_gaps:
|
||||||
|
require(set(gap.evidence_refs) <= set(report_input.evidence_refs),
|
||||||
"REPORT_GAP_EVIDENCE_INVALID",
|
"REPORT_GAP_EVIDENCE_INVALID",
|
||||||
"Gap evidence must be included in report evidence", status=422)
|
"Gap evidence must be included in report evidence", status=422)
|
||||||
if command.report.diagram:
|
if report_input.diagram:
|
||||||
for node in command.report.diagram.node_refs:
|
for node in report_input.diagram.node_refs:
|
||||||
self._require_evidence_refs(node.evidence_refs, set(command.report.evidence_refs))
|
self._require_evidence_refs(node.evidence_refs, set(report_input.evidence_refs))
|
||||||
require(all(str(ref) in actions for ref in node.action_refs),
|
require(all(str(ref) in actions for ref in node.action_refs),
|
||||||
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
|
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
|
||||||
if command.report.outcome == "insufficient_data":
|
if report_input.outcome == "insufficient_data":
|
||||||
require(not any(action.approval_status == "pending" or (
|
require(not any(action.approval_status == "pending" or (
|
||||||
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
||||||
) for action in actions.values()), "REPORT_ACTION_PENDING",
|
) for action in actions.values()), "REPORT_ACTION_PENDING",
|
||||||
@@ -967,7 +978,7 @@ class OperationalTaskService:
|
|||||||
revision=report_revision,
|
revision=report_revision,
|
||||||
execution_generation=task["execution_generation"],
|
execution_generation=task["execution_generation"],
|
||||||
source_session_id=command.source_session_id,
|
source_session_id=command.source_session_id,
|
||||||
published_at=now, published_by=self._actor(), report=command.report,
|
published_at=now, published_by=self._actor(), report=report_input,
|
||||||
)
|
)
|
||||||
report = await self._report_with_progress_diagram(task_id, report)
|
report = await self._report_with_progress_diagram(task_id, report)
|
||||||
await self.repo.insert_report(str(task_id), _json(report))
|
await self.repo.insert_report(str(task_id), _json(report))
|
||||||
@@ -980,20 +991,21 @@ class OperationalTaskService:
|
|||||||
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
|
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
|
||||||
)
|
)
|
||||||
previous_result = previous_report["report"] if previous_report else None
|
previous_result = previous_report["report"] if previous_report else None
|
||||||
if command.report.stage == "final" and (
|
effective_report = _json(report.report)
|
||||||
|
if report.report.stage == "final" and (
|
||||||
previous_report is None
|
previous_report is None
|
||||||
or previous_report.get("execution_generation") != task["execution_generation"]
|
or previous_report.get("execution_generation") != task["execution_generation"]
|
||||||
or previous_result.get("stage") != "final"
|
or previous_result.get("stage") != "final"
|
||||||
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
|
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != effective_report.get(key)
|
||||||
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
|
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
|
||||||
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
|
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks", "scada_device_ids"))
|
||||||
):
|
):
|
||||||
await self._resolve_notifications(
|
await self._resolve_notifications(
|
||||||
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
|
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
|
||||||
now=now, reason="report_superseded",
|
now=now, reason="report_superseded",
|
||||||
)
|
)
|
||||||
await self._notify(
|
await self._notify(
|
||||||
task_id, "report_updated", task["title"], command.report.current_conclusion, now,
|
task_id, "report_updated", task["title"], report.report.current_conclusion, now,
|
||||||
report_reference=InvestigationReportReference(
|
report_reference=InvestigationReportReference(
|
||||||
execution_generation=task["execution_generation"], revision=report_revision,
|
execution_generation=task["execution_generation"], revision=report_revision,
|
||||||
),
|
),
|
||||||
@@ -1017,25 +1029,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:
|
||||||
@@ -1100,8 +1096,11 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
payload = _json(command.command)
|
payload = _json(command.command)
|
||||||
|
await self._record_scada_presentation(task, run, payload, now)
|
||||||
if command.command.action == "open_chart":
|
if command.command.action == "open_chart":
|
||||||
validate_time_comparison_chart(payload["chart"])
|
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
||||||
|
payload["chart"].get("scada") and scada_presentation_coverage.applies(task),
|
||||||
|
))
|
||||||
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
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":
|
||||||
@@ -1139,6 +1138,61 @@ class OperationalTaskService:
|
|||||||
await self._save_request(scope, command.request_id, body, _json(result))
|
await self._save_request(scope, command.request_id, body, _json(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _record_scada_presentation(self, task: dict, run: dict, payload: dict, now: datetime) -> None:
|
||||||
|
if not scada_presentation_coverage.applies(task):
|
||||||
|
return
|
||||||
|
action = payload["action"]
|
||||||
|
targets = []
|
||||||
|
if action == "render_scada_analysis":
|
||||||
|
targets = [item["device_id"] for item in payload["items"]]
|
||||||
|
elif action in {"locate_features", "highlight_features"} and payload["layer"] == "scada":
|
||||||
|
targets = payload["feature_ids"]
|
||||||
|
elif action != "open_chart":
|
||||||
|
return
|
||||||
|
evidence = await self.repo.list_evidence(task["task_id"])
|
||||||
|
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
|
||||||
|
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
|
||||||
|
if action == "open_chart":
|
||||||
|
if payload["chart"].get("scada"):
|
||||||
|
known = {item["evidence_id"] for item in evidence}
|
||||||
|
self._require_evidence_refs(payload["evidence_refs"], known)
|
||||||
|
chart = scada_presentation_coverage.verify_chart(task, payload["chart"], evidence, payload["evidence_refs"])
|
||||||
|
targets = chart["device_ids"]
|
||||||
|
duplicates = [chart_id for chart_id, stored in (document or {}).get("charts", {}).items()
|
||||||
|
if chart_id != payload["chart_id"] and stored["role"] == chart["role"] == "comparison"
|
||||||
|
and any(set(stored.get("coverage", {}).get(device, {}).get("covered", []))
|
||||||
|
& set(chart["coverage"][device]["covered"])
|
||||||
|
for device in set(stored["device_ids"]) & set(targets))]
|
||||||
|
require(not duplicates, "SCADA_CHART_DUPLICATE_DEVICE",
|
||||||
|
f"设备同期图已存在,请更新原chart_id:{', '.join(duplicates)}", refs=duplicates)
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
document["charts"][payload["chart_id"]] = chart
|
||||||
|
elif document and payload["chart_id"] in document["charts"]:
|
||||||
|
# Replacing a typed chart with a generic one removes its coverage,
|
||||||
|
# while cumulative targets prevent shrinking the required set.
|
||||||
|
del document["charts"][payload["chart_id"]]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
|
||||||
|
|
||||||
|
async def _verify_scada_report_presentation(self, task: dict, run: dict, report: dict, now: datetime) -> None:
|
||||||
|
if not scada_presentation_coverage.applies(task):
|
||||||
|
return
|
||||||
|
if report.get("stage") != "final" and not report.get("scada_device_ids"):
|
||||||
|
return
|
||||||
|
evidence = await self.repo.list_evidence(task["task_id"])
|
||||||
|
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
|
||||||
|
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
|
||||||
|
targets = list(report.get("scada_device_ids") or [])
|
||||||
|
if targets:
|
||||||
|
document = scada_presentation_coverage.add_targets(document, targets, observations)
|
||||||
|
if report.get("stage") == "final":
|
||||||
|
scada_presentation_coverage.require_complete(task, document, observations)
|
||||||
|
if document:
|
||||||
|
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
|
||||||
|
|
||||||
async def propose_action(
|
async def propose_action(
|
||||||
self, task_id: UUID, command: ActionProposalCommand
|
self, task_id: UUID, command: ActionProposalCommand
|
||||||
) -> HumanApprovalAction:
|
) -> HumanApprovalAction:
|
||||||
@@ -1387,6 +1441,7 @@ class OperationalTaskService:
|
|||||||
|
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
await self.repo.lock(f"request:{scope}:{command.request_id}")
|
await self.repo.lock(f"request:{scope}:{command.request_id}")
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
current = await self._action(task_id, action_id)
|
current = await self._action(task_id, action_id)
|
||||||
require(current.version == action.version and current.execution_status == "unknown",
|
require(current.version == action.version and current.execution_status == "unknown",
|
||||||
@@ -1422,8 +1477,12 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||||
task_document = await self._task(task_id)
|
task_document = await self._task(task_id)
|
||||||
track_work_order(task_document, _json(current))
|
tracked = track_work_order(task_document, _json(current))
|
||||||
await self._requeue_task(task_document, outcome_recorded_at)
|
if (tracked or task_document["status"] == "cancelled"
|
||||||
|
or (task_document.get("source") or {}).get("deleted_at")):
|
||||||
|
await self._keep_work_order_wait(task_document, outcome_recorded_at)
|
||||||
|
else:
|
||||||
|
await self._requeue_task(task_document, outcome_recorded_at)
|
||||||
await self.repo.update_task(
|
await self.repo.update_task(
|
||||||
_task_json(OperationalTaskSummary.model_validate(task_document))
|
_task_json(OperationalTaskSummary.model_validate(task_document))
|
||||||
)
|
)
|
||||||
@@ -1459,6 +1518,7 @@ 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 HumanApprovalAction.model_validate(replay["action"])
|
return HumanApprovalAction.model_validate(replay["action"])
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
current = await self._action(task_id, action_id)
|
current = await self._action(task_id, action_id)
|
||||||
require(current.version == action.version, "VERSION_CONFLICT",
|
require(current.version == action.version, "VERSION_CONFLICT",
|
||||||
@@ -1496,8 +1556,12 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
if outcome.status in {"succeeded", "failed", "unsupported"}:
|
||||||
task_document = await self._task(task_id)
|
task_document = await self._task(task_id)
|
||||||
track_work_order(task_document, _json(current))
|
tracked = track_work_order(task_document, _json(current))
|
||||||
await self._requeue_task(task_document, outcome_recorded_at)
|
if (tracked or task_document["status"] == "cancelled"
|
||||||
|
or (task_document.get("source") or {}).get("deleted_at")):
|
||||||
|
await self._keep_work_order_wait(task_document, outcome_recorded_at)
|
||||||
|
else:
|
||||||
|
await self._requeue_task(task_document, outcome_recorded_at)
|
||||||
await self.repo.update_task(
|
await self.repo.update_task(
|
||||||
_task_json(OperationalTaskSummary.model_validate(task_document))
|
_task_json(OperationalTaskSummary.model_validate(task_document))
|
||||||
)
|
)
|
||||||
@@ -1513,11 +1577,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")
|
||||||
@@ -1599,8 +1665,8 @@ class OperationalTaskService:
|
|||||||
"EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409)
|
"EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409)
|
||||||
else:
|
else:
|
||||||
await self.repo.insert_evidence(str(task_id), _json(evidence))
|
await self.repo.insert_evidence(str(task_id), _json(evidence))
|
||||||
self._apply_feedback_status(task, command.status)
|
|
||||||
if task["task_type"] == "external_work_order":
|
if task["task_type"] == "external_work_order":
|
||||||
|
self._apply_feedback_status(task, command.status)
|
||||||
task["source"] = {
|
task["source"] = {
|
||||||
**task["source"],
|
**task["source"],
|
||||||
"external_revision": command.source_revision,
|
"external_revision": command.source_revision,
|
||||||
@@ -1620,10 +1686,27 @@ class OperationalTaskService:
|
|||||||
# Recover older tasks whose successful dispatch predates tracking.
|
# Recover older tasks whose successful dispatch predates tracking.
|
||||||
for item in await self.repo.list_actions(str(task_id)):
|
for item in await self.repo.list_actions(str(task_id)):
|
||||||
track_work_order(task, item)
|
track_work_order(task, item)
|
||||||
accept_completion(task, body)
|
linked_work_order = accept_completion(task, body, recorded_at=now)
|
||||||
if matching_source:
|
if matching_source:
|
||||||
task["source"] = {**task["source"], "external_revision": command.source_revision}
|
task["source"] = {**task["source"], "external_revision": command.source_revision}
|
||||||
await self._requeue_task(task, now)
|
feedback_order = (task.get("handling_context") or {}).get("work_orders", {}).get(
|
||||||
|
command.source_event_id.removeprefix("peripheral_sample:"), {})
|
||||||
|
if linked_work_order and (feedback_order.get("superseded_by")
|
||||||
|
or task["handling_context"].get("phase") == "completed"):
|
||||||
|
# A receipt edit with the same completion time does not
|
||||||
|
# reopen an incident that already passed recovery review.
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
elif (linked_work_order and command.status not in {"failed", "cancelled"}
|
||||||
|
and task["handling_context"].get("phase") in {"waiting_work_order", "waiting_scada"}):
|
||||||
|
# Progress is persisted, not a reason to restart analysis.
|
||||||
|
# Completed field work waits for causal SCADA evidence;
|
||||||
|
# reconcile_recoveries wakes the same task when it is ready.
|
||||||
|
await self._keep_work_order_wait(task, now)
|
||||||
|
elif (linked_work_order and task["handling_context"].get("phase") == "verification_ready"
|
||||||
|
and await self.repo.get_active_run(str(task_id)) is not None):
|
||||||
|
await self._keep_work_order_wait(task, now)
|
||||||
|
else:
|
||||||
|
await self._requeue_task(task, now)
|
||||||
else:
|
else:
|
||||||
task.update(revision=task["revision"] + 1, updated_at=now)
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||||
@@ -1674,11 +1757,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"
|
||||||
@@ -1984,13 +2069,12 @@ 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"]),
|
)
|
||||||
)
|
require(not pending, "PENDING_CALCULATIONS",
|
||||||
require(not pending, "PENDING_CALCULATIONS",
|
"Wait for submitted calculations before publishing a final report or completing the run",
|
||||||
"Wait for submitted calculations before publishing a final report or completing the run",
|
refs=pending, status=409, recovery="retry_same_request")
|
||||||
refs=pending, status=409, recovery="retry_same_request")
|
|
||||||
|
|
||||||
async def _finish_run(
|
async def _finish_run(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
||||||
@@ -2020,11 +2104,10 @@ 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",
|
status=409, recovery="retry_same_request", refs=pending_messages)
|
||||||
status=409, recovery="retry_same_request", refs=pending_messages)
|
|
||||||
plan_document = await self.repo.get_latest_plan(str(task_id))
|
plan_document = await self.repo.get_latest_plan(str(task_id))
|
||||||
report_document = await self.repo.get_latest_report(str(task_id))
|
report_document = await self.repo.get_latest_report(str(task_id))
|
||||||
plan = (
|
plan = (
|
||||||
@@ -2045,8 +2128,22 @@ class OperationalTaskService:
|
|||||||
and plan.source_session_id == command.source_session_id
|
and plan.source_session_id == command.source_session_id
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
actions = [HumanApprovalAction.model_validate(item)
|
||||||
|
for item in await self.repo.list_actions(str(task_id))]
|
||||||
|
work_order_wait = task["task_type"] == "event_investigation" and (
|
||||||
|
(task.get("handling_context") or {}).get("phase") in {"waiting_work_order", "waiting_scada"}
|
||||||
|
or any(action.kind == "create_work_order" and (
|
||||||
|
action.approval_status == "pending" or (
|
||||||
|
action.approval_status == "approved"
|
||||||
|
and action.execution_status in {"not_submitted", "submitted", "unknown"}
|
||||||
|
)) for action in actions)
|
||||||
|
)
|
||||||
if pending_question:
|
if pending_question:
|
||||||
lifecycle = "waiting_human"
|
lifecycle = "waiting_human"
|
||||||
|
elif same_generation and work_order_wait and report.report.stage == "interim":
|
||||||
|
# Ending a run is distinct from closing an incident. Keep
|
||||||
|
# the current stage report and release the execution lease.
|
||||||
|
lifecycle = "waiting_human"
|
||||||
elif not same_generation or report.report.stage != "final":
|
elif not same_generation or report.report.stage != "final":
|
||||||
final_outcome = "failed"
|
final_outcome = "failed"
|
||||||
lifecycle = "failed"
|
lifecycle = "failed"
|
||||||
@@ -2055,10 +2152,7 @@ class OperationalTaskService:
|
|||||||
"Run requires a final report from the same generation"
|
"Run requires a final report from the same generation"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
actions = [
|
await self._verify_scada_report_presentation(task, run, _json(report.report), now)
|
||||||
HumanApprovalAction.model_validate(item)
|
|
||||||
for item in await self.repo.list_actions(str(task_id))
|
|
||||||
]
|
|
||||||
access_requests = [
|
access_requests = [
|
||||||
TaskAccessRequest.model_validate(item)
|
TaskAccessRequest.model_validate(item)
|
||||||
for item in await self._list_access_requests(task_id)
|
for item in await self._list_access_requests(task_id)
|
||||||
@@ -2122,7 +2216,7 @@ class OperationalTaskService:
|
|||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
elif (lifecycle == "waiting_human" and not pending_question
|
elif (lifecycle == "waiting_human" and not pending_question
|
||||||
and (task.get("handling_context") or {}).get("phase") != "waiting_scada"):
|
and (task.get("handling_context") or {}).get("phase") not in {"waiting_work_order", "waiting_scada"}):
|
||||||
pending_actions = any(
|
pending_actions = any(
|
||||||
action.approval_status == "pending" for action in actions
|
action.approval_status == "pending" for action in actions
|
||||||
)
|
)
|
||||||
@@ -2154,8 +2248,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"}},
|
||||||
@@ -2358,44 +2450,58 @@ class OperationalTaskService:
|
|||||||
}[status]
|
}[status]
|
||||||
task["agent_lease_expires_at"] = None
|
task["agent_lease_expires_at"] = None
|
||||||
|
|
||||||
|
async def _keep_work_order_wait(self, task: dict, now: datetime) -> None:
|
||||||
|
"""Persist a field wait without revoking a run or allocating a generation."""
|
||||||
|
if task["status"] != "cancelled" and not (task.get("source") or {}).get("deleted_at"):
|
||||||
|
active = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
|
if active is not None:
|
||||||
|
task.update(
|
||||||
|
lifecycle_status="open", status="queued" if active["status"] == "queued" else "running",
|
||||||
|
agent_session_id=active.get("agent_session_id"),
|
||||||
|
agent_lease_expires_at=active.get("lease_expires_at"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
task.update(lifecycle_status="waiting_human", status="waiting_human",
|
||||||
|
agent_session_id=None, agent_lease_expires_at=None)
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
|
||||||
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']}")
|
# Completion may have won the run lock after our first read.
|
||||||
# Completion may have won the run lock after our first read.
|
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
|
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
|
||||||
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
|
|
||||||
active_run = refreshed_run
|
|
||||||
break
|
|
||||||
active_run = refreshed_run
|
active_run = refreshed_run
|
||||||
if active_run is not None and active_run["status"] == "queued":
|
break
|
||||||
# Multiple inputs can arrive before the worker claims the next
|
active_run = refreshed_run
|
||||||
# generation. They are all visible when that queued run starts,
|
if active_run is not None and active_run["status"] == "queued":
|
||||||
# so coalesce them instead of violating the one-active-run rule.
|
# Multiple inputs can arrive before the worker claims the next
|
||||||
task.update(
|
# generation. They are all visible when that queued run starts,
|
||||||
lifecycle_status="open", status="queued",
|
# so coalesce them instead of violating the one-active-run rule.
|
||||||
revision=task["revision"] + 1,
|
task.update(
|
||||||
execution_generation=active_run["generation"],
|
lifecycle_status="open", status="queued",
|
||||||
execution_request_id=str(active_run["execution_request_id"]),
|
revision=task["revision"] + 1,
|
||||||
agent_session_id=None, agent_lease_expires_at=None,
|
execution_generation=active_run["generation"],
|
||||||
updated_at=now,
|
execution_request_id=str(active_run["execution_request_id"]),
|
||||||
)
|
agent_session_id=None, agent_lease_expires_at=None,
|
||||||
return
|
updated_at=now,
|
||||||
if active_run is not None:
|
)
|
||||||
expected = active_run["revision"]
|
return
|
||||||
active_run.update(
|
if active_run is not None:
|
||||||
status="cancelled", revision=expected + 1,
|
expected = active_run["revision"]
|
||||||
lease_expires_at=None, finished_at=now,
|
active_run.update(
|
||||||
failure_code="SUPERSEDED_BY_REQUEUE",
|
status="cancelled", revision=expected + 1,
|
||||||
failure_detail="New task input superseded the active run",
|
lease_expires_at=None, finished_at=now,
|
||||||
updated_at=now,
|
failure_code="SUPERSEDED_BY_REQUEUE",
|
||||||
)
|
failure_detail="New task input superseded the active run",
|
||||||
cancelled = OperationalTaskRun.model_validate(active_run)
|
updated_at=now,
|
||||||
await self.repo.update_run(_json(cancelled), expected)
|
)
|
||||||
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
|
cancelled = OperationalTaskRun.model_validate(active_run)
|
||||||
await self._run_event(cancelled, now)
|
await self.repo.update_run(_json(cancelled), expected)
|
||||||
|
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
|
||||||
|
await self._run_event(cancelled, now)
|
||||||
generation = task["execution_generation"] + 1
|
generation = task["execution_generation"] + 1
|
||||||
task_id = UUID(task["task_id"])
|
task_id = UUID(task["task_id"])
|
||||||
task.update(
|
task.update(
|
||||||
@@ -2405,9 +2511,8 @@ 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)
|
|
||||||
|
|
||||||
async def _notify(
|
async def _notify(
|
||||||
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
|
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
|
||||||
@@ -2443,10 +2548,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,
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Verify monitoring charts against observations and require run-local device coverage."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from math import isclose
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow"}
|
||||||
|
|
||||||
|
|
||||||
|
def applies(task: dict) -> bool:
|
||||||
|
if task.get("workflow") != "scada_anomaly" and not task.get("source", {}).get("replay_context"):
|
||||||
|
return False
|
||||||
|
# Recovery has its own Server-verified post-repair evidence and finalization gate.
|
||||||
|
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "completed"}
|
||||||
|
|
||||||
|
|
||||||
|
def cutoff(task: dict) -> datetime:
|
||||||
|
replay = task.get("source", {}).get("replay_context") or {}
|
||||||
|
return datetime.fromisoformat(replay.get("knowledge_cutoff") or task["trigger"]["occurred_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(value: str) -> str:
|
||||||
|
return value.replace("³", "3").replace(" ", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaObservations:
|
||||||
|
def __init__(self, task: dict, evidence: list[dict], refs: list[str] | None = None):
|
||||||
|
self.devices: dict[str, dict] = {}
|
||||||
|
self.values: dict[tuple[str, datetime], dict] = {}
|
||||||
|
self.windows: list[tuple[set[str], set[str], datetime, datetime]] = []
|
||||||
|
self.frozen_limits: list[tuple[set[str], datetime]] = []
|
||||||
|
target = cutoff(task)
|
||||||
|
replay = task.get("source", {}).get("replay_context") or {}
|
||||||
|
for item in evidence:
|
||||||
|
snapshot = item.get("data_snapshot") or {}
|
||||||
|
result = snapshot.get("data") or {}
|
||||||
|
if snapshot.get("source") != "query_scada_readings" or not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
data = result.get("data")
|
||||||
|
coverage = data.get("coverage", {}) if isinstance(data, dict) else {}
|
||||||
|
if replay and coverage and (coverage.get("replay_id") != replay["replay_id"]
|
||||||
|
or coverage.get("batch_sequence") != replay["batch_sequence"]):
|
||||||
|
continue
|
||||||
|
window = result.get("window") or {}
|
||||||
|
if not window.get("start") or not window.get("end"):
|
||||||
|
continue
|
||||||
|
start, end = (datetime.fromisoformat(window[key]) for key in ("start", "end"))
|
||||||
|
if end > target:
|
||||||
|
continue
|
||||||
|
devices = data.get("devices", []) if isinstance(data, dict) else []
|
||||||
|
for device in devices:
|
||||||
|
self.devices[str(device["device_id"])] = device
|
||||||
|
if refs is not None and item["evidence_id"] not in refs:
|
||||||
|
continue
|
||||||
|
rows = data.get("readings", []) if isinstance(data, dict) else data
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
continue
|
||||||
|
ids = {str(row["device_id"]) for row in rows if "device_id" in row}
|
||||||
|
outside = set(coverage.get("outside_frozen_scope_device_ids", []))
|
||||||
|
if coverage.get("query_complete") is True:
|
||||||
|
ids.update(coverage.get("queried_device_ids", []))
|
||||||
|
ids.update(str(device["device_id"]) for device in devices)
|
||||||
|
page = result.get("page") or {}
|
||||||
|
complete = not (snapshot.get("truncated") or result.get("truncated")
|
||||||
|
or page.get("has_more") or page.get("offset", 0))
|
||||||
|
if complete:
|
||||||
|
self.windows.append((ids, outside, start, end))
|
||||||
|
if replay and coverage.get("query_complete") and coverage.get("frozen_start"):
|
||||||
|
self.frozen_limits.append((ids, datetime.fromisoformat(coverage["frozen_start"])))
|
||||||
|
for row in rows:
|
||||||
|
if "time" in row and "device_id" in row:
|
||||||
|
at = datetime.fromisoformat(row["time"])
|
||||||
|
if start <= at <= end:
|
||||||
|
observed = self.values.setdefault((str(row["device_id"]), at), {})
|
||||||
|
observed.update({field: row[field] for field in ("monitored_value", "cleaned_value") if field in row})
|
||||||
|
|
||||||
|
def absent_reason(self, device: str, at: datetime) -> str | None:
|
||||||
|
for ids, outside, start, end in self.windows:
|
||||||
|
if device in ids and start <= at <= end:
|
||||||
|
return "outside_frozen_scope" if device in outside else "no_readings"
|
||||||
|
for ids, frozen_start in self.frozen_limits:
|
||||||
|
if device in ids and at < frozen_start:
|
||||||
|
return "outside_frozen_window"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def window_is_empty(self, device: str, start: datetime, end: datetime) -> bool:
|
||||||
|
if any(key == device and start <= at <= end for key, at in self.values):
|
||||||
|
return False
|
||||||
|
intervals = [(left, right) for ids, _, left, right in self.windows if device in ids]
|
||||||
|
intervals.extend((start, frozen_start) for ids, frozen_start in self.frozen_limits if device in ids)
|
||||||
|
through = start
|
||||||
|
for left, right in sorted(intervals):
|
||||||
|
if left > through:
|
||||||
|
break
|
||||||
|
through = max(through, right)
|
||||||
|
if through >= end:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def period(self, device: str, start: datetime, end: datetime) -> timedelta:
|
||||||
|
frequency = str(self.devices[device].get("transmission_frequency") or "")
|
||||||
|
match = re.fullmatch(r"(\d+):([0-5]\d):([0-5]\d)", frequency)
|
||||||
|
if match:
|
||||||
|
hours, minutes, seconds = map(int, match.groups())
|
||||||
|
period = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||||
|
else:
|
||||||
|
minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
|
||||||
|
times = sorted(at for key, at in self.values if key == device and start <= at <= end)
|
||||||
|
period = (timedelta(minutes=int(minutes[1])) if minutes else
|
||||||
|
min((right - left for left, right in zip(times, times[1:])), default=timedelta(0)))
|
||||||
|
require(period > timedelta(0), "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 缺少可核实的采样周期,请读取完整设备属性", refs=[device])
|
||||||
|
return period
|
||||||
|
|
||||||
|
def slots(self, device: str, start: datetime, end: datetime) -> list[datetime]:
|
||||||
|
"""Keep the business bounds exact, aligning slots to the observed sampling phase."""
|
||||||
|
period = self.period(device, start, end)
|
||||||
|
observed = sorted(at for key, at in self.values if key == device and start <= at <= end)
|
||||||
|
historical = sorted(at for key, at in self.values if key == device and at < start)
|
||||||
|
# Referenced historical observations preserve phase even if current data is absent.
|
||||||
|
anchor = (observed[0] if observed else historical[-1] if historical else
|
||||||
|
start.astimezone(DISPLAY_TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0))
|
||||||
|
first = start + (anchor - start) % period
|
||||||
|
return [first + period * index for index in range(int((end - first) // period) + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def add_targets(document: dict | None, devices: list[str], observations: ScadaObservations) -> dict:
|
||||||
|
result = deepcopy(document) if document else {"version": 1, "targets": [], "charts": {}}
|
||||||
|
targets = set(result["targets"])
|
||||||
|
for device in devices:
|
||||||
|
require(device in observations.devices, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 未出现在本轮截止时间内的完整设备证据中", refs=[device])
|
||||||
|
if observations.devices[device].get("device_type") in METRICS:
|
||||||
|
targets.add(device)
|
||||||
|
result["targets"] = sorted(targets)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_chart(task: dict, chart: dict, evidence: list[dict], refs: list[str]) -> dict:
|
||||||
|
"""Return verified coverage; mutate only the outgoing chart's Server-derived gaps."""
|
||||||
|
context = chart["scada"]
|
||||||
|
end = cutoff(task)
|
||||||
|
start = end - timedelta(hours=3)
|
||||||
|
require(datetime.fromisoformat(context["window_start"]) == start
|
||||||
|
and datetime.fromisoformat(context["window_end"]) == end,
|
||||||
|
"SCADA_CHART_WINDOW_INVALID", "SCADA图表必须使用本轮截止时刻之前的完整三小时窗口")
|
||||||
|
observations = ScadaObservations(task, evidence, refs)
|
||||||
|
series = chart["series"]
|
||||||
|
ids = {item.get("device_id") for item in series}
|
||||||
|
require(None not in ids and all(device in observations.devices for device in ids),
|
||||||
|
"SCADA_CHART_EVIDENCE_REQUIRED", "每条SCADA序列须绑定已核实的 device_id", refs=sorted(ids - {None}))
|
||||||
|
comparison = context["role"] == "comparison"
|
||||||
|
require(chart["chart_type"] == ("time_comparison" if comparison else "line"),
|
||||||
|
"SCADA_CHART_INVALID", "SCADA总览须使用line,同期图须使用time_comparison")
|
||||||
|
bindings = [(item["device_id"], item.get("day_offset")) for item in series]
|
||||||
|
require(len(bindings) == len(set(bindings)), "SCADA_CHART_INVALID", "SCADA序列的设备与日期绑定不能重复")
|
||||||
|
require((len(ids) == 1 and {day for _, day in bindings} == set(range(8))) if comparison
|
||||||
|
else all(day == 0 for _, day in bindings),
|
||||||
|
"SCADA_CHART_INVALID", "总览每个设备绑定day_offset=0;同期图每个设备须有当天及此前七天共8组序列")
|
||||||
|
field = context.get("value_field", "monitored_value")
|
||||||
|
gaps = []
|
||||||
|
coverage = {}
|
||||||
|
for item in series:
|
||||||
|
device, day = item["device_id"], item["day_offset"]
|
||||||
|
metadata = observations.devices[device]
|
||||||
|
require(METRICS.get(metadata.get("device_type")) == context["metric"]
|
||||||
|
and _unit(metadata.get("measurement_unit") or "") == _unit(context["unit"]),
|
||||||
|
"SCADA_CHART_INVALID", f"设备 {device} 的指标或单位与完整设备证据不一致", refs=[device])
|
||||||
|
period = observations.period(device, start, end)
|
||||||
|
full_slots = observations.slots(device, start, end)
|
||||||
|
no_schedule = not full_slots
|
||||||
|
if no_schedule:
|
||||||
|
require(observations.window_is_empty(device, start - timedelta(days=day), end - timedelta(days=day)),
|
||||||
|
"SCADA_CHART_EVIDENCE_REQUIRED", f"设备 {device} 无计划采样槽,须完整读取对应三小时窗口并证明无读数", refs=[device])
|
||||||
|
# This is an empty display entry, never an observation or a missed scheduled sample.
|
||||||
|
full_slots = [end]
|
||||||
|
labels = [at.astimezone(DISPLAY_TIMEZONE).strftime("%H:%M:%S" if period.total_seconds() % 60 else "%H:%M")
|
||||||
|
for at in full_slots]
|
||||||
|
first = labels.index(chart["x_data"][0]) if chart["x_data"][0] in labels else -1
|
||||||
|
count = len(chart["x_data"])
|
||||||
|
require(first >= 0 and chart["x_data"] == labels[first:first + count]
|
||||||
|
and len(item.get("sample_times") or []) == count,
|
||||||
|
"SCADA_CHART_INVALID", f"设备 {device} 须保留连续真实采样槽和sample_times;超限按时间分图", refs=[device])
|
||||||
|
slots = full_slots[first:first + count]
|
||||||
|
coverage[device] = {"covered": [at.isoformat() for at in slots]}
|
||||||
|
require((item.get("type") or "line") == ("scatter" if day else "line"),
|
||||||
|
"SCADA_CHART_INVALID", "当前序列须为line,历史序列须为scatter")
|
||||||
|
missing = 0
|
||||||
|
reason = "no_readings"
|
||||||
|
expected = {at - timedelta(days=day) for at in (full_slots[-3:] if day else full_slots)}
|
||||||
|
# Off-grid observations cannot be silently discarded by a regular-looking chart.
|
||||||
|
observed = {at for key, at in observations.values if key == device
|
||||||
|
and min(expected) <= at <= max(expected)}
|
||||||
|
require(observed <= expected, "SCADA_CHART_INVALID",
|
||||||
|
f"设备 {device} 含非规则采样时刻,须明确处理,不能静默丢点", refs=[device])
|
||||||
|
for index, slot in enumerate(slots):
|
||||||
|
at = slot - timedelta(days=day)
|
||||||
|
value, sample_time = item["data"][index], item["sample_times"][index]
|
||||||
|
if at not in expected:
|
||||||
|
require(value is None and sample_time is None, "SCADA_CHART_INVALID",
|
||||||
|
"历史散点只保留每日末三个对应时刻")
|
||||||
|
continue
|
||||||
|
record = observations.values.get((device, at))
|
||||||
|
require(record is None or field in record, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 的证据未标明 {field},请重新读取明确的监测字段", refs=[device])
|
||||||
|
actual = record.get(field) if record is not None else None
|
||||||
|
if actual is None:
|
||||||
|
absent = observations.absent_reason(device, at)
|
||||||
|
require(record is not None or absent is not None, "SCADA_CHART_EVIDENCE_REQUIRED",
|
||||||
|
f"设备 {device} 的 {at.isoformat()} 未被完整读取,不能将未查询当作缺测", refs=[device])
|
||||||
|
require(value is None and sample_time is None, "SCADA_CHART_DATA_MISMATCH",
|
||||||
|
f"设备 {device} 缺测槽必须保留null,不能补零或推算", refs=[device])
|
||||||
|
missing += 1
|
||||||
|
if absent in {"outside_frozen_scope", "outside_frozen_window"}:
|
||||||
|
reason = absent
|
||||||
|
elif no_schedule:
|
||||||
|
reason = "no_scheduled_samples"
|
||||||
|
else:
|
||||||
|
require(value is not None and sample_time is not None
|
||||||
|
and datetime.fromisoformat(sample_time) == at
|
||||||
|
and (isclose(value, actual, rel_tol=1e-9, abs_tol=1e-9)
|
||||||
|
or any(isclose(value, round(actual, digits), rel_tol=0, abs_tol=1e-9)
|
||||||
|
for digits in range(1, 7))),
|
||||||
|
"SCADA_CHART_DATA_MISMATCH",
|
||||||
|
f"设备 {device} 的 {at.isoformat()} 图表值或采样时间与证据不符", refs=[device])
|
||||||
|
if missing:
|
||||||
|
gaps.append({"device_id": device, "day_offset": day, "missing_count": missing, "reason": reason})
|
||||||
|
context["gaps"] = gaps
|
||||||
|
return {"role": context["role"], "metric": context["metric"], "device_ids": sorted(ids),
|
||||||
|
"window_start": context["window_start"], "window_end": context["window_end"], "gaps": gaps,
|
||||||
|
"coverage": coverage}
|
||||||
|
|
||||||
|
|
||||||
|
def require_complete(task: dict, document: dict | None, observations: ScadaObservations) -> None:
|
||||||
|
targets = set((document or {}).get("targets", []))
|
||||||
|
supported = {key for key, device in observations.devices.items() if device.get("device_type") in METRICS}
|
||||||
|
if not supported and not targets:
|
||||||
|
return
|
||||||
|
require(bool(targets), "SCADA_PRESENTATION_INCOMPLETE",
|
||||||
|
"请声明本轮SCADA目标设备并发布当前总览与七天同期图;旧格式图表不计入设备覆盖", status=409)
|
||||||
|
end = cutoff(task)
|
||||||
|
start = end - timedelta(hours=3)
|
||||||
|
covered = {"current": {}, "comparison": {}}
|
||||||
|
for chart in document["charts"].values():
|
||||||
|
if (datetime.fromisoformat(chart["window_start"]) == start
|
||||||
|
and datetime.fromisoformat(chart["window_end"]) == end):
|
||||||
|
for device, slots in chart.get("coverage", {}).items():
|
||||||
|
if device not in chart["device_ids"]:
|
||||||
|
continue
|
||||||
|
covered[chart["role"]].setdefault(device, set()).update(
|
||||||
|
datetime.fromisoformat(at) for at in slots["covered"])
|
||||||
|
missing = {}
|
||||||
|
for role in covered:
|
||||||
|
missing[role] = sorted(device for device in targets
|
||||||
|
if not covered[role].get(device) or not set(observations.slots(device, start, end) or [end])
|
||||||
|
<= covered[role][device])
|
||||||
|
missing_current, missing_comparison = missing["current"], missing["comparison"]
|
||||||
|
require(not missing_current and not missing_comparison, "SCADA_PRESENTATION_INCOMPLETE",
|
||||||
|
f"本轮SCADA目标共{len(targets)}个;缺少当前总览设备:{', '.join(missing_current) or '无'};"
|
||||||
|
f"缺少七天同期对比设备:{', '.join(missing_comparison) or '无'}。请补图后重试;仅作参考不能豁免展示。",
|
||||||
|
refs=sorted(set(missing_current + missing_comparison)), status=409,
|
||||||
|
recovery="retry_same_request")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
|||||||
DAY_SECONDS = 86400
|
DAY_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
|
def validate_time_comparison_chart(chart: Mapping[str, Any], *, allow_missing_current: bool = False) -> None:
|
||||||
"""Validate new publications; persisted historical charts keep their read contract."""
|
"""Validate new publications; persisted historical charts keep their read contract."""
|
||||||
chart_type = chart["chart_type"]
|
chart_type = chart["chart_type"]
|
||||||
series = chart["series"]
|
series = chart["series"]
|
||||||
@@ -70,8 +70,17 @@ def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
|
|||||||
current_bases.update(bases)
|
current_bases.update(bases)
|
||||||
else:
|
else:
|
||||||
historical_bases.extend(bases)
|
historical_bases.extend(bases)
|
||||||
check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
|
scada = chart.get("scada")
|
||||||
current = next(iter(current_bases))
|
empty_scada = allow_missing_current and not current_bases and scada and scada["role"] == "comparison"
|
||||||
|
check(len(current_bases) == 1 or bool(empty_scada),
|
||||||
|
"当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
|
||||||
|
# SCADA publication separately verifies every null against persisted reads.
|
||||||
|
if empty_scada:
|
||||||
|
window_start = datetime.fromisoformat(scada["window_start"]).astimezone(DISPLAY_TIMEZONE)
|
||||||
|
start_seconds = window_start.hour * 3600 + window_start.minute * 60 + window_start.second
|
||||||
|
current = window_start.replace(microsecond=0) + timedelta(seconds=(seconds[0] - start_seconds) % DAY_SECONDS)
|
||||||
|
else:
|
||||||
|
current = next(iter(current_bases))
|
||||||
for historical in historical_bases:
|
for historical in historical_bases:
|
||||||
delta = (current - historical).total_seconds()
|
delta = (current - historical).total_seconds()
|
||||||
check(delta > 0 and delta % DAY_SECONDS == 0,
|
check(delta > 0 and delta % DAY_SECONDS == 0,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
|
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,7 +530,7 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/InvestigationProgress"
|
"$ref": "#/components/schemas/InvestigationProgressInput"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
@@ -3540,6 +3540,21 @@
|
|||||||
"InvestigationDiagram": {
|
"InvestigationDiagram": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"edges": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/InvestigationGraphEdge"
|
||||||
|
},
|
||||||
|
"maxItems": 200,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Edges"
|
||||||
|
},
|
||||||
"mermaid": {
|
"mermaid": {
|
||||||
"maxLength": 20000,
|
"maxLength": 20000,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
@@ -3554,6 +3569,19 @@
|
|||||||
"title": "Node Refs",
|
"title": "Node Refs",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"source_mermaid": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 20000,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Source Mermaid"
|
||||||
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
@@ -3578,6 +3606,19 @@
|
|||||||
"title": "Action Refs",
|
"title": "Action Refs",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 2000,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Detail"
|
||||||
|
},
|
||||||
"evidence_refs": {
|
"evidence_refs": {
|
||||||
"items": {
|
"items": {
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
@@ -3608,14 +3649,11 @@
|
|||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"enum": [
|
"enum": [
|
||||||
"pending",
|
"reference",
|
||||||
"running",
|
"recorded",
|
||||||
"completed",
|
"needs_input",
|
||||||
"waiting",
|
|
||||||
"waiting_human",
|
|
||||||
"failed",
|
"failed",
|
||||||
"skipped",
|
"not_taken"
|
||||||
"cancelled"
|
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3623,7 +3661,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors.",
|
"description": "A saved process-reference annotation, not live execution state. recorded means a finding exists, not that a task or field action completed.",
|
||||||
"title": "Status"
|
"title": "Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3634,6 +3672,153 @@
|
|||||||
"title": "InvestigationDiagramNodeRef",
|
"title": "InvestigationDiagramNodeRef",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"InvestigationGraphEdge": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 200,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Label"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
|
||||||
|
"title": "Source",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
|
||||||
|
"title": "Target",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"title": "InvestigationGraphEdge",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"InvestigationGraphNodeUpdate": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"action_refs": {
|
||||||
|
"items": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Action Refs",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 2000,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Detail"
|
||||||
|
},
|
||||||
|
"evidence_refs": {
|
||||||
|
"items": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": "Evidence Refs",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Label",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"map_targets": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/InvestigationMapTarget"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"title": "Map Targets",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"node_id": {
|
||||||
|
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
|
||||||
|
"title": "Node Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"reference",
|
||||||
|
"recorded",
|
||||||
|
"needs_input",
|
||||||
|
"failed",
|
||||||
|
"not_taken"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. No live node/task binding or per-step synchronization is required.",
|
||||||
|
"title": "Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"node_id",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"title": "InvestigationGraphNodeUpdate",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"InvestigationGraphUpdate": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"edges": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/InvestigationGraphEdge"
|
||||||
|
},
|
||||||
|
"maxItems": 200,
|
||||||
|
"title": "Edges",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/InvestigationGraphNodeUpdate"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"title": "Nodes",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 200,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Title"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "InvestigationGraphUpdate",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"InvestigationMapTarget": {
|
"InvestigationMapTarget": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3691,6 +3876,42 @@
|
|||||||
"title": "InvestigationProgress",
|
"title": "InvestigationProgress",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"InvestigationProgressInput": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"diagram": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/InvestigationDiagram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"graph_update": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/InvestigationGraphUpdate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"maxLength": 20000,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Summary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"summary"
|
||||||
|
],
|
||||||
|
"title": "InvestigationProgressInput",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"InvestigationProgressSnapshot": {
|
"InvestigationProgressSnapshot": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3811,6 +4032,23 @@
|
|||||||
"title": "Recommendations",
|
"title": "Recommendations",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"scada_device_ids": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 1000,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scada Device Ids"
|
||||||
|
},
|
||||||
"stage": {
|
"stage": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -3923,6 +4161,23 @@
|
|||||||
"title": "Recommendations",
|
"title": "Recommendations",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"scada_device_ids": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 1000,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Scada Device Ids"
|
||||||
|
},
|
||||||
"stage": {
|
"stage": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"interim",
|
"interim",
|
||||||
@@ -7162,6 +7417,110 @@
|
|||||||
"title": "RunSimulationManuallyByDateRest",
|
"title": "RunSimulationManuallyByDateRest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ScadaChartContext": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"gaps": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScadaChartGap"
|
||||||
|
},
|
||||||
|
"maxItems": 8,
|
||||||
|
"title": "Gaps",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"metric": {
|
||||||
|
"enum": [
|
||||||
|
"pressure",
|
||||||
|
"flow"
|
||||||
|
],
|
||||||
|
"title": "Metric",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"enum": [
|
||||||
|
"current",
|
||||||
|
"comparison"
|
||||||
|
],
|
||||||
|
"title": "Role",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Unit",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value_field": {
|
||||||
|
"default": "monitored_value",
|
||||||
|
"enum": [
|
||||||
|
"monitored_value",
|
||||||
|
"cleaned_value"
|
||||||
|
],
|
||||||
|
"title": "Value Field",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"window_end": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Window End",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"window_start": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Window Start",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"role",
|
||||||
|
"metric",
|
||||||
|
"unit",
|
||||||
|
"window_start",
|
||||||
|
"window_end"
|
||||||
|
],
|
||||||
|
"title": "ScadaChartContext",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ScadaChartGap": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"day_offset": {
|
||||||
|
"maximum": 7.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"title": "Day Offset",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Device Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"missing_count": {
|
||||||
|
"maximum": 100.0,
|
||||||
|
"minimum": 1.0,
|
||||||
|
"title": "Missing Count",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"enum": [
|
||||||
|
"no_readings",
|
||||||
|
"outside_frozen_scope",
|
||||||
|
"outside_frozen_window",
|
||||||
|
"no_scheduled_samples"
|
||||||
|
],
|
||||||
|
"title": "Reason",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"device_id",
|
||||||
|
"day_offset",
|
||||||
|
"missing_count",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"title": "ScadaChartGap",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ScadaDeviceResponse": {
|
"ScadaDeviceResponse": {
|
||||||
"description": "Project SCADA metadata keyed by the canonical device identifier.",
|
"description": "Project SCADA metadata keyed by the canonical device identifier.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -10229,6 +10588,16 @@
|
|||||||
"title": "Chart Type",
|
"title": "Chart Type",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"scada": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ScadaChartContext"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"series": {
|
"series": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TaskSafeChartSeries"
|
"$ref": "#/components/schemas/TaskSafeChartSeries"
|
||||||
@@ -10306,6 +10675,32 @@
|
|||||||
"title": "Data",
|
"title": "Data",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"day_offset": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maximum": 7.0,
|
||||||
|
"minimum": 0.0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Day Offset"
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Device Id"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"maxLength": 128,
|
"maxLength": 128,
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
|
|||||||
@@ -207,6 +207,97 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 200
|
"maxLength": 200
|
||||||
},
|
},
|
||||||
|
"scada": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"current",
|
||||||
|
"comparison"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metric": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pressure",
|
||||||
|
"flow"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"window_start": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
|
||||||
|
},
|
||||||
|
"window_end": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
|
||||||
|
},
|
||||||
|
"value_field": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"monitored_value",
|
||||||
|
"cleaned_value"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gaps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"day_offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 7
|
||||||
|
},
|
||||||
|
"missing_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"no_readings",
|
||||||
|
"outside_frozen_scope",
|
||||||
|
"outside_frozen_window",
|
||||||
|
"no_scheduled_samples"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"device_id",
|
||||||
|
"day_offset",
|
||||||
|
"missing_count",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"maxItems": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"role",
|
||||||
|
"metric",
|
||||||
|
"unit",
|
||||||
|
"window_start",
|
||||||
|
"window_end"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"chart_type": {
|
"chart_type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -241,6 +332,16 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 128
|
"maxLength": 128
|
||||||
},
|
},
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"day_offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 7
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 供水 Server 文档目录
|
||||||
|
|
||||||
|
环境、启动和发布命令见 [仓库 README](../README.md),开发规则见 [AGENTS.md](../AGENTS.md)。整理日期:2026-11-10。下列文档按职责组织,不将历史迁移或本地验证当作现有服务状态。
|
||||||
|
|
||||||
|
## API、认证与数据
|
||||||
|
|
||||||
|
| 主题 | 维护入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| API 命名、字段和生成契约 | [API 风格](api-style.md) · [OpenAPI](../contracts/server-v1.openapi.json) |
|
||||||
|
| 身份、项目成员、管理接口与元数据 | [认证与用户管理](../AUTHENTICATION_AND_USER_MANAGEMENT.md) |
|
||||||
|
| MCP 设计依据、启用与认证配置 | [查询设计](mcp-query-design.md) · [实施与启用](mcp-query-implementation.md) · [Keycloak 配置](mcp-keycloak-setup.md) |
|
||||||
|
| MCP 分析、项目服务和后台计算 | [分析工具](mcp-analysis-tools.md) · [项目服务](mcp-project-services.md) · [计算队列](calculation-queue.md) |
|
||||||
|
| 项目地名与空间覆盖 | [地名搜索](project-place-search.md) · [节点覆盖](node-coverage-tool.md) |
|
||||||
|
| 项目 BizDB 与时序模型 | [数据库架构](../resources/db_v2/DATABASE_ARCHITECTURE.md) · [管网结构](../resources/db_v2/WNDB_STRUCTURE.md) |
|
||||||
|
|
||||||
|
工具目录以 `app/mcp/catalog.py` 为准;启用范围、项目权限和会话用途决定实际可用项,不以文档中的历史工具数量推定。
|
||||||
|
|
||||||
|
## 任务、调查与工作台
|
||||||
|
|
||||||
|
| 主题 | 维护入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 任务类型、调查轮次与协作 | [工况任务契约](operational-tasks.md) |
|
||||||
|
| Worker、租约、持久消息、外部动作和部署 | [工况工作进程](operational-task-workers.md) |
|
||||||
|
| 进展、展示事件与人工反馈续调 | [调查进展与展示](investigation-progress-and-presentation.md) |
|
||||||
|
| 正式报告与稳定引用 | [调查报告契约](investigation-report-contract.md) |
|
||||||
|
| 爆管工单到处置闭环 | [爆管处置路线](burst-response-workflow.md) |
|
||||||
|
| 工作台运行态、外围工单与告警存储 | [运行态与存储](workbench-runtime.md) |
|
||||||
|
| 事件顺序、SSE 与读取去重 | [工作台事件链路](workbench-event-refinement.md) |
|
||||||
|
| 通知进入、退出与生命周期 | [通知路由](operational-notification-routing.md) |
|
||||||
|
| 迁移门禁与逐项目验证 | [数据库变更门禁](workbench-database-rollout.md) |
|
||||||
|
| 外围工单来源、项目路由和主动同步 | [外围服务](peripheral-sample.md) |
|
||||||
|
|
||||||
|
[旧动态计划协议](dynamic-investigation-plan.md) 用于兼容历史计划,当前任务和报告规则以上述专门契约为准。[15173 UI Keycloak 客户端](integration-ui-keycloak-client.md) 是特定联调环境说明,不能作为全部部署环境的通用配置。
|
||||||
|
|
||||||
|
## SCADA、回放与数据集
|
||||||
|
|
||||||
|
| 主题 | 维护入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 回放、时间轴、周期监测与调查边界 | [SCADA 回放设计与实现](scada-peripheral-replay-design.md) |
|
||||||
|
| 外围测试样本与数据再现 | [合成样本](peripheral-generated-data.md) |
|
||||||
|
| 凤阳历史爆管模拟 | [凤阳 SCADA 复制记录](fengyang-burst-scada.md) |
|
||||||
|
| 奉贤独立 Agent benchmark | [tjwater_v2 benchmark](tjwater-v2-burst-benchmark.md) |
|
||||||
|
| tjwater2 场景选取与观测 | [多距离与弱信号方案](tjwater2-v2-burst-balanced-design.md) |
|
||||||
|
| tjwater2 水力校准方法 | [爆管模拟校准](tjwater2-v2-burst-simulation-design.md) |
|
||||||
|
| 监测点算法与资源压力取舍 | [灵敏度优化](sensor-sensitivity-optimization.md) |
|
||||||
|
|
||||||
|
这些数据文档分别描述数据准备、算法设计或历史试算,不能互相替代,也不证明目标数据库已经加载或真实 Agent 已完成验收。
|
||||||
|
|
||||||
|
## 合并与历史资料
|
||||||
|
|
||||||
|
- `workbench-operations-storage.md` 已并入运行态文档的“工单与告警存储”。
|
||||||
|
- `peripheral-work-order-events.md` 已并入外围服务文档的“工单事件主动同步”,旧固定库限制已按项目路由实现修订。
|
||||||
|
- [历史命名审查](history/backend-naming-audit.md) 与 [失效接口排查](history/retired-api-investigation.md) 保留原始审查依据,不能作为当前 API 目录。
|
||||||
|
- `scripts/missing_api_endpoints.md` 是历史能力盘点,新增能力前需重新核对当前契约与调用方。
|
||||||
|
|
||||||
|
设计与部署说明保留分工:设计解释选择与边界,运行文档给出可执行配置;不同数据库、不同数据集和不同生命周期的业务对象不为减少文件数而合并。
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
# TJWater REST API v1
|
# TJWater REST API v1
|
||||||
|
|
||||||
This contract is the public API contract for coordinated TJWater
|
This contract is the public API contract for coordinated TJWater
|
||||||
Server, Agent, Frontend, and CLI releases.
|
Server, Agent, and Frontend releases. Legacy CLI material is a historical
|
||||||
|
capability reference, not a current runtime integration.
|
||||||
|
|
||||||
- Paths use lowercase kebab-case, have no trailing slash, and identify
|
- Paths use lowercase kebab-case, have no trailing slash, and identify
|
||||||
resources rather than handler actions.
|
resources rather than handler actions.
|
||||||
|
|||||||
@@ -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 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 动态调查计划数据契约
|
# 旧动态调查计划兼容契约
|
||||||
|
|
||||||
调查任务从工单投诉内容或人工请求创建。Agent 在本次执行中生成 `execution-plan@2`,根据新证据追加修订;数据库保存不可变计划快照的 JSONB 文档,并不保存固定流程模板。任务、Run、证据、访问申请、审核动作和报告各有独立生命周期。计划中的 `gate_ref` 只引用已经持久化的访问申请或动作,不能把审批结果写成模型自行判断的文本。
|
> 历史兼容文档:字段和示例用于读取已有 `execution-plan@1/@2` 快照,不要求当前调查生成或执行这些计划。当前进展与累计图见 [调查进展](investigation-progress-and-presentation.md),Agent 生命周期见 [任务执行](../../next-tjwater-agent/docs/execution-plan.md)。
|
||||||
|
|
||||||
|
早期设计由 Agent 生成 `execution-plan@2` 并按证据追加修订;数据库保存不可变计划快照的 JSONB 文档,不保存固定流程模板。任务、Run、证据、访问申请、审核动作和报告各有独立生命周期。计划中的 `gate_ref` 只引用已经持久化的访问申请或动作,不能把审批结果写成模型自行判断的文本。
|
||||||
|
|
||||||
## `execution-plan@2`
|
## `execution-plan@2`
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Backend Naming Audit
|
# Backend Naming Audit
|
||||||
|
|
||||||
|
> 历史接口盘点,保留旧名称与修正依据。本文中的旧仓库名、URL 和替代建议不作为当前可用接口清单;现行规范见 [API 风格](../api-style.md),实际契约见 [Server OpenAPI](../../contracts/server-v1.openapi.json)。
|
||||||
|
|
||||||
DOC-003 audit for the internal `TJWaterServerBinary` backend.
|
DOC-003 audit for the internal `TJWaterServerBinary` backend.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# `app/api/v1/endpoints/` 失效 API 排查与修正
|
# `app/api/v1/endpoints/` 失效 API 排查与修正
|
||||||
|
|
||||||
|
> 历史接口盘点,保留旧名称与修正依据。本文中的旧仓库名、URL 和替代建议不作为当前可用接口清单;现行规范见 [API 风格](../api-style.md),实际契约见 [Server OpenAPI](../../contracts/server-v1.openapi.json)。
|
||||||
|
|
||||||
排查范围:`app/api/v1/endpoints/`
|
排查范围:`app/api/v1/endpoints/`
|
||||||
|
|
||||||
结论:本次共确认 5 个问题接口,处理结果如下:
|
结论:本次共确认 5 个问题接口,处理结果如下:
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# 调查进展与实时展示
|
# 调查进展与实时展示
|
||||||
|
|
||||||
调查执行中可持续发布正文和完整 Mermaid 图,尚未形成结论时也能发布。
|
调查执行中可按需整理正文和流程参考图,Server 累计保留路径并生成 Mermaid,尚未形成结论时也能发布。
|
||||||
Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告,
|
Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告,
|
||||||
进展中的节点状态不参与调度、审批或结束判断。
|
图节点不绑定实际执行任务,标记只解释最近整理的路线、结果和限制;不参与调度、审批、等待或结束判断。
|
||||||
|
路线有实质调整、形成重要结果、需要保留失败尝试或整理报告时更新即可,不要求逐步发布、频繁刷新或及时同步。
|
||||||
|
|
||||||
## 进展快照
|
## 进展快照
|
||||||
|
|
||||||
@@ -14,37 +15,73 @@ Server 将阅读进展、业务报告和浏览器展示请求分别保存。运
|
|||||||
"run_id": "<current-run-uuid>",
|
"run_id": "<current-run-uuid>",
|
||||||
"source_session_id": "<claimed-session-id>",
|
"source_session_id": "<claimed-session-id>",
|
||||||
"progress": {
|
"progress": {
|
||||||
"summary": "正在核对投诉时段的监测记录,尚未形成结论。",
|
"summary": "整理投诉时段的监测核查路线,尚未形成结论。",
|
||||||
"diagram": {
|
"graph_update": {
|
||||||
"title": "当前调查进展",
|
"title": "调查流程参考",
|
||||||
"mermaid": "flowchart LR\nread[核查监测记录] --> judge[判断证据范围]",
|
"nodes": [{
|
||||||
"node_refs": [{
|
|
||||||
"node_id": "read",
|
"node_id": "read",
|
||||||
"label": "核查监测记录",
|
"label": "核查监测记录",
|
||||||
"status": "running",
|
"status": "reference",
|
||||||
"evidence_refs": [],
|
"evidence_refs": [],
|
||||||
"action_refs": [],
|
"action_refs": [],
|
||||||
"map_targets": [{"source_id": "pipes", "feature_id": "<known-pipe-id>"}]
|
"map_targets": [{"source_id": "pipes", "feature_id": "<known-pipe-id>"}]
|
||||||
}]
|
}],
|
||||||
|
"edges": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`diagram` 可空。
|
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`graph_update` 可省略。
|
||||||
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
||||||
节点状态可空,或为 `pending/running/completed/waiting_human/failed/skipped/cancelled`。
|
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
|
||||||
`map_targets` 只接受 `junctions/pipes/valves/pumps/tanks/reservoirs/scada` 七类来源和要素编号。
|
|
||||||
图中节点引用在本次发布内唯一,证据和动作引用必须属于该任务。跨修订可以修改节点编号、
|
|
||||||
分支和文字,Server 不验证依赖执行顺序或节点完工。
|
|
||||||
|
|
||||||
每次传入 `progress` 都完整替换该 run 的快照,传入 `diagram: null` 清除上一版图示。
|
| status | 显示 | 含义 |
|
||||||
省略 `progress` 保留原快照。同一个请求编号重试只发布一次。
|
| --- | --- | --- |
|
||||||
|
| reference | 参考步骤 | 路线或分支说明,不断言是否执行 |
|
||||||
|
| recorded | 已有结果 | 已整理出结果或依据,不表示任务、计算或现场动作完成 |
|
||||||
|
| needs_input | 待补充条件 | 记录缺少的资料、权限或人工事实,不控制任务等待 |
|
||||||
|
| failed | 尝试失败 | 保留一次失败尝试及原因,后续路线另建节点 |
|
||||||
|
| not_taken | 未采用路线 | 未采用、不适用或改走其他分支 |
|
||||||
|
|
||||||
|
旧图读取兼容:pending/running 转为 reference,completed 转为 recorded,waiting/waiting_human 转为 needs_input,
|
||||||
|
skipped/cancelled 转为 not_taken;failed 保留。仅规范化读取结果,不改写历史文档,不再显示“正在执行”或“记录时执行中”。
|
||||||
|
REST 与 MCP 使用相同的旧标记映射。MCP 的 progress/report 图示在分页及 data_path 投影之前完成转换,
|
||||||
|
读取整份说明、节点列表或单个 status 时含义一致;实际 task/run/action 状态和旧执行计划状态保持各自语义。
|
||||||
|
`map_targets` 只接受 `junctions/pipes/valves/pumps/tanks/reservoirs/scada` 七类来源和要素编号。
|
||||||
|
图中节点引用唯一,证据和动作引用必须属于该任务。新 Agent 使用下述增量更新,节点编号在任务各轮保持稳定。
|
||||||
|
Server 不验证依赖执行顺序或推断节点完工。
|
||||||
|
|
||||||
|
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
|
||||||
|
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
|
||||||
|
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
|
||||||
|
尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
|
||||||
|
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
|
||||||
|
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
|
||||||
|
旧图内节点不得改名或重用为其他步骤。已被旧实现覆盖且未存入报告的内容无法还原。
|
||||||
|
尚未迁移的旧图仍接受旧客户端显式提交的完整图;仅更新正文或传入 `diagram: null` 时自动接管旧图,
|
||||||
|
保留原源码并转为累计图。任务启用累计图后拒绝旧客户端重画覆盖,不提供通过空值清图的入口。
|
||||||
|
省略 `progress` 保留原快照,同一个请求编号重试只发布一次。
|
||||||
`workbench.operational_task_progress` 每个任务和 run 保存最新一份快照,独立于七天事件清理。
|
`workbench.operational_task_progress` 每个任务和 run 保存最新一份快照,独立于七天事件清理。
|
||||||
任务详情和 Agent 上下文返回 `latest_progress`,包含 `revision/run_id/execution_generation/`
|
任务详情和 Agent 上下文返回 `latest_progress`,包含 `revision/run_id/execution_generation/`
|
||||||
`published_at/source_session_id/progress`。只读取当前执行代次,新轮次尚未发布时返回空。
|
`published_at/source_session_id/progress`。优先返回当前执行代次;新轮次尚未发布时保留最近的累计图及其原始轮次。
|
||||||
|
`get_task_context(section="progress")` 同样允许读取上轮累计图以接续,摘要和图示不证明新轮次已执行。
|
||||||
|
报告发布时由 Server 存入累计图及其已有依据,历史报告保持独立快照;最多保留 100 个节点、200 条连线,超限显式报错,不静默裁剪。
|
||||||
快照更新发出 `operational.progress.changed`,前端据此读取最新内容。
|
快照更新发出 `operational.progress.changed`,前端据此读取最新内容。
|
||||||
|
|
||||||
|
### 旧任务图迁移
|
||||||
|
|
||||||
|
在 Server 仓库运行 `python -m scripts.migrate_investigation_graphs --plan /受保护目录/plan.json --project-id <项目UUID>`;
|
||||||
|
多个项目重复指定 `--project-id`。预览只读数据库,计划文件以私有权限保存原始进度文档和迁移后的完整文档。
|
||||||
|
核对项目、业务库、任务和图数量后,运行 `python -m scripts.migrate_investigation_graphs --apply /受保护目录/plan.json`。
|
||||||
|
连接使用 MetaDB 路由,计划中不保存连接凭据。迁移逐项目事务执行,与正常发布使用相同的事件锁和任务锁,
|
||||||
|
预览后仍为旧图但已发生变化的行会使该项目回滚;重复执行跳过已经接管的图。
|
||||||
|
|
||||||
|
迁移仅更新已有进度图及其 revision,保留原 summary、发布时间、run、会话、节点说明和引用;
|
||||||
|
原 Mermaid 字节保存在 `source_mermaid`,旧节点标记转换为参考标记,不解析或猜测原始连线。
|
||||||
|
历史报告保持原样,通过读取兼容显示新标记。迁移逐项目校验任务、run 和报告整行摘要未变,
|
||||||
|
并发出进度变更事件及写入独立执行回执;不产生报告更新通知。备份和回执不得提交到仓库。
|
||||||
|
|
||||||
## 浏览器实时联动
|
## 浏览器实时联动
|
||||||
|
|
||||||
`POST /api/v1/operational-tasks/{task_id}/presentation-events` 接收
|
`POST /api/v1/operational-tasks/{task_id}/presentation-events` 接收
|
||||||
@@ -68,7 +105,7 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
|||||||
|
|
||||||
以上十种动作与 Chat 的 `present_view`、Dev JSONL 共用扁平 `command: {action, ...字段}`。Chat 使用 `frontend-action@2` 和执行回执;任务仍只记录展示请求,Dev 只做本地预览。三者使用独立的地图源、SCADA 图层和图表状态。Dev 的动作数据行必须为 `{"record_type":"presentation","command":{...}}`;界面块行使用 `record_type: ui_block`,新示例明确带 `phase: final`。普通调查数据保持来源字段,不执行其中的 action。仓库示例见[前端统一展示夹具](../../next-tjwater-frontend/tests/fixtures/unified-presentation.jsonl),其中编号来自测试夹具。
|
以上十种动作与 Chat 的 `present_view`、Dev JSONL 共用扁平 `command: {action, ...字段}`。Chat 使用 `frontend-action@2` 和执行回执;任务仍只记录展示请求,Dev 只做本地预览。三者使用独立的地图源、SCADA 图层和图表状态。Dev 的动作数据行必须为 `{"record_type":"presentation","command":{...}}`;界面块行使用 `record_type: ui_block`,新示例明确带 `phase: final`。普通调查数据保持来源字段,不执行其中的 action。仓库示例见[前端统一展示夹具](../../next-tjwater-frontend/tests/fixtures/unified-presentation.jsonl),其中编号来自测试夹具。
|
||||||
|
|
||||||
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
|
图表使用普通折线 line、柱状 bar,或当前曲线加历史散点的 time_comparison,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
|
||||||
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
|
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
|
||||||
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
|
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
|
||||||
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
|
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
|
||||||
@@ -103,7 +140,53 @@ MCP 保存完整查询响应作为不可变证据,展示解析器同时支持
|
|||||||
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
|
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
|
||||||
过程图表通过 `open_chart` 提供 `chart_id/chart/evidence_refs`,每图最多100个横轴值、8组序列,
|
过程图表通过 `open_chart` 提供 `chart_id/chart/evidence_refs`,每图最多100个横轴值、8组序列,
|
||||||
超限时分图或明确说明抽样、聚合范围;单值或无可比数据用文字或表格。
|
超限时分图或明确说明抽样、聚合范围;单值或无可比数据用文字或表格。
|
||||||
|
同指标、单位和窗口的同期复合图按对象分别发布稳定 chart_id,标题为“对象ID 共同标题”,每条序列名以“对象ID ”开头。共同标题、chart_type、轴名、x_data、当前序列去掉对象前缀后的名称与 sample_times 完全一致时,浏览器将对象数据集聚合为一个子图表:右侧按钮切换对象,下方图例控制序列;窄屏对象列表移到下方。普通趋势总览、柱状图和结构化 Chart 保持独立,不新增 objects 字段。原 chart_id 的 close_chart 只移除对应对象,关闭聚合卡片移除组内对象。
|
||||||
|
|
||||||
|
浏览器的总图表窗口统一管理过程图组与报告 Chart,子图表仍可手动独立弹窗放大。只有正在跟随的运行中任务新收到 open_chart 时可自动弹出总窗口;后续命令更新内容,不自动打开子窗口,用户关闭后不重弹。任务打开、切换、刷新、已完成任务载入、SSE 重连历史补载及历史导航只恢复数据,不弹窗。窗口意图是浏览器临时状态,不写入事件或历史快照,不新增协议字段;open_chart 的内容加载与窗口是否弹出是两个不同状态,Server 的 recorded 也不表示浏览器已经打开窗口。
|
||||||
|
|
||||||
|
time_comparison 的 x_data 使用精度一致、按窗口排列的 UTC+8 HH:mm 或 HH:mm:ss 共享槽,允许跨午夜但短于一天,不追加历史日期。series 同时包含当前 line 与历史 scatter,不混入 bar;sample_times 与 data 等长,有效值和真实带时区时间成对保留,缺测值与时间均为 null。时间转换后须与横轴槽一致,当前线使用同一窗口,每条历史序列来自同一历史日的对应窗口,不接受当天或未来日期。新任务展示与报告发布不满足这些规则时返回 TIME_COMPARISON_INVALID,旧保存内容保持结构读取。普通 line/bar 跳过同期语义,但不能将 line/scatter 复合图标为 line。
|
||||||
|
|
||||||
|
前端维护的脚本 scripts/export-view-command-contract.mjs 同步 Agent/Server 的结构协议及 tests/fixtures 下的统一 JSONL 和 chart-publication-cases.json;JSON Schema 不代替跨字段语义校验。Server 的独立测试用相同正反例校验 Pydantic 边界和 validate_time_comparison_chart;Agent 主机、原生工具及 Dev 的新图表输入也执行对应规则。
|
||||||
|
|
||||||
关键趋势与比较结论保留到报告中,最终报告的图表不替代过程发布。
|
关键趋势与比较结论保留到报告中,最终报告的图表不替代过程发布。
|
||||||
|
|
||||||
|
### SCADA 设备展示覆盖
|
||||||
|
|
||||||
|
SCADA 异常初次调查的监测过程图使用可选的 `chart.scada` 结构:
|
||||||
|
`role=current|comparison`、`metric=pressure|flow`、`unit`、带时区的 `window_start/window_end`,
|
||||||
|
窗口固定为本轮截止时刻之前三小时;采样槽保持真实采样相位,截止非整刻不平移。超过100槽按连续时间分图,使用不同稳定chart_id,元数据仍声明完整三小时窗口;Server逐点核实后合并覆盖,缺少任一槽仍拒绝收尾。同期历史只取完整窗口末三个采样槽,同设备同期分图不得重叠。低频设备窗口无计划采样槽时,完整读取对应当天/历史三小时并核实无读数后,以截止时刻的单个null保留设备入口,标记no_scheduled_samples,不计作漏采。`value_field` 默认为 `monitored_value`,可显式选 `cleaned_value`。
|
||||||
|
每条序列绑定 `device_id` 和 `day_offset`(当天 0,此前七天 1 至 7)。
|
||||||
|
同期图每个设备包含当天 line 和七组 scatter,历史仅末三个对应采样槽有值。
|
||||||
|
Server 校验设备属性、单位、完整采样槽、真实时间与引用快照的对应值,允许数值四舍五入保留 1 至 6 位小数,
|
||||||
|
保留负流量;缺测必须有完整查询或明确空值记录证明,未查询不能视为缺测。
|
||||||
|
`chart.scada.gaps` 由 Server 生成并存入展示事件,前端保留设备入口并展示缺测点数。
|
||||||
|
回放全量查询的 `coverage.frozen_start` 记录服务端已冻结的最早时刻;历史早于该边界时,
|
||||||
|
以本轮匹配 replay/batch 的完整回执证明 `outside_frozen_window`,无需虚构一次成功的历史读取。
|
||||||
|
|
||||||
|
地图的 SCADA 定位/标记、已验证图表和最终报告 `scada_device_ids` 累计本轮目标;
|
||||||
|
监测明细表使用该字段声明完整设备集合。按指标和单位分图,跨多张总览取设备并集,
|
||||||
|
同一 `chart_id` 更新只采用最新覆盖,累计目标不缩小。关闭图表及清空地图只影响浏览器,
|
||||||
|
不撤销产物覆盖。最终报告及成功结束均拒绝遗漏,返回 `409 SCADA_PRESENTATION_INCOMPLETE`,
|
||||||
|
`related_refs` 列出待补设备;Agent 补图后可重试原请求。普通进度和 interim 报告可继续发布。
|
||||||
|
“仅作参考”不豁免展示;确有缺测时仍发布有空槽的图表并保留设备,
|
||||||
|
完整查询无法完成时保留 interim 和具体失败原因,不伪造缺测来通过 final。
|
||||||
|
|
||||||
|
覆盖事实保存在 `workbench.operational_task_runs.artifact_manifest.scada_presentation`,
|
||||||
|
JSON 包含累计 `targets`、按 `chart_id` 保存的最新验证 `charts` 及 `updated_at`。
|
||||||
|
它与 Run 一起保留,独立于七天展示事件清理;不依赖浏览器在线或回执。
|
||||||
|
不同 Run、项目和截止窗口不能抵数。恢复复核阶段沿用恢复证据门禁,不要求重画异常轮次图表。
|
||||||
|
历史读取不要求新字段,已完成报告不回写;旧 Agent 的无绑定图表仍可读,但不能通过新轮次的覆盖门禁。
|
||||||
|
|
||||||
|
部署按[工作台数据库变更门禁](workbench-database-rollout.md)核实目标 BizDB、备份和回滚演练。
|
||||||
|
先部署已兼容内部清单读取的 Server,再暂停目标项目的任务写入并在维护窗口应用
|
||||||
|
[035 迁移](../resources/sql/035_scada_presentation_coverage.sql),核对读取与存储就绪后发布 Agent 和前端。
|
||||||
|
不要在旧 Server 仍处理请求时新增该列:旧版整行 Run 投影会将内部字段传入严格响应模型。
|
||||||
|
新 Server 在迁移前可读取历史任务,SCADA 写入明确返回 503;完成迁移后再恢复目标项目写入。
|
||||||
|
迁移只为 Run 新增通用 JSON 产物清单,默认空对象;不会自动回填旧图或自动迁移其他项目。
|
||||||
|
未应用迁移的 SCADA 写入与收尾返回明确的 503,历史读取保持可用。
|
||||||
|
Run 读取投影显式排除内部清单,REST 与 MCP 原有 Run 结构保持一致。
|
||||||
|
若回退到本次读取兼容修复之前的 Server,须先备份清单再移除新增列;旧代码的整行读取不能直接带着新列回退。
|
||||||
|
本次实现不代表迁移或上线已执行。
|
||||||
业务 Skill 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
|
业务 Skill 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
|
||||||
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
|
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
|
||||||
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
|
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开
|
|||||||
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
||||||
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
|
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
|
||||||
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
|
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
|
||||||
|
可选 status 仅为参考标记:reference(参考步骤)、recorded(已有结果)、needs_input(待补充条件)、
|
||||||
|
failed(尝试失败)、not_taken(未采用路线)。recorded 不证明业务完成,标记不与任务或工具运行状态绑定。
|
||||||
|
旧运行状态按[兼容映射](investigation-progress-and-presentation.md)读取,未提供标记时保持中性的参考步骤。
|
||||||
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
|
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
|
||||||
|
|
||||||
## 业务约束
|
## 业务约束
|
||||||
@@ -150,7 +153,8 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
|||||||
|
|
||||||
## 发布、阅读与协作
|
## 发布、阅读与协作
|
||||||
|
|
||||||
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照持续更新,
|
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照按需整理,
|
||||||
|
在路线调整、重要结果或报告归纳时更新即可,不要求逐步更新或及时同步。
|
||||||
见 [调查进展与实时展示](investigation-progress-and-presentation.md)。
|
见 [调查进展与实时展示](investigation-progress-and-presentation.md)。
|
||||||
报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。
|
报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# NextTJWater MCP 查询与认证设计
|
# NextTJWater MCP 查询与认证设计
|
||||||
|
|
||||||
日期:2026-10-15。状态:查询与认证代码已在本地实现,认证发现、resource 参数和版本头体校验已补齐,默认关闭,未部署;真实 Keycloak 与项目数据库验收待完成。
|
初稿日期:2026-10-15。本文保留 MCP 查询与认证的设计依据,后续工具扩展及当前启用步骤见 [实施与启用](mcp-query-implementation.md)。早期本地实现、默认配置和验证快照不代表当前线上启用或部署状态;需核对目标环境。
|
||||||
|
|
||||||
本设计将现有后端查询能力通过 MCP 开放给普通项目 Agent、调查主 Agent 和调查只读子 Agent。采用 MCP 2026-07-28、Streamable HTTP、现有 Python Server 内挂载服务、可信 Agent 宿主按请求代理认证。旧 CLI 用于查找功能和接口线索,业务实现继续由当前 Server 负责。
|
本设计将现有后端查询能力通过 MCP 开放给普通项目 Agent、调查主 Agent 和调查只读子 Agent。采用 MCP 2026-07-28、Streamable HTTP、现有 Python Server 内挂载服务、可信 Agent 宿主按请求代理认证。旧 CLI 用于查找功能和接口线索,业务实现继续由当前 Server 负责。
|
||||||
|
|
||||||
第一版完成查询、权限、取消、分页、调查证据和凭据续期的完整闭环。已有报告发布、工单审批、设备控制、调查运行生命周期继续使用当前受控工具;不通过 MCP 自动开放旧 CLI 中的分析执行命令。
|
本文第一版设计聚焦查询、权限、取消、分页、调查证据和凭据续期。后续已扩展受控分析、预览式写入及后台计算队列,详见实施说明;报告发布、审批、设备控制和调查运行仍遵循各自业务边界,不从旧 CLI 清单自动开放能力。
|
||||||
|
|
||||||
## 当前系统与功能核对
|
## 当前系统与功能核对
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 产品 MCP 查询:本地实施与启用
|
# 产品 MCP 查询:本地实施与启用
|
||||||
|
|
||||||
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和71项工具(42项原查询、12项计算、13项预览式写入、4项计算任务管理);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
|
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 通过 `/mcp` 提供查询、分析计算、预览式写入和计算任务管理,具体目录以 `app/mcp/catalog.py` 为准;Agent 投影到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`。普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 在各自授权范围内查询;计算仅向非 reader 会话开放,写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
|
||||||
|
|
||||||
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
|
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
|
||||||
|
|
||||||
@@ -107,10 +107,10 @@ Agent:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run check
|
bun run check
|
||||||
bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.test.ts tests/product/credentialRefresh.test.ts tests/runtime/task-agent-policy.test.ts tests/runtime/opencodeConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/opencode/productMcp.test.ts
|
bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.test.ts tests/product/credentialRefresh.test.ts tests/runtime/task-agent-policy.test.ts tests/runtime/opencodeConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/opencode/queryMcp.test.ts tests/mcp/queryServer.test.ts
|
||||||
MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT=<Server仓库绝对路径> bun tests/integration/mcp-query-smoke.ts
|
MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT=<Server仓库绝对路径> bun tests/integration/mcp-query-smoke.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、67项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
|
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、对应源码版本的工具目录、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
|
||||||
|
|
||||||
查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。
|
查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
|||||||
conda run -n server python scripts/sync_peripheral_work_orders.py
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
```
|
```
|
||||||
|
|
||||||
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
|
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到源工单事件后按 ID 定向读取工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、项目存储范围与启用步骤见 [外围工单主动同步](peripheral-sample.md#工单事件主动同步)。
|
||||||
|
|
||||||
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
||||||
|
|
||||||
@@ -210,7 +210,9 @@ SQL 同时增加必填范围约束,不设置数据库默认值。回滚版本
|
|||||||
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
|
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
|
||||||
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
|
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
|
||||||
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
|
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
|
||||||
`403 EXECUTION_SCOPE_MISMATCH`。这属于配置隔离,不能替代服务凭据授权:
|
`403 EXECUTION_SCOPE_MISMATCH`。外部工单反馈与来源撤回也必须匹配任务的执行范围,
|
||||||
|
并在幂等重放前校验;开发环境同步器不能更新生产任务或重置其恢复监测起点。
|
||||||
|
这属于配置隔离,不能替代服务凭据授权:
|
||||||
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
|
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
|
||||||
|
|
||||||
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
|
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ conda run -n server python scripts/seed_peripheral_sample.py --project-id <proje
|
|||||||
|
|
||||||
外围面板的“历史监测回放”读取现有项目历史监测源,与上述 `water-demo-v1` 合成数据集是两个独立区域。前端可通过 `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo` 默认使用已知有数据的固定窗口,并用 `TJWATER_PERIPHERAL_SAMPLE_DEMO_START`、`TJWATER_PERIPHERAL_SAMPLE_DEMO_END` 配置开始和结束时间;切到 `recent` 时按 `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。
|
外围面板的“历史监测回放”读取现有项目历史监测源,与上述 `water-demo-v1` 合成数据集是两个独立区域。前端可通过 `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo` 默认使用已知有数据的固定窗口,并用 `TJWATER_PERIPHERAL_SAMPLE_DEMO_START`、`TJWATER_PERIPHERAL_SAMPLE_DEMO_END` 配置开始和结束时间;切到 `recent` 时按 `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。
|
||||||
|
|
||||||
本地默认演示窗口为 UTC 2026-04-30 08:00 至 16:00。它只控制历史监测查询,不改变 `water-demo-v1` 自身的 UTC 2026-09-17 00:00 至 12:00 固定内容。
|
前端源码默认窗口为 UTC 2026-09-17 00:00 至 12:00,可由运行时配置覆盖,见 [前端配置定义](../../next-tjwater-frontend/src/shared/config/env.ts)。它只控制历史监测查询,不改变 `water-demo-v1` 自身的固定内容;部署环境的实际配置与数据覆盖需现场核对。
|
||||||
|
|
||||||
## 接口与页面
|
## 接口与页面
|
||||||
|
|
||||||
@@ -62,5 +62,4 @@ conda run -n server python scripts/seed_peripheral_sample.py --project-id <proje
|
|||||||
|
|
||||||
前端在“外围样板”中单独读取合成样本,展示版本、种子和摘要,以及读数、设备状态、告警和运行历史。加载失败不会清空或阻止既有核查任务及历史回放。
|
前端在“外围样板”中单独读取合成样本,展示版本、种子和摘要,以及读数、设备状态、告警和运行历史。加载失败不会清空或阻止既有核查任务及历史回放。
|
||||||
|
|
||||||
核查任务、反馈仍需用户依据已保存来源显式提交。生成器不会伪造 Next 来源、现场反馈或业务审批。
|
外围业务工单及现场反馈使用 `system_data` 来源,合成生成器不伪造业务上报、人工证据或审批。旧的“反馈导入后建立人工复核草稿”不再作为当前流程;人工事实、外部反馈与调查新轮次的边界见 [工况任务](operational-tasks.md#调查轮次与协作边界),工单来源与事件同步见 [外围服务](peripheral-sample.md)。
|
||||||
已审核反馈现在可经人工确认[导入独立新证据](field-feedback-import.md),再另行确认建立[人工复核草稿修订](feedback-review-revisions.md)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。
|
|
||||||
|
|||||||
@@ -48,3 +48,56 @@ SCADA 按月数据集、5 分钟时间定位、主动周期监测、质量校验
|
|||||||
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的水质投诉事件,或 `work_order_type=burst_report` 的爆管上报,均默认使用业务来源。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。普通业务录入无需绑定演练场景码,也不会向 Agent 注入预设监测结果或邻区投诉情况。
|
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的水质投诉事件,或 `work_order_type=burst_report` 的爆管上报,均默认使用业务来源。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。普通业务录入无需绑定演练场景码,也不会向 Agent 注入预设监测结果或邻区投诉情况。
|
||||||
|
|
||||||
外围服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
|
外围服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
|
||||||
|
|
||||||
|
## 工单事件主动同步
|
||||||
|
|
||||||
|
工单的创建、编辑、现场反馈和删除改为由来源事件触发同步。浏览器继续使用已有工作台 SSE,不需要新增连接或定时工单轮询。
|
||||||
|
|
||||||
|
```text
|
||||||
|
外围工单写入
|
||||||
|
→ 同一 PostgreSQL 事务保存源记录、历史版本和变更事件
|
||||||
|
→ COMMIT 后 NOTIFY
|
||||||
|
→ Server 工作台 SSE 唤醒外围同步器
|
||||||
|
→ 同步器通过授权 API 更新任务投影
|
||||||
|
→ task/action/notification 事件推送给浏览器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 项目与存储范围
|
||||||
|
|
||||||
|
当前 HTTP 请求按已授权项目的 Metadata 路由访问其 BizDB,源变更事件复用 `workbench.domain_event_log` 的 `workbench.work_order.changed` topic。聚合类型为 `peripheral_work_order`,元数据只包含 `source=peripheral_sample`、工单 ID 和 `created|updated|deleted`,不包含正文。
|
||||||
|
|
||||||
|
源记录、历史版本及变更事件在同一事务写入,提交后通过 PostgreSQL NOTIFY 唤醒监听;事务失败整体回滚,幂等重放不再次产生事件。外围服务只保存源事实,任务投影、调查创建和通知仍经 Server 状态机与授权 API 处理。
|
||||||
|
|
||||||
|
代码仅在当前项目已有事件表时写事件,不自动创建表、不回退其他项目数据库;表缺失时跳过事件发布,已有表写入失败则使源变更事务失败。HTTP 项目路由、数据库结构是否部署、后台同步器是否配置该项目是三个条件,需要分别核验。早期测试和迁移仅在 `tjwater_v2` 执行的记录不再作为运行时代码的固定库限制。扩展遵循 [数据库变更门禁](workbench-database-rollout.md)。
|
||||||
|
|
||||||
|
### 同步器
|
||||||
|
|
||||||
|
从 Server 仓库运行已有命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
||||||
|
```
|
||||||
|
|
||||||
|
继续复用 `OPERATIONAL_WORKER_*` OAuth 工作身份及 `OPERATIONAL_ACTION_PERIPHERAL_URL`,不需要新账号或密钥。启动和游标过期时先读取事件游标,再进行全量对账;扫描期间的事件从该游标继续补读。相同源版本仍由原幂等键去重。源事件按工单 ID 唤醒同步器;动作回执事件只补查该任务关联的工单,避免源创建早于回执保存时漏同步。任务投影事件不再触发全量扫描。
|
||||||
|
|
||||||
|
正常状态收到源事件即处理对应 ID:`GET /api/v1/operational-tasks?peripheral_work_order_id=<uuid>` 只返回同项目的来源任务和动作回执关联任务;外围接口只读取该工单,不再分页遍历全部工单和全部调查详情。重复 ID 合并;目标工单的明确删除只撤回该工单投影,不影响其他任务。
|
||||||
|
|
||||||
|
内存待处理 ID 最多 200 个,超过上限合并为一次全量对账,不持续扩张队列。这是突发积压保护,不增加公共配置项。
|
||||||
|
|
||||||
|
默认每 300 秒仍进行一次全量对账,处理漏通知、断线、旧批量脚本直接写库及重启。持续收到事件不会推迟全量对账期限。可用 `--reconcile-seconds` 调整这一兜底周期(最小 1 秒),不再复用工况 worker 的 30 秒租约检查周期。同步失败按 5、10、20、40、60 秒退避重试;事件连接断开每 5 秒重试,保留最后游标。扫描开始前取出待处理 ID,扫描期间新到达的 ID 留给下一轮;失败的 ID 会放回队列,重试不依赖新消息。
|
||||||
|
|
||||||
|
浏览器收到源事件时不会提前刷新业务快照,而是等待任务投影事件后读取对应任务。工况手动刷新仍只读 Server 权威快照。工单事件不等于 Header 通知;只有需要处理的业务待办或告警才进入通知列表。
|
||||||
|
|
||||||
|
游标顺序、共享 Hub 查询与兼容边界见 [工作台事件链路](workbench-event-refinement.md)。新增查询参数已从 Server OpenAPI 同步生成 Agent 类型,不另建手写契约。
|
||||||
|
|
||||||
|
### 验证与启用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit/test_peripheral_push_sync.py tests/unit/test_peripheral_work_order_events.py tests/unit/test_peripheral_task_sync.py -q
|
||||||
|
PERIPHERAL_SAMPLE_LIVE_TEST=1 conda run -n server python -m pytest tests/integration/test_peripheral_work_order_events.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库测试强制限定 `tjwater_v2`,使用随机项目键和回滚事务,不保留测试工单或事件。覆盖创建、修改、反馈、删除、幂等重放及回滚无事件。HTTP 隔离测试覆盖源事件即时投影、游标重置、断线续传、失败重试和监听取消。
|
||||||
|
|
||||||
|
发布时外围服务随主 Server 镜像更新,同步器是独立常驻进程;需按维护者授权的发布流程分别更新 Server、同步器及配套前端。提交代码不证明现有进程已重载。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# 外围工单主动同步
|
|
||||||
|
|
||||||
工单的创建、编辑、现场反馈和删除改为由来源事件触发同步。浏览器继续使用已有工作台 SSE,不需要新增连接或定时工单轮询。
|
|
||||||
|
|
||||||
```text
|
|
||||||
外围工单写入
|
|
||||||
→ 同一 PostgreSQL 事务保存源记录、历史版本和变更事件
|
|
||||||
→ COMMIT 后 NOTIFY
|
|
||||||
→ Server 工作台 SSE 唤醒外围同步器
|
|
||||||
→ 同步器通过授权 API 更新任务投影
|
|
||||||
→ task/action/notification 事件推送给浏览器
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据库范围
|
|
||||||
|
|
||||||
本次仅启用 `tjwater_v2`。没有新增表、列或迁移;复用 `workbench.domain_event_log` 的 `workbench.work_order.changed` topic。源事件的聚合类型为 `peripheral_work_order`,元数据只包含 `source=peripheral_sample`、工单 ID 和 `created|updated|deleted`,不包含工单正文。事务失败时,源记录、历史、事件及通知一起回滚;同一请求的幂等重放不再产生事件。
|
|
||||||
|
|
||||||
外围服务只写源记录及源变更事实,不直接改业务任务。任务投影、调查创建和通知仍通过 Server 状态机和现有授权 API 处理。其他数据库不写入该源事件;扩展前遵循 [数据库变更门禁](workbench-database-rollout.md)。事件表缺失或无写权限时,`tjwater_v2` 的工单写入返回存储错误,不会假装成功。
|
|
||||||
|
|
||||||
## 同步器
|
|
||||||
|
|
||||||
从 Server 仓库运行已有命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
conda run -n server python scripts/sync_peripheral_work_orders.py
|
|
||||||
conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
|
||||||
```
|
|
||||||
|
|
||||||
继续复用 `OPERATIONAL_WORKER_*` OAuth 工作身份及 `OPERATIONAL_ACTION_PERIPHERAL_URL`,不需要新账号或密钥。启动和游标过期时先读取事件游标,再进行全量对账;扫描期间的事件从该游标继续补读。相同源版本仍由原幂等键去重。源事件按工单 ID 唤醒同步器;动作回执事件只补查该任务关联的工单,避免源创建早于回执保存时漏同步。任务投影事件不再触发全量扫描。
|
|
||||||
|
|
||||||
正常状态收到源事件即处理对应 ID:`GET /api/v1/operational-tasks?peripheral_work_order_id=<uuid>` 只返回同项目的来源任务和动作回执关联任务;外围接口只读取该工单,不再分页遍历全部工单和全部调查详情。重复 ID 合并;目标工单的明确删除只撤回该工单投影,不影响其他任务。
|
|
||||||
|
|
||||||
内存待处理 ID 最多 200 个,超过上限合并为一次全量对账,不持续扩张队列。这是突发积压保护,不增加公共配置项。
|
|
||||||
|
|
||||||
默认每 300 秒仍进行一次全量对账,处理漏通知、断线、旧批量脚本直接写库及重启。持续收到事件不会推迟全量对账期限。可用 `--reconcile-seconds` 调整这一兜底周期(最小 1 秒),不再复用工况 worker 的 30 秒租约检查周期。同步失败按 5、10、20、40、60 秒退避重试;事件连接断开每 5 秒重试,保留最后游标。扫描开始前取出待处理 ID,扫描期间新到达的 ID 留给下一轮;失败的 ID 会放回队列,重试不依赖新消息。
|
|
||||||
|
|
||||||
浏览器收到源事件时不会提前刷新业务快照,而是等待任务投影事件后读取对应任务。工况手动刷新仍只读 Server 权威快照。工单事件不等于 Header 通知;只有需要处理的业务待办或告警才进入通知列表。
|
|
||||||
|
|
||||||
游标顺序、共享 Hub 查询与兼容边界见 [事件链路收敛](workbench-event-refinement.md)。新增查询参数已从 Server OpenAPI 同步生成 Agent 类型,不另建手写契约。
|
|
||||||
|
|
||||||
## 验证与启用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
conda run -n server python -m pytest tests/unit/test_peripheral_push_sync.py tests/unit/test_peripheral_work_order_events.py tests/unit/test_peripheral_task_sync.py -q
|
|
||||||
PERIPHERAL_SAMPLE_LIVE_TEST=1 conda run -n server python -m pytest tests/integration/test_peripheral_work_order_events.py -q
|
|
||||||
```
|
|
||||||
|
|
||||||
数据库测试强制限定 `tjwater_v2`,使用随机项目键和回滚事务,不保留测试工单或事件。覆盖创建、修改、反馈、删除、幂等重放及回滚无事件。HTTP 隔离测试覆盖源事件即时投影、游标重置、断线续传、失败重试和监听取消。
|
|
||||||
|
|
||||||
上线时更新外围服务、Server 同步器及前端,再按既有发布流程重启对应进程。提交代码不会自动重启现有服务;旧同步器进程仍按旧的 30 秒周期运行。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
|
|
||||||
@@ -161,7 +161,7 @@ MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,
|
|||||||
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
|
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
|
||||||
| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
|
| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
|
||||||
| [压力清洗](../app/algorithms/scada_cleaning/pressure_series.py)、[流量清洗](../app/algorithms/scada_cleaning/flow_series.py) | 包含居中窗口、后向补值、Kalman 平滑;流量路径还会补为 1 分钟序列 | 在线路径只使用已到达的数据,保留设备频率,不整月预清洗后再模拟检测 |
|
| [压力清洗](../app/algorithms/scada_cleaning/pressure_series.py)、[流量清洗](../app/algorithms/scada_cleaning/flow_series.py) | 包含居中窗口、后向补值、Kalman 平滑;流量路径还会补为 1 分钟序列 | 在线路径只使用已到达的数据,保留设备频率,不整月预清洗后再模拟检测 |
|
||||||
| [外围工单事件同步](peripheral-work-order-events.md) | 持久事件、通知唤醒、后台同步和定期对账 | 复用可靠处理方式,SCADA 的批次有自己的持久状态 |
|
| [外围工单事件同步](peripheral-sample.md#工单事件主动同步) | 持久事件、通知唤醒、后台同步和定期对账 | 复用可靠处理方式,SCADA 的批次有自己的持久状态 |
|
||||||
| [调查 worker](../scripts/run_operational_worker.py) | 可靠投递 `event_investigation`,恢复执行租约,管理 Agent 执行代次 | 接收由 SCADA 异常事件建立的调查;不承担每条观测的检测 |
|
| [调查 worker](../scripts/run_operational_worker.py) | 可靠投递 `event_investigation`,恢复执行租约,管理 Agent 执行代次 | 接收由 SCADA 异常事件建立的调查;不承担每条观测的检测 |
|
||||||
| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
|
| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
|
||||||
| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
|
| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- 这个锁会串行化事件写事务,任务仓储的短命令事务(包括心跳)也使用它。这是当前低频业务规模下的明确取舍,不适用于高频遥测或长事务。扩大业务事件量前应实际测量写入等待时间,不把增加锁误称为写吞吐优化。
|
- 这个锁会串行化事件写事务,任务仓储的短命令事务(包括心跳)也使用它。这是当前低频业务规模下的明确取舍,不适用于高频遥测或长事务。扩大业务事件量前应实际测量写入等待时间,不把增加锁误称为写吞吐优化。
|
||||||
- 每个进程/项目仍只有一条专用 LISTEN 连接,不长期占用普通请求池。Hub 按唤醒代次共享同游标读取;最多保留 32 个游标批次,每批最多 200 条,慢客户端不会形成无界缓存。事件通知和每 30 秒一次的共享对账使缓存失效;每个浏览器的 15 秒 keepalive 不再触发独立 SQL 查询。无订阅者 60 秒后仍关闭 Hub。
|
- 每个进程/项目仍只有一条专用 LISTEN 连接,不长期占用普通请求池。Hub 按唤醒代次共享同游标读取;最多保留 32 个游标批次,每批最多 200 条,慢客户端不会形成无界缓存。事件通知和每 30 秒一次的共享对账使缓存失效;每个浏览器的 15 秒 keepalive 不再触发独立 SQL 查询。无订阅者 60 秒后仍关闭 Hub。
|
||||||
- `reset_required` 允许权威快照把客户端游标从较大值重置为 0;普通快照仍禁止回退,过时并发响应仍不能覆盖新响应。重置失败会在重连时重试。只有 SSE 请求成功打开后才显示连接正常,HTTP 失败按原退避策略重连;离开或重置时释放流读取器。
|
- `reset_required` 允许权威快照把客户端游标从较大值重置为 0;普通快照仍禁止回退,过时并发响应仍不能覆盖新响应。重置失败会在重连时重试。只有 SSE 请求成功打开后才显示连接正常,HTTP 失败按原退避策略重连;离开或重置时释放流读取器。
|
||||||
- 工单源事件按 ID 定向同步。任务投影的 `operational.task.changed` 负责更新详情,其伴随的 `workbench.work_order.changed/operational_task` 不再重复拉取整份快照。告警和分析事件仍合并后刷新运行态;SCADA/流量采样通道不变。全量对账见 [外围工单主动同步](peripheral-work-order-events.md)。
|
- 工单源事件按 ID 定向同步。任务投影的 `operational.task.changed` 负责更新详情,其伴随的 `workbench.work_order.changed/operational_task` 不再重复拉取整份快照。告警和分析事件仍合并后刷新运行态;SCADA/流量采样通道不变。全量对账见 [外围工单主动同步](peripheral-sample.md#工单事件主动同步)。
|
||||||
- 项目查询按同一身份提供器共享,缓存 5 分钟。每个调用仍重新获取 token,各调用可独立取消;共享查询有 15 秒超时,失败后清除缓存。此缓存不授予权限,Server 每次请求和每次 SSE 重建仍鉴权。
|
- 项目查询按同一身份提供器共享,缓存 5 分钟。每个调用仍重新获取 token,各调用可独立取消;共享查询有 15 秒超时,失败后清除缓存。此缓存不授予权限,Server 每次请求和每次 SSE 重建仍鉴权。
|
||||||
- 事件接收游标与数据读取成功分开处理:增量详情、通知或事件触发的运行态快照失败时,只对失败读取按 1、2、4 秒递增至 30 秒重试,成功后才确认失效事件。被并发游标守卫拒绝的快照不能当作刷新成功;仅在更新的成功快照已覆盖失效游标时停止重试。正常读取成功后不安排定时请求;依赖变化、重置或离开页面会取消旧请求及重试定时器。这不是新增正常轮询通道,也不扩大初始化列表的裁剪/重试语义。
|
- 事件接收游标与数据读取成功分开处理:增量详情、通知或事件触发的运行态快照失败时,只对失败读取按 1、2、4 秒递增至 30 秒重试,成功后才确认失效事件。被并发游标守卫拒绝的快照不能当作刷新成功;仅在更新的成功快照已覆盖失效游标时停止重试。正常读取成功后不安排定时请求;依赖变化、重置或离开页面会取消旧请求及重试定时器。这不是新增正常轮询通道,也不扩大初始化列表的裁剪/重试语义。
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# 工作台工单与告警存储契约
|
|
||||||
|
|
||||||
工作台聚合外围来源工况任务与任务主动推送的告警。浏览器不推导告警,也不直接写工况任务。
|
|
||||||
|
|
||||||
## 任务告警
|
|
||||||
|
|
||||||
DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。
|
|
||||||
|
|
||||||
## 预约工单
|
|
||||||
|
|
||||||
外围样板的 `peripheral_sample.work_orders` 是演示工单的管理来源;`activity` 只记录实际发生的创建、修改和现场反馈,不由总状态补造阶段。同步器将有效工单写入 `workbench.operational_tasks` 的 `external_work_order` 投影。原有三张独立演示工单也已迁入外围样板,并由同一面板管理。
|
|
||||||
|
|
||||||
`resources/sql/011_workbench_work_orders.sql` 和 `017_workbench_schema_comments.sql` 属于历史基线;`021_retire_workbench_work_orders.sql` 在验证三个迁入目标及其任务投影后,仅在 `tjwater_v2` 删除旧表。不要重放 `011` 重建它。`GET /api/v1/workbench/runtime` 暂保留空的 `work_orders` 字段作为接口兼容。其他项目数据库没有旧表,不需要应用 `021`。
|
|
||||||
|
|
||||||
告警事件表需要 `SELECT, INSERT`,告警状态表需要 `SELECT, INSERT, UPDATE`。迁移及扩展门禁见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。
|
|
||||||
@@ -7,4 +7,20 @@
|
|||||||
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
|
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
|
||||||
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
|
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
|
||||||
|
|
||||||
原有三张独立演示工单已迁入 `peripheral_sample.work_orders`,仍由外围样板面板管理,并由同步器投影为 `external_work_order` 工况任务。旧表退役前的完整数据库备份由部署记录保存,不能重新运行历史 `011_workbench_work_orders.sql` 或旧种子脚本重建双份数据。
|
历史上原有三张独立演示工单已迁入 `peripheral_sample.work_orders`,仍由外围样板面板管理,并由同步器投影为 `external_work_order` 工况任务。旧表退役前的完整数据库备份由部署记录保存,不能重新运行历史 `011_workbench_work_orders.sql` 或旧种子脚本重建双份数据。
|
||||||
|
|
||||||
|
## 工单与告警存储
|
||||||
|
|
||||||
|
### 任务告警
|
||||||
|
|
||||||
|
DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。
|
||||||
|
|
||||||
|
### 外围工单
|
||||||
|
|
||||||
|
外围服务的 `peripheral_sample.work_orders` 是业务工单的管理来源,当前 `origin` 固定为 `system_data`;`activity` 只记录实际发生的创建、修改和现场反馈,不由总状态补造阶段。同步器将有效工单写入 `workbench.operational_tasks` 的 `external_work_order` 投影。原有三张独立演示工单也已迁入外围样板,并由同一面板管理。
|
||||||
|
|
||||||
|
`resources/sql/011_workbench_work_orders.sql` 和 `017_workbench_schema_comments.sql` 属于历史基线;`021_retire_workbench_work_orders.sql` 在验证三个迁入目标及其任务投影后,仅在 `tjwater_v2` 删除旧表。不要重放 `011` 重建它。`GET /api/v1/workbench/runtime` 暂保留空的 `work_orders` 字段作为接口兼容。其他项目数据库没有旧表,不需要应用 `021`。
|
||||||
|
|
||||||
|
告警事件表需要 `SELECT, INSERT`,告警状态表需要 `SELECT, INSERT, UPDATE`。迁移及扩展门禁见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。
|
||||||
|
|
||||||
|
当前外围工单来源与按项目路由见 [外围服务](peripheral-sample.md),其中 `sample` 只保留兼容名称。上述旧表迁移的历史数据库范围不表示其他项目已应用迁移,也不限制当前 API 的项目路由。
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Run-scoped artifact facts survive domain-event retention without a new lifecycle table.
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
ALTER TABLE workbench.operational_task_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS artifact_manifest jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE workbench.operational_task_runs
|
||||||
|
DROP CONSTRAINT IF EXISTS operational_task_runs_artifact_manifest_object;
|
||||||
|
ALTER TABLE workbench.operational_task_runs
|
||||||
|
ADD CONSTRAINT operational_task_runs_artifact_manifest_object
|
||||||
|
CHECK (jsonb_typeof(artifact_manifest) = 'object');
|
||||||
|
COMMENT ON COLUMN workbench.operational_task_runs.artifact_manifest IS
|
||||||
|
'Server-verified per-run artifact facts. scada_presentation holds cumulative targets and latest chart coverage, independent of browser receipts and event retention; not run status.';
|
||||||
|
COMMIT;
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
name: burst-event-handling
|
name: burst-event-handling
|
||||||
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.1"
|
version: "1.1.3"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管事件处理
|
# 爆管事件处理
|
||||||
|
|
||||||
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
||||||
|
|
||||||
|
先按handling_context接续:等待阶段按第6节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
||||||
|
|
||||||
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
||||||
|
|
||||||
## 1. 接收与核对
|
## 1. 接收与核对
|
||||||
@@ -42,12 +44,14 @@ metadata:
|
|||||||
|
|
||||||
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
||||||
|
|
||||||
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。已完成本轮可执行分析且依赖结果已读取时发布 final,现场处理进展由后续反馈接续。
|
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
||||||
|
|
||||||
## 6. 反馈与闭环复核
|
## 6. 反馈与闭环复核
|
||||||
|
|
||||||
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
||||||
|
|
||||||
|
草稿待审批、waiting_work_order或waiting_scada且当前无独立可执行工作时,保存本轮interim/concluded阶段报告并关联真实action_id,然后结束本轮执行,任务保持等待并释放租约。不能只更新progress或沿用旧轮报告,不循环查询工单或重复异常分析。创建成功及普通进度更新不启动新一轮;工单失败或取消时重新决策,完工后由Server等待恢复证据就绪,再唤醒同一任务。
|
||||||
|
|
||||||
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,13 +2,15 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.13"
|
version: "1.0.16"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SCADA 数据异常调查
|
# SCADA 数据异常调查
|
||||||
|
|
||||||
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
|
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
|
||||||
|
|
||||||
|
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
||||||
|
|
||||||
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
||||||
|
|
||||||
## 数据核验与诊断
|
## 数据核验与诊断
|
||||||
@@ -17,7 +19,7 @@ metadata:
|
|||||||
|
|
||||||
通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
||||||
|
|
||||||
全量响应中的devices包含完整目录属性,readings包含实际读数,coverage列出全部查询设备、各设备读数数量、缺测和冻结范围外设备。不能只读取已标记、异常或代表设备后推断全网状态。单独读取list_scada_devices时,默认每页100个,持续翻页到page.has_more=false;属性缩略不等于已读取完整设备信息。全量读取后可显式指定device_ids做局部复核,局部读数仍按page.has_more接续,不能替代全量输入。最终报告引用覆盖本轮knowledge_cutoff和batch_sequence的全量证据,Server会拒绝仅有局部或旧批次证据的final。
|
全量devices含完整目录,readings含观测,coverage含各设备读数数量、缺测及冻结范围外清单。不得由代表测点推断全网。单查list_scada_devices默认每页100,读至page.has_more=false,属性缩略不代表完整信息。全量后可按device_ids局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
|
||||||
|
|
||||||
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
||||||
|
|
||||||
@@ -31,7 +33,9 @@ metadata:
|
|||||||
|
|
||||||
## 对比展示与影响
|
## 对比展示与影响
|
||||||
|
|
||||||
先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。
|
监测图必填chart.scada:role=current/comparison、metric=pressure/flow、unit、window_start/window_end(三小时);series绑定device_id、day_offset=0..7。evidence_refs包含所有采样来源。报告scada_device_ids声明表格目标。Server在final及成功结束校验本轮总览和同期覆盖,SCADA_PRESENTATION_INCOMPLETE须补图;仅作参考不豁免。缺测须完整查询证明,保留null和设备图;scada.gaps由Server生成。恢复复核沿用恢复门禁。
|
||||||
|
|
||||||
|
先查询并发布以本轮knowledge_cutoff或异常时刻t为终点的[t-3小时,t]当前总览,覆盖地图定位、等级标记和表格列出的全部SCADA目标,含正常与异常测点。压力、流量按指标及单位分图,每设备一条序列,name带device_id;每图最多8组,超限按设备分图并用不同稳定chart_id。保留三小时内真实采样网格;截止非整刻不平移采样。超过100槽按连续时间分图,各用稳定chart_id,scada窗口仍填完整三小时;Server合并采样槽覆盖。低频无计划采样槽时,完整查询当天及历史窗口,以截止时刻单个null保留入口。缺测保留设备;“数据”视图沿用全部序列,报告附设备、单位、窗口、有效点数与缺测表。
|
||||||
|
|
||||||
定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。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的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
|
定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。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,12 +45,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保持不变。
|
关联工单completed后,以handling_context.returned_at为实际完工业务时间,不用创建、预约或电脑当前时间代替。Server读取严格晚于该时刻的最近一帧及事件前15天同相位对照,独立保存恢复证据。回放来源工单同样适用,不推进回放,原knowledge_cutoff与batch_sequence不变。
|
||||||
|
|
||||||
phase=waiting_work_order时等待现场完工;phase=waiting_scada时查看last_check,分别说明尚无后续采样、基线或测点缺项、实际仍异常,等待监测复核,不将原回放查询越界解释为恢复数据不存在。phase=verification_ready时,用get_task_evidence读取verification_evidence_id的完整恢复证据,比较实际样本、正常范围与现场回报,最终报告同时引用原异常全量证据与当前恢复证据。恢复数据已由Server保存,不用query_scada_readings越过原冻结上限重复查询,也不因该上限再建复测工单;确有独立现场工作时才提议新动作。完工时间修订后使用新返回的证据编号,旧证据仅作历史。正常读数不替代阀位、漏点照片等现场事实,报告分别说明监测恢复、现场待核验项与事件闭环。
|
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仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Preview, back up, then adopt saved progress graphs without rewriting reports.
|
||||||
|
|
||||||
|
Run with ``python -m scripts.migrate_investigation_graphs --help`` from the Server.
|
||||||
|
DSNs are resolved from MetaDB and never written to the plan or console.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationProgressSnapshot
|
||||||
|
from app.infra.db.metadb.database import SessionLocal, engine
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.services.investigation_graph import adopt_legacy_investigation_graph
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION = "investigation-graph-reference-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_progress_document(document: dict) -> dict | None:
|
||||||
|
"""Return a new revision, preserving publication provenance and Mermaid bytes."""
|
||||||
|
graph = document["progress"].get("diagram")
|
||||||
|
if graph is None or graph.get("edges") is not None:
|
||||||
|
return None
|
||||||
|
diagram = adopt_legacy_investigation_graph(InvestigationDiagram.model_validate(graph))
|
||||||
|
result = deepcopy(document)
|
||||||
|
result["revision"] += 1
|
||||||
|
result["progress"]["diagram"] = diagram.model_dump(mode="json")
|
||||||
|
InvestigationProgressSnapshot.model_validate(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _write_private(path: Path, content: str, *, append: bool = False) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | (os.O_APPEND if append else os.O_EXCL)
|
||||||
|
with os.fdopen(os.open(path, flags, 0o600), "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
async def _routing(project_id: UUID) -> ProjectDbRouting:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(text("SET TRANSACTION READ ONLY"))
|
||||||
|
route = await MetadataRepository(session).get_project_db_routing(project_id, "biz_data")
|
||||||
|
if route is None or route.db_type != "postgresql":
|
||||||
|
raise ValueError(f"Project {project_id} has no business PostgreSQL routing")
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(project_id: UUID) -> dict:
|
||||||
|
route = await _routing(project_id)
|
||||||
|
async with await AsyncConnection.connect(
|
||||||
|
route.dsn, row_factory=dict_row, connect_timeout=8,
|
||||||
|
options="-c default_transaction_read_only=on -c statement_timeout=30000",
|
||||||
|
) as conn:
|
||||||
|
cursor = await conn.execute("SELECT current_database() AS database")
|
||||||
|
database = (await cursor.fetchone())["database"]
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""SELECT task_id,run_id,document FROM workbench.operational_task_progress
|
||||||
|
WHERE project_id=%s AND jsonb_typeof(document#>'{progress,diagram}')='object'
|
||||||
|
AND jsonb_typeof(document#>'{progress,diagram,edges}') IS DISTINCT FROM 'array'
|
||||||
|
ORDER BY task_id,run_id""", (project_id,),
|
||||||
|
)
|
||||||
|
records = []
|
||||||
|
for row in await cursor.fetchall():
|
||||||
|
after = prepare_progress_document(row["document"])
|
||||||
|
if after is not None:
|
||||||
|
records.append({"task_id": str(row["task_id"]), "run_id": str(row["run_id"]),
|
||||||
|
"before": row["document"], "after": after})
|
||||||
|
return {"project_id": str(project_id), "database": database, "records": records}
|
||||||
|
|
||||||
|
|
||||||
|
async def _protected_digest(conn: AsyncConnection, project_id: UUID) -> dict[str, str]:
|
||||||
|
"""Compare complete protected rows inside the migration transaction."""
|
||||||
|
hashes = {}
|
||||||
|
for table in ("operational_tasks", "operational_task_runs", "operational_task_reports"):
|
||||||
|
# Table names are fixed here, never supplied by CLI input.
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"SELECT to_jsonb(t) AS row FROM workbench.{table} t WHERE project_id=%s",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
rows = sorted(json.dumps(row["row"], sort_keys=True) for row in await cursor.fetchall())
|
||||||
|
hashes[table] = hashlib.sha256(json.dumps(rows).encode()).hexdigest()
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_project(project: dict) -> dict:
|
||||||
|
project_id = UUID(project["project_id"])
|
||||||
|
route = await _routing(project_id)
|
||||||
|
async with await AsyncConnection.connect(
|
||||||
|
route.dsn, row_factory=dict_row, connect_timeout=8,
|
||||||
|
options="-c lock_timeout=5000 -c statement_timeout=30000",
|
||||||
|
) as conn:
|
||||||
|
repo = OperationalTaskRepository(conn, project_id)
|
||||||
|
updated = already_managed = 0
|
||||||
|
async with repo.transaction():
|
||||||
|
cursor = await conn.execute("SELECT current_database() AS database")
|
||||||
|
if (await cursor.fetchone())["database"] != project["database"]:
|
||||||
|
raise ValueError("Project database routing changed since preview")
|
||||||
|
before = await _protected_digest(conn, project_id)
|
||||||
|
for item in project["records"]:
|
||||||
|
await repo.lock(f"task:{item['task_id']}")
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_progress
|
||||||
|
WHERE project_id=%s AND task_id=%s AND run_id=%s FOR UPDATE""",
|
||||||
|
(project_id, item["task_id"], item["run_id"]),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row and (row["document"]["progress"].get("diagram") or {}).get("edges") is not None:
|
||||||
|
already_managed += 1
|
||||||
|
continue
|
||||||
|
if row is None or row["document"] != item["before"]:
|
||||||
|
raise ValueError(f"Progress changed since preview: {item['task_id']}; prepare a new plan")
|
||||||
|
expected = prepare_progress_document(item["before"])
|
||||||
|
if expected is None or expected != item["after"]:
|
||||||
|
raise ValueError("Migration plan does not match the graph contract")
|
||||||
|
await repo.save_progress(item["task_id"], expected)
|
||||||
|
await repo.append_event(
|
||||||
|
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
||||||
|
aggregate_id=item["task_id"], aggregate_revision=expected["revision"],
|
||||||
|
occurred_at=datetime.now(timezone.utc), metadata={
|
||||||
|
"task_id": item["task_id"], "run_id": item["run_id"],
|
||||||
|
"execution_generation": expected["execution_generation"], "migration": MIGRATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
if before != await _protected_digest(conn, project_id):
|
||||||
|
raise ValueError("Protected task/run/report rows changed; migration rolled back")
|
||||||
|
return {"project_id": str(project_id), "database": project["database"], "updated": updated,
|
||||||
|
"already_managed": already_managed, "protected_rows_unchanged": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args: argparse.Namespace) -> None:
|
||||||
|
try:
|
||||||
|
if args.plan:
|
||||||
|
if not args.project_id:
|
||||||
|
raise ValueError("--plan requires at least one --project-id")
|
||||||
|
projects = [await preview(project_id) for project_id in dict.fromkeys(args.project_id)]
|
||||||
|
plan = {"migration": MIGRATION, "created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"projects": projects}
|
||||||
|
_write_private(args.plan, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
print(json.dumps({"plan": str(args.plan), "projects": [
|
||||||
|
{"project_id": item["project_id"], "database": item["database"],
|
||||||
|
"progress_graphs": len(item["records"]),
|
||||||
|
"tasks": len({row["task_id"] for row in item["records"]})} for item in projects
|
||||||
|
]}))
|
||||||
|
else:
|
||||||
|
plan = json.loads(args.apply.read_text(encoding="utf-8"))
|
||||||
|
if plan["migration"] != MIGRATION:
|
||||||
|
raise ValueError("Unsupported migration plan")
|
||||||
|
for project in plan["projects"]:
|
||||||
|
result = await apply_project(project)
|
||||||
|
_write_private(args.apply.with_suffix(".receipts.jsonl"), json.dumps(result) + "\n", append=True)
|
||||||
|
print(json.dumps(result), flush=True)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
mode = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
mode.add_argument("--plan", type=Path, help="Write a preview and full progress backup to a new private JSON file")
|
||||||
|
mode.add_argument("--apply", type=Path, help="Apply an existing preview; changed rows abort that project's transaction")
|
||||||
|
parser.add_argument("--project-id", type=UUID, action="append", help="Explicit project scope for --plan")
|
||||||
|
asyncio.run(run(parser.parse_args()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Missing API Endpoints
|
# Missing API Endpoints
|
||||||
|
|
||||||
|
> Historical endpoint inventory. Counts and missing-route claims describe the original scan, not the current API. Check [API rules](../docs/api-style.md) and [the generated contract](../contracts/server-v1.openapi.json) before planning changes.
|
||||||
|
|
||||||
- Legacy endpoints checked: 392
|
- Legacy endpoints checked: 392
|
||||||
- Current endpoints found: 401
|
- Current endpoints found: 401
|
||||||
- Missing endpoints: 17
|
- Missing endpoints: 17
|
||||||
|
|||||||
@@ -90,25 +90,27 @@ 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:
|
||||||
if time.monotonic() >= self._expires:
|
async with self._refresh_lock:
|
||||||
response = await client.post(self.settings.token_url, data={
|
if time.monotonic() >= self._expires:
|
||||||
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
response = await client.post(self.settings.token_url, data={
|
||||||
"client_secret": self.settings.client_secret,
|
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
||||||
})
|
"client_secret": self.settings.client_secret,
|
||||||
response.raise_for_status()
|
})
|
||||||
payload = response.json()
|
response.raise_for_status()
|
||||||
self._token = payload["access_token"]
|
payload = response.json()
|
||||||
if not isinstance(self._token, str) or not self._token:
|
self._token = payload["access_token"]
|
||||||
raise ValueError("identity provider returned no access token")
|
if not isinstance(self._token, str) or not self._token:
|
||||||
# Product auth also restricts token age, independently of the
|
raise ValueError("identity provider returned no access token")
|
||||||
# provider's exp. Do not cache a long-lived OAuth token for hours.
|
# Product auth also restricts token age, independently of the
|
||||||
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
|
# provider's exp. Do not cache a long-lived OAuth token for hours.
|
||||||
self._expires = time.monotonic() + max(1, lifetime - 15)
|
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
|
||||||
|
self._expires = time.monotonic() + max(1, lifetime - 15)
|
||||||
token = self._token
|
token = self._token
|
||||||
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
|
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
|||||||
existing[task["source"].get("external_id")] = task
|
existing[task["source"].get("external_id")] = task
|
||||||
elif task["task_type"] == "event_investigation":
|
elif task["task_type"] == "event_investigation":
|
||||||
event_tasks[task.get("source", {}).get("external_id")] = task
|
event_tasks[task.get("source", {}).get("external_id")] = task
|
||||||
if task["status"] in {"queued", "waiting_human", "running"} or (
|
if task["status"] in {"queued", "waiting_human", "running", "failed"} or (
|
||||||
work_order_ids is not None and task["status"] == "completed"
|
work_order_ids is not None and task["status"] == "completed"
|
||||||
):
|
):
|
||||||
investigations[task["task_id"]] = task
|
investigations[task["task_id"]] = task
|
||||||
|
|||||||
+992
@@ -0,0 +1,992 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "zero and sparse historical data",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "old mixed charts are read only",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "line",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "missing history timestamps",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "missing observed timestamp",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "timestamp on a missing value",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-09T08:00:00+08:00",
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wrong projected slot",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:30:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mixed historical dates",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-10T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "same day history",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "future history",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-17T08:15:00+08:00",
|
||||||
|
"2026-07-17T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duplicate slots",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:00",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "date appended axis",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"07-16 08:00",
|
||||||
|
"07-16 08:15",
|
||||||
|
"07-16 08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inconsistent slot precision",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bars mixed with comparison",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "bar",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no historical series",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no current observations",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "missing samples with missing timestamps",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "UTC spelling preserves projected instant",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T00:15:00Z",
|
||||||
|
"2026-07-09T00:30:00Z"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ordinary line",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "line",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "合成读数",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ordinary bar",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "bar",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "合成读数",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "midnight rollover keeps one window",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "跨午夜同期对比",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_data": [
|
||||||
|
"23:45",
|
||||||
|
"00:00"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "当前",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
25
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T23:45:00+08:00",
|
||||||
|
"2026-07-17T00:00:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "历史",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
26,
|
||||||
|
27
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-15T23:45:00+08:00",
|
||||||
|
"2026-07-16T00:00:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "microseconds are not aligned",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00.000001+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fraction beyond Python microsecond precision",
|
||||||
|
"accepted": true,
|
||||||
|
"chart": {
|
||||||
|
"title": "P1 合成压力七日对照(m)",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)",
|
||||||
|
"x_data": [
|
||||||
|
"08:00",
|
||||||
|
"08:15",
|
||||||
|
"08:30"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "P1 当前 2026-07-16",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
0,
|
||||||
|
28,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"2026-07-16T08:00:00.0000001+08:00",
|
||||||
|
"2026-07-16T08:15:00+08:00",
|
||||||
|
"2026-07-16T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "P1 2026-07-09",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null,
|
||||||
|
32,
|
||||||
|
33
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null,
|
||||||
|
"2026-07-09T08:15:00+08:00",
|
||||||
|
"2026-07-09T08:30:00+08:00"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calendar overflow",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "calendar overflow",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_data": [
|
||||||
|
"07:45"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "当前",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"9999-12-31T23:45:00Z"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "历史缺测",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calendar underflow",
|
||||||
|
"accepted": false,
|
||||||
|
"chart": {
|
||||||
|
"title": "calendar underflow",
|
||||||
|
"chart_type": "time_comparison",
|
||||||
|
"x_data": [
|
||||||
|
"08:00"
|
||||||
|
],
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "当前",
|
||||||
|
"type": "line",
|
||||||
|
"data": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
"0001-01-01T00:00:00+23:59"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "历史缺测",
|
||||||
|
"type": "scatter",
|
||||||
|
"data": [
|
||||||
|
null
|
||||||
|
],
|
||||||
|
"sample_times": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}}
|
||||||
|
{"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}}
|
||||||
|
{"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}}
|
||||||
|
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P1","evidence_refs":["dev:synthetic"],"chart":{"title":"P1 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P1 当前 2026-07-16","type":"line","data":[32,28,30],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P1 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P1 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P1 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P1 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P1 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P1 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P1 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||||
|
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P2","evidence_refs":["dev:synthetic"],"chart":{"title":"P2 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P2 当前 2026-07-16","type":"line","data":[31,27,29],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P2 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P2 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P2 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P2 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P2 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P2 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P2 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||||
|
{"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}}
|
||||||
|
{"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}}
|
||||||
|
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}}
|
||||||
|
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"}
|
||||||
|
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P1"}}
|
||||||
|
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P2"}}
|
||||||
|
{"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}}
|
||||||
|
{"record_type":"presentation","command":{"action":"clear_highlights"}}
|
||||||
|
{"record_type":"presentation","command":{"action":"clear_scada_analysis"}}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Opt-in read-only JSONB probes; no tables or business records are written."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from psycopg import AsyncConnection
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.infra.db.postgresql import read_queries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("section", ["progress", "report"])
|
||||||
|
def test_reference_annotations_are_normalized_before_nested_paging(section):
|
||||||
|
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||||
|
if not dsn:
|
||||||
|
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
statuses = ["running", "completed", "waiting", "waiting_human", "skipped", "cancelled", "failed", None]
|
||||||
|
original = {"section": section, "data": {
|
||||||
|
"revision": 3, "execution_generation": 2,
|
||||||
|
section: {"diagram": {"mermaid": "flowchart TD\nA[原图]", "node_refs": [
|
||||||
|
{"node_id": f"n{index}", "status": status, "detail": "保留原说明", "evidence_refs": ["e1"]}
|
||||||
|
for index, status in enumerate(statuses)
|
||||||
|
] + [{"node_id": "unset"}]}},
|
||||||
|
"status": "running", "action": {"status": "completed"},
|
||||||
|
}}
|
||||||
|
untouched = deepcopy(original)
|
||||||
|
async with await AsyncConnection.connect(
|
||||||
|
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||||
|
) as conn:
|
||||||
|
async def page(path=(), *, limit=50, offset=0, full=False):
|
||||||
|
statement, params = read_queries._reference_diagram_projection(
|
||||||
|
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), section,
|
||||||
|
)
|
||||||
|
return await read_queries._snapshot_page(
|
||||||
|
conn, stored_sql=statement, stored_params=params, data_path=list(path),
|
||||||
|
limit=limit, offset=offset, mcp_sources=[], full_snapshot=full, max_bytes=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
scalar = await page([section, "diagram", "node_refs", "0", "status"])
|
||||||
|
assert scalar["value"] == "reference"
|
||||||
|
second = await page([section, "diagram", "node_refs"], limit=1, offset=1)
|
||||||
|
assert second["value"][0]["status"] == "recorded"
|
||||||
|
assert second["total"] == 9 and second["nested"]
|
||||||
|
assert second["value"][0]["evidence_refs"]["data_path"] == [section, "diagram", "node_refs", "1", "evidence_refs"]
|
||||||
|
snapshot = await page(full=True)
|
||||||
|
nodes = snapshot["data"][section]["diagram"]["node_refs"]
|
||||||
|
assert [node.get("status") for node in nodes] == [
|
||||||
|
"reference", "recorded", "needs_input", "needs_input", "not_taken", "not_taken", "failed", None, None,
|
||||||
|
]
|
||||||
|
assert "status" not in nodes[-1]
|
||||||
|
assert nodes[6]["detail"] == "保留原说明" and nodes[6]["evidence_refs"] == ["e1"]
|
||||||
|
assert snapshot["data"]["status"] == "running"
|
||||||
|
assert snapshot["data"]["action"]["status"] == "completed"
|
||||||
|
assert snapshot["data"][section]["diagram"]["mermaid"] == original["data"][section]["diagram"]["mermaid"]
|
||||||
|
assert original == untouched
|
||||||
|
|
||||||
|
asyncio.run(check())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("diagram", [None, {}, {"node_refs": []}])
|
||||||
|
def test_reference_projection_preserves_empty_and_missing_legacy_diagrams(diagram):
|
||||||
|
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||||
|
if not dsn:
|
||||||
|
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
original = {"section": "progress", "data": {"progress": {"diagram": diagram}, "status": "running"}}
|
||||||
|
async with await AsyncConnection.connect(
|
||||||
|
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||||
|
) as conn:
|
||||||
|
statement, params = read_queries._reference_diagram_projection(
|
||||||
|
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), "progress",
|
||||||
|
)
|
||||||
|
result = await read_queries._snapshot_page(
|
||||||
|
conn, stored_sql=statement, stored_params=params, data_path=[],
|
||||||
|
limit=20, offset=0, mcp_sources=[], full_snapshot=True,
|
||||||
|
)
|
||||||
|
assert result == original
|
||||||
|
|
||||||
|
asyncio.run(check())
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""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.scada_presentations = {}
|
||||||
|
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 get_scada_presentation(self, task_id, run_id):
|
||||||
|
return deepcopy(self.scada_presentations.get((task_id, run_id)))
|
||||||
|
|
||||||
|
async def save_scada_presentation(self, task_id, run_id, document, now):
|
||||||
|
self.scada_presentations[(task_id, run_id)] = deepcopy(document)
|
||||||
|
|
||||||
|
async def get_latest_graph_progress(self, task_id):
|
||||||
|
snapshots = [value for (stored_id, _), value in self.progress.items()
|
||||||
|
if stored_id == task_id
|
||||||
|
and (value["progress"].get("diagram") or {}).get("edges") is not None]
|
||||||
|
return max(snapshots, key=lambda row: (row["execution_generation"], row["revision"])) if snapshots else None
|
||||||
|
|
||||||
|
async def get_latest_diagram(self, task_id):
|
||||||
|
diagrams = [(row["published_at"], row["progress"].get("diagram"))
|
||||||
|
for (stored_id, _), row in self.progress.items() if stored_id == task_id]
|
||||||
|
diagrams.extend((row["published_at"], row["report"].get("diagram")) for row in self.reports)
|
||||||
|
return next((diagram for _, diagram in sorted(diagrams, key=lambda item: item[0], reverse=True)
|
||||||
|
if diagram is not None), None)
|
||||||
|
|
||||||
|
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,48 @@
|
|||||||
|
"""Generated fixtures keep executable examples and publication semantics aligned across repositories."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
|
||||||
|
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parents[1] / "fixtures"
|
||||||
|
CASES = json.loads((FIXTURES / "chart-publication-cases.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"])
|
||||||
|
def test_chart_publication_matches_frontend_and_agent(case):
|
||||||
|
def validate():
|
||||||
|
chart = TaskSafeChart.model_validate(case["chart"]).model_dump(mode="json")
|
||||||
|
validate_time_comparison_chart(chart)
|
||||||
|
|
||||||
|
if case["accepted"]:
|
||||||
|
validate()
|
||||||
|
else:
|
||||||
|
with pytest.raises((DomainError, ValidationError)):
|
||||||
|
validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_mixed_charts_remain_structurally_readable():
|
||||||
|
chart = next(case["chart"] for case in CASES if case["name"] == "old mixed charts are read only")
|
||||||
|
TaskSafeChart.model_validate(chart)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_command_examples_use_the_current_publication_contract():
|
||||||
|
for line in (FIXTURES / "unified-presentation.jsonl").read_text().splitlines():
|
||||||
|
record = json.loads(line)
|
||||||
|
if record["record_type"] != "presentation":
|
||||||
|
continue
|
||||||
|
event = TaskPresentationEventCommand.model_validate({
|
||||||
|
"request_id": "95000000-0000-4000-8000-000000000001",
|
||||||
|
"run_id": "95000000-0000-4000-8000-000000000002",
|
||||||
|
"source_session_id": "synthetic-chart-contract",
|
||||||
|
"command": record["command"],
|
||||||
|
})
|
||||||
|
command = event.command
|
||||||
|
if command.action == "open_chart":
|
||||||
|
validate_time_comparison_chart(command.chart.model_dump(mode="json"))
|
||||||
@@ -82,6 +82,7 @@ def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypat
|
|||||||
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
||||||
assert data["coverage"]["missing_device_ids"] == ["Q1"]
|
assert data["coverage"]["missing_device_ids"] == ["Q1"]
|
||||||
assert data["coverage"]["outside_frozen_scope_device_ids"] == ["NEW"]
|
assert data["coverage"]["outside_frozen_scope_device_ids"] == ["NEW"]
|
||||||
|
assert data["coverage"]["frozen_start"] == START.isoformat()
|
||||||
repository.detected_outputs.assert_awaited_once_with(
|
repository.detected_outputs.assert_awaited_once_with(
|
||||||
context["replay_id"], ["P1", "Q1"], START, START + timedelta(hours=1), 9, limit=1001, offset=0,
|
context["replay_id"], ["P1", "Q1"], START, START + timedelta(hours=1), 9, limit=1001, offset=0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
|
|||||||
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
|
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
|
||||||
)
|
)
|
||||||
assert report.diagram == progress.diagram
|
assert report.diagram == progress.diagram
|
||||||
assert [node.status for node in report.diagram.node_refs] == ["completed", "waiting", None, None]
|
assert [node.status for node in report.diagram.node_refs] == ["recorded", "needs_input", None, None]
|
||||||
assert "status" not in report.diagram.node_refs[-1].model_fields_set
|
assert "status" not in report.diagram.node_refs[-1].model_fields_set
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ def test_class_attached_nodes_are_valid_declarations_without_accepting_partial_i
|
|||||||
"node_refs": [{"node_id": "QUERY", "label": "核查", "status": "running"},
|
"node_refs": [{"node_id": "QUERY", "label": "核查", "status": "running"},
|
||||||
{"node_id": "REVIEW", "label": "复核", "status": "pending"}]},
|
{"node_id": "REVIEW", "label": "复核", "status": "pending"}]},
|
||||||
}}
|
}}
|
||||||
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "running"
|
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "reference"
|
||||||
payload["progress"]["diagram"]["node_refs"][0]["node_id"] = "QUER"
|
payload["progress"]["diagram"]["node_refs"][0]["node_id"] = "QUER"
|
||||||
with pytest.raises(ValidationError, match="QUER"):
|
with pytest.raises(ValidationError, match="QUER"):
|
||||||
AgentArtifactsCommand.model_validate(payload)
|
AgentArtifactsCommand.model_validate(payload)
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||||
|
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationGraphUpdate
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.services.investigation_graph import update_investigation_graph
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from tests.support.operational_task_repository import (
|
||||||
|
TaskRepository, _active_run, _context, _task_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_service():
|
||||||
|
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repo = TaskRepository(task, _active_run(task, now))
|
||||||
|
return task, repo, OperationalTaskService(repo, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish(service, repo, task_id, *, nodes=(), edges=(), **progress):
|
||||||
|
command = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||||
|
"run_id": repo.run["run_id"],
|
||||||
|
"progress": {"summary": "更新调查路线", "graph_update": {
|
||||||
|
"nodes": list(nodes), "edges": list(edges),
|
||||||
|
}, **progress},
|
||||||
|
})
|
||||||
|
return await service.publish_artifacts(task_id, command)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_failed_path_is_retained_when_a_new_route_is_added():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "read", "label": "读取全量 SCADA", "status": "recorded"},
|
||||||
|
{"node_id": "locate_1", "label": "第一次候选定位", "status": "reference"},
|
||||||
|
], edges=[{"source": "read", "target": "locate_1"}])
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "locate_1", "label": "第一次候选定位", "status": "failed", "detail": "计算超时"},
|
||||||
|
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "reference"},
|
||||||
|
], edges=[{"source": "locate_1", "target": "locate_2", "label": "调整后重试"}])
|
||||||
|
result = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "recorded"},
|
||||||
|
])
|
||||||
|
diagram = result.task.latest_progress.progress.diagram
|
||||||
|
assert [(n.node_id, n.status) for n in diagram.node_refs] == [
|
||||||
|
("read", "recorded"), ("locate_1", "failed"), ("locate_2", "recorded"),
|
||||||
|
]
|
||||||
|
assert diagram.node_refs[1].detail == "计算超时"
|
||||||
|
assert len(diagram.edges) == 2
|
||||||
|
assert "第一次候选定位(失败)" in diagram.mermaid
|
||||||
|
assert 'locate_1 -->|"调整后重试"| locate_2' in diagram.mermaid
|
||||||
|
# A text-only update cannot clear the accumulated graph.
|
||||||
|
summary = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||||
|
"run_id": repo.run["run_id"], "progress": {"summary": "等待人工", "diagram": None},
|
||||||
|
})
|
||||||
|
assert (await service.publish_artifacts(task["task_id"], summary)).task.latest_progress.progress.diagram == diagram
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_failed_attempt_cannot_be_relabelled_as_a_success():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "attempt_1", "label": "候选定位", "status": "failed", "detail": "超时"},
|
||||||
|
])
|
||||||
|
with pytest.raises(DomainError, match="new node"):
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "attempt_1", "label": "候选定位", "status": "recorded"},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_new_execution_round_continues_the_same_task_graph():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
first = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "first", "label": "首次查询", "status": "failed"},
|
||||||
|
])
|
||||||
|
archived = first.task.latest_progress.progress.diagram.model_dump(mode="json")
|
||||||
|
repo.task["execution_generation"] = 2
|
||||||
|
repo.task["execution_request_id"] = str(uuid4())
|
||||||
|
repo.run["status"] = "succeeded"
|
||||||
|
repo.run = _active_run(repo.task, datetime(2026, 11, 10, tzinfo=timezone.utc))
|
||||||
|
repo.runs[repo.run["run_id"]] = repo.run
|
||||||
|
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs[0].status == "failed"
|
||||||
|
result = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "second", "label": "补充信息后查询", "status": "recorded"},
|
||||||
|
], edges=[{"source": "first", "target": "second"}])
|
||||||
|
assert result.progress_revision == 1
|
||||||
|
assert result.task.latest_progress.execution_generation == 2
|
||||||
|
assert len(result.task.latest_progress.progress.diagram.node_refs) == 2
|
||||||
|
assert repo.progress[(task["task_id"], 1)]["progress"]["diagram"] == archived
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_reports_archive_the_host_graph_and_keep_older_versions_unchanged():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "failed", "label": "数据读取", "status": "failed", "detail": "接口超时"},
|
||||||
|
])
|
||||||
|
command = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||||
|
"run_id": repo.run["run_id"], "report": {
|
||||||
|
"title": "调查", "current_conclusion": "已有阶段判断", "conclusion_boundary": "限于现有资料",
|
||||||
|
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||||
|
"diagram": {"title": "重新生成的图", "mermaid": "flowchart TD\nNEW[新图]"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
first = await service.publish_artifacts(task["task_id"], command)
|
||||||
|
assert first.task.latest_report.report.diagram.node_refs[0].status == "failed"
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "retry", "label": "重新读取", "status": "recorded"},
|
||||||
|
], edges=[{"source": "failed", "target": "retry"}])
|
||||||
|
assert len((await service.report(task["task_id"], 1)).report.diagram.node_refs) == 1
|
||||||
|
assert len((await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs) == 2
|
||||||
|
# Idempotent replays do not append nodes or change the archived report.
|
||||||
|
assert (await service.publish_artifacts(task["task_id"], command)).report_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_graph_updates_validate_references_and_cannot_replace_a_managed_graph():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
with pytest.raises(DomainError, match="unknown evidence"):
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "unknown", "label": "未知依据", "evidence_refs": ["forged"]},
|
||||||
|
])
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "kept", "label": "实际步骤", "status": "recorded"},
|
||||||
|
])
|
||||||
|
with pytest.raises(DomainError, match="graph_update"):
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||||
|
"run_id": repo.run["run_id"], "progress": {
|
||||||
|
"summary": "重画", "diagram": {"title": "替换", "mermaid": "flowchart TD\nNEW[新图]"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_paths():
|
||||||
|
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [
|
||||||
|
{"node_id": "start", "label": '核查 "地点" <script> ``` %%{init: {}}'},
|
||||||
|
{"node_id": "left", "label": "监测", "status": "failed"},
|
||||||
|
{"node_id": "right", "label": "工单", "status": "reference"},
|
||||||
|
], "edges": [{"source": "start", "target": target} for target in ["left", "right"]],
|
||||||
|
}))
|
||||||
|
assert "<script>" not in graph.mermaid
|
||||||
|
assert "#lt;script#gt;" in graph.mermaid
|
||||||
|
assert "#quot;地点#quot;" in graph.mermaid
|
||||||
|
assert "```" not in graph.mermaid and "%%{" not in graph.mermaid
|
||||||
|
assert "监测(失败)" in graph.mermaid
|
||||||
|
updated = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "right", "label": "工单", "status": "recorded"}],
|
||||||
|
"edges": [{"source": "start", "target": "right"}],
|
||||||
|
}))
|
||||||
|
assert len(updated.edges) == 2
|
||||||
|
assert graph.node_refs[2].status == "reference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
|
||||||
|
original = InvestigationDiagram.model_validate({
|
||||||
|
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
|
||||||
|
"node_refs": [{"node_id": "B", "label": "原路径", "status": "failed", "detail": "查询超时"}],
|
||||||
|
})
|
||||||
|
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "reference"}],
|
||||||
|
"edges": [{"source": "B", "target": "retry"}],
|
||||||
|
}))
|
||||||
|
assert updated.mermaid.startswith(original.mermaid + "\n")
|
||||||
|
assert updated.source_mermaid == original.mermaid
|
||||||
|
assert updated.node_refs[0].status == "failed"
|
||||||
|
final = update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "recorded"}],
|
||||||
|
}))
|
||||||
|
assert final.mermaid.count("A{核查条件}") == 1
|
||||||
|
assert "B --> retry" in final.mermaid
|
||||||
|
with pytest.raises(DomainError, match="original Mermaid"):
|
||||||
|
update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "C", "label": "与旧图冲突的新步骤"}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_first_update_uses_the_last_saved_legacy_report_graph():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
repo.reports.append({
|
||||||
|
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
||||||
|
"published_at": "2026-11-09T00:00:00Z", "published_by": "agent", "report": {
|
||||||
|
"title": "旧报告", "current_conclusion": "已有判断", "conclusion_boundary": "已有证据",
|
||||||
|
"diagram": {"title": "旧路线", "mermaid": "flowchart TD\nold[此前查询]",
|
||||||
|
"node_refs": [{"node_id": "old", "label": "此前查询", "status": "failed"}]},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
result = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "retry", "label": "接续查询", "status": "reference"},
|
||||||
|
], edges=[{"source": "old", "target": "retry"}])
|
||||||
|
diagram = result.task.latest_progress.progress.diagram
|
||||||
|
assert diagram.source_mermaid == "flowchart TD\nold[此前查询]"
|
||||||
|
assert [n.node_id for n in diagram.node_refs] == ["old", "retry"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("update,code", [
|
||||||
|
({"nodes": []}, "GRAPH_NODE_REQUIRED"),
|
||||||
|
({"nodes": [{"node_id": "end", "label": "保留字"}]}, "GRAPH_NODE_ID_INVALID"),
|
||||||
|
({"nodes": [{"node_id": "n", "label": "一"}, {"node_id": "n", "label": "二"}]}, "GRAPH_NODE_DUPLICATE"),
|
||||||
|
({"nodes": [{"node_id": "n", "label": "一"}], "edges": [{"source": "n", "target": "missing"}]}, "GRAPH_EDGE_NODE_NOT_FOUND"),
|
||||||
|
])
|
||||||
|
def test_graph_updates_reject_invalid_structure_without_erasing_history(update, code):
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
update_investigation_graph(None, InvestigationGraphUpdate.model_validate(update))
|
||||||
|
assert error.value.code == code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("node_refs", [[], [{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "查询超时"}]])
|
||||||
|
async def test_summary_only_adopts_legacy_graph_before_the_first_incremental_update(node_refs):
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
original = {"title": "旧路线", "mermaid": "flowchart LR\nold{旧尝试} --> other[另一分支]", "node_refs": node_refs}
|
||||||
|
for progress in [{"summary": "此前调查", "diagram": original}, {"summary": "继续调查"}]:
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"], progress=progress,
|
||||||
|
))
|
||||||
|
diagram = result.task.latest_progress.progress.diagram
|
||||||
|
assert diagram is not None
|
||||||
|
assert diagram.mermaid == diagram.source_mermaid == original["mermaid"]
|
||||||
|
assert diagram.edges == []
|
||||||
|
assert diagram.node_refs == InvestigationDiagram.model_validate(original).node_refs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_identical_effective_reports_do_not_repeat_notifications_for_inherited_graph_evidence():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="probe", queried_at=service._now(),
|
||||||
|
origin="system_data", truncated=False, data={"data": []},
|
||||||
|
))
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[{
|
||||||
|
"node_id": "finding", "label": "已有结果", "status": "recorded", "evidence_refs": [evidence.evidence_id],
|
||||||
|
}])
|
||||||
|
saved = []
|
||||||
|
for _ in range(2):
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={
|
||||||
|
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
|
||||||
|
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
saved.append(result.task.latest_report.report)
|
||||||
|
assert saved[0] == saved[1]
|
||||||
|
assert saved[0].evidence_refs == [evidence.evidence_id]
|
||||||
|
assert len([notice for notice in repo.notifications if notice["kind"] == "report_updated"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_final_scada_report_validates_the_effective_inherited_evidence():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
task["workflow"] = "scada_anomaly"
|
||||||
|
now = service._now()
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="complete-scada", queried_at=now,
|
||||||
|
origin="system_data", truncated=False, data={
|
||||||
|
"data": {"coverage": {"mode": "all_scada", "query_complete": True}},
|
||||||
|
"window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
await publish(service, repo, task["task_id"], nodes=[{
|
||||||
|
"node_id": "all_scada", "label": "全量读取", "evidence_refs": [evidence.evidence_id],
|
||||||
|
}])
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={
|
||||||
|
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
|
||||||
|
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
assert result.task.latest_report.report.evidence_refs == [evidence.evidence_id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_evidence_only_publication_does_not_load_the_graph_baseline(monkeypatch):
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
calls = []
|
||||||
|
original = repo.get_latest_graph_progress
|
||||||
|
|
||||||
|
async def load_graph(task_id):
|
||||||
|
calls.append(task_id)
|
||||||
|
return await original(task_id)
|
||||||
|
|
||||||
|
# The returned task detail can read progress; count separately from publication.
|
||||||
|
original_detail = service.detail
|
||||||
|
|
||||||
|
async def detail(task_id):
|
||||||
|
before_detail = len(calls)
|
||||||
|
result = await original_detail(task_id)
|
||||||
|
del calls[before_detail:]
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(repo, "get_latest_graph_progress", load_graph)
|
||||||
|
monkeypatch.setattr(service, "detail", detail)
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", evidence=[{
|
||||||
|
"evidence_id": "demo", "kind": "synthetic", "summary": "演练依据",
|
||||||
|
"source_label": "fixture", "observed_at": service._now(),
|
||||||
|
}],
|
||||||
|
))
|
||||||
|
assert calls == []
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.migrate_investigation_graphs import prepare_progress_document
|
||||||
|
from tests.support.operational_task_repository import _active_run, _context, _task_document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("node_refs", [[], [
|
||||||
|
{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "原查询超时", "evidence_refs": ["e1"]},
|
||||||
|
{"node_id": "next", "label": "后续核查", "status": "running"},
|
||||||
|
]])
|
||||||
|
def test_migration_keeps_source_paths_failures_and_original_publication_provenance(node_refs):
|
||||||
|
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
|
||||||
|
task = _task_document(_context(), now)
|
||||||
|
run = _active_run(task, now)
|
||||||
|
document = {
|
||||||
|
"revision": 7, "run_id": run["run_id"], "execution_generation": 1,
|
||||||
|
"source_session_id": "session-1", "published_at": now.isoformat(),
|
||||||
|
"progress": {"summary": "原调查说明", "diagram": {"title": "旧图", "node_refs": node_refs,
|
||||||
|
"mermaid": "flowchart LR\nold{旧尝试} -->|失败后重试| next[后续核查]"}},
|
||||||
|
}
|
||||||
|
original = deepcopy(document)
|
||||||
|
migrated = prepare_progress_document(document)
|
||||||
|
assert document == original
|
||||||
|
assert migrated["revision"] == 8
|
||||||
|
assert {key: migrated[key] for key in original if key not in {"revision", "progress"}} == {
|
||||||
|
key: original[key] for key in original if key not in {"revision", "progress"}
|
||||||
|
}
|
||||||
|
assert migrated["progress"]["summary"] == original["progress"]["summary"]
|
||||||
|
graph = migrated["progress"]["diagram"]
|
||||||
|
assert graph["mermaid"] == graph["source_mermaid"] == original["progress"]["diagram"]["mermaid"]
|
||||||
|
assert graph["edges"] == []
|
||||||
|
if node_refs:
|
||||||
|
assert graph["node_refs"][0]["detail"] == "原查询超时"
|
||||||
|
assert graph["node_refs"][0]["evidence_refs"] == ["e1"]
|
||||||
|
assert [node["status"] for node in graph["node_refs"]] == ["failed", "reference"]
|
||||||
|
assert prepare_progress_document(migrated) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_does_not_invent_a_graph_when_none_was_saved():
|
||||||
|
document = {"progress": {"summary": "仅说明", "diagram": None}}
|
||||||
|
assert prepare_progress_document(document) is None
|
||||||
|
assert document == {"progress": {"summary": "仅说明", "diagram": None}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.domain.schemas.operational_task import InvestigationDiagramNodeRef, InvestigationGraphUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["reference", "recorded", "needs_input", "failed", "not_taken"])
|
||||||
|
def test_graph_updates_only_describe_reference_steps_and_recorded_outcomes(status):
|
||||||
|
update = InvestigationGraphUpdate.model_validate({"nodes": [{"node_id": "check", "label": "核查", "status": status}]})
|
||||||
|
assert update.nodes[0].status == status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("legacy,expected", [
|
||||||
|
("pending", "reference"), ("running", "reference"), ("completed", "recorded"),
|
||||||
|
("waiting", "needs_input"), ("waiting_human", "needs_input"),
|
||||||
|
("skipped", "not_taken"), ("cancelled", "not_taken"),
|
||||||
|
])
|
||||||
|
def test_old_diagrams_are_read_as_reference_annotations_without_changing_stored_documents(legacy, expected):
|
||||||
|
document = {"node_id": "check", "label": "核查", "status": legacy}
|
||||||
|
assert InvestigationDiagramNodeRef.model_validate(document).status == expected
|
||||||
|
assert document["status"] == legacy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["running", "pending", "completed", "waiting", "waiting_human", "skipped", "cancelled"])
|
||||||
|
def test_new_incremental_writes_reject_execution_lifecycle_states(status):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
InvestigationGraphUpdate.model_validate({"nodes": [{"node_id": "check", "label": "核查", "status": status}]})
|
||||||
@@ -1116,6 +1116,31 @@ def test_time_series_sql_is_bounded_and_field_names_cannot_inject_sql():
|
|||||||
asyncio.run(check())
|
asyncio.run(check())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["monitored_value", "cleaned_value"])
|
||||||
|
def test_scada_detail_preserves_the_requested_field_with_its_value_alias(monkeypatch, field):
|
||||||
|
at = datetime.now(timezone.utc)
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda ctx: PERMISSIONS)
|
||||||
|
monkeypatch.setattr(query_service.reads, "series_rows", AsyncMock(return_value=[{
|
||||||
|
"device_id": "D1", "time": at, "value": 32.0,
|
||||||
|
}]))
|
||||||
|
monkeypatch.setattr(query_service.reads, "scada_devices", AsyncMock(return_value=[{
|
||||||
|
"device_id": "D1", "measurement_unit": "m",
|
||||||
|
}]))
|
||||||
|
|
||||||
|
class Scope:
|
||||||
|
project = object()
|
||||||
|
connection = Connection()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def timescale(self):
|
||||||
|
yield self.connection
|
||||||
|
|
||||||
|
args = ScadaInput(context_handle="a" * 43, device_ids=["D1"], field=field,
|
||||||
|
start=at - timedelta(hours=3), end=at)
|
||||||
|
result = asyncio.run(query_service.execute_query(Scope(), binding(), TOOLS["query_scada_readings"], args))
|
||||||
|
assert result.data[0][field] == result.data[0]["value"] == 32.0
|
||||||
|
|
||||||
|
|
||||||
def test_analysis_preserves_run_units_and_reports_inference(monkeypatch):
|
def test_analysis_preserves_run_units_and_reports_inference(monkeypatch):
|
||||||
from app.mcp.models import AnalysisValuesInput
|
from app.mcp.models import AnalysisValuesInput
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -98,3 +98,57 @@ async def test_presentation_history_storage_filters_project_task_and_committed_c
|
|||||||
assert "project_id=%s" in query and "aggregate_id=%s" in query
|
assert "project_id=%s" in query and "aggregate_id=%s" in query
|
||||||
assert "operational.presentation.requested" in query and "event_seq<=%s" in query
|
assert "operational.presentation.requested" in query and "event_seq<=%s" in query
|
||||||
assert "ORDER BY event_seq LIMIT %s" in query
|
assert "ORDER BY event_seq LIMIT %s" in query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_scada_manifest_migration_fails_closed():
|
||||||
|
connection = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(
|
||||||
|
fetchone=AsyncMock(return_value={"available": False}),
|
||||||
|
)))
|
||||||
|
repository = OperationalTaskRepository(connection, uuid4())
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await repository.get_scada_presentation(str(uuid4()), str(uuid4()))
|
||||||
|
assert failure.value.code == "DEPENDENCY_UNAVAILABLE"
|
||||||
|
assert failure.value.status == 503
|
||||||
|
assert "035_scada_presentation_coverage.sql" in failure.value.message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_scada_manifest_is_scoped_to_project_task_and_run_and_preserves_other_run_facts():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
project, task, run = map(str, (uuid4(), uuid4(), uuid4()))
|
||||||
|
document = {"version": 1, "targets": ["524529"], "charts": {}}
|
||||||
|
connection = SimpleNamespace(execute=AsyncMock(side_effect=[
|
||||||
|
SimpleNamespace(fetchone=AsyncMock(return_value={"available": True})),
|
||||||
|
SimpleNamespace(fetchone=AsyncMock(return_value={"document": document})),
|
||||||
|
SimpleNamespace(rowcount=1),
|
||||||
|
]))
|
||||||
|
repository = OperationalTaskRepository(connection, project)
|
||||||
|
assert await repository.get_scada_presentation(task, run) == document
|
||||||
|
query, params = connection.execute.call_args.args
|
||||||
|
assert "artifact_manifest->'scada_presentation'" in query
|
||||||
|
assert params == (project, task, run)
|
||||||
|
await repository.save_scada_presentation(task, run, document, datetime.now(timezone.utc))
|
||||||
|
query, params = connection.execute.call_args.args
|
||||||
|
assert "jsonb_set(artifact_manifest,'{scada_presentation}'" in query
|
||||||
|
assert params[1:] == (project, task, run)
|
||||||
|
assert "revision=" not in query and "status=" not in query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_internal_manifest_does_not_change_public_run_schemas_after_migration():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskRun
|
||||||
|
from tests.support.operational_task_repository import _active_run, _context, _task_document
|
||||||
|
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, datetime.now(timezone.utc))
|
||||||
|
run = _active_run(task, datetime.now(timezone.utc))
|
||||||
|
connection = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(fetchall=AsyncMock(return_value=[{
|
||||||
|
**run, "project_id": context.project_id, "artifact_manifest": {"scada_presentation": {"targets": ["524529"]}},
|
||||||
|
}]))))
|
||||||
|
repository = OperationalTaskRepository(connection, context.project_id)
|
||||||
|
public = (await repository.list_task_runs(task["task_id"], 10, 0))[0]
|
||||||
|
assert "artifact_manifest" not in public
|
||||||
|
assert OperationalTaskRun.model_validate(public).run_id == OperationalTaskRun.model_validate(run).run_id
|
||||||
|
|||||||
@@ -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,208 +228,12 @@ 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(
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -457,7 +257,7 @@ async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_sc
|
|||||||
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)
|
execution_scope=worker_scope)
|
||||||
|
|
||||||
@@ -488,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"],
|
||||||
@@ -514,12 +314,13 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
|||||||
updated = await service.publish_artifacts(task["task_id"], replacement)
|
updated = await service.publish_artifacts(task["task_id"], replacement)
|
||||||
assert updated.progress_revision == 2
|
assert updated.progress_revision == 2
|
||||||
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
|
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
|
||||||
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
retained = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
|
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
|
||||||
progress={"summary": "当前不需要图示。", "diagram": None},
|
progress={"summary": "补充本轮说明。", "diagram": None},
|
||||||
))
|
))
|
||||||
assert cleared.progress_revision == 3
|
assert retained.progress_revision == 3
|
||||||
assert cleared.task.latest_progress.progress.diagram is None
|
assert retained.task.latest_progress.progress.diagram.mermaid == updated.task.latest_progress.progress.diagram.mermaid
|
||||||
|
assert retained.task.latest_progress.progress.diagram.edges == []
|
||||||
assert not repository.notifications
|
assert not repository.notifications
|
||||||
|
|
||||||
|
|
||||||
@@ -535,7 +336,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"}
|
||||||
@@ -556,7 +357,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(
|
||||||
@@ -582,7 +383,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"]
|
||||||
@@ -608,7 +409,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,
|
||||||
@@ -645,7 +446,7 @@ async def test_comparison_chart_publication_is_validated_at_both_write_boundarie
|
|||||||
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)
|
||||||
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:comparison", queried_at=now,
|
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
||||||
@@ -715,7 +516,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())
|
||||||
@@ -743,7 +544,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,
|
||||||
@@ -762,7 +563,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"]},
|
||||||
@@ -795,7 +596,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,
|
||||||
@@ -844,7 +645,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(
|
||||||
@@ -891,7 +692,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(
|
||||||
@@ -923,7 +724,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="新投诉信息")
|
||||||
@@ -940,7 +741,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(
|
||||||
@@ -962,7 +763,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:
|
||||||
@@ -982,7 +783,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(
|
||||||
@@ -1014,7 +815,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(
|
||||||
@@ -1069,7 +870,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,
|
||||||
@@ -1124,7 +925,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",
|
||||||
@@ -1179,7 +980,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": "关键数据不足"}
|
||||||
@@ -1229,7 +1030,7 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
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())
|
||||||
@@ -1308,8 +1109,10 @@ 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(
|
||||||
@@ -1334,7 +1137,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(
|
||||||
@@ -1369,7 +1172,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"
|
||||||
@@ -1410,7 +1213,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
|
||||||
@@ -1418,7 +1225,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(),
|
||||||
@@ -1441,7 +1248,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")
|
||||||
@@ -1463,7 +1270,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(
|
||||||
@@ -1499,7 +1306,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"}]}
|
||||||
@@ -1512,7 +1319,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
|
|||||||
)
|
)
|
||||||
await service.publish_artifacts(task["task_id"], command)
|
await service.publish_artifacts(task["task_id"], command)
|
||||||
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
|
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
|
||||||
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
|
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "reference"
|
||||||
assert command.report.diagram is None # Do not change the idempotency request.
|
assert command.report.diagram is None # Do not change the idempotency request.
|
||||||
repository.task["execution_generation"] = 2
|
repository.task["execution_generation"] = 2
|
||||||
detail = await service.detail(task["task_id"])
|
detail = await service.detail(task["task_id"])
|
||||||
@@ -1526,7 +1333,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",
|
||||||
@@ -1559,7 +1366,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(
|
||||||
@@ -1575,7 +1382,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(),
|
||||||
@@ -1624,7 +1431,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,
|
||||||
@@ -1673,7 +1480,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,
|
||||||
@@ -1737,7 +1544,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",
|
||||||
@@ -1771,7 +1578,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",
|
||||||
@@ -1794,7 +1601,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)
|
||||||
|
|
||||||
@@ -1813,7 +1620,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",
|
||||||
@@ -1850,7 +1657,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(
|
||||||
@@ -1881,7 +1688,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)
|
||||||
@@ -2125,7 +1932,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)
|
||||||
|
|
||||||
@@ -2253,7 +2060,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(
|
||||||
@@ -2290,7 +2097,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(
|
||||||
@@ -2322,7 +2129,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)
|
||||||
@@ -2346,7 +2153,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')
|
||||||
@@ -2380,7 +2187,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(
|
||||||
@@ -2487,3 +2294,59 @@ def test_task_scope_is_required_in_persisted_contract():
|
|||||||
OperationalTaskSummary.model_validate(task)
|
OperationalTaskSummary.model_validate(task)
|
||||||
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
|
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
|
||||||
for item in error.value.errors())
|
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
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""A field-work wait ends the run without closing or restarting the incident."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionExecuteCommand, ActionReconcileCommand, ActionReceipt, AgentRunCommand,
|
||||||
|
ExternalFeedbackCommand, HumanApprovalAction,
|
||||||
|
)
|
||||||
|
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
||||||
|
from app.services.operational_recovery import track_work_order
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
|
||||||
|
from tests.unit.test_operational_tasks import _seed_concluded_report
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_wait(*, active=True, phase="waiting_work_order"):
|
||||||
|
now = datetime(2026, 11, 12, 5, 14, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task.update(workflow="scada_anomaly", lifecycle_status="open")
|
||||||
|
run = _active_run(task, now)
|
||||||
|
if not active:
|
||||||
|
run.update(status="succeeded", lease_expires_at=None, finished_at=now.isoformat())
|
||||||
|
task.update(status="waiting_human", lifecycle_status="waiting_human",
|
||||||
|
agent_session_id=None, agent_lease_expires_at=None)
|
||||||
|
order_id = str(uuid4())
|
||||||
|
action = HumanApprovalAction(
|
||||||
|
action_id=uuid4(), task_id=task["task_id"], version=3, kind="create_work_order",
|
||||||
|
reason="现场检查", payload={"title": "检查", "description": "核验候选点", "work_order_type": "general"},
|
||||||
|
evidence_refs=[], required_approval_permission="work-order.approve",
|
||||||
|
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
|
approval_status="approved", execution_status="succeeded",
|
||||||
|
proposed_by="principal:test", proposed_at=now,
|
||||||
|
receipt=ActionReceipt(adapter="peripheral_sample", receipt_id=order_id,
|
||||||
|
submitted_at=now, completed_at=now, external_status="open", detail="创建成功"),
|
||||||
|
)
|
||||||
|
repository = TaskRepository(task, run, action=action.model_dump(mode="json"))
|
||||||
|
track_work_order(task, repository.action)
|
||||||
|
task["handling_context"]["phase"] = phase
|
||||||
|
if phase == "waiting_scada":
|
||||||
|
task["handling_context"]["returned_at"] = now.isoformat()
|
||||||
|
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
|
||||||
|
return service, repository, now, action
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||||
|
@pytest.mark.parametrize("active", [True, False])
|
||||||
|
async def test_successful_dispatch_preserves_run_and_parks_only_idle_task(method, active):
|
||||||
|
service, repository, now, action = setup_wait(active=active)
|
||||||
|
repository.task["handling_context"] = {}
|
||||||
|
_seed_concluded_report(repository, now)
|
||||||
|
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||||
|
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": AsyncMock(
|
||||||
|
return_value=ActionDispatchOutcome("succeeded", "创建成功", action.receipt))})
|
||||||
|
run_before = repository.run.copy()
|
||||||
|
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||||
|
command = command_type(request_id=uuid4(), expected_version=action.version)
|
||||||
|
await getattr(service, f"{method}_action")(action.task_id, action.action_id, command, dispatcher, None)
|
||||||
|
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
|
||||||
|
assert repository.task["execution_generation"] == 1
|
||||||
|
assert repository.run == run_before
|
||||||
|
assert repository.task["status"] == ("running" if active else "waiting_human")
|
||||||
|
if active:
|
||||||
|
await service.heartbeat_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
else:
|
||||||
|
assert await repository.get_active_run(str(action.task_id)) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||||
|
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "unsupported"])
|
||||||
|
async def test_late_dispatch_receipt_is_saved_without_reviving_cancelled_parent(method, outcome):
|
||||||
|
service, repository, now, action = setup_wait()
|
||||||
|
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||||
|
async def cancelled_while_dispatching(**kwargs):
|
||||||
|
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||||
|
repository.run.update(status="cancelled", lease_expires_at=None)
|
||||||
|
return ActionDispatchOutcome(outcome, "外部回执", action.receipt if outcome == "succeeded" else None)
|
||||||
|
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": cancelled_while_dispatching})
|
||||||
|
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||||
|
result = await getattr(service, f"{method}_action")(action.task_id, action.action_id,
|
||||||
|
command_type(request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||||
|
assert result.execution_status == outcome
|
||||||
|
assert repository.task["status"] == "cancelled"
|
||||||
|
assert repository.task["execution_generation"] == 1
|
||||||
|
assert await repository.get_active_run(str(action.task_id)) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("phase", ["waiting_work_order", "waiting_scada", "pending_approval"])
|
||||||
|
async def test_current_interim_report_finishes_run_as_waiting_without_closing_incident(phase):
|
||||||
|
service, repository, now, action = setup_wait()
|
||||||
|
if phase == "pending_approval":
|
||||||
|
repository.task["handling_context"] = {}
|
||||||
|
repository.action.update(approval_status="pending", execution_status="not_submitted", receipt=None)
|
||||||
|
else:
|
||||||
|
repository.task["handling_context"]["phase"] = phase
|
||||||
|
_seed_concluded_report(repository, now)
|
||||||
|
repository.reports[-1]["report"]["stage"] = "interim"
|
||||||
|
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
assert result.status == result.lifecycle_status == "waiting_human"
|
||||||
|
assert repository.run["status"] == "succeeded"
|
||||||
|
assert repository.run["lease_expires_at"] is None
|
||||||
|
assert result.active_run is None
|
||||||
|
assert result.execution_generation == 1
|
||||||
|
assert not any(n["kind"] == "task_failed" for n in repository.notifications)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("defect", ["missing", "old_generation", "other_session", "verification_ready"])
|
||||||
|
async def test_wait_does_not_bypass_current_report_or_final_recovery_requirements(defect):
|
||||||
|
service, repository, now, action = setup_wait()
|
||||||
|
if defect != "missing":
|
||||||
|
_seed_concluded_report(repository, now)
|
||||||
|
repository.reports[-1]["report"]["stage"] = "interim"
|
||||||
|
if defect == "old_generation":
|
||||||
|
repository.task["execution_generation"] = repository.run["generation"] = 2
|
||||||
|
repository.reports[-1]["execution_generation"] = 1
|
||||||
|
elif defect == "other_session":
|
||||||
|
repository.reports[-1]["source_session_id"] = "another-session"
|
||||||
|
else:
|
||||||
|
repository.task["handling_context"]["phase"] = "verification_ready"
|
||||||
|
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("status", ["assigned", "in_progress", "awaiting_feedback", "completed"])
|
||||||
|
@pytest.mark.parametrize("active", [True, False])
|
||||||
|
async def test_ordinary_child_feedback_waits_without_starting_or_cancelling_run(status, active):
|
||||||
|
service, repository, now, action = setup_wait(active=active)
|
||||||
|
if not active:
|
||||||
|
# Recover the persisted shape left by the reported ARTIFACT_INCOMPLETE failure.
|
||||||
|
repository.task.update(status="failed", lifecycle_status="failed")
|
||||||
|
repository.run.update(status="failed", failure_code="ARTIFACT_INCOMPLETE")
|
||||||
|
run_before = repository.run.copy()
|
||||||
|
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||||
|
source_revision=2, status=status, occurred_at=now, detail="工单进度更新"))
|
||||||
|
assert result.task.execution_generation == 1
|
||||||
|
assert repository.run == run_before
|
||||||
|
assert result.task.status == ("running" if active else "waiting_human")
|
||||||
|
assert result.task.handling_context["phase"] == ("waiting_scada" if status == "completed" else "waiting_work_order")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("status", ["failed", "cancelled"])
|
||||||
|
async def test_failed_or_cancelled_child_wakes_same_task_for_replanning(status):
|
||||||
|
service, repository, now, action = setup_wait(active=False)
|
||||||
|
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||||
|
source_revision=2, status=status, occurred_at=now, detail="现场处置无法继续"))
|
||||||
|
assert str(result.task.task_id) == str(action.task_id)
|
||||||
|
assert result.task.status == "queued" and result.task.execution_generation == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("phase", ["verification_ready", "completed"])
|
||||||
|
async def test_revised_receipt_with_ready_recovery_evidence_does_not_strand_task(phase):
|
||||||
|
service, repository, now, action = setup_wait(active=False, phase="waiting_scada")
|
||||||
|
repository.task["handling_context"].update(phase=phase, verification_evidence_id="recovery:saved")
|
||||||
|
if phase == "completed":
|
||||||
|
repository.task.update(status="completed", lifecycle_status="completed")
|
||||||
|
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||||
|
source_revision=3, status="completed", occurred_at=now, detail="补充完工说明,实际完工时间不变"))
|
||||||
|
assert result.task.status == ("completed" if phase == "completed" else "queued")
|
||||||
|
assert result.task.execution_generation == (1 if phase == "completed" else 2)
|
||||||
|
assert result.task.handling_context["verification_evidence_id"] == "recovery:saved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_completion_time_revision_does_not_interrupt_active_recovery_review():
|
||||||
|
service, repository, now, action = setup_wait(phase="waiting_scada")
|
||||||
|
repository.task["handling_context"].update(phase="verification_ready", verification_evidence_id="recovery:saved")
|
||||||
|
run_before = repository.run.copy()
|
||||||
|
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||||
|
source_revision=3, status="completed", occurred_at=now, detail="补充备注,完工时间不变"))
|
||||||
|
assert result.task.status == "running" and result.task.execution_generation == 1
|
||||||
|
assert repository.run == run_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("new_decision", [True, False])
|
||||||
|
@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
|
||||||
|
async def test_only_new_dispatch_decisions_replace_terminated_work_orders(new_decision, terminal_status):
|
||||||
|
service, repository, now, action = setup_wait(active=False)
|
||||||
|
old_id = action.receipt.receipt_id
|
||||||
|
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=2,
|
||||||
|
status=terminal_status, occurred_at=now - timedelta(hours=1), detail="需重新安排"))
|
||||||
|
replacement_id = str(uuid4())
|
||||||
|
new_action = action.model_dump(mode="json")
|
||||||
|
new_action.update(action_id=str(uuid4()), proposed_at=(now + timedelta(seconds=1 if new_decision else -1)).isoformat())
|
||||||
|
new_action["receipt"]["receipt_id"] = replacement_id
|
||||||
|
track_work_order(repository.task, new_action)
|
||||||
|
repository.action = new_action
|
||||||
|
# Finish the replanning run before the replacement's completion arrives.
|
||||||
|
repository.run.update(status="succeeded", lease_expires_at=None)
|
||||||
|
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{replacement_id}", source_revision=2,
|
||||||
|
status="completed", occurred_at=now + timedelta(hours=1), detail="替代处置完成"))
|
||||||
|
phase = repository.task["handling_context"]["phase"]
|
||||||
|
assert phase == ("waiting_scada" if new_decision else "waiting_work_order")
|
||||||
|
if new_decision:
|
||||||
|
orders = repository.task["handling_context"]["work_orders"]
|
||||||
|
assert orders[old_id]["superseded_by"] == replacement_id
|
||||||
|
# A late historical edit cannot interrupt the replacement or change T.
|
||||||
|
context_before = {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"}
|
||||||
|
generation = repository.task["execution_generation"]
|
||||||
|
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=3,
|
||||||
|
status=terminal_status, occurred_at=now, detail="旧工单备注更新"))
|
||||||
|
assert repository.task["execution_generation"] == generation
|
||||||
|
assert {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"} == context_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_superseded_run_heartbeat_has_distinct_problem_code():
|
||||||
|
service, repository, now, action = setup_wait()
|
||||||
|
old_run = repository.run.copy()
|
||||||
|
await service._requeue_task(repository.task, now)
|
||||||
|
with pytest.raises(DomainError) as rejected:
|
||||||
|
await service.heartbeat_run(action.task_id, old_run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=old_run["revision"], source_session_id="session-1"))
|
||||||
|
assert rejected.value.code == "RUN_SUPERSEDED"
|
||||||
@@ -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,12 +226,13 @@ 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"),
|
||||||
[
|
[
|
||||||
("waiting_human", 2, True),
|
("waiting_human", 2, True),
|
||||||
("queued", 2, True),
|
("queued", 2, True),
|
||||||
|
("failed", 2, True),
|
||||||
("completed", 2, True),
|
("completed", 2, True),
|
||||||
("cancelled", 1, False),
|
("cancelled", 1, False),
|
||||||
],
|
],
|
||||||
@@ -254,9 +255,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):
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""SCADA display coverage is independent of whether a device supports a conclusion."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from copy import deepcopy
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.domain.schemas.operational_task import AgentArtifactsCommand, AgentRunCommand, TaskPresentationEventCommand
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
|
||||||
|
|
||||||
|
|
||||||
|
DEVICES = ["486033", "542017", "541061", "524529"]
|
||||||
|
END = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
||||||
|
START = END - timedelta(hours=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_value_only_detail_evidence_cannot_claim_missing_observations():
|
||||||
|
from app.services.scada_presentation_coverage import verify_chart
|
||||||
|
task, repository, _, evidence_id = await investigation()
|
||||||
|
detail = deepcopy(repository.evidence[evidence_id])
|
||||||
|
detail["evidence_id"] = "detail-values"
|
||||||
|
rows = detail["data_snapshot"]["data"]["data"]["readings"]
|
||||||
|
detail["data_snapshot"]["data"]["data"] = [
|
||||||
|
{"device_id": row["device_id"], "time": row["time"], "value": row["monitored_value"]}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
value = chart(devices=["524529"])
|
||||||
|
value["series"][0].update(data=[None] * 13, sample_times=[None] * 13)
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
verify_chart(task, value, [repository.evidence[evidence_id], detail], ["detail-values"])
|
||||||
|
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("field", ["monitored_value", "cleaned_value"])
|
||||||
|
async def test_detail_fields_do_not_discard_other_observed_fields(field):
|
||||||
|
from app.services.scada_presentation_coverage import verify_chart
|
||||||
|
task, repository, _, evidence_id = await investigation()
|
||||||
|
detail = deepcopy(repository.evidence[evidence_id])
|
||||||
|
detail["evidence_id"] = "detail-cleaned"
|
||||||
|
rows = detail["data_snapshot"]["data"]["data"]["readings"]
|
||||||
|
detail["data_snapshot"]["data"]["data"] = [
|
||||||
|
{"device_id": row["device_id"], "time": row["time"], "value": row["cleaned_value"] + 10,
|
||||||
|
"cleaned_value": row["cleaned_value"] + 10} for row in rows
|
||||||
|
]
|
||||||
|
value = chart(devices=["524529"])
|
||||||
|
value["scada"]["value_field"] = field
|
||||||
|
if field == "cleaned_value":
|
||||||
|
value["series"][0]["data"] = [point + 10 for point in value["series"][0]["data"]]
|
||||||
|
result = verify_chart(task, value, [repository.evidence[evidence_id], detail], [evidence_id, "detail-cleaned"])
|
||||||
|
assert result["gaps"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
async def investigation():
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, END, lease=END + timedelta(minutes=1))
|
||||||
|
task["workflow"] = "scada_anomaly"
|
||||||
|
repository = TaskRepository(task, _active_run(task, END))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: END)
|
||||||
|
readings = []
|
||||||
|
for day in range(8):
|
||||||
|
slots = range(13) if day == 0 else range(10, 13)
|
||||||
|
for slot in slots:
|
||||||
|
for device in DEVICES:
|
||||||
|
readings.append({"device_id": device, "time": (START + timedelta(minutes=15 * slot, days=-day)).isoformat(),
|
||||||
|
"monitored_value": float(slot + 1), "cleaned_value": float(slot + 1)})
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="mcp:observations", queried_at=END,
|
||||||
|
origin="system_data", truncated=False, data={
|
||||||
|
"window": {"start": (START - timedelta(days=7)).isoformat(), "end": END.isoformat()},
|
||||||
|
"data": {"devices": [{"device_id": device, "device_type": "pipe_flow", "measurement_unit": "m3/h",
|
||||||
|
"transmission_frequency": "0:15:00"} for device in DEVICES],
|
||||||
|
"readings": readings, "coverage": {"mode": "all_scada", "query_complete": True}},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return task, repository, service, evidence.evidence_id
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(comparison_devices):
|
||||||
|
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
|
||||||
|
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
|
||||||
|
return {"version": 1, "targets": DEVICES.copy(), "charts": {
|
||||||
|
"overview": {**common, "role": "current", "device_ids": DEVICES.copy()},
|
||||||
|
**{f"comparison-{device}": {**common, "role": "comparison", "device_ids": [device],
|
||||||
|
"coverage": {device: {"covered": slots}}}
|
||||||
|
for device in comparison_devices},
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
def final_report(evidence_id):
|
||||||
|
return {"stage": "final", "outcome": "concluded", "title": "调查结果",
|
||||||
|
"current_conclusion": "524529 仅作参考", "conclusion_boundary": "待现场核验",
|
||||||
|
"evidence_refs": [evidence_id], "data_gaps": [], "scada_device_ids": DEVICES}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_final_rejects_four_target_devices_with_three_comparisons():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES[:3])}
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
|
||||||
|
))
|
||||||
|
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
assert "524529" in failure.value.message
|
||||||
|
assert not repository.reports
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_final_accepts_all_four_devices():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES)}
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
|
||||||
|
))
|
||||||
|
assert result.report_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
def chart(role="current", devices=None):
|
||||||
|
devices = DEVICES if devices is None else devices
|
||||||
|
comparison = role == "comparison"
|
||||||
|
series = []
|
||||||
|
for device in devices:
|
||||||
|
for day in (range(8) if comparison else [0]):
|
||||||
|
slots = range(13) if day == 0 else range(10, 13)
|
||||||
|
series.append({"name": f"{device} {day}", "device_id": device, "day_offset": day,
|
||||||
|
"type": "scatter" if day else "line",
|
||||||
|
"data": [float(index + 1) if index in slots else None for index in range(13)],
|
||||||
|
"sample_times": [(START + timedelta(minutes=15 * index, days=-day)).isoformat()
|
||||||
|
if index in slots else None for index in range(13)]})
|
||||||
|
return {"title": "流量观测", "chart_type": "time_comparison" if comparison else "line",
|
||||||
|
"x_data": [(START + timedelta(minutes=15 * index, hours=8)).strftime("%H:%M") for index in range(13)],
|
||||||
|
"series": series, "scada": {"role": role, "metric": "flow", "unit": "m3/h",
|
||||||
|
"window_start": START.isoformat(), "window_end": END.isoformat()}}
|
||||||
|
|
||||||
|
|
||||||
|
async def publish(task, repository, service, evidence_id, chart_id, data):
|
||||||
|
command = TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||||
|
command={"action": "open_chart", "chart_id": chart_id, "chart": data, "evidence_refs": [evidence_id]},
|
||||||
|
)
|
||||||
|
return await service.publish_presentation_event(task["task_id"], command)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_publications_require_complete_targets_and_retry_without_a_browser():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
await publish(task, repository, service, evidence_id, "overview", chart())
|
||||||
|
for device in DEVICES[:3]:
|
||||||
|
await publish(task, repository, service, evidence_id, device, chart("comparison", [device]))
|
||||||
|
interim = {**final_report(evidence_id), "stage": "interim"}
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=interim,
|
||||||
|
))
|
||||||
|
final = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id))
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.publish_artifacts(task["task_id"], final)
|
||||||
|
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
assert failure.value.refs == ["524529"]
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
|
||||||
|
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": "close_chart", "chart_id": "524529"},
|
||||||
|
))
|
||||||
|
result = await service.publish_artifacts(task["task_id"], final)
|
||||||
|
assert result.task.latest_report.report.stage == "final"
|
||||||
|
# Neither retained event history nor a browser acknowledgement is required.
|
||||||
|
repository.events.clear()
|
||||||
|
complete = 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 complete.latest_run.status == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_successful_completion_rechecks_coverage_after_final_report():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = coverage(DEVICES)
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
|
||||||
|
))
|
||||||
|
# Replacing a chart cannot erase its fourth target from the required set.
|
||||||
|
await publish(task, repository, service, evidence_id, "overview", chart(devices=DEVICES[:3]))
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
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 failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
assert "524529" in failure.value.message
|
||||||
|
assert repository.run["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("missing_current", [False, True])
|
||||||
|
async def test_missing_observations_keep_the_object_and_server_derived_gaps(missing_current):
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
|
||||||
|
data["readings"] = [row for row in data["readings"] if row["device_id"] != "524529"
|
||||||
|
or not missing_current and datetime.fromisoformat(row["time"]) >= START]
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
for series in value["series"]:
|
||||||
|
if missing_current or series["day_offset"]:
|
||||||
|
series["data"] = [None] * 13
|
||||||
|
series["sample_times"] = [None] * 13
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", value)
|
||||||
|
saved = repository.events[-1]["metadata"]["command"]["chart"]
|
||||||
|
assert len(saved["scada"]["gaps"]) == (8 if missing_current else 7)
|
||||||
|
assert sum(gap["missing_count"] for gap in saved["scada"]["gaps"]) == (34 if missing_current else 21)
|
||||||
|
document = await repository.get_scada_presentation(task["task_id"], repository.run["run_id"])
|
||||||
|
assert document["targets"] == ["524529"]
|
||||||
|
assert document["charts"]["524529"]["device_ids"] == ["524529"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("defect,code", [
|
||||||
|
("null_existing_value", "SCADA_CHART_DATA_MISMATCH"),
|
||||||
|
("invented_value", "SCADA_CHART_DATA_MISMATCH"),
|
||||||
|
("wrong_window", "SCADA_CHART_WINDOW_INVALID"),
|
||||||
|
("missing_day", "SCADA_CHART_INVALID"),
|
||||||
|
("wrong_unit", "SCADA_CHART_INVALID"),
|
||||||
|
("unqueried_history", "SCADA_CHART_EVIDENCE_REQUIRED"),
|
||||||
|
])
|
||||||
|
async def test_scada_chart_cannot_fake_coverage(defect, code):
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
if defect == "null_existing_value":
|
||||||
|
value["series"][1]["data"][10] = None
|
||||||
|
value["series"][1]["sample_times"][10] = None
|
||||||
|
elif defect == "invented_value":
|
||||||
|
value["series"][0]["data"][0] = 999.
|
||||||
|
elif defect == "wrong_window":
|
||||||
|
value["scada"]["window_end"] = (END - timedelta(minutes=15)).isoformat()
|
||||||
|
elif defect == "missing_day":
|
||||||
|
value["series"].pop()
|
||||||
|
elif defect == "wrong_unit":
|
||||||
|
value["scada"]["unit"] = "m"
|
||||||
|
elif defect == "unqueried_history":
|
||||||
|
repository.evidence[evidence_id]["data_snapshot"]["data"]["window"]["start"] = START.isoformat()
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", value)
|
||||||
|
assert failure.value.code == code
|
||||||
|
assert not repository.scada_presentations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_previous_run_charts_never_satisfy_a_new_run():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
original = repository.run["run_id"]
|
||||||
|
repository.scada_presentations[(task["task_id"], original)] = coverage(DEVICES)
|
||||||
|
task["execution_generation"] += 1
|
||||||
|
repository.run = _active_run(task, END)
|
||||||
|
repository.runs[repository.run["run_id"]] = repository.run
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
|
||||||
|
))
|
||||||
|
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
assert original != repository.run["run_id"]
|
||||||
|
assert (task["task_id"], original) in repository.scada_presentations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_one_device_cannot_inflate_comparison_object_count_with_a_new_chart_id():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await publish(task, repository, service, evidence_id, "524529-duplicate", chart("comparison", ["524529"]))
|
||||||
|
assert failure.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
|
||||||
|
assert failure.value.refs == ["524529"]
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
|
||||||
|
assert len(repository.scada_presentations[(task["task_id"], repository.run["run_id"])]["charts"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_server_frozen_boundary_proves_unavailable_history_without_fabricating_a_query():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
replay_id = str(uuid4())
|
||||||
|
task["source"]["replay_context"] = {"replay_id": replay_id, "knowledge_cutoff": END.isoformat(), "batch_sequence": 3}
|
||||||
|
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
|
||||||
|
snapshot["window"]["start"] = START.isoformat()
|
||||||
|
snapshot["data"]["coverage"].update(replay_id=replay_id, batch_sequence=3, frozen_start=START.isoformat())
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
for series in value["series"][1:]:
|
||||||
|
series["data"] = [None] * 13
|
||||||
|
series["sample_times"] = [None] * 13
|
||||||
|
await publish(task, repository, service, evidence_id, "524529", value)
|
||||||
|
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
|
||||||
|
assert len(gaps) == 7
|
||||||
|
assert {gap["reason"] for gap in gaps} == {"outside_frozen_window"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_interim_monitoring_table_targets_cannot_be_removed_from_a_later_final_report():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
incomplete = coverage(DEVICES[:3])
|
||||||
|
incomplete["targets"] = DEVICES[:3]
|
||||||
|
incomplete["charts"]["overview"]["device_ids"] = DEVICES[:3]
|
||||||
|
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = incomplete
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "stage": "interim"},
|
||||||
|
))
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": DEVICES[:3]},
|
||||||
|
))
|
||||||
|
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
assert failure.value.refs == ["524529"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_narrative_diagram_references_do_not_declare_business_display_targets():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
selected = DEVICES[:3]
|
||||||
|
document = coverage(selected)
|
||||||
|
document["targets"] = selected
|
||||||
|
document["charts"]["overview"]["device_ids"] = selected
|
||||||
|
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = document
|
||||||
|
report = {**final_report(evidence_id), "scada_device_ids": selected, "diagram": {
|
||||||
|
"title": "待选参考测点", "mermaid": "flowchart LR\nreference[参考测点]", "node_refs": [{
|
||||||
|
"node_id": "reference", "label": "参考测点", "map_targets": [{"source_id": "scada", "feature_id": "524529"}],
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=report,
|
||||||
|
))
|
||||||
|
assert result.report_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unaligned_cutoff_keeps_actual_quarter_hour_samples():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
task["trigger"]["occurred_at"] = (END + timedelta(minutes=8)).isoformat()
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
value["scada"].update(window_start=(START + timedelta(minutes=8)).isoformat(),
|
||||||
|
window_end=(END + timedelta(minutes=8)).isoformat())
|
||||||
|
value["x_data"] = value["x_data"][1:]
|
||||||
|
for item in value["series"]:
|
||||||
|
item["data"] = item["data"][1:]
|
||||||
|
item["sample_times"] = item["sample_times"][1:]
|
||||||
|
await publish(task, repository, service, evidence_id, "unaligned", value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("frequency", ["15min", "15分钟"])
|
||||||
|
async def test_wholly_missing_devices_accept_supported_cadence_formats(frequency):
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
|
||||||
|
data["readings"] = []
|
||||||
|
for device in data["devices"]:
|
||||||
|
device["transmission_frequency"] = frequency
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
for item in value["series"]:
|
||||||
|
item["data"] = [None] * 13
|
||||||
|
item["sample_times"] = [None] * 13
|
||||||
|
await publish(task, repository, service, evidence_id, "missing", value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_one_minute_chunks_require_union_of_verified_slots():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
|
||||||
|
data["devices"] = [{**data["devices"][0], "transmission_frequency": "1min"}]
|
||||||
|
device = DEVICES[0]
|
||||||
|
data["readings"] = [{"device_id": device, "time": (START + timedelta(minutes=i, days=-day)).isoformat(),
|
||||||
|
"monitored_value": float(i + 1)}
|
||||||
|
for day in range(8) for i in (range(181) if day == 0 else range(178, 181))]
|
||||||
|
|
||||||
|
def chunk(role, first, stop, missing_current=False):
|
||||||
|
value = chart(role, [device])
|
||||||
|
value["x_data"] = [(START + timedelta(minutes=i, hours=8)).strftime("%H:%M") for i in range(first, stop)]
|
||||||
|
for item in value["series"]:
|
||||||
|
day = item["day_offset"]
|
||||||
|
present = lambda i: (not day or i >= 178) and not (missing_current and day == 0)
|
||||||
|
item["data"] = [float(i + 1) if present(i) else None for i in range(first, stop)]
|
||||||
|
item["sample_times"] = [(START + timedelta(minutes=i, days=-day)).isoformat() if present(i) else None
|
||||||
|
for i in range(first, stop)]
|
||||||
|
return value
|
||||||
|
|
||||||
|
report = {**final_report(evidence_id), "scada_device_ids": [device]}
|
||||||
|
for role in ("current", "comparison"):
|
||||||
|
await publish(task, repository, service, evidence_id, role + "-first", chunk(role, 0, 100))
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||||
|
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
|
||||||
|
with pytest.raises(DomainError) as duplicate:
|
||||||
|
await publish(task, repository, service, evidence_id, "overlap", chunk("comparison", 99, 181))
|
||||||
|
assert duplicate.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
|
||||||
|
for role in ("current", "comparison"):
|
||||||
|
await publish(task, repository, service, evidence_id, role + "-last", chunk(role, 100, 181))
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||||
|
# Missing current data still permits the last chunk's historical dates across midnight.
|
||||||
|
data["readings"] = [row for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
|
||||||
|
await publish(task, repository, service, evidence_id, "comparison-last", chunk("comparison", 100, 181, True))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start,end,labels,historical", [
|
||||||
|
("2026-07-16T05:08:00+08:00", "2026-07-16T08:08:00+08:00", ["07:45", "08:00"],
|
||||||
|
["2026-07-15T07:45:00+08:00", "2026-07-15T08:00:00+08:00"]),
|
||||||
|
("2026-07-15T22:08:00+08:00", "2026-07-16T01:08:00+08:00", ["00:45", "01:00"],
|
||||||
|
["2026-07-15T00:45:00+08:00", "2026-07-15T01:00:00+08:00"]),
|
||||||
|
])
|
||||||
|
def test_missing_current_chunk_uses_its_first_slot_date(start, end, labels, historical):
|
||||||
|
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||||
|
|
||||||
|
validate_time_comparison_chart({"chart_type": "time_comparison", "x_data": labels,
|
||||||
|
"scada": {"role": "comparison", "window_start": start, "window_end": end},
|
||||||
|
"series": [{"type": "line", "data": [None, None], "sample_times": [None, None]},
|
||||||
|
{"type": "scatter", "data": [1., 2.], "sample_times": historical}]},
|
||||||
|
allow_missing_current=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_current_preserves_observed_historical_sampling_phase():
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
|
||||||
|
data["readings"] = [{**row, "time": (datetime.fromisoformat(row["time"]) + timedelta(minutes=5)).isoformat()}
|
||||||
|
for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
|
||||||
|
value = chart("comparison", ["524529"])
|
||||||
|
value["x_data"] = [(START + timedelta(minutes=15 * i + 5, hours=8)).strftime("%H:%M") for i in range(12)]
|
||||||
|
for item in value["series"]:
|
||||||
|
day = item["day_offset"]
|
||||||
|
item["data"] = [float(i + 1) if day and i >= 9 else None for i in range(12)]
|
||||||
|
item["sample_times"] = [(START + timedelta(minutes=15 * i + 5, days=-day)).isoformat()
|
||||||
|
if day and i >= 9 else None for i in range(12)]
|
||||||
|
# The final three corresponding slots are now 23:20,23:35,23:50 UTC.
|
||||||
|
for day in range(1, 8):
|
||||||
|
data["readings"].append({"device_id": "524529", "time": (START + timedelta(minutes=140, days=-day)).isoformat(),
|
||||||
|
"monitored_value": 10.})
|
||||||
|
await publish(task, repository, service, evidence_id, "historical-phase", value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("point_only", [False, True])
|
||||||
|
async def test_six_hour_devices_with_no_scheduled_slots_require_full_absence_proof(point_only):
|
||||||
|
task, repository, service, evidence_id = await investigation()
|
||||||
|
end = END + timedelta(hours=2)
|
||||||
|
start = end - timedelta(hours=3)
|
||||||
|
task["trigger"]["occurred_at"] = end.isoformat()
|
||||||
|
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
|
||||||
|
snapshot["window"] = {"start": (end if point_only else start - timedelta(days=7)).isoformat(), "end": end.isoformat()}
|
||||||
|
snapshot["data"]["readings"] = []
|
||||||
|
for device in snapshot["data"]["devices"]:
|
||||||
|
device["transmission_frequency"] = "6:00:00"
|
||||||
|
for role in ("current", "comparison"):
|
||||||
|
value = chart(role, ["524529"])
|
||||||
|
value["scada"].update(window_start=start.isoformat(), window_end=end.isoformat())
|
||||||
|
value["x_data"] = ["10:00"]
|
||||||
|
for item in value["series"]:
|
||||||
|
item["data"], item["sample_times"] = [None], [None]
|
||||||
|
if point_only:
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await publish(task, repository, service, evidence_id, role, value)
|
||||||
|
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
|
||||||
|
else:
|
||||||
|
await publish(task, repository, service, evidence_id, role, value)
|
||||||
|
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
|
||||||
|
assert all(gap["reason"] == "no_scheduled_samples" for gap in gaps)
|
||||||
|
if not point_only:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": ["524529"]}))
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user