feat(w05): S2 关系表拆两笔写、S1 自投影类型字典、e2e 跨案例隔离 —— 真跑逼出来的三处

① S2:substitution.relation 拆成两笔同主键覆盖写,中间夹 difference_item ——
   difference_item.substitution_relation_id 声明了 refersTo(要求父行已存在),
   而 relation 的等级/风险/摘要要读 difference_item 的 tally,两张表互相要对方先写。

② S1:总把选中的那一行 chip_type / type_param 原样投一遍(幂等),
   于是这一单的草稿层永不为空 —— parts.chip_type 得以放回 gateLedgers,
   新建的类型随「数据工单确认即入库」一起落进正式表,不再留悬空引用。
   (此前把它删出名单只顾了闸、没顾落地:引用它的表落了地而它自己没落。)

③ e2e:案例 0 之后做跨案例隔离(withDb + isolationCleanup,不论成败都跑、
   失败只告警不拖垮整套)—— LAND_MASTER=1 之下案例 0 为满足 distinct=1 必须把
   共享的原 PN 转挂新类型,那一改会落进主数据,案例 1 共用同一颗料就看到假的类型不一致。
   断言 expectRevised 改名 expectFinalValue:「点名驳回归属」那条对 W05 不适用
   (四步 guide 全空、唯一的 agent 调用发生在意见之前,没有轮次会读到那条观察)。

包版本 2.1.5。pnpm gates 退出码 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-30 07:06:52 +08:00
co-authored by Claude Opus 5
parent 6636abfc39
commit d420284887
5 changed files with 357 additions and 73 deletions
+129 -17
View File
@@ -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 清运行数据`);