发问准备可以并行,三个开关先保持关闭(BUG-1337~1339)
开流前的注销检查可以和读会话、解析模型同时进行;审核改到扣点前等待,不再挡住第一行;模型版本可以短缓存;两个领域可以一起算。本机没有数据库中位数,也没有带星盘网络库的镜像,所以默认仍是关闭、缓存 0、领域逐个算。 自测:tsc 0 错;lint 0 error;相关测试失败 0、取消 0。test:db 未跑。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
66cd60081f
commit
a0e223e20c
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Call-time switches for consult preparation. Read on each request, not at import. */
|
||||
|
||||
const warned = new Set<string>();
|
||||
|
||||
function warnOnce(name: string, raw: string): void {
|
||||
if (warned.has(name)) return;
|
||||
warned.add(name);
|
||||
console.warn(`[consult-prep] ${name} 的值无法识别,按默认处理`);
|
||||
void raw;
|
||||
}
|
||||
|
||||
function readRaw(env: Readonly<Record<string, string | undefined>>, name: string): string | undefined {
|
||||
const raw = env[name];
|
||||
if (raw === undefined) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function consultPrepParallelEnabled(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
const raw = readRaw(env, "CONSULT_PREP_PARALLEL");
|
||||
if (raw === undefined) return false;
|
||||
const value = raw.toLowerCase();
|
||||
if (value === "on") return true;
|
||||
if (value === "off") return false;
|
||||
warnOnce("CONSULT_PREP_PARALLEL", raw);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 0 closes the cache. Anything outside 0–15000 stays on the default, which is off. */
|
||||
export function modelVersionCacheTtlMs(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): number {
|
||||
const raw = readRaw(env, "MODEL_VERSION_CACHE_TTL_MS");
|
||||
if (raw === undefined) return 0;
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (parsed > 15_000) {
|
||||
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 1 is today's serial loop. 2 starts the capped domains together. */
|
||||
export function consultDomainConcurrency(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): 1 | 2 {
|
||||
const raw = readRaw(env, "CONSULT_DOMAIN_CONCURRENCY");
|
||||
if (raw === undefined || raw === "1") return 1;
|
||||
if (raw === "2") return 2;
|
||||
warnOnce("CONSULT_DOMAIN_CONCURRENCY", raw);
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function resetConsultPrepFlagWarningsForTests(): void {
|
||||
warned.clear();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ModerationResult } from "@/lib/moderation";
|
||||
|
||||
export const CONSULT_PREP_AGENT_VERSION = "consultation-prep-v1";
|
||||
|
||||
export class ConsultationInputBlockedError extends Error {
|
||||
readonly result: ModerationResult;
|
||||
|
||||
constructor(result: ModerationResult) {
|
||||
super("content_blocked");
|
||||
this.name = "ConsultationInputBlockedError";
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsultPrepPhase = {
|
||||
phase: string;
|
||||
durationMs: number;
|
||||
status: "completed" | "failed" | "skipped";
|
||||
};
|
||||
|
||||
/** Closed observability event. The question text is not a field. */
|
||||
export function consultPrepObservabilityEvent(input: {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
phases: readonly ConsultPrepPhase[];
|
||||
}) {
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
agentVersion: CONSULT_PREP_AGENT_VERSION,
|
||||
contractPhases: input.phases.map((phase) => ({ ...phase })),
|
||||
billingSettlementResult: "not_applicable" as const,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type ConsultPrepTimings = {
|
||||
authMs: number;
|
||||
deletionMs: number;
|
||||
sessionMs: number;
|
||||
modelMs: number;
|
||||
};
|
||||
|
||||
type AuthResult<User> = {
|
||||
data: { user: User | null };
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type BodyResult<Parsed> =
|
||||
| { success: true; data: Parsed }
|
||||
| { success: false; error: { flatten: () => unknown } };
|
||||
|
||||
type SessionResult<Session> = {
|
||||
data: Session | null;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
model_id: string | null;
|
||||
session_type: string | null;
|
||||
model_config_version?: number | null;
|
||||
messages?: unknown;
|
||||
title?: unknown;
|
||||
theme?: unknown;
|
||||
chart_profile_id?: unknown;
|
||||
chart_profile_name?: unknown;
|
||||
chart_profile_role?: unknown;
|
||||
context_summary?: unknown;
|
||||
};
|
||||
|
||||
type ParsedRequest = {
|
||||
sessionId: string;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends ParsedRequest, Session extends SessionRow, Model> = {
|
||||
parallel: boolean;
|
||||
getUser: () => Promise<AuthResult<User>>;
|
||||
readPendingDeletion: (userId: string) => Promise<Response | null>;
|
||||
readBody: () => Promise<BodyResult<Parsed>>;
|
||||
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
|
||||
resolveModel: (chatSession: Session) => Promise<Model | null>;
|
||||
};
|
||||
|
||||
export type ConsultTurnHead<User, Parsed, Session, Model> =
|
||||
| { ok: false; response: Response; frozen: boolean }
|
||||
| {
|
||||
ok: true;
|
||||
user: User;
|
||||
parsed: { success: true; data: Parsed };
|
||||
chatSession: Session & { model_id: string };
|
||||
sessionModel: Model;
|
||||
timings: ConsultPrepTimings;
|
||||
};
|
||||
|
||||
function elapsedSince(started: number): number {
|
||||
return Math.max(0, Date.now() - started);
|
||||
}
|
||||
|
||||
function loginRequired(): Response {
|
||||
return NextResponse.json(
|
||||
{ error: "请先登录", message: "登录后才能开始咨询。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
function badBody(error: { flatten: () => unknown }): Response {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料或问题格式不正确", details: error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
function sessionUnreadable(): Response {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
function sessionMissing(): Response {
|
||||
return NextResponse.json(
|
||||
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
function notConsultation(): Response {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "这是生时校正会话",
|
||||
code: "session_not_consultation",
|
||||
message: "请在生时校正里继续。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
function modelChanged(): Response {
|
||||
return NextResponse.json(
|
||||
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
function modelUnavailable(): Response {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "模型暂不可用",
|
||||
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
type Chain<Parsed, Session, Model> =
|
||||
| { ok: false; response: Response }
|
||||
| {
|
||||
ok: true;
|
||||
parsed: { success: true; data: Parsed };
|
||||
chatSession: Session & { model_id: string };
|
||||
sessionModel: Model;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auth, then either the old serial checks or deletion in parallel with
|
||||
* body → session → model. Failures are still reported in the old order.
|
||||
*/
|
||||
export async function loadConsultTurnHead<
|
||||
User extends { id: string },
|
||||
Parsed extends ParsedRequest,
|
||||
Session extends SessionRow,
|
||||
Model,
|
||||
>(
|
||||
deps: ConsultTurnHeadDeps<User, Parsed, Session, Model>,
|
||||
): Promise<ConsultTurnHead<User, Parsed, Session, Model>> {
|
||||
const timings: ConsultPrepTimings = { authMs: 0, deletionMs: 0, sessionMs: 0, modelMs: 0 };
|
||||
const authStarted = Date.now();
|
||||
const auth = await deps.getUser();
|
||||
timings.authMs = elapsedSince(authStarted);
|
||||
const user = auth.data.user;
|
||||
if (auth.error || !user) return { ok: false, response: loginRequired(), frozen: false };
|
||||
|
||||
const readChain = async (): Promise<Chain<Parsed, Session, Model>> => {
|
||||
const body = await deps.readBody();
|
||||
if (!body.success) return { ok: false, response: badBody(body.error) };
|
||||
const sessionStarted = Date.now();
|
||||
const session = await deps.readSession(user.id, body.data);
|
||||
timings.sessionMs = elapsedSince(sessionStarted);
|
||||
if (session.error) return { ok: false, response: sessionUnreadable() };
|
||||
if (!session.data) return { ok: false, response: sessionMissing() };
|
||||
const chatSession = session.data;
|
||||
if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() };
|
||||
if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) {
|
||||
return { ok: false, response: modelChanged() };
|
||||
}
|
||||
const modelStarted = Date.now();
|
||||
const sessionModel = await deps.resolveModel(chatSession);
|
||||
timings.modelMs = elapsedSince(modelStarted);
|
||||
if (!sessionModel) return { ok: false, response: modelUnavailable() };
|
||||
return {
|
||||
ok: true,
|
||||
parsed: body,
|
||||
chatSession: chatSession as Session & { model_id: string },
|
||||
sessionModel,
|
||||
};
|
||||
};
|
||||
|
||||
if (!deps.parallel) {
|
||||
const deletionStarted = Date.now();
|
||||
const frozen = await deps.readPendingDeletion(user.id);
|
||||
timings.deletionMs = elapsedSince(deletionStarted);
|
||||
if (frozen) return { ok: false, response: frozen, frozen: true };
|
||||
const chain = await readChain();
|
||||
if (!chain.ok) return { ok: false, response: chain.response, frozen: false };
|
||||
return { ...chain, user, timings };
|
||||
}
|
||||
|
||||
const deletionStarted = Date.now();
|
||||
const deletion = deps.readPendingDeletion(user.id).then((frozen) => {
|
||||
timings.deletionMs = elapsedSince(deletionStarted);
|
||||
return frozen;
|
||||
});
|
||||
const chain = readChain();
|
||||
const [frozen, settled] = await Promise.all([deletion, chain]);
|
||||
if (frozen) return { ok: false, response: frozen, frozen: true };
|
||||
if (!settled.ok) return { ok: false, response: settled.response, frozen: false };
|
||||
return { ...settled, user, timings };
|
||||
}
|
||||
@@ -3,8 +3,22 @@ import "server-only";
|
||||
import type { MastraModelConfig } from "@mastra/core/llm";
|
||||
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
import { modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
|
||||
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
|
||||
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
|
||||
|
||||
/** Tests replace the database, decrypt and DNS steps. Production leaves these as the real functions. */
|
||||
const catalogIo = {
|
||||
queryAdminRows,
|
||||
assertAllowedModelProviderUrl,
|
||||
decryptModelProviderApiKey,
|
||||
};
|
||||
|
||||
export function setModelCatalogIoForTests(next: Partial<typeof catalogIo> | null): void {
|
||||
catalogIo.queryAdminRows = next?.queryAdminRows ?? queryAdminRows;
|
||||
catalogIo.assertAllowedModelProviderUrl = next?.assertAllowedModelProviderUrl ?? assertAllowedModelProviderUrl;
|
||||
catalogIo.decryptModelProviderApiKey = next?.decryptModelProviderApiKey ?? decryptModelProviderApiKey;
|
||||
}
|
||||
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
|
||||
|
||||
const cacheTtlMs = 15_000;
|
||||
@@ -17,7 +31,11 @@ type PublishedModelRow = {
|
||||
context_window: number | null;
|
||||
};
|
||||
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
|
||||
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
|
||||
type VersionHit = { expiresAt: number; model: ResolvedLanguageModel };
|
||||
const state = globalThis as typeof globalThis & {
|
||||
jyotishaModelCatalogCache?: Cache;
|
||||
jyotishaModelVersionCache?: Map<string, VersionHit>;
|
||||
};
|
||||
|
||||
function isSecretSettingKey(key: string) {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
@@ -37,7 +55,7 @@ function publicModel(model: ResolvedLanguageModel) {
|
||||
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
|
||||
}
|
||||
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
|
||||
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
|
||||
const apiKey = catalogIo.decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
|
||||
if (!apiKey) return null;
|
||||
let model: MastraModelConfig;
|
||||
if (row.provider_type === "openai") {
|
||||
@@ -45,7 +63,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
|
||||
} else if (row.provider_type === "anthropic") {
|
||||
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
|
||||
} else {
|
||||
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
|
||||
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await catalogIo.assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
|
||||
}
|
||||
return {
|
||||
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
|
||||
@@ -55,7 +73,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
|
||||
};
|
||||
}
|
||||
async function queryCatalog(where: string, values: readonly unknown[] = []) {
|
||||
return queryAdminRows<PublishedModelRow>(`
|
||||
return catalogIo.queryAdminRows<PublishedModelRow>(`
|
||||
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
|
||||
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
|
||||
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
|
||||
@@ -86,9 +104,32 @@ async function readVersion(modelId: string, version: number) {
|
||||
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
|
||||
return rows[0] ? resolveRow(rows[0]) : null;
|
||||
}
|
||||
function versionCache(): Map<string, VersionHit> {
|
||||
return state.jyotishaModelVersionCache ??= new Map();
|
||||
}
|
||||
|
||||
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
|
||||
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
|
||||
if (configVersion !== null && configVersion !== undefined) {
|
||||
const ttl = modelVersionCacheTtlMs();
|
||||
const key = `${modelId}:${configVersion}`;
|
||||
const cache = versionCache();
|
||||
if (ttl > 0) {
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.model;
|
||||
}
|
||||
try {
|
||||
const model = await readVersion(modelId, configVersion);
|
||||
// Failures and null stay uncached so a disabled provider is not pinned.
|
||||
if (model && ttl > 0) cache.set(key, { expiresAt: Date.now() + ttl, model });
|
||||
return model;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
return catalog.models.find((model) => model.id === modelId) ?? null;
|
||||
}
|
||||
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
|
||||
export function invalidateLanguageModelCatalog() {
|
||||
delete state.jyotishaModelCatalogCache;
|
||||
state.jyotishaModelVersionCache?.clear();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
|
||||
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
|
||||
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
|
||||
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
|
||||
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
|
||||
import {
|
||||
buildEvidenceCard,
|
||||
citedEvidenceCardFields,
|
||||
@@ -1227,57 +1228,114 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
try {
|
||||
const userIntent = ctx.plan?.userIntent ?? input.question;
|
||||
const executions: DomainExecution[] = [];
|
||||
for (let index = 0; index < domains.length; index += 1) {
|
||||
const domain = domains[index];
|
||||
if (!domain) continue;
|
||||
// Every domain shares the run's single abort deadline, so a plan
|
||||
// that runs long would abort mid-loop and lose the domains already
|
||||
// calculated. Stop while there is still time to answer instead.
|
||||
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: chartCalculationProgressLabel(index + 1, domains.length),
|
||||
},
|
||||
});
|
||||
const domainPlan = ctx.plan
|
||||
&& domains.length === 1
|
||||
&& ctx.plan.requestedDomains.length === 1
|
||||
&& ctx.plan.requestedDomains[0] === domain
|
||||
? ctx.plan
|
||||
: createConsultationPlan({
|
||||
userIntent,
|
||||
theme: domain,
|
||||
consultationMode: ctx.consultationMode,
|
||||
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
|
||||
...(domain === "timing" || domain === "annual"
|
||||
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
|
||||
: { timingHorizon: null }),
|
||||
if (consultDomainConcurrency() === 1) {
|
||||
for (let index = 0; index < domains.length; index += 1) {
|
||||
const domain = domains[index];
|
||||
if (!domain) continue;
|
||||
// Every domain shares the run's single abort deadline, so a plan
|
||||
// that runs long would abort mid-loop and lose the domains already
|
||||
// calculated. Stop while there is still time to answer instead.
|
||||
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: chartCalculationProgressLabel(index + 1, domains.length),
|
||||
},
|
||||
});
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...ctx.serverChart.toolInput,
|
||||
entryMode: "direct_chart",
|
||||
question: userIntent,
|
||||
theme: domain,
|
||||
});
|
||||
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
});
|
||||
const agentContext = toAgentConsultationContext(guarded);
|
||||
executions.push({
|
||||
domain,
|
||||
context: agentContext,
|
||||
modelOutput: toModelOutput(agentContext, domainPlan, domain),
|
||||
receipt: consultationWorkflowReceipt(guarded),
|
||||
});
|
||||
const domainPlan = ctx.plan
|
||||
&& domains.length === 1
|
||||
&& ctx.plan.requestedDomains.length === 1
|
||||
&& ctx.plan.requestedDomains[0] === domain
|
||||
? ctx.plan
|
||||
: createConsultationPlan({
|
||||
userIntent,
|
||||
theme: domain,
|
||||
consultationMode: ctx.consultationMode,
|
||||
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
|
||||
...(domain === "timing" || domain === "annual"
|
||||
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
|
||||
: { timingHorizon: null }),
|
||||
});
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...ctx.serverChart.toolInput,
|
||||
entryMode: "direct_chart",
|
||||
question: userIntent,
|
||||
theme: domain,
|
||||
});
|
||||
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
});
|
||||
const agentContext = toAgentConsultationContext(guarded);
|
||||
executions.push({
|
||||
domain,
|
||||
context: agentContext,
|
||||
modelOutput: toModelOutput(agentContext, domainPlan, domain),
|
||||
receipt: consultationWorkflowReceipt(guarded),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < domains.length; index += 1) {
|
||||
const domain = domains[index];
|
||||
if (!domain) continue;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: chartCalculationProgressLabel(index + 1, domains.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
|
||||
if (!domain) return null;
|
||||
const domainPlan = ctx.plan
|
||||
&& domains.length === 1
|
||||
&& ctx.plan.requestedDomains.length === 1
|
||||
&& ctx.plan.requestedDomains[0] === domain
|
||||
? ctx.plan
|
||||
: createConsultationPlan({
|
||||
userIntent,
|
||||
theme: domain,
|
||||
consultationMode: ctx.consultationMode,
|
||||
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
|
||||
...(domain === "timing" || domain === "annual"
|
||||
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
|
||||
: { timingHorizon: null }),
|
||||
});
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...ctx.serverChart.toolInput,
|
||||
entryMode: "direct_chart",
|
||||
question: userIntent,
|
||||
theme: domain,
|
||||
});
|
||||
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
|
||||
foreground: true,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
});
|
||||
const agentContext = toAgentConsultationContext(guarded);
|
||||
return {
|
||||
domain,
|
||||
context: agentContext,
|
||||
modelOutput: toModelOutput(agentContext, domainPlan, domain),
|
||||
receipt: consultationWorkflowReceipt(guarded),
|
||||
};
|
||||
}));
|
||||
for (const execution of parallelExecutions) {
|
||||
if (execution) executions.push(execution);
|
||||
}
|
||||
}
|
||||
// Domains the cap refused and domains the clock ran out on are the
|
||||
// same disclosure to the caller: requested but not calculated.
|
||||
|
||||
Reference in New Issue
Block a user