Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45c5c6f46 | ||
|
|
2e4f6cb93b | ||
|
|
471ad92a47 | ||
|
|
1892b1d1f0 | ||
|
|
2e8fe86f3f | ||
|
|
b853b61a0b | ||
|
|
e47c61c121 | ||
|
|
6a4478bfe4 |
@@ -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)。
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
|
||||
'cancel_task_run',
|
||||
'run_simulation',
|
||||
'run_contaminant_simulation',
|
||||
'run_contaminant_source_trace',
|
||||
'analyze_valve_closure',
|
||||
'analyze_flushing',
|
||||
'analyze_pressure_regulation',
|
||||
|
||||
@@ -5,13 +5,16 @@ 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, StringConstraints, 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,
|
||||
)
|
||||
|
||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||
from app.domain.schemas.report_authoring import (
|
||||
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchDateTime,
|
||||
@@ -222,25 +225,65 @@ 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):
|
||||
label: NonEmptyStr | None = Field(default=None, description=(
|
||||
"Required for a new node. Omit or use null to retain an existing node's label. "
|
||||
"Use the same node_id to revise status or detail without adding another step."
|
||||
))
|
||||
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 +327,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):
|
||||
@@ -308,12 +352,44 @@ class InvestigationReport(StrictModel):
|
||||
return self
|
||||
|
||||
|
||||
class InvestigationDataGapInput(InvestigationDataGap):
|
||||
"""New gap descriptions use business prose; references remain opaque."""
|
||||
|
||||
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
|
||||
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
|
||||
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
|
||||
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
|
||||
|
||||
@field_validator("missing_data", "scope", "impact", "required_data")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str) -> str:
|
||||
return validate_report_text(value)
|
||||
|
||||
|
||||
class InvestigationReportInput(InvestigationReport):
|
||||
"""Write model: every new report explicitly declares its business result."""
|
||||
|
||||
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
|
||||
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
|
||||
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
|
||||
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
|
||||
stage: Literal["interim", "final"]
|
||||
outcome: Literal["concluded", "insufficient_data"]
|
||||
data_gaps: list[InvestigationDataGap]
|
||||
data_gaps: list[InvestigationDataGapInput]
|
||||
|
||||
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_authoring(self):
|
||||
for value in [*self.recommendations, *self.pending_items]:
|
||||
validate_report_text(value)
|
||||
validate_report_ui_text(self.ui_blocks)
|
||||
return self
|
||||
|
||||
|
||||
class InvestigationProgress(StrictModel):
|
||||
@@ -323,6 +399,22 @@ class InvestigationProgress(StrictModel):
|
||||
diagram: InvestigationDiagram | None = None
|
||||
|
||||
|
||||
class InvestigationProgressInput(InvestigationProgress):
|
||||
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
|
||||
graph_update: InvestigationGraphUpdate | None = None
|
||||
|
||||
@field_validator("summary")
|
||||
@classmethod
|
||||
def validate_reader_summary(cls, value: str) -> str:
|
||||
return validate_report_text(value)
|
||||
|
||||
@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 +459,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 +469,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 +788,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", "quality"]
|
||||
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 +835,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
|
||||
@@ -835,11 +949,72 @@ TaskPresentationCommand = Annotated[
|
||||
]
|
||||
|
||||
|
||||
class TaskPresentationNarration(StrictModel):
|
||||
stage: Literal[
|
||||
"anomaly_detection", "evidence_verification", "asset_localization",
|
||||
"event_impact", "source_control", "isolation_plan", "flushing_plan",
|
||||
"field_feedback", "clearance_verification", "recovery_verification",
|
||||
]
|
||||
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
|
||||
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_narration(self):
|
||||
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
|
||||
raise ValueError("presentation narration must contain readable text")
|
||||
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||
raise ValueError("presentation narration evidence IDs must be unique")
|
||||
return self
|
||||
|
||||
|
||||
TaskPresentationPurpose = Annotated[
|
||||
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
|
||||
]
|
||||
|
||||
|
||||
class TaskPresentationNarrationInput(TaskPresentationNarration):
|
||||
"""New live findings use business prose; saved narration remains readable."""
|
||||
|
||||
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
|
||||
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
|
||||
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
|
||||
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
|
||||
|
||||
@field_validator("summary", "next_step")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
|
||||
class TaskPresentationEventCommand(StrictModel):
|
||||
request_id: UUID
|
||||
run_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
command: TaskPresentationCommand
|
||||
narration: TaskPresentationNarrationInput | None = None
|
||||
purpose: TaskPresentationPurpose | None = Field(default=None,
|
||||
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
|
||||
|
||||
@field_validator("purpose")
|
||||
@classmethod
|
||||
def validate_reader_purpose(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_visible_labels(self):
|
||||
validate_presentation_command_text(self.command.model_dump())
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize_legacy_command(self, handler):
|
||||
value = handler(self)
|
||||
# Keep old idempotency digests stable when no narration was supplied.
|
||||
if self.narration is None:
|
||||
value.pop("narration", None)
|
||||
if self.purpose is None:
|
||||
value.pop("purpose", None)
|
||||
return value
|
||||
|
||||
|
||||
class TaskPresentationEventResult(StrictModel):
|
||||
@@ -870,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
|
||||
# Stored records are validated against the current command contract by the
|
||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||
command: dict[str, JsonValue]
|
||||
narration: TaskPresentationNarration | None = None
|
||||
purpose: TaskPresentationPurpose | None = None
|
||||
|
||||
|
||||
class TaskPresentationHistoryPage(StrictModel):
|
||||
@@ -946,6 +1123,13 @@ class HumanEvidenceCommand(StrictModel):
|
||||
evidence: EvidenceInput
|
||||
|
||||
|
||||
class WorkOrderNotificationReference(StrictModel):
|
||||
source_event_id: NonEmptyStr
|
||||
source_revision: PositiveInt
|
||||
evidence_id: NonEmptyStr | None = None
|
||||
status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"]
|
||||
|
||||
|
||||
class OperationalTaskNotification(StrictModel):
|
||||
notification_id: UUID
|
||||
task_id: UUID
|
||||
@@ -953,7 +1137,7 @@ class OperationalTaskNotification(StrictModel):
|
||||
"task_created", "action_pending", "report_updated", "task_completed",
|
||||
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
||||
"action_approval_pending", "human_input_required",
|
||||
"external_coordination_failed"
|
||||
"external_coordination_failed", "work_order_feedback"
|
||||
]
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
@@ -962,6 +1146,8 @@ class OperationalTaskNotification(StrictModel):
|
||||
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||
subject_id: UUID | None = None
|
||||
report_reference: InvestigationReportReference | None = None
|
||||
work_order_action: HumanApprovalAction | None = None
|
||||
work_order_reference: WorkOrderNotificationReference | None = None
|
||||
resolved_at: WorkbenchDateTime | None = None
|
||||
resolution_reason: NonEmptyStr | None = None
|
||||
status: Literal["unread", "read"]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Reader-facing text checks for new publications, never historical reads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock
|
||||
|
||||
|
||||
_execution_text = re.compile(
|
||||
r"\b(?:execution_generation|handling_context|verification_ready|waiting_work_order|"
|
||||
r"waiting_scada|returned_at|verified_through|required_samples|interval_seconds|"
|
||||
r"knowledge_cutoff|batch_sequence|query_complete|evidence_id|action_id|run_id|"
|
||||
r"task_id|source_session_id|approval_status|execution_status|verification_evidence_id|"
|
||||
r"normal_day_count|baseline_method|selection_method|get_task_context|query_scada_readings|"
|
||||
r"run_burst_location|publish_task_report|publish_task_progress)\b|"
|
||||
r"\b(?:status|phase|interval)\s*[:=]|\b(?:mcp|recovery|sha256):|"
|
||||
r"\bskill(?:\.(?:name|version))?\s*[:=]|\bskill\s+[\w-]+\s+v?\d|"
|
||||
r"\b(?:final|interim)/concluded\b|"
|
||||
r"[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}",
|
||||
re.IGNORECASE | re.ASCII,
|
||||
)
|
||||
_source_time = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}|UTC\s*[+-]\s*\d|Asia/Shanghai|上海时间|"
|
||||
r"\d{2}:\d{2}(?::\d{2})?\s*\+\d{2}:\d{2}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_unrendered_markup = re.compile(r"`|\*\*|</?[A-Za-z][^>]*>|^\s{0,3}#{1,6}\s", re.MULTILINE)
|
||||
|
||||
|
||||
def validate_report_text(value: str) -> str:
|
||||
if _execution_text.search(value):
|
||||
raise ValueError("请将执行字段和状态码改写为中文业务说明,完整编号保留在结构化引用中")
|
||||
if _source_time.search(value):
|
||||
raise ValueError("请将来源时间转换为当地日期和时刻,正文省略时区名称、偏移和 ISO 时间戳")
|
||||
if _unrendered_markup.search(value):
|
||||
raise ValueError("可见文字使用纯文本;重点与明细使用对应结果组件,不写 Markdown 或 HTML")
|
||||
return value
|
||||
|
||||
|
||||
def validate_report_ui_text(blocks: list[ReportUiBlock]) -> None:
|
||||
for item in blocks:
|
||||
validate_report_text(item.block.fallback_text)
|
||||
for element in item.block.spec.elements.values():
|
||||
props = element.props
|
||||
for key in ("title", "summary", "text", "description", "x_axis_name", "y_axis_name"):
|
||||
if isinstance(props.get(key), str):
|
||||
validate_report_text(props[key])
|
||||
for value in props.get("columns", []):
|
||||
validate_report_text(value)
|
||||
for row in props.get("rows", []):
|
||||
for value in row:
|
||||
validate_report_text(value)
|
||||
for value in props.get("x_data", []):
|
||||
validate_report_text(value)
|
||||
for series in props.get("series", []):
|
||||
validate_report_text(series["name"])
|
||||
|
||||
|
||||
def validate_presentation_command_text(command: dict[str, Any]) -> None:
|
||||
"""Check visible labels without touching geometry, references or timestamps."""
|
||||
if command["action"] == "set_overlay":
|
||||
validate_report_text(command["title"])
|
||||
elif command["action"] == "open_chart":
|
||||
chart = command["chart"]
|
||||
for key in ("title", "x_axis_name", "y_axis_name"):
|
||||
if isinstance(chart.get(key), str):
|
||||
validate_report_text(chart[key])
|
||||
for value in chart["x_data"]:
|
||||
validate_report_text(value)
|
||||
for series in chart["series"]:
|
||||
validate_report_text(series["name"])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput):
|
||||
return self
|
||||
|
||||
|
||||
class ContaminantSourceTraceInput(CalculationInput):
|
||||
candidate_node_ids: list[Id] = Field(min_length=1, max_length=20,
|
||||
description="根据事件位置、附近资产或水质设备映射取得的候选模型节点;Server核验节点存在后稳定返回一个候选。当前为临时定位接口,尚未执行污染反演")
|
||||
start: datetime = Field(description="带时区的事件核查窗口开始")
|
||||
end: datetime = Field(description="带时区的事件核查窗口结束")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_window(self):
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("timestamps require explicit timezone")
|
||||
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||
raise ValueError("window must be positive and at most 24 hours")
|
||||
if len(set(self.candidate_node_ids)) != len(self.candidate_node_ids):
|
||||
raise ValueError("candidate_node_ids must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class ValveIsolationInput(CalculationInput):
|
||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
|
||||
@@ -143,6 +160,10 @@ class ContaminantSimulationInput(ScenarioInput):
|
||||
source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
||||
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
||||
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源")
|
||||
include_impact_coverage: bool = Field(default=False, strict=True,
|
||||
description="估计处置前的污染传播范围;在筛选展示节点前汇总全网节点整个模拟时段的浓度,保存影响节点、首个超过阈值的报告时刻、峰值及地图覆盖")
|
||||
impact_threshold_mg_l: float = Field(default=0, ge=0, allow_inf_nan=False,
|
||||
description="模型影响范围的浓度阈值,mg/L,按严格大于判定;默认0表示模型中出现正浓度,仅为情景展示条件,不是饮用水安全限值。沿用模型初始水质与反应条件")
|
||||
|
||||
|
||||
class ValveClosureInput(ScenarioInput):
|
||||
@@ -150,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
|
||||
|
||||
|
||||
class FlushingInput(ScenarioInput):
|
||||
drainage_node_id: Id
|
||||
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
|
||||
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
|
||||
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
|
||||
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
|
||||
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def distinct_valve_states(self):
|
||||
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
|
||||
raise ValueError("flushing valve lists must contain distinct IDs")
|
||||
if set(self.closed_valves) & set(self.opened_valves):
|
||||
raise ValueError("a flushing valve cannot be both opened and closed")
|
||||
return self
|
||||
|
||||
|
||||
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
||||
|
||||
@@ -114,3 +114,30 @@ async def calculate_replay_burst_location(
|
||||
final_candidates_csv_path=None,
|
||||
**parameters,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||
"""An independent current-model scenario, with no live SCADA substitution."""
|
||||
return await bounded_calculation(
|
||||
calculate_scenario, "run_burst_simulation",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||
network=network, username=username, model_only=True,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||
"""Estimate propagation in a current-model scenario without live SCADA substitution."""
|
||||
return await bounded_calculation(
|
||||
calculate_scenario, "run_contaminant_simulation",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||
network=network, username=username, model_only=True,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_replay_flushing(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||
"""Evaluate a model-only flushing plan without live SCADA or field execution."""
|
||||
return await bounded_calculation(
|
||||
calculate_scenario, "analyze_flushing",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||
network=network, username=username, model_only=True,
|
||||
)
|
||||
|
||||
@@ -166,6 +166,19 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||
raise HTTPException(422, "offset requires a collection data_path")
|
||||
else:
|
||||
result.data = value
|
||||
if not args.data_path and binding.task_id:
|
||||
from app.mcp.presentation_preview import saved_burst_result_preview, saved_presentation_preview
|
||||
|
||||
presentation = saved_presentation_preview(result)
|
||||
if presentation is not None and isinstance(result.data, dict):
|
||||
result.data = {**result.data, "presentation": presentation}
|
||||
if len(result.model_dump_json().encode()) > 11000:
|
||||
preview = saved_burst_result_preview(result, str(binding.task_id))
|
||||
if preview is not None:
|
||||
result.data = preview
|
||||
result.truncated = True
|
||||
result.data_gaps = [*result.data_gaps,
|
||||
"Inline burst impact summary only; full node comparisons and geometry are saved in task evidence. Read the evidence resource or select a narrower data_path."]
|
||||
if len(result.model_dump_json().encode()) > 11000:
|
||||
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||
return result
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@ from mcp import types
|
||||
from app.mcp import models as m
|
||||
from app.mcp import calculation_models as jobs
|
||||
from app.mcp import extended_models as e
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ContaminantSourceTraceInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||
|
||||
|
||||
@@ -62,10 +62,11 @@ class QueryTool:
|
||||
|
||||
|
||||
_specs = [
|
||||
("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
|
||||
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
|
||||
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
|
||||
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True),
|
||||
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
|
||||
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||
@@ -217,7 +218,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",
|
||||
@@ -258,7 +259,7 @@ _specs += [
|
||||
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
||||
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;返回给定情景的模拟结果,正式方案通过方案工具保存", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("run_burst_simulation", "在隔离模型中模拟管道破口;include_impact_coverage=true时按起算同刻已有在线模拟计算全网节点压力下降及1/3/5米水头分档覆盖,保存为展示证据;回放仅用当前模型情景,不替代冻结观测", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
|
||||
|
||||
@@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput):
|
||||
|
||||
|
||||
class BurstSimulationInput(ScenarioInput):
|
||||
include_impact_coverage: bool = Field(default=False, strict=True, description="比较起算时刻全网节点与已有在线模拟压力,按下降1≤Δ<3、3≤Δ<5、Δ≥5米水头生成事件影响覆盖;不重算或覆盖在线基线")
|
||||
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
|
||||
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积,单位cm²;保留面积来源及估算依据")
|
||||
|
||||
|
||||
@@ -189,11 +189,12 @@ def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||
if args.scenario == "flushing":
|
||||
from app.native.wndb.model.options import get_option
|
||||
from app.mcp.scenario_service import flushing_valve_controls
|
||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||
raise ValueError("Flushing supports LPS or CMH models only")
|
||||
return scenarios.flushing_analysis(**common, username=username,
|
||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
||||
valve_control=flushing_valve_controls(parameters))
|
||||
mappings = query_corresponding_element_id_and_query_id(network)
|
||||
controls = parameters["pump_control"]
|
||||
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
||||
|
||||
@@ -5,8 +5,34 @@ from uuid import NAMESPACE_URL, uuid5
|
||||
from app.mcp.models import QueryResult
|
||||
|
||||
|
||||
def saved_burst_result_preview(result: QueryResult, task_id: str) -> dict | None:
|
||||
"""Keep durable full-network results usable without copying node arrays inline."""
|
||||
if result.source != "run_burst_simulation" or not result.evidence_id:
|
||||
return None
|
||||
data = result.data if isinstance(result.data, dict) else {}
|
||||
analysis = data.get("result", {})
|
||||
impact = analysis.get("impact") if isinstance(analysis, dict) else None
|
||||
if not isinstance(impact, dict):
|
||||
return None
|
||||
summary = {key: value for key, value in impact.items()
|
||||
if key not in {"comparisons", "grades", "unavailable_node_ids"}}
|
||||
summary["unavailable_node_count"] = len(impact.get("unavailable_node_ids", []))
|
||||
summary["grades"] = [
|
||||
{**{key: value for key, value in grade.items() if key != "node_ids"},
|
||||
"node_count": len(grade.get("node_ids", []))} for grade in impact.get("grades", [])
|
||||
]
|
||||
preview = {
|
||||
"resource_uri": f"tjwater://tasks/{task_id}/evidence/{result.evidence_id}",
|
||||
"parameters": data.get("parameters", {}), "result": {"impact": summary},
|
||||
}
|
||||
presentation = saved_presentation_preview(result)
|
||||
if presentation is not None:
|
||||
preview["presentation"] = presentation
|
||||
return preview
|
||||
|
||||
|
||||
def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||
if result.source != "generate_node_coverage" or not result.evidence_id:
|
||||
if result.source not in {"generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation"} or not result.evidence_id:
|
||||
return None
|
||||
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||
if not isinstance(analysis, dict):
|
||||
@@ -15,11 +41,18 @@ def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||
if not isinstance(payload, dict) or not payload.get("features"):
|
||||
return None
|
||||
coverage = analysis.get("coverage", {})
|
||||
burst = result.source == "run_burst_simulation"
|
||||
contaminant = result.source == "run_contaminant_simulation"
|
||||
if burst and analysis.get("impact", {}).get("phase") != "after_burst_before_intervention":
|
||||
return None
|
||||
if contaminant and analysis.get("impact", {}).get("phase") != "after_contamination_before_intervention":
|
||||
return None
|
||||
return {
|
||||
"command": {
|
||||
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
|
||||
"title": "节点空间覆盖示意",
|
||||
"title": ("爆管后、处置前的预计影响范围(米水头)" if burst else
|
||||
"水污染传播预计影响范围(处置前情景)" if contaminant else "节点空间覆盖示意"),
|
||||
},
|
||||
"display_only": True,
|
||||
"coverage_status": coverage.get("status"),
|
||||
|
||||
@@ -75,16 +75,28 @@ async def authorize_query(
|
||||
if replay_context:
|
||||
from app.services.scada_replay_queries import check_replay_window
|
||||
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
|
||||
"query_scada_readings", "get_network_schema", "run_burst_location",
|
||||
"query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace",
|
||||
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
|
||||
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
||||
"list_regions", "get_region", "get_region_nodes",
|
||||
"analyze_valve_isolation", "generate_node_coverage",
|
||||
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
|
||||
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||
if tool.name not in allowed:
|
||||
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings")
|
||||
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
|
||||
if tool.name == "run_burst_simulation":
|
||||
if not args.include_impact_coverage:
|
||||
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
|
||||
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||
if tool.name == "run_contaminant_simulation":
|
||||
if not args.include_impact_coverage:
|
||||
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
|
||||
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||
if tool.name == "analyze_flushing":
|
||||
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||
if tool.name == "query_scada_readings":
|
||||
check_replay_window(replay_context, args.start, args.end)
|
||||
if tool.name == "run_contaminant_source_trace":
|
||||
check_replay_window(replay_context, args.start, args.end)
|
||||
if tool.name == "run_burst_location":
|
||||
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||
@@ -107,6 +119,8 @@ async def authorize_query(
|
||||
required = {tool.permission}
|
||||
if tool.name in SCENARIO_TOOLS:
|
||||
required.add("scada.view")
|
||||
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
|
||||
required.add("simulation.view")
|
||||
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
|
||||
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
|
||||
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
|
||||
@@ -182,7 +196,15 @@ async def execute_query(
|
||||
source_ref = "mcp:" + str(uuid4())
|
||||
evidence_id = None
|
||||
replay_context = getattr(binding, "replay_context", None)
|
||||
if name == "query_scada_readings" and args.device_ids is None:
|
||||
if name == "run_contaminant_source_trace":
|
||||
from app.services.contaminant_source_trace import trace_contaminant_source
|
||||
|
||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
|
||||
"result": await trace_contaminant_source(conn, args)}
|
||||
origin = "synthetic"
|
||||
if data["result"]["located_node"] is None:
|
||||
gaps.append(data["result"]["notice"])
|
||||
elif name == "query_scada_readings" and args.device_ids is None:
|
||||
from app.mcp.scada_readings import read_complete_scada
|
||||
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||
elif name == "query_scada_readings" and replay_context:
|
||||
@@ -191,6 +213,39 @@ async def execute_query(
|
||||
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||
data, page, truncated = page_result(rows, args)
|
||||
origin = "synthetic"
|
||||
elif name == "run_burst_simulation" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_burst_simulation
|
||||
|
||||
await conn.commit()
|
||||
async with scope.simulation_routing():
|
||||
result = await calculate_replay_burst_simulation(
|
||||
args, network=scope.project.project_code, username=scope.user.username,
|
||||
)
|
||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||
origin = "synthetic"
|
||||
gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"]
|
||||
elif name == "run_contaminant_simulation" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
|
||||
|
||||
await conn.commit()
|
||||
async with scope.simulation_routing():
|
||||
result = await calculate_replay_contaminant_simulation(
|
||||
args, network=scope.project.project_code, username=scope.user.username,
|
||||
)
|
||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||
origin = "synthetic"
|
||||
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
|
||||
elif name == "analyze_flushing" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_flushing
|
||||
|
||||
await conn.commit()
|
||||
async with scope.simulation_routing():
|
||||
result = await calculate_replay_flushing(
|
||||
args, network=scope.project.project_code, username=scope.user.username,
|
||||
)
|
||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||
origin = "synthetic"
|
||||
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
|
||||
elif name == "run_burst_location" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||
@@ -241,7 +296,7 @@ async def execute_query(
|
||||
}
|
||||
gaps = [{
|
||||
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
|
||||
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
|
||||
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
|
||||
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
|
||||
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
|
||||
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
|
||||
@@ -252,7 +307,7 @@ async def execute_query(
|
||||
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
||||
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
|
||||
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
|
||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
|
||||
}[name]]
|
||||
if name == "calculate_demand_allocation":
|
||||
truncated = data["result"]["has_more"] or args.offset > 0
|
||||
@@ -394,6 +449,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
|
||||
@@ -488,6 +546,18 @@ async def execute_query(
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||
truncated = True
|
||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
||||
if skill.get("name") == "water-pollution-event":
|
||||
data["source_trace_capability"] = {
|
||||
"available": "run_contaminant_source_trace" in binding.tool_cap,
|
||||
"tool": "run_contaminant_source_trace",
|
||||
"algorithm_status": "provisional",
|
||||
"required_inputs": ["candidate_node_ids", "start", "end"],
|
||||
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
|
||||
"source_control", "analyze_valve_isolation", "generate_node_coverage",
|
||||
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
|
||||
"work_order_wait_blocks_analysis": False,
|
||||
"simulation_parameters": {"include_impact_coverage": True},
|
||||
}
|
||||
if (skill.get("name") == "scada-anomaly" and replay_context
|
||||
and "run_burst_location" in binding.tool_cap):
|
||||
from app.services.burst_location_demo import location_demo_context
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -10,7 +10,14 @@ SCENARIO_TOOLS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
|
||||
def flushing_valve_controls(parameters: dict[str, Any]) -> dict[str, dict[str, str]]:
|
||||
"""Apply explicit states without changing regulating-valve settings."""
|
||||
return {**{valve: {"status": "CLOSED"} for valve in parameters.get("closed_valves", [])},
|
||||
**{valve: {"status": "OPEN"} for valve in parameters.get("opened_valves", [])}}
|
||||
|
||||
|
||||
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
|
||||
model_only: bool = False) -> dict[str, Any]:
|
||||
from app.services import simulation_scenarios as scenarios
|
||||
|
||||
parameters = dict(parameters)
|
||||
@@ -18,6 +25,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
links = parameters.pop("output_link_ids")
|
||||
start = parameters.pop("start_time")
|
||||
duration = parameters.pop("duration")
|
||||
include_impact = parameters.pop("include_impact_coverage", False)
|
||||
impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
|
||||
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
||||
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
||||
if name == "run_simulation":
|
||||
@@ -27,11 +36,27 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
||||
)
|
||||
elif name == "run_burst_simulation":
|
||||
boundary = {}
|
||||
if model_only:
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
# Replay scenarios use current model assumptions, never mutable live
|
||||
# SCADA readings in place of the investigation's frozen observations.
|
||||
boundary["scada_mappings"] = ScadaElementMappings(
|
||||
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||
pressure={}, demand={}, quality={},
|
||||
)
|
||||
result = scenarios.burst_analysis(**common, username=username,
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
|
||||
elif name == "run_contaminant_simulation":
|
||||
boundary = {}
|
||||
if model_only:
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
boundary["scada_mappings"] = ScadaElementMappings(
|
||||
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||
pressure={}, demand={}, quality={},
|
||||
)
|
||||
result = scenarios.contaminant_simulation(**common, username=username,
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
|
||||
elif name == "analyze_valve_closure":
|
||||
result = scenarios.valve_close_analysis(**common,
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||
@@ -41,9 +66,16 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
from app.native.wndb.model.options import get_option
|
||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||
raise ValueError("Flushing currently supports LPS or CMH models only")
|
||||
boundary = {}
|
||||
if model_only:
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
boundary["scada_mappings"] = ScadaElementMappings(
|
||||
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||
pressure={}, demand={}, quality={},
|
||||
)
|
||||
result = scenarios.flushing_analysis(**common, username=username,
|
||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
||||
valve_control=flushing_valve_controls(parameters), **boundary)
|
||||
elif name == "analyze_pressure_regulation":
|
||||
from app.services.simulation import query_corresponding_element_id_and_query_id
|
||||
mappings = query_corresponding_element_id_and_query_id(network)
|
||||
@@ -62,7 +94,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
raise ValueError("Scenario did not return calculation results")
|
||||
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
|
||||
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
|
||||
return {
|
||||
output = {
|
||||
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
||||
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
||||
"start_time": start.isoformat(), "duration_seconds": duration,
|
||||
@@ -72,3 +104,28 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
|
||||
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
|
||||
}
|
||||
if name == "run_burst_simulation" and include_impact:
|
||||
from app.services.burst_impact import estimate_burst_impact
|
||||
impact = estimate_burst_impact(network, start, result, model_only=model_only)
|
||||
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||
output.update(impact)
|
||||
if name == "run_contaminant_simulation" and include_impact:
|
||||
from app.services.contaminant_impact import estimate_contaminant_impact
|
||||
impact = estimate_contaminant_impact(network, start, duration, result,
|
||||
threshold_mg_l=impact_threshold, model_only=model_only)
|
||||
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||
output.update(impact)
|
||||
if name == "analyze_flushing":
|
||||
output["flushing_plan"] = {
|
||||
"opened_valves": parameters.get("opened_valves", []),
|
||||
"closed_valves": parameters.get("closed_valves", []),
|
||||
"drainage_node_id": parameters["drainage_node_id"],
|
||||
"flushing_flow_m3h": parameters["flushing_flow_m3h"],
|
||||
"estimated_discharge_volume_m3": parameters["flushing_flow_m3h"] * duration / 3600,
|
||||
"state_basis": "independent_current_model_scenario",
|
||||
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||
"result_scope": "hydraulic_only",
|
||||
"field_execution_verified": False,
|
||||
"water_quality_clearance_verified": False,
|
||||
}
|
||||
return output
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import yaml
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||
WORKFLOW_SKILLS = {
|
||||
"scada_anomaly": "scada-anomaly",
|
||||
"water_pollution": "water-pollution-event",
|
||||
"burst_report": "burst-event-handling",
|
||||
"water_quality_complaint": "water-quality-complaint",
|
||||
"general": "appointment-follow-up",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
|
||||
|
||||
from datetime import datetime
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from app.infra.db.timescaledb.internal_queries import InternalQueries
|
||||
from app.native.wndb.model.options import get_option, get_option_v3
|
||||
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
|
||||
|
||||
GRADES = (
|
||||
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
|
||||
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
|
||||
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
|
||||
)
|
||||
|
||||
|
||||
def _head_factor(unit: Any) -> float | None:
|
||||
# Standard water head; unsupported or absent units must not become zero loss.
|
||||
return {"m": 1.0, "meters": 1.0, "metres": 1.0,
|
||||
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
|
||||
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
|
||||
"ft": 0.3048}.get(str(unit).strip().lower())
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return None
|
||||
return result if isfinite(result) else None
|
||||
|
||||
|
||||
def _instant(value: Any) -> datetime | None:
|
||||
try:
|
||||
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return result if result.tzinfo is not None else None
|
||||
|
||||
|
||||
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
|
||||
model_only: bool = False) -> dict[str, Any]:
|
||||
"""Compare every native node at t=0, before the adapter filters requested outputs.
|
||||
|
||||
The online snapshot is a model reference read at calculation time, not frozen
|
||||
SCADA or a reconstructed historical model. All numerical inputs are retained
|
||||
in task evidence so playback does not repeat this mutable reference query.
|
||||
"""
|
||||
impact: dict[str, Any] = {
|
||||
"status": "unavailable", "phase": "after_burst_before_intervention",
|
||||
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
|
||||
"baseline_source": "existing_online_simulation",
|
||||
"baseline_read_at": datetime.now().astimezone().isoformat(),
|
||||
"baseline_units_source": "current_model_settings",
|
||||
"model_temporality": "current_model", "baseline_frozen": False,
|
||||
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
|
||||
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
|
||||
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
|
||||
}
|
||||
output = {"impact": impact}
|
||||
# Never silently shift t=0 to an unknown/delayed report sample or another day.
|
||||
times = simulation.get("times", {})
|
||||
sample_times = times.get("sample_times")
|
||||
if ((sample_times and _instant(sample_times[0]) != start)
|
||||
or (not sample_times and times.get("report_start") != 0)):
|
||||
impact["reason"] = "simulation_has_no_verified_start_time_sample"
|
||||
return output
|
||||
options = get_option(network)
|
||||
options_v3 = get_option_v3(network)
|
||||
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
|
||||
impact["baseline_units_source"] = (
|
||||
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
|
||||
)
|
||||
impact["model_conditions"] = {
|
||||
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
|
||||
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
|
||||
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
|
||||
}
|
||||
scenario_unit = simulation.get("units", {}).get("pressure")
|
||||
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
|
||||
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
|
||||
if baseline_factor is None or scenario_factor is None:
|
||||
impact["reason"] = "unsupported_or_missing_pressure_units"
|
||||
return output
|
||||
nodes = simulation.get("node_results", [])
|
||||
ids = [str(row["node"]) for row in nodes]
|
||||
if not ids or len(ids) != len(set(ids)):
|
||||
impact["reason"] = "missing_or_duplicate_simulation_nodes"
|
||||
return output
|
||||
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
|
||||
ids, start, start, "node", "pressure", db_name=network,
|
||||
)
|
||||
comparisons, missing = [], []
|
||||
for row in nodes:
|
||||
node_id = str(row["node"])
|
||||
samples = row.get("result") or []
|
||||
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
|
||||
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
|
||||
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
|
||||
if burst is None or normal is None:
|
||||
missing.append(node_id)
|
||||
continue
|
||||
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
|
||||
drop = normal_m - burst_m
|
||||
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
|
||||
missing.append(node_id)
|
||||
continue
|
||||
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
|
||||
"burst_m": burst_m, "pressure_drop_m": drop})
|
||||
for group in impact["grades"]:
|
||||
if drop >= group["min_inclusive_m"] and (
|
||||
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
|
||||
):
|
||||
group["node_ids"].append(node_id)
|
||||
break
|
||||
impact.update(status="partial" if missing else "ready", node_count=len(ids),
|
||||
compared_node_count=len(comparisons), unavailable_node_ids=missing,
|
||||
comparisons=comparisons)
|
||||
affected = sum(len(group["node_ids"]) for group in impact["grades"])
|
||||
impact["affected_node_count"] = affected
|
||||
impact["below_threshold_node_count"] = len(comparisons) - affected
|
||||
if not comparisons:
|
||||
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
|
||||
return output
|
||||
if affected > MAX_COVERAGE_NODES:
|
||||
impact["coverage_status"] = "unavailable"
|
||||
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
|
||||
return output
|
||||
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
|
||||
for group in impact["grades"]]
|
||||
geometry = generate_node_coverage(network, groups)
|
||||
impact["coverage_status"] = geometry["coverage"]["status"]
|
||||
geometry["coverage"]["boundary"] = impact["boundary"]
|
||||
for feature in geometry["map_payload"]["features"]:
|
||||
feature["properties"].update(
|
||||
impact_phase=impact["phase"], pressure_drop_unit="m",
|
||||
pressure_drop_grade=feature["properties"]["coverage_group"],
|
||||
comparison_time=start.isoformat(), model_estimate=True,
|
||||
)
|
||||
if geometry["coverage"]["status"] == "partial":
|
||||
impact["status"] = "partial"
|
||||
output.update(geometry)
|
||||
return output
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Estimate a scenario's time-cumulative concentration coverage before output filtering."""
|
||||
from datetime import datetime, timedelta
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
|
||||
|
||||
|
||||
def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]:
|
||||
explicit = metadata.get("sample_times")
|
||||
if explicit is not None:
|
||||
times = [datetime.fromisoformat(value) for value in explicit]
|
||||
else:
|
||||
offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods"))
|
||||
if (any(type(value) is not int for value in (offset, step, count))
|
||||
or offset < 0 or step <= 0 or not 0 < count <= 86401):
|
||||
raise ValueError("missing or invalid report grid")
|
||||
times = [start + timedelta(seconds=offset + index * step) for index in range(count)]
|
||||
if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times)
|
||||
or any(not start <= at <= start + timedelta(seconds=duration) for at in times)
|
||||
or any(left >= right for left, right in zip(times, times[1:]))):
|
||||
raise ValueError("invalid simulation sample times")
|
||||
return times
|
||||
|
||||
|
||||
def estimate_contaminant_impact(network: str, start: datetime, duration: int,
|
||||
simulation: dict[str, Any], *, threshold_mg_l: float = 0,
|
||||
model_only: bool = False) -> dict[str, Any]:
|
||||
"""Use every native node and report sample; never infer propagation from requested nodes."""
|
||||
impact: dict[str, Any] = {
|
||||
"status": "unavailable", "phase": "after_contamination_before_intervention",
|
||||
"window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(),
|
||||
"threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L",
|
||||
"threshold_purpose": "scenario_display_not_drinking_water_standard",
|
||||
"model_temporality": "current_model", "quantity": "total_model_concentration",
|
||||
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
|
||||
"boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;"
|
||||
"首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。",
|
||||
}
|
||||
output = {"impact": impact}
|
||||
unit = simulation.get("units", {}).get("quality")
|
||||
factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get(
|
||||
str(unit).strip().lower().replace("μ", "u").replace("µ", "u"))
|
||||
impact["input_concentration_unit"] = unit
|
||||
if factor is None:
|
||||
impact["reason"] = "unsupported_or_missing_concentration_unit"
|
||||
return output
|
||||
try:
|
||||
times = _sample_times(start, duration, simulation.get("times", {}))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
impact["reason"] = "simulation_has_no_verified_sample_times"
|
||||
return output
|
||||
rows = simulation.get("node_results", [])
|
||||
ids = [str(row["node"]) for row in rows]
|
||||
if not ids or len(ids) != len(set(ids)):
|
||||
impact["reason"] = "missing_or_duplicate_simulation_nodes"
|
||||
return output
|
||||
affected, unavailable, incomplete = [], [], []
|
||||
for row in rows:
|
||||
node = str(row["node"])
|
||||
samples = row.get("result") or []
|
||||
if len(samples) > len(times):
|
||||
impact["reason"] = "simulation_sample_count_mismatch"
|
||||
return output
|
||||
valid = []
|
||||
for at, sample in zip(times, samples):
|
||||
value = sample.get("quality") if isinstance(sample, dict) else None
|
||||
if isinstance(value, bool) or value is None:
|
||||
continue
|
||||
try:
|
||||
concentration = float(value) * factor
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if isfinite(concentration) and concentration >= 0:
|
||||
valid.append((at, concentration))
|
||||
if not valid:
|
||||
unavailable.append(node)
|
||||
continue
|
||||
if len(valid) != len(times):
|
||||
incomplete.append(node)
|
||||
exceedances = [(at, value) for at, value in valid if value > threshold_mg_l]
|
||||
if exceedances:
|
||||
peak_at, peak = max(valid, key=lambda item: item[1])
|
||||
affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(),
|
||||
"peak_mg_l": peak, "peak_at": peak_at.isoformat(),
|
||||
"valid_sample_count": len(valid), "complete": len(valid) == len(times)})
|
||||
impact.update(status="partial" if unavailable or incomplete else "ready",
|
||||
node_count=len(ids), report_sample_count=len(times),
|
||||
reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(),
|
||||
unavailable_node_ids=unavailable, incomplete_node_ids=incomplete,
|
||||
affected_node_count=len(affected), nodes=affected,
|
||||
affected_node_ids=[item["node_id"] for item in affected])
|
||||
if len(unavailable) == len(ids):
|
||||
impact.update(status="unavailable", reason="no_valid_concentration_samples")
|
||||
return output
|
||||
if len(affected) > MAX_COVERAGE_NODES:
|
||||
impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit")
|
||||
return output
|
||||
geometry = generate_node_coverage(network, [{
|
||||
"group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围",
|
||||
"node_ids": impact["affected_node_ids"],
|
||||
}])
|
||||
impact["coverage_status"] = geometry["coverage"]["status"]
|
||||
geometry["coverage"]["boundary"] = impact["boundary"]
|
||||
for feature in geometry["map_payload"]["features"]:
|
||||
feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L",
|
||||
threshold_mg_l=threshold_mg_l, model_estimate=True,
|
||||
window_start=impact["window_start"], window_end=impact["window_end"])
|
||||
if geometry["coverage"]["status"] == "partial":
|
||||
impact["status"] = "partial"
|
||||
output.update(geometry)
|
||||
return output
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Temporary deterministic candidate selection behind the source-tracing contract.
|
||||
|
||||
Replace this selection with source inversion when the algorithm is available.
|
||||
Only project-owned nodes supplied from the investigation are eligible. No
|
||||
observation fit, confidence or contamination extent is inferred here.
|
||||
"""
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.infra.db.postgresql.read_queries import read_rows
|
||||
from app.mcp.analysis_models import ContaminantSourceTraceInput
|
||||
|
||||
|
||||
async def trace_contaminant_source(connection: AsyncConnection, args: ContaminantSourceTraceInput) -> dict:
|
||||
rows = await read_rows(connection, """
|
||||
SELECT id, node_type::text AS kind FROM network.nodes
|
||||
WHERE id = ANY(%s) ORDER BY id
|
||||
""", (args.candidate_node_ids,))
|
||||
# Sort in Python as well so selection does not depend on DB collation.
|
||||
nodes = sorted({str(row["id"]) for row in rows})
|
||||
selected = nodes[0] if nodes else None
|
||||
return {
|
||||
"located_node": selected,
|
||||
"top_candidates": [{"node_id": selected}] if selected else [],
|
||||
"exit_condition": "candidate_selected" if selected else "no_candidate",
|
||||
"method": "deterministic_candidate_selection",
|
||||
"algorithm_status": "provisional",
|
||||
"selection_basis": "lexicographically_first_existing_candidate_node",
|
||||
"validated_node_ids": nodes,
|
||||
"missing_node_ids": sorted(set(args.candidate_node_ids) - set(nodes)),
|
||||
"notice": ("已返回待现场核验的候选节点;先展示节点、保存阶段结论并提出现场核查/采样处置工单,"
|
||||
"工单审核派发与污染传播模拟、节点隔离及影响分析并行,结果补入同一调查。"
|
||||
"正式污染反演算法尚未接入,本次选择不代表污染源确诊或污染传播范围。" if selected else
|
||||
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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:
|
||||
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
|
||||
|
||||
Unspecified fields retain their saved values. A failed attempt remains
|
||||
failed, and a retry uses a new node id. Relationships impose no execution
|
||||
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)
|
||||
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
|
||||
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
|
||||
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||
continue
|
||||
require(not in_source(node.node_id) or node.label is None 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 (node.label is None or 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 node.label is None:
|
||||
changes.pop("label", None)
|
||||
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,
|
||||
)
|
||||
@@ -16,40 +16,66 @@ if TYPE_CHECKING:
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
|
||||
|
||||
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
|
||||
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly", "water_pollution"}
|
||||
|
||||
|
||||
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"]
|
||||
if task.get("workflow") == "water_pollution":
|
||||
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
|
||||
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)
|
||||
if task.get("workflow") == "water_pollution":
|
||||
# A completion receipt resumes review, never the pressure-based
|
||||
# automatic recovery calculation. Source revisions may carry new
|
||||
# sampling or retest facts even when the field time is unchanged.
|
||||
context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at)
|
||||
context.pop("verification_evidence_id", None)
|
||||
return True
|
||||
previous = context.get("returned_at")
|
||||
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
||||
or context.get("phase") == "waiting_work_order"):
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.domain.domain_errors import require
|
||||
|
||||
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
|
||||
replay = task.get("source", {}).get("replay_context")
|
||||
if task.get("workflow") != "scada_anomaly" and not replay:
|
||||
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay:
|
||||
return
|
||||
target = datetime.fromisoformat(
|
||||
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
||||
|
||||
+298
-150
@@ -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,11 +49,14 @@ from app.domain.schemas.operational_task import (
|
||||
InvestigationReportRevision,
|
||||
InvestigationReportReference,
|
||||
InvestigationProgressSnapshot,
|
||||
InvestigationProgress,
|
||||
InvestigationDiagram,
|
||||
IssueCommandPayload,
|
||||
NotificationAcknowledgeCommand,
|
||||
OperationalTaskCreate,
|
||||
OperationalTaskDetail,
|
||||
OperationalTaskNotification,
|
||||
WorkOrderNotificationReference,
|
||||
OperationalTaskPermissions,
|
||||
OperationalTaskSchedule,
|
||||
OperationalTaskScheduleCreate,
|
||||
@@ -71,12 +74,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 +98,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 +123,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 +267,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 +379,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 +561,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 +599,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 +754,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 +766,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 +859,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 patch saved nodes or extend the 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 +888,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 +944,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 +979,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 +992,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 +1030,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:
|
||||
@@ -1070,6 +1067,8 @@ class OperationalTaskService:
|
||||
run_id=row["metadata"]["run_id"],
|
||||
execution_generation=row["metadata"]["execution_generation"],
|
||||
command=row["metadata"]["command"],
|
||||
narration=row["metadata"].get("narration"),
|
||||
purpose=row["metadata"].get("purpose"),
|
||||
) for row in rows[:limit]]
|
||||
return TaskPresentationHistoryPage(
|
||||
task_id=task_id, event_cursor=boundary, items=items,
|
||||
@@ -1100,8 +1099,14 @@ class OperationalTaskService:
|
||||
now = self._now()
|
||||
self._require_active_run_lease(run, now)
|
||||
payload = _json(command.command)
|
||||
if command.narration is not None:
|
||||
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
||||
self._require_evidence_refs(command.narration.evidence_refs, known)
|
||||
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":
|
||||
@@ -1122,6 +1127,10 @@ class OperationalTaskService:
|
||||
payload["data"] = resolved
|
||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||
"execution_generation": run["generation"], "command": payload}
|
||||
if command.narration is not None:
|
||||
metadata["narration"] = _json(command.narration)
|
||||
if command.purpose is not None:
|
||||
metadata["purpose"] = command.purpose
|
||||
try:
|
||||
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
@@ -1139,6 +1148,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:
|
||||
@@ -1204,6 +1268,7 @@ class OperationalTaskService:
|
||||
task_id, "action_approval_pending", task["title"], command.reason, now,
|
||||
required_permission=approval_permission,
|
||||
subject_type="action", subject_id=action.action_id,
|
||||
work_order_action=action if action.kind == "create_work_order" else None,
|
||||
)
|
||||
await self._save_request(
|
||||
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
||||
@@ -1387,6 +1452,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 +1488,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 +1529,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 +1567,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))
|
||||
)
|
||||
@@ -1601,8 +1676,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,
|
||||
@@ -1622,10 +1697,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)))
|
||||
@@ -1662,6 +1754,29 @@ class OperationalTaskService:
|
||||
task_id, "task_failed",
|
||||
task["title"], command.detail, now,
|
||||
)
|
||||
if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}:
|
||||
# The feedback, its notification and outbox event commit together.
|
||||
# Duplicate request/source versions return above, before publishing.
|
||||
work_order_action = next((item for item in await self.repo.list_actions(str(task_id))
|
||||
if item["kind"] == "create_work_order" and item.get("receipt")
|
||||
and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None)
|
||||
is_work_order_feedback = (task["task_type"] == "external_work_order"
|
||||
or work_order_action is not None
|
||||
or (task["task_type"] == "event_investigation" and matching_source))
|
||||
if is_work_order_feedback:
|
||||
await self._notify(
|
||||
task_id, "work_order_feedback", "收到工单反馈",
|
||||
command.evidence.summary if command.evidence else command.detail, now,
|
||||
subject_type="action" if work_order_action else "task",
|
||||
subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id,
|
||||
work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None,
|
||||
work_order_reference=WorkOrderNotificationReference(
|
||||
source_event_id=command.source_event_id,
|
||||
source_revision=command.source_revision,
|
||||
evidence_id=command.evidence.evidence_id if command.evidence else None,
|
||||
status=command.status,
|
||||
),
|
||||
)
|
||||
response = {
|
||||
"source_event_id": command.source_event_id,
|
||||
"source_revision": command.source_revision,
|
||||
@@ -1988,13 +2103,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,
|
||||
@@ -2024,11 +2138,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 = (
|
||||
@@ -2049,8 +2162,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"
|
||||
@@ -2059,10 +2186,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)
|
||||
@@ -2071,12 +2195,24 @@ class OperationalTaskService:
|
||||
lifecycle = self._investigation_status(actions, task)
|
||||
recovery = task.get("handling_context") or {}
|
||||
if lifecycle == "completed" and recovery:
|
||||
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
|
||||
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
|
||||
if task.get("workflow") == "water_pollution":
|
||||
orders = [item for item in recovery.get("work_orders", {}).values()
|
||||
if not item.get("superseded_by")]
|
||||
feedback_refs = [item.get("feedback_evidence_id") for item in orders]
|
||||
require(recovery.get("phase") in {"field_review", "completed"} and bool(orders)
|
||||
and all(item["status"] == "completed" for item in orders)
|
||||
and all(ref and ref in report.report.evidence_refs for ref in feedback_refs),
|
||||
"POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409)
|
||||
verification_id = feedback_refs[-1]
|
||||
else:
|
||||
verification_id = recovery.get("verification_evidence_id")
|
||||
require(verification_id in report.report.evidence_refs,
|
||||
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
|
||||
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
|
||||
"恢复复核通过后才能结束事件", status=409)
|
||||
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
|
||||
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
|
||||
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
|
||||
else "恢复供水复核成功"), completed_at=now.isoformat())
|
||||
await self._resolve_investigation_alerts(str(task_id), verification_id)
|
||||
elif report.report.outcome == "insufficient_data":
|
||||
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
||||
for node in plan.plan.nodes)
|
||||
@@ -2126,7 +2262,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
|
||||
)
|
||||
@@ -2158,8 +2294,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"}},
|
||||
@@ -2362,44 +2496,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(
|
||||
@@ -2409,15 +2557,16 @@ 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,
|
||||
*, required_permission: str | None = None,
|
||||
subject_type: str = "task", subject_id: UUID | None = None,
|
||||
report_reference: InvestigationReportReference | None = None,
|
||||
work_order_reference: WorkOrderNotificationReference | None = None,
|
||||
work_order_action: HumanApprovalAction | None = None,
|
||||
) -> None:
|
||||
notification = OperationalTaskNotification(
|
||||
notification_id=uuid4(), task_id=task_id, kind=kind,
|
||||
@@ -2426,6 +2575,8 @@ class OperationalTaskService:
|
||||
lifecycle_status="active", subject_type=subject_type,
|
||||
subject_id=subject_id or task_id,
|
||||
report_reference=report_reference,
|
||||
work_order_reference=work_order_reference,
|
||||
work_order_action=work_order_action,
|
||||
)
|
||||
await self.repo.insert_notification(_json(notification))
|
||||
await self._append_event(
|
||||
@@ -2447,10 +2598,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,
|
||||
|
||||
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def pollution_signal(document: dict, event: dict) -> bool:
|
||||
"""Water-quality measurements have their own incident investigation workflow."""
|
||||
if event.get("category") == "data_quality":
|
||||
return False
|
||||
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
|
||||
ids = event.get("device_ids") or next((item.get("device_ids", [])
|
||||
for item in (document.get("config") or {}).get("rules", [])
|
||||
if item["rule_id"] == event.get("rule_id")), [])
|
||||
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
|
||||
|
||||
|
||||
def diagnostic_signal(document: dict, event: dict) -> bool:
|
||||
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
|
||||
if event.get("category") == "data_quality":
|
||||
if event.get("category") == "data_quality" or pollution_signal(document, event):
|
||||
return False
|
||||
if not weighted_diagnosis(document):
|
||||
return True
|
||||
|
||||
@@ -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", "quality": "quality", "water_quality": "quality"}
|
||||
|
||||
|
||||
def applies(task: dict) -> bool:
|
||||
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} 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", "field_review", "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")
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import HTTPException
|
||||
from app.core.config import settings
|
||||
@@ -429,6 +430,7 @@ class ScadaReplayService:
|
||||
await self.repo.save(document)
|
||||
|
||||
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
|
||||
from app.services.scada_normal_profile import pollution_signal
|
||||
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
@@ -443,9 +445,10 @@ class ScadaReplayService:
|
||||
self.context, permissions_for_context(self.context),
|
||||
execution_scope=settings.operational_task_execution_scope)
|
||||
for event in changes:
|
||||
# Period diagnosis owns measurement notifications during manual steps.
|
||||
# Retain rule events as evidence; quality alerts remain independent.
|
||||
if (not create_tasks and event.get("category") != "data_quality"
|
||||
pollution = pollution_signal(document, event)
|
||||
# Pressure/flow rules share period diagnosis during manual steps.
|
||||
# Water-quality incidents and telemetry-quality alerts stay independent.
|
||||
if (not create_tasks and not pollution and event.get("category") != "data_quality"
|
||||
and (event["change"] == "raised" or event.get("notification_suppressed"))):
|
||||
if event["change"] == "raised":
|
||||
event["batch_sequence"] = batch["sequence"]
|
||||
@@ -457,13 +460,33 @@ class ScadaReplayService:
|
||||
if event["change"] == "raised":
|
||||
event["batch_sequence"] = batch["sequence"]
|
||||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
|
||||
if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution):
|
||||
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
|
||||
command = OperationalTaskCreate(
|
||||
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
|
||||
workflow="water_pollution" if pollution else "scada_anomaly",
|
||||
title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}",
|
||||
summary=event["detail"], source=TaskSource(
|
||||
kind="external_system", external_id=external, external_revision=1,
|
||||
event_kind="water_pollution" if pollution else "scada_anomaly",
|
||||
replay_context={"replay_id": document["replay_id"],
|
||||
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
|
||||
),
|
||||
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail=(
|
||||
"水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else
|
||||
"已存 SCADA 历史的回放检测信号,原因尚待调查")),
|
||||
)
|
||||
task = await tasks.create(command)
|
||||
event["task_id"] = str(task.task_id)
|
||||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
|
||||
await alerts.push(TaskAlertEvent(
|
||||
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||||
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
|
||||
occurred_at=event["occurred_at"], condition_id=None,
|
||||
occurred_at=event["occurred_at"],
|
||||
condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else None,
|
||||
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||||
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
||||
title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
|
||||
title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning",
|
||||
))
|
||||
if event["change"] != "raised":
|
||||
if event.get("task_id"):
|
||||
@@ -472,25 +495,6 @@ class ScadaReplayService:
|
||||
"batch_sequence": batch["sequence"],
|
||||
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||||
continue
|
||||
if event.get("category") == "data_quality":
|
||||
continue
|
||||
if not create_tasks:
|
||||
continue
|
||||
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
|
||||
command = OperationalTaskCreate(
|
||||
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
|
||||
workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
|
||||
summary=event["detail"], source=TaskSource(
|
||||
kind="external_system", external_id=external, external_revision=1,
|
||||
event_kind="scada_anomaly",
|
||||
replay_context={"replay_id": document["replay_id"],
|
||||
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
|
||||
),
|
||||
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
|
||||
)
|
||||
task = await tasks.create(command)
|
||||
event["task_id"] = str(task.task_id)
|
||||
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
|
||||
|
||||
async def _create_monitoring_task(self, document, monitoring, batch):
|
||||
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
||||
@@ -528,35 +532,24 @@ class ScadaReplayService:
|
||||
external = incident["external_id"]
|
||||
pressure = monitoring["pressure"]
|
||||
flow = monitoring["outlet_flow"]
|
||||
detector = monitoring["burst_detection"]
|
||||
score = detector.get("target_score")
|
||||
score_text = f"{score:.4f}" if score is not None else "数据不足"
|
||||
summary = (
|
||||
f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。"
|
||||
f"爆管侦测得分 {score_text};"
|
||||
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
||||
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
||||
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
|
||||
)
|
||||
findings = []
|
||||
if pressure.get("hit_device_ids"):
|
||||
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
|
||||
if flow.get("hit_device_ids"):
|
||||
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
|
||||
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
|
||||
if monitoring.get("anomaly_signals"):
|
||||
signals = monitoring["anomaly_signals"]
|
||||
devices = {device for item in signals for device in item.get("device_ids", [])}
|
||||
summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。"
|
||||
if monitoring.get("weighted"):
|
||||
weighted = monitoring["weighted"]
|
||||
weights = weighted["weights"]
|
||||
summary += (
|
||||
f" 综合评分 {weighted['score']:.2f},爆管嫌疑门槛 {weighted['threshold']:.2f}"
|
||||
f"(侦测 {weights['burst_detection']:.0%}、压力 {weights['pressure']:.0%}、"
|
||||
f"流量 {weights['outlet_flow']:.0%})。"
|
||||
)
|
||||
if detector.get("score_threshold") is not None:
|
||||
summary += f" 侦测动态阈值 {detector['score_threshold']:.4f}。"
|
||||
summary += f"本次异常涉及 {len(devices)} 个测点。"
|
||||
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
|
||||
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
|
||||
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
|
||||
command = OperationalTaskCreate(
|
||||
request_id=uuid5(NAMESPACE_URL, external),
|
||||
task_type="event_investigation",
|
||||
workflow="scada_anomaly",
|
||||
title=f"SCADA 异常调查 · {monitoring['period_end']}",
|
||||
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
|
||||
summary=summary,
|
||||
source=TaskSource(
|
||||
kind="external_system",
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.native.wndb.model.sources import (
|
||||
)
|
||||
from app.native.wndb.model.times import get_time, set_time
|
||||
from app.domain.time import parse_clock_duration_seconds
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
|
||||
|
||||
def _isolated_analysis(purpose: str):
|
||||
@@ -79,6 +80,7 @@ def burst_analysis(
|
||||
username: str | None = None,
|
||||
persist_results: bool = True,
|
||||
_temporary_project: str | None = None,
|
||||
scada_mappings: ScadaElementMappings | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
爆管模拟
|
||||
@@ -123,6 +125,7 @@ def burst_analysis(
|
||||
name=new_name,
|
||||
simulation_type="manually_temporary",
|
||||
modify_pattern_start_time=modify_pattern_start_time,
|
||||
scada_mappings=scada_mappings,
|
||||
)
|
||||
print(
|
||||
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -203,6 +206,7 @@ def burst_analysis(
|
||||
modify_variable_pump_pattern=modify_variable_pump_pattern,
|
||||
modify_valve_opening=modify_valve_opening,
|
||||
scheme_type="burst_analysis",
|
||||
scada_mappings=scada_mappings,
|
||||
scheme_name=scheme_name,
|
||||
result_db_name=name,
|
||||
scheme_username=username,
|
||||
@@ -312,6 +316,7 @@ def flushing_analysis(
|
||||
valve_control: dict[str, dict] = None,
|
||||
_temporary_project: str | None = None,
|
||||
persist_results: bool = True,
|
||||
scada_mappings: ScadaElementMappings | None = None,
|
||||
) -> dict | str | None:
|
||||
"""
|
||||
管道冲洗模拟
|
||||
@@ -441,6 +446,7 @@ def flushing_analysis(
|
||||
modify_total_duration=modify_total_duration,
|
||||
modify_valve_opening=modify_valve_opening,
|
||||
valve_control=valve_control,
|
||||
scada_mappings=scada_mappings,
|
||||
scheme_type="flushing_analysis",
|
||||
scheme_name=scheme_name,
|
||||
result_db_name=name,
|
||||
@@ -469,6 +475,7 @@ def contaminant_simulation(
|
||||
username: str | None = None,
|
||||
_temporary_project: str | None = None,
|
||||
persist_results: bool = True,
|
||||
scada_mappings: ScadaElementMappings | None = None,
|
||||
) -> dict | str | None:
|
||||
"""
|
||||
污染模拟
|
||||
@@ -591,6 +598,7 @@ def contaminant_simulation(
|
||||
scheme_username=username,
|
||||
scheme_detail=scheme_detail,
|
||||
persist_results=persist_results,
|
||||
scada_mappings=scada_mappings,
|
||||
)
|
||||
return result if not persist_results else None
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
|
||||
"sha256": "c31e9b4c71216951d1525acf1bcb8aeba4158b6cdc6b0b9dc01be02f9b6e6af3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@
|
||||
"progress": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvestigationProgress"
|
||||
"$ref": "#/components/schemas/InvestigationProgressInput"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -3537,9 +3537,99 @@
|
||||
"title": "InvestigationDataGap",
|
||||
"type": "object"
|
||||
},
|
||||
"InvestigationDataGapInput": {
|
||||
"additionalProperties": false,
|
||||
"description": "New gap descriptions use business prose; references remain opaque.",
|
||||
"properties": {
|
||||
"blocking": {
|
||||
"title": "Blocking",
|
||||
"type": "boolean"
|
||||
},
|
||||
"evidence_refs": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"title": "Evidence Refs",
|
||||
"type": "array"
|
||||
},
|
||||
"gap_id": {
|
||||
"minLength": 1,
|
||||
"title": "Gap Id",
|
||||
"type": "string"
|
||||
},
|
||||
"impact": {
|
||||
"description": "资料缺项对当前判断的实际影响",
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"title": "Impact",
|
||||
"type": "string"
|
||||
},
|
||||
"missing_data": {
|
||||
"description": "缺失的业务资料,不写执行字段",
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"title": "Missing Data",
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Node Id"
|
||||
},
|
||||
"required_data": {
|
||||
"description": "需要补充的具体业务资料",
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"title": "Required Data",
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"description": "缺口涉及的对象、地点与当地时间范围",
|
||||
"maxLength": 4000,
|
||||
"minLength": 1,
|
||||
"title": "Scope",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"gap_id",
|
||||
"missing_data",
|
||||
"scope",
|
||||
"impact",
|
||||
"required_data",
|
||||
"evidence_refs",
|
||||
"blocking"
|
||||
],
|
||||
"title": "InvestigationDataGapInput",
|
||||
"type": "object"
|
||||
},
|
||||
"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 +3644,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 +3681,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 +3724,11 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"pending",
|
||||
"running",
|
||||
"completed",
|
||||
"waiting",
|
||||
"waiting_human",
|
||||
"reference",
|
||||
"recorded",
|
||||
"needs_input",
|
||||
"failed",
|
||||
"skipped",
|
||||
"cancelled"
|
||||
"not_taken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3623,7 +3736,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 +3747,160 @@
|
||||
"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": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required for a new node. Omit or use null to retain an existing node's label. Use the same node_id to revise status or detail without adding another step.",
|
||||
"title": "Label"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"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 +3958,43 @@
|
||||
"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": {
|
||||
"description": "用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号",
|
||||
"maxLength": 300,
|
||||
"minLength": 1,
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary"
|
||||
],
|
||||
"title": "InvestigationProgressInput",
|
||||
"type": "object"
|
||||
},
|
||||
"InvestigationProgressSnapshot": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -3811,6 +4115,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": [
|
||||
{
|
||||
@@ -3865,18 +4186,22 @@
|
||||
"description": "Write model: every new report explicitly declares its business result.",
|
||||
"properties": {
|
||||
"conclusion_boundary": {
|
||||
"description": "只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数",
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"title": "Conclusion Boundary",
|
||||
"type": "string"
|
||||
},
|
||||
"current_conclusion": {
|
||||
"description": "先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table",
|
||||
"maxLength": 300,
|
||||
"minLength": 1,
|
||||
"title": "Current Conclusion",
|
||||
"type": "string"
|
||||
},
|
||||
"data_gaps": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InvestigationDataGap"
|
||||
"$ref": "#/components/schemas/InvestigationDataGapInput"
|
||||
},
|
||||
"title": "Data Gaps",
|
||||
"type": "array"
|
||||
@@ -3909,6 +4234,7 @@
|
||||
},
|
||||
"pending_items": {
|
||||
"items": {
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3917,12 +4243,30 @@
|
||||
},
|
||||
"recommendations": {
|
||||
"items": {
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"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",
|
||||
@@ -3934,6 +4278,7 @@
|
||||
"stop_reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3944,6 +4289,8 @@
|
||||
"title": "Stop Reason"
|
||||
},
|
||||
"title": {
|
||||
"description": "简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳",
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
@@ -4905,7 +5252,8 @@
|
||||
"access_approval_pending",
|
||||
"action_approval_pending",
|
||||
"human_input_required",
|
||||
"external_coordination_failed"
|
||||
"external_coordination_failed",
|
||||
"work_order_feedback"
|
||||
],
|
||||
"title": "Kind",
|
||||
"type": "string"
|
||||
@@ -5040,6 +5388,26 @@
|
||||
"minimum": 1.0,
|
||||
"title": "Version",
|
||||
"type": "integer"
|
||||
},
|
||||
"work_order_action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/HumanApprovalAction"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"work_order_reference": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorkOrderNotificationReference"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7162,6 +7530,111 @@
|
||||
"title": "RunSimulationManuallyByDateRest",
|
||||
"type": "object"
|
||||
},
|
||||
"ScadaChartContext": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"gaps": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScadaChartGap"
|
||||
},
|
||||
"maxItems": 8,
|
||||
"title": "Gaps",
|
||||
"type": "array"
|
||||
},
|
||||
"metric": {
|
||||
"enum": [
|
||||
"pressure",
|
||||
"flow",
|
||||
"quality"
|
||||
],
|
||||
"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": {
|
||||
@@ -9353,6 +9826,7 @@
|
||||
"cancel_task_run",
|
||||
"run_simulation",
|
||||
"run_contaminant_simulation",
|
||||
"run_contaminant_source_trace",
|
||||
"analyze_valve_closure",
|
||||
"analyze_flushing",
|
||||
"analyze_pressure_regulation",
|
||||
@@ -9770,6 +10244,30 @@
|
||||
],
|
||||
"title": "Command"
|
||||
},
|
||||
"narration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TaskPresentationNarrationInput"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"purpose": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现",
|
||||
"title": "Purpose"
|
||||
},
|
||||
"request_id": {
|
||||
"format": "uuid",
|
||||
"title": "Request Id",
|
||||
@@ -9848,11 +10346,34 @@
|
||||
"title": "Execution Generation",
|
||||
"type": "integer"
|
||||
},
|
||||
"narration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TaskPresentationNarration"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"occurred_at": {
|
||||
"format": "date-time",
|
||||
"title": "Occurred At",
|
||||
"type": "string"
|
||||
},
|
||||
"purpose": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Purpose"
|
||||
},
|
||||
"run_id": {
|
||||
"format": "uuid",
|
||||
"title": "Run Id",
|
||||
@@ -9910,6 +10431,125 @@
|
||||
"title": "TaskPresentationHistoryPage",
|
||||
"type": "object"
|
||||
},
|
||||
"TaskPresentationNarration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"evidence_refs": {
|
||||
"items": {
|
||||
"maxLength": 128,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 100,
|
||||
"minItems": 1,
|
||||
"title": "Evidence Refs",
|
||||
"type": "array"
|
||||
},
|
||||
"next_step": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Next Step"
|
||||
},
|
||||
"stage": {
|
||||
"enum": [
|
||||
"anomaly_detection",
|
||||
"evidence_verification",
|
||||
"asset_localization",
|
||||
"event_impact",
|
||||
"source_control",
|
||||
"isolation_plan",
|
||||
"flushing_plan",
|
||||
"field_feedback",
|
||||
"clearance_verification",
|
||||
"recovery_verification"
|
||||
],
|
||||
"title": "Stage",
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stage",
|
||||
"summary",
|
||||
"evidence_refs"
|
||||
],
|
||||
"title": "TaskPresentationNarration",
|
||||
"type": "object"
|
||||
},
|
||||
"TaskPresentationNarrationInput": {
|
||||
"additionalProperties": false,
|
||||
"description": "New live findings use business prose; saved narration remains readable.",
|
||||
"properties": {
|
||||
"evidence_refs": {
|
||||
"items": {
|
||||
"maxLength": 128,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 100,
|
||||
"minItems": 1,
|
||||
"title": "Evidence Refs",
|
||||
"type": "array"
|
||||
},
|
||||
"next_step": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行",
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Next Step"
|
||||
},
|
||||
"stage": {
|
||||
"enum": [
|
||||
"anomaly_detection",
|
||||
"evidence_verification",
|
||||
"asset_localization",
|
||||
"event_impact",
|
||||
"source_control",
|
||||
"isolation_plan",
|
||||
"flushing_plan",
|
||||
"field_feedback",
|
||||
"clearance_verification",
|
||||
"recovery_verification"
|
||||
],
|
||||
"title": "Stage",
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"description": "本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的",
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
"title": "Summary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stage",
|
||||
"summary",
|
||||
"evidence_refs"
|
||||
],
|
||||
"title": "TaskPresentationNarrationInput",
|
||||
"type": "object"
|
||||
},
|
||||
"TaskQuestion": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -10229,6 +10869,16 @@
|
||||
"title": "Chart Type",
|
||||
"type": "string"
|
||||
},
|
||||
"scada": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ScadaChartContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"series": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TaskSafeChartSeries"
|
||||
@@ -10306,6 +10956,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",
|
||||
@@ -10735,6 +11411,53 @@
|
||||
"title": "WorkOrderDraftUpdateCommand",
|
||||
"type": "object"
|
||||
},
|
||||
"WorkOrderNotificationReference": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"evidence_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Evidence Id"
|
||||
},
|
||||
"source_event_id": {
|
||||
"minLength": 1,
|
||||
"title": "Source Event Id",
|
||||
"type": "string"
|
||||
},
|
||||
"source_revision": {
|
||||
"minimum": 1.0,
|
||||
"title": "Source Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"scheduled",
|
||||
"assigned",
|
||||
"in_progress",
|
||||
"awaiting_feedback",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"failed"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source_event_id",
|
||||
"source_revision",
|
||||
"status"
|
||||
],
|
||||
"title": "WorkOrderNotificationReference",
|
||||
"type": "object"
|
||||
},
|
||||
"WorkbenchAlertSource": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
||||
@@ -207,6 +207,98 @@
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"scada": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"current",
|
||||
"comparison"
|
||||
]
|
||||
},
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pressure",
|
||||
"flow",
|
||||
"quality"
|
||||
]
|
||||
},
|
||||
"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 +333,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": [
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 供水 Server 文档目录
|
||||
|
||||
环境、启动和发布命令见 [仓库 README](../README.md),开发规则见 [AGENTS.md](../AGENTS.md)。整理日期:2026-11-10。下列文档按职责组织,不将历史迁移或本地验证当作现有服务状态。
|
||||
|
||||
## API、认证与数据
|
||||
|
||||
| 主题 | 维护入口 |
|
||||
| --- | --- |
|
||||
| API 命名、字段和生成契约 | [API 风格](api-style.md) · [OpenAPI](../contracts/server-v1.openapi.json) |
|
||||
| 身份、项目成员、管理接口与元数据 | [认证与用户管理](../AUTHENTICATION_AND_USER_MANAGEMENT.md) |
|
||||
| MCP 设计依据、启用与认证配置 | [查询设计](mcp-query-design.md) · [实施与启用](mcp-query-implementation.md) · [Keycloak 配置](mcp-keycloak-setup.md) |
|
||||
| MCP 分析、项目服务和后台计算 | [分析工具](mcp-analysis-tools.md) · [项目服务](mcp-project-services.md) · [计算队列](calculation-queue.md) |
|
||||
| 项目地名与空间覆盖 | [地名搜索](project-place-search.md) · [节点覆盖](node-coverage-tool.md) |
|
||||
| 项目 BizDB 与时序模型 | [数据库架构](../resources/db_v2/DATABASE_ARCHITECTURE.md) · [管网结构](../resources/db_v2/WNDB_STRUCTURE.md) |
|
||||
|
||||
工具目录以 `app/mcp/catalog.py` 为准;启用范围、项目权限和会话用途决定实际可用项,不以文档中的历史工具数量推定。
|
||||
|
||||
## 任务、调查与工作台
|
||||
|
||||
| 主题 | 维护入口 |
|
||||
| --- | --- |
|
||||
| 任务类型、调查轮次与协作 | [工况任务契约](operational-tasks.md) |
|
||||
| Worker、租约、持久消息、外部动作和部署 | [工况工作进程](operational-task-workers.md) |
|
||||
| 进展、展示事件与人工反馈续调 | [调查进展与展示](investigation-progress-and-presentation.md) |
|
||||
| 正式报告与稳定引用 | [调查报告契约](investigation-report-contract.md) |
|
||||
| 爆管工单到处置闭环 | [爆管处置路线](burst-response-workflow.md) |
|
||||
| 工作台运行态、外围工单与告警存储 | [运行态与存储](workbench-runtime.md) |
|
||||
| 事件顺序、SSE 与读取去重 | [工作台事件链路](workbench-event-refinement.md) |
|
||||
| 通知进入、退出与生命周期 | [通知路由](operational-notification-routing.md) |
|
||||
| 迁移门禁与逐项目验证 | [数据库变更门禁](workbench-database-rollout.md) |
|
||||
| 外围工单来源、项目路由和主动同步 | [外围服务](peripheral-sample.md) |
|
||||
|
||||
[旧动态计划协议](dynamic-investigation-plan.md) 用于兼容历史计划,当前任务和报告规则以上述专门契约为准。[15173 UI Keycloak 客户端](integration-ui-keycloak-client.md) 是特定联调环境说明,不能作为全部部署环境的通用配置。
|
||||
|
||||
## SCADA、回放与数据集
|
||||
|
||||
| 主题 | 维护入口 |
|
||||
| --- | --- |
|
||||
| 回放、时间轴、周期监测与调查边界 | [SCADA 回放设计与实现](scada-peripheral-replay-design.md) |
|
||||
| 外围测试样本与数据再现 | [合成样本](peripheral-generated-data.md) |
|
||||
| 凤阳历史爆管模拟 | [凤阳 SCADA 复制记录](fengyang-burst-scada.md) |
|
||||
| 奉贤独立 Agent benchmark | [tjwater_v2 benchmark](tjwater-v2-burst-benchmark.md) |
|
||||
| tjwater2 场景选取与观测 | [多距离与弱信号方案](tjwater2-v2-burst-balanced-design.md) |
|
||||
| tjwater2 水力校准方法 | [爆管模拟校准](tjwater2-v2-burst-simulation-design.md) |
|
||||
| 监测点算法与资源压力取舍 | [灵敏度优化](sensor-sensitivity-optimization.md) |
|
||||
|
||||
这些数据文档分别描述数据准备、算法设计或历史试算,不能互相替代,也不证明目标数据库已经加载或真实 Agent 已完成验收。
|
||||
|
||||
## 合并与历史资料
|
||||
|
||||
- `workbench-operations-storage.md` 已并入运行态文档的“工单与告警存储”。
|
||||
- `peripheral-work-order-events.md` 已并入外围服务文档的“工单事件主动同步”,旧固定库限制已按项目路由实现修订。
|
||||
- [历史命名审查](history/backend-naming-audit.md) 与 [失效接口排查](history/retired-api-investigation.md) 保留原始审查依据,不能作为当前 API 目录。
|
||||
- `scripts/missing_api_endpoints.md` 是历史能力盘点,新增能力前需重新核对当前契约与调用方。
|
||||
|
||||
设计与部署说明保留分工:设计解释选择与边界,运行文档给出可执行配置;不同数据库、不同数据集和不同生命周期的业务对象不为减少文件数而合并。
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
# TJWater REST API v1
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
flowchart TD
|
||||
order[爆管上报工单] --> context[读取原工单与当前任务]
|
||||
context --> asset[核对地点并关联管网资产]
|
||||
asset --> isolation[分析隔离边界与阀门方案]
|
||||
isolation --> impact[说明事件与隔离方案影响]
|
||||
impact --> report[保存阶段报告与处置建议]
|
||||
report --> approval[人工审批与现场执行]
|
||||
asset --> interim[保存定位阶段报告]
|
||||
interim --> approval[立即发起工单草稿、人工审核派发]
|
||||
interim --> burst[爆管模拟与同刻在线压力差]
|
||||
burst --> coverage[保存并展示爆管后、处置前影响覆盖]
|
||||
coverage --> isolation[分析隔离边界与阀门方案]
|
||||
isolation --> report[补充阶段报告与处置建议]
|
||||
approval --> feedback[接收实际处置与恢复反馈]
|
||||
feedback --> verify[复核影响与结案条件]
|
||||
verify --> close[Server 完成任务并解除告警]
|
||||
@@ -20,10 +22,11 @@ flowchart TD
|
||||
|
||||
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
||||
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
|
||||
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
||||
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
|
||||
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
||||
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
||||
3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。
|
||||
4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
||||
5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。
|
||||
6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
||||
7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
||||
|
||||
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||
|
||||
@@ -64,3 +67,19 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
|
||||
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
||||
|
||||
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。
|
||||
|
||||
## 爆管压力下降覆盖与历史还原
|
||||
|
||||
`run_burst_simulation` 新增可选 `include_impact_coverage=true`,原默认行为保留。指定已定位管道、带时区的事件起算时刻及有来源或明确估计依据的破口面积(cm²)。计算保持隔离临时模型、不保存分析方案、不重算在线基线,不施加新的关阀或抢修操作。
|
||||
|
||||
在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。
|
||||
|
||||
输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。地图采用黄/橙/红,图例保留每档已绘制/分组节点数。
|
||||
|
||||
爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。
|
||||
|
||||
携带 `replay_context` 的调查只开放启用影响覆盖的爆管情景,起算时刻不得超过 `knowledge_cutoff`。使用当前模型的独立假设情景,初始化及扩展模拟均传入空SCADA边界映射,禁止从实时SCADA回填冻结观测;同刻在线结果仅作为计算时读取的模型参考,标明非冻结基线。未来样本属于预测,不能作为已发生的观测。
|
||||
|
||||
完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。
|
||||
|
||||
全网结果超过单次工具回执上限时,默认 `get_calculation_result` 返回标明缩略的计数与模型条件、保存证据资源链接及可发布地图命令。原始逐节点结果和几何保持完整,可通过证据资源或 `data_path=["result","impact","comparisons"]` 分页读取;不将全网计算缩为20个展示节点。
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 变更文件
|
||||
@@ -1,8 +1,9 @@
|
||||
# 调查进展与实时展示
|
||||
|
||||
调查执行中可持续发布正文和完整 Mermaid 图,尚未形成结论时也能发布。
|
||||
调查执行中可按需整理正文和流程参考图,Server 累计保留路径并生成 Mermaid,尚未形成结论时也能发布。
|
||||
Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告,
|
||||
进展中的节点状态不参与调度、审批或结束判断。
|
||||
图节点不绑定实际执行任务,标记只解释最近整理的路线、结果和限制;不参与调度、审批、等待或结束判断。
|
||||
路线有实质调整、形成重要结果、需要保留失败尝试或整理报告时更新即可,不要求逐步发布、频繁刷新或及时同步。
|
||||
|
||||
## 进展快照
|
||||
|
||||
@@ -14,37 +15,92 @@ 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` 可空。
|
||||
示例中的编号由实际任务和资产替换。新发布的 `summary` 最多 300 字符,建议 120 字以内,
|
||||
只说明本轮新增发现、当前依赖和下一步;使用中文业务语言与当地时间,不复制报告或执行参数。
|
||||
详细结果和引用留在图节点、证据与报告明细中,历史正文仍按原值读取。`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,保留已有路径和引用。
|
||||
“保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`;
|
||||
修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。
|
||||
省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。
|
||||
证据、动作和地图引用继续按集合累加,空列表不移除已有引用。资料或条件补齐后,应显式修订原 `needs_input` 节点,
|
||||
新增下游节点不会自动改变上游标记,也不根据节点顺序推断执行结果。例如已有节点 `read` 可更新为:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "补充资料已核对,更新原核查节点。",
|
||||
"graph_update": {
|
||||
"nodes": [{"node_id": "read", "status": "recorded", "detail": "已核对收到的现场记录。"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上述更新不增加节点或连线,也不改写已归档的报告图。旧图已保存于 `node_refs` 的节点支持相同的局部修订;
|
||||
仅存在于旧 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` 接收
|
||||
@@ -53,6 +109,33 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
||||
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
|
||||
不表示浏览器已显示或现场动作已执行。
|
||||
|
||||
### 展示表达与阅读
|
||||
|
||||
实时展示与正式报告共用中文业务表达和时间要求,字段沿用现有结构:
|
||||
|
||||
| 字段 | 表达职责 | 长度 |
|
||||
| --- | --- | --- |
|
||||
| purpose | 为什么核查这些对象;不复述动作名称、当前发现或完整报告 | 建议 60 字以内,最多 160 字符 |
|
||||
| narration.stage | 本步所属业务阶段,前端显示中文阶段名称 | 使用既有枚举 |
|
||||
| narration.summary | 本步已有证据支持的关键发现,区分观测、候选、模型与现场反馈 | 建议 80 字以内,最多 160 字符 |
|
||||
| narration.next_step | 实际下一步或等待条件;无后续时省略 | 建议 60 字以内,最多 120 字符 |
|
||||
| narration.evidence_refs | 该发现引用的实际证据,不作为正文展示 | 保持现有引用校验 |
|
||||
|
||||
已有明确分析结果时提供 `narration`;纯视角调整或清除操作不编造发现。
|
||||
目的、发现和后续分别组织,不重复粘贴同一段文字。同一核查目的的批量图表可以复用有依据的
|
||||
相同讲解,保留合并阅读能力;不同发现分别保存,不为合并而抹去结论差异。
|
||||
|
||||
讲解、目的和图表、覆盖层的可见标签均采用纯文本,省略执行字段、版本号、技术状态码、
|
||||
完整 UUID、证据哈希、时区名称、偏移和 ISO 时间戳。必要设备编号、单位与历史日期保留;
|
||||
标题交代对象、指标或预计范围,算法参数留在证据明细。结构化采样时刻、设备标识、数值、
|
||||
几何和证据引用保持原值,不因文字整理改变分析范围。
|
||||
|
||||
Server 的新请求模型、Agent 原生工具及宿主入口校验这些文字规则;历史读取模型保持兼容。
|
||||
前端在当前动作说明中突出已保存的发现,再呈现目的和后续,缺少讲解的历史步骤只显示用途,
|
||||
不推断结论。讲解进入已有滚动阅读区,历史与播放控件留在标题区,长文字不挤出控件。
|
||||
历史选择固定在所选步骤;自动停留时间考虑实际可见的目的、发现与后续文字,
|
||||
用户手动选择的停留时间保持优先。上述文字和播放行为不控制后台调查、审批或现场执行。
|
||||
|
||||
| command.action | 参数 |
|
||||
| --- | --- |
|
||||
| locate_features | layer、feature_ids |
|
||||
@@ -68,7 +151,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 +186,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` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
|
||||
@@ -153,3 +282,12 @@ git diff --check
|
||||
```
|
||||
|
||||
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
|
||||
|
||||
|
||||
## 工单通知与历史展示
|
||||
|
||||
工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。
|
||||
|
||||
前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。
|
||||
|
||||
历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。
|
||||
|
||||
@@ -9,17 +9,19 @@
|
||||
|
||||
| 字段 | 类型 | 新写入规则 |
|
||||
| --- | --- | --- |
|
||||
| title | 非空字符串 | 报告标题 |
|
||||
| current_conclusion | 非空字符串 | 当前判断文字;无结论时明确写无法判断,不编造原因 |
|
||||
| conclusion_boundary | 非空字符串 | 判断范围及限制 |
|
||||
| title | 非空字符串,最多 80 字符 | 简短中文业务标题,建议 30 字以内,不包含执行轮次和流程版本 |
|
||||
| current_conclusion | 非空字符串,最多 300 字符 | 首句写当前判断,接关键依据和实际下一步,建议 150 字以内;完整数值放入表格 |
|
||||
| conclusion_boundary | 非空字符串,最多 240 字符 | 影响判断的证据范围、不确定性和适用限制;不复述执行过程、参数或完整资产清单 |
|
||||
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
|
||||
| recommendations | 字符串数组 | 保持原类型,处置建议不等于已执行动作 |
|
||||
| pending_items | 字符串数组 | 保持原类型,不从文字推断状态 |
|
||||
| recommendations | 字符串数组,每项最多 160 字符 | 具体对象与后续动作;处置建议不等于已执行动作 |
|
||||
| pending_items | 字符串数组,每项最多 160 字符 | 实际尚未完成或等待外部条件的事项,不从文字推断状态 |
|
||||
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
|
||||
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
|
||||
| stop_reason | 非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
||||
| stop_reason | 最多 160 字符的非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
||||
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
|
||||
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
|
||||
| ui_blocks | 结果组件数组,最多 8 块、合计 400 KB | 以现有 Result、Section、Table 等组件表达重点与明细,块内引用必须属于报告证据 |
|
||||
| scada_device_ids | 设备编号数组,最多 1000 项 | 监测明细与展示目标的完整集合,不能因精简摘要删除目标 |
|
||||
|
||||
缺口对象除 node_id 外的字段必填:
|
||||
|
||||
@@ -27,10 +29,10 @@
|
||||
| --- | --- | --- |
|
||||
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
|
||||
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
|
||||
| missing_data | 非空字符串 | 缺失数据内容 |
|
||||
| scope | 非空字符串 | 查询对象、地点及时间范围 |
|
||||
| impact | 非空字符串 | 因此无法回答的调查问题 |
|
||||
| required_data | 非空字符串 | 新一轮调查需要补齐的具体数据 |
|
||||
| missing_data | 非空字符串,最多 4000 字符 | 缺失的业务资料 |
|
||||
| scope | 非空字符串,最多 4000 字符 | 查询对象、地点及当地时间范围 |
|
||||
| impact | 非空字符串,最多 4000 字符 | 因此无法回答的调查问题 |
|
||||
| required_data | 非空字符串,最多 4000 字符 | 新一轮调查需要补齐的具体数据 |
|
||||
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
|
||||
| blocking | boolean | 是否阻止形成结论 |
|
||||
|
||||
@@ -42,11 +44,56 @@
|
||||
不包含 Markdown 围栏、初始化指令和 click 命令。前端使用 strict 安全级别渲染 SVG。
|
||||
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
|
||||
|
||||
## 报告表达与生成要求
|
||||
|
||||
报告面向供水运维人员,按当前判断、关键依据、判断限制、后续安排组织内容。
|
||||
字段仍按现有模型发布,不将多段报告重新堆入 `current_conclusion` 或 `conclusion_boundary`。
|
||||
|
||||
- 结论首句直接回答当前发生了什么、形成什么判断。随后只保留最关键的变化和实际下一步,
|
||||
正文最多举两个关键对象。评分权重、算法参数、全部测点、阀门和影响对象放入依据明细。
|
||||
- 多个对象或多组数值比较使用有证据引用的 `ui_blocks.Table`。表格按实际资料选择对象、
|
||||
指标、时刻、异常值、当前值、基线与结果等列,单位放在列名或单元格;缺值明确写未取得。
|
||||
全部目标按需分表,不以精简正文为由缩小调查、证据或 SCADA 展示覆盖。
|
||||
- 使用 `Result` 突出有依据的判断、关键变化或实际待办,使用带业务标题的 `Section` 组织明细。
|
||||
模型候选或尚待现场核验用 `unconfirmed`,部分完成用 `partial`;`success` 必须有相应通过依据。
|
||||
现有文字字段按纯文本展示,不写 Markdown 加粗、标题、表格或 HTML,也不引入自定义强调字段。
|
||||
- 正文、进展、缺口说明和组件可见文字采用中文业务语言。执行字段、技术状态码、流程版本、
|
||||
完整 UUID 和证据哈希留在结构化引用或执行记录中。必要资产编号、SCADA、DN600、单位、
|
||||
模型相似度和有业务用途的工单简称可以保留,不能机械删除所有英文或数字。
|
||||
- 阅读时间先换算为 `Asia/Shanghai`,再以当地日期和时刻显示,例如 `2026-07-16 13:15`。
|
||||
正文、标题、进展、表格和图表均省略时区名称、偏移及 ISO 原始时间戳。已有 `+08:00`
|
||||
不重复换算,跨日同步更新日期;查询时间、`sample_times` 和证据时间戳仍保留明确偏移。
|
||||
事件发生、工单完工、恢复采样和报告生成时间各自标明用途,不能互相代替。
|
||||
- 区分观测、模型估计、候选和现场反馈。相似度不是发生概率,提交工单不证明现场完工,
|
||||
单帧复核通过不证明长期稳定;不同来源或阶段的设备数分别说明范围,不能混用分母。
|
||||
单条流量规则未触发不表示所有流量未变化;不同影响集合不能直接相加。
|
||||
- 判断边界只保留影响理解的限制。实际资料缺项放入 `data_gaps`;未来常规采样和日常监测
|
||||
写入建议,不将其包装成当前关键缺口。非阻塞资料缺项不得新增事件闭环条件。
|
||||
|
||||
初次调查说明异常、候选及预计影响;等待处置说明已完成事项、实际等待条件和下一步;
|
||||
恢复报告先说明现场反馈与完工时间、恢复复核时刻与结果,再给出必要限制和后续监测安排。
|
||||
恢复明细来自本次恢复证据,原异常分析作为历史依据保留,不在摘要中重复全部定位与隔离参数。
|
||||
|
||||
`progress.summary` 最多 300 字符,建议 120 字以内,只写本轮新增发现、当前依赖和下一步,
|
||||
不复制正式报告、不汇报执行字段和流程版本。整理报告时核对脉络图:已解除的等待条件应按
|
||||
原节点更新业务说明和参考标记,保留失败尝试与原路线,不重复添加相同连线。
|
||||
这不要求逐步更新图示,也不改变任何运行、审批、等待或闭环判据。
|
||||
|
||||
Server 的新写入模型及 Agent 的原生工具、宿主发布入口校验长度、明显执行字段与技术编号、
|
||||
原始时间表达和无法渲染的格式标记;组件的可见文字同样校验,结构化证据编号与采样时间除外。
|
||||
校验错误返回具体原因,由 Agent 依据原证据改写后重试,不截断正文、不删除引用或补造缺口。
|
||||
语义准确性、信息取舍和表格完整性仍需遵循提示词与既有证据、覆盖校验,不能由正则代替。
|
||||
|
||||
上述长度和文字规则仅约束新发布,不应用于历史读取或原证据快照;不回写已有报告。
|
||||
|
||||
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
||||
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。
|
||||
|
||||
## 业务约束
|
||||
@@ -130,7 +177,7 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
||||
"gap_id": "terminal-sample-gap",
|
||||
"node_id": null,
|
||||
"missing_data": "投诉时段末梢水样检测结果",
|
||||
"scope": "投诉小区,2026-09-24 08:00 至 09:00(北京时间)",
|
||||
"scope": "投诉小区,2026-09-24 08:00 至 09:00",
|
||||
"impact": "无法判断异常来自供水管网还是用户端。",
|
||||
"required_data": "该地点和时段的采样检测记录",
|
||||
"evidence_refs": ["sample-query-empty"],
|
||||
@@ -147,10 +194,15 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
||||
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
|
||||
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
|
||||
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
|
||||
- 新发布拒绝超长摘要、正文执行字段、完整技术编号、原始时间表达和未支持的格式标记;
|
||||
相同历史报告仍可读取,结构化证据引用与带偏移的采样时间保持不变。
|
||||
- 生成结果结论在前、明细入表,明确单位、时间和各自覆盖范围;
|
||||
候选、现场反馈、单帧复核与长期监测保持各自含义,进展和图示不与当前事实冲突。
|
||||
|
||||
## 发布、阅读与协作
|
||||
|
||||
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照持续更新,
|
||||
执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照按需整理,
|
||||
在路线调整、重要结果或报告归纳时更新即可,不要求逐步更新或及时同步。
|
||||
见 [调查进展与实时展示](investigation-progress-and-presentation.md)。
|
||||
报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
|
||||
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
|
||||
| --- | --- | --- |
|
||||
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
|
||||
| `run_burst_simulation` | 假设管道破口正向模拟;可选全网同刻在线压力差与1/3/5米水头覆盖,详见[爆管流程](burst-response-workflow.md) | `simulation.run` + `scada.view`,影响比较另需 `simulation.view` |
|
||||
| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
|
||||
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
|
||||
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
|
||||
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
||||
| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` |
|
||||
| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` |
|
||||
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
|
||||
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
|
||||
| `analyze_flushing` | 指定排放点、流量与互斥的 `opened_valves` / `closed_valves`,评估独立冲洗水力情景并保存阀态和预计排放量;不证明污染清除或水质合格 | `simulation.run` + `scada.view` |
|
||||
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
||||
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
|
||||
|
||||
|
||||
@@ -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 清单自动开放能力。
|
||||
|
||||
## 当前系统与功能核对
|
||||
|
||||
|
||||
@@ -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边界、字节门槛和合成摘要,不代替真实项目验收。
|
||||
|
||||
@@ -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#工单事件主动同步)。
|
||||
|
||||
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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、同步器及配套前端。提交代码不证明现有进程已重载。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# 外围工单主动同步
|
||||
|
||||
工单的创建、编辑、现场反馈和删除改为由来源事件触发同步。浏览器继续使用已有工作台 SSE,不需要新增连接或定时工单轮询。
|
||||
|
||||
```text
|
||||
外围工单写入
|
||||
→ 同一 PostgreSQL 事务保存源记录、历史版本和变更事件
|
||||
→ COMMIT 后 NOTIFY
|
||||
→ Server 工作台 SSE 唤醒外围同步器
|
||||
→ 同步器通过授权 API 更新任务投影
|
||||
→ task/action/notification 事件推送给浏览器
|
||||
```
|
||||
|
||||
## 数据库范围
|
||||
|
||||
本次仅启用 `tjwater_v2`。没有新增表、列或迁移;复用 `workbench.domain_event_log` 的 `workbench.work_order.changed` topic。源事件的聚合类型为 `peripheral_work_order`,元数据只包含 `source=peripheral_sample`、工单 ID 和 `created|updated|deleted`,不包含工单正文。事务失败时,源记录、历史、事件及通知一起回滚;同一请求的幂等重放不再产生事件。
|
||||
|
||||
外围服务只写源记录及源变更事实,不直接改业务任务。任务投影、调查创建和通知仍通过 Server 状态机和现有授权 API 处理。其他数据库不写入该源事件;扩展前遵循 [数据库变更门禁](workbench-database-rollout.md)。事件表缺失或无写权限时,`tjwater_v2` 的工单写入返回存储错误,不会假装成功。
|
||||
|
||||
## 同步器
|
||||
|
||||
从 Server 仓库运行已有命令:
|
||||
|
||||
```bash
|
||||
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||
conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
||||
```
|
||||
|
||||
继续复用 `OPERATIONAL_WORKER_*` OAuth 工作身份及 `OPERATIONAL_ACTION_PERIPHERAL_URL`,不需要新账号或密钥。启动和游标过期时先读取事件游标,再进行全量对账;扫描期间的事件从该游标继续补读。相同源版本仍由原幂等键去重。源事件按工单 ID 唤醒同步器;动作回执事件只补查该任务关联的工单,避免源创建早于回执保存时漏同步。任务投影事件不再触发全量扫描。
|
||||
|
||||
正常状态收到源事件即处理对应 ID:`GET /api/v1/operational-tasks?peripheral_work_order_id=<uuid>` 只返回同项目的来源任务和动作回执关联任务;外围接口只读取该工单,不再分页遍历全部工单和全部调查详情。重复 ID 合并;目标工单的明确删除只撤回该工单投影,不影响其他任务。
|
||||
|
||||
内存待处理 ID 最多 200 个,超过上限合并为一次全量对账,不持续扩张队列。这是突发积压保护,不增加公共配置项。
|
||||
|
||||
默认每 300 秒仍进行一次全量对账,处理漏通知、断线、旧批量脚本直接写库及重启。持续收到事件不会推迟全量对账期限。可用 `--reconcile-seconds` 调整这一兜底周期(最小 1 秒),不再复用工况 worker 的 30 秒租约检查周期。同步失败按 5、10、20、40、60 秒退避重试;事件连接断开每 5 秒重试,保留最后游标。扫描开始前取出待处理 ID,扫描期间新到达的 ID 留给下一轮;失败的 ID 会放回队列,重试不依赖新消息。
|
||||
|
||||
浏览器收到源事件时不会提前刷新业务快照,而是等待任务投影事件后读取对应任务。工况手动刷新仍只读 Server 权威快照。工单事件不等于 Header 通知;只有需要处理的业务待办或告警才进入通知列表。
|
||||
|
||||
游标顺序、共享 Hub 查询与兼容边界见 [事件链路收敛](workbench-event-refinement.md)。新增查询参数已从 Server OpenAPI 同步生成 Agent 类型,不另建手写契约。
|
||||
|
||||
## 验证与启用
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests/unit/test_peripheral_push_sync.py tests/unit/test_peripheral_work_order_events.py tests/unit/test_peripheral_task_sync.py -q
|
||||
PERIPHERAL_SAMPLE_LIVE_TEST=1 conda run -n server python -m pytest tests/integration/test_peripheral_work_order_events.py -q
|
||||
```
|
||||
|
||||
数据库测试强制限定 `tjwater_v2`,使用随机项目键和回滚事务,不保留测试工单或事件。覆盖创建、修改、反馈、删除、幂等重放及回滚无事件。HTTP 隔离测试覆盖源事件即时投影、游标重置、断线续传、失败重试和监听取消。
|
||||
|
||||
上线时更新外围服务、Server 同步器及前端,再按既有发布流程重启对应进程。提交代码不会自动重启现有服务;旧同步器进程仍按旧的 30 秒周期运行。回退应用代码不涉及数据结构回滚,已有事件仍可正常补读;回退到旧同步器后恢复周期对账。
|
||||
@@ -161,7 +161,7 @@ MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,
|
||||
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
|
||||
| [清洗服务](../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) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 水污染侦测事件调查
|
||||
|
||||
水质监测侦测到异常后创建 `workflow=water_pollution`、`event_kind=water_pollution` 的事件调查,分发 [water-pollution-event](../resources/work-order-skills/water-pollution-event/SKILL.md)。`water_quality_complaint` 保持供水水质投诉上报语义,使用原投诉流程。两者不共用触发条件;污染事件不依赖爆管评分、压力下降或工单上报。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
detection[有效水质观测命中业务规则] --> task[独立水污染事件调查]
|
||||
task --> evidence[全量观测及七天同期核验]
|
||||
evidence --> protection[并行提出用户保护与应急供水措施]
|
||||
evidence --> trace[候选溯源与采样核验]
|
||||
trace --> node[展示已核验节点]
|
||||
node --> report[候选源阶段结论]
|
||||
report --> action[立即提出现场核查采样工单供审批派发]
|
||||
report --> simulation[污染传播模拟与累计浓度影响范围]
|
||||
simulation --> source[污染输入控制建议]
|
||||
source --> isolation[保护用户与隔离边界]
|
||||
isolation --> treatment[按污染物选择清除方法]
|
||||
treatment --> flushing[清洁水入口 导流阀门 排放处置与分步冲洗]
|
||||
action --> feedback[现场操作与采样反馈]
|
||||
flushing --> feedback
|
||||
feedback --> review[核对实际水质复测与恢复依据]
|
||||
review --> decision{复测依据是否支持恢复}
|
||||
decision -->|否或资料不足| treatment
|
||||
decision -->|是| final[分区恢复建议与最终报告]
|
||||
```
|
||||
|
||||
图中连线表示业务与展示层次,先期保护、工单审核和独立计算可以并行。阀门操作均为方案,现场动作继续经过原审批与派发;阶段记录不能替代执行回执。首次展示保留处置前传播结果或明确的数据缺口,再展示隔离及清除方案。
|
||||
|
||||
## 侦测入口
|
||||
|
||||
现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。
|
||||
|
||||
当前侦测来源沿用既有 SCADA 历史回放处理器;本次没有新增实时采集或常驻水质侦测调度器。其他可信侦测生产者可经现有 operational-tasks 创建接口提交同一 workflow,保留实际侦测信号与发生时间;普通任务无需 replay_context。
|
||||
|
||||
## 临时溯源契约
|
||||
|
||||
`run_contaminant_source_trace` 要求 `simulation.run`,只读当前项目模型,禁止只读调查子角色调用。输入包含从水质设备映射或资产查询取得的 `candidate_node_ids`(1–20 个不同节点)及带时区、最长24小时的 `start/end`。回放窗口不能越过知识截止。接口直接返回结果,当前不入计算队列。
|
||||
|
||||
临时实现从提交的候选中核验模型节点,按字符串排序返回首个存在的节点,不执行反演或拟合。返回 `located_node`、`top_candidates`、`validated_node_ids`、`missing_node_ids`、`selection_basis`、`algorithm_status=provisional`;证据 `origin=synthetic`,不编造相似度或置信度。所有节点不存在时明确返回 `no_candidate`。后续正式算法替换服务内部选择逻辑,保留节点结果契约及后续调查步骤。
|
||||
|
||||
候选节点只是当前可分析目标;定位后先保存 `interim/concluded`,立即提出关联 `general` 现场核查/采样处置工单,随后继续污染传播模拟和 `accident_element_type=node` 的隔离。审核和派发不依赖模拟完成;等待工单时继续独立分析,模拟结果补入同一 `task_id`,不重复创建工单或改写已审核动作,权限与版本校验保持原有流程。
|
||||
|
||||
## 污染传播模拟与影响覆盖
|
||||
|
||||
`run_contaminant_simulation` 增加 `include_impact_coverage`(默认 false)与 `impact_threshold_mg_l`(默认0,严格大于)。开启时,在筛选最多20个展示节点之前,使用原生结果的全网节点、整个模拟窗口的报告采样汇总累计超阈值节点数、各节点首个超阈值报告时刻和峰值。原生浓度 `MGL` / `UGL` 分别转换为 mg/L 后比较;缺失、无效浓度、错误时间网格或不支持的单位保留为缺口,不能转成零浓度或安全范围。几何超过10000节点上限时仍返回数值估计,并明确地图不可用。
|
||||
|
||||
阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。
|
||||
|
||||
回放同样允许该工具,要求启用影响覆盖且起算不晚于知识截止;预测时段可以越过截止,但后续时刻只是模型预测。与爆管情景一样,使用当前模型和空 SCADA 映射传入临时模型计算,不读取可变实时监测,不重建历史模型。仍禁止借此放开未授权观测或其他水力工具。
|
||||
|
||||
工具沿用计算队列和 owner/run 校验,返回终态后通过 `get_calculation_result` 读取并持久保存证据。`presentation_preview` 提供基于 `evidence_id` 的地图请求;用正式调查展示事件保存模型曲线和覆盖,历史播放还原已保存证据。审批、派发可在计算排队或运行期间进行;存在未终态计算时继续保留原运行,不允许发布 final 或成功结束调查,已有工单不受模拟失败影响。
|
||||
|
||||
## 展示、反馈与收尾
|
||||
|
||||
`water-pollution-event` 1.2.0 将观测核验、候选定位、处置前传播、源头控制、用户隔离、清除冲洗、现场反馈、水质复测和恢复复核分开。每次正式展示保存 `purpose`;有证据的阶段通过 `narration.stage` 记录,新增 `source_control`、`flushing_plan`、`clearance_verification`。历史保存格式继续使用原事件 metadata JSON,不增加数据库列。前端显示阶段和本步目的,逐步回看保留独立画面;同阶段兼容图表仍可合并。
|
||||
|
||||
源头控制与保护下游用户分别决策。用已核实的连接和流向判断水力下游,不能按地理方位判断;源头未核实不得声称污染输入已停止。清除方法按污染物及现场条件选择,不统一推荐煮沸、加药或排水。通知保留,工单按执行职责和阶段复用或必要拆分,不能同时要求同一阀门开和关。
|
||||
|
||||
### 冲洗模型与阀门方案
|
||||
|
||||
`analyze_flushing` 增加可选 `opened_valves`,与 `closed_valves` 互斥且各自去重。明确开阀用于清洁水进入或导流,关阀用于保护用户和约束路径;不改变阀门原调节设定。普通计算和保存冲洗方案使用相同显式阀态。未列阀门沿用当前模型状态,每次计算为独立情景,不能继承上次计算的阀位或浓度场。
|
||||
|
||||
每一步操作表保存:目的、前置条件、清洁水源及依据、排放节点与污染水处置去向、完整开关阀清单、计划顺序、流量、持续时间、预计排放量、监测要求、实际执行反馈。清洁进水阀打开不表示用户可以恢复供水。路径只采用已核实资产几何,不用直线伪造流向。
|
||||
|
||||
计算返回 `flushing_plan`,包含开关阀、排放节点、流量和 `estimated_discharge_volume_m3`(给定流量乘持续时间)。它仅是水力情景,`water_quality_clearance_verified=false`、`field_execution_verified=false`;估计水量不是实测排放量。当前不模拟停止污染输入后的连续清除衰减,也不验证进水洁净、排放合规或实验室合格。缺少模型或现场依据时报告具体限制,不伪造清除效果。带回放上下文的调查仅新增独立模型冲洗情景:起算不晚于知识截止,始终传入空 SCADA 映射,禁止实时观测回填;原角色、任务、权限及作业所有权校验保留。历史播放读取已保存方案,不重新计算或现场操作。
|
||||
|
||||
### 复测与恢复依据
|
||||
|
||||
复核记录采样地点、时间、指标、数值、单位、适用判定依据、受影响范围覆盖及结论,区分实际结果和模型预测。工单完成、压力恢复或余氯单项正常不能证明目标污染物已清除。关键复测资料不足或未通过时,Agent 保存 `interim` 并提出具体补测或调整方案;不统一新增固定轮数、时长或阈值。已有足够复测与恢复依据时,展示可恢复区域并发布引用有效现场反馈及复测证据的最终报告。Server 继续校验反馈引用,不自动判定实验室合格,也不授权设备操作或现场复供。
|
||||
|
||||
水质图表使用 `chart.scada.metric=quality`,沿用真实单位、三小时当前趋势、前七天同期散点及证据校验;不同指标分图。同单位不同水质指标通过图表纵轴名称区分展示分组。Server、Agent、Frontend 的图表协议同步支持 quality,正式报告仍需引用本轮全量 SCADA 证据。
|
||||
|
||||
关联现场任务使用 general 工单。成功创建后保存在 `handling_context.work_orders`,进入 `waiting_work_order`;全部有效工单 completed 后进入 `field_review`,接续 Agent,而非执行爆管压力恢复计算。最终 concluded 报告必须引用全部有效工单最新的 `feedback_evidence_id`,然后由 Server 完成任务并解除关联告警。完工反馈或新版本复测资料会重新进入复核;证据不齐时 Agent 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。
|
||||
|
||||
所有新增状态保存在既有 JSON 文档,无数据库迁移、环境配置或新服务。现有服务更新代码及流程包后才生效;单元测试和契约校验不等同真实模型 Agent 调查验收。
|
||||
@@ -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 秒重试,成功后才确认失效事件。被并发游标守卫拒绝的快照不能当作刷新成功;仅在更新的成功快照已覆盖失效游标时停止重试。正常读取成功后不安排定时请求;依赖变化、重置或离开页面会取消旧请求及重试定时器。这不是新增正常轮询通道,也不扩大初始化列表的裁剪/重试语义。
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# 工作台工单与告警存储契约
|
||||
|
||||
工作台聚合外围来源工况任务与任务主动推送的告警。浏览器不推导告警,也不直接写工况任务。
|
||||
|
||||
## 任务告警
|
||||
|
||||
DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST /api/v1/workbench/task-alert-events` 推送 `raised`、`acknowledged` 或 `resolved` 事件。来源包含 `task_type`、`task_id` 和 `run_id`,生产者由认证项目身份绑定。`event_id` 在项目和生产者范围内幂等,告警事实在首次 raised 后不可改写。
|
||||
|
||||
## 预约工单
|
||||
|
||||
外围样板的 `peripheral_sample.work_orders` 是演示工单的管理来源;`activity` 只记录实际发生的创建、修改和现场反馈,不由总状态补造阶段。同步器将有效工单写入 `workbench.operational_tasks` 的 `external_work_order` 投影。原有三张独立演示工单也已迁入外围样板,并由同一面板管理。
|
||||
|
||||
`resources/sql/011_workbench_work_orders.sql` 和 `017_workbench_schema_comments.sql` 属于历史基线;`021_retire_workbench_work_orders.sql` 在验证三个迁入目标及其任务投影后,仅在 `tjwater_v2` 删除旧表。不要重放 `011` 重建它。`GET /api/v1/workbench/runtime` 暂保留空的 `work_orders` 字段作为接口兼容。其他项目数据库没有旧表,不需要应用 `021`。
|
||||
|
||||
告警事件表需要 `SELECT, INSERT`,告警状态表需要 `SELECT, INSERT, UPDATE`。迁移及扩展门禁见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。
|
||||
@@ -7,4 +7,20 @@
|
||||
- `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。
|
||||
- 每个可选分区独立读取并返回 `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.2"
|
||||
version: "1.1.4"
|
||||
---
|
||||
|
||||
# 爆管事件处理
|
||||
|
||||
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
||||
|
||||
先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
||||
|
||||
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
||||
|
||||
## 1. 接收与核对
|
||||
@@ -21,33 +23,45 @@ metadata:
|
||||
|
||||
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
|
||||
|
||||
## 3. 隔离方案
|
||||
定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。
|
||||
|
||||
## 3. 爆管后、处置前的影响范围
|
||||
|
||||
核对定位管道后,用 run_burst_simulation(include_impact_coverage=true)模拟事件时刻的破口,orifice_areas_cm2 保留来源;无实测破口面积时可用有说明依据的估计情景,明确假设与不确定性,不把定位漏量直接当作破口面积。缺少依据则提具体待补参数,继续工单与隔离分析。
|
||||
|
||||
工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。
|
||||
|
||||
读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。先保留此步骤,再展示隔离方案,不能用后续隔离覆盖替换本步。
|
||||
|
||||
## 4. 隔离方案
|
||||
|
||||
对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。
|
||||
|
||||
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
|
||||
|
||||
## 4. 影响说明
|
||||
## 5. 影响说明
|
||||
|
||||
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
|
||||
|
||||
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
|
||||
- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。
|
||||
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。已有可靠破口参数时可按需补充事件情景模拟。
|
||||
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。事件情景采用第3节结果,缺口单列。
|
||||
- **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。
|
||||
|
||||
现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。
|
||||
|
||||
## 5. 处置建议与报告
|
||||
## 6. 处置建议与报告
|
||||
|
||||
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
||||
复用定位阶段已发起的工单及 action_id;保存更新后的 interim/concluded 阶段报告,再按新增需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
||||
|
||||
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。已完成本轮可执行分析且依赖结果已读取时发布 final,现场处理进展由后续反馈接续。
|
||||
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第7节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
||||
|
||||
## 6. 反馈与闭环复核
|
||||
## 7. 反馈与闭环复核
|
||||
|
||||
读取摘要中的 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、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,工单 completed 且恢复证据 status=passed 时发布 final/concluded 恢复闭环报告并引用该证据。Server 完成运行后关闭任务并解除关联告警,phase=completed 表示已闭环。照片、阀位或复测记录未提供时仅记为资料缺项,不增设关闭前置条件。来源系统若另有明确行政结案流程,按其实际状态另记,不推断为当前任务尚未闭环。
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
name: scada-anomaly
|
||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||
metadata:
|
||||
version: "1.0.14"
|
||||
version: "1.0.19"
|
||||
---
|
||||
|
||||
# SCADA 数据异常调查
|
||||
|
||||
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
|
||||
|
||||
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
||||
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
||||
|
||||
阅读时间按 Asia/Shanghai 换算,已有 +08:00 不重复换算,跨日更新日期。正文、标题、进展、表格和图表只写当地日期、时刻与单位,省略时区名称、偏移及原始时间戳;横轴用“时间”。查询、冻结边界和 sample_times 保留明确偏移。
|
||||
|
||||
## 数据核验与诊断
|
||||
|
||||
任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。
|
||||
任一已配置压力、流量或压力模型信号异常即调查。先核验数据质量与测点,再判别原因;组合指标有缺口时继续核查单项异常。水质侦测使用独立 water_pollution / water-pollution-event 流程,与 water_quality_complaint 投诉分开,不计入爆管支持分。
|
||||
|
||||
通过 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拒绝局部或旧批次证据。
|
||||
|
||||
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
||||
核对模型得分、压力变化与出厂边界流量的联合证据;权重、门槛、阈值、测点与基线留在依据。得分越低越异常,相似度非发生概率。区分边界与其他管流测点,规则零命中不代表无流量变化;其他异常按证据选择原因。
|
||||
|
||||
## 候选定位与隔离
|
||||
|
||||
@@ -27,11 +29,17 @@ metadata:
|
||||
|
||||
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
||||
|
||||
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
||||
取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。
|
||||
|
||||
run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,缺口单列;先事件影响,再隔离。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。
|
||||
|
||||
再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
||||
|
||||
## 对比展示与影响
|
||||
|
||||
先查询并发布截至本轮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,14 +49,16 @@ metadata:
|
||||
|
||||
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
||||
|
||||
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。
|
||||
报告先写判断与关键依据,摘要建议 150 字、最多 300 字;多测点数值入 Table,保留单位、方向与缺测。区分各阶段测点数,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
|
||||
|
||||
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
|
||||
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
|
||||
|
||||
## 完工后的恢复复核
|
||||
|
||||
收到关联工单的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不变。
|
||||
|
||||
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
||||
|
||||
恢复报告先写现场反馈与完工时间、采样时刻和结果,表格列全部复核测点及基线;边界说明单帧与模型限制,日常监测列建议。不重列旧定位参数或执行字段;更新原图中已解除的等待标记,保留路线与失败记录。
|
||||
|
||||
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: water-pollution-event
|
||||
description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。
|
||||
metadata:
|
||||
version: "1.2.0"
|
||||
---
|
||||
|
||||
# 水污染侦测事件调查
|
||||
|
||||
适用于 workflow=water_pollution,与 water_quality_complaint 投诉分开,不依赖爆管评分、漏量或压力流量异常。侦测信号、候选污染源、污染确诊和恢复供水分别说明。
|
||||
|
||||
读取任务、信号、时间、handling_context 和已有成果。回放遵守 knowledge_cutoff、batch_sequence;阅读时间 Asia/Shanghai,查询与 sample_times 带时区。后续轮次复用成果,优先新增事实。
|
||||
|
||||
## 1. 核验观测与先期保护
|
||||
|
||||
query_scada_readings 省略 device_ids,读取异常前三小时全量 SCADA,核查分页完成、覆盖、质量、单位、映射及配置阈值。无效值或缺测不等于污染,业务异常不等于法定超标。逐设备对照七天同期,保留日期和 null;压力正常不能排除污染。
|
||||
|
||||
核验、采样与用户保护并行。按风险提出用水限制、通知、重点用户保障及应急供水;缺关联、适配器或批准依据则交人工核定,不称已执行。保护措施不等溯源或模拟,未知污染物不统一建议煮沸、加药或冲洗。
|
||||
|
||||
## 2. 候选溯源与现场工单
|
||||
|
||||
用设备映射、get_element_properties、get_node_connections 核实节点,缺映射则查坐标附近资产。检查 source_trace_capability,run_contaminant_source_trace 提交已核实 candidate_node_ids 和核查窗,读取候选、缺失节点及 selection_basis。当前为临时确定性选点,非正式反演,不编造概率或确诊。synthetic 不阻止分析,无节点仅停依赖分支。
|
||||
|
||||
取得候选后立即 publish_task_presentation 展示节点,保存 interim/concluded,注明“候选污染源待现场核验、传播范围计算中”;propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用定位证据和地点。沿用已有 action_id,不等模拟完成才发起工单。审核派发与模拟、隔离分析并行,结果补入同一 task_id,不覆盖已审核工单或绕过版本校验。
|
||||
|
||||
## 3. 处置前污染影响
|
||||
|
||||
run_contaminant_simulation 设 source=located_node、include_impact_coverage=true,采用事件起算时刻、明确预测时长和需画曲线的 output_node_ids。concentration 为 mg/L,须有来源或说明情景假设,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺依据时列具体参数缺口,继续工单和独立分析。
|
||||
|
||||
impact_threshold_mg_l 默认0、严格大于,仅供展示,非饮用水安全限值。全网整个时段汇总累计节点、首个超阈值采样时刻、峰值与覆盖;沿用初始水质和反应条件,非源独立贡献,范围外不等于安全。
|
||||
|
||||
queued/running 时继续独立工作,get_calculation_job 跟踪、get_calculation_result 读终态。未完计算不能 final,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。
|
||||
|
||||
按 presentation_preview.evidence_id 发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用 chart.scada。保存正式事件供历史复用;空范围、缺测、单位及几何限制单列。先保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。
|
||||
|
||||
回放允许传播及冲洗情景,起算不晚于知识截止,使用当前模型且不读取实时 SCADA;未来样本仅预测,非历史重建。
|
||||
|
||||
## 4. 源头控制与用户隔离
|
||||
|
||||
分别提出污染输入控制、污染区边界与用户保护。源头措施依据污染物、进入路径和现场事实;候选节点不是可关设备,不假定源已停止。用流向及连接核对水力下游,不能按地理方位推断。
|
||||
|
||||
analyze_valve_isolation 设 accident_element=[located_node]、accident_element_type="node",保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。失效阀门用 disabled_valves 核查替代方案,不能隔离时保留原因。
|
||||
|
||||
展示阀门,generate_node_coverage 分组生成直接隔离及新增失源覆盖,再 publish_task_presentation;空组不算。拓扑覆盖非污染边界,无资料不编户数。有权限和输入时 analyze_valve_closure 核验水力影响,不证明污染清除。
|
||||
|
||||
## 5. 污染物清除与分步冲洗
|
||||
|
||||
先说明污染物性质、处理依据和未知项,再提出排空、冲洗、消毒或其他适用处理;不得默认任何污染都能冲洗清除。排放去向及污染水收集处理须有依据,不默认排入雨水、河道或普通排水口。
|
||||
|
||||
核对清洁水源、排放点、流量及许可条件后用 analyze_flushing。每阶段保存目的、前置条件、清洁进水依据、排放去向、完整开关阀清单、顺序、流量、时长、预计水量、监测和待办。隔离和冲洗不能共用固定关阀清单,进水阀打开不等于用户复供;缺参数仅停依赖分支。
|
||||
|
||||
opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。每次是独立当前模型情景,未列阀门沿用模型状态,不继承上次模拟;重列本阶段必要阀态,不能称为连续污染清除。flushing_plan 保存方案及估计排放量,仅证明水力结果,不验证清洁进水、排放合规或现场执行。尚未耦合停止输入后的水质衰减,压力、流量或模型零浓度不能替代复测。
|
||||
|
||||
展示清洁水入口、保护边界、导流阀及排放点;有真实GIS才画路径。拟开、拟关、现场实际阀位分列,不改写实时资产。回放未授权工具记录限制,复用已有证据。
|
||||
|
||||
## 6. 图表、报告与展示节奏
|
||||
|
||||
水质 chart.scada 填 metric=quality、unit、role、完整三小时 window_start/window_end,总览覆盖全部目标。逐设备 time_comparison 用当前 line、七天每天末三真实槽 scatter;series 带 device_id、day_offset、等长 sample_times,缺测 null。不同指标或单位分图,超限分段,不标成流量。
|
||||
|
||||
展示依次为观测、候选、传播、源头控制、隔离、冲洗、反馈、复测、恢复;计算可并行,首次隔离展示须在传播结果或缺口说明之后,通知和工单不等待展示。发布填写具体 purpose;有证据时带 narration(stage、summary、evidence_refs)。沿用原阶段,源头控制用 source_control、冲洗用 flushing_plan、水质复测用 clearance_verification。阶段不等于批准或已执行,不为凑阶段造证据或空操作。
|
||||
|
||||
报告依次写事件概况、观测、候选与传播、源头控制、用户隔离、清除冲洗、反馈复测及恢复条件;各项列结论、对象、预计或实际、证据和待办。操作表保留阶段、阀态、进排水、条件、反馈及复测依据。引用本轮全量 SCADA 和已取得的分析证据;权限拒绝不写成无数据。各阶段独立保存,回放不合并吞步骤,不派单或操作设备。
|
||||
|
||||
## 7. 工单反馈与复测恢复
|
||||
|
||||
复用 general 工单及 action_id,仅因职责或阶段确需拆分才建新单,保留前置条件,不能同时要求同阀开关。回传实际阀位、进排水、执行时间和异常;复测含采样地点、时间、指标、结果、单位、判定依据及结论。通知保留,操作遵守原权限。
|
||||
|
||||
待审批或 waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用 action_id 后退出释放租约,不轮询或重复建单。field_review 复用原调查,只核对最新反馈与相关水质观测;关键事实缺失时先完成独立工作,再用 ask_task_user 提问并等待,必要时提出复测或调整处置工单。
|
||||
|
||||
工单 completed、压力恢复、余氯单项正常或模型预测均不能证明污染物已清除。恢复依据须说明目标污染物或已核实风险、适用检测项目及判定标准、实际采样结果与时间地点、受影响区域覆盖和有依据的恢复结论;无适用依据或结果未通过时保持 interim,列待复测区域与下一步,不能宣布恢复供水或发布恢复闭环 final。不能为所有事件统一增设固定轮数、等待时长或未配置阈值。
|
||||
|
||||
有复测与恢复依据后展示可恢复区域,final/concluded 引用所有有效工单最新 feedback_evidence_id 及复测证据。不套用爆管压力结案,仍异常调整方案,未来采样和恢复不写成已完成。Server不自动认定实验室合格或授权现场复供。
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Preview, back up, then adopt saved progress graphs without rewriting reports.
|
||||
|
||||
Run with ``python -m scripts.migrate_investigation_graphs --help`` from the Server.
|
||||
DSNs are resolved from MetaDB and never written to the plan or console.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationProgressSnapshot
|
||||
from app.infra.db.metadb.database import SessionLocal, engine
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.services.investigation_graph import adopt_legacy_investigation_graph
|
||||
|
||||
|
||||
MIGRATION = "investigation-graph-reference-v1"
|
||||
|
||||
|
||||
def prepare_progress_document(document: dict) -> dict | None:
|
||||
"""Return a new revision, preserving publication provenance and Mermaid bytes."""
|
||||
graph = document["progress"].get("diagram")
|
||||
if graph is None or graph.get("edges") is not None:
|
||||
return None
|
||||
diagram = adopt_legacy_investigation_graph(InvestigationDiagram.model_validate(graph))
|
||||
result = deepcopy(document)
|
||||
result["revision"] += 1
|
||||
result["progress"]["diagram"] = diagram.model_dump(mode="json")
|
||||
InvestigationProgressSnapshot.model_validate(result)
|
||||
return result
|
||||
|
||||
|
||||
def _write_private(path: Path, content: str, *, append: bool = False) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | (os.O_APPEND if append else os.O_EXCL)
|
||||
with os.fdopen(os.open(path, flags, 0o600), "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
async def _routing(project_id: UUID) -> ProjectDbRouting:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(text("SET TRANSACTION READ ONLY"))
|
||||
route = await MetadataRepository(session).get_project_db_routing(project_id, "biz_data")
|
||||
if route is None or route.db_type != "postgresql":
|
||||
raise ValueError(f"Project {project_id} has no business PostgreSQL routing")
|
||||
return route
|
||||
|
||||
|
||||
async def preview(project_id: UUID) -> dict:
|
||||
route = await _routing(project_id)
|
||||
async with await AsyncConnection.connect(
|
||||
route.dsn, row_factory=dict_row, connect_timeout=8,
|
||||
options="-c default_transaction_read_only=on -c statement_timeout=30000",
|
||||
) as conn:
|
||||
cursor = await conn.execute("SELECT current_database() AS database")
|
||||
database = (await cursor.fetchone())["database"]
|
||||
cursor = await conn.execute(
|
||||
"""SELECT task_id,run_id,document FROM workbench.operational_task_progress
|
||||
WHERE project_id=%s AND jsonb_typeof(document#>'{progress,diagram}')='object'
|
||||
AND jsonb_typeof(document#>'{progress,diagram,edges}') IS DISTINCT FROM 'array'
|
||||
ORDER BY task_id,run_id""", (project_id,),
|
||||
)
|
||||
records = []
|
||||
for row in await cursor.fetchall():
|
||||
after = prepare_progress_document(row["document"])
|
||||
if after is not None:
|
||||
records.append({"task_id": str(row["task_id"]), "run_id": str(row["run_id"]),
|
||||
"before": row["document"], "after": after})
|
||||
return {"project_id": str(project_id), "database": database, "records": records}
|
||||
|
||||
|
||||
async def _protected_digest(conn: AsyncConnection, project_id: UUID) -> dict[str, str]:
|
||||
"""Compare complete protected rows inside the migration transaction."""
|
||||
hashes = {}
|
||||
for table in ("operational_tasks", "operational_task_runs", "operational_task_reports"):
|
||||
# Table names are fixed here, never supplied by CLI input.
|
||||
cursor = await conn.execute(
|
||||
f"SELECT to_jsonb(t) AS row FROM workbench.{table} t WHERE project_id=%s",
|
||||
(project_id,),
|
||||
)
|
||||
rows = sorted(json.dumps(row["row"], sort_keys=True) for row in await cursor.fetchall())
|
||||
hashes[table] = hashlib.sha256(json.dumps(rows).encode()).hexdigest()
|
||||
return hashes
|
||||
|
||||
|
||||
async def apply_project(project: dict) -> dict:
|
||||
project_id = UUID(project["project_id"])
|
||||
route = await _routing(project_id)
|
||||
async with await AsyncConnection.connect(
|
||||
route.dsn, row_factory=dict_row, connect_timeout=8,
|
||||
options="-c lock_timeout=5000 -c statement_timeout=30000",
|
||||
) as conn:
|
||||
repo = OperationalTaskRepository(conn, project_id)
|
||||
updated = already_managed = 0
|
||||
async with repo.transaction():
|
||||
cursor = await conn.execute("SELECT current_database() AS database")
|
||||
if (await cursor.fetchone())["database"] != project["database"]:
|
||||
raise ValueError("Project database routing changed since preview")
|
||||
before = await _protected_digest(conn, project_id)
|
||||
for item in project["records"]:
|
||||
await repo.lock(f"task:{item['task_id']}")
|
||||
cursor = await conn.execute(
|
||||
"""SELECT document FROM workbench.operational_task_progress
|
||||
WHERE project_id=%s AND task_id=%s AND run_id=%s FOR UPDATE""",
|
||||
(project_id, item["task_id"], item["run_id"]),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row and (row["document"]["progress"].get("diagram") or {}).get("edges") is not None:
|
||||
already_managed += 1
|
||||
continue
|
||||
if row is None or row["document"] != item["before"]:
|
||||
raise ValueError(f"Progress changed since preview: {item['task_id']}; prepare a new plan")
|
||||
expected = prepare_progress_document(item["before"])
|
||||
if expected is None or expected != item["after"]:
|
||||
raise ValueError("Migration plan does not match the graph contract")
|
||||
await repo.save_progress(item["task_id"], expected)
|
||||
await repo.append_event(
|
||||
topic="operational.progress.changed", aggregate_type="operational_task_progress",
|
||||
aggregate_id=item["task_id"], aggregate_revision=expected["revision"],
|
||||
occurred_at=datetime.now(timezone.utc), metadata={
|
||||
"task_id": item["task_id"], "run_id": item["run_id"],
|
||||
"execution_generation": expected["execution_generation"], "migration": MIGRATION,
|
||||
},
|
||||
)
|
||||
updated += 1
|
||||
if before != await _protected_digest(conn, project_id):
|
||||
raise ValueError("Protected task/run/report rows changed; migration rolled back")
|
||||
return {"project_id": str(project_id), "database": project["database"], "updated": updated,
|
||||
"already_managed": already_managed, "protected_rows_unchanged": True}
|
||||
|
||||
|
||||
async def run(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
if args.plan:
|
||||
if not args.project_id:
|
||||
raise ValueError("--plan requires at least one --project-id")
|
||||
projects = [await preview(project_id) for project_id in dict.fromkeys(args.project_id)]
|
||||
plan = {"migration": MIGRATION, "created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"projects": projects}
|
||||
_write_private(args.plan, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
|
||||
print(json.dumps({"plan": str(args.plan), "projects": [
|
||||
{"project_id": item["project_id"], "database": item["database"],
|
||||
"progress_graphs": len(item["records"]),
|
||||
"tasks": len({row["task_id"] for row in item["records"]})} for item in projects
|
||||
]}))
|
||||
else:
|
||||
plan = json.loads(args.apply.read_text(encoding="utf-8"))
|
||||
if plan["migration"] != MIGRATION:
|
||||
raise ValueError("Unsupported migration plan")
|
||||
for project in plan["projects"]:
|
||||
result = await apply_project(project)
|
||||
_write_private(args.apply.with_suffix(".receipts.jsonl"), json.dumps(result) + "\n", append=True)
|
||||
print(json.dumps(result), flush=True)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--plan", type=Path, help="Write a preview and full progress backup to a new private JSON file")
|
||||
mode.add_argument("--apply", type=Path, help="Apply an existing preview; changed rows abort that project's transaction")
|
||||
parser.add_argument("--project-id", type=UUID, action="append", help="Explicit project scope for --plan")
|
||||
asyncio.run(run(parser.parse_args()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,7 @@
|
||||
# Missing API Endpoints
|
||||
|
||||
> 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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,28 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
|
||||
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_progress_route_accepts_partial_node_updates_without_weakening_saved_diagrams(monkeypatch):
|
||||
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||
|
||||
schemas = app.openapi()["components"]["schemas"]
|
||||
assert "label" not in schemas["InvestigationGraphNodeUpdate"]["required"]
|
||||
assert "label" in schemas["InvestigationDiagramNodeRef"]["required"]
|
||||
service = SimpleNamespace(publish_artifacts=AsyncMock(return_value={"progress_revision": 2}))
|
||||
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
||||
task_id = uuid4()
|
||||
command = AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": str(uuid4()),
|
||||
"progress": {"summary": "已核对补充资料", "graph_update": {
|
||||
"nodes": [{"node_id": "review", "status": "recorded"}],
|
||||
}},
|
||||
})
|
||||
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
|
||||
forwarded = service.publish_artifacts.await_args.args[1].progress.graph_update.nodes[0]
|
||||
assert forwarded.node_id == "review" and forwarded.status == "recorded"
|
||||
assert "label" not in forwarded.model_fields_set
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
+992
@@ -0,0 +1,992 @@
|
||||
[
|
||||
{
|
||||
"name": "zero and sparse historical data",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "old mixed charts are read only",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "line",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing history timestamps",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing observed timestamp",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
null,
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timestamp on a missing value",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-09T08:00:00+08:00",
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wrong projected slot",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:30:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mixed historical dates",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-10T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "same day history",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "future history",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-17T08:15:00+08:00",
|
||||
"2026-07-17T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate slots",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:00",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date appended axis",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"07-16 08:00",
|
||||
"07-16 08:15",
|
||||
"07-16 08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "inconsistent slot precision",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bars mixed with comparison",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "bar",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no historical series",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no current observations",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing samples with missing timestamps",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
null,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "UTC spelling preserves projected instant",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T00:15:00Z",
|
||||
"2026-07-09T00:30:00Z"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ordinary line",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "line",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "合成读数",
|
||||
"data": [
|
||||
0,
|
||||
null,
|
||||
30
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ordinary bar",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "bar",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "合成读数",
|
||||
"data": [
|
||||
0,
|
||||
null,
|
||||
30
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "midnight rollover keeps one window",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "跨午夜同期对比",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"23:45",
|
||||
"00:00"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
25
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T23:45:00+08:00",
|
||||
"2026-07-17T00:00:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
26,
|
||||
27
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-15T23:45:00+08:00",
|
||||
"2026-07-16T00:00:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "microseconds are not aligned",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00.000001+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fraction beyond Python microsecond precision",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00.0000001+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar overflow",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "calendar overflow",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"07:45"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
1
|
||||
],
|
||||
"sample_times": [
|
||||
"9999-12-31T23:45:00Z"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史缺测",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar underflow",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "calendar underflow",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"08:00"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
1
|
||||
],
|
||||
"sample_times": [
|
||||
"0001-01-01T00:00:00+23:59"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史缺测",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}}
|
||||
{"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}}
|
||||
{"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}}
|
||||
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P1","evidence_refs":["dev:synthetic"],"chart":{"title":"P1 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P1 当前 2026-07-16","type":"line","data":[32,28,30],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P1 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P1 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P1 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P1 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P1 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P1 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P1 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P2","evidence_refs":["dev:synthetic"],"chart":{"title":"P2 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P2 当前 2026-07-16","type":"line","data":[31,27,29],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P2 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P2 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P2 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P2 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P2 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P2 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P2 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||
{"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}}
|
||||
{"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}}
|
||||
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}}
|
||||
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"}
|
||||
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P1"}}
|
||||
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P2"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_highlights"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_scada_analysis"}}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Opt-in read-only JSONB probes; no tables or business records are written."""
|
||||
import asyncio
|
||||
import os
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.infra.db.postgresql import read_queries
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", ["progress", "report"])
|
||||
def test_reference_annotations_are_normalized_before_nested_paging(section):
|
||||
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||
|
||||
async def check():
|
||||
statuses = ["running", "completed", "waiting", "waiting_human", "skipped", "cancelled", "failed", None]
|
||||
original = {"section": section, "data": {
|
||||
"revision": 3, "execution_generation": 2,
|
||||
section: {"diagram": {"mermaid": "flowchart TD\nA[原图]", "node_refs": [
|
||||
{"node_id": f"n{index}", "status": status, "detail": "保留原说明", "evidence_refs": ["e1"]}
|
||||
for index, status in enumerate(statuses)
|
||||
] + [{"node_id": "unset"}]}},
|
||||
"status": "running", "action": {"status": "completed"},
|
||||
}}
|
||||
untouched = deepcopy(original)
|
||||
async with await AsyncConnection.connect(
|
||||
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||
) as conn:
|
||||
async def page(path=(), *, limit=50, offset=0, full=False):
|
||||
statement, params = read_queries._reference_diagram_projection(
|
||||
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), section,
|
||||
)
|
||||
return await read_queries._snapshot_page(
|
||||
conn, stored_sql=statement, stored_params=params, data_path=list(path),
|
||||
limit=limit, offset=offset, mcp_sources=[], full_snapshot=full, max_bytes=8000,
|
||||
)
|
||||
|
||||
scalar = await page([section, "diagram", "node_refs", "0", "status"])
|
||||
assert scalar["value"] == "reference"
|
||||
second = await page([section, "diagram", "node_refs"], limit=1, offset=1)
|
||||
assert second["value"][0]["status"] == "recorded"
|
||||
assert second["total"] == 9 and second["nested"]
|
||||
assert second["value"][0]["evidence_refs"]["data_path"] == [section, "diagram", "node_refs", "1", "evidence_refs"]
|
||||
snapshot = await page(full=True)
|
||||
nodes = snapshot["data"][section]["diagram"]["node_refs"]
|
||||
assert [node.get("status") for node in nodes] == [
|
||||
"reference", "recorded", "needs_input", "needs_input", "not_taken", "not_taken", "failed", None, None,
|
||||
]
|
||||
assert "status" not in nodes[-1]
|
||||
assert nodes[6]["detail"] == "保留原说明" and nodes[6]["evidence_refs"] == ["e1"]
|
||||
assert snapshot["data"]["status"] == "running"
|
||||
assert snapshot["data"]["action"]["status"] == "completed"
|
||||
assert snapshot["data"][section]["diagram"]["mermaid"] == original["data"][section]["diagram"]["mermaid"]
|
||||
assert original == untouched
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("diagram", [None, {}, {"node_refs": []}])
|
||||
def test_reference_projection_preserves_empty_and_missing_legacy_diagrams(diagram):
|
||||
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||
|
||||
async def check():
|
||||
original = {"section": "progress", "data": {"progress": {"diagram": diagram}, "status": "running"}}
|
||||
async with await AsyncConnection.connect(
|
||||
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||
) as conn:
|
||||
statement, params = read_queries._reference_diagram_projection(
|
||||
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), "progress",
|
||||
)
|
||||
result = await read_queries._snapshot_page(
|
||||
conn, stored_sql=statement, stored_params=params, data_path=[],
|
||||
limit=20, offset=0, mcp_sources=[], full_snapshot=True,
|
||||
)
|
||||
assert result == original
|
||||
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,272 @@
|
||||
"""In-memory task/run fixtures exercising the production repository contract."""
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_task import OperationalTaskSummary, OperationalTaskRun
|
||||
|
||||
def _context(user_id=None, *, system_role="user"):
|
||||
return ProjectContext(
|
||||
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
||||
project_role="member", system_role=system_role,
|
||||
)
|
||||
|
||||
def _task_document(context, now, *, lease=None):
|
||||
task_id = uuid4()
|
||||
return OperationalTaskSummary(
|
||||
execution_scope="production",
|
||||
project_id=context.project_id,
|
||||
task_id=task_id,
|
||||
task_type="event_investigation",
|
||||
workflow="water_quality_complaint",
|
||||
status="running",
|
||||
priority="normal",
|
||||
revision=2,
|
||||
execution_generation=1,
|
||||
execution_request_id=uuid4(),
|
||||
title="调查",
|
||||
summary="验证状态机",
|
||||
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
||||
scheduled_for=None,
|
||||
agent_session_id="session-1",
|
||||
agent_lease_expires_at=lease,
|
||||
current_plan_revision=None,
|
||||
current_report_revision=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
).model_dump(mode="json")
|
||||
|
||||
def _active_run(task, now):
|
||||
return OperationalTaskRun(
|
||||
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
||||
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
||||
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
||||
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
||||
next_dispatch_at=now, created_at=now, updated_at=now,
|
||||
).model_dump(mode="json")
|
||||
|
||||
class TaskRepository:
|
||||
def __init__(self, task, run=None, *, action=None):
|
||||
self.task = task
|
||||
self.action = action
|
||||
self.requests = {}
|
||||
self.evidence = {}
|
||||
self.feedback = {}
|
||||
self.plans = []
|
||||
self.reports = []
|
||||
self.progress = {}
|
||||
self.scada_presentations = {}
|
||||
self.notifications = []
|
||||
self.locks = []
|
||||
self.access_requests = {}
|
||||
self.events = []
|
||||
self.questions = {}
|
||||
now = datetime.fromisoformat(task["created_at"])
|
||||
seeded = _active_run(task, now) if run is None else run
|
||||
if run is None:
|
||||
seeded.update(
|
||||
status=task["status"] if task["status"] in {"queued", "running"} else "succeeded",
|
||||
agent_session_id=task.get("agent_session_id"),
|
||||
lease_expires_at=task.get("agent_lease_expires_at"),
|
||||
)
|
||||
self.run = seeded
|
||||
self.runs = {seeded["run_id"]: seeded}
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield self
|
||||
|
||||
async def lock(self, key):
|
||||
self.locks.append(key)
|
||||
|
||||
async def get_request(self, scope, request_id):
|
||||
return self.requests.get((scope, request_id))
|
||||
|
||||
async def save_request(self, scope, request_id, request_digest, response):
|
||||
self.requests[(scope, request_id)] = {
|
||||
"request_digest": request_digest,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
async def get_task(self, task_id):
|
||||
return self.task if task_id == self.task["task_id"] else None
|
||||
|
||||
async def update_task(self, task):
|
||||
self.task = task
|
||||
|
||||
async def get_latest_plan(self, _task_id):
|
||||
return self.plans[-1] if self.plans else None
|
||||
|
||||
async def get_latest_report(self, _task_id):
|
||||
return self.reports[-1] if self.reports else None
|
||||
|
||||
async def get_latest_progress(self, task_id, generation):
|
||||
return self.progress.get((task_id, generation))
|
||||
|
||||
async def get_scada_presentation(self, task_id, run_id):
|
||||
return deepcopy(self.scada_presentations.get((task_id, run_id)))
|
||||
|
||||
async def save_scada_presentation(self, task_id, run_id, document, now):
|
||||
self.scada_presentations[(task_id, run_id)] = deepcopy(document)
|
||||
|
||||
async def get_latest_graph_progress(self, task_id):
|
||||
snapshots = [value for (stored_id, _), value in self.progress.items()
|
||||
if stored_id == task_id
|
||||
and (value["progress"].get("diagram") or {}).get("edges") is not None]
|
||||
return max(snapshots, key=lambda row: (row["execution_generation"], row["revision"])) if snapshots else None
|
||||
|
||||
async def get_latest_diagram(self, task_id):
|
||||
diagrams = [(row["published_at"], row["progress"].get("diagram"))
|
||||
for (stored_id, _), row in self.progress.items() if stored_id == task_id]
|
||||
diagrams.extend((row["published_at"], row["report"].get("diagram")) for row in self.reports)
|
||||
return next((diagram for _, diagram in sorted(diagrams, key=lambda item: item[0], reverse=True)
|
||||
if diagram is not None), None)
|
||||
|
||||
async def save_progress(self, task_id, snapshot):
|
||||
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
||||
|
||||
async def get_report(self, _task_id, revision):
|
||||
return next((item for item in self.reports if item["revision"] == revision), None)
|
||||
|
||||
async def list_reports(self, _task_id, limit, offset):
|
||||
return list(reversed(self.reports))[offset:offset + limit]
|
||||
|
||||
async def insert_plan(self, _task_id, snapshot):
|
||||
self.plans.append(snapshot)
|
||||
|
||||
async def insert_report(self, _task_id, snapshot):
|
||||
self.reports.append(snapshot)
|
||||
|
||||
async def list_evidence(self, _task_id):
|
||||
return list(self.evidence.values())
|
||||
|
||||
async def get_evidence(self, _task_id, evidence_id):
|
||||
return self.evidence.get(evidence_id)
|
||||
|
||||
async def insert_evidence(self, _task_id, evidence):
|
||||
self.evidence[evidence["evidence_id"]] = evidence
|
||||
|
||||
async def list_access_requests(self, _task_id):
|
||||
return list(self.access_requests.values())
|
||||
|
||||
async def get_access_request(self, _task_id, access_request_id):
|
||||
return self.access_requests.get(access_request_id)
|
||||
|
||||
async def insert_access_request(self, _task_id, document):
|
||||
self.access_requests[document["access_request_id"]] = document
|
||||
|
||||
async def update_access_request(self, _task_id, document, expected_version):
|
||||
assert document["version"] == expected_version + 1
|
||||
self.access_requests[document["access_request_id"]] = document
|
||||
|
||||
async def list_actions(self, _task_id):
|
||||
return [self.action] if self.action else []
|
||||
|
||||
async def get_action(self, _task_id, action_id):
|
||||
if self.action and self.action["action_id"] == action_id:
|
||||
return self.action
|
||||
return None
|
||||
|
||||
async def update_action(self, _task_id, action):
|
||||
self.action = action
|
||||
|
||||
async def insert_action(self, _task_id, action):
|
||||
self.action = action
|
||||
|
||||
async def insert_notification(self, notification):
|
||||
self.notifications.append(notification)
|
||||
|
||||
async def resolve_notifications(
|
||||
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
||||
):
|
||||
resolved = []
|
||||
for notification in self.notifications:
|
||||
if (
|
||||
notification["task_id"] == task_id
|
||||
and notification["kind"] in kinds
|
||||
and notification.get("lifecycle_status", "active") == "active"
|
||||
and notification.get("subject_type") == subject_type
|
||||
and notification.get("subject_id") == subject_id
|
||||
):
|
||||
notification.update(
|
||||
lifecycle_status="resolved",
|
||||
resolved_at=resolved_at.isoformat(),
|
||||
resolution_reason=resolution_reason,
|
||||
)
|
||||
resolved.append(notification.copy())
|
||||
return resolved
|
||||
|
||||
async def get_feedback(self, task_id, source_event_id):
|
||||
return self.feedback.get((task_id, source_event_id))
|
||||
|
||||
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
||||
self.feedback[(task_id, feedback["source_event_id"])] = {
|
||||
"source_revision": feedback["source_revision"],
|
||||
"document": feedback,
|
||||
"accepted_at": accepted_at,
|
||||
}
|
||||
|
||||
async def get_active_run(self, task_id):
|
||||
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True)
|
||||
if run["task_id"] == task_id and run["status"] in {"queued", "claimed", "running"}), None)
|
||||
|
||||
async def get_latest_run(self, task_id):
|
||||
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True) if run["task_id"] == task_id), None)
|
||||
|
||||
async def get_run(self, run_id):
|
||||
return self.runs.get(run_id)
|
||||
|
||||
async def require_execution_storage(self):
|
||||
return None
|
||||
|
||||
async def update_run(self, run, expected_revision):
|
||||
assert run["revision"] == expected_revision + 1
|
||||
self.runs[run["run_id"]] = run
|
||||
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||
|
||||
async def insert_run(self, run):
|
||||
self.runs[run["run_id"]] = run
|
||||
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||
|
||||
async def append_event(self, **event):
|
||||
self.events.append(event)
|
||||
return len(self.events)
|
||||
|
||||
async def current_event_cursor(self):
|
||||
return len(self.events)
|
||||
|
||||
async def list_presentation_events(self, task_id, after, through, limit):
|
||||
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
||||
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
||||
and event["aggregate_id"] == task_id][:limit]
|
||||
async def access_requests_available(self):
|
||||
return True
|
||||
|
||||
async def fail_run_messages(self, run_id, reason, now):
|
||||
return None
|
||||
|
||||
async def try_lock(self, key):
|
||||
await self.lock(key)
|
||||
return True
|
||||
|
||||
async def pending_calculation_jobs(self, task_id, run_id, execution_request_id):
|
||||
return []
|
||||
|
||||
async def pending_run_messages(self, run_id):
|
||||
return []
|
||||
|
||||
async def list_investigation_alerts(self, task_id):
|
||||
return []
|
||||
|
||||
async def list_questions(self, task_id):
|
||||
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
||||
|
||||
async def get_question(self, task_id, question_id):
|
||||
q = self.questions.get(question_id)
|
||||
return deepcopy(q) if q and q["task_id"] == task_id else None
|
||||
|
||||
async def save_question(self, task_id, document):
|
||||
assert document["task_id"] == task_id
|
||||
self.questions[document["question_request_id"]] = deepcopy(document)
|
||||
@@ -44,6 +44,12 @@ class Repository:
|
||||
async def get_task(self, task_id):
|
||||
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,197 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import burst_impact as service
|
||||
from app.services.node_coverage import build_node_coverage
|
||||
|
||||
START = datetime(2026, 7, 16, 5, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def simulation(drops, unit="m"):
|
||||
return {"units": {"pressure": unit}, "times": {"report_start": 0},
|
||||
"node_results": [{"node": f"J{i}", "result": [{"pressure": 30 - drop}]}
|
||||
for i, drop in enumerate(drops)]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inputs(monkeypatch):
|
||||
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": "METERS"})
|
||||
monkeypatch.setattr(service, "get_option_v3", lambda _: {})
|
||||
read = Mock(side_effect=lambda ids, *a, **kw: {
|
||||
node: [{"time": START.isoformat(), "value": 30}] for node in ids})
|
||||
monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read)
|
||||
def coverage(network, groups):
|
||||
ids = sorted({node for group in groups for node in group["node_ids"]})
|
||||
data = build_node_coverage(groups, [
|
||||
{"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9}
|
||||
for i, node in enumerate(ids)], [])
|
||||
payload = data.pop("map_payload")
|
||||
return {"coverage": data, "map_payload": payload}
|
||||
monkeypatch.setattr(service, "generate_node_coverage", coverage)
|
||||
return read
|
||||
|
||||
|
||||
def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([.999, 1, 2.999, 3, 4.999, 5, 8, -1]))
|
||||
impact = result["impact"]
|
||||
assert [group["node_ids"] for group in impact["grades"]] == [["J1", "J2"], ["J3", "J4"], ["J5", "J6"]]
|
||||
assert impact["below_threshold_node_count"] == 2
|
||||
assert impact["compared_node_count"] == 8 and impact["affected_node_count"] == 6
|
||||
assert impact["phase"] == "after_burst_before_intervention"
|
||||
assert impact["baseline_frozen"] is False
|
||||
inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own")
|
||||
assert len(result["map_payload"]["features"]) == 3
|
||||
assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65),
|
||||
("bar", 100000 / 9806.65), ("m", 1)])
|
||||
def test_normalizes_baseline_and_simulation_independently(monkeypatch, inputs, unit, factor):
|
||||
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 / factor}]}
|
||||
inputs.side_effect = None
|
||||
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": unit})
|
||||
result = service.estimate_burst_impact("own", START, simulation([4]))
|
||||
assert result["impact"]["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||
assert result["impact"]["grades"][1]["node_ids"] == ["J0"]
|
||||
|
||||
|
||||
def test_v3_units_take_precedence_over_legacy_settings(monkeypatch, inputs):
|
||||
monkeypatch.setattr(service, "get_option_v3", lambda _: {"PRESSURE_UNITS": "KPA"})
|
||||
inputs.side_effect = None
|
||||
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 * 9.80665}]}
|
||||
impact = service.estimate_burst_impact("own", START, simulation([4]))["impact"]
|
||||
assert impact["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||
assert impact["baseline_units_source"] == "current_v3_model_settings"
|
||||
|
||||
|
||||
def test_missing_nonfinite_duplicate_and_wrong_time_are_unavailable_not_zero(inputs):
|
||||
inputs.side_effect = None
|
||||
inputs.return_value = {
|
||||
"J0": [{"time": (START + timedelta(seconds=1)).isoformat(), "value": 30}],
|
||||
"J1": [{"time": START.isoformat(), "value": float("nan")}],
|
||||
"J2": [{"time": START.isoformat(), "value": 30}] * 2,
|
||||
"J3": [{"time": START.isoformat(), "value": 30}],
|
||||
"J4": [{"time": START.isoformat(), "value": 30}],
|
||||
}
|
||||
native = simulation([1] * 6)
|
||||
native["node_results"][3]["result"] = [{"pressure": float("inf")}]
|
||||
result = service.estimate_burst_impact("own", START, native)
|
||||
assert result["impact"]["status"] == "partial"
|
||||
assert result["impact"]["unavailable_node_ids"] == ["J0", "J1", "J2", "J3", "J5"]
|
||||
assert result["impact"]["compared_node_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("times", [{}, {"report_start": 900}, {"sample_times": ["2026-07-16T05:00:01Z"]}])
|
||||
def test_unknown_or_shifted_first_sample_never_queries_baseline(inputs, times):
|
||||
native = simulation([2])
|
||||
native["times"] = times
|
||||
result = service.estimate_burst_impact("own", START, native)
|
||||
assert result["impact"]["reason"] == "simulation_has_no_verified_start_time_sample"
|
||||
assert "map_payload" not in result
|
||||
inputs.assert_not_called()
|
||||
|
||||
|
||||
def test_unknown_units_do_not_draw_an_impact_area(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([2], "unknown"))
|
||||
assert result["impact"]["reason"] == "unsupported_or_missing_pressure_units"
|
||||
inputs.assert_not_called()
|
||||
|
||||
|
||||
def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs):
|
||||
from app.mcp import scenario_service
|
||||
from app.services import simulation_scenarios
|
||||
native = simulation([4] * 25)
|
||||
run = Mock(return_value=native)
|
||||
monkeypatch.setattr(simulation_scenarios, "burst_analysis", run)
|
||||
result = scenario_service.calculate_scenario("run_burst_simulation", {
|
||||
"output_node_ids": ["J0"], "output_link_ids": [], "start_time": START, "duration": 900,
|
||||
"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], "include_impact_coverage": True,
|
||||
}, network="own", username="user", model_only=True)
|
||||
assert len(result["node_results"]) == 1
|
||||
assert result["impact"]["affected_node_count"] == 25
|
||||
assert result["coverage"]["requested_nodes"] == ["J0"]
|
||||
assert result["persisted_baseline"] is False
|
||||
assert run.call_args.kwargs["persist_results"] is False
|
||||
assert run.call_args.kwargs["burst_ID"] == ["P1"]
|
||||
assert not run.call_args.kwargs["scada_mappings"].reservoirs
|
||||
assert result["impact"]["boundary_source"] == "model_only_no_live_scada"
|
||||
|
||||
|
||||
def test_large_affected_sets_report_full_counts_without_truncating(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([4] * 10001))
|
||||
assert result["impact"]["affected_node_count"] == 10001
|
||||
assert result["impact"]["coverage_status"] == "unavailable"
|
||||
assert "map_payload" not in result
|
||||
|
||||
|
||||
def test_replay_passes_empty_boundary_mappings_to_both_native_runs(monkeypatch):
|
||||
from app.services import simulation_scenarios as scenarios
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
mappings = ScadaElementMappings({}, {}, {}, {}, {}, {}, {})
|
||||
runs = Mock(return_value={"node_results": []})
|
||||
monkeypatch.setattr(scenarios.simulation, "run_simulation", runs)
|
||||
monkeypatch.setattr(scenarios, "get_pipe", lambda *a: {"node1": "A", "node2": "B", "diameter": 100})
|
||||
monkeypatch.setattr(scenarios, "is_junction", lambda *a: True)
|
||||
monkeypatch.setattr(scenarios, "get_emitter", lambda *a: None)
|
||||
monkeypatch.setattr(scenarios, "set_emitter", lambda *a: None)
|
||||
monkeypatch.setattr(scenarios, "get_option", lambda *a: {"UNITS": "LPS"})
|
||||
monkeypatch.setattr(scenarios, "set_option", lambda *a: None)
|
||||
scenarios.burst_analysis.__wrapped__(
|
||||
name="own", modify_pattern_start_time=START.isoformat(), burst_ID=["P1"], burst_size=[2],
|
||||
persist_results=False, _temporary_project="tjw_tmp_own", scada_mappings=mappings,
|
||||
)
|
||||
assert len(runs.call_args_list) == 2
|
||||
assert all(call.kwargs["scada_mappings"] is mappings for call in runs.call_args_list)
|
||||
assert runs.call_args.kwargs["persist_results"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_notification_precedes_durable_impact_and_keeps_run_available(inputs):
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from app.domain.schemas.operational_task import ActionProposalCommand, TaskPresentationEventCommand
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.mcp.models import QueryResult
|
||||
from app.mcp.presentation_preview import saved_presentation_preview
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||
|
||||
context = _context()
|
||||
task = _task_document(context, START, lease=START + timedelta(minutes=1))
|
||||
task["workflow"] = "burst_report"
|
||||
repository = TaskRepository(task, _active_run(task, START))
|
||||
repository.reports.append({"revision": 1, "execution_generation": 1,
|
||||
"report": {"stage": "interim", "outcome": "concluded"}})
|
||||
tasks = OperationalTaskService(repository, context, set(), clock=lambda: START)
|
||||
order = await tasks.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="P1候选管段待现场核验",
|
||||
payload={"title": "P1现场核查", "description": "核查定位管道并反馈", "work_order_type": "general"},
|
||||
))
|
||||
assert repository.task["status"] == "running"
|
||||
assert repository.notifications[-1]["kind"] == "action_approval_pending"
|
||||
assert order.approval_status == "pending" and order.execution_status == "not_submitted"
|
||||
result = QueryResult(
|
||||
source="run_burst_simulation", source_ref="mcp:burst-impact", queried_at=START,
|
||||
semantics="analysis", origin="system_data", truncated=False,
|
||||
data=jsonable_encoder({"result": service.estimate_burst_impact("own", START, simulation([2, 4, 6]))}),
|
||||
)
|
||||
evidence, _ = await tasks.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source=result.source, source_ref=result.source_ref, queried_at=START,
|
||||
origin="system_data", truncated=False, data=jsonable_encoder(result),
|
||||
))
|
||||
result.evidence_id = evidence.evidence_id
|
||||
hint = saved_presentation_preview(result)
|
||||
await tasks.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||
command=hint["command"],
|
||||
))
|
||||
recorded = deepcopy(repository.events[-1]["metadata"]["command"])
|
||||
assert recorded["data"] == jsonable_encoder(result.data["result"]["map_payload"])
|
||||
assert len(recorded["data"]["features"]) == 3
|
||||
# Saved presentation remains complete even if the live query result changes.
|
||||
result.data["result"]["map_payload"]["features"].clear()
|
||||
assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3
|
||||
assert repository.run["status"] == "running"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Generated fixtures keep executable examples and publication semantics aligned across repositories."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
|
||||
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parents[1] / "fixtures"
|
||||
CASES = json.loads((FIXTURES / "chart-publication-cases.json").read_text())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"])
|
||||
def test_chart_publication_matches_frontend_and_agent(case):
|
||||
def validate():
|
||||
chart = TaskSafeChart.model_validate(case["chart"]).model_dump(mode="json")
|
||||
validate_time_comparison_chart(chart)
|
||||
|
||||
if case["accepted"]:
|
||||
validate()
|
||||
else:
|
||||
with pytest.raises((DomainError, ValidationError)):
|
||||
validate()
|
||||
|
||||
|
||||
def test_historical_mixed_charts_remain_structurally_readable():
|
||||
chart = next(case["chart"] for case in CASES if case["name"] == "old mixed charts are read only")
|
||||
TaskSafeChart.model_validate(chart)
|
||||
|
||||
|
||||
def test_exported_command_examples_use_the_current_publication_contract():
|
||||
for line in (FIXTURES / "unified-presentation.jsonl").read_text().splitlines():
|
||||
record = json.loads(line)
|
||||
if record["record_type"] != "presentation":
|
||||
continue
|
||||
event = TaskPresentationEventCommand.model_validate({
|
||||
"request_id": "95000000-0000-4000-8000-000000000001",
|
||||
"run_id": "95000000-0000-4000-8000-000000000002",
|
||||
"source_session_id": "synthetic-chart-contract",
|
||||
"command": record["command"],
|
||||
})
|
||||
command = event.command
|
||||
if command.action == "open_chart":
|
||||
validate_time_comparison_chart(command.chart.model_dump(mode="json"))
|
||||
@@ -82,6 +82,7 @@ def test_all_scada_replay_reads_only_frozen_devices_batches_and_window(monkeypat
|
||||
assert data["coverage"]["queried_device_ids"] == ["P1", "Q1"]
|
||||
assert data["coverage"]["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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import contaminant_impact as impact
|
||||
from app.services.node_coverage import build_node_coverage
|
||||
|
||||
|
||||
START = datetime(2026, 7, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def simulation(unit="MGL"):
|
||||
return {"units": {"quality": unit}, "times": {"report_start": 0, "report_step": 900, "num_periods": 3},
|
||||
"node_results": [{"node": node, "result": [{"quality": value} for value in values]}
|
||||
for node, values in {"source": [1, 1, 1], "late": [0, 0.2, 0.5], "clean": [0, 0, 0]}.items()]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def geometry(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def generate(network, groups):
|
||||
calls.append((network, deepcopy(groups)))
|
||||
coverage = build_node_coverage(groups, [{"id": "source", "longitude": 121.4, "latitude": 31.1}], [])
|
||||
payload = coverage.pop("map_payload")
|
||||
return {"coverage": coverage, "map_payload": payload}
|
||||
|
||||
monkeypatch.setattr(impact, "generate_node_coverage", generate)
|
||||
return calls
|
||||
|
||||
|
||||
def test_impact_uses_whole_prediction_window_and_real_concentration_units(geometry):
|
||||
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(), threshold_mg_l=0.2, model_only=True)
|
||||
data = result["impact"]
|
||||
assert data["affected_node_ids"] == ["source", "late"]
|
||||
late = next(row for row in data["nodes"] if row["node_id"] == "late")
|
||||
assert late["first_exceedance_sample_at"] == (START + timedelta(minutes=30)).isoformat()
|
||||
assert late["peak_mg_l"] == 0.5 and late["peak_at"] == late["first_exceedance_sample_at"]
|
||||
assert data["node_count"] == 3 and data["status"] == "partial" # Late node has no GIS position.
|
||||
assert data["boundary_source"] == "model_only_no_live_scada"
|
||||
assert data["threshold_purpose"] == "scenario_display_not_drinking_water_standard"
|
||||
assert geometry[0][1][0]["node_ids"] == ["source", "late"]
|
||||
assert result["coverage"]["groups"]["contaminant_exposure"]["missing_node_ids"] == ["late"]
|
||||
assert result["map_payload"]["features"][0]["properties"]["impact_phase"] == "after_contamination_before_intervention"
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.services.operational_presentation import resolve_overlay
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
snapshot = TaskDataSnapshot(source="run_contaminant_simulation", source_ref="calculation:propagation",
|
||||
queried_at=START, origin="synthetic", truncated=False, data=jsonable_encoder({"result": result}))
|
||||
saved = resolve_overlay(snapshot, "geometry")
|
||||
assert saved["features"][0]["properties"]["model_estimate"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["UGL", "ug/L", "µg/L"])
|
||||
def test_micrograms_are_converted_before_threshold_comparison(geometry, unit):
|
||||
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(unit), threshold_mg_l=0.0002)
|
||||
assert result["impact"]["nodes"][1]["peak_mg_l"] == 0.0005
|
||||
assert result["impact"]["affected_node_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problem", ["unit", "times", "duplicate", "too_many_samples", "no_values"])
|
||||
def test_invalid_results_do_not_turn_into_an_empty_safe_range(geometry, problem):
|
||||
data = simulation()
|
||||
if problem == "unit": data["units"]["quality"] = "HOURS"
|
||||
if problem == "times": data["times"] = {"report_step": 0}
|
||||
if problem == "duplicate": data["node_results"].append(data["node_results"][0])
|
||||
if problem == "too_many_samples": data["node_results"][0]["result"].append({"quality": 2})
|
||||
if problem == "no_values":
|
||||
for row in data["node_results"]: row["result"] = [{"quality": float("nan")}] * 3
|
||||
result = impact.estimate_contaminant_impact("project", START, 1800, data)
|
||||
assert result["impact"]["status"] == "unavailable"
|
||||
assert "reason" in result["impact"] and "map_payload" not in result
|
||||
assert not geometry
|
||||
|
||||
|
||||
def test_missing_samples_remain_explicit_and_do_not_replace_first_observation(geometry):
|
||||
data = simulation()
|
||||
data["node_results"][1]["result"][0] = {"quality": None}
|
||||
result = impact.estimate_contaminant_impact("project", START, 1800, data)
|
||||
assert result["impact"]["incomplete_node_ids"] == ["late"]
|
||||
assert result["impact"]["nodes"][1]["complete"] is False
|
||||
|
||||
|
||||
def test_geometry_limit_retains_the_numeric_prediction(monkeypatch):
|
||||
monkeypatch.setattr(impact, "MAX_COVERAGE_NODES", 1)
|
||||
result = impact.estimate_contaminant_impact("project", START, 1800, simulation())
|
||||
assert result["impact"]["affected_node_count"] == 2
|
||||
assert result["impact"]["coverage_status"] == "unavailable"
|
||||
assert "map_payload" not in result
|
||||
|
||||
|
||||
def test_adapter_estimates_all_native_nodes_before_selecting_display_series(monkeypatch):
|
||||
from app.mcp.analysis_models import ContaminantSimulationInput
|
||||
from app.mcp.scenario_service import calculate_scenario
|
||||
from app.services import simulation_scenarios
|
||||
|
||||
native = simulation()
|
||||
calls = []
|
||||
monkeypatch.setattr(simulation_scenarios, "contaminant_simulation", lambda **kw: calls.append(kw) or native)
|
||||
seen = []
|
||||
monkeypatch.setattr(impact, "estimate_contaminant_impact",
|
||||
lambda network, start, duration, result, **kw: seen.append((network, start, duration, result, kw))
|
||||
or {"impact": {"affected_node_count": 2}})
|
||||
args = ContaminantSimulationInput(context_handle="a" * 43, start_time=START, duration=1800,
|
||||
output_node_ids=["source"], source="source", concentration=1,
|
||||
include_impact_coverage=True, impact_threshold_mg_l=0.2)
|
||||
result = calculate_scenario("run_contaminant_simulation",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}), network="project", username="actor", model_only=True)
|
||||
assert len(result["node_results"]) == 1 and len(seen[0][3]["node_results"]) == 3
|
||||
assert seen[0][4] == {"threshold_mg_l": 0.2, "model_only": True}
|
||||
assert result["impact"]["affected_node_count"] == 2
|
||||
assert calls[0]["scada_mappings"].reservoirs == {} and calls[0]["scada_mappings"].quality == {}
|
||||
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
|
||||
|
||||
|
||||
def test_native_contaminant_service_passes_empty_mappings_to_the_engine(monkeypatch):
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
from app.services import simulation_scenarios as native
|
||||
|
||||
for name in ("set_time", "set_option", "add_source", "set_demand"):
|
||||
monkeypatch.setattr(native, name, lambda *args: None)
|
||||
monkeypatch.setattr(native, "get_time", lambda *_: {"HYDRAULIC TIMESTEP": "0:15:00"})
|
||||
monkeypatch.setattr(native, "get_pattern", lambda *_: {"id": "pattern"})
|
||||
monkeypatch.setattr(native, "get_source", lambda *_: {})
|
||||
monkeypatch.setattr(native, "get_demand", lambda *_: {"demands": []})
|
||||
monkeypatch.setattr(native, "get_option", lambda *_: {})
|
||||
calls = []
|
||||
monkeypatch.setattr(native.simulation, "run_simulation", lambda **kw: calls.append(kw) or simulation())
|
||||
mappings = ScadaElementMappings(reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, pressure={}, demand={}, quality={})
|
||||
native.contaminant_simulation.__wrapped__(name="project", modify_pattern_start_time=START.isoformat(),
|
||||
modify_total_duration=1800, source="source", concentration=1, source_pattern="pattern",
|
||||
username="actor", _temporary_project="temporary", persist_results=False, scada_mappings=mappings)
|
||||
assert calls[0]["scada_mappings"] is mappings
|
||||
assert calls[0]["name"] == "temporary" and calls[0]["persist_results"] is False
|
||||
@@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.mcp import analysis_service, query_service
|
||||
from app.mcp.analysis_models import ContaminantSimulationInput
|
||||
from app.mcp.calculation_jobs import should_queue
|
||||
from app.mcp.catalog import TOOLS
|
||||
|
||||
|
||||
def args(**extra):
|
||||
return ContaminantSimulationInput(context_handle="a" * 43, **({
|
||||
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
||||
"output_node_ids": ["Q1"], "source": "Q1", "concentration": 1,
|
||||
"include_impact_coverage": True, "impact_threshold_mg_l": 0.01,
|
||||
} | extra))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")},
|
||||
{"include_impact_coverage": "true"}])
|
||||
def test_impact_inputs_reject_invalid_thresholds_and_flags(extra):
|
||||
with pytest.raises(ValidationError): args(**extra)
|
||||
|
||||
|
||||
def context(monkeypatch):
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
|
||||
@asynccontextmanager
|
||||
async def routing():
|
||||
yield
|
||||
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
|
||||
project=SimpleNamespace(project_code="project", project_role="member"), user=SimpleNamespace(username="actor"),
|
||||
simulation_routing=routing)
|
||||
binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], permission_cap=["simulation.run", "scada.view"],
|
||||
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
|
||||
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||
return scope, binding
|
||||
|
||||
|
||||
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch):
|
||||
scope, binding = context(monkeypatch)
|
||||
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
|
||||
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
|
||||
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args()))
|
||||
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
|
||||
assert "预测" in result.data_gaps[0]
|
||||
scope.connection.commit.assert_awaited_once()
|
||||
worker.assert_awaited_once()
|
||||
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
|
||||
assert should_queue(TOOLS["run_contaminant_simulation"], args())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"])
|
||||
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
|
||||
scope, binding = context(monkeypatch)
|
||||
parameters = args()
|
||||
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
|
||||
if problem == "no_impact": parameters = args(include_impact_coverage=False)
|
||||
if problem == "reader": binding.purpose = "task_reader"
|
||||
if problem == "permission": binding.permission_cap.remove("scada.view")
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
|
||||
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403)
|
||||
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.mcp import query_service
|
||||
from app.mcp.analysis_models import ContaminantSourceTraceInput
|
||||
from app.mcp.calculation_jobs import should_queue
|
||||
from app.mcp.catalog import TOOLS
|
||||
from app.services import contaminant_source_trace as trace
|
||||
|
||||
|
||||
def arguments(**extra):
|
||||
return ContaminantSourceTraceInput(context_handle="a" * 43,
|
||||
**({"candidate_node_ids": ["J2", "J1", "missing"],
|
||||
"start": "2026-07-16T08:00:00+08:00", "end": "2026-07-16T08:15:00+08:00"} | extra))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nodes", [[{"id": "J2"}, {"id": "J1"}], [{"id": "J1"}, {"id": "J2"}]])
|
||||
def test_trace_is_immediate_project_scoped_stable_and_explicit_about_algorithm(monkeypatch, nodes):
|
||||
read = AsyncMock(return_value=nodes)
|
||||
monkeypatch.setattr(trace, "read_rows", read)
|
||||
connection = object()
|
||||
args = arguments()
|
||||
result = asyncio.run(trace.trace_contaminant_source(connection, args))
|
||||
assert result["located_node"] == "J1"
|
||||
assert result["missing_node_ids"] == ["missing"]
|
||||
assert result["top_candidates"] == [{"node_id": "J1"}]
|
||||
assert result["algorithm_status"] == "provisional"
|
||||
assert result["selection_basis"] == "lexicographically_first_existing_candidate_node"
|
||||
assert "相似度" not in result and "confidence" not in result
|
||||
assert read.await_args.args[0] is connection
|
||||
assert "network.nodes" in read.await_args.args[1] and "ANY(%s)" in read.await_args.args[1]
|
||||
assert read.await_args.args[2] == (args.candidate_node_ids,)
|
||||
assert not should_queue(TOOLS["run_contaminant_source_trace"], args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"candidate_node_ids": []}, {"candidate_node_ids": ["J1", "J1"]},
|
||||
{"candidate_node_ids": [str(index) for index in range(21)]},
|
||||
{"start": "2026-07-16T08:00:00"}, {"end": "2026-07-16T08:00:00+08:00"},
|
||||
{"end": "2026-07-18T08:00:00+08:00"}])
|
||||
def test_trace_rejects_unbounded_or_ambiguous_inputs(change):
|
||||
with pytest.raises(ValidationError):
|
||||
arguments(**change)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nodes", [[], [{"id": "J1"}]])
|
||||
def test_dispatch_preserves_provenance_and_reports_only_actual_node_gaps(monkeypatch, nodes):
|
||||
monkeypatch.setattr(trace, "read_rows", AsyncMock(return_value=nodes))
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
||||
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_role="member"))
|
||||
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
||||
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], replay_context=None)
|
||||
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
||||
assert result.origin == "synthetic" and result.semantics == "analysis"
|
||||
assert (result.data["result"]["located_node"] is None) == (not nodes)
|
||||
assert bool(result.data_gaps) == (not nodes)
|
||||
assert "context_handle" not in result.data["parameters"]
|
||||
|
||||
|
||||
def test_replay_trace_cannot_escape_frozen_window(monkeypatch):
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
||||
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[],
|
||||
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
||||
assert caught.value.status_code == 422
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.mcp.analysis_models import FlushingInput
|
||||
from app.mcp.extended_models import SavedScenarioInput
|
||||
from app.mcp.mutation_service import saved_scenario
|
||||
from app.mcp.scenario_service import calculate_scenario, flushing_valve_controls
|
||||
|
||||
|
||||
def parameters(**changes):
|
||||
return {"context_handle": "a" * 43, "start_time": datetime(2026, 7, 16, tzinfo=timezone.utc),
|
||||
"duration": 1800, "output_node_ids": ["J1"], "drainage_node_id": "J2",
|
||||
"flushing_flow_m3h": 12, **changes}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"closed_valves": ["V1"], "opened_valves": ["V1"]},
|
||||
{"closed_valves": ["V1", "V1"]},
|
||||
{"opened_valves": ["V1", "V1"]},
|
||||
])
|
||||
def test_conflicting_or_duplicate_valves_are_rejected_for_preview_and_saved_scenarios(changes):
|
||||
values = parameters(**changes)
|
||||
with pytest.raises(ValidationError):
|
||||
FlushingInput.model_validate(values)
|
||||
with pytest.raises(ValidationError):
|
||||
SavedScenarioInput.model_validate({"context_handle": values.pop("context_handle"),
|
||||
"request_id": uuid4(), "name": "冲洗", "scenario": "flushing", "parameters": values})
|
||||
|
||||
|
||||
def test_preview_and_saved_flushing_apply_identical_open_and_closed_states(monkeypatch):
|
||||
from app.services import simulation_scenarios
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(simulation_scenarios, "flushing_analysis", lambda **kw: calls.append(kw) or {
|
||||
"node_results": [{"node": "J1", "result": []}], "link_results": []})
|
||||
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"})
|
||||
args = FlushingInput.model_validate(parameters(opened_valves=["V-in"], closed_valves=["V-user"]))
|
||||
output = calculate_scenario("analyze_flushing", args.model_dump(exclude={"context_handle", "timeout_seconds"}),
|
||||
network="owned", username="actor")
|
||||
values = SavedScenarioInput.model_validate({"context_handle": args.context_handle,
|
||||
"request_id": uuid4(), "name": "分区冲洗", "scenario": "flushing",
|
||||
"parameters": args.model_dump(exclude={"context_handle", "timeout_seconds"})})
|
||||
saved_scenario("owned", "actor", values)
|
||||
for call in calls:
|
||||
assert call["valve_control"] == {"V-in": {"status": "OPEN"}, "V-user": {"status": "CLOSED"}}
|
||||
assert "modify_valve_opening" not in call
|
||||
assert call["drainage_node_ID"] == "J2" and call["flushing_flow"] == 12
|
||||
assert calls[0]["persist_results"] is False and calls[1]["persist_results"] is True
|
||||
assert output["flushing_plan"] == {
|
||||
"opened_valves": ["V-in"], "closed_valves": ["V-user"], "drainage_node_id": "J2",
|
||||
"flushing_flow_m3h": 12, "estimated_discharge_volume_m3": 6,
|
||||
"state_basis": "independent_current_model_scenario", "result_scope": "hydraulic_only",
|
||||
"boundary_source": "model_with_start_time_scada",
|
||||
"field_execution_verified": False, "water_quality_clearance_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_closed_only_inputs_remain_valid():
|
||||
args = FlushingInput.model_validate(parameters(closed_valves=["V1"]))
|
||||
assert args.opened_valves == []
|
||||
assert flushing_valve_controls(args.model_dump()) == {"V1": {"status": "CLOSED"}}
|
||||
|
||||
|
||||
def test_explicit_opening_reopens_a_closed_valve_without_overwriting_its_setting(monkeypatch):
|
||||
from app.services import simulation
|
||||
|
||||
model = {"V-in": {"link": "V-in", "status": "CLOSED", "setting": 15},
|
||||
"V-user": {"link": "V-user", "status": "ACTIVE", "setting": 23}}
|
||||
monkeypatch.setattr(simulation, "get_status", lambda project, valve: dict(model[valve]))
|
||||
monkeypatch.setattr(simulation, "set_status", lambda project, changes: model.update({
|
||||
row["link"]: row for row in changes.operations}))
|
||||
simulation._apply_valve_control("temporary", flushing_valve_controls({
|
||||
"opened_valves": ["V-in"], "closed_valves": ["V-user"]}))
|
||||
assert model["V-in"] == {"link": "V-in", "status": "OPEN", "setting": 15}
|
||||
assert model["V-user"] == {"link": "V-user", "status": "CLOSED", "setting": 23}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
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_existing_waiting_node_accepts_a_status_only_update_without_extending_the_graph():
|
||||
task, repo, service = setup_service()
|
||||
first = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "read", "label": "读取监测", "status": "recorded"},
|
||||
{"node_id": "review", "label": "核对补充资料", "status": "needs_input", "detail": "等待现场记录"},
|
||||
], edges=[{"source": "read", "target": "review"}])
|
||||
original = first.task.latest_progress.progress.diagram
|
||||
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"], "report": {
|
||||
"title": "阶段报告", "current_conclusion": "仍需核对现场资料", "conclusion_boundary": "现有资料范围",
|
||||
"stage": "interim", "outcome": "concluded", "data_gaps": [],
|
||||
},
|
||||
}))
|
||||
result = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "review", "status": "recorded", "detail": "已核对收到的现场记录"},
|
||||
])
|
||||
updated = result.task.latest_progress.progress.diagram
|
||||
assert [node.node_id for node in updated.node_refs] == ["read", "review"]
|
||||
assert updated.node_refs[1].label == "核对补充资料"
|
||||
assert updated.node_refs[1].status == "recorded"
|
||||
assert updated.node_refs[1].detail == "已核对收到的现场记录"
|
||||
assert updated.edges == original.edges
|
||||
assert updated.mermaid == original.mermaid
|
||||
assert original.node_refs[1].status == "needs_input"
|
||||
archived = (await service.report(task["task_id"], 1)).report.diagram
|
||||
assert archived.node_refs[1].status == "needs_input"
|
||||
assert archived.node_refs[1].detail == "等待现场记录"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label_patch", [{}, {"label": None}])
|
||||
def test_partial_updates_keep_labels_and_accumulated_references_including_legacy_nodes(label_patch):
|
||||
evidence_id = "saved-reading"
|
||||
action_id = uuid4()
|
||||
original = InvestigationDiagram.model_validate({
|
||||
"title": "旧调查图", "mermaid": "flowchart LR\nold{核对资料} --> next[继续调查]",
|
||||
"node_refs": [{"node_id": "old", "label": "核对资料", "status": "waiting_human",
|
||||
"detail": "等待资料", "evidence_refs": [evidence_id], "action_refs": [action_id],
|
||||
"map_targets": [{"source_id": "scada", "feature_id": "P1"}]}],
|
||||
})
|
||||
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "old", "status": "recorded", **label_patch}],
|
||||
}))
|
||||
assert updated.mermaid == updated.source_mermaid == original.mermaid
|
||||
assert updated.node_refs[0] == original.node_refs[0].model_copy(update={"status": "recorded"})
|
||||
assert original.node_refs[0].status == "needs_input"
|
||||
|
||||
|
||||
def test_status_only_updates_cannot_create_an_unlabelled_node_or_rewrite_a_failed_attempt():
|
||||
with pytest.raises(DomainError) as error:
|
||||
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "missing", "status": "recorded"}],
|
||||
}))
|
||||
assert error.value.code == "GRAPH_NODE_LABEL_REQUIRED"
|
||||
failed = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "attempt", "label": "失败尝试", "status": "failed", "detail": "接口超时"}],
|
||||
}))
|
||||
enriched = update_investigation_graph(failed, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "attempt", "evidence_refs": ["timeout-record"]}],
|
||||
}))
|
||||
assert enriched.node_refs[0].label == "失败尝试"
|
||||
assert enriched.node_refs[0].detail == "接口超时"
|
||||
assert enriched.node_refs[0].status == "failed"
|
||||
assert enriched.node_refs[0].evidence_refs == ["timeout-record"]
|
||||
with pytest.raises(DomainError) as error:
|
||||
update_investigation_graph(enriched, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "attempt", "status": "recorded"}],
|
||||
}))
|
||||
assert error.value.code == "GRAPH_FAILED_ATTEMPT_IMMUTABLE"
|
||||
|
||||
|
||||
@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}]})
|
||||
@@ -0,0 +1,96 @@
|
||||
"""New publications are readable; historical reports keep their original evidence."""
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.schemas.operational_task import (
|
||||
InvestigationProgress,
|
||||
InvestigationProgressInput,
|
||||
InvestigationReport,
|
||||
InvestigationReportInput,
|
||||
)
|
||||
|
||||
|
||||
def report():
|
||||
return {
|
||||
"title": "压力异常恢复复核报告",
|
||||
"current_conclusion": "恢复复核通过。7 月 16 日 13:15,13 台复核设备均处于历史正常范围。",
|
||||
"conclusion_boundary": "依据完工后的一个采样时刻判断恢复,长期稳定性继续由日常监测跟踪。",
|
||||
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||
"evidence_refs": ["recovery:sha256:stored-evidence"],
|
||||
"recommendations": ["继续监测压力与流量。"], "pending_items": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"恢复复核 status=passed,13 台设备 normal。",
|
||||
"第 3 轮 execution_generation=3,handling_context.phase=verification_ready。",
|
||||
"完工后 required_samples=1,interval_seconds=900,verified_through=13:15。",
|
||||
"已读取 recovery:sha256:01234567,复核通过。",
|
||||
"Skill scada-anomaly v1.0.16 已按 Server 分发执行。",
|
||||
"query_scada_readings 已执行,query_complete=true。",
|
||||
"关联工单 60000000-0000-4000-8000-000000000002 已完工。",
|
||||
"恢复采样时间为 2026-07-16T05:15:00Z。",
|
||||
"恢复采样时间为 2026-07-16 13:15(UTC+8)。",
|
||||
"恢复采样时间为 2026-07-16 13:15+08:00。",
|
||||
"**恢复复核通过**,其余设备正常。",
|
||||
"<strong>恢复复核通过</strong>。",
|
||||
])
|
||||
def test_new_report_and_progress_reject_execution_details_and_raw_display_markup(text):
|
||||
legacy = {**report(), "current_conclusion": text}
|
||||
assert InvestigationReport.model_validate(legacy).current_conclusion == text
|
||||
assert InvestigationProgress.model_validate({"summary": text}).summary == text
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(legacy)
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationProgressInput.model_validate({"summary": text})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "limit"), [
|
||||
("title", 80), ("current_conclusion", 300), ("conclusion_boundary", 240),
|
||||
])
|
||||
def test_new_report_bounds_summary_fields_without_truncating_history(field, limit):
|
||||
valid = {**report(), field: "测" * limit}
|
||||
assert getattr(InvestigationReportInput.model_validate(valid), field) == valid[field]
|
||||
too_long = {**valid, field: "测" * (limit + 1)}
|
||||
assert getattr(InvestigationReport.model_validate(too_long), field) == too_long[field]
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(too_long)
|
||||
|
||||
|
||||
def test_progress_summary_is_short_and_keeps_technical_references_structured():
|
||||
progress = {"summary": "测" * 300, "graph_update": {"nodes": [{
|
||||
"node_id": "recovery", "label": "恢复监测复核通过", "status": "recorded",
|
||||
"evidence_refs": ["recovery:sha256:stored-evidence"],
|
||||
}]}}
|
||||
assert InvestigationProgressInput.model_validate(progress).summary == progress["summary"]
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationProgressInput.model_validate({**progress, "summary": "测" * 301})
|
||||
|
||||
|
||||
def test_asset_ids_measurements_and_evidence_linked_tables_remain_publishable():
|
||||
value = report()
|
||||
value["current_conclusion"] = "05:00-08:00 的候选管段 515870(DN600)相似度 97.08%,尚待现场核验。压力测点 170486 回升至 26.12 m。"
|
||||
value["ui_blocks"] = [{"evidence_refs": value["evidence_refs"], "block": {
|
||||
"version": "answer-ui@1", "block_id": "recovery-table", "phase": "final",
|
||||
"fallback_text": "压力测点 170486 恢复至 26.12 m。",
|
||||
"spec": {"root": "table", "elements": {"table": {
|
||||
"type": "Table", "children": [], "props": {
|
||||
"title": "恢复监测明细", "columns": ["测点", "修复后", "正常范围"],
|
||||
"rows": [["170486", "26.12 m", "26.02 至 26.30 m"]],
|
||||
},
|
||||
}}},
|
||||
}}]
|
||||
assert InvestigationReportInput.model_validate(value).ui_blocks
|
||||
leaked = deepcopy(value)
|
||||
leaked["ui_blocks"][0]["block"]["spec"]["elements"]["table"]["props"]["rows"][0][2] = "status=passed"
|
||||
assert InvestigationReport.model_validate(leaked).ui_blocks
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(leaked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["recommendations", "pending_items"])
|
||||
def test_follow_up_is_business_prose(field):
|
||||
value = {**report(), field: ["等待 handling_context.phase=waiting_scada。"]}
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(value)
|
||||
@@ -26,6 +26,7 @@ HANDLE = "x" * 43
|
||||
START = "2026-10-08T12:00:00+08:00"
|
||||
END = "2026-10-08T13:00:00+08:00"
|
||||
INPUTS = {
|
||||
"run_contaminant_source_trace": {"candidate_node_ids": ["J1"], "start": START, "end": END},
|
||||
"run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]},
|
||||
"run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0},
|
||||
"analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]},
|
||||
|
||||
@@ -204,3 +204,44 @@ def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch
|
||||
assert args.timeout_seconds <= 240
|
||||
assert row['parameters']['timeout_seconds'] == 3600
|
||||
authorize.assert_awaited_once()
|
||||
|
||||
|
||||
def test_large_burst_result_keeps_saved_map_command_and_full_evidence(monkeypatch):
|
||||
from copy import deepcopy
|
||||
from unittest.mock import AsyncMock
|
||||
from app.mcp import calculation_jobs as jobs
|
||||
from app.mcp.calculation_models import CalculationResultInput
|
||||
from app.mcp.models import QueryResult
|
||||
original = binding()
|
||||
original.task_id = uuid4()
|
||||
full = {"parameters": {"burst_pipe_ids": ["P1"]}, "result": {
|
||||
"impact": {"phase": "after_burst_before_intervention", "status": "ready",
|
||||
"compared_node_count": 1000, "comparisons": [{"node_id": f"J{i}", "pressure_drop_m": 4} for i in range(1000)],
|
||||
"grades": [{"group_id": "drop_3_5", "node_ids": [f"J{i}" for i in range(1000)]}],
|
||||
"unavailable_node_ids": []},
|
||||
"coverage": {"status": "ready", "groups": {"drop_3_5": {"node_ids": [f"J{i}" for i in range(1000)],
|
||||
"mapped_node_count": 1000, "missing_node_ids": []}}},
|
||||
"map_payload": {"type": "FeatureCollection", "features": [{"large": "recorded-geometry"}]},
|
||||
}}
|
||||
result = QueryResult(source='run_burst_simulation', source_ref='mcp:burst', queried_at=datetime.now(timezone.utc),
|
||||
semantics='analysis', data=full, evidence_id='saved-burst')
|
||||
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
|
||||
persisted = deepcopy(row)
|
||||
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
|
||||
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
|
||||
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
|
||||
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
|
||||
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])
|
||||
response = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
|
||||
assert response.truncated and len(response.model_dump_json().encode()) < 11000
|
||||
assert response.data['result']['impact']['compared_node_count'] == 1000
|
||||
assert response.data['result']['impact']['grades'][0]['node_count'] == 1000
|
||||
assert response.data['presentation']['command']['evidence_id'] == 'saved-burst'
|
||||
assert response.data['presentation']['command']['action'] == 'set_overlay'
|
||||
assert response.data['resource_uri'].endswith('/evidence/saved-burst')
|
||||
assert 'comparisons' not in response.data['result']['impact']
|
||||
assert row == persisted
|
||||
# Explicit path pagination still reads every original node without summary replacement.
|
||||
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
|
||||
args.model_copy(update={'data_path': ['result', 'impact', 'comparisons'], 'offset': 990, 'limit': 10})))
|
||||
assert page.data[0]['node_id'] == 'J990' and page.page.total == 1000
|
||||
|
||||
@@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
|
||||
|
||||
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
|
||||
assert not any("health" in name for name in TOOLS)
|
||||
assert len(TOOLS) == 72
|
||||
assert len(TOOLS) == 73
|
||||
assert sum(t.mutating for t in TOOLS.values()) == 13
|
||||
for tool in TOOLS.values():
|
||||
if tool.mutating:
|
||||
|
||||
@@ -34,3 +34,32 @@ def test_missing_geometry_does_not_offer_a_broken_overlay_command():
|
||||
data={"result": {"coverage": {"status": "unavailable"}}},
|
||||
)
|
||||
assert saved_presentation_preview(result) is None
|
||||
|
||||
|
||||
def test_pollution_impact_keeps_a_saved_overlay_reference_for_history():
|
||||
result = QueryResult(source="run_contaminant_simulation", source_ref="calculation:pollution",
|
||||
evidence_id="pollution:impact", queried_at=datetime.now(timezone.utc), semantics="analysis", origin="synthetic",
|
||||
data={"result": {"impact": {"phase": "after_contamination_before_intervention"},
|
||||
"map_payload": {"type": "FeatureCollection", "features": [{"geometry": "saved"}]},
|
||||
"coverage": {"status": "ready", "groups": {"contaminant_exposure": {"node_ids": ["Q1"], "mapped_node_count": 1}}}}})
|
||||
preview = saved_presentation_preview(result)
|
||||
assert preview["command"]["evidence_id"] == "pollution:impact"
|
||||
assert preview["command"]["title"] == "水污染传播预计影响范围(处置前情景)"
|
||||
assert "data" not in preview["command"] and preview["groups"]["contaminant_exposure"]["node_count"] == 1
|
||||
result.data["result"]["impact"]["phase"] = "unknown"
|
||||
assert saved_presentation_preview(result) is None
|
||||
|
||||
|
||||
def test_burst_impact_hint_uses_saved_evidence_and_distinct_pre_intervention_title():
|
||||
result = QueryResult(
|
||||
source="run_burst_simulation", source_ref="mcp:burst", evidence_id="mcp:burst",
|
||||
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
|
||||
data={"result": {"impact": {"phase": "after_burst_before_intervention"},
|
||||
"map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]}}},
|
||||
)
|
||||
hint = saved_presentation_preview(result)
|
||||
assert hint["command"]["evidence_id"] == "mcp:burst"
|
||||
assert "爆管后、处置前" in hint["command"]["title"]
|
||||
assert "data" not in hint["command"]
|
||||
result.data["result"].pop("impact")
|
||||
assert saved_presentation_preview(result) is None
|
||||
|
||||
@@ -373,6 +373,30 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
|
||||
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
|
||||
|
||||
|
||||
def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monkeypatch):
|
||||
bound = binding(task=True)
|
||||
bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"}
|
||||
permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"}
|
||||
bound.permission_cap = sorted(permissions)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
args = TOOLS["run_burst_simulation"].model.model_validate({
|
||||
"context_handle": bound.context_handle, "start_time": "2026-10-08T01:00:00Z",
|
||||
"output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2],
|
||||
"include_impact_coverage": True,
|
||||
})
|
||||
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
|
||||
for changed, status in (({"include_impact_coverage": False}, 422),
|
||||
({"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)}, 422)):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args.model_copy(update=changed)))
|
||||
assert error.value.status_code == status
|
||||
bound.permission_cap = sorted(permissions - {"simulation.view"})
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
|
||||
app, client, _, _ = runtime
|
||||
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
||||
@@ -1116,6 +1140,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
|
||||
|
||||
@@ -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)]
|
||||
@@ -19,6 +19,7 @@ from app.mcp.work_order_skills import read_work_order_skill
|
||||
("water_quality_complaint", "water-quality-complaint"),
|
||||
("general", "appointment-follow-up"),
|
||||
("scada_anomaly", "scada-anomaly"),
|
||||
("water_pollution", "water-pollution-event"),
|
||||
])
|
||||
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
||||
descriptor = read_work_order_skill(workflow)
|
||||
@@ -59,9 +60,31 @@ def test_scada_instructions_advance_candidate_analysis_independently_of_source_l
|
||||
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
|
||||
|
||||
|
||||
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
|
||||
def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the_investigation():
|
||||
skill = read_work_order_skill("water_pollution", include_content=True)
|
||||
text = skill["instructions"]
|
||||
assert text.index("propose_task_action") < text.index("run_contaminant_simulation")
|
||||
assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text
|
||||
assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text
|
||||
assert "模型曲线不填观测专用 chart.scada" in text
|
||||
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text
|
||||
|
||||
|
||||
def test_pollution_workflow_distinguishes_protection_flushing_and_clearance():
|
||||
skill = read_work_order_skill("water_pollution", include_content=True)
|
||||
text = skill["instructions"]
|
||||
assert skill["version"] == "1.2.0"
|
||||
assert text.index("## 4. 源头控制与用户隔离") < text.index("## 5. 污染物清除与分步冲洗")
|
||||
assert "opened_valves" in text and "closed_valves" in text
|
||||
assert "不继承上次模拟" in text and "排放去向" in text
|
||||
assert all(stage in text for stage in ("source_control", "flushing_plan", "clearance_verification"))
|
||||
assert "不能证明污染物已清除" in text and "结果未通过时保持 interim" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"])
|
||||
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
|
||||
task_id, project_id = uuid4(), uuid4()
|
||||
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
|
||||
reader = AsyncMock(return_value={"task": {"workflow": workflow}})
|
||||
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
||||
scope = SimpleNamespace(connection=object(), project=object())
|
||||
@@ -80,8 +103,17 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch
|
||||
body = query("skill")
|
||||
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
||||
assert summary.data["display_time_zone"] == "Asia/Shanghai"
|
||||
assert body.data["skill"]["workflow"] == "burst_report"
|
||||
assert body.data["skill"]["workflow"] == workflow
|
||||
for result in (summary, body):
|
||||
assert ("source_trace_capability" in result.data) == (workflow == "water_pollution")
|
||||
if workflow == "water_pollution":
|
||||
assert result.data["source_trace_capability"]["tool"] == "run_contaminant_source_trace"
|
||||
capability = result.data["source_trace_capability"]
|
||||
assert capability["work_order_wait_blocks_analysis"] is False
|
||||
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
|
||||
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
|
||||
assert body.data["skill"]["instructions"] and not body.truncated
|
||||
assert len(json.dumps(body.model_dump(mode="json"), ensure_ascii=False).encode()) < 10000
|
||||
assert body.evidence_id is None and body.semantics == "task"
|
||||
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
||||
binding.permission_cap = []
|
||||
|
||||
@@ -22,6 +22,25 @@ def observations():
|
||||
return rows
|
||||
|
||||
|
||||
def test_pollution_field_feedback_resumes_review_without_pressure_recovery():
|
||||
task = {"workflow": "water_pollution"}
|
||||
for order_id in ("sampling", "flush"):
|
||||
assert track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()),
|
||||
"execution_status": "succeeded", "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
||||
feedback = {"source_event_id": "peripheral_sample:sampling", "status": "completed", "source_revision": 2,
|
||||
"occurred_at": RETURNED.isoformat(), "evidence": {"evidence_id": "sampling:2"}}
|
||||
accept_completion(task, feedback)
|
||||
assert task["handling_context"]["phase"] == "waiting_work_order"
|
||||
accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:flush", "evidence": {"evidence_id": "flush:2"}})
|
||||
assert task["handling_context"]["phase"] == "field_review"
|
||||
assert "verification_evidence_id" not in task["handling_context"]
|
||||
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:2"
|
||||
task["handling_context"]["phase"] = "completed"
|
||||
accept_completion(task, {**feedback, "source_revision": 3, "evidence": {"evidence_id": "sampling:3"}})
|
||||
assert task["handling_context"]["phase"] == "field_review"
|
||||
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:3"
|
||||
|
||||
|
||||
def evaluate(rows):
|
||||
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
||||
incident_at=INCIDENT, interval_seconds=900)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -874,6 +675,19 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
|
||||
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
|
||||
assert again.task.execution_generation == 2
|
||||
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
|
||||
notices = [item for item in repository.notifications if item["kind"] == "work_order_feedback"]
|
||||
assert len(notices) == 1
|
||||
assert notices[0]["subject_id"] == str(action.action_id)
|
||||
assert notices[0]["work_order_action"]["receipt"]["receipt_id"] == child_id
|
||||
review_notice = next(item for item in repository.notifications if item["kind"] == "action_approval_pending")
|
||||
assert review_notice["work_order_action"]["approval_status"] == "pending"
|
||||
assert review_notice["work_order_action"]["receipt"] is None
|
||||
assert notices[0]["work_order_reference"] == {
|
||||
"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
|
||||
"evidence_id": None, "status": "completed",
|
||||
}
|
||||
assert any(event["topic"] == "operational.notification.changed"
|
||||
and event["metadata"].get("kind") == "work_order_feedback" for event in repository.events)
|
||||
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||
with pytest.raises(DomainError) as cancelled:
|
||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||
@@ -891,7 +705,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 +737,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 +754,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 +776,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 +796,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 +828,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 +883,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 +938,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 +993,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 +1043,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 +1122,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 +1150,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 +1185,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 +1226,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 +1238,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 +1261,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 +1283,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 +1319,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 +1332,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 +1346,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 +1379,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 +1395,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 +1444,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 +1493,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 +1557,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 +1591,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 +1614,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 +1633,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 +1670,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 +1701,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 +1945,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 +2073,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 +2110,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 +2142,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 +2166,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 +2200,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(
|
||||
@@ -2543,3 +2363,77 @@ async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation
|
||||
await getattr(other, method)(task["task_id"], command)
|
||||
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||
assert repository.task == before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("stage", ["asset_localization", "source_control", "flushing_plan", "clearance_verification"])
|
||||
async def test_presentation_narration_is_evidence_backed_saved_and_replayed_without_mutating_task(stage):
|
||||
now = datetime(2026, 11, 14, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = TaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
evidence, _ = await service.record_data_snapshot(task['task_id'], TaskDataSnapshot(
|
||||
source='query_scada_readings', source_ref='observations', queried_at=now,
|
||||
origin='synthetic', data={'items': []}, truncated=False,
|
||||
))
|
||||
narration = {'stage': stage, 'summary': '本阶段保存的候选方案及核验依据。',
|
||||
'evidence_refs': [evidence.evidence_id], 'next_step': '提出现场工单草稿,并行开展影响分析。'}
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||
command={'action': 'locate_features', 'layer': 'pipes', 'feature_ids': ['P1']}, narration=narration,
|
||||
purpose='核对候选管段与异常测点的空间关系,供后续影响分析。',
|
||||
)
|
||||
result = await service.publish_presentation_event(task['task_id'], command)
|
||||
assert repository.events[-1]['metadata']['narration'] == narration
|
||||
assert repository.events[-1]['metadata']['purpose'] == command.purpose
|
||||
assert await service.publish_presentation_event(task['task_id'], command) == result
|
||||
page = await service.presentation_history(task['task_id'])
|
||||
assert page.items[-1].narration.model_dump(exclude_none=True) == narration
|
||||
assert page.items[-1].purpose == command.purpose
|
||||
assert repository.task['revision'] == task['revision']
|
||||
with pytest.raises(DomainError) as conflict:
|
||||
await service.publish_presentation_event(task['task_id'], command.model_copy(update={
|
||||
'narration': command.narration.model_copy(update={'summary': '不同判断'}),
|
||||
}))
|
||||
assert conflict.value.code == 'IDEMPOTENCY_CONFLICT'
|
||||
foreign = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'}, narration={**narration, 'evidence_refs': ['other-task']},
|
||||
)
|
||||
before = len(repository.events)
|
||||
with pytest.raises(DomainError) as rejected:
|
||||
await service.publish_presentation_event(task['task_id'], foreign)
|
||||
assert rejected.value.code == 'EVIDENCE_NOT_FOUND'
|
||||
assert len(repository.events) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [
|
||||
{'stage': 'invented_stage'}, {'summary': 'x' * 161}, {'summary': ' '},
|
||||
{'evidence_refs': []}, {'evidence_refs': ['e1', 'e1']}, {'next_step': 'x' * 121},
|
||||
])
|
||||
def test_presentation_narration_rejects_unbounded_or_unsubstantiated_fields(changes):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'},
|
||||
narration={'stage': 'evidence_verification', 'summary': '测点异常待核验',
|
||||
'evidence_refs': ['e1'], **changes},
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_presentation_idempotency_body_does_not_gain_an_empty_narration_field():
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'},
|
||||
)
|
||||
assert 'narration' not in command.model_dump(mode='json')
|
||||
assert 'purpose' not in command.model_dump(mode='json')
|
||||
|
||||
@pytest.mark.parametrize('purpose', ['', ' ', 'x' * 161, 123])
|
||||
def test_presentation_purpose_rejects_invalid_intent(purpose):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'zoom_to_map', 'center': [121, 31], 'zoom': 15}, purpose=purpose,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""A field-work wait ends the run without closing or restarting the incident."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionExecuteCommand, ActionReconcileCommand, ActionReceipt, AgentRunCommand,
|
||||
ExternalFeedbackCommand, HumanApprovalAction,
|
||||
)
|
||||
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
||||
from app.services.operational_recovery import track_work_order
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
|
||||
from tests.unit.test_operational_tasks import _seed_concluded_report
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def setup_wait(*, active=True, phase="waiting_work_order"):
|
||||
now = datetime(2026, 11, 12, 5, 14, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task.update(workflow="scada_anomaly", lifecycle_status="open")
|
||||
run = _active_run(task, now)
|
||||
if not active:
|
||||
run.update(status="succeeded", lease_expires_at=None, finished_at=now.isoformat())
|
||||
task.update(status="waiting_human", lifecycle_status="waiting_human",
|
||||
agent_session_id=None, agent_lease_expires_at=None)
|
||||
order_id = str(uuid4())
|
||||
action = HumanApprovalAction(
|
||||
action_id=uuid4(), task_id=task["task_id"], version=3, kind="create_work_order",
|
||||
reason="现场检查", payload={"title": "检查", "description": "核验候选点", "work_order_type": "general"},
|
||||
evidence_refs=[], required_approval_permission="work-order.approve",
|
||||
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
approval_status="approved", execution_status="succeeded",
|
||||
proposed_by="principal:test", proposed_at=now,
|
||||
receipt=ActionReceipt(adapter="peripheral_sample", receipt_id=order_id,
|
||||
submitted_at=now, completed_at=now, external_status="open", detail="创建成功"),
|
||||
)
|
||||
repository = TaskRepository(task, run, action=action.model_dump(mode="json"))
|
||||
track_work_order(task, repository.action)
|
||||
task["handling_context"]["phase"] = phase
|
||||
if phase == "waiting_scada":
|
||||
task["handling_context"]["returned_at"] = now.isoformat()
|
||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
|
||||
return service, repository, now, action
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||
@pytest.mark.parametrize("active", [True, False])
|
||||
async def test_successful_dispatch_preserves_run_and_parks_only_idle_task(method, active):
|
||||
service, repository, now, action = setup_wait(active=active)
|
||||
repository.task["handling_context"] = {}
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": AsyncMock(
|
||||
return_value=ActionDispatchOutcome("succeeded", "创建成功", action.receipt))})
|
||||
run_before = repository.run.copy()
|
||||
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||
command = command_type(request_id=uuid4(), expected_version=action.version)
|
||||
await getattr(service, f"{method}_action")(action.task_id, action.action_id, command, dispatcher, None)
|
||||
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
|
||||
assert repository.task["execution_generation"] == 1
|
||||
assert repository.run == run_before
|
||||
assert repository.task["status"] == ("running" if active else "waiting_human")
|
||||
if active:
|
||||
await service.heartbeat_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
else:
|
||||
assert await repository.get_active_run(str(action.task_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method", ["submit", "reconcile"])
|
||||
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "unsupported"])
|
||||
async def test_late_dispatch_receipt_is_saved_without_reviving_cancelled_parent(method, outcome):
|
||||
service, repository, now, action = setup_wait()
|
||||
repository.action.update(execution_status="not_submitted" if method == "submit" else "unknown", receipt=None)
|
||||
async def cancelled_while_dispatching(**kwargs):
|
||||
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||
repository.run.update(status="cancelled", lease_expires_at=None)
|
||||
return ActionDispatchOutcome(outcome, "外部回执", action.receipt if outcome == "succeeded" else None)
|
||||
dispatcher = SimpleNamespace(**{method if method == "reconcile" else "dispatch": cancelled_while_dispatching})
|
||||
command_type = ActionExecuteCommand if method == "submit" else ActionReconcileCommand
|
||||
result = await getattr(service, f"{method}_action")(action.task_id, action.action_id,
|
||||
command_type(request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||
assert result.execution_status == outcome
|
||||
assert repository.task["status"] == "cancelled"
|
||||
assert repository.task["execution_generation"] == 1
|
||||
assert await repository.get_active_run(str(action.task_id)) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("phase", ["waiting_work_order", "waiting_scada", "pending_approval"])
|
||||
async def test_current_interim_report_finishes_run_as_waiting_without_closing_incident(phase):
|
||||
service, repository, now, action = setup_wait()
|
||||
if phase == "pending_approval":
|
||||
repository.task["handling_context"] = {}
|
||||
repository.action.update(approval_status="pending", execution_status="not_submitted", receipt=None)
|
||||
else:
|
||||
repository.task["handling_context"]["phase"] = phase
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.reports[-1]["report"]["stage"] = "interim"
|
||||
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == result.lifecycle_status == "waiting_human"
|
||||
assert repository.run["status"] == "succeeded"
|
||||
assert repository.run["lease_expires_at"] is None
|
||||
assert result.active_run is None
|
||||
assert result.execution_generation == 1
|
||||
assert not any(n["kind"] == "task_failed" for n in repository.notifications)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("defect", ["missing", "old_generation", "other_session", "verification_ready"])
|
||||
async def test_wait_does_not_bypass_current_report_or_final_recovery_requirements(defect):
|
||||
service, repository, now, action = setup_wait()
|
||||
if defect != "missing":
|
||||
_seed_concluded_report(repository, now)
|
||||
repository.reports[-1]["report"]["stage"] = "interim"
|
||||
if defect == "old_generation":
|
||||
repository.task["execution_generation"] = repository.run["generation"] = 2
|
||||
repository.reports[-1]["execution_generation"] = 1
|
||||
elif defect == "other_session":
|
||||
repository.reports[-1]["source_session_id"] = "another-session"
|
||||
else:
|
||||
repository.task["handling_context"]["phase"] = "verification_ready"
|
||||
result = await service.complete_run(action.task_id, repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == "failed"
|
||||
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("status", ["assigned", "in_progress", "awaiting_feedback", "completed"])
|
||||
@pytest.mark.parametrize("active", [True, False])
|
||||
async def test_ordinary_child_feedback_waits_without_starting_or_cancelling_run(status, active):
|
||||
service, repository, now, action = setup_wait(active=active)
|
||||
if not active:
|
||||
# Recover the persisted shape left by the reported ARTIFACT_INCOMPLETE failure.
|
||||
repository.task.update(status="failed", lifecycle_status="failed")
|
||||
repository.run.update(status="failed", failure_code="ARTIFACT_INCOMPLETE")
|
||||
run_before = repository.run.copy()
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=2, status=status, occurred_at=now, detail="工单进度更新"))
|
||||
assert result.task.execution_generation == 1
|
||||
assert repository.run == run_before
|
||||
assert result.task.status == ("running" if active else "waiting_human")
|
||||
assert result.task.handling_context["phase"] == ("waiting_scada" if status == "completed" else "waiting_work_order")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("status", ["failed", "cancelled"])
|
||||
async def test_failed_or_cancelled_child_wakes_same_task_for_replanning(status):
|
||||
service, repository, now, action = setup_wait(active=False)
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=2, status=status, occurred_at=now, detail="现场处置无法继续"))
|
||||
assert str(result.task.task_id) == str(action.task_id)
|
||||
assert result.task.status == "queued" and result.task.execution_generation == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("phase", ["verification_ready", "completed"])
|
||||
async def test_revised_receipt_with_ready_recovery_evidence_does_not_strand_task(phase):
|
||||
service, repository, now, action = setup_wait(active=False, phase="waiting_scada")
|
||||
repository.task["handling_context"].update(phase=phase, verification_evidence_id="recovery:saved")
|
||||
if phase == "completed":
|
||||
repository.task.update(status="completed", lifecycle_status="completed")
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=3, status="completed", occurred_at=now, detail="补充完工说明,实际完工时间不变"))
|
||||
assert result.task.status == ("completed" if phase == "completed" else "queued")
|
||||
assert result.task.execution_generation == (1 if phase == "completed" else 2)
|
||||
assert result.task.handling_context["verification_evidence_id"] == "recovery:saved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_same_completion_time_revision_does_not_interrupt_active_recovery_review():
|
||||
service, repository, now, action = setup_wait(phase="waiting_scada")
|
||||
repository.task["handling_context"].update(phase="verification_ready", verification_evidence_id="recovery:saved")
|
||||
run_before = repository.run.copy()
|
||||
result = await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{action.receipt.receipt_id}",
|
||||
source_revision=3, status="completed", occurred_at=now, detail="补充备注,完工时间不变"))
|
||||
assert result.task.status == "running" and result.task.execution_generation == 1
|
||||
assert repository.run == run_before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("new_decision", [True, False])
|
||||
@pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
|
||||
async def test_only_new_dispatch_decisions_replace_terminated_work_orders(new_decision, terminal_status):
|
||||
service, repository, now, action = setup_wait(active=False)
|
||||
old_id = action.receipt.receipt_id
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=2,
|
||||
status=terminal_status, occurred_at=now - timedelta(hours=1), detail="需重新安排"))
|
||||
replacement_id = str(uuid4())
|
||||
new_action = action.model_dump(mode="json")
|
||||
new_action.update(action_id=str(uuid4()), proposed_at=(now + timedelta(seconds=1 if new_decision else -1)).isoformat())
|
||||
new_action["receipt"]["receipt_id"] = replacement_id
|
||||
track_work_order(repository.task, new_action)
|
||||
repository.action = new_action
|
||||
# Finish the replanning run before the replacement's completion arrives.
|
||||
repository.run.update(status="succeeded", lease_expires_at=None)
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{replacement_id}", source_revision=2,
|
||||
status="completed", occurred_at=now + timedelta(hours=1), detail="替代处置完成"))
|
||||
phase = repository.task["handling_context"]["phase"]
|
||||
assert phase == ("waiting_scada" if new_decision else "waiting_work_order")
|
||||
if new_decision:
|
||||
orders = repository.task["handling_context"]["work_orders"]
|
||||
assert orders[old_id]["superseded_by"] == replacement_id
|
||||
# A late historical edit cannot interrupt the replacement or change T.
|
||||
context_before = {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"}
|
||||
generation = repository.task["execution_generation"]
|
||||
await service.external_feedback(action.task_id, ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{old_id}", source_revision=3,
|
||||
status=terminal_status, occurred_at=now, detail="旧工单备注更新"))
|
||||
assert repository.task["execution_generation"] == generation
|
||||
assert {k: v for k, v in repository.task["handling_context"].items() if k != "work_orders"} == context_before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_superseded_run_heartbeat_has_distinct_problem_code():
|
||||
service, repository, now, action = setup_wait()
|
||||
old_run = repository.run.copy()
|
||||
await service._requeue_task(repository.task, now)
|
||||
with pytest.raises(DomainError) as rejected:
|
||||
await service.heartbeat_run(action.task_id, old_run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=old_run["revision"], source_session_id="session-1"))
|
||||
assert rejected.value.code == "RUN_SUPERSEDED"
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import 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"
|
||||
|
||||
@@ -232,6 +232,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||
[
|
||||
("waiting_human", 2, True),
|
||||
("queued", 2, True),
|
||||
("failed", 2, True),
|
||||
("completed", 2, True),
|
||||
("cancelled", 1, False),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
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 (
|
||||
ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReceipt,
|
||||
AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulation_runs():
|
||||
from types import SimpleNamespace
|
||||
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
|
||||
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
||||
|
||||
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task["workflow"] = "water_pollution"
|
||||
repository = TaskRepository(task, _active_run(task, now))
|
||||
repository.pending_calculation_jobs = AsyncMock(return_value=["pollution-simulation"])
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "候选源核验",
|
||||
"current_conclusion": "候选节点已取得,可先派现场核查", "conclusion_boundary": "污染传播计算中",
|
||||
"evidence_refs": [], "recommendations": ["采样核验"], "pending_items": ["传播模拟"]}
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="先核验候选源",
|
||||
payload={"title": "候选节点现场核查", "description": "核查污染进入路径并采样", "work_order_type": "general"}))
|
||||
with pytest.raises(DomainError) as denied:
|
||||
await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
|
||||
request_id=uuid4(), expected_version=action.version, decision="approved"))
|
||||
assert denied.value.code == "PERMISSION_DENIED"
|
||||
service = OperationalTaskService(repository, context,
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
|
||||
approved = await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
|
||||
request_id=uuid4(), expected_version=action.version, decision="approved"))
|
||||
dispatcher = SimpleNamespace(dispatch=AsyncMock(return_value=ActionDispatchOutcome("succeeded", "现场工单已创建",
|
||||
ActionReceipt(adapter="peripheral_sample", receipt_id=str(uuid4()), submitted_at=now,
|
||||
completed_at=now, external_status="open", detail="创建成功"))))
|
||||
with pytest.raises(DomainError) as stale:
|
||||
await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
|
||||
request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||
assert stale.value.code == "VERSION_CONFLICT"
|
||||
dispatched = await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
|
||||
request_id=uuid4(), expected_version=approved.version), dispatcher, None)
|
||||
assert dispatched.execution_status == "succeeded"
|
||||
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
|
||||
assert repository.run["status"] == "running" and repository.task["execution_generation"] == 1
|
||||
dispatcher.dispatch.assert_awaited_once()
|
||||
repository.pending_calculation_jobs.assert_not_awaited() # No simulation dependency on dispatch.
|
||||
|
||||
with pytest.raises(DomainError) as pending:
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
|
||||
assert pending.value.code == "PENDING_CALCULATIONS"
|
||||
repository.pending_calculation_jobs.return_value = []
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="run_contaminant_simulation", source_ref="calculation:pollution", queried_at=now,
|
||||
origin="synthetic", truncated=False, data={"result": {"impact": {"affected_node_count": 2}}}))
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={**report,
|
||||
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
|
||||
"pending_items": ["等待现场核查和采样处置工单反馈"]}))
|
||||
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
|
||||
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pollution_report_waits_for_field_feedback_and_requires_latest_feedback_evidence(monkeypatch):
|
||||
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task.update(workflow="water_pollution", source={"kind": "external_system", "external_id": "scada:quality:1",
|
||||
"external_revision": 1, "event_kind": "water_pollution"})
|
||||
repository = TaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
||||
scada, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="query_scada_readings", source_ref="mcp:quality", queried_at=now,
|
||||
origin="system_data", truncated=False, data={
|
||||
"window": {"start": (now - timedelta(hours=3)).isoformat(), "end": now.isoformat()},
|
||||
"data": {"devices": [], "readings": [], "coverage": {"mode": "all_scada", "query_complete": True}},
|
||||
}))
|
||||
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "水污染事件调查",
|
||||
"current_conclusion": "候选分析完成,待采样与处置反馈", "conclusion_boundary": "未确诊污染源",
|
||||
"evidence_refs": [scada.evidence_id], "recommendations": ["现场采样复测"], "pending_items": ["现场反馈"]}
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="采样和处置核验",
|
||||
payload={"title": "节点采样复测", "description": "回传位置、时间、数值和判定依据", "work_order_type": "general"}))
|
||||
child = str(uuid4())
|
||||
repository.action = action.model_copy(update={"approval_status": "approved", "execution_status": "succeeded"}).model_dump(mode="json")
|
||||
repository.action["receipt"] = {"adapter": "peripheral_sample", "receipt_id": child,
|
||||
"submitted_at": now.isoformat(), "completed_at": now.isoformat(), "external_status": "open", "detail": "创建成功"}
|
||||
track_work_order(repository.task, repository.action)
|
||||
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == "waiting_human"
|
||||
service._resolve_investigation_alerts.assert_not_awaited()
|
||||
|
||||
evidence_id = "field-quality:2"
|
||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{child}", source_revision=2,
|
||||
status="completed", occurred_at=now, detail="采样、处置与复测反馈已返回",
|
||||
evidence={"evidence_id": evidence_id, "kind": "external_feedback", "summary": "现场记录及复测结果",
|
||||
"source_label": "现场反馈", "source_ref": f"peripheral_sample:{child}", "observed_at": now}))
|
||||
assert repository.task["handling_context"]["phase"] == "field_review"
|
||||
assert "last_check" not in repository.task["handling_context"] # No pressure recovery shortcut.
|
||||
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"))
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
|
||||
with pytest.raises(DomainError) as rejected:
|
||||
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 rejected.value.code == "POLLUTION_FEEDBACK_REQUIRED"
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final",
|
||||
"evidence_refs": [scada.evidence_id, evidence_id], "pending_items": []}))
|
||||
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||
assert result.status == "completed" and result.handling_context["phase"] == "completed"
|
||||
service._resolve_investigation_alerts.assert_awaited_once_with(task["task_id"], evidence_id)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Presentation prose is validated on writes without rejecting historical scenes."""
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskPresentationHistoryItem
|
||||
|
||||
|
||||
def presentation():
|
||||
return {
|
||||
"request_id": uuid4(), "run_id": uuid4(), "source_session_id": "fixture-session",
|
||||
"purpose": "核对压力变化与异常时刻的对应关系。",
|
||||
"narration": {"stage": "evidence_verification", "summary": "压力已回升,恢复情况仍需结合现场反馈复核。",
|
||||
"next_step": "核对完工后的恢复监测。", "evidence_refs": ["recovery:sha256:fixture"]},
|
||||
"command": {"action": "open_chart", "chart_id": "pressure", "evidence_refs": ["recovery:sha256:fixture"],
|
||||
"chart": {"title": "170486 压力恢复趋势", "chart_type": "line", "x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)", "x_data": ["13:00", "13:15"],
|
||||
"series": [{"name": "170486 当前压力", "data": [20.09, 26.12],
|
||||
"sample_times": ["2026-07-16T05:00:00Z", "2026-07-16T05:15:00Z"]}]}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"status=passed,required_samples=1。", "Skill scada-anomaly v1.0.16 已执行。",
|
||||
"已读取 recovery:sha256:01234567。", "恢复时刻 2026-07-16T05:15:00Z。",
|
||||
"恢复时刻 13:15(UTC+8)。", "**恢复复核通过**。",
|
||||
])
|
||||
def test_presentation_rejects_execution_text_in_purpose_finding_and_follow_up(text):
|
||||
value = presentation()
|
||||
for field in ("purpose", "summary", "next_step"):
|
||||
changed = deepcopy(value)
|
||||
if field == "purpose":
|
||||
changed[field] = text
|
||||
else:
|
||||
changed["narration"][field] = text
|
||||
history = TaskPresentationHistoryItem(
|
||||
event_seq=1, run_id=changed["run_id"], execution_generation=1,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=timezone.utc),
|
||||
command=changed["command"], purpose=changed["purpose"], narration=changed["narration"],
|
||||
)
|
||||
assert history.purpose == changed["purpose"]
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(changed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["title", "x_axis_name", "y_axis_name", "x_data", "series"])
|
||||
def test_new_chart_visible_labels_follow_the_same_prose_contract(field):
|
||||
value = presentation()
|
||||
chart = value["command"]["chart"]
|
||||
if field == "x_data":
|
||||
chart[field][0] = "2026-07-16T05:00:00Z"
|
||||
elif field == "series":
|
||||
chart[field][0]["name"] = "execution_generation=3"
|
||||
else:
|
||||
chart[field] = "压力复核 status=passed"
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(value)
|
||||
|
||||
|
||||
def test_display_validation_preserves_real_timestamps_identifiers_and_values():
|
||||
value = presentation()
|
||||
parsed = TaskPresentationEventCommand.model_validate(value).model_dump(mode="json")
|
||||
assert parsed["command"] == value["command"]
|
||||
assert parsed["narration"]["evidence_refs"] == value["narration"]["evidence_refs"]
|
||||
value["command"] = {"action": "set_overlay", "overlay_id": "estimated-impact",
|
||||
"title": "候选管段预计影响范围", "mode": "geometry", "evidence_id": "mcp:fixture"}
|
||||
assert TaskPresentationEventCommand.model_validate(value).command.title == "候选管段预计影响范围"
|
||||
value["command"]["title"] = "候选影响范围(UTC+8)"
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(value)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user