docs(rectification): add adopt-flow task brief (collect-phase cards, post-adopt question, observability)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# 任务书 · 采用流程整治:采集阶段不出采用卡、采用后核对题可答、采用卡沉淀进历史、运行可观测(2026-09-02)
|
||||
|
||||
基线:`origin/staging` `8dfe457f`(`TASK-rectification-question-ownership-fix-20260902.md` 的实现,已验收)。本任务书来自真实环境 case `bf7a8de6-4873-4e74-94fe-bfaa3b368e32` 的两份 GET 快照与用户操作记录,覆盖两组问题:**A. 采集阶段同时出现采集题和三张"采用此时间"卡**;**B. 点"采用此时间"之后卡片消失、弹出一道不可答的题和一个裸按钮、运行 178 秒**。两组问题的根都在"卡片/题目/采用状态各自为政",一起修。
|
||||
|
||||
## 0. 真实环境复现(事实,不要重新猜)
|
||||
|
||||
快照一(采用前,`case_revision 5`):
|
||||
- `interview.session_outcome = "collect_evidence"`,`propose_allowed=false`,`can_adopt=true`,`selection_allowed=true`;`current_question = probe:family.2021`(`collect_spoken`,active)。
|
||||
- 候选 05:06 / 04:53 / 05:03 支持度 14 / 13 / 13,`margin_percent 2.45`,`unique_top.passed=false`,`diagnostic_quality.passed=false`。
|
||||
- 界面:最新助手消息里是采集题"2021 年前后,家里……",**同一条消息下面**是"当前可能的出生时间"+ 三张"采用此时间"卡。
|
||||
|
||||
用户点了 05:06 的"采用此时间",随后:
|
||||
- 三张卡消失(续跑期间 `busy`),列表末尾出现"用这个时间看盘"按钮。
|
||||
- 续跑 turn `3fe40677`:`started_at 13:27:05 → completed_at 13:30:03`(178 秒,上限 210 秒),`tool_activities`: read-case completed → compare-candidates completed → **set-focus failed**;正文"前面几轮的作答都记下了,候选之间的差异在一步步变清楚。我们继续——……"(没有一句承认已采用)。
|
||||
- 该 turn 的 `question`:`question_id reverse_verify:education_style:score`,`kind reverse_verify`,四个选项,status active;题干是服务端模板原文。
|
||||
- 快照二 `choice_card: null`,`current_question.kind = "choice"`。前端嵌入卡 `disabled`,用户"这个选择题没法选择"。卡片底部仍显示"先这样,先看当前范围"。
|
||||
- `probe:family.2021` 被静默标成 `skipped`(`persistNextInterviewIfIdle` 的 `isStalePreAdoptFocus` 分支)。
|
||||
|
||||
## 1. P0-A · `collect_evidence` 阶段出采用卡(BUG-313 / BUG-323 复发)
|
||||
|
||||
**根因**:`core/rectification-decision.ts` 的 `collect()` 把 `capability`(含 `canAdopt`)原样展开进 decision,`publicDecisionFields` 再把 `canAdopt` 直接投影成 `can_adopt`。于是 `session_outcome=collect_evidence` 时 `can_adopt` 仍是 true。前端 `showSelectionCards`(`rectification-agentic-chat.tsx`)只看 `selectionAllowed && canAdopt && canRenderRectificationSelectionCards && !showLiveChoiceCard && !busy`,`showLiveChoiceCard` 只在 A–D 卡活着时为真,采集题(`collect_spoken`)不算,卡片就露出来了。v9 删除 `rectification-offer-candidates` 门后,BUG-313/323 的防复发条款失效。
|
||||
|
||||
**修法**:
|
||||
1. 服务端单一真值:`publicDecisionFields` 里 `can_adopt = decision.canAdopt && ADOPT_OUTCOMES.has(decision.sessionOutcome)`,`ADOPT_OUTCOMES = {adopt_representative, provisional_range_user_stopped, awaiting_confirmation}`(`validated_range` / `exact_minute_confirmed` / `completed_with_range` 按现有语义处理,不得新增放行)。`collect_evidence` / `discriminate_candidates` / `validate_holdout` 一律 false。内部 `capability.canAdopt` 保留给 decision 逻辑用,不外泄。
|
||||
2. `POST /candidates/accept` 复用同一判断:`can_adopt=false` 时返回 `409 { error: "adoption_not_allowed", session_outcome }`,不调 RPC。
|
||||
3. 前端 `parseRectificationCandidateResult` 把 `session_outcome` 解析进 `RectificationCandidateResult.sessionOutcome`;`showSelectionCards` 追加 `ADOPT_OUTCOMES.has(sessionOutcome)`(双保险,前端不得单靠 `selection_allowed`)。
|
||||
4. `collect_evidence` / `discriminate_candidates` 且 `credible_range` 非空时,只允许一行只读文案(如"目前范围 04:53–05:06,还在收窄"),放在 `RectificationCandidateCards` 同位置,无按钮、无支持度数字。
|
||||
5. 采集题(`collect_spoken`)也要有"先这样,先看当前范围"的出口:composer 旁加一个次要按钮,走现有 `stop` 动作(`CHOICE_STOP_MESSAGE`),服务端把当前 collect focus 标 `skipped`、`userStopped=true`,进入 `provisional_range_user_stopped` 后才出卡。
|
||||
|
||||
## 2. P0-B · 采用后的核对题永远不可答
|
||||
|
||||
**根因**:采用后服务端建核对 focus 用的是 `method-followup.ts` `input.accepted` 分支(`method_id "reverse_verify"`,`ask_theme` 如 `education_style`),`stableFollowupQuestionId` 落成 `reverse_verify:education_style:score`。之后每次 GET / set-focus 重算 followup 时,active focus 已是 reverse_verify,走的是"承接当前焦点"分支(`keepAcceptedFocus`,`makeFollowup({ method_id: "active_focus", ask_theme: "active_focus", ... }, true, keepChoice)`),`choice_frame.question_id` 变成 `active_focus:active_focus:score`。`projectRectificationChoiceCard` 对没有 `semantic_key` / `candidate_split_hash` 的 followup 要求 `activeFocus.questionId === frame.question_id`,不等 → 返回 null → GET `choice_card: null` → 前端 `liveQuestion=false` → 嵌入卡 `disabled`。同一 id 不一致极可能也是本轮 `set-focus failed`(`probe_mismatch`)的原因。
|
||||
|
||||
**修法**:
|
||||
1. `keepAcceptedFocus` 分支(以及 `out_of_sample_check` 同路径)承接时**沿用已持久化 focus 的 `questionId`、`ask_theme`、`domain`**:`makeFollowup` 前先从 `focus.questionId` / `focus.expectedAnswerSchema` 还原 `method_id`/`ask_theme`(reverse_verify 的 question_id 形状是 `${method_id}:${ask_theme}:score`),保证 `stableFollowupQuestionId(keepNext) === focus.questionId`。不要靠放宽 `projectRectificationChoiceCard` 的校验来绕过——校验是防串题的,要保留。
|
||||
2. `distinguish_candidates` 的 keep 分支不动(它有 `semantic_key`),但补一条断言测试防止同类回归。
|
||||
3. set-focus 在 active focus 为 reverse_verify 时:`validateSpokenPrompt` 的 `questionId` 比对必须能通过(同上),且题干年份要求按 `probe_year`(核对探针有 year,如 2016)执行。
|
||||
4. 前端 `liveQuestion` 对 `kind === "reverse_verify"` 的题也走同一条件;`choiceCardFromQuestion` 的 `scoring: question.kind !== "reverse_verify"` 语义保留。
|
||||
|
||||
## 3. P1 · 采用卡沉淀进历史、采用后的界面形态
|
||||
|
||||
现状:三张卡永远锚在 `latestSettledAssistant`,`busy` 时隐藏;采用后的续跑把它们抹掉,跑完又出现在新消息下(05:06 标"已采用")——再次与核对题并存。"用这个时间看盘"是 `savedStatus==="accepted"` 时挂在列表末尾的裸按钮(`rectification-consult-handoff`)。
|
||||
|
||||
**目标形态**(产品拍板):
|
||||
1. 出卡的那条消息**拥有**这组卡:`RenderMessage` 增加 `candidateOffer`(result_id + 三候选 + 出卡时的 session_outcome),出卡时写入该消息,刷新后由 `turns[].receipt` / `latest_result.resultId` 重建(服务端 GET 给每条 assistant turn 补 `offer_result_id`,或在 turn receipt 里记 `candidates.offered`)。卡只在拥有它的消息里渲染,不随最新消息漂移。
|
||||
2. 点"采用"后,这组卡原地变成结算态:05:06 "已采用",其余两张保留"改选为此时间";不再在后续消息里重复出卡。
|
||||
3. 采用后在会话顶部(或 composer 上方)出一条常驻状态条:"已采用 05:06 · 范围 04:53–05:06 · 改选 · 用这个时间看盘"。"用这个时间看盘"从列表末尾移进状态条;`rectification-consult-handoff` 删除。
|
||||
4. 核对题(reverse_verify / out_of_sample_check)的卡片不显示"先这样,先看当前范围",改为"这题跳过"(走现有 `skipped`)。
|
||||
5. 采用瞬间被 `isStalePreAdoptFocus` 关掉的采集题:对应消息里的题干保留、卡片灰掉并标"已跳过(已采用 05:06)",不能像现在这样无痕消失。
|
||||
|
||||
## 4. P1 · 采用后的续跑话术与 set-focus 失败
|
||||
|
||||
1. 续跑(`send("read_only","")`)的 Agent 正文必须承认已采用并说明接下来是核对:`method-followup.ts` accepted 分支的 `user_prompt_hint` 已写"当前排盘已采用……",但 `active_focus` 承接分支的 hint 没有;两处统一,并在 `agentic-rectification.ts` 指令里加一条"case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对"。
|
||||
2. §2 修好后,在 `rectification-question-ownership.test.ts` 追加"采用后续跑:Agent 只传 spokenPrompt + 服务端 questionId → set-focus `ok:true`、focus id 不变、选项 4 个"。
|
||||
3. set-focus `failed` 的 receipt 目前只有 `safeErrorCode`;把 `reason`(`year_missing` / `probe_mismatch` 等)也写进 `tool_activities[].detail`,后台可查。
|
||||
|
||||
## 5. P2 · 运行可观测与超时
|
||||
|
||||
178 秒的运行从回执上分不出时间花在哪(`tool_activities` 只有 tool/status/methods)。
|
||||
|
||||
1. `tool_activities[]` 增加 `started_at` / `elapsed_ms`;`rectification-compare-candidates` 额外记引擎各段耗时(engine compare / vedastro-validate / persist),来源用 `engine-client.ts` 已有的调用边界,不改 Python。
|
||||
2. 前端时间线:某一步超过 45 秒时,状态文案从"读取校正记录/正在分析"切成"引擎在比较候选,可能需要一两分钟"(按当前活跃步骤,不是固定第一步);超过 `RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS` 前 20 秒提示"即将超时,会自动重试或提示"。
|
||||
3. 采用后的续跑是 `read_only`:确认 `compare-candidates` 在该动作下不重复调用 `vedastro-validate`(上一轮 receipt 已有 `vedastro_event_validation` 且 `evidenceLedgerFingerprint` 未变时直接沿用);若现有代码已如此,写一条测试固定它;若没有,加这条快路径。不得改确认门语义(沿用的结果仍是 failed 就仍 failed)。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- `rectification-decide-next-action.test.ts` / `rectification-confirmation-gate.test.ts`:`collect_evidence` + `capability.canAdopt=true` → `can_adopt=false`、`selection_allowed` 不变;`provisional_range_user_stopped` → true;`adopt_representative` → true。
|
||||
- accept route 测试:`session_outcome=collect_evidence` 时 POST 返回 409 且 RPC 未调用。
|
||||
- `rectification-candidate-result.test.ts`:快照一形状 → `showSelectionCards` 为 false,只读范围行可渲染;快照二形状(accepted)→ 不再出新卡。
|
||||
- `rectification-server-focus.test.ts` / `rectification-choice-card.test.ts`:accepted + active reverse_verify focus → `projectRectificationChoiceCard` 非空、`focus_id` 一致、`question_id === focus.questionId`;连续两次 GET 结果一致。
|
||||
- `rectification-question-in-message.test.ts`:采用后消息里的核对题 `liveQuestion=true`(结构断言:`choice_card.focus_id === turns[last].question.focus_id`)。
|
||||
- 组件/渲染测试:采用后卡片留在原消息、05:06 "已采用"、新消息无卡;状态条含"用这个时间看盘";`rectification-consult-handoff` 不再渲染。
|
||||
- `rectification-activity-receipt.test.ts`:`elapsed_ms` 序列化与时间线行映射。
|
||||
- 用 §0 的两份快照做 fixture(脱敏后放 `frontend/tests/fixtures/rectification-case-bf7a8de6-*.json`)。
|
||||
|
||||
## 7. 硬红线
|
||||
|
||||
1. 确认门恒 fail-closed;`can_adopt` 只能收紧不能放宽;不得用正文字符串判断状态。
|
||||
2. 选项、answer_class、计分仍全部服务端所有;Agent 只写 `spokenPrompt`。
|
||||
3. 不恢复问题槽、不改 `asked_turn_id` 契约;不改 `d9404976` / `8dfe457f` 已定的消息形态。
|
||||
4. Python 引擎不动;Skill 10.0.14 不 bump。
|
||||
5. `./node_modules/.bin/tsc --noEmit` 通过(不用 `npx tsc`);`npm run lint --prefix frontend` 0 错误;`rectification-*` / `consultation-*` / `consult-*` / `chat-*` 测试 fail=0;改动的既有断言逐条三栏说明(旧→新→保留语义)。
|
||||
6. 无凭据不得声称已真实环境验证。
|
||||
|
||||
## 8. 开工前置
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-adopt-flow-20260902 \
|
||||
../.worktrees/rectification-adopt-flow-20260902 origin/staging
|
||||
```
|
||||
|
||||
读 `TASK-rectification-question-in-message-20260902.md`、`TASK-rectification-question-ownership-fix-20260902.md`、`docs/BUG_HISTORY.md`(BUG-313、BUG-323 及 BUG-492 起的链)、`frontend/docs/VOICE.md`。BUG 编号从 **BUG-495** 起(先 grep 确认最大号):495 = 采集阶段出采用卡(复发自 313/323),496 = 采用后核对题不可答,497 = 采用卡不沉淀/裸按钮,498 = 运行耗时不可观测。**行号是线索,按符号名定位。**
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. 快照一形状:GET `can_adopt=false`,页面只有采集题 + 只读范围行,无"采用此时间";composer 旁有"先这样"入口,点后 `session_outcome=provisional_range_user_stopped`、三张卡出现。
|
||||
2. 快照二形状:GET `choice_card` 非空且 `focus_id` 与最新题一致;A–D 可点,点后 `answer_option` 落库、候选比较更新。
|
||||
3. 采用后:卡片留在原消息且 05:06 标"已采用";新消息无卡;状态条含"改选"和"用这个时间看盘";列表末尾无裸按钮;被关掉的采集题标"已跳过"。
|
||||
4. 采用后续跑:正文第一句承认已采用;set-focus `completed`;核对题卡片无"先这样",有"这题跳过"。
|
||||
5. 回执含每步 `elapsed_ms`;45 秒后文案切换。
|
||||
6. tsc + lint + 四组测试 fail=0;BUG_HISTORY 追加 495–498。
|
||||
7. 真实环境人工清单(部署后):走一遍"两件事 → 三道区分题 → 采集题阶段无卡 → 先这样 → 出卡 → 采用 → 核对题可答 → 看盘"。
|
||||
Reference in New Issue
Block a user