fix(rectification): keep range-card tie-break entry honest after D9/D10 answers (BUG-666~668)
Independent Staging Quality Gate / validate (push) Successful in 13m8s
Independent Staging Quality Gate / publish (push) Canceled after 9m59s

Hide the button once both personality cards are answered, persist the second card in the same opt-in round, and freeze Skill 10.0.26.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-13 22:45:31 +08:00
co-authored by Cursor
parent 192845ce2f
commit 9912760104
44 changed files with 1046 additions and 184 deletions
@@ -1387,6 +1387,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
);
const payload = await response.json().catch(() => null);
if (payload?.code === "tie_break_unavailable") {
await loadCaseSnapshot();
return;
}
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
}
@@ -71,7 +71,7 @@ import {
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card";
import { clusterScoreDeltas } from "./probe-explain.ts";
import {
adoptDeliveryFacts,
@@ -923,6 +923,7 @@ export async function persistNextInterviewAfterChoice(input: {
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
skipRefresh?: boolean;
continueTieBreakRound?: boolean;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -966,7 +967,7 @@ export async function persistNextInterviewAfterChoice(input: {
const sessionOutcome = typeof nextAction.session_outcome === "string"
? nextAction.session_outcome as SessionOutcomeKind
: "collect_evidence";
const plan = planWithDateReliability(buildMethodFollowupPlan({
const planInput = {
evidence: liveDossier.evidence,
activeFocus: null,
declinedTopics: liveDossier.conversationSummary.declinedSkippedTopics,
@@ -983,7 +984,32 @@ export async function persistNextInterviewAfterChoice(input: {
reportedBirthTime: liveDossier.case.reportedBirthTime,
candidateRange: liveDossier.case.candidateRange,
}),
}), liveDossier.evidence, input.askedTurnId);
};
const roundFollowup = input.continueTieBreakRound === true
? tieBreakPersonalityFollowup(planInput)
: null;
if (roundFollowup) {
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
followup: roundFollowup,
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: roundFollowup,
};
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
const followup = interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: nextAction.can_adopt,
@@ -1388,6 +1414,7 @@ export async function applyCollectFocusDenial(
decision,
birthDate,
narrateAdopt: input.narrateAdopt,
continueTieBreakRound: isTieBreakRoundSchema(focus.expectedAnswerSchema),
});
return {
narration: nextInterview.hostNarration,
@@ -1684,19 +1711,20 @@ export async function persistNextInterviewIfIdle(input: {
}));
}
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
skipRefresh: true,
});
const nextInterview = await persistNextInterviewAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
skipRefresh: true,
continueTieBreakRound: isTieBreakRoundSchema(dossier.conversationSummary.activeFocus?.expectedAnswerSchema),
});
return finishIdle({
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
@@ -2032,6 +2060,9 @@ async function persistApplied(
decision: nextDecision,
birthDate,
narrateAdopt: command.narrateAdopt,
continueTieBreakRound: isTieBreakRoundSchema(
input.dossier.conversationSummary.activeFocus?.expectedAnswerSchema,
),
});
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
@@ -15,6 +15,8 @@ import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectifica
import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/evidence-model";
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 { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
export function dossierResponse(
@@ -92,6 +94,7 @@ function publicLatestResult(
if (!latest) return null;
const projected = overlayPublicDecision(latest, decision);
const toolProjection = latestResultToolProjection(latest, decision);
const catalog = rectificationFollowupCatalog(latest, dossier.evidence);
const rangeDelivery = rangeDeliveryForSnapshot({
decisionReceipt: projected.decisionReceipt,
candidates: projected.candidates,
@@ -102,6 +105,16 @@ function publicLatestResult(
skill_verification_report: toolProjection.skill_verification_report,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
accepted: Boolean(dossier.case.acceptedTime),
tieBreakAvailable: tieBreakPersonalityAvailable({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...catalog,
accepted: Boolean(dossier.case.acceptedTime),
}),
});
const withDelivery = {
...projected,
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.25";
export const RECTIFICATION_SKILL_VERSION = "10.0.26";
@@ -40,6 +40,15 @@ export function isPersistedFocusId(value: string | null | undefined): value is s
return typeof value === "string" && FOCUS_ID_PATTERN.test(value.trim());
}
export function isTieBreakRoundSchema(value: unknown): boolean {
return Boolean(
value
&& typeof value === "object"
&& !Array.isArray(value)
&& (value as { tie_break_round?: unknown }).tie_break_round === true,
);
}
export type ChoiceKey = "A" | "B" | "C" | "D";
export type RectificationChoiceOption = Readonly<{
@@ -639,31 +639,6 @@ export function isTargetedCollectFollowup(followup: {
|| hint.startsWith("targeted:");
}
export function isTargetedCollectYearFollowup(followup: {
collection_key?: string;
kind_hint?: string | null;
} | null | undefined): boolean {
if (!followup) return false;
const key = followup.collection_key ?? "";
const hint = followup.kind_hint ?? "";
return parseTargetedCollectQuestionId(key)?.stage === "year"
|| parseTargetedCollectKind(hint)?.stage === "year";
}
/**
* True when at least one targeted line was declined or skipped.
* Per-domain cards must not treat this as “the whole targeted pool is done”.
*/
export function isTargetedCollectDeclined(
topics: readonly CollectionTopic[] = [],
): boolean {
return topics.some((topic) => {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") return false;
return isTargetedCollectTopic(topic);
});
}
function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
const declined = new Set<CollectKind>();
for (const topic of topics) {
@@ -451,16 +451,6 @@ function rangeFromUnknown(value: unknown): readonly [string, string] | null {
return start && end ? [start, end] : null;
}
function rawVargaStyleTieBreak(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
const nested = row.window_scan;
const scan = nested && typeof nested === "object" && !Array.isArray(nested)
? nested as Record<string, unknown>
: row;
return scan.d9_candidates_differ === true || scan.d10_candidates_differ === true;
}
export function rangeDeliveryForSnapshot(snapshot: {
decisionReceipt?: Readonly<Record<string, unknown>> | null;
decision_receipt?: Readonly<Record<string, unknown>> | null;
@@ -484,6 +474,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
}>[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
window_scan?: unknown;
accepted?: boolean;
tieBreakAvailable?: boolean;
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -510,12 +502,10 @@ export function rangeDeliveryForSnapshot(snapshot: {
eventDashaLedger: refinement.event_dasha_ledger,
evidence: snapshot?.evidence,
declinedTopics: snapshot?.declinedTopics,
tieBreakAvailable: Boolean(
windowScan?.d9_candidates_differ
|| windowScan?.d10_candidates_differ
|| rawVargaStyleTieBreak(snapshot)
|| rawVargaStyleTieBreak(receipt)
),
// BUG-666: the GET flag is the same unasked D9/D10 varga_style
// 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,
});
}
@@ -218,6 +218,7 @@ export type MethodFollowup = Readonly<{
invite_more_once?: boolean;
spoken_prompt?: string;
collection_key?: string;
tie_break_round?: boolean;
block_periods?: Readonly<Record<"A" | "B" | "C", BlockScanBlock>>;
widen_windows?: Readonly<Record<"A" | "B", import("./window-widen.ts").WidenWindowOption>>;
date_reliability_evidence_id?: string;
@@ -2975,7 +2976,14 @@ export function tieBreakPersonalityFollowup(
if (kind !== "varga_style") return null;
const key = followup.semantic_key ?? "";
if (!key.startsWith("varga.d9") && !key.startsWith("varga.d10")) return null;
return followup;
return { ...followup, tie_break_round: true };
}
export function tieBreakPersonalityAvailable(
input: Parameters<typeof tieBreakPersonalityFollowup>[0] & { accepted?: boolean },
): boolean {
if (input.accepted) return false;
return tieBreakPersonalityFollowup(input) !== null;
}
export function projectRectificationChoiceCard(
@@ -105,6 +105,7 @@ export function expectedAnswerSchemaFor(
collect_kind: followup.kind_hint ?? null,
}
: {}),
...(followup.tie_break_round ? { tie_break_round: true } : {}),
...(followup.block_periods ? { block_periods: followup.block_periods } : {}),
...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}),
};
@@ -77,6 +77,7 @@ import {
buildNextUserAction,
spokenCollectFallbackFollowup,
collectQuestionDomain,
tieBreakPersonalityAvailable,
} from "@/lib/rectification-agentic/v9/method-followup";
import { dashaAgreementAmongActive, refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
@@ -580,6 +581,12 @@ export function latestResultToolProjection(
credibleRange: decision.credibleRange,
credible_range: decision.credibleRange,
skill_verification_report: base.skill_verification_report,
tieBreakAvailable: tieBreakPersonalityAvailable({
evidence: [],
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...rectificationFollowupCatalog(latest, []),
}),
}),
};
}