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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user