手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
/**
|
|
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。
|
|
*
|
|
* A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干,
|
|
* 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 ——
|
|
* 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。
|
|
*
|
|
* 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。
|
|
* 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、
|
|
* 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { evaluateCondition } from '@sfeel';
|
|
import { INTAKE_STEPS } 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');
|
|
// 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行
|
|
expect(confirm?.guide).toContain('"ledger":"quote_intake.record"');
|
|
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;
|
|
}
|
|
}
|