fix(rectification): keep collection denials from stalling the interview
Independent Staging Quality Gate / validate (push) Successful in 23m37s
Independent Staging Quality Gate / publish (push) Has been cancelled

A spoken no neither scored nor declined coverage, so relatives never closed and free-text turns left current_question null. Prefer a same-domain yearless scoring card before an unscoreable collect, resolve explicit collect denials as declined, and persist the next followup after an idle agent turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 22:37:32 +08:00
co-authored by Cursor
parent 187ef6ae24
commit 078c1cdc0d
11 changed files with 1014 additions and 45 deletions
@@ -13,7 +13,7 @@ import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
} from "./inference-adapter";
import { decideAfterInferenceChange, rectificationFollowupCatalog } from "./decision-from-dossier";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import type { InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
@@ -32,10 +32,12 @@ import {
loadV9CaseDossier,
persistV9ChoiceAction,
persistV9DeterministicTurn,
resolveV10ConversationFocus,
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
type PersistChoiceActionInput,
type V9CaseDossier,
} from "./tool-service";
import type { ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "./server-focus";
@@ -84,6 +86,31 @@ function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function dossierWithClosedFocus<T extends {
conversationSummary: {
activeFocus: { targetDomain?: string | null } | null;
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
};
}>(dossier: T, status: "resolved" | "declined" | "skipped"): T {
const domain = dossier.conversationSummary.activeFocus?.targetDomain ?? null;
const declinedSkippedTopics = (
(status === "declined" || status === "skipped") && domain
? [
...dossier.conversationSummary.declinedSkippedTopics,
{ target_domain: domain, status },
]
: dossier.conversationSummary.declinedSkippedTopics
);
return {
...dossier,
conversationSummary: {
...dossier.conversationSummary,
activeFocus: null,
declinedSkippedTopics,
},
};
}
export async function applyRectificationChoice(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -245,7 +272,7 @@ export async function applyRectificationChoice(
});
}
async function persistNextInterviewAfterChoice(input: {
export async function persistNextInterviewAfterChoice(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
@@ -359,6 +386,105 @@ async function persistFocusAfterChoice(input: {
}
}
export async function applyCollectFocusDenial(
accounting: AccountingClient,
input: { userId: string; caseId: string; focusId: string },
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
throw new RectificationToolServiceError("agentic_rectification_focus_not_active");
}
await resolveV10ConversationFocus(accounting, input.userId, input.caseId, {
focusId: input.focusId,
status: "declined",
evidenceId: null,
});
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
birthDate = null;
}
let latest: V9CaseDossier = dossier;
try {
latest = await loadV9CaseDossier(accounting, input.userId, input.caseId);
} catch {
latest = dossier;
}
const withDeclined = dossierWithClosedFocus({
...latest,
conversationSummary: {
...latest.conversationSummary,
activeFocus: {
...focus,
targetDomain: focus.targetDomain,
},
},
}, "declined");
const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate }));
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
userId: input.userId,
caseId: input.caseId,
dossier: withDeclined,
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
nextAction,
birthDate,
});
return {
narration: nextInterview.hostNarration ?? "记下了,这方面先跳过。",
nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
nextChoiceReady: nextInterview.choiceReady,
};
}
export async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean }> {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (dossier.conversationSummary.activeFocus) {
return { persisted: false, choiceReady: false };
}
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
} catch {
birthDate = null;
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
});
if (!plan.next_followup) {
return { persisted: false, choiceReady: false };
}
const nextAction = publicNextAction(decideFromDossier(dossier, { birthDate }));
const nextInterview = await persistNextInterviewAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
birthDate,
});
return {
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
};
}
async function persistApplied(
accounting: AccountingClient,
command: ApplyChoiceCommand,
@@ -422,7 +548,7 @@ async function persistApplied(
accounting,
userId: command.userId,
caseId: command.caseId,
dossier: input.dossier,
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
decisionState: input.decisionState ?? null,
nextAction,
birthDate,
@@ -278,11 +278,14 @@ function eventQuestionPrompt(
const time = period.trim();
const topic = family.replace(/[?。]+$/g, "").trim();
if (!topic) return time;
if (kind === "event_quality") return `${time},有没有${topic}的时候?`;
if (kind === "varga_style") {
return isConcreteChoicePeriod(period) ? `${time}${topic}` : `${topic}`;
const dated = isConcreteChoicePeriod(period);
if (kind === "event_quality") {
return dated ? `${time}有没有${topic}的时候` : `有没有${topic}的时候`;
}
return `${time},有没有${topic}`;
if (kind === "varga_style") {
return dated ? `${time}${topic}` : `${topic}`;
}
return dated ? `${time},有没有${topic}` : `有没有${topic}`;
}
function withStyleOptionLabels(
@@ -331,7 +334,8 @@ function hypothesisFor(
if (!styleOptions.ok) return null;
const period = periodFor(evidence, domain, probes, birthDate, followup);
const kind = followup.choice_kind ?? probe.choice_kind ?? "existence";
if (kind !== "varga_style" && !isConcreteChoicePeriod(period)) return null;
const yearlessVarga = (followup.semantic_key ?? probe.semantic_key ?? "").startsWith("varga.");
if (kind !== "varga_style" && !isConcreteChoicePeriod(period) && !yearlessVarga) return null;
const prompt = eventQuestionPrompt(period, probe.event_family, kind);
const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim();
if (!why) return null;
@@ -44,9 +44,10 @@
* do not stamp a ledger year onto a yearless scoring card. An open varga
* discriminator must resolve from the contrast packet when Python event
* probes have no row in that domain. Scoring reverse-inference cards need
* the engine year or month. A yearless D12/family contrast does not attach
* A/B/C/D; skip it and either ask the next dated discriminator or collect a
* dated family event in natural language.
* the engine year or month. Yearless varga cards stay out of the dated
* ranking until coverage is complete. If the next ask would be a spoken
* collect in the same domain, prefer a renderable yearless scoring card
* instead of an unscoreable collect question.
*/
import {
@@ -1477,9 +1478,17 @@ export function buildMethodFollowupPlan(input: {
}
}
}
const sameDomainYearlessCard = (domain: string): MethodFollowup | null => {
const ranked = yearlessDiscriminators.find((row) => (
(row.eventProbe?.domain ?? row.contrastProbe?.domain ?? null) === domain
));
if (!ranked) return null;
const candidate = followupFromRanked(ranked);
return candidate.choice_frame ? candidate : null;
};
if (!next) {
if (!relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
next = sameDomainYearlessCard("relationship") ?? makeFollowup({
method_id: "d9_relationship",
intent: "collect_method_evidence",
ask_theme: "relationship_style",
@@ -1493,7 +1502,7 @@ export function buildMethodFollowupPlan(input: {
source: "method_coverage",
});
} else if (!careerCovered && !declined.has("career")) {
next = makeFollowup({
next = sameDomainYearlessCard("career") ?? makeFollowup({
method_id: "d10_career",
intent: "collect_method_evidence",
ask_theme: "career_style",
@@ -1508,7 +1517,7 @@ export function buildMethodFollowupPlan(input: {
});
} else if (!familyCovered && !declined.has("family")) {
const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family");
next = makeFollowup({
next = sameDomainYearlessCard("family") ?? makeFollowup({
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
@@ -1523,7 +1532,7 @@ export function buildMethodFollowupPlan(input: {
...collectionYearFields(familyCollect),
});
} else if (!occupationCovered) {
next = makeFollowup({
next = sameDomainYearlessCard("occupation") ?? makeFollowup({
method_id: "occupation",
intent: "collect_method_evidence",
ask_theme: "occupation",
@@ -1745,7 +1754,7 @@ export function buildMethodFollowupPlan(input: {
);
if (fields) next = makeFollowup(fields, false, true);
} else if (horaryStatus === "uncovered") {
next = makeFollowup({
next = sameDomainYearlessCard("horary") ?? makeFollowup({
method_id: "horary",
intent: "collect_method_evidence",
ask_theme: "horary",
@@ -4,6 +4,7 @@ import { z } from "zod";
import type { ResolvedLanguageModel } from "@/mastra/model";
import type { AnswerClass } from "../core/types";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
import type { ConversationFocus } from "./tool-service";
const turnIntentSchema = z.object({
@@ -28,6 +29,12 @@ export function parseRectificationTurnIntent(value: unknown): RectificationTurnI
return parsed.data.answer_class === null ? parsed.data : null;
}
export function shouldDeclineCollectFocus(
classified: RectificationTurnIntent | null,
): boolean {
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
}
export function optionIdForAnswerClass(
focus: ConversationFocus,
answerClass: AnswerClass,
@@ -46,23 +53,33 @@ export async function classifyRectificationTurnIntent(
},
): Promise<RectificationTurnIntent | null> {
const choice = parseAgentChoiceCopy(input.focus.expectedAnswerSchema);
if (!choice) return null;
const collectPrompt = isCollectFocusSchema(input.focus.expectedAnswerSchema)
? String(input.focus.expectedAnswerSchema.prompt ?? "").trim()
: "";
if (!choice && !collectPrompt) return null;
const agent = new Agent({
id: `rectification-focus-intent-${model.id}`,
name: "Rectification Focus Intent Classifier",
model: model.model,
instructions: `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
instructions: choice
? `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。
“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`,
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`
: `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。
当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focusanswer_class 为 no。
若用户在补充带时间的经历,intent 为 provide_new_evidenceanswer_class 必须为 null。
“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`,
});
const result = await agent.generate([{
role: "user",
content: JSON.stringify({
current_question: choice.prompt,
options: choice.options,
current_question: choice?.prompt ?? collectPrompt,
options: choice?.options ?? [],
user_message: input.userMessage,
case_status: input.caseStatus,
}),