551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { getRectificationV9Agent, type RectificationAgentAction } from "@/mastra/agentic-rectification";
|
|
import {
|
|
loadV9CaseDossier,
|
|
persistV9DeterministicTurn,
|
|
RectificationToolServiceError,
|
|
transitionV9CaseStatus,
|
|
} from "@/lib/rectification-agentic/v9/tool-service";
|
|
import { applyRectificationChoice } from "@/lib/rectification-agentic/v9/answer-choice";
|
|
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
|
|
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
|
|
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";
|
|
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
|
|
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
|
|
import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
|
|
import {
|
|
classifyRectificationTurnIntent,
|
|
optionIdForAnswerClass,
|
|
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 240;
|
|
|
|
function completedMessageResponse(text: string, requestId: string, caseId: string) {
|
|
const body = [
|
|
JSON.stringify({ type: "answer.delta", text }),
|
|
JSON.stringify({ type: "run.completed" }),
|
|
"",
|
|
].join("\n");
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
const agentRequestSchema = z.object({
|
|
caseId: z.string().uuid(),
|
|
sessionId: z.string().uuid(),
|
|
requestId: z.string().uuid(),
|
|
action: z.enum(["opening", "message", "read_only", "answer_choice", "stop_and_review"]),
|
|
message: z.string().trim().min(1).max(4000).optional(),
|
|
modelId: z.string().trim().min(1).max(64).optional(),
|
|
actionId: z.string().uuid().optional(),
|
|
focusId: z.string().uuid().optional(),
|
|
questionId: z.string().trim().min(1).max(200).optional(),
|
|
probeId: z.string().trim().min(1).max(200).nullable().optional(),
|
|
optionId: z.enum(["A", "B", "C", "D"]).optional(),
|
|
expectedRevision: z.number().int().min(0).max(10_000).optional(),
|
|
origin: z.enum([
|
|
"typed",
|
|
"suggestion_click",
|
|
"choice_click",
|
|
"voice_input",
|
|
"retry_replay",
|
|
"system_recovery",
|
|
]).optional(),
|
|
clientActionId: z.string().uuid().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;
|
|
const isStructuredChoice = action === "answer_choice" || action === "stop_and_review";
|
|
|
|
// 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 },
|
|
);
|
|
}
|
|
|
|
if (isStructuredChoice) {
|
|
const actionId = parsed.data.actionId;
|
|
const focusId = parsed.data.focusId;
|
|
const expectedRevision = parsed.data.expectedRevision;
|
|
if (!actionId || !focusId || expectedRevision === undefined) {
|
|
return NextResponse.json(
|
|
{ error: "选择题请求缺少 actionId、focusId 或 expectedRevision" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (action === "answer_choice" && !parsed.data.optionId) {
|
|
return NextResponse.json({ error: "选择题请求缺少 optionId" }, { status: 400 });
|
|
}
|
|
try {
|
|
const applied = await applyRectificationChoice(accounting, {
|
|
userId,
|
|
caseId,
|
|
sessionId,
|
|
actionId,
|
|
action: action === "stop_and_review" ? STOP_ACTION : CHOICE_ACTION,
|
|
focusId,
|
|
questionId: parsed.data.questionId,
|
|
probeId: parsed.data.probeId ?? null,
|
|
optionId: action === "stop_and_review" ? "stop" : parsed.data.optionId!,
|
|
expectedRevision,
|
|
});
|
|
if (action === "stop_and_review") {
|
|
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
|
|
}
|
|
return NextResponse.json({
|
|
type: "choice.applied",
|
|
action: applied.idempotent ? "replayed" : "applied",
|
|
replayed: applied.idempotent,
|
|
status: applied.status,
|
|
narrationPersisted: applied.narrationPersisted,
|
|
focusId: applied.focusId,
|
|
questionId: applied.questionId,
|
|
optionId: applied.optionId,
|
|
probeId: applied.probeId,
|
|
caseRevision: applied.revision,
|
|
narration: applied.narration,
|
|
userMessage: applied.userDisplay,
|
|
sourceQuote: applied.sourceQuote,
|
|
derivedContext: applied.derivedContext,
|
|
nextAction: applied.nextAction,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof RectificationToolServiceError) {
|
|
const mapped = mapRectificationRpcError(error);
|
|
return NextResponse.json(
|
|
{ error: mapped.message, message: mapped.message, code: mapped.code },
|
|
{ status: mapped.status },
|
|
);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: "选择题处理失败", message: "请稍后重试。" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
const selectedModel = await resolveSessionLanguageModel(
|
|
chatSession.model_id,
|
|
chatSession.model_config_version,
|
|
);
|
|
if (!selectedModel) {
|
|
return NextResponse.json(
|
|
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
if (action === "message") {
|
|
try {
|
|
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
|
|
const focus = dossier.conversationSummary.activeFocus;
|
|
if (focus) {
|
|
const choice = parseAgentChoiceCopy(focus.expectedAnswerSchema);
|
|
if (!choice) {
|
|
return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId);
|
|
}
|
|
let classified = null;
|
|
try {
|
|
classified = await classifyRectificationTurnIntent(selectedModel, {
|
|
focus,
|
|
userMessage: parsed.data.message ?? "",
|
|
caseStatus,
|
|
signal: request.signal,
|
|
});
|
|
} catch {
|
|
classified = null;
|
|
}
|
|
if (!classified || classified.intent === "unclear") {
|
|
const narration = "我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。";
|
|
await persistV9DeterministicTurn(accounting, userId, caseId, {
|
|
requestId,
|
|
userMessage: parsed.data.message ?? null,
|
|
assistantMessage: narration,
|
|
});
|
|
return completedMessageResponse(narration, requestId, caseId);
|
|
}
|
|
if (classified.intent === "answer_current_focus") {
|
|
if (!classified.answer_class) {
|
|
return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId);
|
|
}
|
|
const optionId = optionIdForAnswerClass(focus, classified.answer_class);
|
|
if (!optionId) {
|
|
return completedMessageResponse("当前问题已更新,请刷新后重新作答。", requestId, caseId);
|
|
}
|
|
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
|
const applied = await applyRectificationChoice(accounting, {
|
|
userId,
|
|
caseId,
|
|
sessionId,
|
|
actionId: requestId,
|
|
action: CHOICE_ACTION,
|
|
focusId: focus.id,
|
|
questionId: focus.questionId,
|
|
probeId: typeof focus.expectedAnswerSchema.probe_id === "string"
|
|
? focus.expectedAnswerSchema.probe_id
|
|
: null,
|
|
optionId,
|
|
expectedRevision: previous?.revision ?? 0,
|
|
userDisplay: parsed.data.message ?? null,
|
|
});
|
|
return completedMessageResponse(applied.narration, requestId, caseId);
|
|
}
|
|
if (classified.intent === "stop_rectification") {
|
|
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
|
const applied = await applyRectificationChoice(accounting, {
|
|
userId,
|
|
caseId,
|
|
sessionId,
|
|
actionId: requestId,
|
|
action: STOP_ACTION,
|
|
focusId: focus.id,
|
|
questionId: focus.questionId,
|
|
probeId: typeof focus.expectedAnswerSchema.probe_id === "string"
|
|
? focus.expectedAnswerSchema.probe_id
|
|
: null,
|
|
optionId: "stop",
|
|
expectedRevision: previous?.revision ?? 0,
|
|
userDisplay: parsed.data.message ?? null,
|
|
});
|
|
await transitionV9CaseStatus(accounting, userId, caseId, "paused");
|
|
return completedMessageResponse(applied.narration, requestId, caseId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof RectificationToolServiceError) {
|
|
const mapped = mapRectificationRpcError(error);
|
|
return NextResponse.json(
|
|
{ error: mapped.message, message: mapped.message, code: mapped.code },
|
|
{ status: mapped.status },
|
|
);
|
|
}
|
|
console.error(`[rectification-v9] focus answer failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`);
|
|
return NextResponse.json(
|
|
{ error: "校正服务暂时不可用", message: "请稍后重试。", code: "rectification_service_failed" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
messageOrigin: isRectificationMessageOrigin(parsed.data.origin)
|
|
? parsed.data.origin
|
|
: defaultMessageOrigin(action),
|
|
clientActionId: parsed.data.clientActionId ?? requestId,
|
|
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,
|
|
userMessage: action === "message" ? parsed.data.message ?? null : null,
|
|
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,
|
|
},
|
|
});
|
|
}
|