diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6943f05f..2892a92d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
+## 2026-09-14 — 并列到顶时给出区间卡,不再说「没有拿到下一个问题」
+
+生时校正里,几个候选打成平手、再问也分不开时,会给出区间和代表分钟的交付卡,不会只念结论却不出卡,也不会印「没有拿到下一个问题」。刷新尝试如果没写进库,不会假装已经试过。Skill 版本不变。
+
+
## 2026-09-14 — 口述追问挂回助手消息;同屏只写一个范围;引擎代表时间不进模型上下文
生时校正里,定向补事答「有过这件事」之后的「大概哪年几月?」会跟选择题一样出现在助手消息里,带头像,不再是时间轴下面一行裸题。采集旁白「现在还剩 X–Y」与时间轴「目前范围」用同一段可信区间。已被淘汰的引擎代表分钟不会再把宫位表送给模型。Skill 版本不变。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index a1a0cac2..e15ba23f 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -10580,3 +10580,51 @@
- 相关记录:BUG-678、BUG-635、BUG-677
- 复发自:BUG-678(DESIGN 与合同测试分叉)
- 修复版本:待发布
+
+## BUG-680 | 刷新尝试落库失败仍按已尝试推进,POST 交付 GET 回采集
+
+- 状态:resolved
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`refreshDatedDiscriminatorPoolIfNeeded`、`persistRefreshAttempt`、`decideRectification`、`shouldSkipFollowupPersist`
+- 用户现象:跳过最后一道定向补事题后,助手播出完整交付结论,刷新后没有交付卡、没有下一题,时间轴写「再说一件带年月的事就能继续」,并印出「没有拿到下一个问题。」
+- 触发条件:前两名 posterior 并列(如 16/16),`stop_reason=tied_first`;本轮选择题刚推进 revision,刷新尝试用旧 revision 写库撞版本冲突。
+- 根因:`persistRefreshAttempt` 失败时返回值被丢弃,`attemptRecorded` 仍为 true,POST 第二次 `decideFromDossier` 把 `refreshExhausted` 当成真并交付;GET 读库没有这条 attempt,`stillNeedNarrowing` 为真,把 `tied_first` 压回 `collect_evidence`。并列到顶本是终局,被「还有收窄手段」无条件压过。
+- 修复:落库失败时 `attemptRecorded=false`,不把内存态写进 dossier,并打 `rectification_refresh_attempt_persist_failed`。`tied_first` 在 `stillNeedNarrowing` / coverageBlocks 之前直接 `completeWithRange`。idle persist 在 `tied_first` 且 outcome 属于交付集合时跳过下一问。不放宽 `can_adopt`。
+- 验证:`frontend/tests/rectification-delivery-vs-collect-20260914.test.ts`:persist 抛错后 attemptRecorded=false;同一份并列 dossier 上 `persistNextInterviewIfIdle` 与 `decideFromDossier` 都交付。
+- 防复发:任何「是否已尝试/已耗尽」的标志,落库失败时一律按未完成处理。POST 与 GET 对同一状态的决策必须一致,并由对拍测试守着。不得靠内存 overlay 播交付。
+- 相关记录:BUG-674、BUG-681、BUG-682
+- 复发自:BUG-674(POST 与 GET 对同一状态判定不一致)
+- 修复版本:待发布
+
+## BUG-681 | 交付话术能说、交付卡不能画
+
+- 状态:resolved
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`DELIVERY_OUTCOMES`、`deliveryNarrationAllowed`、`canShowRectificationSelectionCards`、`canShowRectificationReadonlyRange`
+- 用户现象:正文已经念完「现在给的范围是…代表分钟为…」,界面没有区间交付卡。
+- 触发条件:`session_outcome` 为 `completed_with_range` 或 `provisional_range`;`can_adopt` 为假。
+- 根因:卡片闸门用 `ADOPT_OUTCOMES` 且要求 `canAdopt`。这两种正常收尾不在采用集合里。话术闸门认 `offer_provisional_range` / 公开采用,于是能说不能画。
+- 修复:新增 `DELIVERY_OUTCOMES` = 采用集合 ∪ `{completed_with_range, provisional_range}`。卡片闸门用它,不把 `can_adopt` 提权。交付 outcome 且 `selection_allowed` 时出卡、不出「再说一件」范围行。
+- 验证:`rectification-delivery-vs-collect-20260914.test.ts`、`rectification-candidate-result.test.ts` 表驱动:话术为真则卡为真。
+- 防复发:不得新增第二套交付卡。话术与卡必须由同一判据驱动。
+- 相关记录:BUG-680、BUG-590
+- 复发自:无
+- 修复版本:待发布
+
+## BUG-682 | 已交付无题时缺口状态机兜底成「没有拿到下一个问题」
+
+- 状态:resolved
+- 首次发现:2026-09-14
+- 最近更新:2026-09-14
+- 影响面:`rectificationQuestionGapState`、`interviewDeliveredGap`、`rectification-agentic-chat.tsx`
+- 用户现象:没有题、没有卡时印出「没有拿到下一个问题。」并出现「接着问」。
+- 触发条件:`current_question === null` 且 `session_outcome=collect_evidence`、`stop_reason=tied_first`(或已交付 outcome);不是 collect_waiting 的那几个不足理由。
+- 根因:缺口状态机没有「已交付 / 没有更多可问」终态,重试耗尽后落到 `unavailable`。
+- 修复:新增 `delivered`:无题且 outcome 属于交付集合,或 `stop_reason` 为 `tied_first` / `user_uncertainty_too_high`。该状态显示出口说明,禁止「没有拿到下一个问题」。普通题没取到仍是 `unavailable`。
+- 验证:`rectification-delivery-vs-collect-20260914.test.ts`、`rectification-surface-state.test.ts`。
+- 防复发:已交付无题不得再走修复入口文案。不得把 `delivered` 误伤真正缺题的 `unavailable`。
+- 相关记录:BUG-680、BUG-681
+- 复发自:无
+- 修复版本:待发布
diff --git a/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md b/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md
new file mode 100644
index 00000000..02e8bffe
--- /dev/null
+++ b/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md
@@ -0,0 +1,23 @@
+# 进度 · 播了交付却回到采集态(2026-09-14)
+
+## 范围
+
+- 分支:`codex/rectification-delivery-vs-collect-20260914`(基于 `origin/staging` @ `e59ef9fb`)
+- 任务单:`docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md`
+- BUG-680 / 681 / 682 resolved
+- 任务 1/2/3 均做完,没有走让步
+
+## 完成
+
+- T1:`persistRefreshAttempt` 失败时 `attemptRecorded=false`,不 overlay 内存态。`tied_first` 提到 `stillNeedNarrowing` 与 coverageBlocks 之前。idle persist 在并列到顶时跳过下一问。
+- T2:`DELIVERY_OUTCOMES`;卡片闸门用它;交付 outcome 且可选择时出卡、不出采集范围行。未提权 `can_adopt`。
+- T3:缺口状态 `delivered`,文案「再问下去也分不开了。范围在上面,对不上可以改选。」普通缺题仍 `unavailable`。
+- T4:BUG_HISTORY、CHANGELOG、真机清单、DESIGN。
+
+## 验收数字
+
+- `./node_modules/.bin/tsc --noEmit`:0 错
+- `npm run lint`:0 error / 120 warning(既有,未顺手改)
+- 相关单测:`rectification-delivery-vs-collect-20260914.test.ts` 6/6;candidate-result / surface-state / superseded-focus / unwritten-evidence 之外的定向套件 53/53;probe-pool-exhausted 10/10;convergence-budget 14/14
+- 全量 `npm test`:2996 / pass 2829 / fail 153 / skip 14。失败条数与本机 Windows 基线一致(spoken-orphan 轮次 2989/2822/153/14;本轮多 7 条新测全部通过)。153 条是 CRLF、Skill SHA、Docker/DB、`G:\\G:\\` 双盘符、无 symlink 权限,不是本单回归。Linux 门禁基线仍是任务书写的 27 条缺口。
+- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction(`points out of the filesystem root`)。未在本树 `npm ci`(避免再占一份依赖)。Linux 门禁会在树内安装后构建。未测 `/` Static 与首屏 gzip。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 4a9d225d..51a9a147 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-delivery-vs-collect-split-20260914.md` | `PROGRESS-rectification-delivery-vs-collect-20260914.md` | **P0**:并列到顶(tied_first)时 POST 播了交付结论、GET 又算回 collect_evidence——刷新尝试落库失败仍回报「已尝试」+ `stillNeedNarrowing` 压过 exhausted(BUG-680);交付话术闸门与交付卡闸门不一致,`completed_with_range`/`provisional_range` 能说不能画(BUG-681);缺口状态机没有终态,兜底印「没有拿到下一个问题」(BUG-682) | 待执行 | `codex/rectification-delivery-vs-collect-20260914` |
+| `TASK-rectification-delivery-vs-collect-split-20260914.md` | `PROGRESS-rectification-delivery-vs-collect-20260914.md` | **P0**:并列到顶(tied_first)时 POST 播了交付结论、GET 又算回 collect_evidence——刷新尝试落库失败仍回报「已尝试」+ `stillNeedNarrowing` 压过 exhausted(BUG-680);交付话术闸门与交付卡闸门不一致,`completed_with_range`/`provisional_range` 能说不能画(BUG-681);缺口状态机没有终态,兜底印「没有拿到下一个问题」(BUG-682) | 待验收 | `codex/rectification-delivery-vs-collect-20260914` |
| `TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` | `PROGRESS-rectification-spoken-orphan-20260914.md` | **P0**:口述题(定向补事年份追问)没有 `askedTurnId` 时仍是无头像裸行——BUG-675 的挂回规则只覆盖选择题(BUG-678);同屏两个范围口径(旁白用活跃候选首尾、时间轴用 credible_range);引擎 `representative_time` 把已淘汰分钟的宫位表带进模型上下文(BUG-676 收敛为 resolved) | 待验收 | `codex/rectification-spoken-orphan-20260914` |
diff --git a/docs/testing/rectification-delivery-vs-collect-20260914.md b/docs/testing/rectification-delivery-vs-collect-20260914.md
new file mode 100644
index 00000000..397d2651
--- /dev/null
+++ b/docs/testing/rectification-delivery-vs-collect-20260914.md
@@ -0,0 +1,16 @@
+# 并列到顶交付 vs 采集回退:真机清单(2026-09-14)
+
+分支 `codex/rectification-delivery-vs-collect-20260914`。本环境无 staging 登录态。
+
+## BUG-680 / 681
+
+进入当时那个校正会话,或再走一遍「定向补事问到家人并跳过」。
+
+- [ ] 助手念完交付结论后,同一屏出现区间交付卡(范围、代表分钟、可点「更像这个」若允许选择)。
+- [ ] 不会只剩「目前范围 …,再说一件带年月的事就能继续」而没有卡。
+- [ ] 同屏不会既出交付卡又出「再说一件带年月的事就能继续」。
+
+## BUG-682
+
+- [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。
+- [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 7a1a8b8e..ef362936 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -231,6 +231,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled |
| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” |
+| `question-gap`, delivered | no current question and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a “还能再收窄” caption under the title, then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold | enabled |
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 4e747421..ba980cfc 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -58,6 +58,7 @@ import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
+ RECTIFICATION_DELIVERED_COPY,
RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState,
@@ -1581,6 +1582,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
collectWaiting: collectWaiting && !deadChoice,
+ sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
+ stopReason: interviewStopReason,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1898,6 +1901,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
+ {questionGap === "delivered" && !showSelectionCards && (
+
+ {RECTIFICATION_DELIVERED_COPY}
+
+ )}
{questionGap === "unavailable" && (
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts
index 03c2837c..b69a8d27 100644
--- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts
+++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts
@@ -130,12 +130,25 @@ export const ADOPT_OUTCOMES: ReadonlySet = new Set([
"validated_range",
]);
+/** Range-delivery card and delivery copy share this set. ADOPT_OUTCOMES stays the adopt gate. */
+export const DELIVERY_OUTCOMES: ReadonlySet = new Set([
+ ...ADOPT_OUTCOMES,
+ "completed_with_range",
+ "provisional_range",
+]);
+
export function sessionOutcomeAllowsAdopt(
outcome: DecisionSessionOutcome | string | null | undefined,
): outcome is DecisionSessionOutcome {
return typeof outcome === "string" && ADOPT_OUTCOMES.has(outcome as DecisionSessionOutcome);
}
+export function sessionOutcomeAllowsDelivery(
+ outcome: DecisionSessionOutcome | string | null | undefined,
+): outcome is DecisionSessionOutcome {
+ return typeof outcome === "string" && DELIVERY_OUTCOMES.has(outcome as DecisionSessionOutcome);
+}
+
export function publicCanAdopt(decision: Pick): boolean {
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
}
@@ -151,7 +164,8 @@ export function deliveryNarrationAllowed(
): boolean {
if (publicCanAdopt(decision)) return true;
const action = nextAction ?? decision.nextAction;
- return decision.selectionAllowed === true && action === "offer_provisional_range";
+ if (decision.selectionAllowed === true && action === "offer_provisional_range") return true;
+ return decision.selectionAllowed === true && sessionOutcomeAllowsDelivery(decision.sessionOutcome);
}
export type CompletionStatus =
@@ -317,6 +331,14 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
return collect(separation, holdout, range, probe, capability, stopReason);
}
+ if (
+ stopClass?.kind === "exhausted"
+ && stopClass.reason === "tied_first"
+ && input.trainingGateOpen !== false
+ && separation.ranked.length > 0
+ ) {
+ return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
+ }
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
|| input.engineCeiling.proposeAllowed;
diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
index af543307..75a353fb 100644
--- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
+++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
@@ -15,6 +15,7 @@ import {
nonConvergingRangeNarration,
publicCanAdopt,
publicNextAction,
+ sessionOutcomeAllowsDelivery,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import {
@@ -272,6 +273,12 @@ function shouldSkipFollowupPersist(input: {
declinedTopics?: readonly Readonly>[];
}): boolean {
if (input.accepted) return false;
+ if (
+ input.stopReason === "tied_first"
+ && sessionOutcomeAllowsDelivery(input.sessionOutcome)
+ ) {
+ return true;
+ }
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
diff --git a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts
index 1b8928a6..a58946d8 100644
--- a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts
+++ b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts
@@ -565,7 +565,7 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
{ ...state, revision: state.revision + 1 },
"no_new_probes",
);
- await persistRefreshAttempt({
+ const recorded = await persistRefreshAttempt({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -573,6 +573,20 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
previous: state,
next: attempted,
});
+ if (!recorded) {
+ console.warn(JSON.stringify({
+ event: "rectification_refresh_attempt_persist_failed",
+ case_id: input.caseId,
+ candidate_set_id: state.candidate_set_id,
+ answer_count: state.answered_probes.length,
+ }));
+ return {
+ dossier: input.dossier,
+ state,
+ refreshed: false,
+ attemptRecorded: false,
+ };
+ }
return {
dossier: applyRefreshedProbesToDossier(input.dossier, attempted),
state: attempted,
@@ -597,11 +611,17 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
askableCount: askable.length,
});
if (!written) {
+ console.warn(JSON.stringify({
+ event: "rectification_refresh_attempt_persist_failed",
+ case_id: input.caseId,
+ candidate_set_id: state.candidate_set_id,
+ answer_count: state.answered_probes.length,
+ }));
return {
- dossier: applyRefreshedProbesToDossier(input.dossier, nextState),
- state: nextState,
+ dossier: input.dossier,
+ state,
refreshed: false,
- attemptRecorded: true,
+ attemptRecorded: false,
};
}
const persistableEventProbes = result.eventProbes.filter((probe) => {
diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts
index b09556ac..4ecdc103 100644
--- a/frontend/src/lib/rectification-candidate-result.ts
+++ b/frontend/src/lib/rectification-candidate-result.ts
@@ -22,6 +22,7 @@ import { MIN_SEPARATION_LEAD } from "./rectification-agentic/core/candidate-sepa
import {
engineCapabilityCeilingFromReceipt,
sessionOutcomeAllowsAdopt,
+ sessionOutcomeAllowsDelivery,
type DecisionSessionOutcome,
} from "./rectification-agentic/core/rectification-decision";
import {
@@ -426,8 +427,11 @@ export function canRenderRectificationSelectionCards(
export function canShowRectificationSelectionCards(
result: RectificationCandidateResult | null,
): boolean {
- return canRenderRectificationSelectionCards(result)
- && sessionOutcomeAllowsAdopt(result?.sessionOutcome);
+ if (!result || !sessionOutcomeAllowsDelivery(result.sessionOutcome)) return false;
+ if (sessionOutcomeAllowsAdopt(result.sessionOutcome)) {
+ return canRenderRectificationSelectionCards(result);
+ }
+ return result.selectionAllowed === true;
}
export function canShowRectificationReadonlyRange(
@@ -435,6 +439,9 @@ export function canShowRectificationReadonlyRange(
): boolean {
if (!result?.credibleRange) return false;
const outcome = result.sessionOutcome;
+ if (sessionOutcomeAllowsDelivery(outcome)) {
+ return result.selectionAllowed !== true;
+ }
return outcome === "collect_evidence" || outcome === "discriminate_candidates";
}
diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts
index 1bdb746f..385f3610 100644
--- a/frontend/src/lib/rectification-surface-state.ts
+++ b/frontend/src/lib/rectification-surface-state.ts
@@ -10,6 +10,7 @@
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
+import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts";
/**
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
@@ -30,6 +31,7 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事";
+export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
@@ -269,7 +271,8 @@ export type RectificationQuestionGapState =
| "unavailable"
| "verified_idle"
| "persisted_question"
- | "collect_waiting";
+ | "collect_waiting"
+ | "delivered";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -289,6 +292,8 @@ export type RectificationQuestionGapInput = Readonly<{
questionPersisted?: boolean;
/** Training gate still closed; pool empty; keep the case open for another dated event. */
collectWaiting?: boolean;
+ sessionOutcome?: string | null;
+ stopReason?: string | null;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -313,6 +318,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
+ if (interviewDeliveredGap(input)) return "delivered";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
@@ -378,6 +384,16 @@ export function interviewChoiceCardUnavailable(input: Readonly<{
return false;
}
+export function interviewDeliveredGap(input: Readonly<{
+ questionMissing: boolean;
+ sessionOutcome?: string | null;
+ stopReason?: string | null;
+}>): boolean {
+ if (!input.questionMissing) return false;
+ if (sessionOutcomeAllowsDelivery(input.sessionOutcome)) return true;
+ return input.stopReason === "tied_first" || input.stopReason === "user_uncertainty_too_high";
+}
+
export function interviewCollectWaiting(input: Readonly<{
stopReason?: string | null;
sessionOutcome?: string | null;
diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts
index 41b13270..a60efb97 100644
--- a/frontend/tests/rectification-candidate-result.test.ts
+++ b/frontend/tests/rectification-candidate-result.test.ts
@@ -305,6 +305,19 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () =
});
assert.equal(canShowRectificationSelectionCards(adopted), true);
assert.equal(canShowRectificationReadonlyRange(adopted), false);
+ const delivered = parseRectificationCandidateResult({
+ ...camelCaseSnapshot,
+ session_outcome: "completed_with_range",
+ canAdopt: false,
+ can_adopt: false,
+ selectionAllowed: true,
+ credible_range: ["04:48", "05:07"],
+ });
+ // 原值: completed_with_range 且 canAdopt=false 不出卡、只出「再说一件」
+ // 新值: 出交付卡,不出采集范围行
+ // 原因: BUG-681 能说就能画
+ assert.equal(canShowRectificationSelectionCards(delivered), true);
+ assert.equal(canShowRectificationReadonlyRange(delivered), false);
});
test("parses inference marks from decisionReceipt.inference_state.candidates", () => {
diff --git a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts
new file mode 100644
index 00000000..e7a0aa7e
--- /dev/null
+++ b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts
@@ -0,0 +1,420 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ DELIVERY_OUTCOMES,
+ decideRectification,
+ deliveryNarrationAllowed,
+ sessionOutcomeAllowsDelivery,
+ type DecisionSessionOutcome,
+} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
+import {
+ canShowRectificationReadonlyRange,
+ canShowRectificationSelectionCards,
+ parseRectificationCandidateResult,
+} from "../src/lib/rectification-candidate-result.ts";
+import {
+ persistNextInterviewIfIdle,
+} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
+import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
+import {
+ refreshDatedDiscriminatorPoolIfNeeded,
+ resetRefreshDiscriminatorProbesForTests,
+ setRefreshDiscriminatorProbesForTests,
+} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
+import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
+import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
+import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
+import {
+ RECTIFICATION_DELIVERED_COPY,
+ RECTIFICATION_QUESTION_RETRY_LIMIT,
+ RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
+ rectificationQuestionGapState,
+} from "../src/lib/rectification-surface-state.ts";
+import {
+ CASE_ID,
+ OPEN_ENGINE_CAPABILITY_CEILING,
+ TURN_ID,
+ USER_ID,
+ candidateSnapshotFixture,
+ computeFixture,
+ dossierFixture,
+ fakeAccounting,
+ receiptHandlers,
+} from "./rectification-v9-test-support.ts";
+
+const TIED_CANDIDATES = [
+ { candidateId: "88888888-8888-4888-8888-888888888881", time: "04:53", rank: 1, relativeSupport: 16 },
+ { candidateId: "88888888-8888-4888-8888-888888888882", time: "05:00", rank: 2, relativeSupport: 16 },
+ { candidateId: "88888888-8888-4888-8888-888888888883", time: "05:06", rank: 3, relativeSupport: 15 },
+ { candidateId: "88888888-8888-4888-8888-888888888884", time: "04:51", rank: 4, relativeSupport: 14 },
+ { candidateId: "88888888-8888-4888-8888-888888888885", time: "04:59", rank: 5, relativeSupport: 13 },
+] as const;
+
+const SNAPSHOT_BASE = {
+ resultId: "11111111-1111-4111-8111-111111111111",
+ candidates: [
+ { candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 },
+ { candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 },
+ ],
+ overallConfidence: "medium" as const,
+ selectionAllowed: true,
+ canAdopt: false,
+ confirmationAllowed: false,
+ representativeTime: "04:53",
+ selectedTime: null,
+ selectionKind: null,
+ credibleRange: ["04:48", "05:07"] as const,
+ decisionReceipt: {
+ display_allowed: true,
+ selection_allowed: true,
+ acceptance_allowed: true,
+ propose_allowed: true,
+ confirmation_allowed: false,
+ accept_allowed: true,
+ confirm_allowed: false,
+ },
+};
+
+function datedEvidenceRpc() {
+ return [
+ {
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
+ source_turn_id: TURN_ID,
+ subject: "self",
+ event_kind: "career_entry",
+ domain: "career",
+ occurred_from: "2016-09-01",
+ occurred_to: null,
+ date_precision: "month",
+ summary: "入职",
+ status: "confirmed",
+ supersedes_evidence_id: null,
+ created_at: "2026-08-12T10:00:06.000Z",
+ },
+ {
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
+ source_turn_id: TURN_ID,
+ subject: "self",
+ event_kind: "education_completion",
+ domain: "education",
+ occurred_from: "2012-06-01",
+ occurred_to: null,
+ date_precision: "month",
+ summary: "毕业",
+ status: "confirmed",
+ supersedes_evidence_id: null,
+ created_at: "2026-08-12T10:00:07.000Z",
+ },
+ {
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
+ source_turn_id: TURN_ID,
+ subject: "self",
+ event_kind: "family_event",
+ domain: "family",
+ occurred_from: "2018-03-01",
+ occurred_to: null,
+ date_precision: "month",
+ summary: "家里添丁",
+ status: "confirmed",
+ supersedes_evidence_id: null,
+ created_at: "2026-08-12T10:00:08.000Z",
+ },
+ {
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
+ source_turn_id: TURN_ID,
+ subject: "self",
+ event_kind: "relationship_start",
+ domain: "relationship",
+ occurred_from: "2020-05-01",
+ occurred_to: null,
+ date_precision: "month",
+ summary: "恋爱",
+ status: "confirmed",
+ supersedes_evidence_id: null,
+ created_at: "2026-08-12T10:00:09.000Z",
+ },
+ ];
+}
+
+function tiedInference(extra: Record = {}) {
+ const times = TIED_CANDIDATES.map((item) => item.time);
+ return {
+ algorithm_version: INFERENCE_ALGORITHM_VERSION,
+ candidate_set_id: candidateSetId("04:48", "05:07", times),
+ revision: 6,
+ phase: "discrimination",
+ result_status: "discriminating",
+ range_start: "04:48",
+ range_end: "05:07",
+ candidates: TIED_CANDIDATES.map((item) => ({
+ id: item.candidateId,
+ time: item.time,
+ cluster_range: ["04:48", "05:07"] as const,
+ prior_score: item.relativeSupport,
+ posterior_score: item.relativeSupport,
+ probability: item.relativeSupport === 16 ? 0.2592219508007118 : item.relativeSupport / 74,
+ status: "active" as const,
+ rank: item.rank,
+ strong_conflict_count: 0,
+ })),
+ events: datedEvidenceRpc().map((item, index) => ({
+ id: item.id,
+ domain: item.domain,
+ year: 2012 + index,
+ precision: "month" as const,
+ usage: "training" as const,
+ })),
+ probes: [],
+ answered_probes: [
+ {
+ probe_id: "probe-1",
+ semantic_key: "career.2016.test",
+ candidate_split_hash: "split-1",
+ answer_class: "yes" as const,
+ classified_from: "choice" as const,
+ },
+ ],
+ rounds: [],
+ entropy: 1,
+ representative_time: "04:53",
+ credible_range: ["04:48", "05:07"] as const,
+ refresh_attempts: [],
+ ...extra,
+ };
+}
+
+function tiedRpcDossier() {
+ const evidence = datedEvidenceRpc();
+ return dossierFixture({
+ evidenceCount: 4,
+ evidence,
+ latestResult: candidateSnapshotFixture({
+ representativeTime: "04:53",
+ selectionAllowed: true,
+ candidates: TIED_CANDIDATES.map((item) => ({
+ candidate_id: item.candidateId,
+ rank: item.rank,
+ time: item.time,
+ relative_support: item.relativeSupport,
+ tied_minute_count: 2,
+ })),
+ decisionReceipt: {
+ acceptance_allowed: true,
+ selection_allowed: true,
+ propose_allowed: true,
+ confirmation_allowed: false,
+ inference_state: tiedInference(),
+ },
+ }),
+ });
+}
+
+test("tied first place delivers a range even when narrowing flags are still open", () => {
+ const decision = decideRectification({
+ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
+ methodCoverageAll: false,
+ trainingGateOpen: true,
+ candidateScores: TIED_CANDIDATES.map((item) => ({ time: item.time, score: item.relativeSupport })),
+ discriminatorProbe: null,
+ holdoutValidation: "unavailable",
+ datedEventCount: 3,
+ datedDomainCount: 3,
+ refreshExhausted: false,
+ targetedCollectExhausted: false,
+ });
+ assert.equal(decision.stopReason, "tied_first");
+ assert.equal(decision.canOfferRange, true);
+ assert.equal(decision.nextAction, "complete_with_range");
+ assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
+});
+
+test("refresh persist failure is not recorded as an attempt", async () => {
+ resetRefreshDiscriminatorProbesForTests();
+ setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
+ state: { ...state, candidates: [] },
+ eventProbes: [],
+ candidateSetId: state.candidate_set_id,
+ refreshCount: 1,
+ }));
+ const rpc = tiedRpcDossier();
+ const dossier = parseV9CaseDossier(rpc);
+ assert.ok(dossier);
+ const warnings: string[] = [];
+ const original = console.warn;
+ console.warn = (value: unknown) => {
+ warnings.push(String(value));
+ };
+ try {
+ const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
+ accounting: fakeAccounting({
+ ...receiptHandlers,
+ get_agentic_rectification_case_compute: () => computeFixture(),
+ append_agentic_rectification_inference_transition: () => {
+ throw new Error("candidate_state_inconsistent");
+ },
+ }).client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ dossier: dossier!,
+ hasDatedProbe: false,
+ });
+ assert.equal(refreshed.attemptRecorded, false);
+ assert.equal(refreshed.refreshed, false);
+ assert.ok(warnings.some((line) => line.includes("rectification_refresh_attempt_persist_failed")));
+ const before = decideFromDossier(dossier!, { birthDate: "1997-08-08" });
+ const after = decideFromDossier(refreshed.dossier, { birthDate: "1997-08-08" });
+ assert.equal(after.sessionOutcome, before.sessionOutcome);
+ assert.equal(after.canOfferRange, before.canOfferRange);
+ assert.equal(after.stopReason, before.stopReason);
+ } finally {
+ console.warn = original;
+ resetRefreshDiscriminatorProbesForTests();
+ }
+});
+
+test("POST idle persist and GET decideFromDossier agree on a first-place tie", async () => {
+ const rpc = tiedRpcDossier();
+ const dossier = parseV9CaseDossier(rpc);
+ assert.ok(dossier);
+ const getDecision = decideFromDossier(dossier!, { birthDate: "1997-08-08", snapshotCurrent: true });
+ const accounting = fakeAccounting({
+ ...receiptHandlers,
+ get_agentic_rectification_case_dossier: () => rpc,
+ get_agentic_rectification_case_compute: () => computeFixture(),
+ append_agentic_rectification_inference_transition: () => {
+ throw new Error("candidate_state_inconsistent");
+ },
+ set_agentic_rectification_conversation_focus: (_fn, args) => {
+ throw new Error(`must not persist ${String(args.p_question_id)}`);
+ },
+ append_agentic_rectification_turn: () => ({
+ turn_id: "33333333-3333-4333-8333-333333333333",
+ idempotent: false,
+ }),
+ finalize_agentic_rectification_turn: () => ({
+ turn_id: "33333333-3333-4333-8333-333333333333",
+ status: "completed",
+ idempotent: false,
+ }),
+ get_agentic_rectification_turn_receipt: () => null,
+ });
+ const idle = await persistNextInterviewIfIdle({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ });
+ const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome);
+ const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration);
+ assert.equal(getDecision.stopReason, "tied_first");
+ assert.equal(getDelivers, true);
+ assert.equal(postDelivers, getDelivers);
+});
+
+test("completed_with_range with selection allowed shows the delivery card, not the collect range line", () => {
+ const result = parseRectificationCandidateResult({
+ ...SNAPSHOT_BASE,
+ session_outcome: "completed_with_range",
+ selectionAllowed: true,
+ canAdopt: false,
+ });
+ assert.equal(canShowRectificationSelectionCards(result), true);
+ assert.equal(canShowRectificationReadonlyRange(result), false);
+});
+
+test("delivery narration and delivery cards stay true together for every outcome", () => {
+ const outcomes: DecisionSessionOutcome[] = [
+ "collect_evidence",
+ "compare_blocks",
+ "widen_window",
+ "discriminate_candidates",
+ "validate_holdout",
+ "provisional_range",
+ "provisional_range_user_stopped",
+ "completed_with_range",
+ "validated_range",
+ "exact_minute_confirmed",
+ "adopt_representative",
+ "awaiting_confirmation",
+ ];
+ for (const outcome of outcomes) {
+ const adopt = outcome === "adopt_representative"
+ || outcome === "provisional_range_user_stopped"
+ || outcome === "awaiting_confirmation"
+ || outcome === "validated_range";
+ const nextAction = outcome === "provisional_range"
+ ? "offer_provisional_range"
+ : outcome === "completed_with_range"
+ ? "complete_with_range"
+ : "ask_fact_collection";
+ const narrate = deliveryNarrationAllowed({
+ canAdopt: adopt,
+ sessionOutcome: outcome,
+ nextAction,
+ selectionAllowed: true,
+ });
+ const result = parseRectificationCandidateResult({
+ ...SNAPSHOT_BASE,
+ session_outcome: outcome,
+ canAdopt: adopt,
+ selectionAllowed: true,
+ });
+ const cards = canShowRectificationSelectionCards(result);
+ if (narrate) {
+ assert.equal(cards, true, outcome);
+ }
+ if (DELIVERY_OUTCOMES.has(outcome) && outcome !== "completed_with_range" && outcome !== "provisional_range") {
+ assert.equal(narrate, true, outcome);
+ }
+ }
+});
+
+test("a delivered tie is not the unavailable repair gap", () => {
+ assert.equal(
+ rectificationQuestionGapState({
+ liveQuestionVisible: false,
+ questionMissing: true,
+ questionLoadFailed: false,
+ busy: false,
+ readonly: false,
+ regenerating: false,
+ snapshotLoaded: true,
+ resumableCase: true,
+ retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
+ sessionOutcome: "collect_evidence",
+ stopReason: "tied_first",
+ }),
+ "delivered",
+ );
+ assert.equal(
+ rectificationQuestionGapState({
+ liveQuestionVisible: false,
+ questionMissing: true,
+ questionLoadFailed: false,
+ busy: false,
+ readonly: false,
+ regenerating: false,
+ snapshotLoaded: true,
+ resumableCase: true,
+ retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
+ sessionOutcome: "completed_with_range",
+ }),
+ "delivered",
+ );
+ assert.equal(
+ rectificationQuestionGapState({
+ liveQuestionVisible: false,
+ questionMissing: true,
+ questionLoadFailed: true,
+ busy: false,
+ readonly: false,
+ regenerating: false,
+ snapshotLoaded: true,
+ resumableCase: true,
+ retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
+ }),
+ "unavailable",
+ );
+ assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
+ assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
+});
diff --git a/frontend/tests/rectification-superseded-focus.test.ts b/frontend/tests/rectification-superseded-focus.test.ts
index a79a72a4..b9b346d9 100644
--- a/frontend/tests/rectification-superseded-focus.test.ts
+++ b/frontend/tests/rectification-superseded-focus.test.ts
@@ -240,6 +240,15 @@ test("deliveryNarrationAllowed requires public can_adopt or a selectable range o
nextAction: "offer_provisional_range",
selectionAllowed: false,
}), false);
+ // 原值: completed_with_range 且 canAdopt=false 不能播交付
+ // 新值: selectionAllowed 且 outcome 在 DELIVERY_OUTCOMES 即可播
+ // 原因: BUG-681 能说就能画,completed_with_range / provisional_range 与卡片同真同假
+ assert.equal(deliveryNarrationAllowed({
+ canAdopt: false,
+ sessionOutcome: "completed_with_range",
+ nextAction: "complete_with_range",
+ selectionAllowed: true,
+ }), true);
});
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts
index 45568c61..a941304b 100644
--- a/frontend/tests/rectification-surface-state.test.ts
+++ b/frontend/tests/rectification-surface-state.test.ts
@@ -140,6 +140,26 @@ test("question gap: a persisted focus question is shown instead of preparing", (
assert.equal(rectificationQuestionRetryActive("persisted_question"), false);
});
+test("question gap: a delivered range is not the unavailable repair path", () => {
+ assert.equal(
+ rectificationQuestionGapState({
+ ...gapBase,
+ retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
+ sessionOutcome: "completed_with_range",
+ }),
+ "delivered",
+ );
+ assert.equal(
+ rectificationQuestionGapState({
+ ...gapBase,
+ retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
+ stopReason: "tied_first",
+ }),
+ "delivered",
+ );
+ assert.equal(rectificationQuestionRetryActive("delivered"), false);
+});
+
test("question gap: a choice question without a card is unavailable, not collect waiting", () => {
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true);
assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);