feat(w05): 重做的意见交回给 AI 重跑那一步 —— 不再只是把派生列重算一遍

用户:「唯一调用 ai 的这一步,使用重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」
确实没有:S3 那一轮只跑三笔纯投影重算,人写的那句话除了留痕之外无人读 ——
AI 判过的四态一个字不会变,除非他自己在件上逐格改。

平台:引擎把「人这次打回写了什么」注给配方(`case_redo_note`)。
· 下划线不是 case: 前缀 —— S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 when 条件用;
· **没打回也永远绑着空串**:未绑定的名字 `!= ""` 求值为真,不绑就等于那几笔每一轮都跑
  (拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住);
· 静态审认这个名字 —— 它当场拦下过一次未登记的引用,这道门做对了事。

W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 ——
带意见重判(入参多带上一轮四态/影响/依据/UNKNOWN 去向 + 他那句话;判法与第一轮同一份
ASSESS_RULES,后接复核纪律:没被点到、也确实没判错的行原样带回,别趁重判把人改过的冲掉)
+ 判完落回明细(与第一轮同一笔投影,只多一个 when)。判法、出参形状、落明细抽成三个
共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。

验收:e2e w05 5/5 released、被拒 0;案例 0(先点名驳回、再通过)模型调用恰 2 次,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。auto-runner 3 测(含变异验证)、包内 +1。

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 00:33:23 +08:00
co-authored by Claude Fable 5
parent f5435f4c9c
commit 4d2c3657f9
9 changed files with 284 additions and 79 deletions
+18
View File
@@ -1834,6 +1834,24 @@
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
这道门做对了事)。
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
@@ -406,11 +406,41 @@ 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(['project', 'project', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation');
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record');
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');
});
/*
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
*
* 只在被打回的那一轮跑:`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 != ""');
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
const first = s2[0];
const rules = first?.kind === 'analyze' ? first.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 : {});
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
expect(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);
});
/*
@@ -420,7 +450,7 @@ describe('S3 / S4 配方', () => {
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[0];
const recompute = s3[2];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
@@ -438,7 +468,7 @@ describe('S3 / S4 配方', () => {
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
const s3Fields = s3[2]?.kind === 'project' ? s3[2].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']);
@@ -459,7 +489,7 @@ describe('S3 / S4 配方', () => {
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
*/
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
const relation = s3[1];
const relation = s3[3];
expect(relation?.kind).toBe('project');
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
act?.kind === 'project' ? act.fields : {};
@@ -474,7 +504,7 @@ describe('S3 / S4 配方', () => {
/* 主键带回:同 PK 覆盖,不是新开一行 */
expect(fields['substitution_relation_id']).toBeDefined();
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
expect(s3[2]).toBe(s2[4]);
expect(s3[4]).toBe(s2[4]);
});
/*
@@ -490,7 +520,7 @@ describe('S3 / S4 配方', () => {
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[3];
const card = s3[5];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
@@ -349,6 +349,136 @@ const GATE_OPEN_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';
/**
* **逐参数差异判定的规矩** —— S2 第一轮判、S3 被人打回后复核,**两处共用同一份**:
* 各写一份早晚分叉,而分叉的那天,屏上会同时摆着两套判法判出来的结论。
*/
const ASSESS_RULES = [
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n');
/** 判定出参的形状 —— 同上,两处共用一份 */
const ASSESS_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' },
},
},
},
},
} as const;
/**
* **AI 判完之后落明细的那一笔** —— S2 第一轮、S3 被人打回后的复核轮,**共用同一份**。
* 行由字典 `each` 出来,AI 的出参只是拿 `param_name` 去查的一张表:它漏一条,那一行的四态
* 就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住(从前 `from each(AI 出参)`,
* 它少给两条就少两行,**而每一行看着都合规**)。
*/
const DIFFERENCE_ITEM_FROM_RESULT = {
kind: 'project' as const,
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 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
} as const;
/**
* **人把结论打回时,意见要交回给 AI 重判 —— 这是整个活动第二次(也是最后一次)出场**
* (2026-09-01 用户定:「重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」——
* 当时确实没有:S3 那一轮只把派生列重算一遍,人写的那句话除了留痕之外无人读)。
*
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的「人这次打回写了什么」
* (没打回就是空串,永远绑得上 —— 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。
*
* **入参比第一轮多两样**:①上一轮的结论(四态 / 影响 / 依据 / UNKNOWN 去向)——
* 人可能已经就地改过几行,那也是「上一轮结论」的一部分;②他这次写的意见。
* 判法本身与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律:
* **没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉。
*/
const REVIEW_REDO_ANALYZE = {
kind: 'analyze' as const,
when: 'case_redo_note != ""',
model: 'deepseek-v4-flash',
into: 'items',
instruction: [
ASSESS_RULES,
'',
'【这一轮是复核,不是从头判】上一轮的结论已经摆在每一条的 last_* 里,审核人写下的意见在 reviewer_note。',
'· 意见点到的那几条:按他说的复核,该改就改,并在 evidence_note 里写清这一轮改了什么、依据是什么;',
'· **意见没点到、你也确实没判错的**:把 last_* 原样带回来 —— 人可能已经在件上亲手改过几行,重判不是把它们冲掉的机会;',
'· 他说的若与硬规矩冲突(比如要把证据不足的硬约束判成 match),照硬规矩judge,并在 evidence_note 里说明为什么不能照办。',
].join('\n'),
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
param_name: { col: 'param_name' },
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: '' },
/* 上一轮的结论 —— 含人就地改过的那几行(读的是本单草稿的当下值) */
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: '' },
/* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状),指令里说明它是整批的意见 */
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;
export const ASSESSMENT_STEPS = [
{
item: 'S1_inputs' as const,
@@ -648,26 +778,7 @@ export const ASSESSMENT_STEPS = [
kind: 'analyze' as const,
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'),
instruction: ASSESS_RULES,
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
@@ -682,26 +793,7 @@ export const ASSESSMENT_STEPS = [
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' },
},
},
},
},
},
output: ASSESS_OUTPUT,
},
/*
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
@@ -730,30 +822,7 @@ export const ASSESSMENT_STEPS = [
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
*/
{
kind: 'project' as const,
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 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
},
DIFFERENCE_ITEM_FROM_RESULT,
RELATION_PROJECTION,
VALIDATION_PROJECTION,
{ kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
@@ -798,6 +867,13 @@ export const ASSESSMENT_STEPS = [
*/
let: SHARED_LET,
do: [
/*
* ①② **人打回时,把他的意见连同上一轮结论交回给 AI 重判**(2026-09-01 用户定)——
* 只在被打回的那一轮跑(`case_redo_note != ""`),常路(第一次送审)这两笔跳过。
* 判完落回明细,后面三笔重算照旧走:派生列跟着新四态变,等级、风险、验证记录跟着变。
*/
REVIEW_REDO_ANALYZE,
DIFFERENCE_ITEM_FROM_REDO,
/*
* **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有
* 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE`
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.16.0',
packageVersion: '2.17.0',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -316,6 +316,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
}
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
/*
* **人把这一步打回重做时写下的那句话**(2026-09-01)—— 引擎每一拍都注给配方(没打回就是空串,
* 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。活动据它决定「这一轮要不要把意见交回给 AI 重判」。
* 名字用下划线而不是 `case:` 前缀:S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 `when` 条件用。
*/
out.add('case_redo_note');
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
@@ -134,6 +134,12 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
if (expr === 'never') return false;
if (expr === 'always') return true;
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
/*
* 人打回时写的那句话:空串 = 没打回(2026-09-01)。**照真 S-FEEL 的语义写**——
* 未绑定的名字 `!= ""` 求值为 **true**(见本 describe 里那条直接拿 @sfeel 验的用例);
* 桩若在这儿手滑写成「没有就当空串」,「必须永远绑上」这条就永远钉不住。
*/
if (expr === 'case_redo_note != ""') return !('case_redo_note' in scope) || String(scope['case_redo_note']) !== '';
throw new Error(`没教过这句:${expr}`);
},
expression: () => undefined,
@@ -383,6 +389,57 @@ describe('analyze 动词', () => {
});
});
/*
* **人打回时写的那句话要到得了配方**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 配方据它决定这一轮要不要把意见交回给 AI 重判。
* 名字是 `case_redo_note`(下划线,不是 `case:` 前缀):S-FEEL 的名字不许带冒号,
* 而这句话最要紧的用法恰恰是当 `when` 条件用。
*/
describe('case_redo_note —— 人打回写的那句话', () => {
const ROW = { kind: 'project' as const, into: 'sub_eval.part', from: { once: true as const } };
it('打回了:那句话进作用域,`when` 与 `{in:}` 都读得到', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0', note: { in: 'case_redo_note' } } }],
},
{ ...ctx, redoNote: '使能阈值那条请写明验证任务号' },
);
expect(out.wrote).toBe(1);
expect([...drafts.values()][0]?.patch['note']).toBe('使能阈值那条请写明验证任务号');
});
it('没打回:这个名字**仍然绑着**(空串)—— 只在被打回时才跑的那几笔因此关得住', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [
{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'REDO-ONLY', role: 'x' } },
{ ...ROW, fields: { eval_ref: 'W05-1', internal_pn: 'ALWAYS', note: { in: 'case_redo_note' } } },
],
},
ctx,
);
/* 第一笔被 when 挡住(绑了空串);第二笔照跑,那句话取出来是空串 */
expect(out.wrote).toBe(1);
const rows = [...drafts.values()];
expect(rows.map((d) => d.key['internal_pn'])).toEqual(['ALWAYS']);
expect(rows[0]?.patch['note']).toBe('');
});
/*
* **为什么非绑不可**——用**真** S-FEEL(不是本文件顶部那个只认几句话的桩)把两种下场摆出来:
* 不绑,「只在被打回时才跑」的那几笔**每一轮都会跑**(未绑定的名字 `!= ""` 求值为真);
* 绑上空串才关得住。本仓踩过同一个坑不止一次(S1 的 `doneWhen`、`gate_reason` 那条)。
*/
it('不绑的下场:真 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true**', () => {
expect(evaluateCondition('case_redo_note != ""', {})).toBe(true);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '' })).toBe(false);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '有意见' })).toBe(true);
});
});
describe('card 与 submit', () => {
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
@@ -43,6 +43,16 @@ export type AutoContext = {
readonly quiet: boolean;
/** 这一步已经发过卡(worldTouched) */
readonly carded: boolean;
/**
* **人把这一步打回重做时写下的那句话**(2026-08-31 用户定:「重做后要把原因带回去,
* 让 AI 重跑这一步」)—— 没有打回就是空串,**永远绑得上**:S-FEEL 里未绑定的名字参与
* 比较会翻车(`!= ""` 求值为真),那正好会让「只在被打回时才跑」的那几笔每一轮都跑。
*
* 配方里的名字是 **`case_redo_note`**(下划线,不是 `case:` 前缀)—— S-FEEL 的名字只认
* `[A-Za-z0-9_一-龥]`(`packages/sfeel/src/lexer.ts` 的 NAME_PART),带冒号的名字
* 写不进 `when` 条件;而这句话最要紧的用法恰恰是当条件用。
*/
readonly redoNote?: string | undefined;
};
export type AutoOutcome = {
@@ -98,7 +108,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
async run(recipe, ctx) {
const writable = await deps.data.writable(ctx.taskId);
const results: Record<string, unknown[]> = {};
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
/* 人打回时写的那句话:没有就是空串(见 `AutoContext.redoNote` 的头注 —— 必须永远绑得上) */
const redoNote = { case_redo_note: ctx.redoNote ?? '' };
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
let dirty = false;
let wrote = 0;
let dropped = 0;
@@ -391,7 +403,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
for (const [index, action] of recipe.do.entries()) {
try {
if (dirty) {
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
scope = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
dirty = false;
}
/*
@@ -2102,6 +2102,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
quiet: false,
carded: touchedItem && settled,
/* 打回那句话交给配方(`case_redo_note`)—— 活动据它决定这一轮要不要把意见交回给 AI 重跑 */
...(settled || answer?.chose === undefined ? {} : { redoNote: answer.chose.startsWith('custom:') ? answer.chose.slice(7).trim() : answer.chose }),
});
if (auto.failedAt !== undefined) {
/*
+6 -2
View File
@@ -640,8 +640,12 @@ const SUITES = {
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
expectDecision: true,
expectPort: 'assessment-released',
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
expectAnalyzeCalls: 1,
/*
* **两次**:S2 第一轮判 + S3 被打回那一轮的复核重判(2026-09-01 用户定:
* 「重做后要把原因带回去,重新让 AI 重跑这一步」)。这条案例正是「先点名驳回、再通过」——
* 它同时钉住:意见真交回给了 AI(不是只把派生列重算一遍),而没被打回的案例仍然只调一次。
*/
expectAnalyzeCalls: 2,
/*
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):