feat(rectification): stream agent execution and redesign entry routing

Replace the Direct Agentic textStream relay with a durable V9 agent runtime:

- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
  ->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
  per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
  accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
  revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
  accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
  only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
  accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
  Case<->Session binding verified server-side; client history never overrides
  the durable dossier; pending turn -> completed/failed/retryable; consumes
  result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
  metadata/tool payloads/birth data/scores never forwarded); first-turn real
  skill.started/skill.loaded gate with one controlled retry; billing bound to
  rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
  case dossier/compute, turn finalize, fingerprint-cached candidate persist,
  case-scoped accept, consent-gated confirm, guarded transitions,
  needs_rebaseline profile guard, run_phases receipt table, and the
  rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
  (openRectificationFromHomepage/openRectificationSession/startNewRectification)
  with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
  by entry-summary; chat restores from persisted turns, candidate cards from
  the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
  direct durable candidate-accept endpoint for the UI cards.
This commit is contained in:
Jesse
2026-08-11 17:17:48 +08:00
parent d394dd0585
commit ca7484666f
14 changed files with 4367 additions and 558 deletions
+198 -311
View File
@@ -1,91 +1,41 @@
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 { 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 { 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(),
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(),
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([]),
};
}).strict();
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}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
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: ReturnType<typeof createAdminSupabaseClient>,
accounting: Awaited<ReturnType<typeof createAdminSupabaseClient>>,
userId: string,
sessionId: string,
caseId: string,
) {
const billingRequestPrefix = `rectification:${sessionId}`;
const billingRequestPrefix = `rectification:case:${caseId}`;
const { data, error } = await accounting
.from("usage_reservations")
.select("request_id,status")
@@ -102,43 +52,23 @@ async function rectificationBillingRequestId(
: `${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 });
}
}
/**
* 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: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
let supabase;
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ error: "服务尚未配置", message: "请先配置数据库环境变量。" },
{ status: 503 },
);
}
@@ -148,15 +78,10 @@ export async function POST(request: Request) {
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始生时校正。" },
{ status: 401 },
);
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const parsed = agenticRectificationRequestSchema.safeParse(
await request.json().catch(() => null),
);
const parsed = agentRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: "请求格式不正确", details: parsed.error.flatten() },
@@ -164,9 +89,8 @@ export async function POST(request: Request) {
);
}
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),
const promptSource = [
parsed.data.action === "message" ? parsed.data.message ?? "" : "",
].join("\n");
if (blocksPromptExtraction(promptSource)) {
return NextResponse.json(
@@ -176,84 +100,82 @@ export async function POST(request: Request) {
}
const userId = user.id;
const requestTime = new Date();
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")
.eq("id", parsed.data.sessionId)
.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 },
);
return NextResponse.json({ error: "暂时无法读取会话", message: "请稍后重试。" }, { status: 503 });
}
if (!chatSession || chatSession.session_type !== "birth_time_rectification") {
return NextResponse.json(
{ error: "生时校正会话不存在", message: "请重新进入生时校正。" },
{ status: 404 },
);
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) {
if (chatSession.agentic_rectification_case_id !== caseId) {
return NextResponse.json(
{ code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" },
{ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" },
{ 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,
@@ -265,160 +187,124 @@ export async function POST(request: Request) {
);
}
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 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 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, {
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 === "opening" || action === "read_only") {
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) {
try {
const billingRequestId = await rectificationBillingRequestId(accounting, userId, caseId);
const settlement = await completeUsage(accounting, userId, billingRequestId, {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
usage.inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ usage.outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
durationMs: usage.durationMs,
});
} else {
settlement = await releaseUsage(accounting, userId, billingRequestId, "rectification_cancelled");
return settlement.success;
} catch (error) {
console.warn(`[rectification-v9] usage settle failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`);
return false;
}
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`));
},
async release() {
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 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 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,
buildAgent: (turnId) => Promise.resolve(
getRectificationV9Agent(selectedModel, {
userId,
caseId,
turnId,
accounting: accounting as never,
}),
),
});
if (!result.ok) {
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
} else {
send({ type: "done", emitted: true });
}
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 {
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", message: "咨询点数不足,请先兑换后再继续。" });
} else {
send({ type: "error", message: "生时校正暂时不可用,请稍后再试。" });
} catch {
// controller may already be errored
}
await settle(false);
} finally {
closed = true;
try {
controller.close();
} catch {
@@ -434,6 +320,7 @@ export async function POST(request: Request) {
"content-type": "application/x-ndjson; charset=utf-8",
"x-accel-buffering": "no",
"x-ayanam-request-id": requestId,
"x-rectification-case-id": caseId,
},
});
}
@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
const acceptSchema = z.object({
sessionId: z.string().uuid(),
resultId: z.string().uuid(),
candidateId: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
requestId: z.string().uuid(),
}).strict();
function errorResponse(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const known = [
["agentic_rectification_candidate_not_found", 404, "候选记录不存在或已过期"],
["agentic_rectification_candidate_expired", 409, "候选结果已过期,请重新比较"],
["agentic_rectification_candidate_selection_blocked", 409, "当前还不能采用候选"],
["agentic_rectification_candidate_time_not_allowed", 409, "该时间不在当前候选内"],
["agentic_rectification_candidate_already_selected", 409, "该时间已采用"],
["agentic_rectification_candidate_superseded", 409, "已有更新的候选结果"],
["agentic_rectification_candidate_profile_changed", 409, "出生资料已变化,候选已失效"],
["agentic_rectification_case_terminal", 409, "该校正已结束"],
["agentic_rectification_case_not_found", 404, "校正记录不存在或无权访问"],
] as const;
for (const [code, status, text] of known) {
if (message.includes(code)) {
return NextResponse.json({ error: text, code: code.replace("agentic_rectification_", "") }, { status });
}
}
return NextResponse.json(
{ error: "暂时无法采用该候选时间", code: "candidate_accept_failed" },
{ status: 503 },
);
}
/**
* POST /api/rectification/cases/[caseId]/candidates/accept
*
* Durable, case-scoped candidate adoption driven by the UI candidate card.
* accepted is never upgraded to confirmed; the confirmation gate lives in the
* agent confirm-birth-time tool / confirm RPC.
*/
export async function POST(request: Request, context: RouteContext) {
let supabase;
let accounting;
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 { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const parsed = acceptSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求内容不正确", code: "invalid_accept_request" }, { status: 400 });
}
// Exact Case/Session binding before any write.
const { data: caseRow, error: caseError } = await accounting.rpc(
"get_agentic_rectification_case",
{ p_user_id: user.id, p_case_id: caseId },
);
if (caseError) {
return errorResponse(new RectificationToolServiceError(caseError.message));
}
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 !== parsed.data.sessionId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
try {
const { data, error } = await accounting.rpc(
"accept_agentic_rectification_candidate_for_case",
{
p_user_id: user.id,
p_case_id: caseId,
p_result_id: parsed.data.resultId,
p_time: parsed.data.candidateId,
},
);
if (error) throw new RectificationToolServiceError(error.message);
const row = Array.isArray(data) ? data[0] : data;
if (!row || typeof row !== "object" || (row as { success?: unknown }).success !== true) {
return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 });
}
const result = row as Record<string, unknown>;
return NextResponse.json({
ok: true,
saved_time: result.saved_time,
status: result.status === "confirmed" ? "confirmed" : "accepted",
result_id: result.result_id,
case_status: result.case_status,
idempotent: result.idempotent === true,
});
} catch (error) {
return errorResponse(error);
}
}
@@ -3,22 +3,26 @@ import { z } from "zod";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
RectificationCaseServiceError,
getRectificationCase,
mapRectificationRpcError,
} from "@/lib/rectification-agentic/v9/case-service";
loadV9CaseDossier,
loadV9TurnReceipt,
receiptStatusFromTurn,
RectificationToolServiceError,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ caseId: string }> };
/**
* GET /api/rectification/cases/[caseId]
* GET /api/rectification/cases/[caseId]?sessionId=...
*
* Sanitized case projection (never the baseline birth snapshot). Terminal
* cases are served read-only.
* Sanitized, durable refresh payload: case projection + persisted turns +
* evidence ledger + latest candidate snapshot + per-turn execution receipts.
* The browser restores real history from here; it never reconstructs history
* from candidate text or local sentinels.
*/
export async function GET(_request: Request, context: RouteContext) {
export async function GET(request: Request, context: RouteContext) {
let supabase;
let accounting;
try {
@@ -37,26 +41,85 @@ export async function GET(_request: Request, context: RouteContext) {
const { caseId } = await context.params;
if (!z.string().uuid().safeParse(caseId).success) {
return NextResponse.json(
{ error: "请求内容不正确", code: "invalid_case_id" },
{ status: 400 },
);
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
}
const sessionId = new URL(request.url).searchParams.get("sessionId") ?? "";
try {
return NextResponse.json(
await getRectificationCase(accounting, user.id, caseId),
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
if (sessionId && dossier.case.sessionId !== sessionId) {
return NextResponse.json({ error: "校正记录与会话绑定不一致", code: "case_session_mismatch" }, { status: 409 });
}
const receipts = await Promise.all(
dossier.turns.map(async (turn) => {
try {
return await loadV9TurnReceipt(accounting, user.id, caseId, turn.id);
} catch {
return null;
}
}),
);
return NextResponse.json(dossierResponse(dossier, receipts));
} catch (error) {
if (error instanceof RectificationCaseServiceError) {
const view = mapRectificationRpcError(
new Error(`agentic_rectification_${error.code}`),
);
return NextResponse.json({ error: view.message, code: view.code }, { status: view.status });
if (error instanceof RectificationToolServiceError) {
const message = error.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: 500 },
{ error: "暂时无法读取校正记录", code: "rectification_service_failed" },
{ status: 503 },
);
}
}
function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
) {
return {
case: {
case_id: dossier.case.caseId,
session_id: dossier.case.sessionId,
status: dossier.case.status,
skill_name: dossier.case.skillName,
skill_version: dossier.case.skillVersion,
candidate_range: dossier.case.candidateRange,
accepted_time: dossier.case.acceptedTime,
confirmed_time: dossier.case.confirmedTime,
completed_at: dossier.case.completedAt,
closed_reason: dossier.case.closedReason,
last_activity_at: dossier.case.lastActivityAt,
},
turns: dossier.turns.map((turn) => ({
id: turn.id,
role: turn.role,
text: turn.text,
status: turn.status,
created_at: turn.createdAt,
receipt: turnReceipt(turn.id, receipts),
})),
evidence: dossier.evidence,
latest_result: dossier.latestResult,
};
}
function turnReceipt(
turnId: string,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
) {
const receipt = receipts.find((item) => item?.turnId === turnId) ?? null;
if (!receipt) return null;
return {
turn_id: receipt.turnId,
skill_name: receipt.skillName,
skill_version: receipt.skillVersion,
engine_version: receipt.engineVersion,
status: receiptStatusFromTurn(receipt.status),
phases: receipt.phases.map((phase) => phase.phase),
tools: receipt.tools,
started_at: receipt.startedAt,
completed_at: receipt.completedAt,
};
}