fix(rectification): replay answered probes after evidence rescore (BUG-587, BUG-588)
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user