merge origin/staging into frontend dead code (issue #63)
Docs-only catch-up; agent-loop rule A. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
@@ -10,7 +10,15 @@ import {
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
import { consultDomainConcurrency, consultPrepParallelEnabled, modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
|
||||
import {
|
||||
ConsultationInputBlockedError,
|
||||
consultPrepObservabilityEvent,
|
||||
type ConsultPrepPhase,
|
||||
} from "@/lib/consult/prep-log";
|
||||
import { loadConsultTurnHead } from "@/lib/consult/turn-prep";
|
||||
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
|
||||
import type { ModerationResult } from "@/lib/moderation";
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
@@ -304,78 +312,43 @@ export async function POST(request: Request) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/consult");
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: "请先登录", message: "登录后才能开始咨询。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
const prepParallel = consultPrepParallelEnabled();
|
||||
const prepPhases: ConsultPrepPhase[] = [];
|
||||
const head = await loadConsultTurnHead({
|
||||
parallel: prepParallel,
|
||||
getUser: () => supabase.auth.getUser(),
|
||||
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
|
||||
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
|
||||
readSession: async (userId, parsed) => await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
|
||||
.eq("id", parsed.sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle(),
|
||||
resolveModel: async (chatSession) => {
|
||||
if (!chatSession.model_id) return null;
|
||||
return await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!head.ok) {
|
||||
// A pending account deletion freezes the account: nothing new is charged.
|
||||
const frozen = head.frozen ? head.response : null;
|
||||
if (frozen) return frozen;
|
||||
return head.response;
|
||||
}
|
||||
// A pending account deletion freezes the account: nothing new is charged.
|
||||
const frozen = await refuseWhenAccountDeletionPending(user.id);
|
||||
if (frozen) return frozen;
|
||||
|
||||
const parsed = chatRequestSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
const { user, parsed, chatSession, sessionModel } = head;
|
||||
prepPhases.push(
|
||||
{ phase: "prep.auth", durationMs: head.timings.authMs, status: "completed" },
|
||||
{ phase: "prep.deletion", durationMs: head.timings.deletionMs, status: "completed" },
|
||||
{ phase: "prep.session", durationMs: head.timings.sessionMs, status: "completed" },
|
||||
{ phase: "prep.model", durationMs: head.timings.modelMs, status: "completed" },
|
||||
{ phase: prepParallel ? "prep.flag.parallel-on" : "prep.flag.parallel-off", durationMs: 0, status: "completed" },
|
||||
{ phase: modelVersionCacheTtlMs() > 0 ? "prep.flag.cache-on" : "prep.flag.cache-off", durationMs: 0, status: "completed" },
|
||||
{ phase: consultDomainConcurrency() === 2 ? "prep.flag.domains-2" : "prep.flag.domains-1", durationMs: 0, status: "completed" },
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
|
||||
.eq("id", parsed.data.sessionId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (chatSessionError) {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!chatSession) {
|
||||
return NextResponse.json(
|
||||
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (chatSession.session_type !== "consultation") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "这是生时校正会话",
|
||||
code: "session_not_consultation",
|
||||
message: "请在生时校正里继续。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
|
||||
return NextResponse.json(
|
||||
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const sessionModel = await resolveSessionLanguageModel(
|
||||
chatSession.model_id,
|
||||
chatSession.model_config_version,
|
||||
);
|
||||
|
||||
if (!sessionModel) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const sessionContextWindow = sessionModel.contextWindow;
|
||||
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
@@ -421,14 +394,58 @@ export async function POST(request: Request) {
|
||||
// Content moderation, input side (2026-09-30): before any reservation, so a
|
||||
// blocked question is never sent to a model and never charged.
|
||||
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
|
||||
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
|
||||
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
|
||||
if (inputModeration.verdict === "block") {
|
||||
let inputModerationPending: Promise<ModerationResult> | null = null;
|
||||
let inputModerationRecorded = false;
|
||||
let moderationTimed = false;
|
||||
let prepLogged = false;
|
||||
const moderationStartedAt = Date.now();
|
||||
function logPrep() {
|
||||
if (prepLogged) return;
|
||||
prepLogged = true;
|
||||
logAgentObservability(consultPrepObservabilityEvent({
|
||||
requestId,
|
||||
sessionId,
|
||||
phases: prepPhases,
|
||||
}));
|
||||
}
|
||||
async function settleInputModeration(): Promise<ModerationResult | null> {
|
||||
if (!inputModerationPending) return null;
|
||||
const result = await inputModerationPending;
|
||||
if (!moderationTimed) {
|
||||
moderationTimed = true;
|
||||
prepPhases.push({
|
||||
phase: "prep.moderation",
|
||||
durationMs: Math.max(0, Date.now() - moderationStartedAt),
|
||||
status: result.verdict === "block" ? "failed" : "completed",
|
||||
});
|
||||
}
|
||||
if (result.verdict !== "pass" && !inputModerationRecorded) {
|
||||
inputModerationRecorded = true;
|
||||
await recordModerationEvent(accounting, result, "input", moderationContext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function contentBlockedResponse(result: ModerationResult) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
|
||||
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!prepParallel) {
|
||||
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
|
||||
prepPhases.push({
|
||||
phase: "prep.moderation",
|
||||
durationMs: Math.max(0, Date.now() - moderationStartedAt),
|
||||
status: inputModeration.verdict === "block" ? "failed" : "completed",
|
||||
});
|
||||
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
|
||||
if (inputModeration.verdict === "block") {
|
||||
logPrep();
|
||||
return contentBlockedResponse(inputModeration);
|
||||
}
|
||||
} else {
|
||||
inputModerationPending = moderate(visibleQuestion, "input", moderationContext);
|
||||
}
|
||||
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
|
||||
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
|
||||
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
|
||||
@@ -443,6 +460,7 @@ export async function POST(request: Request) {
|
||||
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
|
||||
const runTurn = async (): Promise<Response> => {
|
||||
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
|
||||
const prepareStarted = Date.now();
|
||||
try {
|
||||
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
|
||||
userId,
|
||||
@@ -475,13 +493,25 @@ export async function POST(request: Request) {
|
||||
return data;
|
||||
},
|
||||
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
|
||||
beforeReserve: ({ consultationMode }) => createConsultationPlan({
|
||||
userIntent: parsed.data.question,
|
||||
theme: consultationTheme,
|
||||
consultationMode,
|
||||
modelCreditCost: sessionModel.creditCost,
|
||||
}),
|
||||
reserve: () => reserveConsultationModel(
|
||||
beforeReserve: async ({ consultationMode }) => {
|
||||
prepPhases.push({
|
||||
phase: "prep.subject",
|
||||
durationMs: Math.max(0, Date.now() - prepareStarted),
|
||||
status: "completed",
|
||||
});
|
||||
const inputModeration = await settleInputModeration();
|
||||
if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration);
|
||||
return createConsultationPlan({
|
||||
userIntent: parsed.data.question,
|
||||
theme: consultationTheme,
|
||||
consultationMode,
|
||||
modelCreditCost: sessionModel.creditCost,
|
||||
});
|
||||
},
|
||||
reserve: async () => {
|
||||
const reserveStarted = Date.now();
|
||||
try {
|
||||
return await reserveConsultationModel(
|
||||
chatSession.model_id,
|
||||
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
|
||||
async (model) => {
|
||||
@@ -503,9 +533,22 @@ export async function POST(request: Request) {
|
||||
error_code: reservation.data.reason,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
prepPhases.push({
|
||||
phase: "prep.reserve",
|
||||
durationMs: Math.max(0, Date.now() - reserveStarted),
|
||||
status: "completed",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const blocked = await settleInputModeration();
|
||||
if (blocked?.verdict === "block") {
|
||||
logPrep();
|
||||
return contentBlockedResponse(blocked);
|
||||
}
|
||||
if (error instanceof FeaturePricingError) {
|
||||
console.error(
|
||||
`[billing] reservation failed request=${requestId} reason=${error.code}`,
|
||||
@@ -626,6 +669,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
let appendedQuestion: { success: boolean; error_code?: string | null };
|
||||
const appendStarted = Date.now();
|
||||
try {
|
||||
appendedQuestion = await retryDetachedSettlement(async () => {
|
||||
const { data, error } = await accounting.rpc("append_consultation_question", {
|
||||
@@ -640,7 +684,18 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return parsedAppend.data;
|
||||
});
|
||||
prepPhases.push({
|
||||
phase: "prep.append",
|
||||
durationMs: Math.max(0, Date.now() - appendStarted),
|
||||
status: "completed",
|
||||
});
|
||||
} catch {
|
||||
prepPhases.push({
|
||||
phase: "prep.append",
|
||||
durationMs: Math.max(0, Date.now() - appendStarted),
|
||||
status: "failed",
|
||||
});
|
||||
logPrep();
|
||||
await cancel();
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法保存问题", message: "请稍后重试。" },
|
||||
@@ -670,6 +725,14 @@ export async function POST(request: Request) {
|
||||
let classificationOutcome: string | undefined;
|
||||
let classificationDurationMs: number | null = null;
|
||||
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
|
||||
const titleStarted = Date.now();
|
||||
const titleRead = supabase
|
||||
.from("chat_sessions")
|
||||
.select("title")
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
const classifyStarted = Date.now();
|
||||
const turn = parsed.data.entrypoint === undefined
|
||||
? await classifyConsultationTurn({
|
||||
model: selectedModel,
|
||||
@@ -705,8 +768,14 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
: { kind: "consult" as const };
|
||||
prepPhases.push({
|
||||
phase: "prep.classify",
|
||||
durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted),
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
if (turn.kind === "smalltalk") {
|
||||
logPrep();
|
||||
return streamSmalltalkResponse({
|
||||
requestId,
|
||||
reply: turn.reply,
|
||||
@@ -738,12 +807,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
|
||||
const { data: titleRow } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("title")
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
const { data: titleRow } = await titleRead;
|
||||
prepPhases.push({
|
||||
phase: "prep.title",
|
||||
durationMs: Math.max(0, Date.now() - titleStarted),
|
||||
status: "completed",
|
||||
});
|
||||
logPrep();
|
||||
|
||||
async function checkpointConsultationContext() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user