口径账本的界面检查:check-ledger 换成真检查,check-evidence、check-chat 各加一段

都用 page.route 假接口加夹具(scripts/ledger-fixtures.mjs:GET /api/runs/{id}/ledger 的形状照服务端 run_view,挂在可点击
证据的夹具 evidence-doc.json 上,只用通用名),写请求在浏览器层拦下、核对请求体,认不出的写请求一律记账、结束时必须为空。

- check-ledger(骨架里的占位换掉):
  · 比对还没出结果:「口径」一步写「正在比对口径」、按退避再取(间隔越来越长),等满了照实说、给「再取一次」、不再取,
    点了重新取、比对过了就画出来,之后不再取(store/ledger 在开发构建里挂 window.__ledger,把等待起点挪到两分钟前);
  · 三种情况:来自口径(口径、级别、怎么认的、参数、事后补比的说明)、相关口径(差在哪 + 看差多少)、这次的推理;
  · 对账:参数要核对时表单直接打开、照入口字段画 → 发起带核对过的参数(空着的不送)→ 409 带回的选项画「选择要对照的
    指标」、一组没选不发起 → 选好再发起带上对照 → 结果卡片先写进行中、再取一次后画出按你的问法 / 按口径 / 差值 / 相对差,
    点差值打开出处链;
  · 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写、各写怎么认出来的;聚类只按那一轮固化;「不是口径」不写理由
    不能确认、写了才记下并拿掉这一项;固化走 from-run 的 freeze(带同组的别的运行和来源轮次)、打开画布里的草稿;
  · 使用者:不能出具的照服务端的原因写「请搭建者出具」、不摆按钮,对账照样能用,出具过的正式运行不给链接、写由搭建者查看;
  · 设置里口径账本一组:显示生效的值,不合规的值写范围、不让存,保存条写改了哪几项,整组写 ledger。
- check-evidence「口径账本」一节:来自口径的数字挂「受管 / 已发布」小标记(紧跟数字、悬停写口径和认法、读屏念在数字上),
  相关口径、这次的推理、没比对的不挂;面板「口径」一步在出处链最上面,四种标签各写各的,不是数的片段不画;同一次运行只取
  一次账本。别的节的夹具一律答不比对,不落到真服务端。
- check-chat「口径账本」一节:交付了的轮次底部先写「正在比对口径」,按退避再取到比对出结果后写摘要、比完不再取;按口径正式
  出具、对账按钮照服务端的动作摆,出具的请求只说按哪份口径,出具过的正式运行挂在轮次下面(档位、打开运行);对账请求带
  哪条查询、哪份口径、核对过的参数。
This commit is contained in:
ThearchyHelios
2026-10-16 20:34:21 +08:00
parent b24aabefcf
commit 9ad6c035d0
5 changed files with 704 additions and 6 deletions
+3
View File
@@ -178,3 +178,6 @@ export function useRunLedger(runId: string | null | undefined, enabled = true):
}, [on, runId, acquire, release])
return useLedger((s) => (on && runId ? s.slots[runId] : undefined))
}
// 检查脚本(scripts/check-ledger.mjs)读写它:把一轮等待的起点挪到很早以前,不用真等两分钟就能看到「还没有结果」
if (import.meta.env.DEV) (window as any).__ledger = useLedger
+111 -1
View File
@@ -30,12 +30,16 @@
// 「正在理解需求」;输入框提示回答助手的问题;刷新后照样标「助手反问」,不当成没查库的回答;
// 15. 回答上的「做成应用」带着这次运行进蓝图(建运行起点的蓝图),不再提取工作流、跳画布的发布弹窗;
// 16. 服务端这一轮已经自查修正过(收到 check 的结论)还没过的:不再整轮重新生成一遍,直接说没过、给补救。
// 17. 口径账本(next-round 02):交付了的轮次底部写「正在比对口径」,按退避再取到比对出结果、比完不再取;一行摘要
// (几个数来自哪份口径)、按口径正式出具和对账按钮照服务端给的动作摆;出具、对账的写请求在浏览器层拦下并核对请求体,
// 出具过的正式运行挂在轮次下面(档位、打开运行)。
//
// 所有写请求都被拦在浏览器里,不碰任何库。
// 跑之前前端得起着(./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, readFileSync } from 'node:fs'
import { ISSUE, RECONCILE, WHOLE, WHOLE_LABELS, ledgerOf } from './ledger-fixtures.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH
@@ -211,12 +215,20 @@ const REJECT_CONVS = {
const ASKBACK_CONVS = {
askBack: conv('c0askbk', '助手反问过的会话', 1, 60 * 24 * 34),
}
/** 只在「口径账本」那段出现:库里存着一轮带证据的答案(evidence-doc 的周报),交付了,等着比对口径 */
const LEDGER_CONVS = {
led: conv('c0ledgr', '问数据:上周的周报', 1, 60 * 24 * 32),
}
/** 只在「问数据的证据」那段出现:库里存着一轮带证据的答案,刷新后回运行取 _evidence */
const ASK_CONVS = {
askZ: conv('c0askz', '问数据:各区域销售额和最大的一单', 1, 60 * 24 * 33),
}
const DETAIL = {
c0askbk: [turn('tab1', '上周销售额是多少', { answer: '销售额按下单日还是支付日算?', meta: { v: 1, asked: true } })],
c0ledgr: [turn('tl1', '上周的周报', {
answer: '(周报原文)', graph: GRAPH, run_id: 'run-led',
meta: { v: 1, runId: 'run-led', runClass: 'exploratory', runStatus: 'succeeded', evidence: true },
})],
c0askz: [turn('tz9', '上周各区域的销售额,最大的一单是多少', {
answer: EVQ.output.answer, graph: GRAPH, run_id: 'run-evq',
meta: { v: 1, runId: 'run-evq', runClass: 'exploratory', runStatus: 'succeeded', evidence: true },
@@ -361,6 +373,7 @@ const EVIDENCE = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/
const RUNS = {
'run-evid': { status: 'succeeded', output: EVIDENCE.output, error: null },
'run-evq': { status: 'succeeded', output: EVQ.output, error: null },
'run-led': { 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: '人工驳回:数字对不上' },
@@ -419,6 +432,8 @@ const ctl = {
extraSource: false,
/** 列表里多摆「被驳回」那段的两个会话(REJECT_CONVS) */
rejectConvs: false,
/** 列表里多摆「口径账本」那段的会话(LEDGER_CONVS) */
ledgerConvs: false,
}
/** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */
const db = { archived: new Set(), purged: new Set() }
@@ -430,7 +445,8 @@ resetDb()
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []),
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : []),
...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []),
...(ctl.fixConvs ? Object.values(FIX_CONVS) : []), ...(ctl.rejectConvs ? Object.values(REJECT_CONVS) : [])]
...(ctl.fixConvs ? Object.values(FIX_CONVS) : []), ...(ctl.rejectConvs ? Object.values(REJECT_CONVS) : []),
...(ctl.ledgerConvs ? Object.values(LEDGER_CONVS) : [])]
.filter((c) => !db.purged.has(c.id))
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
let runSeq = 0
@@ -1507,6 +1523,100 @@ for (const theme of THEMES) {
}
})
await section('ledger', '口径账本:轮次底部「正在比对口径」→ 摘要、按口径正式出具、对账(写请求在浏览器层拦下)', async () => {
ctl.ledgerConvs = true
try {
const { page, ctx, errors } = await open(theme)
// 文档记着的运行要和成果的运行对得上(run-led),片段、证据图按这次运行答
await page.route(/\/api\/artifacts\//, (route) => route.fulfill({ json: { id: EVIDENCE.doc_artifact,
content: { ...EVIDENCE.doc, run_id: 'run-led' } } }))
await page.route(/\/api\/runs\/run-led\/evidence(\/.*)?(\?.*)?$/, (route) => {
const m = new URL(route.request().url()).pathname.match(/\/segments\/([^/]+)$/)
return m ? route.fulfill({ json: EVIDENCE.segments[decodeURIComponent(m[1])] ?? {} }) : route.fulfill({ json: EVIDENCE.graph })
})
// 账本:头两次答 pending(正在比对),之后比对过了:整题等价于受管口径「周报口径」v2,另有一条查询可以对账
const led = { gets: [], writes: [], issued: [] }
await page.route(/\/api\/runs\/run-led\/ledger$/, (route) => {
led.gets.push(Date.now())
if (led.gets.length <= 2) return route.fulfill({ json: { run_id: 'run-led', state: 'pending', note: '封存后比对,不在封存范围内' } })
return route.fulfill({ json: ledgerOf('run-led', { labels: WHOLE_LABELS, whole: WHOLE, actions: [ISSUE, RECONCILE],
issued: led.issued }) })
})
await page.route(/\/api\/ledger\//, (route) => {
const req = route.request()
const path = new URL(req.url()).pathname
let body = null
try { body = req.postDataJSON() } catch { body = null }
led.writes.push({ key: `${req.method()} ${path}`, body })
if (req.method() === 'POST' && path === '/api/ledger/answers/run-led/issue') {
led.issued = [{ run_id: 'run-iss-1', status: 'succeeded', end_reason: null, workflow_id: 'wf-weekly', version: 2,
tier: 'formal', tier_text: '完整出具', auto: false, created_at: iso(0) }]
return route.fulfill({ json: { run_id: 'run-iss-1', created: true, issued: led.issued[0] } })
}
// 对账:这一段只核对按钮发出的请求体,结果卡片在 check-ledger 里看
if (req.method() === 'POST' && path === '/api/ledger/answers/run-led/reconcile') {
return route.fulfill({ status: 409, json: { detail: '检查脚本:不真的发起对账', code: 'ledger_reconcile_refused' } })
}
return route.fulfill({ status: 409, json: { detail: '检查脚本不写库' } })
})
await goto(page, 'c0ledgr')
const turnBox = page.locator('[data-turn]').first()
const waiting = await page.locator('[data-ledger-turn="waiting"]').first().waitFor({ timeout: 8000 }).then(() => true, () => false)
check('交付了的轮次:比对还没出结果时写「正在比对口径」', waiting
&& (await page.locator('[data-ledger-turn="waiting"]').first().innerText()).includes('正在比对口径'))
const summary = page.locator('[data-ledger-summary]').first()
const shown = await summary.waitFor({ timeout: 10000 }).then(() => true, () => false)
check('按退避再取,比对出结果后写摘要(不靠实时流)', shown && led.gets.length === 3, `${led.gets.length} 次`)
const gaps = led.gets.slice(1).map((t, i) => t - led.gets[i])
check('……隔得越来越久(退避),不是每一拍都取', gaps.length === 2 && gaps[0] >= 700 && gaps[1] >= gaps[0] - 100,
gaps.join('ms, ') + 'ms')
const text = (await summary.innerText()).replace(/\s+/g, ' ')
check('摘要:几个数来自哪份口径', text.includes('3 个数来自口径「周报口径」v2(受管)'), text)
check('整题等价:写等价于哪份口径、怎么认的', (await page.locator('[data-ledger-whole]').first().innerText())
.includes('整题等价于口径「周报口径」v2(受管) · 等价已证明'))
await page.waitForTimeout(3500)
check('比对过了、没有要等的:不再取', led.gets.length === 3, `${led.gets.length} 次`)
const issueBtn = page.locator('[data-ledger-issue-button]').first()
check('按服务端的动作摆「按口径正式出具」,下面写为什么不自动', await issueBtn.count() === 1
&& (await page.locator('[data-ledger-auto-reason]').first().innerText()).includes('设置里关了自动按口径出具'))
check('有相关口径:摆「对账」按钮', (await page.locator('[data-ledger-turn-reconcile]').first().innerText()).includes('对账:「订单口径」v1(已发布)'))
check('数字上挂着来自口径的小标记', await turnBox.locator('[data-ev-caliber-of="s6"]').count() === 1)
await issueBtn.click()
const posted = await until(() => led.writes.some((w) => w.key === 'POST /api/ledger/answers/run-led/issue'), 5000)
const issueBody = led.writes.find((w) => w.key === 'POST /api/ledger/answers/run-led/issue')?.body
check('出具的请求只说按哪份口径(参数由服务端从比对里取)', posted
&& JSON.stringify(issueBody) === JSON.stringify({ workflow_id: 'wf-weekly', version: 2 }), JSON.stringify(issueBody))
const issued = page.locator('[data-ledger-issued="run-iss-1"]').first()
const issuedShown = await issued.waitFor({ timeout: 6000 }).then(() => true, () => false)
const issuedText = issuedShown ? (await issued.innerText()).replace(/\s+/g, ' ') : ''
check('出具的正式运行挂在轮次下面:写档位', issuedText.includes('按口径正式出具') && issuedText.includes('「周报口径」v2(受管)')
&& issuedText.includes('完整出具'), issuedText)
check('……点开去运行', await issued.locator('a[href="/runs/run-iss-1"]').count() === 1)
check('出具过了:不再摆出具按钮', await page.locator('[data-ledger-issue-button]').count() === 0)
await page.locator('[data-ledger-turn-reconcile]').first().click()
const dialog = page.locator('[data-ledger-reconcile-dialog]')
check('点「对账」打开对账:差在哪、核对参数', await dialog.waitFor({ timeout: 4000 }).then(() => true, () => false)
&& (await dialog.innerText()).includes('口径只保留状态为已支付的行') && (await dialog.innerText()).includes('参数按这次的 SQL 推断,请核对'))
await page.locator('[data-ledger-param-input="start"]').fill('2026-09-02')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => led.writes.some((w) => w.key === 'POST /api/ledger/answers/run-led/reconcile'), 5000)
const recBody = led.writes.find((w) => w.key === 'POST /api/ledger/answers/run-led/reconcile')?.body
check('对账的请求:哪条查询、哪份口径、核对过的参数(confirm)', recBody?.query === 'q-orders' && recBody?.workflow_id === 'wf-orders'
&& recBody?.version === 1 && recBody?.confirm === true && recBody?.inputs?.start === '2026-09-02' && !('store' in (recBody?.inputs ?? {})),
JSON.stringify(recBody))
check('服务端拒了:照它的话写在对账框里', (await page.locator('[data-ledger-reconcile-problem]').innerText().catch(() => ''))
.includes('检查脚本:不真的发起对账'))
check('写请求只有出具、对账这两次(都在浏览器层拦下)', led.writes.length === 2, led.writes.map((w) => w.key).join(' | '))
await page.screenshot({ path: `${SHOTS}/ledger-${theme}.png` })
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
} finally {
ctl.ledgerConvs = false
}
})
await section('fixfirst', '要改的东西在工具库里:先去改参数定义,「接着跑」退成次要', async () => {
ctl.fixConvs = true
try {
+77
View File
@@ -17,6 +17,7 @@
// EVIDENCE_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核。
import { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
import { ledgerOf } from './ledger-fixtures.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH
@@ -106,6 +107,13 @@ async function routed(page, { judge = null, segPatch = null, prov = null, graph
return r.fulfill({ status: reply.status ?? 200, json: reply.json })
}
if (req.method() !== 'GET') return r.abort()
// 口径账本(next-round 02):证据正文、面板会按运行取一次。各节的夹具都不比对(只有「口径账本」那一节另给答复),
// 免得落到真服务端、按夹具的假运行答 404
const lm = url.pathname.match(/^\/api\/runs\/([^/]+)\/ledger$/)
if (lm) {
hits.push(`ledger:${decodeURIComponent(lm[1])}`)
return r.fulfill({ json: { run_id: decodeURIComponent(lm[1]), state: 'ineligible', reason: '检查脚本的夹具不比对口径' } })
}
const pm = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)\/provenance$/)
if (pm) {
const [run, sid] = [decodeURIComponent(pm[1]), decodeURIComponent(pm[2])]
@@ -4130,6 +4138,75 @@ await section('ask', '期 4:句末「质询」图标平时的对比度——
await r.ctx.close()
})
await section('ledger', '口径账本:来自口径的数字挂小标记(受管 / 已发布),面板出处链最上面「口径」一步分三种情况', async () => {
// 来自口径的数加一个小标记、写明受管还是已发布;相关口径的数不加标记,只在面板里说。面板「口径」一步:来自口径——「X」v3
//(受管)· 参数 · 怎么认的;相关口径——差在哪 + 看差多少;这次的推理——没有相关的已发布口径(02 做法 3、11)
const LED = ledgerOf(fx.run_id)
let asked = 0
const r = await open('/ui-harness.html?evidence=1', { before: (page) => page.route(
(u) => new URL(u).pathname === `/api/runs/${fx.run_id}/ledger`, (route) => { asked += 1; return route.fulfill({ json: LED }) }) })
const p = r.page
await p.waitForSelector('#evidence-wide [data-ev-caliber-of="s6"]', { timeout: 6000 }).catch(() => {})
const mark = (id) => p.evaluate((sid) => {
const el = document.querySelector(`#evidence-wide [data-ev-caliber-of="${sid}"]`)
const seg = document.querySelector(`#evidence-wide [data-seg="${sid}"]`)
return el ? { text: el.textContent, title: el.getAttribute('title'), level: el.getAttribute('data-ev-caliber'),
hidden: el.getAttribute('aria-hidden'), aria: seg?.getAttribute('aria-label') ?? '',
after: seg?.nextElementSibling === el } : null
}, id)
const s6 = await mark('s6')
check('受管口径的数挂「受管」小标记,紧跟在数字后面', s6?.text === '受管' && s6.level === 'governed' && s6.after, JSON.stringify(s6))
check('……悬停写是哪一份口径、怎么认的', s6?.title === '来自口径「周报口径」v2(受管) · 等价已证明', s6?.title)
check('……标记不进读屏(读屏念在数字上)', s6?.hidden === 'true' && s6.aria.includes('来自口径「周报口径」v2(受管),等价已证明'), s6?.aria)
const s10 = await mark('s10')
check('已发布口径、助手选用的数挂「已发布」', s10?.text === '已发布' && s10.title.includes('助手选用'), JSON.stringify(s10))
check('相关口径、这次的推理、没比对的数都不挂标记', !(await mark('s8')) && !(await mark('s12')) && !(await mark('s23')))
check('标记只挂在面向读者的正文上:窄栏那份同样挂', await p.locator('#evidence-narrow [data-ev-caliber-of="s6"]').count() === 1)
const part = () => p.locator('[data-evidence-panel] [data-ev-ledger]')
await openSeg(p, 's6', '[data-ev-ledger]')
const caliber = await part().innerText().catch(() => '')
check('来自口径:「口径」一步写口径、级别、怎么认的', await part().getAttribute('data-ev-ledger').catch(() => null) === 'caliber'
&& caliber.includes('来自口径「周报口径」v2(受管)') && caliber.includes('等价已证明'), caliber.replace(/\s+/g, ' '))
check('……写参数', caliber.includes('week = 2026-W37'), caliber.replace(/\s+/g, ' '))
check('……脚注写明封存后比对、不在封存范围内', caliber.includes('封存后比对,不在封存范围内'))
const order = await p.evaluate(() => {
const body = document.querySelector('[data-evidence-panel] [data-ev-body]')
const kids = [...(body?.children ?? [])]
const at = (sel) => kids.findIndex((k) => k.matches(sel) || !!k.querySelector(sel))
return { sentence: at('[data-ev-sentence]'), ledger: at('[data-ev-ledger]'), metric: at('[data-ev-metric], [data-ev-metric-step]'),
seal: at('[data-ev-seal]') }
})
check('「口径」一步在出处链最上面:紧跟句子,在指标步骤、封存之前', order.ledger === order.sentence + 1
&& (order.metric < 0 || order.ledger < order.metric) && order.ledger < order.seal, JSON.stringify(order))
check('整题不等价:这个数上没有「按口径正式出具」', await p.locator('[data-evidence-panel] [data-ledger-issue-button]').count() === 0)
await openSeg(p, 's10', '[data-ev-ledger="caliber"]')
const composed = await part().innerText().catch(() => '')
check('助手选用:写「助手选用」和由人确认的说明', composed.includes('助手选用') && composed.includes('要由人确认'), composed.replace(/\s+/g, ' '))
check('……绑定的遮罩值只写「已遮罩」', composed.includes('store = 已遮罩'), composed.replace(/\s+/g, ' '))
await openSeg(p, 's8', '[data-ev-ledger="related"]')
const related = await part().innerText().catch(() => '')
check('相关口径:写哪一份、差在哪', related.includes('相关口径「订单口径」v1(已发布)') && related.includes('差在哪')
&& related.includes('口径只保留状态为已支付的行,这次没有这个条件'), related.replace(/\s+/g, ' '))
check('……给「看差多少」(服务端给了对账这一项)', await p.locator('[data-evidence-panel] [data-ledger-reconcile-open]').count() === 1)
await openSeg(p, 's12', '[data-ev-ledger="reasoning"]')
const reasoning = await part().innerText().catch(() => '')
check('这次的推理:写「没有相关的已发布口径」', reasoning.includes('这次的推理') && reasoning.includes('没有相关的已发布口径'), reasoning)
await openSeg(p, 's23', '[data-ev-ledger="unknown"]')
const unknown = await part().innerText().catch(() => '')
check('没有比对:写原因', unknown.includes('没有比对') && unknown.includes('比对超过时限,没有做完'), unknown)
await openSeg(p, 's4', '[data-ev-seal-state]')
check('不是数的片段(运行输入)不画「口径」一步', await part().count() === 0)
check('同一次运行只取一次账本(正文两份、面板开了几次都共用一份,比对过了不再轮询)', asked === 1, `${asked} 次`)
check('没有运行时报错', r.errors.length === 0, r.errors.join(' | '))
await r.ctx.close()
})
check('没有运行时报错(组件预览)', harness.errors.length === 0, harness.errors.join(' | '))
await harness.ctx.close()
await browser.close()
+399 -5
View File
@@ -1,7 +1,401 @@
#!/usr/bin/env node
// 口径账本的界面检查(docs/design/next-round 02 做法 8)。
// 口径账本的界面检查(docs/design/next-round/02-口径账本.md 做法 3–6、8 的界面,第 4 波 W4-c)。
//
// 第 4 波骨架里的占位:check-all 先登记好名字,W4-c 换成真检查(假接口加夹具,照 check-share 的写法)。
console.log('=== 占位 ===')
console.log(' ✓ 占位:W4-c 换成真检查')
console.log('\n✓ 占位通过')
// 守的是:
// - 证据面板「口径」一步:比对还没出结果时写「正在比对口径」,按退避再取(不靠实时流:ledger.matched 在事件总线关闭
// 之后才追加);等满了还没有结论照实说、给「再取一次」,点了重新取、取到就画;三种情况(来自口径 / 相关口径 / 这次的
// 推理)各写各的;
// - 口径对账:核对参数(推断的参数要先核对)→ 发起(认不出列怎么配时照 409 带回的选项选对照的指标,再发起)→ 结果卡片:
// 这次 / 口径 / 差值 / 相对差四个数各自取值、点开是出处链;请求体逐项核对;
// - 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写;「固化成口径草稿」走 from-run 的 freeze(带同组的别的运行)、
// 打开画布;聚类按人挑的那一轮固化;「不是口径」要写理由;
// - 使用者看到的入口照服务端的动作:不能出具的写原因(请搭建者出具)、不摆出具按钮,能对账的照样对账;出具过的正式运行
// 不给打开运行的链接(使用者读不了它的原始记录),写由搭建者查看;
// - 设置里口径账本一组:和其余偏好共用保存条,整组写 ledger,值不合规不让存。
//
// 不写库:所有写请求都在浏览器层拦下(伪造的答复),核对请求体;认不出的写请求一律 409 并记账,检查结束时必须是空的。
// 夹具见 scripts/ledger-fixtures.mjs(挂在可点击证据的夹具 evidence-doc.json 上,只用通用名)。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-ledger.mjs
import { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
import {
ISSUE, QUEUE, RECONCILE, RECONCILE_VALUES, WHOLE, WHOLE_LABELS, ledgerOf, metricDetail, reconciledOf,
} from './ledger-fixtures.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 ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const fx = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUN = fx.run_id
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 200)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const until = async (fn, ms = 6000, step = 100) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) {
await new Promise((r) => setTimeout(r, step))
v = await fn()
}
return v
}
const stray = []
const ADMIN = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const USER = { mode: 'local', username: 'carol', display_name: '使用者丙', role: 'user', groups: [], verified: true, user_can_chat: true }
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:/api/ 下的请求按 reply 答,记下每个请求(sent);reply 不认的 GET 交给真服务端,不认的写请求一律 409 并记进
* stray(检查结束时必须是空的)
*/
async function open(path, { me = ADMIN, reply = () => null, w = 1280, h = 900 } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h } })
opened.add(ctx)
ctx.setDefaultTimeout(8000)
const page = await ctx.newPage()
const errors = []
const sent = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = decodeURIComponent(url.pathname.replace(/^\/api/, ''))
let body = null
try { body = req.postDataJSON() } catch { body = null }
sent.push({ key: `${req.method()} ${p}`, body, at: Date.now() })
if (p === '/auth/me') return route.fulfill({ json: me })
const out = await reply(req.method(), p, body, url)
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json ?? {} })
if (req.method() === 'GET') return route.continue()
stray.push(`${req.method()} ${p}`)
return route.fulfill({ status: 409, json: { detail: '检查脚本不写库' } })
})
await page.goto(`${WEB}${path}`, { waitUntil: 'domcontentloaded' })
return { ctx, page, errors, sent }
}
/** 可点击证据的组件预览页:片段接口、证据图按夹具答,账本按 ledger() 答 */
function evidenceReply(ledger, extra = () => null) {
return async (method, p, body, url) => {
const more = await extra(method, p, body, url)
if (more) return more
if (method !== 'GET') return null
if (p === `/runs/${RUN}/ledger`) return { json: await ledger() }
const seg = p.match(new RegExp(`^/runs/${RUN}/evidence/segments/([^/]+)$`))
if (seg) return fx.segments[seg[1]] ? { json: fx.segments[seg[1]] } : { status: 404, json: { detail: '报告中没有这个片段' } }
if (p === `/runs/${RUN}/evidence`) return { json: fx.graph }
// 组件预览页上别的几份夹具:不比对
const other = p.match(/^\/runs\/([^/]+)\/ledger$/)
if (other) return { json: { run_id: other[1], state: 'ineligible', reason: '检查脚本的夹具不比对口径' } }
return null
}
}
const panelPart = (page) => page.locator('[data-evidence-panel] [data-ev-ledger]')
async function openSeg(page, id) {
await page.locator(`#evidence-wide [data-seg="${id}"]`).click()
await page.waitForSelector('[data-evidence-panel]', { timeout: 4000 }).catch(() => {})
}
// ---------------------------------------------------------------- 正在比对、退避、等满了、再取一次
await section('比对还没出结果:正在比对口径、按退避再取、等满了照实说、再取一次', async () => {
const state = { matched: false, gets: [] }
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => {
state.gets.push(Date.now())
return state.matched ? ledgerOf(RUN) : { run_id: RUN, state: 'pending', note: '封存后比对,不在封存范围内' }
}) })
await page.waitForSelector('[data-evidence-doc]', { timeout: 10000 })
await openSeg(page, 's6')
const waiting = await page.locator('[data-evidence-panel] [data-ledger-state="pending"]').waitFor({ timeout: 5000 }).then(() => true, () => false)
check('「口径」一步写「正在比对口径」', waiting && (await panelPart(page).innerText()).includes('正在比对口径'))
check('……说明比完会自动更新', (await panelPart(page).innerText()).includes('比完这里会自动更新'))
check('还没比完时正文上不挂小标记', await page.locator('[data-ev-caliber-of]').count() === 0)
await until(() => state.gets.length >= 4, 9000)
const gaps = state.gets.slice(1).map((t, i) => t - state.gets[i])
check('按退避再取:间隔越来越长(1、2、3 秒……)', state.gets.length >= 4 && gaps[0] >= 700 && gaps[1] >= gaps[0] - 150
&& gaps[2] >= gaps[1] - 150, gaps.map((g) => `${g}ms`).join(', '))
// 把这一轮等待的起点挪到两分钟以前:下一次取回来还是 pending,就该照实说等满了,不再取
await page.evaluate((run) => window.__ledger.setState((s) => ({ slots: { ...s.slots, [run]: { ...s.slots[run], since: Date.now() - 200_000 } } })), RUN)
const timedOut = await page.locator('[data-evidence-panel] [data-ledger-state="timeout"]').waitFor({ timeout: 15000 }).then(() => true, () => false)
check('等满了还没有结论:照实说,给「再取一次」', timedOut
&& (await panelPart(page).innerText()).includes('比对口径还没有结果') && await page.locator('[data-ledger-retry]').count() >= 1)
const after = state.gets.length
await page.waitForTimeout(3500)
check('……等满了就不再取', state.gets.length === after, `${state.gets.length - after} 次`)
state.matched = true
await page.locator('[data-evidence-panel] [data-ledger-retry]').click()
const drawn = await page.locator('[data-evidence-panel] [data-ev-ledger="caliber"]').waitFor({ timeout: 5000 }).then(() => true, () => false)
check('点「再取一次」:重新取,比对过了就画出来', drawn && state.gets.length === after + 1, `${state.gets.length - after} 次`)
check('正文上的小标记跟着出来', await page.locator('#evidence-wide [data-ev-caliber-of="s6"]').count() === 1)
await page.waitForTimeout(2500)
check('比对过了、没有要等的:不再取', state.gets.length === after + 1, `${state.gets.length - after} 次`)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 三种情况
await section('「口径」一步的三种情况:来自口径、相关口径、这次的推理', async () => {
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(() => ledgerOf(RUN, { retro: true })) })
await page.waitForSelector('#evidence-wide [data-ev-caliber-of="s6"]', { timeout: 10000 })
await openSeg(page, 's6')
await page.locator('[data-evidence-panel] [data-ev-ledger="caliber"]').waitFor({ timeout: 5000 })
const caliber = await panelPart(page).innerText()
check('来自口径:口径、版本、级别、怎么认的、参数', caliber.includes('来自口径「周报口径」v2(受管)') && caliber.includes('等价已证明')
&& caliber.includes('week = 2026-W37'), caliber)
check('……事后补比的写「按现在的口径回看」', caliber.includes('封存后比对,不在封存范围内 · 按现在的口径回看'), caliber)
await openSeg(page, 's8')
await page.locator('[data-evidence-panel] [data-ev-ledger="related"]').waitFor({ timeout: 5000 })
const related = await panelPart(page).innerText()
check('相关口径:差在哪 + 看差多少', related.includes('相关口径「订单口径」v1(已发布)') && related.includes('口径只保留状态为已支付的行')
&& await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').count() === 1, related)
check('……这个数本身是这次的推理(照实说)', related.includes('这个数是这次的推理'), related)
await openSeg(page, 's12')
await page.locator('[data-evidence-panel] [data-ev-ledger="reasoning"]').waitFor({ timeout: 5000 })
check('这次的推理:没有相关的已发布口径', (await panelPart(page).innerText()).includes('没有相关的已发布口径'))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 对账
await section('口径对账:核对参数 → 发起 → 选对照的指标 → 三个数各自点得开', async () => {
const state = { posts: [], reconciled: [], polls: 0 }
const extra = (method, p, body) => {
if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile`) {
state.posts.push(body)
if (!body?.pairs) {
return { status: 409, json: { detail: '认不出这次的哪一列对应口径的哪个指标,请选择要对照的指标', code: 'ledger_reconcile_needs_pairs',
options: { columns: ['orders', 'refunds'], metrics: [{ id: 'orders', name: '订单数', unit: '单' }] } } }
}
state.reconciled = [reconciledOf('running')]
return { status: 201, json: { run_id: 'run-rec-1', reconciled: state.reconciled[0] } }
}
const m = p.match(/^\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/([^/]+)$/)
if (method === 'GET' && m) return { json: metricDetail('run-rec-1', m[1]) }
return null
}
const ledger = () => {
// 发起之后:第一次取回来对账还在进行中,再取一次就做完了
if (state.reconciled[0]?.status === 'running') {
state.polls += 1
if (state.polls > 1) state.reconciled = [reconciledOf('succeeded')]
}
return ledgerOf(RUN, { reconciled: state.reconciled })
}
const { page, errors } = await open('/ui-harness.html?evidence=1', { reply: evidenceReply(ledger, extra) })
await page.waitForSelector('#evidence-wide [data-seg="s8"]', { timeout: 10000 })
await openSeg(page, 's8')
await page.locator('[data-evidence-panel] [data-ledger-reconcile-open]').click()
const dialog = page.locator('[data-ledger-reconcile-dialog]')
await dialog.waitFor({ timeout: 4000 })
const text = await dialog.innerText()
check('对账框:差在哪、写明只读', text.includes('口径只保留状态为已支付的行') && text.includes('只读'), text)
check('参数是推断的:先核对(表单直接打开、写原因)', await page.locator('[data-ledger-params="edit"]').count() === 1
&& text.includes('参数按这次的 SQL 推断,请核对'))
check('表单照动作里的入口字段画:推断出的值填好,有缺省值的写明留空用缺省值',
await page.locator('[data-ledger-param-input="start"]').inputValue() === '2026-09-01'
&& await page.locator('[data-ledger-param-input="store"]').inputValue() === '' && text.includes('留空用口径的缺省值'))
await page.locator('[data-ledger-param-input="start"]').fill('2026-09-03')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => state.posts.length === 1)
check('第一次发起:带核对过的参数(confirm),空着的不送', JSON.stringify(state.posts[0]) === JSON.stringify({
query: 'q-orders', workflow_id: 'wf-orders', version: 1, inputs: { start: '2026-09-03' }, confirm: true }), JSON.stringify(state.posts[0]))
const pairs = await page.locator('[data-ledger-pairs]').waitFor({ timeout: 4000 }).then(() => true, () => false)
check('认不出列怎么配:照服务端给的选项画「选择要对照的指标」', pairs && await page.locator('[data-ledger-pair]').count() === 2
&& (await page.locator('[data-ledger-reconcile-problem]').innerText()).includes('请选择要对照的指标'))
await page.locator('[data-ledger-reconcile-launch]').click()
check('一组都没选:不发起,说要至少选一组', state.posts.length === 1
&& (await page.locator('[data-ledger-reconcile-problem]').innerText()).includes('至少选一组对照'))
await page.locator('[data-ledger-pair="orders"]').selectOption('orders')
await page.locator('[data-ledger-reconcile-launch]').click()
await until(() => state.posts.length === 2)
check('选好再发起:带上对照(这次的列 → 口径卡上的指标)', JSON.stringify(state.posts[1]?.pairs) === JSON.stringify([{ ours: 'orders', metric: 'orders' }])
&& state.posts[1]?.confirm === true, JSON.stringify(state.posts[1]))
check('发起之后对账框收起', await until(async () => await dialog.count() === 0, 4000))
const card = page.locator('[data-evidence-panel] [data-ledger-reconciled="run-rec-1"]')
const running = await card.waitFor({ timeout: 6000 }).then(() => true, () => false)
check('结果卡片:对账运行还在进行时写「对账进行中」', running && (await card.innerText()).includes('对账进行中'))
const done = await page.locator('[data-evidence-panel] [data-ledger-reconciled-state="succeeded"]').waitFor({ timeout: 12000 }).then(() => true, () => false)
check('……再取一次(运行还没结束时接着等),做完了画出每组对照', done)
await until(async () => (await card.innerText()).includes(RECONCILE_VALUES.diff_0), 5000)
const cardText = (await card.innerText()).replace(/\s+/g, ' ')
check('四个数:按你的问法、按口径、差值、相对差', cardText.includes(`按你的问法 ${RECONCILE_VALUES.ours_0}`)
&& cardText.includes(`按口径 ${RECONCILE_VALUES.cal_0}`) && cardText.includes(`差值 ${RECONCILE_VALUES.diff_0}`)
&& cardText.includes(`相对差 ${RECONCILE_VALUES.rel_0}`), cardText)
check('……写参数从哪来、原答案里写的是什么', cardText.includes('按核对过的参数') && cardText.includes('原答案里写的是 8.7%'), cardText)
await card.locator('[data-ledger-value="diff_0"]').click()
const drawer = page.locator('[data-evidence-panel="side"]').last()
const opened = await until(async () => (await drawer.innerText().catch(() => '')).includes('差值'), 5000)
check('点差值:打开它的出处链(同应用页点数)', opened, (await drawer.innerText().catch(() => '')).slice(0, 80))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 可沉淀的问题
const WF = { id: 'wf-led-check', name: '检查用工作流', description: '', version: 1, status: 'draft', tags: [], is_template: false,
graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '入口', config: { fields: [] } } }], edges: [] } }
const FROZEN = { ...WF, id: 'wf-frozen', name: '上周销售额是多少(固化的口径草稿)', tags: ['certified'] }
await section('可沉淀的问题:工作流列表的抽屉,三类来源分开写,固化成口径草稿、不是口径要写理由', async () => {
const state = { queue: structuredClone(QUEUE), freezes: [], dismisses: [] }
const reply = (method, p, body) => {
if (method === 'GET' && p === `/workflows/${WF.id}`) return { json: WF }
if (method === 'GET' && p === `/workflows/${FROZEN.id}`) return { json: FROZEN }
if (method === 'POST' && p === '/workflows/validate') return { json: { ok: true, issues: [] } }
if (method === 'POST' && p === '/workflows/variables') return { json: { variables: [], issues: [] } }
if (p.startsWith('/conversations')) return { json: method === 'GET' ? [] : { id: 'cv', kind: 'canvas', title: '', turns: [] } }
if (method === 'GET' && p === '/ledger/certify-queue') return { json: state.queue }
if (method === 'POST' && p === '/ledger/certify-queue/dismiss') {
state.dismisses.push(body)
return { json: { ok: true, item_id: body?.item_id } }
}
if (method === 'POST' && p === '/copilot/from-run') {
state.freezes.push(body)
return { json: { workflow_id: FROZEN.id, name: FROZEN.name, nodes: 5, edges: 4, params: ['p1'], metrics: ['m1'], sources: [] } }
}
return null
}
const { page, errors } = await open(`/studio/${WF.id}`, { reply })
await page.locator('button[title="切换、新建、从模板新建工作流"]').click()
await page.locator('[data-certify-open]').click()
const drawer = page.locator('[data-certify-drawer]')
await drawer.waitFor({ timeout: 5000 })
await page.locator('[data-certify-item]').first().waitFor({ timeout: 5000 })
const kinds = await page.locator('[data-certify-kind]').evaluateAll((els) => els.map((e) => e.getAttribute('data-certify-kind')))
check('三类来源分开列:确定性重复、问题聚类、被反复用的答案', JSON.stringify(kinds) === JSON.stringify(['shape', 'cluster', 'reused']), kinds.join(','))
const text = (await drawer.innerText()).replace(/\s+/g, ' ')
check('各类写明是怎么认出来的(聚类不等于口径相同)', text.includes('确定性重复:同样形状的 SQL') && text.includes('问法相近不等于口径相同'), text.slice(0, 200))
check('确定性重复:几次运行、几段对话、参数候选、已有相关口径', text.includes('4 次运行 · 3 段对话') && text.includes('1 个参数候选 · 1 个口径常量')
&& text.includes('已有相关口径'), text.slice(0, 300))
check('被反复用的答案:怎么被用的、已固化成哪张草稿', text.includes('导出 2 次 · 在画布里打开过') && text.includes('本月新客数(固化的口径草稿)'))
const cluster = page.locator('[data-certify-item="cluster:bb22"]')
check('问题聚类没有共同的 SQL:不给整项固化,按人挑的那一轮固化', await cluster.locator('[data-certify-freeze]').count() === 0
&& await cluster.locator('[data-certify-freeze-turn]').count() === 2)
await cluster.locator('[data-certify-dismiss]').click()
const dialog = page.getByRole('dialog', { name: '这不是口径' })
await dialog.waitFor({ timeout: 4000 })
check('「不是口径」不写理由:确认键不能点', await dialog.getByRole('button', { name: '确认不是口径' }).isDisabled()
&& state.dismisses.length === 0)
await dialog.getByRole('textbox').fill('问的是各门店的临时核对')
await dialog.getByRole('button', { name: '确认不是口径' }).click()
await until(() => state.dismisses.length === 1)
check('写了理由才记下:请求带这一项、种类和理由', JSON.stringify(state.dismisses[0]) === JSON.stringify({
item_id: 'cluster:bb22', kind: 'cluster', note: '问的是各门店的临时核对' }), JSON.stringify(state.dismisses[0]))
check('……这一项从抽屉里拿掉', await until(async () => await page.locator('[data-certify-item="cluster:bb22"]').count() === 0, 3000))
await page.locator('[data-certify-item="shape:aa11"] [data-certify-freeze]').click()
await until(() => state.freezes.length === 1)
check('固化成口径草稿:走 from-run 的 freeze,带同组的别的运行和来源轮次', JSON.stringify(state.freezes[0]) === JSON.stringify({
run_id: 'run-s1', freeze: true, peers: ['run-s2', 'run-s3'], conversation_id: 'c-1', turn_id: 't-1' }), JSON.stringify(state.freezes[0]))
check('……打开画布里的草稿', await until(() => new URL(page.url()).pathname === `/studio/${FROZEN.id}`, 5000), page.url())
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 使用者
await section('使用者看到的入口照服务端的动作:不能出具的写原因,能对账的照样对账,出具的运行不给链接', async () => {
const EV = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUN_U = 'run-user-led'
const at = new Date(Date.now() - 86400e3).toISOString()
const conv = { id: 'c-user-led', title: '问数据:上周的周报', kind: 'chat', workflow_id: null, archived: false, created_at: at,
last_active_at: at, turn_count: 1, last_question: '上周的周报', last_status: 'done', last_run_id: RUN_U }
const turn = { id: 'tu1', seq: 0, question: '上周的周报', answer: '(周报原文)', explanation: '', graph: null, run_id: RUN_U,
status: 'done', error: '', review: null, created_at: at,
meta: { v: 1, runId: RUN_U, runClass: 'exploratory', runStatus: 'succeeded', evidence: true } }
const refused = { ...ISSUE, available: false, auto: false, reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }
delete refused.auto_reason
const LED = ledgerOf(RUN_U, { labels: WHOLE_LABELS, whole: WHOLE, actions: [refused, RECONCILE],
// 出具过的是另一份口径(订单口径):周报口径这一项还没出具过,照服务端的原因写为什么不能出具
issued: [{ run_id: 'run-iss-u', status: 'succeeded', workflow_id: 'wf-orders', version: 1, tier: 'degraded', tier_text: '降档出具', auto: true }] })
const reply = (method, p) => {
if (method === 'GET' && p === '/conversations') return { json: [conv] }
if (method === 'GET' && p === `/conversations/${conv.id}`) return { json: { ...conv, turns: [turn] } }
if (p.startsWith('/conversations')) return { json: {} }
if (method === 'GET' && p === `/runs/${RUN_U}`) return { json: { id: RUN_U, status: 'succeeded', run_class: 'exploratory', output: EV.output, usage: {} } }
if (method === 'GET' && p === `/runs/${RUN_U}/events`) return { json: [] }
if (method === 'GET' && p === `/runs/${RUN_U}/ledger`) return { json: LED }
if (method === 'GET' && p === `/runs/${RUN_U}/evidence`) return { json: EV.graph }
if (method === 'GET' && p.startsWith('/artifacts/')) return { json: { id: EV.doc_artifact, content: { ...EV.doc, run_id: RUN_U } } }
if (method === 'GET' && p.startsWith('/approvals')) return { json: [] }
return null
}
const { page, errors } = await open(`/chat/${conv.id}`, { me: USER, reply })
const box = page.locator('[data-ledger-turn="matched"]').first()
const shown = await box.waitFor({ timeout: 12000 }).then(() => true, () => false)
const text = shown ? (await box.innerText()).replace(/\s+/g, ' ') : ''
check('使用者也看到摘要', text.includes('3 个数来自口径「周报口径」v2(受管)'), text)
check('不能出具:照服务端的原因写「请搭建者出具」,不摆出具按钮', text.includes('请搭建者出具')
&& await page.locator('[data-ledger-issue-button]').count() === 0, text)
check('能对账:照样摆对账按钮', await page.locator('[data-ledger-turn-reconcile]').count() === 1)
const issued = page.locator('[data-ledger-issued="run-iss-u"]')
const issuedText = (await issued.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('自动出具过的正式运行:写「按口径自动出具」、哪份口径和档位', issuedText.includes('按口径自动出具')
&& issuedText.includes('「订单口径」v1(已发布)') && issuedText.includes('降档出具'), issuedText)
check('……使用者不给打开运行的链接,写由搭建者查看', await issued.locator('a').count() === 0 && issuedText.includes('由搭建者在记录页查看'), issuedText)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// ---------------------------------------------------------------- 设置
await section('设置里的口径账本一组:共用保存条,整组写 ledger,值不合规不让存', async () => {
const state = { puts: [], ledger: { auto_issue: true, issue_daily_limit: 20, match_timeout_s: 8, recall_limit: 50 } }
const reply = (method, p, body) => {
if (method === 'GET' && p === '/settings') return { json: { run: {}, limits: { max_agent_steps: 100 }, apps: {}, ledger: state.ledger } }
if (method === 'PUT' && p === '/settings') {
state.puts.push(body)
if (body?.values?.ledger) state.ledger = body.values.ledger
return { json: { ledger: state.ledger } }
}
return null
}
const { page, errors } = await open('/settings/prefs', { reply })
const sec = page.locator('[data-ledger-settings]')
await sec.waitFor({ timeout: 8000 })
const text = await sec.innerText()
check('四项都在:自动出具、每日上限、比对时限、升版后重算几组', ['自动按口径正式出具', '按口径正式出具每日上限', '口径比对时限(秒)',
'口径升版后每次最多重算几组'].every((t) => text.includes(t)), text)
check('显示的是服务端生效的值', await page.locator('[data-ledger-setting="issue_daily_limit"]').inputValue() === '20'
&& await page.locator('[data-ledger-setting="recall_limit"]').inputValue() === '50'
&& await page.locator('[data-ledger-setting="auto_issue"]').isChecked())
await page.locator('[data-ledger-setting="recall_limit"]').fill('5000')
const bar = page.locator('[data-prefs-bar]')
check('不合规的值:写明范围,保存键不能点', (await sec.innerText()).includes('请填写 0 到 1,000 之间的整数')
&& await bar.getByRole('button', { name: '保存设置' }).isDisabled())
await page.locator('[data-ledger-setting="recall_limit"]').fill('30')
await page.locator('[data-ledger-setting="auto_issue"]').uncheck()
check('保存条写改了哪几项(服务端的叫法)', (await bar.innerText()).includes('有 2 项未保存')
&& (await bar.innerText()).includes('自动按口径正式出具') && (await bar.innerText()).includes('口径升版后每次最多重算几组'))
await bar.getByRole('button', { name: '保存设置' }).click()
await until(() => state.puts.length === 1)
check('整组写 ledger(没改的项照原值带上,不会被冲回默认)', JSON.stringify(state.puts[0]?.values) === JSON.stringify({
ledger: { auto_issue: false, issue_daily_limit: 20, match_timeout_s: 8, recall_limit: 30 } }), JSON.stringify(state.puts[0]))
check('存完:保存条写「已保存」', await until(async () => (await bar.innerText().catch(() => '')).includes('已保存'), 3000))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
check('认不出的写请求一个都没有(都在浏览器层拦下)', stray.length === 0, stray.join(' | '))
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 口径账本全部通过')
process.exit(failed ? 1 : 0)
+114
View File
@@ -0,0 +1,114 @@
// 口径账本的检查夹具(check-ledger、check-evidence、check-chat 共用):GET /api/runs/{id}/ledger 的答复形状照服务端
// app/ledger/view.py 的 run_view(labels.view_label、issue.Plan.action、reconcile.actions_of / reconciled_view),
// 挂在可点击证据的夹具(frontend/src/run/__tests__/evidence-doc.json,compose_doc 真跑出来的周报,只用通用名)上:
// s6「45,678.5元」来自受管口径「周报口径」v2,等价已证明;s10「1,234单」来自已发布口径「订单口径」v1,助手选用;
// s8「8.7%」相关口径「订单口径」v1(差在哪:少了一个筛选条件),可以对账;s12「37.0元」这次的推理;
// s23「2.35%」没有比对(比对超时)。
// 口径名、表名、数都是虚构的通用名。
/** 口径的几份(calibers) */
export const WEEKLY = { caliber: 'wf-weekly@2', workflow_id: 'wf-weekly', version: 2, level: 'governed', level_text: '受管',
workflow_name: '周报口径', graph_hash: 'aa11' }
export const ORDERS = { caliber: 'wf-orders@1', workflow_id: 'wf-orders', version: 1, level: 'published', level_text: '已发布',
workflow_name: '订单口径', graph_hash: 'bb22' }
const caliberLabel = (c, how, extra = {}) => ({
kind: 'caliber', how, caliber: c.caliber, workflow_id: c.workflow_id, version: c.version, level: c.level,
level_text: c.level_text, workflow_name: c.workflow_name, ...extra,
})
export const DIFF = { items: [{ aspect: 'filter', a: null, b: "status = '[已遮罩]'", text: '口径只保留状态为已支付的行,这次没有这个条件' }],
fallback: false, text_diff: [] }
/** 一份比对过的账本。whole:整题判断(缺省不等价);actions、issued、reconciled 可以换 */
export function ledgerOf(runId, { whole, actions, issued = [], reconciled = [], retro = false, labels } = {}) {
return {
run_id: runId, state: 'matched', note: '封存后比对,不在封存范围内', post_seal: true, artifact: 'cm-1', algo: 'v1',
trigger: retro ? 'on_demand' : 'sealed', retro, matched_at: '2026-10-15T01:00:00+00:00',
...(retro ? { retro_note: '按现在的口径回看' } : {}),
calibers: [WEEKLY, ORDERS],
queries: [
{ artifact: 'q-gmv', node_id: 'ag', source: 'shop', tables: ['orders'], status: 'proven',
candidates: [{ ...WEEKLY, node_id: 'fetch', status: 'proven', how: 'independent' }] },
{ artifact: 'q-orders', node_id: 'ag', source: 'shop', tables: ['orders'], status: 'related',
candidates: [{ ...ORDERS, node_id: 'fetch', status: 'related', comparable: true, via: 'heuristic', diff: DIFF,
binding: [{ field: 'start', value: '2026-09-01' }] }] },
],
reports: { write: { labels: labels ?? {
s6: caliberLabel(WEEKLY, 'independent', { binding: [{ field: 'week', value: '2026-W37' }], card: 'caliber', metric: 'gmv',
queries: ['q-gmv'] }),
s10: caliberLabel(ORDERS, 'composed', { binding: [{ field: 'store', masked: true, value: '已遮罩' }], card: 'caliber',
metric: 'orders', queries: ['q-gmv'] }),
s8: { kind: 'related', related: [{ caliber: ORDERS.caliber, workflow_id: ORDERS.workflow_id, version: 1, level: 'published',
level_text: '已发布', workflow_name: '订单口径', node_id: 'fetch' }], queries: ['q-orders'] },
s12: { kind: 'reasoning', query: 'q-gmv' },
s23: { kind: 'unknown', reason: '比对超过时限,没有做完', query: 'q-gmv' },
} } },
primary: 'write',
whole: whole ?? { equivalent: false, reason: '有的数是这次的推理', counts: { caliber: 2, related: 1, reasoning: 1, unknown: 1 } },
actions: actions ?? [RECONCILE],
issued, reconciled,
}
}
/** 看差多少(s8 那条查询、订单口径):参数是推断的,要先核对 */
export const RECONCILE = {
kind: 'reconcile', query: 'q-orders', caliber: ORDERS.caliber, workflow_id: ORDERS.workflow_id, version: 1, node_id: 'fetch',
workflow_name: '订单口径', level_text: '已发布', needs_check: true, check_reason: '参数按这次的 SQL 推断,请核对',
binding: [{ field: 'start', value: '2026-09-01' }], fallback: false,
fields: [{ name: 'start', required: true, has_default: false }, { name: 'store', required: false, has_default: true }],
}
/** 按口径正式出具(整题等价于周报口径):搭建者能一键,不自动的原因写在 auto_reason */
export const ISSUE = {
kind: 'issue', workflow_id: WEEKLY.workflow_id, version: 2, caliber: WEEKLY.caliber, workflow_name: '周报口径', how: 'independent',
level: 'governed', level_text: '受管', available: true, auto: false, auto_reason: '设置里关了自动按口径出具',
}
/** 整题等价时的标签(三个数都来自周报口径) */
export const WHOLE_LABELS = {
s6: caliberLabel(WEEKLY, 'independent', { binding: [{ field: 'week', value: '2026-W37' }], queries: ['q-gmv'] }),
s10: caliberLabel(WEEKLY, 'independent', { binding: [{ field: 'week', value: '2026-W37' }], queries: ['q-gmv'] }),
s12: caliberLabel(WEEKLY, 'independent', { binding: [{ field: 'week', value: '2026-W37' }], queries: ['q-gmv'] }),
}
export const WHOLE = { equivalent: true, caliber: WEEKLY.caliber, workflow_id: WEEKLY.workflow_id, version: 2, level: 'governed',
level_text: '受管', workflow_name: '周报口径', how: 'independent', binding: [{ field: 'week', value: '2026-W37' }],
missing: [], missing_required: [], counts: { caliber: 3, related: 0, reasoning: 0, unknown: 0 } }
/** 对过的一次账(单行:一组对照)。status 可以换 */
export const reconciledOf = (status = 'succeeded') => ({
run_id: 'run-rec-1', status, query: 'q-orders', caliber: ORDERS.caliber, workflow_id: ORDERS.workflow_id, version: 1,
inputs_from: 'checked', rows: null, card: 'reconcile',
groups: [{ label: '订单数', ours: 'ours_0', caliber: 'cal_0', diff: 'diff_0', rel: 'rel_0' }], created_at: '2026-10-15T02:00:00Z',
})
/** 对账卡上四个指标的展示值(/evidence/metrics/reconcile/{id} 的 rendered) */
export const RECONCILE_VALUES = { ours_0: '1,234单', cal_0: '1,180单', diff_0: '54单', rel_0: '4.58%' }
/** 对账运行的指标出处(形状同片段接口:chain、seal),给 EvidenceDrawer 画 */
export const metricDetail = (runId, metric) => ({
run_id: runId, node: 'reconcile', metric, rendered: RECONCILE_VALUES[metric] ?? '—', eid: `ev:metric:${metric}`,
chain: [{ step: 'metric', alias: 'm', metric, name: metric, value: 1, rendered: RECONCILE_VALUES[metric], node_id: 'reconcile',
caliber: '口径对账', version: 'v1', expression: 'cell(nodes.ours_q1, 0, \'orders\')', sealed: true, inputs: [] }],
seal: { sealed: true, ok: true, covered: true }, redacted: { columns: [] },
})
/** 认证队列:三类来源各一项 */
export const QUEUE = {
items: [
{ id: 'shape:aa11', kind: 'shape', source: 'shop', shape: 'SELECT SUM(amount) FROM orders WHERE paid_at >= ? AND status = ?',
runs: 4, conversations: 3, question: '上周销售额是多少',
turns: [{ run_id: 'run-s1', conversation_id: 'c-1', turn_id: 't-1', question: '上周销售额是多少' },
{ run_id: 'run-s2', conversation_id: 'c-2', turn_id: 't-2', question: '这周销售额是多少' }],
params: [{ index: 0, distinct: 3 }], constants: 1, related: [{ caliber: ORDERS.caliber, workflow_id: ORDERS.workflow_id, version: 1, level: 'published' }],
representative: { run_id: 'run-s1', query: 'q-1' }, peers: ['run-s2', 'run-s3'], frozen_into: [] },
{ id: 'cluster:bb22', kind: 'cluster', question: '各门店的退款率', count: 3,
turns: [{ run_id: 'run-c1', turn_id: 't-c1', question: '各门店的退款率' }, { run_id: 'run-c2', turn_id: 't-c2', question: '门店退款率怎么样' }],
frozen_into: [] },
{ id: 'reused:cc33', kind: 'reused', question: '本月新客数', uses: { exports: 2, app: false, extracted: false, canvas: true },
turns: [{ run_id: 'run-r1', conversation_id: 'c-3', turn_id: 't-r1', question: '本月新客数' }],
representative: { run_id: 'run-r1' }, frozen_into: [{ workflow_id: 'wf-new-old', name: '本月新客数(固化的口径草稿)', status: 'draft' }] },
],
considered: { runs: 120, matched: 100, questions: 90 },
threshold: { runs: 3, conversations: 2, exports: 2 },
}