6a44c778c3
Rectification dropped tool.activity started events and treated length finishes as completed. Share generation settings with consultation, keep the activity line through streaming, and name multi-domain chart calculation. Co-authored-by: Cursor <cursoragent@cursor.com>
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
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 { 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";
|
|
|
|
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"]),
|
|
message: z.string().trim().min(1).max(4000).optional(),
|
|
modelId: z.string().trim().min(1).max(64).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<ReturnType<typeof createAdminSupabaseClient>>,
|
|
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;
|
|
|
|
// 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 },
|
|
);
|
|
}
|
|
|
|
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<Uint8Array>({
|
|
async start(controller) {
|
|
let closed = false;
|
|
const send = (event: Record<string, unknown>) => {
|
|
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,
|
|
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,
|
|
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,
|
|
},
|
|
});
|
|
}
|