# TASK · 一轮对话把整份 Case 档案从数据库取 3.4 次 - 日期:2026-09-15 - 基线 commit:`origin/staging` @ `6b3248bf` - 执行分支:`codex/rectification-request-dossier-cache-20260915` - **串行在 `TASK-rectification-failure-attribution-20260915` 之后**:两单都要改 `frontend/src/app/api/rectification/agent/route.ts`,本单必须以那一单合入后的 staging 为基线,不得并行 - 主要落点:`frontend/src/lib/rectification-agentic/v9/tool-service.ts`(或新建同目录的缓存模块)、`route.ts` 一处接线 - 规模:一个请求内缓存层 + 一处接线。**零调用点改动、零行为变化。** --- ## 1. 事故实证 `loadV9CaseDossier` / `loadV9CaseCompute`(`tool-service.ts`)每次调用都是一次 Postgres RPC,没有任何缓存。全仓约 **40 个调用点**:`rectification-v9-tools.ts` 13 + 3、`answer-choice.ts` 6 + 4、`block-scan-answer.ts` 3、`agent-run.ts` 3 + 1、`score-persist.ts` 2 + 1、`route.ts` 2 + 2,以及 `regenerate-turn.ts`、`refresh-discriminator-probes.ts`、三条 GET 路由各一。 用现有测试套件的 RPC 计数器实测(在 `fakeAccounting` 上按场景聚合): | 路径 | 场景数 | `get_..._case_dossier` | 每场景均值 | `get_..._case_compute` | 均值 | | --- | ---: | ---: | ---: | ---: | ---: | | Agent 轮(`rectification-v9-stream.test.ts`) | 39 | 134 | **3.44** | 25 | 0.64 | | 点选题(`rectification-answer-choice.test.ts`) | 15 | 31 | **2.07** | 17 | 1.13 | 同一轮里还有 `insert_agentic_rectification_run_phase` 7.05 次、`set_agentic_rectification_conversation_focus` 2.46 次——那些是真写入,不在本单范围。 这份档案不小。`get_agentic_rectification_case_dossier`(`supabase/migrations/20260907020000_rectification_declared_uncertainty.sql` 里的最新定义)单次要做: - 取 `agentic_rectification_cases` 一行 - 取**最近 50 轮** turns,`cross join lateral` 展开成 user/assistant 两条消息再 `jsonb_agg` - 取该 Case 的**全部** evidence 行 `jsonb_agg` - 两次 `count(*)`(evidence、turns) - 取 conversation summary(不存在时还要先 `refresh_..._conversation_summary`) - 取最新 result,并调 `compose_agentic_rectification_decision_receipt` 合成收据(内含 candidates 数组) 在 2 vCPU 的生产主机上,这段 jsonb 构建与 Next.js、Python 引擎抢的是同一批核。 界面侧还会放大:`questionGap === "preparing"` 时 `useVisibilityAwarePoll` 每 `RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2000` 毫秒打一次 `GET /api/rectification/cases/[caseId]`,每次又是一份完整档案(有重试次数上限,不是无限轮询)。 ## 2. 根因 档案在**一次请求内**是不变的(除非本请求自己写了东西),但每个需要它的地方都独立重查。没有请求作用域的概念,于是「读一次用多处」退化成「用几处读几次」。 ## 3. 决策记录 产品 2026-09-15 授权本单,口径: 1. **缓存必须是「写即失效」的,不是「请求内固定」。** 档案在一次请求里**会**变——写证据、设焦点、落候选、切状态都会改它。任何「整个请求只读一次」的实现都会让下游拿到陈旧档案,那比多查几次严重得多。 2. **不得改动那 40 个调用点。** 逐点传缓存参数既大又容易漏一处,漏的那一处就是陈旧数据。缓存必须挂在**客户端对象**上,对调用点完全透明。 3. **不引入跨请求缓存。** 档案里有出生资料派生数据与会话内容,跨请求持有违反 AGENTS §8;而且并发校正之间必然串味。 4. **本单不动轮询间隔、不动 GET 路由的投影形状。** 「档案能不能瘦一点」「GET 能不能只返回下一问」是另一个话题,需要先确认哪些字段真的有人读,本单不碰。 ## 4. 硬红线 1. 写操作后必须能读到写后的档案。这是本单唯一的成败判据。 2. 缓存生命周期严格等于一次 HTTP 请求(含流式响应体执行期间),不得挂在模块作用域、`globalThis` 或任何跨请求容器上。 3. 缓存键必须包含 `userId` 与 `caseId`。同一请求内不会出现第二个用户,但键里带上它是防止将来被误用的最低成本。 4. `route.ts` 只允许新增**一处**接线(包装 `createAdminSupabaseClient()` 的返回值)。不得在别处零散加缓存。 5. 不得改 `get_agentic_rectification_case_dossier` / `_compute` 的 SQL 定义(本轮不动数据库结构,AGENTS §7.6)。 6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 ## 5. 任务分解 ### 5.1 请求作用域的缓存包装器 新增 `withRectificationRequestCache(accounting)`,返回一个与 `RectificationRpcClient` 结构相同的包装对象: - `rpc("get_agentic_rectification_case_dossier" | "get_agentic_rectification_case_compute", args)`:按 `(fn, p_user_id, p_case_id)` 命中则返回缓存的 in-flight promise,未命中则实际调用并缓存该 promise(缓存 promise 而不是结果,可以顺带把并发的重复读合并成一次)。 - `rpc(其它任何函数名, args)`:**先整体清空缓存**,再转发。不需要区分读写——除这两个只读投影外的 RPC 一律按可能写处理,这是保守且正确的一侧。 - 任何其它属性(例如某些调用点用 `accounting as never` 走的 `.from(table).update(...)`)必须透传,并且在被访问时同样清空缓存。用 `Proxy` 或显式转发都行,选一个并在注释里写明为什么。 注意:`agent-run.ts`、`case-service.ts`、`session.ts`、`regenerate-turn.ts` 里有若干 `accounting.rpc(...)` 的**直接**调用(不走 `tool-service.rpc` 这个私有 helper)。正因为如此,缓存必须挂在客户端对象上——挂在 `tool-service` 的 helper 里会漏掉这些。 - 验收:单元测试覆盖——连续两次读同一 `(userId, caseId)` 只产生一次底层 RPC;中间插入任意一次其它 RPC 后,第三次读必须重新打底层;两个不同 `caseId` 互不命中;并发两次读只打一次底层。 - 验收:`.from(...)` 透传后缓存被清空。 ### 5.2 在路由入口接上 `route.ts` 里 `accounting = createAdminSupabaseClient()` 之后立刻包一层,后续一切原样。`regenerate` 路由与三条 `cases/[caseId]/*` GET/POST 路由同样处理(每条也只允许一处接线)。 - 验收:源码合同断言这几条路由里 `createAdminSupabaseClient()` 的返回值都经过包装器,没有裸用。 ### 5.3 用现有套件量出前后差 把 §1 那张表的测法固化下来:在 `rectification-v9-test-support.ts` 的 `fakeAccounting` 上加一个可选的计数导出(**默认关闭**,靠显式开关启用,不得影响既有断言),新增一条测试断言「同一场景下 `get_..._case_dossier` 的调用次数不超过 N」。N 取改动后的实测值,不是拍的。 - 验收:进度记录里贴出改前/改后两组计数(Agent 轮与点选题各一组)。 - 验收:既有 34 + 47 条断言一条不改仍全绿。 ### 5.4 Bug 历史 预占 **BUG-726**,状态可写 `resolved`(有针对性回归)。这不是回归,是自 V9 运行时引入以来的分层遗漏。防复发写成:**Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。** ## 6. 让步顺序 1. 5.1 + 5.2 是一体,必须一起做。只做 5.1 不接线等于没做。 2. 5.3 的计数断言可以只覆盖 Agent 轮一条路径,点选题那条砍掉时在进度记录里写明。 3. 5.4 不得砍。 4. 如果 5.1 的「其它属性透传」在类型上过不去(`AccountingClient` 只声明了 `rpc`,多处调用点用 `as never` 绕过),**宁可缩小范围**:只包装 `rpc`,并在记录里写明 `.from(...)` 路径未覆盖、可能读到陈旧档案的具体位置。不得为了让类型通过去改业务代码(这是产品明确的偏好)。 ## 7. 开工前置命令 ```bash git fetch origin --prune # 确认 failure-attribution 单已合入 staging 再开工 git log --oneline origin/staging | head -5 git worktree add -b codex/rectification-request-dossier-cache-20260915 \ .worktrees/rectification-request-dossier-cache-20260915 origin/staging cd .worktrees/rectification-request-dossier-cache-20260915/frontend git status -sb | head -1 npm ci ``` 验收命令: ```bash ./node_modules/.bin/tsc --noEmit npm run lint npx tsx --test tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单 npm run build ``` ## 8. BUG 编号起点 基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-726**。因为串行在 failure-attribution(722–724)之后,开工时那几号大概率已落库;以当时实际最大号 +1 为准。 ## 9. 不在本单范围 - 档案投影瘦身(哪些字段真的有人读、50 轮 turns 是不是必要) - `useVisibilityAwarePoll` 的 2 秒间隔与重试上限 - `insert_agentic_rectification_run_phase`(每轮 7 次)的批量化 - 引擎耗时、前端渲染、故障归因(各有专单)