feat: add persistent personal report document v2

This commit is contained in:
Jesse_Chen
2026-08-15 05:10:37 +08:00
parent 9d2d79801d
commit 5012ff7212
48 changed files with 9301 additions and 932 deletions
@@ -4,23 +4,22 @@ import {
safeParseReportDocument,
ReportDocumentValidationError,
type EvidenceAppendix,
type ReportDocument,
type ReportDocumentParseResult,
type ReportDocumentV1,
} from "./personal-report-contract.ts";
/**
* Server hash core for ReportDocument v1.
* Server hash core for ReportDocument v1/v2.
*
* Pure Node implementation (node:crypto) without the server-only marker so
* tests can import it directly; the production entry
* personal-report-contract.server.ts adds `import "server-only"` and
* re-exports this module. Client bundles must never import this file: besides
* the marker on the production entry, node:crypto fails Next.js client builds.
* re-exports this module. Client bundles must never import this file.
*
* Flow per the architecture ruling: canonical isomorphic parse first, then
* verify provenance.evidenceHash against the recomputed hash. The hash is
* never trusted as a model self-report. The Python validator
* (scripts/personal_report_contract.py) performs the same recomputation.
* The canonical isomorphic parse runs first, then provenance.evidenceHash is
* recomputed over the evidence appendix. Stored documents are never trusted to
* self-report their evidence hash. The Python validator performs the same
* recomputation for both schema versions.
*/
export function computeEvidenceHash(appendix: EvidenceAppendix): string {
@@ -35,16 +34,18 @@ export function safeParseServerReportDocument(input: unknown): ReportDocumentPar
if (parsed.document.provenance.evidenceHash !== recomputed) {
return {
ok: false,
errors: [{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
}],
errors: [
{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
},
],
};
}
return parsed;
}
export function parseServerReportDocument(input: unknown): ReportDocumentV1 {
export function parseServerReportDocument(input: unknown): ReportDocument {
const result = safeParseServerReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
+311 -145
View File
@@ -1,25 +1,29 @@
/**
* ReportDocument v1 contract (isomorphic Zod side).
* ReportDocument v1/v2 contract (isomorphic Zod side).
*
* This file is importable from server and client bundles: it contains no
* node:crypto and no hash recomputation. Semantics are shared with:
* - contracts/personal-report/report-document.v1.schema.json (JSON Schema)
* - scripts/personal_report_contract.py (stdlib Python validator)
* - frontend/src/lib/personal-report-contract.server-core.ts (server hash)
* ReportDocument v2 is the canonical current contract. The v1 schema and
* compatibility type alias remain readable while the existing producer and
* renderer migrate independently. This file contains no node:crypto; evidence
* hashes are recomputed only by the server entry and the Python validator.
*
* JSON Schema draft-07 cannot express every rule; the runtime-enforced
* semantics below (chart-set invariants, evidenceRefs existence, blocked
* non-determinism, forbidden content, evidence-id uniqueness, serialization
* cap) are implemented identically in this file and in the Python validator,
* with tests on both sides. The cryptographic evidence hash is verified only
* by the server runtime (personal-report-contract.server.ts) and the Python
* validator; it is never part of this isomorphic parse.
* JSON Schema draft-07 cannot express every semantic rule. The runtime guards
* below are mirrored by scripts/personal_report_contract.py: chart integrity,
* requested-theme coverage, evidence-reference closure, evidence-id
* uniqueness, blocked-claim safety, unsupported date rejection, content
* safety, and the serialization cap.
*/
import { z } from "zod";
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_CONTRACT_VERSION = "1" as const;
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
export const REPORT_DOCUMENT_V1_SCHEMA_VERSION = LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_DOCUMENT_V2_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_CONTRACT_V1_VERSION = "1" as const;
export const REPORT_CONTRACT_V2_VERSION = "2" as const;
/** @deprecated Existing v1 producer compatibility. */
export const REPORT_CONTRACT_VERSION = REPORT_CONTRACT_V1_VERSION;
export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap.
export const CLAIM_STATUSES = [
@@ -34,12 +38,18 @@ export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PRESENTATION_MODES = ["default", "research"] as const;
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const CHART_IDS = ["D1", "D9", "D10"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D9", "D10", "D11", "D24"] as const;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id");
const sectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/, "invalid section id");
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex");
const skillNameSchema = z.string()
@@ -52,7 +62,9 @@ const iso8601Schema = z.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp");
const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength);
const textArray = (maxItems: number, maxLength: number) => z.array(text(maxLength)).max(maxItems);
const textArray = (maxItems: number, maxLength: number, minItems = 0) =>
z.array(text(maxLength)).min(minItems).max(maxItems);
const evidenceRefsSchema = (minItems = 0) => z.array(evidenceIdSchema).min(minItems).max(24);
const houseSchema = z.strictObject({
houseNumber: z.number().int().min(1).max(12),
@@ -68,22 +80,42 @@ const planetSchema = z.strictObject({
retrograde: z.boolean(),
});
const chartSchema = z.strictObject({
id: z.enum(CHART_IDS),
const reportDocumentV1ChartSchema = z.strictObject({
id: z.enum(REPORT_DOCUMENT_V1_CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
});
const thematicSectionSchema = z.strictObject({
const reportDocumentV2ChartSchema = z.strictObject({
id: z.enum(CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const thematicSectionV1Schema = z.strictObject({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"),
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceIdSchema).max(24),
evidenceRefs: evidenceRefsSchema(),
});
const thematicSectionV2Schema = z.strictObject({
id: sectionIdSchema,
theme: themeIdSchema,
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const techniqueAuditRowSchema = z.strictObject({
@@ -117,46 +149,139 @@ const evidenceAppendixSchema = z.strictObject({
blockedTechniques: textArray(100, 120),
});
const reportDocumentShape = {
schemaVersion: z.literal(REPORT_DOCUMENT_SCHEMA_VERSION),
const subjectSchema = z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
});
const provenanceV1Schema = z.strictObject({
// Optional only so legacy v1 rows generated before named/versioned Skill
// provenance remain readable. Current v2 generation requires both.
skillName: skillNameSchema.optional(),
skillVersion: skillVersionSchema.optional(),
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V1_VERSION),
});
const provenanceV2Schema = z.strictObject({
skillName: skillNameSchema,
skillVersion: skillVersionSchema,
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V2_VERSION),
});
const executiveSummaryV1Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
});
const executiveSummaryV2Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const natalFoundationSchema = z.strictObject({
title: text(160),
narrative: text(4000),
keyFactors: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const currentPhaseSchema = z.strictObject({
title: text(160),
phaseLabel: text(200),
narrative: text(4000),
timingNotes: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const actionNoteSchema = z.strictObject({
id: sectionIdSchema,
title: text(160),
note: text(1000),
priority: z.enum(REPORT_ACTION_PRIORITIES),
evidenceRefs: evidenceRefsSchema(1),
});
const blockedConflictDisclosureSchema = z.strictObject({
theme: themeIdSchema,
title: text(160),
reason: text(2000),
missingEvidence: textArray(24, 400, 1),
conflictNotes: textArray(24, 500),
evidenceRefs: evidenceRefsSchema(),
claimStatus: z.literal("blocked"),
});
export const reportDocumentV1Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V1_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
generatedAt: iso8601Schema,
subject: z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
}),
provenance: z.strictObject({
// Optional only so ReportDocument v1 rows generated before named/versioned
// Skill provenance remain readable. Current generation always emits both.
skillName: skillNameSchema.optional(),
skillVersion: skillVersionSchema.optional(),
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_VERSION),
}),
executiveSummary: z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
}),
charts: z.array(chartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionSchema).max(12),
subject: subjectSchema,
provenance: provenanceV1Schema,
executiveSummary: executiveSummaryV1Schema,
charts: z.array(reportDocumentV1ChartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionV1Schema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
};
});
export const reportDocumentSchema = z.strictObject(reportDocumentShape);
export const reportDocumentV2Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V2_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
depth: z.enum(REPORT_DEPTHS),
requestedThemes: z.array(themeIdSchema).min(1).max(12),
generatedAt: iso8601Schema,
subject: subjectSchema,
provenance: provenanceV2Schema,
executiveSummary: executiveSummaryV2Schema,
natalFoundation: natalFoundationSchema,
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(24),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
});
export type ReportDocumentV1 = z.infer<typeof reportDocumentSchema>;
export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"];
export type ChartV1 = ReportDocumentV1["charts"][number];
export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][number];
/** Canonical reader accepts current v2 plus stored v1 rows during migration. */
export const reportDocumentSchema = z.union([reportDocumentV2Schema, reportDocumentV1Schema]);
export type LegacyReportDocumentV1 = z.infer<typeof reportDocumentV1Schema>;
export type ReportDocumentV2 = z.infer<typeof reportDocumentV2Schema>;
export type ReportDocument = LegacyReportDocumentV1 | ReportDocumentV2;
/** @deprecated Compatibility name retained for existing producer/renderer imports. */
export type ReportDocumentV1 = LegacyReportDocumentV1;
export type EvidenceAppendix = ReportDocument["evidenceAppendix"];
export type ChartV1 = LegacyReportDocumentV1["charts"][number];
export type ChartV2 = ReportDocumentV2["charts"][number];
export type ThematicSectionV1 = LegacyReportDocumentV1["thematicNarrative"][number];
export type ThematicSectionV2 = ReportDocumentV2["thematicNarrative"][number];
export type NatalFoundationV2 = ReportDocumentV2["natalFoundation"];
export type CurrentPhaseV2 = ReportDocumentV2["currentPhase"];
export type ActionNoteV2 = ReportDocumentV2["actionNotes"][number];
export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDisclosure"][number];
export type ReportDocumentParseError = Readonly<{
path: string;
@@ -173,6 +298,10 @@ export class ReportDocumentValidationError extends Error {
}
}
export function isReportDocumentV2(document: ReportDocument): document is ReportDocumentV2 {
return document.schemaVersion === REPORT_DOCUMENT_V2_SCHEMA_VERSION;
}
/** Canonical evidence object used by the evidence hash on both language sides. */
export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, unknown> {
return {
@@ -199,18 +328,18 @@ export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, un
};
}
export function serializedReportDocumentBytes(document: ReportDocumentV1): number {
export function serializedReportDocumentBytes(document: ReportDocument): number {
return new TextEncoder().encode(JSON.stringify(document)).length;
}
/**
* Forbidden content patterns. Keep byte-for-byte equivalent to
* FORBIDDEN_PATTERNS in scripts/personal_report_contract.py.
*/
/** Keep regex semantics equivalent to FORBIDDEN_PATTERNS on the Python side. */
export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [
{ name: "html_tag_open", pattern: /<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b/i },
{ name: "html_tag_close", pattern: /<\/\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>/i },
{ name: "html_tag", pattern: /<\s*\/?\s*[a-z][^>]*>/i },
{ name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i },
{ name: "style_attribute", pattern: /\bstyle\s*=/i },
{ name: "css_at_rule", pattern: /@(?:import|media|supports|font-face|keyframes)\b/i },
{ name: "css_rule", pattern: /(?:^|[}\s])(?:[.#]?[a-z][a-z0-9_-]*)(?:\s+[.#]?[a-z][a-z0-9_-]*)*\s*\{[^{}]*\}/i },
{ name: "css_declaration", pattern: /(?:^|[;{\s])(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|display|position|margin|padding|width|height|grid|flex|border|transform|animation)\s*:\s*[^;\n{}]+[;}]?/i },
{ name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i },
{ name: "processing_instruction", pattern: /<\?/i },
{ name: "template_literal", pattern: /\$\{/i },
@@ -223,71 +352,92 @@ export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; patte
{ name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i },
{ name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i },
{ name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i },
{ name: "medical_diagnosis", pattern: /(?:你|命主)(?:已经|已|必将|一定会|确定)?(?:患有|罹患|得了)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|(?:确诊为|诊断为)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|\b(?:you|the native)\s+(?:definitely\s+)?(?:have|has|will develop)\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b|\bdiagnosed with\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b/i },
{ name: "deterministic_financial_promise", pattern: /(?:保证收益|保本保收益|稳赚不赔|稳赚|必赚|确定(?:盈利|获利|回报)|一定会(?:赚钱|盈利|获利))|\b(?:guaranteed|certain|risk-free)\s+(?:profit|return|gain)s?\b|\bwill definitely\s+(?:profit|earn|make money)\b/i },
];
/**
* Deterministic-prediction phrases forbidden inside blocked sections.
* Keep equivalent to DETERMINISTIC_PHRASES in the Python validator.
*/
export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
];
const DATE_CLAIM_PATTERN = /(?:^|[^0-9])(?:19|20)\d{2}(?:年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\d|3[01])日)?|[-/.](?:0?[1-9]|1[0-2])(?:[-/.](?:0?[1-9]|[12]\d|3[01]))?)(?:[^0-9]|$)/i;
export function findForbiddenContent(value: string): readonly string[] {
return FORBIDDEN_CONTENT_PATTERNS
.filter(({ pattern }) => pattern.test(value))
.map(({ name }) => name);
}
function blockedTexts(document: ReportDocumentV1): readonly Readonly<{ path: string; text: string }>[] {
function textLeaves(value: unknown, path = ""): readonly Readonly<{ path: string; text: string }>[] {
if (typeof value === "string") return [{ path: path || "(root)", text: value }];
if (Array.isArray(value)) return value.flatMap((entry, index) => textLeaves(entry, `${path}[${index}]`));
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, entry]) => textLeaves(entry, path ? `${path}.${key}` : key));
}
return [];
}
function blockedTexts(document: ReportDocument): readonly Readonly<{ path: string; text: string }>[] {
const entries: { path: string; text: string }[] = [];
if (document.executiveSummary.overallClaimStatus === "blocked") {
entries.push({ path: "executiveSummary.headline", text: document.executiveSummary.headline });
entries.push({ path: "executiveSummary.summary", text: document.executiveSummary.summary });
document.executiveSummary.priorities.forEach((priority, index) => {
entries.push({ path: `executiveSummary.priorities[${index}]`, text: priority });
});
entries.push(...textLeaves(document.executiveSummary, "executiveSummary"));
}
document.charts.forEach((chart, index) => {
if (chart.claimStatus === "blocked") {
entries.push({ path: `charts[${index}].title`, text: chart.title });
}
if (chart.claimStatus === "blocked") entries.push(...textLeaves(chart, `charts[${index}]`));
});
document.thematicNarrative.forEach((section, index) => {
if (section.claimStatus !== "blocked") return;
entries.push({ path: `thematicNarrative[${index}].title`, text: section.title });
entries.push({ path: `thematicNarrative[${index}].narrative`, text: section.narrative });
section.actions.forEach((action, actionIndex) => {
entries.push({ path: `thematicNarrative[${index}].actions[${actionIndex}]`, text: action });
});
section.caveats.forEach((caveat, caveatIndex) => {
entries.push({ path: `thematicNarrative[${index}].caveats[${caveatIndex}]`, text: caveat });
});
if (section.claimStatus === "blocked") entries.push(...textLeaves(section, `thematicNarrative[${index}]`));
});
if (isReportDocumentV2(document)) {
if (document.natalFoundation.claimStatus === "blocked") {
entries.push(...textLeaves(document.natalFoundation, "natalFoundation"));
}
if (document.currentPhase?.claimStatus === "blocked") {
entries.push(...textLeaves(document.currentPhase, "currentPhase"));
}
document.blockedConflictDisclosure.forEach((section, index) => {
entries.push(...textLeaves(section, `blockedConflictDisclosure[${index}]`));
});
}
return entries;
}
export function findBlockedDeterministicClaims(document: ReportDocumentV1): readonly string[] {
export function findBlockedDeterministicClaims(document: ReportDocument): readonly string[] {
const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
return blockedTexts(document)
.filter(({ text }) => phrases.some((pattern) => pattern.test(text)))
.map(({ path }) => path);
}
export function findDanglingEvidenceRefs(document: ReportDocumentV1): readonly string[] {
function evidenceRefEntries(document: ReportDocument): readonly Readonly<{ path: string; refs: readonly string[] }>[] {
const entries: { path: string; refs: readonly string[] }[] = document.thematicNarrative.map((section, index) => ({
path: document.schemaVersion === REPORT_DOCUMENT_V1_SCHEMA_VERSION ? section.id : `thematicNarrative[${index}].evidenceRefs`,
refs: section.evidenceRefs,
}));
if (!isReportDocumentV2(document)) return entries;
entries.push({ path: "executiveSummary.evidenceRefs", refs: document.executiveSummary.evidenceRefs });
entries.push({ path: "natalFoundation.evidenceRefs", refs: document.natalFoundation.evidenceRefs });
if (document.currentPhase) entries.push({ path: "currentPhase.evidenceRefs", refs: document.currentPhase.evidenceRefs });
document.actionNotes.forEach((note, index) => entries.push({ path: `actionNotes[${index}].evidenceRefs`, refs: note.evidenceRefs }));
document.charts.forEach((chart, index) => entries.push({ path: `charts[${index}].evidenceRefs`, refs: chart.evidenceRefs }));
document.blockedConflictDisclosure.forEach((section, index) => entries.push({ path: `blockedConflictDisclosure[${index}].evidenceRefs`, refs: section.evidenceRefs }));
return entries;
}
export function findDanglingEvidenceRefs(document: ReportDocument): readonly string[] {
const knownIds = new Set<string>([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]);
return document.thematicNarrative.flatMap((section) =>
section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`),
return evidenceRefEntries(document).flatMap(({ path, refs }) =>
refs.filter((ref) => !knownIds.has(ref)).map((ref) => `${path}:${ref}`),
);
}
/** Evidence ids must be globally unique across the whole appendix. */
export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] {
export function findDuplicateEvidenceIds(document: ReportDocument): readonly string[] {
const locations = new Map<string, string>();
const duplicates: string[] = [];
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
@@ -298,16 +448,13 @@ export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly s
for (const { key, index, id } of rows) {
const location = `${key}[${index}]`;
const first = locations.get(id);
if (first !== undefined) {
duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
} else {
locations.set(id, location);
}
if (first !== undefined) duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
else locations.set(id, location);
}
return duplicates;
}
export function findChartSetViolations(document: ReportDocumentV1): readonly string[] {
export function findChartSetViolations(document: ReportDocument): readonly string[] {
const violations: string[] = [];
const ids = document.charts.map((chart) => chart.id);
const d1Count = ids.filter((id) => id === "D1").length;
@@ -317,71 +464,90 @@ export function findChartSetViolations(document: ReportDocumentV1): readonly str
if (seen.has(id)) violations.push(`duplicate chart id ${id}`);
seen.add(id);
}
document.charts.forEach((chart) => {
const numbers = chart.houses.map((house) => house.houseNumber);
if (new Set(numbers).size !== numbers.length) violations.push(`${chart.id} chart contains duplicate house numbers`);
});
const d1 = document.charts.find((chart) => chart.id === "D1");
if (d1) {
const numbers = d1.houses.map((house) => house.houseNumber);
const unique = new Set(numbers);
if (unique.size !== numbers.length) violations.push("D1 chart contains duplicate house numbers");
const unique = new Set(d1.houses.map((house) => house.houseNumber));
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
if (numbers.length !== 12 || expected.some((number) => !unique.has(number))) {
if (d1.houses.length !== 12 || expected.some((number) => !unique.has(number))) {
violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once");
}
}
return violations;
}
export function validateReportDocumentGuards(document: ReportDocumentV1): readonly string[] {
const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
career: ["D10"],
wealth: ["D2", "D11"],
marriage: ["D9"],
education: ["D24"],
};
export function findThemeCoverageViolations(document: ReportDocumentV2): readonly string[] {
const violations: string[] = [];
const requestedCounts = new Map<string, number>();
document.requestedThemes.forEach((theme) => requestedCounts.set(theme, (requestedCounts.get(theme) ?? 0) + 1));
requestedCounts.forEach((count, theme) => {
if (count !== 1) violations.push(`requestedThemes must contain ${theme} exactly once, found ${count}`);
});
const coverageCounts = new Map<string, number>();
const requested = new Set(document.requestedThemes);
for (const section of document.thematicNarrative) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`thematic section covers unrequested theme ${section.theme}`);
}
for (const section of document.blockedConflictDisclosure) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`blocked disclosure covers unrequested theme ${section.theme}`);
}
for (const theme of requested) {
const count = coverageCounts.get(theme) ?? 0;
if (count !== 1) violations.push(`requested theme ${theme} must have exactly one thematic section or blocked disclosure, found ${count}`);
}
const chartIds = new Set(document.charts.map((chart) => chart.id));
for (const section of document.thematicNarrative) {
for (const requiredChart of REQUIRED_THEME_CHARTS[section.theme] ?? []) {
if (!chartIds.has(requiredChart)) {
violations.push(`thematic section ${section.theme} requires structured ${requiredChart} chart data or a blocked disclosure`);
}
}
}
return violations;
}
export function findUnsupportedDateClaims(document: ReportDocument): readonly string[] {
if (!isReportDocumentV2(document)) return [];
const sections: Readonly<{ path: string; value: unknown; refs: readonly string[] }>[] = [
{ path: "executiveSummary", value: document.executiveSummary, refs: document.executiveSummary.evidenceRefs },
{ path: "natalFoundation", value: document.natalFoundation, refs: document.natalFoundation.evidenceRefs },
...(document.currentPhase ? [{ path: "currentPhase", value: document.currentPhase, refs: document.currentPhase.evidenceRefs }] : []),
...document.thematicNarrative.map((section, index) => ({ path: `thematicNarrative[${index}]`, value: section, refs: section.evidenceRefs })),
...document.actionNotes.map((note, index) => ({ path: `actionNotes[${index}]`, value: note, refs: note.evidenceRefs })),
...document.blockedConflictDisclosure.map((section, index) => ({ path: `blockedConflictDisclosure[${index}]`, value: section, refs: section.evidenceRefs })),
];
return sections
.filter(({ value, refs }) => refs.length === 0 && textLeaves(value).some(({ text }) => DATE_CLAIM_PATTERN.test(text)))
.map(({ path }) => `${path}: date claim requires evidenceRefs`);
}
export function validateReportDocumentGuards(document: ReportDocument): readonly string[] {
const errors: string[] = [];
errors.push(...findChartSetViolations(document));
errors.push(...findDuplicateEvidenceIds(document));
errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `thematicNarrative.evidenceRefs: unknown evidence id ${ref}`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `evidenceRefs: unknown evidence id ${ref}`));
errors.push(...findUnsupportedDateClaims(document));
if (isReportDocumentV2(document)) errors.push(...findThemeCoverageViolations(document));
const forbidden: { path: string; hits: readonly string[] }[] = [];
const collectTexts = (path: string, value: string) => {
for (const { path, text: value } of textLeaves(document)) {
const hits = findForbiddenContent(value);
if (hits.length > 0) forbidden.push({ path, hits });
};
collectTexts("subject.displayName", document.subject.displayName);
collectTexts("subject.birthPlaceLabel", document.subject.birthPlaceLabel);
collectTexts("executiveSummary.headline", document.executiveSummary.headline);
collectTexts("executiveSummary.summary", document.executiveSummary.summary);
document.executiveSummary.priorities.forEach((priority, index) => collectTexts(`executiveSummary.priorities[${index}]`, priority));
document.charts.forEach((chart, chartIndex) => {
collectTexts(`charts[${chartIndex}].title`, chart.title);
chart.houses.forEach((house, houseIndex) => {
collectTexts(`charts[${chartIndex}].houses[${houseIndex}].sign`, house.sign);
house.occupants.forEach((occupant, occupantIndex) => collectTexts(`charts[${chartIndex}].houses[${houseIndex}].occupants[${occupantIndex}]`, occupant));
});
(chart.planets ?? []).forEach((planet, planetIndex) => {
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].name`, planet.name);
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].sign`, planet.sign);
});
});
document.thematicNarrative.forEach((section, sectionIndex) => {
collectTexts(`thematicNarrative[${sectionIndex}].title`, section.title);
collectTexts(`thematicNarrative[${sectionIndex}].narrative`, section.narrative);
section.actions.forEach((action, actionIndex) => collectTexts(`thematicNarrative[${sectionIndex}].actions[${actionIndex}]`, action));
section.caveats.forEach((caveat, caveatIndex) => collectTexts(`thematicNarrative[${sectionIndex}].caveats[${caveatIndex}]`, caveat));
});
document.evidenceAppendix.techniqueAudit.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].techniqueName`, row.techniqueName);
if (row.notes !== undefined) collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].notes`, row.notes);
});
document.evidenceAppendix.conflicts.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].description`, row.description);
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].impact`, row.impact);
});
document.evidenceAppendix.calculationEvidence.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].label`, row.label);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].value`, row.value);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].source`, row.source);
});
document.evidenceAppendix.blockedTechniques.forEach((technique, rowIndex) => {
collectTexts(`evidenceAppendix.blockedTechniques[${rowIndex}]`, technique);
});
collectTexts("disclaimer", document.disclaimer);
forbidden.forEach(({ path, hits }) => errors.push(`${path}: forbidden content ${hits.join(",")}`));
if (hits.length > 0) errors.push(`${path}: forbidden content ${hits.join(",")}`);
}
const size = serializedReportDocumentBytes(document);
if (size > REPORT_DOCUMENT_MAX_BYTES) {
@@ -391,7 +557,7 @@ export function validateReportDocumentGuards(document: ReportDocumentV1): readon
}
export type ReportDocumentParseResult =
| Readonly<{ ok: true; document: ReportDocumentV1 }>
| Readonly<{ ok: true; document: ReportDocument }>
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
@@ -416,7 +582,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
return { ok: true, document };
}
export function parseReportDocument(input: unknown): ReportDocumentV1 {
export function parseReportDocument(input: unknown): ReportDocument {
const result = safeParseReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
+387 -13
View File
@@ -4,8 +4,11 @@ import {
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import type {
ClaimStatus,
EvidenceAppendix,
ReportDepth,
ReportDocumentV1,
ReportDocumentV2,
} from "./personal-report-contract.ts";
import type {
EvidenceRefStatus,
@@ -25,6 +28,7 @@ import type {
import { finalizeReportEvidenceBundleV2, validateReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan } from "./personal-report-plan.ts";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
@@ -84,6 +88,7 @@ const sha256Pattern = /^[0-9a-f]{64}$/;
export function computeRequestFingerprint(input: Readonly<{
reportType: string;
presentationMode: string;
depth: string;
themes: readonly string[];
sessionId: string | null;
chartProfileId: string | null;
@@ -91,6 +96,7 @@ export function computeRequestFingerprint(input: Readonly<{
return sha256Hex(canonicalSerialize({
reportType: input.reportType,
presentationMode: input.presentationMode,
depth: input.depth,
themes: [...new Set(input.themes)].sort(),
sessionId: input.sessionId,
chartProfileId: input.chartProfileId,
@@ -123,7 +129,7 @@ let cachedSkillSnapshot: SkillSnapshot | null = null;
export function resolveSkillSnapshot(): SkillSnapshot {
if (cachedSkillSnapshot) return cachedSkillSnapshot;
try {
const identity = resolveActiveSkillPackage("jyotish-vedic-astrology");
const identity = resolveActiveSkillPackage("jyotish-personal-report");
cachedSkillSnapshot = {
name: identity.name,
version: identity.version,
@@ -1404,6 +1410,347 @@ export function assembleReportDocument(
return document;
}
export type AssembleReportDocumentV2Input = Readonly<{
reportId: string;
generatedAt: string;
depth: ReportDepth;
bundle: ReportEvidenceBundleV2;
plan: PersonalReportSectionPlan;
agentOutput: PersonalReportAgentOutput;
}>;
const CLAIM_STATUS_RANK: Readonly<Record<ClaimStatus, number>> = {
multi_system_consensus: 0,
single_system_inference: 1,
parameter_sensitive: 2,
unclosed_divisional_chart: 3,
user_history_verification_required: 4,
blocked: 5,
};
function uniqueInOrder(values: readonly string[]): string[] {
return [...new Set(values)];
}
function equalStringArrays(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* The model is a writer only. This validation binds every generated thematic
* section to the deterministic server plan and its single Claim Card before
* any model text can enter ReportDocument v2.
*/
export function validatePersonalReportAgentOutputAgainstPlan(
output: PersonalReportAgentOutput,
plan: PersonalReportSectionPlan,
bundle: ReportEvidenceBundleV2,
): PersonalReportAgentOutput {
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
if (output.thematicNarrative.length !== writePlans.length) {
throw new Error("report_writer_theme_count_mismatch");
}
const seen = new Set<string>();
const claimCards = new Map(bundle.claimCards.map((card) => [card.theme, card]));
const plans = new Map(writePlans.map((section) => [section.theme as string, section]));
for (const section of output.thematicNarrative) {
if (seen.has(section.theme)) throw new Error(`report_writer_duplicate_theme:${section.theme}`);
seen.add(section.theme);
const sectionPlan = plans.get(section.theme);
const card = claimCards.get(section.theme);
if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`);
if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`);
if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) {
throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`);
}
if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) {
throw new Error(`report_writer_claim_status_upgrade:${section.theme}`);
}
}
for (const sectionPlan of writePlans) {
if (!seen.has(sectionPlan.theme as string)) {
throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`);
}
}
return output;
}
function claimStatusForEvidenceStatus(status: EvidenceRefStatus): ClaimStatus {
if (status === "verified") return "single_system_inference";
if (status === "partial") return "parameter_sensitive";
return "blocked";
}
function evidenceRefsForTechnique(
bundle: ReportEvidenceBundleV2,
technique: string,
requireExecuted = true,
): string[] {
const normalized = technique.toUpperCase();
return bundle.executionLedger.filter((receipt) => {
const receiptTechnique = receipt.technique.toUpperCase();
const matches = receiptTechnique === normalized || receiptTechnique.endsWith(`:${normalized}`);
return matches && (!requireExecuted || (receipt.executed && receipt.status !== "blocked"));
}).map((receipt) => receipt.id);
}
function evidenceAppendixFromBundle(
bundle: ReportEvidenceBundleV2,
usedEvidenceRefs: ReadonlySet<string>,
): EvidenceAppendix {
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const calculationEvidence: EvidenceAppendix["calculationEvidence"] = [];
if (bundle.calculationProfile.calculationHashDerived) {
calculationEvidence.push({
id: "ev-calc-derived",
label: "calculation_hash",
value: bundle.calculationProfile.calculationHash,
source: "derived_server_sha256_over_allowlisted_calculation_facts",
});
}
if (d1?.ascendant) {
calculationEvidence.push({
id: "ev-calc-ascendant",
label: "ascendant",
value: `${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°`,
source: "server_calculation",
});
}
bundle.calculationProfile.vimshottari?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-vimshottari-${index + 1}`,
label: `Vimshottari 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
bundle.calculationProfile.narayana?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-narayana-${index + 1}`,
label: `Narayana 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
const ledgerById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
expandedByDefault: bundle.presentationMode === "research",
techniqueAudit: bundle.executionLedger.map((receipt, index) => ({
id: receipt.id,
techniqueId: techniqueSlug(receipt.technique, `tech-${index + 1}`),
techniqueName: receipt.technique,
status: receipt.status,
used: usedEvidenceRefs.has(receipt.id),
...(receipt.note ? { notes: receipt.note.slice(0, 500) } : {}),
})),
conflicts: bundle.conflicts.map((conflict) => ({
id: conflict.id,
description: conflict.summary,
impact: conflict.resolutionStatus === "bounded"
? "冲突已被限制在证据边界内,相关结论不得提升确定性"
: "多技法结果不一致,相关结论已按确定性边界降级",
status: conflict.resolutionStatus === "bounded" ? "partial" : "unresolved",
})),
calculationEvidence,
blockedTechniques: uniqueInOrder(bundle.blockedSections.flatMap((section) => (
section.missingTechniqueRefs.map((ref) => ledgerById.get(ref)?.technique ?? ref)
))).slice(0, 100),
};
}
function canonicalBundleChart(
chart: ReportChartFact,
evidenceRefs: readonly string[],
status: ClaimStatus,
): ReportDocumentV2["charts"][number] {
const planets = chart.planets.filter((planet) => planet.house !== null).slice(0, 12).map((planet) => ({
name: planet.id,
sign: planet.sign,
longitudeDegrees: ((planet.degree % 360) + 360) % 360,
houseNumber: planet.house as number,
retrograde: planet.retrograde ?? false,
}));
return {
id: chart.id as ReportDocumentV2["charts"][number]["id"],
title: chart.title,
houses: chart.houses.map((house) => ({
houseNumber: house.number,
sign: house.sign,
occupants: [...house.occupants].slice(0, 12),
})),
...(planets.length > 0 ? { planets } : {}),
claimStatus: status,
evidenceRefs: [...evidenceRefs],
};
}
/** Assemble the canonical current report contract without model-generated markup. */
export function assembleReportDocumentV2(
input: AssembleReportDocumentV2Input,
): ReportDocumentV2 {
const bundle = validateReportEvidenceBundleV2(input.bundle);
const plan = validatePersonalReportSectionPlan(input.plan, bundle);
const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle);
if (bundle.skill.name !== "jyotish-personal-report") {
throw new Error("report_writer_skill_package_invalid");
}
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const d1Refs = evidenceRefsForTechnique(bundle, "D1");
if (!d1 || !d1.ascendant || d1Refs.length === 0) {
throw new ReportEvidenceInsufficientError("d1_evidence_missing");
}
const thematicNarrative: ReportDocumentV2["thematicNarrative"] = agentOutput.thematicNarrative.map((section) => ({
id: section.id,
theme: section.theme,
title: section.title,
narrative: section.narrative,
actions: [...section.actions],
caveats: [...section.caveats],
claimStatus: section.claimStatus,
evidenceRefs: [...section.evidenceRefs],
}));
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]);
const charts: ReportDocumentV2["charts"] = [];
for (const chart of bundle.charts) {
if (!chartIds.has(chart.id)) continue;
const refs = evidenceRefsForTechnique(bundle, chart.id);
if (refs.length === 0) continue;
const statuses = refs.map((ref) => bundle.evidenceRefs.find((entry) => entry.id === ref)?.status ?? "blocked");
const status = statuses.every((entry) => entry === "verified")
? "single_system_inference"
: statuses.some((entry) => entry === "blocked")
? "parameter_sensitive"
: claimStatusForEvidenceStatus(statuses[0]);
charts.push(canonicalBundleChart(chart, refs, status));
}
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = bundle.blockedSections.map((section) => {
const missingEvidence = section.missingTechniqueRefs.map((ref) => (
bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref
));
const conflictNotes = bundle.conflicts.filter((conflict) => (
conflict.techniqueRefs.some((ref) => section.missingTechniqueRefs.includes(ref))
)).map((conflict) => conflict.summary);
return {
theme: section.theme,
title: section.section,
reason: section.reason,
missingEvidence,
conflictNotes,
evidenceRefs: [...section.missingTechniqueRefs],
claimStatus: "blocked" as const,
};
});
const timingSection = thematicNarrative.find((section) => section.theme === "timing");
const currentPhase: ReportDocumentV2["currentPhase"] = timingSection
? {
title: "当前阶段与时间边界",
phaseLabel: bundle.answerPolicy.canAnswerPreciseTiming ? "当前阶段" : "当前阶段(方向性)",
narrative: timingSection.narrative,
timingNotes: [],
caveats: [...timingSection.caveats],
claimStatus: timingSection.claimStatus,
evidenceRefs: [...timingSection.evidenceRefs],
}
: null;
const actionNotes: ReportDocumentV2["actionNotes"] = [];
thematicNarrative.forEach((section) => {
section.actions.forEach((action, index) => actionNotes.push({
id: `action-${section.theme.replaceAll(".", "-")}-${index + 1}`,
title: section.title,
note: action,
priority: index === 0 ? "now" : index === 1 ? "next" : "watch",
evidenceRefs: [...section.evidenceRefs],
}));
});
if (actionNotes.length === 0) {
const fallback = agentOutput.executiveSummary.priorities[0]
?? "将本报告列出的证据边界与待核验事项作为后续行动清单。";
actionNotes.push({
id: "action-evidence-review",
title: "后续核验",
note: fallback,
priority: "now",
evidenceRefs: [...executiveRefs],
});
}
const usedEvidenceRefs = new Set<string>([
...executiveRefs,
...d1Refs,
...thematicNarrative.flatMap((section) => section.evidenceRefs),
...actionNotes.flatMap((note) => note.evidenceRefs),
...charts.flatMap((chart) => chart.evidenceRefs),
...blockedConflictDisclosure.flatMap((section) => section.evidenceRefs),
]);
const appendix = evidenceAppendixFromBundle(bundle, usedEvidenceRefs);
const evidenceHash = computeEvidenceHash(appendix);
const hasBlockedCoverage = blockedConflictDisclosure.length > 0;
const weakestThematicStatus = thematicNarrative.reduce<ClaimStatus>((weakest, section) => (
CLAIM_STATUS_RANK[section.claimStatus] > CLAIM_STATUS_RANK[weakest] ? section.claimStatus : weakest
), "multi_system_consensus");
return {
schemaVersion: "report_document.v2",
reportId: input.reportId,
reportType: bundle.reportType,
presentationMode: bundle.presentationMode,
depth: input.depth,
requestedThemes: [...bundle.requestedThemes],
generatedAt: input.generatedAt,
subject: { ...bundle.subject },
provenance: {
skillName: bundle.skill.name,
skillVersion: bundle.skill.version,
skillSourceCommit: bundle.skill.sourceCommit,
skillSnapshotSha256: bundle.skill.sha256,
calculationHash: bundle.calculationProfile.calculationHash,
evidenceHash,
reportContractVersion: "2",
},
executiveSummary: {
headline: agentOutput.executiveSummary.headline,
summary: agentOutput.executiveSummary.summary,
priorities: [...agentOutput.executiveSummary.priorities],
overallClaimStatus: thematicNarrative.length === 0 || hasBlockedCoverage
? "blocked"
: weakestThematicStatus,
evidenceRefs: [...executiveRefs],
},
natalFoundation: {
title: "本命基础",
narrative: `D1 本命盘上升点位于 ${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°。以下基础结构仅陈述服务器排盘事实,不自行增加未提供的占星推断。`,
keyFactors: d1.houses.slice(0, 12).map((house) => (
`${house.number} 宫落 ${house.sign}${house.occupants.length > 0 ? `,宫内对象:${house.occupants.join("、")}` : ""}`
)),
caveats: bundle.subject.birthTimeStatus === "confirmed"
? ["本节仍须与具体主题证据和执行凭证共同阅读。"]
: ["出生时间尚未达到 confirmed;涉及宫位敏感与时间性判断仅可作方向性参考。"],
claimStatus: claimStatusForEvidenceStatus(
bundle.evidenceRefs.find((entry) => entry.id === d1Refs[0])?.status ?? "blocked",
),
evidenceRefs: [...d1Refs],
},
currentPhase,
actionNotes,
charts,
thematicNarrative,
blockedConflictDisclosure,
evidenceAppendix: appendix,
disclaimer: PERSONAL_REPORT_DISCLAIMER,
};
}
// ---------------------------------------------------------------------------
// Deterministic post-generation guard
// ---------------------------------------------------------------------------
@@ -1521,7 +1868,13 @@ export function projectReportGuardReadModel(document: unknown): ReportGuardReadM
caveats: stringArray(row?.caveats),
});
}
if (sections.length === 0) return null;
if (sections.length === 0) {
const schemaVersion = text(root.schemaVersion);
const blockedDisclosures = Array.isArray(root.blockedConflictDisclosure)
? root.blockedConflictDisclosure
: [];
if (schemaVersion !== "report_document.v2" || blockedDisclosures.length === 0) return null;
}
return { executiveSummary: { headline, summary, overallClaimStatus }, sections };
}
@@ -1718,7 +2071,7 @@ export function applyReportGuard<D>(
}
}
}
if (summary && blockedSectionCount === readModel.sections.length) {
if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) {
summary.overallClaimStatus = "blocked";
}
@@ -1733,14 +2086,16 @@ type GeneratePersonalReportBaseDeps = Readonly<{
reportId: string;
agent: ReportAgentPort;
now?: () => Date;
signal?: AbortSignal;
}>;
export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{
bundle: ReportEvidenceBundleV2;
depth: ReportDepth;
}>;
export type GeneratePersonalReportResult = Readonly<
| { status: "ready"; document: ReportDocumentV1; evidenceHash: string }
| { status: "ready"; document: ReportDocumentV2; evidenceHash: string }
| { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" }
>;
@@ -1755,21 +2110,40 @@ export type GeneratePersonalReportResult = Readonly<
export async function generatePersonalReport(
deps: GeneratePersonalReportDeps,
): Promise<GeneratePersonalReportResult> {
const bundle = validateReportEvidenceBundleV2(deps.bundle);
let bundle: ReportEvidenceBundleV2;
let plan: PersonalReportSectionPlan;
let agentOutput: PersonalReportAgentOutput;
try {
bundle = validateReportEvidenceBundleV2(deps.bundle);
plan = validatePersonalReportSectionPlan(
buildPersonalReportSectionPlan(bundle, deps.depth),
bundle,
);
agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal });
validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle);
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const packet = buildLegacyPacketFromBundle(bundle);
const agentOutput = await deps.agent.generate(bundle);
const candidate = assembleReportDocument({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
packet,
agentOutput,
});
let candidate: ReportDocumentV2;
try {
candidate = assembleReportDocumentV2({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
depth: deps.depth,
bundle,
plan,
agentOutput,
});
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const guarded = applyReportGuard(candidate, packet);
if (!guarded.ok) {
return { status: "failed", failureCode: "report_guard_rejected" };
}
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok) {
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
return { status: "failed", failureCode: "report_schema_invalid" };
}
return {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
import "server-only";
export * from "./personal-report-job-service-core";
@@ -0,0 +1,203 @@
export const PERSONAL_REPORT_JOB_STATUSES = [
"queued",
"running",
"suspended",
"retrying",
"ready",
"failed",
"cancelled",
] as const;
export type PersonalReportJobStatus = (typeof PERSONAL_REPORT_JOB_STATUSES)[number];
export const PERSONAL_REPORT_JOB_ACTIVE_STATUSES = [
"queued",
"running",
"suspended",
"retrying",
] as const satisfies readonly PersonalReportJobStatus[];
export const PERSONAL_REPORT_JOB_TERMINAL_STATUSES = [
"ready",
"failed",
"cancelled",
] as const satisfies readonly PersonalReportJobStatus[];
export const DEFAULT_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 3;
export const MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 10;
const transitionTargets = {
queued: ["running", "suspended", "cancelled"],
running: ["suspended", "retrying", "ready", "failed", "cancelled"],
suspended: ["queued", "cancelled"],
retrying: ["queued", "running", "failed", "cancelled"],
ready: [],
failed: [],
cancelled: [],
} as const satisfies Record<PersonalReportJobStatus, readonly PersonalReportJobStatus[]>;
const requestFingerprintPattern = /^[0-9a-f]{64}$/;
const progressPhasePattern = /^[a-z][a-z0-9_]{0,63}$/;
export type PersonalReportJobStateErrorReason =
| "invalid_transition"
| "invalid_retry_budget"
| "invalid_progress_phase";
export class PersonalReportJobStateError extends Error {
readonly name = "PersonalReportJobStateError";
constructor(
readonly reason: PersonalReportJobStateErrorReason,
message: string,
) {
super(message);
}
}
export function isPersonalReportJobActive(status: PersonalReportJobStatus): boolean {
return (PERSONAL_REPORT_JOB_ACTIVE_STATUSES as readonly string[]).includes(status);
}
export function isPersonalReportJobTerminal(status: PersonalReportJobStatus): boolean {
return (PERSONAL_REPORT_JOB_TERMINAL_STATUSES as readonly string[]).includes(status);
}
export function canTransitionPersonalReportJob(
from: PersonalReportJobStatus,
to: PersonalReportJobStatus,
): boolean {
return (transitionTargets[from] as readonly PersonalReportJobStatus[]).includes(to);
}
export function assertPersonalReportJobTransition(
from: PersonalReportJobStatus,
to: PersonalReportJobStatus,
): void {
if (!canTransitionPersonalReportJob(from, to)) {
throw new PersonalReportJobStateError(
"invalid_transition",
`Personal report job cannot transition from ${from} to ${to}`,
);
}
}
function assertRetryBudget(attemptCount: number, maxAttempts: number): void {
if (
!Number.isInteger(attemptCount)
|| attemptCount < 0
|| !Number.isInteger(maxAttempts)
|| maxAttempts < 1
|| maxAttempts > MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS
|| attemptCount > maxAttempts
) {
throw new PersonalReportJobStateError(
"invalid_retry_budget",
`Invalid personal report retry budget: ${attemptCount}/${maxAttempts}`,
);
}
}
/**
* attemptCount is incremented when a worker successfully acquires a running
* lease. A retry may be scheduled only while another attempt remains.
*/
export function canRetryPersonalReportJob(
attemptCount: number,
maxAttempts: number,
): boolean {
assertRetryBudget(attemptCount, maxAttempts);
return attemptCount < maxAttempts;
}
export type PersonalReportJobLease = Readonly<{
status: PersonalReportJobStatus;
leaseOwner: string | null;
leaseExpiresAt: string | null;
}>;
function parsedTime(value: string | null): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* A running row without a valid expiry is treated as expired. This fail-closed
* rule prevents malformed leases from holding a user's active slot forever.
*/
export function isPersonalReportJobLeaseExpired(
job: PersonalReportJobLease,
now: Date,
): boolean {
if (job.status !== "running") return false;
const expiresAt = parsedTime(job.leaseExpiresAt);
return expiresAt === null || expiresAt <= now.getTime();
}
export function canHeartbeatPersonalReportJobLease(
job: PersonalReportJobLease,
workerId: string,
now: Date,
): boolean {
const expiresAt = parsedTime(job.leaseExpiresAt);
return job.status === "running"
&& workerId.length > 0
&& job.leaseOwner === workerId
&& expiresAt !== null
&& expiresAt > now.getTime();
}
export type PersonalReportExpiredLeaseRecovery =
| Readonly<{ kind: "none"; nextStatus: null }>
| Readonly<{ kind: "retry"; nextStatus: "retrying" }>
| Readonly<{ kind: "exhausted"; nextStatus: "failed" }>;
export function recoverExpiredPersonalReportJobLease(
job: PersonalReportJobLease & Readonly<{ attemptCount: number; maxAttempts: number }>,
now: Date,
): PersonalReportExpiredLeaseRecovery {
assertRetryBudget(job.attemptCount, job.maxAttempts);
if (!isPersonalReportJobLeaseExpired(job, now)) {
return { kind: "none", nextStatus: null };
}
return canRetryPersonalReportJob(job.attemptCount, job.maxAttempts)
? { kind: "retry", nextStatus: "retrying" }
: { kind: "exhausted", nextStatus: "failed" };
}
export function isPersonalReportRequestFingerprint(value: string): boolean {
return requestFingerprintPattern.test(value);
}
export type PersonalReportJobRequestIdentity = Readonly<{
requestId: string;
requestFingerprint: string;
}>;
export type PersonalReportJobRequestMatch = "new" | "replay" | "conflict";
/**
* requestId is the idempotency key. Reusing it with the same canonical request
* fingerprint is a replay; reusing it for different intent is a conflict.
*/
export function classifyPersonalReportJobRequest(
existing: PersonalReportJobRequestIdentity | null,
incoming: PersonalReportJobRequestIdentity,
): PersonalReportJobRequestMatch {
if (!existing || existing.requestId !== incoming.requestId) return "new";
return existing.requestFingerprint === incoming.requestFingerprint ? "replay" : "conflict";
}
export function isValidPersonalReportJobProgressPhase(value: string): boolean {
return progressPhasePattern.test(value);
}
export function assertPersonalReportJobProgressPhase(value: string): void {
if (!isValidPersonalReportJobProgressPhase(value)) {
throw new PersonalReportJobStateError(
"invalid_progress_phase",
`Invalid personal report progress phase: ${value}`,
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod";
import type { ReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const REPORT_SECTION_KINDS = [
"executive_summary",
"natal_foundation",
"thematic",
"current_phase",
"action_notes",
"charts",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
"disclaimer",
] as const;
export type ReportSectionKind = (typeof REPORT_SECTION_KINDS)[number];
export type ReportSectionPlanEntry = Readonly<{
id: string;
kind: ReportSectionKind;
theme: string | null;
disposition: "write" | "blocked";
evidenceRefs: readonly string[];
targetCharacters: Readonly<{ min: number; max: number }>;
}>;
export type PersonalReportSectionPlan = Readonly<{
schemaVersion: "personal_report_section_plan.v1";
depth: ReportDepth;
sections: readonly ReportSectionPlanEntry[];
}>;
const characterTargets: Record<ReportDepth, Readonly<{ min: number; max: number }>> = {
concise: { min: 120, max: 500 },
standard: { min: 260, max: 1_200 },
deep: { min: 480, max: 2_000 },
research: { min: 600, max: 2_800 },
};
const reportSectionPlanEntrySchema = z.object({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/),
kind: z.enum(REPORT_SECTION_KINDS),
theme: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/).nullable(),
disposition: z.enum(["write", "blocked"]),
evidenceRefs: z.array(z.string().regex(/^ev-[a-z0-9_-]{1,63}$/)).max(80),
targetCharacters: z.object({
min: z.number().int().min(0).max(10_000),
max: z.number().int().min(1).max(10_000),
}).strict(),
}).strict().superRefine((entry, context) => {
if (entry.targetCharacters.min > entry.targetCharacters.max) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_character_range_invalid" });
}
if (entry.kind === "thematic" && entry.theme === null) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_missing" });
}
if (entry.kind !== "thematic" && entry.theme !== null) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_unexpected" });
}
});
export const personalReportSectionPlanSchema = z.object({
schemaVersion: z.literal("personal_report_section_plan.v1"),
depth: z.enum(REPORT_DEPTHS),
sections: z.array(reportSectionPlanEntrySchema).min(1).max(40),
}).strict();
function uniqueSorted(values: readonly string[]): string[] {
return [...new Set(values)].sort();
}
function fixedSection(
id: string,
kind: Exclude<ReportSectionKind, "thematic">,
depth: ReportDepth,
evidenceRefs: readonly string[] = [],
): ReportSectionPlanEntry {
return {
id,
kind,
theme: null,
disposition: "write",
evidenceRefs: uniqueSorted(evidenceRefs),
targetCharacters: characterTargets[depth],
};
}
export function validatePersonalReportSectionPlan(
input: unknown,
bundle: ReportEvidenceBundleV2,
): PersonalReportSectionPlan {
const plan = personalReportSectionPlanSchema.parse(input);
const ids = new Set<string>();
for (const section of plan.sections) {
if (ids.has(section.id)) throw new Error(`report_plan_duplicate_section:${section.id}`);
ids.add(section.id);
}
const requiredKinds: ReportSectionKind[] = [
"executive_summary",
"natal_foundation",
"action_notes",
"charts",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
"disclaimer",
];
for (const kind of requiredKinds) {
if (plan.sections.filter((section) => section.kind === kind).length !== 1) {
throw new Error(`report_plan_required_section_invalid:${kind}`);
}
}
const thematic = plan.sections.filter((section) => section.kind === "thematic");
const bundleEvidenceRefs = new Set(bundle.evidenceRefs.map((entry) => entry.id));
for (const theme of bundle.requestedThemes) {
const matches = thematic.filter((section) => section.theme === theme);
if (matches.length !== 1) throw new Error(`report_plan_theme_coverage_invalid:${theme}`);
const hasClaim = bundle.claimCards.some((card) => card.theme === theme);
const hasBlocked = bundle.blockedSections.some((section) => section.theme === theme);
const expected = hasClaim ? "write" : "blocked";
if (hasClaim === hasBlocked || matches[0].disposition !== expected) {
throw new Error(`report_plan_theme_disposition_invalid:${theme}`);
}
}
if (thematic.some((section) => !section.theme || !bundle.requestedThemes.includes(section.theme))) {
throw new Error("report_plan_unrequested_theme");
}
for (const section of plan.sections) {
for (const ref of section.evidenceRefs) {
if (!bundleEvidenceRefs.has(ref)) throw new Error(`report_plan_dangling_evidence_ref:${ref}`);
}
}
return plan;
}
export function buildPersonalReportSectionPlan(
bundle: ReportEvidenceBundleV2,
depth: ReportDepth,
): PersonalReportSectionPlan {
const themeSections = bundle.requestedThemes.map((theme): ReportSectionPlanEntry => {
const card = bundle.claimCards.find((entry) => entry.theme === theme);
const blocked = bundle.blockedSections.find((entry) => entry.theme === theme);
return {
id: `theme-${theme.replaceAll(".", "-")}`,
kind: "thematic",
theme,
disposition: card ? "write" : "blocked",
evidenceRefs: card
? uniqueSorted(card.executedTechniqueRefs)
: uniqueSorted(blocked?.missingTechniqueRefs ?? []),
targetCharacters: characterTargets[depth],
};
});
const timingEvidenceRefs = uniqueSorted([
...bundle.claimCards.filter((card) => card.theme === "timing").flatMap((card) => card.executedTechniqueRefs),
...bundle.blockedSections.filter((section) => section.theme === "timing").flatMap((section) => section.missingTechniqueRefs),
]);
const sections: ReportSectionPlanEntry[] = [
fixedSection("executive-summary", "executive_summary", depth),
fixedSection("natal-foundation", "natal_foundation", depth),
...themeSections,
...(bundle.requestedThemes.includes("timing")
? [{
...fixedSection("current-phase", "current_phase", depth, timingEvidenceRefs),
disposition: bundle.claimCards.some((card) => card.theme === "timing") ? "write" as const : "blocked" as const,
}]
: []),
fixedSection("action-notes", "action_notes", depth),
fixedSection("charts", "charts", depth),
fixedSection("claim-evidence-appendix", "claim_evidence_appendix", depth, bundle.evidenceRefs.map((entry) => entry.id)),
fixedSection("blocked-conflict-disclosure", "blocked_conflict_disclosure", depth, [
...bundle.blockedSections.flatMap((section) => section.missingTechniqueRefs),
...bundle.conflicts.flatMap((conflict) => conflict.techniqueRefs),
]),
fixedSection("provenance", "provenance", depth),
fixedSection("disclaimer", "disclaimer", depth),
];
return validatePersonalReportSectionPlan({
schemaVersion: "personal_report_section_plan.v1",
depth,
sections,
}, bundle);
}
+40 -24
View File
@@ -25,6 +25,7 @@ import {
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobService } from "./personal-report-job-service-core";
import type {
CreateGeneratingInput,
CreateGeneratingResult,
@@ -40,6 +41,7 @@ export const personalReportCreateRequestSchema = z.object({
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]),
}).strict();
@@ -106,6 +108,7 @@ export function reportView(row: PersonalReportRecord) {
requestId: row.requestId,
reportType: row.reportType,
presentationMode: row.presentationMode,
depth: row.depth,
status: row.status,
failureCode: row.failureCode,
createdAt: row.createdAt,
@@ -161,12 +164,9 @@ export type ReportCreateCoreDeps = Readonly<{
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
/**
* Production may schedule the expensive workflow after the HTTP response.
* Unit tests omit this hook and execute synchronously so the complete
* generation contract remains deterministic and directly testable.
*/
deferGeneration?: (task: () => Promise<void>) => void;
/** Production supplies the durable queue. Tests may omit it to execute the
* full pipeline inline with deterministic fakes. */
jobs?: Pick<PersonalReportJobService, "enqueue">;
now?: () => Date;
}>;
@@ -250,6 +250,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
const fingerprint = computeRequestFingerprint({
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
themes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
@@ -284,6 +285,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
requestFingerprint: fingerprint,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
requestedThemes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
@@ -393,6 +395,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
bundle,
depth: payload.depth,
agent: deps.createAgent(deps.model),
now: deps.now,
});
@@ -425,25 +428,38 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
}
};
if (deps.deferGeneration) {
deps.deferGeneration(async () => {
try {
await finishGeneration();
} catch {
// Unexpected model/adapter exceptions must not leave a permanent
// generating row. The stable schema failure is deliberately generic;
// report text, profile data and exception details are never logged.
try {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
} catch {
// A concurrent terminal transition already won; keep that state.
}
if (deps.jobs) {
try {
const enqueued = await deps.jobs.enqueue({
userId,
requestId: payload.requestId,
requestFingerprint: fingerprint,
});
if (enqueued.kind === "request_conflict") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 409,
body: { error: "请求内容与已有任务不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
});
return {
status: 202,
body: { report: reportView(row) },
};
if (enqueued.kind === "active_job_exists") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.generationInProgress);
return {
status: 409,
body: { error: "已有报告任务正在处理中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
return {
status: 202,
body: { report: reportView(row), jobId: enqueued.job.id },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 503,
body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed },
};
}
}
return finishGeneration();
@@ -3,7 +3,7 @@ import {
computeEvidenceHash,
} from "./personal-report-contract.server-core.ts";
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
import type { ReportDocumentV1 } from "./personal-report-contract.ts";
import type { ReportDocument } from "./personal-report-contract.ts";
/**
* Server-only persistence layer for personal reports (pure core).
@@ -50,6 +50,7 @@ export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
export const PERSONAL_REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type PersonalReportServiceErrorCode =
| "invalid_request"
@@ -82,8 +83,9 @@ export type PersonalReportRecord = Readonly<{
status: PersonalReportStatus;
schemaVersion: string;
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes: readonly string[];
reportDocument: ReportDocumentV1 | null;
reportDocument: ReportDocument | null;
calculationHash: string | null;
evidenceHash: string | null;
skillName: string | null;
@@ -102,6 +104,7 @@ export type CreateGeneratingInput = Readonly<{
requestFingerprint: string;
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes?: readonly string[];
sessionId?: string | null;
chartProfileId?: string | null;
@@ -162,6 +165,7 @@ const RECORD_COLUMNS = [
"status",
"schema_version",
"presentation_mode",
"depth",
"requested_themes",
"report_document",
"calculation_hash",
@@ -230,7 +234,7 @@ function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | nu
: [];
const reportDocument = row.report_document === null || row.report_document === undefined
? null
: row.report_document as ReportDocumentV1;
: row.report_document as ReportDocument;
return {
id: stringOrNull(row.id) ?? "",
userId: stringOrNull(row.user_id) ?? "",
@@ -242,6 +246,7 @@ function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | nu
status: row.status as PersonalReportStatus,
schemaVersion: stringOrNull(row.schema_version) ?? "",
presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"],
depth: row.depth as PersonalReportRecord["depth"],
requestedThemes,
reportDocument,
calculationHash: stringOrNull(row.calculation_hash),
@@ -360,6 +365,9 @@ export function createPersonalReportService(
if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) {
throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode");
}
if (!PERSONAL_REPORT_DEPTHS.includes(input.depth)) {
throw new PersonalReportServiceError("invalid_request", "unsupported depth");
}
optionalUuid(input.sessionId, "sessionId");
optionalUuid(input.chartProfileId, "chartProfileId");
requireSkillName(input.skillName);
@@ -379,6 +387,7 @@ export function createPersonalReportService(
status: "generating",
schema_version: REPORT_DOCUMENT_SCHEMA_VERSION,
presentation_mode: input.presentationMode,
depth: input.depth,
requested_themes: themes,
skill_name: input.skillName,
skill_version: input.skillVersion,
@@ -432,7 +441,7 @@ export function createPersonalReportService(
): Promise<PersonalReportRecord> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
let parsed: ReportDocumentV1;
let parsed: ReportDocument;
try {
parsed = parseServerReportDocument(document);
} catch (error) {
@@ -474,6 +483,7 @@ export function createPersonalReportService(
const { data, error } = await records()
.update({
status: "ready",
schema_version: parsed.schemaVersion,
report_document: parsed,
evidence_hash: evidenceHash,
calculation_hash: parsed.provenance.calculationHash,
@@ -0,0 +1,384 @@
import {
PersonalReportJobServiceError,
type PersonalReportJobRecord,
type PersonalReportJobService,
type RecoverExpiredPersonalReportJobsResult,
} from "./personal-report-job-service-core.ts";
import {
PersonalReportServiceError,
type PersonalReportFailureCode,
type PersonalReportRecord,
type PersonalReportService,
} from "./personal-report-service-core.ts";
import type { GeneratePersonalReportResult } from "./personal-report-generation.ts";
/**
* Durable personal-report worker orchestration.
*
* This core owns only lease/state coordination. It deliberately delegates
* profile loading and the evidence/model pipeline so unit tests can exercise
* crash recovery without a database, HTTP server, or model provider.
*/
export const PERSONAL_REPORT_WORKER_PROGRESS = {
loadingContext: { phase: "loading_context", percent: 10 },
generatingReport: { phase: "generating_report", percent: 30 },
persistingReport: { phase: "persisting_report", percent: 90 },
} as const;
export type PersonalReportWorkerErrorCode =
| PersonalReportFailureCode
| "report_generation_failed"
| "lease_lost";
export class PersonalReportWorkerError extends Error {
readonly name = "PersonalReportWorkerError";
constructor(
readonly code: PersonalReportWorkerErrorCode,
readonly retryable: boolean,
message?: string,
) {
super(message ?? code);
}
}
export type PersonalReportWorkerGenerationContext = Readonly<{
report: PersonalReportRecord;
profile: unknown;
signal: AbortSignal;
}>;
export type PersonalReportWorkerJobPort = Pick<
PersonalReportJobService,
| "recoverExpiredLeases"
| "claimLease"
| "heartbeatLease"
| "updateProgress"
| "markReady"
| "completeReportReady"
| "completeReportFailed"
| "markFailed"
| "scheduleRetry"
>;
export type PersonalReportWorkerReportPort = Pick<
PersonalReportService,
"getByUserAndRequestId"
>;
export type PersonalReportWorkerDeps = Readonly<{
workerId: string;
jobs: PersonalReportWorkerJobPort;
reports: PersonalReportWorkerReportPort;
loadProfile: (userId: string) => Promise<unknown | null>;
generate: (
context: PersonalReportWorkerGenerationContext,
) => Promise<GeneratePersonalReportResult>;
leaseSeconds?: number;
heartbeatIntervalMs?: number;
recoveryLimit?: number;
retryDelayMs?: (job: PersonalReportJobRecord, errorCode: string) => number;
now?: () => Date;
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
}>;
export type PersonalReportWorkerTickResult = Readonly<{
recovery: RecoverExpiredPersonalReportJobsResult;
outcome:
| "idle"
| "ready"
| "failed"
| "retry_scheduled"
| "reconcile_deferred"
| "lease_lost";
jobId: string | null;
}>;
export type PersonalReportWorkerLoopOptions = Readonly<{
pollIntervalMs?: number;
signal?: AbortSignal;
onError?: (error: unknown) => void;
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
}>;
function positiveInteger(value: number | undefined, fallback: number, field: string): number {
const resolved = value ?? fallback;
if (!Number.isInteger(resolved) || resolved <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return resolved;
}
function isLeaseLoss(error: unknown): boolean {
return error instanceof PersonalReportJobServiceError
&& (error.code === "lease_lost" || error.code === "terminal_immutable");
}
function stableErrorCode(error: unknown): PersonalReportWorkerErrorCode {
if (error instanceof PersonalReportWorkerError) return error.code;
if (error instanceof PersonalReportServiceError) {
if (error.code === "invalid_document") return "report_schema_invalid";
if (error.code === "not_found") return "report_not_found";
}
if (error instanceof PersonalReportJobServiceError) {
if (error.code === "storage_invalid" || error.code === "invalid_request") {
return "report_schema_invalid";
}
if (error.code === "not_found") return "report_not_found";
if (error.code === "lease_lost") return "lease_lost";
}
return "report_generation_failed";
}
function isRetryable(error: unknown): boolean {
if (error instanceof PersonalReportWorkerError) return error.retryable;
if (error instanceof PersonalReportServiceError) {
return error.code === "storage_failed";
}
if (error instanceof PersonalReportJobServiceError) {
return error.code === "storage_failed";
}
return true;
}
function reportFailureCode(code: PersonalReportWorkerErrorCode): PersonalReportFailureCode {
switch (code) {
case "profile_incomplete":
case "birth_time_not_usable":
case "calculation_unavailable":
case "model_unavailable":
case "report_schema_invalid":
case "report_guard_rejected":
case "report_not_found":
return code;
default:
return "calculation_unavailable";
}
}
function timerUnref(timer: ReturnType<typeof globalThis.setInterval>): void {
const candidate = timer as ReturnType<typeof globalThis.setInterval> & { unref?: () => void };
candidate.unref?.();
}
function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
const exponent = Math.max(0, Math.min(job.attemptCount - 1, 5));
return 5_000 * (2 ** exponent);
}
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
const timer = globalThis.setTimeout(resolve, milliseconds);
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
candidate.unref?.();
signal?.addEventListener("abort", () => {
globalThis.clearTimeout(timer);
resolve();
}, { once: true });
});
}
export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
const leaseSeconds = positiveInteger(deps.leaseSeconds, 120, "leaseSeconds");
const heartbeatIntervalMs = positiveInteger(
deps.heartbeatIntervalMs,
Math.max(1_000, Math.floor((leaseSeconds * 1_000) / 3)),
"heartbeatIntervalMs",
);
const recoveryLimit = positiveInteger(deps.recoveryLimit, 100, "recoveryLimit");
const setIntervalFn = deps.setInterval ?? globalThis.setInterval.bind(globalThis);
const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval.bind(globalThis);
const now = deps.now ?? (() => new Date());
const retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
async function settleFailure(
job: PersonalReportJobRecord,
report: PersonalReportRecord | null,
error: unknown,
): Promise<PersonalReportWorkerTickResult["outcome"]> {
if (!job.leaseToken || isLeaseLoss(error)) return "lease_lost";
const code = stableErrorCode(error);
const failureCode = reportFailureCode(code);
const terminal = !isRetryable(error) || job.attemptCount >= job.maxAttempts;
try {
if (terminal) {
if (!report || report.status !== "generating") {
await deps.jobs.markFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
});
} else {
await deps.jobs.completeReportFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
report,
failureCode,
});
}
return "failed";
}
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
const scheduled = await deps.jobs.scheduleRetry({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
nextAttemptAt: retryAt,
});
if (scheduled.kind === "scheduled") return "retry_scheduled";
if (!report || report.status !== "generating") return "failed";
await deps.jobs.completeReportFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
report,
failureCode,
});
return "failed";
} catch (transitionError) {
if (isLeaseLoss(transitionError)) return "lease_lost";
throw transitionError;
}
}
async function processClaimed(job: PersonalReportJobRecord): Promise<PersonalReportWorkerTickResult["outcome"]> {
if (!job.leaseToken) {
throw new PersonalReportWorkerError("lease_lost", false, "claimed job has no lease token");
}
const controller = new AbortController();
let heartbeatError: unknown = null;
let heartbeatChain = Promise.resolve();
const heartbeatTimer = setIntervalFn(() => {
heartbeatChain = heartbeatChain
.then(async () => {
if (heartbeatError !== null || controller.signal.aborted) return;
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken!,
leaseSeconds,
});
})
.catch((error) => {
heartbeatError = error;
controller.abort(error);
});
}, heartbeatIntervalMs);
timerUnref(heartbeatTimer);
let report: PersonalReportRecord | null = null;
try {
report = await deps.reports.getByUserAndRequestId(job.userId, job.requestId);
if (!report) {
throw new PersonalReportWorkerError("report_not_found", false);
}
if (report.requestFingerprint !== job.requestFingerprint) {
throw new PersonalReportWorkerError("report_schema_invalid", false, "job/report fingerprint mismatch");
}
if (report.status === "ready") {
await deps.jobs.markReady({ jobId: job.id, leaseToken: job.leaseToken });
return "ready";
}
if (report.status === "failed") {
await deps.jobs.markFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: report.failureCode ?? "report_generation_failed",
});
return "failed";
}
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.loadingContext,
});
const profile = await deps.loadProfile(job.userId);
if (!profile) {
throw new PersonalReportWorkerError("profile_incomplete", false);
}
// Refresh the lease synchronously immediately before the potentially
// long workflow/model call; the interval continues the heartbeat while
// that call is in flight.
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken,
leaseSeconds,
});
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.generatingReport,
});
const generated = await deps.generate({ report, profile, signal: controller.signal });
await heartbeatChain;
if (heartbeatError !== null) throw heartbeatError;
if (generated.status === "failed") {
throw new PersonalReportWorkerError(generated.failureCode, false);
}
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.persistingReport,
});
await deps.jobs.completeReportReady({
jobId: job.id,
leaseToken: job.leaseToken,
report,
document: generated.document,
});
return "ready";
} catch (error) {
if (report?.status === "ready") {
// A ready report is the source of truth. If the projection-only job
// reconciliation fails, keep the live lease untouched so expiry
// recovery can converge it to ready; never downgrade it to failed.
return isLeaseLoss(error) ? "lease_lost" : "reconcile_deferred";
}
return settleFailure(job, report, error);
} finally {
clearIntervalFn(heartbeatTimer);
controller.abort();
await heartbeatChain;
}
}
return {
async tick(): Promise<PersonalReportWorkerTickResult> {
const recovery = await deps.jobs.recoverExpiredLeases(recoveryLimit);
const job = await deps.jobs.claimLease({ workerId: deps.workerId, leaseSeconds });
if (!job) return { recovery, outcome: "idle", jobId: null };
const outcome = await processClaimed(job);
return { recovery, outcome, jobId: job.id };
},
};
}
export async function runPersonalReportWorkerLoop(
worker: Readonly<{ tick: () => Promise<PersonalReportWorkerTickResult> }>,
options: PersonalReportWorkerLoopOptions = {},
): Promise<void> {
const pollIntervalMs = positiveInteger(options.pollIntervalMs, 2_000, "pollIntervalMs");
const sleep = options.sleep ?? defaultSleep;
while (!options.signal?.aborted) {
try {
const result = await worker.tick();
if (result.outcome !== "idle") continue;
} catch (error) {
options.onError?.(error);
}
await sleep(pollIntervalMs, options.signal);
}
}
+240
View File
@@ -0,0 +1,240 @@
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;
}