可点击证据第一期:报告里的每个数字都点得开出处

「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡
填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。

内核(engine/evidence.py,口径卡、出具契约、表达式等)
- 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本
- 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个
- 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理
- 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档

报告撰写节点(report)与引用模式的出具契约
- 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写
  (max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认)
- report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败
- 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具
- 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道)

证据接口
- GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链
- 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存
- 没有报告的旧运行降级为 none 或 legacy_contract

界面
- EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位,
  ←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去
- EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、
  封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿
- 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差
- 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」

验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。
This commit is contained in:
ThearchyHelios
2026-09-28 11:47:23 +08:00
parent b418c0d951
commit e4f2693918
56 changed files with 12309 additions and 215 deletions
+3 -1
View File
@@ -34,6 +34,7 @@ const ORDER = [
'guards', // 输入法、未知节点、错误边界
'shell', // 外壳:导航、徽标、命令面板、离开前确认、启动页
'stream', // 回答流的渲染
'evidence', // 可点击证据:报告逐段渲染、证据面板、旧契约按位置标记
'run-states', // 节点卡的运行态
'canvas-fx', // 画布表层的运行态
'studio', // 编排页的编辑
@@ -45,7 +46,8 @@ const ORDER = [
const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000)
// 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截,
// 却照样报「全部通过」,所以一律不传给子进程
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME']
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME',
'EVIDENCE_ONLY']
// 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒
const unlisted = readdirSync(HERE)
.map((f) => f.match(/^check-(.+)\.mjs$/)?.[1])
+58 -2
View File
@@ -31,7 +31,7 @@
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-chat.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
import { mkdirSync } from 'node:fs'
import { mkdirSync, readFileSync } from 'node:fs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH
@@ -273,7 +273,13 @@ const DETAIL = {
// 要改的是工具库里的参数定义,不在这一轮的流程里:先去改,再接着跑(没记 fix 的老写法,按原话认)
c0fixy: [turn('ty1', '查一下订单状态', { status: 'error', error: BROKEN_TOOL, graph: GRAPH, run_id: 'run-badtool' })],
}
/**
* 成果带逐段证据的一轮(报告撰写节点 + _evidence)。夹具是后端 compose_doc 真跑出来的,只用
* 通用名。复核回的是一次改写:有证据时复核只能加说明,答案不能被换掉
*/
const EVIDENCE = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUNS = {
'run-evid': { status: 'succeeded', output: EVIDENCE.output, error: null },
'run-cancelled': { status: 'cancelled', output: {}, error: null },
'run-failed': { status: 'failed', output: {}, error: '查询超时' },
'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' },
@@ -406,6 +412,12 @@ async function fakeApi(route) {
if (path === '/copilot/review' && method === 'POST') {
const wait = ctl.reviewDelay[body().run_id]
if (wait) await new Promise((r) => setTimeout(r, wait))
// 老后端的复核不认 _evidence,照样回一次改写
if (body().run_id === 'run-evid') {
return json({ verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多',
original: EVIDENCE.output['周报'], retry: false, severity: 'degraded',
signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] })
}
return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] })
}
if (path === '/runs' && method === 'POST') {
@@ -413,7 +425,8 @@ async function fakeApi(route) {
const conversation = String(b.input?.question ?? '')
// 发起请求根本没到后端(网断了、后端正在重启)
if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused')
const id = conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long')
const id = conversation.includes('带证据') ? 'run-evid'
: conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long')
: conversation.includes('审批') ? 'run-wait'
: conversation.includes('重启') ? 'run-restart'
: conversation.includes('正在写') ? 'run-writing'
@@ -478,6 +491,18 @@ function script(runId, after = 0) {
end: 'succeeded',
}
}
if (runId === 'run-evid') {
return {
events: [
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'write', { node_type: 'report', label: '写周报' }),
ev(3, 'report.checked', 'write', EVIDENCE.report_checked),
ev(4, 'node.finished', 'write', { duration_ms: 900 }),
ev(5, 'run.finished', null, { output: EVIDENCE.output }),
],
end: 'succeeded',
}
}
if (runId === 'run-long') {
return {
events: [
@@ -876,6 +901,37 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('evidence', '成果带逐段证据:复核只加说明,不改写答案(可点击证据第一期)', async () => {
const { page, ctx, errors } = await open(theme)
// 文档记着的运行要和成果的运行对得上(run-evid),不然前端按「别的运行写的」退回普通文本
await page.route(/\/api\/artifacts\//, (route) => route.fulfill({ json: { id: EVIDENCE.doc_artifact,
content: { ...EVIDENCE.doc, run_id: 'run-evid' } } }))
await page.route(/\/api\/runs\/run-evid\/evidence(\/.*)?(\?.*)?$/, (route) => route.fulfill({ json: EVIDENCE.graph }))
const patchesBefore = log.patches.length
await goto(page, 'c0trunc')
await send(page, '写一份带证据的周报')
check('答完了', await page.waitForFunction(
() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'done', null, { timeout: 10000 })
.then(() => true, () => false))
const t = (await chatState(page)).byConversation.c0trunc.at(-1)
check('复核的改写没有落到答案上:成果还是报告原文和它的证据标注',
t.output?.['周报'] === EVIDENCE.output['周报'] && !!t.output?._evidence && !t.output?.answer, JSON.stringify(Object.keys(t.output ?? {})))
check('复核的说明照样摆出来,改写的文字丢掉', t.review?.note?.includes('检索降级过') && t.review.answer === null
&& t.review.verdict === 'annotated' && !t.rawOutput, JSON.stringify(t.review))
await page.waitForSelector('[data-evidence-doc]', { timeout: 5000 }).catch(() => {})
check('页面上是逐段可点的报告,不是改写后的文字', await page.locator('[data-evidence-doc] [data-seg]').count() > 0
&& !(await shows(page, '改写后的答案')))
check('没有「复核改写过这个答案」的对照', await page.getByText('复核改写过这个答案').count() === 0)
await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta))
const saved = log.patches.slice(patchesBefore)
check('落库的是报告原文,不是改写', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer)
.every((p) => p.body.answer === EVIDENCE.output['周报']), saved.map((p) => String(p.body.answer ?? '').slice(0, 12)).join(' | '))
check('meta 记下这一轮有证据(刷新后回运行那里取标注)', saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true)
check('落库的复核也不带改写', saved.filter((p) => p.body.review?.verdict).every((p) => !p.body.review.answer))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('restart', '服务重启:interrupted 且没有待审批,收尾而不是转圈', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0resti')
+76
View File
@@ -1049,6 +1049,82 @@ await section('分支、循环出口的说法(runfx-9、终验 NEW)', async
check('没有出口的节点不进表', labelOf('x', 'out') === undefined)
})
await section('报告核对 report.checked、口径卡的台账(可点击证据第一期)', async () => {
// 夹具由后端 evidence.py 的 compose_doc 真跑生成(只用通用名),report_checked 是报告节点发的那条事件的载荷
const evidence = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
const rc = evidence.report_checked
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
// 修复重写那条日志的原话:和 backend/app/engine/nodes/report.py 发的同一个格式(_named 用「、」
// 接着点名的字)。下面先核对后端源码里还是这个格式,免得这里的夹具自说自话
const REPAIR_MESSAGE = '报告里有 3 处没通过核对(「12」、「m:nope」、「3」),已要求写作者重写(第 1 次)'
const reportPy = readFileSync(`${root}backend/app/engine/nodes/report.py`, 'utf8')
check('后端发修复日志的格式还是夹具里这一种', reportPy.includes('code="report_repair"')
&& reportPy.includes('处没通过核对({_named(blocking)}),') && reportPy.includes('已要求写作者重写(第 {repairs} 次)')
&& reportPy.includes('"、".join(f"「{n}」"'), '改了 report.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
const card = evidence.doc.catalog['m:gmv'].artifact
const events = [
E(1, 'run.started', null, { total: 3 }),
E(2, 'node.started', 'caliber', { node_type: 'metrics', label: '周报口径' }),
// 口径卡的 node.finished 多了台账(evidence 数组),产出里多了 artifact 字符串:照常解码、不另起行
E(3, 'node.finished', 'caliber', { duration_ms: 12,
preview: { kind: 'metric_set', caliber: '周报口径', caliber_version: 'v2', artifact: card, text: '销售额 = 45,678.5元' },
evidence: [{ kind: 'metric_set', node_id: 'caliber', exec: 1, artifact: card, caliber: '周报口径', version: 'v2', metrics: ['gmv'] }] }),
E(4, 'node.started', 'write', { node_type: 'report' }),
// 后端 report.py 原样的说法:「报告里有 N 处没通过核对(点名的字),已要求写作者重写(第 N 次)」
E(5, 'log', 'write', { level: 'warn', code: 'report_repair', message: REPAIR_MESSAGE }),
E(6, 'report.checked', 'write', rc),
E(7, 'node.finished', 'write', { duration_ms: 2300, preview: { text: evidence.doc.markdown.slice(0, 40), doc_artifact: rc.doc_artifact } }),
E(8, 'run.finished', null, { output: {} }),
]
const steps = mod.decodeRun(events)
const all = flatten(steps)
check('新事件都有翻译,没有「一条还没翻译的记录」', !all.some((s) => s.title === '一条还没翻译的记录'),
all.filter((s) => s.title === '一条还没翻译的记录').map((s) => s.detail?.split('\n')[0]).join('、'))
const node = steps.find((s) => s.nodeId === 'write' && s.kind === 'node')
check('没起名的报告节点叫「报告撰写」', node?.title === '报告撰写', node?.title)
const row = all.find((s) => s.code === 'report_checked')
check('report.checked 解码成一条步骤,挂在报告节点下面', !!row && !!node?.children?.includes(row), row?.title)
check('标题说几个数字有出处、几个没有(算得平:12 − 7 = 5),非数字引用另起一句(和横幅同一种说法)',
row?.title === '核对报告:7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了', row?.title)
check('有无证据的地方就是提醒,不是安静的一行', row?.level === 'warn', row?.level)
check('能下钻到报告文档', row?.artifact === rc.doc_artifact, row?.artifact)
check('违规清单放进展开区', !!row?.detail?.includes('数字「12」没有出处') && !!row.detail.includes('这种引用在后续版本支持'),
row?.detail?.slice(0, 80))
check('重写过几次写在行尾', row?.meta === '重写 1 次', row?.meta)
const repair = all.find((s) => s.code === 'report_repair')
check('修复重写说人话:几处没通过、第几次,原话留在展开区', repair?.title === '报告有 3 处没通过核对,已让模型按清单重写(第 1 次)'
&& repair.detail === REPAIR_MESSAGE, `${repair?.title} / ${repair?.detail}`)
check('点名的字放进副标题(「12」「m:nope」这类)', repair?.sub === '「12」、「m:nope」、「3」', repair?.sub)
const oldSaying = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report' }),
E(2, 'log', 'w', { level: 'warn', code: 'report_repair', message: '报告要重写' })])).find((s) => s.code === 'report_repair')
check('别的说法(老后端):标题照样说人话,不瞎填数', oldSaying?.title === '报告没通过核对,已让模型按清单重写'
&& oldSaying.sub === undefined, `${oldSaying?.title} / ${oldSaying?.sub}`)
const caliber = steps.find((s) => s.nodeId === 'caliber')
check('口径卡带台账的 node.finished 照常收成完成', caliber?.status === 'done' && !caliber.children?.length,
`${caliber?.status} ${caliber?.children?.length ?? 0}`)
const clean = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写周报' }),
E(2, 'report.checked', 'w', { doc_artifact: 'abc', repairs: 0,
stats: { numbers: 7, numbers_cited: 7, uncited_numbers: 0, unresolved: 0 }, violations: [] })]))
.find((s) => s.code === 'report_checked')
check('全都有出处:一句话说完,不是提醒', clean?.title === '核对报告:7 个数字都有出处' && clean.level !== 'warn'
&& !clean.meta, `${clean?.title} ${clean?.level} ${clean?.meta}`)
const blocked = flatten(mod.decodeRun([E(1, 'node.started', 'w', { node_type: 'report', label: '写周报' }),
E(2, 'report.checked', 'w', { doc_artifact: 'abc', repairs: 1, ok: false, on_violation: 'fail', failed: true,
stats: { numbers: 3, numbers_cited: 2, uncited_numbers: 1, unresolved: 0 },
violations: [{ code: 'uncited_number', message: '数字「45678」没有出处' }] })]))
.find((s) => s.code === 'report_checked')
check('fail 模式下仍不过:这一步画成失败,不是完成', blocked?.status === 'failed' && blocked.level === 'error',
`${blocked?.status} ${blocked?.level}`)
const flagged = flatten(mod.decodeRun([E(1, 'report.checked', 'w', { ok: false, on_violation: 'flag', failed: false,
stats: { numbers: 3, numbers_cited: 2, uncited_numbers: 1, unresolved: 0 }, violations: [] })]))
.find((s) => s.code === 'report_checked')
check('flag 模式照常产出:完成、提醒级', flagged?.status === 'done' && flagged.level === 'warn',
`${flagged?.status} ${flagged?.level}`)
const bare = flatten(mod.decodeRun([E(1, 'report.checked', null, {})])).find((s) => s.code === 'report_checked')
check('载荷缺字段也不崩,只说核对了报告', bare?.title === '核对报告' && !bare.artifact, bare?.title)
})
await section('术语', async () => {
// 没起名的节点退到类型名,类型名跟全站同一张表
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])
+697
View File
@@ -0,0 +1,697 @@
// 可点击证据(第一期:数字层)的回归检查:报告文档逐段渲染、证据面板、键盘、读屏、
// 减少动效、窄栏,以及旧契约「按位置标记」和没有 _evidence 的旧回答。
//
// 守的是这些「看着没坏、其实用不了」的地方:两种状态的下划线长得一样(去掉颜色就分不
// 出有没有出处)、整份报告每个数字各占一个 Tab 位、面板关了焦点丢到页首、减少动效时面板
// 照样滑进来、360px 的画布右栏被面板撑出横向滚动、日期里的 15 被当成回指不上的 15、
// 旧运行的答案被新组件改了样子。
//
// 跑在预览页上:/ui-harness.html?evidence=1(组件)和 /preview.html?syn=evidence(问数据页
// 的答案形态)。文档是后端 compose_doc 真跑出来的夹具(frontend/src/run/__tests__/
// evidence-doc.json,只用通用名);片段接口、证据图、报告工件都用 page.route 伪造,非 GET
// 一律拦掉,不写库。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
// 带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-evidence.mjs
// EVIDENCE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.EVIDENCE_SHOTS
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
// EVIDENCE_ONLY=look,keys 只跑这几节(改坏验证时省时间);check-all 不会把它传下来
const ONLY = (process.env.EVIDENCE_ONLY ?? '').split(',').filter(Boolean)
async function section(key, name, fn) {
if (ONLY.length && !ONLY.includes(key)) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
const browser = await chromium.launch({ executablePath: CHROME })
/** 伪造证据相关的 GET;片段请求记账(缓存:同一个片段点两次只取一次) */
async function routed(page) {
const hits = []
await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => {
const req = r.request()
const url = new URL(req.url())
if (req.method() !== 'GET') return r.abort()
const seg = url.pathname.match(/\/api\/runs\/[^/]+\/evidence\/segments\/([^/]+)$/)
if (seg) {
hits.push(decodeURIComponent(seg[1]))
const body = fx.segments[decodeURIComponent(seg[1])]
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '报告里没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: fx.graph })
if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } })
return r.continue()
})
return hits
}
async function open(url, { w = 1280, h = 1000, reduced = false } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const hits = await routed(page)
await page.goto(`${WEB}${url}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-evidence-doc], [data-turn]', { timeout: 8000 })
return { ctx, page, errors, hits }
}
const active = (page) => page.evaluate(() => document.activeElement?.getAttribute('data-seg')
?? (document.activeElement?.closest('[data-evidence-panel]') ? 'panel' : document.activeElement?.tagName ?? null))
const panel = (page) => page.locator('[data-evidence-panel]')
let harness
try {
harness = await open('/ui-harness.html?evidence=1')
} catch (e) {
console.error(`✗ 打不开预览页(${WEB}/ui-harness.html?evidence=1)——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
await browser.close()
process.exit(1)
}
const { page } = harness
const wide = '#evidence-wide'
await section('look', '两种状态四通道都分得开:线型、字形、颜色、文字', async () => {
const look = await page.evaluate((sel) => {
const pick = (state) => {
const el = document.querySelector(`${sel} [data-ev-state="${state}"]`)
if (!el) return null
const cs = getComputedStyle(el)
return { line: cs.textDecorationLine, style: cs.textDecorationStyle, color: cs.textDecorationColor,
tag: el.tagName, type: el.getAttribute('type'), font: cs.fontSize, fg: cs.color }
}
const body = getComputedStyle(document.querySelector(`${sel} [data-evidence-doc] p`)).color
return { det: pick('deterministic'), none: pick('none'), body }
}, wide)
check('确定性画实线下划线', look.det?.line.includes('underline') && look.det.style === 'solid', JSON.stringify(look.det))
check('无证据画点状下划线', look.none?.line.includes('underline') && look.none.style === 'dotted', JSON.stringify(look.none))
check('两种线型不同(读计算后的 text-decoration-style)', look.det?.style !== look.none?.style)
check('两种下划线颜色也不同', look.det?.color !== look.none?.color, `${look.det?.color} / ${look.none?.color}`)
check('片段是原生 button type=button', look.det?.tag === 'BUTTON' && look.det.type === 'button')
check('片段外观和正文一样:字色、字号继承正文', look.det?.fg === look.body, `${look.det?.fg} vs ${look.body}`)
const labels = await page.evaluate((sel) => Object.fromEntries(['s8', 's16', 's33', 's4'].map((id) =>
[id, document.querySelector(`${sel} [data-seg="${id}"]`)?.getAttribute('aria-label') ?? ''])), wide)
check('aria-label 写了状态和出处(有出处)', labels.s8 === '8.7%,有出处:口径卡指标 环比增幅', labels.s8)
check('aria-label 写了状态和原因(裸数字)', labels.s16.startsWith('12,无证据:'), labels.s16)
check('aria-label 写了解析不了的原因', labels.s33.includes('无证据') && labels.s33.includes('显示为 0'), labels.s33)
check('运行输入也写明出处', labels.s4 === '2026-W37,有出处:运行输入 week', labels.s4)
const tags = await page.locator(`${wide} .ev-tag`).allInnerTexts()
check('含无证据的句子末尾挂「?无证据」(字形 + 文字),表格格子里不挂', tags.length === 5 && tags.every((t) => t === '?无证据'),
tags.join('|'))
const summary = await page.locator(`${wide} [data-evidence-summary]`).innerText().catch(() => '')
check('顶部有一段读屏摘要,说清计数和按键', summary.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && summary.includes('方向键'), summary)
check('读屏摘要说清画不了线的在哪:列表序号里的数字,不混说成「句末依据」', summary.includes('其中 1 个数字在列表序号')
&& !summary.includes('句末依据'), summary)
const sr = await page.evaluate((sel) => {
const el = document.querySelector(`${sel} [data-evidence-summary]`)
const r = el?.getBoundingClientRect()
return !!r && r.width <= 1 && r.height <= 1
}, wide)
check('读屏摘要不占视觉位置(sr-only)', sr)
const tally = await page.locator(`${wide} [data-evidence-tally]`).innerText().catch(() => '')
check('没有出具横幅时文档自己给出计数条', tally.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && tally.includes('定位下一处'), tally)
const [cited, total, gaps] = (tally.match(/(\d+)\/(\d+) 数字有出处 · 无证据 (\d+)/) ?? []).slice(1).map(Number)
check('计数算得平:无证据 = 总数 − 有出处', total - cited === gaps, tally)
})
await section('keys', '键盘:整份报告一个 Tab 位,←/→、↑/↓、n/N', async () => {
const stops = await page.locator(`${wide} [data-seg][tabindex="0"]`).count()
const segs = await page.locator(`${wide} [data-seg]`).count()
check('只有一个片段在 Tab 顺序里(roving tabindex)', stops === 1 && segs > 5, `${stops}/${segs}`)
await page.locator(`${wide} [data-evidence-list]`).focus()
await page.keyboard.press('Tab')
check('Tab 进报告落在第一个片段', await active(page) === 's4', await active(page))
await page.keyboard.press('Tab')
// 下一个 Tab 位是表格的「复制为 CSV」之类的控件,不能是另一个片段
const next = await active(page)
check('再按一次 Tab 不会落到另一个片段上', !/^s\d+$/.test(String(next)), String(next))
await page.locator(`${wide} [data-seg="s4"]`).focus()
const walk = []
for (const key of ['ArrowRight', 'ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp']) {
await page.keyboard.press(key)
walk.push(await active(page))
}
check('→ → ← 在片段间走', walk.slice(0, 3).join(',') === 's6,s8,s6', walk.join(','))
check('↓ 到下一句的第一个片段,↑ 回来', walk[3] === 's16' && walk[4] === 's4', walk.join(','))
const stop0 = await page.locator(`${wide} [data-seg][tabindex="0"]`).getAttribute('data-seg')
check('Tab 位跟着焦点走', stop0 === 's4', stop0)
const jumps = []
for (const key of ['n', 'n', 'Shift+N', 'N']) {
await page.keyboard.press(key)
jumps.push(await active(page))
}
check('n 跳到下一处无证据、N / Shift+N 回上一处', jumps.join(',') === 's16,s26,s16,s36', jumps.join(','))
await page.keyboard.press('End')
check('End 到最后一个片段', await active(page) === 's49', await active(page))
await page.keyboard.press('ArrowRight')
check('→ 首尾相接', await active(page) === 's4', await active(page))
check('只是走动不打开面板', await panel(page).count() === 0)
})
await section('panel', '面板:回车打开、能看到代入式、Esc 关掉焦点回原片段', async () => {
await page.locator(`${wide} [data-seg="s8"]`).focus()
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]', { timeout: 4000 })
const p = panel(page)
check('宽屏从侧边弹出', await p.getAttribute('data-evidence-panel') === 'side')
const sub = await p.locator('[data-ev-substituted]').innerText()
check('代入式', sub === 'round((45678.5 - 42010.0) / 42010.0 * 100, 1)', sub)
check('原式', (await p.locator('[data-ev-expression]').innerText()).includes('vars.kpi.gmv_prev'))
const chips = await p.locator('[data-ev-input]').allInnerTexts()
check('每个输入是「值 ← 路径」的小标签', chips.join('|') === '45,678.5 ← vars.kpi.gmv|42,010 ← vars.kpi.gmv_prev', chips.join('|'))
const text = await p.innerText()
check('指标名称、值、口径与版本', text.includes('环比增幅') && text.includes('8.7%') && text.includes('口径卡「周报口径」') && text.includes('v2'))
check('复算结果', await p.locator('[data-ev-recompute]').getAttribute('data-ev-recompute') === 'ok' && text.includes('复算一致'))
check('封存状态用状态徽标', await p.locator('[data-ev-seal] svg[data-status="done"]').count() === 1 && text.includes('已封存 · 核对一致'))
check('所在的句子里点开的那段描底', (await p.locator('[data-ev-sentence] [data-ev-here]').innerText()) === '8.7%')
check('片段标着展开了哪个面板', await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-expanded') === 'true'
&& !!(await page.locator(`${wide} [data-seg="s8"]`).getAttribute('aria-controls')))
check('焦点留在片段上,读屏从 live region 听到标题', await active(page) === 's8'
&& (await page.locator(`${wide} [aria-live="polite"]`).innerText()).includes('8.7%,有出处'))
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(150)
check('面板开着时方向键走到哪,面板跟到哪', (await p.locator('h3').innerText()) === '1,234单', await p.locator('h3').innerText())
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 关面板,焦点仍在原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page))
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel]')
await page.keyboard.press('Tab')
check('Tab 从片段直接进面板', await active(page) === 'panel', await active(page))
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('在面板里按 Esc:关掉并把焦点还回原片段', await panel(page).count() === 0 && await active(page) === 's10', await active(page))
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
const again = harness.hits.filter((h) => h === 's8').length
check('同一个片段再点开不重复请求(按 runId:segId 缓存)', again === 1, `请求了 ${again} 次`)
await page.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click()
check('关闭按钮', await panel(page).count() === 0)
})
await section('none', '没有证据的地方:原因、缺输入 / 显示不出来、违规清单', async () => {
await page.locator(`${wide} [data-seg="s26"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]')
let text = await panel(page).innerText()
check('解析不了的引用说出原因', text.includes('缺输入'), text.slice(0, 80))
check('value 为 null:写「缺输入」', await panel(page).locator('[data-ev-missing]').count() === 1
&& await panel(page).locator('[data-ev-unshowable]').count() === 0)
check('没有证据的片段不说「这个数已封存」', text.includes('报告文档已封存'))
await page.locator(`${wide} [data-seg="s33"]`).click()
await page.waitForTimeout(200)
text = await panel(page).innerText()
check('value 不为 null、rendered 为「—」:写「有值但显示不出来」,和缺输入分开',
(await panel(page).locator('[data-ev-unshowable]').innerText().catch(() => '')).includes('有值(29)')
&& await panel(page).locator('[data-ev-missing]').count() === 0, text.slice(0, 120))
await page.locator(`${wide} [data-seg="s16"]`).click()
await page.waitForTimeout(200)
text = await panel(page).innerText()
check('裸数字说清没写引用标记', text.includes('没有写成引用标记') && text.includes('要写成引用标记'), text.slice(0, 120))
await page.locator('[data-evidence-panel] [data-ev-open-violations]').click()
await page.waitForSelector('[data-ev-violations]')
const items = page.locator('[data-ev-violation]')
check('违规清单列全 6 条', await items.count() === 6, String(await items.count()))
const hundred = items.filter({ hasText: '100' })
check('列表序号里的 100 在清单里找得到,并说清为什么正文里没画线',
await hundred.getAttribute('data-ev-locatable') === 'no' && (await hundred.innerText()).includes('列表序号'),
await hundred.innerText().catch(() => ''))
await items.filter({ hasText: '数字「3」' }).getByRole('button', { name: '定位' }).click()
await page.waitForTimeout(250)
check('清单里「定位」跳到正文那一处并打开它的证据', await active(page) === 's36'
&& (await panel(page).locator('h3').innerText()) === '3', await active(page))
await page.keyboard.press('Escape')
await page.locator(`${wide} [data-evidence-next]`).click()
await page.waitForTimeout(250)
const first = await active(page)
const flash = await page.locator(`${wide} [data-seg="s16"]`).getAttribute('data-flash')
await page.locator(`${wide} [data-evidence-next]`).click()
await page.waitForTimeout(250)
check('「定位下一处」依次跳到无证据的片段、描一下边、打开面板', first === 's16' && flash === 'focus'
&& await active(page) === 's26' && await panel(page).count() === 1, `${first} ${flash} ${await active(page)}`)
await page.keyboard.press('Escape')
})
await section('narrow', '360px:画布右栏里栏内展开,不出横向滚动', async () => {
const narrow = '#evidence-narrow'
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth, w: el.getBoundingClientRect().width }
}, narrow)
const before = await overflow()
await page.locator(`${narrow} [data-seg="s8"]`).click()
await page.waitForSelector(`${narrow} [data-evidence-panel="inline"] [data-ev-substituted]`)
const after = await overflow()
check('窄栏宽 360px', Math.round(before.w) === 360, String(before.w))
check('窄栏里没有横向滚动(面板打开前后)', before.sw <= before.cw && after.sw <= after.cw, JSON.stringify({ before, after }))
check('窄栏里的面板在栏内、紧跟着片段所在的块', await page.evaluate((sel) => {
const p = document.querySelector(`${sel} [data-evidence-panel]`)
return p?.previousElementSibling?.getAttribute('data-block') === 'b1'
}, narrow))
check('栏内面板带「回到正文」', await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).count() === 1)
await page.locator(`${narrow} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
await page.waitForTimeout(150)
check('回到正文:面板收起,焦点回片段', await page.locator(`${narrow} [data-evidence-panel]`).count() === 0 && await active(page) === 's8')
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
const scroll = () => small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
const a = await scroll()
await small.page.locator('#evidence-wide [data-seg="s8"]').click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
const b = await scroll()
check('360px 宽的屏幕:面板从底部抽出', await small.page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel') === 'drawer')
check('360px 宽的屏幕:整页没有横向滚动(面板打开前后)', a.sw <= a.vw && b.sw <= b.vw, JSON.stringify({ a, b }))
const box = await small.page.locator('[data-evidence-panel]').boundingBox()
check('抽屉不超出屏幕', !!box && box.x >= 0 && box.x + box.width <= 361, JSON.stringify(box))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await small.page.waitForTimeout(250)
await small.page.screenshot({ path: `${SHOTS}/evidence-360-drawer-${theme}.png` })
}
}
check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | '))
await small.ctx.close()
})
await section('motion', '减少动效:面板直接出现,没有动画', async () => {
// 先证明这条检查抓得住:正常情况下面板是滑进来的
// 全局的减少动效规则把过渡压到 0.01ms(主题色切换那种),那不算动画;动画和更长的过渡才算
const running = () => {
const p = document.querySelector('[data-evidence-panel]')
if (!p) return -1
return p.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length
}
await page.locator(`${wide} [data-seg="s8"]`).click()
const moving = await page.evaluate(running)
check('正常情况下面板有入场动画(对照)', moving > 0, String(moving))
await page.keyboard.press('Escape')
const r = await open('/ui-harness.html?evidence=1', { reduced: true })
await r.page.locator('#evidence-wide [data-seg="s8"]').click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-substituted]').catch(() => {})
const still = await r.page.evaluate((fn) => ({
n: new Function(`return (${fn})()`)(),
name: getComputedStyle(document.querySelector('[data-evidence-panel]')).animationName,
}), running.toString())
check('减少动效时面板没有任何动画', still.n === 0 && still.name === 'none', JSON.stringify(still))
await r.page.locator('#evidence-narrow [data-seg="s8"]').click()
const inline = await r.page.evaluate(() => document.querySelector('#evidence-narrow [data-evidence-panel]')
?.getAnimations({ subtree: true })
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length ?? -1)
check('减少动效时栏内面板也不动', inline === 0, String(inline))
await r.ctx.close()
})
await section('legacy', '旧契约按位置标记(REQ-A-2)', async () => {
const pos = await page.evaluate(() => {
const box = document.querySelector('#legacy-marks')
const marks = [...box.querySelectorAll('[data-mark]')]
return marks.map((m) => ({ tone: m.dataset.mark, text: m.textContent, title: m.title,
before: (m.previousSibling?.textContent ?? '').slice(-3) }))
})
const warn = pos.filter((m) => m.tone === 'warn')
check('按位置:只标正文里回指不上的那个 15,日期 2026-09-15 里的不标', warn.length === 1 && warn[0].text === '15'
&& !warn[0].before.includes('-'), JSON.stringify(warn))
const amb = pos.filter((m) => m.tone === 'ambiguous')
check('出处不唯一写「出处不唯一:候选 a、b」', amb.length === 2 && amb.every((m) => m.title === '出处不唯一:候选 refund_cnt、reship_cnt'),
JSON.stringify(amb))
check('不再出现「来自口径卡指标「」」', !pos.some((m) => m.title.includes('指标「」')))
check('回指上的照常标出来源', pos.some((m) => m.tone === 'ok' && m.text === '1,234' && m.title.includes('orders')))
const loose = await page.locator('#legacy-loose [data-mark="warn"]').allInnerTexts()
check('老运行没有位置:退回按字符串标(日期里的 15 也会被标,和以前一样)', loose.length === 2 && loose.every((t) => t === '15'),
loose.join('|'))
})
await section('chat', '问数据页的答案:带 _evidence 的换成证据文档,没有的和以前一样', async () => {
const chat = await open('/preview.html?syn=evidence')
const p = chat.page
await p.waitForSelector('[data-turn="evidence"] [data-evidence-doc]', { timeout: 6000 })
const line = await p.locator('[data-turn="evidence"] [data-evidence-line]').innerText().catch(() => '')
check('出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」', line.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了') && line.includes('定位下一处'), line)
const note = await p.locator('[data-turn="evidence"] [data-unmatched-note]').innerText().catch(() => '')
check('横幅不再说「正文里已用虚线标出」(列表序号里的 100 画不了线),改指违规清单', !note.includes('虚线')
&& note.includes('违规清单'), note)
await p.locator('[data-turn="evidence"] [data-issuance-banner] [data-evidence-list]').click()
await p.waitForSelector('[data-evidence-panel] [data-ev-violations]', { timeout: 3000 }).catch(() => {})
check('横幅上的「违规清单」打开报告的违规清单,100 在里面', (await p.locator('[data-evidence-panel] [data-ev-violation]')
.filter({ hasText: '100' }).count()) === 1)
await p.keyboard.press('Escape')
await p.locator('[data-evidence-panel] button[aria-label="关闭证据"]').click().catch(() => {})
check('有横幅时文档不再重复计数条', await p.locator('[data-turn="evidence"] [data-evidence-tally]').count() === 0)
await p.locator('[data-turn="evidence"] [data-evidence-next]').click()
await p.waitForSelector('[data-evidence-panel]')
check('横幅的「定位下一处」跳到第一处无证据并打开面板', await active(p) === 's16')
await p.keyboard.press('Escape')
const legacy = await p.evaluate(() => {
const turn = document.querySelector('[data-turn="evidence-legacy"]')
const md = turn?.querySelector('div.space-y-2.leading-relaxed')
const base = document.querySelector('#md-baseline > div')
return { segs: turn?.querySelectorAll('[data-seg], [data-evidence-doc]').length ?? -1,
same: !!md && !!base && md.outerHTML === base.outerHTML, md: !!md, base: !!base }
})
check('没有 _evidence 的回答不出现证据片段', legacy.segs === 0, String(legacy.segs))
check('没有 _evidence 的回答渲染和普通 Markdown 一字不差', legacy.same, JSON.stringify(legacy))
const row = await p.locator('[data-turn="evidence"]').innerText()
check('执行过程里有「核对报告」那一行', row.includes('核对报告:7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了'))
const legacyNote = await p.locator('[data-turn="evidence-legacy"] [data-unmatched-note]').count()
check('没有 _evidence 的旧回答不挂横幅也不挂证据说法', legacyNote === 0)
check('没有运行时报错(问数据页)', chat.errors.length === 0, chat.errors.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.locator('[data-turn="evidence"] [data-seg="s8"]').click()
await p.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
await p.waitForTimeout(350)
await p.screenshot({ path: `${SHOTS}/evidence-chat-${theme}.png` })
await p.keyboard.press('Escape')
}
}
await chat.ctx.close()
})
await section('artifacts', '记录页的工件:口径卡指标集、报告文档有名字(REQ-A-1)', async () => {
// 按页面自己加载的那一份取(热更新过的模块带 ?t=,另 import 一份也是同一套代码,这里只读纯函数)
const got = await page.evaluate(async () => {
const m = await import('/src/pages/runs/model.ts')
return {
metric: m.artifactKindLabel('metric_set'), report: m.artifactKindLabel('report_doc'),
card: m.artifactDescription('metric_set', { caliber: '周报口径', version: 'v2' }),
bare: m.artifactDescription('metric_set', null), doc: m.artifactDescription('report_doc', {}),
evidence: m.isEvidence('metric_set') && m.isEvidence('report_doc'),
}
})
check('工件种类:口径卡指标集、报告文档', got.metric === '口径卡指标集' && got.report === '报告文档', JSON.stringify(got))
check('口径卡那件写成「口径卡「x」v2」', got.card === '口径卡「周报口径」v2', got.card)
check('没有 meta 的老数据也有一句说明', got.bare === '口径卡的指标、算式和输入' && got.doc.includes('报告'))
check('两种都算证据(列表里醒目)', got.evidence)
})
await section('long', '长报告:块级 content-visibility、折叠、跳进折叠区自动展开', async () => {
const box = '#evidence-long'
const info = await page.evaluate((sel) => {
const blocks = [...document.querySelectorAll(`${sel} [data-block]`)]
return { n: blocks.length, cv: blocks.map((b) => getComputedStyle(b).contentVisibility),
fold: document.querySelector(sel).innerText.includes('后面还有,这里先折叠了') }
}, box)
check('超过 40 块的报告:每块 content-visibility: auto', info.cv.length > 0 && info.cv.every((v) => v === 'auto'), info.cv.slice(0, 3).join(','))
check('超过 3000 字先折叠,说清「后面还有」', info.fold && info.n < 128, `${info.n} 块`)
const tally = await page.locator(`${box} [data-evidence-tally]`).innerText()
check('没有 stats、没有违规清单时从片段自己数(16 份:数字 4 处 + 非数字引用 1 处)',
tally.includes('112/176 数字有出处 · 无证据 64 · 另有 16 处引用解析不了'), tally)
await page.locator(`${box} [data-seg="s4-0"]`).focus()
await page.keyboard.press('End')
await page.waitForTimeout(250)
const after = await page.evaluate((sel) => ({ n: document.querySelectorAll(`${sel} [data-block]`).length,
active: document.activeElement?.getAttribute('data-seg') }), box)
check('跳到折叠区里的片段:先展开再聚焦', after.n === 128 && after.active === 's49-15', JSON.stringify(after))
const stops = await page.locator(`${box} [data-seg][tabindex="0"]`).count()
check('长报告也只占一个 Tab 位', stops === 1, String(stops))
})
await section('integrity', '证据接口复核出问题:醒目写出来,不说「有出处」就完事', async () => {
const bad = await open('/ui-harness.html?evidence=1')
await bad.page.unroute((u) => new URL(u).pathname.startsWith('/api/'))
await bad.page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => {
const m = new URL(r.request().url()).pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (!m) return r.request().method() === 'GET' ? r.continue() : r.abort()
const body = structuredClone(fx.segments[m[1]])
body.chain[0] = { ...body.chain[0], hash_ok: false, sealed: false }
return r.fulfill({ json: body })
})
await bad.page.locator('#evidence-wide [data-seg="s8"]').click()
await bad.page.waitForSelector('[data-evidence-panel] [data-ev-integrity]', { timeout: 4000 }).catch(() => {})
const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '')
check('工件哈希对不上、不在封存范围内:用失败色逐条写出', said.includes('哈希对不上') && said.includes('不在封存范围内'), said)
await bad.page.keyboard.press('Escape')
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
check('全部复核通过时不多说一句', await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0)
await page.keyboard.press('Escape')
await bad.ctx.close()
})
/**
* 组件预览页,片段接口按 answer(segId, 夹具里那一份的拷贝) 回:返回对象就当 200,返回数字
* 就当那个状态码。证据图按 graph(夹具拷贝) 回
*/
async function probe(answer, { graph = (g) => g } = {}) {
const r = await open('/ui-harness.html?evidence=1')
await r.page.unroute((u) => new URL(u).pathname.startsWith('/api/'))
await r.page.route((u) => new URL(u).pathname.startsWith('/api/'), (route) => {
const req = route.request()
if (req.method() !== 'GET') return route.abort()
const path = new URL(req.url()).pathname
const m = path.match(/\/evidence\/segments\/([^/]+)$/)
if (m) {
const body = answer(m[1], structuredClone(fx.segments[m[1]] ?? {}))
return typeof body === 'number'
? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } })
: route.fulfill({ json: body })
}
if (/\/evidence$/.test(path)) return route.fulfill({ json: graph(structuredClone(fx.graph)) })
return route.continue()
})
return r
}
const sealOf = async (p) => ({
state: await p.locator('[data-evidence-panel] [data-ev-seal-state]').getAttribute('data-ev-seal-state').catch(() => null),
text: await p.locator('[data-evidence-panel]').innerText().catch(() => ''),
})
async function openSeg(p, id = 's8', wait = '[data-ev-seal-state]:not([data-ev-seal-state="idle"])') {
await p.locator(`#evidence-wide [data-seg="${id}"]`).click()
await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {})
await p.waitForTimeout(100)
}
await section('trust', '只信封存链:证据接口答的是另一份报告时,不能画成绿的', async () => {
// 正文是按成果上的 doc_artifact 取的(可改写),接口从封存的事件找文档。两边不是同一份:
// 接口报的文档工件 id 不同、这个位置的字也不同(封存的那份写的是 9.9%)
const other = await probe((id, body) => (id === 's8' ? {
...body, report: { ...body.report, doc_artifact: 'f'.repeat(64) },
segment: { ...body.segment, text: '9.9%' },
chain: [{ ...body.chain[0], value: 9.9, rendered: '9.9%' }, ...body.chain.slice(1)],
} : body))
await openSeg(other.page)
let got = await sealOf(other.page)
const integrity = await other.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').innerText().catch(() => '')
check('封存文档不是正文这份:封存那一行不是绿的「核对一致」,是失败', got.state === 'failed' && !got.text.includes('核对一致'),
`${got.state} ${got.text.replace(/\s+/g, ' ').slice(0, 120)}`)
check('醒目写出「正文不是封存的那一份」,并说封存的那份这里写的是 9.9%', integrity.includes('不是封存范围内的那一份')
&& integrity.includes('9.9%'), integrity.replace(/\s+/g, ' '))
check('另一份报告的算式和值不挂到正文上', await other.page.locator('[data-evidence-panel] [data-ev-substituted]').count() === 0
&& !(await other.page.locator('[data-evidence-panel] [data-ev-metric]').innerText()).includes('9.9%'))
check('标题还是正文上的字', (await other.page.locator('[data-evidence-panel] h3').innerText()) === '8.7%')
await other.ctx.close()
// 只有文档工件 id 不同(字碰巧一样)也不行
const idOnly = await probe((id, body) => ({ ...body, report: { ...body.report, doc_artifact: 'e'.repeat(64) } }))
await openSeg(idOnly.page)
got = await sealOf(idOnly.page)
check('只是文档工件 id 不同:一样判失败', got.state === 'failed'
&& await idOnly.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state)
await idOnly.ctx.close()
// 片段接口取不到,退回证据图:证据图里封存的报告没有正文这份 → 失败;有 → 只说文档封存了
const noSeg = await probe(() => 500, { graph: (g) => ({ ...g, reports: g.reports.map((r) => ({ ...r, doc_artifact: 'd'.repeat(64) })) }) })
await openSeg(noSeg.page)
got = await sealOf(noSeg.page)
check('证据图兜底:封存的报告里没有正文这份,判失败', got.state === 'failed'
&& await noSeg.page.locator('[data-evidence-panel] [data-ev-integrity="doc"]').count() === 1, got.state)
await noSeg.ctx.close()
const viaGraph = await probe(() => 500)
await openSeg(viaGraph.page)
got = await sealOf(viaGraph.page)
check('证据图兜底、正文这份在封存的报告里:只说文档封存了,这个数的链没逐项核对', got.state === 'done'
&& got.text.includes('证据链这次没取到'), `${got.state} ${got.text.replace(/\s+/g, ' ').slice(-80)}`)
await viaGraph.ctx.close()
// 对照:接口答的就是正文这份时照常是绿的(上面那几条不是因为什么都判失败才过的)
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-seal-state="done"]', { timeout: 4000 }).catch(() => {})
got = await sealOf(page)
check('对照:同一份文档照常「已封存 · 核对一致」', got.state === 'done' && got.text.includes('已封存 · 核对一致')
&& await page.locator('[data-evidence-panel] [data-ev-integrity]').count() === 0, got.state)
await page.keyboard.press('Escape')
})
await section('seal', '封存状态:被改过的封存画成失败,没封存不说「事后补进来」', async () => {
// 后端 covered = 封存核对通过 && 每步在台账里:核对失败时 covered 必然 false、每步 sealed 也是 false
const cases = [
['封存被改过(sealed, ok=false)', { sealed: true, ok: false, covered: false }, 'failed', '已封存 · 核对不一致', false],
['还没封存', { sealed: false, ok: null, covered: false }, 'idle', '尚未封存', false],
['封存完好、这件证据不在台账里', { sealed: true, ok: true, covered: false }, 'waiting', '不在封存范围内', true],
]
for (const [name, seal, state, label, late] of cases) {
const r = await probe((id, body) => ({ ...body, seal, chain: body.chain?.map((st) => ('sealed' in st ? { ...st, sealed: false } : st)) }))
await openSeg(r.page, 's8', '[data-ev-substituted]')
const got = await sealOf(r.page)
check(`${name}:封存那一行是 ${state}「${label}」`, got.state === state && got.text.includes(label), `${got.state}`)
check(`${name}:${late ? '单独报「不在封存范围内」' : '不说「事后补进来的」(封存那一行已经说清了)'}`,
got.text.includes('事后补进来') === late, got.text.replace(/\s+/g, ' ').slice(0, 120))
await r.ctx.close()
}
})
await section('fields', '成果字段取文档:别的运行写的、没记正文的都不画', async () => {
const r = await open('/ui-harness.html?evidence=1')
await r.page.route(/\/api\/artifacts\/fx-field-/, (route) => {
const id = new URL(route.request().url()).pathname.split('/').pop()
const doc = id === 'fx-field-other-run' ? { ...fx.doc, run_id: 'run-someone-else' }
: id === 'fx-field-no-markdown' ? (({ markdown: _m, ...d }) => d)(fx.doc) : fx.doc
return route.fulfill({ json: { id, content: doc } })
})
await r.page.reload({ waitUntil: 'networkidle' })
await r.page.waitForSelector('#fx-field-ok [data-evidence-doc]', { timeout: 5000 }).catch(() => {})
check('对照:正文这份照常逐段可点', await r.page.locator('#fx-field-ok [data-evidence-doc] [data-seg]').count() > 0)
const other = await r.page.locator('#fx-field-other-run [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null)
check('文档的 run_id 和成果的运行不同:退回普通文本并说明', other === 'other-run'
&& (await r.page.locator('#fx-field-other-run').innerText()).includes('不是这次运行写的')
&& await r.page.locator('#fx-field-other-run [data-seg]').count() === 0, String(other))
const bare = await r.page.locator('#fx-field-no-markdown [data-evidence-fallback]').getAttribute('data-evidence-fallback').catch(() => null)
check('文档没记正文(缺 markdown):当对不上处理,不画', bare === 'mismatch'
&& await r.page.locator('#fx-field-no-markdown [data-seg]').count() === 0, String(bare))
await r.ctx.close()
})
await section('pinned', '没有 _evidence 的旧回答:和 Markdown.tsx 改动前逐字一样', async () => {
// markdown-pinned.json 里的 html 是改动前(HEAD b418c0d)的 Markdown.tsx 渲染的。重新生成:把
// `git show <旧提交>:frontend/src/run/Markdown.tsx` 临时放进 src/run/,另起一个页面逐个渲染
// samples、取 outerHTML 写回 html 字段,然后删掉临时文件。唯一有意的改动列在 intended 里
const pinned = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/markdown-pinned.json`, 'utf8'))
const now = await page.evaluate(() => Object.fromEntries([...document.querySelectorAll('#md-pinned [data-pin]')]
.map((el) => [el.dataset.pin, el.firstElementChild?.outerHTML ?? ''])))
for (const sample of pinned.samples) {
let before = sample.html
for (const d of pinned.intended) before = before.split(d.from).join(d.to)
const cur = now[sample.id] ?? ''
let at = 0
while (at < before.length && before[at] === cur[at]) at++
check(`「${sample.id}」和改动前逐字一样(只差列表缩进 ml-4 → pl-4)`, !!cur && cur === before,
cur === before ? '' : `第 ${at} 个字起不同:改动前「${before.slice(at, at + 60)}」现在「${cur.slice(at, at + 60)}」`)
}
// ml-4 → pl-4:量一下列表项的位置和宽度,把类名换回 ml-4 再量一遍,必须一样
const moved = await page.evaluate(() => {
const out = []
for (const list of document.querySelectorAll('#md-pinned ul, #md-pinned ol')) {
const rects = () => [...list.children].map((li) => {
const r = li.getBoundingClientRect()
return [r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10).join(',')
}).join('|')
// 类名得真是 pl-4 才有得换:换不了就算没量(别的缩进一律不认)
if (!list.classList.contains('pl-4') || list.classList.contains('ml-4')) { out.push(`类名是「${list.className}」`); continue }
const a = rects()
list.classList.replace('pl-4', 'ml-4')
const b = rects()
list.classList.replace('ml-4', 'pl-4')
if (a !== b) out.push(`${a} ≠ ${b}`)
}
return { lists: document.querySelectorAll('#md-pinned ul, #md-pinned ol').length, moved: out }
})
check('列表缩进 pl-4 和以前的 ml-4 看起来一样:每个列表项的位置、宽度都没变', moved.lists >= 6 && !moved.moved.length,
`${moved.lists} 个列表 ${moved.moved.join(' ; ')}`)
})
await section('studio', '报告撰写节点:画布认得、检查器能配(REQ-A-3)', async () => {
// 假的工作流,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库
const at = (x, y) => ({ x, y })
const node = (id, type, x, label, config = {}) => ({ id, type, position: at(x, 80), data: { label, config } })
const graph = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('caliber', 'metrics', 260, '周报口径', { caliber: '周报口径', caliber_version: 'v2',
metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'input.week' }] }),
node('write', 'report', 520, '写周报', { instructions: '为 {{ input.week }} 写周报', numbers: 'strict', max_repairs: 1 }),
node('late', 'metrics', 780, '事后口径', { caliber: '事后', metrics: [{ id: 'x', expression: '1' }] }),
node('out', 'output', 1040, '成果', { fields: [{ name: '周报', value: '{{ nodes.write.text }}' }] }),
],
edges: [['in', 'caliber'], ['caliber', 'write'], ['write', 'late'], ['late', 'out']]
.map(([source, target]) => ({ id: `${source}-${target}`, source, target })),
}
const wf = { id: 'fx-report', name: '报告节点检查', description: '', graph, tags: [], version: 1, is_template: false,
status: 'draft', published_version: null, run_count: 0, created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z' }
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const p = await ctx.newPage()
const errors = []
p.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf]) : json(r, { detail: '检查脚本不写库' }, 409)))
await p.route(/\/api\/workflows\/fx-report(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, [])))
await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] })))
await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409)))
await p.goto(`${WEB}/studio/fx-report`, { waitUntil: 'networkidle' })
await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-report', null, { timeout: 15000 })
await p.waitForTimeout(600)
const card = await p.locator('.react-flow__node[data-id="write"]').innerText().catch(() => '')
check('画布上的报告节点有摘要(写作要求、指标从哪来),不是未知类型', card.includes('为 {{ input.week }} 写周报')
&& card.includes('上游全部口径卡'), card.replace(/\n/g, ' ').slice(0, 120))
const tint = await p.evaluate(() => {
const el = document.querySelector('.react-flow__node[data-id="write"] .nt-report')
return el ? getComputedStyle(el).getPropertyValue('--nt').trim() : ''
})
check('报告节点有自己的类型色(--nt-report)', !!tint, tint)
await p.evaluate(() => window.__studio.getState().select('write'))
await p.waitForSelector('[data-field="metrics_from"]', { timeout: 5000 })
check('检查器标题写「报告撰写」', (await p.locator('body').innerText()).includes('报告撰写'))
const fields = await p.evaluate(() => [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field')))
check('检查器的字段:写作要求、指标来自、裸数字、违规时、重写次数、模型',
['instructions', 'metrics_from', 'numbers', 'on_violation', 'max_repairs', 'model'].every((f) => fields.includes(f)), fields.join(','))
await p.locator('[data-field="metrics_from"] button', { hasText: '选择口径卡' }).click()
const opts = await p.locator('[data-field="metrics_from"] .max-h-52 button').allInnerTexts()
check('「指标来自」只列上游的口径卡(下游那张不列)', opts.length === 1 && opts[0].includes('周报口径'), opts.join(' | '))
const onViolation = await p.locator('[data-field="on_violation"] option').allInnerTexts()
check('违规时的默认写明按运行类别', onViolation[0]?.includes('探索运行照常产出'), onViolation.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(250)
await p.screenshot({ path: `${SHOTS}/evidence-studio-report-${theme}.png` })
}
}
check('没有运行时报错(画布)', errors.length === 0, errors.join(' | '))
await ctx.close()
})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.locator(`${wide} [data-seg="s8"]`).click()
await page.waitForTimeout(350)
await page.screenshot({ path: `${SHOTS}/evidence-harness-${theme}.png`, fullPage: true })
await page.locator('#evidence-narrow [data-seg="s26"]').click()
await page.waitForTimeout(350)
await page.locator('#evidence-narrow').screenshot({ path: `${SHOTS}/evidence-360-inline-${theme}.png` })
await page.keyboard.press('Escape')
}
}
check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | '))
await harness.ctx.close()
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 可点击证据全部通过')
process.exit(failed ? 1 : 0)
+47
View File
@@ -1648,6 +1648,53 @@ await section('画布右栏:去审批、放弃、出具横幅、回执(REQ-1
await ctx.close()
})
await section('逐段证据:带 _evidence 的成果(可点击证据第一期)', async () => {
// 报告文档和片段接口用夹具伪造(后端 compose_doc 真跑出来的,只用通用名),非 GET 一律拦掉
const { readFileSync } = await import('node:fs')
const fx = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
for (const [w, dense] of [[1100, false], [380, true]]) {
const page = await browser.newPage({ viewport: { width: w, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route((u) => new URL(u).pathname.startsWith('/api/'), (r) => {
const url = new URL(r.request().url())
if (r.request().method() !== 'GET') return r.abort()
const seg = url.pathname.match(/\/evidence\/segments\/([^/]+)$/)
if (seg) return r.fulfill({ json: fx.segments[seg[1]] ?? {} })
if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } })
return r.continue()
})
const tag = dense ? '窄栏' : '宽栏'
await page.goto(`${WEB}/preview.html?syn=evidence${dense ? '&dense=1' : ''}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-turn="evidence"] [data-evidence-doc]', { timeout: 6000 })
const turn = page.locator('[data-turn="evidence"]')
check(`${tag}:带证据的字段换成逐段文档,数字是可点的片段`, await turn.locator('[data-seg]').count() === 13)
check(`${tag}:出具横幅多一行计数`, (await turn.locator('[data-evidence-line]').innerText())
.includes('7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了'))
check(`${tag}:没有 _evidence 的那一轮不变`, await page.locator('[data-turn="evidence-legacy"] [data-seg]').count() === 0)
await turn.locator('[data-seg="s6"]').click()
await page.waitForSelector('[data-evidence-panel] [data-ev-substituted]')
const mode = await page.locator('[data-evidence-panel]').getAttribute('data-evidence-panel')
check(`${tag}:面板${dense ? '在栏内展开' : '从侧边弹出'}`, mode === (dense ? 'inline' : 'side'), mode)
const box = await page.evaluate(() => {
const el = document.querySelector('[data-stream-scroll]')
return { sw: el.scrollWidth, cw: el.clientWidth }
})
check(`${tag}:打开面板后流里没有横向滚动`, box.sw <= box.cw, JSON.stringify(box))
const copy = await turn.locator('button[aria-label="复制"]').count()
check(`${tag}:答案的复制 / 导出照常在`, copy > 0)
check(`${tag}:没有运行时报错`, errors.length === 0, errors.join(' | '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/stream-evidence-${dense ? 'dense' : 'wide'}-${theme}.png` })
}
}
await page.close()
}
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 助手流渲染全部通过')
process.exit(failed ? 1 : 0)
+108
View File
@@ -282,6 +282,114 @@ await section('lib:格式、状态、快捷键、术语、报错', async () =>
})) check(name, ok, detail)
})
await section('lib/evidence:证据状态的四通道元数据、报告节点的叫法、复核守卫', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const { evidence: ev, terms: t } = window.__ui.lib
const out = []
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
const codes = ev.EVIDENCE_STATES
// 方案 6.1 的八种:确定性、概率性四种、无证据、连接性、旧运行候选
ok('八种状态一种不少', ['deterministic', 'supported', 'partial', 'unsupported', 'unjudged', 'none', 'connective', 'candidate']
.every((c) => codes.includes(c)) && codes.length === 8, codes.join(','))
const lines = new Set(['solid', 'dotted', 'badge', 'none'])
const bad = codes.filter((c) => {
const m = ev.EVIDENCE_STATE[c]
return !m || m.code !== c || !m.label || !lines.has(m.line) || typeof m.glyph !== 'string'
|| !/^var\(--st-[\w-]+\)$/.test(m.color) || !m.decoration || !/^var\(--st-[\w-]+\)$/.test(m.soft) || !m.hint
})
ok('每种都有线型、字形、颜色、文字(颜色只用 --st-*)', !bad.length, bad.join(','))
ok('下划线颜色也只取 --st-*(或透明)', codes.every((c) => {
const d = ev.EVIDENCE_STATE[c].decoration
return d === 'transparent' || /var\(--st-[\w-]+\)/.test(d)
}), codes.map((c) => ev.EVIDENCE_STATE[c].decoration).join(' | '))
const pairs = codes.map((c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`)
ok('去掉颜色也分得开:线型 + 字形两两不同', new Set(pairs).size === codes.length, pairs.join(' '))
const labels = codes.map((c) => ev.EVIDENCE_STATE[c].label)
ok('文字两两不同', new Set(labels).size === codes.length, labels.join(' '))
eq('确定性:细实线、有出处', `${ev.EVIDENCE_STATE.deterministic.line}/${ev.EVIDENCE_STATE.deterministic.label}`, 'solid/有出处')
eq('无证据:点状线、?、无证据', `${ev.EVIDENCE_STATE.none.line}/${ev.EVIDENCE_STATE.none.glyph}/${ev.EVIDENCE_STATE.none.label}`, 'dotted/?/无证据')
ok('概率性的「有依据」不用确定性的绿', ev.EVIDENCE_STATE.supported.color !== ev.EVIDENCE_STATE.deterministic.color)
eq('本期只会出现确定性和无证据', codes.filter((c) => ev.EVIDENCE_STATE[c].phase === 1).join(','), 'deterministic,none')
ok('只有异常态醒目(进 n / N 的跳转)', !ev.EVIDENCE_STATE.deterministic.alert && ev.EVIDENCE_STATE.none.alert)
eq('片段状态:deterministic', ev.segmentState({ kind: 'number', state: 'deterministic' }), 'deterministic')
eq('片段状态:结构片段不画', ev.segmentState({ kind: 'structural', state: 'none' }), null)
eq('片段状态:文字不画', ev.segmentState({ kind: 'text', state: 'neutral' }), null)
eq('片段状态:probabilistic 裁判前按未裁判画', ev.segmentState({ kind: 'text', state: 'probabilistic' }), 'unjudged')
eq('报告节点叫「报告撰写」', t.nodeTypeLabel('report'), '报告撰写')
eq('计数的说法:无证据 = 总数 − 有出处,算得平', t.evidenceTally(7, 12), '7/12 数字有出处 · 无证据 5')
eq('不是数字的解析不了的引用另起一句', t.evidenceTally(7, 12, 1), '7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了')
eq('数字全有出处', t.evidenceTally(1234, 1234, 0), '1,234 个数字都有出处')
eq('数字全有出处、另有引用解析不了', t.evidenceTally(3, 3, 2), '3 个数字都有出处 · 另有 2 处引用解析不了')
eq('一个数字都没有、只有解析不了的引用', t.evidenceTally(0, 0, 1), '1 处引用解析不了')
eq('什么都没有时给空串', t.evidenceTally(0, 0, 0), '')
const out1 = { 周报: 'x', _evidence: { report_node: 'write', doc_artifact: 'abc', fields: ['周报'],
others: [{ report_node: 'w2', doc_artifact: 'def', fields: ['附录'] }] } }
const f = ev.evidenceFields(out1)
ok('_evidence 的字段和 others 都认', f.get('周报')?.artifact === 'abc' && f.get('附录')?.report === 'w2', JSON.stringify([...f]))
eq('缺 doc_artifact 的标注当没有', ev.evidenceFields({ _evidence: { fields: ['a'] } }).size, 0)
const review = { verdict: 'rewritten', note: '有缺口', answer: '改写后的', original: '原文', retry: false, severity: 'degraded', signals: [] }
const guarded = ev.guardReview(review, out1)
ok('成果带证据:复核只加说明,不改写', guarded.answer === null && guarded.original === null && guarded.verdict === 'annotated'
&& guarded.note === '有缺口', JSON.stringify(guarded))
ok('成果不带证据:复核照旧可以改写', ev.guardReview(review, { answer: 'x' }).answer === '改写后的')
eq('出处不唯一的说法', ev.matchedSource({ token: '0', metric: null, candidates: ['a', 'b'], ambiguous: true }).text, '出处不唯一:候选 a、b')
eq('出处唯一的照写指标', ev.matchedSource({ token: '12', metric: 'gmv' }).text, 'gmv')
const cp = ev.codePointIndex('📦 15 单')
eq('码点偏移换成 UTF-16:emoji 后面差一位', cp(2), 3)
eq('没有 emoji 时原样', ev.codePointIndex('abc')(2), 2)
const doc = window.__ui.evidenceFixture.doc
const tally = ev.docTally(doc)
const k = (x) => `${x.cited}/${x.total}/${x.none}/${x.other}/${x.hidden}/${x.structural}/${x.noSegment}`
eq('文档计数(有出处/总数/无证据/非数字引用/画不了线/其中结构/其中没对应字)', k(tally), '7/12/5/1/1/1/0')
const { stats: _s, ...bare } = doc
eq('没有 stats 时从片段和违规清单数,口径一样', k(ev.docTally(bare)), '7/12/5/1/1/1/0')
const { violations: _v, ...noList } = doc
eq('有 stats、没有违规清单:非数字引用由 stats 反推', `${ev.docTally(noList).none}/${ev.docTally(noList).other}`, '5/1')
eq('只有 stats(report.checked 的载荷):同一种算法', JSON.stringify(ev.statsTally(doc.stats)),
JSON.stringify({ total: 12, cited: 7, none: 5, other: 1 }))
eq('载荷缺字段:不给计数', ev.statsTally({ uncited_numbers: 1 }), null)
// 句末依据里写错的引用:违规不指任何片段,和列表序号里的数字分开数、分开说
const withSee = { ...doc, stats: { ...doc.stats, unresolved: 4, violations: 7 },
violations: [...doc.violations, { code: 'unresolved_ref', message: '依据 [[see:m:nope]] 解析不了', ref: 'm:nope', unit: 'u1' }] }
const t2 = ev.docTally(withSee)
eq('句末依据的引用:算进非数字引用、算进「没对应字」', k(t2), '7/12/5/2/2/1/1')
const sum = ev.tallySummary(t2, true)
ok('读屏摘要:两种画不了线的分开说在哪', sum.includes('其中 1 个数字在列表序号') && sum.includes('1 处在正文里没有对应的字')
&& sum.includes('句末依据'), sum)
ok('读屏摘要:只有结构片段时不提「句末依据」', !ev.tallySummary(tally, true).includes('句末依据'), ev.tallySummary(tally, true))
// 封存那一行:顺序是 正文不是封存那份 → 没拿到 → 没封存 → 核对失败 → 不在范围 → 通过
const sv = (seal, opts) => { const v = ev.sealVerdict(seal, opts); return `${v.status}:${v.label}` }
eq('封存被改过(covered 必然也是 false):失败,不是「不在封存范围内」', sv({ sealed: true, ok: false, covered: false }),
'failed:已封存 · 核对不一致')
eq('没封存:灰「尚未封存」', sv({ sealed: false, ok: null, covered: false }), 'idle:尚未封存')
eq('封存完好、这件不在台账里:琥珀', sv({ sealed: true, ok: true, covered: false }), 'waiting:不在封存范围内')
eq('都过了:绿', sv({ sealed: true, ok: true, covered: true }), 'done:已封存 · 核对一致')
ok('正文不是封存的那份:再完好的封存也判失败', sv({ sealed: true, ok: true, covered: true }, { foreign: true }).startsWith('failed:'))
ok('没有证据的片段:落到报告文档上', sv({ sealed: true, ok: true, covered: true }, { docOnly: true }).includes('报告文档已封存'))
ok('链没取到、封存状态从证据图查的:只说文档封存了', sv({ sealed: true, ok: true }, { viaGraph: true }).includes('证据链这次没取到'))
eq('逐项复核:没封存时不报「不在封存范围内」', ev.integrityFailures({ sealed: false, hash_ok: true }, { sealed: false }).join(','), '')
eq('逐项复核:封存被改过时也不报', ev.integrityFailures({ sealed: false }, { sealed: true, ok: false }).join(','), '')
eq('逐项复核:封存完好时照报', ev.integrityFailures({ sealed: false, hash_ok: false }, { sealed: true, ok: true }).join(','), 'hash,sealed')
// 证据接口答的是不是正文这份
const seg = { id: 's8', text: '8.7%' }
const mine = { artifact: 'aaa', node: 'write', seg }
eq('同一份:不算外来', ev.docForeign({ report: { doc_artifact: 'aaa', node_id: 'write' }, segment: { id: 's8', text: '8.7%' } }, mine), null)
ok('文档工件 id 不同:外来', !!ev.docForeign({ report: { doc_artifact: 'bbb' }, segment: { id: 's8', text: '8.7%' } }, mine))
eq('这个位置的字不同:外来,并记下封存那份写的是什么',
ev.docForeign({ report: { doc_artifact: 'aaa' }, segment: { id: 's8', text: '9.9%' } }, mine)?.sealedText, '9.9%')
eq('接口没报文档 id(老后端):只比字', ev.docForeign({ segment: { text: '8.7%' } }, mine), null)
eq('证据图兜底:封存的报告里有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'aaa' }] }, 'aaa'), 'ok')
eq('证据图兜底:没有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'bbb' }] }, 'aaa'), 'foreign')
eq('证据图兜底:有,但哈希对不上', ev.graphDoc({ reports: [{ doc_artifact: 'aaa', hash_ok: false }] }, 'aaa'), 'tampered')
return out
})) check(name, ok, detail)
})
await section('lib/actor:本机署名', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const a = window.__ui.lib.actor