241 lines
8.2 KiB
TypeScript
241 lines
8.2 KiB
TypeScript
import "server-only";
|
|
|
|
import { runConsultationWorkflow } from "@/mastra";
|
|
import { createPersonalReportAgent } from "@/mastra/personal-report";
|
|
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
|
import {
|
|
buildReportEvidenceBundleV2,
|
|
generatePersonalReport,
|
|
type SkillSnapshot,
|
|
} from "@/lib/personal-report-generation";
|
|
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
|
|
import {
|
|
createPersonalReportWorker,
|
|
PersonalReportWorkerError,
|
|
runPersonalReportWorkerLoop,
|
|
type PersonalReportWorkerGenerationContext,
|
|
} from "@/lib/personal-report-worker-core";
|
|
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
|
|
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
|
import type { ConsultationInput } from "@/mastra/consultation-workflow";
|
|
|
|
const PROFILE_COLUMNS = [
|
|
"name",
|
|
"birth_date",
|
|
"active_birth_time",
|
|
"birth_time_status",
|
|
"latitude",
|
|
"longitude",
|
|
"timezone_offset",
|
|
"birth_place_label",
|
|
].join(",");
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type WorkerGlobal = typeof globalThis & {
|
|
jyotishaPersonalReportWorker?: PersonalReportWorkerHandle;
|
|
};
|
|
|
|
export type PersonalReportWorkerHandle = Readonly<{
|
|
workerId: string;
|
|
done: Promise<void>;
|
|
stop: () => void;
|
|
}>;
|
|
|
|
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;
|
|
}
|
|
|
|
function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null {
|
|
const date = text(value);
|
|
const match = date ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) : null;
|
|
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 };
|
|
}
|
|
|
|
function parseBirthClock(value: unknown): { hour: number; minute: number } | null {
|
|
const clock = text(value);
|
|
const match = clock ? /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock) : null;
|
|
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 skillSnapshotForReport(context: PersonalReportWorkerGenerationContext): SkillSnapshot {
|
|
const { report } = context;
|
|
if (!report.skillName || !report.skillVersion || !report.skillSnapshotSha256) {
|
|
throw new PersonalReportWorkerError("report_schema_invalid", false, "report Skill provenance is incomplete");
|
|
}
|
|
return {
|
|
name: report.skillName,
|
|
version: report.skillVersion,
|
|
sha256: report.skillSnapshotSha256,
|
|
sourceCommit: report.skillSourceCommit,
|
|
};
|
|
}
|
|
|
|
async function generateProductionReport(context: PersonalReportWorkerGenerationContext) {
|
|
const profile = record(context.profile);
|
|
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
|
|
|
|
const birthTimeStatus = text(profile.birth_time_status);
|
|
const birthDate = parseBirthDate(profile.birth_date);
|
|
const birthClock = parseBirthClock(profile.active_birth_time);
|
|
const latitude = finiteNumber(profile.latitude);
|
|
const longitude = finiteNumber(profile.longitude);
|
|
const timezoneOffset = finiteNumber(profile.timezone_offset);
|
|
const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地";
|
|
const displayName = text(profile.name) ?? "我的报告";
|
|
if ((birthTimeStatus !== "accepted" && birthTimeStatus !== "confirmed")
|
|
|| !birthDate || !birthClock || latitude === null || longitude === null || timezoneOffset === null) {
|
|
throw new PersonalReportWorkerError("birth_time_not_usable", false);
|
|
}
|
|
|
|
const catalog = await loadLanguageModelCatalog();
|
|
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
|
|
if (!model) throw new PersonalReportWorkerError("model_unavailable", true);
|
|
|
|
const workflows: { theme: string; workflow: unknown }[] = [];
|
|
for (const rawTheme of context.report.requestedThemes) {
|
|
const input: 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: `请为个人报告计算 ${rawTheme} 主题证据`,
|
|
theme: rawTheme as ConsultationInput["theme"],
|
|
entryMode: "direct_chart",
|
|
};
|
|
try {
|
|
workflows.push({
|
|
theme: rawTheme,
|
|
workflow: await runConsultationWorkflow(input, { signal: context.signal }),
|
|
});
|
|
} catch (error) {
|
|
if (context.signal.aborted) throw error;
|
|
throw new PersonalReportWorkerError("calculation_unavailable", true);
|
|
}
|
|
}
|
|
|
|
const hasUsableBaseChart = workflows.some(({ workflow }) => {
|
|
const workflowRecord = record(workflow);
|
|
return workflowRecord?.success === true && record(workflowRecord.chart) !== null;
|
|
});
|
|
if (!hasUsableBaseChart) {
|
|
throw new PersonalReportWorkerError("calculation_unavailable", true);
|
|
}
|
|
|
|
let bundle;
|
|
try {
|
|
bundle = buildReportEvidenceBundleV2({
|
|
workflows,
|
|
subject: {
|
|
displayName,
|
|
birthTimeStatus,
|
|
birthPlaceLabel,
|
|
},
|
|
requestedThemes: context.report.requestedThemes,
|
|
reportType: context.report.reportType,
|
|
presentationMode: context.report.presentationMode,
|
|
skillSnapshot: skillSnapshotForReport(context),
|
|
});
|
|
} catch {
|
|
throw new PersonalReportWorkerError("calculation_unavailable", false);
|
|
}
|
|
|
|
return generatePersonalReport({
|
|
reportId: context.report.id,
|
|
bundle,
|
|
depth: context.report.depth,
|
|
agent: createPersonalReportAgent(model),
|
|
signal: context.signal,
|
|
});
|
|
}
|
|
|
|
function createProductionWorker(workerId: string) {
|
|
const admin = createAdminSupabaseClient();
|
|
const backend = admin as unknown as {
|
|
from(table: string): {
|
|
select(columns: string): {
|
|
eq(column: string, value: unknown): {
|
|
maybeSingle(): PromiseLike<{ data: unknown; error: { message: string } | null }>;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
return createPersonalReportWorker({
|
|
workerId,
|
|
jobs: createSupabasePersonalReportJobService(admin),
|
|
reports: createSupabasePersonalReportService(admin),
|
|
loadProfile: async (userId) => {
|
|
const { data, error } = await backend
|
|
.from("profiles")
|
|
.select(PROFILE_COLUMNS)
|
|
.eq("id", userId)
|
|
.maybeSingle();
|
|
if (error) throw new Error("personal report profile load failed");
|
|
return data ?? null;
|
|
},
|
|
generate: generateProductionReport,
|
|
});
|
|
}
|
|
|
|
function sanitizedErrorName(error: unknown): string {
|
|
return error instanceof Error ? error.name : "UnknownError";
|
|
}
|
|
|
|
/**
|
|
* Starts one unref'ed loop per Node.js server instance. Database leases remain
|
|
* the cross-instance exclusivity boundary; the global only prevents duplicate
|
|
* loops caused by repeated instrumentation/module evaluation in one process.
|
|
*/
|
|
export function startPersonalReportWorker(): PersonalReportWorkerHandle {
|
|
const state = globalThis as WorkerGlobal;
|
|
if (state.jyotishaPersonalReportWorker) return state.jyotishaPersonalReportWorker;
|
|
|
|
const controller = new AbortController();
|
|
const workerId = `personal-report:${globalThis.crypto.randomUUID()}`;
|
|
let worker: ReturnType<typeof createProductionWorker> | null = null;
|
|
const lazyWorker = {
|
|
tick: async () => {
|
|
worker ??= createProductionWorker(workerId);
|
|
return worker.tick();
|
|
},
|
|
};
|
|
const done = runPersonalReportWorkerLoop(lazyWorker, {
|
|
signal: controller.signal,
|
|
onError: (error) => {
|
|
console.error(`[personal-report-worker] tick failed reason=${sanitizedErrorName(error)}`);
|
|
},
|
|
});
|
|
const handle: PersonalReportWorkerHandle = {
|
|
workerId,
|
|
done,
|
|
stop: () => controller.abort(),
|
|
};
|
|
state.jyotishaPersonalReportWorker = handle;
|
|
return handle;
|
|
}
|