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
+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;