fix(rectification): narrate exhausted-probe adopt instead of keep-collecting
Independent Staging Quality Gate / validate (push) Successful in 10m33s
Independent Staging Quality Gate / publish (push) Successful in 1m55s

When leftover probes cannot split adjacent minutes, skip frameless follow-ups and let a no-tool agent explain the stop. Distinguish-card "no" no longer closes a whole evidence domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 14:22:13 +08:00
parent cfa824994e
commit 0aaa0d702e
18 changed files with 1757 additions and 51 deletions
@@ -74,6 +74,8 @@ export type RangeNarrationInput = {
openingRange?: readonly [string, string] | null;
variant?: RangeNarrationVariant;
stopReason?: string | null;
/** Extra stop sentence after BUG-503 prefixes; used when the probe pool is exhausted. */
stopExplain?: string | null;
};
function clockMinutes(value: string): number | null {
@@ -137,6 +139,8 @@ export function nonConvergingRangeNarration(
? progressClause(input.openingRange, rangeText, range)
: null;
const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null;
const explain = variant === "delivery" ? (input.stopExplain?.trim() || null) : null;
const prefix = [reason, explain].filter(Boolean).join("");
if (variant === "intermediate") {
if (rangeText && representative) {
@@ -155,19 +159,19 @@ export function nonConvergingRangeNarration(
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}` : ``;
const body = `${head}${representativeClause}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
return prefix ? `${prefix}${body}` : body;
}
if (rangeText) {
const head = progress ?? `更站得住的范围是 ${rangeText}`;
const body = `${head}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
return prefix ? `${prefix}${body}` : body;
}
if (representative) {
const body = `当前代表分钟 ${representative}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
return prefix ? `${prefix}${body}` : body;
}
const body = `当前几个候选还分不开。${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
return prefix ? `${prefix}${body}` : body;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
@@ -0,0 +1,106 @@
/**
* Single-shot adopt-delivery narrator. No tools. It only writes copy from
* structured facts; validation fail-closes to the template.
*
* Billing matches the turn-intent classifier: this call is not reserved.
*/
import { Agent } from "@mastra/core/agent";
import type { ResolvedLanguageModel } from "@/mastra/model";
import {
appendAdoptCue,
shouldWriteAdoptNarration,
validateAdoptNarration,
type AdoptDeliveryFacts,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
export async function generateAdoptNarrationText(
model: ResolvedLanguageModel,
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
): Promise<string> {
const agent = new Agent({
id: `rectification-adopt-narration-${model.id}`,
name: "Rectification Adopt Narration",
model: model.model,
instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不得承诺“确认”或“精确”,不得再提问,不要写“可以从下面选一个先用着”。`,
});
const result = await agent.generate([{
role: "user",
content: JSON.stringify({
credible_range: facts.credible_range,
representative_minute: facts.representative_minute,
runner_up_minute: facts.runner_up_minute,
opening_window: facts.opening_window,
active_candidates: facts.active_candidates,
answered_rounds: facts.answered_rounds,
stop_facts: facts.stop_facts,
post_adopt_verification: facts.post_adopt_verification.map((item) => ({
kind: item.kind,
domain: item.domain,
year: item.year,
})),
}),
}], {
abortSignal: signal,
});
const text = typeof result.text === "string" ? result.text : "";
return text.trim();
}
export async function deliverAdoptNarration(input: {
facts: AdoptDeliveryFacts;
fallback: string;
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): Promise<string> {
if (!shouldWriteAdoptNarration(input.facts)) return input.fallback;
const generate = input.generateText ?? (input.model
? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => (
generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal)
)
: null);
if (!generate) return input.fallback;
try {
const text = await generate(input.facts, input.signal);
const checked = validateAdoptNarration(text, input.facts);
if (!checked.ok) return input.fallback;
return appendAdoptCue(checked.text);
} catch {
return input.fallback;
}
}
export function createAdoptNarrationWriter(input: {
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
resolveModel?: () => Promise<ResolvedLanguageModel | null>;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): AdoptNarrationWriter {
return async (facts, fallback) => deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
}
@@ -0,0 +1,295 @@
/**
* Structured facts and fail-closed checks for the adopt-delivery narration.
*
* The decision layer remains the only authority. This module only explains
* why the interview stopped, what range is on offer, and what will be
* checked after adopt. It never changes sessionOutcome or focus.
*/
import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import type { DroppedProbe } from "./probe-question-contract.ts";
export const ADOPT_NARRATION_MAX_CHARS = 240;
export type AdoptStopFactKind =
| "indistinguishable"
| "yearless_varga"
| "window_ends_only";
export type AdoptStopFact = Readonly<{
kind: AdoptStopFactKind;
label: string;
count: number;
}>;
export type AdoptCandidateSupport = Readonly<{
time: string;
relative_support: number;
}>;
export type AdoptPostAdoptCheck = Readonly<{
kind: "holdout" | "oos";
domain: string;
year: number | null;
}>;
export type AdoptDeliveryFacts = Readonly<{
precision_stage: string;
already_accepted: boolean;
credible_range: readonly [string, string] | null;
representative_minute: string | null;
runner_up_minute: string | null;
opening_window: readonly [string, string] | null;
active_candidates: readonly AdoptCandidateSupport[];
answered_rounds: number;
stop_facts: readonly AdoptStopFact[];
post_adopt_verification: readonly AdoptPostAdoptCheck[];
minutes: readonly string[];
years: readonly number[];
}>;
const CLOCK = /^(\d{1,2}):(\d{2})$/;
const FOUR_DIGIT_YEAR = /(?<!\d)((?:19|20)\d{2})(?!\d)/g;
const CLOCK_TOKEN = /\d{1,2}:\d{2}/g;
export function padClock(value: string | null | undefined): string | null {
const match = CLOCK.exec((value ?? "").trim());
if (!match) return null;
return `${match[1]!.padStart(2, "0")}:${match[2]}`;
}
function uniqueClocks(values: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>();
for (const value of values) {
const clock = padClock(value);
if (clock) seen.add(clock);
}
return [...seen];
}
function uniqueYears(values: readonly (number | null | undefined)[]): number[] {
return [...new Set(values.filter((item): item is number => (
typeof item === "number" && Number.isInteger(item) && item >= 1900 && item <= 2100
)))];
}
function activeCandidatesFromDossier(dossier: DecisionDossier): AdoptCandidateSupport[] {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const fromInference = (inference?.candidates ?? [])
.filter((item) => item.status === "active")
.map((item) => ({
time: padClock(item.time) ?? item.time,
relative_support: Math.round(item.posterior_score),
}))
.filter((item) => CLOCK.test(item.time))
.sort((left, right) => right.relative_support - left.relative_support);
if (fromInference.length > 0) return fromInference;
return (dossier.latestResult?.candidates ?? [])
.map((item) => ({
time: padClock(item.time) ?? item.time,
relative_support: Math.round(item.relativeSupport ?? item.posterior_score ?? 0),
}))
.filter((item) => CLOCK.test(item.time))
.sort((left, right) => right.relative_support - left.relative_support);
}
function stopFactsFromDropped(
dropped: readonly DroppedProbe[],
representative: string | null,
runnerUp: string | null,
): AdoptStopFact[] {
const facts: AdoptStopFact[] = [];
const noSplit = dropped.filter((item) => item.reason === "no_split_among_active").length;
const yearless = dropped.filter((item) => item.reason === "yearless_ungrounded_contrast").length;
const windowEnds = dropped.filter((item) => (
item.reason === "insufficient_candidates" || item.reason === "not_renderable"
)).length;
if (representative && runnerUp && representative !== runnerUp) {
facts.push({
kind: "indistinguishable",
label: `剩下的题分不开 ${representative}${runnerUp}`,
count: Math.max(1, noSplit),
});
} else if (noSplit > 0) {
facts.push({
kind: "indistinguishable",
label: "剩下的题分不开当前候选",
count: noSplit,
});
}
if (yearless > 0) {
facts.push({
kind: "yearless_varga",
label: "没有年份的分盘题不再问",
count: yearless,
});
}
if (windowEnds > 0) {
facts.push({
kind: "window_ends_only",
label: "婚恋题只覆盖窗口两端",
count: windowEnds,
});
}
return facts;
}
export function adoptDeliveryFacts(
decision: Pick<RectificationDecision, "precisionStage" | "credibleRange" | "representativeTime" | "droppedProbes">,
dossier: DecisionDossier,
): AdoptDeliveryFacts {
const receipt = dossier.latestResult?.decisionReceipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
const refinement = refinementFromDecisionReceipt(receipt);
const active = activeCandidatesFromDossier(dossier);
const representative = padClock(decision.representativeTime)
?? padClock(active[0]?.time)
?? null;
const runnerUp = active.find((item) => item.time !== representative)?.time ?? null;
const range = decision.credibleRange
? [
padClock(decision.credibleRange[0]) ?? decision.credibleRange[0],
padClock(decision.credibleRange[1]) ?? decision.credibleRange[1],
] as const
: null;
const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null);
const holdout = (inference?.events ?? [])
.filter((item) => item.usage === "holdout" && item.year !== null)
.map((item) => ({
kind: "holdout" as const,
domain: item.domain,
year: item.year,
}));
const oos = refinement.oos_blind_prompts.map((item) => ({
kind: "oos" as const,
domain: item.domain,
year: null,
}));
const verification = [...holdout, ...oos];
const years = uniqueYears([
...holdout.map((item) => item.year),
...(inference?.events ?? []).map((item) => item.year),
]);
const minutes = uniqueClocks([
representative,
runnerUp,
range?.[0],
range?.[1],
opening?.[0],
opening?.[1],
...active.map((item) => item.time),
]);
return {
precision_stage: decision.precisionStage,
already_accepted: Boolean(dossier.case.acceptedTime),
credible_range: range,
representative_minute: representative,
runner_up_minute: runnerUp,
opening_window: opening
? [
padClock(opening[0]) ?? opening[0],
padClock(opening[1]) ?? opening[1],
] as const
: null,
active_candidates: active,
answered_rounds: inference?.answered_probes.length ?? 0,
stop_facts: stopFactsFromDropped(decision.droppedProbes, representative, runnerUp),
post_adopt_verification: verification,
minutes,
years,
};
}
export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute) {
return `剩下的问题分不开 ${facts.representative_minute}${facts.runner_up_minute}`;
}
if (split) return `${split.label}`;
return null;
}
export function allowedMinuteSet(facts: AdoptDeliveryFacts): Set<string> {
const allowed = new Set<string>();
for (const minute of facts.minutes) {
allowed.add(minute);
const padded = padClock(minute);
if (padded) {
allowed.add(padded);
allowed.add(`${Number(padded.slice(0, 2))}:${padded.slice(3)}`);
}
}
return allowed;
}
export function validateAdoptNarration(
text: string,
facts: AdoptDeliveryFacts,
): { ok: true; text: string } | { ok: false; reason: string } {
const trimmed = text.trim().replace(/\s+/g, " ");
if (!trimmed) return { ok: false, reason: "empty" };
if (trimmed.length > ADOPT_NARRATION_MAX_CHARS) return { ok: false, reason: "length" };
const sentences = trimmed.split(/(?<=[。!?!?.])/).map((item) => item.trim()).filter(Boolean);
if (sentences.some((item) => /[?]$/.test(item))) {
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
const minutes = allowedMinuteSet(facts);
for (const token of trimmed.match(CLOCK_TOKEN) ?? []) {
const padded = padClock(token);
if (!padded || (!minutes.has(token) && !minutes.has(padded))) {
return { ok: false, reason: "unknown_minute" };
}
}
const years = new Set(facts.years);
for (const match of trimmed.matchAll(FOUR_DIGIT_YEAR)) {
const year = Number(match[1]);
if (!years.has(year)) return { ok: false, reason: "unknown_year" };
}
const allowedNumbers = allowedFactIntegers(facts);
const leftover = trimmed
.replace(CLOCK_TOKEN, " ")
.replace(FOUR_DIGIT_YEAR, " ");
for (const token of leftover.match(/\d+/g) ?? []) {
if (!allowedNumbers.has(Number(token))) {
return { ok: false, reason: "unknown_number" };
}
}
return { ok: true, text: trimmed };
}
export function allowedFactIntegers(facts: AdoptDeliveryFacts): Set<number> {
const allowed = new Set<number>();
for (const item of facts.active_candidates) allowed.add(item.relative_support);
allowed.add(facts.answered_rounds);
for (const item of facts.stop_facts) allowed.add(item.count);
for (const year of facts.years) allowed.add(year);
for (const minute of facts.minutes) {
const padded = padClock(minute);
if (!padded) continue;
allowed.add(Number(padded.slice(0, 2)));
allowed.add(Number(padded.slice(3)));
}
return allowed;
}
export function appendAdoptCue(text: string): string {
const trimmed = text.trim();
if (!trimmed) return RECTIFICATION_USER_COPY.adoptCue;
if (trimmed.includes(RECTIFICATION_USER_COPY.adoptCue)) return trimmed;
return `${trimmed} ${RECTIFICATION_USER_COPY.adoptCue}`;
}
export type AdoptNarrationWriter = (
facts: AdoptDeliveryFacts,
fallback: string,
) => Promise<string>;
@@ -47,7 +47,12 @@ import {
type V9CaseDossier,
} from "./tool-service";
import { isPersistedFocusId, type ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn } from "./server-focus";
import {
adoptDeliveryFacts,
templateStopExplain,
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -84,11 +89,17 @@ function openingRangeFromDossier(dossier: {
}
function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null {
return plan.next_followup ?? plan.deferred_followup ?? null;
if (plan.next_followup) return plan.next_followup;
const deferred = plan.deferred_followup;
if (deferred?.intent === "distinguish_candidates" && !deferred.choice_frame) {
return null;
}
return deferred ?? null;
}
function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean {
if (!followup) return false;
if (followup.intent === "distinguish_candidates" && !followup.choice_frame) return false;
return followup.source === "event_probe"
|| followup.source === "varga_observation"
|| followup.source === "precision_stage"
@@ -116,16 +127,17 @@ function shouldSkipFollowupPersist(input: {
}
function adoptHostNarration(input: {
credibleRange?: readonly [string, string] | null;
representativeTime?: string | null;
openingRange: readonly [string, string] | null;
dossier: Parameters<typeof decideFromDossier>[0];
decision: ReturnType<typeof decideFromDossier>;
receipt: Readonly<Record<string, unknown>> | null | undefined;
}): string {
const facts = adoptDeliveryFacts(input.decision, input.dossier);
return withProspectiveWindows(deliveryAdoptNarration({
credibleRange: input.credibleRange,
representativeTime: input.representativeTime,
openingRange: input.openingRange,
stopReason: typeof input.receipt?.stopReason === "string" ? input.receipt.stopReason : null,
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: input.decision.stopReason ?? null,
stopExplain: templateStopExplain(facts),
}), input.receipt);
}
@@ -142,6 +154,7 @@ export type ApplyChoiceCommand = Readonly<{
expectedRevision: number;
userDisplay?: string | null;
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
}>;
export type AppliedChoiceReceipt = Readonly<{
@@ -170,16 +183,22 @@ function asText(value: unknown): string | null {
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null } | null;
activeFocus: { targetDomain?: string | null; intent?: string | null } | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
}>(dossier: T, status: "resolved" | "declined" | "skipped"): T {
const domain = dossier.conversationSummary.activeFocus?.targetDomain ?? null;
const focus = dossier.conversationSummary.activeFocus;
const domain = focus?.targetDomain ?? null;
const intent = focus?.intent ?? null;
const declinedSkippedTopics = (
(status === "declined" || status === "skipped") && domain
? [
...dossier.conversationSummary.declinedSkippedTopics,
{ target_domain: domain, status },
{
target_domain: domain,
status,
...(intent ? { intent } : {}),
},
]
: dossier.conversationSummary.declinedSkippedTopics
);
@@ -373,6 +392,7 @@ export async function persistNextInterviewAfterChoice(input: {
nextAction: ReturnType<typeof publicNextAction>;
birthDate?: string | null;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -415,13 +435,17 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
methods: plan.methods,
})) {
const decision = decideFromDossier(input.dossier, { birthDate });
const facts = adoptDeliveryFacts(decision, input.dossier);
const fallback = adoptHostNarration({
dossier: input.dossier,
decision,
receipt: input.dossier.latestResult?.decisionReceipt,
});
return {
hostNarration: adoptHostNarration({
credibleRange: input.nextAction.credible_range,
representativeTime: input.nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
receipt: input.dossier.latestResult?.decisionReceipt,
}),
hostNarration: input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback,
choiceReady: false,
persisted: false,
};
@@ -564,6 +588,9 @@ async function persistFocusAfterChoice(input: {
if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) {
return persisted;
}
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
return persisted;
}
try {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
return await persistServerOwnedFocus({
@@ -585,7 +612,13 @@ async function persistFocusAfterChoice(input: {
export async function applyCollectFocusDenial(
accounting: AccountingClient,
input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean },
input: {
userId: string;
caseId: string;
focusId: string;
deferFollowup?: boolean;
narrateAdopt?: AdoptNarrationWriter;
},
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
@@ -636,10 +669,11 @@ export async function applyCollectFocusDenial(
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
nextAction,
birthDate,
narrateAdopt: input.narrateAdopt,
});
return {
narration: nextInterview.hostNarration,
nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
nextInterviewPersisted: nextInterview.persisted === true || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
};
}
@@ -658,6 +692,7 @@ export async function persistNextInterviewIfIdle(input: {
userId: string;
caseId: string;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const staleFocus = dossier.conversationSummary.activeFocus;
@@ -722,15 +757,18 @@ export async function persistNextInterviewIfIdle(input: {
followup,
methods: plan.methods,
})) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
dossier,
decision,
receipt: dossier.latestResult?.decisionReceipt,
});
return {
persisted: false,
choiceReady: false,
hostNarration: adoptHostNarration({
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
receipt: dossier.latestResult?.decisionReceipt,
}),
hostNarration: input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback,
};
}
if (isNonConvergingRangeOffer(decision)) {
@@ -757,6 +795,7 @@ export async function persistNextInterviewIfIdle(input: {
nextAction,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
});
return {
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
@@ -866,12 +905,13 @@ async function persistApplied(
} catch {
// Ranking stays fail-open when compute is unavailable.
}
const nextAction = publicNextAction(decideAfterInferenceChange({
const nextDecision = decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
birthDate,
}));
});
const nextAction = publicNextAction(nextDecision);
let narrationPersisted = false;
let nextInterviewPersisted = false;
@@ -892,6 +932,7 @@ async function persistApplied(
decisionState: input.decisionState ?? null,
nextAction,
birthDate,
narrateAdopt: command.narrateAdopt,
});
nextChoiceReady = nextInterview.choiceReady;
skippedNextInterview = nextInterview.persisted === false;
@@ -905,12 +946,16 @@ ${nextInterview.hostNarration}`;
nextInterviewPersisted = true;
}
}
const adoptionFacts = nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, input.dossier)
: null;
const adoptionNarration = nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
}), input.dossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
@@ -926,7 +971,7 @@ ${nonConvergingRangeNarration({
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion) {
if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion && !skippedNextInterview) {
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
}
@@ -268,15 +268,25 @@ export function rectificationFollowupCatalog(
const receipt = latest?.decisionReceipt ?? null;
const refinement = refinementFromDecisionReceipt(receipt);
const inference = previousInferenceFromReceipt(receipt);
const askedKeys = askedDiscriminatorKeys(receipt, evidence);
const topCandidateTimes = discriminatorCandidateTimes(latest ?? null);
const nakshatraRaw = inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null;
const nakshatraInspected = nakshatraRaw
? inspectDiscriminatorProbes(nakshatraContrastPacket(nakshatraRaw, inference?.candidate_set_id ?? ""), {
askedKeys,
mentionedKeys: mentionedVargaKeysFromLedgerEvidence(evidence),
topCandidateTimes,
})
: null;
return {
contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence),
topCandidateTimes: discriminatorCandidateTimes(latest ?? null),
askedProbeKeys: askedDiscriminatorKeys(receipt, evidence),
topCandidateTimes,
askedProbeKeys: askedKeys,
eventProbes: refinement.discriminating_event_probes,
eventClarificationProbes: refinement.event_clarification_probes,
evidenceCollectionProbes: refinement.evidence_collection_probes,
precisionStage: refinement.precision_stage?.current ?? null,
nakshatraProbe: inference?.probes.find((probe) => probe.source === "nakshatra_boundary") ?? null,
nakshatraProbe: nakshatraInspected?.selected ? nakshatraRaw : null,
oosBlindPrompts: refinement.oos_blind_prompts,
holdoutEvents: (inference?.events ?? [])
.filter((item) => item.usage === "holdout")
@@ -369,12 +369,21 @@ function isOpeningOtherCollectTopic(topic: Readonly<Record<string, unknown>>): b
return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:");
}
function topicIntent(topic: Readonly<Record<string, unknown>>): string {
return typeof topic.intent === "string" ? topic.intent : "";
}
function declinedDomains(
topics: readonly Readonly<Record<string, unknown>>[],
): Set<string> {
const domains = new Set<string>();
for (const topic of topics) {
if (isOpeningOtherCollectTopic(topic)) continue;
const intent = topicIntent(topic);
// Distinguish-card "no" is an answer, not a domain refusal (BUG-520).
// Missing intent stays collect for legacy declined_skipped rows.
if (intent === "distinguish_candidates") continue;
if (intent && intent !== "collect_method_evidence") continue;
const domain = topicDomain(topic);
if (domain) domains.add(domain);
}
@@ -1710,6 +1719,7 @@ export function buildMethodFollowupPlan(input: {
}
let next: MethodFollowup | null = null;
const extraDropped: DroppedProbe[] = [];
const stage = input.precisionStage ?? null;
const followupFromRanked = (ranked: RankedDiscriminator): MethodFollowup => {
if (ranked.kind === "event" && ranked.eventProbe) {
@@ -1834,7 +1844,7 @@ export function buildMethodFollowupPlan(input: {
&& input.nakshatraProbe
) {
const probe = input.nakshatraProbe;
next = makeFollowup({
const nakshatraFollowup = makeFollowup({
method_id: "nakshatra_boundary",
intent: "distinguish_candidates",
ask_theme: "nakshatra_trait",
@@ -1852,6 +1862,15 @@ export function buildMethodFollowupPlan(input: {
style_options: probe.style_options,
probe_id: probe.id,
}, true, true);
if (nakshatraFollowup.choice_frame) {
next = nakshatraFollowup;
} else {
extraDropped.push({
semantic_key: probe.semantic_key,
information_gain: probe.information_gain,
reason: "not_renderable",
});
}
}
const sameDomainYearlessCard = (domain: string): MethodFollowup | null => {
const ranked = yearlessDiscriminators.find((row) => (
@@ -2146,23 +2165,41 @@ export function buildMethodFollowupPlan(input: {
}
}
if (next?.intent === "distinguish_candidates" && !next.choice_frame && next.source === "nakshatra_boundary") {
if (next.semantic_key) {
extraDropped.push({
semantic_key: next.semantic_key,
information_gain: next.information_gain ?? 0,
reason: "not_renderable",
});
}
next = null;
}
const deferAdoption = sessionOutcome === "adopt_representative"
|| sessionOutcome === "validated_range"
|| sessionOutcome === "exact_minute_confirmed"
|| sessionOutcome === "provisional_range_user_stopped"
|| sessionOutcome === "completed_with_range";
const deferProvisionalDiscriminator = sessionOutcome === "provisional_range"
&& next?.intent === "distinguish_candidates";
&& next?.intent === "distinguish_candidates"
&& Boolean(next.choice_frame);
const deferFollowup = deferAdoption || deferProvisionalDiscriminator;
const deferred = deferFollowup ? next : null;
return {
methods,
next_followup: deferFollowup ? null : next,
deferred_followup: deferFollowup ? next : null,
// Adopt may stash a collect for later. Frameless distinguish must not
// sit in deferred_followup or it bypasses the adopt early-exit (BUG-519).
deferred_followup: (
deferred?.intent === "distinguish_candidates" && !deferred.choice_frame
? null
: deferred
),
session_outcome: sessionOutcome ?? "collect_evidence",
stop_domain_rotation: true,
do_not_poll: DO_NOT_POLL,
not_in_rotation: NOT_IN_ROTATION,
dropped_probes: rankedCatalog.dropped,
dropped_probes: [...rankedCatalog.dropped, ...extraDropped],
};
}
@@ -199,7 +199,14 @@ export function persistableFocusDomain(domain: string | null | undefined): strin
if (domain === "health_pressure") return "health";
if (domain === "occupation") return "other";
if (PERSISTABLE_FOCUS_DOMAINS.has(domain)) return domain;
return domain;
return null;
}
export function followupHasPersistableDomain(followup: Pick<MethodFollowup, "domain">): boolean {
return Boolean(
persistableFocusDomain(followup.domain)
?? persistableFocusDomain(collectQuestionDomain(followup.domain)),
);
}
function isFocusIdempotencyConflict(error: unknown): boolean {
@@ -283,6 +290,14 @@ async function persistCollectFocus(input: {
prompt: null,
};
}
if (!followupHasPersistableDomain(input.followup)) {
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
const questionId = stableFollowupQuestionId(input.followup);
const prompt = typeof schema.prompt === "string" ? schema.prompt : null;
const active = input.activeFocus;
@@ -308,7 +323,7 @@ async function persistCollectFocus(input: {
targetEvidenceId: null,
targetDomain: persistableFocusDomain(input.followup.domain)
?? (input.followup.intent === "collect_method_evidence"
? collectQuestionDomain(input.followup.domain)
? persistableFocusDomain(collectQuestionDomain(input.followup.domain))
: null),
targetKind: null,
expectedAnswerSchema: schema,
@@ -370,6 +385,14 @@ async function persistSpokenChoiceFallback(input: {
followup: MethodFollowup;
askedTurnId?: string | null;
}): Promise<PersistServerFocusResult> {
if (!followupHasPersistableDomain(input.followup)) {
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
return persistCollectFocus({
accounting: input.accounting,
userId: input.userId,