Compare commits

..

8 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
Jesse_Chen a00b069dcc fix(rectification): unblock range-card tsc and restore post-adopt windows (BUG-583)
Independent Staging Quality Gate / validate (push) Successful in 13m23s
Independent Staging Quality Gate / publish (push) Successful in 30m54s
Null-check the candidate result before reading resultId so next build can ship. Adopt reconnects prospective windows on the first verify turn, and dismissing the range card now shows the collect hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:22:24 +08:00
Jesse_Chen f3e7270237 docs(tasks): accept ab57d03f with tsc blocker; range delivery fix brief (BUG-583)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-07 12:04:39 +00:00
46 changed files with 2026 additions and 137 deletions
+16
View File
@@ -1,5 +1,21 @@
# 印度占星 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。
## 2026-09-07 — 生时校正结尾改成一张区间卡,不再让你在分钟里挑
问完经历后,交付轮只出一张卡:这段范围、排盘用的代表分钟、这段里稳定和会随分钟变的主题。如果几段之间还有没问过的关系盘或事业盘差别,会并排两三列对照,文案只来自类型表。动作只有「按这个范围用」和「再补一件经历」。不再显示相对支持度,也不再并排三行「候选 A 预测下次事业变动」。Skill 版本 10.0.15。
+113 -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`
@@ -9030,5 +9030,115 @@
- 复发自:BUG-558
- 修复版本:待发布(`codex/rectification-stop-rescore-fix-20260907`
## BUG-583 | 区间交付卡判空顺序使 tsc 失败,staging 无法部署
- 状态:resolved
- 首次发现:2026-09-07
- 最近更新:2026-09-07
- 影响面:`rectification-agentic-chat.tsx` 区间卡渲染、采用后首轮旁白、`RectificationRangeDelivery`「再补一件经历」
- 用户现象:staging 门禁与 `next build` 因 TypeScript 失败,BUG-581~582 的修复无法部署。采用后看不到承诺过的预测窗口。点「再补一件经历」后卡片消失、输入框上方没有下一句。
- 触发条件:`ab57d03f` 合入后跑 `tsc --noEmit`;或交付轮点「再补一件经历」;或采用后进入核对。
- 根因:渲染条件先读 `candidateResult.resultId` 再写 `candidateResult &&`,收窄发生在属性读取之后。`prospectiveWindowsNarration` 从交付旁白撤掉后没有接到采用后首轮。`onContinue` 只改本地 dismiss,不给采集提示。步骤条类名 `rectification-step-state` 已由 BUG-575 禁止,不能靠加回步骤条过关。
- 修复:判空提到 `dismissedRangeResultId` 之前。`persistNextInterviewIfIdle` 在已采用时把 `prospective_probes` 接到首轮旁白末尾;`deferredCareerWindow` 仍留在交付旁白。收起卡片后用 `RangeDeliveryContinueHint` 显示「第 1 步·收集经历」加 `continueCollectFallback`,不用被禁的步骤条类名。
- 验证:`frontend/tests/rectification-range-delivery-20260907.test.ts``tsc --noEmit` 原文见 `PROGRESS-rectification-range-delivery-fix-20260907.md`
- 防复发:区间卡渲染源码必须先出现 `candidateResult &&` 再读 `resultId`。采用后首轮 idle persist 必须调用 `appendPostAdoptProspectiveWindows`。聊天源码仍不得出现 `rectification-step-state`
- 相关记录:BUG-575、BUG-581、BUG-582
- 复发自:无(区间卡 `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。
@@ -0,0 +1,50 @@
# PROGRESS · 区间交付卡修复单(2026-09-07
工作树:`.worktrees/rectification-range-delivery-fix-20260907`
分支:`codex/rectification-range-delivery-fix-20260907`
任务书:`docs/tasks/TASK-rectification-range-delivery-fix-20260907.md`
基线:`origin/staging` @ `f3e72702`(任务书写 `ab57d03f`;其上有验收文档提交)
本单状态:**待验收**。未 commit、未 push。
未改:采用 RPC 校验、确认门、`MIN_SEPARATION_LEAD``_relative_support``page.tsx`、Skill 版本(仍 10.0.15)、聊天源码不出现 `rectification-step-state`BUG-575)。
## 做了什么
- **3.1** `rectification-agentic-chat.tsx` 渲染条件改为 `showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && …`
- **3.2** 选「接上」:`persistNextInterviewIfIdle``acceptedTime` 时把 `prospectiveWindowsNarration(receipt.prospective_probes)` 接到首轮旁白末尾。交付旁白仍留 `deferredCareerWindow`。跳过最后一道核对题的 `postAdoptVerifyDone` 不走 idle 包装,仍是原句。
- **3.3** 「再补一件经历」收起卡片后,输入框上方 `RangeDeliveryContinueHint` 显示 `STEP_STATE_COPY.collect.headline` + `continueCollectFallback`。不用被禁的 `.rectification-step-state`
- **3.4** BUG-583、CHANGELOG、本文件、场景第 5 条、DESIGN / VOICE。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 区间卡渲染条件 | 先读 `candidateResult.resultId``candidateResult &&` | 先 `candidateResult &&` | TS18047 |
| 采用后首轮旁白 | 无 `prospectiveWindowsNarration` 调用点 | idle persist 已采用时追加 | 决策 2 接上 |
| 「再补一件经历」 | 只 `setDismissedRangeResultId` | 另显示收集经历提示 | 决策 3;不恢复步骤条类名 |
## 测试
开工前 `frontend` `./node_modules/.bin/tsc --noEmit; echo "tsc exit $?"` 原文:
```
src/components/rectification-agentic-chat.tsx(1655,67): error TS18047: 'candidateResult' is possibly 'null'.
tsc exit 2
```
修复后同一命令原文(stdout 为空,只有 echo):
```
tsc exit 0
```
- 任务书指定 TS 套件(`rectification-*.test.ts` + `consultation-*.test.ts` + `agent-voice-copy-contract.test.ts` + `skill-registry.test.ts`,排除 database):**1230 passed / 0 failed**`ab57d03f` 同切片 1227;本单 +3
- 本单改动文件 eslint**0 error**chat 上 2 条既有 hooks warning
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
- 浏览器点选「再补一件经历」:无登录态 / Chrome 驱动,记环境缺口,见 `docs/testing/rectification-scenarios-20260907.md` §5
## 偏离
1. 步骤条文案用 `RangeDeliveryContinueHint``.rectification-continue-hint`),没有恢复 `.rectification-step-state`。BUG-575 防复发与 `rectification-step-state-20260906.test.ts` 禁止该类名。
2. 预测窗口挂在 `persistNextInterviewIfIdle`(采用 RPC 之后的首轮),不挂 `applyRectificationChoice` 的跳过核对收尾,以免改掉 `postAdoptVerifyDone` 全等断言。
+6 -2
View File
@@ -69,8 +69,12 @@
| `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 已验收通过(P2block_scan 15 s 壁钟断言在门禁里可能间歇红) | `517df002`BUG-569570);staging 未部署,需先 Migrate Staging Database |
| `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 已验收通过(2 P3 建议:可靠度正则去掉「记得」、档案写回改走 account-profile-patch | `8e31680b`BUG-571573);迁移 `20260907020000` 待应用,部署前先 Migrate Staging Database |
| `TASK-rectification-stale-compare-fix-20260907.md` | `PROGRESS-rectification-stale-compare-fix-20260907.md` | **P0**BUG-559 把过长 `candidate_split_hash` 塞进引擎比较键,答完性格/边界卡后比较静默失败;快照过期压过用户停止;holdout 用过期领域被改写成「再说一件事」;采集轮正文双写 | 已验收通过(P2 正文双写根因未拆、P3 重算节流 Map 只增不减) | `4e0db55f`(落地编号 BUG-577580);staging 未部署 |
| `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 采集排在带年月采集之前 | 验收 | `codex/rectification-stop-rescore-fix-20260907`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 | 待验收 | `codex/rectification-range-delivery-card-20260907` |
| `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 无重叠。
@@ -77,3 +77,21 @@ ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ls tests/rectification-*.test.ts tests/consultation-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
grep -n "^version" ../skills/jyotish-birth-time-rectification/SKILL.md
```
## 验收(Claude2026-09-07`origin/staging` @ `ab57d03f`
**未通过(P1 阻断)**`frontend/src/components/rectification-agentic-chat.tsx` L1655 把 `dismissedRangeResultId !== candidateResult.resultId` 放在 `candidateResult &&` 判空之前,`tsc --noEmit``TS18047: 'candidateResult' is possibly 'null'`。硬红线 tsc 0 错不满足,`next build` 会失败,staging 门禁过不去。修法一行:把判空提到最前面。执行方进度记录写 tsc 0,不符。
其余各项(在修掉这一行之后可视为通过):
| 项 | 结论 |
| --- | --- |
| 4.1 投影 `range_delivery` | 通过。`divergence-panel.ts`:活跃簇 2~3、只列未问的 D9/D10/月宿层,星座先从探针选项取、再从换升表推,文案只取 `D9_TYPE_TABLE` / `D10_TYPE_TABLE``userChoice` 与 nakshatra `traits`;代表 id 用公开 UUID |
| 4.2 组件 | 通过。四张分钟卡、相对支持度、当前推荐徽章全部删除;两个动作;列单选,"都不像 / 说不好"清空 |
| 4.3 旁白与 Skill | 通过。三行预测窗口删除;Skill §9 改口径、10.0.15 快照与 live 一致、五份 references 与 10.0.14 逐字相同(没有新写方法学);注册表新条目 `sourceCommit: null` 与历史条目一致 |
| 4.4 记录 | 通过;手测场景第 5 条已加 |
| P2 | 旁白改成"下一次事业变动的预测窗口留在采用后的核对阶段",但采用后没有任何地方再展示预测窗口(`prospectiveWindowsNarration` 已无调用点)——这是一句兑现不了的承诺,与之前"说一声我来汇总"同类。要么在采用后核对首轮真的接上,要么把这句删掉 |
| P3 | "再补一件经历"只在本地隐藏卡片,不给用户任何提示该说什么;建议隐藏后由步骤条/旁白给一句采集提示 |
| P3 | lint warning 从 84 涨到 103 |
修复单:`TASK-rectification-range-delivery-fix-20260907.md`
@@ -0,0 +1,44 @@
# TASK · 区间交付卡修复单:tsc 判空顺序、兑现不了的预测窗口句、再补经历无提示(2026-09-07)
- 基线:`origin/staging` @ `ab57d03f`
- 分支:`codex/rectification-range-delivery-fix-20260907`
- 执行方:coding agent;验收:Claude
- 优先级:**P1 阻断**tsc 不过,staging 门禁与 `next build` 都会失败,BUG-581~582 的 P0 修复被一起卡住)
- BUG 编号起点:**BUG-583**(开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
## 1. 事故实证
| # | 事实 | 位置 |
| --- | --- | --- |
| 1 | `tsc --noEmit``TS18047: 'candidateResult' is possibly 'null'` | `frontend/src/components/rectification-agentic-chat.tsx` L1655`showSelectionCards && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && candidateResult && (…)` |
| 2 | 交付旁白句"下一次事业变动的预测窗口留在采用后的核对阶段",但 `prospectiveWindowsNarration` 已无任何调用点,采用后从不展示 | `user-copy.ts` `deferredCareerWindow``grep prospectiveWindowsNarration` 只剩定义 |
| 3 | 「再补一件经历」只 `setDismissedRangeResultId`,卡片消失后没有任何提示 | `rectification-agentic-chat.tsx` `onContinue` |
| 4 | 执行方进度记录写"tsc 0",与 `ab57d03f` 实测不符 | `PROGRESS-rectification-range-delivery-card-20260907.md` |
## 2. 决策记录
1. 判空提到最前:`showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && …`
2. 预测窗口二选一,本单选 **接上**:采用后 `postAdoptVerify` 第一轮旁白末尾追加 `prospectiveWindowsNarration(receipt.prospective_probes)`(有则加,无则不加);`deferredCareerWindow` 句保留。若执行方评估接不上,则删句并写明。
3. 「再补一件经历」隐藏卡片后,步骤条切到"第 1 步·收集经历"文案,并在输入框上方给一句 `continueCollectFallback`(已有文案:"请继续说下一件你记得比较清楚、大概带年份的经历。")。
4. 进度记录必须贴 `tsc --noEmit` 的真实输出行;没有复跑不得写 0。
## 3. 任务分解
- 3.1 判空修复 + 源扫描断言(`rectification-range-delivery-20260907.test.ts`:正则锁定 `candidateResult &&` 出现在 `dismissedRangeResultId` 之前)。
- 3.2 预测窗口接到采用后首轮(`postAdoptVerify` 路径),测试:有 `prospective_probes` 的 receipt → 采用后首轮旁白含"预测下一次事业变动";无则不含。
- 3.3 再补经历提示:组件测试——点击后卡片隐藏且出现 `continueCollectFallback`
- 3.4 记录:BUG-583tsc 判空,复发自本单 4.2);CHANGELOG 一句;PROGRESS 贴 tsc 原文。
## 4. 让步顺序
3.1 必须当天合入(否则 P0 修复无法部署);3.2、3.3 其次;3.4 不可省。
## 5. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-range-delivery-fix-20260907 .worktrees/rectification-range-delivery-fix-20260907 origin/staging
cd .worktrees/rectification-range-delivery-fix-20260907
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; echo "tsc exit $?"
```
@@ -70,3 +70,22 @@ ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
ln -s /workspace/Jyotisha/.venv .venv
cd frontend && ls tests/rectification-*.test.ts tests/consultation-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```
## 验收(Claude2026-09-07`origin/staging` @ `ab57d03f`;与区间交付卡同一提交)
| 门 | 结果 |
| --- | --- |
| tsc | **1 错**(在同提交的区间交付卡改动里:`rectification-agentic-chat.tsx` L1655 先访问 `candidateResult.resultId` 再判空,`'candidateResult' is possibly 'null'`)。执行方进度记录写 tsc 0,与实测不符——应是最后一次改动后没有复跑 |
| lint | 0 error / 103 warning(比上一轮多 19 条,分散在测试文件与 `rectification-v9-tools.ts`,未清) |
| 前端 rectification + consultation + birth-time + skill + voice(非 DB | 1605 / 0 |
| Python v5_services / input_contract / growth contract | 全绿 |
| 引擎 `minute_step=1` 字节级不变 | 全等 |
| 项 | 结论 |
| --- | --- |
| 4.1 BUG-581 | 通过。`scoreAndPersistCurrentEvidence` 抽到 `v9/score-persist.ts`,停止/闲置 `keepAnswers=true``previous` 重放;时段/放宽 `keepAnswers=false` 仍写出空答案的 `inference_state`;VedAstro 校验与对比包逻辑随之搬迁 |
| 4.2 BUG-582 | 通过。无框区分题一律 `skipped`;工具层与 `spokenFollowupForUser` 的 GENERIC 兜底删除;`dropped_probes(frameless_distinguish)` 记录 |
| 4.3 顺序 | 通过。holdout 排在带年月采集与职业之后 |
| 4.4 记录 | 通过 |
结论:本单逻辑通过,但同一提交因区间交付卡的 tsc 错误**整体不可部署**,见 `TASK-rectification-range-delivery-card-20260907.md` 验收段。
@@ -88,5 +88,41 @@
- 若有分歧面板,每列只有 1~3 行,且都是没问过的 D9 / D10 / 月宿层
- 不见「相对支持度」「当前推荐」「采用此时间」
- 点「按这个范围用」成功,设置页出生时间状态变为已采用
- 点「再补一件经历」卡片收起,服务端范围不变
- 点「再补一件经历」卡片收起,输入框上方出现「第 1 步·收集经历」和「请继续说下一件…」,服务端范围不变
## 6. 七领域问完后下一问必须是职业题或直接出区间卡
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后依次说或拒答七个带年月领域(学业、感情、事业、家人、财务、迁居、健康),不要主动说职业。
期望:
- 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡
- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」
- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮
## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说一件带年月的虚构经历,等到出现下一问。
期望:
- 助手气泡正文只有一段旁白,没有第二段换个说法再讲一遍
- 题干只出现在同一条消息的问题块里一次
- 正文不以问号结尾,也不把下一题再问一遍
## 8. 答完点选题再补经历,范围不得无故变宽
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说到能出对照卡,答完几张分盘风格或大运边界卡,等到旁白报出已经收到大约 8 分钟。然后再补一件带年月的虚构经历(财务或迁居均可)。
期望:
- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
+1 -1
View File
@@ -252,7 +252,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card (window, representative minute, stable/sensitive themes, boundary sentence) plus an optional unasked D9/D10/nakshatra panel. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels the representative minute as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year and keeps skip and pause as secondary 44px actions. Spoken collect (`collect_spoken`) places the same `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) as a 44px secondary button above `ChatComposer`. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The composer has no `rectification-step-state` status sentence. After “再补一件经历” dismisses the range card, `rectification-composer-meta` shows the collect-step headline plus `continueCollectFallback`.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
- **Scoring and retry:** `score_pending` is a quiet progress surface with cancellable bounded polling and no manual compare control. `retry_scoring` preserves the confirmed evidence and exposes one explicit retry action. Refresh and device changes resume from the persisted action rather than inferring progress from copy.
- **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute.
+2 -1
View File
@@ -33,7 +33,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 |
| 相对支持度 16 · 采用此时间 | 按这个范围用 / 再补一件经历 | 交付对象是区间,不是四张分钟卡上的支持度数字。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 下一次事业变动的预测窗口留在采用后的核对阶段。 | 交付轮不并排三行预测。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 下一次事业变动的预测窗口留在采用后的核对阶段。 | 交付轮不并排三行预测。采用后首轮核对旁白末尾才接上实际窗口句。 |
| 「再补一件经历」后卡片消失、输入框上方空白 | 第 1 步·收集经历。请继续说下一件你记得比较清楚、大概带年份的经历。 | 收起交付卡后要告诉人下一步做什么。 |
| 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 |
| (采用后只念代表分钟)代表分钟是 05:12。 | 这 27 分钟里,事业方向、性格底色的判断是稳定的;婚恋(D9)会随分钟变,看盘时按范围读。这不是已确认的唯一出生分钟。 | 采用后先讲区间里什么稳、什么随分钟变;两句来自服务端,不是模型编的。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
+10
View File
@@ -3091,6 +3091,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-composer-meta .rectification-collect-stop {
margin: 0;
}
.rectification-continue-hint {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-continue-hint strong {
color: var(--color-ink);
font-weight: 600;
}
.rectification-candidates-heading { display: grid; gap: var(--space-1); }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -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";
@@ -39,7 +39,7 @@ import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { RangeDeliveryContinueHint, RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
@@ -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) => (
@@ -1506,6 +1521,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
const rangeCardDismissed = Boolean(
candidateResult
&& dismissedRangeResultId === candidateResult.resultId
&& !candidateResult.selectedTime
&& savedStatus !== "accepted"
&& savedStatus !== "confirmed"
&& !readonly
);
const spokenCollectOpen = currentQuestion?.kind === "collect_spoken" && !readonly && !busy;
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1652,7 +1676,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRegenerate={() => void regenerateMessage(message)}
/>
)}
{showSelectionCards && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && candidateResult && (
{showSelectionCards && candidateResult && dismissedRangeResultId !== candidateResult.resultId && message.renderKey === selectionCardMessageKey && (
<RectificationRangeDelivery
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
@@ -1724,8 +1748,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<span className="rectification-adopt-status__hint"></span>
</div>
)}
{currentQuestion?.kind === "collect_spoken" && !readonly && !busy && (
{(rangeCardDismissed || spokenCollectOpen) && (
<div className="rectification-composer-meta">
<RangeDeliveryContinueHint visible={rangeCardDismissed && !busy} />
{spokenCollectOpen && (
<button
type="button"
className="rectification-collect-stop"
@@ -1733,6 +1759,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
>
{CHOICE_STOP_LABEL}
</button>
)}
</div>
)}
<ChatComposer
@@ -9,6 +9,7 @@ import {
rangeDeliverySensitiveCopy,
rangeDeliveryStableCopy,
} from "@/lib/rectification-agentic/user-copy";
import { STEP_STATE_COPY } from "@/lib/rectification-agentic/v9/step-state";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
function adoptCandidateId(
@@ -25,6 +26,17 @@ function adoptCandidateId(
?? null;
}
export function RangeDeliveryContinueHint({ visible }: Readonly<{ visible: boolean }>) {
if (!visible) return null;
return (
<p className="rectification-continue-hint" role="status">
<strong>{STEP_STATE_COPY.collect.headline}</strong>
{" "}
{RECTIFICATION_USER_COPY.continueCollectFallback}
</p>
);
}
export function RectificationRangeDelivery({
result,
acceptingCandidateId,
@@ -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,
@@ -92,7 +93,7 @@ import {
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { refinementFromDecisionReceipt } from "./refinement-packet";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
const EXHAUSTION_DELIVERY_ACTIONS = new Set([
@@ -143,6 +144,18 @@ function withDeferredCareerWindow(base: string): string {
return `${base} ${extra}`.replace(/\s+/g, " ").trim();
}
export function appendPostAdoptProspectiveWindows(
narration: string,
receipt: Readonly<Record<string, unknown>> | null | undefined,
): string {
const extra = prospectiveWindowsNarration(
refinementFromDecisionReceipt(receipt).prospective_probes,
);
if (!extra) return narration;
if (narration.includes(extra)) return narration;
return `${narration} ${extra}`.replace(/\s+/g, " ").trim();
}
async function withRangeReadingNarration(
base: string,
input: {
@@ -728,6 +741,7 @@ export async function persistNextInterviewAfterChoice(input: {
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
const latest = input.dossier.latestResult
? {
@@ -798,6 +812,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
persisted: false,
followup: null,
terminalNote: true,
};
}
if (input.dossier.case.acceptedTime && !followup) {
@@ -1091,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;
@@ -1105,26 +1129,34 @@ export async function persistNextInterviewIfIdle(input: {
caseId: input.caseId,
askedTurnId: input.askedTurnId ?? null,
});
let dossier = rescored.dossier;
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
let hostNarration = result.hostNarration;
if (dossier.case.acceptedTime && hostNarration) {
hostNarration = appendPostAdoptProspectiveWindows(
hostNarration,
dossier.latestResult?.decisionReceipt,
);
}
if (
input.userStopped === true
&& rescored.rescoreAttempted
&& !rescored.snapshotCurrent
&& result.hostNarration
&& hostNarration
) {
return {
...result,
hostNarration: withLastSuccessfulCompareNotice(result.hostNarration),
};
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
}
return result;
if (hostNarration === result.hostNarration) return result;
return { ...result, hostNarration };
};
let dossier = rescored.dossier;
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, {
@@ -1212,6 +1244,7 @@ export async function persistNextInterviewIfIdle(input: {
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
terminalNote: true,
});
}
const remainingCollect = exhaustionSpokenCollectFollowup({
@@ -1292,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", () => {
@@ -169,6 +203,7 @@ test("delivery narration defers career windows and the range card replaces minut
assert.doesNotMatch(choice, /withProspectiveWindows/);
assert.doesNotMatch(choice, /候选 A 预测/);
assert.match(choice, /deferredCareerWindow/);
assert.match(choice, /appendPostAdoptProspectiveWindows/);
assert.match(chat, /<RectificationRangeDelivery/);
assert.doesNotMatch(chat, /相对支持度/);
assert.doesNotMatch(card, /相对支持度|概率|置信度/);
@@ -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\(/);
});
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
@@ -10,8 +12,11 @@ import {
d9DivergenceLabel,
} from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { D10_TYPE_TABLE, D9_TYPE_TABLE } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts";
import { REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_USER_COPY, REPRESENTATIVE_MINUTE_DISCLAIMER } from "../src/lib/rectification-agentic/user-copy.ts";
import type { NakshatraBoundary } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { appendPostAdoptProspectiveWindows } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RangeDeliveryContinueHint } from "../src/components/rectification-range-delivery.tsx";
import { STEP_STATE_COPY } from "../src/lib/rectification-agentic/v9/step-state.ts";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
@@ -288,3 +293,53 @@ test("the range-delivery component does not render relative support", () => {
assert.match(source, /rangeDeliveryAdopt/);
assert.match(source, /rangeDeliveryContinue/);
});
test("chat null-checks candidateResult before reading dismissedRangeResultId", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const start = chat.indexOf("showSelectionCards && candidateResult");
assert.ok(start >= 0);
const slice = chat.slice(start, start + 280);
assert.match(slice, /candidateResult && dismissedRangeResultId !== candidateResult\.resultId/);
assert.ok(slice.indexOf("candidateResult &&") < slice.indexOf("dismissedRangeResultId"));
});
const CAREER_WINDOW_PROBE = {
candidate_label: "A",
domain: "career",
window_label: "2027 年 3 月附近",
user_meaning: "候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。",
used_for_scoring: false,
};
test("post-adopt first-round narration appends prospective windows when present", () => {
const base = "2013 年前后,钱上有没有明显变化?";
const withWindows = appendPostAdoptProspectiveWindows(base, {
prospective_probes: [CAREER_WINDOW_PROBE],
});
assert.match(withWindows, /预测下一次事业变动/);
assert.match(withWindows, /2013 年前后/);
const without = appendPostAdoptProspectiveWindows(base, { prospective_probes: [] });
assert.equal(without, base);
assert.doesNotMatch(without, /预测下一次事业变动/);
const idle = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const start = idle.indexOf("export async function persistNextInterviewIfIdle");
const slice = idle.slice(start, start + 1800);
assert.match(slice, /appendPostAdoptProspectiveWindows/);
assert.match(slice, /dossier\.case\.acceptedTime/);
});
test("dismissing the range card hides it and shows the collect-continue hint", () => {
const hidden = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: false }));
assert.equal(hidden, "");
const shown = renderToStaticMarkup(createElement(RangeDeliveryContinueHint, { visible: true }));
assert.ok(shown.includes(STEP_STATE_COPY.collect.headline));
assert.ok(shown.includes(RECTIFICATION_USER_COPY.continueCollectFallback));
assert.doesNotMatch(shown, /rectification-step-state/);
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /RangeDeliveryContinueHint/);
assert.match(chat, /setDismissedRangeResultId\(candidateResult\.resultId\)/);
assert.match(chat, /rangeCardDismissed/);
});
@@ -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);
});