fix(consult): drop traces, budget checkpoints, silent summary inherit
Independent Staging Quality Gate / validate (push) Canceled after 3m8s
Independent Staging Quality Gate / publish (push) Canceled after 0s

BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot.
BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000).
BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
This commit is contained in:
jesse-ux
2026-09-16 07:38:36 +08:00
parent e61535f464
commit 149e1ec4c3
18 changed files with 451 additions and 31 deletions
+7 -1
View File
@@ -330,6 +330,7 @@ export async function POST(request: Request) {
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
@@ -353,7 +354,7 @@ export async function POST(request: Request) {
// Client `history` stays in the request schema for old bundles and is not read.
const contextSummary = parseSessionContextSummary(chatSession.context_summary);
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {
contextWindow: sessionModel.contextWindow,
contextWindow: sessionContextWindow,
});
const storedHistory = historyWindow.tail;
const userControlledPrompt = [
@@ -596,6 +597,7 @@ export async function POST(request: Request) {
await checkpointSessionContextSummary({
messages: sessionRow.messages,
summary: sessionRow.context_summary,
contextWindow: sessionContextWindow,
generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal),
update: async (summary, seenUpdatedAt) => {
let query = supabase.from("chat_sessions")
@@ -875,6 +877,7 @@ export async function POST(request: Request) {
instruction: modeInstruction,
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1271,6 +1274,7 @@ export async function POST(request: Request) {
instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)),
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1355,6 +1359,7 @@ export async function POST(request: Request) {
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1379,6 +1384,7 @@ export async function POST(request: Request) {
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
+28 -7
View File
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJson } from "@/lib/chat-session-write-contract";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
ChatSessionBodyTooLargeError,
readChatSessionJson,
} from "@/lib/chat-session-write-contract";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { resolveInheritedContextSummary } from "@/lib/session-context-summary";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
@@ -94,13 +100,28 @@ export async function POST(request: Request) {
}
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
const { error } = await supabase.from("chat_sessions").insert({
id,
user_id: user.id,
...values,
updated_at: new Date().toISOString(),
const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data;
const inheritedSummary = await resolveInheritedContextSummary({
continuedFromSessionId,
loadOwnedSummary: async (sourceId) => {
const { data } = await supabase
.from("chat_sessions")
.select("context_summary")
.eq("id", sourceId)
.eq("user_id", user.id)
.maybeSingle();
return data?.context_summary ?? null;
},
});
const { error } = await supabase.from("chat_sessions").insert(
chatSessionCreateInsertRow({
id,
userId: user.id,
values,
inheritedSummary,
updatedAt: new Date().toISOString(),
}),
);
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });
} catch (error) {
+1 -1
View File
@@ -135,7 +135,7 @@ export type ConsultationRunParams = {
uiPreviewMode: MutableRefObject<string | null>;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
startNewChat: () => Promise<ChatSession | null>;
startNewChat: (options?: { continuedFromSessionId?: string }) => Promise<ChatSession | null>;
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
+20 -4
View File
@@ -139,7 +139,11 @@ export function useSessionManagement(params: SessionManagementParams) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
}
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
async function persistSession(
session: ChatSession,
mode: "create" | "update" = "update",
options?: { continuedFromSessionId?: string },
) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
const values = mode === "create"
@@ -153,6 +157,9 @@ export function useSessionManagement(params: SessionManagementParams) {
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
...(options?.continuedFromSessionId
? { continued_from_session_id: options.continuedFromSessionId }
: {}),
}
: {
title: session.title,
@@ -192,7 +199,10 @@ export function useSessionManagement(params: SessionManagementParams) {
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
setSessionFullPrompt(null);
const created = await startNewChat();
const sourceSessionId = activeSessionId;
const created = await startNewChat(
sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined,
);
if (!created) return;
setDraft(prompt.question);
setDraftTheme(prompt.theme);
@@ -294,7 +304,7 @@ export function useSessionManagement(params: SessionManagementParams) {
}
}
async function startNewChat(): Promise<ChatSession | null> {
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
@@ -312,7 +322,13 @@ export function useSessionManagement(params: SessionManagementParams) {
setComposerNotice("");
setRequestError(null);
try {
await persistSession(nextSession, "create");
await persistSession(
nextSession,
"create",
options?.continuedFromSessionId
? { continuedFromSessionId: options.continuedFromSessionId }
: undefined,
);
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
@@ -59,6 +59,7 @@ export const chatSessionCreateSchema = z.object({
messages: z.array(chatMessageSchema).max(0),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
continued_from_session_id: z.string().uuid().optional(),
...chartBindingSchema,
updated_at: z.string().datetime().optional(),
}).strict();
@@ -148,11 +149,41 @@ export type ChatSessionCreate = Readonly<{
messages: readonly [];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
continued_from_session_id?: string;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export type ChatSessionCreateInsertValues = Readonly<{
title: string;
theme: ConsultationDomain;
model_id: string;
messages: readonly unknown[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export function chatSessionCreateInsertRow(input: {
id: string;
userId: string;
values: ChatSessionCreateInsertValues;
inheritedSummary?: unknown;
updatedAt: string;
}): Record<string, unknown> {
const row: Record<string, unknown> = {
id: input.id,
user_id: input.userId,
...input.values,
updated_at: input.updatedAt,
};
if (input.inheritedSummary) row.context_summary = input.inheritedSummary;
return row;
}
export type ChatSessionWrite = Readonly<{
title: string;
theme: ConsultationDomain;
@@ -1,11 +1,14 @@
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
// 0.4 of the history budget. At the default 128k window the budget is 40,000,
// so the checkpoint fires at 16,000 — the previous hard-coded threshold.
// Smaller windows then checkpoint before the tail can exceed the budget.
export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4;
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
@@ -51,6 +54,15 @@ export function historyBudgetChars(contextWindow: number | null | undefined): nu
);
}
export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number {
const budget = historyBudgetChars(contextWindow);
const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO);
if (!(threshold < budget)) {
throw new Error("consultation history checkpoint threshold must stay below the history budget");
}
return threshold;
}
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
@@ -74,6 +86,13 @@ export function omissionMarker(omittedChars: number): string {
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
}
export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string {
if (droppedCount <= 0) return "";
return hasSummary
? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)`
: `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`;
}
export function clipConsultationHistoryText(text: string): string {
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
@@ -165,16 +184,18 @@ export function consultationUserTurnContent(input: {
instruction: string;
extra?: string;
summaryText?: string | null;
droppedCount?: number;
question: string;
}): string {
const summary = input.summaryText?.trim() ?? "";
const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary));
return [
input.currentTime,
input.name ? `用户称呼:${input.name}` : "",
input.instruction,
input.extra ?? "",
input.summaryText?.trim()
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
: "",
summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "",
dropped,
input.question,
].filter(Boolean).join("\n");
}
+23 -5
View File
@@ -1,7 +1,7 @@
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
consultationHistoryCheckpointChars,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
@@ -98,7 +98,7 @@ export function sanitizeSessionContextSummary(raw: string): string | null {
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
@@ -110,9 +110,10 @@ export function tailCharCount(
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
return tailCharCount(messages, summary, options)
> consultationHistoryCheckpointChars(options.contextWindow);
}
export function messagesForSummaryInput(
@@ -215,6 +216,19 @@ export function nextSessionContextSummary(
};
}
export async function resolveInheritedContextSummary(input: {
continuedFromSessionId?: string | null;
loadOwnedSummary: (sessionId: string) => Promise<unknown>;
}): Promise<SessionContextSummaryV1 | null> {
const sourceId = input.continuedFromSessionId?.trim();
if (!sourceId) return null;
try {
return parseSessionContextSummary(await input.loadOwnedSummary(sourceId));
} catch {
return null;
}
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
@@ -228,13 +242,17 @@ export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
contextWindow?: number | null;
now?: () => Date;
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
timeoutMs?: number;
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
const previous = parseSessionContextSummary(input.summary);
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
if (!shouldCheckpoint(input.messages, previous, {
excludeRequestId: input.excludeRequestId,
contextWindow: input.contextWindow,
})) {
return "skipped";
}
try {