召回复核修正(前端):召回待办的系统通知去待办页、使用者不给重新出具的记录页链接、旧版写不再接着算的原因、时间线认对账反馈

- W4R-06:后台系统通知点开时,口径召回(不属于应用、挂在召回运行上)和使用者(进不了记录页)一律去 /apps/inbox/{id}。原来
  没有 app_id 的都去 /runs/{run_id}:使用者被送回应用首页,搭建者落到召回运行的详情页,那里没有这张待办卡。
- W4R-07:recall 待办里「重新出具的正式运行」只给搭建者链到记录页,使用者写「正式运行的记录由搭建者在记录页查看」(同问数据
  轮次下的出具结果)。
- W4R-12:版本历史里旧版还有没算的组却不能接着算时,照服务端给的 continue_reason 写「不再接着算:……」,不给按钮。
- decode 认 ledger.feedback(对账运行时间线上一条一行:谁给了哪一种反馈;认不出的不猜)。

检查:check-inbox 加使用者 / 搭建者看重新出具过的待办、系统通知点开去待办页两段(通知换成记录器,点它就是调 onclick);
check-publish 加之后又发布了更新版本的旧版不给接着算;check-decode 加 ledger.feedback 一段。去掉修复回跑,新加的几项都不过;
加上修复 decode、inbox、publish 三项 1110 项全过。tsc、check-copy 通过。
This commit is contained in:
ThearchyHelios
2026-10-16 22:16:58 +08:00
parent b3dcddb52e
commit 464d1c0741
9 changed files with 156 additions and 4 deletions
+4 -1
View File
@@ -1222,7 +1222,10 @@ function watchTasks(go: Go): () => void {
title: `${TASK_NOTIFY_TEXT.title}${t.title || TASK_NOTIFY_TEXT.untitled}`,
body: [owner, t.node_label].filter(Boolean).join(' · ') || undefined,
tag: `task:${t.id}`,
onClick: () => go(t.app_id ? `/apps/inbox/${encodeURIComponent(t.id)}` : `/runs/${t.run_id}`),
// 应用里的待办、口径召回(挂在召回运行上,记录页里没有这张卡)、使用者(进不了记录页)都去待办页;
// 搭建者的别的待办去那次运行
onClick: () => go(t.app_id || t.kind === 'recall' || !builder
? `/apps/inbox/${encodeURIComponent(t.id)}` : `/runs/${t.run_id}`),
})
}
} catch {
+5 -1
View File
@@ -3,7 +3,8 @@
* - 发布弹窗的召回预告(RecallPreviewRow):把这一版发布成新版本之后,旧版本被多少问数回答、导出、定时任务、应用用过,
* 预计自动重算几组,超出上限的写明(GET /api/workflows/{id}/recall-preview)。和发布之后真的召回走同一份计划,规模一致;
* - 版本历史里口径升到这一版之后的召回批次(RecallBatches):每组的结论(数变了 / 没变 / 无法重算:原因 / 重算失败 / 正在
* 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。
* 重算 / 未重算),「另有 M 组未重算」和「接着算」(GET / POST /api/ledger/recall/{wf}/{v})。之后又发布了更新的版本的,
* 旧的那一版不再给「接着算」,写服务端给的原因(continue_reason)。
*
* 界面层不放逻辑:几组、能不能重算、为什么不能、能不能接着算都由服务端给。老服务端没有这两个接口(404)时不画。
*/
@@ -138,6 +139,9 @@ export function RecallBatches({ workflowId, version }: { workflowId: string; ver
{busy ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {busy ? B.continuing : B.continue(data.batch_size)}
</button>
)}
{!data.can_continue && data.continue_reason && (
<div className="mt-0.5 text-dim" data-version-recall-continue-reason="">{B.cannotContinue(data.continue_reason)}</div>
)}
</div>
)}
{data.truncated && <div className="mt-1 text-faint">{B.truncated}</div>}
+16
View File
@@ -70,6 +70,8 @@ export const RECALL_TEXT = {
} as Record<string, string>,
reason: (state: string, reason: string) => (state === 'not_recomputable' ? `无法重算:${reason}` : reason),
remaining: (count: number) => `另有 ${n(count)} 组未重算`,
/** 还有没算的组却不能接着算(之后又发布了更新的版本……):照服务端给的原因写 */
cannotContinue: (reason: string) => `不再接着算:${reason}`,
continue: (size: number) => `接着算(每次 ${n(size)} 组)`,
continuing: '正在发起…',
continued: (count: number) => (count ? `已发起 ${n(count)} 组召回` : '没有发起新的召回'),
@@ -108,6 +110,8 @@ export const RECALL_TEXT = {
reissueHint: '按新版口径、这一组的参数发起一次正式运行(和按口径正式出具同一套检查)',
reissued: '已按新版重新出具',
issuedRun: '查看出具的正式运行',
/** 使用者进不了记录页:只写已经出具,不给点了回不来的链接(同问数据轮次下的出具结果) */
issuedUserNote: '正式运行的记录由搭建者在记录页查看',
ack: '知道了',
acked: '已记为知道了',
doneReissue: '已按新版发起正式出具',
@@ -152,3 +156,15 @@ export const RECALL_TEXT = {
sub: '封存后追加,不在封存范围内',
},
}
/**
* 对账结果上的「说明有用 / 看不懂」在时间线上的说法(run/decode.ts 认 ledger.feedback,记在对账运行上,服务端
* app/ledger/feedback.py)。按钮在对账结果卡片上(问数据轮次、证据面板),这里只管时间线那一行。
*/
export const LEDGER_FEEDBACK_TEXT = {
verdict: { useful: '说明有用', unclear: '看不懂' } as Record<string, string>,
title: (verdict: string | undefined, by: string | null | undefined) =>
`${by ? `${by} ` : ''}反馈:${(verdict && LEDGER_FEEDBACK_TEXT.verdict[verdict]) || '对账说明'}`,
generic: '对账说明的反馈',
sub: '封存后追加,不在封存范围内',
}
+5
View File
@@ -211,7 +211,12 @@ function AnswerLink({ answer }: { answer: RecallAnswer }) {
)
}
/** 重新出具的那次正式运行:搭建者链到记录页;使用者进不了记录页(点了会被送回应用首页),只写已经出具 */
function IssuedLink({ runId }: { runId: string }) {
const builder = useAtLeast('builder')
if (!builder) {
return <span className="text-2xs text-dim" data-recall-issued-note="">{T.issuedUserNote}</span>
}
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">
+14 -1
View File
@@ -12,7 +12,7 @@ import {
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch'
import { RECALL_TEXT } from '../lib/termsRecall'
import { LEDGER_FEEDBACK_TEXT, RECALL_TEXT } from '../lib/termsRecall'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
@@ -2441,6 +2441,19 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
break
}
case 'ledger.feedback': {
// 对账结果上的「说明有用 / 看不懂」(next-round 02 做法 10,服务端 ledger/feedback):记在对账运行上、封存之后追加。
// 同一个人改主意会再记一条(统计只算最后一次),时间线照实一条一行
const F = LEDGER_FEEDBACK_TEXT
const verdict = typeof d.verdict === 'string' ? d.verdict : undefined
out.push({
id: `lf-${seq}`, seq, kind: 'note', status: 'done', code: 'ledger_feedback', level: 'info',
title: verdict && F.verdict[verdict] ? F.title(verdict, typeof d.by === 'string' ? d.by : null) : F.generic,
sub: F.sub,
})
break
}
case 'ledger.basis_changed': {
// 答案依据的东西变了(目录事实被驳回或修改、表结构变了、新码值):封存之后追加,标「依据有变」。按新目录多出错误级
// 问题的写几条(服务端已经开了待办)
+2
View File
@@ -78,6 +78,8 @@ export interface RecallBatches {
/** 还没重算的几组(「另有 M 组未重算」) */
remaining: number
can_continue: boolean
/** 还有没算的组却不能接着算时为什么(之后又发布了更新的版本……);能接着算、都算过了为 null */
continue_reason?: string | null
truncated: boolean
launched?: string[]
}
+21
View File
@@ -1935,6 +1935,27 @@ await section('口径召回:封存后的核对结论与依据有变(ledger.r
check('不改相位:运行照旧是结束了', phase.phase === 'succeeded', phase.phase)
})
await section('对账结果的反馈:说明有用 / 看不懂(ledger.feedback)', async () => {
// next-round 02 做法 10 的 E2:对账结果卡片上的反馈记成对账运行上的封存后事件(服务端 ledger/feedback),时间线一条一行
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', 'reconcile', { node_type: 'metrics' }),
E(3, 'node.finished', 'reconcile', {}), E(4, 'run.finished', null, { status: 'succeeded' })]
const steps = flatten(mod.decodeRun([...base,
E(5, 'ledger.feedback', null, { verdict: 'useful', by: '分析员甲', verified: true, source_run: 'run-a', post_seal: true }),
E(6, 'ledger.feedback', null, { verdict: 'unclear', by: null, post_seal: true }),
E(7, 'ledger.feedback', null, { verdict: 'great' })]))
const lines = steps.filter((s) => s.code === 'ledger_feedback')
check('认得它:不是「未识别的事件」,一条一行', !steps.some((s) => s.title === '未识别的事件') && lines.length === 3,
steps.map((s) => s.title).join(' | '))
check('写谁给了哪一种反馈、封存后追加', lines[0]?.title === '分析员甲 反馈:说明有用' && lines[0]?.sub === '封存后追加,不在封存范围内'
&& lines[0]?.kind === 'note' && lines[0]?.level === 'info', `${lines[0]?.title} / ${lines[0]?.sub}`)
check('没署名的不写名字', lines[1]?.title === '反馈:看不懂', lines[1]?.title)
check('认不出的反馈不猜', lines[2]?.title === '对账说明的反馈', lines[2]?.title)
const phase = [E(4, 'run.finished', null, {}), E(5, 'ledger.feedback', null, { verdict: 'useful' })]
.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。过程里一行说清选用了
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
+76 -1
View File
@@ -174,6 +174,18 @@ const TASKS = {
actions: [{ decision: 'reissue', available: false,
reason: '这份口径没有被你能使用的应用固定,不能由你按口径出具,请搭建者出具' }, { decision: 'ack', available: true }] },
},
// 已经按新版重新出具过的(处理完的):出具的那次正式运行搭建者点得开,使用者只写一句(进不了记录页)
recallIssued: {
...base, id: 'recallIssued', kind: 'recall', title: '口径「客户分层」升到 v3,答案的数变了', node_id: null, app_id: null,
app_name: null, app_version: null, workflow_name: '口径召回:客户分层 v2 → v3', run_id: 'rc_3', status: 'resolved',
resolved_by: 'zhangsan', resolved_at: ago(60_000), can_respond: false,
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
assignments: [assignment('rc4', 'zhangsan', 'responded', 'reissue', { source: 'recall' })],
payload: { kind: 'recall', reason: 'caliber', recall_run: 'rc_3', workflow_id: 'wf_tier', workflow_name: '客户分层',
from_version: 2, to_version: 3, binding: [], metrics: [], answers: [],
outcome: { decision: 'reissue', note: '', issued_run: 'run_issued_9' },
actions: [{ decision: 'reissue', available: false, issued_run: 'run_issued_9' }, { decision: 'ack', available: false }] },
},
// 答案依据的目录有变、按新目录检查出了新的错误级问题:只有「知道了」
recallCatalog: {
...base, id: 'recallCatalog', kind: 'recall', title: '答案依据的数据目录有变:按新目录检查出了新的问题', node_id: null,
@@ -209,6 +221,21 @@ async function open(state) {
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.addInitScript(() => localStorage.setItem('agentlab_actor', '审批人甲'))
if (state.notify) {
// 系统通知换成记录器:开了后台提醒、已授权;记下每一条通知(点它就是调它的 onclick),页面一直在后台
await page.addInitScript(() => {
localStorage.setItem('agentlab.notify', '1')
window.__notes = []
class FakeNotification {
static permission = 'granted'
static async requestPermission() { return 'granted' }
constructor(title, opts) { this.title = title; this.tag = opts?.tag; window.__notes.push(this) }
close() {}
}
window.Notification = FakeNotification
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
})
}
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
const r = route.request()
const p = new URL(r.url()).pathname
@@ -247,6 +274,13 @@ async function open(state) {
}
// 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端
if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } })
// state.live:派给我的待处理待办由这一段自己换(外壳的待办通知看数和最新一条的时间变没变)
if (p === '/api/tasks/count' && state.live) {
return route.fulfill({ json: { mine: state.live.length, apps: 0, other: state.live.length,
latest: state.live.map((t) => t.created_at).sort().at(-1) ?? null } })
}
if (p === '/api/tasks' && state.live) return route.fulfill({ json: { items: state.live } })
if (p === '/api/approvals' && state.live) return route.fulfill({ json: [] })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: Object.values(TASKS) } })
const m = p.match(/^\/api\/tasks\/([^/]+)$/)
@@ -526,6 +560,19 @@ await section('口径召回:使用者看到的遮罩值、不能重新出具
&& (await page.locator('[data-recall-reissue-reason]').innerText()).includes('请搭建者出具'))
check('使用者不给记录页的链接', await box.locator('[data-recall-answer-link="run"]').count() === 0)
const p3 = await open({ writes: [], me: state.me })
await p3.goto(`${WEB}/apps/inbox/recallIssued`)
const done = p3.locator('[data-recall-outcome="reissue"]')
await done.waitFor({ timeout: 15000 })
check('使用者重新出具过:只写已出具、正式运行由搭建者在记录页查看,不给点了回不来的记录页链接',
(await done.innerText()).includes('正式运行的记录由搭建者在记录页查看')
&& await p3.locator('[data-recall-issued]').count() === 0, (await done.innerText()).replace(/\s+/g, ' '))
const p4 = await open({ writes: [] })
await p4.goto(`${WEB}/apps/inbox/recallIssued`)
await p4.locator('[data-recall-outcome="reissue"]').waitFor({ timeout: 15000 })
check('……搭建者照样链到那次正式运行', (await p4.locator('[data-recall-outcome] [data-recall-issued]').getAttribute('href'))
=== '/runs/run_issued_9' && await p4.locator('[data-recall-issued-note]').count() === 0)
const p2 = await open({ writes: [] })
await p2.goto(`${WEB}/apps/inbox/recallCatalog`)
const basis = p2.locator('[data-recall-basis="catalog"]')
@@ -534,7 +581,35 @@ await section('口径召回:使用者看到的遮罩值、不能重新出具
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] ?? '')
check('没有未捕获的运行时错误', [page, p2, p3, p4].every((x) => x.errors.length === 0),
[page, p2, p3, p4].flatMap((x) => x.errors)[0] ?? '')
})
await section('口径召回:后台的系统通知点开去待办页(不去记录页)', async () => {
// recall 待办不属于应用(app_id 为空),挂在召回运行上:记录页里没有这张卡,使用者也进不了记录页。点通知一律去待办页
const users = {
builder: null,
user: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true },
}
for (const [who, me] of Object.entries(users)) {
const state = { writes: [], notify: true, live: [], me: me ?? undefined }
const page = await open(state)
await page.goto(`${WEB}/apps`)
await page.locator('nav[aria-label="主导航"]').waitFor({ timeout: 15000 })
await page.waitForTimeout(1200)
state.live = [{ ...TASKS.recall, created_at: new Date().toISOString() }]
const noted = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:recall'), null, { timeout: 15000 })
.then(() => true, () => false)
check(`${who === 'user' ? '使用者' : '搭建者'}:来了 recall 待办,发系统通知`, noted,
(await page.evaluate(() => window.__notes.map((n) => n.tag))).join(','))
if (!noted) continue
await page.evaluate(() => window.__notes.find((n) => n.tag === 'task:recall').onclick())
const there = await page.waitForURL((u) => u.pathname === '/apps/inbox/recall', { timeout: 8000 }).then(() => true, () => false)
check('……点通知去这条待办的待办页', there, new URL(page.url()).pathname)
check('……待办页画出召回卡', await page.locator('[data-task-recall="caliber"]').waitFor({ timeout: 8000 })
.then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
}
})
await section('待办箱:列表和徽标', async () => {
+13
View File
@@ -2170,6 +2170,11 @@ await section('口径召回:版本历史看召回批次、另有 M 组未重
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])
// v2:口径升到 v2 时自动算了一批、还剩一组,后来又发布了 v4——v2 那一版不再给「接着算」,服务端写明原因
if (v === 2) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...BATCHES, version: 2,
groups: [{ ...BATCHES.groups[4], id: '1-e', from_version: 1, to_version: 2 }], remaining: 1, can_continue: false,
continue_reason: '已有更新的发布版 v4,召回在 v4 那一版接着看',
counts: { changed: 0, unchanged: 0, failed: 0, running: 0, pending: 1, not_recomputable: 0 } }) })
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 } }) })
@@ -2217,6 +2222,14 @@ await section('口径召回:版本历史看召回批次、另有 M 组未重
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)
await sheet.locator('ol button', { hasText: 'v2' }).first().click()
const old = sheet.locator('[data-version-lifecycle="2"] [data-version-recall]')
check('之后又发布了更新版本的旧版:照样列出另有几组未重算', await waitFor(page, async () => await old.count() === 1)
&& (await old.locator('[data-version-recall-remaining="1"]').innerText().catch(() => '')).includes('另有 1 组未重算'))
check('……不给「接着算」,写服务端给的原因', await old.locator('[data-version-recall-continue]').count() === 0
&& (await old.locator('[data-version-recall-continue-reason]').innerText().catch(() => ''))
=== '不再接着算:已有更新的发布版 v4,召回在 v4 那一版接着看')
check('……没有多发接着算的请求', continued.length === 1, continued.join(','))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})