探针池空后判别题盖不上 probe,焦点写不进库,上一单 D3 把题干念进正文导致 同一道题反复问且回答不算数(BUG-674,本单 D1 推翻该决策,改走定向补事卡)。 快照已有 choice_card 时 persisted_question 仍只画一行裸题(BUG-675)。同一轮 receipt 的 representative_time 落在 eliminated_ids 内,先取证加守卫(BUG-676)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
18 KiB
TASK · 定向补事题被存成口述题,裸题顶掉卡片 — 2026-09-13
- 基线:
origin/staging@007a05fe(最近一次含门禁路径改动的提交是14d199e6,staging 已部署该 SHA;其后均为纯文档提交)。 - 分支:
codex/rectification-targeted-collect-spoken-focus-20260913,worktree.worktrees/rectification-targeted-collect-spoken-focus-20260913。 - 关联:BUG-661(定向补事必须逐条点选)、BUG-669(快照投影拿不到 choice_card)、BUG-670(承接焦点分支让模型改写定向题)、BUG-671(选择题无卡时不得停在采集等待态)。本单是 BUG-670 的复发:用户现象完全一致,但触发路径不经过模型,670 的识别条件拦不住。
- 串行:本单改
method-followup.ts、server-focus.ts、answer-choice.ts。开工前git log --oneline origin/staging -20确认没有同文件在途分支;若 09-10 采集重设计单(BUG-646~648)或 09-11 定向补事单(BUG-661~663 后续)正在改同一批文件,本单排在其后。
1. 事故实证(产品负责人 staging 真机,2026-09-13)
现象
校正采集阶段,用户答完一道带年月的关系题(选 C「明确没有发生」)后,屏幕依次出现:
- 正常助手气泡:
已记录,范围收到 04:48–05:07。 - 浅色状态行:
目前范围 04:48–05:07,再说一件带年月的事就能继续(时间轴只读范围行,rectification-surface-state.ts:380rectificationReadonlyRangeCopy,设计内,非缺陷) - 一行没有头像、没有 A/B/C/D 卡片的粗体裸题:
家里添过丁或长辈住过院吗?
用户可见后果:这道题看起来和前面所有题"UI 不一样",无法点选;且本轮服务端原本计划的那道 D9 关系题彻底消失。
实证 1 · 那句题干只可能来自定向补事卡
frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts:502:
const TARGETED_EXISTENCE_PROMPT: Readonly<Record<CollectKind, string>> = {
…
family: "家里添过丁或长辈住过院吗?",
全仓唯一出处,只被 targetedCollectPool → targetedCollectExistenceFollowup → buildTargetedCollectExistenceFrame 使用。按 BUG-661,它必须以四选一卡出现(A 有过这件事 / B 没有发生过 / C 记不太清楚 / D 这条先跳过,scoring:false)。
实证 2 · 本轮服务端根本没打算问这道题
choice.applied 响应(产品负责人提供,已脱敏)关键字段:
| 字段 | 值 | 含义 |
|---|---|---|
nextChoiceReady |
false |
服务端没有准备好可点卡 |
nextInterviewPersisted |
true |
服务端自认"下一题已就绪" |
next_user_action.id |
ask_method_followup |
|
next_user_action.user_meaning |
d9_refine 模板("关系盘仍会换升…本题绑定 D9…点选卡只出 A/B/C/D") | 本轮计划的下一题是 D9 关系题,不是家庭题 |
session_outcome / precision_stage |
collect_evidence / collect_events |
nextInterviewPersisted: true 会让前端不再追一轮 Agent:
// frontend/src/lib/rectification-agentic/v9/choice-action.ts:132
export function shouldContinueAfterStructuredChoice(…) {
if (receipt?.nextInterviewPersisted) return false;
于是本轮既没有新题进消息,也没有新焦点落库,屏幕上那道家庭题是上一段遗留的活动焦点被快照渲染出来的。
实证 3 · 裸题渲染是前端的 persisted_question 兜底块
frontend/src/lib/rectification-surface-state.ts:309 rectificationQuestionGapState 的判定顺序里,questionPersisted 一旦为真就返回 persisted_question;组件按该状态只画题干:
// frontend/src/components/rectification-agentic-chat.tsx:1868
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
无头像、无卡、无修复入口——与截图逐像素对应。BUG-671 的兜底只覆盖 kind === "choice" 且无 choice_card(interviewChoiceCardUnavailable,rectification-surface-state.ts:350);这次快照里的 current_question.kind 是 collect_spoken(turn-decision.ts:72 projectCurrentQuestion 对采集型 schema 的投影),被当成"合法口述题"放行。
2. 根因
一道定向补事题以"采集型 schema"存在于会话焦点里,而系统没有任何一条路径能把它复原成卡片。
采集型 schema 形如 { prompt, collect: true, collect_kind: "targeted:family" }(server-focus.ts:321 collectFocusSchema),其 prompt 取 spoken_prompt;定向题在没有"现在还剩 … 个候选"那句时 spoken_prompt 就等于存在题题干本身 —— 正好是截图那句。
P0-a 写入侧:三条路径会把定向题降级成采集型 schema
| 位置 | 降级动作 |
|---|---|
server-focus.ts:337 serverOwnedExpectedAnswerSchema |
有 choice_frame 但 expectedAnswerSchemaFor 没产出 .choice 时,对采集意图直接 collectFocusSchema(spokenCollectFallbackFollowup(followup)) |
frontend/src/mastra/rectification-v9-tools.ts:1303 rectification-set-focus |
同样的口述兜底,写进焦点 |
server-focus.ts:645 persistSkippedCollectFocus |
先用采集型 schema 写一条 active 焦点(会 supersede 真正的活动焦点),再调 resolveV10ConversationFocus 置 skipped;第二步失败(异常被外层 catch 吞掉)就留下一条活动的口述定向焦点 |
P0-b 复原侧:BUG-670 的识别条件覆盖不到采集型 schema
// frontend/src/lib/rectification-agentic/v9/method-followup.ts:2231
const targetedCopy = isTargetedCollectChoiceSchema(focus.expectedAnswerSchema) ? … : null;
// frontend/src/lib/rectification-agentic/v9/choice-card.ts:546
export function isTargetedCollectChoiceSchema(value: unknown): boolean {
return (value as { targeted_collect?: unknown }).targeted_collect === true
&& parseAgentChoiceCopy(value) !== null;
}
采集型 schema 既没有 targeted_collect: true(那是 expectedAnswerSchemaFor 只在写点选 schema 时盖的章),也没有可解析的选项 → 重建 choice_frame 的分支永不触发。焦点一旦以口述形态落库,本轮、下一轮、刷新之后都还是裸题。
P1 卡片落不了库时本轮的题被静默吞掉
answer-choice.ts:1154 persistNextInterviewAfterChoice 尾部:
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
…
return { hostNarration: spoken, choiceReady: false, followup }; // 没有 persisted 字段
followup 带 choice_frame 时 spokenFollowupForUser 返回的是卡片题干字符串。焦点落库失败(zero_information_gain / probe_already_answered / duplicate_focus / invalid_choice_schema)时就走到这里。applyStructuredChoice(answer-choice.ts:2036 起)据此:
skippedNextInterview = nextInterview.persisted === false→undefined !== false→ false- 只有
accepted / skipThisProbe / skippedNextInterview / 定向卡就绪四种情况才会把hostNarration并进本轮旁白 → 题干没有进消息 - 但
if (nextInterview.hostNarration) nextInterviewPersisted = true→ 前端不再追一轮 keptNextQuestion(answer-choice.ts:2109)为真 →next_user_action仍然引用这道既没进消息也没进焦点的题
即"焦点没写成功"与"题已经问出去了"这两件事共用了一个 hostNarration 非空的判断。
P2 前端兜底不对称
BUG-671 只拦 kind === "choice" 无卡;题号以 collect:targeted: 开头的题以 collect_spoken 形态出现时没有任何校验,直接裸画。
3. 决策记录
| 决策 | 内容 |
|---|---|
| D1 | 定向补事题只有点选一种形态。 写入侧禁止降级:collection_key / question_id 以 collect:targeted: 开头(或 kind_hint 以 targeted: 开头)时,任何口述兜底都必须先用 buildTargetedCollectExistenceFrame 重建 frame 再写点选 schema;重建不出来就不写焦点(返回 skipped),不得写采集型 schema。此处推翻"采集意图一律可降级为口述"的既有兜底惯例,执行方不得以"兜底总比没有好"为由保留。 |
| D2 | 复原侧按题号识别,不再只认 targeted_collect 章。 method-followup.ts:2231 的承接分支增加一条:焦点 intent === "collect_method_evidence" 且(question_id 以 collect:targeted: 开头 或 collect_kind 以 targeted: 开头)且 schema 是采集型(isCollectFocusSchema)时,用 schema.prompt 当题干重建 frame。这条是存量 case 的唯一救法(线上已经存在这样的焦点行,只修写入侧救不回来)。 |
| D3 | (已被 TASK-rectification-unstampable-probe-and-naked-card-20260914.md 的 D1 推翻:自 2026-09-14 起只适用于采集题,判别题落不了库一律转定向补事卡) 题没落库就必须进旁白。 persistNextInterviewAfterChoice 尾部改为显式 persisted: false,让 applyStructuredChoice 把题干拼进本轮旁白。不改成"让前端再追一轮 Agent"——那会多烧一次模型、多一次揭幕,违反"一次等待一次揭幕"。 |
| D4 | persistSkippedCollectFocus 不得留下活动焦点。resolve 失败时必须把这条焦点补置 skipped(重试一次)或整体回滚,不允许一条 status=active 的口述定向焦点活到下一轮。 |
| D5 | 不动数据库结构、不动迁移、不动 deploy/** 与 workflow。不放宽 BUG-661(定向题必须逐条点选、四选项固定)、BUG-662(不得写范围两端钟点)、BUG-669、BUG-671 的任何红线。 |
4. 硬红线
./node_modules/.bin/tsc --noEmit0 错;npm run lint0 error;npm test失败清单与开工时origin/staging的无 Docker 基线逐条一致(新增测试只许让通过数上升);next build后/仍○ Static;首屏 gzip ±2%。- 测试总数不得低于开工时实测;改任何既有断言写"原值 / 新值 / 原因"三栏。
- 定向存在题的题干与四个选项仍是服务端自有,模型不得改写(BUG-670 防复发条维持)。
frontend/src/app/page.tsx不动;不新增依赖;不顺手修不在本单里的 warning。- 不得用"把
persisted_question块画成卡片"来绕过:数据层是采集型 schema 时前端不得假装有卡。
5. 任务分解
任务 1 · 写入侧:定向题不许降级成口述(P0)
frontend/src/lib/rectification-agentic/v9/server-focus.ts- 新增
isTargetedCollectFollowupLike(followup)(或复用method-followup.ts已有的isTargetedCollectFollowup)判定。 serverOwnedExpectedAnswerSchema(l.337):定向题走expectedAnswerSchemaFor失败时,用buildTargetedCollectExistenceFrame({ questionId, methodId, prompt })重建 frame 再取一次点选 schema;仍失败则返回null,不得collectFocusSchema(spokenCollectFallbackFollowup(...))。persistServerOwnedFocusCore(l.513):定向题落到!frame分支时同样先重建 frame,不得直落persistCollectFocus。
- 新增
frontend/src/mastra/rectification-v9-tools.ts:1303:同一条件下不降级;模型给的spokenPrompt只能进withSpokenPrompt,不得替换卡片题干。- 验收标准:
- 单测:给一个
collection_key = "collect:targeted:family"且expectedAnswerSchemaFor返回 null 的 followup,serverOwnedExpectedAnswerSchema返回的 schema 含.choice四选项与targeted_collect: true,或返回null;断言不会返回{ collect: true }。 - 单测:
rectification-set-focus在活动焦点为定向存在题时写入的expected_answer_schema始终含.choice。
- 单测:给一个
任务 2 · 复原侧:存量口述定向焦点复原成卡(P0)
frontend/src/lib/rectification-agentic/v9/method-followup.ts:2231承接分支:targetedCopy取不到时,按 D2 的条件用schema.prompt重建 frame,spoken_prompt用同一句,collection_key用焦点question_id,user_prompt_hint用既有的TARGETED_COLLECT_KEEP_HINT。frontend/src/lib/rectification-agentic/v9/turn-decision.ts:72projectCurrentQuestion:同条件下把该焦点投影成kind: "choice"并给出四选项,使 GET 快照与choice_card一致(对齐 BUG-669)。- 验收标准:
- 单测(新文件
frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts):活动焦点{ questionId: "collect:targeted:family", intent: "collect_method_evidence", expectedAnswerSchema: { collect: true, prompt: "家里添过丁或长辈住过院吗?", collect_kind: "targeted:family" } }时buildMethodFollowupPlan(...).next_followup.choice_frame非空,choice_frame.question_id === "collect:targeted:family",四个选项与buildTargetedCollectExistenceFrame一致,scoring === false;projectCurrentQuestion(focus)返回kind === "choice"且选项可解析。
- 单测覆盖题号变体:
collect:targeted:family:next(采集重试后缀)同样能复原。
- 单测(新文件
任务 3 · 卡片落不了库时不得吞题(P1)
frontend/src/lib/rectification-agentic/v9/answer-choice.ts:1154尾部 return 加persisted: false。applyStructuredChoice(l.2036 起)确认:skippedNextInterview为真时题干拼进旁白(现有逻辑已覆盖),且keptNextQuestion为假时buildNextUserAction拿到的nextFollowup为null(现有三元已覆盖,补测试锁住)。persistSkippedCollectFocus(server-focus.ts:645)按 D4 补 resolve 失败处理。- 验收标准:
- 单测:
persistNextInterviewAfterChoice在焦点落库返回zero_information_gain/duplicate_focus时,返回值persisted === false且hostNarration为卡片题干。 - 单测:同一场景下
applyStructuredChoice的narration同时包含本轮旁白与题干,next_user_action不再引用未落库的题。 - 单测:
persistSkippedCollectFocus的 resolve 抛错路径不留status === "active"的焦点。
- 单测:
任务 4 · 前端兜底对齐 BUG-671(P2)
frontend/src/lib/rectification-surface-state.ts:350interviewChoiceCardUnavailable增加questionId入参:kind === "collect_spoken"且questionId以collect:targeted:开头时同样判为不可用 → 走unavailable+ repair-exit(「接着问」),不得进persisted_question。- 调用点
frontend/src/components/rectification-agentic-chat.tsx(deadChoice一带)同步传题号。 - 验收标准:
frontend/tests/rectification-surface-state.test.ts或表面合同测试新增用例:collect_spoken+collect:targeted:*→unavailable;普通collect_spoken(如collect:other:collect_method_evidence)仍为persisted_question,不得误伤。
任务 5 · 记录(必做)
docs/BUG_HISTORY.md新增 BUG-673(开工时核对当前最大号,现为 672):状态resolved,现象/触发条件/根因/修复/验证/防复发按上文写实;相关记录写 BUG-670、BUG-669、BUG-671、BUG-661;复发自写 BUG-670;修复版本填实际 staging SHA。- 防复发条必须写明:定向补事题的焦点 schema 只允许点选形态;识别定向题以题号前缀为准,不得只认
targeted_collect字段。 CHANGELOG.md加 2026-09-13 一行;frontend/DESIGN.md本轮无视觉改动可不动(若动了修复入口样式则同提交更新)。docs/tasks/PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md写执行记录与失败清单比对。
6. 让步顺序
- 任务 2(复原侧)+ 任务 4(前端兜底)必做:前者救存量 case,后者保证再出现同类死角时用户有出口。
- 任务 1(写入侧)必做;若
rectification-v9-tools.ts那条路径改动风险大,可先只改server-focus.ts两处,工具侧在进度记录里写成待办。 - 任务 3 若
nextInterviewPersisted语义牵连过广,最小改动只做"尾部 return 加persisted: false",不重构该标志的语义;重构写进BLOCKED.md。 - 任何一项做不完,写进度记录说明剩什么,不得标"通过"。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-targeted-collect-spoken-focus-20260913 \
.worktrees/rectification-targeted-collect-spoken-focus-20260913 origin/staging
cd .worktrees/rectification-targeted-collect-spoken-focus-20260913/frontend
npm ci
grep -n "BUG-6[6-7][0-9]" ../docs/BUG_HISTORY.md | tail -20 # 核对最大号
按 AGENTS.md §5,动手前用「定向补事」「口述题」「choice_card」「collect_spoken」「targeted_collect」检索 docs/BUG_HISTORY.md,读完 BUG-661 / 669 / 670 / 671 四条命中记录,逐条确认其防复发措施是否仍在代码里。
8. 验收(Claude 侧独立再做)
./node_modules/.bin/tsc --noEmit、npm run lint、npm test、npm run build四项口径按 §4。- 对照任务 1~4 的验收标准逐条跑测试。
- 环境缺口如实写:无 staging 登录态与数据库/日志权限,"线上那条存量家庭焦点已被复原成卡片"这一条只能由产品负责人在真机复验,写成
docs/testing/清单条目:进入那个校正会话 → 刷新 → 确认「家里添过丁或长辈住过院吗?」以四选一卡出现(或给出「接着问」修复入口),不再是裸题。
9. BUG 编号起点
- 起点 BUG-673(当前最大号 672,开工时以
docs/BUG_HISTORY.md实际最大号为准)。