/** * 验收判据的达标口径 —— **这段错了不报错,只是那张卡上永远挂着消不掉的红叉**。 * * 用户实测报的就是它:一单办得干干净净,卡面却停在 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, judged: ReadonlyMap, ): 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, judged: ReadonlyMap, ): 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 = { line_total: 3, missing_key_total: 0, untyped_total: 0 }; describe('裁量判据读 Agent 的裁定 —— 不再一律判 false', () => { it('**没人裁过 ≠ 通过** —— 一条没裁时 L3/L4 仍是未达标', () => { const judged = new Map(); 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[] => [ ...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']); }); });