From 464d1c0741a892873f21b9f90bf54913624b4886 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 22:16:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AC=E5=9B=9E=E5=A4=8D=E6=A0=B8=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E5=BE=85=E5=8A=9E=E7=9A=84=E7=B3=BB=E7=BB=9F=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8E=BB=E5=BE=85=E5=8A=9E=E9=A1=B5=E3=80=81=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=80=85=E4=B8=8D=E7=BB=99=E9=87=8D=E6=96=B0=E5=87=BA?= =?UTF-8?q?=E5=85=B7=E7=9A=84=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E3=80=81=E6=97=A7=E7=89=88=E5=86=99=E4=B8=8D=E5=86=8D=E6=8E=A5?= =?UTF-8?q?=E7=9D=80=E7=AE=97=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=81=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E8=AE=A4=E5=AF=B9=E8=B4=A6=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 通过。 --- frontend/src/App.tsx | 5 +- frontend/src/canvas/RecallPanel.tsx | 6 ++- frontend/src/lib/termsRecall.ts | 16 ++++++ frontend/src/run/RecallTask.tsx | 5 ++ frontend/src/run/decode.ts | 15 +++++- frontend/src/typesRecall.ts | 2 + scripts/check-decode.mjs | 21 ++++++++ scripts/check-inbox.mjs | 77 ++++++++++++++++++++++++++++- scripts/check-publish.mjs | 13 +++++ 9 files changed, 156 insertions(+), 4 deletions(-) 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(' | ')) })