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

213 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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「明确没有发生」)后,屏幕依次出现:
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`:
```ts
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**:
```ts
// 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`;组件按该状态只画题干:
```tsx
// 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
```ts
// 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` 尾部:
```ts
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. 硬红线
1. `./node_modules/.bin/tsc --noEmit` 0 错;`npm run lint` 0 error;`npm 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`)判定。
- `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: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`。
- `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:350` `interviewChoiceCardUnavailable` 增加 `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. 让步顺序
1. 任务 2(复原侧)+ 任务 4(前端兜底)**必做**:前者救存量 case,后者保证再出现同类死角时用户有出口。
2. 任务 1(写入侧)必做;若 `rectification-v9-tools.ts` 那条路径改动风险大,可先只改 `server-focus.ts` 两处,工具侧在进度记录里写成待办。
3. 任务 3 若 `nextInterviewPersisted` 语义牵连过广,最小改动只做"尾部 return 加 `persisted: false`",不重构该标志的语义;重构写进 `BLOCKED.md`。
4. 任何一项做不完,写进度记录说明剩什么,不得标"通过"。
## 7. 开工前置命令
```bash
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` 实际最大号为准)。