Files
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

299 lines
15 KiB
TypeScript

/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。
*
* A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干,
* 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 ——
* 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。
*
* 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。
* 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、
* 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from '../src/case-model.ts';
import { CMMN_XML } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of INTAKE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(INTAKE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(INTAKE_STEPS);
});
});
describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => {
/** 开案那一刻的局面:台账一行没有,汇总列全无值 */
const FRESH: Record<string, unknown> = {
customerEmail: 'buyer@x.com',
subject: '【询价】RFQ-2026-0001',
body: 'LM358DR 12K/年',
};
it('**开局一步都不该被自动收掉** —— 这是最容易写错的地方', () => {
for (const step of INTAKE_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, FRESH);
expect(ripe, `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`).toBe(false);
}
});
/* ── ① 定位客户信息 ─────────────────────────────────────────────── */
it('**老客户**认出来了 → S1 收得掉(不必问人)', () => {
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 0 })).toBe(true);
});
/*
* 这一条是拿一次实测换来的:剧本白纸黑字写着「别拿邮箱域名凑一个客户编码,也别自己编」,
* 模型照样从 purchasing@sz-machinery.com 编出 `CUST-SZ-MACH` 直接把档建了 ——
* 人根本没被问过一句,而客户编码是主数据的主键,编错一次后面每一单都跟着错。
* **说明书管不住,收步条件管**:新客户没问过人,这一步就收不掉。
*/
it('**新客户没问过人 → S1 收不掉**(说明书管不住的,让收步条件管)', () => {
expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1 })).toBe(false);
});
it('新客户问过人了 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1, customer_asked_total: 1 }),
).toBe(true);
});
/*
* 这一条守的是那个老坑:**不能写 `customer_code != null`** ——
* S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会在还没认人之前就被收掉。
* 判据必须取同步写下的计数列。
*/
it('还没认到客户 → S1 收不掉,归一也进不了场', () => {
expect(safeEval(doneOf('S1_customer'), FRESH)).toBe(false);
expect(safeEval(doneOf('S2_normalize'), FRESH)).toBe(false);
});
/* ── ② 归一客户 RFQ 输入 ────────────────────────────────────────── */
const normalized = (over: Record<string, unknown>) => ({
...FRESH,
customer_total: 1,
line_total: 3,
// 拆出来几行就报几条 —— 收步与验收同源(见下面那条「多写一行」)
quote_intake_record_rows: 3,
missing_key_total: 0,
missing_minor_total: 0,
asked_total: 0,
...over,
});
it('拆出行来了 → S2 收得掉(拆料是 AI 的活,办完交给下一步请人)', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({}))).toBe(true);
});
/*
* **缺不缺字段不再是 S2 的收步条件** —— 缺口是卡上要说清的事,不是拦住拆料的理由。
* 从前把「缺了就得问过人」写进这里,连带出下面那条真正的病:
* 不缺就等于不必经过人。现在分开:S2 只管拆,S3_confirm 管「经过人」。
*/
it('缺必填 / 缺选填都不拦 S2 —— 那是 S3 卡上的事', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_key_total: 2 }))).toBe(true);
expect(safeEval(doneOf('S2_normalize'), normalized({ missing_minor_total: 2 }))).toBe(true);
});
it('一条都没归一出来 → S2 收不掉', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ line_total: 0 }))).toBe(false);
});
/*
* **多写一行 → S2 就收不掉**(2026-08-01 加)。
*
* 实测:客户明说本轮跳过的那颗料被写进了有效清单 —— 数报得全对(9 有效 / 1 跳过),
* 行却有 10 条。从前 S2 只看 `line_total > 0`,照收;错要到第 10 拍提交放行时
* 才被同一条等式拦住,那时模型只会去改数(改了 skipped_total 又不对),连拒五拍熔断。
* **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下,多的是哪一行一目了然。
*/
it('**多写一行(如把客户说跳过的那条也写了)→ S2 收不掉**,错在第 2 拍就现形', () => {
expect(safeEval(doneOf('S2_normalize'), normalized({ quote_intake_record_rows: 4 }))).toBe(false);
});
/* ── ③ 受理需求交人审定:阶段2 那张执行页,一单必经一次 ─────────── */
/*
* **这一条是用户 2026-08-01 当场逼出来的**:「我都没确认需求 它就让我验收了」。
*
* 病根在旧口径:「经过人」是靠 `missing_minor_total = 0 or answered_total > 0`
* 表达的 —— 只有缺东西的时候才叫人。字段齐全的一封询价进来,这个条件在拆完那一刻
* 就为真,引擎自己把归一那步收了,人一眼没看过受理需求,
* 下一次被叫到已经是终局的验收放行。**确认不是缺口的补丁,是一道恒在的门。**
*
* 所以这一步跟放行同一个做法:**人工激活 + 不给 doneWhen**。
* 给它写 doneWhen 就是给引擎一把替人签字的钥匙;
* 而「人看过、改过、认了」按定义也不是台账里任何一个数能自己变出来的。
*/
it('**受理需求那一步不给 doneWhen** —— 引擎不许替人认下这份稿', () => {
expect(INTAKE_STEPS.find((s) => s.item === 'S3_confirm')?.doneWhen).toBeUndefined();
});
it('审定这一步在剧本里,且摆的是阶段2 那份目标件', () => {
const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm');
expect(confirm, '受理需求交人审定这一步不见了').toBeDefined();
expect(confirm?.view).toBe('intake-overview');
/*
* **执行卡零输入**(2026-08-02 用户定:填写全去工作区)——
* 卡上不绑表、不摆格子;清单在工作区那份件上就地改(editGate + 孪生件),
* 提交闸回落到必交付物的台账声明照样把必填看住。
* 从前这儿断言卡里带 `"ledger":"quote_intake.record"` 的 blocks —— 那一版
* 同一张表摆两处(卡上一份、件上一份),人不知道改哪一份算数。
*/
/*
* 骨架已搬进说明书(2026-08-03:模型常在「拆表+收步+派卡」同一拍发这张卡,
* 那一拍 S3 的剧本没摊开 —— 骨架只放剧本里就会发出裸卡,实测指导书整个是空的)。
* 剧本里留的是指路;零输入纪律与骨架本体都在说明书里,每一拍都看得见。
*/
expect(confirm?.guide).toContain('骨架在说明书里');
expect(NARRATIVE).toContain('受理需求审定卡的骨架');
expect(NARRATIVE).toContain('卡上不摆表、不摆格子');
expect(NARRATIVE).toContain('"itemId":"S3_confirm"');
expect(confirm?.guide).not.toContain('"blocks"');
expect(confirm?.guide).toContain('confirmed_total');
});
/*
* **收步判据与验收判据同源**(2026-08-01 改)。
*
* 从前 S5 只看自报的 `untyped_total = 0`、S6 只看「写过 alt_request_total 这个数」——
* 数写对了就收步,而验收比的是**基座数出来的行**(`type_table_rows = record_rows`、
* `alt_request_rows = type_c_total`)。两处比的不是同一件事,松的那一截就是返工:
* 收了步却过不了验收,模型得回头补写一张已经收掉的表。
*/
it('打完标且两张表行数对上 → S5 收得掉', () => {
const typed = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 3, quote_intake_record_rows: 3 };
expect(safeEval(doneOf('S5_classify'), typed)).toBe(true);
});
it('**数归零了但表没写全 → S5 收不掉** —— 那正是从前「收了步却过不了验收」的口子', () => {
const short = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 1, quote_intake_record_rows: 3 };
expect(safeEval(doneOf('S5_classify'), short)).toBe(false);
});
/*
* **S6 不再开单**(2026-08-02 用户当场定:「替代评估请求 / 询价请求这两项数据
* 不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容」)。
* 收步判据随之改成「两枚旗立过了」——它仍取**计数**而非布尔:
* 一单全 A 类时两枚旗都是 false,「写了 false」与「压根没写」分不清。
*/
it('去向定了 → S6 收得掉', () => {
expect(safeEval(doneOf('S6_route'), { untyped_total: 0, route_decided_total: 1 })).toBe(true);
});
it('**没记过那个数 → S6 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => {
expect(safeEval(doneOf('S6_route'), { untyped_total: 0 })).toBe(false);
});
it('全 A 类(两枚旗都是 false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => {
const allA = { untyped_total: 0, route_decided_total: 1, need_alternative: false, need_sourcing: false };
expect(safeEval(doneOf('S6_route'), allA)).toBe(true);
});
it('**S6 不写请求单** —— 那两张表已经不归 A1 产出', () => {
const writes = INTAKE_STEPS.find((x) => x.item === 'S6_route')?.writes ?? [];
expect(writes).toEqual(['quote_intake.verdict']);
});
/*
* 放行触达世界 —— 它必须由人按一下(CMMN 里标了 manualActivationRule)。
* 给它写 doneWhen 就是给引擎一把自动放行的钥匙,那是铁律③ 的红线。
*/
it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => {
expect(INTAKE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined();
});
});
describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => {
const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`);
const declaredTools = manifest.declarations.tools.map((t) => t.name);
it('每一步要写的台账都在清单的台账声明里', () => {
for (const step of INTAKE_STEPS) {
for (const address of step.writes) {
expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address);
}
}
});
it('每一步要调的工具都在清单的工具声明里', () => {
for (const step of INTAKE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool);
}
}
});
it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => {
for (const step of INTAKE_STEPS) {
expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe('');
expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20);
}
});
});
function doneOf(item: string): string {
const found = INTAKE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
/**
* **每个计划项都得有格可待**(2026-08-02 用户:「卡片的进度条走的不对」)。
*
* 从前 `INTAKE_PHASES` 漏了 `S3_confirm`:计划项六个、名单里五个。
* 于是 S2 一收,前两格全过,进度条当场跳到「判定去向」—— 而人这会儿正对着
* 受理需求审定那张卡。**他手上办的那件事在条上没有格子**,条却已经走过去了:
* 那不是走快了一格,是它在说一件没发生的事。
*/
describe('阶段刻度:每一项都有格可待', () => {
const inPhase = new Set(INTAKE_PHASES.flatMap((p) => p.items));
it('**除放行外每一项恰好属于一个阶段** —— 漏一项,进度条就会在它办着的时候跳过去', () => {
const planned = INTAKE_STEPS.map((s) => s.item).filter((id) => id !== 'T_release');
expect(planned.filter((id) => !inPhase.has(id))).toEqual([]);
});
it('一项不进两个阶段 —— 进了两个,「这一格过没过」就有两种答案', () => {
const all = INTAKE_PHASES.flatMap((p) => p.items);
expect(all.length).toBe(new Set(all).size);
});
it('**放行不在名单里** —— 收口那一格由基座统一加,活动自己起名会各叫各的', () => {
expect(inPhase.has('T_release')).toBe(false);
});
it('阶段里不许出现案例模型没有的项 —— 那一格永远过不去', () => {
const ids = new Set(INTAKE_STEPS.map((s) => s.item));
expect([...inPhase].filter((id) => !ids.has(id))).toEqual([]);
});
});