Archived
资料文件真产出来了;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
This commit is contained in:
@@ -17,6 +17,8 @@ import { manifest as quoteNegotiate } from '../activities/quote-negotiate/src/ma
|
||||
import { manifest as quoteArchive } from '../activities/quote-archive/src/manifest.ts';
|
||||
import { manifest as quoteSourcing } from '../activities/quote-sourcing/src/manifest.ts';
|
||||
import { manifest as quoteAlternative } from '../activities/quote-alternative/src/manifest.ts';
|
||||
import { manifest as crmRelationshipCoverage } from '../activities/crm-relationship-coverage/src/manifest.ts';
|
||||
import { manifest as brandContentToolkit } from '../activities/brand-content-toolkit/src/manifest.ts';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
@@ -27,6 +29,8 @@ const PACKAGES = [
|
||||
{ dir: 'quote-archive', manifest: quoteArchive },
|
||||
{ dir: 'quote-sourcing', manifest: quoteSourcing },
|
||||
{ dir: 'quote-alternative', manifest: quoteAlternative },
|
||||
{ dir: 'crm-relationship-coverage', manifest: crmRelationshipCoverage },
|
||||
{ dir: 'brand-content-toolkit', manifest: brandContentToolkit },
|
||||
] as const;
|
||||
|
||||
for (const pkg of PACKAGES) {
|
||||
|
||||
+315
-26
@@ -7,6 +7,8 @@
|
||||
* 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 起)
|
||||
*
|
||||
@@ -292,6 +294,128 @@ const SUITES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
/*
|
||||
* 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',
|
||||
@@ -399,7 +523,7 @@ async function submitExecution(cardId) {
|
||||
* **断言要分清「闸没拦」与「压根没东西可拦」**,否则它报的失败是假的,
|
||||
* 真失效那天反而没人信它。
|
||||
*/
|
||||
async function expectGateBlocks(cardId, taskId, suite, kase) {
|
||||
async function expectGateBlocks(cardId, taskId, stage) {
|
||||
try {
|
||||
await submitExecution(cardId);
|
||||
} catch (error) {
|
||||
@@ -411,10 +535,10 @@ async function expectGateBlocks(cardId, taskId, suite, kase) {
|
||||
* 同一个列名 —— 两处认的必须是同一件事,否则这条断言自己就会说谎)。
|
||||
* 一格都不空 = 模型这一轮把该填的全填上了,闸无需出手;还空着却放过去 = 真失效。
|
||||
*/
|
||||
const column = kase.blankCol ?? suite.blankCol ?? kase.fillMissing?.column;
|
||||
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 ?? [], suite.address);
|
||||
const rows = draftRows(body.drafts ?? [], stage.address);
|
||||
const blank = rows.filter((r) => String(r[column] ?? '').trim() === '').length;
|
||||
return blank > 0 ? 'leaked' : 'nothing-missing';
|
||||
}
|
||||
@@ -433,8 +557,12 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
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];
|
||||
/*
|
||||
* `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: '试跑代答' } }),
|
||||
@@ -446,33 +574,126 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
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'] ?? '') === '');
|
||||
/**
|
||||
* 把案例声明的格子对齐成保存载荷。行键随套件走(`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 kase.edits.entries()) {
|
||||
const { pn, ...cells } = edit;
|
||||
const target = pn !== '' ? rows.find((r) => String(r.pn) === pn) : pending[i];
|
||||
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(` ⚠️ 找不到要填的行(pn=${pn || `待填第${i}行`});现有行:${rows.map((r) => r.pn).join(', ')}`);
|
||||
console.log(` ⚠️ 找不到要填的行(${rowKey}=${keyed || `待填第${i}行`});现有行:${rows.map((r) => r[rowKey]).join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
taken.add(String(target.pn));
|
||||
lines.push({ pn: String(target.pn), ...cells });
|
||||
taken.add(String(target[rowKey]));
|
||||
lines.push({ [rowKey]: String(target[rowKey]), ...cells });
|
||||
}
|
||||
if (kase.fillMissing) {
|
||||
const { column, value } = kase.fillMissing;
|
||||
if (stage.fillMissing) {
|
||||
const { column, value } = stage.fillMissing;
|
||||
for (const r of rows) {
|
||||
if (String(r[column] ?? '') === '' && !taken.has(String(r.pn))) {
|
||||
lines.push({ pn: String(r.pn), [column]: value });
|
||||
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}`);
|
||||
@@ -487,10 +708,34 @@ async function runCase(suite, kase) {
|
||||
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}`);
|
||||
@@ -505,15 +750,40 @@ async function runCase(suite, kase) {
|
||||
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') {
|
||||
if (kase.expectGateBlock && !gateBlocked) {
|
||||
const verdict = await expectGateBlocks(card.id, taskId, suite, kase);
|
||||
gateBlocked = verdict !== 'leaked';
|
||||
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)—— 如设计`
|
||||
@@ -522,11 +792,11 @@ async function runCase(suite, kase) {
|
||||
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
|
||||
);
|
||||
}
|
||||
const lines = buildEdits(suite, kase, body.drafts ?? []);
|
||||
const lines = buildEdits(stage, body.drafts ?? []);
|
||||
if (lines.length > 0) {
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tables: { [suite.address]: lines } }),
|
||||
body: JSON.stringify({ tables: { [stage.address]: lines } }),
|
||||
});
|
||||
console.log(` 件上保存 ${lines.length} 行(${card.title})`);
|
||||
}
|
||||
@@ -573,7 +843,7 @@ async function runCase(suite, kase) {
|
||||
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
|
||||
`决策卡 ${decisions} 张 · 发口 [${ports.join(', ')}]`,
|
||||
);
|
||||
if (kase.expectGateBlock && !gateBlocked) {
|
||||
if (gateLeaked || (kase.expectGateBlock && !gateBlocked)) {
|
||||
console.log(' ⚠️ 提交闸没拦住空手交卷 —— 判失败');
|
||||
ok = false;
|
||||
}
|
||||
@@ -585,6 +855,25 @@ async function runCase(suite, kase) {
|
||||
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}`);
|
||||
@@ -596,7 +885,7 @@ 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];
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user