fix(rectification): guarantee nonterminal turn exits
This commit is contained in:
@@ -13,7 +13,6 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
|
||||
import {
|
||||
applyRectificationChoice,
|
||||
applyCollectFocusDenial,
|
||||
ensureNonTerminalTurnExit,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
|
||||
@@ -41,6 +40,12 @@ import {
|
||||
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 {
|
||||
awaitTurnExitBeforeResponse,
|
||||
finalizeSuccessfulTurnExit,
|
||||
RECTIFICATION_ACTION_EXECUTION,
|
||||
type RectificationRouteAction,
|
||||
} from "@/lib/rectification-agentic/v9/turn-exit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 240;
|
||||
@@ -86,6 +91,9 @@ const agentRequestSchema = z.object({
|
||||
clientActionId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
type ParsedRectificationAction = z.infer<typeof agentRequestSchema>["action"];
|
||||
const rectificationActionExecution: Record<ParsedRectificationAction, (typeof RECTIFICATION_ACTION_EXECUTION)[RectificationRouteAction]> = RECTIFICATION_ACTION_EXECUTION;
|
||||
|
||||
function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction {
|
||||
if (action === "opening") return "opening";
|
||||
if (action === "read_only") return "read_only";
|
||||
@@ -170,7 +178,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const userId = user.id;
|
||||
const { caseId, sessionId, requestId, action } = parsed.data;
|
||||
const isStructuredChoice = action === "answer_choice" || action === "stop_and_review";
|
||||
const execution = rectificationActionExecution[action];
|
||||
const isStructuredChoice = execution === "immediate";
|
||||
|
||||
// Feature selector: the V9 runtime is DB-driven. When the flag is not
|
||||
// published/enabled, no new runs are served (legacy stays read-only).
|
||||
@@ -246,8 +255,15 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isStructuredChoice) {
|
||||
const actionId = parsed.data.actionId;
|
||||
const selectedModel = isStructuredChoice
|
||||
? null
|
||||
: await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
const immediateResponse = await (async (): Promise<Response | null> => {
|
||||
if (isStructuredChoice) {
|
||||
const actionId = parsed.data.actionId;
|
||||
const focusId = parsed.data.focusId;
|
||||
const expectedRevision = parsed.data.expectedRevision;
|
||||
if (!actionId || !focusId || expectedRevision === undefined) {
|
||||
@@ -309,11 +325,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedModel = await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
if (!selectedModel) {
|
||||
const resolvedModel = selectedModel;
|
||||
if (!resolvedModel) {
|
||||
return NextResponse.json(
|
||||
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
|
||||
{ status: 409 },
|
||||
@@ -328,7 +341,7 @@ export async function POST(request: Request) {
|
||||
if (focus && choice) {
|
||||
let classified = null;
|
||||
try {
|
||||
classified = await classifyRectificationTurnIntent(selectedModel, {
|
||||
classified = await classifyRectificationTurnIntent(resolvedModel, {
|
||||
focus,
|
||||
userMessage: parsed.data.message ?? "",
|
||||
caseStatus,
|
||||
@@ -537,6 +550,34 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
if (immediateResponse) {
|
||||
if (immediateResponse.status !== 200) return immediateResponse;
|
||||
const response = await awaitTurnExitBeforeResponse(
|
||||
immediateResponse,
|
||||
() => finalizeSuccessfulTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
action,
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
if (action === "answer_choice" || action === "stop_and_review") {
|
||||
return NextResponse.json(
|
||||
{ error: "选择题处理失败", message: "请稍后重试。" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
if (!selectedModel) {
|
||||
return NextResponse.json(
|
||||
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const requestTime = new Date();
|
||||
const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
@@ -673,30 +714,14 @@ export async function POST(request: Request) {
|
||||
if (!result.ok) {
|
||||
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
|
||||
} else {
|
||||
if (action === "message" || action === "opening") {
|
||||
try {
|
||||
await persistNextInterviewIfIdle({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await ensureNonTerminalTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit;
|
||||
// The shared gate replaces the old message/opening-only cleanup.
|
||||
await finalizeSuccessfulTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
action,
|
||||
});
|
||||
send({ type: "done", emitted: true });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user