feat: add persistent personal report document v2
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user