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-MVP/tools/e2e-a2.mjs
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

908 lines
40 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 # 全跑
* 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';
const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026';
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 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 · 计划一次通过',
decide: 'approve',
expectPort: 'coverage-map',
},
{
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',
},
],
},
};
let cookie = '';
async function api(path, init = {}) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'content-type': 'application/json', cookie, ...(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 res = await fetch(`${BASE}/api/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: 'AI', password: PASSWORD }),
});
if (!res.ok) throw new Error(`登录失败:${res.status} ${await res.text()}`);
const raw = res.headers.get('set-cookie') ?? '';
cookie = raw.split(';')[0] ?? '';
if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`);
}
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);
const blank = rows.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)];
const picked = 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: '试跑代答' } }),
});
answered.add(card.id);
count += 1;
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
}
return count;
}
/**
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
* `fillAllBlank` 整行兜底扫空(回执这类「几行由模型定、每行要填一把」的表用它)。
*/
function buildEdits(stage, drafts) {
const rows = draftRows(drafts, stage.address);
const rowKey = stage.rowKey ?? 'pn';
const blankCol = stage.blankCol ?? 'base_price';
const pending = rows.filter((r) => String(r[blankCol] ?? '') === '');
const lines = [];
const taken = new Set();
for (const [i, edit] of (stage.edits ?? []).entries()) {
const { [rowKey]: keyed, ...cells } = edit;
const target = keyed !== '' ? rows.find((r) => String(r[rowKey]) === keyed) : pending[i];
if (!target) {
console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
continue;
}
taken.add(String(target[rowKey]));
lines.push({ [rowKey]: String(target[rowKey]), ...cells });
}
if (stage.fillMissing) {
const { column, value } = stage.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), [column]: value });
}
}
}
if (stage.fillAllBlank) {
for (const r of rows) {
if (String(r[blankCol] ?? '') === '' && !taken.has(String(r[rowKey]))) {
lines.push({ [rowKey]: String(r[rowKey]), ...stage.fillAllBlank });
}
}
}
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: { cookie } });
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, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` };
}
/**
* 收工之后再看一眼:那几份资料**存档了没有、点得开吗**。
* 查的是工作台任务详情里那一摞(`assetFiles`,工作成果那一屏摆的就是它),
* 逐份按同一条路取回来 —— 与人在界面上点它是同一个接口。
*/
async function assertArchived(taskId) {
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} 份还标着「在办中」—— 放行那一刻没冻住` };
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: { cookie } });
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',
body: JSON.stringify({ activityId: suite.activityId, data: sample.input, message: sample.message ?? '', ownerId: '' }),
});
const taskId = started.taskId;
console.log(` 起单 ${taskId}`);
const replied = new Set();
const retries = new Map();
const decided = new Set();
let gateBlocked = false;
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
let fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
let decisions = 0;
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) {
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}`);
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') {
const stage = stageOf(card);
if (stage === undefined) continue;
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 ?? []);
if (lines.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [stage.address]: lines } }),
});
console.log(` 件上保存 ${lines.length} 行(${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') {
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 = 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 (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);
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'] : [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);
});