Archived
用户:「评审放行与落账确认这一步的验收卡不应该有重做的选项,因为这里都没有接入 ai」。 W05 的 T_release 整个由配方跑完(guide 是空串,不问模型),件又是只读的 —— 打回之后没有任何东西能接住那句意见:配方只会原样再提交一次,撞在「打回之后台账没动过 就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的兜底卡。 一颗按下去只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。 · 契约:AcceptanceWorkOrder.redoable(不写 = 打得回,老卡老行为); · 装配层按放行那一步的声明现算 —— releaseRedoable 纯函数(有 auto 且 guide 空 → 打不回; 半 auto 与模型驱动照旧打得回,那儿真有模型会读意见),与 decisionCardFace / executionCardVariant 同一种做法、同一个测试位; · 卡面收起理由框与那颗钮,换一句真路:「这一步只落账,退不回上一步 —— 有异议就别确认, 去终止这一单重开」; · **服务端才是门**:redoable:false 的卡收到非 pass 的回传当场拒(redo-unavailable), 直调 API 绕不过;拒回把真路指出来。 S3 结论审核那张审核卡不受影响 —— 它的重做真会带着意见重判(昨夜那一轮)。 测:card-rules 3 条 + releaseRedoable 4 条,两处变异验证;e2e w05 5/5 released、被拒 0。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
/**
|
|
* 人类工具三规矩的机检 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 05,AM 06)。
|
|
*
|
|
* 调用必附上下文(必须)—— 每张卡自带依据与快照;**无上下文的裸调用,基座必须拒发**
|
|
* 回传即留痕(必须)—— 谁、何时、答了什么、依据什么,随卡入任务记忆
|
|
* 完成只出自放行(必须)—— 完成只能由验收工单的「放行通过」产生
|
|
*/
|
|
import type {
|
|
AcceptanceWorkOrder,
|
|
ChatCard,
|
|
ChatCardReply,
|
|
ExecutionWorkOrder,
|
|
FormField,
|
|
WorkOrder,
|
|
WorkOrderReply,
|
|
} from '@contracts';
|
|
import { elicitationReplyIsEmpty, rejectionNeedsComment } from '@contracts';
|
|
|
|
export type CardViolation = { readonly code: string; readonly message: string };
|
|
|
|
/** 到达面两路(OS 2.1):负责人的卡进任务对话流;其他人的卡以系统消息送达 */
|
|
export const ARRIVAL_SURFACES = ['task-chat', 'system-message'] as const;
|
|
export type ArrivalSurface = (typeof ARRIVAL_SURFACES)[number];
|
|
|
|
export function arrivalOf(recipientId: string, ownerId: string): ArrivalSurface {
|
|
return recipientId === ownerId ? 'task-chat' : 'system-message';
|
|
}
|
|
|
|
/* ── 发卡前的机检:裸调用必须被拒发 ─────────────────────────────────── */
|
|
|
|
export function checkWorkOrder(order: WorkOrder): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
if (order.basis.length === 0) {
|
|
out.push({ code: 'bare-call', message: '调用必附上下文:卡面无依据快照,基座拒发(OS 05)' });
|
|
}
|
|
if (order.title.trim() === '') out.push({ code: 'title-missing', message: '工单标题不得为空' });
|
|
|
|
if (order.mode === 'execution') out.push(...checkExecution(order));
|
|
else out.push(...checkAcceptance(order));
|
|
return out;
|
|
}
|
|
|
|
function checkExecution(order: ExecutionWorkOrder): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' });
|
|
/*
|
|
* **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去
|
|
* 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件),
|
|
* 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 +
|
|
* 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后,
|
|
* 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。
|
|
* 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。
|
|
*/
|
|
out.push(...checkFieldKeys(order.fields));
|
|
return out;
|
|
}
|
|
|
|
function checkAcceptance(order: AcceptanceWorkOrder): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
if (order.criteria.length === 0) {
|
|
out.push({ code: 'criteria-missing', message: '验收工单必须逐条列出判据(成果清单的来源)' });
|
|
}
|
|
// 验收等级不得谎报:L4 生成者不得自评 —— 这里只机检「等级必须标注」
|
|
for (const c of order.criteria) {
|
|
if (c.label.trim() === '') out.push({ code: 'criterion-unlabeled', message: '判据必须有可读的表述' });
|
|
}
|
|
out.push(...checkFieldKeys(order.fields));
|
|
return out;
|
|
}
|
|
|
|
function checkFieldKeys(fields: readonly FormField[]): CardViolation[] {
|
|
const seen = new Set<string>();
|
|
const out: CardViolation[] = [];
|
|
for (const field of fields) {
|
|
if (field.key.trim() === '') out.push({ code: 'field-key-empty', message: '表单字段 key 不得为空' });
|
|
if (seen.has(field.key)) out.push({ code: 'field-key-duplicate', message: `表单字段 key 重复:${field.key}` });
|
|
seen.add(field.key);
|
|
if (field.kind === 'radio' || field.kind === 'select') {
|
|
if (field.options === undefined || field.options.length === 0) {
|
|
out.push({ code: 'options-missing', message: `${field.kind} 字段 ${field.key} 缺选项` });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function checkChatCard(card: ChatCard): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
switch (card.k) {
|
|
case 'decision':
|
|
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
|
/*
|
|
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
|
|
* 改全在件上就地做,卡上只有「确认通过」一个动作。
|
|
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
|
|
*/
|
|
{
|
|
const floor = card.variant === 'review' ? 1 : 2;
|
|
if (card.options.length < floor) {
|
|
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
|
|
}
|
|
}
|
|
break;
|
|
case 'elicitation':
|
|
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
|
break;
|
|
case 'rating':
|
|
if (card.ask.trim() === '') out.push({ code: 'ask-missing', message: '评价卡必须给出评价对象' });
|
|
break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ── 回传校验:与前端提交校验同 schema(§9.2) ─────────────────────── */
|
|
|
|
export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
if (order.id !== reply.workOrderId) {
|
|
out.push({ code: 'mismatch', message: '回传的工单 ID 与卡不符' });
|
|
return out;
|
|
}
|
|
if (order.done !== undefined) {
|
|
out.push({ code: 'already-answered', message: '该工单已回传,只增不改' });
|
|
return out;
|
|
}
|
|
if (order.mode !== reply.mode) {
|
|
out.push({ code: 'mode-mismatch', message: '回传模式与工单模式不符' });
|
|
return out;
|
|
}
|
|
|
|
if (reply.mode === 'acceptance') {
|
|
// 驳回必附意见 —— 三规矩之一
|
|
if (rejectionNeedsComment(reply)) {
|
|
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
|
|
}
|
|
/*
|
|
* **打不回的卡就是打不回**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
|
|
* 重做的选项,因为这里都没有接入 ai」)。卡面把那颗钮收起来只是给人看的,**这里才是门** ——
|
|
* 直调 API 把「重做」交回来,单子会被推进死胡同(没有模型读那句意见,配方只会原样重提,
|
|
* 撞在「打回之后台账没动过」那道闸上)。拒回把真路指出来:别签、去终止重开。
|
|
*/
|
|
if (reply.verdict !== 'pass' && (order as AcceptanceWorkOrder).redoable === false) {
|
|
out.push({
|
|
code: 'redo-unavailable',
|
|
message: '这一步打不回:它整个由配方跑完,没有模型会读你的意见,件也只读 —— 有异议就别确认,去终止这一单重开。',
|
|
});
|
|
}
|
|
/*
|
|
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
|
|
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
|
|
* 选的这一口是这次运行的分支结论,下游网关判的正是它,不能靠默认值溜过去。
|
|
* 只在**通过**时强制 —— 驳回是打回重做,没有分支结论可言。
|
|
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
|
|
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
|
|
*/
|
|
/*
|
|
* **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤,
|
|
* 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。
|
|
*/
|
|
if (reply.verdict === 'pass') {
|
|
const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked);
|
|
if (missing.length > 0) {
|
|
out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` });
|
|
}
|
|
}
|
|
const choice = (order as AcceptanceWorkOrder).portChoice;
|
|
if (choice !== undefined && reply.verdict === 'pass') {
|
|
const picked = reply.selectedPorts[choice.group];
|
|
if (picked === undefined) {
|
|
out.push({ code: 'port-required', message: `放行前先选「${choice.group}」—— 选的这一口就是这单的去向` });
|
|
} else if (!choice.ports.some((p) => p.name === picked)) {
|
|
out.push({ code: 'port-invalid', message: `「${picked}」不在「${choice.group}」的可选出口里` });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const execution = order as ExecutionWorkOrder;
|
|
for (const field of execution.fields) {
|
|
if (field.required !== true) continue;
|
|
const value = reply.values[field.key];
|
|
if (value === undefined || value.trim() === '') {
|
|
out.push({ code: 'required-missing', message: `必填字段未回填:${field.label || field.key}` });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 清单里还没点成「达标」的那几条;没有清单就是空 */
|
|
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlyArray<string> | undefined): string[] {
|
|
const done = new Set(checked ?? []);
|
|
return (checklist ?? []).filter((item) => !done.has(item));
|
|
}
|
|
|
|
/**
|
|
* 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。
|
|
* 清单只拦这一个选项:驳回不需要把清单点全。
|
|
*/
|
|
export function reviewApproveValue(card: Extract<ChatCard, { k: 'decision' }>): string | undefined {
|
|
return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value;
|
|
}
|
|
|
|
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
|
|
const out: CardViolation[] = [];
|
|
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
|
|
if (card.k !== reply.k) return [{ code: 'mode-mismatch', message: '回传类型与卡类型不符' }];
|
|
|
|
if (card.k === 'decision' && reply.k === 'decision') {
|
|
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
|
|
// 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门)
|
|
if (reply.value === reviewApproveValue(card)) {
|
|
const missing = checklistMissing(card.checklist, reply.checked);
|
|
if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }];
|
|
}
|
|
const known = card.options.some((o) => o.value === reply.value);
|
|
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
|
|
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
|
|
if (!known && card.allowCustom && (reply.note ?? '').trim() === '') {
|
|
out.push({ code: 'note-required', message: '自定义收尾项必附理由' });
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住:
|
|
* 必填空着不让确认(缺一格这张档就是残的,而它是主数据);
|
|
* 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。
|
|
*/
|
|
if (card.k === 'entry' && reply.k === 'entry') {
|
|
if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }];
|
|
/*
|
|
* **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 ——
|
|
* 两张表都有 `name` 这一列,平铺就会互相盖掉。
|
|
* 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。
|
|
*/
|
|
const groups = [{ fields: card.fields }, ...(card.also ?? [])];
|
|
const blank = groups.flatMap((g, at) =>
|
|
g.fields
|
|
.filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '')
|
|
.map((f) => f.label),
|
|
);
|
|
if (blank.length > 0) {
|
|
out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` });
|
|
}
|
|
}
|
|
|
|
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
|
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
|
|
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
|
|
if (elicitationReplyIsEmpty(reply)) {
|
|
out.push({ code: 'answer-required', message: '要么回填字段,要么说明「没法填」的理由' });
|
|
}
|
|
}
|
|
|
|
if (card.k === 'rating' && reply.k === 'rating') {
|
|
if (card.done !== undefined) return [{ code: 'already-answered', message: '该评价已提交,只增不改' }];
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 完成只出自放行通过 —— 唯一判据。
|
|
* 判据全绿只是**可提交**,认账永远是人的动作(AM 06)。
|
|
*/
|
|
export function producesCompletion(reply: WorkOrderReply): boolean {
|
|
return reply.mode === 'acceptance' && reply.verdict === 'pass';
|
|
}
|
|
|
|
/** 判据全绿?—— L1/L2 由引擎预检,L3–L5 按分级执行;这里只看核对结果 */
|
|
export function allCriteriaGreen(order: AcceptanceWorkOrder): boolean {
|
|
return order.criteria.every((c) => c.pass);
|
|
}
|