Files
JX-BPOS/packages/contracts/src/card.ts
T
ChaiHaoandClaude Opus 5 ade0fa5a94 A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:28:31 +08:00

768 lines
30 KiB
TypeScript

/**
* 人类工具的卡协议(编码规范 §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<typeof toneSchema>;
/** 依据快照:调用必附上下文(OS 05)—— 裸调用基座必须拒发 */
export const basisSchema = z.array(z.object({ k: z.string(), v: z.string() }));
export type Basis = z.infer<typeof basisSchema>;
export const formOptionSchema = z.object({
value: z.string(),
label: z.string(),
negative: z.boolean().optional(),
});
export type FormOption = z.infer<typeof formOptionSchema>;
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<typeof formFieldKindSchema>;
/** 表单字段:结构化交互的原子。卡片表单由本 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<typeof formFieldSchema>;
/* ── 视图块:视图区的结构化呈现(活动推送,只读) ────────────────────── */
const fileDocSchema = z.discriminatedUnion('kind', [
/**
* HTML 汇报报告 —— 成果文件的**主要形态**:活动按当下局面组织的一屏。
* 渲染一律经 `@ui` 的 `ViewContainer` 沙箱,无脚本执行权(AM 07)。
*/
z.object({ kind: z.literal('html'), name: z.string(), html: z.string() }),
z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string() }),
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<typeof fileDocSchema>;
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<typeof viewBlockSchema>;
/* ── 阶段与过程成果 ───────────────────────────────────────────────────── */
/**
* 阶段:交互层的统一进展刻度。
* 引擎层的来源随范式而异(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<typeof stageSchema>;
export const DELIVERABLE_STATES = ['draft', 'pending-acceptance', 'released', 'rejected'] as const;
export const deliverableStateSchema = z.enum(DELIVERABLE_STATES);
export type DeliverableState = z.infer<typeof deliverableStateSchema>;
export const DELIVERABLE_STATE_LABEL: Readonly<Record<DeliverableState, string>> = {
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<typeof deliverableSchema>;
/* ── 工单两模式(执行 / 验收) ────────────────────────────────────────── */
export const WORK_ORDER_MODES = ['execution', 'acceptance'] as const;
export const workOrderModeSchema = z.enum(WORK_ORDER_MODES);
export type WorkOrderMode = z.infer<typeof workOrderModeSchema>;
export const WORK_ORDER_MODE_LABEL: Readonly<Record<WorkOrderMode, string>> = {
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<typeof workOrderResultSchema>;
export const WORK_ORDER_RESULT_LABEL: Readonly<Record<WorkOrderResult, string>> = {
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<typeof workDoneSchema>;
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`),缺几格由基座数(草稿层现读)——
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
* 按钮照常能按:**没有依据的时候不拦人**。
*/
gate: z
.object({
/** 能交了吗 */
ready: z.boolean(),
/** 交不了的话,一句话说清差在哪 */
why: z.string(),
/** 差在哪几行哪一格 —— 摆主键 + 中文列名,人照着就能找到那一行 */
blanks: z.array(z.string()).default([]),
})
.optional(),
});
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
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<typeof acceptanceCriterionCheckSchema>;
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
export const actionIntentViewSchema = z.object({
label: z.string(),
effect: z.enum(['read', 'write']),
detail: z.string(),
});
export type ActionIntentView = z.infer<typeof actionIntentViewSchema>;
export const ACTION_EFFECT_LABEL: Readonly<Record<'read' | 'write', string>> = {
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<typeof acceptanceWorkOrderSchema>;
export const workOrderSchema = z.discriminatedUnion('mode', [
executionWorkOrderSchema,
acceptanceWorkOrderSchema,
]);
export type WorkOrder = z.infer<typeof workOrderSchema>;
/* ── 对话卡族(决策 / 补全 / 评价) ──────────────────────────────────── */
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
export const AI_TAGS = ['conclusion', 'guidance', 'criterion'] as const;
export const aiTagSchema = z.enum(AI_TAGS);
export type AiTag = z.infer<typeof aiTagSchema>;
export const AI_TAG_LABEL: Readonly<Record<AiTag, string>> = {
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(),
});
export type DecisionCard = z.infer<typeof decisionCardSchema>;
/** 补全卡:就地要几个小信息;含「没法填」旁路 —— 两条路必走其一 */
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<typeof elicitationCardSchema>;
/**
* **数据录入卡** —— 人类工具的第五类(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<typeof dataEntryCardSchema>;
/** 评价卡:任务评价(评分 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<typeof ratingCardSchema>;
export const chatCardSchema = z.discriminatedUnion('k', [
decisionCardSchema,
dataEntryCardSchema,
elicitationCardSchema,
ratingCardSchema,
]);
export type ChatCard = z.infer<typeof chatCardSchema>;
/* ── 对话流消息 ───────────────────────────────────────────────────────── */
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<typeof chatMessageSchema>;
/* ── 回传协议(前端 → 后端;后端以同一 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<typeof workOrderReplySchema>;
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<typeof chatCardReplySchema>;
/**
* 驳回必附意见 —— 三规矩之一的机检形态(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();
}