feat(report): generate grounded reports with Mastra

This commit is contained in:
Jesse
2026-08-06 12:43:30 +08:00
parent 82dab96b07
commit 47e829e971
10 changed files with 4100 additions and 0 deletions
@@ -0,0 +1,133 @@
import { NextResponse } from "next/server";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
import {
resolveReportDelete,
resolveReportRead,
} from "@/lib/personal-report-route-core";
import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server";
import {
createSupabasePersonalReportService,
type PersonalReportService,
} from "@/lib/personal-report-service";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
type RouteContext = { params: Promise<{ reportId: string }> };
function sanitizedErrorCode(error: unknown): string {
if (error instanceof Error) return error.name;
return "UnknownError";
}
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
return NextResponse.json(response.body, { status: response.status });
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* GET/DELETE use the AUTHENTICATED client: RLS permits owners to select and
* delete their own rows only, and the persistence service additionally scopes
* every query by userId (least privilege — no service role here).
*/
async function resolvePersistenceForUser() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return { userId: null as string | null, persistence: null as PersonalReportService | null };
}
const persistence = createSupabasePersonalReportService(supabase);
return { userId: user.id, persistence };
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
{ error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
{ status: 404 },
);
}
const response = await resolveReportRead({
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
userId,
reportId,
persistence: persistence ?? {
async getOwnedById() {
return null;
},
},
// Defense in depth: a stored ready document is re-validated through the
// canonical server parse (schema + guards + evidence hash recompute)
// before it is returned to the browser. Client-side validation is never
// a substitute.
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
? { ok: true, document: parsed.document }
: { ok: false };
},
});
return toNextResponse(response);
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] read failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}
export async function DELETE(request: Request, context: RouteContext) {
try {
const { userId, persistence } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
{ error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
{ status: 404 },
);
}
const response = await resolveReportDelete({
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
userId,
reportId,
persistence: persistence ?? {
async getOwnedById() {
return null;
},
async deleteOwned() {
return false;
},
},
});
return toNextResponse(response);
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] delete failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告暂时无法删除", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse } from "next/server";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { defaultLanguageModel } from "@/mastra/model";
import {
resolveSkillSnapshot,
} from "@/lib/personal-report-generation";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import {
isPersonalReportFeatureEnabled,
readPersonalReportDailyLimit,
resolveAllowedReportOrigins,
} from "@/lib/personal-report-entitlement";
import {
resolveReportCreate,
type ReportCreateCoreDeps,
} from "@/lib/personal-report-route-core";
import {
createPersonalReportDataClient,
createSupabasePersonalReportService,
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 120;
function sanitizedErrorCode(error: unknown): string {
if (error instanceof Error) return error.name;
return "UnknownError";
}
function toNextResponse(response: { status: number; body: Record<string, unknown> }) {
return NextResponse.json(response.body, { status: response.status });
}
export async function POST(request: Request) {
try {
// Authenticated client: auth, profile, session/chart-profile owner reads.
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
const userId = authError || !user ? null : user.id;
// Admin client (service_role / self-hosted admin DB): generation writes
// and counting. The authenticated client is forbidden by migration grants
// from inserting/updating personal_reports.
const admin = createAdminSupabaseClient();
const persistence: PersonalReportService = createSupabasePersonalReportService(admin);
const adminDataClient = createPersonalReportDataClient(admin);
let profile: unknown = null;
let profileError: unknown = null;
if (userId) {
const result = await supabase
.from("profiles")
.select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label")
.eq("id", userId)
.maybeSingle();
profile = result.data ?? null;
profileError = result.error;
}
const deps: ReportCreateCoreDeps = {
requestUrl: request.url,
origin: request.headers.get("origin"),
allowedOrigins: resolveAllowedReportOrigins(process.env),
userId,
rawBody: await request.json().catch(() => null),
profile,
checkSessionOwned: async (sessionId) => {
const { data, error } = await supabase
.from("chat_sessions")
.select("id")
.eq("id", sessionId)
.eq("user_id", userId as string)
.maybeSingle();
if (error) throw error;
return Boolean(data);
},
checkChartProfileOwned: async (chartProfileId) => {
const { data, error } = await supabase
.from("chart_profiles")
.select("id")
.eq("id", chartProfileId)
.eq("user_id", userId as string)
.maybeSingle();
if (error) throw error;
return Boolean(data);
},
featureEnabled: isPersonalReportFeatureEnabled(process.env),
dailyLimit: readPersonalReportDailyLimit(process.env),
counts: {
countGenerating: async () => {
const { data, error } = await adminDataClient.from("personal_reports")
.select("id")
.eq("user_id", userId as string)
.eq("status", "generating")
.limit(2);
if (error) throw error;
return Array.isArray(data) ? data.length : 0;
},
countCreatedToday: async () => {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const { data, error } = await adminDataClient.from("personal_reports")
.select("id,created_at")
.eq("user_id", userId as string);
if (error) throw error;
if (!Array.isArray(data)) return 0;
const startIso = todayStart.toISOString();
return data.filter((row) => {
const createdAt = row && typeof row === "object"
? (row as Record<string, unknown>).created_at
: null;
return typeof createdAt === "string" && createdAt >= startIso;
}).length;
},
},
persistence,
model: defaultLanguageModel(),
runWorkflow: (input) => runConsultationWorkflow(input),
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
skillSnapshot: resolveSkillSnapshot(),
};
const response = await resolveReportCreate(deps);
if (response.status >= 500 && profileError) {
console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object"
? (deps.rawBody as Record<string, unknown>).requestId ?? "unknown"
: "unknown")} reason=${sanitizedErrorCode(profileError)}`);
}
return toNextResponse(response);
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`);
return NextResponse.json(
{ error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 },
);
}
}