Files
Jyotisha/frontend/src/lib/personal-report-worker.ts
T
2026-08-15 05:10:37 +08:00

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;
}