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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user