diff --git a/frontend/src/api/caliberLedger.ts b/frontend/src/api/caliberLedger.ts new file mode 100644 index 00000000..5e9b8cf6 --- /dev/null +++ b/frontend/src/api/caliberLedger.ts @@ -0,0 +1,37 @@ +/** + * 口径账本的接口(docs/design/next-round/02-口径账本.md),挂在 api.caliberLedger 上。形状见 typesLedger.ts。 + * + * - run:GET /api/runs/{id}/ledger,一次运行里每个数的口径标签、整题判断、能做的动作、出具过的正式运行、对过的账; + * - issue:按口径正式出具(参数只由服务端从比对工件里取,这里只能选「按哪份口径」); + * - reconcile:口径对账(点开才跑一次确定性派生的只读图); + * - certifyQueue / dismiss:可沉淀的问题、「不是口径」(搭建者及以上); + * - freeze:固化成口径草稿(/copilot/from-run 的 freeze 选项,报告引用的查询变成固定 SQL 的取数节点)。 + * + * 应用平台的「账本」(版本被哪些应用固定、生效记录)在 api.ledger(api/apps.ts),和这里不是一回事。 + * 请求函数从 ./http 导入,不从 ./client 导入(client.ts 要导入这个文件,反过来就成了循环)。 + */ +import type { + CertifyQueue, FreezeOut, LedgerIssueOut, LedgerReconcileIn, LedgerReconcileOut, RunLedger, +} from '../typesLedger' +import { get, post, type RequestOptions } from './http' + +const at = (runId: string) => `/ledger/answers/${encodeURIComponent(runId)}` + +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), + 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), + /** + * 固化成口径草稿:代表运行里报告引用了的查询变成固定 SQL 的取数节点,peers(同一个 SQL 形状的别的运行)用来认哪些字面量 + * 跨轮变了。落成新的草稿工作流,人在画布上审改、按受管级别发布 + */ + freeze: (runId: string, opts: { peers?: string[]; conversation_id?: string | null; turn_id?: string | null } = {}) => + post('/copilot/from-run', { + run_id: runId, freeze: true, peers: opts.peers ?? [], + ...(opts.conversation_id ? { conversation_id: opts.conversation_id } : {}), + ...(opts.turn_id ? { turn_id: opts.turn_id } : {}), + }), +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d37f2e4b..f81cda84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -30,6 +30,8 @@ import { apps, ledger } from './apps' import { actions, tasks } from './tasks' import { auth, identity } from './auth' import { accessKeys } from './accessKeys' +// 口径账本(下一轮第 4 波 W4-c):问数据答案的口径标签、按口径出具、对账、可沉淀的问题 +import { caliberLedger } from './caliberLedger' import { capabilities, endpoints, notify } from './capabilities' // 普通用户建应用(typesBlueprint.ts 是接口约定) import { blueprints, files, personal, templates } from './blueprints' @@ -963,6 +965,8 @@ export const api = { identity, // 访问密钥与服务账号(下一轮第 2 波) accessKeys, + // 口径账本:答案里每个数的口径标签、按口径正式出具、口径对账、可沉淀的问题(typesLedger.ts 是接口约定) + caliberLedger, capabilities, endpoints, notify, diff --git a/frontend/src/canvas/WorkflowPicker.tsx b/frontend/src/canvas/WorkflowPicker.tsx index 17bc205f..1d60e568 100644 --- a/frontend/src/canvas/WorkflowPicker.tsx +++ b/frontend/src/canvas/WorkflowPicker.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Check, Copy, LayoutTemplate, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react' +import { Check, Copy, Layers, LayoutTemplate, Plus, Search, Trash2, Workflow as WorkflowIcon } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { useCatalog } from '../store/catalog' @@ -11,6 +11,9 @@ import { formatShortcut } from '../lib/keys' import { leavePass, registerLeaveGuard } from '../lib/leave' import { WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms' import type { GraphSpec, Workflow } from '../types' +import { useAtLeast } from '../store/auth' +import { LEDGER_UI } from '../lib/termsLedger' +import { CertifyQueue } from '../pages/ledger/CertifyQueue' /** 标签的中文。后端写的是机器标记,和中文界面混排很突兀 */ const TAG_LABEL: Record = { extracted: '从运行提取', template: '模板' } @@ -192,8 +195,11 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () = const [filter, setFilter] = useState('all') const [cursor, setCursor] = useState(0) const listRef = useRef(null) + // 「可沉淀的问题」抽屉(口径账本的认证队列,搭建者及以上):盖在列表上,返回回到列表 + const [queue, setQueue] = useState(false) + const builder = useAtLeast('builder') - useEffect(() => { if (open) { setQuery(''); setCursor(0) } }, [open]) + useEffect(() => { if (open) { setQuery(''); setCursor(0); setQueue(false) } }, [open]) const shown = useMemo(() => { const q = query.trim().toLowerCase() @@ -267,13 +273,14 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () = } return ( - + ↑↓ 选择 · {formatShortcut('Enter')} 打开 · Esc 关闭 }> + {queue ? setQueue(false)} onClose={onClose} beforeLeave={confirmDiscard} /> : <>
@@ -302,6 +309,12 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () = ))}
+ {builder && ( + + )}
{!shown.length ? ( @@ -369,6 +382,7 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () = })} )} + }
) } diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts new file mode 100644 index 00000000..20543f3d --- /dev/null +++ b/frontend/src/lib/termsLedger.ts @@ -0,0 +1,207 @@ +/** + * 口径账本的界面文案(docs/design/next-round/02-口径账本.md 做法 3–6、8 的界面部分;总方案 X10):证据正文里数字上的 + * 小标记、证据面板的「口径」一步、问数据轮次底部的摘要和动作、口径对账、可沉淀的问题、设置里的口径账本一组。 + * + * 叫法:来自口径(受管 / 已发布)· 等价已证明 / 助手选用、相关口径、这次的推理、口径对账、可沉淀的问题;出具档位沿用 + * 「完整出具 / 降档出具 / 不予出具」(服务端给 tier_text);版本写「固定版本」。标签四种、认法两种、级别两种的叫法在 + * terms.ts 的 LEDGER_TEXT(时间线那一行同一套),这里不重写。规矩同 terms.ts 开头的术语表。 + */ +import { formatNumber } from './format' + +/** 口径的写法:「周销售」v3(受管) */ +export const caliberName = (name: string | null | undefined, version: number | null | undefined, level?: string) => + `「${name || '未命名'}」${version != null ? `v${version}` : ''}${level ? `(${level})` : ''}` + +export const LEDGER_UI = { + // ---- 证据正文:来自口径的数字上的小标记 ---- + mark: { + /** 标记上的字就是级别(受管 / 已发布);相关口径的数不加标记,只在面板里说 */ + title: (caliber: string, how: string) => `来自口径${caliber} · ${how}`, + aria: (caliber: string, how: string) => `,来自口径${caliber},${how}`, + }, + + // ---- 证据面板最上面的「口径」一步 ---- + step: { + title: '口径', + pending: '正在比对口径…', + pendingSub: '运行封存后在后台和已发布口径比对,比完这里会自动更新', + timeout: '比对口径还没有结果:可能在排队,或比对超过了时限', + retry: '再取一次', + failed: '无法读取口径比对', + unreadable: (reason: string) => `口径比对记录无法读取${reason ? `:${reason}` : ''}`, + params: '参数', + noParams: '没有参数', + masked: '已遮罩', + column: (c: string) => `对应口径里的列「${c}」`, + how: { + independent: '这次 Agent 独立写的 SQL 被证明和这份口径的取数等价', + composed: '助手按问法选用了这份口径,取数照那一版生成;这份口径答的是不是这个问题,要由人确认', + }, + relatedSub: '这个数是这次的推理,下面这份已发布口径和它相关', + where: '差在哪', + noDiff: '没有归纳出具体差异', + fallback: '两条 SQL 的结构对不上,未能归纳成具体差异,请对照原文', + notComparable: '这份口径不能对账', + reasoningSub: '没有相关的已发布口径', + unknownSub: '没有和已发布口径比对', + }, + + // ---- 按口径正式出具(整题等价时) ---- + issue: { + button: '按口径正式出具', + busy: '正在发起…', + /** 服务端判了能自动出具:比对完就自动发起,这里不另给按钮 */ + auto: '整题等价于这份口径,已在按口径自动正式出具', + autoWaiting: '正在按口径自动出具…', + autoTimeout: '自动出具的正式运行还没有出现,可以手动发起', + launched: (caliber: string) => `已按口径${caliber}发起正式运行`, + again: (caliber: string) => `已经按口径${caliber}出具过同样参数的这一次`, + issuedRun: '已出具', + readOnly: '对话在回收站中,恢复后才能出具', + }, + /** 出具过的正式运行,挂在轮次下面 */ + issued: { + title: (auto: boolean) => (auto ? '按口径自动出具' : '按口径正式出具'), + running: '正式运行进行中', + rejected: '被驳回', + failed: '正式运行没有完成', + cancelled: '已取消', + open: '打开运行', + /** 使用者读不了正式运行的原始记录(记录页只给搭建者) */ + userNote: '正式运行的记录由搭建者在记录页查看', + }, + + // ---- 口径对账 ---- + reconcile: { + button: '看差多少', + turnButton: (caliber: string) => `对账:${caliber}`, + title: (caliber: string) => `口径对账:${caliber}`, + intro: '按你的问法和按口径,在同一份数据上各算一次,差值在对账卡里算。只读,不写任何数据', + where: '差在哪', + params: '核对参数', + paramsHint: '参数按这次的 SQL 推断,请核对', + paramsFixed: '参数取自比对时绑定出来的值', + editParams: '修改参数', + required: '必填', + defaulted: '留空用口径的缺省值', + maskedPlaceholder: '已遮罩,请重新填写', + pairs: '选择要对照的指标', + pairsHint: '认不出这次的哪一列对应口径的哪个指标,请为要对照的列选一个口径卡上的指标', + pairNone: '不对照', + pairEmpty: '至少选一组对照', + launch: '发起对账', + busy: '正在发起…', + cancel: '取消', + launched: '已发起对账,结果在下面', + readOnly: '对话在回收站中,恢复后才能对账', + }, + /** 对账的结果卡片 */ + result: { + title: (caliber: string) => `口径对账 · ${caliber}`, + running: '对账进行中…', + failed: '对账没有完成', + cancelled: '对账已取消', + inputs: { binding: '按比对时推断的参数', checked: '按核对过的参数' } as Record, + ours: '按你的问法', + caliber: '按口径', + diff: '差值', + rel: '相对差', + loading: '正在取值…', + noValue: '取值失败', + open: (what: string, label: string) => `${label} · ${what}:点开看出处`, + more: (n: number) => `展开其余 ${formatNumber(n)} 组`, + less: '收起', + original: (text: string) => `原答案里写的是 ${text};对账时按同样的 SQL 重新取数,数据有变化时两者可能不同`, + openRun: '打开对账运行', + }, + + // ---- 问数据:轮次底部的摘要 ---- + turn: { + pending: '正在比对口径…', + timeout: '比对口径还没有结果', + /** 一行摘要:几个数来自哪份口径、几个是这次的推理、几个没有比对 */ + summary: (parts: { caliber: number; calibers: string[]; reasoning: number; related: number; unknown: number }) => { + const out: string[] = [] + if (parts.caliber) { + out.push(parts.calibers.length === 1 ? `${formatNumber(parts.caliber)} 个数来自口径${parts.calibers[0]}` + : `${formatNumber(parts.caliber)} 个数来自 ${formatNumber(parts.calibers.length)} 份口径`) + } + const reasoning = parts.reasoning + parts.related + if (reasoning) { + out.push(`${formatNumber(reasoning)} 个数是这次的推理${parts.related ? `(其中 ${formatNumber(parts.related)} 个有相关口径)` : ''}`) + } + if (parts.unknown) out.push(`${formatNumber(parts.unknown)} 个数没有比对`) + return out.join(',') + }, + whole: (caliber: string, how: string) => `整题等价于口径${caliber} · ${how}`, + label: '口径', + }, + + // ---- 工作流列表页:可沉淀的问题 ---- + certify: { + open: '可沉淀的问题', + openHint: '被反复问到、反复写出同样查询的问题:固化成口径草稿,人审后发布,以后再问就认得出等价', + back: '返回工作流列表', + title: '可沉淀的问题', + intro: '三类来源分开列出。固化成口径草稿会新建一张草稿工作流(报告引用的查询变成固定 SQL 的取数节点),在画布上审改、按受管级别发布后才生效', + kinds: { + shape: '确定性重复:同样形状的 SQL', + cluster: '问题聚类:问法相近的问题', + reused: '被反复用的答案', + } as Record, + kindHint: { + shape: '问数据里执行过、被报告引用、又没有对上任何口径的查询,按数据源和规范化后的 SQL 分组:出现在多次运行、多段对话里', + cluster: '只按问题原文聚类(不含对话历史),问法相近不等于口径相同,固化前请确认是同一个问题', + reused: '导出过多次、做成过应用、在画布里打开或提取过的答案', + } as Record, + empty: '暂时没有可沉淀的问题', + emptyBody: (runs: number) => `看了最近 ${formatNumber(runs)} 次问数据的运行,没有达到门槛的重复`, + threshold: (runs: number, conversations: number) => `门槛:同一形状出现在 ${formatNumber(runs)} 次以上运行、来自 ${formatNumber(conversations)} 段以上对话`, + shapeStats: (runs: number, conversations: number) => `${formatNumber(runs)} 次运行 · ${formatNumber(conversations)} 段对话`, + params: (p: number, c: number) => `${formatNumber(p)} 个参数候选 · ${formatNumber(c)} 个口径常量`, + clusterStats: (n: number) => `${formatNumber(n)} 个相近的问题`, + uses: (u: { exports: number; app: boolean; extracted: boolean; canvas: boolean }) => [ + u.exports ? `导出 ${formatNumber(u.exports)} 次` : '', u.app ? '做成过应用' : '', u.extracted ? '提取过模板' : '', + u.canvas ? '在画布里打开过' : '', + ].filter(Boolean).join(' · '), + source: (s: string) => `数据源 ${s}`, + shape: 'SQL 形状(取值已换成「?」)', + related: '已有相关口径', + frozen: '已固化成', + turns: '来自这些轮次', + openTurn: '打开对话', + openRun: '打开运行', + freeze: '固化成口径草稿', + freezeTurn: '按这一轮固化', + freezeBusy: '正在固化…', + frozenOk: (name: string) => `已固化成草稿「${name}」,在画布里审改后发布`, + dismiss: '不是口径', + dismissTitle: '这不是口径', + dismissLabel: '理由', + dismissPlaceholder: '例如:每次问的是不同门店的临时核对,不需要固定下来', + dismissNeed: '请写明理由', + dismissConfirm: '确认不是口径', + dismissed: '已记下,这一项不再出现', + noRun: '这一项没有可固化的运行', + loadFailed: '无法读取可沉淀的问题', + }, + + // ---- 设置:口径账本一组 ---- + settings: { + title: '口径账本', + hint: '问数据的答案封存后在后台和已发布口径比对:按口径正式出具、口径对账、升版后的召回都按这里的设置', + autoIssue: '自动按口径正式出具', + autoIssueHint: '问数据的答案整题等价于受管口径(Agent 独立写的 SQL 被证明等价、图里没有人工节点和写入)时自动发起一次正式运行;不满足的只给一键出具', + dailyLimit: '按口径正式出具每日上限', + dailyLimitHint: '自动的、一键的合计;0 表示不再按口径出具。正式运行含报告撰写的模型费用', + timeout: '口径比对时限(秒)', + timeoutHint: '一次运行的口径比对最多算这么久,超时的照实记没比完', + recallLimit: '口径升版后每次最多重算几组', + recallLimitHint: '发布新版本后自动重算受影响的答案,按参数去重成组;超出的写明另有几组未重算;0 表示不自动重算', + invalid: { + limit: (max: number) => `请填写 0 到 ${formatNumber(max)} 之间的整数`, + timeout: (lo: number, hi: number) => `请填写 ${lo} 到 ${hi} 之间的秒数`, + }, + saveInvalid: '口径账本的设置中有无效值,请修正后再保存', + }, +} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 5ea4aed0..d3790806 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -11,6 +11,7 @@ import { AssistantStream, type StreamTurn, type TurnFailure } from '../run/Assis import { PromptBox } from '../run/Composer' import { decodeCopilot, decodeRun, exitLabels } from '../run/decode' import { catalogPatchesOf } from '../run/catalogPatch' +import { LedgerTurn } from '../run/LedgerTurn' import { sourceHandles } from '../canvas/nodeDefs' import { isComposing, PageHeader, Skeleton, Spinner, StatusBadge, TechDetails, toast } from '../components/ui' import { humanizeError } from '../lib/errors' @@ -294,10 +295,19 @@ export function ChatPage() { approvalsFor={(t) => } renderTurnActions={(t) => { const turn = turns?.find((x) => x.id === t.id) - return currentId && turn && hasRemedies(turn) - ? - : null + if (!currentId || !turn) return null + // 轮次底部:口径账本的摘要和动作(交付了的轮次才有:封存后在后台比对,见 run/LedgerTurn),再是补救动作 + const ledger = turn.phase === 'done' && !!turn.run?.id && !turn.asked + if (!ledger && !hasRemedies(turn)) return null + return ( + <> + {ledger && } + {hasRemedies(turn) && ( + + )} + + ) }} onFollowUp={(text) => { // 追问是从表格里派生的一句话,点了就问;这会儿发不出去就先放进输入框 diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index a058f038..31ff84e5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -38,6 +38,10 @@ import { IdentityTab } from './settings/IdentityTab' import { NotifyTab } from './settings/NotifyTab' import { AuditTab } from './settings/AuditTab' import { AccessKeysTab } from './settings/AccessKeysTab' +import { + LedgerSection, ledgerChanged, ledgerDraftOf, ledgerErrors, ledgerLabels, ledgerPayload, type LedgerDraft, +} from './settings/LedgerSettings' +import { LEDGER_UI } from '../lib/termsLedger' import { ACCESS_KEY_TEXT } from '../lib/termsApp' // 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。 @@ -689,6 +693,8 @@ function PrefsTab() { // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 const [verifyDraft, setVerifyDraft] = useState(null) const [verifySaves, setVerifySaves] = useState(0) + // 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条 + const [ledgerDraft, setLedgerDraft] = useState(null) const [saving, setSaving] = useState(false) const [savedFlash, setSavedFlash] = useState(0) const [saveError, setSaveError] = useState(null) @@ -778,9 +784,14 @@ function PrefsTab() { const verifyKeys = verifyChanged(verifyCur, verifySaved) const verifyErrs = verifyErrors(verifyCur, verifySaved) const verifyInvalid = Object.values(verifyErrs).some(Boolean) - const invalid = guardInvalid || judgeInvalid || verifyInvalid || !!appsError + const ledgerSaved = ledgerDraftOf(values?.ledger) + const ledgerCur = ledgerDraft ?? ledgerSaved + const ledgerKeys = values?.ledger ? ledgerChanged(ledgerCur, ledgerSaved) : [] + const ledgerErrs = ledgerErrors(ledgerCur, ledgerSaved) + const ledgerInvalid = Object.values(ledgerErrs).some(Boolean) + const invalid = guardInvalid || judgeInvalid || verifyInvalid || ledgerInvalid || !!appsError const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k]) - const dirty = values ? changed.length + verifyKeys.length : 0 + const dirty = values ? changed.length + verifyKeys.length + ledgerKeys.length : 0 // 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算 useLeaveGuard(dirty > 0, () => confirmLeave(dirty)) @@ -800,6 +811,11 @@ function PrefsTab() { setFieldError(null) setVerifyDraft({ ...verifyCur, ...patch }) } + const editLedger = (patch: Partial) => { + setSaveError(null) + setFieldError(null) + setLedgerDraft({ ...ledgerCur, ...patch }) + } /** * 署名只在这台浏览器里,先落本机:它不该跟着后端一起「没存上」。运行默认值 @@ -833,6 +849,8 @@ function PrefsTab() { } : null // 核查一组同样整组写(VerifySettings 的 verifyPayload) const verify = verifyKeys.length ? verifyPayload(verifyCur) : null + // 口径账本一组同样整组写(LedgerSettings 的 ledgerPayload) + const ledger = ledgerKeys.length ? ledgerPayload(ledgerCur) : null // 起草模型一组:两项一起写,空着的存 null(跟随助手的模型) const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null // 应用一组同样整组写:别的键(以后加的)照原样带回去 @@ -840,7 +858,7 @@ function PrefsTab() { ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal, share_links: cur.appsShare } : null - if (runChanged || judge || verify || catalogDraft || apps) { + if (runChanged || judge || verify || ledger || catalogDraft || apps) { const run = { ...(values.run ?? {}), default_memory_scope: cur.scope, @@ -854,6 +872,7 @@ function PrefsTab() { } const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}), + ...(ledger ? { ledger } : {}), ...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}), }) setValues((v) => ({ @@ -861,6 +880,7 @@ function PrefsTab() { ...(runChanged ? { run: out?.run ?? run } : {}), ...(judge ? { judge: out?.judge ?? judge } : {}), ...(verify ? { verify: out?.verify ?? verify } : {}), + ...(ledger ? { ledger: out?.ledger ?? ledger } : {}), ...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}), ...(apps ? { apps: out?.apps ?? apps } : {}), })) @@ -870,6 +890,7 @@ function PrefsTab() { } setDraft(null) setVerifyDraft(null) + setLedgerDraft(null) setSavedFlash(Date.now()) } catch (e) { // 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少 @@ -1180,6 +1201,8 @@ function PrefsTab() { {values.verify && } + {values.ledger && } + {values.catalog_draft && ( @@ -1244,7 +1267,7 @@ function PrefsTab() { draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, appsShare: APP_SETTING_TEXT.shareLinks, - })[k]).concat(verifyLabels(verifyKeys)).join('、')} + })[k]).concat(verifyLabels(verifyKeys), ledgerLabels(ledgerKeys)).join('、')} {saveError != null && ( @@ -1255,12 +1278,13 @@ function PrefsTab() { )} + onClick={() => { setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setSaveError(null); setFieldError(null) }}>放弃 diff --git a/frontend/src/pages/ledger/CertifyQueue.tsx b/frontend/src/pages/ledger/CertifyQueue.tsx new file mode 100644 index 00000000..be549d25 --- /dev/null +++ b/frontend/src/pages/ledger/CertifyQueue.tsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ChevronLeft, Layers, Sparkles, X } from 'lucide-react' +import { api } from '../../api/client' +import { EmptyState, ErrorState, Skeleton, Spinner, promptDialog, toast } from '../../components/ui' +import { leavePass } from '../../lib/leave' +import { LEDGER_UI, caliberName } from '../../lib/termsLedger' +import { LEDGER_TEXT } from '../../lib/terms' +import { useCatalog } from '../../store/catalog' +import type { CertifyItem, CertifyQueue as Queue, CertifyTurn } from '../../typesLedger' + +/** + * 可沉淀的问题(认证队列,docs/design/next-round/02-口径账本.md 做法 8;服务端 app/ledger/certify.py):工作流列表页 + * (编排页的「工作流」弹窗)里的一个抽屉。 + * + * 同一类问题被反复问、Agent 反复写出同一个形状的 SQL,就该沉淀成一份口径:以后再问,比对器认得出「等价已证明」,还能按 + * 口径正式出具。三类来源照实分开写——确定性重复(同样形状的 SQL)、问题聚类(只按问题原文,问法相近不等于口径相同)、 + * 被反复用的答案——门槛、计数都是服务端给的。 + * + * - 固化成口径草稿:走 /copilot/from-run 的 freeze(报告引用的查询变成固定 SQL 的取数节点,跨轮变化的字面量成为参数), + * 落成新的草稿工作流,打开画布让人审改、按受管级别发布——提取器是作者,不是发布者。确定性重复的带上同组的别的运行 + * (认哪些字面量变了);问题聚类没有共同的 SQL,按人挑的那一轮固化。 + * - 不是口径:要写理由(记一条审计 ledger.certify.dismiss),之后这一项不再进队。 + */ + +const ORDER: CertifyItem['kind'][] = ['shape', 'cluster', 'reused'] + +/** 一轮在哪看:有对话的打开对话,没有的打开运行 */ +const turnHref = (t: CertifyTurn) => (t.conversation_id ? `/chat/${t.conversation_id}` : t.run_id ? `/runs/${t.run_id}` : null) + +export function CertifyQueue({ onBack, onClose, beforeLeave }: { + onBack: () => void + /** 打开了固化出的草稿:关掉外面的弹窗 */ + onClose: () => void + /** 去画布之前问一句(画布上有没保存的改动时);返回 false 就不去 */ + beforeLeave: () => Promise +}) { + const navigate = useNavigate() + const refreshCatalog = useCatalog((s) => s.refresh) + const workflows = useCatalog((s) => s.workflows) + const [queue, setQueue] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + + const load = () => { + setError(null) + setQueue(null) + api.caliberLedger.certifyQueue().then(setQueue, setError) + } + useEffect(load, []) + + const nameOf = (r: NonNullable[number]) => { + const level = r.level ? (LEDGER_TEXT.level as Record)[r.level] ?? '' : '' + return caliberName(workflows.find((w) => w.id === r.workflow_id)?.name ?? r.workflow_id, r.version, level) + } + + const freeze = async (item: CertifyItem, turn?: CertifyTurn) => { + const runId = turn?.run_id ?? item.representative?.run_id + if (!runId) { toast.warn(LEDGER_UI.certify.noRun); return } + if (!(await beforeLeave())) return + const first = turn ?? item.turns.find((t) => t.run_id === runId) ?? item.turns[0] + setBusy(`${item.id}:${runId}`) + try { + const out = await api.caliberLedger.freeze(runId, { + peers: !turn && item.kind === 'shape' ? item.peers ?? [] : [], + conversation_id: first?.conversation_id, turn_id: first?.turn_id, + }) + toast.ok(LEDGER_UI.certify.frozenOk(out.name)) + void refreshCatalog() + onClose() + navigate(`/studio/${out.workflow_id}`, leavePass({ state: { discard: true } })) + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + + const dismiss = async (item: CertifyItem) => { + const note = await promptDialog({ + title: LEDGER_UI.certify.dismissTitle, label: LEDGER_UI.certify.dismissLabel, + placeholder: LEDGER_UI.certify.dismissPlaceholder, confirmLabel: LEDGER_UI.certify.dismissConfirm, + validate: (v) => (v.trim() ? null : LEDGER_UI.certify.dismissNeed), + }) + if (note == null || !note.trim()) return + setBusy(`${item.id}:dismiss`) + try { + await api.caliberLedger.dismiss({ item_id: item.id, kind: item.kind, note: note.trim() }) + setQueue((q) => (q ? { ...q, items: q.items.filter((i) => i.id !== item.id) } : q)) + toast.ok(LEDGER_UI.certify.dismissed) + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + + const C = LEDGER_UI.certify + const groups = ORDER.map((kind) => ({ kind, items: (queue?.items ?? []).filter((i) => i.kind === kind) })) + .filter((g) => g.items.length) + + return ( +
+
+ +
+

{C.intro}

+ {error ? + : !queue ? + : !groups.length ? ( + } title={C.empty} + body={<>{C.emptyBody(queue.considered.runs)}
{C.threshold(queue.threshold.runs, queue.threshold.conversations)}} /> + ) : ( +
+ {groups.map((g) => ( +
+

{C.kinds[g.kind]} {g.items.length}

+

{C.kindHint[g.kind]}

+
    + {g.items.map((item) => ( +
  • +
    +
    +
    {item.question || '—'}
    +
    + {item.kind === 'shape' && [ + C.shapeStats(item.runs ?? 0, item.conversations ?? 0), + C.params(item.params?.length ?? 0, item.constants ?? 0), + item.source ? C.source(item.source) : '', + ].filter(Boolean).join(' · ')} + {item.kind === 'cluster' && C.clusterStats(item.count ?? item.turns.length)} + {item.kind === 'reused' && item.uses && C.uses(item.uses)} +
    +
    +
    + {item.kind !== 'cluster' && ( + + )} + +
    +
    + {item.kind === 'shape' && item.shape && ( +
    + {C.shape} +
    {item.shape}
    +
    + )} + {!!item.related?.length && ( +

    + {C.related}:{item.related.map(nameOf).join('、')} +

    + )} + {!!item.frozen_into?.length && ( +

    + {C.frozen} + {item.frozen_into.map((w) => ( + 「{w.name}」 + ))} +

    + )} + {!!item.turns.length && ( +
    + {C.turns}({item.turns.length}) +
      + {item.turns.map((t, i) => { + const href = turnHref(t) + return ( +
    • + {t.question || '—'} + {href && ( + + {t.conversation_id ? C.openTurn : C.openRun} + + )} + {item.kind === 'cluster' && t.run_id && ( + + )} +
    • + ) + })} +
    +
    + )} +
  • + ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/settings/LedgerSettings.tsx b/frontend/src/pages/settings/LedgerSettings.tsx new file mode 100644 index 00000000..476caa28 --- /dev/null +++ b/frontend/src/pages/settings/LedgerSettings.tsx @@ -0,0 +1,114 @@ +import { Field, SectionBar } from '../../components/ui' +import { LEDGER_UI } from '../../lib/termsLedger' + +/* + * 设置页「偏好设置」里「口径账本」一节(服务端 api/settings.py 的 ledger 一组,docs/design/next-round/02-口径账本.md + * 做法 5、7):自动按口径正式出具的开关、每日上限、比对时限、口径升版后每次最多重算几组。 + * + * 照 VerifySettings.tsx 的写法:草稿由 PrefsTab 持有、和其余设置共用一条保存条(这一节只画、只报改动),存的时候整组写 + * ledger——服务端对没给的项回落到默认值,只发改过的那一项会把别的项冲回默认。字段名就是服务端 LEDGER_SETTING_LABEL 的 + * 叫法,422 的报错写的也是它。范围同服务端(ledger_problem):每日上限 0 到 100,000、重算组数 0 到 1,000、时限 1 到 60 秒。 + */ + +const S = LEDGER_UI.settings + +/** 范围:同服务端 settings.APPS_DAILY_LIMIT_MAX、LEDGER_RECALL_MAX、LEDGER_TIMEOUT_RANGE */ +const DAILY_MAX = 100_000 +const RECALL_MAX = 1000 +const TIMEOUT_RANGE = [1, 60] as const + +export interface LedgerDraft { + auto_issue: boolean + issue_daily_limit: string + match_timeout_s: string + recall_limit: string +} +export type LedgerKey = keyof LedgerDraft + +const LABEL: Record = { + auto_issue: S.autoIssue, issue_daily_limit: S.dailyLimit, match_timeout_s: S.timeout, recall_limit: S.recallLimit, +} + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +const text = (v: unknown, fallback: string) => (typeof v === 'number' && Number.isFinite(v) ? String(v) : fallback) + +/** GET /settings 的 ledger 一组(服务端给的是生效的样子,没存过的补了默认)→ 草稿 */ +export function ledgerDraftOf(group: unknown): LedgerDraft { + const g = isRecord(group) ? group : {} + return { + auto_issue: g.auto_issue !== false, + issue_daily_limit: text(g.issue_daily_limit, ''), + match_timeout_s: text(g.match_timeout_s, ''), + recall_limit: text(g.recall_limit, ''), + } +} + +export function ledgerChanged(cur: LedgerDraft, saved: LedgerDraft): LedgerKey[] { + return (Object.keys(saved) as LedgerKey[]).filter((k) => cur[k] !== saved[k]) +} + +/** 保存条上这几项的叫法:和字段名、服务端报错同一套 */ +export const ledgerLabels = (keys: LedgerKey[]): string[] => keys.map((k) => LABEL[k]) + +const intIn = (v: string, lo: number, hi: number) => /^\d+$/.test(v.trim()) && Number(v) >= lo && Number(v) <= hi + +/** 各项的问题。只校验改过的(存着的值不合规不该挡住别的设置保存) */ +export function ledgerErrors(cur: LedgerDraft, saved: LedgerDraft): Partial> { + const out: Partial> = {} + for (const k of ledgerChanged(cur, saved)) { + if (k === 'issue_daily_limit' && !intIn(cur[k], 0, DAILY_MAX)) out[k] = S.invalid.limit(DAILY_MAX) + if (k === 'recall_limit' && !intIn(cur[k], 0, RECALL_MAX)) out[k] = S.invalid.limit(RECALL_MAX) + if (k === 'match_timeout_s') { + const n = Number(cur[k].trim()) + if (!(cur[k].trim() !== '' && Number.isFinite(n) && n >= TIMEOUT_RANGE[0] && n <= TIMEOUT_RANGE[1])) { + out[k] = S.invalid.timeout(TIMEOUT_RANGE[0], TIMEOUT_RANGE[1]) + } + } + } + return out +} + +/** 草稿 → 存进去的整组 ledger */ +export function ledgerPayload(cur: LedgerDraft): Record { + return { + auto_issue: cur.auto_issue, + issue_daily_limit: Number(cur.issue_daily_limit.trim()), + match_timeout_s: Number(cur.match_timeout_s.trim()), + recall_limit: Number(cur.recall_limit.trim()), + } +} + +const id = (k: LedgerKey) => `pref-ledger-${k.replace(/_/g, '-')}` + +export function LedgerSection({ cur, errors, onEdit }: { + cur: LedgerDraft + errors: Partial> + onEdit: (patch: Partial) => void +}) { + const num = (k: Exclude, hint: string, mode: 'numeric' | 'decimal' = 'numeric') => ( + + {(p) => ( + onEdit({ [k]: e.target.value } as Partial)} /> + )} + + ) + return ( +
+ + +
+ {num('issue_daily_limit', S.dailyLimitHint)} + {num('match_timeout_s', S.timeoutHint, 'decimal')} + {num('recall_limit', S.recallLimitHint)} +
+
+ ) +} diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 454e2e35..32493827 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -16,7 +16,7 @@ import { } from '../lib/evidence' import { formatNumber } from '../lib/format' import { - CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, claimTally, evidenceTally, + CHALLENGE_TEXT, EVIDENCE_STATE_LABEL, EVIDENCE_TEXT, INQUIRY_TEXT, JUDGE_TEXT, LEDGER_TEXT, claimTally, evidenceTally, type ClaimTallyCounts, } from '../lib/terms' import { useEvidence, useVerdicts, type Slot } from '../store/evidence' @@ -26,6 +26,10 @@ import type { } from '../types' import { EvidencePanel, type PanelMode, type PanelView } from './EvidencePanel' import { askAbout, type InquiryAccess } from './Inquiry' +import { caliberOf, ledgerEnabled } from './LedgerStep' +import { LEDGER_UI } from '../lib/termsLedger' +import { useRunLedger } from '../store/ledger' +import type { LedgerLabel } from '../typesLedger' import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type InlineStyles } from './Markdown' /** @@ -47,6 +51,10 @@ import { BlockView, Markdown, StyledSlice, inlineStyles, numericColumns, type In * 上一处有问题的地方(无证据、可疑名称、证据相矛盾、部分有依据;证据不足的排在这些之后),回车打开证据面板, * Esc 关掉并留在原片段;面板开着时方向键走到哪、面板跟到哪,Tab 进面板。 * + * 口径账本(next-round 02 做法 3):来自口径的数字后面挂一个小标记,写明受管还是已发布(悬停写是哪一份口径、怎么认的); + * 相关口径的数不加标记,只在证据面板的「口径」一步里说。标签按运行另取(GET /runs/{id}/ledger,store/ledger 按退避 + * 等比对出结果),正式运行、没有运行的文档不取。 + * * 长报告:事件都挂在容器上(片段只带 data-seg),块用 memo,外加 * content-visibility: auto;超过 TEXT_CAP 的部分先折叠,跳转到折叠里的片段时自动展开。 */ @@ -370,6 +378,10 @@ export const EvidenceDoc = forwardRef challengeMarks(graph, report, artifact), [graph, report, artifact]) const model = useMemo(() => buildModel(blocks, overlay, ask, marks), [blocks, overlay, ask, marks]) + // 来自口径的数字上的小标记:按运行取的口径账本里这份报告的标签(只画 caliber 一种) + const ledger = useRunLedger(runId, ledgerEnabled(runId, runClass)) + const labels = report ? ledger?.data?.reports?.[report]?.labels : undefined + const caliberMarks = useMemo(() => caliberMarksOf(labels), [labels]) const counts = useMemo(() => docTally(doc), [doc]) const claims = useMemo(() => claimCounts(doc, overlay), [doc, overlay]) const rootRef = useRef(null) @@ -664,7 +676,7 @@ export const EvidenceDoc = forwardRef + panelId={panelId} model={model} ask={ask} calibers={caliberMarks} /> {mode === 'inline' && b === activeBlock && panelNode} ))} @@ -741,7 +753,7 @@ function useSelectionPick(root: RefObject, anchor: RefObject const start = at(range.startContainer) if (!inside(start) || !inside(at(range.endContainer))) { setPick(null); return } const frag = range.cloneContents() - frag.querySelectorAll('[data-inquiry-ask], .ev-tag, .ev-badge, .sr-only').forEach((n) => n.remove()) + frag.querySelectorAll('[data-inquiry-ask], .ev-tag, .ev-badge, .ev-caliber, .sr-only').forEach((n) => n.remove()) const quote = (frag.textContent ?? '').replace(/\s+/g, ' ').trim() if ([...quote].length < 2) { setPick(null); return } const rects = range.getClientRects() @@ -878,8 +890,10 @@ export function ClaimTally({ claims, lead }: { claims: ClaimTallyCounts; lead: b /** 列表项的开头:结构片段里带着行首的 - / 1. 。列表项被分成几句时,后面几句接在同一项里 */ const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/ -const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask }: { +const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, active, panelId, model, ask, calibers }: { block: EvidenceBlock + /** 来自口径的数字:片段 id → 小标记(口径账本) */ + calibers?: CaliberMarks dense: boolean doc: EvidenceDocData /** 句末徽标从这里取:这一句眼下的判定(封存后追加的盖过文档里的) */ @@ -904,7 +918,7 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac } const unit = (u: EvidenceUnit, opts?: { code?: boolean; tag?: boolean }) => ( ) @@ -970,6 +984,23 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac } }) +/** 来自口径的数字上的小标记:片段 id → 标记上的字(级别)、悬停、读屏 */ +type CaliberMarks = Map + +function caliberMarksOf(labels: Record | undefined): CaliberMarks | undefined { + if (!labels) return undefined + const out: CaliberMarks = new Map() + for (const [id, label] of Object.entries(labels)) { + if (label.kind !== 'caliber') continue + const how = LEDGER_TEXT.how[label.how === 'composed' ? 'composed' : 'independent'] + const caliber = caliberOf(label) + const level = label.level_text || (label.level ? (LEDGER_TEXT.level as Record)[label.level] ?? '' : '') + out.set(id, { text: level || LEDGER_TEXT.kind.caliber, level: label.level ?? '', title: LEDGER_UI.mark.title(caliber, how), + aria: LEDGER_UI.mark.aria(caliber, how) }) + } + return out.size ? out : undefined +} + /** 反方审稿的判断在句末徽标里的样子。soft 是悬停、展开时铺的浅底,醒目的那两种不铺(见 LOUD_RING) */ interface MarkLook { icon: LucideIcon; color: string; soft: string; loud: boolean } @@ -1016,9 +1047,10 @@ function ChallengeMark({ verdict, look, after }: { verdict: string; look: MarkLo * 一枚徽标(也是按钮),证据相矛盾(和旧的证据不支持)的整句铺浅底——字本身不画线。被反方审过的 * 句子同样挂徽标(和判定并在一起),点开都是整句面板 */ -function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }: { +function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask, calibers }: { unit: EvidenceUnit; doc: EvidenceDocData; current: string | null; active: string | null; panelId: string code?: boolean; tag: boolean; badge?: Badge + calibers?: CaliberMarks /** * 审批质询的「质询」:roving 是这一句一个可点的都没有,「质询」本身进键盘顺序;follow 跟在这一句后面,焦点在这一句上时 * 按 Tab 到达 @@ -1041,7 +1073,8 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask } const meta = EVIDENCE_STATE[state] if (meta.alert && TAG_TEXT[state]) alerts.add(state) const open = active === s.id - return ( + const mark = calibers?.get(s.id) + const button = ( ) + if (!mark) return button + // 来自口径的数:后面挂一个小标记(受管 / 已发布),悬停写是哪一份口径、怎么认的。不进键盘顺序(读屏念在片段上) + return ( + + {button} + + + ) }) const key = badge ? badgeKey(unit.id) : '' const meta = badge?.state ? EVIDENCE_STATE[badge.state] : null diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index c8fe069d..87df5131 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -34,6 +34,7 @@ import { ArtifactViewer, ResultTable } from './AssistantStream' import { ChallengeSummary, ClaimVerdictPending, type ClaimWait } from './ChallengeSummary' import { useEvidenceHost } from './evidenceHost' import { EvidenceProvenance } from './EvidenceProvenance' +import { LedgerPart } from './LedgerStep' import { CopyChip } from './Markdown' import { SqlCheckList } from './SqlChecks' import { @@ -86,6 +87,10 @@ import { * 输入来源里只说核对结果、不写值;遮没遮核对不了的(快照读不出来)同样不显示值,写明原因。类型都在 types.ts * (EvidenceDualCheck、EvidenceUnitChallenge、EvidenceInput 的 masked / mask_unverified)。 * + * 口径账本(next-round 02 做法 3、6):数字、取值片段的出处链最上面多「口径」一步(run/LedgerStep 的 LedgerPart)—— + * 来自口径「X」v3(受管)· 参数 · 等价已证明 / 助手选用 · 按口径正式出具;相关口径的差在哪和看差多少(口径对账);这次的 + * 推理。比对在封存之后做,不在封存范围内,脚注写明;比对还没出结果时写「正在比对口径」并按退避再取。 + * * 四种摆法:side 从右侧弹出(宽屏),drawer 从底部抽出(窄屏),inline 在 360px 的 * 画布右栏里直接栏内展开,dock 放进页面给的一块常驻位置(记录页的「证据」页签)。 * 都不是模态的:开着面板照样能在正文里走。 @@ -424,6 +429,13 @@ function SegmentBody({ panelId, doc, artifact, runId, runClass, seg, unit, block + {state === 'deterministic' && (isMetric || isCell) && !docIssue && ( + // 口径账本:出处链最上面的「口径」一步(来自口径 / 相关口径 / 这次的推理)。比对在封存之后做,这一步按运行另取 + // (GET /runs/{id}/ledger,还在比对时按退避再取);片段接口的 ledger 键先顶上 + + )} + {bad && ( diff --git a/frontend/src/run/LedgerReconcile.tsx b/frontend/src/run/LedgerReconcile.tsx new file mode 100644 index 00000000..a07905e6 --- /dev/null +++ b/frontend/src/run/LedgerReconcile.tsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Scale } from 'lucide-react' +import { ApiError, api } from '../api/client' +import { EvidenceDrawer, type EvidenceTarget } from '../apps/EvidenceDrawer' +import { Field, Modal, Spinner, StatusBadge, toast } from '../components/ui' +import { errorMessage } from '../lib/errors' +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, +} from '../typesLedger' +import { BindingList, DiffList, caliberOf } from './LedgerStep' + +/** + * 口径对账(docs/design/next-round/02-口径账本.md 做法 6 的界面):「差在哪」比对时就有,「差多少」点开才跑一次确定性 + * 派生的只读图(来源 reconcile,服务端专用),差值在对账卡里算(叙述层无算术,界面也不算)。 + * + * 流程:核对参数 → 发起 → 三个数各自点得开。 + * - 核对参数:服务端在动作里写了 needs_check(参数是推断的、不全的、带遮罩值的)就先让人核对,表单照动作里的入口字段画 + * (必填、有没有缺省值),遮罩值不回填、要人重新填写;不用核对的照样能「修改参数」,改了就按核对过的参数发起。 + * - 发起:服务端认不出这次的哪一列对口径的哪个指标时回 409(ledger_reconcile_needs_pairs)、带上可选的列和指标,这里照它 + * 画「选择要对照的指标」,人选好再发起;参数不对的回 422 / 409,把服务端的话写在表单下面。 + * - 结果(ReconcileResults):每一组对照写按你的问法、按口径、差值(另附相对差),每个数按 + * /runs/{对账运行}/evidence/metrics/{对账卡}/{指标} 取值,点一下打开它的出处链(apps/EvidenceDrawer,和应用页点数同一套)。 + * 哪几个指标是一组由服务端给(reconciled.groups),这里不按命名规则拼。 + */ + +const NEEDS_PAIRS = 'ledger_reconcile_needs_pairs' +const CHECK_INPUTS = 'ledger_reconcile_check_inputs' + +/** 核对参数的表单:动作里的入口字段(老答复没有时按绑定的字段),值先填绑定出来的,遮罩值空着 */ +function fieldsOf(action: LedgerReconcileAction): LedgerField[] { + if (action.fields?.length) return action.fields + return action.binding.map((b) => ({ name: b.field, required: false, has_default: false })) +} + +function draftOf(action: LedgerReconcileAction): Record { + const out: Record = {} + for (const f of fieldsOf(action)) { + const b = action.binding.find((x) => x.field === f.name) + out[f.name] = b && !b.masked && b.value != null ? String(b.value) : '' + } + return out +} + +export function ReconcileDialog({ runId, action, related, onClose }: { + runId: string; action: LedgerReconcileAction; related?: LedgerRelated; onClose: () => void +}) { + const refresh = useLedger((s) => s.refresh) + const fields = fieldsOf(action) + const [values, setValues] = useState>(() => draftOf(action)) + const [editing, setEditing] = useState(action.needs_check) + const [options, setOptions] = useState(null) + const [pairs, setPairs] = useState>({}) + const [busy, setBusy] = useState(false) + const [problem, setProblem] = useState('') + const caliber = caliberOf(action) + const masked = new Set(action.binding.filter((b) => b.masked).map((b) => b.field)) + const chosen = Object.entries(pairs).filter(([, m]) => !!m).map(([ours, metric]) => ({ ours, metric })) + const dirty = editing && JSON.stringify(values) !== JSON.stringify(draftOf(action)) + + const launch = async () => { + if (options && !chosen.length) { setProblem(LEDGER_UI.reconcile.pairEmpty); return } + setBusy(true) + setProblem('') + const body: LedgerReconcileIn = { query: action.query, workflow_id: action.workflow_id, version: action.version } + if (editing) { + // 空着的不送:留空的项按口径的缺省值(服务端按入口字段校验,缺必填的照实拒收) + body.inputs = Object.fromEntries(Object.entries(values).filter(([, v]) => v.trim() !== '').map(([k, v]) => [k, v.trim()])) + body.confirm = true + } + if (options) body.pairs = chosen + try { + await api.caliberLedger.reconcile(runId, body) + toast.ok(LEDGER_UI.reconcile.launched) + refresh(runId) + onClose() + } catch (e) { + if (e instanceof ApiError && e.code === NEEDS_PAIRS) { + const raw = (e.body as { options?: LedgerPairOptions } | undefined)?.options + if (raw && Array.isArray(raw.columns) && Array.isArray(raw.metrics)) setOptions(raw) + setProblem(e.message) + } else if (e instanceof ApiError && e.code === CHECK_INPUTS) { + setEditing(true) + setProblem(e.message) + } else { + setProblem(errorMessage(e)) + } + } finally { + setBusy(false) + } + } + + return ( + + + + }> +
+

{LEDGER_UI.reconcile.intro}

+ {related && ( +
+
{LEDGER_UI.reconcile.where}
+ +
+ )} +
+
+ {LEDGER_UI.reconcile.params} + {!editing && fields.length > 0 && ( + + )} +
+ {editing ? ( + <> +

+ {action.check_reason || LEDGER_UI.reconcile.paramsHint} +

+
+ {fields.map((f) => ( + + {(p) => ( + setValues((v) => ({ ...v, [f.name]: e.target.value }))} /> + )} + + ))} +
+ + ) : ( +
+

{LEDGER_UI.reconcile.paramsFixed}

+ +
+ )} +
+ {options && ( +
+
{LEDGER_UI.reconcile.pairs}
+

{LEDGER_UI.reconcile.pairsHint}

+
+ {options.columns.map((col) => ( + + ))} +
+
+ )} + {problem &&

{problem}

} +
+
+ ) +} + +/** 正在取的指标值,按(运行、卡、指标)记一份:同一组数在面板、轮次两处挂着时只取一次 */ +const values = new Map>() +function valueOf(runId: string, card: string, metric: string): Promise { + const key = `${runId}\u0000${card}\u0000${metric}` + let p = values.get(key) + if (!p) { + p = api.apps.metricEvidence(runId, card, metric).then((d) => String(d?.rendered ?? '')) + values.set(key, p) + p.catch(() => values.delete(key)) + } + return p +} + +/** 对账卡上的一个数:取值,点一下打开它的出处链 */ +function MetricValue({ runId, card, metric, what, label, onOpen }: { + runId: string; card: string; metric: string; what: string; label: string; onOpen: (t: EvidenceTarget) => void +}) { + const [text, setText] = useState(null) + const [failed, setFailed] = useState(false) + useEffect(() => { + let alive = true + setText(null) + setFailed(false) + valueOf(runId, card, metric).then((v) => { if (alive) setText(v) }, () => { if (alive) setFailed(true) }) + return () => { alive = false } + }, [runId, card, metric]) + return ( + + {what} + + + ) +} + +const SHOWN = 3 + +function GroupRow({ runId, card, group, onOpen }: { + runId: string; card: string; group: LedgerReconcileGroup; onOpen: (t: EvidenceTarget) => void +}) { + const R = LEDGER_UI.result + return ( +
  • + {group.label} + + + + +
  • + ) +} + +function ReconcileCard({ item, data, original }: { item: LedgerReconciled; data?: RunLedger; original?: string }) { + const builder = useAtLeast('builder') + const [all, setAll] = useState(false) + const [target, setTarget] = useState(null) + const c = (data?.calibers ?? []).find((x) => x.caliber === item.caliber) + const caliber = c ? caliberOf(c) : caliberOf({ version: item.version }) + const groups = item.groups ?? [] + const shown = all ? groups : groups.slice(0, SHOWN) + const R = LEDGER_UI.result + const state = item.status === 'succeeded' ? 'succeeded' : item.status === 'failed' ? 'failed' + : item.status === 'cancelled' ? 'cancelled' : 'running' + return ( +
    +
    + + {R.title(caliber)} + {item.inputs_from && R.inputs[item.inputs_from] && {R.inputs[item.inputs_from]}} + {builder && {R.openRun}} +
    + {state === 'running' &&

    {R.running}

    } + {state === 'failed' &&

    {R.failed}

    } + {state === 'cancelled' &&

    {R.cancelled}

    } + {state === 'succeeded' && item.card && groups.length > 0 && ( + <> +
      + {shown.map((g) => )} +
    + {groups.length > SHOWN && ( + + )} + {original &&

    {R.original(original)}

    } + + )} + setTarget(null)} /> +
    + ) +} + +/** 对过的账(新的在前)。original:从证据面板打开时,正文里这个数写的是什么 */ +export function ReconcileResults({ items, data, original }: { + runId: string; items: LedgerReconciled[]; data?: RunLedger; original?: string +}) { + const sorted = useMemo(() => items, [items]) + if (!sorted.length) return null + return ( +
    + {sorted.map((item) => )} +
    + ) +} diff --git a/frontend/src/run/LedgerStep.tsx b/frontend/src/run/LedgerStep.tsx new file mode 100644 index 00000000..ce95db74 --- /dev/null +++ b/frontend/src/run/LedgerStep.tsx @@ -0,0 +1,325 @@ +import { useState, type ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { BookMarked, RotateCw, Scale } from 'lucide-react' +import { ApiError, api } from '../api/client' +import { Spinner, StatusBadge, toast } from '../components/ui' +import { errorMessage } from '../lib/errors' +import { LEDGER_TEXT } from '../lib/terms' +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, +} from '../typesLedger' +import { ReconcileDialog, ReconcileResults } from './LedgerReconcile' + +/** + * 口径账本在证据里的那一部分(docs/design/next-round/02-口径账本.md 做法 3、5、6 的界面): + * - 证据面板出处链最上面的「口径」一步(LedgerPart):来自口径——「X」v3(受管)· 参数 · 怎么认的(等价已证明 / 助手选用) + * · 按口径正式出具;相关口径——差在哪 + 看差多少(口径对账:核对参数 → 发起 → 三个数各自点得开);这次的推理—— + * 写「没有相关的已发布口径」;没比对的写原因。比对还没出结果时写「正在比对口径」,store/ledger 按退避轮询, + * 等满了还没结论照实说、给「再取一次」; + * - 按口径正式出具那一项(IssueControl)和出具过的正式运行(IssuedList):问数据的轮次摘要用同一份。 + * + * 界面层不放逻辑:标签、能不能出具、能不能自动、不能的原因、要不要先核对参数都是服务端给的(GET /runs/{id}/ledger), + * 这里只照着画。比对在封存之后做,脚注写明「封存后比对,不在封存范围内」,事后补比的另写按哪种口径回看。 + */ + +/** 级别的叫法:服务端给 level_text,老答复没有时按 LEDGER_TEXT 的说法 */ +export const levelOf = (x: { level?: string | null; level_text?: string }) => + x.level_text || (x.level ? (LEDGER_TEXT.level as Record)[x.level] ?? '' : '') + +export const caliberOf = (x: { workflow_name?: string | null; version?: number | null; level?: string | null; level_text?: string }) => + caliberName(x.workflow_name, x.version, levelOf(x)) + +/** 一次运行的口径账本里,某份报告的某个片段的标签:先看按运行取的那一份,没有时退回片段接口的 ledger 键 */ +export function labelOf(data: RunLedger | undefined, report: string | undefined, segId: string, + fallback?: SegmentLedger | null): LedgerLabel | undefined { + const fromRun = report ? data?.reports?.[report]?.labels?.[segId] : undefined + return fromRun ?? fallback?.label ?? undefined +} + +/** 能不能取账本:正式运行本身就是某一版口径的出具,不比对;没有运行(预览、导出的文档)也没有 */ +export const ledgerEnabled = (runId: string | undefined, runClass: string | undefined) => !!runId && runClass !== 'formal' + +/** + * 取账本出了错要不要说:服务端说这次运行不存在(404)时不说——证据链那边已经照实说了运行取不到,这里再挂一句「口径比对 + * 读不出」是同一件事说两遍;别的错(服务端出错、连不上)照实说、给「再取一次」 + */ +export const ledgerErrorShown = (slot: LedgerSlot | undefined) => + slot?.status === 'error' && !(slot.error instanceof ApiError && slot.error.status === 404) + +/** 绑定的参数:字段 = 值,遮罩值只写「已遮罩」 */ +export function BindingList({ binding }: { binding?: LedgerBinding[] }) { + if (!binding?.length) return {LEDGER_UI.step.noParams} + return ( + + {binding.map((b) => ( + + {b.field} = {b.masked ? LEDGER_UI.step.masked : String(b.value ?? '')} + + ))} + + ) +} + +/** 比对还没出结论 / 等满了 / 读不出:三种都说清,后两种给「再取一次」 */ +export function LedgerWaiting({ slot, runId, compact = false }: { slot: LedgerSlot; runId: string; compact?: boolean }) { + const refresh = useLedger((s) => s.refresh) + const retry = ( + + ) + if (slot.status === 'error') { + return ( +
    + {LEDGER_UI.step.failed}:{errorMessage(slot.error)}{retry} +
    + ) + } + if (slot.timedOut) { + return ( +
    + {compact ? LEDGER_UI.turn.timeout : LEDGER_UI.step.timeout}{retry} +
    + ) + } + return ( +
    + {LEDGER_UI.step.pending} + {!compact && {LEDGER_UI.step.pendingSub}} +
    + ) +} + +/** 比对的来历:封存后比对,不在封存范围内;事后补比的写按哪种口径回看 */ +export function LedgerFootnote({ data }: { data: RunLedger }) { + return ( +

    + {[data.note || LEDGER_TEXT.sub, data.retro ? data.retro_note || LEDGER_TEXT.retro : ''].filter(Boolean).join(' · ')} +

    + ) +} + +/** + * 按口径正式出具(整题等价时)。服务端给了能不能、能不能自动、不能的原因:能自动的写「已在自动出具」、等出具的那一次出现 + * (等满了还没有,给按钮);只给一键的给按钮,按钮下写为什么不自动;不能出具的照写原因(使用者没有应用授权时是「请搭建者 + * 出具」)。同一运行、同一口径、同一组参数只发起一次:再点交回那一次 + */ +export function IssueControl({ runId, action, slot, readOnly = false }: { + runId: string; action: LedgerIssueAction; slot?: LedgerSlot; readOnly?: boolean +}) { + const refresh = useLedger((s) => s.refresh) + const [busy, setBusy] = useState(false) + const caliber = caliberOf(action) + const issued = action.issued_run || (slot?.data?.issued ?? []).some((i) => i.workflow_id === action.workflow_id + && i.version === action.version) + if (issued) return null + if (!action.available) { + return

    {action.reason}

    + } + if (action.auto && !slot?.timedOut) { + return ( +

    + {LEDGER_UI.issue.auto} +

    + ) + } + const issue = async () => { + setBusy(true) + try { + const out = await api.caliberLedger.issue(runId, { workflow_id: action.workflow_id, version: action.version }) + toast.ok(out.created ? LEDGER_UI.issue.launched(caliber) : LEDGER_UI.issue.again(caliber)) + refresh(runId) + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + return ( +
    + + {action.auto && slot?.timedOut &&

    {LEDGER_UI.issue.autoTimeout}

    } + {!action.auto && action.auto_reason && ( +

    {action.auto_reason}

    + )} +
    + ) +} + +/** 出具过的一次正式运行的状态:结束了写档位(完整出具 / 降档出具 / 不予出具),没结束写进行中 */ +function issuedState(i: LedgerIssued): { code: string; text: string } { + if (i.status === 'succeeded') return { code: 'succeeded', text: i.tier_text || LEDGER_TEXT.kind.caliber } + if (i.status === 'cancelled' && i.end_reason === 'rejected') return { code: 'rejected', text: LEDGER_UI.issued.rejected } + if (i.status === 'cancelled') return { code: 'cancelled', text: LEDGER_UI.issued.cancelled } + if (i.status === 'failed') return { code: 'failed', text: LEDGER_UI.issued.failed } + return { code: 'running', text: LEDGER_UI.issued.running } +} + +/** 出具过的正式运行,挂在轮次下面、面板里:点开去运行(使用者读不了正式运行的原始记录,写明由搭建者查看) */ +export function IssuedList({ data }: { data: RunLedger }) { + const builder = useAtLeast('builder') + const issued = data.issued ?? [] + if (!issued.length) return null + const nameOf = (i: LedgerIssued) => { + const c = (data.calibers ?? []).find((x) => x.workflow_id === i.workflow_id && x.version === i.version) + const a = (data.actions ?? []).find((x): x is LedgerIssueAction => x.kind === 'issue' && x.workflow_id === i.workflow_id + && x.version === i.version) + return caliberOf(c ?? a ?? { version: i.version }) + } + return ( +
      + {issued.map((i) => { + const st = issuedState(i) + return ( +
    • + + {LEDGER_UI.issued.title(i.auto)} + {nameOf(i)} + {st.text} + {builder + ? {LEDGER_UI.issued.open} + : {LEDGER_UI.issued.userNote}} +
    • + ) + })} +
    + ) +} + +/** 一份相关口径对应的那一项「看差多少」:同一条查询(指标的几条查询之一)、同一份口径 */ +export function reconcileActionFor(data: RunLedger | undefined, queries: string[], caliber: string | undefined) { + return (data?.actions ?? []).find((a): a is LedgerReconcileAction => a.kind === 'reconcile' && a.caliber === caliber + && queries.includes(a.query)) +} + +/** 差在哪:explain_sql_diff 的条目(SQL 片段服务端已按遮罩换过);兜底说明另写一句 */ +export function DiffList({ related }: { related?: LedgerRelated }) { + const items = (related?.diff?.items ?? []).filter((i) => i.text) + if (!items.length && !related?.diff?.fallback) { + return

    {related?.reason || LEDGER_UI.step.noDiff}

    + } + return ( +
      + {items.map((i, n) =>
    • {i.text}
    • )} + {related?.diff?.fallback && !items.length &&
    • {LEDGER_UI.step.fallback}
    • } +
    + ) +} + +/** 面板里的一小节:同 EvidencePanel 的 Part */ +function Section({ title, children, ...rest }: { title: string; children: ReactNode } & Record<`data-${string}`, string>) { + return ( +
    +
    {title}
    + {children} +
    + ) +} + +/** + * 证据面板出处链最上面的「口径」一步。只给数字、取值片段(单元格、指标):别的片段服务端本来就不标。正式运行、没有运行、 + * 不比对的运行不画这一步 + */ +export function LedgerPart({ runId, runClass, report, segId, segText, segLedger, readOnly = false }: { + runId?: string; runClass?: string; report?: string; segId: string + /** 正文里这个数写的是什么:对账结果里对照着说原答案当时的值 */ + segText?: string + /** 片段接口的 ledger 键(按运行取的那一份还没回来时先用它) */ + segLedger?: SegmentLedger | null + readOnly?: boolean +}) { + const on = ledgerEnabled(runId, runClass) + const slot = useRunLedger(runId, on) + const [dialog, setDialog] = useState<{ action: LedgerReconcileAction; related?: LedgerRelated } | null>(null) + if (!on || !runId) return null + const data = slot?.data + const label = labelOf(data, report, segId, segLedger) + if (!label) { + // 还在比对、等满了、读不出:照实说;比对过了这个片段没有标签(不是数)、不比对的、第一次还没取回来的:这一步不画 + if (!slot || (slot.status === 'loading' && !data)) return null + if (ledgerErrorShown(slot) || (slot.status !== 'error' && data?.state === 'pending')) { + return
    + } + if (data?.state === 'unreadable') { + return ( +
    +

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

    +
    + ) + } + return null + } + const queries = label.queries ?? (label.query ? [label.query] : []) + const issue = label.kind === 'caliber' && data?.whole?.equivalent + ? (data.actions ?? []).find((a): a is LedgerIssueAction => a.kind === 'issue' && a.workflow_id === label.workflow_id + && a.version === label.version) + : undefined + const how = label.how === 'composed' ? 'composed' : 'independent' + return ( +
    + {label.kind === 'caliber' && ( +
    +

    + {LEDGER_TEXT.kind.caliber}{caliberOf(label)} + {LEDGER_TEXT.how[how]} +

    +

    {LEDGER_UI.step.how[how]}

    +
    + {LEDGER_UI.step.params} + +
    + {label.column &&

    {LEDGER_UI.step.column(label.column)}

    } + {issue && } + {data && issue && + i.workflow_id === issue.workflow_id && i.version === issue.version) }} />} +
    + )} + {label.kind === 'related' && ( +
    +

    {LEDGER_UI.step.relatedSub}{label.reason ? `:${label.reason}` : ''}

    + {(label.related ?? []).map((r, i) => { + const action = reconcileActionFor(data, queries, r.caliber) + // 指标的相关口径只写是哪一版,差在哪在它的各条查询上 + const where = r.diff ? r : (data?.queries ?? []).filter((q) => queries.includes(q.artifact)) + .flatMap((q) => q.candidates).find((c) => c.caliber === r.caliber && c.status === 'related') + const done = (data?.reconciled ?? []).filter((x) => x.caliber === r.caliber && !!x.query && queries.includes(x.query)) + return ( +
    +

    {LEDGER_TEXT.kind.related}{caliberOf(r)}

    +
    +
    {LEDGER_UI.step.where}
    + +
    + {action ? ( + + ) : r.comparable === false ?

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

    : null} + {!!done.length && } +
    + ) + })} +
    + )} + {label.kind === 'reasoning' && ( +

    {LEDGER_TEXT.kind.reasoning} · {LEDGER_UI.step.reasoningSub}

    + )} + {label.kind === 'unknown' && ( +

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

    + )} + {data && } + {dialog && data && ( + setDialog(null)} /> + )} +
    + ) +} diff --git a/frontend/src/run/LedgerTurn.tsx b/frontend/src/run/LedgerTurn.tsx new file mode 100644 index 00000000..ab5e1e12 --- /dev/null +++ b/frontend/src/run/LedgerTurn.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react' +import { BookMarked, Scale } from 'lucide-react' +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 { ReconcileDialog, ReconcileResults } from './LedgerReconcile' + +/** + * 问数据轮次底部的口径账本(docs/design/next-round/02-口径账本.md 做法 11):一行摘要(「3 个数来自口径「周销售」v3, + * 1 个数是这次的推理」)和对应的动作——整题等价时按口径正式出具(能自动的写正在自动出具),有相关口径时对账; + * 正式出具的结果(自动的、一键的)挂在轮次下面、点开去运行;对过的账一张结果卡片,三个数各自点得开。 + * + * 比对在封存之后做:轮次刚交付时写「正在比对口径」,store/ledger 按退避再取,等满了还没结论照实说、给「再取一次」。 + * 不靠实时流(ledger.matched 追加时这次运行的事件总线已经关了)。界面层不放逻辑:每个数的标签、整题判断、能不能出具、 + * 能不能自动、不能的原因都是服务端给的;摘要只是把主报告里服务端标好的数按标签数一数、写出是哪份口径。 + */ + +/** 摘要的几个数:主报告里服务端标好的标签按种类数,来自口径的写出是哪几份口径(按出现的先后) */ +function summaryOf(data: RunLedger) { + const labels = Object.values((data.primary ? data.reports?.[data.primary]?.labels : undefined) ?? {}) + const counts = { caliber: 0, related: 0, reasoning: 0, unknown: 0 } + const calibers: string[] = [] + for (const l of labels) { + counts[l.kind] += 1 + if (l.kind === 'caliber') { + const name = caliberOf(l) + if (!calibers.includes(name)) calibers.push(name) + } + } + return { ...counts, calibers, total: labels.length } +} + +/** 对账的入口:每条查询、每份相关口径一项;差在哪取那条查询上的比对 */ +function reconcileEntries(data: RunLedger): { action: LedgerReconcileAction; related?: LedgerRelated }[] { + return (data.actions ?? []).filter((a): a is LedgerReconcileAction => a.kind === 'reconcile').map((action) => ({ + action, + related: (data.queries ?? []).find((q) => q.artifact === action.query)?.candidates + .find((c) => c.caliber === action.caliber && c.status === 'related'), + })) +} + +export function LedgerTurn({ runId, runClass, done, readOnly = false }: { + runId: string | undefined + runClass?: string + /** 这一轮交付了(运行成功结束):之前不取,运行没封存、没比对 */ + done: boolean + /** 对话在回收站里:只看,不出具、不对账 */ + readOnly?: boolean +}) { + const on = done && ledgerEnabled(runId, runClass) + const slot = useRunLedger(runId, on) + const [dialog, setDialog] = useState<{ action: LedgerReconcileAction; related?: LedgerRelated } | null>(null) + const data = slot?.data + const summary = useMemo(() => (data?.state === 'matched' ? summaryOf(data) : null), [data]) + if (!on || !runId || !slot) return null + if (ledgerErrorShown(slot) || (slot.status !== 'error' && data?.state === 'pending')) { + return ( +
    + +
    + ) + } + if (!data || data.state !== 'matched' || !summary) return 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 + const whole = data.whole + return ( +
    + {summary.total > 0 && ( +

    + + {LEDGER_UI.turn.label} + {LEDGER_UI.turn.summary(summary)} +

    + )} + {whole?.equivalent && ( +

    + {LEDGER_UI.turn.whole(caliberOf(whole), LEDGER_TEXT.how[whole.how === 'composed' ? 'composed' : 'independent'])} +

    + )} + {(issue || reconciles.length > 0) && ( +
    + {issue && } + {reconciles.map(({ action, related }) => ( + + ))} +
    + )} + + {reconciled.length > 0 && } + + {dialog && setDialog(null)} />} +
    + ) +} diff --git a/frontend/src/store/ledger.ts b/frontend/src/store/ledger.ts new file mode 100644 index 00000000..951700ca --- /dev/null +++ b/frontend/src/store/ledger.ts @@ -0,0 +1,180 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import { api } from '../api/client' +import type { LedgerIssueAction, RunLedger } from '../typesLedger' + +/** + * 一次运行的口径账本(GET /api/runs/{id}/ledger):证据正文里数字上的小标记、证据面板的「口径」一步、问数据轮次底部的 + * 摘要和动作都读这一份,同一次运行只取一份。 + * + * **比对结果不靠实时流。** 比对在封存之后放到后台做,结果是一条封存后追加的 ledger.matched——那时这次运行的事件总线已经 + * 关了,打开着的页面收不到(第 2 波遗留)。所以这里按退避轮询:服务端答 pending(正在比对,或者刚跑完、封存完就比)时 + * 隔 1、2、3、5、8、13 秒、之后每 15 秒再取一次,等满 MATCH_WAIT_MS 还没结论就停下、照实说(timedOut),给「再取一次」。 + * 比对完、要自动按口径出具的(动作写着 auto、还没有发起的那一次)再等一会儿,等到出具的正式运行出现;出具、对账的运行 + * 还没结束的,隔一阵再取,等它们结束(档位、三个数要等运行结束才有)。 + * + * 只在有人看着(有组件挂着 useRunLedger)时轮询;不比对的(ineligible)答复只当 INELIGIBLE_TTL_MS 内的结论:运行状态会变 + * (还在跑的运行、审批停着的运行之后才成功结束)。界面层不放逻辑:要不要接着等只看服务端给的 state、actions、运行状态。 + */ + +export interface LedgerSlot { + status: 'loading' | 'ok' | 'error' + data?: RunLedger + error?: unknown + /** 等满了还没有比对结论(或者自动出具一直没出现):停下来照实说 */ + timedOut?: boolean + /** 这一轮等待从什么时候开始(毫秒) */ + since?: number + fetchedAt?: number +} + +/** 等比对出结论最多多久 */ +export const MATCH_WAIT_MS = 120_000 +/** 比对完、动作写着要自动出具的:再等这么久,等出具的那一次运行出现 */ +export const AUTO_WAIT_MS = 60_000 +/** 出具、对账的运行还没结束:最多再等这么久 */ +export const RUN_WAIT_MS = 15 * 60_000 +const INELIGIBLE_TTL_MS = 20_000 +const BACKOFF = [1000, 2000, 3000, 5000, 8000, 13000] +const SLOW = 15_000 +/** 出具、对账运行还在跑时隔多久取一次 */ +const RUN_POLL = 5000 + +/** 运行还没结束的几种状态(interrupted 是停在审批或挂起,照样算没结束) */ +const LIVE = new Set(['queued', 'running', 'interrupted']) + +/** 动作写着要自动按口径出具、还没有发起的那一次(服务端判的能不能自动,这里只看它给的字段) */ +export function autoIssuePending(data: RunLedger | undefined): LedgerIssueAction | undefined { + if (!data || data.state !== 'matched') return undefined + const action = (data.actions ?? []).find((a): a is LedgerIssueAction => a.kind === 'issue' && a.auto && !a.issued_run) + return action && !(data.issued ?? []).some((i) => i.workflow_id === action.workflow_id && i.version === action.version) + ? action : undefined +} + +/** 出具、对账的运行里还有没结束的 */ +export const runsLive = (data: RunLedger | undefined): boolean => + !!data && [...(data.issued ?? []), ...(data.reconciled ?? [])].some((r) => LIVE.has(r.status)) + +interface LedgerState { + slots: Record + /** 有人看着这次运行:没取过、取失败了、不比对的结论过期了就取,按需接着轮询 */ + acquire: (runId: string) => void + release: (runId: string) => void + /** 刚发起了出具、对账,或者用户点了「再取一次」:马上取,重新开始等 */ + refresh: (runId: string) => void +} + +const watchers = new Map() +const timers = new Map>() +const attempts = new Map() +const inflight = new Map>() + +const clear = (runId: string) => { + const t = timers.get(runId) + if (t) clearTimeout(t) + timers.delete(runId) +} + +export const useLedger = create((set, get) => { + const patch = (runId: string, next: Partial) => + set((s) => ({ slots: { ...s.slots, [runId]: { ...(s.slots[runId] ?? { status: 'loading' }), ...next } } })) + + /** 下一次什么时候取(毫秒);不用再取了为 null。到点了还没结论的,顺手记 timedOut */ + const nextDelay = (runId: string): number | null => { + const slot = get().slots[runId] + const data = slot?.data + const since = slot?.since ?? Date.now() + const waited = Date.now() - since + const n = attempts.get(runId) ?? 0 + const backoff = BACKOFF[n] ?? SLOW + if (slot?.status === 'error') return null + if (data?.state === 'pending') { + if (waited >= MATCH_WAIT_MS) { patch(runId, { timedOut: true }); return null } + return backoff + } + if (autoIssuePending(data)) { + if (waited >= AUTO_WAIT_MS) { patch(runId, { timedOut: true }); return null } + return backoff + } + if (runsLive(data)) return waited >= RUN_WAIT_MS ? null : Math.max(RUN_POLL, Math.min(backoff, SLOW)) + return null + } + + const schedule = (runId: string) => { + clear(runId) + if (!watchers.get(runId)) return + const delay = nextDelay(runId) + if (delay == null) return + attempts.set(runId, (attempts.get(runId) ?? 0) + 1) + timers.set(runId, setTimeout(() => { timers.delete(runId); void load(runId) }, delay)) + } + + const load = (runId: string): Promise => { + const going = inflight.get(runId) + if (going) return going + const before = get().slots[runId] + if (!before?.data) patch(runId, { status: 'loading' }) + const p = api.caliberLedger.run(runId).then( + (data) => { + const prev = get().slots[runId]?.data + // 等的东西变了(比对出了结论、自动出具出现了):等待从这一刻重新算 + const moved = !!prev && (prev.state !== data.state + || (prev.issued?.length ?? 0) !== (data.issued?.length ?? 0)) + if (moved) attempts.set(runId, 0) + patch(runId, { status: 'ok', data, error: undefined, fetchedAt: Date.now(), timedOut: false, + ...(moved ? { since: Date.now() } : {}) }) + }, + (error) => { patch(runId, { status: 'error', error, fetchedAt: Date.now() }) }, + ).finally(() => { + inflight.delete(runId) + schedule(runId) + }) + inflight.set(runId, p) + return p + } + + return { + slots: {}, + acquire: (runId) => { + watchers.set(runId, (watchers.get(runId) ?? 0) + 1) + const slot = get().slots[runId] + const stale = !slot || slot.status === 'error' + || (slot.data?.state === 'ineligible' && Date.now() - (slot.fetchedAt ?? 0) > INELIGIBLE_TTL_MS) + if (stale) { + attempts.set(runId, 0) + patch(runId, { since: Date.now(), timedOut: false }) + void load(runId) + } else if (!timers.has(runId) && !inflight.has(runId) && !slot.timedOut) { + schedule(runId) + } + }, + release: (runId) => { + const n = (watchers.get(runId) ?? 1) - 1 + if (n > 0) { watchers.set(runId, n); return } + watchers.delete(runId) + clear(runId) + }, + refresh: (runId) => { + clear(runId) + attempts.set(runId, 0) + patch(runId, { since: Date.now(), timedOut: false }) + void load(runId) + }, + } +}) + +/** + * 这次运行的口径账本。enabled 为假(运行还没结束、正式运行、没有运行)时不取:正式运行本身就是某一版口径的出具,不比对。 + * 同一次运行挂几处(正文、面板、轮次摘要)只取一份、只轮询一份 + */ +export function useRunLedger(runId: string | null | undefined, enabled = true): LedgerSlot | undefined { + const acquire = useLedger((s) => s.acquire) + const release = useLedger((s) => s.release) + const on = !!runId && enabled + useEffect(() => { + if (!on || !runId) return + acquire(runId) + return () => release(runId) + }, [on, runId, acquire, release]) + return useLedger((s) => (on && runId ? s.slots[runId] : undefined)) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 742ac45a..d5aa82e0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2527,6 +2527,13 @@ export interface EvidenceSegmentDetail { redacted?: { columns?: string[] } /** 可疑实体:最接近的已知名字(最多 3 个),给「是不是想写…」 */ closest?: (string | { alias?: string; name?: string; table?: string; kind?: string })[] + /** + * 口径账本(next-round 02 做法 3):这个数的口径标签和比对的来历。封存后比对,没比对过的运行、不是数的片段没有这个键 + * (老运行的返回一字不差)。形状见 typesLedger.ts + */ + ledger?: import('./typesLedger').SegmentLedger + /** 指标接口(/evidence/metrics/{node}/{metric})给的展示值:遮罩的写「已遮罩」 */ + rendered?: string } // ------------------------------------------------------------------------- diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts new file mode 100644 index 00000000..982c2dfd --- /dev/null +++ b/frontend/src/typesLedger.ts @@ -0,0 +1,321 @@ +/** + * 口径账本的接口约定(docs/design/next-round/02-口径账本.md;服务端 app/ledger/view.py、issue.py、reconcile.py、certify.py)。 + * + * 界面层不放逻辑:每个数的标签、整题判断、能做的动作(能不能出具、能不能自动、不能的原因、要不要先核对参数)都由服务端 + * 给,界面照着画。比对在封存之后做(不在封存范围内),GET /api/runs/{id}/ledger 在比对还没出结果时答 state=pending, + * 页面按退避再取(store/ledger.ts),不靠实时流:ledger.matched 在事件总线关闭之后才追加,打开着的页面收不到。 + */ + +/** 绑定的一个参数:值是这次运行查到过的遮罩值时不给值(masked),也不能拿它代填出具 */ +export interface LedgerBinding { + field: string + value?: unknown + masked?: boolean +} + +/** 差在哪的一条(explain_sql_diff 的条目,SQL 片段已按遮罩换过)。text 是一句中文说明 */ +export interface LedgerDiffItem { + aspect?: string + a?: unknown + b?: unknown + text?: string +} + +export interface LedgerDiff { + items: LedgerDiffItem[] + /** 兜底说明:两条 SQL 对不上结构,没能归纳成具体差异 */ + fallback: boolean + text_diff: string[] +} + +/** 一份相关口径(同一个数据源、查了同样的表,但不等价) */ +export interface LedgerRelated { + caliber?: string + workflow_id?: string + version?: number + level?: string + level_text?: string + workflow_name?: string | null + node_id?: string + comparable?: boolean + reason?: string + via?: string + binding?: LedgerBinding[] + diff?: LedgerDiff | null +} + +/** + * 一个数的标签(四种): + * - caliber:来自口径「X」vN(how:independent 等价已证明 / composed 助手选用) + * - related:相关口径(附差在哪),数本身是这次的推理 + * - reasoning:这次的推理,没有相关的已发布口径 + * - unknown:没比对(reason 写为什么) + */ +export interface LedgerLabel { + kind: 'caliber' | 'related' | 'reasoning' | 'unknown' + how?: 'independent' | 'composed' + caliber?: string + workflow_id?: string + version?: number + level?: string + level_text?: string + workflow_name?: string | null + binding?: LedgerBinding[] + related?: LedgerRelated[] + reason?: string + /** 出自哪条查询(查询快照工件);指标是 queries */ + query?: string + queries?: string[] + node_id?: string + column?: string | null + card?: string + metric?: string +} + +/** 一条查询的比对:proven / composed / related / none / skipped */ +export interface LedgerQuery { + artifact: string + node_id?: string + source?: string + tables?: string[] + status: 'proven' | 'composed' | 'related' | 'none' | 'skipped' | string + reason?: string + note?: string + candidates: (LedgerRelated & { status?: string; how?: string })[] +} + +/** 整题判断(主报告):所有带标签的数都来自同一口径的同一版、参数一致时 equivalent */ +export interface LedgerWhole { + equivalent: boolean + reason?: string + counts?: Partial> + caliber?: string + workflow_id?: string + version?: number + level?: string + level_text?: string + workflow_name?: string | null + how?: 'independent' | 'composed' + binding?: LedgerBinding[] + missing?: string[] + missing_required?: string[] +} + +/** 按口径正式出具这一项:能不能(available / reason)、能不能自动(auto / auto_reason)、已经发起过的那一次(issued_run) */ +export interface LedgerIssueAction { + kind: 'issue' + workflow_id: string + version: number + caliber: string + workflow_name: string + how?: 'independent' | 'composed' | null + level?: string | null + level_text: string + available: boolean + auto: boolean + reason?: string + auto_reason?: string + issued_run?: string +} + +/** 入口字段(比对工件里记的那一份):核对参数的表单照它画 */ +export interface LedgerField { + name: string + required: boolean + has_default: boolean +} + +/** 看差多少(口径对账)这一项 */ +export interface LedgerReconcileAction { + kind: 'reconcile' + query: string + caliber: string + workflow_id: string + version: number + node_id?: string + workflow_name?: string | null + level_text: string + /** 参数是推断的、不全的、带遮罩值的:要人先核对(check_reason 是原因) */ + needs_check: boolean + check_reason?: string + binding: LedgerBinding[] + /** 两条 SQL 对不上结构:多半要人选对照的指标 */ + fallback: boolean + fields?: LedgerField[] +} + +export type LedgerAction = LedgerIssueAction | LedgerReconcileAction + +/** 按口径出具过的一次正式运行。tier_text 是「完整出具 / 降档出具 / 不予出具」,运行没结束时为空 */ +export interface LedgerIssued { + run_id: string + status: string + end_reason?: string | null + workflow_id: string + version: number + tier?: string | null + tier_text?: string | null + auto: boolean + created_at?: string | null +} + +/** 对账卡上的一组对照:这次 / 口径 / 差值 / 相对差各是哪个指标 */ +export interface LedgerReconcileGroup { + label: string + ours: string + caliber: string + diff: string + rel: string +} + +/** 对过的一次账 */ +export interface LedgerReconciled { + run_id: string + status: string + query?: string + caliber?: string + workflow_id?: string + version?: number + /** binding:按比对时的参数;checked:人核对过的参数 */ + inputs_from?: 'binding' | 'checked' | string + rows?: number[] | null + card?: string + groups?: LedgerReconcileGroup[] + created_at?: string | null +} + +export interface LedgerCaliber { + caliber: string + workflow_id: string + version: number + level?: string + graph_hash?: string | null + workflow_name?: string | null + level_text: string +} + +/** + * GET /api/runs/{id}/ledger。state:matched 比对过;pending 正在比对(或刚跑完、封存完就比);ineligible 这次运行不比对 + * (reason 写为什么);unreadable 比对记录读不出 + */ +export interface RunLedger { + run_id: string + state: 'matched' | 'pending' | 'ineligible' | 'unreadable' + reason?: string + /** 「封存后比对,不在封存范围内」 */ + note?: string + artifact?: string + algo?: string + trigger?: string + retro?: boolean + matched_at?: string | null + post_seal?: boolean + /** 事后补比的:按现在的口径回看 / 按之后发布的口径回看 */ + retro_note?: string + calibers?: LedgerCaliber[] + queries?: LedgerQuery[] + reports?: Record }> + primary?: string | null + whole?: LedgerWhole + actions?: LedgerAction[] + issued?: LedgerIssued[] + reconciled?: LedgerReconciled[] +} + +/** 证据片段接口(GET /runs/{id}/evidence/segments/{seg})的 ledger 键:这个片段的标签和比对的来历 */ +export interface SegmentLedger { + label: LedgerLabel + artifact?: string + algo?: string + trigger?: string + retro?: boolean + matched_at?: string | null + post_seal?: boolean + note?: string + retro_note?: string +} + +/** POST /api/ledger/answers/{run}/issue 的答复:created 为假是同一次已经发起过、交回那一次 */ +export interface LedgerIssueOut { + run_id: string + created: boolean + issued: LedgerIssued +} + +/** POST /api/ledger/answers/{run}/reconcile 的请求体 */ +export interface LedgerReconcileIn { + query: string + workflow_id: string + version: number + inputs?: Record + confirm?: boolean + pairs?: { ours: string; metric: string }[] +} + +export interface LedgerReconcileOut { + run_id: string + reconciled: LedgerReconciled +} + +/** 认不出列怎么配时 409(ledger_reconcile_needs_pairs)另带的选项 */ +export interface LedgerPairOptions { + columns: string[] + metrics: { id: string; name: string; unit?: string }[] +} + +// ---------------------------------------------------------------- 认证队列(可沉淀的问题) + +export interface CertifyTurn { + run_id: string | null + conversation_id?: string | null + turn_id?: string | null + question: string +} + +export interface CertifyFrozen { + workflow_id: string + name: string + status?: string + published_version?: number | null +} + +/** 三种来源:确定性重复(SQL 形状相同)、问题聚类、被反复用的答案 */ +export interface CertifyItem { + id: string + kind: 'shape' | 'cluster' | 'reused' + question: string + turns: CertifyTurn[] + frozen_into?: CertifyFrozen[] + // shape + source?: string + shape?: string + runs?: number + conversations?: number + params?: { index: number; distinct: number }[] + constants?: number + related?: { caliber?: string; workflow_id?: string; version?: number; level?: string }[] + representative?: { run_id: string; query?: string } + peers?: string[] + // cluster + count?: number + // reused + uses?: { exports: number; app: boolean; extracted: boolean; canvas: boolean } +} + +export interface CertifyQueue { + items: CertifyItem[] + considered: { runs: number; matched: number; questions: number } + threshold: { runs: number; conversations: number; exports: number } + embedder?: unknown +} + +/** 固化成口径草稿(POST /copilot/from-run,freeze: true)的答复 */ +export interface FreezeOut { + workflow_id: string + name: string + nodes: number + edges: number + params: string[] + metrics: string[] + sources: CertifyTurn[] +}