diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index ba1de97..4a1a3a4 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -297,3 +297,44 @@ describe('S2 配方 —— AI 只出场一次', () => { } }); }); + +describe('S3 / S4 配方', () => { + const s3 = stepOf('S3_review')?.auto?.do ?? []; + const s4 = stepOf('T_release')?.auto?.do ?? []; + const s2 = stepOf('S2_assess')?.auto?.do ?? []; + + it('S3 四笔:两笔重算在前、卡在后 —— 人答完那一刻重算才发生', () => { + expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'card', 'say']); + expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation'); + expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record'); + }); + + it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => { + expect(s3[0]).toBe(s2[2]); + expect(s3[1]).toBe(s2[3]); + }); + + it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { + const card = s3[2]; + expect(card?.kind === 'card' && card.as).toBe('decide'); + expect(card?.kind === 'card' && card.variant).toBe('review'); + expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1); + expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve'); + expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); + }); + + it('S4 两笔:写结论一句话 → 提交放行', () => { + expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']); + expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case'); + expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true); + }); + + it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => { + expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4); + }); + + it('两步都不给 doneWhen —— 收步由人答卡决定', () => { + expect(stepOf('S3_review')?.doneWhen).toBeUndefined(); + expect(stepOf('T_release')?.doneWhen).toBeUndefined(); + }); +}); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index db2ee46..19af51f 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -110,6 +110,90 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号 · 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比; · 不写客户批准状态——那是独立审批流的事,本活动只答技术可替代性。`; +/* + * **关系与验证记录的投影,S2 与 S3 共用同一份**。 + * S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 —— + * 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。 + */ +const RELATION_PROJECTION = { + 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, + }, +} as const; + +/* P-06 **一单固定一条**(用户定)。本活动只写「还得去验什么」,状态一律 `not_started`:它不是实测结果,**不许虚构实测**。 */ +const VALIDATION_PROJECTION = { + 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: '本轮无待验项。', + }, + }, +} as const; + /** * 步骤剧本 —— 一个计划项一条。四步**全程零 `guide`、零工具**:行为改由 `auto` 配方接管 * (ADR-014),这一份只放骨架 —— 每步 `auto.do` 先各摆一笔 `say` 占位,配方的实体 @@ -420,92 +504,19 @@ export const ASSESSMENT_STEPS = [ 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: '本轮无待验项。', - }, - }, - }, + RELATION_PROJECTION, + VALIDATION_PROJECTION, { kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' }, - ], + /* + * **`] as const` 是必须的,不是随手加的**:`do` 数组里一旦混入对已命名常量的 + * *引用*(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`),TS 对这个数组字面量的 + * 逐位置精确类型推断就整体失效 —— 连数组里其它仍是内联字面量的位置(如 `acts[1]`) + * 也会被拉平成「这个数组里出现过的所有形状的并集」,`fields['具体列名']` 当场报 + * TS7053(实测:去掉这个 `as const`,`pnpm typecheck` 在 `S2 配方` describe 块 + * 报 9 处这类错误,尽管 `vitest run` 不做类型检查、照样能跑绿 —— 这是本仓已有的 + * `: {}` vs `: undefined` 那类「并集吞窄类型」陷阱的第二种触发形态)。 + */ + ] as const, }, }, { @@ -523,7 +534,28 @@ export const ASSESSMENT_STEPS = [ '判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿', '影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值', ], - auto: { do: [{ kind: 'say' as const, text: '差异结论已备好,请过目。' }] }, + auto: { + do: [ + /* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */ + RELATION_PROJECTION, + VALIDATION_PROJECTION, + { + kind: 'card' as const, + as: 'decide' as const, + variant: 'review' as const, + title: '差异评估结论审核', + ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。', + options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }], + basis: [ + { k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } }, + { k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } }, + { k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } }, + ], + }, + { kind: 'say' as const, text: '差异结论已送审,请在件上逐条核对。' }, + /* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */ + ] as const, + }, }, { item: 'T_release' as const, @@ -541,7 +573,36 @@ export const ASSESSMENT_STEPS = [ writes: ['sub_eval.case'], reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'], tools: [], - auto: { do: [{ kind: 'say' as const, text: '拟落账内容已备好,请核对后签字。' }] }, + auto: { + do: [ + { + kind: 'project' as const, + into: 'sub_eval.case', + from: { once: true as const }, + fields: { + eval_ref: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }, + summary: { + cat: [ + { lookup: 'mine:substitution.relation', by: {}, take: 'candidate_internal_pn', else: '' }, + ' 替代 ', + { lookup: 'mine:substitution.relation', by: {}, take: 'original_internal_pn', else: '' }, + ':', + { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' }, + ], + }, + }, + }, + { + kind: 'submit' as const, + rationale: { + cat: [ + '逐项差异评估已经审核通过:', + { in: 'substitution_difference_item_rows' }, ' 条明细、1 条替代关系、1 条验证记录待落账。', + ], + }, + }, + ], + }, }, ];