Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df182c16d6 | |||
| fec316b3e3 | |||
| 9d1c08cab1 | |||
| 9aec502961 | |||
| 6c7a8b0262 | |||
| 3973b6c3dc |
@@ -1,5 +1,17 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-08 — 补经历后仍按已答对照题收范围,变宽会说出来
|
||||
|
||||
生时校正答完对照卡后再补一件经历,会把已经答过的题按分钟重算进去,不会因为候选分钟换了两个就把范围弹回开工窗口。如果这次比较让范围变了,旁白末尾会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.15。
|
||||
|
||||
## 2026-09-08 — 生时校正每一轮只说一段旁白,题目只出现在问题块
|
||||
|
||||
生时校正记下你刚说的事后,助手气泡里只留一段说明。下一问只出现在下面的问题块,不会在正文里再问一遍。Skill 版本仍是 10.0.15。
|
||||
|
||||
## 2026-09-08 — 七个领域问完后是职业题或区间卡,不再补问「再说一件」
|
||||
|
||||
生时校正把学业、感情、事业、家人、财务、迁居、健康问过或跳过后,下一问是「你平时主要做什么工作?」,或者直接出区间交付卡。不会再出现「也可以再说一件你记得大概时间的事」。Skill 版本仍是 10.0.15。
|
||||
|
||||
## 2026-09-07 — 区间卡能编译,采用后才说预测窗口,再补经历有下一句提示
|
||||
|
||||
生时校正交付卡不再把编译挡在门外。点采用后,第一轮核对旁白末尾会接上实际的下次事业变动窗口(有才写)。点「再补一件经历」后卡片收起,输入框上方改成收集经历的提示。Skill 版本仍是 10.0.15。
|
||||
|
||||
+97
-3
@@ -7517,7 +7517,7 @@
|
||||
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
|
||||
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`)。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`(render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`;ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调。
|
||||
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
|
||||
- 相关记录:BUG-471、BUG-485、BUG-487
|
||||
- 相关记录:BUG-471、BUG-485、BUG-487、BUG-585
|
||||
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -8241,7 +8241,7 @@
|
||||
- 修复:`rectification-set-focus` 的 call/result 不再收回已播出正文。读诊断、记证据仍收回。
|
||||
- 验证:`rectification-step-answer`:打招呼 + set-focus 为 `none`,其后短句仍 `live`;read-diagnostics 仍 `retract`。
|
||||
- 防复发:set-focus 不得把同轮已播出的正文 replace 成空。不得把这个例外扩到 record/compare/diagnostics。
|
||||
- 相关记录:无
|
||||
- 相关记录:BUG-584
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -8641,7 +8641,7 @@
|
||||
- 修复:穷尽采集不再问 `other`。还有 dated / occupation 就继续问;否则 `acceptance_allowed` 且训练门开且可采用则 `adopt_representative`(`stopReason=probe_pool_exhausted`),否则写门槛翻译句、不写焦点,并用 `terminalNote` 满足非终止出口。口述采集在输入框上方放与点选卡同文案的停止按钮;只读范围改为同一停止动作。`ask_about_result` 与 `stop_rectification` 在点选和口述焦点都映射到停止。`keep_collecting` 仍继续采集,不得劫持成出牌。
|
||||
- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`;既有 adopt / spoken-collect / timeline / hidden-e2e 三栏更新。
|
||||
- 防复发:`exhaustionSpokenCollectFollowup` 不得再返回 `other`。穷尽日志必须有 `rectification_exhaustion_collect` 且不含证据摘要或年份。不得把 `keep_collecting` 改成强制采用。
|
||||
- 相关记录:BUG-550、BUG-546
|
||||
- 相关记录:BUG-550、BUG-546、BUG-586
|
||||
- 复发自:无
|
||||
- 修复版本:`150d7ef1`
|
||||
|
||||
@@ -9046,5 +9046,99 @@
|
||||
- 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过)
|
||||
- 修复版本:待发布(`codex/rectification-range-delivery-fix-20260907`)
|
||||
|
||||
## BUG-584 | 同一轮采集旁白说两遍
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-07
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`applyStepAnswerChunk`、`KEEP_LIVE_SPOKEN_TOOLS`、`publishSpokenStep`、`POST /api/rectification/agent`
|
||||
- 用户现象:每一轮采集助手气泡里有两段近似旁白。第一段已经承接用户刚说的事,第二段换个说法再讲一遍。
|
||||
- 触发条件:模型在 `rectification-set-focus` 前写出正文,拿到工具结果后再写一段。
|
||||
- 根因:BUG-533 让 set-focus 不撤回已直播正文。set-focus 之后下一个 step 的正文仍按 `shouldPublishStepText` 发布;`publishSpokenStep` 把两段无分隔拼进 `assistant_message`。
|
||||
- 修复:set-focus 结果后置 `stemAttached`。本轮已经发布过正文则丢掉后一段;尚未发布则仍放行开场问候。set-focus 调用时把未直播的中文半句 `publish` 出去,英文规划稿仍 discard。
|
||||
- 验证:`frontend/tests/rectification-step-answer.test.ts`;`frontend/tests/rectification-v9-agent.test.ts` 文本→set-focus→文本只保留第一段。
|
||||
- 防复发:set-focus 之后不得再拼接第二段正文。开场仍允许「先问候、后 set-focus」或「只在 set-focus 后说一句」。不得把这个例外扩到 record/compare/diagnostics。
|
||||
- 相关记录:BUG-533
|
||||
- 复发自:无(533 的放行例外把第二段也留下了)
|
||||
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`)
|
||||
|
||||
## BUG-585 | 题干在正文和问题块各出现一次
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-07
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`stripQuestionSentences`、`attachQuestionsToTurns`、`runV9AgentTurn`、`RECTIFICATION_USER_COPY.collectHandoff`
|
||||
- 用户现象:旁白末尾已经用自然语言问了下一题,问题块再显示同一句服务端题干。
|
||||
- 触发条件:本轮 set-focus 成功,模型把问句写进正文;`detachCollectSpokenAssistantText` 只剥逐字后缀。
|
||||
- 根因:不让模型提问的约束只在提示词。BUG-488 防复发写「不得同时让模型提问又拼接同一句服务端题干」,`d9404976` 把题干改成消息内问题块后没有代码守卫。`collectSpokenEmitted` 是从未使用的常量。
|
||||
- 修复:有本轮 focus 时,`answer.composed` 前按句删掉与题干相同、题干前 12 字开头、或以问号结尾的句子;剪空则用 `collectHandoff`。变化时 `answer.delta replace:true`。历史回看同样走 `stripQuestionSentences`。删除 `collectSpokenEmitted`。
|
||||
- 验证:`frontend/tests/rectification-collect-prompt.test.ts`;`frontend/tests/rectification-v9-agent.test.ts`;`frontend/tests/agent-voice-copy-contract.test.ts`。
|
||||
- 防复发:有 focus 的已结算助手正文不得再出现以问号结尾的句子。不得靠提示词单独保证模型不问。不得恢复把题干拼进 `assistant_message`。
|
||||
- 相关记录:BUG-488、BUG-461、BUG-471、`d9404976`
|
||||
- 复发自:BUG-488(防线只在提示词)
|
||||
- 修复版本:待发布(`codex/rectification-duplicate-narration-20260907`)
|
||||
|
||||
## BUG-586 | 七领域问完落到补问兜底句,职业题被覆盖、区间卡出不来
|
||||
|
||||
- 状态:mitigated
|
||||
- 首次发现:2026-09-07
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`USER_COLLECT_QUESTION`、`spokenFollowupForUser`、active-focus 承接 followup、`rectification-set-focus` 兜底、`persistNextInterviewIfIdle`
|
||||
- 用户现象:学业、感情、事业、家人、财务、迁居、健康都问过或拒过后,助手不问「你平时主要做什么工作?」,也不出区间交付卡,只剩一句「也可以再说一件你记得大概时间的事」。
|
||||
- 触发条件:七个带年月领域已覆盖或拒答,职业尚未问完或已有 `collect:other:*` 焦点挂着;同轮可能两次 set-focus。
|
||||
- 根因:三条活路已由代码确认。职业焦点落库时 `persistableFocusDomain("occupation")` 仍写 `other`(表约束没有 occupation);承接 followup 用 `focus.targetDomain` 重建成 `domain=other`,稳定 id 变成 `collect:other:*` 并 supersede 职业题。set-focus 两次 `invalid_spoken_prompt` 后查 `USER_COLLECT_QUESTION[domain]`,`domain=other` 把那句兜底写进焦点。`collectQuestionDomain` 把空领域归为 other。挂着的 `collect:other:*` 让 `persistNextInterviewIfIdle` 见 active focus 提前返回,出卡路径跑不到。同轮第二次 set-focus 覆盖职业题的具体 receipt 链(任务书 2.2)未从事故 Case 证实,不得写成已闭环。
|
||||
- 修复:删除 `USER_COLLECT_QUESTION.other` 与 retry。非开场 `domain=other` 的口述题返回 `null`。承接 followup 从 `collect:<domain>:` 解析领域。set-focus 兜底只允许表内真实领域,否则不落地。同轮已成功的 set-focus 第二次返回 `idempotent: true`。账本已有带年月事件时,闲置路径把存量 `collect:other:*` 标 `skipped`。采用跳过持久化时写 `terminalNote`,让 agent-run 能写 exhaustion 闸门轮。
|
||||
- 验证:`frontend/tests/rectification-other-collect-fallback-20260908.test.ts`;`frontend/tests/rectification-exhaustion-exit-20260906.test.ts` 覆盖事故形状出卡;`frontend` `tsc --noEmit` 0;指定 TS 切片 983/0;`grep -rn "也可以再说一件" src tests` 为空。未拿事故 Case receipt,2.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
|
||||
- 本单改动文件 eslint:0 error(tools / 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:50–04:57 变为 04:47–05:15。` | 决策 4 |
|
||||
| `showSelectionCards` | 不看 `busy` / 快照 | `!busy && caseSnapshotLoaded` | 决策 5,P3 未复现 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0
|
||||
- 本单改动文件 eslint:0 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。
|
||||
@@ -72,6 +72,9 @@
|
||||
| `TASK-rectification-stop-rescore-fix-20260907.md` | `PROGRESS-rectification-stop-rescore-fix-20260907.md` | **P0**:BUG-579 的停止路径用「裸重算」(`rescoreMinuteAfterWindowChange` 不重建 `inference_state`、不重放已答探针、不写 transition),停止后范围变宽、卡片变成引擎裸支持度、采用 RPC 报 `candidate_state_inconsistent`;无框区分题经 `spokenCollectFallbackFollowup` 变成 domain=other 的采集题,再次冒出开场句 `GENERIC_COLLECT_QUESTION`;holdout 采集排在带年月采集之前 | 已验收通过(逻辑);但同提交含区间卡 tsc 错,整体不可部署 | `ab57d03f`(BUG-581~582) |
|
||||
| `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-584~585) |
|
||||
| `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-587~588;P3 闪现仍 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-505~509) |
|
||||
|
||||
|
||||
@@ -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-584(P1):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` L1655(BUG-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` 兜底 L1085–1100)、`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` L2016–2025,`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` L201–206、L52–53;`method-followup.ts` L2016 |
|
||||
| B | **set-focus 工具的兜底**:模型 `spokenPrompt` 两次 `invalid_spoken_prompt` 后,`USER_COLLECT_QUESTION[nextFollowup.domain]` 直接落库;`nextFollowup.domain="other"` 时就是这句 | `rectification-v9-tools.ts` L1085–1100 |
|
||||
| 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 兜底只允许表内真实领域。** L1085–1100:`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:三条活路 + 出卡
|
||||
- 决策 1–5 全部落地。
|
||||
- 验收(形状用例,`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` L54–80)、`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 staging,Case 导出;只写结构)
|
||||
|
||||
| 时点 | 事实 |
|
||||
| --- | --- |
|
||||
| 事业事件写入后 | 引擎候选集 9 分钟(含 04:57、05:13),连问 5 张卡(D9 风格、D10 风格、事业 2023 前后、事业 2024 前后、迁居 2015 前后),旁白逐轮报"范围从 05:00–05:13 收到 … 04:50–04: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:47–05:15`(29 分钟) |
|
||||
| 交付卡 | "这次给出的范围 04:47–05:15,排盘用 05:00"。用户:"这里给的时间范围有点长了吧" |
|
||||
| 期间旁白 | 三轮证据旁白只说"记下了",从未说范围已从 8 分钟回到 29 分钟 |
|
||||
|
||||
## 2. 根因
|
||||
|
||||
### 2.1 BUG-587(P0):候选集一变,旧答案就没有可重放的题目定义
|
||||
|
||||
`core/build-state.ts::buildInferenceState`:
|
||||
|
||||
1. L54–55 `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-588(P1):证据轮不报范围变化
|
||||
|
||||
点选题轮次由服务端写"范围从 X 收到 Y"(`answer-choice`),证据轮的旁白是模型写的,服务端不补范围句。范围从 8 分钟回到 29 分钟这件事,用户在三轮里没有任何机会知道。
|
||||
|
||||
### 2.3 P3(未证实):交付/选择卡闪现
|
||||
|
||||
用户报告:最后一题的选择卡出现前一瞬,"时间选择的卡片"弹出又消失。`showSelectionCards`(`rectification-agentic-chat.tsx` L1401–1408)没有 `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:50–04:57 变为 04:47–05: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(决策 1–3)
|
||||
- 验收(`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 一轮证据 turn,compare 前范围 04:50–04:57、后 04:47–05:15 → 落库 `assistant_message` 末尾含范围句;范围不变 → 不含;`agent-voice-copy-contract` 收录新句。
|
||||
|
||||
### 4.3 P3(决策 5)
|
||||
- 验收:`rectification-agentic-entry` 源码锁 `showSelectionCards` 含 `!busy`;无需复现闪现。
|
||||
|
||||
### 4.4 记录
|
||||
- `docs/BUG_HISTORY.md`:BUG-587(关联 BUG-559 —— 其"去重"把已答题从生成侧删掉,副作用是重放丢定义;关联 BUG-581 的 `keepAnswers` 路径:那次修的是停止路径,这次是证据路径同一根因的另一半)、BUG-588;P3 写成 `investigating`。`CHANGELOG.md`;`PROGRESS-rectification-probe-replay-loss-20260908.md`;`docs/testing/rectification-scenarios-20260907.md` 加一条"答完点选题再补经历,范围不得变宽超过新增分钟数;证据轮旁白必须报范围变化"。
|
||||
|
||||
## 5. 让步顺序
|
||||
|
||||
4.1 (a)(b) 当天必做并部署;(c) 可后置为 P2(携带题已能按旧 key 命中);4.2 必做;4.3 顺手;4.4 不可省。
|
||||
|
||||
## 6. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-probe-replay-loss-20260908 .worktrees/rectification-probe-replay-loss-20260908 origin/staging
|
||||
cd .worktrees/rectification-probe-replay-loss-20260908
|
||||
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
|
||||
ln -s /workspace/Jyotisha/.venv .venv
|
||||
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
|
||||
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
|
||||
cd .. && .venv/bin/python -m pytest tests/test_rectification_v5_services.py -q
|
||||
```
|
||||
|
||||
与 BUG-584/585(`step-answer.ts` / `agent-run.ts` 剪问句)在 `agent-run.ts` 有交叠:范围句接在剪问句之后,**先合 584/585 再做本单**,或本单 rebase 到其上。与 BUG-586 无重叠。
|
||||
@@ -90,3 +90,39 @@
|
||||
- 点「按这个范围用」成功,设置页出生时间状态变为已采用
|
||||
- 点「再补一件经历」卡片收起,输入框上方出现「第 1 步·收集经历」和「请继续说下一件…」,服务端范围不变
|
||||
|
||||
## 6. 七领域问完后下一问必须是职业题或直接出区间卡
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
|
||||
|
||||
开场后依次说或拒答七个带年月领域(学业、感情、事业、家人、财务、迁居、健康),不要主动说职业。
|
||||
|
||||
期望:
|
||||
|
||||
- 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡
|
||||
- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」
|
||||
- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮
|
||||
|
||||
## 7. 每轮助手气泡只有一段旁白,题干只在问题块出现一次
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
|
||||
|
||||
开场后说一件带年月的虚构经历,等到出现下一问。
|
||||
|
||||
期望:
|
||||
|
||||
- 助手气泡正文只有一段旁白,没有第二段换个说法再讲一遍
|
||||
- 题干只出现在同一条消息的问题块里一次
|
||||
- 正文不以问号结尾,也不把下一题再问一遍
|
||||
|
||||
## 8. 答完点选题再补经历,范围不得无故变宽
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
|
||||
|
||||
开场后说到能出对照卡,答完几张分盘风格或大运边界卡,等到旁白报出已经收到大约 8 分钟。然后再补一件带年月的虚构经历(财务或迁居均可)。
|
||||
|
||||
期望:
|
||||
|
||||
- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数
|
||||
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
|
||||
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQueuedMessage } from "@/hooks/use-queued-message";
|
||||
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -635,7 +635,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCaseSnapshot = useCallback(async (): Promise<{
|
||||
const loadCaseSnapshot = useCallback(async (
|
||||
mergeMessages?: (turns: readonly unknown[]) => void,
|
||||
): Promise<{
|
||||
question: CurrentQuestionModel | null;
|
||||
turns: readonly unknown[];
|
||||
} | null | undefined> => {
|
||||
@@ -650,10 +652,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
applyCaseSnapshot(payload);
|
||||
const turns = snapshotTurns(payload);
|
||||
startTransition(() => {
|
||||
applyCaseSnapshot(payload);
|
||||
mergeMessages?.(turns);
|
||||
});
|
||||
return {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
turns: snapshotTurns(payload),
|
||||
turns,
|
||||
};
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
@@ -960,10 +966,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
if (succeeded) {
|
||||
runOutcome = "succeeded";
|
||||
const snapshot = await loadCaseSnapshot();
|
||||
if (snapshot?.turns.length) {
|
||||
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
|
||||
}
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (turns.length) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
}
|
||||
});
|
||||
onMessagesChange?.([
|
||||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||||
{ role: "assistant", text: parsed.text },
|
||||
@@ -1121,15 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: "";
|
||||
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
|
||||
onCompleted?.();
|
||||
const snapshot = await loadCaseSnapshot();
|
||||
const turns = snapshot?.turns ?? [];
|
||||
if (willContinue) {
|
||||
// The follow-up turn continues on the row already in place: no removed
|
||||
// row, no effect hop, and `busy` never drops in between (so the next
|
||||
// card cannot flash before the turn hides it).
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (willContinue) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
return;
|
||||
}
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
@@ -1151,6 +1154,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
if (willContinue) {
|
||||
// The follow-up turn continues on the row already in place: no removed
|
||||
// row, no effect hop, and `busy` never drops in between (so the next
|
||||
// card cannot flash before the turn hides it).
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
onMessagesChange?.([
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
@@ -1400,6 +1413,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult
|
||||
&& caseSnapshotLoaded
|
||||
&& !busy
|
||||
&& selectionCardMessageKey
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime))
|
||||
&& !messages.some((message) => (
|
||||
|
||||
@@ -55,7 +55,13 @@ export function buildInferenceState(input: {
|
||||
const previous = sameSet ? input.previous : null;
|
||||
const events = stickyHoldoutEvents(input.events, previous?.events);
|
||||
const holdoutKeys = holdoutDomainYears(events);
|
||||
const probes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const liveProbes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const probes = [
|
||||
...liveProbes,
|
||||
...carriedAnsweredProbes(input.previous, liveProbes).filter((probe) => (
|
||||
!holdoutKeys.has(`${probe.domain}:${probe.year}`)
|
||||
)),
|
||||
];
|
||||
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
|
||||
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
|
||||
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
|
||||
@@ -73,8 +79,8 @@ export function buildInferenceState(input: {
|
||||
// Engine event scores already include dated evidence. A structured A/yes
|
||||
// choice is not an evidence row, so it must still move the posterior.
|
||||
if (answer.answer_class === "yes" && answer.classified_from === "evidence") continue;
|
||||
const probe = input.probes.find((item) => item.id === answer.probe_id)
|
||||
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
|
||||
const probe = probes.find((item) => item.id === answer.probe_id)
|
||||
?? probes.find((item) => item.semantic_key === answer.semantic_key)
|
||||
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
|
||||
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
|
||||
if (!probe) continue;
|
||||
@@ -239,7 +245,9 @@ export function replayInferenceState(
|
||||
|
||||
export function nextProbe(state: InferenceState): ConflictProbe | null {
|
||||
const holdoutKeys = holdoutDomainYears(state.events);
|
||||
const probes = state.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const probes = state.probes.filter((probe) => (
|
||||
!probe.carried && !holdoutKeys.has(`${probe.domain}:${probe.year}`)
|
||||
));
|
||||
return selectHighestGainProbe(probes, state.answered_probes);
|
||||
}
|
||||
|
||||
@@ -328,6 +336,24 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
|
||||
});
|
||||
}
|
||||
|
||||
export function carriedAnsweredProbes(
|
||||
previous: InferenceState | null | undefined,
|
||||
current: readonly ConflictProbe[],
|
||||
): ConflictProbe[] {
|
||||
if (!previous) return [];
|
||||
const present = new Set(current.flatMap((probe) => [probe.id, probe.semantic_key]));
|
||||
return previous.probes.flatMap((probe) => {
|
||||
const answered = previous.answered_probes.some((item) => (
|
||||
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
|
||||
));
|
||||
if (!answered) return [];
|
||||
if (present.has(probe.id) || present.has(probe.semantic_key)) return [];
|
||||
present.add(probe.id);
|
||||
present.add(probe.semantic_key);
|
||||
return [{ ...probe, carried: true }];
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
|
||||
const rows = [...previous];
|
||||
for (const item of incoming) {
|
||||
|
||||
@@ -792,6 +792,21 @@ export function isStructuredDiscriminator(probe: Pick<CandidateDiscriminatorProb
|
||||
|| probe.semanticKey.startsWith("varga.");
|
||||
}
|
||||
|
||||
const CLOCK_TOKEN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function vargaSignPartitionKey(
|
||||
layer: string,
|
||||
signs: readonly (string | null | undefined)[],
|
||||
): string {
|
||||
const parts = signs.flatMap((item) => {
|
||||
const raw = typeof item === "string" ? item.trim() : "";
|
||||
if (!raw || CLOCK_TOKEN.test(raw)) return [];
|
||||
return [signKey(raw)];
|
||||
});
|
||||
if (parts.length >= 2) return `varga.${layer}.${parts.join("|")}`;
|
||||
return `varga.${layer}.unsigned`;
|
||||
}
|
||||
|
||||
function vargaProbes(
|
||||
remainingSplits: readonly RemainingVargaSplit[],
|
||||
candidateSetVersion: string,
|
||||
@@ -825,7 +840,7 @@ function vargaProbeFromRemaining(
|
||||
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
|
||||
const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds]));
|
||||
if (outcomes.length < 2 || ids.size < 2) return null;
|
||||
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
|
||||
const semanticKey = vargaSignPartitionKey(split.layer, split.signs);
|
||||
const layerLabel = split.layer.toUpperCase();
|
||||
const domain = remainingDomain(split.layer);
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,8 @@ function isConflictProbe(value: unknown): boolean {
|
||||
&& isFiniteNumber(value.information_gain)
|
||||
&& typeof value.source === "string" && value.source.length > 0
|
||||
&& (value.choice_kind === undefined
|
||||
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)));
|
||||
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)))
|
||||
&& (value.carried === undefined || value.carried === true || value.carried === false);
|
||||
}
|
||||
|
||||
function isProbeAnswer(value: unknown): boolean {
|
||||
|
||||
@@ -91,6 +91,8 @@ export type ConflictProbe = Readonly<{
|
||||
answer_class: AnswerClass;
|
||||
sign?: string;
|
||||
}>[];
|
||||
/** Kept only so an answered probe can be replayed after the candidate set changes. */
|
||||
carried?: boolean;
|
||||
}>;
|
||||
|
||||
export type ProbeAnswer = Readonly<{
|
||||
|
||||
@@ -51,7 +51,6 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
|
||||
relocation: "有没有哪年搬家,或开始长期住在外地?",
|
||||
finance: "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?",
|
||||
health_pressure: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
|
||||
other: "也可以再说一件你记得大概时间的事。",
|
||||
};
|
||||
|
||||
/** Second phrasing when that domain's collect focus was already established and not declined. */
|
||||
@@ -64,7 +63,6 @@ export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
|
||||
relocation: "搬家或开始长期住外地,大概是哪年?",
|
||||
finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?",
|
||||
health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
|
||||
other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。",
|
||||
};
|
||||
|
||||
export const RECTIFICATION_USER_COPY = {
|
||||
@@ -76,6 +74,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
|
||||
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
hostNarrationFallback: "我按现有材料继续往下收。",
|
||||
collectHandoff: "接下来我们继续。",
|
||||
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
|
||||
collectDeclinedAck: "记下了,这方面先跳过。",
|
||||
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
|
||||
@@ -190,6 +189,30 @@ export function formatClockRange(range: readonly [string, string] | null | undef
|
||||
return range[0] === range[1] ? range[0] : `${range[0]}–${range[1]}`;
|
||||
}
|
||||
|
||||
export function rangeChangedAfterEvidence(
|
||||
from: readonly [string, string] | null | undefined,
|
||||
to: readonly [string, string] | null | undefined,
|
||||
): string | null {
|
||||
const fromText = formatClockRange(from);
|
||||
const toText = formatClockRange(to);
|
||||
if (!fromText || !toText || fromText === toText) return null;
|
||||
return `范围从 ${fromText} 变为 ${toText}。`;
|
||||
}
|
||||
|
||||
export function withRangeChangedAfterEvidence(
|
||||
body: string,
|
||||
from: readonly [string, string] | null | undefined,
|
||||
to: readonly [string, string] | null | undefined,
|
||||
): string {
|
||||
const notice = rangeChangedAfterEvidence(from, to);
|
||||
if (!notice) return body;
|
||||
const spoken = body.trim();
|
||||
if (!spoken) return notice;
|
||||
if (spoken.includes(notice)) return spoken;
|
||||
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
|
||||
return `${prefix}${notice}`;
|
||||
}
|
||||
|
||||
export function openingRangeFromCandidateRange(
|
||||
range: { start_time?: string | null; end_time?: string | null } | null | undefined,
|
||||
): readonly [string, string] | null {
|
||||
@@ -317,6 +340,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.questionUpdated,
|
||||
RECTIFICATION_USER_COPY.adoptCue,
|
||||
RECTIFICATION_USER_COPY.hostNarrationFallback,
|
||||
RECTIFICATION_USER_COPY.collectHandoff,
|
||||
RECTIFICATION_USER_COPY.continueCollectFallback,
|
||||
RECTIFICATION_USER_COPY.collectDeclinedAck,
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
@@ -341,6 +365,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.divergenceTitle,
|
||||
RECTIFICATION_USER_COPY.divergenceUnlike,
|
||||
RECTIFICATION_USER_COPY.divergenceUnsure,
|
||||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
|
||||
rangeDeliveryEventCopy(3),
|
||||
rangeDeliveryStableCopy(["事业方向"]),
|
||||
rangeDeliverySensitiveCopy(["婚恋(D9)"]),
|
||||
|
||||
@@ -34,7 +34,10 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import { withCompareFailedRetryNotice } from "../user-copy";
|
||||
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
||||
import {
|
||||
resolveExactSkillPackage,
|
||||
type ResolvedSkillPackageIdentity,
|
||||
@@ -110,7 +113,6 @@ export type V9AgentRunResult = Readonly<{
|
||||
toolsUsed: readonly string[];
|
||||
errorCode: string | null;
|
||||
previousFocusId: string | null;
|
||||
collectSpokenEmitted: boolean;
|
||||
}>;
|
||||
|
||||
type AttemptStatus = "completed" | "failed" | "retryable";
|
||||
@@ -281,7 +283,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
}
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
const collectSpokenEmitted = false;
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
@@ -378,7 +379,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: [],
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
if (existingStatus !== "pending") await billing.release();
|
||||
@@ -489,7 +489,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: outcome.errorCode,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -524,7 +523,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: [],
|
||||
errorCode: "usage_settlement_failed",
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -592,7 +590,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
toolsUsed: outcome.toolsUsed,
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
collectSpokenEmitted,
|
||||
};
|
||||
|
||||
async function streamAttempt(attemptNumber: number, attemptId: string): Promise<AttemptOutcome> {
|
||||
@@ -641,6 +638,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const emittedActivities = new Set<string>();
|
||||
const repeatedCalls = new Map<string, number>();
|
||||
let phaseSequence = 0;
|
||||
const rangeBeforeCompare = previousInferenceFromReceipt(
|
||||
dossier.latestResult?.decisionReceipt ?? null,
|
||||
)?.credible_range ?? null;
|
||||
|
||||
const recordPhase = async (phase: string, tool: string | null = null) => {
|
||||
if (
|
||||
@@ -885,25 +885,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
|
||||
}
|
||||
|
||||
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const decision = decideFromDossier(latest);
|
||||
if (decision.nextAction !== "ask_candidate_discriminator") return { ok: true };
|
||||
const focus = latest.conversationSummary.activeFocus;
|
||||
if (
|
||||
focus
|
||||
&& isPersistedFocusId(focus.id)
|
||||
&& parseAgentChoiceCopy(focus.expectedAnswerSchema)
|
||||
) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, errorCode: "state_invariant_failed" };
|
||||
} catch {
|
||||
return { ok: false, errorCode: "state_invariant_failed" };
|
||||
}
|
||||
};
|
||||
|
||||
const completeAttempt = async (): Promise<AttemptOutcome> => {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
@@ -967,8 +948,43 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
return failedAttempt(attemptId, mapped);
|
||||
}
|
||||
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
||||
const invariant = await discriminatorInvariant();
|
||||
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
|
||||
let latestDossier: V9CaseDossier;
|
||||
try {
|
||||
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
} catch {
|
||||
return failedAttempt(attemptId, "state_invariant_failed");
|
||||
}
|
||||
const decision = decideFromDossier(latestDossier);
|
||||
if (decision.nextAction === "ask_candidate_discriminator") {
|
||||
const openFocus = latestDossier.conversationSummary.activeFocus;
|
||||
if (!(
|
||||
openFocus
|
||||
&& isPersistedFocusId(openFocus.id)
|
||||
&& parseAgentChoiceCopy(openFocus.expectedAnswerSchema)
|
||||
)) {
|
||||
return failedAttempt(attemptId, "state_invariant_failed");
|
||||
}
|
||||
}
|
||||
const askedFocus = latestDossier.conversationSummary.activeFocus;
|
||||
if (askedFocus?.askedTurnId === turnId) {
|
||||
const stem = focusSpokenPrompt(askedFocus.expectedAnswerSchema);
|
||||
if (stem) {
|
||||
const stripped = stripQuestionSentences(answerText, stem);
|
||||
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
|
||||
if (next !== answerText) answerText = next;
|
||||
}
|
||||
}
|
||||
if (toolsUsed.has("rectification-compare-candidates")) {
|
||||
const rangeAfterCompare = previousInferenceFromReceipt(
|
||||
latestDossier.latestResult?.decisionReceipt ?? null,
|
||||
)?.credible_range ?? null;
|
||||
answerText = withRangeChangedAfterEvidence(
|
||||
answerText,
|
||||
rangeBeforeCompare,
|
||||
rangeAfterCompare,
|
||||
);
|
||||
}
|
||||
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
|
||||
return completeAttempt();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
planWithDateReliability,
|
||||
spokenCollectFallbackFollowup,
|
||||
spokenFollowupForUser,
|
||||
ledgerHasConfirmedDatedEvent,
|
||||
type MethodCoverage,
|
||||
type MethodFollowup,
|
||||
type MethodFollowupPlan,
|
||||
@@ -740,6 +741,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
focusId?: string | null;
|
||||
focus?: ConversationFocus | null;
|
||||
followup?: MethodFollowup | null;
|
||||
terminalNote?: boolean;
|
||||
}> {
|
||||
const latest = input.dossier.latestResult
|
||||
? {
|
||||
@@ -810,6 +812,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
choiceReady: false,
|
||||
persisted: false,
|
||||
followup: null,
|
||||
terminalNote: true,
|
||||
};
|
||||
}
|
||||
if (input.dossier.case.acceptedTime && !followup) {
|
||||
@@ -1103,6 +1106,15 @@ export function isStalePreAdoptFocus(
|
||||
return intent !== "reverse_verify" && intent !== "out_of_sample_check";
|
||||
}
|
||||
|
||||
export function isOrphanOtherCollectFocus(
|
||||
focus: { questionId?: string | null } | null | undefined,
|
||||
evidence: readonly { status: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null }[],
|
||||
): boolean {
|
||||
const questionId = focus?.questionId ?? "";
|
||||
if (!questionId.startsWith("collect:other:")) return false;
|
||||
return ledgerHasConfirmedDatedEvent(evidence);
|
||||
}
|
||||
|
||||
export async function persistNextInterviewIfIdle(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
@@ -1140,8 +1152,11 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
const staleFocus = dossier.conversationSummary.activeFocus;
|
||||
const staleFocusId = staleFocus?.id;
|
||||
if (
|
||||
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|
||||
&& isPersistedFocusId(staleFocusId)
|
||||
isPersistedFocusId(staleFocusId)
|
||||
&& (
|
||||
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|
||||
|| isOrphanOtherCollectFocus(staleFocus, dossier.evidence)
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
@@ -1229,6 +1244,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
credibleRange: decision.credibleRange,
|
||||
representativeTime: decision.representativeTime,
|
||||
}),
|
||||
terminalNote: true,
|
||||
});
|
||||
}
|
||||
const remainingCollect = exhaustionSpokenCollectFollowup({
|
||||
@@ -1309,6 +1325,7 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
|
||||
choiceReady: nextInterview.choiceReady,
|
||||
hostNarration: nextInterview.hostNarration,
|
||||
...(nextInterview.terminalNote ? { terminalNote: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,46 @@
|
||||
* “did this body already ask”.
|
||||
*/
|
||||
|
||||
const SENTENCE_SPLIT = /(?<=[。!??\n])/;
|
||||
const NARRATIVE_SENTENCE = /范围|记下|对照|\d{1,2}:\d{2}/;
|
||||
|
||||
function isQuestionSentence(text: string, stem: string): boolean {
|
||||
if (!text) return false;
|
||||
if (stem && text === stem) return true;
|
||||
const prefix = stem.slice(0, 12);
|
||||
if (prefix && text.startsWith(prefix)) return true;
|
||||
return /[??]$/.test(text);
|
||||
}
|
||||
|
||||
export function stripQuestionSentences(body: string, stem: string): string {
|
||||
const prompt = stem.trim();
|
||||
const spoken = body.trim();
|
||||
if (!spoken) return "";
|
||||
const kept: string[] = [];
|
||||
let dropContinuation = false;
|
||||
for (const part of spoken.split(SENTENCE_SPLIT)) {
|
||||
const text = part.trim();
|
||||
if (!text) continue;
|
||||
if (isQuestionSentence(text, prompt)) {
|
||||
dropContinuation = true;
|
||||
continue;
|
||||
}
|
||||
if (dropContinuation && !NARRATIVE_SENTENCE.test(text)) {
|
||||
dropContinuation = false;
|
||||
continue;
|
||||
}
|
||||
dropContinuation = false;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("").trim();
|
||||
}
|
||||
|
||||
export function composeCollectSpokenAssistantText(body: string, prompt: string): string {
|
||||
const stem = prompt.trim();
|
||||
const spoken = body.trim();
|
||||
if (!stem) return spoken;
|
||||
if (!spoken || spoken === stem) return stem;
|
||||
const prefix = stem.slice(0, 12);
|
||||
const stripped = spoken
|
||||
.split(/(?<=[。!?\n])/)
|
||||
.filter((sentence) => {
|
||||
const text = sentence.trim();
|
||||
if (!text) return false;
|
||||
if (text === stem) return false;
|
||||
return !(prefix && text.startsWith(prefix));
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
const stripped = stripQuestionSentences(spoken, stem);
|
||||
if (!stripped) return stem;
|
||||
const suffix = `\n\n${stem}`;
|
||||
if (stripped.includes(stem)) return stripped;
|
||||
|
||||
@@ -237,7 +237,9 @@ const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
|
||||
"relatives",
|
||||
]);
|
||||
|
||||
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
|
||||
function isConfirmedDated(
|
||||
item: Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">,
|
||||
): boolean {
|
||||
return item.status === "confirmed"
|
||||
&& item.datePrecision !== "unknown"
|
||||
&& Boolean(item.occurredFrom || item.occurredTo);
|
||||
@@ -1207,11 +1209,12 @@ export function spokenFollowupForUser(
|
||||
&& domain === "other"
|
||||
&& followup.collect_retry !== true
|
||||
&& !evidence.some(isConfirmedDated);
|
||||
if (domain === "other") {
|
||||
return openingOther ? GENERIC_COLLECT_QUESTION : null;
|
||||
}
|
||||
const mapped = followup.collect_retry === true
|
||||
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
|
||||
: openingOther
|
||||
? GENERIC_COLLECT_QUESTION
|
||||
: USER_COLLECT_QUESTION[domain];
|
||||
: USER_COLLECT_QUESTION[domain];
|
||||
return mapped ?? null;
|
||||
}
|
||||
|
||||
@@ -1289,25 +1292,6 @@ export function isRemainingEvidenceCollect(
|
||||
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
|
||||
}
|
||||
|
||||
function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup {
|
||||
return {
|
||||
method_id: "dasha_events",
|
||||
intent: "collect_method_evidence",
|
||||
ask_theme: "dated_event",
|
||||
domain: "other",
|
||||
kind_hint: null,
|
||||
user_prompt_hint: collectHint(
|
||||
USER_COLLECT_QUESTION.other,
|
||||
"本命 Dasha + 行运(方法1)",
|
||||
"",
|
||||
evidence,
|
||||
),
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "oos_blind",
|
||||
};
|
||||
}
|
||||
|
||||
export function exhaustionSpokenCollectFollowup(input: {
|
||||
evidence: readonly MethodFollowupEvidence[];
|
||||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||||
@@ -1345,6 +1329,22 @@ export function collectQuestionDomain(domain: string | null | undefined): string
|
||||
return OPENING_COLLECT_DOMAIN;
|
||||
}
|
||||
|
||||
export function parseCollectFocusQuestionId(questionId: string | null | undefined): {
|
||||
domain: string;
|
||||
} | null {
|
||||
if (!questionId) return null;
|
||||
const match = /^collect:([^:]+):/.exec(questionId.trim());
|
||||
const domain = match?.[1]?.trim() ?? "";
|
||||
if (!domain) return null;
|
||||
return { domain };
|
||||
}
|
||||
|
||||
export function ledgerHasConfirmedDatedEvent(
|
||||
evidence: readonly Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">[],
|
||||
): boolean {
|
||||
return evidence.some(isConfirmedDated);
|
||||
}
|
||||
|
||||
export function parsePersistedFollowupQuestionId(questionId: string | null | undefined): {
|
||||
method_id: string;
|
||||
ask_theme: string;
|
||||
@@ -2017,7 +2017,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
method_id: "active_focus",
|
||||
intent: focus.intent || "active_focus",
|
||||
ask_theme: "active_focus",
|
||||
domain: focus.targetDomain,
|
||||
domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain,
|
||||
kind_hint: focus.targetKind,
|
||||
user_prompt_hint: keepChoice
|
||||
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type ConversationFocus,
|
||||
} from "./tool-service";
|
||||
|
||||
export { parsePersistedFollowupQuestionId } from "./method-followup";
|
||||
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
|
||||
|
||||
export type PersistServerFocusStatus =
|
||||
| "created"
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* or live-unlock after compare/offer/result tools — tokens may stream as
|
||||
* `live`. A later public tool-call retracts that speculative stream so
|
||||
* "Let me set" never stays in `answer.delta`. Exception: `rectification-set-focus`
|
||||
* only attaches the question stem; live greeting/handoff in the same step stays.
|
||||
* keeps already-live greeting/handoff and does not retract it; after the stem
|
||||
* is attached, a second spoken paragraph in the same turn is discarded.
|
||||
*/
|
||||
|
||||
export type StepAnswerChunk = Readonly<{
|
||||
@@ -27,6 +28,8 @@ export type StepAnswerState = {
|
||||
live: boolean;
|
||||
publishedUpTo: number;
|
||||
unlocked: boolean;
|
||||
stemAttached: boolean;
|
||||
publishedAny: boolean;
|
||||
};
|
||||
|
||||
export type StepAnswerEffect =
|
||||
@@ -67,6 +70,8 @@ export function createStepAnswerState(): StepAnswerState {
|
||||
live: false,
|
||||
publishedUpTo: 0,
|
||||
unlocked: false,
|
||||
stemAttached: false,
|
||||
publishedAny: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +121,7 @@ function liveRemainder(state: StepAnswerState): StepAnswerEffect {
|
||||
const next = state.text.slice(state.publishedUpTo);
|
||||
if (!next) return { kind: "none" };
|
||||
state.live = true;
|
||||
state.publishedAny = true;
|
||||
state.publishedUpTo = state.text.length;
|
||||
return { kind: "live", text: next };
|
||||
}
|
||||
@@ -127,9 +133,24 @@ function retractLive(state: StepAnswerState): StepAnswerEffect {
|
||||
state.live = false;
|
||||
state.publishedUpTo = 0;
|
||||
state.unlocked = false;
|
||||
state.publishedAny = false;
|
||||
return wasLive ? { kind: "retract" } : { kind: "none" };
|
||||
}
|
||||
|
||||
function publishUnpublishedSetFocus(state: StepAnswerState): StepAnswerEffect {
|
||||
const unpublished = state.text.slice(state.publishedUpTo);
|
||||
if (hasCjk(unpublished)) {
|
||||
state.publishedAny = true;
|
||||
state.publishedUpTo = state.text.length;
|
||||
const pieces = [unpublished];
|
||||
resetBuffers(state);
|
||||
return { kind: "publish", pieces };
|
||||
}
|
||||
const leftover = unpublished.trim().length > 0;
|
||||
resetBuffers(state);
|
||||
return leftover ? { kind: "discard" } : { kind: "none" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the per-step buffer. Terminal Chinese may stream as `live` once
|
||||
* the step is unlocked or a spoken sentence has closed. Tool-result ends
|
||||
@@ -149,6 +170,7 @@ export function applyStepAnswerChunk(
|
||||
resetBuffers(state);
|
||||
return { kind: "none" };
|
||||
case "text-delta": {
|
||||
if (state.stemAttached && state.publishedAny) return { kind: "discard" };
|
||||
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
|
||||
if (text) {
|
||||
state.text += text;
|
||||
@@ -160,7 +182,9 @@ export function applyStepAnswerChunk(
|
||||
case "tool-call":
|
||||
if (isPublicToolCall(chunk, isPublicTool)) {
|
||||
state.calledTool = true;
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) return { kind: "none" };
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
|
||||
return publishUnpublishedSetFocus(state);
|
||||
}
|
||||
const retracted = retractLive(state);
|
||||
return retracted.kind === "retract" ? retracted : { kind: "none" };
|
||||
}
|
||||
@@ -169,6 +193,7 @@ export function applyStepAnswerChunk(
|
||||
case "tool-error": {
|
||||
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
|
||||
if (KEEP_LIVE_SPOKEN_TOOLS.has(toolName(chunk))) {
|
||||
state.stemAttached = true;
|
||||
resetBuffers(state);
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -188,11 +213,13 @@ export function applyStepAnswerChunk(
|
||||
const reason = stepFinishReason(chunk);
|
||||
if (state.live) {
|
||||
const rest = state.text.slice(state.publishedUpTo);
|
||||
if (rest) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
|
||||
}
|
||||
const publish = shouldPublishStepText(state, reason);
|
||||
const pieces = publish ? [...state.pieces] : [];
|
||||
if (publish) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return publish ? { kind: "publish", pieces } : { kind: "discard" };
|
||||
}
|
||||
@@ -207,6 +234,7 @@ export function flushStepAnswerOnStreamFinish(
|
||||
): StepAnswerEffect {
|
||||
if (state.live) {
|
||||
const rest = state.text.slice(state.publishedUpTo);
|
||||
if (rest) state.publishedAny = true;
|
||||
resetBuffers(state);
|
||||
return rest ? { kind: "publish", pieces: [rest] } : { kind: "none" };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detachCollectSpokenAssistantText } from "./collect-prompt";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
|
||||
import type { ConversationFocus } from "./tool-service";
|
||||
|
||||
@@ -114,7 +114,7 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
|
||||
if (!focus) return { ...turn, question: null };
|
||||
const question = turnQuestionFromFocus(focus);
|
||||
const text = question && turn.text
|
||||
? detachCollectSpokenAssistantText(turn.text, question.prompt)
|
||||
? stripQuestionSentences(turn.text, question.prompt)
|
||||
: turn.text;
|
||||
return { ...turn, text, question };
|
||||
});
|
||||
|
||||
@@ -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:00–05:10。你大概哪一年搬过家?",
|
||||
}], [{
|
||||
id: FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: "collect:relocation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
targetEvidenceId: null,
|
||||
targetDomain: "relocation",
|
||||
targetKind: null,
|
||||
expectedAnswerSchema: { prompt: stem, collect: true },
|
||||
status: "active",
|
||||
askedAt: "2026-09-07T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
askedTurnId: TURN_ID,
|
||||
}]);
|
||||
assert.doesNotMatch(linked[0]?.text ?? "", /[??]/);
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
|
||||
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
assert.match(agentRun, /stripQuestionSentences/);
|
||||
assert.match(agentRun, /withRangeChangedAfterEvidence/);
|
||||
});
|
||||
|
||||
test("delivery narration defers career windows and the range card replaces minute cards", () => {
|
||||
|
||||
@@ -360,6 +360,9 @@ test("opening collect domain is other rather than unknown", () => {
|
||||
source: "method_coverage",
|
||||
});
|
||||
assert.equal(fallback.domain, "other");
|
||||
// 原值: collect:other:collect_method_evidence
|
||||
// 新值: collect:other:collect_method_evidence
|
||||
// 原因: 开场 domain 仍是 other,稳定 id 不得含 unknown
|
||||
assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence");
|
||||
assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,9 @@ const COLLECT_EVIDENCE = [
|
||||
] as const;
|
||||
|
||||
const OPENING_SKIPPED = {
|
||||
// 原值: collect:other:collect_method_evidence
|
||||
// 新值: collect:other:collect_method_evidence
|
||||
// 原因: 开场跳过记录仍按 other 身份,避免把 unknown 写入账本
|
||||
questionId: "collect:other:collect_method_evidence",
|
||||
target_domain: "other",
|
||||
status: "skipped",
|
||||
|
||||
@@ -593,7 +593,7 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.match(chat, /persistedOfferFromTurn/);
|
||||
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& caseSnapshotLoaded\s*&& !busy\s*&& selectionCardMessageKey/);
|
||||
assert.doesNotMatch(
|
||||
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
|
||||
/offeredSelectionOnce/,
|
||||
|
||||
@@ -295,7 +295,7 @@ test("dated collect order walks remaining year-bearing domains then occupation",
|
||||
assert.equal(occupationClosed.next_followup, null);
|
||||
// 原值: other
|
||||
// 新值: null
|
||||
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到「也可以再说一件事」
|
||||
// 原因: BUG-558 方法覆盖完成且七个带年份领域都问过/拒过后不得再落到开放式补问兜底句
|
||||
assert.equal(
|
||||
exhaustionSpokenCollectFollowup({
|
||||
evidence: TWO_SCOREABLE,
|
||||
|
||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||||
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
|
||||
|
||||
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
|
||||
@@ -88,7 +88,36 @@ test("runtime no longer composes the stem into assistant_message", () => {
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
|
||||
assert.match(attach, /detachCollectSpokenAssistantText/);
|
||||
assert.match(attach, /stripQuestionSentences/);
|
||||
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
|
||||
});
|
||||
|
||||
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
|
||||
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
|
||||
const stem = "你大概是哪一年搬的家?";
|
||||
assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:00–05: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:00–05: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:00–05: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:50–04:57 变为 04:47–05:15。/);
|
||||
assert.equal(
|
||||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]),
|
||||
"范围从 04:50–04:57 变为 04:47–05:15。",
|
||||
);
|
||||
assert.equal(
|
||||
withRangeChangedAfterEvidence("记下了。", ["04:50", "04:57"], ["04:50", "04:57"]),
|
||||
"记下了。",
|
||||
);
|
||||
assert.equal(rangeChangedAfterEvidence(["04:50", "04:57"], ["04:50", "04:57"]), null);
|
||||
|
||||
const agentRun = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const askedFocusAt = agentRun.indexOf("const askedFocus = latestDossier.conversationSummary.activeFocus");
|
||||
const settlement = askedFocusAt >= 0 ? agentRun.slice(askedFocusAt) : "";
|
||||
const stripAt = settlement.indexOf("stripQuestionSentences");
|
||||
const rangeAt = settlement.indexOf("withRangeChangedAfterEvidence");
|
||||
assert.ok(stripAt >= 0 && rangeAt > stripAt);
|
||||
assert.match(agentRun, /rectification-compare-candidates/);
|
||||
});
|
||||
|
||||
test("P3 selection cards stay gated by busy and a loaded snapshot", () => {
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const block = chat.slice(
|
||||
chat.indexOf("const showSelectionCards"),
|
||||
chat.indexOf("const showReadonlyRange"),
|
||||
);
|
||||
assert.match(block, /!busy/);
|
||||
assert.match(block, /caseSnapshotLoaded/);
|
||||
assert.match(chat, /startTransition\(/);
|
||||
});
|
||||
@@ -660,6 +660,6 @@ test("exhaustion after coverage is closed does not fall back to domain other", (
|
||||
});
|
||||
// 原值: domain=other / USER_COLLECT_QUESTION.other
|
||||
// 新值: null
|
||||
// 原因: 穷尽后交付,不再问「也可以再说一件事」。
|
||||
// 原因: 穷尽后交付,不再问开放式补问兜底句。
|
||||
assert.equal(next, null);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
USER_COLLECT_QUESTION,
|
||||
USER_COLLECT_QUESTION_RETRY,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
|
||||
@@ -514,8 +513,11 @@ test("zero-evidence opening persists the server-owned first question", async ()
|
||||
assert.equal(followup.domain, "other");
|
||||
assert.equal(followup.source, "method_coverage");
|
||||
assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/);
|
||||
// 原值: collect:other:collect_method_evidence
|
||||
// 新值: collect:other:collect_method_evidence
|
||||
// 原因: 开场仍用 domain=other,GENERIC 题干;稳定 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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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:00–05: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:00–05: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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user