# TASK · 生时校正修复单:答完性格卡后每次候选比较都静默失败,快照过期把对话拖进死胡同(2026-09-07) - 基线:`origin/staging` @ `7cf7705a`(代码头 `8e31680b`,staging 已部署该版本,事故就是这版跑出来的) - 分支:`codex/rectification-stale-compare-fix-20260907` - 执行方:coding agent;验收:Claude - 涉及文件:`frontend/src/lib/rectification-agentic/v9/inference-adapter.ts`、`engine-client.ts`、`frontend/src/mastra/rectification-v9-tools.ts`、`frontend/src/lib/rectification-agentic/core/rectification-decision.ts`、`v9/answer-choice.ts`、`v9/method-followup.ts`(`holdoutFollowupFor`)、`v9/agent-run.ts`、`scripts/rectification/contracts.py`、`frontend/src/lib/rectification-agentic/user-copy.ts` - BUG 编号起点:**BUG-575**(BUG-574 已被报告列表修复占用;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核) - 优先级:**P0**,先于 `docs/testing/rectification-scenarios-20260907.md` 的走查;不修这条,走查全部作废 ## 1. 事故实证(staging,2026-09-07,产品负责人实测;Case JSON 已由产品负责人提供,本单只写结构,不写年份与经历) 对话走向:开场 → 三条口述采集(学业、感情、工作,共 5 件带年月事件)→ 引擎比较成功(21.6 s)→ 两张性格卡(D9 相处方式、D10 做事风格)→ 三张年月存在卡 → 家人拒答 → 财务采集(1 件)→ 搬家采集(1 件)→ 健康拒答 → 职业口述 → 助手说"也再说一件你能记得大致时间的事吧" → 用户点「先这样,先看当前范围」→ 助手写了一段"再问下去也分不开 X 和 Y…下面是这次的结果" → **没有候选卡**,界面只有"目前范围 X–Y,还在收窄"和"没有拿到下一个问题"。 Case JSON 里的关键事实: | # | 事实 | 位置 | | --- | --- | --- | | 1 | `latest_result.createdAt` 停在第 3 条口述之后;财务与搬家两轮的 `rectification-compare-candidates` 都是 `status: failed, detail: {error: "tool_failed"}`,耗时 28 ms / 37 ms(不是引擎超时,是请求被拒) | `turns[].receipt.tool_activities` | | 2 | 财务轮之后 `askedProbeKeys` 里带上了两张性格卡的 `candidate_split_hash`,形如 `04:45-05:15:<9 个分钟>:varga.d9.<分组>`,**长 128 字符**;nakshatra 边界探针的 hash 126 字符 | `inference_state.answered_probes[].candidate_split_hash` | | 3 | 引擎 `normalize_rectification_request` 对 `asked_probe_keys` 每条限 **120 字符**,超限直接 `ValueError` → `BadRequest` → 工具抛错 | `scripts/rectification/contracts.py` L339 | | 4 | TS 把 `probe_id`、`semantic_key`、`candidate_split_hash` 三种键都塞进 `asked_probe_keys` | `inference-adapter.ts::askedProbeKeysFromReceipt` L53–55(BUG-559 引入) | | 5 | 于是:**任何 varga_style / nakshatra 卡答过之后,后续每一次比较都失败**;账本已有 7 件事,`latest_result` 只算了 5 件,`evidenceLedgerFingerprint` 与当前账本不等 → `snapshotCurrent=false` | `decision-from-dossier.ts::scoreableSnapshotCurrentFromDossier` | | 6 | `decideRectification` 的 `snapshotCurrent === false` 分支排在 `userStopped` 之前:用户点了停止、`case.status=paused`,决策仍是 `collect`(`type: ask_fact_collection`、`session_outcome: collect_evidence`、`can_adopt: false`) → 卡片条件 `ADOPT_OUTCOMES` 不满足 → 无卡;`current_question: null` → 客户端显示"没有拿到下一个问题" | `rectification-decision.ts` L285–290;`rectification-surface-state.ts` L31 | | 7 | 工具失败被 Agent 吞掉:助手正文仍写"这条记下了…对缩小候选范围很有帮助",用户不知道比较根本没跑 | `rectification-v9-tools.ts` L2073 只记 receipt 后 `throw`,agent-run 继续作答 | | 8 | "也再说一件你能记得大致时间的事吧"来自 `oos_blind:holdout` 焦点(`kind: reverse_verify`):`holdoutFollowupFor` 从过期 `oos_blind_prompts` 里取了"财务这条线还没用过"(账本其实已有财务事件),模型把它改写成泛泛的"再说一件事" | `method-followup.ts` L1684–1694;`next_user_action.user_meaning` | | 9 | 采集轮正文重复:第 1、2 轮助手文字各出现两段近似复述;工作采集题在正文出现一次、问题块又出现一次 | `turns[2].text`、`turns[4].text` | ## 2. 根因 1. **BUG-575(P0)**:`asked_probe_keys` 把 `candidate_split_hash` 也传给引擎,varga/nakshatra 的 hash 超过引擎 120 字符上限,比较请求被 400 拒绝。BUG-559 只在 `test_rectification_event_probes.py` 用短键测过,没有用真实 split hash 测过整条 compare。 2. **BUG-576(P1)**:比较失败没有任何用户可见后果:receipt 只有 `tool_failed`,没有错误码;Agent 继续写"记下了、很有帮助";下一轮也不重试。 3. **BUG-577(P1)**:`decideRectification` 让"快照过期"压过"用户已停止",停止后交付不出来;`persistNextInterviewIfIdle` 的停止路径也不先重算。 4. **BUG-578(P1)**:`holdoutFollowupFor` 只排除拒答领域,不排除账本里已有事件的领域,而且用的是可能过期的 `oos_blind_prompts`;焦点是 `reverse_verify`,题干交给模型自由改写,成了"再说一件事"。 5. **P2**:采集轮正文双写;题干进正文。 ## 3. 决策记录 1. `asked_probe_keys` 只传 `semantic_key`(去重、≤120),不传 `probe_id` 与 `candidate_split_hash`;引擎侧上限提到 200 并对超长键**跳过而不是拒绝**(记 `dropped_asked_probe_keys` 计数进 receipt)。TS 侧再加一层保险:超过 200 的键在 `engineRequestBody` 里过滤并 `console.warn`。 2. 比较失败必须可见:receipt `detail` 带 `safe_error_code` 与引擎返回的错误文本前 120 字;agent-run 在工具失败后给正文追加服务端固定句"候选比较这次没跑成,下一句话时会自动再试";下一轮 `persistNextInterviewIfIdle` 见 `snapshotCurrent=false` 且账本有可评分事件时先重算一次(复用 `rescoreMinuteAfterWindowChange`),再决策。 3. 用户停止优先于快照过期:`decideRectification` 把 `userStopped && ranked.length>0` 提到 `snapshotCurrent` 判断之前;停止路径(route 的 `stop_and_review` 与 `applyRectificationChoice` STOP)在决策前先尝试重算,重算失败则按上一次有效结果交付,旁白加一句"这是按上一次成功比较给出的范围"。 4. `holdoutFollowupFor`:候选领域 = `oos_blind_prompts` 中 **既未拒答、账本里也没有该领域带年月事件** 的领域;没有则 holdout 记为 `unavailable`,直接进交付;题干用服务端固定句(`USER_COLLECT_QUESTION[domain]` 同源,不给模型改写),焦点 kind 改为 `collect_spoken` 而不是 `reverse_verify`。 5. 正文双写:`composeCollectSpokenAssistantText` 与模型正文若都包含采集题,只保留问题块;模型正文里与 `spokenPrompt` 相同或高度相似(前 12 字相同)的句子删除;两段复述的根因要查(`phases` 里 `intent.classified` 出现两次),进度记录写明。 ## 4. 硬红线 - 不动采用门、确认门、`MIN_SEPARATION_LEAD`、`_relative_support`。 - `minute_step=1` 路径字节不变(指纹用例)。 - BUG-559 的去重用例照常通过(去重靠 `semantic_key`,不靠 hash)。 - 测试总数 ≥ 1899;tsc 0 错;lint 0 error。 - 任务书 / 进度 / Bug 历史不得写入产品负责人 Case JSON 里的年份、经历、Case ID。 ## 5. 任务分解 ### 5.1 BUG-575 - `askedDiscriminatorKeys` 拆成两份:`askedSemanticKeys`(给引擎)与现有全量(给 TS 去重);`engineRequestBody` 只用前者并过滤 >200;`contracts.py` 上限 200、超长跳过、计数进 `decision_receipt.dropped_asked_probe_keys`。 - 验收:`tests/test_rectification_input_contract.py`——128 字符键不再报错、被跳过并计数;TS `rectification-v9-engine-contract.test.ts`——用真实 varga split hash(≥128 字符)构造 receipt,`engineRequestBody.asked_probe_keys` 全部 ≤120 且不含 `:varga.`;**端到端**:`rectification-probe-year-dedupe` 加一例——答过 D9 卡后 compare 请求体合法。 ### 5.2 BUG-576 - receipt failed detail 加 `safe_error_code` + `engine_message`(截 120 字,不含用户资料);agent-run 工具失败 → 正文追加固定句;`persistNextInterviewIfIdle` 与 `inspectNonTerminalTurnExit` 在 `snapshotCurrent=false` 时先重算一次(有节流:同一证据指纹只重试一次,记 `rescore_attempted_fingerprint`)。 - 验收:`rectification-exhaustion-exit` 或新文件——比较抛错的回合正文含固定句;下一回合看到过期快照会调用一次 `runV9CandidateScore`,成功后决策不再是 `collect`。 ### 5.3 BUG-577 - `decideRectification` 顺序调整(决策 3);stop 路径先重算。 - 验收:`rectification-decision-authority.test.ts` 三栏——`snapshotCurrent=false && userStopped && ranked>0` → `completeWithRange(user_stopped)`,`session_outcome ∈ ADOPT_OUTCOMES`;`rectification-window-widen` 或 answer-choice 测试——STOP 在过期快照上先重算再交付。 ### 5.4 BUG-578 - `holdoutFollowupFor` 过滤已有事件领域;无可问领域 → `holdoutValidation="unavailable"`;焦点改 `collect_spoken` + 服务端题干。 - 验收:`rectification-choice-card` 或 `collect-direction` 测试——账本已有财务事件且家人/健康拒答 → 不出 holdout 题,决策直接交付;有可问领域时题干等于 `USER_COLLECT_QUESTION[domain]`,不是"再说一件事"。 ### 5.5 P2 正文双写 - 定位 `intent.classified` 双触发与正文拼接;去重规则见决策 5。 - 验收:`rectification-spoken-collect.test.ts`——采集轮最终正文不含与 `spokenPrompt` 重复的句子;同一段复述不出现两次。 ### 5.6 记录 - `docs/BUG_HISTORY.md` BUG-575~578(575 标"复发自 BUG-559");`CHANGELOG.md`;`PROGRESS-rectification-stale-compare-fix-20260907.md`;`docs/testing/rectification-scenarios-20260907.md` 加第 0 条"答完性格卡后再说一件事,顶部范围必须变化或旁白说明比较结果"。 ## 6. 让步顺序 5.1 单独就能解封线上,**当天必须合入并部署**;5.2 + 5.3 其次;5.4 必做;5.5 可后置到同分支第二次提交;5.6 不可省。 ## 7. 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/rectification-stale-compare-fix-20260907 .worktrees/rectification-stale-compare-fix-20260907 origin/staging cd .worktrees/rectification-stale-compare-fix-20260907 ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules ln -s /workspace/Jyotisha/.venv .venv .venv/bin/python -c "print(len('04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15'))" # 128 .venv/bin/python -m pytest tests/test_rectification_input_contract.py tests/test_rectification_v5_services.py -q cd frontend && ls tests/rectification-*.test.ts tests/consultation-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)" ``` ## 验收(Claude,2026-09-07,`origin/staging` @ `84a29754`,实现 `4e0db55f`;落地编号 BUG-577~580) | 门 | 结果 | | --- | --- | | tsc / lint | 0 错 / 0 error(84 warning,既有类型) | | 前端 rectification + consultation + birth-time + voice(非 DB) | 1556 / 0 | | Python input_contract / v5_services / event_probes / engine_convergence / growth contract | 全绿 | | 引擎实测:把事故里 128 字符的 varga split hash + 一个 250 字符键传给 `normalize_rectification_request` | 不再 400:128 字符保留,250 字符跳过并计数 `dropped_asked_probe_keys=1`,`score_candidates` 正常返回,receipt 带计数 | | `minute_step=1` 字节级不变(虚构 7 件事新旧复跑) | candidate_scores / result_id / spec_hash 全等 | | 项 | 结论 | | --- | --- | | 5.1 BUG-577 | 通过。TS 只传 `semantic_key` + 账本年份键,再过滤 `:varga.` 与 >200;引擎上限 200、超长跳过计数。与任务书"跳过 128"冲突处按决策 1(上限 200)处理,执行方已写明 | | 5.2 BUG-578 | 通过。失败 receipt 带 `safe_error_code` + `engine_message`(120 字);正文追加固定句;闲置/非终止退出在快照过期时先重算,同 case+指纹+turn 只一次 | | 5.3 BUG-579 | 通过。`userStopped && ranked>0` 提前;STOP 与无焦点停止先重算,失败则按上一次成功比较交付并加旁白 | | 5.4 BUG-580 | 通过。holdout 领域排除账本已有带年月事件;题干改 `USER_COLLECT_QUESTION[domain]` 口述采集。执行方没把决策层改成 `unavailable`(避免落到无卡的 offer),理由成立 | | 5.5 P2 正文双写 | 部分:只做了与题干近重复句的删除;两段复述的根因(`intent.classified` 双触发)未拆,执行方如实记为偏离 | | P3 | `rescoreAttempts` 是进程内 Map,只增不减(有测试用 reset 钩子),长期运行会缓慢增长;建议加上限或按 case 清理 | 部署:staging 当前 `3f4d38d4`,本轮无迁移,直接部署即可;部署后按 `docs/testing/rectification-scenarios-20260907.md` 第 0 条先复现"答完性格卡再说一件事"。