fix(rectification): keep same-sentence dated events after structured answers
Independent Staging Quality Gate / validate (push) Successful in 13m7s
Independent Staging Quality Gate / publish (push) Successful in 29m19s

The choice and collect fast paths applied the answer then returned,
so a dated event in the same utterance never reached the evidence
ledger. Idle persist also prechecked follow-up with a hardcoded
collect_evidence outcome instead of the dossier decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 23:18:07 +08:00
parent 078c1cdc0d
commit 3a0319694e
8 changed files with 306 additions and 17 deletions
@@ -30,6 +30,7 @@ import {
classifyRectificationTurnIntent,
optionIdForAnswerClass,
shouldDeclineCollectFocus,
shouldContinueAgentForDatedEvent,
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
@@ -348,6 +349,7 @@ export async function POST(request: Request) {
if (!optionId) {
return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId);
}
const continueToAgent = shouldContinueAgentForDatedEvent(classified);
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const applied = await applyRectificationChoice(accounting, {
userId,
@@ -363,8 +365,11 @@ export async function POST(request: Request) {
optionId,
expectedRevision: previous?.revision ?? 0,
userDisplay: parsed.data.message ?? null,
deferFollowup: continueToAgent,
});
return completedMessageResponse(applied.narration, requestId, caseId);
if (!continueToAgent) {
return completedMessageResponse(applied.narration, requestId, caseId);
}
}
if (classified.intent === "stop_rectification") {
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
@@ -399,17 +404,21 @@ export async function POST(request: Request) {
classified = null;
}
if (shouldDeclineCollectFocus(classified)) {
const continueToAgent = shouldContinueAgentForDatedEvent(classified);
const applied = await applyCollectFocusDenial(accounting, {
userId,
caseId,
focusId: focus.id,
deferFollowup: continueToAgent,
});
await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: applied.narration,
});
return completedMessageResponse(applied.narration, requestId, caseId);
if (!continueToAgent) {
await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: applied.narration,
});
return completedMessageResponse(applied.narration, requestId, caseId);
}
}
} else {
let birthDate: string | null = null;
@@ -60,6 +60,7 @@ export type ApplyChoiceCommand = Readonly<{
optionId: ChoiceOptionId;
expectedRevision: number;
userDisplay?: string | null;
deferFollowup?: boolean;
}>;
export type AppliedChoiceReceipt = Readonly<{
@@ -388,7 +389,7 @@ async function persistFocusAfterChoice(input: {
export async function applyCollectFocusDenial(
accounting: AccountingClient,
input: { userId: string; caseId: string; focusId: string },
input: { userId: string; caseId: string; focusId: string; deferFollowup?: boolean },
): Promise<{ narration: string; nextInterviewPersisted: boolean; nextChoiceReady: boolean }> {
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const focus = dossier.conversationSummary.activeFocus;
@@ -400,6 +401,13 @@ export async function applyCollectFocusDenial(
status: "declined",
evidenceId: null,
});
if (input.deferFollowup === true) {
return {
narration: "记下了,这方面先跳过。",
nextInterviewPersisted: false,
nextChoiceReady: false,
};
}
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId);
@@ -456,12 +464,13 @@ export async function persistNextInterviewIfIdle(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
@@ -469,7 +478,7 @@ export async function persistNextInterviewIfIdle(input: {
if (!plan.next_followup) {
return { persisted: false, choiceReady: false };
}
const nextAction = publicNextAction(decideFromDossier(dossier, { birthDate }));
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -543,7 +552,11 @@ async function persistApplied(
let nextInterviewPersisted = false;
let nextChoiceReady = false;
let hostNarration = input.narration;
if (input.userStopped !== true && shouldContinueAfterStructuredChoice(nextAction)) {
if (
command.deferFollowup !== true
&& input.userStopped !== true
&& shouldContinueAfterStructuredChoice(nextAction)
) {
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
userId: command.userId,
@@ -559,7 +572,10 @@ async function persistApplied(
nextInterviewPersisted = true;
}
}
if (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted })) {
if (
command.deferFollowup !== true
&& (nextInterviewPersisted || !shouldContinueAfterStructuredChoice(nextAction, { nextInterviewPersisted }))
) {
try {
await persistV9DeterministicTurn(accounting, command.userId, command.caseId, {
requestId: command.actionId,
@@ -16,6 +16,7 @@ const turnIntentSchema = z.object({
"unclear",
]),
answer_class: z.enum(["yes", "weak_yes", "no", "unsure"]).nullable(),
has_new_dated_event: z.boolean().optional(),
}).strict();
export type RectificationTurnIntent = z.infer<typeof turnIntentSchema>;
@@ -35,6 +36,12 @@ export function shouldDeclineCollectFocus(
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
}
export function shouldContinueAgentForDatedEvent(
classified: RectificationTurnIntent | null,
): boolean {
return classified?.has_new_dated_event === true;
}
export function optionIdForAnswerClass(
focus: ConversationFocus,
answerClass: AnswerClass,
@@ -66,12 +73,16 @@ export async function classifyRectificationTurnIntent(
? `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。
has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或单纯的选项回答必须为 false。
若同一句话既回答了当前问题又补充了新的带时间经历,intent 仍为 answer_current_focushas_new_dated_event 为 true。
“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`
: `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。
当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focusanswer_class 为 no。
若用户在补充带时间的经历,intent 为 provide_new_evidenceanswer_class 必须为 null。
若用户在补充带时间的经历、并没有回答当前采集题intent 为 provide_new_evidenceanswer_class 必须为 null。
has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定必须为 false。
若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。
“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`,
});
@@ -1195,7 +1195,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const resolveFocusTool = createTool({
id: "rectification-resolve-focus",
description:
"处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。",
"处理用户对当前问题的明确拒答、跳过或无证据式解决。必须引用服务器返回的 active focusId;若当前没有 active focus,不要调用本工具。若目标是既有证据,可同时引用 evidenceId。点选卡由服务器 answer_choice 处理,不要为 A/B/C/D 或「先这样」调用本工具。自由文本拒答/跳过才带 choiceKey,服务器会立刻更新候选后验,不要等下一次 compare。采集题得到「确实没有」必须用 declined;resolved 只用于已落证据的情形。不得从中文措辞或上一条助手消息猜测目标。",
inputSchema: z.object({
caseId: z.string().uuid(),
focusId: z.string().uuid(),