Files
Jyotisha/frontend/src/app/api/rectification/agent/route.ts
T
Jesse_Chen 6a44c778c3 fix(web): show live agent work progress and fail truncated rectification answers
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>
2026-08-21 12:39:09 +08:00

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,
},
});
}