From 60955171da0abda2f5c8540601fd8e78599b9c8f Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 15:40:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(w05):=20S2=20=E9=85=8D=E6=96=B9=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20AI=20=E5=94=AF=E4=B8=80=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=87=BA=E5=9C=BA,=E8=A1=8C=E7=94=B1=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E5=86=B3=E5=AE=9A=E4=B8=8D=E7=94=B1=E5=AE=83=E5=86=B3=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明细按类型字典 each 出来,AI 的出参只是拿参数名去 lookup 的一张查表: 它漏一条,那一行的四态就是空,required 立刻变成 _missing > 0,闸与放行门当场拦住。 从前是 from each(AI 出参) —— 少给两条就少两行,而每一行看着都合规。 值一律 lookup 台账,一个字不让它重述;推荐等级、风险、摘要、理由全由规则与模板拼。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/steps.test.ts | 94 +++++++- .../src/case-model.ts | 203 +++++++++++++++++- 2 files changed, 294 insertions(+), 3 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 8e0fdbe..c169636 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { planStepSchema } from '@contracts'; +import { planStepSchema, type PlanStep } from '@contracts'; import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts'; import { manifest } from '../src/manifest.ts'; const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release']; -const stepOf = (item: string) => ASSESSMENT_STEPS.find((s) => s.item === item); +/* + * **返回类型显式标 `PlanStep`**(2026-08-29,Task 5 逼出来的修正,与「文件里没有的名字先核一遍」同一课)—— + * `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集), + * 与查的是哪个 `item` 无关。S2 从占位 `say` 换成真配方之前,并集里只有 S1 自己的四种 `project` 形状, + * S1 的断言侥幸压得住;换上真配方后并集里混进了 S2 的三种 `project` 形状(字段名完全不同), + * S1 原有的 `fields?.['found_pn_ref']` 这类按属性名取值当场过不了 `tsc`(TS7053)。 + * 标成契约类型 `PlanStep` 之后 `.auto.do[i]` 落回 `AutoAction`(`fields: Readonly>` + * 本就有索引签名),按哪个字符串查步骤才真正决定读到的形状 —— 这也是测试原本就该核的东西。 + */ +const stepOf = (item: string): PlanStep | undefined => ASSESSMENT_STEPS.find((s) => s.item === item); describe('四步案例模型', () => { it('恰好四步,次序即业务次序', () => { @@ -189,3 +198,84 @@ describe('S1 配方', () => { } }); }); + +describe('S2 配方 —— AI 只出场一次', () => { + const recipe = stepOf('S2_assess')?.auto; + const acts = recipe?.do ?? []; + + it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => { + expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']); + expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1); + }); + + it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => { + const items = acts[1]; + expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' }); + const fields = items?.kind === 'project' ? items.fields : {}; + /* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */ + expect(fields['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' }); + expect(fields['impact_statement']).toMatchObject({ lookup: 'result:items' }); + expect(fields['evidence_note']).toMatchObject({ lookup: 'result:items' }); + }); + + it('**值不取 AI 出参,一律 lookup 台账**', () => { + const fields = acts[1]?.kind === 'project' ? acts[1].fields : {}; + for (const col of ['original_value', 'candidate_value']) { + expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' }); + } + /* 约束类型来自字典这一行本身,不是 AI 说的 */ + expect(fields['constraint_type']).toEqual({ col: 'constraint_type' }); + }); + + it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => { + const fields = acts[1]?.kind === 'project' ? acts[1].fields : {}; + const jc = JSON.stringify(fields['judgement_class']); + expect(jc).toContain('hard_mismatch'); + expect(jc).toContain('soft_mismatch'); + /* 顺序要紧:先认四态,最后才按约束类型分硬软 */ + expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch')); + }); + + it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => { + const rel = acts[2]?.kind === 'project' ? acts[2].fields : {}; + const level = JSON.stringify(rel['recommendation_level']); + expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0'); + expect(level).toContain('not_recommended'); + /* 硬约束不符必须直接落到「不推荐」,排在最前 */ + expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown')); + }); + + it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => { + const val = acts[3]; + expect(val?.kind === 'project' && val.into).toBe('validation.record'); + expect(val?.kind === 'project' && val.from).toEqual({ once: true }); + const fields = val?.kind === 'project' ? val.fields : {}; + expect(fields['validation_status']).toBe('not_started'); + expect(fields['validation_type']).toBe('parametric'); + }); + + it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => { + const analyze = acts[0]; + const prompt = analyze?.kind === 'analyze' ? analyze.instruction : ''; + expect(prompt).toContain('不要复述'); + expect(prompt).toContain('unknown'); + expect(prompt).toContain('硬约束'); + for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) { + expect(prompt).not.toContain(forbidden); + } + /* 出参 schema 顶层是对象,数组字段名与 into 对得上 */ + const out = analyze?.kind === 'analyze' ? analyze.output : {}; + expect(out['type']).toBe('object'); + expect(analyze?.kind === 'analyze' && analyze.into).toBe('items'); + }); + + it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => { + const analyze = acts[0]; + const fields = analyze?.kind === 'analyze' ? analyze.input.fields : {}; + expect(fields['param_desc']).toEqual({ col: 'param_desc' }); + expect(fields['constraint_type']).toEqual({ col: 'constraint_type' }); + for (const col of ['original_value', 'candidate_value']) { + expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' }); + } + }); +}); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 8c4ff68..954edb5 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -305,7 +305,208 @@ export const ASSESSMENT_STEPS = [ doneWhen: 'substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' + 'substitution_relation_rows = 1 and validation_record_rows = 1', - auto: { do: [{ kind: 'say' as const, text: '正在逐条比对两颗料的核心参数。' }] }, + auto: { + /** **为什么不用 `{in:'chip_type_id'}`**:这个名字在三张表上都有,顶层同名以后写的赢 —— 拿它取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 */ + let: { + evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }, + typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' }, + originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' }, + candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' }, + relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] }, + }, + do: [ + /* + * ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数 + * 组装好交给它,它只答「这一条算什么、为什么」——**不给工具、不给记忆、不多轮**。 + */ + { + kind: 'analyze' as const, + model: 'deepseek-v4-flash', + into: 'items', + instruction: [ + '你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、', + '硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。', + '', + '对**每一条**给出四态之一:', + '· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);', + '· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);', + '· unknown —— 任一侧的值缺失或语义不明,判不了;', + '· conditional —— 改外围、限定工况、或加一道验证之后可以成立。', + '', + '三条硬规矩:', + '1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。', + '2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。', + '3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。', + '', + '范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;', + '单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。', + '', + '只输出 JSON,不要解释、不要 markdown 代码围栏。', + ].join('\n'), + input: { + from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, + fields: { + param_name: { col: 'param_name' }, + /* **AI 分析时读的就是它** —— 参数名本身说不清「它为什么要紧」 */ + param_desc: { col: 'param_desc' }, + constraint_type: { col: 'constraint_type' }, + unit: { col: 'param_unit' }, + original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' }, + original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' }, + candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' }, + candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' }, + }, + }, + output: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'object', + required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'], + properties: { + param_name: { type: 'string' }, + difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] }, + impact_statement: { type: 'string' }, + evidence_note: { type: 'string' }, + unknown_disposition: { type: 'string' }, + }, + }, + }, + }, + }, + }, + /* + * ② 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。 + * 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。 + * 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。 + */ + { + kind: 'project' as const, + into: 'substitution.difference_item', + prune: true, + from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, + fields: { + substitution_relation_id: { in: 'relationId' }, + difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] }, + parameter_name: { col: 'param_name' }, + parameter_desc: { col: 'param_desc' }, + parameter_unit: { col: 'param_unit' }, + /* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */ + constraint_type: { col: 'constraint_type' }, + original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' }, + candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' }, + difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' }, + impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' }, + evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' }, + unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' }, + /* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 */ + judgement_class: { + when: 'difference_status = "match"', then: 'match', + else: { + when: 'difference_status = "unknown"', then: 'unknown', + else: { + when: 'difference_status = "conditional"', then: 'conditional', + else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' }, + }, + }, + }, + risk_level: { + when: 'judgement_class = "hard_mismatch"', then: 'high', + else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' }, + }, + rule_version: RULE_VERSION, + }, + }, + /* + * ③ 关系一行 —— **等级、风险、摘要、理由全由规则与模板拼,不让 AI 编**。 + * 于是永远不会出现「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。 + */ + { + kind: 'project' as const, + into: 'substitution.relation', + from: { once: true as const }, + fields: { + substitution_relation_id: { in: 'relationId' }, + eval_ref: { in: 'evalRef' }, + original_internal_pn: { in: 'originalPn' }, + candidate_internal_pn: { in: 'candidatePn' }, + chip_type_id: { in: 'typeId' }, + recommendation_level: { + when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended', + else: { + when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional', + else: { + when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional', + else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' }, + }, + }, + }, + risk_level: { + when: 'recommendation_level = "not_recommended"', then: 'high', + else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' }, + }, + difference_summary: { + cat: [ + '共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:', + { in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ', + { in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ', + { in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ', + { in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ', + { in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。', + ], + }, + recommendation_reason: { + when: 'recommendation_level = "not_recommended"', + then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。', + else: { + when: 'recommendation_level = "recommended"', + then: '每一项核心参数都判为一致,没有待确认项,可直接替代。', + else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。', + }, + }, + verification_requirement: { + when: 'recommendation_level = "conditional"', + /* `{joinRows}` 未实现 —— 不逐个列参数名,指向明细表(那里逐条摆着,且人能就地改) */ + then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。', + else: '', + }, + validation_id: { cat: ['VAL-', { in: 'evalRef' }] }, + analysis_status: 'fae_confirmed', + rule_version: RULE_VERSION, + }, + }, + /* + * ④ 验证记录一行 —— **一单固定一条**(用户定)。本活动只写「还得去验什么」, + * 状态一律 `not_started`:它不是实测结果,**不许虚构实测**。 + */ + { + kind: 'project' as const, + into: 'validation.record', + from: { once: true as const }, + fields: { + validation_id: { cat: ['VAL-', { in: 'evalRef' }] }, + eval_ref: { in: 'evalRef' }, + validation_internal_pn: { in: 'candidatePn' }, + validation_type: 'parametric', + validation_status: 'not_started', + test_condition: { + when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0', + then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。', + else: '无', + }, + next_validation_action: { + when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0', + then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。', + else: '本轮无待验项。', + }, + }, + }, + { kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' }, + ], + }, }, { item: 'S3_review',