feat(investigations): refine report authoring and replay flushing
This commit is contained in:
@@ -5,13 +5,16 @@ import re
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
|
from pydantic import Field, JsonValue, StringConstraints, field_validator, model_serializer, model_validator
|
||||||
from app.domain.schemas.answer_ui import (
|
from app.domain.schemas.answer_ui import (
|
||||||
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||||
|
from app.domain.schemas.report_authoring import (
|
||||||
|
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
|
||||||
|
)
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.domain.schemas.workbench_runtime import (
|
from app.domain.schemas.workbench_runtime import (
|
||||||
WorkbenchDateTime,
|
WorkbenchDateTime,
|
||||||
@@ -349,12 +352,44 @@ class InvestigationReport(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationDataGapInput(InvestigationDataGap):
|
||||||
|
"""New gap descriptions use business prose; references remain opaque."""
|
||||||
|
|
||||||
|
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
|
||||||
|
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
|
||||||
|
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
|
||||||
|
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
|
||||||
|
|
||||||
|
@field_validator("missing_data", "scope", "impact", "required_data")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str) -> str:
|
||||||
|
return validate_report_text(value)
|
||||||
|
|
||||||
|
|
||||||
class InvestigationReportInput(InvestigationReport):
|
class InvestigationReportInput(InvestigationReport):
|
||||||
"""Write model: every new report explicitly declares its business result."""
|
"""Write model: every new report explicitly declares its business result."""
|
||||||
|
|
||||||
|
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
|
||||||
|
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
|
||||||
|
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
|
||||||
|
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||||
|
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||||
|
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
|
||||||
stage: Literal["interim", "final"]
|
stage: Literal["interim", "final"]
|
||||||
outcome: Literal["concluded", "insufficient_data"]
|
outcome: Literal["concluded", "insufficient_data"]
|
||||||
data_gaps: list[InvestigationDataGap]
|
data_gaps: list[InvestigationDataGapInput]
|
||||||
|
|
||||||
|
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_authoring(self):
|
||||||
|
for value in [*self.recommendations, *self.pending_items]:
|
||||||
|
validate_report_text(value)
|
||||||
|
validate_report_ui_text(self.ui_blocks)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class InvestigationProgress(StrictModel):
|
class InvestigationProgress(StrictModel):
|
||||||
@@ -365,8 +400,14 @@ class InvestigationProgress(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
class InvestigationProgressInput(InvestigationProgress):
|
class InvestigationProgressInput(InvestigationProgress):
|
||||||
|
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
|
||||||
graph_update: InvestigationGraphUpdate | None = None
|
graph_update: InvestigationGraphUpdate | None = None
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_summary(cls, value: str) -> str:
|
||||||
|
return validate_report_text(value)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_graph_input(self):
|
def validate_graph_input(self):
|
||||||
if self.graph_update is not None and self.diagram is not None:
|
if self.graph_update is not None and self.diagram is not None:
|
||||||
@@ -908,11 +949,72 @@ TaskPresentationCommand = Annotated[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationNarration(StrictModel):
|
||||||
|
stage: Literal[
|
||||||
|
"anomaly_detection", "evidence_verification", "asset_localization",
|
||||||
|
"event_impact", "source_control", "isolation_plan", "flushing_plan",
|
||||||
|
"field_feedback", "clearance_verification", "recovery_verification",
|
||||||
|
]
|
||||||
|
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
|
||||||
|
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
|
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_narration(self):
|
||||||
|
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
|
||||||
|
raise ValueError("presentation narration must contain readable text")
|
||||||
|
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||||
|
raise ValueError("presentation narration evidence IDs must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
TaskPresentationPurpose = Annotated[
|
||||||
|
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationNarrationInput(TaskPresentationNarration):
|
||||||
|
"""New live findings use business prose; saved narration remains readable."""
|
||||||
|
|
||||||
|
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
|
||||||
|
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
|
||||||
|
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
|
||||||
|
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
|
||||||
|
|
||||||
|
@field_validator("summary", "next_step")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationEventCommand(StrictModel):
|
class TaskPresentationEventCommand(StrictModel):
|
||||||
request_id: UUID
|
request_id: UUID
|
||||||
run_id: UUID
|
run_id: UUID
|
||||||
source_session_id: NonEmptyStr
|
source_session_id: NonEmptyStr
|
||||||
command: TaskPresentationCommand
|
command: TaskPresentationCommand
|
||||||
|
narration: TaskPresentationNarrationInput | None = None
|
||||||
|
purpose: TaskPresentationPurpose | None = Field(default=None,
|
||||||
|
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
|
||||||
|
|
||||||
|
@field_validator("purpose")
|
||||||
|
@classmethod
|
||||||
|
def validate_reader_purpose(cls, value: str | None) -> str | None:
|
||||||
|
return validate_report_text(value) if value is not None else value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_visible_labels(self):
|
||||||
|
validate_presentation_command_text(self.command.model_dump())
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_legacy_command(self, handler):
|
||||||
|
value = handler(self)
|
||||||
|
# Keep old idempotency digests stable when no narration was supplied.
|
||||||
|
if self.narration is None:
|
||||||
|
value.pop("narration", None)
|
||||||
|
if self.purpose is None:
|
||||||
|
value.pop("purpose", None)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationEventResult(StrictModel):
|
class TaskPresentationEventResult(StrictModel):
|
||||||
@@ -943,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
|
|||||||
# Stored records are validated against the current command contract by the
|
# Stored records are validated against the current command contract by the
|
||||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||||
command: dict[str, JsonValue]
|
command: dict[str, JsonValue]
|
||||||
|
narration: TaskPresentationNarration | None = None
|
||||||
|
purpose: TaskPresentationPurpose | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskPresentationHistoryPage(StrictModel):
|
class TaskPresentationHistoryPage(StrictModel):
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -171,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
|
|||||||
|
|
||||||
|
|
||||||
class FlushingInput(ScenarioInput):
|
class FlushingInput(ScenarioInput):
|
||||||
drainage_node_id: Id
|
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
|
||||||
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
|
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;提供流量来源与估算依据")
|
||||||
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
|
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||||
|
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
|
||||||
|
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
|
||||||
|
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def distinct_valve_states(self):
|
||||||
|
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
|
||||||
|
raise ValueError("flushing valve lists must contain distinct IDs")
|
||||||
|
if set(self.closed_valves) & set(self.opened_valves):
|
||||||
|
raise ValueError("a flushing valve cannot be both opened and closed")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
||||||
|
|||||||
@@ -132,3 +132,12 @@ async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: s
|
|||||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||||
network=network, username=username, model_only=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,
|
||||||
|
)
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ _specs = [
|
|||||||
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
|
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
|
||||||
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
|
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True),
|
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
|
||||||
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
||||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||||
|
|||||||
@@ -189,11 +189,12 @@ def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
|
|||||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||||
if args.scenario == "flushing":
|
if args.scenario == "flushing":
|
||||||
from app.native.wndb.model.options import get_option
|
from app.native.wndb.model.options import get_option
|
||||||
|
from app.mcp.scenario_service import flushing_valve_controls
|
||||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||||
raise ValueError("Flushing supports LPS or CMH models only")
|
raise ValueError("Flushing supports LPS or CMH models only")
|
||||||
return scenarios.flushing_analysis(**common, username=username,
|
return scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
valve_control=flushing_valve_controls(parameters))
|
||||||
mappings = query_corresponding_element_id_and_query_id(network)
|
mappings = query_corresponding_element_id_and_query_id(network)
|
||||||
controls = parameters["pump_control"]
|
controls = parameters["pump_control"]
|
||||||
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async def authorize_query(
|
|||||||
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
|
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
|
||||||
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
||||||
"list_regions", "get_region", "get_region_nodes",
|
"list_regions", "get_region", "get_region_nodes",
|
||||||
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation",
|
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
|
||||||
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||||
if tool.name not in allowed:
|
if tool.name not in allowed:
|
||||||
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
|
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
|
||||||
@@ -91,6 +91,8 @@ async def authorize_query(
|
|||||||
if not args.include_impact_coverage:
|
if not args.include_impact_coverage:
|
||||||
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
|
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
|
||||||
check_replay_window(replay_context, args.start_time, args.start_time)
|
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||||
|
if tool.name == "analyze_flushing":
|
||||||
|
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||||
if tool.name == "query_scada_readings":
|
if tool.name == "query_scada_readings":
|
||||||
check_replay_window(replay_context, args.start, args.end)
|
check_replay_window(replay_context, args.start, args.end)
|
||||||
if tool.name == "run_contaminant_source_trace":
|
if tool.name == "run_contaminant_source_trace":
|
||||||
@@ -233,6 +235,17 @@ async def execute_query(
|
|||||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
origin = "synthetic"
|
origin = "synthetic"
|
||||||
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
|
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
|
||||||
|
elif name == "analyze_flushing" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_flushing
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_flushing(
|
||||||
|
args, network=scope.project.project_code, username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
|
||||||
elif name == "run_burst_location" and replay_context:
|
elif name == "run_burst_location" and replay_context:
|
||||||
from app.mcp.analysis_service import calculate_replay_burst_location
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
@@ -540,7 +553,8 @@ async def execute_query(
|
|||||||
"algorithm_status": "provisional",
|
"algorithm_status": "provisional",
|
||||||
"required_inputs": ["candidate_node_ids", "start", "end"],
|
"required_inputs": ["candidate_node_ids", "start", "end"],
|
||||||
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
|
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
|
||||||
"analyze_valve_isolation", "generate_node_coverage", "report"],
|
"source_control", "analyze_valve_isolation", "generate_node_coverage",
|
||||||
|
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
|
||||||
"work_order_wait_blocks_analysis": False,
|
"work_order_wait_blocks_analysis": False,
|
||||||
"simulation_parameters": {"include_impact_coverage": True},
|
"simulation_parameters": {"include_impact_coverage": True},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ SCENARIO_TOOLS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
|
||||||
model_only: bool = False) -> dict[str, Any]:
|
model_only: bool = False) -> dict[str, Any]:
|
||||||
from app.services import simulation_scenarios as scenarios
|
from app.services import simulation_scenarios as scenarios
|
||||||
@@ -60,9 +66,16 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
from app.native.wndb.model.options import get_option
|
from app.native.wndb.model.options import get_option
|
||||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||||
raise ValueError("Flushing currently supports LPS or CMH models only")
|
raise ValueError("Flushing currently supports LPS or CMH models only")
|
||||||
|
boundary = {}
|
||||||
|
if model_only:
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
boundary["scada_mappings"] = ScadaElementMappings(
|
||||||
|
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||||
|
pressure={}, demand={}, quality={},
|
||||||
|
)
|
||||||
result = scenarios.flushing_analysis(**common, username=username,
|
result = scenarios.flushing_analysis(**common, username=username,
|
||||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
valve_control=flushing_valve_controls(parameters), **boundary)
|
||||||
elif name == "analyze_pressure_regulation":
|
elif name == "analyze_pressure_regulation":
|
||||||
from app.services.simulation import query_corresponding_element_id_and_query_id
|
from app.services.simulation import query_corresponding_element_id_and_query_id
|
||||||
mappings = query_corresponding_element_id_and_query_id(network)
|
mappings = query_corresponding_element_id_and_query_id(network)
|
||||||
@@ -102,4 +115,17 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
|||||||
threshold_mg_l=impact_threshold, model_only=model_only)
|
threshold_mg_l=impact_threshold, model_only=model_only)
|
||||||
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||||
output.update(impact)
|
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
|
return output
|
||||||
|
|||||||
@@ -1067,6 +1067,8 @@ class OperationalTaskService:
|
|||||||
run_id=row["metadata"]["run_id"],
|
run_id=row["metadata"]["run_id"],
|
||||||
execution_generation=row["metadata"]["execution_generation"],
|
execution_generation=row["metadata"]["execution_generation"],
|
||||||
command=row["metadata"]["command"],
|
command=row["metadata"]["command"],
|
||||||
|
narration=row["metadata"].get("narration"),
|
||||||
|
purpose=row["metadata"].get("purpose"),
|
||||||
) for row in rows[:limit]]
|
) for row in rows[:limit]]
|
||||||
return TaskPresentationHistoryPage(
|
return TaskPresentationHistoryPage(
|
||||||
task_id=task_id, event_cursor=boundary, items=items,
|
task_id=task_id, event_cursor=boundary, items=items,
|
||||||
@@ -1097,6 +1099,9 @@ class OperationalTaskService:
|
|||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
payload = _json(command.command)
|
payload = _json(command.command)
|
||||||
|
if command.narration is not None:
|
||||||
|
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
||||||
|
self._require_evidence_refs(command.narration.evidence_refs, known)
|
||||||
await self._record_scada_presentation(task, run, payload, now)
|
await self._record_scada_presentation(task, run, payload, now)
|
||||||
if command.command.action == "open_chart":
|
if command.command.action == "open_chart":
|
||||||
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
validate_time_comparison_chart(payload["chart"], allow_missing_current=bool(
|
||||||
@@ -1122,6 +1127,10 @@ class OperationalTaskService:
|
|||||||
payload["data"] = resolved
|
payload["data"] = resolved
|
||||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||||
"execution_generation": run["generation"], "command": payload}
|
"execution_generation": run["generation"], "command": payload}
|
||||||
|
if command.narration is not None:
|
||||||
|
metadata["narration"] = _json(command.narration)
|
||||||
|
if command.purpose is not None:
|
||||||
|
metadata["purpose"] = command.purpose
|
||||||
try:
|
try:
|
||||||
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import json
|
import json
|
||||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -531,35 +532,24 @@ class ScadaReplayService:
|
|||||||
external = incident["external_id"]
|
external = incident["external_id"]
|
||||||
pressure = monitoring["pressure"]
|
pressure = monitoring["pressure"]
|
||||||
flow = monitoring["outlet_flow"]
|
flow = monitoring["outlet_flow"]
|
||||||
detector = monitoring["burst_detection"]
|
findings = []
|
||||||
score = detector.get("target_score")
|
if pressure.get("hit_device_ids"):
|
||||||
score_text = f"{score:.4f}" if score is not None else "数据不足"
|
findings.append(f"{len(pressure['hit_device_ids'])} 个压力测点下降")
|
||||||
summary = (
|
if flow.get("hit_device_ids"):
|
||||||
f"SCADA 数据诊断发现指标异常,进入异常调查与爆管诊断。"
|
findings.append(f"{len(flow['hit_device_ids'])} 个出厂边界流量测点增加")
|
||||||
f"爆管侦测得分 {score_text};"
|
summary = f"SCADA 监测发现{'、'.join(findings) or '指标异常'},已启动调查。"
|
||||||
f"压力下降测点 {len(pressure.get('hit_device_ids', []))} 个;"
|
|
||||||
f"出厂边界流量增加测点 {len(flow.get('hit_device_ids', []))} 个。"
|
|
||||||
"任一指标异常即启动调查,加权分用于评估爆管嫌疑;达到门槛后继续候选定位、隔离与影响分析。"
|
|
||||||
)
|
|
||||||
if monitoring.get("anomaly_signals"):
|
if monitoring.get("anomaly_signals"):
|
||||||
signals = monitoring["anomaly_signals"]
|
signals = monitoring["anomaly_signals"]
|
||||||
devices = {device for item in signals for device in item.get("device_ids", [])}
|
devices = {device for item in signals for device in item.get("device_ids", [])}
|
||||||
summary += f" 单项异常 {len(signals)} 项,涉及 {len(devices)} 个测点;明细通过冻结 SCADA 查询核验。"
|
summary += f"本次异常涉及 {len(devices)} 个测点。"
|
||||||
if monitoring.get("weighted"):
|
summary += ("现有监测结果提示爆管嫌疑,需结合管网与现场核验。"
|
||||||
weighted = monitoring["weighted"]
|
if monitoring["status"] == "suspected_burst" else "异常原因及影响范围待核查。")
|
||||||
weights = weighted["weights"]
|
event_time = parse_time(monitoring["period_end"]).astimezone(ZoneInfo("Asia/Shanghai"))
|
||||||
summary += (
|
|
||||||
f" 综合评分 {weighted['score']:.2f},爆管嫌疑门槛 {weighted['threshold']:.2f}"
|
|
||||||
f"(侦测 {weights['burst_detection']:.0%}、压力 {weights['pressure']:.0%}、"
|
|
||||||
f"流量 {weights['outlet_flow']:.0%})。"
|
|
||||||
)
|
|
||||||
if detector.get("score_threshold") is not None:
|
|
||||||
summary += f" 侦测动态阈值 {detector['score_threshold']:.4f}。"
|
|
||||||
command = OperationalTaskCreate(
|
command = OperationalTaskCreate(
|
||||||
request_id=uuid5(NAMESPACE_URL, external),
|
request_id=uuid5(NAMESPACE_URL, external),
|
||||||
task_type="event_investigation",
|
task_type="event_investigation",
|
||||||
workflow="scada_anomaly",
|
workflow="scada_anomaly",
|
||||||
title=f"SCADA 异常调查 · {monitoring['period_end']}",
|
title=f"SCADA 异常调查 · {event_time:%Y-%m-%d %H:%M}",
|
||||||
summary=summary,
|
summary=summary,
|
||||||
source=TaskSource(
|
source=TaskSource(
|
||||||
kind="external_system",
|
kind="external_system",
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ def flushing_analysis(
|
|||||||
valve_control: dict[str, dict] = None,
|
valve_control: dict[str, dict] = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
管道冲洗模拟
|
管道冲洗模拟
|
||||||
@@ -445,6 +446,7 @@ def flushing_analysis(
|
|||||||
modify_total_duration=modify_total_duration,
|
modify_total_duration=modify_total_duration,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
valve_control=valve_control,
|
valve_control=valve_control,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_type="flushing_analysis",
|
scheme_type="flushing_analysis",
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "aeff2c644006bf7eddd6cfe1c8aa0bad7f38c4632a456b5d3be1bb8201efc0db"
|
"sha256": "c31e9b4c71216951d1525acf1bcb8aeba4158b6cdc6b0b9dc01be02f9b6e6af3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3537,6 +3537,81 @@
|
|||||||
"title": "InvestigationDataGap",
|
"title": "InvestigationDataGap",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"InvestigationDataGapInput": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "New gap descriptions use business prose; references remain opaque.",
|
||||||
|
"properties": {
|
||||||
|
"blocking": {
|
||||||
|
"title": "Blocking",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"evidence_refs": {
|
||||||
|
"items": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"title": "Evidence Refs",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"gap_id": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Gap Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"impact": {
|
||||||
|
"description": "资料缺项对当前判断的实际影响",
|
||||||
|
"maxLength": 4000,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Impact",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"missing_data": {
|
||||||
|
"description": "缺失的业务资料,不写执行字段",
|
||||||
|
"maxLength": 4000,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Missing Data",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"node_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Node Id"
|
||||||
|
},
|
||||||
|
"required_data": {
|
||||||
|
"description": "需要补充的具体业务资料",
|
||||||
|
"maxLength": 4000,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Required Data",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "缺口涉及的对象、地点与当地时间范围",
|
||||||
|
"maxLength": 4000,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Scope",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"gap_id",
|
||||||
|
"missing_data",
|
||||||
|
"scope",
|
||||||
|
"impact",
|
||||||
|
"required_data",
|
||||||
|
"evidence_refs",
|
||||||
|
"blocking"
|
||||||
|
],
|
||||||
|
"title": "InvestigationDataGapInput",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"InvestigationDiagram": {
|
"InvestigationDiagram": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3907,7 +3982,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"maxLength": 20000,
|
"description": "用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号",
|
||||||
|
"maxLength": 300,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Summary",
|
"title": "Summary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -4110,18 +4186,22 @@
|
|||||||
"description": "Write model: every new report explicitly declares its business result.",
|
"description": "Write model: every new report explicitly declares its business result.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"conclusion_boundary": {
|
"conclusion_boundary": {
|
||||||
|
"description": "只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数",
|
||||||
|
"maxLength": 240,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Conclusion Boundary",
|
"title": "Conclusion Boundary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"current_conclusion": {
|
"current_conclusion": {
|
||||||
|
"description": "先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table",
|
||||||
|
"maxLength": 300,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Current Conclusion",
|
"title": "Current Conclusion",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"data_gaps": {
|
"data_gaps": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/InvestigationDataGap"
|
"$ref": "#/components/schemas/InvestigationDataGapInput"
|
||||||
},
|
},
|
||||||
"title": "Data Gaps",
|
"title": "Data Gaps",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
@@ -4154,6 +4234,7 @@
|
|||||||
},
|
},
|
||||||
"pending_items": {
|
"pending_items": {
|
||||||
"items": {
|
"items": {
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4162,6 +4243,7 @@
|
|||||||
},
|
},
|
||||||
"recommendations": {
|
"recommendations": {
|
||||||
"items": {
|
"items": {
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4196,6 +4278,7 @@
|
|||||||
"stop_reason": {
|
"stop_reason": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
"maxLength": 160,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4206,6 +4289,8 @@
|
|||||||
"title": "Stop Reason"
|
"title": "Stop Reason"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
|
"description": "简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳",
|
||||||
|
"maxLength": 80,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -10159,6 +10244,30 @@
|
|||||||
],
|
],
|
||||||
"title": "Command"
|
"title": "Command"
|
||||||
},
|
},
|
||||||
|
"narration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TaskPresentationNarrationInput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 160,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现",
|
||||||
|
"title": "Purpose"
|
||||||
|
},
|
||||||
"request_id": {
|
"request_id": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Request Id",
|
"title": "Request Id",
|
||||||
@@ -10237,11 +10346,34 @@
|
|||||||
"title": "Execution Generation",
|
"title": "Execution Generation",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"narration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TaskPresentationNarration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"occurred_at": {
|
"occurred_at": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"title": "Occurred At",
|
"title": "Occurred At",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"purpose": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 160,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Purpose"
|
||||||
|
},
|
||||||
"run_id": {
|
"run_id": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Run Id",
|
"title": "Run Id",
|
||||||
@@ -10299,6 +10431,125 @@
|
|||||||
"title": "TaskPresentationHistoryPage",
|
"title": "TaskPresentationHistoryPage",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TaskPresentationNarration": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"evidence_refs": {
|
||||||
|
"items": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"minItems": 1,
|
||||||
|
"title": "Evidence Refs",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"next_step": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 120,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Next Step"
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"enum": [
|
||||||
|
"anomaly_detection",
|
||||||
|
"evidence_verification",
|
||||||
|
"asset_localization",
|
||||||
|
"event_impact",
|
||||||
|
"source_control",
|
||||||
|
"isolation_plan",
|
||||||
|
"flushing_plan",
|
||||||
|
"field_feedback",
|
||||||
|
"clearance_verification",
|
||||||
|
"recovery_verification"
|
||||||
|
],
|
||||||
|
"title": "Stage",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"maxLength": 160,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Summary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"stage",
|
||||||
|
"summary",
|
||||||
|
"evidence_refs"
|
||||||
|
],
|
||||||
|
"title": "TaskPresentationNarration",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TaskPresentationNarrationInput": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "New live findings use business prose; saved narration remains readable.",
|
||||||
|
"properties": {
|
||||||
|
"evidence_refs": {
|
||||||
|
"items": {
|
||||||
|
"maxLength": 128,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"minItems": 1,
|
||||||
|
"title": "Evidence Refs",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"next_step": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行",
|
||||||
|
"maxLength": 120,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Next Step"
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"enum": [
|
||||||
|
"anomaly_detection",
|
||||||
|
"evidence_verification",
|
||||||
|
"asset_localization",
|
||||||
|
"event_impact",
|
||||||
|
"source_control",
|
||||||
|
"isolation_plan",
|
||||||
|
"flushing_plan",
|
||||||
|
"field_feedback",
|
||||||
|
"clearance_verification",
|
||||||
|
"recovery_verification"
|
||||||
|
],
|
||||||
|
"title": "Stage",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"description": "本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的",
|
||||||
|
"maxLength": 160,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Summary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"stage",
|
||||||
|
"summary",
|
||||||
|
"evidence_refs"
|
||||||
|
],
|
||||||
|
"title": "TaskPresentationNarrationInput",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TaskQuestion": {
|
"TaskQuestion": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ Server 将阅读进展、业务报告和浏览器展示请求分别保存。运
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`graph_update` 可省略。
|
示例中的编号由实际任务和资产替换。新发布的 `summary` 最多 300 字符,建议 120 字以内,
|
||||||
|
只说明本轮新增发现、当前依赖和下一步;使用中文业务语言与当地时间,不复制报告或执行参数。
|
||||||
|
详细结果和引用留在图节点、证据与报告明细中,历史正文仍按原值读取。`graph_update` 可省略。
|
||||||
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。
|
||||||
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
|
节点标记可省略或为 null,均显示为参考步骤;新增量更新只接受以下五种:
|
||||||
|
|
||||||
@@ -107,6 +109,33 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
|||||||
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
|
响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存,
|
||||||
不表示浏览器已显示或现场动作已执行。
|
不表示浏览器已显示或现场动作已执行。
|
||||||
|
|
||||||
|
### 展示表达与阅读
|
||||||
|
|
||||||
|
实时展示与正式报告共用中文业务表达和时间要求,字段沿用现有结构:
|
||||||
|
|
||||||
|
| 字段 | 表达职责 | 长度 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| purpose | 为什么核查这些对象;不复述动作名称、当前发现或完整报告 | 建议 60 字以内,最多 160 字符 |
|
||||||
|
| narration.stage | 本步所属业务阶段,前端显示中文阶段名称 | 使用既有枚举 |
|
||||||
|
| narration.summary | 本步已有证据支持的关键发现,区分观测、候选、模型与现场反馈 | 建议 80 字以内,最多 160 字符 |
|
||||||
|
| narration.next_step | 实际下一步或等待条件;无后续时省略 | 建议 60 字以内,最多 120 字符 |
|
||||||
|
| narration.evidence_refs | 该发现引用的实际证据,不作为正文展示 | 保持现有引用校验 |
|
||||||
|
|
||||||
|
已有明确分析结果时提供 `narration`;纯视角调整或清除操作不编造发现。
|
||||||
|
目的、发现和后续分别组织,不重复粘贴同一段文字。同一核查目的的批量图表可以复用有依据的
|
||||||
|
相同讲解,保留合并阅读能力;不同发现分别保存,不为合并而抹去结论差异。
|
||||||
|
|
||||||
|
讲解、目的和图表、覆盖层的可见标签均采用纯文本,省略执行字段、版本号、技术状态码、
|
||||||
|
完整 UUID、证据哈希、时区名称、偏移和 ISO 时间戳。必要设备编号、单位与历史日期保留;
|
||||||
|
标题交代对象、指标或预计范围,算法参数留在证据明细。结构化采样时刻、设备标识、数值、
|
||||||
|
几何和证据引用保持原值,不因文字整理改变分析范围。
|
||||||
|
|
||||||
|
Server 的新请求模型、Agent 原生工具及宿主入口校验这些文字规则;历史读取模型保持兼容。
|
||||||
|
前端在当前动作说明中突出已保存的发现,再呈现目的和后续,缺少讲解的历史步骤只显示用途,
|
||||||
|
不推断结论。讲解进入已有滚动阅读区,历史与播放控件留在标题区,长文字不挤出控件。
|
||||||
|
历史选择固定在所选步骤;自动停留时间考虑实际可见的目的、发现与后续文字,
|
||||||
|
用户手动选择的停留时间保持优先。上述文字和播放行为不控制后台调查、审批或现场执行。
|
||||||
|
|
||||||
| command.action | 参数 |
|
| command.action | 参数 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| locate_features | layer、feature_ids |
|
| locate_features | layer、feature_ids |
|
||||||
|
|||||||
@@ -9,17 +9,19 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 新写入规则 |
|
| 字段 | 类型 | 新写入规则 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| title | 非空字符串 | 报告标题 |
|
| title | 非空字符串,最多 80 字符 | 简短中文业务标题,建议 30 字以内,不包含执行轮次和流程版本 |
|
||||||
| current_conclusion | 非空字符串 | 当前判断文字;无结论时明确写无法判断,不编造原因 |
|
| current_conclusion | 非空字符串,最多 300 字符 | 首句写当前判断,接关键依据和实际下一步,建议 150 字以内;完整数值放入表格 |
|
||||||
| conclusion_boundary | 非空字符串 | 判断范围及限制 |
|
| conclusion_boundary | 非空字符串,最多 240 字符 | 影响判断的证据范围、不确定性和适用限制;不复述执行过程、参数或完整资产清单 |
|
||||||
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
|
| evidence_refs | 字符串数组 | 引用该任务中已持久化、不可变的证据编号 |
|
||||||
| recommendations | 字符串数组 | 保持原类型,处置建议不等于已执行动作 |
|
| recommendations | 字符串数组,每项最多 160 字符 | 具体对象与后续动作;处置建议不等于已执行动作 |
|
||||||
| pending_items | 字符串数组 | 保持原类型,不从文字推断状态 |
|
| pending_items | 字符串数组,每项最多 160 字符 | 实际尚未完成或等待外部条件的事项,不从文字推断状态 |
|
||||||
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
|
| stage | interim / final | 必填;阶段性报告不能用于结束运行 |
|
||||||
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
|
| outcome | concluded / insufficient_data | 必填;业务调查结果,与运行成功/失败独立 |
|
||||||
| stop_reason | 非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
| stop_reason | 最多 160 字符的非空字符串或 null | 证据不足时必填;已形成结论时必须为 null |
|
||||||
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
|
| data_gaps | 缺口对象数组 | 必填;空数组表示已记录且没有缺口 |
|
||||||
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
|
| diagram | 调查脉络对象或 null | 可选;给人阅读的 Mermaid 说明,不控制执行或任务状态 |
|
||||||
|
| ui_blocks | 结果组件数组,最多 8 块、合计 400 KB | 以现有 Result、Section、Table 等组件表达重点与明细,块内引用必须属于报告证据 |
|
||||||
|
| scada_device_ids | 设备编号数组,最多 1000 项 | 监测明细与展示目标的完整集合,不能因精简摘要删除目标 |
|
||||||
|
|
||||||
缺口对象除 node_id 外的字段必填:
|
缺口对象除 node_id 外的字段必填:
|
||||||
|
|
||||||
@@ -27,10 +29,10 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
|
| gap_id | 非空字符串 | 报告内唯一的缺口编号 |
|
||||||
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
|
| node_id | 非空字符串或 null | 可选的展示关联,不要求有执行计划 |
|
||||||
| missing_data | 非空字符串 | 缺失数据内容 |
|
| missing_data | 非空字符串,最多 4000 字符 | 缺失的业务资料 |
|
||||||
| scope | 非空字符串 | 查询对象、地点及时间范围 |
|
| scope | 非空字符串,最多 4000 字符 | 查询对象、地点及当地时间范围 |
|
||||||
| impact | 非空字符串 | 因此无法回答的调查问题 |
|
| impact | 非空字符串,最多 4000 字符 | 因此无法回答的调查问题 |
|
||||||
| required_data | 非空字符串 | 新一轮调查需要补齐的具体数据 |
|
| required_data | 非空字符串,最多 4000 字符 | 新一轮调查需要补齐的具体数据 |
|
||||||
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
|
| evidence_refs | 至少一个证据编号 | 实际查询或可信输入记录;必须也出现在报告 evidence_refs 中 |
|
||||||
| blocking | boolean | 是否阻止形成结论 |
|
| blocking | boolean | 是否阻止形成结论 |
|
||||||
|
|
||||||
@@ -42,6 +44,48 @@
|
|||||||
不包含 Markdown 围栏、初始化指令和 click 命令。前端使用 strict 安全级别渲染 SVG。
|
不包含 Markdown 围栏、初始化指令和 click 命令。前端使用 strict 安全级别渲染 SVG。
|
||||||
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
|
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
|
||||||
|
|
||||||
|
## 报告表达与生成要求
|
||||||
|
|
||||||
|
报告面向供水运维人员,按当前判断、关键依据、判断限制、后续安排组织内容。
|
||||||
|
字段仍按现有模型发布,不将多段报告重新堆入 `current_conclusion` 或 `conclusion_boundary`。
|
||||||
|
|
||||||
|
- 结论首句直接回答当前发生了什么、形成什么判断。随后只保留最关键的变化和实际下一步,
|
||||||
|
正文最多举两个关键对象。评分权重、算法参数、全部测点、阀门和影响对象放入依据明细。
|
||||||
|
- 多个对象或多组数值比较使用有证据引用的 `ui_blocks.Table`。表格按实际资料选择对象、
|
||||||
|
指标、时刻、异常值、当前值、基线与结果等列,单位放在列名或单元格;缺值明确写未取得。
|
||||||
|
全部目标按需分表,不以精简正文为由缩小调查、证据或 SCADA 展示覆盖。
|
||||||
|
- 使用 `Result` 突出有依据的判断、关键变化或实际待办,使用带业务标题的 `Section` 组织明细。
|
||||||
|
模型候选或尚待现场核验用 `unconfirmed`,部分完成用 `partial`;`success` 必须有相应通过依据。
|
||||||
|
现有文字字段按纯文本展示,不写 Markdown 加粗、标题、表格或 HTML,也不引入自定义强调字段。
|
||||||
|
- 正文、进展、缺口说明和组件可见文字采用中文业务语言。执行字段、技术状态码、流程版本、
|
||||||
|
完整 UUID 和证据哈希留在结构化引用或执行记录中。必要资产编号、SCADA、DN600、单位、
|
||||||
|
模型相似度和有业务用途的工单简称可以保留,不能机械删除所有英文或数字。
|
||||||
|
- 阅读时间先换算为 `Asia/Shanghai`,再以当地日期和时刻显示,例如 `2026-07-16 13:15`。
|
||||||
|
正文、标题、进展、表格和图表均省略时区名称、偏移及 ISO 原始时间戳。已有 `+08:00`
|
||||||
|
不重复换算,跨日同步更新日期;查询时间、`sample_times` 和证据时间戳仍保留明确偏移。
|
||||||
|
事件发生、工单完工、恢复采样和报告生成时间各自标明用途,不能互相代替。
|
||||||
|
- 区分观测、模型估计、候选和现场反馈。相似度不是发生概率,提交工单不证明现场完工,
|
||||||
|
单帧复核通过不证明长期稳定;不同来源或阶段的设备数分别说明范围,不能混用分母。
|
||||||
|
单条流量规则未触发不表示所有流量未变化;不同影响集合不能直接相加。
|
||||||
|
- 判断边界只保留影响理解的限制。实际资料缺项放入 `data_gaps`;未来常规采样和日常监测
|
||||||
|
写入建议,不将其包装成当前关键缺口。非阻塞资料缺项不得新增事件闭环条件。
|
||||||
|
|
||||||
|
初次调查说明异常、候选及预计影响;等待处置说明已完成事项、实际等待条件和下一步;
|
||||||
|
恢复报告先说明现场反馈与完工时间、恢复复核时刻与结果,再给出必要限制和后续监测安排。
|
||||||
|
恢复明细来自本次恢复证据,原异常分析作为历史依据保留,不在摘要中重复全部定位与隔离参数。
|
||||||
|
|
||||||
|
`progress.summary` 最多 300 字符,建议 120 字以内,只写本轮新增发现、当前依赖和下一步,
|
||||||
|
不复制正式报告、不汇报执行字段和流程版本。整理报告时核对脉络图:已解除的等待条件应按
|
||||||
|
原节点更新业务说明和参考标记,保留失败尝试与原路线,不重复添加相同连线。
|
||||||
|
这不要求逐步更新图示,也不改变任何运行、审批、等待或闭环判据。
|
||||||
|
|
||||||
|
Server 的新写入模型及 Agent 的原生工具、宿主发布入口校验长度、明显执行字段与技术编号、
|
||||||
|
原始时间表达和无法渲染的格式标记;组件的可见文字同样校验,结构化证据编号与采样时间除外。
|
||||||
|
校验错误返回具体原因,由 Agent 依据原证据改写后重试,不截断正文、不删除引用或补造缺口。
|
||||||
|
语义准确性、信息取舍和表格完整性仍需遵循提示词与既有证据、覆盖校验,不能由正则代替。
|
||||||
|
|
||||||
|
上述长度和文字规则仅约束新发布,不应用于历史读取或原证据快照;不回写已有报告。
|
||||||
|
|
||||||
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
||||||
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
|
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
|
||||||
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
||||||
@@ -133,7 +177,7 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
|||||||
"gap_id": "terminal-sample-gap",
|
"gap_id": "terminal-sample-gap",
|
||||||
"node_id": null,
|
"node_id": null,
|
||||||
"missing_data": "投诉时段末梢水样检测结果",
|
"missing_data": "投诉时段末梢水样检测结果",
|
||||||
"scope": "投诉小区,2026-09-24 08:00 至 09:00(北京时间)",
|
"scope": "投诉小区,2026-09-24 08:00 至 09:00",
|
||||||
"impact": "无法判断异常来自供水管网还是用户端。",
|
"impact": "无法判断异常来自供水管网还是用户端。",
|
||||||
"required_data": "该地点和时段的采样检测记录",
|
"required_data": "该地点和时段的采样检测记录",
|
||||||
"evidence_refs": ["sample-query-empty"],
|
"evidence_refs": ["sample-query-empty"],
|
||||||
@@ -150,6 +194,10 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态
|
|||||||
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
|
- 已有未决动作阻止矛盾的缺数退出;重试幂等,不重复生成报告。
|
||||||
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
|
- 新轮次保留旧报告历史标识,不能显示成当前轮次已结束。
|
||||||
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
|
- 桌面及 320/375px 展示结果、缺口、证据,审批区不出现数据授权入口。
|
||||||
|
- 新发布拒绝超长摘要、正文执行字段、完整技术编号、原始时间表达和未支持的格式标记;
|
||||||
|
相同历史报告仍可读取,结构化证据引用与带偏移的采样时间保持不变。
|
||||||
|
- 生成结果结论在前、明细入表,明确单位、时间和各自覆盖范围;
|
||||||
|
候选、现场反馈、单帧复核与长期监测保持各自含义,进展和图示不与当前事实冲突。
|
||||||
|
|
||||||
## 发布、阅读与协作
|
## 发布、阅读与协作
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
|
|||||||
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
||||||
| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` |
|
| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
|
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
|
| `analyze_flushing` | 指定排放点、流量与互斥的 `opened_valves` / `closed_valves`,评估独立冲洗水力情景并保存阀态和预计排放量;不证明污染清除或水质合格 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
||||||
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
|
| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 |
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,26 @@
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
detection[有效水质观测命中业务规则] --> task[独立水污染事件调查]
|
detection[有效水质观测命中业务规则] --> task[独立水污染事件调查]
|
||||||
task --> evidence[全量观测及七天同期核验]
|
task --> evidence[全量观测及七天同期核验]
|
||||||
evidence --> trace[快速候选溯源]
|
evidence --> protection[并行提出用户保护与应急供水措施]
|
||||||
|
evidence --> trace[候选溯源与采样核验]
|
||||||
trace --> node[展示已核验节点]
|
trace --> node[展示已核验节点]
|
||||||
node --> report[候选源阶段结论]
|
node --> report[候选源阶段结论]
|
||||||
report --> action[立即提出现场核查采样工单供审批派发]
|
report --> action[立即提出现场核查采样工单供审批派发]
|
||||||
report --> simulation[污染传播模拟与累计浓度影响范围]
|
report --> simulation[污染传播模拟与累计浓度影响范围]
|
||||||
report --> isolation[节点隔离与阀门边界]
|
simulation --> source[污染输入控制建议]
|
||||||
simulation --> impact[同一调查补充证据地图与报告]
|
source --> isolation[保护用户与隔离边界]
|
||||||
isolation --> impact
|
isolation --> treatment[按污染物选择清除方法]
|
||||||
action --> feedback[采样处置复测反馈]
|
treatment --> flushing[清洁水入口 导流阀门 排放处置与分步冲洗]
|
||||||
feedback --> review[接续原调查复核]
|
action --> feedback[现场操作与采样反馈]
|
||||||
impact --> review
|
flushing --> feedback
|
||||||
review --> final[最终报告及事件收尾]
|
feedback --> review[核对实际水质复测与恢复依据]
|
||||||
|
review --> decision{复测依据是否支持恢复}
|
||||||
|
decision -->|否或资料不足| treatment
|
||||||
|
decision -->|是| final[分区恢复建议与最终报告]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
图中连线表示业务与展示层次,先期保护、工单审核和独立计算可以并行。阀门操作均为方案,现场动作继续经过原审批与派发;阶段记录不能替代执行回执。首次展示保留处置前传播结果或明确的数据缺口,再展示隔离及清除方案。
|
||||||
|
|
||||||
## 侦测入口
|
## 侦测入口
|
||||||
|
|
||||||
现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。
|
现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。
|
||||||
@@ -46,6 +52,22 @@ flowchart TD
|
|||||||
|
|
||||||
## 展示、反馈与收尾
|
## 展示、反馈与收尾
|
||||||
|
|
||||||
|
`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 证据。
|
水质图表使用 `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 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。
|
关联现场任务使用 general 工单。成功创建后保存在 `handling_context.work_orders`,进入 `waiting_work_order`;全部有效工单 completed 后进入 `field_review`,接续 Agent,而非执行爆管压力恢复计算。最终 concluded 报告必须引用全部有效工单最新的 `feedback_evidence_id`,然后由 Server 完成任务并解除关联告警。完工反馈或新版本复测资料会重新进入复核;证据不齐时 Agent 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.18"
|
version: "1.0.19"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SCADA 数据异常调查
|
# SCADA 数据异常调查
|
||||||
@@ -11,7 +11,7 @@ metadata:
|
|||||||
|
|
||||||
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
先按handling_context接续:待审批/工单/监测且无独立工作时,保存本轮interim/concluded及action_id后退出,不轮询。verification_ready复用原异常证据,只补恢复复核;初次分析按下文。
|
||||||
|
|
||||||
报告与图表的阅读时区统一为Asia/Shanghai(UTC+8)。工具返回的+08:00时间按原值使用;Z时间换算后同步更新日期、历史系列名和横轴标签,例如2026-07-16T00:00:00Z对应7月16日08:00。sample_times记录相同真实时间点并使用+08:00偏移,查询与冻结边界按带时区的时间点比较。图表标题、横轴和系列名只写日期、时刻与单位,不写“上海时间”“Asia/Shanghai”或“UTC+8”;横轴命名为“时间”。
|
阅读时间按 Asia/Shanghai 换算,已有 +08:00 不重复换算,跨日更新日期。正文、标题、进展、表格和图表只写当地日期、时刻与单位,省略时区名称、偏移及原始时间戳;横轴用“时间”。查询、冻结边界和 sample_times 保留明确偏移。
|
||||||
|
|
||||||
## 数据核验与诊断
|
## 数据核验与诊断
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ metadata:
|
|||||||
|
|
||||||
全量devices含完整目录,readings含观测,coverage含各设备读数数量、缺测及冻结范围外清单。不得由代表测点推断全网。单查list_scada_devices默认每页100,读至page.has_more=false,属性缩略不代表完整信息。全量后可按device_ids局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
|
全量devices含完整目录,readings含观测,coverage含各设备读数数量、缺测及冻结范围外清单。不得由代表测点推断全网。单查list_scada_devices默认每页100,读至page.has_more=false,属性缩略不代表完整信息。全量后可按device_ids局部复核并持续翻页,不能替代全量输入。final引用本轮knowledge_cutoff及batch_sequence的全量证据;Server拒绝局部或旧批次证据。
|
||||||
|
|
||||||
分别说明压力异常模型得分、压力绝对下降及变化率、已配置出厂边界流量变化,再说明联合证据支持程度。使用实际权重、嫌疑门槛、动态阈值、参与测点与各自基线。综合分用于爆管嫌疑判断;得分越低越异常,目标告警与参考日分别解释。水质等其他异常按实际证据选择原因分支。
|
核对模型得分、压力变化与出厂边界流量的联合证据;权重、门槛、阈值、测点与基线留在依据。得分越低越异常,相似度非发生概率。区分边界与其他管流测点,规则零命中不代表无流量变化;其他异常按证据选择原因。
|
||||||
|
|
||||||
## 候选定位与隔离
|
## 候选定位与隔离
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ run_burst_simulation设include_impact_coverage=true,用定位管道、异常
|
|||||||
|
|
||||||
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
||||||
|
|
||||||
报告含观测、规则、原因、定位隔离、影响和建议;摘要≤150字,测点用表格、完整依据留证据。完成分析、展示与报告后收尾;现场核验、审批维修按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
|
报告先写判断与关键依据,摘要建议 150 字、最多 300 字;多测点数值入 Table,保留单位、方向与缺测。区分各阶段测点数,不相加不同影响集合。现场事项按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
|
||||||
|
|
||||||
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
|
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
|
||||||
|
|
||||||
@@ -59,4 +59,6 @@ run_burst_simulation设include_impact_coverage=true,用定位管道、异常
|
|||||||
|
|
||||||
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
|
||||||
|
|
||||||
|
恢复报告先写现场反馈与完工时间、采样时刻和结果,表格列全部复核测点及基线;边界说明单帧与模型限制,日常监测列建议。不重列旧定位参数或执行字段;更新原图中已解除的等待标记,保留路线与失败记录。
|
||||||
|
|
||||||
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
|
||||||
|
|||||||
@@ -1,62 +1,72 @@
|
|||||||
---
|
---
|
||||||
name: water-pollution-event
|
name: water-pollution-event
|
||||||
description: 从水质监测侦测信号调查水污染事件,完成数据核验、候选溯源、隔离与影响分析、处置报告和反馈复核。
|
description: 水污染侦测后的观测核验、候选溯源、源头控制、用户隔离、清除冲洗与水质复测。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.0"
|
version: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 水污染侦测事件调查
|
# 水污染侦测事件调查
|
||||||
|
|
||||||
适用于 `workflow=water_pollution`,入口是水质监测规则侦测异常,与供水水质投诉 `water_quality_complaint` 分开。无需投诉或人工上报才能调查,也不需要压力下降、流量增加或爆管综合评分达到门槛。侦测异常、候选污染源和污染确诊分别说明。
|
适用于 workflow=water_pollution,与 water_quality_complaint 投诉分开,不依赖爆管评分、漏量或压力流量异常。侦测信号、候选污染源、污染确诊和恢复供水分别说明。
|
||||||
|
|
||||||
先读取任务、规则信号、异常设备、发生时刻、已有证据与报告。任务携带 replay_context 时,查询严格遵守 knowledge_cutoff 和 batch_sequence;普通侦测任务使用实际异常时刻。时间统一按 Asia/Shanghai 阅读,查询和 sample_times 保留明确时区,图表标题只写日期、时刻与单位。后续轮次优先处理新事实,复用已有资产、溯源、隔离和展示成果。
|
读取任务、信号、时间、handling_context 和已有成果。回放遵守 knowledge_cutoff、batch_sequence;阅读时间 Asia/Shanghai,查询与 sample_times 带时区。后续轮次复用成果,优先新增事实。
|
||||||
|
|
||||||
## 1. 核验侦测与观测
|
## 1. 核验观测与先期保护
|
||||||
|
|
||||||
调用 query_scada_readings,省略 device_ids,全量读取异常时刻之前三小时的授权 SCADA;核对 devices、readings、coverage、分页完成与缺测。核查水质指标名称、单位、设备状态、清洗质量、模型节点映射以及实际规则上下限和连续命中条件。超出仪表量程、无效值和缺测属于数据质量问题,不能直接当作污染。有效水质读数超出配置业务范围支持异常调查,不自行认定为法定水质超标。
|
query_scada_readings 省略 device_ids,读取异常前三小时全量 SCADA,核查分页完成、覆盖、质量、单位、映射及配置阈值。无效值或缺测不等于污染,业务异常不等于法定超标。逐设备对照七天同期,保留日期和 null;压力正常不能排除污染。
|
||||||
|
|
||||||
对照同一设备前七天同期及实际采样记录,保留真实时间和 null 缺口。压力、流量仅作关联依据,正常压力不能排除污染,爆管得分不用于替代水污染判断。没有适用的限值或采样结果时记录具体缺口,继续候选分析。
|
核验、采样与用户保护并行。按风险提出用水限制、通知、重点用户保障及应急供水;缺关联、适配器或批准依据则交人工核定,不称已执行。保护措施不等溯源或模拟,未知污染物不统一建议煮沸、加药或冲洗。
|
||||||
|
|
||||||
## 2. 候选溯源与节点展示
|
## 2. 候选溯源与现场工单
|
||||||
|
|
||||||
使用水质设备映射取得异常关联节点,以 get_element_properties、get_node_connections 核对。映射缺失时查询设备坐标附近模型节点,不猜编号。候选必须来自当前项目已查询资产。
|
用设备映射、get_element_properties、get_node_connections 核实节点,缺映射则查坐标附近资产。检查 source_trace_capability,run_contaminant_source_trace 提交已核实 candidate_node_ids 和核查窗,读取候选、缺失节点及 selection_basis。当前为临时确定性选点,非正式反演,不编造概率或确诊。synthetic 不阻止分析,无节点仅停依赖分支。
|
||||||
|
|
||||||
检查 source_trace_capability,调用 run_contaminant_source_trace:candidate_node_ids 提交已核验的候选节点,start/end 为实际核查窗。读取 located_node、top_candidates、validated_node_ids、missing_node_ids 和 selection_basis。当前接口以稳定规则快速返回确定节点,正式反演算法后续接入,不虚构相似度、确诊概率或污染范围。
|
取得候选后立即 publish_task_presentation 展示节点,保存 interim/concluded,注明“候选污染源待现场核验、传播范围计算中”;propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用定位证据和地点。沿用已有 action_id,不等模拟完成才发起工单。审核派发与模拟、隔离分析并行,结果补入同一 task_id,不覆盖已审核工单或绕过版本校验。
|
||||||
|
|
||||||
临时算法和 synthetic 来源只说明结果取得方式,不构成停止调查的条件。取得 located_node 后立即通过 publish_task_presentation 展示候选节点,继续隔离、覆盖、影响和报告。无候选时修正资产映射;确实无可用节点时仅暂停依赖源节点的分支,完成已有监测事实与处置建议。
|
## 3. 处置前污染影响
|
||||||
|
|
||||||
定位到源节点后,立即保存 interim/concluded 阶段报告,注明“候选污染源待现场核验、传播范围计算中”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用节点、地点和定位证据。沿用已有 action_id,不因模拟返回再建同类工单。工单审核、派发与传播模拟、隔离分析并行,不等模拟完成才发起工单;审批权限与动作版本校验沿用 Server 流程。
|
run_contaminant_simulation 设 source=located_node、include_impact_coverage=true,采用事件起算时刻、明确预测时长和需画曲线的 output_node_ids。concentration 为 mg/L,须有来源或说明情景假设,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺依据时列具体参数缺口,继续工单和独立分析。
|
||||||
|
|
||||||
## 3. 处置前的污染传播模拟
|
impact_threshold_mg_l 默认0、严格大于,仅供展示,非饮用水安全限值。全网整个时段汇总累计节点、首个超阈值采样时刻、峰值与覆盖;沿用初始水质和反应条件,非源独立贡献,范围外不等于安全。
|
||||||
|
|
||||||
继续调用 run_contaminant_simulation,source=located_node、include_impact_coverage=true;start_time 使用事件业务时刻,duration 使用明确的情景预测时长,output_node_ids 选择源节点和需展示曲线的监测/重要节点。源浓度 concentration 单位 mg/L,使用有来源的浓度或有明确依据的情景假设,保留依据与不确定性,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺少可用依据时记录具体参数缺口,继续工单和隔离分支。
|
queued/running 时继续独立工作,get_calculation_job 跟踪、get_calculation_result 读终态。未完计算不能 final,由宿主等待接续;待审批不阻止分析,模拟失败不撤回工单。
|
||||||
|
|
||||||
impact_threshold_mg_l 仅作模型影响范围的展示条件;默认0表示正浓度,非饮用水安全限值。工具在筛选展示节点前汇总全网节点整个模拟时段,返回累计超阈值节点、首个超阈值报告时刻、峰值和地图覆盖。沿用模型初始水质及反应条件,不能将总模型浓度当作源独立贡献,不能据此宣布范围外安全。
|
按 presentation_preview.evidence_id 发布“水污染传播预计影响范围(处置前情景)”、参数与计数表及浓度曲线;模型曲线不填观测专用 chart.scada。保存正式事件供历史复用;空范围、缺测、单位及几何限制单列。先保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。
|
||||||
|
|
||||||
queued/running 时继续独立工作,用 get_calculation_job 跟踪并以 get_calculation_result 读取终态,不以回执代替结果。待审批或 waiting_work_order 不阻止继续可执行分析;模拟结果补入同一 task_id 的证据、进度与报告,不覆盖已审核工单或绕过版本校验。模拟失败只记录该分支问题,不撤回已有现场核查工单。存在未完成计算时不能结束调查运行或发布 final,按宿主计算等待机制接续。
|
回放允许传播及冲洗情景,起算不晚于知识截止,使用当前模型且不读取实时 SCADA;未来样本仅预测,非历史重建。
|
||||||
|
|
||||||
读取结果后,用 presentation_preview 的 evidence_id 发布“水污染传播预计影响范围(处置前情景)”地图;将源浓度、阈值、预测窗、影响节点数、首达采样时刻和峰值写入报告表格,按真实时刻展示模型浓度曲线,模型曲线不填观测专用 chart.scada。地图和图表进入正式调查展示事件,历史播放读取已保存证据,不重新运行模拟。空范围、缺测、不支持的单位或几何超限按实际结果说明。保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。
|
## 4. 源头控制与用户隔离
|
||||||
|
|
||||||
带 replay_context 时同样可运行此情景,起算不得晚于知识截止;使用当前模型且不读取实时 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 核查替代方案,不能隔离时保留原因。
|
||||||
|
|
||||||
调用 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 核验水力影响,不证明污染清除。
|
||||||
|
|
||||||
展示候选节点、必关与可选阀门。用 generate_node_coverage 分组生成直接隔离、新增供水失源覆盖,再通过 publish_task_presentation 发布。空节点组不提交覆盖计算。拓扑隔离、供水失源、污染传播、水力变化和用户影响分别说明,节点覆盖不能当作污染传播边界,无用户资料不虚构户数。
|
## 5. 污染物清除与分步冲洗
|
||||||
|
|
||||||
输入和权限具备时使用 analyze_valve_closure 评估关阀水力影响,污染传播复用第3节结果。有排放节点、流量和前置条件时用 analyze_flushing 评估冲洗,缺少可选参数不阻止形成隔离、采样和保障建议。回放未开放的其他水力工具按实际权限记录限制,继续已授权分析。
|
先说明污染物性质、处理依据和未知项,再提出排空、冲洗、消毒或其他适用处理;不得默认任何污染都能冲洗清除。排放去向及污染水收集处理须有依据,不默认排入雨水、河道或普通排水口。
|
||||||
|
|
||||||
## 5. 过程图表与报告
|
核对清洁水源、排放点、流量及许可条件后用 analyze_flushing。每阶段保存目的、前置条件、清洁进水依据、排放去向、完整开关阀清单、顺序、流量、时长、预计水量、监测和待办。隔离和冲洗不能共用固定关阀清单,进水阀打开不等于用户复供;缺参数仅停依赖分支。
|
||||||
|
|
||||||
水质曲线填写 chart.scada:metric="quality"、真实 unit、role=current/comparison、完整三小时 window_start/window_end;series 绑定 device_id 和 day_offset。不同水质指标或单位分图,不能把浊度、余氯或其他指标混成同一浓度。当前总览覆盖报告与地图列出的全部目标;各设备七天同期图采用 time_comparison,当前三小时为 line,前七天每天末三个实际采样时刻为 scatter,sample_times 与 data 等长且保留真实日期。缺测保留 null,超过槽位上限按时间分图。所有数据引用本轮证据;展示不要求等待浏览器。
|
opened_valves 明确开阀、closed_valves 指定关闭阀门,两者互斥。每次是独立当前模型情景,未列阀门沿用模型状态,不继承上次模拟;重列本阶段必要阀态,不能称为连续污染清除。flushing_plan 保存方案及估计排放量,仅证明水力结果,不验证清洁进水、排放合规或现场执行。尚未耦合停止输入后的水质衰减,压力、流量或模型零浓度不能替代复测。
|
||||||
|
|
||||||
报告包含“事件概况、侦测与数据核验、候选溯源、隔离方案、影响说明、处置建议、反馈复核与待办”。影响说明列出实际异常指标、单位、时间、测点、直接隔离与失源节点数、水力或传播预测和用户保障。明确临时选点、观测事实、模型假设及待现场确认事项。最终报告引用本轮全量 SCADA 证据及已取得的溯源、隔离和覆盖证据。查询失败或权限拒绝记录为执行异常,不能写成无数据或未见异常。
|
展示清洁水入口、保护边界、导流阀及排放点;有真实GIS才画路径。拟开、拟关、现场实际阀位分列,不改写实时资产。回放未授权工具记录限制,复用已有证据。
|
||||||
|
|
||||||
## 6. 处置与反馈接续
|
## 6. 图表、报告与展示节奏
|
||||||
|
|
||||||
第2节已提出的 general 工单继续沿用,按新增证据组织污染进入控制、隔离核验、通知保障、清洗冲洗及复测;已有工单确有不足时再提出必要的新动作。标题写“地点+动作”,写明执行条件、真实节点、时间要求与回执内容;审批、派发和现场执行遵守 Server 权限。没有用户通知适配器时交由人工实施。回放草稿注明异常业务时间及来源,不直接执行设备命令。
|
水质 chart.scada 填 metric=quality、unit、role、完整三小时 window_start/window_end,总览覆盖全部目标。逐设备 time_comparison 用当前 line、七天每天末三真实槽 scatter;series 带 device_id、day_offset、等长 sample_times,缺测 null。不同指标或单位分图,超限分段,不标成流量。
|
||||||
|
|
||||||
待审批、waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用真实 action_id 后结束本轮,释放租约,不循环查询或重复建单。field_review 时核对各关联工单反馈及实际采样、处置、复测的时间、位置、数值和判定依据,再查询反馈业务时间之后已有水质观测;不要重跑原始调查。缺少关键事实时先完成独立工作,再用 ask_task_user 明确待补内容并结束本轮等待。
|
展示依次为观测、候选、传播、源头控制、隔离、冲洗、反馈、复测、恢复;计算可并行,首次隔离展示须在传播结果或缺口说明之后,通知和工单不等待展示。发布填写具体 purpose;有证据时带 narration(stage、summary、evidence_refs)。沿用原阶段,源头控制用 source_control、冲洗用 flushing_plan、水质复测用 clearance_verification。阶段不等于批准或已执行,不为凑阶段造证据或空操作。
|
||||||
|
|
||||||
本轮调查成果完成与现场事件结案分别表述。工单 completed 或压力恢复不证明水质合格,不套用爆管压力恢复的自动结案条件。反馈复核具备依据时发布 final/concluded,引用最新现场反馈证据;仍异常时调整建议或新建必要动作,未来采样、复测和恢复不能写成已完成。
|
报告依次写事件概况、观测、候选与传播、源头控制、用户隔离、清除冲洗、反馈复测及恢复条件;各项列结论、对象、预计或实际、证据和待办。操作表保留阶段、阀态、进排水、条件、反馈及复测依据。引用本轮全量 SCADA 和已取得的分析证据;权限拒绝不写成无数据。各阶段独立保存,回放不合并吞步骤,不派单或操作设备。
|
||||||
|
|
||||||
|
## 7. 工单反馈与复测恢复
|
||||||
|
|
||||||
|
复用 general 工单及 action_id,仅因职责或阶段确需拆分才建新单,保留前置条件,不能同时要求同阀开关。回传实际阀位、进排水、执行时间和异常;复测含采样地点、时间、指标、结果、单位、判定依据及结论。通知保留,操作遵守原权限。
|
||||||
|
|
||||||
|
待审批或 waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用 action_id 后退出释放租约,不轮询或重复建单。field_review 复用原调查,只核对最新反馈与相关水质观测;关键事实缺失时先完成独立工作,再用 ask_task_user 提问并等待,必要时提出复测或调整处置工单。
|
||||||
|
|
||||||
|
工单 completed、压力恢复、余氯单项正常或模型预测均不能证明污染物已清除。恢复依据须说明目标污染物或已核实风险、适用检测项目及判定标准、实际采样结果与时间地点、受影响区域覆盖和有依据的恢复结论;无适用依据或结果未通过时保持 interim,列待复测区域与下一步,不能宣布恢复供水或发布恢复闭环 final。不能为所有事件统一增设固定轮数、等待时长或未配置阈值。
|
||||||
|
|
||||||
|
有复测与恢复依据后展示可恢复区域,final/concluded 引用所有有效工单最新 feedback_evidence_id 及复测证据。不套用爆管压力结案,仍异常调整方案,未来采样和恢复不写成已完成。Server不自动认定实验室合格或授权现场复供。
|
||||||
|
|||||||
@@ -0,0 +1,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}
|
||||||
@@ -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)
|
||||||
@@ -70,6 +70,17 @@ def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the
|
|||||||
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" 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"])
|
@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"])
|
||||||
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
|
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
|
||||||
task_id, project_id = uuid4(), uuid4()
|
task_id, project_id = uuid4(), uuid4()
|
||||||
@@ -102,6 +113,7 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch
|
|||||||
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
|
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
|
||||||
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
|
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
|
||||||
assert body.data["skill"]["instructions"] and not body.truncated
|
assert body.data["skill"]["instructions"] and not body.truncated
|
||||||
|
assert len(json.dumps(body.model_dump(mode="json"), ensure_ascii=False).encode()) < 10000
|
||||||
assert body.evidence_id is None and body.semantics == "task"
|
assert body.evidence_id is None and body.semantics == "task"
|
||||||
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
||||||
binding.permission_cap = []
|
binding.permission_cap = []
|
||||||
|
|||||||
@@ -2363,3 +2363,77 @@ async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation
|
|||||||
await getattr(other, method)(task["task_id"], command)
|
await getattr(other, method)(task["task_id"], command)
|
||||||
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||||
assert repository.task == before
|
assert repository.task == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("stage", ["asset_localization", "source_control", "flushing_plan", "clearance_verification"])
|
||||||
|
async def test_presentation_narration_is_evidence_backed_saved_and_replayed_without_mutating_task(stage):
|
||||||
|
now = datetime(2026, 11, 14, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
evidence, _ = await service.record_data_snapshot(task['task_id'], TaskDataSnapshot(
|
||||||
|
source='query_scada_readings', source_ref='observations', queried_at=now,
|
||||||
|
origin='synthetic', data={'items': []}, truncated=False,
|
||||||
|
))
|
||||||
|
narration = {'stage': stage, 'summary': '本阶段保存的候选方案及核验依据。',
|
||||||
|
'evidence_refs': [evidence.evidence_id], 'next_step': '提出现场工单草稿,并行开展影响分析。'}
|
||||||
|
command = TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||||
|
command={'action': 'locate_features', 'layer': 'pipes', 'feature_ids': ['P1']}, narration=narration,
|
||||||
|
purpose='核对候选管段与异常测点的空间关系,供后续影响分析。',
|
||||||
|
)
|
||||||
|
result = await service.publish_presentation_event(task['task_id'], command)
|
||||||
|
assert repository.events[-1]['metadata']['narration'] == narration
|
||||||
|
assert repository.events[-1]['metadata']['purpose'] == command.purpose
|
||||||
|
assert await service.publish_presentation_event(task['task_id'], command) == result
|
||||||
|
page = await service.presentation_history(task['task_id'])
|
||||||
|
assert page.items[-1].narration.model_dump(exclude_none=True) == narration
|
||||||
|
assert page.items[-1].purpose == command.purpose
|
||||||
|
assert repository.task['revision'] == task['revision']
|
||||||
|
with pytest.raises(DomainError) as conflict:
|
||||||
|
await service.publish_presentation_event(task['task_id'], command.model_copy(update={
|
||||||
|
'narration': command.narration.model_copy(update={'summary': '不同判断'}),
|
||||||
|
}))
|
||||||
|
assert conflict.value.code == 'IDEMPOTENCY_CONFLICT'
|
||||||
|
foreign = TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||||
|
command={'action': 'clear_highlights'}, narration={**narration, 'evidence_refs': ['other-task']},
|
||||||
|
)
|
||||||
|
before = len(repository.events)
|
||||||
|
with pytest.raises(DomainError) as rejected:
|
||||||
|
await service.publish_presentation_event(task['task_id'], foreign)
|
||||||
|
assert rejected.value.code == 'EVIDENCE_NOT_FOUND'
|
||||||
|
assert len(repository.events) == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('changes', [
|
||||||
|
{'stage': 'invented_stage'}, {'summary': 'x' * 161}, {'summary': ' '},
|
||||||
|
{'evidence_refs': []}, {'evidence_refs': ['e1', 'e1']}, {'next_step': 'x' * 121},
|
||||||
|
])
|
||||||
|
def test_presentation_narration_rejects_unbounded_or_unsubstantiated_fields(changes):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||||
|
command={'action': 'clear_highlights'},
|
||||||
|
narration={'stage': 'evidence_verification', 'summary': '测点异常待核验',
|
||||||
|
'evidence_refs': ['e1'], **changes},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_presentation_idempotency_body_does_not_gain_an_empty_narration_field():
|
||||||
|
command = TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||||
|
command={'action': 'clear_highlights'},
|
||||||
|
)
|
||||||
|
assert 'narration' not in command.model_dump(mode='json')
|
||||||
|
assert 'purpose' not in command.model_dump(mode='json')
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('purpose', ['', ' ', 'x' * 161, 123])
|
||||||
|
def test_presentation_purpose_rejects_invalid_intent(purpose):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||||
|
command={'action': 'zoom_to_map', 'center': [121, 31], 'zoom': 15}, purpose=purpose,
|
||||||
|
)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulat
|
|||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", report={**report,
|
request_id=uuid4(), source_session_id="session-1", report={**report,
|
||||||
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
|
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
|
||||||
"pending_items": [f"等待现场工单反馈,action_id={action.action_id}"]}))
|
"pending_items": ["等待现场核查和采样处置工单反馈"]}))
|
||||||
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
|
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
|
||||||
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
|
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Presentation prose is validated on writes without rejecting historical scenes."""
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskPresentationHistoryItem
|
||||||
|
|
||||||
|
|
||||||
|
def presentation():
|
||||||
|
return {
|
||||||
|
"request_id": uuid4(), "run_id": uuid4(), "source_session_id": "fixture-session",
|
||||||
|
"purpose": "核对压力变化与异常时刻的对应关系。",
|
||||||
|
"narration": {"stage": "evidence_verification", "summary": "压力已回升,恢复情况仍需结合现场反馈复核。",
|
||||||
|
"next_step": "核对完工后的恢复监测。", "evidence_refs": ["recovery:sha256:fixture"]},
|
||||||
|
"command": {"action": "open_chart", "chart_id": "pressure", "evidence_refs": ["recovery:sha256:fixture"],
|
||||||
|
"chart": {"title": "170486 压力恢复趋势", "chart_type": "line", "x_axis_name": "时间",
|
||||||
|
"y_axis_name": "压力(m)", "x_data": ["13:00", "13:15"],
|
||||||
|
"series": [{"name": "170486 当前压力", "data": [20.09, 26.12],
|
||||||
|
"sample_times": ["2026-07-16T05:00:00Z", "2026-07-16T05:15:00Z"]}]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"status=passed,required_samples=1。", "Skill scada-anomaly v1.0.16 已执行。",
|
||||||
|
"已读取 recovery:sha256:01234567。", "恢复时刻 2026-07-16T05:15:00Z。",
|
||||||
|
"恢复时刻 13:15(UTC+8)。", "**恢复复核通过**。",
|
||||||
|
])
|
||||||
|
def test_presentation_rejects_execution_text_in_purpose_finding_and_follow_up(text):
|
||||||
|
value = presentation()
|
||||||
|
for field in ("purpose", "summary", "next_step"):
|
||||||
|
changed = deepcopy(value)
|
||||||
|
if field == "purpose":
|
||||||
|
changed[field] = text
|
||||||
|
else:
|
||||||
|
changed["narration"][field] = text
|
||||||
|
history = TaskPresentationHistoryItem(
|
||||||
|
event_seq=1, run_id=changed["run_id"], execution_generation=1,
|
||||||
|
occurred_at=datetime(2026, 7, 16, tzinfo=timezone.utc),
|
||||||
|
command=changed["command"], purpose=changed["purpose"], narration=changed["narration"],
|
||||||
|
)
|
||||||
|
assert history.purpose == changed["purpose"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TaskPresentationEventCommand.model_validate(changed)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["title", "x_axis_name", "y_axis_name", "x_data", "series"])
|
||||||
|
def test_new_chart_visible_labels_follow_the_same_prose_contract(field):
|
||||||
|
value = presentation()
|
||||||
|
chart = value["command"]["chart"]
|
||||||
|
if field == "x_data":
|
||||||
|
chart[field][0] = "2026-07-16T05:00:00Z"
|
||||||
|
elif field == "series":
|
||||||
|
chart[field][0]["name"] = "execution_generation=3"
|
||||||
|
else:
|
||||||
|
chart[field] = "压力复核 status=passed"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TaskPresentationEventCommand.model_validate(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_validation_preserves_real_timestamps_identifiers_and_values():
|
||||||
|
value = presentation()
|
||||||
|
parsed = TaskPresentationEventCommand.model_validate(value).model_dump(mode="json")
|
||||||
|
assert parsed["command"] == value["command"]
|
||||||
|
assert parsed["narration"]["evidence_refs"] == value["narration"]["evidence_refs"]
|
||||||
|
value["command"] = {"action": "set_overlay", "overlay_id": "estimated-impact",
|
||||||
|
"title": "候选管段预计影响范围", "mode": "geometry", "evidence_id": "mcp:fixture"}
|
||||||
|
assert TaskPresentationEventCommand.model_validate(value).command.title == "候选管段预计影响范围"
|
||||||
|
value["command"]["title"] = "候选影响范围(UTC+8)"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
TaskPresentationEventCommand.model_validate(value)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.mcp import analysis_service, query_service
|
||||||
|
from app.mcp.analysis_models import FlushingInput
|
||||||
|
from app.mcp.calculation_jobs import should_queue
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def parameters(**changes):
|
||||||
|
return FlushingInput.model_validate({"context_handle": "a" * 43,
|
||||||
|
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
||||||
|
"output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 12,
|
||||||
|
"opened_valves": ["V-in"], "closed_valves": ["V-user"], **changes})
|
||||||
|
|
||||||
|
|
||||||
|
def context(monkeypatch):
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def routing():
|
||||||
|
yield
|
||||||
|
|
||||||
|
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
|
||||||
|
project=SimpleNamespace(project_code="owned", project_role="member"), user=SimpleNamespace(username="actor"),
|
||||||
|
simulation_routing=routing)
|
||||||
|
binding = SimpleNamespace(tool_cap=["analyze_flushing"], permission_cap=["simulation.run", "scada.view"],
|
||||||
|
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
|
||||||
|
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||||
|
return scope, binding
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_flushing_uses_the_model_only_adapter_and_retains_prediction_boundary(monkeypatch):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
worker = AsyncMock(return_value={"flushing_plan": {"water_quality_clearance_verified": False}})
|
||||||
|
monkeypatch.setattr(analysis_service, "calculate_replay_flushing", worker)
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["analyze_flushing"], parameters()))
|
||||||
|
assert result.origin == "synthetic" and "预测" in result.data_gaps[0]
|
||||||
|
assert result.data["result"]["flushing_plan"]["water_quality_clearance_verified"] is False
|
||||||
|
assert result.data["parameters"]["opened_valves"] == ["V-in"]
|
||||||
|
scope.connection.commit.assert_awaited_once()
|
||||||
|
worker.assert_awaited_once()
|
||||||
|
assert worker.await_args.kwargs == {"network": "owned", "username": "actor"}
|
||||||
|
assert should_queue(TOOLS["analyze_flushing"], parameters())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
|
||||||
|
def test_replay_flushing_preserves_time_permissions_and_reader_boundaries(monkeypatch, problem):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
args = parameters()
|
||||||
|
if problem == "future_start": args = parameters(start_time="2026-07-16T08:15:00+08:00")
|
||||||
|
if problem == "reader": binding.purpose = "task_reader"
|
||||||
|
if problem == "permission": binding.permission_cap.remove("scada.view")
|
||||||
|
if problem == "tool_cap": binding.tool_cap.clear()
|
||||||
|
with pytest.raises(HTTPException) as caught:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["analyze_flushing"], args))
|
||||||
|
assert caught.value.status_code == (422 if problem == "future_start" else 403)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_only_flushing_and_native_runner_both_receive_empty_scada_mappings(monkeypatch):
|
||||||
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
|
||||||
|
captures = []
|
||||||
|
native = scenarios.flushing_analysis.__wrapped__
|
||||||
|
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"})
|
||||||
|
|
||||||
|
def isolated(**kwargs):
|
||||||
|
captures.append(kwargs)
|
||||||
|
return native(**kwargs, _temporary_project="temporary")
|
||||||
|
|
||||||
|
monkeypatch.setattr(scenarios, "flushing_analysis", isolated)
|
||||||
|
monkeypatch.setattr(scenarios, "is_junction", lambda *_: True)
|
||||||
|
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
|
||||||
|
monkeypatch.setattr(scenarios, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "00:15:00"})
|
||||||
|
monkeypatch.setattr(scenarios, "get_demand", lambda *_: {"demands": []})
|
||||||
|
for name in ("add_pattern", "set_demand", "set_option"):
|
||||||
|
monkeypatch.setattr(scenarios, name, lambda *_: None)
|
||||||
|
monkeypatch.setattr(scenarios.simulation, "run_simulation", lambda **kw: captures.append(kw) or {
|
||||||
|
"node_results": [], "link_results": []})
|
||||||
|
output = asyncio.run(analysis_service.calculate_replay_flushing(parameters(), network="owned", username="actor"))
|
||||||
|
assert len(captures) == 2
|
||||||
|
assert captures[0]["scada_mappings"] is captures[1]["scada_mappings"]
|
||||||
|
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
|
||||||
|
assert captures[1]["name"] == "temporary" and captures[1]["persist_results"] is False
|
||||||
|
assert output["flushing_plan"]["boundary_source"] == "model_only_no_live_scada"
|
||||||
@@ -603,15 +603,20 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
|
|||||||
|
|
||||||
command = create_task.await_args.args[0]
|
command = create_task.await_args.args[0]
|
||||||
assert command.workflow == "scada_anomaly"
|
assert command.workflow == "scada_anomaly"
|
||||||
|
assert command.title == "SCADA 异常调查 · 2026-10-08 08:15"
|
||||||
|
assert "2 个压力测点下降" in command.summary
|
||||||
|
assert "1 个出厂边界流量测点增加" in command.summary
|
||||||
|
assert "爆管嫌疑" in command.summary
|
||||||
assert command.source.replay_context.batch_sequence == 8
|
assert command.source.replay_context.batch_sequence == 8
|
||||||
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
||||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||||
assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}"
|
assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}"
|
||||||
assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。"
|
assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。"
|
||||||
if weighted:
|
if weighted:
|
||||||
assert "-0.0843" in command.summary
|
assert "-0.0843" not in command.summary
|
||||||
assert "综合评分 0.90" in command.summary
|
assert "综合评分" not in command.summary
|
||||||
assert "流量 10%" in command.summary
|
assert "10%" not in command.summary
|
||||||
|
assert monitoring["weighted"]["score"] == 0.9
|
||||||
|
|
||||||
|
|
||||||
def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch):
|
def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch):
|
||||||
@@ -916,8 +921,10 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
|
|||||||
command = create_task.await_args.args[0]
|
command = create_task.await_args.args[0]
|
||||||
assert command.workflow == "scada_anomaly"
|
assert command.workflow == "scada_anomaly"
|
||||||
assert command.source.event_kind == "scada_anomaly"
|
assert command.source.event_kind == "scada_anomaly"
|
||||||
assert "任一指标异常即启动调查" in command.summary
|
assert "已启动调查" in command.summary
|
||||||
assert "爆管嫌疑门槛 0.80" in command.summary
|
assert "异常原因及影响范围待核查" in command.summary
|
||||||
|
assert "爆管嫌疑门槛" not in command.summary
|
||||||
|
assert monitoring["weighted"]["score"] == 0.1
|
||||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||||
assert monitoring["incident"]["action"] == "raised"
|
assert monitoring["incident"]["action"] == "raised"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user