import { NextResponse } from "next/server"; import { z } from "zod"; import { getRectificationV9Agent, type RectificationAgentAction } from "@/mastra/agentic-rectification"; import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service"; import { applyRectificationChoice } 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 { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; export const runtime = "nodejs"; export const maxDuration = 120; 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(), 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 questionId = parsed.data.questionId; const expectedRevision = parsed.data.expectedRevision; if (!actionId || !questionId || expectedRevision === undefined) { return NextResponse.json( { error: "选择题请求缺少 actionId、questionId 或 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, questionId, probeId: parsed.data.probeId ?? null, optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!, expectedRevision, }); return NextResponse.json({ type: "choice.applied", action: applied.idempotent ? "replayed" : "applied", replayed: applied.idempotent, status: applied.status, narrationPersisted: applied.narrationPersisted, questionId: applied.questionId, optionId: applied.optionId, probeId: applied.probeId, caseRevision: applied.revision, narration: applied.narration, userMessage: applied.userDisplay, sourceQuote: applied.sourceQuote, derivedContext: applied.derivedContext, }); } 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 }, ); } 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: (turnId, skillPackage, attemptId) => Promise.resolve( getRectificationV9Agent(selectedModel, { userId, caseId, turnId, attemptId, userMessage: action === "message" ? parsed.data.message ?? null : null, accounting: accounting as never, }, skillPackage), ), }); if (!result.ok) { send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); } else { 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, }, }); }