diff --git a/frontend/src/api/caliberLedger.ts b/frontend/src/api/caliberLedger.ts index 5e9b8cf6..aa78ac75 100644 --- a/frontend/src/api/caliberLedger.ts +++ b/frontend/src/api/caliberLedger.ts @@ -3,7 +3,8 @@ * * - run:GET /api/runs/{id}/ledger,一次运行里每个数的口径标签、整题判断、能做的动作、出具过的正式运行、对过的账; * - issue:按口径正式出具(参数只由服务端从比对工件里取,这里只能选「按哪份口径」); - * - reconcile:口径对账(点开才跑一次确定性派生的只读图); + * - reconcile:口径对账(点开才跑一次确定性派生的只读图);feedback:对账结果上的「说明有用 / 看不懂」(记成对账运行上的 + * 封存后事件,统计 E2 数它); * - certifyQueue / dismiss:可沉淀的问题、「不是口径」(搭建者及以上); * - freeze:固化成口径草稿(/copilot/from-run 的 freeze 选项,报告引用的查询变成固定 SQL 的取数节点)。 * @@ -11,7 +12,7 @@ * 请求函数从 ./http 导入,不从 ./client 导入(client.ts 要导入这个文件,反过来就成了循环)。 */ import type { - CertifyQueue, FreezeOut, LedgerIssueOut, LedgerReconcileIn, LedgerReconcileOut, RunLedger, + CertifyQueue, FreezeOut, LedgerFeedbackOut, LedgerFeedbackVerdict, LedgerIssueOut, LedgerReconcileIn, LedgerReconcileOut, RunLedger, } from '../typesLedger' import { get, post, type RequestOptions } from './http' @@ -21,6 +22,8 @@ export const caliberLedger = { run: (runId: string, opts?: RequestOptions) => get(`/runs/${encodeURIComponent(runId)}/ledger`, opts), issue: (runId: string, body: { workflow_id: string; version: number }) => post(`${at(runId)}/issue`, body), reconcile: (runId: string, body: LedgerReconcileIn) => post(`${at(runId)}/reconcile`, body), + feedback: (runId: string, reconcileId: string, verdict: LedgerFeedbackVerdict) => + post(`${at(runId)}/reconcile/${encodeURIComponent(reconcileId)}/feedback`, { verdict }), certifyQueue: (opts?: RequestOptions) => get('/ledger/certify-queue', opts), dismiss: (body: { item_id: string; kind?: string; note: string }) => post<{ ok: boolean; item_id: string }>('/ledger/certify-queue/dismiss', body), diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index 20543f3d..5be516de 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -113,6 +113,34 @@ export const LEDGER_UI = { less: '收起', original: (text: string) => `原答案里写的是 ${text};对账时按同样的 SQL 重新取数,数据有变化时两者可能不同`, openRun: '打开对账运行', + /** 「差在哪」的说明有没有帮人看懂(统计 E2 数它,衡量差异说明写得好不好) */ + feedback: { + ask: '「差在哪」的说明有帮助吗?', + useful: '说明有用', + unclear: '看不懂', + mine: (v: 'useful' | 'unclear') => `你选了「${v === 'useful' ? '说明有用' : '看不懂'}」,可以改`, + counts: (useful: number, unclear: number) => [useful ? `${formatNumber(useful)} 人觉得有用` : '', + unclear ? `${formatNumber(unclear)} 人看不懂` : ''].filter(Boolean).join(' · '), + saved: '已记下你的反馈', + readOnly: '对话在回收站中,恢复后才能反馈', + }, + }, + + // ---- 答案身上的召回标记(问数据的轮次、证据面板「口径」一步) ---- + recall: { + /** 口径升版后在同一份数据上重算过、数没变 */ + checked: (caliber: string) => `已按口径${caliber} 核对,不影响`, + /** 依据有变:目录里影响数字的项被驳回或修改、出现新码值、处理变化时改了目录(目录类),表或列被删、类型变了(表结构) */ + basis: (what: 'catalog' | 'structure' | 'other', items: number) => + `${what === 'structure' ? '依据的表结构有变' : what === 'catalog' ? '依据的数据目录有变' : '答案的依据有变'}` + + `${items ? `(${formatNumber(items)} 项)` : ''}`, + newErrors: (n: number) => `按新目录检查新出现 ${formatNumber(n)} 条错误级问题`, + basisHint: '答案本身没有改:它依据的数据目录或表结构在答案之后有变化,请核对是否还能用', + /** 看的这个人手上说的是这个答案的 recall 待办:口径升版后数变了的、依据有变检查出新问题的 */ + taskCaliber: (version: number | null | undefined) => `口径升到 ${version != null ? `v${version}` : '新版本'},这个答案用到的数变了`, + taskBasis: '依据有变,按新目录检查出了新的问题', + taskOther: '有一条召回待办', + task: '去待办处理', }, // ---- 问数据:轮次底部的摘要 ---- diff --git a/frontend/src/run/LedgerReconcile.tsx b/frontend/src/run/LedgerReconcile.tsx index a07905e6..aa8f0ab1 100644 --- a/frontend/src/run/LedgerReconcile.tsx +++ b/frontend/src/run/LedgerReconcile.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import clsx from 'clsx' import { Link } from 'react-router-dom' import { Scale } from 'lucide-react' import { ApiError, api } from '../api/client' @@ -9,8 +10,8 @@ import { LEDGER_UI } from '../lib/termsLedger' import { useAtLeast } from '../store/auth' import { useLedger } from '../store/ledger' import type { - LedgerField, LedgerPairOptions, LedgerReconcileAction, LedgerReconcileIn, LedgerReconciled, LedgerReconcileGroup, - LedgerRelated, RunLedger, + LedgerFeedback, LedgerFeedbackVerdict, LedgerField, LedgerPairOptions, LedgerReconcileAction, LedgerReconcileIn, LedgerReconciled, + LedgerReconcileGroup, LedgerRelated, RunLedger, } from '../typesLedger' import { BindingList, DiffList, caliberOf } from './LedgerStep' @@ -26,6 +27,8 @@ import { BindingList, DiffList, caliberOf } from './LedgerStep' * - 结果(ReconcileResults):每一组对照写按你的问法、按口径、差值(另附相对差),每个数按 * /runs/{对账运行}/evidence/metrics/{对账卡}/{指标} 取值,点一下打开它的出处链(apps/EvidenceDrawer,和应用页点数同一套)。 * 哪几个指标是一组由服务端给(reconciled.groups),这里不按命名规则拼。 + * - 反馈(FeedbackRow):对账跑完之后问一句「差在哪」的说明有没有帮人看懂——「说明有用 / 看不懂」,记成对账运行上的封存后 + * 事件(服务端 ledger/feedback),统计 E2 数它。同一个人改主意只算最后一次;看的人选过哪个、各几个人都由服务端给。 */ const NEEDS_PAIRS = 'ledger_reconcile_needs_pairs' @@ -229,7 +232,55 @@ function GroupRow({ runId, card, group, onOpen }: { ) } -function ReconcileCard({ item, data, original }: { item: LedgerReconciled; data?: RunLedger; original?: string }) { +/** + * 「差在哪」的说明有没有帮人看懂:说明有用 / 看不懂。按下的那个是看的人最后一次给的(服务端的 mine),可以改;回来的是这次 + * 对账现在的反馈,账本再取一次(别处挂着的同一张卡跟着变) + */ +function FeedbackRow({ runId, item, readOnly }: { runId: string; item: LedgerReconciled; readOnly: boolean }) { + const refresh = useLedger((s) => s.refresh) + const [fb, setFb] = useState(item.feedback) + const [busy, setBusy] = useState(null) + useEffect(() => { setFb(item.feedback) }, [item.feedback]) + const F = LEDGER_UI.result.feedback + const give = async (verdict: LedgerFeedbackVerdict) => { + setBusy(verdict) + try { + const out = await api.caliberLedger.feedback(runId, item.run_id, verdict) + setFb(out.feedback) + toast.ok(F.saved) + refresh(runId) + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + const counts = fb ? F.counts(fb.useful, fb.unclear) : '' + return ( +
+ {F.ask} + {(['useful', 'unclear'] as const).map((v) => ( + + ))} + {fb?.mine && {F.mine(fb.mine)}} + {counts && {counts}} +
+ ) +} + +/** 这张对账卡对照的那份相关口径「差在哪」(问数据的轮次里卡片上方没有这一段,反馈要有说的是什么) */ +function whereOf(data: RunLedger | undefined, item: LedgerReconciled): LedgerRelated | undefined { + return (data?.queries ?? []).find((q) => q.artifact === item.query)?.candidates + .find((c) => c.caliber === item.caliber && c.status === 'related') +} + +function ReconcileCard({ runId, item, data, original, readOnly, showWhere }: { + runId: string; item: LedgerReconciled; data?: RunLedger; original?: string; readOnly: boolean; showWhere: boolean +}) { const builder = useAtLeast('builder') const [all, setAll] = useState(false) const [target, setTarget] = useState(null) @@ -240,6 +291,7 @@ function ReconcileCard({ item, data, original }: { item: LedgerReconciled; data? const R = LEDGER_UI.result const state = item.status === 'succeeded' ? 'succeeded' : item.status === 'failed' ? 'failed' : item.status === 'cancelled' ? 'cancelled' : 'running' + const where = showWhere ? whereOf(data, item) : undefined return (
@@ -264,20 +316,33 @@ function ReconcileCard({ item, data, original }: { item: LedgerReconciled; data? {original &&

{R.original(original)}

} )} + {state === 'succeeded' && showWhere && where && ( +
+
{LEDGER_UI.step.where}
+ +
+ )} + {state === 'succeeded' && } setTarget(null)} />
) } -/** 对过的账(新的在前)。original:从证据面板打开时,正文里这个数写的是什么 */ -export function ReconcileResults({ items, data, original }: { - runId: string; items: LedgerReconciled[]; data?: RunLedger; original?: string +/** + * 对过的账(新的在前)。runId 是答案的运行(反馈记在它名下的对账上);original:从证据面板打开时,正文里这个数写的是什么; + * showWhere:卡片上方没有「差在哪」时(问数据的轮次)卡里写一份,反馈问的就是它;readOnly:对话在回收站里,只看不反馈 + */ +export function ReconcileResults({ runId, items, data, original, showWhere = false, readOnly = false }: { + runId: string; items: LedgerReconciled[]; data?: RunLedger; original?: string; showWhere?: boolean; readOnly?: boolean }) { const sorted = useMemo(() => items, [items]) if (!sorted.length) return null return (
- {sorted.map((item) => )} + {sorted.map((item) => ( + + ))}
) } diff --git a/frontend/src/run/LedgerStep.tsx b/frontend/src/run/LedgerStep.tsx index ce95db74..cf511dbf 100644 --- a/frontend/src/run/LedgerStep.tsx +++ b/frontend/src/run/LedgerStep.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { BookMarked, RotateCw, Scale } from 'lucide-react' +import { BookMarked, CircleCheck, Inbox, RotateCw, Scale, TriangleAlert } from 'lucide-react' import { ApiError, api } from '../api/client' import { Spinner, StatusBadge, toast } from '../components/ui' import { errorMessage } from '../lib/errors' @@ -9,7 +9,8 @@ import { LEDGER_UI, caliberName } from '../lib/termsLedger' import { useAtLeast } from '../store/auth' import { useLedger, useRunLedger, type LedgerSlot } from '../store/ledger' import type { - LedgerBinding, LedgerIssueAction, LedgerIssued, LedgerLabel, LedgerReconcileAction, LedgerRelated, RunLedger, SegmentLedger, + LedgerBinding, LedgerIssueAction, LedgerIssued, LedgerLabel, LedgerRecallMarks, LedgerReconcileAction, LedgerRelated, RunLedger, + SegmentLedger, } from '../typesLedger' import { ReconcileDialog, ReconcileResults } from './LedgerReconcile' @@ -19,7 +20,9 @@ import { ReconcileDialog, ReconcileResults } from './LedgerReconcile' * · 按口径正式出具;相关口径——差在哪 + 看差多少(口径对账:核对参数 → 发起 → 三个数各自点得开);这次的推理—— * 写「没有相关的已发布口径」;没比对的写原因。比对还没出结果时写「正在比对口径」,store/ledger 按退避轮询, * 等满了还没结论照实说、给「再取一次」; - * - 按口径正式出具那一项(IssueControl)和出具过的正式运行(IssuedList):问数据的轮次摘要用同一份。 + * - 按口径正式出具那一项(IssueControl)和出具过的正式运行(IssuedList):问数据的轮次摘要用同一份; + * - 答案身上的召回标记(RecallMarks):「已按口径「X」vN 核对,不影响」、依据有变、说的是这个答案的召回待办(链到待办), + * 「口径」一步和问数据的轮次底部用同一份。 * * 界面层不放逻辑:标签、能不能出具、能不能自动、不能的原因、要不要先核对参数都是服务端给的(GET /runs/{id}/ledger), * 这里只照着画。比对在封存之后做,脚注写明「封存后比对,不在封存范围内」,事后补比的另写按哪种口径回看。 @@ -93,6 +96,60 @@ export function LedgerWaiting({ slot, runId, compact = false }: { slot: LedgerSl ) } +/** 依据有变的几种归成两类说:目录(目录里影响数字的项、新码值、处理变化时改了目录)和表结构;认不出的只说依据有变,不猜 */ +const BASIS_GROUP: Record = { + catalog: 'catalog', values: 'catalog', catalog_edit: 'catalog', structure: 'structure', +} + +/** 有没有要画的召回标记 */ +export const hasRecallMarks = (recall: LedgerRecallMarks | undefined) => + !!(recall?.checked?.some((c) => !c.changed) || recall?.basis?.length || recall?.tasks?.length) + +/** + * 答案身上的召回标记(细案做法 7):「已按口径「X」v3 核对,不影响」、「依据的数据目录有变(2 项)」,看的这个人手上有说的 + * 是这个答案的 recall 待办时链到待办。标记是服务端从这次运行上封存后追加的 ledger.recalled、ledger.basis_changed 读出来的 + * (ledger/recall.answer_marks),这里只照着写;同一份口径核对过几次只写最近的那一版,依据有变按两类各写一行、项数相加 + */ +export function RecallMarks({ recall }: { recall?: LedgerRecallMarks }) { + if (!hasRecallMarks(recall)) return null + const R = LEDGER_UI.recall + const checked = new Map[number]>() + for (const c of recall?.checked ?? []) if (!c.changed) checked.set(String(c.workflow_id ?? ''), c) + const basis = new Map<'catalog' | 'structure' | 'other', { items: number; errors: number }>() + for (const b of recall?.basis ?? []) { + const group = BASIS_GROUP[String(b.kind ?? '')] ?? 'other' + const cur = basis.get(group) ?? { items: 0, errors: 0 } + basis.set(group, { items: cur.items + (b.items ?? 0), errors: cur.errors + (b.new_errors ?? 0) }) + } + const tasks = recall?.tasks ?? [] + return ( +
    + {[...checked.values()].map((c) => ( +
  • + + {R.checked(caliberName(c.workflow_name, c.to_version))} +
  • + ))} + {[...basis.entries()].map(([group, b]) => ( +
  • + + + {R.basis(group, b.items)} + {b.errors > 0 && · {R.newErrors(b.errors)}} + +
  • + ))} + {tasks.map((t) => ( +
  • + + {t.reason === 'caliber' ? R.taskCaliber(t.to_version) : t.reason === 'catalog' ? R.taskBasis : R.taskOther} + {R.task} +
  • + ))} +
+ ) +} + /** 比对的来历:封存后比对,不在封存范围内;事后补比的写按哪种口径回看 */ export function LedgerFootnote({ data }: { data: RunLedger }) { return ( @@ -251,9 +308,14 @@ export function LedgerPart({ runId, runClass, report, segId, segText, segLedger, return (

{LEDGER_UI.step.unreadable(data.reason ?? '')}

+
) } + // 没比对的答案照样可能被标依据有变:标记说的是整个答案,这一步只写它 + if (data && hasRecallMarks(data.recall)) { + return
+ } return null } const queries = label.queries ?? (label.query ? [label.query] : []) @@ -304,7 +366,7 @@ export function LedgerPart({ runId, runClass, report, segId, segText, segLedger, {LEDGER_UI.reconcile.button} ) : r.comparable === false ?

{r.reason || LEDGER_UI.step.notComparable}

: null} - {!!done.length && } + {!!done.length && }
) })} @@ -316,6 +378,7 @@ export function LedgerPart({ runId, runClass, report, segId, segText, segLedger, {label.kind === 'unknown' && (

{LEDGER_TEXT.kind.unknown}{label.reason ? `:${label.reason}` : ''}

)} + {hasRecallMarks(data?.recall) &&
} {data && } {dialog && data && ( setDialog(null)} /> diff --git a/frontend/src/run/LedgerTurn.tsx b/frontend/src/run/LedgerTurn.tsx index ab5e1e12..fcb7bedd 100644 --- a/frontend/src/run/LedgerTurn.tsx +++ b/frontend/src/run/LedgerTurn.tsx @@ -4,13 +4,17 @@ import { LEDGER_TEXT } from '../lib/terms' import { LEDGER_UI } from '../lib/termsLedger' import { useRunLedger } from '../store/ledger' import type { LedgerIssueAction, LedgerReconcileAction, LedgerRelated, RunLedger } from '../typesLedger' -import { IssueControl, IssuedList, LedgerFootnote, LedgerWaiting, caliberOf, ledgerEnabled, ledgerErrorShown } from './LedgerStep' +import { + IssueControl, IssuedList, LedgerFootnote, LedgerWaiting, RecallMarks, caliberOf, hasRecallMarks, ledgerEnabled, ledgerErrorShown, +} from './LedgerStep' import { ReconcileDialog, ReconcileResults } from './LedgerReconcile' /** * 问数据轮次底部的口径账本(docs/design/next-round/02-口径账本.md 做法 11):一行摘要(「3 个数来自口径「周销售」v3, * 1 个数是这次的推理」)和对应的动作——整题等价时按口径正式出具(能自动的写正在自动出具),有相关口径时对账; - * 正式出具的结果(自动的、一键的)挂在轮次下面、点开去运行;对过的账一张结果卡片,三个数各自点得开。 + * 正式出具的结果(自动的、一键的)挂在轮次下面、点开去运行;对过的账一张结果卡片,三个数各自点得开,卡里写「差在哪」、 + * 问说明有没有帮助(说明有用 / 看不懂)。答案身上的召回标记(「已按口径「X」vN 核对,不影响」、依据有变、说的是这个答案的 + * 召回待办)也写在这里:以前只在「过程」里点开才看得到,使用者又进不了记录页;没比对的答案照样可能带着,单独写一行。 * * 比对在封存之后做:轮次刚交付时写「正在比对口径」,store/ledger 按退避再取,等满了还没结论照实说、给「再取一次」。 * 不靠实时流(ledger.matched 追加时这次运行的事件总线已经关了)。界面层不放逻辑:每个数的标签、整题判断、能不能出具、 @@ -62,12 +66,21 @@ export function LedgerTurn({ runId, runClass, done, readOnly = false }: { ) } - if (!data || data.state !== 'matched' || !summary) return null + if (!data) return null + // 召回标记(依据有变、已按 vN 核对)说的是整个答案:没比对过、不比对的答案照样可能带着,单独写一行 + const marked = hasRecallMarks(data.recall) + if (data.state !== 'matched' || !summary) { + return marked ? ( +
+ +
+ ) : null + } const issue = (data.actions ?? []).find((a): a is LedgerIssueAction => a.kind === 'issue') const reconciles = reconcileEntries(data) const issued = data.issued ?? [] const reconciled = data.reconciled ?? [] - if (!summary.total && !issued.length && !reconciled.length) return null + if (!summary.total && !issued.length && !reconciled.length && !marked) return null const whole = data.whole return (
@@ -83,6 +96,7 @@ export function LedgerTurn({ runId, runClass, done, readOnly = false }: { {LEDGER_UI.turn.whole(caliberOf(whole), LEDGER_TEXT.how[whole.how === 'composed' ? 'composed' : 'independent'])}

)} + {(issue || reconciles.length > 0) && (
{issue && } @@ -96,7 +110,7 @@ export function LedgerTurn({ runId, runClass, done, readOnly = false }: {
)} - {reconciled.length > 0 && } + {reconciled.length > 0 && } {dialog && setDialog(null)} />}
diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index 982c2dfd..ae5e868a 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -182,6 +182,44 @@ export interface LedgerReconciled { card?: string groups?: LedgerReconcileGroup[] created_at?: string | null + /** 「说明有用 / 看不懂」(服务端 ledger/feedback.feedback_of):看的这个人最后一次给的、各几个人 */ + feedback?: LedgerFeedback +} + +export type LedgerFeedbackVerdict = 'useful' | 'unclear' + +/** 对账结果上的反馈:mine 是看的这个人最后一次给的(没给过为空),useful / unclear 是各几个人(每人只算最后一次) */ +export interface LedgerFeedback { + mine?: LedgerFeedbackVerdict | null + useful: number + unclear: number +} + +/** POST /api/ledger/answers/{run}/reconcile/{对账运行}/feedback 的答复 */ +export interface LedgerFeedbackOut { + run_id: string + feedback: LedgerFeedback +} + +/** + * 答案身上的召回标记(GET /runs/{id}/ledger 的 recall,服务端 ledger/recall.answer_marks;细案做法 7「依据有变」「已按 vN + * 核对,不影响」)。几种 state 都可能带:没比对过、不比对的答案照样可能被标依据有变。不放数、表名、取值 + */ +export interface LedgerRecallMarks { + /** 口径升版后召回重算、数没变:在这个答案上记的「已按 vN 核对,不影响」 */ + checked?: { + workflow_id?: string | null + workflow_name?: string | null + from_version?: number | null + to_version?: number | null + recall_run?: string | null + changed?: boolean + at?: string | null + }[] + /** 依据有变:kind 是 catalog(目录里影响数字的项被驳回或修改)/ structure(表结构)/ values(新码值)/ catalog_edit */ + basis?: { kind?: string | null; items?: number; new_errors?: number; at?: string | null }[] + /** 看的这个人手上、说的是这个答案的 recall 待办(还没处理的) */ + tasks?: { id: string; reason?: string; to_version?: number | null }[] } export interface LedgerCaliber { @@ -220,6 +258,7 @@ export interface RunLedger { actions?: LedgerAction[] issued?: LedgerIssued[] reconciled?: LedgerReconciled[] + recall?: LedgerRecallMarks } /** 证据片段接口(GET /runs/{id}/evidence/segments/{seg})的 ledger 键:这个片段的标签和比对的来历 */ diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 688e66df..0d38c41c 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -33,14 +33,17 @@ // 17. 口径账本(next-round 02):交付了的轮次底部写「正在比对口径」,按退避再取到比对出结果、比完不再取;一行摘要 // (几个数来自哪份口径)、按口径正式出具和对账按钮照服务端给的动作摆;出具、对账的写请求在浏览器层拦下并核对请求体, // 出具过的正式运行挂在轮次下面(档位、打开运行)。 -// 18. 过程里被驳回的那一步(node.failed 带 reason=rejected):停止的剪影、中性色、行尾写「被驳回」,不画成完成的绿勾。 +// 18. 过程里被驳回的那一步(node.failed 带 reason=rejected):停止的剪影、中性色、行尾写「被驳回」,不画成完成的绿勾; +// 19. 答案身上的召回标记(账本接口的 recall)写在轮次底部:「已按口径「X」vN 核对,不影响」、依据有变(目录 / 表结构,几项、 +// 按新目录新出现几条错误级问题),看的人手上有说的是这个答案的召回待办时链到待办;没比对的答案照样写依据有变;做完的 +// 对账卡上写「差在哪」、问说明有没有帮助(说明有用 / 看不懂)。 // // 所有写请求都被拦在浏览器里,不碰任何库。 // 跑之前前端得起着(./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' +import { ISSUE, RECALL, RECONCILE, 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 @@ -1653,6 +1656,58 @@ for (const theme of THEMES) { } }) + await section('recall', '口径账本:答案身上的召回标记写在轮次底部(已按 vN 核对,不影响;依据有变;有待办时链到待办)', async () => { + // 召回在原答案的运行上追加 ledger.recalled、ledger.basis_changed(封存后),账本接口的 recall 把它们交出来(02 做法 7)。 + // 以前只在「过程」里点开才看得到,使用者又进不了记录页。没比对的答案(state 不是 matched)照样可能被标依据有变 + ctl.ledgerConvs = true + try { + // 比对过的那份另带一次做完的对账(另有两个人说过说明有用):轮次里卡片上方没有「差在哪」,卡里写一份,反馈问的就是它 + const reconciled = [{ ...reconciledOf('succeeded'), feedback: { mine: null, useful: 2, unclear: 0 } }] + for (const [name, LED] of [['matched', { ...ledgerOf('run-led', { labels: WHOLE_LABELS, whole: WHOLE, actions: [], reconciled }), + recall: RECALL }], + ['ineligible', { run_id: 'run-led', state: 'ineligible', reason: '这种来源的运行不比对', note: '封存后比对,不在封存范围内', + recall: { checked: [], basis: [{ kind: 'structure', items: 1, new_errors: 0, at: iso(5) }], tasks: [] } }]]) { + const { page, ctx, errors } = await open(theme) + 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 }) + }) + await page.route(/\/api\/runs\/run-led\/ledger$/, (route) => route.fulfill({ json: LED })) + await page.route(/\/api\/runs\/run-rec-1\/evidence\/metrics\/reconcile\/[^/]+$/, (route) => + route.fulfill({ json: metricDetail('run-rec-1', new URL(route.request().url()).pathname.split('/').at(-1)) })) + await goto(page, 'c0ledgr') + const marks = page.locator('[data-ledger-turn] [data-ledger-recall]').first() + const shown = await marks.waitFor({ timeout: 8000 }).then(() => true, () => false) + const text = shown ? (await marks.innerText()).replace(/\s+/g, ' ') : '' + if (name === 'matched') { + check('轮次底部写「已按口径「周报口径」v3 核对,不影响」(核对过两次只写最近那一版)', shown + && text.includes('已按口径「周报口径」v3 核对,不影响') && !text.includes('v2 核对'), text) + check('……依据有变:项数相加,写按新目录新出现几条错误级问题', text.includes('依据的数据目录有变(3 项)') + && text.includes('按新目录检查新出现 1 条错误级问题'), text) + const link = marks.locator('[data-ledger-recall-task="tk-recall-1"] a') + check('……有说的是这个答案的召回待办:写数变了、链到待办', text.includes('口径升到 v2,这个答案用到的数变了') + && await link.getAttribute('href').catch(() => null) === '/apps/inbox/tk-recall-1', text) + check('……和摘要在同一张账本卡里', await page.locator('[data-ledger-turn="matched"] [data-ledger-recall]').count() === 1) + const card = page.locator('[data-ledger-turn] [data-ledger-reconciled="run-rec-1"]') + const where = (await card.locator('[data-ledger-reconciled-where]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check('轮次里做完的对账卡:写「差在哪」,下面问说明有没有帮助、写各几个人', where.includes('口径只保留状态为已支付的行') + && (await card.locator('[data-ledger-feedback]').innerText().catch(() => '')).includes('2 人觉得有用') + && await card.locator('[data-ledger-feedback-give]').count() === 2, where) + await page.screenshot({ path: `${SHOTS}/ledger-recall-${theme}.png` }) + } else { + check('不比对的答案:轮次底部只写依据有变(表结构)', shown && text.includes('依据的表结构有变(1 项)') + && await page.locator('[data-ledger-turn="recall"]').count() === 1 && await page.locator('[data-ledger-summary]').count() === 0, text) + } + check(`没有运行时报错(${name})`, 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 84d326b8..9c06ecbd 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -17,7 +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' +import { RECALL, ledgerOf } from './ledger-fixtures.mjs' const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' const CHROME = process.env.CHROME_PATH @@ -4207,6 +4207,40 @@ await section('ledger', '口径账本:来自口径的数字挂小标记(受 await r.ctx.close() }) +await section('ledger-recall', '口径账本:答案身上的召回标记写在「口径」一步里(已按 vN 核对,不影响;依据有变;有待办时链到待办)', async () => { + // 召回在原答案的运行上追加 ledger.recalled、ledger.basis_changed(封存后),账本接口的 recall 把它们交出来(02 做法 7)。 + // 以前它们只在运行时间线里,证据面板、问数据的轮次都看不见。没比对的答案(state 不是 matched)照样可能被标依据有变 + for (const [name, LED] of [['matched', { ...ledgerOf(fx.run_id), recall: RECALL }], + ['ineligible', { run_id: fx.run_id, state: 'ineligible', reason: '这种来源的运行不比对', note: '封存后比对,不在封存范围内', + recall: { checked: [], basis: RECALL.basis, tasks: [] } }]]) { + 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) => route.fulfill({ json: LED })) }) + const p = r.page + const part = p.locator('[data-evidence-panel] [data-ev-ledger]') + await openSeg(p, 's6', '[data-ev-ledger]') + const marks = p.locator('[data-evidence-panel] [data-ledger-recall]') + await marks.waitFor({ timeout: 4000 }).catch(() => {}) + const text = (await marks.innerText().catch(() => '')).replace(/\s+/g, ' ') + if (name === 'matched') { + check('比对过的答案:「口径」一步写「已按口径「周报口径」v3 核对,不影响」(核对过两次只写最近那一版)', + text.includes('已按口径「周报口径」v3 核对,不影响') && !text.includes('v2 核对') && await p.locator('[data-evidence-panel] [data-ledger-recall-checked]').count() === 1, + text) + check('……依据有变:目录那一类两次的项数相加,写按新目录新出现几条错误级问题', text.includes('依据的数据目录有变(3 项)') + && text.includes('按新目录检查新出现 1 条错误级问题'), text) + const link = p.locator('[data-evidence-panel] [data-ledger-recall-task="tk-recall-1"] a') + check('……看的人手上有说的是这个答案的召回待办:写数变了、链到待办', text.includes('口径升到 v2,这个答案用到的数变了') + && await link.getAttribute('href').catch(() => null) === '/apps/inbox/tk-recall-1', text) + check('……标记写在「口径」一步里,标签照旧', await part.getAttribute('data-ev-ledger').catch(() => null) === 'caliber') + if (SHOTS) await p.locator('[data-evidence-panel]').screenshot({ path: `${SHOTS}/ledger-recall.png` }).catch(() => {}) + } else { + check('不比对的答案:没有标签,「口径」一步只写依据有变', await part.getAttribute('data-ev-ledger').catch(() => null) === 'recall' + && text.includes('依据的数据目录有变(3 项)') && !text.includes('核对,不影响'), text) + } + check(`没有运行时报错(${name})`, 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 62000b94..1ac25b3c 100644 --- a/scripts/check-ledger.mjs +++ b/scripts/check-ledger.mjs @@ -6,7 +6,8 @@ // 之后才追加);等满了还没有结论照实说、给「再取一次」,点了重新取、取到就画;三种情况(来自口径 / 相关口径 / 这次的 // 推理)各写各的; // - 口径对账:核对参数(推断的参数要先核对)→ 发起(认不出列怎么配时照 409 带回的选项选对照的指标,再发起)→ 结果卡片: -// 这次 / 口径 / 差值 / 相对差四个数各自取值、点开是出处链;请求体逐项核对; +// 这次 / 口径 / 差值 / 相对差四个数各自取值、点开是出处链;请求体逐项核对;做完的卡片上问「差在哪」的说明有没有帮助 +// (说明有用 / 看不懂,统计 E2):请求体只有 verdict,选过的按下、写各几个人,能改主意;没做完的不问; // - 可沉淀的问题:工作流列表弹窗里的抽屉,三类来源分开写;「固化成口径草稿」走 from-run 的 freeze(带同组的别的运行)、 // 打开画布;聚类按人挑的那一轮固化;「不是口径」要写理由; // - 使用者看到的入口照服务端的动作:不能出具的写原因(请搭建者出具)、不摆出具按钮,能对账的照样对账;出具过的正式运行 @@ -180,8 +181,15 @@ await section('「口径」一步的三种情况:来自口径、相关口径 // ---------------------------------------------------------------- 对账 await section('口径对账:核对参数 → 发起 → 选对照的指标 → 三个数各自点得开', async () => { - const state = { posts: [], reconciled: [], polls: 0 } + const state = { posts: [], reconciled: [], polls: 0, feedback: [] } const extra = (method, p, body) => { + // 「说明有用 / 看不懂」:服务端交回这次对账现在的反馈(看的人最后一次给的、各几个人;另有一个人之前说过有用) + if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile/run-rec-1/feedback`) { + state.feedback.push(body) + const fb = { mine: body?.verdict, useful: 1 + (body?.verdict === 'useful' ? 1 : 0), unclear: body?.verdict === 'unclear' ? 1 : 0 } + state.reconciled = state.reconciled.map((r) => ({ ...r, feedback: fb })) + return { json: { run_id: 'run-rec-1', feedback: fb } } + } if (method === 'POST' && p === `/ledger/answers/${RUN}/reconcile`) { state.posts.push(body) if (!body?.pairs) { @@ -236,6 +244,7 @@ await section('口径对账:核对参数 → 发起 → 选对照的指标 → 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('对账进行中')) + check('……还没做完:不问说明有没有用', await card.locator('[data-ledger-feedback-give]').count() === 0) 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) @@ -244,6 +253,24 @@ await section('口径对账:核对参数 → 发起 → 选对照的指标 → && 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) + + // 「差在哪」的说明有没有帮人看懂(02 做法 10 的 E2):做完的卡片上问一句,记成对账运行上的封存后事件 + const fbRow = card.locator('[data-ledger-feedback]') + check('做完的卡片问「差在哪」的说明有没有帮助:说明有用 / 看不懂', (await fbRow.innerText().catch(() => '')).includes('说明有用') + && await card.locator('[data-ledger-feedback-give]').count() === 2, await fbRow.innerText().catch(() => '')) + await card.locator('[data-ledger-feedback-give="unclear"]').click() + await until(() => state.feedback.length === 1) + check('点「看不懂」:请求体只有 verdict,记在这次答案的这次对账上', JSON.stringify(state.feedback[0]) === JSON.stringify({ verdict: 'unclear' }), + JSON.stringify(state.feedback[0])) + const pressed = await until(async () => await card.locator('[data-ledger-feedback-give="unclear"]').getAttribute('aria-pressed') === 'true', 4000) + const fbText = (await fbRow.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('……选过的按下,写各几个人(服务端给的数)', pressed && fbText.includes('1 人觉得有用') && fbText.includes('1 人看不懂'), fbText) + await card.locator('[data-ledger-feedback-give="useful"]').click() + await until(() => state.feedback.length === 2) + const changed = await until(async () => await card.locator('[data-ledger-feedback-give="useful"]').getAttribute('aria-pressed') === 'true', 4000) + check('改主意:再点「说明有用」照样记一条,按下的换过来', changed && JSON.stringify(state.feedback[1]) === JSON.stringify({ verdict: 'useful' }) + && await card.locator('[data-ledger-feedback-give="unclear"]').getAttribute('aria-pressed') === 'false') + 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) diff --git a/scripts/ledger-fixtures.mjs b/scripts/ledger-fixtures.mjs index 246efcaa..049e7b35 100644 --- a/scripts/ledger-fixtures.mjs +++ b/scripts/ledger-fixtures.mjs @@ -51,6 +51,25 @@ export function ledgerOf(runId, { whole, actions, issued = [], reconciled = [], } } +/** + * 答案身上的召回标记(服务端 ledger/recall.answer_marks):周报口径升到 v3 重算过、数没变(同一份口径核对过两次,只写最近 + * 那一版);目录里影响数字的两项被驳回、按新目录检查新出现 1 条错误级问题,另有一次出现了新码值(同归「数据目录」那一类, + * 项数相加);看的这个人手上有一条说的是这个答案的 recall 待办(订单口径升到 v2,数变了) + */ +export const RECALL = { + checked: [ + { workflow_id: 'wf-weekly', workflow_name: '周报口径', from_version: 1, to_version: 2, recall_run: 'run-rc0', changed: false, + at: '2026-10-14T01:00:00+00:00' }, + { workflow_id: 'wf-weekly', workflow_name: '周报口径', from_version: 2, to_version: 3, recall_run: 'run-rc1', changed: false, + at: '2026-10-15T01:00:00+00:00' }, + ], + basis: [ + { kind: 'catalog', items: 2, new_errors: 1, at: '2026-10-15T02:00:00+00:00' }, + { kind: 'values', items: 1, new_errors: 0, at: '2026-10-15T03:00:00+00:00' }, + ], + tasks: [{ id: 'tk-recall-1', reason: 'caliber', to_version: 2 }], +} + /** 看差多少(s8 那条查询、订单口径):参数是推断的,要先核对 */ export const RECONCILE = { kind: 'reconcile', query: 'q-orders', caliber: ORDERS.caliber, workflow_id: ORDERS.workflow_id, version: 1, node_id: 'fetch',