This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/activities/part-substitution-assessment/src/case-model.ts
T
xcsandClaude Opus 5 59afcbfdbd feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读
推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们,
等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。

同批修一条与本次设计矛盾的既有断言:「差异审核与落账确认只读」原把
difference-review 也算只读,而 spec §4.4/§6.2 已把 S3 改成可就地编辑
——改窄成只管 landing-confirm,differerence-review 的只读面改由新增的
「件②」测试组精确覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:50:31 +08:00

673 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="part-substitution-assessment">
<!--
案卷项的 id 就是任务视图里的键名 —— **全是基座读数**(本单草稿写下的行数 / 缺必填数 /
按值计数 / 不同值个数)。这一版里**一面记账旗都没有**:旗是活动自己写的数,
而「机检只读基座数」这条纪律在本仓被违反过六次,每次都是同一种事故。
-->
<caseFileModel>
<caseFileItem id="sub_eval_part_exists_status_found" name="两颗料里已在库的个数(基座现数)"/>
<caseFileItem id="sub_eval_part_missing" name="本单两颗料缺必填的行数(基座现数)"/>
<caseFileItem id="sub_eval_part_distinct_chip_type_id" name="两颗料用到的不同类型个数(基座现数不同值)"/>
<caseFileItem id="parts_profile_missing" name="本单碰过的芯片主数据缺必填的行数(基座现数)"/>
<caseFileItem id="parts_parameter_missing" name="本单碰过的参数行缺必填的行数(基座现数)"/>
<caseFileItem id="substitution_difference_item_rows" name="差异明细行数(基座现数)"/>
<caseFileItem id="substitution_difference_item_missing" name="差异明细缺必填的行数(基座现数)"/>
<caseFileItem id="substitution_relation_rows" name="替代关系行数(基座现数)"/>
<caseFileItem id="substitution_relation_missing" name="替代关系缺必填的行数(基座现数)"/>
<caseFileItem id="validation_record_rows" name="验证记录行数(基座现数)"/>
<caseFileItem id="validation_record_missing" name="验证记录缺必填的行数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="差异评估" autoComplete="true">
<!-- ① 入参齐备:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺 —— 分析那一步的入场前提 -->
<sentry id="s_inputs_ready">
<onPart id="op_i" caseFileItemRef="sub_eval_part_exists_status_found"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_i"><condition>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</condition></ifPart>
</sentry>
<!-- ② 结论已成形:逐条明细、一条关系、一条验证记录都写下了且不缺 —— 审核那一步的入场前提 -->
<sentry id="s_assessed">
<onPart id="op_a" caseFileItemRef="substitution_difference_item_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_a"><condition>substitution_difference_item_rows &gt; 0 and substitution_difference_item_missing = 0 and substitution_relation_rows = 1 and validation_record_rows = 1</condition></ifPart>
</sentry>
<!-- ③ 放行的入场前提与 ② 同源:审核那一步收没收由人答卡决定,不在这儿判 -->
<sentry id="s_reviewable">
<onPart id="op_r" caseFileItemRef="substitution_relation_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_r"><condition>substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0</condition></ifPart>
</sentry>
<!-- 1 校验入参与补数据:核对两颗料在不在库、归没归同一类型、参数齐不齐;缺什么派数据工单让人补(全 auto,doneWhen 收步) -->
<humanTask id="S1_inputs" name="校验入参与补数据"/>
<!-- 2 逐项差异分析:核心参数逐条比对,四态、推荐等级、风险由系统按规则算(全 auto,doneWhen 收步) -->
<humanTask id="S2_assess" name="逐项差异分析">
<entryCriterion id="e_assess" sentryRef="s_inputs_ready"/>
</humanTask>
<!-- 3 结论审核**触达的是人**:标人工激活,且不给 doneWhen —— 完成与否不该由数据形状推出来 -->
<humanTask id="S3_review" name="差异评估结论审核" manualActivation="true">
<entryCriterion id="e_review" sentryRef="s_assessed"/>
</humanTask>
<!-- 4 落账放行**触达的是人**:标人工激活,且不给 doneWhen —— 签字这件事只能由人做 -->
<humanTask id="T_release" name="落账放行" manualActivation="true">
<entryCriterion id="e_release" sentryRef="s_reviewable"/>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**,不讲逐步做法(逐步做法归配方,而配方不是给模型看的,
* 是引擎按声明确定性执行的数据)。四步全部 `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'] },
];