mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
前端跟上口径账本内核:时间线认得 ledger.matched;问数据的运行带上属于哪段对话、哪一轮;「在画布里打开」带来源
为什么:这一波合入就进 main,后端新加的封存后事件、来源链要在界面上接得住,不能有订阅了收不到、点了没用的地方
(next-round 02 做法 2、9)。账本的标签、证据面板的「口径」一步、对账在第 4 波接界面,这一波只有接口。
- run/decode.ts:ledger.matched 解读成时间线上一行「比对已发布口径:1 条查询等价已证明,2 条有相关口径」,副标题写明「封存后比对,
不在封存范围内」,事后补比、按需比的另写「按现在的口径回看」;有没比完的标成提醒;不改相位。文案进 lib/terms.ts 的 LEDGER_TEXT
(连同 X10 的叫法:来自口径 / 相关口径 / 这次的推理 / 没有比对,等价已证明 / 助手选用,受管 / 已发布)。
- store/chat.ts:发起运行时带 origin_ref {conversation_id, turn_id}(等开轮请求拿到服务端的轮次 id);服务端核对不过
(origin_ref_invalid:对话刚被删)时不带来源照常发起——来源只是留痕,不挡这一轮的回答。
- pages/ChatPage.tsx:「在画布里打开」带 origin {run_id, conversation_id, turn_id}(这一轮跑过才有);核对不过时不带来源照常新建。
run/AssistantStream.tsx 的 onOpenGraph 多给一个轮次 id(只加参数,别处不受影响)。
- api/client.ts:runs.start 的 origin_ref、workflows.create 的 origin。
- 检查脚本:check-decode 加「口径账本」一节;check-chat 断言发起时带上 origin_ref、「在画布里打开」带上来源;check-report-export
加「口径一步」:没比对过的运行没有这一步,数据块里放一份编的标签断网打开,面板最上面是「口径」、写明封存后比对、遮罩的参数写
「已遮罩」(只打印这一节,不打印真数据)。
验证:tsc、check-copy;check-all --stacks 1 decode chat report-export 全过(1485 项)。
This commit is contained in:
@@ -288,3 +288,13 @@ async def test_only_sealed_query_snapshots_are_compared(monkeypatch):
|
||||
await ledger_match.match_run(row.id, trigger="on_demand")
|
||||
recorded, _ = await matched(row.id)
|
||||
assert [q["status"] for q in recorded.content["queries"]] == ["proven"]
|
||||
|
||||
|
||||
async def test_cells_inside_a_table_are_labelled_the_same_way(monkeypatch):
|
||||
wf = await S.publish()
|
||||
row = await S.explore(monkeypatch, S.EQUIVALENT,
|
||||
text="本周销售额 [[v:Q1.r0.销售额]]。\n\n[[table:Q1 cols=销售额 rows=0-0]]")
|
||||
recorded, _ = await matched(row.id)
|
||||
[labels] = [r["labels"] for r in recorded.content["reports"].values()]
|
||||
assert len(labels) >= 2 and all(label["kind"] == "caliber" and label["caliber"] == f"{wf}@1"
|
||||
for label in labels.values())
|
||||
|
||||
@@ -225,8 +225,14 @@ export const api = {
|
||||
workflows: {
|
||||
list: (opts?: RequestOptions) => get<Workflow[]>('/workflows', opts),
|
||||
get: (id: string) => get<Workflow>(`/workflows/${id}`),
|
||||
create: (body: { name: string; description?: string; graph?: GraphSpec; tags?: string[] }) =>
|
||||
post<Workflow>('/workflows', body),
|
||||
/**
|
||||
* origin:新工作流出自哪次运行、哪段对话、哪一轮(问数据的「在画布里打开」带上,next-round 02 做法 9)。服务端核对运行
|
||||
* 读得到、对话用得了、这一轮属于这段对话,对不上回 422(workflow_origin_invalid)
|
||||
*/
|
||||
create: (body: {
|
||||
name: string; description?: string; graph?: GraphSpec; tags?: string[]
|
||||
origin?: { run_id: string; conversation_id?: string; turn_id?: string }
|
||||
}) => post<Workflow>('/workflows', body),
|
||||
update: (id: string, body: Partial<Workflow> & { note?: string }) =>
|
||||
patch<Workflow>(`/workflows/${id}`, body),
|
||||
remove: (id: string) => del(`/workflows/${id}`),
|
||||
@@ -582,6 +588,11 @@ export const api = {
|
||||
run_class?: 'formal' | 'exploratory'; version?: number
|
||||
/** 运行来源(X3):客户端只能声明 chat、canvas、launcher、api(不传按 api 记),其余由服务端写 */
|
||||
origin?: 'chat' | 'canvas' | 'launcher' | 'api'
|
||||
/**
|
||||
* 问数据的运行属于哪段对话、哪一轮(只在 origin 是 chat 时收,next-round 02 做法 9):服务端核对后写进运行来源、在封存
|
||||
* 范围内。对话、轮次对不上回 422(origin_ref_invalid)
|
||||
*/
|
||||
origin_ref?: { conversation_id: string; turn_id: string }
|
||||
}) => post<Run>('/runs', body),
|
||||
artifacts: (id: string) => get<any[]>(`/runs/${id}/artifacts`),
|
||||
/** 从失败的节点接着跑。graph 只能带改过配置的同一张图,结构必须一致 */
|
||||
|
||||
@@ -4349,6 +4349,34 @@ export const BUNDLE_TEXT = {
|
||||
fileName: (name: string) => `${name}-可验证报告.html`,
|
||||
}
|
||||
|
||||
/**
|
||||
* 口径账本(docs/design/next-round/02-口径账本.md)。探索运行封存之后,服务端在后台拿这次执行的 SQL 和已发布口径的取数比对,
|
||||
* 追加一条 ledger.matched(封存之后,不在封存范围内)。这一波只有时间线上那一行;逐个数的标签、证据面板的「口径」一步、对账在
|
||||
* 第 4 波接上界面(接口 GET /api/runs/{id}/ledger 已就绪)。叫法同总方案 X10:来自口径(受管 / 已发布)· 等价已证明 / 助手选用、
|
||||
* 相关口径、这次的推理
|
||||
*/
|
||||
export const LEDGER_TEXT = {
|
||||
/** 时间线那一行:只说几条查询各是什么情况(服务端的 counts),不带绑定和差异 */
|
||||
timeline: (counts: { proven?: unknown; composed?: unknown; related?: unknown; none?: unknown; skipped?: unknown }) => {
|
||||
const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
|
||||
const parts = [
|
||||
n(counts.proven) ? `${formatNumber(n(counts.proven))} 条查询等价已证明` : '',
|
||||
n(counts.composed) ? `${formatNumber(n(counts.composed))} 条来自助手选用的口径` : '',
|
||||
n(counts.related) ? `${formatNumber(n(counts.related))} 条有相关口径` : '',
|
||||
n(counts.none) ? `${formatNumber(n(counts.none))} 条没有相关的已发布口径` : '',
|
||||
n(counts.skipped) ? `${formatNumber(n(counts.skipped))} 条没有比完` : '',
|
||||
].filter(Boolean)
|
||||
return parts.length ? `比对已发布口径:${parts.join(',')}` : '比对已发布口径'
|
||||
},
|
||||
/** 副标题:比对在封存之后;事后补比、打开时按需比的另写一句 */
|
||||
sub: '封存后比对,不在封存范围内',
|
||||
retro: '按现在的口径回看',
|
||||
/** 标签的四种、认法的两种(第 4 波的证据面板、轮次摘要用同一套) */
|
||||
kind: { caliber: '来自口径', related: '相关口径', reasoning: '这次的推理', unknown: '没有比对' },
|
||||
how: { independent: '等价已证明', composed: '助手选用' },
|
||||
level: { governed: '受管', published: '已发布' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批质询:审批人在批准前就待审的报告提问(选一句、划一段原文,或就整份报告),核查模型只读查库作答。问答写进运行
|
||||
* 记录、随运行一并封存,不改动报告;封存之后只有探索运行还能问,追加在封存之后
|
||||
|
||||
@@ -304,7 +304,7 @@ export function ChatPage() {
|
||||
if (busy || blocked) pick(text)
|
||||
else void send(text)
|
||||
}}
|
||||
onOpenGraph={async (graph, question) => {
|
||||
onOpenGraph={async (graph, question, turnId) => {
|
||||
// 建成一张新工作流,走它自己的地址。以前是 setGraph(graph):只换掉了画布
|
||||
// 上的节点,store 里的 workflow 还是上一次打开的那张图——于是 ⌘S 把那张图
|
||||
// 整张覆盖了(旧版本还在版本表里,界面上却没有入口找回);没打开过图时
|
||||
@@ -313,7 +313,19 @@ export function ChatPage() {
|
||||
if (!(await canLeave('/studio/'))) return
|
||||
try {
|
||||
const name = `问数据:${(question ?? '').trim().slice(0, 24) || '未命名'}`
|
||||
const created = await api.workflows.create({ name, graph })
|
||||
// 新工作流记下出自哪次运行、哪段对话、哪一轮(服务端核对,同「提取成工作流」的来源):沉淀和召回要找得到它。
|
||||
// 这一轮还没运行过(只建了图)的没有来源可记
|
||||
const turn = turns?.find((t) => t.id === turnId)
|
||||
const origin = turn?.run?.id
|
||||
? { run_id: turn.run.id, ...(currentId ? { conversation_id: currentId } : {}),
|
||||
...(currentId && turn.serverId ? { turn_id: turn.serverId } : {}) }
|
||||
: undefined
|
||||
// 来源核对不过(运行刚被删):来源只是留痕,不挡「在画布里打开」,不带来源照常新建
|
||||
const created = await (origin
|
||||
? api.workflows.create({ name, graph, origin }).catch((e) => (
|
||||
e instanceof ApiError && e.code === 'workflow_origin_invalid'
|
||||
? api.workflows.create({ name, graph }) : Promise.reject(e)))
|
||||
: api.workflows.create({ name, graph }))
|
||||
void refreshCatalog()
|
||||
navigate(`/studio/${created.id}`, leavePass())
|
||||
toast.ok('已在画布中新建工作流,原有工作流不受影响')
|
||||
|
||||
@@ -191,7 +191,7 @@ export function AssistantStream({
|
||||
empty?: ReactNode
|
||||
/** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */
|
||||
approvalsFor?: (turn: StreamTurn) => ReactNode
|
||||
onOpenGraph?: (graph: any, question?: string) => void
|
||||
onOpenGraph?: (graph: any, question?: string, turnId?: string) => void
|
||||
footer?: ReactNode
|
||||
/** 每一轮结束之后的动作槽:重试、接着跑、让 Copilot 再修……由页面决定放什么 */
|
||||
renderTurnActions?: (turn: StreamTurn, index: number) => ReactNode
|
||||
@@ -443,7 +443,7 @@ function flash(el: HTMLElement, kind: 'failed' | 'focus'): () => void {
|
||||
|
||||
function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: {
|
||||
turn: StreamTurn; last: boolean; approvals?: ReactNode
|
||||
onOpenGraph?: (graph: any, question?: string) => void
|
||||
onOpenGraph?: (graph: any, question?: string, turnId?: string) => void
|
||||
onFollowUp?: (text: string) => void
|
||||
}) {
|
||||
const { dense } = useContext(Ctx)
|
||||
@@ -551,7 +551,7 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: {
|
||||
|
||||
{!!turn.graph?.nodes?.length && (
|
||||
<GraphPeek graph={turn.graph} note={turn.graphNote}
|
||||
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
|
||||
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question, turn.id))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ABSENCE_CLAIM_TEXT, ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, ACTION_LOG_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, LOOK_TEXT, MERGE_TEXT,
|
||||
ORIGIN_TEXT, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, TASK_KIND_LABEL, TASK_LOG_TEXT, TASK_STATUS_LABEL, TASK_TEXT, TYPE_LABEL, originLabel,
|
||||
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, JUDGE_TRUNCATED_TEXT, MASKED_CELL_TEXT, NODE_TYPE_LABEL,
|
||||
VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT,
|
||||
VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT, LEDGER_TEXT,
|
||||
claimTally,
|
||||
evidenceTally, issuanceLabel, nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
@@ -2402,6 +2402,20 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
break
|
||||
}
|
||||
|
||||
case 'ledger.matched': {
|
||||
// 口径账本:探索运行封存之后,服务端在后台拿这次执行的 SQL 和已发布口径比对过了。追加在封存之后、不在封存范围内;
|
||||
// 事件里只有几条查询各是什么情况,逐个数的标签走 GET /runs/{id}/ledger
|
||||
const counts = d.counts && typeof d.counts === 'object' ? d.counts : {}
|
||||
const retro = typeof d.trigger === 'string' && d.trigger !== 'sealed'
|
||||
out.push({
|
||||
id: `lm-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_matched',
|
||||
level: typeof counts.skipped === 'number' && counts.skipped > 0 ? 'warn' : 'info',
|
||||
title: LEDGER_TEXT.timeline(counts),
|
||||
sub: [LEDGER_TEXT.sub, retro ? LEDGER_TEXT.retro : ''].filter(Boolean).join(' · '),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.inquiry':
|
||||
// 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、
|
||||
// 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层
|
||||
|
||||
@@ -1628,10 +1628,15 @@ async function launch(
|
||||
) {
|
||||
const { patch } = flow
|
||||
try {
|
||||
const run = await api.runs.start({
|
||||
graph, input: fillInput(graph, question, historyOf(flow.conversationId, flow.turnId)),
|
||||
origin: 'chat',
|
||||
})
|
||||
// 这一轮在服务端的 id(开轮请求可能还在路上,等它):运行记下属于哪段对话、哪一轮(服务端核对后封存进运行来源)。
|
||||
// 开轮没成的(serverId 为空)不带,运行照常发起
|
||||
const turnId = findTurn(flow.conversationId, flow.turnId)?.serverId ?? (await serverIds.get(flow.turnId)) ?? null
|
||||
const body = { graph, input: fillInput(graph, question, historyOf(flow.conversationId, flow.turnId)), origin: 'chat' as const }
|
||||
// 来源核对不过(对话刚被删、这一轮已不在):来源只是留痕,不挡这一轮的回答,不带来源照常发起
|
||||
const run = await (turnId
|
||||
? api.runs.start({ ...body, origin_ref: { conversation_id: flow.conversationId, turn_id: turnId } })
|
||||
.catch((e) => (e instanceof ApiError && e.code === 'origin_ref_invalid' ? api.runs.start(body) : Promise.reject(e)))
|
||||
: api.runs.start(body))
|
||||
// 启动请求在路上时用户点了停止或重试:运行已经起来了,得追一个取消过去
|
||||
if (!flow.alive()) {
|
||||
void api.runs.cancel(run.id).catch(() => undefined)
|
||||
|
||||
@@ -628,7 +628,7 @@ async function fakeApi(route) {
|
||||
: conversation.includes('步数用满') ? `run-steps-${++runSeq}`
|
||||
: conversation.includes('照服务端的决定') ? `run-decide-${++runSeq}`
|
||||
: `run-busy-${++runSeq}`
|
||||
log.runStarts.push({ id, graph: b.graph })
|
||||
log.runStarts.push({ id, graph: b.graph, origin_ref: b.origin_ref })
|
||||
return json({ id, status: 'queued', run_class: 'exploratory', output: {}, usage: {} })
|
||||
}
|
||||
m = path.match(/^\/runs\/([^/]+)\/(cancel|continue)$/)
|
||||
@@ -1455,6 +1455,10 @@ for (const theme of THEMES) {
|
||||
const started = log.runStarts.slice(startsBefore).at(-1)
|
||||
check('发起的图里 agent 步数是 24', started?.graph?.nodes?.find((n) => n.type === 'agent')?.data?.config?.max_steps === 24,
|
||||
JSON.stringify(started?.graph?.nodes?.map((n) => n.data?.config)))
|
||||
// 口径账本的来源链(next-round 02 做法 9):运行记下属于哪段对话、哪一轮(服务端核对后封存进运行来源)
|
||||
const server = await page.evaluate(() => window.__chat.getState().byConversation.c0histf.at(-1).serverId)
|
||||
check('发起时带上这一轮属于哪段对话、哪一轮', started?.origin_ref?.conversation_id === 'c0histf'
|
||||
&& !!server && started?.origin_ref?.turn_id === server, JSON.stringify(started?.origin_ref))
|
||||
check('上一次留档:第 1 次尝试 · 结论不可用', await shows(page, '第 1 次尝试'))
|
||||
await page.screenshot({ path: `${SHOTS}/retried-${theme}.png` })
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
@@ -2442,6 +2446,8 @@ for (const theme of THEMES) {
|
||||
const asks = await page.evaluate(() => window.__asks)
|
||||
check('说了放弃:建一个、跳过去,整条路只问这一遍', creates.length === 1 && asks === 2
|
||||
&& new URL(page.url()).pathname === '/studio/wf-chat-open', `建了 ${creates.length} 个 · 一共问了 ${asks} 遍(取消那次算一遍)`)
|
||||
check('建的工作流带上来源:哪次运行、哪段对话、哪一轮(服务端核对)', JSON.stringify(creates[0]?.origin)
|
||||
=== JSON.stringify({ run_id: 'run-b1', conversation_id: 'c0idleb', turn_id: 'tb1' }), JSON.stringify(creates[0]?.origin))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
@@ -1823,6 +1823,31 @@ await section('结论句裁判(可点击证据第四期):裁判调用单
|
||||
odd.find((s) => s.code === 'report_checked')?.title)
|
||||
})
|
||||
|
||||
await section('口径账本:封存后比对已发布口径(ledger.matched)', async () => {
|
||||
// next-round 02 做法 2:探索运行封存之后,服务端在后台比对已发布口径,追加一条 ledger.matched(只有各种状态几条)
|
||||
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
|
||||
const run = (data) => flatten(mod.decodeRun([
|
||||
E(1, 'run.started', null, {}), E(2, 'node.started', 'q', { node_type: 'tool' }), E(3, 'node.finished', 'q', {}),
|
||||
E(4, 'run.finished', null, { status: 'succeeded' }),
|
||||
E(5, 'ledger.matched', null, { artifact: 'cm-1', manifest_hash: 'h', algo: '1', post_seal: true, ...data }),
|
||||
]))
|
||||
const all = run({ counts: { proven: 1, composed: 0, related: 2, none: 0, skipped: 0 }, trigger: 'sealed' })
|
||||
check('认得它:不是「未识别的事件」', !all.some((s) => s.title === '未识别的事件'))
|
||||
const lm = all.find((s) => s.code === 'ledger_matched')
|
||||
check('一行:几条等价已证明、几条有相关口径,写明封存后比对、不在封存范围内',
|
||||
lm?.title === '比对已发布口径:1 条查询等价已证明,2 条有相关口径' && lm?.sub === '封存后比对,不在封存范围内'
|
||||
&& lm?.level === 'info' && lm?.kind === 'note', `${lm?.title} / ${lm?.sub} / ${lm?.level}`)
|
||||
const late = run({ counts: { proven: 0, related: 0, none: 1, skipped: 1 }, trigger: 'backfill' }).find((s) => s.code === 'ledger_matched')
|
||||
check('事后补比的写明按现在的口径回看;没比完的是提醒', late?.title === '比对已发布口径:1 条没有相关的已发布口径,1 条没有比完'
|
||||
&& late?.sub === '封存后比对,不在封存范围内 · 按现在的口径回看' && late?.level === 'warn', `${late?.title} / ${late?.sub} / ${late?.level}`)
|
||||
const odd = run({ counts: 'x' }).find((s) => s.code === 'ledger_matched')
|
||||
check('缺字段:只说比对过,不出 undefined、不瞎填数', odd?.title === '比对已发布口径' && !/undefined|NaN/.test(`${odd?.title}${odd?.sub}`),
|
||||
odd?.title)
|
||||
const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.matched', null, {})]
|
||||
.reduce((s, ev) => mod.nextPhase(s, ev), { phase: 'running', awaiting: false })
|
||||
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
|
||||
})
|
||||
|
||||
await section('术语', async () => {
|
||||
// 没起名的节点退到类型名,类型名跟全站同一张表
|
||||
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])
|
||||
|
||||
@@ -238,6 +238,74 @@ await section('断网打开', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 口径一步(口径账本,next-round 02 做法 3)
|
||||
|
||||
await section('口径一步', async () => {
|
||||
if (skipped()) return
|
||||
const data = dataOf(html)
|
||||
// 没比对过的运行:数据块里没有 ledger,面板里也没有「口径」一步
|
||||
if (!data.ledger) {
|
||||
const plain = await openFile(writePage('report-plain.html', html))
|
||||
try {
|
||||
const cited = plain.page.locator('#report button[data-seg][data-state="deterministic"]').first()
|
||||
if (await cited.count()) {
|
||||
await cited.click()
|
||||
await plain.page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
check('没比对过的运行:面板里没有「口径」一步', await plain.page.locator('#detail [data-step="ledger"]').count() === 0)
|
||||
}
|
||||
} finally {
|
||||
await plain.ctx.close()
|
||||
}
|
||||
}
|
||||
// 服务端比对过的运行面板里是什么样:数据块里放一份口径账本给的标签(来自口径、相关口径各一个),断网打开点开看。
|
||||
// 只改数据块(脚本的哈希不变,内容安全策略照样放行)
|
||||
const keys = Object.keys(data.refs ?? {})
|
||||
if (!keys.length) return note('报告里没有可点的片段,口径一步不查')
|
||||
const labels = {
|
||||
[keys[0]]: { kind: 'caliber', how: 'independent', caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3, level: 'governed',
|
||||
workflow_name: '周销售', level_text: '受管', column: 'gmv',
|
||||
binding: [{ field: 'start', value: '2026-09-01' }, { field: 'phone', masked: true, value: '已遮罩' }] },
|
||||
...(keys[1] ? { [keys[1]]: { kind: 'related', related: [{ caliber: 'wf-x@3', workflow_id: 'wf-x', version: 3,
|
||||
workflow_name: '周销售', level_text: '受管', diff: { items: [{ aspect: 'filter', text: '口径只保留已支付的行,这次没有这个条件' }] } }] } } : {}),
|
||||
}
|
||||
const ledger = { labels, note: '封存后比对,不在封存范围内', retro: true, retro_note: '按现在的口径回看' }
|
||||
const patched = html.replace(DATA_RE, (_, a, body, c) => a + JSON.stringify({ ...JSON.parse(body), ledger })
|
||||
.replace(/</g, '\\u003c') + c)
|
||||
const { ctx, page, errors } = await openFile(writePage('report-ledger.html', patched))
|
||||
try {
|
||||
const first = page.locator(`#report button[data-seg="${keys[0]}"]`).first()
|
||||
if (!(await first.count())) return note('数据块里的片段在正文里找不到按钮,口径一步不查')
|
||||
// 只打印「口径」这一节(数据块里放的是编的标签):面板别处是真数据,不打印
|
||||
const ledgerText = async (seg) => {
|
||||
await seg.click()
|
||||
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
|
||||
const part = page.locator('#detail [data-step="ledger"]')
|
||||
const text = (await part.count()) ? await part.innerText() : ''
|
||||
const order = await page.evaluate(() => [...document.querySelectorAll('#detail-body > section')].map((s) => s.dataset.step ?? ''))
|
||||
await page.keyboard.press('Escape')
|
||||
return { text, order }
|
||||
}
|
||||
const { text, order } = await ledgerText(first)
|
||||
check('面板最上面多「口径」一步:来自口径「周销售」v3(受管)、等价已证明', /口径/.test(text)
|
||||
&& text.includes('来自口径「周销售」v3(受管)') && text.includes('等价已证明'), text.replace(/\n/g, ' '))
|
||||
check('参数照给,遮罩的写「已遮罩」', text.includes('start = 「2026-09-01」') && text.includes('phone = 已遮罩'))
|
||||
check('写明封存后比对、不在封存范围内,事后补比的写按现在的口径回看',
|
||||
text.includes('封存后比对,不在封存范围内') && text.includes('按现在的口径回看'))
|
||||
check('「口径」一步排在出处链前面', order[1] === 'ledger', order.slice(0, 2).join(','))
|
||||
if (keys[1]) {
|
||||
const second = page.locator(`#report button[data-seg="${keys[1]}"]`).first()
|
||||
if (await second.count()) {
|
||||
const other = (await ledgerText(second)).text
|
||||
check('相关口径:写明是哪一份、差在哪', other.includes('相关口径') && other.includes('口径只保留已支付的行'),
|
||||
other.replace(/\n/g, ' '))
|
||||
}
|
||||
}
|
||||
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 不执行脚本
|
||||
|
||||
await section('不执行脚本', async () => {
|
||||
|
||||
Reference in New Issue
Block a user