From 1c6978818c79a0399caef94f976c8c64a433a227 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 16:18:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20S2=20=E6=B5=8B=E8=AF=95=E7=9A=84=20?= =?UTF-8?q?stepOf=20=E7=B1=BB=E5=9E=8B=E4=BF=9D=E6=8A=A4=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E6=B3=9B=E5=9E=8B=20Extract,=E8=A1=A5=20{}=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E7=9A=84=E7=AC=AC=E4=BA=8C=E4=B8=AA=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审判定 PlanStep 标注太松:.fields 落在契约的开放索引签名上,拼写保护回来了但 14 处方括号取值(含 S1 既有三条)全部失去编译期拼写检查。换成泛型 Extract 版 (case-model.ts 四处 item 补 as const 配合),按查的 item 精确narrow 到那一步自己的形状。 Extract 单独应用后 tsc 仍报 13 个错——根因是 brief 给的 7 处测试断言用 `: {}` 兜底, 而"有形对象类型 | {}"在 TS 里恒被吸收成裸 {}(与 stepOf 精不精确无关,5 个隔离复现验证过), S1 既有断言用的是 `: undefined` 从未踩过。补齐 `: {}` → `: undefined` + `?.` 与两处 for-of 数组补 as const 后,拼错实验复现 TS2551 + "Did you mean" 建议,改回后零错误。 debt.md 补一条:analyze 出参靠 requiredWhen 兜底的字段漏填时,全 auto 步没有就近恢复点 (ADR-014 通用特性,不是 W05 的病)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/steps.test.ts | 78 ++++++++++++------- .../src/case-model.ts | 8 +- docs/debt.md | 21 +++++ 3 files changed, 73 insertions(+), 34 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index c169636..ba1de97 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -1,19 +1,29 @@ import { describe, expect, it } from 'vitest'; -import { planStepSchema, type PlanStep } from '@contracts'; +import { planStepSchema } from '@contracts'; import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts'; import { manifest } from '../src/manifest.ts'; const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release']; /* - * **返回类型显式标 `PlanStep`**(2026-08-29,Task 5 逼出来的修正,与「文件里没有的名字先核一遍」同一课)—— + * **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)—— * `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集), - * 与查的是哪个 `item` 无关。S2 从占位 `say` 换成真配方之前,并集里只有 S1 自己的四种 `project` 形状, - * S1 的断言侥幸压得住;换上真配方后并集里混进了 S2 的三种 `project` 形状(字段名完全不同), - * S1 原有的 `fields?.['found_pn_ref']` 这类按属性名取值当场过不了 `tsc`(TS7053)。 - * 标成契约类型 `PlanStep` 之后 `.auto.do[i]` 落回 `AutoAction`(`fields: Readonly>` - * 本就有索引签名),按哪个字符串查步骤才真正决定读到的形状 —— 这也是测试原本就该核的东西。 + * 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在 + * `fields: Readonly>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个 + * 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查, + * `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言 + * 落空才发现,反馈反而往后挪了一步。 + * + * 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`, + * `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')` + * 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的** + * 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象 + * 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。 */ -const stepOf = (item: string): PlanStep | undefined => ASSESSMENT_STEPS.find((s) => s.item === item); +function stepOf(item: T) { + return ASSESSMENT_STEPS.find( + (s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item, + ); +} describe('四步案例模型', () => { it('恰好四步,次序即业务次序', () => { @@ -211,25 +221,33 @@ describe('S2 配方 —— AI 只出场一次', () => { 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 : {}; + /* + * **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)—— + * `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型, + * 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报 + * 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。 + * `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的 + * `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。 + */ + const fields = items?.kind === 'project' ? items.fields : undefined; /* 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' }); + 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' }); + const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined; + for (const col of ['original_value', 'candidate_value'] as const) { + expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' }); } /* 约束类型来自字典这一行本身,不是 AI 说的 */ - expect(fields['constraint_type']).toEqual({ col: 'constraint_type' }); + 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']); + const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined; + const jc = JSON.stringify(fields?.['judgement_class']); expect(jc).toContain('hard_mismatch'); expect(jc).toContain('soft_mismatch'); /* 顺序要紧:先认四态,最后才按约束类型分硬软 */ @@ -237,8 +255,8 @@ describe('S2 配方 —— AI 只出场一次', () => { }); it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => { - const rel = acts[2]?.kind === 'project' ? acts[2].fields : {}; - const level = JSON.stringify(rel['recommendation_level']); + const rel = acts[2]?.kind === 'project' ? acts[2].fields : undefined; + const level = JSON.stringify(rel?.['recommendation_level']); expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0'); expect(level).toContain('not_recommended'); /* 硬约束不符必须直接落到「不推荐」,排在最前 */ @@ -249,9 +267,9 @@ describe('S2 配方 —— AI 只出场一次', () => { 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'); + const fields = val?.kind === 'project' ? val.fields : undefined; + expect(fields?.['validation_status']).toBe('not_started'); + expect(fields?.['validation_type']).toBe('parametric'); }); it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => { @@ -264,18 +282,18 @@ describe('S2 配方 —— AI 只出场一次', () => { expect(prompt).not.toContain(forbidden); } /* 出参 schema 顶层是对象,数组字段名与 into 对得上 */ - const out = analyze?.kind === 'analyze' ? analyze.output : {}; - expect(out['type']).toBe('object'); + const out = analyze?.kind === 'analyze' ? analyze.output : undefined; + 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' }); + const fields = analyze?.kind === 'analyze' ? analyze.input.fields : undefined; + expect(fields?.['param_desc']).toEqual({ col: 'param_desc' }); + expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' }); + for (const col of ['original_value', 'candidate_value'] as const) { + expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' }); } }); }); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 954edb5..db2ee46 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -120,7 +120,7 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号 */ export const ASSESSMENT_STEPS = [ { - item: 'S1_inputs', + item: 'S1_inputs' as const, intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补', guide: '', writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'], @@ -296,7 +296,7 @@ export const ASSESSMENT_STEPS = [ }, }, { - item: 'S2_assess', + item: 'S2_assess' as const, intent: '把两颗料的核心参数逐条交给模型判四态,系统按规则算推荐等级、风险与验证要求', guide: '', writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], @@ -509,7 +509,7 @@ export const ASSESSMENT_STEPS = [ }, }, { - item: 'S3_review', + item: 'S3_review' as const, intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止', guide: '', writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'], @@ -526,7 +526,7 @@ export const ASSESSMENT_STEPS = [ auto: { do: [{ kind: 'say' as const, text: '差异结论已备好,请过目。' }] }, }, { - item: 'T_release', + item: 'T_release' as const, intent: '把这一签下去会落哪几张表、几行摆清楚,人签字后落账并发口', guide: '', /* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */ diff --git a/docs/debt.md b/docs/debt.md index 901c45a..284edd9 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1410,3 +1410,24 @@ loop-engine/__tests__/` import 它们是 `apps → apps`,两条隔离规则一 `w05-gate-reason-order.test.ts`,核一遍 `ACT_*` 六个常量描述的地址/字段写法是否还能代表 `case-model.ts` 当前用到的构造种类;不必逐字同步(那不是这份测试的目标),但要能回答 "如果生产那边这一类写法出了刷新时机的 bug,这份镜像能不能测得出来"。 + +## `analyze` 出参靠 `requiredWhen` 兜底的字段,漏填时全 auto 步没有就近恢复点(2026-08-29,Task 5 评审) + +`S2_assess` 的 `analyze` 出参 schema 里 `unknown_disposition` 不在 `required` 名单——只靠提示词 +约束模型「判了 unknown 就必须在这一格写清楚」,漏填由 `manifest.ts:397` 的 +`requiredWhen: {column: 'difference_status', value: 'unknown'}` 兜底拦成 +`substitution_difference_item_missing > 0`(评审已核过:机制是对的,提交闸与放行门都看得见)。 +但 `S2_assess` 全程 `auto`、零 `guide`、零人工卡口——模型这一拍真漏填,当下没有任何东西能就近 +接住它,只能等清单声明的 `harness`(`memoryWindow:24, stallAfter:'PT4H', +nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢兜底, +比人工激活步「重发已答过的卡=无事发生」那类近距离恢复慢得多。 + +**这是 ADR-014 `analyze` 动词的通用基础设施特性,不是 W05 这一个活动的病**——凡是拿 `analyze` +出参配合 `requiredWhen` 兜底可选字段的活动,都会有同一个洞。**把它改进 `required` 是错的**: +`unknown_disposition` 只有 `difference_status = unknown` 的那几行才该填,MATCH 的行填了反而是 +编造,逼模型给不需要的行也填一格。 + +**要治得在 ADR-014 层面**:给 `analyze` 一个「出参不合规(含 `requiredWhen` 这类 JSON Schema +本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时 +「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法 +(2026-08-03,见 CLAUDE.md「兜底卡不再摆模型原话」条)——`analyze` 目前没有对应机制。