798 lines
27 KiB
TypeScript
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;
|
|
}
|