Files
Jyotisha/docs/tasks/TASK-consult-route-split-20261013.md
T
Jesse_ChenandClaude Opus 5.5 0be4cb299c docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward)
Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources,
home-sidebar-rerender, api-disk-cache-retention, report-reference-perf,
consult-prep-parallel, frontend-dead-code.
Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab,
consult-route-split, pl9-render-split, python-hygiene, dasha-unify,
api-server-service-extract, data-layer-types, contracts-helpers-dedupe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:34:35 +08:00

171 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 普通对话路由拆分(第一阶段:开流前加载 + 计费结算)(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」· `/api/consult` 路由过大
- 执行方:**Grok(Windows + Docker)**,分支 `codex/consult-route-split-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**无 BUG 编号**(重构,无缺陷;记录写 PROGRESS)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
- 开工前置:**`consult-prep-parallel` 与 `answer-effort-ab` 都已合入 `origin/staging`**。前者在 `route.ts` 开流前段落新建了 `frontend/src/lib/consult/`(`prep-flags.ts`、`turn-prep.ts`),本单在此基础上继续;后者改 `consultation-tools.ts` 与 prepareStep。三单串行,不得并行开工。
## 1. 基线
- 基线 commit:起草时 `origin/staging` `77057a819`;开工时以两单前置合入后的 staging 为准,**行号会变,一律按符号定位**。
- `frontend/src/app/api/consult/route.ts`:1,840 行;`export async function POST` 从第 296 行到文件末尾(约 1,545 行),内部闭包函数 17 个,鉴权、扣费、校验、编排、流输出混在一个函数作用域里。
- 起草时 `POST` 内的段落(`77057a819` 行号,仅供定位):
| 段 | 行 | 内容 | 本单 |
| --- | --- | --- | --- |
| 开流前加载 | 296–443 | 建客户端、`getUser`、删号冻结、请求体、读会话、会话模型、历史窗口、提示词提取拦截、输入审核 | **搬**(与前置单的 `turn-prep.ts` 合并) |
| 预留回调 | 488–503 | `resolveFeaturePricing` + `reserve_consultation_usage` | **搬** |
| 取消 / 追加问题 | 602–668 | `cancel()`、`append_consultation_question` 与 `session_full` | **搬** |
| 寒暄免费结算 | 709–737 | `complete_consultation_free`(smalltalk) | **搬**(结算函数;`streamSmalltalkResponse` 调用留在 route) |
| 上下文摘要 | 748–782 | `checkpointConsultationContext` | **搬** |
| 用量与结算 | 783–962 | `usagePayload`、`moderateAnswer`、`completeModeratedResponse`、`completeResponse`、`completeDirectRectificationTurn`、`settleResult` / `settle` | **搬** |
| 标题副作用 | 970–996 | `titleSideEvent` | 留 |
| 运行编排 | 998–1630 | `runAgenticConsultation`(流选项、重试、续写、窗口预计算) | 留(第二阶段) |
| legacy 与流输出 | 1630–1839 | 旧运行时、`streamTextResponse`、`streamFirstResponse` | 留(第三阶段) |
## 2. 事故实证
- 一个函数作用域内约 1,545 行;计费 RPC(`reserve_` / `cancel_` / `append_` / `complete_consultation_*`)散在 6 处闭包里,靠共享的 `let` 变量(`classificationUsage`、`latencyState`、`outputModerated`、`outputBlocked`、`settlement`)通信。
- 33 个前端测试文件读 `route.ts` 源码做正则/`indexOf` 断言(`grep -rl "api/consult/route" frontend/tests`),按读源码变量的出现次数估约 400 处;**没有任何测试 import `route.ts` 的 `POST` 跑行为**——计费顺序、冻结、审核先于预留这些红线今天全靠源码文本守。
- 后果:每次改动都要同步改源码断言(AGENTS.md §7.8 记录的 BUG-933 / 934 / 939 / 992 / 1014 都是这类),而真正的行为没有测试。
## 3. 根因
不是回归。BUG-1074(先建流)、BUG-976(寒暄免费结算)、BUG-1306(明说要校正免费结算)、09-30 内容审核等一轮轮加在同一个函数里,从未拆分。
关联红线(全部原样保留,本单只搬不改):
| 来源 | 红线 |
| --- | --- |
| BUG-1074 | 开流前只答鉴权 / 请求体 / 会话 / 会话模型;其余在 `runTurn` 内;`streamFirstResponse` 首帧 |
| BUG-976 | reserve 在分类前;寒暄走 `complete_consultation_free` |
| BUG-1306 / 1317 | 直达校正轮免费结算,不走 `complete_consultation_response` |
| BUG-1051 / 1142 | 截断不扣点;答题钟 |
| 09-30 审核 | 输入审核先于预留;输出审核命中走 `complete_consultation_moderated`;三条 agentic 流都带 `moderateOutput: moderateAnswer` |
| 账户注销 | 冻结检查 `if (frozen) return frozen;` 在任何写操作前 |
| `consult-prep-parallel` | §5.2 的状态码优先级;审核在 `beforeReserve` 内等结论 |
## 4. 决策记录
- 产品授权:2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;重构只做第一阶段。
- **纯搬移,行为不变**:不改文案、状态码、RPC 参数、调用顺序、日志字段;不顺手「优化」。
- 拆分方向(四层):鉴权与会话加载 / 计费预留与结算 / 运行编排 / 流输出。**本单只做前两层**;后两层见 §10。
- 测试迁移原则:源码断言**跟着代码搬到新文件**(读新文件源码),并为搬出去的计费函数**补行为测试**(新模块可注入假 `accounting` 客户端,今天做不到)。不得把多个文件拼成一个字符串来让跨文件的 `indexOf` 顺序断言继续「通过」——那是伪造顺序。
- 不推翻任何既有红线。
## 5. 硬红线
1. 行为不变:同一请求在改前改后的 HTTP 状态、NDJSON 事件序列、RPC 调用序列(名称 + 参数)、落库内容完全相同(T4 差分)。
2. 测试总数不得低于开工时 `origin/staging` 实测;每条被改的断言写「原值 / 新值 / 原因」三栏;不得删除断言而不替换;替换后的断言不得更弱(同一事实,换文件或换成行为断言)。
3. 新文件只放在 `frontend/src/lib/consult/`;`route.ts` 净减少;新模块 `import "server-only"`(与现有服务端模块一致)。
4. 改动规模:diff 合计 < 1,500 行(`git diff --stat origin/staging...HEAD` 的增删之和;纯搬移的增删都算),超了就按 §7 砍。
5. 不改 `consultation-route-service.ts::prepareConsultationRoute` 的签名与行为;不改 `stream-first-response.ts`、`stream-agent-response.ts`。
6. 不动表结构与迁移;不改 `scripts/jyotish_api_server.py`;不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖;不顺手修 warning。
7. 不改 `frontend/tests/account-deletion.test.tsx` 的「付费路由清单」语义:`route.ts` 若不再直接调用 `refuseWhenAccountDeletionPending`,测试改为断言 `route.ts` 调用 `loadConsultTurnHead`(或前置单定的名字)**且**该模块含 `refuseWhenAccountDeletionPending(` 与 `if (frozen) return frozen;`(或等价的冻结即返回),三栏写明。
## 6. 任务分解
### T1 · 开流前加载归入 `lib/consult/turn-prep.ts`(Grok 自跑 / Claude 验收)
- 把前置单留在 `route.ts` 的开流前剩余部分(旧入口 409、历史窗口 `consultationHistoryWindow`、`blocksPromptExtraction`、审核启动与 `moderationContext`)搬进 `turn-prep.ts`,`POST` 开头变成一次调用:失败直接 `return response`,成功拿到 `{ user, parsed, chatSession, sessionModel, historyWindow, storedHistory, moderation }`。
- 搬移时保留每个 `NextResponse.json(...)` 字面量原文。
- 验收:`consult-turn-prep-20261013.test.ts`(前置单已建)补「旧入口 409」「提示词提取 400」两种返回;`consult-first-frame-and-pacing-20260928.test.tsx` 第 256–289 行「开流前清单」改为:标记在 `turn-prep.ts` 源码中存在,且 `route.ts` 中对 `loadConsultTurnHead` 的调用位于 `const runTurn = async` 之前(三栏)。
### T2 · 计费与结算归入 `lib/consult/settlement.ts`(Grok 自跑 / Claude 验收)
- 新建工厂 `createConsultSettlement(ctx)`,`ctx` 显式传入今天闭包捕获的值:`accounting`、`supabase`、`userId`、`requestId`、`sessionId`、`visibleQuestion`、`moderationContext`、`sessionContextWindow`、`usageStartedAt`,以及对可变状态的访问器(`getSelectedModel()`、`getClassification()`、`getLatencyState()`)。返回:`reserveCredit(model)`(原 488–503 的回调体)、`cancel`、`appendQuestion`、`completeSmalltalk(reply)`、`checkpointContext`、`usagePayload`、`moderateAnswer`、`completeModeratedResponse`、`completeResponse`、`completeDirectRectificationTurn`、`settleResult`、`settle`。`retryDetachedSettlement`、`first`、`consultationReservationSchema` / `consultationCompletionSchema` / `consultationQuestionAppendSchema` 一起搬。
- `outputModerated` / `outputBlocked` / `settlement` 变成工厂内部状态(每个请求一个实例),对外行为与今天一致(`completeResponse` 里「未审核则先审、命中则走 moderated」顺序不变)。
- `route.ts` 中所有调用点改成 `settlement.xxx(...)`,参数与顺序不变。`cancel` 返回值、`AgentSettlementResult` 语义不变。
- 新增行为测试 `frontend/tests/consult-settlement-20261013.test.ts`(假 `accounting.rpc` 记录调用序列):
- `completeResponse`:正常 → 调 `complete_consultation_response` 一次、成功后触发上下文摘要;RPC 失败 → 抛错且已调 `cancel_consultation_credit`;输出审核 `block` → 只调 `complete_consultation_moderated`,不调 `complete_consultation_response`;
- `completeDirectRectificationTurn` → `complete_consultation_free`,`responseKind: "smalltalk"`;
- `appendQuestion`:`session_full` → 返回给 route 的 409 不变、已调 cancel;
- `cancel` 对 `request_completed` 视为成功;`retryDetachedSettlement` 最多 3 次、退避 150/300 ms;
- `usagePayload` 合并分类用量、费用按百万 token 单价计算(与今天逐字段相同);
- `settleResult` 重复调用只执行一次。
- 受影响的源码断言(起草时 grep 得到,开工时按 AGENTS.md §7.8 重新 grep 为准),逐条迁到 `settlement.ts` 源码或改为上面的行为断言,三栏记录:
| 文件 | 断言(约行号) | 迁移方式 |
| --- | --- | --- |
| `consultation-stream-recovery.test.ts` | 16(`rpc("reserve_consultation_usage"…p_session_id`)、96(`await settleResult(action)`)、103–108(`completeResponse` 内 try 包 `complete_consultation_response`、失败后 `await cancel()`)、123、146–147(`cancel` / `completeResponse` 经 `retryDetachedSettlement`) | 文本改读 `settlement.ts`;103–108 的顺序断言换成行为测试「RPC 失败 → cancel 被调」 |
| `consultation-stream-recovery.test.ts` | 22、44、81(`onComplete: … settle(() => completeResponse(`、`onError` 不结算) | 留在 `route.ts`,只改成 `settlement.settle(() => settlement.completeResponse(` 的写法 |
| `content-moderation.test.ts` | 243(输入审核先于预留)、244(三处 `moderateOutput: moderateAnswer`)、245(`if (!outputModerated) await moderateAnswer…`)、246 | 243 沿用前置单的新断言;244 留 route(写法改为 `settlement.moderateAnswer`);245–246 读 `settlement.ts` + 行为测试 |
| `application-billing-contract.test.ts` | 160(`resolveSessionLanguageModel(chatSession.model_id, …)`)、175–178、216(`usagePayload` 与 `p_actual_usage`) | 160 读 `turn-prep.ts`;其余读 `settlement.ts` |
| `chat-session-authority.test.ts` | 66(`session_not_consultation`)、83(`append_consultation_question`) | 66 读 `turn-prep.ts`;83 读 `settlement.ts` |
| `rectification-session-composer-guard.test.ts` | 56 | 读 `turn-prep.ts` |
| `rectification-explicit-entry-20261012.test.ts` | 663–669(`completeDirectRectificationTurn` 只走 free) | 读 `settlement.ts` + 行为测试 |
| `consultation-smalltalk.test.ts` / `consult-first-frame-and-pacing-20260928.test.tsx` | 229 / 288(寒暄分支 `complete_consultation_free`、不调 `streamAgentResponse`) | route 中寒暄分支调用 `settlement.completeSmalltalk`;`complete_consultation_free` 文本读 `settlement.ts` |
| `consult-first-frame-and-pacing-20260928.test.tsx` | 274–275(`reserve_` / `append_` 在流内) | 改为 `route.ts` 中 `settlement.reserveCredit` / `settlement.appendQuestion` 的调用位于 `runTurn` 内 |
| `session-title-persist-contract.test.ts` | 55–56(`async function usagePayload(` 作为顺序锚点) | 锚点改为 route 中寒暄早返之后的标题逻辑,三栏写明 |
| `consultation-context-cache-contract.test.ts` | 14(历史窗口)、37(`void checkpointConsultationContext()`) | 14 读 `turn-prep.ts`;37 读 `settlement.ts` |
| `consult-evidence-card-telemetry-20260927.test.ts` | 96(`settleRun(() => completeResponse(`) | 留 route,改写法 |
| `account-deletion.test.tsx` | 111–122 | 见 §5.7 |
| `model-catalog.test.ts` | 49(`await resolveSessionLanguageModel(chatSession.model_id…`) | 读 `turn-prep.ts` |
其余 20 个读 `route.ts` 的测试文件(编排、流输出相关)预计不受影响;开工 grep 后若命中,同样三栏。
### T3 · `route.ts` 只编排(Grok 自跑 / Claude 验收)
- 完成 T1、T2 后 `POST` 的结构:`loadConsultTurnHead` → 定义 `runTurn`(prepare + `settlement.reserveCredit` → 拒绝分支 → `settlement.appendQuestion` → 分类 → 寒暄/编排)→ `streamFirstResponse`。编排与流输出原样留着。
- 验收:`route.ts` 行数净减(目标减少 ≥ 450 行);`grep -c "accounting.rpc(" route.ts` 为 0。
### T4 · 行为不变的差分验证(Grok 自跑 / Claude 验收)
- 写 `frontend/scripts/research/consult-route-split-diff.mts`:在同一进程里分别加载**基线版**(`git show origin/staging:frontend/src/app/api/consult/route.ts` 等文件写到临时目录)与**新版** `POST`,用 `mock.module` 注入同一套假依赖(假 `createServerSupabaseClient` / `createAdminSupabaseClient` 记录每次 `.from()` 与 `.rpc()` 的名称和参数、假模型返回固定流、假审核、假引擎返回 `frontend/tests/fixtures/consult-evidence-card-golden.json` 的真实引擎响应),跑以下场景并逐字节比较「HTTP 状态 + 响应体(NDJSON 全文)+ RPC/查询调用序列」:
401;冻结 423;请求体 400;会话不存在 404;校正会话 409;模型变更 409;模型不可用 503;提示词提取 400;输入审核拦截;点数不足 402;`session_full` 409;寒暄;普通咨询完成;输出审核命中;生成中失败(取消);直达校正。
- 若进程内同时加载两版因模块别名(`@/`)冲突做不到,退一步:先在基线检出上跑一遍把结果写成 JSON 快照(`fixtureProvenance` 写命令与基线 SHA),再在新分支上跑同一脚本比对。
- 验收:16 个场景全部一致,脚本输出贴进 PROGRESS(只贴「一致 / 不一致」与场景名,不贴响应全文)。
### T5 · 记录(Grok)
- `docs/tasks/PROGRESS-consult-route-split-20261013.md`:搬移清单(原位置符号 → 新位置符号)、三栏改断言总表、diff 行数、T4 结果、第二阶段建议。
- `CHANGELOG.md`:不写(用户不可感知)。`frontend/DESIGN.md`:不改。`docs/BUG_HISTORY.md`:不写(无缺陷)。
## 7. 让步顺序
时间或 diff 预算不够时:先只做 T2 的「预留 / 取消 / 追加 / 寒暄结算」与 `usagePayload`,`completeResponse` / `completeDirectRectificationTurn` / `moderateAnswer` 留到第二阶段 → 再砍 T1(开流前段落已由前置单部分抽出)。T4 差分与 T5 不可砍。
## 8. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑 | `cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint`;`./node_modules/.bin/tsx --test tests/consult-settlement-20261013.test.ts tests/consult-turn-prep-20261013.test.ts` + §6 T2 表中每个文件(`# fail`、`# cancelled` 都为 0,退出码 0);`./node_modules/.bin/tsx scripts/research/consult-route-split-diff.mts`。前端全量、整站构建**不跑** |
| Claude 验收 | 上述全部 + 前端全量 `npm test` 与基线逐名比对(测试总数不降)+ `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 复跑差分脚本 + 抽查三栏表每一条新断言不弱于原断言 |
| 不需要 | `test:db`(不改 SQL、不改 RPC 参数)、Python 快速门 |
## 9. 开工前置命令
```bash
git fetch origin --prune
git log origin/staging --oneline | grep -i "consult-prep-parallel\|BUG-1337" | head -3 # 前置一
git log origin/staging --oneline | grep -i "answer-effort" | head -3 # 前置二
# 任一为空则停,回报「前置未合入」
git worktree add -b codex/consult-route-split-20261013 .worktrees/consult-route-split-20261013 origin/staging
cd .worktrees/consult-route-split-20261013
ls frontend/src/lib/consult/ # 应有 prep-flags.ts、turn-prep.ts
wc -l frontend/src/app/api/consult/route.ts # 记下开工基线行数
grep -rl "api/consult/route" frontend/tests | wc -l
grep -rn "reserve_consultation_usage\|cancel_consultation_credit\|append_consultation_question\|complete_consultation_\|usagePayload\|retryDetachedSettlement\|settleResult\|checkpointConsultationContext\|moderateAnswer\|completeResponse\|completeModeratedResponse\|completeDirectRectificationTurn\|session_not_consultation\|blocksPromptExtraction\|consultationHistoryWindow\|refuseWhenAccountDeletionPending\|resolveSessionLanguageModel" $(grep -rl "api/consult/route" frontend/tests)
```
## 10. BUG 编号起点
无 BUG 编号(重构,无缺陷)。若搬移中发现真实缺陷,不在本单修,写进 PROGRESS「发现」并回报,由 Claude 另立单。
## 11. 交付物清单
- 代码:`frontend/src/lib/consult/settlement.ts`(新)、`frontend/src/lib/consult/turn-prep.ts`(扩充)、`route.ts`(净减)
- 测试:`frontend/tests/consult-settlement-20261013.test.ts`(新)、§6 T2 表中各文件的三栏迁移
- 脚本:`frontend/scripts/research/consult-route-split-diff.mts`(新)
- 文档:`docs/tasks/PROGRESS-consult-route-split-20261013.md`
- 不改:`CHANGELOG.md`、`frontend/DESIGN.md`、`docs/BUG_HISTORY.md`、数据库迁移
## 后续阶段(本单不做)
1. 第二阶段:运行编排 `runAgenticConsultation`(流选项、`streamWithOverflowRetry`、重试 / 续写 / 窗口预计算、`logRun` / `settleRun`)搬到 `lib/consult/run-agentic.ts`。
2. 第三阶段:legacy 运行时与流输出(`streamTextResponse` 包装、`streamFirstResponse` 接线、响应头)搬到 `lib/consult/stream-output.ts`;评估能否删除 legacy 运行时(需产品拍板,`CONSULTATION_AGENTIC_RUNTIME=legacy` 仍是开关)。
3. 第四阶段:把剩余约 400 处读 `route.ts` 源码的断言逐步换成对服务函数的行为测试,并加一条 `route.ts` 行数 / 闭包数增长合同(参照 `home-shell-growth-contract.test.ts`)。
4. 标题副作用 `titleSideEvent` 与上下文摘要的归属(会话服务)。