Files
Jyotisha/frontend/src/lib/personal-report-worker.ts
T
Jesse_ChenandCursor cfcd369d4f feat(report): render longform Markdown as the report and close gaps2 holes
New reports skip the writer, persist pl9 Markdown as the body, and settle zero-token usage on the catalog model. Planned longform sections now emit blocked rows instead of vanishing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 21:46:16 +08:00

370 lines
13 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 { createPersonalReportSectionService } from "@/lib/personal-report-section-service-core";
import {
resolveReportBirthClock,
resolveReportBirthTimeSensitivityInput,
} from "@/lib/personal-report-route-core";
import {
generatePersonalReportLongform,
LongformGenerateError,
type AppendixClient,
} from "@/lib/personal-report-longform-generate";
import { PERSONAL_REPORT_WRITER_ENABLED } from "@/lib/personal-report-writer-flag";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { completeUsage, releaseUsage } from "@/lib/consultation-billing";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
import { resolveAyanamsa } from "@/lib/ayanamsa";
const PROFILE_COLUMNS = [
"name",
"birth_date",
"reported_birth_time",
"active_birth_time",
"birth_time_source",
"birth_time_status",
"rectification_case_id",
"declared_window_start",
"declared_window_end",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
"latitude",
"longitude",
"timezone_offset",
"birth_place_label",
"ayanamsa",
].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 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 generateWriterReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null = null,
) {
const profile = record(context.profile);
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
const birthDate = parseBirthDate(profile.birth_date);
const usableBirth = resolveReportBirthClock(profile);
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 (!usableBirth || !birthDate || latitude === null || longitude === null || timezoneOffset === null) {
throw new PersonalReportWorkerError("birth_time_not_usable", false);
}
const birthClock = usableBirth.clock;
const birthTimeStatus = usableBirth.status;
const sensitivityInput = resolveReportBirthTimeSensitivityInput(
profile,
birthTimeStatus,
`${String(birthClock.hour).padStart(2, "0")}:${String(birthClock.minute).padStart(2, "0")}`,
candidateRange,
);
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,
ayanamsa: resolveAyanamsa(profile),
question: `请为个人报告计算 ${rawTheme} 主题证据`,
theme: rawTheme as ConsultationInput["theme"],
entryMode: "direct_chart",
...sensitivityInput,
};
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);
}
const result = await generatePersonalReport({
reportId: context.report.id,
bundle,
depth: context.report.depth,
agent: createPersonalReportAgent(model),
signal: context.signal,
userId: context.report.userId,
requestId: context.report.requestId,
sectionService: context.sectionService,
onProgress: context.onProgress,
});
if (result.status !== "ready") return result;
return {
...result,
usage: {
inputTokens: result.usage?.inputTokens ?? 0,
outputTokens: result.usage?.outputTokens ?? 0,
cache: result.usage?.cache,
actualModelId: model.id,
modelConfigVersion: model.configVersion,
},
};
}
async function generateLongformReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null,
admin: AppendixClient,
) {
const profile = record(context.profile);
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
const usableBirth = resolveReportBirthClock(profile);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
const displayName = text(profile.name) ?? "我的报告";
if (!usableBirth || 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);
try {
const generated = await generatePersonalReportLongform({
report: context.report,
profile,
candidateRange,
admin,
displayName,
birthTimeStatus: usableBirth.status,
signal: context.signal,
});
if (generated.status !== "ready") return generated;
return {
...generated,
usage: {
inputTokens: 0,
outputTokens: 0,
actualModelId: model.id,
modelConfigVersion: model.configVersion,
},
};
} catch (error) {
if (context.signal.aborted) throw error;
if (error instanceof PersonalReportWorkerError) throw error;
if (error instanceof LongformGenerateError) {
throw new PersonalReportWorkerError(error.code, error.retryable, error.message);
}
throw new PersonalReportWorkerError("calculation_unavailable", true);
}
}
async function generateProductionReport(
context: PersonalReportWorkerGenerationContext,
candidateRange: Readonly<{ startTime: string; endTime: string }> | null,
admin: AppendixClient,
) {
if (PERSONAL_REPORT_WRITER_ENABLED) {
return generateWriterReport(context, candidateRange);
}
return generateLongformReport(context, candidateRange, admin);
}
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,
leaseSeconds: 600,
heartbeatIntervalMs: 20_000,
jobs: createSupabasePersonalReportJobService(admin),
reports: createSupabasePersonalReportService(admin),
sectionService: createPersonalReportSectionService(admin as never),
billing: {
complete: async ({ userId, requestId, usage }) => {
const model = (await loadLanguageModelCatalog()).models.find((entry) => entry.id === usage.actualModelId);
if (!model) return false;
const costMicrousd = Math.round((
usage.inputTokens * (model.inputCostMicrousdPerMillion ?? 0)
+ usage.outputTokens * (model.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const settled = await completeUsage(admin, userId, requestId, {
eventKey: "report.full", actualModelId: usage.actualModelId,
modelConfigVersion: usage.modelConfigVersion, inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens, costMicrousd, durationMs: usage.durationMs,
...(usage.cache ? { metadata: { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } } : {}),
});
return settled.success;
},
release: async ({ userId, requestId, reason }) => (await releaseUsage(admin, userId, requestId, reason)).success,
reserve: async () => { throw new Error("report worker does not reserve usage"); },
},
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: async (context) => {
const profile = record(context.profile) ?? {};
if (resolveReportBirthClock(profile)?.status === "confirmed") {
return generateProductionReport(context, null, admin as never);
}
const range = await loadReportCandidateRange(admin, {
userId: context.report.userId,
rectificationCaseId: text(profile.rectification_case_id),
});
return generateProductionReport(context, range, admin as never);
},
});
}
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;
}