diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03f394e7..417072f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
+## 2026-09-14 — 探针耗尽后不再把判别题念进正文;有卡不再画裸题
+
+生时校正里,带年月题问完、探针盖不上号时,不会再把同一句判别题写进正文让人打字。会改出定向补事点选卡。若快照里已经有选择题卡,界面不再只画一行题干。代表时间如果落在被淘汰候选或范围外,会记一条告警,排序逻辑本轮不动。Skill 版本不变。
+
+
## 2026-09-13 — 定向补事存在题不再落成口述裸题
生时校正里,定向补事「有没有这件事」必须是四点选。若焦点被写成口述采集,刷新后不再只剩一行没有卡片的题干,会复原成可点的卡,或给出「接着问」修复入口。答完上一题后,下一问如果没落库,题干会写进本轮旁白,不会把计划中的 D9 题悄悄吞掉。年份追问仍是口述。Skill 版本不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 25721894..18df14f9 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -10484,3 +10484,51 @@
- 相关记录:BUG-670、BUG-669、BUG-671、BUG-661
- 复发自:BUG-670
- 修复版本:待发布
+
+## BUG-674 | 探针耗尽后判别题被念进正文,同一题反复问且答了不算数
+
+- 状态:resolved
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`persistNextInterviewAfterChoice`、`expectedAnswerSchemaFor` / `stampChoiceSchemaWithProbe`、`persistExhaustionCollect`
+- 用户现象:答完带年月题后,正文反复出现同一句判别题(无 A/B/C/D)。用户打字回答「没有」只得到「记下了,这方面先跳过」,同一句再出现一次。
+- 触发条件:`prospective_probes` 为空,精度阶段仍给出带 `choice_frame` 的判别题;`stampChoiceSchemaWithProbe` 盖不上 `probe_id`,persist 返回 `invalid_choice_schema`。
+- 根因:上一单把「焦点没写成功」当成「题还在、只是没进消息」,把卡片题干拼进旁白。探针耗尽时这道题根本不该再问,也没有焦点可答。
+- 修复:判别题(`intent === distinguish_candidates`,或带 `choice_frame` 的非采集题)落不了库时改走 `persistExhaustionCollect`,不得把题干写进正文。采集题仍可进正文,但与上一条助手消息相同的题干会去重并转耗尽分支。
+- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts`。
+- 防复发:判别题落不了库时不得把题干写进正文;探针耗尽必须转定向补事卡。
+- 相关记录:BUG-673、BUG-652、BUG-661
+- 复发自:BUG-673(D3 把未落库题干拼进旁白)
+- 修复版本:待发布
+
+## BUG-675 | 快照已有 choice_card,界面仍画一行裸题
+
+- 状态:resolved
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`attachQuestionsToTurns`、`persisted_question` 渲染、`persistedQuestionSurface`
+- 用户现象:`目前范围` 下面一行裸题(如「结过婚或订过婚吗?」),无头像、无选项、也没有「接着问」。
+- 触发条件:`current_question.kind === "choice"` 且 GET `choice_card` 非空,但焦点没有 `asked_turn_id`,卡片无法挂到消息上;`persisted_question` 只画 `prompt`。
+- 根因:`persisted_question` 从设计起只服务口述题。BUG-673 把定向题修成点选后,这个纯文本块成了新的裸题来源。
+- 修复:无 `asked_turn_id` 的活动选择题挂到最后一条助手消息,走既有消息内卡片。`persisted_question` 在有卡时渲染同一套 `RectificationChoiceCard`,不再只画一行字。
+- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts`;`rectification-surface-state.test.ts`。
+- 防复发:`current_question.kind === "choice"` 且有 `choice_card` 时必须渲染可点卡,不得只画题干。不得新增第二套卡片组件。
+- 相关记录:BUG-673、BUG-669、BUG-671
+- 复发自:BUG-673(数据形态修好后渲染仍走口述兜底)
+- 修复版本:待发布
+
+## BUG-676 | 代表时间取到被淘汰候选
+
+- 状态:investigating
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`evaluateCandidateSeparation`、`rankActive`、decision receipt `representative_time` / `last_inference_round`
+- 用户现象:同一轮 receipt 里 `representative_time` 落在 `eliminated_ids` 中(例如 05:14),界面范围是 04:48–05:07,也不含该分钟。
+- 触发条件:本轮有淘汰名单,对外代表时间与 `winner_id` / 范围不一致。
+- 根因:未知。已确认 `evaluateCandidateSeparation` 按 score 取 `ranked[0]`,`evaluateConvergence` 先滤 `status === eliminated` 再按后验排序。尚未拿到同一份 `inference_state.candidates`(id / time / status / probability / posterior_score)与 `credible_range`,不能断定是 status 没写回、后验没重算,还是两套分数不是同一份。
+- 修复:本轮只加不变量告警 `rectification_representative_time_inconsistent`(代表时间在 eliminated 内或落在 credible_range 外)。不改排序。
+- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts` 两条告警路径。
+- 防复发:代表时间必须不在本轮 `eliminated_ids` 内,且落在 `credible_range` 内。根因确认后另立修复单,不得为了「数字看起来对」顺手改排序。
+- 相关记录:BUG-442
+- 复发自:无
+- 修复版本:待发布
diff --git a/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md b/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md
new file mode 100644
index 00000000..ebe9abfb
--- /dev/null
+++ b/docs/tasks/PROGRESS-rectification-unstampable-probe-20260914.md
@@ -0,0 +1,23 @@
+# 进度 · 探针耗尽、裸题与代表时间(2026-09-14)
+
+## 范围
+
+- 分支:`codex/rectification-unstampable-probe-20260914`(基于 `origin/staging` @ `9375012f`)
+- 任务单:`docs/tasks/TASK-rectification-unstampable-probe-and-naked-card-20260914.md`
+- BUG-674 / BUG-675 resolved;BUG-676 investigating
+- 上一单 D3 已在任务书里标注推翻
+
+## 完成
+
+- T1:`persistNextInterviewAfterChoice` 尾部对判别题改走 `persistExhaustionCollect`;采集题保留题干进正文,但与上一条助手消息相同则去重。
+- T2:`attachQuestionsToTurns` 把无 `asked_turn_id` 的活动选择题挂到最后一条助手消息;`persisted_question` 有卡时渲染既有 `RectificationChoiceCard`。
+- T3:`warnRepresentativeTimeInconsistency`,不改排序。
+- T4:BUG_HISTORY、CHANGELOG、真机清单。
+
+## 验收数字
+
+- `./node_modules/.bin/tsc --noEmit`:0 错
+- `npm run lint`:0 error(120 warning,与基线同类,无新增 error)
+- 相关单测 93/93:`rectification-unstampable-probe-20260914.test.ts` 8/8,加上 collect-prompt / question-in-message / surface-state / targeted-spoken / targeted-card-live / answer-choice
+- `npm test`:2985 tests,2818 pass / 153 fail / 14 skip。失败项是本机 Windows 基线(Caddy/CRLF、Docker、bash、workflow YAML、Skill SHA),新增用例均绿,没有新的校正产品失败
+- `next build`:本工作树 `frontend/node_modules` 是 junction,Turbopack 拒绝软链;`--webpack` 编译成功后在收集 page data 时因本机 Skill 10.0.26 SHA 与 registry 不一致失败(环境问题,未改 skill 包)。`/` 是否仍 `○ Static` 需在有真实 `node_modules` 且 skill 哈希匹配的树上复核
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 568b511c..0c20c563 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -184,7 +184,7 @@
| `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` |
-| `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676,investigating) | 待执行 | `codex/rectification-unstampable-probe-20260914` |
+| `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676,investigating) | 待验收 | `codex/rectification-unstampable-probe-20260914` |
| `TASK-rectification-targeted-collect-spoken-focus-20260913.md` | `PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md` | **P0**:定向补事题以采集型 schema 存进焦点,前端只能裸画一行题干(无头像无卡无修复入口),本轮计划的 D9 题被静默吞掉;BUG-670 的识别条件只认 `targeted_collect` 章,复原不了(BUG-673,复发自 BUG-670) | 待验收 | `codex/rectification-targeted-collect-spoken-focus-20260913` |
diff --git a/docs/testing/rectification-unstampable-probe-20260914.md b/docs/testing/rectification-unstampable-probe-20260914.md
new file mode 100644
index 00000000..385661bf
--- /dev/null
+++ b/docs/testing/rectification-unstampable-probe-20260914.md
@@ -0,0 +1,20 @@
+# 探针耗尽、裸题与代表时间:真机清单(2026-09-14)
+
+分支 `codex/rectification-unstampable-probe-20260914`。本环境无 staging 登录态。
+
+## BUG-674
+
+进入当时那个校正会话,或再走一遍「答完带年月题、探针池空」。
+
+- [ ] 正文不再反复出现同一句「2023 年 5 月前后,有没有开始一段认真关系…」且没有选项。
+- [ ] 下一问是定向补事四选一卡,或诚实说明为什么现在没有下一题。
+- [ ] 打字回答「没有」不会让同一句判别题再出现一次。
+
+## BUG-675
+
+- [ ] 「结过婚或订过婚吗?」或「家里添过丁或长辈住过院吗?」出现时带四个选项,可点。
+- [ ] 刷新后仍是卡,不是一行裸题,也不应只剩「再说一件带年月的事就能继续」。
+
+## BUG-676
+
+- [ ] 打开当时那轮的 receipt(脱敏):记下 `inference_state.candidates` 每条的 `id` / `time` / `status` / `probability` / `posterior_score`,以及 `credible_range`。把这段交给修复单,不要改排序。
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 718f9fc8..4e747421 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -69,6 +69,7 @@ import {
caseStageFromSnapshot,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
+ persistedQuestionSurface,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -1868,7 +1869,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{questionGap === "persisted_question" && currentQuestion?.prompt && (
-
{currentQuestion.prompt}
+ {persistedQuestionSurface({
+ questionKind: currentQuestion.kind,
+ hasChoiceCard: Boolean(choiceCard),
+ questionPersisted: true,
+ }) === "choice_card" && choiceCard ? (
+ <>
+
{currentQuestion.prompt}
+
+ >
+ ) : (
+
{currentQuestion.prompt}
+ )}
)}
diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts
index 334f790b..7b5130b8 100644
--- a/frontend/src/lib/rectification-agentic/core/build-state.ts
+++ b/frontend/src/lib/rectification-agentic/core/build-state.ts
@@ -2,6 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
+import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
@@ -197,6 +198,14 @@ export function buildInferenceState(input: {
...(transitions ? { transitions } : {}),
};
const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed });
+ const lastRound = rounds.at(-1) ?? null;
+ warnRepresentativeTimeInconsistency({
+ representativeTime: decision.representative_time,
+ eliminatedIds: lastRound?.eliminated_ids,
+ credibleRange: decision.credible_range ?? draft.credible_range,
+ winnerId: lastRound?.winner_id,
+ scoresAfter: lastRound?.scores_after,
+ });
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
diff --git a/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts b/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts
new file mode 100644
index 00000000..5ed38b91
--- /dev/null
+++ b/frontend/src/lib/rectification-agentic/core/representative-time-guard.ts
@@ -0,0 +1,77 @@
+export type RepresentativeTimeGuardInput = Readonly<{
+ representativeTime: string | null | undefined;
+ eliminatedIds?: readonly string[] | null;
+ credibleRange?: readonly string[] | null;
+ winnerId?: string | null;
+ scoresAfter?: Readonly> | null;
+}>;
+
+export type RepresentativeTimeGuardResult = Readonly<{
+ ok: boolean;
+ inEliminated: boolean;
+ outsideRange: boolean;
+}>;
+
+function clockMinutes(value: string): number | null {
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
+ if (!match) return null;
+ return Number(match[1]) * 60 + Number(match[2]);
+}
+
+function outsideCredibleRange(time: string, range: readonly string[]): boolean {
+ if (range.length === 0) return false;
+ if (range.includes(time)) return false;
+ if (range.length < 2) return true;
+ const start = clockMinutes(range[0] ?? "");
+ const end = clockMinutes(range[range.length - 1] ?? "");
+ const point = clockMinutes(time);
+ if (start === null || end === null || point === null) return true;
+ if (start <= end) return point < start || point > end;
+ return point < start && point > end;
+}
+
+function scoreExtremes(scores: Readonly> | null | undefined): {
+ max?: readonly [string, number];
+ min?: readonly [string, number];
+} {
+ let max: readonly [string, number] | undefined;
+ let min: readonly [string, number] | undefined;
+ for (const [time, score] of Object.entries(scores ?? {})) {
+ if (!Number.isFinite(score)) continue;
+ if (!max || score > max[1]) max = [time, score];
+ if (!min || score < min[1]) min = [time, score];
+ }
+ return { max, min };
+}
+
+export function inspectRepresentativeTime(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
+ const time = typeof input.representativeTime === "string" ? input.representativeTime.trim() : "";
+ if (!time) {
+ return { ok: true, inEliminated: false, outsideRange: false };
+ }
+ const inEliminated = (input.eliminatedIds ?? []).includes(time);
+ const outsideRange = outsideCredibleRange(time, input.credibleRange ?? []);
+ return {
+ ok: !inEliminated && !outsideRange,
+ inEliminated,
+ outsideRange,
+ };
+}
+
+export function warnRepresentativeTimeInconsistency(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
+ const result = inspectRepresentativeTime(input);
+ if (result.ok) return result;
+ const extremes = scoreExtremes(input.scoresAfter);
+ console.warn(JSON.stringify({
+ event: "rectification_representative_time_inconsistent",
+ winner_id: input.winnerId ?? null,
+ representative_time: input.representativeTime ?? null,
+ credible_range: input.credibleRange ?? null,
+ eliminated_ids: input.eliminatedIds ?? [],
+ scores_after_max: extremes.max ?? null,
+ scores_after_min: extremes.min ?? null,
+ in_eliminated: result.inEliminated,
+ outside_range: result.outsideRange,
+ }));
+ return result;
+}
diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
index c0e88edb..77d22c56 100644
--- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
+++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
@@ -924,6 +924,7 @@ export async function persistNextInterviewAfterChoice(input: {
narrateAdopt?: AdoptNarrationWriter;
skipRefresh?: boolean;
continueTieBreakRound?: boolean;
+ followup?: MethodFollowup | null;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -1010,7 +1011,7 @@ export async function persistNextInterviewAfterChoice(input: {
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
- const followup = interviewToPersist(plan);
+ const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: nextAction.can_adopt,
nextAction: nextAction.type,
@@ -1048,6 +1049,15 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
+ const exhaustion = {
+ accounting: input.accounting,
+ userId: input.userId,
+ caseId: input.caseId,
+ dossier: liveDossier,
+ decision,
+ decisionReceipt: liveDossier.latestResult?.decisionReceipt,
+ askedTurnId: input.askedTurnId ?? null,
+ };
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1068,6 +1078,9 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
+ if (stemAlreadyInLastAssistant(open.prompt, input.dossier)) {
+ return persistExhaustionCollect(exhaustion);
+ }
return {
hostNarration: open.prompt,
choiceReady: false,
@@ -1078,20 +1091,15 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) {
- return persistExhaustionCollect({
- accounting: input.accounting,
- userId: input.userId,
- caseId: input.caseId,
- dossier: liveDossier,
- decision,
- decisionReceipt: liveDossier.latestResult?.decisionReceipt,
- askedTurnId: input.askedTurnId ?? null,
- });
+ return persistExhaustionCollect(exhaustion);
}
if (followup?.intent === "collect_method_evidence" && followup.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence);
if (spoken) {
+ if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
+ return persistExhaustionCollect(exhaustion);
+ }
const fallback = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1121,17 +1129,12 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
+ if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
+ return persistExhaustionCollect(exhaustion);
+ }
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
- return persistExhaustionCollect({
- accounting: input.accounting,
- userId: input.userId,
- caseId: input.caseId,
- dossier: liveDossier,
- decision,
- decisionReceipt: liveDossier.latestResult?.decisionReceipt,
- askedTurnId: input.askedTurnId ?? null,
- });
+ return persistExhaustionCollect(exhaustion);
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
@@ -1141,15 +1144,13 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
- return persistExhaustionCollect({
- accounting: input.accounting,
- userId: input.userId,
- caseId: input.caseId,
- dossier: liveDossier,
- decision,
- decisionReceipt: liveDossier.latestResult?.decisionReceipt,
- askedTurnId: input.askedTurnId ?? null,
- });
+ return persistExhaustionCollect(exhaustion);
+ }
+ if (
+ followup.intent === "distinguish_candidates"
+ || (followup.choice_frame && followup.intent !== "collect_method_evidence")
+ ) {
+ return persistExhaustionCollect(exhaustion);
}
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
if (!spoken) {
@@ -1160,15 +1161,10 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
- return persistExhaustionCollect({
- accounting: input.accounting,
- userId: input.userId,
- caseId: input.caseId,
- dossier: liveDossier,
- decision,
- decisionReceipt: liveDossier.latestResult?.decisionReceipt,
- askedTurnId: input.askedTurnId ?? null,
- });
+ return persistExhaustionCollect(exhaustion);
+ }
+ if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
+ return persistExhaustionCollect(exhaustion);
}
return {
hostNarration: spoken,
@@ -1178,6 +1174,23 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
+function stemAlreadyInLastAssistant(
+ stem: string,
+ dossier: { turns?: readonly { role?: string; text?: string | null }[] },
+): boolean {
+ const needle = stem.trim();
+ if (needle.length < 8) return false;
+ const turns = dossier.turns ?? [];
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
+ const turn = turns[index];
+ if (turn?.role !== "assistant") continue;
+ const text = typeof turn.text === "string" ? turn.text : "";
+ if (!text.trim()) continue;
+ return text.includes(needle);
+ }
+ return false;
+}
+
export async function requestTieBreakPersonality(input: {
accounting: AccountingClient;
userId: string;
@@ -1747,6 +1760,7 @@ async function persistExhaustionCollect(input: {
choiceReady: boolean;
hostNarration: string;
focus?: ConversationFocus | null;
+ followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
let dossier = input.dossier as DecisionDossier;
@@ -1833,6 +1847,7 @@ async function persistExhaustionCollect(input: {
choiceReady: true,
hostNarration: targetedCollectHostNarration(followup, open.prompt),
focus: persistedFocus.focus,
+ followup,
};
}
const spoken = followup.choice_frame
diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts
index 57a02e67..51acbe32 100644
--- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts
+++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts
@@ -26,6 +26,7 @@ import {
type RectificationDecision,
} from "../core/rectification-decision.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
+import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
askedDiscriminatorKeys,
@@ -1031,18 +1032,19 @@ export function overlayPublicDecision(
completionStatus: ReturnType["completion_status"];
} {
const fields = publicDecisionFields(decision);
+ const receipt = (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null;
+ const inference = previousInferenceFromReceipt(receipt);
+ const round = inference?.last_inference_round ?? null;
const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]);
const inconsistent = !projection.consistent;
- const ceiling = engineCapabilityCeilingFromReceipt(
- (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null,
- );
+ const ceiling = engineCapabilityCeilingFromReceipt(receipt);
const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed;
const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed;
const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed;
const canConfirmExactMinute = !inconsistent
&& fields.can_confirm_exact_minute
&& ceiling.confirmationAllowed;
- return {
+ const overlaid = {
...snapshot,
...fields,
can_adopt: canAdopt,
@@ -1062,4 +1064,12 @@ export function overlayPublicDecision(
validated: fields.validated,
completionStatus: fields.completion_status,
};
+ warnRepresentativeTimeInconsistency({
+ representativeTime: overlaid.representative_time,
+ eliminatedIds: round?.eliminated_ids,
+ credibleRange: overlaid.credible_range ?? inference?.credible_range,
+ winnerId: round?.winner_id,
+ scoresAfter: round?.scores_after,
+ });
+ return overlaid;
}
diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts
index b4e8802b..67bcf43d 100644
--- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts
+++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts
@@ -104,10 +104,20 @@ export function attachQuestionsToTurns {
const byTurn = new Map();
+ const hanging: ConversationFocus[] = [];
for (const focus of focuses) {
- if (!focus.askedTurnId) continue;
+ if (!focus.askedTurnId) {
+ if (focus.status === "active" && turnQuestionKind(focus) === "choice") {
+ hanging.push(focus);
+ }
+ continue;
+ }
byTurn.set(focus.askedTurnId, focus);
}
+ const lastAssistant = [...turns].reverse().find((turn) => turn.role === "assistant");
+ if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) {
+ byTurn.set(lastAssistant.id, hanging[0]);
+ }
return turns.map((turn) => {
if (turn.role !== "assistant") return { ...turn, question: null };
const focus = byTurn.get(turn.id);
diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts
index afc2b8e3..1bdb746f 100644
--- a/frontend/src/lib/rectification-surface-state.ts
+++ b/frontend/src/lib/rectification-surface-state.ts
@@ -347,6 +347,16 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn
return null;
}
+export function persistedQuestionSurface(input: Readonly<{
+ questionKind?: string | null;
+ hasChoiceCard: boolean;
+ questionPersisted: boolean;
+}>): "none" | "spoken" | "choice_card" {
+ if (!input.questionPersisted) return "none";
+ if (input.questionKind === "choice" && input.hasChoiceCard) return "choice_card";
+ return "spoken";
+}
+
export function interviewChoiceCardUnavailable(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
diff --git a/frontend/tests/rectification-collect-prompt.test.ts b/frontend/tests/rectification-collect-prompt.test.ts
index b38f5b66..bd752b09 100644
--- a/frontend/tests/rectification-collect-prompt.test.ts
+++ b/frontend/tests/rectification-collect-prompt.test.ts
@@ -89,7 +89,10 @@ test("runtime no longer composes the stem into assistant_message", () => {
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
- assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
+ // 原值: if (!focus.askedTurnId) continue
+ // 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
+ // 原因: BUG-675 焦点没有 asked_turn_id 时卡片仍要出现在消息上
+ assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts
index f472b0be..6333e8c8 100644
--- a/frontend/tests/rectification-question-in-message.test.ts
+++ b/frontend/tests/rectification-question-in-message.test.ts
@@ -77,7 +77,10 @@ test("GET turns attach question by asked_turn_id and never by timestamps", () =>
assert.match(caseProjection, /question: turn\.question/);
assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
- assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
+ // 原值: if (!focus.askedTurnId) continue
+ // 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
+ // 原因: BUG-675
+ assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
assert.doesNotMatch(attach, /askedAt|createdAt/);
});
diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts
index 7a054dd2..45568c61 100644
--- a/frontend/tests/rectification-surface-state.test.ts
+++ b/frontend/tests/rectification-surface-state.test.ts
@@ -16,6 +16,7 @@ import {
rectificationQuestionRetryActive,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
+ persistedQuestionSurface,
interviewStopReasonFromSnapshot,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
rectificationReadonlyRangeCopy,
@@ -111,6 +112,17 @@ test("question gap: nothing is shown while busy, readonly, regenerating, or for
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
+test("a persisted choice with a live card is not the spoken-only prompt surface", () => {
+ assert.equal(
+ persistedQuestionSurface({
+ questionKind: "choice",
+ hasChoiceCard: true,
+ questionPersisted: true,
+ }),
+ "choice_card",
+ );
+});
+
test("question gap: a persisted focus question is shown instead of preparing", () => {
assert.equal(
rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
diff --git a/frontend/tests/rectification-unstampable-probe-20260914.test.ts b/frontend/tests/rectification-unstampable-probe-20260914.test.ts
new file mode 100644
index 00000000..00a697e5
--- /dev/null
+++ b/frontend/tests/rectification-unstampable-probe-20260914.test.ts
@@ -0,0 +1,575 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { applyRectificationChoice, persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
+import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
+import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
+import {
+ expectedAnswerSchemaFor,
+ persistServerOwnedFocus,
+ stableFollowupQuestionId,
+} from "../src/lib/rectification-agentic/v9/server-focus.ts";
+import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
+import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts";
+import {
+ inspectRepresentativeTime,
+ warnRepresentativeTimeInconsistency,
+} from "../src/lib/rectification-agentic/core/representative-time-guard.ts";
+import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
+import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
+import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
+import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
+import {
+ CASE_ID,
+ FOCUS_ID,
+ SESSION_ID,
+ TURN_ID,
+ USER_ID,
+ activeFocusFixture,
+ candidateSnapshotFixture,
+ computeFixture,
+ conversationSummaryFixture,
+ dossierFixture,
+ fakeAccounting,
+ receiptHandlers,
+} from "./rectification-v9-test-support.ts";
+
+const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+
+const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?";
+const OPTIONS = [
+ { label: "明确发生且时间吻合", answer_class: "yes" as const },
+ { label: "发生过但程度较弱", answer_class: "weak_yes" as const },
+ { label: "明确没有发生", answer_class: "no" as const },
+ { label: "这段记不清楚", answer_class: "unsure" as const },
+];
+
+function emptyInference() {
+ return {
+ algorithm_version: INFERENCE_ALGORITHM_VERSION,
+ candidate_set_id: "04:48-05:07",
+ revision: 6,
+ phase: "discrimination",
+ result_status: "discriminating",
+ range_start: "04:48",
+ range_end: "05:07",
+ candidates: [
+ {
+ id: "04:53",
+ time: "04:53",
+ cluster_range: ["04:48", "05:07"] as const,
+ prior_score: 16,
+ posterior_score: 16,
+ probability: 0.4,
+ status: "active" as const,
+ rank: 1,
+ strong_conflict_count: 0,
+ },
+ {
+ id: "05:06",
+ time: "05:06",
+ cluster_range: ["04:48", "05:07"] as const,
+ prior_score: 15,
+ posterior_score: 15,
+ probability: 0.35,
+ status: "active" as const,
+ rank: 2,
+ strong_conflict_count: 0,
+ },
+ ],
+ events: [],
+ probes: [],
+ answered_probes: [],
+ rounds: [],
+ entropy: 1,
+ representative_time: "04:53",
+ credible_range: ["04:48", "05:07"] as const,
+ };
+}
+
+const WINDOW_SCAN = {
+ scanned: true,
+ d9_lagna_count: 2,
+ d10_lagna_count: 2,
+ d4_lagna_count: 2,
+ d9_candidates_differ: true,
+ d10_candidates_differ: true,
+ d4_candidates_differ: true,
+};
+
+function distinguishFollowup(): MethodFollowup {
+ const frame = buildChoiceFrame({
+ method_id: "d9_relationship",
+ ask_theme: "dated_event",
+ domain: "relationship",
+ user_prompt_hint: "ask",
+ }, {
+ probes: [{
+ year: 2023,
+ year_label: "2023 年 5 月前后",
+ domain: "relationship",
+ event_family: "开始一段认真关系、分手或结婚",
+ source: "dasha_boundary",
+ tracks: ["vimshottari", "narayana"],
+ tracks_agree: true,
+ unique_minute_claim: false,
+ user_meaning: STEM,
+ role: "distinguish",
+ information_gain: 0.4,
+ semantic_key: "relationship.2023.05.dasha_boundary",
+ style_options: OPTIONS,
+ }],
+ });
+ assert.ok(frame);
+ return {
+ method_id: "d9_relationship",
+ intent: "distinguish_candidates",
+ ask_theme: "dated_event",
+ domain: "relationship",
+ kind_hint: "relationship_change",
+ user_prompt_hint: "ask",
+ must_not_label: false,
+ choice_frame: frame,
+ source: "precision_stage",
+ information_gain: 0.4,
+ semantic_key: "relationship.2023.05.dasha_boundary",
+ probe_year: 2023,
+ year_label: "2023 年 5 月前后",
+ candidate_ids: ["04:48", "05:07"],
+ expected_outcomes: [
+ { answer_class: "yes", supports: ["04:48"], conflicts: ["05:07"] },
+ { answer_class: "no", supports: ["05:07"], conflicts: ["04:48"] },
+ ],
+ };
+}
+
+const DATED_EVIDENCE = [
+ {
+ id: "e-edu",
+ status: "confirmed" as const,
+ domain: "education",
+ datePrecision: "month" as const,
+ occurredFrom: "2016-09-01",
+ occurredTo: "2016-09-30",
+ eventKind: "education_start",
+ summary: "2016年9月上大学",
+ },
+ {
+ id: "e-career",
+ status: "confirmed" as const,
+ domain: "career",
+ datePrecision: "month" as const,
+ occurredFrom: "2020-04-01",
+ occurredTo: null,
+ eventKind: "career_entry",
+ summary: "2020年4月入职",
+ },
+ {
+ id: "e-rel",
+ status: "confirmed" as const,
+ domain: "relationship",
+ datePrecision: "year" as const,
+ occurredFrom: "2018-01-01",
+ occurredTo: "2018-12-31",
+ eventKind: "relationship_start",
+ summary: "2018年恋爱",
+ },
+ {
+ id: "e-move",
+ status: "confirmed" as const,
+ domain: "relocation",
+ datePrecision: "year" as const,
+ occurredFrom: "2015-01-01",
+ occurredTo: "2015-12-31",
+ eventKind: "relocation",
+ summary: "2015年搬家",
+ },
+];
+
+const PERSIST_EVIDENCE = DATED_EVIDENCE.filter((row) => row.domain !== "relationship");
+
+function focusAccounting() {
+ return fakeAccounting({
+ ...receiptHandlers,
+ get_agentic_rectification_case_compute: () => computeFixture(),
+ get_agentic_rectification_case_dossier: () => snakeDossier(),
+ set_agentic_rectification_conversation_focus: (_fn, args) => ({
+ focus: {
+ id: FOCUS_ID,
+ case_id: CASE_ID,
+ question_id: args.p_question_id,
+ intent: args.p_intent,
+ target_evidence_id: args.p_target_evidence_id ?? null,
+ target_domain: args.p_target_domain ?? null,
+ target_kind: args.p_target_kind ?? null,
+ expected_answer_schema: args.p_expected_answer_schema,
+ status: "active",
+ asked_at: "2026-09-14T00:00:00.000Z",
+ resolved_at: null,
+ asked_turn_id: args.p_asked_turn_id ?? null,
+ },
+ idempotent: false,
+ }),
+ resolve_agentic_rectification_conversation_focus: () => ({
+ focus: { id: FOCUS_ID, status: "resolved" },
+ idempotent: false,
+ }),
+ append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
+ apply_agentic_rectification_choice_action: (_fn, args) => ({
+ action_id: args.p_action_id,
+ status: "applied",
+ idempotent: false,
+ question_id: args.p_question_id,
+ option_id: args.p_option_id,
+ probe_id: "p-cd",
+ revision: Number(args.p_expected_revision ?? 6) + 1,
+ source_quote: args.p_source_quote,
+ derived_context: args.p_derived_context,
+ narration: args.p_narration,
+ focus_status: args.p_focus_status,
+ }),
+ });
+}
+
+function snakeEvidence(rows: typeof DATED_EVIDENCE = DATED_EVIDENCE) {
+ return rows.map((row) => ({
+ id: row.id,
+ status: row.status,
+ domain: row.domain,
+ date_precision: row.datePrecision,
+ occurred_from: row.occurredFrom,
+ occurred_to: row.occurredTo,
+ event_kind: row.eventKind,
+ summary: row.summary,
+ }));
+}
+
+function snakeDossier(overrides: Parameters[0] = {}) {
+ return dossierFixture({
+ evidence: snakeEvidence(PERSIST_EVIDENCE),
+ evidenceCount: PERSIST_EVIDENCE.length,
+ stage: "minute",
+ candidateRange: { start_time: "04:48", end_time: "05:07" },
+ latestResult: candidateSnapshotFixture({
+ selectionAllowed: true,
+ representativeTime: "04:53",
+ candidates: [
+ { candidate_id: "04:53", rank: 1, time: "04:53", relative_support: 16 },
+ { candidate_id: "05:06", rank: 2, time: "05:06", relative_support: 15 },
+ ],
+ decisionReceipt: {
+ inference_state: emptyInference(),
+ prospective_probes: [],
+ precision_stage: { current: "d9_refine" },
+ discriminating_event_probes: [],
+ window_scan: WINDOW_SCAN,
+ },
+ }),
+ conversationSummary: conversationSummaryFixture({
+ activeFocus: activeFocusFixture({
+ expectedAnswerSchema: {
+ prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
+ probe_id: "p-cd",
+ semantic_key: "career.2015",
+ scoring: true,
+ choice: {
+ prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
+ option_a: "是,大概就在那段时间",
+ option_b: "有类似,但年份不对或不够重大",
+ option_c: "没有明显发生",
+ option_d: "不记得 / 不确定",
+ options: [
+ { key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
+ { key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" },
+ { key: "C", label: "没有明显发生", answer_class: "no" },
+ { key: "D", label: "不记得 / 不确定", answer_class: "unsure" },
+ ],
+ },
+ },
+ }),
+ }),
+ turns: [
+ {
+ id: TURN_ID,
+ role: "assistant",
+ text: "已记录,范围收到 04:48–05:07。",
+ status: "completed",
+ },
+ ],
+ ...overrides,
+ });
+}
+
+function camelDossier() {
+ return {
+ evidence: PERSIST_EVIDENCE,
+ conversationSummary: {
+ activeFocus: null,
+ declinedSkippedTopics: [],
+ },
+ latestResult: {
+ resultId: "55555555-5555-4555-8555-555555555555",
+ decisionReceipt: {
+ inference_state: emptyInference(),
+ prospective_probes: [],
+ precision_stage: { current: "d9_refine" },
+ discriminating_event_probes: [],
+ window_scan: WINDOW_SCAN,
+ },
+ selectionAllowed: true,
+ candidates: [
+ { time: "04:53", rank: 1, relativeSupport: 16 },
+ { time: "05:06", rank: 2, relativeSupport: 15 },
+ ],
+ },
+ case: {
+ acceptedTime: null,
+ status: "collecting_evidence",
+ candidateRange: { start_time: "04:48", end_time: "05:07" },
+ stage: "minute" as const,
+ },
+ turns: [
+ { role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" },
+ ],
+ };
+}
+
+test("empty probe pool cannot stamp a scoring distinguish schema", () => {
+ const followup = distinguishFollowup();
+ const schema = expectedAnswerSchemaFor(
+ followup.choice_frame!,
+ stableFollowupQuestionId(followup),
+ { inference_state: emptyInference() },
+ followup,
+ );
+ assert.equal(schema, null);
+});
+
+test("unstampable distinguish persist is invalid_choice_schema", async () => {
+ const accounting = focusAccounting();
+ const result = await persistServerOwnedFocus({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ activeFocus: null,
+ decisionReceipt: { inference_state: emptyInference() },
+ followup: distinguishFollowup(),
+ });
+ assert.equal(result.status, "invalid_choice_schema");
+});
+
+test("persistNextInterviewAfterChoice does not speak an unstampable distinguish stem", async () => {
+ const followup = distinguishFollowup();
+ const stem = followup.choice_frame?.prompt ?? STEM;
+ const accounting = focusAccounting();
+ const dossier = camelDossier();
+ const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
+ const first = await persistNextInterviewAfterChoice({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ dossier,
+ decisionState: emptyInference() as never,
+ nextAction: publicNextAction(decision),
+ decision,
+ birthDate: "1997-08-08",
+ skipRefresh: true,
+ followup,
+ });
+ assert.equal(first.hostNarration.includes(stem), false, first.hostNarration);
+ assert.notEqual(first.followup?.intent, "distinguish_candidates");
+ assert.ok(
+ first.choiceReady === true || first.followup?.intent === "collect_method_evidence",
+ JSON.stringify({ choiceReady: first.choiceReady, intent: first.followup?.intent, host: first.hostNarration }),
+ );
+ const second = await persistNextInterviewAfterChoice({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ dossier: {
+ ...dossier,
+ turns: [{ role: "assistant" as const, text: stem }],
+ },
+ decisionState: emptyInference() as never,
+ nextAction: publicNextAction(decision),
+ decision,
+ birthDate: "1997-08-08",
+ skipRefresh: true,
+ followup,
+ });
+ assert.equal(second.hostNarration.includes(stem), false, second.hostNarration);
+});
+
+test("collect stem already in the last assistant turn is not spoken again", async () => {
+ const stem = "请再补充一件带年月的搬家或长期住到外地的经历。";
+ const followup: MethodFollowup = {
+ method_id: "dasha_events",
+ intent: "collect_method_evidence",
+ ask_theme: "dated_event",
+ domain: "relocation",
+ kind_hint: "home_change",
+ user_prompt_hint: "ask",
+ must_not_label: false,
+ choice_frame: null,
+ spoken_prompt: stem,
+ source: "method_coverage",
+ };
+ const accounting = focusAccounting();
+ const dossier = {
+ ...camelDossier(),
+ turns: [{ role: "assistant" as const, text: `记下了。\n\n${stem}` }],
+ };
+ const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
+ const result = await persistNextInterviewAfterChoice({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ dossier,
+ decisionState: emptyInference() as never,
+ nextAction: publicNextAction(decision),
+ decision,
+ birthDate: "1997-08-08",
+ skipRefresh: true,
+ followup,
+ });
+ assert.equal(result.hostNarration.includes(stem), false, result.hostNarration);
+});
+
+test("applyRectificationChoice narration does not re-ask an unstampable distinguish stem", async () => {
+ const followup = distinguishFollowup();
+ const stem = followup.choice_frame?.prompt ?? STEM;
+ const accounting = focusAccounting();
+ const applied = await applyRectificationChoice(accounting.client, {
+ userId: USER_ID,
+ caseId: CASE_ID,
+ sessionId: SESSION_ID,
+ actionId: ACTION_ID,
+ action: CHOICE_ACTION,
+ focusId: FOCUS_ID,
+ questionId: "question-1",
+ probeId: "p-cd",
+ optionId: "C",
+ expectedRevision: 6,
+ });
+ assert.equal(applied.applied, true);
+ assert.equal(applied.narration.includes(stem), false, applied.narration);
+ const nextMeaning = JSON.stringify(applied.nextUserAction ?? {});
+ assert.equal(nextMeaning.includes(stem), false, nextMeaning);
+});
+
+test("active choice focus without asked_turn_id hangs on the last assistant turn", () => {
+ const prompt = "结过婚或订过婚吗?";
+ const attached = attachQuestionsToTurns(
+ [
+ { id: TURN_ID, role: "assistant" as const, text: "已记录,范围收到 04:48–05:07。" },
+ ],
+ [{
+ id: FOCUS_ID,
+ caseId: CASE_ID,
+ questionId: "collect:targeted:relationship",
+ intent: "collect_method_evidence",
+ targetEvidenceId: null,
+ targetDomain: "relationship",
+ targetKind: "targeted:relationship",
+ expectedAnswerSchema: {
+ targeted_collect: true,
+ prompt,
+ choice: {
+ prompt,
+ option_a: "有过这件事",
+ option_b: "没有发生过",
+ option_c: "记不太清楚",
+ option_d: "这条先跳过",
+ options: [
+ { key: "A", label: "有过这件事", answer_class: "yes" },
+ { key: "B", label: "没有发生过", answer_class: "no" },
+ { key: "C", label: "记不太清楚", answer_class: "unsure" },
+ { key: "D", label: "这条先跳过", answer_class: "weak_yes" },
+ ],
+ },
+ },
+ status: "active",
+ askedAt: "2026-09-14T00:00:00.000Z",
+ resolvedAt: null,
+ askedTurnId: null,
+ answerOption: null,
+ }],
+ );
+ assert.equal(attached[0]?.question?.kind, "choice");
+ assert.equal(attached[0]?.question?.focus_id, FOCUS_ID);
+ assert.equal(attached[0]?.question?.options?.length, 4);
+ assert.equal(attached[0]?.question?.prompt, prompt);
+});
+
+test("persisted question with a live choice card is not the spoken-only surface", () => {
+ assert.equal(
+ persistedQuestionSurface({
+ questionKind: "choice",
+ hasChoiceCard: true,
+ questionPersisted: true,
+ }),
+ "choice_card",
+ );
+ assert.equal(
+ persistedQuestionSurface({
+ questionKind: "collect_spoken",
+ hasChoiceCard: false,
+ questionPersisted: true,
+ }),
+ "spoken",
+ );
+ assert.equal(
+ persistedQuestionSurface({
+ questionKind: "choice",
+ hasChoiceCard: true,
+ questionPersisted: false,
+ }),
+ "none",
+ );
+});
+
+test("representative time in eliminated ids or outside the range warns once each", () => {
+ const lines: string[] = [];
+ const original = console.warn;
+ console.warn = (value: unknown) => {
+ lines.push(String(value));
+ };
+ try {
+ const eliminated = warnRepresentativeTimeInconsistency({
+ representativeTime: "05:14",
+ eliminatedIds: ["05:14", "05:08"],
+ credibleRange: ["04:48", "05:07"],
+ winnerId: "04:53",
+ scoresAfter: { "04:53": 16, "05:14": 7 },
+ });
+ assert.equal(eliminated.ok, false);
+ assert.equal(eliminated.inEliminated, true);
+ const outside = warnRepresentativeTimeInconsistency({
+ representativeTime: "05:14",
+ eliminatedIds: [],
+ credibleRange: ["04:48", "05:07"],
+ winnerId: "04:53",
+ scoresAfter: { "04:53": 16, "05:14": 7 },
+ });
+ assert.equal(outside.ok, false);
+ assert.equal(outside.outsideRange, true);
+ const healthy = warnRepresentativeTimeInconsistency({
+ representativeTime: "04:53",
+ eliminatedIds: ["05:14"],
+ credibleRange: ["04:48", "05:07"],
+ winnerId: "04:53",
+ scoresAfter: { "04:53": 16, "05:14": 7 },
+ });
+ assert.equal(healthy.ok, true);
+ assert.equal(inspectRepresentativeTime({
+ representativeTime: "04:53",
+ eliminatedIds: ["05:14"],
+ credibleRange: ["04:48", "05:07"],
+ }).ok, true);
+ } finally {
+ console.warn = original;
+ }
+ assert.equal(lines.filter((line) => line.includes("rectification_representative_time_inconsistent")).length, 2);
+ assert.match(lines[0] ?? "", /"representative_time":"05:14"/);
+ assert.doesNotMatch(lines.join("\n"), /birth|email|jwt/i);
+});