Files
Jyotisha/frontend/src/lib/personal-report-worker.ts
T
Jesse_ChenandClaude Opus 5.5 a9a917d50c feat(report): birth-sky report cover bound to the report's birth fingerprint (F1–F3)
Product decision A (TASK-birth-sky-followup-20260928):
- every generation path writes subject.birthFingerprint (stored-row
  fingerprint, same function as the chart page's profileFingerprint);
  never into the evidence bundle the model reads
- GET /api/reports/:id compares on the server and returns only
  report.coverSubject (subject id or null); legacy reports never get one
- report page shows the night cover above the body, paper cover alone on
  the first printed A4 page; silent on any failure, no waiting state
- TS / JSON schema / Python contract accept the optional field
- BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-28 21:47:44 +08:00

340 lines
13 KiB
TypeScript

import type { ReportCandidateClockRange } from "./report-candidate-range";
import "server-only";
import { effectiveBirthDate } from "./effective-birth-date";
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";
import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "@/lib/personal-report-worker-subject";
import { readReportBirthFingerprint } from "@/lib/report-birth-binding";
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: ReportCandidateClockRange | null = null,
) {
const profile = record(context.profile);
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
const birthDate = parseBirthDate(effectiveBirthDate(profile));
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,
birthFingerprint: readReportBirthFingerprint(profile),
});
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: ReportCandidateClockRange | 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: ReportCandidateClockRange | null,
admin: AppendixClient,
) {
if (PERSONAL_REPORT_WRITER_ENABLED) {
return generateWriterReport(context, candidateRange);
}
return generateLongformReport(context, candidateRange, admin);
}
function createProductionWorker(workerId: string) {
const admin = createAdminSupabaseClient();
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: (userId, subjectId) => loadReportWorkerProfile(admin, userId, subjectId),
generate: async (context) => {
const profile = record(context.profile) ?? {};
if (!reportWorkerUsesCandidateRange(context.report, profile)) {
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;
}