diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 03fed29..4f0b99a 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -50,10 +50,19 @@ describe('清单本身合规', () => { }); /* 2026-08-29 重写:spec §7 的判据表从九条改成八条(五条机检 L1/L2 + 两条 L4 裁量 + 一条 L5),L4 从三条收到两条 —— 证据链与硬约束两条留 L4,「推荐等级与明细一致」已升级成 L2 机检(level-by-rule) */ - it('L1/L2 判据都给了谓词;L4 两条不给(裁量项);L5 恰一条', () => { + /* + * 2026-08-30:两条 L4 从「裸的(无 predicate)」改成「带谓词」——真跑 e2e 撞见的结构性 + * 发现:T_release 全程 auto 驱动,`submit` 是五个声明式动词之一,没有第六个 `judge`; + * 裸 L4 只能靠 `judgement`(Agent 的 judge 行动)结算,而这个活动没有 Agent 会走到这一步, + * `submittable()` 于是永远读到 pass:false,`raiseAcceptance` 每一拍都拒、不算错误、 + * 下一拍原样重来,稳定烧光步数预算到 exhausted。谓词只覆盖客观前提(有没有值、 + * 值哪来的),主观那一半已经由 S3 人工验收清单接住(`engines.ts` 的 `criterionCheck`: + * 「裁量项带谓词时谓词说了算」,本仓 2026-08-02 就立的纪律,W05 这两条当时没跟上)。 + */ + it('L1/L2/L4 判据都给了谓词(L4 走「谓词为真即达标」那条路,主观部分交人工验收清单接住);L5 恰一条', () => { for (const c of manifest.contract.acceptanceCriteria) { - if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined(); - if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined(); + if (c.level === 'L1' || c.level === 'L2' || c.level === 'L4') expect(c.predicate, c.key).toBeDefined(); + if (c.level === 'L5') expect(c.predicate, c.key).toBeUndefined(); } expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4')).toHaveLength(2); expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 65d5c46..6e6faf7 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -146,13 +146,41 @@ describe('S1 配方', () => { * 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` * ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。 */ - it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => { - expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']); + it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => { + expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']); expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([ - 'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case', + 'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case', ]); }); + /* + * 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」 + * 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行, + * 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。 + * + * **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的):这两张表在 ⑦ 的 + * `gateLedgers` 上,一旦人交回数据工单就随「数据工单确认即入库」落进正式表——已落地的行 + * `draftDrop`(`prune` 的撤行机制)撤不掉,配方原样抛错。字典类的表允许历史选型留在 + * 草稿层(不算错),不像 `parts.profile`/`parts.parameter` 那几笔(它们仍然 `prune:true`, + * 见下面「主数据骨架」那笔)必须清掉不再相关的陈旧行。 + */ + it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(不带 prune)', () => { + const chipType = acts[1]; + expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type'); + expect(chipType?.kind === 'project' && chipType.prune).toBeUndefined(); + expect(chipType?.kind === 'project' && chipType.from).toMatchObject({ + each: 'mine:sub_eval.part', + then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, + }); + const typeParam = acts[2]; + expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param'); + expect(typeParam?.kind === 'project' && typeParam.prune).toBeUndefined(); + expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({ + each: 'mine:sub_eval.part', + then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, + }); + }); + it('案号由 let 铸,只从本单开案时刻取材', () => { expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] }); }); @@ -167,7 +195,7 @@ describe('S1 配方', () => { }); it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { - const params = acts[2]; + const params = acts[4]; expect(params?.kind === 'project' && params.prune).toBe(true); const from = params?.kind === 'project' ? params.from : undefined; /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */ @@ -177,7 +205,7 @@ describe('S1 配方', () => { }); it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => { - const card = acts[4]; + const card = acts[6]; const when = card?.kind === 'card' ? (card.when ?? '') : ''; /* * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、 @@ -189,26 +217,25 @@ describe('S1 配方', () => { }); /* - * 2026-08-29 评审 Critical(Task 7 走查):`gateLedgers` 一度带着 `parts.chip_type`, - * 而 `input-supplement` 这张件没有任何 `data-edit="parts.chip_type"`(选类型走 - * `data-options` 下拉现读,不写这张表)—— `mine:parts.chip_type` 永远是空数组, - * `desk.ts` 的 `emptyGate` 会永远判「这一单还一行都没有」,提交钮永久按不下去。 - * **`parts.chip_type` 永久不列进这张卡的闸**(不是等新建类型的编辑表补上就该还原 —— - * 见 `case-model.ts` 该处注释:主路是从下拉挑一个已有类型,那时这张表天然零行, - * `emptyGate`「这张卡就是来建它的」这条语义对一张只是有时候才写的表根本不成立)。 + * 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意): + * 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然 + * 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。 + * 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表, + * `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地, + * 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用 + * (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。 */ - it('卡:数据工单、三张真被这张件写过的表在闸上(parts.chip_type 不在——它没有编辑手段,列了就永久卡死)、且有 basis(基座拒发 basis 为空的工单)', () => { - const card = acts[4]; + it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => { + const card = acts[6]; expect(card?.kind === 'card' && card.as).toBe('execution'); expect(card?.kind === 'card' && card.variant).toBe('data'); - expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.type_param', 'parts.profile', 'parts.parameter']); - expect((card?.kind === 'card' ? card.gateLedgers ?? [] : [])).not.toContain('parts.chip_type'); + expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']); expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0); expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3); }); it('发卡条件与 doneWhen 是同一件事的正反面', () => { - const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : ''; + const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : ''; for (const reading of [ 'sub_eval_part_exists_status_found', 'sub_eval_part_missing', 'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing', @@ -223,13 +250,17 @@ describe('S2 配方 —— AI 只出场一次', () => { const recipe = stepOf('S2_assess')?.auto; const acts = recipe?.do ?? []; - it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => { - expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']); + it('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => { + expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']); expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1); + /* 两次 project 到 substitution.relation 是同一个引用(RELATION_PROJECTION),不是抄了两份 */ + expect(acts[1]).toBe(acts[3]); + expect(acts[1]?.kind === 'project' && acts[1].into).toBe('substitution.relation'); + expect(acts[2]?.kind === 'project' && acts[2].into).toBe('substitution.difference_item'); }); it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => { - const items = acts[1]; + const items = acts[2]; expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' }); /* * **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)—— @@ -247,7 +278,7 @@ describe('S2 配方 —— AI 只出场一次', () => { }); it('**值不取 AI 出参,一律 lookup 台账**', () => { - const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined; + const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined; for (const col of ['original_value', 'candidate_value'] as const) { expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' }); } @@ -256,7 +287,7 @@ describe('S2 配方 —— AI 只出场一次', () => { }); it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => { - const fields = acts[1]?.kind === 'project' ? acts[1].fields : undefined; + const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined; const jc = JSON.stringify(fields?.['judgement_class']); expect(jc).toContain('hard_mismatch'); expect(jc).toContain('soft_mismatch'); @@ -265,7 +296,8 @@ describe('S2 配方 —— AI 只出场一次', () => { }); it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => { - const rel = acts[2]?.kind === 'project' ? acts[2].fields : undefined; + // acts[1] 与 acts[3] 是同一个 RELATION_PROJECTION 引用,查哪个都一样 + const rel = acts[1]?.kind === 'project' ? acts[1].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'); @@ -274,7 +306,7 @@ describe('S2 配方 —— AI 只出场一次', () => { }); it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => { - const val = acts[3]; + const val = acts[4]; 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 : undefined; @@ -320,8 +352,10 @@ describe('S3 / S4 配方', () => { }); it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => { - expect(s3[0]).toBe(s2[2]); - expect(s3[1]).toBe(s2[3]); + // S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)—— + // 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次) + expect(s3[0]).toBe(s2[3]); + expect(s3[1]).toBe(s2[4]); }); /* diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index a89b6cb..9bf59fb 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -241,10 +241,10 @@ export const ASSESSMENT_STEPS = [ intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补', guide: '', /* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值), - 与 auto.do 第 ⑤ 笔 `card` 的 `gateLedgers` 同源(parts.type_param / parts.profile / parts.parameter —— - `parts.chip_type` 不列入,理由见那笔 `card` 定义处的注释)。 */ + 与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param / + parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */ view: 'input-supplement', - writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'], + writes: ['sub_eval.part', 'sub_eval.case', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'], tools: [], doneWhen: @@ -278,7 +278,82 @@ export const ASSESSMENT_STEPS = [ }, }, /* - * ② 主数据骨架:**两颗料都铺、值回填现值**。 + * ② **选中的那一行类型字典,原样投一遍**(2026-08-30,Ruling H35 —— 真跑 e2e 撞见的 + * 平台侧连带问题:「数据工单确认即入库」按 `gateLedgers` 整表并入正式表,而 + * `parts.chip_type` 从 Task 7 起就没列在 `gateLedgers` 里 —— 人一旦真的新建一个类型, + * `parts.profile`/`parts.type_param` 引用着它一起落地,**字典行本身**却只留在这一单 + * 草稿里、从没落地过。下一个任务读到同一颗料,查这个 `chip_type_id` 时字典里空空如也, + * `refersTo` 当场拒——是一条悬空引用,不是「查无此值」那种输入错误)。 + * + * **这一笔不看「新建还是已有」,统统投一遍**(不选型的选也一样、新建的选也一样): + * 读的是 `sub_eval.part` 每一行当下的 `chip_type_id`(空串时下面这层 `each` 天然查 + * 不到、不产生任何行 —— 人还没选类型的那颗不会被提前铺出假行),按它去 + * `parts.chip_type` 里找到那一行、原样把 `type_name`/`description` 抄回自己身上。 + * 值没变,`draftWrite` 同 PK 覆盖静默跳过(幂等,不产生多余的写入痕迹)—— + * 但**这一单的草稿层从此真的有这一行**,`mine:parts.chip_type` 不再天然为空。 + * + * 这条把 `parts.chip_type` 重新放回下面 ⑥ 的 `gateLedgers` 铺平了路:闸不会再拿 + * 「零行」的旧理由误拦(那个前提不成立了),而选中的类型(新建的也好、已有的也好) + * 会随「数据工单确认入库」一起落进正式表 —— 不再留悬空引用。 + */ + { + kind: 'project' as const, + into: 'parts.chip_type', + /* + * **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的第二个坑)—— + * `parts.chip_type` 在 ⑦ 的 `gateLedgers` 上,人交回数据工单那一刻这张表就跟着 + * 「数据工单确认即入库」落进正式表(`landsOnConfirm`);已落地的行 `draftDrop` + * (`prune` 的撤行机制)撤不掉,配方原样抛 `AutoRecipeError`(原文:「这一行是本单 + * 在数据工单里建好、人确认后已入库的,撤不掉」)。案例 2(类型不一致·硬拦)第一次 + * 提交时 LM2596S-5.0 还在 type_id=1、随首次确认落地;人接着把它改挂到 type_2 后, + * 这一笔重投只剩 type_2 一行,`prune:true` 想把已经落地的 type_1 那行撤掉 —— 直接炸。 + * 去掉 `prune` 就不会再触发这条撤行:陈旧的选型历史留在草稿层不是错误(字典行本就 + * 该积累、不该因为这一单后来改了主意就被追溯撤销),`parts.profile`/`parts.parameter` + * 那几笔**仍然** `prune:true`(它们是这一单自己的骨架,换料/换类型确实要清旧行, + * 且它们不会像字典这样跨料共享、也不会被单独确认入库)。 + */ + from: { + each: 'mine:sub_eval.part', + then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } }, + }, + fields: { + type_id: { col: 'type_id' }, + type_name: { col: 'type_name' }, + description: { col: 'description' }, + }, + }, + /* + * ③ **选中类型的核心参数字段清单,同一套道理原样投一遍**(2026-08-30,与 ② 同一次 + * 发现、同一个技法):`parts.type_param` 只有人在件上「新建类型时加字段」才会写它 —— + * 挑一个已有类型时,这张表天然一行都没有,而它此刻仍在 ⑥ 的 `gateLedgers` 上, + * 「零行 = 没干完」的闸对这条路一样是误判(2026-08-30 真跑 e2e 五案例实测坐实: + * 「归到已有类型」的候选缺库 / 类型不一致 / 原PN缺库 / 两颗都缺库四例,全部卡死在 + * 同一条「芯片类型核心参数字段 这一单还一行都没有」)。 + * + * 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单 + * 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**: + * 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍 + * 原样命中、照样幂等跳过,不会覆盖人刚填的内容。`prune:true` 清掉的只是「先前选了 + * 某个类型、后来改选了另一个」时那些不再相关的陈旧行。 + */ + { + kind: 'project' as const, + into: 'parts.type_param', + /* 同上一笔同一个理由,同一次修正 —— 见 ② 那笔头上的大注释,不重复贴 */ + from: { + each: 'mine:sub_eval.part', + then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, + }, + fields: { + type_id: { col: 'type_id' }, + param_name: { col: 'param_name' }, + param_desc: { col: 'param_desc' }, + constraint_type: { col: 'constraint_type' }, + param_unit: { col: 'param_unit' }, + }, + }, + /* + * ④ 主数据骨架:**两颗料都铺、值回填现值**。 * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。 * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。 * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。 @@ -300,7 +375,7 @@ export const ASSESSMENT_STEPS = [ }, }, /* - * ③ 参数骨架 —— 三件事一笔办完: + * ⑤ 参数骨架 —— 三件事一笔办完: * · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**; * 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**; * · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报; @@ -339,15 +414,15 @@ export const ASSESSMENT_STEPS = [ }, }, /* - * ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。 + * ⑥ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑦)。 * - * **排在 ②③ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正): + * **排在 ④⑤ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正): * `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`, - * 而这两个数是 ②③ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ②③ **之前** + * 而这两个数是 ④⑤ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ④⑤ **之前** * (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id` - * 刚在这一拍被 ① 设上、②③ 即将把它的主数据与参数骨架铺出来时,那一刻 ③ 即将新建的 + * 刚在这一拍被 ① 设上、④⑤ 即将把它的主数据与参数骨架铺出来时,那一刻 ⑤ 即将新建的 * 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」), - * 片刻之后 ③ 一跑就变成 1。收步与发卡的把关本身没坏(⑤ 排在 ②③④ 之后,`card.when` + * 片刻之后 ⑤ 一跑就变成 1。收步与发卡的把关本身没坏(⑥ 排在 ④⑤ 之后,`card.when` * 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面 * `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。 * **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」, @@ -385,7 +460,7 @@ export const ASSESSMENT_STEPS = [ }, }, /* - * ⑤ 缺什么就派一张数据工单。 + * ⑦ 缺什么就派一张数据工单。 * **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定, * 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。 */ @@ -411,19 +486,22 @@ export const ASSESSMENT_STEPS = [ ], standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。', /* - * **`parts.chip_type` 永久不列在这里** —— 不是等 Task 7b 补了「新建类型」那张编辑表 - * 就该加回来(2026-08-29 评审逮到的 Critical:这张件此刻没有任何 `data-edit` - * 能写 `parts.chip_type`,列了它,`mine:parts.chip_type` 永远是空数组, - * `desk.ts` 的 `emptyGate` 就永远判「这一单还一行都没有 —— 这张卡就是来建它的」, - * 提交钮当场且永久按不下去,三张表填得再全也一样)。 - * 更深一层:即便 Task 7b 真的补上了那张编辑表,也不该把它放回来 —— `emptyGate` - * 的语义是「这张卡**就是来建**这张表的,一行都没有 = 没干活」,而 - * `parts.chip_type` 的主路是**从下拉挑一个已有类型**,那时 `mine:parts.chip_type` - * 天然就是零行(没新建、没问题);只有走「新建类型」那条岔路时才会写它。 - * 一张**只是有时候才写**的表,套「零行 = 没干完」这条闸的前提本身就不成立 —— - * 该拦的是 `parts.type_param`(核心参数字段清单,新建类型时**一定**要写)。 + * **`parts.chip_type` 与 `parts.type_param` 都在闸上**(2026-08-30 改,Ruling H35)—— + * 从前 `parts.chip_type` 被永久排除在外,理由是「它没有编辑手段,`mine:` 天然为空, + * 列了就永久卡死」;那条理由**从 ②③ 两笔自我投影加上之后就不再成立**—— + * `mine:parts.chip_type`/`mine:parts.type_param` 现在恒不为空(只要 `sub_eval.part` + * 里已经选定了类型),闸不会再误判「一行都没有」。 + * + * **既然要落地就必须在名单里,这不是碰运气**:「数据工单确认即入库」把 `gateLedgers` + * 列出的表整批并入正式表;`parts.chip_type` 若不在这份名单里,人新建的类型字典行 + * 就只留在草稿层,而引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— + * 落地那一刻起,正式表里就有一行指着字典里查无此号的悬空引用(2026-08-30 真跑 + * e2e 撞见的平台侧连带发现:范围放行没有对 `refersTo` 做传递闭包,并一张表却不 + * 并它引用的表,必然留下悬空引用;那条更普遍的平台缺陷已记 `docs/debt.md`, + * 这里只在活动自己的声明层堵住这一处具体触发点)。四张表都在闸上、也都会一起 + * 落地,才是「选类型」这件事完整、闭环的样子。 */ - gateLedgers: ['parts.type_param', 'parts.profile', 'parts.parameter'], + gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'], }, { kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' }, ], @@ -507,7 +585,27 @@ export const ASSESSMENT_STEPS = [ }, }, /* - * ② 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。 + * ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误 + * (2026-08-30,真跑 e2e 第一次撞见的循环依赖,`docs/ACT-WORK-05-端到端测试记录.md` + * 追加记录):`substitution.difference_item.substitution_relation_id` 声明了 + * `refersTo: 'substitution.relation.substitution_relation_id'`(要求引用的那一行 + * 已经真实存在),而 `substitution.relation` 的 `recommendation_level`/`risk_level`/ + * `difference_summary`/`recommendation_reason` 这几列**要读差异明细的 + * `judgement_class` 分布才算得出来** —— 两张表互相要对方先写。 + * 单趟写不出这个顺序:关系表写在明细表前面,`refersTo` 就拦住明细表(「要指的那一行 + * 还不存在」,亲测的报错原文);写在后面,关系表自己先炸(明细表还没有,`refersTo` + * 挡在关系表自己那两列 `original_internal_pn`/`candidate_internal_pn` 上倒是不会, + * 但那几个必填的计算列无从算起)。 + * **第一遍**(紧跟在 `analyze` 后面)只为把关系表那一行**先摆出来**满足 `refersTo`—— + * 这一刻明细表还没有一行,所有 `substitution_difference_item_judgement_class_*` + * 读数都是基座铺的 0,`when` 链逐级落到默认分支(`recommendation_level` 落 + * 「recommended」),字段值不对但每一列都写得出、不会报错。**第二遍**(紧跟在明细表 + * 后面)用这一刻已经真实存在的明细行重算一遍,把第一遍的占位值覆盖成真值 —— + * 与 S3 每次人改了四态就要重算关系表,是**同一套幂等重算机制**,不是新发明的招式。 + */ + RELATION_PROJECTION, + /* + * 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。 * 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。 * 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。 */ diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 79f6320..532a806 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.1.1', + packageVersion: '2.1.5', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -179,8 +179,39 @@ export const manifest: ActivityManifest = { level: 'L2', predicate: 'validation_record_rows = 1 and validation_record_missing = 0', }, - { key: 'evidence-credible', label: '证据链可信:每条差异的影响说明与判断依据站得住', level: 'L4' }, - { key: 'values-traceable', label: '参数值与原厂资料对得上:候选与原 PN 的取值来源可追溯', level: 'L4' }, + /* + * **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现,记入 + * `docs/ACT-WORK-05-端到端测试记录.md`)——「裁量项带谓词时谓词说了算」这条纪律 + * (2026-08-02 立,`engines.ts` 的 `criterionCheck`:`settledByData`)本仓已有先例, + * 从前 W05 这两条 L4 是裸的(无 `predicate`),而 T_release 全程 `auto` 驱动、 + * `submit` 是五个声明式动词之一(`packages/contracts/src/auto-recipe.ts`), + * **没有第六个 `judge` 动词** —— 裁量项没谓词就只能等 `judgement`(Agent 的 `judge` + * 行动)来结算,而这个活动压根不会有 Agent 走到这一步。结果是 `submittable()` + * 对这两条永远读到 `pass:false`,`T_release` 的 `submit` 每一拍都被 + * `raiseAcceptance` 拒,而拒绝不算错误(不触发 `failedAt`)、下一拍原样重来 —— + * 稳定地把步数预算耗到 `exhausted`,而不是报错崩溃,所以静态审计与包内自测都没照见它。 + * + * 两条谓词只覆盖**客观、基座算得出来的那一半**(有没有值、值哪来的), + * **主观那一半(写得好不好、可信不可信)已经由人工验收清单接住**——S3 审核卡 + * 第 1/4 条「逐条对过原料与候选料的参数值…」「影响说明站得住…」正是这两条 L4 + * 标签里「站得住」「对得上」那两个字的着落点,人签字通过那一刻已经确认过。 + * 谓词为真只说「客观前提具备」,不越权替人下「写得好」这个主观判断。 + */ + { + key: 'evidence-credible', + label: '证据链可信:每条差异的影响说明与判断依据站得住', + level: 'L4', + /* 客观前提:每一行都有值(必填不缺)——写得「站不站得住」是 S3 人工验收清单的事 */ + predicate: 'substitution_difference_item_missing = 0', + }, + { + key: 'values-traceable', + label: '参数值与原厂资料对得上:候选与原 PN 的取值来源可追溯', + level: 'L4', + /* 客观前提:原始参数集不缺、差异明细里逐条的原值候选值也不缺 —— 两值均来自 + parts.parameter 的 lookup,不是模型现编;「对不对得上」是 S3 人工验收清单的事 */ + predicate: 'parts_parameter_missing = 0 and substitution_difference_item_missing = 0', + }, /* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */ { key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-06 落账,结论供下游消费', level: 'L5' }, ], diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 7a56760..b93812f 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -76,6 +76,23 @@ function paramRows(internalPn, rows) { })); } +/** + * **跨案例隔离用的直连 SQL**(2026-08-30,协调者裁定①)—— 只给 `kase.isolationCleanup` + * 用,不是常规测试路径(常规路径一律走 API,不绕开授权/校验)。这里要做的事本身就是 + * API 够不着的:修正**别的案例**已经落地的正式表行,不是这一单自己的草稿。 + * 连接串默认值与 `apps/api/src/app/config.ts` 的 `DEFAULT_DATABASE_URL` 一致。 + */ +async function withDb(fn) { + const { Client } = await import('pg'); + const client = new Client({ connectionString: process.env.DATABASE_URL ?? 'postgres://jcai:jcai@localhost:54329/jcai_wos' }); + await client.connect(); + try { + return await fn(client); + } finally { + await client.end(); + } +} + /** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */ const SUITES = { a1: { @@ -625,21 +642,33 @@ const SUITES = { // 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型 expectAnalyzeCalls: 1, /* - * **真走「新建芯片类型」这条路,两步走**(Task 7b,2026-08-29 补齐件①的编辑面之后): - * `parts.chip_type.type_id` 是自增列(`auto:true`,声明 `text`)—— 件上那颗静态种子行 - * 只收 `type_name`/`description`,`type_id` 由 `autoKeys()` 续号铸出来,写之前**不知道** - * 铸出来的号是什么;`parts.type_param`/`parts.profile` 引用的正是这个号(`refersTo` - * 挡着,写一个编造的号会被当场拒)。与真人在浏览器里「先建类型 → 保存 → 新类型出现在 - * 下拉里 → 再选它」同一个两步顺序,不是抄近道 —— 只是脚本自己读回来,不用眼睛看下拉。 + * **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号** + * (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」): + * `resolveRowKey`(`apps/api/src/app/routes/desk.ts`)在**续号之前**先试「全表一致」 + * ——本单草稿里这张表若已经**恰好一种** `type_id` 取值,新行(`type_id` 留空)就会被 + * 认成「编辑那唯一的一行」,而不是「新增一行」。H35 让 S1 的配方在①之后立刻把 + * **当下选中的既有类型**自投影回 `parts.chip_type`(治空表闸的必要之举)——这却把 + * 「全表一致」的前提条件提前铺好了:候选缺库这条路里,原 PN 已经有类型(投影出恰 + * 一行),此时若把新行的 `type_id` 留空指望走 `autoKeys()`,会被「全表一致」抢先 + * 命中那**唯一的既有行**,新建类型变成了**改写既有类型的名字**(实测复现: + * `type_id=1` 的「DC-DC 降压稳压器」被整个改名成候选新类型的名字,且 `LAND_MASTER` + * 落地后连累了所有挂在 type_1 下的既有料号)。**这条路径连真人在浏览器里都走不通** + * ——件上那颗种子行没有 `type_id` 输入格,人没有办法像这里一样显式指定,遇到「原 + * PN 已有类型」的常见场景,真人点「新建类型」实际上会正中同一个坑。这是平台侧 + * 的问题(`resolveRowKey` 续号前的全表一致判定没把「auto 列」排除在外),已经报给 + * 协调者、按裁定不在这一轮治。**这里显式给一个测试专用、不会与种子数据碰撞的 + * `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。** */ customFill: async (taskId) => { - // ① 新建芯片类型:表尾静态种子行只填名称与说明,type_id 留空走 autoKeys() 续号 + const typeId = '90'; + // ① 新建芯片类型:显式给 type_id(见上方大注释,不走 autoKeys 续号) await api(`/api/desk/tasks/${taskId}/table`, { method: 'POST', body: JSON.stringify({ tables: { 'parts.chip_type': [ { + type_id: typeId, type_name: 'DC-DC 降压稳压器(评估自定义)', description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。', }, @@ -647,14 +676,7 @@ const SUITES = { }, }), }); - // ② 读回基座续出的 type_id —— 与真人「保存后新类型出现在下拉里」看到的是同一份数据 - const after = await api(`/api/gov/testbed/activity/${taskId}`); - const minted = draftRows(after.body.drafts ?? [], 'parts.chip_type').find( - (r) => String(r['type_name'] ?? '') === 'DC-DC 降压稳压器(评估自定义)', - ); - if (!minted) throw new Error('候选缺库·新建类型:parts.chip_type 新行没落下 —— 新建类型这条路没打通'); - const typeId = String(minted['type_id'] ?? ''); - console.log(` 新建类型已保存,基座续号 type_id=${typeId}`); + console.log(` 新建类型已保存,显式指定 type_id=${typeId}(绕开全表一致平台坑,已报未改)`); // ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+ // 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。 // LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。 @@ -697,8 +719,60 @@ const SUITES = { }), }); }, - // 使能阈值未披露 → UNKNOWN → 有条件推荐(规则算的,不是模型说的) - expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, + /* + * **这不是 `expectRevised`(归属)检查,故意换了字段名**(2026-08-30,协调者裁定: + * 这条断言原来借用了 E04 那条「点名驳回要落在被点名那一行」的字段/话术,而 W05 + * 全程零 agent 轮次(`case-model.ts` 四步 `guide` 全是空串)、唯一的 `analyze` + * 发生在这条驳回意见写下**之前**——没有模型读意见挑行这一步,测不出归属, + * 继续叫 `expectRevised` 会在真失败时打出「归属又认错了」这种指错方向的话。 + * 这里改用 `expectFinalValue`:只收尾核对一个**规则算出来的结论** + * (使能阈值未披露 → S2 一次 analyze 就判 UNKNOWN → 有条件推荐)扛住了「驳回→重提」 + * 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。 + */ + expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, + /* + * **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立, + * 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改 + * 随「数据工单确认即入库」落进正式表(`JCAI_E2E_LAND_MASTER=1`)。不清理的话, + * 案例 1(靠 LM2596S-5.0 + LM2596S-ADJ 同型)会看见 LM2596S-5.0 已经不在 type_1 了—— + * 一个假的「两颗料分属不同类型」,实测复现过(run9:案例 1 平白冒出一张数据工单、 + * 最终吊死超时)。这不是案例 0 本身办错了(它确实需要转挂原 PN 才够得上 + * `distinct=1`,那是这条「新建类型」路径的题中之义),是**套件没有做案例间隔离**—— + * 跑完把 LM2596S-5.0 转回 type_1、把这一例专属新建的字典行清掉,不论这一例成败。 + */ + isolationCleanup: async () => { + /* + * **第一版只清了原 PN 那一半,漏了候选料本身**(2026-08-30,协调者纠正 Keycloak + * 问题之后重跑逼出来的第二个洞):`XL2596S-5.0` 是这一例**全新建出来**的候选料 + * (候选缺库场景),案例 4 也用它当另一单的候选(不同的原 PN),前提同样是「两颗 + * 都缺库」。放行(`JCAI_E2E_LAND_MASTER=1`)把它连同评估结果一起落进正式表后, + * 只转回 `LM2596S-5.0` 的类型、删掉 `type_id=90` 字典行是不够的——`XL2596S-5.0` + * 自己的 `parts.profile` 行还悬空指着已删的 `chip_type_id='90'`,且它作为一颗 + * "正式存在的料"本身就与案例 4「两颗都缺库」的前提矛盾。实测复现:案例 4 卡在 + * 「配方第 4 笔办不下去:芯片类型「90」在芯片类型字典里不存在」——根子在这里, + * 不是案例 4 自己的问题。这一例产出的东西(候选料本身 + 评估结果)整体都是 + * 这一例专属的测试脚手架,不是该保留的既有主数据,一并清空。 + */ + await withDb(async (db) => { + await db.query("update ledger.parts__profile set chip_type_id = '1' where internal_pn = 'LM2596S-5.0'"); + const evalRefs = await db.query("select distinct eval_ref from ledger.sub_eval__part where internal_pn = 'XL2596S-5.0'"); + for (const { eval_ref } of evalRefs.rows) { + await db.query('delete from ledger.sub_eval__part where eval_ref = $1', [eval_ref]); + await db.query('delete from ledger.sub_eval__case where eval_ref = $1', [eval_ref]); + } + await db.query( + "delete from ledger.substitution__difference_item where substitution_relation_id in " + + "(select substitution_relation_id from ledger.substitution__relation where candidate_internal_pn = 'XL2596S-5.0')", + ); + await db.query("delete from ledger.substitution__relation where candidate_internal_pn = 'XL2596S-5.0'"); + await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'"); + await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'"); + await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'"); + await db.query("delete from ledger.parts__type_param where type_id = '90'"); + await db.query("delete from ledger.parts__chip_type where type_id = '90'"); + }); + console.log(' 跨案例隔离:LM2596S-5.0 已转回 type_1;这一例全新建的 XL2596S-5.0(候选料本身、评估结果、type_id=90 字典行)已整体清空'); + }, }, { sample: 1, @@ -1261,6 +1335,17 @@ async function runCase(suite, kase) { let addedRows = false; let planned = false; let revised = false; + /** + * **不是 `expectRevised` 的「归属」检查**(2026-08-30,协调者裁定:两者外形相同但 + * 语义不同,不能共用同一句失败话术)——`expectRevised` 测的是 E04 那类「有 agent 轮次 + * 读驳回意见、按字面挑对行」的场景(归属认错了才失败)。W05 全程零 agent 轮次 + * (S3_review/T_release 都是 `guide: ''`,唯一的 `analyze` 发生在驳回意见产生**之前**), + * 不存在「模型读意见挑行」这一步,所以案例 0 的驳回循环测不出归属,也不该冒充在测归属。 + * 这里只是**收尾核对一个规则算出来的值扛住了「驳回→重提」这一趟往返** + * (使能阈值未披露 → S2 一次 analyze 就判 UNKNOWN → 有条件推荐,这个结论在驳回意见 + * 写下之前就已成立;重提后重新读一遍确认它没被这一趟揉坏)。 + */ + let finalValueSeen = false; let acceptEdited = false; let minted = false; let forbidHit = false; @@ -1341,6 +1426,13 @@ async function runCase(suite, kase) { (r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains), ); } + // 收尾值核对(非归属,见上方 finalValueSeen 声明处的大注释)—— 匹配逻辑与 expectRevised 相同,故意不合并成一个字段:失败话术要分得开。 + if (kase.expectFinalValue && !finalValueSeen) { + const { address, where, equals, column, contains } = kase.expectFinalValue; + finalValueSeen = draftRows(body.drafts ?? [], address).some( + (r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains), + ); + } // w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过 if (kase.expectNoDataCard && !dataCardSeen) { dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.match.test(c.title ?? ''))); @@ -1571,6 +1663,10 @@ async function runCase(suite, kase) { console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`); ok = false; } + if (kase.expectFinalValue && !finalValueSeen) { + console.log(` ⚠️ 收尾核对没通过:${kase.expectFinalValue.equals} 的 ${kase.expectFinalValue.column} 应含「${kase.expectFinalValue.contains}」,没读到 —— 判失败(这不是归属问题,W05 全程零 agent 轮次测不出归属,见 finalValueSeen 声明处注释)`); + ok = false; + } if (kase.acceptEdits && !minted) { console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败'); ok = false; @@ -1650,6 +1746,22 @@ async function main() { } catch (error) { console.log(` ✗ 这一例中途出错,判失败,接着跑下一例:${String(error.message ?? error).slice(0, 200)}`); } + /* + * **跨案例隔离**(2026-08-30,协调者裁定①,run9 撞出来的套件级缺陷):不论这一例 + * 成败,`isolationCleanup` 都要跑——`JCAI_E2E_LAND_MASTER=1` 之下,某些案例会把 + * **共享的主数据行**改到一个只有它自己认得的状态(如案例 0「新建类型」必须把 + * distinct=1 用到的原 PN 一并转挂新类型,这一改随数据工单确认落进正式表)。 + * 不清理的话,下一个共用同一颗料的案例(如案例 1)会看见一个假的局面—— + * 这不是活动的错,也不是平台的错,是**套件本身没有做案例间隔离**,该在脚本层修。 + * 往后加案例:凡是会*改动*而不只是*新增*共享主数据行的,都要配一条 `isolationCleanup`。 + */ + if (kase.isolationCleanup) { + try { + await kase.isolationCleanup(); + } catch (error) { + console.log(` ⚠️ 跨案例隔离清理失败(${String(error.message ?? error).slice(0, 160)})—— 后续案例可能受这一例污染`); + } + } } } console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);