fix(rectification): re-ask superseded collect ids instead of fake delivery (BUG-644/645)

A superseded collect row still occupied the unique question id, so the last
"没有" skipped the unasked domain and spoke delivery copy while can_adopt stayed false.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 21:03:12 +08:00
co-authored by Cursor
parent d40f2032fc
commit a3a51c32c2
11 changed files with 621 additions and 79 deletions
@@ -45,7 +45,7 @@ import {
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { deliveryNarrationAllowed, isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { parseDeclaredBirthWindow } from "@/lib/rectification-agentic/v9/declared-window-utterance";
import {
@@ -607,7 +607,15 @@ export async function POST(request: Request) {
caseId,
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction);
const intermediate = nonConvergingRangeNarration({
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
variant: "intermediate",
});
const assistantMessage = allowDelivery
? (idle.hostNarration || nonConvergingRangeNarration(decision))
: ((idle.terminalNote ? "" : idle.hostNarration) || intermediate);
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
@@ -140,6 +140,20 @@ export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt"
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
}
/**
* Delivery copy (`这次给出的范围` / representative-candidate close) is only
* allowed when the public adopt flag is on, or the session is explicitly
* offering a selectable range. Internal `canAdopt` during collect is not enough.
*/
export function deliveryNarrationAllowed(
decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome" | "nextAction" | "selectionAllowed">,
nextAction?: string | null,
): boolean {
if (publicCanAdopt(decision)) return true;
const action = nextAction ?? decision.nextAction;
return decision.selectionAllowed === true && action === "offer_provisional_range";
}
export type CompletionStatus =
| "provisional_range_user_stopped"
| "validated_range"
@@ -9,6 +9,7 @@
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { nextProbe } from "../core/build-state";
import {
deliveryNarrationAllowed,
engineCapabilityCeilingFromReceipt,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
@@ -831,7 +832,7 @@ export async function persistNextInterviewAfterChoice(input: {
sessionOutcome: input.nextAction.session_outcome,
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
})) {
}) && deliveryNarrationAllowed(decision, input.nextAction.type)) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
dossier: liveDossier,
@@ -1291,7 +1292,7 @@ export async function persistNextInterviewIfIdle(input: {
sessionOutcome: decision.sessionOutcome,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
})) {
}) && deliveryNarrationAllowed(decision, decision.nextAction)) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
dossier,
@@ -1432,27 +1433,8 @@ async function persistExhaustionCollect(input: {
answers: inference?.answered_probes.length ?? 0,
plateau: plateauRounds,
};
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: followup?.domain ?? null,
next_source: followup?.source ?? null,
}));
let persistStatus: string | null = null;
let persistQuestionId: string | null = null;
if (followup) {
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
@@ -1469,6 +1451,31 @@ async function persistExhaustionCollect(input: {
followup,
askedTurnId: input.askedTurnId ?? null,
});
persistStatus = persistedFocus.status;
persistQuestionId = persistedFocus.questionId;
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: followup.domain ?? null,
next_source: followup.source ?? null,
persist_status: persistStatus,
question_id: persistQuestionId,
}));
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
);
@@ -1480,22 +1487,48 @@ async function persistExhaustionCollect(input: {
focus: persistedFocus.focus,
};
}
// duplicate_focus: this ask is already closed; fall through to adopt/gate.
// Other persist misses still return the spoken collect so mid-session
// turns keep a next question instead of a terminal gate.
if (persistedFocus.status !== "duplicate_focus") {
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
// duplicate_focus is not "already asked". Keep the spoken collect or the
// intermediate range sentence instead of falling through to adopt.
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
} else {
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: null,
next_source: null,
persist_status: persistStatus,
question_id: persistQuestionId,
}));
}
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
if (
ceiling.acceptanceAllowed
&& trainingGate.open
&& decision.canAdopt
&& deliveryNarrationAllowed(decision, decision.nextAction)
) {
const adopted = {
...decision,
stopReason: decision.stopReason ?? "probe_pool_exhausted",
@@ -186,6 +186,14 @@ export function isRenderableChoiceOpenQuestion(
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
export const COLLECT_FOCUS_RETRY_SUFFIX = "next";
/** Suffixes tried after a collect `question_id` unique conflict. Probe ids must not use these. */
export const COLLECT_FOCUS_RETRY_SUFFIXES = ["next", "next2", "next3"] as const;
export function collectFocusRetryQuestionIds(questionId: string): string[] {
return COLLECT_FOCUS_RETRY_SUFFIXES
.map((suffix) => `${questionId}:${suffix}`.slice(0, 160))
.filter((id) => id !== questionId);
}
const PERSISTABLE_FOCUS_DOMAINS = new Set([
"education",
@@ -356,41 +364,31 @@ async function persistCollectFocus(input: {
prompt: null,
};
}
if (input.followup.collect_retry !== true) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
const retryId = `${questionId}:${COLLECT_FOCUS_RETRY_SUFFIX}`.slice(0, 160);
if (retryId === questionId) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
try {
return await insertFocus(retryId);
} catch (retryError) {
if (isFocusIdempotencyConflict(retryError)) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId: retryId,
prompt,
};
// Superseded rows still occupy the unique (case_id, question_id). Always
// try suffix ids so an unasked collect can be asked again. collect_retry
// only switches copy, it does not gate the suffix.
let lastId = questionId;
for (const retryId of collectFocusRetryQuestionIds(questionId)) {
lastId = retryId;
try {
return await insertFocus(retryId);
} catch (retryError) {
if (!isFocusIdempotencyConflict(retryError)) {
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
}
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId: lastId,
prompt,
};
}
}