mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:51:58 +00:00
口径账本的界面检查: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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
Reference in New Issue
Block a user