Compare commits

..
Author SHA1 Message Date
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
56 changed files with 2908 additions and 144 deletions
+1
View File
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
'cancel_task_run',
'run_simulation',
'run_contaminant_simulation',
'run_contaminant_source_trace',
'analyze_valve_closure',
'analyze_flushing',
'analyze_pressure_regulation',
+121 -4
View File
@@ -5,13 +5,16 @@ import re
from typing import Annotated, Literal
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 (
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
)
from app.domain.schemas.task_question import TaskQuestionRequest
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.report_authoring import (
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
@@ -252,6 +255,10 @@ class InvestigationDiagramNodeRef(_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=(
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
"No live node/task binding or per-step synchronization is required."
@@ -345,12 +352,44 @@ class InvestigationReport(StrictModel):
return self
class InvestigationDataGapInput(InvestigationDataGap):
"""New gap descriptions use business prose; references remain opaque."""
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
@field_validator("missing_data", "scope", "impact", "required_data")
@classmethod
def validate_reader_text(cls, value: str) -> str:
return validate_report_text(value)
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
data_gaps: list[InvestigationDataGapInput]
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
@classmethod
def validate_reader_text(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
@model_validator(mode="after")
def validate_authoring(self):
for value in [*self.recommendations, *self.pending_items]:
validate_report_text(value)
validate_report_ui_text(self.ui_blocks)
return self
class InvestigationProgress(StrictModel):
@@ -361,8 +400,14 @@ class InvestigationProgress(StrictModel):
class InvestigationProgressInput(InvestigationProgress):
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
graph_update: InvestigationGraphUpdate | None = None
@field_validator("summary")
@classmethod
def validate_reader_summary(cls, value: str) -> str:
return validate_report_text(value)
@model_validator(mode="after")
def validate_graph_input(self):
if self.graph_update is not None and self.diagram is not None:
@@ -752,7 +797,7 @@ class ScadaChartGap(StrictModel):
class ScadaChartContext(StrictModel):
role: Literal["current", "comparison"]
metric: Literal["pressure", "flow"]
metric: Literal["pressure", "flow", "quality"]
unit: Annotated[str, Field(min_length=1, max_length=128)]
window_start: WorkbenchDateTime
window_end: WorkbenchDateTime
@@ -904,11 +949,72 @@ TaskPresentationCommand = Annotated[
]
class TaskPresentationNarration(StrictModel):
stage: Literal[
"anomaly_detection", "evidence_verification", "asset_localization",
"event_impact", "source_control", "isolation_plan", "flushing_plan",
"field_feedback", "clearance_verification", "recovery_verification",
]
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
@model_validator(mode="after")
def validate_narration(self):
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
raise ValueError("presentation narration must contain readable text")
if len(set(self.evidence_refs)) != len(self.evidence_refs):
raise ValueError("presentation narration evidence IDs must be unique")
return self
TaskPresentationPurpose = Annotated[
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
]
class TaskPresentationNarrationInput(TaskPresentationNarration):
"""New live findings use business prose; saved narration remains readable."""
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
@field_validator("summary", "next_step")
@classmethod
def validate_reader_text(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
class TaskPresentationEventCommand(StrictModel):
request_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
command: TaskPresentationCommand
narration: TaskPresentationNarrationInput | None = None
purpose: TaskPresentationPurpose | None = Field(default=None,
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
@field_validator("purpose")
@classmethod
def validate_reader_purpose(cls, value: str | None) -> str | None:
return validate_report_text(value) if value is not None else value
@model_validator(mode="after")
def validate_visible_labels(self):
validate_presentation_command_text(self.command.model_dump())
return self
@model_serializer(mode="wrap")
def serialize_legacy_command(self, handler):
value = handler(self)
# Keep old idempotency digests stable when no narration was supplied.
if self.narration is None:
value.pop("narration", None)
if self.purpose is None:
value.pop("purpose", None)
return value
class TaskPresentationEventResult(StrictModel):
@@ -939,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
# Stored records are validated against the current command contract by the
# reader; an obsolete record must not prevent reading the rest of the page.
command: dict[str, JsonValue]
narration: TaskPresentationNarration | None = None
purpose: TaskPresentationPurpose | None = None
class TaskPresentationHistoryPage(StrictModel):
@@ -1015,6 +1123,13 @@ class HumanEvidenceCommand(StrictModel):
evidence: EvidenceInput
class WorkOrderNotificationReference(StrictModel):
source_event_id: NonEmptyStr
source_revision: PositiveInt
evidence_id: NonEmptyStr | None = None
status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"]
class OperationalTaskNotification(StrictModel):
notification_id: UUID
task_id: UUID
@@ -1022,7 +1137,7 @@ class OperationalTaskNotification(StrictModel):
"task_created", "action_pending", "report_updated", "task_completed",
"task_failed", "run_delivery_degraded", "access_approval_pending",
"action_approval_pending", "human_input_required",
"external_coordination_failed"
"external_coordination_failed", "work_order_feedback"
]
title: NonEmptyStr
description: NonEmptyStr
@@ -1031,6 +1146,8 @@ class OperationalTaskNotification(StrictModel):
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
work_order_action: HumanApprovalAction | None = None
work_order_reference: WorkOrderNotificationReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]
+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"])
+34 -2
View File
@@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput):
return self
class ContaminantSourceTraceInput(CalculationInput):
candidate_node_ids: list[Id] = Field(min_length=1, max_length=20,
description="根据事件位置、附近资产或水质设备映射取得的候选模型节点;Server核验节点存在后稳定返回一个候选。当前为临时定位接口,尚未执行污染反演")
start: datetime = Field(description="带时区的事件核查窗口开始")
end: datetime = Field(description="带时区的事件核查窗口结束")
@model_validator(mode="after")
def valid_window(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("window must be positive and at most 24 hours")
if len(set(self.candidate_node_ids)) != len(self.candidate_node_ids):
raise ValueError("candidate_node_ids must be distinct")
return self
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
@@ -143,6 +160,10 @@ class ContaminantSimulationInput(ScenarioInput):
source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释")
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源")
include_impact_coverage: bool = Field(default=False, strict=True,
description="估计处置前的污染传播范围;在筛选展示节点前汇总全网节点整个模拟时段的浓度,保存影响节点、首个超过阈值的报告时刻、峰值及地图覆盖")
impact_threshold_mg_l: float = Field(default=0, ge=0, allow_inf_nan=False,
description="模型影响范围的浓度阈值,mg/L,按严格大于判定;默认0表示模型中出现正浓度,仅为情景展示条件,不是饮用水安全限值。沿用模型初始水质与反应条件")
class ValveClosureInput(ScenarioInput):
@@ -150,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
class FlushingInput(ScenarioInput):
drainage_node_id: Id
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
@model_validator(mode="after")
def distinct_valve_states(self):
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
raise ValueError("flushing valve lists must contain distinct IDs")
if set(self.closed_valves) & set(self.opened_valves):
raise ValueError("a flushing valve cannot be both opened and closed")
return self
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
+27
View File
@@ -114,3 +114,30 @@ async def calculate_replay_burst_location(
final_candidates_csv_path=None,
**parameters,
)
async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""An independent current-model scenario, with no live SCADA substitution."""
return await bounded_calculation(
calculate_scenario, "run_burst_simulation",
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Estimate propagation in a current-model scenario without live SCADA substitution."""
return await bounded_calculation(
calculate_scenario, "run_contaminant_simulation",
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
async def calculate_replay_flushing(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Evaluate a model-only flushing plan without live SCADA or field execution."""
return await bounded_calculation(
calculate_scenario, "analyze_flushing",
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
+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")
else:
result.data = value
if not args.data_path and binding.task_id:
from app.mcp.presentation_preview import saved_burst_result_preview, saved_presentation_preview
presentation = saved_presentation_preview(result)
if presentation is not None and isinstance(result.data, dict):
result.data = {**result.data, "presentation": presentation}
if len(result.model_dump_json().encode()) > 11000:
preview = saved_burst_result_preview(result, str(binding.task_id))
if preview is not None:
result.data = preview
result.truncated = True
result.data_gaps = [*result.data_gaps,
"Inline burst impact summary only; full node comparisons and geometry are saved in task evidence. Read the evidence resource or select a narrower data_path."]
if len(result.model_dump_json().encode()) > 11000:
raise HTTPException(413, "Select a narrower data_path or smaller result page")
return result
+5 -4
View File
@@ -6,7 +6,7 @@ from mcp import types
from app.mcp import models as m
from app.mcp import calculation_models as jobs
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ContaminantSourceTraceInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@@ -62,10 +62,11 @@ class QueryTool:
_specs = [
("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
@@ -258,7 +259,7 @@ _specs += [
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;返回给定情景的模拟结果,正式方案通过方案工具保存", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
("run_burst_simulation", "在隔离模型中模拟管道破口;include_impact_coverage=true时按起算同刻已有在线模拟计算全网节点压力下降及1/3/5米水头分档覆盖,保存为展示证据;回放仅用当前模型情景,不替代冻结观测", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
+1
View File
@@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput):
class BurstSimulationInput(ScenarioInput):
include_impact_coverage: bool = Field(default=False, strict=True, description="比较起算时刻全网节点与已有在线模拟压力,按下降1≤Δ<3、3≤Δ<5、Δ≥5米水头生成事件影响覆盖;不重算或覆盖在线基线")
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积,单位cm²;保留面积来源及估算依据")
+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"]})
if args.scenario == "flushing":
from app.native.wndb.model.options import get_option
from app.mcp.scenario_service import flushing_valve_controls
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing supports LPS or CMH models only")
return scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters))
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
+35 -2
View File
@@ -5,8 +5,34 @@ from uuid import NAMESPACE_URL, uuid5
from app.mcp.models import QueryResult
def saved_burst_result_preview(result: QueryResult, task_id: str) -> dict | None:
"""Keep durable full-network results usable without copying node arrays inline."""
if result.source != "run_burst_simulation" or not result.evidence_id:
return None
data = result.data if isinstance(result.data, dict) else {}
analysis = data.get("result", {})
impact = analysis.get("impact") if isinstance(analysis, dict) else None
if not isinstance(impact, dict):
return None
summary = {key: value for key, value in impact.items()
if key not in {"comparisons", "grades", "unavailable_node_ids"}}
summary["unavailable_node_count"] = len(impact.get("unavailable_node_ids", []))
summary["grades"] = [
{**{key: value for key, value in grade.items() if key != "node_ids"},
"node_count": len(grade.get("node_ids", []))} for grade in impact.get("grades", [])
]
preview = {
"resource_uri": f"tjwater://tasks/{task_id}/evidence/{result.evidence_id}",
"parameters": data.get("parameters", {}), "result": {"impact": summary},
}
presentation = saved_presentation_preview(result)
if presentation is not None:
preview["presentation"] = presentation
return preview
def saved_presentation_preview(result: QueryResult) -> dict | None:
if result.source != "generate_node_coverage" or not result.evidence_id:
if result.source not in {"generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation"} or not result.evidence_id:
return None
analysis = result.data.get("result") if isinstance(result.data, dict) else None
if not isinstance(analysis, dict):
@@ -15,11 +41,18 @@ def saved_presentation_preview(result: QueryResult) -> dict | None:
if not isinstance(payload, dict) or not payload.get("features"):
return None
coverage = analysis.get("coverage", {})
burst = result.source == "run_burst_simulation"
contaminant = result.source == "run_contaminant_simulation"
if burst and analysis.get("impact", {}).get("phase") != "after_burst_before_intervention":
return None
if contaminant and analysis.get("impact", {}).get("phase") != "after_contamination_before_intervention":
return None
return {
"command": {
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
"title": "节点空间覆盖示意",
"title": ("爆管后、处置前的预计影响范围(米水头)" if burst else
"水污染传播预计影响范围(处置前情景)" if contaminant else "节点空间覆盖示意"),
},
"display_only": True,
"coverage_status": coverage.get("status"),
+73 -6
View File
@@ -75,16 +75,28 @@ async def authorize_query(
if replay_context:
from app.services.scada_replay_queries import check_replay_window
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
"query_scada_readings", "get_network_schema", "run_burst_location",
"query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace",
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings")
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
if tool.name == "run_burst_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "run_contaminant_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "analyze_flushing":
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_burst_location":
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
@@ -107,6 +119,8 @@ async def authorize_query(
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
required.add("simulation.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
@@ -182,7 +196,15 @@ async def execute_query(
source_ref = "mcp:" + str(uuid4())
evidence_id = None
replay_context = getattr(binding, "replay_context", None)
if name == "query_scada_readings" and args.device_ids is None:
if name == "run_contaminant_source_trace":
from app.services.contaminant_source_trace import trace_contaminant_source
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": await trace_contaminant_source(conn, args)}
origin = "synthetic"
if data["result"]["located_node"] is None:
gaps.append(data["result"]["notice"])
elif name == "query_scada_readings" and args.device_ids is None:
from app.mcp.scada_readings import read_complete_scada
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
elif name == "query_scada_readings" and replay_context:
@@ -191,6 +213,39 @@ async def execute_query(
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
data, page, truncated = page_result(rows, args)
origin = "synthetic"
elif name == "run_burst_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_burst_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"]
elif name == "run_contaminant_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_contaminant_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
elif name == "analyze_flushing" and replay_context:
from app.mcp.analysis_service import calculate_replay_flushing
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_flushing(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
elif name == "run_burst_location" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_location
from app.services.scada_replay_queries import replay_burst_location_inputs
@@ -241,7 +296,7 @@ async def execute_query(
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
@@ -252,7 +307,7 @@ async def execute_query(
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
}[name]]
if name == "calculate_demand_allocation":
truncated = data["result"]["has_more"] or args.offset > 0
@@ -491,6 +546,18 @@ async def execute_query(
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
if skill.get("name") == "water-pollution-event":
data["source_trace_capability"] = {
"available": "run_contaminant_source_trace" in binding.tool_cap,
"tool": "run_contaminant_source_trace",
"algorithm_status": "provisional",
"required_inputs": ["candidate_node_ids", "start", "end"],
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
"source_control", "analyze_valve_isolation", "generate_node_coverage",
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
"work_order_wait_blocks_analysis": False,
"simulation_parameters": {"include_impact_coverage": True},
}
if (skill.get("name") == "scada-anomaly" and replay_context
and "run_burst_location" in binding.tool_cap):
from app.services.burst_location_demo import location_demo_context
+62 -5
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
parameters = dict(parameters)
@@ -18,6 +25,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
links = parameters.pop("output_link_ids")
start = parameters.pop("start_time")
duration = parameters.pop("duration")
include_impact = parameters.pop("include_impact_coverage", False)
impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None)
if name == "run_simulation":
@@ -27,11 +36,27 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_burst_simulation":
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
# Replay scenarios use current model assumptions, never mutable live
# SCADA readings in place of the investigation's frozen observations.
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
elif name == "run_contaminant_simulation":
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
@@ -41,9 +66,16 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters), **boundary)
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
@@ -62,7 +94,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
raise ValueError("Scenario did not return calculation results")
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
return {
output = {
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
"start_time": start.isoformat(), "duration_seconds": duration,
@@ -72,3 +104,28 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
}
if name == "run_burst_simulation" and include_impact:
from app.services.burst_impact import estimate_burst_impact
impact = estimate_burst_impact(network, start, result, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
if name == "run_contaminant_simulation" and include_impact:
from app.services.contaminant_impact import estimate_contaminant_impact
impact = estimate_contaminant_impact(network, start, duration, result,
threshold_mg_l=impact_threshold, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
if name == "analyze_flushing":
output["flushing_plan"] = {
"opened_valves": parameters.get("opened_valves", []),
"closed_valves": parameters.get("closed_valves", []),
"drainage_node_id": parameters["drainage_node_id"],
"flushing_flow_m3h": parameters["flushing_flow_m3h"],
"estimated_discharge_volume_m3": parameters["flushing_flow_m3h"] * duration / 3600,
"state_basis": "independent_current_model_scenario",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"result_scope": "hydraulic_only",
"field_execution_verified": False,
"water_quality_clearance_verified": False,
}
return output
+1
View File
@@ -10,6 +10,7 @@ import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
"scada_anomaly": "scada-anomaly",
"water_pollution": "water-pollution-event",
"burst_report": "burst-event-handling",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",
+147
View File
@@ -0,0 +1,147 @@
"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
from datetime import datetime
from math import isfinite
from typing import Any
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.model.options import get_option, get_option_v3
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
GRADES = (
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
)
def _head_factor(unit: Any) -> float | None:
# Standard water head; unsupported or absent units must not become zero loss.
return {"m": 1.0, "meters": 1.0, "metres": 1.0,
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
"ft": 0.3048}.get(str(unit).strip().lower())
def _number(value: Any) -> float | None:
if isinstance(value, bool):
return None
try:
result = float(value)
except (ValueError, TypeError, OverflowError):
return None
return result if isfinite(result) else None
def _instant(value: Any) -> datetime | None:
try:
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
return result if result.tzinfo is not None else None
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
model_only: bool = False) -> dict[str, Any]:
"""Compare every native node at t=0, before the adapter filters requested outputs.
The online snapshot is a model reference read at calculation time, not frozen
SCADA or a reconstructed historical model. All numerical inputs are retained
in task evidence so playback does not repeat this mutable reference query.
"""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_burst_before_intervention",
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
"baseline_source": "existing_online_simulation",
"baseline_read_at": datetime.now().astimezone().isoformat(),
"baseline_units_source": "current_model_settings",
"model_temporality": "current_model", "baseline_frozen": False,
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
}
output = {"impact": impact}
# Never silently shift t=0 to an unknown/delayed report sample or another day.
times = simulation.get("times", {})
sample_times = times.get("sample_times")
if ((sample_times and _instant(sample_times[0]) != start)
or (not sample_times and times.get("report_start") != 0)):
impact["reason"] = "simulation_has_no_verified_start_time_sample"
return output
options = get_option(network)
options_v3 = get_option_v3(network)
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
impact["baseline_units_source"] = (
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
)
impact["model_conditions"] = {
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
}
scenario_unit = simulation.get("units", {}).get("pressure")
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
if baseline_factor is None or scenario_factor is None:
impact["reason"] = "unsupported_or_missing_pressure_units"
return output
nodes = simulation.get("node_results", [])
ids = [str(row["node"]) for row in nodes]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
ids, start, start, "node", "pressure", db_name=network,
)
comparisons, missing = [], []
for row in nodes:
node_id = str(row["node"])
samples = row.get("result") or []
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
if burst is None or normal is None:
missing.append(node_id)
continue
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
drop = normal_m - burst_m
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
missing.append(node_id)
continue
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
"burst_m": burst_m, "pressure_drop_m": drop})
for group in impact["grades"]:
if drop >= group["min_inclusive_m"] and (
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
):
group["node_ids"].append(node_id)
break
impact.update(status="partial" if missing else "ready", node_count=len(ids),
compared_node_count=len(comparisons), unavailable_node_ids=missing,
comparisons=comparisons)
affected = sum(len(group["node_ids"]) for group in impact["grades"])
impact["affected_node_count"] = affected
impact["below_threshold_node_count"] = len(comparisons) - affected
if not comparisons:
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
return output
if affected > MAX_COVERAGE_NODES:
impact["coverage_status"] = "unavailable"
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
return output
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
for group in impact["grades"]]
geometry = generate_node_coverage(network, groups)
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(
impact_phase=impact["phase"], pressure_drop_unit="m",
pressure_drop_grade=feature["properties"]["coverage_group"],
comparison_time=start.isoformat(), model_estimate=True,
)
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output
+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
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
}
+10 -5
View File
@@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga
def update_investigation_graph(
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
) -> InvestigationDiagram:
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
"""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
impose no ordering or completion requirements on the Agent.
Unspecified fields retain their saved values. A failed attempt remains
failed, and a retry uses a new node id. Relationships impose no execution
ordering or completion requirements on the Agent.
"""
if previous is not None:
previous = adopt_legacy_investigation_graph(previous)
@@ -47,15 +48,19 @@ def update_investigation_graph(
if existing is None:
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
"This id belongs to the original Mermaid; use a new node id", status=409)
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
continue
require(not in_source(node.node_id) or 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)
require(existing.status != "failed" or (
("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)
changes = node.model_dump(exclude_unset=True)
if node.label is None:
changes.pop("label", None)
if existing.status == "failed" and existing.detail and not changes.get("detail"):
changes.pop("detail", None)
for field in ("evidence_refs", "action_refs", "map_targets"):
+10 -1
View File
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly", "water_pollution"}
def track_work_order(task: dict, action: dict) -> 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.
if feedback["status"] == "completed":
order["returned_at"] = feedback["occurred_at"]
if task.get("workflow") == "water_pollution":
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
else:
order.pop("returned_at", None)
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")]
if orders and all(item["status"] == "completed" for item in orders):
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
if task.get("workflow") == "water_pollution":
# A completion receipt resumes review, never the pressure-based
# automatic recovery calculation. Source revisions may carry new
# sampling or retest facts even when the field time is unchanged.
context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at)
context.pop("verification_evidence_id", None)
return True
previous = context.get("returned_at")
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
or context.get("phase") == "waiting_work_order"):
+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:
replay = task.get("source", {}).get("replay_context")
if task.get("workflow") != "scada_anomaly" and not replay:
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay:
return
target = datetime.fromisoformat(
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
+55 -5
View File
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
OperationalTaskCreate,
OperationalTaskDetail,
OperationalTaskNotification,
WorkOrderNotificationReference,
OperationalTaskPermissions,
OperationalTaskSchedule,
OperationalTaskScheduleCreate,
@@ -874,7 +875,7 @@ class OperationalTaskService:
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
elif saved_diagram is not None:
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
"Use graph_update to extend the saved investigation graph", status=422)
"Use graph_update to patch saved nodes or extend the investigation graph", status=422)
if diagram is None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
if diagram:
@@ -1066,6 +1067,8 @@ class OperationalTaskService:
run_id=row["metadata"]["run_id"],
execution_generation=row["metadata"]["execution_generation"],
command=row["metadata"]["command"],
narration=row["metadata"].get("narration"),
purpose=row["metadata"].get("purpose"),
) for row in rows[:limit]]
return TaskPresentationHistoryPage(
task_id=task_id, event_cursor=boundary, items=items,
@@ -1096,6 +1099,9 @@ class OperationalTaskService:
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
if command.narration is not None:
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.narration.evidence_refs, known)
await self._record_scada_presentation(task, run, payload, now)
if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
@@ -1121,6 +1127,10 @@ class OperationalTaskService:
payload["data"] = resolved
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
if command.narration is not None:
metadata["narration"] = _json(command.narration)
if command.purpose is not None:
metadata["purpose"] = command.purpose
try:
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
except (ValueError, TypeError) as exc:
@@ -1258,6 +1268,7 @@ class OperationalTaskService:
task_id, "action_approval_pending", task["title"], command.reason, now,
required_permission=approval_permission,
subject_type="action", subject_id=action.action_id,
work_order_action=action if action.kind == "create_work_order" else None,
)
await self._save_request(
scope, command.request_id, body, {"action_id": str(action.action_id)}
@@ -1743,6 +1754,29 @@ class OperationalTaskService:
task_id, "task_failed",
task["title"], command.detail, now,
)
if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}:
# The feedback, its notification and outbox event commit together.
# Duplicate request/source versions return above, before publishing.
work_order_action = next((item for item in await self.repo.list_actions(str(task_id))
if item["kind"] == "create_work_order" and item.get("receipt")
and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None)
is_work_order_feedback = (task["task_type"] == "external_work_order"
or work_order_action is not None
or (task["task_type"] == "event_investigation" and matching_source))
if is_work_order_feedback:
await self._notify(
task_id, "work_order_feedback", "收到工单反馈",
command.evidence.summary if command.evidence else command.detail, now,
subject_type="action" if work_order_action else "task",
subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id,
work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None,
work_order_reference=WorkOrderNotificationReference(
source_event_id=command.source_event_id,
source_revision=command.source_revision,
evidence_id=command.evidence.evidence_id if command.evidence else None,
status=command.status,
),
)
response = {
"source_event_id": command.source_event_id,
"source_revision": command.source_revision,
@@ -2161,12 +2195,24 @@ class OperationalTaskService:
lifecycle = self._investigation_status(actions, task)
recovery = task.get("handling_context") or {}
if lifecycle == "completed" and recovery:
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
if task.get("workflow") == "water_pollution":
orders = [item for item in recovery.get("work_orders", {}).values()
if not item.get("superseded_by")]
feedback_refs = [item.get("feedback_evidence_id") for item in orders]
require(recovery.get("phase") in {"field_review", "completed"} and bool(orders)
and all(item["status"] == "completed" for item in orders)
and all(ref and ref in report.report.evidence_refs for ref in feedback_refs),
"POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409)
verification_id = feedback_refs[-1]
else:
verification_id = recovery.get("verification_evidence_id")
require(verification_id in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
"恢复复核通过后才能结束事件", status=409)
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
else "恢复供水复核成功"), completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), verification_id)
elif report.report.outcome == "insufficient_data":
terminal = all(node.status in {"completed", "skipped", "cancelled"}
for node in plan.plan.nodes)
@@ -2519,6 +2565,8 @@ class OperationalTaskService:
*, required_permission: str | None = None,
subject_type: str = "task", subject_id: UUID | None = None,
report_reference: InvestigationReportReference | None = None,
work_order_reference: WorkOrderNotificationReference | None = None,
work_order_action: HumanApprovalAction | None = None,
) -> None:
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=task_id, kind=kind,
@@ -2527,6 +2575,8 @@ class OperationalTaskService:
lifecycle_status="active", subject_type=subject_type,
subject_id=subject_id or task_id,
report_reference=report_reference,
work_order_reference=work_order_reference,
work_order_action=work_order_action,
)
await self.repo.insert_notification(_json(notification))
await self._append_event(
+12 -1
View File
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
return None
def pollution_signal(document: dict, event: dict) -> bool:
"""Water-quality measurements have their own incident investigation workflow."""
if event.get("category") == "data_quality":
return False
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
ids = event.get("device_ids") or next((item.get("device_ids", [])
for item in (document.get("config") or {}).get("rules", [])
if item["rule_id"] == event.get("rule_id")), [])
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
def diagnostic_signal(document: dict, event: dict) -> bool:
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
if event.get("category") == "data_quality":
if event.get("category") == "data_quality" or pollution_signal(document, event):
return False
if not weighted_diagnosis(document):
return True
+3 -3
View File
@@ -10,14 +10,14 @@ from app.domain.domain_errors import require
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:
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
# 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:
+39 -46
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
from uuid import NAMESPACE_URL, uuid4, uuid5
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from app.core.config import settings
@@ -429,6 +430,7 @@ class ScadaReplayService:
await self.repo.save(document)
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
from app.services.scada_normal_profile import pollution_signal
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
@@ -443,9 +445,10 @@ class ScadaReplayService:
self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope)
for event in changes:
# Period diagnosis owns measurement notifications during manual steps.
# Retain rule events as evidence; quality alerts remain independent.
if (not create_tasks and event.get("category") != "data_quality"
pollution = pollution_signal(document, event)
# Pressure/flow rules share period diagnosis during manual steps.
# Water-quality incidents and telemetry-quality alerts stay independent.
if (not create_tasks and not pollution and event.get("category") != "data_quality"
and (event["change"] == "raised" or event.get("notification_suppressed"))):
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
@@ -457,13 +460,33 @@ class ScadaReplayService:
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution):
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
workflow="water_pollution" if pollution else "scada_anomaly",
title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}",
summary=event["detail"], source=TaskSource(
kind="external_system", external_id=external, external_revision=1,
event_kind="water_pollution" if pollution else "scada_anomaly",
replay_context={"replay_id": document["replay_id"],
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
),
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail=(
"水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else
"已存 SCADA 历史的回放检测信号,原因尚待调查")),
)
task = await tasks.create(command)
event["task_id"] = str(task.task_id)
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
await alerts.push(TaskAlertEvent(
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
occurred_at=event["occurred_at"], condition_id=None,
occurred_at=event["occurred_at"],
condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else None,
source={"kind": "scada_replay", "replay_id": document["replay_id"],
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning",
))
if event["change"] != "raised":
if event.get("task_id"):
@@ -472,25 +495,6 @@ class ScadaReplayService:
"batch_sequence": batch["sequence"],
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
continue
if event.get("category") == "data_quality":
continue
if not create_tasks:
continue
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
summary=event["detail"], source=TaskSource(
kind="external_system", external_id=external, external_revision=1,
event_kind="scada_anomaly",
replay_context={"replay_id": document["replay_id"],
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
),
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
)
task = await tasks.create(command)
event["task_id"] = str(task.task_id)
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
async def _create_monitoring_task(self, document, monitoring, batch):
from app.services.scada_monitoring_incidents import attach_incident, update_incident
@@ -528,35 +532,24 @@ class ScadaReplayService:
external = incident["external_id"]
pressure = monitoring["pressure"]
flow = monitoring["outlet_flow"]
detector = monitoring["burst_detection"]
score = detector.get("target_score")
score_text = f"{score:.4f}" if score is not None else "数据不足"
summary = (
f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。"
f"爆管侦测得分 {score_text};"
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
)
findings = []
if pressure.get("hit_device_ids"):
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
if flow.get("hit_device_ids"):
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
if monitoring.get("anomaly_signals"):
signals = monitoring["anomaly_signals"]
devices = {device for item in signals for device in item.get("device_ids", [])}
summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。"
if monitoring.get("weighted"):
weighted = monitoring["weighted"]
weights = weighted["weights"]
summary += (
f" 综合评分 {weighted['score']:.2f},爆管嫌疑门槛 {weighted['threshold']:.2f}"
f"(侦测 {weights['burst_detection']:.0%}、压力 {weights['pressure']:.0%}、"
f"流量 {weights['outlet_flow']:.0%})。"
)
if detector.get("score_threshold") is not None:
summary += f" 侦测动态阈值 {detector['score_threshold']:.4f}。"
summary += f"本次异常涉及 {len(devices)} 个测点。"
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external),
task_type="event_investigation",
workflow="scada_anomaly",
title=f"SCADA 异常调查 · {monitoring['period_end']}",
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
summary=summary,
source=TaskSource(
kind="external_system",
+8
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.domain.time import parse_clock_duration_seconds
from app.infra.db.postgresql.scada import ScadaElementMappings
def _isolated_analysis(purpose: str):
@@ -79,6 +80,7 @@ def burst_analysis(
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | None:
"""
爆管模拟
@@ -123,6 +125,7 @@ def burst_analysis(
name=new_name,
simulation_type="manually_temporary",
modify_pattern_start_time=modify_pattern_start_time,
scada_mappings=scada_mappings,
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
@@ -203,6 +206,7 @@ def burst_analysis(
modify_variable_pump_pattern=modify_variable_pump_pattern,
modify_valve_opening=modify_valve_opening,
scheme_type="burst_analysis",
scada_mappings=scada_mappings,
scheme_name=scheme_name,
result_db_name=name,
scheme_username=username,
@@ -312,6 +316,7 @@ def flushing_analysis(
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
persist_results: bool = True,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None:
"""
管道冲洗模拟
@@ -441,6 +446,7 @@ def flushing_analysis(
modify_total_duration=modify_total_duration,
modify_valve_opening=modify_valve_opening,
valve_control=valve_control,
scada_mappings=scada_mappings,
scheme_type="flushing_analysis",
scheme_name=scheme_name,
result_db_name=name,
@@ -469,6 +475,7 @@ def contaminant_simulation(
username: str | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None:
"""
污染模拟
@@ -591,6 +598,7 @@ def contaminant_simulation(
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
scada_mappings=scada_mappings,
)
return result if not persist_results else None
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
"sha256": "c31e9b4c71216951d1525acf1bcb8aeba4158b6cdc6b0b9dc01be02f9b6e6af3"
}
}
}
+337 -9
View File
@@ -3537,6 +3537,81 @@
"title": "InvestigationDataGap",
"type": "object"
},
"InvestigationDataGapInput": {
"additionalProperties": false,
"description": "New gap descriptions use business prose; references remain opaque.",
"properties": {
"blocking": {
"title": "Blocking",
"type": "boolean"
},
"evidence_refs": {
"items": {
"minLength": 1,
"type": "string"
},
"minItems": 1,
"title": "Evidence Refs",
"type": "array"
},
"gap_id": {
"minLength": 1,
"title": "Gap Id",
"type": "string"
},
"impact": {
"description": "资料缺项对当前判断的实际影响",
"maxLength": 4000,
"minLength": 1,
"title": "Impact",
"type": "string"
},
"missing_data": {
"description": "缺失的业务资料,不写执行字段",
"maxLength": 4000,
"minLength": 1,
"title": "Missing Data",
"type": "string"
},
"node_id": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Node Id"
},
"required_data": {
"description": "需要补充的具体业务资料",
"maxLength": 4000,
"minLength": 1,
"title": "Required Data",
"type": "string"
},
"scope": {
"description": "缺口涉及的对象、地点与当地时间范围",
"maxLength": 4000,
"minLength": 1,
"title": "Scope",
"type": "string"
}
},
"required": [
"gap_id",
"missing_data",
"scope",
"impact",
"required_data",
"evidence_refs",
"blocking"
],
"title": "InvestigationDataGapInput",
"type": "object"
},
"InvestigationDiagram": {
"additionalProperties": false,
"properties": {
@@ -3739,9 +3814,17 @@
"type": "array"
},
"label": {
"minLength": 1,
"title": "Label",
"type": "string"
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"description": "Required for a new node. Omit or use null to retain an existing node's label. Use the same node_id to revise status or detail without adding another step.",
"title": "Label"
},
"map_targets": {
"items": {
@@ -3777,8 +3860,7 @@
}
},
"required": [
"node_id",
"label"
"node_id"
],
"title": "InvestigationGraphNodeUpdate",
"type": "object"
@@ -3900,7 +3982,8 @@
]
},
"summary": {
"maxLength": 20000,
"description": "用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号",
"maxLength": 300,
"minLength": 1,
"title": "Summary",
"type": "string"
@@ -4103,18 +4186,22 @@
"description": "Write model: every new report explicitly declares its business result.",
"properties": {
"conclusion_boundary": {
"description": "只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数",
"maxLength": 240,
"minLength": 1,
"title": "Conclusion Boundary",
"type": "string"
},
"current_conclusion": {
"description": "先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table",
"maxLength": 300,
"minLength": 1,
"title": "Current Conclusion",
"type": "string"
},
"data_gaps": {
"items": {
"$ref": "#/components/schemas/InvestigationDataGap"
"$ref": "#/components/schemas/InvestigationDataGapInput"
},
"title": "Data Gaps",
"type": "array"
@@ -4147,6 +4234,7 @@
},
"pending_items": {
"items": {
"maxLength": 160,
"minLength": 1,
"type": "string"
},
@@ -4155,6 +4243,7 @@
},
"recommendations": {
"items": {
"maxLength": 160,
"minLength": 1,
"type": "string"
},
@@ -4189,6 +4278,7 @@
"stop_reason": {
"anyOf": [
{
"maxLength": 160,
"minLength": 1,
"type": "string"
},
@@ -4199,6 +4289,8 @@
"title": "Stop Reason"
},
"title": {
"description": "简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳",
"maxLength": 80,
"minLength": 1,
"title": "Title",
"type": "string"
@@ -5160,7 +5252,8 @@
"access_approval_pending",
"action_approval_pending",
"human_input_required",
"external_coordination_failed"
"external_coordination_failed",
"work_order_feedback"
],
"title": "Kind",
"type": "string"
@@ -5295,6 +5388,26 @@
"minimum": 1.0,
"title": "Version",
"type": "integer"
},
"work_order_action": {
"anyOf": [
{
"$ref": "#/components/schemas/HumanApprovalAction"
},
{
"type": "null"
}
]
},
"work_order_reference": {
"anyOf": [
{
"$ref": "#/components/schemas/WorkOrderNotificationReference"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -7431,7 +7544,8 @@
"metric": {
"enum": [
"pressure",
"flow"
"flow",
"quality"
],
"title": "Metric",
"type": "string"
@@ -9712,6 +9826,7 @@
"cancel_task_run",
"run_simulation",
"run_contaminant_simulation",
"run_contaminant_source_trace",
"analyze_valve_closure",
"analyze_flushing",
"analyze_pressure_regulation",
@@ -10129,6 +10244,30 @@
],
"title": "Command"
},
"narration": {
"anyOf": [
{
"$ref": "#/components/schemas/TaskPresentationNarrationInput"
},
{
"type": "null"
}
]
},
"purpose": {
"anyOf": [
{
"maxLength": 160,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"description": "一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现",
"title": "Purpose"
},
"request_id": {
"format": "uuid",
"title": "Request Id",
@@ -10207,11 +10346,34 @@
"title": "Execution Generation",
"type": "integer"
},
"narration": {
"anyOf": [
{
"$ref": "#/components/schemas/TaskPresentationNarration"
},
{
"type": "null"
}
]
},
"occurred_at": {
"format": "date-time",
"title": "Occurred At",
"type": "string"
},
"purpose": {
"anyOf": [
{
"maxLength": 160,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Purpose"
},
"run_id": {
"format": "uuid",
"title": "Run Id",
@@ -10269,6 +10431,125 @@
"title": "TaskPresentationHistoryPage",
"type": "object"
},
"TaskPresentationNarration": {
"additionalProperties": false,
"properties": {
"evidence_refs": {
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 100,
"minItems": 1,
"title": "Evidence Refs",
"type": "array"
},
"next_step": {
"anyOf": [
{
"maxLength": 120,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Next Step"
},
"stage": {
"enum": [
"anomaly_detection",
"evidence_verification",
"asset_localization",
"event_impact",
"source_control",
"isolation_plan",
"flushing_plan",
"field_feedback",
"clearance_verification",
"recovery_verification"
],
"title": "Stage",
"type": "string"
},
"summary": {
"maxLength": 160,
"minLength": 1,
"title": "Summary",
"type": "string"
}
},
"required": [
"stage",
"summary",
"evidence_refs"
],
"title": "TaskPresentationNarration",
"type": "object"
},
"TaskPresentationNarrationInput": {
"additionalProperties": false,
"description": "New live findings use business prose; saved narration remains readable.",
"properties": {
"evidence_refs": {
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 100,
"minItems": 1,
"title": "Evidence Refs",
"type": "array"
},
"next_step": {
"anyOf": [
{
"description": "实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行",
"maxLength": 120,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Next Step"
},
"stage": {
"enum": [
"anomaly_detection",
"evidence_verification",
"asset_localization",
"event_impact",
"source_control",
"isolation_plan",
"flushing_plan",
"field_feedback",
"clearance_verification",
"recovery_verification"
],
"title": "Stage",
"type": "string"
},
"summary": {
"description": "本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的",
"maxLength": 160,
"minLength": 1,
"title": "Summary",
"type": "string"
}
},
"required": [
"stage",
"summary",
"evidence_refs"
],
"title": "TaskPresentationNarrationInput",
"type": "object"
},
"TaskQuestion": {
"additionalProperties": false,
"properties": {
@@ -11130,6 +11411,53 @@
"title": "WorkOrderDraftUpdateCommand",
"type": "object"
},
"WorkOrderNotificationReference": {
"additionalProperties": false,
"properties": {
"evidence_id": {
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Evidence Id"
},
"source_event_id": {
"minLength": 1,
"title": "Source Event Id",
"type": "string"
},
"source_revision": {
"minimum": 1.0,
"title": "Source Revision",
"type": "integer"
},
"status": {
"enum": [
"scheduled",
"assigned",
"in_progress",
"awaiting_feedback",
"completed",
"cancelled",
"failed"
],
"title": "Status",
"type": "string"
}
},
"required": [
"source_event_id",
"source_revision",
"status"
],
"title": "WorkOrderNotificationReference",
"type": "object"
},
"WorkbenchAlertSource": {
"additionalProperties": false,
"properties": {
+2 -1
View File
@@ -221,7 +221,8 @@
"type": "string",
"enum": [
"pressure",
"flow"
"flow",
"quality"
]
},
"unit": {
+27 -8
View File
@@ -8,10 +8,12 @@
flowchart TD
order[爆管上报工单] --> context[读取原工单与当前任务]
context --> asset[核对地点并关联管网资产]
asset --> isolation[分析隔离边界与阀门方案]
isolation --> impact[说明事件与隔离方案影响]
impact --> report[保存阶段报告与处置建议]
report --> approval[人工审批与现场执行]
asset --> interim[保存定位阶段报告]
interim --> approval[立即发起工单草稿、人工审核派发]
interim --> burst[爆管模拟与同刻在线压力差]
burst --> coverage[保存并展示爆管后、处置前影响覆盖]
coverage --> isolation[分析隔离边界与阀门方案]
isolation --> report[补充阶段报告与处置建议]
approval --> feedback[接收实际处置与恢复反馈]
feedback --> verify[复核影响与结案条件]
verify --> close[Server 完成任务并解除告警]
@@ -20,10 +22,11 @@ flowchart TD
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。
4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。
6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
@@ -64,3 +67,19 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。
## 爆管压力下降覆盖与历史还原
`run_burst_simulation` 新增可选 `include_impact_coverage=true`,原默认行为保留。指定已定位管道、带时区的事件起算时刻及有来源或明确估计依据的破口面积(cm²)。计算保持隔离临时模型、不保存分析方案、不重算在线基线,不施加新的关阀或抢修操作。
在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。
输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。地图采用黄/橙/红,图例保留每档已绘制/分组节点数。
爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。
携带 `replay_context` 的调查只开放启用影响覆盖的爆管情景,起算时刻不得超过 `knowledge_cutoff`。使用当前模型的独立假设情景,初始化及扩展模拟均传入空SCADA边界映射,禁止从实时SCADA回填冻结观测;同刻在线结果仅作为计算时读取的模型参考,标明非冻结基线。未来样本属于预测,不能作为已发生的观测。
完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。
全网结果超过单次工具回执上限时,默认 `get_calculation_result` 返回标明缩略的计数与模型条件、保存证据资源链接及可发布地图命令。原始逐节点结果和几何保持完整,可通过证据资源或 `data_path=["result","impact","comparisons"]` 分页读取;不将全网计算缩为20个展示节点。
@@ -32,7 +32,9 @@ Server 将阅读进展、业务报告和浏览器展示请求分别保存。运
}
```
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`graph_update` 可省略。
示例中的编号由实际任务和资产替换。新发布的 `summary` 最多 300 字符,建议 120 字以内,
只说明本轮新增发现、当前依赖和下一步;使用中文业务语言与当地时间,不复制报告或执行参数。
详细结果和引用留在图节点、证据与报告明细中,历史正文仍按原值读取。`graph_update` 可省略。
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
@@ -55,6 +57,23 @@ Server 不验证依赖执行顺序或推断节点完工。
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
“保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`;
修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。
省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。
证据、动作和地图引用继续按集合累加,空列表不移除已有引用。资料或条件补齐后,应显式修订原 `needs_input` 节点,
新增下游节点不会自动改变上游标记,也不根据节点顺序推断执行结果。例如已有节点 `read` 可更新为:
```json
{
"summary": "补充资料已核对,更新原核查节点。",
"graph_update": {
"nodes": [{"node_id": "read", "status": "recorded", "detail": "已核对收到的现场记录。"}]
}
}
```
上述更新不增加节点或连线,也不改写已归档的报告图。旧图已保存于 `node_refs` 的节点支持相同的局部修订;
仅存在于旧 Mermaid 源码、尚无节点元数据的编号仍不能作为新增步骤重用。
尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
@@ -90,6 +109,33 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
不表示浏览器已显示或现场动作已执行。
### 展示表达与阅读
实时展示与正式报告共用中文业务表达和时间要求,字段沿用现有结构:
| 字段 | 表达职责 | 长度 |
| --- | --- | --- |
| purpose | 为什么核查这些对象;不复述动作名称、当前发现或完整报告 | 建议 60 字以内,最多 160 字符 |
| narration.stage | 本步所属业务阶段,前端显示中文阶段名称 | 使用既有枚举 |
| narration.summary | 本步已有证据支持的关键发现,区分观测、候选、模型与现场反馈 | 建议 80 字以内,最多 160 字符 |
| narration.next_step | 实际下一步或等待条件;无后续时省略 | 建议 60 字以内,最多 120 字符 |
| narration.evidence_refs | 该发现引用的实际证据,不作为正文展示 | 保持现有引用校验 |
已有明确分析结果时提供 `narration`;纯视角调整或清除操作不编造发现。
目的、发现和后续分别组织,不重复粘贴同一段文字。同一核查目的的批量图表可以复用有依据的
相同讲解,保留合并阅读能力;不同发现分别保存,不为合并而抹去结论差异。
讲解、目的和图表、覆盖层的可见标签均采用纯文本,省略执行字段、版本号、技术状态码、
完整 UUID、证据哈希、时区名称、偏移和 ISO 时间戳。必要设备编号、单位与历史日期保留;
标题交代对象、指标或预计范围,算法参数留在证据明细。结构化采样时刻、设备标识、数值、
几何和证据引用保持原值,不因文字整理改变分析范围。
Server 的新请求模型、Agent 原生工具及宿主入口校验这些文字规则;历史读取模型保持兼容。
前端在当前动作说明中突出已保存的发现,再呈现目的和后续,缺少讲解的历史步骤只显示用途,
不推断结论。讲解进入已有滚动阅读区,历史与播放控件留在标题区,长文字不挤出控件。
历史选择固定在所选步骤;自动停留时间考虑实际可见的目的、发现与后续文字,
用户手动选择的停留时间保持优先。上述文字和播放行为不控制后台调查、审批或现场执行。
| command.action | 参数 |
| --- | --- |
| locate_features | layer、feature_ids |
@@ -236,3 +282,12 @@ git diff --check
```
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
## 工单通知与历史展示
工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。
前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。
历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。
+59 -11
View File
@@ -9,17 +9,19 @@
| 字段 | 类型 | 新写入规则 |
| --- | --- | --- |
| title | 非空字符串 | 报告标题 |
| current_conclusion | 非空字符串 | 当前判断文字;无结论时明确写无法判断,不编造原因 |
| conclusion_boundary | 非空字符串 | 判断范围及限制 |
| title | 非空字符串,最多 80 字符 | 简短中文业务标题,建议 30 字以内,不包含执行轮次和流程版本 |
| current_conclusion | 非空字符串,最多 300 字符 | 首句写当前判断,接关键依据和实际下一步,建议 150 字以内;完整数值放入表格 |
| conclusion_boundary | 非空字符串,最多 240 字符 | 影响判断的证据范围、不确定性和适用限制;不复述执行过程、参数或完整资产清单 |
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
| recommendations | 字符串数组 | 保持原类型,处置建议不等于已执行动作 |
| pending_items | 字符串数组 | 保持原类型,不从文字推断状态 |
| recommendations | 字符串数组,每项最多 160 字符 | 具体对象与后续动作;处置建议不等于已执行动作 |
| pending_items | 字符串数组,每项最多 160 字符 | 实际尚未完成或等待外部条件的事项,不从文字推断状态 |
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
| stop_reason | 非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
| stop_reason | 最多 160 字符的非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
| ui_blocks | 结果组件数组,最多 8 块、合计 400 KB | 以现有 Result、Section、Table 等组件表达重点与明细,块内引用必须属于报告证据 |
| scada_device_ids | 设备编号数组,最多 1000 项 | 监测明细与展示目标的完整集合,不能因精简摘要删除目标 |
缺口对象除 node_id 外的字段必填:
@@ -27,10 +29,10 @@
| --- | --- | --- |
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
| missing_data | 非空字符串 | 缺失数据内容 |
| scope | 非空字符串 | 查询对象、地点及时间范围 |
| impact | 非空字符串 | 因此无法回答的调查问题 |
| required_data | 非空字符串 | 新一轮调查需要补齐的具体数据 |
| missing_data | 非空字符串,最多 4000 字符 | 缺失的业务资料 |
| scope | 非空字符串,最多 4000 字符 | 查询对象、地点及当地时间范围 |
| impact | 非空字符串,最多 4000 字符 | 因此无法回答的调查问题 |
| required_data | 非空字符串,最多 4000 字符 | 新一轮调查需要补齐的具体数据 |
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
| blocking | boolean | 是否阻止形成结论 |
@@ -42,6 +44,48 @@
不包含 Markdown 围栏、初始化指令和 click 命令。前端使用 strict 安全级别渲染 SVG。
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
## 报告表达与生成要求
报告面向供水运维人员,按当前判断、关键依据、判断限制、后续安排组织内容。
字段仍按现有模型发布,不将多段报告重新堆入 `current_conclusion` 或 `conclusion_boundary`。
- 结论首句直接回答当前发生了什么、形成什么判断。随后只保留最关键的变化和实际下一步,
正文最多举两个关键对象。评分权重、算法参数、全部测点、阀门和影响对象放入依据明细。
- 多个对象或多组数值比较使用有证据引用的 `ui_blocks.Table`。表格按实际资料选择对象、
指标、时刻、异常值、当前值、基线与结果等列,单位放在列名或单元格;缺值明确写未取得。
全部目标按需分表,不以精简正文为由缩小调查、证据或 SCADA 展示覆盖。
- 使用 `Result` 突出有依据的判断、关键变化或实际待办,使用带业务标题的 `Section` 组织明细。
模型候选或尚待现场核验用 `unconfirmed`,部分完成用 `partial`;`success` 必须有相应通过依据。
现有文字字段按纯文本展示,不写 Markdown 加粗、标题、表格或 HTML,也不引入自定义强调字段。
- 正文、进展、缺口说明和组件可见文字采用中文业务语言。执行字段、技术状态码、流程版本、
完整 UUID 和证据哈希留在结构化引用或执行记录中。必要资产编号、SCADA、DN600、单位、
模型相似度和有业务用途的工单简称可以保留,不能机械删除所有英文或数字。
- 阅读时间先换算为 `Asia/Shanghai`,再以当地日期和时刻显示,例如 `2026-07-16 13:15`。
正文、标题、进展、表格和图表均省略时区名称、偏移及 ISO 原始时间戳。已有 `+08:00`
不重复换算,跨日同步更新日期;查询时间、`sample_times` 和证据时间戳仍保留明确偏移。
事件发生、工单完工、恢复采样和报告生成时间各自标明用途,不能互相代替。
- 区分观测、模型估计、候选和现场反馈。相似度不是发生概率,提交工单不证明现场完工,
单帧复核通过不证明长期稳定;不同来源或阶段的设备数分别说明范围,不能混用分母。
单条流量规则未触发不表示所有流量未变化;不同影响集合不能直接相加。
- 判断边界只保留影响理解的限制。实际资料缺项放入 `data_gaps`;未来常规采样和日常监测
写入建议,不将其包装成当前关键缺口。非阻塞资料缺项不得新增事件闭环条件。
初次调查说明异常、候选及预计影响;等待处置说明已完成事项、实际等待条件和下一步;
恢复报告先说明现场反馈与完工时间、恢复复核时刻与结果,再给出必要限制和后续监测安排。
恢复明细来自本次恢复证据,原异常分析作为历史依据保留,不在摘要中重复全部定位与隔离参数。
`progress.summary` 最多 300 字符,建议 120 字以内,只写本轮新增发现、当前依赖和下一步,
不复制正式报告、不汇报执行字段和流程版本。整理报告时核对脉络图:已解除的等待条件应按
原节点更新业务说明和参考标记,保留失败尝试与原路线,不重复添加相同连线。
这不要求逐步更新图示,也不改变任何运行、审批、等待或闭环判据。
Server 的新写入模型及 Agent 的原生工具、宿主发布入口校验长度、明显执行字段与技术编号、
原始时间表达和无法渲染的格式标记;组件的可见文字同样校验,结构化证据编号与采样时间除外。
校验错误返回具体原因,由 Agent 依据原证据改写后重试,不截断正文、不删除引用或补造缺口。
语义准确性、信息取舍和表格完整性仍需遵循提示词与既有证据、覆盖校验,不能由正则代替。
上述长度和文字规则仅约束新发布,不应用于历史读取或原证据快照;不回写已有报告。
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
@@ -133,7 +177,7 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
"gap_id": "terminal-sample-gap",
"node_id": null,
"missing_data": "投诉时段末梢水样检测结果",
"scope": "投诉小区,2026-09-24 08:00 至 09:00(北京时间)",
"scope": "投诉小区,2026-09-24 08:00 至 09:00",
"impact": "无法判断异常来自供水管网还是用户端。",
"required_data": "该地点和时段的采样检测记录",
"evidence_refs": ["sample-query-empty"],
@@ -150,6 +194,10 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
- 新发布拒绝超长摘要、正文执行字段、完整技术编号、原始时间表达和未支持的格式标记;
相同历史报告仍可读取,结构化证据引用与带偏移的采样时间保持不变。
- 生成结果结论在前、明细入表,明确单位、时间和各自覆盖范围;
候选、现场反馈、单帧复核与长期监测保持各自含义,进展和图示不与当前事实冲突。
## 发布、阅读与协作
+3 -2
View File
@@ -5,13 +5,14 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
| --- | --- | --- |
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
| `run_burst_simulation` | 假设管道破口正向模拟;可选全网同刻在线压力差与1/3/5米水头覆盖,详见[爆管流程](burst-response-workflow.md) | `simulation.run` + `scada.view`,影响比较另需 `simulation.view` |
| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` |
| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` |
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
| `analyze_flushing` | 指定排放点、流量与互斥的 `opened_valves` / `closed_valves`,评估独立冲洗水力情景并保存阀态和预计排放量;不证明污染清除或水质合格 | `simulation.run` + `scada.view` |
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
+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[分区恢复建议与最终报告]
```
图中连线表示业务与展示层次,先期保护、工单审核和独立计算可以并行。阀门操作均为方案,现场动作继续经过原审批与派发;阶段记录不能替代执行回执。首次展示保留处置前传播结果或明确的数据缺口,再展示隔离及清除方案。
## 侦测入口
现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。
当前侦测来源沿用既有 SCADA 历史回放处理器;本次没有新增实时采集或常驻水质侦测调度器。其他可信侦测生产者可经现有 operational-tasks 创建接口提交同一 workflow,保留实际侦测信号与发生时间;普通任务无需 replay_context。
## 临时溯源契约
`run_contaminant_source_trace` 要求 `simulation.run`,只读当前项目模型,禁止只读调查子角色调用。输入包含从水质设备映射或资产查询取得的 `candidate_node_ids`(1–20 个不同节点)及带时区、最长24小时的 `start/end`。回放窗口不能越过知识截止。接口直接返回结果,当前不入计算队列。
临时实现从提交的候选中核验模型节点,按字符串排序返回首个存在的节点,不执行反演或拟合。返回 `located_node`、`top_candidates`、`validated_node_ids`、`missing_node_ids`、`selection_basis`、`algorithm_status=provisional`;证据 `origin=synthetic`,不编造相似度或置信度。所有节点不存在时明确返回 `no_candidate`。后续正式算法替换服务内部选择逻辑,保留节点结果契约及后续调查步骤。
候选节点只是当前可分析目标;定位后先保存 `interim/concluded`,立即提出关联 `general` 现场核查/采样处置工单,随后继续污染传播模拟和 `accident_element_type=node` 的隔离。审核和派发不依赖模拟完成;等待工单时继续独立分析,模拟结果补入同一 `task_id`,不重复创建工单或改写已审核动作,权限与版本校验保持原有流程。
## 污染传播模拟与影响覆盖
`run_contaminant_simulation` 增加 `include_impact_coverage`(默认 false)与 `impact_threshold_mg_l`(默认0,严格大于)。开启时,在筛选最多20个展示节点之前,使用原生结果的全网节点、整个模拟窗口的报告采样汇总累计超阈值节点数、各节点首个超阈值报告时刻和峰值。原生浓度 `MGL` / `UGL` 分别转换为 mg/L 后比较;缺失、无效浓度、错误时间网格或不支持的单位保留为缺口,不能转成零浓度或安全范围。几何超过10000节点上限时仍返回数值估计,并明确地图不可用。
阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。
回放同样允许该工具,要求启用影响覆盖且起算不晚于知识截止;预测时段可以越过截止,但后续时刻只是模型预测。与爆管情景一样,使用当前模型和空 SCADA 映射传入临时模型计算,不读取可变实时监测,不重建历史模型。仍禁止借此放开未授权观测或其他水力工具。
工具沿用计算队列和 owner/run 校验,返回终态后通过 `get_calculation_result` 读取并持久保存证据。`presentation_preview` 提供基于 `evidence_id` 的地图请求;用正式调查展示事件保存模型曲线和覆盖,历史播放还原已保存证据。审批、派发可在计算排队或运行期间进行;存在未终态计算时继续保留原运行,不允许发布 final 或成功结束调查,已有工单不受模拟失败影响。
## 展示、反馈与收尾
`water-pollution-event` 1.2.0 将观测核验、候选定位、处置前传播、源头控制、用户隔离、清除冲洗、现场反馈、水质复测和恢复复核分开。每次正式展示保存 `purpose`;有证据的阶段通过 `narration.stage` 记录,新增 `source_control`、`flushing_plan`、`clearance_verification`。历史保存格式继续使用原事件 metadata JSON,不增加数据库列。前端显示阶段和本步目的,逐步回看保留独立画面;同阶段兼容图表仍可合并。
源头控制与保护下游用户分别决策。用已核实的连接和流向判断水力下游,不能按地理方位判断;源头未核实不得声称污染输入已停止。清除方法按污染物及现场条件选择,不统一推荐煮沸、加药或排水。通知保留,工单按执行职责和阶段复用或必要拆分,不能同时要求同一阀门开和关。
### 冲洗模型与阀门方案
`analyze_flushing` 增加可选 `opened_valves`,与 `closed_valves` 互斥且各自去重。明确开阀用于清洁水进入或导流,关阀用于保护用户和约束路径;不改变阀门原调节设定。普通计算和保存冲洗方案使用相同显式阀态。未列阀门沿用当前模型状态,每次计算为独立情景,不能继承上次计算的阀位或浓度场。
每一步操作表保存:目的、前置条件、清洁水源及依据、排放节点与污染水处置去向、完整开关阀清单、计划顺序、流量、持续时间、预计排放量、监测要求、实际执行反馈。清洁进水阀打开不表示用户可以恢复供水。路径只采用已核实资产几何,不用直线伪造流向。
计算返回 `flushing_plan`,包含开关阀、排放节点、流量和 `estimated_discharge_volume_m3`(给定流量乘持续时间)。它仅是水力情景,`water_quality_clearance_verified=false`、`field_execution_verified=false`;估计水量不是实测排放量。当前不模拟停止污染输入后的连续清除衰减,也不验证进水洁净、排放合规或实验室合格。缺少模型或现场依据时报告具体限制,不伪造清除效果。带回放上下文的调查仅新增独立模型冲洗情景:起算不晚于知识截止,始终传入空 SCADA 映射,禁止实时观测回填;原角色、任务、权限及作业所有权校验保留。历史播放读取已保存方案,不重新计算或现场操作。
### 复测与恢复依据
复核记录采样地点、时间、指标、数值、单位、适用判定依据、受影响范围覆盖及结论,区分实际结果和模型预测。工单完成、压力恢复或余氯单项正常不能证明目标污染物已清除。关键复测资料不足或未通过时,Agent 保存 `interim` 并提出具体补测或调整方案;不统一新增固定轮数、时长或阈值。已有足够复测与恢复依据时,展示可恢复区域并发布引用有效现场反馈及复测证据的最终报告。Server 继续校验反馈引用,不自动判定实验室合格,也不授权设备操作或现场复供。
水质图表使用 `chart.scada.metric=quality`,沿用真实单位、三小时当前趋势、前七天同期散点及证据校验;不同指标分图。同单位不同水质指标通过图表纵轴名称区分展示分组。Server、Agent、Frontend 的图表协议同步支持 quality,正式报告仍需引用本轮全量 SCADA 证据。
关联现场任务使用 general 工单。成功创建后保存在 `handling_context.work_orders`,进入 `waiting_work_order`;全部有效工单 completed 后进入 `field_review`,接续 Agent,而非执行爆管压力恢复计算。最终 concluded 报告必须引用全部有效工单最新的 `feedback_evidence_id`,然后由 Server 完成任务并解除关联告警。完工反馈或新版本复测资料会重新进入复核;证据不齐时 Agent 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。
所有新增状态保存在既有 JSON 文档,无数据库迁移、环境配置或新服务。现有服务更新代码及流程包后才生效;单元测试和契约校验不等同真实模型 Agent 调查验收。
@@ -2,14 +2,14 @@
name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata:
version: "1.1.3"
version: "1.1.4"
---
# 爆管事件处理
适用于工单触发的 `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保留明确时区,跨日按转换后的日期展示。
@@ -23,30 +23,40 @@ metadata:
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
## 3. 隔离方案
定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。
## 3. 爆管后、处置前的影响范围
核对定位管道后,用 run_burst_simulation(include_impact_coverage=true)模拟事件时刻的破口,orifice_areas_cm2 保留来源;无实测破口面积时可用有说明依据的估计情景,明确假设与不确定性,不把定位漏量直接当作破口面积。缺少依据则提具体待补参数,继续工单与隔离分析。
工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。
读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。先保留此步骤,再展示隔离方案,不能用后续隔离覆盖替换本步。
## 4. 隔离方案
对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
## 4. 影响说明
## 5. 影响说明
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。已有可靠破口参数时可按需补充事件情景模拟。
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。事件情景采用第3节结果,缺口单列。
- **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。
现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。
## 5. 处置建议与报告
## 6. 处置建议与报告
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
复用定位阶段已发起的工单及 action_id;保存更新后的 interim/concluded 阶段报告,再按新增需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第7节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
## 6. 反馈与闭环复核
## 7. 反馈与闭环复核
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
@@ -2,7 +2,7 @@
name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata:
version: "1.0.16"
version: "1.0.19"
---
# SCADA 数据异常调查
@@ -11,17 +11,17 @@ metadata:
先按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和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
全量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小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。
run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,缺口单列;先事件影响,再隔离。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。
再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
## 对比展示与影响
@@ -45,9 +49,9 @@ metadata:
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。
报告先写判断与关键依据,摘要建议 150 字、最多 300 字;多测点数值入 Table,保留单位、方向与缺测。区分各阶段测点数,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
## 完工后的恢复复核
@@ -55,4 +59,6 @@ metadata:
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
恢复报告先写现场反馈与完工时间、采样时刻和结果,表格列全部复核测点及基线;边界说明单帧与模型限制,日常监测列建议。不重列旧定位参数或执行字段;更新原图中已解除的等待标记,保留路线与失败记录。
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
@@ -0,0 +1,72 @@
---
name: water-pollution-event
description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。
metadata:
version: "1.2.0"
---
# 水污染侦测事件调查
适用于 workflow=water_pollution,与 water_quality_complaint 投诉分开,不依赖爆管评分、漏量或压力流量异常。侦测信号、候选污染源、污染确诊和恢复供水分别说明。
读取任务、信号、时间、handling_context 和已有成果。回放遵守 knowledge_cutoff、batch_sequence;阅读时间 Asia/Shanghai,查询与 sample_times 带时区。后续轮次复用成果,优先新增事实。
## 1. 核验观测与先期保护
query_scada_readings 省略 device_ids,读取异常前三小时全量 SCADA,核查分页完成、覆盖、质量、单位、映射及配置阈值。无效值或缺测不等于污染,业务异常不等于法定超标。逐设备对照七天同期,保留日期和 null;压力正常不能排除污染。
核验、采样与用户保护并行。按风险提出用水限制、通知、重点用户保障及应急供水;缺关联、适配器或批准依据则交人工核定,不称已执行。保护措施不等溯源或模拟,未知污染物不统一建议煮沸、加药或冲洗。
## 2. 候选溯源与现场工单
用设备映射、get_element_properties、get_node_connections 核实节点,缺映射则查坐标附近资产。检查 source_trace_capability,run_contaminant_source_trace 提交已核实 candidate_node_ids 和核查窗,读取候选、缺失节点及 selection_basis。当前为临时确定性选点,非正式反演,不编造概率或确诊。synthetic 不阻止分析,无节点仅停依赖分支。
取得候选后立即 publish_task_presentation 展示节点,保存 interim/concluded,注明“候选污染源待现场核验、传播范围计算中”;propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用定位证据和地点。沿用已有 action_id,不等模拟完成才发起工单。审核派发与模拟、隔离分析并行,结果补入同一 task_id,不覆盖已审核工单或绕过版本校验。
## 3. 处置前污染影响
run_contaminant_simulation 设 source=located_node、include_impact_coverage=true,采用事件起算时刻、明确预测时长和需画曲线的 output_node_ids。concentration 为 mg/L,须有来源或说明情景假设,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺依据时列具体参数缺口,继续工单和独立分析。
impact_threshold_mg_l 默认0、严格大于,仅供展示,非饮用水安全限值。全网整个时段汇总累计节点、首个超阈值采样时刻、峰值与覆盖;沿用初始水质和反应条件,非源独立贡献,范围外不等于安全。
queued/running 时继续独立工作,get_calculation_job 跟踪、get_calculation_result 读终态。未完计算不能 final,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。
按 presentation_preview.evidence_id 发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用 chart.scada。保存正式事件供历史复用;空范围、缺测、单位及几何限制单列。先保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。
回放允许传播及冲洗情景,起算不晚于知识截止,使用当前模型且不读取实时 SCADA;未来样本仅预测,非历史重建。
## 4. 源头控制与用户隔离
分别提出污染输入控制、污染区边界与用户保护。源头措施依据污染物、进入路径和现场事实;候选节点不是可关设备,不假定源已停止。用流向及连接核对水力下游,不能按地理方位推断。
analyze_valve_isolation 设 accident_element=[located_node]、accident_element_type="node",保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。失效阀门用 disabled_valves 核查替代方案,不能隔离时保留原因。
展示阀门,generate_node_coverage 分组生成直接隔离及新增失源覆盖,再 publish_task_presentation;空组不算。拓扑覆盖非污染边界,无资料不编户数。有权限和输入时 analyze_valve_closure 核验水力影响,不证明污染清除。
## 5. 污染物清除与分步冲洗
先说明污染物性质、处理依据和未知项,再提出排空、冲洗、消毒或其他适用处理;不得默认任何污染都能冲洗清除。排放去向及污染水收集处理须有依据,不默认排入雨水、河道或普通排水口。
核对清洁水源、排放点、流量及许可条件后用 analyze_flushing。每阶段保存目的、前置条件、清洁进水依据、排放去向、完整开关阀清单、顺序、流量、时长、预计水量、监测和待办。隔离和冲洗不能共用固定关阀清单,进水阀打开不等于用户复供;缺参数仅停依赖分支。
opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。每次是独立当前模型情景,未列阀门沿用模型状态,不继承上次模拟;重列本阶段必要阀态,不能称为连续污染清除。flushing_plan 保存方案及估计排放量,仅证明水力结果,不验证清洁进水、排放合规或现场执行。尚未耦合停止输入后的水质衰减,压力、流量或模型零浓度不能替代复测。
展示清洁水入口、保护边界、导流阀及排放点;有真实GIS才画路径。拟开、拟关、现场实际阀位分列,不改写实时资产。回放未授权工具记录限制,复用已有证据。
## 6. 图表、报告与展示节奏
水质 chart.scada 填 metric=quality、unit、role、完整三小时 window_start/window_end,总览覆盖全部目标。逐设备 time_comparison 用当前 line、七天每天末三真实槽 scatter;series 带 device_id、day_offset、等长 sample_times,缺测 null。不同指标或单位分图,超限分段,不标成流量。
展示依次为观测、候选、传播、源头控制、隔离、冲洗、反馈、复测、恢复;计算可并行,首次隔离展示须在传播结果或缺口说明之后,通知和工单不等待展示。发布填写具体 purpose;有证据时带 narration(stage、summary、evidence_refs)。沿用原阶段,源头控制用 source_control、冲洗用 flushing_plan、水质复测用 clearance_verification。阶段不等于批准或已执行,不为凑阶段造证据或空操作。
报告依次写事件概况、观测、候选与传播、源头控制、用户隔离、清除冲洗、反馈复测及恢复条件;各项列结论、对象、预计或实际、证据和待办。操作表保留阶段、阀态、进排水、条件、反馈及复测依据。引用本轮全量 SCADA 和已取得的分析证据;权限拒绝不写成无数据。各阶段独立保存,回放不合并吞步骤,不派单或操作设备。
## 7. 工单反馈与复测恢复
复用 general 工单及 action_id,仅因职责或阶段确需拆分才建新单,保留前置条件,不能同时要求同阀开关。回传实际阀位、进排水、执行时间和异常;复测含采样地点、时间、指标、结果、单位、判定依据及结论。通知保留,操作遵守原权限。
待审批或 waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用 action_id 后退出释放租约,不轮询或重复建单。field_review 复用原调查,只核对最新反馈与相关水质观测;关键事实缺失时先完成独立工作,再用 ask_task_user 提问并等待,必要时提出复测或调整处置工单。
工单 completed、压力恢复、余氯单项正常或模型预测均不能证明污染物已清除。恢复依据须说明目标污染物或已核实风险、适用检测项目及判定标准、实际采样结果与时间地点、受影响区域覆盖和有依据的恢复结论;无适用依据或结果未通过时保持 interim,列待复测区域与下一步,不能宣布恢复供水或发布恢复闭环 final。不能为所有事件统一增设固定轮数、等待时长或未配置阈值。
有复测与恢复依据后展示可恢复区域,final/concluded 引用所有有效工单最新 feedback_evidence_id 及复测证据。不套用爆管压力结案,仍异常调整方案,未来采样和恢复不写成已完成。Server不自动认定实验室合格或授权现场复供。
@@ -39,6 +39,28 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
@pytest.mark.anyio
async def test_progress_route_accepts_partial_node_updates_without_weakening_saved_diagrams(monkeypatch):
from app.domain.schemas.operational_task import AgentArtifactsCommand
schemas = app.openapi()["components"]["schemas"]
assert "label" not in schemas["InvestigationGraphNodeUpdate"]["required"]
assert "label" in schemas["InvestigationDiagramNodeRef"]["required"]
service = SimpleNamespace(publish_artifacts=AsyncMock(return_value={"progress_revision": 2}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
command = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": str(uuid4()),
"progress": {"summary": "已核对补充资料", "graph_update": {
"nodes": [{"node_id": "review", "status": "recorded"}],
}},
})
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
forwarded = service.publish_artifacts.await_args.args[1].progress.graph_update.nodes[0]
assert forwarded.node_id == "review" and forwarded.status == "recorded"
assert "label" not in forwarded.model_fields_set
@pytest.mark.anyio
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
from app.domain.domain_errors import DomainError
+197
View File
@@ -0,0 +1,197 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import Mock
import pytest
from app.services import burst_impact as service
from app.services.node_coverage import build_node_coverage
START = datetime(2026, 7, 16, 5, tzinfo=timezone.utc)
def simulation(drops, unit="m"):
return {"units": {"pressure": unit}, "times": {"report_start": 0},
"node_results": [{"node": f"J{i}", "result": [{"pressure": 30 - drop}]}
for i, drop in enumerate(drops)]}
@pytest.fixture
def inputs(monkeypatch):
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": "METERS"})
monkeypatch.setattr(service, "get_option_v3", lambda _: {})
read = Mock(side_effect=lambda ids, *a, **kw: {
node: [{"time": START.isoformat(), "value": 30}] for node in ids})
monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read)
def coverage(network, groups):
ids = sorted({node for group in groups for node in group["node_ids"]})
data = build_node_coverage(groups, [
{"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9}
for i, node in enumerate(ids)], [])
payload = data.pop("map_payload")
return {"coverage": data, "map_payload": payload}
monkeypatch.setattr(service, "generate_node_coverage", coverage)
return read
def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs):
result = service.estimate_burst_impact("own", START, simulation([.999, 1, 2.999, 3, 4.999, 5, 8, -1]))
impact = result["impact"]
assert [group["node_ids"] for group in impact["grades"]] == [["J1", "J2"], ["J3", "J4"], ["J5", "J6"]]
assert impact["below_threshold_node_count"] == 2
assert impact["compared_node_count"] == 8 and impact["affected_node_count"] == 6
assert impact["phase"] == "after_burst_before_intervention"
assert impact["baseline_frozen"] is False
inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own")
assert len(result["map_payload"]["features"]) == 3
assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"])
@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65),
("bar", 100000 / 9806.65), ("m", 1)])
def test_normalizes_baseline_and_simulation_independently(monkeypatch, inputs, unit, factor):
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 / factor}]}
inputs.side_effect = None
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": unit})
result = service.estimate_burst_impact("own", START, simulation([4]))
assert result["impact"]["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
assert result["impact"]["grades"][1]["node_ids"] == ["J0"]
def test_v3_units_take_precedence_over_legacy_settings(monkeypatch, inputs):
monkeypatch.setattr(service, "get_option_v3", lambda _: {"PRESSURE_UNITS": "KPA"})
inputs.side_effect = None
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 * 9.80665}]}
impact = service.estimate_burst_impact("own", START, simulation([4]))["impact"]
assert impact["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
assert impact["baseline_units_source"] == "current_v3_model_settings"
def test_missing_nonfinite_duplicate_and_wrong_time_are_unavailable_not_zero(inputs):
inputs.side_effect = None
inputs.return_value = {
"J0": [{"time": (START + timedelta(seconds=1)).isoformat(), "value": 30}],
"J1": [{"time": START.isoformat(), "value": float("nan")}],
"J2": [{"time": START.isoformat(), "value": 30}] * 2,
"J3": [{"time": START.isoformat(), "value": 30}],
"J4": [{"time": START.isoformat(), "value": 30}],
}
native = simulation([1] * 6)
native["node_results"][3]["result"] = [{"pressure": float("inf")}]
result = service.estimate_burst_impact("own", START, native)
assert result["impact"]["status"] == "partial"
assert result["impact"]["unavailable_node_ids"] == ["J0", "J1", "J2", "J3", "J5"]
assert result["impact"]["compared_node_count"] == 1
@pytest.mark.parametrize("times", [{}, {"report_start": 900}, {"sample_times": ["2026-07-16T05:00:01Z"]}])
def test_unknown_or_shifted_first_sample_never_queries_baseline(inputs, times):
native = simulation([2])
native["times"] = times
result = service.estimate_burst_impact("own", START, native)
assert result["impact"]["reason"] == "simulation_has_no_verified_start_time_sample"
assert "map_payload" not in result
inputs.assert_not_called()
def test_unknown_units_do_not_draw_an_impact_area(inputs):
result = service.estimate_burst_impact("own", START, simulation([2], "unknown"))
assert result["impact"]["reason"] == "unsupported_or_missing_pressure_units"
inputs.assert_not_called()
def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs):
from app.mcp import scenario_service
from app.services import simulation_scenarios
native = simulation([4] * 25)
run = Mock(return_value=native)
monkeypatch.setattr(simulation_scenarios, "burst_analysis", run)
result = scenario_service.calculate_scenario("run_burst_simulation", {
"output_node_ids": ["J0"], "output_link_ids": [], "start_time": START, "duration": 900,
"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], "include_impact_coverage": True,
}, network="own", username="user", model_only=True)
assert len(result["node_results"]) == 1
assert result["impact"]["affected_node_count"] == 25
assert result["coverage"]["requested_nodes"] == ["J0"]
assert result["persisted_baseline"] is False
assert run.call_args.kwargs["persist_results"] is False
assert run.call_args.kwargs["burst_ID"] == ["P1"]
assert not run.call_args.kwargs["scada_mappings"].reservoirs
assert result["impact"]["boundary_source"] == "model_only_no_live_scada"
def test_large_affected_sets_report_full_counts_without_truncating(inputs):
result = service.estimate_burst_impact("own", START, simulation([4] * 10001))
assert result["impact"]["affected_node_count"] == 10001
assert result["impact"]["coverage_status"] == "unavailable"
assert "map_payload" not in result
def test_replay_passes_empty_boundary_mappings_to_both_native_runs(monkeypatch):
from app.services import simulation_scenarios as scenarios
from app.infra.db.postgresql.scada import ScadaElementMappings
mappings = ScadaElementMappings({}, {}, {}, {}, {}, {}, {})
runs = Mock(return_value={"node_results": []})
monkeypatch.setattr(scenarios.simulation, "run_simulation", runs)
monkeypatch.setattr(scenarios, "get_pipe", lambda *a: {"node1": "A", "node2": "B", "diameter": 100})
monkeypatch.setattr(scenarios, "is_junction", lambda *a: True)
monkeypatch.setattr(scenarios, "get_emitter", lambda *a: None)
monkeypatch.setattr(scenarios, "set_emitter", lambda *a: None)
monkeypatch.setattr(scenarios, "get_option", lambda *a: {"UNITS": "LPS"})
monkeypatch.setattr(scenarios, "set_option", lambda *a: None)
scenarios.burst_analysis.__wrapped__(
name="own", modify_pattern_start_time=START.isoformat(), burst_ID=["P1"], burst_size=[2],
persist_results=False, _temporary_project="tjw_tmp_own", scada_mappings=mappings,
)
assert len(runs.call_args_list) == 2
assert all(call.kwargs["scada_mappings"] is mappings for call in runs.call_args_list)
assert runs.call_args.kwargs["persist_results"] is False
@pytest.mark.anyio
async def test_order_notification_precedes_durable_impact_and_keeps_run_available(inputs):
from copy import deepcopy
from uuid import uuid4
from fastapi.encoders import jsonable_encoder
from app.domain.schemas.operational_task import ActionProposalCommand, TaskPresentationEventCommand
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp.models import QueryResult
from app.mcp.presentation_preview import saved_presentation_preview
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
context = _context()
task = _task_document(context, START, lease=START + timedelta(minutes=1))
task["workflow"] = "burst_report"
repository = TaskRepository(task, _active_run(task, START))
repository.reports.append({"revision": 1, "execution_generation": 1,
"report": {"stage": "interim", "outcome": "concluded"}})
tasks = OperationalTaskService(repository, context, set(), clock=lambda: START)
order = await tasks.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="P1候选管段待现场核验",
payload={"title": "P1现场核查", "description": "核查定位管道并反馈", "work_order_type": "general"},
))
assert repository.task["status"] == "running"
assert repository.notifications[-1]["kind"] == "action_approval_pending"
assert order.approval_status == "pending" and order.execution_status == "not_submitted"
result = QueryResult(
source="run_burst_simulation", source_ref="mcp:burst-impact", queried_at=START,
semantics="analysis", origin="system_data", truncated=False,
data=jsonable_encoder({"result": service.estimate_burst_impact("own", START, simulation([2, 4, 6]))}),
)
evidence, _ = await tasks.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source=result.source, source_ref=result.source_ref, queried_at=START,
origin="system_data", truncated=False, data=jsonable_encoder(result),
))
result.evidence_id = evidence.evidence_id
hint = saved_presentation_preview(result)
await tasks.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command=hint["command"],
))
recorded = deepcopy(repository.events[-1]["metadata"]["command"])
assert recorded["data"] == jsonable_encoder(result.data["result"]["map_payload"])
assert len(recorded["data"]["features"]) == 3
# Saved presentation remains complete even if the live query result changes.
result.data["result"]["map_payload"]["features"].clear()
assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3
assert repository.run["status"] == "running"
+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,68 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.mcp import analysis_service, query_service
from app.mcp.analysis_models import ContaminantSimulationInput
from app.mcp.calculation_jobs import should_queue
from app.mcp.catalog import TOOLS
def args(**extra):
return ContaminantSimulationInput(context_handle="a" * 43, **({
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
"output_node_ids": ["Q1"], "source": "Q1", "concentration": 1,
"include_impact_coverage": True, "impact_threshold_mg_l": 0.01,
} | extra))
@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")},
{"include_impact_coverage": "true"}])
def test_impact_inputs_reject_invalid_thresholds_and_flags(extra):
with pytest.raises(ValidationError): args(**extra)
def context(monkeypatch):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
@asynccontextmanager
async def routing():
yield
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_code="project", project_role="member"), user=SimpleNamespace(username="actor"),
simulation_routing=routing)
binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], permission_cap=["simulation.run", "scada.view"],
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
return scope, binding
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch):
scope, binding = context(monkeypatch)
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args()))
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
assert "预测" in result.data_gaps[0]
scope.connection.commit.assert_awaited_once()
worker.assert_awaited_once()
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
assert should_queue(TOOLS["run_contaminant_simulation"], args())
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"])
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
scope, binding = context(monkeypatch)
parameters = args()
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
if problem == "no_impact": parameters = args(include_impact_coverage=False)
if problem == "reader": binding.purpose = "task_reader"
if problem == "permission": binding.permission_cap.remove("scada.view")
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403)
@@ -0,0 +1,73 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.mcp import query_service
from app.mcp.analysis_models import ContaminantSourceTraceInput
from app.mcp.calculation_jobs import should_queue
from app.mcp.catalog import TOOLS
from app.services import contaminant_source_trace as trace
def arguments(**extra):
return ContaminantSourceTraceInput(context_handle="a" * 43,
**({"candidate_node_ids": ["J2", "J1", "missing"],
"start": "2026-07-16T08:00:00+08:00", "end": "2026-07-16T08:15:00+08:00"} | extra))
@pytest.mark.parametrize("nodes", [[{"id": "J2"}, {"id": "J1"}], [{"id": "J1"}, {"id": "J2"}]])
def test_trace_is_immediate_project_scoped_stable_and_explicit_about_algorithm(monkeypatch, nodes):
read = AsyncMock(return_value=nodes)
monkeypatch.setattr(trace, "read_rows", read)
connection = object()
args = arguments()
result = asyncio.run(trace.trace_contaminant_source(connection, args))
assert result["located_node"] == "J1"
assert result["missing_node_ids"] == ["missing"]
assert result["top_candidates"] == [{"node_id": "J1"}]
assert result["algorithm_status"] == "provisional"
assert result["selection_basis"] == "lexicographically_first_existing_candidate_node"
assert "相似度" not in result and "confidence" not in result
assert read.await_args.args[0] is connection
assert "network.nodes" in read.await_args.args[1] and "ANY(%s)" in read.await_args.args[1]
assert read.await_args.args[2] == (args.candidate_node_ids,)
assert not should_queue(TOOLS["run_contaminant_source_trace"], args)
@pytest.mark.parametrize("change", [{"candidate_node_ids": []}, {"candidate_node_ids": ["J1", "J1"]},
{"candidate_node_ids": [str(index) for index in range(21)]},
{"start": "2026-07-16T08:00:00"}, {"end": "2026-07-16T08:00:00+08:00"},
{"end": "2026-07-18T08:00:00+08:00"}])
def test_trace_rejects_unbounded_or_ambiguous_inputs(change):
with pytest.raises(ValidationError):
arguments(**change)
@pytest.mark.parametrize("nodes", [[], [{"id": "J1"}]])
def test_dispatch_preserves_provenance_and_reports_only_actual_node_gaps(monkeypatch, nodes):
monkeypatch.setattr(trace, "read_rows", AsyncMock(return_value=nodes))
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_role="member"))
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], replay_context=None)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
assert result.origin == "synthetic" and result.semantics == "analysis"
assert (result.data["result"]["located_node"] is None) == (not nodes)
assert bool(result.data_gaps) == (not nodes)
assert "context_handle" not in result.data["parameters"]
def test_replay_trace_cannot_escape_frozen_window(monkeypatch):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[],
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
assert caught.value.status_code == 422
+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}
+72
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
async def test_new_execution_round_continues_the_same_task_graph():
task, repo, service = setup_service()
@@ -0,0 +1,96 @@
"""New publications are readable; historical reports keep their original evidence."""
from copy import deepcopy
import pytest
from app.domain.schemas.operational_task import (
InvestigationProgress,
InvestigationProgressInput,
InvestigationReport,
InvestigationReportInput,
)
def report():
return {
"title": "压力异常恢复复核报告",
"current_conclusion": "恢复复核通过。7 月 16 日 13:15,13 台复核设备均处于历史正常范围。",
"conclusion_boundary": "依据完工后的一个采样时刻判断恢复,长期稳定性继续由日常监测跟踪。",
"stage": "final", "outcome": "concluded", "data_gaps": [],
"evidence_refs": ["recovery:sha256:stored-evidence"],
"recommendations": ["继续监测压力与流量。"], "pending_items": [],
}
@pytest.mark.parametrize("text", [
"恢复复核 status=passed,13 台设备 normal。",
"第 3 轮 execution_generation=3,handling_context.phase=verification_ready。",
"完工后 required_samples=1,interval_seconds=900,verified_through=13:15。",
"已读取 recovery:sha256:01234567,复核通过。",
"Skill scada-anomaly v1.0.16 已按 Server 分发执行。",
"query_scada_readings 已执行,query_complete=true。",
"关联工单 60000000-0000-4000-8000-000000000002 已完工。",
"恢复采样时间为 2026-07-16T05:15:00Z。",
"恢复采样时间为 2026-07-16 13:15(UTC+8)。",
"恢复采样时间为 2026-07-16 13:15+08:00。",
"**恢复复核通过**,其余设备正常。",
"<strong>恢复复核通过</strong>。",
])
def test_new_report_and_progress_reject_execution_details_and_raw_display_markup(text):
legacy = {**report(), "current_conclusion": text}
assert InvestigationReport.model_validate(legacy).current_conclusion == text
assert InvestigationProgress.model_validate({"summary": text}).summary == text
with pytest.raises(ValueError):
InvestigationReportInput.model_validate(legacy)
with pytest.raises(ValueError):
InvestigationProgressInput.model_validate({"summary": text})
@pytest.mark.parametrize(("field", "limit"), [
("title", 80), ("current_conclusion", 300), ("conclusion_boundary", 240),
])
def test_new_report_bounds_summary_fields_without_truncating_history(field, limit):
valid = {**report(), field: "测" * limit}
assert getattr(InvestigationReportInput.model_validate(valid), field) == valid[field]
too_long = {**valid, field: "测" * (limit + 1)}
assert getattr(InvestigationReport.model_validate(too_long), field) == too_long[field]
with pytest.raises(ValueError):
InvestigationReportInput.model_validate(too_long)
def test_progress_summary_is_short_and_keeps_technical_references_structured():
progress = {"summary": "测" * 300, "graph_update": {"nodes": [{
"node_id": "recovery", "label": "恢复监测复核通过", "status": "recorded",
"evidence_refs": ["recovery:sha256:stored-evidence"],
}]}}
assert InvestigationProgressInput.model_validate(progress).summary == progress["summary"]
with pytest.raises(ValueError):
InvestigationProgressInput.model_validate({**progress, "summary": "测" * 301})
def test_asset_ids_measurements_and_evidence_linked_tables_remain_publishable():
value = report()
value["current_conclusion"] = "05:00-08:00 的候选管段 515870(DN600)相似度 97.08%,尚待现场核验。压力测点 170486 回升至 26.12 m。"
value["ui_blocks"] = [{"evidence_refs": value["evidence_refs"], "block": {
"version": "answer-ui@1", "block_id": "recovery-table", "phase": "final",
"fallback_text": "压力测点 170486 恢复至 26.12 m。",
"spec": {"root": "table", "elements": {"table": {
"type": "Table", "children": [], "props": {
"title": "恢复监测明细", "columns": ["测点", "修复后", "正常范围"],
"rows": [["170486", "26.12 m", "26.02 至 26.30 m"]],
},
}}},
}}]
assert InvestigationReportInput.model_validate(value).ui_blocks
leaked = deepcopy(value)
leaked["ui_blocks"][0]["block"]["spec"]["elements"]["table"]["props"]["rows"][0][2] = "status=passed"
assert InvestigationReport.model_validate(leaked).ui_blocks
with pytest.raises(ValueError):
InvestigationReportInput.model_validate(leaked)
@pytest.mark.parametrize("field", ["recommendations", "pending_items"])
def test_follow_up_is_business_prose(field):
value = {**report(), field: ["等待 handling_context.phase=waiting_scada。"]}
with pytest.raises(ValueError):
InvestigationReportInput.model_validate(value)
+1
View File
@@ -26,6 +26,7 @@ HANDLE = "x" * 43
START = "2026-10-08T12:00:00+08:00"
END = "2026-10-08T13:00:00+08:00"
INPUTS = {
"run_contaminant_source_trace": {"candidate_node_ids": ["J1"], "start": START, "end": END},
"run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]},
"run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0},
"analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]},
+41
View File
@@ -204,3 +204,44 @@ def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch
assert args.timeout_seconds <= 240
assert row['parameters']['timeout_seconds'] == 3600
authorize.assert_awaited_once()
def test_large_burst_result_keeps_saved_map_command_and_full_evidence(monkeypatch):
from copy import deepcopy
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
from app.mcp.models import QueryResult
original = binding()
original.task_id = uuid4()
full = {"parameters": {"burst_pipe_ids": ["P1"]}, "result": {
"impact": {"phase": "after_burst_before_intervention", "status": "ready",
"compared_node_count": 1000, "comparisons": [{"node_id": f"J{i}", "pressure_drop_m": 4} for i in range(1000)],
"grades": [{"group_id": "drop_3_5", "node_ids": [f"J{i}" for i in range(1000)]}],
"unavailable_node_ids": []},
"coverage": {"status": "ready", "groups": {"drop_3_5": {"node_ids": [f"J{i}" for i in range(1000)],
"mapped_node_count": 1000, "missing_node_ids": []}}},
"map_payload": {"type": "FeatureCollection", "features": [{"large": "recorded-geometry"}]},
}}
result = QueryResult(source='run_burst_simulation', source_ref='mcp:burst', queried_at=datetime.now(timezone.utc),
semantics='analysis', data=full, evidence_id='saved-burst')
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
persisted = deepcopy(row)
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])
response = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
assert response.truncated and len(response.model_dump_json().encode()) < 11000
assert response.data['result']['impact']['compared_node_count'] == 1000
assert response.data['result']['impact']['grades'][0]['node_count'] == 1000
assert response.data['presentation']['command']['evidence_id'] == 'saved-burst'
assert response.data['presentation']['command']['action'] == 'set_overlay'
assert response.data['resource_uri'].endswith('/evidence/saved-burst')
assert 'comparisons' not in response.data['result']['impact']
assert row == persisted
# Explicit path pagination still reads every original node without summary replacement.
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
args.model_copy(update={'data_path': ['result', 'impact', 'comparisons'], 'offset': 990, 'limit': 10})))
assert page.data[0]['node_id'] == 'J990' and page.page.total == 1000
+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():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 72
assert len(TOOLS) == 73
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
@@ -34,3 +34,32 @@ def test_missing_geometry_does_not_offer_a_broken_overlay_command():
data={"result": {"coverage": {"status": "unavailable"}}},
)
assert saved_presentation_preview(result) is None
def test_pollution_impact_keeps_a_saved_overlay_reference_for_history():
result = QueryResult(source="run_contaminant_simulation", source_ref="calculation:pollution",
evidence_id="pollution:impact", queried_at=datetime.now(timezone.utc), semantics="analysis", origin="synthetic",
data={"result": {"impact": {"phase": "after_contamination_before_intervention"},
"map_payload": {"type": "FeatureCollection", "features": [{"geometry": "saved"}]},
"coverage": {"status": "ready", "groups": {"contaminant_exposure": {"node_ids": ["Q1"], "mapped_node_count": 1}}}}})
preview = saved_presentation_preview(result)
assert preview["command"]["evidence_id"] == "pollution:impact"
assert preview["command"]["title"] == "水污染传播预计影响范围(处置前情景)"
assert "data" not in preview["command"] and preview["groups"]["contaminant_exposure"]["node_count"] == 1
result.data["result"]["impact"]["phase"] = "unknown"
assert saved_presentation_preview(result) is None
def test_burst_impact_hint_uses_saved_evidence_and_distinct_pre_intervention_title():
result = QueryResult(
source="run_burst_simulation", source_ref="mcp:burst", evidence_id="mcp:burst",
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
data={"result": {"impact": {"phase": "after_burst_before_intervention"},
"map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]}}},
)
hint = saved_presentation_preview(result)
assert hint["command"]["evidence_id"] == "mcp:burst"
assert "爆管后、处置前" in hint["command"]["title"]
assert "data" not in hint["command"]
result.data["result"].pop("impact")
assert saved_presentation_preview(result) is None
+24
View File
@@ -373,6 +373,30 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monkeypatch):
bound = binding(task=True)
bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"}
permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"}
bound.permission_cap = sorted(permissions)
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
args = TOOLS["run_burst_simulation"].model.model_validate({
"context_handle": bound.context_handle, "start_time": "2026-10-08T01:00:00Z",
"output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2],
"include_impact_coverage": True,
})
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
for changed, status in (({"include_impact_coverage": False}, 422),
({"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)}, 422)):
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args.model_copy(update=changed)))
assert error.value.status_code == status
bound.permission_cap = sorted(permissions - {"simulation.view"})
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
assert error.value.status_code == 403
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
app, client, _, _ = runtime
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
+35 -3
View File
@@ -19,6 +19,7 @@ from app.mcp.work_order_skills import read_work_order_skill
("water_quality_complaint", "water-quality-complaint"),
("general", "appointment-follow-up"),
("scada_anomaly", "scada-anomaly"),
("water_pollution", "water-pollution-event"),
])
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
descriptor = read_work_order_skill(workflow)
@@ -59,9 +60,31 @@ def test_scada_instructions_advance_candidate_analysis_independently_of_source_l
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the_investigation():
skill = read_work_order_skill("water_pollution", include_content=True)
text = skill["instructions"]
assert text.index("propose_task_action") < text.index("run_contaminant_simulation")
assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text
assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text
assert "模型曲线不填观测专用 chart.scada" in text
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text
def test_pollution_workflow_distinguishes_protection_flushing_and_clearance():
skill = read_work_order_skill("water_pollution", include_content=True)
text = skill["instructions"]
assert skill["version"] == "1.2.0"
assert text.index("## 4. 源头控制与用户隔离") < text.index("## 5. 污染物清除与分步冲洗")
assert "opened_valves" in text and "closed_valves" in text
assert "不继承上次模拟" in text and "排放去向" in text
assert all(stage in text for stage in ("source_control", "flushing_plan", "clearance_verification"))
assert "不能证明污染物已清除" in text and "结果未通过时保持 interim" in text
@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"])
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
task_id, project_id = uuid4(), uuid4()
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
reader = AsyncMock(return_value={"task": {"workflow": workflow}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
scope = SimpleNamespace(connection=object(), project=object())
@@ -80,8 +103,17 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch
body = query("skill")
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
assert summary.data["display_time_zone"] == "Asia/Shanghai"
assert body.data["skill"]["workflow"] == "burst_report"
assert body.data["skill"]["workflow"] == workflow
for result in (summary, body):
assert ("source_trace_capability" in result.data) == (workflow == "water_pollution")
if workflow == "water_pollution":
assert result.data["source_trace_capability"]["tool"] == "run_contaminant_source_trace"
capability = result.data["source_trace_capability"]
assert capability["work_order_wait_blocks_analysis"] is False
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
assert body.data["skill"]["instructions"] and not body.truncated
assert len(json.dumps(body.model_dump(mode="json"), ensure_ascii=False).encode()) < 10000
assert body.evidence_id is None and body.semantics == "task"
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
binding.permission_cap = []
+19
View File
@@ -22,6 +22,25 @@ def observations():
return rows
def test_pollution_field_feedback_resumes_review_without_pressure_recovery():
task = {"workflow": "water_pollution"}
for order_id in ("sampling", "flush"):
assert track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()),
"execution_status": "succeeded", "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
feedback = {"source_event_id": "peripheral_sample:sampling", "status": "completed", "source_revision": 2,
"occurred_at": RETURNED.isoformat(), "evidence": {"evidence_id": "sampling:2"}}
accept_completion(task, feedback)
assert task["handling_context"]["phase"] == "waiting_work_order"
accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:flush", "evidence": {"evidence_id": "flush:2"}})
assert task["handling_context"]["phase"] == "field_review"
assert "verification_evidence_id" not in task["handling_context"]
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:2"
task["handling_context"]["phase"] = "completed"
accept_completion(task, {**feedback, "source_revision": 3, "evidence": {"evidence_id": "sampling:3"}})
assert task["handling_context"]["phase"] == "field_review"
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:3"
def evaluate(rows):
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
incident_at=INCIDENT, interval_seconds=900)
+87
View File
@@ -675,6 +675,19 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
assert again.task.execution_generation == 2
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
notices = [item for item in repository.notifications if item["kind"] == "work_order_feedback"]
assert len(notices) == 1
assert notices[0]["subject_id"] == str(action.action_id)
assert notices[0]["work_order_action"]["receipt"]["receipt_id"] == child_id
review_notice = next(item for item in repository.notifications if item["kind"] == "action_approval_pending")
assert review_notice["work_order_action"]["approval_status"] == "pending"
assert review_notice["work_order_action"]["receipt"] is None
assert notices[0]["work_order_reference"] == {
"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
"evidence_id": None, "status": "completed",
}
assert any(event["topic"] == "operational.notification.changed"
and event["metadata"].get("kind") == "work_order_feedback" for event in repository.events)
repository.task.update(status="cancelled", lifecycle_status="cancelled")
with pytest.raises(DomainError) as cancelled:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
@@ -2350,3 +2363,77 @@ async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation
await getattr(other, method)(task["task_id"], command)
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
assert repository.task == before
@pytest.mark.anyio
@pytest.mark.parametrize("stage", ["asset_localization", "source_control", "flushing_plan", "clearance_verification"])
async def test_presentation_narration_is_evidence_backed_saved_and_replayed_without_mutating_task(stage):
now = datetime(2026, 11, 14, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
evidence, _ = await service.record_data_snapshot(task['task_id'], TaskDataSnapshot(
source='query_scada_readings', source_ref='observations', queried_at=now,
origin='synthetic', data={'items': []}, truncated=False,
))
narration = {'stage': stage, 'summary': '本阶段保存的候选方案及核验依据。',
'evidence_refs': [evidence.evidence_id], 'next_step': '提出现场工单草稿,并行开展影响分析。'}
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
command={'action': 'locate_features', 'layer': 'pipes', 'feature_ids': ['P1']}, narration=narration,
purpose='核对候选管段与异常测点的空间关系,供后续影响分析。',
)
result = await service.publish_presentation_event(task['task_id'], command)
assert repository.events[-1]['metadata']['narration'] == narration
assert repository.events[-1]['metadata']['purpose'] == command.purpose
assert await service.publish_presentation_event(task['task_id'], command) == result
page = await service.presentation_history(task['task_id'])
assert page.items[-1].narration.model_dump(exclude_none=True) == narration
assert page.items[-1].purpose == command.purpose
assert repository.task['revision'] == task['revision']
with pytest.raises(DomainError) as conflict:
await service.publish_presentation_event(task['task_id'], command.model_copy(update={
'narration': command.narration.model_copy(update={'summary': '不同判断'}),
}))
assert conflict.value.code == 'IDEMPOTENCY_CONFLICT'
foreign = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
command={'action': 'clear_highlights'}, narration={**narration, 'evidence_refs': ['other-task']},
)
before = len(repository.events)
with pytest.raises(DomainError) as rejected:
await service.publish_presentation_event(task['task_id'], foreign)
assert rejected.value.code == 'EVIDENCE_NOT_FOUND'
assert len(repository.events) == before
@pytest.mark.parametrize('changes', [
{'stage': 'invented_stage'}, {'summary': 'x' * 161}, {'summary': ' '},
{'evidence_refs': []}, {'evidence_refs': ['e1', 'e1']}, {'next_step': 'x' * 121},
])
def test_presentation_narration_rejects_unbounded_or_unsubstantiated_fields(changes):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
command={'action': 'clear_highlights'},
narration={'stage': 'evidence_verification', 'summary': '测点异常待核验',
'evidence_refs': ['e1'], **changes},
)
def test_legacy_presentation_idempotency_body_does_not_gain_an_empty_narration_field():
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
command={'action': 'clear_highlights'},
)
assert 'narration' not in command.model_dump(mode='json')
assert 'purpose' not in command.model_dump(mode='json')
@pytest.mark.parametrize('purpose', ['', ' ', 'x' * 161, 123])
def test_presentation_purpose_rejects_invalid_intent(purpose):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
command={'action': 'zoom_to_map', 'center': [121, 31], 'zoom': 15}, purpose=purpose,
)
+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)
+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"
@@ -85,6 +85,25 @@ async def investigation():
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):
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": []}
+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]
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.knowledge_cutoff == START + timedelta(minutes=15)
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"]["description"] == "已启动调查,详细诊断见调查报告。"
if weighted:
assert "-0.0843" in command.summary
assert "综合评分 0.90" in command.summary
assert "流量 10%" in command.summary
assert "-0.0843" not in command.summary
assert "综合评分" not 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):
@@ -807,6 +812,60 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
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):
repo, service, _, _, create = setup(monkeypatch)
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]
assert command.workflow == "scada_anomaly"
assert command.source.event_kind == "scada_anomaly"
assert "任一指标异常即启动调查" in command.summary
assert "爆管嫌疑门槛 0.80" in command.summary
assert "已启动调查" 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["incident"]["action"] == "raised"