Five-theme assemble cleared the chart and actionNotes caps, then failed three anonymous guards. Log field path plus kind only, and strip deterministic phrases from sections the producer already marked blocked. Co-authored-by: Cursor <cursoragent@cursor.com>
674 lines
30 KiB
TypeScript
674 lines
30 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", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
|
|
export const REPORT_DOCUMENT_V2_ACTION_NOTES_MAX = 24;
|
|
|
|
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 birthTimeSensitivitySchema = z.strictObject({
|
|
window: z.strictObject({
|
|
startTime: text(5),
|
|
endTime: text(5),
|
|
representativeTime: text(5),
|
|
candidateCount: z.number().int().min(2).max(15),
|
|
}),
|
|
themes: z.array(z.strictObject({
|
|
theme: themeIdSchema,
|
|
status: z.enum(["stable", "sensitive"]),
|
|
stableLayers: textArray(24, 100),
|
|
sensitiveLayers: textArray(24, 100),
|
|
minuteVariations: z.array(z.strictObject({
|
|
layer: text(100),
|
|
values: z.array(z.strictObject({
|
|
minute: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
|
value: text(300),
|
|
})).min(2).max(15),
|
|
})).max(24),
|
|
})).max(12),
|
|
claimBoundary: text(500),
|
|
});
|
|
|
|
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),
|
|
birthTimeSensitivity: birthTimeSensitivitySchema.optional(),
|
|
});
|
|
|
|
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(REPORT_DOCUMENT_V2_ACTION_NOTES_MAX),
|
|
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
|
|
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;
|
|
code: string;
|
|
}>;
|
|
|
|
/**
|
|
* Map a guard message to a field path + kind. The message itself is kept on
|
|
* the typed error for tests; logs must use only path and code (no values).
|
|
*/
|
|
export function classifyReportDocumentGuardError(message: string): ReportDocumentParseError {
|
|
const forbidden = /^(.*?): forbidden content ([a-z_,]+)$/.exec(message);
|
|
if (forbidden) {
|
|
const kinds = forbidden[2].split(",").filter((kind) => /^[a-z_]+$/.test(kind));
|
|
return {
|
|
path: forbidden[1] || "(guard)",
|
|
message,
|
|
code: kinds[0] ? `forbidden_${kinds[0]}` : "forbidden_content",
|
|
};
|
|
}
|
|
const dateClaim = /^(.*?): date claim requires evidenceRefs$/.exec(message);
|
|
if (dateClaim) {
|
|
return { path: dateClaim[1] || "(guard)", message, code: "unsupported_date" };
|
|
}
|
|
const blocked = /^(.*?): blocked section contains deterministic prediction$/.exec(message);
|
|
if (blocked) {
|
|
return { path: blocked[1] || "(guard)", message, code: "blocked_deterministic" };
|
|
}
|
|
if (
|
|
message.startsWith("requestedThemes must contain ")
|
|
|| message.startsWith("requested theme ")
|
|
|| message.startsWith("thematic section covers unrequested theme ")
|
|
|| message.startsWith("blocked disclosure covers unrequested theme ")
|
|
) {
|
|
return { path: "requestedThemes", message, code: "theme_coverage" };
|
|
}
|
|
if (message.includes(" requires structured ") && message.includes(" chart data")) {
|
|
return { path: "thematicNarrative", message, code: "missing_required_chart" };
|
|
}
|
|
if (
|
|
message.startsWith("charts must ")
|
|
|| message.startsWith("duplicate chart id ")
|
|
|| message.startsWith("D1 chart ")
|
|
|| / chart contains duplicate house numbers$/.test(message)
|
|
) {
|
|
return { path: "charts", message, code: "chart_set" };
|
|
}
|
|
if (message.startsWith("evidence id ")) {
|
|
return { path: "evidenceAppendix", message, code: "duplicate_evidence_id" };
|
|
}
|
|
const dangling = /^evidenceRefs: unknown evidence id (.+):([^:]+)$/.exec(message);
|
|
if (dangling) {
|
|
return { path: dangling[1], message, code: "dangling_ref" };
|
|
}
|
|
if (message.startsWith("evidenceRefs:")) {
|
|
return { path: "evidenceRefs", message, code: "dangling_ref" };
|
|
}
|
|
if (message.startsWith("serialized document")) {
|
|
return { path: "(root)", message, code: "document_too_large" };
|
|
}
|
|
return { path: "(guard)", message, code: "guard" };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export 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,
|
|
code: issue.code,
|
|
})),
|
|
};
|
|
}
|
|
const document = parsed.data;
|
|
const guardErrors = validateReportDocumentGuards(document);
|
|
if (guardErrors.length > 0) {
|
|
return {
|
|
ok: false,
|
|
errors: guardErrors.map((message) => classifyReportDocumentGuardError(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;
|
|
}
|