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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user