# ACT-WORK-05 差异评估 · 整体重写设计 状态:**已评审通过**(2026-08-29,用户逐节点头) 落点:`activities/part-substitution-assessment@2.0.0` + 平台侧「确定性步骤」机制 关联:ADR-014(确定性步骤,本设计是它的第一个落地者与两处改口)、ADR-004(不执行包代码)、 `docs/活动开发规范.md`、`docs/HTML报告生成规范.md`、`docs/ss02-act-05-new/`(业务素材) --- ## 1. 为什么推倒重来 现版 `@1.7.3` 是**九步、两枚工具、全程 agent 驱动**:每一拍问模型「下一步干什么」, 它自己 `write` 台账、自己 `finish`、自己发卡。`docs/ACT-WORK-05-端到端测试记录.md` 的 A–AE 发现表里,八条事故落在「本来就该是确定的」那几步上 —— 编号抄别单、判在库判错、 数据工单派给在库的料、没写骨架就发卡、库里有值当缺口、抄别的料的规格书。 用户 2026-08-29 定的新立场: > 「不是把所有东西都丢给 ai 来处理,不要给 ai 暴露任何的工具,全部由系统确定性的来控制, > 一定是系统先确定性的从数据库中取出数据后交给 ai 分析。」 **agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。** 这个活动里,真需要判断的只有一件事:**这一条参数,候选跟原厂比算不算事**。别的全是确定的。 新版:**四步、零工具、零 `guide`、AI 出场恰好一次**。 --- ## 2. 已定的八条(评审逐条通过) | # | 决定 | |---|---| | 1 | **驱动**:落地 ADR-014 声明式配方 + 第四动词「一次结构化 AI 调用」。活动内部不再引第二套引擎(Mastra 仍在流程包编排与模型网关的原位) | | 2 | **台账**:两张新字典表 + P-08 主记录 + 参数子表 + P-11 主/明细 + P-06 + 一张活动自持案卷。**P-01 需求主记录 / P-10 原厂资料 / 客户档案本活动不碰** | | 3 | **P-06 芯片验证记录**:每条替代关系**固定落一条**,全推荐的写「本轮无待验项」 | | 4 | **结论谁定**:逐条四态由 AI 判;**推荐等级、风险、摘要、推荐理由由规则与模板算** | | 5 | **补数据的执行工单**:保存即铺格 —— 人选定/新建类型后按一次保存,系统按该类型的字段清单把参数值的行铺出来,人只填值,不加行不删行 | | 6 | **两颗料类型不一致**:硬拦,与「PN 不在库」同一条路(派工单让人改),保住「两边字段清单一定是同一套」这个前提 | | 7 | **结论审核卡**:只有「确认通过」一个钮,改全在件上;**人改完不重跑 AI**,只重算等级与摘要 | | 8 | **机制规模**:只实现本活动真用得到的那几式;没实现的形状装包时静态审当场拒 | --- ## 3. 数据模型 ### 3.1 两张新字典表(`parts` 域,与 P-08 同域) **`parts.chip_type` · 芯片类型** | 列 | 型 | 档 | 说明 | |---|---|---|---| | `type_id` | integer | 主键 · `auto` | 人不填,基座续号(现有最大 +1) | | `type_name` | text | 必填 | 「DC-DC 降压稳压器」;件上下拉显示它 | | `description` | text | 建议 | 这个类型是干什么的 | **`parts.type_param` · 芯片类型核心参数字段** | 列 | 型 | 档 | 说明 | |---|---|---|---| | `type_id` | integer | 主键 · `refersTo parts.chip_type.type_id` | 属于哪个类型 | | `param_name` | text | 主键 | 「输入电压范围」 | | `param_desc` | text | 必填 | 这个参数是什么意思 —— **AI 分析时读它** | | `constraint_type` | text | 必填 · tally `hard`/`soft` | 硬约束 / 软约束 | | `param_unit` | text | 建议 | 参考单位 | 关联:一行 P-08 关联一个类型;多行 `type_param` 关联一个类型。 ### 3.2 P-08 及其子表的改动 **`parts.profile`**(声明后为八列,物理表其余列不声明 = 本活动不碰): `internal_pn`(主键)· **`chip_type_id` integer 必填 refersTo `parts.chip_type.type_id`** · `brand_name` 必填 · `lifecycle_status` 必填 tally · `specification_description` 建议 · `manufacturer_name` 建议 · `datasheet_ref` 建议 · `datasheet_version` 建议 **`createOnly` 撤掉**(现版标着它,只许建新料、不许改已有行)。它当初是为了拦模型凭空建料; 模型现在不在场,**行由配方按本单两颗料铺出来,凭空建在结构上不可能**。而撤掉它换来一件必需的事: **给在库的老料补上「芯片类型」这一格** —— 迁移前建的行没有类型,不许改就永远补不了,单子卡死。 **不声明**:`package` 封装 · `process_technology` 工艺 · `eol_pcn_status` · `application_scenarios`。 **封装和工艺本来就是「核心参数」** —— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列。 **`parts.parameter`**:`internal_pn`(主键,refersTo profile)· `param_name`(主键)· `param_value` 必填 · `param_unit` 建议 · `param_note` 建议(**值说明,AI 也读它**) `parts.parameter` 的 **`createOnly` 同样撤掉**(理由同上:④那一笔要把在库那颗料的现值回填进草稿层, 只许建新就写不进去)。 两处退休: - **`constraint_type` 退休** —— 搬去 `type_param`。同一个参数在两颗料上标法不一致这件事从结构上消失。 - **`param_name` 的自指引用墙退休**(`refersTo: 'parts.parameter.param_name'`)。它当初是为了硬逼两颗料 用同一套参数名,措辞拦了三次没拦住才砌的;现在字段清单由类型字典给、**行由系统按字典铺**, 名字对不上根本不可能发生。`distinct` 一并退休。 ### 3.3 活动自持两张(顶掉现在的 `pn_check` / `input_gap` / `verdict` 三张) **`sub_eval.part` · 本单两颗料**,`kind: record`,`caseKey: eval_ref` —— 一单**恰两行** `eval_ref`(主键)· `internal_pn`(主键)· `role` 必填 tally `original`/`candidate` · `exists_status` 必填 tally `found`/`absent` · `chip_type_id` integer **必填** · **`distinct: true`** (「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种) **`sub_eval.case` · 本单案卷**,同上 —— 一单**一行** `eval_ref`(主键)· `chip_type_id`(本单锁定的类型)· `gate_reason`(入参未就绪的原因,件上摆的就是它)· `summary`(结论一句话) `exists_status` 与两处 `chip_type_id` **每一拍由 `lookup` 重算** —— 人把料建好之后自己变 `found`, 不设 `created` 档、不靠谁记账。 **为什么非拆成两张不可**:`part` 是**配方唯一能拿来遍历「哪几颗料要建」的数据源** —— `each: 'mine:sub_eval.part' where exists_status = absent`。挤进一行案卷里就没法 `where` 过滤, 而不能过滤就会给已经在库的那颗料也铺一遍空参数行,`_missing` 当场报缺、单子死在那儿。 ### 3.4 结论三张 **`substitution.relation`**(P-11 主记录,一单一行) `substitution_relation_id`(主键)· `eval_ref` 必填 · `original_internal_pn` / `candidate_internal_pn` 必填 refersTo profile · `chip_type_id` 必填 · `recommendation_level` 必填 tally `recommended`/`conditional`/`not_recommended` · `recommendation_reason` 必填 · `difference_summary` 必填 · `risk_level` 必填 tally `low`/`medium`/`high` · `verification_requirement`(`requiredWhen recommendation_level = conditional`)· `validation_id` · `analysis_status` 必填(固定 `fae_confirmed`)· `rule_version` 必填 退休:`substitution_type`(新流程里没有它的定夺人)· `confidence_score` · `analysis_report_ref` · `recommendation_level` 的 `alternate` 档(原 D02 的 `configurable` 来路已不存在)。 **`substitution.difference_item`**(P-11 明细,一参数一行) `substitution_relation_id`(主键 · `distinct` · refersTo relation)· `difference_item_id`(主键)· `parameter_name` 必填 · `parameter_desc` 建议(字段说明快照,归档件不必 join)· `constraint_type` 必填 tally · `original_value` / `candidate_value` 必填 · `parameter_unit` 建议 · **`difference_status` 必填 tally `match`/`mismatch`/`unknown`/`conditional`(AI 判)** · **`judgement_class` 必填 tally `match`/`soft_mismatch`/`hard_mismatch`/`unknown`/`conditional`(系统合成)** · `impact_statement` 必填(AI)· `evidence_note` 必填(AI)· `unknown_disposition`(`requiredWhen difference_status = unknown`,AI)· `risk_level` 必填 tally · `rule_version` 必填 **`judgement_class` 是这套设计的关键一列**:推荐等级要数的是「硬约束不符有几条」, 而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。 `evidence_ref` 改名 `evidence_note`「判断依据」:不写 P-10 了,AI 能给的就是依据不是文件引用,名字得说实话。 **`validation.record`**(P-06,一单一行) `validation_id`(主键)· **`eval_ref` 必填**(顶掉 `request_id` —— P-01 不在本活动里)· `validation_internal_pn` 必填 refersTo profile · `validation_type` 必填 tally(**追加一档 `parametric`**)· `validation_status` 必填(固定 `not_started`)· `test_condition` 建议 · `next_validation_action` 必填 ### 3.5 规则(唯一真源在配方里,不再另留 DMN) **`judgement_class`**:`match` → match;`unknown` → unknown;`conditional` → conditional; `mismatch` 且硬 → `hard_mismatch`;`mismatch` 且软 → `soft_mismatch`。 **明细 `risk_level`**:`hard_mismatch` → high;`unknown`/`conditional`/`soft_mismatch` → medium;`match` → low。 **`recommendation_level`**(读 `judgement_class` 的 tally 计数,自上而下第一条命中): | 条件 | 等级 | |---|---| | `hard_mismatch > 0` | `not_recommended` | | `unknown > 0` | `conditional` | | `conditional > 0` | `conditional` | | `soft_mismatch > 0` | `conditional` | | 其余 | `recommended` | **关系 `risk_level`**:`not_recommended` → high;`conditional` → medium;`recommended` → low。 **模板串**: - `difference_summary` = 「共 N 项核心参数:x 一致 / y 不一致(硬 z)/ u 待确认 / c 有条件」 - `recommendation_reason` = 按等级三形:点名硬约束不符的参数 / 点名待确认与有条件的项 / 「N 项全部一致,无待确认项」 - `verification_requirement` = 「实测并回填:」 - P-06 `test_condition` = 同上参数名列表(没有就写「无」);`next_validation_action` = 「实测上述参数并回填 P-08 标准化参数集」/「本轮无待验项」 --- ## 4. 四步案例模型 进度条四格:**校验入参 → 差异分析 → 结论审核 → 落账放行**。 全程 `guide: ''`、`tools: []`、`declarations.tools: []`。 ### 4.1 启动参数(运营中心活动详情一键启动) 两格,**都选填**: - `originalInternalPn`「原内部料号」—— `x-control: combo`,`x-options` 从 `parts.profile.internal_pn` 现读;可选库里的,也可自己写一个新的 - `candidateInternalPn`「候选内部料号」—— 同款 ### 4.2 S1 校验入参与补数据 —— 纯系统 +(条件)一张数据工单 ``` let evalRef = cat('W05-', fmt(case:openedAt, digits)) ① project sub_eval.part from rows[{pn:入参原PN, role:'original'}, {pn:入参候选PN, role:'candidate'}] eval_ref = {in:'evalRef'};internal_pn = {col:'pn'};role = {col:'role'} exists_status = when lookup('parts.profile' by internal_pn={col:'pn'} take internal_pn) != '' then 'found' else 'absent' chip_type_id = lookup('parts.profile' by internal_pn={col:'pn'} take chip_type_id, else '') ② project sub_eval.case from once eval_ref = {in:'evalRef'} chip_type_id = lookup('mine:sub_eval.part' by role='original' take chip_type_id, else '') gate_reason = when 链(**全读基座数**): 两颗 PN 有一格为空 → 「原 / 候选料号还没填」 sub_eval_part_exists_status_absent > 0 → 「 库里没有,请在下面建」 sub_eval_part_distinct_chip_type_id > 1 → 「两颗料类型不一致,换一颗或把类型改对」 parts_profile_missing + parts_parameter_missing > 0 → 「还差 n 格没填」 其余 → '' ③ project parts.profile prune from each('mine:sub_eval.part') ← **两颗料都铺** internal_pn = {col:'internal_pn'} chip_type_id / brand_name / lifecycle_status / specification_description / manufacturer_name / datasheet_ref / datasheet_version = lookup('parts.profile' by internal_pn={col:'internal_pn'} take 同名列, else '') ← 回填现值 ↑ 与④同构。在库那颗回填出它此刻的样子;要建那颗全空等人填。 **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 chip_type_id 必填。 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 ④ project parts.parameter prune from each('mine:sub_eval.part') ← **两颗料都铺,不只是缺库那颗** then each('parts.type_param' where type_id={outer:'chip_type_id'}) internal_pn = {outer:'internal_pn'};param_name / param_unit ← {col:…} param_value = lookup('parts.parameter' by internal_pn={outer:'internal_pn'}, param_name={col:'param_name'} take param_value, else '') ← **回填现值** param_note = 同上 take param_note ↑ 三件事一笔办完: · 人还没挑类型 → {outer:'chip_type_id'} 是空串,where 一行不命中,什么也不铺; 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; · **「在库但参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 ⑤ card 执行工单 · variant data · view 'input-supplement' when gate_reason != '' gateLedgers = [parts.chip_type, parts.type_param, parts.profile, parts.parameter] ⑥ say doneWhen sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 ← 两行的 chip_type_id 都有值 and sub_eval_part_distinct_chip_type_id = 1 ← 且是同一个类型 and parts_profile_missing = 0 and parts_parameter_missing = 0 ``` **`doneWhen` 里没有「行数 = 字段数×2」这条对账,是故意的**:参数行由 `prune` + `each 字典` 铺出来,**人不加行不删行**,少一行在结构上不可能发生(真发生就是配方 bug,归静态审与包内自测)。 而「本类型有几个字段」根本不是基座数得出来的数 —— `parts_type_param_rows` 数的是**本单写下的** 那几行(人新建的字段),不是整本字典。**给判据编一个数不出来的数,是这个仓踩过六次的同一个坑。** - **两颗都在库、类型一致、参数齐 → 一张卡都不发**,首拍 `doneWhen` 即真,直接进 S2。 - 人在件上按「保存」→ 系统 tick → 配方**原样重跑**:类型这会儿知道了,④把参数行铺出来; ⑤因为卡发过(`worldTouched`)不再发;件就地刷新。 **幂等靠同 PK 覆盖 + 发过不再发 + `prune`**,不靠谁记得住。 - 人点「提交」= 数据工单确认 → **`chip_type` / `type_param` / `profile` / `parameter` 当场进正式表** (平台 2026-08-27 的范围放行)。它们是主数据,该立刻可用,不等整单放行。 ### 4.3 S2 差异分析 —— 纯系统 + AI 唯一一次出场 ``` ① analyze model: deepseek-v4-flash into 'result:items' input from each('parts.type_param' where type_id=本单类型): { 字段名, 字段说明, 约束(硬/软), 单位, 原PN值 = lookup('parts.parameter' by internal_pn=原PN, param_name=col), 原PN值说明 = 同上 take param_note, 候选值 = lookup('parts.parameter' by internal_pn=候选PN, param_name=col), 候选值说明 = 同上 take param_note } output schema { items: [{ param_name, difference_status, impact_statement, evidence_note, unknown_disposition? }] } ② project substitution.difference_item prune from each('parts.type_param' where type_id=本单类型) ← **行由字典决定,不由 AI 决定** parameter_name / parameter_desc / constraint_type / parameter_unit ← {col:…} original_value / candidate_value ← lookup('parts.parameter' …) difference_status / impact_statement / evidence_note / unknown_disposition ← lookup('result:items' by param_name={col:'param_name'}, else '') judgement_class = when 链(见 §3.5);risk_level 由它映射 ③ project substitution.relation from once recommendation_level / risk_level / difference_summary / recommendation_reason / verification_requirement ← §3.5 规则与模板 ④ project validation.record from once ⑤ say doneWhen difference_item_rows = 本类型字段数 and substitution_relation_rows = 1 and validation_record_rows = 1 ``` **这一步一张卡都不发,人不出现。** 三处刻意的克制: - **AI 决定不了「有几行」。** 明细的行由类型字典 `each` 出来,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表;它漏了哪一条,那一行的四态就是空 —— `required` 立刻把它变成 `_missing > 0`,提交闸与放行门当场拦住。**从前是 `from each(AI 出参)`,AI 少给两条就少两行, 而每一行看着都合规。** - AI 只答「这一条算什么、为什么」,**值是多少一个字都不重述** —— 基座读得到的事实不让模型转述。 - 推荐等级、风险、摘要、推荐理由**全由规则与模板拼**。永远不会出现「有硬约束不符却写着推荐」。 - 出参不合 schema → **就地重问一次**;第二次仍不成 → 留痕交人,不退回模型自由发挥。 ### 4.4 S3 结论审核 —— 一张验收工单,人就地改 配方只有一笔:`card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一选项「确认通过」**, 带人工验收清单四条)。 人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → S2 的第③笔把推荐等级、摘要、风险 **重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。 > 重算发生在 S2 那一步的配方里,而 S2 已收步 —— 所以 S3 的配方里**复制一份 ③④ 两笔** > (纯投影、幂等),不去回退步骤。这是刻意的:一步一份配方,不做跨步回跳。 这一步**不给 `doneWhen`**(它触达世界),人点了「确认通过」即收步。 ### 4.5 S4 落账放行 —— 纯系统派单 ``` ① project sub_eval.case.summary from once ← 模板串 ② submit view 'landing-confirm' + 人工验收清单五条 ``` 人签 → 合并 `substitution.relation` / `difference_item` / `validation.record` / `sub_eval.case` 进正式表 → 发口 `assessment-released`。 **出口载荷**:`evalRef` · `relationId` · `originalPn` · `candidatePn` · `recommendationLevel` · `matchTotal` / `mismatchTotal` / `unknownTotal` / `conditionalTotal` · `validationId`。 ### 4.6 预算 调人 3 次(补数据 / 审核 / 放行),cap **8**;模型调用 **1** 次,tokens **30 万**; 步数 **40**(保存一次 = 一拍,给人反复保存留余量);`activeMinutes` 60。 对照现版:90 步 / 200 万 token / 16 次调人。 --- ## 5. 平台要补的能力 全是**加法**。E04 / I01 一个字不用改 —— 有 `guide` 的步照常问模型。 ### 5.1 `PlanStep.auto` 配方 + 求值器 契约照 ADR-014 §4 的形状,**只实现这几式**: - **动词**:`project` · `card` · `analyze` · `submit` · `say` - **表达式**:常量 · `{in:入参|基座读数|let}` · `{col:本行列}` / `{outer:外层行列}` · `{lookup:地址, by, take, else}` · `{when:S-FEEL, then, else}` · `{cat:[…]}` · `{fmt:…, as:'digits'}` —— `lookup` 的地址除了 `域.对象`(任务视图)/ `mine:域.对象`,还能是**拍内作用域的数组** `result:xxx` (`analyze` 的出参当查表用,S2 的明细就靠这一条) - **数据源**:`{once}` · `{rows:[…]}` · `{each:…, where?, then?{each:…}}`(**两层封顶**) - **地址只有四种**,配方里出现的每一处都得是其中之一: - **`域.对象`** —— **任务视图 = 正式表 + 本单草稿叠加**。这是配方默认该用的那一种。 (它不是新东西:`engines.ts` 的 `taskData.view` 早就把 `ledger.taskView` 的结果铺在这个键上。 **这一点是这套设计的命门** —— 人在件上新建的料与字段,按保存那一刻只在草稿层; 拿纯正式表去看永远看不见它们,参数格就永远铺不出来。) - `mine:域.对象` —— 只有本单草稿写下的行。**「哪几颗料是这一单要办的」只有它答得出** - `in:入参数组` —— 起单入参里的数组 - `result:xxx` —— 本拍 `analyze` 的出参 **没有「纯正式表」这一种**,配方不需要它:要区分「本单写的」与「别人放行的」,用 `mine:` 就够了。 - **`project` 加 `prune: true`**:这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。 只动本单草稿层,正式表一个字不碰。(换类型时上一个类型铺的参数行必须消失。) 求值器是纯函数(域层、零 IO);执行器在 usecase,拿 ledger / humanTool / llm 端口。 **必须写死的一条**:每一笔 `do` 执行**之前**刷新基座读数。S2 的第③笔要数第②笔刚写下的行 —— 不刷新就恒为 0,等级永远算成「推荐」。 ### 5.2 `analyze` 动词 —— 声明式 AI 调用 入参由声明组装、出参给 JSON schema、结果落进拍内作用域 `result:*` 供后面的 `project` 消费。 走现有模型网关(四元绑定、凭证隔离、录制回放、用量计入预算),**不给工具、不给记忆、不多轮**: 一次 generate,结构化出参。出参不合 schema → 就地重问一次 → 仍不成 → 留痕交人。 ### 5.3 视图绑定加一件:`data-options` `data-options="parts.chip_type"` + `data-option-value="type_id"` + `data-option-label="type_name"` → 渲染时把 `