Files
Jyotisha/docs/tasks/TASK-rectification-question-ownership-fix-20260902.md
T
Jesse_Chen 8db71aaf81 docs: product-level README, AGENTS.md split into code/reading parts, add CLAUDE.md, move task briefs to docs/tasks
- README.md is now the product/repo front door (architecture, repo map,
  local dev, test tiers, delivery flow, doc map). Engine positioning,
  VedAstro/Codex setup and the oracle/benchmark command reference move
  verbatim to docs/engine/README.md, docs/engine/vedastro-gateway.md and
  docs/benchmark/README.md. Capability badges realigned with the registry
  (91/78/8/0); tests/test_readme_badges.py was red on staging.
- AGENTS.md: Part A (environment truth, delivery, worktrees, record
  placement, bug workflow, growth freeze, frontend red lines, privacy,
  pre-work check, test tiers) and Part B (reading-rigor constraints).
  GitHub issue-tracker/triage boilerplate removed: GitHub is a read-only
  mirror. All strings locked by tests/ are preserved.
- CLAUDE.md added: roles, three working modes, task-brief sections,
  acceptance criteria, session discipline; imports AGENTS.md.
- 50 tracked TASK-*/PROGRESS-* files and 3 never-committed briefs move to
  docs/tasks/ with an index; REPO_LAYOUT.md merged into README.

Docs-only change (no gated path touched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-03 06:56:06 +00:00

9.9 KiB
Raw Blame History

任务书 · 题目进消息修复:选择题选项回归服务端、题目加载 fail-closed、题干必含探针年份(2026-09-02)

基线:origin/staging d9404976TASK-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-focusd9404976 重新开放给 AgentagentToolsForDecision 不再剔除它),但 execute 里选择题的 expectedAnswerSchema.choice 仍走老逻辑——parseAgentChoiceCopy(expectedAnswerSchemaInput) 要求 Agent 同时给 choice.promptoption_a..d options[].answer_class。三种可能的 Agent 行为结果都不对:

Agent 写法 结果
只写 spokenPromptmethod-followup.ts 的 choice hint 就是这么教的:"用 spokenPrompt 写出题干" focus schema = {prompt}parseAgentChoiceCopy 为 null → turnQuestionFromFocus 判成 collect_spoken 无选项;projectCurrentQuestion 返回 nullcurrent_question 为空;随后 persistNextInterviewIfIdle 看到 active focus 直接 return,服务端 choice focus 永远建不出来,该探针永远不计分
按工具描述只写 choice.option_a..d parseAgentChoiceCopy 因缺 options 返回 null → hasChoice && !choiceCopyinvalid_choice_copy
options[].answer_class 也写上 选项文本与答案→候选映射由模型决定,违反红线

d9404976 之前 Agent 根本调不到 set-focuschoice focus 全部由 persistNextInterviewIfIdle → persistServerOwnedFocus → expectedAnswerSchemaFor(frame) 建立,所以这条路径没被测试覆盖。

修法frontend/src/mastra/rectification-v9-tools.ts set-focus execute):

  1. 已经算出 nextFollowupsessionAwareFollowupForParsed(...).plan.next_followup)。当 nextFollowup?.choice_frame 非空且 input.questionId === stableFollowupQuestionId(nextFollowup)(现有 probe_mismatch 校验已保证)时:schema 强制取服务端——把 server-focus.tsexpectedAnswerSchemaFor(frame, questionId, decisionReceipt, followup) 导出并复用,得到含 choice.options[].answer_classsemantic_keycandidate_split_hashprobe_idscoring 的完整 schema;再 withSpokenPrompt(schema, spoken.prompt)。Agent 传入的 expectedAnswerSchema.choice 一律忽略(不报错,不合并)。expectedAnswerSchemaFor 返回 null(frame 不可渲染)时,走与 persistServerOwnedFocus 相同的降级:spokenCollectFallbackFollowup 的 collect schema,不得建立无选项的 distinguish focus。
  2. nextFollowup 没有 choice_frame(采集题)时:schema 强制带 collect: trueCOLLECT_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 followupmethod-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 listV10ConversationFocusesRPC 报错或异常时静默 return []——迁移没跑、RPC 权限不对时,聊天里所有题目消失且没有任何日志。

修法

  1. 失败时 console.warn("[rectification-v9] list focuses failed case=… reason=<safeToolErrorCode>"),并让函数返回 { 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.tsyear_mismatch 只在题干写了 19xx/20xx 时比对;rectification-choice-card.tsxvariant="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.tsyear_missing 正反例;embedded 卡片测试断言"渲染出的题干含探针年份"(结构断言:用 question.promptprobe_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. 开工前置

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.mddocs/BUG_HISTORY.mdBUG-490/491 及 BUG-441/449/456 链)、frontend/docs/VOICE.md。BUG 编号从 BUG-492 起(先 grep 确认最大号)。行号是线索,按符号名定位。

7. 验收标准

  1. 消息路径合成测试:Agent 只传 spokenPrompt + 探针 questionIdnext_followupchoice_frame → 建立的 focus expectedAnswerSchema.choice.optionsserverOwnedChoiceCopy(frame) 逐项相等、probe_id 非空、projectCurrentQuestion(...).kind === "choice"、GET 的 turns[last].question.options.length === 4Agent 额外传的 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=0BUG_HISTORY 追加条目。
  8. 真实环境人工清单(部署后):答一件事后的区分题在同一条消息里有 AD 四个选项且题干带年份;点 A 后计分生效(候选比较更新);刷新后一致。