fix(rectification): ask tie-break questions before the range card (BUG-685/686/687)

Close leads hold delivery until unused D9/D10 style questions are asked. Tie-break POST merges the new turn into the transcript. Range copy no longer lists declined lines.
This commit is contained in:
jesse-ux
2026-09-14 18:23:54 +08:00
parent 1c424bf6d5
commit 7f28bfecbb
22 changed files with 833 additions and 86 deletions
@@ -120,13 +120,17 @@ import {
interviewQuestionBlocksAdoptOffer,
nextSelectionCardLock,
parseTurnQuestion,
persistedOfferFromTurn,
questionIsAnswered,
resolveSelectionCardMessageKey,
type SelectionCardLock,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
mergeTurnQuestions,
} from "@/lib/rectification-snapshot-messages";
type PersistedTurn = Readonly<{
id: string;
@@ -245,33 +249,6 @@ type RenderMessage = ChatMessageView & {
candidateOffer?: Readonly<{ resultId: string }>;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
@@ -295,16 +272,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => (
message.role === "assistant"
&& message.turnId
&& !known.has(message.turnId)
));
return extras.length ? [...current, ...extras] : current;
}
function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
@@ -1194,7 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
mergeTurnQuestions(withoutPlaceholder, turns),
turns,
messagesFromTurns(turns as readonly PersistedTurn[]),
);
if (withHistory.length > withoutPlaceholder.length) return withHistory;
return [
@@ -1396,7 +1363,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
}
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
} finally {
@@ -1579,7 +1552,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null,
nextUserActionId,
collectWaiting: collectWaiting && !deadChoice,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
@@ -1622,7 +1595,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
});
async function refetchQuestion() {
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
}
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
@@ -1650,7 +1629,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
@@ -59,6 +59,9 @@ export function RectificationRangeDelivery({
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
</button>
) : null}
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
) : null}
{sharedTraits.length > 0 ? (
<ul className="rectification-range-delivery__shared">
{sharedTraits.map((line) => (
@@ -197,6 +197,8 @@ export type RectificationDecision = Readonly<{
droppedProbes: readonly DroppedProbe[];
stopReason?: EvidenceStopReason | null;
terminationCopy?: string | null;
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
heldForTieBreak?: boolean;
}>;
export type DecideRectificationInput = Readonly<{
@@ -228,6 +230,8 @@ export type DecideRectificationInput = Readonly<{
targetedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
/** Unasked D9/D10 style questions remain. */
pendingTieBreak?: boolean;
}>;
function classifyStop(
@@ -329,7 +333,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "user_stopped", rangeDeliveryCapability);
}
if (input.snapshotCurrent === false) {
@@ -346,7 +350,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& !probe
&& input.targetedCollectExhausted !== false
) {
return completeWithRange(
return deliverRange(
input,
separation,
holdout,
range,
@@ -367,6 +372,9 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& engineOffers
&& !narrowingOpen
) {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return collect(
@@ -382,7 +390,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, capability, stopClass.reason);
}
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (!separation.sufficient) {
if (probe) {
@@ -400,7 +408,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
return finish("adopt_representative", {
@@ -413,10 +421,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
stopReason: "probe_pool_exhausted",
});
}
return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (input.accepted) {
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
@@ -448,9 +456,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
}
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability);
}
if (holdout === "unavailable") {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return finish("adopt_representative", {
@@ -479,7 +490,7 @@ function discriminateOrExhaust(
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (budgetExhausted(input)) {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason);
return deliverRange(input, separation, holdout, range, "exhausted", capability, stopReason);
}
return discriminate(separation, holdout, range, probe, capability, stopReason);
}
@@ -565,6 +576,61 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
export function shouldHoldForTieBreak(
input: Pick<DecideRectificationInput, "pendingTieBreak" | "userStopped">,
separation: CandidateSeparation,
kind?: "user_stopped" | "offer" | "exhausted",
): boolean {
if (kind === "user_stopped" || input.userStopped === true) return false;
if (input.pendingTieBreak !== true) return false;
if (separation.ranked.length < 2) return false;
return separation.lead <= 1;
}
function holdForTieBreak(
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
return {
phase: "discrimination",
nextAction: "ask_candidate_discriminator",
sessionOutcome: "discriminate_candidates",
resultStatus: "discriminating",
canOfferRange: false,
...waitToNarrowCapability(capability),
precisionStage: "theme_refine",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
probe: null,
holdoutValidation: holdout,
droppedProbes: [],
heldForTieBreak: true,
...(stopReason ? { stopReason } : {}),
};
}
function deliverRange(
input: DecideRectificationInput,
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
kind: "user_stopped" | "offer" | "exhausted",
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (shouldHoldForTieBreak(input, separation, kind)) {
return holdForTieBreak(separation, holdout, range, capability, stopReason);
}
return completeWithRange(separation, holdout, range, kind, capability, stopReason);
}
function clockMinute(value: string): string {
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
if (!match) return value.trim().slice(0, 5);
@@ -148,6 +148,8 @@ export const RECTIFICATION_USER_COPY = {
rangeDeliveryAdopted: "已采用",
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
rangeDeliveryCollectClosed: "能问的都问完了",
verificationReportSummary: "查看验证报告",
} as const;
@@ -501,6 +503,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.rangeDeliveryAdopted,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed,
RECTIFICATION_USER_COPY.verificationReportSummary,
postAdoptVerifyDoneCopy("04:53", false),
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
@@ -101,6 +101,7 @@ import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
targetedCollectFollowup,
tieBreakGateInput,
tieBreakPersonalityFollowup,
collectQuestionDomain,
ledgerHasConfirmedDatedEvent,
@@ -1019,6 +1020,30 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
}
const heldFollowup = decision.heldForTieBreak === true
? tieBreakPersonalityFollowup({ ...planInput, tieBreakRequested: true })
: null;
if (heldFollowup) {
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
followup: heldFollowup,
askedTurnId: input.askedTurnId ?? null,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return {
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
choiceReady: true,
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup: heldFollowup,
};
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
if (shouldSkipFollowupPersist({
@@ -1229,22 +1254,18 @@ export async function requestTieBreakPersonality(input: {
birthDate = null;
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const followup = tieBreakPersonalityFollowup({
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...catalog,
catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}),
});
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
if (!followup) {
return {
persisted: false,
@@ -1631,6 +1652,38 @@ export async function persistNextInterviewIfIdle(input: {
});
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
if (decision.heldForTieBreak === true) {
const heldFollowup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
if (heldFollowup) {
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: dossier.latestResult?.decisionReceipt,
followup: heldFollowup,
askedTurnId: input.askedTurnId ?? null,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return finishIdle({
persisted: true,
choiceReady: true,
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
});
}
}
}
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
@@ -16,7 +16,7 @@ import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/ev
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
import { rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
export function dossierResponse(
@@ -106,15 +106,21 @@ function publicLatestResult(
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
accepted: Boolean(dossier.case.acceptedTime),
tieBreakAvailable: tieBreakPersonalityAvailable({
tieBreakAvailable: tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...catalog,
catalog,
accepted: Boolean(dossier.case.acceptedTime),
}),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
})),
tieBreakUsed: (catalog.askedProbeKeys ?? []).some((key) => (
key.includes("varga.d9") || key.includes("varga.d10")
)),
stillTied: decision.separation.lead <= 1 && decision.separation.ranked.length >= 2,
});
const withDelivery = {
...projected,
@@ -4,7 +4,7 @@
* value, not a fixed domain rotation. Age-band years never enter prompts.
*/
import { USER_COLLECT_QUESTION } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../user-copy.ts";
import { canonicalCollectDomain } from "./domain-alias.ts";
export const COLLECT_KIND_ORDER = [
@@ -825,7 +825,6 @@ export function targetedCollectHintFromPool(
return targetedCollectHint(items[0]);
}
/** Card copy ignores a declined targeted collect so the delivered range still says what would narrow it. */
export function rangeNarrowHint(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
@@ -834,7 +833,21 @@ export function rangeNarrowHint(
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
): string {
const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount, credibleRange);
const open = targetedCollectPool(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
);
if (open.length === 0) {
const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes);
if (range && (candidateCount ?? 0) > 0) {
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选。${RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed}`;
}
return RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed;
}
const remaining = remainingCandidatesLine(
splitTimes,
candidateCount ?? 0,
@@ -842,14 +855,6 @@ export function rangeNarrowHint(
credibleRange,
);
const hint = targetedCollectHintFromPool(open)
?? targetedCollectHint(targetedCollectPool(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
)[0])
?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`;
if (remaining && !hint.startsWith(remaining)) {
return `${remaining}${hint}`;
@@ -40,6 +40,8 @@ import {
eventFamilyForDiscriminator,
exhaustionSpokenCollectFollowup,
isRemainingEvidenceCollect,
tieBreakGateInput,
tieBreakPersonalityAvailable,
} from "./method-followup";
import { buildConfirmationGate } from "./confirmation-gate";
import {
@@ -767,6 +769,18 @@ export function decideFromDossier(
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: options?.birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
const oosBlindPrompts = refinementFromDecisionReceipt(
dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
@@ -878,6 +892,7 @@ export function decideFromDossier(
),
windowWidenSuggested,
openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange),
pendingTieBreak,
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -892,6 +907,18 @@ export function decideAfterInferenceChange(input: {
snapshotCurrent?: boolean;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: input.birthDate,
accepted: Boolean(input.dossier.case.acceptedTime),
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
reportedBirthTime: input.dossier.case.reportedBirthTime,
candidateRange: input.dossier.case.candidateRange,
}));
const collecting = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
@@ -947,6 +974,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
});
}
const training = input.state.events.filter((item) => item.usage === "training");
@@ -1024,6 +1052,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -12,6 +12,7 @@ import type {
} from "../core/types.ts";
import {
RANGE_DELIVERY_MORE_MINUTES,
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryFitLine,
rangeDeliveryWindowLine,
@@ -82,6 +83,7 @@ export type RangeDeliveryProjection = Readonly<{
verification_markdown: string | null;
narrow_hint: string | null;
tie_break_available: boolean;
tie_break_note: string | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -334,6 +336,8 @@ export function buildRangeDelivery(input: {
}>[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
}): RangeDeliveryProjection {
const inference = input.inference;
const range = input.credibleRange
@@ -419,6 +423,11 @@ export function buildRangeDelivery(input: {
inference?.credible_range,
),
tie_break_available: input.tieBreakAvailable === true,
tie_break_note: input.tieBreakAvailable !== true
&& input.tieBreakUsed === true
&& input.stillTied === true
? RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed
: null,
};
}
@@ -477,6 +486,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
window_scan?: unknown;
accepted?: boolean;
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -507,6 +518,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
// judgment the tie-break route uses. Callers pass it; window_scan
// alone must not keep the button after both cards are answered.
tieBreakAvailable: snapshot?.accepted !== true && snapshot?.tieBreakAvailable === true,
tieBreakUsed: snapshot?.tieBreakUsed === true,
stillTied: snapshot?.stillTied === true,
});
}
@@ -649,5 +662,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
? row.narrowHint.trim()
: null,
tie_break_available: row.tie_break_available === true || row.tieBreakAvailable === true,
tie_break_note: typeof row.tie_break_note === "string" && row.tie_break_note.trim()
? row.tie_break_note.trim()
: typeof row.tieBreakNote === "string" && row.tieBreakNote.trim()
? row.tieBreakNote.trim()
: null,
};
}
@@ -165,6 +165,7 @@ import {
WIDEN_WINDOW_INTENT,
blockPeriodsForChoice,
buildBlockChoiceFrame,
followupCaseArgs,
type BlockScanBlock,
type BlockScanPayload,
type RectificationCaseStage,
@@ -3015,6 +3016,53 @@ export function buildMethodFollowupPlan(input: {
};
}
export function tieBreakGateInput(input: {
evidence: Parameters<typeof buildMethodFollowupPlan>[0]["evidence"];
declinedTopics?: Parameters<typeof buildMethodFollowupPlan>[0]["declinedTopics"];
closedCollectFocuses?: Parameters<typeof buildMethodFollowupPlan>[0]["closedCollectFocuses"];
catalog: Pick<
Parameters<typeof buildMethodFollowupPlan>[0],
| "remainingLayers"
| "remainingSplitTimes"
| "remainingCandidateCount"
| "remainingCredibleRange"
| "answeredProbes"
| "eventProbes"
| "contrastPacket"
| "topCandidateTimes"
| "askedProbeKeys"
| "eventClarificationProbes"
| "evidenceCollectionProbes"
| "precisionStage"
| "nakshatraProbe"
| "oosBlindPrompts"
| "holdoutEvents"
>;
birthDate?: string | null;
accepted?: boolean;
stage?: string | null;
blockScan?: BlockScanPayload | null;
reportedBirthTime?: string | null;
candidateRange?: { start_time?: string; end_time?: string } | null;
}): Parameters<typeof tieBreakPersonalityFollowup>[0] & { accepted?: boolean } {
return {
evidence: input.evidence,
declinedTopics: input.declinedTopics ?? [],
closedCollectFocuses: input.closedCollectFocuses ?? input.declinedTopics ?? [],
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...input.catalog,
birthDate: input.birthDate ?? null,
accepted: input.accepted === true,
...followupCaseArgs({
stage: input.stage,
blockScan: input.blockScan,
reportedBirthTime: input.reportedBirthTime,
candidateRange: input.candidateRange,
}),
};
}
export function tieBreakPersonalityFollowup(
input: Parameters<typeof buildMethodFollowupPlan>[0],
): MethodFollowup | null {
@@ -0,0 +1,64 @@
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
export type SnapshotTurnMessage = {
role: "assistant" | "user";
turnId?: string;
text: string;
renderKey: string;
question?: TurnQuestion;
candidateOffer?: { resultId: string };
};
export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
current: T[],
turns: readonly unknown[],
): T[] {
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
current: T[],
extras: readonly T[],
): T[] {
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
const incoming = extras.filter((message) => (
message.role === "assistant"
&& message.turnId
&& !known.has(message.turnId)
));
return incoming.length ? [...current, ...incoming] : current;
}
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
current: T[],
turns: readonly unknown[],
extrasFromTurns: (turns: readonly unknown[]) => T[],
): T[] {
return mergeTurnQuestions(
appendUnseenAssistantTurns(current, extrasFromTurns(turns)),
turns,
);
}
@@ -321,10 +321,11 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.liveQuestionVisible) return "idle";
if (interviewDeliveredGap(input)) return "delivered";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (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