fix(rectification): stop other-collect fallback from blocking delivery (BUG-586)
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

After the dated domains are asked or declined, keep the occupation question or the range card instead of hanging on a leftover other-collect prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 09:02:20 +08:00
co-authored by Cursor
parent 6c7a8b0262
commit 9aec502961
18 changed files with 535 additions and 45 deletions
@@ -51,7 +51,6 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
relocation: "有没有哪年搬家,或开始长期住在外地?",
finance: "钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗?",
health_pressure: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
other: "也可以再说一件你记得大概时间的事。",
};
/** Second phrasing when that domain's collect focus was already established and not declined. */
@@ -64,7 +63,6 @@ export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
relocation: "搬家或开始长期住外地,大概是哪年?",
finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?",
health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?",
other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。",
};
export const RECTIFICATION_USER_COPY = {
@@ -85,6 +85,7 @@ import {
planWithDateReliability,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
ledgerHasConfirmedDatedEvent,
type MethodCoverage,
type MethodFollowup,
type MethodFollowupPlan,
@@ -740,6 +741,7 @@ export async function persistNextInterviewAfterChoice(input: {
focusId?: string | null;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
const latest = input.dossier.latestResult
? {
@@ -810,6 +812,7 @@ export async function persistNextInterviewAfterChoice(input: {
choiceReady: false,
persisted: false,
followup: null,
terminalNote: true,
};
}
if (input.dossier.case.acceptedTime && !followup) {
@@ -1103,6 +1106,15 @@ export function isStalePreAdoptFocus(
return intent !== "reverse_verify" && intent !== "out_of_sample_check";
}
export function isOrphanOtherCollectFocus(
focus: { questionId?: string | null } | null | undefined,
evidence: readonly { status: string; datePrecision: string; occurredFrom: string | null; occurredTo: string | null }[],
): boolean {
const questionId = focus?.questionId ?? "";
if (!questionId.startsWith("collect:other:")) return false;
return ledgerHasConfirmedDatedEvent(evidence);
}
export async function persistNextInterviewIfIdle(input: {
accounting: AccountingClient;
userId: string;
@@ -1140,8 +1152,11 @@ export async function persistNextInterviewIfIdle(input: {
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
&& isPersistedFocusId(staleFocusId)
isPersistedFocusId(staleFocusId)
&& (
isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus)
|| isOrphanOtherCollectFocus(staleFocus, dossier.evidence)
)
) {
try {
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
@@ -1229,6 +1244,7 @@ export async function persistNextInterviewIfIdle(input: {
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
terminalNote: true,
});
}
const remainingCollect = exhaustionSpokenCollectFollowup({
@@ -1309,6 +1325,7 @@ export async function persistNextInterviewIfIdle(input: {
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
hostNarration: nextInterview.hostNarration,
...(nextInterview.terminalNote ? { terminalNote: true } : {}),
});
}
@@ -237,7 +237,9 @@ const BLOCKING_COVERAGE_IDS = new Set<MethodFollowupId>([
"relatives",
]);
function isConfirmedDated(item: MethodFollowupEvidence): boolean {
function isConfirmedDated(
item: Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">,
): boolean {
return item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& Boolean(item.occurredFrom || item.occurredTo);
@@ -1207,11 +1209,12 @@ export function spokenFollowupForUser(
&& domain === "other"
&& followup.collect_retry !== true
&& !evidence.some(isConfirmedDated);
if (domain === "other") {
return openingOther ? GENERIC_COLLECT_QUESTION : null;
}
const mapped = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
: openingOther
? GENERIC_COLLECT_QUESTION
: USER_COLLECT_QUESTION[domain];
: USER_COLLECT_QUESTION[domain];
return mapped ?? null;
}
@@ -1289,25 +1292,6 @@ export function isRemainingEvidenceCollect(
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "other",
kind_hint: null,
user_prompt_hint: collectHint(
USER_COLLECT_QUESTION.other,
"本命 Dasha + 行运(方法1",
"",
evidence,
),
must_not_label: false,
choice_frame: null,
source: "oos_blind",
};
}
export function exhaustionSpokenCollectFollowup(input: {
evidence: readonly MethodFollowupEvidence[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
@@ -1345,6 +1329,22 @@ export function collectQuestionDomain(domain: string | null | undefined): string
return OPENING_COLLECT_DOMAIN;
}
export function parseCollectFocusQuestionId(questionId: string | null | undefined): {
domain: string;
} | null {
if (!questionId) return null;
const match = /^collect:([^:]+):/.exec(questionId.trim());
const domain = match?.[1]?.trim() ?? "";
if (!domain) return null;
return { domain };
}
export function ledgerHasConfirmedDatedEvent(
evidence: readonly Pick<MethodFollowupEvidence, "status" | "datePrecision" | "occurredFrom" | "occurredTo">[],
): boolean {
return evidence.some(isConfirmedDated);
}
export function parsePersistedFollowupQuestionId(questionId: string | null | undefined): {
method_id: string;
ask_theme: string;
@@ -2017,7 +2017,7 @@ export function buildMethodFollowupPlan(input: {
method_id: "active_focus",
intent: focus.intent || "active_focus",
ask_theme: "active_focus",
domain: focus.targetDomain,
domain: parseCollectFocusQuestionId(focus.questionId)?.domain ?? focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
@@ -21,7 +21,7 @@ import {
type ConversationFocus,
} from "./tool-service";
export { parsePersistedFollowupQuestionId } from "./method-followup";
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
export type PersistServerFocusStatus =
| "created"
+31 -1
View File
@@ -55,6 +55,7 @@ import {
import { USER_COLLECT_QUESTION } from "@/lib/rectification-agentic/user-copy";
import {
isHoldoutVerificationQuote,
isPersistedFocusId,
} from "@/lib/rectification-agentic/v9/choice-card";
import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau";
import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan";
@@ -817,6 +818,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const engineVersion = v9EngineVersion();
let hasReadCase = false;
let spokenPromptFailures = 0;
let setFocusCompleted = 0;
const receipt = async (
toolName: string,
@@ -1062,6 +1064,31 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const parsed = parseDossierForTools(dossier);
const active = parsed.conversationSummary.activeFocus;
if (
setFocusCompleted > 0
&& active
&& isPersistedFocusId(active.id)
&& active.askedTurnId === turnId
) {
const projection = {
focus_id: active.id,
question_id: active.questionId,
intent: active.intent,
target_evidence_id: active.targetEvidenceId,
target_domain: active.targetDomain,
target_kind: active.targetKind,
expected_answer_schema: active.expectedAnswerSchema,
status: active.status,
asked_at: active.askedAt,
idempotent: true,
};
await receipt("rectification-set-focus", "intent.classified", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
return projection;
}
nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup;
decisionReceipt = parsed.latestResult?.decisionReceipt ?? null;
} catch {
@@ -1089,7 +1116,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
resultFingerprint: JSON.stringify({ reason: spoken.reason }),
});
const fallbackDomain = nextFollowup.domain ?? "";
const fallbackPrompt = USER_COLLECT_QUESTION[fallbackDomain];
const fallbackPrompt = fallbackDomain && fallbackDomain !== "other"
? USER_COLLECT_QUESTION[fallbackDomain]
: undefined;
if (
spokenPromptFailures < 2
|| nextFollowup.intent !== "collect_method_evidence"
@@ -1147,6 +1176,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
inputFingerprint,
resultFingerprint: hashResult(projection),
});
setFocusCompleted += 1;
return projection;
} catch (error) {
await receipt("rectification-set-focus", "intent.classified", "failed", {