Archived
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
1734 lines
89 KiB
JavaScript
1734 lines
89 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|