Files
Jyotisha/docs/tasks/TASK-rectification-failure-attribution-20260915.md
T
Jesse_ChenandClaude Opus 5 a8d29d1b6c docs(tasks): 记录产品两项决定(分类用会话模型 / 年份采样不改)
产品 2026-09-15 对审计单里挂着的两个待拍板项给出结论,两项都是「不改」:

- 意图分类继续用会话选定的模型,不引入便宜快模型,模型目录不新增
  「工具模型」角色。failure-attribution 单 §4.6 立为决策记录,§10 从
  「待产品拍板后另开单」改为已决定不做;本单只改归因,不改用哪个模型。
- 只给年份的事件继续采满 12 个月,降采样不做、研究单也不立。
  engine-memoization 单 §4.2 / §11 同步,并写明后续不得以性能为由重提。

两条都加了「要重提必须先拿到产品新的授权」,避免下一轮被当成遗漏又提一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 17:17:29 +00:00

201 lines
16 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 · 三处把系统故障说成别的东西(校正轮次链路)
- 日期:2026-09-15
- 基线 commit`origin/staging` @ `6b3248bf`
- 执行分支:`codex/rectification-failure-attribution-20260915`
- 独占文件:`frontend/src/app/api/rectification/agent/route.ts``frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts``frontend/src/lib/rectification-agentic/v9/engine-client.ts``frontend/src/lib/rectification-agentic/v9/agent-run.ts``frontend/src/lib/rectification-activity-labels.ts`
- **`route.ts` 由本单独占。** `TASK-rectification-request-dossier-cache-20260915` 也要碰同一文件,**必须串行在本单之后**。
- 与 engine-memoization 单、settled-render-split 单无文件重叠,可并行
---
## 1. 三条缺陷的共同点
都不是算错,是**归因错**:后端出了故障,但对用户显示成「你没说清楚」「已经记下了」或者干脆断流。用户没有任何线索知道要重试,也不知道刚才那句话有没有算数。
| # | 用户看到 | 实际发生 | 预占 BUG |
| --- | --- | --- | --- |
| 1 | 「我不太确定这句是不是在回答上面的问题」 | 意图分类器两次都异常,这句话里的经历直接丢弃 | BUG-722 |
| 2 | 「记下了:2016 年 3 月……」但范围一动不动 | 引擎 429(算不过来),重算静默失败 | BUG-723 |
| 3 | 等三五分钟,无错误码断流 | 两次尝试 420 秒 > 路由预算 240 秒 | BUG-724 |
## 2. 事故实证
### 2.1 BUG-722 · 分类器失败被说成用户表达不清
`route.ts``POST` 里,`action === "message"` 且当前焦点是**点选题**`parseAgentChoiceCopy(focus.expectedAnswerSchema)` 非空)时:
```
const intent = await classifyTurnIntentWithRetry(resolvedModel, {...});
const classified = intent.classified;
...
if (!classified || classified.intent === "unclear") {
const narration = RECTIFICATION_USER_COPY.unclearFocusReply; // 「我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。」
const turn = await persistV9DeterministicTurn(...); // 本轮落库
return completedMessageResponse(narration, ...); // 直接返回,不进 Agent
}
```
`classifyTurnIntentWithRetry``turn-intent-classifier.ts`)的契约是:两次尝试都抛异常 → 返回 `{ classified: null, expectedWrite: "unknown" }`。也就是说 **`classified === null` 表示「模型没答上来」,`classified.intent === "unclear"` 表示「用户确实说不清」**,这是两件性质完全相反的事,路由把它们合并进了同一条分支。
后果:模型超时、供应商 5xx、网络抖动时,用户被回一句质疑他表达能力的话;这一轮正常落库,但**没有任何证据写入尝试**,他刚讲的那件事就此消失,只能自己再说一遍。
同一文件里**采集题**分支(`isCollectFocusSchema` 为真)已经处理对了:`classified` 为 null 时只记 `collectIntent = "unclassified"`,不短路,继续往下进 Agent,由 `expectedWrite = "unknown"` 的 fail-open 守卫接管。这是 BUG-643 的成果。点选题分支没有跟上。
第三处同类:`decision.nextAction === "ask_candidate_discriminator"` 分支里的 `classifyRectificationTurnIntent` 包在 `try { } catch { classified = null }` 里,随后落 `RECTIFICATION_USER_COPY.choicePrompt`。危害小(那条路本来就要出卡),但归因同样错,一并处理。
**关联记录**BUG-643(分类器 null 不得回退到关键词;`collectIntent=unclassified` 就是那一单加的)、BUG-635(证据轮只说「记下了」却没写入)、BUG-522(其记录末尾原文写着「意图分类器超时仍是既有缺口,本单不修」——**这条缺口从 2026-09-04 挂到今天**)。
### 2.2 BUG-723 · 引擎「忙」被当成引擎「坏」
`engine-client.ts``readEngineJson`
```
if (!response.ok) {
... throw new RectificationEngineError("engine_request_failed", message);
}
```
所有非 2xx 一视同仁。而 Python 侧 `jyotish_api_server.py``except HeavyComputeBusy` 明确返回 **429 + `Retry-After` 头 + `ERR_COMPUTE_BUSY`**,语义是「现在满了,过几秒再来」,不是「坏了」。重算闸门 `api_heavy_compute_gate.py` 并发默认 2、饱和 fail-fast 不排队——**这是设计,不要改它**,要改的是调用侧。
失败之后:`rectification-v9-tools.ts``autoRescoreAfterEvidenceChange` 整个包在 `try/catch` 里,返回 `{ status: "failed", errorCode, openQuestion: null }`**不打任何日志**。这个结果进 `record-evidence-batch` 的 projection 交给模型,但系统提示词明确要求「工具执行保持静默……不叙述工具或内部状态」,所以模型不会说。于是:证据入库成功 → 重算静默失败 → 模型照常写「记下了:2016 年 3 月……」→ 范围一动不动 → 也没有下一问被盖戳。
这正是历史上反复出现的「说记下了但范围没变」的一个来源,而且它**只在两个人同时校正时出现**,本机永远复现不了。
**复发自 BUG-715**(星盘页同一天刚修完同一个错误)。BUG-715 的防复发原文:「引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档。」那一单只改了 `chart-view-engine.ts`,校正链路的 `engine-client.ts` 是同样的写法,没有被扫到。
### 2.3 BUG-724 · 超时预算自相矛盾
| 位置 | 值 |
| --- | ---: |
| `route.ts` `export const maxDuration` | 240 s |
| `regenerate/route.ts` `export const maxDuration` | 240 s |
| `agent-run.ts` `RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS` | 210 s |
| `agent-run.ts` `MAX_ATTEMPTS` | 2 |
单次尝试 210 s < 240 s,满足 BUG-388 防复发的字面要求(「attempt 超时必须小于路由 `maxDuration`」)。但两次加起来 **420 s > 240 s**,违反 BUG-059 的防复发:「**两次模型尝试的总预算必须显式小于路由 `maxDuration`**」。
只要发生一次重试(`empty_stream``evidence_not_written` 都会触发),这一轮必然撞上路由预算或边缘代理超时,用户等三五分钟拿到一个连错误码都没有的断流。
另有一处隐患:`RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000` 在仓库里**定义了两遍**——`agent-run.ts:123`(服务端真正用的)和 `rectification-activity-labels.ts:40`(前端「即将超时」提示用的)。改一个不改另一个,提示时机就会漂。
**复发自 BUG-059****关联 BUG-388**(把 105 s 提到 210 s、把 120 s 提到 240 s 的那一单,它的防复发只写了单次尝试,没写总预算,所以这次没拦住)。
## 3. 根因
三条共用一个根因:**故障的机器语义在传递过程中被压平**。分类器把「抛异常」和「答了 unclear」压成同一个 `null`;引擎客户端把 429/500/超时/坏 JSON 压成同一个 `engine_request_failed`;运行器把「单次尝试的预算」当成「整轮的预算」。压平之后,上层再想按原因分档就没有信息可用了。
## 4. 决策记录
产品 2026-09-15 授权本单,并明确以下口径:
1. **分类器失败必须让用户知道「是我们这边的事」,并且要能把这句话捡回来。** 允许的做法是提示重试;**不允许**用年份正则、关键词表或任何模式匹配去猜用户意图——这是 BUG-643 的防复发红线,本单不推翻。
2. **429 不是错误,是排队信号。** 允许在重算链路上按 `Retry-After` 做有限次退避重试。退避重试仍失败时,**必须让用户知道这次没有重算**,不得让模型继续说「记下了」而范围不动。具体文案由执行方按 `frontend/docs/VOICE.md` 拟,评审在验收轮。
3. **超时按「整轮一个预算」重构,不是简单调数字。** 不接受把 attempt 砍到 110 s——那会把 BUG-388 重新打开(带引擎重算的轮次实测就要超过 105 s)。也不接受把 `maxDuration` 提到 430 s——让用户等七分钟不是产品。
4. **并发闸门(默认 2)、`_SWISSEPH_LOCK`、fail-fast 不排队三项一律不动。** 那是主机只有 2 vCPU 的保护,不是 bug。吞吐问题由 `TASK-rectification-engine-memoization-20260915` 解决。
5. **不改计费口径。** 现在这三条失败路径都发生在 `billing.reserve()` 之前或走 release,用户不扣点;改完必须仍然不扣点。
6. **意图分类继续用会话选定的模型,不引入便宜快模型。** 产品 2026-09-15 明确决定:分类用贵的。因此模型目录**不新增**「工具模型」角色,`classifyRectificationTurnIntent` / `classifyTurnIntentWithRetry` 继续走 `resolveSessionLanguageModel` 的返回值。本单不得以「省钱 / 提速」为由改模型选择;后续轮次要重提必须先拿到产品新的授权。本单只改**归因**,不改**用哪个模型**。
## 5. 硬红线
1. 不得引入任何关键词/正则/词表兜底去替代分类器(BUG-643 防复发)。
2. 不得放宽 `expectedWrite` 守卫、不得改 `evidence_not_written` 的重试语义、不得用确定性业务模板伪装成模型生成成功(BUG-059 防复发)。
3. 引擎调用失败必须留服务端日志,只含路径、状态码或错误名、耗时;**不得含出生资料、案例 ID、用户原文、模型原文、JWT**BUG-715 防复发 + AGENTS §8)。
4. 超时改动后,「两次尝试总预算 < 路由 `maxDuration`」必须由一条测试断言钉死,而不是靠注释。
5. 超时后若本轮已盖戳 `open_question`,仍不得把整轮打成空 `run_timeout`(BUG-388 防复发,现状行为,保持)。
6. `frontend/src/app/page.tsx` 一行不许动(1951/2000AGENTS §6)。
7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 6. 任务分解
### 6.1 分类器区分「模型没答上来」与「用户说不清」
`classifyTurnIntentWithRetry` 的返回值加一个判别字段(例如 `outcome: "classified" | "unclear" | "classifier_unavailable"`),`classifier_unavailable` 专指两次尝试都抛异常。`classified: null` 仍然保留给调用方兼容,但路由不再据此分档。
`route.ts` 三处改为:
- `outcome === "unclear"` → 维持现状,回 `unclearFocusReply`
- `outcome === "classifier_unavailable"` → 走一条新文案(大意:这边没接上,把刚才那句再发一次就行),并且**这一轮不得被记成用户已经答过当前焦点**;焦点保持 active,下一次重发能正常进入同一条路径。
- 同一改动覆盖点选题分支、`ask_candidate_discriminator` 分支的 `try/catch`。采集题分支已经正确,只需保证它的 `collectIntent` 语义不被本次重构改坏。
另加一条服务端日志(只含 case 前缀无关的机器码与耗时,不含原文),让这类失败在日志里可数。
- 验收:`rectification-turn-intent-classifier.test.ts` 新增断言——分类器抛异常两次时 `outcome === "classifier_unavailable"`;模型正常返回 `intent: "unclear"``outcome === "unclear"`;两者的路由回复文案不同。
- 验收:源码合同断言 `route.ts` 不存在 `!classified || classified.intent === "unclear"` 这种合并判断。
- 验收:`classifier_unavailable` 路径不写任何证据、不推进焦点状态、不扣点。
### 6.2 引擎失败按原因分档
`engine-client.ts``readEngineJson` 改为按原因产出判别码:`busy`429/ `http_error` / `timeout` / `bad_payload`,并读取 `Retry-After`。每一种非 ok 打一条 `console.warn`(路径、状态码或错误名、耗时)。对齐 BUG-715 在 `chart-view-engine.ts` 里已经落地的那套形状,不要另发明一套。
`scoreAndPersistCurrentEvidence` / `autoRescoreAfterEvidenceChange` 链路上:`busy``Retry-After`(上限取一个显式常量,建议 ≤ 2 次、总退避 ≤ 6 s,写成具名常量并在测试里钉死)退避重试;仍失败时把失败原因显式带回 projection,并让本轮的主持人正文告诉用户这次没有重算、经历已经记下、稍后会再比一次。
- 验收:新增测试,桩出 429 + `Retry-After: 2`、500、超时、坏 JSON 四种,断言四种分别产出不同判别码、各有可区分日志、只有 429 触发退避重试。
- 验收:断言退避总时长有上限,且上限是具名常量不是字面量散落。
- 验收:`busy` 重试成功后,`rescore.status` 必须是 `completed`,与从未失败过的那条路径逐字相同。
- 验收:`busy` 最终失败时,用户可见正文里必须出现「这次没有重新比较」这一语义(具体文案对照 `frontend/docs/VOICE.md`),且不得出现「范围在收窄」这类进度句。
### 6.3 超时改成整轮一个预算
`runV9AgentTurn` 里引入一个整轮 deadline(建议 `RECTIFICATION_RUN_BUDGET_MS`,取值必须显式小于 `maxDuration`,例如 225 s),单次尝试仍保留 210 s 上限,但实际超时取 `min(单次上限, 剩余预算)`。重试条件从 `attemptNumber < MAX_ATTEMPTS` 改为 `attemptNumber < MAX_ATTEMPTS && 剩余预算 ≥ 最小可用尝试时长`(同样具名常量)。预算不够重试时,走现有的 `hostFallbackUsed` 优雅路径,而不是启动一次注定被砍断的尝试。
同时把 `RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS` 收敛成**一处定义**`rectification-activity-labels.ts` 从那一处 import,消除两份 210_000 漂移的可能。
- 验收:新增断言 `RECTIFICATION_RUN_BUDGET_MS < maxDuration`,且 `maxDuration` 从路由模块读取而不是重写一遍字面量(`agent``regenerate` 两条路由都要覆盖)。
- 验收:`rectification-v9-stream.test.ts` 新增用例——第一次尝试耗尽大部分预算后返回 retryable,运行器不得发起第二次尝试,必须走 host fallback 并给出可见正文。
- 验收:源码合同断言全仓只有一个 `210_000` 的定义点。
- 验收:已盖戳 `open_question` 的超时轮仍然落题干、不空失败(BUG-388 现状行为回归)。
### 6.4 三条 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`,编号连续,每条都要有「复发自 / 关联记录」:
- BUG-722:关联 BUG-643、BUG-635、BUG-522522 里写明的既有缺口本单关闭)。
- BUG-723**复发自 BUG-715**,说明 715 的防复发为什么没覆盖到 `engine-client.ts`(那一单的范围写死在星盘页三个文件里)。防复发要升级成仓库级:**任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码,429 必须单独成档。**
- BUG-724**复发自 BUG-059**,说明 BUG-388 的防复发只约束了单次尝试、没约束总预算,因此没拦住。
## 7. 让步顺序
1. 6.1 必须做,它是唯一会**丢用户数据**的一条。
2. 6.3 次之,改动最小、风险最低,而且它是重试链路的前提。
3. 6.2 的「分档 + 日志」必须做;「按 `Retry-After` 退避重试」可以砍到下一轮,但那样的话**用户可见的「这次没有重算」提示不得砍**——宁可不重试也不许静默。
4. 6.4 不得砍。
## 8. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-failure-attribution-20260915 \
.worktrees/rectification-failure-attribution-20260915 origin/staging
cd .worktrees/rectification-failure-attribution-20260915/frontend
git status -sb | head -1
npm ci
```
验收命令:
```bash
./node_modules/.bin/tsc --noEmit
npm run lint
npx tsx --test tests/rectification-turn-intent-classifier.test.ts \
tests/rectification-v9-stream.test.ts \
tests/rectification-answer-choice.test.ts \
tests/rectification-spoken-collect.test.ts \
tests/rectification-unwritten-evidence.test.ts \
tests/rectification-v9-agent.test.ts \
tests/chart-view-engine.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
npm run build # `/` 仍须 ○ Static,首屏 gzip ±2%
```
## 9. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-722 / 723 / 724**BUG-721 留给 engine-memoization 单)。开工时核对当时的实际最大号;同日四单并行,先落库者先占号,冲突时顺延并在进度记录里写明。
## 10. 不在本单范围
- 分类改用便宜快模型:**产品 2026-09-15 已决定不改,分类继续用会话选定的贵模型**,不另开单(见 §4.6)。
- 并发闸门、`_SWISSEPH_LOCK`、加机器。
- 引擎本身的耗时(见 `TASK-rectification-engine-memoization-20260915`)。
- 请求内 Case 档案缓存(见 `TASK-rectification-request-dossier-cache-20260915`,串行在本单之后)。