fix: resume incomplete SCADA presentations

This commit is contained in:
2026-11-12 10:38:55 +08:00
parent db87323b69
commit 0be30b80bb
12 changed files with 420 additions and 5 deletions
+2
View File
@@ -55,6 +55,8 @@ SCADA地图定位、等级标记、表格与图表采用同一目标设备集合
同图最多100横轴槽、8组序列,每组与x_data对齐。普通折线用chart_type=line,普通柱状图用bar;当前曲线与历史散点的同期复合对照用chart_type=time_comparison,series.type分别为line和scatter。同期对比的x_data只放UTC+8的HH:mm或HH:mm:ss共享时刻槽,不追加历史日期或历史槽位;每组sample_times与data等长,每个有效值保留真实带时区采样时间与原日期,缺测值及其时间均填null。当前曲线使用同一窗口,历史每组来自此前某一天的对应窗口,历史点填到同一时刻槽;跨午夜时按窗口时间顺序排列。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。
SCADA监测过程图填写chart.scada={role:current或comparison,metric:pressure或flow,unit,window_start,window_end},窗口为本轮完整三小时,截止非整刻仍按真实采样网格;超过100槽时连续分段并用不同稳定chart_id,分段scada窗口保持完整三小时,Server合并采样槽校验覆盖;历史散点仅保留完整窗口末三个采样槽;低频设备无计划采样槽时完整查询对应当天及历史窗口,以截止时刻单个null保留入口;每条series显式填写device_id和day_offset(当天0、此前七天1至7)。同期图逐设备保留当天和七天共8组,证据引用覆盖全部当前与历史采样;value_field默认monitored_value。缺测需完整查询证明,再保留null,Server生成scada.gaps;仅供参考的设备仍要展示。报告scada_device_ids列出监测明细表和展示目标的完整设备集合。Server按本轮累计目标校验当前总览及同期图,SCADA_PRESENTATION_INCOMPLETE表示必须按返回设备补图,不能删除目标、关闭窗口或声明不参与结论来绕过;报告和成功结束均校验。浏览器连接及显示回执不参与这项产物校验。
覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。
对象ID、数量、图层与样式遵循schema,坐标使用受控来源。展示失败时修正调用,仍失败则记录具体限制并继续独立分析。recorded表示请求已保存,浏览器跟随、连接和暂停由前端处理,收尾依据分析结果与发布回执。
File diff suppressed because one or more lines are too long
@@ -42,6 +42,8 @@ const reportFields = {
conclusion_boundary: text,
evidence_refs: tool.schema.array(text).default([]),
ui_blocks: taskReportUiBlocksSchema,
scada_device_ids: tool.schema.array(identifier).max(1000).optional()
.describe("SCADA devices listed in monitoring tables or used as display targets, including reference-only devices. Server checks their current and seven-day chart coverage."),
};
export const taskPublicationReportSchema = tool.schema.discriminatedUnion("outcome", [
+174
View File
@@ -4032,6 +4032,23 @@
"title": "Recommendations",
"type": "array"
},
"scada_device_ids": {
"anyOf": [
{
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 1000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Scada Device Ids"
},
"stage": {
"anyOf": [
{
@@ -4144,6 +4161,23 @@
"title": "Recommendations",
"type": "array"
},
"scada_device_ids": {
"anyOf": [
{
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 1000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Scada Device Ids"
},
"stage": {
"enum": [
"interim",
@@ -7383,6 +7417,110 @@
"title": "RunSimulationManuallyByDateRest",
"type": "object"
},
"ScadaChartContext": {
"additionalProperties": false,
"properties": {
"gaps": {
"items": {
"$ref": "#/components/schemas/ScadaChartGap"
},
"maxItems": 8,
"title": "Gaps",
"type": "array"
},
"metric": {
"enum": [
"pressure",
"flow"
],
"title": "Metric",
"type": "string"
},
"role": {
"enum": [
"current",
"comparison"
],
"title": "Role",
"type": "string"
},
"unit": {
"maxLength": 128,
"minLength": 1,
"title": "Unit",
"type": "string"
},
"value_field": {
"default": "monitored_value",
"enum": [
"monitored_value",
"cleaned_value"
],
"title": "Value Field",
"type": "string"
},
"window_end": {
"format": "date-time",
"title": "Window End",
"type": "string"
},
"window_start": {
"format": "date-time",
"title": "Window Start",
"type": "string"
}
},
"required": [
"role",
"metric",
"unit",
"window_start",
"window_end"
],
"title": "ScadaChartContext",
"type": "object"
},
"ScadaChartGap": {
"additionalProperties": false,
"properties": {
"day_offset": {
"maximum": 7.0,
"minimum": 0.0,
"title": "Day Offset",
"type": "integer"
},
"device_id": {
"maxLength": 128,
"minLength": 1,
"title": "Device Id",
"type": "string"
},
"missing_count": {
"maximum": 100.0,
"minimum": 1.0,
"title": "Missing Count",
"type": "integer"
},
"reason": {
"enum": [
"no_readings",
"outside_frozen_scope",
"outside_frozen_window",
"no_scheduled_samples"
],
"title": "Reason",
"type": "string"
}
},
"required": [
"device_id",
"day_offset",
"missing_count",
"reason"
],
"title": "ScadaChartGap",
"type": "object"
},
"ScadaDeviceResponse": {
"description": "Project SCADA metadata keyed by the canonical device identifier.",
"properties": {
@@ -10450,6 +10588,16 @@
"title": "Chart Type",
"type": "string"
},
"scada": {
"anyOf": [
{
"$ref": "#/components/schemas/ScadaChartContext"
},
{
"type": "null"
}
]
},
"series": {
"items": {
"$ref": "#/components/schemas/TaskSafeChartSeries"
@@ -10527,6 +10675,32 @@
"title": "Data",
"type": "array"
},
"day_offset": {
"anyOf": [
{
"maximum": 7.0,
"minimum": 0.0,
"type": "integer"
},
{
"type": "null"
}
],
"title": "Day Offset"
},
"device_id": {
"anyOf": [
{
"maxLength": 128,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Device Id"
},
"name": {
"maxLength": 128,
"title": "Name",
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "7ed3f6485909c3ef7fac8d2083d6931765e4d197e690c3b9914b905a8aa743b2"
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
}
}
}
+25 -2
View File
@@ -10,6 +10,7 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
type PublicationChart = {
chart_type: string;
scada?: { role: "current" | "comparison"; window_start: string };
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
@@ -57,8 +58,12 @@ export function chartPublicationError(chart: PublicationChart): string | null {
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const start = [...current][0];
const emptyScada = current.size === 0 && chart.scada?.role === "comparison";
if (current.size !== 1 && !emptyScada) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const windowStart = emptyScada ? Date.parse(chart.scada!.window_start) + 8 * 3600_000 : 0;
const midnight = Math.floor(windowStart / 86400_000) * 86400_000;
const firstSlot = midnight + seconds[0] * 1000;
const start = emptyScada ? firstSlot + (firstSlot < windowStart ? 86400_000 : 0) : [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
@@ -68,9 +73,25 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const scadaChartSchema = z.object({
role: z.enum(["current", "comparison"]),
metric: z.enum(["pressure", "flow"]),
unit: presentationIdentifierSchema,
window_start: createChartSampleTimeSchema(z),
window_end: createChartSampleTimeSchema(z),
value_field: z.enum(["monitored_value", "cleaned_value"]).optional(),
// Populated by Server after checking observations, never a display exemption.
gaps: z.array(z.object({
device_id: presentationIdentifierSchema,
day_offset: z.number().int().min(0).max(7),
missing_count: z.number().int().min(1).max(100),
reason: z.enum(["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"])
}).strict()).max(8).optional()
}).strict();
const chartSchema = z
.object({
title: z.string().max(200),
scada: scadaChartSchema.optional(),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
@@ -80,6 +101,8 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida
z
.object({
name: z.string().max(128),
device_id: presentationIdentifierSchema.optional(),
day_offset: z.number().int().min(0).max(7).optional(),
type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
+101
View File
@@ -207,6 +207,97 @@
"type": "string",
"maxLength": 200
},
"scada": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": [
"current",
"comparison"
]
},
"metric": {
"type": "string",
"enum": [
"pressure",
"flow"
]
},
"unit": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"window_start": {
"type": "string",
"maxLength": 64,
"format": "date-time",
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
},
"window_end": {
"type": "string",
"maxLength": 64,
"format": "date-time",
"pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$"
},
"value_field": {
"type": "string",
"enum": [
"monitored_value",
"cleaned_value"
]
},
"gaps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"device_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"day_offset": {
"type": "integer",
"minimum": 0,
"maximum": 7
},
"missing_count": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"reason": {
"type": "string",
"enum": [
"no_readings",
"outside_frozen_scope",
"outside_frozen_window",
"no_scheduled_samples"
]
}
},
"required": [
"device_id",
"day_offset",
"missing_count",
"reason"
],
"additionalProperties": false
},
"maxItems": 8
}
},
"required": [
"role",
"metric",
"unit",
"window_start",
"window_end"
],
"additionalProperties": false
},
"chart_type": {
"type": "string",
"enum": [
@@ -241,6 +332,16 @@
"type": "string",
"maxLength": 128
},
"device_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"day_offset": {
"type": "integer",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"enum": [
+56
View File
@@ -7219,6 +7219,8 @@ export interface components {
pending_items?: string[];
/** Recommendations */
recommendations?: string[];
/** Scada Device Ids */
scada_device_ids?: string[] | null;
/** Stage */
stage?: ("interim" | "final") | null;
/** Stop Reason */
@@ -7251,6 +7253,8 @@ export interface components {
pending_items?: string[];
/** Recommendations */
recommendations?: string[];
/** Scada Device Ids */
scada_device_ids?: string[] | null;
/**
* Stage
* @enum {string}
@@ -8505,6 +8509,53 @@ export interface components {
*/
start_time: string;
};
/** ScadaChartContext */
ScadaChartContext: {
/** Gaps */
gaps?: components["schemas"]["ScadaChartGap"][];
/**
* Metric
* @enum {string}
*/
metric: "pressure" | "flow";
/**
* Role
* @enum {string}
*/
role: "current" | "comparison";
/** Unit */
unit: string;
/**
* Value Field
* @default monitored_value
* @enum {string}
*/
value_field: "monitored_value" | "cleaned_value";
/**
* Window End
* Format: date-time
*/
window_end: string;
/**
* Window Start
* Format: date-time
*/
window_start: string;
};
/** ScadaChartGap */
ScadaChartGap: {
/** Day Offset */
day_offset: number;
/** Device Id */
device_id: string;
/** Missing Count */
missing_count: number;
/**
* Reason
* @enum {string}
*/
reason: "no_readings" | "outside_frozen_scope" | "outside_frozen_window" | "no_scheduled_samples";
};
/**
* ScadaDeviceResponse
* @description Project SCADA metadata keyed by the canonical device identifier.
@@ -9656,6 +9707,7 @@ export interface components {
* @enum {string}
*/
chart_type: "line" | "bar" | "time_comparison";
scada?: components["schemas"]["ScadaChartContext"] | null;
/** Series */
series: components["schemas"]["TaskSafeChartSeries"][];
/** Title */
@@ -9671,6 +9723,10 @@ export interface components {
TaskSafeChartSeries: {
/** Data */
data: (number | null)[];
/** Day Offset */
day_offset?: number | null;
/** Device Id */
device_id?: string | null;
/** Name */
name: string;
/** Sample Times */
+1
View File
@@ -107,6 +107,7 @@ export const taskReportSchema = z.object({
data_gaps: z.array(dataGapSchema),
diagram: investigationDiagramSchema.nullish(),
ui_blocks: z.array(reportUiBlockSchema).max(8).optional(),
scada_device_ids: z.array(text.max(128)).max(1000).optional(),
}).strict().superRefine((report, ctx) => {
const invalid = (message: string, path: (string | number)[] = []) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, message, path });
+6
View File
@@ -404,6 +404,12 @@ export class OperationalTaskClientError extends Error {
}
}
/** Feed a concrete Server coverage rejection back into the same active investigation. */
export function scadaPresentationContinuation(error: unknown): string | null {
if (!(error instanceof OperationalTaskClientError) || error.code !== "SCADA_PRESENTATION_INCOMPLETE") return null;
return `Server尚未接受本轮调查结束:${error.message}\n请按返回设备补充publish_task_presentation监测图,必要时更新publish_task_report。保留目标设备与真实缺测说明,完成后再结束本轮。`;
}
const parseJson = (text: string): unknown => {
if (!text.trim()) return {};
try {
+4 -1
View File
@@ -45,6 +45,7 @@ import {
clearOperationalTaskLease,
operationalTaskClient,
releaseOperationalTaskCredential,
scadaPresentationContinuation,
} from "../product/operational-task-client.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
@@ -798,7 +799,7 @@ export const registerChatStreamRoute = (
if (messaging && (messaging.hasInputThisTurn(binding.sessionId) || !await messaging.closeInteractive(activeSessionRecord))) return false;
return inputTracker.canClose();
},
beforeIdle: messaging || (sessionPurpose === "task_execution" && waitTaskCalculations) ? async () => {
beforeIdle: messaging || sessionPurpose === "task_execution" ? async () => {
if (sessionPurpose !== "task_execution") return messaging ? messaging.closeInteractive(activeSessionRecord) : true;
if (await messaging?.flush(binding.sessionId)) return false;
const calculationResume = await waitTaskCalculations?.(binding.sessionId, abortController.signal);
@@ -818,6 +819,8 @@ export const registerChatStreamRoute = (
if (task.lifecycle_status === "failed") throw new Error("Server rejected incomplete investigation artifacts");
return true;
} catch (error) {
const continuation = scadaPresentationContinuation(error);
if (continuation) return continuation;
if (error && typeof error === "object" && "code" in error && error.code === "PENDING_AGENT_MESSAGES") {
await messaging?.flush(binding.sessionId);
return false;
+47
View File
@@ -0,0 +1,47 @@
import { expect, it } from "bun:test";
import { taskPresentationCommandSchema, type TaskPresentationCommand } from "../../src/product/task-presentation.js";
import { taskPublicationCommandSchema, taskPublicationReportSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskReportSchema } from "../../src/product/investigation-report.js";
import { OperationalTaskClientError, scadaPresentationContinuation } from "../../src/product/operational-task-client.js";
it("preserves SCADA device identities through native and host publication boundaries", () => {
const command: TaskPresentationCommand = { action: "open_chart", chart_id: "flow-overview", evidence_refs: ["observations"], chart: {
title: "流量当前趋势", chart_type: "line", x_data: ["05:00", "05:15"],
scada: { role: "current", metric: "flow", unit: "m3/h", window_start: "2026-07-16T05:00:00+08:00", window_end: "2026-07-16T08:00:00+08:00" },
series: [{ name: "524529", device_id: "524529", day_offset: 0, type: "line", data: [-6.8, 303.3],
sample_times: ["2026-07-16T05:00:00+08:00", "2026-07-16T05:15:00+08:00"] }],
} };
expect(taskPresentationCommandSchema.parse(command)).toEqual(command);
expect(taskPublicationCommandSchema.parse(command)).toEqual(command);
const report = { title: "调查", current_conclusion: "524529仅作参考", conclusion_boundary: "待复核",
stage: "final", outcome: "concluded", evidence_refs: ["observations"], data_gaps: [], scada_device_ids: ["524529"] };
expect(taskReportSchema.parse(report).scada_device_ids).toEqual(["524529"]);
expect(taskPublicationReportSchema.parse(report).scada_device_ids).toEqual(["524529"]);
});
it("continues an incomplete SCADA presentation with the exact missing device instead of retrying other failures", () => {
const rejection = new OperationalTaskClientError(409, "缺少七天同期对比设备:524529", "SCADA_PRESENTATION_INCOMPLETE");
expect(scadaPresentationContinuation(rejection)).toContain("524529");
expect(scadaPresentationContinuation(rejection)).toContain("publish_task_presentation");
expect(scadaPresentationContinuation(new OperationalTaskClientError(403, "拒绝", "PERMISSION_DENIED"))).toBeNull();
expect(scadaPresentationContinuation(new Error("transport failed"))).toBeNull();
});
it("keeps all-null current chunks aligned to their own first slot across midnight", () => {
for (const [start, end, labels, times] of [
["2026-07-16T05:08:00+08:00", "2026-07-16T08:08:00+08:00", ["07:45", "08:00"],
["2026-07-15T07:45:00+08:00", "2026-07-15T08:00:00+08:00"]],
["2026-07-15T22:08:00+08:00", "2026-07-16T01:08:00+08:00", ["00:45", "01:00"],
["2026-07-15T00:45:00+08:00", "2026-07-15T01:00:00+08:00"]],
] as const) {
const command = { action: "open_chart", chart_id: "chunk", evidence_refs: ["observations"], chart: {
title: "流量同期分段", chart_type: "time_comparison", x_data: labels,
scada: { role: "comparison", metric: "flow", unit: "m3/h", window_start: start, window_end: end },
series: [{ name: "current", type: "line", data: [null, null], sample_times: [null, null] },
{ name: "history", type: "scatter", data: [1, 2], sample_times: times }],
} };
expect(taskPresentationCommandSchema.safeParse(command).success).toBe(true);
expect(taskPublicationCommandSchema.safeParse(command).success).toBe(true);
}
});