fix(rectification): keep collection denials from stalling the interview
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:
@@ -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_focus,answer_class 为 no。
|
||||
若用户在补充带时间的经历,intent 为 provide_new_evidence,answer_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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user