/** * Deterministic A/B/C/D and stop handler. * * Clicks never start a language-model turn. The server validates the open * question, writes the probe answer, updates posteriors, closes the focus and * returns a fallback narration in one receipt. */ import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { nextProbe } from "../core/build-state"; import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, publicCanAdopt, publicNextAction } from "../core/rectification-decision.ts"; import { deliveryAdoptNarration, openingRangeFromCandidateRange, RECTIFICATION_USER_COPY, } from "../user-copy.ts"; import { applyChoiceWithoutEvidence, previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier"; import type { AnswerClass, InferenceState } from "../core/types.ts"; import { CHOICE_ACTION, STOP_ACTION, composeChoiceNarration, focusStatusForAnswer, outcomeIdForOption, shouldContinueAfterStructuredChoice, structuredProbeContext, type ChoiceOptionId, } from "./choice-action"; import { evidenceLedgerFingerprint, inferenceFingerprintForState, loadV9CaseCompute, loadV9CaseDossier, persistV9ChoiceAction, persistV9DeterministicTurn, resolveV10ConversationFocus, RectificationToolServiceError, safeToolErrorCode, type AccountingClient, type ConversationFocus, type PersistChoiceActionInput, type V9CaseDossier, } from "./tool-service"; import { isPersistedFocusId, type ChoiceKey } from "./choice-card"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn } from "./server-focus"; import { blockingMethodsCovered, buildMethodFollowupPlan, exhaustionSpokenCollectFollowup, GENERIC_COLLECT_QUESTION, spokenCollectFallbackFollowup, spokenFollowupForUser, type MethodCoverage, type MethodFollowup, type MethodFollowupPlan, } from "./method-followup"; import type { SessionOutcomeKind } from "./confirmation-gate"; import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet"; import { projectCurrentQuestion } from "./turn-decision"; function withProspectiveWindows( base: string, receipt: Readonly> | null | undefined, ): string { const extra = prospectiveWindowsNarration( refinementFromDecisionReceipt(receipt).prospective_probes, ); return extra ? `${base} ${extra}` : base; } function openingRangeFromDossier(dossier: { case?: { acceptedTime?: string | null; status?: string; candidateRange?: { start_time?: string; end_time?: string } | null; } | null; }): readonly [string, string] | null { return openingRangeFromCandidateRange(dossier.case?.candidateRange ?? null); } function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null { return plan.next_followup ?? plan.deferred_followup ?? null; } function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean { if (!followup) return false; return followup.source === "event_probe" || followup.source === "varga_observation" || followup.source === "precision_stage" || followup.source === "nakshatra_boundary" || followup.intent === "distinguish_candidates"; } function shouldSkipFollowupPersist(input: { canAdopt: boolean; nextAction: string; followup: MethodFollowup | null; methods?: readonly MethodCoverage[]; }): boolean { if (!input.canAdopt) return false; if ( input.nextAction === "ask_fact_collection" || input.nextAction === "ask_candidate_discriminator" || input.nextAction === "ask_holdout_validation" ) { return false; } if (input.methods && !blockingMethodsCovered(input.methods)) return false; if (isRemainingDiscriminatorFollowup(input.followup)) return false; return true; } function adoptHostNarration(input: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null; openingRange: readonly [string, string] | null; receipt: Readonly> | null | undefined; }): string { return withProspectiveWindows(deliveryAdoptNarration({ credibleRange: input.credibleRange, representativeTime: input.representativeTime, openingRange: input.openingRange, stopReason: typeof input.receipt?.stopReason === "string" ? input.receipt.stopReason : null, }), input.receipt); } export type ApplyChoiceCommand = Readonly<{ userId: string; caseId: string; sessionId: string; actionId: string; action: typeof CHOICE_ACTION | typeof STOP_ACTION; focusId: string; questionId?: string; probeId?: string | null; optionId: ChoiceOptionId; expectedRevision: number; userDisplay?: string | null; deferFollowup?: boolean; }>; export type AppliedChoiceReceipt = Readonly<{ applied: true; idempotent: boolean; status: "applied" | "narrated"; narrationPersisted: boolean; focusId: string; questionId: string; optionId: ChoiceOptionId; probeId: string | null; revision: number; answerClass: string | null; sourceQuote: string | null; derivedContext: PersistChoiceActionInput["derivedContext"]; narration: string; userDisplay: string | null; nextAction: ReturnType; nextInterviewPersisted: boolean; nextChoiceReady: boolean; }>; function asText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function dossierWithClosedFocus>[]; }; }>(dossier: T, status: "resolved" | "declined" | "skipped"): T { const domain = dossier.conversationSummary.activeFocus?.targetDomain ?? null; const declinedSkippedTopics = ( (status === "declined" || status === "skipped") && domain ? [ ...dossier.conversationSummary.declinedSkippedTopics, { target_domain: domain, status }, ] : dossier.conversationSummary.declinedSkippedTopics ); return { ...dossier, conversationSummary: { ...dossier.conversationSummary, activeFocus: null, declinedSkippedTopics, }, }; } export async function applyRectificationChoice( accounting: AccountingClient, command: ApplyChoiceCommand, ): Promise { const dossier = await loadV9CaseDossier(accounting, command.userId, command.caseId); if (dossier.case.sessionId !== command.sessionId) { throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch"); } const focus = dossier.conversationSummary.activeFocus; if (!focus) { throw new RectificationToolServiceError("agentic_rectification_focus_not_active"); } if (command.focusId !== focus.id) { throw new RectificationToolServiceError("agentic_rectification_stale_question"); } const schema = focus.expectedAnswerSchema; const schemaProbeId = asText(schema.probe_id); const optionId = command.optionId; const questionId = focus.questionId; const scoring = schema.scoring !== false && !questionId.endsWith(":holdout"); const receipt = dossier.latestResult?.decisionReceipt ?? null; const previous = withNakshatraBoundaryProbe( previousInferenceFromReceipt(receipt), refinementFromDecisionReceipt(receipt).nakshatra_boundary, ); const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema); if (optionId !== "stop" && !answerClass) { throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema"); } if (optionId === "stop" || command.action === STOP_ACTION) { const narration = composeChoiceNarration({ optionId: "stop", scoring, appliedInference: false, }); return persistApplied(accounting, command, { focusId: focus.id, questionId, focusStatus: "skipped", probeId: schemaProbeId, optionId: "stop", scoring, appliedInference: false, answerClass: null, sourceQuote: null, year: null, expectedRevision: previous?.revision ?? command.expectedRevision, inference: null, narration, userDisplay: "先这样,先看当前范围", decisionState: previous, userStopped: true, dossier, }); } if (!previous) { const narration = composeChoiceNarration({ optionId, scoring, appliedInference: false, answerClass, }); return persistApplied(accounting, command, { focusId: focus.id, questionId, focusStatus: focusStatusForAnswer(answerClass, optionId), probeId: schemaProbeId ?? command.probeId ?? null, optionId, scoring, appliedInference: false, answerClass, sourceQuote: optionQuoteFromSchema(schema, optionId), year: null, expectedRevision: command.expectedRevision, inference: null, narration, userDisplay: command.userDisplay ?? userDisplayFromSchema(schema, optionId), dossier, }); } const applied = applyChoiceWithoutEvidence(previous, { choiceKey: optionId, schema, questionId, domain: focus.targetDomain, }); if (applied.reason === "stale_probe") { throw new RectificationToolServiceError("agentic_rectification_stale_probe"); } if (command.expectedRevision !== previous.revision && applied.reason !== "already_answered") { throw new RectificationToolServiceError("agentic_rectification_revision_conflict"); } const persistable = applied.applied || applied.reason === "already_answered" || applied.reason === "superseded" || applied.reason === "holdout"; const probe = applied.probeId ? applied.state.probes.find((item) => item.id === applied.probeId) ?? previous.probes.find((item) => item.id === applied.probeId) ?? nextProbe(previous) : null; const sourceQuote = optionQuoteFromSchema(schema, optionId); const narration = composeChoiceNarration({ optionId, scoring, appliedInference: persistable && scoring, answerClass, }); const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint ?? evidenceLedgerFingerprint(dossier.evidence); const writeInference = persistable && applied.answerClass && (scoring || applied.reason === "holdout") && (applied.probeId || applied.reason === "holdout"); const inference = writeInference ? { expectedRevision: previous.revision, probeId: applied.probeId ?? schemaProbeId ?? "holdout", openProbeId: schemaProbeId ?? applied.probeId ?? "holdout", semanticKey: probe?.semantic_key ?? "holdout", candidateSplitHash: probe?.candidate_split_hash ?? "holdout", answerClass: applied.answerClass, rawAnswer: optionId, inferenceState: applied.state as unknown as Record, posteriorBefore: posteriorMap(previous.candidates), posteriorAfter: posteriorMap(applied.state.candidates), scoreDeltas: scoreDeltas( posteriorMap(previous.candidates), posteriorMap(applied.state.candidates), ), decisionStateFingerprint: inferenceFingerprintForState( command.caseId, evidenceFp, applied.state, ), reason: applied.reason === "superseded" ? "supersede" as const : applied.reason === "already_answered" ? "already_answered" as const : applied.reason === "holdout" ? "choice" as const : "choice" as const, idempotencyKey: `${applied.reason === "superseded" ? "supersede" : applied.reason === "holdout" ? "holdout" : "choice"}:${applied.probeId ?? "holdout"}:${applied.answerClass}`, candidateSetId: applied.state.candidate_set_id, } : null; return persistApplied(accounting, command, { focusId: focus.id, questionId, focusStatus: focusStatusForAnswer(applied.answerClass, optionId), probeId: applied.probeId ?? schemaProbeId, optionId, scoring, appliedInference: Boolean(inference), answerClass: applied.answerClass, sourceQuote, year: probe?.year ?? null, expectedRevision: previous.revision, inference, narration, userDisplay: command.userDisplay ?? userDisplayFromSchema(schema, optionId), decisionState: applied.state, userStopped: false, dossier, }); } export async function persistNextInterviewAfterChoice(input: { accounting: AccountingClient; userId: string; caseId: string; dossier: Parameters[0]["dossier"]; decisionState: InferenceState | null; nextAction: ReturnType; birthDate?: string | null; askedTurnId?: string | null; }): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean; focusId?: string | null; focus?: ConversationFocus | null; }> { const latest = input.dossier.latestResult ? { ...input.dossier.latestResult, decisionReceipt: { ...(input.dossier.latestResult.decisionReceipt ?? {}), ...(input.decisionState ? { inference_state: input.decisionState } : {}), }, } : { decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null, }; const birthDate = input.birthDate ?? null; const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence); const sessionOutcome = typeof input.nextAction.session_outcome === "string" ? input.nextAction.session_outcome as SessionOutcomeKind : "collect_evidence"; const plan = buildMethodFollowupPlan({ evidence: input.dossier.evidence, activeFocus: null, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, sessionOutcome, ...catalog, birthDate, accepted: Boolean(input.dossier.case.acceptedTime), candidatesSeparated: input.nextAction.type !== "ask_candidate_discriminator" && input.nextAction.type !== "ask_holdout_validation", }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ canAdopt: input.nextAction.can_adopt, nextAction: input.nextAction.type, followup, methods: plan.methods, })) { return { hostNarration: adoptHostNarration({ credibleRange: input.nextAction.credible_range, representativeTime: input.nextAction.representative_time, openingRange: openingRangeFromDossier(input.dossier), receipt: input.dossier.latestResult?.decisionReceipt, }), choiceReady: false, persisted: false, }; } const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, decisionReceipt: latest.decisionReceipt, followup, askedTurnId: input.askedTurnId ?? null, }); const open = openQuestionFromPersistedFocus(persistedFocus); if (isRenderableChoiceOpenQuestion(open) && open.prompt) { return { hostNarration: open.prompt, choiceReady: true, focusId: persistedFocus.focus?.id ?? null, focus: persistedFocus.focus, }; } if (open?.kind === "collect_spoken" && open.prompt) { return { hostNarration: open.prompt, choiceReady: false, persisted: true, focusId: persistedFocus.focus?.id ?? null, focus: persistedFocus.focus, }; } if (followup?.choice_frame) { const spokenFollowup = spokenCollectFallbackFollowup(followup); const spoken = spokenFollowupForUser(spokenFollowup) ?? GENERIC_COLLECT_QUESTION; const fallback = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, decisionReceipt: latest.decisionReceipt, followup: { ...spokenFollowup, user_prompt_hint: spoken, }, askedTurnId: input.askedTurnId ?? null, }); return { hostNarration: spoken, choiceReady: false, focusId: fallback.focus?.id ?? null, focus: fallback.focus, }; } if (followup?.intent === "collect_method_evidence") { const spoken = spokenFollowupForUser(followup); if ( spoken && (persistedFocus.status === "created" || persistedFocus.status === "already_open") ) { return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus }; } 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, askedTurnId: input.askedTurnId ?? null, }); } if (!followup) { if (isNonConvergingRangeOffer({ canOfferRange: input.nextAction.can_offer_range, canAdopt: input.nextAction.can_adopt, canConfirmExactMinute: input.nextAction.can_confirm_exact_minute, nextAction: input.nextAction.type, })) { 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, askedTurnId: input.askedTurnId ?? null, }); } return { hostNarration: withProspectiveWindows(nonConvergingRangeNarration({ credibleRange: input.nextAction.credible_range, representativeTime: input.nextAction.representative_time, openingRange: openingRangeFromDossier(input.dossier), variant: input.nextAction.can_adopt ? "delivery" : "intermediate", stopReason: input.nextAction.stop_reason, }), latest.decisionReceipt), choiceReady: false, }; } return { hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback, choiceReady: false, }; } async function persistFocusAfterChoice(input: { accounting: AccountingClient; userId: string; caseId: string; decisionReceipt: Readonly> | null | undefined; followup: ReturnType["next_followup"]; askedTurnId?: string | null; }) { let persisted; try { persisted = await persistServerOwnedFocus({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, activeFocus: null, decisionReceipt: input.decisionReceipt, followup: input.followup, askedTurnId: input.askedTurnId ?? null, }); } catch (error) { console.warn( `[rectification-v9] persist next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, ); 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, askedTurnId: input.askedTurnId ?? null, }); } catch (error) { console.warn( `[rectification-v9] retry next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, ); return persisted; } } export async function applyCollectFocusDenial( accounting: AccountingClient, input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean }, ): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> { const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId); const focus = dossier.conversationSummary.activeFocus; if (!focus || focus.id !== input.focusId) { throw new RectificationToolServiceError("agentic_rectification_focus_not_active"); } await resolveV10ConversationFocus(accounting, input.userId, input.caseId, { focusId: input.focusId, status: "declined", evidenceId: null, }); if (input.deferFollowup === true) { return { narration: "记下了,这方面先跳过。", nextInterviewPersisted: false, nextChoiceReady: false, }; } let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId); birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; } catch { birthDate = null; } let latest: V9CaseDossier = dossier; try { latest = await loadV9CaseDossier(accounting, input.userId, input.caseId); } catch { latest = dossier; } const withDeclined = dossierWithClosedFocus({ ...latest, conversationSummary: { ...latest.conversationSummary, activeFocus: { ...focus, targetDomain: focus.targetDomain, }, }, }, "declined"); const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate })); const nextInterview = await persistNextInterviewAfterChoice({ accounting, userId: input.userId, caseId: input.caseId, dossier: withDeclined, decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null), nextAction, birthDate, }); return { narration: nextInterview.hostNarration, nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, nextChoiceReady: nextInterview.choiceReady, }; } export function isStalePreAdoptFocus( acceptedTime: string | null | undefined, focus: { intent?: string | null } | null | undefined, ): boolean { if (!acceptedTime || !focus) return false; const intent = focus.intent ?? ""; return intent !== "reverse_verify" && intent !== "out_of_sample_check"; } export async function persistNextInterviewIfIdle(input: { accounting: AccountingClient; userId: string; caseId: string; askedTurnId?: string | null; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); const staleFocus = dossier.conversationSummary.activeFocus; const staleFocusId = staleFocus?.id; if ( isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus) && isPersistedFocusId(staleFocusId) ) { try { await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { focusId: staleFocusId, status: "skipped", }); dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); } catch (error) { console.warn( `[rectification-v9] close stale pre-adopt focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, ); } } if (dossier.conversationSummary.activeFocus) { if (input.askedTurnId) { try { await linkFocusAskedTurn({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, focus: dossier.conversationSummary.activeFocus, askedTurnId: input.askedTurnId, }); } catch (error) { console.warn( `[rectification-v9] link idle focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, ); } } 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 }); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: null, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, ...catalog, birthDate, accepted: Boolean(dossier.case.acceptedTime), }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ canAdopt: publicCanAdopt(decision), nextAction: decision.nextAction, followup, methods: plan.methods, })) { return { persisted: false, choiceReady: false, hostNarration: adoptHostNarration({ credibleRange: decision.credibleRange, representativeTime: decision.representativeTime, openingRange: openingRangeFromDossier(dossier), receipt: dossier.latestResult?.decisionReceipt, }), }; } if (isNonConvergingRangeOffer(decision)) { return persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, dossier, decision, decisionReceipt: dossier.latestResult?.decisionReceipt, askedTurnId: input.askedTurnId ?? null, }); } if (!followup) { return { persisted: false, choiceReady: false, hostNarration: null }; } const nextAction = publicNextAction(decision); const nextInterview = await persistNextInterviewAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, dossier, decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null), nextAction, birthDate, askedTurnId: input.askedTurnId ?? null, }); return { persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, choiceReady: nextInterview.choiceReady, hostNarration: nextInterview.hostNarration, }; } async function persistExhaustionCollect(input: { accounting: AccountingClient; userId: string; caseId: string; dossier: { evidence: Parameters[0]["evidence"]; conversationSummary: { declinedSkippedTopics?: readonly Readonly>[] }; latestResult?: { decisionReceipt?: Readonly> | null } | null; case?: { acceptedTime?: string | null; status?: string; candidateRange?: { start_time?: string; end_time?: string } | null; }; }; decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null }; decisionReceipt?: Readonly> | null; askedTurnId?: string | null; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string; focus?: ConversationFocus | null; }> { const followup = exhaustionSpokenCollectFollowup({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, }); const range = nonConvergingRangeNarration({ ...input.decision, openingRange: openingRangeFromDossier(input.dossier), variant: "intermediate", }); const spoken = spokenFollowupForUser(followup); const persistedFocus = followup ? await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, decisionReceipt: input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt, followup, askedTurnId: input.askedTurnId ?? null, }) : { status: "skipped" as const, focus: null, questionId: null, prompt: null }; const persisted = Boolean(spoken) && ( persistedFocus.status === "created" || persistedFocus.status === "already_open" ); return { persisted, choiceReady: false, hostNarration: withProspectiveWindows(range, input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt), focus: persistedFocus.focus, }; } async function persistApplied( accounting: AccountingClient, command: ApplyChoiceCommand, input: { focusId: string; questionId: string; focusStatus: "resolved" | "declined" | "skipped"; probeId: string | null; optionId: ChoiceOptionId; scoring: boolean; appliedInference: boolean; answerClass: AnswerClass | null; sourceQuote: string | null; year: number | null; expectedRevision: number; inference: PersistChoiceActionInput["inference"]; narration: string; userDisplay: string | null; decisionState?: InferenceState | null; userStopped?: boolean; dossier: Parameters[0]["dossier"]; }, ): Promise { const persisted = await persistV9ChoiceAction(accounting, command.userId, command.caseId, { actionId: command.actionId, questionId: input.questionId, optionId: input.optionId, expectedRevision: input.expectedRevision, focusId: input.focusId, focusStatus: input.focusStatus, sourceQuote: input.sourceQuote, derivedContext: structuredProbeContext({ probeId: input.probeId, year: input.year, answerClass: input.answerClass as never, }), narration: input.narration, inference: input.inference, }); let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(accounting, command.userId, command.caseId); birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; } catch { // Ranking stays fail-open when compute is unavailable. } const nextAction = publicNextAction(decideAfterInferenceChange({ dossier: input.dossier, state: input.decisionState ?? null, userStopped: input.userStopped === true, birthDate, })); let narrationPersisted = false; let nextInterviewPersisted = false; let nextChoiceReady = false; let hostNarration = input.narration; let skippedNextInterview = false; let nextFocus: ConversationFocus | null = null; if ( command.deferFollowup !== true && input.userStopped !== true && shouldContinueAfterStructuredChoice(nextAction) ) { const nextInterview = await persistNextInterviewAfterChoice({ accounting, userId: command.userId, caseId: command.caseId, dossier: dossierWithClosedFocus(input.dossier, input.focusStatus), decisionState: input.decisionState ?? null, nextAction, birthDate, }); nextChoiceReady = nextInterview.choiceReady; skippedNextInterview = nextInterview.persisted === false; nextFocus = nextInterview.focus ?? null; if (nextInterview.hostNarration) { if (skippedNextInterview) { hostNarration = `${input.narration} ${nextInterview.hostNarration}`; } nextInterviewPersisted = true; } } const adoptionNarration = nextAction.can_adopt ? withProspectiveWindows(deliveryAdoptNarration({ credibleRange: nextAction.credible_range, representativeTime: nextAction.representative_time, openingRange: openingRangeFromDossier(input.dossier), stopReason: nextAction.stop_reason, }), input.dossier.latestResult?.decisionReceipt) : null; const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim(); const completedRangeNarration = nextAction.type === "complete_with_range" ? withProspectiveWindows(`${completedRangePrefix} ${nonConvergingRangeNarration({ credibleRange: nextAction.credible_range, representativeTime: nextAction.representative_time, openingRange: openingRangeFromDossier(input.dossier), variant: "delivery", stopReason: nextAction.stop_reason, }, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt) : null; const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview); if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion) { hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration; } if ( command.deferFollowup !== true && (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) ) { try { const turn = await persistV9DeterministicTurn(accounting, command.userId, command.caseId, { requestId: command.actionId, userMessage: input.userDisplay, assistantMessage: hostNarration, }); narrationPersisted = true; if (turn.turnId) { try { const focus = nextFocus ?? (await loadV9CaseDossier(accounting, command.userId, command.caseId)) .conversationSummary.activeFocus; if (focus) { await linkFocusAskedTurn({ accounting, userId: command.userId, caseId: command.caseId, focus, askedTurnId: turn.turnId, }); } } catch (error) { console.warn( `[rectification-v9] link choice focus to turn failed case=${command.caseId} reason=${safeToolErrorCode(error)}`, ); } } } catch { narrationPersisted = false; } } const narration = (adoptionNarration || completedRangeNarration || nextInterviewPersisted ? hostNarration : null) || (persisted.narration && persisted.narration.trim()) || hostNarration || composeChoiceNarration({ optionId: input.optionId, scoring: input.scoring, appliedInference: input.appliedInference, answerClass: input.answerClass, }); return { applied: true, idempotent: persisted.idempotent, status: narrationPersisted ? "narrated" : "applied", narrationPersisted, focusId: input.focusId, questionId: input.questionId, optionId: input.optionId, probeId: persisted.probeId ?? input.probeId, revision: persisted.revision, answerClass: input.answerClass, sourceQuote: input.sourceQuote, derivedContext: structuredProbeContext({ probeId: input.probeId, year: input.year, answerClass: input.answerClass as never, }), narration, userDisplay: input.userDisplay, nextAction, nextInterviewPersisted, nextChoiceReady, }; } async function inspectNonTerminalTurnExit(input: { accounting: AccountingClient; userId: string; caseId: string; }) { const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); 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 }); const satisfied = Boolean( projectCurrentQuestion(dossier.conversationSummary.activeFocus) || dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" || publicCanAdopt(decision) ); return { dossier, decision, satisfied }; } export async function ensureNonTerminalTurnExit(input: { accounting: AccountingClient; userId: string; caseId: string; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { const before = await inspectNonTerminalTurnExit(input); if (before.satisfied) { 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", })); const repaired = await persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, dossier: before.dossier, decision: before.decision, decisionReceipt: before.dossier.latestResult?.decisionReceipt, }); const after = await inspectNonTerminalTurnExit(input); if (!after.satisfied) { throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing"); } return repaired; } function optionQuoteFromSchema(schema: Readonly>, optionId: ChoiceKey): string | null { const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice) ? schema.choice as Record : schema; const key = optionId === "A" ? "option_a" : optionId === "B" ? "option_b" : optionId === "C" ? "option_c" : "option_d"; return asText(choice[key]); } function userDisplayFromSchema(schema: Readonly>, optionId: ChoiceKey): string | null { const quote = optionQuoteFromSchema(schema, optionId); return quote ? `${optionId}. ${quote}` : optionId; }