From 4d2c3657f9efc0d0b2ccbc423cb21586d93562db Mon Sep 17 00:00:00 2001 From: xcs Date: Tue, 1 Sep 2026 00:33:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(w05):=20=E9=87=8D=E5=81=9A=E7=9A=84?= =?UTF-8?q?=E6=84=8F=E8=A7=81=E4=BA=A4=E5=9B=9E=E7=BB=99=20AI=20=E9=87=8D?= =?UTF-8?q?=E8=B7=91=E9=82=A3=E4=B8=80=E6=AD=A5=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=98=AF=E6=8A=8A=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E5=88=97=E9=87=8D=E7=AE=97=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「唯一调用 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 Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- CLAUDE.md | 18 ++ .../__tests__/steps.test.ts | 50 ++++- .../src/case-model.ts | 204 ++++++++++++------ .../src/manifest.ts | 2 +- .../internal/domain/static-audit.ts | 6 + .../loop-engine/__tests__/auto-runner.test.ts | 57 +++++ .../internal/usecase/auto-runner.ts | 16 +- .../internal/usecase/turn-runner.ts | 2 + tools/e2e-a2.mjs | 8 +- 9 files changed, 284 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe7962b..0e3b310 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。 diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 1b6c4a5..7ae5940 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -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> => 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); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 52359fc..67af5d2 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -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` diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 5c162f7..2f3f758 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.16.0', + packageVersion: '2.17.0', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index 4111f5c..9b6b6ee 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -316,6 +316,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet { } // 决策卡回答读数(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]); diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index b8b29c2..97b1fbf 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -134,6 +134,12 @@ function harness(seed: Record = {}, 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' }); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index f0e731c..5df6f7e 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -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 = {}; - let scope: Record = { ...(await deps.data.view(ctx.taskId)), ...results }; + /* 人打回时写的那句话:没有就是空串(见 `AutoContext.redoNote` 的头注 —— 必须永远绑得上) */ + const redoNote = { case_redo_note: ctx.redoNote ?? '' }; + let scope: Record = { ...(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; } /* diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index 4a21977..acefe60 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -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) { /* diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index d388e2d..70d629d 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -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 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):