Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
T
Jesse_Chen d94049769e
Independent Staging Quality Gate / validate (push) Successful in 25m7s
Independent Staging Quality Gate / publish (push) Successful in 2m8s
fix(rectification): put each question inside the assistant message
Focuses now carry asked_turn_id so GET rebuilds stem and options on the
same turn. Agent writes spokenPrompt; the live question slot is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 19:55:20 +08:00

1063 lines
36 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 { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, 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 { 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<Record<string, unknown>> | 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<Record<string, unknown>> | null | undefined;
}): string {
return withProspectiveWindows(deliveryAdoptNarration({
credibleRange: input.credibleRange,
representativeTime: input.representativeTime,
openingRange: input.openingRange,
}), 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<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 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 });
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;
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",
}), 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<Record<string, unknown>> | null | undefined;
followup: ReturnType<typeof buildMethodFollowupPlan>["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: decision.canAdopt,
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<typeof exhaustionSpokenCollectFollowup>[0]["evidence"];
conversationSummary: { declinedSkippedTopics?: readonly Readonly<Record<string, unknown>>[] };
latestResult?: { decisionReceipt?: Readonly<Record<string, unknown>> | 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<Record<string, unknown>> | 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: 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;
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),
}), 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",
}, 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,
});
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"
|| decision.canAdopt
);
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<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;
}