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) => (