diff --git a/.opencode/agents/investigation.md b/.opencode/agents/investigation.md index 106bf5b..aed1a2e 100644 --- a/.opencode/agents/investigation.md +++ b/.opencode/agents/investigation.md @@ -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表示请求已保存,浏览器跟随、连接和暂停由前端处理,收尾依据分析结果与发布回执。 diff --git a/.opencode/tools/publish_task_presentation.ts b/.opencode/tools/publish_task_presentation.ts index 29ebdd1..66f9516 100644 --- a/.opencode/tools/publish_task_presentation.ts +++ b/.opencode/tools/publish_task_presentation.ts @@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati import { taskPublicationCommandSchema } from "./task-publication-schema.js"; export default tool({ - description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For object switching within one metric/unit/window, keep one stable chart_id per device, title each chart as , and start every series name with followed by a space. Use identical x/y axis names, x_data and current sample_times across the group, with matching current-series names after removing the device prefix. The browser groups these object datasets into one child chart, with object buttons on the right and series legends below the plot; ordinary overview and bar charts stay separate. The browser manages all charts in one parent window and preserves manual child enlargement. Only new live commands may open the parent; task loading, switching, refresh, completed results and history restoration never replay popup intent. Do not republish charts to force windows open. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.", + description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA monitoring charts, include chart.scada with role=current or comparison, metric=pressure or flow, unit, window_start and window_end for the full current three-hour task window. Each series must bind device_id and day_offset (0=current, 1..7=previous days). Each per-device comparison has all eight series. Omit scada.gaps: Server derives actual gaps from the referenced complete readings. evidence_refs must include all current and historical sources; unread history is not missing data. Reports declare all monitoring-table targets in scada_device_ids. Reference-only devices still require charts. Final reports and run completion reject SCADA_PRESENTATION_INCOMPLETE with missing device IDs; publish the missing charts and retry. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit; split more than 8 series by device and more than 100 slots into contiguous time chunks with distinct stable chart_ids. Keep chart.scada window_start/window_end at the full three-hour bounds in every chunk; Server unions verified slots. Keep the real sampling grid even if the cutoff is not aligned. Historical scatter uses only the final three sampling slots of the full window. If a low-frequency device has no scheduled slot, query each full current/historical window and retain one null slot at the cutoff. Explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For object switching within one metric/unit/window, keep one stable chart_id per device and time chunk, title each chart as , and start every series name with followed by a space. Use identical x/y axis names, x_data and current sample_times across the group, with matching current-series names after removing the device prefix. The browser groups these object datasets into one child chart, with object buttons on the right and series legends below the plot; ordinary overview and bar charts stay separate. The browser manages all charts in one parent window and preserves manual child enlargement. Only new live commands may open the parent; task loading, switching, refresh, completed results and history restoration never replay popup intent. Do not republish charts to force windows open. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.", args: { request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"), command: taskPublicationCommandSchema, diff --git a/.opencode/tools/task-publication-schema.ts b/.opencode/tools/task-publication-schema.ts index 72b09d8..282b954 100644 --- a/.opencode/tools/task-publication-schema.ts +++ b/.opencode/tools/task-publication-schema.ts @@ -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", [ diff --git a/openapi/openapi.json b/openapi/openapi.json index 5d5ea86..c30012b 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -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", diff --git a/openapi/server-manifest.json b/openapi/server-manifest.json index e930bd3..7f9dc13 100644 --- a/openapi/server-manifest.json +++ b/openapi/server-manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "7ed3f6485909c3ef7fac8d2083d6931765e4d197e690c3b9914b905a8aa743b2" + "sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb" } } } diff --git a/src/frontendAction/command.ts b/src/frontendAction/command.ts index 9c40621..ed9df64 100644 --- a/src/frontendAction/command.ts +++ b/src/frontendAction/command.ts @@ -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; sample_times?: Array }>; }; @@ -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() diff --git a/src/frontendAction/view-command.schema.json b/src/frontendAction/view-command.schema.json index 8064e81..e1f8dc9 100644 --- a/src/frontendAction/view-command.schema.json +++ b/src/frontendAction/view-command.schema.json @@ -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": [ diff --git a/src/generated/server-api.d.ts b/src/generated/server-api.d.ts index b689563..c659ea7 100644 --- a/src/generated/server-api.d.ts +++ b/src/generated/server-api.d.ts @@ -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 */ diff --git a/src/product/investigation-report.ts b/src/product/investigation-report.ts index 01ad6cd..301314e 100644 --- a/src/product/investigation-report.ts +++ b/src/product/investigation-report.ts @@ -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 }); diff --git a/src/product/operational-task-client.ts b/src/product/operational-task-client.ts index 8a22cb1..d617f7e 100644 --- a/src/product/operational-task-client.ts +++ b/src/product/operational-task-client.ts @@ -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 { diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index dff92e2..13efc96 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -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; diff --git a/tests/product/scada-presentation.test.ts b/tests/product/scada-presentation.test.ts new file mode 100644 index 0000000..7850e3d --- /dev/null +++ b/tests/product/scada-presentation.test.ts @@ -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); + } +});