- 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
9.9 KiB
任务书 · 题目进消息修复:选择题选项回归服务端、题目加载 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):
- 已经算出
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。 - 当
nextFollowup没有choice_frame(采集题)时:schema 强制带collect: true(COLLECT_FOCUS_SCHEMA_KEY)+prompt,与collectFocusSchema(followup)同形,Agent 传入的choice同样忽略。目的:projectCurrentQuestion对 Agent 建的采集 focus 也能投影出collect_spoken(现在{prompt}或{required:[...]}形状会投影成 null,composer 占位丢失)。 nextFollowup为 null(无下一问)时:工具返回{ ok:false, error:"no_pending_question" },不建 focus(指令第 4 条已写"没有下一问时不要自拟问题",服务端也要挡)。- 工具描述与
method-followup.ts两处 hint 同步改:删掉"在 expectedAnswerSchema.choice 写入 option_a…",改为"选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt"。expectedAnswerSchema输入保留为可选(兼容旧 prompt),但服务端不再读它的choice。 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 权限不对时,聊天里所有题目消失且没有任何日志。
修法:
- 失败时
console.warn("[rectification-v9] list focuses failed case=… reason=<safeToolErrorCode>"),并让函数返回{ focuses, available: boolean }(或抛出让 GET 决定)。 - GET
/api/rectification/cases/[caseId]在 focuses 不可用时响应体带question_source: "unavailable"(可用时"focus"),前端showMissingQuestion的 status 文案区分"服务端更新中"与"题目加载失败,请刷新"。 - 测试: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):
followup.probe_year非空时,题干必须包含String(probe_year)(允许 "2023 年"/"2023年"/"2023"),否则reason: "year_missing"。probe_year为空但choice_frame.period非空时(区间探针),题干必须包含 period 里出现的每个年份;period 无年份则不检查。- 采集题(无 choice_frame)不加年份要求。
- 工具描述补一句:"题干必须写出服务端给你的年份/期间"。VOICE.md 的坏例加一条"没写年份"。
- 测试:
rectification-spoken-prompt.test.ts加year_missing正反例;embedded 卡片测试断言"渲染出的题干含探针年份"(结构断言:用question.prompt与probe_year比对,不做正文匹配)。
4. 测试欠账
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。- 补任务书 §7 第 3 条:以走查 case
a17efc37形状跑完整链(opening → 2 采集 → 3 区分点选 → 1 打字答区分 → 采用 → 前事核对),断言每条 assistant turn 的question形状(kind / focus_id / answer_option / status)与在线会话messagesFromTurns结果一致。用 fake accounting,不需要真实环境。 - BUG_HISTORY 记一条已知边界:用户不答上一题直接发新消息时,旧 focus 仍挂在旧消息、新 turn 无 question(不变量 1 短暂不成立),旧卡可点,属可接受。
5. 硬红线
- 问哪道题、年份、选项文本、
answer_class、计分:服务端所有,Agent 只写spokenPrompt。任何路径不得再从 Agent 输入读choice。 - 不改
d9404976的消息形态与asked_turn_id契约;不恢复问题槽。 - 确认门恒 fail-closed;不得用正文字符串判断状态。
- Python 引擎不动;不 bump Skill 10.0.14。
./node_modules/.bin/tsc --noEmit通过(不要用npx tsc);npm run lint --prefix frontend0 错误;rectification-*/consultation-*/consult-*/chat-*测试 fail=0;改动的既有断言逐条三栏说明(旧→新→保留语义)。- 无凭据不得声称已真实环境验证。
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.md、docs/BUG_HISTORY.md(BUG-490/491 及 BUG-441/449/456 链)、frontend/docs/VOICE.md。BUG 编号从 BUG-492 起(先 grep 确认最大号)。行号是线索,按符号名定位。
7. 验收标准
- 消息路径合成测试:Agent 只传
spokenPrompt+ 探针questionId,next_followup带choice_frame→ 建立的 focusexpectedAnswerSchema.choice.options与serverOwnedChoiceCopy(frame)逐项相等、probe_id非空、projectCurrentQuestion(...).kind === "choice"、GET 的turns[last].question.options.length === 4;Agent 额外传的choice被忽略(结果与不传完全一致)。 - 无
choice_frame时 Agent set-focus → focus 为collect: true形状,current_question.kind === "collect_spoken"。 next_followup为 null 时 set-focus 返回no_pending_question,无 focus 写入。- 同一 choice 探针第二轮再 set-focus → focus id 不变、选项不丢。
list_…_focusesRPC 失败 → GET 200 +question_source: "unavailable"+ warn。spokenPrompt缺探针年份 →year_missing;含错年份 →year_mismatch;含正确年份 → 通过。- §4 的 fixture 修正与全链等价测试全绿;tsc + lint + 四组测试 fail=0;BUG_HISTORY 追加条目。
- 真实环境人工清单(部署后):答一件事后的区分题在同一条消息里有 A–D 四个选项且题干带年份;点 A 后计分生效(候选比较更新);刷新后一致。