From 2e4f6cb93b1d3dfdf8f81508e52b84f89559a7a3 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 14 Nov 2026 15:14:02 +0800 Subject: [PATCH] feat(investigations): add event impact and work-order feedback flows Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure. --- app/domain/schemas/operational_data.py | 1 + app/domain/schemas/operational_task.py | 17 +- app/mcp/analysis_models.py | 21 ++ app/mcp/analysis_service.py | 18 ++ app/mcp/calculation_jobs.py | 13 ++ app/mcp/catalog.py | 7 +- app/mcp/extended_models.py | 1 + app/mcp/presentation_preview.py | 37 +++- app/mcp/query_service.py | 65 +++++- app/mcp/scenario_service.py | 39 +++- app/mcp/work_order_skills.py | 1 + app/services/burst_impact.py | 147 +++++++++++++ app/services/contaminant_impact.py | 112 ++++++++++ app/services/contaminant_source_trace.py | 34 +++ app/services/investigation_graph.py | 15 +- app/services/operational_recovery.py | 11 +- app/services/operational_scada_coverage.py | 2 +- app/services/operational_tasks.py | 51 ++++- app/services/scada_normal_profile.py | 13 +- app/services/scada_presentation_coverage.py | 6 +- app/services/scada_replay.py | 51 ++--- app/services/simulation_scenarios.py | 6 + contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 91 +++++++- contracts/view-command.schema.json | 3 +- docs/burst-response-workflow.md | 35 +++- ...investigation-progress-and-presentation.md | 26 +++ docs/mcp-analysis-tools.md | 3 +- docs/water-pollution-response-workflow.md | 53 +++++ .../burst-event-handling/SKILL.md | 28 ++- .../work-order-skills/scada-anomaly/SKILL.md | 14 +- .../water-pollution-event/SKILL.md | 62 ++++++ .../api/test_operational_task_dynamic_flow.py | 22 ++ tests/unit/test_burst_impact.py | 197 ++++++++++++++++++ tests/unit/test_contaminant_impact.py | 135 ++++++++++++ tests/unit/test_contaminant_simulation_mcp.py | 68 ++++++ tests/unit/test_contaminant_source_trace.py | 73 +++++++ tests/unit/test_investigation_graph.py | 72 +++++++ tests/unit/test_mcp_analysis.py | 1 + tests/unit/test_mcp_calculation_jobs.py | 41 ++++ tests/unit/test_mcp_extended.py | 2 +- tests/unit/test_mcp_presentation_preview.py | 29 +++ tests/unit/test_mcp_query.py | 24 +++ tests/unit/test_mcp_work_order_skills.py | 26 ++- tests/unit/test_operational_recovery.py | 19 ++ tests/unit/test_operational_tasks.py | 13 ++ tests/unit/test_pollution_event_workflow.py | 139 ++++++++++++ .../unit/test_scada_presentation_coverage.py | 19 ++ tests/unit/test_scada_replay_service.py | 54 +++++ 49 files changed, 1826 insertions(+), 93 deletions(-) create mode 100644 app/services/burst_impact.py create mode 100644 app/services/contaminant_impact.py create mode 100644 app/services/contaminant_source_trace.py create mode 100644 docs/water-pollution-response-workflow.md create mode 100644 resources/work-order-skills/water-pollution-event/SKILL.md create mode 100644 tests/unit/test_burst_impact.py create mode 100644 tests/unit/test_contaminant_impact.py create mode 100644 tests/unit/test_contaminant_simulation_mcp.py create mode 100644 tests/unit/test_contaminant_source_trace.py create mode 100644 tests/unit/test_pollution_event_workflow.py diff --git a/app/domain/schemas/operational_data.py b/app/domain/schemas/operational_data.py index 8dccf37..580c4dd 100644 --- a/app/domain/schemas/operational_data.py +++ b/app/domain/schemas/operational_data.py @@ -51,6 +51,7 @@ McpSnapshotSource = Literal[ 'cancel_task_run', 'run_simulation', 'run_contaminant_simulation', + 'run_contaminant_source_trace', 'analyze_valve_closure', 'analyze_flushing', 'analyze_pressure_regulation', diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py index 927e248..29550e8 100644 --- a/app/domain/schemas/operational_task.py +++ b/app/domain/schemas/operational_task.py @@ -252,6 +252,10 @@ class InvestigationDiagramNodeRef(_InvestigationDiagramNode): class InvestigationGraphNodeUpdate(_InvestigationDiagramNode): + label: NonEmptyStr | None = Field(default=None, description=( + "Required for a new node. Omit or use null to retain an existing node's label. " + "Use the same node_id to revise status or detail without adding another step." + )) status: InvestigationNodeAnnotation | None = Field(default=None, description=( "Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. " "No live node/task binding or per-step synchronization is required." @@ -752,7 +756,7 @@ class ScadaChartGap(StrictModel): class ScadaChartContext(StrictModel): role: Literal["current", "comparison"] - metric: Literal["pressure", "flow"] + metric: Literal["pressure", "flow", "quality"] unit: Annotated[str, Field(min_length=1, max_length=128)] window_start: WorkbenchDateTime window_end: WorkbenchDateTime @@ -1015,6 +1019,13 @@ class HumanEvidenceCommand(StrictModel): evidence: EvidenceInput +class WorkOrderNotificationReference(StrictModel): + source_event_id: NonEmptyStr + source_revision: PositiveInt + evidence_id: NonEmptyStr | None = None + status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"] + + class OperationalTaskNotification(StrictModel): notification_id: UUID task_id: UUID @@ -1022,7 +1033,7 @@ class OperationalTaskNotification(StrictModel): "task_created", "action_pending", "report_updated", "task_completed", "task_failed", "run_delivery_degraded", "access_approval_pending", "action_approval_pending", "human_input_required", - "external_coordination_failed" + "external_coordination_failed", "work_order_feedback" ] title: NonEmptyStr description: NonEmptyStr @@ -1031,6 +1042,8 @@ class OperationalTaskNotification(StrictModel): subject_type: Literal["task", "run", "access_request", "action", "question"] = "task" subject_id: UUID | None = None report_reference: InvestigationReportReference | None = None + work_order_action: HumanApprovalAction | None = None + work_order_reference: WorkOrderNotificationReference | None = None resolved_at: WorkbenchDateTime | None = None resolution_reason: NonEmptyStr | None = None status: Literal["unread", "read"] diff --git a/app/mcp/analysis_models.py b/app/mcp/analysis_models.py index 2db6a4f..79a865c 100644 --- a/app/mcp/analysis_models.py +++ b/app/mcp/analysis_models.py @@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput): return self +class ContaminantSourceTraceInput(CalculationInput): + candidate_node_ids: list[Id] = Field(min_length=1, max_length=20, + description="根据事件位置、附近资产或水质设备映射取得的候选模型节点;Server核验节点存在后稳定返回一个候选。当前为临时定位接口,尚未执行污染反演") + start: datetime = Field(description="带时区的事件核查窗口开始") + end: datetime = Field(description="带时区的事件核查窗口结束") + + @model_validator(mode="after") + def valid_window(self): + if self.start.tzinfo is None or self.end.tzinfo is None: + raise ValueError("timestamps require explicit timezone") + if not timedelta(0) < self.end - self.start <= timedelta(days=1): + raise ValueError("window must be positive and at most 24 hours") + if len(set(self.candidate_node_ids)) != len(self.candidate_node_ids): + raise ValueError("candidate_node_ids must be distinct") + return self + + class ValveIsolationInput(CalculationInput): accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID") accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link") @@ -143,6 +160,10 @@ class ContaminantSimulationInput(ScenarioInput): source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释") concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L") pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源") + include_impact_coverage: bool = Field(default=False, strict=True, + description="估计处置前的污染传播范围;在筛选展示节点前汇总全网节点整个模拟时段的浓度,保存影响节点、首个超过阈值的报告时刻、峰值及地图覆盖") + impact_threshold_mg_l: float = Field(default=0, ge=0, allow_inf_nan=False, + description="模型影响范围的浓度阈值,mg/L,按严格大于判定;默认0表示模型中出现正浓度,仅为情景展示条件,不是饮用水安全限值。沿用模型初始水质与反应条件") class ValveClosureInput(ScenarioInput): diff --git a/app/mcp/analysis_service.py b/app/mcp/analysis_service.py index 4fed2d5..c8d6ce3 100644 --- a/app/mcp/analysis_service.py +++ b/app/mcp/analysis_service.py @@ -114,3 +114,21 @@ async def calculate_replay_burst_location( final_candidates_csv_path=None, **parameters, ) + + +async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]: + """An independent current-model scenario, with no live SCADA substitution.""" + return await bounded_calculation( + calculate_scenario, "run_burst_simulation", + args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), + network=network, username=username, model_only=True, + ) + + +async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]: + """Estimate propagation in a current-model scenario without live SCADA substitution.""" + return await bounded_calculation( + calculate_scenario, "run_contaminant_simulation", + args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), + network=network, username=username, model_only=True, + ) diff --git a/app/mcp/calculation_jobs.py b/app/mcp/calculation_jobs.py index aeaf0ea..22f3fd6 100644 --- a/app/mcp/calculation_jobs.py +++ b/app/mcp/calculation_jobs.py @@ -166,6 +166,19 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult: raise HTTPException(422, "offset requires a collection data_path") else: result.data = value + if not args.data_path and binding.task_id: + from app.mcp.presentation_preview import saved_burst_result_preview, saved_presentation_preview + + presentation = saved_presentation_preview(result) + if presentation is not None and isinstance(result.data, dict): + result.data = {**result.data, "presentation": presentation} + if len(result.model_dump_json().encode()) > 11000: + preview = saved_burst_result_preview(result, str(binding.task_id)) + if preview is not None: + result.data = preview + result.truncated = True + result.data_gaps = [*result.data_gaps, + "Inline burst impact summary only; full node comparisons and geometry are saved in task evidence. Read the evidence resource or select a narrower data_path."] if len(result.model_dump_json().encode()) > 11000: raise HTTPException(413, "Select a narrower data_path or smaller result page") return result diff --git a/app/mcp/catalog.py b/app/mcp/catalog.py index 918bb79..752d88a 100644 --- a/app/mcp/catalog.py +++ b/app/mcp/catalog.py @@ -6,7 +6,7 @@ from mcp import types from app.mcp import models as m from app.mcp import calculation_models as jobs from app.mcp import extended_models as e -from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput +from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ContaminantSourceTraceInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput @@ -62,8 +62,9 @@ class QueryTool: _specs = [ + ("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True), ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True), - ("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True), + ("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True), ("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True), ("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True), ("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True), @@ -258,7 +259,7 @@ _specs += [ ("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"), ("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"), ("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True), - ("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;返回给定情景的模拟结果,正式方案通过方案工具保存", e.BurstSimulationInput, "simulation.run", "analysis", False, True), + ("run_burst_simulation", "在隔离模型中模拟管道破口;include_impact_coverage=true时按起算同刻已有在线模拟计算全网节点压力下降及1/3/5米水头分档覆盖,保存为展示证据;回放仅用当前模型情景,不替代冻结观测", e.BurstSimulationInput, "simulation.run", "analysis", False, True), ("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True), ("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True), ("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True), diff --git a/app/mcp/extended_models.py b/app/mcp/extended_models.py index f088ede..d102ba3 100644 --- a/app/mcp/extended_models.py +++ b/app/mcp/extended_models.py @@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput): class BurstSimulationInput(ScenarioInput): + include_impact_coverage: bool = Field(default=False, strict=True, description="比较起算时刻全网节点与已有在线模拟压力,按下降1≤Δ<3、3≤Δ<5、Δ≥5米水头生成事件影响覆盖;不重算或覆盖在线基线") burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20) orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积,单位cm²;保留面积来源及估算依据") diff --git a/app/mcp/presentation_preview.py b/app/mcp/presentation_preview.py index 388ce0b..b593a1e 100644 --- a/app/mcp/presentation_preview.py +++ b/app/mcp/presentation_preview.py @@ -5,8 +5,34 @@ from uuid import NAMESPACE_URL, uuid5 from app.mcp.models import QueryResult +def saved_burst_result_preview(result: QueryResult, task_id: str) -> dict | None: + """Keep durable full-network results usable without copying node arrays inline.""" + if result.source != "run_burst_simulation" or not result.evidence_id: + return None + data = result.data if isinstance(result.data, dict) else {} + analysis = data.get("result", {}) + impact = analysis.get("impact") if isinstance(analysis, dict) else None + if not isinstance(impact, dict): + return None + summary = {key: value for key, value in impact.items() + if key not in {"comparisons", "grades", "unavailable_node_ids"}} + summary["unavailable_node_count"] = len(impact.get("unavailable_node_ids", [])) + summary["grades"] = [ + {**{key: value for key, value in grade.items() if key != "node_ids"}, + "node_count": len(grade.get("node_ids", []))} for grade in impact.get("grades", []) + ] + preview = { + "resource_uri": f"tjwater://tasks/{task_id}/evidence/{result.evidence_id}", + "parameters": data.get("parameters", {}), "result": {"impact": summary}, + } + presentation = saved_presentation_preview(result) + if presentation is not None: + preview["presentation"] = presentation + return preview + + def saved_presentation_preview(result: QueryResult) -> dict | None: - if result.source != "generate_node_coverage" or not result.evidence_id: + if result.source not in {"generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation"} or not result.evidence_id: return None analysis = result.data.get("result") if isinstance(result.data, dict) else None if not isinstance(analysis, dict): @@ -15,11 +41,18 @@ def saved_presentation_preview(result: QueryResult) -> dict | None: if not isinstance(payload, dict) or not payload.get("features"): return None coverage = analysis.get("coverage", {}) + burst = result.source == "run_burst_simulation" + contaminant = result.source == "run_contaminant_simulation" + if burst and analysis.get("impact", {}).get("phase") != "after_burst_before_intervention": + return None + if contaminant and analysis.get("impact", {}).get("phase") != "after_contamination_before_intervention": + return None return { "command": { "action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id, "overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}", - "title": "节点空间覆盖示意", + "title": ("爆管后、处置前的预计影响范围(米水头)" if burst else + "水污染传播预计影响范围(处置前情景)" if contaminant else "节点空间覆盖示意"), }, "display_only": True, "coverage_status": coverage.get("status"), diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py index e5878a8..223a0d9 100644 --- a/app/mcp/query_service.py +++ b/app/mcp/query_service.py @@ -75,16 +75,26 @@ async def authorize_query( if replay_context: from app.services.scada_replay_queries import check_replay_window allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device", - "query_scada_readings", "get_network_schema", "run_burst_location", + "query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace", "get_element_properties", "list_elements", "query_nearby_elements", "get_network_options", "get_node_connections", "get_element_gis_properties", "query_network_extent", "list_regions", "get_region", "get_region_nodes", - "analyze_valve_isolation", "generate_node_coverage", + "analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "get_calculation_job", "get_calculation_result", "list_calculation_jobs"} if tool.name not in allowed: - raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings") + raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings") + if tool.name == "run_burst_simulation": + if not args.include_impact_coverage: + raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线") + check_replay_window(replay_context, args.start_time, args.start_time) + if tool.name == "run_contaminant_simulation": + if not args.include_impact_coverage: + raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围") + check_replay_window(replay_context, args.start_time, args.start_time) if tool.name == "query_scada_readings": check_replay_window(replay_context, args.start, args.end) + if tool.name == "run_contaminant_source_trace": + check_replay_window(replay_context, args.start, args.end) if tool.name == "run_burst_location": if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None: raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入") @@ -107,6 +117,8 @@ async def authorize_query( required = {tool.permission} if tool.name in SCENARIO_TOOLS: required.add("scada.view") + if tool.name == "run_burst_simulation" and args.include_impact_coverage: + required.add("simulation.view") if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}: required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view") if not required.issubset(permissions) or not required.issubset(binding.permission_cap): @@ -182,7 +194,15 @@ async def execute_query( source_ref = "mcp:" + str(uuid4()) evidence_id = None replay_context = getattr(binding, "replay_context", None) - if name == "query_scada_readings" and args.device_ids is None: + if name == "run_contaminant_source_trace": + from app.services.contaminant_source_trace import trace_contaminant_source + + data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), + "result": await trace_contaminant_source(conn, args)} + origin = "synthetic" + if data["result"]["located_node"] is None: + gaps.append(data["result"]["notice"]) + elif name == "query_scada_readings" and args.device_ids is None: from app.mcp.scada_readings import read_complete_scada data, units, gaps, origin = await read_complete_scada(scope, binding, args) elif name == "query_scada_readings" and replay_context: @@ -191,6 +211,28 @@ async def execute_query( args.device_ids, args.start, args.end, args.limit + 1, args.offset) data, page, truncated = page_result(rows, args) origin = "synthetic" + elif name == "run_burst_simulation" and replay_context: + from app.mcp.analysis_service import calculate_replay_burst_simulation + + await conn.commit() + async with scope.simulation_routing(): + result = await calculate_replay_burst_simulation( + args, network=scope.project.project_code, username=scope.user.username, + ) + data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result} + origin = "synthetic" + gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"] + elif name == "run_contaminant_simulation" and replay_context: + from app.mcp.analysis_service import calculate_replay_contaminant_simulation + + await conn.commit() + async with scope.simulation_routing(): + result = await calculate_replay_contaminant_simulation( + args, network=scope.project.project_code, username=scope.user.username, + ) + data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result} + origin = "synthetic" + gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"] elif name == "run_burst_location" and replay_context: from app.mcp.analysis_service import calculate_replay_burst_location from app.services.scada_replay_queries import replay_burst_location_inputs @@ -241,7 +283,7 @@ async def execute_query( } gaps = [{ "run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.", - "run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.", + "run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.", "analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.", "analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.", "analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.", @@ -252,7 +294,7 @@ async def execute_query( "analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.", "generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.", "calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.", - "run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.", + "run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.", }[name]] if name == "calculate_demand_allocation": truncated = data["result"]["has_more"] or args.offset > 0 @@ -491,6 +533,17 @@ async def execute_query( data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] truncated = True gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.") + if skill.get("name") == "water-pollution-event": + data["source_trace_capability"] = { + "available": "run_contaminant_source_trace" in binding.tool_cap, + "tool": "run_contaminant_source_trace", + "algorithm_status": "provisional", + "required_inputs": ["candidate_node_ids", "start", "end"], + "next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation", + "analyze_valve_isolation", "generate_node_coverage", "report"], + "work_order_wait_blocks_analysis": False, + "simulation_parameters": {"include_impact_coverage": True}, + } if (skill.get("name") == "scada-anomaly" and replay_context and "run_burst_location" in binding.tool_cap): from app.services.burst_location_demo import location_demo_context diff --git a/app/mcp/scenario_service.py b/app/mcp/scenario_service.py index d55c000..0ab69d7 100644 --- a/app/mcp/scenario_service.py +++ b/app/mcp/scenario_service.py @@ -10,7 +10,8 @@ SCENARIO_TOOLS = frozenset({ }) -def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]: +def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str, + model_only: bool = False) -> dict[str, Any]: from app.services import simulation_scenarios as scenarios parameters = dict(parameters) @@ -18,6 +19,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u links = parameters.pop("output_link_ids") start = parameters.pop("start_time") duration = parameters.pop("duration") + include_impact = parameters.pop("include_impact_coverage", False) + impact_threshold = parameters.pop("impact_threshold_mg_l", 0) common = dict(name=network, modify_pattern_start_time=start.isoformat(), modify_total_duration=duration, persist_results=False, scheme_name=None) if name == "run_simulation": @@ -27,11 +30,27 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u network, start, duration, output_node_ids=nodes, output_link_ids=links, ) elif name == "run_burst_simulation": + boundary = {} + if model_only: + from app.infra.db.postgresql.scada import ScadaElementMappings + # Replay scenarios use current model assumptions, never mutable live + # SCADA readings in place of the investigation's frozen observations. + boundary["scada_mappings"] = ScadaElementMappings( + reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, + pressure={}, demand={}, quality={}, + ) result = scenarios.burst_analysis(**common, username=username, - burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"]) + burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary) elif name == "run_contaminant_simulation": + boundary = {} + if model_only: + from app.infra.db.postgresql.scada import ScadaElementMappings + boundary["scada_mappings"] = ScadaElementMappings( + reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, + pressure={}, demand={}, quality={}, + ) result = scenarios.contaminant_simulation(**common, username=username, - source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern")) + source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary) elif name == "analyze_valve_closure": result = scenarios.valve_close_analysis(**common, modify_valve_opening={valve: 0.0 for valve in parameters["valves"]}) @@ -62,7 +81,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u raise ValueError("Scenario did not return calculation results") selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes] selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links] - return { + output = { "input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation", "persisted_baseline": name == "run_simulation", "persisted_scheme": False, "start_time": start.isoformat(), "duration_seconds": duration, @@ -72,3 +91,15 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u "missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}), "missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})}, } + if name == "run_burst_simulation" and include_impact: + from app.services.burst_impact import estimate_burst_impact + impact = estimate_burst_impact(network, start, result, model_only=model_only) + impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})} + output.update(impact) + if name == "run_contaminant_simulation" and include_impact: + from app.services.contaminant_impact import estimate_contaminant_impact + impact = estimate_contaminant_impact(network, start, duration, result, + threshold_mg_l=impact_threshold, model_only=model_only) + impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})} + output.update(impact) + return output diff --git a/app/mcp/work_order_skills.py b/app/mcp/work_order_skills.py index 0007a52..d4816ea 100644 --- a/app/mcp/work_order_skills.py +++ b/app/mcp/work_order_skills.py @@ -10,6 +10,7 @@ import yaml SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills" WORKFLOW_SKILLS = { "scada_anomaly": "scada-anomaly", + "water_pollution": "water-pollution-event", "burst_report": "burst-event-handling", "water_quality_complaint": "water-quality-complaint", "general": "appointment-follow-up", diff --git a/app/services/burst_impact.py b/app/services/burst_impact.py new file mode 100644 index 0000000..89f1de8 --- /dev/null +++ b/app/services/burst_impact.py @@ -0,0 +1,147 @@ +"""Estimate pre-intervention burst pressure loss against a read-only online snapshot.""" + +from datetime import datetime +from math import isfinite +from typing import Any + +from app.infra.db.timescaledb.internal_queries import InternalQueries +from app.native.wndb.model.options import get_option, get_option_v3 +from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage + +GRADES = ( + ("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3), + ("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5), + ("drop_5_plus", "压力下降 Δ≥5 m", 5, None), +) + + +def _head_factor(unit: Any) -> float | None: + # Standard water head; unsupported or absent units must not become zero loss. + return {"m": 1.0, "meters": 1.0, "metres": 1.0, + "kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65, + "bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65, + "ft": 0.3048}.get(str(unit).strip().lower()) + + +def _number(value: Any) -> float | None: + if isinstance(value, bool): + return None + try: + result = float(value) + except (ValueError, TypeError, OverflowError): + return None + return result if isfinite(result) else None + + +def _instant(value: Any) -> datetime | None: + try: + result = value if isinstance(value, datetime) else datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + return result if result.tzinfo is not None else None + + +def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *, + model_only: bool = False) -> dict[str, Any]: + """Compare every native node at t=0, before the adapter filters requested outputs. + + The online snapshot is a model reference read at calculation time, not frozen + SCADA or a reconstructed historical model. All numerical inputs are retained + in task evidence so playback does not repeat this mutable reference query. + """ + impact: dict[str, Any] = { + "status": "unavailable", "phase": "after_burst_before_intervention", + "comparison_time": start.isoformat(), "pressure_drop_unit": "m", + "baseline_source": "existing_online_simulation", + "baseline_read_at": datetime.now().astimezone().isoformat(), + "baseline_units_source": "current_model_settings", + "model_temporality": "current_model", "baseline_frozen": False, + "boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada", + "boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。", + "grades": [{"group_id": key, "title": title, "min_inclusive_m": low, + "max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES], + } + output = {"impact": impact} + # Never silently shift t=0 to an unknown/delayed report sample or another day. + times = simulation.get("times", {}) + sample_times = times.get("sample_times") + if ((sample_times and _instant(sample_times[0]) != start) + or (not sample_times and times.get("report_start") != 0)): + impact["reason"] = "simulation_has_no_verified_start_time_sample" + return output + options = get_option(network) + options_v3 = get_option_v3(network) + baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE") + impact["baseline_units_source"] = ( + "current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings" + ) + impact["model_conditions"] = { + "baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"), + "burst_demand_model": "PDA", "burst_required_pressure_model_units": 10, + "note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。", + } + scenario_unit = simulation.get("units", {}).get("pressure") + baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit) + impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit} + if baseline_factor is None or scenario_factor is None: + impact["reason"] = "unsupported_or_missing_pressure_units" + return output + nodes = simulation.get("node_results", []) + ids = [str(row["node"]) for row in nodes] + if not ids or len(ids) != len(set(ids)): + impact["reason"] = "missing_or_duplicate_simulation_nodes" + return output + baseline = InternalQueries.query_realtime_simulation_by_ids_timerange( + ids, start, start, "node", "pressure", db_name=network, + ) + comparisons, missing = [], [] + for row in nodes: + node_id = str(row["node"]) + samples = row.get("result") or [] + burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None + exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start] + normal = _number(exact[0].get("value")) if len(exact) == 1 else None + if burst is None or normal is None: + missing.append(node_id) + continue + normal_m, burst_m = normal * baseline_factor, burst * scenario_factor + drop = normal_m - burst_m + if not all(isfinite(value) for value in (normal_m, burst_m, drop)): + missing.append(node_id) + continue + comparisons.append({"node_id": node_id, "baseline_m": normal_m, + "burst_m": burst_m, "pressure_drop_m": drop}) + for group in impact["grades"]: + if drop >= group["min_inclusive_m"] and ( + group["max_exclusive_m"] is None or drop < group["max_exclusive_m"] + ): + group["node_ids"].append(node_id) + break + impact.update(status="partial" if missing else "ready", node_count=len(ids), + compared_node_count=len(comparisons), unavailable_node_ids=missing, + comparisons=comparisons) + affected = sum(len(group["node_ids"]) for group in impact["grades"]) + impact["affected_node_count"] = affected + impact["below_threshold_node_count"] = len(comparisons) - affected + if not comparisons: + impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot") + return output + if affected > MAX_COVERAGE_NODES: + impact["coverage_status"] = "unavailable" + impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit" + return output + groups = [{key: group[key] for key in ("group_id", "title", "node_ids")} + for group in impact["grades"]] + geometry = generate_node_coverage(network, groups) + impact["coverage_status"] = geometry["coverage"]["status"] + geometry["coverage"]["boundary"] = impact["boundary"] + for feature in geometry["map_payload"]["features"]: + feature["properties"].update( + impact_phase=impact["phase"], pressure_drop_unit="m", + pressure_drop_grade=feature["properties"]["coverage_group"], + comparison_time=start.isoformat(), model_estimate=True, + ) + if geometry["coverage"]["status"] == "partial": + impact["status"] = "partial" + output.update(geometry) + return output diff --git a/app/services/contaminant_impact.py b/app/services/contaminant_impact.py new file mode 100644 index 0000000..476a763 --- /dev/null +++ b/app/services/contaminant_impact.py @@ -0,0 +1,112 @@ +"""Estimate a scenario's time-cumulative concentration coverage before output filtering.""" +from datetime import datetime, timedelta +from math import isfinite +from typing import Any + +from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage + + +def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]: + explicit = metadata.get("sample_times") + if explicit is not None: + times = [datetime.fromisoformat(value) for value in explicit] + else: + offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods")) + if (any(type(value) is not int for value in (offset, step, count)) + or offset < 0 or step <= 0 or not 0 < count <= 86401): + raise ValueError("missing or invalid report grid") + times = [start + timedelta(seconds=offset + index * step) for index in range(count)] + if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times) + or any(not start <= at <= start + timedelta(seconds=duration) for at in times) + or any(left >= right for left, right in zip(times, times[1:]))): + raise ValueError("invalid simulation sample times") + return times + + +def estimate_contaminant_impact(network: str, start: datetime, duration: int, + simulation: dict[str, Any], *, threshold_mg_l: float = 0, + model_only: bool = False) -> dict[str, Any]: + """Use every native node and report sample; never infer propagation from requested nodes.""" + impact: dict[str, Any] = { + "status": "unavailable", "phase": "after_contamination_before_intervention", + "window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(), + "threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L", + "threshold_purpose": "scenario_display_not_drinking_water_standard", + "model_temporality": "current_model", "quantity": "total_model_concentration", + "boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada", + "boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;" + "首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。", + } + output = {"impact": impact} + unit = simulation.get("units", {}).get("quality") + factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get( + str(unit).strip().lower().replace("μ", "u").replace("µ", "u")) + impact["input_concentration_unit"] = unit + if factor is None: + impact["reason"] = "unsupported_or_missing_concentration_unit" + return output + try: + times = _sample_times(start, duration, simulation.get("times", {})) + except (TypeError, ValueError, OverflowError): + impact["reason"] = "simulation_has_no_verified_sample_times" + return output + rows = simulation.get("node_results", []) + ids = [str(row["node"]) for row in rows] + if not ids or len(ids) != len(set(ids)): + impact["reason"] = "missing_or_duplicate_simulation_nodes" + return output + affected, unavailable, incomplete = [], [], [] + for row in rows: + node = str(row["node"]) + samples = row.get("result") or [] + if len(samples) > len(times): + impact["reason"] = "simulation_sample_count_mismatch" + return output + valid = [] + for at, sample in zip(times, samples): + value = sample.get("quality") if isinstance(sample, dict) else None + if isinstance(value, bool) or value is None: + continue + try: + concentration = float(value) * factor + except (TypeError, ValueError, OverflowError): + continue + if isfinite(concentration) and concentration >= 0: + valid.append((at, concentration)) + if not valid: + unavailable.append(node) + continue + if len(valid) != len(times): + incomplete.append(node) + exceedances = [(at, value) for at, value in valid if value > threshold_mg_l] + if exceedances: + peak_at, peak = max(valid, key=lambda item: item[1]) + affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(), + "peak_mg_l": peak, "peak_at": peak_at.isoformat(), + "valid_sample_count": len(valid), "complete": len(valid) == len(times)}) + impact.update(status="partial" if unavailable or incomplete else "ready", + node_count=len(ids), report_sample_count=len(times), + reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(), + unavailable_node_ids=unavailable, incomplete_node_ids=incomplete, + affected_node_count=len(affected), nodes=affected, + affected_node_ids=[item["node_id"] for item in affected]) + if len(unavailable) == len(ids): + impact.update(status="unavailable", reason="no_valid_concentration_samples") + return output + if len(affected) > MAX_COVERAGE_NODES: + impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit") + return output + geometry = generate_node_coverage(network, [{ + "group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围", + "node_ids": impact["affected_node_ids"], + }]) + impact["coverage_status"] = geometry["coverage"]["status"] + geometry["coverage"]["boundary"] = impact["boundary"] + for feature in geometry["map_payload"]["features"]: + feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L", + threshold_mg_l=threshold_mg_l, model_estimate=True, + window_start=impact["window_start"], window_end=impact["window_end"]) + if geometry["coverage"]["status"] == "partial": + impact["status"] = "partial" + output.update(geometry) + return output diff --git a/app/services/contaminant_source_trace.py b/app/services/contaminant_source_trace.py new file mode 100644 index 0000000..6a9f333 --- /dev/null +++ b/app/services/contaminant_source_trace.py @@ -0,0 +1,34 @@ +"""Temporary deterministic candidate selection behind the source-tracing contract. + +Replace this selection with source inversion when the algorithm is available. +Only project-owned nodes supplied from the investigation are eligible. No +observation fit, confidence or contamination extent is inferred here. +""" +from psycopg import AsyncConnection + +from app.infra.db.postgresql.read_queries import read_rows +from app.mcp.analysis_models import ContaminantSourceTraceInput + + +async def trace_contaminant_source(connection: AsyncConnection, args: ContaminantSourceTraceInput) -> dict: + rows = await read_rows(connection, """ + SELECT id, node_type::text AS kind FROM network.nodes + WHERE id = ANY(%s) ORDER BY id + """, (args.candidate_node_ids,)) + # Sort in Python as well so selection does not depend on DB collation. + nodes = sorted({str(row["id"]) for row in rows}) + selected = nodes[0] if nodes else None + return { + "located_node": selected, + "top_candidates": [{"node_id": selected}] if selected else [], + "exit_condition": "candidate_selected" if selected else "no_candidate", + "method": "deterministic_candidate_selection", + "algorithm_status": "provisional", + "selection_basis": "lexicographically_first_existing_candidate_node", + "validated_node_ids": nodes, + "missing_node_ids": sorted(set(args.candidate_node_ids) - set(nodes)), + "notice": ("已返回待现场核验的候选节点;先展示节点、保存阶段结论并提出现场核查/采样处置工单," + "工单审核派发与污染传播模拟、节点隔离及影响分析并行,结果补入同一调查。" + "正式污染反演算法尚未接入,本次选择不代表污染源确诊或污染传播范围。" if selected else + "提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"), + } diff --git a/app/services/investigation_graph.py b/app/services/investigation_graph.py index 8f02f3b..cadc506 100644 --- a/app/services/investigation_graph.py +++ b/app/services/investigation_graph.py @@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga def update_investigation_graph( previous: InvestigationDiagram | None, update: InvestigationGraphUpdate, ) -> InvestigationDiagram: - """Upsert nodes and append explicit edges; never delete a path or erase a failed attempt. + """Patch saved nodes or add labelled nodes and explicit edges; never erase a path. - A retry uses a new node id. Relationships describe activity for readers and - impose no ordering or completion requirements on the Agent. + Unspecified fields retain their saved values. A failed attempt remains + failed, and a retry uses a new node id. Relationships impose no execution + ordering or completion requirements on the Agent. """ if previous is not None: previous = adopt_legacy_investigation_graph(previous) @@ -47,15 +48,19 @@ def update_investigation_graph( if existing is None: require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT", "This id belongs to the original Mermaid; use a new node id", status=409) + require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED", + "A new graph node requires a label; use an existing node_id for a partial update", status=422) nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True)) continue - require(not in_source(node.node_id) or existing.label == node.label, + require(not in_source(node.node_id) or node.label is None or existing.label == node.label, "GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409) require(existing.status != "failed" or ( ("status" not in node.model_fields_set or node.status == "failed") - and existing.label == node.label + and (node.label is None or existing.label == node.label) ), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409) changes = node.model_dump(exclude_unset=True) + if node.label is None: + changes.pop("label", None) if existing.status == "failed" and existing.detail and not changes.get("detail"): changes.pop("detail", None) for field in ("evidence_refs", "action_refs", "map_targets"): diff --git a/app/services/operational_recovery.py b/app/services/operational_recovery.py index 6d4295d..759e801 100644 --- a/app/services/operational_recovery.py +++ b/app/services/operational_recovery.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from app.services.operational_tasks import OperationalTaskService -RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"} +RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly", "water_pollution"} def track_work_order(task: dict, action: dict) -> bool: @@ -60,6 +60,8 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non # edits. A revised field receipt can explicitly correct the verification start. if feedback["status"] == "completed": order["returned_at"] = feedback["occurred_at"] + if task.get("workflow") == "water_pollution": + order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id") else: order.pop("returned_at", None) if order.get("superseded_by"): @@ -67,6 +69,13 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")] if orders and all(item["status"] == "completed" for item in orders): returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat) + if task.get("workflow") == "water_pollution": + # A completion receipt resumes review, never the pressure-based + # automatic recovery calculation. Source revisions may carry new + # sampling or retest facts even when the field time is unchanged. + context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at) + context.pop("verification_evidence_id", None) + return True previous = context.get("returned_at") if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at) or context.get("phase") == "waiting_work_order"): diff --git a/app/services/operational_scada_coverage.py b/app/services/operational_scada_coverage.py index d3d99c4..672bf0f 100644 --- a/app/services/operational_scada_coverage.py +++ b/app/services/operational_scada_coverage.py @@ -7,7 +7,7 @@ from app.domain.domain_errors import require def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None: replay = task.get("source", {}).get("replay_context") - if task.get("workflow") != "scada_anomaly" and not replay: + if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay: return target = datetime.fromisoformat( replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"] diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index cb5685e..2526665 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import ( OperationalTaskCreate, OperationalTaskDetail, OperationalTaskNotification, + WorkOrderNotificationReference, OperationalTaskPermissions, OperationalTaskSchedule, OperationalTaskScheduleCreate, @@ -874,7 +875,7 @@ class OperationalTaskService: diagram = update_investigation_graph(saved_diagram, command.progress.graph_update) elif saved_diagram is not None: require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED", - "Use graph_update to extend the saved investigation graph", status=422) + "Use graph_update to patch saved nodes or extend the investigation graph", status=422) if diagram is None: diagram = adopt_legacy_investigation_graph(saved_diagram) if diagram: @@ -1258,6 +1259,7 @@ class OperationalTaskService: task_id, "action_approval_pending", task["title"], command.reason, now, required_permission=approval_permission, subject_type="action", subject_id=action.action_id, + work_order_action=action if action.kind == "create_work_order" else None, ) await self._save_request( scope, command.request_id, body, {"action_id": str(action.action_id)} @@ -1743,6 +1745,29 @@ class OperationalTaskService: task_id, "task_failed", task["title"], command.detail, now, ) + if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}: + # The feedback, its notification and outbox event commit together. + # Duplicate request/source versions return above, before publishing. + work_order_action = next((item for item in await self.repo.list_actions(str(task_id)) + if item["kind"] == "create_work_order" and item.get("receipt") + and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None) + is_work_order_feedback = (task["task_type"] == "external_work_order" + or work_order_action is not None + or (task["task_type"] == "event_investigation" and matching_source)) + if is_work_order_feedback: + await self._notify( + task_id, "work_order_feedback", "收到工单反馈", + command.evidence.summary if command.evidence else command.detail, now, + subject_type="action" if work_order_action else "task", + subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id, + work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None, + work_order_reference=WorkOrderNotificationReference( + source_event_id=command.source_event_id, + source_revision=command.source_revision, + evidence_id=command.evidence.evidence_id if command.evidence else None, + status=command.status, + ), + ) response = { "source_event_id": command.source_event_id, "source_revision": command.source_revision, @@ -2161,12 +2186,24 @@ class OperationalTaskService: lifecycle = self._investigation_status(actions, task) recovery = task.get("handling_context") or {} if lifecycle == "completed" and recovery: - require(recovery.get("verification_evidence_id") in report.report.evidence_refs, - "RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409) + if task.get("workflow") == "water_pollution": + orders = [item for item in recovery.get("work_orders", {}).values() + if not item.get("superseded_by")] + feedback_refs = [item.get("feedback_evidence_id") for item in orders] + require(recovery.get("phase") in {"field_review", "completed"} and bool(orders) + and all(item["status"] == "completed" for item in orders) + and all(ref and ref in report.report.evidence_refs for ref in feedback_refs), + "POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409) + verification_id = feedback_refs[-1] + else: + verification_id = recovery.get("verification_evidence_id") + require(verification_id in report.report.evidence_refs, + "RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409) require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED", "恢复复核通过后才能结束事件", status=409) - recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat()) - await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"]) + recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution" + else "恢复供水复核成功"), completed_at=now.isoformat()) + await self._resolve_investigation_alerts(str(task_id), verification_id) elif report.report.outcome == "insufficient_data": terminal = all(node.status in {"completed", "skipped", "cancelled"} for node in plan.plan.nodes) @@ -2519,6 +2556,8 @@ class OperationalTaskService: *, required_permission: str | None = None, subject_type: str = "task", subject_id: UUID | None = None, report_reference: InvestigationReportReference | None = None, + work_order_reference: WorkOrderNotificationReference | None = None, + work_order_action: HumanApprovalAction | None = None, ) -> None: notification = OperationalTaskNotification( notification_id=uuid4(), task_id=task_id, kind=kind, @@ -2527,6 +2566,8 @@ class OperationalTaskService: lifecycle_status="active", subject_type=subject_type, subject_id=subject_id or task_id, report_reference=report_reference, + work_order_reference=work_order_reference, + work_order_action=work_order_action, ) await self.repo.insert_notification(_json(notification)) await self._append_event( diff --git a/app/services/scada_normal_profile.py b/app/services/scada_normal_profile.py index 91fce0d..518e3ac 100644 --- a/app/services/scada_normal_profile.py +++ b/app/services/scada_normal_profile.py @@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None: return None +def pollution_signal(document: dict, event: dict) -> bool: + """Water-quality measurements have their own incident investigation workflow.""" + if event.get("category") == "data_quality": + return False + devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])} + ids = event.get("device_ids") or next((item.get("device_ids", []) + for item in (document.get("config") or {}).get("rules", []) + if item["rule_id"] == event.get("rule_id")), []) + return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids) + + def diagnostic_signal(document: dict, event: dict) -> bool: """Never revive a persisted fixed-percentage event as new diagnosis evidence.""" - if event.get("category") == "data_quality": + if event.get("category") == "data_quality" or pollution_signal(document, event): return False if not weighted_diagnosis(document): return True diff --git a/app/services/scada_presentation_coverage.py b/app/services/scada_presentation_coverage.py index ea1d817..dd9c27f 100644 --- a/app/services/scada_presentation_coverage.py +++ b/app/services/scada_presentation_coverage.py @@ -10,14 +10,14 @@ from app.domain.domain_errors import require DISPLAY_TIMEZONE = timezone(timedelta(hours=8)) -METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow"} +METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow", "quality": "quality", "water_quality": "quality"} def applies(task: dict) -> bool: - if task.get("workflow") != "scada_anomaly" and not task.get("source", {}).get("replay_context"): + if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not task.get("source", {}).get("replay_context"): return False # Recovery has its own Server-verified post-repair evidence and finalization gate. - return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "completed"} + return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "field_review", "completed"} def cutoff(task: dict) -> datetime: diff --git a/app/services/scada_replay.py b/app/services/scada_replay.py index 4567b29..6a26aa7 100644 --- a/app/services/scada_replay.py +++ b/app/services/scada_replay.py @@ -429,6 +429,7 @@ class ScadaReplayService: await self.repo.save(document) async def _create_investigations(self, document, changes, batch, *, create_tasks=True): + from app.services.scada_normal_profile import pollution_signal from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository from app.services.operational_tasks import OperationalTaskService @@ -443,9 +444,10 @@ class ScadaReplayService: self.context, permissions_for_context(self.context), execution_scope=settings.operational_task_execution_scope) for event in changes: - # Period diagnosis owns measurement notifications during manual steps. - # Retain rule events as evidence; quality alerts remain independent. - if (not create_tasks and event.get("category") != "data_quality" + pollution = pollution_signal(document, event) + # Pressure/flow rules share period diagnosis during manual steps. + # Water-quality incidents and telemetry-quality alerts stay independent. + if (not create_tasks and not pollution and event.get("category") != "data_quality" and (event["change"] == "raised" or event.get("notification_suppressed"))): if event["change"] == "raised": event["batch_sequence"] = batch["sequence"] @@ -457,13 +459,33 @@ class ScadaReplayService: if event["change"] == "raised": event["batch_sequence"] = batch["sequence"] document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"] + if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution): + external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}" + command = OperationalTaskCreate( + request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation", + workflow="water_pollution" if pollution else "scada_anomaly", + title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}", + summary=event["detail"], source=TaskSource( + kind="external_system", external_id=external, external_revision=1, + event_kind="water_pollution" if pollution else "scada_anomaly", + replay_context={"replay_id": document["replay_id"], + "knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]}, + ), + trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail=( + "水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else + "已存 SCADA 历史的回放检测信号,原因尚待调查")), + ) + task = await tasks.create(command) + event["task_id"] = str(task.task_id) + document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"] await alerts.push(TaskAlertEvent( event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]), alert_id=event["event_id"], producer_id=producer, event_type=event["change"], - occurred_at=event["occurred_at"], condition_id=None, + occurred_at=event["occurred_at"], + condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else None, source={"kind": "scada_replay", "replay_id": document["replay_id"], "rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]}, - title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning", + title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning", )) if event["change"] != "raised": if event.get("task_id"): @@ -472,25 +494,6 @@ class ScadaReplayService: "batch_sequence": batch["sequence"], }, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery")) continue - if event.get("category") == "data_quality": - continue - if not create_tasks: - continue - external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}" - command = OperationalTaskCreate( - request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation", - workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}", - summary=event["detail"], source=TaskSource( - kind="external_system", external_id=external, external_revision=1, - event_kind="scada_anomaly", - replay_context={"replay_id": document["replay_id"], - "knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]}, - ), - trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"), - ) - task = await tasks.create(command) - event["task_id"] = str(task.task_id) - document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"] async def _create_monitoring_task(self, document, monitoring, batch): from app.services.scada_monitoring_incidents import attach_incident, update_incident diff --git a/app/services/simulation_scenarios.py b/app/services/simulation_scenarios.py index 846f15e..aa7da94 100644 --- a/app/services/simulation_scenarios.py +++ b/app/services/simulation_scenarios.py @@ -29,6 +29,7 @@ from app.native.wndb.model.sources import ( ) from app.native.wndb.model.times import get_time, set_time from app.domain.time import parse_clock_duration_seconds +from app.infra.db.postgresql.scada import ScadaElementMappings def _isolated_analysis(purpose: str): @@ -79,6 +80,7 @@ def burst_analysis( username: str | None = None, persist_results: bool = True, _temporary_project: str | None = None, + scada_mappings: ScadaElementMappings | None = None, ) -> dict | None: """ 爆管模拟 @@ -123,6 +125,7 @@ def burst_analysis( name=new_name, simulation_type="manually_temporary", modify_pattern_start_time=modify_pattern_start_time, + scada_mappings=scada_mappings, ) print( datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") @@ -203,6 +206,7 @@ def burst_analysis( modify_variable_pump_pattern=modify_variable_pump_pattern, modify_valve_opening=modify_valve_opening, scheme_type="burst_analysis", + scada_mappings=scada_mappings, scheme_name=scheme_name, result_db_name=name, scheme_username=username, @@ -469,6 +473,7 @@ def contaminant_simulation( username: str | None = None, _temporary_project: str | None = None, persist_results: bool = True, + scada_mappings: ScadaElementMappings | None = None, ) -> dict | str | None: """ 污染模拟 @@ -591,6 +596,7 @@ def contaminant_simulation( scheme_username=username, scheme_detail=scheme_detail, persist_results=persist_results, + scada_mappings=scada_mappings, ) return result if not persist_results else None diff --git a/contracts/manifest.json b/contracts/manifest.json index 7f9dc13..b8fbe90 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb" + "sha256": "aeff2c644006bf7eddd6cfe1c8aa0bad7f38c4632a456b5d3be1bb8201efc0db" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index c30012b..5e08730 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -3739,9 +3739,17 @@ "type": "array" }, "label": { - "minLength": 1, - "title": "Label", - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required for a new node. Omit or use null to retain an existing node's label. Use the same node_id to revise status or detail without adding another step.", + "title": "Label" }, "map_targets": { "items": { @@ -3777,8 +3785,7 @@ } }, "required": [ - "node_id", - "label" + "node_id" ], "title": "InvestigationGraphNodeUpdate", "type": "object" @@ -5160,7 +5167,8 @@ "access_approval_pending", "action_approval_pending", "human_input_required", - "external_coordination_failed" + "external_coordination_failed", + "work_order_feedback" ], "title": "Kind", "type": "string" @@ -5295,6 +5303,26 @@ "minimum": 1.0, "title": "Version", "type": "integer" + }, + "work_order_action": { + "anyOf": [ + { + "$ref": "#/components/schemas/HumanApprovalAction" + }, + { + "type": "null" + } + ] + }, + "work_order_reference": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkOrderNotificationReference" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -7431,7 +7459,8 @@ "metric": { "enum": [ "pressure", - "flow" + "flow", + "quality" ], "title": "Metric", "type": "string" @@ -9712,6 +9741,7 @@ "cancel_task_run", "run_simulation", "run_contaminant_simulation", + "run_contaminant_source_trace", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", @@ -11130,6 +11160,53 @@ "title": "WorkOrderDraftUpdateCommand", "type": "object" }, + "WorkOrderNotificationReference": { + "additionalProperties": false, + "properties": { + "evidence_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Evidence Id" + }, + "source_event_id": { + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "source_revision": { + "minimum": 1.0, + "title": "Source Revision", + "type": "integer" + }, + "status": { + "enum": [ + "scheduled", + "assigned", + "in_progress", + "awaiting_feedback", + "completed", + "cancelled", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_event_id", + "source_revision", + "status" + ], + "title": "WorkOrderNotificationReference", + "type": "object" + }, "WorkbenchAlertSource": { "additionalProperties": false, "properties": { diff --git a/contracts/view-command.schema.json b/contracts/view-command.schema.json index e1f8dc9..fddb08e 100644 --- a/contracts/view-command.schema.json +++ b/contracts/view-command.schema.json @@ -221,7 +221,8 @@ "type": "string", "enum": [ "pressure", - "flow" + "flow", + "quality" ] }, "unit": { diff --git a/docs/burst-response-workflow.md b/docs/burst-response-workflow.md index 541e842..fdb7ea0 100644 --- a/docs/burst-response-workflow.md +++ b/docs/burst-response-workflow.md @@ -8,10 +8,12 @@ flowchart TD order[爆管上报工单] --> context[读取原工单与当前任务] context --> asset[核对地点并关联管网资产] - asset --> isolation[分析隔离边界与阀门方案] - isolation --> impact[说明事件与隔离方案影响] - impact --> report[保存阶段报告与处置建议] - report --> approval[人工审批与现场执行] + asset --> interim[保存定位阶段报告] + interim --> approval[立即发起工单草稿、人工审核派发] + interim --> burst[爆管模拟与同刻在线压力差] + burst --> coverage[保存并展示爆管后、处置前影响覆盖] + coverage --> isolation[分析隔离边界与阀门方案] + isolation --> report[补充阶段报告与处置建议] approval --> feedback[接收实际处置与恢复反馈] feedback --> verify[复核影响与结案条件] verify --> close[Server 完成任务并解除告警] @@ -20,10 +22,11 @@ flowchart TD 1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。 2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。 -3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。 -4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。 -5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。 -6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。 +3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。 +4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。 +5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。 +6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。 +7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。 SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。 @@ -64,3 +67,19 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口 检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。 关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。 + +## 爆管压力下降覆盖与历史还原 + +`run_burst_simulation` 新增可选 `include_impact_coverage=true`,原默认行为保留。指定已定位管道、带时区的事件起算时刻及有来源或明确估计依据的破口面积(cm²)。计算保持隔离临时模型、不保存分析方案、不重算在线基线,不施加新的关阀或抢修操作。 + +在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。 + +输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。地图采用黄/橙/红,图例保留每档已绘制/分组节点数。 + +爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。 + +携带 `replay_context` 的调查只开放启用影响覆盖的爆管情景,起算时刻不得超过 `knowledge_cutoff`。使用当前模型的独立假设情景,初始化及扩展模拟均传入空SCADA边界映射,禁止从实时SCADA回填冻结观测;同刻在线结果仅作为计算时读取的模型参考,标明非冻结基线。未来样本属于预测,不能作为已发生的观测。 + +完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。 + +全网结果超过单次工具回执上限时,默认 `get_calculation_result` 返回标明缩略的计数与模型条件、保存证据资源链接及可发布地图命令。原始逐节点结果和几何保持完整,可通过证据资源或 `data_path=["result","impact","comparisons"]` 分页读取;不将全网计算缩为20个展示节点。 diff --git a/docs/investigation-progress-and-presentation.md b/docs/investigation-progress-and-presentation.md index f113b19..0b268de 100644 --- a/docs/investigation-progress-and-presentation.md +++ b/docs/investigation-progress-and-presentation.md @@ -55,6 +55,23 @@ Server 不验证依赖执行顺序或推断节点完工。 `progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`, 可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容, `update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。 +“保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`; +修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。 +省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。 +证据、动作和地图引用继续按集合累加,空列表不移除已有引用。资料或条件补齐后,应显式修订原 `needs_input` 节点, +新增下游节点不会自动改变上游标记,也不根据节点顺序推断执行结果。例如已有节点 `read` 可更新为: + +```json +{ + "summary": "补充资料已核对,更新原核查节点。", + "graph_update": { + "nodes": [{"node_id": "read", "status": "recorded", "detail": "已核对收到的现场记录。"}] + } +} +``` + +上述更新不增加节点或连线,也不改写已归档的报告图。旧图已保存于 `node_refs` 的节点支持相同的局部修订; +仅存在于旧 Mermaid 源码、尚无节点元数据的编号仍不能作为新增步骤重用。 尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。 仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。 首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容; @@ -236,3 +253,12 @@ git diff --check ``` 这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。 + + +## 工单通知与历史展示 + +工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。 + +前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。 + +历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。 diff --git a/docs/mcp-analysis-tools.md b/docs/mcp-analysis-tools.md index 0818fc0..b5f7f1b 100644 --- a/docs/mcp-analysis-tools.md +++ b/docs/mcp-analysis-tools.md @@ -5,11 +5,12 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42 | 工具名(不含前缀) | 能力与边界 | 必要权限 | | --- | --- | --- | | `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` | +| `run_burst_simulation` | 假设管道破口正向模拟;可选全网同刻在线压力差与1/3/5米水头覆盖,详见[爆管流程](burst-response-workflow.md) | `simulation.run` + `scada.view`,影响比较另需 `simulation.view` | | `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` | | `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` | | `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` | | `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` | -| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` | +| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` | | `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` | | `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` | | `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` | diff --git a/docs/water-pollution-response-workflow.md b/docs/water-pollution-response-workflow.md new file mode 100644 index 0000000..c5691e2 --- /dev/null +++ b/docs/water-pollution-response-workflow.md @@ -0,0 +1,53 @@ +# 水污染侦测事件调查 + +水质监测侦测到异常后创建 `workflow=water_pollution`、`event_kind=water_pollution` 的事件调查,分发 [water-pollution-event](../resources/work-order-skills/water-pollution-event/SKILL.md)。`water_quality_complaint` 保持供水水质投诉上报语义,使用原投诉流程。两者不共用触发条件;污染事件不依赖爆管评分、压力下降或工单上报。 + +```mermaid +flowchart TD + detection[有效水质观测命中业务规则] --> task[独立水污染事件调查] + task --> evidence[全量观测及七天同期核验] + evidence --> trace[快速候选溯源] + trace --> node[展示已核验节点] + node --> report[候选源阶段结论] + report --> action[立即提出现场核查采样工单供审批派发] + report --> simulation[污染传播模拟与累计浓度影响范围] + report --> isolation[节点隔离与阀门边界] + simulation --> impact[同一调查补充证据地图与报告] + isolation --> impact + action --> feedback[采样处置复测反馈] + feedback --> review[接续原调查复核] + impact --> review + review --> final[最终报告及事件收尾] +``` + +## 侦测入口 + +现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。 + +当前侦测来源沿用既有 SCADA 历史回放处理器;本次没有新增实时采集或常驻水质侦测调度器。其他可信侦测生产者可经现有 operational-tasks 创建接口提交同一 workflow,保留实际侦测信号与发生时间;普通任务无需 replay_context。 + +## 临时溯源契约 + +`run_contaminant_source_trace` 要求 `simulation.run`,只读当前项目模型,禁止只读调查子角色调用。输入包含从水质设备映射或资产查询取得的 `candidate_node_ids`(1–20 个不同节点)及带时区、最长24小时的 `start/end`。回放窗口不能越过知识截止。接口直接返回结果,当前不入计算队列。 + +临时实现从提交的候选中核验模型节点,按字符串排序返回首个存在的节点,不执行反演或拟合。返回 `located_node`、`top_candidates`、`validated_node_ids`、`missing_node_ids`、`selection_basis`、`algorithm_status=provisional`;证据 `origin=synthetic`,不编造相似度或置信度。所有节点不存在时明确返回 `no_candidate`。后续正式算法替换服务内部选择逻辑,保留节点结果契约及后续调查步骤。 + +候选节点只是当前可分析目标;定位后先保存 `interim/concluded`,立即提出关联 `general` 现场核查/采样处置工单,随后继续污染传播模拟和 `accident_element_type=node` 的隔离。审核和派发不依赖模拟完成;等待工单时继续独立分析,模拟结果补入同一 `task_id`,不重复创建工单或改写已审核动作,权限与版本校验保持原有流程。 + +## 污染传播模拟与影响覆盖 + +`run_contaminant_simulation` 增加 `include_impact_coverage`(默认 false)与 `impact_threshold_mg_l`(默认0,严格大于)。开启时,在筛选最多20个展示节点之前,使用原生结果的全网节点、整个模拟窗口的报告采样汇总累计超阈值节点数、各节点首个超阈值报告时刻和峰值。原生浓度 `MGL` / `UGL` 分别转换为 mg/L 后比较;缺失、无效浓度、错误时间网格或不支持的单位保留为缺口,不能转成零浓度或安全范围。几何超过10000节点上限时仍返回数值估计,并明确地图不可用。 + +阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。 + +回放同样允许该工具,要求启用影响覆盖且起算不晚于知识截止;预测时段可以越过截止,但后续时刻只是模型预测。与爆管情景一样,使用当前模型和空 SCADA 映射传入临时模型计算,不读取可变实时监测,不重建历史模型。仍禁止借此放开未授权观测或其他水力工具。 + +工具沿用计算队列和 owner/run 校验,返回终态后通过 `get_calculation_result` 读取并持久保存证据。`presentation_preview` 提供基于 `evidence_id` 的地图请求;用正式调查展示事件保存模型曲线和覆盖,历史播放还原已保存证据。审批、派发可在计算排队或运行期间进行;存在未终态计算时继续保留原运行,不允许发布 final 或成功结束调查,已有工单不受模拟失败影响。 + +## 展示、反馈与收尾 + +水质图表使用 `chart.scada.metric=quality`,沿用真实单位、三小时当前趋势、前七天同期散点及证据校验;不同指标分图。同单位不同水质指标通过图表纵轴名称区分展示分组。Server、Agent、Frontend 的图表协议同步支持 quality,正式报告仍需引用本轮全量 SCADA 证据。 + +关联现场任务使用 general 工单。成功创建后保存在 `handling_context.work_orders`,进入 `waiting_work_order`;全部有效工单 completed 后进入 `field_review`,接续 Agent,而非执行爆管压力恢复计算。最终 concluded 报告必须引用全部有效工单最新的 `feedback_evidence_id`,然后由 Server 完成任务并解除关联告警。完工反馈或新版本复测资料会重新进入复核;证据不齐时 Agent 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。 + +所有新增状态保存在既有 JSON 文档,无数据库迁移、环境配置或新服务。现有服务更新代码及流程包后才生效;单元测试和契约校验不等同真实模型 Agent 调查验收。 diff --git a/resources/work-order-skills/burst-event-handling/SKILL.md b/resources/work-order-skills/burst-event-handling/SKILL.md index ecc8c7e..aafb01a 100644 --- a/resources/work-order-skills/burst-event-handling/SKILL.md +++ b/resources/work-order-skills/burst-event-handling/SKILL.md @@ -2,14 +2,14 @@ name: burst-event-handling description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 metadata: - version: "1.1.3" + version: "1.1.4" --- # 爆管事件处理 适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。 -先按handling_context接续:等待阶段按第6节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。 +先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。 正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。 @@ -23,30 +23,40 @@ metadata: 在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。 -## 3. 隔离方案 +定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。 + +## 3. 爆管后、处置前的影响范围 + +核对定位管道后,用 run_burst_simulation(include_impact_coverage=true)模拟事件时刻的破口,orifice_areas_cm2 保留来源;无实测破口面积时可用有说明依据的估计情景,明确假设与不确定性,不把定位漏量直接当作破口面积。缺少依据则提具体待补参数,继续工单与隔离分析。 + +工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。 + +读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。先保留此步骤,再展示隔离方案,不能用后续隔离覆盖替换本步。 + +## 4. 隔离方案 对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。 展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。 -## 4. 影响说明 +## 5. 影响说明 报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质: - **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。 - **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。 -- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。已有可靠破口参数时可按需补充事件情景模拟。 +- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。事件情景采用第3节结果,缺口单列。 - **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。 现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。 -## 5. 处置建议与报告 +## 6. 处置建议与报告 -先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。 +复用定位阶段已发起的工单及 action_id;保存更新后的 interim/concluded 阶段报告,再按新增需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。 -报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。 +报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第7节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。 -## 6. 反馈与闭环复核 +## 7. 反馈与闭环复核 读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。 diff --git a/resources/work-order-skills/scada-anomaly/SKILL.md b/resources/work-order-skills/scada-anomaly/SKILL.md index 45d644e..02491f3 100644 --- a/resources/work-order-skills/scada-anomaly/SKILL.md +++ b/resources/work-order-skills/scada-anomaly/SKILL.md @@ -2,7 +2,7 @@ name: scada-anomaly description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 metadata: - version: "1.0.16" + version: "1.0.18" --- # SCADA 数据异常调查 @@ -15,7 +15,7 @@ metadata: ## 数据核验与诊断 -任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。 +任一已配置压力、流量或压力模型信号异常即调查。先核验数据质量与测点,再判别原因;组合指标有缺口时继续核查单项异常。水质侦测使用独立 water_pollution / water-pollution-event 流程,与 water_quality_complaint 投诉分开,不计入爆管支持分。 通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。 @@ -29,7 +29,11 @@ metadata: 检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。 -取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。 +取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。 + +run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,缺口单列;先事件影响,再隔离。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。 + +再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。 ## 对比展示与影响 @@ -45,9 +49,9 @@ metadata: 本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。 -报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。 +报告含观测、规则、原因、定位隔离、影响和建议;摘要≤150字,测点用表格、完整依据留证据。完成分析、展示与报告后收尾;现场核验、审批维修按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。 -需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。 +现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。 ## 完工后的恢复复核 diff --git a/resources/work-order-skills/water-pollution-event/SKILL.md b/resources/work-order-skills/water-pollution-event/SKILL.md new file mode 100644 index 0000000..6ad77eb --- /dev/null +++ b/resources/work-order-skills/water-pollution-event/SKILL.md @@ -0,0 +1,62 @@ +--- +name: water-pollution-event +description: 从水质监测侦测信号调查水污染事件,完成数据核验、候选溯源、隔离与影响分析、处置报告和反馈复核。 +metadata: + version: "1.1.0" +--- + +# 水污染侦测事件调查 + +适用于 `workflow=water_pollution`,入口是水质监测规则侦测异常,与供水水质投诉 `water_quality_complaint` 分开。无需投诉或人工上报才能调查,也不需要压力下降、流量增加或爆管综合评分达到门槛。侦测异常、候选污染源和污染确诊分别说明。 + +先读取任务、规则信号、异常设备、发生时刻、已有证据与报告。任务携带 replay_context 时,查询严格遵守 knowledge_cutoff 和 batch_sequence;普通侦测任务使用实际异常时刻。时间统一按 Asia/Shanghai 阅读,查询和 sample_times 保留明确时区,图表标题只写日期、时刻与单位。后续轮次优先处理新事实,复用已有资产、溯源、隔离和展示成果。 + +## 1. 核验侦测与观测 + +调用 query_scada_readings,省略 device_ids,全量读取异常时刻之前三小时的授权 SCADA;核对 devices、readings、coverage、分页完成与缺测。核查水质指标名称、单位、设备状态、清洗质量、模型节点映射以及实际规则上下限和连续命中条件。超出仪表量程、无效值和缺测属于数据质量问题,不能直接当作污染。有效水质读数超出配置业务范围支持异常调查,不自行认定为法定水质超标。 + +对照同一设备前七天同期及实际采样记录,保留真实时间和 null 缺口。压力、流量仅作关联依据,正常压力不能排除污染,爆管得分不用于替代水污染判断。没有适用的限值或采样结果时记录具体缺口,继续候选分析。 + +## 2. 候选溯源与节点展示 + +使用水质设备映射取得异常关联节点,以 get_element_properties、get_node_connections 核对。映射缺失时查询设备坐标附近模型节点,不猜编号。候选必须来自当前项目已查询资产。 + +检查 source_trace_capability,调用 run_contaminant_source_trace:candidate_node_ids 提交已核验的候选节点,start/end 为实际核查窗。读取 located_node、top_candidates、validated_node_ids、missing_node_ids 和 selection_basis。当前接口以稳定规则快速返回确定节点,正式反演算法后续接入,不虚构相似度、确诊概率或污染范围。 + +临时算法和 synthetic 来源只说明结果取得方式,不构成停止调查的条件。取得 located_node 后立即通过 publish_task_presentation 展示候选节点,继续隔离、覆盖、影响和报告。无候选时修正资产映射;确实无可用节点时仅暂停依赖源节点的分支,完成已有监测事实与处置建议。 + +定位到源节点后,立即保存 interim/concluded 阶段报告,注明“候选污染源待现场核验、传播范围计算中”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用节点、地点和定位证据。沿用已有 action_id,不因模拟返回再建同类工单。工单审核、派发与传播模拟、隔离分析并行,不等模拟完成才发起工单;审批权限与动作版本校验沿用 Server 流程。 + +## 3. 处置前的污染传播模拟 + +继续调用 run_contaminant_simulation,source=located_node、include_impact_coverage=true;start_time 使用事件业务时刻,duration 使用明确的情景预测时长,output_node_ids 选择源节点和需展示曲线的监测/重要节点。源浓度 concentration 单位 mg/L,使用有来源的浓度或有明确依据的情景假设,保留依据与不确定性,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺少可用依据时记录具体参数缺口,继续工单和隔离分支。 + +impact_threshold_mg_l 仅作模型影响范围的展示条件;默认0表示正浓度,非饮用水安全限值。工具在筛选展示节点前汇总全网节点整个模拟时段,返回累计超阈值节点、首个超阈值报告时刻、峰值和地图覆盖。沿用模型初始水质及反应条件,不能将总模型浓度当作源独立贡献,不能据此宣布范围外安全。 + +queued/running 时继续独立工作,用 get_calculation_job 跟踪并以 get_calculation_result 读取终态,不以回执代替结果。待审批或 waiting_work_order 不阻止继续可执行分析;模拟结果补入同一 task_id 的证据、进度与报告,不覆盖已审核工单或绕过版本校验。模拟失败只记录该分支问题,不撤回已有现场核查工单。存在未完成计算时不能结束调查运行或发布 final,按宿主计算等待机制接续。 + +读取结果后,用 presentation_preview 的 evidence_id 发布“水污染传播预计影响范围(处置前情景)”地图;将源浓度、阈值、预测窗、影响节点数、首达采样时刻和峰值写入报告表格,按真实时刻展示模型浓度曲线,模型曲线不填观测专用 chart.scada。地图和图表进入正式调查展示事件,历史播放读取已保存证据,不重新运行模拟。空范围、缺测、不支持的单位或几何超限按实际结果说明。保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。 + +带 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 分析替代边界;不能隔离时记录原因并继续其他处置建议。 + +展示候选节点、必关与可选阀门。用 generate_node_coverage 分组生成直接隔离、新增供水失源覆盖,再通过 publish_task_presentation 发布。空节点组不提交覆盖计算。拓扑隔离、供水失源、污染传播、水力变化和用户影响分别说明,节点覆盖不能当作污染传播边界,无用户资料不虚构户数。 + +输入和权限具备时使用 analyze_valve_closure 评估关阀水力影响,污染传播复用第3节结果。有排放节点、流量和前置条件时用 analyze_flushing 评估冲洗,缺少可选参数不阻止形成隔离、采样和保障建议。回放未开放的其他水力工具按实际权限记录限制,继续已授权分析。 + +## 5. 过程图表与报告 + +水质曲线填写 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,超过槽位上限按时间分图。所有数据引用本轮证据;展示不要求等待浏览器。 + +报告包含“事件概况、侦测与数据核验、候选溯源、隔离方案、影响说明、处置建议、反馈复核与待办”。影响说明列出实际异常指标、单位、时间、测点、直接隔离与失源节点数、水力或传播预测和用户保障。明确临时选点、观测事实、模型假设及待现场确认事项。最终报告引用本轮全量 SCADA 证据及已取得的溯源、隔离和覆盖证据。查询失败或权限拒绝记录为执行异常,不能写成无数据或未见异常。 + +## 6. 处置与反馈接续 + +第2节已提出的 general 工单继续沿用,按新增证据组织污染进入控制、隔离核验、通知保障、清洗冲洗及复测;已有工单确有不足时再提出必要的新动作。标题写“地点+动作”,写明执行条件、真实节点、时间要求与回执内容;审批、派发和现场执行遵守 Server 权限。没有用户通知适配器时交由人工实施。回放草稿注明异常业务时间及来源,不直接执行设备命令。 + +待审批、waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用真实 action_id 后结束本轮,释放租约,不循环查询或重复建单。field_review 时核对各关联工单反馈及实际采样、处置、复测的时间、位置、数值和判定依据,再查询反馈业务时间之后已有水质观测;不要重跑原始调查。缺少关键事实时先完成独立工作,再用 ask_task_user 明确待补内容并结束本轮等待。 + +本轮调查成果完成与现场事件结案分别表述。工单 completed 或压力恢复不证明水质合格,不套用爆管压力恢复的自动结案条件。反馈复核具备依据时发布 final/concluded,引用最新现场反馈证据;仍异常时调整建议或新建必要动作,未来采样、复测和恢复不能写成已完成。 diff --git a/tests/api/test_operational_task_dynamic_flow.py b/tests/api/test_operational_task_dynamic_flow.py index 6803441..1038e77 100644 --- a/tests/api/test_operational_task_dynamic_flow.py +++ b/tests/api/test_operational_task_dynamic_flow.py @@ -39,6 +39,28 @@ def test_dynamic_investigation_routes_are_in_the_public_contract(): assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params) +@pytest.mark.anyio +async def test_progress_route_accepts_partial_node_updates_without_weakening_saved_diagrams(monkeypatch): + from app.domain.schemas.operational_task import AgentArtifactsCommand + + schemas = app.openapi()["components"]["schemas"] + assert "label" not in schemas["InvestigationGraphNodeUpdate"]["required"] + assert "label" in schemas["InvestigationDiagramNodeRef"]["required"] + service = SimpleNamespace(publish_artifacts=AsyncMock(return_value={"progress_revision": 2})) + monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service) + task_id = uuid4() + command = AgentArtifactsCommand.model_validate({ + "request_id": str(uuid4()), "source_session_id": "session-1", "run_id": str(uuid4()), + "progress": {"summary": "已核对补充资料", "graph_update": { + "nodes": [{"node_id": "review", "status": "recorded"}], + }}, + }) + await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object()) + forwarded = service.publish_artifacts.await_args.args[1].progress.graph_update.nodes[0] + assert forwarded.node_id == "review" and forwarded.status == "recorded" + assert "label" not in forwarded.model_fields_set + + @pytest.mark.anyio async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch): from app.domain.domain_errors import DomainError diff --git a/tests/unit/test_burst_impact.py b/tests/unit/test_burst_impact.py new file mode 100644 index 0000000..77ad8f3 --- /dev/null +++ b/tests/unit/test_burst_impact.py @@ -0,0 +1,197 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock + +import pytest + +from app.services import burst_impact as service +from app.services.node_coverage import build_node_coverage + +START = datetime(2026, 7, 16, 5, tzinfo=timezone.utc) + + +def simulation(drops, unit="m"): + return {"units": {"pressure": unit}, "times": {"report_start": 0}, + "node_results": [{"node": f"J{i}", "result": [{"pressure": 30 - drop}]} + for i, drop in enumerate(drops)]} + + +@pytest.fixture +def inputs(monkeypatch): + monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": "METERS"}) + monkeypatch.setattr(service, "get_option_v3", lambda _: {}) + read = Mock(side_effect=lambda ids, *a, **kw: { + node: [{"time": START.isoformat(), "value": 30}] for node in ids}) + monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read) + def coverage(network, groups): + ids = sorted({node for group in groups for node in group["node_ids"]}) + data = build_node_coverage(groups, [ + {"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9} + for i, node in enumerate(ids)], []) + payload = data.pop("map_payload") + return {"coverage": data, "map_payload": payload} + monkeypatch.setattr(service, "generate_node_coverage", coverage) + return read + + +def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs): + result = service.estimate_burst_impact("own", START, simulation([.999, 1, 2.999, 3, 4.999, 5, 8, -1])) + impact = result["impact"] + assert [group["node_ids"] for group in impact["grades"]] == [["J1", "J2"], ["J3", "J4"], ["J5", "J6"]] + assert impact["below_threshold_node_count"] == 2 + assert impact["compared_node_count"] == 8 and impact["affected_node_count"] == 6 + assert impact["phase"] == "after_burst_before_intervention" + assert impact["baseline_frozen"] is False + inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own") + assert len(result["map_payload"]["features"]) == 3 + assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"]) + + +@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65), + ("bar", 100000 / 9806.65), ("m", 1)]) +def test_normalizes_baseline_and_simulation_independently(monkeypatch, inputs, unit, factor): + inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 / factor}]} + inputs.side_effect = None + monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": unit}) + result = service.estimate_burst_impact("own", START, simulation([4])) + assert result["impact"]["comparisons"][0]["pressure_drop_m"] == pytest.approx(4) + assert result["impact"]["grades"][1]["node_ids"] == ["J0"] + + +def test_v3_units_take_precedence_over_legacy_settings(monkeypatch, inputs): + monkeypatch.setattr(service, "get_option_v3", lambda _: {"PRESSURE_UNITS": "KPA"}) + inputs.side_effect = None + inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 * 9.80665}]} + impact = service.estimate_burst_impact("own", START, simulation([4]))["impact"] + assert impact["comparisons"][0]["pressure_drop_m"] == pytest.approx(4) + assert impact["baseline_units_source"] == "current_v3_model_settings" + + +def test_missing_nonfinite_duplicate_and_wrong_time_are_unavailable_not_zero(inputs): + inputs.side_effect = None + inputs.return_value = { + "J0": [{"time": (START + timedelta(seconds=1)).isoformat(), "value": 30}], + "J1": [{"time": START.isoformat(), "value": float("nan")}], + "J2": [{"time": START.isoformat(), "value": 30}] * 2, + "J3": [{"time": START.isoformat(), "value": 30}], + "J4": [{"time": START.isoformat(), "value": 30}], + } + native = simulation([1] * 6) + native["node_results"][3]["result"] = [{"pressure": float("inf")}] + result = service.estimate_burst_impact("own", START, native) + assert result["impact"]["status"] == "partial" + assert result["impact"]["unavailable_node_ids"] == ["J0", "J1", "J2", "J3", "J5"] + assert result["impact"]["compared_node_count"] == 1 + + +@pytest.mark.parametrize("times", [{}, {"report_start": 900}, {"sample_times": ["2026-07-16T05:00:01Z"]}]) +def test_unknown_or_shifted_first_sample_never_queries_baseline(inputs, times): + native = simulation([2]) + native["times"] = times + result = service.estimate_burst_impact("own", START, native) + assert result["impact"]["reason"] == "simulation_has_no_verified_start_time_sample" + assert "map_payload" not in result + inputs.assert_not_called() + + +def test_unknown_units_do_not_draw_an_impact_area(inputs): + result = service.estimate_burst_impact("own", START, simulation([2], "unknown")) + assert result["impact"]["reason"] == "unsupported_or_missing_pressure_units" + inputs.assert_not_called() + + +def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs): + from app.mcp import scenario_service + from app.services import simulation_scenarios + native = simulation([4] * 25) + run = Mock(return_value=native) + monkeypatch.setattr(simulation_scenarios, "burst_analysis", run) + result = scenario_service.calculate_scenario("run_burst_simulation", { + "output_node_ids": ["J0"], "output_link_ids": [], "start_time": START, "duration": 900, + "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], "include_impact_coverage": True, + }, network="own", username="user", model_only=True) + assert len(result["node_results"]) == 1 + assert result["impact"]["affected_node_count"] == 25 + assert result["coverage"]["requested_nodes"] == ["J0"] + assert result["persisted_baseline"] is False + assert run.call_args.kwargs["persist_results"] is False + assert run.call_args.kwargs["burst_ID"] == ["P1"] + assert not run.call_args.kwargs["scada_mappings"].reservoirs + assert result["impact"]["boundary_source"] == "model_only_no_live_scada" + + +def test_large_affected_sets_report_full_counts_without_truncating(inputs): + result = service.estimate_burst_impact("own", START, simulation([4] * 10001)) + assert result["impact"]["affected_node_count"] == 10001 + assert result["impact"]["coverage_status"] == "unavailable" + assert "map_payload" not in result + + +def test_replay_passes_empty_boundary_mappings_to_both_native_runs(monkeypatch): + from app.services import simulation_scenarios as scenarios + from app.infra.db.postgresql.scada import ScadaElementMappings + mappings = ScadaElementMappings({}, {}, {}, {}, {}, {}, {}) + runs = Mock(return_value={"node_results": []}) + monkeypatch.setattr(scenarios.simulation, "run_simulation", runs) + monkeypatch.setattr(scenarios, "get_pipe", lambda *a: {"node1": "A", "node2": "B", "diameter": 100}) + monkeypatch.setattr(scenarios, "is_junction", lambda *a: True) + monkeypatch.setattr(scenarios, "get_emitter", lambda *a: None) + monkeypatch.setattr(scenarios, "set_emitter", lambda *a: None) + monkeypatch.setattr(scenarios, "get_option", lambda *a: {"UNITS": "LPS"}) + monkeypatch.setattr(scenarios, "set_option", lambda *a: None) + scenarios.burst_analysis.__wrapped__( + name="own", modify_pattern_start_time=START.isoformat(), burst_ID=["P1"], burst_size=[2], + persist_results=False, _temporary_project="tjw_tmp_own", scada_mappings=mappings, + ) + assert len(runs.call_args_list) == 2 + assert all(call.kwargs["scada_mappings"] is mappings for call in runs.call_args_list) + assert runs.call_args.kwargs["persist_results"] is False + + +@pytest.mark.anyio +async def test_order_notification_precedes_durable_impact_and_keeps_run_available(inputs): + from copy import deepcopy + from uuid import uuid4 + from fastapi.encoders import jsonable_encoder + from app.domain.schemas.operational_task import ActionProposalCommand, TaskPresentationEventCommand + from app.domain.schemas.operational_data import TaskDataSnapshot + from app.mcp.models import QueryResult + from app.mcp.presentation_preview import saved_presentation_preview + from app.services.operational_tasks import OperationalTaskService + from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run + + context = _context() + task = _task_document(context, START, lease=START + timedelta(minutes=1)) + task["workflow"] = "burst_report" + repository = TaskRepository(task, _active_run(task, START)) + repository.reports.append({"revision": 1, "execution_generation": 1, + "report": {"stage": "interim", "outcome": "concluded"}}) + tasks = OperationalTaskService(repository, context, set(), clock=lambda: START) + order = await tasks.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="P1候选管段待现场核验", + payload={"title": "P1现场核查", "description": "核查定位管道并反馈", "work_order_type": "general"}, + )) + assert repository.task["status"] == "running" + assert repository.notifications[-1]["kind"] == "action_approval_pending" + assert order.approval_status == "pending" and order.execution_status == "not_submitted" + result = QueryResult( + source="run_burst_simulation", source_ref="mcp:burst-impact", queried_at=START, + semantics="analysis", origin="system_data", truncated=False, + data=jsonable_encoder({"result": service.estimate_burst_impact("own", START, simulation([2, 4, 6]))}), + ) + evidence, _ = await tasks.record_data_snapshot(task["task_id"], TaskDataSnapshot( + source=result.source, source_ref=result.source_ref, queried_at=START, + origin="system_data", truncated=False, data=jsonable_encoder(result), + )) + result.evidence_id = evidence.evidence_id + hint = saved_presentation_preview(result) + await tasks.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", + command=hint["command"], + )) + recorded = deepcopy(repository.events[-1]["metadata"]["command"]) + assert recorded["data"] == jsonable_encoder(result.data["result"]["map_payload"]) + assert len(recorded["data"]["features"]) == 3 + # Saved presentation remains complete even if the live query result changes. + result.data["result"]["map_payload"]["features"].clear() + assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3 + assert repository.run["status"] == "running" diff --git a/tests/unit/test_contaminant_impact.py b/tests/unit/test_contaminant_impact.py new file mode 100644 index 0000000..c26410d --- /dev/null +++ b/tests/unit/test_contaminant_impact.py @@ -0,0 +1,135 @@ +from copy import deepcopy +from datetime import datetime, timedelta, timezone + +import pytest + +from app.services import contaminant_impact as impact +from app.services.node_coverage import build_node_coverage + + +START = datetime(2026, 7, 16, 0, tzinfo=timezone.utc) + + +def simulation(unit="MGL"): + return {"units": {"quality": unit}, "times": {"report_start": 0, "report_step": 900, "num_periods": 3}, + "node_results": [{"node": node, "result": [{"quality": value} for value in values]} + for node, values in {"source": [1, 1, 1], "late": [0, 0.2, 0.5], "clean": [0, 0, 0]}.items()]} + + +@pytest.fixture +def geometry(monkeypatch): + calls = [] + + def generate(network, groups): + calls.append((network, deepcopy(groups))) + coverage = build_node_coverage(groups, [{"id": "source", "longitude": 121.4, "latitude": 31.1}], []) + payload = coverage.pop("map_payload") + return {"coverage": coverage, "map_payload": payload} + + monkeypatch.setattr(impact, "generate_node_coverage", generate) + return calls + + +def test_impact_uses_whole_prediction_window_and_real_concentration_units(geometry): + result = impact.estimate_contaminant_impact("project", START, 1800, simulation(), threshold_mg_l=0.2, model_only=True) + data = result["impact"] + assert data["affected_node_ids"] == ["source", "late"] + late = next(row for row in data["nodes"] if row["node_id"] == "late") + assert late["first_exceedance_sample_at"] == (START + timedelta(minutes=30)).isoformat() + assert late["peak_mg_l"] == 0.5 and late["peak_at"] == late["first_exceedance_sample_at"] + assert data["node_count"] == 3 and data["status"] == "partial" # Late node has no GIS position. + assert data["boundary_source"] == "model_only_no_live_scada" + assert data["threshold_purpose"] == "scenario_display_not_drinking_water_standard" + assert geometry[0][1][0]["node_ids"] == ["source", "late"] + assert result["coverage"]["groups"]["contaminant_exposure"]["missing_node_ids"] == ["late"] + assert result["map_payload"]["features"][0]["properties"]["impact_phase"] == "after_contamination_before_intervention" + from app.domain.schemas.operational_data import TaskDataSnapshot + from app.services.operational_presentation import resolve_overlay + from fastapi.encoders import jsonable_encoder + snapshot = TaskDataSnapshot(source="run_contaminant_simulation", source_ref="calculation:propagation", + queried_at=START, origin="synthetic", truncated=False, data=jsonable_encoder({"result": result})) + saved = resolve_overlay(snapshot, "geometry") + assert saved["features"][0]["properties"]["model_estimate"] is True + + +@pytest.mark.parametrize("unit", ["UGL", "ug/L", "µg/L"]) +def test_micrograms_are_converted_before_threshold_comparison(geometry, unit): + result = impact.estimate_contaminant_impact("project", START, 1800, simulation(unit), threshold_mg_l=0.0002) + assert result["impact"]["nodes"][1]["peak_mg_l"] == 0.0005 + assert result["impact"]["affected_node_count"] == 2 + + +@pytest.mark.parametrize("problem", ["unit", "times", "duplicate", "too_many_samples", "no_values"]) +def test_invalid_results_do_not_turn_into_an_empty_safe_range(geometry, problem): + data = simulation() + if problem == "unit": data["units"]["quality"] = "HOURS" + if problem == "times": data["times"] = {"report_step": 0} + if problem == "duplicate": data["node_results"].append(data["node_results"][0]) + if problem == "too_many_samples": data["node_results"][0]["result"].append({"quality": 2}) + if problem == "no_values": + for row in data["node_results"]: row["result"] = [{"quality": float("nan")}] * 3 + result = impact.estimate_contaminant_impact("project", START, 1800, data) + assert result["impact"]["status"] == "unavailable" + assert "reason" in result["impact"] and "map_payload" not in result + assert not geometry + + +def test_missing_samples_remain_explicit_and_do_not_replace_first_observation(geometry): + data = simulation() + data["node_results"][1]["result"][0] = {"quality": None} + result = impact.estimate_contaminant_impact("project", START, 1800, data) + assert result["impact"]["incomplete_node_ids"] == ["late"] + assert result["impact"]["nodes"][1]["complete"] is False + + +def test_geometry_limit_retains_the_numeric_prediction(monkeypatch): + monkeypatch.setattr(impact, "MAX_COVERAGE_NODES", 1) + result = impact.estimate_contaminant_impact("project", START, 1800, simulation()) + assert result["impact"]["affected_node_count"] == 2 + assert result["impact"]["coverage_status"] == "unavailable" + assert "map_payload" not in result + + +def test_adapter_estimates_all_native_nodes_before_selecting_display_series(monkeypatch): + from app.mcp.analysis_models import ContaminantSimulationInput + from app.mcp.scenario_service import calculate_scenario + from app.services import simulation_scenarios + + native = simulation() + calls = [] + monkeypatch.setattr(simulation_scenarios, "contaminant_simulation", lambda **kw: calls.append(kw) or native) + seen = [] + monkeypatch.setattr(impact, "estimate_contaminant_impact", + lambda network, start, duration, result, **kw: seen.append((network, start, duration, result, kw)) + or {"impact": {"affected_node_count": 2}}) + args = ContaminantSimulationInput(context_handle="a" * 43, start_time=START, duration=1800, + output_node_ids=["source"], source="source", concentration=1, + include_impact_coverage=True, impact_threshold_mg_l=0.2) + result = calculate_scenario("run_contaminant_simulation", + args.model_dump(exclude={"context_handle", "timeout_seconds"}), network="project", username="actor", model_only=True) + assert len(result["node_results"]) == 1 and len(seen[0][3]["node_results"]) == 3 + assert seen[0][4] == {"threshold_mg_l": 0.2, "model_only": True} + assert result["impact"]["affected_node_count"] == 2 + assert calls[0]["scada_mappings"].reservoirs == {} and calls[0]["scada_mappings"].quality == {} + assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None + + +def test_native_contaminant_service_passes_empty_mappings_to_the_engine(monkeypatch): + from app.infra.db.postgresql.scada import ScadaElementMappings + from app.services import simulation_scenarios as native + + for name in ("set_time", "set_option", "add_source", "set_demand"): + monkeypatch.setattr(native, name, lambda *args: None) + monkeypatch.setattr(native, "get_time", lambda *_: {"HYDRAULIC TIMESTEP": "0:15:00"}) + monkeypatch.setattr(native, "get_pattern", lambda *_: {"id": "pattern"}) + monkeypatch.setattr(native, "get_source", lambda *_: {}) + monkeypatch.setattr(native, "get_demand", lambda *_: {"demands": []}) + monkeypatch.setattr(native, "get_option", lambda *_: {}) + calls = [] + monkeypatch.setattr(native.simulation, "run_simulation", lambda **kw: calls.append(kw) or simulation()) + mappings = ScadaElementMappings(reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, pressure={}, demand={}, quality={}) + native.contaminant_simulation.__wrapped__(name="project", modify_pattern_start_time=START.isoformat(), + modify_total_duration=1800, source="source", concentration=1, source_pattern="pattern", + username="actor", _temporary_project="temporary", persist_results=False, scada_mappings=mappings) + assert calls[0]["scada_mappings"] is mappings + assert calls[0]["name"] == "temporary" and calls[0]["persist_results"] is False diff --git a/tests/unit/test_contaminant_simulation_mcp.py b/tests/unit/test_contaminant_simulation_mcp.py new file mode 100644 index 0000000..a6bcfdf --- /dev/null +++ b/tests/unit/test_contaminant_simulation_mcp.py @@ -0,0 +1,68 @@ +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from app.mcp import analysis_service, query_service +from app.mcp.analysis_models import ContaminantSimulationInput +from app.mcp.calculation_jobs import should_queue +from app.mcp.catalog import TOOLS + + +def args(**extra): + return ContaminantSimulationInput(context_handle="a" * 43, **({ + "start_time": "2026-07-16T08:00:00+08:00", "duration": 1800, + "output_node_ids": ["Q1"], "source": "Q1", "concentration": 1, + "include_impact_coverage": True, "impact_threshold_mg_l": 0.01, + } | extra)) + + +@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")}, + {"include_impact_coverage": "true"}]) +def test_impact_inputs_reject_invalid_thresholds_and_flags(extra): + with pytest.raises(ValidationError): args(**extra) + + +def context(monkeypatch): + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"}) + @asynccontextmanager + async def routing(): + yield + scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()), + project=SimpleNamespace(project_code="project", project_role="member"), user=SimpleNamespace(username="actor"), + simulation_routing=routing) + binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], permission_cap=["simulation.run", "scada.view"], + purpose="task_execution", task_id=uuid4(), task_scope_cap=[], + replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"}) + return scope, binding + + +def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch): + scope, binding = context(monkeypatch) + worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}}) + monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args())) + assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2 + assert "预测" in result.data_gaps[0] + scope.connection.commit.assert_awaited_once() + worker.assert_awaited_once() + assert worker.await_args.kwargs == {"network": "project", "username": "actor"} + assert should_queue(TOOLS["run_contaminant_simulation"], args()) + + +@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"]) +def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem): + scope, binding = context(monkeypatch) + parameters = args() + if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00") + if problem == "no_impact": parameters = args(include_impact_coverage=False) + if problem == "reader": binding.purpose = "task_reader" + if problem == "permission": binding.permission_cap.remove("scada.view") + with pytest.raises(HTTPException) as caught: + asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters)) + assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403) diff --git a/tests/unit/test_contaminant_source_trace.py b/tests/unit/test_contaminant_source_trace.py new file mode 100644 index 0000000..b30da9b --- /dev/null +++ b/tests/unit/test_contaminant_source_trace.py @@ -0,0 +1,73 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from app.mcp import query_service +from app.mcp.analysis_models import ContaminantSourceTraceInput +from app.mcp.calculation_jobs import should_queue +from app.mcp.catalog import TOOLS +from app.services import contaminant_source_trace as trace + + +def arguments(**extra): + return ContaminantSourceTraceInput(context_handle="a" * 43, + **({"candidate_node_ids": ["J2", "J1", "missing"], + "start": "2026-07-16T08:00:00+08:00", "end": "2026-07-16T08:15:00+08:00"} | extra)) + + +@pytest.mark.parametrize("nodes", [[{"id": "J2"}, {"id": "J1"}], [{"id": "J1"}, {"id": "J2"}]]) +def test_trace_is_immediate_project_scoped_stable_and_explicit_about_algorithm(monkeypatch, nodes): + read = AsyncMock(return_value=nodes) + monkeypatch.setattr(trace, "read_rows", read) + connection = object() + args = arguments() + result = asyncio.run(trace.trace_contaminant_source(connection, args)) + assert result["located_node"] == "J1" + assert result["missing_node_ids"] == ["missing"] + assert result["top_candidates"] == [{"node_id": "J1"}] + assert result["algorithm_status"] == "provisional" + assert result["selection_basis"] == "lexicographically_first_existing_candidate_node" + assert "相似度" not in result and "confidence" not in result + assert read.await_args.args[0] is connection + assert "network.nodes" in read.await_args.args[1] and "ANY(%s)" in read.await_args.args[1] + assert read.await_args.args[2] == (args.candidate_node_ids,) + assert not should_queue(TOOLS["run_contaminant_source_trace"], args) + + +@pytest.mark.parametrize("change", [{"candidate_node_ids": []}, {"candidate_node_ids": ["J1", "J1"]}, + {"candidate_node_ids": [str(index) for index in range(21)]}, + {"start": "2026-07-16T08:00:00"}, {"end": "2026-07-16T08:00:00+08:00"}, + {"end": "2026-07-18T08:00:00+08:00"}]) +def test_trace_rejects_unbounded_or_ambiguous_inputs(change): + with pytest.raises(ValidationError): + arguments(**change) + + +@pytest.mark.parametrize("nodes", [[], [{"id": "J1"}]]) +def test_dispatch_preserves_provenance_and_reports_only_actual_node_gaps(monkeypatch, nodes): + monkeypatch.setattr(trace, "read_rows", AsyncMock(return_value=nodes)) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"}) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_role="member")) + binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution", + permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], replay_context=None) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments())) + assert result.origin == "synthetic" and result.semantics == "analysis" + assert (result.data["result"]["located_node"] is None) == (not nodes) + assert bool(result.data_gaps) == (not nodes) + assert "context_handle" not in result.data["parameters"] + + +def test_replay_trace_cannot_escape_frozen_window(monkeypatch): + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"}) + scope = SimpleNamespace(project=SimpleNamespace(project_role="member")) + binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution", + permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], + replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"}) + with pytest.raises(HTTPException) as caught: + asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments())) + assert caught.value.status_code == 422 diff --git a/tests/unit/test_investigation_graph.py b/tests/unit/test_investigation_graph.py index c34c78b..f5b3b61 100644 --- a/tests/unit/test_investigation_graph.py +++ b/tests/unit/test_investigation_graph.py @@ -80,6 +80,78 @@ async def test_failed_attempt_cannot_be_relabelled_as_a_success(): ]) +@pytest.mark.anyio +async def test_existing_waiting_node_accepts_a_status_only_update_without_extending_the_graph(): + task, repo, service = setup_service() + first = await publish(service, repo, task["task_id"], nodes=[ + {"node_id": "read", "label": "读取监测", "status": "recorded"}, + {"node_id": "review", "label": "核对补充资料", "status": "needs_input", "detail": "等待现场记录"}, + ], edges=[{"source": "read", "target": "review"}]) + original = first.task.latest_progress.progress.diagram + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({ + "request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"], + "run_id": repo.run["run_id"], "report": { + "title": "阶段报告", "current_conclusion": "仍需核对现场资料", "conclusion_boundary": "现有资料范围", + "stage": "interim", "outcome": "concluded", "data_gaps": [], + }, + })) + result = await publish(service, repo, task["task_id"], nodes=[ + {"node_id": "review", "status": "recorded", "detail": "已核对收到的现场记录"}, + ]) + updated = result.task.latest_progress.progress.diagram + assert [node.node_id for node in updated.node_refs] == ["read", "review"] + assert updated.node_refs[1].label == "核对补充资料" + assert updated.node_refs[1].status == "recorded" + assert updated.node_refs[1].detail == "已核对收到的现场记录" + assert updated.edges == original.edges + assert updated.mermaid == original.mermaid + assert original.node_refs[1].status == "needs_input" + archived = (await service.report(task["task_id"], 1)).report.diagram + assert archived.node_refs[1].status == "needs_input" + assert archived.node_refs[1].detail == "等待现场记录" + + +@pytest.mark.parametrize("label_patch", [{}, {"label": None}]) +def test_partial_updates_keep_labels_and_accumulated_references_including_legacy_nodes(label_patch): + evidence_id = "saved-reading" + action_id = uuid4() + original = InvestigationDiagram.model_validate({ + "title": "旧调查图", "mermaid": "flowchart LR\nold{核对资料} --> next[继续调查]", + "node_refs": [{"node_id": "old", "label": "核对资料", "status": "waiting_human", + "detail": "等待资料", "evidence_refs": [evidence_id], "action_refs": [action_id], + "map_targets": [{"source_id": "scada", "feature_id": "P1"}]}], + }) + updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({ + "nodes": [{"node_id": "old", "status": "recorded", **label_patch}], + })) + assert updated.mermaid == updated.source_mermaid == original.mermaid + assert updated.node_refs[0] == original.node_refs[0].model_copy(update={"status": "recorded"}) + assert original.node_refs[0].status == "needs_input" + + +def test_status_only_updates_cannot_create_an_unlabelled_node_or_rewrite_a_failed_attempt(): + with pytest.raises(DomainError) as error: + update_investigation_graph(None, InvestigationGraphUpdate.model_validate({ + "nodes": [{"node_id": "missing", "status": "recorded"}], + })) + assert error.value.code == "GRAPH_NODE_LABEL_REQUIRED" + failed = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({ + "nodes": [{"node_id": "attempt", "label": "失败尝试", "status": "failed", "detail": "接口超时"}], + })) + enriched = update_investigation_graph(failed, InvestigationGraphUpdate.model_validate({ + "nodes": [{"node_id": "attempt", "evidence_refs": ["timeout-record"]}], + })) + assert enriched.node_refs[0].label == "失败尝试" + assert enriched.node_refs[0].detail == "接口超时" + assert enriched.node_refs[0].status == "failed" + assert enriched.node_refs[0].evidence_refs == ["timeout-record"] + with pytest.raises(DomainError) as error: + update_investigation_graph(enriched, InvestigationGraphUpdate.model_validate({ + "nodes": [{"node_id": "attempt", "status": "recorded"}], + })) + assert error.value.code == "GRAPH_FAILED_ATTEMPT_IMMUTABLE" + + @pytest.mark.anyio async def test_new_execution_round_continues_the_same_task_graph(): task, repo, service = setup_service() diff --git a/tests/unit/test_mcp_analysis.py b/tests/unit/test_mcp_analysis.py index e8e71c3..3da30e6 100644 --- a/tests/unit/test_mcp_analysis.py +++ b/tests/unit/test_mcp_analysis.py @@ -26,6 +26,7 @@ HANDLE = "x" * 43 START = "2026-10-08T12:00:00+08:00" END = "2026-10-08T13:00:00+08:00" INPUTS = { + "run_contaminant_source_trace": {"candidate_node_ids": ["J1"], "start": START, "end": END}, "run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]}, "run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0}, "analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]}, diff --git a/tests/unit/test_mcp_calculation_jobs.py b/tests/unit/test_mcp_calculation_jobs.py index a00b2a8..c481646 100644 --- a/tests/unit/test_mcp_calculation_jobs.py +++ b/tests/unit/test_mcp_calculation_jobs.py @@ -204,3 +204,44 @@ def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch assert args.timeout_seconds <= 240 assert row['parameters']['timeout_seconds'] == 3600 authorize.assert_awaited_once() + + +def test_large_burst_result_keeps_saved_map_command_and_full_evidence(monkeypatch): + from copy import deepcopy + from unittest.mock import AsyncMock + from app.mcp import calculation_jobs as jobs + from app.mcp.calculation_models import CalculationResultInput + from app.mcp.models import QueryResult + original = binding() + original.task_id = uuid4() + full = {"parameters": {"burst_pipe_ids": ["P1"]}, "result": { + "impact": {"phase": "after_burst_before_intervention", "status": "ready", + "compared_node_count": 1000, "comparisons": [{"node_id": f"J{i}", "pressure_drop_m": 4} for i in range(1000)], + "grades": [{"group_id": "drop_3_5", "node_ids": [f"J{i}" for i in range(1000)]}], + "unavailable_node_ids": []}, + "coverage": {"status": "ready", "groups": {"drop_3_5": {"node_ids": [f"J{i}" for i in range(1000)], + "mapped_node_count": 1000, "missing_node_ids": []}}}, + "map_payload": {"type": "FeatureCollection", "features": [{"large": "recorded-geometry"}]}, + }} + result = QueryResult(source='run_burst_simulation', source_ref='mcp:burst', queried_at=datetime.now(timezone.utc), + semantics='analysis', data=full, evidence_id='saved-burst') + row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')} + persisted = deepcopy(row) + monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row))) + monkeypatch.setattr(jobs, 'authorize_original', AsyncMock()) + monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result)) + scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4())) + args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id']) + response = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args)) + assert response.truncated and len(response.model_dump_json().encode()) < 11000 + assert response.data['result']['impact']['compared_node_count'] == 1000 + assert response.data['result']['impact']['grades'][0]['node_count'] == 1000 + assert response.data['presentation']['command']['evidence_id'] == 'saved-burst' + assert response.data['presentation']['command']['action'] == 'set_overlay' + assert response.data['resource_uri'].endswith('/evidence/saved-burst') + assert 'comparisons' not in response.data['result']['impact'] + assert row == persisted + # Explicit path pagination still reads every original node without summary replacement. + page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), + args.model_copy(update={'data_path': ['result', 'impact', 'comparisons'], 'offset': 990, 'limit': 10}))) + assert page.data[0]['node_id'] == 'J990' and page.page.total == 1000 diff --git a/tests/unit/test_mcp_extended.py b/tests/unit/test_mcp_extended.py index 851a02f..526454a 100644 --- a/tests/unit/test_mcp_extended.py +++ b/tests/unit/test_mcp_extended.py @@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes(): def test_no_health_prediction_tool_and_every_write_requests_native_permission(): assert not any("health" in name for name in TOOLS) - assert len(TOOLS) == 72 + assert len(TOOLS) == 73 assert sum(t.mutating for t in TOOLS.values()) == 13 for tool in TOOLS.values(): if tool.mutating: diff --git a/tests/unit/test_mcp_presentation_preview.py b/tests/unit/test_mcp_presentation_preview.py index 2ec010e..28e9cc7 100644 --- a/tests/unit/test_mcp_presentation_preview.py +++ b/tests/unit/test_mcp_presentation_preview.py @@ -34,3 +34,32 @@ def test_missing_geometry_does_not_offer_a_broken_overlay_command(): data={"result": {"coverage": {"status": "unavailable"}}}, ) assert saved_presentation_preview(result) is None + + +def test_pollution_impact_keeps_a_saved_overlay_reference_for_history(): + result = QueryResult(source="run_contaminant_simulation", source_ref="calculation:pollution", + evidence_id="pollution:impact", queried_at=datetime.now(timezone.utc), semantics="analysis", origin="synthetic", + data={"result": {"impact": {"phase": "after_contamination_before_intervention"}, + "map_payload": {"type": "FeatureCollection", "features": [{"geometry": "saved"}]}, + "coverage": {"status": "ready", "groups": {"contaminant_exposure": {"node_ids": ["Q1"], "mapped_node_count": 1}}}}}) + preview = saved_presentation_preview(result) + assert preview["command"]["evidence_id"] == "pollution:impact" + assert preview["command"]["title"] == "水污染传播预计影响范围(处置前情景)" + assert "data" not in preview["command"] and preview["groups"]["contaminant_exposure"]["node_count"] == 1 + result.data["result"]["impact"]["phase"] = "unknown" + assert saved_presentation_preview(result) is None + + +def test_burst_impact_hint_uses_saved_evidence_and_distinct_pre_intervention_title(): + result = QueryResult( + source="run_burst_simulation", source_ref="mcp:burst", evidence_id="mcp:burst", + queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False, + data={"result": {"impact": {"phase": "after_burst_before_intervention"}, + "map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]}}}, + ) + hint = saved_presentation_preview(result) + assert hint["command"]["evidence_id"] == "mcp:burst" + assert "爆管后、处置前" in hint["command"]["title"] + assert "data" not in hint["command"] + result.data["result"].pop("impact") + assert saved_presentation_preview(result) is None diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py index ecaeea1..6ac81bb 100644 --- a/tests/unit/test_mcp_query.py +++ b/tests/unit/test_mcp_query.py @@ -373,6 +373,30 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch): asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace())) +def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monkeypatch): + bound = binding(task=True) + bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"} + permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"} + bound.permission_cap = sorted(permissions) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + scope = SimpleNamespace(project=SimpleNamespace(project_role="member")) + args = TOOLS["run_burst_simulation"].model.model_validate({ + "context_handle": bound.context_handle, "start_time": "2026-10-08T01:00:00Z", + "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], + "include_impact_coverage": True, + }) + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args)) + for changed, status in (({"include_impact_coverage": False}, 422), + ({"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)}, 422)): + with pytest.raises(HTTPException) as error: + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args.model_copy(update=changed))) + assert error.value.status_code == status + bound.permission_cap = sorted(permissions - {"simulation.view"}) + with pytest.raises(HTTPException) as error: + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args)) + assert error.value.status_code == 403 + + def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime): app, client, _, _ = runtime at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc) diff --git a/tests/unit/test_mcp_work_order_skills.py b/tests/unit/test_mcp_work_order_skills.py index 1bff77a..2ffae21 100644 --- a/tests/unit/test_mcp_work_order_skills.py +++ b/tests/unit/test_mcp_work_order_skills.py @@ -19,6 +19,7 @@ from app.mcp.work_order_skills import read_work_order_skill ("water_quality_complaint", "water-quality-complaint"), ("general", "appointment-follow-up"), ("scada_anomaly", "scada-anomaly"), + ("water_pollution", "water-pollution-event"), ]) def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name): descriptor = read_work_order_skill(workflow) @@ -59,9 +60,20 @@ def test_scada_instructions_advance_candidate_analysis_independently_of_source_l assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果")) -def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch): +def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the_investigation(): + skill = read_work_order_skill("water_pollution", include_content=True) + text = skill["instructions"] + assert text.index("propose_task_action") < text.index("run_contaminant_simulation") + assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text + assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text + assert "模型曲线不填观测专用 chart.scada" in text + assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text + + +@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"]) +def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow): task_id, project_id = uuid4(), uuid4() - reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}}) + reader = AsyncMock(return_value={"task": {"workflow": workflow}}) monkeypatch.setattr(query_service.reads, "task_context_summary", reader) monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) scope = SimpleNamespace(connection=object(), project=object()) @@ -80,7 +92,15 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch body = query("skill") assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"] assert summary.data["display_time_zone"] == "Asia/Shanghai" - assert body.data["skill"]["workflow"] == "burst_report" + assert body.data["skill"]["workflow"] == workflow + for result in (summary, body): + assert ("source_trace_capability" in result.data) == (workflow == "water_pollution") + if workflow == "water_pollution": + assert result.data["source_trace_capability"]["tool"] == "run_contaminant_source_trace" + capability = result.data["source_trace_capability"] + assert capability["work_order_wait_blocks_analysis"] is False + assert capability["simulation_parameters"] == {"include_impact_coverage": True} + assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation") assert body.data["skill"]["instructions"] and not body.truncated assert 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) diff --git a/tests/unit/test_operational_recovery.py b/tests/unit/test_operational_recovery.py index 6c28235..7b95a7a 100644 --- a/tests/unit/test_operational_recovery.py +++ b/tests/unit/test_operational_recovery.py @@ -22,6 +22,25 @@ def observations(): return rows +def test_pollution_field_feedback_resumes_review_without_pressure_recovery(): + task = {"workflow": "water_pollution"} + for order_id in ("sampling", "flush"): + assert track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), + "execution_status": "succeeded", "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}}) + feedback = {"source_event_id": "peripheral_sample:sampling", "status": "completed", "source_revision": 2, + "occurred_at": RETURNED.isoformat(), "evidence": {"evidence_id": "sampling:2"}} + accept_completion(task, feedback) + assert task["handling_context"]["phase"] == "waiting_work_order" + accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:flush", "evidence": {"evidence_id": "flush:2"}}) + assert task["handling_context"]["phase"] == "field_review" + assert "verification_evidence_id" not in task["handling_context"] + assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:2" + task["handling_context"]["phase"] = "completed" + accept_completion(task, {**feedback, "source_revision": 3, "evidence": {"evidence_id": "sampling:3"}}) + assert task["handling_context"]["phase"] == "field_review" + assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:3" + + def evaluate(rows): return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED, incident_at=INCIDENT, interval_seconds=900) diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index 6dee6bd..95959bc 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -675,6 +675,19 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity)) assert again.task.execution_generation == 2 assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1 + notices = [item for item in repository.notifications if item["kind"] == "work_order_feedback"] + assert len(notices) == 1 + assert notices[0]["subject_id"] == str(action.action_id) + assert notices[0]["work_order_action"]["receipt"]["receipt_id"] == child_id + review_notice = next(item for item in repository.notifications if item["kind"] == "action_approval_pending") + assert review_notice["work_order_action"]["approval_status"] == "pending" + assert review_notice["work_order_action"]["receipt"] is None + assert notices[0]["work_order_reference"] == { + "source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2, + "evidence_id": None, "status": "completed", + } + assert any(event["topic"] == "operational.notification.changed" + and event["metadata"].get("kind") == "work_order_feedback" for event in repository.events) repository.task.update(status="cancelled", lifecycle_status="cancelled") with pytest.raises(DomainError) as cancelled: await service.external_feedback(task["task_id"], ExternalFeedbackCommand( diff --git a/tests/unit/test_pollution_event_workflow.py b/tests/unit/test_pollution_event_workflow.py new file mode 100644 index 0000000..8420f1f --- /dev/null +++ b/tests/unit/test_pollution_event_workflow.py @@ -0,0 +1,139 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.domain.domain_errors import DomainError +from app.domain.schemas.operational_data import TaskDataSnapshot +from app.domain.schemas.operational_task import ( + ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReceipt, + AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand, +) +from app.services.operational_recovery import track_work_order +from app.services.operational_tasks import OperationalTaskService +from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document + + +@pytest.mark.anyio +async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulation_runs(): + from types import SimpleNamespace + from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE + from app.infra.integrations.operational_actions import ActionDispatchOutcome + + now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task["workflow"] = "water_pollution" + repository = TaskRepository(task, _active_run(task, now)) + repository.pending_calculation_jobs = AsyncMock(return_value=["pollution-simulation"]) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "候选源核验", + "current_conclusion": "候选节点已取得,可先派现场核查", "conclusion_boundary": "污染传播计算中", + "evidence_refs": [], "recommendations": ["采样核验"], "pending_items": ["传播模拟"]} + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=report)) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="先核验候选源", + payload={"title": "候选节点现场核查", "description": "核查污染进入路径并采样", "work_order_type": "general"})) + with pytest.raises(DomainError) as denied: + await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version, decision="approved")) + assert denied.value.code == "PERMISSION_DENIED" + service = OperationalTaskService(repository, context, + {OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now) + approved = await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version, decision="approved")) + dispatcher = SimpleNamespace(dispatch=AsyncMock(return_value=ActionDispatchOutcome("succeeded", "现场工单已创建", + ActionReceipt(adapter="peripheral_sample", receipt_id=str(uuid4()), submitted_at=now, + completed_at=now, external_status="open", detail="创建成功")))) + with pytest.raises(DomainError) as stale: + await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand( + request_id=uuid4(), expected_version=action.version), dispatcher, None) + assert stale.value.code == "VERSION_CONFLICT" + dispatched = await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand( + request_id=uuid4(), expected_version=approved.version), dispatcher, None) + assert dispatched.execution_status == "succeeded" + assert repository.task["handling_context"]["phase"] == "waiting_work_order" + assert repository.run["status"] == "running" and repository.task["execution_generation"] == 1 + dispatcher.dispatch.assert_awaited_once() + repository.pending_calculation_jobs.assert_not_awaited() # No simulation dependency on dispatch. + + with pytest.raises(DomainError) as pending: + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"})) + assert pending.value.code == "PENDING_CALCULATIONS" + repository.pending_calculation_jobs.return_value = [] + evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot( + source="run_contaminant_simulation", source_ref="calculation:pollution", queried_at=now, + origin="synthetic", truncated=False, data={"result": {"impact": {"affected_node_count": 2}}})) + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report={**report, + "current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id], + "pending_items": [f"等待现场工单反馈,action_id={action.action_id}"]})) + assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version + assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_pollution_report_waits_for_field_feedback_and_requires_latest_feedback_evidence(monkeypatch): + now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task.update(workflow="water_pollution", source={"kind": "external_system", "external_id": "scada:quality:1", + "external_revision": 1, "event_kind": "water_pollution"}) + repository = TaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock()) + scada, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot( + source="query_scada_readings", source_ref="mcp:quality", queried_at=now, + origin="system_data", truncated=False, data={ + "window": {"start": (now - timedelta(hours=3)).isoformat(), "end": now.isoformat()}, + "data": {"devices": [], "readings": [], "coverage": {"mode": "all_scada", "query_complete": True}}, + })) + report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "水污染事件调查", + "current_conclusion": "候选分析完成,待采样与处置反馈", "conclusion_boundary": "未确诊污染源", + "evidence_refs": [scada.evidence_id], "recommendations": ["现场采样复测"], "pending_items": ["现场反馈"]} + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=report)) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="采样和处置核验", + payload={"title": "节点采样复测", "description": "回传位置、时间、数值和判定依据", "work_order_type": "general"})) + child = str(uuid4()) + repository.action = action.model_copy(update={"approval_status": "approved", "execution_status": "succeeded"}).model_dump(mode="json") + repository.action["receipt"] = {"adapter": "peripheral_sample", "receipt_id": child, + "submitted_at": now.isoformat(), "completed_at": now.isoformat(), "external_status": "open", "detail": "创建成功"} + track_work_order(repository.task, repository.action) + result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert result.status == "waiting_human" + service._resolve_investigation_alerts.assert_not_awaited() + + evidence_id = "field-quality:2" + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{child}", source_revision=2, + status="completed", occurred_at=now, detail="采样、处置与复测反馈已返回", + evidence={"evidence_id": evidence_id, "kind": "external_feedback", "summary": "现场记录及复测结果", + "source_label": "现场反馈", "source_ref": f"peripheral_sample:{child}", "observed_at": now})) + assert repository.task["handling_context"]["phase"] == "field_review" + assert "last_check" not in repository.task["handling_context"] # No pressure recovery shortcut. + await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"], source_session_id="session-1")) + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"})) + with pytest.raises(DomainError) as rejected: + await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert rejected.value.code == "POLLUTION_FEEDBACK_REQUIRED" + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final", + "evidence_refs": [scada.evidence_id, evidence_id], "pending_items": []})) + result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1")) + assert result.status == "completed" and result.handling_context["phase"] == "completed" + service._resolve_investigation_alerts.assert_awaited_once_with(task["task_id"], evidence_id) diff --git a/tests/unit/test_scada_presentation_coverage.py b/tests/unit/test_scada_presentation_coverage.py index 4854b03..17e2585 100644 --- a/tests/unit/test_scada_presentation_coverage.py +++ b/tests/unit/test_scada_presentation_coverage.py @@ -85,6 +85,25 @@ async def investigation(): return task, repository, service, evidence.evidence_id +@pytest.mark.anyio +async def test_pollution_charts_validate_quality_values_and_keep_their_own_workflow(): + from app.services.scada_presentation_coverage import add_targets, applies, ScadaObservations, verify_chart + task, repository, _, evidence_id = await investigation() + task["workflow"] = "water_pollution" + evidence = repository.evidence[evidence_id] + for device in evidence["data_snapshot"]["data"]["data"]["devices"]: + device.update(device_type="quality", measurement_unit="mg/L") + value = chart(devices=[DEVICES[0]]) + value["scada"].update(metric="quality", unit="mg/L") + assert applies(task) + verified = verify_chart(task, value, [evidence], [evidence_id]) + assert verified["metric"] == "quality" and verified["gaps"] == [] + observations = ScadaObservations(task, [evidence]) + assert add_targets(None, [DEVICES[0]], observations)["targets"] == [DEVICES[0]] + task["handling_context"] = {"phase": "field_review"} + assert not applies(task) + + def coverage(comparison_devices): slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)] common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []} diff --git a/tests/unit/test_scada_replay_service.py b/tests/unit/test_scada_replay_service.py index 995d135..169a2bc 100644 --- a/tests/unit/test_scada_replay_service.py +++ b/tests/unit/test_scada_replay_service.py @@ -807,6 +807,60 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke asyncio.run(run()) +@pytest.mark.parametrize("create_tasks", [False, True]) +def test_pollution_detection_creates_own_investigation_even_during_period_diagnosis(monkeypatch, create_tasks): + from app.services.scada_normal_profile import diagnostic_signal, pollution_signal + from app.services.scada_replay_processing import detect_frame + + _, service, *_ = setup(monkeypatch) + push = AsyncMock() + create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) + advance = AsyncMock() + monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object()) + monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push)) + monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) + monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", + lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance)) + config = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1), + device_ids=["Q1"], rules=[{"rule_id": "chlorine", "device_ids": ["Q1"], + "kind": "bounds", "lower": 0.1, "unit": "mg/L", "metric_name": "余氯"}]) + document = {"replay_id": str(uuid4()), "config": config.model_dump(mode="json"), + "devices": [{"device_id": "Q1", "device_type": "quality", "transmission_frequency": "0:15:00"}]} + + async def run(): + nonlocal document + # One continuous quality episode creates exactly one task, without any + # pressure roles, burst score or complaint. + for step, value in enumerate((0.05, 0.04, 0.2, 0.3)): + at = START + timedelta(minutes=15 * step) + document, changes = detect_frame(document, [{"device_id": "Q1", "time": at.isoformat(), + "quality": "valid", "cleaned_value": value, "monitored_value": value}], config, at) + for event in changes: + assert pollution_signal(document, event) + assert not diagnostic_signal(document, event) + await ScadaReplayService._create_investigations(service, document, changes, + {"sequence": step + 1, "at": at.isoformat()}, create_tasks=create_tasks) + create_task.assert_awaited_once() + command = create_task.await_args.args[0] + assert command.workflow == command.source.event_kind == "water_pollution" + assert command.task_type == "event_investigation" + assert command.source.replay_context.batch_sequence == 1 + assert command.trigger.kind == "event" + assert "投诉" not in command.title and "爆管" not in command.summary + assert [call.args[0].event_type for call in push.await_args_list] == ["raised", "resolved"] + assert all(call.args[0].condition_id == f"operational-task:{create_task.return_value.task_id}" + for call in push.await_args_list) + advance.assert_awaited_once() + asyncio.run(run()) + + +def test_invalid_quality_data_is_not_a_pollution_detection(): + from app.services.scada_normal_profile import pollution_signal + document = {"devices": [{"device_id": "Q1", "device_type": "quality"}]} + assert not pollution_signal(document, {"category": "data_quality", "device_ids": ["Q1"]}) + assert not pollution_signal(document, {"device_ids": ["unknown"]}) + + def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch): repo, service, _, _, create = setup(monkeypatch) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))