fix(rectification): restore targeted collect cards from spoken focus (BUG-673)
Keep targeted existence questions as A-D cards. Recover collect-schema stock by question-id prefix, surface the stem when persist fails, and send spoken targeted existence to the repair exit instead of a naked prompt.
This commit is contained in:
@@ -1551,6 +1551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const deadChoice = interviewChoiceCardUnavailable({
|
||||
questionKind: currentQuestion?.kind,
|
||||
hasChoiceCard: Boolean(choiceCard),
|
||||
questionId: currentQuestion?.question_id,
|
||||
});
|
||||
const liveQuestionOnMessages = Boolean(
|
||||
latestSettledAssistant
|
||||
|
||||
@@ -1173,6 +1173,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
return {
|
||||
hostNarration: spoken,
|
||||
choiceReady: false,
|
||||
persisted: false,
|
||||
followup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -640,6 +640,32 @@ export function isTargetedCollectFollowup(followup: {
|
||||
|| hint.startsWith("targeted:");
|
||||
}
|
||||
|
||||
export function isTargetedCollectExistenceFollowup(followup: {
|
||||
collection_key?: string;
|
||||
kind_hint?: string | null;
|
||||
} | null | undefined): boolean {
|
||||
if (!followup) return false;
|
||||
const ref = parseTargetedCollectQuestionId(followup.collection_key)
|
||||
?? parseTargetedCollectKind(followup.kind_hint);
|
||||
return ref?.stage === "existence";
|
||||
}
|
||||
|
||||
export function isTargetedCollectExistenceFocus(focus: {
|
||||
questionId?: string | null;
|
||||
targetKind?: string | null;
|
||||
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
|
||||
} | null | undefined): boolean {
|
||||
if (!focus) return false;
|
||||
const schema = focus.expectedAnswerSchema;
|
||||
const schemaKind = schema && typeof schema.collect_kind === "string"
|
||||
? schema.collect_kind
|
||||
: null;
|
||||
const ref = parseTargetedCollectQuestionId(focus.questionId)
|
||||
?? parseTargetedCollectKind(focus.targetKind)
|
||||
?? parseTargetedCollectKind(schemaKind);
|
||||
return ref?.stage === "existence";
|
||||
}
|
||||
|
||||
function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
|
||||
const declined = new Set<CollectKind>();
|
||||
for (const topic of topics) {
|
||||
|
||||
@@ -102,6 +102,10 @@ import {
|
||||
targetedCollectPool,
|
||||
targetedCollectQuestionId,
|
||||
TARGETED_YEAR_PROMPT,
|
||||
isTargetedCollectExistenceFollowup,
|
||||
isTargetedCollectExistenceFocus,
|
||||
parseTargetedCollectQuestionId,
|
||||
parseTargetedCollectKind,
|
||||
type CollectKind,
|
||||
type CollectionPoolItem,
|
||||
} from "./collection-question-pool.ts";
|
||||
@@ -655,13 +659,56 @@ function targetedCollectDomainFromFocus(focus: {
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function targetedCollectMethodId(domain: string | null): MethodFollowup["method_id"] {
|
||||
export function targetedCollectMethodId(domain: string | null): MethodFollowup["method_id"] {
|
||||
if (domain && domain in PROBE_METHOD_ID) {
|
||||
return PROBE_METHOD_ID[domain as keyof typeof PROBE_METHOD_ID];
|
||||
}
|
||||
return "dasha_events";
|
||||
}
|
||||
|
||||
export function rebuildTargetedCollectExistenceFrame(input: {
|
||||
questionId?: string | null;
|
||||
domain?: string | null;
|
||||
kindHint?: string | null;
|
||||
prompt?: string | null;
|
||||
}): RectificationChoiceFrame | null {
|
||||
const ref = parseTargetedCollectQuestionId(input.questionId)
|
||||
?? parseTargetedCollectKind(input.kindHint);
|
||||
if (ref?.stage !== "existence") return null;
|
||||
const questionId = (input.questionId ?? "").trim() || targetedCollectQuestionId(ref.domain);
|
||||
const prompt = (input.prompt ?? "").trim();
|
||||
if (!questionId || !prompt) return null;
|
||||
return buildTargetedCollectExistenceFrame({
|
||||
questionId,
|
||||
methodId: targetedCollectMethodId(input.domain ?? ref.domain),
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
|
||||
export function rebuildTargetedCollectExistenceFollowup(
|
||||
followup: MethodFollowup,
|
||||
): MethodFollowup | null {
|
||||
if (!isTargetedCollectExistenceFollowup(followup)) return null;
|
||||
const prompt = followup.spoken_prompt?.trim()
|
||||
|| followup.choice_frame?.prompt?.trim()
|
||||
|| followup.user_prompt_hint?.trim()
|
||||
|| "";
|
||||
const frame = rebuildTargetedCollectExistenceFrame({
|
||||
questionId: followup.collection_key ?? followup.choice_frame?.question_id,
|
||||
domain: followup.domain,
|
||||
kindHint: followup.kind_hint,
|
||||
prompt,
|
||||
});
|
||||
if (!frame) return null;
|
||||
return {
|
||||
...followup,
|
||||
choice_frame: frame,
|
||||
choice_kind: "existence",
|
||||
collection_key: followup.collection_key ?? frame.question_id,
|
||||
spoken_prompt: followup.spoken_prompt ?? frame.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
function contrastFollowupDomain(
|
||||
domain: string | null,
|
||||
): keyof typeof REVERSE_VERIFY_THEME {
|
||||
@@ -2231,15 +2278,23 @@ export function buildMethodFollowupPlan(input: {
|
||||
const targetedCopy = isTargetedCollectChoiceSchema(focus.expectedAnswerSchema)
|
||||
? parseAgentChoiceCopy(focus.expectedAnswerSchema)
|
||||
: null;
|
||||
if (focus.intent === "collect_method_evidence" && targetedCopy) {
|
||||
const spokenTargetedPrompt = !targetedCopy
|
||||
&& focus.intent === "collect_method_evidence"
|
||||
&& isTargetedCollectExistenceFocus(focus)
|
||||
&& focus.expectedAnswerSchema?.collect === true
|
||||
&& typeof focus.expectedAnswerSchema.prompt === "string"
|
||||
? focus.expectedAnswerSchema.prompt.trim()
|
||||
: "";
|
||||
if (focus.intent === "collect_method_evidence" && (targetedCopy || spokenTargetedPrompt)) {
|
||||
const questionId = focus.questionId?.trim() ?? "";
|
||||
const domain = targetedCollectDomainFromFocus(focus);
|
||||
const methodId = targetedCollectMethodId(domain);
|
||||
const prompt = targetedCopy?.prompt ?? spokenTargetedPrompt;
|
||||
const frame = questionId
|
||||
? buildTargetedCollectExistenceFrame({
|
||||
questionId,
|
||||
methodId,
|
||||
prompt: targetedCopy.prompt,
|
||||
prompt,
|
||||
})
|
||||
: null;
|
||||
if (frame) {
|
||||
@@ -2259,7 +2314,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
choice_kind: "existence",
|
||||
source: "active_focus",
|
||||
collection_key: questionId,
|
||||
spoken_prompt: targetedCopy.prompt,
|
||||
spoken_prompt: prompt,
|
||||
invite_more_once: false,
|
||||
},
|
||||
deferred_followup: null,
|
||||
@@ -3038,19 +3093,48 @@ export function projectRectificationChoiceCard(
|
||||
// from the persisted copy even when the plan recast has no frame, and even
|
||||
// when persist added :next to the question id.
|
||||
const persistedTargetedCard = (): RectificationChoiceCard | null => {
|
||||
if (!isTargetedCollectChoiceSchema(schema) || !schemaCopy) return null;
|
||||
const questionId = input.activeFocus?.questionId?.trim() ?? "";
|
||||
return choiceCardFromPersistedTargetedCopy({
|
||||
copy: schemaCopy,
|
||||
questionId,
|
||||
methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({
|
||||
if (isTargetedCollectChoiceSchema(schema) && schemaCopy) {
|
||||
return choiceCardFromPersistedTargetedCopy({
|
||||
copy: schemaCopy,
|
||||
questionId,
|
||||
targetDomain: input.activeFocus?.targetDomain,
|
||||
})),
|
||||
probeId: schemaProbeId,
|
||||
caseRevision: input.caseRevision ?? null,
|
||||
focusId,
|
||||
});
|
||||
methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({
|
||||
questionId,
|
||||
targetDomain: input.activeFocus?.targetDomain,
|
||||
})),
|
||||
probeId: schemaProbeId,
|
||||
caseRevision: input.caseRevision ?? null,
|
||||
focusId,
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.activeFocus?.intent === "collect_method_evidence"
|
||||
&& isTargetedCollectExistenceFocus(input.activeFocus)
|
||||
&& schema
|
||||
&& schema.collect === true
|
||||
&& typeof schema.prompt === "string"
|
||||
) {
|
||||
const frame = rebuildTargetedCollectExistenceFrame({
|
||||
questionId,
|
||||
domain: input.activeFocus.targetDomain,
|
||||
kindHint: collectKindFromFocus(input.activeFocus),
|
||||
prompt: schema.prompt,
|
||||
});
|
||||
const copy = frame ? serverOwnedChoiceCopy(frame) : null;
|
||||
if (!copy) return null;
|
||||
return choiceCardFromPersistedTargetedCopy({
|
||||
copy,
|
||||
questionId,
|
||||
methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({
|
||||
questionId,
|
||||
targetDomain: input.activeFocus.targetDomain,
|
||||
})),
|
||||
probeId: schemaProbeId,
|
||||
caseRevision: input.caseRevision ?? null,
|
||||
focusId,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const targetedCard = persistedTargetedCard();
|
||||
if (targetedCard) return targetedCard;
|
||||
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
previousInferenceFromReceipt,
|
||||
withNakshatraBoundaryProbe,
|
||||
} from "./inference-adapter";
|
||||
import { spokenFollowupForUser, spokenCollectFallbackFollowup, collectQuestionDomain, type MethodFollowup } from "./method-followup";
|
||||
import {
|
||||
spokenFollowupForUser,
|
||||
spokenCollectFallbackFollowup,
|
||||
collectQuestionDomain,
|
||||
rebuildTargetedCollectExistenceFollowup,
|
||||
type MethodFollowup,
|
||||
} from "./method-followup";
|
||||
import { isTargetedCollectExistenceFollowup } from "./collection-question-pool";
|
||||
import { USER_COLLECT_QUESTION } from "../user-copy";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import {
|
||||
@@ -338,18 +345,23 @@ export function serverOwnedExpectedAnswerSchema(
|
||||
followup: MethodFollowup,
|
||||
decisionReceipt?: Readonly<Record<string, unknown>> | null,
|
||||
): Record<string, unknown> | null {
|
||||
const frame = followup.choice_frame;
|
||||
const targeted = isTargetedCollectExistenceFollowup(followup)
|
||||
? rebuildTargetedCollectExistenceFollowup(followup) ?? followup
|
||||
: followup;
|
||||
const frame = targeted.choice_frame;
|
||||
if (frame) {
|
||||
const schema = expectedAnswerSchemaFor(
|
||||
frame,
|
||||
stableFollowupQuestionId(followup),
|
||||
stableFollowupQuestionId(targeted),
|
||||
decisionReceipt,
|
||||
followup,
|
||||
targeted,
|
||||
);
|
||||
if (schema?.choice) return schema;
|
||||
if (isTargetedCollectExistenceFollowup(targeted)) return null;
|
||||
if (followup.intent !== "collect_method_evidence") return null;
|
||||
return collectFocusSchema(spokenCollectFallbackFollowup(followup));
|
||||
}
|
||||
if (isTargetedCollectExistenceFollowup(followup)) return null;
|
||||
return collectFocusSchema(followup);
|
||||
}
|
||||
|
||||
@@ -519,7 +531,11 @@ async function persistServerOwnedFocusCore(input: {
|
||||
followup: MethodFollowup | null;
|
||||
askedTurnId?: string | null;
|
||||
}): Promise<PersistServerFocusResult> {
|
||||
const followup = input.followup;
|
||||
const followup = input.followup
|
||||
? (isTargetedCollectExistenceFollowup(input.followup)
|
||||
? rebuildTargetedCollectExistenceFollowup(input.followup) ?? input.followup
|
||||
: input.followup)
|
||||
: null;
|
||||
const frame = followup?.choice_frame ?? null;
|
||||
if (!followup) {
|
||||
return {
|
||||
@@ -529,6 +545,14 @@ async function persistServerOwnedFocusCore(input: {
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
if (isTargetedCollectExistenceFollowup(followup) && !frame) {
|
||||
return {
|
||||
status: "skipped",
|
||||
focus: input.activeFocus,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
if (!frame) {
|
||||
if (followup.intent === "distinguish_candidates") {
|
||||
return {
|
||||
@@ -648,24 +672,45 @@ export async function persistSkippedCollectFocus(input: {
|
||||
caseId: string;
|
||||
followup: MethodFollowup;
|
||||
}): Promise<ConversationFocus | null> {
|
||||
const schema = collectFocusSchema(input.followup);
|
||||
if (!schema) return null;
|
||||
const questionId = stableFollowupQuestionId(input.followup);
|
||||
const targeted = isTargetedCollectExistenceFollowup(input.followup)
|
||||
? rebuildTargetedCollectExistenceFollowup(input.followup)
|
||||
: null;
|
||||
const followup = targeted ?? input.followup;
|
||||
const schema = targeted
|
||||
? serverOwnedExpectedAnswerSchema(targeted, null)
|
||||
: collectFocusSchema(followup);
|
||||
if (!schema || (targeted && !schema.choice)) return null;
|
||||
const questionId = stableFollowupQuestionId(followup);
|
||||
try {
|
||||
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
questionId,
|
||||
intent: input.followup.intent,
|
||||
targetEvidenceId: input.followup.date_reliability_evidence_id ?? null,
|
||||
targetDomain: persistableFocusDomain(input.followup.domain)
|
||||
?? persistableFocusDomain(collectQuestionDomain(input.followup.domain)),
|
||||
targetKind: collectFocusTargetKind(input.followup),
|
||||
intent: followup.intent,
|
||||
targetEvidenceId: followup.date_reliability_evidence_id ?? null,
|
||||
targetDomain: persistableFocusDomain(followup.domain)
|
||||
?? persistableFocusDomain(collectQuestionDomain(followup.domain)),
|
||||
targetKind: collectFocusTargetKind(followup),
|
||||
expectedAnswerSchema: schema,
|
||||
});
|
||||
if (!result.focus.id) return result.focus;
|
||||
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
focusId: result.focus.id,
|
||||
status: "skipped",
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
focusId: result.focus.id,
|
||||
status: "skipped",
|
||||
});
|
||||
return { ...result.focus, status: "skipped" };
|
||||
} catch (error) {
|
||||
if (attempt === 1) {
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_skipped_collect_focus_resolve_failed",
|
||||
case_id: input.caseId,
|
||||
question_id: questionId,
|
||||
reason: safeToolErrorCode(error),
|
||||
}));
|
||||
return { ...result.focus, status: "skipped" };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...result.focus, status: "skipped" };
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -100,6 +100,9 @@ export function withSpokenPrompt(
|
||||
schema: Readonly<Record<string, unknown>>,
|
||||
spokenPrompt: string,
|
||||
): Record<string, unknown> {
|
||||
if (schema.targeted_collect === true) {
|
||||
return { ...schema, spoken_prompt: spokenPrompt };
|
||||
}
|
||||
const next: Record<string, unknown> = { ...schema, prompt: spokenPrompt };
|
||||
const choice = schema.choice;
|
||||
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
||||
|
||||
@@ -12,8 +12,10 @@ import { collectionProgressFromReceipt } from "./evidence-model";
|
||||
import type { V9CaseDossier } from "./tool-service";
|
||||
import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
|
||||
import { parseAgentChoiceCopy } from "./choice-card";
|
||||
import { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card";
|
||||
import { isCollectFocusSchema } from "./server-focus";
|
||||
import { isTargetedCollectExistenceFocus } from "./collection-question-pool";
|
||||
import { rebuildTargetedCollectExistenceFrame } from "./method-followup";
|
||||
|
||||
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
|
||||
export const TURN_DECISION_RECENT_TURNS = 6;
|
||||
@@ -95,6 +97,31 @@ export function projectCurrentQuestion(
|
||||
};
|
||||
}
|
||||
const collectPrompt = spoken || "";
|
||||
if (
|
||||
focus.intent === "collect_method_evidence"
|
||||
&& isTargetedCollectExistenceFocus(focus)
|
||||
&& isCollectFocusSchema(schema)
|
||||
&& collectPrompt
|
||||
) {
|
||||
const frame = rebuildTargetedCollectExistenceFrame({
|
||||
questionId: focus.questionId,
|
||||
domain: focus.targetDomain,
|
||||
kindHint: typeof schema?.collect_kind === "string" ? schema.collect_kind : null,
|
||||
prompt: collectPrompt,
|
||||
});
|
||||
const rebuilt = frame ? serverOwnedChoiceCopy(frame) : null;
|
||||
if (rebuilt) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
focus_id: focus.id ?? null,
|
||||
probe_id: probeId,
|
||||
prompt: rebuilt.prompt,
|
||||
kind: "choice",
|
||||
intent: focus.intent,
|
||||
domain: focus.targetDomain ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isCollectFocusSchema(schema) && collectPrompt) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
|
||||
@@ -350,10 +350,22 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn
|
||||
export function interviewChoiceCardUnavailable(input: Readonly<{
|
||||
questionKind?: string | null;
|
||||
hasChoiceCard: boolean;
|
||||
questionId?: string | null;
|
||||
}>): boolean {
|
||||
// A choice question without a GET card is a dead tap target, not a
|
||||
// collect-wait. The repair-exit path must take over.
|
||||
return input.questionKind === "choice" && !input.hasChoiceCard;
|
||||
if (input.questionKind === "choice" && !input.hasChoiceCard) return true;
|
||||
// Spoken targeted existence is the same dead end: the stem is a card
|
||||
// question stored as collect_spoken. Year-stage targeted questions stay spoken.
|
||||
const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, "");
|
||||
if (
|
||||
input.questionKind === "collect_spoken"
|
||||
&& !input.hasChoiceCard
|
||||
&& /^collect:targeted:[a-z_]+$/.test(questionId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function interviewCollectWaiting(input: Readonly<{
|
||||
|
||||
@@ -77,8 +77,10 @@ import {
|
||||
buildNextUserAction,
|
||||
spokenCollectFallbackFollowup,
|
||||
collectQuestionDomain,
|
||||
rebuildTargetedCollectExistenceFollowup,
|
||||
tieBreakPersonalityAvailable,
|
||||
} from "@/lib/rectification-agentic/v9/method-followup";
|
||||
import { isTargetedCollectExistenceFollowup } from "@/lib/rectification-agentic/v9/collection-question-pool";
|
||||
import { dashaAgreementAmongActive, refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
|
||||
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
|
||||
import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label";
|
||||
@@ -1300,13 +1302,39 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
&& persistFollowup.choice_frame
|
||||
&& !expectedAnswerSchema?.choice
|
||||
) {
|
||||
persistFollowup = spokenCollectFallbackFollowup(persistFollowup);
|
||||
expectedAnswerSchema = collectFocusSchema(persistFollowup);
|
||||
if (isTargetedCollectExistenceFollowup(persistFollowup)) {
|
||||
const rebuilt = rebuildTargetedCollectExistenceFollowup(persistFollowup);
|
||||
if (rebuilt) {
|
||||
persistFollowup = rebuilt;
|
||||
expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt);
|
||||
}
|
||||
if (!expectedAnswerSchema?.choice) {
|
||||
await receipt("rectification-set-focus", "intent.classified", "failed", {
|
||||
inputFingerprint,
|
||||
safeErrorCode: "invalid_choice_schema",
|
||||
});
|
||||
return { ok: false, error: "invalid_choice_schema" };
|
||||
}
|
||||
} else {
|
||||
persistFollowup = spokenCollectFallbackFollowup(persistFollowup);
|
||||
expectedAnswerSchema = collectFocusSchema(persistFollowup);
|
||||
}
|
||||
}
|
||||
if (!expectedAnswerSchema) {
|
||||
if (isTargetedCollectExistenceFollowup(persistFollowup)) {
|
||||
await receipt("rectification-set-focus", "intent.classified", "failed", {
|
||||
inputFingerprint,
|
||||
safeErrorCode: "invalid_choice_schema",
|
||||
});
|
||||
return { ok: false, error: "invalid_choice_schema" };
|
||||
}
|
||||
expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true };
|
||||
}
|
||||
expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText);
|
||||
expectedAnswerSchema = persistFollowup.choice_frame
|
||||
&& isTargetedCollectExistenceFollowup(persistFollowup)
|
||||
&& expectedAnswerSchema.choice
|
||||
? { ...expectedAnswerSchema, spoken_prompt: spokenText }
|
||||
: withSpokenPrompt(expectedAnswerSchema, spokenText);
|
||||
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
|
||||
const result = await setV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
questionId: stableFollowupQuestionId(persistFollowup),
|
||||
|
||||
Reference in New Issue
Block a user