Files
Jyotisha/frontend/src/app/api/rectification/agent/route.ts
T
2026-08-06 20:15:08 +08:00

440 lines
17 KiB
TypeScript

import { NextResponse } from "next/server";
import { z } from "zod";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage } from "@/lib/chat-message-view";
import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
AgenticRectificationProfileError,
acceptAgenticRectificationCandidate,
createAgenticRectificationContext,
loadAgenticRectificationProfile,
loadLatestAgenticRectificationResult,
} from "@/lib/rectification-agentic/session";
export const runtime = "nodejs";
export const maxDuration = 120;
const agenticRectificationConversationFields = {
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().max(80).optional().default(""),
history: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
}),
)
.max(30)
.default([]),
};
const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
z.object({
...agenticRectificationConversationFields,
action: z.literal("opening"),
}).strict(),
z.object({
...agenticRectificationConversationFields,
action: z.literal("message"),
message: z.string().trim().min(1).max(4000),
}).strict(),
z.object({
action: z.literal("accept_candidate"),
sessionId: z.string().uuid(),
resultId: z.string().uuid(),
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
}).strict(),
]);
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
const agenticRectificationMaxSteps = 8;
function readPersistedMessages(value: unknown): ChatMessage[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item): ChatMessage[] => {
if (!item || typeof item !== "object") return [];
const message = item as Partial<ChatMessage>;
if ((message.role !== "user" && message.role !== "assistant") || typeof message.text !== "string") return [];
return [{
role: message.role,
text: message.text.slice(0, 100_000),
...(Array.isArray(message.suggestions)
? { suggestions: message.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string").slice(0, 3) }
: {}),
}];
});
}
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}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
async function rectificationBillingRequestId(
accounting: ReturnType<typeof createAdminSupabaseClient>,
userId: string,
sessionId: string,
) {
const billingRequestPrefix = `rectification:${sessionId}`;
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}`;
}
export async function GET(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "请求格式不正确" }, { status: 400 });
const { data: session, error } = await supabase
.from("chat_sessions")
.select("id,session_type")
.eq("id", sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (error) return NextResponse.json({ error: "暂时无法读取生时校正会话" }, { status: 503 });
if (!session || session.session_type !== "birth_time_rectification") return NextResponse.json({ error: "生时校正会话不存在" }, { status: 404 });
try {
return NextResponse.json({ result: await loadLatestAgenticRectificationResult(accounting, user.id, sessionId) });
} catch {
return NextResponse.json({ error: "暂时无法读取候选结果" }, { status: 503 });
}
}
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 = agenticRectificationRequestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json(
{ error: "请求格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const promptSource = parsed.data.action === "accept_candidate" ? "" : [
parsed.data.action === "message" ? parsed.data.message : "",
...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(promptSource)) {
return NextResponse.json(
{ error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续描述人生事件。" },
{ status: 400 },
);
}
const userId = user.id;
const requestTime = new Date();
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,messages,session_type,model_id,model_config_version")
.eq("id", parsed.data.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 (parsed.data.action === "accept_candidate") {
const accepted = await acceptAgenticRectificationCandidate(
accounting,
userId,
parsed.data.sessionId,
parsed.data.time,
parsed.data.resultId,
);
if (!accepted.ok) {
return NextResponse.json(
{ error: "暂时无法采用该候选时间", message: accepted.reason },
{ status: 409 },
);
}
return NextResponse.json(accepted);
}
const conversation = parsed.data;
const requestId = conversation.requestId;
const persistedMessages = readPersistedMessages(chatSession.messages);
if (conversation.action === "opening" && persistedMessages.length > 0) {
return NextResponse.json(
{ code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" },
{ status: 409 },
);
}
let profile;
try {
profile = await loadAgenticRectificationProfile(accounting, userId);
} catch (error) {
if (error instanceof AgenticRectificationProfileError) {
if (error.code === "profile_unavailable") {
return NextResponse.json(
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
{ status: 503 },
);
}
return NextResponse.json(
{
code: "profile_incomplete",
error: "出生资料尚未完成",
message: "请先完成出生日期、出生时间线索和出生地点资料。",
},
{ status: 409 },
);
}
return NextResponse.json(
{ error: "暂时无法核对出生资料", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession.model_id || (conversation.modelId && conversation.modelId !== chatSession.model_id)) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新生时校正会话后重试,本次不会扣除点数。" },
{ status: 409 },
);
}
const selectedModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!selectedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
);
}
let reserveResult;
let billingRequestId: string;
try {
billingRequestId = await rectificationBillingRequestId(
accounting,
userId,
conversation.sessionId,
);
reserveResult = await authorizeUsage(accounting, {
userId,
requestId: billingRequestId,
featureKey: "rectification",
requestedModelId: selectedModel.id,
creditCost: selectedModel.creditCost,
});
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!reserveResult.success) {
const insufficient = reserveResult.reason === "insufficient_credits";
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.reason || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
}
const ctx = createAgenticRectificationContext(accounting, userId, profile, conversation.sessionId);
const agent = getAgenticRectificationAgent(selectedModel, ctx);
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
let emitted = false;
let raw = "";
let settled = false;
const usageStartedAt = Date.now();
const settle = async (complete: boolean, usage?: Promise<{ inputTokens?: number; outputTokens?: number }>) => {
if (settled) return true;
settled = true;
try {
let settlement;
if (complete) {
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved?.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved?.outputTokens ?? 0));
settlement = await completeUsage(accounting, userId, billingRequestId, {
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,
});
} else {
settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled");
}
if (!settlement.success) throw new Error(settlement.error_code ?? "usage_settlement_failed");
return true;
} catch (error) {
const reason = error instanceof Error ? error.message : "UnknownError";
console.warn(`[agentic-rectification] usage settle failed request=${requestId} complete=${complete} reason=${reason}`);
return false;
}
};
const send = (event: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
};
try {
const result = await agent.stream(
[
...conversation.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),
conversation.name ? `用户称呼:${conversation.name}` : "",
conversation.action === "opening" ? openingContext : conversation.message,
].filter(Boolean).join("\n"),
},
],
{ maxSteps: agenticRectificationMaxSteps },
);
for await (const chunk of result.textStream) {
if (/\S/.test(chunk)) emitted = true;
raw += chunk;
send({ type: "delta", text: chunk });
}
const reply = parseAgentReply(raw, "general");
if (!emitted || !reply.text) {
console.warn(`[agentic-rectification] empty response request=${requestId}`);
send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" });
await settle(false);
controller.close();
return;
}
const requestHistory = conversation.history.map((message) => ({
role: message.role,
text: message.text,
} satisfies ChatMessage));
const baseMessages = requestHistory.length > persistedMessages.length
? requestHistory
: persistedMessages;
const nextMessages: ChatMessage[] = [
...baseMessages,
...(conversation.action === "message"
? [{ role: "user" as const, text: conversation.message }]
: []),
{ role: "assistant" as const, text: reply.text, suggestions: reply.suggestions },
].slice(-500);
const { data: savedSession, error: saveError } = await supabase
.from("chat_sessions")
.update({ messages: nextMessages, updated_at: new Date().toISOString() })
.eq("id", conversation.sessionId)
.eq("user_id", userId)
.eq("session_type", "birth_time_rectification")
.select("id")
.maybeSingle();
if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError");
try {
const candidateResult = await loadLatestAgenticRectificationResult(accounting, userId, conversation.sessionId);
if (candidateResult) send({ type: "candidates", result: candidateResult });
} catch {
console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`);
}
if (!await settle(true, result.totalUsage)) {
send({ type: "error", message: "生时校正回复已生成,但用量结算失败,请稍后重试。" });
controller.close();
return;
}
send({ type: "done", emitted: true });
controller.close();
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[agentic-rectification] generation failed request=${requestId} reason=${reason}`);
try {
send({ type: "error", message: "生时校正暂时不可用,请稍后再试。" });
} catch {
// controller may already be errored
}
await settle(false);
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,
},
});
}