Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
11 KiB
TASK · 生时校正修复单:同一轮旁白说两遍、题干在正文与问题块各出现一次(2026-09-07)
- 基线:
origin/staging@f3e72702(代码头ab57d03f,含 BUG-583 未修的 tsc 错;staging 已部署84a29754,事故在多轮实测中稳定复现) - 分支:
codex/rectification-duplicate-narration-20260907,基于origin/staging。与codex/rectification-range-delivery-fix-20260907(BUG-583,只改rectification-agentic-chat.tsx)无文件重叠,可并行;谁后合谁 rebase。 - 执行方:coding agent;验收:Claude
- 涉及文件:
frontend/src/lib/rectification-agentic/v9/step-answer.ts、v9/agent-run.ts、v9/collect-prompt.ts、v9/turn-question.ts、frontend/tests/rectification-step-answer.test.ts、rectification-collect-prompt.test.ts - BUG 编号起点:BUG-584(BUG-583 已由区间卡修复单占用;开工时
grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1复核) - 优先级:P1(每一轮采集都复现;用户读到两段近似旁白 + 两遍题干,以为系统出错)
1. 事故实证(staging,多次实测;只写结构)
一条助手消息自上而下是:
- 旁白第一段:承接用户刚说的事,说范围/对照情况,末尾把下一题问了一遍(自然语言,含年份与领域,以「?」结尾)。
- 旁白第二段:换个说法把第一段又说一遍(有时更短,有时又带一遍题)。
- 「本轮对照了 …」方法句(客户端
vargaSentenceFromMethods渲染)。 - 问题块:服务端
ConversationFocus.spokenPrompt的题干,与第 1 段末尾那句同义或逐字相同。
2. 根因(两条,叠加)
2.1 BUG-584(P1):set-focus 前后两个 step 的正文都被放行并拼接
step-answer.ts 的规则:文本先于 public 工具调用的算「规划稿」,工具调用时 retractLive 撤回。但 KEEP_LIVE_SPOKEN_TOOLS = {rectification-set-focus} 是例外(a793edb1,BUG-533:为了不把开场问候撤掉)——set-focus 之前已直播的中文保留,set-focus tool-result 只 resetBuffers 不撤回;随后模型在下一个 step 看到 set-focus 结果再写一段正文,step-finish(reason=stop) 走 shouldPublishStepText 正常发布。agent-run.ts 的 publishSpokenStep 是 spokenRaw += spoken,两段正文无分隔、无去重直接拼成 answerText,落库为 assistant_message。
固定这个行为的正是测试 rectification-step-answer.test.ts::keeps a live opening greeting when the next public tool is set-focus:它断言 set-focus 之后的第二段也 live 发布。开场轮两段短句无伤;证据轮模型在 set-focus 前先总结一遍、拿到结果后又总结一遍,就是用户看到的"说了两遍"。receipt 里每轮两个 intent.classified 是同一件事的旁证。
附带损失:set-focus 前尚未触发直播(没遇到句末标点)的文本、以及已直播部分之后 publishedUpTo 以外的尾巴,在 tool-result 的 resetBuffers 里被静默丢掉——这是偶发"旁白半句"的来路。
2.2 BUG-585(P1):题干去重只认逐字后缀,模型写进正文的问句剪不掉
服务端只在 turn-question.ts::attachQuestionsToTurns 用 detachCollectSpokenAssistantText(turn.text, question.prompt) 剥题干,而它只剥两种形态:正文整体 === 题干,或正文以 \n\n${题干} 结尾。模型自己在正文里问出来的句子(同义改写、或逐字但嵌在段落中间)一概保留;问题块再从 focus 渲染一遍 → 同一题两遍。composeCollectSpokenAssistantText 里那套「按题干前 12 字剪句」只用于拒答确认句(answer-choice.ts L1059),agent 路径根本没走。
不让模型提问的约束只有提示词:SKILL.md L59「正文……不得自行提出、复述、改写或预告问题」、set-focus 描述「你只写 spokenPrompt」、opening brief「不要提问」。BUG-488 的防复发写的是「不得同时让模型提问又拼接同一句服务端题干」,靠"模型仍不自己提问"兜住;d9404976 把题干改成消息内问题块后,这条防线没有任何代码守卫,于是复发。
另外 agent-run.ts L284 const collectSpokenEmitted = false 是常量,只被原样透传进结果,没有任何读者——它就是当年打算做"题干已出、正文别再出"守卫的残骸。
3. 决策记录
- 有服务端问题的轮次,正文不得含问句;含则服务端剪掉。 这是把 SKILL.md L59 从提示词升级为代码守卫:
answer.composed之前,若本轮 set-focus 成功(focus.askedTurnId = 本轮),对answerText做stripQuestionSentences(body, stem):按。!?\n切句,删掉 (a) 与题干相同、(b) 以题干前 12 字开头、(c) 以「?」/「?」结尾 的句子。剪空则用一句固定承接(RECTIFICATION_USER_COPY新增collectHandoff,对照VOICE.md,不得说"请回答下面的问题"之类机器话)。剪后文本以answer.delta replace:true再发一次,保证直播与落库一致。选择题轮(choice_frame)同样适用——选项由卡片承载,正文不问。 - set-focus 之后不再接受新一段正文。
step-answer.ts:set-focustool-result后置state.stemAttached = true(随 turn 存活,不随resetBuffers清);此后 step 的text-delta只有在本轮此前没有发布过任何正文时才放行(保住 BUG-533 的开场问候路径:问候在 set-focus 前,或模型只在 set-focus 后说一句),否则discard。两段都有时保留前一段——它是模型对刚才证据的直接回应;后一段只是对工具结果的复述。 - set-focus 前的未直播文本不得静默丢弃。
tool-call(set-focus)时把state.text未发布部分作为publish效果发出(它是给用户的话,不是规划稿),再resetBuffers。若该文本没有 CJK(英文规划稿),维持原 discard。 - 删除
collectSpokenEmitted常量与其类型字段。 - 提示词不动、Skill 不 bump(不是口径变化,是守卫补齐)。不动采用门、确认门、
MIN_SEPARATION_LEAD、_relative_support、minute_step=1指纹;不动frontend/src/app/page.tsx。
4. 任务分解
4.1 BUG-584:单段正文
step-answer.ts按决策 2、3 改;StepAnswerState加stemAttached、publishedAny(后者由agent-run.ts在首次publishSpokenStep成功后写回,或由 state 自记)。- 改既有测试
keeps a live opening greeting when the next public tool is set-focus:原值——set-focus 后第二段live发布;新值——第二段discard;原因——决策 2。新增:(a) set-focus 前无正文、后有一段 → 后段live/publish(BUG-533 路径仍通);(b) set-focus 前有未闭合半句「范围已经收到 05:00 到 05:10」(无句末标点)→tool-call(set-focus)时以publish发出,不丢;(c) 英文规划稿 + set-focus → 仍 discard。 agent-run级:rectification-v9-agent-run*.test.ts(现有 mock stream 夹具)构造「文本 → set-focus → 文本 → finish」序列,断言answerText只含第一段,answerDeltas无第二段,且没有replace:true的清空事件。
4.2 BUG-585:正文剪问句
collect-prompt.ts新增stripQuestionSentences(body, stem)(复用composeCollectSpokenAssistantText的切句与前缀规则 + 问号规则),composeCollectSpokenAssistantText内部改调它,行为对拒答句路径不变(既有rectification-collect-prompt.test.ts全部保持)。agent-run.ts:流结束、answer.composed之前,读本轮 focus(loadV9CaseDossier已在discriminatorInvariant里做过,复用那次读取,不加第二次 RPC);askedTurnId === turnId且 schema 里有 spokenPrompt → 剪;剪空 →collectHandoff;变化时emitVisibleSpoken(stripped)(它自会发replace:true)。turn-question.ts::attachQuestionsToTurns同样改调stripQuestionSentences,让历史消息回看也干净(老数据不迁移)。- 验收:单元——(a) 正文「…范围收在 05:00–05:10。你大概哪一年搬过家?有年份就行。」+ 题干「你大概是哪一年搬的家?」→ 剪成「…范围收在 05:00–05:10。」;(b) 正文中段逐字含题干 → 剪掉那句,前后句保留;(c) 正文只有问句 →
collectHandoff;(d) 无本轮 focus → 原样。合同——agent-voice-copy-contract增加「有 focus 的已结算助手正文不得以『?』结尾的句子出现」(对docs/testing之外的夹具跑)。
4.3 记录
docs/BUG_HISTORY.mdBUG-584(关联 BUG-533:其修复放行了第二段)、BUG-585(复发自 BUG-488:防线只在提示词;关联 BUG-461、BUG-471、d9404976);CHANGELOG.md一条;PROGRESS-rectification-duplicate-narration-20260907.md;docs/testing/rectification-scenarios-20260907.md加一条「每轮助手气泡只有一段旁白,题干只在问题块出现一次」。
5. 让步顺序
4.2 是用户直接看到的"两遍题",先做;4.1 次之;决策 3(半句不丢)可后置为 P3 但要在进度记录里写明;4.3 不可省。
6. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-duplicate-narration-20260907 .worktrees/rectification-duplicate-narration-20260907 origin/staging
cd .worktrees/rectification-duplicate-narration-20260907
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
开工时 tsc 若仍报 rectification-agentic-chat.tsx L1655(BUG-583),先确认 codex/rectification-range-delivery-fix-20260907 是否已合入;未合入则本单不修它,进度记录里写清基线含该错。
验收(Claude,2026-09-08,origin/staging @ df182c16,staging 已部署同 SHA)
| 门 | 结果 |
|---|---|
| tsc | 0 错 |
| lint | 0 error / 102 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB) | 1253 / 0 |
| Python v5_services + growth contract | 全绿(执行方记的 15s 壁钟用例本机未复现超时) |
| Docker DB 套件、浏览器走查 | 环境缺口,同执行方记录 |
| 项 | 结论 |
|---|---|
| 4.1 BUG-584 | 通过。stemAttached/publishedAny 守卫;set-focus 后第二段 discard;半句 publish;三栏齐全。2026-09-08 真实 Case 每轮只剩一段旁白 |
| 4.2 BUG-585 | 通过。stripQuestionSentences 在 agent-run 与 attachQuestionsToTurns 两处生效,collectSpokenEmitted 删除。真实 Case 正文不再含问句 |
| 4.3 记录 | 通过 |
结论:通过。