import { NextResponse } from "next/server"; import { z } from "zod"; import { getRectificationV9Agent, type RectificationAgentAction } from "@/mastra/agentic-rectification"; import { evidenceLedgerFingerprint, loadV9CaseCompute, loadV9CaseDossier, persistV9DeterministicTurn, RectificationToolServiceError, transitionV9CaseStatus, } from "@/lib/rectification-agentic/v9/tool-service"; import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier"; import { applyRectificationChoice, applyCollectFocusDenial, persistNextInterviewIfIdle } from "@/lib/rectification-agentic/v9/answer-choice"; import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; import { runV9AgentTurn, type V9RunBilling } from "@/lib/rectification-agentic/v9/agent-run"; import { safePublicEvent } from "@/lib/rectification-agentic/v9/stream-mapping"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "@/lib/rectification-agentic/v9/case-status"; import { blocksPromptExtraction } from "@/lib/consult-safety"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { loadRuntimeFeatureFlags } from "@/lib/feature-flags"; import { isProductEnabled } from "@/lib/product-access"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { classifyRectificationTurnIntent, optionIdForAnswerClass, shouldDeclineCollectFocus, shouldContinueAgentForDatedEvent, } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; export const runtime = "nodejs"; export const maxDuration = 240; export const DISCRIMINATOR_EXHAUSTED_NARRATION = "当前几个候选已经构成可信区间。你可以再说一件记得住时间的经历,也可以先按这个区间看盘。"; function completedMessageResponse(text: string, requestId: string, caseId: string) { const body = [ JSON.stringify({ type: "answer.delta", text }), JSON.stringify({ type: "run.completed" }), "", ].join("\n"); return new Response(body, { headers: { "cache-control": "no-cache, no-transform", "content-type": "application/x-ndjson; charset=utf-8", "x-accel-buffering": "no", "x-ayanam-request-id": requestId, "x-rectification-case-id": caseId, }, }); } const agentRequestSchema = z.object({ caseId: z.string().uuid(), sessionId: z.string().uuid(), requestId: z.string().uuid(), action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review"]), message: z.string().trim().min(1).max(4000).optional(), modelId: z.string().trim().min(1).max(64).optional(), actionId: z.string().uuid().optional(), focusId: z.string().uuid().optional(), questionId: z.string().trim().min(1).max(200).optional(), probeId: z.string().trim().min(1).max(200).nullable().optional(), optionId: z.enum(["A", "B", "C", "D"]).optional(), expectedRevision: z.number().int().min(0).max(10_000).optional(), origin: z.enum([ "typed", "suggestion_click", "choice_click", "voice_input", "retry_replay", "system_recovery", ]).optional(), clientActionId: z.string().uuid().optional(), }).strict(); function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction { if (action === "opening") return "opening"; if (action === "read_only") return "read_only"; return "evidence"; } async function rectificationBillingRequestId( accounting: Awaited>, userId: string, caseId: string, ) { const billingRequestPrefix = `rectification:case:${caseId}`; const { data, error } = await accounting .from("usage_reservations") .select("request_id,status") .eq("user_id", userId) .eq("feature_key", "rectification") .like("request_id", `${billingRequestPrefix}%`); if (error) throw new Error("RectificationBillingLookupError"); const reservations = (data ?? []) as Array<{ request_id: string; status: string }>; const active = reservations.find((item) => item.status === "completed") ?? reservations.find((item) => item.status === "reserved"); if (active) return active.request_id; return reservations.length === 0 ? billingRequestPrefix : `${billingRequestPrefix}:retry:${reservations.length}`; } /** * POST /api/rectification/agent * * Case-ref API. The browser sends only caseId/sessionId/requestId/action/ * message; the server verifies the exact Case↔Session binding, reads the * durable dossier, streams the agent's fullStream and emits allowlisted NDJSON * phases only. Client history can never override server history. */ export async function POST(request: Request) { let supabase; let accounting; try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); } catch { return NextResponse.json( { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, { status: 503 }, ); } const { data: { user }, error: authError, } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } if (!await isProductEnabled("rectification")) { return NextResponse.json( { error: "生时校正服务暂未开放", code: "rectification_product_disabled" }, { status: 503 }, ); } const parsed = agentRequestSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( { error: "请求格式不正确", details: parsed.error.flatten() }, { status: 400 }, ); } const promptSource = [ parsed.data.action === "message" ? parsed.data.message ?? "" : "", ].join("\n"); if (blocksPromptExtraction(promptSource)) { return NextResponse.json( { error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续描述人生事件。" }, { status: 400 }, ); } const userId = user.id; const { caseId, sessionId, requestId, action } = parsed.data; const isStructuredChoice = action === "answer_choice" || action === "stop_and_review"; // Feature selector: the V9 runtime is DB-driven. When the flag is not // published/enabled, no new runs are served (legacy stays read-only). try { const flags = await loadRuntimeFeatureFlags(["rectification_runtime_version"]); const runtime = flags.get("rectification_runtime_version"); if (!runtime?.enabled) { return NextResponse.json( { error: "生时校正服务暂未开放", code: "rectification_runtime_disabled" }, { status: 503 }, ); } } catch { // Flag read failure defaults to the V9 runtime being unavailable. return NextResponse.json( { error: "生时校正服务暂未开放", code: "rectification_runtime_disabled" }, { status: 503 }, ); } // Read the case projection to verify ownership + session binding + model. const { data: caseRow, error: caseError } = await accounting.rpc( "get_agentic_rectification_case", { p_user_id: userId, p_case_id: caseId }, ); if (caseError) { const message = caseError.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_service_failed" }, { status: 503 }); } const caseView = Array.isArray(caseRow) ? caseRow[0] : caseRow; const boundSessionId = caseView && typeof caseView === "object" ? (caseView as { session_id?: unknown }).session_id : null; if (typeof boundSessionId !== "string" || boundSessionId !== sessionId) { return NextResponse.json( { error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }, ); } const caseStatus = caseView && typeof caseView === "object" ? String((caseView as { status?: unknown }).status ?? "") : ""; const skillVersion = caseView && typeof caseView === "object" ? String((caseView as { skill_version?: unknown }).skill_version ?? "") : ""; if (caseStatus === "confirmed" || caseStatus === "closed" || caseStatus === "abandoned" || caseStatus === "superseded") { return NextResponse.json( { error: "该校正已结束,只能查看历史", code: "case_terminal" }, { status: 409 }, ); } const { data: chatSession, error: chatSessionError } = await supabase .from("chat_sessions") .select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id") .eq("id", sessionId) .eq("user_id", userId) .maybeSingle(); if (chatSessionError) { return NextResponse.json({ error: "暂时无法读取会话", message: "请稍后重试。" }, { status: 503 }); } if (!chatSession || chatSession.session_type !== "birth_time_rectification") { return NextResponse.json({ error: "生时校正会话不存在", message: "请重新进入生时校正。" }, { status: 404 }); } if (chatSession.agentic_rectification_case_id !== caseId) { return NextResponse.json( { error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 }, ); } if (isStructuredChoice) { const actionId = parsed.data.actionId; const focusId = parsed.data.focusId; const expectedRevision = parsed.data.expectedRevision; if (!actionId || !focusId || expectedRevision === undefined) { return NextResponse.json( { error: "选择题请求缺少 actionId、focusId 或 expectedRevision" }, { status: 400 }, ); } if (action === "answer_choice" && !parsed.data.optionId) { return NextResponse.json({ error: "选择题请求缺少 optionId" }, { status: 400 }); } try { const applied = await applyRectificationChoice(accounting, { userId, caseId, sessionId, actionId, action: action === "stop_and_review" ? STOP_ACTION : CHOICE_ACTION, focusId, questionId: parsed.data.questionId, probeId: parsed.data.probeId ?? null, optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!, expectedRevision, }); if (action === "stop_and_review") { await transitionV9CaseStatus(accounting, userId, caseId, "paused"); } return NextResponse.json({ type: "choice.applied", action: applied.idempotent ? "replayed" : "applied", replayed: applied.idempotent, status: applied.status, narrationPersisted: applied.narrationPersisted, focusId: applied.focusId, questionId: applied.questionId, optionId: applied.optionId, probeId: applied.probeId, caseRevision: applied.revision, narration: applied.narration, userMessage: applied.userDisplay, sourceQuote: applied.sourceQuote, derivedContext: applied.derivedContext, nextAction: applied.nextAction, nextInterviewPersisted: applied.nextInterviewPersisted, nextChoiceReady: applied.nextChoiceReady, }); } catch (error) { if (error instanceof RectificationToolServiceError) { const mapped = mapRectificationRpcError(error); return NextResponse.json( { error: mapped.message, message: mapped.message, code: mapped.code }, { status: mapped.status }, ); } return NextResponse.json( { error: "选择题处理失败", message: "请稍后重试。" }, { status: 500 }, ); } } const selectedModel = await resolveSessionLanguageModel( chatSession.model_id, chatSession.model_config_version, ); if (!selectedModel) { return NextResponse.json( { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, { status: 409 }, ); } if (action === "message") { try { const dossier = await loadV9CaseDossier(accounting, userId, caseId); const focus = dossier.conversationSummary.activeFocus; const choice = focus ? parseAgentChoiceCopy(focus.expectedAnswerSchema) : null; if (focus && choice) { let classified = null; try { classified = await classifyRectificationTurnIntent(selectedModel, { focus, userMessage: parsed.data.message ?? "", caseStatus, signal: request.signal, }); } catch { classified = null; } if (!classified || classified.intent === "unclear") { const narration = "我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。"; await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, assistantMessage: narration, }); return completedMessageResponse(narration, requestId, caseId); } if (classified.intent === "answer_current_focus") { if (!classified.answer_class) { return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId); } const optionId = optionIdForAnswerClass(focus, classified.answer_class); if (!optionId) { return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId); } const continueToAgent = shouldContinueAgentForDatedEvent(classified); const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, caseId, sessionId, actionId: requestId, action: CHOICE_ACTION, focusId: focus.id, questionId: focus.questionId, probeId: typeof focus.expectedAnswerSchema.probe_id === "string" ? focus.expectedAnswerSchema.probe_id : null, optionId, expectedRevision: previous?.revision ?? 0, userDisplay: parsed.data.message ?? null, deferFollowup: continueToAgent, }); if (!continueToAgent) { return completedMessageResponse(applied.narration, requestId, caseId); } } if (classified.intent === "stop_rectification") { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, caseId, sessionId, actionId: requestId, action: STOP_ACTION, focusId: focus.id, questionId: focus.questionId, probeId: typeof focus.expectedAnswerSchema.probe_id === "string" ? focus.expectedAnswerSchema.probe_id : null, optionId: "stop", expectedRevision: previous?.revision ?? 0, userDisplay: parsed.data.message ?? null, }); await transitionV9CaseStatus(accounting, userId, caseId, "paused"); return completedMessageResponse(applied.narration, requestId, caseId); } } else if (focus && isCollectFocusSchema(focus.expectedAnswerSchema)) { let classified = null; try { classified = await classifyRectificationTurnIntent(selectedModel, { focus, userMessage: parsed.data.message ?? "", caseStatus, signal: request.signal, }); } catch { classified = null; } if (shouldDeclineCollectFocus(classified)) { const continueToAgent = shouldContinueAgentForDatedEvent(classified); const applied = await applyCollectFocusDenial(accounting, { userId, caseId, focusId: focus.id, deferFollowup: continueToAgent, }); if (!continueToAgent) { await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, assistantMessage: applied.narration, }); return completedMessageResponse(applied.narration, requestId, caseId); } } } else { let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(accounting, userId, caseId); birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; } catch { // Ranking stays fail-open when compute is unavailable. } const decision = decideFromDossier(dossier, { birthDate }); if (decision.nextAction === "ask_candidate_discriminator") { const catalog = rectificationFollowupCatalog( dossier.latestResult, dossier.evidence, ); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, birthDate, candidatesSeparated: false, }); const persisted = await persistServerOwnedFocus({ accounting, userId, caseId, activeFocus: focus, decisionReceipt: dossier.latestResult?.decisionReceipt, followup: plan.next_followup, }); const open = openQuestionFromPersistedFocus(persisted); if (open && persisted.focus && open.unrenderable !== true) { let classified = null; try { classified = await classifyRectificationTurnIntent(selectedModel, { focus: persisted.focus, userMessage: parsed.data.message ?? "", caseStatus, signal: request.signal, }); } catch { classified = null; } if (classified?.intent === "answer_current_focus" && classified.answer_class) { const optionId = optionIdForAnswerClass(persisted.focus, classified.answer_class); if (optionId) { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { userId, caseId, sessionId, actionId: requestId, action: CHOICE_ACTION, focusId: persisted.focus.id, questionId: persisted.focus.questionId, probeId: typeof persisted.focus.expectedAnswerSchema.probe_id === "string" ? persisted.focus.expectedAnswerSchema.probe_id : null, optionId, expectedRevision: previous?.revision ?? 0, userDisplay: parsed.data.message ?? null, }); return completedMessageResponse(applied.narration, requestId, caseId); } } const narration = "请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。"; await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, assistantMessage: narration, }); return completedMessageResponse(narration, requestId, caseId); } if (!plan.next_followup) { await persistV9DeterministicTurn(accounting, userId, caseId, { requestId, userMessage: parsed.data.message ?? null, assistantMessage: DISCRIMINATOR_EXHAUSTED_NARRATION, }); return completedMessageResponse(DISCRIMINATOR_EXHAUSTED_NARRATION, requestId, caseId); } } } } catch (error) { if (error instanceof RectificationToolServiceError) { const mapped = mapRectificationRpcError(error); return NextResponse.json( { error: mapped.message, message: mapped.message, code: mapped.code }, { status: mapped.status }, ); } console.error(`[rectification-v9] focus answer failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`); return NextResponse.json( { error: "校正服务暂时不可用", message: "请稍后重试。", code: "rectification_service_failed" }, { status: 500 }, ); } } const requestTime = new Date(); const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000) .toISOString() .replace("T", " ") .slice(0, 19); const timeContext = `服务端当前时间(权威):${requestTime.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; const encoder = new TextEncoder(); const body = new ReadableStream({ async start(controller) { let closed = false; const send = (event: Record) => { if (closed) return; const safe = safePublicEvent(event); if (!safe) return; controller.enqueue(encoder.encode(`${JSON.stringify(safe)}\n`)); }; const billing: V9RunBilling = { async reserve() { // opening / read-only turns are free; the first substantive run // reserves once, and resume/retry reuse the same case-bound request. if (action !== "message") { return { success: true, status: 200 }; } try { const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); const result = await authorizeUsage(accounting, { userId, requestId: billingRequestId, featureKey: "rectification", requestedModelId: selectedModel.id, creditCost: selectedModel.creditCost, }); if (result.success) return { success: true, status: 200 }; return { success: false, reason: result.reason ?? "billing_denied", status: result.reason === "insufficient_credits" ? 402 : 503, }; } catch (error) { console.error(`[rectification-v9] reserve failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`); return { success: false, reason: "billing_unavailable", status: 503 }; } }, async complete(usage) { if (action !== "message") return true; try { const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); const settlement = await completeUsage(accounting, userId, billingRequestId, { eventKey: requestId, actualModelId: selectedModel.id, modelConfigVersion: selectedModel.configVersion, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, costMicrousd: Math.round(( usage.inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0) + usage.outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0) ) / 1_000_000), durationMs: usage.durationMs, }); return settlement.success; } catch (error) { console.warn(`[rectification-v9] usage settle failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`); return false; } }, async release() { if (action !== "message") return true; try { const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId); const settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled"); return settlement.success; } catch { return false; } }, }; try { const result = await runV9AgentTurn({ userId, caseId, sessionId, requestId, action: actionToBudget(action), message: action === "message" ? parsed.data.message ?? "" : null, messageOrigin: isRectificationMessageOrigin(parsed.data.origin) ? parsed.data.origin : defaultMessageOrigin(action), clientActionId: parsed.data.clientActionId ?? requestId, modelName: selectedModel.id, skillName: RECTIFICATION_SKILL_NAME, skillVersion: skillVersion || RECTIFICATION_SKILL_VERSION, accounting: accounting as never, billing, emit: (event) => send(event), signal: request.signal, timeContext, generationModel: selectedModel.model, buildAgent: async (turnId, skillPackage, attemptId) => { let decision; try { const dossier = await loadV9CaseDossier(accounting as never, userId, caseId); let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(accounting as never, userId, caseId); birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; } catch { // Ranking stays fail-open when compute is unavailable. } decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), birthDate, }); } catch { decision = undefined; } return getRectificationV9Agent(selectedModel, { userId, caseId, turnId, attemptId, userMessage: action === "message" ? parsed.data.message ?? null : null, accounting: accounting as never, decision, }, skillPackage); }, }); if (!result.ok) { send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); } else { if (action === "message") { 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"}`, ); } } send({ type: "done", emitted: true }); } } catch (error) { const code = error instanceof RectificationToolServiceError ? error.code : "run_failed"; console.error(`[rectification-v9] run failed case=${caseId} code=${code}`); if (code === "billing_denied" || code.includes("insufficient")) { send({ type: "error", code: "billing_denied", message: "咨询点数不足,请先兑换后再继续。" }); } else if (code.includes("legacy_skill_identity_unverifiable")) { send({ type: "error", code: "skill_identity_unverifiable", message: "该校正绑定的是无法核验的历史 Skill,请先采用当前注册版本。" }); } else if (code.includes("skill_identity_missing")) { send({ type: "error", code: "skill_identity_missing", message: "该校正绑定的 Skill 版本不可用,请联系支持人员。" }); } else if (code.includes("skill_identity_mismatch")) { send({ type: "error", code: "skill_identity_mismatch", message: "校正记录与 Skill 版本绑定不一致,请先刷新后重试。" }); } else { send({ type: "error", code: "run_failed", message: "生时校正暂时不可用,请稍后再试。" }); } } finally { closed = true; try { controller.close(); } catch { // already closed } } }, }); return new Response(body, { headers: { "cache-control": "no-cache, no-transform", "content-type": "application/x-ndjson; charset=utf-8", "x-accel-buffering": "no", "x-ayanam-request-id": requestId, "x-rectification-case-id": caseId, }, }); }