账本前端:证据「口径」一步、数字上的小标记、问数据轮次摘要与出具 / 对账、可沉淀的问题、设置里的口径账本一组

第 2、3 波只有接口,这一波接上界面(docs/design/next-round/02-口径账本.md 做法 3–6、8 的界面、做法 11;总方案 X3、X10)。
界面层不放逻辑:标签、整题判断、能不能出具、能不能自动、不能的原因、要不要先核对参数都照 GET /api/runs/{id}/ledger 给的画。

- 比对结果不靠实时流(第 2 波遗留):ledger.matched 在封存之后追加、事件总线已经关了,打开着的页面收不到。store/ledger
  按退避轮询(1、2、3、5、8、13 秒,之后每 15 秒),服务端答 pending 时写「正在比对口径」,等满 2 分钟还没结论照实说、
  给「再取一次」;要自动出具的再等出具的那一次出现,出具、对账的运行没结束的等它们结束。只在有组件看着时轮询,同一次
  运行的正文、面板、轮次摘要共用一份
- 证据正文(EvidenceDoc):来自口径的数字后面挂一个小标记,写明受管 / 已发布,悬停写是哪一份口径、怎么认的;相关口径的
  数不加标记
- 证据面板出处链最上面加「口径」一步(run/LedgerStep):来自口径——「X」v3(受管)· 参数 · 等价已证明 / 助手选用 ·
  按口径正式出具;相关口径——差在哪 + 看差多少;这次的推理——没有相关的已发布口径;没比对的写原因;脚注写「封存后比对,
  不在封存范围内」,事后补比的写按哪种口径回看
- 口径对账(run/LedgerReconcile):核对参数(照动作里的入口字段画,推断的参数要先核对,遮罩值不回填)→ 发起(认不出列
  怎么配时照 409 带回的选项选对照的指标)→ 结果卡片:每组按你的问法、按口径、差值(附相对差)各自点得开出处链
  (apps/EvidenceDrawer,同应用页点数)
- 问数据轮次底部(run/LedgerTurn):一行摘要(几个数来自口径「X」vN、几个是这次的推理、几个没有比对)、整题等价时按口径
  正式出具(能自动的写正在自动出具;不能的照写原因,使用者没有应用授权写「请搭建者出具」)、有相关口径时对账;出具过的
  正式运行挂在下面,档位写完整出具 / 降档出具 / 不予出具,搭建者点开去运行;回收站里的对话只看不发起
- 可沉淀的问题(pages/ledger/CertifyQueue):工作流列表弹窗里的抽屉,确定性重复、问题聚类、被反复用的答案分开写;
  「固化成口径草稿」走 /copilot/from-run 的 freeze 并打开画布,聚类没有共同的 SQL、按人挑的那一轮固化;「不是口径」
  要写理由
- 设置(pages/settings/LedgerSettings,照 VerifySettings):自动按口径正式出具、每日上限、比对时限、升版后每次最多重算几组,
  和其余偏好共用保存条、整组写 ledger
- 类型 typesLedger.ts、接口 api/caliberLedger.ts(挂 api.caliberLedger)、文案 lib/termsLedger.ts;types.ts 的证据片段
  答复加 ledger、rendered 两个键
This commit is contained in:
ThearchyHelios
2026-10-16 20:02:08 +08:00
parent a43224fd9a
commit b24aabefcf
16 changed files with 1917 additions and 22 deletions
+37
View File
@@ -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<RunLedger>(`/runs/${encodeURIComponent(runId)}/ledger`, opts),
issue: (runId: string, body: { workflow_id: string; version: number }) => post<LedgerIssueOut>(`${at(runId)}/issue`, body),
reconcile: (runId: string, body: LedgerReconcileIn) => post<LedgerReconcileOut>(`${at(runId)}/reconcile`, body),
certifyQueue: (opts?: RequestOptions) => get<CertifyQueue>('/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<FreezeOut>('/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 } : {}),
}),
}
+4
View File
@@ -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,
+18 -4
View File
@@ -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<string, string> = { extracted: '从运行提取', template: '模板' }
@@ -192,8 +195,11 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () =
const [filter, setFilter] = useState<Filter>('all')
const [cursor, setCursor] = useState(0)
const listRef = useRef<HTMLUListElement>(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 (
<Modal open={open} onClose={onClose} title="工作流" width={720}
footer={<>
<Modal open={open} onClose={onClose} title={queue ? LEDGER_UI.certify.title : '工作流'} width={720}
footer={queue ? undefined : <>
<span className="mr-auto self-center text-2xs text-faint">↑↓ 选择 · {formatShortcut('Enter')} 打开 · Esc 关闭</span>
<button className="btn btn-primary" onClick={async () => { if (await createWorkflow(navigate, refresh)) onClose() }}>
<Plus size={12} /> 新建空白工作流
</button>
</>}>
{queue ? <CertifyQueue onBack={() => setQueue(false)} onClose={onClose} beforeLeave={confirmDiscard} /> : <>
<div className="mb-2 flex items-center gap-2">
<div className="relative flex-1">
<Search size={12} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint" />
@@ -302,6 +309,12 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () =
</button>
))}
</div>
{builder && (
<button type="button" className="btn btn-sm shrink-0" title={LEDGER_UI.certify.openHint}
onClick={() => setQueue(true)} data-certify-open="">
<Layers size={12} aria-hidden /> {LEDGER_UI.certify.open}
</button>
)}
</div>
{!shown.length ? (
@@ -369,6 +382,7 @@ export function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () =
})}
</ul>
)}
</>}
</Modal>
)
}
+207
View File
@@ -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<string, string>,
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<string, string>,
kindHint: {
shape: '问数据里执行过、被报告引用、又没有对上任何口径的查询,按数据源和规范化后的 SQL 分组:出现在多次运行、多段对话里',
cluster: '只按问题原文聚类(不含对话历史),问法相近不等于口径相同,固化前请确认是同一个问题',
reused: '导出过多次、做成过应用、在画布里打开或提取过的答案',
} as Record<string, string>,
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: '口径账本的设置中有无效值,请修正后再保存',
},
}
+14 -4
View File
@@ -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) => <TurnExtras turnId={t.id} readOnly={!!trashed} />}
renderTurnActions={(t) => {
const turn = turns?.find((x) => x.id === t.id)
return currentId && turn && hasRemedies(turn)
? <Remedies conversationId={currentId} turnId={t.id} last={t.id === lastId} onRephrase={rephrase}
readOnly={!!trashed} />
: 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 && <LedgerTurn runId={turn.run!.id} runClass={turn.run!.run_class} done readOnly={!!trashed} />}
{hasRemedies(turn) && (
<Remedies conversationId={currentId} turnId={t.id} last={t.id === lastId} onRephrase={rephrase}
readOnly={!!trashed} />
)}
</>
)
}}
onFollowUp={(text) => {
// 追问是从表格里派生的一句话,点了就问;这会儿发不出去就先放进输入框
+30 -6
View File
@@ -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<VerifyDraft | null>(null)
const [verifySaves, setVerifySaves] = useState(0)
// 口径账本一组(自动按口径出具、每日上限、比对时限、升版后重算几组,pages/settings/LedgerSettings):草稿另放一份,共用保存条
const [ledgerDraft, setLedgerDraft] = useState<LedgerDraft | null>(null)
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState<unknown>(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<LedgerDraft>) => {
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 && <VerifySection cur={verifyCur} onEdit={editVerify} refresh={verifySaves}
errors={fieldError?.field === 'verify.provider' ? { ...verifyErrs, provider: fieldError.message } : verifyErrs} />}
{values.ledger && <LedgerSection cur={ledgerCur} errors={ledgerErrs} onEdit={editLedger} />}
{values.catalog_draft && (
<DraftModelSection provider={cur.draftProvider} model={cur.draftModel} onEdit={edit}
error={fieldError?.field === 'catalog_draft.provider' ? fieldError.message : undefined} />
@@ -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('、')}
</span>
</span>
{saveError != null && (
@@ -1255,12 +1278,13 @@ function PrefsTab() {
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button>
onClick={() => { setDraft(null); setVerifyDraft(null); setLedgerDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
: appsError ? APP_SETTING_TEXT.onDemandLimitInvalid
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid : undefined}
: verifyInvalid ? VERIFY_SETTING_TEXT.section.saveInvalid
: ledgerInvalid ? LEDGER_UI.settings.saveInvalid : undefined}
onClick={() => void save()}>
{saving ? <Spinner size={11} /> : <Check size={12} aria-hidden />} 保存设置
</button>
+208
View File
@@ -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<boolean>
}) {
const navigate = useNavigate()
const refreshCatalog = useCatalog((s) => s.refresh)
const workflows = useCatalog((s) => s.workflows)
const [queue, setQueue] = useState<Queue | null>(null)
const [error, setError] = useState<unknown>(null)
const [busy, setBusy] = useState<string | null>(null)
const load = () => {
setError(null)
setQueue(null)
api.caliberLedger.certifyQueue().then(setQueue, setError)
}
useEffect(load, [])
const nameOf = (r: NonNullable<CertifyItem['related']>[number]) => {
const level = r.level ? (LEDGER_TEXT.level as Record<string, string>)[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 (
<div className="sheet-in flex min-h-[52vh] flex-col" data-certify-drawer="" role="region" aria-label={C.title}>
<div className="mb-2 flex items-center gap-1.5">
<button type="button" className="btn btn-xs btn-ghost -ml-1" onClick={onBack} data-certify-back="">
<ChevronLeft size={13} aria-hidden /> {C.back}
</button>
</div>
<p className="mb-2 text-2xs leading-relaxed text-dim">{C.intro}</p>
{error ? <ErrorState error={error} onRetry={load} compact />
: !queue ? <Skeleton rows={4} height={40} />
: !groups.length ? (
<EmptyState offline={false} icon={<Layers size={20} />} title={C.empty}
body={<>{C.emptyBody(queue.considered.runs)}<br />{C.threshold(queue.threshold.runs, queue.threshold.conversations)}</>} />
) : (
<div className="max-h-[52vh] space-y-4 overflow-y-auto pr-0.5">
{groups.map((g) => (
<section key={g.kind} data-certify-kind={g.kind}>
<h4 className="text-xs font-semibold">{C.kinds[g.kind]} <span className="tnum font-normal text-faint">{g.items.length}</span></h4>
<p className="mb-1.5 mt-0.5 text-2xs leading-relaxed text-faint">{C.kindHint[g.kind]}</p>
<ul className="space-y-1.5">
{g.items.map((item) => (
<li key={item.id} className="rounded-lg border px-3 py-2" data-certify-item={item.id}>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium [overflow-wrap:anywhere]">{item.question || '—'}</div>
<div className="mt-0.5 text-2xs text-faint" data-certify-stats="">
{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)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{item.kind !== 'cluster' && (
<button type="button" className="btn btn-xs" disabled={!!busy} data-certify-freeze=""
onClick={() => void freeze(item)}>
{busy === `${item.id}:${item.representative?.run_id}` ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />}
{' '}{busy === `${item.id}:${item.representative?.run_id}` ? C.freezeBusy : C.freeze}
</button>
)}
<button type="button" className="btn btn-xs btn-ghost" disabled={!!busy} data-certify-dismiss=""
onClick={() => void dismiss(item)}>
<X size={11} aria-hidden /> {C.dismiss}
</button>
</div>
</div>
{item.kind === 'shape' && item.shape && (
<details className="mt-1.5">
<summary className="cursor-pointer text-2xs text-dim">{C.shape}</summary>
<pre className="mono mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-elev p-1.5 text-[11px]"
data-certify-shape="">{item.shape}</pre>
</details>
)}
{!!item.related?.length && (
<p className="mt-1 text-2xs text-dim" data-certify-related="">
{C.related}:{item.related.map(nameOf).join('、')}
</p>
)}
{!!item.frozen_into?.length && (
<p className="mt-1 flex flex-wrap items-center gap-1 text-2xs" data-certify-frozen="">
<span className="text-faint">{C.frozen}</span>
{item.frozen_into.map((w) => (
<Link key={w.workflow_id} to={`/studio/${w.workflow_id}`} onClick={onClose}
className="text-[var(--accent)] hover:underline">「{w.name}」</Link>
))}
</p>
)}
{!!item.turns.length && (
<details className="mt-1.5" open={item.kind === 'cluster'}>
<summary className="cursor-pointer text-2xs text-dim">{C.turns}({item.turns.length})</summary>
<ul className="mt-1 space-y-0.5">
{item.turns.map((t, i) => {
const href = turnHref(t)
return (
<li key={`${t.run_id}-${i}`} className="flex items-center gap-1.5 text-2xs" data-certify-turn={t.run_id ?? ''}>
<span className="min-w-0 flex-1 truncate" title={t.question}>{t.question || '—'}</span>
{href && (
<Link to={href} onClick={onClose} className="shrink-0 text-[var(--accent)] hover:underline">
{t.conversation_id ? C.openTurn : C.openRun}
</Link>
)}
{item.kind === 'cluster' && t.run_id && (
<button type="button" className="btn btn-xs btn-ghost shrink-0" disabled={!!busy}
data-certify-freeze-turn={t.run_id} onClick={() => void freeze(item, t)}>
{busy === `${item.id}:${t.run_id}` ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />}
{' '}{C.freezeTurn}
</button>
)}
</li>
)
})}
</ul>
</details>
)}
</li>
))}
</ul>
</section>
))}
</div>
)}
</div>
)
}
@@ -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<LedgerKey, string> = {
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<string, unknown> => !!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<Record<LedgerKey, string>> {
const out: Partial<Record<LedgerKey, string>> = {}
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<string, unknown> {
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<Record<LedgerKey, string>>
onEdit: (patch: Partial<LedgerDraft>) => void
}) {
const num = (k: Exclude<LedgerKey, 'auto_issue'>, hint: string, mode: 'numeric' | 'decimal' = 'numeric') => (
<Field label={LABEL[k]} htmlFor={id(k)} error={errors[k]} hint={hint}>
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode={mode} value={cur[k]} data-ledger-setting={k}
onChange={(e) => onEdit({ [k]: e.target.value } as Partial<LedgerDraft>)} />
)}
</Field>
)
return (
<section data-ledger-settings="">
<SectionBar title={S.title} hint={S.hint} />
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.auto_issue} data-ledger-setting="auto_issue"
aria-describedby="pref-ledger-auto-hint" onChange={(e) => onEdit({ auto_issue: e.target.checked })} />
<span>
{S.autoIssue}
<span id="pref-ledger-auto-hint" className="mt-0.5 block text-2xs leading-relaxed text-faint">{S.autoIssueHint}</span>
</span>
</label>
<div className="mt-3 grid grid-cols-2 gap-3 max-sm:grid-cols-1">
{num('issue_daily_limit', S.dailyLimitHint)}
{num('match_timeout_s', S.timeoutHint, 'decimal')}
{num('recall_limit', S.recallLimitHint)}
</div>
</section>
)
}
+53 -8
View File
@@ -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<EvidenceDocHandle, {
// 被反方审过的句子:句末挂标记(和裁判的判定并在一个徽标里),点开是整句面板
const marks = useMemo(() => 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<HTMLDivElement>(null)
@@ -664,7 +676,7 @@ export const EvidenceDoc = forwardRef<EvidenceDocHandle, {
<DocBlock block={block} dense={dense} doc={doc} visual={visual}
current={b === currentBlock ? current : null}
active={b === activeBlock ? activeSeg : null}
panelId={panelId} model={model} ask={ask} />
panelId={panelId} model={model} ask={ask} calibers={caliberMarks} />
{mode === 'inline' && b === activeBlock && panelNode}
</Fragment>
))}
@@ -741,7 +753,7 @@ function useSelectionPick(root: RefObject<HTMLElement | null>, 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 }) => (
<UnitView key={u.id} unit={u} doc={doc} current={current} active={active} panelId={panelId}
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))}
code={opts?.code} tag={opts?.tag ?? true} badge={model.badge.get(badgeKey(u.id))} calibers={calibers}
ask={ask && askable(u, block) ? (model.ask.has(askKey(u.id)) ? 'roving' : 'follow') : undefined} />
)
@@ -970,6 +984,23 @@ const DocBlock = memo(function DocBlock({ block, dense, doc, visual, current, ac
}
})
/** 来自口径的数字上的小标记:片段 id → 标记上的字(级别)、悬停、读屏 */
type CaliberMarks = Map<string, { text: string; level: string; title: string; aria: string }>
function caliberMarksOf(labels: Record<string, LedgerLabel> | 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<string, string>)[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 = (
<button
key={s.id}
type="button"
@@ -1052,7 +1085,7 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
// 实体、引文:线型和颜色照状态,另有自己的样子(引文前挂引号,见 index.css)
data-ev-kind={segmentKind(s) ?? undefined}
tabIndex={current === s.id ? 0 : -1}
aria-label={segmentLabel(s, doc)}
aria-label={mark ? `${segmentLabel(s, doc)}${mark.aria}` : segmentLabel(s, doc)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
style={segStyle(state)}
@@ -1060,6 +1093,18 @@ function UnitView({ unit, doc, current, active, panelId, code, tag, badge, ask }
{s.strong ? <strong className="font-semibold">{body}</strong> : body}
</button>
)
if (!mark) return button
// 来自口径的数:后面挂一个小标记(受管 / 已发布),悬停写是哪一份口径、怎么认的。不进键盘顺序(读屏念在片段上)
return (
<Fragment key={s.id}>
{button}
<sup className="ev-caliber ml-px select-none whitespace-nowrap rounded-sm px-0.5 text-[9px] font-medium"
style={{ color: 'var(--accent)', background: 'var(--accent-soft, transparent)' }}
aria-hidden="true" title={mark.title} data-ev-caliber={mark.level} data-ev-caliber-of={s.id}>
{mark.text}
</sup>
</Fragment>
)
})
const key = badge ? badgeKey(unit.id) : ''
const meta = badge?.state ? EVIDENCE_STATE[badge.state] : null
+12
View File
@@ -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
<Sentence unit={unit} active={seg.id} />
</Part>
{state === 'deterministic' && (isMetric || isCell) && !docIssue && (
// 口径账本:出处链最上面的「口径」一步(来自口径 / 相关口径 / 这次的推理)。比对在封存之后做,这一步按运行另取
// (GET /runs/{id}/ledger,还在比对时按退避再取);片段接口的 ledger 键先顶上
<LedgerPart runId={runId} runClass={runClass} report={report} segId={seg.id} segText={seg.text}
segLedger={detail?.ledger} />
)}
{bad && (
<Part title={state === 'suspect' ? '标记为可疑的原因' : state === 'unverified' ? '无法核实的原因' : '无证据的原因'}
data-ev-reason="">
+283
View File
@@ -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<string, string> {
const out: Record<string, string> = {}
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<Record<string, string>>(() => draftOf(action))
const [editing, setEditing] = useState(action.needs_check)
const [options, setOptions] = useState<LedgerPairOptions | null>(null)
const [pairs, setPairs] = useState<Record<string, string>>({})
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 (
<Modal open onClose={onClose} width={560} dirty={dirty} title={LEDGER_UI.reconcile.title(caliber)}
footer={<>
<button type="button" className="btn" onClick={onClose}>{LEDGER_UI.reconcile.cancel}</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void launch()}
data-ledger-reconcile-launch="">
{busy ? <Spinner size={12} /> : <Scale size={12} aria-hidden />} {busy ? LEDGER_UI.reconcile.busy : LEDGER_UI.reconcile.launch}
</button>
</>}>
<div className="space-y-3 text-xs" data-ledger-reconcile-dialog="">
<p className="text-dim">{LEDGER_UI.reconcile.intro}</p>
{related && (
<section>
<div className="mb-1 text-2xs font-medium text-faint">{LEDGER_UI.reconcile.where}</div>
<DiffList related={related} />
</section>
)}
<section data-ledger-params={editing ? 'edit' : 'fixed'}>
<div className="mb-1 flex items-center gap-2">
<span className="text-2xs font-medium text-faint">{LEDGER_UI.reconcile.params}</span>
{!editing && fields.length > 0 && (
<button type="button" className="btn btn-xs btn-ghost" onClick={() => setEditing(true)} data-ledger-edit-params="">
{LEDGER_UI.reconcile.editParams}
</button>
)}
</div>
{editing ? (
<>
<p className="mb-1.5 text-2xs" style={{ color: action.needs_check ? 'var(--st-waiting)' : undefined }}
data-ledger-check-reason="">
{action.check_reason || LEDGER_UI.reconcile.paramsHint}
</p>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{fields.map((f) => (
<Field key={f.name} label={f.name} htmlFor={`ledger-param-${f.name}`} required={f.required}
hint={f.has_default ? LEDGER_UI.reconcile.defaulted : f.required ? LEDGER_UI.reconcile.required : undefined}>
{(p) => (
<input {...p} className="field mono text-xs" value={values[f.name] ?? ''} data-ledger-param-input={f.name}
placeholder={masked.has(f.name) ? LEDGER_UI.reconcile.maskedPlaceholder : undefined}
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))} />
)}
</Field>
))}
</div>
</>
) : (
<div className="text-2xs">
<p className="mb-1 text-faint">{LEDGER_UI.reconcile.paramsFixed}</p>
<BindingList binding={action.binding} />
</div>
)}
</section>
{options && (
<section data-ledger-pairs="">
<div className="mb-1 text-2xs font-medium text-faint">{LEDGER_UI.reconcile.pairs}</div>
<p className="mb-1.5 text-2xs text-dim">{LEDGER_UI.reconcile.pairsHint}</p>
<div className="space-y-1.5">
{options.columns.map((col) => (
<label key={col} className="flex items-center gap-2 text-2xs">
<span className="mono w-28 shrink-0 truncate" title={col}>{col}</span>
<span className="text-faint" aria-hidden>→</span>
<select className="field text-xs" value={pairs[col] ?? ''} data-ledger-pair={col}
onChange={(e) => setPairs((p) => ({ ...p, [col]: e.target.value }))}>
<option value="">{LEDGER_UI.reconcile.pairNone}</option>
{options.metrics.map((m) => (
<option key={m.id} value={m.id}>{m.name}{m.unit ? `(${m.unit})` : ''}</option>
))}
</select>
</label>
))}
</div>
</section>
)}
{problem && <p className="text-2xs text-[var(--err)]" role="alert" data-ledger-reconcile-problem="">{problem}</p>}
</div>
</Modal>
)
}
/** 正在取的指标值,按(运行、卡、指标)记一份:同一组数在面板、轮次两处挂着时只取一次 */
const values = new Map<string, Promise<string>>()
function valueOf(runId: string, card: string, metric: string): Promise<string> {
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<string | null>(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 (
<span className="inline-flex items-baseline gap-1">
<span className="text-faint">{what}</span>
<button type="button" className="mono tnum rounded px-0.5 underline decoration-dotted underline-offset-2 hover:bg-hover"
disabled={text == null} title={LEDGER_UI.result.open(what, label)} data-ledger-value={metric}
onClick={() => onOpen({ evidence: { run_id: runId, kind: 'metric', node: card, metric }, label: `${label} · ${what}`,
text: text ?? '' })}>
{failed ? LEDGER_UI.result.noValue : text == null ? '…' : text || '—'}
</button>
</span>
)
}
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 (
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-2xs" data-ledger-group={group.ours}>
<span className="font-medium">{group.label}</span>
<MetricValue runId={runId} card={card} metric={group.ours} what={R.ours} label={group.label} onOpen={onOpen} />
<MetricValue runId={runId} card={card} metric={group.caliber} what={R.caliber} label={group.label} onOpen={onOpen} />
<MetricValue runId={runId} card={card} metric={group.diff} what={R.diff} label={group.label} onOpen={onOpen} />
<MetricValue runId={runId} card={card} metric={group.rel} what={R.rel} label={group.label} onOpen={onOpen} />
</li>
)
}
function ReconcileCard({ item, data, original }: { item: LedgerReconciled; data?: RunLedger; original?: string }) {
const builder = useAtLeast('builder')
const [all, setAll] = useState(false)
const [target, setTarget] = useState<EvidenceTarget | null>(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 (
<div className="mt-1.5 rounded border px-2 py-1.5" data-ledger-reconciled={item.run_id} data-ledger-reconciled-state={state}>
<div className="flex flex-wrap items-center gap-1.5 text-2xs">
<StatusBadge status={state} size={11} decorative />
<span className="font-medium">{R.title(caliber)}</span>
{item.inputs_from && R.inputs[item.inputs_from] && <span className="text-faint">{R.inputs[item.inputs_from]}</span>}
{builder && <Link to={`/runs/${item.run_id}`} className="text-[var(--accent)] hover:underline">{R.openRun}</Link>}
</div>
{state === 'running' && <p className="mt-1 flex items-center gap-1.5 text-2xs text-dim"><Spinner size={11} /> {R.running}</p>}
{state === 'failed' && <p className="mt-1 text-2xs text-dim">{R.failed}</p>}
{state === 'cancelled' && <p className="mt-1 text-2xs text-dim">{R.cancelled}</p>}
{state === 'succeeded' && item.card && groups.length > 0 && (
<>
<ul className="mt-1 space-y-1">
{shown.map((g) => <GroupRow key={g.ours} runId={item.run_id} card={item.card!} group={g} onOpen={setTarget} />)}
</ul>
{groups.length > SHOWN && (
<button type="button" className="btn btn-xs btn-ghost mt-0.5 -ml-1" onClick={() => setAll((v) => !v)}>
{all ? R.less : R.more(groups.length - SHOWN)}
</button>
)}
{original && <p className="mt-1 text-2xs text-faint" data-ledger-original="">{R.original(original)}</p>}
</>
)}
<EvidenceDrawer target={target} onClose={() => setTarget(null)} />
</div>
)
}
/** 对过的账(新的在前)。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 (
<div data-ledger-reconciled-list="">
{sorted.map((item) => <ReconcileCard key={item.run_id} item={item} data={data} original={original} />)}
</div>
)
}
+325
View File
@@ -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<string, string>)[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 <span className="text-faint">{LEDGER_UI.step.noParams}</span>
return (
<span className="flex flex-wrap gap-1" data-ledger-binding="">
{binding.map((b) => (
<span key={b.field} className="chip mono" data-ledger-param={b.field} data-masked={b.masked ? '' : undefined}>
{b.field} = {b.masked ? LEDGER_UI.step.masked : String(b.value ?? '')}
</span>
))}
</span>
)
}
/** 比对还没出结论 / 等满了 / 读不出:三种都说清,后两种给「再取一次」 */
export function LedgerWaiting({ slot, runId, compact = false }: { slot: LedgerSlot; runId: string; compact?: boolean }) {
const refresh = useLedger((s) => s.refresh)
const retry = (
<button type="button" className="btn btn-xs btn-ghost" onClick={() => refresh(runId)} data-ledger-retry="">
<RotateCw size={11} aria-hidden /> {LEDGER_UI.step.retry}
</button>
)
if (slot.status === 'error') {
return (
<div className="flex flex-wrap items-center gap-1.5 text-dim" data-ledger-state="error">
<span>{LEDGER_UI.step.failed}:{errorMessage(slot.error)}</span>{retry}
</div>
)
}
if (slot.timedOut) {
return (
<div className="flex flex-wrap items-center gap-1.5 text-dim" data-ledger-state="timeout">
<span>{compact ? LEDGER_UI.turn.timeout : LEDGER_UI.step.timeout}</span>{retry}
</div>
)
}
return (
<div data-ledger-state="pending" role="status">
<span className="flex items-center gap-1.5 text-dim"><Spinner size={11} /> {LEDGER_UI.step.pending}</span>
{!compact && <span className="mt-0.5 block text-2xs text-faint">{LEDGER_UI.step.pendingSub}</span>}
</div>
)
}
/** 比对的来历:封存后比对,不在封存范围内;事后补比的写按哪种口径回看 */
export function LedgerFootnote({ data }: { data: RunLedger }) {
return (
<p className="mt-1 text-2xs text-faint" data-ledger-footnote="">
{[data.note || LEDGER_TEXT.sub, data.retro ? data.retro_note || LEDGER_TEXT.retro : ''].filter(Boolean).join(' · ')}
</p>
)
}
/**
* 按口径正式出具(整题等价时)。服务端给了能不能、能不能自动、不能的原因:能自动的写「已在自动出具」、等出具的那一次出现
* (等满了还没有,给按钮);只给一键的给按钮,按钮下写为什么不自动;不能出具的照写原因(使用者没有应用授权时是「请搭建者
* 出具」)。同一运行、同一口径、同一组参数只发起一次:再点交回那一次
*/
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 <p className="text-2xs text-dim" data-ledger-issue="refused">{action.reason}</p>
}
if (action.auto && !slot?.timedOut) {
return (
<p className="flex items-center gap-1.5 text-2xs text-dim" data-ledger-issue="auto" role="status">
<Spinner size={11} /> {LEDGER_UI.issue.auto}
</p>
)
}
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 (
<div data-ledger-issue="manual">
<button type="button" className="btn btn-xs" disabled={busy || readOnly} onClick={() => void issue()}
title={readOnly ? LEDGER_UI.issue.readOnly : undefined} data-ledger-issue-button="">
{busy ? <Spinner size={11} /> : <BookMarked size={11} aria-hidden />} {busy ? LEDGER_UI.issue.busy : LEDGER_UI.issue.button}
</button>
{action.auto && slot?.timedOut && <p className="mt-0.5 text-2xs text-faint">{LEDGER_UI.issue.autoTimeout}</p>}
{!action.auto && action.auto_reason && (
<p className="mt-0.5 text-2xs text-faint" data-ledger-auto-reason="">{action.auto_reason}</p>
)}
</div>
)
}
/** 出具过的一次正式运行的状态:结束了写档位(完整出具 / 降档出具 / 不予出具),没结束写进行中 */
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 (
<ul className="space-y-1" data-ledger-issued-list="">
{issued.map((i) => {
const st = issuedState(i)
return (
<li key={i.run_id} className="flex flex-wrap items-center gap-1.5 text-2xs" data-ledger-issued={i.run_id}
data-ledger-issued-state={st.code}>
<StatusBadge status={st.code} size={11} decorative />
<span className="font-medium">{LEDGER_UI.issued.title(i.auto)}</span>
<span className="text-dim">{nameOf(i)}</span>
<span data-ledger-tier="">{st.text}</span>
{builder
? <Link to={`/runs/${i.run_id}`} className="text-[var(--accent)] hover:underline" data-ledger-issued-open="">{LEDGER_UI.issued.open}</Link>
: <span className="text-faint">{LEDGER_UI.issued.userNote}</span>}
</li>
)
})}
</ul>
)
}
/** 一份相关口径对应的那一项「看差多少」:同一条查询(指标的几条查询之一)、同一份口径 */
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 <p className="text-2xs text-faint">{related?.reason || LEDGER_UI.step.noDiff}</p>
}
return (
<ul className="list-disc space-y-0.5 pl-4" data-ledger-diff="">
{items.map((i, n) => <li key={n}>{i.text}</li>)}
{related?.diff?.fallback && !items.length && <li className="text-faint">{LEDGER_UI.step.fallback}</li>}
</ul>
)
}
/** 面板里的一小节:同 EvidencePanel 的 Part */
function Section({ title, children, ...rest }: { title: string; children: ReactNode } & Record<`data-${string}`, string>) {
return (
<div {...rest}>
<div className="mb-1 text-2xs font-medium text-faint">{title}</div>
{children}
</div>
)
}
/**
* 证据面板出处链最上面的「口径」一步。只给数字、取值片段(单元格、指标):别的片段服务端本来就不标。正式运行、没有运行、
* 不比对的运行不画这一步
*/
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 <Section title={LEDGER_UI.step.title} data-ev-ledger="waiting"><LedgerWaiting slot={slot} runId={runId} /></Section>
}
if (data?.state === 'unreadable') {
return (
<Section title={LEDGER_UI.step.title} data-ev-ledger="unreadable">
<p className="text-dim">{LEDGER_UI.step.unreadable(data.reason ?? '')}</p>
</Section>
)
}
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 (
<Section title={LEDGER_UI.step.title} data-ev-ledger={label.kind}>
{label.kind === 'caliber' && (
<div className="space-y-1">
<p className="font-medium" data-ledger-caliber="">
{LEDGER_TEXT.kind.caliber}{caliberOf(label)}
<span className="chip ml-1.5" data-ledger-how={how}>{LEDGER_TEXT.how[how]}</span>
</p>
<p className="text-2xs text-dim">{LEDGER_UI.step.how[how]}</p>
<div className="flex flex-wrap items-center gap-1 text-2xs">
<span className="text-faint">{LEDGER_UI.step.params}</span>
<BindingList binding={label.binding} />
</div>
{label.column && <p className="text-2xs text-faint">{LEDGER_UI.step.column(label.column)}</p>}
{issue && <IssueControl runId={runId} action={issue} slot={slot} readOnly={readOnly} />}
{data && issue && <IssuedList data={{ ...data, issued: (data.issued ?? []).filter((i) =>
i.workflow_id === issue.workflow_id && i.version === issue.version) }} />}
</div>
)}
{label.kind === 'related' && (
<div className="space-y-2">
<p className="text-2xs text-dim">{LEDGER_UI.step.relatedSub}{label.reason ? `:${label.reason}` : ''}</p>
{(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 (
<div key={r.caliber ?? i} className="rounded border px-2 py-1.5" data-ledger-related={r.caliber ?? ''}>
<p className="font-medium">{LEDGER_TEXT.kind.related}{caliberOf(r)}</p>
<div className="mt-1 text-2xs" data-ledger-where="">
<div className="text-faint">{LEDGER_UI.step.where}</div>
<DiffList related={where} />
</div>
{action ? (
<button type="button" className="btn btn-xs mt-1.5" disabled={readOnly}
title={readOnly ? LEDGER_UI.reconcile.readOnly : undefined}
onClick={() => setDialog({ action, related: where })} data-ledger-reconcile-open={r.caliber ?? ''}>
<Scale size={11} aria-hidden /> {LEDGER_UI.reconcile.button}
</button>
) : r.comparable === false ? <p className="mt-1 text-2xs text-faint">{r.reason || LEDGER_UI.step.notComparable}</p> : null}
{!!done.length && <ReconcileResults runId={runId} items={done} data={data} original={segText} />}
</div>
)
})}
</div>
)}
{label.kind === 'reasoning' && (
<p data-ledger-reasoning="">{LEDGER_TEXT.kind.reasoning}<span className="text-dim"> · {LEDGER_UI.step.reasoningSub}</span></p>
)}
{label.kind === 'unknown' && (
<p className="text-dim" data-ledger-unknown="">{LEDGER_TEXT.kind.unknown}{label.reason ? `:${label.reason}` : ''}</p>
)}
{data && <LedgerFootnote data={data} />}
{dialog && data && (
<ReconcileDialog runId={runId} action={dialog.action} related={dialog.related} onClose={() => setDialog(null)} />
)}
</Section>
)
}
+104
View File
@@ -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 (
<div className="w-full basis-full text-2xs" data-ledger-turn="waiting">
<LedgerWaiting slot={slot} runId={runId} compact />
</div>
)
}
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 (
<div className="w-full basis-full space-y-1.5 rounded-md border px-2.5 py-2 text-xs" data-ledger-turn="matched">
{summary.total > 0 && (
<p className="flex flex-wrap items-center gap-1.5" data-ledger-summary="">
<BookMarked size={12} className="shrink-0 text-faint" aria-hidden />
<span className="text-2xs font-medium text-faint">{LEDGER_UI.turn.label}</span>
<span>{LEDGER_UI.turn.summary(summary)}</span>
</p>
)}
{whole?.equivalent && (
<p className="text-2xs text-dim" data-ledger-whole="">
{LEDGER_UI.turn.whole(caliberOf(whole), LEDGER_TEXT.how[whole.how === 'composed' ? 'composed' : 'independent'])}
</p>
)}
{(issue || reconciles.length > 0) && (
<div className="flex flex-wrap items-start gap-1.5" data-ledger-actions="">
{issue && <IssueControl runId={runId} action={issue} slot={slot} readOnly={readOnly} />}
{reconciles.map(({ action, related }) => (
<button key={`${action.query}|${action.caliber}`} type="button" className="btn btn-xs" disabled={readOnly}
title={readOnly ? LEDGER_UI.reconcile.readOnly : undefined}
onClick={() => setDialog({ action, related })} data-ledger-turn-reconcile={action.caliber}>
<Scale size={11} aria-hidden /> {LEDGER_UI.reconcile.turnButton(caliberOf(action))}
</button>
))}
</div>
)}
<IssuedList data={data} />
{reconciled.length > 0 && <ReconcileResults runId={runId} items={reconciled} data={data} />}
<LedgerFootnote data={data} />
{dialog && <ReconcileDialog runId={runId} action={dialog.action} related={dialog.related} onClose={() => setDialog(null)} />}
</div>
)
}
+180
View File
@@ -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<string, LedgerSlot>
/** 有人看着这次运行:没取过、取失败了、不比对的结论过期了就取,按需接着轮询 */
acquire: (runId: string) => void
release: (runId: string) => void
/** 刚发起了出具、对账,或者用户点了「再取一次」:马上取,重新开始等 */
refresh: (runId: string) => void
}
const watchers = new Map<string, number>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const attempts = new Map<string, number>()
const inflight = new Map<string, Promise<void>>()
const clear = (runId: string) => {
const t = timers.get(runId)
if (t) clearTimeout(t)
timers.delete(runId)
}
export const useLedger = create<LedgerState>((set, get) => {
const patch = (runId: string, next: Partial<LedgerSlot>) =>
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<void> => {
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))
}
+7
View File
@@ -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
}
// -------------------------------------------------------------------------
+321
View File
@@ -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<Record<LedgerLabel['kind'], number>>
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<string, { labels: Record<string, LedgerLabel> }>
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<string, unknown>
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[]
}