Files
Jyotisha/frontend/src/app/api/consult/route.ts
T
2026-08-11 11:59:14 +08:00

562 lines
20 KiB
TypeScript

import { NextResponse } from "next/server";
import {
consultationInputSchema,
consultationWorkflowReceipt,
getGeneralJyotishAgent,
getJyotishAgent,
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { parseAgentReply } from "@/lib/agent-reply";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} from "@/lib/consultation-entrypoint";
import { CreditRpcError } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import {
applyBirthTimeModeToWorkflowContext,
consultationBirthTimeModeSchema,
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
type ConsultationBirthTimeMode,
} from "@/lib/consultation-birth-time-mode";
import {
ConsultationProfileTruthError,
prepareConsultationRoute,
} from "@/lib/consultation-route-service";
import { z } from "zod";
export const runtime = "nodejs";
export const maxDuration = 60;
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64),
name: z.string().trim().max(80).optional().default(""),
history: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
}),
)
.max(20)
.default([]),
});
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
}).strict();
const generalChatRequestSchema = z.object({
...chatRequestMetadataSchema.shape,
consultationMode: z.literal("general_no_birth_time"),
question: z.string().trim().min(1).max(500),
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
entrypoint: z.undefined().optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const consultationReservationSchema = z.object({
success: z.boolean(),
reservation_id: z.string().uuid().nullable(),
source: z.enum(["subscription", "credits"]).nullable(),
credits: z.number().int().nullable(),
subscription_id: z.string().uuid().nullable(),
reason: z.string().nullable(),
retry_after_seconds: z.number().int().nullable(),
});
const consultationCompletionSchema = z.object({
success: z.boolean(),
credits: z.number().int().nullable(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
const detachedSettlementAttempts = 3;
// ponytail: Staging MVP ceiling—without a queue/worker, detached settlement gets only three short retries.
async function retryDetachedSettlement<T>(action: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= detachedSettlementAttempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
if (attempt < detachedSettlementAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
}
throw lastError;
}
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
.replace("T", " ")
.slice(0, 19);
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession || chatSession.session_type !== "consultation") {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
{
error: "旧版生时校正入口已停用",
message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。",
},
{ status: 409 },
);
}
const requestTime = new Date();
const resolvedQuestion = resolveConsultationQuestion({
visibleQuestion: parsed.data.question,
entrypoint: parsed.data.entrypoint,
currentDate: chinaCalendarDate(requestTime),
});
const userId = user.id;
const requestId = parsed.data.requestId;
const sessionId = parsed.data.sessionId;
const consultationTheme = parsed.data.theme;
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
.filter((message) => message.role === "user")
.map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
{
error: "无法处理该请求",
message:
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
},
{ status: 400 },
);
}
let prepared;
try {
prepared = await prepareConsultationRoute({
userId,
mode: parsed.data.consultationMode,
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", profileUserId)
.single();
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
return data;
},
reserve: () => reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
const { data, error } = await accounting.rpc("reserve_consultation_usage", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_requested_model_id: model.id,
p_credit_cost: model.creditCost,
});
const reservation = consultationReservationSchema.safeParse(first(data ?? []));
if (error || !reservation.success) {
throw new CreditRpcError(error?.message || "invalid_reservation_response");
}
return {
success: reservation.data.success,
credits: reservation.data.credits,
error_code: reservation.data.reason,
};
},
),
});
} catch (error) {
if (error instanceof ConsultationProfileTruthError) {
const modeChanged = error.code === "mode_changed";
return NextResponse.json(
modeChanged
? {
error: "出生时间状态已经变化",
message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。",
}
: {
error: "暂时无法核对完整出生资料",
message: "出生日期、时间来源或出生地点资料不完整或不一致,请重新保存后再试,本次不会扣点。",
},
{ status: modeChanged ? 409 : 503 },
);
}
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[billing] reservation failed request=${requestId} reason=${reason}`,
);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
const modelSelection = prepared.reservation;
if (modelSelection.status === "unavailable") {
return NextResponse.json(
{
error: "模型暂不可用",
message: "请选择其他模型后重新发送,本次不会扣除点数。",
},
{ status: 409 },
);
}
const selectedModel = modelSelection.model;
const reserveResult = modelSelection.reservation;
if (!reserveResult.success) {
const insufficient = reserveResult.error_code === "insufficient_credits";
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient
? "请先兑换咨询点数后再继续。"
: reserveResult.error_code || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
}
async function cancel() {
try {
const result = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("cancel_consultation_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const parsedCancellation = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCancellation.success) {
throw new CreditRpcError(error?.message || "invalid_cancellation_response");
}
return parsedCancellation.data;
});
if (!result.success && result.error_code !== "request_completed") {
throw new CreditRpcError(result.error_code || "cancellation_rejected");
}
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[billing] cancellation failed request=${requestId} reason=${reason}`,
);
}
}
const usageStartedAt = Date.now();
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
return {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
};
}
async function completeResponse(
rawTransformedText: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
techniqueTruth: string,
workflowReceipt: {
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
},
) {
try {
const reply = parseAgentReply(rawTransformedText, consultationTheme);
if (!reply.text) throw new Error("empty_agent_reply");
const responseMessage = {
role: "assistant" as const,
text: reply.text,
suggestions: reply.suggestions,
techniqueTruth,
workflowReceipt,
};
const actualUsage = await usagePayload(usage);
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_response", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCompletion.success) {
throw new CreditRpcError(error?.message || "invalid_completion_response");
}
return parsedCompletion.data;
});
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected");
}
} catch (error) {
await cancel();
throw error;
}
}
let settlement: Promise<void> | null = null;
function settle(action: () => Promise<void>) {
settlement ??= action();
return settlement;
}
try {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
"当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
]);
const workflowReceipt = {
route: "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
} as const;
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
"not-applicable",
workflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": "not-applicable",
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers.join(","),
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
"not-applicable",
workflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// Unverified use is still a normal chart calculation with a hard answer
// boundary. It must never reactivate the retired rectification questionnaire.
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
theme: parsed.data.theme,
});
const workflowContext = applyBirthTimeModeToWorkflowContext(
await runConsultationWorkflow(toolInput, { foreground: true }),
consultationMode,
);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
resolvedQuestion.modelQuestion,
"\n需要查询星盘时,使用以下经过服务端校验的工具参数:",
JSON.stringify(toolInput),
].filter(Boolean).join("\n"),
},
]);
const responseWorkflowReceipt = {
route: workflowReceipt.route,
status: workflowReceipt.status,
preciseTiming: workflowReceipt.preciseTiming,
missingLayers: workflowReceipt.missingLayers === "none"
? []
: workflowReceipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean),
};
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
workflowReceipt.preciseTiming !== "blocked",
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
} catch (error) {
await cancel();
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
);
return NextResponse.json(
{
error: "暂时无法生成解读",
message: "咨询服务暂时不可用,请稍后再试。",
recovery: "稍后重试,或换一个模型继续。",
},
{ status: 503 },
);
}
}