Files
Jyotisha/docs/tasks/TASK-rectification-stale-compare-fix-20260907.md
T

114 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 事故实证(staging2026-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` L5355BUG-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` L285290`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` L16841694`next_user_action.user_meaning` |
| 9 | 采集轮正文重复:第 1、2 轮助手文字各出现两段近似复述;工作采集题在正文出现一次、问题块又出现一次 | `turns[2].text``turns[4].text` |
## 2. 根因
1. **BUG-575P0**`asked_probe_keys``candidate_split_hash` 也传给引擎,varga/nakshatra 的 hash 超过引擎 120 字符上限,比较请求被 400 拒绝。BUG-559 只在 `test_rectification_event_probes.py` 用短键测过,没有用真实 split hash 测过整条 compare。
2. **BUG-576P1**:比较失败没有任何用户可见后果:receipt 只有 `tool_failed`,没有错误码;Agent 继续写"记下了、很有帮助";下一轮也不重试。
3. **BUG-577P1**`decideRectification` 让"快照过期"压过"用户已停止",停止后交付不出来;`persistNextInterviewIfIdle` 的停止路径也不先重算。
4. **BUG-578P1**`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)。
- 测试总数 ≥ 1899tsc 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-575578575 标"复发自 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)"
```
## 验收(Claude2026-09-07`origin/staging` @ `84a29754`,实现 `4e0db55f`;落地编号 BUG-577580
| 门 | 结果 |
| --- | --- |
| tsc / lint | 0 错 / 0 error84 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 条先复现"答完性格卡再说一件事"。