fix(rectification): dedupe post-adopt verify and skip this probe only
Independent Staging Quality Gate / validate (push) Successful in 14m31s
Independent Staging Quality Gate / publish (push) Successful in 1m59s

Adopted reverse-verify repeated already-asked collect stems, treated 「这题跳过」 as stopping the case, and promised holdout/OOS checks that never ran. Collect spoken stems no longer prefix a year.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 21:51:59 +08:00
co-authored by Cursor
parent 6b038e6d0d
commit d4627a5019
36 changed files with 1065 additions and 91 deletions
@@ -18,7 +18,7 @@ import {
} from "@/lib/rectification-agentic/v9/answer-choice";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { CHOICE_ACTION, SKIP_PROBE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status";
@@ -82,14 +82,14 @@ const agentRequestSchema = z.object({
caseId: z.string().uuid(),
sessionId: z.string().uuid(),
requestId: z.string().uuid(),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review"]),
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"]),
message: z.string().trim().min(1).max(4000).optional(),
modelId: z.string().trim().min(1).max(64).optional(),
actionId: z.string().uuid().optional(),
focusId: z.string().uuid().optional(),
questionId: z.string().trim().min(1).max(200).optional(),
probeId: z.string().trim().min(1).max(200).nullable().optional(),
optionId: z.enum(["A", "B", "C", "D"]).optional(),
optionId: z.enum(["A", "B", "C", "D", "skip_probe"]).optional(),
expectedRevision: z.number().int().min(0).max(10_000).optional(),
origin: z.enum([
"typed",
@@ -300,11 +300,19 @@ export async function POST(request: Request) {
caseId,
sessionId,
actionId,
action: action === "stop_and_review" ? STOP_ACTION : CHOICE_ACTION,
action: action === "stop_and_review"
? STOP_ACTION
: action === "skip_probe"
? SKIP_PROBE_ACTION
: CHOICE_ACTION,
focusId,
questionId: parsed.data.questionId,
probeId: parsed.data.probeId ?? null,
optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!,
optionId: action === "stop_and_review"
? "stop"
: action === "skip_probe"
? "skip_probe"
: parsed.data.optionId!,
expectedRevision,
narrateAdopt,
});
@@ -327,6 +335,7 @@ export async function POST(request: Request) {
sourceQuote: applied.sourceQuote,
derivedContext: applied.derivedContext,
nextAction: applied.nextAction,
next_user_action: applied.nextUserAction,
nextInterviewPersisted: applied.nextInterviewPersisted,
nextChoiceReady: applied.nextChoiceReady,
});
@@ -600,7 +609,7 @@ export async function POST(request: Request) {
);
return response;
}
if (action === "answer_choice" || action === "stop_and_review") {
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") {
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
@@ -14,7 +14,7 @@ import {
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns } from "@/lib/rectification-agentic/v9/turn-question";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
@@ -147,6 +147,7 @@ export function dossierResponse(
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
question_source: questionSourceFromFocusList(listed),
};
}
@@ -70,12 +70,14 @@ import {
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
isStructuredChoiceUserText,
shouldContinueAfterStructuredChoice,
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
@@ -272,7 +274,7 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[])
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
selected: ChoiceKey | "stop" | "typed",
selected: ChoiceKey | "stop" | "skip_probe" | "typed",
): RenderMessage[] {
return current.map((message) => {
const question = message.question;
@@ -329,6 +331,7 @@ function choiceCardFromQuestion(
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -418,8 +421,14 @@ type CaseSnapshotState = Readonly<{
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
nextUserActionId: string | null;
}>;
function nextUserActionIdFromSnapshot(payload: RectificationCaseSnapshotPayload | null): string | null {
const id = payload?.next_user_action?.id;
return typeof id === "string" && id.trim() ? id.trim() : null;
}
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
@@ -433,6 +442,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
nextUserActionId: nextUserActionIdFromSnapshot(payload),
};
}
@@ -470,6 +480,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [openingRequested, setOpeningRequested] = useState(false);
@@ -628,6 +639,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choice_card?: unknown;
turns?: unknown;
question_source?: unknown;
next_user_action?: { id?: unknown };
case?: {
status?: unknown;
accepted_time?: unknown;
@@ -643,11 +655,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const nextActionId = typeof payload.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
setCandidateResult(nextCandidate);
setCurrentQuestion(nextQuestion);
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
setChoiceCard(nextChoice);
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (confirmedTime) {
setSavedTime(confirmedTime);
@@ -1051,8 +1067,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, []);
const submitStructuredChoice = useCallback(async (
action: typeof CHOICE_ACTION | typeof STOP_ACTION,
optionId: ChoiceKey | "stop",
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION,
optionId: ChoiceKey | "stop" | "skip_probe",
override?: Readonly<{
focusId: string;
questionId: string | null;
@@ -1124,6 +1140,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return;
}
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
const payloadNextActionId = typeof payload?.next_user_action?.id === "string"
? payload.next_user_action.id.trim()
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
@@ -1392,6 +1412,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -1447,6 +1468,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
function submitStop() {
if (!choiceCard) return;
if (choiceCard.skip_this_probe) {
void submitStructuredChoice(SKIP_PROBE_ACTION, "skip_probe");
return;
}
void submitStructuredChoice(STOP_ACTION, "stop");
}
@@ -1622,6 +1647,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</Button>
</div>
)}
{questionGap === "verified_idle" && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_USER_COPY.postAdoptVerifyDone}
</p>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
{savedTime}
@@ -12,7 +12,7 @@ type RectificationChoiceCardProps = Readonly<{
card: ChoiceCard;
pending: boolean;
disabled: boolean;
selectedKey?: ChoiceKey | "stop" | "";
selectedKey?: ChoiceKey | "stop" | "skip_probe" | "";
onSelect: (key: ChoiceKey) => void;
onStop: () => void;
variant?: "card" | "embedded";
@@ -20,7 +20,7 @@ type RectificationChoiceCardProps = Readonly<{
}>;
export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "">("");
const [localSelected, setLocalSelected] = useState<ChoiceKey | "stop" | "skip_probe" | "">("");
const selectedKey = props.selectedKey || localSelected;
const answered = Boolean(selectedKey);
@@ -77,7 +77,7 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
<button
type="button"
className="birth-time-choice-option is-primary"
data-selected={selectedKey === "stop" ? "true" : "false"}
data-selected={selectedKey === "stop" || selectedKey === "skip_probe" ? "true" : "false"}
onClick={stop}
>
{props.card.stop_label}
@@ -77,6 +77,7 @@ export const RECTIFICATION_USER_COPY = {
collectDeclinedAck: "记下了,这方面先跳过。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
postAdoptVerifyDone: "前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
@@ -232,6 +233,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
@@ -20,7 +20,8 @@ import {
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
@@ -120,7 +121,7 @@ export async function generateAdoptNarrationText(
post_adopt_verification: facts.post_adopt_verification.map((item) => ({
kind: item.kind,
domain: item.domain,
year: item.year,
year_label: item.year_label,
})),
}),
}], {
@@ -10,7 +10,8 @@ import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import { reverseVerifyChecksFromProbes, reverseVerifyRemainingForAdopt } from "./method-followup.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import type { DroppedProbe } from "./probe-question-contract.ts";
@@ -33,9 +34,9 @@ export type AdoptCandidateSupport = Readonly<{
}>;
export type AdoptPostAdoptCheck = Readonly<{
kind: "holdout" | "oos";
kind: "reverse_verify";
domain: string;
year: number | null;
year_label: string;
}>;
export type AdoptDeliveryFacts = Readonly<{
@@ -158,21 +159,23 @@ export function adoptDeliveryFacts(
] as const
: null;
const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null);
const holdout = (inference?.events ?? [])
.filter((item) => item.usage === "holdout" && item.year !== null)
.map((item) => ({
kind: "holdout" as const,
domain: item.domain,
year: item.year,
}));
const oos = refinement.oos_blind_prompts.map((item) => ({
kind: "oos" as const,
domain: item.domain,
year: null,
}));
const verification = [...holdout, ...oos];
const askedKeys = askedDiscriminatorKeys(receipt, dossier.evidence);
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: refinement.discriminating_event_probes,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
askedProbeKeys: askedKeys,
});
const verification = reverseVerifyChecksFromProbes(remaining);
const years = uniqueYears([
...holdout.map((item) => item.year),
...remaining.map((item) => item.year),
...verification.flatMap((item) => {
const found: number[] = [];
for (const match of item.year_label.matchAll(FOUR_DIGIT_YEAR)) {
found.push(Number(match[1]));
}
return found;
}),
...(inference?.events ?? []).map((item) => item.year),
]);
const minutes = uniqueClocks([
@@ -209,6 +212,16 @@ export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
if (facts.post_adopt_verification.length === 0) {
return "采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。";
}
const labels = facts.post_adopt_verification.map((item) => (
item.year_label ? `${item.year_label}${item.domain}` : item.domain
));
return `采用后会拿${labels.join("、")}来核对。`;
}
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
@@ -243,6 +256,12 @@ export function validateAdoptNarration(
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
if (
facts.post_adopt_verification.length === 0
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
) {
return { ok: false, reason: "promise" };
}
const minutes = allowedMinuteSet(facts);
for (const token of trimmed.match(CLOCK_TOKEN) ?? []) {
const padded = padClock(token);
@@ -23,6 +23,7 @@ import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCat
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
SKIP_PROBE_ACTION,
STOP_ACTION,
composeChoiceNarration,
focusStatusForAnswer,
@@ -46,9 +47,10 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card";
import {
adoptDeliveryFacts,
templatePostAdoptExplain,
templateStopExplain,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
@@ -57,6 +59,7 @@ import { composeCollectSpokenAssistantText } from "./collect-prompt";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
buildNextUserAction,
exhaustionSpokenCollectFollowup,
GENERIC_COLLECT_QUESTION,
spokenCollectFallbackFollowup,
@@ -105,7 +108,9 @@ function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): bool
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
|| followup.source === "nakshatra_boundary"
|| followup.intent === "distinguish_candidates";
|| followup.source === "reverse_verify"
|| followup.intent === "distinguish_candidates"
|| followup.intent === "reverse_verify";
}
function shouldSkipFollowupPersist(input: {
@@ -113,7 +118,9 @@ function shouldSkipFollowupPersist(input: {
nextAction: string;
followup: MethodFollowup | null;
methods?: readonly MethodCoverage[];
accepted?: boolean;
}): boolean {
if (input.accepted) return false;
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
@@ -133,13 +140,19 @@ function adoptHostNarration(input: {
receipt: Readonly<Record<string, unknown>> | null | undefined;
}): string {
const facts = adoptDeliveryFacts(input.decision, input.dossier);
return withProspectiveWindows(deliveryAdoptNarration({
const base = withProspectiveWindows(deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: input.decision.stopReason ?? null,
stopExplain: templateStopExplain(facts),
}), input.receipt);
if (facts.post_adopt_verification.length > 0) return base;
const explain = templatePostAdoptExplain(facts);
if (base.includes("没有还能核对的前事")) return base;
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${explain} ${RECTIFICATION_USER_COPY.adoptCue}`
.replace(/\s+/g, " ")
.trim();
}
function dossierWithCurrentInference(
@@ -164,7 +177,7 @@ export type ApplyChoiceCommand = Readonly<{
caseId: string;
sessionId: string;
actionId: string;
action: typeof CHOICE_ACTION | typeof STOP_ACTION;
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
focusId: string;
questionId?: string;
probeId?: string | null;
@@ -191,6 +204,7 @@ export type AppliedChoiceReceipt = Readonly<{
narration: string;
userDisplay: string | null;
nextAction: ReturnType<typeof publicNextAction>;
nextUserAction: ReturnType<typeof buildNextUserAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
turnId: string | null;
@@ -200,6 +214,38 @@ function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function markVerifyProbeSkipped(
state: InferenceState,
schema: Readonly<Record<string, unknown>>,
fallbackProbeId: string | null,
): InferenceState {
const probeId = asText(schema.probe_id) ?? fallbackProbeId ?? asText(schema.semantic_key);
const semantic = asText(schema.semantic_key) ?? probeId;
const split = asText(schema.candidate_split_hash) ?? semantic;
if (!probeId || !semantic || !split) return state;
if (state.answered_probes.some((item) => (
item.probe_id === probeId
|| item.semantic_key === semantic
|| item.candidate_split_hash === split
))) {
return state;
}
return {
...state,
revision: state.revision + 1,
answered_probes: [
...state.answered_probes,
{
probe_id: probeId,
semantic_key: semantic,
candidate_split_hash: split,
answer_class: "unsure",
classified_from: "declined",
},
],
};
}
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
@@ -257,11 +303,71 @@ export async function applyRectificationChoice(
previousInferenceFromReceipt(receipt),
refinementFromDecisionReceipt(receipt).nakshatra_boundary,
);
const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && !answerClass) {
const answerClass = optionId === "stop" || optionId === "skip_probe"
? null
: outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && optionId !== "skip_probe" && !answerClass) {
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
}
if (optionId === "skip_probe" || command.action === SKIP_PROBE_ACTION) {
const skippedState = previous ? markVerifyProbeSkipped(previous, schema, schemaProbeId ?? command.probeId ?? null) : null;
const probeId = schemaProbeId ?? command.probeId ?? asText(schema.semantic_key) ?? "skip_probe";
const semanticKey = asText(schema.semantic_key) ?? probeId;
const splitHash = asText(schema.candidate_split_hash) ?? semanticKey;
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
const inference = skippedState && previous
? {
expectedRevision: previous.revision,
probeId,
openProbeId: probeId,
semanticKey,
candidateSplitHash: splitHash,
answerClass: "unsure" as const,
rawAnswer: "skip_probe",
inferenceState: skippedState as unknown as Record<string, unknown>,
posteriorBefore: posteriorMap(previous.candidates),
posteriorAfter: posteriorMap(skippedState.candidates),
scoreDeltas: scoreDeltas(
posteriorMap(previous.candidates),
posteriorMap(skippedState.candidates),
),
decisionStateFingerprint: inferenceFingerprintForState(
command.caseId,
evidenceFp,
skippedState,
),
reason: "choice" as const,
idempotencyKey: `skip_probe:${probeId}:unsure`,
candidateSetId: skippedState.candidate_set_id,
}
: null;
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: "skipped",
probeId,
optionId: "skip_probe",
scoring: false,
appliedInference: Boolean(inference),
answerClass: null,
sourceQuote: null,
year: typeof schema.probe_year === "number" ? schema.probe_year : null,
expectedRevision: previous?.revision ?? command.expectedRevision,
inference,
narration: composeChoiceNarration({
optionId: "skip_probe",
scoring: false,
appliedInference: false,
}),
userDisplay: CHOICE_SKIP_QUESTION_LABEL,
decisionState: skippedState,
userStopped: false,
dossier,
});
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
optionId: "stop",
@@ -419,6 +525,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted?: boolean;
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
}> {
const latest = input.dossier.latestResult
? {
@@ -461,6 +568,7 @@ export async function persistNextInterviewAfterChoice(input: {
nextAction: input.nextAction.type,
followup,
methods: plan.methods,
accepted: Boolean(input.dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
@@ -474,6 +582,15 @@ export async function persistNextInterviewAfterChoice(input: {
: fallback,
choiceReady: false,
persisted: false,
followup: null,
};
}
if (input.dossier.case.acceptedTime && !followup) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
persisted: false,
followup: null,
};
}
const persistedFocus = await persistFocusAfterChoice({
@@ -491,6 +608,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
@@ -500,6 +618,7 @@ export async function persistNextInterviewAfterChoice(input: {
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup,
};
}
if (followup?.choice_frame) {
@@ -521,6 +640,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
focusId: fallback.focus?.id ?? null,
focus: fallback.focus,
followup,
};
}
if (followup?.intent === "collect_method_evidence") {
@@ -529,7 +649,7 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus };
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
return persistExhaustionCollect({
accounting: input.accounting,
@@ -545,6 +665,13 @@ export async function persistNextInterviewAfterChoice(input: {
});
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
return {
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
choiceReady: false,
followup: null,
};
}
if (isNonConvergingRangeOffer({
canOfferRange: input.nextAction.can_offer_range,
canAdopt: input.nextAction.can_adopt,
@@ -578,6 +705,7 @@ export async function persistNextInterviewAfterChoice(input: {
return {
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
choiceReady: false,
followup,
};
}
@@ -838,6 +966,7 @@ export async function persistNextInterviewIfIdle(input: {
nextAction: decision.nextAction,
followup,
methods: plan.methods,
accepted: Boolean(dossier.case.acceptedTime),
})) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
@@ -865,6 +994,13 @@ export async function persistNextInterviewIfIdle(input: {
});
}
if (!followup) {
if (dossier.case.acceptedTime) {
return {
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
}
return { persisted: false, choiceReady: false, hostNarration: null };
}
const nextAction = publicNextAction(decision);
@@ -995,6 +1131,8 @@ async function persistApplied(
birthDate,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
const skipThisProbe = input.optionId === "skip_probe";
let narrationPersisted = false;
let nextInterviewPersisted = false;
@@ -1002,11 +1140,16 @@ async function persistApplied(
let hostNarration = input.narration;
let skippedNextInterview = false;
let nextFocus: ConversationFocus | null = null;
let nextFollowup: MethodFollowup | null = null;
let turnId: string | null = null;
if (
command.deferFollowup !== true
&& input.userStopped !== true
&& shouldContinueAfterStructuredChoice(nextAction)
&& (
shouldContinueAfterStructuredChoice(nextAction)
|| skipThisProbe
|| accepted
)
) {
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
@@ -1022,8 +1165,11 @@ async function persistApplied(
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
nextFocus = nextInterview.focus ?? null;
nextFollowup = nextInterview.followup ?? null;
if (nextInterview.hostNarration) {
if (skippedNextInterview) {
if (accepted || skipThisProbe) {
hostNarration = nextInterview.hostNarration;
} else if (skippedNextInterview) {
hostNarration = `${input.narration}
${nextInterview.hostNarration}`;
@@ -1032,10 +1178,10 @@ ${nextInterview.hostNarration}`;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = nextAction.can_adopt
const adoptionFacts = !accepted && nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const adoptionNarration = nextAction.can_adopt
const adoptionNarration = !accepted && nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
@@ -1045,7 +1191,7 @@ ${nextInterview.hostNarration}`;
}), liveDossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = nextAction.type === "complete_with_range"
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
? withProspectiveWindows(`${completedRangePrefix}
${nonConvergingRangeNarration({
@@ -1057,7 +1203,10 @@ ${nonConvergingRangeNarration({
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
if (accepted && !keptNextQuestion) {
hostNarration = RECTIFICATION_USER_COPY.postAdoptVerifyDone;
nextFollowup = null;
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
@@ -1075,8 +1224,10 @@ ${nonConvergingRangeNarration({
if (turn.turnId) {
turnId = turn.turnId;
try {
const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus;
const focus = nextFocus ?? (keptNextQuestion
? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
.conversationSummary.activeFocus
: null);
if (focus) {
await linkFocusAskedTurn({
accounting,
@@ -1127,6 +1278,18 @@ ${nonConvergingRangeNarration({
narration,
userDisplay: input.userDisplay,
nextAction,
nextUserAction: buildNextUserAction({
scorableCount: input.dossier.evidence.length,
evidenceCount: input.dossier.evidence.length,
hasLatestResult: Boolean(input.dossier.latestResult),
selectionAllowed: nextAction.can_adopt,
sessionOutcome: typeof nextAction.session_outcome === "string"
? nextAction.session_outcome as SessionOutcomeKind
: "collect_evidence",
nextFollowup: keptNextQuestion ? nextFollowup : null,
workingTime: input.dossier.case.acceptedTime ?? nextAction.representative_time ?? null,
accepted,
}),
nextInterviewPersisted,
nextChoiceReady,
turnId,
@@ -20,10 +20,11 @@ import {
export const CHOICE_ACTION = "answer_choice" as const;
export const STOP_ACTION = "stop_and_review" as const;
export const SKIP_PROBE_ACTION = "skip_probe" as const;
export const DETERMINISTIC_CHOICE_MODEL = "deterministic:choice";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop";
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop" | "skip_probe";
export type ChoiceActionStatus = "received" | "applied" | "narrated";
export type StructuredProbeDerivedContext = Readonly<{
@@ -38,7 +39,7 @@ export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): Answe
}
export function focusStatusForAnswer(answerClass: AnswerClass | null, optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
if (optionId === "stop" || answerClass === "unsure") return "skipped";
if (optionId === "stop" || optionId === "skip_probe" || answerClass === "unsure") return "skipped";
if (answerClass === "no") return "declined";
return "resolved";
}
@@ -99,6 +100,9 @@ export function composeChoiceNarration(input: {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.optionId === "skip_probe") {
return "已记录。这题先不计分,换一件事问。";
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
}
@@ -56,6 +56,7 @@ export type RectificationChoiceFrame = Readonly<{
stop_message: string;
scoring: boolean;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type AgentChoiceCopy = Readonly<{
@@ -86,6 +87,7 @@ export type RectificationChoiceCard = Readonly<{
case_revision: number | null;
focus_id: string | null;
choice_kind?: EventProbeChoiceKind;
skip_this_probe?: boolean;
}>;
export type ChoiceCardFollowup = Readonly<{
@@ -379,9 +381,10 @@ export function buildChoiceFrame(
);
if (!hypothesis) return null;
const domain = followupDomain(followup);
const skipQuestion = followup.intent === "reverse_verify"
const skipThisProbe = followup.intent === "reverse_verify"
|| followup.source === "reverse_verify";
const skipQuestion = skipThisProbe
|| followup.intent === "out_of_sample_check"
|| followup.source === "reverse_verify"
|| followup.source === "oos_blind";
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
@@ -403,6 +406,7 @@ export function buildChoiceFrame(
stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring,
choice_kind: hypothesisKind(followup, input.probes),
...(skipThisProbe ? { skip_this_probe: true } : {}),
};
}
@@ -521,6 +525,7 @@ export function mergeChoiceCard(
case_revision: meta.case_revision ?? null,
focus_id: meta.focus_id ?? null,
choice_kind: frame.choice_kind,
...(frame.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
@@ -553,6 +558,7 @@ export function choiceCardFromPersistedVerifyCopy(input: {
case_revision: input.caseRevision,
focus_id: input.focusId,
choice_kind: "existence",
skip_this_probe: true,
};
}
@@ -614,5 +620,6 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
? { choice_kind: row.choice_kind }
: {}),
...(row.skip_this_probe === true ? { skip_this_probe: true } : {}),
};
}
@@ -13,7 +13,7 @@ import {
rectificationFollowupCatalog,
} from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { projectRectificationChoiceCard } from "./method-followup";
import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction } from "./method-followup";
import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
@@ -102,4 +102,37 @@ export function choiceCardFromCaseDossier(dossier: {
});
}
export function nextUserActionFromDossier(dossier: Parameters<typeof choiceCardFromCaseDossier>[0]) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const accepted = Boolean(dossier.case.acceptedTime);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: dossier.conversationSummary.activeFocus,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
observations,
sessionOutcome: decision.sessionOutcome,
...catalog,
accepted,
candidatesSeparated: decision.separation.sufficient,
holdoutValidation: decision.holdoutValidation,
});
return buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: Boolean(dossier.latestResult),
selectionAllowed: decision.selectionAllowed,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: dossier.case.acceptedTime ?? decision.representativeTime ?? null,
accepted,
});
}
export { decideFromDossier, overlayPublicDecision } from "./decision-from-dossier";
@@ -17,7 +17,7 @@ export function isRectificationMessageOrigin(value: unknown): value is Rectifica
}
export function defaultMessageOrigin(action: string): RectificationMessageOrigin {
if (action === "answer_choice" || action === "stop_and_review") return "choice_click";
if (action === "answer_choice" || action === "stop_and_review" || action === "skip_probe") return "choice_click";
if (action === "opening") return "system_recovery";
return "typed";
}
@@ -545,10 +545,22 @@ const CONFLICT_PROBE_SOURCES = new Set<string>([
"dasha_activation",
]);
function remainingReverseVerifyProbes(
function reverseVerifyProbeAsked(
probe: DiscriminatingEventProbe,
askedKeys: ReadonlySet<string>,
): boolean {
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
const split = probe.candidate_split_hash ?? "";
return askedKeys.has(semantic)
|| (split !== "" && askedKeys.has(split))
|| askedKeys.has(`${probe.domain}.${probe.year}`);
}
export function remainingReverseVerifyProbes(
probes: readonly DiscriminatingEventProbe[] | undefined,
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
askedKeys: ReadonlySet<string> = new Set(),
birthDate?: string | null,
): DiscriminatingEventProbe[] {
const dasha: DiscriminatingEventProbe[] = [];
@@ -562,6 +574,7 @@ function remainingReverseVerifyProbes(
if (declined.has(probe.domain)) continue;
if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
if (probeBelowAdultFloor(probe, birthDate)) continue;
if (reverseVerifyProbeAsked(probe, askedKeys)) continue;
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
dasha.push(probe);
} else {
@@ -571,6 +584,32 @@ function remainingReverseVerifyProbes(
return [...dasha, ...fallback].slice(0, MAX_REVERSE_VERIFY);
}
export function reverseVerifyChecksFromProbes(
probes: readonly DiscriminatingEventProbe[],
): ReadonlyArray<{ kind: "reverse_verify"; domain: string; year_label: string }> {
return probes.map((probe) => ({
kind: "reverse_verify" as const,
domain: probe.domain,
year_label: probe.year_label,
}));
}
export function reverseVerifyRemainingForAdopt(input: {
eventProbes?: readonly DiscriminatingEventProbe[];
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
askedProbeKeys?: readonly string[];
birthDate?: string | null;
}): DiscriminatingEventProbe[] {
return remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declinedDomains(input.declinedTopics ?? []),
new Set(input.askedProbeKeys ?? []),
input.birthDate,
);
}
function datedCollectionProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string,
@@ -581,12 +620,11 @@ function datedCollectionProbe(
function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick<
MethodFollowup,
"probe_year" | "year_label" | "probe_month" | "semantic_key"
"probe_year" | "probe_month" | "semantic_key"
> {
if (!probe) return {};
return {
probe_year: probe.year,
year_label: probe.year_label,
...(probe.month ? { probe_month: probe.month } : {}),
semantic_key: probe.semantic_key,
};
@@ -996,9 +1034,7 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string |
: openingOther
? GENERIC_COLLECT_QUESTION
: (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION);
const period = followup.year_label
?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null);
return period ? `${period}${base}` : base;
return base;
}
export const DATED_COLLECT_ORDER = [
@@ -1720,7 +1756,13 @@ export function buildMethodFollowupPlan(input: {
}
if (input.accepted) {
const probe = remainingReverseVerifyProbes(input.eventProbes, input.evidence, declined, input.birthDate)[0] ?? null;
const probe = remainingReverseVerifyProbes(
input.eventProbes,
input.evidence,
declined,
askedKeys,
input.birthDate,
)[0] ?? null;
const theme = probe ? REVERSE_VERIFY_THEME[probe.domain] : null;
const next = probe && theme
? makeFollowup({
@@ -1734,6 +1776,17 @@ export function buildMethodFollowupPlan(input: {
REVERSE_VERIFY_VARGA[probe.domain],
),
source: "reverse_verify",
semantic_key: probe.semantic_key ?? `${probe.domain}.${probe.year}`,
candidate_split_hash: probe.candidate_split_hash,
probe_year: probe.year,
year_label: probe.year_label,
probe_month: probe.month,
choice_kind: probe.choice_kind,
candidate_ids: probe.candidate_ids ?? candidateIdsFromProbe(probe),
expected_outcomes: probe.expected_outcomes,
style_options: probe.style_options,
information_gain: probe.information_gain ?? 0,
probe_id: probe.semantic_key,
}, true, true)
: null;
return {
@@ -9,7 +9,8 @@ export type RectificationRouteAction =
| "message"
| "read_only"
| "answer_choice"
| "stop_and_review";
| "stop_and_review"
| "skip_probe";
export const RECTIFICATION_ACTION_EXECUTION = {
opening: "stream",
@@ -17,6 +18,7 @@ export const RECTIFICATION_ACTION_EXECUTION = {
read_only: "read_only",
answer_choice: "immediate",
stop_and_review: "immediate",
skip_probe: "immediate",
} as const satisfies Record<RectificationRouteAction, "stream" | "hybrid" | "read_only" | "immediate">;
export function dossierHasNonemptyAssistantBody(
@@ -16,11 +16,11 @@ export type TurnQuestion = Readonly<{
prompt: string;
options: readonly TurnQuestionOption[] | null;
status: string;
answer_option: ChoiceKey | "stop" | null;
answer_option: ChoiceKey | "stop" | "skip_probe" | null;
probe_id: string | null;
}>;
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop"]);
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop", "skip_probe"]);
export function focusSpokenPrompt(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
if (!schema) return null;
@@ -91,7 +91,8 @@ export function parseTurnQuestion(value: unknown): TurnQuestion | null {
prompt,
options: options.length === 4 ? options : (row.kind === "choice" || row.kind === "reverse_verify" ? options : null),
status: typeof row.status === "string" ? row.status : "active",
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D" || answer === "stop"
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D"
|| answer === "stop" || answer === "skip_probe"
? answer
: null,
probe_id: typeof row.probe_id === "string" ? row.probe_id : null,
@@ -55,6 +55,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{
current_question?: unknown;
choice_card?: unknown;
question_source?: unknown;
next_user_action?: Readonly<{ id?: unknown }>;
case?: Readonly<{
status?: unknown;
accepted_time?: unknown;
@@ -219,7 +220,7 @@ export function rectificationConversationState(input: Readonly<{
return "empty";
}
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable" | "verified_idle";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -230,6 +231,8 @@ export type RectificationQuestionGapInput = Readonly<{
questionLoadFailed: boolean;
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -254,6 +257,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
// message carries live yet: both are a gap the next read may close.