fix(rectification): one stem per turn, one card per focus (BUG-1045/1046)
BUG-1045 (recurrence of BUG-585 via BUG-969): the snapshot merge now drops the attached stem from the streamed "ack + stem" text with the same stripQuestionSentences the GET route uses; server text unchanged. BUG-1046: a failed choice submit (409 / network) or typed send withdraws the local answered mark, remounts the card, re-reads the Case and shows "这次没提交上,请再点一次。"; the persisted question hangs on the latest settled assistant message with other copies of the same focus removed (standalone block only when nothing can carry it); willContinue and the send() settle merge unseen assistant turns (same gap as BUG-685). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
07998972e0
commit
e4c1c7a342
@@ -13988,3 +13988,35 @@
|
||||
- 相关记录:BUG-930、BUG-931(同症状、留白坍塌触发)、BUG-1042、BUG-1043。
|
||||
- 复发自:无(BUG-931 同症状、不同触发条件)。
|
||||
- 修复版本:`codex/scroll-anchor-hook-fixes-20260926`,未部署。
|
||||
|
||||
## BUG-1045 | 生时校正打字回答后,同一轮里下一题出现两次(刷新后只剩一遍)
|
||||
|
||||
- 状态:investigating(修复已实现并有实时路径回归测试,本地 `next start` + 无头 Chrome 虚构数据复现并验证;staging 真机未走,未部署,不写 resolved)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:`frontend/src/lib/rectification-snapshot-messages.ts` `mergeTurnQuestions`(`rectification-agentic-chat.tsx` 的 `send` / `submitStructuredChoice` 结算后快照合并);服务端 `answer-choice.ts` `persistApplied` / `persistCollectDenialTurn` 的确定性回复正文(本轮不改)。
|
||||
- 用户现象:产品 09-26 staging 真机:打字回答一道待答选择题后,助手这一轮正文是「已记录,范围没变;…领先,…落后。」+ 下一题题干,下面又是同一句加粗题干和四个选项;刷新后只剩一遍。
|
||||
- 触发条件:确定性回复路径(打字回答命中待答选择题 `action=message` + choice 分支;或采集拒答),且下一题是可渲染选择题。两条路径都不经模型 runner,BUG-585 在 `agent-run.ts` 的 `stripQuestionSentences` 不执行。
|
||||
- 根因:BUG-969 修复第③项(`824ecff0`)为防「卡片没挂上时看不到题」,让 `persistApplied` / `persistCollectDenialTurn` 用 `composeCollectSpokenAssistantText` 把题干拼进 `assistant_message` 并作为一个 `answer.delta` 下发,声称「挂卡后去重」。去重只存在于 GET 组装的 `attachQuestionsToTurns`;客户端结算后保留流式原文,`mergeTurnQuestions` 只拷 `question` / `candidateOffer`、不动 `text`,`rectification-message-entry` 先画正文再画问题块 → 两遍;刷新走 GET 才去重,所以刷新后消失。
|
||||
- 复发自:**BUG-585**(修复 `9d1c08ca`,防复发写明「不得恢复把题干拼进 `assistant_message`」)。BUG-969 绕过了这条:它有意恢复拼接,用「挂卡后去重」代替原防线,但只在刷新路径实现了去重。为什么测试没拦住:`account-dialog-inert-20260918.test.ts` 的 BUG-969 用例只断言 `persistCollectDenialTurn` 流文本与 `attachQuestionsToTurns`(刷新路径)结果,没有走「流式结算 → 快照合并」的实时路径;同一提交把 `rectification-answer-choice.test.ts` 的断言从「正文不含题干」改成「正文含题干」(三栏注释写了原因,但没有对应的客户端去重断言);BUG-585 的防复发只锁在模型 runner 的 `agent-run.ts`,确定性回复路径没有源码合同。
|
||||
- 修复:D1(产品授权保留 BUG-969 的服务端兜底文本):`mergeTurnQuestions` 在快照回合带问题时,对消息正文执行与 GET 相同的 `stripQuestionSentences(text, question.prompt)`,幂等;实时对话与刷新读到同一段正文。服务端文本不改。
|
||||
- 验证:`frontend/tests/rectification-dup-question-20260926.test.tsx` 挂载真实 `RectificationAgenticChat`、假 `fetch` 走实时路径:打字回答命中选择题(流 = `composeCollectSpokenAssistantText(ack, stem)`)与采集拒答(流 = 真实 `persistCollectDenialTurn` 输出)两条,各断言可见文本里题干只出现 1 次、4 个选项可点;修复前两条都是 2 次。浏览器同场景:修复前可见题干 2 次(`A-before-1280.png`),修复后 1 次(`A-after-1280.png`)。`rectification-answer-choice.test.ts`、`account-dialog-inert-20260918.test.ts` 在服务端断言后追加客户端合并断言。浏览器:`docs/testing/rectification-dup-question-20260926/`。
|
||||
- 防复发:确定性回复若把题干拼进正文,客户端结算后的快照合并必须去掉同一句——回归测试走「流式 → 结算 → 合并」而不是只测 GET 组装。任何改「题干是否进正文」的提交必须同时有实时路径断言。
|
||||
- 相关记录:BUG-585、BUG-969、BUG-488、BUG-525。
|
||||
- 修复版本:`codex/rectification-dup-question-20260926`,未部署。
|
||||
|
||||
## BUG-1046 | 点选一道选择题后,同一道题又完整画了一张(未选中)
|
||||
|
||||
- 状态:investigating(H1 在虚构数据上已复现并修复;staging 真机当时的 POST 状态码未取证,H1/H2 以哪条为准待真机日志;未部署)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:`rectification-agentic-chat.tsx` `submitStructuredChoice` 失败分支、`send` 失败分支、`persisted_question` 兜底块;`rectification-snapshot-messages.ts` `appendUnseenAssistantTurns` / `placePersistedQuestion`。
|
||||
- 用户现象:产品 09-26 staging 真机:第 N 轮点选「B 发生过但程度较弱」(高亮),下面是操作按钮、「目前范围 14:35-15:05,还在核对」,再下面又出现完全相同的题干和四个选项(未选中)。
|
||||
- 触发条件与根因:
|
||||
- H1(虚构数据已复现):`submitStructuredChoice` 先 `markQuestionAnswered` 本地标 B 再 POST;`!response.ok` / `catch` 只移除占位、`choiceNonce++`、`setError`,不撤回已答标记、不重读快照。最新助手消息的题变成「已答」→ `liveQuestionOnMessages` 为假;快照里的 `current_question` 仍是这道题 → 缺口判定 `persisted_question` → 独立问题块再画一张可点卡。409 `stale_question` 与网络错误两种失败在修复前都得到「题干 2 次、两张卡」。打字回答失败(`send` 的 `!response.ok` / 网络错误)同型:`typed` 标记也不撤回。
|
||||
- H2(虚构数据已复现机制):点选成功、`willContinue` 分支只跑 `mergeTurnQuestions`,不跑 `appendUnseenAssistantTurns`(BUG-685 在 `requestTieBreak` 修过同型缺口);`send()` 结算后同样只合并。服务端另写的、承载下一题的回合进不了对话区,兜底块替它把题画出来。若题目完全相同还意味着选题重复:BUG-540(`pickProbe` 先 `probe_id` → `semantic_key`)、BUG-559 / 592(`sameYearProbeAsked`、`same_year_asked` 硬排除)守卫已核对仍在代码里;本单未改选题。
|
||||
- 兜底块条件过宽:BUG-678 的意图是「只在没有助手消息时兜底」,但闸门没强制;BUG-635 又规定旧消息上的同 `focus_id` 不算仍在显示,于是旧消息一张 + 兜底一张。
|
||||
- 修复:D2:选择题提交失败撤回本次本地标记(题回到 active、无 `answer_option`),卡片 key 随之变化重新挂载,重读快照(`applySnapshotTurnsToMessages`),错误行统一「这次没提交上,请再点一次。」(VOICE 已加);打字回答失败同样撤回 `typed` 标记。D3:`placePersistedQuestion` 把快照当前题挂到最新一条已结算助手消息上,并摘掉其它消息上同 `focus_id` 的副本;独立问题块只在没有助手消息、或最新助手消息已挂着另一道题(不同 focus,不会重复)时出现;缺口状态机本身不改。D4:`willContinue` 分支在占位行之前并入未见回合,`send()` 成功结算后改用 `applySnapshotTurnsToMessages`。
|
||||
- 验证:`frontend/tests/rectification-dup-question-20260926.test.tsx`:409 `stale_question` 与网络错误各一条(一张卡、0 个已选、4 个可点、错误文案、快照重读、可再次提交);打字失败一条;旧消息同焦点挪到最新消息一条;无助手消息时兜底一次一条;`willContinue` 未见回合并入且位于跟进行之前、`send()` 结算并入额外回合且不重复各一条。本文件 10 条在 `origin/staging`(`509987b9`)代码上 9 条失败(「无助手消息兜底」一条本来就对)。浏览器(`next start` + Chrome 151 无头,CDP 回放虚构接口):点 B、POST 回 409 `stale_question`,修复前可见题干 2 次、2 张卡、B 仍高亮、兜底块 1 个、错误行是服务端原句——与真机截图同形(`B-before-1280.png`);修复后 1 张卡、0 个已选、4 个可点、错误行「这次没提交上,请再点一次。」(`B-after-1280.png`)。
|
||||
- 防复发:任何先改本地状态再发请求的动作,失败分支必须撤回并重读;同一 `focus_id` 在对话区只能画一张卡;新写回合的前端动作一律并入对话区(BUG-685)。
|
||||
- 相关记录:BUG-675、BUG-678、BUG-685、BUG-635、BUG-917、BUG-540、BUG-559、BUG-592。
|
||||
- 复发自:BUG-678(兜底块「只在没有助手消息时」没有闸门);D4 同型于 BUG-685。
|
||||
- 修复版本:`codex/rectification-dup-question-20260926`,未部署。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# PROGRESS · 生时校正:同一轮问题两次 + 同一道题两张卡(2026-09-26)
|
||||
|
||||
- 任务书:`TASK-rectification-dup-question-20260926.md`
|
||||
- 执行:Claude 子代理(直接执行模式,产品授权),分支 `codex/rectification-dup-question-20260926`,worktree `.worktrees/rectification-dup-question-20260926`
|
||||
- 基线:开工 `ac224cef`;BUG-1042 合入后变基到 `origin/staging` `509987b9`(按任务书「以新 head 为准」)。本单未推送。
|
||||
- BUG:BUG-1045(复发自 BUG-585)、BUG-1046;开工时 staging 最大号 BUG-1044,核对无冲突。状态均 investigating(真机与部署未做)。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 项 | 实现 | 位置 |
|
||||
|---|---|---|
|
||||
| T1 / D1 实时去重 | `mergeTurnQuestions` 在快照回合带问题时对正文跑 GET 同款 `stripQuestionSentences`,幂等。服务端 BUG-969 兜底文本**不改** | `src/lib/rectification-snapshot-messages.ts` |
|
||||
| T2 / D2 失败回滚 | `submitStructuredChoice` 的 `!ok` 与网络错误:撤回本次本地标记(`withdrawAnswerMark`,题回 active、无 `answer_option`)、移除占位、`choiceNonce++`(卡片 key 变化 → 重新挂载、清掉卡内本地选中)、重读快照(`applySnapshotTurnsToMessages`)、错误行「这次没提交上,请再点一次。」(`RECTIFICATION_CHOICE_SUBMIT_FAILED_COPY`,VOICE 已加一行)。`send` 打字回答的 `!ok` / 无 body / 网络错误同样撤回 `typed` 标记 | `rectification-agentic-chat.tsx`、`rectification-surface-state.ts`、`docs/VOICE.md` |
|
||||
| T2 / D3 兜底闸门 | `placePersistedQuestion` + `turnQuestionFromCurrent`:缺口为 `persisted_question` 时,把快照当前题挂到最新一条已结算助手消息上,其它消息上同 `focus_id` 的副本摘掉;独立问题块只在没有助手消息、或最新助手消息已挂另一道题时出现。缺口状态机、`liveQuestionOnMessages` 只看最新消息(BUG-635 合同)都不改 | `rectification-snapshot-messages.ts`、`turn-question.ts`、`rectification-agentic-chat.tsx` |
|
||||
| T3 / D4 漏收回合 | `willContinue` 分支用 `appendUnseenAssistantTurns(..., { beforeRenderKey })` 把未见回合插在跟进行之前;`send()` 成功结算后改用 `applySnapshotTurnsToMessages` | 同上 |
|
||||
| T4 记录 | BUG-1045 / 1046、CHANGELOG、DESIGN 状态表两行、VOICE 一行、真机清单、状态板 | `docs/` |
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
1. **D3 的一个例外**:最新助手消息已经挂着*另一道*题(不同 `focus_id`)时,当前题无处可挂,仍用独立问题块。这时不会重复同一 focus;硬挂到更早的消息会让题出现在已答题上方、打乱顺序。任务书写的是「兜底块只在没有任何助手消息时渲染」,此处放宽一格,写进 DESIGN 状态表。
|
||||
2. **BUG-635 合同保留**:旧消息上的同焦点仍不算「仍在显示」(`liveQuestionOnMessages` 只读最新消息,源码合同未动);D3 的做法是把题**挪到**最新消息、摘掉旧副本,所以「只画一张」与 BUG-635 同时成立。
|
||||
3. **最新消息上同焦点但本地已答、服务端仍 active** 时(失败未回滚的残留),D3 用服务端的 active 题替换本地标记。只在缺口为 `persisted_question`(非 busy)时发生,不会在提交途中闪回。
|
||||
4. 为不改 7 处按 `{messages.map((message) => {` 切片的源码合同,渲染循环保留 `messages.map`,循环内按 `renderKey` 取放置后的消息(`entryMessage`)。
|
||||
5. `rectification-agentic-chat.tsx` 注释里提到 `requestTieBreak` 会撞两条禁用该标识符的源码合同,已改写措辞(第一次全量跑出 2 条新失败,修正后通过)。
|
||||
6. 未改 `use-conversation-scroll-anchor.ts`(另一单在改)。H2 若真机确认服务端出了完全相同的题:BUG-540 / 559 / 592 守卫已核对仍在(`pickProbe` 先 `probe_id` → `semantic_key`;`sameYearProbeAsked` / `same_year_asked` 硬排除),本单不改选题。
|
||||
|
||||
## 改动的既有断言
|
||||
|
||||
没有改任何既有断言。`rectification-answer-choice.test.ts`(BUG-969 改成「正文含题干」的那条)与 `account-dialog-inert-20260918.test.ts` 的服务端断言保持原样——服务端文本没变,按任务书只**追加**客户端合并断言(正文不再含题干、问题块是这道题)。DESIGN.md 状态表改写时保留了 `rectification-unwritten-evidence.test.ts` 锁定的两句。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,126 warnings(与基线 126 相同;改动文件无新增) |
|
||||
| 新测试 `tests/rectification-dup-question-20260926.test.tsx` | 11/11 通过;挂载真实 `RectificationAgenticChat`,假 `fetch` 走「流式 → 结算 → 快照合并」。前 10 条在 `509987b9` 代码上 9 条失败(「无助手消息兜底」本来就对) |
|
||||
| 全量 `npm test`(Node 20.19.2,无 Docker) | 3972 条:3884 通过 / 61 失败 / 27 跳过。失败名单与基线 `test11.log`(3960 / 61)**逐条一致**,新增失败 0;测试名消失 0、新增 12(本单 11 条 + BUG-1042 合入的 1 条) |
|
||||
| `npm run build -- --webpack` | 通过;`┌ ○ /` Static |
|
||||
| 首屏 gzip | `rootMainFiles` 4 个文件 130933 B → 130933 B(0%);`index.html` 引用的 36 个静态文件 656741 → 657563 B(+0.13%) |
|
||||
| 浏览器(`next start` + Chrome 151 无头,CDP 回放虚构 `/api/*`) | 见下表 |
|
||||
|
||||
浏览器,同一份虚构数据,基线 `509987b9` 构建 vs 本分支构建(1280 宽;计数排除 `legend.sr-only`):
|
||||
|
||||
| 场景 | 修复前 | 修复后 | 截图 |
|
||||
|---|---|---|---|
|
||||
| A 打字回答命中选择题 → 流 ack+stem → 快照挂卡 | 下一题可见 2 次 | 1 次;4 个可点选项 | `docs/testing/rectification-dup-question-20260926/A-before-1280.png` / `A-after-1280.png` |
|
||||
| B 点 B → POST 409 `stale_question` → 快照不变 | 题干 2 次、2 张卡、B 仍高亮、兜底块 1 个、错误行为服务端原句 | 1 张卡、0 个已选、4 个可点、错误行「这次没提交上,请再点一次。」 | `B-before-1280.png` / `B-after-1280.png` |
|
||||
|
||||
B 修复前截图与产品真机截图同形(已选 B 的卡 → 操作按钮 → 同题空白卡),即 H1 在虚构数据上可复现。截图右下「合盘历史暂时无法读取」是回放脚本没 mock `/api/synastry-reports`,与本单无关。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无登录态真机、无真实模型:真机清单 `docs/testing/rectification-dup-question-20260926.md`。H1 / H2 以哪条为准需真机当时 `/api/rectification/agent` 的状态码。
|
||||
- Node 20:`mock.module` 类测试 ERR_MODULE_NOT_FOUND;无 Docker:DB 套件。失败清单与基线逐条一致(61 条)。
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rectification-dup-question-20260926.md` | — | **同一轮问题出现两次(BUG-1045,复发自 BUG-585,BUG-969 拼回题干、去重只在刷新路径)+ 同一道选择题连画两张(BUG-1046:提交失败不回滚本地已答 + 兜底问题块条件过宽;H2 漏收回合)**。先于 latency 单 | 待领取 | — |
|
||||
| `TASK-rectification-dup-question-20260926.md` | `PROGRESS-rectification-dup-question-20260926.md` | **同一轮问题出现两次(BUG-1045,复发自 BUG-585,BUG-969 拼回题干、去重只在刷新路径)+ 同一道选择题连画两张(BUG-1046:提交失败不回滚本地已答 + 兜底问题块条件过宽;H2 漏收回合)**。先于 latency 单 | 待验收 | `codex/rectification-dup-question-20260926`(未推送;BUG-1045/1046) |
|
||||
| `TASK-rectification-latency-20260926.md` | — | **每轮等待过长(BUG-1047)**:一轮打字回答串行 5 次开思考的模型调用,分类在开流前且无超时。产品定:收尾两步不动、分类保持思考只加 10 秒超时、发出后立即出确定性进度句并按阶段更新;补埋点;服务端无口吻优化须 A/B 逐位一致。排在 dup-question 之后 | 待领取 | — |
|
||||
| `TASK-rectification-session-title-result-20260922.md` | `PROGRESS-rectification-session-title-result-20260922.md` | **校正会话标题改写结果(BUG-1001)**:BUG-988 把副标题改成最后活动时间后,标题里的日期成了重复;而 BUG-929 删掉 `uniquifySessionTitle` 之后,`resolveSessionTitle` 对校正入口只返回 `生时校正 · M月D日`,**同一天多条标题完全相同**(真机截图:9/17 三条同名、9/16 两条同名、9/14 一天 5 条);旧标题的钟点后缀是创建时间、副标题是最后活动时间,同一行两个对不上的时间。**产品 09-22 拍板**:D1 标题改为承载结果——有 `accepted_time`/`confirmed_time` 写 `生时校正 · HH:MM`,否则写 `candidate_range` 的 `生时校正 · HH:MM–HH:MM`(推翻 BUG-929 的日期口径,但「不得再加墙钟去重后缀」保留);D2 存量批量重算(推翻 BUG-929「旧标题不批量改」,先例 `20260916020000_rectification_session_title_repair.sql`);D3 今日节奏保留日期(我判定的例外);D4 只在结果变化时改 title,`open` 路径不动(对 BUG-699 边界的有限扩展)。**红线**:任何写标题的路径都不得 bump `updated_at`(否则 13 条历史会话集体跳顶、毁掉 BUG-988);标题算式必须只有一处实现(一个 SQL 函数,触发器与回填共用)——BUG-987/992 都栽在「两层规则各写一份」。T4 跨层对断不得让步。已 grep 出真正受影响的只有 7 个文件,Python 侧只查裸字符串、无需改。BUG 段 1001 起 | 已验收(Claude 09-25:单一 SQL 函数、不动 updated_at、手改名不覆盖、回填幂等) | `88fe67df`/`a94a1d67`(已部署) |
|
||||
| `TASK-rectification-convergence-20260830.md` | `PROGRESS-rectification-convergence-20260830.md` | 收敛重构 v2 | 已验收 | `3a4396a4`、`86ba17ee` |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# 生时校正:同一轮问题两次 / 同一道题两张卡 · 真机清单(2026-09-26)
|
||||
|
||||
本轮在本地 `next start` + 无头 Chrome 里用虚构接口数据截过图(`rectification-dup-question-20260926/`),没有登录态真机,也没有真实模型。下面留给真人,用自己的账户在 staging 上照做(部署后):
|
||||
|
||||
1. 开一次生时校正,答到出现 A/B/C/D 选择题。**不点选项**,在输入框打字回答(例如「有,2023 年 3 月」)。这一轮回答里:正文只有「已记录……」一类的话,下一题的题干只出现一次(在选项卡上方),不会正文里一遍、卡上又一遍。
|
||||
2. 采集题(「还有吗?比如……」)下打「想不到了」。如果下一步是选择题:题干同样只出现一次。
|
||||
3. 第 1、2 步各刷新一次页面:刷新前后看到的内容一致(都是一遍)。
|
||||
4. 选择题下点一个选项的同时断网(手机开飞行模式,或电脑拔网线后立刻点):刚点的选项取消高亮,页面只有一张可以点的卡,下面一行「这次没提交上,请再点一次。」。恢复网络后再点一次,正常进入下一步。不会出现「上面一张已选、下面又一张空白」的两张卡。
|
||||
5. 同一局里连答五六道选择题:每道题只画一张卡;「目前范围 …,还在核对」下面不会再出现一张同样题干的卡。
|
||||
6. 手机宽度(375px)上重复第 1、4 步:卡片与错误行不溢出、不遮挡输入框。
|
||||
|
||||
如果第 4 步真机复现了两张卡,请把当时浏览器开发者工具 Network 里 `/api/rectification/agent` 那次请求的状态码记下来(不需要请求内容),用于判定 BUG-1046 的 H1 / H2。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Reference in New Issue
Block a user