Compare commits

..

6 Commits

Author SHA1 Message Date
Jesse_Chen df182c16d6 fix(rectification): replay answered probes after evidence rescore (BUG-587, BUG-588)
Independent Staging Quality Gate / validate (push) Successful in 10m56s
Independent Staging Quality Gate / publish (push) Successful in 9m1s
Carry answered probe defs into the new candidate set, replay them by minute, and announce range changes on evidence turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 10:38:51 +08:00
Jesse_Chen fec316b3e3 docs(tasks): probe replay loss after new evidence fix brief (BUG-587/588)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-08 02:09:30 +00:00
Jesse_Chen 9d1c08cab1 fix(rectification): keep one spoken paragraph and strip body questions (BUG-584, BUG-585)
Independent Staging Quality Gate / validate (push) Successful in 9m48s
Independent Staging Quality Gate / publish (push) Successful in 7m33s
Set-focus must not append a second narration, and a server-owned stem must not also appear as a question in the assistant body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:05:01 +08:00
Jesse_Chen 9aec502961 fix(rectification): stop other-collect fallback from blocking delivery (BUG-586)
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
After the dated domains are asked or declined, keep the occupation question or the range card instead of hanging on a leftover other-collect prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:02:20 +08:00
Jesse_Chen 6c7a8b0262 docs(tasks): other-collect fallback dead-end fix brief (BUG-586)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-07 16:56:47 +00:00
Jesse_Chen 3973b6c3dc docs(tasks): duplicate narration fix brief (BUG-584/585)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-07 15:31:59 +00:00
37 changed files with 1749 additions and 120 deletions
+12
View File
@@ -1,5 +1,17 @@
# 印度占星 Skill 更新日志
## 2026-09-08 — 补经历后仍按已答对照题收范围,变宽会说出来
生时校正答完对照卡后再补一件经历,会把已经答过的题按分钟重算进去,不会因为候选分钟换了两个就把范围弹回开工窗口。如果这次比较让范围变了,旁白末尾会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.15。
## 2026-09-08 — 生时校正每一轮只说一段旁白,题目只出现在问题块
生时校正记下你刚说的事后,助手气泡里只留一段说明。下一问只出现在下面的问题块,不会在正文里再问一遍。Skill 版本仍是 10.0.15。
## 2026-09-08 — 七个领域问完后是职业题或区间卡,不再补问「再说一件」
生时校正把学业、感情、事业、家人、财务、迁居、健康问过或跳过后,下一问是「你平时主要做什么工作?」,或者直接出区间交付卡。不会再出现「也可以再说一件你记得大概时间的事」。Skill 版本仍是 10.0.15。
## 2026-09-07 — 区间卡能编译,采用后才说预测窗口,再补经历有下一句提示
生时校正交付卡不再把编译挡在门外。点采用后,第一轮核对旁白末尾会接上实际的下次事业变动窗口(有才写)。点「再补一件经历」后卡片收起,输入框上方改成收集经历的提示。Skill 版本仍是 10.0.15。
+97 -3
View File
@@ -7517,7 +7517,7 @@
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`)。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调。
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
- 相关记录:BUG-471、BUG-485、BUG-487
- 相关记录:BUG-471、BUG-485、BUG-487、BUG-585
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
- 修复版本:待发布
@@ -8241,7 +8241,7 @@
- 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。
- 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`read-diagnostics 仍 `retract`
- 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。
- 相关记录:
- 相关记录:BUG-584
- 复发自:无
- 修复版本:待发布
@@ -8641,7 +8641,7 @@
- 修复:穷尽采集不再问 `other`。还有 dated / occupation 就继续问;否则 `acceptance_allowed` 且训练门开且可采用则 `adopt_representative``stopReason=probe_pool_exhausted`),否则写门槛翻译句、不写焦点,并用 `terminalNote` 满足非终止出口。口述采集在输入框上方放与点选卡同文案的停止按钮;只读范围改为同一停止动作。`ask_about_result``stop_rectification` 在点选和口述焦点都映射到停止。`keep_collecting` 仍继续采集,不得劫持成出牌。
- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`;既有 adopt / spoken-collect / timeline / hidden-e2e 三栏更新。
- 防复发:`exhaustionSpokenCollectFollowup` 不得再返回 `other`。穷尽日志必须有 `rectification_exhaustion_collect` 且不含证据摘要或年份。不得把 `keep_collecting` 改成强制采用。
- 相关记录:BUG-550、BUG-546
- 相关记录:BUG-550、BUG-546、BUG-586
- 复发自:无
- 修复版本:`150d7ef1`
@@ -9046,5 +9046,99 @@
- 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过)
- 修复版本:待发布(`codex/rectification-range-delivery-fix-20260907`
## BUG-584 | 同一轮采集旁白说两遍
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-08
- 影响面:`applyStepAnswerChunk``KEEP_LIVE_SPOKEN_TOOLS``publishSpokenStep``POST /api/rectification/agent`
- 用户现象:每一轮采集助手气泡里有两段近似旁白。第一段已经承接用户刚说的事,第二段换个说法再讲一遍。
- 触发条件:模型在 `rectification-set-focus` 前写出正文,拿到工具结果后再写一段。
- 根因:BUG-533 让 set-focus 不撤回已直播正文。set-focus 之后下一个 step 的正文仍按 `shouldPublishStepText` 发布;`publishSpokenStep` 把两段无分隔拼进 `assistant_message`
- 修复:set-focus 结果后置 `stemAttached`。本轮已经发布过正文则丢掉后一段;尚未发布则仍放行开场问候。set-focus 调用时把未直播的中文半句 `publish` 出去,英文规划稿仍 discard。
- 验证:`frontend/tests/rectification-step-answer.test.ts``frontend/tests/rectification-v9-agent.test.ts` 文本→set-focus→文本只保留第一段。
- 防复发:set-focus 之后不得再拼接第二段正文。开场仍允许「先问候、后 set-focus」或「只在 set-focus 后说一句」。不得把这个例外扩到 record/compare/diagnostics。
- 相关记录:BUG-533
- 复发自:无(533 的放行例外把第二段也留下了)
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`
## BUG-585 | 题干在正文和问题块各出现一次
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-08
- 影响面:`stripQuestionSentences``attachQuestionsToTurns``runV9AgentTurn``RECTIFICATION_USER_COPY.collectHandoff`
- 用户现象:旁白末尾已经用自然语言问了下一题,问题块再显示同一句服务端题干。
- 触发条件:本轮 set-focus 成功,模型把问句写进正文;`detachCollectSpokenAssistantText` 只剥逐字后缀。
- 根因:不让模型提问的约束只在提示词。BUG-488 防复发写「不得同时让模型提问又拼接同一句服务端题干」,`d9404976` 把题干改成消息内问题块后没有代码守卫。`collectSpokenEmitted` 是从未使用的常量。
- 修复:有本轮 focus 时,`answer.composed` 前按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;剪空则用 `collectHandoff`。变化时 `answer.delta replace:true`。历史回看同样走 `stripQuestionSentences`。删除 `collectSpokenEmitted`
- 验证:`frontend/tests/rectification-collect-prompt.test.ts``frontend/tests/rectification-v9-agent.test.ts``frontend/tests/agent-voice-copy-contract.test.ts`
- 防复发:有 focus 的已结算助手正文不得再出现以问号结尾的句子。不得靠提示词单独保证模型不问。不得恢复把题干拼进 `assistant_message`
- 相关记录:BUG-488、BUG-461、BUG-471、`d9404976`
- 复发自:BUG-488(防线只在提示词)
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`
## BUG-586 | 七领域问完落到补问兜底句,职业题被覆盖、区间卡出不来
- 状态:mitigated
- 首次发现:2026-09-07
- 最近更新:2026-09-08
- 影响面:`USER_COLLECT_QUESTION``spokenFollowupForUser`、active-focus 承接 followup、`rectification-set-focus` 兜底、`persistNextInterviewIfIdle`
- 用户现象:学业、感情、事业、家人、财务、迁居、健康都问过或拒过后,助手不问「你平时主要做什么工作?」,也不出区间交付卡,只剩一句「也可以再说一件你记得大概时间的事」。
- 触发条件:七个带年月领域已覆盖或拒答,职业尚未问完或已有 `collect:other:*` 焦点挂着;同轮可能两次 set-focus。
- 根因:三条活路已由代码确认。职业焦点落库时 `persistableFocusDomain("occupation")` 仍写 `other`(表约束没有 occupation);承接 followup 用 `focus.targetDomain` 重建成 `domain=other`,稳定 id 变成 `collect:other:*` 并 supersede 职业题。set-focus 两次 `invalid_spoken_prompt` 后查 `USER_COLLECT_QUESTION[domain]``domain=other` 把那句兜底写进焦点。`collectQuestionDomain` 把空领域归为 other。挂着的 `collect:other:*``persistNextInterviewIfIdle` 见 active focus 提前返回,出卡路径跑不到。同轮第二次 set-focus 覆盖职业题的具体 receipt 链(任务书 2.2)未从事故 Case 证实,不得写成已闭环。
- 修复:删除 `USER_COLLECT_QUESTION.other` 与 retry。非开场 `domain=other` 的口述题返回 `null`。承接 followup 从 `collect:<domain>:` 解析领域。set-focus 兜底只允许表内真实领域,否则不落地。同轮已成功的 set-focus 第二次返回 `idempotent: true`。账本已有带年月事件时,闲置路径把存量 `collect:other:*``skipped`。采用跳过持久化时写 `terminalNote`,让 agent-run 能写 exhaustion 闸门轮。
- 验证:`frontend/tests/rectification-other-collect-fallback-20260908.test.ts``frontend/tests/rectification-exhaustion-exit-20260906.test.ts` 覆盖事故形状出卡;`frontend` `tsc --noEmit` 0;指定 TS 切片 983/0`grep -rn "也可以再说一件" src tests` 为空。未拿事故 Case receipt2.2 仍 investigating。
- 防复发:源码与测试不得再出现「也可以再说一件」整句。`USER_COLLECT_QUESTION` 不得再有 `other` 键。职业承接 id 必须仍是 `collect:occupation:*`。set-focus 不得在 `domain=other` 时查表落地。闲置路径必须跳过孤儿 `collect:other:*`
- 相关记录:BUG-558、BUG-580、BUG-582
- 复发自:BUG-558(同现象、不同来路;558 只堵了穷尽采集 `otherCollectFollowup`
- 修复版本:待发布(`codex/rectification-other-collect-fallback-20260908`
## BUG-587 | 新证据重算后已答题无法重放,范围弹回搜索窗口
- 状态:resolved
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`buildInferenceState``buildCaseInferenceState`、分盘对照 `semantic_key`、证据轮 compare 后的 `inference_state`
- 用户现象:答完多张对照卡后范围已经收到大约 8 分钟,再补一件带年月经历,交付时范围又回到大约 29 分钟,候选分数跟引擎 prior 一样。
- 触发条件:已有 5 条点选答案;新证据触发 compare,引擎 9 个分钟换掉其中 2 个;本轮 `probes` 按 asked keys 排除已答题。
- 根因:BUG-559 让引擎和对照包不再生成已答题。第一次重算还能从 `previous.probes` 找到定义,但新状态的 `probes` 不携带这些题。第二次重算时 `previous.probes` 也没有了,`if (!probe) continue` 把 5 条答案变成死账本,`rounds: []``posterior === prior`。分盘风格题的 `semantic_key` 还把分钟列表编进去,候选集一变 key 也对不上。BUG-581 的 `keepAnswers` 修的是停止路径同一根因的另一半。
- 修复:已答题的定义随状态携带(`carried: true`),重放按候选分钟,新分钟落中性。分盘 `semantic_key` 改为星座分区,例如 `varga.d9.天秤座|天蝎座|射手座`,不再内嵌 `HH:MM``candidate_split_hash` 仍按当前候选集计算。
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`(连续两次重算仍 5 轮、未变分钟 delta 不变、新分钟 delta 0);指定 TS 切片 1000/0`tsc --noEmit` 0。
- 防复发:候选集变化后 `answered_probes` 对应的题目定义必须仍在 `state.probes`。新生成的分盘 `semantic_key` 不得匹配 `\d\d:\d\d``nextProbe` 不得把携带题再问一遍。
- 相关记录:BUG-559、BUG-581、BUG-588、BUG-589
- 复发自:BUG-559(去重删生成侧定义);BUG-581(停止路径已修,证据路径未修)
- 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`
## BUG-588 | 证据轮旁白不报范围变化
- 状态:resolved
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`runV9AgentTurn` 证据/比较轮结算、`withRangeChangedAfterEvidence`
- 用户现象:范围从 8 分钟变回 29 分钟时,三轮证据旁白只说「记下了」,用户要到交付卡才看见变宽。
- 触发条件:本轮调用了 `rectification-compare-candidates`compare 前后 `credible_range` 不同。
- 根因:点选题由服务端写「范围从 X 收到 Y」;证据轮旁白是模型写的,服务端不补范围句。
- 修复:结算时先按 BUG-585 剪问句,再比较本轮 compare 前后的 `credible_range`。变了就把「范围从 A–B 变为 C–D。」接到正文末尾;没变不写。
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts``frontend/tests/agent-voice-copy-contract.test.ts` 收录新句并锁 `agent-run` 先剪后接。
- 防复发:证据轮只要跑过 compare 且范围变了,落库 `assistant_message` 必须含范围句。不得靠提示词让模型自己报。
- 相关记录:BUG-585、BUG-587、BUG-569
- 复发自:无
- 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`
## BUG-589 | 交付/选择卡在最后一题出现前可能闪现
- 状态:investigating
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`showSelectionCards``loadCaseSnapshot`
- 用户现象:最后一题的选择卡出现前一瞬,时间选择卡片弹出又消失。
- 触发条件:用户报告发生在证据轮之后、下一张对照卡出现之前。按导出最终状态 `can_adopt=false`,正常结算后不应出卡。
- 根因:未证实。可能是 `loadCaseSnapshot` 先写入 `candidateResult`,问题合并进消息是另一次渲染,中间 `interviewQuestionBlocksAdoptOffer` 看不到新问题。
- 修复:源码加守卫:`showSelectionCards` 要求 `!busy && caseSnapshotLoaded`;快照与问题合并放进同一次 `startTransition`。不得声称已复现闪现。
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts``frontend/tests/rectification-agentic-entry.test.ts` 源码锁。无复现实验。
- 防复发:选择卡不得在 `busy` 或快照未加载时出现。
- 相关记录:BUG-587
- 复发自:无
- 修复版本:待提交(守卫已加,现象仍 investigating
@@ -0,0 +1,41 @@
# PROGRESS · 同一轮旁白说两遍、题干出现两次(2026-09-07)
工作树:`.worktrees/rectification-duplicate-narration-20260907`
分支:`codex/rectification-duplicate-narration-20260907`
任务书:`docs/tasks/TASK-rectification-duplicate-narration-20260907.md`
基线:`origin/staging` @ `6c7a8b02`(任务书写 `f3e72702` / 代码头 `ab57d03f`;其后 `a00b069d` 已合入,tsc 不再报 `rectification-agentic-chat.tsx` L1655
本单状态:**待验收**。未 commit、未 push。
未改:提示词、Skill 版本(仍 10.0.15)、采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1``page.tsx`
并行:`codex/rectification-other-collect-fallback-20260908`BUG-586)也改 `user-copy.ts``agent-voice-copy-contract.test.ts`。本单只加 `collectHandoff`;谁后合谁 rebase。
## 做了什么
- **4.2 / BUG-585** `stripQuestionSentences`:按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;问号后的非叙事尾巴一并丢掉。`composeCollectSpokenAssistantText` 改调它。agent-run 在 `answer.composed` 前复用 discriminator 那次 `loadV9CaseDossier`:本轮 focus 有 spokenPrompt 则剪,剪空用 `collectHandoff`,变化时 `replace:true``attachQuestionsToTurns` 同样剪。删除 `collectSpokenEmitted`
- **4.1 / BUG-584** `StepAnswerState` 增加 `stemAttached``publishedAny`,不随 `resetBuffers` 清。set-focus 之后若本轮已发布正文,后续 `text-delta` discard。
- **决策 3** set-focus `tool-call` 把未直播的中文半句 `publish`;无 CJK 的规划稿 discard。未后置为 P3。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| set-focus 后第二段正文 | `live` 发布 | `discard` | 决策 2,丢掉工具结果后的复述 |
| set-focus 前无正文、后有一段 | (原测试未覆盖) | 后段 `live` | BUG-533 开场仍通 |
| 无句末标点的中文半句 + set-focus | 静默丢掉 | `publish` | 决策 3 |
| 英文规划稿 + set-focus | 不直播 | `discard` | 维持原 discard |
| `collectSpokenEmitted` | 恒 `false` | 字段删除 | 决策 4 |
| 历史回看剥题干 | 只剥 `\n\n` 后缀 | `stripQuestionSentences` | 决策 1 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**988 passed / 0 failed**
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 浏览器真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6
## 偏离
1. 任务书点名的 `rectification-v9-agent-run*.test.ts` 不存在;agent-run 序列写在既有 `rectification-v9-agent.test.ts`
2. 问号后的「有年份就行。」按非叙事续句丢掉,否则验收 (a) 会留下这半句。
@@ -0,0 +1,49 @@
# PROGRESS · 采集问完落到补问兜底句(2026-09-08)
工作树:`.worktrees/rectification-other-collect-fallback-20260908`
分支:`codex/rectification-other-collect-fallback-20260908`
任务书:`docs/tasks/TASK-rectification-other-collect-fallback-20260908.md`
基线:`origin/staging` @ `6c7a8b02`
本单状态:**待验收**。未 commit、未 push。
未改:采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1``page.tsx`、Skill 版本(仍 10.0.15)、`persistableFocusDomain("occupation") === "other"`(表约束未动)。
## 做了什么
- **决策 1** 删除 `USER_COLLECT_QUESTION.other` / `_RETRY.other` 与死代码 `otherCollectFollowup``spokenFollowupForUser``domain=other` 只保留开场 GENERIC,其余 `null`
- **决策 2** 新增 `parseCollectFocusQuestionId`。active-focus 非点选承接 followup 的 `domain` 先从 `collect:<domain>:` 解析,再退回 `focus.targetDomain`
- **决策 3** set-focus 两次无效后的查表兜底排除 `other`;没有真实领域题干则 `invalid_spoken_prompt`、不落地。
- **决策 4** 同轮已成功 persist 后再调 set-focus:若 active focus 的 `askedTurnId === turnId`,返回现有投影并标 `idempotent: true`
- **决策 5** `isOrphanOtherCollectFocus``collect:other:*` 且账本已有带年月事件时,闲置路径 `skipped` 后继续出卡。
- 采用跳过 followup 持久化(idle 与 after-choice 两条)补 `terminalNote: true`agent-run 才能写 exhaustion 闸门轮。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 职业承接 followup `domain` | `focus.targetDomain`other | `parseCollectFocusQuestionId` → occupation | 决策 2,稳定 id 保持 `collect:occupation:*` |
| set-focus other 两次无效 | 查表落地 `USER_COLLECT_QUESTION.other` | `invalid_spoken_prompt`0 次 persist | 决策 3 |
| 同轮第二次 set-focus | 可 supersede 成 `collect:other:*` | `idempotent: true`,焦点 id 不变 | 决策 4 |
| 存量 `collect:other:*` + 已有带年月事件 | idle 见 focus 提前返回 | `skipped``terminalNote` 出卡 | 决策 5 |
| `spokenFollowupForUser` other retry | `USER_COLLECT_QUESTION_RETRY.other` | `null` | 决策 1 |
| 开场 `collect:other:collect_method_evidence` | 同左 | 同左 | 开场身份仍是 other + GENERIC |
| `persistableFocusDomain("occupation")` | `"other"` | `"other"` | 表约束未改,测试保留 |
| covered accident idle `terminalNote` | `undefined` | `true` | 验收 (d)agent-run 闸门轮 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 本单改动文件 eslint0 errortools / method-followup 上既有 unused-import warning
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**983 passed / 0 failed**
- `grep -rn "也可以再说一件" src tests`:空
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 事故 Case receipt / 任务书 2.2:未取到,仍 investigating
- 浏览器七领域真人走查:无登录态,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §6
## 偏离
1. 验收形状写在新建 `frontend/tests/rectification-other-collect-fallback-20260908.test.ts`,没有只扩 `rectification-collect-stall.test.ts` / `rectification-occupation-coverage-exit.test.ts`。既有 exhaustion-exit covered-accident 补了 `terminalNote`
2. `isConfirmedDated` 参数放宽为 dated 字段 Pick,否则 `ledgerHasConfirmedDatedEvent` 在 tsc 上报 domain 缺失。
3. 决策 4 已做,没有后置为 P2。
4. BUG-586 状态写 **mitigated** 而非 resolved:三条活路已堵,但 2.2 同轮覆盖链没有事故 receipt。
@@ -0,0 +1,44 @@
# PROGRESS · 新证据重算后已答题全部失效(2026-09-08)
工作树:`.worktrees/rectification-probe-replay-loss-20260908`
分支:`codex/rectification-probe-replay-loss-20260908`
任务书:`docs/tasks/TASK-rectification-probe-replay-loss-20260908.md`
基线:`origin/staging` @ `fec316b3`(代码头含 BUG-584/585 `9d1c08ca`、BUG-586 `9aec5029`
本单状态:**待验收**。未 commit、未 push。
未改:采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1` 指纹、四选项合同、`SCORE_DELTA`、Skill 版本(仍 10.0.15)。
## 做了什么
- **决策 1** `buildInferenceState``previous.probes` 里已答题且本轮没有同 key 的定义带进新状态,标 `carried: true`。重放循环改查合并后的 `probes`
- **决策 2** `sameSet=false` 时仍从答案账本重建 `rounds`(原循环)。`applyProbeOutcome` 不改;新分钟不在旧 supports/conflicts 里则 delta 0。
- **决策 3** 分盘生成 key 改为 `vargaSignPartitionKey``varga.d9.天秤座|天蝎座|射手座`,不再内嵌分钟列表。缺星座时用 `varga.d9.unsigned`。已答旧 key 靠携带题命中。
- **决策 4** `agent-run` 在本轮用过 `rectification-compare-candidates` 时,先 `stripQuestionSentences` 再接 `withRangeChangedAfterEvidence`
- **决策 5** `showSelectionCards` 增加 `!busy && caseSnapshotLoaded``loadCaseSnapshot` 把快照与消息合并放进同一次 `startTransition`。闪现未复现,BUG-589 写 investigating。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 候选集换 2 分钟后 `rounds.length` | 0(找不到题定义) | 5 | 决策 1 携带已答题 |
| 连续两次重算 posterior | 全员 `=== prior` | 7 个未变分钟保持原 delta,2 个新分钟 0 | 决策 2 |
| 生成侧 D9 `semantic_key` | `varga.d9.04:50\|…/04:57` | `varga.d9.天秤座\|天蝎座\|射手座` | 决策 3 |
| 证据轮旁白范围变化 | 无 | `范围从 04:5004:57 变为 04:4705:15。` | 决策 4 |
| `showSelectionCards` | 不看 `busy` / 快照 | `!busy && caseSnapshotLoaded` | 决策 5P3 未复现 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 本单改动文件 eslint0 error`rectification-agentic-chat.tsx` 两处既有 hooks warning
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1000 passed / 0 failed**
- `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -q`:其余绿;`test_block_scan_seven_events_finishes_within_fifteen_seconds` 本机 63.4s > 15s。这是既有壁钟门(BUG-570 P2),与 asked_probe_keys / 本单无关,记环境缺口
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 浏览器真人走查:无登录态,见 `docs/testing/rectification-scenarios-20260907.md` §8
## 偏离
1. 验收形状写在新建 `frontend/tests/rectification-probe-replay-loss-20260908.test.ts`,没有只扩 `rectification-inference-machine.test.ts` / `rectification-stale-compare-fix-20260907.test.ts`
2. 分盘分区用中文星座名(`天秤座`)而不是任务书例子里的 `libra`,与现有 `window_scan.transitions` / 类型表一致;验收只要求不含 `\d\d:\d\d` 且同分区稳定。
3. 决策 3 当天做了,没有后置为 P2。
4. P3 闪现未复现,只加守卫,BUG-589 保持 investigating。
+3
View File
@@ -72,6 +72,9 @@
| `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-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,76 @@
# TASK · 生时校正修复单:同一轮旁白说两遍、题干在正文与问题块各出现一次(2026-09-07)
- 基线:`origin/staging` @ `f3e72702`(代码头 `ab57d03f`,含 BUG-583 未修的 tsc 错;staging 已部署 `84a29754`,事故在多轮实测中稳定复现)
- 分支:`codex/rectification-duplicate-narration-20260907`,基于 `origin/staging`。与 `codex/rectification-range-delivery-fix-20260907`BUG-583,只改 `rectification-agentic-chat.tsx`)无文件重叠,可并行;谁后合谁 rebase。
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/v9/step-answer.ts``v9/agent-run.ts``v9/collect-prompt.ts``v9/turn-question.ts``frontend/tests/rectification-step-answer.test.ts``rectification-collect-prompt.test.ts`
- BUG 编号起点:**BUG-584**BUG-583 已由区间卡修复单占用;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
- 优先级:**P1**(每一轮采集都复现;用户读到两段近似旁白 + 两遍题干,以为系统出错)
## 1. 事故实证(staging,多次实测;只写结构)
一条助手消息自上而下是:
1. 旁白第一段:承接用户刚说的事,说范围/对照情况,**末尾把下一题问了一遍**(自然语言,含年份与领域,以「?」结尾)。
2. 旁白第二段:换个说法把第一段又说一遍(有时更短,有时又带一遍题)。
3. 「本轮对照了 …」方法句(客户端 `vargaSentenceFromMethods` 渲染)。
4. 问题块:服务端 `ConversationFocus.spokenPrompt` 的题干,与第 1 段末尾那句同义或逐字相同。
## 2. 根因(两条,叠加)
### 2.1 BUG-584P1):set-focus 前后两个 step 的正文都被放行并拼接
`step-answer.ts` 的规则:文本先于 public 工具调用的算「规划稿」,工具调用时 `retractLive` 撤回。但 `KEEP_LIVE_SPOKEN_TOOLS = {rectification-set-focus}` 是例外(`a793edb1`,BUG-533:为了不把开场问候撤掉)——set-focus 之前已直播的中文保留,set-focus `tool-result``resetBuffers` 不撤回;随后模型在**下一个 step** 看到 set-focus 结果再写一段正文,`step-finish(reason=stop)``shouldPublishStepText` 正常发布。`agent-run.ts``publishSpokenStep``spokenRaw += spoken`,两段正文无分隔、无去重直接拼成 `answerText`,落库为 `assistant_message`
固定这个行为的正是测试 `rectification-step-answer.test.ts::keeps a live opening greeting when the next public tool is set-focus`:它断言 set-focus 之后的第二段也 `live` 发布。开场轮两段短句无伤;证据轮模型在 set-focus 前先总结一遍、拿到结果后又总结一遍,就是用户看到的"说了两遍"。receipt 里每轮两个 `intent.classified` 是同一件事的旁证。
附带损失:set-focus 前尚未触发直播(没遇到句末标点)的文本、以及已直播部分之后 `publishedUpTo` 以外的尾巴,在 `tool-result``resetBuffers` 里被静默丢掉——这是偶发"旁白半句"的来路。
### 2.2 BUG-585(P1):题干去重只认逐字后缀,模型写进正文的问句剪不掉
服务端只在 `turn-question.ts::attachQuestionsToTurns``detachCollectSpokenAssistantText(turn.text, question.prompt)` 剥题干,而它只剥两种形态:正文整体 === 题干,或正文以 `\n\n${题干}` 结尾。模型自己在正文里问出来的句子(同义改写、或逐字但嵌在段落中间)一概保留;问题块再从 focus 渲染一遍 → 同一题两遍。`composeCollectSpokenAssistantText` 里那套「按题干前 12 字剪句」只用于拒答确认句(`answer-choice.ts` L1059),agent 路径根本没走。
不让模型提问的约束只有提示词:`SKILL.md` L59「正文……不得自行提出、复述、改写或预告问题」、set-focus 描述「你只写 spokenPrompt」、opening brief「不要提问」。BUG-488 的防复发写的是「不得同时让模型提问又拼接同一句服务端题干」,靠"模型仍不自己提问"兜住;`d9404976` 把题干改成消息内问题块后,这条防线没有任何代码守卫,于是复发。
另外 `agent-run.ts` L284 `const collectSpokenEmitted = false` 是常量,只被原样透传进结果,没有任何读者——它就是当年打算做"题干已出、正文别再出"守卫的残骸。
## 3. 决策记录
1. **有服务端问题的轮次,正文不得含问句;含则服务端剪掉。** 这是把 SKILL.md L59 从提示词升级为代码守卫:`answer.composed` 之前,若本轮 set-focus 成功(focus.askedTurnId = 本轮),对 `answerText``stripQuestionSentences(body, stem)`:按 `。!?\n` 切句,删掉 (a) 与题干相同、(b) 以题干前 12 字开头、(c) 以「?」/「?」结尾 的句子。剪空则用一句固定承接(`RECTIFICATION_USER_COPY` 新增 `collectHandoff`,对照 `VOICE.md`,不得说"请回答下面的问题"之类机器话)。剪后文本以 `answer.delta replace:true` 再发一次,保证直播与落库一致。选择题轮(`choice_frame`)同样适用——选项由卡片承载,正文不问。
2. **set-focus 之后不再接受新一段正文。** `step-answer.ts`set-focus `tool-result` 后置 `state.stemAttached = true`(随 turn 存活,不随 `resetBuffers` 清);此后 step 的 `text-delta` 只有在本轮此前**没有**发布过任何正文时才放行(保住 BUG-533 的开场问候路径:问候在 set-focus 前,或模型只在 set-focus 后说一句),否则 `discard`。两段都有时保留**前一段**——它是模型对刚才证据的直接回应;后一段只是对工具结果的复述。
3. **set-focus 前的未直播文本不得静默丢弃。** `tool-call(set-focus)` 时把 `state.text` 未发布部分作为 `publish` 效果发出(它是给用户的话,不是规划稿),再 `resetBuffers`。若该文本没有 CJK(英文规划稿),维持原 discard。
4. 删除 `collectSpokenEmitted` 常量与其类型字段。
5. 提示词不动、Skill 不 bump(不是口径变化,是守卫补齐)。不动采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1` 指纹;不动 `frontend/src/app/page.tsx`
## 4. 任务分解
### 4.1 BUG-584:单段正文
- `step-answer.ts` 按决策 2、3 改;`StepAnswerState``stemAttached``publishedAny`(后者由 `agent-run.ts` 在首次 `publishSpokenStep` 成功后写回,或由 state 自记)。
- 改既有测试 `keeps a live opening greeting when the next public tool is set-focus`:原值——set-focus 后第二段 `live` 发布;新值——第二段 `discard`;原因——决策 2。新增:(a) set-focus 前无正文、后有一段 → 后段 `live/publish`BUG-533 路径仍通);(b) set-focus 前有未闭合半句「范围已经收到 05:00 到 05:10」(无句末标点)→ `tool-call(set-focus)` 时以 `publish` 发出,不丢;(c) 英文规划稿 + set-focus → 仍 discard。
- `agent-run` 级:`rectification-v9-agent-run*.test.ts`(现有 mock stream 夹具)构造「文本 → set-focus → 文本 → finish」序列,断言 `answerText` 只含第一段,`answerDeltas` 无第二段,且没有 `replace:true` 的清空事件。
### 4.2 BUG-585:正文剪问句
- `collect-prompt.ts` 新增 `stripQuestionSentences(body, stem)`(复用 `composeCollectSpokenAssistantText` 的切句与前缀规则 + 问号规则),`composeCollectSpokenAssistantText` 内部改调它,行为对拒答句路径不变(既有 `rectification-collect-prompt.test.ts` 全部保持)。
- `agent-run.ts`:流结束、`answer.composed` 之前,读本轮 focus`loadV9CaseDossier` 已在 `discriminatorInvariant` 里做过,复用那次读取,不加第二次 RPC);`askedTurnId === turnId` 且 schema 里有 spokenPrompt → 剪;剪空 → `collectHandoff`;变化时 `emitVisibleSpoken(stripped)`(它自会发 `replace:true`)。
- `turn-question.ts::attachQuestionsToTurns` 同样改调 `stripQuestionSentences`,让历史消息回看也干净(老数据不迁移)。
- 验收:单元——(a) 正文「…范围收在 05:00–05:10。你大概哪一年搬过家?有年份就行。」+ 题干「你大概是哪一年搬的家?」→ 剪成「…范围收在 05:00–05:10。」;(b) 正文中段逐字含题干 → 剪掉那句,前后句保留;(c) 正文只有问句 → `collectHandoff`;(d) 无本轮 focus → 原样。合同——`agent-voice-copy-contract` 增加「有 focus 的已结算助手正文不得以『?』结尾的句子出现」(对 `docs/testing` 之外的夹具跑)。
### 4.3 记录
- `docs/BUG_HISTORY.md` BUG-584(关联 BUG-533:其修复放行了第二段)、BUG-585(复发自 BUG-488:防线只在提示词;关联 BUG-461、BUG-471、`d9404976`);`CHANGELOG.md` 一条;`PROGRESS-rectification-duplicate-narration-20260907.md``docs/testing/rectification-scenarios-20260907.md` 加一条「每轮助手气泡只有一段旁白,题干只在问题块出现一次」。
## 5. 让步顺序
4.2 是用户直接看到的"两遍题",先做;4.1 次之;决策 3(半句不丢)可后置为 P3 但要在进度记录里写明;4.3 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-duplicate-narration-20260907 .worktrees/rectification-duplicate-narration-20260907 origin/staging
cd .worktrees/rectification-duplicate-narration-20260907
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)"
```
开工时 tsc 若仍报 `rectification-agentic-chat.tsx` L1655BUG-583),先确认 `codex/rectification-range-delivery-fix-20260907` 是否已合入;未合入则本单不修它,进度记录里写清基线含该错。
@@ -0,0 +1,83 @@
# TASK · 生时校正修复单:采集问完落到「也可以再说一件你记得大概时间的事」,职业题从未出现、没有自动出卡(2026-09-08)
- 基线:`origin/staging` @ `3973b6c3`(代码头 `a00b069d`staging 已部署 `a00b069d`;事故转录来自 2026-09-07 晚间实测,跑在 `84a29754` 还是 `a00b069d` 上待用户确认,下文两版都对过)
- 分支:`codex/rectification-other-collect-fallback-20260908`,基于 `origin/staging`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/v9/server-focus.ts``persistableFocusDomain``stableFollowupQuestionId`)、`v9/method-followup.ts`active-focus 承接 followup、`collectQuestionDomain``otherCollectFollowup` 死代码)、`frontend/src/mastra/rectification-v9-tools.ts`set-focus 的 `invalid_spoken_prompt` 兜底 L10851100)、`frontend/src/lib/rectification-agentic/user-copy.ts``USER_COLLECT_QUESTION.other` / `_RETRY.other`
- BUG 编号起点:**BUG-586**584/585 已由重复旁白修复单占用;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
- 优先级:**P1**(采集走完后无出口,第四次实测复现;BUG-558 同现象、不同来路)
## 1. 事故实证(只写结构)
七个带年月领域全部问过或拒过(学业、感情、事业、家人拒答、财务、迁居、健康),中间答了 5 张点选卡,范围从 30 分钟收到 8 分钟。最后一轮:
1. 助手气泡两段旁白(BUG-584);
2. 「本轮对照了 …」方法句;
3. 问题块:**「也可以再说一件你记得大概时间的事。」**——这是 `USER_COLLECT_QUESTION.other` 逐字;
4. 没有区间交付卡、没有职业题「你平时主要做什么工作?」、没有停止按钮以外的出口。receipt 显示该轮 6 步(此前每轮 3 步)。
按当前设计,这一步应当是:职业口述题 → (若有未问领域则 holdout)→ `adopt_representative` 自动出区间交付卡。职业题一次都没出现,而「other」这句取代了它。
## 2. 已由代码确认的事实
### 2.1 这句固定文案在 `a00b069d` 仍有三条活路
`spokenFollowupForUser``domain="other"` 的采集 followup:只有「`source=method_coverage``method_id=dasha_events` 且非 retry 且账本无带年月事件」才给开场句 GENERIC;其余一律查表落到 `USER_COLLECT_QUESTION.other`。BUG-558 只堵了 `exhaustionSpokenCollectFollowup` 这一条(`otherCollectFollowup` 现已无人调用,是死代码),但 `domain="other"` 还能从下面三处产生:
| # | 来路 | 位置 |
| --- | --- | --- |
| A | **职业焦点落库时领域被压成 other**DB 约束 `target_domain in (education, career, relationship, relocation, finance, health, family, other)` 没有 `occupation``persistableFocusDomain("occupation")` 返回 `"other"`(测试 `rectification-server-focus.test.ts` L716/L775 锁定)。随后 `buildMethodFollowupPlan` 有 active focus 时用 `domain: focus.targetDomain` 重建「承接 followup」(`method-followup.ts` L20162025`source="active_focus"`),职业焦点被重建成 `domain=other, intent=collect``spokenFollowupForUser` 给出「也可以再说一件」。`stableFollowupQuestionId` 对它算出 `collect:other:collect_method_evidence`,与原职业焦点 `collect:occupation:collect_method_evidence` 不同,因此不会被判 `already_open`,会 supersede 掉职业题 | `server-focus.ts` L201206、L5253`method-followup.ts` L2016 |
| B | **set-focus 工具的兜底**:模型 `spokenPrompt` 两次 `invalid_spoken_prompt` 后,`USER_COLLECT_QUESTION[nextFollowup.domain]` 直接落库;`nextFollowup.domain="other"` 时就是这句 | `rectification-v9-tools.ts` L10851100 |
| C | `spokenCollectFallbackFollowup` / `collectQuestionDomain(null | "unknown" | "active_focus")` 把无领域的采集 followup 归为 other | `method-followup.ts` L1343、L1371 |
### 2.2 尚未确认的触发链(`investigating`
最符合转录的链:该轮模型先用 set-focus 写出职业题(焦点 `target_domain=other`),同一轮再次调用 set-focus(6 步);第二次的 `next_followup` 是承接 followup`domain=other`),模型的 `spokenPrompt``domain_mismatch`(模型填 `targetDomain="career"``persistableFocusDomain` 后 ≠ `"other"`)或词表问题两次被拒,走 2.1-B 兜底,把职业题覆盖成「也可以再说一件」。**这一段需要事故 Case 的 receipt 证实**(预期能看到两条 `rectification-set-focus / intent.classified / failed / invalid_spoken_prompt` 和一条 completed,以及 `collect:occupation:*` 焦点被 `collect:other:*` supersede)。执行方拿不到 staging 数据时,把这一节保留为 `investigating`,不得编造。
无论触发链是哪条,2.1 的三条活路都是确定性缺陷,修掉后这句话在代码里不再存在。
### 2.3 为什么没有自动出卡
出卡的服务端路径是 `persistNextInterviewIfIdle → persistExhaustionCollect``exhaustionSpokenCollectFollowup` 为空且 `acceptanceAllowed && trainingGate.open && canAdopt` 时写 `adopt_representative` 旁白 + `terminalNote`,客户端据此渲染区间交付卡。它只在**没有 active focus** 时运行(L1158 提前返回)。一个 `collect:other:*` 焦点挂着,出卡永远轮不到;用户答了这句「再说一件」之后,若职业仍未覆盖,还会再来一遍。
## 3. 决策记录
1. **删掉 `USER_COLLECT_QUESTION.other` 与 `USER_COLLECT_QUESTION_RETRY.other`**(产品口径:多余入口宁可删除也不修)。`spokenFollowupForUser``domain=other` 只剩开场 GENERIC 一条路;非开场的 other 返回 `null`,上游按「无可问」处理。`otherCollectFollowup` 一并删除。
2. **职业焦点不再丢领域。** 不改表(本轮不动迁移):`target_domain` 仍写 `other`,但 `questionId` 已是 `collect:occupation:collect_method_evidence`;承接 followup 的 `domain` 改为「先从 `questionId` 解析 `collect:<domain>:` 段,再退回 `focus.targetDomain`」。新增 `parseCollectFocusQuestionId``persistableFocusDomain("occupation") === "other"` 的两条测试保留(DB 约束未变)。
3. **set-focus 兜底只允许表内真实领域。** L10851100`fallbackDomain` 必须是 `USER_COLLECT_QUESTION` 的键且 ≠ `other`(删表后自然满足),否则返回 `invalid_spoken_prompt` 不落地;且兜底题干必须与当前 `next_followup` 同领域。
4. **同一轮第二次 set-focus 必须承接、不得覆盖。** 若 active focus 的 `askedTurnId === turnId`(本轮刚写的),set-focus 返回该焦点投影并标 `idempotent: true`,不重建、不 supersede。
5. **有 `collect:other:*` 非开场焦点的存量 Case**`persistNextInterviewIfIdle` 开头把这种焦点判为 stale 并 `skipped`(复用 `isStalePreAdoptFocus` 旁边加一条 `isOrphanOtherCollectFocus``questionId``collect:other:` 开头且账本已有带年月事件),让出卡路径能跑起来。
6. 不动采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1` 指纹;不动 `page.tsx`Skill 不 bump。
## 4. 任务分解
### 4.1 BUG-586:三条活路 + 出卡
- 决策 15 全部落地。
- 验收(形状用例,`rectification-collect-stall.test.ts` / `rectification-occupation-coverage-exit.test.ts` 扩):
- (a) 7 个带年月领域已覆盖或拒答、职业未覆盖、active focus 为 `collect:occupation:*``target_domain=other`)→ `buildMethodFollowupPlan` 的承接 followup `domain === "occupation"``spokenFollowupForUser` 给「你平时主要做什么工作?」,`stableFollowupQuestionId` 等于原焦点 id`already_open`,不 supersede)。
- (b) 同 (a),模型 `spokenPrompt` 两次无效 → set-focus 返回 `invalid_spoken_prompt`**不**落库任何 `collect:other:*`
- (c) 同一轮 set-focus 两次(第二次 `next_followup` 为承接 followup)→ 第二次返回 `idempotent: true`,焦点 id 不变。
- (d) 职业已答、无剩余采集、`acceptanceAllowed && trainingGate.open && canAdopt``persistNextInterviewIfIdle` 返回 `terminalNote: true` 的采用旁白,`agent-run` 写 exhaustion gate turn,客户端 `canOfferCards` 为真(区间交付卡)。
- (e) 存量 `collect:other:*` 焦点 + 账本有带年月事件 → 进入 `persistNextInterviewIfIdle` 时被 `skipped`,随后走 (d)。
- (f) `agent-voice-copy-contract`:源码与 `user-copy.ts` 不再含「也可以再说一件」;`rectification-server-focus.test.ts` L517/L727、`rectification-adopt-flow-*.test.ts` 里以 `collect:other:collect_method_evidence` 为期望值的断言逐条写「原值 / 新值 / 原因」。
### 4.2 记录
- `docs/BUG_HISTORY.md` BUG-586:状态先写 `investigating`2.2 未证实)或 `resolved`(若执行方能从 staging receipt 证实触发链);复发自 BUG-558(同现象),关联 BUG-580、BUG-582`CHANGELOG.md``PROGRESS-rectification-other-collect-fallback-20260908.md``docs/testing/rectification-scenarios-20260907.md` 加「七领域问完后下一问必须是职业题或直接出区间卡,不得出现『再说一件』」。
## 5. 让步顺序
决策 1、2、3 必做且同一提交;决策 4 若 set-focus 工具改动牵扯太广可后置为 P2 但要写进度记录;决策 5 必做(否则存量 Case 卡死);4.2 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-other-collect-fallback-20260908 .worktrees/rectification-other-collect-fallback-20260908 origin/staging
cd .worktrees/rectification-other-collect-fallback-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)"
grep -rn "也可以再说一件" src tests # 收工时必须为空
```
`codex/rectification-duplicate-narration-20260907`BUG-584/585,改 `step-answer.ts` / `agent-run.ts` / `collect-prompt.ts`)无文件重叠;谁后合谁 rebase。
@@ -0,0 +1,85 @@
# TASK · 生时校正修复单:新证据重算后已答题全部失效,范围从 8 分钟弹回 29 分钟且旁白不说(2026-09-08
- 基线:`origin/staging` @ `6c7a8b02`(代码头 `a00b069d`staging 已部署 `a00b069d`;事故 Case 就是这版跑出来的,Skill 10.0.15
- 分支:`codex/rectification-probe-replay-loss-20260908`,基于 `origin/staging`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/core/build-state.ts``buildInferenceState` L5480)、`v9/inference-adapter.ts``buildCaseInferenceState`)、`v9/score-persist.ts``v9/agent-run.ts``v9/answer-choice.ts`(证据轮范围变化旁白)、`frontend/src/components/rectification-agentic-chat.tsx``showSelectionCards`
- BUG 编号起点:**BUG-587**586 已由 other-collect 修复单占用)
- 优先级:**P0**(这是"收敛不了"的直接原因:用户答了 5 道题把范围收到 8 分钟,之后每补一件经历都被静默作废,交付时又回到 29 分钟)
## 1. 事故实证(2026-09-08 stagingCase 导出;只写结构)
| 时点 | 事实 |
| --- | --- |
| 事业事件写入后 | 引擎候选集 9 分钟(含 04:57、05:13),连问 5 张卡(D9 风格、D10 风格、事业 2023 前后、事业 2024 前后、迁居 2015 前后),旁白逐轮报"范围从 05:0005:13 收到 … 04:5004:57"8 分钟) |
| 之后 | 家人拒答 → 财务事件 → 迁居事件 → 健康拒答 → 职业 → 学业事件,每件带年月的经历都触发 batch 内 compare 重算 |
| 交付时 `inference_state` | `answered_probes` 5 条仍在;**`rounds: []`**9 个候选 `posterior_score === prior_score``candidate_set_id` 已变(04:57→04:59、05:13→05:14);`credible_range = 04:4705:15`29 分钟) |
| 交付卡 | "这次给出的范围 04:4705:15,排盘用 05:00"。用户:"这里给的时间范围有点长了吧" |
| 期间旁白 | 三轮证据旁白只说"记下了",从未说范围已从 8 分钟回到 29 分钟 |
## 2. 根因
### 2.1 BUG-587(P0):候选集一变,旧答案就没有可重放的题目定义
`core/build-state.ts::buildInferenceState`
1. L5455 `sameSet = previous.candidate_set_id === setId; previous = sameSet ? input.previous : null` —— 只要引擎给出的 9 个分钟有任何一个不同(新证据改变分数排序就会),`rounds` 从零开始。这本身可接受,因为下面的循环会用答案账本重建。
2. 重建循环 L72–79 要先找到题目定义:`input.probes`(本轮引擎 `discriminating_event_probes` + 对照包 `conflictProbesFromContrast`)→ `input.previous.probes`。但两处都**故意排除已答题**`score-persist.ts``askedSemanticKeysForEngine` 传给引擎让它不再生成(BUG-559 去重),`buildCandidateContrastPacket({askedKeys})` 也过滤掉已答的分盘对照题;而新状态的 `probes` 就是这两份的并集,所以**第一次重算后,已答题的定义不再进入状态**;第二次重算时 `input.previous.probes` 里也没有了 → `if (!probe) continue` → 5 条答案全部变成账本里的死记录,`posterior = prior`
3. 分盘风格题的 `semantic_key` 还把分钟列表编进了 key`varga.d9.04:50|04:47/05:00|…`),候选集一变 key 就对不上,连按 key 找都不可能。
题目本身的语义是**按分钟**定义的(某分钟的 D9 上升是什么、某分钟的大运边界在哪一年),换了证据也不变;`applyProbeOutcome` 已经是按候选 id(分钟字符串)查 `supports / conflicts` 成员,只要定义还在,7 个未变的分钟就能正确重放,新出现的分钟落为中性。丢的只是定义,不是可重放性。
### 2.2 BUG-588P1):证据轮不报范围变化
点选题轮次由服务端写"范围从 X 收到 Y"`answer-choice`),证据轮的旁白是模型写的,服务端不补范围句。范围从 8 分钟回到 29 分钟这件事,用户在三轮里没有任何机会知道。
### 2.3 P3(未证实):交付/选择卡闪现
用户报告:最后一题的选择卡出现前一瞬,"时间选择的卡片"弹出又消失。`showSelectionCards``rectification-agentic-chat.tsx` L14011408)没有 `busy` 守卫;`loadCaseSnapshot` 内先 `setCandidateResult``await` 之后才 `setMessages(mergeTurnQuestions)`,两次渲染之间 `interviewQuestionBlocksAdoptOffer` 看不到新问题。但按导出的最终状态 `can_adopt=false``session_outcome=discriminate_candidates``canShowRectificationSelectionCards` 应为假,闪现的来源没有证实。执行方按 4.3 加守卫即可,不得声称已复现。
## 3. 决策记录
1. **已答题的定义随状态一起活下去。** `buildCaseInferenceState``probes = [...本轮 probes, ...previous.probes 中 id 或 semantic_key 出现在 previous.answered_probes 里且本轮没有同 key 的]`。这些"携带题"只用于重放,不参与 `nextProbe` 选题(加 `carried: true` 或按 answered 过滤,`selectHighestGainProbe` 已排除已答)。
2. **重放按分钟。** `applyProbeOutcome` 不改;新候选集里不在旧 `supports/conflicts` 的分钟落为 `neutral`,不做邻近插值(保守、确定)。`rounds``sameSet=false` 时按答案账本顺序重建(现有循环即可),`kind` 照旧。
3. **分盘风格题的 `semantic_key` 去掉分钟列表**:改为 `varga.<layer>.<sign-partition>`(按各分钟所属星座分组的稳定表示,例如 `varga.d9.libra|scorpio|sagittarius`),分钟→星座的映射来自 `window_scan.transitions``candidate_split_hash` 仍按当前候选集算,只用于"同一题是否已在当前集问过"。已答账本里的旧 key 通过决策 1 的携带题仍能命中(携带题保留旧 key)。
4. **证据轮补一句范围句(服务端写、模型不写)。** `agent-run` 在 evidence 轮结算前,比较本轮 compare 前后的 `credible_range`:变了就把 `RECTIFICATION_USER_COPY.rangeChangedAfterEvidence(from, to)`(对照 VOICE.md,形如"范围从 04:5004:57 变为 04:4705:15。")接到 `answerText` 末尾、题干之前;没变不写。与 BUG-585 的剪问句在同一位置串行(先剪、再接)。
5. **卡片守卫**`showSelectionCards` 追加 `&& !busy && caseSnapshotLoaded``loadCaseSnapshot``setCandidateResult` 与随后的问题合并放进同一次 `startTransition`/同步块。
6. 不动采用门、确认门、`MIN_SEPARATION_LEAD``_relative_support``minute_step=1` 指纹、四选项合同;`SCORE_DELTA` 不变;Skill 不 bump。
## 4. 任务分解
### 4.1 BUG-587(决策 13
- 验收(`rectification-inference-*.test.ts` / `rectification-stale-compare-fix-20260907.test.ts` 扩):
- (a) 状态 S1:9 分钟候选、5 条已答(2 分盘风格 + 3 大运边界),范围 8 分钟;新证据 → 候选集换掉其中 2 个分钟,且本轮 `probes` 不含这 5 题 → 新状态 `rounds.length === 5`7 个未变分钟的 `posterior_score` 与 S1 相同增减,2 个新分钟 delta 为 0,`credible_range` 宽度 ≤ S1 宽度 + 新分钟数。
- (b) 连续两次重算(模拟财务 → 迁居)后,答案仍全部重放(rounds 5),不退化为 `posterior === prior`
- (c) 分盘风格题的 `semantic_key` 不含 `\d\d:\d\d`;同一分区不同候选集下 key 相同;已答旧 key 的账本能命中携带题。
- (d) `nextProbe` 不会再把携带题当新题问出(`selectHighestGainProbe` 排除)。
- (e) 引擎侧 `asked_probe_keys` 行为不变(`tests/test_rectification_*` 绿)。
### 4.2 BUG-588(决策 4
- 验收:mock 一轮证据 turncompare 前范围 04:5004:57、后 04:4705:15 → 落库 `assistant_message` 末尾含范围句;范围不变 → 不含;`agent-voice-copy-contract` 收录新句。
### 4.3 P3(决策 5
- 验收:`rectification-agentic-entry` 源码锁 `showSelectionCards``!busy`;无需复现闪现。
### 4.4 记录
- `docs/BUG_HISTORY.md`BUG-587(关联 BUG-559 —— 其"去重"把已答题从生成侧删掉,副作用是重放丢定义;关联 BUG-581 的 `keepAnswers` 路径:那次修的是停止路径,这次是证据路径同一根因的另一半)、BUG-588;P3 写成 `investigating``CHANGELOG.md``PROGRESS-rectification-probe-replay-loss-20260908.md``docs/testing/rectification-scenarios-20260907.md` 加一条"答完点选题再补经历,范围不得变宽超过新增分钟数;证据轮旁白必须报范围变化"。
## 5. 让步顺序
4.1 (a)(b) 当天必做并部署;(c) 可后置为 P2(携带题已能按旧 key 命中);4.2 必做;4.3 顺手;4.4 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-probe-replay-loss-20260908 .worktrees/rectification-probe-replay-loss-20260908 origin/staging
cd .worktrees/rectification-probe-replay-loss-20260908
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
ln -s /workspace/Jyotisha/.venv .venv
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)"
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 无重叠。
@@ -90,3 +90,39 @@
- 点「按这个范围用」成功,设置页出生时间状态变为已采用
- 点「再补一件经历」卡片收起,输入框上方出现「第 1 步·收集经历」和「请继续说下一件…」,服务端范围不变
## 6. 七领域问完后下一问必须是职业题或直接出区间卡
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后依次说或拒答七个带年月领域(学业、感情、事业、家人、财务、迁居、健康),不要主动说职业。
期望:
- 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡
- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」
- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮
## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说一件带年月的虚构经历,等到出现下一问。
期望:
- 助手气泡正文只有一段旁白,没有第二段换个说法再讲一遍
- 题干只出现在同一条消息的问题块里一次
- 正文不以问号结尾,也不把下一题再问一遍
## 8. 答完点选题再补经历,范围不得无故变宽
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说到能出对照卡,答完几张分盘风格或大运边界卡,等到旁白报出已经收到大约 8 分钟。然后再补一件带年月的虚构经历(财务或迁居均可)。
期望:
- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
@@ -635,7 +635,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, []);
const loadCaseSnapshot = useCallback(async (): Promise<{
const loadCaseSnapshot = useCallback(async (
mergeMessages?: (turns: readonly unknown[]) => void,
): Promise<{
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
@@ -650,10 +652,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) return undefined;
applyCaseSnapshot(payload);
const turns = snapshotTurns(payload);
startTransition(() => {
applyCaseSnapshot(payload);
mergeMessages?.(turns);
});
return {
question: currentQuestionFromSnapshot(payload?.current_question),
turns: snapshotTurns(payload),
turns,
};
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
@@ -960,10 +966,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
if (succeeded) {
runOutcome = "succeeded";
const snapshot = await loadCaseSnapshot();
if (snapshot?.turns.length) {
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
}
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
}
});
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
@@ -1121,15 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
if (willContinue) {
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
setMessages((current) => mergeTurnQuestions(current, turns));
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
await loadCaseSnapshot((turns) => {
if (willContinue) {
setMessages((current) => mergeTurnQuestions(current, turns));
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
@@ -1151,6 +1154,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
];
});
});
if (willContinue) {
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
@@ -1400,6 +1413,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& !busy
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
@@ -55,7 +55,13 @@ export function buildInferenceState(input: {
const previous = sameSet ? input.previous : null;
const events = stickyHoldoutEvents(input.events, previous?.events);
const holdoutKeys = holdoutDomainYears(events);
const probes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const liveProbes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const probes = [
...liveProbes,
...carriedAnsweredProbes(input.previous, liveProbes).filter((probe) => (
!holdoutKeys.has(`${probe.domain}:${probe.year}`)
)),
];
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
@@ -73,8 +79,8 @@ export function buildInferenceState(input: {
// Engine event scores already include dated evidence. A structured A/yes
// choice is not an evidence row, so it must still move the posterior.
if (answer.answer_class === "yes" && answer.classified_from === "evidence") continue;
const probe = input.probes.find((item) => item.id === answer.probe_id)
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
const probe = probes.find((item) => item.id === answer.probe_id)
?? probes.find((item) => item.semantic_key === answer.semantic_key)
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
if (!probe) continue;
@@ -239,7 +245,9 @@ export function replayInferenceState(
export function nextProbe(state: InferenceState): ConflictProbe | null {
const holdoutKeys = holdoutDomainYears(state.events);
const probes = state.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const probes = state.probes.filter((probe) => (
!probe.carried && !holdoutKeys.has(`${probe.domain}:${probe.year}`)
));
return selectHighestGainProbe(probes, state.answered_probes);
}
@@ -328,6 +336,24 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
});
}
export function carriedAnsweredProbes(
previous: InferenceState | null | undefined,
current: readonly ConflictProbe[],
): ConflictProbe[] {
if (!previous) return [];
const present = new Set(current.flatMap((probe) => [probe.id, probe.semantic_key]));
return previous.probes.flatMap((probe) => {
const answered = previous.answered_probes.some((item) => (
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
));
if (!answered) return [];
if (present.has(probe.id) || present.has(probe.semantic_key)) return [];
present.add(probe.id);
present.add(probe.semantic_key);
return [{ ...probe, carried: true }];
});
}
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
const rows = [...previous];
for (const item of incoming) {
@@ -792,6 +792,21 @@ export function isStructuredDiscriminator(probe: Pick<CandidateDiscriminatorProb
|| probe.semanticKey.startsWith("varga.");
}
const CLOCK_TOKEN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export function vargaSignPartitionKey(
layer: string,
signs: readonly (string | null | undefined)[],
): string {
const parts = signs.flatMap((item) => {
const raw = typeof item === "string" ? item.trim() : "";
if (!raw || CLOCK_TOKEN.test(raw)) return [];
return [signKey(raw)];
});
if (parts.length >= 2) return `varga.${layer}.${parts.join("|")}`;
return `varga.${layer}.unsigned`;
}
function vargaProbes(
remainingSplits: readonly RemainingVargaSplit[],
candidateSetVersion: string,
@@ -825,7 +840,7 @@ function vargaProbeFromRemaining(
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds]));
if (outcomes.length < 2 || ids.size < 2) return null;
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
const semanticKey = vargaSignPartitionKey(split.layer, split.signs);
const layerLabel = split.layer.toUpperCase();
const domain = remainingDomain(split.layer);
return {
@@ -91,7 +91,8 @@ function isConflictProbe(value: unknown): boolean {
&& isFiniteNumber(value.information_gain)
&& typeof value.source === "string" && value.source.length > 0
&& (value.choice_kind === undefined
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)));
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)))
&& (value.carried === undefined || value.carried === true || value.carried === false);
}
function isProbeAnswer(value: unknown): boolean {
@@ -91,6 +91,8 @@ export type ConflictProbe = Readonly<{
answer_class: AnswerClass;
sign?: string;
}>[];
/** Kept only so an answered probe can be replayed after the candidate set changes. */
carried?: boolean;
}>;
export type ProbeAnswer = Readonly<{
@@ -51,7 +51,6 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
relocation: "有没有哪年搬家,或开始长期住在外地?",
finance: "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?",
health_pressure: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
other: "也可以再说一件你记得大概时间的事。",
};
/** Second phrasing when that domain's collect focus was already established and not declined. */
@@ -64,7 +63,6 @@ export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
relocation: "搬家或开始长期住外地,大概是哪年?",
finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?",
health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。",
};
export const RECTIFICATION_USER_COPY = {
@@ -76,6 +74,7 @@ export const RECTIFICATION_USER_COPY = {
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
collectHandoff: "接下来我们继续。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
@@ -190,6 +189,30 @@ export function formatClockRange(range: readonly [string, string] | null | undef
return range[0] === range[1] ? range[0] : `${range[0]}${range[1]}`;
}
export function rangeChangedAfterEvidence(
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string | null {
const fromText = formatClockRange(from);
const toText = formatClockRange(to);
if (!fromText || !toText || fromText === toText) return null;
return `范围从 ${fromText} 变为 ${toText}`;
}
export function withRangeChangedAfterEvidence(
body: string,
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string {
const notice = rangeChangedAfterEvidence(from, to);
if (!notice) return body;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}`;
return `${prefix}${notice}`;
}
export function openingRangeFromCandidateRange(
range: { start_time?: string | null; end_time?: string | null } | null | undefined,
): readonly [string, string] | null {
@@ -317,6 +340,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.collectHandoff,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
@@ -341,6 +365,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.divergenceTitle,
RECTIFICATION_USER_COPY.divergenceUnlike,
RECTIFICATION_USER_COPY.divergenceUnsure,
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
rangeDeliveryEventCopy(3),
rangeDeliveryStableCopy(["事业方向"]),
rangeDeliverySensitiveCopy(["婚恋(D9"]),
@@ -34,7 +34,10 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { withCompareFailedRetryNotice } from "../user-copy";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
import { stripQuestionSentences } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -110,7 +113,6 @@ export type V9AgentRunResult = Readonly<{
toolsUsed: readonly string[];
errorCode: string | null;
previousFocusId: string | null;
collectSpokenEmitted: boolean;
}>;
type AttemptStatus = "completed" | "failed" | "retryable";
@@ -281,7 +283,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
const collectSpokenEmitted = false;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
@@ -378,7 +379,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: [],
errorCode: null,
previousFocusId,
collectSpokenEmitted,
};
}
if (existingStatus !== "pending") await billing.release();
@@ -489,7 +489,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: outcome.toolsUsed,
errorCode: outcome.errorCode,
previousFocusId,
collectSpokenEmitted,
};
}
@@ -524,7 +523,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: [],
errorCode: "usage_settlement_failed",
previousFocusId,
collectSpokenEmitted,
};
}
@@ -592,7 +590,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolsUsed: outcome.toolsUsed,
errorCode: null,
previousFocusId,
collectSpokenEmitted,
};
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
@@ -641,6 +638,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const emittedActivities = new Set<string>();
const repeatedCalls = new Map<string, number>();
let phaseSequence = 0;
const rangeBeforeCompare = previousInferenceFromReceipt(
dossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (
@@ -885,25 +885,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const decision = decideFromDossier(latest);
if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true };
const focus = latest.conversationSummary.activeFocus;
if (
focus
&& isPersistedFocusId(focus.id)
&& parseAgentChoiceCopy(focus.expectedAnswerSchema)
) {
return { ok: true };
}
return { ok: false, errorCode: "state_invariant_failed" };
} catch {
return { ok: false, errorCode: "state_invariant_failed" };
}
};
const completeAttempt = async (): Promise<AttemptOutcome> => {
let inputTokens = 0;
let outputTokens = 0;
@@ -967,8 +948,43 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
let latestDossier: V9CaseDossier;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
} catch {
return failedAttempt(attemptId, "state_invariant_failed");
}
const decision = decideFromDossier(latestDossier);
if (decision.nextAction === "ask_candidate_discriminator") {
const openFocus = latestDossier.conversationSummary.activeFocus;
if (!(
openFocus
&& isPersistedFocusId(openFocus.id)
&& parseAgentChoiceCopy(openFocus.expectedAnswerSchema)
)) {
return failedAttempt(attemptId, "state_invariant_failed");
}
}
const askedFocus = latestDossier.conversationSummary.activeFocus;
if (askedFocus?.askedTurnId === turnId) {
const stem = focusSpokenPrompt(askedFocus.expectedAnswerSchema);
if (stem) {
const stripped = stripQuestionSentences(answerText, stem);
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
if (next !== answerText) answerText = next;
}
}
if (toolsUsed.has("rectification-compare-candidates")) {
const rangeAfterCompare = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterCompare,
);
}
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
clearTimeout(timeout);
@@ -85,6 +85,7 @@ import {
planWithDateReliability,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
ledgerHasConfirmedDatedEvent,
type MethodCoverage,
type MethodFollowup,
type MethodFollowupPlan,
@@ -740,6 +741,7 @@ export async function persistNextInterviewAfterChoice(input: {
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
const latest = input.dossier.latestResult
? {
@@ -810,6 +812,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
persisted: false,
followup: null,
terminalNote: true,
};
}
if (input.dossier.case.acceptedTime && !followup) {
@@ -1103,6 +1106,15 @@ export function isStalePreAdoptFocus(
return intent !== "reverse_verify" && intent !== "out_of_sample_check";
}
export function isOrphanOtherCollectFocus(
focus: { questionId?: string | null } | null | undefined,
evidence: readonly { status: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null }[],
): boolean {
const questionId = focus?.questionId ?? "";
if (!questionId.startsWith("collect:other:")) return false;
return ledgerHasConfirmedDatedEvent(evidence);
}
export async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
@@ -1140,8 +1152,11 @@ export async function persistNextInterviewIfIdle(input: {
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
&& isPersistedFocusId(staleFocusId)
isPersistedFocusId(staleFocusId)
&& (
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|| isOrphanOtherCollectFocus(staleFocus, dossier.evidence)
)
) {
try {
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
@@ -1229,6 +1244,7 @@ export async function persistNextInterviewIfIdle(input: {
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
terminalNote: true,
});
}
const remainingCollect = exhaustionSpokenCollectFollowup({
@@ -1309,6 +1325,7 @@ export async function persistNextInterviewIfIdle(input: {
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
hostNarration: nextInterview.hostNarration,
...(nextInterview.terminalNote ? { terminalNote: true } : {}),
});
}
@@ -4,22 +4,46 @@
* did this body already ask.
*/
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
function isQuestionSentence(text: string, stem: string): boolean {
if (!text) return false;
if (stem && text === stem) return true;
const prefix = stem.slice(0, 12);
if (prefix && text.startsWith(prefix)) return true;
return /[?]$/.test(text);
}
export function stripQuestionSentences(body: string, stem: string): string {
const prompt = stem.trim();
const spoken = body.trim();
if (!spoken) return "";
const kept: string[] = [];
let dropContinuation = false;
for (const part of spoken.split(SENTENCE_SPLIT)) {
const text = part.trim();
if (!text) continue;
if (isQuestionSentence(text, prompt)) {
dropContinuation = true;
continue;
}
if (dropContinuation && !NARRATIVE_SENTENCE.test(text)) {
dropContinuation = false;
continue;
}
dropContinuation = false;
kept.push(part);
}
return kept.join("").trim();
}
export function composeCollectSpokenAssistantText(body: string, prompt: string): string {
const stem = prompt.trim();
const spoken = body.trim();
if (!stem) return spoken;
if (!spoken || spoken === stem) return stem;
const prefix = stem.slice(0, 12);
const stripped = spoken
.split(/(?<=[。!?\n])/)
.filter((sentence) => {
const text = sentence.trim();
if (!text) return false;
if (text === stem) return false;
return !(prefix && text.startsWith(prefix));
})
.join("")
.trim();
const stripped = stripQuestionSentences(spoken, stem);
if (!stripped) return stem;
const suffix = `\n\n${stem}`;
if (stripped.includes(stem)) return stripped;
@@ -237,7 +237,9 @@ const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
"relatives",
]);
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
function isConfirmedDated(
item: Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">,
): boolean {
return item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& Boolean(item.occurredFrom || item.occurredTo);
@@ -1207,11 +1209,12 @@ export function spokenFollowupForUser(
&& domain === "other"
&& followup.collect_retry !== true
&& !evidence.some(isConfirmedDated);
if (domain === "other") {
return openingOther ? GENERIC_COLLECT_QUESTION : null;
}
const mapped = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
: openingOther
? GENERIC_COLLECT_QUESTION
: USER_COLLECT_QUESTION[domain];
: USER_COLLECT_QUESTION[domain];
return mapped ?? null;
}
@@ -1289,25 +1292,6 @@ export function isRemainingEvidenceCollect(
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: collectHint(
USER_COLLECT_QUESTION.other,
"本命 Dasha + 行运(方法1",
"",
evidence,
),
must_not_label: false,
choice_frame: null,
source: "oos_blind",
};
}
export function exhaustionSpokenCollectFollowup(input: {
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
@@ -1345,6 +1329,22 @@ export function collectQuestionDomain(domain: string | null | undefined): string
return OPENING_COLLECT_DOMAIN;
}
export function parseCollectFocusQuestionId(questionId: string | null | undefined): {
domain: string;
} | null {
if (!questionId) return null;
const match = /^collect:([^:]+):/.exec(questionId.trim());
const domain = match?.[1]?.trim() ?? "";
if (!domain) return null;
return { domain };
}
export function ledgerHasConfirmedDatedEvent(
evidence: readonly Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">[],
): boolean {
return evidence.some(isConfirmedDated);
}
export function parsePersistedFollowupQuestionId(questionId: string | null | undefined): {
method_id: string;
ask_theme: string;
@@ -2017,7 +2017,7 @@ export function buildMethodFollowupPlan(input: {
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
@@ -21,7 +21,7 @@ import {
type ConversationFocus,
} from "./tool-service";
export { parsePersistedFollowupQuestionId } from "./method-followup";
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
export type PersistServerFocusStatus =
| "created"
@@ -7,7 +7,8 @@
* or live-unlock after compare/offer/result tools tokens may stream as
* `live`. A later public tool-call retracts that speculative stream so
* "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus`
* only attaches the question stem; live greeting/handoff in the same step stays.
* keeps already-live greeting/handoff and does not retract it; after the stem
* is attached, a second spoken paragraph in the same turn is discarded.
*/
export type StepAnswerChunk = Readonly<{
@@ -27,6 +28,8 @@ export type StepAnswerState = {
live: boolean;
publishedUpTo: number;
unlocked: boolean;
stemAttached: boolean;
publishedAny: boolean;
};
export type StepAnswerEffect =
@@ -67,6 +70,8 @@ export function createStepAnswerState(): StepAnswerState {
live: false,
publishedUpTo: 0,
unlocked: false,
stemAttached: false,
publishedAny: false,
};
}
@@ -116,6 +121,7 @@ function liveRemainder(state: StepAnswerState): StepAnswerEffect {
const next = state.text.slice(state.publishedUpTo);
if (!next) return { kind: "none" };
state.live = true;
state.publishedAny = true;
state.publishedUpTo = state.text.length;
return { kind: "live", text: next };
}
@@ -127,9 +133,24 @@ function retractLive(state: StepAnswerState): StepAnswerEffect {
state.live = false;
state.publishedUpTo = 0;
state.unlocked = false;
state.publishedAny = false;
return wasLive ? { kind: "retract" } : { kind: "none" };
}
function publishUnpublishedSetFocus(state: StepAnswerState): StepAnswerEffect {
const unpublished = state.text.slice(state.publishedUpTo);
if (hasCjk(unpublished)) {
state.publishedAny = true;
state.publishedUpTo = state.text.length;
const pieces = [unpublished];
resetBuffers(state);
return { kind: "publish", pieces };
}
const leftover = unpublished.trim().length > 0;
resetBuffers(state);
return leftover ? { kind: "discard" } : { kind: "none" };
}
/**
* Advance the per-step buffer. Terminal Chinese may stream as `live` once
* the step is unlocked or a spoken sentence has closed. Tool-result ends
@@ -149,6 +170,7 @@ export function applyStepAnswerChunk(
resetBuffers(state);
return { kind: "none" };
case "text-delta": {
if (state.stemAttached && state.publishedAny) return { kind: "discard" };
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
if (text) {
state.text += text;
@@ -160,7 +182,9 @@ export function applyStepAnswerChunk(
case "tool-call":
if (isPublicToolCall(chunk, isPublicTool)) {
state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" };
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
return publishUnpublishedSetFocus(state);
}
const retracted = retractLive(state);
return retracted.kind === "retract" ? retracted : { kind: "none" };
}
@@ -169,6 +193,7 @@ export function applyStepAnswerChunk(
case "tool-error": {
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
state.stemAttached = true;
resetBuffers(state);
return { kind: "none" };
}
@@ -188,11 +213,13 @@ export function applyStepAnswerChunk(
const reason = stepFinishReason(chunk);
if (state.live) {
const rest = state.text.slice(state.publishedUpTo);
if (rest) state.publishedAny = true;
resetBuffers(state);
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
}
const publish = shouldPublishStepText(state, reason);
const pieces = publish ? [...state.pieces] : [];
if (publish) state.publishedAny = true;
resetBuffers(state);
return publish ? { kind: "publish", pieces } : { kind: "discard" };
}
@@ -207,6 +234,7 @@ export function flushStepAnswerOnStreamFinish(
): StepAnswerEffect {
if (state.live) {
const rest = state.text.slice(state.publishedUpTo);
if (rest) state.publishedAny = true;
resetBuffers(state);
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
}
@@ -1,4 +1,4 @@
import { detachCollectSpokenAssistantText } from "./collect-prompt";
import { stripQuestionSentences } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
@@ -114,7 +114,7 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
if (!focus) return { ...turn, question: null };
const question = turnQuestionFromFocus(focus);
const text = question && turn.text
? detachCollectSpokenAssistantText(turn.text, question.prompt)
? stripQuestionSentences(turn.text, question.prompt)
: turn.text;
return { ...turn, text, question };
});
+31 -1
View File
@@ -55,6 +55,7 @@ import {
import { USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy";
import {
isHoldoutVerificationQuote,
isPersistedFocusId,
} from "@/lib/rectification-agentic/v9/choice-card";
import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau";
import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan";
@@ -817,6 +818,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const engineVersion = v9EngineVersion();
let hasReadCase = false;
let spokenPromptFailures = 0;
let setFocusCompleted = 0;
const receipt = async (
toolName: string,
@@ -1062,6 +1064,31 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
const active = parsed.conversationSummary.activeFocus;
if (
setFocusCompleted > 0
&& active
&& isPersistedFocusId(active.id)
&& active.askedTurnId === turnId
) {
const projection = {
focus_id: active.id,
question_id: active.questionId,
intent: active.intent,
target_evidence_id: active.targetEvidenceId,
target_domain: active.targetDomain,
target_kind: active.targetKind,
expected_answer_schema: active.expectedAnswerSchema,
status: active.status,
asked_at: active.askedAt,
idempotent: true,
};
await receipt("rectification-set-focus", "intent.classified", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
}
nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup;
decisionReceipt = parsed.latestResult?.decisionReceipt ?? null;
} catch {
@@ -1089,7 +1116,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
resultFingerprint: JSON.stringify({ reason: spoken.reason }),
});
const fallbackDomain = nextFollowup.domain ?? "";
const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain];
const fallbackPrompt = fallbackDomain && fallbackDomain !== "other"
? USER_COLLECT_QUESTION[fallbackDomain]
: undefined;
if (
spokenPromptFailures < 2
|| nextFollowup.intent !== "collect_method_evidence"
@@ -1147,6 +1176,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
setFocusCompleted += 1;
return projection;
} catch (error) {
await receipt("rectification-set-focus", "intent.classified", "failed", {
@@ -14,6 +14,8 @@ import {
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
export const ACCIDENT_CASE_BEFORE_COPY = {
@@ -154,11 +156,43 @@ test("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus",
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const followup = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
const copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
assert.doesNotMatch(tools, /\?\? GENERIC_COLLECT_QUESTION/);
assert.doesNotMatch(answerChoice, /\?\? GENERIC_COLLECT_QUESTION/);
assert.match(followup, /openingOther/);
assert.match(followup, /followup\.method_id === "dasha_events"/);
assert.match(followup, /collect:other:/);
const banned = "也可以再" + "说一件";
assert.equal(copy.includes(banned), false);
assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false);
});
test("settled assistant body with a focus has no question-mark sentences", () => {
const stem = "你大概是哪一年搬的家?";
const linked = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant",
text: "范围收到 05:0005:10。你大概哪一年搬过家?",
}], [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relocation:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: { prompt: stem, collect: true },
status: "active",
askedAt: "2026-09-07T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
}]);
assert.doesNotMatch(linked[0]?.text ?? "", /[?]/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /stripQuestionSentences/);
assert.match(agentRun, /withRangeChangedAfterEvidence/);
});
test("delivery narration defers career windows and the range card replaces minute cards", () => {
@@ -360,6 +360,9 @@ test("opening collect domain is other rather than unknown", () => {
source: "method_coverage",
});
assert.equal(fallback.domain, "other");
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场 domain 仍是 other,稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/);
});
@@ -74,6 +74,9 @@ const COLLECT_EVIDENCE = [
] as const;
const OPENING_SKIPPED = {
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场跳过记录仍按 other 身份,避免把 unknown 写入账本
questionId: "collect:other:collect_method_evidence",
target_domain: "other",
status: "skipped",
@@ -593,7 +593,7 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.match(chat, /persistedOfferFromTurn/);
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& caseSnapshotLoaded\s*&& !busy\s*&& selectionCardMessageKey/);
assert.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
/offeredSelectionOnce/,
@@ -295,7 +295,7 @@ test("dated collect order walks remaining year-bearing domains then occupation",
assert.equal(occupationClosed.next_followup, null);
// 原值: other
// 新值: null
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到「也可以再说一件事」
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到开放式补问兜底句
assert.equal(
exhaustionSpokenCollectFollowup({
evidence: TWO_SCOREABLE,
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
@@ -88,7 +88,36 @@ test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /detachCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:0005:10。");
});
test("stripQuestionSentences drops a mid-body stem and keeps the surrounding sentences", () => {
const stem = "你大概是哪一年搬的家?";
const body = "记下了。你大概是哪一年搬的家?范围还在 05:00–05:10。";
assert.equal(stripQuestionSentences(body, stem), "记下了。范围还在 05:0005:10。");
});
test("stripQuestionSentences returns empty when the body is only a question", () => {
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences("你大概哪一年搬过家?", stem), "");
assert.equal(RECTIFICATION_USER_COPY.collectHandoff.includes("请回答下面的问题"), false);
});
test("attachQuestionsToTurns leaves body unchanged when the turn has no focus", () => {
const body = "范围收到 05:0005:10。你大概哪一年搬过家?";
const turns = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant" as const,
text: body,
}], []);
assert.equal(turns[0]?.text, body);
assert.equal(turns[0]?.question, null);
});
@@ -10,7 +10,7 @@ import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
applyRectificationChoice,
ensureNonTerminalTurnExit,
@@ -580,13 +580,14 @@ test("skill version stays 10.0.15 after the range-delivery bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.15");
});
test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () => {
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
const srcRoot = new URL("../src/", import.meta.url);
const files = [
"lib/rectification-agentic/v9/method-followup.ts",
"lib/rectification-agentic/v9/answer-choice.ts",
"mastra/rectification-v9-tools.ts",
"app/api/rectification/agent/route.ts",
"lib/rectification-agentic/user-copy.ts",
];
const hits: string[] = [];
for (const relative of files) {
@@ -597,9 +598,7 @@ test("USER_COLLECT_QUESTION.other remains only on the opening collect path", ()
}
}
}
assert.equal(hits.some((line) => line.includes("answer-choice.ts")), false, hits.join("\n"));
assert.equal(hits.some((line) => line.includes("rectification-v9-tools.ts")), false, hits.join("\n"));
assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n"));
assert.deepEqual(hits, []);
});
test("collect spoken stop button keeps CHOICE_STOP_LABEL; range line is status only", () => {
@@ -645,8 +644,9 @@ test("covered accident shape adopts instead of collecting other", async () => {
);
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration ?? "", new RegExp(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
assert.match(persisted.hostNarration ?? "", /04:47|04:51|范围/);
assert.equal(persisted.terminalNote, true);
});
test("closed ceiling translates the gate and does not persist a focus", async () => {
@@ -668,7 +668,7 @@ test("closed ceiling translates the gate and does not persist a focus", async ()
);
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration ?? "", /还差|带月份的经历|日期还没对清|排不出可比较的候选/);
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
@@ -705,7 +705,7 @@ test("inconsistent projection logs ranked_count 0 and still delivers a gate", as
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
@@ -0,0 +1,348 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
persistNextInterviewIfIdle,
isOrphanOtherCollectFocus,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import {
persistableFocusDomain,
parseCollectFocusQuestionId,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
buildMethodFollowupPlan,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
type ExecutableTool<T = unknown> = {
execute(input: unknown): Promise<T>;
};
const BANNED_OTHER_COLLECT = "也可以再" + "说一件";
function dated(
domain: string,
year: string,
extra: { eventKind?: string } = {},
) {
const occupation = extra.eventKind === "occupation_note";
return {
status: "confirmed" as const,
domain,
datePrecision: occupation ? "unknown" as const : "month" as const,
occurredFrom: occupation ? null : `${year}-03-01`,
occurredTo: null,
eventKind: extra.eventKind,
};
}
const SEVEN_DATED = [
dated("education", "2016"),
dated("career", "2020"),
dated("relationship", "2018"),
dated("finance", "2024"),
dated("relocation", "2022"),
dated("health_pressure", "2021"),
];
const FAMILY_DECLINED = [{ target_domain: "family", status: "declined" }];
const OCCUPATION_FOCUS = {
id: FOCUS_ID,
questionId: "collect:occupation:collect_method_evidence",
intent: "collect_method_evidence",
targetDomain: "other",
targetKind: "occupation_note",
expectedAnswerSchema: {
collect: true,
prompt: USER_COLLECT_QUESTION.occupation,
},
};
function rpcEvidence(rows: readonly ReturnType<typeof dated>[]) {
return rows.map((item, index) => ({
id: `e-${item.domain}-${index}`,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-08T00:00:00.000Z",
}));
}
test("occupation continuation keeps collect:occupation even when target_domain is other", () => {
assert.equal(persistableFocusDomain("occupation"), "other");
assert.deepEqual(parseCollectFocusQuestionId("collect:occupation:collect_method_evidence"), {
domain: "occupation",
});
const plan = buildMethodFollowupPlan({
evidence: SEVEN_DATED,
declinedTopics: FAMILY_DECLINED,
activeFocus: OCCUPATION_FOCUS,
sessionOutcome: "collect_evidence",
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
});
const next = plan.next_followup;
assert.ok(next);
assert.equal(next?.domain, "occupation");
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation);
assert.equal(stableFollowupQuestionId(next!), OCCUPATION_FOCUS.questionId);
});
test("set-focus does not persist collect:other after two invalid spoken prompts", async () => {
const writes: string[] = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [],
evidenceCount: 0,
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-08T00:00:00.000Z",
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes.push(String(args.p_question_id ?? ""));
throw new Error("must not persist collect:other fallback");
},
});
const execute = (createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
})["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
const invalid = {
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
spokenPrompt: "请选一个选项继续问经历吧",
targetDomain: "career",
};
const first = await execute(invalid);
const second = await execute(invalid);
assert.equal((first as { error?: string }).error, "invalid_spoken_prompt");
assert.equal((second as { error?: string }).error, "invalid_spoken_prompt");
assert.equal(writes.some((id) => id.startsWith("collect:other:")), false);
assert.equal(writes.length, 0);
});
test("second set-focus in the same turn is idempotent and keeps the occupation focus", async () => {
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = {
focus: activeFocusFixture({
questionId: OCCUPATION_FOCUS.questionId,
intent: "collect_method_evidence",
targetDomain: "other",
targetKind: "occupation_note",
expectedAnswerSchema: OCCUPATION_FOCUS.expectedAnswerSchema,
askedTurnId: TURN_ID,
}),
};
const writes: string[] = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: rpcEvidence(SEVEN_DATED),
evidenceCount: SEVEN_DATED.length,
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: store.focus,
declined_skipped_topics: FAMILY_DECLINED,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-08T00:00:00.000Z",
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes.push(String(args.p_question_id ?? ""));
store.focus = activeFocusFixture({
questionId: String(args.p_question_id),
intent: String(args.p_intent),
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : "other",
targetKind: "occupation_note",
expectedAnswerSchema: (args.p_expected_answer_schema as Record<string, unknown>) ?? {},
askedTurnId: typeof args.p_asked_turn_id === "string" ? args.p_asked_turn_id : TURN_ID,
});
return {
focus: store.focus,
idempotent: writes.length > 1,
};
},
});
const execute = (createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
})["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
const first = await execute({
caseId: CASE_ID,
questionId: OCCUPATION_FOCUS.questionId,
intent: "collect_method_evidence",
spokenPrompt: USER_COLLECT_QUESTION.occupation,
targetDomain: "occupation",
}) as Record<string, unknown>;
const second = await execute({
caseId: CASE_ID,
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
spokenPrompt: "请选一个选项继续问经历吧",
targetDomain: "career",
}) as Record<string, unknown>;
assert.equal(first.focus_id, FOCUS_ID);
assert.equal(second.idempotent, true);
assert.equal(second.focus_id, first.focus_id);
assert.equal(second.question_id, OCCUPATION_FOCUS.questionId);
assert.equal(writes.some((id) => id.startsWith("collect:other:")), false);
});
test("orphan collect:other focus is skipped so idle persist can deliver", async () => {
const covered = [
...SEVEN_DATED,
dated("occupation", "2020", { eventKind: "occupation_note" }),
];
const rpcRows = rpcEvidence(covered);
const fingerprint = evidenceLedgerFingerprint(rpcRows.map((item) => ({
id: item.id,
sourceTurnId: item.source_turn_id,
subject: item.subject,
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,
supersedesEvidenceId: item.supersedes_evidence_id,
createdAt: item.created_at,
})) as never);
let skipped = false;
const base = dossierFixture({
evidence: rpcRows,
evidenceCount: covered.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: fingerprint,
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocusFixture({
questionId: "collect:other:collect_method_evidence",
intent: "collect_method_evidence",
targetDomain: "other",
expectedAnswerSchema: { collect: true, prompt: "placeholder" },
}),
declined_skipped_topics: FAMILY_DECLINED,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-08T00:00:00.000Z",
},
});
assert.equal(isOrphanOtherCollectFocus({
questionId: "collect:other:collect_method_evidence",
}, covered), true);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => skipped
? {
...base,
conversation_summary: {
...base.conversation_summary,
active_focus: null,
},
}
: base,
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => {
skipped = true;
return {
focus_id: args.p_focus_id,
status: "skipped",
evidence_id: null,
idempotent: false,
};
},
set_agentic_rectification_conversation_focus: (_fn, args) => {
throw new Error(`must not persist ${String(args.p_question_id)}`);
},
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(skipped, true);
assert.equal(idle.terminalNote, true);
assert.ok(idle.hostNarration);
assert.equal((idle.hostNarration ?? "").includes(BANNED_OTHER_COLLECT), false);
});
test("agent-run 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/);
});
test("user-copy and rectification sources no longer contain the other-collect fallback", () => {
const files = [
"../src/lib/rectification-agentic/user-copy.ts",
"../src/lib/rectification-agentic/v9/method-followup.ts",
"../src/lib/rectification-agentic/v9/server-focus.ts",
"../src/lib/rectification-agentic/v9/answer-choice.ts",
"../src/mastra/rectification-v9-tools.ts",
];
for (const relative of files) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
assert.equal(source.includes(BANNED_OTHER_COLLECT), false, relative);
}
assert.equal(USER_COLLECT_QUESTION.other, undefined);
assert.equal(USER_COLLECT_QUESTION_RETRY.other, undefined);
});
@@ -0,0 +1,307 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildCandidateContrastPacket,
vargaSignPartitionKey,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import {
buildInferenceState,
nextProbe,
} from "../src/lib/rectification-agentic/core/build-state.ts";
import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts";
import type { ConflictProbe, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts";
import {
rangeChangedAfterEvidence,
rangeWidthMinutes,
withRangeChangedAfterEvidence,
} from "../src/lib/rectification-agentic/user-copy.ts";
const S1_TIMES = [
"04:50", "04:51", "04:52", "04:53", "04:54", "04:55", "04:56", "04:57", "05:13",
] as const;
const KEPT_TIMES = S1_TIMES.slice(0, 7);
const REPLACED = ["04:57", "05:13"] as const;
const NEW_TIMES = ["04:59", "05:14"] as const;
const S2_TIMES = [...KEPT_TIMES, ...NEW_TIMES] as const;
const EVENTS = [
{ id: "e-edu-2010", domain: "education", year: 2010, precision: "year" as const },
{ id: "e-family-2011", domain: "family", year: 2011, precision: "year" as const },
{ id: "e-finance-2012", domain: "finance", year: 2012, precision: "year" as const },
{ id: "e-health-2013", domain: "health_pressure", year: 2013, precision: "year" as const },
];
function clockProbe(input: {
id: string;
semanticKey: string;
domain: string;
year: number;
source: string;
choiceKind?: ConflictProbe["choice_kind"];
yesSupports: readonly string[];
yesConflicts: readonly string[];
}): ConflictProbe {
return {
id: input.id,
semantic_key: input.semanticKey,
candidate_split_hash: `${input.semanticKey}:${input.yesSupports.join(",")}`,
domain: input.domain,
year: input.year,
question: input.semanticKey,
candidate_ids: [...input.yesSupports, ...input.yesConflicts],
expected_outcomes: [
{ answer_class: "yes", supports: input.yesSupports, conflicts: input.yesConflicts },
{ answer_class: "weak_yes", supports: input.yesSupports, conflicts: input.yesConflicts },
{ answer_class: "no", supports: input.yesConflicts, conflicts: input.yesSupports },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: input.source,
...(input.choiceKind ? { choice_kind: input.choiceKind } : {}),
};
}
function answeredProbes(): ConflictProbe[] {
const yesSupports = [...KEPT_TIMES];
const yesConflicts = [...REPLACED];
return [
clockProbe({
id: "probe:varga.d9.old",
semanticKey: "varga.d9.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
domain: "relationship",
year: 0,
source: "varga_contrast",
choiceKind: "varga_style",
yesSupports,
yesConflicts,
}),
clockProbe({
id: "probe:varga.d10.old",
semanticKey: "varga.d10.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
domain: "career",
year: 0,
source: "varga_contrast",
choiceKind: "varga_style",
yesSupports,
yesConflicts,
}),
clockProbe({
id: "probe:career.2023",
semanticKey: "career.2023",
domain: "career",
year: 2023,
source: "dasha_boundary",
yesSupports,
yesConflicts,
}),
clockProbe({
id: "probe:career.2024",
semanticKey: "career.2024",
domain: "career",
year: 2024,
source: "dasha_boundary",
yesSupports,
yesConflicts,
}),
clockProbe({
id: "probe:relocation.2015",
semanticKey: "relocation.2015",
domain: "relocation",
year: 2015,
source: "dasha_boundary",
yesSupports,
yesConflicts,
}),
];
}
function engineCandidates(times: readonly string[]) {
return times.map((time) => ({ id: time, time, relative_support: 20 }));
}
function choiceAnswers(probes: readonly ConflictProbe[]): ProbeAnswer[] {
return probes.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "yes" as const,
classified_from: "choice" as const,
}));
}
function deltaFor(state: ReturnType<typeof buildInferenceState>, time: string): number {
const row = state.candidates.find((item) => item.time === time);
assert.ok(row, time);
return row.posterior_score - row.prior_score;
}
function width(range: readonly [string, string] | null | undefined): number {
if (!range) return Number.POSITIVE_INFINITY;
return rangeWidthMinutes(range[0], range[1]) ?? Number.POSITIVE_INFINITY;
}
function buildS1() {
const probes = answeredProbes();
return buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: engineCandidates(S1_TIMES),
events: EVENTS,
probes,
answered_probes: choiceAnswers(probes),
});
}
function rescoreWithoutLiveProbes(
previous: ReturnType<typeof buildInferenceState>,
times: readonly string[],
liveProbes: readonly ConflictProbe[] = [],
) {
return buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: engineCandidates(times),
events: EVENTS,
probes: liveProbes,
previous,
});
}
test("BUG-587 (a) a changed candidate set still replays five answered probes", () => {
const s1 = buildS1();
assert.equal(s1.answered_probes.length, 5);
assert.equal(s1.rounds.length, 5);
assert.ok(width(s1.credible_range) <= 8);
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
assert.equal(s2.rounds.length, 5);
assert.equal(s2.answered_probes.length, 5);
assert.equal(s2.probes.filter((item) => item.carried).length, 5);
for (const time of KEPT_TIMES) {
assert.equal(deltaFor(s2, time), deltaFor(s1, time), time);
}
for (const time of NEW_TIMES) {
assert.equal(deltaFor(s2, time), 0, time);
}
assert.ok(width(s2.credible_range) <= width(s1.credible_range) + NEW_TIMES.length);
});
test("BUG-587 (b) a second rescore still replays every answer", () => {
const s1 = buildS1();
const afterFinance = rescoreWithoutLiveProbes(s1, S2_TIMES);
const afterRelocation = rescoreWithoutLiveProbes(afterFinance, S2_TIMES);
assert.equal(afterRelocation.rounds.length, 5);
for (const time of KEPT_TIMES) {
assert.equal(deltaFor(afterRelocation, time), deltaFor(s1, time), time);
}
for (const time of NEW_TIMES) {
assert.equal(deltaFor(afterRelocation, time), 0, time);
}
assert.equal(
afterRelocation.candidates.every((item) => item.posterior_score === item.prior_score),
false,
);
});
test("BUG-587 (c) varga semantic keys use the sign partition, not the minute list", () => {
const transitions = [
{ layer: "d9", at: "04:53", from_sign: "天秤座", to_sign: "天蝎座" },
{ layer: "d9", at: "04:57", from_sign: "天蝎座", to_sign: "射手座" },
];
const first = buildCandidateContrastPacket({
candidateSetVersion: "set-a",
candidateTimes: ["04:50", "04:53", "04:57"],
transitions,
});
const second = buildCandidateContrastPacket({
candidateSetVersion: "set-b",
candidateTimes: ["04:50", "04:52", "04:53", "04:57"],
transitions,
});
const firstKey = first.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
const secondKey = second.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
assert.equal(firstKey, "varga.d9.天秤座|天蝎座|射手座");
assert.equal(secondKey, firstKey);
assert.doesNotMatch(firstKey ?? "", /\d\d:\d\d/);
assert.equal(vargaSignPartitionKey("d9", ["天秤座", "天蝎座", "射手座"]), firstKey);
const s1 = buildS1();
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
const carried = s2.probes.find((item) => item.semantic_key.includes("varga.d9."));
assert.ok(carried?.carried);
assert.match(carried.semantic_key, /\d\d:\d\d/);
assert.equal(
s2.answered_probes.some((item) => item.semantic_key === carried.semantic_key),
true,
);
});
test("BUG-587 (d) nextProbe does not re-ask a carried probe", () => {
const s1 = buildS1();
const fresh: ConflictProbe = clockProbe({
id: "probe:education.2016",
semanticKey: "education.2016",
domain: "education",
year: 2016,
source: "dasha_boundary",
yesSupports: ["04:50", "04:51"],
yesConflicts: ["05:14"],
});
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES, [fresh]);
const selected = nextProbe(s2);
assert.equal(selected?.id, fresh.id);
assert.equal(s2.probes.some((item) => item.carried && item.id === selected?.id), false);
assert.equal(
selectHighestGainProbe(s2.probes, s2.answered_probes)?.id,
fresh.id,
);
for (const probe of s2.probes.filter((item) => item.carried)) {
assert.equal(selectHighestGainProbe([probe], s2.answered_probes), null);
}
});
test("BUG-588 evidence narration names a widened range after compare", () => {
const changed = withRangeChangedAfterEvidence(
"记下了。",
["04:50", "04:57"],
["04:47", "05:15"],
);
assert.match(changed, /范围从 04:5004:57 变为 04:4705:15。/);
assert.equal(
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]),
"范围从 04:5004:57 变为 04:4705:15。",
);
assert.equal(
withRangeChangedAfterEvidence("记下了。", ["04:50", "04:57"], ["04:50", "04:57"]),
"记下了。",
);
assert.equal(rangeChangedAfterEvidence(["04:50", "04:57"], ["04:50", "04:57"]), null);
const agentRun = readFileSync(
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
"utf8",
);
const askedFocusAt = agentRun.indexOf("const askedFocus = latestDossier.conversationSummary.activeFocus");
const settlement = askedFocusAt >= 0 ? agentRun.slice(askedFocusAt) : "";
const stripAt = settlement.indexOf("stripQuestionSentences");
const rangeAt = settlement.indexOf("withRangeChangedAfterEvidence");
assert.ok(stripAt >= 0 && rangeAt > stripAt);
assert.match(agentRun, /rectification-compare-candidates/);
});
test("P3 selection cards stay gated by busy and a loaded snapshot", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const block = chat.slice(
chat.indexOf("const showSelectionCards"),
chat.indexOf("const showReadonlyRange"),
);
assert.match(block, /!busy/);
assert.match(block, /caseSnapshotLoaded/);
assert.match(chat, /startTransition\(/);
});
@@ -660,6 +660,6 @@ test("exhaustion after coverage is closed does not fall back to domain other", (
});
// 原值: domain=other / USER_COLLECT_QUESTION.other
// 新值: null
// 原因: 穷尽后交付,不再问「也可以再说一件事」
// 原因: 穷尽后交付,不再问开放式补问兜底句
assert.equal(next, null);
});
@@ -22,7 +22,6 @@ import {
import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
@@ -514,8 +513,11 @@ test("zero-evidence opening persists the server-owned first question", async ()
assert.equal(followup.domain, "other");
assert.equal(followup.source, "method_coverage");
assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/);
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场仍用 domain=otherGENERIC 题干;稳定 id 不得含 unknown
assert.equal(stableFollowupQuestionId(followup), "collect:other:collect_method_evidence");
// 旧:开场 domain=other 后查表落到「也可以再说一件」→ 新:method_coverage + other 且非 retry 用 GENERIC_COLLECT_QUESTION → 保留 开场题由服务端出、带具体例子
// 旧:开场 domain=other 后查表落到补问兜底句 → 新:method_coverage + other 且非 retry 用 GENERIC_COLLECT_QUESTION → 保留 开场题由服务端出、带具体例子
assert.equal(spokenFollowupForUser(followup), GENERIC_COLLECT_QUESTION);
assert.doesNotMatch(spokenFollowupForUser(followup) ?? "", /^也可以再/);
assert.match(spokenFollowupForUser(followup) ?? "", /比如/);
@@ -598,7 +600,10 @@ test("exhaustion other fallback and other retry keep the after-evidence copy", (
source: "method_coverage",
collect_retry: true,
};
assert.equal(spokenFollowupForUser(retry), USER_COLLECT_QUESTION_RETRY.other);
// 原值: USER_COLLECT_QUESTION_RETRY.other
// 新值: null
// 原因: BUG-586 删除 other 补问文案,非开场 other 按无可问处理
assert.equal(spokenFollowupForUser(retry), null);
});
test("GENERIC_COLLECT_QUESTION keeps concrete opening examples", () => {
@@ -724,6 +729,9 @@ test("collect persist maps occupation to other and health_pressure to health", a
assert.equal(persistableFocusDomain(null), null);
assert.equal(persistableFocusDomain("unknown"), null);
// 旧 collect:education → 新 collect:other → 保留 unknown/null 不得写入 unknown
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: unknown/null 采集仍归一到开场 other id,不得写入 unknown
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: "unknown" })), "collect:other:collect_method_evidence");
assert.equal(stableFollowupQuestionId(collectFollowup({ domain: null })), "collect:other:collect_method_evidence");
@@ -132,13 +132,80 @@ test("keeps a live opening greeting when the next public tool is set-focus", ()
).kind,
"none",
);
assert.deepEqual(
// 原值: set-focus 后第二段 live 发布
// 新值: discard
// 原因: BUG-584 决策 2,保留 set-focus 前的问候,丢掉工具结果后的复述
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }),
isPublicTool,
).kind,
"discard",
);
});
test("set-focus with no prior spoken text still publishes the later greeting", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }),
isPublicTool,
),
{ kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" },
{ kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" },
);
});
test("set-focus publishes an unfinished CJK remainder instead of dropping it", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "范围已经收到 05:00 到 05:10" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
),
{ kind: "publish", pieces: ["范围已经收到 05:00 到 05:10"] },
);
});
test("English planning before set-focus is still discarded", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set the next collect focus" }), isPublicTool).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"discard",
);
});
+110 -2
View File
@@ -23,7 +23,7 @@ import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
@@ -393,7 +393,6 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, greeting);
assert.equal(result.collectSpokenEmitted, false);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
@@ -677,3 +676,112 @@ test("reply regeneration is a separate Jyotisha agent with only read-case access
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});
test("set-focus after spoken text does not append a second paragraph", async () => {
const first = "2016 年 9 月去北京工作,记下了。范围收到 05:00 到 05:10。";
const second = "2016 年那件事对校正很有帮助,我们再对一下搬家。";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: first }),
chunk("tool-call", { toolName: "rectification-set-focus" }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: second }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, first);
assert.equal(result.answerText.includes(second), false);
const deltas = emitted.filter((event) => event.type === "answer.delta") as Array<{
text?: string;
replace?: boolean;
}>;
assert.equal(deltas.some((item) => (item.text ?? "").includes(second)), false);
assert.equal(deltas.some((item) => item.replace === true && item.text === ""), false);
});
test("agent-run strips question sentences when this turn owns the focus", async () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: body }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "范围已经收到,收在 05:0005:10。");
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.equal(replaced?.text, "范围已经收到,收在 05:0005:10。");
});
test("agent-run uses collectHandoff when the owned-focus body is only a question", async () => {
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你大概哪一年搬过家?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});