fix(rectification): restore targeted collect cards from spoken focus (BUG-673)
Independent Staging Quality Gate / validate (push) Successful in 10m39s
Independent Staging Quality Gate / publish (push) Successful in 2m9s

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:
jesse-ux
2026-09-14 01:51:55 +08:00
parent 9a943cc2dc
commit 8221c6121b
17 changed files with 673 additions and 38 deletions
@@ -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<{
+31 -3
View File
@@ -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),