Archived
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:
@@ -50,10 +50,19 @@ describe('清单本身合规', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* 2026-08-29 重写:spec §7 的判据表从九条改成八条(五条机检 L1/L2 + 两条 L4 裁量 + 一条 L5),L4 从三条收到两条 —— 证据链与硬约束两条留 L4,「推荐等级与明细一致」已升级成 L2 机检(level-by-rule) */
|
/* 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) {
|
for (const c of manifest.contract.acceptanceCriteria) {
|
||||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate, c.key).toBeDefined();
|
if (c.level === 'L1' || c.level === 'L2' || c.level === 'L4') expect(c.predicate, c.key).toBeDefined();
|
||||||
if (c.level === 'L4') expect(c.predicate, c.key).toBeUndefined();
|
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 === 'L4')).toHaveLength(2);
|
||||||
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1);
|
expect(manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L5')).toHaveLength(1);
|
||||||
|
|||||||
@@ -146,13 +146,41 @@ describe('S1 配方', () => {
|
|||||||
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
|
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
|
||||||
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
|
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
|
||||||
*/
|
*/
|
||||||
it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
|
it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
|
||||||
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
|
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']);
|
||||||
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
|
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 铸,只从本单开案时刻取材', () => {
|
it('案号由 let 铸,只从本单开案时刻取材', () => {
|
||||||
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
|
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
|
||||||
});
|
});
|
||||||
@@ -167,7 +195,7 @@ describe('S1 配方', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
|
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
|
||||||
const params = acts[2];
|
const params = acts[4];
|
||||||
expect(params?.kind === 'project' && params.prune).toBe(true);
|
expect(params?.kind === 'project' && params.prune).toBe(true);
|
||||||
const from = params?.kind === 'project' ? params.from : undefined;
|
const from = params?.kind === 'project' ? params.from : undefined;
|
||||||
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
|
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
|
||||||
@@ -177,7 +205,7 @@ describe('S1 配方', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
|
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
|
||||||
const card = acts[4];
|
const card = acts[6];
|
||||||
const when = card?.kind === 'card' ? (card.when ?? '') : '';
|
const when = card?.kind === 'card' ? (card.when ?? '') : '';
|
||||||
/*
|
/*
|
||||||
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
|
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
|
||||||
@@ -189,26 +217,25 @@ describe('S1 配方', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 2026-08-29 评审 Critical(Task 7 走查):`gateLedgers` 一度带着 `parts.chip_type`,
|
* 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意):
|
||||||
* 而 `input-supplement` 这张件没有任何 `data-edit="parts.chip_type"`(选类型走
|
* 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然
|
||||||
* `data-options` 下拉现读,不写这张表)—— `mine:parts.chip_type` 永远是空数组,
|
* 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。
|
||||||
* `desk.ts` 的 `emptyGate` 会永远判「这一单还一行都没有」,提交钮永久按不下去。
|
* 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表,
|
||||||
* **`parts.chip_type` 永久不列进这张卡的闸**(不是等新建类型的编辑表补上就该还原 ——
|
* `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地,
|
||||||
* 见 `case-model.ts` 该处注释:主路是从下拉挑一个已有类型,那时这张表天然零行,
|
* 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用
|
||||||
* `emptyGate`「这张卡就是来建它的」这条语义对一张只是有时候才写的表根本不成立)。
|
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
|
||||||
*/
|
*/
|
||||||
it('卡:数据工单、三张真被这张件写过的表在闸上(parts.chip_type 不在——它没有编辑手段,列了就永久卡死)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
||||||
const card = acts[4];
|
const card = acts[6];
|
||||||
expect(card?.kind === 'card' && card.as).toBe('execution');
|
expect(card?.kind === 'card' && card.as).toBe('execution');
|
||||||
expect(card?.kind === 'card' && card.variant).toBe('data');
|
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).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
|
||||||
expect((card?.kind === 'card' ? card.gateLedgers ?? [] : [])).not.toContain('parts.chip_type');
|
|
||||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||||
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
|
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
|
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
|
||||||
const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
|
const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : '';
|
||||||
for (const reading of [
|
for (const reading of [
|
||||||
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
|
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
|
||||||
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_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 recipe = stepOf('S2_assess')?.auto;
|
||||||
const acts = recipe?.do ?? [];
|
const acts = recipe?.do ?? [];
|
||||||
|
|
||||||
it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => {
|
it('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => {
|
||||||
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']);
|
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']);
|
||||||
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
|
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', () => {
|
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' });
|
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
|
||||||
/*
|
/*
|
||||||
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
|
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
|
||||||
@@ -247,7 +278,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('**值不取 AI 出参,一律 lookup 台账**', () => {
|
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) {
|
for (const col of ['original_value', 'candidate_value'] as const) {
|
||||||
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
||||||
}
|
}
|
||||||
@@ -256,7 +287,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
|
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']);
|
const jc = JSON.stringify(fields?.['judgement_class']);
|
||||||
expect(jc).toContain('hard_mismatch');
|
expect(jc).toContain('hard_mismatch');
|
||||||
expect(jc).toContain('soft_mismatch');
|
expect(jc).toContain('soft_mismatch');
|
||||||
@@ -265,7 +296,8 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 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']);
|
const level = JSON.stringify(rel?.['recommendation_level']);
|
||||||
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
|
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
|
||||||
expect(level).toContain('not_recommended');
|
expect(level).toContain('not_recommended');
|
||||||
@@ -274,7 +306,7 @@ describe('S2 配方 —— AI 只出场一次', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
|
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.into).toBe('validation.record');
|
||||||
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
|
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
|
||||||
const fields = val?.kind === 'project' ? val.fields : undefined;
|
const fields = val?.kind === 'project' ? val.fields : undefined;
|
||||||
@@ -320,8 +352,10 @@ describe('S3 / S4 配方', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
|
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
|
||||||
expect(s3[0]).toBe(s2[2]);
|
// S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)——
|
||||||
expect(s3[1]).toBe(s2[3]);
|
// 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次)
|
||||||
|
expect(s3[0]).toBe(s2[3]);
|
||||||
|
expect(s3[1]).toBe(s2[4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -241,10 +241,10 @@ export const ASSESSMENT_STEPS = [
|
|||||||
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
|
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
|
||||||
guide: '',
|
guide: '',
|
||||||
/* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值),
|
/* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值),
|
||||||
与 auto.do 第 ⑤ 笔 `card` 的 `gateLedgers` 同源(parts.type_param / parts.profile / parts.parameter ——
|
与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param /
|
||||||
`parts.chip_type` 不列入,理由见那笔 `card` 定义处的注释)。 */
|
parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */
|
||||||
view: 'input-supplement',
|
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'],
|
reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
|
||||||
tools: [],
|
tools: [],
|
||||||
doneWhen:
|
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` 必填。
|
* **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。
|
||||||
* 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
|
* 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
|
||||||
@@ -300,7 +375,7 @@ export const ASSESSMENT_STEPS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
/*
|
/*
|
||||||
* ③ 参数骨架 —— 三件事一笔办完:
|
* ⑤ 参数骨架 —— 三件事一笔办完:
|
||||||
* · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**;
|
* · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**;
|
||||||
* 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
|
* 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
|
||||||
* · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报;
|
* · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_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`,
|
* `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`,
|
||||||
* 而这两个数是 ②③ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ②③ **之前**
|
* 而这两个数是 ④⑤ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ④⑤ **之前**
|
||||||
* (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id`
|
* (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id`
|
||||||
* 刚在这一拍被 ① 设上、②③ 即将把它的主数据与参数骨架铺出来时,那一刻 ③ 即将新建的
|
* 刚在这一拍被 ① 设上、④⑤ 即将把它的主数据与参数骨架铺出来时,那一刻 ⑤ 即将新建的
|
||||||
* 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」),
|
* 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」),
|
||||||
* 片刻之后 ③ 一跑就变成 1。收步与发卡的把关本身没坏(⑤ 排在 ②③④ 之后,`card.when`
|
* 片刻之后 ⑤ 一跑就变成 1。收步与发卡的把关本身没坏(⑥ 排在 ④⑤ 之后,`card.when`
|
||||||
* 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面
|
* 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面
|
||||||
* `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。
|
* `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。
|
||||||
* **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」,
|
* **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」,
|
||||||
@@ -385,7 +460,7 @@ export const ASSESSMENT_STEPS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
/*
|
/*
|
||||||
* ⑤ 缺什么就派一张数据工单。
|
* ⑦ 缺什么就派一张数据工单。
|
||||||
* **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定,
|
* **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定,
|
||||||
* 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。
|
* 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。
|
||||||
*/
|
*/
|
||||||
@@ -411,19 +486,22 @@ export const ASSESSMENT_STEPS = [
|
|||||||
],
|
],
|
||||||
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
|
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
|
||||||
/*
|
/*
|
||||||
* **`parts.chip_type` 永久不列在这里** —— 不是等 Task 7b 补了「新建类型」那张编辑表
|
* **`parts.chip_type` 与 `parts.type_param` 都在闸上**(2026-08-30 改,Ruling H35)——
|
||||||
* 就该加回来(2026-08-29 评审逮到的 Critical:这张件此刻没有任何 `data-edit`
|
* 从前 `parts.chip_type` 被永久排除在外,理由是「它没有编辑手段,`mine:` 天然为空,
|
||||||
* 能写 `parts.chip_type`,列了它,`mine:parts.chip_type` 永远是空数组,
|
* 列了就永久卡死」;那条理由**从 ②③ 两笔自我投影加上之后就不再成立**——
|
||||||
* `desk.ts` 的 `emptyGate` 就永远判「这一单还一行都没有 —— 这张卡就是来建它的」,
|
* `mine:parts.chip_type`/`mine:parts.type_param` 现在恒不为空(只要 `sub_eval.part`
|
||||||
* 提交钮当场且永久按不下去,三张表填得再全也一样)。
|
* 里已经选定了类型),闸不会再误判「一行都没有」。
|
||||||
* 更深一层:即便 Task 7b 真的补上了那张编辑表,也不该把它放回来 —— `emptyGate`
|
*
|
||||||
* 的语义是「这张卡**就是来建**这张表的,一行都没有 = 没干活」,而
|
* **既然要落地就必须在名单里,这不是碰运气**:「数据工单确认即入库」把 `gateLedgers`
|
||||||
* `parts.chip_type` 的主路是**从下拉挑一个已有类型**,那时 `mine:parts.chip_type`
|
* 列出的表整批并入正式表;`parts.chip_type` 若不在这份名单里,人新建的类型字典行
|
||||||
* 天然就是零行(没新建、没问题);只有走「新建类型」那条岔路时才会写它。
|
* 就只留在草稿层,而引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 ——
|
||||||
* 一张**只是有时候才写**的表,套「零行 = 没干完」这条闸的前提本身就不成立 ——
|
* 落地那一刻起,正式表里就有一行指着字典里查无此号的悬空引用(2026-08-30 真跑
|
||||||
* 该拦的是 `parts.type_param`(核心参数字段清单,新建类型时**一定**要写)。
|
* 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: '已核对两颗料的在库状态与参数齐备情况。' },
|
{ 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`,提交闸与放行门当场拦住。
|
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
|
||||||
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
|
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
|
|||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'ACT-WORK-05 · 差异评估',
|
name: 'ACT-WORK-05 · 差异评估',
|
||||||
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
|
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
|
||||||
packageVersion: '2.1.1',
|
packageVersion: '2.1.5',
|
||||||
category: '需求分析与方案匹配',
|
category: '需求分析与方案匹配',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-part-substitution-assessment',
|
checksum: 'dev-checksum-part-substitution-assessment',
|
||||||
@@ -179,8 +179,39 @@ export const manifest: ActivityManifest = {
|
|||||||
level: 'L2',
|
level: 'L2',
|
||||||
predicate: 'validation_record_rows = 1 and validation_record_missing = 0',
|
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 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
|
||||||
{ key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-06 落账,结论供下游消费', level: 'L5' },
|
{ key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-06 落账,结论供下游消费', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
|||||||
+129
-17
@@ -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 对行) */
|
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
|
||||||
const SUITES = {
|
const SUITES = {
|
||||||
a1: {
|
a1: {
|
||||||
@@ -625,21 +642,33 @@ const SUITES = {
|
|||||||
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
|
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
|
||||||
expectAnalyzeCalls: 1,
|
expectAnalyzeCalls: 1,
|
||||||
/*
|
/*
|
||||||
* **真走「新建芯片类型」这条路,两步走**(Task 7b,2026-08-29 补齐件①的编辑面之后):
|
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
|
||||||
* `parts.chip_type.type_id` 是自增列(`auto:true`,声明 `text`)—— 件上那颗静态种子行
|
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):
|
||||||
* 只收 `type_name`/`description`,`type_id` 由 `autoKeys()` 续号铸出来,写之前**不知道**
|
* `resolveRowKey`(`apps/api/src/app/routes/desk.ts`)在**续号之前**先试「全表一致」
|
||||||
* 铸出来的号是什么;`parts.type_param`/`parts.profile` 引用的正是这个号(`refersTo`
|
* ——本单草稿里这张表若已经**恰好一种** `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) => {
|
customFill: async (taskId) => {
|
||||||
// ① 新建芯片类型:表尾静态种子行只填名称与说明,type_id 留空走 autoKeys() 续号
|
const typeId = '90';
|
||||||
|
// ① 新建芯片类型:显式给 type_id(见上方大注释,不走 autoKeys 续号)
|
||||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tables: {
|
tables: {
|
||||||
'parts.chip_type': [
|
'parts.chip_type': [
|
||||||
{
|
{
|
||||||
|
type_id: typeId,
|
||||||
type_name: 'DC-DC 降压稳压器(评估自定义)',
|
type_name: 'DC-DC 降压稳压器(评估自定义)',
|
||||||
description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。',
|
description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。',
|
||||||
},
|
},
|
||||||
@@ -647,14 +676,7 @@ const SUITES = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// ② 读回基座续出的 type_id —— 与真人「保存后新类型出现在下拉里」看到的是同一份数据
|
console.log(` 新建类型已保存,显式指定 type_id=${typeId}(绕开全表一致平台坑,已报未改)`);
|
||||||
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)+
|
// ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+
|
||||||
// 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。
|
// 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。
|
||||||
// LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。
|
// 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,
|
sample: 1,
|
||||||
@@ -1261,6 +1335,17 @@ async function runCase(suite, kase) {
|
|||||||
let addedRows = false;
|
let addedRows = false;
|
||||||
let planned = false;
|
let planned = false;
|
||||||
let revised = 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 acceptEdited = false;
|
||||||
let minted = false;
|
let minted = false;
|
||||||
let forbidHit = false;
|
let forbidHit = false;
|
||||||
@@ -1341,6 +1426,13 @@ async function runCase(suite, kase) {
|
|||||||
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
|
(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 起手就成立,断言一张数据工单都没出现过
|
// w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过
|
||||||
if (kase.expectNoDataCard && !dataCardSeen) {
|
if (kase.expectNoDataCard && !dataCardSeen) {
|
||||||
dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.match.test(c.title ?? '')));
|
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})—— 归属又认错了,判失败`);
|
console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`);
|
||||||
ok = false;
|
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) {
|
if (kase.acceptEdits && !minted) {
|
||||||
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
|
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
|
||||||
ok = false;
|
ok = false;
|
||||||
@@ -1650,6 +1746,22 @@ async function main() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(` ✗ 这一例中途出错,判失败,接着跑下一例:${String(error.message ?? error).slice(0, 200)}`);
|
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 清运行数据`);
|
console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);
|
||||||
|
|||||||
Reference in New Issue
Block a user