fix(rectification): prevent silent collect focus stalls
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user