/** * 人类工具的卡协议(编码规范 §8.1 / §9;语义出处 OS 05,AM 06/07)。 * * **双层边界**:范式(loop / flow / skill)是后端实现逻辑的模型,只存在于引擎层; * 本文件是**前端呈现协议**,前端永远感知不到范式。旧文「四卡」的映射: * 执行卡 → 执行工单(work order · execution) * 放行卡 → 验收(工作区可修订报告 + 对话区验收单)(work order · acceptance) * 决策 / 补全 → 对话卡(decision / elicitation) * 落地失败卡 = 执行工单的系统预置形态,不构成第五种卡(OS 7.3)。 * * 形状以 2026-07-26 原型定稿为准(`~/coding/JCAI_PRD/workbench/_data/model.ts`); * 枚举值改用英文标识符 + 本文件的 *_LABEL 字典,界面文案由字典给出、与原型逐字一致。 */ import { z } from 'zod'; import { cardIdSchema, deliverableIdSchema, stageIdSchema, staffIdSchema, workOrderIdSchema, } from './id.ts'; import { acceptanceLevelSchema, rejectionKindSchema } from './terminal.ts'; /* ── 基础原语 ─────────────────────────────────────────────────────────── */ export const TONES = ['ok', 'warn', 'danger', 'brand', 'muted'] as const; export const toneSchema = z.enum(TONES); export type Tone = z.infer; /** 依据快照:调用必附上下文(OS 05)—— 裸调用基座必须拒发 */ export const basisSchema = z.array(z.object({ k: z.string(), v: z.string() })); export type Basis = z.infer; export const formOptionSchema = z.object({ value: z.string(), label: z.string(), negative: z.boolean().optional(), }); export type FormOption = z.infer; export const FORM_FIELD_KINDS = [ 'text', 'textarea', 'radio', 'number', 'select', 'check', 'date', 'money', 'file', ] as const; export const formFieldKindSchema = z.enum(FORM_FIELD_KINDS); export type FormFieldKind = z.infer; /** 表单字段:结构化交互的原子。卡片表单由本 schema 驱动渲染与校验,后端回传校验同 schema(§9.2) */ export const formFieldSchema = z.object({ key: z.string().min(1), label: z.string(), kind: formFieldKindSchema, placeholder: z.string().optional(), options: z.array(formOptionSchema).optional(), /** 字段分组(渲染为表单小节) */ group: z.string().optional(), hint: z.string().optional(), /** AI 拟值 —— 验收工单落地时已填好,人可改全部字段 */ ai: z.string().optional(), /** * 这个值改完**落回哪个台账地址**(`域.对象`)—— 只有验收单的修订字段需要。 * * 没有它,人在验收报告上改的值只留在工单留痕里,落地时用的还是 AI 的原值: * 「验收报告可编辑」就成了摆设(第 12 章③)。 * 口径:只改**本单在该对象的那一行**;该对象有多行时不猜是哪一行,如实拒改。 */ ledger: z.string().optional(), unit: z.string().optional(), required: z.boolean().optional(), cols: z.union([z.literal(1), z.literal(2)]).optional(), }); export type FormField = z.infer; /* ── 视图块:视图区的结构化呈现(活动推送,只读) ────────────────────── */ const fileDocSchema = z.discriminatedUnion('kind', [ /** * HTML 汇报报告 —— 成果文件的**主要形态**:活动按当下局面组织的一屏。 * 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。 */ z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }), /** * Markdown —— `md` 是**原文**(下载得到的就是它),`html` 是基座渲好的那一份。 * * 为什么渲染要在基座做:HTML 一律经沙箱呈现,而 Markdown 若在前端拼 DOM 就得 * `dangerouslySetInnerHTML`,那是把模型写的字直接注进父文档。基座渲的这一份 * 全程转义、只吐固定几种标签,再经同一个沙箱摆出来,与别的 HTML 件同一条路。 * `html` 缺席不是错误,是「这一份还没渲」—— 查看面照旧摆原文,不假装渲染过。 */ z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string(), html: z.string().optional() }), z.object({ kind: z.literal('excel'), name: z.string(), sheet: z.string(), rows: z.array(z.array(z.string())), }), z.object({ kind: z.literal('word'), name: z.string(), blocks: z.array( z.union([ z.object({ t: z.enum(['h1', 'h2', 'p']), text: z.string() }), z.object({ t: z.literal('ul'), items: z.array(z.string()) }), ]), ), }), z.object({ kind: z.literal('pdf'), name: z.string(), src: z.string() }), ]); export type FileDoc = z.infer; export { fileDocSchema }; export const viewBlockSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('callout'), tone: toneSchema, text: z.string() }), z.object({ t: z.literal('kv'), title: z.string().optional(), items: z.array( z.object({ k: z.string(), v: z.string(), /** 绑定某工作事项的回填字段:未答显示占位,作答后实时落定 */ fromItem: z.object({ item: z.string(), field: z.string() }).optional(), }), ), }), z.object({ t: z.literal('badges'), title: z.string().optional(), items: z.array(z.object({ label: z.string(), on: z.boolean() })), }), z.object({ t: z.literal('table'), title: z.string().optional(), cols: z.array(z.string()), rows: z.array(z.array(z.string())), editable: z.boolean().optional(), editableCols: z.array(z.number().int()).optional(), /* * ── 绑台账:**行由基座铺,不由活动塞**(2026-08-01)──────────────────── * * 给了 `ledger` + `fields`,发卡那一刻基座就从这一单的草稿层把行铺进来 * (`rows` 写不写都会被铺出来的行盖掉)。 * * 为什么必须这样:人要在卡上改的是**那份清单本身**(PRD 的执行页就是整份 9 行 * 逐格可改),而让活动把 9 行塞进回复里有两处死结 —— * · 回复有长度上限,铺长了半途截断,整拍作废(实测); * · 那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。 * * `fields` 与 `cols` 一一对应:`cols` 是给人看的列名,`fields` 是台账列名。 * 人提交之后,基座按同样的对应关系把改动写回草稿层并留痕 —— AI 不经手 * (与数据录入卡同一条规矩:该由人亲手落下的,就别在中间加一道传话)。 */ ledger: z.string().optional(), fields: z.array(z.string()).optional(), linkDonut: z .object({ col: z.number().int(), title: z.string().optional(), unit: z.string().optional(), map: z.array(z.object({ match: z.string(), label: z.string(), tone: toneSchema })), }) .optional(), }), z.object({ t: z.literal('bars'), title: z.string().optional(), max: z.number().optional(), redline: z.number().optional(), items: z.array( z.object({ label: z.string(), value: z.number(), text: z.string().optional(), tone: toneSchema.optional(), }), ), }), z.object({ t: z.literal('donut'), title: z.string().optional(), unit: z.string().optional(), items: z.array(z.object({ label: z.string(), value: z.number(), tone: toneSchema })), }), z.object({ t: z.literal('mail'), dir: z.enum(['in', 'out']), statusLabel: z.string().optional(), editable: z.boolean().optional(), from: z.string(), fromAddr: z.string().optional(), to: z.string(), cc: z.string().optional(), subject: z.string(), time: z.string(), body: z.string(), attachments: z .array(z.object({ name: z.string(), meta: z.string().optional(), file: fileDocSchema.optional() })) .optional(), }), z.object({ t: z.literal('cards'), title: z.string().optional(), items: z.array( z.object({ tag: z.string(), icon: z.string().optional(), tone: toneSchema.optional(), title: z.string(), code: z.string().optional(), swap: z.object({ from: z.string(), to: z.string(), hint: z.string().optional() }).optional(), big: z.string(), bigLabel: z.string().optional(), meta: z.array(z.object({ k: z.string(), v: z.string() })).optional(), note: z.string().optional(), from: z.string().optional(), time: z.string().optional(), }), ), }), z.object({ t: z.literal('waiting'), label: z.string(), hint: z.string().optional() }), z.object({ t: z.literal('files'), title: z.string().optional(), items: z.array(z.object({ label: z.string(), desc: z.string(), file: fileDocSchema })), }), ]); export type ViewBlock = z.infer; /* ── 阶段与过程成果 ───────────────────────────────────────────────────── */ /** * 阶段:交互层的统一进展刻度。 * 引擎层的来源随范式而异(loop = CMMN Stage / 里程碑,flow = 步序位置),前端不感知。 */ export const stageSchema = z.object({ id: stageIdSchema, label: z.string(), round: z.number().int().min(1).optional(), }); export type Stage = z.infer; export const DELIVERABLE_STATES = ['draft', 'pending-acceptance', 'released', 'rejected'] as const; export const deliverableStateSchema = z.enum(DELIVERABLE_STATES); export type DeliverableState = z.infer; export const DELIVERABLE_STATE_LABEL: Readonly> = { draft: '草稿', 'pending-acceptance': '待验收', released: '已放行', rejected: '已驳回', }; /** 过程成果:每个成果都要经验收放行才落地(放行门,OS 09) */ export const deliverableSchema = z.object({ id: deliverableIdSchema, stage: stageIdSchema, round: z.number().int().min(1).optional(), title: z.string(), desc: z.string().optional(), blocks: z.array(viewBlockSchema), state: deliverableStateSchema, at: z.string().optional(), }); export type Deliverable = z.infer; /* ── 工单两模式(执行 / 验收) ────────────────────────────────────────── */ export const WORK_ORDER_MODES = ['execution', 'acceptance'] as const; export const workOrderModeSchema = z.enum(WORK_ORDER_MODES); export type WorkOrderMode = z.infer; export const WORK_ORDER_MODE_LABEL: Readonly> = { execution: '执行', acceptance: '验收', }; /** * 办结口径:执行件只有「已提交」,验收件是放行三选一 —— 与回传协议同一套标识符。 * 界面文案由字典给出(与原型逐字一致),不在组件里散落中文。 */ export const WORK_ORDER_RESULTS = ['submitted', 'pass', 'redo', 'revise'] as const; export const workOrderResultSchema = z.enum(WORK_ORDER_RESULTS); export type WorkOrderResult = z.infer; export const WORK_ORDER_RESULT_LABEL: Readonly> = { submitted: '已提交', pass: '验收通过', redo: '驳回重做', revise: '驳回迭代', }; /** 驳回两式 —— 退回即重来,判定处只此一处 */ export function isRejection(result: WorkOrderResult): boolean { return result === 'redo' || result === 'revise'; } /** 完成回传:谁、何时、答了什么(回传即留痕) */ export const workDoneSchema = z.object({ at: z.iso.datetime(), by: staffIdSchema, result: workOrderResultSchema, values: z.record(z.string(), z.string()).optional(), }); export type WorkDone = z.infer; const workOrderBase = { id: workOrderIdSchema, at: z.iso.datetime(), title: z.string(), /** 调用必附上下文 —— basis 为空的工单基座必须拒发 */ basis: basisSchema, stage: stageIdSchema.optional(), done: workDoneSchema.optional(), /** * **办结那一刻的件快照**(2026-08-02 用户:「HTML UI、数据、内容、布局都要 * 1:1 地从待处理状态转换为静态报告放到工作成果」)。 * * 从前工作成果点开渲的是**当下**的任务视图(随后续推进还会变),执行工单的 * 定稿甚至退回卡壳(题头/编号带/blocks)—— 三样没有一样是人办结时看到的那份。 * 现在收下回传那一刻,基座把当时渲好的件原样存这里:成果页 1:1 静态重放, * 数据、内容、布局与人交卷时一个字不差。 */ finalHtml: z.string().optional(), }; /** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */ export const executionWorkOrderSchema = z.object({ ...workOrderBase, mode: z.literal('execution'), /** 系统预置形态标记:landing-failure = 落地失败卡(OS 7.3) */ preset: z.enum(['landing-failure']).optional(), ask: z.string(), standard: z.string().optional(), steps: z.array(z.string()).optional(), /** 报告正文取自哪份过程成果 —— 人要在数据上作判断,数据就得摆在表单旁边 */ basedOn: deliverableIdSchema.optional(), blocks: z.array(viewBlockSchema).optional(), fields: z.array(formFieldSchema), submitLabel: z.string().optional(), /** * **必填还空着就按不下提交**(2026-08-02 用户定)—— 由基座按台账声明**现算**。 * * 起因是实测的一单:受理需求那张卡上有一条料缺年用量(必填),人照样交了回来; * 这一单于是一路走到放行门,被 L1 机检 `quote_intake_record_missing = 0` 拦住, * 模型连拒五拍熔断 —— **同一件事,在提交那一刻拦下比在第 10 拍拦下便宜得多**: * 那时缺的是哪一格就摆在他眼前;到了放行门,他只看见一条红判据和一个停住的单。 * * 「必填」是**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)—— * 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格, * 按钮照常能按:**没有依据的时候不拦人**。 */ /** * **这张卡该拦哪几张台账**(发卡的人说了算)—— 不给就照旧回落到必交付物声明。 * * 起因(2026-08-27 W05 实测):数据工单是零输入卡(没有 `blocks`),闸于是回落到 * **必交付物**的那几张表 —— 而数据工单要人补的是 `parts.profile` / `parts.parameter` * 这类**主数据**,压根不在交付物名单里。结果:件上红带如实点着「缺品牌名称、缺封装」, * 提交钮却是绿的,空手交卷一路放行。**闸看的表和件上摆的表不是同一张,闸就等于没有。** * * 声明的是台账地址(`域.对象`),基座据此现算缺几格 —— 与红带、放行门 L1 同一个数同一套算法。 * 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。 */ gateLedgers: z.array(z.string()).optional(), gate: z .object({ /** 能交了吗 */ ready: z.boolean(), /** 交不了的话,一句话说清差在哪 */ why: z.string(), /** 差在哪几行哪一格 —— 摆主键 + 中文列名,人照着就能找到那一行 */ blanks: z.array(z.string()).default([]), }) .optional(), }); export type ExecutionWorkOrder = z.infer; export const acceptanceCriterionCheckSchema = z.object({ label: z.string(), level: acceptanceLevelSchema, pass: z.boolean(), /** * 裁量项(L3/L4)**裁过没有** —— 机检项与 L5 不带这一维(它们不是裁出来的)。 * * 「裁了但裁成不满足」与「压根没裁」是两件完全不同的事:前者是 AI 办过、 * 办不到,把判断交给人;后者是活没干完。从前卡上只有一个 `pass`, * 两者长得一模一样(都是红叉「未达标」)—— 人看到一条永远不绿的项, * 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。 */ settled: z.boolean().optional(), /** * 裁断的理由 —— **裁成「不满足」的那一条,这句话才是签字的人要看的东西**。 * 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。 */ rationale: z.string().optional(), }); export type AcceptanceCriterionCheck = z.infer; /** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */ export const actionIntentViewSchema = z.object({ label: z.string(), effect: z.enum(['read', 'write']), detail: z.string(), }); export type ActionIntentView = z.infer; export const ACTION_EFFECT_LABEL: Readonly> = { read: '读', write: '写', }; /** 验收工单:AI 拟好的可修订报告 + 判据逐条核对 + 放行三选一 */ export const acceptanceWorkOrderSchema = z.object({ ...workOrderBase, mode: z.literal('acceptance'), deliverable: deliverableIdSchema, criteria: z.array(acceptanceCriterionCheckSchema), actions: z.array(actionIntentViewSchema).optional(), /** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */ fields: z.array(formFieldSchema).default([]), /** * 验收页的正文块 —— 与执行工单同一套件(可含**绑台账的可编辑表**)。 * * 验收是这一单最重的一道门:人得能在**成果本身**上改,而不是只在几个整单级字段上改。 * 绑了台账的表由基座铺行、由基座写回(`blocks[].ledger` + `fields`),AI 不经手 —— * 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。 */ blocks: z.array(viewBlockSchema).optional(), /** * 选择组(活动契约的 selective 出口)—— **只在算不出去向时才摆给人选**。 * * 去向能算就别问:契约里每个出口都写了 `when` 谓词时,基座按序求值(首个命中即出), * 结论随放行一并落定,卡上只如实**告诉**人走哪一口、凭什么(见 `routedPort`)。 * 「这一单往哪走」是分类表算出来的事实,不是一道让人凭印象点的选择题 —— * 让人选,等于把一个可回放的判定改成一次不可回放的手感。 * 只有当出口没写全谓词(算不出来)时,才退回这张选择题。 */ portChoice: z .object({ group: z.string(), ports: z.array(z.object({ name: z.string(), label: z.string().optional() })).min(2), }) .optional(), /** * **这一签下去,世界上发生了什么**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。 * * 只在办结之后才有。放行卡上摆的是**签字前要核的东西**(判据); * 签完之后人关心的换成了另一件事:落了几行、进了哪几张表、转去哪儿了。 * 从前这两件事共用一屏判据表 —— 签完再回看,那一摞对勾说的还是签之前的话。 * * 数据来自放行那一刻的结构化留痕(逐表计数 + 发出的口),不是把日志句子再解析一遍。 */ landing: z .object({ /** 合并进正式版的总行数 */ rows: z.number().int().min(0), /** 试跑挡下的主数据行数 —— 非 0 就得说,不然「合并 N 行」是假的 */ simulated: z.number().int().min(0).default(0), /** 逐表:进了哪张表、几行 */ tables: z.array(z.object({ label: z.string(), rows: z.number().int().min(0) })).default([]), /** 转去哪儿了 —— 摆出口的人读名;一个都没有就是这条流到此为止 */ ports: z.array(z.string()).default([]), /** 带了哪些字段过去 —— 下游活动读到的就是这几个(摆名字不摆值) */ carried: z.array(z.string()).default([]), }) .optional(), /** * **算出来的去向** —— 契约的出口谓词按序求值,首个命中即出。 * 卡上只告诉人「走哪一口、凭哪一条」,不给他改;放行时按它发口。 */ routedPort: z .object({ group: z.string(), name: z.string(), label: z.string().optional(), /** 命中的那条谓词原文 —— 人要回核「凭什么走这条」 */ because: z.string(), }) .optional(), passLabel: z.string().optional(), }); export type AcceptanceWorkOrder = z.infer; export const workOrderSchema = z.discriminatedUnion('mode', [ executionWorkOrderSchema, acceptanceWorkOrderSchema, ]); export type WorkOrder = z.infer; /* ── 对话卡族(决策 / 补全 / 评价) ──────────────────────────────────── */ /** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */ export const AI_TAGS = ['conclusion', 'guidance', 'criterion'] as const; export const aiTagSchema = z.enum(AI_TAGS); export type AiTag = z.infer; export const AI_TAG_LABEL: Readonly> = { conclusion: '结论', guidance: '指导', criterion: '判据', }; const chatBase = { id: cardIdSchema, at: z.iso.datetime() }; /** 决策卡:分支条件选择表;含自定义收尾项 */ export const decisionCardSchema = z.object({ ...chatBase, k: z.literal('decision'), ask: z.string(), basis: basisSchema, recommend: z.string().optional(), options: z.array( z.object({ value: z.string(), label: z.string(), detail: z.string().optional(), consequence: z.string().optional(), recommended: z.boolean().optional(), }), ), /** 自定义收尾项:人可给出选项之外的处置(附理由) */ allowCustom: z.boolean().default(false), chosen: z .object({ value: z.string(), at: z.iso.datetime(), by: staffIdSchema, note: z.string().optional(), }) .optional(), /** * **答完那一刻的件快照**(与 `workOrderBase.finalHtml` 同一条道理)。 * * `decide` 是唯一能循环的验收机制(`issueAcceptance` 一个任务只能发一次, * 中间验收点只能靠决策卡),但决策卡本身不是工单 —— 没有这个字段的话, * 人在这张卡上通过的那份报告(如 E04 的经营计划)永远进不了工作成果: * 那一屏只认 `sys.work_order`,而这一签根本没有工单行。 * 回传那一刻基座把当时渲好的件原样存这里,工作成果拼装时并进同一份列表。 */ finalHtml: z.string().optional(), }); export type DecisionCard = z.infer; /** 补全卡:就地要几个小信息;含「没法填」旁路 —— 两条路必走其一 */ export const elicitationCardSchema = z.object({ ...chatBase, k: z.literal('elicitation'), ask: z.string(), basis: basisSchema.optional(), fields: z.array( z.object({ key: z.string(), label: z.string(), placeholder: z.string().optional(), /** * 预填值 —— AI 从来件里读得出的先替人填上(公司名、联系人姓名)。 * 人核对一眼、补上填不出的那个即可。**读不出来的别给**: * 填错了人得先擦掉再写,比空着更费事。 */ value: z.string().optional(), }), ), done: z .object({ at: z.iso.datetime(), by: staffIdSchema, values: z.record(z.string(), z.string()), /** 没法填的理由 —— 人让 AI 迭代一轮需求 */ reason: z.string().optional(), }) .optional(), }); export type ElicitationCard = z.infer; /** * **数据录入卡** —— 人类工具的第五类(2026-07-31 用户定)。 * * 与补全卡的差别是**谁写库**: * · 补全卡:人填的值进轨迹 → **AI 再转写进台账**。中间隔一道传话, * 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。 * · 数据录入卡:**人按下确认那一刻就是写入** —— 卡上写着落哪张表、哪个主键, * 确认即落草稿层。AI 只负责把能填的先填好,填错了人当场改。 * * 所以它适合「建档」这类事:客户档案是主数据,一旦建错后面每一单都跟着错; * 那一笔该由人亲手落下,不该经 AI 的手转述一遍。 * * 仍守铁律②:确认写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。 */ /** 录入卡的一格 —— 主段与 `also` 各段同一形状 */ const entryFieldSchema = z.object({ key: z.string(), label: z.string(), /** AI 预填的值 —— 人可改;填不出的留空,界面标「待填写」 */ value: z.string().optional(), /** 必填的空着不让确认 —— 缺了这一格这张档就是残的 */ required: z.boolean().default(false), placeholder: z.string().optional(), }); export const dataEntryCardSchema = z.object({ ...chatBase, k: z.literal('entry'), ask: z.string(), basis: basisSchema.optional(), /** 落哪张表(`域.对象`)—— 确认那一下写的就是它 */ ledger: z.string().min(1), /** * 那张表**给人看的名字**(台账登记的表注释,如「客户档案」)。 * * 卡面上摆的是它,不是 `customer.profile` —— 界面不摆系统内的英文名 * (用户 2026-08-02:「这种卡片里说:确认后写入 xxx数据库」)。 * 登记里没有人读名时不给这一项,卡面退回对象地址:宁可笼统,不可编。 */ ledgerLabel: z.string().optional(), /** 主键值:确认时按它定位那一行 */ key: z.record(z.string(), z.string()), /** 主键那几列的中文列名 —— 卡面摆它,不摆 `customer_code`(界面不摆系统内的英文名) */ keyLabels: z.record(z.string(), z.string()).optional(), fields: z.array(entryFieldSchema), /** * **这一段有几列是基座自己生成的** —— 摆一句话告诉人「这个不用你填」。 * * 声明里标了 `auto` 的列(如联系人编码)压根不进 `fields`: * 让人填是白占一格他答不上来的表单。但**不能一声不吭地拿掉** —— * 那样他会以为这张档少了一格。 */ autoNote: z.string().optional(), /** * **同一次确认还要落的另几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。 * * 建一位新客户落的是**两条主数据**:公司档(`customer.profile`)与 * 联系人档(`customer.contact`)。从前卡上只有公司那一半,联系人由 AI 自己写 —— * 而联系人档里的 `contact_code` 是它**编**出来的,人一眼没看过。 * 主数据编错一次,后面每一单都跟着错,这正是数据录入卡存在的理由。 * * 分成两张卡也能办,但那是把**一件事**(建档)拆成两次打断; * 一张卡两段,人一次看全、一次确认,基座一次落两行。 * * 值按段隔开:`values` 的键是 `段号.列名`(主段是 `0.`)—— 两张表都有 `name` 这一列, * 平铺就会互相盖掉,而盖掉之后没人看得出来。 */ also: z .array( z.object({ /** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */ title: z.string(), ledger: z.string().min(1), ledgerLabel: z.string().optional(), key: z.record(z.string(), z.string()), keyLabels: z.record(z.string(), z.string()).optional(), fields: z.array(entryFieldSchema), autoNote: z.string().optional(), }), ) .optional(), done: z .object({ at: z.iso.datetime(), by: staffIdSchema, /** 键是 `段号.列名`(主段 `0.`)—— 见 `also` 的注释 */ values: z.record(z.string(), z.string()), }) .optional(), }); export type DataEntryCard = z.infer; /** 评价卡:任务评价(评分 1–5 + 改进建议) */ export const ratingCardSchema = z.object({ ...chatBase, k: z.literal('rating'), ask: z.string(), basis: basisSchema.optional(), done: z .object({ at: z.iso.datetime(), by: staffIdSchema, score: z.number().int().min(1).max(5), note: z.string().optional(), }) .optional(), }); export type RatingCard = z.infer; export const chatCardSchema = z.discriminatedUnion('k', [ decisionCardSchema, dataEntryCardSchema, elicitationCardSchema, ratingCardSchema, ]); export type ChatCard = z.infer; /* ── 对话流消息 ───────────────────────────────────────────────────────── */ export const chatMessageSchema = z.discriminatedUnion('k', [ z.object({ ...chatBase, k: z.literal('ai'), text: z.string(), tag: aiTagSchema.optional(), /** 指涉的工单 id —— 对话区点击即让视图区定位到那张工单 */ ref: workOrderIdSchema.optional(), }), z.object({ ...chatBase, k: z.literal('human'), text: z.string(), by: staffIdSchema }), z.object({ ...chatBase, k: z.literal('system'), type: z.enum(['data', 'time', 'human', 'control']), text: z.string(), /** * 这条系统消息带一个「查看邮件」的去处 —— 原件在沟通记录里,一步跳过去。 * **点不点都不挡路**:开场白之后 AI 照常开跑,不等人按。 */ openComms: z.boolean().optional(), }), decisionCardSchema, dataEntryCardSchema, elicitationCardSchema, ratingCardSchema, ]); export type ChatMessage = z.infer; /* ── 回传协议(前端 → 后端;后端以同一 schema 校验,§9.2) ───────────── */ export const workOrderReplySchema = z.discriminatedUnion('mode', [ z.object({ mode: z.literal('execution'), workOrderId: workOrderIdSchema, values: z.record(z.string(), z.string()), }), z.object({ mode: z.literal('acceptance'), workOrderId: workOrderIdSchema, /** 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 驳回必附意见 */ verdict: z.union([z.literal('pass'), rejectionKindSchema]), /** 报告的人工修订(可空 = 全盘采信 AI 拟值) */ values: z.record(z.string(), z.string()).default({}), comment: z.string().optional(), /** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */ selectedPorts: z.record(z.string(), z.string()).default({}), }), ]); export type WorkOrderReply = z.infer; export const chatCardReplySchema = z.discriminatedUnion('k', [ z.object({ k: z.literal('decision'), cardId: cardIdSchema, value: z.string(), note: z.string().optional(), }), z.object({ k: z.literal('elicitation'), cardId: cardIdSchema, values: z.record(z.string(), z.string()).default({}), /** 「没法填」旁路 */ reason: z.string().optional(), }), /** * 数据录入卡的确认 —— **这一下就是写入**。 * 基座按卡上声明的 `ledger` + `key` 把 `values` 落进草稿层,并留一条痕: * 谁、何时、往哪张表写了什么。AI 不经手。 */ z.object({ k: z.literal('entry'), cardId: cardIdSchema, values: z.record(z.string(), z.string()), }), z.object({ k: z.literal('rating'), cardId: cardIdSchema, score: z.number().int().min(1).max(5), note: z.string().optional(), }), ]); export type ChatCardReply = z.infer; /** * 驳回必附意见 —— 三规矩之一的机检形态(OS 05)。 * 后端回传校验与前端提交校验共用本函数。 */ export function rejectionNeedsComment(reply: WorkOrderReply): boolean { return reply.mode === 'acceptance' && reply.verdict !== 'pass' && !reply.comment?.trim(); } /** 补全卡两条路必走其一:要么填值,要么给「没法填」的理由 */ export function elicitationReplyIsEmpty(reply: ChatCardReply): boolean { if (reply.k !== 'elicitation') return false; const hasValue = Object.values(reply.values).some((v) => v.trim() !== ''); return !hasValue && !reply.reason?.trim(); }