/** * ACT-WORK-05 差异评估的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * * **2026-08-29 整体重写(Task 3,spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §4)**: * 九步砍成四步 —— `S1_inputs`(校验入参与补数据)→ `S2_assess`(逐项差异分析)→ * `S3_review`(结论审核,人工激活)→ `T_release`(落账放行,人工激活)。三道入场哨兵全读基座数, * `caseFileModel` 里**一面记账旗都没有**——旗是活动自己写的数,「机检只读基座数」这条纪律在本仓 * 被违反过六次,每次都是同一种事故(见 `docs/ACT-WORK-05-端到端测试记录.md` A–K 发现表)。 * * **全程零 `guide`**:四步的行为改由 `PlanStep.auto` 声明式配方接管(ADR-014)—— 这一份只搭骨架 * (每步 `auto` 先各放一笔 `say` 占位),配方的实体在 Task 4–6 逐步填进去;`view` 这一步不写 * (它是可选的,Task 7–9 各自补上自己那一张)。 * * 触达世界的两步(`S3_review` 审核、`T_release` 放行)标了人工激活且**不给 `doneWhen`**—— * 完成与否不该由数据形状推出来。`ASSESSMENT_STEPS` / `ASSESSMENT_PHASES` 不用 `as const` * (与 E04 / I01 同款):它会把 `writes`/`reads` 这类数组字面量收窄成只读元组, * 装配进 `manifest.ts` 的 `businessModel.steps`(类型是可变的 `PlanStep[]`)时会因为 * `ReadonlyArray` 缺 `push` 等方法而过不了类型检查。 */ import { RULE_VERSION } from './constants.ts'; export const CMMN_XML = ` update sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0 update substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and substitution_relation_rows = 1 and validation_record_rows = 1 update substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0 `; /** * 案例说明书 —— **只写整单一以贯之的事**,不讲逐步做法(逐步做法归配方,而配方不是给模型看的, * 是引擎按声明确定性执行的数据)。四步全部 `guide: ''`,这份说明书理论上不会再被任何一拍摊开给 * 模型看 —— 留着它是因为 `narrative` 契约仍要求非空,且它仍是给人读的活动说明。 * * **示例一律占位形**(`<料号>` / `<参数名>`),不写具体料号与参数名 —— 本仓 2026-08-27 复现过三次: * 关思考的模型会把示例里的具体值当成这一单的值照抄。 */ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号在技术上能不能替代原内部料号**——逐条核心参数给出可复核的 差异结论,人审核通过后落账。不做数量 / 价格 / 交期 / 采购判断;客户批准状态由独立审批流维护, 本活动没有那一列的写权。 入参两格,都选填:原内部料号、候选内部料号(如 <料号>)——库里有就用,没有就自己写一个新的, 启动后由第一步核对补齐,不必人先建好档。 **四步各自的产出**: ① 校验入参与补数据(S1_inputs)—— 核对两颗料在不在库、是否归了同一个芯片类型、核心参数 (如 <参数名>)是否齐备;缺什么就派数据工单让人补。产出:两颗料的存在性与类型核对、 补齐的芯片主数据与标准化参数。 ② 逐项差异分析(S2_assess)—— 把两颗料的核心参数逐条比对,判定 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 四态,依规则算出推荐等级与风险。产出:替代关系一行 + 差异明细逐参数一行 + 验证记录一行(全推荐也要写「本轮无待验项」)。规则版本 ${RULE_VERSION} 随结论一并锁定。 ③ 结论审核(S3_review,人工激活)—— 交人核对:参数比对对不对、硬约束有没有被含糊带过、 UNKNOWN 有没有去向、判断依据站不站得住;可就地改四态与影响说明,改到没问题为止。 ④ 落账放行(T_release,人工激活)—— 把这一签下去会落哪几张表、几行摆清楚,人签字后正式落账, 发射 assessment-released。 **三条不许**: · 不伪造参数值——查不到就显式写「未披露」,不许估、不许拿别的料号或别的参数的值顶替; · 硬约束 MISMATCH 不许被措辞改判成 MATCH,要改只能补证据后重比; · 不写客户批准状态——那是独立审批流的事,本活动只答技术可替代性。`; /* * **两步各自的 `auto.let` 都要指向这份绑定,不能只有一步有**(2026-08-29 评审 Critical 修正)。 * * `createAutoRunner.run()` **每次调用都从它收到的那份 `recipe.let` 现算一遍** `lets` * (`auto-runner.ts:93-97`)—— 步骤之间不传递 `let`,turn-runner 的两个调用点 * (`:1725-1738` 的 quiet 分支、`:2020-2022` 的常规分支)都是把 `step.auto` 原样递进去。 * `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 引用的 `{in:'relationId'}`/`{in:'evalRef'}`/ * `{in:'originalPn'}`/`{in:'candidatePn'}`/`{in:'typeId'}` 若只在 S2 的 `auto.let` 里定义过、 * S3 没有自己的 `let`,这几个名字在 S3 那一拍就是**未绑定**——`{in:X}` 找不到就退回 `''` * (`auto-expr.ts:44-47`)。后果两条,已用执行级测试实证(`apps/api/src/modules/loop-engine/ * __tests__/w05-s3-review-recompute.test.ts`): * ① 关系表 `substitution_relation_id` 算成空串,整笔写被「主键算不出来先不写」悄悄丢掉 * ——**人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由; * ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个 * 新主键写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。 * * spec(`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md:293-294`)说的是 * 「S3 的配方里**复制一份** ③④ 两笔(纯投影、幂等)……一步一份配方,不做跨步回跳」——**复制**, * 不是共享一个只算一次的结果。两步各自持有一份**完整可求值**的 `let` 才是这句话的本意; * 绑定的**文本**仍只有一处真源(这个常量),避免真的抄两份而分叉。 */ /** **为什么不用 `{in:'chip_type_id'}`**:这个名字在三张表上都有,顶层同名以后写的赢 —— 拿它取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 */ const SHARED_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: '' }] }, } as const; /* * **关系与验证记录的投影,S2 与 S3 共用同一份**。 * S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 —— * 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。 * * 关系一行本身:**等级、风险、摘要、理由全由规则与模板拼,不让 AI 编** —— 于是永远不会出现 * 「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。 */ 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` 占位,配方的实体 * (`project` / `analyze` / `card` / `submit`)在 Task 4–6 逐步填进去。 * * 两处不给 `doneWhen`:`S3_review`(人拍板且可循环)、`T_release`(签下去)—— * 判据不许摆在还欠一张卡的那一步眼前(会诱使它去凑判据,2026-08-28 用户实测逼出的立墙)。 */ export const ASSESSMENT_STEPS = [ { item: 'S1_inputs' as const, intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补', guide: '', /* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值), 与 auto.do 第 ⑤ 笔 `card` 的 `gateLedgers` 同源(parts.type_param / parts.profile / parts.parameter —— `parts.chip_type` 不列入,理由见那笔 `card` 定义处的注释)。 */ view: 'input-supplement', writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'], reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'], tools: [], doneWhen: 'sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and ' + 'sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0', auto: { /** 整单只有这一个案号,从本单开案时刻取材 —— **不许从正式表里别单的号里挑**(踩过) */ let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' as const }] } }, do: [ /* * ① 本单两颗料 —— **后面每一笔遍历的唯一来源**。 * 挤进案卷一行里就没法 `where` 过滤,而不能过滤就会给已经在库的那颗也铺一遍空参数行。 */ { kind: 'project' as const, into: 'sub_eval.part', from: { rows: [ { pn: { in: 'originalInternalPn' }, role: 'original' }, { pn: { in: 'candidateInternalPn' }, role: 'candidate' }, ], }, fields: { eval_ref: { in: 'evalRef' }, internal_pn: { col: 'pn' }, role: { col: 'role' }, /* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */ found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' }, exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' }, chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' }, }, }, /* * ② 主数据骨架:**两颗料都铺、值回填现值**。 * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 */ { kind: 'project' as const, into: 'parts.profile', prune: true, from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' }, brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' }, lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' }, specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' }, manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' }, datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' }, datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' }, }, }, /* * ③ 参数骨架 —— 三件事一笔办完: * · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**; * 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; * · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; * · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 * * **`param_unit` 是有意不回填现值、直接取字典**(2026-08-29 评审 Minor 记录): * 同一类型下两颗料必须按**同一个单位**比,`parts.type_param.param_unit` 才是这次比对的基准 —— * 若某颗料自己记的单位与字典不同,以字典覆盖是**故意的**,不是漏写了 `lookup`; * S2 喂给 AI 判定时用的 `unit` 同样取自这张字典,两处必须同源,换成回填现值反而会让 * 「展示的单位」与「判定时用的单位」分叉。 */ { kind: 'project' as const, into: 'parts.parameter', prune: true, from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, }, fields: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' }, param_unit: { col: 'param_unit' }, param_value: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '', }, param_note: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '', }, }, }, /* * ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。 * * **排在 ②③ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正): * `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`, * 而这两个数是 ②③ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ②③ **之前** * (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id` * 刚在这一拍被 ① 设上、②③ 即将把它的主数据与参数骨架铺出来时,那一刻 ③ 即将新建的 * 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」), * 片刻之后 ③ 一跑就变成 1。收步与发卡的把关本身没坏(⑤ 排在 ②③④ 之后,`card.when` * 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面 * `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。 * **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」, * 保证不了「这一笔算的话用了后面几笔才有的数」—— 读别人数据的那一笔,必须排在被读的 * 那几笔之后。 */ { kind: 'project' as const, into: 'sub_eval.case', from: { once: true as const }, fields: { eval_ref: { in: 'evalRef' }, chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' }, gate_reason: { when: 'sub_eval_part_rows < 2', then: '原料号与候选料号还没填全 —— 在下面把两格补上。', else: { when: 'sub_eval_part_exists_status_absent > 0', then: '有料号库里查不到 —— 在下面把它的主数据建起来。', else: { when: 'sub_eval_part_missing > 0', then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。', else: { when: 'sub_eval_part_distinct_chip_type_id > 1', then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。', else: { when: 'parts_profile_missing + parts_parameter_missing > 0', then: '还有必填格空着 —— 红带点名的那几格填上就能提交。', else: '', }, }, }, }, }, }, }, /* * ⑤ 缺什么就派一张数据工单。 * **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定, * 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。 */ { kind: 'card' as const, when: 'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or ' + 'sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0', as: 'execution' as const, variant: 'data' as const, title: '数据工单 · 差异评估的输入还差几格', basis: [ { k: '原料号', v: { in: 'originalInternalPn' } }, { k: '候选料号', v: { in: 'candidateInternalPn' } }, { k: '还差什么', v: { in: 'gate_reason' } }, ], guide: [ '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。', '类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。', '说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。', '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。', '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。', ], standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', /* * **`parts.chip_type` 永久不列在这里** —— 不是等 Task 7b 补了「新建类型」那张编辑表 * 就该加回来(2026-08-29 评审逮到的 Critical:这张件此刻没有任何 `data-edit` * 能写 `parts.chip_type`,列了它,`mine:parts.chip_type` 永远是空数组, * `desk.ts` 的 `emptyGate` 就永远判「这一单还一行都没有 —— 这张卡就是来建它的」, * 提交钮当场且永久按不下去,三张表填得再全也一样)。 * 更深一层:即便 Task 7b 真的补上了那张编辑表,也不该把它放回来 —— `emptyGate` * 的语义是「这张卡**就是来建**这张表的,一行都没有 = 没干活」,而 * `parts.chip_type` 的主路是**从下拉挑一个已有类型**,那时 `mine:parts.chip_type` * 天然就是零行(没新建、没问题);只有走「新建类型」那条岔路时才会写它。 * 一张**只是有时候才写**的表,套「零行 = 没干完」这条闸的前提本身就不成立 —— * 该拦的是 `parts.type_param`(核心参数字段清单,新建类型时**一定**要写)。 */ gateLedgers: ['parts.type_param', 'parts.profile', 'parts.parameter'], }, { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, ], }, }, { item: 'S2_assess' as const, intent: '把两颗料的核心参数逐条交给模型判四态,系统按规则算推荐等级、风险与验证要求', guide: '', writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], reads: ['parts.type_param', 'parts.parameter', 'sub_eval.part', 'sub_eval.case'], tools: [], doneWhen: 'substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' + 'substitution_relation_rows = 1 and validation_record_rows = 1', auto: { /* 绑定的真身在 `SHARED_LET`(本文件靠前处)—— S3 复用同一份投影时也要靠它,原地写一份会漂 */ let: SHARED_LET, 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, }, }, 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, }, }, { item: 'S3_review' as const, intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止', guide: '', /* Task 8:人核对结论用的验收工单 —— 对决头(原 PN → 候选 PN)+ 四态分布带 + 推荐等级大徽章 只读,逐条明细可就地改(四态 / 影响说明 / 判断依据 / UNKNOWN 去向),收口四格 (推荐理由 / 差异摘要 / 风险 / 验证要求)只读——它们是配方按规则算的,人改了上面的四态, 这一步的 auto.do 里那两笔投影(RELATION_PROJECTION / VALIDATION_PROJECTION)会重算它们。 */ view: 'difference-review', writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], reads: ['substitution.difference_item', 'substitution.relation'], tools: [], cardVariant: 'review' as const, /* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */ acceptanceChecklist: [ '逐条对过原料与候选料的参数值,没有把需求边界写成候选料的问题', '硬约束那几条逐条看过,没有一条是「没证据也判了 MATCH」', '判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿', '影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值', ], auto: { /* * **这一步自己的 `let`,不是 S2 那份算好的结果**(2026-08-29 评审 Critical 修正)—— * `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 里的 `{in:'relationId'}` 等名字要靠它才 * 绑得上,少了这一行两笔投影的主键在这一步就是空的(详见 `SHARED_LET` 定义处的头注)。 */ let: SHARED_LET, 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, intent: '把这一签下去会落哪几张表、几行摆清楚,人签字后落账并发口', guide: '', /* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */ acceptanceChecklist: [ '推荐等级与四态分布对得上:有硬约束不符的没给「推荐」', '待验项清单认可:该实测的都列出来了,没有该验而漏掉的', '落账内容核对过:进哪几张表、各几行,与屏上摆的一致', '参数值来源可追:每一条都能说出它是库里既有的还是这一单补录的', '签下去就发给下游,这一步之后本活动不再改动这些行', ], /* 被打回重做时要改的就是这几张 —— 不声明就写不进去 */ writes: ['sub_eval.case'], reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'], tools: [], 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 条验证记录待落账。', ], }, }, ], }, }, ]; /** * 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段; * 放行那一格由基座统一加(与 E04 / I01 同款),人看到的是 **x/4**。 */ export const ASSESSMENT_PHASES = [ { name: '校验入参', items: ['S1_inputs'] }, { name: '差异分析', items: ['S2_assess'] }, { name: '结论审核', items: ['S3_review'] }, ];