From 8314148ebca352e02183e2d5cd91e14e10d000d8 Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 1 Sep 2026 01:17:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20=E9=87=8D=E5=88=A4=E7=9A=84?= =?UTF-8?q?=E8=A1=8C=E7=94=B1=E7=A1=AE=E5=AE=9A=E7=9A=84=E4=B8=80=E4=BE=A7?= =?UTF-8?q?=E5=9C=88=20=E2=80=94=E2=80=94=20=E6=89=93=E5=9B=9E=E7=82=B9?= =?UTF-8?q?=E5=90=8D=E5=B7=A5=E4=BD=9C=E6=B8=A9=E5=BA=A6,=E4=B8=8D?= =?UTF-8?q?=E8=AE=B8=E5=8E=BB=E6=94=B9=E9=9D=99=E6=80=81=E7=94=B5=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:「我打回让修改工作温度的结论,给我改了静态电流的结论」。库里的证据一字不差: 工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足 —— 这半截是对的), 而没人问过的静态电流被它从 soft_mismatch 翻成 match,evidence_note 里却写着「维持原判」。 根因不是提示词不够严,是**整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行, 而「我只改了那一条」这句话本身也出自它嘴里。规范 §4.6 纪律一(行由确定的那一侧决定, 不由 AI 决定)—— 昨天刚加的重判轮自己栽在这条上。 S3 复核轮两笔变四笔(全部只在 case_redo_note != "" 那一轮跑): ① 圈范围:按字面点名(contains,与基座给模型摆的「意见点名对上了哪几行」同一种认法)标 named / kept(明细表新增 redo_scope 列); ② 兜底:一条都没点到(意见说的是整批)→ 整表算被点名,读上一笔刚写下的 tally,不重算; ③ 重判只喂 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着(也省 token); ④ 写回也只写这几行;模型漏答某一条时 else 兜回它上一轮的值。 同批修一条挡了两次路的老毛病(昨夜刚记 debt,今晚又撞停、还卡住了我自己的验证): 人答得比发卡那一拍收尾还快时,回传被判 stale、wake 又被那一拍的 park 覆盖,单子停在 awaiting-event 再没人叫醒。deliver 判了 stale 照旧不跑这一拍,但把停着的单子拨回 running。 验收:e2e w05 5/5 released、被拒 0;案例 0 新断言「打回只动了被点名的那几条,其余行一个字没变」 真跑通过(途中逼出脚本自己的坑:放行会清空草稿层,快照得留在放行之前); 模型调用 案例 0 = 2 次、其余 = 1 次。四处变异验证。W05@2.18.0 已装库。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- CLAUDE.md | 33 +++++ .../__tests__/steps.test.ts | 82 ++++++++---- .../src/case-model.ts | 123 +++++++++++++----- .../src/manifest.ts | 10 +- .../task/__tests__/deliver-stale.test.ts | 27 +++- .../task/internal/usecase/task-service.ts | 19 +++ docs/debt.md | 35 ++++- tools/e2e-a2.mjs | 51 ++++++++ 8 files changed, 320 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0e3b310..cfb225b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1834,6 +1834,39 @@ .dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道 对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。 +- **答得太快就没人再叫醒它 —— stale 那条守卫补最后一下**(2026-09-01,一夜之内撞停两次, + 第二次直接把我自己的验证卡住 15 分钟):卡发出 **270ms** 后人就答了,而**把 `lastProgressAt` + 往前挪的那一拍,正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传, + 2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)在这种时序下不成立; + 回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。 + 三样凑齐:卡答完了、没有新卡在等、单子停着 —— **再没有第三样东西会叫醒它** + (手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。 + 修:`deliver` 判了 stale **照旧不跑这一拍**(它的本意不变),但把停着的单子拨回 `running` + 交给推进器;真没事可干,它自己再停回去(代价一拍空转)。两条测钉死 + 变异验证。 + 真人点卡要几秒,撞不上这条窗口;**e2e 是 3 秒轮询 + 秒答,专把这种窗口撞出来** —— + 它替人踩的正是这一类。 + +- **「我打回让改工作温度,它给我改了静态电流」—— 重判的行由确定的一侧圈,不由 AI 决定** + (2026-09-01 用户实测,`part-substitution-assessment@2.18.0`)。上一条刚把意见交回给 AI 重判, + 当天就被这一单打脸,而库里的证据一字不差:**工作温度它没改**(还给了理由:候选值 1℃ 远窄于 + -40~+85℃,硬约束不满足 —— 这半截是对的),而**没人问过的静态电流**被它从 soft_mismatch + 翻成 match,`evidence_note` 里却写着「**维持原判**」。两张审核卡的「本轮结论」都还停在 + S2 那一版(0 项一致 / 5 硬 / 2 软),而草稿此刻是 1 match / 5 硬 / 1 软 —— 差的那一项就是它。 + **根因不是提示词不够严,是整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行, + 而「我只改了那一条」这句话本身也出自它嘴里(它说了「维持原判」,可它改了)。规范 §4.6 纪律一 + ——**行由确定的那一侧决定,不由 AI 决定** —— 这次栽在自己手上。 + 治法(S3 复核轮从两笔变四笔,全部只在 `case_redo_note != ""` 那一轮跑): + ①**圈范围**:按字面点名(`contains(case_redo_note, parameter_name)`,与基座给模型摆的 + 「意见点名对上了哪几行」同一种认法)把行标成 `named` / `kept`(明细表新增 `redo_scope` 列,tally); + ②**兜底**:一条都没点到(「结论整体判得太松」这类意见说的是整批)→ 整表算被点名, + 读的是上一笔刚写下的 tally 读数,不重新算一遍;③**重判只喂 `named` 的行** —— + 没点名的行 AI 连见都见不到,自然改不着(也省 token);④**写回也只写这几行**, + 模型漏答某一条时 `else` 兜回它上一轮的值(漏答不该变成「把它清空」)。 + 两处变异各自验证(写回不过滤 / 圈范围时一律标 named,都当场变红)。 + e2e 案例 0 加一条**跨轮比对**断言:打回前存一份明细,放行前留最后一份, + 逐行比「名字没出现在意见里的行,四态与三段叙述一个字都不许变」—— + 途中还逼出脚本自己的一个坑:**放行会清空草稿层**,收工时再取明细什么都没有,快照得留在放行之前。 + - **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后 是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍, 人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。 diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 7ae5940..4174647 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -406,12 +406,11 @@ describe('S3 / S4 配方', () => { const s4 = stepOf('T_release')?.auto?.do ?? []; const s2 = stepOf('S2_assess')?.auto?.do ?? []; - it('S3 七笔:复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 卡 → 交代', () => { - expect(s3.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'card', 'say']); - expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.difference_item'); - expect(s3[2]?.kind === 'project' && s3[2].into).toBe('substitution.difference_item'); - expect(s3[3]?.kind === 'project' && s3[3].into).toBe('substitution.relation'); - expect(s3[4]?.kind === 'project' && s3[4].into).toBe('validation.record'); + it('S3 九笔:圈范围 → 兜底扩范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 卡 → 交代', () => { + expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'analyze', 'project', 'project', 'project', 'project', 'card', 'say']); + for (const at of [0, 1, 3, 4]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item'); + expect(s3[5]?.kind === 'project' && s3[5].into).toBe('substitution.relation'); + expect(s3[6]?.kind === 'project' && s3[6].into).toBe('validation.record'); }); /* @@ -422,25 +421,56 @@ describe('S3 / S4 配方', () => { * 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)—— * 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。 */ - it('复核轮:两笔都钉在「人打回了才跑」上,判法与第一轮同一份规矩,入参多带上一轮结论与他的意见', () => { - const redo = s3[0]; - const land = s3[1]; - expect(redo?.kind === 'analyze' && redo.when).toBe('case_redo_note != ""'); - expect(land?.kind === 'project' && land.when).toBe('case_redo_note != ""'); + it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => { + /* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */ + const whenOf = (at: number): unknown => { + const act = s3[at]; + return act !== undefined && 'when' in act ? act.when : undefined; + }; + for (const at of [0, 1, 2, 3]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""'); + /* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */ + for (const at of [4, 5, 6]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined(); + }); + + /* + * **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了 + * 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的 + * 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。 + * 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。 + */ + it('圈范围:点名的标 named,其余 kept;一条都没点到时整表算被点名', () => { + const mark = s3[0]; + const widen = s3[1]; + const markFields: Readonly> = mark?.kind === 'project' ? mark.fields : {}; + expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' }); + /* 兜底那一笔读的是上一笔刚写下的 tally 读数 —— 不重新算一遍谁被点到 */ + expect(widen?.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0'); + expect(widen?.kind === 'project' ? widen.fields['redo_scope'] : undefined).toBe('named'); + }); + + it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => { + const redo = s3[2]; + const land = s3[3]; + const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } }; + expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named); + expect(land?.kind === 'project' ? land.from : undefined).toEqual(named); /* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */ - const first = s2[0]; - const rules = first?.kind === 'analyze' ? first.instruction : ''; + const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : ''; expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true); - expect(redo?.kind === 'analyze' && redo.instruction).toContain('原样带回'); - /* 入参:上一轮四态 + 他写的意见 —— 少了任何一样,重判要么冲掉人改过的行,要么读不到意见 */ - const fields = Object.keys(redo?.kind === 'analyze' ? redo.input.fields : {}); + /* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */ + const fields: Readonly> = redo?.kind === 'analyze' ? redo.input.fields : {}; for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) { - expect(fields).toContain(key); + expect(Object.keys(fields)).toContain(key); } - expect(redo?.kind === 'analyze' && redo.input.fields['reviewer_note']).toEqual({ in: 'case_redo_note' }); - /* 落明细那一笔与第一轮同一份投影(只多一个 when)—— 各写一份早晚分叉 */ - const s2Land = s2[2]; - expect(land?.kind === 'project' && land.fields).toBe(s2Land?.kind === 'project' ? s2Land.fields : undefined); + expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' }); + /* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */ + const landFields: Readonly> = land?.kind === 'project' ? land.fields : {}; + for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) { + expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } }); + } + /* 派生列与两处创建笔共用同一份 when 链 */ + const s2Fields: Readonly> = s2[2]?.kind === 'project' ? s2[2].fields : {}; + expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']); }); /* @@ -450,7 +480,7 @@ describe('S3 / S4 配方', () => { * `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。 */ it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => { - const recompute = s3[2]; + const recompute = s3[4]; expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' }); /* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有 `prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */ @@ -468,7 +498,7 @@ describe('S3 / S4 配方', () => { * 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。 */ it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => { - const s3Fields = s3[2]?.kind === 'project' ? s3[2].fields : undefined; + const s3Fields = s3[4]?.kind === 'project' ? s3[4].fields : undefined; const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined; expect(s2Fields?.['judgement_class']).toBeDefined(); expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']); @@ -489,7 +519,7 @@ describe('S3 / S4 配方', () => { * 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。 */ it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => { - const relation = s3[3]; + const relation = s3[5]; expect(relation?.kind).toBe('project'); const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly> => act?.kind === 'project' ? act.fields : {}; @@ -504,7 +534,7 @@ describe('S3 / S4 配方', () => { /* 主键带回:同 PK 覆盖,不是新开一行 */ expect(fields['substitution_relation_id']).toBeDefined(); /* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */ - expect(s3[4]).toBe(s2[4]); + expect(s3[6]).toBe(s2[4]); }); /* @@ -520,7 +550,7 @@ describe('S3 / S4 配方', () => { }); it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { - const card = s3[5]; + const card = s3[7]; expect(card?.kind === 'card' && card.as).toBe('decide'); expect(card?.kind === 'card' && card.variant).toBe('review'); expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 67af5d2..b7fb9e8 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -428,17 +428,56 @@ const DIFFERENCE_ITEM_FROM_RESULT = { } as const; /** - * **人把结论打回时,意见要交回给 AI 重判 —— 这是整个活动第二次(也是最后一次)出场** - * (2026-09-01 用户定:「重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」—— - * 当时确实没有:S3 那一轮只把派生列重算一遍,人写的那句话除了留痕之外无人读)。 + * ── 复核轮(人把结论打回那一轮)的四笔 ────────────────────────────────── * - * 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的「人这次打回写了什么」 - * (没打回就是空串,永远绑得上 —— 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。 + * **背景**:2026-09-01 第一版做成了「把意见连同整表交回给 AI 重判」,当天就被用户实测打脸: + * 「我打回让修改**工作温度**的结论,给我改了**静态电流**的结论」。库里的证据一字不差 —— + * 工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足), + * 而没人问过的静态电流被它从 soft_mismatch 翻成了 match,`evidence_note` 里却写着「**维持原判**」。 * - * **入参比第一轮多两样**:①上一轮的结论(四态 / 影响 / 依据 / UNKNOWN 去向)—— - * 人可能已经就地改过几行,那也是「上一轮结论」的一部分;②他这次写的意见。 - * 判法本身与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律: - * **没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉。 + * **根因不是提示词不够严,是行由 AI 决定**:整表交出去、整表写回来,没有任何一样东西限制它 + * 只动被点名的那一行;「我只改了那一条」这句话本身也出自它嘴里,而它说的不作数。 + * 这正是规范 §4.6 纪律一 —— **行由确定的那一侧决定,不由 AI 决定**。 + * + * **治法**:先由确定的一侧圈出「这一轮谁可以变」(①②),再把**只有这几行**交给 AI(③), + * 写回也只写这几行(④)。没被点名的行 AI 连见都见不到,自然也就改不着。 + */ + +/** ① 圈范围:人打回的那句话里点了名的行标 `named`,其余 `kept` —— 与基座给模型摆的「意见点名对上了哪几行」同一种认法(字面包含) */ +const REDO_SCOPE_MARK = { + kind: 'project' as const, + when: 'case_redo_note != ""', + into: 'substitution.difference_item', + from: { each: 'mine:substitution.difference_item' }, + fields: { + substitution_relation_id: { col: 'substitution_relation_id' }, + difference_item_id: { col: 'difference_item_id' }, + redo_scope: { when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' }, + }, +} as const; + +/** + * ② 一条都没点到 → 整表都算被点名(「结论整体判得太松」这类意见说的是整批,不是某一条)。 + * 读的是上一笔刚写下的 tally 读数(每一笔之前刷读数)—— 不重新算一遍谁被点到,同一件事只许有一处判定。 + */ +const REDO_SCOPE_WIDEN = { + kind: 'project' as const, + when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named = 0', + into: 'substitution.difference_item', + from: { each: 'mine:substitution.difference_item' }, + fields: { + substitution_relation_id: { col: 'substitution_relation_id' }, + difference_item_id: { col: 'difference_item_id' }, + redo_scope: 'named', + }, +} as const; + +/** + * ③ **带着意见重判 —— 只判被点名的那几条**(整个活动第二次、也是最后一次模型出场)。 + * + * 入参直接来自明细表自己的行(参数名/说明/约束/单位/两侧的值/上一轮结论都在上面), + * 外加人写的那句话;**没被点名的行压根不在这一批里**,所以「别去动它」不需要靠提示词求它。 + * 判法与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律。 */ const REVIEW_REDO_ANALYZE = { kind: 'analyze' as const, @@ -448,36 +487,57 @@ const REVIEW_REDO_ANALYZE = { instruction: [ ASSESS_RULES, '', - '【这一轮是复核,不是从头判】上一轮的结论已经摆在每一条的 last_* 里,审核人写下的意见在 reviewer_note。', - '· 意见点到的那几条:按他说的复核,该改就改,并在 evidence_note 里写清这一轮改了什么、依据是什么;', - '· **意见没点到、你也确实没判错的**:把 last_* 原样带回来 —— 人可能已经在件上亲手改过几行,重判不是把它们冲掉的机会;', - '· 他说的若与硬规矩冲突(比如要把证据不足的硬约束判成 match),照硬规矩judge,并在 evidence_note 里说明为什么不能照办。', + '【这一轮是复核】下面**只列了审核人这次点到的那几条**(他写的话在 reviewer_note,每条都带着上一轮的结论 last_*)。', + '· 按他说的复核这几条:该改就改,并在 evidence_note 里写清这一轮改了什么、依据是什么;', + '· 复核下来确实是上一轮判对了,就把 last_* 原样带回,evidence_note 说明为什么维持;', + '· 他要的若与硬规矩冲突(比如要把证据不足的硬约束判成 match),照硬规矩判,并在 evidence_note 里说明为什么不能照办;', + '· **列出来的每一条都要回**,一条不多、一条不少。', ].join('\n'), input: { - from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } }, + from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } }, fields: { - param_name: { col: 'param_name' }, - param_desc: { col: 'param_desc' }, + param_name: { col: 'parameter_name' }, + param_desc: { col: 'parameter_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: '' }, + unit: { col: 'parameter_unit' }, + original_value: { col: 'original_value' }, + candidate_value: { col: 'candidate_value' }, + /* 值的备注不在明细表上,回台账现取 —— 与第一轮同一份事实来源 */ + original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' }, + candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' }, /* 上一轮的结论 —— 含人就地改过的那几行(读的是本单草稿的当下值) */ - last_status: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'difference_status', else: '' }, - last_impact: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'impact_statement', else: '' }, - last_evidence: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'evidence_note', else: '' }, - last_unknown_disposition: { lookup: 'mine:substitution.difference_item', by: { parameter_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' }, - /* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状),指令里说明它是整批的意见 */ + last_status: { col: 'difference_status' }, + last_impact: { col: 'impact_statement' }, + last_evidence: { col: 'evidence_note' }, + last_unknown_disposition: { col: 'unknown_disposition' }, + /* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状) */ reviewer_note: { in: 'case_redo_note' }, }, }, output: ASSESS_OUTPUT, } as const; -/** 复核轮判完之后落回明细 —— 与第一轮同一笔投影,同样只在被打回的那一轮跑 */ -const DIFFERENCE_ITEM_FROM_REDO = { ...DIFFERENCE_ITEM_FROM_RESULT, when: 'case_redo_note != ""' } as const; +/** + * ④ 判完写回 —— **同样只写被点名的那几行**(`from` 里压根没有别的行)。 + * 模型漏答某一条时 `else` 兜回这一行上一轮的值:漏答不该变成「把它清空」。 + */ +const DIFFERENCE_ITEM_FROM_REDO = { + kind: 'project' as const, + when: 'case_redo_note != ""', + into: 'substitution.difference_item', + from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } }, + fields: { + substitution_relation_id: { col: 'substitution_relation_id' }, + difference_item_id: { col: 'difference_item_id' }, + constraint_type: { col: 'constraint_type' }, + difference_status: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'difference_status', else: { col: 'difference_status' } }, + impact_statement: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'impact_statement', else: { col: 'impact_statement' } }, + evidence_note: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'evidence_note', else: { col: 'evidence_note' } }, + unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'unknown_disposition', else: { col: 'unknown_disposition' } }, + /* 派生列与两处创建笔共用同一份 when 链 —— 四态一变,判定分类与风险跟着变 */ + ...JUDGEMENT_DERIVED_FIELDS, + }, +} as const; export const ASSESSMENT_STEPS = [ { @@ -868,10 +928,13 @@ export const ASSESSMENT_STEPS = [ let: SHARED_LET, do: [ /* - * ①② **人打回时,把他的意见连同上一轮结论交回给 AI 重判**(2026-09-01 用户定)—— - * 只在被打回的那一轮跑(`case_redo_note != ""`),常路(第一次送审)这两笔跳过。 + * ①-④ **人打回那一轮:先圈出他点名的是哪几条,只把这几条交回给 AI 重判** + * (2026-09-01;第一版整表重判,当天被实测打脸 —— 见 `REDO_SCOPE_MARK` 上面那段头注)。 + * 常路(第一次送审)这四笔全跳过(`case_redo_note` 是空串),整单仍然只调一次模型。 * 判完落回明细,后面三笔重算照旧走:派生列跟着新四态变,等级、风险、验证记录跟着变。 */ + REDO_SCOPE_MARK, + REDO_SCOPE_WIDEN, REVIEW_REDO_ANALYZE, DIFFERENCE_ITEM_FROM_REDO, /* diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 2f3f758..46323e1 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -54,7 +54,7 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ - packageVersion: '2.17.0', + packageVersion: '2.18.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -529,6 +529,14 @@ export const manifest: ActivityManifest = { col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }), col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }), col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }), + /* + * **这一轮复核点到了哪几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论, + * 给我改了静态电流的结论」)—— 人打回时写的那句话里点了名的行标 `named`,其余 `kept`。 + * 谁可以变由这一列说了算(**行由确定的那一侧决定,不由 AI 决定** —— 规范 §4.6 纪律一): + * 只有 `named` 的行才进重判的入参、也只有它们会被写回;`kept` 的行 AI 连见都见不到。 + * 意见一条都没点到时另有一笔把整表标 `named`(那种意见说的是整批,不是某一条)。 + */ + col({ name: 'redo_scope', type: 'text', label: '本轮复核范围', tally: ['named', 'kept'] }), ], }, /* P-06 验证记录:一单一行,关联的是本单案号(P-01 不在本活动里,顶掉 request_id) */ diff --git a/apps/api/src/modules/task/__tests__/deliver-stale.test.ts b/apps/api/src/modules/task/__tests__/deliver-stale.test.ts index ec545c1..bc40491 100644 --- a/apps/api/src/modules/task/__tests__/deliver-stale.test.ts +++ b/apps/api/src/modules/task/__tests__/deliver-stale.test.ts @@ -11,13 +11,13 @@ import type { LandedFacts, Task } from '../contract.ts'; import { createTaskService } from '../internal/usecase/task-service.ts'; import type { TaskServiceDeps } from '../internal/usecase/task-service.ts'; -function harness(lastProgressAt: Date | undefined) { +function harness(lastProgressAt: Date | undefined, runtime: Task['runtime'] = 'awaiting-human') { const task: Task = { id: 't-1' as TaskId, activityId: 'w05', ownerId: 's-1' as StaffId, title: 'x', - runtime: 'awaiting-human', + runtime, envelope: {}, data: {}, caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 }, @@ -88,6 +88,29 @@ describe('迟到的人事件', () => { expect(arrival?.detail?.['stale']).toBe(true); }); + /* + * **判了 stale 还得管它有没有人叫醒**(2026-09-01,两次实测撞停):卡发出 270ms 后人就答了 —— + * 把 `lastProgressAt` 往前挪的那一拍,**正是发出这张卡的那一拍**,它不可能消费一条当时还不存在的回传; + * 而回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。 + * 于是卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它** —— 实测停了 15 分钟, + * 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完。 + * 这一拍照旧不跑(stale 的本意不变),但停着的单子要拨回 running 交给推进器。 + */ + it('**stale 但单子停着** → 这一拍照旧不跑,可要把它拨回 running,不然再没人叫醒它', async () => { + const h = harness(new Date('2026-08-27T09:48:00.200Z')); + const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT }); + expect(h.advanced()).toBe(0); + expect(h.onEvents()).toBe(0); + expect(out.task.runtime).toBe('running'); + }); + + it('stale 而单子本来就在跑 → 一个字不动(推进器自己会跑到它)', async () => { + const h = harness(new Date('2026-08-27T09:48:00.200Z'), 'running'); + const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT }); + expect(out.task.runtime).toBe('running'); + expect(h.advanced()).toBe(0); + }); + it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => { const h = harness(new Date('2026-08-27T09:47:40.000Z')); await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT }); diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts index c8c55eb..5dce2d1 100644 --- a/apps/api/src/modules/task/internal/usecase/task-service.ts +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -350,7 +350,26 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) }, }); if (stale) { + /* + * **判了 stale 还得管它有没有人叫醒** —— 不然就是把单子锁死(2026-09-01 实测两次: + * 卡发出 270ms 后人就答了,而**把 `lastProgressAt` 往前挪的那一拍,正是发出这张卡的那一拍** + * ——它不可能消费一条当时还不存在的回传。上面那条 stale 判据的前提在这种时序下不成立。 + * + * 更糟的是回传路由的 `wake` 也白做了:它先把任务拨成 `running`,而发卡那一拍**随后**收尾时 + * 又把它 park 回 `awaiting-event`(晚到的写覆盖了早到的 wake)。于是三样东西凑齐: + * 卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它**(实测停了 15 分钟, + * 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。 + * + * 这里只补最后一下:**不跑这一拍**(stale 的本意照旧 —— 这条回传不该再推一遍), + * 但把停着的单子拨回 `running`,交给推进器按它自己的节奏跑。真没事可干,它自己再停回去 + * (推进器那一拍会走「有卡在等人 → 只算不问模型」或直接原地返回,代价是一拍空转)。 + */ const held = await require(taskId); + if (held.terminal === undefined && (held.runtime === 'awaiting-event' || held.runtime === 'awaiting-human')) { + await deps.store.update(taskId, { runtime: 'running' }); + await deps.interaction.setPhase(taskId, 'active'); + return { task: await require(taskId), verdict: { kind: 'ok' }, progressed: false }; + } return { task: held, verdict: { kind: 'ok' }, progressed: false }; } diff --git a/docs/debt.md b/docs/debt.md index 627ac4c..c3785ab 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1721,7 +1721,14 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留 --- -## 人答得太快,那条回传被判 `stale`,单子就此没人叫醒(2026-08-31 夜,未修) +## ~~人答得太快,那条回传被判 `stale`,单子就此没人叫醒~~(2026-08-31 夜发现 → **2026-09-01 已修**) + +**已修**:`task-service.deliver` 判了 stale 之后**照旧不跑这一拍**(stale 的本意不变), +但把停着的单子拨回 `running` 交给推进器 —— 三样凑齐时(卡答完了、没有新卡在等、单子停着) +再没有第三样东西会叫醒它,补的就是最后这一下。真没事可干,它自己再停回去(代价一拍空转)。 +`deliver-stale.test.ts` 两条钉死(停着的拨回 running / 本来就在跑的一个字不动),变异验证过。 +下面是当时的诊断,原样留着 —— 指纹与病理都对。 + **实测指纹**(e2e w05 案例 0,ACT-WORK-05): @@ -1755,3 +1762,29 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留 **别去放宽 stale 守卫本身** —— 它治的是另一个真病(晚到十几秒的事件把没人答的卡收掉, 2026-08-27 实测),两件事不要混。 +--- + +## 审核卡上的「本轮结论」在重判之后会过期(2026-09-01,未修) + +**实测**(柴浩那一单 f14d94de):两张审核卡的 `本轮结论` 都写着 +「共 7 项核心参数:0 项一致 / 5 项硬约束不符 / 2 项软约束不符」,而第二张发出去的那一刻 +草稿里已经是 **1 项一致 / 5 硬 / 1 软**(重判把一条从 soft_mismatch 翻成了 match)。 +卡上那一行说的是上一轮的数。 + +**根因不是 bug,是两条正确规矩撞在一起**: +- 卡的 `本轮结论` 取自 `substitution.relation.difference_summary`; +- 而 S3 用的是 `RELATION_RECOMPUTE`(只重算 `recommendation_level`),**故意不碰**收口四格 + (推荐理由 / 差异摘要 / 风险 / 验证要求)—— 2026-08-31 用户定「这四格人可以就地改」, + 每跑一次就按规则重写会把人写的话冲掉。 + +于是:等级会跟着重判变(判据核心那条链没断),**摘要这句话不会**。 + +**要治**(方向,未验证): +- 卡面那一行改成**按基座读数当场拼**(不读 `difference_summary`)—— 但拼法与 + `RELATION_PROJECTION.difference_summary` 会成为两处真源,得先把拼法抽成一处; +- 或者给 `difference_summary` 一个「人动过没有」的记号:人没改过就随重判重写,改过就留着。 + 记号本身要是基座数得出来的(别又变成模型自报)。 + +**先别做的理由**:它只影响卡上那一句话,件上逐条明细与等级都是真的;而两种治法都要动 +「人可改的格子」这条已经踩过坑的边界(2026-08-31:「人改完一保存就被冲掉」),值得单独一轮。 + diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 70d629d..13b1077 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -709,6 +709,16 @@ const SUITES = { * 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。 */ expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, + /* + * **打回只许动被点名的那几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论, + * 给我改了静态电流的结论」)。这条意见点名的是「使能阈值」—— + * 交回之前先把明细存一份,收工时逐行比:名字没出现在意见里的行,四态与三段叙述一个字都不许变。 + */ + expectRedoTouchesOnlyNamed: { + address: 'substitution.difference_item', + nameCol: 'parameter_name', + cols: ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition'], + }, /* * **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立, * 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改 @@ -1339,6 +1349,10 @@ async function runCase(suite, kase) { let dataCardSeen = false; /** w05「都在库」:匹配**验收工单**那一段的执行卡出现过 —— 数据齐了也要叫人一次 */ let acceptCardSeen = false; + /** 打回之前的明细快照(`expectRedoTouchesOnlyNamed`)—— 只存第一份 */ + let redoBefore; + /** 放行前的最后一份明细快照 —— 放行会清空草稿层,收工时取不到 */ + let redoAfter; /** 那张卡的**面孔**不对(标题是验收工单,发出去却是执行工单)—— 2026-08-31 真撞过一次 */ let acceptFaceWrong = false; /** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */ @@ -1446,6 +1460,19 @@ async function runCase(suite, kase) { gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? '')); } + /* + * 打回之前把明细存一份(2026-09-01)—— 收工时拿它逐行比「没点名的行有没有被动过」。 + * 存在**答卡之前**:答完那一拍配方就重判了,再存就晚了。 + */ + if (kase.expectRedoTouchesOnlyNamed !== undefined) { + const rows = draftRows(body.drafts ?? [], kase.expectRedoTouchesOnlyNamed.address); + /* 第一份 = 打回之前(S2 刚判完那一版);此后每轮覆盖,**留住放行前的最后一份** —— + 放行会把草稿并进正式表并清空,收工时再取就什么都没有了(实测踩到) */ + if (rows.length > 0) { + if (redoBefore === undefined) redoBefore = rows.map((r) => ({ ...r })); + redoAfter = rows.map((r) => ({ ...r })); + } + } // 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的 decisions += await answerDecisions(taskId, kase, decided); await answerEntries(taskId, kase, entered); @@ -1678,6 +1705,30 @@ async function runCase(suite, kase) { console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败'); ok = false; } + if (kase.expectRedoTouchesOnlyNamed !== undefined) { + /* `address` 在轮询那一头取快照时用过,这儿只比对已经存下来的两份 */ + const { nameCol, cols } = kase.expectRedoTouchesOnlyNamed; + const note = (kase.decideSeq ?? []).find((d) => String(d).startsWith('custom:')) ?? ''; + const after = redoAfter ?? []; + if (redoBefore === undefined || redoBefore.length === 0 || after.length === 0) { + console.log(' ⚠️ 打回前后的明细没取到,「只动被点名的行」这条没验成 —— 判失败'); + ok = false; + } else { + const moved = []; + for (const row of after) { + const name = String(row[nameCol] ?? ''); + // 被点名的那几行本来就该变 + if (name === '' || note.includes(name)) continue; + const was = redoBefore.find((r) => String(r[nameCol] ?? '') === name); + if (was === undefined) continue; + for (const col of cols) if (String(was[col] ?? '') !== String(row[col] ?? '')) moved.push(`${name}.${col}`); + } + if (moved.length > 0) { + console.log(` ⚠️ 打回只点了名的那几条,没点名的行却被改了:${moved.join('、')} —— 判失败`); + ok = false; + } else console.log(' 打回只动了被点名的那几条,其余行一个字没变'); + } + } if (kase.expectAcceptCard && !acceptCardSeen) { console.log(' ⚠️ 数据齐了也该叫人一次(只读的验收工单),一张都没见到 —— 判失败(多半是 S1 又被拍首的 doneWhen 扫描收掉了)'); ok = false;