fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 7m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:59:47 +08:00
co-authored by Cursor
parent 0263658477
commit d96b24c276
16 changed files with 615 additions and 76 deletions
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
loadV9CaseSkillIdentityStatus,
loadV9TurnReceipt,
RectificationToolServiceError,
listV10ConversationFocuses,
} from "@/lib/rectification-agentic/v9/tool-service";
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
import { dossierResponse } from "../route";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
const repairSchema = z.object({
sessionId: z.string().uuid().optional(),
}).strict();
/**
* POST /api/rectification/cases/[caseId]/repair-exit
*
* Repair a turn that settled without a visible next question or card.
* Only runs ensureNonTerminalTurnExit, then returns the same snapshot as GET.
*/
export async function POST(request: Request, context: RouteContext) {
let supabase;
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const parsed = repairSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_repair_request" }, { status: 400 });
}
try {
await ensureNonTerminalTurnExit({
accounting,
userId: user.id,
caseId,
});
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
if (parsed.data.sessionId && dossier.case.sessionId !== parsed.data.sessionId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
const skillIdentity = await loadV9CaseSkillIdentityStatus(accounting, user.id, caseId);
const receipts = await Promise.all(
dossier.turns.map(async (turn) => {
try {
return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id);
} catch {
return null;
}
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json({
ok: true,
...dossierResponse(dossier, receipts, skillIdentity, { listed }),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const message = error.message;
if (message.includes("agentic_rectification_case_not_found")) {
return NextResponse.json({ error: "校正记录不存在或无权访问", code: "case_not_found" }, { status: 404 });
}
}
return NextResponse.json(
{ error: "暂时无法接上下一个问题", code: "rectification_repair_failed" },
{ status: 503 },
);
}
}
@@ -93,7 +93,7 @@ export async function GET(request: Request, context: RouteContext) {
}
}
function dossierResponse(
export function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
@@ -52,6 +52,8 @@ import {
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
RECTIFICATION_QUESTION_REPAIR_LIMIT,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
@@ -484,6 +486,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
@@ -547,6 +551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
if (value) setQuestionRepairAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
@@ -649,6 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
setQuestionRepairAttempts(0);
}
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1500,7 +1508,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
));
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a reload button) — never bare copy telling the
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server.
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
@@ -1532,7 +1540,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a reload button. Attempts reset once a question arrives
// hand the reader a repair button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
@@ -1546,9 +1554,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function refetchQuestion() {
await loadCaseSnapshot();
}
function reloadQuestion() {
setQuestionRetryAttempts(0);
void refetchQuestion();
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
setQuestionRepairing(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`,
{
method: "POST",
headers: { "content-type": "application/json" },
cache: "no-store",
body: JSON.stringify({ sessionId }),
},
);
const payload = await response.json().catch(() => null);
if (response.ok && payload) {
startTransition(() => {
applyCaseSnapshot(payload);
});
const recovered = Boolean(
currentQuestionFromSnapshot(payload.current_question)
|| parseRectificationChoiceCard(payload.choice_card)
|| payload.case?.accepted_time
|| payload.case?.confirmed_time
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot();
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
setQuestionRepairAttempts((current) => current + 1);
} finally {
setQuestionRepairing(false);
}
}
// A Case with no turns and no automatic first turn (the server never starts
@@ -1768,10 +1807,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={reloadQuestion}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
) : (
<>
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</>
)}
</div>
)}
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (
@@ -32,7 +32,7 @@ import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { alreadyDelivered } from "./delivery-turn-guard";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import {
@@ -622,6 +622,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
console.warn(
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
);
try {
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
} catch (repairError) {
console.warn(
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
);
}
}
}
if (action === "evidence") {
@@ -30,7 +30,7 @@ import {
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { datedEventCount } from "./divergence-panel.ts";
import { logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
@@ -114,6 +114,47 @@ export function isExhaustedGateState(input: {
&& !input.accepted;
}
export function looksLikeTerminalNoteNarration(text: string | null | undefined): boolean {
const trimmed = text?.trim() ?? "";
if (!trimmed) return false;
return trimmed.includes(RECTIFICATION_USER_COPY.noCandidatesGate)
|| trimmed.includes("这只是代表性候选")
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|| trimmed.includes("两件事的日期还没对清")
|| trimmed.includes("这次给出的范围");
}
function terminalNoteHostPresent(input: {
caseId: string;
dossier: {
turns?: readonly Readonly<{ role: string; text: string | null }>[];
latestResult?: { resultId?: string } | null;
};
}): boolean {
const resultId = input.dossier.latestResult?.resultId ?? "";
if (alreadyDelivered({
caseId: input.caseId,
resultId,
hasUserMessage: false,
})) return true;
return (input.dossier.turns ?? []).some((turn) => (
turn.role === "assistant" && looksLikeTerminalNoteNarration(turn.text)
));
}
function logDuplicateFocus(caseId: string, questionId: string | null | undefined): void {
console.warn(JSON.stringify({
event: "rectification_focus_duplicate",
case_id: caseId,
question_id: questionId ?? null,
}));
}
function exhaustionCarrierNarration(range: string | null | undefined, gate: string | null | undefined): string {
return [range, gate].filter((item): item is string => Boolean(item?.trim())).join("")
|| RECTIFICATION_USER_COPY.noCandidatesGate;
}
export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string {
return `${askedTurnId ?? caseId}:gate`;
}
@@ -132,6 +173,9 @@ export async function persistExhaustionGateTurn(input: {
resultId: input.resultId ?? null,
terminalNote: true,
});
if (input.resultId) {
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
}
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -868,6 +912,9 @@ export async function persistNextInterviewAfterChoice(input: {
}
if (followup?.intent === "collect_method_evidence") {
const spoken = spokenFollowupForUser(followup);
if (persistedFocus.status === "duplicate_focus") {
logDuplicateFocus(input.caseId, persistedFocus.questionId);
}
if (
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
@@ -945,6 +992,9 @@ async function persistFocusAfterChoice(input: {
|| persisted.status === "duplicate_focus"
|| !input.followup
) {
if (persisted.status === "duplicate_focus") {
logDuplicateFocus(input.caseId, persisted.questionId);
}
return persisted;
}
if (persisted.status === "skipped" && !followupHasPersistableDomain(input.followup)) {
@@ -1422,12 +1472,28 @@ async function persistExhaustionCollect(input: {
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
);
return {
persisted,
choiceReady: false,
hostNarration: spoken ?? range,
focus: persistedFocus.focus,
};
if (persisted) {
return {
persisted,
choiceReady: false,
hostNarration: spoken ?? range,
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,
};
}
}
}
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
const adopted = {
@@ -1463,7 +1529,7 @@ async function persistExhaustionCollect(input: {
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
});
const hostNarration = [range, gate].filter(Boolean).join("");
const hostNarration = exhaustionCarrierNarration(range, gate);
return {
persisted: false,
choiceReady: false,
@@ -1713,42 +1779,13 @@ async function inspectNonTerminalTurnExit(input: {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}),
});
const exhausted = isExhaustedGateState({
remainingCollect,
methods: plan.methods,
canAdopt: decision.canAdopt,
accepted: Boolean(dossier.case.acceptedTime),
});
const satisfied = Boolean(
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|| dossier.case.acceptedTime
|| dossier.case.confirmedTime
|| decision.completionStatus === "provisional_range_user_stopped"
|| publicCanAdopt(decision)
|| exhausted
|| terminalNoteHostPresent({ caseId: input.caseId, dossier })
);
return { dossier, decision, satisfied };
}
@@ -1775,11 +1812,38 @@ export async function ensureNonTerminalTurnExit(input: {
decision: before.decision,
decisionReceipt: before.dossier.latestResult?.decisionReceipt,
});
if (repaired.persisted && repaired.focus) {
const afterFocus = await inspectNonTerminalTurnExit(input);
if (!afterFocus.satisfied && !repaired.hostNarration) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
}
const hostNarration = (repaired.hostNarration ?? "").trim()
|| RECTIFICATION_USER_COPY.noCandidatesGate;
try {
await persistExhaustionGateTurn({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
hostNarration,
resultId: before.dossier.latestResult?.resultId ?? null,
});
} catch (error) {
console.warn(
`[rectification-v9] persist exhaustion gate during exit repair failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
);
}
const after = await inspectNonTerminalTurnExit(input);
if (!after.satisfied && !repaired.terminalNote) {
if (!after.satisfied && !hostNarration) {
throw new RectificationToolServiceError("agentic_rectification_nonterminal_exit_missing");
}
return repaired;
return {
...repaired,
persisted: true,
hostNarration,
terminalNote: true,
};
}
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
@@ -1778,6 +1778,15 @@ function holdoutAskFields(
};
}
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
const next = new Set(declined);
if (declinedHealth(declined)) {
next.add("health");
next.add("health_pressure");
}
return next;
}
export function holdoutFollowupFor(
input: {
evidence: readonly MethodFollowupEvidence[];
@@ -1788,21 +1797,13 @@ export function holdoutFollowupFor(
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const occupied = holdoutOccupiedDomains(input.evidence);
const healthDeclined = declinedHealth(declined);
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
if (!item.domain || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => {
if (item.year == null || occupied.has(item.domain)) return false;
if (item.domain === "health" || item.domain === "health_pressure") {
return !healthDeclined;
}
return !declined.has(item.domain);
}) ?? null;
const blocked = declinedForHoldout(declined);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain)
)) ?? null;
return holdoutAskFields(prompt, reserved);
}
@@ -29,7 +29,9 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问";
export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。";
export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2;
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
@@ -291,7 +293,7 @@ export type RectificationQuestionGapInput = Readonly<{
* The gap between a settled turn and its next question. While retries remain
* it is `preparing` (one live row, timed refetches); once they run out, or the
* server itself reports the question unavailable, it is `unavailable` with a
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
* repair button. A snapshot that never arrived (hydration timed out) is a gap
* too: the same retries fill it. Questions themselves live inside assistant
* messages, so a visible live question means there is no gap.
*/