fix(rectification): narrate exhausted-probe adopt instead of keep-collecting
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user