This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/tools/e2e-a2.mjs
T
xcsandClaude Fable 5 c4219a7b7d feat(w05+platform): 确认闸条件档 gateWhen —— 类型不一致不许确认;量高改可缩;卡间互换动画
2026-08-31 用户三连:

① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」——
  缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 card 声明
  gateWhen(S-FEEL,只读基座读数)+ gateWhenSay,受理回传前在 submitGate 现算
  (rows/missing/tally/distinct 全套读数按声明表现读),为假 400、拒回用发卡人那句话;
  装包静态审与动作级 when 同一套审;评估不出的条件不成墙(坏表达式装包就拦)。
  W05 钉 parts_profile_distinct_chip_type_id <= 1 —— 钉 profile 本表(草稿现读零滞后;
  钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦),profile.chip_type_id
  加 distinct 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。

② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从
  max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身
  高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次,
  底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。

③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿,
  走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的
  就是这件事。reduced-motion 全静。

测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙)
+ steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx);
:8787 已重建重启。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:43:08 +08:00

1734 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A1 / A2 / A3 / A4 端到端跑测 —— 验收口径:全部 released、行动被拒 0。
*
* node tools/e2e-a2.mjs a2 # 只跑 A2 三案例
* node tools/e2e-a2.mjs a3 # 只跑 A3 三案例
* node tools/e2e-a2.mjs a4 # 只跑 A4 三案例(含决策卡代答)
* node tools/e2e-a2.mjs a6 # 只跑 A6 三案例(客户回音代答 + 决策卡,三口各一条)
* node tools/e2e-a2.mjs a7 # 只跑 A7 三案例(归因代答 + 入库口径决策卡,三档各一条)
* node tools/e2e-a2.mjs a1 # 只跑 A1 回归(执行卡零输入 + 提交闸回落)
* node tools/e2e-a2.mjs e04 # 只跑 E04 三案例(两张执行单 + 决策卡审批循环)
* node tools/e2e-a2.mjs i01 # 只跑 I01 三案例(一张执行单 + 初稿审核决策卡 + 发布放行)
* node tools/e2e-a2.mjs w05 # 只跑 W05 五案例(ACT-WORK-05 差异评估:条件数据工单 P-08/P-10 + 执行单 + 决策卡审核循环 + 放行)
* node tools/e2e-a2.mjs # 全跑
* node tools/e2e-a2.mjs a2 1 # 只跑 A2 的第 1 条(0 起)
*
* 前置:./start.sh 起着(api:8787);模型实例已绑密钥。
* 跑完记得:pnpm --filter @app/api reset-runs(清跑出来的,留配置好的)。
*
* 试跑一律用 AI 账号(铁规矩) —— 起单不指派,testbed 默认派给操作者自己。
*
* **执行卡零输入**(2026-08-02 用户定):卡上没有任何表单字段,填写全在工作区的
* 件上(POST /tasks/:id/table,按地址与列名,与真人保存同一条链);交卷载荷
* `values: {}`。A2 逐颗派单:每张卡只把**当前 NOW 那一颗**的行填了再交卷。
* 必填(如 A3 底价、A1 年用量)由提交闸看住 —— 案例里先空手交一次、
* 吃到 400 才算闸真的在(expectGateBlock)。
*/
const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787';
/** 身份归统一鉴权中心(ADR-012):e2e 用服务账号的 client_credentials 拿 token,不用人的账号 */
const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, '');
const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp';
const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? '';
const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
/**
* **直供 token 的旁路**(`JCAI_E2E_BEARER`)—— 手边没有服务账号客户端时的临时通道:
* 直接给一枚已经拿到手的 access token(比如从浏览器会话里取的、有效期调长过的)。
* 两处代价说在前头,别不知不觉踩上:
* ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的);
* ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。
*
* **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();
/**
* **要不要落真库**(`JCAI_E2E_LAND_MASTER=1`)—— 默认关:起单接口默认开着(人手点那条路要看见数据真变),
* 而 e2e 关它是为了保护「只能测一次」的样例(A1 新客户来函 / W05 候选缺库:主数据一旦真建进去,
* 第二次跑那条路就成了老客户 / 都在库)。开着跑一次能验「数据工单确认即入库」真的落到了正式表 ——
* 跑完记得把建进去的料号清掉,不然 W05 案例 0/2 从此测不出缺库那条路。
*/
const LAND_MASTER = process.env.JCAI_E2E_LAND_MASTER === '1';
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
/**
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 说明]
* 四元组批量转成保存载荷的行对象。
*
* **2026-08-29 Task 11 四元组(从前是五元组)**:`constraint_type` 已随四步重写搬去
* `parts.type_param`(约束按类型标一次,不再逐颗料各标一遍 —— 见 manifest.ts 该字段的头注)。
* `parts.parameter` 现在只有 internal_pn / param_name / param_value / param_unit / param_note
* 五列,没有 `constraint_type` 这一列了;继续按老的五元组喂会把一个物理表没有的列塞进 patch。
*/
function paramRows(internalPn, rows) {
return rows.map(([param_name, param_value, param_unit, param_note]) => ({
internal_pn: internalPn,
param_name,
param_value,
param_unit,
param_note,
}));
}
/**
* **跨案例隔离用的直连 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: {
activityId: 'quote-intake',
address: 'quote_intake.record',
cases: [
{
sample: 1,
name: '老客户 Itelma · 缺年用量 → 提交闸拦 → 件上补 → 放行(执行卡零输入回归)',
// 缺年用量的行(哪几行缺随模型抽取略有出入)—— 空手交卷必须被服务端的闸拦下
expectGateBlock: true,
edits: [{ pn: 'DAG2400L321504XZB01-R0', eau: '30000' }],
// 兜底:凡年用量还空着的行都补上 —— 闸点名哪行缺,人就补哪行,这里替人扫一遍
fillMissing: { column: 'eau', value: '25000' },
expectPort: 'to-alternative',
},
],
},
a2: {
activityId: 'quote-alternative',
address: 'quote_alternative.list',
// 一张执行单,件里一颗一页:一次把全部待选型行的定案表存,卡交回 values:{}
blankCol: 'alt_pn',
cases: [
{
sample: 0,
name: '流内常路 · 两颗 C 逐颗派单 · 替代无历史 → 转 B 去寻源',
// 定案三格是 NOW 行的必填 —— 空手交卷必须被闸拦(400)
expectGateBlock: true,
edits: [
{
pn: 'BBPY00201209601Y00',
alt_pn: 'PBY201209T-601Y-N',
alt_brand: 'YAGEO',
alt_grade: 'P2P',
alt_diff: '五项全核(封装尺寸/引脚/电气/温度/认证),无差异',
alt_risk: '直换,免改动',
alt_verify: '免打样,首批抽检即可',
alt_note: '客户预授权替代(BOM Analog 列)',
},
{
pn: 'DAG2400L321504XZB01-R0',
alt_pn: 'ANT-2400-KLM3216',
alt_brand: 'Kinsun',
alt_grade: 'FUNC',
alt_diff: '同频段 2.4G,贴片尺寸 3216 同;馈点位置差 0.4mm',
alt_risk: '需微调 layout 馈线;整机方向图需复测',
alt_verify: '打样验证驻波比与方向图',
alt_note: '原厂 EOL,官网规格书 2026-03 版核对',
},
],
expectPort: 'to-sourcing',
},
{
sample: 1,
name: '点名替代 · 替代料库内行情稳 → 转 A 直通定价',
expectGateBlock: true,
edits: [
{
pn: 'TL072CDR',
alt_pn: 'CJTB0101R6N',
alt_brand: 'Jiangsu Changjing',
alt_grade: 'FUNC',
alt_diff: '封装同 SOIC-8,引脚兼容;压摆率低 0.5V/us',
alt_risk: '音频链路无影响;高速采样场景需评估',
alt_verify: '建议打样验证增益带宽与噪声',
alt_note: '国产替代,客户接受国产优先',
},
],
expectPort: 'to-pricing',
},
{
sample: 2,
name: '单飞宽进 · 类型需求归一 → 替代无历史转 B',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前 NOW 那一颗」
edits: [
{
pn: '',
alt_pn: 'GS8558-SR',
alt_brand: 'Gainsil',
alt_grade: 'FUNC',
alt_diff: '供电 2.7-36V 覆盖;AEC-Q100 一级;失调电压略大',
alt_risk: '外围无需改动;精密测量场景需复核失调',
alt_verify: '打样验证失调与温漂',
alt_note: '原厂官网规格书核对,版本 2025-11',
},
],
expectPort: 'to-sourcing',
},
],
},
/*
* A4 成本定价:归一算建议价 → 一张执行单交 BD **定价** → **超限才叫人拍板**(决策卡)
* → 放行。必填列是 `quoted_price`(拟报单价):**这一列 AI 一格都不写**,
* 系统只给 `suggested_price`(建议价)—— 所以三条案例都必然先吃一次 400,
* 闸真不真在这里每一单都验一遍(结构治:不靠模型分辨「这行算得出/算不出」)。
*/
a4: {
activityId: 'quote-pricing',
address: 'quote_pricing.draft',
blankCol: 'quoted_price',
cases: [
{
sample: 0,
name: '寻源转来 · 两条底价齐 · 定的价都在底价线之上(常路,不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', quoted_price: '0.085', price_note: '客户长单,按目标价接' },
{ pn: 'CJTB0101R6N', quoted_price: '0.42', price_note: '照建议价' },
],
expectPort: 'draft',
},
{
sample: 1,
name: '客户压价 · 定的价低于底价线两成以上 → 决策卡叫人拍板(回调到底价线)',
expectGateBlock: true,
edits: [
// 0.06 对底价线 0.0943(成本 0.082 × 1.15)—— 偏离 −36%,门禁必判拦截
{ pn: 'PESD2ETH-XR', quoted_price: '0.06', price_note: '客户拿竞对价压,先照客户价挂着' },
{ pn: 'NE555P', quoted_price: '1.05', price_note: '照建议价' },
],
// 决策卡上选哪一条:回调到底价线 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'floor',
expectDecision: true,
expectPort: 'draft',
},
{
sample: 2,
name: '单飞宽进 · 库内无基准连建议价都没有 → 价全由人定(空手交卷吃 400)',
expectGateBlock: true,
edits: [
{ pn: '', quoted_price: '14.8', price_note: '按同类电源模块经验价,毛利 18%' },
{ pn: '', quoted_price: '2.35', price_note: '按同类稳压器经验价,毛利 20%' },
],
expectPort: 'draft',
},
],
},
/*
* A6 报价议价:归一排版报价函 → 一张执行单交 BD(发出去 + 把**客户回音**落回来)
* → 逐条定案 → **还价才叫人拍板**(决策卡)→ 放行,三口的去向由谓词算出来。
*
* 必填列是 `reply_type`(客户表态):**这一列 AI 一格都不写** —— 客户回了什么
* 只有收信的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* **客户回音就是靠这一手喂进去的**:脚本扮演 BD,把客户的话按地址存进件上
* (`POST /tasks/:id/table`),再零输入交卷 —— 与真人在工作区填格子同一条链。
* 三条案例覆盖三个出口:won / revise(决策卡拍板)/ lost。
*/
a6: {
activityId: 'quote-negotiate',
address: 'quote_negotiate.line',
blankCol: 'reply_type',
cases: [
{
sample: 0,
name: '流内常路 · 客户一轮全接受 → 成交转履约(不叫人拍板)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', reply_type: 'ACCEPT', reply_note: '客户回:按这个价走,PO 下周发' },
{ pn: 'CJTB0101R6N', reply_type: 'ACCEPT', reply_note: '客户回:可以,一起下' },
],
expectPort: 'won',
},
{
sample: 1,
name: '客户压价两成 · 让幅超授权 → 决策卡拍板退回定价重批',
expectGateBlock: true,
edits: [
// 报价 0.098 → 客户还 0.078:让幅 20.4%,让步梯度必判 ESCALATE(超授权)
{
pn: 'PESD2ETH-XR',
reply_type: 'COUNTER',
counter_price: '0.078',
reply_note: '客户回:竞对给到 0.078,这个价才做,不然这颗给别家',
},
{ pn: 'NE555P', reply_type: 'ACCEPT', reply_note: '客户回:这颗没问题' },
],
// 决策卡上选哪一条:退回成本定价重批 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'revise',
expectDecision: true,
expectPort: 'revise',
},
{
sample: 2,
name: '单飞宽进 · D+3/D+7 两轮催问无回复 → 超时定案转情报归档',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
{ pn: '', reply_type: 'NOREPLY', reply_note: 'D+3 已催、D+7 最后一催,客户始终未表态' },
],
// 兜底:凡表态还空着的行都按无回复落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'reply_type', value: 'NOREPLY' },
expectPort: 'lost',
},
],
},
/*
* A7 情报归档:归一情报卡 → 一张执行单交 BD(逐条落**输赢归因**与竞对价)
* → 按败价幅度 A-01 算档 → **入库口径要人拍板**(决策卡)→ 放行,一口 archived。
*
* 必填列是 `cause_tag`(输赢归因):**这一列 AI 一格都不写** —— 客户为什么不下单
* 只有跟单的人知道。所以三条案例都必然先吃一次 400,闸真不真每一单都验一遍。
* 归因落 PRICE 的行还有一道**行级必填**(`competitor_price`),
* 案例一与案例三各验一遍:归因与竞对价一起存,闸才放行。
* 决策卡**每一单都发**(口径是一条对外边界,不由系统自己越过)——
* 三条案例的 `decide` 各选一档:全量 / 脱敏 / 只留内部。
*/
a7: {
activityId: 'quote-archive',
address: 'quote_archive.intel',
blankCol: 'cause_tag',
cases: [
{
sample: 0,
name: '流内失单 · 竞对压两成 → 归因 PRICE + 竞对价 → 脱敏入库',
expectGateBlock: true,
edits: [
// 报价 0.098 → 竞对 0.078:价差 20.4%,败价幅度必判 STRUCTURAL
{
pn: 'PESD2ETH-XR',
cause_tag: 'PRICE',
competitor_price: '0.078',
competitor_name: '深圳华强某代理',
evidence: '客户原话:别家给到 0.078,这个价才做',
},
{
pn: 'NE555P',
cause_tag: 'RELATION',
evidence: '客户原话:这颗一直在别家拿,一起给他了',
},
],
// 决策卡上选哪一档:脱敏入库 —— 选项值对不上时退回「推荐项 / 第一项」
decide: 'masked',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 1,
name: '成交归档 · 胜在交期与既有关系 → 全量入库(竞对价那道行级闸不该拦)',
expectGateBlock: true,
edits: [
{ pn: 'BBPY00201209601Y00', cause_tag: 'LEADTIME', evidence: '客户原话:你们八周能到,别家要十四周' },
{ pn: 'CJTB0101R6N', cause_tag: 'RELATION', evidence: '客户原话:这颗一直用你们的,一起下' },
],
decide: 'full',
expectDecision: true,
expectPort: 'archived',
},
{
sample: 2,
name: '单飞宽进 · 客户始终未表态 → 归因 OTHER · 只留内部',
expectGateBlock: true,
// 单飞的行是 AI 归一出来的,pn 事先不知道 —— 置空表示「填当前待填那一行」
edits: [
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
{ pn: '', cause_tag: 'OTHER', evidence: 'D+3 已催、D+7 最后一催,客户始终未获反馈' },
],
// 兜底:凡归因还空着的行都按说不清落 —— 闸点名哪行缺,人就补哪行
fillMissing: { column: 'cause_tag', value: 'OTHER' },
decide: 'internal',
expectDecision: true,
expectPort: 'archived',
},
],
},
/*
* E04 关系覆盖与关键人经营 —— 本仓第一个非报价管线的活动,也是第一个
* **一单里有两张不同表的执行单 + 一个决策卡循环**的套件:
* S2 覆盖范围确认(件上改 crm_coverage.candidate,键 customer_code)
* → S4 经营计划审批(决策卡:approve,或先 reject 再 approve 走一轮修订循环)
* → S5 执行回执(件上填 crm_coverage.receipt 四列,键 contact_code —— AI 只写骨架,
* 回执四列一格不写,空手交卷必吃 400,闸真不真每单验一遍)
* → T_release 验收 pass → released,发口 coverage-map。
* 两张执行单表不同 → 用 `stages` 按卡名对号(老套件没有 stages,走 suite 级老路)。
* 回执行数随计划行数走(模型定几位关键人),所以不写死 edits,
* 用 `fillAllBlank` 把回执列扫齐 —— 与真人「逐行填完保存」同一条链。
* 新客户建档路(entry 卡)不进本套件,浏览器走查覆盖它。
*/
e04: {
activityId: 'crm-relationship-coverage',
address: 'crm_coverage.receipt',
blankCol: 'completion_status',
stages: [
{
match: /覆盖范围确认/,
address: 'crm_coverage.candidate',
rowKey: 'customer_code',
// AI 起草的清单必填(编码/名称)本就齐 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
},
{
match: /执行回执|回执/,
address: 'crm_coverage.receipt',
rowKey: 'contact_code',
blankCol: 'completion_status',
expectGateBlock: true,
edits: [],
fillAllBlank: {
completion_status: 'done',
result_summary: '已完成拜访与交流,关系明显推进,达成下一步动作意向',
customer_feedback: '客户反馈积极,提出了后续技术支持诉求',
key_person_change: 'unchanged',
},
},
],
cases: [
{
// 样例 0 = 满载的那条(测试台落地即预填的也是它)
sample: 0,
name: '事件驱动 · 重大商机点名 CU-0142 · 人在确认卡上加一家 · 点名驳回后通过',
/*
* 第一张审批卡以**点名意见**驳回(复刻 2026-08-24 柴浩实测:「测试111建议动作
* 改成拜访CTO」被改到了 Itelma 头上)—— 意见点的是人加的那家客户,断言改动
* 落在**被点名那一行**(expectRevised),不是落到别家。
*/
decideSeq: ['custom:端到端新增客户的建议动作改成约见CTO并建立联系', 'approve'],
expectDecision: true,
expectPort: 'coverage-map',
expectRevised: { address: 'crm_coverage.plan', where: 'customer_code', equals: 'CU-E2E-ADD', column: 'action', contains: 'CTO' },
/*
* 查无联系人的新客户 → S3 先发数据录入卡收第一位关键人(2026-08-24 用户裁定:
* 计划里不许出现占位人名 —— 从前那两行「待识别」是模型把占位句式泛化出来的)。
*/
entryValues: { name: '测试CTO王工', title: 'CTO', decision_node: 'bt-02' },
forbidInPlan: ['待识别', '-00'],
/*
* 台账更新确认那一屏补录联系人(2026-08-24 用户定):编码留空 —— 自增列不问人,
* 由基座按「客户编码-两位序号」续号;断言草稿里真长出了带号的这一行。
*/
acceptEdits: {
address: 'customer.contact',
rows: [
{
customer_code: 'CU-E2E-ADD',
contact_code: '',
name: '测试副总李工',
title: '副总经理',
decision_node: 'bt-03',
influence: 'high',
relationship: '2',
key_person: 'true',
},
],
minted: { column: 'contact_code', prefix: 'CU-E2E-ADD-' },
},
/*
* **复刻 2026-08-24 柴浩的实测**:覆盖范围确认那张卡上人加一家台账里不存在的
* 客户 —— 从前它落进了台账,而计划/回执/收口把它从头无视到尾,released 照发。
* 现在覆盖闭合(计划客户数 = 候选行数)钉在 S3 收步/S4 哨兵/放行 L2 三处,
* 这一家没进计划这一单就走不下去;expectPlanned 再显式对一遍。
*/
adds: {
address: 'crm_coverage.candidate',
rows: [
{
customer_code: 'CU-E2E-ADD',
customer_name: '端到端新增客户',
cust_type: '组装厂',
bt_level: 'bt-03',
depth: 'standard',
basis: '人工核对时额外纳入(e2e)',
},
],
},
expectPlanned: { address: 'crm_coverage.plan', column: 'customer_code', value: 'CU-E2E-ADD' },
},
{
sample: 1,
name: '月度例行 · AI 自选候选 · 计划一次通过',
decide: 'approve',
expectPort: 'coverage-map',
},
{
sample: 4,
name: '攻坚项目 T04 · 决策卡先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'coverage-map',
},
],
},
/*
* I01 品牌形象与销售工具包管理 —— 内容生产型:一张执行单(需求受理)+ 两道验收
* (内容初稿审核走决策卡循环、发布确认走终局放行)。没有线下执行环节,
* 所以只有一张执行单,且**人不必在件上填什么** —— AI 识别的需求清单本就齐,
* 人的活是「查漏补缺」:这里替他补一格「适用场景」,证明就地改这条链是通的。
*/
i01: {
activityId: 'brand-content-toolkit',
address: 'content_brand.requirement',
stages: [
{
match: /需求受理|内容需求/,
address: 'content_brand.requirement',
rowKey: 'req_seq',
// 内容类型是 AI 写的必填,本就不缺 —— 空手交卷该是 nothing-missing,不设闸断言
expectGateBlock: false,
edits: [],
// 人的查漏补缺:凡场景还空着的行补一个(与真人在件上改同一条链)
fillAllBlank: { scene: '攻坚' },
blankCol: 'scene',
},
],
/*
* **这一单必须真产出资料文件**(2026-08-23 用户定):每一行内容资产都要能
* 按 `域.对象/主键` 取回一份文件,并且渲得出来 —— 渲不出来就不是文件,
* 是一行标题。四种格式各自读回来的形态在 `assertAssetFiles` 里逐条对。
*/
assetFiles: { address: 'content.asset', keyCol: 'content_id', formatCol: 'file_format' },
cases: [
{
sample: 0,
name: '被动触发 · E04 请求定向素材 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 1,
name: '主动触发 · 月度定期内容更新 · 初稿一次通过',
decide: 'approve',
expectPort: 'content-published',
},
{
sample: 2,
name: '进入新市场(俄罗斯)· 初稿先驳回再通过(修订循环)',
decideSeq: ['reject', 'approve'],
expectDecision: true,
expectPort: 'content-published',
},
{
sample: 3,
name: '对照表 / 落地页 / 速记 —— 三种形态各挑各的格式',
decide: 'approve',
expectPort: 'content-published',
// 这一单要的就是格式分得开:一封需求里三种形态,不许一律 docx
expectFormats: 2,
},
],
},
a3: {
activityId: 'quote-sourcing',
address: 'quote_sourcing.quote_list',
cases: [
{
sample: 0,
name: '选型转来 · 替代料问价(A 行历史价预填)',
edits: [{ pn: 'BBPY00201209601Y00', supplier: '深圳华强渠道', base_price: '0.058', moq: '3000', source_lead_time: '6周', note: '两家比价取低' }],
expectPort: 'quotation',
},
{
sample: 1,
name: '受理直达 · 两颗 B 类问价',
edits: [
{ pn: 'PESD2ETH-XR', supplier: 'Nexperia 代理商', base_price: '0.112', moq: '10000', source_lead_time: '8周', note: '' },
{ pn: 'NE555P', supplier: 'TI 现货商', base_price: '0.85', moq: '2500', source_lead_time: '4周', note: '现货实拍已核' },
],
expectPort: 'quotation',
},
{
sample: 2,
name: '单飞宽进 · 正文列料问底价(空手交卷先吃 400 —— 闸在)',
// 底价是必填列:没填之前交卷必须被拦;填齐两行再交
expectGateBlock: true,
edits: [
{ pn: '', supplier: '现货商甲', base_price: '0.05', moq: '3000', source_lead_time: '现货', note: '库存实拍已核' },
{ pn: '', supplier: '现货商乙', base_price: '0.21', moq: '1000', source_lead_time: '现货', note: '库存实拍已核' },
],
expectPort: 'quotation',
},
],
},
/*
* ACT-WORK-05 差异评估 —— **2026-08-29 Task 11 随四步重写整体换掉**(spec
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)。旧套件是九步 /
* 两枚工具 / 三张数据工单(P-08 主数据 + P-10 资料 + 评估输入清单)那个模型的素材,
* 新模型只有一张数据工单(`input-supplement`,三块竖排:要评估什么 / 芯片类型核心参数
* 字段 / 参数值,合在一张卡上、一次保存四张表)+ 一次 AI 分析(S2,零人环)+
* 一张审核决策卡(S3,可循环)+ 一张终局放行验收卡(T_release)。
* 库里播了 LM2596S-5.0 / LM2596S-ADJ(DC-DC 降压稳压器 type_id=1)与
* AMS1117-3.3(LDO 线性稳压器 type_id=2)三颗;XL2596S-5.0 / CN-DCDC-32V / LM2596S-3.3
* 刻意不在库。e2e 关落真库,主数据不落正式表,案例可反复跑。
*
* **两处平台机制这一次是第一个用到的调用方,`runCase`/`answerDecisions` 主循环里
* 补了对应支持**(其余八套件都没用过,行为不变):
* ① **人工验收清单**(2026-08-27 用户定,`PlanStep.acceptanceChecklist`):S3 审核卡
* 与 T_release 终局验收卡都带 `checklist`,选「通过」/`verdict:'pass'` 必须
* `checked ⊇ checklist`,不然回传吃 `checklist-incomplete`(400)。清单原文由
* 卡自己带下来(`card.checklist` / `workOrders[].checklist`),代答脚本原样照抄
* 回传,不必知道清单具体写的是哪几句话。
* ② **零数据卡直分析**(样例 1):S1 的 `doneWhen` 一开局就成立,`auto.do` 的
* `card` 那一笔永不触发 —— 断言机制是 `expectNoDataCard`(见 `stage` 定义处)。
*/
w05: {
activityId: 'part-substitution-assessment',
address: 'parts.profile',
blankCol: 'brand_name',
stages: [
{
// 唯一的数据工单:一张卡摆三块内容,写向四张表(主表 parts.profile,
// extraTables 里另外三张)——四张表随同一次「保存」一并发出,不分次交。
match: /差异评估的输入还差几格/,
address: 'parts.profile',
rowKey: 'internal_pn',
blankCol: 'brand_name',
expectGateBlock: true,
edits: [],
extraTables: [
/*
* 2026-08-31「只选不建」:parts.chip_type / parts.type_param 两条随功能退役 ——
* S1 的写授权面(writes)已收窄,再喂这两张表会被 400 拒。字典只在治理侧维护。
*/
{
// 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」)
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
address: 'parts.parameter',
rowKey: ['internal_pn', 'param_name'],
blankCol: 'param_value',
edits: [],
},
],
},
],
cases: [
{
sample: 0,
name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)'
+ '· 审核先点名意见驳回再通过',
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
expectDecision: true,
expectPort: 'assessment-released',
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
expectAnalyzeCalls: 1,
/*
* **真走「新建芯片类型」这条路**——但**显式给 `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) => {
/* 2026-08-31「只选不建」:两颗料都挂到既有的 type_1(DC-DC)—— 新建类型那条路
(显式 type_id=90 绕全表一致坑的那整套)随功能移除,史见 git 2.9.0 及更早。 */
const typeId = '1';
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({
tables: {
'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', '原厂未给'],
]),
},
}),
});
},
/*
* **这不是 `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'");
});
console.log(' 跨案例隔离:XL2596S-5.0(这一例建档的候选料与它的评估结果)已整体清空;LM2596S-5.0 类型兜底归位 type_1');
},
},
{
sample: 1,
name: '都在库 · 两颗同型且齐备 · 零数据卡直接进 S2 分析 · 审核一次通过',
// S1 的 doneWhen 起手就成立(两颗都在库、同型、参数不缺)—— 断言一张数据工单都不发
expectNoDataCard: true,
decide: 'approve',
expectPort: 'assessment-released',
expectAnalyzeCalls: 1,
},
{
sample: 2,
name: '两颗都在库但分属不同芯片类型 · 硬拦(填别的格子绕不过去,只有改类型才行)· 审核通过',
// gate_reason 要点名「不是同一个芯片类型」——数据卡一出现就查这句话,latch 住
expectGateReason: /芯片类型/,
// 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上。
// 2026-08-31 起确认闸多了条件档(gateWhen 钉 parts_profile_distinct_chip_type_id):
// 类型不一致时确认直接 400 —— 上面的空手交卷探针吃到的就是它;修好类型再确认才收。
stageEdits: {
'parts.profile': [{ internal_pn: 'LM2596S-5.0', chip_type_id: '2' }],
// 转去 LDO 类型后,LM2596S-5.0 多出一条它从没记过的必填参数「压差」
'parts.parameter': [
{ internal_pn: 'LM2596S-5.0', param_name: '压差', param_value: '0.4', param_unit: 'V', param_note: '满载时(本次评估按 LDO 口径补充,DC-DC 拓扑本无此规格)' },
],
},
decide: 'approve',
expectPort: 'assessment-released',
expectAnalyzeCalls: 1,
},
{
sample: 3,
name: '原 PN 缺库(候选在库)· 自动派数据工单建档 · 审核通过',
decide: 'approve',
expectPort: 'assessment-released',
expectAnalyzeCalls: 1,
stageEdits: {
'parts.profile': [
{
internal_pn: 'LM2596S-3.3',
chip_type_id: '1',
brand_name: 'TI',
manufacturer_name: 'Texas Instruments',
specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz',
lifecycle_status: 'nrnd',
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
datasheet_version: 'Rev H (2023-09)',
},
],
'parts.parameter': paramRows('LM2596S-3.3', [
['输入电压范围', '4.5~40', 'V', '客户上限 40V'],
['输出电压', '3.3', 'V', '固定版'],
['输出电流', '3', 'A', '满载能力'],
['开关频率', '150', 'kHz', '影响外围'],
['静态电流', '5', 'mA', '待机功耗'],
['工作温度', '-40~+85', '℃', '工业温域'],
['使能阈值', '1.4', 'V', 'ON/OFF 阈值'],
]),
},
},
{
sample: 4,
name: '两颗都缺库 · 自动派数据工单各自建档(原 PN 32V 上限 · 候选 40V 盖住它)· 审核通过',
decide: 'approve',
expectPort: 'assessment-released',
expectAnalyzeCalls: 1,
stageEdits: {
'parts.profile': [
{
internal_pn: 'CN-DCDC-32V',
chip_type_id: '1',
brand_name: '国产某厂',
manufacturer_name: '国产某厂',
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz,输入上限 32V',
lifecycle_status: 'active',
datasheet_ref: 'https://example.invalid/cn-dcdc-32v.pdf',
datasheet_version: 'V1.0',
},
{
internal_pn: 'XL2596S-5.0',
chip_type_id: '1',
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('CN-DCDC-32V', [
['输入电压范围', '4.5~32', 'V', '上限 32V'],
['输出电压', '5.0', 'V', '固定版'],
['输出电流', '3', 'A', '满载能力'],
['开关频率', '150', 'kHz', '影响外围'],
['静态电流', '6', 'mA', '待机功耗'],
['工作温度', '-40~+85', '℃', '工业温域'],
['使能阈值', '1.3', 'V', 'ON/OFF 阈值'],
]),
...paramRows('XL2596S-5.0', [
['输入电压范围', '4.5~40', 'V', '客户上限 40V'],
['输出电压', '5.0', 'V', '固定版'],
['输出电流', '3', 'A', '满载能力'],
['开关频率', '150', 'kHz', '影响外围'],
['静态电流', '4.2', 'mA', '待机功耗'],
['工作温度', '-40~+125', '℃', '工业温域'],
['使能阈值', '未披露', 'V', '原厂未给'],
]),
],
},
// 原 PN(CN-DCDC-32V)输入上限只有 32V,候选 XL2596S-5.0 上限 40V 盖住原 PN —— 不该拦;
// 真正的硬约束不符落在别处案例,这条只钉「两颗都缺库仍能走完全程」,故不设 expectRevised。
},
],
},
};
let bearer = '';
let bearerAt = 0;
async function api(path, init = {}) {
/* 直供的那枚续不了期(没有 refresh 凭据),只有服务账号那条路才换 */
if (bearer !== '' && DIRECT_BEARER === '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
if (!res.ok) {
const err = new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${text.slice(0, 300)}`);
err.status = res.status;
throw err;
}
return { res, body };
}
async function login() {
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 (!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`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'client_credentials', client_id: E2E_CLIENT_ID, client_secret: E2E_CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`);
const { access_token: token } = await res.json();
bearer = `Bearer ${token}`;
bearerAt = Date.now();
// 试跑要进设计端(测试台)、答卡要进工作台 —— 服务账号的角色不够,这里就先说清楚
const me = await api('/api/me');
for (const end of ['design', 'workbench']) {
if (!me.body.ends.includes(end)) throw new Error(`e2e 服务账号进不了 ${end}:ends=${JSON.stringify(me.body.ends)} —— 给它 realm 角色 admin`);
}
}
async function loadSample(activityId, index) {
const { readFile } = await import('node:fs/promises');
const manifest = JSON.parse(await readFile(new URL(`../activities/${activityId}/dist/manifest.json`, import.meta.url), 'utf8'));
const sample = manifest.samples[index];
if (!sample) throw new Error(`${activityId} 没有第 ${index} 条样例`);
return sample;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 被拒的行动 —— 轨迹里认「行动被拒」的痕(标题形状照 turn-runner 的拒回) */
function rejectedCount(trail) {
return trail.filter((t) => t.kind === 'action-rejected' || /行动被拒|被拒[::]|拒绝执行/.test(t.title ?? '')).length;
}
/**
* **AI 出场次数**(w05 专用,ADR-014 的 `analyze` 那一笔)—— 轨迹里数
* 「已把 N 条参数交给模型逐条判定,拿回 M 条结论」这句话(`auto-runner.ts` 的
* `kind:'observation'` 留痕,字面原样)。W05 全程零 agent,唯一一次模型调用长在 S2;
* 「行动被拒 0」这条验收线对没有 agent 决策的确定性步骤活动自动成立(没有行动可拒),
* 所以这里改守的是这个数 —— 它才是这次真正防回归的那条线(S3 审核循环 / T_release
* 重做都不该让它超过 1,S2 的入场哨兵 `s_inputs_ready` 也不该被绕过去多算一次)。
*/
function analyzeCallCount(trail) {
return trail.filter((t) => /已把 \d+ 条参数交给模型逐条判定/.test(t.title ?? '')).length;
}
/** 草稿层的行(带主键),按台账地址取 */
function draftRows(drafts, address) {
return (drafts ?? []).filter((d) => d.address === address).map((d) => ({ ...d.key, ...d.patch }));
}
/**
* **数据工单确认即入库**(2026-08-27 用户定:「数据工单里填的数据,确认后就应该入库」)——
* 交卷那一刻就验,不等放行:
* · 痕上必须有一条「数据工单确认入库 / 已确认(试跑」(基座在回传路由里同步落的,交卷返回时已在);
* · 落真库时(`JCAI_E2E_LAND_MASTER=1`)正式表里必须查得到件上填的那几颗料;
* · 不落真库时痕必须如实写「只留在活动缓存」—— 界面不撒谎那一条也要验。
* 回 'landed' / 'held' / 'missing'(没这条痕 = 这道门没开)/ 'absent'(说落了、正式表里没有)。
*/
async function expectLandedOnConfirm(taskId, stage, rows) {
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const note = [...(body.trail ?? [])].reverse().find((t) => t.kind === 'ledger-write' && /数据工单(确认入库|已确认)/.test(t.title ?? ''));
if (note === undefined) return 'missing';
if (/试跑|未落正式表/.test(note.title)) return LAND_MASTER ? 'absent' : 'held';
if (!LAND_MASTER) return 'landed';
const [domain, object] = stage.address.split('.');
const formal = await api(`/api/gov/data/${domain}/${object}`);
const keys = Array.isArray(stage.rowKey) ? stage.rowKey : [stage.rowKey];
const present = (row) => (formal.body.rows ?? []).some((f) => keys.every((k) => String(f[k] ?? '') === String(row[k] ?? '')));
return rows.every(present) ? 'landed' : 'absent';
}
/** 交卷执行卡 —— 载荷零字段(执行卡上没有任何输入,填写全在件上) */
async function submitExecution(cardId) {
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'execution', workOrderId: cardId, values: {} } }),
});
}
/** 空手交卷,期望被提交闸拦下(400)—— 闸不在才是要报的错 */
/**
* 空手交卷该不该被拦 —— 返回 `'blocked'` / `'nothing-missing'` / `'leaked'`。
*
* **闸只在真有缺项时才该出手**(2026-08-03 实测逼出来的区分):
* 这条断言从前写成「必须吃 400」,于是模型碰巧把该填的格子全填上的那些轮
* (A1 把缺的年用量补齐了、A2 客户点名的替代料型号本来就在来件里)一律被判成
* 「提交闸失效」—— 而闸好端端的,拿一单直接空手交卷实测吃的就是 400。
* **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的,
* 真失效那天反而没人信它。
*/
async function expectGateBlocks(cardId, taskId, stage) {
try {
await submitExecution(cardId);
} catch (error) {
if (error.status === 400) return 'blocked';
throw error;
}
/*
* 没被拦:去数**这一单该填的那一列**还空着几格(用与 buildEdits 同一份草稿、
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
*/
const column = stage.blankCol ?? stage.fillMissing?.column;
if (column === undefined) return 'nothing-missing';
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], stage.address);
// 有 fillWhere 的段只数该档的行:default / ok 行空着是本分,不是「闸没拦」
const scoped = stage.fillWhere ? rows.filter((r) => String(r[stage.fillWhere.column] ?? '') === stage.fillWhere.equals) : rows;
const blank = scoped.filter((r) => String(r[column] ?? '').trim() === '').length;
return blank > 0 ? 'leaked' : 'nothing-missing';
}
/**
* 决策卡 —— 它是**对话卡**,不在试跑观测面的工单摞里(那一摞只有执行/验收),
* 要从工作台的任务详情取:`chat` 里 `k === 'decision'` 且还没 `chosen` 的那几张。
*
* 选项按案例声明的 value 认;认不上退「推荐项 → 第一项」——
* 代答脚本不该因为模型换了个选项名就判活动失败。
*
* **人工验收清单**(2026-08-27 用户定,W05 的 S3 审核卡第一个用到这条机制):
* 审核卡(`variant:'review'`)可能带 `card.checklist` ——选**通过**那一项时必须
* `checked ⊇ checklist`,否则回传吃 `checklist-incomplete`(400)。这里**原样照抄**
* `card.checklist` 回传(不必知道清单具体写的是哪几句话,清单原文由卡自己带下来);
* 只在选中的是「通过」那个值时才带(驳回/自定义意见不需要点全清单,与服务端
* `reviewApproveValue` 同一条判法:优先 value==='approve',否则退回推荐项、再退第一项)。
* 没有 `checklist` 的卡(其余八套件)`checked` 不出现在载荷里,行为与从前一字不差。
*/
async function answerDecisions(taskId, kase, answered) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'decision' || card.chosen !== undefined || answered.has(card.id)) continue;
const options = card.options ?? [];
if (options.length === 0) continue;
/*
* `decideSeq` 按张走(E04 审批循环:第一张驳回、修订后的第二张通过);
* 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。
*/
const want = kase.decideSeq === undefined ? kase.decide : kase.decideSeq[Math.min(answered.size, kase.decideSeq.length - 1)];
// 'custom:…' = 写具体意见驳回(与人在输入框里写一句话同一条链)
const custom = typeof want === 'string' && want.startsWith('custom:');
const picked = custom
? { value: want, label: `意见:${want.slice(7)}` }
: (options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]);
const approveValue = (options.find((o) => o.value === 'approve') ?? options.find((o) => o.recommended === true) ?? options[0])?.value;
const reply = { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' };
if (Array.isArray(card.checklist) && card.checklist.length > 0 && picked.value === approveValue) reply.checked = card.checklist;
await api('/api/desk/chat-cards/reply', { method: 'POST', body: JSON.stringify({ reply }) });
answered.add(card.id);
asked.push(String(card.ask ?? card.title ?? ''));
count += 1;
console.log(
` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}` +
(reply.checked ? `(人工验收清单 ${reply.checked.length} 条已点全)` : ''),
);
}
return count;
}
/**
* 数据录入卡代答(E04 查无联系人的候选:S3 先收第一位关键人再排计划)。
* 人确认那一下基座直写草稿 —— 这里只按案例声明的 entryValues 把表单交回去。
*/
async function answerEntries(taskId, kase, answered) {
if (!kase.entryValues) return 0;
const { body } = await api(`/api/desk/tasks/${taskId}`);
let count = 0;
for (const card of body.chat ?? []) {
if (card.k !== 'entry' || card.chosen !== undefined || answered.has(card.id)) continue;
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'entry', cardId: card.id, values: kase.entryValues } }),
});
answered.add(card.id);
count += 1;
console.log(` 录入卡代答:${(card.ask ?? '').slice(0, 40)} → ${JSON.stringify(kase.entryValues)}`);
}
return count;
}
/**
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
* `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
*
* **复合主键**(2026-08-27,w05 的 `parts.parameter` 主键是 `(internal_pn, param_name)`)——
* `rowKey` 除了旧的单列字符串形态,也接受一个列名数组:两颗料同名参数(如都有「工作温度」)
* 若只按 `param_name` 单列匹配,`rows.find` 会永远命中第一条同名行、把不同料的值互相顶掉;
* 数组形态按**全部列同时相等**才算命中同一行,单列字符串形态原样保留旧行为(不影响既有八套件)。
*/
function buildEdits(stage, drafts, kase = {}) {
const rows = draftRows(drafts, stage.address);
const keyCols = Array.isArray(stage.rowKey) ? stage.rowKey : [stage.rowKey ?? 'pn'];
const blankCol = stage.blankCol ?? 'base_price';
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
const lines = [];
const taken = new Set();
const sig = (obj) => keyCols.map((k) => String(obj[k] ?? '')).join('');
const keyOf = (obj) => Object.fromEntries(keyCols.map((k) => [k, String(obj[k] ?? '')]));
// 案例级按表的编辑叠在套件级之上(同一套件不同案例要建的料号不同 —— 参数集不能是同一份)
const edits = [...(stage.edits ?? []), ...((kase.stageEdits ?? {})[stage.address] ?? [])];
for (const [i, edit] of edits.entries()) {
const cells = { ...edit };
const keyVals = {};
for (const k of keyCols) {
keyVals[k] = cells[k];
delete cells[k];
}
const allGiven = keyCols.every((k) => keyVals[k] !== undefined && keyVals[k] !== '');
const target = allGiven ? rows.find((r) => keyCols.every((k) => String(r[k]) === String(keyVals[k]))) : pending[i];
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) {
const { column, value } = stage.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), [column]: value });
}
}
}
if (stage.fillAllBlank) {
for (const r of rows) {
if (String(r[blankCol] ?? '') === '' && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), ...stage.fillAllBlank });
}
}
}
/*
* 按条件整行扫空:只给某一档状态的行填(评估输入清单里只有 status = gap 的行要补充内容,
* default / ok 的行本就不该填 —— fillAllBlank 会把它们也填上,那是替人说了他没说的话)。
*/
if (stage.fillWhere) {
const { column, equals, values } = stage.fillWhere;
for (const r of rows) {
if (String(r[column] ?? '') === equals && !taken.has(sig(r))) {
lines.push({ ...keyOf(r), ...values });
}
}
}
return lines;
}
/**
* **资料文件真产出了没有** —— 逐行按台账地址取回来,看它渲成什么。
*
* 断的是三件事,少一件都算没做到:
* ① 每一行都取得回一份文件(取不回 = 那一份根本不存在);
* ② 格式列说的是什么,读回来就该是什么形态(docx→word / xlsx→excel / md→markdown / html→html);
* ③ 里头**真有内容**(word 有块、excel 有行、md/html 有字)——
* 一份空壳也能"渲染成功",那种成功没有意义。
*/
async function assertAssetFiles(taskId, spec) {
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], spec.address);
if (rows.length === 0) return { ok: false, note: '一行内容资产都没写下 —— 谈不上产出资料' };
const KIND = { docx: 'word', xlsx: 'excel', md: 'markdown', html: 'html' };
const seen = new Set();
for (const row of rows) {
const key = String(row[spec.keyCol] ?? '');
const format = String(row[spec.formatCol] ?? '');
if (key === '') return { ok: false, note: `有一行没有主键(${spec.keyCol} 空着),件上点不开` };
if (!(format in KIND)) return { ok: false, note: `${key} 的格式是「${format}」—— 不在 md/html/docx/xlsx 里` };
let got;
try {
got = await api(`/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}`);
} catch (error) {
return { ok: false, note: `${key}(${format})取不回来:${String(error.message).slice(0, 120)}` };
}
const doc = got.body.file;
if (doc?.kind !== KIND[format]) return { ok: false, note: `${key} 说好是 ${format},读回来却是 ${doc?.kind}` };
const empty =
(doc.kind === 'word' && (doc.blocks ?? []).length === 0) ||
(doc.kind === 'excel' && (doc.rows ?? []).length === 0) ||
(doc.kind === 'markdown' && (doc.md ?? '').trim().length < 20) ||
(doc.kind === 'html' && (doc.html ?? '').length < 200);
if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` };
// 原字节也得取得到:下载到的是真 .docx / .xlsx
const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { authorization: bearer } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` };
if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${key} 的 .${format} 不是 ZIP 包(Word/Excel 打不开)` };
}
seen.add(format);
}
return { ok: true, formats: seen, count: rows.length, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` };
}
/**
* 收工之后再看一眼:那几份资料**存档了没有、点得开吗**。
* 查的是工作台任务详情里那一摞(`assetFiles`,工作成果那一屏摆的就是它),
* 逐份按同一条路取回来 —— 与人在界面上点它是同一个接口。
*
* **份数也得对上**(2026-08-25 柴浩:「工作成果怎么有 4 个资料,这单只会产出一个」)——
* 资料落在主数据上,整表里躺着历次跑出来的资产;从前存档拿的是整表,
* 于是这一摞里混着别单的成果。多一份也是错:签字的人会以为那是他交出去的东西。
*/
async function assertArchived(taskId, expected) {
const { body } = await api(`/api/desk/tasks/${taskId}`);
const files = body.assetFiles ?? [];
if (files.length === 0) return { ok: false, note: '工作成果里一份资料都没有 —— 放行时没存档' };
const live = files.filter((f) => !f.archived);
if (live.length > 0) return { ok: false, note: `${live.length} 份还标着「在办中」—— 放行那一刻没冻住` };
if (expected !== undefined && files.length !== expected) {
return {
ok: false,
note: `工作成果里 ${files.length} 份,而这一单只产出 ${expected} 份 —— 多出来的是别单的成果(${files.map((f) => f.title).join(' / ')})`,
};
}
for (const f of files) {
const url = `/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(f.address)}&key=${encodeURIComponent(f.key)}`;
let got;
try {
got = await api(url);
} catch (error) {
return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` };
}
if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` };
const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { authorization: bearer } });
const bytes = new Uint8Array(await raw.arrayBuffer());
if (!raw.ok || bytes.length < 40) return { ok: false, note: `${f.name} 的原件取不到(${raw.status} / ${bytes.length}B)` };
if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) {
return { ok: false, note: `${f.name} 存档里那份不是 ZIP 包` };
}
}
return { ok: true, note: `${files.length} 份都已存档、逐份点得开(工作成果那一摞)` };
}
async function runCase(suite, kase) {
const sample = await loadSample(suite.activityId, kase.sample);
console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
const { body: started } = await api('/api/gov/testbed/run-activity', {
method: 'POST',
/*
* **e2e 显式关掉「落真库」** —— 起单接口默认是开的(人手点那条路要看见数据真变),
* 而这道保护正是为 e2e 设的:A1 的「新客户来函」样例一旦把客户真写进主数据,
* 第二次跑那条路就成了老客户,用例从此测的不是它要测的东西。
* **要保护的那个调用方自己说出来**,别指望一个默认值替所有人做决定。
*/
body: JSON.stringify({
activityId: suite.activityId,
data: sample.input,
message: sample.message ?? '',
ownerId: '',
landMaster: LAND_MASTER,
}),
});
const taskId = started.taskId;
console.log(` 起单 ${taskId}`);
const replied = new Set();
const retries = new Map();
const decided = new Set();
// 录入卡的已答名单单独记 —— 与 decided 共用会把 decideSeq 按 size 取的序号顶掉(实测:点名驳回被跳过)
const entered = new Set();
let gateBlocked = false;
/** 数据工单确认了却没入库(或说入库了而表里没有)—— 收工判失败 */
let landingBroken = false;
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
let fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
let decisions = 0;
missedRows.clear();
asked.length = 0;
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
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;
let bailed = false;
let receiptChecked = false;
let receiptMismatch = false;
/** w05「都在库·零数据卡直分析」:任意一张匹配 stages 的执行卡出现过(不论此刻答没答)就算见过 */
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;
/*
* 这张执行卡对应哪一套「表 + 键 + 该填什么」—— 一单里有两张不同表的执行单
* (E04:候选清单 / 执行回执)按卡名对号;老套件没有 stages,整单一套。
*/
const stageOf = (card) => {
if (suite.stages === undefined) {
return {
address: suite.address,
rowKey: suite.rowKey,
blankCol: kase.blankCol ?? suite.blankCol,
edits: kase.edits ?? [],
fillMissing: kase.fillMissing,
expectGateBlock: kase.expectGateBlock === true,
};
}
const hit = suite.stages.find((s) => s.match.test(card.title ?? ''));
if (hit === undefined) console.log(` ⚠️ 认不出这张执行卡属于哪一段:${card.title}`);
return hit;
};
while (Date.now() < deadline && !bailed) {
await sleep(3000);
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
last = body;
if (body.terminal) break;
// 熔断卡 = 这一条案例已经失败 —— 别去答它,留着轨迹好查
const stalled = (body.cards ?? []).find((c) => !c.done && /推不动了/.test(c.title ?? ''));
if (stalled) {
console.log(` ✗ 熔断交人:${stalled.title}`);
last = body;
break;
}
/*
* **资料文件要在「人正在审」那一刻验** —— 而不是放行之后。
*
* 放行那一下草稿被消费掉,而 `content.asset` 是主数据:**试跑一律不落正式表**
* (免得试跑往品牌内容库里塞真资产),于是收工再去找,一行都找不着 ——
* 第一版就是这么误报"一行内容资产都没写下"的,而那一单资料写得好好的。
* 人点资料名恰恰发生在审核那一屏,这里验的就是他那一刻点得开点不开。
*/
/*
* 时机:**等到控制权真的交回人手上那一刻**(有卡等着答),而不是「一见到有行就查」——
* 后者会撞上模型还在逐行写的中途,查到 2 份就收工,而这一单其实产了 3 份
* (实测撞见过一次)。人点资料名恰恰发生在有卡等他的时候,查的就该是那一刻。
*/
const handedOff =
(body.cards ?? []).some((c) => !c.done) ||
((await api(`/api/desk/tasks/${taskId}`)).body.chat ?? []).some((c) => c.k === 'decision' && c.chosen === undefined);
if (suite.assetFiles && fileCheck === undefined && handedOff && (body.drafts ?? []).some((d) => d.address === suite.assetFiles.address)) {
fileCheck = await assertAssetFiles(taskId, suite.assetFiles);
console.log(` 资料文件:${fileCheck.note}`);
}
if (kase.expectPlanned && !planned) {
const { address, column, value } = kase.expectPlanned;
planned = draftRows(body.drafts ?? [], address).some((r) => String(r[column] ?? '') === value);
}
if (kase.expectRevised && !revised) {
const { address, where, equals, column, contains } = kase.expectRevised;
revised = draftRows(body.drafts ?? [], address).some(
(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 ?? '')));
}
// w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了)
if (kase.expectGateReason && !gateReasonMatched) {
const caseRow = draftRows(body.drafts ?? [], 'sub_eval.case')[0];
gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? ''));
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
await answerEntries(taskId, kase, entered);
// 计划里不许出现的字样(占位人名等)—— 一出现就记下,收工判失败
if (kase.forbidInPlan && !forbidHit) {
const planRows = draftRows(body.drafts ?? [], 'crm_coverage.plan');
forbidHit = planRows.some((r) => Object.values(r).some((v) => kase.forbidInPlan.some((w) => String(v ?? '').includes(w))));
}
for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') {
/*
* 「需要你判断下一步」是引擎的兜底卡(模型两次输出不成形 / 决断不了)——
* 它不属于任何一段,也不会自己好起来;从前每轮打一行警告空转到 20 分钟才超时。
* 认出来就当场收工判失败,把轨迹末尾摆出来尸检。
*/
if (/需要你判断下一步/.test(card.title ?? '')) {
console.log(` ⚠️ 模型交回兜底卡(${card.title})—— 这一单到此判失败`);
bailed = true;
break;
}
const stage = stageOf(card);
if (stage === undefined) continue;
// 回执单派出那一刻,骨架必须与计划一一对应(2026-08-24:计划 7 回执 6 的实测账)
if (stage.address === 'crm_coverage.receipt' && !receiptChecked) {
receiptChecked = true;
const planN = draftRows(body.drafts ?? [], 'crm_coverage.plan').length;
const rcptN = draftRows(body.drafts ?? [], 'crm_coverage.receipt').length;
if (planN !== rcptN) {
console.log(` ⚠️ 回执骨架 ${rcptN} 行 ≠ 计划 ${planN} 行 —— 派单前没对齐,判失败`);
receiptMismatch = true;
} else console.log(` 回执骨架与计划一一对应(${planN} 行)`);
}
if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) {
gateChecked.add(stage.address);
const verdict = await expectGateBlocks(card.id, taskId, stage);
if (verdict === 'leaked') gateLeaked = true;
else gateBlocked = true;
console.log(
verdict === 'blocked'
? ` 闸拦住空手交卷(400)—— 如设计`
: verdict === 'nothing-missing'
? ` 这一轮该填的格子模型全填上了,闸无需出手(不是失效)`
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
/*
* **两阶段自定义填表**(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) {
lines.push(...kase.adds.rows);
addedRows = true;
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
}
/*
* **一张卡填两张表**(w05:P-08 数据工单同一张卡上摆着表 1 主记录 + 表 2 标准化参数子表)——
* 加性写法:`stage.extraTables`(可选)是同形状的段描述数组,各自按自己的 address 走
* 同一套 `buildEdits`;保存载荷 `{tables:{地址:行[]}}` 本就支持多地址一次发,不必分两次保存、
* 也不影响没有 `extraTables` 的老套件(a1/a2/a3/a4/a6/a7/e04/i01 的 stage 都没有这个键)。
*/
const tables = {};
if (lines.length > 0) tables[stage.address] = lines;
for (const extra of stage.extraTables ?? []) {
const extraLines = buildEdits(extra, body.drafts ?? [], kase);
if (extraLines.length > 0) tables[extra.address] = extraLines;
}
const tableAddresses = Object.keys(tables);
if (tableAddresses.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables }),
});
console.log(` 件上保存 ${tableAddresses.map((a) => `${a}:${tables[a].length}行`).join(' + ')}(${card.title})`);
}
try {
await submitExecution(card.id);
} catch (error) {
if (error.status !== 400) throw error;
/*
* **「已回传」不是「闸拦着」** —— 两种 400 长得一样,处置正相反。
* 这一单交过了(卡还挂在开着的名单里多半是投影慢了一拍),该记下交过、往下走;
* 当成闸拦着就会原样再交一遍,再吃一个 400,三次判失败 ——
* 实测把一单 released 的 A1 记成了失败(2026-08-03)。
*/
if (/already-answered|已回传/.test(String(error.message))) {
console.log(` 这张卡已经交过了,接着往下走(${card.title})`);
replied.add(card.id);
continue;
}
// 闸还拦着(保存后仍有缺)—— 下一轮按最新草稿再补再交,连拦三次判失败
retries.set(card.id, (retries.get(card.id) ?? 0) + 1);
console.log(` 闸仍拦着(第 ${retries.get(card.id)} 次):${String(error.message).slice(0, 160)}`);
if ((retries.get(card.id) ?? 0) >= 3) throw error;
continue;
}
console.log(` 交卷执行卡:${card.title}`);
replied.add(card.id);
if (stage.landsOnConfirm === true) {
const verdict = await expectLandedOnConfirm(taskId, stage, tables[stage.address] ?? []);
console.log(
verdict === 'landed'
? ` 数据工单确认即入库:${stage.address} 已进正式表`
: verdict === 'held'
? ` 数据工单已确认(试跑不落主数据,痕如实写「只留在活动缓存」)`
: verdict === 'missing'
? ` ⚠️ 数据工单确认了,痕上没有「确认入库」那一条 —— 这道门没开,判失败`
: ` ⚠️ 痕说入库了,正式表里查不到件上填的那几行 —— 判失败`,
);
if (verdict === 'missing' || verdict === 'absent') landingBroken = true;
}
} else if (card.mode === 'acceptance') {
if (kase.acceptEdits && !acceptEdited) {
acceptEdited = true;
const { address, rows, minted: mintSpec } = kase.acceptEdits;
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [address]: rows } }),
});
const after = await api(`/api/gov/testbed/activity/${taskId}`);
/*
* 按**我们填的姓名**找行,不能只看编码前缀:错配覆盖的那次(2026-08-24),
* 模型自己建的 CU-E2E-ADD-01 也满足前缀 —— 断言过了,而人加的行其实把它顶掉了。
* 名对上 + 号续上,才是「真加了一行」。
*/
const mineRow = rows[0] ?? {};
const got = draftRows(after.body.drafts ?? [], address).find(
(r) => String(r['name'] ?? '') === String(mineRow['name'] ?? '') && String(r[mintSpec.column] ?? '').startsWith(mintSpec.prefix),
);
minted = got !== undefined;
console.log(
minted
? ` 确认屏上补一行联系人 → 基座续号 ${got[mintSpec.column]}`
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
);
}
/*
* **人工验收清单**(同上,W05 的 T_release 终局验收卡):试跑观测面的 `cards`
* 摘要故意瘦身(`{id,mode,title,done,result}`,见 governance-ops.ts),没有
* `checklist` 这一格 —— 要拿清单原文得走**任务详情**的 `workOrders`(与 `card` 同一个
* `id`,那边走的是完整契约 `acceptanceWorkOrderSchema`)。没有清单的卡(其余套件)
* 这次多查的一趟落空,`checked` 不进载荷,行为不变。
*/
const detail = await api(`/api/desk/tasks/${taskId}`);
const workOrder = (detail.body.workOrders ?? []).find((w) => w.id === card.id);
const acceptReply = { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} };
if (Array.isArray(workOrder?.checklist) && workOrder.checklist.length > 0) acceptReply.checked = workOrder.checklist;
await api('/api/desk/work-orders/reply', { method: 'POST', body: JSON.stringify({ reply: acceptReply }) });
console.log(` 验收通过:${card.title}${acceptReply.checked ? `(人工验收清单 ${acceptReply.checked.length} 条已点全)` : ''}`);
replied.add(card.id);
}
}
}
const rejects = rejectedCount(last?.trail ?? []);
const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports);
const ports = fired?.detail?.ports ?? [];
let ok = !bailed && last?.terminal === 'released' && rejects === 0;
console.log(
` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` +
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
`决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`,
);
if (gateLeaked || (kase.expectGateBlock && !gateBlocked)) {
console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败');
ok = false;
}
if (landingBroken) {
console.log(' ⚠️ 数据工单确认即入库那道门没兑现 —— 判失败');
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, max } of kase.expectAsks) {
const got = asked.filter((a) => re.test(a)).length;
if (got < count) {
console.log(` ⚠️ 该问的没问够:${re} 期望 ≥${count} 张,实际 ${got} 张 —— 判失败`);
ok = false;
}
if (max !== undefined && got > max) {
console.log(` ⚠️ 不该问的问了:${re} 最多 ${max} 张,实际 ${got} 张 —— 判失败`);
ok = false;
}
}
}
if (kase.expectDecision && decisions === 0) {
console.log(' ⚠️ 超限条目没走决策卡 —— 判失败');
ok = false;
}
if (kase.expectPort && !ports.includes(kase.expectPort)) {
console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`);
}
if (kase.expectPlanned && !planned) {
console.log(` ⚠️ 人加的那家(${kase.expectPlanned.value})没进经营计划 —— 新增行又被无视了,判失败`);
ok = false;
}
if (kase.expectRevised && !revised) {
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;
}
if (kase.expectNoDataCard && dataCardSeen) {
console.log(' ⚠️ 这一例本该是零数据卡直分析,却见到了一张数据工单 —— 判失败');
ok = false;
}
if (kase.expectGateReason && !gateReasonMatched) {
console.log(` ⚠️ gate_reason 没点名(期望命中 ${kase.expectGateReason})—— 判失败`);
ok = false;
}
if (kase.expectAnalyzeCalls !== undefined) {
const analyzeCalls = analyzeCallCount(last?.trail ?? []);
console.log(` 模型调用 ${analyzeCalls} 次`);
if (analyzeCalls !== kase.expectAnalyzeCalls) {
console.log(` ⚠️ 模型调用该恰 ${kase.expectAnalyzeCalls} 次,实际 ${analyzeCalls} 次 —— 判失败`);
ok = false;
}
}
if (receiptMismatch) ok = false;
if (forbidHit) {
console.log(` ⚠️ 计划里出现了不该有的字样(${(kase.forbidInPlan ?? []).join('/')})—— 占位人名又回来了,判失败`);
ok = false;
}
if (suite.assetFiles) {
if (fileCheck === undefined) {
console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');
ok = false;
} else if (!fileCheck.ok) {
ok = false;
} else if (kase.expectFormats && (fileCheck.formats?.size ?? 0) < kase.expectFormats) {
console.log(` ⚠️ 一封需求里三种形态,却只出了 ${[...(fileCheck.formats ?? [])].join('/')} —— 格式没分开`);
ok = false;
}
/*
* **放行之后还得在** —— 工作成果恰恰是收工才去看的地方。
* 资料落在主数据上,而试跑单放行时主数据不落正式表:不存档的话这一刻全是 404
* (这正是 2026-08-23 之前的实情)。所以这条断言查的是**存档**,不是台账。
*/
const archived = await assertArchived(taskId, fileCheck?.count);
console.log(` 资料存档:${archived.note}`);
if (!archived.ok) ok = false;
}
if (!ok) {
console.log(' —— 轨迹末 12 条 ——');
for (const t of (last?.trail ?? []).slice(-12)) console.log(` [${t.kind}] ${t.title}`);
}
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]);
await login();
const suites = which === 'all' || which === 'both' ? ['a1', 'a2', 'a3', 'a4', 'a6', 'a7', 'e04', 'i01', 'w05'] : [which];
let pass = 0;
let total = 0;
for (const key of suites) {
const suite = SUITES[key];
if (!suite) throw new Error(`没有这套:${key}`);
for (const [i, kase] of suite.cases.entries()) {
if (only !== undefined && i !== only) continue;
total += 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)}`);
}
/*
* **跨案例隔离**(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 清运行数据`);
process.exit(pass === total ? 0 : 1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});