Archived
手改是**兜 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
350 lines
16 KiB
TypeScript
350 lines
16 KiB
TypeScript
/**
|
||
* 验收判据的达标口径 —— **这段错了不报错,只是那张卡上永远挂着消不掉的红叉**。
|
||
*
|
||
* 用户实测报的就是它:一单办得干干净净,卡面却停在 5/8 —— 因为 L3/L4 这类
|
||
* 机器判不了的判据被写死成 `false`,而 Agent 的 `judge` 裁定只进了留痕、
|
||
* 从没回流到判据。于是「达标数」这个读数失去意义(反正它永远不满),
|
||
* 人签字时面对一排红叉,只能凭感觉签,前面那些机检也就白做了。
|
||
*
|
||
* 口径(用户 2026-07-31 定):**提交放行时只许剩「人放行」那一条**。
|
||
* 机检未过、或裁量判据一条没裁,都不该发卡 —— 那不是「请你验收」,
|
||
* 是把没办完的活推给人。
|
||
*
|
||
* 这里测的是那条判定规则本身(纯函数级),不拉起整个装配层。
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { evaluateCondition } from '@sfeel';
|
||
import { machineCheckable } from '@contracts';
|
||
import type { AcceptanceCriterion } from '@contracts';
|
||
/*
|
||
* 判据名归一**用生产的那一个**,不再在测里抄一份。
|
||
* 抄一份的下场刚踩过:生产那边加了「削掉引擎自己贴的等级标记」,
|
||
* 测里这份还是旧的 —— 测全绿,线上照样连拒五拍熔断。
|
||
*/
|
||
import { criterionKey } from '@modules/loop-engine';
|
||
import { criterionCheck } from '../wiring/engines.ts';
|
||
|
||
|
||
/** 与 `engines.ts` 里那段判定同一口径 —— 改一处这里要跟着改,不然测的是旧规矩 */
|
||
function passOf(
|
||
c: AcceptanceCriterion,
|
||
scope: Record<string, unknown>,
|
||
judged: ReadonlyMap<string, boolean>,
|
||
): boolean {
|
||
if (machineCheckable(c)) {
|
||
if (c.predicate === undefined || c.predicate.trim() === '') return false;
|
||
try {
|
||
return evaluateCondition(c.predicate, scope);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
// L5 = 人放行:只有人按下去才算,引擎与 Agent 都不许代答
|
||
if (c.level === 'L5') return false;
|
||
return judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label)) ?? false;
|
||
}
|
||
|
||
/**
|
||
* 提交门 —— **两类判据把两道不同的关**(与 `engines.ts` 那段同一口径):
|
||
* · 机检(L1/L2):数不对就是活没干完 —— 必须为真;
|
||
* · 裁量(L3/L4):必须**裁过**,裁成什么不拦。
|
||
*
|
||
* 后半句是 2026-08-01 用实测改回来的:从前对裁量项也要求「为真」,
|
||
* 而裁量的本义恰恰是可以裁出「不满足」,且不满足正是最该交给人的情形。
|
||
* 于是模型只剩两条路 —— 改数据,或改裁断,两条都是撒谎,它两条都走过
|
||
* (先把 missing_minor_total 从 1 改回 0;换成 L4 后又把「未满足」翻成「满足」)。
|
||
* **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。**
|
||
*/
|
||
const submittable = (
|
||
criteria: readonly AcceptanceCriterion[],
|
||
scope: Record<string, unknown>,
|
||
judged: ReadonlyMap<string, boolean>,
|
||
): string[] =>
|
||
criteria
|
||
.filter((c) => {
|
||
if (c.level === 'L5') return false;
|
||
if (machineCheckable(c)) return !passOf(c, scope, judged);
|
||
// 裁量项:裁过就放行提交,裁断本身随卡交给人
|
||
return judged.get(criterionKey(c.key)) === undefined && judged.get(criterionKey(c.label)) === undefined;
|
||
})
|
||
.map((c) => c.label);
|
||
|
||
/** 照 A1 的判据表建一份(等级与谓词都取真的) */
|
||
const CRITERIA: AcceptanceCriterion[] = [
|
||
{ key: 'lines-present', label: '至少归一出一条需求', level: 'L1', predicate: 'line_total > 0' },
|
||
{ key: 'fields-complete', label: '三要素齐(PN/EAU/交期)', level: 'L1', predicate: 'missing_key_total = 0' },
|
||
{ key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped_total = 0' },
|
||
{ key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' },
|
||
{ key: 'route-judgement', label: '去向判断合乎业务常理', level: 'L4' },
|
||
{ key: 'owner-release', label: 'BD 放行', level: 'L5' },
|
||
];
|
||
|
||
/** 台账都写齐了的局面 */
|
||
const DONE: Record<string, unknown> = { line_total: 3, missing_key_total: 0, untyped_total: 0 };
|
||
|
||
describe('裁量判据读 Agent 的裁定 —— 不再一律判 false', () => {
|
||
it('**没人裁过 ≠ 通过** —— 一条没裁时 L3/L4 仍是未达标', () => {
|
||
const judged = new Map<string, boolean>();
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||
expect(passOf(CRITERIA[4]!, DONE, judged)).toBe(false);
|
||
});
|
||
|
||
it('裁定为满足 → 达标', () => {
|
||
const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), true]]);
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||
});
|
||
|
||
/*
|
||
* **这一条是拿实测的事故写的**:模型把判据名抄成了全角标点、还吃掉了空格
|
||
* (「吃不准降B,不许臆断A」),逐字比对当然对不上 —— 那条判据永远未达标,
|
||
* 提交放行被拒 32 次直到预算烧完(exhausted)。一个标点差异吃掉一整单。
|
||
*/
|
||
it('**全角标点 / 少空格照样认得出** —— 一个标点不该吃掉一整单', () => {
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降B,不许臆断A'), true],
|
||
[criterionKey('去向判断合乎业务常理(客户特殊要求、急单等)'), true],
|
||
]);
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||
const l4: AcceptanceCriterion = {
|
||
key: 'route-judgement',
|
||
label: '去向判断合乎业务常理(客户特殊要求、急单等)',
|
||
level: 'L4',
|
||
};
|
||
expect(passOf(l4, DONE, judged)).toBe(true);
|
||
});
|
||
|
||
it('归一不做模糊匹配 —— 两条不同的判据不许混成一条', () => {
|
||
const judged = new Map([[criterionKey('吃不准降 B'), true]]);
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||
});
|
||
|
||
it('裁定为不满足 → 照旧未达标(裁了不等于过)', () => {
|
||
const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), false]]);
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||
});
|
||
|
||
/* 模型写 key 还是写中文名都可能 —— 两种都认,免得一个称呼差异吃掉一条判据 */
|
||
it('criterion 用 key 写也认', () => {
|
||
const judged = new Map([[criterionKey('downgrade-safe'), true]]);
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true);
|
||
});
|
||
|
||
/*
|
||
* L5 是这道门的底:它必须由人按下去。
|
||
* 给 Agent 留一条「裁定自己放行」的缝,终局唯放行就成了一句空话。
|
||
*/
|
||
it('**L5 人放行谁也代答不了** —— 连 judge 裁了也不算', () => {
|
||
const judged = new Map([[criterionKey('BD 放行'), true], [criterionKey('owner-release'), true]]);
|
||
expect(passOf(CRITERIA[5]!, DONE, judged)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('提交放行只许剩「人放行」那一条', () => {
|
||
it('机检没过就提交 —— 拒,并说得出是哪一条', () => {
|
||
const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 };
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||
[criterionKey('去向判断合乎业务常理'), true],
|
||
]);
|
||
expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']);
|
||
});
|
||
|
||
it('机检全过但裁量项一条没裁 —— 照样拒(这正是 5/8 那一单)', () => {
|
||
expect(submittable(CRITERIA, DONE, new Map())).toEqual(['吃不准降 B,不许臆断 A', '去向判断合乎业务常理']);
|
||
});
|
||
|
||
/*
|
||
* 裁成「不满足」照样能提交 —— 这一条是 2026-08-01 拿两轮实测换来的:
|
||
* 门只接受「是」的时候,模型先改数据、后改裁断,两轮都撒了谎才过得去。
|
||
* 不满足连着理由进验收卡,才是签字的人该看见的东西。
|
||
*/
|
||
it('**裁量裁成「不满足」也能提交** —— 判断交给人,不是逼 AI 说是', () => {
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), false],
|
||
[criterionKey('去向判断合乎业务常理'), false],
|
||
]);
|
||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||
// 但它在卡面上仍是未达标 —— 提交得了,不等于这一条过了
|
||
expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false);
|
||
});
|
||
|
||
it('机检没过时,裁量裁了也拦得住 —— 两道关互不代替', () => {
|
||
const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 };
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), false],
|
||
[criterionKey('去向判断合乎业务常理'), false],
|
||
]);
|
||
expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']);
|
||
});
|
||
|
||
it('**机检全过 + 裁量都裁了 → 放行,此时只剩人那一条**', () => {
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||
[criterionKey('去向判断合乎业务常理'), true],
|
||
]);
|
||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||
// 剩下的那一条恰恰是 L5,并且它此刻确实还没达标 —— 等人按
|
||
expect(CRITERIA.filter((c) => !passOf(c, DONE, judged)).map((c) => c.level)).toEqual(['L5']);
|
||
});
|
||
|
||
it('L1/L2 漏配 predicate 判 false —— 装不出证据就不算过', () => {
|
||
const noPredicate: AcceptanceCriterion[] = [{ key: 'x', label: '没写谓词的机检项', level: 'L2' }];
|
||
expect(submittable(noPredicate, DONE, new Map())).toEqual(['没写谓词的机检项']);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡,
|
||
* 也不该显示在这里 —— 这里是判据」)。
|
||
*
|
||
* L5 是责任关,而放行卡本身就是那道关。把它列进判据表,等于在卡上摆一条
|
||
* 恒为「未达标」的项:人一路核到底,最后一行永远是红的,而消掉它的动作
|
||
* 就是按下同一张卡上的「确认通过」;「n/m 达标」那个数也因此永远差一条。
|
||
* 判据是**签字前要核的东西**,不是签字这件事自己。
|
||
*
|
||
* 声明照旧留在契约里(它说明这份交付物必须由人放行),提交门与预检也照旧
|
||
* 把它排除在外 —— 少的只是这一处呈现。这一组把两件事分开钉死。
|
||
*/
|
||
describe('放行卡的判据表:摆 L1–L4,不摆 L5', () => {
|
||
/** 与 `engines.ts` 组卡那一处同款:落到卡面之前滤掉 L5 */
|
||
const onCard = (list: readonly AcceptanceCriterion[]) => list.filter((c) => c.level !== 'L5');
|
||
|
||
it('卡面判据里没有 L5,而 L1–L4 一条不少', () => {
|
||
const shown = onCard(CRITERIA);
|
||
expect(shown.map((c) => c.level)).toEqual(['L1', 'L1', 'L2', 'L3', 'L4']);
|
||
expect(shown.some((c) => c.label === 'BD 放行')).toBe(false);
|
||
});
|
||
|
||
it('**「n/m 达标」不再永远差一条** —— 全办完时卡面是满的', () => {
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||
[criterionKey('去向判断合乎业务常理'), true],
|
||
]);
|
||
const shown = onCard(CRITERIA);
|
||
const passN = shown.filter((c) => passOf(c, DONE, judged)).length;
|
||
expect(`${passN}/${shown.length}`).toBe('5/5');
|
||
// 摆着 L5 的时候它永远是 5/6 —— 最后一行只有按下按钮才会绿
|
||
expect(CRITERIA.filter((c) => passOf(c, DONE, judged)).length).toBe(5);
|
||
});
|
||
|
||
it('契约里仍然声明着 L5 —— 少的只是呈现,不是那道关', () => {
|
||
expect(CRITERIA.some((c) => c.level === 'L5')).toBe(true);
|
||
// 提交门照旧不因为它拦人(它由人按下去消掉,不由 Agent 办)
|
||
const judged = new Map([
|
||
[criterionKey('吃不准降 B,不许臆断 A'), true],
|
||
[criterionKey('去向判断合乎业务常理'), true],
|
||
]);
|
||
expect(submittable(CRITERIA, DONE, judged)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* **裁量判据带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)。
|
||
*
|
||
* 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题:
|
||
* 前半句(已补齐)基座数得出来,后半句(人确认先受理)才需要人。
|
||
* 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了,
|
||
* 它照样裁「未满足」,理由写「NE555P 缺目标价,客户未提供」。
|
||
* 用户原话:「这个价格是我填写的,但是它判错了」。
|
||
*
|
||
* **数得出来的就别让它自报** —— 这条纪律在这个仓里第三次被违反。
|
||
*/
|
||
describe('criterionCheck —— 卡面与提交门同一段', () => {
|
||
const optional = {
|
||
key: 'optional-asked',
|
||
label: '选填缺口:已补齐,或由你放行时确认可以先受理',
|
||
level: 'L4' as const,
|
||
predicate: 'quote_intake_record_gaps = 0',
|
||
};
|
||
const stale = { satisfied: false, rationale: 'NE555P 缺目标价,客户未提供,已按历史价估 8 元' };
|
||
|
||
it('**数据说补齐了 → 达标,模型那句相反的话一并丢掉**', () => {
|
||
const got = criterionCheck(optional, { quote_intake_record_gaps: 0 }, stale);
|
||
expect(got.pass).toBe(true);
|
||
expect(got.settled).toBe(true);
|
||
expect(got.rationale).toBeUndefined(); // 一件已办成的事,不需要谁再解释为什么没办成
|
||
});
|
||
|
||
it('数据说还缺着 → 轮到裁量:裁断与理由照旧交给签字的人', () => {
|
||
const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, stale);
|
||
expect(got.pass).toBe(false);
|
||
expect(got.settled).toBe(true); // 裁过了(裁成不满足照样能提交)
|
||
expect(got.rationale).toContain('NE555P');
|
||
});
|
||
|
||
it('数据说还缺着、又没人裁过 → 未达标且**未裁**,提交门该拦住', () => {
|
||
const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, undefined);
|
||
expect(got.pass).toBe(false);
|
||
expect(got.settled).toBe(false);
|
||
});
|
||
|
||
it('**不带谓词的裁量项照旧只读裁断** —— 机器判不了的别硬判', () => {
|
||
const l3 = { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' as const };
|
||
expect(criterionCheck(l3, {}, undefined).pass).toBe(false);
|
||
expect(criterionCheck(l3, {}, { satisfied: true }).pass).toBe(true);
|
||
});
|
||
|
||
it('机检项不掺裁断 —— 模型裁「满足」也翻不动一个假的数', () => {
|
||
const l1 = { key: 'k', label: '必填齐', level: 'L1' as const, predicate: 'quote_intake_record_missing = 0' };
|
||
expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).pass).toBe(false);
|
||
expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).settled).toBeUndefined();
|
||
});
|
||
|
||
it('L5 只有人按得下 —— 引擎与 Agent 都不许代答', () => {
|
||
const l5 = { key: 'bd', label: 'BD 放行', level: 'L5' as const, predicate: '1 = 1' };
|
||
expect(criterionCheck(l5, {}, { satisfied: true }).pass).toBe(false);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。
|
||
*
|
||
* 那一单跳过了打标与定去向就来提交:判据正确地拦住了,而拒回的话给了两条路 ——
|
||
* 改数、改行。两条都不对:数是对的(它诚实地报了「3 条没打标」)、
|
||
* 行也是对的(记录三行一行不多),**真正该做的是回去把那一步办了**。
|
||
* 于是它只能原样重提,连撞五拍熔断。
|
||
*
|
||
* 判法很硬:判据里点名的那张表,这一单一行都没写(基座数的 `_rows` 是 0),
|
||
* 那就不是数错也不是行错,是那一步压根没办。
|
||
*/
|
||
describe('拒回指路:一行都没写 = 那一步还没办', () => {
|
||
/** 与 `engines.ts` 那段挑表同一口径 —— 改一处这里要跟着改 */
|
||
const emptyTables = (predicates: readonly string[], scope: Record<string, unknown>): string[] => [
|
||
...new Set(
|
||
predicates.flatMap((p) =>
|
||
[...p.matchAll(/([a-z][a-z0-9_]*)_rows\b/g)]
|
||
.map((m) => m[1] ?? '')
|
||
.filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0),
|
||
),
|
||
),
|
||
];
|
||
|
||
it('**那张表一行都没写 → 点名它**,别让模型去改数改行', () => {
|
||
const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], {
|
||
quote_intake_type_table_rows: 0,
|
||
quote_intake_record_rows: 3,
|
||
});
|
||
expect(got).toEqual(['quote_intake_type_table']);
|
||
});
|
||
|
||
it('表写过了、只是数对不上 → 不点名,照旧走「数还是行」那条指路', () => {
|
||
const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], {
|
||
quote_intake_type_table_rows: 2,
|
||
quote_intake_record_rows: 3,
|
||
});
|
||
expect(got).toEqual([]);
|
||
});
|
||
|
||
it('读不到那个数就不猜 —— 宁可少说一句,不可编一句', () => {
|
||
expect(emptyTables(['quote_intake_type_table_rows = 3'], {})).toEqual([]);
|
||
});
|
||
|
||
it('几条判据点到同一张表只说一遍', () => {
|
||
const got = emptyTables(
|
||
['quote_intake_type_table_rows = quote_intake_record_rows', 'quote_intake_type_table_rows > 0'],
|
||
{ quote_intake_type_table_rows: 0, quote_intake_record_rows: 3 },
|
||
);
|
||
expect(got).toEqual(['quote_intake_type_table']);
|
||
});
|
||
});
|