Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse_Chen 7ca6ade244 fix(rectification): stop re-asking covered domains after adopt (BUG-590, BUG-591)
Independent Staging Quality Gate / validate (push) Successful in 11m33s
Independent Staging Quality Gate / publish (push) Successful in 7m49s
Yearless-to-collect now skips domains already confirmed, declined, or asked; adopt-exhausted turns skip leftover collect persist. Collect focus collisions only retry with :next when collect_retry is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 12:27:40 +08:00
Jesse_Chen 43fd8875ce docs(tasks): accept BUG-584~589 fixes; covered-domain recollect fix brief (BUG-590/591)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-08 03:39:11 +00:00
15 changed files with 754 additions and 38 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-08 — 范围收到后不再把已答领域再问一遍
生时校正把七个带年月领域和职业问完、范围已经收到、决策已是采用时,下一步是区间交付卡。不会再把已经答过的财务或其他领域采集题拿出来问。同一道采集题撞过题号时,也不会改个后缀再问一次。Skill 版本仍是 10.0.15。
## 2026-09-08 — 补经历后仍按已答对照题收范围,变宽会说出来
生时校正答完对照卡后再补一件经历,会把已经答过的题按分钟重算进去,不会因为候选分钟换了两个就把范围弹回开工窗口。如果这次比较让范围变了,旁白末尾会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.15。
+32
View File
@@ -9142,3 +9142,35 @@
- 复发自:无
- 修复版本:待提交(守卫已加,现象仍 investigating
## BUG-590 | 范围已收到 2 分钟、决策已是采用,却再问答过的财务采集题
- 状态:resolved
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`method-followup.ts` 职业覆盖后的 yearless→采集分支、`shouldSkipFollowupPersist``holdoutFollowupFor`
- 用户现象:范围已经收到大约 2 分钟,决策已是采用,输入框上方却再问一遍已经答过的财务采集题,区间交付卡出不来。
- 触发条件:七个带年月领域和职业都覆盖或拒答;yearless 目录里还有无年份分盘对照(本例 D11 财务);闲置路径带着 `contrastPacket` 持久化下一问;财务账本已有带年月确认事件且该领域采集焦点已 `resolved`
- 根因:职业覆盖后的 yearless→采集分支只取 `yearlessDiscriminators[0]`,只跳过拒答,不看该领域是否已有带年月事件、是否已经问过 `collect:<domain>:*`。财务已覆盖仍被改写成 dated collect。`shouldSkipFollowupPersist` 只要 `isRemainingEvidenceCollect` 为真就不跳过,采用被这道题挡住。holdout 的 `occupied` 不认 `health` / `health_pressure` 同义,答过健康后仍可能再问健康线。
- 修复:遍历全部 yearless 对照,跳过拒答、已确认(含健康同义)和已问过采集的领域,全部跳过则不再派采集。采用且 `probe_pool_exhausted` 时,剩余采集只计「该领域没有确认事件且未拒答」的题,否则跳过持久化并写 `terminalNote`。holdout `occupied``health``health_pressure` 归并。不动采用门、确认门、Skill 版本(仍 10.0.15)。
- 验证:`frontend/tests/rectification-occupation-coverage-exit.test.ts` 事故形状:yearless→采集 `next_followup === null`;迁居未覆盖仍问迁居;idle persist `terminalNote: true` 且 0 次 set-focusagent-run 闸门轮与 `canShowRectificationSelectionCards`holdout 健康同义。`frontend/tests/rectification-server-focus.test.ts` 见 BUG-591。`frontend` `tsc --noEmit` 0;指定 TS 切片 1006/0。
- 防复发:yearless→采集不得只取第一道分盘题。已覆盖或已问过的领域不得再派 dated collect。采用穷尽时不得把已覆盖领域的采集当剩余采集挡住出卡。holdout occupied 必须与 `hasConfirmedHealth` 同口径。`USER_COLLECT_QUESTION.other` 不得回潮。
- 相关记录:BUG-586、BUG-587、BUG-591
- 复发自:BUG-586(同一「职业覆盖后多问一题」现象,不同分支);BUG-587(收敛成功后才暴露)
- 修复版本:待提交(`codex/rectification-covered-domain-recollect-20260908`
## BUG-591 | 非 retry 采集焦点撞 id 时无条件加 `:next` 再插一次
- 状态:resolved
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`server-focus.ts::persistCollectFocus``persistFocusAfterChoice`
- 用户现象:财务采集题已经问过并关闭,下一轮仍以 `collect:finance:collect_method_evidence:next` 落库,题干与已答财务题相同。
- 触发条件:计划层再次派出同一领域的非 `collect_retry` 采集题;`set_agentic_rectification_conversation_focus``focus_idempotency_conflict`
- 根因:`75fc456d`BUG-462 附带)在撞 id 时一律改 `${questionId}:next` 再插,原本给穷尽采集撞开场题号用。BUG-586 删掉 other 穷尽采集后,这条路径只剩副作用:已覆盖领域只要被计划层重复派出,就能以 `:next` 落地。
- 修复:撞 id 时仅 `followup.collect_retry === true` 才用 `:next`;否则返回 `duplicate_focus`、不插入。`persistFocusAfterChoice``duplicate_focus` 当已处理、不再读 dossier 重试。
- 验证:`frontend/tests/rectification-server-focus.test.ts`:非 retry 冲突 `status: "duplicate_focus"` 且 RPC 一次;`collect_retry: true` 仍以 `:next` 落地。
- 防复发:非 retry 采集撞 id 不得再加 `:next`。retry 采集仍须能以 `:next` 落地。
- 相关记录:BUG-462、BUG-586、BUG-590
- 复发自:BUG-462`:next` 重试本为穷尽采集撞开场题号)
- 修复版本:待提交(`codex/rectification-covered-domain-recollect-20260908`
@@ -0,0 +1,44 @@
# PROGRESS · 已覆盖领域再采集(2026-09-08
工作树:`.worktrees/rectification-covered-domain-recollect-20260908`
分支:`codex/rectification-covered-domain-recollect-20260908`
任务书:`docs/tasks/TASK-rectification-covered-domain-recollect-20260908.md`
基线:`origin/staging` @ `43fd8875`
本单状态:**待验收**。本提交交付到 staging。
未改:采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1``SCORE_DELTA`、四选项合同、Skill 版本(仍 10.0.15)。
## 做了什么
- **决策 1** `method-followup.ts` 职业覆盖后的 yearless→采集:遍历全部 `yearlessDiscriminators`,跳过拒答、`hasConfirmedDomain`(含 `health`/`health_pressure`)和已问过 `collect:<domain>:*` 的领域。全部跳过则 `next` 保持空。
- **决策 2** `shouldSkipFollowupPersist``canAdopt` 且(`nextAction === "ready_to_adopt"``sessionOutcome === "adopt_representative"`)且 `stopReason === "probe_pool_exhausted"` 时,`isRemainingEvidenceCollect` 只有该领域没有确认事件且未拒答才算剩余采集;否则跳过持久化、`terminalNote`
- **决策 3** `persistCollectFocus``focus_idempotency_conflict`:仅 `collect_retry === true``:next`,否则 `duplicate_focus``persistFocusAfterChoice` 不再为 `duplicate_focus` 读 dossier 重试。
- **决策 4** `holdoutFollowupFor` 的 occupied 归并 `health` / `health_pressure`
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 事故形状 yearless→采集 | `next_followup.domain = finance`(取 yearless[0]=D11 | `null` | 决策 1,财务已覆盖 |
| 同形状但迁居未覆盖 | finance | relocation | 守卫只跳过已覆盖领域 |
| idle persist 事故形状 | 财务采集焦点 / `:next` | `terminalNote: true`0 次 set-focus | 决策 1+2 |
| 非 retry 采集撞 id | 任何撞 id 都 `:next` | `duplicate_focus`RPC 一次 | 决策 3 |
| `collect_retry` 撞 id | `:next` | 仍 `:next` | 保留 BUG-462 retry |
| holdout 账本 `health` + oos `health_pressure` | `health_pressure` | `null` | 决策 4 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- `npm run lint`0 error(仓库既有 warning 未清)
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1006 passed / 0 failed**
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- `block_scan` 壁钟(BUG-570 P2):既有缺口,本单不声称全量 Python 门通过
- 浏览器七领域真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6
## 偏离
1. 任务书写 `nextAction === "adopt_representative"`。实现同时认 `ready_to_adopt``sessionOutcome === "adopt_representative"`,因为公开 nextAction 是前者。
2. 验收形状扩在 `rectification-occupation-coverage-exit.test.ts`,没有新建文件。
3. idle 夹具按解析后账本算 `evidenceLedgerFingerprint`,避免 RPC `summary` 与内存账本对不上把快照判成过期、决策掉回 `collect_evidence`
4. `rectification-eight-method.test.ts`yearless D24 在 CLASSIC_COVERAGE 已有学业时,不再锁「再问一遍学业」;改为收未覆盖的财务。
+4 -3
View File
@@ -72,9 +72,10 @@
| `TASK-rectification-stop-rescore-fix-20260907.md` | `PROGRESS-rectification-stop-rescore-fix-20260907.md` | **P0**:BUG-579 的停止路径用「裸重算」(`rescoreMinuteAfterWindowChange` 不重建 `inference_state`、不重放已答探针、不写 transition),停止后范围变宽、卡片变成引擎裸支持度、采用 RPC 报 `candidate_state_inconsistent`;无框区分题经 `spokenCollectFallbackFollowup` 变成 domain=other 的采集题,再次冒出开场句 `GENERIC_COLLECT_QUESTION`;holdout 采集排在带年月采集之前 | 已验收通过(逻辑);但同提交含区间卡 tsc 错,整体不可部署 | `ab57d03f`BUG-581582 |
| `TASK-rectification-range-delivery-card-20260907.md` | `PROGRESS-rectification-range-delivery-card-20260907.md` | 结尾重设计:四张分钟卡 + 相对支持度换成一张区间交付卡(范围、代表分钟、稳定/敏感主题、边界句)+ 只列未问 D9/D10/月宿分歧的 2~3 列对比面板(文案只取类型表),动作只有「按这个范围用 / 再补一件经历」;删交付旁白里三行预测窗口;Skill §9 口径改、bump 10.0.15 | **未通过**:tsc 1 错(判空顺序);P2 预测窗口句兑现不了;P3 再补经历无提示 | `ab57d03f`;修复单 `TASK-rectification-range-delivery-fix-20260907.md` |
| `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待验收 | `codex/rectification-range-delivery-fix-20260907`BUG-583 |
| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 验收 | `codex/rectification-duplicate-narration-20260907`BUG-584585 |
| `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 验收(2.2 触发链仍 investigating | `codex/rectification-other-collect-fallback-20260908`BUG-586 |
| `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 验收 | `codex/rectification-probe-replay-loss-20260908`BUG-587588P3 闪现 investigating |
| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 验收通过 | `9d1c08ca`BUG-584585 |
| `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 验收通过(触发链仍 investigatingBUG-586 mitigated | `9aec5029`BUG-586 |
| `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 验收通过(真实 Case 范围收到 2 分钟;BUG-589 闪现 investigating | `df182c16`BUG-587589 |
| `TASK-rectification-covered-domain-recollect-20260908.md` | `PROGRESS-rectification-covered-domain-recollect-20260908.md` | 范围已收到 2 分钟、决策已是采用,却再问答过的财务题:职业覆盖后的 yearless→采集分支(L2224)不看领域是否已覆盖;`shouldSkipFollowupPersist` 把它当剩余采集挡住出卡;`persistCollectFocus` 撞 id 无条件 `:next` 再插;holdout `occupied` 不认 health/health_pressure 同义 | 待验收 | `codex/rectification-covered-domain-recollect-20260908`BUG-590591 |
| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 |
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505509 |
@@ -0,0 +1,83 @@
# TASK · 生时校正修复单:范围已收到 2 分钟、决策已是采用,却把答过的财务题再问一遍(2026-09-08)
- 基线:`origin/staging` @ `df182c16`BUG-584589 已合入并部署;事故 Case 就是这版跑出来的,Skill 10.0.15
- 分支:`codex/rectification-covered-domain-recollect-20260908`,基于 `origin/staging`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/v9/method-followup.ts`L2224 起的 yearless→采集分支、`holdoutFollowupFor`)、`v9/server-focus.ts``persistCollectFocus``:next` 重试)、`v9/answer-choice.ts``shouldSkipFollowupPersist`
- BUG 编号起点:**BUG-590**`docs/BUG_HISTORY.md` 当前最大 BUG-589
- 优先级:**P1**(收敛已经成功,出口被一道重复题堵死;用户原话"怎么又问相同的问题 而且时间已经收敛到 2 分钟的范围了")
## 1. 事故实证(2026-09-08 staging `df182c16`Case 导出;只写结构)
| 项 | 事实 |
| --- | --- |
| 推断状态 | 6 道题全部重放(`rounds` 6 条、`carried: true`、分盘 key 已是星座分区),`credible_range = 04:5104:53``stop_reason = probe_pool_exhausted` |
| 决策 | `type = ready_to_adopt``session_outcome = adopt_representative``can_adopt = true``step_state.stage = deliver` |
| 当前问题 | `collect:finance:collect_method_evidence:next`,题干 = `USER_COLLECT_QUESTION.finance` 逐字。财务领域账本里已有一条带年月的确认事件(`usage = holdout`),该领域焦点早已 `resolved` |
| 该轮 receipt | `rectification-set-focus` **failed** `focus_idempotency_conflict`;随后闲置路径把同题以 `:next` 后缀落库 |
| 界面 | 没有区间交付卡(采集焦点挂着即 `interviewQuestionBlocksAdoptOffer`),输入框上方是财务题 |
BUG-587 的重放已经把范围收到 2 分钟,出口本该是区间交付卡。
## 2. 根因
### 2.1 BUG-590P1):职业覆盖后的"无年份分盘题→采集"分支不看领域是否已覆盖
`method-followup.ts` L2224`!next && dashaCovered && coverageComplete && occupationCovered` 时取 `yearlessDiscriminators[0]`(本例是 D11 财富分盘的对照题,D24 学业题因账本已提到该分盘被降权、D12 家人题被拒答),把它的领域直接变成一道 **dated collect**`intent = collect_method_evidence``source = method_coverage`、无 `choice_frame`)。分支只检查 `!declined.has(domain)`,不检查该领域是否已有带年月事件、是否已问过采集题。财务已经答过,于是再问一遍。
这个分支的本意是"分盘对照题没有年份可锚,先向该领域要一件带年月的事";领域已经有带年月事件时,再要一件也锚不上(引擎把它标成 `yearless_ungrounded_contrast` 不是因为缺事件),应当跳过该领域、继续找下一个,没有就交付。
链条第二环:`answer-choice.ts::shouldSkipFollowupPersist``canAdopt = true` 的决策,只要 `isRemainingEvidenceCollect(followup)`(领域在 `DATED_COLLECT_ORDER` 里)就不跳过 → 采用被这道题挡住。第三环:工具层 set-focus 用同一 `next_followup` 落库,`questionId` 与已 resolved 的财务焦点相同 → `focus_idempotency_conflict`;闲置路径再来一次。
### 2.2 BUG-591P2):采集焦点撞 id 时无条件加 `:next` 再插一次
`server-focus.ts::persistCollectFocus` L359`insertFocus(questionId)``focus_idempotency_conflict` 就改用 `${questionId}:next` 再插。这是 `75fc456d`BUG-462 附带)为"穷尽采集撞开场题号"加的;BUG-586 删掉 other 穷尽采集后,它只剩副作用——同一领域的采集题只要被计划层重复派出,就一定能以 `:next` 落地。撞 id 本身就是"这题问过了"的信号,非 `collect_retry` 的采集题应当在这里止步。
### 2.3 P2holdout 的 `occupied` 不认 `health` / `health_pressure` 同义
`holdoutFollowupFor` 用账本领域算 `occupied`,账本里健康事件的 `domain = "health"`,而 `oos_blind_prompts``health_pressure`;用户答了健康事件后,holdout 仍会把健康当"没用过的线"再问一遍。`datedCollectDomainBlocked` 已经用 `hasConfirmedHealth` 处理了这对同义词,`holdoutFollowupFor` 没有。本例没触发(财务题先挡住),但下一次会。
### 2.4 记录:转录里选择题题干看似出现两次
选择题的题干在问题块 `<p>` 与卡片 `<legend class="sr-only">` 各一份,后者只给读屏,复制文本时会一起带出。视觉上只有一份,不是缺陷,不立单。
## 3. 决策记录
1. **L2224 分支加覆盖守卫。** 遍历 `yearlessDiscriminators`(不只取 `[0]`),跳过:`declined` 领域、`hasConfirmedDomain`(含 `health`/`health_pressure` 同义)为真的领域、账本或已关闭焦点里已出现过 `collect:<domain>:*` 的领域。全部跳过则 `next` 保持空。
2. **采用优先。** `shouldSkipFollowupPersist``canAdopt && nextAction === "adopt_representative" && stopReason === "probe_pool_exhausted"` 时,`isRemainingEvidenceCollect` 只有在该领域**没有**确认事件且未拒答时才算"剩余采集";否则跳过持久化、走交付(`terminalNote`)。
3. **撞 id 不再 `:next`。** `persistCollectFocus``focus_idempotency_conflict``followup.collect_retry === true` 才用 `:next`,否则返回 `duplicate_focus`、不插入。`persistFocusAfterChoice``duplicate_focus``skipped` 处理,继续走交付分支。
4. **holdout `occupied` 用同义表**`health``health_pressure` 归并(复用 `hasConfirmedHealth` 的口径)。
5. 不动采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1``SCORE_DELTA`、四选项合同;Skill 不 bump。
## 4. 任务分解
### 4.1 BUG-590(决策 1、2
- 验收(形状用例,新建或扩 `rectification-occupation-coverage-exit.test.ts`):
- (a) 账本:学业 2 件、感情 2 件、财务 1 件(`usage = holdout`)、事业 2 件、迁居 1 件、健康 1 件、职业 1 件;家人拒答;6 道题已答;yearless 目录里 D11/D2(财务)、D24/D5(学业)、D4(迁居)、D7/D12(家人)都在 → `buildMethodFollowupPlan().next_followup === null`
- (b) 同 (a) 但迁居账本为空、未拒答 → `next_followup.domain === "relocation"`(守卫只跳过已覆盖领域,不是删分支)。
- (c) 同 (a) 的 dossier 进 `persistNextInterviewIfIdle`decision `adopt_representative``probe_pool_exhausted`)→ 返回 `terminalNote: true` 的采用旁白,0 次 `setV10ConversationFocus`
- (d) agent-run 集成:该轮结束后 `current_question === null`、客户端 `canOfferCards` 为真。
### 4.2 BUG-591(决策 3
- 验收(`rectification-server-focus.test.ts` 扩):非 retry 采集题撞 id → `status: "duplicate_focus"`RPC 只调一次;`collect_retry: true` 撞 id → 仍以 `:next` 落地(原值 / 新值 / 原因三栏:原"任何撞 id 都 `:next`"→ 新"仅 retry"→ 决策 3)。
### 4.3 P2(决策 4
- 验收:账本 `domain = "health"` 已确认、oos 提示 `health_pressure``holdoutFollowupFor` 不返回健康题。
### 4.4 记录
- `docs/BUG_HISTORY.md` BUG-590(关联 BUG-586:同一"职业覆盖后多问一题"的现象,不同分支;关联 BUG-587:收敛成功后才暴露)、BUG-591(关联 BUG-462 / `75fc456d`);P2 记进 BUG-590 的防复发;`CHANGELOG.md``PROGRESS-rectification-covered-domain-recollect-20260908.md``docs/testing/rectification-scenarios-20260907.md` 加"七领域 + 职业问完后,下一步必须是区间交付卡,不得再出任何已答领域的采集题"。
## 5. 让步顺序
4.1 (a)(c) 当天必做并部署;(b)(d) 必做;4.2、4.3 同一提交;4.4 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-covered-domain-recollect-20260908 .worktrees/rectification-covered-domain-recollect-20260908 origin/staging
cd .worktrees/rectification-covered-domain-recollect-20260908
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```
@@ -74,3 +74,21 @@ ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep
```
开工时 tsc 若仍报 `rectification-agentic-chat.tsx` L1655BUG-583),先确认 `codex/rectification-range-delivery-fix-20260907` 是否已合入;未合入则本单不修它,进度记录里写清基线含该错。
## 验收(Claude2026-09-08`origin/staging` @ `df182c16`staging 已部署同 SHA
| 门 | 结果 |
| --- | --- |
| tsc | 0 错 |
| lint | 0 error / 102 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB | 1253 / 0 |
| Python v5_services + growth contract | 全绿(执行方记的 15s 壁钟用例本机未复现超时) |
| Docker DB 套件、浏览器走查 | 环境缺口,同执行方记录 |
| 项 | 结论 |
| --- | --- |
| 4.1 BUG-584 | 通过。`stemAttached`/`publishedAny` 守卫;set-focus 后第二段 discard;半句 publish;三栏齐全。2026-09-08 真实 Case 每轮只剩一段旁白 |
| 4.2 BUG-585 | 通过。`stripQuestionSentences` 在 agent-run 与 `attachQuestionsToTurns` 两处生效,`collectSpokenEmitted` 删除。真实 Case 正文不再含问句 |
| 4.3 记录 | 通过 |
结论:通过。
@@ -81,3 +81,21 @@ grep -rn "也可以再说一件" src tests # 收工时必须为空
```
`codex/rectification-duplicate-narration-20260907`BUG-584/585,改 `step-answer.ts` / `agent-run.ts` / `collect-prompt.ts`)无文件重叠;谁后合谁 rebase。
## 验收(Claude2026-09-08`origin/staging` @ `df182c16`staging 已部署同 SHA
| 门 | 结果 |
| --- | --- |
| tsc | 0 错 |
| lint | 0 error / 102 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB | 1253 / 0 |
| Python v5_services + growth contract | 全绿(执行方记的 15s 壁钟用例本机未复现超时) |
| Docker DB 套件、浏览器走查 | 环境缺口,同执行方记录 |
| 项 | 结论 |
| --- | --- |
| 决策 15 | 通过。`grep 也可以再说一件` 为空;`parseCollectFocusQuestionId` 让职业承接不再压成 other;同轮二次 set-focus 幂等;`isOrphanOtherCollectFocus` 放行出卡 |
| 2.2 触发链 | 仍 investigating(执行方按事实写 mitigated,接受) |
| 真实 Case2026-09-08 | 职业题正常出现、其后不再出 other 句;但暴露下一道题(BUG-590,见 `TASK-rectification-covered-domain-recollect-20260908.md` |
结论:通过。
@@ -83,3 +83,22 @@ cd .. && .venv/bin/python -m pytest tests/test_rectification_v5_services.py -q
```
与 BUG-584/585`step-answer.ts` / `agent-run.ts` 剪问句)在 `agent-run.ts` 有交叠:范围句接在剪问句之后,**先合 584/585 再做本单**,或本单 rebase 到其上。与 BUG-586 无重叠。
## 验收(Claude2026-09-08`origin/staging` @ `df182c16`staging 已部署同 SHA
| 门 | 结果 |
| --- | --- |
| tsc | 0 错 |
| lint | 0 error / 102 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB | 1253 / 0 |
| Python v5_services + growth contract | 全绿(执行方记的 15s 壁钟用例本机未复现超时) |
| Docker DB 套件、浏览器走查 | 环境缺口,同执行方记录 |
| 项 | 结论 |
| --- | --- |
| 4.1 BUG-587 | 通过。真实 Case 导出:6 道题 `carried: true` 全部重放(`rounds` 6 条),分盘 key 已是星座分区,`credible_range` 04:5104:53(此前同类流程为 29 分钟) |
| 4.2 BUG-588 | 代码路径通过(compare 轮先剪问句再接范围句);真实转录里没有范围变化的轮次可核,留待场景清单 |
| 4.3 P3 | `!busy && caseSnapshotLoaded` 守卫已加;闪现未复现,BUG-589 investigating |
| 4.4 记录 | 通过 |
结论:通过。收敛成功后出口被 BUG-590 挡住,另立修复单。
@@ -101,6 +101,7 @@
- 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡
- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」
- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮
- 职业也问完、决策已是采用后,下一步必须是区间交付卡,不得再出任何已答领域的采集题(例如已经答过的财务)
## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次
@@ -82,12 +82,14 @@ import {
buildNextUserAction,
exhaustionSpokenCollectFollowup,
isRemainingEvidenceCollect,
remainingEvidenceCollectStillOpen,
planWithDateReliability,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
ledgerHasConfirmedDatedEvent,
type MethodCoverage,
type MethodFollowup,
type MethodFollowupEvidence,
type MethodFollowupPlan,
} from "./method-followup";
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
@@ -230,6 +232,10 @@ function shouldSkipFollowupPersist(input: {
followup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
accepted?: boolean;
stopReason?: string | null;
sessionOutcome?: string | null;
evidence?: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
}): boolean {
if (input.accepted) return false;
if (!input.canAdopt) return false;
@@ -244,7 +250,22 @@ function shouldSkipFollowupPersist(input: {
}
if (input.methods && !blockingMethodsCovered(input.methods)) return false;
if (isRemainingDiscriminatorFollowup(input.followup)) return false;
if (isRemainingEvidenceCollect(input.followup)) return false;
if (isRemainingEvidenceCollect(input.followup)) {
const adoptExhausted = (
input.nextAction === "ready_to_adopt"
|| input.sessionOutcome === "adopt_representative"
) && input.stopReason === "probe_pool_exhausted";
if (
!adoptExhausted
|| remainingEvidenceCollectStillOpen(
input.followup,
input.evidence ?? [],
input.declinedTopics ?? [],
)
) {
return false;
}
}
return true;
}
@@ -791,6 +812,10 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
methods: plan.methods,
accepted: Boolean(input.dossier.case.acceptedTime),
stopReason: decision.stopReason ?? null,
sessionOutcome: input.nextAction.session_outcome,
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
})) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
@@ -948,7 +973,12 @@ async function persistFocusAfterChoice(input: {
prompt: null,
};
}
if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) {
if (
persisted.status === "created"
|| persisted.status === "already_open"
|| persisted.status === "duplicate_focus"
|| !input.followup
) {
return persisted;
}
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
@@ -1224,6 +1254,10 @@ export async function persistNextInterviewIfIdle(input: {
followup,
methods: plan.methods,
accepted: Boolean(dossier.case.acceptedTime),
stopReason: decision.stopReason ?? null,
sessionOutcome: decision.sessionOutcome,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
})) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
@@ -1292,6 +1292,67 @@ export function isRemainingEvidenceCollect(
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
function collectFocusAskedForDomain(
topics: readonly Readonly<Record<string, unknown>>[],
domain: string,
): boolean {
if (domain === "health" || domain === "health_pressure") {
return domainCollectFocusAsked(topics, "health")
|| domainCollectFocusAsked(topics, "health_pressure");
}
return domainCollectFocusAsked(topics, domain);
}
function yearlessCollectDomainBlocked(
domain: string,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedRows: readonly Readonly<Record<string, unknown>>[],
): boolean {
if (domain === "health" || domain === "health_pressure") {
return declinedHealth(declined)
|| hasConfirmedHealth(evidence)
|| collectFocusAskedForDomain(askedRows, domain);
}
return declined.has(domain)
|| hasConfirmedDomain(evidence, domain)
|| collectFocusAskedForDomain(askedRows, domain);
}
/** Remaining spoken collect that can still add a dated event in an uncovered domain. */
export function remainingEvidenceCollectStillOpen(
followup: Pick<MethodFollowup, "intent" | "domain" | "date_reliability_evidence_id"> | null | undefined,
evidence: readonly MethodFollowupEvidence[] = [],
declinedTopics: readonly Readonly<Record<string, unknown>>[] = [],
): boolean {
if (!isRemainingEvidenceCollect(followup) || !followup?.domain) return false;
const declined = declinedDomains(declinedTopics);
if (followup.domain === "occupation") {
return !hasConfirmedDomain(evidence, "occupation") && !declined.has("occupation");
}
if ((DATED_COLLECT_ORDER as readonly string[]).includes(followup.domain)) {
return !datedCollectDomainBlocked(
followup.domain as (typeof DATED_COLLECT_ORDER)[number],
evidence,
declined,
);
}
return true;
}
function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set<string> {
const occupied = new Set(
evidence
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
.map((item) => item.domain),
);
if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) {
occupied.add("health");
occupied.add("health_pressure");
}
return occupied;
}
export function exhaustionSpokenCollectFollowup(input: {
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
@@ -1682,17 +1743,22 @@ export function holdoutFollowupFor(
declined: ReadonlySet<string>,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const occupied = new Set(
input.evidence
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
.map((item) => item.domain),
);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
item.domain && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
const occupied = holdoutOccupiedDomains(input.evidence);
const healthDeclined = declinedHealth(declined);
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
if (!item.domain || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => {
if (item.year == null || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
return holdoutAskFields(prompt, reserved);
}
@@ -2223,23 +2289,28 @@ export function buildMethodFollowupPlan(input: {
}
if (!next && dashaCovered && coverageComplete && occupationCovered) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
const yearless = yearlessDiscriminators[0];
if (yearless && (allowLowGainDiscriminator || yearless.score >= 0.08)) {
const askedRows = [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
];
for (const yearless of yearlessDiscriminators) {
if (!allowLowGainDiscriminator && yearless.score < 0.08) continue;
const domain = contrastFollowupDomain(
yearless.eventProbe?.domain ?? yearless.contrastProbe?.domain ?? null,
);
const lead = YEARLESS_COLLECT_LEAD[domain];
if (lead && !declined.has(domain)) {
next = makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
source: "method_coverage",
});
}
if (!lead) continue;
if (yearlessCollectDomainBlocked(domain, input.evidence, declined, askedRows)) continue;
next = makeFollowup({
method_id: PROBE_METHOD_ID[domain],
intent: "collect_method_evidence",
ask_theme: REVERSE_VERIFY_THEME[domain],
domain,
kind_hint: REVERSE_VERIFY_KIND[domain],
user_prompt_hint: collect(lead, REVERSE_VERIFY_VARGA[domain]),
source: "method_coverage",
});
break;
}
}
if (
@@ -356,6 +356,14 @@ async function persistCollectFocus(input: {
prompt: null,
};
}
if (input.followup.collect_retry !== true) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
const retryId = `${questionId}:${COLLECT_FOCUS_RETRY_SUFFIX}`.slice(0, 160);
if (retryId === questionId) {
return {
@@ -2146,7 +2146,7 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt"
}), "discriminate_candidates");
});
test("answered duty language skips window D10 and collects a dated education event instead of a yearless D24 card", () => {
test("answered duty language skips window D10 and does not re-ask covered education from a yearless D24 card", () => {
const packet = {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [
@@ -2185,11 +2185,13 @@ test("answered duty language skips window D10 and collects a dated education eve
evidence,
contrastPacket: packet,
});
assert.equal(plan.next_followup?.domain, "education");
// 原值: educationyearless D24 直接变成学业采集)
// 新值: finance
// 原因: CLASSIC_COVERAGE 已有学业;BUG-590 跳过已覆盖领域,改收未覆盖的财务
assert.equal(plan.next_followup?.domain, "finance");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住年份/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d(10|24)/);
});
test("yearless D24 yields to a dated career dasha instead of borrowing the recorded education year", () => {
@@ -7,15 +7,31 @@ import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, holdoutFollowupFor } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
workingRectificationHouseTable,
} from "../src/lib/rectification-candidate-result.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { interviewQuestionBlocksAdoptOffer } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import {
CASE_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
@@ -503,3 +519,343 @@ test("collect focus is still not a renderable choice card", () => {
probe_id: null,
}), false);
});
const ACCIDENT_EVIDENCE = [
dated("e-edu-1", "education", "education_start", "2016-09-01"),
dated("e-edu-2", "education", "education_completion", "2020-06-01"),
dated("e-rel-1", "relationship", "relationship_start", "2018-05-01"),
dated("e-rel-2", "relationship", "relationship_end", "2021-08-01"),
dated("e-fin-1", "finance", "finance_change", "2024-03-01"),
dated("e-career-1", "career", "career_entry", "2020-04-01"),
dated("e-career-2", "career", "career_change", "2023-07-01"),
dated("e-reloc-1", "relocation", "home_change", "2022-10-01"),
dated("e-health-1", "health", "self_health_event", "2021-11-01"),
{
id: "e-occ-1",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const FAMILY_DECLINED_TOPIC = [{
target_domain: "family",
status: "declined",
questionId: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
}];
const CLOSED_FINANCE_FOCUS = [{
questionId: "collect:finance:collect_method_evidence",
targetDomain: "finance",
intent: "collect_method_evidence",
status: "resolved",
}];
const ACCIDENT_YEARLESS = {
candidateSetVersion: "04:51-04:53",
vargaDifferences: [] as const,
probes: [
yearlessProbe("d11", 2.5, "finance"),
yearlessProbe("d2", 2.2, "finance"),
yearlessProbe("d24", 1.2, "education"),
yearlessProbe("d5", 0.5, "education"),
yearlessProbe("d4", 0.99, "relocation"),
yearlessProbe("d7", 1.22, "family"),
yearlessProbe("d12", 1.1, "family"),
],
};
const ANSWERED_SIX = [
{ semantic_key: "varga.d9.天秤座|天蝎座", answer_class: "yes" },
{ semantic_key: "varga.d10.巨蟹座|狮子座", answer_class: "yes" },
{ semantic_key: "career.2020", answer_class: "yes" },
{ semantic_key: "career.2023", answer_class: "no" },
{ semantic_key: "relocation.2022", answer_class: "no" },
{ semantic_key: "finance.2024", answer_class: "no" },
];
function coveredDomainPlan(
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
return buildMethodFollowupPlan({
evidence: ACCIDENT_EVIDENCE,
contrastPacket: ACCIDENT_YEARLESS,
declinedTopics: FAMILY_DECLINED_TOPIC,
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
answeredProbes: ANSWERED_SIX,
sessionOutcome: "adopt_representative",
...extra,
});
}
test("yearless-to-collect does not re-ask a covered domain after occupation is done", () => {
const plan = coveredDomainPlan();
// 原值: next_followup.domain = financeL2224 取 yearless[0]=D11
// 新值: null
// 原因: 财务已有带年月事件且采集焦点已关闭;守卫跳过已覆盖领域后交付
assert.equal(plan.next_followup, null);
});
test("yearless-to-collect still asks an uncovered relocation domain", () => {
const plan = coveredDomainPlan({
evidence: ACCIDENT_EVIDENCE.filter((item) => item.domain !== "relocation"),
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
});
// 原值: financeD11 仍排第一)
// 新值: relocation
// 原因: 守卫只跳过已覆盖领域,不是删 yearless→采集分支
assert.equal(plan.next_followup?.domain, "relocation");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
});
function rpcAccidentEvidence(rows: readonly typeof ACCIDENT_EVIDENCE[number][]) {
return rows.map((item, index) => ({
id: item.id ?? `e-${index}`,
source_turn_id: "33333333-3333-4333-8333-333333333333",
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: "",
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-08T00:00:00.000Z",
}));
}
function accidentEvidenceFingerprint() {
return evidenceLedgerFingerprint(rpcAccidentEvidence(ACCIDENT_EVIDENCE).map((item) => ({
id: item.id,
eventKind: item.event_kind,
domain: item.domain,
occurredFrom: item.occurred_from,
occurredTo: item.occurred_to,
datePrecision: item.date_precision,
summary: item.summary,
status: item.status,
})) as never);
}
function yearlessInferenceProbe(layer: string, domain: string, gain: number) {
return {
id: `contrast:varga.${layer}.unsigned`,
semantic_key: `varga.${layer}.unsigned`,
candidate_split_hash: `varga.${layer}.04:51/04:53`,
domain,
year: 0,
question: `当前几个候选在 ${layer} 上还分得开。`,
candidate_ids: ["04:51", "04:53"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["04:53"] },
{ answer_class: "no" as const, supports: ["04:53"], conflicts: ["04:51"] },
],
information_gain: gain,
source: "varga_contrast",
choice_kind: "existence" as const,
style_options: STYLE_OPTIONS,
};
}
function accidentIdleState() {
const times = ["04:51", "04:52", "04:53"] as const;
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:51", "04:53", times),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:51",
range_end: "04:53",
candidates: times.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: 30 - index,
posterior_score: 30 - index,
probability: index === 0 ? 0.5 : 0.25,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
})),
events: [
{ id: "e-edu-1", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: "e-edu-2", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-1", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-2", domain: "relationship", year: 2021, precision: "month" as const, usage: "holdout" as const },
{ id: "e-fin-1", domain: "finance", year: 2024, precision: "month" as const, usage: "holdout" as const },
{ id: "e-career-1", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-career-2", domain: "career", year: 2023, precision: "month" as const, usage: "training" as const },
{ id: "e-reloc-1", domain: "relocation", year: 2022, precision: "month" as const, usage: "training" as const },
{ id: "e-health-1", domain: "health", year: 2021, precision: "month" as const, usage: "training" as const },
],
probes: [
yearlessInferenceProbe("d11", "finance", 2.5),
yearlessInferenceProbe("d24", "education", 1.2),
yearlessInferenceProbe("d4", "relocation", 0.99),
yearlessInferenceProbe("d7", "family", 1.22),
],
answered_probes: ANSWERED_SIX.map((item) => ({
probe_id: `probe:${item.semantic_key}`,
semantic_key: item.semantic_key,
candidate_split_hash: item.semantic_key,
answer_class: item.answer_class as "yes" | "no",
classified_from: "choice" as const,
})),
rounds: ANSWERED_SIX.map((item, index) => ({
round: index + 1,
phase: "discrimination" as const,
probe_id: `probe:${item.semantic_key}`,
scores_before: { "04:51": 30, "04:52": 29, "04:53": 28 },
scores_after: { "04:51": 32, "04:52": 29, "04:53": 28 },
entropy_before: 1.1,
entropy_after: 1.0,
eliminated_ids: [] as string[],
winner_id: null,
kind: "informative" as const,
})),
last_inference_round: null,
entropy: 1.0,
representative_time: "04:51",
credible_range: ["04:51", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "covered-domain accident inference must parse");
return loaded;
}
function accidentIdleDossier() {
const fingerprint = accidentEvidenceFingerprint();
const state = accidentIdleState();
return dossierFixture({
evidence: rpcAccidentEvidence(ACCIDENT_EVIDENCE),
evidenceCount: ACCIDENT_EVIDENCE.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: fingerprint,
candidates: [
{ candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 40, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "04:52", relative_support: 35, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "04:53", relative_support: 25, tied_minute_count: 1 },
],
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: FAMILY_DECLINED_TOPIC,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-08T00:00:00.000Z",
},
});
}
test("idle persist on the covered-domain accident delivers adopt and writes no focus", async () => {
const rpc = accidentIdleDossier();
const dossier = parseV9CaseDossier(rpc);
assert.ok(dossier, "covered-domain accident RPC dossier must parse");
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.canAdopt, true);
assert.equal(decision.stopReason, "probe_pool_exhausted");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpc,
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
throw new Error(`must not persist ${String(args.p_question_id)}`);
},
append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false }),
finalize_agentic_rectification_turn: () => ({
turn_id: "33333333-3333-4333-8333-333333333333",
status: "completed",
idempotent: false,
}),
get_agentic_rectification_turn_receipt: () => null,
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(idle.terminalNote, true);
assert.ok(idle.hostNarration);
assert.equal(projectCurrentQuestion(null), null);
assert.equal(interviewQuestionBlocksAdoptOffer(null, false), false);
});
test("agent-run still writes the exhaustion gate when idle persist returns terminalNote", () => {
const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /idle\.terminalNote && idle\.hostNarration/);
assert.match(agent, /persistExhaustionGateTurn/);
assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/);
const offer = canShowRectificationSelectionCards(
parseRectificationCandidateResult({
resultId: "55555555-5555-4555-8555-555555555555",
candidates: [
{ candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 1 },
{ candidateId: CANDIDATE_IDS[1], rank: 2, time: "04:53", relativeSupport: 35, tiedMinuteCount: 1 },
],
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
representativeTime: "04:51",
selectedTime: null,
sessionOutcome: "adopt_representative",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
},
}),
);
assert.equal(offer, true);
});
test("holdout occupied treats health and health_pressure as the same line", () => {
const evidence = [
...ACCIDENT_EVIDENCE.filter((item) => item.domain !== "health"),
dated("e-health-ledger", "health", "self_health_event", "2021-11-01"),
];
const fields = holdoutFollowupFor({
evidence,
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
}, new Set());
// 原值: health_pressure
// 新值: null
// 原因: occupied 不认 health / health_pressure 同义时会再问一遍健康
assert.equal(fields, null);
});
@@ -784,8 +784,10 @@ test("collect persist maps occupation to other and health_pressure to health", a
assert.equal(healthWrite?.args.p_target_domain, "health");
});
test("collect focus unique conflict retries with a :next question id", async () => {
const followup = collectFollowup();
test("collect focus unique conflict is duplicate_focus unless collect_retry", async () => {
// 原值: 任何撞 id 都 :next
// 新值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次
// 原因: BUG-591 决策 3;撞 id 本身就是这题问过了
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
@@ -800,7 +802,29 @@ test("collect focus unique conflict retries with a :next question id", async ()
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
followup: collectFollowup(),
});
assert.equal(persisted.status, "duplicate_focus");
assert.equal(writes, 1);
assert.equal(persisted.questionId, "collect:relationship:collect_method_evidence");
});
test("collect_retry unique conflict still retries with a :next question id", async () => {
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({ collect_retry: true }),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 2);