Files
Jyotisha/docs/tasks/TASK-rectification-targeted-collect-spoken-focus-20260913.md
T
Jesse_ChenandClaude Fable 5 9375012f17 docs(tasks): brief for unstampable probes, naked cards and stale representative time
探针池空后判别题盖不上 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
2026-09-13 18:34:23 +00:00

18 KiB
Raw Blame History

TASK · 定向补事题被存成口述题,裸题顶掉卡片 — 2026-09-13

  • 基线:origin/staging @ 007a05fe(最近一次含门禁路径改动的提交是 14d199e6staging 已部署该 SHA;其后均为纯文档提交)。
  • 分支:codex/rectification-targeted-collect-spoken-focus-20260913worktree .worktrees/rectification-targeted-collect-spoken-focus-20260913
  • 关联:BUG-661(定向补事必须逐条点选)、BUG-669(快照投影拿不到 choice_card)、BUG-670(承接焦点分支让模型改写定向题)、BUG-671(选择题无卡时不得停在采集等待态)。本单是 BUG-670 的复发:用户现象完全一致,但触发路径不经过模型,670 的识别条件拦不住。
  • 串行:本单改 method-followup.tsserver-focus.tsanswer-choice.ts。开工前 git log --oneline origin/staging -20 确认没有同文件在途分支;若 09-10 采集重设计单(BUG-646648)或 09-11 定向补事单(BUG-661~663 后续)正在改同一批文件,本单排在其后。

1. 事故实证(产品负责人 staging 真机,2026-09-13

现象

校正采集阶段,用户答完一道带年月的关系题(选 C「明确没有发生」)后,屏幕依次出现:

  1. 正常助手气泡:已记录,范围收到 04:48–05:07。
  2. 浅色状态行:目前范围 04:48–05:07,再说一件带年月的事就能继续(时间轴只读范围行,rectification-surface-state.ts:380 rectificationReadonlyRangeCopy设计内,非缺陷
  3. 一行没有头像、没有 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: "家里添过丁或长辈住过院吗?",

全仓唯一出处,只被 targetedCollectPooltargetedCollectExistenceFollowupbuildTargetedCollectExistenceFrame 使用。按 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_cardinterviewChoiceCardUnavailablerectification-surface-state.ts:350);这次快照里的 current_question.kindcollect_spokenturn-decision.ts:72 projectCurrentQuestion 对采集型 schema 的投影),被当成"合法口述题"放行。

2. 根因

一道定向补事题以"采集型 schema"存在于会话焦点里,而系统没有任何一条路径能把它复原成卡片。

采集型 schema 形如 { prompt, collect: true, collect_kind: "targeted:family" }server-focus.ts:321 collectFocusSchema),其 promptspoken_prompt;定向题在没有"现在还剩 … 个候选"那句时 spoken_prompt 就等于存在题题干本身 —— 正好是截图那句。

P0-a 写入侧:三条路径会把定向题降级成采集型 schema

位置 降级动作
server-focus.ts:337 serverOwnedExpectedAnswerSchema choice_frameexpectedAnswerSchemaFor 没产出 .choice 时,对采集意图直接 collectFocusSchema(spokenCollectFallbackFollowup(followup))
frontend/src/mastra/rectification-v9-tools.ts:1303 rectification-set-focus 同样的口述兜底,写进焦点
server-focus.ts:645 persistSkippedCollectFocus 先用采集型 schema 写一条 active 焦点(会 supersede 真正的活动焦点),再调 resolveV10ConversationFocusskipped;第二步失败(异常被外层 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 字段

followupchoice_framespokenFollowupForUser 返回的是卡片题干字符串。焦点落库失败(zero_information_gain / probe_already_answered / duplicate_focus / invalid_choice_schema)时就走到这里。applyStructuredChoiceanswer-choice.ts:2036 起)据此:

  • skippedNextInterview = nextInterview.persisted === falseundefined !== falsefalse
  • 只有 accepted / skipThisProbe / skippedNextInterview / 定向卡就绪 四种情况才会把 hostNarration 并进本轮旁白 → 题干没有进消息
  • if (nextInterview.hostNarration) nextInterviewPersisted = true → 前端不再追一轮
  • keptNextQuestionanswer-choice.ts:2109)为真 → next_user_action 仍然引用这道既没进消息也没进焦点的题

即"焦点没写成功"与"题已经问出去了"这两件事共用了一个 hostNarration 非空的判断。

P2 前端兜底不对称

BUG-671 只拦 kind === "choice" 无卡;题号以 collect:targeted: 开头的题以 collect_spoken 形态出现时没有任何校验,直接裸画。

3. 决策记录

决策 内容
D1 定向补事题只有点选一种形态。 写入侧禁止降级:collection_key / question_idcollect:targeted: 开头(或 kind_hinttargeted: 开头)时,任何口述兜底都必须先用 buildTargetedCollectExistenceFrame 重建 frame 再写点选 schema;重建不出来就不写焦点(返回 skipped),不得写采集型 schema。此处推翻"采集意图一律可降级为口述"的既有兜底惯例,执行方不得以"兜底总比没有好"为由保留。
D2 复原侧按题号识别,不再只认 targeted_collect 章。 method-followup.ts:2231 的承接分支增加一条:焦点 intent === "collect_method_evidence" 且(question_idcollect:targeted: 开头 或 collect_kindtargeted: 开头)且 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. 硬红线

  1. ./node_modules/.bin/tsc --noEmit 0 错;npm run lint 0 errornpm test 失败清单与开工时 origin/staging 的无 Docker 基线逐条一致(新增测试只许让通过数上升);next build/○ Static;首屏 gzip ±2%。
  2. 测试总数不得低于开工时实测;改任何既有断言写"原值 / 新值 / 原因"三栏。
  3. 定向存在题的题干与四个选项仍是服务端自有,模型不得改写(BUG-670 防复发条维持)。
  4. frontend/src/app/page.tsx 不动;不新增依赖;不顺手修不在本单里的 warning。
  5. 不得用"把 persisted_question 块画成卡片"来绕过:数据层是采集型 schema 时前端不得假装有卡。

5. 任务分解

任务 1 · 写入侧:定向题不许降级成口述(P0)

  • frontend/src/lib/rectification-agentic/v9/server-focus.ts
    • 新增 isTargetedCollectFollowupLike(followup)(或复用 method-followup.ts 已有的 isTargetedCollectFollowup)判定。
    • serverOwnedExpectedAnswerSchemal.337):定向题走 expectedAnswerSchemaFor 失败时,用 buildTargetedCollectExistenceFrame({ questionId, methodId, prompt }) 重建 frame 再取一次点选 schema;仍失败则返回 null不得 collectFocusSchema(spokenCollectFallbackFollowup(...))
    • persistServerOwnedFocusCorel.513):定向题落到 !frame 分支时同样先重建 frame,不得直落 persistCollectFocus
  • frontend/src/mastra/rectification-v9-tools.ts:1303:同一条件下不降级;模型给的 spokenPrompt 只能进 withSpokenPrompt,不得替换卡片题干。
  • 验收标准:
    • 单测:给一个 collection_key = "collect:targeted:family"expectedAnswerSchemaFor 返回 null 的 followupserverOwnedExpectedAnswerSchema 返回的 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 重建 framespoken_prompt 用同一句,collection_key 用焦点 question_iduser_prompt_hint 用既有的 TARGETED_COLLECT_KEEP_HINT
  • frontend/src/lib/rectification-agentic/v9/turn-decision.ts:72 projectCurrentQuestion:同条件下把该焦点投影成 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
  • applyStructuredChoicel.2036 起)确认:skippedNextInterview 为真时题干拼进旁白(现有逻辑已覆盖),且 keptNextQuestion 为假时 buildNextUserAction 拿到的 nextFollowupnull(现有三元已覆盖,补测试锁住)。
  • persistSkippedCollectFocusserver-focus.ts:645)按 D4 补 resolve 失败处理。
  • 验收标准:
    • 单测:persistNextInterviewAfterChoice 在焦点落库返回 zero_information_gain / duplicate_focus 时,返回值 persisted === falsehostNarration 为卡片题干。
    • 单测:同一场景下 applyStructuredChoicenarration 同时包含本轮旁白与题干,next_user_action 不再引用未落库的题。
    • 单测:persistSkippedCollectFocus 的 resolve 抛错路径不留 status === "active" 的焦点。

任务 4 · 前端兜底对齐 BUG-671(P2)

  • frontend/src/lib/rectification-surface-state.ts:350 interviewChoiceCardUnavailable 增加 questionId 入参:kind === "collect_spoken"questionIdcollect:targeted: 开头时同样判为不可用 → 走 unavailable + repair-exit(「接着问」),不得进 persisted_question
  • 调用点 frontend/src/components/rectification-agentic-chat.tsxdeadChoice 一带)同步传题号。
  • 验收标准: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. 让步顺序

  1. 任务 2(复原侧)+ 任务 4(前端兜底)必做:前者救存量 case,后者保证再出现同类死角时用户有出口。
  2. 任务 1(写入侧)必做;若 rectification-v9-tools.ts 那条路径改动风险大,可先只改 server-focus.ts 两处,工具侧在进度记录里写成待办。
  3. 任务 3 若 nextInterviewPersisted 语义牵连过广,最小改动只做"尾部 return 加 persisted: false",不重构该标志的语义;重构写进 BLOCKED.md
  4. 任何一项做不完,写进度记录说明剩什么,不得标"通过"。

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 --noEmitnpm run lintnpm testnpm run build 四项口径按 §4。
  • 对照任务 1~4 的验收标准逐条跑测试。
  • 环境缺口如实写:无 staging 登录态与数据库/日志权限,"线上那条存量家庭焦点已被复原成卡片"这一条只能由产品负责人在真机复验,写成 docs/testing/ 清单条目:进入那个校正会话 → 刷新 → 确认「家里添过丁或长辈住过院吗?」以四选一卡出现(或给出「接着问」修复入口),不再是裸题。

9. BUG 编号起点

  • 起点 BUG-673(当前最大号 672,开工时以 docs/BUG_HISTORY.md 实际最大号为准)。