fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
Refresh persist failures no longer count as attempts. tied_first completes with a range before stillNeedNarrowing. Delivery narration and cards share DELIVERY_OUTCOMES. The question gap gets a delivered terminal so the unavailable copy does not appear after a range is given.
This commit is contained in:
@@ -58,6 +58,7 @@ import {
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
|
||||
RECTIFICATION_DELIVERED_COPY,
|
||||
RECTIFICATION_STOPPED_NOTICE,
|
||||
isAbortError,
|
||||
rectificationConversationState,
|
||||
@@ -1581,6 +1582,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
|
||||
nextUserActionId,
|
||||
collectWaiting: collectWaiting && !deadChoice,
|
||||
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
|
||||
stopReason: interviewStopReason,
|
||||
busy,
|
||||
readonly,
|
||||
regenerating: regeneratingMessageKey !== null,
|
||||
@@ -1898,6 +1901,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "delivered" && !showSelectionCards && (
|
||||
<p className="rectification-pending-note" role="status">
|
||||
{RECTIFICATION_DELIVERED_COPY}
|
||||
</p>
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
|
||||
|
||||
@@ -130,12 +130,25 @@ export const ADOPT_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
|
||||
"validated_range",
|
||||
]);
|
||||
|
||||
/** Range-delivery card and delivery copy share this set. ADOPT_OUTCOMES stays the adopt gate. */
|
||||
export const DELIVERY_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
|
||||
...ADOPT_OUTCOMES,
|
||||
"completed_with_range",
|
||||
"provisional_range",
|
||||
]);
|
||||
|
||||
export function sessionOutcomeAllowsAdopt(
|
||||
outcome: DecisionSessionOutcome | string | null | undefined,
|
||||
): outcome is DecisionSessionOutcome {
|
||||
return typeof outcome === "string" && ADOPT_OUTCOMES.has(outcome as DecisionSessionOutcome);
|
||||
}
|
||||
|
||||
export function sessionOutcomeAllowsDelivery(
|
||||
outcome: DecisionSessionOutcome | string | null | undefined,
|
||||
): outcome is DecisionSessionOutcome {
|
||||
return typeof outcome === "string" && DELIVERY_OUTCOMES.has(outcome as DecisionSessionOutcome);
|
||||
}
|
||||
|
||||
export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome">): boolean {
|
||||
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
|
||||
}
|
||||
@@ -151,7 +164,8 @@ export function deliveryNarrationAllowed(
|
||||
): boolean {
|
||||
if (publicCanAdopt(decision)) return true;
|
||||
const action = nextAction ?? decision.nextAction;
|
||||
return decision.selectionAllowed === true && action === "offer_provisional_range";
|
||||
if (decision.selectionAllowed === true && action === "offer_provisional_range") return true;
|
||||
return decision.selectionAllowed === true && sessionOutcomeAllowsDelivery(decision.sessionOutcome);
|
||||
}
|
||||
|
||||
export type CompletionStatus =
|
||||
@@ -317,6 +331,14 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
}
|
||||
return collect(separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
if (
|
||||
stopClass?.kind === "exhausted"
|
||||
&& stopClass.reason === "tied_first"
|
||||
&& input.trainingGateOpen !== false
|
||||
&& separation.ranked.length > 0
|
||||
) {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
|
||||
}
|
||||
if (coverageBlocks) {
|
||||
const engineOffers = input.engineCeiling.acceptanceAllowed
|
||||
|| input.engineCeiling.proposeAllowed;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
nonConvergingRangeNarration,
|
||||
publicCanAdopt,
|
||||
publicNextAction,
|
||||
sessionOutcomeAllowsDelivery,
|
||||
type RectificationDecision,
|
||||
} from "../core/rectification-decision.ts";
|
||||
import {
|
||||
@@ -272,6 +273,12 @@ function shouldSkipFollowupPersist(input: {
|
||||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||||
}): boolean {
|
||||
if (input.accepted) return false;
|
||||
if (
|
||||
input.stopReason === "tied_first"
|
||||
&& sessionOutcomeAllowsDelivery(input.sessionOutcome)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!input.canAdopt) return false;
|
||||
if (
|
||||
input.nextAction === "ask_fact_collection"
|
||||
|
||||
@@ -565,7 +565,7 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
|
||||
{ ...state, revision: state.revision + 1 },
|
||||
"no_new_probes",
|
||||
);
|
||||
await persistRefreshAttempt({
|
||||
const recorded = await persistRefreshAttempt({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
@@ -573,6 +573,20 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
|
||||
previous: state,
|
||||
next: attempted,
|
||||
});
|
||||
if (!recorded) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_refresh_attempt_persist_failed",
|
||||
case_id: input.caseId,
|
||||
candidate_set_id: state.candidate_set_id,
|
||||
answer_count: state.answered_probes.length,
|
||||
}));
|
||||
return {
|
||||
dossier: input.dossier,
|
||||
state,
|
||||
refreshed: false,
|
||||
attemptRecorded: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
dossier: applyRefreshedProbesToDossier(input.dossier, attempted),
|
||||
state: attempted,
|
||||
@@ -597,11 +611,17 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
|
||||
askableCount: askable.length,
|
||||
});
|
||||
if (!written) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_refresh_attempt_persist_failed",
|
||||
case_id: input.caseId,
|
||||
candidate_set_id: state.candidate_set_id,
|
||||
answer_count: state.answered_probes.length,
|
||||
}));
|
||||
return {
|
||||
dossier: applyRefreshedProbesToDossier(input.dossier, nextState),
|
||||
state: nextState,
|
||||
dossier: input.dossier,
|
||||
state,
|
||||
refreshed: false,
|
||||
attemptRecorded: true,
|
||||
attemptRecorded: false,
|
||||
};
|
||||
}
|
||||
const persistableEventProbes = result.eventProbes.filter((probe) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MIN_SEPARATION_LEAD } from "./rectification-agentic/core/candidate-sepa
|
||||
import {
|
||||
engineCapabilityCeilingFromReceipt,
|
||||
sessionOutcomeAllowsAdopt,
|
||||
sessionOutcomeAllowsDelivery,
|
||||
type DecisionSessionOutcome,
|
||||
} from "./rectification-agentic/core/rectification-decision";
|
||||
import {
|
||||
@@ -426,8 +427,11 @@ export function canRenderRectificationSelectionCards(
|
||||
export function canShowRectificationSelectionCards(
|
||||
result: RectificationCandidateResult | null,
|
||||
): boolean {
|
||||
return canRenderRectificationSelectionCards(result)
|
||||
&& sessionOutcomeAllowsAdopt(result?.sessionOutcome);
|
||||
if (!result || !sessionOutcomeAllowsDelivery(result.sessionOutcome)) return false;
|
||||
if (sessionOutcomeAllowsAdopt(result.sessionOutcome)) {
|
||||
return canRenderRectificationSelectionCards(result);
|
||||
}
|
||||
return result.selectionAllowed === true;
|
||||
}
|
||||
|
||||
export function canShowRectificationReadonlyRange(
|
||||
@@ -435,6 +439,9 @@ export function canShowRectificationReadonlyRange(
|
||||
): boolean {
|
||||
if (!result?.credibleRange) return false;
|
||||
const outcome = result.sessionOutcome;
|
||||
if (sessionOutcomeAllowsDelivery(outcome)) {
|
||||
return result.selectionAllowed !== true;
|
||||
}
|
||||
return outcome === "collect_evidence" || outcome === "discriminate_candidates";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
|
||||
import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts";
|
||||
|
||||
/**
|
||||
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
|
||||
@@ -30,6 +31,7 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
|
||||
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
|
||||
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
|
||||
export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事";
|
||||
export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
|
||||
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
|
||||
@@ -269,7 +271,8 @@ export type RectificationQuestionGapState =
|
||||
| "unavailable"
|
||||
| "verified_idle"
|
||||
| "persisted_question"
|
||||
| "collect_waiting";
|
||||
| "collect_waiting"
|
||||
| "delivered";
|
||||
|
||||
export type RectificationQuestionGapInput = Readonly<{
|
||||
/** The current question is rendered live inside an assistant message. */
|
||||
@@ -289,6 +292,8 @@ export type RectificationQuestionGapInput = Readonly<{
|
||||
questionPersisted?: boolean;
|
||||
/** Training gate still closed; pool empty; keep the case open for another dated event. */
|
||||
collectWaiting?: boolean;
|
||||
sessionOutcome?: string | null;
|
||||
stopReason?: string | null;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
@@ -313,6 +318,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
|
||||
if (!input.snapshotLoaded) return retryGate;
|
||||
if (!input.resumableCase) return "idle";
|
||||
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
|
||||
if (interviewDeliveredGap(input)) return "delivered";
|
||||
if (input.collectWaiting) return "collect_waiting";
|
||||
if (input.questionPersisted) return "persisted_question";
|
||||
if (input.nextUserActionId === "start_consultation") return "verified_idle";
|
||||
@@ -378,6 +384,16 @@ export function interviewChoiceCardUnavailable(input: Readonly<{
|
||||
return false;
|
||||
}
|
||||
|
||||
export function interviewDeliveredGap(input: Readonly<{
|
||||
questionMissing: boolean;
|
||||
sessionOutcome?: string | null;
|
||||
stopReason?: string | null;
|
||||
}>): boolean {
|
||||
if (!input.questionMissing) return false;
|
||||
if (sessionOutcomeAllowsDelivery(input.sessionOutcome)) return true;
|
||||
return input.stopReason === "tied_first" || input.stopReason === "user_uncertainty_too_high";
|
||||
}
|
||||
|
||||
export function interviewCollectWaiting(input: Readonly<{
|
||||
stopReason?: string | null;
|
||||
sessionOutcome?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user