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:
@@ -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"))) {
|
||||
|
||||
Reference in New Issue
Block a user