fix(rectification): host-fallback empty evidence turns and choice range narration (BUG-633, BUG-634)

Evidence turns that already recorded a batch no longer fail the whole run when the model emits no text; unchanged ranges now name which clock spans lead or lag, and the timeline no longer says 收窄.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:57:07 +08:00
co-authored by Cursor
parent 31f2a722aa
commit 0263658477
28 changed files with 817 additions and 49 deletions
@@ -214,5 +214,8 @@ function turnReceipt(
methods: receipt.methods,
started_at: receipt.startedAt,
completed_at: receipt.completedAt,
...(receipt.phases.some((phase) => phase.phase === "answer.host_fallback")
? { answer_origin: "host_fallback" as const }
: {}),
};
}
@@ -27,6 +27,7 @@ export type PersistedRectificationTurn = Readonly<{
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
answer_origin?: "host_fallback";
}> | null;
}>;
@@ -60,6 +60,8 @@ import {
rectificationConversationState,
rectificationInitialLiveLabel,
rectificationQuestionGapState,
rectificationReadonlyRangeCopy,
contrastProbesFromReceipt,
searchWindowFromSnapshot,
caseStageFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -177,12 +179,23 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n
function RectificationReadonlyRange({
range,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
}: Readonly<{
range: readonly [string, string];
sessionOutcome: string | null;
discriminatingEventProbes: unknown;
contrastProbes: unknown;
}>) {
return (
<p className="rectification-readonly-range" role="status">
目前范围 {range[0]}–{range[1]},还在收窄
{rectificationReadonlyRangeCopy({
range,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
})}
</p>
);
}
@@ -986,6 +999,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
} else {
await loadCaseSnapshot((turns) => {
if (turns.length) {
setMessages((current) => mergeTurnQuestions(current, turns));
}
});
}
} catch (caught) {
frames.settle();
@@ -1487,6 +1506,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
liveQuestionVisible: liveQuestionOnMessages,
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable",
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus"),
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
nextUserActionId,
busy,
@@ -1726,11 +1746,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
sessionOutcome={candidateResult.sessionOutcome}
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
/>
)}
</div>
);
})}
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
</div>
</div>
)}
{questionGap === "preparing" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap">
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
@@ -1777,7 +1807,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
: collectSpokenPrompt
: (liveQuestionOnMessages || questionGap === "persisted_question") && collectSpokenPrompt
? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
@@ -59,6 +59,12 @@ import {
type PublicStreamEvent,
} from "./stream-mapping";
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
import {
batchResultFromToolChunk,
composeHostFallbackNarration,
lastCompletedPublicTool,
publicWriteToolCompleted,
} from "./host-fallback";
import {
applyStepAnswerChunk,
createStepAnswerState,
@@ -224,8 +230,14 @@ function isRetryableError(errorCode: string): boolean {
return RETRYABLE_ERROR_CODES.has(errorCode);
}
function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean {
return !signal?.aborted && isRetryableError(errorCode);
function shouldAutoRetry(
errorCode: string,
signal?: AbortSignal,
status?: AttemptStatus,
): boolean {
if (signal?.aborted) return false;
if (errorCode === "empty_stream") return status === "retryable";
return isRetryableError(errorCode);
}
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): string | null {
@@ -484,7 +496,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
finalOutcome = outcome;
if (outcome.ok
|| outcome.status === "failed"
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal, outcome.status)
|| attemptNumber === MAX_ATTEMPTS) break;
await emit({ type: "attempt.reset" });
}
@@ -671,12 +683,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let intentClassified = false;
let streamFailed = false;
let finished = false;
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
let answerText = "";
const answerDeltas: string[] = [];
const phases: string[] = [];
const toolsUsed = new Set<string>();
const events: PublicStreamEvent[] = [];
const toolTerminalStatus = new Map<string, "completed" | "failed">();
let batchToolResult: unknown = null;
let hostFallbackUsed = false;
const emittedKeys = new Set<string>();
const emittedActivities = new Set<string>();
const repeatedCalls = new Map<string, number>();
@@ -771,7 +786,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
},
});
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
const stepAnswer = createStepAnswerState();
let spokenRaw = "";
@@ -819,6 +833,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
};
const applyHostFallback = async (): Promise<boolean> => {
if (answerText.trim()) return false;
if (toolTerminalStatus.get("rectification-record-evidence-batch") !== "completed") {
return false;
}
const spoken = composeHostFallbackNarration(batchToolResult ?? {});
if (!spoken) return false;
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(spoken);
return true;
};
try {
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
@@ -868,6 +896,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const capturedBatch = batchResultFromToolChunk(chunk as never);
if (capturedBatch != null) batchToolResult = capturedBatch;
const phaseEvent = skipDuplicateToolCallReceipt
? null
: mapStreamChunkToPhase(chunk as never);
@@ -972,14 +1002,36 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
if (mapped === "answer_truncated") {
if (!await applyHostFallback()) {
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
} else if (mapped === "max_steps" || mapped === "provider_error") {
if (mapped !== "max_steps" || !await applyHostFallback()) {
return failedAttempt(attemptId, mapped);
}
} else if (!answerText.trim() && !await applyHostFallback()) {
const retryable = !publicWriteToolCompleted(toolTerminalStatus);
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
status: retryable ? "retryable" : "failed",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
@@ -987,10 +1039,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
attemptId,
};
}
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
let latestDossier: V9CaseDossier;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
@@ -1038,6 +1086,21 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
console.info(JSON.stringify({
scope: "RectificationRunDiagnostic",
runId: attemptId,
modelId: options.modelName,
finishReason: finishReason ?? "unknown",
inputTokens: null,
reasoningTokens: null,
outputTokens: null,
stepCount: toolsUsed.size,
toolCallCount: toolsUsed.size,
readCasePayloadBytes: null,
elapsedMs: Date.now() - startedAt,
lastCompletedTool: lastCompletedPublicTool(toolTerminalStatus),
stateMutationCommitted: publicWriteToolCompleted(toolTerminalStatus) || hostFallbackUsed,
}));
}
}
@@ -18,6 +18,7 @@ export const MACHINE_VOICE_LEXICON = [
"请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病",
"有没有记得住时间的收入变化、大笔支出或欠债",
"继续收窄",
"还在收窄",
"范围还在",
"领先",
"落后",
@@ -19,6 +19,7 @@ import {
} from "./choice-card";
import {
explainRangeChange,
explainScoreMovement,
type ClusterScoreDelta,
} from "./probe-explain.ts";
@@ -118,7 +119,12 @@ export function composeChoiceNarration(input: {
}
if (input.appliedInference) {
const range = explainRangeChange(input.credibleBefore, input.credibleAfter);
return `已记录,${range || "范围没变"}。`;
if (range && range !== "范围没变") {
return `已记录,${range}。`;
}
const movement = explainScoreMovement(input.deltasByCluster ?? []);
if (movement) return `已记录,范围没变;${movement}。`;
return "已记录,范围没变。";
}
return "已记录你的选择。";
}
@@ -192,6 +192,11 @@ export function displayDateLabel(
return from || "日期不明";
}
/** Short event phrase from a ledger summary. Does not invent years. */
export function eventPhraseFromSummary(summary: string): string {
return summary.replace(/\s+/g, " ").trim().replace(/[。..!?!?]+$/u, "").slice(0, 24);
}
export function quoteIsGroundedInMessage(
userMessage: string,
quote: string,
@@ -0,0 +1,78 @@
/**
* Host-spoken fallback when the model records evidence but emits no answer.
* Recap text comes only from the batch tool return.
*/
export const HOST_FALLBACK_WRITE_TOOLS = [
"rectification-record-evidence-batch",
"rectification-set-focus",
"rectification-compare-candidates",
] as const;
export type HostFallbackRecap = Readonly<{
display_date_label?: string;
event_phrase?: string;
}>;
export function publicWriteToolCompleted(
status: ReadonlyMap<string, "completed" | "failed">,
): boolean {
return HOST_FALLBACK_WRITE_TOOLS.some((tool) => status.get(tool) === "completed");
}
export function lastCompletedPublicTool(
status: ReadonlyMap<string, "completed" | "failed">,
): string | null {
let last: string | null = null;
for (const [tool, value] of status) {
if (value === "completed") last = tool;
}
return last;
}
function recapLine(item: HostFallbackRecap): string {
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
return [label, phrase].filter(Boolean).join(" ").trim();
}
function recapsFromBatchResult(value: unknown): HostFallbackRecap[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const row = value as {
accepted_recaps?: unknown;
items?: unknown;
};
if (Array.isArray(row.accepted_recaps)) {
return row.accepted_recaps.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
return [item as HostFallbackRecap];
});
}
if (!Array.isArray(row.items)) return [];
return row.items.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const entry = item as HostFallbackRecap & { outcome?: unknown };
if (entry.outcome && entry.outcome !== "accepted") return [];
if (!entry.display_date_label && !entry.event_phrase) return [];
return [entry];
});
}
export function batchResultFromToolChunk(chunk: {
type?: string;
payload?: { toolName?: unknown; result?: unknown; output?: unknown };
object?: unknown;
}): unknown {
if (chunk.type !== "tool-result") return null;
if (chunk.payload?.toolName !== "rectification-record-evidence-batch") return null;
return chunk.payload?.result ?? chunk.payload?.output ?? chunk.object ?? null;
}
export function composeHostFallbackNarration(batchResult: unknown): string | null {
if (batchResult == null) return null;
const lines = recapsFromBatchResult(batchResult)
.map(recapLine)
.filter(Boolean);
if (lines.length > 0) return `记下了:${lines.join("、")}。`;
return "记下了。";
}
@@ -7,6 +7,7 @@ export const RECTIFICATION_MESSAGE_ORIGINS = [
"voice_input",
"retry_replay",
"system_recovery",
"host_fallback",
] as const;
export type RectificationMessageOrigin = (typeof RECTIFICATION_MESSAGE_ORIGINS)[number];
@@ -211,15 +211,23 @@ export function clusterScoreDeltas(
return [...summed.values()].filter((item) => item.delta !== 0);
}
function clockSpanLabel(range: readonly [string, string]): string {
const start = range[0]?.slice(0, 5) ?? "";
const end = range[1]?.slice(0, 5) ?? "";
if (!CLOCK.test(start)) return "";
if (!CLOCK.test(end) || start === end) return start;
return `${start}–${end}`;
}
export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): string {
if (deltas.length === 0) return "";
const rising = [...deltas].filter((item) => item.delta > 0).sort((a, b) => b.delta - a.delta)[0] ?? null;
const falling = [...deltas].filter((item) => item.delta < 0).sort((a, b) => a.delta - b.delta)[0] ?? null;
const up = rising ? formatClusterRange(rising.range) : "";
const down = falling ? formatClusterRange(falling.range) : "";
if (up && down) return `${up}领先,${down}落后`;
if (up) return `${up}领先`;
if (down) return `${down}落后`;
const up = rising ? clockSpanLabel(rising.range) : "";
const down = falling ? clockSpanLabel(falling.range) : "";
if (up && down) return `${up} 领先,${down} 落后`;
if (up) return `${up} 领先`;
if (down) return `${down} 落后`;
return "";
}
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"candidate.accepted",
"birth_time.confirmed",
"answer.composed",
"answer.host_fallback",
"billing.settled",
"answer.delta",
"activity.changed",
@@ -136,7 +136,16 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
if (!Array.isArray(value)) return [];
return value.map((turn: RawTurn) => {
const receipt = turn?.receipt && typeof turn.receipt === "object"
? turn.receipt as { status?: unknown; phases?: unknown; tools?: unknown; methods?: unknown; skill_name?: unknown; skill_version?: unknown; tool_activities?: unknown }
? turn.receipt as {
status?: unknown;
phases?: unknown;
tools?: unknown;
methods?: unknown;
skill_name?: unknown;
skill_version?: unknown;
tool_activities?: unknown;
answer_origin?: unknown;
}
: null;
const toolActivities = parseToolActivities(receipt?.tool_activities);
return {
@@ -154,6 +163,7 @@ export function parsePersistedRectificationTurns(value: unknown): PersistedRecti
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
...(toolActivities ? { tool_activities: toolActivities } : {}),
...(receipt.answer_origin === "host_fallback" ? { answer_origin: "host_fallback" as const } : {}),
} : null,
};
});
@@ -245,7 +255,12 @@ export function rectificationConversationState(input: Readonly<{
return "empty";
}
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable" | "verified_idle";
export type RectificationQuestionGapState =
| "idle"
| "preparing"
| "unavailable"
| "verified_idle"
| "persisted_question";
export type RectificationQuestionGapInput = Readonly<{
/** The current question is rendered live inside an assistant message. */
@@ -258,6 +273,11 @@ export type RectificationQuestionGapInput = Readonly<{
offerAwaitingReader?: boolean;
/** GET / structured-choice `next_user_action.id`; `start_consultation` means post-adopt verify is done. */
nextUserActionId?: string | null;
/**
* Snapshot has a current question from a persisted focus, even if no
* settled assistant message carries it yet.
*/
questionPersisted?: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -282,6 +302,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.questionPersisted) return "persisted_question";
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
@@ -294,6 +315,39 @@ export function rectificationQuestionRetryActive(state: RectificationQuestionGap
return state === "preparing";
}
function probeHasYear(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const year = (value as { year?: unknown }).year;
return typeof year === "number" && Number.isFinite(year) && year > 0;
}
function listHasDatedProbe(value: unknown): boolean {
if (!Array.isArray(value)) return false;
return value.some(probeHasYear);
}
export function contrastProbesFromReceipt(receipt: Readonly<Record<string, unknown>> | null | undefined): unknown {
const packet = receipt?.candidate_contrast_packet;
if (!packet || typeof packet !== "object" || Array.isArray(packet)) return null;
return (packet as { probes?: unknown }).probes ?? null;
}
/** Timeline range line. Never says 收窄. */
export function rectificationReadonlyRangeCopy(input: Readonly<{
range: readonly [string, string];
sessionOutcome?: string | null;
discriminatingEventProbes?: unknown;
contrastProbes?: unknown;
}>): string {
const datedOpen = listHasDatedProbe(input.discriminatingEventProbes)
|| listHasDatedProbe(input.contrastProbes);
const choicesExhausted = input.sessionOutcome === "discriminate_candidates" && !datedOpen;
const suffix = choicesExhausted
? "选择题已问完,再补带年月的经历才会变"
: "还在核对";
return `目前范围 ${input.range[0]}–${input.range[1]},${suffix}`;
}
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
export function rectificationAdoptingLabel(time: string): string {
return `正在采用 ${time}…`;
@@ -49,6 +49,7 @@ import {
isEvidenceDomain,
isDatePrecision,
displayDateLabel,
eventPhraseFromSummary,
evidenceSubjectForDomain,
applyOccupationCollectLedgerNorm,
} from "@/lib/rectification-agentic/v9/evidence-model";
@@ -1484,6 +1485,18 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const rescore = result.acceptedCount > 0
? await autoRescoreAfterEvidenceChange(input.caseId)
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
const acceptedRecaps = scoringItems.flatMap(({ item }, offset) => {
const recorded = result.items[offset];
if (!recorded || recorded.outcome !== "accepted") return [];
const label = displayDateLabel(
item.datePrecision,
item.occurredFrom ?? null,
item.occurredTo ?? null,
);
const phrase = eventPhraseFromSummary(item.summary);
if (!label && !phrase) return [];
return [{ display_date_label: label, event_phrase: phrase }];
});
const projection = {
items: result.items.map((item) => ({
index: item.index,
@@ -1499,6 +1512,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
clarification_fields: item.clarificationFields,
error_code: item.errorCode === "quote_not_grounded" ? "quote_mismatch" : item.errorCode,
})),
accepted_recaps: acceptedRecaps,
accepted_count: result.acceptedCount,
needs_clarification_count: result.needsClarificationCount,
rejected_count: result.rejectedCount,