口径召回前端(W4-a):版本用量加问数回答和召回、发布弹窗预告召回规模、版本历史看召回批次、recall 待办卡、时间线认两个新事件

为什么:前三波召回只有接口;这一波收口,口径升版之后谁的答案受影响、重算了几组、数变没变、怎么处置都要在界面上走得通。

- canvas/versionUsage:使用情况那一行加问数回答、导出、定时任务、召回(老服务端不给就不写)
- canvas/RecallPanel(新):发布弹窗「召回」一行——旧版本被谁用过、按参数去重几组、发布后自动重算几组、超出上限几组(可在
  版本历史接着算)、上限为 0 时只列不算、不能重算的原因;版本历史展开一版——每组结论(数变了 / 没变 / 无法重算:原因 /
  重算失败 / 正在重算 / 未重算)、变了的指标、开了几条待办、召回运行链接,「另有 M 组未重算」+ 接着算
- run/RecallTask(新)+ TaskCard:recall 待办卡——旧口径、新口径、两版之差一行一个指标,每个数点得开召回卡的指标链;每个答案
  当时的值点得开(正式运行的口径卡、问数回答的查询快照),问数回答链回对话;「按新版重新出具」(不能时停用并写服务端给的
  原因)、「知道了」;遮罩值写已遮罩、点不开;目录有变、表结构变了两种只有「知道了」
- run/decode:ledger.recalled(已按 vN 核对,不影响;召回核对数变没变;试算结果)、ledger.basis_changed(依据有变、依据的
  表结构已变);缺字段不猜
- 文案进 lib/termsRecall.ts;接口 api/recall.ts、形状 typesRecall.ts;TaskKind 加 recall
- 检查:check-inbox 加 recall 卡两段,check-publish 加召回预告和召回批次两段(版本历史、发布弹窗都在这里测;check-versions
  测的是上传表格的版本页,和召回无关),check-decode 加两个新事件的夹具,check-capabilities 的可订阅事件跟上 ledger.recall
This commit is contained in:
ThearchyHelios
2026-10-16 20:05:24 +08:00
parent 77fc0efa10
commit 35583ba1d8
18 changed files with 1236 additions and 16 deletions
+7 -2
View File
@@ -10,6 +10,7 @@ import type {
ViewShareRow,
} from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
import type { VersionRecallCounts } from '../typesRecall'
import { del, get, patch, post, put, qs, request, type RequestOptions } from './http'
const at = (id: string) => `/apps/${encodeURIComponent(id)}`
@@ -105,8 +106,12 @@ type AssistantConversation =
const assistantInflight = new Map<string, Promise<AssistantConversation>>()
export const ledger = {
/** 一个工作流版本被哪些应用钉住、生效记录数、正式运行数 */
/**
* 一个工作流版本被哪些应用固定、生效记录数、正式运行数;口径账本另加的(第 4 波,老服务端不给):被几次问数回答用过、
* 导出过几次、定时任务几个、召回(以这一版为新版本召回过几组、其中数变了几组,作为旧版本被召回过几组)
*/
version: (workflowId: string, version: number) =>
get<{ apps: { app_id: string; name: string; app_version: number }[]; effects: number; formal_runs: number }>(
get<{ apps: { app_id: string; name: string; app_version: number }[]; effects: number; formal_runs: number
answers?: number; exports?: number; schedules?: number; recall?: VersionRecallCounts }>(
`/ledger/workflow-versions/${encodeURIComponent(workflowId)}/${version}`),
}
+3
View File
@@ -27,6 +27,7 @@ import { whatif } from './whatif'
import { reportExport } from './reportExport'
// 应用平台:各自的接口文件,这里只挂上去
import { apps, ledger } from './apps'
import { recall } from './recall'
import { actions, tasks } from './tasks'
import { auth, identity } from './auth'
import { accessKeys } from './accessKeys'
@@ -957,6 +958,8 @@ export const api = {
// ---- 应用平台(typesApp.ts 是接口约定) ----
apps,
ledger,
// 口径召回(下一轮第 4 波)
recall,
tasks,
actions,
auth,
+23
View File
@@ -0,0 +1,23 @@
/**
* 口径召回的接口(docs/design/next-round/02-口径账本.md 做法 7、8),挂在 api.recall 上。形状见 typesRecall.ts。
*
* - preview:发布弹窗的召回预告(把这一版发布成新版本之后,旧版本被谁用过、预计重算几组、超出上限几组);
* - batches:版本历史里口径升到这一版之后的召回批次(每组的结论、「另有 M 组未重算」);
* - continueBatch:手动接着算下一批(都算过了回 409)。
* 待办里的「按新版重新出具」「知道了」走待办接口(api.tasks.respond,decision=reissue / ack)。
*
* 请求函数从 ./http 导入,不要从 ./client 导入(循环导入,见 http.ts 开头)。
*/
import type { RecallBatches, RecallPreview } from '../typesRecall'
import { get, post, qs, type RequestOptions } from './http'
const wf = (id: string) => encodeURIComponent(id)
export const recall = {
preview: (workflowId: string, version: number, opts?: RequestOptions) =>
get<RecallPreview>(`/workflows/${wf(workflowId)}/recall-preview${qs({ version })}`, opts),
batches: (workflowId: string, version: number, opts?: RequestOptions) =>
get<RecallBatches>(`/ledger/recall/${wf(workflowId)}/${version}`, opts),
continueBatch: (workflowId: string, version: number) =>
post<RecallBatches>(`/ledger/recall/${wf(workflowId)}/${version}/continue`),
}
+5
View File
@@ -17,6 +17,7 @@ import {
publishedLevelOf,
} from '../lib/terms'
import { VersionUsageLine, useVersionUsage } from './versionUsage'
import { RecallPreviewRow } from './RecallPanel'
import { newAppPath } from '../pages/apps/shared'
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
import { SqlLevelBadge } from '../run/SqlChecks'
@@ -255,6 +256,10 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: {
</dd>
</div>
)}
{/* 口径召回的预告(next-round 02 做法 7):发布新版本之后用过旧版本的答案按参数分组重算,规模先摆出来 */}
{published != null && published !== workflow.version && (
<RecallPreviewRow workflowId={workflow.id} version={workflow.version} />
)}
<div className="flex items-start gap-2" data-publish-signer={signer.verified ? 'verified' : actor ? 'actor' : 'unsigned'}>
<dt className="w-14 shrink-0 pt-px text-faint">{signer.verified ? '发布人' : '署名'}</dt>
<dd className="min-w-0 flex-1">
+182
View File
@@ -0,0 +1,182 @@
/**
* 口径召回在画布上的两处(docs/design/next-round/02-口径账本.md 做法 7、8):
* - 发布弹窗的召回预告(RecallPreviewRow):把这一版发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过,
* 预计自动重算几组,超出上限的写明(GET /api/workflows/{id}/recall-preview)。和发布之后真的召回走同一份计划,规模一致;
* - 版本历史里口径升到这一版之后的召回批次(RecallBatches):每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在
* 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。
*
* 界面层不放逻辑:几组、能不能重算、为什么不能、能不能接着算都由服务端给。老服务端没有这两个接口(404)时不画。
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ExternalLink, Play } from 'lucide-react'
import { api } from '../api/client'
import { Spinner, toast } from '../components/ui'
import { formatDateTime } from '../lib/format'
import { RECALL_TEXT } from '../lib/termsRecall'
import type { RecallBatches as Batches, RecallGroup, RecallPreview } from '../typesRecall'
const P = RECALL_TEXT.preview
const B = RECALL_TEXT.batches
/** 发布弹窗「召回」那一行。旧版本里没有口径、也没有用过的答案时不画(发布不牵涉召回) */
export function RecallPreviewRow({ workflowId, version }: { workflowId: string; version: number }) {
const [preview, setPreview] = useState<RecallPreview | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
setPreview(null)
setFailed(false)
api.recall.preview(workflowId, version)
.then((p) => { if (alive) setPreview(p) })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [workflowId, version])
if (failed || !preview) {
return failed ? null : (
<div className="flex gap-2" data-publish-recall="loading">
<dt className="w-14 shrink-0 text-faint">{P.label}</dt>
<dd className="min-w-0 flex-1 text-faint">{P.loading}</dd>
</div>
)
}
if (!preview.caliber && !preview.groups) return null
const uses = preview.versions.reduce((acc, v) => ({
answers: acc.answers + v.answers, exports: acc.exports + v.exports, schedules: acc.schedules + v.schedules,
apps: acc.apps + v.apps, formal: acc.formal + v.formal, pinned: acc.pinned + v.pinned,
}), { answers: 0, exports: 0, schedules: 0, apps: 0, formal: 0, pinned: 0 })
return (
<div className="flex gap-2" data-publish-recall={preview.groups ? 'groups' : 'none'}>
<dt className="w-14 shrink-0 text-faint">{P.label}</dt>
<dd className="min-w-0 flex-1 space-y-0.5">
{!preview.groups ? <div className="text-faint">{P.none}</div> : (
<>
<div data-publish-recall-uses="">{P.uses(uses)}</div>
<div data-publish-recall-groups={preview.groups} data-publish-recall-will={preview.will_recompute}>
{P.groups(preview.groups, preview.will_recompute)}
{preview.already > 0 && <span className="text-faint">({P.already(preview.already)})</span>}
</div>
{preview.limit === 0 && <div style={{ color: 'var(--st-waiting)' }} data-publish-recall-zero="">{P.zero}</div>}
{preview.limit > 0 && preview.over_limit > 0 && (
<div style={{ color: 'var(--st-waiting)' }} data-publish-recall-over={preview.over_limit}>
{P.over(preview.over_limit, preview.limit)}
</div>
)}
{preview.not_recomputable > 0 && (
<div className="text-dim" data-publish-recall-unrecomputable={preview.not_recomputable}>
{P.notRecomputable(preview.not_recomputable)}:{preview.reasons.map((r) => r.reason).join(';')}
</div>
)}
{preview.truncated && <div className="text-faint">{P.truncated}</div>}
<div className="text-faint">{P.hint}</div>
</>
)}
</dd>
</div>
)
}
const STATE_COLOR: Record<string, string | undefined> = {
changed: 'var(--st-waiting)', failed: 'var(--err)', not_recomputable: 'var(--text-faint)', unchanged: 'var(--ok)',
}
/** 列表先列几组,其余收起 */
const SHOWN = 6
/** 版本历史里这一版的召回。还没有任何一组(旧版本没被用过、或者这是第一版)时不画 */
export function RecallBatches({ workflowId, version }: { workflowId: string; version: number }) {
const [data, setData] = useState<Batches | null>(null)
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
const [all, setAll] = useState(false)
useEffect(() => {
let alive = true
setData(null)
setFailed(false)
api.recall.batches(workflowId, version)
.then((d) => { if (alive) setData(d) })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [workflowId, version])
const continueBatch = async () => {
if (busy) return
setBusy(true)
try {
const next = await api.recall.continueBatch(workflowId, version)
setData(next)
toast.ok(B.continued(next.launched?.length ?? 0))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
if (failed) return null
if (!data) return <div className="mt-3 border-t pt-2 text-2xs text-faint" data-version-recall="loading">{B.loading}</div>
if (!data.groups.length) return null
const shown = all ? data.groups : data.groups.slice(0, SHOWN)
const summary = B.summary(data.counts)
return (
<div className="mt-3 border-t pt-2 text-2xs" data-version-recall={data.groups.length}>
<div className="font-medium text-dim">{B.title}</div>
{summary && <div className="mt-1 text-dim" data-version-recall-summary="">{summary}</div>}
<ul className="mt-1 space-y-1">
{shown.map((g) => <GroupRow key={g.id} group={g} />)}
</ul>
{!all && data.groups.length > SHOWN && (
<button type="button" className="mt-1 text-faint underline-offset-2 hover:underline" onClick={() => setAll(true)}>
{B.more(data.groups.length - SHOWN)}
</button>
)}
{data.remaining > 0 && (
<div className="mt-1.5" data-version-recall-remaining={data.remaining}>
<div style={{ color: 'var(--st-waiting)' }}>{B.remaining(data.remaining)}</div>
{data.can_continue && (
<button type="button" className="btn btn-sm mt-1 w-full justify-center" disabled={busy}
data-version-recall-continue="" onClick={() => void continueBatch()}>
{busy ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {busy ? B.continuing : B.continue(data.batch_size)}
</button>
)}
</div>
)}
{data.truncated && <div className="mt-1 text-faint">{B.truncated}</div>}
</div>
)
}
function GroupRow({ group }: { group: RecallGroup }) {
return (
<li className="rounded border bg-bg px-2 py-1" data-version-recall-group={group.id} data-state={group.state}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="chip" style={STATE_COLOR[group.state] ? { color: STATE_COLOR[group.state] } : undefined}>
{B.state[group.state] ?? group.state}
</span>
<span className="text-dim">{B.from(group.from_version)}</span>
<span className="text-faint">{B.uses(group.uses)}</span>
{group.latest && <span className="text-faint">· {formatDateTime(group.latest)}</span>}
</div>
<div className="mt-0.5 flex flex-wrap gap-1">
{group.binding.length
? group.binding.map((b) => <span key={b.field} className="chip mono">{b.field}={String(b.value ?? '')}</span>)
: <span className="text-faint">{B.noBinding}</span>}
</div>
{group.reason && (
<div className="mt-0.5" style={{ color: group.state === 'pending' ? 'var(--st-waiting)' : 'var(--text-dim)' }}
data-version-recall-reason="">
{B.reason(group.state, group.reason)}
</div>
)}
{!!group.changed_metrics?.length && <div className="mt-0.5 text-dim">{B.changedMetrics(group.changed_metrics)}</div>}
<div className="mt-0.5 flex flex-wrap gap-x-3">
{!!group.tasks && <span className="text-faint">{B.tasks(group.tasks)}</span>}
{group.run_id && (
<Link to={`/runs/${encodeURIComponent(group.run_id)}`} data-version-recall-run={group.run_id}
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {B.openRun}
</Link>
)}
</div>
</li>
)
}
+5 -1
View File
@@ -12,6 +12,7 @@ import { formatDateTime, formatTime } from '../lib/format'
import { CATALOG_DRIFT_TEXT, VERSION_USAGE_TEXT, WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms'
import { useAtLeast } from '../store/auth'
import { RetiredNote, VersionUsageLine, retireBlocked, retireVersion, useVersionUsage } from './versionUsage'
import { RecallBatches } from './RecallPanel'
import { hintOf } from './shortcuts'
import { newAppPath } from '../pages/apps/shared'
import type { Edge } from '@xyflow/react'
@@ -240,7 +241,8 @@ function VersionPreview({ version, target, diff, lock, onRestore }: {
const DIFF_COLOR = { add: 'var(--ok)', del: 'var(--err)', mod: 'var(--warn)' } as const
/**
* 发布过的版本:被哪些应用固定、生效记录和正式运行各有多少(L4),做成应用,以及退役(A1)。草稿版本都没有,不画。
* 发布过的版本:被哪些应用固定、生效记录和正式运行各有多少(L4),问数回答、导出、召回(next-round 02 做法 8),口径升到
* 这一版之后的召回批次,做成应用,以及退役(A1)。草稿版本都没有,不画。
* 退役之后这一版不能再发起正式运行,也不能再做成应用(应用只能固定已发布且未退役的版本);固定它的应用标「需升级」,
* 进行中的实例照常走完。退役的人、时间和说明写在列表那一行上,这里不再重复
*/
@@ -261,6 +263,8 @@ function VersionLifecycle({ workflowId, version, current, onRetired }: {
<div className="mt-1">
{usage ? <VersionUsageLine usage={usage} /> : <span className="text-faint">…</span>}
</div>
{/* 口径升到这一版之后的召回批次:每组的结论、「另有 M 组未重算」、接着算(next-round 02 做法 7) */}
<RecallBatches workflowId={workflowId} version={version.version} />
{!version.retired_at && (
<>
{builder && (
+16 -2
View File
@@ -4,11 +4,13 @@ import { api } from '../api/client'
import { confirmDialog, promptDialog, toast } from '../components/ui'
import { formatDateTime } from '../lib/format'
import { VERSION_USAGE_TEXT as T } from '../lib/terms'
import { RECALL_TEXT } from '../lib/termsRecall'
/*
* 工作流版本的使用情况与退役(A1、L4):发布弹窗、版本历史、退役确认三处共用。
*
* 使用情况取账本的只读接口(/api/ledger/workflow-versions/{wf}/{v}):被哪些应用固定、生效记录数、正式运行数。
* 使用情况取账本的只读接口(/api/ledger/workflow-versions/{wf}/{v}):被哪些应用固定、生效记录数、正式运行数;口径账本另加
* 问数回答、导出、定时任务、召回(next-round 02 做法 8,老服务端不给就不写)。
* 退役只给管理员(Q8);关登录时所有人等同管理员。当前发布版不能退役,服务端回 409,这里先不给按钮
*/
@@ -27,8 +29,19 @@ export function useVersionUsage(workflowId: string, version: number | null, epoc
return usage
}
/** 「被 2 个应用固定:报销审批、周报看板 · 生效记录 3 条 · 正式运行 14 次」,应用名可点开编辑 */
/**
* 「被 2 个应用固定:报销审批、周报看板 · 生效记录 3 条 · 正式运行 14 次 · 问数回答 5 次 · 导出 2 次 · 召回 3 组(数变了
* 1 组)」,应用名可点开编辑。账本另加的几项老服务端不给就不写;召回两头都是 0 的不写
*/
export function VersionUsageLine({ usage }: { usage: VersionUsage }) {
const U = RECALL_TEXT.usage
const extra = [
usage.answers != null ? U.answers(usage.answers) : null,
usage.exports != null ? U.exports(usage.exports) : null,
usage.schedules ? U.schedules(usage.schedules) : null,
usage.recall?.groups ? U.recall(usage.recall.groups, usage.recall.changed) : null,
usage.recall?.recalled_from ? U.recalledFrom(usage.recall.recalled_from) : null,
].filter((x): x is string => !!x)
return (
<span className="text-dim" data-version-usage="">
{T.apps(usage.apps.length)}
@@ -47,6 +60,7 @@ export function VersionUsageLine({ usage }: { usage: VersionUsage }) {
</>
)}
<span className="text-faint"> · {T.effects(usage.effects)} · {T.formalRuns(usage.formal_runs)}</span>
{extra.length > 0 && <span className="text-faint" data-version-usage-ledger=""> · {extra.join(' · ')}</span>}
</span>
)
}
+1
View File
@@ -77,6 +77,7 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
reconcile: '核实结果',
signal: '等待外部系统',
review: '审核个人应用',
recall: '口径召回',
}
export const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
+152
View File
@@ -0,0 +1,152 @@
/**
* 口径召回的文案(docs/design/next-round/02-口径账本.md 做法 7、8):版本用量、发布弹窗的召回预告、版本历史里的召回批次、
* recall 待办卡、时间线上的两个封存后事件。规矩同 terms.ts 开头的术语表:「固定版本」不写钉住;「运行」不写跑;受管 /
* 已发布;「完整出具 / 降档出具 / 不予出具」;不在句子里裸写键名。叫法同总方案 X10:召回、口径对账、来自口径。
*
* 召回只运行口径的数据部分(取数节点和口径卡)、不调用模型;两版在同一次运行里查同一份数据,差值在召回卡里算——这几句
* 写在预告和待办里,让人知道这些数是怎么来的。消息、待办标题里不放数(服务端照规矩不放),界面上的数都点得开出处。
*/
import { formatNumber } from './format'
const n = (v: number) => formatNumber(v)
export const RECALL_TEXT = {
// ---- 版本用量(版本历史、发布弹窗的「使用情况」那一行,跟在应用、生效记录、正式运行后面) ----
usage: {
answers: (count: number) => `问数回答 ${n(count)} 次`,
exports: (count: number) => `导出 ${n(count)} 次`,
schedules: (count: number) => `定时任务 ${n(count)} 个`,
recall: (groups: number, changed: number) => `召回 ${n(groups)} 组${groups ? `(数变了 ${n(changed)} 组)` : ''}`,
recalledFrom: (groups: number) => `被新版本召回 ${n(groups)} 组`,
},
// ---- 发布弹窗的召回预告 ----
preview: {
label: '召回',
loading: '正在估算召回规模…',
failed: '无法估算召回规模',
none: '旧版本没有被问数回答、导出、定时任务或应用用过,发布后不需要召回',
notCaliber: '旧版本里没有可复用的口径卡,发布后不召回',
uses: (c: { answers: number; exports: number; schedules: number; apps: number; formal: number; pinned: number }) => {
const parts = [
c.answers ? `${n(c.answers)} 次问数回答` : '',
c.exports ? `${n(c.exports)} 次导出` : '',
c.schedules ? `${n(c.schedules)} 个定时任务` : '',
c.apps ? `${n(c.apps)} 个应用` : '',
c.formal ? `${n(c.formal)} 次正式运行` : '',
c.pinned ? `${n(c.pinned)} 次固定了它的工作流的正式运行` : '',
].filter(Boolean)
return parts.length ? `旧版本被 ${parts.join('、')}用过` : '旧版本没有被用过'
},
groups: (groups: number, will: number) => `按参数去重共 ${n(groups)} 组,发布后自动重算 ${n(will)} 组`,
already: (count: number) => `其中 ${n(count)} 组之前已经召回过`,
over: (count: number, limit: number) => `另有 ${n(count)} 组超出每次 ${n(limit)} 组的上限,发布后可在版本历史里手动接着算`,
zero: '设置里每次最多重算 0 组:发布后不自动重算,只在版本历史里列出受影响的答案,可以手动接着算',
notRecomputable: (count: number) => `${n(count)} 组无法重算`,
hint: '重算只运行口径的数据部分(取数和口径卡),不调用模型;新旧两版在同一份数据上各算一次,数真变了才给答案的主人开待办',
truncated: '用过的答案太多,只看了最近的一部分',
},
// ---- 版本历史里口径升到这一版之后的召回批次 ----
batches: {
title: '这一版的召回',
loading: '正在读取召回情况…',
failed: '无法读取召回情况',
none: '旧版本没有被用过的答案,不需要召回',
notCaliber: '旧版本里没有可复用的口径卡,不召回',
summary: (c: Record<string, number>) => {
const parts = [
c.changed ? `数变了 ${n(c.changed)} 组` : '',
c.unchanged ? `没变 ${n(c.unchanged)} 组` : '',
c.running ? `正在重算 ${n(c.running)} 组` : '',
c.failed ? `重算失败 ${n(c.failed)} 组` : '',
c.not_recomputable ? `无法重算 ${n(c.not_recomputable)} 组` : '',
].filter(Boolean)
return parts.join(',')
},
state: {
changed: '数变了', unchanged: '没变', failed: '重算失败', running: '正在重算', pending: '未重算',
not_recomputable: '无法重算',
} as Record<string, string>,
reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason),
remaining: (count: number) => `另有 ${n(count)} 组未重算`,
continue: (size: number) => `接着算(每次 ${n(size)} 组)`,
continuing: '正在发起…',
continued: (count: number) => (count ? `已发起 ${n(count)} 组召回` : '没有发起新的召回'),
from: (v: number) => `v${v} 的答案`,
binding: '参数',
noBinding: '没有参数',
uses: (items: { kind_text: string; count: number }[]) => items.map((u) => `${u.kind_text} ${n(u.count)}`).join('、'),
changedMetrics: (names: string[]) => `变了的指标:${names.join('、')}`,
tasks: (count: number) => `开了 ${n(count)} 条待办`,
openRun: '查看召回运行',
more: (count: number) => `还有 ${n(count)} 组…`,
truncated: '用过的答案太多,只看了最近的一部分',
},
// ---- recall 待办卡(待办箱、待办页) ----
task: {
intro: (name: string, from: number | null | undefined, to: number | null | undefined) =>
`口径「${name}」从 v${from ?? '?'} 升到了 v${to ?? '?'}。在同一份数据上把新旧两版各算了一次,这一组答案用到的数变了。`,
how: '两版在同一次运行里查同一份数据,差值在召回卡里算;原答案当时的值另外列出,和旧口径的值不同说明这段时间数据有变化。',
binding: '这一组的参数',
metric: '指标',
old: (v: number | null | undefined) => `旧口径 v${v ?? '?'}`,
new: (v: number | null | undefined) => `新口径 v${v ?? '?'}`,
diff: '两版之差',
original: '原答案当时的值',
changed: '变了',
same: '没变',
masked: '已遮罩',
missing: '—',
answers: '用到这份口径的答案',
answerOf: (kindText: string, at: string | null) => `${kindText}${at ? ` · ${at}` : ''}`,
openAnswer: '查看答案',
openTurn: '回到问数据',
evidenceTitle: (name: string, side: string) => `${name} · ${side}`,
reissue: '按新版重新出具',
reissueHint: '按新版口径、这一组的参数发起一次正式运行(和按口径正式出具同一套检查)',
reissued: '已按新版重新出具',
issuedRun: '查看出具的正式运行',
ack: '知道了',
acked: '已记为知道了',
doneReissue: '已按新版发起正式出具',
doneAck: '已记为知道了',
resolved: (decision: string | undefined) => (decision === 'reissue' ? '已按新版重新出具' : '已记为知道了'),
catalogIntro: '答案依据的数据目录有变:按新目录检查,这次执行过的 SQL 出现了新的错误级问题。答案本身没有改,请核对是否还能用。',
catalogItems: '变了的目录项',
catalogChecks: (count: number) => `按新目录新出现的错误级问题 ${n(count)} 条`,
structureIntro: (name: string, version: number | null | undefined) =>
`口径「${name}」v${version ?? '?'} 依据的表结构变了,按最近一次的参数试算没有算出来。请检查口径的取数,必要时发布新版本。`,
structureWhere: (table: string, column?: string | null) => `表 ${table}${column ? ` 的列 ${column}` : ''}`,
probeError: '试算的报错',
openProbe: '查看试算运行',
change: {
removed: '删掉了', rejected: '被驳回', changed: '改了',
} as Record<string, string>,
},
// ---- 时间线(run/decode.ts 认 ledger.recalled、ledger.basis_changed) ----
timeline: {
answer: (v: number | null) => (v != null ? `已按 v${v} 核对,不影响` : '已按新口径核对,不影响'),
recallChanged: (from: number | null, to: number | null) =>
`召回核对${from != null && to != null ? `(v${from} → v${to})` : ''}:数变了`,
recallSame: (from: number | null, to: number | null) =>
`召回核对${from != null && to != null ? `(v${from} → v${to})` : ''}:数没变`,
recallFailed: '召回重算失败',
/** 认不出结论(缺字段)时只说核对过,不猜变没变 */
generic: '口径召回核对',
probeOk: '表结构变了之后试算口径:算出来了',
probeFailed: '表结构变了之后试算口径:没有算出来',
tasks: (count: number) => `开了 ${n(count)} 条待办`,
basis: {
catalog: '依据有变:数据目录里影响数字的项被驳回或修改',
structure: '依据的表结构已变',
values: '依据有变:出现了触及有效记录条件的新码值',
catalog_edit: '依据有变:处理数据源变化时改了数据目录',
} as Record<string, string>,
basisItems: (labels: string[]) => labels.join('、'),
newErrors: (count: number) => `按新目录检查新出现 ${n(count)} 条错误级问题`,
sub: '封存后追加,不在封存范围内',
},
}
+270
View File
@@ -0,0 +1,270 @@
/**
* recall 待办的卡身(待办箱、待办页里 TaskCard 的一段;docs/design/next-round/02-口径账本.md 做法 7)。
*
* 三种来由(payload.reason,服务端 ledger/recall.task_payload 给):
* - caliber:口径发布了新版本,同一份数据上新旧两版各算一次,这一组答案用到的数变了。列旧口径的值、新口径的值、两版之差
* (召回运行里召回卡上的三个指标)和每个答案当时的值,每个数都点得开出处(召回卡的指标链;正式运行的口径卡指标链;问数
* 回答的那条查询快照)。动作「按新版重新出具」(服务端走按口径出具同一套判法,能不能、为什么不能由它给)和「知道了」;
* - catalog:答案依据的数据目录里影响数字的项被驳回或改了,按新目录检查这次执行过的 SQL 出现了新的错误级问题;只有「知道了」;
* - structure:口径依据的表结构变了,试算没有算出来;只有「知道了」。
*
* 界面层不放逻辑:数变没变、值是不是遮罩、能不能重新出具都照服务端给的写。使用者看到的值服务端已经按遮罩换过(masked)。
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Check, ExternalLink, RefreshCw } from 'lucide-react'
import { EvidenceDrawer, type EvidenceTarget } from '../apps/EvidenceDrawer'
import { Spinner } from '../components/ui'
import { useAtLeast } from '../store/auth'
import { formatDateTime } from '../lib/format'
import { RECALL_TEXT } from '../lib/termsRecall'
import type { Task } from '../typesApp'
import type { RecallAnswer, RecallCell, RecallOriginal, RecallTaskPayload } from '../typesRecall'
const T = RECALL_TEXT.task
function cellText(cell: { text: string | null; masked: boolean } | undefined): string {
if (!cell) return T.missing
if (cell.masked) return T.masked
return cell.text ?? T.missing
}
export function RecallTask({ task, canAct, busy, onRespond }: {
task: Task; canAct: boolean; busy: boolean; onRespond: (decision: 'reissue' | 'ack') => void
}) {
const payload = (task.payload ?? {}) as Partial<RecallTaskPayload>
const [evidence, setEvidence] = useState<EvidenceTarget | null>(null)
const reason = payload.reason ?? 'caliber'
const name = payload.workflow_name || ''
const actions = Array.isArray(payload.actions) ? payload.actions : []
const reissue = actions.find((a) => a.decision === 'reissue')
const ack = actions.find((a) => a.decision === 'ack')
const pending = task.status === 'pending'
const outcome = (task.outcome ?? payload.outcome ?? null) as Record<string, any> | null
return (
<div className="mb-2 space-y-2" data-task-recall={reason}>
{reason === 'caliber' && (
<>
<p className="text-2xs leading-relaxed text-dim">{T.intro(name, payload.from_version, payload.to_version)}</p>
{!!payload.binding?.length && (
<div className="flex flex-wrap items-center gap-1 text-2xs" data-recall-binding="">
<span className="text-faint">{T.binding}:</span>
{payload.binding.map((b) => (
<span key={b.field} className="chip mono" data-recall-binding-field={b.field}>
{b.field}={b.masked ? T.masked : String(b.value ?? '')}
</span>
))}
</div>
)}
<Metrics payload={payload} onOpen={setEvidence} />
<Answers payload={payload} onOpen={setEvidence} />
<p className="text-2xs leading-relaxed text-faint">{T.how}</p>
</>
)}
{reason === 'catalog' && <CatalogBasis payload={payload} />}
{reason === 'structure' && <StructureBasis payload={payload} name={name} />}
{!pending && outcome?.decision && (
<div className="flex flex-wrap items-center gap-x-2 text-2xs text-dim" data-recall-outcome={outcome.decision}>
<span>{T.resolved(outcome.decision)}</span>
{outcome.issued_run && <IssuedLink runId={String(outcome.issued_run)} />}
</div>
)}
{canAct && (
<div className="flex gap-2">
{reissue && (
<button type="button" className="btn btn-primary flex-1 justify-center" data-recall-reissue=""
disabled={busy || !reissue.available} title={reissue.reason ?? T.reissueHint}
onClick={() => onRespond('reissue')}>
{busy ? <Spinner size={12} /> : <RefreshCw size={12} aria-hidden />} {T.reissue}
</button>
)}
{ack && (
<button type="button" className="btn flex-1 justify-center" data-recall-ack="" disabled={busy || !ack.available}
onClick={() => onRespond('ack')}>
<Check size={12} aria-hidden /> {T.ack}
</button>
)}
</div>
)}
{canAct && reissue && !reissue.available && reissue.reason && (
<div className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-recall-reissue-reason="">
{reissue.reason}
</div>
)}
{canAct && reissue?.issued_run && <IssuedLink runId={reissue.issued_run} />}
<EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />
</div>
)
}
/** 三个数(旧口径、新口径、两版之差)一行一个指标,每个都点得开召回卡上那个指标的出处链 */
function Metrics({ payload, onOpen }: { payload: Partial<RecallTaskPayload>; onOpen: (t: EvidenceTarget) => void }) {
const metrics = Array.isArray(payload.metrics) ? payload.metrics : []
if (!metrics.length) return null
const run = payload.recall_run ?? ''
const open = (label: string, side: string, cell: RecallCell) => {
if (!run || !cell.metric) return
onOpen({ evidence: { run_id: run, kind: 'metric', node: 'recall', metric: cell.metric },
label: T.evidenceTitle(label, side), text: cellText(cell) })
}
const heads = [T.old(payload.from_version), T.new(payload.to_version), T.diff]
return (
<div className="overflow-x-auto rounded border bg-bg" data-recall-metrics={metrics.length}>
<table className="w-full text-2xs">
<thead>
<tr className="text-left text-faint">
<th className="px-2 py-1 font-normal">{T.metric}</th>
{heads.map((h) => <th key={h} className="px-2 py-1 text-right font-normal">{h}</th>)}
</tr>
</thead>
<tbody>
{metrics.map((m) => (
<tr key={m.k} className="border-t" data-recall-metric={m.metric} data-changed={m.changed ? 'true' : 'false'}>
<td className="px-2 py-1">
{m.name}
<span className="ml-1 text-faint" style={m.changed ? { color: 'var(--st-waiting)' } : undefined}>
{m.changed ? T.changed : T.same}
</span>
</td>
{(['old', 'new', 'diff'] as const).map((side, i) => (
<td key={side} className="px-2 py-1 text-right">
<button type="button" className="tnum underline-offset-2 hover:underline" data-recall-cell={side}
disabled={m[side].masked} onClick={() => open(m.name, heads[i], m[side])}>
{cellText(m[side])}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** 用到这份口径的答案:哪一种、什么时候、当时的值(点得开当时的出处)、去哪里看 */
function Answers({ payload, onOpen }: { payload: Partial<RecallTaskPayload>; onOpen: (t: EvidenceTarget) => void }) {
const answers = Array.isArray(payload.answers) ? payload.answers : []
const names = new Map((payload.metrics ?? []).map((m) => [m.k, m.name]))
if (!answers.length) return null
const open = (answer: RecallAnswer, value: RecallOriginal) => {
const label = T.evidenceTitle(names.get(value.k) ?? '', T.original)
if (value.node && value.metric) {
onOpen({ evidence: { run_id: answer.run_id, kind: 'metric', node: value.node, metric: value.metric }, label,
text: cellText(value) })
} else if (value.query) {
onOpen({ evidence: { run_id: answer.run_id, kind: 'cell', artifact: value.query }, label, text: cellText(value) })
}
}
return (
<div data-recall-answers={answers.length}>
<div className="mb-1 text-2xs font-medium text-dim">{T.answers}</div>
<ul className="space-y-1">
{answers.map((a) => (
<li key={`${a.run_id}-${a.kind}`} className="rounded border bg-bg px-2 py-1 text-2xs" data-recall-answer={a.run_id}
data-recall-answer-kind={a.kind}>
<div className="flex flex-wrap items-center gap-x-2">
<span className="text-dim">{T.answerOf(a.kind_text, a.at ? formatDateTime(a.at) : null)}</span>
<AnswerLink answer={a} />
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3">
{a.original.map((v) => (
<span key={v.k}>
<span className="text-faint">{names.get(v.k) ?? ''} · {T.original}:</span>
{v.missing && !v.masked
? <span className="text-faint" title={v.missing}>{T.missing}</span>
: (
<button type="button" className="tnum underline-offset-2 hover:underline" data-recall-original={v.k}
disabled={v.masked || (!v.metric && !v.query)} onClick={() => open(a, v)}>
{cellText(v)}
</button>
)}
</span>
))}
</div>
</li>
))}
</ul>
</div>
)
}
/** 去看这个答案:问数回答回到那段对话;别的(正式运行)给搭建者去记录页 */
function AnswerLink({ answer }: { answer: RecallAnswer }) {
const builder = useAtLeast('builder')
if (answer.kind === 'answer' && answer.conversation_id) {
return (
<Link to={`/chat/${encodeURIComponent(answer.conversation_id)}`} data-recall-answer-link="chat"
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openTurn}
</Link>
)
}
if (!builder) return null
return (
<Link to={`/runs/${encodeURIComponent(answer.run_id)}`} data-recall-answer-link="run"
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openAnswer}
</Link>
)
}
function IssuedLink({ runId }: { runId: string }) {
return (
<Link to={`/runs/${encodeURIComponent(runId)}`} data-recall-issued={runId}
className="inline-flex items-center gap-1 text-2xs text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.issuedRun}
</Link>
)
}
function CatalogBasis({ payload }: { payload: Partial<RecallTaskPayload> }) {
const basis = payload.basis ?? {}
const items = Array.isArray(basis.items) ? basis.items : []
const checks = Array.isArray(basis.checks) ? basis.checks : []
return (
<div className="space-y-1.5 text-2xs" data-recall-basis="catalog">
<p className="leading-relaxed text-dim">{T.catalogIntro}</p>
{items.length > 0 && (
<div>
<span className="text-faint">{T.catalogItems}:</span>
{items.map((i, k) => (
<span key={`${i.path}-${k}`}>
{k > 0 && '、'}{i.label ?? i.path}{i.change ? `(${T.change[i.change] ?? i.change})` : ''}
</span>
))}
</div>
)}
{basis.new_errors != null && <div className="text-dim">{T.catalogChecks(basis.new_errors)}</div>}
{checks.length > 0 && (
<ul className="list-disc space-y-0.5 pl-4" data-recall-checks={checks.length}>
{checks.map((c, k) => <li key={`${c.code}-${k}`}>{c.message}</li>)}
</ul>
)}
</div>
)
}
function StructureBasis({ payload, name }: { payload: Partial<RecallTaskPayload>; name: string }) {
const basis = payload.basis ?? {}
const builder = useAtLeast('builder')
return (
<div className="space-y-1.5 text-2xs" data-recall-basis="structure">
<p className="leading-relaxed text-dim">{T.structureIntro(name, basis.version)}</p>
{basis.table && <div className="text-dim">{T.structureWhere(basis.table, basis.column)}</div>}
{basis.error && (
<div className="rounded border bg-bg p-1.5">
<span className="text-faint">{T.probeError}:</span>{basis.error}
</div>
)}
{builder && basis.probe_run && (
<Link to={`/runs/${encodeURIComponent(basis.probe_run)}`}
className="inline-flex items-center gap-1 text-dim underline-offset-2 hover:text-fg hover:underline">
<ExternalLink size={10} aria-hidden /> {T.openProbe}
</Link>
)}
</div>
)
}
+12 -3
View File
@@ -10,6 +10,8 @@
* - 等外部系统(signal):说明由入站钩子完成,不在这里处理;
* - 审核个人应用(review,docs/design/normal-user-apps.md N7):同意不在这里点,处理人取一份共享副本、在副本上试运行和
* 走发布单(发布单做完这条待办自动记为已发布);不发布就退回。
* - 口径召回(recall,docs/design/next-round/02-口径账本.md 做法 7):旧口径、新口径、两版之差和原答案当时的值都点得开,
* 「按新版重新出具」「知道了」(run/RecallTask.tsx)。
*
* 不碰画布的 store:恢复之后谁去重新接事件流、刷新哪张列表,由调用方在 onResolved 里决定。会签时一个人的回复记下了、
* 还没凑齐(resolved 为假),照样回调——调用方据此刷新卡片。
@@ -21,8 +23,8 @@ import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Ban, Check, CheckCheck, Clock, Code, Copy, ExternalLink, Hand, Keyboard, PenLine, Send, SearchCheck, TriangleAlert,
UserCheck, Webhook, Wrench,
Ban, Check, CheckCheck, Clock, Code, Copy, ExternalLink, Hand, History, Keyboard, PenLine, Send, SearchCheck,
TriangleAlert, UserCheck, Webhook, Wrench,
} from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../api/client'
@@ -38,6 +40,7 @@ import {
TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName,
} from '../lib/terms'
import { REVIEW_TASK_TEXT } from '../lib/termsApp'
import { RECALL_TEXT } from '../lib/termsRecall'
import { blueprintPath } from '../pages/blueprints/shared'
import type { Approval, ApprovalReport } from '../types'
import type {
@@ -48,6 +51,7 @@ import { ApprovalReports, ApprovalRunClass } from './ApprovalReport'
import { ChallengeSummary } from './ChallengeSummary'
import { DualCheckApproval } from './DualCheckApproval'
import { useInquiryBusy } from './Inquiry'
import { RecallTask } from './RecallTask'
export interface TaskCardProps {
task: Task
@@ -73,7 +77,7 @@ export interface TaskCardProps {
const KIND_ICON: Record<TaskKind, typeof Hand> = {
approve: Hand, input: Keyboard, edit: PenLine, tool: Wrench, code: Code, dual_check: Hand,
action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck,
action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck, recall: History,
}
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */
@@ -216,6 +220,11 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
<Review task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
onReject={() => void respond({ decision: 'reject' }, REVIEW_TASK_TEXT.rejected)} />
)}
{kind === 'recall' && (
<RecallTask task={task} canAct={canAct} busy={busy}
onRespond={(decision) => void respond({ decision },
decision === 'reissue' ? RECALL_TEXT.task.doneReissue : RECALL_TEXT.task.doneAck)} />
)}
{showForm && (
<div className="mb-2 rounded border bg-bg p-2.5">
+43
View File
@@ -12,6 +12,7 @@ import {
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch'
import { RECALL_TEXT } from '../lib/termsRecall'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
@@ -2418,6 +2419,48 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
case 'ledger.recalled': {
// 口径召回(next-round 02 做法 7):封存之后追加。原答案上的是「已按 vN 核对,不影响」;召回运行自己身上的是收尾的
// 结论(数变没变、开了几条待办);表结构变了之后的口径试算是 probe。事件里不带数
const R = RECALL_TEXT.timeline
const to = typeof d.to_version === 'number' ? d.to_version : null
const from = typeof d.from_version === 'number' ? d.from_version : null
const tasks = Array.isArray(d.tasks) ? d.tasks.length : 0
const failed = d.outcome === 'failed'
const title = d.role === 'answer' ? R.answer(to)
: d.probe ? (failed ? R.probeFailed : R.probeOk)
: failed ? R.recallFailed : d.changed === true ? R.recallChanged(from, to)
: d.changed === false ? R.recallSame(from, to) : R.generic
out.push({
id: `lr-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_recalled',
level: failed || (d.role !== 'answer' && d.changed) ? 'warn' : 'info',
title, sub: R.sub,
...(tasks ? { meta: R.tasks(tasks) } : {}),
...(failed && typeof d.error === 'string' && d.error ? { detail: d.error } : {}),
})
break
}
case 'ledger.basis_changed': {
// 答案依据的东西变了(目录事实被驳回或修改、表结构变了、新码值):封存之后追加,标「依据有变」。按新目录多出错误级
// 问题的写几条(服务端已经开了待办)
const R = RECALL_TEXT.timeline
const items = Array.isArray(d.items) ? d.items : []
const labels = items.map((i: any) => (i && typeof i.label === 'string' ? i.label : '')).filter(Boolean)
const errors = typeof d.new_errors === 'number' ? d.new_errors : 0
const where = [d.source, d.table].filter((x) => typeof x === 'string' && x).join(' · ')
out.push({
id: `lb-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_basis_changed',
level: errors > 0 || d.kind === 'structure' ? 'warn' : 'info',
title: R.basis[String(d.kind)] ?? R.basis.catalog,
sub: R.sub,
...(where ? { meta: where } : {}),
...(labels.length || errors ? { detail: [labels.length ? R.basisItems(labels) : '', errors ? R.newErrors(errors) : '']
.filter(Boolean).join('\n') } : {}),
})
break
}
case 'approval.inquiry':
// 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、
// 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层
+6 -2
View File
@@ -520,10 +520,13 @@ export interface InstanceRow {
/**
* 待办的种类。review 是个人应用提交审核(docs/design/normal-user-apps.md N7):不挂在运行的中断上,同意不在待办上点——
* 处理人取一份共享副本、走发布单(发布单做完这条自动记为已发布),不发布就退回(decision=reject);payload 见 ReviewTaskPayload
* 处理人取一份共享副本、走发布单(发布单做完这条自动记为已发布),不发布就退回(decision=reject);payload 见 ReviewTaskPayload。
* recall 是口径召回(docs/design/next-round/02-口径账本.md 做法 7):口径升版后答案的数变了(「按新版重新出具」decision=reissue、
* 「知道了」decision=ack),或者答案依据的目录、表结构变了(只有「知道了」);payload 见 typesRecall.ts 的 RecallTaskPayload
*/
export type TaskKind =
| 'approve' | 'input' | 'edit' | 'tool' | 'code' | 'dual_check' | 'action_confirm' | 'reconcile' | 'signal' | 'review'
| 'recall'
export type TaskStatus = 'pending' | 'resolving' | 'resolved' | 'cancelled' | 'expired'
export type TaskPolicy = { kind: 'any' } | { kind: 'all' } | { kind: 'quorum'; m: number }
@@ -627,7 +630,8 @@ export interface ReviewTaskPayload {
}
export interface TaskRespond {
decision: 'approve' | 'reject' | 'submit' | 'confirmed' | 'not_applied_resend' | 'not_applied_abandon'
/** reissue / ack 只给口径召回(recall):按新版重新出具、知道了 */
decision: 'approve' | 'reject' | 'submit' | 'confirmed' | 'not_applied_resend' | 'not_applied_abandon' | 'reissue' | 'ack'
values?: Record<string, any>
note?: string
/** 动作确认:确认卡上显示的哈希,原样带回 */
+176
View File
@@ -0,0 +1,176 @@
/**
* 口径召回的接口形状(服务端 app/ledger/recall.py、uses.py;docs/design/next-round/02-口径账本.md 做法 7、8)。
*
* 界面层不放逻辑:几组、能不能重算、为什么不能、数变没变、能不能重新出具,都由服务端给,界面只照着画。
*/
/** 一种用法的计数:问数回答几次、导出几次、定时任务几个、应用几个、正式运行几次、固定了它的工作流发起的正式运行几次 */
export interface RecallUseCounts {
answers: number
exports: number
schedules: number
apps: number
formal: number
pinned: number
/** 反查有上限(按新的在前),少看了的为真 */
truncated: boolean
}
/** 发布弹窗的召回预告(GET /api/workflows/{id}/recall-preview?version=) */
export interface RecallPreview {
workflow_id: string
version: number
/** 每次发布最多自动重算几组(设置 ledger.recall_limit;0 表示不自动重算) */
limit: number
/** 旧版本里有没有能复用的口径卡(没有就不是口径,发布后不召回) */
caliber: boolean
problem: string | null
/** 每个旧版本被谁用过;problem 是这一版整体不能重算的原因 */
versions: (RecallUseCounts & { version: number; problem?: string })[]
/** 一共几组(按旧版本、参数去重) */
groups: number
recomputable: number
not_recomputable: number
/** 已经召回过的组(重新发布同一版时) */
already: number
/** 发布之后预计自动重算几组 */
will_recompute: number
/** 超出上限、要手动接着算的几组 */
over_limit: number
truncated: boolean
/** 不能重算的原因各几组 */
reasons: { reason: string; groups: number }[]
}
export type RecallGroupState = 'changed' | 'unchanged' | 'failed' | 'running' | 'pending' | 'not_recomputable'
/** 召回批次里的一组 */
export interface RecallGroup {
id: string
from_version: number
to_version: number
binding: { field: string; value: unknown }[]
uses: { kind: string; kind_text: string; count: number }[]
latest: string | null
state: RecallGroupState
/** 不能重算的原因、重算失败的原因、发起被拒的原因 */
reason?: string
run_id?: string
batch?: 'auto' | 'manual'
/** 开了几条待办(数变了的) */
tasks?: number
/** 数变了的指标名 */
changed_metrics?: string[]
}
/** 版本历史里「这一版的召回」(GET /api/ledger/recall/{wf}/{v};POST …/continue 交回同样的形状加 launched) */
export interface RecallBatches {
workflow_id: string
workflow_name: string
version: number
limit: number
/** 手动接着算一次发起几组 */
batch_size: number
problem: string | null
caliber: boolean
counts: Record<RecallGroupState, number>
groups: RecallGroup[]
/** 还没重算的几组(「另有 M 组未重算」) */
remaining: number
can_continue: boolean
truncated: boolean
launched?: string[]
}
/** 版本用量里的召回:以这一版为新版本召回过几组、其中数变了几组;作为旧版本被召回过几组 */
export interface VersionRecallCounts {
groups: number
changed: number
recalled_from: number
}
/** recall 待办里的一个数:值(遮罩了的为 null)、显示的字、出处(召回卡上哪个指标) */
export interface RecallCell {
value: unknown
text: string | null
masked: boolean
metric?: string
}
/** 原答案当时的值:正式运行给口径卡节点和指标,问数回答给那条查询快照和列;没有单独这个数的写 missing */
export interface RecallOriginal {
k: number
value: unknown
text: string | null
masked: boolean
node?: string
metric?: string
query?: string
column?: string
missing?: string
}
export interface RecallAnswer {
kind: string
kind_text: string
run_id: string
version?: number
conversation_id?: string
turn_id?: string
schedule_id?: string
app_id?: string
at?: string
via_workflow?: string
via_version?: number
original: RecallOriginal[]
}
export interface RecallAction {
decision: 'reissue' | 'ack'
available: boolean
reason?: string
issued_run?: string
}
/**
* recall 待办的 payload(服务端 ledger/recall.task_payload)。reason:caliber 口径升版数变了(旧值、新值、差值、原答案当时
* 的值);catalog 答案依据的数据目录有变、按新目录检查出了新的问题;structure 口径依据的表结构变了、试算没有算出来
*/
export interface RecallTaskPayload {
kind: 'recall'
reason: 'caliber' | 'catalog' | 'structure'
recall_run: string
workflow_id?: string | null
workflow_name?: string
from_version?: number | null
to_version?: number | null
binding?: { field: string; value: unknown; masked: boolean }[]
metrics?: {
k: number
name: string
unit: string
changed: boolean
card: string
metric: string
old: RecallCell
new: RecallCell
diff: RecallCell
}[]
answers?: RecallAnswer[]
basis?: {
kind?: string
source?: string
table?: string
column?: string
items?: { path?: string; label?: string; change?: string }[]
new_errors?: number
checks?: { code: string; message: string }[]
workflow_id?: string
version?: number
change_kind?: string
error?: string | null
probe_run?: string
}
actions: RecallAction[]
outcome?: Record<string, any> | null
}
+8 -6
View File
@@ -332,7 +332,7 @@ await section('设置:身份、通知、审计', async () => {
await section('设置 → 通知:可订阅的事件照服务端登记列(总方案 X6)', async () => {
// 列表从真后端的 GET /api/notify/events 来(GET 放行):这种范围的渠道收得到的才列;从来推不到的(以前列着、一勾就 422
// 的「待办升级」「动作已生效」……)和登记了还没推送的(召回、待办汇总……)都不列
// 的「待办升级」「动作已生效」……)和登记了还没推送的(数据源变化……)都不列
const { page, errors } = await open('/settings/notify')
await page.getByRole('button', { name: '添加渠道' }).first().click()
const dlg = page.locator('[role="dialog"]')
@@ -343,21 +343,23 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总
}
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval);
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道。第 4 波:口径召回
// 数变了的推全局渠道和答案主人的个人渠道(notify_recall),不归哪个应用
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed', 'key.expiring']),
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'ledger.recall', 'eval.regressed', 'eval.failed',
'key.expiring']),
JSON.stringify(await listed()))
const text = await dlg.innerText()
// 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推
check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text)
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
check('登记了还没推送的不列', !['数据源有变化要处理', '口径升版,答案的数变了']
check('登记了还没推送的不列', !['数据源有变化要处理']
.some((t) => text.includes(t)), text)
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text)
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」,也不列口径召回', await scope('app')
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user')
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']),
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'ledger.recall', 'task.digest', 'key.expiring']),
JSON.stringify(await listed()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
+46
View File
@@ -1888,6 +1888,52 @@ await section('口径账本:封存后比对已发布口径(ledger.matched)
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('口径召回:封存后的核对结论与依据有变(ledger.recalled、ledger.basis_changed)', async () => {
// next-round 02 做法 7:口径发布新版本后召回运行在同一份数据上重算两版,数没变的在原答案上记「已按 vN 核对,不影响」,
// 召回运行自己记结论;目录事实被驳回或修改、表结构变了的在答案上记「依据有变」。都在封存之后,事件里不带数
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const base = [E(1, 'run.started', null, {}), E(2, 'node.started', 'q', { node_type: 'tool' }), E(3, 'node.finished', 'q', {}),
E(4, 'run.finished', null, { status: 'succeeded' })]
const run = (...more) => flatten(mod.decodeRun([...base, ...more]))
const answer = run(E(5, 'ledger.recalled', null, { role: 'answer', changed: false, recall_run: 'rc-1', workflow_id: 'wf-week',
from_version: 1, to_version: 2, post_seal: true }))
check('认得它们:不是「未识别的事件」', !answer.some((s) => s.title === '未识别的事件'))
const ok = answer.find((s) => s.code === 'ledger_recalled')
check('原答案上:已按 v2 核对,不影响;写明封存后追加', ok?.title === '已按 v2 核对,不影响'
&& ok?.sub === '封存后追加,不在封存范围内' && ok?.level === 'info' && ok?.kind === 'note', `${ok?.title} / ${ok?.sub}`)
const changed = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'changed', changed: true, from_version: 1,
to_version: 2, answers: 2, tasks: ['t1', 't2'], metrics: [{ k: 0, name: '销售额', changed: true }] }))
.find((s) => s.code === 'ledger_recalled')
check('召回运行上:数变了是提醒,写开了几条待办,不带数', changed?.title === '召回核对(v1 → v2):数变了'
&& changed?.level === 'warn' && changed?.meta === '开了 2 条待办' && !/\d+\.\d/.test(`${changed?.title}${changed?.detail ?? ''}`),
`${changed?.title} / ${changed?.meta}`)
const same = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'unchanged', changed: false, from_version: 1, to_version: 2 }))
.find((s) => s.code === 'ledger_recalled')
check('……数没变的是一般说明', same?.title === '召回核对(v1 → v2):数没变' && same?.level === 'info', same?.title)
const failedRun = run(E(5, 'ledger.recalled', null, { role: 'recall', outcome: 'failed', changed: null, error: '数据源已停用' }))
.find((s) => s.code === 'ledger_recalled')
check('……重算失败照实写原因', failedRun?.title === '召回重算失败' && failedRun?.detail === '数据源已停用' && failedRun?.level === 'warn',
`${failedRun?.title} / ${failedRun?.detail}`)
const probe = run(E(5, 'ledger.recalled', null, { role: 'recall', probe: true, outcome: 'failed', error: 'no such column' }))
.find((s) => s.code === 'ledger_recalled')
check('表结构变了之后的口径试算:没有算出来', probe?.title === '表结构变了之后试算口径:没有算出来', probe?.title)
const basis = run(E(5, 'ledger.basis_changed', null, { kind: 'catalog', source: 'shop', table: 'orders',
items: [{ path: 'columns.amount.measure', label: '列 amount 的度量类型', change: 'changed' }], new_errors: 1 }))
.find((s) => s.code === 'ledger_basis_changed')
check('目录事实被改:依据有变,列出哪几项、新出现几条错误级问题', basis?.title === '依据有变:数据目录里影响数字的项被驳回或修改'
&& basis?.meta === 'shop · orders' && basis?.detail === '列 amount 的度量类型\n按新目录检查新出现 1 条错误级问题'
&& basis?.level === 'warn', `${basis?.title} / ${basis?.detail}`)
const structure = run(E(5, 'ledger.basis_changed', null, { kind: 'structure', table: 'orders', column: 'amount', items: [] }))
.find((s) => s.code === 'ledger_basis_changed')
check('表结构变了:依据的表结构已变', structure?.title === '依据的表结构已变' && structure?.level === 'warn', structure?.title)
const odd = run(E(5, 'ledger.recalled', null, {}), E(6, 'ledger.basis_changed', null, { kind: 'x', items: 'y' }))
check('缺字段:不出 undefined、不瞎填数', !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}${s.detail ?? ''}${s.meta ?? ''}`)),
odd.map((s) => s.title).join(' | '))
const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.recalled', null, {}), E(6, 'ledger.basis_changed', null, {})]
.reduce((s, ev) => mod.nextPhase(s, ev), { phase: 'running', awaiting: false })
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('口径账本:问数据的助手选用已发布口径(use_caliber 操作)', async () => {
// next-round 02 做法 4:取数节点和口径卡由服务端按那一版生成(ledger/compose.py),不另转 add_node。过程里一行说清选用了
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
+136
View File
@@ -17,6 +17,10 @@
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
// 一份,别人取的不算);「退回」可以写原因,走待办接口 decision=reject。
// - 口径召回(kind=recall,payload 同服务端 ledger/recall.task_payload,next-round 02 做法 7):旧口径、新口径、两版之差
// 一行一个指标,每个数点得开出处(召回卡的指标链);每个答案当时的值也点得开(正式运行的口径卡、问数回答的查询快照);
// 问数回答链回那段对话;「按新版重新出具」decision=reissue、「知道了」decision=ack;服务端说不能重新出具时按钮停用、
// 写原因;使用者看到的遮罩值写「已遮罩」、点不开;目录有变那一种只有「知道了」,列出变了的目录项和新出现的问题。
//
// 接口全部在浏览器层答掉(/api/tasks/**),数据是这里虚构的;写请求记在探针里。
//
@@ -134,6 +138,53 @@ const TASKS = {
{ id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] },
people: { zhangsan: '张三' },
},
// 口径召回:口径升到 v2,这一组答案的数变了(payload 同服务端 ledger/recall.task_payload)
recall: {
...base, id: 'recall', kind: 'recall', title: '口径「周销售」升到 v2,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:周销售 v1 → v2', run_id: 'rc_1',
assignments: [assignment('rc1', '审批人甲', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_1', workflow_id: 'wf_week', workflow_name: '周销售',
from_version: 1, to_version: 2,
binding: [{ field: 'end', value: '2026-09-08', masked: false }, { field: 'start', value: '2026-09-01', masked: false }],
metrics: [{ k: 0, name: '销售额', unit: '元', changed: true, card: 'card', metric: 'gmv',
old: { value: 170.5, text: '170.50元', masked: false, metric: 'old_0' },
new: { value: 208.5, text: '208.50元', masked: false, metric: 'new_0' },
diff: { value: 38, text: '38.00元', masked: false, metric: 'diff_0' } }],
answers: [
{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_1', version: 1, conversation_id: 'conv_1', turn_id: 'turn_1',
at: ago(86400_000), original: [{ k: 0, value: 170.5, text: '170.50元', masked: false, query: 'snap_1', column: '销售额' }] },
{ kind: 'schedule', kind_text: '定时任务', run_id: 'run_fm_1', version: 1, schedule_id: 'sch_1', at: ago(3 * 86400_000),
original: [{ k: 0, value: 165, text: '165.00元', masked: false, node: 'card', metric: 'gmv' }] },
],
actions: [{ decision: 'reissue', available: true }, { decision: 'ack', available: true }] },
},
// 使用者看的:遮罩值写「已遮罩」、点不开;没有被他能用的应用固定,服务端说不能重新出具
recallUser: {
...base, id: 'recallUser', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_2',
assignments: [assignment('rc2', 'zhangsan', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_2', workflow_id: 'wf_tier', workflow_name: '客户分层',
from_version: 2, to_version: 3, binding: [{ field: 'phone', value: null, masked: true }],
metrics: [{ k: 0, name: '消费额', unit: '元', changed: true, card: 'card', metric: 'spend',
old: { value: null, text: '已遮罩', masked: true, metric: 'old_0' },
new: { value: 90, text: '90元', masked: false, metric: 'new_0' },
diff: { value: null, text: '已遮罩', masked: true, metric: 'diff_0' } }],
answers: [{ kind: 'answer', kind_text: '问数回答', run_id: 'run_ans_2', version: 2, conversation_id: 'conv_2',
original: [{ k: 0, value: null, text: null, masked: false, missing: '原答案里没有单独的这个数' }] }],
actions: [{ decision: 'reissue', available: false,
reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }, { decision: 'ack', available: true }] },
},
// 答案依据的目录有变、按新目录检查出了新的错误级问题:只有「知道了」
recallCatalog: {
...base, id: 'recallCatalog', kind: 'recall', title: '答案依据的数据目录有变:按新目录检查出了新的问题', node_id: null,
app_id: null, app_name: null, app_version: null, workflow_name: '', run_id: 'run_ans_3',
assignments: [assignment('rc3', '审批人甲', 'pending', null, { source: 'recall' })],
payload: { kind: 'recall', reason: 'catalog', recall_run: 'run_ans_3',
basis: { kind: 'catalog', source: 'shop', table: 'orders', new_errors: 1,
items: [{ path: 'columns.amount.measure', label: '列 amount 的度量类型', change: 'changed' }],
checks: [{ code: 'stock_summed', message: '对存量列 amount 求和,分组里没有业务日期' }] },
actions: [{ decision: 'ack', available: true }] },
},
// 补充输入里选复核人(选人控件):使用者按这条待办的候选池选
pick: {
...base, id: 'pick', approval_id: 'ap8', kind: 'input', title: '指定复核人',
@@ -403,6 +454,89 @@ await section('审核个人应用:取副本去发布、打开我的副本、
check('退回不取副本', !s3.writes.some((w) => w.path.endsWith('/copy')))
})
await section('口径召回:三个值和差值、原答案当时的值、两个动作', async () => {
const state = { writes: [] }
const page = await open(state)
const asked = []
page.on('request', (r) => { if (r.url().includes('/api/')) asked.push(new URL(r.url()).pathname) })
await page.goto(`${WEB}/apps/inbox/recall`)
const box = page.locator('[data-task-recall="caliber"]')
await box.waitFor({ timeout: 15000 })
check('写明口径从 v1 升到 v2、同一份数据上两版各算一次', (await box.innerText()).includes('口径「周销售」从 v1 升到了 v2')
&& (await box.innerText()).includes('同一份数据'), (await box.innerText()).slice(0, 80))
check('这一组的参数', (await box.locator('[data-recall-binding-field="start"]').innerText()) === 'start=2026-09-01')
const row = box.locator('[data-recall-metric="gmv"]')
check('旧口径、新口径、两版之差一行,标「变了」', await row.getAttribute('data-changed') === 'true'
&& (await row.locator('[data-recall-cell="old"]').innerText()) === '170.50元'
&& (await row.locator('[data-recall-cell="new"]').innerText()) === '208.50元'
&& (await row.locator('[data-recall-cell="diff"]').innerText()) === '38.00元', (await row.innerText()).replace(/\s+/g, ' '))
const head = await box.locator('[data-recall-metrics] thead').innerText()
check('表头写旧口径 v1、新口径 v2、两版之差', head.includes('旧口径 v1') && head.includes('新口径 v2') && head.includes('两版之差'), head)
check('两个答案各一行,写原答案当时的值', await box.locator('[data-recall-answer]').count() === 2
&& (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').innerText()) === '170.50元'
&& (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').innerText()) === '165.00元')
check('问数回答链回那段对话', (await box.locator('[data-recall-answer="run_ans_1"] [data-recall-answer-link="chat"]')
.getAttribute('href')) === '/chat/conv_1')
check('正式运行(搭建者)链到记录页', (await box.locator('[data-recall-answer="run_fm_1"] [data-recall-answer-link="run"]')
.getAttribute('href')) === '/runs/run_fm_1')
const wait = (part) => page.waitForRequest((r) => r.url().includes(part), { timeout: 5000 }).then(() => true, () => false)
let hit = wait('/api/runs/rc_1/evidence/metrics/recall/diff_0')
await row.locator('[data-recall-cell="diff"]').click()
check('点差值:取召回卡上这个指标的出处链', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
hit = wait('/api/runs/run_fm_1/evidence/metrics/card/gmv')
await box.locator('[data-recall-answer="run_fm_1"] [data-recall-original="0"]').click()
check('点定时任务那次当时的值:取那次正式运行口径卡的指标链', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
hit = wait('snap_1')
await box.locator('[data-recall-answer="run_ans_1"] [data-recall-original="0"]').click()
check('点问数回答当时的值:打开那条查询快照', await hit, asked.slice(-3).join(' , '))
await page.keyboard.press('Escape')
await page.locator('[data-recall-reissue]').click()
await page.waitForTimeout(300)
const sent = state.writes.find((w) => w.path === '/api/tasks/recall/respond')
check('「按新版重新出具」走待办接口,decision 是 reissue', sent?.body?.decision === 'reissue', JSON.stringify(sent?.body))
check('……提示已按新版发起正式出具', await page.getByText('已按新版发起正式出具').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
check('不给审批那一套按钮', await page.locator('[data-task-approve], [data-task-reject]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
const s2 = { writes: [] }
const p2 = await open(s2)
await p2.goto(`${WEB}/apps/inbox/recall`)
await p2.locator('[data-recall-ack]').waitFor({ timeout: 15000 })
await p2.locator('[data-recall-ack]').click()
await p2.waitForTimeout(300)
check('「知道了」decision 是 ack', s2.writes.find((w) => w.path === '/api/tasks/recall/respond')?.body?.decision === 'ack',
JSON.stringify(s2.writes))
})
await section('口径召回:使用者看到的遮罩值、不能重新出具的原因;目录有变那一种', async () => {
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
const page = await open(state)
await page.goto(`${WEB}/apps/inbox/recallUser`)
const box = page.locator('[data-task-recall="caliber"]')
await box.waitFor({ timeout: 15000 })
const row = box.locator('[data-recall-metric="spend"]')
check('遮罩值写「已遮罩」、点不开', (await row.locator('[data-recall-cell="old"]').innerText()) === '已遮罩'
&& await row.locator('[data-recall-cell="old"]').isDisabled() && (await row.locator('[data-recall-cell="new"]').innerText()) === '90元')
check('参数里的遮罩值同样遮住', (await box.locator('[data-recall-binding-field="phone"]').innerText()) === 'phone=已遮罩')
check('原答案里没有单独这个数:写「—」不给链接', (await box.locator('[data-recall-answer="run_ans_2"]').innerText()).includes('—')
&& await box.locator('[data-recall-answer="run_ans_2"] [data-recall-original]').count() === 0)
check('不能重新出具:按钮停用,写服务端给的原因', await page.locator('[data-recall-reissue]').isDisabled()
&& (await page.locator('[data-recall-reissue-reason]').innerText()).includes('请搭建者出具'))
check('使用者不给记录页的链接', await box.locator('[data-recall-answer-link="run"]').count() === 0)
const p2 = await open({ writes: [] })
await p2.goto(`${WEB}/apps/inbox/recallCatalog`)
const basis = p2.locator('[data-recall-basis="catalog"]')
await basis.waitFor({ timeout: 15000 })
const text = await basis.innerText()
check('目录有变:写变了的目录项和新出现的问题', text.includes('列 amount 的度量类型(改了)') && text.includes('新出现的错误级问题 1 条')
&& text.includes('分组里没有业务日期'), text.replace(/\s+/g, ' '))
check('……只有「知道了」', await p2.locator('[data-recall-ack]').count() === 1 && await p2.locator('[data-recall-reissue]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0 && p2.errors.length === 0, page.errors[0] ?? p2.errors[0] ?? '')
})
await section('待办箱:列表和徽标', async () => {
const page = await open({ writes: [] })
await page.goto(`${WEB}/apps?tab=inbox`)
@@ -410,6 +544,8 @@ await section('待办箱:列表和徽标', async () => {
check('每条待办一行,链到详情', (await page.locator('[data-inbox-task]').count()) === Object.keys(TASKS).length)
check('审核个人应用的一行写种类「审核个人应用」', (await page.locator('[data-inbox-task="review"]').innerText()).includes('审核个人应用')
&& (await page.locator('[data-inbox-task="review"]').getAttribute('data-task-kind')) === 'review')
check('口径召回的一行写种类「口径召回」', (await page.locator('[data-inbox-task="recall"]').innerText()).includes('口径召回')
&& (await page.locator('[data-inbox-task="recall"]').getAttribute('data-task-kind')) === 'recall')
check('「应用」导航上的徽标数应用里的待办', (await page.locator('[data-apps-badge]').innerText()).trim() === String(Object.keys(TASKS).length))
check('页签上带数', /\d/.test(await page.getByRole('tab', { name: /待办/ }).innerText()))
})
+145
View File
@@ -2075,6 +2075,151 @@ await section('应用平台:发布成功做成应用;已发布版本的使
}
})
// 口径召回(next-round 02 做法 7、8):发布弹窗按这一版预告召回的规模(旧版本被谁用过、预计自动重算几组、超出上限几组、
// 不能重算的原因),版本用量加问数回答、导出、召回;版本历史里看口径升到这一版之后的召回批次(每组的结论),「另有 M 组未
// 重算」能手动接着算。预告、批次、接着算全在浏览器层伪造,写请求拦下核对
const PREVIEW = { workflow_id: 'pf-hard', version: 3, limit: 50, caliber: true, problem: null, groups: 60, recomputable: 58,
not_recomputable: 2, already: 0, will_recompute: 50, over_limit: 8, truncated: false,
versions: [{ version: 2, answers: 3, exports: 1, schedules: 1, apps: 2, formal: 4, pinned: 0, truncated: false }],
reasons: [{ reason: '新版 v3 的入口要的参数这一组里没有或不合规:「区域」必填', groups: 2 }] }
const BATCHES = { workflow_id: 'pf-lib', workflow_name: '__publish_check_lib__', version: 4, limit: 50, batch_size: 50,
problem: null, caliber: true, truncated: false, remaining: 1, can_continue: true,
counts: { changed: 1, unchanged: 1, failed: 1, running: 0, pending: 1, not_recomputable: 1 },
groups: [
{ id: '3-a', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W38' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 2 }], latest: '2026-10-01T02:00:00Z', state: 'changed',
run_id: 'rc-a', batch: 'auto', tasks: 2, changed_metrics: ['销售额'] },
{ id: '3-b', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W37' }],
uses: [{ kind: 'schedule', kind_text: '定时任务', count: 1 }], latest: '2026-09-24T02:00:00Z', state: 'unchanged',
run_id: 'rc-b', batch: 'auto', tasks: 0, changed_metrics: [] },
{ id: '3-c', from_version: 3, to_version: 4, binding: [], uses: [{ kind: 'formal', kind_text: '正式运行', count: 1 }],
latest: '2026-09-20T02:00:00Z', state: 'failed', reason: '重算失败:数据源「shop」已停用', run_id: 'rc-c', batch: 'auto' },
{ id: '2-d', from_version: 2, to_version: 4, binding: [{ field: 'week', value: '2026-W30' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 1 }], latest: '2026-08-01T02:00:00Z', state: 'not_recomputable',
reason: '新版 v4 的入口要的参数这一组里没有或不合规:「区域」必填' },
{ id: '3-e', from_version: 3, to_version: 4, binding: [{ field: 'week', value: '2026-W20' }],
uses: [{ kind: 'answer', kind_text: '问数回答', count: 1 }], latest: '2026-07-01T02:00:00Z', state: 'pending' },
] }
await section('口径召回:发布弹窗预告召回规模、版本用量加问数回答和召回', async () => {
const usage = { apps: [], effects: 0, formal_runs: 4, answers: 5, exports: 2, schedules: 1,
recall: { groups: 1, changed: 1, recalled_from: 0 } }
for (const variant of ['over', 'zero', 'none', 'not-caliber']) {
const { page, errors } = await open({ id: 'pf-hard' })
const asked = []
const body = variant === 'over' ? PREVIEW : variant === 'zero' ? { ...PREVIEW, limit: 0, will_recompute: 0, over_limit: 58 }
: variant === 'none' ? { ...PREVIEW, groups: 0, recomputable: 0, not_recomputable: 0, will_recompute: 0, over_limit: 0,
versions: [], reasons: [] }
: { ...PREVIEW, caliber: false, groups: 0, recomputable: 0, not_recomputable: 0, will_recompute: 0, over_limit: 0,
versions: [], reasons: [] }
await page.route(/\/api\/workflows\/pf-hard\/recall-preview(\?.*)?$/, (route) => {
asked.push(new URL(route.request().url()).search)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) }))
await openDialog(page)
const row = dialog(page).locator('[data-publish-recall]')
if (variant === 'not-caliber') {
await waitFor(page, async () => asked.length > 0)
await page.waitForTimeout(300)
check('旧版本里没有口径、也没有用过的答案:不画召回那一行', await row.count() === 0)
continue
}
check(`预告(${variant}):按要发布的这一版问(version=3)`, await waitFor(page, async () => asked.length > 0)
&& asked[0].includes('version=3'), asked.join(','))
await waitFor(page, async () => (await row.getAttribute('data-publish-recall').catch(() => '')) !== 'loading')
const text = await row.innerText().catch(() => '')
if (variant === 'over') {
check('写旧版本被谁用过', text.includes('旧版本被 3 次问数回答、1 次导出、1 个定时任务、2 个应用、4 次正式运行用过'), text)
check('写去重后几组、发布后自动重算几组', text.includes('按参数去重共 60 组,发布后自动重算 50 组')
&& await row.locator('[data-publish-recall-will="50"]').count() === 1, text)
check('超出上限的写明可以手动接着算', (await row.locator('[data-publish-recall-over="8"]').innerText())
.includes('另有 8 组超出每次 50 组的上限'))
check('不能重算的写几组和原因', (await row.locator('[data-publish-recall-unrecomputable="2"]').innerText())
.includes('「区域」必填'))
check('写明重算只运行口径的数据部分、不调用模型', text.includes('不调用模型'), text)
const line = await dialog(page).locator('[data-publish-usage] [data-version-usage-ledger]').innerText().catch(() => '')
check('使用情况加上问数回答、导出、召回', line.includes('问数回答 5 次') && line.includes('导出 2 次')
&& line.includes('召回 1 组(数变了 1 组)'), line)
await shoot(page, 'publish-recall-preview', dialog(page))
} else if (variant === 'zero') {
check('上限是 0:写明发布后不自动重算、只列出受影响的答案', await row.locator('[data-publish-recall-zero]').count() === 1
&& await row.locator('[data-publish-recall-over]').count() === 0, text)
} else {
check('是口径但旧版本没被用过:写不需要召回', await row.getAttribute('data-publish-recall') === 'none'
&& text.includes('不需要召回'), text)
}
check(`没有运行时报错(${variant})`, errors.length === 0, errors.join(' | '))
}
})
await section('口径召回:版本历史看召回批次、另有 M 组未重算、接着算', async () => {
const { page, errors } = await open({ id: 'pf-lib' })
const versions = [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z',
published: n === 4, level: 'published', retired_at: null, retired_by: null }))
await page.route(/\/api\/workflows\/pf-lib\/versions(\/\d+)?$/, (route) => {
const v = Number(new URL(route.request().url()).pathname.split('/')[5])
const body = v ? { ...versions.find((x) => x.version === v), workflow_id: 'pf-lib', graph: FAKES['pf-lib'].graph, graph_hash: 'x' } : versions
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ apps: [], effects: 0, formal_runs: 3, answers: 4, exports: 0, schedules: 1,
recall: { groups: 4, changed: 1, recalled_from: 0 } }) }))
const continued = []
await page.route(/\/api\/ledger\/recall\/pf-lib\/(\d+)(\/continue)?$/, (route) => {
const req = route.request()
const v = Number(new URL(req.url()).pathname.split('/')[5])
if (v !== 4) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, version: v,
groups: [], remaining: 0, can_continue: false, counts: { changed: 0, unchanged: 0, failed: 0, running: 0, pending: 0,
not_recomputable: 0 } }) })
if (req.method() === 'POST') {
continued.push(new URL(req.url()).pathname)
const groups = BATCHES.groups.map((g) => (g.state === 'pending' ? { ...g, state: 'running', run_id: 'rc-e', batch: 'manual' } : g))
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, groups, remaining: 0,
can_continue: false, launched: ['rc-e'], counts: { ...BATCHES.counts, pending: 0, running: 1 } }) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BATCHES) })
})
await page.evaluate(() => document.activeElement?.blur?.())
await page.keyboard.press('Alt+KeyH')
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
await sheet.waitFor()
await waitFor(page, async () => await sheet.locator('ol button', { hasText: 'v4' }).count() > 0)
await sheet.locator('ol button', { hasText: 'v4' }).first().click()
const box = sheet.locator('[data-version-recall="5"]')
check('展开 v4:列出口径升到这一版之后的召回批次', await waitFor(page, async () => await box.count() === 1))
const summary = await box.locator('[data-version-recall-summary]').innerText().catch(() => '')
check('……一句总的:数变了、没变、重算失败、无法重算各几组', summary === '数变了 1 组,没变 1 组,重算失败 1 组,无法重算 1 组', summary)
const state = async (id) => (await box.locator(`[data-version-recall-group="${id}"] .chip`).first().innerText().catch(() => ''))
check('每组写结论', await state('3-a') === '数变了' && await state('3-b') === '没变' && await state('3-c') === '重算失败'
&& await state('2-d') === '无法重算' && await state('3-e') === '未重算')
check('数变了的写变了的指标、开了几条待办,能看召回运行', (await box.locator('[data-version-recall-group="3-a"]').innerText())
.includes('变了的指标:销售额') && (await box.locator('[data-version-recall-group="3-a"]').innerText()).includes('开了 2 条待办')
&& (await box.locator('[data-version-recall-run="rc-a"]').getAttribute('href')) === '/runs/rc-a')
check('无法重算照实写原因', (await box.locator('[data-version-recall-group="2-d"] [data-version-recall-reason]').innerText())
=== '无法重算:新版 v4 的入口要的参数这一组里没有或不合规:「区域」必填')
check('重算失败写原因', (await box.locator('[data-version-recall-group="3-c"] [data-version-recall-reason]').innerText())
.includes('数据源「shop」已停用'))
const usageLine = await sheet.locator('[data-version-lifecycle="4"] [data-version-usage-ledger]').innerText().catch(() => '')
check('版本用量加上问数回答、召回', usageLine.includes('问数回答 4 次') && usageLine.includes('召回 4 组(数变了 1 组)'), usageLine)
check('另有 1 组未重算', (await box.locator('[data-version-recall-remaining="1"]').innerText()).includes('另有 1 组未重算'))
const go = box.locator('[data-version-recall-continue]')
check('给「接着算」,写每次几组', (await go.innerText()).includes('接着算(每次 50 组)'))
await shoot(page, 'versions-recall', sheet)
await go.click()
check('接着算:POST /api/ledger/recall/pf-lib/4/continue', await waitFor(page, async () => continued.length === 1)
&& continued[0] === '/api/ledger/recall/pf-lib/4/continue', continued.join(','))
check('……之后照服务端交回的批次画:没有未重算的、刚发起的那组正在重算', await waitFor(page, async () =>
await box.locator('[data-version-recall-remaining]').count() === 0 && await state('3-e') === '正在重算'))
check('……提示已发起几组', await page.getByText('已发起 1 组召回').first().waitFor({ timeout: 3000 }).then(() => true, () => false))
await sheet.locator('ol button', { hasText: 'v3' }).first().click()
await sheet.locator('[data-version-lifecycle="3"]').waitFor()
await page.waitForTimeout(300)
check('没有召回过的版本不画召回那一块', await sheet.locator('[data-version-lifecycle="3"] [data-version-recall]').count() === 0)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
// 开了登录:发布记的是登录的人(服务端写 published_by,不看署名),不给署名框、不说「未署名」;受管级只给管理员,
// 搭建者那一档当场置灰、写明原因,不等点了发布才被拒(V1-21、V1-22)。受管工作流的新版本不论哪一档都只有管理员能发:
// 降成「已发布」会让此后的正式运行不再过治理门禁,和以受管级发布同一级(Q8,服务端同样拦)——搭建者两档都锁住