From 35583ba1d8c50d09e3ab7b79c0a6dea05fd8d09a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 20:05:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8F=AC=E5=9B=9E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=88W4-a=EF=BC=89=EF=BC=9A=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E5=8A=A0=E9=97=AE=E6=95=B0=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E5=92=8C=E5=8F=AC=E5=9B=9E=E3=80=81=E5=8F=91=E5=B8=83=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E9=A2=84=E5=91=8A=E5=8F=AC=E5=9B=9E=E8=A7=84=E6=A8=A1?= =?UTF-8?q?=E3=80=81=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2=E7=9C=8B=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E6=89=B9=E6=AC=A1=E3=80=81recall=20=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E5=8D=A1=E3=80=81=E6=97=B6=E9=97=B4=E7=BA=BF=E8=AE=A4=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E6=96=B0=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:前三波召回只有接口;这一波收口,口径升版之后谁的答案受影响、重算了几组、数变没变、怎么处置都要在界面上走得通。 - 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 --- frontend/src/api/apps.ts | 9 +- frontend/src/api/client.ts | 3 + frontend/src/api/recall.ts | 23 +++ frontend/src/canvas/PublishDialog.tsx | 5 + frontend/src/canvas/RecallPanel.tsx | 182 +++++++++++++++++ frontend/src/canvas/VersionsSheet.tsx | 6 +- frontend/src/canvas/versionUsage.tsx | 18 +- frontend/src/lib/termsApp.ts | 1 + frontend/src/lib/termsRecall.ts | 152 +++++++++++++++ frontend/src/run/RecallTask.tsx | 270 ++++++++++++++++++++++++++ frontend/src/run/TaskCard.tsx | 15 +- frontend/src/run/decode.ts | 43 ++++ frontend/src/typesApp.ts | 8 +- frontend/src/typesRecall.ts | 176 +++++++++++++++++ scripts/check-capabilities.mjs | 14 +- scripts/check-decode.mjs | 46 +++++ scripts/check-inbox.mjs | 136 +++++++++++++ scripts/check-publish.mjs | 145 ++++++++++++++ 18 files changed, 1236 insertions(+), 16 deletions(-) create mode 100644 frontend/src/api/recall.ts create mode 100644 frontend/src/canvas/RecallPanel.tsx create mode 100644 frontend/src/lib/termsRecall.ts create mode 100644 frontend/src/run/RecallTask.tsx create mode 100644 frontend/src/typesRecall.ts diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index ecda1b52..be7813cd 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -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>() 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}`), } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d37f2e4b..c98c404d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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, diff --git a/frontend/src/api/recall.ts b/frontend/src/api/recall.ts new file mode 100644 index 00000000..448fd0a5 --- /dev/null +++ b/frontend/src/api/recall.ts @@ -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(`/workflows/${wf(workflowId)}/recall-preview${qs({ version })}`, opts), + batches: (workflowId: string, version: number, opts?: RequestOptions) => + get(`/ledger/recall/${wf(workflowId)}/${version}`, opts), + continueBatch: (workflowId: string, version: number) => + post(`/ledger/recall/${wf(workflowId)}/${version}/continue`), +} diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 4168a95c..b0244401 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -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 }: { )} + {/* 口径召回的预告(next-round 02 做法 7):发布新版本之后用过旧版本的答案按参数分组重算,规模先摆出来 */} + {published != null && published !== workflow.version && ( + + )}
{signer.verified ? '发布人' : '署名'}
diff --git a/frontend/src/canvas/RecallPanel.tsx b/frontend/src/canvas/RecallPanel.tsx new file mode 100644 index 00000000..5898fc27 --- /dev/null +++ b/frontend/src/canvas/RecallPanel.tsx @@ -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(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 : ( +
+
{P.label}
+
{P.loading}
+
+ ) + } + 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 ( +
+
{P.label}
+
+ {!preview.groups ?
{P.none}
: ( + <> +
{P.uses(uses)}
+
+ {P.groups(preview.groups, preview.will_recompute)} + {preview.already > 0 && ({P.already(preview.already)})} +
+ {preview.limit === 0 &&
{P.zero}
} + {preview.limit > 0 && preview.over_limit > 0 && ( +
+ {P.over(preview.over_limit, preview.limit)} +
+ )} + {preview.not_recomputable > 0 && ( +
+ {P.notRecomputable(preview.not_recomputable)}:{preview.reasons.map((r) => r.reason).join(';')} +
+ )} + {preview.truncated &&
{P.truncated}
} +
{P.hint}
+ + )} +
+
+ ) +} + +const STATE_COLOR: Record = { + 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(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
{B.loading}
+ if (!data.groups.length) return null + const shown = all ? data.groups : data.groups.slice(0, SHOWN) + const summary = B.summary(data.counts) + return ( +
+
{B.title}
+ {summary &&
{summary}
} +
    + {shown.map((g) => )} +
+ {!all && data.groups.length > SHOWN && ( + + )} + {data.remaining > 0 && ( +
+
{B.remaining(data.remaining)}
+ {data.can_continue && ( + + )} +
+ )} + {data.truncated &&
{B.truncated}
} +
+ ) +} + +function GroupRow({ group }: { group: RecallGroup }) { + return ( +
  • +
    + + {B.state[group.state] ?? group.state} + + {B.from(group.from_version)} + {B.uses(group.uses)} + {group.latest && · {formatDateTime(group.latest)}} +
    +
    + {group.binding.length + ? group.binding.map((b) => {b.field}={String(b.value ?? '')}) + : {B.noBinding}} +
    + {group.reason && ( +
    + {B.reason(group.state, group.reason)} +
    + )} + {!!group.changed_metrics?.length &&
    {B.changedMetrics(group.changed_metrics)}
    } +
    + {!!group.tasks && {B.tasks(group.tasks)}} + {group.run_id && ( + + {B.openRun} + + )} +
    +
  • + ) +} diff --git a/frontend/src/canvas/VersionsSheet.tsx b/frontend/src/canvas/VersionsSheet.tsx index 31c98dd5..d53ecbb4 100644 --- a/frontend/src/canvas/VersionsSheet.tsx +++ b/frontend/src/canvas/VersionsSheet.tsx @@ -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 }: {
    {usage ? : …}
    + {/* 口径升到这一版之后的召回批次:每组的结论、「另有 M 组未重算」、接着算(next-round 02 做法 7) */} + {!version.retired_at && ( <> {builder && ( diff --git a/frontend/src/canvas/versionUsage.tsx b/frontend/src/canvas/versionUsage.tsx index 1d02084d..797a1a20 100644 --- a/frontend/src/canvas/versionUsage.tsx +++ b/frontend/src/canvas/versionUsage.tsx @@ -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 ( {T.apps(usage.apps.length)} @@ -47,6 +60,7 @@ export function VersionUsageLine({ usage }: { usage: VersionUsage }) { )} · {T.effects(usage.effects)} · {T.formalRuns(usage.formal_runs)} + {extra.length > 0 && · {extra.join(' · ')}} ) } diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 43d29ef6..9f7fb8c4 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -77,6 +77,7 @@ export const TASK_KIND_LABEL: Record = { reconcile: '核实结果', signal: '等待外部系统', review: '审核个人应用', + recall: '口径召回', } export const TASK_STATUS_LABEL: Record = { diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts new file mode 100644 index 00000000..879e7149 --- /dev/null +++ b/frontend/src/lib/termsRecall.ts @@ -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) => { + 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, + 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, + }, + + // ---- 时间线(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, + basisItems: (labels: string[]) => labels.join('、'), + newErrors: (count: number) => `按新目录检查新出现 ${n(count)} 条错误级问题`, + sub: '封存后追加,不在封存范围内', + }, +} diff --git a/frontend/src/run/RecallTask.tsx b/frontend/src/run/RecallTask.tsx new file mode 100644 index 00000000..a0b2b194 --- /dev/null +++ b/frontend/src/run/RecallTask.tsx @@ -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 + const [evidence, setEvidence] = useState(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 | null + + return ( +
    + {reason === 'caliber' && ( + <> +

    {T.intro(name, payload.from_version, payload.to_version)}

    + {!!payload.binding?.length && ( +
    + {T.binding}: + {payload.binding.map((b) => ( + + {b.field}={b.masked ? T.masked : String(b.value ?? '')} + + ))} +
    + )} + + +

    {T.how}

    + + )} + {reason === 'catalog' && } + {reason === 'structure' && } + + {!pending && outcome?.decision && ( +
    + {T.resolved(outcome.decision)} + {outcome.issued_run && } +
    + )} + {canAct && ( +
    + {reissue && ( + + )} + {ack && ( + + )} +
    + )} + {canAct && reissue && !reissue.available && reissue.reason && ( +
    + {reissue.reason} +
    + )} + {canAct && reissue?.issued_run && } + setEvidence(null)} /> +
    + ) +} + +/** 三个数(旧口径、新口径、两版之差)一行一个指标,每个都点得开召回卡上那个指标的出处链 */ +function Metrics({ payload, onOpen }: { payload: Partial; 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 ( +
    + + + + + {heads.map((h) => )} + + + + {metrics.map((m) => ( + + + {(['old', 'new', 'diff'] as const).map((side, i) => ( + + ))} + + ))} + +
    {T.metric}{h}
    + {m.name} + + {m.changed ? T.changed : T.same} + + + +
    +
    + ) +} + +/** 用到这份口径的答案:哪一种、什么时候、当时的值(点得开当时的出处)、去哪里看 */ +function Answers({ payload, onOpen }: { payload: Partial; 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 ( +
    +
    {T.answers}
    +
      + {answers.map((a) => ( +
    • +
      + {T.answerOf(a.kind_text, a.at ? formatDateTime(a.at) : null)} + +
      +
      + {a.original.map((v) => ( + + {names.get(v.k) ?? ''} · {T.original}: + {v.missing && !v.masked + ? {T.missing} + : ( + + )} + + ))} +
      +
    • + ))} +
    +
    + ) +} + +/** 去看这个答案:问数回答回到那段对话;别的(正式运行)给搭建者去记录页 */ +function AnswerLink({ answer }: { answer: RecallAnswer }) { + const builder = useAtLeast('builder') + if (answer.kind === 'answer' && answer.conversation_id) { + return ( + + {T.openTurn} + + ) + } + if (!builder) return null + return ( + + {T.openAnswer} + + ) +} + +function IssuedLink({ runId }: { runId: string }) { + return ( + + {T.issuedRun} + + ) +} + +function CatalogBasis({ payload }: { payload: Partial }) { + const basis = payload.basis ?? {} + const items = Array.isArray(basis.items) ? basis.items : [] + const checks = Array.isArray(basis.checks) ? basis.checks : [] + return ( +
    +

    {T.catalogIntro}

    + {items.length > 0 && ( +
    + {T.catalogItems}: + {items.map((i, k) => ( + + {k > 0 && '、'}{i.label ?? i.path}{i.change ? `(${T.change[i.change] ?? i.change})` : ''} + + ))} +
    + )} + {basis.new_errors != null &&
    {T.catalogChecks(basis.new_errors)}
    } + {checks.length > 0 && ( +
      + {checks.map((c, k) =>
    • {c.message}
    • )} +
    + )} +
    + ) +} + +function StructureBasis({ payload, name }: { payload: Partial; name: string }) { + const basis = payload.basis ?? {} + const builder = useAtLeast('builder') + return ( +
    +

    {T.structureIntro(name, basis.version)}

    + {basis.table &&
    {T.structureWhere(basis.table, basis.column)}
    } + {basis.error && ( +
    + {T.probeError}:{basis.error} +
    + )} + {builder && basis.probe_run && ( + + {T.openProbe} + + )} +
    + ) +} diff --git a/frontend/src/run/TaskCard.tsx b/frontend/src/run/TaskCard.tsx index 96098fd1..78484f99 100644 --- a/frontend/src/run/TaskCard.tsx +++ b/frontend/src/run/TaskCard.tsx @@ -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 = { 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 = void respond({ decision: 'reject' }, REVIEW_TASK_TEXT.rejected)} /> )} + {kind === 'recall' && ( + void respond({ decision }, + decision === 'reissue' ? RECALL_TEXT.task.doneReissue : RECALL_TEXT.task.doneAck)} /> + )} {showForm && (
    diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 6e2eeeb7..7ac59c5b 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -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。一条事件就是一问一答,挂在顶层 diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index fbc45ca7..a5fa0217 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -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 note?: string /** 动作确认:确认卡上显示的哈希,原样带回 */ diff --git a/frontend/src/typesRecall.ts b/frontend/src/typesRecall.ts new file mode 100644 index 00000000..84428b4e --- /dev/null +++ b/frontend/src/typesRecall.ts @@ -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 + 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 | null +} diff --git a/scripts/check-capabilities.mjs b/scripts/check-capabilities.mjs index 2e85afe6..840715a6 100644 --- a/scripts/check-capabilities.mjs +++ b/scripts/check-capabilities.mjs @@ -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(' | ')) }) diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 77af3970..2d0ac338 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -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。过程里一行说清选用了 // 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改) diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index 1fba2777..33031729 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -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())) }) diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index c689c241..be84f74a 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -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,服务端同样拦)——搭建者两档都锁住