diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb76653..eab463c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-08 — 七个领域问完后是职业题或区间卡,不再补问「再说一件」 + +生时校正把学业、感情、事业、家人、财务、迁居、健康问过或跳过后,下一问是「你平时主要做什么工作?」,或者直接出区间交付卡。不会再出现「也可以再说一件你记得大概时间的事」。Skill 版本仍是 10.0.15。 + ## 2026-09-07 — 区间卡能编译,采用后才说预测窗口,再补经历有下一句提示 生时校正交付卡不再把编译挡在门外。点采用后,第一轮核对旁白末尾会接上实际的下次事业变动窗口(有才写)。点「再补一件经历」后卡片收起,输入框上方改成收集经历的提示。Skill 版本仍是 10.0.15。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 87690d66..6451434d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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,19 @@ - 复发自:无(区间卡 `ab57d03f` 引入;任务书 4.2 验收未过) - 修复版本:待发布(`codex/rectification-range-delivery-fix-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::` 解析领域。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`) diff --git a/docs/tasks/PROGRESS-rectification-other-collect-fallback-20260908.md b/docs/tasks/PROGRESS-rectification-other-collect-fallback-20260908.md new file mode 100644 index 00000000..3d9b16a5 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-other-collect-fallback-20260908.md @@ -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::` 解析,再退回 `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。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index a544ed93..2c7de373 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -73,7 +73,7 @@ | `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` 的出卡路径永远不跑 | 待执行(触发链 investigating,需事故 Case receipt) | `codex/rectification-other-collect-fallback-20260908`(BUG-586) | +| `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-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) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 446ad7cc..1c5ede74 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -90,3 +90,15 @@ - 点「按这个范围用」成功,设置页出生时间状态变为已采用 - 点「再补一件经历」卡片收起,输入框上方出现「第 1 步·收集经历」和「请继续说下一件…」,服务端范围不变 +## 6. 七领域问完后下一问必须是职业题或直接出区间卡 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。 + +开场后依次说或拒答七个带年月领域(学业、感情、事业、家人、财务、迁居、健康),不要主动说职业。 + +期望: + +- 七领域问完后下一问是「你平时主要做什么工作?」,或已经可以交付时直接出区间卡 +- 不得出现补问兜底句「也可以再说一件你记得大概时间的事」 +- 职业答过、又没有剩余采集时,必须出现区间交付卡,不能只剩停止按钮 + diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 723702a8..daa4099d 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -51,7 +51,6 @@ export const USER_COLLECT_QUESTION: Readonly> = { 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> = { relocation: "搬家或开始长期住外地,大概是哪年?", finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?", health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?", - other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。", }; export const RECTIFICATION_USER_COPY = { diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 36b27b7e..5f1825ef 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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 } : {}), }); } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index dad30a01..725beb79 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -237,7 +237,9 @@ const BLOCKING_COVERAGE_IDS = new Set([ "relatives", ]); -function isConfirmedDated(item: MethodFollowupEvidence): boolean { +function isConfirmedDated( + item: Pick, +): 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>[]; @@ -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[], +): 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 / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。" diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 1bdf6c4d..d99d2103 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -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" diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 6c360982..c25cbfa5 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -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", { diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 725867d7..7111a886 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -154,11 +154,15 @@ 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("delivery narration defers career windows and the range card replaces minute cards", () => { diff --git a/frontend/tests/rectification-adopt-flow-20260902.test.ts b/frontend/tests/rectification-adopt-flow-20260902.test.ts index f19e2fb4..ad3a1573 100644 --- a/frontend/tests/rectification-adopt-flow-20260902.test.ts +++ b/frontend/tests/rectification-adopt-flow-20260902.test.ts @@ -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/); }); diff --git a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts index d39e9a4e..44561b15 100644 --- a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts +++ b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts @@ -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", diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index e456d2f2..dbf376f4 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -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, diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 2d893998..56167ad5 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -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>; - 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, diff --git a/frontend/tests/rectification-other-collect-fallback-20260908.test.ts b/frontend/tests/rectification-other-collect-fallback-20260908.test.ts new file mode 100644 index 00000000..3409c865 --- /dev/null +++ b/frontend/tests/rectification-other-collect-fallback-20260908.test.ts @@ -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 = { + execute(input: unknown): Promise; +}; + +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[]) { + 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>).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 | 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) ?? {}, + 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>).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; + const second = await execute({ + caseId: CASE_ID, + questionId: "collect:other:collect_method_evidence", + intent: "collect_method_evidence", + spokenPrompt: "请选一个选项继续问经历吧", + targetDomain: "career", + }) as Record; + 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); +}); diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 40e9859e..b34557a8 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -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); }); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index cbd70a4b..2b005a29 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -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");