Compare commits

..
Author SHA1 Message Date
jiang 471ad92a47 fix(tasks): persist work-order waits without restarting runs
Generic Container CI/CD / test-build-publish (push) Successful in 4m20s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m20s
Dispatch receipts previously requeued active investigations and the final-only completion gate then failed waiting generations. Preserve the run, accept current interim wait reports, and wake only on actionable feedback or recovery evidence. Regression tests cover dispatch, same-generation reports, receipt revisions, cancellation and replacement ordering.
2026-11-12 13:45:27 +08:00
jiang 1892b1d1f0 fix(scada): preserve observation fields in chart evidence
Generic Container CI/CD / test-build-publish (push) Successful in 4m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m10s
2026-11-12 12:22:07 +08:00
jiang 2e8fe86f3f fix: enforce SCADA chart device coverage 2026-11-12 10:38:55 +08:00
jiang b853b61a0b feat(investigation): preserve cumulative reference graphs
Accumulate investigation paths and preserve failed attempts across rounds. Keep graph annotations separate from execution state, synchronize the API contract, and consolidate documentation.
2026-11-11 19:10:41 +08:00
jiang e47c61c121 refactor: reuse routing and remove unused analysis helpers
Generic Container CI/CD / test-build-publish (push) Successful in 4m3s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m3s
2026-11-09 18:49:32 +08:00
jiang 6a4478bfe4 refactor: simplify task and history services
Remove retired query paths and repository compatibility branches, and share task test fixtures.
Cancel pending questions and resolve their notifications when recovering expired runs.
2026-11-09 17:55:00 +08:00
jiang 5197a93163 fix(skills): close recovered incidents without optional evidence gates
Generic Container CI/CD / test-build-publish (push) Successful in 3m57s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m57s
A completed field order and passed Server recovery evidence support the final closure report. Treat absent photos, valve records and field measurements as limits on confirmed details rather than new closure requirements.

Clarify already completed recovery state and keep any separate source-system administrative process conditional on its recorded requirements. Bump SCADA and burst handling skill versions.
2026-11-08 04:31:01 +08:00
jiang 549019507e fix(tasks): isolate peripheral feedback writes by execution scope
Generic Container CI/CD / test-build-publish (push) Successful in 4m15s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m17s
An old development synchronizer could overwrite a production recovery timestamp in a shared project database even after production deployment. Check task scope before feedback or source-withdrawal writes and before idempotency replay.

Cover cross-scope rejection, same-scope success and replay, and preserve actual field feedback time when later order metadata changes.
2026-11-08 04:07:07 +08:00
68 changed files with 4732 additions and 1114 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
协议、工作进程、数据库、SCADA 与历史资料索引见 [文档目录](docs/README.md)。
## 技术栈
- Python 3.12
@@ -213,7 +215,7 @@ Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发
## 产品 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)。
+79 -10
View File
@@ -5,7 +5,7 @@ import re
from typing import Annotated, Literal
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 (
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
)
@@ -222,25 +222,61 @@ class InvestigationMapTarget(StrictModel):
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}$")]
label: NonEmptyStr
detail: Annotated[str, Field(min_length=1, max_length=2000)] | None = None
evidence_refs: list[NonEmptyStr] = 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)
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):
title: NonEmptyStr
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
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")
def validate_description(self):
@@ -284,6 +320,7 @@ class InvestigationReport(StrictModel):
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
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")
def validate_result(self):
@@ -323,6 +360,16 @@ class InvestigationProgress(StrictModel):
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):
revision: PositiveInt
run_id: UUID
@@ -367,7 +414,7 @@ class AgentArtifactsCommand(StrictModel):
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
plan: ExecutionPlanInput | None = None
report: InvestigationReportInput | None = None
progress: InvestigationProgress | None = None
progress: InvestigationProgressInput | None = None
@model_validator(mode="after")
def validate_artifacts(self):
@@ -377,6 +424,8 @@ class AgentArtifactsCommand(StrictModel):
self.report.diagram if self.report 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)
if missing:
raise ValueError(
@@ -694,8 +743,27 @@ class AgentArtifactsResult(StrictModel):
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):
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
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(
@@ -722,6 +790,7 @@ class TaskSafeChartSeries(StrictModel):
class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)]
scada: ScadaChartContext | None = None
chart_type: Literal["line", "bar", "time_comparison"]
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
+62 -3
View File
@@ -164,7 +164,7 @@ class OperationalTaskRepository:
ORDER BY generation DESC LIMIT %s OFFSET %s""",
(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()]
async def get_latest_run(self, task_id: str) -> dict | None:
@@ -371,6 +371,34 @@ class OperationalTaskRepository:
)
return [dict(row) for row in await cursor.fetchall()]
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:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
@@ -405,9 +433,9 @@ class OperationalTaskRepository:
cursor = await self.connection.execute(
"""SELECT task.document,
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
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
LEFT JOIN LATERAL (
SELECT run.* FROM workbench.operational_task_runs AS run
@@ -628,6 +656,37 @@ class OperationalTaskRepository:
row = await cursor.fetchone()
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:
cursor = await self.connection.execute(
"""INSERT INTO workbench.operational_task_progress
+33 -3
View File
@@ -7,7 +7,9 @@ from uuid import UUID
from psycopg import AsyncConnection
from psycopg import sql
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
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.timescaledb.repositories.realtime import RealtimeRepository
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
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(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
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"
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":
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"""
params = (project_id, task_id)
elif section == "progress":
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
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_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
WHERE p.project_id=%s AND p.task_id=%s"""
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 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)
else:
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,
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
+1 -1
View File
@@ -217,7 +217,7 @@ _specs = [
),
(
"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,
"operational_task.view",
"task",
+3
View File
@@ -394,6 +394,9 @@ async def execute_query(
if hasattr(args, "field") and args.field not in units:
gaps.append(f"Unit metadata is unavailable for field {args.field}")
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 = [
r
for device in ids
+2
View File
@@ -109,6 +109,8 @@ async def read_complete_scada(
"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 = []
+12 -2
View File
@@ -3,6 +3,8 @@
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, AsyncIterator
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext
from app.infra.db.project_routing import ActiveProjectRouting
@@ -18,16 +20,24 @@ from app.auth.project_dependencies import (
get_project_timescale_connection,
)
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
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)
# Existing connection generators obtain routing before awaiting a pool.
# End that implicit SELECT transaction here, before the network wait.
await self.session.commit()
self._routing[key] = routing
return routing
-19
View File
@@ -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(
*,
start_value: datetime | str | None,
-68
View File
@@ -188,74 +188,6 @@ def _build_area_map_by_topology(
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(
network: str,
sensor_nodes: list[str],
+94
View File
@@ -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,
)
+23 -6
View File
@@ -19,36 +19,53 @@ if TYPE_CHECKING:
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
def track_work_order(task: dict, action: dict) -> None:
def track_work_order(task: dict, action: dict) -> bool:
if task.get("workflow") not in RECOVERY_WORKFLOWS or action["kind"] != "create_work_order":
return
return False
receipt = action.get("receipt") or {}
if action.get("execution_status") != "succeeded" or receipt.get("adapter") != "peripheral_sample":
return
return False
context = task.setdefault("handling_context", {})
orders = context.setdefault("work_orders", {})
order_id = receipt["receipt_id"]
if order_id not in orders:
proposed_at = action.get("proposed_at")
for prior in orders.values():
terminal_at = prior.get("terminal_recorded_at")
# Only a new decision after the failed field attempt replaces it.
# A delayed receipt for an older independent dispatch does not.
if (not prior.get("superseded_by") and prior["status"] in {"failed", "cancelled"}
and proposed_at and terminal_at
and datetime.fromisoformat(proposed_at) > datetime.fromisoformat(terminal_at)):
prior["superseded_by"] = order_id
orders[order_id] = {"status": "awaiting_completion", "action_id": str(action["action_id"])}
context.update(phase="waiting_work_order", title="等待现场工单返回")
context.pop("verification_evidence_id", None)
return True
def accept_completion(task: dict, feedback: dict) -> bool:
def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | None = None) -> bool:
context = task.get("handling_context") or {}
order_id = feedback["source_event_id"].removeprefix("peripheral_sample:")
order = context.get("work_orders", {}).get(order_id)
if order is None:
return False
order.update(status=feedback["status"], source_revision=feedback["source_revision"])
if feedback["status"] in {"failed", "cancelled"}:
if recorded_at is not None and not order.get("terminal_recorded_at"):
order["terminal_recorded_at"] = recorded_at.isoformat()
else:
order.pop("terminal_recorded_at", None)
# The synchronizer preserves the field activity's business time on ordinary
# edits. A revised field receipt can explicitly correct the verification start.
if feedback["status"] == "completed":
order["returned_at"] = feedback["occurred_at"]
else:
order.pop("returned_at", None)
orders = list(context["work_orders"].values())
if all(item["status"] == "completed" for item in orders):
if order.get("superseded_by"):
return True
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
if orders and all(item["status"] == "completed" for item in orders):
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
previous = context.get("returned_at")
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
+252 -150
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
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 app.auth.permissions import (
@@ -49,6 +49,8 @@ from app.domain.schemas.operational_task import (
InvestigationReportRevision,
InvestigationReportReference,
InvestigationProgressSnapshot,
InvestigationProgress,
InvestigationDiagram,
IssueCommandPayload,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
@@ -71,12 +73,18 @@ from app.domain.schemas.operational_task import (
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
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_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)
@@ -89,7 +97,7 @@ def _task_json(model: OperationalTaskSummary) -> dict:
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.context = context
@@ -114,42 +122,14 @@ class OperationalTaskService:
require(owner == command.agent_instance_id,
"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:
if self._supports_runs:
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,
))
return await self.repo.get_active_run(str(task["task_id"]))
async def _append_event(self, **kwargs) -> int | None:
append_event = getattr(self.repo, "append_event", None)
return await append_event(**kwargs) if append_event is not None else None
async def _append_event(self, **kwargs) -> int:
return await self.repo.append_event(**kwargs)
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
list_access_requests = getattr(self.repo, "list_access_requests", None)
if list_access_requests is None:
return []
return await list_access_requests(str(task_id))
return await self.repo.list_access_requests(str(task_id))
def _permission_view(self) -> OperationalTaskPermissions:
permissions = self.permissions
@@ -286,9 +266,10 @@ class OperationalTaskService:
actions = await self.repo.list_actions(str(task_id))
access_requests = await self._list_access_requests(task_id)
active_run = await self._active_run(task)
latest_run = (await self.repo.get_latest_run(str(task_id))
if hasattr(self.repo, "get_latest_run") else active_run)
latest_run = await self.repo.get_latest_run(str(task_id))
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
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
feedback = await self.repo.get_feedback(
@@ -397,9 +378,8 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return TaskAccessRequest.model_validate(replay["access_request"])
availability = getattr(self.repo, "access_requests_available", None)
require(
availability is None or await availability(),
await self.repo.access_requests_available(),
"DEPENDENCY_UNAVAILABLE",
"Task data access requests are not enabled for this project database",
status=503,
@@ -580,6 +560,8 @@ class OperationalTaskService:
return OperationalTaskRun.model_validate(replay["run"])
await self.repo.lock(f"run:{run_id}")
run = await self._run(task_id, run_id)
require(run.get("failure_code") != "SUPERSEDED_BY_REQUEUE", "RUN_SUPERSEDED",
"New task input superseded this run; the replacement run will continue", status=409)
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
"Run revision changed", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
@@ -616,8 +598,7 @@ class OperationalTaskService:
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
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(
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
@@ -772,7 +753,7 @@ class OperationalTaskService:
# Lease recovery already holds a row lock. Try rather than wait
# for the shared run lock to avoid reversing the lock order of
# 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
task_id = UUID(str(run["task_id"]))
expected = run["revision"]
@@ -784,6 +765,7 @@ class OperationalTaskService:
timed_out = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(timed_out), expected)
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)
task = await self._task(task_id)
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
@@ -876,11 +858,27 @@ class OperationalTaskService:
for item in await self.repo.list_actions(str(task_id))
}
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:
require(command.run_id is not None, "RUN_REQUIRED",
"Progress must identify its current execution run", status=422)
if command.progress.diagram:
for node in command.progress.diagram.node_refs:
diagram = command.progress.diagram
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)
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
@@ -889,9 +887,11 @@ class OperationalTaskService:
snapshot = InvestigationProgressSnapshot(
revision=progress_revision, run_id=active_run["run_id"],
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))
saved_diagram = diagram
await self._append_event(
topic="operational.progress.changed", aggregate_type="operational_task_progress",
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
@@ -943,21 +943,32 @@ class OperationalTaskService:
await self.repo.insert_plan(str(task_id), _json(snapshot))
current_plan = snapshot
if command.report:
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
if command.report.stage == "final":
report_input = command.report
if saved_diagram is not None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
report_input = report_input.model_copy(update={
"diagram": diagram,
"evidence_refs": list(dict.fromkeys([
*report_input.evidence_refs,
*(ref for node in diagram.node_refs for ref in node.evidence_refs),
])),
})
self._require_evidence_refs(report_input.evidence_refs, known_evidence)
if report_input.stage == "final":
require_complete_scada_evidence(
task, await self.repo.list_evidence(str(task_id)), command.report.evidence_refs,
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
)
for gap in command.report.data_gaps:
require(set(gap.evidence_refs) <= set(command.report.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",
"Gap evidence must be included in report evidence", status=422)
if command.report.diagram:
for node in command.report.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, set(command.report.evidence_refs))
if report_input.diagram:
for node in report_input.diagram.node_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),
"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 (
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
) for action in actions.values()), "REPORT_ACTION_PENDING",
@@ -967,7 +978,7 @@ class OperationalTaskService:
revision=report_revision,
execution_generation=task["execution_generation"],
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)
await self.repo.insert_report(str(task_id), _json(report))
@@ -980,20 +991,21 @@ class OperationalTaskService:
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
)
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
or previous_report.get("execution_generation") != task["execution_generation"]
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",
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks", "scada_device_ids"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
now=now, reason="report_superseded",
)
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(
execution_generation=task["execution_generation"], revision=report_revision,
),
@@ -1017,25 +1029,9 @@ class OperationalTaskService:
lease_expires_at=now + LEASE_DURATION,
updated_at=now,
)
if self._supports_runs:
await self.repo.update_run(
_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.repo.update_run(
_json(OperationalTaskRun.model_validate(active_run)), run_expected
)
await self._task_event(task, now)
response = {"plan_revision": plan_revision, "report_revision": report_revision}
if progress_revision is not None:
@@ -1100,8 +1096,11 @@ class OperationalTaskService:
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
await self._record_scada_presentation(task, run, payload, now)
if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"])
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
payload["chart"].get("scada") and scada_presentation_coverage.applies(task),
))
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay":
@@ -1139,6 +1138,61 @@ class OperationalTaskService:
await self._save_request(scope, command.request_id, body, _json(result))
return result
async def _record_scada_presentation(self, task: dict, run: dict, payload: dict, now: datetime) -> None:
if not scada_presentation_coverage.applies(task):
return
action = payload["action"]
targets = []
if action == "render_scada_analysis":
targets = [item["device_id"] for item in payload["items"]]
elif action in {"locate_features", "highlight_features"} and payload["layer"] == "scada":
targets = payload["feature_ids"]
elif action != "open_chart":
return
evidence = await self.repo.list_evidence(task["task_id"])
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
if action == "open_chart":
if payload["chart"].get("scada"):
known = {item["evidence_id"] for item in evidence}
self._require_evidence_refs(payload["evidence_refs"], known)
chart = scada_presentation_coverage.verify_chart(task, payload["chart"], evidence, payload["evidence_refs"])
targets = chart["device_ids"]
duplicates = [chart_id for chart_id, stored in (document or {}).get("charts", {}).items()
if chart_id != payload["chart_id"] and stored["role"] == chart["role"] == "comparison"
and any(set(stored.get("coverage", {}).get(device, {}).get("covered", []))
& set(chart["coverage"][device]["covered"])
for device in set(stored["device_ids"]) & set(targets))]
require(not duplicates, "SCADA_CHART_DUPLICATE_DEVICE",
f"设备同期图已存在,请更新原chart_id:{', '.join(duplicates)}", refs=duplicates)
document = scada_presentation_coverage.add_targets(document, targets, observations)
document["charts"][payload["chart_id"]] = chart
elif document and payload["chart_id"] in document["charts"]:
# Replacing a typed chart with a generic one removes its coverage,
# while cumulative targets prevent shrinking the required set.
del document["charts"][payload["chart_id"]]
else:
return
else:
document = scada_presentation_coverage.add_targets(document, targets, observations)
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
async def _verify_scada_report_presentation(self, task: dict, run: dict, report: dict, now: datetime) -> None:
if not scada_presentation_coverage.applies(task):
return
if report.get("stage") != "final" and not report.get("scada_device_ids"):
return
evidence = await self.repo.list_evidence(task["task_id"])
observations = scada_presentation_coverage.ScadaObservations(task, evidence)
document = await self.repo.get_scada_presentation(task["task_id"], str(run["run_id"]))
targets = list(report.get("scada_device_ids") or [])
if targets:
document = scada_presentation_coverage.add_targets(document, targets, observations)
if report.get("stage") == "final":
scada_presentation_coverage.require_complete(task, document, observations)
if document:
await self.repo.save_scada_presentation(task["task_id"], str(run["run_id"]), document, now)
async def propose_action(
self, task_id: UUID, command: ActionProposalCommand
) -> HumanApprovalAction:
@@ -1387,6 +1441,7 @@ class OperationalTaskService:
async with self.repo.transaction():
await self.repo.lock(f"request:{scope}:{command.request_id}")
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"action:{action_id}")
current = await self._action(task_id, action_id)
require(current.version == action.version and current.execution_status == "unknown",
@@ -1422,8 +1477,12 @@ class OperationalTaskService:
)
if outcome.status in {"succeeded", "failed", "unsupported"}:
task_document = await self._task(task_id)
track_work_order(task_document, _json(current))
await self._requeue_task(task_document, outcome_recorded_at)
tracked = track_work_order(task_document, _json(current))
if (tracked or task_document["status"] == "cancelled"
or (task_document.get("source") or {}).get("deleted_at")):
await self._keep_work_order_wait(task_document, outcome_recorded_at)
else:
await self._requeue_task(task_document, outcome_recorded_at)
await self.repo.update_task(
_task_json(OperationalTaskSummary.model_validate(task_document))
)
@@ -1459,6 +1518,7 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return HumanApprovalAction.model_validate(replay["action"])
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"action:{action_id}")
current = await self._action(task_id, action_id)
require(current.version == action.version, "VERSION_CONFLICT",
@@ -1496,8 +1556,12 @@ class OperationalTaskService:
)
if outcome.status in {"succeeded", "failed", "unsupported"}:
task_document = await self._task(task_id)
track_work_order(task_document, _json(current))
await self._requeue_task(task_document, outcome_recorded_at)
tracked = track_work_order(task_document, _json(current))
if (tracked or task_document["status"] == "cancelled"
or (task_document.get("source") or {}).get("deleted_at")):
await self._keep_work_order_wait(task_document, outcome_recorded_at)
else:
await self._requeue_task(task_document, outcome_recorded_at)
await self.repo.update_task(
_task_json(OperationalTaskSummary.model_validate(task_document))
)
@@ -1513,11 +1577,13 @@ class OperationalTaskService:
body = _json(command)
scope = f"task:{task_id}:external-feedback"
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)
if 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":
self._require_actionable_task(task)
own_source = task["source"].get("external_id")
@@ -1599,8 +1665,8 @@ class OperationalTaskService:
"EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409)
else:
await self.repo.insert_evidence(str(task_id), _json(evidence))
self._apply_feedback_status(task, command.status)
if task["task_type"] == "external_work_order":
self._apply_feedback_status(task, command.status)
task["source"] = {
**task["source"],
"external_revision": command.source_revision,
@@ -1620,10 +1686,27 @@ class OperationalTaskService:
# Recover older tasks whose successful dispatch predates tracking.
for item in await self.repo.list_actions(str(task_id)):
track_work_order(task, item)
accept_completion(task, body)
linked_work_order = accept_completion(task, body, recorded_at=now)
if matching_source:
task["source"] = {**task["source"], "external_revision": command.source_revision}
await self._requeue_task(task, now)
feedback_order = (task.get("handling_context") or {}).get("work_orders", {}).get(
command.source_event_id.removeprefix("peripheral_sample:"), {})
if linked_work_order and (feedback_order.get("superseded_by")
or task["handling_context"].get("phase") == "completed"):
# A receipt edit with the same completion time does not
# reopen an incident that already passed recovery review.
task.update(revision=task["revision"] + 1, updated_at=now)
elif (linked_work_order and command.status not in {"failed", "cancelled"}
and task["handling_context"].get("phase") in {"waiting_work_order", "waiting_scada"}):
# Progress is persisted, not a reason to restart analysis.
# Completed field work waits for causal SCADA evidence;
# reconcile_recoveries wakes the same task when it is ready.
await self._keep_work_order_wait(task, now)
elif (linked_work_order and task["handling_context"].get("phase") == "verification_ready"
and await self.repo.get_active_run(str(task_id)) is not None):
await self._keep_work_order_wait(task, now)
else:
await self._requeue_task(task, now)
else:
task.update(revision=task["revision"] + 1, updated_at=now)
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
@@ -1674,11 +1757,13 @@ class OperationalTaskService:
body = _json(command)
scope = f"task:{task_id}:withdraw-external-source"
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)
if replay:
return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
source = task["source"]
require(
task["task_type"] == "external_work_order"
@@ -1984,13 +2069,12 @@ class OperationalTaskService:
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
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(
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
)
require(not pending, "PENDING_CALCULATIONS",
"Wait for submitted calculations before publishing a final report or completing the run",
refs=pending, status=409, recovery="retry_same_request")
pending = await self.repo.pending_calculation_jobs(
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
)
require(not pending, "PENDING_CALCULATIONS",
"Wait for submitted calculations before publishing a final report or completing the run",
refs=pending, status=409, recovery="retry_same_request")
async def _finish_run(
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
@@ -2020,11 +2104,10 @@ class OperationalTaskService:
actions = []
if outcome == "succeeded":
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))
require(not pending_messages, "PENDING_AGENT_MESSAGES",
"Run has messages not yet entered into Agent context",
status=409, recovery="retry_same_request", refs=pending_messages)
pending_messages = await self.repo.pending_run_messages(str(run_id))
require(not pending_messages, "PENDING_AGENT_MESSAGES",
"Run has messages not yet entered into Agent context",
status=409, recovery="retry_same_request", refs=pending_messages)
plan_document = await self.repo.get_latest_plan(str(task_id))
report_document = await self.repo.get_latest_report(str(task_id))
plan = (
@@ -2045,8 +2128,22 @@ class OperationalTaskService:
and plan.source_session_id == command.source_session_id
))
)
actions = [HumanApprovalAction.model_validate(item)
for item in await self.repo.list_actions(str(task_id))]
work_order_wait = task["task_type"] == "event_investigation" and (
(task.get("handling_context") or {}).get("phase") in {"waiting_work_order", "waiting_scada"}
or any(action.kind == "create_work_order" and (
action.approval_status == "pending" or (
action.approval_status == "approved"
and action.execution_status in {"not_submitted", "submitted", "unknown"}
)) for action in actions)
)
if pending_question:
lifecycle = "waiting_human"
elif same_generation and work_order_wait and report.report.stage == "interim":
# Ending a run is distinct from closing an incident. Keep
# the current stage report and release the execution lease.
lifecycle = "waiting_human"
elif not same_generation or report.report.stage != "final":
final_outcome = "failed"
lifecycle = "failed"
@@ -2055,10 +2152,7 @@ class OperationalTaskService:
"Run requires a final report from the same generation"
)
else:
actions = [
HumanApprovalAction.model_validate(item)
for item in await self.repo.list_actions(str(task_id))
]
await self._verify_scada_report_presentation(task, run, _json(report.report), now)
access_requests = [
TaskAccessRequest.model_validate(item)
for item in await self._list_access_requests(task_id)
@@ -2122,7 +2216,7 @@ class OperationalTaskService:
now,
)
elif (lifecycle == "waiting_human" and not pending_question
and (task.get("handling_context") or {}).get("phase") != "waiting_scada"):
and (task.get("handling_context") or {}).get("phase") not in {"waiting_work_order", "waiting_scada"}):
pending_actions = any(
action.approval_status == "pending" for action in actions
)
@@ -2154,8 +2248,6 @@ class OperationalTaskService:
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.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):
command = TaskAlertEvent.model_validate({
**{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}},
@@ -2358,44 +2450,58 @@ class OperationalTaskService:
}[status]
task["agent_lease_expires_at"] = None
async def _keep_work_order_wait(self, task: dict, now: datetime) -> None:
"""Persist a field wait without revoking a run or allocating a generation."""
if task["status"] != "cancelled" and not (task.get("source") or {}).get("deleted_at"):
active = await self.repo.get_active_run(str(task["task_id"]))
if active is not None:
task.update(
lifecycle_status="open", status="queued" if active["status"] == "queued" else "running",
agent_session_id=active.get("agent_session_id"),
agent_lease_expires_at=active.get("lease_expires_at"),
)
else:
task.update(lifecycle_status="waiting_human", status="waiting_human",
agent_session_id=None, agent_lease_expires_at=None)
task.update(revision=task["revision"] + 1, updated_at=now)
async def _requeue_task(self, task: dict, now: datetime) -> None:
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"]))
while active_run is not None:
await self.repo.lock(f"run:{active_run['run_id']}")
# Completion may have won the run lock after our first read.
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
active_run = refreshed_run
break
active_run = await self.repo.get_active_run(str(task["task_id"]))
while active_run is not None:
await self.repo.lock(f"run:{active_run['run_id']}")
# Completion may have won the run lock after our first read.
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
active_run = refreshed_run
if active_run is not None and active_run["status"] == "queued":
# Multiple inputs can arrive before the worker claims the next
# generation. They are all visible when that queued run starts,
# so coalesce them instead of violating the one-active-run rule.
task.update(
lifecycle_status="open", status="queued",
revision=task["revision"] + 1,
execution_generation=active_run["generation"],
execution_request_id=str(active_run["execution_request_id"]),
agent_session_id=None, agent_lease_expires_at=None,
updated_at=now,
)
return
if active_run is not None:
expected = active_run["revision"]
active_run.update(
status="cancelled", revision=expected + 1,
lease_expires_at=None, finished_at=now,
failure_code="SUPERSEDED_BY_REQUEUE",
failure_detail="New task input superseded the active run",
updated_at=now,
)
cancelled = OperationalTaskRun.model_validate(active_run)
await self.repo.update_run(_json(cancelled), expected)
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
await self._run_event(cancelled, now)
break
active_run = refreshed_run
if active_run is not None and active_run["status"] == "queued":
# Multiple inputs can arrive before the worker claims the next
# generation. They are all visible when that queued run starts,
# so coalesce them instead of violating the one-active-run rule.
task.update(
lifecycle_status="open", status="queued",
revision=task["revision"] + 1,
execution_generation=active_run["generation"],
execution_request_id=str(active_run["execution_request_id"]),
agent_session_id=None, agent_lease_expires_at=None,
updated_at=now,
)
return
if active_run is not None:
expected = active_run["revision"]
active_run.update(
status="cancelled", revision=expected + 1,
lease_expires_at=None, finished_at=now,
failure_code="SUPERSEDED_BY_REQUEUE",
failure_detail="New task input superseded the active run",
updated_at=now,
)
cancelled = OperationalTaskRun.model_validate(active_run)
await self.repo.update_run(_json(cancelled), expected)
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
await self._run_event(cancelled, now)
generation = task["execution_generation"] + 1
task_id = UUID(task["task_id"])
task.update(
@@ -2405,9 +2511,8 @@ class OperationalTaskService:
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
)
run = self._new_run(task_id, generation, now)
if self._supports_runs:
await self.repo.insert_run(_json(run))
await self._run_event(run, now, queued=True)
await self.repo.insert_run(_json(run))
await self._run_event(run, now, queued=True)
async def _notify(
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
@@ -2443,10 +2548,7 @@ class OperationalTaskService:
now: datetime,
reason: str,
) -> None:
resolve = getattr(self.repo, "resolve_notifications", None)
if resolve is None:
return
notifications = await resolve(
notifications = await self.repo.resolve_notifications(
str(task_id),
kinds=kinds,
subject_type=subject_type,
+268
View File
@@ -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")
+1 -3
View File
@@ -13,9 +13,7 @@ if TYPE_CHECKING:
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
# Legacy test repositories do not implement question persistence.
reader = getattr(service.repo, "list_questions", None)
return await reader(str(task_id)) if reader else []
return await service.repo.list_questions(str(task_id))
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
+12 -3
View File
@@ -13,7 +13,7 @@ DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
DAY_SECONDS = 86400
def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
def validate_time_comparison_chart(chart: Mapping[str, Any], *, allow_missing_current: bool = False) -> None:
"""Validate new publications; persisted historical charts keep their read contract."""
chart_type = chart["chart_type"]
series = chart["series"]
@@ -70,8 +70,17 @@ def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
current_bases.update(bases)
else:
historical_bases.extend(bases)
check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
current = next(iter(current_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,
+2 -294
View File
@@ -1,11 +1,10 @@
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List
import numpy as np
import pandas as pd
from psycopg import AsyncConnection
from uuid import UUID
from app.algorithms.pipe_health_prediction.survival_predictor import (
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.scada import ScadaInfoRepository
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
class TimeseriesAnalysisService:
"""
复合查询类,提供跨表查询功能
"""
"""SCADA 清洗与管道健康预测;历史查询由 TimeseriesHistoryService 负责。"""
@staticmethod
async def _get_project_scada_index(
@@ -31,294 +27,6 @@ class TimeseriesAnalysisService:
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
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
async def clean_scada_data(
timescale_conn: AsyncConnection,
+34 -32
View File
@@ -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):
descriptors = []
series = []
@@ -66,16 +93,14 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
"source": source, "element_type": element_type,
"id": device_id if source == "scada" else element_id, "field": field,
})
metric = _target_metric(element_type)
series.append(ElementHistorySeries(
series.append(_history_series(
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
) if source == "scada" else (
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
),
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred, points=[],
source_unit=unit, unit_inferred=unit_inferred,
))
# 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,
device_id=device_id, field=field, unit=device["measurement_unit"])
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"
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}
for element_type, element_id in sorted(targets):
metric = _target_metric(element_type)
@@ -202,21 +218,18 @@ class TimeseriesHistoryService:
rows = scada_by_device.get(device_id, [])
if not rows:
continue
metric = _target_metric(element_type)
unit = device["measurement_unit"]
for source, field in (
(HistorySource.SCADA_RAW, "monitored_value"),
(HistorySource.SCADA_CLEANED, "cleaned_value"),
):
series.append(
ElementHistorySeries(
_history_series(
element_id=element_id,
element_type=element_type,
device_id=device_id,
metric=metric,
source=source,
source_unit=unit,
display_unit=display_unit_for_metric(metric.value),
points=_points(
rows,
value_field=field,
@@ -227,15 +240,8 @@ class TimeseriesHistoryService:
if query.mode.value == "observed":
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":
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(
timescale_conn,
query.run_id,
@@ -256,8 +262,6 @@ class TimeseriesHistoryService:
)
source = HistorySource.ANALYSIS_SIMULATION
else:
unit_inferred = False
result_units = current_units
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn,
query.start_time,
@@ -285,13 +289,11 @@ class TimeseriesHistoryService:
continue
source_unit = str(result_units.get(metric.value, "")).strip()
series.append(
ElementHistorySeries(
_history_series(
element_id=target.element_id,
element_type=target.element_type,
metric=metric,
source=source,
source_unit=source_unit,
display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred,
points=_points(
rows,
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
}
}
}
+404 -9
View File
@@ -530,7 +530,7 @@
"progress": {
"anyOf": [
{
"$ref": "#/components/schemas/InvestigationProgress"
"$ref": "#/components/schemas/InvestigationProgressInput"
},
{
"type": "null"
@@ -3540,6 +3540,21 @@
"InvestigationDiagram": {
"additionalProperties": false,
"properties": {
"edges": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/InvestigationGraphEdge"
},
"maxItems": 200,
"type": "array"
},
{
"type": "null"
}
],
"title": "Edges"
},
"mermaid": {
"maxLength": 20000,
"minLength": 1,
@@ -3554,6 +3569,19 @@
"title": "Node Refs",
"type": "array"
},
"source_mermaid": {
"anyOf": [
{
"maxLength": 20000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Mermaid"
},
"title": {
"minLength": 1,
"title": "Title",
@@ -3578,6 +3606,19 @@
"title": "Action Refs",
"type": "array"
},
"detail": {
"anyOf": [
{
"maxLength": 2000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"evidence_refs": {
"items": {
"minLength": 1,
@@ -3608,14 +3649,11 @@
"anyOf": [
{
"enum": [
"pending",
"running",
"completed",
"waiting",
"waiting_human",
"reference",
"recorded",
"needs_input",
"failed",
"skipped",
"cancelled"
"not_taken"
],
"type": "string"
},
@@ -3623,7 +3661,7 @@
"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"
}
},
@@ -3634,6 +3672,153 @@
"title": "InvestigationDiagramNodeRef",
"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": {
"additionalProperties": false,
"properties": {
@@ -3691,6 +3876,42 @@
"title": "InvestigationProgress",
"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": {
"additionalProperties": false,
"properties": {
@@ -3811,6 +4032,23 @@
"title": "Recommendations",
"type": "array"
},
"scada_device_ids": {
"anyOf": [
{
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 1000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Scada Device Ids"
},
"stage": {
"anyOf": [
{
@@ -3923,6 +4161,23 @@
"title": "Recommendations",
"type": "array"
},
"scada_device_ids": {
"anyOf": [
{
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 1000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Scada Device Ids"
},
"stage": {
"enum": [
"interim",
@@ -7162,6 +7417,110 @@
"title": "RunSimulationManuallyByDateRest",
"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": {
"description": "Project SCADA metadata keyed by the canonical device identifier.",
"properties": {
@@ -10229,6 +10588,16 @@
"title": "Chart Type",
"type": "string"
},
"scada": {
"anyOf": [
{
"$ref": "#/components/schemas/ScadaChartContext"
},
{
"type": "null"
}
]
},
"series": {
"items": {
"$ref": "#/components/schemas/TaskSafeChartSeries"
@@ -10306,6 +10675,32 @@
"title": "Data",
"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": {
"maxLength": 128,
"title": "Name",
+101
View File
@@ -207,6 +207,97 @@
"type": "string",
"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": {
"type": "string",
"enum": [
@@ -241,6 +332,16 @@
"type": "string",
"maxLength": 128
},
"device_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"day_offset": {
"type": "integer",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"enum": [
+56
View File
@@ -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
View File
@@ -1,7 +1,8 @@
# TJWater REST API v1
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
resources rather than handler actions.
+3 -3
View File
@@ -14,8 +14,8 @@ flowchart TD
report --> approval[人工审批与现场执行]
approval --> feedback[接收实际处置与恢复反馈]
feedback --> verify[复核影响与结案条件]
verify --> close[来源系统确认事件闭环]
verify -->|新增事实或遗留事项| asset
verify --> close[Server 完成任务并解除告警]
verify -->|仍异常或有未完成处置| asset
```
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
@@ -23,7 +23,7 @@ flowchart TD
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
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 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
+4 -2
View File
@@ -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`
@@ -1,5 +1,7 @@
# Backend Naming Audit
> 历史接口盘点,保留旧名称与修正依据。本文中的旧仓库名、URL 和替代建议不作为当前可用接口清单;现行规范见 [API 风格](../api-style.md),实际契约见 [Server OpenAPI](../../contracts/server-v1.openapi.json)。
DOC-003 audit for the internal `TJWaterServerBinary` backend.
## Scope
@@ -1,5 +1,7 @@
# `app/api/v1/endpoints/` 失效 API 排查与修正
> 历史接口盘点,保留旧名称与修正依据。本文中的旧仓库名、URL 和替代建议不作为当前可用接口清单;现行规范见 [API 风格](../api-style.md),实际契约见 [Server OpenAPI](../../contracts/server-v1.openapi.json)。
排查范围:`app/api/v1/endpoints/`
结论:本次共确认 5 个问题接口,处理结果如下:
@@ -67,7 +69,7 @@ DMA 计算请使用:
服务区计算请使用:
- `/api/v1/calculateservicearea/`
- `/api/v1/calculateservicearea/`
现在返回全部时间步结果,不再接收 `time_index`
## 变更文件
+101 -18
View File
@@ -1,8 +1,9 @@
# 调查进展与实时展示
调查执行中可持续发布正文和完整 Mermaid 图,尚未形成结论时也能发布。
调查执行中可按需整理正文和流程参考图,Server 累计保留路径并生成 Mermaid,尚未形成结论时也能发布。
Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告,
进展中的节点状态不参与调度、审批或结束判断。
图节点不绑定实际执行任务,标记只解释最近整理的路线、结果和限制;不参与调度、审批、等待或结束判断。
路线有实质调整、形成重要结果、需要保留失败尝试或整理报告时更新即可,不要求逐步发布、频繁刷新或及时同步。
## 进展快照
@@ -14,37 +15,73 @@ Server 将阅读进展、业务报告和浏览器展示请求分别保存。运
"run_id": "<current-run-uuid>",
"source_session_id": "<claimed-session-id>",
"progress": {
"summary": "正在核对投诉时段的监测记录,尚未形成结论。",
"diagram": {
"title": "当前调查进展",
"mermaid": "flowchart LR\nread[核查监测记录] --> judge[判断证据范围]",
"node_refs": [{
"summary": "整理投诉时段的监测核查路线,尚未形成结论。",
"graph_update": {
"title": "调查流程参考",
"nodes": [{
"node_id": "read",
"label": "核查监测记录",
"status": "running",
"status": "reference",
"evidence_refs": [],
"action_refs": [],
"map_targets": [{"source_id": "pipes", "feature_id": "<known-pipe-id>"}]
}]
}],
"edges": []
}
}
}
```
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`diagram` 可空。
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`graph_update` 可省略。
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
节点状态可空,或为 `pending/running/completed/waiting_human/failed/skipped/cancelled`。
`map_targets` 只接受 `junctions/pipes/valves/pumps/tanks/reservoirs/scada` 七类来源和要素编号。
图中节点引用在本次发布内唯一,证据和动作引用必须属于该任务。跨修订可以修改节点编号、
分支和文字,Server 不验证依赖执行顺序或节点完工。
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
每次传入 `progress` 都完整替换该 run 的快照,传入 `diagram: null` 清除上一版图示。
省略 `progress` 保留原快照。同一个请求编号重试只发布一次。
| status | 显示 | 含义 |
| --- | --- | --- |
| 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 保存最新一份快照,独立于七天事件清理。
任务详情和 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`,前端据此读取最新内容。
### 旧任务图迁移
在 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` 接收
@@ -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),其中编号来自测试夹具。
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
图表使用普通折线 line、柱状 bar,或当前曲线加历史散点的 time_comparison,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
@@ -103,7 +140,53 @@ MCP 保存完整查询响应作为不可变证据,展示解析器同时支持
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
过程图表通过 `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 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
+5 -1
View File
@@ -47,6 +47,9 @@ node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
可选 status 仅为参考标记:reference(参考步骤)、recorded(已有结果)、needs_input(待补充条件)、
failed(尝试失败)、not_taken(未采用路线)。recorded 不证明业务完成,标记不与任务或工具运行状态绑定。
旧运行状态按[兼容映射](investigation-progress-and-presentation.md)读取,未提供标记时保持中性的参考步骤。
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
## 业务约束
@@ -150,7 +153,8 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
## 发布、阅读与协作
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照持续更新,
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照按需整理,
在路线调整、重要结果或报告归纳时更新即可,不要求逐步更新或及时同步。
见 [调查进展与实时展示](investigation-progress-and-presentation.md)。
报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。
+2 -2
View File
@@ -1,10 +1,10 @@
# 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 自动开放旧 CLI 中的分析执行命令。
本文第一版设计聚焦查询、权限、取消、分页、调查证据和凭据续期。后续已扩展受控分析、预览式写入及后台计算队列,详见实施说明;报告发布、审批、设备控制和调查运行仍遵循各自业务边界,不从旧 CLI 清单自动开放能力。
## 当前系统与功能核对
+3 -3
View File
@@ -1,6 +1,6 @@
# 产品 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)。
@@ -107,10 +107,10 @@ Agent:
```bash
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
```
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边界、字节门槛和合成摘要,不代替真实项目验收。
+4 -2
View File
@@ -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
```
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到源工单事件后按 ID 定向读取工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、项目存储范围与启用步骤见 [外围工单主动同步](peripheral-sample.md#工单事件主动同步)。
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
@@ -210,7 +210,9 @@ SQL 同时增加必填范围约束,不设置数据库默认值。回滚版本
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
`403 EXECUTION_SCOPE_MISMATCH`。这属于配置隔离,不能替代服务凭据授权:
`403 EXECUTION_SCOPE_MISMATCH`。外部工单反馈与来源撤回也必须匹配任务的执行范围,
并在幂等重放前校验;开发环境同步器不能更新生产任务或重置其恢复监测起点。
这属于配置隔离,不能替代服务凭据授权:
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
+2 -3
View File
@@ -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` 查询最近时段。界面仍允许在运行时切换演示、最近和自定义窗口。
本地默认演示窗口为 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 来源、现场反馈或业务审批。
已审核反馈现在可经人工确认[导入独立新证据](field-feedback-import.md),再另行确认建立[人工复核草稿修订](feedback-review-revisions.md)。专业重分析不在本次已确认的样板反馈范围内;生成器不会自动导入或修订成果。
外围业务工单及现场反馈使用 `system_data` 来源,合成生成器不伪造业务上报、人工证据或审批。旧的“反馈导入后建立人工复核草稿”不再作为当前流程;人工事实、外部反馈与调查新轮次的边界见 [工况任务](operational-tasks.md#调查轮次与协作边界),工单来源与事件同步见 [外围服务](peripheral-sample.md)。
+53
View File
@@ -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 注入预设监测结果或邻区投诉情况。
外围服务只保存工单,不直接调用 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、同步器及配套前端。提交代码不证明现有进程已重载。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
-50
View File
@@ -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 秒周期运行。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
+1 -1
View File
@@ -161,7 +161,7 @@ MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
| [压力清洗](../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 异常事件建立的调查;不承担每条观测的检测 |
| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
+1 -1
View File
@@ -9,7 +9,7 @@
- 这个锁会串行化事件写事务,任务仓储的短命令事务(包括心跳)也使用它。这是当前低频业务规模下的明确取舍,不适用于高频遥测或长事务。扩大业务事件量前应实际测量写入等待时间,不把增加锁误称为写吞吐优化。
- 每个进程/项目仍只有一条专用 LISTEN 连接,不长期占用普通请求池。Hub 按唤醒代次共享同游标读取;最多保留 32 个游标批次,每批最多 200 条,慢客户端不会形成无界缓存。事件通知和每 30 秒一次的共享对账使缓存失效;每个浏览器的 15 秒 keepalive 不再触发独立 SQL 查询。无订阅者 60 秒后仍关闭 Hub。
- `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 重建仍鉴权。
- 事件接收游标与数据读取成功分开处理:增量详情、通知或事件触发的运行态快照失败时,只对失败读取按 1、2、4 秒递增至 30 秒重试,成功后才确认失效事件。被并发游标守卫拒绝的快照不能当作刷新成功;仅在更新的成功快照已覆盖失效游标时停止重试。正常读取成功后不安排定时请求;依赖变化、重置或离开页面会取消旧请求及重试定时器。这不是新增正常轮询通道,也不扩大初始化列表的裁剪/重试语义。
-15
View File
@@ -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)。
+17 -1
View File
@@ -7,4 +7,20 @@
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
- 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。
原有三张独立演示工单已迁入 `peripheral_sample.work_orders`,仍由外围样板面板管理,并由同步器投影为 `external_work_order` 工况任务。旧表退役前的完整数据库备份由部署记录保存,不能重新运行历史 `011_workbench_work_orders.sql` 或旧种子脚本重建双份数据。
历史上原有三张独立演示工单已迁入 `peripheral_sample.work_orders`,仍由外围样板面板管理,并由同步器投影为 `external_work_order` 工况任务。旧表退役前的完整数据库备份由部署记录保存,不能重新运行历史 `011_workbench_work_orders.sql` 或旧种子脚本重建双份数据。
## 工单与告警存储
### 任务告警
DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。
### 外围工单
外围服务的 `peripheral_sample.work_orders` 是业务工单的管理来源,当前 `origin` 固定为 `system_data`;`activity` 只记录实际发生的创建、修改和现场反馈,不由总状态补造阶段。同步器将有效工单写入 `workbench.operational_tasks` 的 `external_work_order` 投影。原有三张独立演示工单也已迁入外围样板,并由同一面板管理。
`resources/sql/011_workbench_work_orders.sql` 和 `017_workbench_schema_comments.sql` 属于历史基线;`021_retire_workbench_work_orders.sql` 在验证三个迁入目标及其任务投影后,仅在 `tjwater_v2` 删除旧表。不要重放 `011` 重建它。`GET /api/v1/workbench/runtime` 暂保留空的 `work_orders` 字段作为接口兼容。其他项目数据库没有旧表,不需要应用 `021`。
告警事件表需要 `SELECT, INSERT`,告警状态表需要 `SELECT, INSERT, UPDATE`。迁移及扩展门禁见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。
当前外围工单来源与按项目路由见 [外围服务](peripheral-sample.md),其中 `sample` 只保留兼容名称。上述旧表迁移的历史数据库范围不表示其他项目已应用迁移,也不限制当前 API 的项目路由。
@@ -0,0 +1,14 @@
-- Run-scoped artifact facts survive domain-event retention without a new lifecycle table.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE workbench.operational_task_runs
ADD COLUMN IF NOT EXISTS artifact_manifest jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE workbench.operational_task_runs
DROP CONSTRAINT IF EXISTS operational_task_runs_artifact_manifest_object;
ALTER TABLE workbench.operational_task_runs
ADD CONSTRAINT operational_task_runs_artifact_manifest_object
CHECK (jsonb_typeof(artifact_manifest) = 'object');
COMMENT ON COLUMN workbench.operational_task_runs.artifact_manifest IS
'Server-verified per-run artifact facts. scada_presentation holds cumulative targets and latest chart coverage, independent of browser receipts and event retention; not run status.';
COMMIT;
@@ -2,13 +2,15 @@
name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata:
version: "1.1.1"
version: "1.1.3"
---
# 爆管事件处理
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
先按handling_context接续:等待阶段按第6节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
## 1. 接收与核对
@@ -42,12 +44,14 @@ metadata:
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。已完成本轮可执行分析且依赖结果已读取时发布 final,现场处理进展由后续反馈接续。
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
## 6. 反馈与闭环复核
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
草稿待审批、waiting_work_order或waiting_scada且当前无独立可执行工作时,保存本轮interim/concluded阶段报告并关联真实action_id,然后结束本轮执行,任务保持等待并释放租约。不能只更新progress或沿用旧轮报告,不循环查询工单或重复异常分析。创建成功及普通进度更新不启动新一轮;工单失败或取消时重新决策,完工后由Server等待恢复证据就绪,再唤醒同一任务。
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,发布 final/concluded 报告并引用该证据。运行完成后 Server 才完成任务并解除关联告警。原事件在来源系统的行政结案仍由有权限人员确认。
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,工单 completed 且恢复证据 status=passed 时发布 final/concluded 恢复闭环报告并引用该证据。Server 完成运行后关闭任务并解除关联告警,phase=completed 表示已闭环。照片、阀位或复测记录未提供时仅记为资料缺项,不增设关闭前置条件。来源系统若另有明确行政结案流程,按其实际状态另记,不推断为当前任务尚未闭环。
@@ -2,13 +2,15 @@
name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata:
version: "1.0.13"
version: "1.0.16"
---
# SCADA 数据异常调查
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
## 数据核验与诊断
@@ -17,7 +19,7 @@ metadata:
通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
全量响应中的devices包含完整目录属性,readings包含实际读数,coverage列出全部查询设备、各设备读数数量、缺测和冻结范围外设备。不能只读取已标记、异常或代表设备后推断全网状态。单独读取list_scada_devices时,默认每页100个,持续翻页到page.has_more=false;属性缩略不等于已读取完整设备信息。全量读取后可显式指定device_ids做局部复核,局部读数仍按page.has_more接续,不能替代全量输入。最终报告引用覆盖本轮knowledge_cutoff和batch_sequence的全量证据,Server会拒绝仅有局部或旧批次证据的final。
全量devices含完整目录,readings含观测,coverage含各设备读数数量、缺测及冻结范围外清单。不得由代表测点推断全网。单查list_scada_devices默认每页100,读至page.has_more=false,属性缩略不代表完整信息。全量后可按device_ids局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
@@ -31,7 +33,9 @@ metadata:
## 对比展示与影响
先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。
监测图必填chart.scada:role=current/comparison、metric=pressure/flow、unit、window_start/window_end(三小时);series绑定device_id、day_offset=0..7。evidence_refs包含所有采样来源。报告scada_device_ids声明表格目标。Server在final及成功结束校验本轮总览和同期覆盖,SCADA_PRESENTATION_INCOMPLETE须补图;仅作参考不豁免。缺测须完整查询证明,保留null和设备图;scada.gaps由Server生成。恢复复核沿用恢复门禁。
先查询并发布以本轮knowledge_cutoff或异常时刻t为终点的[t-3小时,t]当前总览,覆盖地图定位、等级标记和表格列出的全部SCADA目标,含正常与异常测点。压力、流量按指标及单位分图,每设备一条序列,name带device_id;每图最多8组,超限按设备分图并用不同稳定chart_id。保留三小时内真实采样网格;截止非整刻不平移采样。超过100槽按连续时间分图,各用稳定chart_id,scada窗口仍填完整三小时;Server合并采样槽覆盖。低频无计划采样槽时,完整查询当天及历史窗口,以截止时刻单个null保留入口。缺测保留设备;“数据”视图沿用全部序列,报告附设备、单位、窗口、有效点数与缺测表。
定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
@@ -41,12 +45,14 @@ metadata:
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以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保持不变。
关联工单completed后,以handling_context.returned_at为实际完工业务时间,不用创建、预约或电脑当前时间代替。Server读取严格晚于该时刻的最近一帧及事件前15天同相位对照,独立保存恢复证据。回放来源工单同样适用,不推进回放,原knowledge_cutoff与batch_sequence不变。
phase=waiting_work_order时等待现场完工;phase=waiting_scada时查看last_check,分别说明尚无后续采样、基线或测点缺项、实际仍异常,等待监测复核,不将原回放查询越界解释为恢复数据不存在。phase=verification_ready时,用get_task_evidence读取verification_evidence_id的完整恢复证据,比较实际样本、正常范围与现场回报,最终报告同时引用原异常全量证据与当前恢复证据。恢复数据已由Server保存,不用query_scada_readings越过原冻结上限重复查询,也不因该上限再建复测工单;确有独立现场工作时才提议新动作。完工时间修订后使用新返回的证据编号,旧证据仅作历史。正常读数不替代阀位、漏点照片等现场事实,报告分别说明监测恢复、现场待核验项与事件闭环。
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
+182
View File
@@ -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()
+2
View File
@@ -1,5 +1,7 @@
# 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
- Current endpoints found: 401
- Missing endpoints: 17
+16 -14
View File
@@ -90,25 +90,27 @@ class CredentialProvider:
self.settings = settings
self._token = ""
self._expires = 0.0
self._refresh_lock = asyncio.Lock()
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
if self.settings.bearer:
token = self.settings.bearer.removeprefix("Bearer ")
else:
if time.monotonic() >= self._expires:
response = await client.post(self.settings.token_url, data={
"grant_type": "client_credentials", "client_id": self.settings.client_id,
"client_secret": self.settings.client_secret,
})
response.raise_for_status()
payload = response.json()
self._token = payload["access_token"]
if not isinstance(self._token, str) or not self._token:
raise ValueError("identity provider returned no access token")
# Product auth also restricts token age, independently of the
# provider's exp. Do not cache a long-lived OAuth token for hours.
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
self._expires = time.monotonic() + max(1, lifetime - 15)
async with self._refresh_lock:
if time.monotonic() >= self._expires:
response = await client.post(self.settings.token_url, data={
"grant_type": "client_credentials", "client_id": self.settings.client_id,
"client_secret": self.settings.client_secret,
})
response.raise_for_status()
payload = response.json()
self._token = payload["access_token"]
if not isinstance(self._token, str) or not self._token:
raise ValueError("identity provider returned no access token")
# Product auth also restricts token age, independently of the
# provider's exp. Do not cache a long-lived OAuth token for hours.
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
self._expires = time.monotonic() + max(1, lifetime - 15)
token = self._token
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
+1 -1
View File
@@ -114,7 +114,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
existing[task["source"].get("external_id")] = task
elif task["task_type"] == "event_investigation":
event_tasks[task.get("source", {}).get("external_id")] = task
if task["status"] in {"queued", "waiting_human", "running"} or (
if task["status"] in {"queued", "waiting_human", "running", "failed"} or (
work_order_ids is not None and task["status"] == "completed"
):
investigations[task["task_id"]] = task
+992
View File
@@ -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
View File
@@ -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())
View File
@@ -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)
+6
View File
@@ -44,6 +44,12 @@ class Repository:
async def get_task(self, task_id):
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):
return self.messages.get(message_id)
@@ -0,0 +1,48 @@
"""Generated fixtures keep executable examples and publication semantics aligned across repositories."""
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
from app.services.time_comparison_chart import validate_time_comparison_chart
FIXTURES = Path(__file__).parents[1] / "fixtures"
CASES = json.loads((FIXTURES / "chart-publication-cases.json").read_text())
@pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"])
def test_chart_publication_matches_frontend_and_agent(case):
def validate():
chart = TaskSafeChart.model_validate(case["chart"]).model_dump(mode="json")
validate_time_comparison_chart(chart)
if case["accepted"]:
validate()
else:
with pytest.raises((DomainError, ValidationError)):
validate()
def test_historical_mixed_charts_remain_structurally_readable():
chart = next(case["chart"] for case in CASES if case["name"] == "old mixed charts are read only")
TaskSafeChart.model_validate(chart)
def test_exported_command_examples_use_the_current_publication_contract():
for line in (FIXTURES / "unified-presentation.jsonl").read_text().splitlines():
record = json.loads(line)
if record["record_type"] != "presentation":
continue
event = TaskPresentationEventCommand.model_validate({
"request_id": "95000000-0000-4000-8000-000000000001",
"run_id": "95000000-0000-4000-8000-000000000002",
"source_session_id": "synthetic-chart-contract",
"command": record["command"],
})
command = event.command
if command.action == "open_chart":
validate_time_comparison_chart(command.chart.model_dump(mode="json"))
+1
View File
@@ -82,6 +82,7 @@ def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypat
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
assert data["coverage"]["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,
)
@@ -28,7 +28,7 @@ def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
stage="final", outcome="concluded", data_gaps=[], diagram=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
@@ -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_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"
with pytest.raises(ValidationError, match="QUER"):
AgentArtifactsCommand.model_validate(payload)
+315
View File
@@ -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}]})
+25
View File
@@ -1116,6 +1116,31 @@ def test_time_series_sql_is_bounded_and_field_names_cannot_inject_sql():
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):
from app.mcp.models import AnalysisValuesInput
from unittest.mock import AsyncMock
+52
View File
@@ -0,0 +1,52 @@
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.auth.project_dependencies import ProjectContext
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
from app.infra.db.project_routing import get_active_project_routing
from app.mcp import scope
@pytest.mark.anyio
async def test_mcp_scope_reads_routing_once_and_refreshes_it_on_the_next_request(monkeypatch):
project = ProjectContext(uuid4(), "fixture", uuid4(), "member")
session = SimpleNamespace(commit=AsyncMock())
connections = []
current_dsn = ["dbname=first"]
async def metadata_session():
yield session
async def read_routing(_repo, project_id, role):
assert project_id == project.project_id and role == "biz_data"
return ProjectDbRouting(project_id, role, "postgresql", current_dsn[0], 1, 4)
@asynccontextmanager
async def connection(project_id, role, dsn, minimum, maximum):
assert session.commit.await_count > 0
connections.append((project_id, role, dsn, minimum, maximum))
yield object()
read = AsyncMock(side_effect=read_routing)
monkeypatch.setattr(scope, "get_metadata_session", metadata_session)
monkeypatch.setattr(scope, "get_current_metadata_user", AsyncMock(return_value=object()))
monkeypatch.setattr(scope, "resolve_project_context", AsyncMock(return_value=project))
# Patch at the repository boundary so both real routing resolvers are exercised.
async def repository_read(self, project_id, role):
return await read(self, project_id, role)
monkeypatch.setattr(MetadataRepository, "get_project_db_routing", repository_read)
monkeypatch.setattr("app.auth.project_dependencies.project_connection_manager.pg_connection", connection)
for dsn in ("dbname=first", "dbname=second"):
current_dsn[0] = dsn
async with scope.project_scope(SimpleNamespace(claims={"sub": "fixture"}), str(project.project_id)) as value:
assert value.routing.business_dsn == dsn
assert get_active_project_routing() is value.routing
assert get_active_project_routing() is None
assert read.await_count == 2
assert [row[2:] for row in connections] == [("dbname=first", 1, 4), ("dbname=second", 1, 4)]
@@ -98,3 +98,57 @@ async def test_presentation_history_storage_filters_project_task_and_committed_c
assert "project_id=%s" in query and "aggregate_id=%s" in query
assert "operational.presentation.requested" in query and "event_seq<=%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
+117 -254
View File
@@ -1,3 +1,4 @@
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
@@ -114,11 +115,6 @@ async def test_access_request_read_uses_table_after_migration():
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
@@ -232,208 +228,12 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
assert exc.value.code == "PERMISSION_DENIED"
def _task_document(context, now, *, lease=None):
task_id = uuid4()
return OperationalTaskSummary(
execution_scope="production",
project_id=context.project_id,
task_id=task_id,
task_type="event_investigation",
workflow="water_quality_complaint",
status="running",
priority="normal",
revision=2,
execution_generation=1,
execution_request_id=uuid4(),
title="调查",
summary="验证状态机",
source={"kind": "operator", "event_kind": "water_quality_complaint"},
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
scheduled_for=None,
agent_session_id="session-1",
agent_lease_expires_at=lease,
current_plan_revision=None,
current_report_revision=None,
created_at=now,
updated_at=now,
).model_dump(mode="json")
class TaskRepository:
def __init__(self, task, *, action=None):
self.task = task
self.action = action
self.requests = {}
self.evidence = {}
self.feedback = {}
self.plans = []
self.reports = []
self.progress = {}
self.notifications = []
self.locks = []
self.access_requests = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock(self, key):
self.locks.append(key)
async def get_request(self, scope, request_id):
return self.requests.get((scope, request_id))
async def save_request(self, scope, request_id, request_digest, response):
self.requests[(scope, request_id)] = {
"request_digest": request_digest,
"response": response,
}
async def get_task(self, task_id):
return self.task if task_id == self.task["task_id"] else None
async def update_task(self, task):
self.task = task
async def get_latest_plan(self, _task_id):
return self.plans[-1] if self.plans else None
async def get_latest_report(self, _task_id):
return self.reports[-1] if self.reports else None
async def get_latest_progress(self, task_id, generation):
return self.progress.get((task_id, generation))
async def save_progress(self, task_id, snapshot):
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
async def get_report(self, _task_id, revision):
return next((item for item in self.reports if item["revision"] == revision), None)
async def list_reports(self, _task_id, limit, offset):
return list(reversed(self.reports))[offset:offset + limit]
async def insert_plan(self, _task_id, snapshot):
self.plans.append(snapshot)
async def insert_report(self, _task_id, snapshot):
self.reports.append(snapshot)
async def list_evidence(self, _task_id):
return list(self.evidence.values())
async def get_evidence(self, _task_id, evidence_id):
return self.evidence.get(evidence_id)
async def insert_evidence(self, _task_id, evidence):
self.evidence[evidence["evidence_id"]] = evidence
async def list_access_requests(self, _task_id):
return list(self.access_requests.values())
async def get_access_request(self, _task_id, access_request_id):
return self.access_requests.get(access_request_id)
async def insert_access_request(self, _task_id, document):
self.access_requests[document["access_request_id"]] = document
async def update_access_request(self, _task_id, document, expected_version):
assert document["version"] == expected_version + 1
self.access_requests[document["access_request_id"]] = document
async def list_actions(self, _task_id):
return [self.action] if self.action else []
async def get_action(self, _task_id, action_id):
if self.action and self.action["action_id"] == action_id:
return self.action
return None
async def update_action(self, _task_id, action):
self.action = action
async def insert_action(self, _task_id, action):
self.action = action
async def insert_notification(self, notification):
self.notifications.append(notification)
async def resolve_notifications(
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
):
resolved = []
for notification in self.notifications:
if (
notification["task_id"] == task_id
and notification["kind"] in kinds
and notification.get("lifecycle_status", "active") == "active"
and notification.get("subject_type") == subject_type
and notification.get("subject_id") == subject_id
):
notification.update(
lifecycle_status="resolved",
resolved_at=resolved_at.isoformat(),
resolution_reason=resolution_reason,
)
resolved.append(notification.copy())
return resolved
async def get_feedback(self, task_id, source_event_id):
return self.feedback.get((task_id, source_event_id))
async def upsert_feedback(self, task_id, feedback, accepted_at):
self.feedback[(task_id, feedback["source_event_id"])] = {
"source_revision": feedback["source_revision"],
"document": feedback,
"accepted_at": accepted_at,
}
class RunTaskRepository(TaskRepository):
def __init__(self, task, run):
super().__init__(task)
self.run = run
self.events = []
async def get_active_run(self, task_id):
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
return self.run
return None
async def get_run(self, run_id):
return self.run if run_id == self.run["run_id"] else None
async def require_execution_storage(self):
return None
async def update_run(self, run, expected_revision):
assert run["revision"] == expected_revision + 1
self.run = run
async def insert_run(self, run):
self.run = run
async def append_event(self, **event):
self.events.append(event)
return len(self.events)
async def current_event_cursor(self):
return len(self.events)
async def list_presentation_events(self, task_id, after, through, limit):
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
if after < index <= through and event["topic"] == "operational.presentation.requested"
and event["aggregate_id"] == task_id][:limit]
def _active_run(task, now):
return OperationalTaskRun(
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
execution_request_id=task["execution_request_id"], status="running", revision=2,
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
heartbeat_at=now, started_at=now, dispatch_attempts=0,
next_dispatch_at=now, created_at=now, updated_at=now,
).model_dump(mode="json")
@pytest.mark.anyio
@@ -457,7 +257,7 @@ async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_sc
run = _active_run(task, now)
run.update(status="queued", revision=1, agent_session_id=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,
execution_scope=worker_scope)
@@ -488,7 +288,7 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
first = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
@@ -514,12 +314,13 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
updated = await service.publish_artifacts(task["task_id"], replacement)
assert updated.progress_revision == 2
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"],
progress={"summary": "当前不需要图示。", "diagram": None},
progress={"summary": "补充本轮说明。", "diagram": None},
))
assert cleared.progress_revision == 3
assert cleared.task.latest_progress.progress.diagram is None
assert retained.progress_revision == 3
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
@@ -535,7 +336,7 @@ async def test_reading_publications_reject_stale_execution_ownership(kind, inval
run["lease_expires_at"] = now.isoformat()
elif invalid == "finished":
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
repository = TaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
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"}
@@ -556,7 +357,7 @@ async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions()
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
@@ -582,7 +383,7 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
baseline = await service.presentation_cursor(task["task_id"])
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
@@ -608,7 +409,7 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
task["source"]["replay_context"] = {
"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)
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
@@ -645,7 +446,7 @@ async def test_comparison_chart_publication_is_validated_at_both_write_boundarie
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
@@ -715,7 +516,7 @@ async def test_replay_cannot_propose_approve_or_submit_device_commands(operation
required_execution_permission="command.execute", approval_status="pending",
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
)
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
repository.action = action.model_dump(mode="json")
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
dispatcher = SimpleNamespace(dispatch=AsyncMock())
@@ -743,7 +544,7 @@ async def test_unified_commands_record_canonical_events_without_mutating_task(pa
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
@@ -762,7 +563,7 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
coverage = build_node_coverage(
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
@@ -795,7 +596,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
origin="system_data", truncated=False,
@@ -844,7 +645,7 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
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)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
@@ -891,7 +692,7 @@ async def test_completed_investigation_rejects_unlinked_or_initial_child_feedbac
task.update(status="completed", lifecycle_status="completed")
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
repository = TaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as unlinked:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
@@ -923,7 +724,7 @@ async def test_original_complaint_feedback_advances_its_source_revision():
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
repository = TaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
@@ -940,7 +741,7 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
updated = await service.heartbeat_run(
@@ -962,7 +763,7 @@ async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_sessio
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
run["agent_instance_id"] = "agent-a"
repository = RunTaskRepository(task, run)
repository = TaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as mismatch:
@@ -982,7 +783,7 @@ async def test_complete_without_same_generation_artifacts_fails_run_explicitly()
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
completed = await service.complete_run(
@@ -1014,7 +815,7 @@ async def test_external_feedback_coalesces_into_an_already_queued_run():
status="queued", revision=1, agent_session_id=None,
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)
result = await service.external_feedback(
@@ -1069,7 +870,7 @@ async def test_scada_final_report_requires_complete_current_input_not_selected_d
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task["workflow"] = "scada_anomaly"
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
"mode": "all_scada", "query_complete": True,
@@ -1124,7 +925,7 @@ async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1",
@@ -1179,7 +980,7 @@ async def test_insufficient_report_rejects_inconsistent_context(defect, expected
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
report = _insufficient_report()
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
@@ -1229,7 +1030,7 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
task["source"]["replay_context"] = {
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
}
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
repository.connection = object()
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
@@ -1308,8 +1109,10 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
evidence_id = revised_id
repository.task.update(status="running", agent_session_id="session-1")
repository.run = _active_run(repository.task, now)
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"],
source_session_id="session-1",
))
_seed_concluded_report(repository, now)
with pytest.raises(DomainError) as rejected:
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
@@ -1334,7 +1137,7 @@ async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
@@ -1369,7 +1172,7 @@ async def test_interim_report_cannot_finish_even_with_a_completed_plan():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
plan = _waiting_plan()
plan["nodes"][0]["status"] = "completed"
@@ -1410,7 +1213,11 @@ async def test_report_is_not_blocked_by_a_historical_plan():
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
))
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
@@ -1418,7 +1225,7 @@ async def test_report_and_action_without_plan_finish_with_real_approval_pending(
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
@@ -1441,7 +1248,7 @@ async def test_insufficient_data_without_graph_or_node_ends_the_round():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
report = _insufficient_report()
report["data_gaps"][0].pop("node_id")
@@ -1463,7 +1270,7 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
for conclusion in ["第一次判断", "更新后的判断"]:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
@@ -1499,7 +1306,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
@@ -1512,7 +1319,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
)
await service.publish_artifacts(task["task_id"], command)
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.
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
@@ -1526,7 +1333,7 @@ async def test_legacy_report_diagram_reads_only_matching_prepublication_progress
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
repository.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
@@ -1559,7 +1366,7 @@ async def test_optional_diagram_cannot_forge_business_references(node_ref, code)
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
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)
with pytest.raises(DomainError) as invalid:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
@@ -1575,7 +1382,7 @@ async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_r
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
@@ -1624,7 +1431,7 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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(
repository,
context,
@@ -1673,7 +1480,7 @@ async def test_action_decision_rejects_a_stale_expected_version():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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,
@@ -1737,7 +1544,7 @@ async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
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)
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
@@ -1771,7 +1578,7 @@ async def test_task_access_request_rejects_a_second_pending_request():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
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)
await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
@@ -1794,7 +1601,7 @@ async def test_task_access_request_fails_closed_before_project_migration():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
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)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
@@ -1813,7 +1620,7 @@ async def test_completed_gate_requires_access_request_decision():
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
context = _context()
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)
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
@@ -1850,7 +1657,7 @@ async def test_work_order_draft_edit_creates_new_review_version(replay):
task["source"]["replay_context"] = {
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
}
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
@@ -1881,7 +1688,7 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
task["source"]["replay_context"] = {
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
}
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, {
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
}, clock=lambda: now)
@@ -2125,7 +1932,7 @@ async def test_expired_owned_lease_is_rejected_with_stable_code():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
@@ -2253,7 +2060,7 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
context = _context()
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)
report = {**rich_report(), "stage": "final"}
command = AgentArtifactsCommand(
@@ -2290,7 +2097,7 @@ async def test_presentation_history_is_task_scoped_paginated_and_readable_after_
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now)
repository = RunTaskRepository(task, _active_run(task, now))
repository = TaskRepository(task, _active_run(task, now))
repository.run["status"] = "succeeded"
for index in range(4):
await repository.append_event(
@@ -2322,7 +2129,7 @@ async def test_final_report_rejected_while_original_run_has_pending_calculations
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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())
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
@@ -2346,7 +2153,7 @@ async def test_completion_rejected_until_calculations_reach_terminal_state():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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())])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
@@ -2380,7 +2187,7 @@ async def test_interim_report_can_explain_progress_while_calculation_is_pending(
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
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())])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
@@ -2487,3 +2294,59 @@ def test_task_scope_is_required_in_persisted_contract():
OperationalTaskSummary.model_validate(task)
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
@@ -0,0 +1,243 @@
"""A field-work wait ends the run without closing or restarting the incident."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import (
ActionExecuteCommand, ActionReconcileCommand, ActionReceipt, AgentRunCommand,
ExternalFeedbackCommand, HumanApprovalAction,
)
from app.infra.integrations.operational_actions import ActionDispatchOutcome
from app.services.operational_recovery import track_work_order
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
from tests.unit.test_operational_tasks import _seed_concluded_report
@pytest.fixture
def anyio_backend():
return "asyncio"
def setup_wait(*, active=True, phase="waiting_work_order"):
now = datetime(2026, 11, 12, 5, 14, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
task.update(workflow="scada_anomaly", lifecycle_status="open")
run = _active_run(task, now)
if not active:
run.update(status="succeeded", lease_expires_at=None, finished_at=now.isoformat())
task.update(status="waiting_human", lifecycle_status="waiting_human",
agent_session_id=None, agent_lease_expires_at=None)
order_id = str(uuid4())
action = HumanApprovalAction(
action_id=uuid4(), task_id=task["task_id"], version=3, kind="create_work_order",
reason="现场检查", payload={"title": "检查", "description": "核验候选点", "work_order_type": "general"},
evidence_refs=[], required_approval_permission="work-order.approve",
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
approval_status="approved", execution_status="succeeded",
proposed_by="principal:test", proposed_at=now,
receipt=ActionReceipt(adapter="peripheral_sample", receipt_id=order_id,
submitted_at=now, completed_at=now, external_status="open", detail="创建成功"),
)
repository = TaskRepository(task, run, action=action.model_dump(mode="json"))
track_work_order(task, repository.action)
task["handling_context"]["phase"] = phase
if phase == "waiting_scada":
task["handling_context"]["returned_at"] = now.isoformat()
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
return service, repository, now, action
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["submit", "reconcile"])
@pytest.mark.parametrize("active", [True, False])
async def test_successful_dispatch_preserves_run_and_parks_only_idle_task(method, active):
service, repository, now, action = setup_wait(active=active)
repository.task["handling_context"] = {}
_seed_concluded_report(repository, now)
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": AsyncMock(
return_value=ActionDispatchOutcome("succeeded", "创建成功", action.receipt))})
run_before = repository.run.copy()
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
command = command_type(request_id=uuid4(), expected_version=action.version)
await getattr(service, f"{method}_action")(action.task_id, action.action_id, command, dispatcher, None)
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
assert repository.task["execution_generation"] == 1
assert repository.run == run_before
assert repository.task["status"] == ("running" if active else "waiting_human")
if active:
await service.heartbeat_run(action.task_id, repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
else:
assert await repository.get_active_run(str(action.task_id)) is None
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["submit", "reconcile"])
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "unsupported"])
async def test_late_dispatch_receipt_is_saved_without_reviving_cancelled_parent(method, outcome):
service, repository, now, action = setup_wait()
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
async def cancelled_while_dispatching(**kwargs):
repository.task.update(status="cancelled", lifecycle_status="cancelled")
repository.run.update(status="cancelled", lease_expires_at=None)
return ActionDispatchOutcome(outcome, "外部回执", action.receipt if outcome == "succeeded" else None)
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": cancelled_while_dispatching})
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
result = await getattr(service, f"{method}_action")(action.task_id, action.action_id,
command_type(request_id=uuid4(), expected_version=action.version), dispatcher, None)
assert result.execution_status == outcome
assert repository.task["status"] == "cancelled"
assert repository.task["execution_generation"] == 1
assert await repository.get_active_run(str(action.task_id)) is None
@pytest.mark.anyio
@pytest.mark.parametrize("phase", ["waiting_work_order", "waiting_scada", "pending_approval"])
async def test_current_interim_report_finishes_run_as_waiting_without_closing_incident(phase):
service, repository, now, action = setup_wait()
if phase == "pending_approval":
repository.task["handling_context"] = {}
repository.action.update(approval_status="pending", execution_status="not_submitted", receipt=None)
else:
repository.task["handling_context"]["phase"] = phase
_seed_concluded_report(repository, now)
repository.reports[-1]["report"]["stage"] = "interim"
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert result.status == result.lifecycle_status == "waiting_human"
assert repository.run["status"] == "succeeded"
assert repository.run["lease_expires_at"] is None
assert result.active_run is None
assert result.execution_generation == 1
assert not any(n["kind"] == "task_failed" for n in repository.notifications)
@pytest.mark.anyio
@pytest.mark.parametrize("defect", ["missing", "old_generation", "other_session", "verification_ready"])
async def test_wait_does_not_bypass_current_report_or_final_recovery_requirements(defect):
service, repository, now, action = setup_wait()
if defect != "missing":
_seed_concluded_report(repository, now)
repository.reports[-1]["report"]["stage"] = "interim"
if defect == "old_generation":
repository.task["execution_generation"] = repository.run["generation"] = 2
repository.reports[-1]["execution_generation"] = 1
elif defect == "other_session":
repository.reports[-1]["source_session_id"] = "another-session"
else:
repository.task["handling_context"]["phase"] = "verification_ready"
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
assert result.status == "failed"
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
@pytest.mark.anyio
@pytest.mark.parametrize("status", ["assigned", "in_progress", "awaiting_feedback", "completed"])
@pytest.mark.parametrize("active", [True, False])
async def test_ordinary_child_feedback_waits_without_starting_or_cancelling_run(status, active):
service, repository, now, action = setup_wait(active=active)
if not active:
# Recover the persisted shape left by the reported ARTIFACT_INCOMPLETE failure.
repository.task.update(status="failed", lifecycle_status="failed")
repository.run.update(status="failed", failure_code="ARTIFACT_INCOMPLETE")
run_before = repository.run.copy()
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
source_revision=2, status=status, occurred_at=now, detail="工单进度更新"))
assert result.task.execution_generation == 1
assert repository.run == run_before
assert result.task.status == ("running" if active else "waiting_human")
assert result.task.handling_context["phase"] == ("waiting_scada" if status == "completed" else "waiting_work_order")
@pytest.mark.anyio
@pytest.mark.parametrize("status", ["failed", "cancelled"])
async def test_failed_or_cancelled_child_wakes_same_task_for_replanning(status):
service, repository, now, action = setup_wait(active=False)
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
source_revision=2, status=status, occurred_at=now, detail="现场处置无法继续"))
assert str(result.task.task_id) == str(action.task_id)
assert result.task.status == "queued" and result.task.execution_generation == 2
@pytest.mark.anyio
@pytest.mark.parametrize("phase", ["verification_ready", "completed"])
async def test_revised_receipt_with_ready_recovery_evidence_does_not_strand_task(phase):
service, repository, now, action = setup_wait(active=False, phase="waiting_scada")
repository.task["handling_context"].update(phase=phase, verification_evidence_id="recovery:saved")
if phase == "completed":
repository.task.update(status="completed", lifecycle_status="completed")
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
source_revision=3, status="completed", occurred_at=now, detail="补充完工说明,实际完工时间不变"))
assert result.task.status == ("completed" if phase == "completed" else "queued")
assert result.task.execution_generation == (1 if phase == "completed" else 2)
assert result.task.handling_context["verification_evidence_id"] == "recovery:saved"
@pytest.mark.anyio
async def test_same_completion_time_revision_does_not_interrupt_active_recovery_review():
service, repository, now, action = setup_wait(phase="waiting_scada")
repository.task["handling_context"].update(phase="verification_ready", verification_evidence_id="recovery:saved")
run_before = repository.run.copy()
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
source_revision=3, status="completed", occurred_at=now, detail="补充备注,完工时间不变"))
assert result.task.status == "running" and result.task.execution_generation == 1
assert repository.run == run_before
@pytest.mark.anyio
@pytest.mark.parametrize("new_decision", [True, False])
@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
async def test_only_new_dispatch_decisions_replace_terminated_work_orders(new_decision, terminal_status):
service, repository, now, action = setup_wait(active=False)
old_id = action.receipt.receipt_id
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=2,
status=terminal_status, occurred_at=now - timedelta(hours=1), detail="需重新安排"))
replacement_id = str(uuid4())
new_action = action.model_dump(mode="json")
new_action.update(action_id=str(uuid4()), proposed_at=(now + timedelta(seconds=1 if new_decision else -1)).isoformat())
new_action["receipt"]["receipt_id"] = replacement_id
track_work_order(repository.task, new_action)
repository.action = new_action
# Finish the replanning run before the replacement's completion arrives.
repository.run.update(status="succeeded", lease_expires_at=None)
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{replacement_id}", source_revision=2,
status="completed", occurred_at=now + timedelta(hours=1), detail="替代处置完成"))
phase = repository.task["handling_context"]["phase"]
assert phase == ("waiting_scada" if new_decision else "waiting_work_order")
if new_decision:
orders = repository.task["handling_context"]["work_orders"]
assert orders[old_id]["superseded_by"] == replacement_id
# A late historical edit cannot interrupt the replacement or change T.
context_before = {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"}
generation = repository.task["execution_generation"]
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=3,
status=terminal_status, occurred_at=now, detail="旧工单备注更新"))
assert repository.task["execution_generation"] == generation
assert {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"} == context_before
@pytest.mark.anyio
async def test_superseded_run_heartbeat_has_distinct_problem_code():
service, repository, now, action = setup_wait()
old_run = repository.run.copy()
await service._requeue_task(repository.task, now)
with pytest.raises(DomainError) as rejected:
await service.heartbeat_run(action.task_id, old_run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=old_run["revision"], source_session_id="session-1"))
assert rejected.value.code == "RUN_SUPERSEDED"
+27
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import os
from uuid import uuid4
@@ -8,6 +9,32 @@ import pytest
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
def anyio_backend():
return "asyncio"
+8 -3
View File
@@ -226,12 +226,13 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio
@pytest.mark.parametrize("targeted", [False, True])
@pytest.mark.parametrize("order_revision", [1, 2])
@pytest.mark.parametrize("order_revision", [1, 4])
@pytest.mark.parametrize(
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[
("waiting_human", 2, True),
("queued", 2, True),
("failed", 2, True),
("completed", 2, True),
("cancelled", 1, False),
],
@@ -254,9 +255,13 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
"updated_at": "2026-09-23T09:00:00Z",
"title": "采样工单", "priority": "normal",
"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": "现场人员",
"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 targeted:
+18 -103
View File
@@ -1,10 +1,10 @@
import asyncio
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from uuid import uuid4
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 = {
@@ -26,107 +26,22 @@ START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc)
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
def _patch_project_scadas(monkeypatch):
monkeypatch.setattr(
composite_queries.ScadaInfoRepository,
"get_scadas",
AsyncMock(return_value=[PROJECT_SCADA.copy()]),
)
def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
_patch_project_scadas(monkeypatch)
query_mock = AsyncMock(
return_value={"J1": [{"time": START_TIME, "value": 26.5}]}
)
monkeypatch.setattr(
composite_queries.RealtimeRepository,
"get_node_fields_by_ids_time_range",
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_history_uses_current_project_metadata(monkeypatch):
postgres = object()
mappings = AsyncMock(return_value=[PROJECT_SCADA.copy()])
monkeypatch.setattr(history.ScadaInfoRepository, "get_scadas_for_elements", mappings)
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,
}]))
result = asyncio.run(history.TimeseriesHistoryService.query(object(), postgres, ElementHistoryQuery(
start_time=START_TIME, end_time=END_TIME,
elements=[{"element_id": "J1", "element_type": "junction", "device_ids": [PROJECT_SCADA["device_id"]]}],
)))
assert mappings.await_args.args == (postgres, ["J1"], [])
assert len(result.series) == 2
assert all(item.device_id == PROJECT_SCADA["device_id"] and item.element_id == "J1" for item in result.series)
assert [item.points[0].value for item in result.series] == [26.5, 26.0]
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
@@ -0,0 +1,471 @@
"""SCADA display coverage is independent of whether a device supports a conclusion."""
from datetime import datetime, timedelta, timezone
from copy import deepcopy
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import AgentArtifactsCommand, AgentRunCommand, TaskPresentationEventCommand
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
DEVICES = ["486033", "542017", "541061", "524529"]
END = datetime(2026, 7, 16, tzinfo=timezone.utc)
START = END - timedelta(hours=3)
@pytest.mark.anyio
async def test_value_only_detail_evidence_cannot_claim_missing_observations():
from app.services.scada_presentation_coverage import verify_chart
task, repository, _, evidence_id = await investigation()
detail = deepcopy(repository.evidence[evidence_id])
detail["evidence_id"] = "detail-values"
rows = detail["data_snapshot"]["data"]["data"]["readings"]
detail["data_snapshot"]["data"]["data"] = [
{"device_id": row["device_id"], "time": row["time"], "value": row["monitored_value"]}
for row in rows
]
value = chart(devices=["524529"])
value["series"][0].update(data=[None] * 13, sample_times=[None] * 13)
with pytest.raises(DomainError) as failure:
verify_chart(task, value, [repository.evidence[evidence_id], detail], ["detail-values"])
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
@pytest.mark.anyio
@pytest.mark.parametrize("field", ["monitored_value", "cleaned_value"])
async def test_detail_fields_do_not_discard_other_observed_fields(field):
from app.services.scada_presentation_coverage import verify_chart
task, repository, _, evidence_id = await investigation()
detail = deepcopy(repository.evidence[evidence_id])
detail["evidence_id"] = "detail-cleaned"
rows = detail["data_snapshot"]["data"]["data"]["readings"]
detail["data_snapshot"]["data"]["data"] = [
{"device_id": row["device_id"], "time": row["time"], "value": row["cleaned_value"] + 10,
"cleaned_value": row["cleaned_value"] + 10} for row in rows
]
value = chart(devices=["524529"])
value["scada"]["value_field"] = field
if field == "cleaned_value":
value["series"][0]["data"] = [point + 10 for point in value["series"][0]["data"]]
result = verify_chart(task, value, [repository.evidence[evidence_id], detail], [evidence_id, "detail-cleaned"])
assert result["gaps"] == []
@pytest.fixture
def anyio_backend():
return "asyncio"
async def investigation():
context = _context()
task = _task_document(context, END, lease=END + timedelta(minutes=1))
task["workflow"] = "scada_anomaly"
repository = TaskRepository(task, _active_run(task, END))
service = OperationalTaskService(repository, context, set(), clock=lambda: END)
readings = []
for day in range(8):
slots = range(13) if day == 0 else range(10, 13)
for slot in slots:
for device in DEVICES:
readings.append({"device_id": device, "time": (START + timedelta(minutes=15 * slot, days=-day)).isoformat(),
"monitored_value": float(slot + 1), "cleaned_value": float(slot + 1)})
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:observations", queried_at=END,
origin="system_data", truncated=False, data={
"window": {"start": (START - timedelta(days=7)).isoformat(), "end": END.isoformat()},
"data": {"devices": [{"device_id": device, "device_type": "pipe_flow", "measurement_unit": "m3/h",
"transmission_frequency": "0:15:00"} for device in DEVICES],
"readings": readings, "coverage": {"mode": "all_scada", "query_complete": True}},
},
))
return task, repository, service, evidence.evidence_id
def coverage(comparison_devices):
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
return {"version": 1, "targets": DEVICES.copy(), "charts": {
"overview": {**common, "role": "current", "device_ids": DEVICES.copy()},
**{f"comparison-{device}": {**common, "role": "comparison", "device_ids": [device],
"coverage": {device: {"covered": slots}}}
for device in comparison_devices},
}}
def final_report(evidence_id):
return {"stage": "final", "outcome": "concluded", "title": "调查结果",
"current_conclusion": "524529 仅作参考", "conclusion_boundary": "待现场核验",
"evidence_refs": [evidence_id], "data_gaps": [], "scada_device_ids": DEVICES}
@pytest.mark.anyio
async def test_final_rejects_four_target_devices_with_three_comparisons():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES[:3])}
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert "524529" in failure.value.message
assert not repository.reports
@pytest.mark.anyio
async def test_final_accepts_all_four_devices():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES)}
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert result.report_revision == 1
def chart(role="current", devices=None):
devices = DEVICES if devices is None else devices
comparison = role == "comparison"
series = []
for device in devices:
for day in (range(8) if comparison else [0]):
slots = range(13) if day == 0 else range(10, 13)
series.append({"name": f"{device} {day}", "device_id": device, "day_offset": day,
"type": "scatter" if day else "line",
"data": [float(index + 1) if index in slots else None for index in range(13)],
"sample_times": [(START + timedelta(minutes=15 * index, days=-day)).isoformat()
if index in slots else None for index in range(13)]})
return {"title": "流量观测", "chart_type": "time_comparison" if comparison else "line",
"x_data": [(START + timedelta(minutes=15 * index, hours=8)).strftime("%H:%M") for index in range(13)],
"series": series, "scada": {"role": role, "metric": "flow", "unit": "m3/h",
"window_start": START.isoformat(), "window_end": END.isoformat()}}
async def publish(task, repository, service, evidence_id, chart_id, data):
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": chart_id, "chart": data, "evidence_refs": [evidence_id]},
)
return await service.publish_presentation_event(task["task_id"], command)
@pytest.mark.anyio
async def test_publications_require_complete_targets_and_retry_without_a_browser():
task, repository, service, evidence_id = await investigation()
await publish(task, repository, service, evidence_id, "overview", chart())
for device in DEVICES[:3]:
await publish(task, repository, service, evidence_id, device, chart("comparison", [device]))
interim = {**final_report(evidence_id), "stage": "interim"}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=interim,
))
final = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], final)
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert failure.value.refs == ["524529"]
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "close_chart", "chart_id": "524529"},
))
result = await service.publish_artifacts(task["task_id"], final)
assert result.task.latest_report.report.stage == "final"
# Neither retained event history nor a browser acknowledgement is required.
repository.events.clear()
complete = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert complete.latest_run.status == "succeeded"
@pytest.mark.anyio
async def test_successful_completion_rechecks_coverage_after_final_report():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = coverage(DEVICES)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
# Replacing a chart cannot erase its fourth target from the required set.
await publish(task, repository, service, evidence_id, "overview", chart(devices=DEVICES[:3]))
with pytest.raises(DomainError) as failure:
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert "524529" in failure.value.message
assert repository.run["status"] == "running"
@pytest.mark.anyio
@pytest.mark.parametrize("missing_current", [False, True])
async def test_missing_observations_keep_the_object_and_server_derived_gaps(missing_current):
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = [row for row in data["readings"] if row["device_id"] != "524529"
or not missing_current and datetime.fromisoformat(row["time"]) >= START]
value = chart("comparison", ["524529"])
for series in value["series"]:
if missing_current or series["day_offset"]:
series["data"] = [None] * 13
series["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "524529", value)
saved = repository.events[-1]["metadata"]["command"]["chart"]
assert len(saved["scada"]["gaps"]) == (8 if missing_current else 7)
assert sum(gap["missing_count"] for gap in saved["scada"]["gaps"]) == (34 if missing_current else 21)
document = await repository.get_scada_presentation(task["task_id"], repository.run["run_id"])
assert document["targets"] == ["524529"]
assert document["charts"]["524529"]["device_ids"] == ["524529"]
@pytest.mark.anyio
@pytest.mark.parametrize("defect,code", [
("null_existing_value", "SCADA_CHART_DATA_MISMATCH"),
("invented_value", "SCADA_CHART_DATA_MISMATCH"),
("wrong_window", "SCADA_CHART_WINDOW_INVALID"),
("missing_day", "SCADA_CHART_INVALID"),
("wrong_unit", "SCADA_CHART_INVALID"),
("unqueried_history", "SCADA_CHART_EVIDENCE_REQUIRED"),
])
async def test_scada_chart_cannot_fake_coverage(defect, code):
task, repository, service, evidence_id = await investigation()
value = chart("comparison", ["524529"])
if defect == "null_existing_value":
value["series"][1]["data"][10] = None
value["series"][1]["sample_times"][10] = None
elif defect == "invented_value":
value["series"][0]["data"][0] = 999.
elif defect == "wrong_window":
value["scada"]["window_end"] = (END - timedelta(minutes=15)).isoformat()
elif defect == "missing_day":
value["series"].pop()
elif defect == "wrong_unit":
value["scada"]["unit"] = "m"
elif defect == "unqueried_history":
repository.evidence[evidence_id]["data_snapshot"]["data"]["window"]["start"] = START.isoformat()
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, "524529", value)
assert failure.value.code == code
assert not repository.scada_presentations
@pytest.mark.anyio
async def test_previous_run_charts_never_satisfy_a_new_run():
task, repository, service, evidence_id = await investigation()
original = repository.run["run_id"]
repository.scada_presentations[(task["task_id"], original)] = coverage(DEVICES)
task["execution_generation"] += 1
repository.run = _active_run(task, END)
repository.runs[repository.run["run_id"]] = repository.run
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert original != repository.run["run_id"]
assert (task["task_id"], original) in repository.scada_presentations
@pytest.mark.anyio
async def test_one_device_cannot_inflate_comparison_object_count_with_a_new_chart_id():
task, repository, service, evidence_id = await investigation()
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, "524529-duplicate", chart("comparison", ["524529"]))
assert failure.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
assert failure.value.refs == ["524529"]
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
assert len(repository.scada_presentations[(task["task_id"], repository.run["run_id"])]["charts"]) == 1
@pytest.mark.anyio
async def test_server_frozen_boundary_proves_unavailable_history_without_fabricating_a_query():
task, repository, service, evidence_id = await investigation()
replay_id = str(uuid4())
task["source"]["replay_context"] = {"replay_id": replay_id, "knowledge_cutoff": END.isoformat(), "batch_sequence": 3}
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
snapshot["window"]["start"] = START.isoformat()
snapshot["data"]["coverage"].update(replay_id=replay_id, batch_sequence=3, frozen_start=START.isoformat())
value = chart("comparison", ["524529"])
for series in value["series"][1:]:
series["data"] = [None] * 13
series["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "524529", value)
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
assert len(gaps) == 7
assert {gap["reason"] for gap in gaps} == {"outside_frozen_window"}
@pytest.mark.anyio
async def test_interim_monitoring_table_targets_cannot_be_removed_from_a_later_final_report():
task, repository, service, evidence_id = await investigation()
incomplete = coverage(DEVICES[:3])
incomplete["targets"] = DEVICES[:3]
incomplete["charts"]["overview"]["device_ids"] = DEVICES[:3]
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = incomplete
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "stage": "interim"},
))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": DEVICES[:3]},
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert failure.value.refs == ["524529"]
@pytest.mark.anyio
async def test_narrative_diagram_references_do_not_declare_business_display_targets():
task, repository, service, evidence_id = await investigation()
selected = DEVICES[:3]
document = coverage(selected)
document["targets"] = selected
document["charts"]["overview"]["device_ids"] = selected
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = document
report = {**final_report(evidence_id), "scada_device_ids": selected, "diagram": {
"title": "待选参考测点", "mermaid": "flowchart LR\nreference[参考测点]", "node_refs": [{
"node_id": "reference", "label": "参考测点", "map_targets": [{"source_id": "scada", "feature_id": "524529"}],
}],
}}
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
assert result.report_revision == 1
@pytest.mark.anyio
async def test_unaligned_cutoff_keeps_actual_quarter_hour_samples():
task, repository, service, evidence_id = await investigation()
task["trigger"]["occurred_at"] = (END + timedelta(minutes=8)).isoformat()
value = chart("comparison", ["524529"])
value["scada"].update(window_start=(START + timedelta(minutes=8)).isoformat(),
window_end=(END + timedelta(minutes=8)).isoformat())
value["x_data"] = value["x_data"][1:]
for item in value["series"]:
item["data"] = item["data"][1:]
item["sample_times"] = item["sample_times"][1:]
await publish(task, repository, service, evidence_id, "unaligned", value)
@pytest.mark.anyio
@pytest.mark.parametrize("frequency", ["15min", "15分钟"])
async def test_wholly_missing_devices_accept_supported_cadence_formats(frequency):
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = []
for device in data["devices"]:
device["transmission_frequency"] = frequency
value = chart("comparison", ["524529"])
for item in value["series"]:
item["data"] = [None] * 13
item["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "missing", value)
@pytest.mark.anyio
async def test_one_minute_chunks_require_union_of_verified_slots():
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["devices"] = [{**data["devices"][0], "transmission_frequency": "1min"}]
device = DEVICES[0]
data["readings"] = [{"device_id": device, "time": (START + timedelta(minutes=i, days=-day)).isoformat(),
"monitored_value": float(i + 1)}
for day in range(8) for i in (range(181) if day == 0 else range(178, 181))]
def chunk(role, first, stop, missing_current=False):
value = chart(role, [device])
value["x_data"] = [(START + timedelta(minutes=i, hours=8)).strftime("%H:%M") for i in range(first, stop)]
for item in value["series"]:
day = item["day_offset"]
present = lambda i: (not day or i >= 178) and not (missing_current and day == 0)
item["data"] = [float(i + 1) if present(i) else None for i in range(first, stop)]
item["sample_times"] = [(START + timedelta(minutes=i, days=-day)).isoformat() if present(i) else None
for i in range(first, stop)]
return value
report = {**final_report(evidence_id), "scada_device_ids": [device]}
for role in ("current", "comparison"):
await publish(task, repository, service, evidence_id, role + "-first", chunk(role, 0, 100))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
with pytest.raises(DomainError) as duplicate:
await publish(task, repository, service, evidence_id, "overlap", chunk("comparison", 99, 181))
assert duplicate.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
for role in ("current", "comparison"):
await publish(task, repository, service, evidence_id, role + "-last", chunk(role, 100, 181))
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
# Missing current data still permits the last chunk's historical dates across midnight.
data["readings"] = [row for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
await publish(task, repository, service, evidence_id, "comparison-last", chunk("comparison", 100, 181, True))
@pytest.mark.parametrize("start,end,labels,historical", [
("2026-07-16T05:08:00+08:00", "2026-07-16T08:08:00+08:00", ["07:45", "08:00"],
["2026-07-15T07:45:00+08:00", "2026-07-15T08:00:00+08:00"]),
("2026-07-15T22:08:00+08:00", "2026-07-16T01:08:00+08:00", ["00:45", "01:00"],
["2026-07-15T00:45:00+08:00", "2026-07-15T01:00:00+08:00"]),
])
def test_missing_current_chunk_uses_its_first_slot_date(start, end, labels, historical):
from app.services.time_comparison_chart import validate_time_comparison_chart
validate_time_comparison_chart({"chart_type": "time_comparison", "x_data": labels,
"scada": {"role": "comparison", "window_start": start, "window_end": end},
"series": [{"type": "line", "data": [None, None], "sample_times": [None, None]},
{"type": "scatter", "data": [1., 2.], "sample_times": historical}]},
allow_missing_current=True)
@pytest.mark.anyio
async def test_missing_current_preserves_observed_historical_sampling_phase():
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = [{**row, "time": (datetime.fromisoformat(row["time"]) + timedelta(minutes=5)).isoformat()}
for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
value = chart("comparison", ["524529"])
value["x_data"] = [(START + timedelta(minutes=15 * i + 5, hours=8)).strftime("%H:%M") for i in range(12)]
for item in value["series"]:
day = item["day_offset"]
item["data"] = [float(i + 1) if day and i >= 9 else None for i in range(12)]
item["sample_times"] = [(START + timedelta(minutes=15 * i + 5, days=-day)).isoformat()
if day and i >= 9 else None for i in range(12)]
# The final three corresponding slots are now 23:20,23:35,23:50 UTC.
for day in range(1, 8):
data["readings"].append({"device_id": "524529", "time": (START + timedelta(minutes=140, days=-day)).isoformat(),
"monitored_value": 10.})
await publish(task, repository, service, evidence_id, "historical-phase", value)
@pytest.mark.anyio
@pytest.mark.parametrize("point_only", [False, True])
async def test_six_hour_devices_with_no_scheduled_slots_require_full_absence_proof(point_only):
task, repository, service, evidence_id = await investigation()
end = END + timedelta(hours=2)
start = end - timedelta(hours=3)
task["trigger"]["occurred_at"] = end.isoformat()
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
snapshot["window"] = {"start": (end if point_only else start - timedelta(days=7)).isoformat(), "end": end.isoformat()}
snapshot["data"]["readings"] = []
for device in snapshot["data"]["devices"]:
device["transmission_frequency"] = "6:00:00"
for role in ("current", "comparison"):
value = chart(role, ["524529"])
value["scada"].update(window_start=start.isoformat(), window_end=end.isoformat())
value["x_data"] = ["10:00"]
for item in value["series"]:
item["data"], item["sample_times"] = [None], [None]
if point_only:
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, role, value)
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
else:
await publish(task, repository, service, evidence_id, role, value)
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
assert all(gap["reason"] == "no_scheduled_samples" for gap in gaps)
if not point_only:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": ["524529"]}))
+31 -19
View File
@@ -1,6 +1,7 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from unittest.mock import AsyncMock
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.services.operational_tasks import OperationalTaskService
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
@@ -18,28 +19,11 @@ def anyio_backend():
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():
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
context = _context()
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)
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": "目前未知"}],
@@ -162,6 +146,34 @@ async def test_real_execution_failure_cancels_question_instead_of_disguising_fai
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
async def test_multiple_selection_and_optional_question_are_preserved():
service, repo, task_id, command = setup()