fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Successful in 13m52s

Refresh persist failures no longer count as attempts. tied_first completes with a range before stillNeedNarrowing. Delivery narration and cards share DELIVERY_OUTCOMES. The question gap gets a delivered terminal so the unavailable copy does not appear after a range is given.
This commit is contained in:
jesse-ux
2026-09-14 15:41:04 +08:00
parent e59ef9fbfd
commit d5a8db0a4e
16 changed files with 644 additions and 9 deletions
@@ -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;