Compare commits

...
Author SHA1 Message Date
jiang 105c1ca8ab fix: make burst impact coverage envelopes exclusive
Generic Container CI/CD / test-build-publish (push) Successful in 4m21s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m21s
2026-11-15 16:06:41 +08:00
jiang 4b47ae00d1 fix(investigation): persist large results and require graph links
Generic Container CI/CD / test-build-publish (push) Successful in 4m15s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m15s
Full-network pressure comparisons exceeded the worker limit before saved-result previews could run. Preserve complete calculation results and keep read-time summaries and pagination.

Incremental graph publication accepted isolated nodes after flow-authoring rules were lost. Require new steps to connect explicitly while retaining historical snapshots and status-only patches. Regression tests reproduce both failures on the previous code.
2026-11-15 13:51:43 +08:00
jiang d3a4433386 test(replay): cover exploratory simulations without impact maps
Generic Container CI/CD / test-build-publish (push) Successful in 4m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m10s
2026-11-15 12:44:06 +08:00
jiang 747e9e53e0 fix(investigation): allow autonomous model baseline recovery
Generic Container CI/CD / test-build-publish (push) Failing after 2m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m11s
2026-11-15 12:37:08 +08:00
jiang 58a907bfbe fix(recovery): scope incident verification to relevant devices
Generic Container CI/CD / test-build-publish (push) Successful in 4m33s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m33s
Full-network browsing expanded persisted recovery gates to unrelated devices, while full fifteen-day reference reads exhausted the row budget. Rebuild legacy cached scopes from report devices and project monitoring roles, and query only the nearest post-feedback frame and its historical phase.

Regression tests fail before the fix for both new and cached scopes and verify automatic Agent requeue after recovery.
2026-11-15 03:47:55 +08:00
jiang 11eb09fe8f fix(burst): recognize native pressure units and separate isolation impact
Generic Container CI/CD / test-build-publish (push) Successful in 4m37s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m37s
2026-11-14 23:45:25 +08:00
jiang b45c5c6f46 feat(investigations): refine report authoring and replay flushing
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s
2026-11-14 23:00:15 +08:00
jiang 2e4f6cb93b feat(investigations): add event impact and work-order feedback flows
Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates.

Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
2026-11-14 15:14:02 +08:00
74 changed files with 3592 additions and 200 deletions
+1
View File
@@ -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',
+130 -4
View File
@@ -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
@@ -817,9 +862,18 @@ class TaskHighlightFeatures(StrictModel):
class TaskLocateFeatures(StrictModel): class TaskLocateFeatures(StrictModel):
action: Literal["locate_features"] action: Literal["locate_features"]
event_kind: Literal["burst", "pollution"] | None = Field(default=None,
description="定位事件语义:爆管候选传 burst,污染源传 pollution;普通资产定位省略")
layer: TaskMapLayer layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
@model_serializer(mode="wrap")
def serialize_optional_kind(self, handler):
value = handler(self)
if self.event_kind is None:
value.pop("event_kind", None)
return value
class TaskZoomToMap(StrictModel): class TaskZoomToMap(StrictModel):
action: Literal["zoom_to_map"] action: Literal["zoom_to_map"]
@@ -904,11 +958,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 +1054,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 +1132,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 +1146,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 +1155,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"]
+73
View File
@@ -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"])
@@ -143,9 +143,12 @@ class ScadaRepository:
await cur.execute( await cur.execute(
"WITH next_frame AS (SELECT min(time) AS time FROM scada.measurements " "WITH next_frame AS (SELECT min(time) AS time FROM scada.measurements "
"WHERE device_id=ANY(%s) AND time>%s AND time<=%s), " "WHERE device_id=ANY(%s) AND time>%s AND time<=%s), "
"reference AS (SELECT time,device_id,monitored_value,cleaned_value " "reference AS (SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value "
"FROM scada.measurements WHERE device_id=ANY(%s) AND time>=%s AND time<%s " "FROM scada.measurements m CROSS JOIN next_frame f "
"ORDER BY time,device_id LIMIT %s) " "WHERE m.device_id=ANY(%s) AND m.time>=%s AND m.time<%s "
"AND mod(extract(epoch FROM m.time)::bigint,86400)="
"mod(extract(epoch FROM f.time)::bigint,86400) "
"ORDER BY m.time,m.device_id LIMIT %s) "
"SELECT time,device_id,monitored_value,cleaned_value FROM reference UNION ALL " "SELECT time,device_id,monitored_value,cleaned_value FROM reference UNION ALL "
"SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value FROM scada.measurements m " "SELECT m.time,m.device_id,m.monitored_value,m.cleaned_value FROM scada.measurements m "
"JOIN next_frame f ON m.time=f.time WHERE m.device_id=ANY(%s) ORDER BY time,device_id", "JOIN next_frame f ON m.time=f.time WHERE m.device_id=ANY(%s) ORDER BY time,device_id",
+34 -2
View File
@@ -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)]
+36
View File
@@ -114,3 +114,39 @@ async def calculate_replay_burst_location(
final_candidates_csv_path=None, final_candidates_csv_path=None,
**parameters, **parameters,
) )
async def calculate_replay_hydraulic_scenario(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Evaluate model-based hydraulic alternatives without live SCADA substitution."""
return await bounded_calculation(
calculate_scenario, name,
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
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,
)
+13
View File
@@ -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
+6 -5
View File
@@ -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_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True), ("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True), ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,更新全网实时基线,返回指定节点和管段结果;调查发现同刻基线缺失时可自主补算,等待成功并读取结果后重试依赖分析。duration单位为秒,补单帧可用60秒;回放按当前模型补算,不读取实时SCADA,不替代冻结观测;正式分析成果通过方案工具保存", ScenarioInput, "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),
-3
View File
@@ -15,7 +15,6 @@ from app.mcp.models import BindInput, BoundContext
from app.mcp.scope import QueryScope from app.mcp.scope import QueryScope
from app.mcp.config import McpSettings from app.mcp.config import McpSettings
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from app.mcp.work_order_skills import skill_name_for_task
def task_repository(scope: QueryScope) -> OperationalTaskRepository: def task_repository(scope: QueryScope) -> OperationalTaskRepository:
@@ -32,8 +31,6 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
if not task: if not task:
raise HTTPException(404, "Task is not in this project") raise HTTPException(404, "Task is not in this project")
binding.replay_context = task.get("source", {}).get("replay_context") binding.replay_context = task.get("source", {}).get("replay_context")
if skill_name_for_task(task.get("workflow"), binding.replay_context) == "burst-event-handling":
binding.tool_cap = [name for name in binding.tool_cap if name != "run_burst_location"]
if binding.purpose == "task_collaboration": if binding.purpose == "task_collaboration":
return return
if OPERATIONAL_TASK_EXECUTE not in permissions: if OPERATIONAL_TASK_EXECUTE not in permissions:
+1
View File
@@ -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²;保留面积来源及估算依据")
+2 -1
View File
@@ -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)):
+35 -2
View File
@@ -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"),
+87 -7
View File
@@ -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", *SCENARIO_TOOLS,
"calculate_demand_allocation", "optimize_sensor_placement",
"query_model_baseline_series", "get_model_baseline_at_time", "query_model_baseline_field",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"} "get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed: asset_query = tool.semantics == "asset" and not tool.mutating and not tool.computation
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings") if tool.name not in allowed and not asset_query:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
if tool.name in SCENARIO_TOOLS:
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.semantics == "model_baseline":
if tool.name == "query_model_baseline_series":
check_replay_window(replay_context, args.start, args.end)
else:
check_replay_window(replay_context, args.time, args.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,50 @@ 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 in {"run_simulation", "analyze_valve_closure", "analyze_pressure_regulation"} and replay_context:
from app.mcp.analysis_service import calculate_replay_hydraulic_scenario
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_hydraulic_scenario(
name, 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_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 +307,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 +318,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 +557,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
@@ -565,6 +643,8 @@ async def execute_query(
truncated = snapshot.truncated truncated = snapshot.truncated
else: else:
raise ValueError("query handler is not registered") raise ValueError("query handler is not registered")
if replay_context and tool.semantics == "model_baseline":
gaps.append("在线模型参考按本次查询时可用数据读取,非冻结SCADA观测;历史报告复用已保存证据。")
window = ( window = (
{"start": args.start, "end": args.end} {"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None if hasattr(args, "start") and args.start is not None
+52 -8
View File
@@ -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,23 +25,34 @@ 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)
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
# Model scenarios may support replay decisions without consuming mutable
# live SCADA in place of the task's frozen observations.
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
if name == "run_simulation": if name == "run_simulation":
from app.services.simulation import run_online_simulation from app.services.simulation import run_online_simulation
result = run_online_simulation( result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links, network, start, duration, output_node_ids=nodes, output_link_ids=links, **boundary,
) )
elif name == "run_burst_simulation": elif name == "run_burst_simulation":
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":
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"]}, **boundary)
elif name == "analyze_flushing": elif name == "analyze_flushing":
# The service consumes m³/h (the older REST label incorrectly says L/s). # The service consumes m³/h (the older REST label incorrectly says L/s).
# Reject unsupported conversion rather than silently ignore the flow. # Reject unsupported conversion rather than silently ignore the flow.
@@ -43,7 +61,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
raise ValueError("Flushing currently supports LPS or CMH models only") raise ValueError("Flushing currently supports LPS or CMH models only")
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)
@@ -55,14 +73,15 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
result = scenarios.pressure_regulation(**common, result = scenarios.pressure_regulation(**common,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps}, modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps}, modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings) modify_tank_initial_level=parameters["tank_init_level"],
scada_mappings=boundary.get("scada_mappings", mappings))
else: else:
raise ValueError("Scenario is not registered") raise ValueError("Scenario is not registered")
if not isinstance(result, dict): if not isinstance(result, dict):
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 +91,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
+1
View File
@@ -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",
+157
View File
@@ -0,0 +1,157 @@
"""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, "mtr": 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")
impact["baseline_recovery"] = {
"tool": "run_simulation",
"parameters": {
"start_time": start.isoformat(), "duration": 60, "output_node_ids": ids[:1],
},
"next_step": "自主补算同刻在线基线;等待作业成功并读取结果后,按原参数重跑爆管影响计算,再发布降压覆盖。返回节点仅用于核验,在线基线按全网写入。",
}
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"]]
# GRADES are ordered from low to high. Keep the numerical classes intact
# while removing overlap between their independently derived envelopes.
geometry = generate_node_coverage(network, groups, exclusive_groups=True)
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
geometry["coverage"]["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
+2 -4
View File
@@ -93,8 +93,8 @@ async def execute_child(input_path: str, output_path: str):
category = 'EXECUTION_CONTEXT_UNAVAILABLE' category = 'EXECUTION_CONTEXT_UNAVAILABLE'
payload = {'status': 'failed', 'error': {'code': category}} payload = {'status': 'failed', 'error': {'code': category}}
encoded = json.dumps(payload, allow_nan=False) encoded = json.dumps(payload, allow_nan=False)
if len(encoded.encode()) > 8 * 1024 * 1024: # Keep the complete calculation for persistence. MCP result reads apply
encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}}) # their own summary/page budgets after the result has been saved.
Path(output_path).write_text(encoded) Path(output_path).write_text(encoded)
@@ -212,8 +212,6 @@ class CalculationWorker:
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}} terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
elif process.returncode is not None: elif process.returncode is not None:
try: try:
if output_path.stat().st_size > 8 * 1024 * 1024:
raise ValueError('Result exceeds persistence limit')
terminal = json.loads(output_path.read_text()) terminal = json.loads(output_path.read_text())
except (OSError, ValueError): except (OSError, ValueError):
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}} terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
+112
View File
@@ -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
+34
View File
@@ -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
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
}
+31 -5
View File
@@ -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)
@@ -38,6 +39,7 @@ def update_investigation_graph(
)) ))
nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {} nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {}
saved_ids = set(nodes)
require(len({node.node_id for node in update.nodes}) == len(update.nodes), require(len({node.node_id for node in update.nodes}) == len(update.nodes),
"GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422) "GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422)
for node in update.nodes: for node in update.nodes:
@@ -47,15 +49,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"):
@@ -76,6 +82,26 @@ def update_investigation_graph(
edges.append(edge.model_copy(deep=True)) edges.append(edge.model_copy(deep=True))
require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED", require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED",
"Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422) "Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422)
# Validate only newly added steps. Historical snapshots and status-only
# patches stay valid, even if an earlier publisher omitted relationships.
new_ids = set(nodes) - saved_ids
if new_ids and len(nodes) > 1:
neighbours = {node_id: set() for node_id in nodes}
for edge in edges:
neighbours[edge.source].add(edge.target)
neighbours[edge.target].add(edge.source)
connected = set(saved_ids) or {next(iter(nodes))}
pending = list(connected)
while pending:
for node_id in neighbours[pending.pop()] - connected:
connected.add(node_id)
pending.append(node_id)
missing = sorted(new_ids - connected)
require(not missing, "GRAPH_RELATION_REQUIRED",
"Add explicit edges connecting these new steps to the investigation narrative: "
+ ", ".join(missing)
+ ". Describe branches, parallel findings or retries; edges do not schedule execution.",
status=422)
lines = [source or "flowchart TD"] lines = [source or "flowchart TD"]
for node in nodes.values(): for node in nodes.values():
if in_source(node.node_id): if in_source(node.node_id):
+51 -2
View File
@@ -6,7 +6,7 @@ from typing import Any
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
from shapely import MultiPoint, concave_hull, get_num_coordinates from shapely import MultiPoint, concave_hull, get_num_coordinates
from shapely.geometry import mapping from shapely.geometry import GeometryCollection, mapping, shape
from shapely.ops import transform, unary_union from shapely.ops import transform, unary_union
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
@@ -17,6 +17,47 @@ SIMPLIFY_METERS = 2.0
MAX_COVERAGE_NODES = 10000 MAX_COVERAGE_NODES = 10000
def exclusive_coverage_features(features: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Clip display envelopes, with later groups taking precedence; retain input order.
This changes only derived geometry, never node membership or model values.
No post-clipping buffer or simplification may reintroduce an overlap.
"""
occupied = GeometryCollection()
result = []
summaries = {}
for feature in features:
properties = feature["properties"]
summaries.update(properties.get("coverage_group_summary", {}))
for feature in features:
properties = feature["properties"]
summaries[properties["coverage_group"]] = {
key: properties.get(key) for key in ("node_count", "mapped_node_count", "missing_node_count")
}
for feature in reversed(features):
original = shape(feature["geometry"])
visible = original.difference(occupied)
occupied = unary_union([occupied, original])
if visible.is_empty:
continue
if (visible.geom_type not in {"Polygon", "MultiPolygon"} or not visible.is_valid
or get_num_coordinates(visible) > 20000):
raise ValueError("Invalid exclusive derived coverage")
result.append({
**feature,
"properties": {**feature["properties"], "overlap_policy": "higher_priority_group_first"},
"geometry": mapping(visible),
})
visible_groups = {feature["properties"]["coverage_group"] for feature in result}
summaries = {key: {**value, "display_area_available": key in visible_groups}
for key, value in summaries.items()}
for feature in result:
# Retain counts even when that group's entire display envelope was
# clipped away. Overlay transport deliberately sends only GeoJSON.
feature["properties"]["coverage_group_summary"] = summaries
return list(reversed(result))
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]: def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
parent = {node_id: node_id for node_id in node_ids} parent = {node_id: node_id for node_id in node_ids}
@@ -41,6 +82,7 @@ def build_node_coverage(
links: list[dict[str, Any]], *, split_by_connectivity: bool = False, links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
excluded_link_ids: list[str] | None = None, excluded_link_ids: list[str] | None = None,
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS, concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
exclusive_groups: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning.""" """Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
coordinates = {} coordinates = {}
@@ -99,6 +141,12 @@ def build_node_coverage(
} }
features.append(feature) features.append(feature)
if exclusive_groups:
features = exclusive_coverage_features(features)
visible_groups = {feature["properties"]["coverage_group"] for feature in features}
for group_id, metadata in groups_metadata.items():
metadata["display_area_available"] = group_id in visible_groups
return { return {
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values()) "status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
else "ready" if features else "empty", else "ready" if features else "empty",
@@ -106,7 +154,8 @@ def build_node_coverage(
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m, "parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
"split_by_connectivity": split_by_connectivity, "split_by_connectivity": split_by_connectivity,
"excluded_link_ids": sorted(closed), "excluded_link_ids": sorted(closed),
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"}, "simplify_m": simplify_m, "projection": "local_azimuthal_equidistant",
"exclusive_groups": exclusive_groups},
"output_crs": "EPSG:4326", "display_only": True, "output_crs": "EPSG:4326", "display_only": True,
"position_source": "gis.node_geometries", "position_source": "gis.node_geometries",
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。", "boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
+14
View File
@@ -104,3 +104,17 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
normalized["properties"]["weight"] = weight normalized["properties"]["weight"] = weight
features.append(normalized) features.append(normalized)
return {"type": "FeatureCollection", "features": features} return {"type": "FeatureCollection", "features": features}
def validate_overlay_stage(data: dict, stage: str | None) -> None:
"""An event-impact claim must be supported by the saved scenario geometry."""
if stage != "event_impact":
return
features = data.get("features", [])
require(bool(features) and all(
(feature.get("properties") or {}).get("impact_phase") in {
"after_burst_before_intervention", "after_contamination_before_intervention",
} for feature in features
), "OVERLAY_IMPACT_PHASE_MISMATCH",
"事件影响阶段须引用处置前情景覆盖;关阀及供水失源覆盖请使用 isolation_plan,不能替代不可用的事件降压结果",
status=422)
+23 -7
View File
@@ -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"):
@@ -161,13 +170,18 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else [] devices = list(dict.fromkeys(roles.pressure_device_ids + roles.outlet_flow_device_ids)) if roles else []
source = "scada_readings" source = "scada_readings"
through = service._now() through = service._now()
if context.get("device_ids"): report_revision = task.get("current_report_revision")
if (context.get("device_ids") and context.get("device_selection_version") == 2
and context.get("source_report_revision") == report_revision):
devices = context["device_ids"] devices = context["device_ids"]
interval = context.get("interval_seconds", interval) interval = context.get("interval_seconds", interval)
else: else:
# Retain the points the Agent actually inspected, including water # Full-network browsing is discovery, not a declaration that every
# quality or local incident sensors outside the project role lists. # device must recover for this incident. Prefer the report's scope.
for evidence in await service.repo.list_evidence(task["task_id"]): report = await service.repo.get_latest_report(task["task_id"])
declared = ((report or {}).get("report") or {}).get("scada_device_ids") or []
devices.extend(declared)
for evidence in ([] if declared else await service.repo.list_evidence(task["task_id"])):
saved = evidence.get("data_snapshot") or {} saved = evidence.get("data_snapshot") or {}
if saved.get("source") not in {"query_scada_readings", "scada_readings"}: if saved.get("source") not in {"query_scada_readings", "scada_readings"}:
continue continue
@@ -191,11 +205,13 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
await service.repo.lock(f"task:{task['task_id']}") await service.repo.lock(f"task:{task['task_id']}")
current = await service._task(UUID(task["task_id"])) current = await service._task(UUID(task["task_id"]))
# A new receipt, cancellation or another poll may have won the race. # A new receipt, cancellation or another poll may have won the race.
if current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}: if (current.get("handling_context") != context or current["status"] in {"cancelled", "failed"}
or current.get("current_report_revision") != report_revision):
continue continue
now = service._now() now = service._now()
if devices: if devices:
current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0) current["handling_context"].update(device_ids=devices, interval_seconds=interval or 0,
device_selection_version=2, source_report_revision=report_revision)
current["handling_context"]["last_checked_at"] = now.isoformat() current["handling_context"]["last_checked_at"] = now.isoformat()
current["handling_context"]["last_check"] = result current["handling_context"]["last_check"] = result
if result["status"] == "passed": if result["status"] == "passed":
+1 -1
View File
@@ -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 -5
View File
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
OperationalTaskCreate, OperationalTaskCreate,
OperationalTaskDetail, OperationalTaskDetail,
OperationalTaskNotification, OperationalTaskNotification,
WorkOrderNotificationReference,
OperationalTaskPermissions, OperationalTaskPermissions,
OperationalTaskSchedule, OperationalTaskSchedule,
OperationalTaskScheduleCreate, OperationalTaskScheduleCreate,
@@ -74,7 +75,7 @@ from app.domain.schemas.operational_task import (
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest from app.services.content_digest import digest
from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay, validate_overlay_stage
from app.services.operational_recovery import accept_completion, track_work_order from app.services.operational_recovery import accept_completion, track_work_order
from app.services.operational_scada_coverage import require_complete_scada_evidence from app.services.operational_scada_coverage import require_complete_scada_evidence
from app.services import scada_presentation_coverage from app.services import scada_presentation_coverage
@@ -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(
@@ -1118,9 +1124,14 @@ class OperationalTaskService:
) )
require(supplied == resolved, "OVERLAY_DATA_MISMATCH", require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
"Overlay data must match the immutable evidence snapshot", status=422) "Overlay data must match the immutable evidence snapshot", status=422)
validate_overlay_stage(resolved, command.narration.stage if command.narration else None)
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 +1269,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 +1755,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 +2196,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 +2566,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 +2576,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(
+12 -1
View File
@@ -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
+3 -3
View File
@@ -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:
+39 -46
View File
@@ -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",
+7 -3
View File
@@ -157,6 +157,7 @@ def run_online_simulation(
*, *,
output_node_ids: list[str] | None = None, output_node_ids: list[str] | None = None,
output_link_ids: list[str] | None = None, output_link_ids: list[str] | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> dict: ) -> dict:
"""Recalculate online baseline snapshots without cloning a model database.""" """Recalculate online baseline snapshots without cloning a model database."""
if start_time.tzinfo is None or duration_seconds <= 0: if start_time.tzinfo is None or duration_seconds <= 0:
@@ -166,7 +167,10 @@ def run_online_simulation(
) )
if step <= 0: if step <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
mappings = query_corresponding_element_id_and_query_id(name) mappings = (
scada_mappings if scada_mappings is not None
else query_corresponding_element_id_and_query_id(name)
)
selected = { selected = {
"node": {node: [] for node in output_node_ids or []}, "node": {node: [] for node in output_node_ids or []},
"link": {link: [] for link in output_link_ids or []}, "link": {link: [] for link in output_link_ids or []},
@@ -179,13 +183,13 @@ def run_online_simulation(
output = run_simulation( output = run_simulation(
name=name, name=name,
simulation_type="realtime", simulation_type="realtime",
modify_pattern_start_time=current.isoformat(timespec="seconds"), modify_pattern_start_time=current.isoformat(),
scada_mappings=mappings, scada_mappings=mappings,
) )
if not isinstance(output, dict): if not isinstance(output, dict):
raise RuntimeError("Online simulation did not return calculation results") raise RuntimeError("Online simulation did not return calculation results")
units = output.get("units", {}) units = output.get("units", {})
sample_times.append(current.isoformat(timespec="seconds")) sample_times.append(current.isoformat())
for kind, elements in selected.items(): for kind, elements in selected.items():
if not elements: if not elements:
continue continue
+10
View File
@@ -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,
@@ -227,6 +231,7 @@ def valve_close_analysis(
_temporary_project: str | None = None, _temporary_project: str | None = None,
persist_results: bool = True, persist_results: bool = True,
username: str = "system", username: str = "system",
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None: ) -> dict | str | None:
""" """
关阀模拟 关阀模拟
@@ -287,6 +292,7 @@ def valve_close_analysis(
result_db_name=name, result_db_name=name,
persist_results=persist_results, persist_results=persist_results,
scheme_username=username, scheme_username=username,
scada_mappings=scada_mappings,
) )
return result if not persist_results else None return result if not persist_results else None
# step 3. restore the base model # step 3. restore the base model
@@ -312,6 +318,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 +448,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 +477,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 +600,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
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb" "sha256": "9801087d40ac0e1f67871f9a1db850cbcce47614cceec0a7e8771cdf58385f53"
} }
} }
} }
+351 -7
View File
@@ -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",
@@ -9830,6 +9945,22 @@
"title": "Action", "title": "Action",
"type": "string" "type": "string"
}, },
"event_kind": {
"anyOf": [
{
"enum": [
"burst",
"pollution"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "定位事件语义:爆管候选传 burst,污染源传 pollution;普通资产定位省略",
"title": "Event Kind"
},
"feature_ids": { "feature_ids": {
"items": { "items": {
"maxLength": 128, "maxLength": 128,
@@ -10129,6 +10260,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 +10362,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 +10447,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 +11427,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": {
+9 -1
View File
@@ -7,6 +7,13 @@
"type": "string", "type": "string",
"const": "locate_features" "const": "locate_features"
}, },
"event_kind": {
"type": "string",
"enum": [
"burst",
"pollution"
]
},
"layer": { "layer": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -221,7 +228,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"pressure", "pressure",
"flow" "flow",
"quality"
] ]
}, },
"unit": { "unit": {
+36 -10
View File
@@ -2,16 +2,21 @@
工单触发的 `workflow=burst_report` 使用 [爆管事件处理 Skill](../resources/work-order-skills/burst-event-handling/SKILL.md)。Server 按持久化来源选择流程,Agent 接收同一任务的核验上下文;SCADA 异常和历史回放使用 [SCADA Skill](../resources/work-order-skills/scada-anomaly/SKILL.md)。 工单触发的 `workflow=burst_report` 使用 [爆管事件处理 Skill](../resources/work-order-skills/burst-event-handling/SKILL.md)。Server 按持久化来源选择流程,Agent 接收同一任务的核验上下文;SCADA 异常和历史回放使用 [SCADA Skill](../resources/work-order-skills/scada-anomaly/SKILL.md)。
## 固定处理路线 ## 参考处理路线
Agent围绕事件核实、影响评估和处置复核自主组织分析。图示说明常见依赖,可按证据补查、补算、调整方法或并行独立步骤;阶段名称与展示顺序不限制工具选择。
```mermaid ```mermaid
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[保存并展示爆管后、处置前影响覆盖]
interim --> isolation[分析隔离边界与阀门方案]
coverage --> report[补充阶段报告与处置建议]
isolation --> report
approval --> feedback[接收实际处置与恢复反馈] approval --> feedback[接收实际处置与恢复反馈]
feedback --> verify[复核影响与结案条件] feedback --> verify[复核影响与结案条件]
verify --> close[Server 完成任务并解除告警] verify --> close[Server 完成任务并解除告警]
@@ -20,10 +25,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 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
@@ -45,12 +51,14 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
## 工具与入口约束 ## 工具与入口约束
工单事件从上报位置开展资产关联。Agent会话策略隐藏 run_burst_location,Server在绑定与后续解析时同步移除其调用能力;普通聊天和SCADA候选定位沿用各自策略。持久计算在执行前重新校验任务绑定。 工单事件通常从上报位置关联资产;监测及模型证据需要时可自主调用run_burst_location补充定位,不按workflow隐藏分析工具。输入、角色、项目权限及任务绑定仍在执行前校验。
历史 burst_report 任务携带 replay_context 时,Server分发scada-anomaly,时序查询保持 knowledge_cutoff 与 batch_sequence,demo能力提示按冻结窗口返回。 历史 burst_report 任务携带 replay_context 时,Server分发scada-anomaly,时序查询保持 knowledge_cutoff 与 batch_sequence,demo能力提示按冻结窗口返回。
业务阶段和工单、返回时间、复核回执保存在任务文档 `handling_context` JSON 中,不增加数据库列。外围样板更新工单时可提供带时区的 `occurred_at`,同时必须填写现场反馈;该时间写入活动记录,系统的 `updated_at` 仍是实际记录时间。同步器将现场反馈时间传给调查,避免历史演示误用电脑当前时间。 业务阶段和工单、返回时间、复核回执保存在任务文档 `handling_context` JSON 中,不增加数据库列。外围样板更新工单时可提供带时区的 `occurred_at`,同时必须填写现场反馈;该时间写入活动记录,系统的 `updated_at` 仍是实际记录时间。同步器将现场反馈时间传给调查,避免历史演示误用电脑当前时间。
恢复复核范围为项目配置的压力、出厂流量监测点与最新调查报告声明的 `scada_device_ids` 的并集;全网浏览查询不自动扩大本事件的复核范围。未声明测点的旧报告保留按已有查询证据补充测点的兼容路径。读取完工时间后最近一帧,并只取该帧时刻对应的事件前 15 天同期参考,不扫描完整 15 天曲线。旧等待任务保存的测点范围会按新选择版本自动重算;相关测点缺测或仍异常时继续等待,恢复通过后才唤醒 Agent。
预约时间 `scheduled_at` 可在派单前的工单草稿和已创建工单的编辑表单中调整。实际完工反馈时间与预约安排分别记录;Agent 以实际反馈时间为恢复复核起点。新的有效现场反馈修订完工时间后,旧复核证据保留为历史记录,当前复核起点更新,重新读取严格晚于新反馈时间的最近一个 SCADA 采样时刻。只修改预约安排或其他工单信息,不改写已有现场反馈时间。 预约时间 `scheduled_at` 可在派单前的工单草稿和已创建工单的编辑表单中调整。实际完工反馈时间与预约安排分别记录;Agent 以实际反馈时间为恢复复核起点。新的有效现场反馈修订完工时间后,旧复核证据保留为历史记录,当前复核起点更新,重新读取严格晚于新反馈时间的最近一个 SCADA 采样时刻。只修改预约安排或其他工单信息,不改写已有现场反馈时间。
`POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。 `POST /operational-tasks/recovery/reconcile` 由现有 operational worker 每轮调用,按项目及任务创建边界处理等待复核的任务;SCADA 读取失败不阻断其他调查派发。复核直接读取项目时序库中完工反馈时间之后最近时刻的 SCADA,不依赖回放状态或推进回放时间。
@@ -64,3 +72,21 @@ 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模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。
回放调查可自主选择在线补算、爆管、关阀、调压、污染传播及冲洗模型计算,起算时刻不超过knowledge_cutoff,使用当前模型和空SCADA边界映射。单独探索情景不强制生成覆盖;目标包含影响范围时应启用覆盖并核对结果。同刻在线结果为可变模型参考,未来样本属于预测,均不冒充冻结观测。
基线缺失时,impact.baseline_recovery提供run_simulation补算建议。Agent可按目标用同刻短窗补算,读取成功结果并核对模型基线,再重试依赖分析;新的工具调用可创建新作业,无需改变破口参数来绕过旧结果。可选择其他有依据的方法,并说明剩余限制。
完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。
全网结果超过单次工具回执上限时,默认 `get_calculation_result` 返回标明缩略的计数与模型条件、保存证据资源链接及可发布地图命令。原始逐节点结果和几何保持完整,可通过证据资源或 `data_path=["result","impact","comparisons"]` 分页读取;不将全网计算缩为20个展示节点。
+3
View File
@@ -43,6 +43,9 @@ API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,
调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。 调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。
计算子进程的完整结果文件及 supervisor 读取不设 8MiB 整份体积上限,全网分析结果按原值保存到 `calculation_jobs.result`。
Agent 读取仍使用 `get_calculation_result` 的摘要及 `data_path/limit/offset` 分页预算;完整结果保存与单次工具消息大小分别处理。
Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。 Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。
## 排队期限与结果保留 ## 排队期限与结果保留
@@ -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,29 @@ 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,保留已有路径和引用。
新建调查图使用 `flowchart TD`,按自上而下的阅读顺序展示。多步骤图必须显式连接流程关系:
独立核查从共同问题分叉并在判断处汇总,条件分支标注条件,失败尝试连接重试或替代路线。
新增节点须通过连线接入已有调查脉络;首次发布多个节点时须形成相连的图。
未接入的新增节点返回 `GRAPH_RELATION_REQUIRED`(422),错误中列出需补关系的节点。
根据真实关系修正后重试,不按节点列表顺序自动补线;关系仅解释调查,不约束执行先后。
单个起点、仅更新已有节点、纯文字更新和已保存的历史图仍可不传连线,不重排或改写历史报告。
“保留路径”约束图的结构,不冻结已有节点的标记。`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 +115,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 +288,12 @@ git diff --check
``` ```
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。 这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
## 工单通知与历史展示
工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。
前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。
历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。
+59 -11
View File
@@ -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 -4
View File
@@ -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`,项目成员角色 |
@@ -32,7 +33,7 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
`persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。 `persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。
该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。 该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。
它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。 它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。
计算读取现有模型和对应SCADA条件,不从摘要虚构观测,也不自动补齐数据或执行现场操作。 普通计算读取现有模型及对应SCADA条件;回放计算使用当前模型和空SCADA映射,不读取实时观测。Agent可自主补算缺失模型基线,不从摘要虚构观测或执行现场操作。
爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。 爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。
@@ -80,7 +81,7 @@ HTTP 预算由宿主从目录元数据读取,Server 默认 120 秒、Agent 默
`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。 `analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。 在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新。先核对实际作业、采样与基线,再自主补算必要范围,不能假定整个窗口已回滚而盲目重提。
队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。 队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。
+1 -1
View File
@@ -16,7 +16,7 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
普通查询默认20条;目录类最多500条,显式指定设备的时序查询最多10000条、20个标识、24小时;offset 最大10000。SCADA调查首次省略 `query_scada_readings.device_ids`,由Server读取项目完整设备目录及窗口内全部授权观测,内部自动翻页,`offset` 必须为0。其余查询按问题选定对象和窗口。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。 普通查询默认20条;目录类最多500条,显式指定设备的时序查询最多10000条、20个标识、24小时;offset 最大10000。SCADA调查首次省略 `query_scada_readings.device_ids`,由Server读取项目完整设备目录及窗口内全部授权观测,内部自动翻页,`offset` 必须为0。其余查询按问题选定对象和窗口。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
单份结果超过8MiB报错;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。 同步查询单份结果超过8MiB报错;后台计算作业的完整结果保存不设此上限,读取时仍执行工具消息摘要和分页预算;超过12KB的调查工具消息默认返回证据资源引用,设备目录的精简路径见下段。完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
`list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。 `list_scada_devices` 默认每页100个、最多500个,其他集合仍沿用各自的分页默认值。设备目录超过12,000字节时,先内联本页全部设备的 `device_id`、`device_type` 和 `measurement_unit`,省略其他属性并明确标注;模型映射、坐标等通过 `get_scada_device` 核验,任务证据和幂等回执保留完整属性。此时 `truncated` 表示属性省略,设备是否还有后续页以 `page.has_more` 为准,按本页实际设备数递增 `offset`。精简目录仍超预算时沿用证据引用或缩页路径。
+75
View File
@@ -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[分区恢复建议与最终报告]
```
图示为参考路径,Agent可按风险、证据和实际依赖调整方法、顺序及并行分析。各类结果按就绪顺序展示并分别标明性质,现场动作仍须审批与派发,阶段记录不能替代执行回执。
## 侦测入口
现有 `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读取并保存证据。data.presentation.command提供基于evidence_id的地图请求;正式事件保存曲线与覆盖,历史播放复用。审批、派发可与计算并行;未终态计算保留原运行,已有工单不受模拟失败影响。
## 展示、反馈与收尾
`water-pollution-event` 1.2.1 将观测核验、候选定位、处置前传播、源头控制、用户隔离、清除冲洗、现场反馈、水质复测和恢复复核分开,作为可调整的参考路径。每次正式展示保存 `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,7 +2,7 @@
name: appointment-follow-up name: appointment-follow-up
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。 description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
metadata: metadata:
version: "2.0.4" version: "2.0.5"
--- ---
# 预约任务跟进 # 预约任务跟进
@@ -13,7 +13,7 @@ metadata:
读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。 读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。
围绕原工作目标完成必要核查: 以下为参考核查路径,围绕原工作目标自主选择方法、调整顺序并补齐可查询或推导的资料。反馈缺项先核对已有来源和证据,不因首次缺数直接停止;新增现场事实仍以实际回执为准:
1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。 1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。
2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。 2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。
@@ -2,14 +2,14 @@
name: burst-event-handling name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata: metadata:
version: "1.1.3" version: "1.1.6"
--- ---
# 爆管事件处理 # 爆管事件处理
适用于工单触发的 `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保留明确时区,跨日按转换后的日期展示。
@@ -21,32 +21,44 @@ metadata:
已有管段或节点编号时,用 get_element_properties 核对资产;已有坐标时直接使用,文字地址通过 geocode 解析后用 query_nearby_elements 获取附近资产。结合属性、道路、连接关系和现场记录确定处理对象,使用 get_node_connections 补充连接依据。 已有管段或节点编号时,用 get_element_properties 核对资产;已有坐标时直接使用,文字地址通过 geocode 解析后用 query_nearby_elements 获取附近资产。结合属性、道路、连接关系和现场记录确定处理对象,使用 get_node_connections 补充连接依据。
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。 爆管候选管段用 locate_features 传 event_kind="burst" 定位。在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
## 3. 隔离方案 上报位置与资产不足以区分候选时,可按现有监测和模型证据自主选择 run_burst_location 等补充分析;准备工具要求的输入,保留假设,不因工单来源限制分析方法。
定位到管道后立即保存 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 发布预计降压范围和分档计数、比较时刻及假设,narration.stage 用 event_impact。基线缺失时可自主用 run_simulation 补算同刻数据;等待成功并读取结果后重试依赖计算,不靠扩大破口面积修复基线缺失。其他失败按原因选择补查、修正参数或替代方法,补救后仍不可用才列具体限制。事件降压与关阀范围分别保存,展示按结果就绪顺序发布;历史播放复用保存证据。
## 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 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。 本步 narration.stage 用 isolation_plan,覆盖标题明确“关阀方案影响”。展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 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,14 +2,14 @@
name: burst-report name: burst-report
description: 爆管业务流程的历史入口索引,运行时按事件来源选择独立 Skill。 description: 爆管业务流程的历史入口索引,运行时按事件来源选择独立 Skill。
metadata: metadata:
version: "2.0.11" version: "2.0.12"
--- ---
# 爆管业务流程入口 # 爆管业务流程入口
Server 根据绑定任务的 workflow 和 replay_context 分发当前流程: Server 根据绑定任务的 workflow 和 replay_context 分发当前流程:
- 工单触发的 burst_report:使用 burst-event-handling,依次完成接收核对、地点与资产关联、隔离方案、影响说明、处置建议和反馈复核。 - 工单触发的 burst_report:使用 burst-event-handling,围绕事件核实、资产关联、影响评估、处置建议和反馈复核自主组织分析。
- SCADA 异常及带 replay_context 的历史回放任务:使用 scada-anomaly,完成冻结数据核验、诊断、候选定位与隔离影响分析。 - SCADA 异常及带 replay_context 的历史回放任务:使用 scada-anomaly,完成冻结数据核验、诊断、候选定位与隔离影响分析。
通过 get_task_context 的 section="skill" 读取实际流程,保留返回的 name、version、content_sha256。工具范围由绑定任务的服务端策略及 Agent 会话策略共同确定。 通过 get_task_context 的 section="skill" 读取实际流程,保留返回的 name、version、content_sha256。工具范围由绑定任务的服务端策略及 Agent 会话策略共同确定。
@@ -2,26 +2,26 @@
name: scada-anomaly name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata: metadata:
version: "1.0.16" version: "1.0.21"
--- ---
# SCADA 数据异常调查 # SCADA 数据异常调查
读取任务与 replay_context,核对设备、单位、异常时刻、knowledge_cutoff 和 batch_sequence。使用本轮已提交的 SCADA 观测开展调查,同一持续事件沿用已有调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。 目标是核验异常、判断原因和影响并形成处置依据。下文为参考流程,可按证据和依赖自主调整方法与顺序,补查、补算前置资料。核对任务、设备、单位、异常时刻、knowledge_cutoff及batch_sequence,使用本轮已提交观测,同一持续事件沿用原调查。证据来源字段用于记录数据取得方式,不构成停止分析的条件。
先按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。基线缺失可自主用run_simulation补算同刻数据,读取成功结果后重试。其他缺口先按原因补查、补算或修正参数,仍不可用才记录限制。按data.presentation发布地图及计数,stage=event_impact;禁用关阀范围替代。回放模型计算不读实时SCADA,非冻结观测;历史展示复用保存证据。
按需用analyze_valve_isolation分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes及计数;generate_node_coverage展示关阀覆盖,stage=isolation_plan;analyze_valve_closure可补水力影响。候选与建议待现场核验,展示按就绪顺序。候选用locate_features传event_kind=burst。
## 对比展示与影响 ## 对比展示与影响
@@ -39,20 +43,22 @@ metadata:
定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。 定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。 影响说明比较异常前、异常期间及已有恢复观测,列压力、流量或水质变化、持续时间、测点与资产。同对象、单位、时段及条件计算变化量,区分观测、模型和拓扑结果;停水及漏量保留依据,缺恢复、用户或现场资料时明确限制。
## 范围与报告 ## 范围与报告
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。 观测保持本轮冻结窗口,后续观测由相应轮次处理。资产、空间与拓扑用当前模型;水力计算标明模型来源,定位保留权限、设备及冻结观测校验。
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。 报告先写判断与依据,摘要建议150字、最多300字;多测点数值入Table,保留单位、方向和缺测,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。 现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
## 完工后的恢复复核 ## 完工后的恢复复核
关联工单completed后,以handling_context.returned_at为实际完工业务时间,不用创建、预约或电脑当前时间代替。Server读取严格晚于该时刻的最近一帧及事件前15天同相位对照,独立保存恢复证据。回放来源工单同样适用,不推进回放,原knowledge_cutoff与batch_sequence不变。 工单completed后,以handling_context.returned_at为实际完工时间;Server取严格晚于该时刻的最近一帧及事件前15天同相位对照,保存恢复证据。回放同样适用,不推进回放或修改原冻结边界。
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,74 @@
---
name: water-pollution-event
description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。
metadata:
version: "1.2.1"
---
# 水污染侦测事件调查
适用于 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,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。
按返回的data.presentation.command发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用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不自动认定实验室合格或授权现场复供。
@@ -2,7 +2,7 @@
name: water-quality-complaint name: water-quality-complaint
description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。 description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。
metadata: metadata:
version: "2.0.4" version: "2.0.5"
--- ---
# 水质投诉处置 # 水质投诉处置
@@ -15,7 +15,7 @@ metadata:
## 风险与工程分析 ## 风险与工程分析
根据当前证据选择必要步骤: 以下为参考核查路径,根据当前证据选择必要步骤,自主调整顺序、查询范围、工具组合及模型情景。可通过补查、推导或有说明的假设解决的缺口先处理,确需现场事实或人工决策时再提问:
1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。 1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。
2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。 2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。
@@ -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
+227
View File
@@ -0,0 +1,227 @@
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, **parameters):
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)], [], **parameters)
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"])
assert result["coverage"]["parameters"]["exclusive_groups"] is True
@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_missing_online_baseline_offers_recalculation_then_coverage_can_be_retried(inputs):
inputs.side_effect = None
inputs.return_value = {}
native = simulation([2, 4, 6])
missing = service.estimate_burst_impact("own", START, native)
assert missing["impact"]["reason"] == "no_exact_comparable_online_snapshot"
recovery = missing["impact"]["baseline_recovery"]
assert recovery["tool"] == "run_simulation"
assert recovery["parameters"] == {
"start_time": START.isoformat(), "duration": 60, "output_node_ids": ["J0"],
}
assert "map_payload" not in missing
inputs.return_value = {
f"J{i}": [{"time": START.isoformat(), "value": 30}] for i in range(3)
}
retried = service.estimate_burst_impact("own", START, native)
assert retried["impact"]["status"] == "ready"
assert len(retried["map_payload"]["features"]) == 3
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"
@pytest.mark.parametrize("unit", ["MTR", "mtr", " MTR "])
def test_native_mtr_pressure_generates_all_three_grades(inputs, unit):
result = service.estimate_burst_impact("own", START, simulation([1, 2.999, 3, 4.999, 5], unit))
assert result["impact"]["status"] == "ready"
assert [g["node_ids"] for g in result["impact"]["grades"]] == [["J0", "J1"], ["J2", "J3"], ["J4"]]
assert len(result["map_payload"]["features"]) == 3
+61
View File
@@ -1,5 +1,6 @@
"""Worker boundaries use real managed subprocesses, without external databases.""" """Worker boundaries use real managed subprocesses, without external databases."""
import asyncio import asyncio
import json
from functools import wraps from functools import wraps
from types import SimpleNamespace from types import SimpleNamespace
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -27,6 +28,66 @@ def run_async(function):
return wrapper return wrapper
@run_async
async def test_child_keeps_complete_result_above_eight_mib(monkeypatch, tmp_path):
from app.mcp import calculation_jobs
from app.services import calculation_worker
data = {"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]}}
@asynccontextmanager
async def scope(row):
yield object()
async def calculate(scope, row):
return SimpleNamespace(model_dump=lambda **kwargs: data)
monkeypatch.setattr(calculation_worker, "execution_scope", scope)
monkeypatch.setattr(calculation_jobs, "execute_calculation_job", calculate)
input_path, output_path = tmp_path / "input.json", tmp_path / "result.json"
input_path.write_text("{}")
await calculation_worker.execute_child(str(input_path), str(output_path))
assert output_path.stat().st_size > 8 * 1024 * 1024
assert json.loads(output_path.read_text()) == {"status": "succeeded", "result": data}
@run_async
async def test_supervisor_persists_complete_result_above_eight_mib(monkeypatch, tmp_path):
monkeypatch.setenv("CALCULATION_WORKER_STATE_DIR", str(tmp_path))
original = asyncio.create_subprocess_exec
async def spawn(*args, **kwargs):
# A real child writes the same file protocol as the calculation process.
code = ('import json,pathlib,sys; pathlib.Path(sys.argv[1]).write_text('
'json.dumps({"status":"succeeded","result":{"comparisons":"x"*(8*1024*1024),'
'"map_payload":{"features":["coverage"]}}}))')
return await original(sys.executable, "-c", code, args[-1], start_new_session=True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
results = []
class Repository:
async def heartbeat(self, *args):
return "running"
async def finish(self, row, owner, **terminal):
results.append(terminal)
return True
@asynccontextmanager
async def repository(project):
yield Repository()
worker = CalculationWorker(WorkerSettings(heartbeat_seconds=.02), repository=repository)
await asyncio.wait_for(worker.run_job({"project_id": "project", "job_id": "large-result",
"timeout_seconds": 10}), 15)
assert len(results) == 1
assert results[0]["status"] == "succeeded"
assert results[0]["result"] == {
"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]},
}
def test_worker_settings_require_valid_concurrency(monkeypatch): def test_worker_settings_require_valid_concurrency(monkeypatch):
settings = WorkerSettings.from_environment() settings = WorkerSettings.from_environment()
assert settings.max_concurrency == 2 assert settings.max_concurrency == 2
+135
View File
@@ -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,70 @@
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
@pytest.mark.parametrize("include_impact", [True, False])
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch, include_impact):
scope, binding = context(monkeypatch)
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
parameters = args(include_impact_coverage=include_impact)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
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.args[0].include_impact_coverage is include_impact
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
assert should_queue(TOOLS["run_contaminant_simulation"], args())
@pytest.mark.parametrize("problem", ["future_start", "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 == "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 == "future_start" 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
+78
View File
@@ -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}
+151
View File
@@ -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()
@@ -167,6 +239,85 @@ def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_pa
assert graph.node_refs[2].status == "reference" assert graph.node_refs[2].status == "reference"
@pytest.mark.parametrize("edges", [[], [{"source": "check", "target": "check"}]])
def test_new_multistep_graph_requires_relationships_instead_of_isolated_nodes(edges):
with pytest.raises(DomainError) as error:
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "read", "label": "观测核验"},
{"node_id": "check", "label": "异常判断"}],
"edges": edges,
}))
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert "check" in str(error.value)
def test_new_branch_must_connect_to_the_saved_narrative_without_inventing_edges():
original = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "root", "label": "调查起点"}],
}))
with pytest.raises(DomainError) as error:
update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "left", "label": "独立核查"},
{"node_id": "right", "label": "核查发现"}],
"edges": [{"source": "left", "target": "right"}],
}))
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert original.edges == []
assert len(original.node_refs) == 1
def test_connected_parallel_branches_and_join_render_top_down():
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": key, "label": label} for key, label in (
("start", "异常核验"), ("isolation", "隔离分析"),
("impact", "爆管影响"), ("summary", "汇总判断"))],
"edges": [{"source": source, "target": target, "label": label}
for source, target, label in (
("start", "isolation", "并行核查"), ("start", "impact", "并行核查"),
("isolation", "summary", "汇总依据"), ("impact", "summary", "汇总依据"))],
}))
assert graph.mermaid.startswith("flowchart TD\n")
assert len(graph.edges) == 4
assert 'start -->|"并行核查"| impact' in graph.mermaid
patched = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "impact", "status": "recorded"}],
}))
assert patched.edges == graph.edges
def test_existing_disconnected_snapshot_remains_readable_and_patchable():
original = InvestigationDiagram.model_validate({
"title": "已保存的旧图", "mermaid": 'flowchart TD\n read["观测"]\n check["判断"]',
"edges": [], "node_refs": [{"node_id": "read", "label": "观测"},
{"node_id": "check", "label": "判断"}],
})
patched = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "check", "status": "recorded"}],
}))
assert patched.mermaid == original.mermaid
assert patched.edges == original.edges == []
assert original.node_refs[1].status is None
@pytest.mark.anyio
async def test_publication_rejects_missing_links_and_accepts_a_corrected_graph():
task, repo, service = setup_service()
first = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "read", "label": "观测核验"},
])
original = first.task.latest_progress.progress.diagram.model_dump(mode="json")
with pytest.raises(DomainError) as error:
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "check", "label": "异常判断"},
])
assert error.value.code == "GRAPH_RELATION_REQUIRED"
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.model_dump(mode="json") == original
corrected = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "check", "label": "异常判断"},
], edges=[{"source": "read", "target": "check", "label": "核验依据"}])
assert 'read -->|"核验依据"| check' in corrected.task.latest_progress.progress.diagram.mermaid
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it(): def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
original = InvestigationDiagram.model_validate({ original = InvestigationDiagram.model_validate({
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]', "title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
@@ -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)
+1
View File
@@ -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"]},
+41
View File
@@ -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
+1 -1
View File
@@ -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
+27 -6
View File
@@ -373,6 +373,31 @@ 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_allows_exploration_and_checks_coverage_permission(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))
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"],
args.model_copy(update={"include_impact_coverage": False})))
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"],
args.model_copy(update={"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)})))
assert error.value.status_code == 422
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)
@@ -857,7 +882,7 @@ def test_old_task_run_and_reader_parent_are_rejected(monkeypatch):
@pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"]) @pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"])
@pytest.mark.parametrize("replay", [False, True]) @pytest.mark.parametrize("replay", [False, True])
def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch, purpose, replay): def test_work_order_source_does_not_remove_authorized_analysis_tools(monkeypatch, purpose, replay):
monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS) monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS)
value = binding(task=True) value = binding(task=True)
value.purpose = purpose value.purpose = purpose
@@ -875,13 +900,9 @@ def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch
monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks()) monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks())
scope = SimpleNamespace(project=object(), connection=Connection()) scope = SimpleNamespace(project=object(), connection=Connection())
asyncio.run(contexts.verify_task(scope, value)) asyncio.run(contexts.verify_task(scope, value))
assert ("run_burst_location" in value.tool_cap) is replay assert "run_burst_location" in value.tool_cap
assert "analyze_valve_isolation" in value.tool_cap assert "analyze_valve_isolation" in value.tool_cap
assert "analyze_valve_closure" in value.tool_cap assert "analyze_valve_closure" in value.tool_cap
if not replay:
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, value, TOOLS["run_burst_location"], SimpleNamespace()))
assert caught.value.status_code == 403
def test_jwt_signature_issuer_audience_lifetime_and_key_cache(): def test_jwt_signature_issuer_audience_lifetime_and_key_cache():
+36 -4
View File
@@ -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)
@@ -45,7 +46,7 @@ def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills():
assert replay["workflow"] == "burst_report" assert replay["workflow"] == "burst_report"
assert "影响说明" in handling["instructions"] assert "影响说明" in handling["instructions"]
assert "supply_affected_nodes" in handling["instructions"] assert "supply_affected_nodes" in handling["instructions"]
assert "run_burst_location" not in handling["instructions"] assert "run_burst_location" in handling["instructions"]
assert "run_burst_location" in replay["instructions"] assert "run_burst_location" in replay["instructions"]
@@ -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.1"
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 = []
+41 -2
View File
@@ -3,11 +3,12 @@ from datetime import datetime, timezone
import pytest import pytest
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError from pydantic import ValidationError
from shapely.geometry import Point, shape from shapely.geometry import Point, box, mapping, shape
from shapely.ops import unary_union
from app.domain.schemas.operational_data import TaskDataSnapshot from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp.analysis_models import NodeCoverageInput from app.mcp.analysis_models import NodeCoverageInput
from app.services.node_coverage import build_node_coverage from app.services.node_coverage import build_node_coverage, exclusive_coverage_features
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay
@@ -32,6 +33,44 @@ def test_coverage_can_compare_arbitrary_groups_without_assigning_isolation_seman
assert coverage["parameters"]["split_by_connectivity"] is False assert coverage["parameters"]["split_by_connectivity"] is False
def test_exclusive_envelopes_keep_union_and_counts_with_higher_groups_taking_precedence():
originals = [box(0, 0, 4, 4), box(2, 0, 6, 4), box(3, 1, 5, 3)]
features = [{"type": "Feature", "id": str(i), "properties": {
"coverage_group": str(i), "node_count": 10 + i, "mapped_node_count": 10 + i,
}, "geometry": mapping(geometry)} for i, geometry in enumerate(originals)]
result = exclusive_coverage_features(features)
visible = [shape(feature["geometry"]) for feature in result]
assert len(result) == 3
assert all(a.intersection(b).area == 0 for i, a in enumerate(visible) for b in visible[i + 1:])
assert unary_union(visible).equals(unary_union(originals))
assert visible[-1].equals(originals[-1])
assert [feature["properties"]["node_count"] for feature in result] == [10, 11, 12]
assert [feature["properties"]["mapped_node_count"] for feature in result] == [10, 11, 12]
assert all(feature["properties"]["overlap_policy"] == "higher_priority_group_first" for feature in result)
assert shape(features[0]["geometry"]).equals(originals[0]) # Never rewrite saved inputs.
def test_covered_grade_keeps_node_metadata_without_invalid_empty_geometry():
positions = [position("A", 117.24), position("B", 117.24)]
groups = [group(["A"], "low"), group(["B"], "high")]
ordinary = build_node_coverage(groups, positions, [])
assert len(ordinary["map_payload"]["features"]) == 2
exclusive = build_node_coverage(groups, positions, [], exclusive_groups=True)
assert [feature["id"] for feature in exclusive["map_payload"]["features"]] == ["high"]
assert exclusive["groups"]["low"]["node_ids"] == ["A"]
assert exclusive["groups"]["low"]["mapped_node_count"] == 1
assert exclusive["groups"]["low"]["display_area_available"] is False
assert exclusive["groups"]["high"]["display_area_available"] is True
summaries = exclusive["map_payload"]["features"][0]["properties"]["coverage_group_summary"]
assert summaries["low"]["node_count"] == 1
assert summaries["low"]["mapped_node_count"] == 1
assert summaries["low"]["display_area_available"] is False
# A second application must not drop the summary for the clipped group.
repeated = exclusive_coverage_features(exclusive["map_payload"]["features"])
assert repeated[0]["properties"]["coverage_group_summary"] == summaries
assert exclusive["status"] == "ready"
def test_connectivity_option_does_not_bridge_disconnected_groups(): def test_connectivity_option_does_not_bridge_disconnected_groups():
positions = [position("A", 117.24), position("B", 117.241), positions = [position("A", 117.24), position("B", 117.241),
position("C", 117.26), position("D", 117.261)] position("C", 117.26), position("D", 117.261)]
+11
View File
@@ -46,6 +46,17 @@ def test_online_simulation_rejects_invalid_step_before_calculating(monkeypatch):
run.assert_not_called() run.assert_not_called()
def test_online_baseline_preserves_fractional_start_for_exact_impact_comparison(monkeypatch):
start = START.replace(microsecond=123456)
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", lambda _: object())
run = Mock(return_value={"node_results": [{"node": "J1", "result": [{"pressure": 30}]}]})
monkeypatch.setattr(simulation, "run_simulation", run)
result = simulation.run_online_simulation("online", start, 60, output_node_ids=["J1"])
assert run.call_args.kwargs["modify_pattern_start_time"] == start.isoformat()
assert result["times"]["sample_times"] == [start.isoformat()]
def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch): def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch):
from app.mcp.scenario_service import calculate_scenario from app.mcp.scenario_service import calculate_scenario
+13 -1
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.domain.domain_errors import DomainError from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay, validate_overlay_stage
def _snapshot(data): def _snapshot(data):
@@ -58,6 +58,7 @@ def test_presentation_input_rejects_free_form_or_invalid_commands(command):
@pytest.mark.parametrize("command", [ @pytest.mark.parametrize("command", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]}, {"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "locate_features", "layer": "pipes", "feature_ids": ["P-1"], "event_kind": "burst"},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24}, {"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]}, {"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]},
{"action": "clear_scada_analysis"}, {"action": "clear_scada_analysis"},
@@ -110,3 +111,14 @@ def test_safe_chart_preserves_sparse_scatter_and_real_sample_times():
def test_safe_chart_rejects_invalid_sample_metadata_and_values(series): def test_safe_chart_rejects_invalid_sample_metadata_and_values(series):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
TaskSafeChart(title="X", chart_type="line", x_data=["t"], series=[series]) TaskSafeChart(title="X", chart_type="line", x_data=["t"], series=[series])
@pytest.mark.parametrize("phase", ["after_burst_before_intervention", "after_contamination_before_intervention"])
def test_event_impact_requires_pre_intervention_geometry(phase):
data = {"features": [{"properties": {"impact_phase": phase}}]}
validate_overlay_stage(data, "event_impact")
closure = {"features": [{"properties": {"coverage_group": "supply_affected_nodes"}}]}
validate_overlay_stage(closure, "isolation_plan")
with pytest.raises(DomainError) as error:
validate_overlay_stage(closure, "event_impact")
assert error.value.code == "OVERLAY_IMPACT_PHASE_MISMATCH"
+19
View File
@@ -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)
+96
View File
@@ -580,6 +580,15 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
}}), }}),
) )
evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot) evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot)
with pytest.raises(DomainError) as error:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "set_overlay", "overlay_id": "wrong-impact", "title": "事件影响",
"mode": "geometry", "evidence_id": evidence.evidence_id},
narration={"stage": "event_impact", "summary": "预计影响范围,现场待核实。",
"evidence_refs": [evidence.evidence_id]},
))
assert error.value.code == "OVERLAY_IMPACT_PHASE_MISMATCH"
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意", command={"action": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意",
@@ -675,6 +684,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 +2372,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,
)
+139
View File
@@ -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)
+72
View File
@@ -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)
+48
View File
@@ -0,0 +1,48 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.domain.schemas.project_settings import ProjectSettings
from app.services.operational_recovery import reconcile_recoveries
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("cached", [False, True])
async def test_recovery_uses_report_devices_instead_of_all_browsed_network_devices(monkeypatch, cached):
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
incident = now - timedelta(hours=6)
returned = now - timedelta(minutes=20)
task = _task_document(_context(), now, lease=now + timedelta(minutes=1))
task.update(workflow="scada_anomaly", status="waiting_human", lifecycle_status="waiting_human",
current_report_revision=1, trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "异常"},
handling_context={"phase": "waiting_scada", "returned_at": returned.isoformat()})
if cached:
task["handling_context"].update(device_ids=["P1", "unrelated"], interval_seconds=900)
repo = TaskRepository(task, _active_run(task, now))
repo.get_latest_report = AsyncMock(return_value={"revision": 1, "report": {"scada_device_ids": ["P1"]}})
repo.list_evidence = AsyncMock(return_value=[{"data_snapshot": {"source": "query_scada_readings",
"data": {"data": [{"device_id": "P1"}, {"device_id": "unrelated"}]}}}])
repo.recovery_candidates = AsyncMock(side_effect=lambda *_: [deepcopy(repo.task)]
if repo.task["handling_context"]["phase"] == "waiting_scada" else [])
service = OperationalTaskService(repo, _context(), set(), clock=lambda: now)
at = returned + timedelta(minutes=5)
rows = [{"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.}
for day in range(1, 16)] + [{"time": at, "device_id": "P1", "monitored_value": 100.}]
reader = AsyncMock(side_effect=lambda _, devices, *args, **kwargs: rows if devices == ["P1"] else [{}] * 100_001)
monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_recovery_scada_readings", reader)
settings = ProjectSettings(scada_monitoring_interval_seconds=900, scada_monitoring={"pressure_device_ids": ["P1"]})
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 1
assert repo.task["handling_context"]["device_ids"] == ["P1"]
assert repo.task["handling_context"]["phase"] == "verification_ready"
assert repo.run["status"] == "queued"
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 0
+91
View File
@@ -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"
+171
View File
@@ -0,0 +1,171 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.mcp import query_service
from app.mcp.analysis_models import ScenarioInput
from app.mcp.catalog import TOOLS
from app.services import simulation
def parameters(**changes):
return ScenarioInput.model_validate({
"context_handle": "a" * 43, "start_time": "2026-07-16T08:00:00+08:00",
"duration": 60, "output_node_ids": ["J1"], **changes,
})
def context(monkeypatch):
permissions = {"simulation.run", "simulation.view", "scada.view", "webgis.view", "optimization.run"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
@asynccontextmanager
async def routing():
yield
scope = SimpleNamespace(
connection=SimpleNamespace(commit=AsyncMock()), simulation_routing=routing,
project=SimpleNamespace(project_code="owned", project_role="member"),
user=SimpleNamespace(username="actor"),
)
binding = SimpleNamespace(
tool_cap=["run_simulation"], permission_cap=sorted(permissions),
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_coordinator_can_rebuild_online_baseline_without_live_scada(monkeypatch):
scope, binding = context(monkeypatch)
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
live_mappings = Mock(side_effect=AssertionError("Replay must not load live SCADA mappings"))
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", live_mappings)
native = Mock(return_value={
"units": {"pressure": "MTR"},
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
monkeypatch.setattr(simulation, "run_simulation", native)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_simulation"], parameters()))
assert result.origin == "synthetic"
assert any("不读取实时SCADA" in gap for gap in result.data_gaps)
assert result.data["result"]["persisted_baseline"] is True
assert result.data["result"]["node_results"][0]["result"] == [{"pressure": 30}]
native.assert_called_once()
call = native.call_args.kwargs
assert call["name"] == "owned" and call["simulation_type"] == "realtime"
assert call["modify_pattern_start_time"] == "2026-07-16T08:00:00+08:00"
assert all(not values for values in vars(call["scada_mappings"]).values())
live_mappings.assert_not_called()
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
def test_replay_online_baseline_preserves_existing_authorization(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("simulation.run")
if problem == "tool_cap":
binding.tool_cap.clear()
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_simulation"], args))
assert caught.value.status_code == (422 if problem == "future_start" else 403)
@pytest.mark.parametrize("name", ["analyze_valve_closure", "analyze_pressure_regulation"])
def test_replay_can_compare_hydraulic_alternatives_with_model_only_boundaries(monkeypatch, name):
from app.infra.db.postgresql.scada import ScadaElementMappings
from app.services import simulation_scenarios as scenarios
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
captures = []
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
monkeypatch.setattr(scenarios, "set_option", lambda *_: None)
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id",
lambda _: ScadaElementMappings({}, {}, {"P1": "device"}, {}, {}, {}, {}))
monkeypatch.setattr(simulation, "run_simulation", lambda **kw: captures.append(kw) or {
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
native_name = "valve_close_analysis" if name == "analyze_valve_closure" else "pressure_regulation"
native = getattr(scenarios, native_name).__wrapped__
monkeypatch.setattr(scenarios, native_name,
lambda **kw: native(**kw, _temporary_project="tjw_tmp_owned"))
args = TOOLS[name].model.model_validate({
**parameters().model_dump(),
**({"valves": ["V1"]} if name == "analyze_valve_closure" else {"pump_control": {"P1": [0]}}),
})
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS[name], args))
assert result.origin == "synthetic"
assert result.data["result"]["persisted_baseline"] is False
assert len(captures) == 1
assert captures[0]["name"] == "tjw_tmp_owned" and captures[0]["persist_results"] is False
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
if name == "analyze_valve_closure":
assert captures[0]["modify_valve_opening"] == {"V1": 0.0}
else:
assert captures[0]["modify_fixed_pump_pattern"] == {"P1": [0]}
@pytest.mark.parametrize("name", [name for name, tool in TOOLS.items()
if tool.semantics == "asset" and not tool.mutating])
def test_replay_allows_authorized_asset_queries_for_input_discovery(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
# Authorization needs only the schema kind; execution validates each tool's inputs.
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
binding.permission_cap = []
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
assert caught.value.status_code == 403
@pytest.mark.parametrize("name,fields", [
("query_model_baseline_series", {"element_type": "junction", "element_ids": ["J1"],
"start": "2026-07-16T07:45:00+08:00", "end": "2026-07-16T08:00:00+08:00"}),
("get_model_baseline_at_time", {"element_type": "junction", "element_id": "J1", "time": "2026-07-16T08:00:00+08:00"}),
("query_model_baseline_field", {"element_type": "junction", "field": "pressure", "time": "2026-07-16T08:00:00+08:00"}),
])
def test_replay_can_verify_baseline_without_reading_future_values(monkeypatch, name, fields):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
args = TOOLS[name].model.model_validate({"context_handle": "a" * 43, **fields})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args))
future = parameters(start_time="2026-07-16T08:15:00+08:00").start_time
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args.model_copy(
update={"end" if "end" in fields else "time": future},
)))
assert caught.value.status_code == 422
@pytest.mark.parametrize("name", ["calculate_demand_allocation", "optimize_sensor_placement"])
def test_replay_can_choose_current_model_design_calculations(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
binding.purpose = "task_reader"
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
assert caught.value.status_code == 403
def test_replay_can_explore_contamination_without_requiring_map_generation(monkeypatch):
scope, binding = context(monkeypatch)
binding.tool_cap = ["run_contaminant_simulation"]
args = TOOLS["run_contaminant_simulation"].model.model_validate({
**parameters().model_dump(), "source": "J1", "concentration": 1,
"include_impact_coverage": False,
})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], args))
@@ -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": []}
+66 -5
View File
@@ -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"
+3 -1
View File
@@ -186,7 +186,9 @@ def test_recovery_read_selects_first_raw_frame_strictly_after_feedback_and_bound
assert result == rows assert result == rows
query, parameters = conn.cursor_instance.calls[0] query, parameters = conn.cursor_instance.calls[0]
assert "min(time)" in query and "time>%s AND time<=%s" in query assert "min(time)" in query and "time>%s AND time<=%s" in query
assert "time>=%s AND time<%s" in query and "JOIN next_frame" in query assert "m.time>=%s AND m.time<%s" in query and "JOIN next_frame" in query
assert "mod(extract(epoch FROM m.time)::bigint,86400)=" in query
assert "mod(extract(epoch FROM f.time)::bigint,86400)" in query
assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"]) assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"])