脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙

凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 09:31:35 +08:00
co-authored by Claude Opus 5
parent e235207e4a
commit df18882322
16 changed files with 490 additions and 43 deletions
@@ -31,6 +31,37 @@ const pair = (parameterName: string, originalValue: string, candidateValue: stri
...extra,
});
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'LM2596S-ADJ',
pairs: [
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
],
},
],
});
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
});
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'X',
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
},
],
});
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
});
});
describe('compare-parameters:逐参数四态', () => {
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/
import { describe, expect, it } from 'vitest';
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -284,6 +284,36 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
});
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
expect(
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
).toEqual(['parts.profile', 'parts.parameter']);
});
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
const watched = [
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
];
const declared = ['parts.profile', 'parts.parameter'];
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
// 一张有一张没有 → 只点没有的那一张
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
});
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
});
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
});
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
expect(gateAddressesOf([], [{}])).toEqual([]);
+47 -6
View File
@@ -794,33 +794,74 @@ async function submitGate(
if (task === undefined) return undefined;
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
if (manifest === undefined) return undefined;
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables, order.gateLedgers);
if (addresses.length === 0) return undefined;
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
if (watched.length === 0) return undefined;
/*
* **卡自己点名要拦的表,「一行都没有」也算没完成**(2026-08-27 W05 e2e 实测)。
*
* 缺必填的算法只数**已有行**缺不缺格子 —— 零行 = 零缺,闸照开。实测那一单:
* 模型压拍,骨架一行没写就把 P-08 数据工单发了出去;人打开件是空的,提交钮却绿着,
* 交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被 refersTo 那道墙拦下。
* 只对**卡自报**(`gateLedgers`)那几张表这么判 —— 那是发卡的人明说「这张卡就是来填它的」;
* 回落到必交付物的老路一个字不变(那条路上零行是常态,比如还没到写的时候)。
*/
const declared = (order.gateLedgers ?? []).map((a) => a.trim()).filter((a) => a !== '');
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
const mine: Record<string, unknown[]> = {};
for (const draft of await container.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
}
return gateOf(missingReadings(mine, watched), watched);
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
}
/**
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
* 而失效的闸在页面上看不出来(按钮照常绿着)。
* **卡自报要拦的表里,有没有「一行都没有」的** —— 有就是没完成,别的都不用算了。
*
* 「缺必填」的算法只数**已有行**缺不缺格子:零行 = 零缺,闸照开(2026-08-27 W05 e2e 实测)。
* 那一单模型压拍、骨架一行没写就把数据工单发了出去 —— 人打开件是空的、什么也补不了,
* 提交钮却绿着;交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被引用检查拦下。
*
* 只对**卡自报**(`gateLedgers`)那几张表这么判:那是发卡的人明说「这张卡就是来填它的」。
* 回落到必交付物的老路一个字不变 —— 那条路上零行是常态(还没轮到写的时候)。
*/
export function emptyGate(
mine: Readonly<Record<string, ReadonlyArray<unknown>>>,
watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>,
declared: ReadonlyArray<string>,
): { ready: boolean; why: string; blanks: string[] } | undefined {
if (declared.length === 0) return undefined;
const empty = watched.filter(
(l) => declared.includes(`${l.domain}.${l.object}`) && (mine[`mine:${l.domain}.${l.object}`] ?? []).length === 0,
);
if (empty.length === 0) return undefined;
const names = empty.map((l) => l.label ?? `${l.domain}.${l.object}`);
return { ready: false, why: `${names.join('、')} 这一单还一行都没有 —— 这张卡就是来建它的,先在件上加一行填好再提交`, blanks: [] };
}
/**
* 闸看哪几张表,三级取一:
* ①卡上绑了表(`blocks`)就看绑的那几张;
* ②**卡自己声明了 `gateLedgers`** 就看它 —— 零输入卡里,只有发卡的人知道这一张卡要人补哪几张表
* (2026-08-27 W05 实测:数据工单要补的是 `parts.*` 主数据,不在必交付物名单里,
* 闸于是盯着一堆与这张卡无关的表、一格不缺、空手交卷照放);
* ③都没有才回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
* 三级全空才真的没有依据 —— **没有依据的时候不拦人**。
* 抽成纯函数是为了钉测:回落断了闸就整个失效,而失效的闸在页面上看不出来(按钮照常绿着)。
*/
export function gateAddressesOf(
blocks: ReadonlyArray<ViewBlock> | undefined,
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
declared?: ReadonlyArray<string> | undefined,
): string[] {
const bound = (blocks ?? [])
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
.map((b) => b.ledger as string);
if (bound.length > 0) return bound;
const own = (declared ?? []).map((a) => a.trim()).filter((a) => a !== '');
if (own.length > 0) return own;
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
}
+20 -9
View File
@@ -357,9 +357,20 @@ type ParamPair = {
candidateValue: string;
unit?: string;
constraintType: 'hard' | 'soft';
requirement?: string;
evidenceRef?: string;
/**
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
*/
requirement?: string | null;
evidenceRef?: string | null;
};
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
function text(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
@@ -397,9 +408,9 @@ function compareParsed(o: Parsed, c: Parsed): CompareResult {
}
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined {
if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const s = requirement.trim();
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
const s = text(requirement);
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
const hi = c.kind === 'range' ? c.hi : c.value;
@@ -415,8 +426,8 @@ function satisfiesRequirement(requirement: string | undefined, c: Parsed): boole
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()];
const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()];
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
if (ro === undefined || rc === undefined) return undefined;
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
}
@@ -430,11 +441,11 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
const req = satisfiesRequirement(p.requirement, c);
if (req === false) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` };
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
}
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
if (compareResult === 'equal' || compareResult === 'covers') {
if (hard && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
+11 -1
View File
@@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
'要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
@@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
: [];
const standard = String(input.card['standard'] ?? '').trim();
/*
* **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
* 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
* 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
* 认不出形状的一律丢掉,不把脏东西端给闸。
*/
const gateLedgers = Array.isArray(input.card['gateLedgers'])
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
: [];
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
@@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(steps.length === 0 ? {} : { steps }),
...(standard === '' ? {} : { standard }),
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });