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 Opus 5 df18882322 脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 09:31:35 +08:00

1428 lines
66 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,得换一枚重跑。
* **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对
* `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效);
* 不设它才走服务账号那条正路(ADR-012)。
*/
const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim();
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
/**
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 约束, 说明]
* 五元组批量转成保存载荷的行对象;数值照抄各案例原先写在 `normalized_parameter_set`
* 那个文本列里的那几行,一个数都不改(2026-08-27 结构改:参数集拆成独立子表)。
*/
function paramRows(internalPn, rows) {
return rows.map(([param_name, param_value, param_unit, constraint_type, param_note]) => ({
internal_pn: internalPn,
param_name,
param_value,
param_unit,
constraint_type,
param_note,
}));
}
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 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 差异评估 —— 1 数据(条件,P-08 / P-10 各一张)+ 1 执行 + 2 验收(审核走决策卡循环、放行终局)。
* 库里只播了 LM2596S-5.0 / LM2596S-ADJ:XL2596S-5.0、CN-DCDC-32V、LM2596S-3.3 刻意不在,
* 「候选缺库 / 原 PN 缺库 / 硬约束不推荐」三条路靠它们。e2e 关落真库,主数据不落正式表,案例可反复跑。
*/
w05: {
activityId: 'part-substitution-assessment',
address: 'sub_eval.input_gap',
blankCol: 'supplement',
stages: [
{
// 这张卡摆两张表(表 1 主记录 + 表 2 标准化参数子表)—— 主表照旧,子表挂在 extraTables 里
// 加性表达(见 buildEdits 顶注),保存时两张表的行一次并进同一个 `tables` 载荷。
match: /数据工单 · 芯片主数据创建/,
address: 'parts.profile',
rowKey: 'internal_pn',
blankCol: 'brand_name',
expectGateBlock: true,
edits: [],
// 案例没点名的料号兜底(不该走到这里 —— 每个案例都在 stageEdits 里逐颗给了)
fillAllBlank: {
brand_name: '兜底品牌',
specification_description: '兜底规格(案例没给参数集)',
package: 'TO-263-5',
lifecycle_status: 'active',
},
extraTables: [
{
// 主键是 (internal_pn, param_name) 复合列 —— 两颗料同名参数(如都有「工作温度」)
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
address: 'parts.parameter',
rowKey: ['internal_pn', 'param_name'],
blankCol: 'param_value',
edits: [],
// 案例没点名的参数行兜底(模型骨架只给了 internal_pn+param_name、案例没接住时才会用到)
fillAllBlank: { param_value: '未披露', constraint_type: 'hard', param_note: '兜底(案例没给这一条)' },
},
],
},
{
match: /数据工单 · 原厂技术资料创建/,
address: 'parts.document',
rowKey: 'internal_pn',
blankCol: 'document_title',
expectGateBlock: true,
edits: [],
fillAllBlank: {
doc_seq: '01',
document_type: 'datasheet',
document_title: 'Datasheet(e2e 兜底)',
document_version: 'Rev A',
document_ref: 'https://example.com/datasheet.pdf',
citation_locator: 'p.1',
document_status: 'valid',
},
},
{
match: /评估输入与资料补齐确认/,
address: 'sub_eval.input_gap',
rowKey: 'item_key',
blankCol: 'supplement',
expectGateBlock: true,
edits: [
{ item_key: 'application_scenario', supplement: '12V→5V、工业温域(-40~85℃)、输入上限 40V' },
{ item_key: 'bom_position', supplement: 'U3 电源位' },
],
// 其余 gap 行(原厂未披露的参数)统一「留 UNKNOWN」—— 补不了也是合法答案
fillWhere: { column: 'status', equals: 'gap', values: { supplement: '原厂未披露,留 UNKNOWN 并挂验证任务' } },
},
],
cases: [
{
sample: 0,
name: '候选缺库 · XL2596S-5.0 派数据工单(P-08 + P-10)· 审核先点名驳回再通过',
// 决策卡序:候选 ≥2 缺库先问 → create;审核第一张点名意见驳回;第二张通过
decideSeq: ['create', 'custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
expectDecision: true,
expectPort: 'assessment-released',
stageEdits: {
'parts.profile': [
{
internal_pn: 'XL2596S-5.0',
brand_name: 'XLSEMI',
manufacturer_name: 'XLSEMI',
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
package: 'TO-263-5',
process_technology: 'BCD',
lifecycle_status: 'active',
datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
datasheet_version: 'Rev C',
application_scenarios: '工业电源;消费电源',
},
],
'parts.parameter': paramRows('XL2596S-5.0', [
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
]),
'parts.document': [
{
internal_pn: 'XL2596S-5.0',
doc_seq: '01',
document_type: 'datasheet',
brand_name: 'XLSEMI',
document_title: 'XL2596S 150KHz 3A Buck DC/DC Converter',
document_version: 'Rev C',
document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
citation_locator: 'p.3 Electrical Characteristics',
document_status: 'valid',
},
],
},
// 使能阈值未披露 → UNKNOWN → 条件推荐(工具算的,不是模型说的)
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
},
{
sample: 1,
name: '都在库 · 常路 · 可调版 → 备选 · 审核一次通过',
decide: 'approve',
expectPort: 'assessment-released',
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'LM2596S-ADJ', column: 'recommendation_level', contains: 'alternate' },
},
{
sample: 2,
name: '原 PN 缺库 · 硬阻塞建档(不问人)· 审核通过',
decide: 'approve',
expectPort: 'assessment-released',
stageEdits: {
'parts.profile': [
{
internal_pn: 'LM2596S-3.3',
brand_name: 'TI',
manufacturer_name: 'Texas Instruments',
specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz',
package: 'TO-263-5',
process_technology: 'BCD',
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', 'hard', '客户上限 40V'],
['输出电压', '3.3', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '5', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
['使能阈值', '1.4', 'V', 'hard', 'ON/OFF 阈值'],
]),
'parts.document': [
{
internal_pn: 'LM2596S-3.3',
doc_seq: '01',
document_type: 'datasheet',
brand_name: 'TI',
document_title: 'LM2596 SIMPLE SWITCHER Power Converter',
document_version: 'Rev H (2023-09)',
document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
citation_locator: '§6.5 Electrical Characteristics(3.3V 列)',
document_status: 'valid',
},
],
},
},
{
sample: 3,
name: '三候选 · 两颗缺库各问一次 · 国产 32V 硬约束不推荐',
decideSeq: ['create', 'create', 'approve'],
expectDecision: true,
expectPort: 'assessment-released',
stageEdits: {
'parts.profile': [
{
internal_pn: 'XL2596S-5.0',
brand_name: 'XLSEMI',
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
package: 'TO-263-5',
lifecycle_status: 'active',
},
{
internal_pn: 'CN-DCDC-32V',
brand_name: '国产某厂',
specification_description: '降压型 DCDC,3A,固定 5.0V,输入上限 32V',
package: 'TO-263-5',
lifecycle_status: 'active',
},
],
'parts.parameter': [
...paramRows('XL2596S-5.0', [
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
]),
...paramRows('CN-DCDC-32V', [
['输入电压范围', '4.5~32', 'V', 'hard', '上限 32V'],
['输出电压', '5.0', 'V', 'hard', '固定版'],
['输出电流', '3', 'A', 'hard', '满载能力'],
['开关频率', '150', 'kHz', 'hard', '影响外围'],
['静态电流', '6', 'mA', 'soft', '待机功耗'],
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
['使能阈值', '1.3', 'V', 'hard', 'ON/OFF 阈值'],
]),
],
'parts.document': [
{ internal_pn: 'XL2596S-5.0', doc_seq: '01', document_type: 'datasheet', document_title: 'XL2596S Datasheet', document_version: 'Rev C', document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', citation_locator: 'p.3', document_status: 'valid' },
{ internal_pn: 'CN-DCDC-32V', doc_seq: '01', document_type: 'datasheet', document_title: 'CN-DCDC-32V Datasheet', document_version: 'V1.0', document_ref: 'https://example.com/cn-dcdc-32v.pdf', citation_locator: 'p.2', document_status: 'valid' },
],
},
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'CN-DCDC-32V', column: 'recommendation_level', contains: 'not_recommended' },
},
{
sample: 4,
name: '新客户 · 录入卡先建档 · 审核通过',
entryValues: { name: '深圳恒星电源有限公司', industry: '工业电源', region: '广东' },
decide: 'approve',
expectPort: 'assessment-released',
},
],
},
};
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() {
if (DIRECT_BEARER !== '') {
if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)');
bearer = `Bearer ${DIRECT_BEARER}`;
bearerAt = Date.now();
return;
}
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
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;
}
/** 草稿层的行(带主键),按台账地址取 */
function draftRows(drafts, address) {
return (drafts ?? []).filter((d) => d.address === address).map((d) => ({ ...d.key, ...d.patch }));
}
/** 交卷执行卡 —— 载荷零字段(执行卡上没有任何输入,填写全在件上) */
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 认;认不上退「推荐项 → 第一项」——
* 代答脚本不该因为模型换了个选项名就判活动失败。
*/
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]);
await api('/api/desk/chat-cards/reply', {
method: 'POST',
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
});
answered.add(card.id);
count += 1;
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
}
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(', ');
console.log(` ⚠️ 找不到要填的行(${keyCols.join('+')}=${wanted});现有行:${have}`);
continue;
}
taken.add(sig(target));
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: false,
}),
});
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 fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
let decisions = 0;
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
let addedRows = false;
let planned = false;
let revised = false;
let acceptEdited = false;
let minted = false;
let forbidHit = false;
let bailed = false;
let receiptChecked = false;
let receiptMismatch = 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),
);
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
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'
? ` 这一轮该填的格子模型全填上了,闸无需出手(不是失效)`
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
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);
} 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]}`
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
);
}
await api('/api/desk/work-orders/reply', {
method: 'POST',
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
});
console.log(` 验收通过:${card.title}`);
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;
}
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
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.acceptEdits && !minted) {
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
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));
}
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;
if (await runCase(suite, kase)) pass += 1;
}
}
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);
});