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
171 lines
18 KiB
Markdown
171 lines
18 KiB
Markdown
# 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` 与上下文摘要的归属(会话服务)。
|