fix(rectification): replay answered probes after evidence rescore (BUG-587, BUG-588)
Independent Staging Quality Gate / validate (push) Successful in 10m56s
Independent Staging Quality Gate / publish (push) Successful in 9m1s

Carry answered probe defs into the new candidate set, replay them by minute, and announce range changes on evidence turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 10:38:51 +08:00
co-authored by Cursor
parent fec316b3e3
commit df182c16d6
15 changed files with 542 additions and 27 deletions
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
@@ -635,7 +635,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, []);
const loadCaseSnapshot = useCallback(async (): Promise<{
const loadCaseSnapshot = useCallback(async (
mergeMessages?: (turns: readonly unknown[]) => void,
): Promise<{
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
@@ -650,10 +652,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) return undefined;
applyCaseSnapshot(payload);
const turns = snapshotTurns(payload);
startTransition(() => {
applyCaseSnapshot(payload);
mergeMessages?.(turns);
});
return {
question: currentQuestionFromSnapshot(payload?.current_question),
turns: snapshotTurns(payload),
turns,
};
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
@@ -960,10 +966,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
if (succeeded) {
runOutcome = "succeeded";
const snapshot = await loadCaseSnapshot();
if (snapshot?.turns.length) {
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
}
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
}
});
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
@@ -1121,15 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: "";
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
onCompleted?.();
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
if (willContinue) {
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
setMessages((current) => mergeTurnQuestions(current, turns));
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
await loadCaseSnapshot((turns) => {
if (willContinue) {
setMessages((current) => mergeTurnQuestions(current, turns));
return;
}
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
@@ -1151,6 +1154,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
];
});
});
if (willContinue) {
// The follow-up turn continues on the row already in place: no removed
// row, no effect hop, and `busy` never drops in between (so the next
// card cannot flash before the turn hides it).
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
: "已记录你的选择。";
onMessagesChange?.([
{ role: "assistant", text: narration },
]);
@@ -1400,6 +1413,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
const showSelectionCards = Boolean(
candidateResult
&& caseSnapshotLoaded
&& !busy
&& selectionCardMessageKey
&& (canOfferCards || Boolean(candidateResult.selectedTime))
&& !messages.some((message) => (
@@ -55,7 +55,13 @@ export function buildInferenceState(input: {
const previous = sameSet ? input.previous : null;
const events = stickyHoldoutEvents(input.events, previous?.events);
const holdoutKeys = holdoutDomainYears(events);
const probes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const liveProbes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const probes = [
...liveProbes,
...carriedAnsweredProbes(input.previous, liveProbes).filter((probe) => (
!holdoutKeys.has(`${probe.domain}:${probe.year}`)
)),
];
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
@@ -73,8 +79,8 @@ export function buildInferenceState(input: {
// Engine event scores already include dated evidence. A structured A/yes
// choice is not an evidence row, so it must still move the posterior.
if (answer.answer_class === "yes" && answer.classified_from === "evidence") continue;
const probe = input.probes.find((item) => item.id === answer.probe_id)
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
const probe = probes.find((item) => item.id === answer.probe_id)
?? probes.find((item) => item.semantic_key === answer.semantic_key)
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
if (!probe) continue;
@@ -239,7 +245,9 @@ export function replayInferenceState(
export function nextProbe(state: InferenceState): ConflictProbe | null {
const holdoutKeys = holdoutDomainYears(state.events);
const probes = state.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
const probes = state.probes.filter((probe) => (
!probe.carried && !holdoutKeys.has(`${probe.domain}:${probe.year}`)
));
return selectHighestGainProbe(probes, state.answered_probes);
}
@@ -328,6 +336,24 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
});
}
export function carriedAnsweredProbes(
previous: InferenceState | null | undefined,
current: readonly ConflictProbe[],
): ConflictProbe[] {
if (!previous) return [];
const present = new Set(current.flatMap((probe) => [probe.id, probe.semantic_key]));
return previous.probes.flatMap((probe) => {
const answered = previous.answered_probes.some((item) => (
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
));
if (!answered) return [];
if (present.has(probe.id) || present.has(probe.semantic_key)) return [];
present.add(probe.id);
present.add(probe.semantic_key);
return [{ ...probe, carried: true }];
});
}
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
const rows = [...previous];
for (const item of incoming) {
@@ -792,6 +792,21 @@ export function isStructuredDiscriminator(probe: Pick<CandidateDiscriminatorProb
|| probe.semanticKey.startsWith("varga.");
}
const CLOCK_TOKEN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export function vargaSignPartitionKey(
layer: string,
signs: readonly (string | null | undefined)[],
): string {
const parts = signs.flatMap((item) => {
const raw = typeof item === "string" ? item.trim() : "";
if (!raw || CLOCK_TOKEN.test(raw)) return [];
return [signKey(raw)];
});
if (parts.length >= 2) return `varga.${layer}.${parts.join("|")}`;
return `varga.${layer}.unsigned`;
}
function vargaProbes(
remainingSplits: readonly RemainingVargaSplit[],
candidateSetVersion: string,
@@ -825,7 +840,7 @@ function vargaProbeFromRemaining(
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds]));
if (outcomes.length < 2 || ids.size < 2) return null;
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
const semanticKey = vargaSignPartitionKey(split.layer, split.signs);
const layerLabel = split.layer.toUpperCase();
const domain = remainingDomain(split.layer);
return {
@@ -91,7 +91,8 @@ function isConflictProbe(value: unknown): boolean {
&& isFiniteNumber(value.information_gain)
&& typeof value.source === "string" && value.source.length > 0
&& (value.choice_kind === undefined
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)));
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)))
&& (value.carried === undefined || value.carried === true || value.carried === false);
}
function isProbeAnswer(value: unknown): boolean {
@@ -91,6 +91,8 @@ export type ConflictProbe = Readonly<{
answer_class: AnswerClass;
sign?: string;
}>[];
/** Kept only so an answered probe can be replayed after the candidate set changes. */
carried?: boolean;
}>;
export type ProbeAnswer = Readonly<{
@@ -189,6 +189,30 @@ export function formatClockRange(range: readonly [string, string] | null | undef
return range[0] === range[1] ? range[0] : `${range[0]}–${range[1]}`;
}
export function rangeChangedAfterEvidence(
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string | null {
const fromText = formatClockRange(from);
const toText = formatClockRange(to);
if (!fromText || !toText || fromText === toText) return null;
return `范围从 ${fromText} 变为 ${toText}。`;
}
export function withRangeChangedAfterEvidence(
body: string,
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string {
const notice = rangeChangedAfterEvidence(from, to);
if (!notice) return body;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
return `${prefix}${notice}`;
}
export function openingRangeFromCandidateRange(
range: { start_time?: string | null; end_time?: string | null } | null | undefined,
): readonly [string, string] | null {
@@ -341,6 +365,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.divergenceTitle,
RECTIFICATION_USER_COPY.divergenceUnlike,
RECTIFICATION_USER_COPY.divergenceUnsure,
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
rangeDeliveryEventCopy(3),
rangeDeliveryStableCopy(["事业方向"]),
rangeDeliverySensitiveCopy(["婚恋(D9)"]),
@@ -34,9 +34,10 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice } from "../user-copy";
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
import { stripQuestionSentences } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -637,6 +638,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const emittedActivities = new Set<string>();
const repeatedCalls = new Map<string, number>();
let phaseSequence = 0;
const rangeBeforeCompare = previousInferenceFromReceipt(
dossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (
@@ -967,9 +971,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (stem) {
const stripped = stripQuestionSentences(answerText, stem);
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
if (next !== answerText) await emitVisibleSpoken(next);
if (next !== answerText) answerText = next;
}
}
if (toolsUsed.has("rectification-compare-candidates")) {
const rangeAfterCompare = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterCompare,
);
}
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
clearTimeout(timeout);