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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user