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

18 KiB
Raw Blame History

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. 开工前置命令

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 与上下文摘要的归属(会话服务)。