Files
Jyotisha/docs/tasks/TASK-rectification-dup-question-20260926.md
T

6.8 KiB
Raw Blame History

TASK · 生时校正:同一轮问题出现两次 + 同一道选择题连画两张(2026-09-26)

基线

  • origin/staging 当前 head(a57c4310 代码;BUG-1042 修复单合入后以新 head 为准)。分支 codex/rectification-dup-question-20260926。
  • 与 TASK-rectification-latency-20260926(改 route / agent-run / 校正面活动状态)冲突:本单先做,那单后做。与 TASK-scroll-anchor-hook-fixes-20260926 不交叉。

事故实证(产品 09-26 真机,staging)

A · 同一轮里问题出现两次:用户打字回答后,助手这一轮先是正文「已记录,范围没变;14:58–14:59 领先,14:40–14:43 落后。」「2024 年 3 月前后,有没有收入明显变化、大笔支出或欠债?」,下面又是加粗的同一句问题块和选项。刷新后只剩一遍。

  • frontend/src/lib/rectification-agentic/v9/answer-choice.ts persistApplied:下一问是选择题时 hostNarration = composeCollectSpokenAssistantText(body, choiceStem),把题干拼进 assistant_message 并作为 applied.narration 返回;persistCollectDenialTurn 同样。
  • frontend/src/app/api/rectification/agent/route.ts:打字回答命中待答选择题(action === "message" + choice 分支)→ applyRectificationChoice → completedMessageResponse(applied.narration) 整段作为一个 answer.delta 下发;不经模型 runner,agent-run.ts 里 BUG-585 的 stripQuestionSentences(...) 不执行。
  • rectification-agentic-chat.tsx send 结算后保留原始流式正文;loadCaseSnapshot → frontend/src/lib/rectification-snapshot-messages.ts mergeTurnQuestions 只拷 question / candidateOffer,不替换 text。rectification-message-entry.tsx 先渲染 message.text 再渲染 question.prompt → 两遍。去重只在 GET 组装的 attachQuestionsToTurns(turn-question.ts)里,所以刷新后消失。
  • 排查已在 tsx 里复现:合并快照后正文仍含题干;stripQuestionSentences 对同一文本能正确去掉。
  • 复发:BUG-585(修复 9d1c08ca,防复发"不得恢复把题干拼进 assistant_message")被 BUG-969 第③项(824ecff0,2026-09-18)带回:为防卡片加载失败时看不到题而拼回题干,声称"挂卡后去重",但测试(account-dialog-inert-20260918.test.ts)只覆盖刷新路径,并把 rectification-answer-choice.test.ts 断言改成期望题干在正文里。

B · 同一道选择题连画两张:用户在第 N 轮点选 B 发生过但程度较弱(高亮),其下是操作按钮、「目前范围 14:35-15:05,还在核对」,再下面又出现完全相同的题干和四个选项(未选中)。

  • 第二张不是新一轮助手回答,而是独立的"持久化问题块":rectification-agentic-chat.tsx 中 questionGap === "persisted_question" 时渲染 currentQuestion.prompt + 新的 RectificationChoiceCard(selectedKey="")。条件是 rectificationQuestionGapState 判定"没有任何消息挂着与 currentQuestion.focus_id 匹配的活题,但快照里仍有持久化问题"。同类:BUG-675 / 678(「目前范围」下挂裸题);BUG-678 意图"只在没有助手消息时兜底",但闸门没强制。
  • 两个假设(需真实会话区分):
    • H1(更可能):submitStructuredChoice 先 markQuestionAnswered(本地标 B)再 POST /api/rectification/agent(action: "answer_choice");!response.ok / catch 只移除占位、choiceNonce++、setError,不撤回已答标记、不重载快照 → 活题判定落空 → 兜底块再画一张。服务端可能的错误:stale_question / revision_conflict / stale_probe / 500。
    • H2:POST 成功但 nextInterviewPersisted 为假且 nextAction 为 ask_candidate_discriminator 的 willContinue 分支只跑 mergeTurnQuestions、没跑 appendUnseenAssistantTurns(BUG-685 在 requestTieBreak 修过同型缺口),承载下一问的那轮没进客户端,兜底块替它画了出来;若题目完全相同,还说明选题重复了(BUG-540 / 559 / 592 守卫仍在代码里,需确认)。

根因

A:服务端为兜底把题干拼进正文,客户端在实时路径上没有"挂卡即去重"。B:选择题提交失败不回滚本地状态 + 兜底问题块的出现条件过宽(H2 另有漏收回合)。

决策记录

  • D1 保留 BUG-969 的兜底意图(卡片没挂上时用户仍能在正文看到题),但卡片一挂上就去掉正文里的同一句:mergeTurnQuestions 在快照回合带问题时,用服务端已去重的 turn.text 覆盖,或对 message.text 执行 stripQuestionSentences(text, question.prompt);实时流里若同一轮已收到问题卡,也立即去重。
  • D2 选择题提交失败:撤回本地已答标记(题目回到 active、无 answer_option)、重载快照、只保留一张可点的卡 + 错误提示「这次没提交上,请再点一次。」(对照 VOICE)。
  • D3 兜底问题块只在没有任何助手消息时渲染;已有助手消息时把问题挂到最新助手消息上(BUG-678 原意),且同 focus_id 的题永远只画一张。
  • D4 H2 分支:willContinue 与 send() 结算后的快照合并同时执行 appendUnseenAssistantTurns(同 BUG-685)。若复现确认服务端真出了重复题,另开 BUG 关联 BUG-540 / 592,不在本单改选题。

硬红线

  1. 回归测试必须走实时路径:流式收到"确认句 + 题干"→ 合并快照 → 断言题干在正文与问题块合计只出现一次;打字回答命中选择题、采集拒答两条路径各一条。
  2. 提交失败的回滚测试:模拟 409 stale_question 与网络错误,断言页面只有一张可点卡 + 错误提示,已答标记被撤回。
  3. 不改服务端选题与打分;不改 BUG-969 的服务端兜底文本(只改客户端去重)——若必须改服务端,写明理由。
  4. 全量测试与基线逐条一致、新增 0;改动断言写三栏(含把 BUG-969 改成"期望题干在正文"的那条,改回并说明);真实浏览器截图(CDP 假数据)验证 A、B 两个场景。
  5. 不改 useConversationScrollAnchor(另一单在改)。

任务分解

  • T1 实时去重(D1)+ 测试。
  • T2 提交失败回滚(D2)+ 兜底块闸门(D3)+ 测试。
  • T3 H2 漏收回合(D4)+ 测试。
  • T4 记录:BUG-1045(A,复发自 BUG-585,写明 BUG-969 如何绕过防复发、为何测试没拦住);BUG-1046(B,关联 BUG-675 / 678 / 685);PROGRESS;docs/testing/ 真机清单(打字回答后问题只出现一遍;选择题提交失败时只有一张卡 + 提示;刷新前后一致)。

BUG 编号

写单时最大 BUG-1044(1042–1044 由按钮间距单预留);本单 BUG-1045 / 1046,开工时核对。