Archived
凭证到位后 `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
161 lines
9.0 KiB
TypeScript
161 lines
9.0 KiB
TypeScript
/**
|
|
* ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
|
|
* 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
|
|
* A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
|
|
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
|
|
|
const handler = (name: string) => {
|
|
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
|
|
if (found === undefined) throw new Error(`没有工具 ${name}`);
|
|
return found[1];
|
|
};
|
|
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
|
|
const call = async (name: string, input: Record<string, unknown>) => {
|
|
const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
|
|
if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
|
|
return out.output as Record<string, unknown>;
|
|
};
|
|
|
|
type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
|
|
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };
|
|
|
|
const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
|
|
parameterName,
|
|
originalValue,
|
|
candidateValue,
|
|
constraintType,
|
|
evidenceRef: 'P-08 · P-10 Rev H',
|
|
...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', {
|
|
candidates: [
|
|
{
|
|
candidateInternalPn: 'XL2596S-5.0',
|
|
pairs: [
|
|
pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
|
|
pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
|
|
pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
|
|
pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
|
|
],
|
|
},
|
|
{
|
|
candidateInternalPn: 'LM2596S-ADJ',
|
|
pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
|
|
},
|
|
],
|
|
});
|
|
const results = out['results'] as Result[];
|
|
const a = results[0]!;
|
|
const b = results[1]!;
|
|
expect(out['ruleVersion']).toBe('R-2026.07');
|
|
const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
|
|
expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
|
|
expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
|
expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
|
|
expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
|
|
expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
|
|
expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
|
|
expect(b.counts['configurable']).toBe(1);
|
|
});
|
|
|
|
it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
|
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
candidates: [
|
|
{
|
|
candidateInternalPn: 'CN-DCDC-32V',
|
|
pairs: [
|
|
pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
|
|
pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const c = (out['results'] as Result[])[0]!;
|
|
expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
|
|
expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
|
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
|
|
});
|
|
|
|
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
|
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
|
|
});
|
|
const x = (out['results'] as Result[])[0]!;
|
|
expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
|
|
});
|
|
|
|
it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
|
|
const out = await call('part-substitution-assessment.compare-parameters', {
|
|
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
|
|
});
|
|
const x = (out['results'] as Result[])[0]!;
|
|
// 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
|
|
expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
|
|
expect(x.items[3]?.compareResult).toBe('covers');
|
|
});
|
|
});
|
|
|
|
describe('recommend:推荐等级四档', () => {
|
|
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
|
|
const out = await call('part-substitution-assessment.recommend', {
|
|
candidates: [
|
|
{ candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
|
|
{ candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
|
{ candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
|
|
{ candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
|
],
|
|
});
|
|
const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
|
|
expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
|
|
['C', 'not_recommended', 'high'],
|
|
['A', 'conditional', 'medium'],
|
|
['B', 'alternate', 'medium'],
|
|
['D', 'recommended', 'low'],
|
|
]);
|
|
});
|
|
|
|
it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
|
|
const out = await call('part-substitution-assessment.recommend', {
|
|
candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
|
|
});
|
|
expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
|
|
});
|
|
});
|