fix(w05): 复核轮审核人说了算,并把这一轮改了什么说进对话区

用户第二次实测:「重做还是没有修改掉内容」—— 他写「静态电流结论改成不匹配」,而两颗料的
静态电流都是 5mA,AI 搬出第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去:
evidence_note 白纸黑字「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。

**行的范围这次是对的**(只把那一条交给 AI、只写回那一条),错在**谁说了算**。三处想岔了:
① 那条规矩管的是它自己判的时候别被措辞带偏,不是用来否决审核人的;
② 方向也反了 —— 它守的是「不许把不满足说成满足」(危险方向),而他要的是收紧结论;
③ 更根本:审核人在件上本来就能亲手改任何一条四态,AI 在重判这条路上顶回去,
   等于同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— 他点名要什么结论就落什么,不要顶回去;落的同时 evidence_note
记两句「按谁的意见」+「数据面是什么」,下一个人回看能一眼分清数据判的还是人定的。

同批(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」):
复核完在对话区说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,摆的是件上那一行
自己的话,不另编一句。**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 say 要等
下一拍才跑:原来那句「差异结论已送审,请核对」实测正是在人答完之后才出现的;而且它与卡自己的
ask 本来就是同一件事,一并退役。

验收:e2e w05 5/5 released、被拒 0;案例 0 三条断言全绿 ——
「复核那一轮在对话区说了话」「打回点名要的结论真落下去了(使能阈值 = conditional)」
「打回只动了被点名的那几条」。包内 +2 测。W05@2.21.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-09-01 01:57:52 +08:00
co-authored by Claude Fable 5
parent cec19adc85
commit e474275b28
5 changed files with 141 additions and 10 deletions
+23
View File
@@ -1834,6 +1834,29 @@
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道 .dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。 对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
- **复核轮审核人说了算 —— AI 不许拿硬规矩顶回去**(2026-09-01 用户第二次实测:「重做还是没有
修改掉内容」):他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 于是搬出
第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去 ——
`evidence_note` 白纸黑字:「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。
**行的范围这次是对的**(只把那一条交给 AI、只写回那一条,其余六条一个字没动),错在**谁说了算**。
两处想岔了:①那条规矩管的是**它自己判**的时候别被措辞带偏,不是用来否决审核人的;
②方向也反了 —— 那条守的是「不许把不满足说成满足」(危险方向),而他要的是**收紧**结论;
③更根本的是:审核人在件上**本来就能亲手改任何一条四态**,AI 在重判这条路上顶回去,
只是让同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— **他点名要什么结论就落什么**(「不匹配」= mismatch、「有条件」=
conditional…,不要跟他讲道理、不要顶回去);落的同时 `evidence_note` 记两句
「按谁的意见」+「数据面是什么」(例:「按审核人意见调整为 mismatch:<原话>。数据面:两侧均为 5mA。」)
—— 下一个人回看时要能一眼分清**这是数据判出来的**还是**这是人定的**;
他只是提疑问 / 要补依据的,照旧由 AI 复核后自判。
e2e 案例 0 的打回意见同批换成**点名要结论**那一种,新断言逐字核那一行真变成了他要的值
(「打回点名要的结论真落下去了(使能阈值.difference_status = conditional)」)。
**同批把这一轮的交代摆进对话区**(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口
展示出来,不然都不知道发生了什么」):复核完说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,
摆的是**件上那一行自己的话**,不另编一句(对话区与件上说的是同一件事,就该是同一句话)。
**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 `say` 要等下一拍才跑:
原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask`
本来就是同一件事,一并退役。
- **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该 - **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该
有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串, 有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串,
不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次, 不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次,
@@ -406,8 +406,8 @@ describe('S3 / S4 配方', () => {
const s4 = stepOf('T_release')?.auto?.do ?? []; const s4 = stepOf('T_release')?.auto?.do ?? [];
const s2 = stepOf('S2_assess')?.auto?.do ?? []; const s2 = stepOf('S2_assess')?.auto?.do ?? [];
it('S3 九笔:圈范围 → 兜底扩范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 卡 → 交代', () => { it('S3 九笔:圈范围 → 兜底扩范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 交代 → 卡', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'analyze', 'project', 'project', 'project', 'project', 'card', 'say']); expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'analyze', 'project', 'project', 'project', 'project', 'say', 'card']);
for (const at of [0, 1, 3, 4]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item'); 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[5]?.kind === 'project' && s3[5].into).toBe('substitution.relation');
expect(s3[6]?.kind === 'project' && s3[6].into).toBe('validation.record'); expect(s3[6]?.kind === 'project' && s3[6].into).toBe('validation.record');
@@ -457,6 +457,19 @@ describe('S3 / S4 配方', () => {
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */ /* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
const rules = s2[0]?.kind === 'analyze' ? s2[0].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.startsWith(rules)).toBe(true);
/*
* **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)——
* 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条
* 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏;
* 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。
* 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。
*/
const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : '';
expect(tail).toContain('这一轮不归它管');
expect(tail).toContain('他点名要什么结论,就落什么');
expect(tail).toContain('不要顶回去');
/* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */
expect(tail).toContain('数据面');
/* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */ /* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */
const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {}; const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {};
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) { for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
@@ -549,8 +562,31 @@ describe('S3 / S4 配方', () => {
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let); expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
}); });
/*
* **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,
* 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑,
* 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。
* 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的
* `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。
*/
it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => {
const kinds = s3.map((a) => a.kind);
/* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */
expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card'));
/* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */
const redoSay = s3[7];
expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != ""');
const text = redoSay?.kind === 'say' ? redoSay.text : undefined;
const parts = JSON.stringify(text);
expect(parts).toContain('按你的意见复核了');
/* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */
expect(parts).toContain('evidence_note');
expect(parts).toContain('parameter_name');
expect(parts).toContain('substitution_difference_item_redo_scope_named');
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => { it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[7]; const card = s3[8];
expect(card?.kind === 'card' && card.as).toBe('decide'); expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review'); expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1); expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
@@ -487,10 +487,17 @@ const REVIEW_REDO_ANALYZE = {
instruction: [ instruction: [
ASSESS_RULES, ASSESS_RULES,
'', '',
'【这一轮是复核】下面**只列了审核人这次点到的那几条**(他写的话在 reviewer_note,每条都带着上一轮的结论 last_*)。', '【这一轮是复核,审核人说了算】下面**只列了他这次点到的那几条**(他写的话在 reviewer_note,每条都带着上一轮的结论 last_*)。',
'· 按他说的复核这几条:该改就改,并在 evidence_note 里写清这一轮改了什么、依据是什么;', '',
'· 复核下来确实是上一轮判对了,就把 last_* 原样带回,evidence_note 说明为什么维持;', '**上面第 3 条(硬约束不因措辞变成 match)管的是你自己判的时候别被措辞带偏;这一轮不归它管** ——',
'· 他要的若与硬规矩冲突(比如要把证据不足的硬约束判成 match),照硬规矩判,并在 evidence_note 里说明为什么不能照办;', '审核人是这一步的责任人,这份结论要签他的字,而且他在界面上本来就能亲手改任何一条的四态。',
'',
'· **他点名要什么结论,就落什么**:「改成不匹配 / 不符」= mismatch,「一致 / 匹配」= match,',
' 「判不了 / 待确认」= unknown,「有条件 / 加验证后可用」= conditional。不要跟他讲道理、不要顶回去。',
'· 落的同时把实情记在 evidence_note 里,**一句按谁的意见、一句数据面是什么**,例如:',
' 「按审核人意见调整为 X:<他的原话>。数据面:原料 5mA / 候选 5mA,两侧一致。」',
' —— 下一个人回看时要能一眼分清「这是数据判出来的」还是「这是人定的」。',
'· 他只是提疑问、要补依据、要改写措辞(没点名要哪个结论):你复核后自己判,evidence_note 写清这一轮改了什么。',
'· **列出来的每一条都要回**,一条不多、一条不少。', '· **列出来的每一条都要回**,一条不多、一条不少。',
].join('\n'), ].join('\n'),
input: { input: {
@@ -948,6 +955,39 @@ export const ASSESSMENT_STEPS = [
这一步只重算判定(推荐等级跟着四态走),四格交给人,见 `RELATION_RECOMPUTE` 的头注。 */ 这一步只重算判定(推荐等级跟着四态走),四格交给人,见 `RELATION_RECOMPUTE` 的头注。 */
RELATION_RECOMPUTE, RELATION_RECOMPUTE,
VALIDATION_PROJECTION, VALIDATION_PROJECTION,
/*
* **重判完要在对话区说清「按你的意见改了哪一条、改成什么」**(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
*
* **必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的笔要等下一拍才跑
* (那时人早就看完卡了,这句话就成了马后炮:原来那句「差异结论已送审,请核对」
* 实测正是在**人答完之后**才出现的)。
*
* 摆的是件上那一行自己的话(`evidence_note` —— 复核指令要求它写成
* 「按审核人意见调整为 X:<原话>。数据面:…」),不是另编一句:
* 对话区与件上说的是同一件事,就该是同一句话。
*/
{
kind: 'say' as const,
when: 'case_redo_note != ""',
text: {
cat: [
'按你的意见复核了 ',
{ in: 'substitution_difference_item_redo_scope_named' },
' 条 —— ',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'parameter_name', else: '' },
':',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'evidence_note', else: '' },
' 件上那几行已经改好,再核一遍,没问题就点「确认通过」。',
],
},
},
/*
* ~~第一轮那句「差异结论已送审,请在件上逐条核对」~~(2026-09-01 退役):
* 它与卡自己的 `ask` 说的是同一件事(「逐条差异结论已备好。请在工作区的件上逐条核对…」)——
* 摆两遍是复读。而且它排在卡后面,要等下一拍才跑:实测那句话出现在**人答完之后**。
* 打回那一轮不同 —— 那时有新东西要交代(改了哪一条、改成什么),所以只留上面那一句。
*/
{ {
kind: 'card' as const, kind: 'card' as const,
as: 'decide' as const, as: 'decide' as const,
@@ -961,7 +1001,8 @@ export const ASSESSMENT_STEPS = [
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } }, { k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
], ],
}, },
{ kind: 'say' as const, text: '差异结论已送审,请在件上逐条核对。' }, /* ~~卡后面那一句 `say`~~(2026-09-01 退役):发了卡这一拍就收了,它要等下一拍才跑 ——
实测「差异结论已送审,请在件上逐条核对」出现在**人答完之后**。两句交代都提到卡之前去了。 */
/* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */ /* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */
] as const, ] as const,
}, },
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估', name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.18.0', packageVersion: '2.21.0',
category: '需求分析与方案匹配', category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment', checksum: 'dev-checksum-part-substitution-assessment',
+32 -1
View File
@@ -637,7 +637,20 @@ const SUITES = {
sample: 0, sample: 0,
name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)' name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)'
+ '· 审核先点名意见驳回再通过', + '· 审核先点名意见驳回再通过',
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'], /*
* **点名要一个结论**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」——
* 他写「静态电流结论改成不匹配」,AI 搬出「硬约束不因措辞改判」把他顶了回去)。
* 审核人是这一步的责任人:他点名要什么结论就该落什么。这条意见既点了名(使能阈值)
* 又点了结论(conditional),`expectRedoSetsStatus` 收工时逐字核。
*/
decideSeq: ['custom:使能阈值那条不是判不了,改成 conditional(有条件)—— 补一次实测就能用', 'approve'],
expectRedoSetsStatus: { address: 'substitution.difference_item', nameCol: 'parameter_name', param: '使能阈值', column: 'difference_status', equals: 'conditional' },
/*
* 复核完得在对话区说清「按你的意见改了哪一条、改成什么」(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
* 那句话必须**排在卡之前**才赶得上人看卡 —— 排在卡后面的 say 要等下一拍才跑。
*/
expectRedoNarration: '按你的意见复核了',
expectDecision: true, expectDecision: true,
expectPort: 'assessment-released', expectPort: 'assessment-released',
/* /*
@@ -1705,6 +1718,24 @@ async function runCase(suite, kase) {
console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败'); console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败');
ok = false; ok = false;
} }
if (kase.expectRedoNarration !== undefined) {
const said = (last?.trail ?? []).some((e) => String(e.title ?? '').includes(kase.expectRedoNarration));
if (said) console.log(` 复核那一轮在对话区说了话(「${kase.expectRedoNarration}…」)`);
else {
console.log(` ⚠️ 复核完对话区一个字没说(找不到「${kase.expectRedoNarration}」)—— 人不知道发生了什么,判失败`);
ok = false;
}
}
if (kase.expectRedoSetsStatus !== undefined) {
const { address, nameCol, param, column, equals } = kase.expectRedoSetsStatus;
const row = (redoAfter ?? draftRows(last?.drafts ?? [], address)).find((r) => String(r[nameCol] ?? '') === param);
const got = String(row?.[column] ?? '');
if (got === equals) console.log(` 打回点名要的结论真落下去了(${param}.${column} = ${equals})`);
else {
console.log(` ⚠️ 打回点名要「${equals}」,${param}.${column} 却是「${got || '(没取到)'}」—— 判失败(AI 又把审核人顶回去了?)`);
ok = false;
}
}
if (kase.expectRedoTouchesOnlyNamed !== undefined) { if (kase.expectRedoTouchesOnlyNamed !== undefined) {
/* `address` 在轮询那一头取快照时用过,这儿只比对已经存下来的两份 */ /* `address` 在轮询那一头取快照时用过,这儿只比对已经存下来的两份 */
const { nameCol, cols } = kase.expectRedoTouchesOnlyNamed; const { nameCol, cols } = kase.expectRedoTouchesOnlyNamed;