diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e4f8696f..81b7835f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 {
diff --git a/frontend/src/canvas/RecallPanel.tsx b/frontend/src/canvas/RecallPanel.tsx
index 5898fc27..a09532fd 100644
--- a/frontend/src/canvas/RecallPanel.tsx
+++ b/frontend/src/canvas/RecallPanel.tsx
@@ -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 ? : } {busy ? B.continuing : B.continue(data.batch_size)}
)}
+ {!data.can_continue && data.continue_reason && (
+
{B.cannotContinue(data.continue_reason)}
+ )}
)}
{data.truncated && {B.truncated}
}
diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts
index 82b40be9..0cc978be 100644
--- a/frontend/src/lib/termsRecall.ts
+++ b/frontend/src/lib/termsRecall.ts
@@ -70,6 +70,8 @@ export const RECALL_TEXT = {
} as Record,
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,
+ title: (verdict: string | undefined, by: string | null | undefined) =>
+ `${by ? `${by} ` : ''}反馈:${(verdict && LEDGER_FEEDBACK_TEXT.verdict[verdict]) || '对账说明'}`,
+ generic: '对账说明的反馈',
+ sub: '封存后追加,不在封存范围内',
+}
diff --git a/frontend/src/run/RecallTask.tsx b/frontend/src/run/RecallTask.tsx
index a0b2b194..f73b8c25 100644
--- a/frontend/src/run/RecallTask.tsx
+++ b/frontend/src/run/RecallTask.tsx
@@ -211,7 +211,12 @@ function AnswerLink({ answer }: { answer: RecallAnswer }) {
)
}
+/** 重新出具的那次正式运行:搭建者链到记录页;使用者进不了记录页(点了会被送回应用首页),只写已经出具 */
function IssuedLink({ runId }: { runId: string }) {
+ const builder = useAtLeast('builder')
+ if (!builder) {
+ return {T.issuedUserNote}
+ }
return (
diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts
index c763383b..42f73195 100644
--- a/frontend/src/run/decode.ts
+++ b/frontend/src/run/decode.ts
@@ -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': {
// 答案依据的东西变了(目录事实被驳回或修改、表结构变了、新码值):封存之后追加,标「依据有变」。按新目录多出错误级
// 问题的写几条(服务端已经开了待办)
diff --git a/frontend/src/typesRecall.ts b/frontend/src/typesRecall.ts
index 84428b4e..1f478a4a 100644
--- a/frontend/src/typesRecall.ts
+++ b/frontend/src/typesRecall.ts
@@ -78,6 +78,8 @@ export interface RecallBatches {
/** 还没重算的几组(「另有 M 组未重算」) */
remaining: number
can_continue: boolean
+ /** 还有没算的组却不能接着算时为什么(之后又发布了更新的版本……);能接着算、都算过了为 null */
+ continue_reason?: string | null
truncated: boolean
launched?: string[]
}
diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs
index 48483cf3..21134ddf 100644
--- a/scripts/check-decode.mjs
+++ b/scripts/check-decode.mjs
@@ -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。过程里一行说清选用了
// 哪一版、生成了几个节点;拒收的照实写原因(同样交回了模型去改)
diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs
index 33031729..edf06083 100644
--- a/scripts/check-inbox.mjs
+++ b/scripts/check-inbox.mjs
@@ -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 () => {
diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs
index be84f74a..17dc853e 100644
--- a/scripts/check-publish.mjs
+++ b/scripts/check-publish.mjs
@@ -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(' | '))
})