Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2647 lines
92 KiB
TypeScript
2647 lines
92 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 { createHash } from "node:crypto";
|
|
import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts";
|
|
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
|
|
import { productDeliveryForDomain } from "./product-delivery.ts";
|
|
import { nextProbe } from "../core/build-state";
|
|
import {
|
|
deliveryNarrationAllowed,
|
|
engineCapabilityCeilingFromReceipt,
|
|
isNonConvergingRangeOffer,
|
|
nonConvergingRangeNarration,
|
|
publicCanAdopt,
|
|
publicNextAction,
|
|
sessionOutcomeAllowsDelivery,
|
|
type RectificationDecision,
|
|
} from "../core/rectification-decision.ts";
|
|
import {
|
|
acceptanceGateNarration,
|
|
deliveryAdoptNarration,
|
|
openingRangeFromCandidateRange,
|
|
postAdoptVerifyDoneCopy,
|
|
RECTIFICATION_USER_COPY,
|
|
withLastSuccessfulCompareNotice,
|
|
postAdoptCheckLead,
|
|
} from "../user-copy.ts";
|
|
import {
|
|
isTargetedCollectFollowup,
|
|
moreCollectHint,
|
|
preciseGapNarration,
|
|
rangeNarrowHint,
|
|
} from "./collection-question-pool.ts";
|
|
import {
|
|
applyChoiceWithoutEvidence,
|
|
previousInferenceFromReceipt,
|
|
withNakshatraBoundaryProbe,
|
|
} from "./inference-adapter";
|
|
import { liveDistinguishProbe, withOwnedDistinguishProbes } from "./varga-distinguish-probe";
|
|
import { isSameCandidateSplit } from "../core/candidate-contrast-packet.ts";
|
|
import { datedEventCount, scoredChoiceCount } from "./divergence-panel.ts";
|
|
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
|
|
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, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card";
|
|
import { vargaStyleFollowupRenderable } from "./probe-question-contract";
|
|
import { clusterScoreDeltas, excludedClusterRanges } from "./probe-explain.ts";
|
|
import {
|
|
adoptDeliveryFacts,
|
|
hadPostAdoptVerifyQuestions,
|
|
type AdoptNarrationWriter,
|
|
} from "./adopt-narration.ts";
|
|
import {
|
|
persistServerOwnedFocus,
|
|
persistSkippedCollectFocus,
|
|
persistableFocusDomain,
|
|
openQuestionFromPersistedFocus,
|
|
isRenderableChoiceOpenQuestion,
|
|
linkFocusAskedTurn,
|
|
followupHasPersistableDomain,
|
|
} from "./server-focus";
|
|
import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model";
|
|
import { composeCollectSpokenAssistantText } from "./collect-prompt";
|
|
import { renderableChoiceStem } from "./turn-question";
|
|
import {
|
|
blockingMethodsCovered,
|
|
buildMethodFollowupPlan,
|
|
buildNextUserAction,
|
|
exhaustionSpokenCollectFollowup,
|
|
isRemainingEvidenceCollect,
|
|
remainingEvidenceCollectStillOpen,
|
|
planWithDateReliability,
|
|
spokenCollectFallbackFollowup,
|
|
spokenFollowupForUser,
|
|
targetedCollectFollowup,
|
|
tieBreakGateInput,
|
|
tieBreakPersonalityFollowup,
|
|
collectQuestionDomain,
|
|
ledgerHasConfirmedDatedEvent,
|
|
type MethodCoverage,
|
|
type MethodFollowup,
|
|
type MethodFollowupEvidence,
|
|
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";
|
|
import { refreshDatedDiscriminatorPoolIfNeeded } from "./refresh-discriminator-probes.ts";
|
|
|
|
const EXHAUSTION_DELIVERY_ACTIONS = new Set([
|
|
"offer_provisional_range",
|
|
"complete_with_range",
|
|
"ask_fact_collection",
|
|
"ready_to_adopt",
|
|
]);
|
|
|
|
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 looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
|
|
const trimmed = text?.trim() ?? "";
|
|
if (!trimmed) return false;
|
|
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|
|
|| trimmed.includes("这只是代表性候选")
|
|
|| trimmed.includes("就能开始筛")
|
|
|| trimmed.includes("现在记下的是")
|
|
|| trimmed.includes("范围还能再收一截")
|
|
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|
|
|| trimmed.includes("两件事的日期还没对清")
|
|
|| trimmed.includes("这次给出的范围")
|
|
|| trimmed.includes("目前范围");
|
|
}
|
|
|
|
function terminalNoteHostPresent(input: {
|
|
caseId: string;
|
|
dossier: {
|
|
turns?: readonly Readonly<{ role: string; text: string | null }>[];
|
|
latestResult?: { resultId?: string } | null;
|
|
};
|
|
}): boolean {
|
|
const resultId = input.dossier.latestResult?.resultId ?? "";
|
|
if (alreadyDelivered({
|
|
caseId: input.caseId,
|
|
resultId,
|
|
hasUserMessage: false,
|
|
})) return true;
|
|
return (input.dossier.turns ?? []).some((turn) => (
|
|
turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text)
|
|
));
|
|
}
|
|
|
|
function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void {
|
|
console.warn(JSON.stringify({
|
|
event: "rectification_focus_duplicate",
|
|
case_id: caseId,
|
|
question_id: questionId ?? null,
|
|
}));
|
|
}
|
|
|
|
function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string {
|
|
return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("")
|
|
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
|
}
|
|
|
|
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
|
|
return deterministicTurnRequestId(`gate:${askedTurnId ?? caseId}`);
|
|
}
|
|
|
|
export async function persistExhaustionGateTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
askedTurnId?: string | null;
|
|
hostNarration: string;
|
|
resultId?: string | null;
|
|
}): Promise<void> {
|
|
logRectificationDeliveryTurn({
|
|
trigger: "persistExhaustionGateTurn",
|
|
caseId: input.caseId,
|
|
resultId: input.resultId ?? null,
|
|
terminalNote: true,
|
|
});
|
|
if (input.resultId) {
|
|
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
|
|
}
|
|
// BUG-1153: the answered choice's own reply often already carries this delivery text
|
|
// (「已记录,…」 + the same sentences). Once BUG-1149 let the gate turn persist, the same
|
|
// narration showed twice. The gate only fills a gap; it never repeats the last reply.
|
|
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
|
if (narrationAlreadyInLastAssistant(input.hostNarration, dossier)) return;
|
|
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
|
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
|
|
userMessage: null,
|
|
assistantMessage: input.hostNarration,
|
|
});
|
|
}
|
|
|
|
function rememberDeliveryTurn(
|
|
caseId: string,
|
|
resultId: string | null | undefined,
|
|
terminalNote?: boolean,
|
|
): void {
|
|
if (!terminalNote || !resultId) return;
|
|
markDeliveryTurn({ caseId, resultId });
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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;
|
|
if (followup.choice_kind === "varga_style") return false;
|
|
if (followup.source === "nakshatra_boundary") return false;
|
|
return followup.source === "event_probe"
|
|
|| followup.source === "varga_observation"
|
|
|| followup.source === "precision_stage"
|
|
|| 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;
|
|
stopReason?: string | null;
|
|
sessionOutcome?: string | null;
|
|
evidence?: readonly MethodFollowupEvidence[];
|
|
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
|
}): boolean {
|
|
if (input.accepted) return false;
|
|
if (
|
|
input.stopReason === "tied_first"
|
|
&& sessionOutcomeAllowsDelivery(input.sessionOutcome)
|
|
) {
|
|
return true;
|
|
}
|
|
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)) {
|
|
const adoptExhausted = (
|
|
input.nextAction === "ready_to_adopt"
|
|
|| input.sessionOutcome === "adopt_representative"
|
|
) && input.stopReason === "probe_pool_exhausted";
|
|
if (
|
|
!adoptExhausted
|
|
|| remainingEvidenceCollectStillOpen(
|
|
input.followup,
|
|
input.evidence ?? [],
|
|
input.declinedTopics ?? [],
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function postAdoptDoneNarration(
|
|
dossier: Parameters<typeof adoptDeliveryFacts>[1],
|
|
decision: Parameters<typeof adoptDeliveryFacts>[0],
|
|
): string {
|
|
return postAdoptVerifyDoneCopy(
|
|
dossier.case.acceptedTime,
|
|
hadPostAdoptVerifyQuestions(decision, dossier),
|
|
);
|
|
}
|
|
|
|
function adoptHostNarration(input: {
|
|
dossier: Parameters<typeof decideFromDossier>[0];
|
|
decision: ReturnType<typeof decideFromDossier>;
|
|
receipt: Readonly<Record<string, unknown>> | null | undefined;
|
|
}): string {
|
|
const inference = previousInferenceFromReceipt(input.receipt);
|
|
const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate;
|
|
const delivered = deliveryAdoptNarration({
|
|
credibleRange: input.decision.credibleRange,
|
|
representativeTime: input.decision.representativeTime,
|
|
eventCount: datedEventCount(inference),
|
|
fitPercent: fit?.percent ?? null,
|
|
choiceCount: scoredChoiceCount(inference),
|
|
segmentSummary: input.dossier.case.rectificationDomain === null ? null : inference?.segment_summary ?? null,
|
|
productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain),
|
|
openingRange: openingRangeFromDossier(input.dossier),
|
|
});
|
|
// BUG-1147: the segment card already says which chart is credible and which cannot be told
|
|
// apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it.
|
|
if (input.dossier.case.rectificationDomain !== null && inference?.segment_summary) return delivered;
|
|
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
|
|
const hint = rangeNarrowHint(
|
|
catalog.remainingLayers,
|
|
input.dossier.evidence,
|
|
input.dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog.remainingSplitTimes,
|
|
catalog.remainingCandidateCount,
|
|
catalog.remainingCredibleRange,
|
|
catalog.guidedCollectWindows,
|
|
true,
|
|
);
|
|
if (!hint || delivered.includes(hint)) return delivered;
|
|
return `${delivered} ${hint}`.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 isTargetedCollectFocus(focus: {
|
|
questionId?: string | null;
|
|
targetKind?: string | null;
|
|
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
|
|
} | null | undefined): boolean {
|
|
if (!focus) return false;
|
|
const questionId = focus.questionId ?? "";
|
|
const kind = focus.targetKind ?? "";
|
|
const schemaKind = typeof focus.expectedAnswerSchema?.collect_kind === "string"
|
|
? focus.expectedAnswerSchema.collect_kind
|
|
: "";
|
|
return questionId.startsWith("collect:targeted:")
|
|
|| questionId.startsWith("collect:guided:")
|
|
|| kind.startsWith("targeted:")
|
|
|| kind.startsWith("guided:")
|
|
|| schemaKind.startsWith("targeted:")
|
|
|| schemaKind.startsWith("guided:")
|
|
|| focus.expectedAnswerSchema?.targeted_collect === true;
|
|
}
|
|
|
|
function isTargetedCollectExistenceSchema(schema: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
|
if (!schema || schema.targeted_collect !== true) return false;
|
|
const choice = schema.choice;
|
|
return Boolean(choice && typeof choice === "object" && !Array.isArray(choice));
|
|
}
|
|
|
|
function targetedCollectExistenceAck(status: "resolved" | "declined" | "skipped"): string {
|
|
if (status === "resolved") return "记下了。";
|
|
if (status === "skipped") return RECTIFICATION_USER_COPY.collectSkippedAck;
|
|
return RECTIFICATION_USER_COPY.collectDeclinedAck;
|
|
}
|
|
|
|
function targetedCollectHostNarration(
|
|
followup: MethodFollowup | null | undefined,
|
|
fallback: string,
|
|
): string {
|
|
if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) {
|
|
const remaining = followup.spoken_prompt?.trim();
|
|
if (remaining) return remaining;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function dossierWithClosedFocus<T extends {
|
|
conversationSummary: {
|
|
activeFocus: {
|
|
targetDomain?: string | null;
|
|
intent?: string | null;
|
|
questionId?: string | null;
|
|
targetKind?: string | null;
|
|
expectedAnswerSchema?: Readonly<Record<string, unknown>> | 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 rememberClosed = Boolean(
|
|
((status === "declined" || status === "skipped") && domain)
|
|
|| (status === "resolved" && isTargetedCollectFocus(focus))
|
|
|| intent === "reverse_verify",
|
|
);
|
|
const questionId = focus?.questionId ?? "";
|
|
const retryClosed = (status === "skipped" || status === "declined")
|
|
&& (questionId.includes(":retry") || String(focus?.targetKind ?? "").endsWith(":retry"));
|
|
const declinedSkippedTopics = rememberClosed
|
|
? [
|
|
...dossier.conversationSummary.declinedSkippedTopics,
|
|
{
|
|
...(domain ? { target_domain: domain } : {}),
|
|
status,
|
|
...(intent ? { intent } : {}),
|
|
...(focus?.questionId ? { questionId: focus.questionId } : {}),
|
|
...(focus?.targetKind ? { target_kind: focus.targetKind } : {}),
|
|
...(focus?.expectedAnswerSchema ? { expected_answer_schema: focus.expectedAnswerSchema } : {}),
|
|
...(retryClosed ? { retry: true } : {}),
|
|
},
|
|
]
|
|
: dossier.conversationSummary.declinedSkippedTopics;
|
|
return {
|
|
...dossier,
|
|
conversationSummary: {
|
|
...dossier.conversationSummary,
|
|
activeFocus: null,
|
|
declinedSkippedTopics,
|
|
},
|
|
};
|
|
}
|
|
|
|
function dossierWithSkippedTargetedCollect<T extends {
|
|
conversationSummary: {
|
|
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
|
|
activeFocus: unknown;
|
|
};
|
|
}>(dossier: T, followup: MethodFollowup): T {
|
|
const domain = persistableFocusDomain(followup.domain)
|
|
?? persistableFocusDomain(collectQuestionDomain(followup.domain))
|
|
?? "other";
|
|
const questionId = followup.collection_key
|
|
?? `collect:targeted:${domain}`;
|
|
const collectKind = followup.kind_hint ?? `targeted:${domain}`;
|
|
return {
|
|
...dossier,
|
|
conversationSummary: {
|
|
...dossier.conversationSummary,
|
|
activeFocus: null,
|
|
declinedSkippedTopics: [
|
|
...dossier.conversationSummary.declinedSkippedTopics,
|
|
{
|
|
target_domain: domain,
|
|
status: "skipped",
|
|
intent: "collect_method_evidence",
|
|
questionId,
|
|
target_kind: collectKind,
|
|
expected_answer_schema: { collect_kind: collectKind },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether an open distinguish focus has expired (BUG-559 split-hash change).
|
|
*
|
|
* `inference` must already carry the probes this layer owns (BUG-915): a focus
|
|
* whose probe was simply never rebuilt is fresh, not stale, and superseding it
|
|
* on every idle persist is what left the reader with a dead card.
|
|
*/
|
|
export function persistedDistinguishFocusStale(
|
|
inference: InferenceState | null,
|
|
focus: Readonly<{
|
|
intent?: string;
|
|
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
|
|
}> | null | undefined,
|
|
): boolean {
|
|
if (focus?.intent !== "distinguish_candidates") return false;
|
|
const schema = focus.expectedAnswerSchema
|
|
&& typeof focus.expectedAnswerSchema === "object"
|
|
&& !Array.isArray(focus.expectedAnswerSchema)
|
|
? focus.expectedAnswerSchema as Record<string, unknown>
|
|
: null;
|
|
if (!schema) return false;
|
|
const live = liveDistinguishProbe(inference, schema);
|
|
if (!live) return true;
|
|
const split = typeof schema.candidate_split_hash === "string" ? schema.candidate_split_hash : "";
|
|
return Boolean(
|
|
split && !isSameCandidateSplit(split, live.candidate_split_hash, live.semantic_key),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The state every persisted-answer path scores against (BUG-915).
|
|
*
|
|
* The schema on an open focus may name a probe this layer owns, which the
|
|
* engine's `inference_state` never carries: it is rebuilt from the same
|
|
* receipt. Without this the tap and the typed answer both fail `stale_probe`.
|
|
*/
|
|
export function inferenceForPersistedAnswer(
|
|
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
|
): InferenceState | null {
|
|
return withOwnedDistinguishProbes(
|
|
withNakshatraBoundaryProbe(
|
|
previousInferenceFromReceipt(receipt ?? null),
|
|
refinementFromDecisionReceipt(receipt ?? null).nakshatra_boundary,
|
|
),
|
|
receipt ?? null,
|
|
);
|
|
}
|
|
|
|
export async function applyRectificationChoice(
|
|
accounting: AccountingClient,
|
|
command: ApplyChoiceCommand,
|
|
): Promise<AppliedChoiceReceipt> {
|
|
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
|
await assertV9ResultWritable(accounting, command.userId, command.caseId);
|
|
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;
|
|
// BUG-915 injection point 2/5: answering a tap.
|
|
const previous = inferenceForPersistedAnswer(receipt);
|
|
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: schema.midnight_side === true ? mutated.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 (isTargetedCollectExistenceSchema(schema) && optionId !== "stop") {
|
|
const declined = answerClass === "no";
|
|
const skipped = optionId === "skip_probe"
|
|
|| answerClass === "unsure"
|
|
|| answerClass === "weak_yes";
|
|
const focusStatus: "resolved" | "declined" | "skipped" = declined
|
|
? "declined"
|
|
: skipped
|
|
? "skipped"
|
|
: "resolved";
|
|
return persistApplied(accounting, command, {
|
|
focusId: focus.id,
|
|
questionId,
|
|
focusStatus,
|
|
probeId: null,
|
|
optionId,
|
|
scoring: false,
|
|
appliedInference: false,
|
|
answerClass: 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: targetedCollectExistenceAck(focusStatus),
|
|
userDisplay: command.userDisplay
|
|
?? (optionId === "skip_probe"
|
|
? CHOICE_SKIP_QUESTION_LABEL
|
|
: userDisplayFromSchema(schema, optionId)),
|
|
decisionState: previous,
|
|
userStopped: false,
|
|
dossier,
|
|
});
|
|
}
|
|
|
|
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,
|
|
excludedRanges: excludedClusterRanges(previous.candidates, applied.state.candidates),
|
|
segmentProduct: dossier.case.rectificationDomain !== null
|
|
&& Boolean(applied.state.segment_summary ?? previous.segment_summary),
|
|
});
|
|
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;
|
|
skipRefresh?: boolean;
|
|
continueTieBreakRound?: boolean;
|
|
followup?: MethodFollowup | null;
|
|
}): Promise<{
|
|
hostNarration: string;
|
|
choiceReady: boolean;
|
|
persisted?: boolean;
|
|
focusId?: string | null;
|
|
focus?: ConversationFocus | null;
|
|
followup?: MethodFollowup | null;
|
|
terminalNote?: boolean;
|
|
}> {
|
|
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
|
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
|
const identityDossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
|
if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) {
|
|
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
|
}
|
|
const birthDate = input.birthDate ?? null;
|
|
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
|
|
let decisionState = input.decisionState;
|
|
let decision = input.decision ?? decideAfterInferenceChange({
|
|
dossier: liveDossier,
|
|
state: decisionState,
|
|
userStopped: false,
|
|
birthDate,
|
|
});
|
|
if (!input.skipRefresh) {
|
|
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier: liveDossier,
|
|
state: decisionState,
|
|
hasDatedProbe: Boolean(decision.probe),
|
|
});
|
|
liveDossier = refreshed.dossier;
|
|
decisionState = refreshed.state;
|
|
if (refreshed.refreshed || refreshed.attemptRecorded) {
|
|
decision = decideAfterInferenceChange({
|
|
dossier: liveDossier,
|
|
state: decisionState,
|
|
userStopped: false,
|
|
birthDate,
|
|
});
|
|
}
|
|
}
|
|
const catalog = rectificationFollowupCatalog(liveDossier.latestResult, liveDossier.evidence);
|
|
const nextAction = publicNextAction(decision);
|
|
const sessionOutcome = typeof nextAction.session_outcome === "string"
|
|
? nextAction.session_outcome as SessionOutcomeKind
|
|
: "collect_evidence";
|
|
const planInput = {
|
|
evidence: liveDossier.evidence,
|
|
activeFocus: null,
|
|
declinedTopics: liveDossier.conversationSummary.declinedSkippedTopics,
|
|
closedCollectFocuses: liveDossier.conversationSummary.declinedSkippedTopics,
|
|
sessionOutcome,
|
|
...catalog,
|
|
birthDate,
|
|
accepted: Boolean(liveDossier.case.acceptedTime),
|
|
candidatesSeparated: nextAction.type !== "ask_candidate_discriminator"
|
|
&& nextAction.type !== "ask_holdout_validation",
|
|
...followupCaseArgs({
|
|
stage: liveDossier.case.stage,
|
|
blockScan: liveDossier.case.blockScan,
|
|
reportedBirthTime: liveDossier.case.reportedBirthTime,
|
|
candidateRange: liveDossier.case.candidateRange,
|
|
}),
|
|
};
|
|
const roundFollowup = input.continueTieBreakRound === true
|
|
? tieBreakPersonalityFollowup(planInput)
|
|
: null;
|
|
if (roundFollowup) {
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
|
|
followup: roundFollowup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
|
return {
|
|
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
|
choiceReady: true,
|
|
persisted: true,
|
|
focusId: persistedFocus.focus?.id ?? null,
|
|
focus: persistedFocus.focus,
|
|
followup: roundFollowup,
|
|
};
|
|
}
|
|
}
|
|
const heldFollowup = decision.heldForTieBreak === true
|
|
? tieBreakPersonalityFollowup({ ...planInput, tieBreakRequested: true })
|
|
: null;
|
|
if (heldFollowup) {
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
|
|
followup: heldFollowup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
|
return {
|
|
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
|
choiceReady: true,
|
|
persisted: true,
|
|
focusId: persistedFocus.focus?.id ?? null,
|
|
focus: persistedFocus.focus,
|
|
followup: heldFollowup,
|
|
};
|
|
}
|
|
}
|
|
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
|
|
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
|
|
if (shouldSkipFollowupPersist({
|
|
canAdopt: nextAction.can_adopt,
|
|
nextAction: nextAction.type,
|
|
followup,
|
|
methods: plan.methods,
|
|
accepted: Boolean(liveDossier.case.acceptedTime),
|
|
stopReason: decision.stopReason ?? null,
|
|
sessionOutcome: nextAction.session_outcome,
|
|
evidence: liveDossier.evidence,
|
|
declinedTopics: liveDossier.conversationSummary.declinedSkippedTopics,
|
|
}) && deliveryNarrationAllowed(decision, nextAction.type)) {
|
|
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;
|
|
rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true);
|
|
return {
|
|
hostNarration: narrated,
|
|
choiceReady: false,
|
|
persisted: false,
|
|
followup: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
if (input.dossier.case.acceptedTime && !followup) {
|
|
return {
|
|
hostNarration: postAdoptDoneNarration(liveDossier, decision),
|
|
choiceReady: false,
|
|
persisted: false,
|
|
followup: null,
|
|
};
|
|
}
|
|
const exhaustion = {
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier: liveDossier,
|
|
decision,
|
|
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
};
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
|
|
followup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
|
return {
|
|
hostNarration: targetedCollectHostNarration(followup, open.prompt),
|
|
choiceReady: true,
|
|
persisted: true,
|
|
focusId: persistedFocus.focus?.id ?? null,
|
|
focus: persistedFocus.focus,
|
|
followup,
|
|
};
|
|
}
|
|
if (open?.kind === "collect_spoken" && open.prompt) {
|
|
if (stemAlreadyInLastAssistant(open.prompt, input.dossier)) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
return {
|
|
hostNarration: open.prompt,
|
|
choiceReady: false,
|
|
persisted: true,
|
|
focusId: persistedFocus.focus?.id ?? null,
|
|
focus: persistedFocus.focus,
|
|
followup,
|
|
};
|
|
}
|
|
if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
if (followup?.intent === "collect_method_evidence" && followup.choice_frame) {
|
|
const spokenFollowup = spokenCollectFallbackFollowup(followup);
|
|
const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence);
|
|
if (spoken) {
|
|
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
const fallback = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: liveDossier.latestResult?.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 (persistedFocus.status === "duplicate_focus") {
|
|
logDuplicateFocus(input.caseId, persistedFocus.questionId);
|
|
}
|
|
if (
|
|
spoken
|
|
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
|
|
) {
|
|
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
|
|
}
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
if (!followup) {
|
|
if (input.dossier.case.acceptedTime) {
|
|
return {
|
|
hostNarration: postAdoptDoneNarration(liveDossier, decision),
|
|
choiceReady: false,
|
|
followup: null,
|
|
};
|
|
}
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
if (
|
|
followup.intent === "distinguish_candidates"
|
|
|| (followup.choice_frame && followup.intent !== "collect_method_evidence")
|
|
) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
|
|
if (!spoken) {
|
|
if (input.dossier.case.acceptedTime) {
|
|
return {
|
|
hostNarration: postAdoptDoneNarration(liveDossier, decision),
|
|
choiceReady: false,
|
|
followup: null,
|
|
};
|
|
}
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
|
|
return persistExhaustionCollect(exhaustion);
|
|
}
|
|
return {
|
|
hostNarration: spoken,
|
|
choiceReady: false,
|
|
persisted: false,
|
|
followup,
|
|
};
|
|
}
|
|
|
|
export function narrationAlreadyInLastAssistant(
|
|
narration: string,
|
|
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
|
|
): boolean {
|
|
const squash = (value: string) => value.replace(/\s+/g, "");
|
|
const needle = squash(narration);
|
|
if (needle.length < 8) return false;
|
|
const turns = dossier.turns ?? [];
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (turn?.role !== "assistant") continue;
|
|
const text = typeof turn.text === "string" ? squash(turn.text) : "";
|
|
if (!text) continue;
|
|
return text.includes(needle);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stemAlreadyInLastAssistant(
|
|
stem: string,
|
|
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
|
|
): boolean {
|
|
const needle = stem.trim();
|
|
if (needle.length < 8) return false;
|
|
const turns = dossier.turns ?? [];
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (turn?.role !== "assistant") continue;
|
|
const text = typeof turn.text === "string" ? turn.text : "";
|
|
if (!text.trim()) continue;
|
|
return text.includes(needle);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function requestTieBreakPersonality(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
requestId: string;
|
|
askedTurnId?: string | null;
|
|
}): Promise<{
|
|
persisted: boolean;
|
|
choiceReady: boolean;
|
|
hostNarration: string;
|
|
focus: ConversationFocus | null;
|
|
}> {
|
|
const { assertV9ResultWritable } = await import("./result-identity.ts");
|
|
await assertV9ResultWritable(input.accounting, input.userId, input.caseId);
|
|
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
|
if (dossier.case.acceptedTime) {
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: "",
|
|
focus: 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 catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
|
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog,
|
|
birthDate,
|
|
accepted: Boolean(dossier.case.acceptedTime),
|
|
stage: dossier.case.stage,
|
|
blockScan: dossier.case.blockScan,
|
|
reportedBirthTime: dossier.case.reportedBirthTime,
|
|
candidateRange: dossier.case.candidateRange,
|
|
}));
|
|
if (!followup) {
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: "",
|
|
focus: null,
|
|
};
|
|
}
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
|
followup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
if (!isRenderableChoiceOpenQuestion(open) || !open.prompt) {
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: "",
|
|
focus: persistedFocus.focus,
|
|
};
|
|
}
|
|
const hostNarration = RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck;
|
|
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
|
requestId: input.requestId,
|
|
userMessage: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
|
|
assistantMessage: hostNarration,
|
|
});
|
|
if (persistedFocus.focus && turn.turnId) {
|
|
try {
|
|
await linkFocusAskedTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
focus: persistedFocus.focus,
|
|
askedTurnId: turn.turnId,
|
|
});
|
|
} catch {
|
|
// Choice card can still render from the persisted focus.
|
|
}
|
|
}
|
|
return {
|
|
persisted: true,
|
|
choiceReady: true,
|
|
hostNarration,
|
|
focus: persistedFocus.focus,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
const followupKind = input.followup?.choice_kind
|
|
?? input.followup?.choice_frame?.choice_kind
|
|
?? null;
|
|
const followupStyles = input.followup?.style_options ?? [];
|
|
if (followupKind === "varga_style" && followupStyles.length > 0 && !vargaStyleFollowupRenderable({
|
|
choiceKind: followupKind,
|
|
styleOptions: followupStyles,
|
|
})) {
|
|
return {
|
|
status: "skipped" as const,
|
|
focus: null,
|
|
questionId: null,
|
|
prompt: 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"
|
|
|| persisted.status === "duplicate_focus"
|
|
|| !input.followup
|
|
) {
|
|
if (persisted.status === "duplicate_focus") {
|
|
logDuplicateFocus(input.caseId, persisted.questionId);
|
|
}
|
|
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;
|
|
status?: "declined" | "skipped";
|
|
},
|
|
): Promise<{
|
|
narration: string;
|
|
ack: string;
|
|
closeStatus: "declined" | "skipped";
|
|
nextInterviewPersisted: boolean;
|
|
nextChoiceReady: boolean;
|
|
focus: ConversationFocus | null;
|
|
}> {
|
|
const closeStatus = input.status === "skipped" ? "skipped" : "declined";
|
|
const ack = closeStatus === "skipped"
|
|
? RECTIFICATION_USER_COPY.collectSkippedAck
|
|
: RECTIFICATION_USER_COPY.collectDeclinedAck;
|
|
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
|
|
const { resultIdentityView } = await import("./result-identity.ts");
|
|
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
|
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
|
|
throw new RectificationToolServiceError("result_identity_read_only");
|
|
}
|
|
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: closeStatus,
|
|
evidenceId: null,
|
|
});
|
|
if (input.deferFollowup === true) {
|
|
return {
|
|
narration: ack,
|
|
ack,
|
|
closeStatus,
|
|
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,
|
|
},
|
|
},
|
|
}, closeStatus);
|
|
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,
|
|
continueTieBreakRound: isTieBreakRoundSchema(focus.expectedAnswerSchema),
|
|
});
|
|
return {
|
|
narration: nextInterview.hostNarration,
|
|
ack,
|
|
closeStatus,
|
|
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. Collect spoken
|
|
* still streams the acknowledgment only (BUG-525); a renderable choice
|
|
* streams ack+stem so the next question is not GET-only (BUG-969).
|
|
*/
|
|
export async function persistCollectDenialTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
requestId: string;
|
|
userMessage: string | null;
|
|
applied: CollectDenialApplied;
|
|
}): Promise<{ streamText: string; turnId: string }> {
|
|
const ack = input.applied.ack ?? RECTIFICATION_USER_COPY.collectDeclinedAck;
|
|
const hasNextStem = Boolean(
|
|
input.applied.focus
|
|
&& (input.applied.nextInterviewPersisted || input.applied.nextChoiceReady)
|
|
&& input.applied.narration.trim(),
|
|
);
|
|
const stored = hasNextStem
|
|
? composeCollectSpokenAssistantText(ack, input.applied.narration)
|
|
: input.applied.narration;
|
|
const choiceStem = renderableChoiceStem(input.applied.focus);
|
|
const streamText = hasNextStem && choiceStem ? stored : hasNextStem ? ack : 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 };
|
|
}
|
|
|
|
/**
|
|
* `append_agentic_rectification_turn` keys idempotency on a uuid `p_request_id`; a suffixed
|
|
* id (「<uuid>:adopt」「<uuid>:gate」) is rejected by PostgreSQL (BUG-1149). Same seed → same uuid,
|
|
* so a retried request still lands on the same turn.
|
|
*/
|
|
export function adoptTurnRequestId(requestId: string): string {
|
|
return deterministicTurnRequestId(`adopt:${requestId}`);
|
|
}
|
|
|
|
export function deterministicTurnRequestId(seed: string): string {
|
|
const hex = createHash("sha256").update(`rectification-turn:${seed}`).digest("hex");
|
|
const variant = ((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
}
|
|
|
|
/**
|
|
* BUG-1146: after 采用 the first post-adopt check is persisted by persistNextInterviewIfIdle, but the
|
|
* accept route wrote no turn. The read_only agent call that follows short-circuits as
|
|
* already_delivered within a minute of delivery, so the check had no message of its own and the
|
|
* client hung its stem under the previous answer's 「已记录,…」 narration. Write the adopt turn
|
|
* here, hang the check on it, and mark the delivery so the follow-up read_only call stays quiet.
|
|
*/
|
|
export async function writeAdoptFollowupTurn(input: {
|
|
accounting: AccountingClient;
|
|
userId: string;
|
|
caseId: string;
|
|
requestId: string;
|
|
resultId: string | null;
|
|
hostNarration: string | null;
|
|
focus: ConversationFocus | null;
|
|
savedTime?: string | null;
|
|
}): Promise<{ turnId: string } | null> {
|
|
const narration = input.hostNarration?.trim();
|
|
if (!narration) return null;
|
|
const focus = input.focus;
|
|
const check = Boolean(focus && !focus.askedTurnId
|
|
&& (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"));
|
|
// The stem leaves the body once the card attaches; the lead is what the message says.
|
|
const turn = await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
|
|
requestId: adoptTurnRequestId(input.requestId),
|
|
userMessage: null,
|
|
assistantMessage: check ? `${postAdoptCheckLead(input.savedTime)}${narration}` : narration,
|
|
});
|
|
if (focus && check) {
|
|
await linkFocusAskedTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
focus,
|
|
askedTurnId: turn.turnId,
|
|
});
|
|
}
|
|
if (input.resultId) markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
|
|
return { 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;
|
|
/** The next focus was already active; it was only (re)linked to this turn. */
|
|
focusActive?: boolean;
|
|
}> {
|
|
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
let dossier = rescored.dossier;
|
|
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
|
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
|
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
|
|
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
|
}
|
|
const finishIdle = <T extends { hostNarration: string | null; terminalNote?: boolean }>(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) {
|
|
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote);
|
|
return result;
|
|
}
|
|
const finished = { ...result, hostNarration };
|
|
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote);
|
|
return finished;
|
|
};
|
|
const staleFocus = dossier.conversationSummary.activeFocus;
|
|
const staleFocusId = staleFocus?.id;
|
|
// BUG-915 injection point 4/5: idle supersede. Without the rebuild a fresh
|
|
// focus is superseded on every idle persist.
|
|
const inferenceForStale = withOwnedDistinguishProbes(
|
|
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
|
dossier.latestResult?.decisionReceipt ?? null,
|
|
);
|
|
const staleDistinguish = persistedDistinguishFocusStale(inferenceForStale, staleFocus);
|
|
if (
|
|
isPersistedFocusId(staleFocusId)
|
|
&& (
|
|
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|
|
|| isOrphanOtherCollectFocus(staleFocus, dossier.evidence)
|
|
|| staleDistinguish
|
|
)
|
|
) {
|
|
try {
|
|
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
|
focusId: staleFocusId,
|
|
status: staleDistinguish ? "superseded" : "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, focusActive: true });
|
|
}
|
|
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;
|
|
}
|
|
let decision = decideFromDossier(dossier, {
|
|
birthDate,
|
|
snapshotCurrent: rescored.snapshotCurrent,
|
|
});
|
|
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
dossier,
|
|
state: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
|
userStopped: input.userStopped,
|
|
hasDatedProbe: Boolean(decision.probe),
|
|
});
|
|
if (refreshed.refreshed || refreshed.attemptRecorded) {
|
|
const latest = dossier.latestResult;
|
|
const overlay = refreshed.dossier.latestResult;
|
|
dossier = {
|
|
...dossier,
|
|
latestResult: latest && overlay
|
|
? {
|
|
...latest,
|
|
decisionReceipt: overlay.decisionReceipt ?? latest.decisionReceipt ?? null,
|
|
}
|
|
: latest,
|
|
};
|
|
decision = decideFromDossier(dossier, {
|
|
birthDate,
|
|
snapshotCurrent: rescored.snapshotCurrent,
|
|
});
|
|
}
|
|
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
|
if (decision.heldForTieBreak === true) {
|
|
const heldFollowup = tieBreakPersonalityFollowup(tieBreakGateInput({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog,
|
|
birthDate,
|
|
accepted: Boolean(dossier.case.acceptedTime),
|
|
stage: dossier.case.stage,
|
|
blockScan: dossier.case.blockScan,
|
|
reportedBirthTime: dossier.case.reportedBirthTime,
|
|
candidateRange: dossier.case.candidateRange,
|
|
}));
|
|
if (heldFollowup) {
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
|
followup: heldFollowup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
|
return finishIdle({
|
|
persisted: true,
|
|
choiceReady: true,
|
|
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const planInference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
|
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,
|
|
inferenceState: planInference,
|
|
candidateRanges: planInference?.candidates.map((item) => ({
|
|
id: item.id,
|
|
time: item.time,
|
|
cluster_range: item.cluster_range,
|
|
})),
|
|
...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),
|
|
stopReason: decision.stopReason ?? null,
|
|
sessionOutcome: decision.sessionOutcome,
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
}) && deliveryNarrationAllowed(decision, decision.nextAction)) {
|
|
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: narrated,
|
|
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) {
|
|
if (dossier.case.acceptedTime) {
|
|
return finishIdle({
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: postAdoptDoneNarration(dossier, decision),
|
|
});
|
|
}
|
|
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,
|
|
skipRefresh: true,
|
|
continueTieBreakRound: isTieBreakRoundSchema(dossier.conversationSummary.activeFocus?.expectedAnswerSchema),
|
|
});
|
|
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;
|
|
followup?: MethodFollowup | null;
|
|
terminalNote?: boolean;
|
|
}> {
|
|
let dossier = input.dossier as DecisionDossier;
|
|
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
|
|
let decision = input.decision;
|
|
let catalog = rectificationFollowupCatalog(
|
|
dossier.latestResult ?? null,
|
|
dossier.evidence,
|
|
);
|
|
let followup = exhaustionSpokenCollectFollowup({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
answeredProbes: catalog.answeredProbes,
|
|
eventProbes: catalog.eventProbes,
|
|
}) ?? targetedCollectFollowup(
|
|
catalog.remainingLayers,
|
|
dossier.evidence,
|
|
dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog.remainingSplitTimes,
|
|
catalog.remainingCandidateCount,
|
|
catalog.remainingCredibleRange,
|
|
);
|
|
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,
|
|
};
|
|
let persistStatus: string | null = null;
|
|
let persistQuestionId: string | null = null;
|
|
let targetedSkipCount = 0;
|
|
while (followup) {
|
|
const range = nonConvergingRangeNarration({
|
|
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
|
|
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
|
|
openingRange: openingRangeFromDossier(dossier),
|
|
variant: "intermediate",
|
|
});
|
|
const persistedFocus = await persistFocusAfterChoice({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
decisionReceipt: receipt,
|
|
followup,
|
|
askedTurnId: input.askedTurnId ?? null,
|
|
});
|
|
persistStatus = persistedFocus.status;
|
|
persistQuestionId = persistedFocus.questionId;
|
|
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,
|
|
persist_status: persistStatus,
|
|
question_id: persistQuestionId,
|
|
}));
|
|
const open = openQuestionFromPersistedFocus(persistedFocus);
|
|
const persistOk = persistedFocus.status === "created" || persistedFocus.status === "already_open";
|
|
if (persistOk && isRenderableChoiceOpenQuestion(open) && open.prompt) {
|
|
return {
|
|
persisted: true,
|
|
choiceReady: true,
|
|
hostNarration: targetedCollectHostNarration(followup, open.prompt),
|
|
focus: persistedFocus.focus,
|
|
followup,
|
|
};
|
|
}
|
|
const spoken = followup.choice_frame
|
|
? (followup.spoken_prompt?.trim() || spokenFollowupForUser({ ...followup, choice_frame: null }))
|
|
: spokenFollowupForUser(followup);
|
|
if (persistOk && spoken && !followup.choice_frame) {
|
|
return {
|
|
persisted: true,
|
|
choiceReady: false,
|
|
hostNarration: spoken,
|
|
focus: persistedFocus.focus,
|
|
};
|
|
}
|
|
if (isTargetedCollectFollowup(followup) && targetedSkipCount < 8) {
|
|
await persistSkippedCollectFocus({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
followup,
|
|
});
|
|
dossier = dossierWithSkippedTargetedCollect(dossier, followup);
|
|
decision = decideAfterInferenceChange({
|
|
dossier,
|
|
state: previousInferenceFromReceipt(receipt),
|
|
userStopped: false,
|
|
});
|
|
catalog = rectificationFollowupCatalog(
|
|
dossier.latestResult ?? null,
|
|
dossier.evidence,
|
|
);
|
|
followup = exhaustionSpokenCollectFollowup({
|
|
evidence: dossier.evidence,
|
|
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
|
answeredProbes: catalog.answeredProbes,
|
|
eventProbes: catalog.eventProbes,
|
|
}) ?? targetedCollectFollowup(
|
|
catalog.remainingLayers,
|
|
dossier.evidence,
|
|
dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog.remainingSplitTimes,
|
|
catalog.remainingCandidateCount,
|
|
catalog.remainingCredibleRange,
|
|
);
|
|
targetedSkipCount += 1;
|
|
continue;
|
|
}
|
|
// duplicate_focus is not "already asked". Keep the spoken collect or the
|
|
// intermediate range sentence instead of falling through to adopt.
|
|
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
|
|
if (hostNarration) {
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration,
|
|
focus: persistedFocus.focus,
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
if (!followup) {
|
|
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: null,
|
|
next_source: null,
|
|
persist_status: persistStatus,
|
|
question_id: persistQuestionId,
|
|
}));
|
|
}
|
|
if (!trainingGate.open) {
|
|
const gap = preciseGapNarration(
|
|
dossier.evidence,
|
|
dossier.conversationSummary.declinedSkippedTopics,
|
|
);
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: gap,
|
|
focus: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
if (
|
|
ceiling.acceptanceAllowed
|
|
&& trainingGate.open
|
|
&& decision.canAdopt
|
|
&& deliveryNarrationAllowed(decision, decision.nextAction)
|
|
) {
|
|
const adopted = {
|
|
...decision,
|
|
stopReason: decision.stopReason ?? "probe_pool_exhausted",
|
|
};
|
|
return {
|
|
persisted: false,
|
|
choiceReady: false,
|
|
hostNarration: adoptHostNarration({
|
|
dossier,
|
|
decision: adopted,
|
|
receipt,
|
|
}),
|
|
focus: null,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
const hint = rangeNarrowHint(
|
|
catalog.remainingLayers,
|
|
dossier.evidence,
|
|
dossier.conversationSummary.declinedSkippedTopics,
|
|
catalog.remainingSplitTimes,
|
|
catalog.remainingCandidateCount,
|
|
catalog.remainingCredibleRange,
|
|
catalog.guidedCollectWindows,
|
|
true,
|
|
);
|
|
const range = nonConvergingRangeNarration({
|
|
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
|
|
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
|
|
openingRange: openingRangeFromDossier(dossier),
|
|
variant: "intermediate",
|
|
});
|
|
const hostNarration = exhaustionCarrierNarration(range, hint);
|
|
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,
|
|
continueTieBreakRound: isTieBreakRoundSchema(
|
|
input.dossier.conversationSummary.activeFocus?.expectedAnswerSchema,
|
|
),
|
|
});
|
|
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
|
|
|| (
|
|
nextInterview.choiceReady
|
|
&& nextInterview.followup
|
|
&& isTargetedCollectFollowup(nextInterview.followup)
|
|
)
|
|
) {
|
|
hostNarration = `${input.narration}
|
|
|
|
${nextInterview.hostNarration}`;
|
|
}
|
|
nextInterviewPersisted = true;
|
|
}
|
|
}
|
|
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
|
|
const liveReceipt = liveDossier.latestResult?.decisionReceipt;
|
|
const liveInference = previousInferenceFromReceipt(liveReceipt);
|
|
const liveFit = refinementFromDecisionReceipt(liveReceipt).event_fit_rate?.percent ?? null;
|
|
const adoptionNarration = !accepted && nextAction.can_adopt
|
|
? deliveryAdoptNarration({
|
|
credibleRange: nextAction.credible_range,
|
|
representativeTime: nextAction.representative_time,
|
|
eventCount: datedEventCount(liveInference),
|
|
fitPercent: liveFit,
|
|
choiceCount: scoredChoiceCount(liveInference),
|
|
segmentSummary: liveDossier.case.rectificationDomain === null ? null : liveInference?.segment_summary ?? null,
|
|
productDelivery: productDeliveryForDomain(liveDossier.case.rectificationDomain),
|
|
openingRange: openingRangeFromDossier(liveDossier),
|
|
})
|
|
: null;
|
|
const completedRangeNarration = !accepted && nextAction.type === "complete_with_range"
|
|
? deliveryAdoptNarration({
|
|
credibleRange: nextAction.credible_range,
|
|
representativeTime: nextAction.representative_time,
|
|
eventCount: datedEventCount(liveInference),
|
|
fitPercent: liveFit,
|
|
choiceCount: scoredChoiceCount(liveInference),
|
|
segmentSummary: liveDossier.case.rectificationDomain === null ? null : liveInference?.segment_summary ?? null,
|
|
productDelivery: productDeliveryForDomain(liveDossier.case.rectificationDomain),
|
|
openingRange: openingRangeFromDossier(liveDossier),
|
|
})
|
|
: null;
|
|
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
|
|
if (accepted && !keptNextQuestion) {
|
|
hostNarration = postAdoptDoneNarration(liveDossier, nextDecision);
|
|
nextFollowup = null;
|
|
} else if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
|
|
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
|
|
}
|
|
|
|
// BUG-1170: an answered post-adopt check says what it meant for the adopted time.
|
|
const answeredIntent = input.dossier.conversationSummary.activeFocus?.intent;
|
|
if (accepted && !skipThisProbe && input.userStopped !== true
|
|
&& (answeredIntent === "reverse_verify" || answeredIntent === "out_of_sample_check")) {
|
|
const before = inferenceForPersistedAnswer(input.dossier.latestResult?.decisionReceipt ?? null);
|
|
const after = input.decisionState ?? before;
|
|
const segmentProduct = input.dossier.case.rectificationDomain !== null && input.dossier.case.rectificationDomain !== undefined;
|
|
const verdict = postAdoptCheckVerdict({
|
|
acceptedTime: input.dossier.case.acceptedTime,
|
|
probe: input.probeId
|
|
? (after?.probes.find((probe) => probe.id === input.probeId) ?? before?.probes.find((probe) => probe.id === input.probeId) ?? null)
|
|
: null,
|
|
answerClass: input.answerClass,
|
|
candidates: after?.candidates ?? [],
|
|
segmentBefore: segmentProduct ? before?.segment_summary ?? null : null,
|
|
segmentAfter: segmentProduct ? after?.segment_summary ?? null : null,
|
|
});
|
|
if (verdict) hostNarration = hostNarration ? `${verdict}\n\n${hostNarration}` : verdict;
|
|
}
|
|
|
|
const choiceStem = nextChoiceReady ? renderableChoiceStem(nextFocus) : null;
|
|
if (choiceStem) {
|
|
const body = skipThisProbe
|
|
? composeCollectSpokenAssistantText(input.narration, hostNarration)
|
|
: hostNarration;
|
|
hostNarration = composeCollectSpokenAssistantText(body, choiceStem);
|
|
nextInterviewPersisted = true;
|
|
}
|
|
|
|
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 satisfied = Boolean(
|
|
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|
|
|| dossier.case.acceptedTime
|
|
|| dossier.case.confirmedTime
|
|
|| decision.completionStatus === "provisional_range_user_stopped"
|
|
|| publicCanAdopt(decision)
|
|
|| terminalNoteHostPresent({ caseId: input.caseId, dossier })
|
|
);
|
|
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);
|
|
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
|
|
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
|
|
if (resultIdentityView(before.dossier, await readV9EngineScoringIdentity()).read_only) {
|
|
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
|
|
}
|
|
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,
|
|
});
|
|
if (repaired.persisted && repaired.focus) {
|
|
const afterFocus = await inspectNonTerminalTurnExit(input);
|
|
if (!afterFocus.satisfied && !repaired.hostNarration) {
|
|
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
|
}
|
|
return repaired;
|
|
}
|
|
const hostNarration = (repaired.hostNarration ?? "").trim()
|
|
|| RECTIFICATION_USER_COPY.noCandidatesGate;
|
|
try {
|
|
await persistExhaustionGateTurn({
|
|
accounting: input.accounting,
|
|
userId: input.userId,
|
|
caseId: input.caseId,
|
|
hostNarration,
|
|
resultId: before.dossier.latestResult?.resultId ?? null,
|
|
});
|
|
} catch (error) {
|
|
console.warn(
|
|
`[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
|
);
|
|
}
|
|
const after = await inspectNonTerminalTurnExit(input);
|
|
if (!after.satisfied && !hostNarration) {
|
|
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
|
|
}
|
|
return {
|
|
...repaired,
|
|
persisted: true,
|
|
hostNarration,
|
|
terminalNote: true,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|