diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index ea69863..7a56760 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -37,9 +37,14 @@ const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? ''; * 两处代价说在前头,别不知不觉踩上: * ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的); * ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。 - * **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对 - * `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效); - * 不设它才走服务账号那条正路(ADR-012)。 + * + * **2026-08-30 取信优先级改成服务账号优先**(实测踩过一次:`.stable/env` 里留着一枚 + * chaihao 的旧 token,`KEYCLOAK_E2E_CLIENT_ID/SECRET` 那时也已经配好能用,而脚本原来 + * 「给了就用它」的写法让旁路无条件盖过正路 —— 每次 e2e 都会悄悄绕开服务账号,把人类卡 + * 派到那个人的真实工作台上,而日志只有一行不起眼的提示,容易被漏看)。 + * **服务账号三个变量(`KEYCLOAK_URL`/`KEYCLOAK_E2E_CLIENT_ID`/`KEYCLOAK_E2E_CLIENT_SECRET`) + * 全齐时走正路**;`JCAI_E2E_BEARER` 只在**任意一个缺席**时才顶上来兜底,兜底那一刻把 + * 代价喊得更响一点(见 `login()`)。 */ const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim(); /** @@ -612,58 +617,85 @@ const SUITES = { cases: [ { sample: 0, - name: '候选缺库 · XL2596S-5.0 新建芯片类型(type_id=3,字段清单沿用原型口径)入库,' + name: '候选缺库 · XL2596S-5.0 新建芯片类型(自增 type_id,字段清单沿用原型口径)入库,' + '原 PN 一并转挂新类型才够得上 distinct=1 · 审核先点名意见驳回再通过', decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'], expectDecision: true, expectPort: 'assessment-released', // 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型 expectAnalyzeCalls: 1, - stageEdits: { - 'parts.chip_type': [ - { - type_id: '3', - type_name: 'DC-DC 降压稳压器(评估自定义)', - description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。', - }, - ], - // 字段名 / 说明 / 约束 / 单位逐字照抄 type_id=1 的字典行(见 seed-mvp-data.ts)—— - // 换个类型号但比对维度不变,原 PN 既有的参数值才能按同名继续 lookup 到。 - 'parts.type_param': [ - { type_id: '3', param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' }, - { type_id: '3', param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' }, - { type_id: '3', param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' }, - { type_id: '3', param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' }, - { type_id: '3', param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' }, - { type_id: '3', param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' }, - { type_id: '3', param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' }, - ], - 'parts.profile': [ - // 原 PN 转挂新类型 —— distinct(chip_type_id) 要求两颗归同一个值,新建类型时 - // 两颗都得落在它上面,不是只给候选料一颗挂新号。 - { internal_pn: 'LM2596S-5.0', chip_type_id: '3' }, - { - internal_pn: 'XL2596S-5.0', - chip_type_id: '3', - brand_name: 'XLSEMI', - manufacturer_name: 'XLSEMI', - specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', - lifecycle_status: 'active', - datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', - datasheet_version: 'Rev C', - }, - ], - // LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填; - // 只需交代候选料 XL2596S-5.0 那一份。 - 'parts.parameter': paramRows('XL2596S-5.0', [ - ['输入电压范围', '4.5~40', 'V', '客户上限 40V'], - ['输出电压', '5.0', 'V', '固定版'], - ['输出电流', '3', 'A', '满载能力'], - ['开关频率', '150', 'kHz', '影响外围'], - ['静态电流', '4.2', 'mA', '待机功耗'], - ['工作温度', '-40~+125', '℃', '工业温域'], - ['使能阈值', '未披露', 'V', '原厂未给'], - ]), + /* + * **真走「新建芯片类型」这条路,两步走**(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` + * 挡着,写一个编造的号会被当场拒)。与真人在浏览器里「先建类型 → 保存 → 新类型出现在 + * 下拉里 → 再选它」同一个两步顺序,不是抄近道 —— 只是脚本自己读回来,不用眼睛看下拉。 + */ + customFill: async (taskId) => { + // ① 新建芯片类型:表尾静态种子行只填名称与说明,type_id 留空走 autoKeys() 续号 + await api(`/api/desk/tasks/${taskId}/table`, { + method: 'POST', + body: JSON.stringify({ + tables: { + 'parts.chip_type': [ + { + type_name: 'DC-DC 降压稳压器(评估自定义)', + description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。', + }, + ], + }, + }), + }); + // ② 读回基座续出的 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}`); + // ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+ + // 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。 + // LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。 + await api(`/api/desk/tasks/${taskId}/table`, { + method: 'POST', + body: JSON.stringify({ + tables: { + 'parts.type_param': [ + { type_id: typeId, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' }, + { type_id: typeId, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' }, + { type_id: typeId, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' }, + { type_id: typeId, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' }, + { type_id: typeId, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' }, + { type_id: typeId, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' }, + { type_id: typeId, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' }, + ], + 'parts.profile': [ + { internal_pn: 'LM2596S-5.0', chip_type_id: typeId }, + { + internal_pn: 'XL2596S-5.0', + chip_type_id: typeId, + brand_name: 'XLSEMI', + manufacturer_name: 'XLSEMI', + specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz', + lifecycle_status: 'active', + datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', + datasheet_version: 'Rev C', + }, + ], + 'parts.parameter': paramRows('XL2596S-5.0', [ + ['输入电压范围', '4.5~40', 'V', '客户上限 40V'], + ['输出电压', '5.0', 'V', '固定版'], + ['输出电流', '3', 'A', '满载能力'], + ['开关频率', '150', 'kHz', '影响外围'], + ['静态电流', '4.2', 'mA', '待机功耗'], + ['工作温度', '-40~+125', '℃', '工业温域'], + ['使能阈值', '未披露', 'V', '原厂未给'], + ]), + }, + }), + }); }, // 使能阈值未披露 → UNKNOWN → 有条件推荐(规则算的,不是模型说的) expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' }, @@ -809,13 +841,22 @@ async function api(path, init = {}) { } async function login() { - if (DIRECT_BEARER !== '') { - if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)'); + const svcReady = KC_URL !== '' && E2E_CLIENT_ID !== '' && E2E_CLIENT_SECRET !== ''; + /* + * **服务账号三个变量全齐时走正路,`JCAI_E2E_BEARER` 只在缺任意一个时才兜底** + * (2026-08-30 改序,见 `DIRECT_BEARER` 声明处的大注释)—— 从前 `DIRECT_BEARER !== ''` + * 无条件优先,环境里留着一枚旧 token 就会悄悄绕开服务账号一整轮,还只有一行不起眼的日志。 + */ + if (!svcReady && DIRECT_BEARER !== '') { + if (bearer === '') { + console.log(' ⚠️ 身份:JCAI_E2E_BEARER 直供的 token(服务账号三个变量缺了至少一个,降级用它)——'); + console.log(' 不续期;卡会落到这枚 token 主人的真实工作台上,不是「绝不派到用户工作台」那条正路。'); + } bearer = `Bearer ${DIRECT_BEARER}`; bearerAt = Date.now(); return; } - if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') { + if (!svcReady) { throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012);临时可用 JCAI_E2E_BEARER 直供一枚'); } const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { @@ -1230,6 +1271,8 @@ async function runCase(suite, kase) { let dataCardSeen = false; /** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */ let gateReasonMatched = false; + /** `kase.customFill` 只跑一次(w05「候选缺库·新建类型」:两阶段写 —— 先铸 type_id,读回来再引用它) */ + let customFilled = false; const deadline = Date.now() + 20 * 60 * 1000; let last; @@ -1356,6 +1399,18 @@ async function runCase(suite, kase) { : ` ⚠️ 缺着必填还放过去了:提交闸失效`, ); } + /* + * **两阶段自定义填表**(w05「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、 + * 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道 + * 引用它的 `parts.type_param`/`parts.profile` 该填哪个值(与真人在浏览器里「先建类型 → + * 保存 → 新类型出现在下拉里 → 再选它」的两步顺序同构,不是脚本抄近道)。跑过一次就不再跑, + * 跑完之后 `buildEdits` 对这张卡照常算(该案例没有别的 `stageEdits`,算出来是空, + * 只会去交卷)。 + */ + if (kase.customFill && !customFilled) { + customFilled = true; + await kase.customFill(taskId); + } const lines = buildEdits(stage, body.drafts ?? [], kase); // 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增 if (kase.adds && kase.adds.address === stage.address && !addedRows) {