fix(rectification): prevent silent collect focus stalls
Independent Staging Quality Gate / validate (push) Successful in 20m38s
Independent Staging Quality Gate / publish (push) Successful in 24m14s

This commit is contained in:
Jesse_Chen
2026-08-30 23:07:17 +08:00
parent 0ae3e2d796
commit 79bfa73da3
5 changed files with 468 additions and 20 deletions
@@ -5,6 +5,7 @@ import {
evidenceLedgerFingerprint,
loadV9CaseCompute,
loadV9CaseDossier,
loadV9TurnReceipt,
persistV9DeterministicTurn,
RectificationToolServiceError,
transitionV9CaseStatus,
@@ -13,6 +14,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
persistCollectSpokenAssistantIfNew,
} from "@/lib/rectification-agentic/v9/answer-choice";
@@ -683,6 +685,22 @@ export async function POST(request: Request) {
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const receipt = await loadV9TurnReceipt(accounting as never, userId, caseId, result.turnId);
const exit = await ensureNonTerminalTurnExit({
accounting: accounting as never,
userId,
caseId,
adoptCarrierReady: Boolean(receipt?.toolActivities.some((activity) => (
activity.tool === "rectification-offer-candidates" && activity.status === "completed"
))),
});
idleHostNarration = exit.hostNarration ?? idleHostNarration;
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
const fallback = await persistCollectSpokenAssistantIfNew({
accounting: accounting as never,
@@ -52,6 +52,7 @@ import { isSafeCollectSpokenPrompt } from "./spoken-answer";
import {
collectSpokenPromptForNewFocus,
composeCollectSpokenAssistantText,
projectCurrentQuestion,
} from "./turn-decision";
export type ApplyChoiceCommand = Readonly<{
@@ -287,7 +288,7 @@ export async function persistNextInterviewAfterChoice(input: {
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string | null; choiceReady: boolean }> {
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
const latest = input.dossier.latestResult
? {
...input.dossier.latestResult,
@@ -351,7 +352,17 @@ export async function persistNextInterviewAfterChoice(input: {
) {
return { hostNarration: spoken, choiceReady: false };
}
return { hostNarration: null, choiceReady: false };
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
decision: {
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
},
decisionReceipt: latest.decisionReceipt,
});
}
if (!followup) {
if (isNonConvergingRangeOffer({
@@ -393,8 +404,9 @@ async function persistFocusAfterChoice(input: {
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
}) {
let persisted;
try {
return await persistServerOwnedFocus({
persisted = await persistServerOwnedFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -406,13 +418,32 @@ async function persistFocusAfterChoice(input: {
console.warn(
`[rectification-v9] persist next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
return {
persisted = {
status: "skipped" as const,
focus: null,
questionId: null,
prompt: null,
};
}
if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) {
return persisted;
}
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
return await persistServerOwnedFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: dossier.conversationSummary.activeFocus,
decisionReceipt: input.decisionReceipt,
followup: input.followup,
});
} catch (error) {
console.warn(
`[rectification-v9] retry next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
return persisted;
}
}
export async function applyCollectFocusDenial(
@@ -470,7 +501,7 @@ export async function applyCollectFocusDenial(
birthDate,
});
return {
narration: nextInterview.hostNarration ?? "记下了,这方面先跳过。",
narration: nextInterview.hostNarration,
nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
};
@@ -567,7 +598,7 @@ async function persistExhaustionCollect(input: {
return {
persisted,
choiceReady: false,
hostNarration: persisted && spoken
hostNarration: spoken
? composeCollectSpokenAssistantText(range, spoken).composed
: range,
};
@@ -690,7 +721,11 @@ async function persistApplied(
});
nextChoiceReady = nextInterview.choiceReady;
if (nextInterview.hostNarration) {
hostNarration = nextInterview.hostNarration;
hostNarration = nextInterview.persisted === false
? `${input.narration}
${nextInterview.hostNarration}`
: nextInterview.hostNarration;
nextInterviewPersisted = true;
}
}
@@ -744,6 +779,47 @@ async function persistApplied(
};
}
export async function ensureNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
adoptCarrierReady: boolean;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
if (
dossier.case.acceptedTime
|| dossier.case.confirmedTime
|| decision.completionStatus === "provisional_range_user_stopped"
|| (decision.canAdopt && input.adoptCarrierReady)
) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
console.warn(JSON.stringify({
event: "rectification_nonterminal_exit_repaired",
case_id: input.caseId,
reason: "missing_question_and_adopt_carrier",
}));
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
});
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice)
? schema.choice as Record<string, unknown>