101 lines
4.8 KiB
TypeScript
101 lines
4.8 KiB
TypeScript
import { z } from "zod";
|
|
import { supplyLayerSchema } from "./task-presentation.js";
|
|
import { answerUiBlockSchema } from "../chat/answer-ui.js";
|
|
|
|
const text = z.string().trim().min(1);
|
|
export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
|
|
const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(
|
|
`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`,
|
|
"m",
|
|
).test(source);
|
|
}
|
|
|
|
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([]),
|
|
}).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: "调查图节点引用重复" });
|
|
}
|
|
diagram.node_refs.forEach((node, index) => {
|
|
if (!mermaidDeclaresNodeId(diagram.mermaid, node.node_id)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["node_refs", index, "node_id"],
|
|
message: `调查图节点引用 ${node.node_id} 未在 Mermaid 源码中声明`,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
export const taskProgressSchema = z.object({
|
|
summary: text.max(20000),
|
|
diagram: investigationDiagramSchema.nullable().default(null),
|
|
}).strict();
|
|
|
|
export type TaskProgress = z.infer<typeof taskProgressSchema>;
|
|
const dataGapSchema = z.object({
|
|
gap_id: text,
|
|
node_id: text.nullish(),
|
|
missing_data: text,
|
|
scope: text,
|
|
impact: text,
|
|
required_data: text,
|
|
evidence_refs: z.array(text).min(1),
|
|
blocking: z.boolean()
|
|
}).strict();
|
|
|
|
/** New report writes require explicit business outcomes; storage reads remain compatible. */
|
|
export const taskReportSchema = z.object({
|
|
title: text,
|
|
current_conclusion: text,
|
|
conclusion_boundary: text,
|
|
evidence_refs: z.array(text).default([]),
|
|
recommendations: z.array(text).default([]),
|
|
pending_items: z.array(text).default([]),
|
|
stage: z.enum(["interim", "final"]),
|
|
outcome: z.enum(["concluded", "insufficient_data"]),
|
|
stop_reason: text.nullable().default(null),
|
|
data_gaps: z.array(dataGapSchema),
|
|
diagram: investigationDiagramSchema.nullish(),
|
|
ui_blocks: z.array(z.object({
|
|
block: answerUiBlockSchema,
|
|
evidence_refs: z.array(text.max(128)).min(1).max(100),
|
|
}).strict()).max(8).optional(),
|
|
}).strict().superRefine((report, ctx) => {
|
|
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
const blocks = report.ui_blocks ?? [];
|
|
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
|
|
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
|
|
for (const item of blocks) {
|
|
if (item.block.phase === "process") invalid("正式报告只能包含 final 界面块;旧版未标注阶段的块保持兼容");
|
|
if (item.evidence_refs.some((ref) => !report.evidence_refs.includes(ref))) invalid("界面依据必须属于报告依据");
|
|
for (const node of Object.values(item.block.spec.elements)) {
|
|
if (node.type === "Evidence" && !item.evidence_refs.includes(String(node.props.evidence_id ?? "")))
|
|
invalid("证据组件必须引用界面块的已有依据");
|
|
}
|
|
}
|
|
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) invalid("数据缺口编号重复");
|
|
const blocking = report.data_gaps.some((gap) => gap.blocking);
|
|
if (report.outcome === "insufficient_data") {
|
|
if (report.stage !== "final" || !report.stop_reason || !blocking) invalid("证据不足必须提供最终报告、结束原因和关键缺口");
|
|
if (report.recommendations.length || report.pending_items.length) invalid("证据不足退出后不再生成后续处置建议或待办");
|
|
} else if (report.stop_reason != null || blocking) invalid("已形成结论不能包含结束原因或阻塞缺口");
|
|
});
|
|
|
|
export type TaskReport = z.infer<typeof taskReportSchema>;
|