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:
@@ -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" }>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Vendored
+54
-3
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user