A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
+618
View File
@@ -0,0 +1,618 @@
/**
* 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 # 全跑
* 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',
},
],
},
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, suite, kase) {
try {
await submitExecution(cardId);
} catch (error) {
if (error.status === 400) return 'blocked';
throw error;
}
/*
* 没被拦:去数**这一单该填的那一列**还空着几格(用与 buildEdits 同一份草稿、
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
*/
const column = kase.blankCol ?? suite.blankCol ?? kase.fillMissing?.column;
if (column === undefined) return 'nothing-missing';
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
const rows = draftRows(body.drafts ?? [], suite.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;
const picked =
options.find((o) => o.value === kase.decide) ?? 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;
}
/** 把案例声明的格子对齐成保存载荷(pn 为空 = 按序发给待填行;fillMissing 兜底扫空) */
function buildEdits(suite, kase, drafts) {
const rows = draftRows(drafts, suite.address);
const pending = rows.filter((r) => String(r[suite.blankCol ?? 'base_price'] ?? '') === '');
const lines = [];
const taken = new Set();
for (const [i, edit] of kase.edits.entries()) {
const { pn, ...cells } = edit;
const target = pn !== '' ? rows.find((r) => String(r.pn) === pn) : pending[i];
if (!target) {
console.log(` ⚠️ 找不到要填的行(pn=${pn || `待填第${i}行`});现有行:${rows.map((r) => r.pn).join(', ')}`);
continue;
}
taken.add(String(target.pn));
lines.push({ pn: String(target.pn), ...cells });
}
if (kase.fillMissing) {
const { column, value } = kase.fillMissing;
for (const r of rows) {
if (String(r[column] ?? '') === '' && !taken.has(String(r.pn))) {
lines.push({ pn: String(r.pn), [column]: value });
}
}
}
return lines;
}
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 decisions = 0;
const deadline = Date.now() + 20 * 60 * 1000;
let last;
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;
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
for (const card of body.cards ?? []) {
if (card.done || replied.has(card.id)) continue;
if (card.mode === 'execution') {
if (kase.expectGateBlock && !gateBlocked) {
const verdict = await expectGateBlocks(card.id, taskId, suite, kase);
gateBlocked = verdict !== 'leaked';
console.log(
verdict === 'blocked'
? ` 闸拦住空手交卷(400)—— 如设计`
: verdict === 'nothing-missing'
? ` 这一轮该填的格子模型全填上了,闸无需出手(不是失效)`
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
const lines = buildEdits(suite, kase, body.drafts ?? []);
if (lines.length > 0) {
await api(`/api/desk/tasks/${taskId}/table`, {
method: 'POST',
body: JSON.stringify({ tables: { [suite.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 (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 (!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'] : [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);
});