/** * 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) => { 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; }; type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string }; type Result = { candidateInternalPn: string; items: Item[]; counts: Record }; const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record = {}) => ({ 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'); }); });