跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次

「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
This commit is contained in:
ThearchyHelios
2026-09-20 11:18:53 +08:00
parent 6a46e4b55c
commit cc608f56ec
21 changed files with 1258 additions and 110 deletions
+36
View File
@@ -155,6 +155,42 @@ console.log('\n=== 没查库的那一轮 ===')
await page.close()
}
console.log('\n=== 复核说明 ===')
{
// 「跑完了」和「答得对」是两回事。复核说明必须排在成果**上方**——排在下面
// 等于让人读完整个结论、信了,才知道它是在什么条件下得出的
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`${WEB}/preview.html?review=1`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
const body = await page.locator('body').innerText()
check('说破了取数没跑通', body.includes('取数没跑通'))
check('说明排在结论前面',
body.indexOf('取数没跑通') < body.indexOf('大约 5 个'),
`说明@${body.indexOf('取数没跑通')} 结论@${body.indexOf('大约 5 个')}`)
check('答案本身照常渲染', body.includes('大约 5 个'))
check('异常清单默认收起', !body.includes('no such table'))
check('数得出有几处异常', body.includes('检测到 2 处异常'))
// broken 用报警色,degraded 用普通边框。混成一个样子,用户就学会了一概忽略
const colors = await page.evaluate(() => {
const err = getComputedStyle(document.documentElement).getPropertyValue('--err').trim()
const hit = [...document.querySelectorAll('div[style*="border-color"]')]
.map((el) => getComputedStyle(el).borderTopColor)
return { err, hit }
})
check('两档复核长得不一样', new Set(colors.hit).size > 1, colors.hit.join(' / '))
// 改写是有损的,原件得留着能对照
check('改写前的原文可以展开', body.includes('看改写前的原文'))
check('原文默认不占地方', !body.includes('平台管理员可以修改他人权限。\n'))
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await page.close()
}
console.log('\n=== 空态 ===')
{
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })