590 lines
26 KiB
TypeScript
590 lines
26 KiB
TypeScript
/**
|
|
* ReportDocument v1/v2 contract (isomorphic Zod side).
|
|
*
|
|
* 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 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 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 = [
|
|
"multi_system_consensus",
|
|
"single_system_inference",
|
|
"parameter_sensitive",
|
|
"unclosed_divisional_chart",
|
|
"user_history_verification_required",
|
|
"blocked",
|
|
] as const;
|
|
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 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()
|
|
.max(120)
|
|
.regex(/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/, "invalid skill name");
|
|
const skillVersionSchema = z.string()
|
|
.max(80)
|
|
.regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, "invalid skill version");
|
|
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, 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),
|
|
sign: text(40),
|
|
occupants: textArray(12, 40),
|
|
});
|
|
|
|
const planetSchema = z.strictObject({
|
|
name: text(40),
|
|
sign: text(40),
|
|
longitudeDegrees: z.number().min(0).lt(360),
|
|
houseNumber: z.number().int().min(1).max(12),
|
|
retrograde: z.boolean(),
|
|
});
|
|
|
|
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 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: 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({
|
|
id: evidenceIdSchema,
|
|
techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"),
|
|
techniqueName: text(160),
|
|
status: z.enum(TECHNIQUE_STATUSES),
|
|
used: z.boolean(),
|
|
notes: z.string().max(500).optional(),
|
|
});
|
|
|
|
const conflictRowSchema = z.strictObject({
|
|
id: evidenceIdSchema,
|
|
description: text(1000),
|
|
impact: text(500),
|
|
status: z.enum(CONFLICT_STATUSES),
|
|
});
|
|
|
|
const calculationEvidenceRowSchema = z.strictObject({
|
|
id: evidenceIdSchema,
|
|
label: text(160),
|
|
value: text(500),
|
|
source: text(200),
|
|
});
|
|
|
|
const evidenceAppendixSchema = z.strictObject({
|
|
expandedByDefault: z.boolean(),
|
|
techniqueAudit: z.array(techniqueAuditRowSchema).max(100),
|
|
conflicts: z.array(conflictRowSchema).max(50),
|
|
calculationEvidence: z.array(calculationEvidenceRowSchema).max(100),
|
|
blockedTechniques: textArray(100, 120),
|
|
});
|
|
|
|
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: 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 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),
|
|
});
|
|
|
|
/** 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;
|
|
message: string;
|
|
}>;
|
|
|
|
export class ReportDocumentValidationError extends Error {
|
|
readonly errors: readonly ReportDocumentParseError[];
|
|
|
|
constructor(errors: readonly ReportDocumentParseError[]) {
|
|
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
|
|
this.name = "ReportDocumentValidationError";
|
|
this.errors = errors;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
techniqueAudit: appendix.techniqueAudit.map((row) => ({
|
|
id: row.id,
|
|
techniqueId: row.techniqueId,
|
|
techniqueName: row.techniqueName,
|
|
status: row.status,
|
|
used: row.used,
|
|
...(row.notes !== undefined ? { notes: row.notes } : {}),
|
|
})),
|
|
conflicts: appendix.conflicts.map((row) => ({
|
|
id: row.id,
|
|
description: row.description,
|
|
impact: row.impact,
|
|
status: row.status,
|
|
})),
|
|
calculationEvidence: appendix.calculationEvidence.map((row) => ({
|
|
id: row.id,
|
|
label: row.label,
|
|
value: row.value,
|
|
source: row.source,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function serializedReportDocumentBytes(document: ReportDocument): number {
|
|
return new TextEncoder().encode(JSON.stringify(document)).length;
|
|
}
|
|
|
|
/** 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", 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 },
|
|
{ name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i },
|
|
{ name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i },
|
|
{ name: "process_env", pattern: /\bprocess\.env\b/i },
|
|
{ name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i },
|
|
{ name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i },
|
|
{ name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i },
|
|
{ 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 },
|
|
];
|
|
|
|
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 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(...textLeaves(document.executiveSummary, "executiveSummary"));
|
|
}
|
|
document.charts.forEach((chart, index) => {
|
|
if (chart.claimStatus === "blocked") entries.push(...textLeaves(chart, `charts[${index}]`));
|
|
});
|
|
document.thematicNarrative.forEach((section, index) => {
|
|
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: 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);
|
|
}
|
|
|
|
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 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: ReportDocument): readonly string[] {
|
|
const locations = new Map<string, string>();
|
|
const duplicates: string[] = [];
|
|
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
|
|
...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })),
|
|
...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })),
|
|
...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })),
|
|
];
|
|
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);
|
|
}
|
|
return duplicates;
|
|
}
|
|
|
|
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;
|
|
if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`);
|
|
const seen = new Set<string>();
|
|
for (const id of ids) {
|
|
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 unique = new Set(d1.houses.map((house) => house.houseNumber));
|
|
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
|
|
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;
|
|
}
|
|
|
|
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) => `evidenceRefs: unknown evidence id ${ref}`));
|
|
errors.push(...findUnsupportedDateClaims(document));
|
|
if (isReportDocumentV2(document)) errors.push(...findThemeCoverageViolations(document));
|
|
|
|
for (const { path, text: value } of textLeaves(document)) {
|
|
const hits = findForbiddenContent(value);
|
|
if (hits.length > 0) errors.push(`${path}: forbidden content ${hits.join(",")}`);
|
|
}
|
|
|
|
const size = serializedReportDocumentBytes(document);
|
|
if (size > REPORT_DOCUMENT_MAX_BYTES) {
|
|
errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export type ReportDocumentParseResult =
|
|
| Readonly<{ ok: true; document: ReportDocument }>
|
|
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
|
|
|
|
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
|
|
const parsed = reportDocumentSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
return {
|
|
ok: false,
|
|
errors: parsed.error.issues.map((issue) => ({
|
|
path: issue.path.join(".") || "(root)",
|
|
message: issue.message,
|
|
})),
|
|
};
|
|
}
|
|
const document = parsed.data;
|
|
const guardErrors = validateReportDocumentGuards(document);
|
|
if (guardErrors.length > 0) {
|
|
return {
|
|
ok: false,
|
|
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
|
|
};
|
|
}
|
|
return { ok: true, document };
|
|
}
|
|
|
|
export function parseReportDocument(input: unknown): ReportDocument {
|
|
const result = safeParseReportDocument(input);
|
|
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
|
|
return result.document;
|
|
}
|