Files
AgentLab/scripts/check-all.mjs
T
ThearchyHelios e4f2693918 可点击证据第一期:报告里的每个数字都点得开出处
「引用即渲染」:写报告的模型只写引用标记([[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)。
2026-09-28 11:47:23 +08:00

204 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 按顺序跑全部检查,汇总成一张表,给一个总的退出码。
//
// node scripts/check-all.mjs 全部跑一遍
// node scripts/check-all.mjs ui runs 只跑 check-ui、check-runs(名字去掉 check- 前缀)
// node scripts/check-all.mjs --repeat 2 整套连跑两遍,列出只在某一遍失败的项(偶发)
//
// 默认连 5273 / 8000。对别的实例(比如一份沙箱拷贝)跑时带上地址,会原样传给每个检查:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-all.mjs
// 每个检查的完整输出写进 CHECK_LOGS(默认 /tmp/agentlab-checks/<时间>/),终端只给汇总。
//
// 一个接一个地跑,不并行:它们共用同一个 vite 开发服务器,有几项还量时间(审批卡多少毫秒
// 出现、计时器走没走),挤在一起跑只会多出偶发失败。
//
// 各检查自己的段过滤(CHECK_ONLY、ONLY、THEMES……)不往下传:整套检查就是整套,只跑某几段
// 请直接跑那个脚本。
//
// 退出码:0 全部通过;1 有检查没通过、中途崩了、超时或一项都没跑;2 前后端连不上,一项都没跑。
import { spawn } from 'node:child_process'
import { createWriteStream, mkdirSync, readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const HERE = dirname(fileURLToPath(import.meta.url))
// 先跑快的、不开浏览器的,再按页面从外壳往里跑;最后是连真数据的两项
const ORDER = [
'tokens', // 令牌、别名、原生对话框:纯静态扫源码
'trace', // 运行态内核的纯函数
'decode', // 事件翻译层
'canvas-layout', // 走线
'ui-kit', // 公共组件和 lib
'guards', // 输入法、未知节点、错误边界
'shell', // 外壳:导航、徽标、命令面板、离开前确认、启动页
'stream', // 回答流的渲染
'evidence', // 可点击证据:报告逐段渲染、证据面板、旧契约按位置标记
'run-states', // 节点卡的运行态
'canvas-fx', // 画布表层的运行态
'studio', // 编排页的编辑
'chat', // 问数据
'runs', // 记录
'manage', // 数据、工具、知识、设置
'ui', // 各页面端到端,连真数据
]
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',
'EVIDENCE_ONLY']
// 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒
const unlisted = readdirSync(HERE)
.map((f) => f.match(/^check-(.+)\.mjs$/)?.[1])
.filter((n) => n && n !== 'all' && !ORDER.includes(n))
const args = process.argv.slice(2)
let repeat = 1
const picked = []
for (let i = 0; i < args.length; i++) {
if (args[i] === '--repeat') repeat = Math.max(1, Number(args[++i]) || 1)
else if (args[i].startsWith('--repeat=')) repeat = Math.max(1, Number(args[i].slice(9)) || 1)
else picked.push(args[i].replace(/^check-/, '').replace(/\.mjs$/, ''))
}
const unknown = picked.filter((n) => !ORDER.includes(n))
if (unknown.length) {
console.error(`认不出的检查:${unknown.join('、')}。可选的有:${ORDER.join(' ')}`)
process.exit(2)
}
const names = picked.length ? ORDER.filter((n) => picked.includes(n)) : ORDER
// 连不上就别跑:十几个检查挨个报「打不开页面」只会淹没真正的原因
const reach = async (url) => {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(5000) })
return r.ok ? '' : `HTTP ${r.status}`
} catch (e) {
return e.cause?.code ?? e.name ?? String(e)
}
}
const needsServers = names.some((n) => n !== 'tokens')
if (needsServers) {
const [web, api] = await Promise.all([reach(WEB), reach(`${API}/health`)])
if (web || api) {
if (web) console.error(`✗ 前端连不上:${WEB}(${web})`)
if (api) console.error(`✗ 后端连不上:${API}/health(${api})`)
console.error(' 先把前后端起来(./scripts/dev.sh),或者用 AGENTLAB_WEB / AGENTLAB_API 指到要检查的那一份')
process.exit(2)
}
}
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const LOGS = process.env.CHECK_LOGS ?? join('/tmp/agentlab-checks', stamp)
mkdirSync(LOGS, { recursive: true })
console.log(`前端 ${WEB} · 后端 ${API}`)
const dropped = FILTERS.filter((k) => process.env[k])
if (dropped.length) console.log(`没传给各检查的段过滤:${dropped.map((k) => `${k}=${process.env[k]}`).join(' ')}(只跑某几段请直接跑那个脚本)`)
console.log(`${names.length} 项检查${repeat > 1 ? ` × ${repeat} 遍` : ''},完整输出在 ${LOGS}/\n`)
const childEnv = { ...process.env, AGENTLAB_WEB: WEB, AGENTLAB_API: API }
for (const k of FILTERS) delete childEnv[k]
/**
* 跑一个检查,输出落盘,数 ✓ / ✗。退出码非 0 却一个 ✗ 都没有的,是中途崩了;
* 退出码 0 却一个 ✓ 都没有的,是什么都没查——都不算通过
*/
function runOne(name, pass) {
const file = join(LOGS, repeat > 1 ? `${name}.${pass}.log` : `${name}.log`)
const out = createWriteStream(file)
const started = Date.now()
return new Promise((resolve) => {
const child = spawn(process.execPath, [join(HERE, `check-${name}.mjs`)], {
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.pipe(out, { end: false })
child.stderr.pipe(out, { end: false })
let timedOut = false
// 先 SIGTERM:Playwright 收到它会把自己拉起来的 Chrome 一起关掉;直接 SIGKILL 会留下孤儿浏览器
let hard
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
hard = setTimeout(() => child.kill('SIGKILL'), 10_000)
}, TIMEOUT_MS)
child.on('close', (code) => {
clearTimeout(timer)
clearTimeout(hard)
out.end(() => {
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
// 检查项是缩进的一行;顶格的「✓ 全部通过」「✗ N 项未通过」是脚本自己的总结
const ok = lines.filter((l) => /^\s+✓ /.test(l)).length
const bad = lines.filter((l) => /^\s+✗ /.test(l)).map((l) => l.trim())
const secs = Math.round((Date.now() - started) / 1000)
const crashed = !timedOut && code !== 0 && !bad.length
const empty = !timedOut && code === 0 && ok === 0
const passed = code === 0 && ok > 0
const tail = crashed || empty ? lines.filter(Boolean).slice(-4).join(' ⏎ ').slice(0, 300) : ''
resolve({ name, pass, code, ok, bad, secs, timedOut, crashed, empty, passed, tail, file })
})
})
})
}
const limit = TIMEOUT_MS >= 60_000 ? `${Math.round(TIMEOUT_MS / 60_000)} 分钟` : `${Math.round(TIMEOUT_MS / 1000)} 秒`
const verdict = (r) => r.timedOut ? `超时(${limit})`
: r.crashed ? '中途崩了'
: r.empty ? '一项都没跑'
: r.passed ? '通过'
: `${r.bad.length} 项没过`
const results = []
for (let pass = 1; pass <= repeat; pass++) {
if (repeat > 1) console.log(`—— 第 ${pass} 遍 ——`)
for (const name of names) {
const r = await runOne(name, pass)
results.push(r)
const mark = r.passed ? '✓' : '✗'
console.log(`${mark} ${`check-${name}`.padEnd(20)} ${verdict(r).padEnd(10)} ${String(r.ok).padStart(4)} 项通过 · ${r.secs}s`)
for (const l of r.bad.slice(0, 5)) console.log(` ${l}`)
if (r.bad.length > 5) console.log(` ……另有 ${r.bad.length - 5} 项,见 ${r.file}`)
if (r.tail) console.log(` ${r.tail}`)
}
if (repeat > 1) console.log('')
}
// 连跑几遍时:同一项只在部分遍数里失败,就是偶发——比稳定失败更该查。按项名认,不带
// 「 — 」后面的读数:每遍都没过、只是毫秒数或像素差不一样的,是稳定失败,不是偶发
if (repeat > 1) {
const seen = new Map() // `${name}\t${项名}` → [{ pass, line }]
for (const r of results) {
const lines = r.crashed || r.timedOut || r.empty ? [verdict(r)] : r.bad
for (const line of lines) {
const k = `${r.name}\t${line.replace(/ — .*$/, '')}`
seen.set(k, [...(seen.get(k) ?? []), { pass: r.pass, line }])
}
}
const flaky = [...seen].filter(([, hits]) => new Set(hits.map((h) => h.pass)).size < repeat)
if (flaky.length) {
console.log('偶发(只在部分遍数里失败):')
for (const [k, hits] of flaky) {
const name = k.split('\t')[0]
for (const h of hits) console.log(` check-${name} 第 ${h.pass} 遍:${h.line}`)
}
console.log('')
}
}
if (unlisted.length && !picked.length) {
console.log(`没跑:${unlisted.map((n) => `check-${n}`).join('、')} 不在 check-all 的清单里(要算进来就加进 ORDER)\n`)
}
const failedRuns = results.filter((r) => !r.passed)
const total = results.reduce((n, r) => n + r.ok, 0)
const secs = results.reduce((n, r) => n + r.secs, 0)
const clock = `${Math.floor(secs / 60)} 分 ${String(secs % 60).padStart(2, '0')} 秒`
if (failedRuns.length) {
console.log(`✗ ${failedRuns.length}/${results.length} 次检查没通过(${[...new Set(failedRuns.map((r) => `check-${r.name}`))].join('、')})· 共 ${clock}`)
} else {
console.log(`✓ 全部通过:${results.length} 次检查、${total} 项 · 共 ${clock}`)
}
process.exit(failedRuns.length ? 1 : 0)