Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1892b1d1f0 | ||
|
|
2e8fe86f3f | ||
|
|
b853b61a0b | ||
|
|
e47c61c121 | ||
|
|
6a4478bfe4 | ||
|
|
5197a93163 | ||
|
|
549019507e | ||
|
|
acc8f8f7cb | ||
|
|
b8359ba83c | ||
|
|
c0dea2aacf | ||
|
|
33df25f22b | ||
|
|
2a7b96f813 | ||
|
|
06d8539aa8 | ||
|
|
2552a9b40d | ||
|
|
01bad116c1 | ||
|
|
bf8ea45d40 |
@@ -6,6 +6,8 @@
|
|||||||
# 基础运行
|
# 基础运行
|
||||||
PROJECT_NAME="TJWater Server"
|
PROJECT_NAME="TJWater Server"
|
||||||
ENVIRONMENT="production"
|
ENVIRONMENT="production"
|
||||||
|
# 任务领取范围;留空时沿用 ENVIRONMENT。历史任务需先执行 034 迁移。
|
||||||
|
OPERATIONAL_TASK_EXECUTION_SCOPE=""
|
||||||
API_V1_STR="/api/v1"
|
API_V1_STR="/api/v1"
|
||||||
NETWORK_NAME="tjwater"
|
NETWORK_NAME="tjwater"
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
|||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from app.auth.metadata_dependencies import get_metadata_repository
|
from app.auth.metadata_dependencies import get_metadata_repository
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.auth.permissions import (
|
from app.auth.permissions import (
|
||||||
OPERATIONAL_TASK_EXECUTE,
|
OPERATIONAL_TASK_EXECUTE,
|
||||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||||
@@ -122,6 +123,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
|
|||||||
OperationalTaskRepository(conn, ctx.project_id),
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
ctx,
|
ctx,
|
||||||
_task_permissions(ctx),
|
_task_permissions(ctx),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
|
|||||||
OperationalTaskRepository(conn, ctx.project_id),
|
OperationalTaskRepository(conn, ctx.project_id),
|
||||||
ctx,
|
ctx,
|
||||||
permissions_for_context(ctx),
|
permissions_for_context(ctx),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str = "TJWater Server"
|
PROJECT_NAME: str = "TJWater Server"
|
||||||
ENVIRONMENT: str = "production"
|
ENVIRONMENT: str = "production"
|
||||||
|
OPERATIONAL_TASK_EXECUTION_SCOPE: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operational_task_execution_scope(self) -> str:
|
||||||
|
return self.OPERATIONAL_TASK_EXECUTION_SCOPE.strip() or self.ENVIRONMENT
|
||||||
|
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
NETWORK_NAME: str = "default_network"
|
NETWORK_NAME: str = "default_network"
|
||||||
# 敏感配置加密密钥 (Fernet)
|
# 敏感配置加密密钥 (Fernet)
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -574,6 +623,7 @@ class OperationalTaskRun(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class OperationalTaskSummary(StrictModel):
|
class OperationalTaskSummary(StrictModel):
|
||||||
|
execution_scope: NonEmptyStr
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
task_type: TaskType
|
task_type: TaskType
|
||||||
@@ -693,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(
|
||||||
@@ -721,7 +790,8 @@ class TaskSafeChartSeries(StrictModel):
|
|||||||
|
|
||||||
class TaskSafeChart(StrictModel):
|
class TaskSafeChart(StrictModel):
|
||||||
title: Annotated[str, Field(max_length=200)]
|
title: Annotated[str, Field(max_length=200)]
|
||||||
chart_type: Literal["line", "bar"]
|
scada: ScadaChartContext | None = None
|
||||||
|
chart_type: Literal["line", "bar", "time_comparison"]
|
||||||
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
||||||
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
||||||
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
|
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -264,6 +264,7 @@ class OperationalTaskRepository:
|
|||||||
async def list_runs(
|
async def list_runs(
|
||||||
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
|
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
|
||||||
task_type: str | None = None, task_created_from: datetime | None = None,
|
task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
|
execution_scope: str = "production",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
result = await self.connection.execute(
|
result = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
@@ -282,18 +283,20 @@ class OperationalTaskRepository:
|
|||||||
AND t.task_id=operational_task_runs.task_id
|
AND t.task_id=operational_task_runs.task_id
|
||||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||||
|
AND t.document->>'execution_scope'=%s
|
||||||
)
|
)
|
||||||
ORDER BY created_at,run_id LIMIT %s""",
|
ORDER BY created_at,run_id LIMIT %s""",
|
||||||
(
|
(
|
||||||
self.project_id, status, status, cursor_created_at,
|
self.project_id, status, status, cursor_created_at,
|
||||||
cursor_created_at, cursor_run_id,
|
cursor_created_at, cursor_run_id,
|
||||||
task_type, task_type, task_created_from, task_created_from, limit,
|
task_type, task_type, task_created_from, task_created_from, execution_scope, limit,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await result.fetchall()]
|
return [dict(row) for row in await result.fetchall()]
|
||||||
|
|
||||||
async def claim_expired_runs(
|
async def claim_expired_runs(
|
||||||
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
|
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
|
execution_scope: str = "production",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
@@ -310,9 +313,10 @@ class OperationalTaskRepository:
|
|||||||
AND t.task_id=operational_task_runs.task_id
|
AND t.task_id=operational_task_runs.task_id
|
||||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||||
|
AND t.document->>'execution_scope'=%s
|
||||||
)
|
)
|
||||||
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||||
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit),
|
(self.project_id, task_type, task_type, task_created_from, task_created_from, execution_scope, limit),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in await cursor.fetchall()]
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
@@ -367,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
|
||||||
@@ -401,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
|
||||||
@@ -624,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
|
||||||
@@ -711,16 +774,16 @@ class OperationalTaskRepository:
|
|||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
async def recovery_candidates(self, limit: int, created_from=None) -> list[dict]:
|
async def recovery_candidates(self, limit: int, created_from=None, execution_scope: str = "production") -> list[dict]:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
WHERE project_id=%s AND task_type='event_investigation'
|
WHERE project_id=%s AND task_type='event_investigation'
|
||||||
AND status NOT IN ('cancelled','failed')
|
AND status NOT IN ('cancelled','failed')
|
||||||
AND document#>>'{handling_context,phase}'='waiting_scada'
|
AND document#>>'{handling_context,phase}'='waiting_scada'
|
||||||
AND document#>>'{source,replay_context}' IS NULL
|
AND document->>'execution_scope'=%s
|
||||||
AND (%s::timestamptz IS NULL OR created_at>=%s)
|
AND (%s::timestamptz IS NULL OR created_at>=%s)
|
||||||
ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""",
|
ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""",
|
||||||
(self.project_id, created_from, created_from, limit),
|
(self.project_id, execution_scope, created_from, created_from, limit),
|
||||||
)
|
)
|
||||||
return [row["document"] for row in await cursor.fetchall()]
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,9 @@ class McpQueryApplication:
|
|||||||
output = display_query_result(result.model_dump(mode="json"))
|
output = display_query_result(result.model_dump(mode="json"))
|
||||||
content = []
|
content = []
|
||||||
from app.mcp.extended_service import ARTIFACT_TOOLS
|
from app.mcp.extended_service import ARTIFACT_TOOLS
|
||||||
|
# Complete SCADA inputs must reach the model intact, not as a first-page preview.
|
||||||
|
# The shared 8 MiB result ceiling above still applies before persistence and output.
|
||||||
|
complete_scada = name == "query_scada_readings" and args.device_ids is None
|
||||||
if (not include_full and name == "list_scada_devices"
|
if (not include_full and name == "list_scada_devices"
|
||||||
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000):
|
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000):
|
||||||
preview = compact_scada_catalog(output, budget=12000)
|
preview = compact_scada_catalog(output, budget=12000)
|
||||||
@@ -436,6 +439,7 @@ class McpQueryApplication:
|
|||||||
output = preview
|
output = preview
|
||||||
if (
|
if (
|
||||||
not include_full
|
not include_full
|
||||||
|
and not complete_scada
|
||||||
and name not in ARTIFACT_TOOLS
|
and name not in ARTIFACT_TOOLS
|
||||||
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
|
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
|
||||||
):
|
):
|
||||||
|
|||||||
+8
-2
@@ -35,6 +35,12 @@ class QueryTool:
|
|||||||
"。默认每页100个,最多500个;page.has_more=true时继续递增offset读取后续页面。"
|
"。默认每页100个,最多500个;page.has_more=true时继续递增offset读取后续页面。"
|
||||||
"大目录内联保留本页全部设备的编号、类型和单位,模型映射及坐标用get_scada_device核验。"
|
"大目录内联保留本页全部设备的编号、类型和单位,模型映射及坐标用get_scada_device核验。"
|
||||||
)
|
)
|
||||||
|
elif self.name == "query_scada_readings":
|
||||||
|
suffix = (
|
||||||
|
"。首次调查省略device_ids,Server读取完整设备属性并自动翻页读取窗口内全部授权观测,"
|
||||||
|
"返回devices、readings和coverage,包括缺测及冻结范围外设备;全量结果完整内联。"
|
||||||
|
"显式device_ids仅用于后续局部复核,不能替代全量核查。"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
suffix = (
|
suffix = (
|
||||||
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
|
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
|
||||||
@@ -64,7 +70,7 @@ _specs = [
|
|||||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||||
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;返回压力异常信号,候选管段由定位工具分析", BurstDetectionInput, "burst.run", "analysis", False, True),
|
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;返回压力异常信号,候选管段由定位工具分析", BurstDetectionInput, "burst.run", "analysis", False, True),
|
||||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;依据现有异常形成候选,水力算法使用有依据的burst_leakage作为输入。任务上下文location_demo.available=true时可省略漏量,Server直接返回候选管段与相似度,随后继续地图展示、隔离边界与影响分析。压力节点可省略,自动读取项目配置;monitoring正常窗省略时默认前一天同时间段,须说明假设;回放必须显式提供冻结正常窗。simulation须有已有分析运行UUID及同窗模型基线,结果标注模型来源", BurstLocationInput, "burst.run", "analysis", False, True),
|
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;依据现有异常形成候选,水力算法使用有依据的burst_leakage作为输入。任务上下文location_capability.available=true时可省略漏量,Server直接返回候选管段与相似度,随后继续地图展示、隔离边界与影响分析。压力节点可省略,自动读取项目配置;monitoring正常窗省略时默认前一天同时间段,须说明假设;回放必须显式提供冻结正常窗。simulation须有已有分析运行UUID及同窗模型基线,结果标注模型来源", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出为拓扑隔离与供水失源范围;水力影响另用关阀模拟评估,现场操作前核实阀门映射及可用性", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出为拓扑隔离与供水失源范围;水力影响另用关阀模拟评估,现场操作前核实阀门映射及可用性", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||||
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,支持各类项目分析。支持最多8组、合计10000个唯一节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;结果为节点空间覆盖示意,保留分组及几何覆盖范围。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;地图通过展示工具发布", NodeCoverageInput, "webgis.view", "analysis", False, True),
|
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,支持各类项目分析。支持最多8组、合计10000个唯一节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;结果为节点空间覆盖示意,保留分组及几何覆盖范围。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;地图通过展示工具发布", NodeCoverageInput, "webgis.view", "analysis", False, True),
|
||||||
(
|
(
|
||||||
@@ -211,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",
|
||||||
|
|||||||
+14
-2
@@ -106,8 +106,20 @@ class WindowInput(PageInput):
|
|||||||
|
|
||||||
|
|
||||||
class ScadaInput(WindowInput):
|
class ScadaInput(WindowInput):
|
||||||
device_ids: list[Id] = Field(min_length=1, max_length=20)
|
device_ids: list[Id] | None = Field(
|
||||||
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
|
default=None, min_length=1, max_length=20,
|
||||||
|
description="Omit to read the complete SCADA catalogue and all authorized readings in the window; explicit IDs select a paginated detail query.",
|
||||||
|
)
|
||||||
|
field: Literal["monitored_value", "cleaned_value"] = Field(
|
||||||
|
default="monitored_value",
|
||||||
|
description="Field for explicit-device detail queries; complete all-device reads return both raw and cleaned values.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def complete_query_starts_at_zero(self):
|
||||||
|
if self.device_ids is None and self.offset:
|
||||||
|
raise ValueError("All-device SCADA queries read every page internally; offset must be zero")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BaselineSeriesInput(WindowInput):
|
class BaselineSeriesInput(WindowInput):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from app.core.config import settings
|
||||||
from app.auth.permissions import permissions_for_context
|
from app.auth.permissions import permissions_for_context
|
||||||
from app.infra.db.postgresql import read_queries as reads
|
from app.infra.db.postgresql import read_queries as reads
|
||||||
from app.infra.db.postgresql.spatial_queries import nearby_elements
|
from app.infra.db.postgresql.spatial_queries import nearby_elements
|
||||||
@@ -52,6 +53,7 @@ def task_service(scope: QueryScope) -> OperationalTaskService:
|
|||||||
OperationalTaskRepository(scope.connection, scope.project.project_id),
|
OperationalTaskRepository(scope.connection, scope.project.project_id),
|
||||||
scope.project,
|
scope.project,
|
||||||
set(permissions_for_context(scope.project)),
|
set(permissions_for_context(scope.project)),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +131,11 @@ async def authorize_query(
|
|||||||
if not granted and binding.task_id and source in binding.task_scope_cap:
|
if not granted and binding.task_id and source in binding.task_scope_cap:
|
||||||
async with scope.connection.transaction():
|
async with scope.connection.transaction():
|
||||||
granted = await task_service(scope).can_read_source(binding.task_id, source)
|
granted = await task_service(scope).can_read_source(binding.task_id, source)
|
||||||
|
if granted and tool.name == "query_scada_readings" and args.device_ids is None:
|
||||||
|
granted = (
|
||||||
|
"scada_devices" in binding.task_scope_cap
|
||||||
|
and await task_service(scope).can_read_source(binding.task_id, "scada_devices")
|
||||||
|
)
|
||||||
if not granted:
|
if not granted:
|
||||||
raise HTTPException(403, "Data source permission is required")
|
raise HTTPException(403, "Data source permission is required")
|
||||||
if tool.name == "run_burst_location" and args.burst_leakage is None:
|
if tool.name == "run_burst_location" and args.burst_leakage is None:
|
||||||
@@ -175,13 +182,15 @@ async def execute_query(
|
|||||||
source_ref = "mcp:" + str(uuid4())
|
source_ref = "mcp:" + str(uuid4())
|
||||||
evidence_id = None
|
evidence_id = None
|
||||||
replay_context = getattr(binding, "replay_context", None)
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
if name == "query_scada_readings" and replay_context:
|
if name == "query_scada_readings" and args.device_ids is None:
|
||||||
|
from app.mcp.scada_readings import read_complete_scada
|
||||||
|
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||||
|
elif name == "query_scada_readings" and replay_context:
|
||||||
from app.services.scada_replay_queries import replay_snapshot_rows
|
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||||
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
|
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
|
||||||
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||||
data, page, truncated = page_result(rows, args)
|
data, page, truncated = page_result(rows, args)
|
||||||
origin = "synthetic"
|
origin = "synthetic"
|
||||||
gaps.append("历史数据回放证据,仅覆盖该轮调查已提交的批次,不表示现场实时观测。")
|
|
||||||
elif name == "run_burst_location" and replay_context:
|
elif name == "run_burst_location" and replay_context:
|
||||||
from app.mcp.analysis_service import calculate_replay_burst_location
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
@@ -190,7 +199,6 @@ async def execute_query(
|
|||||||
conn, scope.project.project_id, replay_context, args,
|
conn, scope.project.project_id, replay_context, args,
|
||||||
)
|
)
|
||||||
result = await location_demo_result(conn, scope.project.project_code, args)
|
result = await location_demo_result(conn, scope.project.project_code, args)
|
||||||
used_demo = result is not None
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
if result is None:
|
if result is None:
|
||||||
async with scope.simulation_routing():
|
async with scope.simulation_routing():
|
||||||
@@ -204,10 +212,6 @@ async def execute_query(
|
|||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
origin = "synthetic"
|
origin = "synthetic"
|
||||||
gaps = [
|
|
||||||
"定位只使用任务知识截止前已固化的回放清洗输出;候选排序依赖漏量假设、模型和测点覆盖,仍需现场核实。"
|
|
||||||
]
|
|
||||||
if used_demo:
|
|
||||||
gaps = [result["notice"]] if result.get("notice") else []
|
gaps = [result["notice"]] if result.get("notice") else []
|
||||||
elif name == "run_burst_location" and (
|
elif name == "run_burst_location" and (
|
||||||
demo_result := await location_demo_result(conn, scope.project.project_code, args)
|
demo_result := await location_demo_result(conn, scope.project.project_code, args)
|
||||||
@@ -390,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
|
||||||
@@ -488,7 +495,7 @@ async def execute_query(
|
|||||||
and "run_burst_location" in binding.tool_cap):
|
and "run_burst_location" in binding.tool_cap):
|
||||||
from app.services.burst_location_demo import location_demo_context
|
from app.services.burst_location_demo import location_demo_context
|
||||||
|
|
||||||
data["location_demo"] = location_demo_context(
|
data["location_capability"] = location_demo_context(
|
||||||
getattr(scope.project, "project_code", None), replay_context,
|
getattr(scope.project, "project_code", None), replay_context,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Complete project SCADA reads with explicit frozen-scope and missing-data accounting."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
from app.mcp.models import BoundContext, ScadaInput
|
||||||
|
from app.mcp.scope import QueryScope
|
||||||
|
from app.infra.db.postgresql import read_queries as reads
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.services.scada_replay_queries import check_replay_window, check_snapshot_start
|
||||||
|
|
||||||
|
|
||||||
|
MAX_COMPLETE_SCADA_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def read_complete_scada(
|
||||||
|
scope: QueryScope, binding: BoundContext, args: ScadaInput,
|
||||||
|
) -> tuple[dict, dict, list[str], str]:
|
||||||
|
"""Exhaust source pages; never treat a first page or selected device subset as a full scan."""
|
||||||
|
devices = []
|
||||||
|
offset = 0
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
def account(items):
|
||||||
|
nonlocal size
|
||||||
|
size += len(json.dumps(jsonable_encoder(items), ensure_ascii=False).encode())
|
||||||
|
if size > MAX_COMPLETE_SCADA_BYTES:
|
||||||
|
raise HTTPException(413, "Complete SCADA result exceeds 8 MiB; split the time window and retain every device")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
page = await reads.scada_devices(scope.connection, limit=501, offset=offset)
|
||||||
|
batch = page[:500]
|
||||||
|
account(batch)
|
||||||
|
devices.extend(batch)
|
||||||
|
if len(page) <= 500:
|
||||||
|
break
|
||||||
|
offset += len(batch)
|
||||||
|
|
||||||
|
project_device_count = len(devices)
|
||||||
|
context = getattr(binding, "replay_context", None)
|
||||||
|
catalog = {str(device["device_id"]): device for device in devices}
|
||||||
|
outside_scope = []
|
||||||
|
if context:
|
||||||
|
check_replay_window(context, args.start, args.end)
|
||||||
|
repository = ScadaReplayRepository(scope.connection, scope.project.project_id)
|
||||||
|
run = await repository.get(context["replay_id"])
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(404, "SCADA replay was not found in the current project")
|
||||||
|
check_snapshot_start(run, args.start)
|
||||||
|
frozen = {str(device["device_id"]): device for device in run.get("devices", [])}
|
||||||
|
selected = list(dict.fromkeys(run["config"]["device_ids"]))
|
||||||
|
outside_scope = sorted(set(catalog) - set(selected))
|
||||||
|
# Retain devices removed from the current catalogue but present in the frozen source.
|
||||||
|
for device in selected:
|
||||||
|
if device not in catalog:
|
||||||
|
catalog[device] = frozen.get(device, {"device_id": device})
|
||||||
|
devices = list(catalog.values())
|
||||||
|
else:
|
||||||
|
selected = list(catalog)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
offset = 0
|
||||||
|
# The public limit controls source page size, not completeness of this operation.
|
||||||
|
page_size = min(max(args.limit, 1000), 10000)
|
||||||
|
|
||||||
|
async def read_pages(connection=None):
|
||||||
|
nonlocal offset
|
||||||
|
while selected:
|
||||||
|
if context:
|
||||||
|
page = await repository.detected_outputs(
|
||||||
|
context["replay_id"], selected, args.start, args.end, context["batch_sequence"],
|
||||||
|
limit=page_size + 1, offset=offset,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
page = await reads.series_rows(
|
||||||
|
connection, source="scada", element_type="junction", ids=selected,
|
||||||
|
start=args.start, end=args.end, field=None,
|
||||||
|
limit=page_size + 1, offset=offset,
|
||||||
|
)
|
||||||
|
batch = page[:page_size]
|
||||||
|
if context:
|
||||||
|
# Match the existing frozen-reading contract; internal source hashes are not observations.
|
||||||
|
fields = ("time", "device_id", "monitored_value", "cleaned_value", "quality", "reason")
|
||||||
|
batch = [{key: row[key] for key in fields if key in row} for row in batch]
|
||||||
|
account(batch)
|
||||||
|
rows.extend(batch)
|
||||||
|
if len(page) <= page_size:
|
||||||
|
break
|
||||||
|
offset += len(batch)
|
||||||
|
|
||||||
|
if context:
|
||||||
|
await read_pages()
|
||||||
|
elif selected:
|
||||||
|
async with scope.timescale() as connection:
|
||||||
|
await read_pages(connection)
|
||||||
|
|
||||||
|
counts = Counter(str(row["device_id"]) for row in rows)
|
||||||
|
missing = sorted(set(selected) - counts.keys())
|
||||||
|
coverage = {
|
||||||
|
"mode": "all_scada", "query_complete": True,
|
||||||
|
"project_device_count": project_device_count, "queried_device_count": len(selected),
|
||||||
|
"devices_with_readings": len(counts), "reading_count": len(rows),
|
||||||
|
"queried_device_ids": selected, "missing_device_ids": missing,
|
||||||
|
"outside_frozen_scope_device_ids": outside_scope,
|
||||||
|
"readings_per_device": {device: counts[device] for device in selected},
|
||||||
|
"replay_id": context["replay_id"] if context else None,
|
||||||
|
"batch_sequence": context["batch_sequence"] if context else None,
|
||||||
|
}
|
||||||
|
if context:
|
||||||
|
coverage["frozen_start"] = (run.get("baseline") or {}).get("start_time") or run["start_time"]
|
||||||
|
data = {"devices": devices, "readings": rows, "coverage": coverage}
|
||||||
|
account([coverage])
|
||||||
|
gaps = []
|
||||||
|
if missing:
|
||||||
|
gaps.append(f"{len(missing)} queried SCADA devices have no readings in this window; see coverage.missing_device_ids")
|
||||||
|
if outside_scope:
|
||||||
|
gaps.append(f"{len(outside_scope)} project devices are outside this frozen replay; see coverage.outside_frozen_scope_device_ids")
|
||||||
|
units = {device: catalog[device]["measurement_unit"] for device in selected
|
||||||
|
if catalog[device].get("measurement_unit")}
|
||||||
|
return data, units, gaps, "synthetic" if context else "system_data"
|
||||||
+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,
|
||||||
|
)
|
||||||
@@ -20,8 +20,7 @@ RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
|
|||||||
|
|
||||||
|
|
||||||
def track_work_order(task: dict, action: dict) -> None:
|
def track_work_order(task: dict, action: dict) -> None:
|
||||||
if (task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order"
|
if task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order":
|
||||||
or task.get("source", {}).get("replay_context")):
|
|
||||||
return
|
return
|
||||||
receipt = action.get("receipt") or {}
|
receipt = action.get("receipt") or {}
|
||||||
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
|
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
|
||||||
@@ -132,9 +131,11 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
|
|||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
async with service.repo.transaction():
|
async with service.repo.transaction():
|
||||||
candidates = await service.repo.recovery_candidates(limit, created_from)
|
candidates = await service.repo.recovery_candidates(limit, created_from, service.execution_scope)
|
||||||
resumed = 0
|
resumed = 0
|
||||||
for task in candidates:
|
for task in candidates:
|
||||||
|
if task.get("execution_scope") != service.execution_scope:
|
||||||
|
continue
|
||||||
context = task["handling_context"]
|
context = task["handling_context"]
|
||||||
returned_at = datetime.fromisoformat(context["returned_at"])
|
returned_at = datetime.fromisoformat(context["returned_at"])
|
||||||
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
|
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
|
||||||
@@ -160,6 +161,9 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
|
|||||||
devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str))
|
devices.extend(row["device_id"] for row in data if isinstance(row, dict) and isinstance(row.get("device_id"), str))
|
||||||
devices = list(dict.fromkeys(devices))
|
devices = list(dict.fromkeys(devices))
|
||||||
async with timescale.transaction():
|
async with timescale.transaction():
|
||||||
|
# Field completion authorizes a separate recovery observation even
|
||||||
|
# when the incident was found in a frozen replay. Keep that replay's
|
||||||
|
# query window and batch boundary unchanged.
|
||||||
rows = await ScadaRepository.get_recovery_scada_readings(
|
rows = await ScadaRepository.get_recovery_scada_readings(
|
||||||
timescale, devices, incident_at, returned_at, through, baseline_limit=100_001,
|
timescale, devices, incident_at, returned_at, through, baseline_limit=100_001,
|
||||||
) if through > returned_at and devices else []
|
) if through > returned_at and devices else []
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Require persisted complete SCADA input before final investigation conclusions."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
|
||||||
|
replay = task.get("source", {}).get("replay_context")
|
||||||
|
if task.get("workflow") != "scada_anomaly" and not replay:
|
||||||
|
return
|
||||||
|
target = datetime.fromisoformat(
|
||||||
|
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
||||||
|
)
|
||||||
|
for item in evidence:
|
||||||
|
if item["evidence_id"] not in refs:
|
||||||
|
continue
|
||||||
|
snapshot = item.get("data_snapshot") or {}
|
||||||
|
if snapshot.get("source") != "query_scada_readings" or snapshot.get("truncated"):
|
||||||
|
continue
|
||||||
|
result = snapshot.get("data") or {}
|
||||||
|
data = result.get("data") if isinstance(result, dict) else None
|
||||||
|
coverage = data.get("coverage") if isinstance(data, dict) else None
|
||||||
|
if not coverage or coverage.get("mode") != "all_scada" or coverage.get("query_complete") is not True:
|
||||||
|
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 not start <= target <= end:
|
||||||
|
continue
|
||||||
|
if replay and (coverage.get("replay_id") != replay["replay_id"]
|
||||||
|
or coverage.get("batch_sequence") != replay["batch_sequence"]
|
||||||
|
or end != target):
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
require(False, "SCADA_COVERAGE_REQUIRED",
|
||||||
|
"最终SCADA报告必须引用本轮全量读取证据。调用query_scada_readings并省略device_ids,"
|
||||||
|
"在任务时间窗内读取全部授权设备、完整属性与观测,核对coverage和缺测清单后再发布。",
|
||||||
|
status=409)
|
||||||
@@ -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,8 +73,16 @@ 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 import scada_presentation_coverage
|
||||||
|
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)
|
||||||
@@ -87,11 +97,13 @@ def _task_json(model: OperationalTaskSummary) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class OperationalTaskService:
|
class OperationalTaskService:
|
||||||
def __init__(self, repository, context, permissions: Iterable[str], *, clock=None):
|
def __init__(self, repository: OperationalTaskRepository, context, permissions: Iterable[str], *, clock=None,
|
||||||
|
execution_scope: str = "production"):
|
||||||
self.repo = repository
|
self.repo = repository
|
||||||
self.context = context
|
self.context = context
|
||||||
self.permissions = frozenset(permissions)
|
self.permissions = frozenset(permissions)
|
||||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
self.execution_scope = execution_scope
|
||||||
|
|
||||||
def _now(self) -> datetime:
|
def _now(self) -> datetime:
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
@@ -110,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
|
||||||
@@ -194,6 +178,7 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
task_id = uuid4()
|
task_id = uuid4()
|
||||||
task = OperationalTaskSummary(
|
task = OperationalTaskSummary(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
project_id=self.context.project_id,
|
project_id=self.context.project_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=command.task_type,
|
task_type=command.task_type,
|
||||||
@@ -281,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(
|
||||||
@@ -392,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,
|
||||||
@@ -488,6 +473,7 @@ class OperationalTaskService:
|
|||||||
task_type: str | None = None, task_created_from: datetime | None = None,
|
task_type: str | None = None, task_created_from: datetime | None = None,
|
||||||
) -> tuple[list[OperationalTaskRun], str | None]:
|
) -> tuple[list[OperationalTaskRun], str | None]:
|
||||||
rows = await self.repo.list_runs(
|
rows = await self.repo.list_runs(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
status=status,
|
status=status,
|
||||||
limit=limit + 1,
|
limit=limit + 1,
|
||||||
cursor_created_at=cursor_created_at,
|
cursor_created_at=cursor_created_at,
|
||||||
@@ -509,6 +495,9 @@ class OperationalTaskService:
|
|||||||
scope = f"run:{run_id}:claim"
|
scope = f"run:{run_id}:claim"
|
||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
await self.repo.require_execution_storage()
|
await self.repo.require_execution_storage()
|
||||||
|
task = await self._task(task_id)
|
||||||
|
require(task.get("execution_scope") == self.execution_scope,
|
||||||
|
"EXECUTION_SCOPE_MISMATCH", "当前环境不能领取其他执行范围的任务", status=403)
|
||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return OperationalTaskRun.model_validate(replay["run"])
|
return OperationalTaskRun.model_validate(replay["run"])
|
||||||
@@ -607,7 +596,6 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
||||||
|
|
||||||
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
||||||
if hasattr(self.repo, "fail_run_messages"):
|
|
||||||
await self.repo.fail_run_messages(str(run_id), reason, now)
|
await self.repo.fail_run_messages(str(run_id), reason, now)
|
||||||
|
|
||||||
async def fail_run(
|
async def fail_run(
|
||||||
@@ -758,11 +746,12 @@ class OperationalTaskService:
|
|||||||
async with self.repo.transaction():
|
async with self.repo.transaction():
|
||||||
for run in await self.repo.claim_expired_runs(
|
for run in await self.repo.claim_expired_runs(
|
||||||
limit, task_type=task_type, task_created_from=task_created_from,
|
limit, task_type=task_type, task_created_from=task_created_from,
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
):
|
):
|
||||||
# Lease recovery already holds a row lock. Try rather than wait
|
# Lease recovery already holds a row lock. Try rather than wait
|
||||||
# for the shared run lock to avoid reversing the lock order of
|
# for the shared run lock to avoid reversing the lock order of
|
||||||
# message/finish transactions; a busy run is checked next tick.
|
# message/finish transactions; a busy run is checked next tick.
|
||||||
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
|
if not await self.repo.try_lock(f"run:{run['run_id']}"):
|
||||||
continue
|
continue
|
||||||
task_id = UUID(str(run["task_id"]))
|
task_id = UUID(str(run["task_id"]))
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -774,6 +763,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))
|
||||||
@@ -828,6 +818,11 @@ class OperationalTaskService:
|
|||||||
"Only a running run accepts Agent artifacts", status=409)
|
"Only a running run accepts Agent artifacts", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(active_run, now)
|
self._require_active_run_lease(active_run, now)
|
||||||
|
if command.report:
|
||||||
|
for item in command.report.ui_blocks:
|
||||||
|
for element in item.block.spec.elements.values():
|
||||||
|
if element.type == "Chart":
|
||||||
|
validate_time_comparison_chart(element.props)
|
||||||
if command.report and (
|
if command.report and (
|
||||||
command.report.stage == "final" or command.report.outcome == "insufficient_data"
|
command.report.stage == "final" or command.report.outcome == "insufficient_data"
|
||||||
):
|
):
|
||||||
@@ -861,11 +856,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)
|
||||||
@@ -874,9 +885,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,
|
||||||
@@ -928,17 +941,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
|
||||||
for gap in command.report.data_gaps:
|
if saved_diagram is not None:
|
||||||
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
|
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(
|
||||||
|
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
|
||||||
|
)
|
||||||
|
await self._verify_scada_report_presentation(task, active_run, _json(report_input), now)
|
||||||
|
for gap in report_input.data_gaps:
|
||||||
|
require(set(gap.evidence_refs) <= set(report_input.evidence_refs),
|
||||||
"REPORT_GAP_EVIDENCE_INVALID",
|
"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",
|
||||||
@@ -948,7 +976,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))
|
||||||
@@ -961,20 +989,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,
|
||||||
),
|
),
|
||||||
@@ -998,25 +1027,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:
|
||||||
@@ -1081,7 +1094,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"], 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":
|
||||||
@@ -1119,6 +1136,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:
|
||||||
@@ -1132,7 +1204,7 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"task:{task_id}")
|
await self.repo.lock(f"task:{task_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
self._require_real_action(task)
|
self._require_real_action(task, command.kind)
|
||||||
self._validate_action_payload(command)
|
self._validate_action_payload(command)
|
||||||
if task["task_type"] == "event_investigation":
|
if task["task_type"] == "event_investigation":
|
||||||
report = await self.repo.get_latest_report(str(task_id))
|
report = await self.repo.get_latest_report(str(task_id))
|
||||||
@@ -1203,8 +1275,8 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
self._require_real_action(task)
|
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
|
require(action.kind == "create_work_order", "INVALID_ACTION_KIND",
|
||||||
"Only work-order drafts can be edited", status=422)
|
"Only work-order drafts can be edited", status=422)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
@@ -1259,8 +1331,8 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
self._require_real_action(task)
|
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
"Action version changed", status=409)
|
"Action version changed", status=409)
|
||||||
require(action.approval_status == "pending", "INVALID_TRANSITION",
|
require(action.approval_status == "pending", "INVALID_TRANSITION",
|
||||||
@@ -1306,8 +1378,8 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"action:{action_id}")
|
await self.repo.lock(f"action:{action_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
self._require_real_action(task)
|
|
||||||
action = await self._action(task_id, action_id)
|
action = await self._action(task_id, action_id)
|
||||||
|
self._require_real_action(task, action.kind)
|
||||||
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
require(action.version == command.expected_version, "VERSION_CONFLICT",
|
||||||
"Action version changed", status=409)
|
"Action version changed", status=409)
|
||||||
require(action.approval_status == "approved", "INVALID_TRANSITION",
|
require(action.approval_status == "approved", "INVALID_TRANSITION",
|
||||||
@@ -1493,11 +1565,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")
|
||||||
@@ -1654,11 +1728,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"
|
||||||
@@ -1964,7 +2040,6 @@ class OperationalTaskService:
|
|||||||
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
||||||
|
|
||||||
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
||||||
if hasattr(self.repo, "pending_calculation_jobs"):
|
|
||||||
pending = await self.repo.pending_calculation_jobs(
|
pending = await self.repo.pending_calculation_jobs(
|
||||||
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
||||||
)
|
)
|
||||||
@@ -2000,7 +2075,6 @@ class OperationalTaskService:
|
|||||||
actions = []
|
actions = []
|
||||||
if outcome == "succeeded":
|
if outcome == "succeeded":
|
||||||
await self._require_no_pending_calculations(task_id, run)
|
await self._require_no_pending_calculations(task_id, run)
|
||||||
if hasattr(self.repo, "pending_run_messages"):
|
|
||||||
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
||||||
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
||||||
"Run has messages not yet entered into Agent context",
|
"Run has messages not yet entered into Agent context",
|
||||||
@@ -2035,6 +2109,7 @@ class OperationalTaskService:
|
|||||||
"Run requires a final report from the same generation"
|
"Run requires a final report from the same generation"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
await self._verify_scada_report_presentation(task, run, _json(report.report), now)
|
||||||
actions = [
|
actions = [
|
||||||
HumanApprovalAction.model_validate(item)
|
HumanApprovalAction.model_validate(item)
|
||||||
for item in await self.repo.list_actions(str(task_id))
|
for item in await self.repo.list_actions(str(task_id))
|
||||||
@@ -2134,8 +2209,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"}},
|
||||||
@@ -2151,9 +2224,10 @@ class OperationalTaskService:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _require_real_action(task: dict) -> None:
|
def _require_real_action(task: dict, kind: str) -> None:
|
||||||
require(not (task.get("source") or {}).get("replay_context"), "REPLAY_ACTION_DISABLED",
|
require(kind == "create_work_order" or not (task.get("source") or {}).get("replay_context"),
|
||||||
"SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409)
|
"REPLAY_ACTION_DISABLED",
|
||||||
|
"回放任务不支持设备命令;可生成工单草稿,由有权限的用户确认发布", status=409)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _require_actionable_task(task: dict) -> None:
|
def _require_actionable_task(task: dict) -> None:
|
||||||
@@ -2339,7 +2413,6 @@ class OperationalTaskService:
|
|||||||
|
|
||||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||||
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||||
if self._supports_runs:
|
|
||||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
while active_run is not None:
|
while active_run is not None:
|
||||||
await self.repo.lock(f"run:{active_run['run_id']}")
|
await self.repo.lock(f"run:{active_run['run_id']}")
|
||||||
@@ -2384,7 +2457,6 @@ class OperationalTaskService:
|
|||||||
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
||||||
)
|
)
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
if self._supports_runs:
|
|
||||||
await self.repo.insert_run(_json(run))
|
await self.repo.insert_run(_json(run))
|
||||||
await self._run_event(run, now, queued=True)
|
await self._run_event(run, now, queued=True)
|
||||||
|
|
||||||
@@ -2422,10 +2494,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,
|
||||||
@@ -2510,6 +2579,7 @@ class OperationalTaskService:
|
|||||||
due_at: datetime, now: datetime,
|
due_at: datetime, now: datetime,
|
||||||
) -> OperationalTaskSummary:
|
) -> OperationalTaskSummary:
|
||||||
return OperationalTaskSummary(
|
return OperationalTaskSummary(
|
||||||
|
execution_scope=self.execution_scope,
|
||||||
project_id=self.context.project_id, task_id=task_id,
|
project_id=self.context.project_id, task_id=task_id,
|
||||||
task_type="scheduled_flow", workflow=schedule.schedule_key,
|
task_type="scheduled_flow", workflow=schedule.schedule_key,
|
||||||
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
|
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from app.core.config import settings
|
||||||
from app.auth.permissions import permissions_for_context
|
from app.auth.permissions import permissions_for_context
|
||||||
|
|
||||||
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
||||||
@@ -439,7 +440,8 @@ class ScadaReplayService:
|
|||||||
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
|
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
|
||||||
self.context, internal_producer_id=producer)
|
self.context, internal_producer_id=producer)
|
||||||
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
self.context, permissions_for_context(self.context))
|
self.context, permissions_for_context(self.context),
|
||||||
|
execution_scope=settings.operational_task_execution_scope)
|
||||||
for event in changes:
|
for event in changes:
|
||||||
# Period diagnosis owns measurement notifications during manual steps.
|
# Period diagnosis owns measurement notifications during manual steps.
|
||||||
# Retain rule events as evidence; quality alerts remain independent.
|
# Retain rule events as evidence; quality alerts remain independent.
|
||||||
@@ -503,6 +505,7 @@ class ScadaReplayService:
|
|||||||
tasks = OperationalTaskService(
|
tasks = OperationalTaskService(
|
||||||
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
self.context, permissions_for_context(self.context),
|
self.context, permissions_for_context(self.context),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
await tasks.advance_replay_investigation(UUID(incident["task_id"]), {
|
await tasks.advance_replay_investigation(UUID(incident["task_id"]), {
|
||||||
"replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"],
|
"replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"],
|
||||||
@@ -520,6 +523,7 @@ class ScadaReplayService:
|
|||||||
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
self.context,
|
self.context,
|
||||||
permissions_for_context(self.context),
|
permissions_for_context(self.context),
|
||||||
|
execution_scope=settings.operational_task_execution_scope,
|
||||||
)
|
)
|
||||||
external = incident["external_id"]
|
external = incident["external_id"]
|
||||||
pressure = monitoring["pressure"]
|
pressure = monitoring["pressure"]
|
||||||
@@ -532,7 +536,7 @@ class ScadaReplayService:
|
|||||||
f"爆管侦测得分 {score_text};"
|
f"爆管侦测得分 {score_text};"
|
||||||
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
||||||
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
||||||
"任一指标异常即启动调查,加权分仅用于评估爆管嫌疑,不能据此提前确诊。"
|
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
|
||||||
)
|
)
|
||||||
if monitoring.get("anomaly_signals"):
|
if monitoring.get("anomaly_signals"):
|
||||||
signals = monitoring["anomaly_signals"]
|
signals = monitoring["anomaly_signals"]
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||||
# Legacy test repositories do not implement question persistence.
|
return await service.repo.list_questions(str(task_id))
|
||||||
reader = getattr(service.repo, "list_questions", None)
|
|
||||||
return await reader(str(task_id)) if reader else []
|
|
||||||
|
|
||||||
|
|
||||||
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Publication-only validation for same-time-of-day line/scatter comparisons."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
DAY_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def validate_time_comparison_chart(chart: Mapping[str, Any], *, allow_missing_current: bool = False) -> None:
|
||||||
|
"""Validate new publications; persisted historical charts keep their read contract."""
|
||||||
|
chart_type = chart["chart_type"]
|
||||||
|
series = chart["series"]
|
||||||
|
kinds = {item.get("type") or chart_type for item in series}
|
||||||
|
if chart_type != "time_comparison":
|
||||||
|
require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID",
|
||||||
|
"曲线与历史散点复合图须使用 chart_type=time_comparison", status=422)
|
||||||
|
return
|
||||||
|
|
||||||
|
def check(condition: bool, message: str) -> None:
|
||||||
|
require(condition, "TIME_COMPARISON_INVALID", message, status=422)
|
||||||
|
|
||||||
|
kinds = {item.get("type") or "line" for item in series}
|
||||||
|
check(kinds == {"line", "scatter"},
|
||||||
|
"time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar")
|
||||||
|
labels = chart["x_data"]
|
||||||
|
check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label)
|
||||||
|
for label in labels),
|
||||||
|
"time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴")
|
||||||
|
check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度")
|
||||||
|
seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1)))
|
||||||
|
for label in labels]
|
||||||
|
offsets = [0]
|
||||||
|
for index in range(1, len(seconds)):
|
||||||
|
step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS
|
||||||
|
check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位")
|
||||||
|
offsets.append(offsets[-1] + step)
|
||||||
|
check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位")
|
||||||
|
|
||||||
|
current_bases: set[datetime] = set()
|
||||||
|
historical_bases: list[datetime] = []
|
||||||
|
for series_index, item in enumerate(series):
|
||||||
|
times = item.get("sample_times")
|
||||||
|
path = f"series[{series_index}]"
|
||||||
|
check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长")
|
||||||
|
bases: set[datetime] = set()
|
||||||
|
for index, (value, at) in enumerate(zip(item["data"], times, strict=True)):
|
||||||
|
check((value is None) == (at is None),
|
||||||
|
f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null")
|
||||||
|
if at is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE)
|
||||||
|
base = local - timedelta(seconds=offsets[index])
|
||||||
|
except OverflowError:
|
||||||
|
check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围")
|
||||||
|
label = labels[index]
|
||||||
|
expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M")
|
||||||
|
check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0),
|
||||||
|
f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐")
|
||||||
|
bases.add(base)
|
||||||
|
check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组")
|
||||||
|
if (item.get("type") or "line") == "line":
|
||||||
|
current_bases.update(bases)
|
||||||
|
else:
|
||||||
|
historical_bases.extend(bases)
|
||||||
|
scada = chart.get("scada")
|
||||||
|
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:
|
||||||
|
delta = (current - historical).total_seconds()
|
||||||
|
check(delta > 0 and delta % DAY_SECONDS == 0,
|
||||||
|
"历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
PipeHealthSurvivalPredictor,
|
PipeHealthSurvivalPredictor,
|
||||||
@@ -15,14 +14,11 @@ from app.algorithms.scada_cleaning.pressure_series import clean_pressure_data_df
|
|||||||
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
class TimeseriesAnalysisService:
|
class TimeseriesAnalysisService:
|
||||||
"""
|
"""SCADA 清洗与管道健康预测;历史查询由 TimeseriesHistoryService 负责。"""
|
||||||
复合查询类,提供跨表查询功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_project_scada_index(
|
async def _get_project_scada_index(
|
||||||
@@ -31,294 +27,6 @@ class TimeseriesAnalysisService:
|
|||||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||||
return {scada["device_id"]: scada for scada in scadas}
|
return {scada["device_id"]: scada for scada in scadas}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(link_devices.values())), "flow",
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(node_devices.values())), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link",
|
|
||||||
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node",
|
|
||||||
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(junction_ids)), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
run_id: 分析运行 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当类型无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
|
|
||||||
start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
|
|
||||||
start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_element_associated_scada_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
element_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
use_cleaned: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
获取 link/node 关联的 SCADA 监测值
|
|
||||||
|
|
||||||
根据传入的 link/node id,匹配 SCADA 信息,
|
|
||||||
如果存在关联的 SCADA device_id,获取实际的监测数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
element_id: link 或 node 的 ID
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
use_cleaned: 是否使用清洗后的数据 (True: "cleaned_value", False: "monitored_value")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SCADA 监测数据值,如果没有找到则返回 None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当元素类型无效时
|
|
||||||
"""
|
|
||||||
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
associated_scada = next(
|
|
||||||
(
|
|
||||||
scada
|
|
||||||
for scada in scada_by_id.values()
|
|
||||||
if (scada.get("node_id") or scada.get("link_id")) == element_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not associated_scada:
|
|
||||||
return None
|
|
||||||
|
|
||||||
device_id = associated_scada["device_id"]
|
|
||||||
|
|
||||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
|
||||||
|
|
||||||
res = await ScadaRepository.get_scada_field_by_id_time_range(
|
|
||||||
timescale_conn, [device_id], start_time, end_time, data_field
|
|
||||||
)
|
|
||||||
|
|
||||||
return {element_id: res.get(device_id, [])}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clean_scada_data(
|
async def clean_scada_data(
|
||||||
timescale_conn: AsyncConnection,
|
timescale_conn: AsyncConnection,
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ def _points(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _history_series(
|
||||||
|
*, element_type: HistoryElementType, element_id: str,
|
||||||
|
source: HistorySource, source_unit: str, device_id: str | None = None,
|
||||||
|
unit_inferred: bool = False, points: list[HistoryPoint] | None = None,
|
||||||
|
) -> ElementHistorySeries:
|
||||||
|
metric = _target_metric(element_type)
|
||||||
|
return ElementHistorySeries(
|
||||||
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
|
metric=metric, source=source, source_unit=source_unit,
|
||||||
|
display_unit=display_unit_for_metric(metric.value),
|
||||||
|
unit_inferred=unit_inferred, points=points if points is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_result_units(
|
||||||
|
postgres_conn: AsyncConnection, query: ElementHistoryQuery,
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
||||||
|
if query.mode.value != "analysis_comparison":
|
||||||
|
return units, False
|
||||||
|
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
||||||
|
if run is None:
|
||||||
|
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
||||||
|
recorded = (run.get("parameters") or {}).get("result_units")
|
||||||
|
return (recorded, False) if isinstance(recorded, dict) else (units, True)
|
||||||
|
|
||||||
|
|
||||||
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
||||||
descriptors = []
|
descriptors = []
|
||||||
series = []
|
series = []
|
||||||
@@ -66,16 +93,14 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
"source": source, "element_type": element_type,
|
"source": source, "element_type": element_type,
|
||||||
"id": device_id if source == "scada" else element_id, "field": field,
|
"id": device_id if source == "scada" else element_id, "field": field,
|
||||||
})
|
})
|
||||||
metric = _target_metric(element_type)
|
series.append(_history_series(
|
||||||
series.append(ElementHistorySeries(
|
|
||||||
element_id=element_id, element_type=element_type, device_id=device_id,
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
metric=metric, source=(
|
source=(
|
||||||
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
||||||
) if source == "scada" else (
|
) if source == "scada" else (
|
||||||
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
||||||
),
|
),
|
||||||
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
|
source_unit=unit, unit_inferred=unit_inferred,
|
||||||
unit_inferred=unit_inferred, points=[],
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
||||||
@@ -84,17 +109,8 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
add(source="scada", element_type=element_type, element_id=element_id,
|
add(source="scada", element_type=element_type, element_id=element_id,
|
||||||
device_id=device_id, field=field, unit=device["measurement_unit"])
|
device_id=device_id, field=field, unit=device["measurement_unit"])
|
||||||
if query.mode.value != "observed":
|
if query.mode.value != "observed":
|
||||||
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
units, inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
||||||
inferred = False
|
|
||||||
if source == "analysis":
|
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
recorded = (run.get("parameters") or {}).get("result_units")
|
|
||||||
inferred = not isinstance(recorded, dict)
|
|
||||||
if not inferred:
|
|
||||||
units = recorded
|
|
||||||
targets = {(t.element_type, t.element_id) for t in query.elements}
|
targets = {(t.element_type, t.element_id) for t in query.elements}
|
||||||
for element_type, element_id in sorted(targets):
|
for element_type, element_id in sorted(targets):
|
||||||
metric = _target_metric(element_type)
|
metric = _target_metric(element_type)
|
||||||
@@ -202,21 +218,18 @@ class TimeseriesHistoryService:
|
|||||||
rows = scada_by_device.get(device_id, [])
|
rows = scada_by_device.get(device_id, [])
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
metric = _target_metric(element_type)
|
|
||||||
unit = device["measurement_unit"]
|
unit = device["measurement_unit"]
|
||||||
for source, field in (
|
for source, field in (
|
||||||
(HistorySource.SCADA_RAW, "monitored_value"),
|
(HistorySource.SCADA_RAW, "monitored_value"),
|
||||||
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
||||||
):
|
):
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=element_id,
|
element_id=element_id,
|
||||||
element_type=element_type,
|
element_type=element_type,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=unit,
|
source_unit=unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
value_field=field,
|
value_field=field,
|
||||||
@@ -227,15 +240,8 @@ class TimeseriesHistoryService:
|
|||||||
if query.mode.value == "observed":
|
if query.mode.value == "observed":
|
||||||
return ElementHistoryResponse(series=series)
|
return ElementHistoryResponse(series=series)
|
||||||
|
|
||||||
current_units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
result_units, unit_inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
if query.mode.value == "analysis_comparison":
|
if query.mode.value == "analysis_comparison":
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
parameters = run.get("parameters") or {}
|
|
||||||
recorded_units = parameters.get("result_units")
|
|
||||||
unit_inferred = not isinstance(recorded_units, dict)
|
|
||||||
result_units = recorded_units if not unit_inferred else current_units
|
|
||||||
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.run_id,
|
query.run_id,
|
||||||
@@ -256,8 +262,6 @@ class TimeseriesHistoryService:
|
|||||||
)
|
)
|
||||||
source = HistorySource.ANALYSIS_SIMULATION
|
source = HistorySource.ANALYSIS_SIMULATION
|
||||||
else:
|
else:
|
||||||
unit_inferred = False
|
|
||||||
result_units = current_units
|
|
||||||
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.start_time,
|
query.start_time,
|
||||||
@@ -285,13 +289,11 @@ class TimeseriesHistoryService:
|
|||||||
continue
|
continue
|
||||||
source_unit = str(result_units.get(metric.value, "")).strip()
|
source_unit = str(result_units.get(metric.value, "")).strip()
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=target.element_id,
|
element_id=target.element_id,
|
||||||
element_type=target.element_type,
|
element_type=target.element_type,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=source_unit,
|
source_unit=source_unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
unit_inferred=unit_inferred,
|
unit_inferred=unit_inferred,
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -310,7 +310,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"x_axis_name": {
|
"x_axis_name": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "939f38733e943f21cc2e3d9f344fa8122ca7a7b35cfebf96ff3f00f469026ceb"
|
"sha256": "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",
|
||||||
@@ -4683,6 +4938,11 @@
|
|||||||
"title": "Execution Request Id",
|
"title": "Execution Request Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"execution_scope": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Execution Scope",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"handling_context": {
|
"handling_context": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/components/schemas/JsonValue"
|
"$ref": "#/components/schemas/JsonValue"
|
||||||
@@ -4846,6 +5106,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"execution_scope",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"task_type",
|
"task_type",
|
||||||
@@ -5660,6 +5921,11 @@
|
|||||||
"title": "Execution Request Id",
|
"title": "Execution Request Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"execution_scope": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Execution Scope",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"handling_context": {
|
"handling_context": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/components/schemas/JsonValue"
|
"$ref": "#/components/schemas/JsonValue"
|
||||||
@@ -5773,6 +6039,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"execution_scope",
|
||||||
"project_id",
|
"project_id",
|
||||||
"task_id",
|
"task_id",
|
||||||
"task_type",
|
"task_type",
|
||||||
@@ -7150,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": {
|
||||||
@@ -10211,11 +10582,22 @@
|
|||||||
"chart_type": {
|
"chart_type": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
],
|
],
|
||||||
"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"
|
||||||
@@ -10293,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,11 +207,103 @@
|
|||||||
"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": [
|
||||||
"line",
|
"line",
|
||||||
"bar"
|
"bar",
|
||||||
|
"time_comparison"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"x_axis_name": {
|
"x_axis_name": {
|
||||||
@@ -240,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 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||||
|
|
||||||
@@ -55,6 +55,10 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
|
|||||||
|
|
||||||
`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。
|
`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。
|
||||||
|
|
||||||
|
这条恢复链路同样跟踪带 `replay_context` 的调查工单。原异常快照的 `knowledge_cutoff` 和 `batch_sequence` 保留,普通回放查询继续受其限制;工单完工反馈单独触发 Server 的 `ScadaRepository.get_recovery_scada_readings()`,以实际完工业务时间为 T,读取严格晚于 T 的最近一帧。例如 2026-07-16 13:00 完工,15 分钟采样时复核 13:15,而非原异常截止的 08:00。Agent 从 `handling_context.verification_evidence_id` 读取保存的恢复证据,并与原异常证据共同引用,不需放开回放查询或重建复测工单。
|
||||||
|
|
||||||
|
已有调查在收到新的有效完工反馈时会补登记成功派发的关联工单。旧回报误用了电脑当前时间时,需在来源工单更正现场反馈业务时间并提交新版本;系统不猜测完工时刻,也不改写原报告或旧证据。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
@@ -14,12 +14,16 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
|
|||||||
|
|
||||||
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
|
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
|
||||||
|
|
||||||
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
|
普通查询默认20条;目录类最多500条,显式指定设备的时序查询最多10000条、20个标识、24小时;offset 最大10000。SCADA调查首次省略 `query_scada_readings.device_ids`,由Server读取项目完整设备目录及窗口内全部授权观测,内部自动翻页,`offset` 必须为0。其余查询按问题选定对象和窗口。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
|
||||||
|
|
||||||
单份结果超过8MiB报错;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
|
单份结果超过8MiB报错;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
|
||||||
|
|
||||||
`list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。
|
`list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。
|
||||||
|
|
||||||
|
全量查询同时要求设备目录与读数权限;通过任务授权读取时,捕获范围及当前批准状态都必须同时包含 `scada_devices` 和 `scada_readings`,显式设备读数查询仍沿用读数授权。
|
||||||
|
|
||||||
|
全量 `query_scada_readings` 返回 `data.devices` 的完整属性、`data.readings` 的原始和清洗值,以及 `data.coverage` 的查询设备、读数数量和缺测清单;该模式保留完整内联数据,不经过12KB缩略,仍执行8MiB整份结果上限。回放仅读取指定批次之前的冻结观测,项目中未纳入该回放的设备单列 `outside_frozen_scope_device_ids`。`query_complete=true` 表示授权范围内的查询已读完,不表示设备均有有效数据。SCADA及历史回放任务发布final时,必须引用窗口覆盖本轮时刻、且回放批次匹配的全量证据;单设备、局部集合、未引用或旧批次证据不能通过此校验。既有报告不回写,后续重跑适用新约束。
|
||||||
|
|
||||||
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
|
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
|
||||||
|
|
||||||
外围 MCP 直接读取所选项目 BizDB 的工单事实和既有时序。外围工单统一返回 `system_data`;测试数据和 DEMO-* 观测仍按各自数据来源处理。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
|
外围 MCP 直接读取所选项目 BizDB 的工单事实和既有时序。外围工单统一返回 `system_data`;测试数据和 DEMO-* 观测仍按各自数据来源处理。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
|
||||||
@@ -103,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#工单事件主动同步)。
|
||||||
|
|
||||||
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
||||||
|
|
||||||
@@ -190,3 +190,29 @@ OPERATIONAL_TASKS_STORAGE_TEST=1 conda run -n server python -m pytest tests/inte
|
|||||||
```
|
```
|
||||||
|
|
||||||
第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
|
第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
|
||||||
|
|
||||||
|
### 共用数据库时的执行范围
|
||||||
|
|
||||||
|
任务创建时,Server 将 `OPERATIONAL_TASK_EXECUTION_SCOPE`(留空则使用
|
||||||
|
`ENVIRONMENT`)写入 `workbench.operational_tasks.document.execution_scope`。
|
||||||
|
本地配置 `development`,线上配置 `production`;浏览器不提交此字段,
|
||||||
|
重试保留原范围。`execution_scope` 为必填字段,运行时代码不为缺失值兜底。
|
||||||
|
现有任务通过 `resources/sql/034_operational_task_execution_scope.sql` 一次性补为
|
||||||
|
`production`;已有明确范围的任务保持原值。此回填是历史任务的迁移策略,
|
||||||
|
不代表通过旧记录推断出了创建环境。
|
||||||
|
|
||||||
|
迁移需要与新版本切换协调执行:暂停任务写入,执行每个业务库的 SQL,
|
||||||
|
启动新版本并恢复任务写入。旧版本的严格模型不接受新增 JSON 字段,
|
||||||
|
新版本也拒绝未迁移数据;不能在旧版本仍服务任务请求时单独回填。
|
||||||
|
SQL 同时增加必填范围约束,不设置数据库默认值。回滚版本前需同步处理该约束
|
||||||
|
与 JSON 字段,不得直接切回旧镜像。
|
||||||
|
|
||||||
|
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
|
||||||
|
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
|
||||||
|
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
|
||||||
|
`403 EXECUTION_SCOPE_MISMATCH`。外部工单反馈与来源撤回也必须匹配任务的执行范围,
|
||||||
|
并在幂等重放前校验;开发环境同步器不能更新生产任务或重置其恢复监测起点。
|
||||||
|
这属于配置隔离,不能替代服务凭据授权:
|
||||||
|
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
|
||||||
|
|
||||||
|
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
|
||||||
|
|||||||
@@ -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 秒周期运行。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
|
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 和设备集合约束。新回放首次监测以只读一致快照冻结开始前 15 天历史,负序号静默基线按最多 2,000 行分块暂存为 `clean_prepared`;全部读取完成后,在一个 BizDB 短事务中把批次转为 `detected` 并原子发布基线与初始化标记,历史查询和定位对照才能读取。
|
Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 和设备集合约束。新回放首次监测以只读一致快照冻结开始前 15 天历史,负序号静默基线按最多 2,000 行分块暂存为 `clean_prepared`;全部读取完成后,在一个 BizDB 短事务中把批次转为 `detected` 并原子发布基线与初始化标记,历史查询和定位对照才能读取。
|
||||||
|
|
||||||
|
关联现场工单完工后的恢复复核采用独立证据链:Server 以现场反馈业务时间 T 读取项目时序库中严格晚于 T 的最近一帧及事件前对照,保存恢复监测证据后接续原调查。此路径不依赖回放推进,也不修改原异常快照的知识上限或批次;Agent 读取保存的恢复证据,不通过普通回放查询越界取数。详见[恢复复核流程](burst-response-workflow.md)。
|
||||||
|
|
||||||
读取各页期间不持有 BizDB 事务,内存只保留当前页和设备短历史。固定准备预算为 1,000,000 行、512 MiB 序列化批次,覆盖 500 设备十五天每 15 分钟一条所需的 720,000 行并留有余量。超限不保存超限页,状态明确返回 `BASELINE_BUDGET_EXCEEDED` 及中文原因;未发布批次不进入观测、监测或待处理批次。失败重试只清理当前项目、本轮回放的未发布基线,从新的一致源快照重新准备,已发布基线保持不变。
|
读取各页期间不持有 BizDB 事务,内存只保留当前页和设备短历史。固定准备预算为 1,000,000 行、512 MiB 序列化批次,覆盖 500 设备十五天每 15 分钟一条所需的 720,000 行并留有余量。超限不保存超限页,状态明确返回 `BASELINE_BUDGET_EXCEEDED` 及中文原因;未发布批次不进入观测、监测或待处理批次。失败重试只清理当前项目、本轮回放的未发布基线,从新的一致源快照重新准备,已发布基线保持不变。
|
||||||
|
|
||||||
旧回放没有冻结基线时明确拒绝回放前查询,不回落到实时 TSDB。爆管定位支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。[SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明证据限制。运行异常自动建立调查,数据质量异常仅建立告警;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
|
旧回放没有冻结基线时明确拒绝回放前查询,不回落到实时 TSDB。爆管定位支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。[SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明证据限制。运行异常自动建立调查,数据质量异常仅建立告警;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
|
||||||
@@ -159,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) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
|
||||||
@@ -573,7 +575,14 @@ next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上
|
|||||||
|
|
||||||
### 自动播放与简短通知(2026-11-07)
|
### 自动播放与简短通知(2026-11-07)
|
||||||
|
|
||||||
调查任务仍按 `replay_context.knowledge_cutoff` 与 `batch_sequence` 截断知识,后续播放不自动扩大当前调查窗口。任务摘要只保留指标与异常数量,逐测点明细通过冻结 SCADA 证据查询。`scada-anomaly` 要求高嫌疑诊断继续候选定位;当前 Case3 的任务摘要和 skill 响应返回 `location_demo` 能力提示。提示可用时 `run_burst_location` 允许省略 `burst_leakage`,仍先核验冻结异常窗、正常窗及压力设备,再调用固定候选方法;水力算法仍要求漏量依据。候选输出不再含 mock 提示,MCP回放证据保留 `origin=synthetic`,不向 Agent 暴露事件真值。
|
调查任务仍按 `replay_context.knowledge_cutoff` 与 `batch_sequence` 截断知识,后续播放不自动扩大当前调查窗口。任务摘要只保留指标与异常数量,逐测点明细通过冻结 SCADA 证据查询。`scada-anomaly` 要求高嫌疑诊断继续候选定位;当前 Case3 的任务摘要和 skill 响应返回 `location_capability` 能力提示。提示可用时 `run_burst_location` 允许省略 `burst_leakage`,仍先核验冻结异常窗、正常窗及压力设备,再调用固定候选方法;水力算法仍要求漏量依据。候选输出不再含 mock 提示,MCP回放证据保留 `origin=synthetic`,不向 Agent 暴露事件真值。
|
||||||
|
|
||||||
|
Agent可见能力字段统一为 `location_capability`,不再输出旧 `location_demo` 字段。
|
||||||
|
公共指令与 SCADA Skill 明确候选及相似度返回后继续地图展示、隔离、节点覆盖、影响分析与报告,
|
||||||
|
来源字段本身不构成停止条件。有效 SCADA 查询不再将来源说明列为 `data_gaps`;实际缺测、
|
||||||
|
窗口与设备校验仍按原规则处理。现场工单与设备动作维持服务端权限限制,动作拒绝文案明确
|
||||||
|
允许继续已授权分析。旧工具回执和历史消息不改写;既有 OpenCode 进程的 system 配置副本
|
||||||
|
需刷新后才使用新的公共指令。
|
||||||
|
|
||||||
回放调查开放项目资产属性、空间查询、拓扑隔离和节点覆盖,沿用原有业务权限。它们依据当前模型,不代表历史现场状态。实时 TSDB、在线模拟等未接入冻结输入的路径仍不可用;候选定位不以出厂流量命中或现场确诊为前提。
|
回放调查开放项目资产属性、空间查询、拓扑隔离和节点覆盖,沿用原有业务权限。它们依据当前模型,不代表历史现场状态。实时 TSDB、在线模拟等未接入冻结输入的路径仍不可用;候选定位不以出厂流量命中或现场确诊为前提。
|
||||||
|
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
|
-- Run against each project business database during the coordinated Server cutover.
|
||||||
|
-- Historical tasks are explicitly assigned to production; retain existing scopes.
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '3s';
|
||||||
|
SET LOCAL statement_timeout = '30s';
|
||||||
|
|
||||||
|
UPDATE workbench.operational_tasks
|
||||||
|
SET document = jsonb_set(document, '{execution_scope}', '"production"'::jsonb)
|
||||||
|
WHERE NOT (document ? 'execution_scope');
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'workbench.operational_tasks'::regclass
|
||||||
|
AND conname = 'operational_tasks_execution_scope_required'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE workbench.operational_tasks
|
||||||
|
ADD CONSTRAINT operational_tasks_execution_scope_required CHECK (
|
||||||
|
(jsonb_typeof(document->'execution_scope') = 'string'
|
||||||
|
AND length(btrim(document->>'execution_scope')) > 0) IS TRUE
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -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,7 +2,7 @@
|
|||||||
name: burst-event-handling
|
name: burst-event-handling
|
||||||
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.1"
|
version: "1.1.2"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管事件处理
|
# 爆管事件处理
|
||||||
@@ -50,4 +50,4 @@ metadata:
|
|||||||
|
|
||||||
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
|
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
|
||||||
|
|
||||||
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,发布 final/concluded 报告并引用该证据。运行完成后 Server 才完成任务并解除关联告警。原事件在来源系统的行政结案仍由有权限人员确认。
|
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,工单 completed 且恢复证据 status=passed 时发布 final/concluded 恢复闭环报告并引用该证据。Server 完成运行后关闭任务并解除关联告警,phase=completed 表示已闭环。照片、阀位或复测记录未提供时仅记为资料缺项,不增设关闭前置条件。来源系统若另有明确行政结案流程,按其实际状态另记,不推断为当前任务尚未闭环。
|
||||||
|
|||||||
@@ -2,22 +2,22 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.8"
|
version: "1.0.15"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SCADA 回放异常调查
|
# SCADA 数据异常调查
|
||||||
|
|
||||||
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。来源标注为已存 SCADA 的演示回放;同一持续事件沿用已有调查。
|
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
|
||||||
|
|
||||||
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。
|
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
||||||
|
|
||||||
## 数据核验与诊断
|
## 数据核验与诊断
|
||||||
|
|
||||||
任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。
|
任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。
|
||||||
|
|
||||||
通过 query_scada_readings 读取冻结异常窗和正常对照,按需核查开始前15天基线;查询结束时间止于 knowledge_cutoff,数据批次止于 batch_sequence。复用已有监测回执。结合 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和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
||||||
|
|
||||||
设备目录用 list_scada_devices,默认每页100个;page.has_more=true时递增offset继续读取,直到覆盖本轮目标设备。大目录内联可能仅包含本页全部设备的编号、类型和单位,模型映射与坐标用get_scada_device核验。读数查询的limit按设备数乘时间点数安排,检查page.has_more;缩略响应通过evidence_read指定的工具和data_path分小页读取已保存证据,不能把首屏、首个设备或属性缩略当作完整覆盖。
|
全量响应中的devices包含完整目录属性,readings包含实际读数,coverage列出全部查询设备、各设备读数数量、缺测和冻结范围外设备。不能用代表设备推断全网。单独读取list_scada_devices时,默认每页100个,持续翻页到page.has_more=false;属性缩略不等于已读取完整设备信息。全量读取后可显式指定device_ids做局部复核,局部读数仍按page.has_more接续,不能替代全量输入。最终报告引用覆盖本轮knowledge_cutoff和batch_sequence的全量证据,Server会拒绝仅有局部或旧批次证据的final。
|
||||||
|
|
||||||
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
||||||
|
|
||||||
@@ -25,20 +25,32 @@ metadata:
|
|||||||
|
|
||||||
综合分达到嫌疑门槛,或压力与模型证据支持高嫌疑时,继续候选定位。流量变化和现场确认作为证据维度,定位输入按当前工具要求准备。
|
综合分达到嫌疑门槛,或压力与模型证据支持高嫌疑时,继续候选定位。流量变化和现场确认作为证据维度,定位输入按当前工具要求准备。
|
||||||
|
|
||||||
检查摘要或 skill 响应顶层的 location_demo。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界与影响分析。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
||||||
|
|
||||||
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
||||||
|
|
||||||
## 对比展示与影响
|
## 对比展示与影响
|
||||||
|
|
||||||
先发布覆盖本轮全部已标识SCADA设备的当前趋势总览,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。
|
监测图必填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生成。恢复复核沿用恢复门禁。
|
||||||
|
|
||||||
定位输入具备后继续推进定位;七天对照作为总览之外的补充,按设备分图,不能因历史散点占用序列额度而遗漏其他设备的当前趋势。复合图采用 [t-3小时,t] 当前折线和前7天每天 t-30分钟、t-15分钟、t 的21个参考位置。15分钟数据用13槽,当前一个line、历史七个scatter;历史散点只填末3槽,其他为null。sample_times保留真实带时区时刻,坐标轴注明单位。各采样周期保留实际缺口,按需翻页并报告实际参考点数。
|
先查询并发布以本轮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的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
|
||||||
|
|
||||||
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
|
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
|
||||||
|
|
||||||
## 范围与报告
|
## 范围与报告
|
||||||
|
|
||||||
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。演示定位继续执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
||||||
|
|
||||||
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。演示分析只发布回放成果,本轮分析完成与现场事件闭环分别说明。
|
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。
|
||||||
|
|
||||||
|
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
|
||||||
|
|
||||||
|
## 完工后的恢复复核
|
||||||
|
|
||||||
|
收到关联工单的completed反馈后,先读取get_task_context摘要中的handling_context,核对实际完工业务时间returned_at,不以工单创建、预约或电脑当前时间代替。Server通过现有恢复监测读取项目时序库中严格晚于returned_at的最近一帧及事件前15天同相位对照,独立保存恢复证据;例如13:00完工、15分钟采样时核对13:15。这条恢复链路适用于回放来源工单,不要求推进回放,原异常knowledge_cutoff与batch_sequence保持不变。
|
||||||
|
|
||||||
|
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
||||||
|
|
||||||
|
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
||||||
|
|||||||
@@ -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,11 +90,13 @@ class CredentialProvider:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._expires = 0.0
|
self._expires = 0.0
|
||||||
|
self._refresh_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
||||||
if self.settings.bearer:
|
if self.settings.bearer:
|
||||||
token = self.settings.bearer.removeprefix("Bearer ")
|
token = self.settings.bearer.removeprefix("Bearer ")
|
||||||
else:
|
else:
|
||||||
|
async with self._refresh_lock:
|
||||||
if time.monotonic() >= self._expires:
|
if time.monotonic() >= self._expires:
|
||||||
response = await client.post(self.settings.token_url, data={
|
response = await client.post(self.settings.token_url, data={
|
||||||
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
||||||
|
|||||||
+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)
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ def test_location_capability_and_tool_contract_do_not_inject_mock_instructions()
|
|||||||
assert "mock" not in str(context) and "模拟" not in context["notice"]
|
assert "mock" not in str(context) and "模拟" not in context["notice"]
|
||||||
tool = TOOLS["run_burst_location"].protocol_tool()
|
tool = TOOLS["run_burst_location"].protocol_tool()
|
||||||
assert "相似度" in tool.description
|
assert "相似度" in tool.description
|
||||||
|
assert "location_capability.available=true" in tool.description
|
||||||
|
assert "location_demo" not in tool.description
|
||||||
assert "demo模拟候选" not in tool.description
|
assert "demo模拟候选" not in tool.description
|
||||||
assert "明确标记的演示候选" not in str(tool.input_schema)
|
assert "明确标记的演示候选" not in str(tool.input_schema)
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.mcp import query_service, scada_readings
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
from app.mcp.models import ScadaInput
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.services.operational_scada_coverage import require_complete_scada_evidence
|
||||||
|
|
||||||
|
|
||||||
|
START = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _args(**extra):
|
||||||
|
return ScadaInput(context_handle="a" * 43, start=START, end=START + timedelta(hours=1), **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_scada_reads_every_catalogue_and_observation_page_with_full_attributes(monkeypatch):
|
||||||
|
devices = [{"device_id": f"D{i}", "device_type": ["pressure", "pipe_flow", "quality"][i % 3],
|
||||||
|
"measurement_unit": ["m", "m3/h", "mg/L"][i % 3], "node_id": f"J{i}",
|
||||||
|
"longitude": 121.5, "latitude": 31.0, "reliability": 1} for i in range(503)]
|
||||||
|
rows = [{"device_id": f"D{i % 502}", "time": START.isoformat(),
|
||||||
|
"monitored_value": float(i), "cleaned_value": None} for i in range(1205)]
|
||||||
|
|
||||||
|
async def catalog(connection, *, limit, offset):
|
||||||
|
return devices[offset:offset + limit]
|
||||||
|
|
||||||
|
async def series(connection, **args):
|
||||||
|
assert args["ids"] == [device["device_id"] for device in devices]
|
||||||
|
assert args["field"] is None
|
||||||
|
return rows[args["offset"]:args["offset"] + args["limit"]]
|
||||||
|
|
||||||
|
catalog_reader, series_reader = AsyncMock(side_effect=catalog), AsyncMock(side_effect=series)
|
||||||
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", catalog_reader)
|
||||||
|
monkeypatch.setattr(scada_readings.reads, "series_rows", series_reader)
|
||||||
|
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def timescale(): yield object()
|
||||||
|
|
||||||
|
scope = SimpleNamespace(connection=object(), timescale=timescale)
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, SimpleNamespace(replay_context=None),
|
||||||
|
TOOLS["query_scada_readings"], _args()))
|
||||||
|
assert result.data["devices"] == devices and result.data["readings"] == rows
|
||||||
|
assert [call.kwargs["offset"] for call in catalog_reader.await_args_list] == [0, 500]
|
||||||
|
assert [call.kwargs["offset"] for call in series_reader.await_args_list] == [0, 1000]
|
||||||
|
coverage = result.data["coverage"]
|
||||||
|
assert coverage["query_complete"] is True and coverage["queried_device_count"] == 503
|
||||||
|
assert coverage["devices_with_readings"] == 502 and coverage["missing_device_ids"] == ["D502"]
|
||||||
|
assert coverage["reading_count"] == 1205 and len(result.units) == 503
|
||||||
|
assert result.origin == "system_data" and result.page is None and not result.truncated
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypatch):
|
||||||
|
devices = [{"device_id": key, "device_type": kind, "measurement_unit": unit, "node_id": key}
|
||||||
|
for key, kind, unit in [("P1", "pressure", "m"), ("Q1", "quality", "mg/L"), ("NEW", "pressure", "m")]]
|
||||||
|
rows = [{"device_id": "P1", "time": START.isoformat(), "monitored_value": 20.,
|
||||||
|
"cleaned_value": 20., "quality": "valid", "reason": "冻结观测", "source_hash": "internal-hash"}]
|
||||||
|
context = {"replay_id": str(uuid4()), "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||||
|
"batch_sequence": 9}
|
||||||
|
repository = SimpleNamespace(
|
||||||
|
get=AsyncMock(return_value={"start_time": START.isoformat(), "config": {"device_ids": ["P1", "Q1"]},
|
||||||
|
"devices": devices[:2]}),
|
||||||
|
detected_outputs=AsyncMock(return_value=rows),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=devices))
|
||||||
|
monkeypatch.setattr(scada_readings, "ScadaReplayRepository", lambda *args: repository)
|
||||||
|
timescale = AsyncMock(side_effect=AssertionError("Frozen reads must never use live TSDB"))
|
||||||
|
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4()), timescale=timescale)
|
||||||
|
data, units, gaps, origin = asyncio.run(scada_readings.read_complete_scada(
|
||||||
|
scope, SimpleNamespace(replay_context=context), _args(),
|
||||||
|
))
|
||||||
|
assert data["devices"] == devices
|
||||||
|
assert data["readings"] == [{key: value for key, value in rows[0].items() if key != "source_hash"}]
|
||||||
|
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
||||||
|
assert data["coverage"]["missing_device_ids"] == ["Q1"]
|
||||||
|
assert data["coverage"]["outside_frozen_scope_device_ids"] == ["NEW"]
|
||||||
|
assert data["coverage"]["frozen_start"] == START.isoformat()
|
||||||
|
repository.detected_outputs.assert_awaited_once_with(
|
||||||
|
context["replay_id"], ["P1", "Q1"], START, START + timedelta(hours=1), 9, limit=1001, offset=0,
|
||||||
|
)
|
||||||
|
assert origin == "synthetic" and len(gaps) == 2 and units == {"P1": "m", "Q1": "mg/L"}
|
||||||
|
timescale.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_scada_empty_catalogue_does_not_query_unfiltered_timeseries(monkeypatch):
|
||||||
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[]))
|
||||||
|
timescale = AsyncMock(side_effect=AssertionError("An empty catalogue must not select every TSDB row"))
|
||||||
|
data, units, _, _ = asyncio.run(scada_readings.read_complete_scada(
|
||||||
|
SimpleNamespace(connection=object(), timescale=timescale), SimpleNamespace(replay_context=None), _args(),
|
||||||
|
))
|
||||||
|
assert data["coverage"]["queried_device_count"] == 0 and data["readings"] == [] and units == {}
|
||||||
|
timescale.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_scada_rejects_offset_and_empty_explicit_selection():
|
||||||
|
for values in ({"offset": 1}, {"device_ids": []}):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_args(**values)
|
||||||
|
assert _args(device_ids=["P1"], offset=100).offset == 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("captured,approved,explicit,direct,allowed", [
|
||||||
|
({"scada_readings", "scada_devices"}, {"scada_readings"}, False, False, False),
|
||||||
|
({"scada_readings"}, {"scada_readings", "scada_devices"}, False, False, False),
|
||||||
|
({"scada_readings", "scada_devices"}, {"scada_devices"}, False, False, False),
|
||||||
|
({"scada_readings", "scada_devices"}, {"scada_readings", "scada_devices"}, False, False, True),
|
||||||
|
({"scada_readings"}, {"scada_readings"}, True, False, True),
|
||||||
|
(set(), set(), False, True, True),
|
||||||
|
])
|
||||||
|
def test_complete_scada_requires_both_captured_and_current_source_grants(
|
||||||
|
monkeypatch, captured, approved, explicit, direct, allowed,
|
||||||
|
):
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction():
|
||||||
|
yield
|
||||||
|
|
||||||
|
can_read = AsyncMock(side_effect=lambda task_id, source: source in approved)
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"scada.view"} if direct else set())
|
||||||
|
monkeypatch.setattr(query_service, "task_service", lambda _: SimpleNamespace(can_read_source=can_read))
|
||||||
|
binding = SimpleNamespace(
|
||||||
|
replay_context=None, tool_cap=["query_scada_readings"],
|
||||||
|
permission_cap=["scada.view"] if direct else [], task_id=uuid4(), task_scope_cap=captured,
|
||||||
|
)
|
||||||
|
scope = SimpleNamespace(project=object(), connection=SimpleNamespace(transaction=transaction))
|
||||||
|
args = _args(**({"device_ids": ["P1"]} if explicit else {}))
|
||||||
|
call = query_service.authorize_query(scope, binding, TOOLS["query_scada_readings"], args)
|
||||||
|
if allowed:
|
||||||
|
asyncio.run(call)
|
||||||
|
else:
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(call)
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
if direct:
|
||||||
|
can_read.assert_not_awaited()
|
||||||
|
elif allowed:
|
||||||
|
expected = {"scada_readings"} if explicit else {"scada_readings", "scada_devices"}
|
||||||
|
assert {call.args[1] for call in can_read.await_args_list} == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_scada_reports_oversize_instead_of_successfully_returning_a_partial_scan(monkeypatch):
|
||||||
|
monkeypatch.setattr(scada_readings.reads, "scada_devices", AsyncMock(return_value=[
|
||||||
|
{"device_id": "P1", "description": "x" * 200},
|
||||||
|
]))
|
||||||
|
monkeypatch.setattr(scada_readings, "MAX_COMPLETE_SCADA_BYTES", 100)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(scada_readings.read_complete_scada(
|
||||||
|
SimpleNamespace(connection=object()), SimpleNamespace(replay_context=None), _args(),
|
||||||
|
))
|
||||||
|
assert error.value.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("defect", [None, "old_batch", "other_replay", "old_window", "unreferenced", "truncated"])
|
||||||
|
def test_final_scada_evidence_must_match_the_current_frozen_scope(defect):
|
||||||
|
replay_id = str(uuid4())
|
||||||
|
replay = {"replay_id": replay_id, "knowledge_cutoff": START.isoformat(), "batch_sequence": 9}
|
||||||
|
task = {"workflow": "burst_report", "source": {"replay_context": replay}}
|
||||||
|
coverage = {"mode": "all_scada", "query_complete": True, "replay_id": replay_id, "batch_sequence": 9}
|
||||||
|
window = {"start": (START - timedelta(hours=1)).isoformat(), "end": START.isoformat()}
|
||||||
|
if defect == "old_batch": coverage["batch_sequence"] = 8
|
||||||
|
if defect == "other_replay": coverage["replay_id"] = str(uuid4())
|
||||||
|
if defect == "old_window": window["end"] = (START - timedelta(minutes=15)).isoformat()
|
||||||
|
evidence = [{"evidence_id": "mcp:all", "data_snapshot": {
|
||||||
|
"source": "query_scada_readings", "truncated": defect == "truncated",
|
||||||
|
"data": {"data": {"coverage": coverage}, "window": window},
|
||||||
|
}}]
|
||||||
|
refs = [] if defect == "unreferenced" else ["mcp:all"]
|
||||||
|
if defect is None:
|
||||||
|
require_complete_scada_evidence(task, evidence, refs)
|
||||||
|
else:
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
require_complete_scada_evidence(task, evidence, refs)
|
||||||
|
assert error.value.code == "SCADA_COVERAGE_REQUIRED"
|
||||||
@@ -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}]})
|
||||||
@@ -774,6 +774,34 @@ def test_scada_catalog_default_100_paginates_without_changing_other_queries(runt
|
|||||||
assert TOOLS["list_elements"].model(context_handle=value.context_handle, element_type="pipe").limit == 20
|
assert TOOLS["list_elements"].model(context_handle=value.context_handle, element_type="pipe").limit == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("complete", [False, True])
|
||||||
|
def test_complete_scada_input_is_not_replaced_by_12kb_preview_but_details_stay_bounded(runtime, complete):
|
||||||
|
app, client, _, _ = runtime
|
||||||
|
value = binding()
|
||||||
|
data = {"devices": [{"device_id": f"D{index}", "node_id": f"J{index}", "description": "x" * 100}
|
||||||
|
for index in range(168)],
|
||||||
|
"readings": [{"device_id": f"D{index}", "monitored_value": 20., "cleaned_value": 19.}
|
||||||
|
for index in range(168)],
|
||||||
|
"coverage": {"mode": "all_scada", "query_complete": True, "queried_device_count": 168}}
|
||||||
|
assert len(json.dumps(data).encode()) > 12000
|
||||||
|
app.executor = AsyncMock(return_value=QueryResult(
|
||||||
|
source="query_scada_readings", source_ref="mcp:all-scada", queried_at=datetime.now(timezone.utc),
|
||||||
|
semantics="observed", data=data,
|
||||||
|
))
|
||||||
|
arguments = {"context_handle": value.context_handle, "start": "2026-07-16T05:00:00+08:00",
|
||||||
|
"end": "2026-07-16T08:00:00+08:00"}
|
||||||
|
if not complete:
|
||||||
|
arguments["device_ids"] = ["D1"]
|
||||||
|
response = request(client, "tools/call", {"name": "query_scada_readings", "arguments": arguments}).json()["result"]
|
||||||
|
if complete:
|
||||||
|
assert not response.get("isError"), response
|
||||||
|
assert response["structuredContent"]["data"] == data
|
||||||
|
assert response["structuredContent"]["truncated"] is False
|
||||||
|
else:
|
||||||
|
assert response["isError"] is True
|
||||||
|
assert response["structuredContent"]["error"]["code"] == "RESULT_TOO_LARGE"
|
||||||
|
|
||||||
|
|
||||||
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
|
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
|
||||||
app, client, _, _ = runtime
|
app, client, _, _ = runtime
|
||||||
value = binding(task=True)
|
value = binding(task=True)
|
||||||
@@ -1088,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)]
|
||||||
@@ -49,6 +49,16 @@ def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills():
|
|||||||
assert "run_burst_location" in replay["instructions"]
|
assert "run_burst_location" in replay["instructions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scada_instructions_advance_candidate_analysis_independently_of_source_labels():
|
||||||
|
skill = read_work_order_skill("scada_anomaly", include_content=True)
|
||||||
|
text = skill["instructions"]
|
||||||
|
assert "location_capability" in text
|
||||||
|
assert "证据来源字段用于记录数据取得方式,不构成停止分析的条件" in text
|
||||||
|
assert "隔离边界、节点覆盖与影响分析,完成报告后再收尾" in text
|
||||||
|
assert "现场工单与设备操作遵守服务端动作权限" in text
|
||||||
|
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
|
||||||
|
|
||||||
|
|
||||||
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
|
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
|
||||||
task_id, project_id = uuid4(), uuid4()
|
task_id, project_id = uuid4(), uuid4()
|
||||||
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
|
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
|
||||||
@@ -103,7 +113,8 @@ def test_replay_context_advertises_demo_location_without_revealing_truth(monkeyp
|
|||||||
for section in ("summary", "skill"):
|
for section in ("summary", "skill"):
|
||||||
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
||||||
TaskContextInput(context_handle=binding.context_handle, section=section)))
|
TaskContextInput(context_handle=binding.context_handle, section=section)))
|
||||||
capability = result.data["location_demo"]
|
capability = result.data["location_capability"]
|
||||||
|
assert "location_demo" not in result.data
|
||||||
assert capability["available"] is True
|
assert capability["available"] is True
|
||||||
assert capability["tool"] == "run_burst_location"
|
assert capability["tool"] == "run_burst_location"
|
||||||
assert capability["burst_leakage_required"] is False
|
assert capability["burst_leakage_required"] is False
|
||||||
@@ -113,4 +124,4 @@ def test_replay_context_advertises_demo_location_without_revealing_truth(monkeyp
|
|||||||
binding.replay_context["knowledge_cutoff"] = "2026-07-15T23:45:00Z"
|
binding.replay_context["knowledge_cutoff"] = "2026-07-15T23:45:00Z"
|
||||||
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
|
||||||
TaskContextInput(context_handle=binding.context_handle)))
|
TaskContextInput(context_handle=binding.context_handle)))
|
||||||
assert result.data["location_demo"]["available"] is False
|
assert result.data["location_capability"]["available"] is False
|
||||||
|
|||||||
@@ -97,8 +97,13 @@ def test_off_grid_feedback_uses_first_later_sample_even_without_monitoring_inter
|
|||||||
assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat()
|
assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time():
|
@pytest.mark.parametrize("replay", [False, True])
|
||||||
|
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time(replay):
|
||||||
task = {"workflow": "burst_report"}
|
task = {"workflow": "burst_report"}
|
||||||
|
if replay:
|
||||||
|
task["source"] = {"replay_context": {
|
||||||
|
"replay_id": str(uuid4()), "knowledge_cutoff": INCIDENT.isoformat(), "batch_sequence": 3,
|
||||||
|
}}
|
||||||
for order_id in ("one", "two"):
|
for order_id in ("one", "two"):
|
||||||
track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded",
|
track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded",
|
||||||
"receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
"receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
||||||
@@ -120,6 +125,9 @@ def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_ti
|
|||||||
assert task["handling_context"]["returned_at"] == corrected.isoformat()
|
assert task["handling_context"]["returned_at"] == corrected.isoformat()
|
||||||
assert task["handling_context"]["phase"] == "waiting_scada"
|
assert task["handling_context"]["phase"] == "waiting_scada"
|
||||||
assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"]
|
assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"]
|
||||||
|
if replay:
|
||||||
|
assert task["source"]["replay_context"]["knowledge_cutoff"] == INCIDENT.isoformat()
|
||||||
|
assert task["source"]["replay_context"]["batch_sequence"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_field_feedback_time_requires_a_real_note_and_explicit_timezone():
|
def test_field_feedback_time_requires_a_real_note_and_explicit_timezone():
|
||||||
|
|||||||
@@ -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,220 +228,48 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
|
|||||||
assert exc.value.code == "PERMISSION_DENIED"
|
assert exc.value.code == "PERMISSION_DENIED"
|
||||||
|
|
||||||
|
|
||||||
def _task_document(context, now, *, lease=None):
|
|
||||||
task_id = uuid4()
|
|
||||||
return OperationalTaskSummary(
|
|
||||||
project_id=context.project_id,
|
|
||||||
task_id=task_id,
|
|
||||||
task_type="event_investigation",
|
|
||||||
workflow="water_quality_complaint",
|
|
||||||
status="running",
|
|
||||||
priority="normal",
|
|
||||||
revision=2,
|
|
||||||
execution_generation=1,
|
|
||||||
execution_request_id=uuid4(),
|
|
||||||
title="调查",
|
|
||||||
summary="验证状态机",
|
|
||||||
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
|
||||||
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
|
||||||
scheduled_for=None,
|
|
||||||
agent_session_id="session-1",
|
|
||||||
agent_lease_expires_at=lease,
|
|
||||||
current_plan_revision=None,
|
|
||||||
current_report_revision=None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskRepository:
|
|
||||||
def __init__(self, task, *, action=None):
|
|
||||||
self.task = task
|
|
||||||
self.action = action
|
|
||||||
self.requests = {}
|
|
||||||
self.evidence = {}
|
|
||||||
self.feedback = {}
|
|
||||||
self.plans = []
|
|
||||||
self.reports = []
|
|
||||||
self.progress = {}
|
|
||||||
self.notifications = []
|
|
||||||
self.locks = []
|
|
||||||
self.access_requests = {}
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def transaction(self):
|
|
||||||
yield self
|
|
||||||
|
|
||||||
async def lock(self, key):
|
|
||||||
self.locks.append(key)
|
|
||||||
|
|
||||||
async def get_request(self, scope, request_id):
|
|
||||||
return self.requests.get((scope, request_id))
|
|
||||||
|
|
||||||
async def save_request(self, scope, request_id, request_digest, response):
|
|
||||||
self.requests[(scope, request_id)] = {
|
|
||||||
"request_digest": request_digest,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_task(self, task_id):
|
|
||||||
return self.task if task_id == self.task["task_id"] else None
|
|
||||||
|
|
||||||
async def update_task(self, task):
|
|
||||||
self.task = task
|
|
||||||
|
|
||||||
async def get_latest_plan(self, _task_id):
|
|
||||||
return self.plans[-1] if self.plans else None
|
|
||||||
|
|
||||||
async def get_latest_report(self, _task_id):
|
|
||||||
return self.reports[-1] if self.reports else None
|
|
||||||
|
|
||||||
async def get_latest_progress(self, task_id, generation):
|
|
||||||
return self.progress.get((task_id, generation))
|
|
||||||
|
|
||||||
async def save_progress(self, task_id, snapshot):
|
|
||||||
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
|
||||||
|
|
||||||
async def get_report(self, _task_id, revision):
|
|
||||||
return next((item for item in self.reports if item["revision"] == revision), None)
|
|
||||||
|
|
||||||
async def list_reports(self, _task_id, limit, offset):
|
|
||||||
return list(reversed(self.reports))[offset:offset + limit]
|
|
||||||
|
|
||||||
async def insert_plan(self, _task_id, snapshot):
|
|
||||||
self.plans.append(snapshot)
|
|
||||||
|
|
||||||
async def insert_report(self, _task_id, snapshot):
|
|
||||||
self.reports.append(snapshot)
|
|
||||||
|
|
||||||
async def list_evidence(self, _task_id):
|
|
||||||
return list(self.evidence.values())
|
|
||||||
|
|
||||||
async def get_evidence(self, _task_id, evidence_id):
|
|
||||||
return self.evidence.get(evidence_id)
|
|
||||||
|
|
||||||
async def insert_evidence(self, _task_id, evidence):
|
|
||||||
self.evidence[evidence["evidence_id"]] = evidence
|
|
||||||
|
|
||||||
async def list_access_requests(self, _task_id):
|
|
||||||
return list(self.access_requests.values())
|
|
||||||
|
|
||||||
async def get_access_request(self, _task_id, access_request_id):
|
|
||||||
return self.access_requests.get(access_request_id)
|
|
||||||
|
|
||||||
async def insert_access_request(self, _task_id, document):
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def update_access_request(self, _task_id, document, expected_version):
|
|
||||||
assert document["version"] == expected_version + 1
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def list_actions(self, _task_id):
|
|
||||||
return [self.action] if self.action else []
|
|
||||||
|
|
||||||
async def get_action(self, _task_id, action_id):
|
|
||||||
if self.action and self.action["action_id"] == action_id:
|
|
||||||
return self.action
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_notification(self, notification):
|
|
||||||
self.notifications.append(notification)
|
|
||||||
|
|
||||||
async def resolve_notifications(
|
|
||||||
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
|
||||||
):
|
|
||||||
resolved = []
|
|
||||||
for notification in self.notifications:
|
|
||||||
if (
|
|
||||||
notification["task_id"] == task_id
|
|
||||||
and notification["kind"] in kinds
|
|
||||||
and notification.get("lifecycle_status", "active") == "active"
|
|
||||||
and notification.get("subject_type") == subject_type
|
|
||||||
and notification.get("subject_id") == subject_id
|
|
||||||
):
|
|
||||||
notification.update(
|
|
||||||
lifecycle_status="resolved",
|
|
||||||
resolved_at=resolved_at.isoformat(),
|
|
||||||
resolution_reason=resolution_reason,
|
|
||||||
)
|
|
||||||
resolved.append(notification.copy())
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
async def get_feedback(self, task_id, source_event_id):
|
|
||||||
return self.feedback.get((task_id, source_event_id))
|
|
||||||
|
|
||||||
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
|
||||||
self.feedback[(task_id, feedback["source_event_id"])] = {
|
|
||||||
"source_revision": feedback["source_revision"],
|
|
||||||
"document": feedback,
|
|
||||||
"accepted_at": accepted_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RunTaskRepository(TaskRepository):
|
|
||||||
def __init__(self, task, run):
|
|
||||||
super().__init__(task)
|
|
||||||
self.run = run
|
|
||||||
self.events = []
|
|
||||||
|
|
||||||
async def get_active_run(self, task_id):
|
|
||||||
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
|
||||||
return self.run
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_run(self, run_id):
|
|
||||||
return self.run if run_id == self.run["run_id"] else None
|
|
||||||
|
|
||||||
async def require_execution_storage(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_run(self, run, expected_revision):
|
|
||||||
assert run["revision"] == expected_revision + 1
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def insert_run(self, run):
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def append_event(self, **event):
|
|
||||||
self.events.append(event)
|
|
||||||
return len(self.events)
|
|
||||||
|
|
||||||
async def current_event_cursor(self):
|
|
||||||
return len(self.events)
|
|
||||||
|
|
||||||
async def list_presentation_events(self, task_id, after, through, limit):
|
|
||||||
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
|
||||||
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
|
||||||
and event["aggregate_id"] == task_id][:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def _active_run(task, now):
|
|
||||||
return OperationalTaskRun(
|
|
||||||
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
|
||||||
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
|
||||||
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
|
||||||
next_dispatch_at=now, created_at=now, updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_run_claim_persists_agent_instance_ownership():
|
@pytest.mark.parametrize("task_scope,worker_scope,allowed", [
|
||||||
|
(None, "production", False),
|
||||||
|
(None, "development", False),
|
||||||
|
("production", "production", True),
|
||||||
|
("production", "development", False),
|
||||||
|
("development", "production", False),
|
||||||
|
("development", "development", True),
|
||||||
|
])
|
||||||
|
async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_scope, allowed):
|
||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=None)
|
task = _task_document(context, now, lease=None)
|
||||||
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
|
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
|
||||||
|
if task_scope is None:
|
||||||
|
task.pop("execution_scope", None)
|
||||||
|
else:
|
||||||
|
task["execution_scope"] = task_scope
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run.update(status="queued", revision=1, agent_session_id=None,
|
run.update(status="queued", revision=1, agent_session_id=None,
|
||||||
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
||||||
|
execution_scope=worker_scope)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
with pytest.raises(DomainError) as failure:
|
||||||
|
await service.claim_run(task["task_id"], run["run_id"], AgentRunCommand(
|
||||||
|
request_id=run["execution_request_id"], expected_revision=1,
|
||||||
|
source_session_id="session-1", agent_instance_id="agent-a"))
|
||||||
|
assert failure.value.code == "EXECUTION_SCOPE_MISMATCH"
|
||||||
|
assert repository.run["status"] == "queued"
|
||||||
|
assert repository.task["status"] == "queued"
|
||||||
|
return
|
||||||
|
|
||||||
claimed = await service.claim_run(
|
claimed = await service.claim_run(
|
||||||
task["task_id"], run["run_id"],
|
task["task_id"], run["run_id"],
|
||||||
@@ -464,7 +288,7 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
first = AgentArtifactsCommand.model_validate({
|
first = AgentArtifactsCommand.model_validate({
|
||||||
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
||||||
@@ -490,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
|
||||||
|
|
||||||
|
|
||||||
@@ -511,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"}
|
||||||
@@ -532,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(
|
||||||
@@ -558,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"]
|
||||||
@@ -577,14 +402,14 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_replay_investigation_can_publish_evidenced_composite_chart_but_cannot_propose_real_action():
|
async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
||||||
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))
|
||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
||||||
@@ -593,44 +418,117 @@ async def test_replay_investigation_can_publish_evidenced_composite_chart_but_ca
|
|||||||
result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||||
command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id],
|
command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id],
|
||||||
"chart": {"title": "压力", "chart_type": "line", "x_data": ["19:45", "20:00"],
|
"chart": {"title": "压力", "chart_type": "time_comparison", "x_data": ["19:45", "20:00"],
|
||||||
"y_axis_name": "压力 m", "series": [
|
"y_axis_name": "压力 m", "series": [
|
||||||
{"name": "当前", "type": "line", "data": [27., 26.]},
|
{"name": "当前", "type": "line", "data": [27., 26.],
|
||||||
|
"sample_times": ["2026-10-10T19:45:00+08:00", "2026-10-10T20:00:00+08:00"]},
|
||||||
{"name": "前一天", "type": "scatter", "data": [None, 28.],
|
{"name": "前一天", "type": "scatter", "data": [None, 28.],
|
||||||
"sample_times": [None, "2026-10-09T20:00:00+08:00"]},
|
"sample_times": [None, "2026-10-09T20:00:00+08:00"]},
|
||||||
]}},
|
]}},
|
||||||
))
|
))
|
||||||
assert result.recorded
|
assert result.recorded
|
||||||
assert repository.events[-1]["metadata"]["command"]["chart"]["series"][1]["data"] == [None, 28.]
|
assert repository.events[-1]["metadata"]["command"]["chart"]["series"][1]["data"] == [None, 28.]
|
||||||
with pytest.raises(DomainError) as caught:
|
_seed_concluded_report(repository, now)
|
||||||
await service.propose_action(task["task_id"], ActionProposalCommand(
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
request_id=uuid4(), kind="create_work_order", reason="演示不得创建真实工单",
|
request_id=uuid4(), kind="create_work_order", reason="候选管段待现场核验",
|
||||||
|
evidence_refs=[evidence.evidence_id],
|
||||||
payload={"title": "现场核查", "description": "核查", "work_order_type": "general"},
|
payload={"title": "现场核查", "description": "核查", "work_order_type": "general"},
|
||||||
))
|
))
|
||||||
assert caught.value.code == "REPLAY_ACTION_DISABLED"
|
assert action.approval_status == "pending"
|
||||||
|
assert action.execution_status == "not_submitted"
|
||||||
|
assert repository.action["action_id"] == str(action.action_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize("operation", ["draft", "approve", "submit"])
|
@pytest.mark.parametrize("destination", ["presentation", "report"])
|
||||||
async def test_replay_cannot_edit_approve_or_submit_preexisting_real_actions(operation):
|
@pytest.mark.parametrize("defect", [None, "legacy_type", "dates_on_axis", "missing_times", "wrong_slot", "future_day"])
|
||||||
|
async def test_comparison_chart_publication_is_validated_at_both_write_boundaries(destination, defect):
|
||||||
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
||||||
|
origin="synthetic", data={"items": []}, truncated=False,
|
||||||
|
))
|
||||||
|
chart = {"title": "同期对照", "chart_type": "time_comparison", "x_data": ["08:00"], "series": [
|
||||||
|
{"name": "当前", "data": [25.], "sample_times": ["2026-10-10T08:00:00+08:00"]},
|
||||||
|
{"name": "历史", "type": "scatter", "data": [26.], "sample_times": ["2026-10-09T08:00:00+08:00"]},
|
||||||
|
]}
|
||||||
|
if defect == "legacy_type":
|
||||||
|
chart["chart_type"] = "line"
|
||||||
|
elif defect == "dates_on_axis":
|
||||||
|
chart["x_data"] = ["10-10 08:00"]
|
||||||
|
elif defect == "missing_times":
|
||||||
|
del chart["series"][1]["sample_times"]
|
||||||
|
elif defect == "wrong_slot":
|
||||||
|
chart["series"][1]["sample_times"] = ["2026-10-09T07:45:00+08:00"]
|
||||||
|
elif defect == "future_day":
|
||||||
|
chart["series"][1]["sample_times"] = ["2026-10-11T08:00:00+08:00"]
|
||||||
|
events_before = len(repository.events)
|
||||||
|
|
||||||
|
async def publish():
|
||||||
|
if destination == "presentation":
|
||||||
|
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||||
|
command={"action": "open_chart", "chart_id": "comparison", "evidence_refs": [evidence.evidence_id],
|
||||||
|
"chart": chart},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={
|
||||||
|
**_current_report(), "evidence_refs": [evidence.evidence_id], "ui_blocks": [{
|
||||||
|
"evidence_refs": [evidence.evidence_id], "block": {
|
||||||
|
"version": "answer-ui@1", "block_id": "comparison", "fallback_text": "同期对照",
|
||||||
|
"spec": {"root": "chart", "elements": {"chart": {
|
||||||
|
"type": "Chart", "children": [], "props": {**chart, "source": "合成数据"},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
if defect is None:
|
||||||
|
await publish()
|
||||||
|
assert len(repository.events) > events_before
|
||||||
|
else:
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await publish()
|
||||||
|
assert error.value.code == "TIME_COMPARISON_INVALID"
|
||||||
|
assert len(repository.events) == events_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("operation", ["propose", "approve", "submit"])
|
||||||
|
async def test_replay_cannot_propose_approve_or_submit_device_commands(operation):
|
||||||
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))
|
||||||
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))
|
action = HumanApprovalAction(
|
||||||
|
action_id=uuid4(), task_id=task["task_id"], version=1, kind="issue_command",
|
||||||
|
reason="关阀", payload={"command_type": "close_valve", "target": {"kind": "valve", "id": "V1"},
|
||||||
|
"parameters": {}, "impact_summary": "隔离候选管段", "preconditions": ["现场核验"],
|
||||||
|
"expires_at": now + timedelta(minutes=5)},
|
||||||
|
evidence_refs=[], required_approval_permission="command.approve",
|
||||||
|
required_execution_permission="command.execute", approval_status="pending",
|
||||||
|
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
||||||
|
)
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
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())
|
||||||
args = {"request_id": uuid4(), "expected_version": 1}
|
args = {"request_id": uuid4(), "expected_version": 1}
|
||||||
with pytest.raises(DomainError) as caught:
|
with pytest.raises(DomainError) as caught:
|
||||||
if operation == "draft":
|
if operation == "propose":
|
||||||
await service.update_work_order_draft(task["task_id"], uuid4(), WorkOrderDraftUpdateCommand(
|
await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
**args, payload={"title": "现场核查", "description": "核查", "work_order_type": "general"}))
|
request_id=uuid4(), kind="issue_command", reason=action.reason, payload=action.payload))
|
||||||
elif operation == "approve":
|
elif operation == "approve":
|
||||||
await service.decide_action(task["task_id"], uuid4(), ActionDecisionCommand(**args, decision="approved"))
|
await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(**args, decision="approved"))
|
||||||
else:
|
else:
|
||||||
await service.submit_action(task["task_id"], uuid4(), ActionExecuteCommand(**args), dispatcher, None)
|
await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(**args), dispatcher, None)
|
||||||
assert caught.value.code == "REPLAY_ACTION_DISABLED"
|
assert caught.value.code == "REPLAY_ACTION_DISABLED"
|
||||||
dispatcher.dispatch.assert_not_awaited()
|
dispatcher.dispatch.assert_not_awaited()
|
||||||
|
|
||||||
@@ -646,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,
|
||||||
@@ -665,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"]},
|
||||||
@@ -698,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,
|
||||||
@@ -747,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(
|
||||||
@@ -794,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(
|
||||||
@@ -826,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="新投诉信息")
|
||||||
@@ -843,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(
|
||||||
@@ -865,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:
|
||||||
@@ -885,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(
|
||||||
@@ -917,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(
|
||||||
@@ -965,6 +863,36 @@ def _current_report():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("complete", [False, True])
|
||||||
|
async def test_scada_final_report_requires_complete_current_input_not_selected_device_evidence(complete):
|
||||||
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task["workflow"] = "scada_anomaly"
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
||||||
|
"mode": "all_scada", "query_complete": True,
|
||||||
|
}} if complete else [{"device_id": "P1", "monitored_value": 20.}]
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="mcp:scada-coverage", queried_at=now,
|
||||||
|
origin="system_data", truncated=False,
|
||||||
|
data={"data": data, "window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()}},
|
||||||
|
))
|
||||||
|
command = AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1",
|
||||||
|
report={**_current_report(), "evidence_refs": [evidence.evidence_id]},
|
||||||
|
)
|
||||||
|
if complete:
|
||||||
|
result = await service.publish_artifacts(task["task_id"], command)
|
||||||
|
assert result.task.latest_report.report.stage == "final"
|
||||||
|
else:
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await service.publish_artifacts(task["task_id"], command)
|
||||||
|
assert error.value.code == "SCADA_COVERAGE_REQUIRED" and not repository.reports
|
||||||
|
|
||||||
|
|
||||||
def _insufficient_report():
|
def _insufficient_report():
|
||||||
return {
|
return {
|
||||||
**_current_report(), "outcome": "insufficient_data",
|
**_current_report(), "outcome": "insufficient_data",
|
||||||
@@ -997,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",
|
||||||
@@ -1052,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": "关键数据不足"}
|
||||||
@@ -1085,21 +1013,39 @@ def _seed_concluded_report(repository, now):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(monkeypatch):
|
@pytest.mark.parametrize("replay", [False, True])
|
||||||
|
async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(monkeypatch, replay):
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from app.domain.schemas.project_settings import ProjectSettings
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
from app.services.operational_recovery import reconcile_recoveries, track_work_order
|
from app.services.operational_recovery import reconcile_recoveries, track_work_order
|
||||||
|
|
||||||
now = datetime(2026, 7, 16, 4, tzinfo=timezone.utc)
|
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
|
||||||
incident = now - timedelta(hours=4)
|
incident = now - timedelta(hours=6)
|
||||||
returned = incident + timedelta(hours=1)
|
returned = datetime.fromisoformat("2026-07-16T13:00:00+08:00")
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
task.update(workflow="burst_report", trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "爆管"})
|
task.update(workflow="burst_report", trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "爆管"})
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
if replay:
|
||||||
|
task["workflow"] = "scada_anomaly"
|
||||||
|
task["source"]["replay_context"] = {
|
||||||
|
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
||||||
|
}
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.connection = object()
|
repository.connection = object()
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
||||||
|
incident_evidence_id = None
|
||||||
|
if replay:
|
||||||
|
frozen = task["source"]["replay_context"]
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="mcp:frozen-incident", queried_at=now,
|
||||||
|
origin="synthetic", truncated=False,
|
||||||
|
data={"data": {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
||||||
|
"mode": "all_scada", "query_complete": True,
|
||||||
|
"replay_id": frozen["replay_id"], "batch_sequence": 3,
|
||||||
|
}}, "window": {"start": (incident - timedelta(hours=3)).isoformat(), "end": incident.isoformat()}},
|
||||||
|
))
|
||||||
|
incident_evidence_id = evidence.evidence_id
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
request_id=uuid4(), kind="create_work_order", reason="隔离抢修与恢复供水",
|
request_id=uuid4(), kind="create_work_order", reason="隔离抢修与恢复供水",
|
||||||
@@ -1135,6 +1081,11 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
reader.return_value = rows
|
reader.return_value = rows
|
||||||
assert await reconcile_recoveries(service, timescale, settings) == 1
|
assert await reconcile_recoveries(service, timescale, settings) == 1
|
||||||
assert await reconcile_recoveries(service, timescale, settings) == 0
|
assert await reconcile_recoveries(service, timescale, settings) == 0
|
||||||
|
reader.assert_awaited_with(timescale, ["P1"], incident, returned, now, baseline_limit=100_001)
|
||||||
|
if replay:
|
||||||
|
assert repository.task["source"]["replay_context"]["replay_id"] == task["source"]["replay_context"]["replay_id"]
|
||||||
|
assert datetime.fromisoformat(repository.task["source"]["replay_context"]["knowledge_cutoff"]) == incident
|
||||||
|
assert repository.task["source"]["replay_context"]["batch_sequence"] == 3
|
||||||
recovery = repository.task["handling_context"]
|
recovery = repository.task["handling_context"]
|
||||||
evidence_id = recovery["verification_evidence_id"]
|
evidence_id = recovery["verification_evidence_id"]
|
||||||
assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed"
|
assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed"
|
||||||
@@ -1158,13 +1109,22 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
||||||
evidence_id = revised_id
|
evidence_id = revised_id
|
||||||
|
|
||||||
repository.task.update(status="running", agent_session_id="session-1")
|
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
repository.run = _active_run(repository.task, now)
|
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"],
|
||||||
|
source_session_id="session-1",
|
||||||
|
))
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
with pytest.raises(DomainError) as rejected:
|
with pytest.raises(DomainError) as rejected:
|
||||||
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
assert rejected.value.code == "RECOVERY_EVIDENCE_REQUIRED"
|
assert rejected.value.code == "RECOVERY_EVIDENCE_REQUIRED"
|
||||||
|
if replay:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1",
|
||||||
|
report={**_current_report(), "evidence_refs": [incident_evidence_id, evidence_id]},
|
||||||
|
))
|
||||||
|
assert repository.reports[-1]["report"]["evidence_refs"] == [incident_evidence_id, evidence_id]
|
||||||
|
else:
|
||||||
repository.reports[-1]["report"]["evidence_refs"] = [evidence_id]
|
repository.reports[-1]["report"]["evidence_refs"] = [evidence_id]
|
||||||
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
@@ -1177,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(
|
||||||
@@ -1212,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"
|
||||||
@@ -1253,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
|
||||||
@@ -1261,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(),
|
||||||
@@ -1284,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")
|
||||||
@@ -1306,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(
|
||||||
@@ -1342,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"}]}
|
||||||
@@ -1355,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"])
|
||||||
@@ -1369,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",
|
||||||
@@ -1402,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(
|
||||||
@@ -1418,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(),
|
||||||
@@ -1467,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,
|
||||||
@@ -1516,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,
|
||||||
@@ -1580,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",
|
||||||
@@ -1614,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",
|
||||||
@@ -1637,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)
|
||||||
|
|
||||||
@@ -1656,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",
|
||||||
@@ -1684,11 +1648,16 @@ async def test_completed_gate_requires_access_request_decision():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_work_order_draft_edit_creates_new_review_version():
|
@pytest.mark.parametrize("replay", [False, True])
|
||||||
|
async def test_work_order_draft_edit_creates_new_review_version(replay):
|
||||||
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))
|
if replay:
|
||||||
|
task["source"]["replay_context"] = {
|
||||||
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
|
}
|
||||||
|
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(
|
||||||
@@ -1710,11 +1679,16 @@ async def test_work_order_draft_edit_creates_new_review_version():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch():
|
@pytest.mark.parametrize("is_replay", [False, True])
|
||||||
|
async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch(is_replay):
|
||||||
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))
|
if is_replay:
|
||||||
|
task["source"]["replay_context"] = {
|
||||||
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -1732,6 +1706,11 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
|||||||
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
|
return ActionDispatchOutcome("unsupported", "测试适配器未配置")
|
||||||
|
|
||||||
dispatcher = Dispatcher()
|
dispatcher = Dispatcher()
|
||||||
|
with pytest.raises(DomainError) as caught:
|
||||||
|
await service.submit_action(task["task_id"], action.action_id,
|
||||||
|
ActionExecuteCommand(request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||||
|
assert caught.value.code == "INVALID_TRANSITION"
|
||||||
|
assert dispatcher.calls == 0
|
||||||
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
|
command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version)
|
||||||
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
|
first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id,
|
||||||
command, dispatcher, None)
|
command, dispatcher, None)
|
||||||
@@ -1953,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)
|
||||||
|
|
||||||
@@ -2081,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(
|
||||||
@@ -2118,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(
|
||||||
@@ -2150,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)
|
||||||
@@ -2174,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')
|
||||||
@@ -2208,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(
|
||||||
@@ -2283,3 +2262,91 @@ async def test_recovery_resolves_alerts_with_original_facts_and_stable_event_id(
|
|||||||
assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1")
|
assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1")
|
||||||
assert command.source.model_dump(mode="json") == alert["source"]
|
assert command.source.model_dump(mode="json") == alert["source"]
|
||||||
assert command.title == alert["title"] and command.description == alert["description"]
|
assert command.title == alert["title"] and command.description == alert["description"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("scope", ["production", "development"])
|
||||||
|
async def test_task_creation_persists_server_execution_scope(monkeypatch, scope):
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskCreate
|
||||||
|
|
||||||
|
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
document = _task_document(context, now)
|
||||||
|
repository = TaskRepository(document)
|
||||||
|
repository.insert_task = AsyncMock()
|
||||||
|
repository.insert_run = AsyncMock()
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
||||||
|
execution_scope=scope)
|
||||||
|
monkeypatch.setattr(service, "_task_event", AsyncMock())
|
||||||
|
monkeypatch.setattr(service, "_run_event", AsyncMock())
|
||||||
|
monkeypatch.setattr(service, "detail", AsyncMock())
|
||||||
|
command = OperationalTaskCreate(request_id=uuid4(), **{
|
||||||
|
key: document[key] for key in ("task_type", "workflow", "title", "summary", "source", "trigger")
|
||||||
|
})
|
||||||
|
await service.create(command)
|
||||||
|
assert repository.insert_task.await_args.args[0]["execution_scope"] == scope
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_scope_is_required_in_persisted_contract():
|
||||||
|
task = _task_document(_context(), datetime(2026, 11, 8, tzinfo=timezone.utc))
|
||||||
|
task.pop("execution_scope")
|
||||||
|
with pytest.raises(ValidationError) as error:
|
||||||
|
OperationalTaskSummary.model_validate(task)
|
||||||
|
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
|
||||||
|
for item in error.value.errors())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("operation", ["feedback", "withdrawal"])
|
||||||
|
@pytest.mark.parametrize("task_scope,server_scope,allowed", [
|
||||||
|
(None, "production", False),
|
||||||
|
(None, "development", False),
|
||||||
|
("production", "production", True),
|
||||||
|
("production", "development", False),
|
||||||
|
("development", "production", False),
|
||||||
|
("development", "development", True),
|
||||||
|
])
|
||||||
|
async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation, task_scope, server_scope, allowed):
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=None)
|
||||||
|
source_id = f"peripheral_sample:{uuid4()}"
|
||||||
|
task.update(task_type="external_work_order", source={
|
||||||
|
"kind": "external_system", "external_id": source_id,
|
||||||
|
"external_revision": 1, "event_kind": "general",
|
||||||
|
})
|
||||||
|
if task_scope is None:
|
||||||
|
task.pop("execution_scope")
|
||||||
|
else:
|
||||||
|
task["execution_scope"] = task_scope
|
||||||
|
repository = TaskRepository(task)
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now, execution_scope=server_scope)
|
||||||
|
if operation == "feedback":
|
||||||
|
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=source_id,
|
||||||
|
source_revision=2, status="completed", occurred_at=now, detail="已关阀修复管道")
|
||||||
|
method = "external_feedback"
|
||||||
|
else:
|
||||||
|
command = ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id)
|
||||||
|
method = "withdraw_external_source"
|
||||||
|
before = deepcopy(repository.task)
|
||||||
|
if not allowed:
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
|
assert repository.task == before
|
||||||
|
assert repository.feedback == repository.evidence == repository.requests == {}
|
||||||
|
return
|
||||||
|
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
assert repository.task["status"] == ("completed" if operation == "feedback" else "cancelled")
|
||||||
|
# Check scope before idempotency replay as well as before the first write.
|
||||||
|
await getattr(service, method)(task["task_id"], command)
|
||||||
|
before = deepcopy(repository.task)
|
||||||
|
other = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
||||||
|
execution_scope="development" if server_scope == "production" else "production")
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
await getattr(other, method)(task["task_id"], command)
|
||||||
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
|
assert repository.task == before
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,6 +9,32 @@ import pytest
|
|||||||
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_parallel_worker_requests_share_cold_and_expired_token_refresh(monkeypatch):
|
||||||
|
clock = [100.0]
|
||||||
|
requests = []
|
||||||
|
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60})
|
||||||
|
|
||||||
|
provider = CredentialProvider(WorkerSettings(
|
||||||
|
"http://server.test", "http://agent.test", str(uuid4()),
|
||||||
|
token_url="https://identity.test/token", client_id="fixture", client_secret="TEST_ONLY",
|
||||||
|
))
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
first = await asyncio.gather(provider.headers(client), provider.headers(client))
|
||||||
|
assert first[0] == first[1]
|
||||||
|
assert len(requests) == 1
|
||||||
|
clock[0] = 146.0
|
||||||
|
second = await asyncio.gather(provider.headers(client), provider.headers(client))
|
||||||
|
assert second[0] == second[1]
|
||||||
|
assert second[0] != first[0]
|
||||||
|
assert len(requests) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def anyio_backend():
|
def anyio_backend():
|
||||||
return "asyncio"
|
return "asyncio"
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize("targeted", [False, True])
|
@pytest.mark.parametrize("targeted", [False, True])
|
||||||
@pytest.mark.parametrize("order_revision", [1, 2])
|
@pytest.mark.parametrize("order_revision", [1, 4])
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
|
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
|
||||||
[
|
[
|
||||||
@@ -254,9 +254,13 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
|
|||||||
"updated_at": "2026-09-23T09:00:00Z",
|
"updated_at": "2026-09-23T09:00:00Z",
|
||||||
"title": "采样工单", "priority": "normal",
|
"title": "采样工单", "priority": "normal",
|
||||||
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
||||||
"scheduled_at": None,
|
"scheduled_at": "2026-09-23T08:30:00Z",
|
||||||
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
|
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
|
||||||
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}]
|
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"},
|
||||||
|
{"sequence": 3, "occurred_at": "2026-09-23T08:50:00Z", "actor": "调度员",
|
||||||
|
"kind": "updated", "title": "工单信息更新", "detail": "更新了状态。"},
|
||||||
|
{"sequence": 4, "occurred_at": "2026-09-23T09:00:00Z", "actor": "调度员",
|
||||||
|
"kind": "updated", "title": "工单信息更新", "detail": "更新了优先级。"}]
|
||||||
if order_revision > 1 else []),
|
if order_revision > 1 else []),
|
||||||
}
|
}
|
||||||
if targeted:
|
if targeted:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.api.v1.endpoints import scada as scada_endpoint
|
from app.api.v1.endpoints import scada as scada_endpoint
|
||||||
from app.services import timeseries_analysis as composite_queries
|
from app.services import timeseries_history as history
|
||||||
|
from app.domain.schemas.timeseries_history import ElementHistoryQuery
|
||||||
|
|
||||||
|
|
||||||
PROJECT_SCADA = {
|
PROJECT_SCADA = {
|
||||||
@@ -26,107 +26,22 @@ START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
|||||||
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _patch_project_scadas(monkeypatch):
|
def test_history_uses_current_project_metadata(monkeypatch):
|
||||||
monkeypatch.setattr(
|
postgres = object()
|
||||||
composite_queries.ScadaInfoRepository,
|
mappings = AsyncMock(return_value=[PROJECT_SCADA.copy()])
|
||||||
"get_scadas",
|
monkeypatch.setattr(history.ScadaInfoRepository, "get_scadas_for_elements", mappings)
|
||||||
AsyncMock(return_value=[PROJECT_SCADA.copy()]),
|
monkeypatch.setattr(history.ScadaRepository, "get_scada_by_ids_time_range", AsyncMock(return_value=[{
|
||||||
)
|
"time": START_TIME, "device_id": PROJECT_SCADA["device_id"],
|
||||||
|
"monitored_value": 26.5, "cleaned_value": 26.0,
|
||||||
|
}]))
|
||||||
def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
|
result = asyncio.run(history.TimeseriesHistoryService.query(object(), postgres, ElementHistoryQuery(
|
||||||
_patch_project_scadas(monkeypatch)
|
start_time=START_TIME, end_time=END_TIME,
|
||||||
query_mock = AsyncMock(
|
elements=[{"element_id": "J1", "element_type": "junction", "device_ids": [PROJECT_SCADA["device_id"]]}],
|
||||||
return_value={"J1": [{"time": START_TIME, "value": 26.5}]}
|
)))
|
||||||
)
|
assert mappings.await_args.args == (postgres, ["J1"], [])
|
||||||
monkeypatch.setattr(
|
assert len(result.series) == 2
|
||||||
composite_queries.RealtimeRepository,
|
assert all(item.device_id == PROJECT_SCADA["device_id"] and item.element_id == "J1" for item in result.series)
|
||||||
"get_node_fields_by_ids_time_range",
|
assert [item.points[0].value for item in result.series] == [26.5, 26.0]
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.RealtimeRepository,
|
|
||||||
"get_link_fields_by_ids_time_range",
|
|
||||||
AsyncMock(return_value={}),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 1
|
|
||||||
assert query_mock.await_args.args[1:] == (
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
["J1"],
|
|
||||||
"pressure",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
async def query_series(_conn, _run_id, element_type, element_ids, *_args):
|
|
||||||
if element_type == "node":
|
|
||||||
return {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
query_mock = AsyncMock(side_effect=query_series)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.AnalysisResultsRepository,
|
|
||||||
"get_series_by_ids",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
uuid4(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 2
|
|
||||||
node_call = next(
|
|
||||||
call for call in query_mock.await_args_list if call.args[2] == "node"
|
|
||||||
)
|
|
||||||
assert node_call.args[3:] == (["J1"], START_TIME, END_TIME, "pressure")
|
|
||||||
|
|
||||||
|
|
||||||
def test_element_scada_query_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
query_mock = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
PROJECT_SCADA["device_id"]: [{"time": START_TIME, "value": 26.5}]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.ScadaRepository,
|
|
||||||
"get_scada_field_by_id_time_range",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_element_associated_scada_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
"J1",
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
|||||||
from app.domain.schemas.project_settings import ScadaMonitoringSettings, ScadaMonitoringWeights
|
from app.domain.schemas.project_settings import ScadaMonitoringSettings, ScadaMonitoringWeights
|
||||||
from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder, adaptive_score_threshold
|
from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder, adaptive_score_threshold
|
||||||
from app.services import scada_replay_monitoring as monitoring
|
from app.services import scada_replay_monitoring as monitoring
|
||||||
from test_scada_replay_monitoring import TARGET, PRESSURE_IDS, FLOW_IDS, _document
|
from tests.unit.test_scada_replay_monitoring import TARGET, PRESSURE_IDS, FLOW_IDS, _document
|
||||||
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||||
from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY, normal_profile_bounds
|
from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY, normal_profile_bounds
|
||||||
|
|
||||||
|
|||||||
@@ -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"]}))
|
||||||
@@ -10,7 +10,7 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from app.mcp.analysis_models import BurstLocationInput
|
from app.mcp.analysis_models import BurstLocationInput
|
||||||
from app.mcp.catalog import TOOLS
|
from app.mcp.catalog import TOOLS
|
||||||
from app.mcp.models import BoundContext
|
from app.mcp.models import BoundContext, ScadaInput
|
||||||
from app.mcp import query_service
|
from app.mcp import query_service
|
||||||
from app.services.scada_replay_queries import replay_burst_location_inputs, replay_snapshot_rows
|
from app.services.scada_replay_queries import replay_burst_location_inputs, replay_snapshot_rows
|
||||||
|
|
||||||
@@ -18,6 +18,30 @@ from app.services.scada_replay_queries import replay_burst_location_inputs, repl
|
|||||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_origin_is_preserved_without_a_provenance_only_data_gap(monkeypatch):
|
||||||
|
from app.services import scada_replay_queries
|
||||||
|
|
||||||
|
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
|
||||||
|
reading = {"time": START.isoformat(), "device_id": "P1", "cleaned_value": 20.0,
|
||||||
|
"monitored_value": 20.0, "quality": "valid"}
|
||||||
|
read = AsyncMock(return_value=[reading])
|
||||||
|
monkeypatch.setattr(scada_replay_queries, "replay_snapshot_rows", read)
|
||||||
|
project_id = uuid4()
|
||||||
|
context = {"replay_id": str(uuid4()), "knowledge_cutoff": (START + timedelta(minutes=15)).isoformat(),
|
||||||
|
"batch_sequence": 9}
|
||||||
|
connection = object()
|
||||||
|
scope = SimpleNamespace(connection=connection, project=SimpleNamespace(project_id=project_id))
|
||||||
|
binding = SimpleNamespace(replay_context=context)
|
||||||
|
args = ScadaInput(context_handle="a" * 43, device_ids=["P1"], start=START,
|
||||||
|
end=START + timedelta(minutes=15))
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_scada_readings"], args))
|
||||||
|
assert result.origin == "synthetic"
|
||||||
|
assert result.data[0]["device_id"] == "P1"
|
||||||
|
assert result.data_gaps == []
|
||||||
|
read.assert_awaited_once_with(connection, project_id, context, ["P1"], args.start, args.end,
|
||||||
|
args.limit + 1, args.offset)
|
||||||
|
|
||||||
|
|
||||||
class Cursor:
|
class Cursor:
|
||||||
def __init__(self, rows): self.rows = rows
|
def __init__(self, rows): self.rows = rows
|
||||||
async def fetchone(self): return self.rows[0] if self.rows else None
|
async def fetchone(self): return self.rows[0] if self.rows else None
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
|
|||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.operational_tasks.OperationalTaskService",
|
"app.services.operational_tasks.OperationalTaskService",
|
||||||
lambda *_: SimpleNamespace(create=create_task),
|
lambda *_, **_kwargs: SimpleNamespace(create=create_task),
|
||||||
)
|
)
|
||||||
monitoring = {
|
monitoring = {
|
||||||
"status": "suspected_burst",
|
"status": "suspected_burst",
|
||||||
@@ -618,7 +618,7 @@ def test_monitoring_summary_counts_signals_without_copying_reading_details(monke
|
|||||||
_, service, *_ = setup(monkeypatch)
|
_, service, *_ = setup(monkeypatch)
|
||||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task))
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
|
||||||
monitoring = incident_monitoring(0)
|
monitoring = incident_monitoring(0)
|
||||||
monitoring["anomaly_signals"] = [
|
monitoring["anomaly_signals"] = [
|
||||||
{"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"}
|
{"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"}
|
||||||
@@ -641,7 +641,7 @@ def test_continuing_burst_reuses_task_across_persisted_periods(monkeypatch):
|
|||||||
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
||||||
lambda *_: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task))
|
lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task))
|
||||||
document = {"replay_id": str(uuid4())}
|
document = {"replay_id": str(uuid4())}
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
@@ -683,7 +683,7 @@ def test_incident_recovers_once_then_allows_new_accident(monkeypatch):
|
|||||||
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
||||||
lambda *_: SimpleNamespace(create=create_task))
|
lambda *_, **_kwargs: SimpleNamespace(create=create_task))
|
||||||
document = {"replay_id": str(uuid4())}
|
document = {"replay_id": str(uuid4())}
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
@@ -784,7 +784,7 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
|
|||||||
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
|
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=AsyncMock()))
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=AsyncMock()))
|
||||||
def event(rule, category=None):
|
def event(rule, category=None):
|
||||||
return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(),
|
return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(),
|
||||||
"detail": "检测信号", "category": category, "task_id": None}
|
"detail": "检测信号", "category": category, "task_id": None}
|
||||||
@@ -812,7 +812,7 @@ def test_failed_incident_publication_rolls_back_state_and_retries_same_task_requ
|
|||||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task))
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
|
||||||
def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence):
|
def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence):
|
||||||
return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(),
|
return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(),
|
||||||
"knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence}
|
"knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence}
|
||||||
@@ -843,7 +843,7 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
|
|||||||
_, service, *_ = setup(monkeypatch)
|
_, service, *_ = setup(monkeypatch)
|
||||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task))
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
|
||||||
monitoring = incident_monitoring(0, status="attention")
|
monitoring = incident_monitoring(0, status="attention")
|
||||||
for key in ("pressure", "outlet_flow", "burst_detection"):
|
for key in ("pressure", "outlet_flow", "burst_detection"):
|
||||||
monitoring[key]["hit"] = key == indicator
|
monitoring[key]["hit"] = key == indicator
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailure
|
|||||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
from app.services.task_questions import create_question, answer_question
|
from app.services.task_questions import create_question, answer_question
|
||||||
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,28 +19,11 @@ def anyio_backend():
|
|||||||
return "asyncio"
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
class QuestionRepository(RunTaskRepository):
|
|
||||||
def __init__(self, task, run):
|
|
||||||
super().__init__(task, run)
|
|
||||||
self.questions = {}
|
|
||||||
|
|
||||||
async def list_questions(self, task_id):
|
|
||||||
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
|
||||||
|
|
||||||
async def get_question(self, task_id, question_id):
|
|
||||||
q = self.questions.get(question_id)
|
|
||||||
return deepcopy(q) if q and q["task_id"] == task_id else None
|
|
||||||
|
|
||||||
async def save_question(self, task_id, document):
|
|
||||||
assert document["task_id"] == task_id
|
|
||||||
self.questions[document["question_request_id"]] = deepcopy(document)
|
|
||||||
|
|
||||||
|
|
||||||
def setup():
|
def setup():
|
||||||
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repo = QuestionRepository(task, _active_run(task, now))
|
repo = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
||||||
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
||||||
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
||||||
@@ -162,6 +146,34 @@ async def test_real_execution_failure_cancels_question_instead_of_disguising_fai
|
|||||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("timeout_count", [1, 3])
|
||||||
|
async def test_lease_timeout_cancels_old_question_and_unblocks_the_next_generation(timeout_count):
|
||||||
|
service, repo, task_id, command = setup()
|
||||||
|
question = await create_question(service, task_id, command)
|
||||||
|
service.clock = lambda: question.created_at + timedelta(minutes=2)
|
||||||
|
repo.claim_expired_runs = AsyncMock(return_value=[deepcopy(repo.run)])
|
||||||
|
repo.consecutive_timed_out_runs = AsyncMock(return_value=timeout_count)
|
||||||
|
|
||||||
|
await service.recover_expired_leases(1)
|
||||||
|
|
||||||
|
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||||
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
with pytest.raises(DomainError):
|
||||||
|
await answer_question(service, task_id, question.question_request_id, reply())
|
||||||
|
if timeout_count == 3:
|
||||||
|
assert repo.task["lifecycle_status"] == "failed"
|
||||||
|
return
|
||||||
|
|
||||||
|
await service.claim_run(task_id, UUID(repo.run["run_id"]), AgentRunCommand(
|
||||||
|
request_id=UUID(repo.run["execution_request_id"]), expected_revision=1,
|
||||||
|
source_session_id="session-2"))
|
||||||
|
next_question = await create_question(service, task_id, command.model_copy(update={
|
||||||
|
"request_id": uuid4(), "run_id": UUID(repo.run["run_id"]), "source_session_id": "session-2"}))
|
||||||
|
assert next_question.status == "pending"
|
||||||
|
assert next_question.execution_generation == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multiple_selection_and_optional_question_are_preserved():
|
async def test_multiple_selection_and_optional_question_are_preserved():
|
||||||
service, repo, task_id, command = setup()
|
service, repo, task_id, command = setup()
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_task import TaskSafeChart
|
||||||
|
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_chart(start="2026-07-16T05:00:00+08:00", interval=15):
|
||||||
|
times = [datetime.fromisoformat(start) + timedelta(minutes=index * interval) for index in range(13)]
|
||||||
|
return {"title": "七天同期对比", "chart_type": "time_comparison",
|
||||||
|
"x_data": [at.strftime("%H:%M") for at in times], "series": [
|
||||||
|
{"name": "当前", "type": "line", "data": [25.] * 13,
|
||||||
|
"sample_times": [at.isoformat() for at in times]},
|
||||||
|
*[{"name": f"前{day}天", "type": "scatter", "data": [None] * 10 + [26.] * 3,
|
||||||
|
"sample_times": [None] * 10 + [(at - timedelta(days=day)).isoformat() for at in times[10:]]}
|
||||||
|
for day in range(1, 8)],
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start,interval", [
|
||||||
|
("2026-07-16T05:00:00+08:00", 15),
|
||||||
|
("2026-07-16T23:00:00+08:00", 15),
|
||||||
|
("2026-07-16T05:00:00+08:00", 5),
|
||||||
|
])
|
||||||
|
def test_same_time_comparison_preserves_seven_sparse_days_and_sampling_intervals(start, interval):
|
||||||
|
chart = comparison_chart(start, interval)
|
||||||
|
# A missing current observation is still a real empty slot.
|
||||||
|
chart["series"][0]["data"][11] = None
|
||||||
|
chart["series"][0]["sample_times"][11] = None
|
||||||
|
# UTC and local-offset spellings refer to the same real historical instant.
|
||||||
|
chart["series"][1]["sample_times"][12] = (
|
||||||
|
datetime.fromisoformat(chart["series"][1]["sample_times"][12])
|
||||||
|
.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
before = deepcopy(chart)
|
||||||
|
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
|
||||||
|
assert chart == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("defect", [
|
||||||
|
"appended_history", "duplicate_slots", "wrong_slot", "missing_times", "missing_point_time",
|
||||||
|
"mixed_dates", "same_day", "future_day", "bar_series", "only_line", "no_current_observations",
|
||||||
|
])
|
||||||
|
def test_special_comparison_rejects_misleading_projections(defect):
|
||||||
|
chart = comparison_chart()
|
||||||
|
history = chart["series"][1]
|
||||||
|
if defect == "appended_history":
|
||||||
|
chart["x_data"] = [f"07-16 {slot}" for slot in chart["x_data"]] + ["07-15 07:30", "07-15 07:45", "07-15 08:00"]
|
||||||
|
for series in chart["series"]:
|
||||||
|
series["data"] += [None] * 3
|
||||||
|
series["sample_times"] += [None] * 3
|
||||||
|
elif defect == "duplicate_slots":
|
||||||
|
chart["x_data"][11] = chart["x_data"][10]
|
||||||
|
elif defect == "wrong_slot":
|
||||||
|
history["sample_times"][10] = "2026-07-15T07:45:00+08:00"
|
||||||
|
elif defect == "missing_times":
|
||||||
|
del history["sample_times"]
|
||||||
|
elif defect == "missing_point_time":
|
||||||
|
history["sample_times"][10] = None
|
||||||
|
elif defect == "mixed_dates":
|
||||||
|
history["sample_times"][10] = "2026-07-14T07:30:00+08:00"
|
||||||
|
elif defect in {"same_day", "future_day"}:
|
||||||
|
day = 16 if defect == "same_day" else 17
|
||||||
|
history["sample_times"] = [at.replace("07-15", f"07-{day}") if at else None for at in history["sample_times"]]
|
||||||
|
elif defect == "bar_series":
|
||||||
|
history["type"] = "bar"
|
||||||
|
elif defect == "only_line":
|
||||||
|
chart["series"] = chart["series"][:1]
|
||||||
|
elif defect == "no_current_observations":
|
||||||
|
chart["series"][0]["data"] = chart["series"][0]["sample_times"] = [None] * 13
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
|
||||||
|
assert error.value.code == "TIME_COMPARISON_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_charts_skip_comparison_validation_and_old_mixed_charts_remain_readable():
|
||||||
|
chart = {"title": "普通趋势", "chart_type": "line", "x_data": ["07-16 08:00"],
|
||||||
|
"series": [{"name": "当前", "data": [25.]}]}
|
||||||
|
validate_time_comparison_chart(chart)
|
||||||
|
chart["chart_type"] = "bar"
|
||||||
|
validate_time_comparison_chart(chart)
|
||||||
|
chart["chart_type"] = "line"
|
||||||
|
chart["series"].append({"name": "旧版散点", "type": "scatter", "data": [26.]})
|
||||||
|
assert TaskSafeChart.model_validate(chart).model_dump(mode="json") == chart
|
||||||
|
with pytest.raises(DomainError, match="time_comparison"):
|
||||||
|
validate_time_comparison_chart(chart)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("labels,times", [
|
||||||
|
(["07:45"], ["9999-12-31T23:45:00Z"]),
|
||||||
|
(["08:00"], ["0001-01-01T00:00:00+23:59"]),
|
||||||
|
(["23:45", "00:15"], [None, "0001-01-01T00:15:00+08:00"]),
|
||||||
|
])
|
||||||
|
def test_calendar_range_overflow_returns_a_domain_rejection(labels, times):
|
||||||
|
chart = {"title": "日期边界", "chart_type": "time_comparison", "x_data": labels, "series": [
|
||||||
|
{"name": "当前", "type": "line", "data": [1. if at else None for at in times], "sample_times": times},
|
||||||
|
{"name": "历史缺测", "type": "scatter", "data": [None] * len(labels), "sample_times": [None] * len(labels)},
|
||||||
|
]}
|
||||||
|
with pytest.raises(DomainError, match="日期范围") as error:
|
||||||
|
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
|
||||||
|
assert error.value.code == "TIME_COMPARISON_INVALID"
|
||||||
|
assert error.value.status == 422
|
||||||
Reference in New Issue
Block a user