diff --git a/docs/superpowers/plans/2026-08-29-act-work-05-rewrite.md b/docs/superpowers/plans/2026-08-29-act-work-05-rewrite.md new file mode 100644 index 0000000..849effb --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-act-work-05-rewrite.md @@ -0,0 +1,2139 @@ +# ACT-WORK-05 差异评估 · 活动重写 实施计划(第二份) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `activities/part-substitution-assessment` 从「九步、两枚工具、全程 agent 驱动」重写成「**四步、零工具、零 `guide`、AI 出场恰好一次**」—— 每一步由引擎按声明式配方确定性地做完。 + +**Architecture:** 活动包只是**数据**(清单 + 案例模型 + HTML 模版),平台不执行它的代码。四步各写一份 `PlanStep.auto` 配方(五动词);唯一的 AI 出场是 S2 的一笔 `analyze` —— 系统把两颗料的核心参数逐条组装好交给它,它只答「这一条算什么、为什么」,**值是多少一个字都不重述**。推荐等级、风险、摘要、验证要求全由规则与模板串算,不让 AI 编。 + +**Tech Stack:** TypeScript(`exactOptionalPropertyTypes`)· zod 契约 · vitest · S-FEEL 判据 · 无脚本沙箱 HTML 模版 + +**Spec:** `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` —— 本计划实现它的 **§3 / §4 / §6 / §7 / §8 / §9 / §10**(§5 平台机制已在 `feat/deterministic-steps` 上落地并通过全支终审) + +**前置(已完成,可直接用)** +- `PlanStep.auto` 配方:五动词 `project` / `analyze` / `card` / `submit` / `say`;八式表达式;四种地址;嵌套两层封顶 +- `guide` 允许为空**当且仅当**这一步声明了 `auto` +- 卡答复即收步(前提:无 `doneWhen` + 卡发过 + 配方会发卡 + **这一步没有 guide**) +- 装包静态审:台账地址 / 列名 / 模型名 / 视图名 / `submit` 要有人工验收清单 / `when` 的 S-FEEL / `project.fields` 必须含目标表主键列 / `card` 必须有 `basis` / `prune` 与 `humanWrites` 同表报 warn +- 写配方怎么写:**《活动开发规范》§4.6** —— 动手前先读它 + +## Global Constraints + +- **五道门禁以退出码验,不许接管道**(`cmd | tail` 的退出码是 `tail` 的):`pnpm gates`。 +- **契约变更四步**:改 `packages/contracts` → depcruise 反查下游 → 同步 CONTRACT.md → `pnpm api:check --update && git add api-report`。 +- **禁止清单**:不许 `any` / `as unknown as` / `@ts-ignore`;**生产代码不许 `as never`**(测试文件里是本仓既有做法)。 +- **活动包只准 import `@sdk` / `@contracts` / `@sfeel`** —— depcruise 门禁会查。 +- **机检只读基座数**:判据的任何一边都不许是活动自己写的汇总列。这条在本仓被违反过六次,每次都是同一种事故。 +- **判据不许编一个数不出来的数**:`_rows` / `_missing` / tally / `_distinct_x` 数的都是**本单草稿写下的行**,不是整张表。「本类型有几个字段」不是基座数,别写进 `doneWhen`。 +- **触达世界的项不给 `doneWhen`** —— 给了等于给引擎一把替人签字的钥匙。 +- **模版不许算、不许判**:只取值;判断留在样式表(`data-class-of`)与基座。 +- **注释用中文**,写「为什么这么做 / 踩过什么坑」,不写「这是什么」。 +- **反例一律不引原话** —— 关思考的模型不读「别说」的否定框,只认框里现成的句子。要引原话就引正例。 +- **安装恒新增**:改了内容必须升 `packageVersion`,同版重装是 no-op(静默丢失,踩过)。**装完看库,不看脚本的话。** +- **试跑一律用 AI 服务账号**,绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs`。 +- **不要跑** `./stable.sh update`(稳定版与开发共用 `jcai_wos` 库,别动它)。 +- 提交信息中文,末尾附: + +``` +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +``` + +--- + +## 前置:分支 + +第一份计划的成果在 `feat/deterministic-steps` 上(38 笔,未合并)。本计划**建在它之上**: + +```bash +git switch feat/deterministic-steps # 若已在别处,先确认它是 HEAD +git switch -c feat/w05-rewrite +``` + +做完由主支发起合并(先合机制那条,再合这条),不要反过来把主支合进 feature。 + +--- + +## 文件结构 + +| 文件 | 职责 | 动作 | +|---|---|---| +| `apps/api/src/app/seed-mvp-data.ts` | 平台基线:物理表 + 种子数据 | **改**:加两张新表的物化 + 类型/字段/LDO 料的种子行 | +| `activities/part-substitution-assessment/src/constants.ts` | 域名、规则版本 | **改**:退役两个工具名常量 | +| `.../src/manifest.ts` | 清单:契约 / 台账声明 / 判据 / 样例 / 视图 | **重写** | +| `.../src/case-model.ts` | CMMN + 说明书 + 四步剧本(含配方)+ 阶段 | **重写** | +| `.../src/views/target-style.ts` | 目标件抽出来的样式表 | **改**:保留可复用的,删无主的 | +| `.../src/views/report.ts` | 三张件 | **重写** | +| `.../src/views/ledger-board.ts` | 运营中心看板 | **改**:重绑到新表 | +| `.../src/skills.ts` | 技能戳(给模型看的) | **删**:全程无 agent,它没有消费方 | +| `.../__tests__/activity.test.ts` | 清单合规 / 授权面对齐 | **重写** | +| `.../__tests__/steps.test.ts` | 剧本与配方口径钉死 | **重写** | +| `tools/e2e-a2.mjs` | e2e 套件 | **改**:`SUITES.w05`(约 550 行起)重写成五案例 | +| `apps/api/src/app/tools/first-party.ts` | 一方工具 | **改**:两枚工具退役并留墓志铭 | + +--- + +### Task 1: 两张新字典表 —— 物化 + 种子 + +**Files:** +- Modify: `apps/api/src/app/seed-mvp-data.ts` +- Test: `apps/api/src/app/__tests__/seed-mvp-data.test.ts` + +**Interfaces:** +- Produces: 物理表 `parts__chip_type`(`type_id` integer 主键 · `type_name` · `description`)与 `parts__type_param`(`type_id` + `param_name` 复合主键 · `param_desc` · `constraint_type` · `param_unit`);`parts.profile` 追加 `chip_type_id` integer 列;种子数据:两个类型、七 + 五个核心参数字段、三颗料的 `chip_type_id`、一颗新 LDO 料 `AMS1117-3.3` 及其参数行 + +- [ ] **Step 1: 先读现状,别猜** + +Run: +```bash +grep -n "domain: 'parts'" apps/api/src/app/seed-mvp-data.ts | head +sed -n '140,200p' apps/api/src/app/seed-mvp-data.ts +``` +看清两件事:①**表的物化清单**长什么样(`f(name, type, label, primaryKey)` 帮手、`address: { domain, object }` 的结构);②**种子行**长什么样(`{ address, key, values }`)。照它的形状写,别另造一套。 + +- [ ] **Step 2: 写失败的测试** + +追加到 `apps/api/src/app/__tests__/seed-mvp-data.test.ts` 末尾(用该文件既有的方式取物化清单与种子行;若它导出的名字与下面不同,**用文件里真有的**): + +```ts +describe('芯片类型字典(W05 重写)', () => { + it('两张新表都物化了,主键形状对', () => { + const chipType = tableOf('parts', 'chip_type'); + expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true }); + expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description'])); + + const typeParam = tableOf('parts', 'type_param'); + /* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */ + expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']); + expect(typeParam?.fields.map((f) => f.name)).toEqual( + expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']), + ); + }); + + it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => { + expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' }); + }); + + it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => { + const types = seedRowsOf('parts', 'chip_type'); + expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']); + + const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id']; + const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc); + expect(params.map((r) => r.values['param_name'])).toEqual([ + '输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值', + ]); + /* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */ + expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft'); + expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard'); + /* 每个字段都要有说明 —— AI 分析时读的就是它 */ + expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true); + + const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; + expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5); + }); + + it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => { + const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id']; + const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; + const parts = seedRowsOf('parts', 'profile'); + for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) { + expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc); + } + expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo); + }); + + it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => { + const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id']; + const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']); + const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3'); + expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort()); + }); +}); +``` + +- [ ] **Step 3: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/red1.txt 2>&1; echo "EXIT=$?"; cat /tmp/red1.txt` +Expected: FAIL —— 两张表不存在。**把 `/tmp/red1.txt` 整份贴进报告,不要裁剪。** + +- [ ] **Step 4: 加两张表的物化** + +在 `seed-mvp-data.ts` 的物化清单里,`parts.profile` **之前**加(字典要先于引用它的表): + +```ts + { + address: { domain: 'parts', object: 'chip_type' }, + kind: 'master', + label: '芯片类型', + fields: [ + /* + * **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号 + * (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。** + */ + f('type_id', 'integer', '类型ID', true), + f('type_name', 'text', '类型名称'), + f('description', 'text', '类型说明'), + ], + }, + { + address: { domain: 'parts', object: 'type_param' }, + kind: 'master', + label: '芯片类型核心参数字段', + fields: [ + f('type_id', 'integer', '所属类型', true), + f('param_name', 'text', '核心参数名', true), + /* **AI 分析时读的就是这一列** —— 参数名本身说不清「它为什么要紧」 */ + f('param_desc', 'text', '参数说明'), + /* + * 约束类型从 `parts.parameter`(每颗料各标一次)搬到这里(按类型标一次)—— + * 「同一个参数在两颗料上标法不一致」这件事从此在结构上不可能发生。 + */ + f('constraint_type', 'text', '约束类型'), + f('param_unit', 'text', '参考单位'), + ], + }, +``` + +并在 `parts.profile` 的字段清单里加一行: + +```ts + f('chip_type_id', 'integer', '芯片类型'), +``` + +- [ ] **Step 5: 加种子行** + +在 `parts.parameter` 的种子行**之前**加两个类型与十二条字段(`type_id` 用 1 / 2 两个字面值,与既有种子的写法一致): + +```ts + { address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 1 }, values: { type_id: 1, type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器:靠电感储能与高频开关把输入电压降到目标输出,效率高、发热小,外围需要电感与输出电容。' } }, + { address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 2 }, values: { type_id: 2, type_name: 'LDO 线性稳压器', description: '低压差线性稳压器:靠调整管压降稳压,外围简单、噪声低,压差与电流之积全部转成热耗。' } }, + + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输入电压范围' }, values: { type_id: 1, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电压' }, values: { type_id: 1, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电流' }, values: { type_id: 1, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '开关频率' }, values: { type_id: 1, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '静态电流' }, values: { type_id: 1, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '工作温度' }, values: { type_id: 1, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '使能阈值' }, values: { type_id: 1, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' } }, + + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输入电压范围' }, values: { type_id: 2, param_name: '输入电压范围', param_desc: '允许的输入直流电压区间。', constraint_type: 'hard', param_unit: 'V' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电压' }, values: { type_id: 2, param_name: '输出电压', param_desc: '固定版给定值;可调版需外围分压设定。', constraint_type: 'hard', param_unit: 'V' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电流' }, values: { type_id: 2, param_name: '输出电流', param_desc: '满载持续输出能力;线性稳压器受热耗限制,实际可用电流常低于标称。', constraint_type: 'hard', param_unit: 'A' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '压差' }, values: { type_id: 2, param_name: '压差', param_desc: '维持稳压所需的最小输入输出电压差。压差不够就稳不住,输出跟着输入掉。', constraint_type: 'hard', param_unit: 'V' } }, + { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '工作温度' }, values: { type_id: 2, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。', constraint_type: 'hard', param_unit: '℃' } }, +``` + +给两颗既有料的 `parts.profile` 种子行各加 `chip_type_id: 1`;再播一颗 LDO 主记录与它的五行参数(**这一颗是「类型不一致 → 硬拦」那条 e2e 用例的唯一素材,没有它那条路测不了**): + +```ts + { + address: { domain: 'parts', object: 'profile' }, + key: { internal_pn: 'AMS1117-3.3' }, + values: { + internal_pn: 'AMS1117-3.3', chip_type_id: 2, brand_name: 'AMS', + manufacturer_name: 'Advanced Monolithic Systems', specification_description: '3.3V 固定输出低压差线性稳压器,SOT-223', + lifecycle_status: 'active', datasheet_ref: 'https://example.invalid/ams1117', datasheet_version: 'Rev.C', + }, + }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围', param_value: '4.75~15', param_unit: 'V', param_note: '最低受压差限制' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电压' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电压', param_value: '3.3', param_unit: 'V', param_note: '固定版' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电流' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电流', param_value: '1', param_unit: 'A', param_note: '受散热限制' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '压差' }, values: { internal_pn: 'AMS1117-3.3', param_name: '压差', param_value: '1.3', param_unit: 'V', param_note: '满载时' } }, + { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '工作温度' }, values: { internal_pn: 'AMS1117-3.3', param_name: '工作温度', param_value: '-40~+125', param_unit: '℃', param_note: '' } }, +``` + +**既有 `parts.parameter` 种子行里的 `constraint_type` 全部删掉** —— 它搬到字典上了,留着会让「同一个参数两处各有一个约束」重新可能。 + +- [ ] **Step 6: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/green1.txt 2>&1; echo "EXIT=$?"; cat /tmp/green1.txt +pnpm gates +``` +Expected: 前者 5 条全 PASS(整份贴进报告);`pnpm gates` 退出码 **0**。 + +> ⚠️ 现在**先不要**跑 `pnpm db:migrate` / `db:seed` —— 那会动开发库,而开发库与稳定版共用。 +> 落库统一放到 Task 12(装包与真库跑通)一次做,那时才有完整的活动包配套。 + +- [ ] **Step 7: 提交** + +```bash +git add apps/api/src/app/seed-mvp-data.ts apps/api/src/app/__tests__/seed-mvp-data.test.ts +git commit -m "$(cat <<'EOF' +feat(seed): 芯片类型字典两张表 —— 约束类型从「每颗料各标一次」搬到「按类型标一次」 + +两颗料是不是用同一套参数名、同一套软硬约束,从此是结构保证而不是措辞叮嘱: +字段清单挂在类型上,一颗料关联一个类型。此前靠 parts.parameter.param_name 的 +自指引用墙硬咬(措辞拦了三次没拦住才砌的),那堵墙可以退休了。 +另播一颗 LDO(AMS1117-3.3)—— 它是「两颗料类型不一致 → 硬拦」那条路的唯一素材。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 2: 清单 —— 台账声明与契约 + +**Files:** +- Modify: `activities/part-substitution-assessment/src/manifest.ts`(`declarations.ledger` 与 `contract` 两段整体重写) +- Modify: `activities/part-substitution-assessment/src/constants.ts` +- Test: `activities/part-substitution-assessment/__tests__/activity.test.ts` + +**Interfaces:** +- Consumes: Task 1 物化的 `parts.chip_type` / `parts.type_param` / `parts.profile.chip_type_id` +- Produces: 七张台账的声明(两张字典 + P-08 主/参数 + 案卷两张 + P-11 主/明细 + P-06)、启动两参、单一出口 `assessment-released`、九条验收判据、预算 + +> **这一步做完包应当仍能 typecheck、包内自测绿。** 案例模型还是旧的九步 —— 下一个任务才换, +> 所以这一步**先不要动** `case-model.ts`;`manifest` 里对旧步骤的引用(`ASSESSMENT_STEPS` 等)原样留着。 +> 若旧剧本引用了本步删掉的台账地址导致包内自测红,**把那几条断言临时注掉并在报告里点名**,Task 3 会补回。 + +- [ ] **Step 1: 写失败的测试** + +把 `__tests__/activity.test.ts` 里「台账声明」那一组整体换成: + +```ts +const ledgerOf = (address: string) => + manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address); +const colOf = (address: string, name: string) => ledgerOf(address)?.fields.find((f) => f.name === name); + +describe('台账声明', () => { + it('恰好九条声明(七个业务对象,P-08 与 P-11 各是主表 + 子表)', () => { + expect(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`).sort()).toEqual([ + 'parts.chip_type', 'parts.parameter', 'parts.profile', 'parts.type_param', + 'sub_eval.case', 'sub_eval.part', + 'substitution.difference_item', 'substitution.relation', 'validation.record', + ].sort()); + }); + + it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => { + expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'integer', auto: true }); + expect(colOf('parts.chip_type', 'type_name')?.required).toBe(true); + }); + + it('字段字典:复合主键 + 约束类型长在这里(不在每颗料上)', () => { + expect(ledgerOf('parts.type_param')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']); + expect(colOf('parts.type_param', 'type_id')?.refersTo).toBe('parts.chip_type.type_id'); + expect(colOf('parts.type_param', 'param_desc')?.required).toBe(true); + expect(colOf('parts.type_param', 'constraint_type')).toMatchObject({ required: true, tally: ['hard', 'soft'] }); + }); + + it('P-08:类型必填并引用字典;createOnly 撤掉 —— 在库老料要能补上类型这一格', () => { + expect(colOf('parts.profile', 'chip_type_id')).toMatchObject({ required: true, type: 'integer', refersTo: 'parts.chip_type.type_id' }); + expect(ledgerOf('parts.profile')?.createOnly).toBeUndefined(); + /* 封装与工艺本来就是「核心参数」—— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列 */ + for (const gone of ['package', 'process_technology', 'eol_pcn_status', 'application_scenarios']) { + expect(colOf('parts.profile', gone)).toBeUndefined(); + } + }); + + it('参数子表:约束类型与自指引用墙都退休了', () => { + expect(colOf('parts.parameter', 'constraint_type')).toBeUndefined(); + expect(colOf('parts.parameter', 'param_name')?.refersTo).toBeUndefined(); + expect(colOf('parts.parameter', 'param_name')?.distinct).toBeUndefined(); + expect(colOf('parts.parameter', 'param_value')?.required).toBe(true); + expect(ledgerOf('parts.parameter')?.createOnly).toBeUndefined(); + }); + + it('案卷两张:part 一单两行(配方遍历的唯一来源)、case 一单一行', () => { + expect(ledgerOf('sub_eval.part')?.caseKey).toBe('eval_ref'); + expect(ledgerOf('sub_eval.part')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']); + /* 「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种 */ + expect(colOf('sub_eval.part', 'chip_type_id')).toMatchObject({ required: true, distinct: true }); + expect(colOf('sub_eval.part', 'exists_status')?.tally).toEqual(['found', 'absent']); + expect(ledgerOf('sub_eval.case')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref']); + }); + + it('P-11 明细带合成列 judgement_class —— 推荐等级要数的是「硬约束不符有几条」', () => { + expect(colOf('substitution.difference_item', 'judgement_class')).toMatchObject({ + required: true, + tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'], + }); + /* 「判断依据」不是文件引用 —— 不写 P-10 了,名字得说实话 */ + expect(colOf('substitution.difference_item', 'evidence_note')?.required).toBe(true); + expect(colOf('substitution.difference_item', 'evidence_ref')).toBeUndefined(); + expect(colOf('substitution.difference_item', 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' }); + }); + + it('P-11 主记录:退休三列,等级只剩三档', () => { + for (const gone of ['substitution_type', 'confidence_score', 'analysis_report_ref']) { + expect(colOf('substitution.relation', gone)).toBeUndefined(); + } + expect(colOf('substitution.relation', 'recommendation_level')?.tally).toEqual(['recommended', 'conditional', 'not_recommended']); + }); + + it('P-06:关联的是本单案号(P-01 不在本活动里),类型加了 parametric 一档', () => { + expect(colOf('validation.record', 'eval_ref')?.required).toBe(true); + expect(colOf('validation.record', 'request_id')).toBeUndefined(); + expect(colOf('validation.record', 'validation_type')?.tally).toContain('parametric'); + }); + + it('这个活动不碰 P-01 / P-10 / 客户档案', () => { + for (const gone of ['demand.request', 'parts.document', 'customer.profile']) { + expect(ledgerOf(gone)).toBeUndefined(); + } + }); +}); + +describe('契约', () => { + it('启动两参,都选填 —— 缺了由第一步派工单补', () => { + const props = manifest.contract.input.data['properties'] as Record>; + expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']); + expect(manifest.contract.input.data['required']).toEqual([]); + /* 运营端启动表单:两格都是「可选可新写」的下拉 */ + for (const key of ['originalInternalPn', 'candidateInternalPn']) { + expect(props[key]?.['x-control']).toBe('combo'); + expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' }); + } + }); + + it('零工具 —— 这是这次重写的立场,不是疏漏', () => { + expect(manifest.contract.resources.tools).toEqual([]); + expect(manifest.declarations.tools).toEqual([]); + }); + + it('单一出口,载荷是已落地的事实', () => { + expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['assessment-released']); + expect(manifest.contract.output.selective).toEqual([]); + }); + + it('判据全读基座数 —— 任何一边都不许是活动自己写的汇总列', () => { + const machine = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L1' || c.level === 'L2'); + expect(machine.length).toBeGreaterThanOrEqual(5); + for (const c of machine) { + expect(c.predicate).toBeDefined(); + /* 基座读数的形状:域_对象_rows / _missing / _distinct_列 / tally 计数 */ + expect(c.predicate).toMatch(/_(rows|missing|distinct_[a-z_]+|[a-z_]+)\b/); + } + }); + + it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => { + expect(manifest.contract.resources.budget.humanCalls).toBe(8); + expect(manifest.contract.authorization.humanCallBudget).toBe(8); + expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run activities/part-substitution-assessment > /tmp/red2.txt 2>&1; echo "EXIT=$?"; cat /tmp/red2.txt` +Expected: FAIL(声明还是旧的七张主数据 + 三张单据)。**整份贴进报告。** + +- [ ] **Step 3: 重写 `declarations.ledger`** + +照 spec §3 逐张写。`col()` 帮手在文件里已有,沿用。**九张**(spec §3 说的「六张 + P-06」是按业务对象数,声明上是九条): + +`parts.chip_type` · `parts.type_param` · `parts.profile` · `parts.parameter` · `sub_eval.part` · `sub_eval.case` · `substitution.relation` · `substitution.difference_item` · `validation.record` + +逐张的列、档位、`tally`、`refersTo`、`requiredWhen` **以 spec §3.1–§3.4 为准**(那几节把每一列都列全了),并把上面测试里断言到的每一条都满足。三处要写注释说清「为什么」: + +```ts + /* + * **约束类型不在这里** —— 它长在 `parts.type_param`(按类型标一次)。 + * 从前每颗料各标一次,同一个参数在两颗料上标法不一致就成了可能; + * 而比对是逐参数对着比的,两边约束档不同,判出来的四态就没有意义。 + */ +``` + +```ts + /* + * **`distinct`**:「两颗料是不是同一个类型」从此是基座数得出来的数 + * (`sub_eval_part_distinct_chip_type_id`)—— 不同取值恰好 1 种才算一致。 + * 这一条是 S1 收步与 S2 入场的硬门:两边字段清单不是同一套,逐项比对无从谈起。 + */ +``` + +```ts + /* + * **`judgement_class` 是合成列**:推荐等级要数的是「**硬约束**不符有几条」, + * 而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。 + * 由配方按 §3.5 的 when 链算,不是 AI 出的。 + */ +``` + +- [ ] **Step 4: 重写 `contract`** + +- `input.data`:两个属性 `originalInternalPn` / `candidateInternalPn`,`required: []`,各带 `x-control: 'combo'`、`x-options: { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }`、`x-order` 1/2、以及一句 `description`(照 spec §4.1 的口径:库里有就选,没有就自己写,启动后由第一步派工单补)。**`datasheets` 与客户相关的三个键全部删掉。** +- `output.mandatory`:一口 `assessment-released`,载荷照 spec §4.5(`evalRef` / `relationId` / `originalPn` / `candidatePn` / `recommendationLevel` / 四态计数 / `validationId`)。 +- `requiredDeliverables`:两件 —— 差异评估结论(`substitution.difference_item` + `substitution.relation`)、落账内容(`substitution.relation` + `validation.record`)。 +- `acceptanceCriteria`:照 spec §7 的表写九条(五条机检 L1/L2、两条 L4 裁量、一条 L5 责任关)。谓词逐条用基座读数;`level-by-rule` 那条把 §3.5 的规则写成 S-FEEL 的析取式。 +- `prohibitions`:保留「UNKNOWN 是一等公民」「硬约束 MISMATCH 不得被措辞改判」「不写客户批准状态」「只答技术可替代性」四条;删掉与 N 候选、外部系统相关的两条。 +- `resources`:`tools: []`、`models: ['deepseek-v4-flash']`、`roles: ['FAE/RI']`、`budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 8 }`;`authorization.humanCallBudget: 8`。 + +`constants.ts` 里 `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量删掉(它们的注册退役在 Task 10)。 + +- [ ] **Step 5: 升版本号** + +`identity.packageVersion` 从 `1.7.3` 升到 **`2.0.0`** —— 这是重写,不是改。**同版重装是 no-op(静默丢失,踩过)。** + +- [ ] **Step 6: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green2.txt 2>&1; echo "EXIT=$?"; cat /tmp/green2.txt +pnpm gates +``` +Expected: 新写的那两组全绿;`pnpm gates` 退出码 **0**。旧剧本引用已删台账导致的断言若被临时注掉,**在报告里逐条点名**。 + +- [ ] **Step 7: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): 清单重写 —— 九张台账、两个启动参数、零工具 + +台账:两张类型字典 + P-08 主/参数(撤 createOnly、封装工艺归字典)+ 案卷两张 + P-11 主/明细 + P-06。 +契约:启动只剩原 PN 与候选 PN 两格且都选填(缺了由第一步派工单补);tools 清空 —— 这是立场不是疏漏。 +判据全读基座数;明细加合成列 judgement_class,因为「硬约束不符有几条」不是单列 tally 数得出来的。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 3: 案例模型骨架 —— 四步、四道哨兵、零 guide + +**Files:** +- Modify: `activities/part-substitution-assessment/src/case-model.ts`(整体重写) +- Test: `activities/part-substitution-assessment/__tests__/steps.test.ts`(整体重写) + +**Interfaces:** +- Consumes: Task 2 的九张台账声明 +- Produces: `CMMN_XML`(四个计划项 + 三道入场哨兵)· `NARRATIVE`(瘦身)· `ASSESSMENT_STEPS`(四条剧本,`guide: ''`,`auto` 先各放一笔 `say`)· `ASSESSMENT_PHASES`(三格) + +> **这一步之后包是完整可装的**:四步俱在、判据挂上、`guide` 全空(靠 R8 那条 schema 放宽)。 +> 配方的实体在 Task 4–6 逐步填进去;`view` 先不写(它是可选的),Task 7–9 各自补上自己那一张。 + +- [ ] **Step 1: 写失败的测试** + +`__tests__/steps.test.ts` 整体换成: + +```ts +import { describe, expect, it } from 'vitest'; +import { planStepSchema } from '@contracts'; +import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts'; + +const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release']; +const stepOf = (item: string) => ASSESSMENT_STEPS.find((s) => s.item === item); + +describe('四步案例模型', () => { + it('恰好四步,次序即业务次序', () => { + expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS); + }); + + it('每一步都过得了 PlanStep 契约', () => { + for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true); + }); + + it('**全程零 guide、零工具** —— 这是这次重写的立场', () => { + for (const step of ASSESSMENT_STEPS) { + expect(step.guide).toBe(''); + expect(step.tools).toEqual([]); + expect(step.auto).toBeDefined(); + } + }); + + it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => { + for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`); + /* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */ + for (const item of ['S3_review', 'T_release']) { + expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true); + } + }); + + it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => { + expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy(); + expect(stepOf('S2_assess')?.doneWhen).toBeTruthy(); + expect(stepOf('S3_review')?.doneWhen).toBeUndefined(); + expect(stepOf('T_release')?.doneWhen).toBeUndefined(); + }); + + it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => { + /* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */ + for (const step of ASSESSMENT_STEPS) { + expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows'); + } + }); + + it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => { + const done = stepOf('S1_inputs')?.doneWhen ?? ''; + expect(done).toContain('sub_eval_part_exists_status_found = 2'); + expect(done).toContain('sub_eval_part_missing = 0'); + expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1'); + expect(done).toContain('parts_profile_missing = 0'); + expect(done).toContain('parts_parameter_missing = 0'); + }); + + it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => { + const done = stepOf('S2_assess')?.doneWhen ?? ''; + expect(done).toContain('substitution_difference_item_rows > 0'); + expect(done).toContain('substitution_difference_item_missing = 0'); + expect(done).toContain('substitution_relation_rows = 1'); + expect(done).toContain('validation_record_rows = 1'); + }); + + it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => { + for (const item of ['S3_review', 'T_release']) { + const list = stepOf(item)?.acceptanceChecklist ?? []; + expect(list.length).toBeGreaterThanOrEqual(4); + for (const line of list) expect(line.length).toBeGreaterThan(6); + } + expect(stepOf('S3_review')?.cardVariant).toBe('review'); + }); + + it('阶段:除放行外每一项恰好属于一个阶段', () => { + const covered = ASSESSMENT_PHASES.flatMap((p) => p.items); + expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort()); + expect(new Set(covered).size).toBe(covered.length); + }); + + it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => { + expect(NARRATIVE.length).toBeLessThan(2000); + /* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */ + for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) { + expect(NARRATIVE).not.toContain(forbidden); + } + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts > /tmp/red3.txt 2>&1; echo "EXIT=$?"; cat /tmp/red3.txt` +Expected: FAIL(现在是九步)。**整份贴进报告。** + +- [ ] **Step 3: 写 CMMN** + +```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 + + + + + + + + + + + + + + + + + + + + +`; +``` + +> **`manualActivation` 的位置照本仓 `cmmn-parse.ts` 认的那一处写。** 动手前先 +> `grep -n "manualActivation" apps/api/src/modules/loop-engine/internal/domain/cmmn-parse.ts` +> 确认它是从 `planItem` 还是从 `humanTask` 上读的,**用解析器真认的那一处**;上面的用例断言的是 +> 「id 与 manualActivation 出现在同一个标签里」,按解析器的口径摆好即可。 + +- [ ] **Step 4: 写说明书(瘦身)** + +`NARRATIVE` 只讲**整单的事**,不讲逐步做法(逐步做法在配方里,而配方不是给模型看的)。2000 字以内,写清:这个活动答什么问题(候选料在技术上能不能替代原料)· 四步各自的产出 · 三条不许(不伪造参数值、硬约束不符不得被措辞改判、不写客户批准状态)。 + +**示例一律用占位形(`<料号>` / `<参数名>`),不许出现真实料号与参数名** —— 关思考的模型会把示例当成这一单的值,本仓 2026-08-27 复现过三次。 + +- [ ] **Step 5: 写四条剧本(配方先各放一笔 `say`)** + +```ts +export const ASSESSMENT_STEPS = [ + { + item: 'S1_inputs', + intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补', + guide: '', + 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: { do: [{ kind: 'say', text: '正在核对两颗料的在库状态与参数齐备情况。' }] }, + }, + { + item: 'S2_assess', + 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: { do: [{ kind: 'say', text: '正在逐条比对两颗料的核心参数。' }] }, + }, + { + item: 'S3_review', + intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止', + guide: '', + writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], + reads: ['substitution.difference_item', 'substitution.relation'], + tools: [], + cardVariant: 'review', + /* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */ + acceptanceChecklist: [ + '逐条对过原料与候选料的参数值,没有把需求边界写成候选料的问题', + '硬约束那几条逐条看过,没有一条是「没证据也判了 MATCH」', + '判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿', + '影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值', + ], + auto: { do: [{ kind: 'say', text: '差异结论已备好,请过目。' }] }, + }, + { + item: 'T_release', + intent: '把这一签下去会落哪几张表、几行摆清楚,人签字后落账并发口', + guide: '', + writes: ['sub_eval.case'], + reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'], + tools: [], + acceptanceChecklist: [ + '推荐等级与四态分布对得上:有硬约束不符的没给「推荐」', + '待验项清单认可:该实测的都列出来了,没有该验而漏掉的', + '落账内容核对过:进哪几张表、各几行,与屏上摆的一致', + '参数值来源可追:每一条都能说出它是库里既有的还是这一单补录的', + '签下去就发给下游,这一步之后本活动不再改动这些行', + ], + auto: { do: [{ kind: 'say', text: '拟落账内容已备好,请核对后签字。' }] }, + }, +] as const; + +export const ASSESSMENT_PHASES = [ + { name: '校验入参', items: ['S1_inputs'] }, + { name: '差异分析', items: ['S2_assess'] }, + { name: '结论审核', items: ['S3_review'] }, +] as const; +``` + +> **阶段里没有 `T_release`** —— 放行那一格由基座统一加(与 E04 / I01 同款)。 +> 用例钉的正是「除放行外每一项恰好属于一个阶段」:漏一格,人手上办的那件事在进度条上就没有位置。 + +- [ ] **Step 6: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green3.txt 2>&1; echo "EXIT=$?"; cat /tmp/green3.txt +pnpm gates +``` +Expected: 全绿(整份贴进报告);`pnpm gates` 退出码 **0**。 + +- [ ] **Step 7: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): 案例模型换成四步 —— 零 guide、零工具、零记账旗 + +九步砍成四步:校验入参 / 差异分析 / 结论审核 / 落账放行。 +三道入场哨兵全读基座数,caseFileModel 里一面记账旗都没有 —— +旗是活动自己写的数,而「机检只读基座数」这条在本仓被违反过六次。 +审核与放行触达的是人:标人工激活、不给 doneWhen。 +说明书瘦成「整单的事」,示例一律占位形 —— 模型会把示例当成这一单的值。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 4: S1 配方 —— 校验入参与补数据 + +**Files:** +- Modify: `activities/part-substitution-assessment/src/case-model.ts`(`S1_inputs.auto`) +- Modify: `activities/part-substitution-assessment/src/manifest.ts`(`sub_eval.part` 加一列 `found_pn_ref`) +- Test: `activities/part-substitution-assessment/__tests__/steps.test.ts` + +**Interfaces:** +- Consumes: Task 3 的剧本骨架;`AutoRecipe` 契约(五动词 / 八式表达式 / 四种地址) +- Produces: S1 的六笔配方;`sub_eval.part.found_pn_ref`(在库核对依据,`refersTo parts.profile.internal_pn`) + +> **动手前先读《活动开发规范》§4.6** —— 它是配方怎么写的权威,含四条纪律。 + +- [ ] **Step 1: 先给 `sub_eval.part` 补一列** + +`exists_status` 不能凭空判 —— **判「在库」就必须拿得出一个基座查得到的料号**。在 `manifest.ts` 的 `sub_eval.part` 声明里,`role` 之后加: + +```ts + /* + * **在库核对依据**:判 `found` 就得把在主数据里真找到的那个料号填在这里, + * 基座拿它去 `parts.profile` 查(`refersTo`),查无此值当场拒。 + * 这一列的存在是为了让「在不在库」成为一次 `lookup` 的结果,而不是一句断言 —— + * 2026-08-27 实测过:原 PN 根本不在库,而模型把两颗都写成 found、交代里还说「都在库」。 + */ + col({ + name: 'found_pn_ref', + type: 'text', + label: '在库核对依据', + refersTo: 'parts.profile.internal_pn', + requiredWhen: { column: 'exists_status', value: 'found' }, + }), +``` + +并在 Task 2 写的那条「案卷两张」用例里补一行断言: + +```ts + expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn'); +``` + +- [ ] **Step 2: 写失败的测试** + +追加到 `steps.test.ts`: + +```ts +describe('S1 配方', () => { + const recipe = stepOf('S1_inputs')?.auto; + const acts = recipe?.do ?? []; + const kinds = acts.map((a) => a.kind); + + it('六笔,次序是:两颗料 → 案卷 → 主数据骨架 → 参数骨架 → 卡 → 交代', () => { + expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']); + expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([ + 'sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter', + ]); + }); + + it('案号由 let 铸,只从本单开案时刻取材', () => { + expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] }); + }); + + it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => { + const part = acts[0]; + expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2); + /* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */ + const fields = part?.kind === 'project' ? part.fields : {}; + expect(fields['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' }); + expect(fields['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' }); + }); + + it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { + const params = acts[3]; + expect(params?.kind === 'project' && params.prune).toBe(true); + const from = params?.kind === 'project' ? params.from : undefined; + /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ + expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } }); + const fields = params?.kind === 'project' ? params.fields : {}; + expect(fields['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' }); + }); + + it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => { + const card = acts[4]; + const when = card?.kind === 'card' ? (card.when ?? '') : ''; + /* + * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、 + * 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。 + */ + expect(when).not.toContain('gate_reason'); + expect(when).toContain('sub_eval_part_exists_status_found < 2'); + expect(when).toContain('parts_parameter_missing > 0'); + }); + + it('卡:数据工单、四张表都在闸上、且有 basis(基座拒发 basis 为空的工单)', () => { + const card = acts[4]; + expect(card?.kind === 'card' && card.as).toBe('execution'); + expect(card?.kind === 'card' && card.variant).toBe('data'); + expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']); + expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); + expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); + }); + + it('发卡条件与 doneWhen 是同一件事的正反面', () => { + const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : ''; + for (const reading of [ + 'sub_eval_part_exists_status_found', 'sub_eval_part_missing', + 'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing', + ]) { + expect(when).toContain(reading); + expect(stepOf('S1_inputs')?.doneWhen).toContain(reading); + } + }); +}); +``` + +- [ ] **Step 3: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S1 配方" > /tmp/red4.txt 2>&1; echo "EXIT=$?"; cat /tmp/red4.txt` +Expected: FAIL(现在只有一笔 `say`)。**整份贴进报告。** + +- [ ] **Step 4: 写配方** + +把 `S1_inputs.auto` 换成: + +```ts + auto: { + /** 整单只有这一个案号,从本单开案时刻取材 —— **不许从正式表里别单的号里挑**(踩过) */ + let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } }, + do: [ + /* + * ① 本单两颗料 —— **后面每一笔遍历的唯一来源**。 + * 挤进案卷一行里就没法 `where` 过滤,而不能过滤就会给已经在库的那颗也铺一遍空参数行。 + */ + { + kind: 'project', + 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: '' }, + }, + }, + /* ② 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤) */ + { + kind: 'project', + into: 'sub_eval.case', + from: { once: true }, + 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: '', + }, + }, + }, + }, + }, + }, + }, + /* + * ③ 主数据骨架:**两颗料都铺、值回填现值**。 + * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 + * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 + * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 + */ + { + kind: 'project', + 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` 不会误报; + * · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。 + */ + { + kind: 'project', + 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` 为空串时那一列压根不写、于是未绑定, + * 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。 + */ + { + kind: 'card', + 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', + variant: 'data', + title: '数据工单 · 差异评估的输入还差几格', + basis: [ + { k: '原料号', v: { in: 'originalInternalPn' } }, + { k: '候选料号', v: { in: 'candidateInternalPn' } }, + { k: '还差什么', v: { in: 'gate_reason' } }, + ], + guide: [ + '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。', + '类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。', + '说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。', + '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。', + '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。', + ], + standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', + gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], + }, + { kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' }, + ], + }, +``` + +- [ ] **Step 5: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green4.txt 2>&1; echo "EXIT=$?"; cat /tmp/green4.txt +pnpm gates +``` +Expected: 全绿(整份贴进报告);`pnpm gates` 退出码 **0**。 + +- [ ] **Step 6: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): S1 配方 —— 校验入参、铺格、缺什么派一张数据工单 + +在库与否是一次 lookup 的结果,不是一句断言:先取 found_pn_ref(refersTo 查无此值当场拒)再按它判。 +参数骨架两颗料都铺、值回填现值 —— 于是「在库但当年参数没录全」也自动被红带拦住。 +发卡的条件只用基座读数:gate_reason 为空串时那一列不写、未绑定,而 S-FEEL 里未绑定的名字 +`!= ""` 求值为 true —— 拿它当条件恰恰会在一切正常时把卡发出去。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 5: S2 配方 —— AI 唯一一次出场,以及那套规则 + +**Files:** +- Modify: `activities/part-substitution-assessment/src/case-model.ts`(`S2_assess.auto`) +- Test: `activities/part-substitution-assessment/__tests__/steps.test.ts` + +**Interfaces:** +- Consumes: S1 落下的 `sub_eval.part` / `sub_eval.case` / `parts.parameter`;字典 `parts.type_param` +- Produces: S2 的五笔配方 —— `analyze`(整个活动唯一一次模型调用)+ 三笔 `project` + 一笔 `say` + +> **这一步是整套设计的要害。** 三处刻意的克制,评审会逐条核: +> ① **行由字典决定,不由 AI 决定** —— 明细按 `parts.type_param` `each` 出来,AI 的出参只是拿 `param_name` +> 去 `lookup` 的一张查表;它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。 +> 写成 `from each(AI 出参)` 的话,它少给两条就少两行,**而每一行看着都合规**。 +> ② **值是多少一个字都不重述** —— 基座读得到的事实不让模型转述(本仓踩过:模型念出的「输入上限 32V」 +> 是示例里的数,不是这一单的)。 +> ③ **推荐等级、风险、摘要、推荐理由全由规则与模板拼** —— 于是永远不会出现「有硬约束不符却写着推荐」。 + +- [ ] **Step 1: 写失败的测试** + +追加到 `steps.test.ts`: + +```ts +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' }); + } + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S2 配方" > /tmp/red5.txt 2>&1; echo "EXIT=$?"; cat /tmp/red5.txt` +Expected: FAIL。**整份贴进报告。** + +- [ ] **Step 3: 写配方** + +把 `S2_assess.auto` 换成下面这份。**`let` 里为什么不用 `{in:'chip_type_id'}`**:`chip_type_id` 这个名字在 +`parts.profile` / `sub_eval.part` / `sub_eval.case` 三张表上都有,而基座把「各对象最新一行」的字段名铺到顶层、 +**同名以后写的赢** —— 拿顶层那个名字取值等于赌哪张表最后写。改成对准一张表 `lookup`,不歧义。 + +```ts + auto: { + 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', + 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', + 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', + into: 'substitution.relation', + from: { once: true }, + 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', + into: 'validation.record', + from: { once: true }, + 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', text: '逐条差异结论已备好,推荐等级由规则按四态算出。' }, + ], + }, +``` + +`RULE_VERSION` 从 `constants.ts` 引(它已存在;若被 Task 2 删掉了就补回来)。 + +- [ ] **Step 4: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green5.txt 2>&1; echo "EXIT=$?"; cat /tmp/green5.txt +pnpm gates +``` +Expected: 全绿(整份贴进报告);`pnpm gates` 退出码 **0**。 + +- [ ] **Step 5: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): S2 配方 —— AI 唯一一次出场,行由字典决定不由它决定 + +明细按类型字典 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 +EOF +)" +``` + +--- + +### Task 6: S3 与 S4 配方 —— 审核卡与落账放行 + +**Files:** +- Modify: `activities/part-substitution-assessment/src/case-model.ts`(抽出两份共享投影;`S3_review.auto` / `T_release.auto`) +- Test: `activities/part-substitution-assessment/__tests__/steps.test.ts` + +**Interfaces:** +- Consumes: Task 5 的关系与验证记录投影 +- Produces: `RELATION_PROJECTION` / `VALIDATION_PROJECTION` 两个模块级常量(S2 与 S3 共用);S3 的四笔;S4 的两笔 + +> **为什么 S3 要重跑那两笔投影**:人在审核件上改了某一条四态,推荐等级、风险、摘要、验证要求都得跟着变。 +> 「让人直接改摘要」等于让他给自己没做的判断盖章 —— 所以摘要只读,改的是四态,系统重算。 +> **投影必须是同一份**,各写一份早晚分叉:抽成模块级常量,两处引用。 +> +> **次序:投影在前、卡在后。** 人还在填(quiet)时卡被跳过、投影照跑;人交回后(carded)卡这一笔 +> 只跳过自己、投影仍跑 —— 「人的答案已经在草稿里」的那一刻,重算才发生。 + +- [ ] **Step 1: 写失败的测试** + +追加到 `steps.test.ts`: + +```ts +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(); + }); +}); +``` + +- [ ] **Step 2: 跑一遍,确认它失败** + +Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S3 / S4 配方" > /tmp/red6.txt 2>&1; echo "EXIT=$?"; cat /tmp/red6.txt` +Expected: FAIL。**整份贴进报告。** + +- [ ] **Step 3: 把两笔投影抽成模块级常量** + +在 `ASSESSMENT_STEPS` **之前**加(内容原样取自 Task 5 写的 S2 第③④笔),然后 S2 与 S3 都引用它们: + +这是一次**移动**,不是重写:上一个任务已经把这两个对象字面量写在 `case-model.ts` 里了。 + +打开 `case-model.ts`,找到 `S2_assess.auto.do` 数组里的**第 3 笔**(`into: 'substitution.relation'` 那个对象字面量) +与**第 4 笔**(`into: 'validation.record'` 那个),**整体剪切**到 `ASSESSMENT_STEPS` 之前,各起一个名字: + +```ts +/* + * **关系与验证记录的投影,S2 与 S3 共用同一份**。 + * S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 —— + * 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。 + */ +const RELATION_PROJECTION = { /* ← 从 S2 第 3 笔整体剪切过来,一个字不改 */ } as const; +const VALIDATION_PROJECTION = { /* ← 从 S2 第 4 笔整体剪切过来,一个字不改 */ } as const; +``` + +再把 S2 原来那两个位置换成对常量的引用。**剪切前后 `pnpm exec vitest run activities/part-substitution-assessment` +必须仍然全绿** —— Task 5 写的那几条用例断言的就是这两笔的内容,它们此刻正是这次移动的安全网。 +用例里 `expect(s3[0]).toBe(s2[2])` 用的是 `toBe`(同一个对象引用),**照抄一份不算数**。 + +- [ ] **Step 4: 写 S3 与 S4 的配方** + +```ts + /* S3_review.auto */ + auto: { + do: [ + /* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */ + RELATION_PROJECTION, + VALIDATION_PROJECTION, + { + kind: 'card', + as: 'decide', + variant: 'review', + 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', text: '差异结论已送审,请在件上逐条核对。' }, + ], + }, +``` + +```ts + /* T_release.auto */ + auto: { + do: [ + { + kind: 'project', + into: 'sub_eval.case', + from: { once: true }, + 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', + rationale: { + cat: [ + '逐项差异评估已经审核通过:', + { in: 'substitution_difference_item_rows' }, ' 条明细、1 条替代关系、1 条验证记录待落账。', + ], + }, + }, + ], + }, +``` + +- [ ] **Step 5: 跑测试与全量门禁** + +Run: +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green6.txt 2>&1; echo "EXIT=$?"; cat /tmp/green6.txt +pnpm gates +``` +Expected: 全绿(整份贴进报告);`pnpm gates` 退出码 **0**。 + +- [ ] **Step 6: 提交** + +```bash +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): S3 审核卡与 S4 落账放行 —— 摘要只读,改的是四态 + +关系与验证记录的投影抽成模块级常量,S2 与 S3 共用同一份: +人改了某一条四态,等级 / 风险 / 摘要 / 验证要求跟着重算。 +各写一份早晚分叉,而分叉那一刻屏上会同时摆着两个互相矛盾的等级。 +次序是投影在前、卡在后:人交回后卡这一笔只跳过自己,投影仍跑 —— 重算才发生在该发生的那一刻。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 7: 件① `input-supplement` —— 评估输入补全(数据工单) + +**Files:** +- Modify: `activities/part-substitution-assessment/src/views/report.ts`(新增 `INPUT_SUPPLEMENT`,删旧的五张) +- Modify: `activities/part-substitution-assessment/src/views/target-style.ts`(留 `P08_CSS`,删无主的) +- Modify: `activities/part-substitution-assessment/src/manifest.ts`(`viewTemplates` 加它)+ `case-model.ts`(`S1_inputs.view`) +- Test: `activities/part-substitution-assessment/__tests__/activity.test.ts` + +**Interfaces:** +- Consumes: Task 4 的 S1 配方(它铺的行就是这张件上要填的行) +- Produces: 视图模版 `input-supplement` + +**这张件三块竖排(spec §6.1),绑定契约如下** —— 模版只取值、不判断: + +| 块 | 绑定 | 说明 | +|---|---|---| +| ① 要评估什么 | `` | 两行(行由系统铺,**人不加行**)。每行摆 `internal_pn`(主键,**必挂 `data-col`** —— 它不是编辑面,是行的钥匙)、在库状态徽(`data-class-of="exists_status"`)、以及可改的主数据格 | +| ② 芯片类型 | ``)· 影响说明 · 判断依据 · UNKNOWN 去向 可改** | +| 收口四格 | 推荐理由 / 差异摘要 / 风险 / 验证要求,各 `data-slot` | **只读** | + +- [ ] **Step 1: 写失败的测试** + +(`viewOf` 与 `ASSESSMENT_STEPS` 的引入在 Task 7 已经加进这个文件了 —— 若不在,照 Task 7 Step 1 的头部补上。) + +```ts +describe('件② 差异评估结论审核', () => { + const html = viewOf('difference-review'); + + it('在 viewTemplates 里,S3 指向它,面孔是审核卡', () => { + expect(html).not.toBe(''); + expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_review')?.view).toBe('difference-review'); + expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_review')?.cardVariant).toBe('review'); + }); + + it('逐条明细可改,且**改的是四态**', () => { + expect(html).toContain('data-rows="mine:substitution.difference_item"'); + expect(html).toContain('data-edit="substitution.difference_item"'); + expect(html).toContain('data-col="difference_status"'); + for (const col of ['impact_statement', 'evidence_note', 'unknown_disposition']) { + expect(html).toContain(`data-col="${col}"`); + } + expect(html).toContain('data-col="difference_item_id"'); + }); + + it('**推荐理由 / 摘要 / 风险 / 验证要求只读** —— 让人直接改摘要,等于让他给自己没做的判断盖章', () => { + for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) { + expect(html).toContain(`data-slot="${col}"`); + expect(html).not.toContain(`data-col="${col}"`); + } + expect(html).not.toContain('data-edit="substitution.relation"'); + }); + + it('等级与四态用 data-class-of 上色 —— 判断留在样式表,模版不判', () => { + expect(html).toContain('data-class-of="recommendation_level"'); + expect(html).not.toContain(' /tmp/red8.txt 2>&1; echo "EXIT=$?"; cat /tmp/red8.txt` +Expected: FAIL。**整份贴进报告。** + +- [ ] **Step 3: 写模版** + +`doc()` + `ACC1_CSS`(验收件那一套)。题头主题色用对话区验收卡同一枚 `#0aa294`(2026-08-27 用户定:验收工单的题头是验收绿,不是红)。 + +**这张件的收口四格必须只读**:它们是系统按 §3.5 规则算出来的,人要改就去改上面那一条四态,系统重算(S3 配方里那两笔投影就是干这个的)。 + +- [ ] **Step 4: 跑测试与全量门禁 → 提交** + +```bash +pnpm exec vitest run activities/part-substitution-assessment > /tmp/green8.txt 2>&1; echo "EXIT=$?"; cat /tmp/green8.txt +pnpm gates +git add activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读 + +推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们, +等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 9: 件③ `landing-confirm` + 运营中心看板重绑 + +**Files:** +- Modify: `.../src/views/report.ts`(新增 `LANDING_CONFIRM`) +- Modify: `.../src/views/ledger-board.ts`(`LEDGER_BOARD` 与 `LEDGER_BOARD_DATA` 重绑到新表) +- Modify: `.../src/manifest.ts` + `case-model.ts`(`T_release.view`) +- Test: `.../__tests__/activity.test.ts` + +**Interfaces:** +- Produces: 视图模版 `landing-confirm`;看板 `activity-ledger` 的 `data` 取数声明改绑新表 + +**件③ 全只读,答「这一签下去发生什么」(spec §6.3)**:进哪几张表各几行(摆**人读名**)· 结论卡(等级 + 一句话)· 四态分布并把硬约束不符的**逐条点名** · 待验项(P-06 的测试条件与后续动作)· 发哪一口。 + +- [ ] **Step 1: 写失败的测试** + +(同上,`viewOf` 复用 Task 7 在这个文件里定义的那个。) + +```ts +describe('件③ 落账确认', () => { + const html = viewOf('landing-confirm'); + + it('在 viewTemplates 里,T_release 指向它', () => { + expect(html).not.toBe(''); + expect(ASSESSMENT_STEPS.find((s) => s.item === 'T_release')?.view).toBe('landing-confirm'); + }); + + it('**全只读** —— 签之前只核对,不改', () => { + expect(html).not.toContain('data-edit='); + expect(html).not.toContain('data-col='); + expect(html).not.toContain(' { + for (const reading of ['substitution_relation_rows', 'substitution_difference_item_rows', 'validation_record_rows']) { + expect(html).toContain(`data-slot="${reading}"`); + } + }); + + it('硬约束不符的逐条点名 —— 签字的人最该看见的就是它', () => { + expect(html).toContain('data-rows="mine:substitution.difference_item"'); + /* + * **筛选交给样式表,不是 `data-by`** —— `data-by` 过滤的是「子列 = 外层那一行的父列」, + * 这里没有外层行可依。做法:整表逐行渲出来,行上挂 `data-class-of="judgement_class"`, + * 由 CSS 只显 `.v-hard_mismatch` 那几行(判断留在样式表,模版不判)。 + */ + expect(html).toContain('data-class-of="judgement_class"'); + }); +}); + +describe('运营中心看板', () => { + const board = manifest.assets.viewTemplates.find((v) => v.name === 'activity-ledger'); + + it('取数声明改绑新表,不再引已退役的那几张', () => { + const json = JSON.stringify(board?.data ?? {}); + for (const gone of ['demand.request', 'parts.document', 'sub_eval.pn_check', 'sub_eval.input_gap', 'sub_eval.verdict']) { + expect(json).not.toContain(gone); + } + for (const kept of ['sub_eval.case', 'substitution.relation', 'substitution.difference_item']) { + expect(json).toContain(kept); + } + }); +}); +``` + +- [ ] **Step 2–4: 跑红 → 写模版与看板取数声明 → 跑绿 + 门禁 → 提交** + +Run 红:`pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件③" > /tmp/red9.txt 2>&1; echo "EXIT=$?"; cat /tmp/red9.txt` + +看板的 `data` 声明形状见《HTML报告生成规范》§4.1(`case{ledger,key,prefix}` + `rows[{as,ledger,by,not,orderBy}]`);案号前缀改成 `W05-`,行圈到:案卷 → 两颗料 → 类型与字段 → 参数对照 → 明细 → 关系 → P-06。 + +绿 + 门禁 + 提交(信息:`feat(w05): 件③ 落账确认与看板重绑 —— 签之前只核对,硬约束不符逐条点名`,附两行 trailer)。 + +--- + +### Task 10: 退役 —— 两枚工具、两张判定表、技能戳 + +**Files:** +- Modify: `apps/api/src/app/tools/first-party.ts`(两枚工具停用 + 墓志铭) +- Modify: `apps/api/src/app/__tests__/first-party-substitution.test.ts` +- Delete: `activities/part-substitution-assessment/src/skills.ts` +- Modify: `.../src/manifest.ts`(`assets.skills` 清空、`assets.businessModel.dmn` 去掉 D01/D02) +- Modify: `.../src/constants.ts` + +**Interfaces:** +- Consumes: 前九个任务(活动已不再声明这两枚工具) +- Produces: 工具与判定表的退役 + 墓志铭 + +> **为什么现在才退**:前面每一步都要门禁绿,而工具的既有测试挂在注册上。等活动侧完全不引用了再拆,拆的是死代码而不是活的。 + +- [ ] **Step 1: 先确认没有别的消费方** + +Run: +```bash +grep -rn "compare-parameters\|recommend" activities apps/api/src --include='*.ts' | grep -v __tests__ | grep -v first-party +``` +Expected: **零命中**(除 `first-party.ts` 自身)。有命中就先处理它,别硬拆。 + +- [ ] **Step 2: 改测试(先描述目标)** + +把 `first-party-substitution.test.ts` 里断言这两枚工具**可用**的用例,改成断言它们**已停用且留了墓志铭**: + +```ts +it('两枚工具已退役 —— W05 重写之后没有消费方,规则活在配方里', () => { + expect(registry.find((t) => t.name.includes('compare-parameters'))?.enabled).toBe(false); + expect(registry.find((t) => t.name.includes('recommend'))?.enabled).toBe(false); +}); +``` + +(`registry` 用该文件里既有的取注册表的方式;名字与形状**照文件里真有的**写。) + +- [ ] **Step 3: 停用并留墓志铭** + +在 `first-party.ts` 里把两枚工具的注册标成停用,并在原处留一段中文墓志铭,写清:**它们算的四态与推荐等级现在活在 +`activities/part-substitution-assessment` 的 S2 配方里**(§3.5 的规则表),规则只许有一处真源; +`D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 就是同一件事两处各存一份。 + +删掉 `skills.ts`(全程无 agent,技能戳没有消费方),`manifest.assets.skills` 置空,`contract.resources.skills` 置空。 + +- [ ] **Step 4: 跑全量门禁 → 提交** + +```bash +pnpm gates +git add apps/api/src/app/tools activities/part-substitution-assessment apps/api/src/app/__tests__ +git commit -m "$(cat <<'EOF' +chore(w05): 两枚工具与两张判定表退役,技能戳删除 —— 规则只许有一处真源 + +四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。 +再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。 +技能戳是给模型看的,而这个活动全程没有 agent。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 11: 装包、落库、五案例 e2e + +**Files:** +- Modify: `tools/e2e-a2.mjs`(`SUITES.w05`,约 550 行起) +- Modify: `activities/part-substitution-assessment/src/manifest.ts`(`samples` 五条) + +**Interfaces:** +- Consumes: 前十个任务(包已完整) +- Produces: 五条样例 + e2e `w05` 套件;真库上跑通 + +> ## ⚠️ 这一步会动**共享库**,开工前必须先过一道人 +> +> 开发实例(:8787)与稳定版实例(:8790)**共用 `jcai_wos` 库**,而稳定版跑的是**分支之前的代码**。 +> 装 W05@2.0.0 会把新清单写进 `sys.activity_package`,**两个实例都读它** —— 而旧代码的 +> `planStepSchema` 要求 `guide` 非空(R8 那条放宽只在本分支上),它**解析不了**这份新清单。 +> +> **所以:控制端必须先与用户确认怎么处理**,三条路自选,别自己拍: +> ① 走查期间先把稳定版停掉(`lsof -iTCP:8790` 找到进程,由**用户**决定停不停); +> ② 给开发实例另开一个库跑(改 `DATABASE_URL`,代价是种子要重播); +> ③ 明知有风险照装(稳定版在此期间用不了 W05)。 +> +> **未经确认不要跑 `pnpm db:migrate` / `db:seed` / `activities:install`。** + +- [ ] **Step 1: 写五条样例** + +`manifest.samples` 五条,名 **≤14 字**(超过 14 字运营端只显示「样例 N」,等于没名字),**第一条是最长的那条路**(测试台落地即预填的是它): + +```ts + samples: [ + { name: '候选缺库·新建类型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } }, + { name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, + { name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } }, + { name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } }, + { name: '两颗都缺库', input: { originalInternalPn: 'CN-DCDC-32V', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } }, + ], +``` + +包内自测补一条:样例名都 ≤14 字、第一条是缺库那条。 + +- [ ] **Step 2: 重写 e2e 的 `w05` 套件** + +`tools/e2e-a2.mjs` 里 `SUITES.w05` 整体换掉。照该文件既有套件的形状写(先读 `e04` / `i01` 两个套件,**用文件里真有的键名**),要覆盖: + +- **零卡直分析那条**:断言**一张数据工单都没发**(第二条样例),直接走到审核卡; +- **缺库那几条**:数据工单派出后,件上按地址保存(`buildEdits` 按 `data-edit` 地址 + `data-col` 列名), + **空手交卷必须吃 400**(提交闸真拦),补齐后才过; +- **类型不一致那条**:断言 `gate_reason` 里点了名、且补不成同类型时提交仍被拦(这条是**硬拦**,不许绕过去); +- **审核卡**:单选项「确认通过」+ **人工验收清单要勾全才能通过**(`checked ⊇ checklist`,不勾全应当被拒); +- **放行**:终局验收卡 → `released` → 发口 `assessment-released`; +- **模型调用恰一次**:从任务留痕里数 `analyze` 那一笔(全套只该有一次)。 + +- [ ] **Step 3: 装包并核库** + +**在 Step 0 的确认拿到之后**: + +```bash +pnpm db:migrate +pnpm db:seed +pnpm activities:install +``` + +然后**用 SQL 核库里的清单版本,不看脚本的话**(同版重装是 no-op,脚本照样打印「已装」—— 踩过): + +```bash +node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();const r=await c.query(\"select version, enabled from sys.activity_package where activity_id like '%part-substitution%' order by installed_at desc limit 3\");console.log(r.rows);await c.end();})()" +``` + +Expected: 最新一行是 **2.0.0**。不是就先查为什么(多半是版本号没升,或另一个会话抢先占了号)。 + +- [ ] **Step 4: 跑 e2e** + +```bash +JCAI_E2E_LAND_MASTER=1 node tools/e2e-a2.mjs w05 > /tmp/e2e-w05.txt 2>&1; echo "EXIT=$?"; tail -60 /tmp/e2e-w05.txt +``` + +**验收线**:**5/5 released** · 发口全对 · **提交闸每单真拦**(空手交卷吃 400)· **模型调用恰 1 次/单** · 配方零出错。 +(「行动被拒 0」这条对无 agent 的活动自动成立,改数**模型调用次数**。) + +跑完清运行数据:`pnpm --filter @app/api reset-runs`。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/e2e-a2.mjs activities/part-substitution-assessment +git commit -m "$(cat <<'EOF' +test(w05): 五案例 e2e —— 零卡直分析 / 缺库 / 类型不一致硬拦 / 两颗都缺库 + +验收线:5/5 released、发口全对、提交闸每单真拦、模型调用恰 1 次。 +「行动被拒 0」对无 agent 的活动自动成立,所以改数模型调用次数 —— 它才是这次要守的那个数。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +### Task 12: 浏览器走查与收口 + +**Files:** +- Modify: `docs/活动开发规范.md` · `docs/debt.md` · `CLAUDE.md` +- Delete: `docs/数据域字段说明-W05.md` 里已不存在的那几张表的段落(或整篇重写) + +**Interfaces:** +- Consumes: Task 11 装好的包 +- Produces: 一次真人路走查的账 + 文档收口 + +> **这一步的交付物是「亲眼看见它跑通」** —— 脚本 e2e 与浏览器走查逮到的缺陷**不重合**: +> 脚本逮机制(它每张卡都先空手交一次、每个岔路口按序代答),浏览器逮**人看见什么、点得动什么**。 +> 本仓两条线各自逮到过对方漏掉的一整批。 + +- [ ] **Step 1: 走最长的那条路** + +用**AI 服务账号**起单(绝不派到用户工作台),样例选「候选缺库·新建类型」,在浏览器里走完: + +1. 运营中心 → 活动 → ACT-WORK-05 → 一键启动,两格下拉各选/填一个; +2. 工作台收到**数据工单**:件上红带点名「还差什么」; +3. 类型下拉里**新建一个类型**,加它的核心参数字段(名字 / 说明 / 硬软约束);**按保存**; +4. **确认参数值那几行自己出现了**(这是「保存即铺格」——它是这套设计最容易出问题的一环); +5. 填齐参数值 → 提交(先试一次**空手提交**,确认闸真拦并点名缺哪一格); +6. 收到**审核卡**:件上逐条摆着四态与影响;**就地改一条四态**,保存,**确认推荐等级跟着变了**; +7. 勾全人工验收清单 → 确认通过; +8. 收到**落账验收卡**:件上摆着「进哪几张表各几行」;签字 → `released`; +9. 工作成果里两份定稿各是各的快照。 + +**逐条记账**:每一步看到什么、有没有与设计不符的地方。**看到不对就停下来记,别顺手改** —— 改动要走任务与评审。 + +- [ ] **Step 2: SQL 复核落账** + +```bash +node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();for(const t of ['substitution__relation','substitution__difference_item','validation__record','sub_eval__case']){const r=await c.query(\`select count(*) from \${t}\`);console.log(t, r.rows[0].count);}await c.end();})()" +``` + +核三件:①明细行数 = 该类型的核心参数字段数;②关系一行,`recommendation_level` 与四态分布**对得上**; +③P-06 一行、`validation_status = 'not_started'`(**本活动不虚构实测结果**)。 + +- [ ] **Step 3: 收口文档** + +- 《活动开发规范》:在 §4.6 后补一小节,把**这个活动**作为第一个全 auto 的范例点名(四步、零 guide、AI 一次), + 并写下这次真正踩到的坑(至少:`gate_reason != ""` 那个未绑定名陷阱、`chip_type_id` 顶层同名冲突要对准一张表 lookup)。 +- `docs/debt.md`:记下**这一轮欠着的**(至少:`{joinRows}` 未实现所以验证要求不逐条列参数名; + 类型字典的去重与合并没做,同一类型建两遍不会被拦)。 +- `CLAUDE.md` 现状快照加一条:**ACT-WORK-05 已重写为四步全 auto**,写清「AI 出场恰一次、行由字典决定不由它决定、 + 推荐等级由规则算」,以及五案例 e2e 与浏览器走查的实测账。 + +- [ ] **Step 4: 最后一遍门禁并提交** + +```bash +pnpm gates +git add docs CLAUDE.md +git commit -m "$(cat <<'EOF' +docs: ACT-WORK-05 重写收口 —— 四步全 auto 的第一个范例 + +规范补下这一轮真踩到的两个坑:S-FEEL 里未绑定的名字 `!= ""` 求值为 true +(拿 gate_reason 当发卡条件会在一切正常时把卡发出去); +顶层同名字段以「后写的赢」为准,取值要对准一张表 lookup 而不是赌哪张表最后写。 + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet +EOF +)" +``` + +--- + +## 验收线 + +1. `pnpm gates` 六道全绿(以退出码验,不许接管道)。 +2. **e2e `w05` 五案例 5/5 released**、发口全对、提交闸每单真拦、**模型调用恰 1 次/单**。 +3. **浏览器走完最长那条路**:新建类型 → 保存 → **参数格自己出现** → 填齐提交 → 审核卡就地改四态 → **等级跟着变** → 勾全清单 → 放行 → released。 +4. 库里核过:明细行数 = 该类型字段数;关系的推荐等级与四态分布对得上;P-06 一行且 `not_started`。 +5. 活动包里**没有一处 `guide`、没有一件工具、没有一面记账旗**。 diff --git a/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md b/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md index a98116b..7eb0162 100644 --- a/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md @@ -97,6 +97,7 @@ A–AE 发现表里,八条事故落在「本来就该是确定的」那几步上 **`sub_eval.part` · 本单两颗料**,`kind: record`,`caseKey: eval_ref` —— 一单**恰两行** `eval_ref`(主键)· `internal_pn`(主键)· `role` 必填 tally `original`/`candidate` · +**`found_pn_ref`**(`refersTo parts.profile.internal_pn`,`requiredWhen exists_status = found`)· `exists_status` 必填 tally `found`/`absent` · `chip_type_id` integer **必填** · **`distinct: true`** (「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种) @@ -105,6 +106,8 @@ A–AE 发现表里,八条事故落在「本来就该是确定的」那几步上 `eval_ref`(主键)· `chip_type_id`(本单锁定的类型)· `gate_reason`(入参未就绪的原因,件上摆的就是它)· `summary`(结论一句话) +**`found_pn_ref` 是「在不在库」的依据**:判 `found` 就得拿得出一个基座查得到的料号, +`refersTo` 让查无此值当场拒 —— 「在库」从此是一次 `lookup` 的结果,不是一句断言。 `exists_status` 与两处 `chip_type_id` **每一拍由 `lookup` 重算** —— 人把料建好之后自己变 `found`, 不设 `created` 档、不靠谁记账。