Files
Jyotisha/docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md
T
Jesse_ChenandCursor a998b6ec53
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): stop unwritten-evidence claims and same-cluster dasha false conflicts (BUG-635–640)
Host only says 记下了 after a real write; Mastra schema rejections fail closed. Ledger year keys no longer drop quality probes, dual-dasha agreement is per cluster, width uses cluster span, and public house tables follow the inference minute.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 17:38:37 +08:00

131 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 · 证据轮模型只说「记下了」却没写证据、没设下一问:财务一件落空后流程停在原题(2026-09-10)
- 基线:`origin/staging` @ `d96b24c2`(BUG-626/627/633/634 已合入并部署;`/api/health` 的 `deployment.gitCommit` = `d96b24c2`)
- 分支:`codex/rectification-unwritten-evidence-claim-20260910`,基于 `origin/staging`
- 串行:无同文件并行单。`TASK-rectification-evidence-turn-empty-answer-20260910.md`(BUG-633/634)已合入基线,本单在其 `applyHostFallback` / 失败刷新之上叠加,不得回退它的任何断言。
- 执行方:coding agent;验收:Claude
- 涉及文件(行号按 `d96b24c2`,定位以符号为准):`frontend/src/app/api/rectification/agent/route.ts`(collect 焦点分支 L522–597、`classifyRectificationTurnIntent` L544、`runV9AgentTurn({` L847)、`frontend/src/lib/rectification-agentic/v9/agent-run.ts`(`V9AgentRunOptions` L96–112、`RETRYABLE_ERROR_CODES` L150、`applyHostFallback` L843、`streamAttempt` 收尾 L1025–1090、`RectificationRunDiagnostic.stateMutationCommitted` L1109、`buildAgentMessages` L1180)、`v9/host-fallback.ts`(`publicWriteToolCompleted` L16)、`v9/stream-mapping.ts`(`mapStreamChunkToActivity` L233–257)、`frontend/src/lib/rectification-agentic/user-copy.ts`(`RECTIFICATION_USER_COPY`)、`frontend/src/components/rectification-agentic-chat.tsx`(`liveQuestionOnMessages` L1501、`questionGap` L1513、输入框占位 L1855)、`frontend/src/lib/rectification-surface-state.ts`(`rectificationQuestionGapState` L300)
- BUG 编号起点:**BUG-635**(`docs/BUG_HISTORY.md` 当前最大 BUG-634)
- 优先级:BUG-635 **P1**(证据被静默丢弃、流程停住,用户看不出任何异常);BUG-636 **P2**(同一路径的 schema 拒绝会被报成 completed,诊断盲区)
- 不改 Skill 版本(保持 `10.0.21`),不改引擎、不改淘汰阈值、不改分类器提示词。
## 0. 给产品的即时绕行(不等修复)
在同一个校正会话里**再发一次**那句带年月的财务经历(原句即可)。当前活动焦点仍是财务采集题,分类器会再跑一次,模型大概率会走 batch。或者回「没有」关闭财务题,服务端会接着问下一条线。两种都不丢已有的 4 件证据和 4 道已答题。
## 1. 事故实证(staging 2026-09-10 13:50 +0800,Skill 10.0.21;只写结构,不写个人资料)
顺序:开场 → 学业两件 → 感情两件 → 三道带年月选择题(事业 / 迁居 / 事业)→ D9 风格题 → 家人题「不记得了」→ 财务采集题「钱的方面,还记得哪一年…」→ 用户答「某年某月开始欠债」→ 助手只回一句 **「记下了:某年某月开始欠债。」**,下面没有任何问题、没有卡。顶部时间线仍是「目前范围 04:45–05:15,还在核对」。
Case JSON 里这一轮:
| 项 | 值 |
| --- | --- |
| 用户 turn `status` | `completed`;`phases` = `run.started, skill.bound, case.loaded, intent.classified, answer.composed, billing.settled, run.completed`;**没有 `evidence.proposed`** |
| `tool_activities` | 只有 `rectification-read-case` completed(48 ms);**没有 `rectification-record-evidence-batch`,也没有 `rectification-set-focus`**(前两轮证据轮都是 read-case → batch → set-focus 三步) |
| `evidence` | 仍是 4 件(学业 2、感情 2),没有 finance 域;`latest_result.createdAt` 仍是上一证据轮的时间 |
| `current_question` | 仍是 `collect:finance:collect_method_evidence`,`status=active`,`question_source=focus`,焦点的 `askedTurnId` 是上一轮 |
| `turns[].question` | 这一轮的 assistant 行 `question=null` |
| `step_state` | 「第 1 步·收集经历 / 继续回答当前这题」 |
| `interview.collection_progress` | `scoreable=3, minimum=3, missing=0` |
| 计费 | `billing.settled` 已出现:一轮什么都没写,照常扣点 |
同一 Case 里另一个与本单无关但产品会问的事实:4 道选择题全部有效计分(deltas ±2/±1),没有一个候选被淘汰,领先 04:52–04:53 为 14 分、落后 8 分。可信区间是全部未淘汰候选的包络,所以「范围没变」是算术结果(BUG-634 已让旁白说出领先/落后)。带年月的区分探针已经问完(`dropped_probes` 里剩下的全是无年份对照题,按 BUG-627 决策被丢弃),**继续收窄只能靠再收一件带年月的经历**——而这件恰好在本轮落空。
## 2. 根因
### 2.1 BUG-635(P1):模型声称「记下了」但没有调用任何写工具,宿主没有「证据轮必须落地」的不变量
1. 路由 collect 焦点分支(`route.ts` L522–597)先跑 `classifyRectificationTurnIntent`。本句不是「没有 / 记不清」,分类结果应为 `provide_new_evidence` 或 `answer_current_focus` + `has_new_dated_event`。这两种都**直接落到 Agent**,分类结果被丢掉:`runV9AgentTurn` 只拿到 `action="evidence"` 和原话,没有任何字段告诉运行器「这一轮必须产生一次 batch 写入」。
2. 模型只调了 `rectification-read-case`,然后按系统提示里「证据轮正文只写一句复述,格式『记下了:年 月 事件短语』」的**格式要求**写了正文,却没做格式前提的那件事(`rectification-record-evidence-batch`),也没调 `rectification-set-focus`。模型为什么跳过,staging 没有落库的 run diagnostic 可查(`RectificationRunDiagnostic` 只进 `console.info`)。候选原因:「开始欠债」被当成持续状态而非带日期事件;`2026 年` 对模型像未来时间(`timeContext` 已给服务端时间,但不保证被采信);finance 域在 Skill 里写着「只有用户主动说才问」,模型可能把服务端主动问的财务题当成不计分。任何一种都不该由用户承担。
3. `streamAttempt` 收尾(`agent-run.ts` L1025–1045)只有三种异常分支:`answer_truncated`、`max_steps/provider_error`、`!answerText.trim()`(`empty_stream`)。正文非空就直接 `completeAttempt()`,**不看 `toolTerminalStatus` 里有没有公开写工具完成**。`stateMutationCommitted = publicWriteToolCompleted(...) || hostFallbackUsed` 这个值算出来了(L1109),但只进日志,不参与决策。
4. `applyHostFallback`(L843,BUG-633)只覆盖「batch 已完成、正文为空」。它的镜像——「正文说记下了、batch 没跑」——没有任何守卫。
5. 成功收尾的焦点守卫(L1056)只在 `decision.nextAction === "ask_candidate_discriminator"` 时要求有已持久化的点选焦点;采集阶段里旧的财务焦点仍是 `active`,`decideFromDossier` 认为一切正常。`askedFocus?.askedTurnId === turnId`(L1066)也不成立,不会补 `collectHandoff`。
6. 客户端 `liveQuestionOnMessages`(L1501)只要求**任一** settled assistant 消息上挂着与 `current_question.focus_id` 相同的问题——上一条消息的财务题满足条件,于是 `questionGap` 不报缺口、不补问题行、输入框占位照常写「请回答上面的问题…」。用户看到的是一句「记下了」和一片空白,要往上翻才知道「上面的问题」还是刚答过的那道。
7. 没有重试:`empty_stream` 至少还有「没有写工具完成时可重试一次」(BUG-633);本情形正文非空,连这条路都不走。
### 2.2 BUG-636(P2):校正流对 Mastra 的 inputSchema 拒绝信封没有识别,会把被拒的 batch 报成 completed
BUG-278 已实测:`createTool` 的 `inputSchema` 校验失败时**不抛**,而是 resolve 一个 `{ error: true, message, validationErrors }` 信封,流层会把它当普通 `tool-result`。咨询流当时按信封结构改发 `tool.failed`;校正流的 `mapStreamChunkToActivity`(`stream-mapping.ts` L233–257)的 `tool-result` 分支没有同样的判定,`batchResultFromToolChunk`(`host-fallback.ts`)也会把信封当 batch 返回值。本事故里模型根本没调 batch,所以这不是触发原因;但若模型传了一个不在 `EVIDENCE_KINDS` 的 kind(例如 `debt`),回执会显示 `evidence.proposed` completed、`toolTerminalStatus` 记 completed、`publicWriteToolCompleted` 为 true——BUG-635 的守卫会被这条假 completed 绕过,且「有写工具完成不得重试」会误判。必须一起补。
## 3. 决策记录(产品授权范围)
1. **「记下了」只能由写入事实支撑。** 任何正文含「记下了」且本 attempt 没有公开写工具 completed、也没走 `applyHostFallback` 的,该正文不得到达用户;流式已发出的部分用既有 `retractSpoken()` 收回。这是对系统提示第 4 条格式要求的宿主侧兜底,不是改提示词。
2. **允许重试一次,条件与 BUG-633 完全一致**:仅当本 attempt 没有任何公开写工具 completed(无证据重放风险,不触犯 BUG-186)。新增错误码 `evidence_not_written`,与 `empty_stream` 同法——不进 `RETRYABLE_ERROR_CODES` 常量,由收尾分支按 `publicWriteToolCompleted` 决定 `retryable` / `failed`。重试 attempt 的 bootstrap 追加一行:「【重试约束】上一 attempt 没有调用 rectification-record-evidence-batch 就写了『记下了』。本轮必须先把用户原话里的带日期事件提交 batch,再用 rectification-set-focus 写下一问,最后才写正文。」
3. **两次都没写 → fail closed 但不断流。** 主持人正文用新增文案 `RECTIFICATION_USER_COPY.evidenceNotRecorded` = 「这件我还没记上。请再说一次大概年月和发生的事。」(对照 `frontend/docs/VOICE.md`,不写内部错误、不写「系统」「模型」)。turn `status=completed`,`answer_origin=host_fallback`,phases 含 `answer.host_fallback`;**不结算计费**(用户这一轮什么都没得到)。若现有 `finalizeTurn("completed")` 与结算不可分离,允许改为 `failed` + 主持人正文,但客户端必须按 BUG-633 的失败路径 `loadCaseSnapshot` 并渲染问题行;两种实现选哪种写进进度记录。
4. **守卫的触发信号**(2026-09-10 产品口径修订:业务判断不得靠正则):路由在 collect 焦点分支已拿到分类结果,通过 `runV9AgentTurn` 新 option `expectedWrite: "evidence" | "none" | "unknown"` 传入(`provide_new_evidence`、或 `answer_current_focus` 且 `has_new_dated_event` → `"evidence"`)。消息不在 collect 焦点分支时,也对用户原话跑一次同一分类器(无焦点时 `current_question` 传空),拿 `has_new_dated_event` / `provide_new_evidence` 判定。分类器为 null / 出错:重试一次,仍失败则 `"unknown"`,守卫 fail-open 并在 `RectificationRunDiagnostic` 记 `expectedWrite=unknown`;**不得用年份正则或关键词兜底**。`action` 为 `opening` / `read_only` 时不触发。
5. **客户端**:完成轮的 assistant 行没有 `question`,而快照 `current_question.question_source === "focus"` 且该焦点不是本轮新设(`askedTurnId` ≠ 本轮 / 本轮回执无 `rectification-set-focus`)→ 在主持人正文下**再渲染一次**问题行(复用 BUG-633 加的主持人问题行,`focus_id` 只能来自 `current_question`)。`liveQuestionOnMessages` 改为只认**最后一条** settled assistant 消息;旧消息上的同 `focus_id` 不再算「仍在显示」。
6. **BUG-636**:`tool-result` 分支按结构识别信封(`error === true` 且 `validationErrors` 为对象),发 `tool.activity` `status=failed`、`code=tool_call_rejected`;`toolTerminalStatus` 记 failed;`batchResultFromToolChunk` 对信封返回 null。不匹配上游英文文案。公开回执不泄漏 `validationErrors` 内容。
7. 不做的事:不改 `turn-intent-classifier.ts` 提示词;不改 Skill 10.0.21;不改引擎计分与淘汰阈值(「范围没变」另议);不给财务/健康域加特殊规则。
## 4. 硬红线
1. 有任何公开写工具 completed 的 attempt 不得重试(BUG-186、BUG-633)。
2. 兜底正文只能来自 batch 返回或本单的固定文案,不得由宿主自己复述用户原话里的「年月 + 事件」当成「记下了」——那等于宿主替模型撒谎。
3. 不得出现第二套 spinner / 骨架;问题行复用既有组件。
4. 既有测试总数不得降低;改任何既有断言写「原值 / 新值 / 原因」。
5. Bug 历史、测试、进度记录不得出现真实用户的年月事件;fixture 用虚构年份。
6. 不动 `scripts/jyotish_api_server.py`、不动迁移、不升依赖。
## 5. 任务分解
### T1 · 路由把「本轮应有写入」传给运行器(BUG-635)
- `route.ts` collect 焦点分支:分类结果落到 Agent 的两种情形计算 `expectedWrite`;非 collect 的 `message` 路径也对原话跑同一分类器。分类失败两次则 `"unknown"`,守卫 fail-open,不用年份正则。
- `V9AgentRunOptions` 新增 `expectedWrite?: "evidence" | "none" | "unknown"`。
- 验收:单测覆盖 `provide_new_evidence` → `"evidence"`;`answer_current_focus` + `has_new_dated_event` → `"evidence"`;`answer_current_focus` + `no` 不进 Agent(既有);分类器两次都抛错 → `"unknown"` 且守卫不触发、诊断里有记录;源码合同断言 `agent-run.ts` / `route.ts` 不含年份正则。
### T2 · 收尾守卫:无写入的「记下了」不得交付,可重试一次(BUG-635)
- `streamAttempt` 收尾在 `empty_stream` 分支之后、`loadV9CaseDossier` 之前加:`needsWrite && !publicWriteToolCompleted(toolTerminalStatus) && !hostFallbackUsed` → 第一次返回 `status="retryable"`、`errorCode="evidence_not_written"`,`retractSpoken()`,不记 `answer.composed`;第二次按决策 3 写主持人正文收口。
- 验收(`frontend/tests/rectification-v9-stream.test.ts` 或新 `rectification-unwritten-evidence.test.ts`,用假流):
- read-case → 正文「记下了:…」→ finish:attempt `retryable`,无 `answer.composed`,spoken 已收回。
- 第二 attempt 同形:turn `completed`,正文 = `evidenceNotRecorded` 文案,phases 含 `answer.host_fallback`,公开回执 `answer_origin=host_fallback`,未结算计费(或按决策 3 的备选路径)。
- read-case → batch completed → set-focus → 正文:行为与基线完全一致(回归)。
- read-case → batch completed → 无正文:仍走 BUG-633 的 `applyHostFallback`(回归)。
- `action="read_only"` + 带年份原话:不触发。
- `RectificationRunDiagnostic` 的 `stateMutationCommitted=false` 且新增 `expectedWrite` 字段。
### T3 · 重试 attempt 的约束句(BUG-635)
- `buildAgentMessages` 在 `attempt > 1` 且上一 attempt 错误码为 `evidence_not_written` 时追加决策 2 的约束句;其他重试原因保持原句。
- 验收:单测断言两种重试原因各自的 bootstrap 内容。
### T4 · 客户端再显示仍未答完的问题(BUG-635)
- `liveQuestionOnMessages` 只认最后一条 settled assistant 消息;完成轮无 `question` 且 `current_question.question_source==="focus"` → 渲染主持人问题行。
- 验收:`rectification-surface-state.test.ts` / 聊天组件测试:旧消息带同 `focus_id` 不算 live;问题行 `focus_id` 来自 `current_question`;`frontend/DESIGN.md` 补「主持人问题行也用于完成轮的未答焦点」。
### T5 · 校正流识别 schema 拒绝信封(BUG-636)
- `mapStreamChunkToActivity` / `mapStreamChunkToPhase` / `batchResultFromToolChunk` 按决策 6 处理信封。
- 验收:单测喂 `tool-call` + 信封 `tool-result`:事件流有 `tool.activity failed code=tool_call_rejected`、无 completed;`toolTerminalStatus` 为 failed;`publicWriteToolCompleted` 为 false;`composeHostFallbackNarration` 不把信封当 recap;公开回执序列化后不含 `validationErrors`。
### T6 · 记录
- `docs/BUG_HISTORY.md` 新增 BUG-635、BUG-636(状态、现象、触发、根因、修复、验证、防复发、关联 BUG-186/278/359/449/633)。
- `docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md`:每条 T 的测试名与结果、决策 3 选了哪条实现、`next build` 后 `/` 是否仍 Static、首屏 gzip 变化。
- `CHANGELOG.md`:「证据没记上时助手会明说并再显示原题,不再只说记下了」。
- `docs/testing/` 新增一条真人清单:在 staging 走到财务采集题,回一句带年月的欠债/收入变化;期望要么证据数 +1 且下一问出现,要么助手说「这件我还没记上」并再显示财务题;**绝不出现**「记下了」而证据数不变。
## 6. 让步顺序
T5 → T4 → T3 可依次延后(延后的写 `BLOCKED.md` 或进度记录并说明);T1 + T2 是本单的最低交付,不可省。若 T4 延后,T2 的 fail-closed 文案已含「请再说一次」,用户仍能直接回答。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-unwritten-evidence-claim-20260910 .worktrees/rectification-unwritten-evidence-claim-20260910 origin/staging
cd .worktrees/rectification-unwritten-evidence-claim-20260910/frontend
grep -c "^## BUG-" ../docs/BUG_HISTORY.md # 核对最大号仍为 634
npm test -- tests/rectification-v9-stream.test.ts tests/rectification-host-fallback.test.ts tests/rectification-spoken-collect.test.ts tests/rectification-surface-state.test.ts # 记下基线 pass 数
```
## 8. 验收口径
- `tsc --noEmit` 0 错;`npm run lint` 0 error;上述四个测试文件加新文件 fail=0,测试总数 ≥ 基线。
- `next build` 后 `/` 仍 `○ Static`,首屏 gzip ±2%。
- 推 staging 后 `/api/health` 的 `deployment.gitCommit` 等于含代码改动的最新提交。
- 真实环境清单见 T6;做不了的写成环境缺口,不得写「通过」。