fix(rectification): keep one delivery and adopt with the birth date

Accept and GET now decide with the same birth snapshot as the answer, so an under-age probe cannot flip can_adopt. The exit skips a second write when this reply already delivered. The card stays without an adopt button when adoption is not allowed, and a refusal shows the Chinese sentence.
This commit is contained in:
jesse-ux
2026-10-06 16:03:57 +08:00
committed by Jesse_Chen
parent da226db1d4
commit 6e16dc5e16
34 changed files with 1100 additions and 182 deletions
@@ -10,11 +10,10 @@
*/
import { getRectificationV9Agent } from "@/mastra/agentic-rectification";
import {
evidenceLedgerFingerprint,
loadV9CaseCompute,
loadV9CaseDossier,
RectificationToolServiceError,
} from "@/lib/rectification-agentic/v9/tool-service";
import { loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
import { decideFromDossier } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { persistNextInterviewIfIdle } from "@/lib/rectification-agentic/v9/answer-choice";
import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run";
@@ -228,17 +227,10 @@ export function respondWithRectificationAgentTurn(
try {
const dossier = await loadV9CaseDossier(accounting as never, userId, caseId);
productDelivery = productDeliveryForDomain(dossier.case.rectificationDomain);
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting as never, userId, caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
// Ranking stays fail-open when compute is unavailable.
}
decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
birthDate,
});
decision = decideFromDossier(
dossier,
await loadCaseDecisionInputs(accounting as never, userId, caseId),
);
} catch {
decision = undefined;
}
@@ -23,6 +23,10 @@ import type {
RectificationAgentRouteContext,
} from "@/lib/rectification-agentic/v9/agent-route-support";
function rectificationTurnHeaders(turnId: string | null | undefined): { headers: Record<string, string> } | undefined {
return turnId ? { headers: { "x-rectification-turn-id": turnId } } : undefined;
}
export async function handleRectificationStructuredChoice(
input: RectificationAgentRouteContext & Readonly<{ narrateAdopt: RectificationAdoptNarrator }>,
): Promise<Response> {
@@ -76,7 +80,7 @@ export async function handleRectificationStructuredChoice(
narration: assistantMessage,
userMessage: CHOICE_STOP_LABEL,
turnId: turn.turnId,
});
}, rectificationTurnHeaders(turn.turnId));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
@@ -145,7 +149,7 @@ export async function handleRectificationStructuredChoice(
next_user_action: applied.nextUserAction,
nextInterviewPersisted: applied.nextInterviewPersisted,
nextChoiceReady: applied.nextChoiceReady,
});
}, rectificationTurnHeaders(applied.turnId));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
@@ -8,12 +8,12 @@
*/
import { NextResponse } from "next/server";
import {
loadV9CaseCompute,
loadV9CaseDossier,
persistV9DeterministicTurn,
RectificationToolServiceError,
transitionV9CaseStatus,
} from "@/lib/rectification-agentic/v9/tool-service";
import { loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import {
applyRectificationChoice,
@@ -251,14 +251,9 @@ export async function handleRectificationTypedMessage(
}
}
} else {
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting, userId, caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
// Ranking stays fail-open when compute is unavailable.
}
const decision = decideFromDossier(dossier, { birthDate });
const decisionInputs = await loadCaseDecisionInputs(accounting, userId, caseId);
const birthDate = decisionInputs.birthDate;
const decision = decideFromDossier(dossier, decisionInputs);
if (isNonConvergingRangeOffer(decision)) {
const idle = await persistNextInterviewIfIdle({
accounting,
@@ -11,6 +11,7 @@
*/
import { resolveRectificationStepBudget } from "./step-budget";
import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
import { loadCaseDecisionInputs } from "./decision-inputs.ts";
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
@@ -580,12 +581,16 @@ export async function streamV9Attempt(
return completeAttempt(false);
}
let latestDossier: V9CaseDossier;
let decision;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
decision = decideFromDossier(
latestDossier,
await loadCaseDecisionInputs(accounting, userId, caseId),
);
} catch {
return failedAttempt(attemptId, "state_invariant_failed");
}
const decision = decideFromDossier(latestDossier);
if (decision.nextAction === "ask_candidate_discriminator") {
const openFocus = latestDossier.conversationSummary.activeFocus;
if (!(
@@ -44,7 +44,8 @@ import {
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 { alreadyDelivered, deliveryCarrierMatchesLastAssistant, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import { decisionInputsFromSnapshot, loadCaseDecisionInputs } from "./decision-inputs.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
@@ -66,7 +67,6 @@ import {
import {
evidenceLedgerFingerprint,
inferenceFingerprintForState,
loadV9CaseCompute,
loadV9CaseDossier,
persistV9ChoiceAction,
persistV9DeterministicTurn,
@@ -207,14 +207,23 @@ export async function persistExhaustionGateTurn(input: {
resultId: input.resultId ?? null,
terminalNote: true,
});
// BUG-1153 kept the substring check. BUG-1241: a rewritten delivery is not a
// substring of the deterministic fill-in, so also skip when the latest
// assistant text is the delivery sentence already stored for this result.
// Read that mark before this call's own mark, or the unsaid fill-in (BUG-1149)
// would see a mark it just wrote and skip itself.
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const markedResultId = input.resultId ?? dossier.latestResult?.resultId ?? null;
const alreadySaid = narrationAlreadyInLastAssistant(input.hostNarration, dossier)
|| (markedResultId !== null && deliveryCarrierMatchesLastAssistant({
caseId: input.caseId,
resultId: markedResultId,
turns: dossier.turns,
}));
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;
if (alreadySaid) return;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -226,9 +235,10 @@ function rememberDeliveryTurn(
caseId: string,
resultId: string | null | undefined,
terminalNote?: boolean,
narration?: string | null,
): void {
if (!terminalNote || !resultId) return;
markDeliveryTurn({ caseId, resultId });
markDeliveryTurn({ caseId, resultId, narration });
}
export function appendPostAdoptProspectiveWindows(
@@ -1030,7 +1040,10 @@ export async function persistNextInterviewAfterChoice(input: {
if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) {
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
}
const birthDate = input.birthDate ?? null;
const decisionInputs = input.birthDate == null
? await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)
: decisionInputsFromSnapshot(input.birthDate);
const birthDate = decisionInputs.birthDate;
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
let decisionState = input.decisionState;
let decision = input.decision ?? decideAfterInferenceChange({
@@ -1152,7 +1165,7 @@ export async function persistNextInterviewAfterChoice(input: {
const narrated = input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback;
rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true);
rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true, narrated);
return {
hostNarration: narrated,
choiceReady: false,
@@ -1177,6 +1190,7 @@ export async function persistNextInterviewAfterChoice(input: {
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
birthDate,
};
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
@@ -1352,13 +1366,7 @@ export async function requestTieBreakPersonality(input: {
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 birthDate = (await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)).birthDate;
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
@@ -1549,13 +1557,8 @@ export async function applyCollectFocusDenial(
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;
}
const decisionInputs = await loadCaseDecisionInputs(accounting, input.userId, input.caseId);
const birthDate = decisionInputs.birthDate;
let latest: V9CaseDossier = dossier;
try {
latest = await loadV9CaseDossier(accounting, input.userId, input.caseId);
@@ -1572,7 +1575,7 @@ export async function applyCollectFocusDenial(
},
},
}, closeStatus);
const decision = decideFromDossier(withDeclined, { birthDate });
const decision = decideFromDossier(withDeclined, decisionInputs);
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
@@ -1764,11 +1767,11 @@ export async function persistNextInterviewIfIdle(input: {
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
}
if (hostNarration === result.hostNarration) {
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote);
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote, hostNarration);
return result;
}
const finished = { ...result, hostNarration };
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote);
rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote, hostNarration);
return finished;
};
const staleFocus = dossier.conversationSummary.activeFocus;
@@ -1818,17 +1821,9 @@ export async function persistNextInterviewIfIdle(input: {
}
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 decisionInputs = await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId);
const birthDate = decisionInputs.birthDate;
let decision = decideFromDossier(dossier, decisionInputs);
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: input.accounting,
userId: input.userId,
@@ -1850,10 +1845,7 @@ export async function persistNextInterviewIfIdle(input: {
}
: latest,
};
decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
decision = decideFromDossier(dossier, decisionInputsFromSnapshot(birthDate));
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
if (decision.heldForTieBreak === true) {
@@ -2028,6 +2020,7 @@ async function persistExhaustionCollect(input: {
decision: RectificationDecision;
decisionReceipt?: Readonly<Record<string, unknown>> | null;
askedTurnId?: string | null;
birthDate?: string | null;
}): Promise<{
persisted: boolean;
choiceReady: boolean;
@@ -2038,6 +2031,9 @@ async function persistExhaustionCollect(input: {
}> {
let dossier = input.dossier as DecisionDossier;
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
const birthDate = input.birthDate == null
? (await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)).birthDate
: decisionInputsFromSnapshot(input.birthDate).birthDate;
let decision = input.decision;
let catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
@@ -2147,6 +2143,7 @@ async function persistExhaustionCollect(input: {
dossier,
state: previousInferenceFromReceipt(receipt),
userStopped: false,
birthDate,
});
catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
@@ -2308,19 +2305,12 @@ async function persistApplied(
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 birthDate = (await loadCaseDecisionInputs(accounting, command.userId, command.caseId)).birthDate;
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);
@@ -2544,17 +2534,10 @@ async function inspectNonTerminalTurnExit(input: {
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 decision = decideFromDossier(
dossier,
await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId),
);
const satisfied = Boolean(
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|| dossier.case.acceptedTime
@@ -9,6 +9,7 @@ import {
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import { decisionInputsFromSnapshot } from "@/lib/rectification-agentic/v9/decision-inputs";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier, stepStateFromCaseDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns, recordedEvidenceByTurn } from "@/lib/rectification-agentic/v9/turn-question";
@@ -41,6 +42,7 @@ export function dossierResponse(
fullDetail?: boolean;
listed?: ConversationFocusList;
liveIdentity?: LiveEngineScoringIdentity;
birthDate?: string | null;
/**
* Hands the decision and card this projection already computed to the
* caller (anonymous telemetry), so nothing is decided twice.
@@ -49,9 +51,10 @@ export function dossierResponse(
} = {},
) {
const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null });
const decision = decideFromDossier(dossier, {
const decisionInputs = decisionInputsFromSnapshot(options.birthDate, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const decision = decideFromDossier(dossier, decisionInputs);
const listed = options.listed ?? { focuses: [] as const, available: true };
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
const fields = publicDecisionFields(decision);
@@ -124,9 +127,9 @@ export function dossierResponse(
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: identity.read_only || consistencySummary ? null : projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: identity.read_only || consistencySummary ? null : choiceCardFromCaseDossier(dossier),
step_state: stepStateFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
choice_card: identity.read_only || consistencySummary ? null : choiceCardFromCaseDossier(dossier, decisionInputs.birthDate),
step_state: stepStateFromCaseDossier(dossier, decisionInputs.birthDate),
next_user_action: nextUserActionFromDossier(dossier, decisionInputs.birthDate),
question_source: questionSourceFromFocusList(listed),
};
if (consistencySummary) {
@@ -0,0 +1,43 @@
/**
* One birth-snapshot input for every production decideFromDossier call.
* A missing or unreadable snapshot throws. Callers must not decide with a
* null birth date: that keeps an under-age probe askable and flips adoption.
*/
import { birthYearFromDate } from "./adult-floor.ts";
import type { DecideFromDossierOptions } from "./decision-from-dossier.ts";
import {
loadV9CaseCompute,
RectificationToolServiceError,
type AccountingClient,
} from "./tool-service.ts";
export function decisionInputsFromSnapshot(
birthDate: string | null | undefined,
extras: Omit<DecideFromDossierOptions, "birthDate"> = {},
): DecideFromDossierOptions & { readonly birthDate: string } {
if (birthYearFromDate(birthDate) === null) {
throw new RectificationToolServiceError("birth_snapshot_unavailable");
}
return { ...extras, birthDate: birthDate as string };
}
export async function loadCaseDecisionInputs(
accounting: AccountingClient,
userId: string,
caseId: string,
extras: Omit<DecideFromDossierOptions, "birthDate"> = {},
): Promise<DecideFromDossierOptions & { readonly birthDate: string }> {
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting, userId, caseId);
const raw = compute.baselineBirthSnapshot.birth_date;
birthDate = typeof raw === "string" ? raw : null;
} catch (error) {
if (error instanceof RectificationToolServiceError && error.code === "birth_snapshot_unavailable") {
throw error;
}
throw new RectificationToolServiceError("birth_snapshot_unavailable");
}
return decisionInputsFromSnapshot(birthDate, extras);
}
@@ -7,7 +7,7 @@
const WINDOW_MS = 60_000;
type DeliveryMark = Readonly<{ resultId: string; at: number }>;
type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>;
const delivered = new Map<string, DeliveryMark>();
@@ -35,14 +35,67 @@ export function markDeliveryTurn(input: {
caseId: string;
resultId: string;
now?: number;
narration?: string | null;
}): void {
if (!input.caseId || !input.resultId) return;
const now = input.now ?? Date.now();
const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined;
const existing = delivered.get(input.caseId);
if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) {
if (existing.narration) return;
delivered.set(input.caseId, {
resultId: existing.resultId,
at: narration ? existing.at : now,
...(narration ? { narration } : {}),
});
return;
}
delivered.set(input.caseId, {
resultId: input.resultId,
at: input.now ?? Date.now(),
at: now,
...(narration ? { narration } : {}),
});
}
/** The first delivery sentence stored for this result inside the 60s window. */
export function deliveryCarrierNarration(input: {
caseId: string;
resultId: string;
now?: number;
}): string | null {
if (!input.caseId || !input.resultId) return null;
const mark = delivered.get(input.caseId);
if (!mark || mark.resultId !== input.resultId || !mark.narration) return null;
if ((input.now ?? Date.now()) - mark.at > WINDOW_MS) return null;
return mark.narration;
}
/**
* True when the latest assistant text is the stored delivery sentence.
* Whitespace is ignored. A needle shorter than 8 characters does not match.
*/
export function deliveryCarrierMatchesLastAssistant(input: {
caseId: string;
resultId: string;
turns?: readonly { role?: string; text?: string | null }[];
now?: number;
}): boolean {
const carrier = deliveryCarrierNarration(input);
if (!carrier) return false;
const squash = (value: string) => value.replace(/\s+/g, "");
const needle = squash(carrier);
if (needle.length < 8) return false;
const turns = input.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 === needle;
}
return false;
}
export function alreadyDelivered(input: {
caseId: string;
resultId: string;
@@ -13,6 +13,7 @@ import {
decideFromDossier,
rectificationFollowupCatalog,
} from "./decision-from-dossier";
import { decisionInputsFromSnapshot } from "./decision-inputs.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction, blockingMethodsCovered } from "./method-followup";
import { projectRectificationStepState, type RectificationStepState } from "./step-state.ts";
@@ -72,7 +73,7 @@ export function choiceCardFromCaseDossier(dossier: {
widenDeclinedAtFingerprint?: string | null;
};
turns?: readonly Readonly<{ role: string; text: string | null }>[];
}): RectificationChoiceCard | null {
}, birthDate?: string | null): RectificationChoiceCard | null {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
@@ -83,9 +84,10 @@ export function choiceCardFromCaseDossier(dossier: {
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
const decision = decideFromDossier(dossier, typeof birthDate === "string"
? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
: { currentEvidenceFingerprint });
const latestAssistantText = [...(dossier.turns ?? [])]
.reverse()
.find((turn) => turn.role === "assistant")
@@ -131,11 +133,15 @@ export function choiceCardFromCaseDossier(dossier: {
});
}
export function stepStateFromCaseDossier(dossier: Parameters<typeof choiceCardFromCaseDossier>[0]): RectificationStepState {
export function stepStateFromCaseDossier(
dossier: Parameters<typeof choiceCardFromCaseDossier>[0],
birthDate?: string | null,
): RectificationStepState {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
const decision = decideFromDossier(dossier, typeof birthDate === "string"
? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
: { currentEvidenceFingerprint });
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
@@ -161,14 +167,18 @@ export function stepStateFromCaseDossier(dossier: Parameters<typeof choiceCardFr
});
}
export function nextUserActionFromDossier(dossier: Parameters<typeof choiceCardFromCaseDossier>[0]) {
export function nextUserActionFromDossier(
dossier: Parameters<typeof choiceCardFromCaseDossier>[0],
birthDate?: string | null,
) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
});
const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
const decision = decideFromDossier(dossier, typeof birthDate === "string"
? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
: { currentEvidenceFingerprint });
const accepted = Boolean(dossier.case.acceptedTime);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
@@ -6,6 +6,7 @@
*/
import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter";
import { decisionInputsFromSnapshot } from "./decision-inputs.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { collectionProgressFromReceipt } from "./evidence-model";
@@ -241,12 +242,14 @@ export function projectTurnDecision(
currentQuestion?: (CurrentQuestionProjection & Record<string, unknown>) | null;
followupHint?: string | null;
questionContract?: Readonly<Record<string, unknown>> | null;
birthDate?: string | null;
} = {},
): Record<string, unknown> {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence);
const decision = decideFromDossier(dossier, typeof extras.birthDate === "string"
? decisionInputsFromSnapshot(extras.birthDate, { currentEvidenceFingerprint })
: { currentEvidenceFingerprint });
const recentTurns = dossier.turns.slice(-TURN_DECISION_RECENT_TURNS).flatMap((turn) => {
const text = clipText(turn.text, TURN_DECISION_TURN_TEXT_LIMIT);
if (!text || (turn.status !== "completed" && !(turn.role === "user" && turn.status === "pending"))) {