期 4 复核修正(第 1 轮,前端):核查缺口分「核查未通过」「核查未完成」两组并补上证据页、印章的检查,写目录预告推断时算上被挡下的同项指标,反方审稿概况的说法与排布改准

- 出具缺口:双盲复算的「指标无法复算」是没核成,不再和两路不一致、结论被推翻一起叫「核查未通过」——splitIssuanceGaps 分成 failed「核查未通过」、unfinished「核查未完成」两组,时间线那一行、证据页、成果节点印章的悬停说明都按先未通过、再未完成、最后校验不完整排。check-decode 按服务端 GAP_* 和 _refuted_gap 的原话加出具契约的前缀拼出缺口再分组,服务端改了说法先变红;新预览用例 verify-gaps 画真的 EvidencePane 和成果节点卡片,check-verify 断言两组各列各的、横幅的「校验未全部完成」里没有它们、印章说明的先后,以及 390px 深浅色不溢出、可读(以前去掉这两处的实现所有检查照样全绿)
- 时间线:无法复算那一行只说「已记为缺口」,不再一律说「按缺口计入出具」(探索运行不出具,接在侧枝上的缺口进不了出具);dual_check_catalog 带值的写法补上检查,和审批卡「会记为」用同一个函数、一字不差
- 双盲复算审批卡:工件里没有合并预告时(老工件、组合超上限、还没取到),被目录挡下、带着写到哪的同项指标也算进来——业务日期、去重规则说法不同就说都不记(服务端先归拢、轮不到对目录),有效记录条件同项上有被挡下的只说可能记、也可能不记;推出来的条目标 data-inferred,结尾换成「实际写入以时间线上的记录为准」。check-verify 用新夹具 time_rejected 断言销售额不再说「会记为」
- 反方审稿审批卡:「没能」改「未能」,提示不再说「没有真正查询数据」(触顶的查过),原因补上报告未引用数据库查询,时间线的下一步不再建议调高上限(这一条不含触顶的);「N 句未能反查」挪到降档说明后面,不再插在「出具会降档」标记和它的说明中间,句数不超过「无法判断」;不在出具路径上的那句写明是反方审稿节点(审批卡上的「本节点」读着像审批节点)。ChallengeSummary 收 runClass,给了就照它、不另取;check-challenge 的夹具改成和服务端约定一致(未能反查 2 句、无法判断 3 句),另断言给了运行类别时另取失败也照样写降档、不发请求
This commit is contained in:
ThearchyHelios
2026-10-06 09:31:28 +08:00
parent 62b9361d88
commit 4c3ba8bc1b
12 changed files with 551 additions and 137 deletions
+72 -19
View File
@@ -3,8 +3,10 @@
//
// 守的是这些「看着没坏、其实用不了」的地方:概况的句数和条目对不上、有被推翻的结论却不提醒降档(或者原因只藏在悬停里)、
// 没有被推翻的结论也写降档、只标注时也写降档、探索运行里说出具、运行类别取不到时说起出具(取不到就不知道是不是正式
// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、
// 根本没反查成的几句混在「无法判断」里看不出来、没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
// 运行,一句和出具有关的话都不说,窗口重新拿到焦点时再取)、审批带着运行类别时还另取一次(取失败了降档提示就没了)、
// 反方审稿不在出具路径上(服务端的 degrades 为假)还写降档、说明里用「本节点」(画在审批卡上,读着像审批节点)、
// 反查没完成的几句混在「无法判断」里看不出来(或者比「无法判断」还多、插在降档标记和它的说明中间)、
// 没经过反方审稿的报告也冒出一块空框(或者白发一次请求)、点开一句不取工件(或者每点一句取一次)、理由里的数字看不出引用了哪次查询、
// 裸数字没被标出来、理由文档是另一个工件时不去取、查询的 SQL / 行数 / 截断不显示、工件取不到时卡死在加载中、
// 审稿记录没存上时每句还能点开、条目只列前十句时看不到其余的(或者补的时候吞掉原文相同的另一句、键盘焦点掉到页面上);
// 检查器里的字段写不进节点配置(或者写进去的不是约定的形状:跟随设置要把这一项拿掉,不能写成空字符串)、下拉里列出了
@@ -41,17 +43,31 @@ const DUP_CONTENT = {
claims: fx.artifacts[MANY].claims.map((c) => (c.unit === 'u30' ? { ...c, text: fx.many.items.find((it) => it.unit === 'u20').text } : c)),
}
const RUNS = fx.runs
// RUNS.given 不在这里:那次运行的 GET 一律答 404,审批带着的运行类别得照样管用
const RUN_CLASS = {
[RUNS.formal]: 'formal', [RUNS.flag]: 'formal', [RUNS.explore]: 'exploratory', [RUNS.none]: 'formal',
[RUNS.many]: 'formal', [RUNS.broken]: 'formal', [RUNS.clear]: 'formal', [RUNS.clearFlag]: 'formal',
[RUNS.exploreFlag]: 'exploratory', [RUNS.runfail]: 'formal', [RUNS.dup]: 'formal', [RUNS.norecord]: 'formal',
[RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal',
[RUNS.unreviewed]: 'formal', [RUNS.offpath]: 'formal', [RUNS.over]: 'formal',
}
// challenge-approval-unreviewed 的工件:同第一个用例,被削弱(u5)、经受住(u8)的两句换成数据源已停用、没查成
// (服务端 unreached 的写法:记为无法判断,理由是系统说的,没有理由文档和查询)
const UNREVIEWED_ART = 'f'.repeat(64)
const UNREVIEWED_UNITS = ['u5', 'u8']
const UNREVIEWED_CONTENT = {
...fx.artifacts[ART],
claims: fx.artifacts[ART].claims.map((c) => (UNREVIEWED_UNITS.includes(c.unit) ? {
...c, verdict: 'inconclusive', rationale_doc: null, queries: [],
rationale: '这句引用的数据源「北仓库」已不存在或已停用,没有可以反查的数据源,这句未反查',
} : c)),
}
const LABEL = { stands: '经受住', weakened: '被削弱', refuted: '被推翻', inconclusive: '无法判断' }
const FLAG_NOTE = '按节点设置,被推翻的结论只做标注,不影响出具档位'
const DEGRADE_NOTE = '被推翻的结论计入出具缺口,本次正式运行将降档出具'
const DEGRADE_OFF = '本节点不在带出具契约的成果节点上游,被推翻的结论不影响出具档位'
const UNREVIEWED = (n) => `${n} 句没能反查,已记为无法判断。核查模型不可用、达到上限或数据源已停用时,这几句没有真正查询数据,原因见运行时间线`
// 主语写明是反方审稿节点:这句画在人工审批的卡上,「本节点」读着像审批节点
const DEGRADE_OFF = '反方审稿节点没有接在带出具契约的成果节点上游,被推翻的结论不计入出具缺口,不影响出具档位'
const UNREVIEWED = (n) => `${n} 句未能反查,已记为无法判断。反查没有完成:可能是核查模型不可用、达到上限、数据源已不存在或已停用,`
+ '或报告未引用数据库查询,原因见运行时间线'
const FAILED = '无法获取反方审稿的详细内容'
// F3a 画布校验(schema._check_challenge)在同样情形下的说法:检查器按 field 挂在字段下面
const SERVER_MANY = '「反方审稿」的上游有多个「报告撰写」节点(「写月报」、「写周报」),请在「要审的报告」中选择要审的那一份'
@@ -152,6 +168,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
hits.artifacts.push(id)
if (id === BROKEN && !state.broken) return route.fulfill({ json: { id, content: fx.artifacts[ART] } })
if (id === DUP) return route.fulfill({ json: { id, content: DUP_CONTENT } })
if (id === UNREVIEWED_ART) return route.fulfill({ json: { id, content: UNREVIEWED_CONTENT } })
const content = fx.artifacts[id]
return content ? route.fulfill({ json: { id, content } })
: route.fulfill({ status: 404, json: { detail: '工件不存在,可能已被删除' } })
@@ -354,9 +371,9 @@ await section('正式运行、没有被推翻的结论:不写降档', async ()
})
await section('运行类别取不到:不说任何和出具有关的话,重新拿到焦点时再取', async () => {
// 取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为正式运行……会降档」外加重试,卡顶却按审批自带的
// 运行类别写着正式运行,两处说法打架。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行
// 降档提示才出现
// 老服务端的审批不带运行类别,只能按运行 id 取。取不到就不知道是不是正式运行:以前写「暂时无法确认运行类别;如为
// 正式运行……会降档」外加重试。期 4 起取不到时和出具有关的话一句不说,窗口重新拿到焦点时再取,取到了正式运行降档
// 提示才出现。审批带着运行类别时照它说、不另取,见下一节
const { ctx, page, hits, state, errors } = await open('challenge-approval-runfail')
const block = page.locator('[data-challenge="write"]')
await block.waitFor()
@@ -386,31 +403,67 @@ await section('运行类别取不到:不说任何和出具有关的话,重
await ctx.close()
})
await section('有句子根本没反查成:单独说一行,不混在「无法判断」里', async () => {
// 服务端的 unreviewed:核查模型不可用、触顶、数据源不在了……这几句记为无法判断,可一条查询都没执行。「无法判断 2 句」
// 分不出是查了、数据不够,还是根本没查,审批卡单独说
await section('审批带着运行类别:照它说出具,不另取(另取失败也不影响)', async () => {
// 审批自带 approval.run_class(卡顶写的就是它)。以前反方审稿那一块只拿运行 id 另取一次,这一次失败了,正式运行里
// 「出具会降档」整块消失,卡顶却照样写着正式运行。用例把反方审稿那一块单独画,运行类别由调用方传进来,这次运行的
// GET 一律答 404
const { ctx, page, hits, errors } = await open('challenge-summary-given')
const block = page.locator('[data-challenge="write"]')
await block.waitFor()
await page.waitForTimeout(500)
check('给了运行类别(正式运行):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE, await text(block))
check('……不另取运行类别', !hits.runs.includes(RUNS.given), hits.runs.join('、'))
check('……页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('有句子反查没完成:单独说一行,不混在「无法判断」里,不插在降档标记和它的说明中间', async () => {
// 服务端的 unreviewed:核查模型不可用、触顶没给出结论、数据源不在了……这几句记为无法判断,可反查没有完成。「无法判断
// 3 句」分不出是查了、数据不够,还是没查成,审批卡单独说。未能反查的每一句都记为无法判断,unreviewed 不会多于它
const { ctx, page, hits, errors } = await open('challenge-approval-unreviewed')
const block = page.locator('[data-challenge="write"]')
await block.waitFor()
await runClassSettled(page, hits, RUNS.unreviewed)
const line = block.locator('[data-challenge-unreviewed]')
check('写明几句没能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2)
check('写明几句未能反查、记为无法判断,原因去时间线看', await line.count() === 1 && (await text(line)) === UNREVIEWED(2)
&& (await line.getAttribute('data-challenge-unreviewed')) === '2', await line.count() ? await text(line) : '没有')
check('……概况照服务端的计数写,不另算', (await text(block.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句')
const inconclusive = await text(block.locator('[data-challenge-count="inconclusive"]'))
check('……概况照服务端的计数写(无法判断 3 句:两句未能反查、一句查过了数据不够),未能反查的不多于无法判断',
inconclusive === '无法判断 3 句' && 2 <= Number(inconclusive.match(/\d+/)?.[0]), inconclusive)
check('……用「未能」,不说「没能」「真正」', !/没能|真正/.test(await text(block)))
// degrades 为真、正式运行、有被推翻的:照常写降档
check('服务端说会降档(degrades 为真):写「出具会降档」和原因', (await text(page.locator('[data-challenge-degrade]'))) === '出具会降档'
&& (await text(page.locator('[data-challenge-degrade-note]'))) === DEGRADE_NOTE && await page.locator('[data-challenge-degrade-off]').count() === 0)
check('没能反查:页面没有报错', errors.length === 0, errors.join(';'))
// 降档的说明紧跟在概况那一行(「出具会降档」的标记)下面,未能反查的那一行在它后面
const order = await block.evaluate((el) => [...el.children].map((c) => (c.hasAttribute('data-challenge-counts') ? 'counts'
: c.hasAttribute('data-challenge-degrade-note') ? 'note' : c.hasAttribute('data-challenge-unreviewed') ? 'unreviewed' : '')).filter(Boolean))
check('……降档的说明紧跟在降档标记那一行下面,未能反查的那一行排在后面', order.join(',') === 'counts,note,unreviewed', order.join(','))
// 点开一句未能反查的:理由是系统说的原因,没有查询
await toggle(page, 'u5').click()
await detail(page, 'u5').locator('[data-challenge-rationale-text]').waitFor()
check('点开未能反查的一句:理由写明数据源已停用、这句未反查', (await text(detail(page, 'u5').locator('[data-challenge-rationale-text]')))
.includes('这句未反查'), await text(detail(page, 'u5')))
check('未能反查:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
// 老服务端不给 unreviewed(第一个用例)、给了 0(offpath 用例):都不写这一行
for (const [syn, run] of [['challenge-approval', RUNS.formal], ['challenge-approval-offpath', RUNS.offpath]]) {
const other = await open(syn)
await other.page.locator('[data-challenge="write"]').waitFor()
await runClassSettled(other.page, other.hits, run)
check(`${syn}:没有没能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0
&& !(await text(other.page.locator('[data-challenge="write"]'))).includes('没能反查'))
check(`${syn}:没有未能反查的句子时不写这一行`, await other.page.locator('[data-challenge-unreviewed]').count() === 0
&& !(await text(other.page.locator('[data-challenge="write"]'))).includes('未能反查'))
await other.ctx.close()
}
// 服务端给的 unreviewed 比「无法判断」还多(不该出现):按「无法判断」的句数说,两个数不打架
const over = await open('challenge-approval-unreviewed-over')
await over.page.locator('[data-challenge="write"]').waitFor()
await runClassSettled(over.page, over.hits, RUNS.over)
const capped = over.page.locator('[data-challenge-unreviewed]')
check('unreviewed 比「无法判断」多:按「无法判断」的句数说', (await capped.getAttribute('data-challenge-unreviewed')) === '1'
&& (await text(capped)).startsWith('1 句未能反查') && (await text(over.page.locator('[data-challenge-count="inconclusive"]'))) === '无法判断 1 句',
await text(capped))
await over.ctx.close()
})
await section('按降档处置、却不在出具路径上(degrades 为假):不写降档,说清为什么不影响', async () => {
@@ -423,8 +476,8 @@ await section('按降档处置、却不在出具路径上(degrades 为假)
check('不写「出具会降档」', await page.locator('[data-challenge-degrade], [data-challenge-degrade-note]').count() === 0
&& !(await text(block)).includes('出具会降档'), await text(block))
const off = page.locator('[data-challenge-degrade-off]')
check('写明本节点不在带出具契约的成果节点上游、不影响出具档位', await off.count() === 1 && (await text(off)) === DEGRADE_OFF,
await off.count() ? await text(off) : '没有')
check('写明反方审稿节点不在带出具契约的成果节点上游、不影响出具档位(不说「本节点」)', await off.count() === 1 && (await text(off)) === DEGRADE_OFF
&& !(await text(off)).includes('本节点'), await off.count() ? await text(off) : '没有')
check('不是只标注:没有「只做标注」那句', await page.locator('[data-challenge-flag]').count() === 0)
check('不在出具路径上:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
@@ -775,7 +828,7 @@ await section('窄屏与深浅色', async () => {
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
// 几行说明(降档原因、没能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清
// 几行说明(降档原因、未能反查、不在出具路径上、审稿记录没存上):窄屏不撑宽,深浅色都看得清
for (const theme of ['dark', 'light']) {
const notes = [
['challenge-approval', '[data-challenge-degrade-note]'],
+75 -20
View File
@@ -2181,10 +2181,11 @@ await section('双盲复算与反方审稿的日志(期 3):按 code 和结
check('……缺字段的工件 id 不当成可下钻的工件', !rows.some((s) => s?.artifact != null))
})
await section('核查类日志与出具缺口(期 4):无法复算、没能反查、只查了部分数据源说人话,核查报上来的缺口另起一组', async () => {
await section('核查类日志与出具缺口(期 4):无法复算、未能反查、只查了部分数据源说人话,核查报上来的缺口另起两组', async () => {
// 期 3 时这三个代码界面认不得,只能照原话显示;出具缺口里双盲复算、反方审稿报上来的几条读成
// 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准——
// 它们是核查查出来的问题,不是校验没做完
// 「校验不完整:反方审稿「反方审稿」:反方审稿推翻了 1 句结论…」,前缀和正文重复,归在「校验不完整」下也不准。
// 核查报上来的另起,再分两组:查出了问题的(两路结果不一致、结论被推翻)是「核查未通过」,没核成的(指标无法复算)
// 是「核查未完成」——没核过的数说成「未通过」,读的人会以为这个数被查出了错
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const events = [
E(1, 'run.started', null, { total: 3 }),
@@ -2202,7 +2203,7 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
E(9, 'node.started', 'done', { node_type: 'output', label: '成果' }),
E(10, 'issuance', 'done', { tier: 'degraded', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 2, matched_numbers: 3,
gaps: ['双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」',
'叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致'] }),
'叙述模板渲染为空(路径可能有误)', '数据整形「双盲复算备份」:两路结果不一致', '双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询'] }),
E(11, 'node.finished', 'done', { duration_ms: 5 }),
E(12, 'run.finished', null, { status: 'succeeded' }),
]
@@ -2215,12 +2216,16 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
check('……标题里没有内部代码,原话留在展开区', NEW.every((c) => !/dual_check_|challenge_/.test(`${by(c)?.title}${by(c)?.sub ?? ''}${by(c)?.next ?? ''}`))
&& by('challenge_unreviewed')?.detail === events[5].data.message)
const un = by('dual_check_unchecked')
check('dual_check_unchecked:几个指标无法复算、计入缺口,副标题点名带原因(没名字的写 id),提醒色',
un?.title === '双盲复算有 2 个指标无法复算,按缺口计入出具' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限'
&& un.level === 'warn' && !!un.next?.includes('核查'), `${un?.title} / ${un?.sub}`)
// 只说记为缺口,不说「计入出具」:探索运行不出具,节点接在侧枝上时缺口也进不了出具,降不降档由出具那一行说
check('dual_check_unchecked:几个指标无法复算、记为缺口(不提出具),副标题点名带原因(没名字的写 id),提醒色',
un?.title === '双盲复算有 2 个指标无法复算,已记为缺口' && un.sub === '「销售额」:输入来自合并查询,无法单独复算;「aov」:盲算已达查询次数上限'
&& un.level === 'warn' && !!un.next?.includes('核查') && !`${un.title}${un.sub}`.includes('出具'), `${un?.title} / ${un?.sub}`)
const ur = by('challenge_unreviewed')
check('challenge_unreviewed:几句没能反查、记为无法判断,原因放副标题', ur?.title === '反方审稿有 2 句结论没能反查,已记为无法判断'
&& ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('没有真正查询数据'), `${ur?.title} / ${ur?.sub}`)
// 用「未能」(同服务端原话「未能反查」),不说「没有真正查询数据」(触顶的句子查过);这一条不含触顶的句子
// (由 challenge_limit 说),下一步不提调高上限
check('challenge_unreviewed:几句未能反查、记为无法判断,原因放副标题,下一步不提上限', ur?.title === '反方审稿有 2 句结论未能反查,已记为无法判断'
&& ur.sub === '数据源「北仓库」已不存在或已停用' && ur.level === 'warn' && !!ur.next?.includes('核查模型') && !!ur.next?.includes('数据源')
&& !ur.next.includes('上限') && !/没能|真正/.test(`${ur.title}${ur.next}`), `${ur?.title} / ${ur?.sub} / ${ur?.next}`)
const lost = by('challenge_sources_lost')
check('challenge_sources_lost:几句只查了部分数据源,点名没查成的源、判断不含它们', lost?.title === '反方审稿有 1 句结论只查了部分数据源'
&& lost.sub === '未能查询:「北仓库」、「南仓库」(已不存在或已停用),判断不含其中的数据' && lost.level === 'warn' && !!lost.next?.includes('数据源'),
@@ -2229,24 +2234,74 @@ await section('核查类日志与出具缺口(期 4):无法复算、没能
const issuance = steps.find((s) => s.kind === 'issuance')
const title = issuance?.title ?? ''
const detail = issuance?.detail ?? ''
check('出具那一行:核查报上来的两条归在「核查未通过」下', detail.split('\n').includes(
'核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail)
check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', detail.split('\n').some((l) => l.startsWith('校验不完整:')
&& l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了')), detail)
check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', detail.split('\n').some((l) => l.startsWith('校验不完整:')
&& l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail)
const lines = detail.split('\n')
check('……核查那一组排在校验不完整前面,标题以档位开头', title.startsWith('降档出具:核查未通过:')
&& lines.findIndex((l) => l.startsWith('核查未通过:')) < lines.findIndex((l) => l.startsWith('校验不完整:')), title)
check('出具那一行:核查查出了问题的两条归在「核查未通过」下', lines.includes(
'核查未通过:双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果;反方审稿:推翻了 1 句结论:「东区增长主要来自新客」'), detail)
check('……无法复算的那条归在「核查未完成」下,不在「核查未通过」下', lines.includes(
'核查未完成:双盲复算「复核销售额」:指标「订单数」无法复算:输入来自合并查询')
&& !lines.some((l) => l.startsWith('核查未通过:') && l.includes('无法复算')), detail)
check('……校验没做完的那条仍在「校验不完整」下,核查的不在那里', lines.some((l) => l.startsWith('校验不完整:')
&& l.includes('叙述模板渲染为空') && !l.includes('两路结果不一致,审核人') && !l.includes('推翻了') && !l.includes('无法复算')), detail)
check('……只是名字里带「双盲复算」的别的节点不算核查(按节点类型的前缀认)', lines.some((l) => l.startsWith('校验不完整:')
&& l.includes('数据整形「双盲复算备份」:两路结果不一致')), detail)
const at = (head) => lines.findIndex((l) => l.startsWith(head))
check('……先「核查未通过」、再「核查未完成」、最后「校验不完整」,标题以档位开头', title.startsWith('降档出具:核查未通过:')
&& at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验不完整:'), title)
check('……前缀和正文不重复节点类型', !/反方审稿:反方审稿|双盲复算「[^」]*」:[^;]*双盲复算/.test(detail), detail)
// splitIssuanceGaps:成果卡片、证据页按同一个函数分组
const split = mod.splitIssuanceGaps(['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」',
'缺少必需指标:gmv', '反方审稿报告没写完'])
check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算', JSON.stringify(split) === JSON.stringify({
verify: ['双盲复算:指标「订单数」无法复算:输入来自合并查询', '反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」'],
'缺少必需指标:gmv', '反方审稿报告没写完', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择',
'双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'])
check('splitIssuanceGaps:「类型:」「类型「名」:」开头的才算核查缺口,同名开头的普通文字不算;无法复算的单列一组', JSON.stringify(split) === JSON.stringify({
failed: ['反方审稿「审周报」:推翻了 2 句结论:「甲」「乙」', '双盲复算「复核」:指标「销售额」两路结果不一致,审核人没有作出选择',
'双盲复算「无法复算的复核」:指标「客单价」两路结果不一致,审核人判断两边都不对'],
unfinished: ['双盲复算:指标「订单数」无法复算:输入来自合并查询'],
other: ['缺少必需指标:gmv', '反方审稿报告没写完'] }), JSON.stringify(split))
// 和服务端的说法对得上:双盲复算、反方审稿报缺口的原话(nodes/dual_check.py 的 GAP_*、nodes/challenge.py 的
// _refuted_gap)加上出具契约的前缀(nodes/io.py 的 _upstream_issuance_gaps:节点名就是类型叫法时只写「类型:」),
// 按服务端的写法拼出来再分组。服务端改了说法(比如「无法复算」换了个词),这里先变红
const dcPy = readFileSync(`${root}backend/app/engine/nodes/dual_check.py`, 'utf8')
const chPy = readFileSync(`${root}backend/app/engine/nodes/challenge.py`, 'utf8')
const ioPy = readFileSync(`${root}backend/app/engine/nodes/io.py`, 'utf8')
const gapOf = (name) => dcPy.match(new RegExp(`^${name} = "([^"]+)"$`, 'm'))?.[1]
const fill = (tpl) => tpl?.replace('{name}', '订单数').replace('{reason}', '输入来自合并查询')
const refuted = chPy.match(/return f"(推翻了 \{len\(claims\)\} 句结论:\{_named\(claims\)\})"/)?.[1]
check('读出了服务端报缺口的写法和前缀的写法', ['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].every((n) => !!gapOf(n))
&& !!refuted && ioPy.includes('prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:"'),
['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED', 'GAP_UNCHECKED'].map((n) => `${n}=${gapOf(n)}`).join(' / '))
const prefixed = (kind, title, body) => (title === kind ? `${kind}:` : `${kind}「${title}」:`) + body
const served = mod.splitIssuanceGaps([
...['GAP_BLIND', 'GAP_NEITHER', 'GAP_UNANSWERED'].map((n) => prefixed('双盲复算', '复核', fill(gapOf(n)))),
prefixed('双盲复算', '双盲复算', fill(gapOf('GAP_UNCHECKED'))),
prefixed('反方审稿', '反方审稿', refuted?.replace('{len(claims)}', '1').replace('{_named(claims)}', '「东区增长主要来自新客」')),
])
check('……两路不一致(选了盲算、两边都不对、没作出选择)和被推翻的归「核查未通过」,无法复算的归「核查未完成」',
served.failed.length === 4 && served.unfinished.length === 1 && served.unfinished[0].startsWith('双盲复算:指标「订单数」无法复算:')
&& served.other.length === 0, JSON.stringify(served))
// dual_check_catalog 的 written 带了写进去的值(期 4):时间线写「…记为「值」」,值的写法和审批卡上的「会记为「值」」
// 同一个函数(catalogPatch 的 patchValueText),两处一字不差
const cp = await import(await load('/src/run/catalogPatch.ts'))
const { DUAL_CHECK_TEXT: DCT } = await import(await load('/src/lib/terms.ts'))
const valued = flatten(mod.decodeRun([
E(1, 'node.started', 'dc', { node_type: 'dual_check' }),
E(2, 'log', 'dc', { level: 'info', code: 'dual_check_catalog', message: '已写入',
written: [{ source: 'shop', table: 'orders', item: 'business_date', value: { column: 'paid_at' } },
{ source: 'shop', table: 'refunds', item: 'valid_filter', value: "status <> 'test'" }], skipped: [] }),
E(3, 'node.finished', 'dc', {}),
])).find((s) => s.code === 'dual_check_catalog')
const date = cp.patchValueText('business_date', { column: 'paid_at' })
const cond = cp.patchValueText('valid_filter', "status <> 'test'")
check('dual_check_catalog 带值:业务日期写列名、有效记录条件写原句,「哪张表的哪一项记为「值」」',
valued?.title === '已将 2 项人工确认的事实记入数据目录'
&& valued.sub === `「orders」(shop)的业务日期记为「${date}」;「refunds」(shop)的有效记录条件记为「${cond}」`
&& date === 'paid_at' && cond === "status <> 'test'", `${valued?.title} / ${valued?.sub}`)
const card = DCT.approval.catalogPreview.write('采用盲算的结果', DCT.log.catalogItem('orders', 'shop', '业务日期'), date)
check('……值和审批卡上「会记为「值」」的写法一字不差', card.endsWith(`会记为「${date}」`)
&& (valued?.sub ?? '').startsWith(`${DCT.log.catalogItem('orders', 'shop', '业务日期')}记为「${date}」`), `${card} / ${valued?.sub}`)
// 老日志缺字段、字段类型不对:不崩,不出 undefined、NaN、[object Object],不瞎填数
const odd = [['dual_check_unchecked', { metrics: 'x' }], ['challenge_unreviewed', { claims_skipped: 'x', reason: 3 }],
['challenge_sources_lost', { claims: null, sources: 'x' }]]
+120 -5
View File
@@ -17,7 +17,11 @@
// 节点设置时有条件地说;
// - 审批卡(期 4):预告逐条说「会记为……」或「不会记入:原因」,原因是服务端的原话;被目录挡下的一边照样说写到哪;
// 几个指标落到同一项上时照完整对照里服务端算好的合并预告(catalog_joint)说,和真写的一字不差(夹具的 merges 是服务端
// 真写一遍的结果);盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目标出,老工件照旧纯文本;
// 真写一遍的结果);没有合并预告时按其余指标当前的选择推,被目录挡下的同项指标也算进来(说法不同就说都不记,说不准的
// 只说可能),结尾换成以时间线上的记录为准;盲算的理由按核查 agent 组装的理由文档画,有出处的数字画实线、裸数字醒目
// 标出,老工件照旧纯文本;
// - 出具缺口:证据页(真的 EvidencePane)和成果节点印章的悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」
// 「核查未完成」两组,不放在「校验未全部完成」下;
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
@@ -456,16 +460,23 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
})
/**
* 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined}。reason:不写、照服务端
* 的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;conflict、joined:没有合并预告时
* 按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句)
* 一块里预告的三条:{a, b, neither} → {text, write, current, reason, joint, together, conflict, joined, pending, inferred}。
* reason:不写、照服务端的原话说原因;joint:照完整对照里服务端算好的合并预告说;together:和别的指标一起记入;
* conflict、joined、pending:没有合并预告时按其余指标当前的选择推的(说法不同都不记、有效记录条件合成一句、同项上有
* 被挡下的指标说不准只说可能);inferred:这一条是推出来的
*/
const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')]
.map((s) => [s.dataset.dualCatalogSide, {
text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight),
reason: s.dataset.reason != null, joint: s.dataset.joint != null, together: s.dataset.together != null,
conflict: s.dataset.conflict != null, joined: s.dataset.joined != null,
conflict: s.dataset.conflict != null, joined: s.dataset.joined != null, pending: s.dataset.pending != null,
inferred: s.dataset.inferred != null,
}])))
/** 一块预告的结尾:{kind: exact(照服务端算好的说)| inferred(有推出来的), text} */
const previewTail = async (loc) => {
const tail = loc.locator('[data-dual-catalog-tail]')
return { kind: await tail.getAttribute('data-dual-catalog-tail'), text: await text(tail) }
}
/** 不写的一条:选择的叫法加服务端的原话 */
const blockedText = (label, reason) => `${label}时不会记入:${reason}`
const CHOICE_LABEL = { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' }
@@ -662,6 +673,10 @@ await section('审批卡:几个指标落到同一项上、工件里没有合
const note = await text(metric(page, 'gmv').locator('[data-dual-catalog]'))
check('……结尾说清预告按各指标当前的选择算,「人工确认」说的是写入的项', note.includes('各指标当前的选择')
&& note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note)
const tail = await previewTail(metric(page, 'gmv'))
check('……推出来的:结尾不说只有目录被改了才会不一致,说实际写入以时间线上的记录为准', tail.kind === 'inferred'
&& tail.text.includes('推断') && tail.text.endsWith('实际写入以时间线上的记录为准') && !tail.text.includes('审批期间数据目录有改动时'),
`${tail.kind} / ${tail.text}`)
// 退款率改选两边都不对:不再落到同一项上,销售额照常记
await choose(page, 'refund_rate', 'neither')
g = await previewClauses(metric(page, 'gmv'))
@@ -698,6 +713,42 @@ await section('审批卡:几个指标落到同一项上、工件里没有合
&& cur.refund_rate?.text === `采用原流程的结果时,「orders」(shop)的有效记录条件会记为「${own.refund_rate}」,与「销售额」采用的条件用 AND 合成一句`
&& cur.gmv.write && cur.gmv.joined && cur.refund_rate.write && cur.refund_rate.joined, `${cur.gmv?.text} ‖ ${cur.refund_rate?.text}`)
// 被目录挡下的同项指标也算进来(2026-10 复核):目录里人工驳回过业务日期 created_at(夹具 time_rejected)。销售额采用
// 原流程(paid_at,单独看会写),退款率采用盲算(created_at,单独看被驳回挡下,但带着写到哪)。服务端先归拢:说法不同,
// 一项都不写,轮不到对目录。以前只看会写的同伴,销售额照样加粗说「会记为 paid_at」
const tr = fx.merges.time_rejected
check('夹具:退款率那一边被驳回挡下、带着写到哪,销售额那一边单独看会写,服务端一项都不写',
tr.catalog.refund_rate.b.write === false && tr.catalog.refund_rate.b.table === 'orders' && tr.catalog.gmv.a.write === true
&& tr.written.length === 0, JSON.stringify(tr.catalog))
const rj = await preview('dual-check-approval', {
query: `merge=time_rejected&peer=${tr.refund_rate}`, artifact: cardWith(tr.gmv, { peer: tr.refund_rate }),
})
await rj.page.waitForSelector('[data-dual-check][data-state="ready"]')
await chooseAll(rj.page, 'time_rejected')
cur = await current(rj.page)
check('……两边加粗的那一句都说说法不同、都不记,销售额不再说「会记为」', ['gmv', 'refund_rate'].every((id) => cur[id]?.conflict
&& !cur[id].write && cur[id].inferred && !cur[id].text.includes('会记为')), JSON.stringify(cur))
check('……退款率被挡下的那一句照归拢的结果说(和销售额说法不同),不照它单独的驳回原因说',
cur.refund_rate?.text === '采用盲算的结果时与「销售额」的选择说法不同,都不记', cur.refund_rate?.text)
const rjTail = await previewTail(metric(rj.page, 'gmv'))
check('……结尾换成实际写入以时间线上的记录为准', rjTail.kind === 'inferred' && rjTail.text.endsWith('实际写入以时间线上的记录为准'), rjTail.text)
// 有效记录条件、同项上有被挡下的指标(夹具 filters_rejected:销售额采纳的条件曾被驳回):挡下它的若是驳回,服务端
// 拿掉它、退款率照写;若是别的原因(和已有条件合不起来),退款率也一起挡下。卡上认不出是哪一种,退款率那一句只说可能
const fr = fx.merges.filters_rejected
const fp = await preview('dual-check-approval', {
query: `merge=filters_rejected&peer=${fr.refund_rate}&variant=${fr.gmv}`, artifact: cardWith(fr.gmv, { peer: fr.refund_rate }),
})
await fp.page.waitForSelector('[data-dual-check][data-state="ready"]')
await chooseAll(fp.page, 'filters_rejected')
cur = await current(fp.page)
check('有效记录条件、同项上有被挡下的:退款率说可能记为自己的条件、也可能不记,不说定了「会记为」', !!cur.refund_rate?.pending
&& !cur.refund_rate.write && cur.refund_rate.inferred && cur.refund_rate.text === '采用原流程的结果时,「orders」(shop)的有效记录条件'
+ `可能记为「${fr.catalog.refund_rate.a.value}」,也可能不记:要与「销售额」的选择合起来才能确定`, cur.refund_rate?.text)
check('……销售额(被驳回挡下)照它的原因说不会记入,和服务端一致(确实没写)', !!cur.gmv?.reason && !cur.gmv.write
&& cur.gmv.text === blockedText(CHOICE_LABEL.a, fr.catalog.gmv.a.reason)
&& !fr.written.some((w) => w.value === fr.catalog.gmv.a.value), cur.gmv?.text)
// 窄屏:说法不同的那一句更长,也不撑宽
for (const theme of ['dark', 'light']) {
const narrow = await open('time_split', { width: 390, height: 844, theme })
@@ -765,6 +816,10 @@ await section('审批卡:几个指标落到同一项上时,照完整对照
const { page, errors } = await open('time_split')
for (const [id, c] of Object.entries(fx.merges.time_split.choices)) await choose(page, id, c)
let cur = await current(page)
const exact = await previewTail(metric(page, 'gmv'))
check('照服务端算好的合并结果说:结尾只说审批期间目录有改动时以时间线为准,不说是推断的', exact.kind === 'exact'
&& exact.text.includes('审批期间数据目录有改动时') && !exact.text.includes('推断')
&& !Object.values(cur).some((c) => c?.inferred), `${exact.kind} / ${exact.text}`)
check('分开选:两边都说不会记入、写明给出了不同的说法', ['gmv', 'refund_rate'].every((id) => cur[id]?.reason
&& cur[id].text.includes('给出了不同的说法') && cur[id].text.includes('都未写入数据目录')), JSON.stringify(cur))
await choose(page, 'refund_rate', 'a')
@@ -1043,6 +1098,66 @@ await section('审批卡:390px 不横向溢出、窄栏里上下排、深浅
await shot(rail.page, 'approval-rail-dark')
})
await section('出具缺口:证据页、成果节点的印章把核查报上来的缺口另起两组,不放在「校验未全部完成」下', async () => {
// 双盲复算、反方审稿报上来的缺口(出具契约加了「双盲复算「节点名」:」「反方审稿:」的前缀)以前和「叙述模板渲染为空」
// 一起列在「校验未全部完成」下。证据页(真的 EvidencePane)在出具横幅下面另起「核查未通过」(两路不一致、结论被推翻)
// 「核查未完成」(无法复算)两组,横幅里只留校验没做完的;成果节点印章的悬停说明同样分组、同样的先后。用例
// verify-gaps(dev/fixtures/dual_check.tsx):缺口的先后照服务端,核查的和别的混在一起;证据图、审计接口在这里伪造
const RUN = 'run-verify-gaps'
const FAILED = ['双盲复算「复核周报口径」:指标「销售额」两路结果不一致,审核人选择了盲算结果', '反方审稿:推翻了 1 句结论:「东区增长主要来自新客」']
const UNFINISHED = ['双盲复算「复核周报口径」:指标「订单数」无法复算:输入来自合并查询,无法单独复算']
const OTHER = ['叙述模板渲染为空(路径可能有误)']
const extra = async (req, url) => {
if (url.pathname === `/api/runs/${RUN}/evidence`) {
return { json: { run_id: RUN, mode: 'none', note: '这次运行没有报告文档', seal: { sealed: true, ok: true, manifest_seq: 7 } } }
}
if (url.pathname === `/api/runs/${RUN}/evidence/audit`) return { status: 404, json: { detail: 'Not Found' } }
return null
}
const items = (els) => els.map((el) => el.textContent.replace(/^·\s*/, '').trim())
for (const [width, theme] of [[1280, 'dark'], [390, 'dark'], [390, 'light']]) {
const what = `${width}px(${theme})`
const { page, errors } = await preview('verify-gaps', { extra, width, height: 900, theme })
await page.waitForSelector('[data-evidence-verify-gaps]')
const groups = await page.evaluate(() => [...document.querySelectorAll('[data-evidence-verify-group]')].map((g) => ({
kind: g.dataset.evidenceVerifyGroup, head: g.firstElementChild?.textContent.trim(),
items: [...g.querySelectorAll('li')].map((li) => li.textContent.replace(/^·\s*/, '').trim()),
})))
check(`${what} 证据页:横幅下面另起两组,先「核查未通过」(两路不一致、被推翻),再「核查未完成」(无法复算)`,
JSON.stringify(groups) === JSON.stringify([{ kind: 'failed', head: '核查未通过', items: FAILED },
{ kind: 'unfinished', head: '核查未完成', items: UNFINISHED }]), JSON.stringify(groups))
const banner = await page.locator('[data-evidence-issuance] [data-issuance-gaps] li').evaluateAll(items)
check(`${what} ……横幅的「校验未全部完成」里只剩校验没做完的那条`, JSON.stringify(banner) === JSON.stringify(OTHER)
&& (await text(page.locator('[data-evidence-issuance] [data-issuance-gaps]'))).startsWith('校验未全部完成'), JSON.stringify(banner))
// 成果节点的印章:悬停说明里核查的两组排在「校验未全部完成」前面,各组只列自己的
const stamp = page.locator('.react-flow__node[data-id="done"] .nc-stamp')
await stamp.waitFor()
const lines = ((await stamp.getAttribute('title')) ?? '').split('\n')
const at = (head) => lines.findIndex((l) => l.startsWith(head))
check(`${what} 成果节点印章:悬停说明先「核查未通过」、再「核查未完成」、最后「校验未全部完成」,各列各的`,
lines[at('核查未通过:')] === `核查未通过:${FAILED.join(';')}` && lines[at('核查未完成:')] === `核查未完成:${UNFINISHED.join(';')}`
&& lines[at('校验未全部完成:')] === `校验未全部完成:${OTHER.join(';')}`
&& at('核查未通过:') < at('核查未完成:') && at('核查未完成:') < at('校验未全部完成:'), lines.join(' / '))
const w = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check(`${what}:证据页不横向溢出`, w <= 0, `${w}px`)
const c = await page.evaluate(() => {
const box = document.querySelector('[data-evidence-verify-gaps]')
const bgOf = (el) => {
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
const v = getComputedStyle(n).backgroundColor
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v
}
return 'rgba(0, 0, 0, 0)'
}
return { fg: getComputedStyle(box).color, bg: bgOf(box), base: getComputedStyle(document.body).backgroundColor }
})
const ratio = contrast(c.fg, c.bg, c.base)
check(`${what}:核查那两组的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`)
await shot(page, `verify-gaps-${width}-${theme}`)
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
}
})
await section('记录页的工件列表:双盲复算对照、反方审稿记录有名字', async () => {
// 工件列表按 pages/runs/model.ts 的 ARTIFACT_KIND_LABEL 写种类:期 3 新加的两种工件没登记时,列表里露出的是键名
// dual_check、challenge,说明一栏只有一句「工件」。按页面自己加载的那一份取(同 check-evidence 的工件一节)