This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/__tests__/acceptance-gate.test.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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
2026-08-02 13:41:44 +08:00

350 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 验收判据的达标口径 —— **这段错了不报错,只是那张卡上永远挂着消不掉的红叉**。
*
* 用户实测报的就是它:一单办得干干净净,卡面却停在 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']);
});
});