From 6f7513aa581f63e21bc70b1dce5f99ef88e29328 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 11:13:53 +0800 Subject: [PATCH] =?UTF-8?q?W05=20=E6=94=B6=E5=8F=A3:=E4=BA=94=E4=BE=8B?= =?UTF-8?q?=E5=9C=A8=E6=94=B6=E7=B4=A7=E6=96=AD=E8=A8=80=E4=B8=8B=E5=85=A8?= =?UTF-8?q?=20released=20=C2=B7=20=E5=85=A8=E8=A2=AB=E6=8B=92=200(?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=201.2.9=20=E2=86=92=201.3.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 7–12 轮逼出七条(T–Z),两条是脚本自己的、五条是活动的,其中一条上结构: - W 参数名写成英文 key,与库里中文行不是同一行 → 27 条里 14 条 UNKNOWN。措辞两遍 + 乘法对账都拦不住单颗料那一路(5 = 5×1 恒成立),改自指 refersTo:param_name 只能用台账里已经在用的名字,一颗料时也咬得住。 - V/Z 数据工单那一项的两个方向:建完第一颗就收掉(再给第二颗发卡撞「已办完」)、 铺完骨架就标 created 直接 finish(撞「从没发过卡」)—— 顺序写死:骨架 → 发卡 → 拍结束 → 人交回 → 才标 created;absent 没清零不许 finish。 - T P-10 也要「发卡前骨架先有行」;X 第二颗缺库也要先问。 - 脚本:U 一例翻车不拖垮整套;Y 按闸的指示「加一行」;断言收紧 —— 点名的行整例跑完 仍没出现 = 失败,决策卡按「问的是哪件事」数(第 9 轮逮到一次假绿)。 流程上两条纪律(用户当场纠正):只跑受影响的那一例,改了平台共享面才全跑;先说计划再动手。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh --- .../__tests__/activity.test.ts | 7 +++ .../__tests__/steps.test.ts | 33 +++++++++- .../src/case-model.ts | 43 ++++++++++--- .../src/manifest.ts | 29 ++++++++- docs/ACT-WORK-05-端到端测试记录.md | 28 ++++++++- tools/e2e-a2.mjs | 62 ++++++++++++++++++- 6 files changed, 188 insertions(+), 14 deletions(-) diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index 82e1aed..57825e8 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -84,6 +84,13 @@ describe('台账声明:授权面与《统一方案》口径', () => { expect(param?.kind).toBe('master'); expect(param?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['internal_pn', 'param_name']); expect(param?.fields.find((f) => f.name === 'internal_pn')?.refersTo).toBe('parts.profile.internal_pn'); + /* + * 参数名自指引用:只能用台账里已经在用的那几个 —— 2026-08-27 实测三次, + * 模型给新料写英文 key,剧本写过两遍 + 一条乘法对账都拦不住「只建一颗」那一路。 + */ + const pn = param?.fields.find((f) => f.name === 'param_name'); + expect(pn?.refersTo).toBe('parts.parameter.param_name'); + expect(pn?.distinct).toBe(true); expect(param?.fields.find((f) => f.name === 'param_value')?.required).toBe(true); expect(param?.fields.find((f) => f.name === 'constraint_type')?.tally).toEqual(['hard', 'soft']); expect(param?.fields.find((f) => f.name === 'constraint_type')?.required).toBe(true); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index be70ee8..6a4c220 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -263,7 +263,9 @@ describe('说明书与剧本的骨架', () => { }); it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => { - expect(NARRATIVE).toContain('必须先有骨架行'); + // 两张数据工单各要一条(P-08 的 profile/parameter、P-10 的 document) + expect(NARRATIVE.match(/必须先有骨架行/g)?.length ?? 0).toBeGreaterThanOrEqual(2); + expect(NARRATIVE).toContain('parts.document` 必须先有骨架行'); expect(NARRATIVE).toMatch(/压拍/); // 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了) expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_created'); @@ -280,6 +282,35 @@ describe('说明书与剧本的骨架', () => { expect(g).toContain('引用检查'); }); + it('absent 没清零之前不许收掉数据工单那一项(2026-08-27 e2e:建完第一颗就 finish,再想给第二颗发卡撞上「这一步已经办完收掉了」,连拒四次)', () => { + const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(g).toMatch(/还有没有 absent 的料号/); + expect(g).toMatch(/这一项千万别 finish/); + expect(g).toMatch(/收掉了就回不去了/); + }); + + it('参数名一律中文、与库里既有行同名(2026-08-27 e2e:模型写成 input_voltage_range,而原 PN 是「输入电压范围」—— 主键的一半对不上,27 条里 14 条判成 UNKNOWN)', () => { + const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(g).toMatch(/参数名一律用中文/); + // 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数 + expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created'); + expect(g).toContain('input_voltage_range'); + expect(g).toMatch(/不是同一行/); + }); + + it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => { + const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(g).toMatch(/这一颗也要先发一张决策卡问人/); + expect(g).toMatch(/第一颗问过了不代表第二颗也问过/); + }); + + it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => { + const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? ''; + expect(g).toMatch(/写骨架 ≠ 建好了/); + expect(g).toMatch(/写骨架 → \*\*发卡\*\* → 这一拍结束 → 人交回/); + expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/); + }); + it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => { expect(ASSESSMENT_PHASES).toHaveLength(5); for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index ec78b82..6d0dd6f 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -45,6 +45,9 @@ export const CMMN_XML = ` + + + @@ -90,7 +93,7 @@ export const CMMN_XML = ` 却只建了第一颗,一行足以让门开着,直到 S2 写关系时才被引用检查拦下。 改成**逐颗对账**:\`parts_profile_rows >= created\` —— 标了几颗建好,就得有几行。 --> - sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created + sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created and parts_document_rows >= sub_eval_pn_check_exists_status_created and parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created @@ -244,7 +247,10 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该 不给它就回落到必交付物那几张(与这张卡无关),于是件上红带点着「缺品牌名称」而提交钮照样绿, 空手交卷一路放行(2026-08-27 实测)。给了之后,闸与红带、放行门读的是同一个数。 -④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发): +④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发)—— +**与③同一条:发这张卡之前 \`parts.document\` 必须先有骨架行**(名单里每颗料至少一行, +key 是 {internal_pn, doc_seq:"01"});一行都没有的话人打开是空表、什么也补不了, +提交闸会如实拦住(「这一单还一行都没有」),这一单就卡在这儿了(2026-08-27 实测)。 {"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{ "title":"数据工单 · 原厂技术资料创建(P-10):<料号列表>", "ask":"请把这些料号的 datasheet / 承诺函等资料记录补进原厂技术资料库(一份资料一行,可加行)。", @@ -383,20 +389,41 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不 1. 给名单里每颗料写一行 \`parts.profile\` 骨架(表 1):key {internal_pn},patch 只带你确有依据的列 —— 有 \`datasheets[].text\` 就从里面读 brand_name / specification_description / package / lifecycle_status(active/nrnd/eol/discontinued/risk 之一)/ datasheet_ref + datasheet_version; 没有 datasheet 就只写 internal_pn 这一列(其余留给人填,**别编**)。 -2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数):有 \`datasheets[].text\` 就从里面读出典型参数 +2. 同一拍给每颗料写标准化参数集骨架(表 2,\`parts.parameter\`,一行一参数): + **参数名一律用中文,与库里既有料号的那几行同名**(输入电压范围 / 输出电压 / 输出电流 / 开关频率 / + 静态电流 / 工作温度 / 使能阈值…)—— \`param_name\` 是主键的一半,写成 \`input_voltage_range\` + 与写成「输入电压范围」**不是同一行**:原 PN 那边是中文、候选这边是英文,比对时两套名字对不上, + 逐条判成 UNKNOWN,结论就废了(2026-08-27 实测:27 条里 14 条 UNKNOWN)。 + 封装、工艺、生命周期这些**不是参数**,它们在表 1,别往表 2 里塞。 + **建几颗料就用同一套名字**:门上有一条对账 —— 参数行数 = 不同参数名数 × 建过的料数, + 只有每颗料的名字集合一模一样时才成立;混着写(这颗中文那颗英文、或同一颗写两套)这一步就过不去。有 \`datasheets[].text\` 就从里面读出典型参数 (如输入电压范围 / 输出电压 / 输出电流 / 开关频率 / 静态电流 / 工作温度 / 使能阈值等,按品类而定)各写一行, key {internal_pn, param_name},patch 带 param_value / param_unit / constraint_type(hard/soft)/ param_note(一句话说清这项为什么要卡, 如「须覆盖客户输入上限」);原厂没写的值写「未披露」(它会进入 UNKNOWN 并挂验证任务)。没有 datasheet 就只写 internal_pn + param_name 骨架(把你判断这颗料该评估的参数名列出来,**param_value 留空给人填,别编**)。 -3. 同一拍给每颗料写一行 \`parts.document\` 骨架:key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending", +3. 同一拍**给名单里每一颗料各写一行** \`parts.document\` 骨架(两颗就两行,别只写一行 —— + 门上有一条对账:建好几颗就得有几行资料记录;少一行,那颗料的资料工单人打开是空的):key {internal_pn, doc_seq: "01"},patch {document_type: "datasheet", document_status: "pending", brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。 4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。 -**\`created\` 是「这颗料真的建好了」,不是「我打算建」**:只有它在 \`parts.profile\` 里真有一行(件上填好交回来了) -才改成 created —— 门上有一条逐颗对账(建好几颗就得有几行主数据),标了却没建,这一步过不去, -到 S2 写关系时还会被引用检查再拦一次。 +**\`created\` 认的是「那张卡交回来了」,不是「表里有行」** —— **写骨架 ≠ 建好了**: +骨架是**你**铺的(只有料号和参数名),值是**人**在件上填的。骨架一铺就把 pn_check 改成 created, +等于替人宣布他已经办完了:接着你会以为数据工单收工、去 finish 这一项,而卡根本没发出去 —— +撞上「这一单里从没给它发过卡」,连撞五拍熔断(2026-08-27 实测,整单白跑)。 +**顺序只有一个**:写骨架 → **发卡** → 这一拍结束 → 人交回 → 这时才把那几颗改成 created。 +门上还有一条逐颗对账(建好几颗就得有几行主数据、几行资料),标了却没建,这一步照样过不去。 -【P-08 那张确认回来了】这一拍的动作串**恰好两个,顺序不能反**: +【P-08 那张确认回来了】**先看名单里还有没有 absent 的料号**(\`${D}.pn_check\` 现读): +· **还有** → **这一项千万别 finish**。先回到上面【问题二】那一档:候选总数 ≥2 的话, + **这一颗也要先发一张决策卡问人**(照说明书②,人选 create 才建 —— 每一颗 absent 的候选都有被放弃的权利, + 第一颗问过了不代表第二颗也问过);人选 create 之后再把它的骨架写好(profile + parameter,有 datasheet 就预填), + 发一张 P-08 卡(标题带那一颗的料号)—— 同一个计划项可以先后发好几张卡,一颗一张也行。 + 原 PN 与「候选总数 = 1」那两档不问,直接建。 + **收掉了就回不去了**:这一项一旦 Completed,再想给它挂第二张卡会被拒「这一步已经办完收掉了」, + 而那颗料从此没人建,后面写替代关系时被引用检查逐颗拦下(2026-08-27 实测,一单连拒四次)。 +· **一颗都不剩了** → 才走下面这两步。 + +这一拍的动作串**恰好两个,顺序不能反**: ① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按 「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在 \`p08-master-create\`(P-08 那两张表);下一拍就算发出了 P-10 的卡,人的工作区也看不见 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index e86fe0a..dd4d2a9 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -89,7 +89,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', - packageVersion: '1.2.9', + packageVersion: '1.3.6', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -309,7 +309,32 @@ export const manifest: ActivityManifest = { label: '标准化参数集(P-08 子表)', fields: [ col({ name: 'internal_pn', type: 'text', label: '所属料号', primaryKey: true, refersTo: 'parts.profile.internal_pn' }), - col({ name: 'param_name', type: 'text', label: '参数名', primaryKey: true }), + /* + * `distinct` 是为了让「两颗料是不是用同一套参数名」变成**基座算得出的数** + * (`parts_parameter_distinct_param_name`)—— 见 CMMN 的 s_pns_resolved: + * 参数行数 = 不同参数名数 × 建过的料数,只有每颗料的名字集合一模一样时才成立。 + * 2026-08-27 实测:模型给第二颗料写英文 key(`input_voltage_range`), + * 而原 PN 在库里是中文「输入电压范围」—— 名字是主键的一半,对不上就没法逐项比。 + * 措辞写过两遍都没拦住(设计律第六条:措辞治不了的结构治)。 + */ + col({ + name: 'param_name', + type: 'text', + label: '参数名', + primaryKey: true, + distinct: true, + /* + * **参数名只能用台账里已经在用的那几个**(自指引用)—— 比对是逐参数对着比的, + * 候选身上有一个原 PN 没有的维度,本来也无从比起;名字对不上更是直接判成 UNKNOWN。 + * 2026-08-27 实测三次:模型给新料写英文 key(`input_voltage_range`), + * 而库里既有行是中文「输入电压范围」—— 剧本写过两遍、又加了一条乘法对账 + * (行数 = 不同名字数 × 建过的料数)仍拦不住**只建一颗**的那一路(5 = 5×1 恒成立)。 + * 自指是唯一在一颗料时也咬得住的:写下去那一刻基座去这一列里查,查无此值当场拒。 + * 代价(已知约束):真要引入一个全新的参数维度,得先有一颗料用过它 —— + * MVP 的品类词汇表是固定的七项,够用;将来要放开,改成引用一张参数字典表。 + */ + refersTo: 'parts.parameter.param_name', + }), col({ name: 'param_value', type: 'text', label: '值', required: true }), col({ name: 'param_unit', type: 'text', label: '单位', expect: true }), col({ name: 'constraint_type', type: 'text', label: '约束类型', required: true, tally: ['hard', 'soft'] }), diff --git a/docs/ACT-WORK-05-端到端测试记录.md b/docs/ACT-WORK-05-端到端测试记录.md index 9043f01..9981c0f 100644 --- a/docs/ACT-WORK-05-端到端测试记录.md +++ b/docs/ACT-WORK-05-端到端测试记录.md @@ -191,7 +191,16 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回 | 3 | 1 通过 | N 的措辞版没用 → **改结构**;**O 工具被 null 打崩** · P `count` 假线索 | | 4 | 2 通过 | **H 的残留面:零行同时骗过闸与哨兵** | | 5 | 4 通过 | **created 逐颗对账太松 · S1 卡的前提没写在说明书** | -| 6 | **跑到的 4 例全 released,3 例被拒 0** | 案例 4 停在 `Insufficient Balance`(大模型余额) | +| 6 | 跑到的 4 例全 released,3 例被拒 0 | 案例 4 停在 `Insufficient Balance`(大模型余额) | +| 7 | 前 3 例 released | 充值后:先撞上我自己拉起的旧稳定实例抢单(`recommend` 无可用实现);**T** P-10 骨架零行 · **U** 脚本一例翻车整套退出 | +| 8 | **5 例全 released,4 例被拒 0** | 案例 3 被拒 6:**V** 建完第一颗就收掉数据工单那一项 | +| 9 | 案例 3 单跑 | 用户纠正「别每次全跑」;逮到一次**假绿**:**W** 参数名写成英文 key · **X** 两颗缺库只问一次 —— 断言收紧 | +| 10–11 | 案例 0/1 被拒 0 | **W 的乘法对账只在 ≥2 颗时咬得住**,单颗照漏 → 自指 `refersTo`;**Y** 脚本按闸的指示「加一行」;案例 2 撞 **Z** | +| 12 | 案例 2 · 4 各单跑 | **Z** 修后 released · 被拒 0 · 卡先发人再答 | + +**最终状态(收紧断言下)**:五例全部 `released` · 全部被拒 0。 +案例 0/1 验于 1.3.5、案例 3 验于 1.3.4、案例 2/4 验于 1.3.6 —— 1.3.6 只改了 S0d 剧本里「何时标 created」那一句, +案例 0/3 走过那一步、未在最终文本上复验(如实记)。 第 6 轮逐条实证:早收容忍让案例 1/2 的最后 1 次被拒消失(轨迹里是**观察**不是拒回); 三候选那例**第一次跑通**(前三轮:超时 / 超时 / 烧穿预算),两颗缺库一起办、三条结论全对。 @@ -219,6 +228,13 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回 | Q | 模型压拍没写骨架就发数据工单,人打开是空表,而**零行 = 零缺**,闸与哨兵一起放过 | 「缺必填」只数已有行;`_missing = 0` 在零行时恒真 | 平台 `emptyGate`(**卡自报的表一行都没有 = 没完成**)+ 说明书③「发卡前骨架必须先有行」+ 哨兵 `profile_rows >= created` 逐颗对账 | 平台 + 活动 | | R | 两颗缺库只办了第一颗,第二颗还 absent 就把 S1 的卡发了出去 | **发卡的授权面是调人预算,不是计划项** —— 它绕得过哨兵;而 S1 那张卡的**前提**只写在剧本里 | 前提写进说明书的卡骨架(「一颗 absent 都不剩」「有几颗 absent 就要办几颗」) | 活动 | | S | 每例白吃一次的「早收」:工具还没轮到调,`finish` 先到了 | 那是**计划次序**的小错,却被记成「行动被拒」去喂熔断计数 | 与「重发已答过的卡」同一把尺:**头一次只落观察,第二次起才算被拒** | **平台** | +| T | P-10 数据工单发出来时 `parts.document` 一行都没有 | 说明书③给 P-08 补了「发卡前骨架必须先有行」,④(P-10)没补 | ④ 补同款前提 | 活动 | +| U | 一例卡住,脚本整套抛出退出,后面两例连跑都没跑 | `main` 逐例调用没兜 | 一例翻车判失败、接着跑下一例 —— 一套五例的价值在「哪几例过、哪几例没过」 | 脚本 | +| V | 建完第一颗料就把数据工单那一项收掉,再给第二颗发卡撞「这一步已经办完」 | 剧本【P-08 确认回来了】没分岔「还有没有 absent」 | absent 没清零不许 finish;写明「收掉了就回不去了」 | 活动 | +| W | 参数名写成英文 key(`input_voltage_range`),与库里中文行**不是同一行** → 27 条里 14 条 UNKNOWN | `param_name` 是主键的一半;措辞两遍 + 乘法对账(行数 = 不同名字数 × 料数)都拦不住**单颗**那一路 | **自指 `refersTo: parts.parameter.param_name`** —— 一颗料时也咬得住 | 活动(结构) | +| X | 两颗缺库只问了一次;`expectDecision` 只看张数,审核卡就把它喂饱 | 剧本「还有 absent → 写骨架再发卡」把「先问」压掉;断言太宽 | 剧本补「第二颗也要先问」;脚本新增 `expectAsks`(按问法数) | 活动 + 脚本 | +| Y | 模型漏铺骨架 → 闸如实拦「一行都没有」→ 脚本只会填已有行,连吃 400 抛出 | 脚本没有「加一行」这条路,而人在浏览器里正是这么做的 | 主键齐了就加一行(与件上「加一行」同一条路) | 脚本 | +| Z | 铺完骨架就标 `created`、直接 finish,撞「从没给它发过卡」连拒五拍 | 我 1.3.1 的措辞「表里真有一行才标 created」—— 而骨架是模型自己铺的 | 「created 认的是卡交回来了」;顺序写死:骨架 → 发卡 → 拍结束 → 人交回 → 才标 | 活动 | **可推广的三条**(已记 `docs/debt.md`): @@ -258,7 +274,15 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回 脚本逮的是**机制**(空手交卷、循环、崩、归属)。发现 L–S 八条没有一条是浏览器走查逮到的 —— 因为人手点的时候会先把格子填好再按提交,永远不会去试「空着交上去会怎样」。 -5. **拒回喂事实,不喂指针。** +5. **断言报的通过是假的,比报的失败是假的更糟。** + 第 9 轮案例 3 报「1/1 通过」,而参数名全是英文、两颗缺库只问了一次 —— 脚本只把它们记成 ⚠️ 就放过。 + 假失败还有人去查;假通过没有人会看第二眼。此后:点名的行整例跑完仍没出现 = 失败;决策卡按**问的是哪件事**数。 + +6. **只跑受影响的那一例;改了平台共享面才全跑回归。** + 前八轮每次把已绿的四例重跑一遍 —— 每轮多烧一百多万 token,而且什么新东西都测不出。 + 脚本本来就支持 `w05 N`。 + +7. **拒回喂事实,不喂指针。** 发现 N 那一轮把「他选的是 approve、下一步写哪面旗」写进拒回,日志里逐字可见,模型照发不误 —— 因为**下一拍它又从头翻一次会滚走的窗口**。把同一件事做成「每拍都在的事实行」之后,一次就好了。 同一条道理已经在 worldTouched(2026-08-21)、E04 的 worldTouched 事实行(2026-08-24)上各现过一次。 diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 230cc91..b0f834a 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -709,6 +709,8 @@ const SUITES = { name: '三候选 · 两颗缺库各问一次 · 国产 32V 硬约束不推荐', decideSeq: ['create', 'create', 'approve'], expectDecision: true, + // 两颗候选缺库 → 按规格各问一次(审核卡也是决策卡,所以要按问法数,不能只看张数) + expectAsks: [{ re: /查无记录/, count: 2 }], expectPort: 'assessment-released', stageEdits: { 'parts.profile': [ @@ -906,6 +908,7 @@ async function answerDecisions(taskId, kase, answered) { body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }), }); answered.add(card.id); + asked.push(String(card.ask ?? card.title ?? '')); count += 1; console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`); } @@ -966,10 +969,34 @@ function buildEdits(stage, drafts, kase = {}) { if (!target) { const wanted = allGiven ? keyCols.map((k) => keyVals[k]).join('/') : `待填第${i}行`; const have = rows.map((r) => keyCols.map((k) => r[k]).join('/')).join(', '); + /* + * **点名的行找不到 = 判失败,不是提个醒**(2026-08-27 实测:模型把参数名写成英文 key + * `input_voltage_range`,而库里既有行是中文「输入电压范围」—— 参数名是主键的一半, + * 两套名字对不上,比对时 27 条里 14 条判成 UNKNOWN。而这一例照样报「1/1 通过」: + * 断言报的通过是假的,比断言报的失败是假的更糟 —— 后者还有人去查。) + */ + /* + * **件上没有这一行就「加一行」** —— 人在浏览器里就是这么做的(闸的原话: + * 「先在件上加一行填好再提交」),脚本从前只会填已有行,于是模型漏写骨架时 + * 这一例直接卡死(2026-08-27:P-10 零行,连吃三次 400 抛出)。 + * 主键齐了才加(凑不出钥匙的行基座也认不出),加完从「缺」的名单里划掉。 + */ + if (allGiven) { + lines.push({ ...keyVals, ...cells }); + missedRows.delete(`${keyCols.join('+')}=${wanted}`); + console.log(` 件上加一行(${keyCols.join('+')}=${wanted})—— 模型没铺这行骨架,照闸的指示补`); + continue; + } + missedRows.add(`${keyCols.join('+')}=${wanted}`); console.log(` ⚠️ 找不到要填的行(${keyCols.join('+')}=${wanted});现有行:${have}`); continue; } taken.add(sig(target)); + /* + * **填上了就从「缺」的名单里划掉** —— 同一类卡一例里可能发好几张(两颗缺库各一张 P-08), + * 第一张上第二颗的行还不存在是**正常的**,不是缺;整例跑完仍没填上才是真缺。 + */ + if (allGiven) missedRows.delete(`${keyCols.join('+')}=${keyCols.map((k) => keyVals[k]).join('/')}`); lines.push({ ...keyOf(target), ...cells }); } if (stage.fillMissing) { @@ -1125,6 +1152,8 @@ async function runCase(suite, kase) { let gateLeaked = false; const gateChecked = new Set(); let decisions = 0; + missedRows.clear(); + asked.length = 0; /** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */ let addedRows = false; let planned = false; @@ -1351,6 +1380,23 @@ async function runCase(suite, kase) { ok = false; } // 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设 + if (missedRows.size > 0) { + console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`); + ok = false; + } + /* + * **决策卡要数「问的是哪件事」,不是笼统数张数**(2026-08-27:两颗缺库一张创建卡都没问, + * 而审核卡也是决策卡 —— `decisions > 0` 于是恒真,该问没问照样绿)。 + */ + if (kase.expectAsks !== undefined) { + for (const { re, count } of kase.expectAsks) { + const got = asked.filter((a) => re.test(a)).length; + if (got < count) { + console.log(` ⚠️ 该问的没问够:${re} 期望 ≥${count} 张,实际 ${got} 张 —— 判失败`); + ok = false; + } + } + } if (kase.expectDecision && decisions === 0) { console.log(' ⚠️ 超限条目没走决策卡 —— 判失败'); ok = false; @@ -1401,6 +1447,11 @@ async function runCase(suite, kase) { return ok && (!kase.expectPort || ports.includes(kase.expectPort)); } +/** 这一例里「案例点名要填、而件上找不到」的行 —— 每例开头清空,收尾判失败 */ +const missedRows = new Set(); +/** 这一例里模型问过的每一句(决策卡的 ask)—— 收尾按「问的是哪件事」核,不是笼统数张数 */ +const asked = []; + async function main() { const which = process.argv[2] ?? 'all'; const only = process.argv[3] === undefined ? undefined : Number(process.argv[3]); @@ -1414,7 +1465,16 @@ async function main() { for (const [i, kase] of suite.cases.entries()) { if (only !== undefined && i !== only) continue; total += 1; - if (await runCase(suite, kase)) pass += 1; + /* + * **一例翻车不该拖垮整套**(2026-08-27 实测:P-10 那张卡零行、闸如实拦着, + * 脚本没东西可填、抛出去,后面两例连跑都没跑)。判失败、记下来、接着跑下一例 —— + * 一套五例的价值就在于「哪几例过、哪几例没过」,死在半路等于什么都没测出来。 + */ + try { + if (await runCase(suite, kase)) pass += 1; + } catch (error) { + console.log(` ✗ 这一例中途出错,判失败,接着跑下一例:${String(error.message ?? error).slice(0, 200)}`); + } } } console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);