/** * 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); });