diff --git a/TASK-rectification-question-ownership-fix-20260902.md b/TASK-rectification-question-ownership-fix-20260902.md new file mode 100644 index 00000000..4e24ddd6 --- /dev/null +++ b/TASK-rectification-question-ownership-fix-20260902.md @@ -0,0 +1,83 @@ +# 任务书 · 题目进消息修复:选择题选项回归服务端、题目加载 fail-closed、题干必含探针年份(2026-09-02) + +基线:`origin/staging` `d9404976`(`TASK-rectification-question-in-message-20260902.md` 的实现)。本任务书是对该实现 review 后的修复单,**不改 `d9404976` 已定的形态**(一条消息承载正文+题干+选项、问题槽已删、`asked_turn_id` 关联),只修下面三个缺口和两个测试欠账。 + +## 0. Review 结论摘要 + +`d9404976` 的数据契约、前端同 article 渲染、刷新重建、复制文本、39c30b55 回归修复均已落地,tsc/lint/四组测试全绿。但有一个 P0 违反任务书 §8 红线"选项、答案→候选映射、计分全部服务端所有",两个 P1。 + +## 1. P0 · 消息路径的选择题选项由 Agent 自填 / 丢失 + +**现象(静态复现,已用脚本验证)**:`rectification-set-focus` 在 `d9404976` 重新开放给 Agent(`agentToolsForDecision` 不再剔除它),但 `execute` 里选择题的 `expectedAnswerSchema.choice` 仍走老逻辑——`parseAgentChoiceCopy(expectedAnswerSchemaInput)` 要求 Agent 同时给 `choice.prompt`、`option_a..d` **和 `options[].answer_class`**。三种可能的 Agent 行为结果都不对: + +| Agent 写法 | 结果 | +|---|---| +| 只写 `spokenPrompt`(`method-followup.ts` 的 choice hint 就是这么教的:"用 spokenPrompt 写出题干") | focus schema = `{prompt}`,`parseAgentChoiceCopy` 为 null → `turnQuestionFromFocus` 判成 `collect_spoken` 无选项;`projectCurrentQuestion` 返回 **null**,`current_question` 为空;随后 `persistNextInterviewIfIdle` 看到 active focus 直接 return,服务端 choice focus 永远建不出来,该探针永远不计分 | +| 按工具描述只写 `choice.option_a..d` | `parseAgentChoiceCopy` 因缺 `options` 返回 null → `hasChoice && !choiceCopy` 抛 `invalid_choice_copy` | +| 把 `options[].answer_class` 也写上 | 选项文本与答案→候选映射由模型决定,违反红线 | + +`d9404976` 之前 Agent 根本调不到 set-focus,choice focus 全部由 `persistNextInterviewIfIdle → persistServerOwnedFocus → expectedAnswerSchemaFor(frame)` 建立,所以这条路径没被测试覆盖。 + +**修法**(`frontend/src/mastra/rectification-v9-tools.ts` set-focus `execute`): + +1. 已经算出 `nextFollowup`(`sessionAwareFollowupForParsed(...).plan.next_followup`)。当 `nextFollowup?.choice_frame` 非空且 `input.questionId === stableFollowupQuestionId(nextFollowup)`(现有 `probe_mismatch` 校验已保证)时:**schema 强制取服务端**——把 `server-focus.ts` 的 `expectedAnswerSchemaFor(frame, questionId, decisionReceipt, followup)` 导出并复用,得到含 `choice.options[].answer_class`、`semantic_key`、`candidate_split_hash`、`probe_id`、`scoring` 的完整 schema;再 `withSpokenPrompt(schema, spoken.prompt)`。Agent 传入的 `expectedAnswerSchema.choice` **一律忽略**(不报错,不合并)。`expectedAnswerSchemaFor` 返回 null(frame 不可渲染)时,走与 `persistServerOwnedFocus` 相同的降级:`spokenCollectFallbackFollowup` 的 collect schema,不得建立无选项的 distinguish focus。 +2. 当 `nextFollowup` 没有 `choice_frame`(采集题)时:schema 强制带 `collect: true`(`COLLECT_FOCUS_SCHEMA_KEY`)+ `prompt`,与 `collectFocusSchema(followup)` 同形,Agent 传入的 `choice` 同样忽略。目的:`projectCurrentQuestion` 对 Agent 建的采集 focus 也能投影出 `collect_spoken`(现在 `{prompt}` 或 `{required:[...]}` 形状会投影成 null,composer 占位丢失)。 +3. `nextFollowup` 为 null(无下一问)时:工具返回 `{ ok:false, error:"no_pending_question" }`,不建 focus(指令第 4 条已写"没有下一问时不要自拟问题",服务端也要挡)。 +4. 工具描述与 `method-followup.ts` 两处 hint 同步改:删掉"在 expectedAnswerSchema.choice 写入 option_a…",改为"选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt"。`expectedAnswerSchema` 输入保留为可选(兼容旧 prompt),但服务端不再读它的 `choice`。 +5. `keepChoice` 分支(active_focus followup,`method-followup.ts:1529` 附近)的 hint 让 Agent 对**已持久化的 choice focus 再次 set-focus**。确认幂等:同 `questionId`、同 intent、服务端 schema(去 prompt 后)一致 → RPC 走 `idempotent` 路径,不 supersede;写一条测试证明"第二轮对同一 choice 探针再 set-focus 不会丢选项、focus id 不变"。若做不到幂等,就把这条 hint 改成"当前焦点已在,不要再调 set-focus"。 + +## 2. P1 · 题目加载 fail-open + +`tool-service.ts` `listV10ConversationFocuses`:RPC 报错或异常时静默 `return []`——迁移没跑、RPC 权限不对时,聊天里**所有**题目消失且没有任何日志。 + +**修法**: +1. 失败时 `console.warn("[rectification-v9] list focuses failed case=… reason=")`,并让函数返回 `{ focuses, available: boolean }`(或抛出让 GET 决定)。 +2. GET `/api/rectification/cases/[caseId]` 在 focuses 不可用时响应体带 `question_source: "unavailable"`(可用时 `"focus"`),前端 `showMissingQuestion` 的 status 文案区分"服务端更新中"与"题目加载失败,请刷新"。 +3. 测试:RPC 返回 error 时 GET 仍 200、`turns[].question` 全 null、`question_source === "unavailable"`、有 warn。 + +## 3. P1 · 题干可以不含探针年份,而嵌入卡又隐藏了 `why` + +`spoken-prompt.ts` 的 `year_mismatch` 只在题干**写了** 19xx/20xx 时比对;`rectification-choice-card.tsx` 的 `variant="embedded"` 把 `why`(原本携带年份/期间)也藏掉了。结果"你有没有一段认真开始或结束的关系?"能过校验,用户看不到是哪一年,答错年份直接影响计分。 + +**修法**(`validateSpokenPrompt`): +1. `followup.probe_year` 非空时,题干必须包含 `String(probe_year)`(允许 "2023 年"/"2023年"/"2023"),否则 `reason: "year_missing"`。 +2. `probe_year` 为空但 `choice_frame.period` 非空时(区间探针),题干必须包含 period 里出现的每个年份;period 无年份则不检查。 +3. 采集题(无 choice_frame)不加年份要求。 +4. 工具描述补一句:"题干必须写出服务端给你的年份/期间"。VOICE.md 的坏例加一条"没写年份"。 +5. 测试:`rectification-spoken-prompt.test.ts` 加 `year_missing` 正反例;embedded 卡片测试断言"渲染出的题干含探针年份"(结构断言:用 `question.prompt` 与 `probe_year` 比对,不做正文匹配)。 + +## 4. 测试欠账 + +1. `tests/rectification-question-in-message.test.ts` 里的 choice fixture 缺 `choice.options[]`,实际被 `parseAgentChoiceCopy` 解析成 `collect_spoken`,测试没断言 `kind`,所以 P0 没被发现。修 fixture(用 `serverOwnedChoiceCopy` 生成或补 `options[].answer_class`),并对每个 choice 断言 `kind === "choice"` 且 `options.length === 4`。 +2. 补任务书 §7 第 3 条:以走查 case `a17efc37` 形状跑完整链(opening → 2 采集 → 3 区分点选 → 1 打字答区分 → 采用 → 前事核对),断言每条 assistant turn 的 `question` 形状(kind / focus_id / answer_option / status)与在线会话 `messagesFromTurns` 结果一致。用 fake accounting,不需要真实环境。 +3. BUG_HISTORY 记一条已知边界:用户不答上一题直接发新消息时,旧 focus 仍挂在旧消息、新 turn 无 question(不变量 1 短暂不成立),旧卡可点,属可接受。 + +## 5. 硬红线 + +1. 问哪道题、年份、选项文本、`answer_class`、计分:服务端所有,Agent 只写 `spokenPrompt`。任何路径不得再从 Agent 输入读 `choice`。 +2. 不改 `d9404976` 的消息形态与 `asked_turn_id` 契约;不恢复问题槽。 +3. 确认门恒 fail-closed;不得用正文字符串判断状态。 +4. Python 引擎不动;不 bump Skill 10.0.14。 +5. `./node_modules/.bin/tsc --noEmit` 通过(不要用 `npx tsc`);`npm run lint --prefix frontend` 0 错误;`rectification-*` / `consultation-*` / `consult-*` / `chat-*` 测试 fail=0;改动的既有断言逐条三栏说明(旧→新→保留语义)。 +6. 无凭据不得声称已真实环境验证。 + +## 6. 开工前置 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-question-ownership-fix-20260902 \ + ../.worktrees/rectification-question-ownership-fix-20260902 origin/staging +``` + +读 `TASK-rectification-question-in-message-20260902.md`、`docs/BUG_HISTORY.md`(BUG-490/491 及 BUG-441/449/456 链)、`frontend/docs/VOICE.md`。BUG 编号从 **BUG-492** 起(先 grep 确认最大号)。**行号是线索,按符号名定位。** + +## 7. 验收标准 + +1. 消息路径合成测试:Agent 只传 `spokenPrompt` + 探针 `questionId`,`next_followup` 带 `choice_frame` → 建立的 focus `expectedAnswerSchema.choice.options` 与 `serverOwnedChoiceCopy(frame)` 逐项相等、`probe_id` 非空、`projectCurrentQuestion(...).kind === "choice"`、GET 的 `turns[last].question.options.length === 4`;Agent 额外传的 `choice` 被忽略(结果与不传完全一致)。 +2. 无 `choice_frame` 时 Agent set-focus → focus 为 `collect: true` 形状,`current_question.kind === "collect_spoken"`。 +3. `next_followup` 为 null 时 set-focus 返回 `no_pending_question`,无 focus 写入。 +4. 同一 choice 探针第二轮再 set-focus → focus id 不变、选项不丢。 +5. `list_…_focuses` RPC 失败 → GET 200 + `question_source: "unavailable"` + warn。 +6. `spokenPrompt` 缺探针年份 → `year_missing`;含错年份 → `year_mismatch`;含正确年份 → 通过。 +7. §4 的 fixture 修正与全链等价测试全绿;tsc + lint + 四组测试 fail=0;BUG_HISTORY 追加条目。 +8. 真实环境人工清单(部署后):答一件事后的区分题在同一条消息里**有 A–D 四个选项且题干带年份**;点 A 后计分生效(候选比较更新);刷新后一致。