Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45c5c6f46 | ||
|
|
2e4f6cb93b |
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
|
|||||||
'cancel_task_run',
|
'cancel_task_run',
|
||||||
'run_simulation',
|
'run_simulation',
|
||||||
'run_contaminant_simulation',
|
'run_contaminant_simulation',
|
||||||
|
'run_contaminant_source_trace',
|
||||||
'analyze_valve_closure',
|
'analyze_valve_closure',
|
||||||
'analyze_flushing',
|
'analyze_flushing',
|
||||||
'analyze_pressure_regulation',
|
'analyze_pressure_regulation',
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import re
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
|
from pydantic import Field, JsonValue, StringConstraints, field_validator, model_serializer, model_validator
|
||||||
from app.domain.schemas.answer_ui import (
|
from app.domain.schemas.answer_ui import (
|
||||||
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
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.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.domain.schemas.workbench_runtime import (
|
from app.domain.schemas.workbench_runtime import (
|
||||||
WorkbenchDateTime,
|
WorkbenchDateTime,
|
||||||
@@ -252,6 +255,10 @@ class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
|
|||||||
|
|
||||||
|
|
||||||
class InvestigationGraphNodeUpdate(_InvestigationDiagramNode):
|
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=(
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
||||||
"No live node/task binding or per-step synchronization is required."
|
"No live node/task binding or per-step synchronization is required."
|
||||||
@@ -345,12 +352,44 @@ class InvestigationReport(StrictModel):
|
|||||||
return self
|
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):
|
class InvestigationReportInput(InvestigationReport):
|
||||||
"""Write model: every new report explicitly declares its business result."""
|
"""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"]
|
stage: Literal["interim", "final"]
|
||||||
outcome: Literal["concluded", "insufficient_data"]
|
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):
|
class InvestigationProgress(StrictModel):
|
||||||
@@ -361,8 +400,14 @@ class InvestigationProgress(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class InvestigationProgressInput(InvestigationProgress):
|
class InvestigationProgressInput(InvestigationProgress):
|
||||||
|
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
|
||||||
graph_update: InvestigationGraphUpdate | None = None
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_graph_input(self):
|
def validate_graph_input(self):
|
||||||
if self.graph_update is not None and self.diagram is not None:
|
if self.graph_update is not None and self.diagram is not None:
|
||||||
@@ -752,7 +797,7 @@ class ScadaChartGap(StrictModel):
|
|||||||
|
|
||||||
class ScadaChartContext(StrictModel):
|
class ScadaChartContext(StrictModel):
|
||||||
role: Literal["current", "comparison"]
|
role: Literal["current", "comparison"]
|
||||||
metric: Literal["pressure", "flow"]
|
metric: Literal["pressure", "flow", "quality"]
|
||||||
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
window_start: WorkbenchDateTime
|
window_start: WorkbenchDateTime
|
||||||
window_end: WorkbenchDateTime
|
window_end: WorkbenchDateTime
|
||||||
@@ -904,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):
|
class TaskPresentationEventCommand(StrictModel):
|
||||||
request_id: UUID
|
request_id: UUID
|
||||||
run_id: UUID
|
run_id: UUID
|
||||||
source_session_id: NonEmptyStr
|
source_session_id: NonEmptyStr
|
||||||
command: TaskPresentationCommand
|
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):
|
class TaskPresentationEventResult(StrictModel):
|
||||||
@@ -939,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
|
|||||||
# Stored records are validated against the current command contract by the
|
# Stored records are validated against the current command contract by the
|
||||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||||
command: dict[str, JsonValue]
|
command: dict[str, JsonValue]
|
||||||
|
narration: TaskPresentationNarration | None = None
|
||||||
|
purpose: TaskPresentationPurpose | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationHistoryPage(StrictModel):
|
class TaskPresentationHistoryPage(StrictModel):
|
||||||
@@ -1015,6 +1123,13 @@ class HumanEvidenceCommand(StrictModel):
|
|||||||
evidence: EvidenceInput
|
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):
|
class OperationalTaskNotification(StrictModel):
|
||||||
notification_id: UUID
|
notification_id: UUID
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
@@ -1022,7 +1137,7 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
"task_created", "action_pending", "report_updated", "task_completed",
|
"task_created", "action_pending", "report_updated", "task_completed",
|
||||||
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
||||||
"action_approval_pending", "human_input_required",
|
"action_approval_pending", "human_input_required",
|
||||||
"external_coordination_failed"
|
"external_coordination_failed", "work_order_feedback"
|
||||||
]
|
]
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
@@ -1031,6 +1146,8 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||||
subject_id: UUID | None = None
|
subject_id: UUID | None = None
|
||||||
report_reference: InvestigationReportReference | None = None
|
report_reference: InvestigationReportReference | None = None
|
||||||
|
work_order_action: HumanApprovalAction | None = None
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None
|
||||||
resolved_at: WorkbenchDateTime | None = None
|
resolved_at: WorkbenchDateTime | None = None
|
||||||
resolution_reason: NonEmptyStr | None = None
|
resolution_reason: NonEmptyStr | None = None
|
||||||
status: Literal["unread", "read"]
|
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"])
|
||||||
@@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput):
|
|||||||
return self
|
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):
|
class ValveIsolationInput(CalculationInput):
|
||||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
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")
|
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="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
||||||
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
||||||
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源")
|
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):
|
class ValveClosureInput(ScenarioInput):
|
||||||
@@ -150,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
|
|||||||
|
|
||||||
|
|
||||||
class FlushingInput(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;提供流量来源与估算依据")
|
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)]
|
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,
|
final_candidates_csv_path=None,
|
||||||
**parameters,
|
**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")
|
raise HTTPException(422, "offset requires a collection data_path")
|
||||||
else:
|
else:
|
||||||
result.data = value
|
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:
|
if len(result.model_dump_json().encode()) > 11000:
|
||||||
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||||
return result
|
return result
|
||||||
|
|||||||
+5
-4
@@ -6,7 +6,7 @@ from mcp import types
|
|||||||
from app.mcp import models as m
|
from app.mcp import models as m
|
||||||
from app.mcp import calculation_models as jobs
|
from app.mcp import calculation_models as jobs
|
||||||
from app.mcp import extended_models as e
|
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
|
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||||
|
|
||||||
|
|
||||||
@@ -62,10 +62,11 @@ class QueryTool:
|
|||||||
|
|
||||||
|
|
||||||
_specs = [
|
_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_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_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),
|
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
||||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||||
@@ -258,7 +259,7 @@ _specs += [
|
|||||||
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
||||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
("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_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "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),
|
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput):
|
|||||||
|
|
||||||
|
|
||||||
class BurstSimulationInput(ScenarioInput):
|
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)
|
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²;保留面积来源及估算依据")
|
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"]})
|
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||||
if args.scenario == "flushing":
|
if args.scenario == "flushing":
|
||||||
from app.native.wndb.model.options import get_option
|
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"}:
|
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||||
raise ValueError("Flushing supports LPS or CMH models only")
|
raise ValueError("Flushing supports LPS or CMH models only")
|
||||||
return scenarios.flushing_analysis(**common, username=username,
|
return scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
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)
|
mappings = query_corresponding_element_id_and_query_id(network)
|
||||||
controls = parameters["pump_control"]
|
controls = parameters["pump_control"]
|
||||||
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
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
|
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:
|
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
|
return None
|
||||||
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||||
if not isinstance(analysis, dict):
|
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"):
|
if not isinstance(payload, dict) or not payload.get("features"):
|
||||||
return None
|
return None
|
||||||
coverage = analysis.get("coverage", {})
|
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 {
|
return {
|
||||||
"command": {
|
"command": {
|
||||||
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||||
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, 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,
|
"display_only": True,
|
||||||
"coverage_status": coverage.get("status"),
|
"coverage_status": coverage.get("status"),
|
||||||
|
|||||||
@@ -75,16 +75,28 @@ async def authorize_query(
|
|||||||
if replay_context:
|
if replay_context:
|
||||||
from app.services.scada_replay_queries import check_replay_window
|
from app.services.scada_replay_queries import check_replay_window
|
||||||
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
|
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_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
|
||||||
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
||||||
"list_regions", "get_region", "get_region_nodes",
|
"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"}
|
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||||
if tool.name not in allowed:
|
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":
|
if tool.name == "query_scada_readings":
|
||||||
check_replay_window(replay_context, args.start, args.end)
|
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 tool.name == "run_burst_location":
|
||||||
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||||
@@ -107,6 +119,8 @@ async def authorize_query(
|
|||||||
required = {tool.permission}
|
required = {tool.permission}
|
||||||
if tool.name in SCENARIO_TOOLS:
|
if tool.name in SCENARIO_TOOLS:
|
||||||
required.add("scada.view")
|
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"}:
|
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")
|
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):
|
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())
|
source_ref = "mcp:" + str(uuid4())
|
||||||
evidence_id = None
|
evidence_id = None
|
||||||
replay_context = getattr(binding, "replay_context", None)
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
if name == "query_scada_readings" and 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
|
from app.mcp.scada_readings import read_complete_scada
|
||||||
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||||
elif name == "query_scada_readings" and replay_context:
|
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)
|
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||||
data, page, truncated = page_result(rows, args)
|
data, page, truncated = page_result(rows, args)
|
||||||
origin = "synthetic"
|
origin = "synthetic"
|
||||||
|
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:
|
elif name == "run_burst_location" and replay_context:
|
||||||
from app.mcp.analysis_service import calculate_replay_burst_location
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
@@ -241,7 +296,7 @@ async def execute_query(
|
|||||||
}
|
}
|
||||||
gaps = [{
|
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_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_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_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.",
|
"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.",
|
"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.",
|
"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.",
|
"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]]
|
}[name]]
|
||||||
if name == "calculate_demand_allocation":
|
if name == "calculate_demand_allocation":
|
||||||
truncated = data["result"]["has_more"] or args.offset > 0
|
truncated = data["result"]["has_more"] or args.offset > 0
|
||||||
@@ -491,6 +546,18 @@ async def execute_query(
|
|||||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||||
truncated = True
|
truncated = True
|
||||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
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
|
if (skill.get("name") == "scada-anomaly" and replay_context
|
||||||
and "run_burst_location" in binding.tool_cap):
|
and "run_burst_location" in binding.tool_cap):
|
||||||
from app.services.burst_location_demo import location_demo_context
|
from app.services.burst_location_demo import location_demo_context
|
||||||
|
|||||||
@@ -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
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
|
||||||
parameters = dict(parameters)
|
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")
|
links = parameters.pop("output_link_ids")
|
||||||
start = parameters.pop("start_time")
|
start = parameters.pop("start_time")
|
||||||
duration = parameters.pop("duration")
|
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(),
|
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
||||||
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
||||||
if name == "run_simulation":
|
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,
|
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
||||||
)
|
)
|
||||||
elif name == "run_burst_simulation":
|
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,
|
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":
|
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,
|
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":
|
elif name == "analyze_valve_closure":
|
||||||
result = scenarios.valve_close_analysis(**common,
|
result = scenarios.valve_close_analysis(**common,
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
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
|
from app.native.wndb.model.options import get_option
|
||||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||||
raise ValueError("Flushing currently supports LPS or CMH models only")
|
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,
|
result = scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
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":
|
elif name == "analyze_pressure_regulation":
|
||||||
from app.services.simulation import query_corresponding_element_id_and_query_id
|
from app.services.simulation import query_corresponding_element_id_and_query_id
|
||||||
mappings = query_corresponding_element_id_and_query_id(network)
|
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")
|
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_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]
|
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",
|
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
||||||
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
||||||
"start_time": start.isoformat(), "duration_seconds": duration,
|
"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_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})},
|
"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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import yaml
|
|||||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||||
WORKFLOW_SKILLS = {
|
WORKFLOW_SKILLS = {
|
||||||
"scada_anomaly": "scada-anomaly",
|
"scada_anomaly": "scada-anomaly",
|
||||||
|
"water_pollution": "water-pollution-event",
|
||||||
"burst_report": "burst-event-handling",
|
"burst_report": "burst-event-handling",
|
||||||
"water_quality_complaint": "water-quality-complaint",
|
"water_quality_complaint": "water-quality-complaint",
|
||||||
"general": "appointment-follow-up",
|
"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
|
||||||
@@ -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
|
||||||
|
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga
|
|||||||
def update_investigation_graph(
|
def update_investigation_graph(
|
||||||
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
||||||
) -> InvestigationDiagram:
|
) -> InvestigationDiagram:
|
||||||
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
|
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
|
||||||
|
|
||||||
A retry uses a new node id. Relationships describe activity for readers and
|
Unspecified fields retain their saved values. A failed attempt remains
|
||||||
impose no ordering or completion requirements on the Agent.
|
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:
|
if previous is not None:
|
||||||
previous = adopt_legacy_investigation_graph(previous)
|
previous = adopt_legacy_investigation_graph(previous)
|
||||||
@@ -47,15 +48,19 @@ def update_investigation_graph(
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
|
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)
|
"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))
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||||
continue
|
continue
|
||||||
require(not in_source(node.node_id) or existing.label == node.label,
|
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)
|
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
|
||||||
require(existing.status != "failed" or (
|
require(existing.status != "failed" or (
|
||||||
("status" not in node.model_fields_set or node.status == "failed")
|
("status" not in node.model_fields_set or node.status == "failed")
|
||||||
and existing.label == node.label
|
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)
|
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
|
||||||
changes = node.model_dump(exclude_unset=True)
|
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"):
|
if existing.status == "failed" and existing.detail and not changes.get("detail"):
|
||||||
changes.pop("detail", None)
|
changes.pop("detail", None)
|
||||||
for field in ("evidence_refs", "action_refs", "map_targets"):
|
for field in ("evidence_refs", "action_refs", "map_targets"):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
from app.services.operational_tasks import OperationalTaskService
|
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) -> bool:
|
def track_work_order(task: dict, action: dict) -> bool:
|
||||||
@@ -60,6 +60,8 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
|
|||||||
# edits. A revised field receipt can explicitly correct the verification start.
|
# edits. A revised field receipt can explicitly correct the verification start.
|
||||||
if feedback["status"] == "completed":
|
if feedback["status"] == "completed":
|
||||||
order["returned_at"] = feedback["occurred_at"]
|
order["returned_at"] = feedback["occurred_at"]
|
||||||
|
if task.get("workflow") == "water_pollution":
|
||||||
|
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
|
||||||
else:
|
else:
|
||||||
order.pop("returned_at", None)
|
order.pop("returned_at", None)
|
||||||
if order.get("superseded_by"):
|
if order.get("superseded_by"):
|
||||||
@@ -67,6 +69,13 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
|
|||||||
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
|
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):
|
if orders and all(item["status"] == "completed" for item in orders):
|
||||||
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
||||||
|
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")
|
previous = context.get("returned_at")
|
||||||
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
||||||
or context.get("phase") == "waiting_work_order"):
|
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:
|
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
|
||||||
replay = task.get("source", {}).get("replay_context")
|
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
|
return
|
||||||
target = datetime.fromisoformat(
|
target = datetime.fromisoformat(
|
||||||
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
|
|||||||
OperationalTaskCreate,
|
OperationalTaskCreate,
|
||||||
OperationalTaskDetail,
|
OperationalTaskDetail,
|
||||||
OperationalTaskNotification,
|
OperationalTaskNotification,
|
||||||
|
WorkOrderNotificationReference,
|
||||||
OperationalTaskPermissions,
|
OperationalTaskPermissions,
|
||||||
OperationalTaskSchedule,
|
OperationalTaskSchedule,
|
||||||
OperationalTaskScheduleCreate,
|
OperationalTaskScheduleCreate,
|
||||||
@@ -874,7 +875,7 @@ class OperationalTaskService:
|
|||||||
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
||||||
elif saved_diagram is not None:
|
elif saved_diagram is not None:
|
||||||
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
|
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
|
||||||
"Use graph_update to extend the saved investigation graph", status=422)
|
"Use graph_update to patch saved nodes or extend the investigation graph", status=422)
|
||||||
if diagram is None:
|
if diagram is None:
|
||||||
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
if diagram:
|
if diagram:
|
||||||
@@ -1066,6 +1067,8 @@ class OperationalTaskService:
|
|||||||
run_id=row["metadata"]["run_id"],
|
run_id=row["metadata"]["run_id"],
|
||||||
execution_generation=row["metadata"]["execution_generation"],
|
execution_generation=row["metadata"]["execution_generation"],
|
||||||
command=row["metadata"]["command"],
|
command=row["metadata"]["command"],
|
||||||
|
narration=row["metadata"].get("narration"),
|
||||||
|
purpose=row["metadata"].get("purpose"),
|
||||||
) for row in rows[:limit]]
|
) for row in rows[:limit]]
|
||||||
return TaskPresentationHistoryPage(
|
return TaskPresentationHistoryPage(
|
||||||
task_id=task_id, event_cursor=boundary, items=items,
|
task_id=task_id, event_cursor=boundary, items=items,
|
||||||
@@ -1096,6 +1099,9 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
payload = _json(command.command)
|
payload = _json(command.command)
|
||||||
|
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)
|
await self._record_scada_presentation(task, run, payload, now)
|
||||||
if command.command.action == "open_chart":
|
if command.command.action == "open_chart":
|
||||||
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
||||||
@@ -1121,6 +1127,10 @@ class OperationalTaskService:
|
|||||||
payload["data"] = resolved
|
payload["data"] = resolved
|
||||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||||
"execution_generation": run["generation"], "command": payload}
|
"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:
|
try:
|
||||||
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
@@ -1258,6 +1268,7 @@ class OperationalTaskService:
|
|||||||
task_id, "action_approval_pending", task["title"], command.reason, now,
|
task_id, "action_approval_pending", task["title"], command.reason, now,
|
||||||
required_permission=approval_permission,
|
required_permission=approval_permission,
|
||||||
subject_type="action", subject_id=action.action_id,
|
subject_type="action", subject_id=action.action_id,
|
||||||
|
work_order_action=action if action.kind == "create_work_order" else None,
|
||||||
)
|
)
|
||||||
await self._save_request(
|
await self._save_request(
|
||||||
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
||||||
@@ -1743,6 +1754,29 @@ class OperationalTaskService:
|
|||||||
task_id, "task_failed",
|
task_id, "task_failed",
|
||||||
task["title"], command.detail, now,
|
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 = {
|
response = {
|
||||||
"source_event_id": command.source_event_id,
|
"source_event_id": command.source_event_id,
|
||||||
"source_revision": command.source_revision,
|
"source_revision": command.source_revision,
|
||||||
@@ -2161,12 +2195,24 @@ class OperationalTaskService:
|
|||||||
lifecycle = self._investigation_status(actions, task)
|
lifecycle = self._investigation_status(actions, task)
|
||||||
recovery = task.get("handling_context") or {}
|
recovery = task.get("handling_context") or {}
|
||||||
if lifecycle == "completed" and recovery:
|
if lifecycle == "completed" and recovery:
|
||||||
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
|
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)
|
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
|
||||||
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
|
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
|
||||||
"恢复复核通过后才能结束事件", status=409)
|
"恢复复核通过后才能结束事件", status=409)
|
||||||
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
|
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
|
||||||
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
|
else "恢复供水复核成功"), completed_at=now.isoformat())
|
||||||
|
await self._resolve_investigation_alerts(str(task_id), verification_id)
|
||||||
elif report.report.outcome == "insufficient_data":
|
elif report.report.outcome == "insufficient_data":
|
||||||
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
||||||
for node in plan.plan.nodes)
|
for node in plan.plan.nodes)
|
||||||
@@ -2519,6 +2565,8 @@ class OperationalTaskService:
|
|||||||
*, required_permission: str | None = None,
|
*, required_permission: str | None = None,
|
||||||
subject_type: str = "task", subject_id: UUID | None = None,
|
subject_type: str = "task", subject_id: UUID | None = None,
|
||||||
report_reference: InvestigationReportReference | None = None,
|
report_reference: InvestigationReportReference | None = None,
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None,
|
||||||
|
work_order_action: HumanApprovalAction | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
notification = OperationalTaskNotification(
|
notification = OperationalTaskNotification(
|
||||||
notification_id=uuid4(), task_id=task_id, kind=kind,
|
notification_id=uuid4(), task_id=task_id, kind=kind,
|
||||||
@@ -2527,6 +2575,8 @@ class OperationalTaskService:
|
|||||||
lifecycle_status="active", subject_type=subject_type,
|
lifecycle_status="active", subject_type=subject_type,
|
||||||
subject_id=subject_id or task_id,
|
subject_id=subject_id or task_id,
|
||||||
report_reference=report_reference,
|
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.repo.insert_notification(_json(notification))
|
||||||
await self._append_event(
|
await self._append_event(
|
||||||
|
|||||||
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
|
|||||||
return 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:
|
def diagnostic_signal(document: dict, event: dict) -> bool:
|
||||||
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
|
"""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
|
return False
|
||||||
if not weighted_diagnosis(document):
|
if not weighted_diagnosis(document):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ from app.domain.domain_errors import require
|
|||||||
|
|
||||||
|
|
||||||
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow"}
|
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow", "quality": "quality", "water_quality": "quality"}
|
||||||
|
|
||||||
|
|
||||||
def applies(task: dict) -> bool:
|
def applies(task: dict) -> bool:
|
||||||
if task.get("workflow") != "scada_anomaly" and not task.get("source", {}).get("replay_context"):
|
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not task.get("source", {}).get("replay_context"):
|
||||||
return False
|
return False
|
||||||
# Recovery has its own Server-verified post-repair evidence and finalization gate.
|
# Recovery has its own Server-verified post-repair evidence and finalization gate.
|
||||||
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "completed"}
|
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "field_review", "completed"}
|
||||||
|
|
||||||
|
|
||||||
def cutoff(task: dict) -> datetime:
|
def cutoff(task: dict) -> datetime:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import json
|
import json
|
||||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -429,6 +430,7 @@ class ScadaReplayService:
|
|||||||
await self.repo.save(document)
|
await self.repo.save(document)
|
||||||
|
|
||||||
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
|
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.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
@@ -443,9 +445,10 @@ class ScadaReplayService:
|
|||||||
self.context, permissions_for_context(self.context),
|
self.context, permissions_for_context(self.context),
|
||||||
execution_scope=settings.operational_task_execution_scope)
|
execution_scope=settings.operational_task_execution_scope)
|
||||||
for event in changes:
|
for event in changes:
|
||||||
# Period diagnosis owns measurement notifications during manual steps.
|
pollution = pollution_signal(document, event)
|
||||||
# Retain rule events as evidence; quality alerts remain independent.
|
# Pressure/flow rules share period diagnosis during manual steps.
|
||||||
if (not create_tasks and event.get("category") != "data_quality"
|
# 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"))):
|
and (event["change"] == "raised" or event.get("notification_suppressed"))):
|
||||||
if event["change"] == "raised":
|
if event["change"] == "raised":
|
||||||
event["batch_sequence"] = batch["sequence"]
|
event["batch_sequence"] = batch["sequence"]
|
||||||
@@ -457,13 +460,33 @@ class ScadaReplayService:
|
|||||||
if event["change"] == "raised":
|
if event["change"] == "raised":
|
||||||
event["batch_sequence"] = batch["sequence"]
|
event["batch_sequence"] = batch["sequence"]
|
||||||
document["rule_states"][event.get("state_key", event["rule_id"])]["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(
|
await alerts.push(TaskAlertEvent(
|
||||||
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||||||
alert_id=event["event_id"], producer_id=producer, event_type=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"],
|
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||||||
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
"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["change"] != "raised":
|
||||||
if event.get("task_id"):
|
if event.get("task_id"):
|
||||||
@@ -472,25 +495,6 @@ class ScadaReplayService:
|
|||||||
"batch_sequence": batch["sequence"],
|
"batch_sequence": batch["sequence"],
|
||||||
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||||||
continue
|
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):
|
async def _create_monitoring_task(self, document, monitoring, batch):
|
||||||
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
||||||
@@ -528,35 +532,24 @@ class ScadaReplayService:
|
|||||||
external = incident["external_id"]
|
external = incident["external_id"]
|
||||||
pressure = monitoring["pressure"]
|
pressure = monitoring["pressure"]
|
||||||
flow = monitoring["outlet_flow"]
|
flow = monitoring["outlet_flow"]
|
||||||
detector = monitoring["burst_detection"]
|
findings = []
|
||||||
score = detector.get("target_score")
|
if pressure.get("hit_device_ids"):
|
||||||
score_text = f"{score:.4f}" if score is not None else "数据不足"
|
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
|
||||||
summary = (
|
if flow.get("hit_device_ids"):
|
||||||
f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。"
|
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
|
||||||
f"爆管侦测得分 {score_text};"
|
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
|
||||||
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
|
||||||
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
|
||||||
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
|
|
||||||
)
|
|
||||||
if monitoring.get("anomaly_signals"):
|
if monitoring.get("anomaly_signals"):
|
||||||
signals = monitoring["anomaly_signals"]
|
signals = monitoring["anomaly_signals"]
|
||||||
devices = {device for item in signals for device in item.get("device_ids", [])}
|
devices = {device for item in signals for device in item.get("device_ids", [])}
|
||||||
summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。"
|
summary += f"本次异常涉及 {len(devices)} 个测点。"
|
||||||
if monitoring.get("weighted"):
|
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
|
||||||
weighted = monitoring["weighted"]
|
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
|
||||||
weights = weighted["weights"]
|
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
|
||||||
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}。"
|
|
||||||
command = OperationalTaskCreate(
|
command = OperationalTaskCreate(
|
||||||
request_id=uuid5(NAMESPACE_URL, external),
|
request_id=uuid5(NAMESPACE_URL, external),
|
||||||
task_type="event_investigation",
|
task_type="event_investigation",
|
||||||
workflow="scada_anomaly",
|
workflow="scada_anomaly",
|
||||||
title=f"SCADA 异常调查 · {monitoring['period_end']}",
|
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
|
||||||
summary=summary,
|
summary=summary,
|
||||||
source=TaskSource(
|
source=TaskSource(
|
||||||
kind="external_system",
|
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.native.wndb.model.times import get_time, set_time
|
||||||
from app.domain.time import parse_clock_duration_seconds
|
from app.domain.time import parse_clock_duration_seconds
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
|
||||||
|
|
||||||
def _isolated_analysis(purpose: str):
|
def _isolated_analysis(purpose: str):
|
||||||
@@ -79,6 +80,7 @@ def burst_analysis(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
爆管模拟
|
爆管模拟
|
||||||
@@ -123,6 +125,7 @@ def burst_analysis(
|
|||||||
name=new_name,
|
name=new_name,
|
||||||
simulation_type="manually_temporary",
|
simulation_type="manually_temporary",
|
||||||
modify_pattern_start_time=modify_pattern_start_time,
|
modify_pattern_start_time=modify_pattern_start_time,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
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_variable_pump_pattern=modify_variable_pump_pattern,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
scheme_type="burst_analysis",
|
scheme_type="burst_analysis",
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
@@ -312,6 +316,7 @@ def flushing_analysis(
|
|||||||
valve_control: dict[str, dict] = None,
|
valve_control: dict[str, dict] = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
管道冲洗模拟
|
管道冲洗模拟
|
||||||
@@ -441,6 +446,7 @@ def flushing_analysis(
|
|||||||
modify_total_duration=modify_total_duration,
|
modify_total_duration=modify_total_duration,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
valve_control=valve_control,
|
valve_control=valve_control,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_type="flushing_analysis",
|
scheme_type="flushing_analysis",
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
@@ -469,6 +475,7 @@ def contaminant_simulation(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
污染模拟
|
污染模拟
|
||||||
@@ -591,6 +598,7 @@ def contaminant_simulation(
|
|||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
scheme_detail=scheme_detail,
|
scheme_detail=scheme_detail,
|
||||||
persist_results=persist_results,
|
persist_results=persist_results,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
return result if not persist_results else None
|
return result if not persist_results else None
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
|
"sha256": "c31e9b4c71216951d1525acf1bcb8aeba4158b6cdc6b0b9dc01be02f9b6e6af3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3537,6 +3537,81 @@
|
|||||||
"title": "InvestigationDataGap",
|
"title": "InvestigationDataGap",
|
||||||
"type": "object"
|
"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": {
|
"InvestigationDiagram": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3739,10 +3814,18 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Label",
|
|
||||||
"type": "string"
|
"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": {
|
"map_targets": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/InvestigationMapTarget"
|
"$ref": "#/components/schemas/InvestigationMapTarget"
|
||||||
@@ -3777,8 +3860,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"node_id",
|
"node_id"
|
||||||
"label"
|
|
||||||
],
|
],
|
||||||
"title": "InvestigationGraphNodeUpdate",
|
"title": "InvestigationGraphNodeUpdate",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -3900,7 +3982,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"maxLength": 20000,
|
"description": "用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号",
|
||||||
|
"maxLength": 300,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Summary",
|
"title": "Summary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -4103,18 +4186,22 @@
|
|||||||
"description": "Write model: every new report explicitly declares its business result.",
|
"description": "Write model: every new report explicitly declares its business result.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"conclusion_boundary": {
|
"conclusion_boundary": {
|
||||||
|
"description": "只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数",
|
||||||
|
"maxLength": 240,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Conclusion Boundary",
|
"title": "Conclusion Boundary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"current_conclusion": {
|
"current_conclusion": {
|
||||||
|
"description": "先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table",
|
||||||
|
"maxLength": 300,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Current Conclusion",
|
"title": "Current Conclusion",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"data_gaps": {
|
"data_gaps": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/InvestigationDataGap"
|
"$ref": "#/components/schemas/InvestigationDataGapInput"
|
||||||
},
|
},
|
||||||
"title": "Data Gaps",
|
"title": "Data Gaps",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
@@ -4147,6 +4234,7 @@
|
|||||||
},
|
},
|
||||||
"pending_items": {
|
"pending_items": {
|
||||||
"items": {
|
"items": {
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4155,6 +4243,7 @@
|
|||||||
},
|
},
|
||||||
"recommendations": {
|
"recommendations": {
|
||||||
"items": {
|
"items": {
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4189,6 +4278,7 @@
|
|||||||
"stop_reason": {
|
"stop_reason": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4199,6 +4289,8 @@
|
|||||||
"title": "Stop Reason"
|
"title": "Stop Reason"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
|
"description": "简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳",
|
||||||
|
"maxLength": 80,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5160,7 +5252,8 @@
|
|||||||
"access_approval_pending",
|
"access_approval_pending",
|
||||||
"action_approval_pending",
|
"action_approval_pending",
|
||||||
"human_input_required",
|
"human_input_required",
|
||||||
"external_coordination_failed"
|
"external_coordination_failed",
|
||||||
|
"work_order_feedback"
|
||||||
],
|
],
|
||||||
"title": "Kind",
|
"title": "Kind",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5295,6 +5388,26 @@
|
|||||||
"minimum": 1.0,
|
"minimum": 1.0,
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"work_order_action": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/HumanApprovalAction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"work_order_reference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/WorkOrderNotificationReference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7431,7 +7544,8 @@
|
|||||||
"metric": {
|
"metric": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"pressure",
|
"pressure",
|
||||||
"flow"
|
"flow",
|
||||||
|
"quality"
|
||||||
],
|
],
|
||||||
"title": "Metric",
|
"title": "Metric",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -9712,6 +9826,7 @@
|
|||||||
"cancel_task_run",
|
"cancel_task_run",
|
||||||
"run_simulation",
|
"run_simulation",
|
||||||
"run_contaminant_simulation",
|
"run_contaminant_simulation",
|
||||||
|
"run_contaminant_source_trace",
|
||||||
"analyze_valve_closure",
|
"analyze_valve_closure",
|
||||||
"analyze_flushing",
|
"analyze_flushing",
|
||||||
"analyze_pressure_regulation",
|
"analyze_pressure_regulation",
|
||||||
@@ -10129,6 +10244,30 @@
|
|||||||
],
|
],
|
||||||
"title": "Command"
|
"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": {
|
"request_id": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Request Id",
|
"title": "Request Id",
|
||||||
@@ -10207,11 +10346,34 @@
|
|||||||
"title": "Execution Generation",
|
"title": "Execution Generation",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"narration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TaskPresentationNarration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"occurred_at": {
|
"occurred_at": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"title": "Occurred At",
|
"title": "Occurred At",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"purpose": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 160,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Purpose"
|
||||||
|
},
|
||||||
"run_id": {
|
"run_id": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Run Id",
|
"title": "Run Id",
|
||||||
@@ -10269,6 +10431,125 @@
|
|||||||
"title": "TaskPresentationHistoryPage",
|
"title": "TaskPresentationHistoryPage",
|
||||||
"type": "object"
|
"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": {
|
"TaskQuestion": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -11130,6 +11411,53 @@
|
|||||||
"title": "WorkOrderDraftUpdateCommand",
|
"title": "WorkOrderDraftUpdateCommand",
|
||||||
"type": "object"
|
"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": {
|
"WorkbenchAlertSource": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -221,7 +221,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"pressure",
|
"pressure",
|
||||||
"flow"
|
"flow",
|
||||||
|
"quality"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
order[爆管上报工单] --> context[读取原工单与当前任务]
|
order[爆管上报工单] --> context[读取原工单与当前任务]
|
||||||
context --> asset[核对地点并关联管网资产]
|
context --> asset[核对地点并关联管网资产]
|
||||||
asset --> isolation[分析隔离边界与阀门方案]
|
asset --> interim[保存定位阶段报告]
|
||||||
isolation --> impact[说明事件与隔离方案影响]
|
interim --> approval[立即发起工单草稿、人工审核派发]
|
||||||
impact --> report[保存阶段报告与处置建议]
|
interim --> burst[爆管模拟与同刻在线压力差]
|
||||||
report --> approval[人工审批与现场执行]
|
burst --> coverage[保存并展示爆管后、处置前影响覆盖]
|
||||||
|
coverage --> isolation[分析隔离边界与阀门方案]
|
||||||
|
isolation --> report[补充阶段报告与处置建议]
|
||||||
approval --> feedback[接收实际处置与恢复反馈]
|
approval --> feedback[接收实际处置与恢复反馈]
|
||||||
feedback --> verify[复核影响与结案条件]
|
feedback --> verify[复核影响与结案条件]
|
||||||
verify --> close[Server 完成任务并解除告警]
|
verify --> close[Server 完成任务并解除告警]
|
||||||
@@ -20,10 +22,11 @@ flowchart TD
|
|||||||
|
|
||||||
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
||||||
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
|
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
|
||||||
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。
|
||||||
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
|
4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
||||||
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。
|
||||||
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
||||||
|
7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
||||||
|
|
||||||
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||||
|
|
||||||
@@ -64,3 +67,19 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
|
|||||||
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
||||||
|
|
||||||
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。
|
关联契约:[分析 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个展示节点。
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ Server 将阅读进展、业务报告和浏览器展示请求分别保存。运
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`graph_update` 可省略。
|
示例中的编号由实际任务和资产替换。新发布的 `summary` 最多 300 字符,建议 120 字以内,
|
||||||
|
只说明本轮新增发现、当前依赖和下一步;使用中文业务语言与当地时间,不复制报告或执行参数。
|
||||||
|
详细结果和引用留在图节点、证据与报告明细中,历史正文仍按原值读取。`graph_update` 可省略。
|
||||||
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
||||||
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
|
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
|
||||||
|
|
||||||
@@ -55,6 +57,23 @@ Server 不验证依赖执行顺序或推断节点完工。
|
|||||||
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
|
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
|
||||||
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
|
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
|
||||||
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
|
`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`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
|
尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
|
||||||
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
|
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
|
||||||
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
|
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
|
||||||
@@ -90,6 +109,33 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
|||||||
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
|
响应为 `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 | 参数 |
|
| command.action | 参数 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| locate_features | layer、feature_ids |
|
| locate_features | layer、feature_ids |
|
||||||
@@ -236,3 +282,12 @@ git diff --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
|
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
|
||||||
|
|
||||||
|
|
||||||
|
## 工单通知与历史展示
|
||||||
|
|
||||||
|
工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。
|
||||||
|
|
||||||
|
前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。
|
||||||
|
|
||||||
|
历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。
|
||||||
|
|||||||
@@ -9,17 +9,19 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 新写入规则 |
|
| 字段 | 类型 | 新写入规则 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| title | 非空字符串 | 报告标题 |
|
| title | 非空字符串,最多 80 字符 | 简短中文业务标题,建议 30 字以内,不包含执行轮次和流程版本 |
|
||||||
| current_conclusion | 非空字符串 | 当前判断文字;无结论时明确写无法判断,不编造原因 |
|
| current_conclusion | 非空字符串,最多 300 字符 | 首句写当前判断,接关键依据和实际下一步,建议 150 字以内;完整数值放入表格 |
|
||||||
| conclusion_boundary | 非空字符串 | 判断范围及限制 |
|
| conclusion_boundary | 非空字符串,最多 240 字符 | 影响判断的证据范围、不确定性和适用限制;不复述执行过程、参数或完整资产清单 |
|
||||||
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
|
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
|
||||||
| recommendations | 字符串数组 | 保持原类型,处置建议不等于已执行动作 |
|
| recommendations | 字符串数组,每项最多 160 字符 | 具体对象与后续动作;处置建议不等于已执行动作 |
|
||||||
| pending_items | 字符串数组 | 保持原类型,不从文字推断状态 |
|
| pending_items | 字符串数组,每项最多 160 字符 | 实际尚未完成或等待外部条件的事项,不从文字推断状态 |
|
||||||
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
|
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
|
||||||
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
|
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
|
||||||
| stop_reason | 非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
| stop_reason | 最多 160 字符的非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
||||||
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
|
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
|
||||||
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
|
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
|
||||||
|
| ui_blocks | 结果组件数组,最多 8 块、合计 400 KB | 以现有 Result、Section、Table 等组件表达重点与明细,块内引用必须属于报告证据 |
|
||||||
|
| scada_device_ids | 设备编号数组,最多 1000 项 | 监测明细与展示目标的完整集合,不能因精简摘要删除目标 |
|
||||||
|
|
||||||
缺口对象除 node_id 外的字段必填:
|
缺口对象除 node_id 外的字段必填:
|
||||||
|
|
||||||
@@ -27,10 +29,10 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
|
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
|
||||||
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
|
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
|
||||||
| missing_data | 非空字符串 | 缺失数据内容 |
|
| missing_data | 非空字符串,最多 4000 字符 | 缺失的业务资料 |
|
||||||
| scope | 非空字符串 | 查询对象、地点及时间范围 |
|
| scope | 非空字符串,最多 4000 字符 | 查询对象、地点及当地时间范围 |
|
||||||
| impact | 非空字符串 | 因此无法回答的调查问题 |
|
| impact | 非空字符串,最多 4000 字符 | 因此无法回答的调查问题 |
|
||||||
| required_data | 非空字符串 | 新一轮调查需要补齐的具体数据 |
|
| required_data | 非空字符串,最多 4000 字符 | 新一轮调查需要补齐的具体数据 |
|
||||||
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
|
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
|
||||||
| blocking | boolean | 是否阻止形成结论 |
|
| blocking | boolean | 是否阻止形成结论 |
|
||||||
|
|
||||||
@@ -42,6 +44,48 @@
|
|||||||
不包含 Markdown 围栏、初始化指令和 click 命令。前端使用 strict 安全级别渲染 SVG。
|
不包含 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_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
||||||
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
|
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
|
||||||
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
||||||
@@ -133,7 +177,7 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
|||||||
"gap_id": "terminal-sample-gap",
|
"gap_id": "terminal-sample-gap",
|
||||||
"node_id": null,
|
"node_id": null,
|
||||||
"missing_data": "投诉时段末梢水样检测结果",
|
"missing_data": "投诉时段末梢水样检测结果",
|
||||||
"scope": "投诉小区,2026-09-24 08:00 至 09:00(北京时间)",
|
"scope": "投诉小区,2026-09-24 08:00 至 09:00",
|
||||||
"impact": "无法判断异常来自供水管网还是用户端。",
|
"impact": "无法判断异常来自供水管网还是用户端。",
|
||||||
"required_data": "该地点和时段的采样检测记录",
|
"required_data": "该地点和时段的采样检测记录",
|
||||||
"evidence_refs": ["sample-query-empty"],
|
"evidence_refs": ["sample-query-empty"],
|
||||||
@@ -150,6 +194,10 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
|||||||
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
|
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
|
||||||
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
|
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
|
||||||
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
|
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
|
||||||
|
- 新发布拒绝超长摘要、正文执行字段、完整技术编号、原始时间表达和未支持的格式标记;
|
||||||
|
相同历史报告仍可读取,结构化证据引用与带偏移的采样时间保持不变。
|
||||||
|
- 生成结果结论在前、明细入表,明确单位、时间和各自覆盖范围;
|
||||||
|
候选、现场反馈、单帧复核与长期监测保持各自含义,进展和图示不与当前事实冲突。
|
||||||
|
|
||||||
## 发布、阅读与协作
|
## 发布、阅读与协作
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
|
|||||||
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
|
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
|
| `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_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
|
||||||
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
|
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
|
||||||
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
|
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
|
||||||
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
| `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_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` |
|
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
||||||
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
|
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 调查验收。
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
name: burst-event-handling
|
name: burst-event-handling
|
||||||
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.3"
|
version: "1.1.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管事件处理
|
# 爆管事件处理
|
||||||
|
|
||||||
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
||||||
|
|
||||||
先按handling_context接续:等待阶段按第6节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
||||||
|
|
||||||
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
||||||
|
|
||||||
@@ -23,30 +23,40 @@ metadata:
|
|||||||
|
|
||||||
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
|
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 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 评估替代边界;现场可操作性列为执行前核验条件。
|
对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。
|
||||||
|
|
||||||
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
|
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
|
||||||
|
|
||||||
## 4. 影响说明
|
## 5. 影响说明
|
||||||
|
|
||||||
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
|
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
|
||||||
|
|
||||||
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
|
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
|
||||||
- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。
|
- **隔离方案影响**:分别说明 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字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第7节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
||||||
|
|
||||||
## 6. 反馈与闭环复核
|
## 7. 反馈与闭环复核
|
||||||
|
|
||||||
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.16"
|
version: "1.0.19"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SCADA 数据异常调查
|
# SCADA 数据异常调查
|
||||||
@@ -11,17 +11,17 @@ metadata:
|
|||||||
|
|
||||||
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
||||||
|
|
||||||
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
阅读时间按 Asia/Shanghai 换算,已有 +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和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
通过 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局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
|
全量devices含完整目录,readings含观测,coverage含各设备读数数量、缺测及冻结范围外清单。不得由代表测点推断全网。单查list_scada_devices默认每页100,读至page.has_more=false,属性缩略不代表完整信息。全量后可按device_ids局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
|
||||||
|
|
||||||
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
核对模型得分、压力变化与出厂边界流量的联合证据;权重、门槛、阈值、测点与基线留在依据。得分越低越异常,相似度非发生概率。区分边界与其他管流测点,规则零命中不代表无流量变化;其他异常按证据选择原因。
|
||||||
|
|
||||||
## 候选定位与隔离
|
## 候选定位与隔离
|
||||||
|
|
||||||
@@ -29,7 +29,11 @@ metadata:
|
|||||||
|
|
||||||
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
||||||
|
|
||||||
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
取得候选即展示管道,发 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 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
||||||
|
|
||||||
## 对比展示与影响
|
## 对比展示与影响
|
||||||
|
|
||||||
@@ -45,9 +49,9 @@ 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),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
|
||||||
|
|
||||||
## 完工后的恢复复核
|
## 完工后的恢复复核
|
||||||
|
|
||||||
@@ -55,4 +59,6 @@ metadata:
|
|||||||
|
|
||||||
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
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仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
恢复观测由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不自动认定实验室合格或授权现场复供。
|
||||||
@@ -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)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
||||||
from app.domain.domain_errors import DomainError
|
from app.domain.domain_errors import DomainError
|
||||||
|
|||||||
@@ -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,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}
|
||||||
@@ -80,6 +80,78 @@ async def test_failed_attempt_cannot_be_relabelled_as_a_success():
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
@pytest.mark.anyio
|
||||||
async def test_new_execution_round_continues_the_same_task_graph():
|
async def test_new_execution_round_continues_the_same_task_graph():
|
||||||
task, repo, service = setup_service()
|
task, repo, service = setup_service()
|
||||||
|
|||||||
@@ -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"
|
START = "2026-10-08T12:00:00+08:00"
|
||||||
END = "2026-10-08T13:00:00+08:00"
|
END = "2026-10-08T13:00:00+08:00"
|
||||||
INPUTS = {
|
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_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},
|
"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"]},
|
"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 args.timeout_seconds <= 240
|
||||||
assert row['parameters']['timeout_seconds'] == 3600
|
assert row['parameters']['timeout_seconds'] == 3600
|
||||||
authorize.assert_awaited_once()
|
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():
|
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
|
||||||
assert not any("health" in name for name in TOOLS)
|
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
|
assert sum(t.mutating for t in TOOLS.values()) == 13
|
||||||
for tool in TOOLS.values():
|
for tool in TOOLS.values():
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
|
|||||||
@@ -34,3 +34,32 @@ def test_missing_geometry_does_not_offer_a_broken_overlay_command():
|
|||||||
data={"result": {"coverage": {"status": "unavailable"}}},
|
data={"result": {"coverage": {"status": "unavailable"}}},
|
||||||
)
|
)
|
||||||
assert saved_presentation_preview(result) is None
|
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()))
|
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):
|
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
|
||||||
app, client, _, _ = runtime
|
app, client, _, _ = runtime
|
||||||
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.mcp.work_order_skills import read_work_order_skill
|
|||||||
("water_quality_complaint", "water-quality-complaint"),
|
("water_quality_complaint", "water-quality-complaint"),
|
||||||
("general", "appointment-follow-up"),
|
("general", "appointment-follow-up"),
|
||||||
("scada_anomaly", "scada-anomaly"),
|
("scada_anomaly", "scada-anomaly"),
|
||||||
|
("water_pollution", "water-pollution-event"),
|
||||||
])
|
])
|
||||||
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
||||||
descriptor = read_work_order_skill(workflow)
|
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", "只发布回放成果"))
|
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()
|
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.reads, "task_context_summary", reader)
|
||||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
||||||
scope = SimpleNamespace(connection=object(), project=object())
|
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")
|
body = query("skill")
|
||||||
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
||||||
assert summary.data["display_time_zone"] == "Asia/Shanghai"
|
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 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"
|
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)
|
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
||||||
binding.permission_cap = []
|
binding.permission_cap = []
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ def observations():
|
|||||||
return rows
|
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):
|
def evaluate(rows):
|
||||||
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
||||||
incident_at=INCIDENT, interval_seconds=900)
|
incident_at=INCIDENT, interval_seconds=900)
|
||||||
|
|||||||
@@ -675,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))
|
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
|
||||||
assert again.task.execution_generation == 2
|
assert again.task.execution_generation == 2
|
||||||
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
|
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")
|
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||||
with pytest.raises(DomainError) as cancelled:
|
with pytest.raises(DomainError) as cancelled:
|
||||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
@@ -2350,3 +2363,77 @@ async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation
|
|||||||
await getattr(other, method)(task["task_id"], command)
|
await getattr(other, method)(task["task_id"], command)
|
||||||
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
assert repository.task == before
|
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,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)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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 app.mcp import analysis_service, query_service
|
||||||
|
from app.mcp.analysis_models import FlushingInput
|
||||||
|
from app.mcp.calculation_jobs import should_queue
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def parameters(**changes):
|
||||||
|
return FlushingInput.model_validate({"context_handle": "a" * 43,
|
||||||
|
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
||||||
|
"output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 12,
|
||||||
|
"opened_valves": ["V-in"], "closed_valves": ["V-user"], **changes})
|
||||||
|
|
||||||
|
|
||||||
|
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="owned", project_role="member"), user=SimpleNamespace(username="actor"),
|
||||||
|
simulation_routing=routing)
|
||||||
|
binding = SimpleNamespace(tool_cap=["analyze_flushing"], 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_flushing_uses_the_model_only_adapter_and_retains_prediction_boundary(monkeypatch):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
worker = AsyncMock(return_value={"flushing_plan": {"water_quality_clearance_verified": False}})
|
||||||
|
monkeypatch.setattr(analysis_service, "calculate_replay_flushing", worker)
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["analyze_flushing"], parameters()))
|
||||||
|
assert result.origin == "synthetic" and "预测" in result.data_gaps[0]
|
||||||
|
assert result.data["result"]["flushing_plan"]["water_quality_clearance_verified"] is False
|
||||||
|
assert result.data["parameters"]["opened_valves"] == ["V-in"]
|
||||||
|
scope.connection.commit.assert_awaited_once()
|
||||||
|
worker.assert_awaited_once()
|
||||||
|
assert worker.await_args.kwargs == {"network": "owned", "username": "actor"}
|
||||||
|
assert should_queue(TOOLS["analyze_flushing"], parameters())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
|
||||||
|
def test_replay_flushing_preserves_time_permissions_and_reader_boundaries(monkeypatch, problem):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
args = parameters()
|
||||||
|
if problem == "future_start": args = parameters(start_time="2026-07-16T08:15:00+08:00")
|
||||||
|
if problem == "reader": binding.purpose = "task_reader"
|
||||||
|
if problem == "permission": binding.permission_cap.remove("scada.view")
|
||||||
|
if problem == "tool_cap": binding.tool_cap.clear()
|
||||||
|
with pytest.raises(HTTPException) as caught:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["analyze_flushing"], args))
|
||||||
|
assert caught.value.status_code == (422 if problem == "future_start" else 403)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_only_flushing_and_native_runner_both_receive_empty_scada_mappings(monkeypatch):
|
||||||
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
|
||||||
|
captures = []
|
||||||
|
native = scenarios.flushing_analysis.__wrapped__
|
||||||
|
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"})
|
||||||
|
|
||||||
|
def isolated(**kwargs):
|
||||||
|
captures.append(kwargs)
|
||||||
|
return native(**kwargs, _temporary_project="temporary")
|
||||||
|
|
||||||
|
monkeypatch.setattr(scenarios, "flushing_analysis", isolated)
|
||||||
|
monkeypatch.setattr(scenarios, "is_junction", lambda *_: True)
|
||||||
|
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
|
||||||
|
monkeypatch.setattr(scenarios, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "00:15:00"})
|
||||||
|
monkeypatch.setattr(scenarios, "get_demand", lambda *_: {"demands": []})
|
||||||
|
for name in ("add_pattern", "set_demand", "set_option"):
|
||||||
|
monkeypatch.setattr(scenarios, name, lambda *_: None)
|
||||||
|
monkeypatch.setattr(scenarios.simulation, "run_simulation", lambda **kw: captures.append(kw) or {
|
||||||
|
"node_results": [], "link_results": []})
|
||||||
|
output = asyncio.run(analysis_service.calculate_replay_flushing(parameters(), network="owned", username="actor"))
|
||||||
|
assert len(captures) == 2
|
||||||
|
assert captures[0]["scada_mappings"] is captures[1]["scada_mappings"]
|
||||||
|
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
|
||||||
|
assert captures[1]["name"] == "temporary" and captures[1]["persist_results"] is False
|
||||||
|
assert output["flushing_plan"]["boundary_source"] == "model_only_no_live_scada"
|
||||||
@@ -85,6 +85,25 @@ async def investigation():
|
|||||||
return task, repository, service, evidence.evidence_id
|
return task, repository, service, evidence.evidence_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pollution_charts_validate_quality_values_and_keep_their_own_workflow():
|
||||||
|
from app.services.scada_presentation_coverage import add_targets, applies, ScadaObservations, verify_chart
|
||||||
|
task, repository, _, evidence_id = await investigation()
|
||||||
|
task["workflow"] = "water_pollution"
|
||||||
|
evidence = repository.evidence[evidence_id]
|
||||||
|
for device in evidence["data_snapshot"]["data"]["data"]["devices"]:
|
||||||
|
device.update(device_type="quality", measurement_unit="mg/L")
|
||||||
|
value = chart(devices=[DEVICES[0]])
|
||||||
|
value["scada"].update(metric="quality", unit="mg/L")
|
||||||
|
assert applies(task)
|
||||||
|
verified = verify_chart(task, value, [evidence], [evidence_id])
|
||||||
|
assert verified["metric"] == "quality" and verified["gaps"] == []
|
||||||
|
observations = ScadaObservations(task, [evidence])
|
||||||
|
assert add_targets(None, [DEVICES[0]], observations)["targets"] == [DEVICES[0]]
|
||||||
|
task["handling_context"] = {"phase": "field_review"}
|
||||||
|
assert not applies(task)
|
||||||
|
|
||||||
|
|
||||||
def coverage(comparison_devices):
|
def coverage(comparison_devices):
|
||||||
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
|
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
|
||||||
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
|
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
|
||||||
|
|||||||
@@ -603,15 +603,20 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
|
|||||||
|
|
||||||
command = create_task.await_args.args[0]
|
command = create_task.await_args.args[0]
|
||||||
assert command.workflow == "scada_anomaly"
|
assert command.workflow == "scada_anomaly"
|
||||||
|
assert command.title == "SCADA 异常调查 · 2026-10-08 08:15"
|
||||||
|
assert "2 个压力测点下降" in command.summary
|
||||||
|
assert "1 个出厂边界流量测点增加" in command.summary
|
||||||
|
assert "爆管嫌疑" in command.summary
|
||||||
assert command.source.replay_context.batch_sequence == 8
|
assert command.source.replay_context.batch_sequence == 8
|
||||||
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
||||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||||
assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}"
|
assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}"
|
||||||
assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。"
|
assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。"
|
||||||
if weighted:
|
if weighted:
|
||||||
assert "-0.0843" in command.summary
|
assert "-0.0843" not in command.summary
|
||||||
assert "综合评分 0.90" in command.summary
|
assert "综合评分" not in command.summary
|
||||||
assert "流量 10%" in command.summary
|
assert "10%" not in command.summary
|
||||||
|
assert monitoring["weighted"]["score"] == 0.9
|
||||||
|
|
||||||
|
|
||||||
def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch):
|
def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch):
|
||||||
@@ -807,6 +812,60 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("create_tasks", [False, True])
|
||||||
|
def test_pollution_detection_creates_own_investigation_even_during_period_diagnosis(monkeypatch, create_tasks):
|
||||||
|
from app.services.scada_normal_profile import diagnostic_signal, pollution_signal
|
||||||
|
from app.services.scada_replay_processing import detect_frame
|
||||||
|
|
||||||
|
_, service, *_ = setup(monkeypatch)
|
||||||
|
push = AsyncMock()
|
||||||
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
|
advance = AsyncMock()
|
||||||
|
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
|
||||||
|
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
|
||||||
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
||||||
|
lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance))
|
||||||
|
config = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
|
||||||
|
device_ids=["Q1"], rules=[{"rule_id": "chlorine", "device_ids": ["Q1"],
|
||||||
|
"kind": "bounds", "lower": 0.1, "unit": "mg/L", "metric_name": "余氯"}])
|
||||||
|
document = {"replay_id": str(uuid4()), "config": config.model_dump(mode="json"),
|
||||||
|
"devices": [{"device_id": "Q1", "device_type": "quality", "transmission_frequency": "0:15:00"}]}
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
nonlocal document
|
||||||
|
# One continuous quality episode creates exactly one task, without any
|
||||||
|
# pressure roles, burst score or complaint.
|
||||||
|
for step, value in enumerate((0.05, 0.04, 0.2, 0.3)):
|
||||||
|
at = START + timedelta(minutes=15 * step)
|
||||||
|
document, changes = detect_frame(document, [{"device_id": "Q1", "time": at.isoformat(),
|
||||||
|
"quality": "valid", "cleaned_value": value, "monitored_value": value}], config, at)
|
||||||
|
for event in changes:
|
||||||
|
assert pollution_signal(document, event)
|
||||||
|
assert not diagnostic_signal(document, event)
|
||||||
|
await ScadaReplayService._create_investigations(service, document, changes,
|
||||||
|
{"sequence": step + 1, "at": at.isoformat()}, create_tasks=create_tasks)
|
||||||
|
create_task.assert_awaited_once()
|
||||||
|
command = create_task.await_args.args[0]
|
||||||
|
assert command.workflow == command.source.event_kind == "water_pollution"
|
||||||
|
assert command.task_type == "event_investigation"
|
||||||
|
assert command.source.replay_context.batch_sequence == 1
|
||||||
|
assert command.trigger.kind == "event"
|
||||||
|
assert "投诉" not in command.title and "爆管" not in command.summary
|
||||||
|
assert [call.args[0].event_type for call in push.await_args_list] == ["raised", "resolved"]
|
||||||
|
assert all(call.args[0].condition_id == f"operational-task:{create_task.return_value.task_id}"
|
||||||
|
for call in push.await_args_list)
|
||||||
|
advance.assert_awaited_once()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_quality_data_is_not_a_pollution_detection():
|
||||||
|
from app.services.scada_normal_profile import pollution_signal
|
||||||
|
document = {"devices": [{"device_id": "Q1", "device_type": "quality"}]}
|
||||||
|
assert not pollution_signal(document, {"category": "data_quality", "device_ids": ["Q1"]})
|
||||||
|
assert not pollution_signal(document, {"device_ids": ["unknown"]})
|
||||||
|
|
||||||
|
|
||||||
def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch):
|
def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch):
|
||||||
repo, service, _, _, create = setup(monkeypatch)
|
repo, service, _, _, create = setup(monkeypatch)
|
||||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
@@ -862,8 +921,10 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
|
|||||||
command = create_task.await_args.args[0]
|
command = create_task.await_args.args[0]
|
||||||
assert command.workflow == "scada_anomaly"
|
assert command.workflow == "scada_anomaly"
|
||||||
assert command.source.event_kind == "scada_anomaly"
|
assert command.source.event_kind == "scada_anomaly"
|
||||||
assert "任一指标异常即启动调查" in command.summary
|
assert "已启动调查" in command.summary
|
||||||
assert "爆管嫌疑门槛 0.80" in command.summary
|
assert "异常原因及影响范围待核查" in command.summary
|
||||||
|
assert "爆管嫌疑门槛" not in command.summary
|
||||||
|
assert monitoring["weighted"]["score"] == 0.1
|
||||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||||
assert monitoring["incident"]["action"] == "raised"
|
assert monitoring["incident"]["action"] == "raised"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user