Archived
计划原稿让 Task 8/9 复用「Task 7 已经加进去的 viewOf / ASSESSMENT_STEPS」, 而 Task 7 的实现者核过文件后用的是既有的 viewHtml(:38 本地定义)与 manifest.assets.businessModel.steps —— 照原稿写会去找两个不存在的东西。 这个文件刻意走清单而不是 case-model.ts 的源常量:源常量对得上而清单里漏装, 正是它要拦的那类错。ASSESSMENT_STEPS 是真导出,但那是 steps.test.ts 的用法。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2253 lines
124 KiB
Markdown
2253 lines
124 KiB
Markdown
# 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) <noreply@anthropic.com>
|
||
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` 末尾。**帮手名已查实**:该文件从
|
||
`../seed-mvp-data.ts` 引 `MASTER_DOMAINS` / `MASTER_OBJECTS` / `MASTER_SAMPLE_ROWS`,先在文件里加两个局部帮手:
|
||
|
||
```ts
|
||
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
|
||
const seedRowsOf = (domain: string, object: string) =>
|
||
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
|
||
```
|
||
|
||
```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) <noreply@anthropic.com>
|
||
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<string, Record<string, unknown>>;
|
||
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)。
|
||
|
||
**`assets` 这一段也要现在处理,否则 Task 2–Task 9 之间包是不自洽的**(清单引着已经不存在的东西):
|
||
- `assets.businessModel.dmn`:**去掉 D01 / D02 两张判定表**(四态改 AI 判、等级改规则算,§3.5 是唯一真源);
|
||
- `assets.skills`:置空;`contract.resources.skills`:置空(`skills.ts` 的删除排在 Task 10);
|
||
- `assets.viewTemplates`:**暂时只留 `activity-ledger` 一张**(三张件由 Task 7–9 各自加回);
|
||
- `assets.distillHooks`:改写成一句与四步对得上的归纳提示,或整个去掉 —— 它是给模型的,而这个活动没有 agent。
|
||
|
||
- [ ] **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) <noreply@anthropic.com>
|
||
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 = `<?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 > 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>
|
||
|
||
<planItem id="S1_inputs" name="校验入参与补数据" definitionRef="d_inputs"/>
|
||
<planItem id="S2_assess" name="逐项差异分析" definitionRef="d_assess">
|
||
<entryCriterion id="e_assess" sentryRef="s_inputs_ready"/>
|
||
</planItem>
|
||
<!-- 审核与放行**触达的是人**:标人工激活,且不给 doneWhen —— 完成与否不该由数据形状推出来 -->
|
||
<planItem id="S3_review" name="差异评估结论审核" definitionRef="d_review" manualActivation="true">
|
||
<entryCriterion id="e_review" sentryRef="s_assessed"/>
|
||
</planItem>
|
||
<planItem id="T_release" name="落账放行" definitionRef="d_release" manualActivation="true">
|
||
<entryCriterion id="e_release" sentryRef="s_reviewable"/>
|
||
</planItem>
|
||
|
||
<humanTask id="d_inputs" name="校验入参与补数据"/>
|
||
<humanTask id="d_assess" name="逐项差异分析"/>
|
||
<humanTask id="d_review" name="差异评估结论审核"/>
|
||
<humanTask id="d_release" name="落账放行"/>
|
||
</casePlanModel>
|
||
</case>
|
||
</definitions>`;
|
||
```
|
||
|
||
> **`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) <noreply@anthropic.com>
|
||
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` 上有 `found_pn_ref`(Task 2 照 spec §3.3 应当已经写了)**
|
||
|
||
Run: `grep -n "found_pn_ref" activities/part-substitution-assessment/src/manifest.ts`
|
||
**有就跳过这一步。** 没有(Task 2 漏了)就在 `role` 之后补上 —— `exists_status` 不能凭空判,
|
||
**判「在库」就必须拿得出一个基座查得到的料号**:
|
||
|
||
```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']);
|
||
/* 案卷排在骨架两笔之后 —— gate_reason 引的 _missing 要等骨架铺出来才有值,提前算它那句话是空的 */
|
||
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
|
||
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
|
||
]);
|
||
});
|
||
|
||
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[2];
|
||
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: '' },
|
||
},
|
||
},
|
||
/*
|
||
* ② 主数据骨架:**两颗料都铺、值回填现值**。
|
||
* 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
|
||
* **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `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` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。
|
||
* **这一笔必须排在②③之后,不许提前**(2026-08-29 走查实测,评审用活探针复现):
|
||
* 它引的 `parts_profile_missing` / `parts_parameter_missing` 要等②③把骨架行铺出来才有值。
|
||
* 排在前面时,引擎在②③写入**之前**给它读到 0 —— 卡照样正确发出(⑤ 的 `when` 排在更后面,
|
||
* 读到的是刷新后的真缺口),但卡上那句「还差什么」是**空白的**,恰在人最想要一句解释的时候。
|
||
* **一笔读的是另一笔写出来的数,它就得排在那一笔后面。**
|
||
*/
|
||
{
|
||
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: '',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
/*
|
||
* ⑤ 缺什么就派一张数据工单。
|
||
* **条件只用基座读数**:`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) <noreply@anthropic.com>
|
||
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) <noreply@anthropic.com>
|
||
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) <noreply@anthropic.com>
|
||
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),绑定契约如下** —— 模版只取值、不判断:
|
||
|
||
| 块 | 绑定 | 说明 |
|
||
|---|---|---|
|
||
| ① 要评估什么 | `<tbody data-rows="mine:sub_eval.part" data-edit="parts.profile">` | 两行(行由系统铺,**人不加行**)。每行摆 `internal_pn`(主键,**必挂 `data-col`** —— 它不是编辑面,是行的钥匙)、在库状态徽(`data-class-of="exists_status"`)、以及可改的主数据格 |
|
||
| ② 芯片类型 | `<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name">` | 选项**从台账现读**;下面 `<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param">` 供新建类型时加字段行 |
|
||
| ③ 参数值 | `<tbody data-rows="mine:parts.parameter" data-edit="parts.parameter">` | 一字段一行、**两栏都可填**;`param_name` 挂 `data-col` 当钥匙,`param_value` / `param_note` 可改 |
|
||
| 顶部红带 | `<div data-class-of="gate_reason">` + `<span data-slot="gate_reason">` | 「还差什么」由 S1 的配方算好摆上来;**一格不缺时那条带子由样式表隐掉**(`v-` 空值那一档 `display:none`),模版不判断 |
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
追加到 `activity.test.ts`:
|
||
|
||
```ts
|
||
const viewOf = (name: string) => manifest.assets.viewTemplates.find((v) => v.name === name)?.html ?? '';
|
||
|
||
describe('件① 评估输入补全', () => {
|
||
const html = viewOf('input-supplement');
|
||
|
||
it('在 viewTemplates 里,且 S1 指向它', () => {
|
||
expect(html).not.toBe('');
|
||
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
|
||
});
|
||
|
||
it('三块都在,行都绑本单草稿(不是整表)', () => {
|
||
expect(html).toContain('data-rows="mine:sub_eval.part"');
|
||
expect(html).toContain('data-rows="mine:parts.parameter"');
|
||
expect(html).toContain('data-rows="mine:parts.type_param"');
|
||
});
|
||
|
||
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
|
||
expect(html).toContain('data-options="parts.chip_type"');
|
||
expect(html).toContain('data-option-value="type_id"');
|
||
expect(html).toContain('data-option-label="type_name"');
|
||
/* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
|
||
expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
|
||
});
|
||
|
||
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
|
||
expect(html).toContain('data-col="internal_pn"');
|
||
expect(html).toContain('data-col="param_name"');
|
||
});
|
||
|
||
it('可改的三张表各有一道编辑闸', () => {
|
||
for (const address of ['parts.profile', 'parts.type_param', 'parts.parameter']) {
|
||
expect(html).toContain(`data-edit="${address}"`);
|
||
}
|
||
});
|
||
|
||
it('模版不算不判:没有内联脚本,红带的显隐交给样式表', () => {
|
||
expect(html).not.toContain('<script');
|
||
expect(html).toContain('data-class-of="gate_reason"');
|
||
});
|
||
|
||
it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => {
|
||
expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑一遍,确认它失败**
|
||
|
||
Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件①" > /tmp/red7.txt 2>&1; echo "EXIT=$?"; cat /tmp/red7.txt`
|
||
Expected: FAIL。**整份贴进报告。**
|
||
|
||
- [ ] **Step 3: 写模版**
|
||
|
||
用 `report.ts` 里既有的 `doc({ css, alias, badge, hic?, tag, kicker, title, step, body, footTag })` 帮手(签名照文件里的),
|
||
`css` 用 `P08_CSS`(数据工单那一套版面语言,已是从目标件抽出来的)。`body` 按上面的绑定契约三块竖排。
|
||
|
||
**动手前先读《HTML报告生成规范》** —— 尤其 §4 绑定全表、§6 编辑态(闸 / 孪生件 / 增删行 / 红带)、§10 陷阱表。
|
||
三条最容易踩的:
|
||
- **行容器的标签名不得在行模版内部再出现**(用 `<article>` 包 `<section>`),否则行会被截成套娃;
|
||
- **枚举列上下两态要说同一个字** —— 只读那一份别 `data-slot` 直显机器码,用 `data-class-of` 徽标或 `data-by` 查表;
|
||
- **空格子的胶囊**用 `data-blank` + CSS `:empty::after`,别在模版里判。
|
||
|
||
删掉 `P08_MASTER_CREATE` / `P10_DOCUMENT_CREATE` / `EVAL_INPUT_LIST` / `DIFFERENCE_REVIEW` / `LANDING_CONFIRM` 五个旧导出(Task 8、9 会新建同名的另两张),`target-style.ts` 里没有消费方的 CSS 常量一并删。
|
||
|
||
- [ ] **Step 4: 跑测试与全量门禁**
|
||
|
||
Run:
|
||
```bash
|
||
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green7.txt 2>&1; echo "EXIT=$?"; cat /tmp/green7.txt
|
||
pnpm gates
|
||
```
|
||
Expected: 全绿(整份贴进报告);退出码 **0**。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add activities/part-substitution-assessment
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(w05): 件① 评估输入补全 —— 类型下拉从台账现读
|
||
|
||
芯片类型是人在件上现造的,选项写死等于「只能选出包那天就有的那几种」。
|
||
行由系统铺、人不加行:要建哪颗料是启动参数就定死的事,他只填格子。
|
||
主键列挂 data-col 当行的钥匙 —— 漏了会把人改的行错配到别行上(2026-08-02 踩过)。
|
||
|
||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7b: 件① 补上「新建芯片类型」—— 空表加行这条路要先通
|
||
|
||
> **这一条是 2026-08-29 从 Task 7 的顾虑里立出来的**(控制端裁定)。Task 7 交付的件让人**从下拉里挑**
|
||
> 一个已有类型(`data-options` 现读),这条主路是通的;但 spec 明写的另一半 —— **人在这张件上新建一个类型
|
||
> 并定下它的核心参数字段** —— 没做。它不是可选的:
|
||
>
|
||
> - spec `:37`:「人**选定/新建类型**后按一次保存,系统按该类型的字段清单把参数值的行铺出来」
|
||
> - spec `:261`:提交时 `chip_type` / `type_param` / `profile` / `parameter` **四张一起**进正式表
|
||
> - 用户原话里也点了名:「选择芯片类型(**或创建一个新类型 + 它的核心参数字段,每个带描述与软硬约束**)」
|
||
>
|
||
> **为什么 Task 7 做不了**(诊断已由控制端核实,属实):
|
||
> `ViewContainer.addRow`(`packages/ui-kit/src/ViewContainer.tsx:303-306`)靠
|
||
> `host.querySelector('tr:last-of-type')` 克隆最后一行,**零行时返回 `false`**;
|
||
> 而 `mine:parts.chip_type` / `mine:parts.type_param` 每单开局都是零行 —— 加行按钮到不了岸。
|
||
> 顺带:平台在这件事上自相矛盾 —— `apps/api/src/app/routes/desk.ts:843` 那条空表闸的话是
|
||
> 「**先在件上加一行**填好再提交」,教人做一件 UI 做不到的事。
|
||
|
||
**三条候选路,开工前先定哪一条**(各自的代价已初勘,但**都要自己复核**):
|
||
|
||
① **件上摆字面量空白行**(不走 `data-rows`,直接写 `data-edit` + `data-col` 的行)——
|
||
零平台改动。已核实两条支撑机制存在:**空行不写**(`desk.ts:270` 一带,`filled.length > 0` 才落)、
|
||
**`auto` 主键由基座续号**(`type_id` 声明 `auto`,不进表单)。
|
||
**未解的坎**:`parts.type_param` 是复合主键(`type_id` + `param_name`),而新类型的 `type_id`
|
||
要等 `chip_type` 那一行写下去才铸得出来 —— **跨行的自增键联动**基座目前是逐行铸的。先查这条通不通。
|
||
|
||
② **平台补「原型行」**:渲染器对零行的 `data-rows` 留一行隐藏原型(`<tr hidden data-row-proto>`),
|
||
`addRow` 找不到 `tr:last-of-type` 时回落到克隆它。改动小,但动的是 `view-render.ts` 与
|
||
`ViewContainer.tsx` **两个共享面** —— E04 / I01 都在用,**照规矩要全量回归**。
|
||
顺带能把 `desk.ts:843` 那句自相矛盾的话变成真的。
|
||
|
||
③ **配方先种一行骨架**:S1 在某颗料没有 `chip_type_id` 时投一行空 `parts.chip_type`。
|
||
零平台改动,但人若改从下拉挑了已有类型,那行空骨架会让 `_missing > 0` **一直卡着闸**,
|
||
得他自己删掉才行 —— 摩擦大且不明显。**列在这里是为了记下它被否过,别再想一遍。**
|
||
|
||
**Files:** 待定(取决于选哪条路)
|
||
**Interfaces:** 消费 Task 7 的 `input-supplement` 件与 S1 配方的 `gateLedgers`
|
||
|
||
- [ ] **Step 1: 定路线** —— 先查①的跨行自增键联动通不通(`autoKeys` 在 `desk.ts:392` 一带);
|
||
通就走①(零平台改动最省),不通就走②(并按「改共享面全量回归」办)。**把结论写进报告。**
|
||
- [ ] **Step 2: 照选定的路实现,并补测试**
|
||
- [ ] **Step 3: 变异验证** —— 拿掉这条能力,证明测试变红
|
||
- [ ] **Step 4: `pnpm gates`(看真实退出码)→ 提交**
|
||
|
||
---
|
||
|
||
### Task 8: 件② `difference-review` —— 差异评估结论审核(验收工单)
|
||
|
||
**Files:**
|
||
- Modify: `activities/part-substitution-assessment/src/views/report.ts`(新增 `DIFFERENCE_REVIEW`)
|
||
- Modify: `.../src/manifest.ts`(`viewTemplates`)+ `case-model.ts`(`S3_review.view`)
|
||
- Test: `.../__tests__/activity.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 5 落下的 `substitution.difference_item` 与 `substitution.relation`
|
||
- Produces: 视图模版 `difference-review`
|
||
|
||
**绑定契约(spec §6.2)**:
|
||
|
||
| 区 | 绑定 | 可改? |
|
||
|---|---|---|
|
||
| 对决头 | `data-slot="original_internal_pn"` / `candidate_internal_pn`;类型名用 `<span data-rows="parts.chip_type" data-by="type_id=chip_type_id">` 查表 | 只读 |
|
||
| 四态分布带 | 四个 `data-slot` 取 `substitution_difference_item_difference_status_*`;条宽用 `data-class-of` 投影成 class | 只读 |
|
||
| 推荐等级大徽章 | `data-class-of="recommendation_level"` + `data-slot` | 只读(**规则算的**) |
|
||
| 逐条明细 | `<tbody data-rows="mine:substitution.difference_item" data-edit="substitution.difference_item">`;`difference_item_id` 挂 `data-col` 当钥匙 | **四态(`<select data-col="difference_status">`)· 影响说明 · 判断依据 · UNKNOWN 去向 可改** |
|
||
| 收口四格 | 推荐理由 / 差异摘要 / 风险 / 验证要求,各 `data-slot` | **只读** |
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
> **⚠️ 助手名照文件里真有的写**(2026-08-29 控制端核正:计划原稿这里写的 `viewOf` / `ASSESSMENT_STEPS`
|
||
> **在这个测试文件里都不存在**)。`activity.test.ts` 用的是:
|
||
>
|
||
> - **`viewHtml(name)`** —— 本地定义在 `:38`(`manifest.assets.viewTemplates.find(...)?.html ?? ''`);
|
||
> - 取剧本走 **`manifest.assets.businessModel.steps`**,不是 import `ASSESSMENT_STEPS`。
|
||
>
|
||
> 这是刻意的:这个文件测的是**装配出来的清单**(成品),不是 `case-model.ts` 里的源常量 ——
|
||
> 源常量对得上而清单里漏装,正是它要拦的那类错。**照它现有的写法来,别新引一套。**
|
||
> (`ASSESSMENT_STEPS` 确实是 `case-model.ts` 的真导出,但那是 `steps.test.ts` 的用法,别串台。)
|
||
|
||
```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('<script');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑一遍,确认它失败**
|
||
|
||
Run: `pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件②" > /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) <noreply@anthropic.com>
|
||
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: 写失败的测试**
|
||
|
||
> **⚠️ 同 Task 8 的那条核正**:这个文件里没有 `viewOf`,用本地的 **`viewHtml(name)`**(`activity.test.ts:38`);
|
||
> 取剧本走 **`manifest.assets.businessModel.steps`**,不是 import `ASSESSMENT_STEPS`。下面的示例代码照这个改。
|
||
|
||
```ts
|
||
describe('件③ 落账确认', () => {
|
||
const html = viewHtml('landing-confirm');
|
||
|
||
it('在 viewTemplates 里,T_release 指向它', () => {
|
||
expect(html).not.toBe('');
|
||
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>)
|
||
.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('<script');
|
||
});
|
||
|
||
it('把「进哪几张表各几行」摆出来,用的是基座读数', () => {
|
||
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:**(2026-08-29 控制端实地核过,**原清单漏了四个文件**,见下方 Step 1 的实测结果)
|
||
- Modify: `apps/api/src/app/tools/first-party.ts` —— 摘掉 `FIRST_PARTY_TOOLS` 里那两行(`:680-681`),
|
||
连同只服务于它们的实现:`compareParameters`(`:650`)· `recommend`(`:656`)· `compareParametersWith`(`:592`)·
|
||
`ParameterLedger` 类型(`:515`);`parseParamValue`(`:393`)/ `describeParsed`(`:415`)**先查还有没有别的调用方**,
|
||
没有才一并摘。留墓志铭。
|
||
- Modify: `apps/api/src/app/container.ts` —— `parameterLedger`(`:424-431`)与那一行 handler 覆盖(`:434`)一并撤,
|
||
连 `ParameterLedger` 的 import。**这一处原清单没有,不撤则工具在容器里照常注册。**
|
||
- Delete: `apps/api/src/app/tools-w05.ts` —— 整份就是这两枚工具的契约定义(`W05_TOOLS` / `W05_TOOL_SAMPLE`),
|
||
没有第三个消费方。**原清单没有。**
|
||
- Modify: `apps/api/src/app/seed-activities.ts`(`:19` import)与 `apps/api/src/app/seed-mvp.ts`(`:15` import)
|
||
—— `tools-w05.ts` 的两个消费方,随它一并清。**原清单没有。**
|
||
- Modify: `apps/api/src/app/__tests__/first-party-substitution.test.ts`(见 Step 2 —— 改成退役断言,不是删文件)
|
||
- 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\|part-substitution-assessment\.recommend" activities apps packages --include='*.ts' \
|
||
| grep -v "app/tools/first-party.ts" | grep -v "activities/part-substitution-assessment"
|
||
```
|
||
|
||
**Expected(2026-08-29 控制端实地跑过,不是零命中)**:恰好这几处,全部在上面的 Files 清单里 ——
|
||
`container.ts:434` · `tools-w05.ts:21,64` · `seed-activities.ts:19` · `seed-mvp.ts:15` ·
|
||
`__tests__/first-party-substitution.test.ts` 若干。
|
||
|
||
**冒出清单之外的命中就停下来报上来,别硬拆。**
|
||
|
||
- [ ] **Step 2: 改测试(先描述目标)**
|
||
|
||
把 `first-party-substitution.test.ts` 里断言这两枚工具**可用**的用例,改成断言它们**已停用且留了墓志铭**:
|
||
|
||
> **⚠️ 计划原先在这里写错了(2026-08-29 控制端核出并改正)。** 原稿写的是
|
||
> `registry.find((t) => t.name.includes(...))?.enabled).toBe(false)` —— **三处都不成立**:
|
||
> 该文件里没有 `registry` 这个名字;`FIRST_PARTY_TOOLS`(`first-party.ts:669`)是
|
||
> `ReadonlyArray<readonly [string, ToolHandler]>` —— **元组数组,元素上没有 `.name`**,取它得 `undefined`、
|
||
> 再 `.includes()` 当场抛 TypeError;而且**本仓的工具压根没有 `enabled` 这个概念**,退役的既有做法是
|
||
> **摘掉注册 + 留墓志铭**(见 quote-archive / quote-intake 的 declarations 头注)。
|
||
>
|
||
> 断言改成**「不在注册表里」** —— 它同样拦得住「哪天有人手滑又把它注册回来」:
|
||
|
||
```ts
|
||
it('两枚工具已退役 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
|
||
const names = FIRST_PARTY_TOOLS.map(([n]) => n);
|
||
expect(names).not.toContain('part-substitution-assessment.compare-parameters');
|
||
expect(names).not.toContain('part-substitution-assessment.recommend');
|
||
});
|
||
```
|
||
|
||
**这个文件其余的用例怎么办**:它们逐条测的是 `compare-parameters` 的比对行为,而那段代码这一步就删了 ——
|
||
**测试跟着它测的东西一起走**,这不是「删测试让门禁变绿」(本仓禁止的是后者)。
|
||
把整份文件缩成上面这一条退役断言,文件头注改写成墓志铭:说清这两枚工具做过什么、
|
||
规则现在活在哪儿(`case-model.ts` 的 `S2_assess.auto`)、以及为什么不留第二份。
|
||
|
||
- [ ] **Step 3: 停用并留墓志铭**
|
||
|
||
**摘掉注册,不是「标成停用」**(2026-08-29 控制端改正:本仓工具没有 `enabled` 这个概念,见 Step 2 的说明)。
|
||
按上面 Files 清单逐处摘干净 —— `first-party.ts` 的两行注册与只服务于它们的实现、`container.ts` 的 handler 覆盖
|
||
与 `parameterLedger`、`tools-w05.ts` 整份、两个 seed 的 import。**摘完跑一次 Step 1 的 grep 复查,应当只剩墓志铭里的文字。**
|
||
|
||
在 `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 apps/api/src/app/container.ts apps/api/src/app/seed-activities.ts \
|
||
apps/api/src/app/seed-mvp.ts apps/api/src/app/__tests__ activities/part-substitution-assessment
|
||
# tools-w05.ts 是删除,`git add -A apps/api/src/app` 或显式 `git rm` 都行 —— 别漏在工作树里
|
||
git commit -m "$(cat <<'EOF'
|
||
chore(w05): 两枚工具与两张判定表退役,技能戳删除 —— 规则只许有一处真源
|
||
|
||
四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。
|
||
再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。
|
||
技能戳是给模型看的,而这个活动全程没有 agent。
|
||
|
||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
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 那条放宽只在本分支上),它**解析不了**这份新清单。
|
||
>
|
||
> **用户已经定了(2026-08-29,原话「走查期间先停掉稳定版」)——走第 ① 条。**
|
||
> 曾摆过的三条路留在这儿备查:①走查期间停稳定版;②另开一个库跑(代价是种子要重播);
|
||
> ③明知有风险照装(稳定版在此期间用不了 W05)。
|
||
>
|
||
> **执行者不要自己去停** —— 停稳定版是**控制端**的事(它是本工作树之外的副作用)。
|
||
> 开工前先确认 `lsof -iTCP:8790 -sTCP:LISTEN` **没有监听**;还在监听就停下来报上来,别自己动手、也别绕过去装。
|
||
> 确认停了之后,`pnpm db:migrate` / `activities:install` 照跑(`db:seed` 仍需另行确认 —— 它会重播治理台配置)。
|
||
|
||
- [ ] **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) <noreply@anthropic.com>
|
||
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) <noreply@anthropic.com>
|
||
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`、没有一件工具、没有一面记账旗**。
|