From 716f17ee2acb03675c5777321c6c6dce66c2a1a5 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 21:20:56 +0800 Subject: [PATCH] =?UTF-8?q?chore(w05):=20=E4=B8=A4=E6=9E=9A=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=B8=8E=E4=B8=A4=E5=BC=A0=E5=88=A4=E5=AE=9A=E8=A1=A8?= =?UTF-8?q?=E9=80=80=E5=BD=B9,=E6=8A=80=E8=83=BD=E6=88=B3=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20=E2=80=94=E2=80=94=20=E8=A7=84=E5=88=99=E5=8F=AA?= =?UTF-8?q?=E8=AE=B8=E6=9C=89=E4=B8=80=E5=A4=84=E7=9C=9F=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。 再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。 技能戳是给模型看的,而这个活动全程没有 agent。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../src/constants.ts | 4 +- .../src/manifest.ts | 2 +- .../src/skills.ts | 55 --- .../first-party-substitution.test.ts | 321 +--------------- apps/api/src/app/container.ts | 21 +- apps/api/src/app/seed-activities.ts | 11 - apps/api/src/app/seed-mvp.ts | 5 +- apps/api/src/app/tools-w05.ts | 99 ----- apps/api/src/app/tools/first-party.ts | 347 +----------------- 9 files changed, 45 insertions(+), 820 deletions(-) delete mode 100644 activities/part-substitution-assessment/src/skills.ts delete mode 100644 apps/api/src/app/tools-w05.ts diff --git a/activities/part-substitution-assessment/src/constants.ts b/activities/part-substitution-assessment/src/constants.ts index 83a3184..d295a0c 100644 --- a/activities/part-substitution-assessment/src/constants.ts +++ b/activities/part-substitution-assessment/src/constants.ts @@ -8,6 +8,6 @@ export const RULE_VERSION = 'R-2026.07'; * `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量已随 2026-08-29 的整体重写退役 * (spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §1/§8): * 逐项四态改由 S2 唯一一次 `analyze` 出场判定,推荐等级改由配方按 §3.5 的规则算 —— - * 两枚工具在工具注册表里的停用排在 Task 10。案例模型(`case-model.ts`)里两处引用 - * 已就地改写成字面量,Task 3 整体重写该文件时会连同工具调用一起撤掉。 + * 两枚工具已在 Task 10 于工具注册表里停用(墓志铭见 `apps/api/src/app/tools/first-party.ts`)。 + * 案例模型(`case-model.ts`)全程零工具调用,没有第二处字面量引用留着。 */ diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 98ac155..7e14a6d 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -435,7 +435,7 @@ export const manifest: ActivityManifest = { * 各存一份。Task 3 会把 `steps` 换成四步、`guide` 全部清空(auto 配方接管行为)。 */ businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES }, - /* 三条技能已随重写退役(全程无 agent,没有临场编排可言);skills.ts 的删除排在 Task 10 */ + /* 三条技能已随重写退役(全程无 agent,没有临场编排可言);skills.ts 已在 Task 10 删除 */ skills: [], /* * 五张旧局面全部作废(它们绑的九步与旧台账地址已经不存在)。**Task 7 已补回第一张**: diff --git a/activities/part-substitution-assessment/src/skills.ts b/activities/part-substitution-assessment/src/skills.ts deleted file mode 100644 index 03f6989..0000000 --- a/activities/part-substitution-assessment/src/skills.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。 - * 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。 - */ -import { skillIdSchema } from '@contracts'; -import type { Skill } from '@contracts'; - -export const ASSESSMENT_SKILLS: Skill[] = [ - { - id: skillIdSchema.parse('substitution.normalize-parameters'), - label: '把多来源资料整成可比对的标准参数对照表', - applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。', - method: [ - '参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。', - '对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);', - '候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。', - '单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。', - '约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。', - '**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。', - ].join('\n'), - crystallized: [], - knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'], - version: '1.0.0', - }, - { - id: skillIdSchema.parse('substitution.difference-judgement'), - label: '逐项四态由工具算,证据与影响由你写', - applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。', - method: [ - '四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。', - '每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。', - '客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。', - '工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;', - '`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。', - '推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。', - ].join('\n'), - crystallized: [], - knowledgeRefs: ['四态口径', '推荐等级四档'], - version: '1.0.0', - }, - { - id: skillIdSchema.parse('substitution.landing'), - label: '落账只写分析层,客户批准不碰', - applicability: '审核通过之后拟 P-11 / 回写 P-01 时。', - method: [ - '`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。', - 'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。', - '规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。', - '被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。', - ].join('\n'), - crystallized: [], - knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'], - version: '1.0.0', - }, -]; diff --git a/apps/api/src/app/__tests__/first-party-substitution.test.ts b/apps/api/src/app/__tests__/first-party-substitution.test.ts index 8023f0e..d04a55b 100644 --- a/apps/api/src/app/__tests__/first-party-substitution.test.ts +++ b/apps/api/src/app/__tests__/first-party-substitution.test.ts @@ -1,307 +1,26 @@ /** - * ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。 - * 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵: - * A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/ - * C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。 + * 【ACT-WORK-05 两枚工具的墓志铭】`part-substitution-assessment.compare-parameters` + * (逐参数四态判定)与 `part-substitution-assessment.recommend`(推荐等级定级)曾经在 + * `../tools/first-party.ts` 里跑,这份文件曾逐条钉死它们的比对语义(硬/软约束、范围 + * 解析、客户约束边界、四档推荐等级……)。 + * + * ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定改成 + * `activities/part-substitution-assessment` 的 `case-model.ts` 里 `S2_assess.auto` + * 唯一一次 `analyze`(系统组装入参、模型只答判断),推荐等级改成同一步 `do` 链里的 + * 投影按 spec §3.5 的 S-FEEL when 链现算 —— 规则只许有一处真源,这两枚工具与它们 + * 对应的 D01/D02 两张 DMN 已经同批退役(见 `first-party.ts` 里留的墓志铭)。 + * + * 这份文件不删(§12.5「不删测试」的铁律拦的是「删测试让门禁变绿」),但逐条比对 + * 用例测的是已经不存在的代码,只能跟着它测的东西一起退场,缩成一条退役断言 —— + * 拦住的是「哪天有人手滑又把这两枚工具注册回去」。完整的历史用例见 git 历史。 */ import { describe, expect, it } from 'vitest'; -import { FIRST_PARTY_TOOLS, compareParametersWith, describeParsed, parseParamValue } from '../tools/first-party.ts'; -import type { ParameterLedger } from '../tools/first-party.ts'; -import type { TaskId } from '@contracts'; +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 }); - }); - - /* - * 2026-08-27 晚,用户送来 0bddd862:客户写「输入电压在 45V 以内」→ requirement ">= 45",而原 PN 与候选的输入 - * 上限都是 40V。从前客户约束一票否决:两颗一模一样却判硬约束 MISMATCH、整颗不推荐。差异评估比的是 - * 「换上去会不会比现在差」—— 原 PN 自己也过不了的客户线,是需求边界的事,不是替代差异。 - */ - it('候选与原 PN 相等、客户约束两颗都不满足 → 仍是 MATCH(风险中、备注点名需求边界),不记在候选头上', async () => { - const out = await call('part-substitution-assessment.compare-parameters', { - candidates: [ - { - candidateInternalPn: 'LM2596S-ADJ', - pairs: [ - pair('输入电压范围', '4.5~40', '4.5~40', 'hard', { unit: 'V', requirement: '>= 45' }), - // 候选盖住原范围、客户线两颗都过不去 —— 同理 - pair('工作温度', '-40~85', '-40~125', 'hard', { unit: '℃', requirement: '-55~125' }), - // 候选比原 PN 窄且过不了客户线 —— 仍是 MISMATCH - pair('输出电流', '3', '2', 'hard', { unit: 'A', requirement: '>= 3' }), - ], - }, - ], - }); - const c = (out['results'] as Result[])[0]!; - expect(c.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', riskLevel: 'medium' }); - expect((c.items[0] as Item & { note: string }).note).toMatch(/需求边界/); - expect(c.items[1]).toMatchObject({ compareResult: 'covers', differenceStatus: 'match' }); - expect(c.items[2]).toMatchObject({ compareResult: 'differs', differenceStatus: 'mismatch', riskLevel: 'high' }); - expect(c.counts).toMatchObject({ match: 2, mismatch: 1, hardMismatch: 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'); - }); -}); - -/* - * **比对入参由基座从台账取**(2026-08-27 晚,用户:「要让它认真比对每一条,然后再输出结论」): - * 模型只报料号与客户约束;参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读。 - */ -describe('compare-parameters:按料号从台账取参数', () => { - const rows: Record>> = { - 'parts.parameter|LM2596S-5.0': [ - { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' }, - { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard' }, - { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard' }, - ], - 'parts.parameter|LM2596S-ADJ': [ - { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' }, - { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard' }, - // 候选多出来的参数不进比对(原 PN 定义比对集),只在 extraCandidateParams 里点名 - { internal_pn: 'LM2596S-ADJ', param_name: '反馈电压', param_value: '1.23', param_unit: 'V', constraint_type: 'soft' }, - ], - 'parts.profile|LM2596S-5.0': [{ internal_pn: 'LM2596S-5.0', package: 'TO-263-5', lifecycle_status: 'nrnd' }], - 'parts.profile|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', package: 'TO-263-5', lifecycle_status: 'active' }], - 'parts.document|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', document_type: 'datasheet', document_version: 'Rev H (2023-09)', document_ref: 'https://x/lm2596.pdf' }], - }; - const seen: string[] = []; - const ledger: ParameterLedger = { - rows: async (taskId, address, where) => { - seen.push(`${taskId ?? '-'}:${address.domain}.${address.object}|${where['internal_pn']}`); - return rows[`${address.domain}.${address.object}|${where['internal_pn']}`] ?? []; - }, - }; - const tool = compareParametersWith(ledger); - const run = async (input: Record, taskId?: TaskId) => { - const out = await tool({ input, connection: {}, operation: undefined, idempotencyKey: undefined, taskId }); - return out; - }; - - it('只给料号 + 客户约束 → 每一对都是台账原值;封装 / 生命周期从档案来;证据引用从资料记录来;读的是**这一单**的任务视图', async () => { - const out = await run({ originalInternalPn: 'LM2596S-5.0', candidates: [{ candidateInternalPn: 'LM2596S-ADJ', requirements: { 输入电压范围: '>= 45' } }] }, 't-1' as TaskId); - expect(out.ok).toBe(true); - const r = (out.output!['results'] as Array>)[0]!; - expect(r['source']).toBe('ledger'); - const items = r['items'] as Array>; - const byName = Object.fromEntries(items.map((i) => [i['parameterName'] as string, i])); - expect(byName['输入电压范围']).toMatchObject({ originalValue: '4.5~40', candidateValue: '4.5~40', unit: 'V', constraintType: 'hard', requirement: '>= 45', evidenceRef: 'P-08 · P-10 Rev H (2023-09)', differenceStatus: 'match' }); - expect(byName['输出电压']).toMatchObject({ originalValue: '5.0', candidateValue: '1.23~37', compareResult: 'configurable', differenceStatus: 'conditional' }); - // 原 PN 有、候选没有 → 候选值空 → UNKNOWN - expect(byName['使能阈值']).toMatchObject({ originalValue: '1.4', candidateValue: '', differenceStatus: 'unknown' }); - expect(byName['封装']).toMatchObject({ originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard', differenceStatus: 'match' }); - expect(byName['生命周期']).toMatchObject({ originalValue: 'nrnd', candidateValue: 'active', constraintType: 'soft', compareResult: 'covers', differenceStatus: 'match' }); - expect(r['extraCandidateParams']).toEqual(['反馈电压']); - expect(r['counts']).toMatchObject({ match: 3, conditional: 1, unknown: 1, mismatch: 0 }); - expect(seen.every((s) => s.startsWith('t-1:'))).toBe(true); - }); - - it('原 PN 台账里没有 → 如实报错,不比一半;没给 originalInternalPn → 拒', async () => { - const missing = await run({ originalInternalPn: 'NOPE', candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] }); - expect(missing.ok).toBe(false); - expect(missing.error?.message).toMatch(/NOPE/); - const noOriginal = await run({ candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] }); - expect(noOriginal.ok).toBe(false); - expect(noOriginal.error?.message).toMatch(/originalInternalPn/); - }); - - it('自带 pairs 的老形状照旧接(试运行 / 老调用方),source 标 pairs', async () => { - const out = await run({ candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0', '5.0', 'hard')] }] }); - expect(out.ok).toBe(true); - const r = (out.output!['results'] as Array>)[0]!; - expect(r['source']).toBe('pairs'); - expect((r['items'] as Array>)[0]).toMatchObject({ originalValue: '5.0', candidateValue: '5.0', differenceStatus: 'match' }); - }); - - it('没有台账的那份(静态名单)遇到不带 pairs 的入参 → 如实说这条通道读不到台账', async () => { - const out = await compareParametersWith()({ input: { originalInternalPn: 'A', candidates: [{ candidateInternalPn: 'B' }] }, connection: {}, operation: undefined, idempotencyKey: undefined }); - expect(out.ok).toBe(false); - expect(out.error?.message).toMatch(/读不到台账/); - }); -}); - -/* - * 2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」。 - * 解析器把各种写法都认成范围,出参 readAs 把「读作什么」摆回去 —— 比对依据可见,模型不必自己再解释一遍值。 - */ -describe('值的读法:「下限~上限」一律是范围', () => { - it('分隔符 / 正负号 / 单位写进值里 / Unicode 减号 / 省略号 / 中文「至到」都认成范围;单边界读成半开范围;定值一个数', () => { - const range = (v: string) => parseParamValue(v); - for (const v of ['4.5~40', '4.5~40', '4.5-40', '4.5 ~ 40', '4.5V~40V', '4.5V ~ 40V (典型)', '4.5…40', '4.5...40', '4.5至40', '4.5到40', '4.5 to 40']) { - expect(range(v), v).toEqual({ kind: 'range', lo: 4.5, hi: 40 }); - } - for (const v of ['-40~+85', '-40~85', '−40~+85', '-40℃~+85℃', '−40 ℃ … +85 ℃', '-40 - 85', '-40 to +85']) { - expect(range(v), v).toEqual({ kind: 'range', lo: -40, hi: 85 }); - } - expect(range('≥45')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY }); - expect(range('45 以上')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY }); - expect(range('<= 40')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 }); - expect(range('40V 以内')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 }); - expect(range('40')).toEqual({ kind: 'number', value: 40 }); - expect(range('5.0(固定)')).toEqual({ kind: 'number', value: 5 }); - expect(range('150 kHz')).toEqual({ kind: 'number', value: 150 }); - expect(range('TO-263-5')).toEqual({ kind: 'text', value: 'to-263-5' }); - }); - - it('readAs 说人话:范围 / 定值 / 单边界 / 文本;纯文本(封装)不啰嗦,像范围却没读成的才提示怎么写', () => { - expect(describeParsed(parseParamValue('-40~+85'))).toBe('范围 -40~85'); - expect(describeParsed(parseParamValue('5.0'))).toBe('定值 5'); - expect(describeParsed(parseParamValue('≥45'))).toBe('下界 ≥ 45'); - expect(describeParsed(parseParamValue('TO-263-5'))).toBe('文本「to-263-5」'); - expect(describeParsed(parseParamValue('约 4.5 ~ 40'))).toMatch(/请写成 下限~上限/); - }); - - it('两颗都写 4.5V~40V(单位在值里)→ 读成同一段范围 → equal / MATCH,出参 readAs 摆出「范围 4.5~40」', async () => { - const out = await call('part-substitution-assessment.compare-parameters', { - candidates: [{ candidateInternalPn: 'X', pairs: [pair('输入电压范围', '4.5V~40V', '4.5 ~ 40 V', 'hard', { unit: 'V' }), pair('工作温度', '-40℃~+85℃', '−40~+85', 'hard', { unit: '℃' })] }], - }); - const items = (out['results'] as Array<{ items: Array }>)[0]!.items; - expect(items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 4.5~40', candidate: '范围 4.5~40' } }); - expect(items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 -40~85', candidate: '范围 -40~85' } }); - }); -}); - -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'); +describe('ACT-WORK-05 两枚工具已退役', () => { + it('不在注册表里 —— 规则活在 S2 的配方里,不再有第二处真源', () => { + const names = FIRST_PARTY_TOOLS.map(([n]) => n); + expect(names).not.toContain('part-substitution-assessment.compare-parameters'); + expect(names).not.toContain('part-substitution-assessment.recommend'); }); }); diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 92b58b4..0c2ea7b 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -67,8 +67,7 @@ import type { ProcessService } from '@modules/process'; import type { TriggerService } from '@modules/trigger'; import type { MinuteService } from '@modules/minute'; import type { ReportService } from '@modules/report'; -import { FIRST_PARTY_TOOLS, compareParametersWith } from './tools/first-party.ts'; -import type { ParameterLedger } from './tools/first-party.ts'; +import { FIRST_PARTY_TOOLS } from './tools/first-party.ts'; import { httpToolHandler } from './tools/http-tool.ts'; import { createMastraSpanFactory } from './mastra-spans.ts'; import { createTraceService, createTraceStore } from '@modules/trace'; @@ -418,22 +417,12 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 /* - * 差异评估的比对工具**从台账取参数**(2026-08-27 晚):静态名单里那份只认 pairs;这里用 ledger 顶掉 —— - * 有 taskId 读任务视图(正式版 + 本单草稿),没有(试运行)只读正式版。只读。 + * 差异评估的两枚比对工具已退役(2026-08-29,墓志铭见 `tools/first-party.ts`)—— + * 规则改由 `part-substitution-assessment` 的 `S2_assess.auto` 配方现算,这里的 + * `parameterLedger`(从台账取参数的只读口)随之一并撤,不再有第二处消费方。 */ - const parameterLedger: ParameterLedger = { - rows: async (taskId, address, where) => { - const query = { address, where, orderBy: [], limit: 500, offset: 0 }; - const page = taskId === undefined ? await ledger.query(query) : await ledger.taskView(taskId, query); - return page.rows; - }, - }; const toolRuntime = createToolRuntime( - new Map([ - ...FIRST_PARTY_TOOLS, - ['part-substitution-assessment.compare-parameters', compareParametersWith(parameterLedger)], - ['mail.send', mailSendTool(mail)], - ]), + new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler, ); const schemas = createSchemaValidator(); diff --git a/apps/api/src/app/seed-activities.ts b/apps/api/src/app/seed-activities.ts index 754b83b..732f798 100644 --- a/apps/api/src/app/seed-activities.ts +++ b/apps/api/src/app/seed-activities.ts @@ -16,7 +16,6 @@ import { join, resolve } from 'node:path'; import { activityManifestSchema } from '@contracts'; import type { Container } from './container.ts'; import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts'; -import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts'; import { createActivityOpsStore } from './activity-ops/store.ts'; /** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */ @@ -96,16 +95,6 @@ export async function installRealActivities(container: Container): Promise (conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`), ); - /* - * **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的 - * 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。 - * 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。 - */ - for (const contract of W05_TOOLS) { - const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] }); - if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`); - } - const root = activitiesRoot(); for (const dir of REAL_PACKAGES) { diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts index fc6d5d4..cad8758 100644 --- a/apps/api/src/app/seed-mvp.ts +++ b/apps/api/src/app/seed-mvp.ts @@ -12,7 +12,6 @@ import type { Container } from './container.ts'; import { installRealActivities } from './seed-activities.ts'; import { installSlots } from './seed-mvp-slots.ts'; import { seedMvpFlow } from './seed-mvp-flow.ts'; -import { W05_TOOLS } from './tools-w05.ts'; /** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */ const HISTORY_TOOL = { @@ -329,7 +328,7 @@ export async function seedMvp(container: Container): Promise { log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`); /* ② 功能工具注册两审 + 逐槽位白名单 */ - for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS]) { + for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) { const verdict = await container.toolbox.register({ contract, samples: [ @@ -360,7 +359,7 @@ export async function seedMvp(container: Container): Promise { for (const [activityId, tools] of Object.entries(ALLOWLIST)) { await container.toolbox.setAllowlist(activityId, tools); } - log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 6,逐活动授权)'); + log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)'); /* * ③ 活动装入 —— 两批: diff --git a/apps/api/src/app/tools-w05.ts b/apps/api/src/app/tools-w05.ts deleted file mode 100644 index 24c147a..0000000 --- a/apps/api/src/app/tools-w05.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。 - * 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 —— - * 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑 - * activities:install 的人第一次跑活动就撞「工具不存在」)。 - */ -const PURE = { - effect: 'read' as const, - errors: [] as Array<{ code: string; retryable: boolean; message: string }>, - idempotent: true, - timeoutMs: 5_000, - retries: 0, - connectionKeys: [] as string[], - pure: true, - ownerActivityId: 'part-substitution-assessment', -}; - -/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */ -export const COMPARE_PARAMETERS_TOOL = { - ...PURE, - name: 'part-substitution-assessment.compare-parameters', - displayName: '替代料逐参数比对', - /* - * 1.1.0(2026-08-27):客户约束原 PN 自己也不满足、候选与原 PN 相等或盖住时不再判 mismatch —— 那是需求边界,不是替代差异。 - * 1.2.0(同日):**比对入参由基座从台账取** —— 给 originalInternalPn + candidates[].candidateInternalPn,参数 / 封装 / 生命周期 / - * 证据引用从 parts.* 现读,模型只译客户约束(requirements);老形状(自带 pairs)照旧接。出参每项带 originalValue / candidateValue / evidenceRef。 - */ - version: '1.3.0', - description: - '按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' + - '**值的读法**:「下限~上限」一律是范围(4.5~40 = 4.5 到 40 的整段;-40~+85 = -40 到 85 的整段,不是两个数),' + - '分隔符认 ~ - … 至 到 to,单位写进值里也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界;只有一个数是定值。出参每项 readAs 摆出两边各读成了什么。' + - 'compare_result、risk_level 与各候选的计数。**推荐用法**:只给 originalInternalPn 与 candidates[].candidateInternalPn,' + - '参数行从 parts.parameter、封装与生命周期从 parts.profile、证据引用从 parts.document 由工具现读(本单草稿层也算),模型不抄值;' + - 'candidates[].requirements 是客户给了边界的参数 {参数名: ">= 40" / "-40~85"}。也接老形状(candidates[].pairs 自带值)。' + - '硬约束没有证据引用不判 match。候选不比原 PN 差(相等 / 盖住)而客户约束原 PN 自己也不满足的,不算替代差异:仍按比对结果判、备注点名需求边界。规则版本 R-2026.07。', - input: { - type: 'object', - properties: { - originalInternalPn: { type: 'string', description: '原 PN(内部料号);按料号从台账取参数时必填' }, - candidates: { - type: 'array', - description: - '[{candidateInternalPn, requirements?: {参数名: 约束}, evidenceRef?}](从台账取值) 或 ' + - '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}](自带值)', - }, - }, - }, - output: { - type: 'object', - properties: { - ruleVersion: { type: 'string' }, - results: { - type: 'array', - description: '[{candidateInternalPn, source:"ledger"|"pairs", items:[{parameterName, originalValue, candidateValue, readAs:{original,candidate}, unit, constraintType, requirement, evidenceRef, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}, extraCandidateParams?}]', - }, - }, - }, -}; - -/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */ -export const RECOMMEND_TOOL = { - ...PURE, - name: 'part-substitution-assessment.recommend', - displayName: '替代推荐定级', - version: '1.0.0', - description: - '按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' + - '只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。', - input: { - type: 'object', - properties: { - candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' }, - }, - }, - output: { - type: 'object', - properties: { - ruleVersion: { type: 'string' }, - results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' }, - }, - }, -}; - -export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const; - -/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */ -export const W05_TOOL_SAMPLE = { - input: { - candidates: [ - { - candidateInternalPn: 'XL2596S-5.0', - pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }], - counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 }, - }, - ], - }, - expectShape: { type: 'object' }, -}; diff --git a/apps/api/src/app/tools/first-party.ts b/apps/api/src/app/tools/first-party.ts index 1d2a685..dfeb5bd 100644 --- a/apps/api/src/app/tools/first-party.ts +++ b/apps/api/src/app/tools/first-party.ts @@ -4,7 +4,6 @@ * 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。 */ import { evaluateUnaryTest } from '@sfeel'; -import type { TaskId } from '@contracts'; import type { ToolHandler } from '@modules/toolbox'; /** @@ -333,338 +332,24 @@ const slotPlaceholder: ToolHandler = async ({ input }) => { }; }; -/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)───────────────── +/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)────────── * - * 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份; - * 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。 + * 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定: + * MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料) + * 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 / + * 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级) + * 两张 DMN 是同一份规则的两处真源。 * - * 比对语义(按硬/软约束): - * equal / covers(候选范围 ⊇ 原范围) → match,低风险; - * configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围); - * narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险; - * 任一侧未披露 → unknown 中风险; - * 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH); - * requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。 + * ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定是**整个活动唯一一次 + * 模型调用**(`case-model.ts` 的 `S2_assess.auto` 用 `analyze` 动词,系统组装入参、模型只答 + * 判断,不转述台账里已有的值),推荐等级是**能算的事**,由同一步 `do` 链里的投影按 + * spec §3.5 的 S-FEEL when 链现算(`judgement_class` / `recommendation_level` 两条合成列)。 + * `D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 或一枚工具,就是同一件事 + * 两处各存一份,改一处就分叉而没人看得出以哪份为准。 + * + * 完整实现见 git 历史(本文件此段落之前的版本);规则表见活动包 spec §3.5, + * 求值逻辑见 `activities/part-substitution-assessment/src/case-model.ts`。 */ -export const SUBSTITUTION_RULE_VERSION = 'R-2026.07'; - -type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' }; -type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown'; -type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional'; -type RiskLevel = 'low' | 'medium' | 'high'; -type ParamPair = { - parameterName: string; - originalValue: string; - candidateValue: string; - unit?: string; - constraintType: 'hard' | 'soft'; - /** - * 这两格**模型爱给 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; -const NUM = '[-+]?\\d+(?:\\.\\d+)?'; -/** 数字后面允许跟一段单位(V / ℃ / °C / kHz / % / mA…)—— 「4.5V~40V」「-40℃~+85℃」这种把单位写进值里的形状要认成范围 */ -const UNIT = '(?:\\s*[A-Za-z℃°%μµΩ][A-Za-z℃°%μµΩ0-9/]*)?'; -const RANGE_RE = new RegExp(`^(${NUM})${UNIT}\\s*(?:~|~|-|–|—|…|\\.\\.\\.|至|到|to)\\s*(${NUM})`, 'i'); -const NUM_RE = new RegExp(`^(${NUM})`); -/** 单边界:「≥45」「>= 45」「45 以上」→ [45, ∞);「≤40」「<= 40」「40 以内 / 以下」→ (-∞, 40] */ -const GE_RE = new RegExp(`^(?:>=|≥)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以上|及以上|起)$`); -const LE_RE = new RegExp(`^(?:<=|≤)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以内|以下|及以下|以内)$`); - -/** - * 参数值怎么读(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」): - * 「下限~上限」一律是**范围**——分隔符认 ~ ~ - – — … ... 至 到 to,正负号认 + - 与 Unicode 减号(−), - * 单位写进值里(4.5V~40V / -40℃~+85℃)也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界,读成半开范围; - * 只有一个数是定值;读不出数的按文本原样比。出参会把「读作什么」摆回去,模型与人都看得见。 - */ -export function parseParamValue(raw: unknown): Parsed { - const s = String(raw ?? '') - .trim() - .replace(/[\u2212\u2010\u2011\u2012]/g, '-') // Unicode 减号 / 连字号 → ASCII 减号 - .replace(/\s+/g, ' '); - if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' }; - const r = RANGE_RE.exec(s); - if (r !== null) { - const lo = Number(r[1]); - const hi = Number(r[2]); - if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) }; - } - const ge = GE_RE.exec(s); - if (ge !== null) return { kind: 'range', lo: Number(ge[1] ?? ge[2]), hi: Number.POSITIVE_INFINITY }; - const le = LE_RE.exec(s); - if (le !== null) return { kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: Number(le[1] ?? le[2]) }; - const n = NUM_RE.exec(s); - if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) }; - return { kind: 'text', value: s.toLowerCase() }; -} - -/** 「读作什么」—— 回给模型与人看的一句话,不是给机器再解析的 */ -export function describeParsed(p: Parsed): string { - if (p.kind === 'range') { - if (p.lo === Number.NEGATIVE_INFINITY) return `上界 ≤ ${p.hi}`; - if (p.hi === Number.POSITIVE_INFINITY) return `下界 ≥ ${p.lo}`; - return `范围 ${p.lo}~${p.hi}`; - } - if (p.kind === 'number') return `定值 ${p.value}`; - if (p.kind === 'unknown') return '未披露'; - // 带数字又带范围分隔符却没读成范围的(如「4.5V 到 40 伏」),提示怎么写;纯文本(封装 TO-263-5)不啰嗦 - const looksLikeRange = /\d[^\d]{0,6}(?:[~~…]|至|到|\bto\b)[^\d]{0,6}\d/.test(p.value) || /^[^\d]+\d.*(?:[~~…]|至|到)/.test(p.value); - return `文本「${p.value}」${looksLikeRange ? '(没读成范围;本意是范围请写成 下限~上限,单位放单位列)' : ''}`; -} - -function compareParsed(o: Parsed, c: Parsed): CompareResult { - if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown'; - if (o.kind === 'range' && c.kind === 'range') { - if (c.lo === o.lo && c.hi === o.hi) return 'equal'; - if (c.lo <= o.lo && c.hi >= o.hi) return 'covers'; - if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower'; - return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial'; - } - if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs'; - if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs'; - if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs'; - if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs'; - return 'differs'; -} - -/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */ -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; - const lo = c.kind === 'range' ? c.lo : c.value; - if (ge !== null) return hi >= Number(ge[1]); - if (le !== null) return lo <= Number(le[1]); - const req = parseParamValue(s); - if (req.kind === 'range') return lo <= req.lo && hi >= req.hi; - if (req.kind === 'number') return lo <= req.value && hi >= req.value; - return undefined; -} - -/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */ -const LIFECYCLE_ORDER: Record = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 }; -function compareLifecycle(o: string, c: string): CompareResult | undefined { - 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'; -} - -function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } { - const o = parseParamValue(p.originalValue); - const c = parseParamValue(p.candidateValue); - const hard = p.constraintType === 'hard'; - let compareResult = compareParsed(o, c); - if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult; - const req = satisfiesRequirement(p.requirement, c); - /* - * **客户约束原 PN 自己也不满足时,不是替代差异**(2026-08-27 晚,用户送来 0bddd862:「输入电压范围两个 - * 不是一样的吗为什么不匹配」):客户写「输入电压在 45V 以内」,模型译成 `>= 45`,而原 PN 与候选的输入上限 - * 都是 40V —— 从前这里一票否决,候选与在用料一模一样却被记成硬约束 MISMATCH、整颗不推荐。 - * 差异评估比的是「换上去会不会比现在差」:候选与原 PN 相等 / 盖住原范围,且这条客户边界**原 PN 一样过不去**, - * 那是需求边界与在用料之间的差距(该回到需求核对 / 验证),不该记在候选头上。 - * 候选比原 PN 窄 / 不同而过不了客户线,仍是 MISMATCH;原 PN 一侧算不出(未披露 / 文本)也仍按候选过不了线判。 - */ - if (req === false) { - const parity = compareResult === 'equal' || compareResult === 'covers'; - const originalFailsToo = parity && satisfiesRequirement(p.requirement, o) === false; - if (!originalFailsToo) { - // 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险 - return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` }; - } - const boundary = `客户约束 ${text(p.requirement)} 原 PN 与候选都不满足 —— 是需求边界与在用料的差距,不是替代差异,请回头核对需求`; - if (hard && text(p.evidenceRef) === '') { - return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: `硬约束缺证据引用:未经证据支持不判 MATCH;${boundary}` }; - } - return { compareResult, differenceStatus: 'match', riskLevel: 'medium', note: `${compareResult === 'covers' ? '候选范围盖住原范围' : '与原 PN 一致'};${boundary}` }; - } - if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' }; - if (compareResult === 'equal' || compareResult === 'covers') { - if (hard && text(p.evidenceRef) === '') { - return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' }; - } - return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' }; - } - if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' }; - if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' }; - compareResult = compareResult === 'narrower' ? 'narrower' : 'differs'; - return hard - ? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' } - : { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' }; -} - -/** - * 比对工具读台账用的口 —— 按地址与等值条件取行(taskId 有就取任务视图 = 正式版 + 本单草稿,没有就只读正式版)。 - * 只读、不写;由 app 用 ledger 服务实装,测试给假的。 - */ -export type ParameterLedger = { - rows(taskId: TaskId | undefined, address: { domain: string; object: string }, where: Record): Promise>>; -}; - -type CandidateInput = { - candidateInternalPn: string; - pairs?: ParamPair[] | null; - /** 客户给了边界的参数:{ 参数名: ">= 40" / "-40~85" } —— 客户约束在需求原文里,归模型译;值不归它 */ - requirements?: Record | null; - evidenceRef?: string | null; -}; - -/** - * **比对入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题, - * 要让它认真比对每一条」)。从前 pairs 由模型从 `substitution.difference_item` 抄给工具:实测它把剧本示例里的 - * 「4.5~32 / >= 40」当成这单的值念出来,而抄了什么**不留痕**(只存 inputHash),事后没法审。 - * 现在给 originalInternalPn + candidateInternalPn,参数行从 `parts.parameter` 现读、封装与生命周期从 `parts.profile` 现读、 - * 证据引用没给就从 `parts.document` 取 —— 每一对都是台账里的原值;出参把 originalValue / candidateValue / evidenceRef - * 一并摆出来,写盘照抄即可。老形状(自带 pairs)照旧接,试运行与老调用方不受影响。 - */ -async function pairsFromLedger(ledger: ParameterLedger, taskId: TaskId | undefined, original: string, candidate: string, cand: CandidateInput) { - const PARAM = { domain: 'parts', object: 'parameter' }; - const PROFILE = { domain: 'parts', object: 'profile' }; - const DOC = { domain: 'parts', object: 'document' }; - const [oParams, cParams, oProfiles, cProfiles] = await Promise.all([ - ledger.rows(taskId, PARAM, { internal_pn: original }), - ledger.rows(taskId, PARAM, { internal_pn: candidate }), - ledger.rows(taskId, PROFILE, { internal_pn: original }), - ledger.rows(taskId, PROFILE, { internal_pn: candidate }), - ]); - const oProfile = oProfiles[0]; - const cProfile = cProfiles[0]; - if (oParams.length === 0 && oProfile === undefined) { - return { error: `原 PN ${original} 在台账里没有主数据也没有参数行 —— 先把它建进 parts.profile / parts.parameter(数据工单),再比` }; - } - if (cParams.length === 0 && cProfile === undefined) { - return { error: `候选 ${candidate} 在台账里没有主数据也没有参数行 —— 先建进 parts.profile / parts.parameter,再比` }; - } - let evidenceRef = text(cand.evidenceRef); - if (evidenceRef === '') { - const docs = await ledger.rows(taskId, DOC, { internal_pn: candidate }); - const sheet = docs.find((d) => text(d['document_type']) === 'datasheet' && text(d['document_ref']) !== '') ?? docs.find((d) => text(d['document_ref']) !== ''); - if (sheet !== undefined) evidenceRef = `P-08 · P-10 ${text(sheet['document_version'])}`.trim(); - } - const requirements = cand.requirements !== null && typeof cand.requirements === 'object' ? cand.requirements : {}; - const requirementOf = (name: string) => text(requirements[name]); - const byName = new Map(cParams.map((r) => [text(r['param_name']), r] as const)); - const sorted = [...oParams].sort((a, b) => text(a['param_name']).localeCompare(text(b['param_name']), 'zh-Hans-CN')); - const pairs: ParamPair[] = sorted - .filter((o) => text(o['param_name']) !== '') - .map((o) => { - const name = text(o['param_name']); - const c = byName.get(name); - return { - parameterName: name, - originalValue: text(o['param_value']), - candidateValue: c === undefined ? '' : text(c['param_value']), - unit: text(o['param_unit']) || (c === undefined ? '' : text(c['param_unit'])), - constraintType: text(o['constraint_type']) === 'soft' ? ('soft' as const) : ('hard' as const), - requirement: requirementOf(name), - evidenceRef, - }; - }); - // 封装(硬)与生命周期(软)不是参数,住在主数据上 —— 两颗都有档案时才比 - if (oProfile !== undefined && cProfile !== undefined) { - if (text(oProfile['package']) !== '' || text(cProfile['package']) !== '') { - pairs.push({ parameterName: '封装', originalValue: text(oProfile['package']), candidateValue: text(cProfile['package']), unit: '', constraintType: 'hard', requirement: requirementOf('封装'), evidenceRef }); - } - if (text(oProfile['lifecycle_status']) !== '' || text(cProfile['lifecycle_status']) !== '') { - pairs.push({ parameterName: '生命周期', originalValue: text(oProfile['lifecycle_status']), candidateValue: text(cProfile['lifecycle_status']), unit: '', constraintType: 'soft', requirement: requirementOf('生命周期'), evidenceRef }); - } - } - const known = new Set(pairs.map((p) => p.parameterName)); - const extraCandidateParams = [...byName.keys()].filter((n) => n !== '' && !known.has(n)); - return { pairs, extraCandidateParams }; -} - -export function compareParametersWith(ledger?: ParameterLedger): ToolHandler { - return async ({ input, taskId }) => { - const original = text(input['originalInternalPn']); - const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as CandidateInput[]) : []; - const results: Array> = []; - for (const cand of candidates) { - const candidatePn = text(cand.candidateInternalPn); - let pairs: ParamPair[]; - let source: 'pairs' | 'ledger'; - let extraCandidateParams: string[] = []; - if (Array.isArray(cand.pairs) && cand.pairs.length > 0) { - pairs = cand.pairs; - source = 'pairs'; - } else { - if (ledger === undefined) { - return { ok: false, error: { code: 'invalid-input', retryable: false, message: `候选 ${candidatePn} 没给 pairs,而这条通道读不到台账 —— 给 pairs,或经任务里调用` } }; - } - if (original === '') { - return { ok: false, error: { code: 'invalid-input', retryable: false, message: '缺 originalInternalPn:按料号从台账取参数,得先说原 PN 是谁' } }; - } - const fetched = await pairsFromLedger(ledger, taskId, original, candidatePn, cand); - if ('error' in fetched) return { ok: false, error: { code: 'not-found', retryable: false, message: fetched.error } }; - pairs = fetched.pairs; - extraCandidateParams = fetched.extraCandidateParams; - source = 'ledger'; - } - const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 }; - const items = pairs.map((p) => { - const j = judgePair(p); - counts[j.differenceStatus] += 1; - if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1; - if (j.compareResult === 'configurable') counts.configurable += 1; - return { - parameterName: p.parameterName, - originalValue: text(p.originalValue), - candidateValue: text(p.candidateValue), - // 两边各读成了什么(范围 / 定值 / 单边界 / 文本)—— 比对的依据摆出来,「4.5~40」是范围不是两个数 - readAs: { original: describeParsed(parseParamValue(p.originalValue)), candidate: describeParsed(parseParamValue(p.candidateValue)) }, - unit: p.unit ?? '', - constraintType: p.constraintType, - requirement: text(p.requirement), - evidenceRef: text(p.evidenceRef), - ...j, - }; - }); - results.push({ - candidateInternalPn: candidatePn, - source, - items, - counts, - ...(extraCandidateParams.length === 0 ? {} : { extraCandidateParams }), - }); - } - return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } }; - }; -} - -/** 不带台账的那份(pairs 模式):静态名单里挂它,app 用 `compareParametersWith(ledger)` 顶掉 */ -const compareParameters: ToolHandler = compareParametersWith(); - -/** - * 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环); - * 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。 - */ -const recommend: ToolHandler = async ({ input }) => { - const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial }>) : []; - const results = candidates.map((cand) => { - const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0); - if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` }; - if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` }; - if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` }; - if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` }; - return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` }; - }); - return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } }; -}; export const FIRST_PARTY_TOOLS: ReadonlyArray = [ ['crm.history-price-read', historyPriceRead], @@ -677,6 +362,4 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray = ['slot.placeholder', slotPlaceholder], ['crm-relationship-coverage.recompute-score', recomputeScore], ['crm-relationship-coverage.rank-key-persons', rankKeyPersons], - ['part-substitution-assessment.compare-parameters', compareParameters], - ['part-substitution-assessment.recommend', recommend], ];