feat(investigation): publish incremental reference graphs

Publish graph deltas using the Server-owned cumulative graph contract. Validate historical comparison chart publications and consolidate Agent documentation.
This commit is contained in:
2026-11-11 19:10:41 +08:00
parent 5ee93329a4
commit db87323b69
28 changed files with 2329 additions and 90 deletions
+64 -4
View File
@@ -8,8 +8,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
);
}
type PublicationChart = {
chart_type: string;
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
export function chartPublicationError(chart: PublicationChart): string | null {
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
const labels = chart.x_data;
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
const offsets = [0];
for (let index = 1; index < seconds.length; index++) {
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
offsets.push(offsets[index - 1] + step);
}
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
const current = new Set<number>();
const historical: number[] = [];
for (const series of chart.series) {
const times = series.sample_times;
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
const bases = new Set<number>();
for (let index = 0; index < times.length; index++) {
const at = times[index];
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
if (at === null) continue;
const local = new Date(Date.parse(at) + 8 * 3600_000);
const base = local.getTime() - offsets[index] * 1000;
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
return "sample_times 超出同期对比可表达的日期范围";
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
bases.add(base);
}
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
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];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
@@ -40,7 +95,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
(!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data"
);
).superRefine((value, ctx) => {
if (!validateCharts) return;
const message = chartPublicationError(value);
if (message) ctx.addIssue({ code: "custom", message });
});
const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite())
@@ -214,8 +273,9 @@ export const {
overlayDataSchema,
variants: viewCommandVariants
} = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) =>
createViewSchemas(z, options.publication).command;
export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }>
+8 -2
View File
@@ -1,11 +1,17 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { viewCommandSchema } from "./command.js";
import { chartPublicationError, viewCommandSchema } from "./command.js";
import { logger } from "../logger.js";
import { getFrontendActionDefinition } from "./registry.js";
import type { FrontendActionRequest, FrontendActionResult } from "./types.js";
const publicationCommandSchema = viewCommandSchema.superRefine((command, context) => {
if (command.action !== "open_chart") return;
const message = chartPublicationError(command.chart);
if (message) context.addIssue({ code: "custom", message, path: ["chart"] });
});
export const frontendActionResultSchema = z.object({
version: z.literal("frontend-action-result@2"),
actionId: z.string().trim().min(1).max(128),
@@ -68,7 +74,7 @@ export class FrontendActionCoordinator {
signal?: AbortSignal;
}): Promise<FrontendActionResult> {
if (!options.toolCallId.trim()) throw new FrontendActionError(400, "frontend action call ID is required");
const parsedParams = viewCommandSchema.safeParse(options.command);
const parsedParams = publicationCommandSchema.safeParse(options.command);
if (!parsedParams.success) throw new FrontendActionError(400, "invalid view command");
const definition = getFrontendActionDefinition(parsedParams.data.action);
if (!definition) throw new FrontendActionError(400, "unknown frontend action");
+54 -3
View File
@@ -5972,7 +5972,7 @@ export interface components {
/** Evidence */
evidence?: components["schemas"]["EvidenceInput"][];
plan?: components["schemas"]["ExecutionPlanInput"] | null;
progress?: components["schemas"]["InvestigationProgress"] | null;
progress?: components["schemas"]["InvestigationProgressInput"] | null;
report?: components["schemas"]["InvestigationReportInput"] | null;
/**
* Request Id
@@ -7084,10 +7084,14 @@ export interface components {
};
/** InvestigationDiagram */
InvestigationDiagram: {
/** Edges */
edges?: components["schemas"]["InvestigationGraphEdge"][] | null;
/** Mermaid */
mermaid: string;
/** Node Refs */
node_refs?: components["schemas"]["InvestigationDiagramNodeRef"][];
/** Source Mermaid */
source_mermaid?: string | null;
/** Title */
title: string;
};
@@ -7095,6 +7099,8 @@ export interface components {
InvestigationDiagramNodeRef: {
/** Action Refs */
action_refs?: string[];
/** Detail */
detail?: string | null;
/** Evidence Refs */
evidence_refs?: string[];
/** Label */
@@ -7105,9 +7111,47 @@ export interface components {
node_id: string;
/**
* Status
* @description Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors.
* @description A saved process-reference annotation, not live execution state. recorded means a finding exists, not that a task or field action completed.
*/
status?: ("pending" | "running" | "completed" | "waiting" | "waiting_human" | "failed" | "skipped" | "cancelled") | null;
status?: ("reference" | "recorded" | "needs_input" | "failed" | "not_taken") | null;
};
/** InvestigationGraphEdge */
InvestigationGraphEdge: {
/** Label */
label?: string | null;
/** Source */
source: string;
/** Target */
target: string;
};
/** InvestigationGraphNodeUpdate */
InvestigationGraphNodeUpdate: {
/** Action Refs */
action_refs?: string[];
/** Detail */
detail?: string | null;
/** Evidence Refs */
evidence_refs?: string[];
/** Label */
label: string;
/** Map Targets */
map_targets?: components["schemas"]["InvestigationMapTarget"][];
/** Node Id */
node_id: string;
/**
* Status
* @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.
*/
status?: ("reference" | "recorded" | "needs_input" | "failed" | "not_taken") | null;
};
/** InvestigationGraphUpdate */
InvestigationGraphUpdate: {
/** Edges */
edges?: components["schemas"]["InvestigationGraphEdge"][];
/** Nodes */
nodes?: components["schemas"]["InvestigationGraphNodeUpdate"][];
/** Title */
title?: string | null;
};
/** InvestigationMapTarget */
InvestigationMapTarget: {
@@ -7128,6 +7172,13 @@ export interface components {
/** Summary */
summary: string;
};
/** InvestigationProgressInput */
InvestigationProgressInput: {
diagram?: components["schemas"]["InvestigationDiagram"] | null;
graph_update?: components["schemas"]["InvestigationGraphUpdate"] | null;
/** Summary */
summary: string;
};
/** InvestigationProgressSnapshot */
InvestigationProgressSnapshot: {
/** Execution Generation */
+13
View File
@@ -0,0 +1,13 @@
export const investigationNodeAnnotations = ["reference", "recorded", "needs_input", "failed", "not_taken"] as const;
export type InvestigationNodeAnnotation = (typeof investigationNodeAnnotations)[number];
export const legacyInvestigationNodeAnnotations = ["pending", "running", "completed", "waiting", "waiting_human", "skipped", "cancelled"] as const;
const legacyAnnotations = {
pending: "reference", running: "reference", completed: "recorded",
waiting: "needs_input", waiting_human: "needs_input", skipped: "not_taken", cancelled: "not_taken",
} as const satisfies Record<string, InvestigationNodeAnnotation>;
export function readInvestigationNodeAnnotation(status: string | null | undefined): InvestigationNodeAnnotation | null | undefined {
if (status == null) return status;
if (status in legacyAnnotations) return legacyAnnotations[status as keyof typeof legacyAnnotations];
return status as InvestigationNodeAnnotation;
}
+33 -12
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { supplyLayerSchema } from "./task-presentation.js";
import { answerUiBlockSchema } from "../chat/answer-ui.js";
import { investigationNodeAnnotations, legacyInvestigationNodeAnnotations, readInvestigationNodeAnnotation } from "./investigation-node-state.js";
const text = z.string().trim().min(1);
export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
@@ -11,23 +12,41 @@ export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
).test(source);
}
export const investigationGraphNodeSchema = z.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
detail: text.max(2000).nullish(),
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(investigationNodeAnnotations).nullish(),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
}).strict()).max(100).default([]),
}).strict();
const graphEdgeSchema = z.object({
source: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
target: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text.max(200).nullish(),
}).strict();
export const investigationGraphUpdateSchema = z.object({
title: text.max(200).nullish(),
nodes: z.array(investigationGraphNodeSchema).max(100).default([]),
edges: z.array(graphEdgeSchema).max(200).default([]),
}).strict();
export const investigationDiagramSchema = z.object({
title: text,
mermaid: text.max(20000).refine((source) =>
/^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source)
&& !source.includes("```") && !source.includes("%%{") && !/(?:^|[;\n])\s*click\b/.test(source),
"调查图只接受不含指令或回调的 Mermaid 流程描述"),
node_refs: z.array(z.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish().describe("Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors."),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
}).strict()).max(100).default([]),
}).strict()).max(100).default([]),
node_refs: z.array(investigationGraphNodeSchema.extend({
status: z.enum([...investigationNodeAnnotations, ...legacyInvestigationNodeAnnotations]).nullish()
.transform(readInvestigationNodeAnnotation),
})).max(100).default([]),
edges: z.array(graphEdgeSchema).max(200).nullish(),
source_mermaid: text.max(20000).nullish(),
}).strict().superRefine((diagram, ctx) => {
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
@@ -45,7 +64,9 @@ export const investigationDiagramSchema = z.object({
export const taskProgressSchema = z.object({
summary: text.max(20000),
diagram: investigationDiagramSchema.nullable().default(null),
}).strict();
graph_update: investigationGraphUpdateSchema.nullish(),
}).strict().refine((progress) => !(progress.diagram && progress.graph_update),
"Use graph_update without a generated Mermaid diagram");
export type TaskProgress = z.infer<typeof taskProgressSchema>;
const dataGapSchema = z.object({