From 9ad6c035d01599cf5a2adb3a8ad83e4412bdfde2 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 20:34:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=A3=E5=BE=84=E8=B4=A6=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=A3=80=E6=9F=A5=EF=BC=9Acheck-ledger=20?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E7=9C=9F=E6=A3=80=E6=9F=A5=EF=BC=8Ccheck-evi?= =?UTF-8?q?dence=E3=80=81check-chat=20=E5=90=84=E5=8A=A0=E4=B8=80=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 都用 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「口径账本」一节:交付了的轮次底部先写「正在比对口径」,按退避再取到比对出结果后写摘要、比完不再取;按口径正式 出具、对账按钮照服务端的动作摆,出具的请求只说按哪份口径,出具过的正式运行挂在轮次下面(档位、打开运行);对账请求带 哪条查询、哪份口径、核对过的参数。 --- frontend/src/store/ledger.ts | 3 + scripts/check-chat.mjs | 112 +++++++++- scripts/check-evidence.mjs | 77 +++++++ scripts/check-ledger.mjs | 404 ++++++++++++++++++++++++++++++++++- scripts/ledger-fixtures.mjs | 114 ++++++++++ 5 files changed, 704 insertions(+), 6 deletions(-) create mode 100644 scripts/ledger-fixtures.mjs diff --git a/frontend/src/store/ledger.ts b/frontend/src/store/ledger.ts index 951700ca..30c3adb3 100644 --- a/frontend/src/store/ledger.ts +++ b/frontend/src/store/ledger.ts @@ -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 diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index e6b073cf..28a140d1 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -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 { diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index bef463b4..84d326b8 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -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() diff --git a/scripts/check-ledger.mjs b/scripts/check-ledger.mjs index eac430e3..62000b94 100644 --- a/scripts/check-ledger.mjs +++ b/scripts/check-ledger.mjs @@ -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) diff --git a/scripts/ledger-fixtures.mjs b/scripts/ledger-fixtures.mjs new file mode 100644 index 00000000..246efcaa --- /dev/null +++ b/scripts/ledger-fixtures.mjs @@ -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 }, +}