mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
口径召回前端(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:
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
|
||||
reconcile: '核实结果',
|
||||
signal: '等待外部系统',
|
||||
review: '审核个人应用',
|
||||
recall: '口径召回',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
|
||||
@@ -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: '封存后追加,不在封存范围内',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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。一条事件就是一问一答,挂在顶层
|
||||
|
||||
@@ -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
|
||||
/** 动作确认:确认卡上显示的哈希,原样带回 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(' | '))
|
||||
})
|
||||
|
||||
@@ -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。过程里一行说清选用了
|
||||
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
|
||||
@@ -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,服务端同样拦)——搭建者两档都锁住
|
||||
|
||||
Reference in New Issue
Block a user