Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
T

798 lines
27 KiB
TypeScript

/**
* 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 { isNonConvergingRangeOffer, nonConvergingRangeNarration, publicNextAction } from "../core/rectification-decision.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
} from "./inference-adapter";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import type { 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 PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import type { ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus";
import {
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
} from "./method-followup";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { projectCurrentQuestion } from "./turn-decision";
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<typeof publicNextAction>;
nextInterviewPersisted: boolean;
nextChoiceReady: boolean;
}>;
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null } | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
}>(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<AppliedChoiceReceipt> {
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 previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
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 });
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,
});
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<string, unknown>,
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<typeof decideAfterInferenceChange>[0]["dossier"];
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
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 = plan.next_followup;
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: latest.decisionReceipt,
followup,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open)) {
return { hostNarration: "接下来请点选下面这一问。", choiceReady: true };
}
if (followup?.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup) ?? "请再说一件记得大概时间的经历。";
await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: latest.decisionReceipt,
followup: {
...spokenFollowup,
user_prompt_hint: spoken,
},
});
return { hostNarration: spoken, choiceReady: false };
}
if (followup?.intent === "collect_method_evidence") {
const spoken = spokenFollowupForUser(followup);
if (
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
return { hostNarration: spoken, 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({
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,
});
}
return {
hostNarration: nonConvergingRangeNarration({
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
}),
choiceReady: false,
};
}
return {
hostNarration: spokenFollowupForUser(followup) ?? "请再说一件记得大概时间的经历。",
choiceReady: false,
};
}
async function persistFocusAfterChoice(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
}) {
let persisted;
try {
persisted = await persistServerOwnedFocus({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
activeFocus: null,
decisionReceipt: input.decisionReceipt,
followup: input.followup,
});
} 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,
});
} 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 async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (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 (isNonConvergingRangeOffer(decision)) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
});
}
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),
});
if (!plan.next_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,
});
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<typeof exhaustionSpokenCollectFollowup>[0]["evidence"];
conversationSummary: { declinedSkippedTopics?: readonly Readonly<Record<string, unknown>>[] };
latestResult?: { decisionReceipt?: Readonly<Record<string, unknown>> | null } | null;
};
decision: { credibleRange?: readonly [string, string] | null; representativeTime?: string | null };
decisionReceipt?: Readonly<Record<string, unknown>> | null;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string }> {
const followup = exhaustionSpokenCollectFollowup({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
});
const range = nonConvergingRangeNarration(input.decision);
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,
})
: { 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: range,
};
}
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: string | null;
sourceQuote: string | null;
year: number | null;
expectedRevision: number;
inference: PersistChoiceActionInput["inference"];
narration: string;
userDisplay: string | null;
decisionState?: InferenceState | null;
userStopped?: boolean;
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
},
): Promise<AppliedChoiceReceipt> {
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;
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;
if (nextInterview.hostNarration) {
hostNarration = nextInterview.persisted === false
? `${input.narration}
${nextInterview.hostNarration}`
: nextInterview.hostNarration;
nextInterviewPersisted = true;
}
}
const adoptionNarration = nextAction.type === "offer_provisional_range" && nextAction.can_adopt
? `${nonConvergingRangeNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
})} 可以从下面的时间里选一个采用。`
: null;
if (adoptionNarration) hostNarration = adoptionNarration;
if (
command.deferFollowup !== true
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
) {
try {
await persistV9DeterministicTurn(accounting, command.userId, command.caseId, {
requestId: command.actionId,
userMessage: input.userDisplay,
assistantMessage: hostNarration,
});
narrationPersisted = true;
} catch {
narrationPersisted = false;
}
}
const narration = (adoptionNarration || nextInterviewPersisted ? hostNarration : null)
|| (persisted.narration && persisted.narration.trim())
|| hostNarration
|| composeChoiceNarration({
optionId: input.optionId,
scoring: input.scoring,
appliedInference: input.appliedInference,
});
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,
};
}
export async function ensureNonTerminalTurnExit(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): 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
) {
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>
: 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<Record<string, unknown>>, optionId: ChoiceKey): string | null {
const quote = optionQuoteFromSchema(schema, optionId);
return quote ? `${optionId}. ${quote}` : optionId;
}