After the dated domains are asked or declined, keep the occupation question or the range card instead of hanging on a leftover other-collect prompt. Co-authored-by: Cursor <cursoragent@cursor.com>
1803 lines
61 KiB
TypeScript
1803 lines
61 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,
|
|
engineCapabilityCeilingFromReceipt,
|
|
isNonConvergingRangeOffer,
|
|
nonConvergingRangeNarration,
|
|
publicCanAdopt,
|
|
publicNextAction,
|
|
type RectificationDecision,
|
|
} from "../core/rectification-decision.ts";
|
|
import {
|
|
acceptanceGateNarration,
|
|
deliveryAdoptNarration,
|
|
openingRangeFromCandidateRange,
|
|
rangeWidthMinutes,
|
|
RECTIFICATION_USER_COPY,
|
|
withLastSuccessfulCompareNotice,
|
|
} from "../user-copy.ts";
|
|
import {
|
|
applyChoiceWithoutEvidence,
|
|
previousInferenceFromReceipt,
|
|
withNakshatraBoundaryProbe,
|
|
} from "./inference-adapter";
|
|
import {
|
|
decideAfterInferenceChange,
|
|
decideFromDossier,
|
|
rectificationFollowupCatalog,
|
|
type DecisionDossier,
|
|
} from "./decision-from-dossier";
|
|
import type { AnswerClass, InferenceState } from "../core/types.ts";
|
|
import {
|
|
CHOICE_ACTION,
|
|
SKIP_PROBE_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 { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card";
|
|
import { clusterScoreDeltas } from "./probe-explain.ts";
|
|
import {
|
|
adoptDeliveryFacts,
|
|
templatePostAdoptExplain,
|
|
templateRangeReadingExplain,
|
|
templateStopExplain,
|
|
type AdoptNarrationWriter,
|
|
} from "./adopt-narration.ts";
|
|
import { runV9RangeReading } from "./engine-client.ts";
|
|
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
|
|
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
|
|
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
|
import {
|
|
blockingMethodsCovered,
|
|
buildMethodFollowupPlan,
|
|
buildNextUserAction,
|
|
exhaustionSpokenCollectFollowup,
|
|
isRemainingEvidenceCollect,
|
|
planWithDateReliability,
|
|
spokenCollectFallbackFollowup,
|
|
spokenFollowupForUser,
|
|
ledgerHasConfirmedDatedEvent,
|
|
type MethodCoverage,
|
|
type MethodFollowup,
|
|
type MethodFollowupPlan,
|
|
} from "./method-followup";
|
|
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
|
|
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts";
|
|
import type { SessionOutcomeKind } from "./confirmation-gate";
|
|
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
|
|
import { projectCurrentQuestion } from "./turn-decision";
|
|
|
|
const EXHAUSTION_DELIVERY_ACTIONS = new Set([
|
|
"offer_provisional_range",
|
|
"complete_with_range",
|
|
"ask_fact_collection",
|
|
]);
|
|
|
|
export function isExhaustedGateState(input: {
|
|
remainingCollect: unknown;
|
|
methods: readonly MethodCoverage[];
|
|
canAdopt: boolean;
|
|
accepted?: boolean;
|
|
}): boolean {
|
|
return !input.remainingCollect
|
|
&& blockingMethodsCovered(input.methods)
|
|
&& !input.canAdopt
|
|
&& !input.accepted;
|
|
}
|
|
|
|
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
|
|
return `${askedTurnId ?? caseId}:gate`;
|
|
}
|
|
|
|
export async function persistExhaustionGateTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
askedTurnId?: string | null;
|
|
hostNarration: string;
|
|
}): Promise<void> {
|
|
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
|
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
|
|
userMessage: null,
|
|
assistantMessage: input.hostNarration,
|
|
});
|
|
}
|
|
|
|
function withDeferredCareerWindow(base: string): string {
|
|
const extra = RECTIFICATION_USER_COPY.deferredCareerWindow;
|
|
if (!base.trim()) return extra;
|
|
if (base.includes(extra)) return base;
|
|
if (base.includes(RECTIFICATION_USER_COPY.adoptCue)) {
|
|
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${extra} ${RECTIFICATION_USER_COPY.adoptCue}`
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
return `${base} ${extra}`.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export function appendPostAdoptProspectiveWindows(
|
|
narration: string,
|
|
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
|
): string {
|
|
const extra = prospectiveWindowsNarration(
|
|
refinementFromDecisionReceipt(receipt).prospective_probes,
|
|
);
|
|
if (!extra) return narration;
|
|
if (narration.includes(extra)) return narration;
|
|
return `${narration} ${extra}`.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
async function withRangeReadingNarration(
|
|
base: string,
|
|
input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
credibleRange?: readonly [string, string] | null;
|
|
representativeTime?: string | null;
|
|
coarseLook?: boolean;
|
|
},
|
|
): Promise<string> {
|
|
const range = input.credibleRange;
|
|
if (!range?.[0] || !range[1]) return base;
|
|
try {
|
|
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
|
|
const reading = await runV9RangeReading({
|
|
baselineBirthSnapshot: compute.baselineBirthSnapshot,
|
|
candidateRange: {
|
|
start_time: range[0],
|
|
end_time: range[1],
|
|
representative_time: input.representativeTime ?? range[0],
|
|
},
|
|
representativeTime: input.representativeTime,
|
|
});
|
|
const span = rangeWidthMinutes(range[0], range[1]);
|
|
const extra = templateRangeReadingExplain({
|
|
widthMinutes: span == null ? null : span + 1,
|
|
stableThemes: reading?.stableThemes,
|
|
sensitiveThemes: reading?.sensitiveThemes,
|
|
coarseLook: input.coarseLook,
|
|
});
|
|
return extra ? `${base} ${extra}`.replace(/\s+/g, " ").trim() : base;
|
|
} catch {
|
|
return 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 {
|
|
if (plan.next_followup) return plan.next_followup;
|
|
const deferred = plan.deferred_followup;
|
|
if (deferred?.intent === "distinguish_candidates" && !deferred.choice_frame) {
|
|
return null;
|
|
}
|
|
return deferred ?? null;
|
|
}
|
|
|
|
function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean {
|
|
if (!followup) return false;
|
|
if (followup.intent === "distinguish_candidates" && !followup.choice_frame) return false;
|
|
return followup.source === "event_probe"
|
|
|| followup.source === "varga_observation"
|
|
|| followup.source === "precision_stage"
|
|
|| followup.source === "nakshatra_boundary"
|
|
|| followup.source === "reverse_verify"
|
|
|| followup.intent === "distinguish_candidates"
|
|
|| followup.intent === "reverse_verify";
|
|
}
|
|
|
|
function shouldSkipFollowupPersist(input: {
|
|
canAdopt: boolean;
|
|
nextAction: string;
|
|
followup: MethodFollowup | null;
|
|
methods?: readonly MethodCoverage[];
|
|
accepted?: boolean;
|
|
}): boolean {
|
|
if (input.accepted) return false;
|
|
if (!input.canAdopt) return false;
|
|
if (
|
|
input.nextAction === "ask_fact_collection"
|
|
|| input.nextAction === "ask_candidate_discriminator"
|
|
|| input.nextAction === "ask_holdout_validation"
|
|
|| input.nextAction === "ask_block_choice"
|
|
|| input.nextAction === "ask_window_widen"
|
|
) {
|
|
return false;
|
|
}
|
|
if (input.methods && !blockingMethodsCovered(input.methods)) return false;
|
|
if (isRemainingDiscriminatorFollowup(input.followup)) return false;
|
|
if (isRemainingEvidenceCollect(input.followup)) return false;
|
|
return true;
|
|
}
|
|
|
|
function adoptHostNarration(input: {
|
|
dossier: Parameters<typeof decideFromDossier>[0];
|
|
decision: ReturnType<typeof decideFromDossier>;
|
|
receipt: Readonly<Record<string, unknown>> | null | undefined;
|
|
}): string {
|
|
const facts = adoptDeliveryFacts(input.decision, input.dossier);
|
|
const base = withDeferredCareerWindow(deliveryAdoptNarration({
|
|
credibleRange: input.decision.credibleRange,
|
|
representativeTime: input.decision.representativeTime,
|
|
openingRange: openingRangeFromDossier(input.dossier),
|
|
stopReason: input.decision.stopReason ?? null,
|
|
stopExplain: templateStopExplain(facts),
|
|
}));
|
|
if (facts.post_adopt_verification.length > 0) return base;
|
|
const explain = templatePostAdoptExplain(facts);
|
|
if (base.includes("没有还能核对的前事")) return base;
|
|
return `${base.replace(RECTIFICATION_USER_COPY.adoptCue, "").trim()} ${explain} ${RECTIFICATION_USER_COPY.adoptCue}`
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function dossierWithCurrentInference(
|
|
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"],
|
|
state: InferenceState | null,
|
|
): Parameters<typeof decideAfterInferenceChange>[0]["dossier"] {
|
|
if (!state) return dossier;
|
|
return {
|
|
...dossier,
|
|
latestResult: {
|
|
...(dossier.latestResult ?? {}),
|
|
decisionReceipt: {
|
|
...(dossier.latestResult?.decisionReceipt ?? {}),
|
|
inference_state: state,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ApplyChoiceCommand = Readonly<{
|
|
userId: string;
|
|
caseId: string;
|
|
sessionId: string;
|
|
actionId: string;
|
|
action: typeof CHOICE_ACTION | typeof STOP_ACTION | typeof SKIP_PROBE_ACTION;
|
|
focusId: string;
|
|
questionId?: string;
|
|
probeId?: string | null;
|
|
optionId: ChoiceOptionId;
|
|
expectedRevision: number;
|
|
userDisplay?: string | null;
|
|
deferFollowup?: boolean;
|
|
narrateAdopt?: AdoptNarrationWriter;
|
|
}>;
|
|
|
|
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>;
|
|
nextUserAction: ReturnType<typeof buildNextUserAction>;
|
|
nextInterviewPersisted: boolean;
|
|
nextChoiceReady: boolean;
|
|
turnId: string | null;
|
|
snapshotCurrent?: boolean;
|
|
}>;
|
|
|
|
function asText(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function markVerifyProbeSkipped(
|
|
state: InferenceState,
|
|
schema: Readonly<Record<string, unknown>>,
|
|
fallbackProbeId: string | null,
|
|
): InferenceState {
|
|
const probeId = asText(schema.probe_id) ?? fallbackProbeId ?? asText(schema.semantic_key);
|
|
const semantic = asText(schema.semantic_key) ?? probeId;
|
|
const split = asText(schema.candidate_split_hash) ?? semantic;
|
|
if (!probeId || !semantic || !split) return state;
|
|
if (state.answered_probes.some((item) => (
|
|
item.probe_id === probeId
|
|
|| item.semantic_key === semantic
|
|
|| item.candidate_split_hash === split
|
|
))) {
|
|
return state;
|
|
}
|
|
return {
|
|
...state,
|
|
revision: state.revision + 1,
|
|
answered_probes: [
|
|
...state.answered_probes,
|
|
{
|
|
probe_id: probeId,
|
|
semantic_key: semantic,
|
|
candidate_split_hash: split,
|
|
answer_class: "unsure",
|
|
classified_from: "declined",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function dossierWithClosedFocus<T extends {
|
|
conversationSummary: {
|
|
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
|
|
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
|
|
};
|
|
}>(dossier: T, status: "resolved" | "declined" | "skipped"): T {
|
|
const focus = dossier.conversationSummary.activeFocus;
|
|
const domain = focus?.targetDomain ?? null;
|
|
const intent = focus?.intent ?? null;
|
|
const declinedSkippedTopics = (
|
|
(status === "declined" || status === "skipped") && domain
|
|
? [
|
|
...dossier.conversationSummary.declinedSkippedTopics,
|
|
{
|
|
target_domain: domain,
|
|
status,
|
|
...(intent ? { intent } : {}),
|
|
},
|
|
]
|
|
: 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" || optionId === "skip_probe"
|
|
? null
|
|
: outcomeIdForOption(optionId, schema);
|
|
if (optionId !== "stop" && optionId !== "skip_probe" && !answerClass) {
|
|
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
|
|
}
|
|
|
|
if (isBlockChoiceSchema(schema)) {
|
|
const mutated = await mutateCaseForBlockChoice({
|
|
accounting,
|
|
userId: command.userId,
|
|
caseId: command.caseId,
|
|
dossier,
|
|
schema,
|
|
optionId,
|
|
});
|
|
const declined = optionId === "D" || optionId === "stop" || optionId === "skip_probe";
|
|
return persistApplied(accounting, command, {
|
|
focusId: focus.id,
|
|
questionId,
|
|
focusStatus: declined ? "skipped" : "resolved",
|
|
probeId: schemaProbeId ?? command.probeId ?? null,
|
|
optionId,
|
|
scoring: false,
|
|
appliedInference: false,
|
|
answerClass: declined && (optionId === "stop" || optionId === "skip_probe") ? null : answerClass,
|
|
sourceQuote: optionId === "A" || optionId === "B" || optionId === "C" || optionId === "D"
|
|
? optionQuoteFromSchema(schema, optionId)
|
|
: null,
|
|
year: null,
|
|
expectedRevision: previous?.revision ?? command.expectedRevision,
|
|
inference: null,
|
|
narration: declined
|
|
? "已记下。再说一件带年份的经历后,会再比一次时段。"
|
|
: mutated.narration ?? "已选定出生时段,接下来只在这一段里按分钟比较。",
|
|
userDisplay: command.userDisplay
|
|
?? (optionId === "skip_probe"
|
|
? CHOICE_SKIP_QUESTION_LABEL
|
|
: optionId === "stop"
|
|
? "先这样,先看当前范围"
|
|
: userDisplayFromSchema(schema, optionId)),
|
|
decisionState: null,
|
|
userStopped: false,
|
|
dossier: mutated.dossier,
|
|
snapshotCurrent: mutated.snapshotCurrent,
|
|
});
|
|
}
|
|
|
|
if (isWidenWindowSchema(schema)) {
|
|
const mutated = await mutateCaseForWidenWindow({
|
|
accounting,
|
|
userId: command.userId,
|
|
caseId: command.caseId,
|
|
dossier,
|
|
schema,
|
|
optionId,
|
|
});
|
|
const declined = optionId === "C" || optionId === "D" || optionId === "stop" || optionId === "skip_probe";
|
|
const widened = optionId === "A" || optionId === "B";
|
|
return persistApplied(accounting, command, {
|
|
focusId: focus.id,
|
|
questionId,
|
|
focusStatus: declined ? "skipped" : "resolved",
|
|
probeId: schemaProbeId ?? command.probeId ?? null,
|
|
optionId,
|
|
scoring: false,
|
|
appliedInference: false,
|
|
answerClass: declined && (optionId === "stop" || optionId === "skip_probe") ? null : answerClass,
|
|
sourceQuote: optionId === "A" || optionId === "B" || optionId === "C" || optionId === "D"
|
|
? optionQuoteFromSchema(schema, optionId)
|
|
: null,
|
|
year: null,
|
|
expectedRevision: previous?.revision ?? command.expectedRevision,
|
|
inference: null,
|
|
narration: declined
|
|
? "先按现在的范围继续。"
|
|
: widened
|
|
? "已放宽范围,按你说的经历再比一次。"
|
|
: "先按现在的范围继续。",
|
|
userDisplay: command.userDisplay
|
|
?? (optionId === "skip_probe"
|
|
? CHOICE_SKIP_QUESTION_LABEL
|
|
: optionId === "stop"
|
|
? "先这样,先看当前范围"
|
|
: userDisplayFromSchema(schema, optionId)),
|
|
decisionState: null,
|
|
userStopped: false,
|
|
dossier: mutated.dossier,
|
|
snapshotCurrent: mutated.snapshotCurrent,
|
|
});
|
|
}
|
|
|
|
if (optionId === "skip_probe" || command.action === SKIP_PROBE_ACTION) {
|
|
const skippedState = previous ? markVerifyProbeSkipped(previous, schema, schemaProbeId ?? command.probeId ?? null) : null;
|
|
const probeId = schemaProbeId ?? command.probeId ?? asText(schema.semantic_key) ?? "skip_probe";
|
|
const semanticKey = asText(schema.semantic_key) ?? probeId;
|
|
const splitHash = asText(schema.candidate_split_hash) ?? semanticKey;
|
|
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
|
|
?? evidenceLedgerFingerprint(dossier.evidence);
|
|
const inference = skippedState && previous
|
|
? {
|
|
expectedRevision: previous.revision,
|
|
probeId,
|
|
openProbeId: probeId,
|
|
semanticKey,
|
|
candidateSplitHash: splitHash,
|
|
answerClass: "unsure" as const,
|
|
rawAnswer: "skip_probe",
|
|
inferenceState: skippedState as unknown as Record<string, unknown>,
|
|
posteriorBefore: posteriorMap(previous.candidates),
|
|
posteriorAfter: posteriorMap(skippedState.candidates),
|
|
scoreDeltas: scoreDeltas(
|
|
posteriorMap(previous.candidates),
|
|
posteriorMap(skippedState.candidates),
|
|
),
|
|
decisionStateFingerprint: inferenceFingerprintForState(
|
|
command.caseId,
|
|
evidenceFp,
|
|
skippedState,
|
|
),
|
|
reason: "choice" as const,
|
|
idempotencyKey: `skip_probe:${probeId}:unsure`,
|
|
candidateSetId: skippedState.candidate_set_id,
|
|
}
|
|
: null;
|
|
return persistApplied(accounting, command, {
|
|
focusId: focus.id,
|
|
questionId,
|
|
focusStatus: "skipped",
|
|
probeId,
|
|
optionId: "skip_probe",
|
|
scoring: false,
|
|
appliedInference: Boolean(inference),
|
|
answerClass: null,
|
|
sourceQuote: null,
|
|
year: typeof schema.probe_year === "number" ? schema.probe_year : null,
|
|
expectedRevision: previous?.revision ?? command.expectedRevision,
|
|
inference,
|
|
narration: composeChoiceNarration({
|
|
optionId: "skip_probe",
|
|
scoring: false,
|
|
appliedInference: false,
|
|
}),
|
|
userDisplay: CHOICE_SKIP_QUESTION_LABEL,
|
|
decisionState: skippedState,
|
|
userStopped: false,
|
|
dossier,
|
|
});
|
|
}
|
|
|
|
if (optionId === "stop" || command.action === STOP_ACTION) {
|
|
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
|
|
accounting,
|
|
userId: command.userId,
|
|
caseId: command.caseId,
|
|
});
|
|
const applied = await 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: composeChoiceNarration({
|
|
optionId: "stop",
|
|
scoring,
|
|
appliedInference: false,
|
|
}),
|
|
userDisplay: "先这样,先看当前范围",
|
|
decisionState: previous,
|
|
userStopped: true,
|
|
dossier: rescored.dossier,
|
|
snapshotCurrent: rescored.snapshotCurrent,
|
|
});
|
|
if (rescored.rescoreAttempted && !rescored.snapshotCurrent) {
|
|
return {
|
|
...applied,
|
|
narration: withLastSuccessfulCompareNotice(applied.narration),
|
|
};
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
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 posteriorBefore = posteriorMap(previous.candidates);
|
|
const posteriorAfter = posteriorMap(applied.state.candidates);
|
|
const appliedScoreDeltas = scoreDeltas(posteriorBefore, posteriorAfter);
|
|
const narration = composeChoiceNarration({
|
|
optionId,
|
|
scoring,
|
|
appliedInference: persistable && scoring,
|
|
answerClass,
|
|
deltasByCluster: clusterScoreDeltas(previous.candidates, appliedScoreDeltas),
|
|
credibleBefore: previous.credible_range,
|
|
credibleAfter: applied.state.credible_range,
|
|
});
|
|
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,
|
|
posteriorAfter,
|
|
scoreDeltas: appliedScoreDeltas,
|
|
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>;
|
|
decision?: ReturnType<typeof decideAfterInferenceChange>;
|
|
birthDate?: string | null;
|
|
askedTurnId?: string | null;
|
|
narrateAdopt?: AdoptNarrationWriter;
|
|
}): Promise<{
|
|
hostNarration: string;
|
|
choiceReady: boolean;
|
|
persisted?: boolean;
|
|
focusId?: string | null;
|
|
focus?: ConversationFocus | null;
|
|
followup?: MethodFollowup | null;
|
|
terminalNote?: 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 liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
|
|
const decision = input.decision ?? decideAfterInferenceChange({
|
|
dossier: input.dossier,
|
|
state: input.decisionState,
|
|
userStopped: false,
|
|
birthDate,
|
|
});
|
|
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 = planWithDateReliability(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",
|
|
...followupCaseArgs({
|
|
stage: input.dossier.case.stage,
|
|
blockScan: input.dossier.case.blockScan,
|
|
reportedBirthTime: input.dossier.case.reportedBirthTime,
|
|
candidateRange: input.dossier.case.candidateRange,
|
|
}),
|
|
}), input.dossier.evidence, input.askedTurnId);
|
|
const followup = interviewToPersist(plan);
|
|
if (shouldSkipFollowupPersist({
|
|
canAdopt: input.nextAction.can_adopt,
|
|
nextAction: input.nextAction.type,
|
|
followup,
|
|
methods: plan.methods,
|
|
accepted: Boolean(input.dossier.case.acceptedTime),
|
|
})) {
|
|
const facts = adoptDeliveryFacts(decision, liveDossier);
|
|
const fallback = adoptHostNarration({
|
|
dossier: liveDossier,
|
|
decision,
|
|
receipt: liveDossier.latestResult?.decisionReceipt,
|
|
});
|
|
const narrated = input.narrateAdopt
|
|
? await input.narrateAdopt(facts, fallback)
|
|
: fallback;
|
|
return {
|
|
hostNarration: await withRangeReadingNarration(narrated, {
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
credibleRange: decision.credibleRange,
|
|
representativeTime: decision.representativeTime,
|
|
}),
|
|
choiceReady: false,
|
|
persisted: false,
|
|
followup: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
if (input.dossier.case.acceptedTime && !followup) {
|
|
return {
|
|
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
|
|
choiceReady: false,
|
|
persisted: false,
|
|
followup: null,
|
|
};
|
|
}
|
|
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,
|
|
followup,
|
|
};
|
|
}
|
|
if (open?.kind === "collect_spoken" && open.prompt) {
|
|
return {
|
|
hostNarration: open.prompt,
|
|
choiceReady: false,
|
|
persisted: true,
|
|
focusId: persistedFocus.focus?.id ?? null,
|
|
focus: persistedFocus.focus,
|
|
followup,
|
|
};
|
|
}
|
|
if (followup?.intent === "collect_method_evidence" && followup.choice_frame) {
|
|
const spokenFollowup = spokenCollectFallbackFollowup(followup);
|
|
const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence);
|
|
if (spoken) {
|
|
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,
|
|
followup,
|
|
};
|
|
}
|
|
}
|
|
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, followup };
|
|
}
|
|
return persistExhaustionCollect({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier: liveDossier,
|
|
decision,
|
|
decisionReceipt: latest.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
}
|
|
if (!followup) {
|
|
if (input.dossier.case.acceptedTime) {
|
|
return {
|
|
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
|
|
choiceReady: false,
|
|
followup: null,
|
|
};
|
|
}
|
|
return persistExhaustionCollect({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier: liveDossier,
|
|
decision,
|
|
decisionReceipt: latest.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
}
|
|
return {
|
|
hostNarration: spokenFollowupForUser(followup) ?? RECTIFICATION_USER_COPY.hostNarrationFallback,
|
|
choiceReady: false,
|
|
followup,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (persisted.status === "skipped" && !followupHasPersistableDomain(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;
|
|
narrateAdopt?: AdoptNarrationWriter;
|
|
},
|
|
): Promise<{
|
|
narration: string;
|
|
nextInterviewPersisted: boolean;
|
|
nextChoiceReady: boolean;
|
|
focus: ConversationFocus | null;
|
|
}> {
|
|
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: RECTIFICATION_USER_COPY.collectDeclinedAck,
|
|
nextInterviewPersisted: false,
|
|
nextChoiceReady: false,
|
|
focus: null,
|
|
};
|
|
}
|
|
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 decision = decideFromDossier(withDeclined, { birthDate });
|
|
const nextAction = publicNextAction(decision);
|
|
const nextInterview = await persistNextInterviewAfterChoice({
|
|
accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier: withDeclined,
|
|
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
|
|
nextAction,
|
|
decision,
|
|
birthDate,
|
|
narrateAdopt: input.narrateAdopt,
|
|
});
|
|
return {
|
|
narration: nextInterview.hostNarration,
|
|
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
|
|
nextChoiceReady: nextInterview.choiceReady,
|
|
focus: nextInterview.focus ?? null,
|
|
};
|
|
}
|
|
|
|
export type CollectDenialApplied = Awaited<ReturnType<typeof applyCollectFocusDenial>>;
|
|
|
|
/**
|
|
* After a collect "没有", persist the deterministic turn then bind the next
|
|
* focus to that turn so the stem can hang on the message. Stream only the
|
|
* acknowledgment; the server-owned stem joins via asked_turn_id (BUG-525).
|
|
*/
|
|
export async function persistCollectDenialTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
requestId: string;
|
|
userMessage: string | null;
|
|
applied: CollectDenialApplied;
|
|
}): Promise<{ streamText: string; turnId: string }> {
|
|
const hasNextStem = Boolean(
|
|
input.applied.focus
|
|
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
|
|
&& input.applied.narration.trim(),
|
|
);
|
|
const stored = hasNextStem
|
|
? composeCollectSpokenAssistantText(RECTIFICATION_USER_COPY.collectDeclinedAck, input.applied.narration)
|
|
: input.applied.narration;
|
|
const streamText = hasNextStem ? RECTIFICATION_USER_COPY.collectDeclinedAck : input.applied.narration;
|
|
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
|
requestId: input.requestId,
|
|
userMessage: input.userMessage,
|
|
assistantMessage: stored,
|
|
});
|
|
if (input.applied.focus) {
|
|
try {
|
|
await linkFocusAskedTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
focus: input.applied.focus,
|
|
askedTurnId: turn.turnId,
|
|
});
|
|
} catch (error) {
|
|
console.warn(
|
|
`[rectification-v9] link collect-denial focus to turn failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
|
);
|
|
}
|
|
}
|
|
return { streamText, turnId: turn.turnId };
|
|
}
|
|
|
|
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 function isOrphanOtherCollectFocus(
|
|
focus: { questionId?: string | null } | null | undefined,
|
|
evidence: readonly { status: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null }[],
|
|
): boolean {
|
|
const questionId = focus?.questionId ?? "";
|
|
if (!questionId.startsWith("collect:other:")) return false;
|
|
return ledgerHasConfirmedDatedEvent(evidence);
|
|
}
|
|
|
|
export async function persistNextInterviewIfIdle(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
askedTurnId?: string | null;
|
|
narrateAdopt?: AdoptNarrationWriter;
|
|
userStopped?: boolean;
|
|
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
|
|
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
let dossier = rescored.dossier;
|
|
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
|
|
let hostNarration = result.hostNarration;
|
|
if (dossier.case.acceptedTime && hostNarration) {
|
|
hostNarration = appendPostAdoptProspectiveWindows(
|
|
hostNarration,
|
|
dossier.latestResult?.decisionReceipt,
|
|
);
|
|
}
|
|
if (
|
|
input.userStopped === true
|
|
&& rescored.rescoreAttempted
|
|
&& !rescored.snapshotCurrent
|
|
&& hostNarration
|
|
) {
|
|
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
|
|
}
|
|
if (hostNarration === result.hostNarration) return result;
|
|
return { ...result, hostNarration };
|
|
};
|
|
const staleFocus = dossier.conversationSummary.activeFocus;
|
|
const staleFocusId = staleFocus?.id;
|
|
if (
|
|
isPersistedFocusId(staleFocusId)
|
|
&& (
|
|
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|
|
|| isOrphanOtherCollectFocus(staleFocus, dossier.evidence)
|
|
)
|
|
) {
|
|
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 finishIdle({ 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,
|
|
snapshotCurrent: rescored.snapshotCurrent,
|
|
});
|
|
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),
|
|
holdoutValidation: decision.holdoutValidation,
|
|
...followupCaseArgs({
|
|
stage: dossier.case.stage,
|
|
blockScan: dossier.case.blockScan,
|
|
reportedBirthTime: dossier.case.reportedBirthTime,
|
|
candidateRange: dossier.case.candidateRange,
|
|
}),
|
|
});
|
|
const followup = interviewToPersist(plan);
|
|
if (shouldSkipFollowupPersist({
|
|
canAdopt: publicCanAdopt(decision),
|
|
nextAction: decision.nextAction,
|
|
followup,
|
|
methods: plan.methods,
|
|
accepted: Boolean(dossier.case.acceptedTime),
|
|
})) {
|
|
const facts = adoptDeliveryFacts(decision, dossier);
|
|
const fallback = adoptHostNarration({
|
|
dossier,
|
|
decision,
|
|
receipt: dossier.latestResult?.decisionReceipt,
|
|
});
|
|
const narrated = input.narrateAdopt
|
|
? await input.narrateAdopt(facts, fallback)
|
|
: fallback;
|
|
return finishIdle({
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: await withRangeReadingNarration(narrated, {
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
credibleRange: decision.credibleRange,
|
|
representativeTime: decision.representativeTime,
|
|
}),
|
|
terminalNote: true,
|
|
});
|
|
}
|
|
const remainingCollect = exhaustionSpokenCollectFollowup({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
answeredProbes: catalog.answeredProbes,
|
|
eventProbes: catalog.eventProbes,
|
|
});
|
|
if (
|
|
isExhaustedGateState({
|
|
remainingCollect,
|
|
methods: plan.methods,
|
|
canAdopt: decision.canAdopt,
|
|
accepted: Boolean(dossier.case.acceptedTime),
|
|
})
|
|
&& EXHAUSTION_DELIVERY_ACTIONS.has(decision.nextAction)
|
|
) {
|
|
return finishIdle(await persistExhaustionCollect({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier,
|
|
decision,
|
|
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
}));
|
|
}
|
|
if (isNonConvergingRangeOffer(decision)
|
|
|| decision.nextAction === "complete_with_range"
|
|
|| decision.sessionOutcome === "completed_with_range"
|
|
) {
|
|
return finishIdle(await persistExhaustionCollect({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier,
|
|
decision,
|
|
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
}));
|
|
}
|
|
if (
|
|
!followup
|
|
&& decision.nextAction !== "ask_candidate_discriminator"
|
|
&& decision.nextAction !== "ask_holdout_validation"
|
|
) {
|
|
if (dossier.case.acceptedTime) {
|
|
return finishIdle({
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
|
|
});
|
|
}
|
|
return finishIdle(await persistExhaustionCollect({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier,
|
|
decision,
|
|
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? 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,
|
|
decision,
|
|
birthDate,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
narrateAdopt: input.narrateAdopt,
|
|
});
|
|
return finishIdle({
|
|
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
|
|
choiceReady: nextInterview.choiceReady,
|
|
hostNarration: nextInterview.hostNarration,
|
|
...(nextInterview.terminalNote ? { terminalNote: true } : {}),
|
|
});
|
|
}
|
|
|
|
async function persistExhaustionCollect(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
dossier: DecisionDossier | Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
|
|
decision: RectificationDecision;
|
|
decisionReceipt?: Readonly<Record<string, unknown>> | null;
|
|
askedTurnId?: string | null;
|
|
}): Promise<{
|
|
persisted: boolean;
|
|
choiceReady: boolean;
|
|
hostNarration: string;
|
|
focus?: ConversationFocus | null;
|
|
terminalNote?: boolean;
|
|
}> {
|
|
const dossier = input.dossier as DecisionDossier;
|
|
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
|
|
const decision = input.decision;
|
|
const catalog = rectificationFollowupCatalog(
|
|
dossier.latestResult ?? null,
|
|
dossier.evidence,
|
|
);
|
|
const followup = exhaustionSpokenCollectFollowup({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
answeredProbes: catalog.answeredProbes,
|
|
eventProbes: catalog.eventProbes,
|
|
});
|
|
const trainingGate = trainingScoreableGate(dossier.evidence);
|
|
const ceiling = engineCapabilityCeilingFromReceipt(receipt);
|
|
const inference = previousInferenceFromReceipt(receipt);
|
|
const rounds = inference?.rounds ?? [];
|
|
let plateauRounds = 0;
|
|
for (let index = rounds.length - 1; index >= 0; index -= 1) {
|
|
if (rounds[index]?.kind !== "low_information") break;
|
|
plateauRounds += 1;
|
|
}
|
|
const budget = {
|
|
rounds: rounds.filter((item) => item.kind === "informative").length,
|
|
answers: inference?.answered_probes.length ?? 0,
|
|
plateau: plateauRounds,
|
|
};
|
|
console.warn(JSON.stringify({
|
|
event: "rectification_exhaustion_collect",
|
|
case_id: input.caseId,
|
|
can_adopt: decision.canAdopt,
|
|
ceiling: {
|
|
acceptance: ceiling.acceptanceAllowed,
|
|
selection: ceiling.selectionAllowed,
|
|
propose: ceiling.proposeAllowed,
|
|
},
|
|
training_gate: {
|
|
count: trainingGate.trainingCount,
|
|
domains: trainingGate.trainingDomainCount,
|
|
open: trainingGate.open,
|
|
},
|
|
stop_class: decision.stopReason ?? decision.sessionOutcome,
|
|
ranked_count: decision.separation.ranked.length,
|
|
probe_key: decision.probe?.semanticKey ?? null,
|
|
budget,
|
|
next_domain: followup?.domain ?? null,
|
|
next_source: followup?.source ?? null,
|
|
}));
|
|
if (followup) {
|
|
const range = nonConvergingRangeNarration({
|
|
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
|
|
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
|
|
openingRange: openingRangeFromDossier(dossier),
|
|
variant: "intermediate",
|
|
});
|
|
const spoken = spokenFollowupForUser(followup);
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: receipt,
|
|
followup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const persisted = Boolean(spoken) && (
|
|
persistedFocus.status === "created" || persistedFocus.status === "already_open"
|
|
);
|
|
return {
|
|
persisted,
|
|
choiceReady: false,
|
|
hostNarration: spoken ?? range,
|
|
focus: persistedFocus.focus,
|
|
};
|
|
}
|
|
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
|
|
const adopted = {
|
|
...decision,
|
|
stopReason: decision.stopReason ?? "probe_pool_exhausted",
|
|
};
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: await withRangeReadingNarration(adoptHostNarration({
|
|
dossier,
|
|
decision: adopted,
|
|
receipt,
|
|
}), {
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
credibleRange: adopted.credibleRange,
|
|
representativeTime: adopted.representativeTime,
|
|
}),
|
|
focus: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
const receiptReasons = receipt && Array.isArray(receipt.acceptance_reasons)
|
|
? receipt.acceptance_reasons.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
const reasons = [
|
|
...receiptReasons,
|
|
...(decision.stopReason ? [decision.stopReason] : []),
|
|
...(decision.separation.ranked.length === 0 ? ["no_candidates"] : []),
|
|
];
|
|
const progress = collectionProgressFromReceipt(receipt);
|
|
const gate = acceptanceGateNarration(reasons, progress?.missing ?? null)
|
|
?? (decision.separation.ranked.length === 0 ? RECTIFICATION_USER_COPY.noCandidatesGate : null);
|
|
const range = nonConvergingRangeNarration({
|
|
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
|
|
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
|
|
openingRange: openingRangeFromDossier(dossier),
|
|
variant: "intermediate",
|
|
});
|
|
const hostNarration = [range, gate].filter(Boolean).join("");
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration,
|
|
focus: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
|
|
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<typeof decideAfterInferenceChange>[0]["dossier"];
|
|
snapshotCurrent?: boolean;
|
|
},
|
|
): 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 nextDecision = decideAfterInferenceChange({
|
|
dossier: input.dossier,
|
|
state: input.decisionState ?? null,
|
|
userStopped: input.userStopped === true,
|
|
birthDate,
|
|
snapshotCurrent: input.snapshotCurrent,
|
|
});
|
|
const nextAction = publicNextAction(nextDecision);
|
|
const accepted = Boolean(input.dossier.case.acceptedTime);
|
|
const skipThisProbe = input.optionId === "skip_probe";
|
|
|
|
let narrationPersisted = false;
|
|
let nextInterviewPersisted = false;
|
|
let nextChoiceReady = false;
|
|
let hostNarration = input.narration;
|
|
let skippedNextInterview = false;
|
|
let nextFocus: ConversationFocus | null = null;
|
|
let nextFollowup: MethodFollowup | null = null;
|
|
let turnId: string | null = null;
|
|
if (
|
|
command.deferFollowup !== true
|
|
&& input.userStopped !== true
|
|
&& (
|
|
shouldContinueAfterStructuredChoice(nextAction)
|
|
|| skipThisProbe
|
|
|| accepted
|
|
|| EXHAUSTION_DELIVERY_ACTIONS.has(nextAction.type)
|
|
)
|
|
) {
|
|
const nextInterview = await persistNextInterviewAfterChoice({
|
|
accounting,
|
|
userId: command.userId,
|
|
caseId: command.caseId,
|
|
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
|
|
decisionState: input.decisionState ?? null,
|
|
nextAction,
|
|
decision: nextDecision,
|
|
birthDate,
|
|
narrateAdopt: command.narrateAdopt,
|
|
});
|
|
nextChoiceReady = nextInterview.choiceReady;
|
|
skippedNextInterview = nextInterview.persisted === false;
|
|
nextFocus = nextInterview.focus ?? null;
|
|
nextFollowup = nextInterview.followup ?? null;
|
|
if (nextInterview.hostNarration) {
|
|
if (accepted || skipThisProbe) {
|
|
hostNarration = nextInterview.hostNarration;
|
|
} else if (skippedNextInterview) {
|
|
hostNarration = `${input.narration}
|
|
|
|
${nextInterview.hostNarration}`;
|
|
}
|
|
nextInterviewPersisted = true;
|
|
}
|
|
}
|
|
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
|
|
const adoptionFacts = !accepted && nextAction.can_adopt
|
|
? adoptDeliveryFacts(nextDecision, liveDossier)
|
|
: null;
|
|
const adoptionNarration = !accepted && nextAction.can_adopt
|
|
? withDeferredCareerWindow(deliveryAdoptNarration({
|
|
credibleRange: nextAction.credible_range,
|
|
representativeTime: nextAction.representative_time,
|
|
openingRange: openingRangeFromDossier(input.dossier),
|
|
stopReason: nextAction.stop_reason,
|
|
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
|
|
}))
|
|
: null;
|
|
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
|
|
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
|
|
? withDeferredCareerWindow(`${completedRangePrefix}
|
|
|
|
${nonConvergingRangeNarration({
|
|
credibleRange: nextAction.credible_range,
|
|
representativeTime: nextAction.representative_time,
|
|
openingRange: openingRangeFromDossier(input.dossier),
|
|
variant: "delivery",
|
|
stopReason: nextAction.stop_reason,
|
|
}, RECTIFICATION_TERMINATION_COPY)}`)
|
|
: null;
|
|
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
|
|
if (accepted && !keptNextQuestion) {
|
|
hostNarration = RECTIFICATION_USER_COPY.postAdoptVerifyDone;
|
|
nextFollowup = null;
|
|
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
|
|
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) {
|
|
turnId = turn.turnId;
|
|
try {
|
|
const focus = nextFocus ?? (keptNextQuestion
|
|
? (await loadV9CaseDossier(accounting, command.userId, command.caseId))
|
|
.conversationSummary.activeFocus
|
|
: null);
|
|
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,
|
|
nextUserAction: buildNextUserAction({
|
|
scorableCount: input.dossier.evidence.length,
|
|
evidenceCount: input.dossier.evidence.length,
|
|
hasLatestResult: Boolean(input.dossier.latestResult),
|
|
selectionAllowed: nextAction.can_adopt,
|
|
sessionOutcome: typeof nextAction.session_outcome === "string"
|
|
? nextAction.session_outcome as SessionOutcomeKind
|
|
: "collect_evidence",
|
|
nextFollowup: keptNextQuestion ? nextFollowup : null,
|
|
workingTime: input.dossier.case.acceptedTime ?? nextAction.representative_time ?? null,
|
|
accepted,
|
|
}),
|
|
nextInterviewPersisted,
|
|
nextChoiceReady,
|
|
turnId,
|
|
snapshotCurrent: input.snapshotCurrent,
|
|
};
|
|
}
|
|
|
|
async function inspectNonTerminalTurnExit(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
}) {
|
|
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
});
|
|
const dossier = rescored.dossier;
|
|
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,
|
|
snapshotCurrent: rescored.snapshotCurrent,
|
|
});
|
|
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
|
const remainingCollect = exhaustionSpokenCollectFollowup({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
answeredProbes: catalog.answeredProbes,
|
|
eventProbes: catalog.eventProbes,
|
|
});
|
|
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),
|
|
...followupCaseArgs({
|
|
stage: dossier.case.stage,
|
|
blockScan: dossier.case.blockScan,
|
|
reportedBirthTime: dossier.case.reportedBirthTime,
|
|
candidateRange: dossier.case.candidateRange,
|
|
}),
|
|
});
|
|
const exhausted = isExhaustedGateState({
|
|
remainingCollect,
|
|
methods: plan.methods,
|
|
canAdopt: decision.canAdopt,
|
|
accepted: Boolean(dossier.case.acceptedTime),
|
|
});
|
|
const satisfied = Boolean(
|
|
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|
|
|| dossier.case.acceptedTime
|
|
|| dossier.case.confirmedTime
|
|
|| decision.completionStatus === "provisional_range_user_stopped"
|
|
|| publicCanAdopt(decision)
|
|
|| exhausted
|
|
);
|
|
return { dossier, decision, satisfied };
|
|
}
|
|
|
|
export async function ensureNonTerminalTurnExit(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
|
|
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 && !repaired.terminalNote) {
|
|
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;
|
|
}
|