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
+23
View File
@@ -0,0 +1,23 @@
/**
* Stable error/failure codes for the personal report API. Kept in a pure
* dependency-free module (no filesystem, no path, no crypto imports) so route
* handlers that only need codes never pull the skill-snapshot scanner or any
* other generation logic into their bundle/trace.
*/
export const REPORT_STABLE_CODES = {
profileIncomplete: "profile_incomplete",
birthTimeNotUsable: "birth_time_not_usable",
generationInProgress: "report_generation_in_progress",
rateLimited: "report_rate_limited",
calculationUnavailable: "calculation_unavailable",
modelUnavailable: "model_unavailable",
schemaInvalid: "report_schema_invalid",
guardRejected: "report_guard_rejected",
notFound: "report_not_found",
requestConflict: "report_request_conflict",
exportDisabled: "report_export_disabled",
invalidRequest: "invalid_request",
resourceForbidden: "report_resource_forbidden",
generationFailed: "report_generation_failed",
} as const;
@@ -0,0 +1,103 @@
/**
* Personal report export entitlement — independent from the "spend 1 credit"
* consultation RPC. Capability key: report.export.personal.
*
* Staging free policy: login-only, single concurrent generation per user,
* daily limit read from environment configuration (never hardcoded in UI).
* If the feature is later priced, a reserve/refund flow plugs in behind the
* same interface; this module stays billing-agnostic.
*/
export const REPORT_EXPORT_PERSONAL_CAPABILITY_KEY = "report.export.personal";
export const REPORT_FEATURE_ENV = "PERSONAL_REPORT_ENABLED";
export const REPORT_DAILY_LIMIT_ENV = "PERSONAL_REPORT_DAILY_LIMIT";
export const REPORT_ALLOWED_ORIGINS_ENV = "PERSONAL_REPORT_ALLOWED_ORIGINS";
/**
* Server-side default when the env variable is absent. The UI must never
* hardcode this number; it is configurable per deployment.
*/
export const DEFAULT_PERSONAL_REPORT_DAILY_LIMIT = 5;
export type Environment = Readonly<Record<string, string | undefined>>;
export function readPersonalReportDailyLimit(environment: Environment): number {
const raw = environment[REPORT_DAILY_LIMIT_ENV]?.trim();
if (!raw) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT;
return parsed;
}
export function isPersonalReportFeatureEnabled(environment: Environment): boolean {
return environment[REPORT_FEATURE_ENV]?.trim() === "true";
}
export function resolveAllowedReportOrigins(environment: Environment): readonly string[] {
return (environment[REPORT_ALLOWED_ORIGINS_ENV] ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
export type SameOriginDecision = Readonly<
{ ok: true } | { ok: false; code: "cross_origin_forbidden" }
>;
/**
* Same-origin check for report APIs. An absent Origin header (curl, server
* tests, same-origin fetch from the browser never sends Origin for GET but
* does for POST) is accepted; a matching request origin is accepted; a
* configured trusted-proxy/test allowlist is accepted; anything else is
* rejected.
*/
export function checkSameOrigin(
requestUrl: string | URL,
originHeader: string | null,
allowedOrigins: readonly string[],
): SameOriginDecision {
const origin = originHeader?.trim();
if (!origin) return { ok: true };
let requestOrigin: string;
try {
requestOrigin = new URL(requestUrl).origin;
} catch {
return { ok: false, code: "cross_origin_forbidden" };
}
if (origin === requestOrigin) return { ok: true };
if (allowedOrigins.includes(origin)) return { ok: true };
return { ok: false, code: "cross_origin_forbidden" };
}
export type PersonalReportEntitlementResult = Readonly<
| { allowed: true }
| { allowed: false; code: "report_export_disabled"; httpStatus: 403 }
| { allowed: false; code: "report_generation_in_progress"; httpStatus: 409 }
| { allowed: false; code: "report_rate_limited"; httpStatus: 429 }
>;
export type PersonalReportEntitlementDeps = Readonly<{
userId: string;
featureEnabled: boolean;
dailyLimit: number;
countGenerating: (userId: string) => Promise<number>;
countCreatedToday: (userId: string) => Promise<number>;
}>;
export async function checkPersonalReportEntitlement(
deps: PersonalReportEntitlementDeps,
): Promise<PersonalReportEntitlementResult> {
if (!deps.featureEnabled) {
return { allowed: false, code: "report_export_disabled", httpStatus: 403 };
}
const generating = await deps.countGenerating(deps.userId);
if (generating > 0) {
return { allowed: false, code: "report_generation_in_progress", httpStatus: 409 };
}
const createdToday = await deps.countCreatedToday(deps.userId);
if (createdToday >= deps.dailyLimit) {
return { allowed: false, code: "report_rate_limited", httpStatus: 429 };
}
return { allowed: true };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,508 @@
/**
* Personal report route handlers as a dependency-injected core (no
* next/server, no network, no model): fully executable in unit tests with
* fakes. The API routes are thin adapters that resolve the real production
* dependencies (authenticated Supabase reads, admin-backed persistence,
* existing auth/profile/workflow) and map the returned { status, body } to
* NextResponse.
*
* Ownership is enforced twice: the authenticated client/RLS scopes reads and
* deletes, and the persistence service scopes every query by userId.
*/
import { z } from "zod";
import type { ConsultationInput } from "@/mastra";
import type {
ReportAgentPort,
ReportEvidencePacket,
} from "@/mastra/personal-report";
import {
buildReportEvidencePacket,
computeRequestFingerprint,
generatePersonalReport,
type GeneratePersonalReportResult,
type SkillSnapshot,
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { checkSameOrigin } from "./personal-report-entitlement";
import type {
CreateGeneratingInput,
CreateGeneratingResult,
PersonalReportRecord,
PersonalReportService,
} from "./personal-report-service-core";
const reportRequestThemes = z.enum(["career", "marriage", "wealth", "timing", "general"]);
export const personalReportCreateRequestSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid().nullable().optional(),
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]),
}).strict();
export type PersonalReportCreateRequest = z.infer<typeof personalReportCreateRequestSchema>;
export type ReportRouteResponse = Readonly<{ status: number; body: Record<string, unknown> }>;
export type ReportServicePort = Pick<
PersonalReportService,
| "getByUserAndRequestId"
| "createGenerating"
| "completeReady"
| "markFailed"
| "getOwnedById"
| "deleteOwned"
>;
type JsonRecord = Record<string, unknown>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
const USABLE_BIRTH_TIME_STATUSES = new Set(["accepted", "confirmed"]);
function parseClockMinutes(value: unknown): { hour: number; minute: number } | null {
const clock = text(value);
if (!clock) return null;
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock);
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null {
const date = text(value);
if (!date) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return null;
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
return { year, month, day };
}
export function reportView(row: PersonalReportRecord) {
return {
id: row.id,
requestId: row.requestId,
reportType: row.reportType,
presentationMode: row.presentationMode,
status: row.status,
failureCode: row.failureCode,
createdAt: row.createdAt,
completedAt: row.completedAt,
};
}
function replayOrConflict(
existing: PersonalReportRecord,
fingerprint: string,
): ReportRouteResponse {
if (existing.requestFingerprint !== fingerprint) {
return {
status: 409,
body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
if (existing.status === "ready") {
return {
status: 200,
body: { report: reportView(existing), reportDocument: existing.reportDocument },
};
}
if (existing.status === "generating") {
return {
status: 409,
body: { error: "该报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
// A failed record is never silently resurrected: surface the stable
// failure. Retrying requires a new requestId.
return { status: 200, body: { report: reportView(existing) } };
}
export type ReportCreateCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
userId: string | null;
rawBody: unknown;
profile: unknown | null;
checkSessionOwned: (sessionId: string) => Promise<boolean>;
checkChartProfileOwned: (chartProfileId: string) => Promise<boolean>;
featureEnabled: boolean;
dailyLimit: number;
counts: Readonly<{
countGenerating: () => Promise<number>;
countCreatedToday: () => Promise<number>;
}>;
persistence: ReportServicePort;
model: Readonly<{ id: string }> | null;
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
now?: () => Date;
}>;
export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<ReportRouteResponse> {
// Same-origin gate first (CSRF), then auth.
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const userId = deps.userId;
const parsed = personalReportCreateRequestSchema.safeParse(deps.rawBody);
if (!parsed.success) {
return {
status: 400,
body: { error: "报告请求格式不正确", code: REPORT_STABLE_CODES.invalidRequest },
};
}
const payload = parsed.data;
// Profile truth + birth status. Missing or unusable profile is 422.
const profile = record(deps.profile);
if (!profile) {
return {
status: 422,
body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete },
};
}
const birthTimeStatus = text(profile.birth_time_status);
const activeBirthTime = text(profile.active_birth_time);
const birthDate = parseBirthDate(profile.birth_date);
const birthClock = parseClockMinutes(activeBirthTime);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
const displayName = text(profile.name) ?? "我的报告";
const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地";
if (!birthTimeStatus || !USABLE_BIRTH_TIME_STATUSES.has(birthTimeStatus)) {
return {
status: 422,
body: { error: "出生时间尚未达到可用状态", code: REPORT_STABLE_CODES.birthTimeNotUsable },
};
}
if (!birthDate || !birthClock || latitude === null || longitude === null
|| timezoneOffset === null) {
return {
status: 422,
body: { error: "出生资料不完整,无法生成报告", code: REPORT_STABLE_CODES.birthTimeNotUsable },
};
}
// Session / chart-profile ownership (when provided).
if (payload.sessionId && !(await deps.checkSessionOwned(payload.sessionId))) {
return {
status: 403,
body: { error: "会话不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (payload.chartProfileId && !(await deps.checkChartProfileOwned(payload.chartProfileId))) {
return {
status: 403,
body: { error: "星盘资料不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.featureEnabled) {
return {
status: 403,
body: { error: "个人报告功能暂未开放", code: REPORT_STABLE_CODES.exportDisabled },
};
}
// Canonical request fingerprint: payload identity only, requestId excluded.
const fingerprint = computeRequestFingerprint({
reportType: payload.reportType,
presentationMode: payload.presentationMode,
themes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
});
// Idempotent replay: an existing row with the same fingerprint returns the
// stored state; a different payload under the same requestId is a 409
// request conflict — never treated as a replay.
const existing = await deps.persistence.getByUserAndRequestId(userId, payload.requestId);
if (existing) {
return replayOrConflict(existing, fingerprint);
}
const generating = await deps.counts.countGenerating();
if (generating > 0) {
return {
status: 409,
body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
const createdToday = await deps.counts.countCreatedToday();
if (createdToday >= deps.dailyLimit) {
return {
status: 429,
body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited },
};
}
const createInput: CreateGeneratingInput = {
userId,
requestId: payload.requestId,
requestFingerprint: fingerprint,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
requestedThemes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
skillSourceCommit: deps.skillSnapshot.sourceCommit,
skillSnapshotSha256: deps.skillSnapshot.sha256,
};
const begun: CreateGeneratingResult = await deps.persistence.createGenerating(createInput);
if (begun.kind === "generation_in_progress") {
return {
status: 409,
body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
if (begun.kind === "request_conflict") {
return {
status: 409,
body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
if (begun.kind === "replayed") {
return replayOrConflict(begun.record, fingerprint);
}
const row = begun.record;
// Real workflow evidence (main chain), never mock/example/random data.
if (!deps.model) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable);
return {
status: 502,
body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable },
};
}
const workflowTheme = payload.reportType === "personal_thematic"
? payload.themes[0]
: "general";
const workflowInput: ConsultationInput = {
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
hour: birthClock.hour,
minute: birthClock.minute,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")}`,
theme: workflowTheme,
entryMode: "direct_chart",
};
let workflow: unknown;
try {
workflow = await deps.runWorkflow(workflowInput);
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
const workflowRecord = record(workflow);
if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 502,
body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
let packet: ReportEvidencePacket;
try {
packet = buildReportEvidencePacket({
workflow,
subject: {
displayName,
birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted",
birthPlaceLabel,
},
requestedThemes: payload.themes,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
candidateRange: birthTimeStatus === "accepted"
? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" }
: null,
skillSnapshot: deps.skillSnapshot,
});
} catch (error) {
// Real evidence could not support an honest report: fail closed, never
// generate an empty or sample-backed report.
if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable);
return {
status: 422,
body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable },
};
}
throw error;
}
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
packet,
agent: deps.createAgent(deps.model),
now: deps.now,
});
if (result.status === "failed") {
await deps.persistence.markFailed(userId, row.id, result.failureCode);
return {
status: 422,
body: {
error: result.failureCode === REPORT_STABLE_CODES.guardRejected
? "报告未通过确定性校验"
: "报告内容未通过结构校验",
code: result.failureCode,
},
};
}
try {
const readyRow = await deps.persistence.completeReady(userId, row.id, result.document);
return {
status: 201,
body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 422,
body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid },
};
}
}
export type ReportReadCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById">;
/**
* Canonical server re-validation of a stored ready document (defense in
* depth: a polluted DB row must never reach the browser). Production wires
* safeParseServerReportDocument; tests inject fakes or the real parser.
*/
validateReadyDocument: (
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId);
if (!row) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
if (row.status === "ready") {
// Re-validate the stored document through the canonical server parse
// before it is allowed to leave the server; an invalid stored document is
// surfaced as a stable failure WITHOUT the document body.
const validated = deps.validateReadyDocument(row.reportDocument);
if (!validated.ok) {
return {
status: 422,
body: {
error: "报告内容未通过合同校验",
code: REPORT_STABLE_CODES.schemaInvalid,
report: reportView(row),
},
};
}
return {
status: 200,
body: { report: reportView(row), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row) } };
}
export type ReportDeleteCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById" | "deleteOwned">;
}>;
export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise<ReportRouteResponse> {
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins);
if (!originDecision.ok) {
return {
status: 403,
body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden },
};
}
if (!deps.userId) {
return { status: 401, body: { error: "请先登录" } };
}
const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId);
if (!row) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
const removed = await deps.persistence.deleteOwned(deps.userId, row.id);
if (!removed) {
return {
status: 404,
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
return { status: 200, body: { ok: true } };
}