From 82dab96b073a6a1e0451c3ed83b8d67c4eeb0387 Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 12:43:10 +0800 Subject: [PATCH] feat(report): add personal report contract and persistence --- .../report-document.v1.schema.json | 432 ++++++++++ .../20260806000000_personal_reports.sql | 104 +++ .../personal-report-contract.server-core.ts | 51 ++ .../lib/personal-report-contract.server.ts | 3 + frontend/src/lib/personal-report-contract.ts | 413 ++++++++++ .../src/lib/personal-report-service-core.ts | 518 ++++++++++++ frontend/src/lib/personal-report-service.ts | 3 + .../20260806010000_personal_reports.sql | 100 +++ .../tests/personal-report-contract.test.ts | 186 +++++ .../tests/personal-report-migration.test.ts | 129 +++ .../tests/personal-report-service.test.ts | 392 +++++++++ scripts/personal_report_contract.py | 771 ++++++++++++++++++ .../fixtures/personal_report_document.v1.json | 399 +++++++++ tests/test_personal_report_contract.py | 295 +++++++ 14 files changed, 3796 insertions(+) create mode 100644 contracts/personal-report/report-document.v1.schema.json create mode 100644 frontend/db/migrations/20260806000000_personal_reports.sql create mode 100644 frontend/src/lib/personal-report-contract.server-core.ts create mode 100644 frontend/src/lib/personal-report-contract.server.ts create mode 100644 frontend/src/lib/personal-report-contract.ts create mode 100644 frontend/src/lib/personal-report-service-core.ts create mode 100644 frontend/src/lib/personal-report-service.ts create mode 100644 frontend/supabase/migrations/20260806010000_personal_reports.sql create mode 100644 frontend/tests/personal-report-contract.test.ts create mode 100644 frontend/tests/personal-report-migration.test.ts create mode 100644 frontend/tests/personal-report-service.test.ts create mode 100644 scripts/personal_report_contract.py create mode 100644 tests/fixtures/personal_report_document.v1.json create mode 100644 tests/test_personal_report_contract.py diff --git a/contracts/personal-report/report-document.v1.schema.json b/contracts/personal-report/report-document.v1.schema.json new file mode 100644 index 00000000..5a15a950 --- /dev/null +++ b/contracts/personal-report/report-document.v1.schema.json @@ -0,0 +1,432 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://jyotisha.chat/contracts/personal-report/report-document.v1.schema.json", + "title": "ReportDocument v1", + "description": "Server-issued personal astrology report document. This contract is enforced identically by the JSON Schema below, frontend/src/lib/personal-report-contract.ts (Zod), and scripts/personal_report_contract.py (stdlib Python validator). Semantics that JSON Schema draft-07 cannot express are enforced by both runtime validators and their tests: (1) charts must contain exactly one D1 chart, chart ids must be unique, and the D1 chart must contain all twelve house numbers 1..12 (enough real houses to render without fabrication); (2) every houseNumber must be unique within its chart; (3) evidence ids (id fields of techniqueAudit, conflicts and calculationEvidence rows) must be globally unique across the whole evidence appendix so evidenceRefs are never ambiguous; (4) provenance.evidenceHash is a deterministic recomputation over the evidence appendix (techniqueAudit, conflicts, calculationEvidence in canonical field order) - it is never trusted as a model self-report; the cryptographic hash is verified by the server runtime (frontend/src/lib/personal-report-contract.server.ts) and by the Python validator (scripts/personal_report_contract.py), and a document whose evidenceHash does not equal the recomputed value is rejected; the isomorphic frontend contract validates structure only and never recomputes the hash; (5) every entry in evidenceRefs must reference an id present in evidenceAppendix.techniqueAudit, evidenceAppendix.conflicts, or evidenceAppendix.calculationEvidence; (6) sections whose claimStatus is blocked must not contain deterministic predictions (e.g. 必然, 必定, 一定会, 肯定会, 绝对会, guaranteed, definitely will); (7) the UTF-8 JSON serialization of the whole document must not exceed 1572864 bytes (1.5 MiB). Fixed reader order: this schema defines the display sequence executiveSummary, thematicNarrative, evidenceAppendix as a UI/type-level presentation contract; JSON object key order is not validated (objects are unordered by definition). Privacy rule: subject/provenance metadata must never repeat full birth date, precise coordinates, or a raw chart payload. Content rule: no HTML/JS/CSS, no executable URLs (javascript:, vbscript:, data:text/html, file:), no internal filesystem paths, no prompt/tool traces or exception stacks, no model secrets or JWTs.", + "type": "object", + "definitions": { + "claimStatus": { + "type": "string", + "enum": [ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked" + ] + }, + "evidenceId": { + "type": "string", + "pattern": "^ev-[a-z0-9_-]{1,63}$", + "minLength": 4, + "maxLength": 67 + }, + "sha256Hex": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "iso8601": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "minLength": 20, + "maxLength": 40 + }, + "house": { + "type": "object", + "additionalProperties": false, + "properties": { + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "occupants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "maxItems": 12 + } + }, + "required": ["houseNumber", "sign", "occupants"] + }, + "planet": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "longitudeDegrees": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 360 + }, + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "retrograde": { + "type": "boolean" + } + }, + "required": ["name", "sign", "longitudeDegrees", "houseNumber", "retrograde"] + }, + "chart": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "enum": ["D1", "D9", "D10"] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "houses": { + "type": "array", + "items": { + "$ref": "#/definitions/house" + }, + "maxItems": 12 + }, + "planets": { + "type": "array", + "items": { + "$ref": "#/definitions/planet" + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["id", "title", "houses", "claimStatus"] + }, + "thematicSection": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$", + "minLength": 1, + "maxLength": 64 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "narrative": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "actions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "caveats": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + }, + "evidenceRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/evidenceId" + }, + "maxItems": 24 + } + }, + "required": ["id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"] + }, + "techniqueAuditRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "techniqueId": { + "type": "string", + "pattern": "^[a-z0-9_.-]{1,80}$", + "minLength": 1, + "maxLength": 80 + }, + "techniqueName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "status": { + "type": "string", + "enum": ["verified", "partial", "blocked"] + }, + "used": { + "type": "boolean" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + }, + "required": ["id", "techniqueId", "techniqueName", "status", "used"] + }, + "conflictRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "impact": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "status": { + "type": "string", + "enum": ["unresolved", "partial", "resolved"] + } + }, + "required": ["id", "description", "impact", "status"] + }, + "calculationEvidenceRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["id", "label", "value", "source"] + } + }, + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "const": "report_document.v1" + }, + "reportId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "reportType": { + "type": "string", + "enum": ["personal_full", "personal_thematic"] + }, + "presentationMode": { + "type": "string", + "enum": ["default", "research"] + }, + "generatedAt": { + "$ref": "#/definitions/iso8601" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "birthTimeStatus": { + "type": "string", + "enum": ["reported", "candidate", "accepted", "confirmed"] + }, + "birthPlaceLabel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["displayName", "birthTimeStatus", "birthPlaceLabel"] + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "skillSourceCommit": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{40}$", + "minLength": 40, + "maxLength": 40 + }, + "skillSnapshotSha256": { + "$ref": "#/definitions/sha256Hex" + }, + "calculationHash": { + "$ref": "#/definitions/sha256Hex" + }, + "evidenceHash": { + "$ref": "#/definitions/sha256Hex" + }, + "reportContractVersion": { + "type": "string", + "const": "1" + } + }, + "required": ["skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"] + }, + "executiveSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "headline": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "priorities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "maxItems": 8 + }, + "overallClaimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["headline", "summary", "priorities", "overallClaimStatus"] + }, + "charts": { + "type": "array", + "items": { + "$ref": "#/definitions/chart" + }, + "minItems": 1, + "maxItems": 3 + }, + "thematicNarrative": { + "type": "array", + "items": { + "$ref": "#/definitions/thematicSection" + }, + "maxItems": 12 + }, + "evidenceAppendix": { + "type": "object", + "additionalProperties": false, + "properties": { + "expandedByDefault": { + "type": "boolean" + }, + "techniqueAudit": { + "type": "array", + "items": { + "$ref": "#/definitions/techniqueAuditRow" + }, + "maxItems": 100 + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/definitions/conflictRow" + }, + "maxItems": 50 + }, + "calculationEvidence": { + "type": "array", + "items": { + "$ref": "#/definitions/calculationEvidenceRow" + }, + "maxItems": 100 + }, + "blockedTechniques": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "maxItems": 100 + } + }, + "required": ["expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"] + }, + "disclaimer": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": [ + "schemaVersion", + "reportId", + "reportType", + "presentationMode", + "generatedAt", + "subject", + "provenance", + "executiveSummary", + "charts", + "thematicNarrative", + "evidenceAppendix", + "disclaimer" + ] +} diff --git a/frontend/db/migrations/20260806000000_personal_reports.sql b/frontend/db/migrations/20260806000000_personal_reports.sql new file mode 100644 index 00000000..2a8a7ee7 --- /dev/null +++ b/frontend/db/migrations/20260806000000_personal_reports.sql @@ -0,0 +1,104 @@ +-- Personal report persistence for self-hosted PostgreSQL (staging). +-- Mirrors supabase/migrations/20260806010000_personal_reports.sql +-- one-to-one in table shape, constraints, RLS and grants. +-- +-- Ownership model: rows are owned by auth.users(id) (the business-auth +-- mirror kept in sync by identity.sync_user_to_business_auth). Normal +-- application sessions connect as app_runtime (member of authenticated) and +-- set local role authenticated; they may select/delete only their own rows +-- through RLS plus the explicit owner grants below, and can never +-- insert/update (generation and status writes are performed exclusively +-- through service_role, which has BYPASSRLS and full table privileges). +-- admin_runtime has no direct access to report bodies (least privilege); +-- server-side generation runs through service_role, which admin_runtime may +-- SET ROLE to. +-- +-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a +-- known requestId requires the same request_fingerprint (a sha256 of the +-- caller's request intent), so a different payload under the same requestId +-- surfaces as request_conflict instead of silently overwriting. Failed +-- retries are not implicitly upserted here; callers either reuse the failed +-- record via a new requestId or surface the stable failure. +-- +-- No birth details, report bodies, model prompts or exception stacks are ever +-- written to logs, index columns or audit events; failure_code is a stable +-- enum shared with frontend/src/lib/personal-report-service.ts and +-- scripts/personal_report_contract.py. + +create table if not exists public.personal_reports ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid, + chart_profile_id uuid, + request_id uuid not null, + request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), + report_type text not null check (report_type in ('personal_full', 'personal_thematic')), + status text not null check (status in ('generating', 'ready', 'failed')), + schema_version text not null check (schema_version = 'report_document.v1'), + presentation_mode text not null check (presentation_mode in ('default', 'research')), + requested_themes text[] not null default '{}'::text[], + report_document jsonb, + calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), + evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), + skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), + skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + failure_code text check (failure_code in ( + 'profile_incomplete', + 'birth_time_not_usable', + 'report_generation_in_progress', + 'report_rate_limited', + 'calculation_unavailable', + 'model_unavailable', + 'report_schema_invalid', + 'report_guard_rejected', + 'report_not_found' + )), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + check ((status = 'ready') = (report_document is not null)), + check ((status = 'ready') = (completed_at is not null)), + check ((status = 'ready') = (calculation_hash is not null)), + check ((status = 'ready') = (evidence_hash is not null)), + check ((status = 'failed') = (failure_code is not null)), + unique (user_id, request_id) +); + +create index if not exists personal_reports_user_created_idx + on public.personal_reports (user_id, created_at desc); + +-- One in-flight generation per user, enforced by the database so a second +-- request cannot start while the first is still generating. +create unique index if not exists personal_reports_one_generating_per_user + on public.personal_reports (user_id) + where status = 'generating'; + +alter table public.personal_reports enable row level security; + +revoke all on table public.personal_reports from public, anon, authenticated, service_role; +revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader; + +drop policy if exists personal_reports_select_own on public.personal_reports; +create policy personal_reports_select_own + on public.personal_reports + for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists personal_reports_delete_own on public.personal_reports; +create policy personal_reports_delete_own + on public.personal_reports + for delete + to authenticated + using (auth.uid() = user_id); + +-- Normal users can never insert or update rows: creating a generating record +-- and moving it to ready/failed are server-side operations only. RLS +-- policies alone do not grant table privileges, so the owner read/delete +-- grants below are required for the policies to be reachable. +grant select, delete on table public.personal_reports to authenticated; + +-- admin_runtime intentionally has no direct access to report bodies (least +-- privilege); server-side generation runs through service_role, which +-- admin_runtime may SET ROLE to. +grant select, insert, update, delete on table public.personal_reports to service_role; diff --git a/frontend/src/lib/personal-report-contract.server-core.ts b/frontend/src/lib/personal-report-contract.server-core.ts new file mode 100644 index 00000000..0095a0f4 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.server-core.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import { + canonicalEvidence, + safeParseReportDocument, + ReportDocumentValidationError, + type EvidenceAppendix, + type ReportDocumentParseResult, + type ReportDocumentV1, +} from "./personal-report-contract.ts"; + +/** + * Server hash core for ReportDocument v1. + * + * 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. + * + * 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. + */ + +export function computeEvidenceHash(appendix: EvidenceAppendix): string { + const canonical = canonicalEvidence(appendix); + return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex"); +} + +export function safeParseServerReportDocument(input: unknown): ReportDocumentParseResult { + const parsed = safeParseReportDocument(input); + if (!parsed.ok) return parsed; + const recomputed = computeEvidenceHash(parsed.document.evidenceAppendix); + if (parsed.document.provenance.evidenceHash !== recomputed) { + return { + ok: false, + errors: [{ + path: "provenance.evidenceHash", + message: `does not match recomputed evidence hash ${recomputed}`, + }], + }; + } + return parsed; +} + +export function parseServerReportDocument(input: unknown): ReportDocumentV1 { + const result = safeParseServerReportDocument(input); + if (!result.ok) throw new ReportDocumentValidationError(result.errors); + return result.document; +} diff --git a/frontend/src/lib/personal-report-contract.server.ts b/frontend/src/lib/personal-report-contract.server.ts new file mode 100644 index 00000000..2b341bc2 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.server.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export * from "./personal-report-contract.server-core"; diff --git a/frontend/src/lib/personal-report-contract.ts b/frontend/src/lib/personal-report-contract.ts new file mode 100644 index 00000000..0d551db9 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.ts @@ -0,0 +1,413 @@ +/** + * ReportDocument v1 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) + * + * 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. + */ + +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 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 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; + +const claimStatusSchema = z.enum(CLAIM_STATUSES); +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 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 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 chartSchema = 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, +}); + +const thematicSectionSchema = 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), +}); + +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 reportDocumentShape = { + schemaVersion: z.literal(REPORT_DOCUMENT_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({ + 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), + evidenceAppendix: evidenceAppendixSchema, + disclaimer: text(2000), +}; + +export const reportDocumentSchema = z.strictObject(reportDocumentShape); + +export type ReportDocumentV1 = z.infer; +export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"]; +export type ChartV1 = ReportDocumentV1["charts"][number]; +export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][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; + } +} + +/** Canonical evidence object used by the evidence hash on both language sides. */ +export function canonicalEvidence(appendix: EvidenceAppendix): Record { + 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: ReportDocumentV1): 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. + */ +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: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/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 }, +]; + +/** + * 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", +]; + +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 }>[] { + 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 }); + }); + } + document.charts.forEach((chart, index) => { + if (chart.claimStatus === "blocked") { + entries.push({ path: `charts[${index}].title`, text: chart.title }); + } + }); + 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 }); + }); + }); + return entries; +} + +export function findBlockedDeterministicClaims(document: ReportDocumentV1): 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[] { + const knownIds = new Set([ + ...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}`), + ); +} + +/** Evidence ids must be globally unique across the whole appendix. */ +export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] { + const locations = new Map(); + 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: ReportDocumentV1): 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(); + for (const id of ids) { + if (seen.has(id)) violations.push(`duplicate chart id ${id}`); + seen.add(id); + } + 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 expected = Array.from({ length: 12 }, (_, index) => index + 1); + if (numbers.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 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}`)); + + const forbidden: { path: string; hits: readonly string[] }[] = []; + const collectTexts = (path: string, value: string) => { + 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(",")}`)); + + 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: ReportDocumentV1 }> + | 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): ReportDocumentV1 { + const result = safeParseReportDocument(input); + if (!result.ok) throw new ReportDocumentValidationError(result.errors); + return result.document; +} diff --git a/frontend/src/lib/personal-report-service-core.ts b/frontend/src/lib/personal-report-service-core.ts new file mode 100644 index 00000000..538dae6b --- /dev/null +++ b/frontend/src/lib/personal-report-service-core.ts @@ -0,0 +1,518 @@ +import { + parseServerReportDocument, + 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"; + +/** + * Server-only persistence layer for personal reports (pure core). + * + * Routing: production callers pass the result of createServerSupabaseClient() + * (which already resolves self-hosted PostgreSQL vs Supabase). This module + * never inspects query-builder or pg specifics: it depends on the narrow + * PersonalReportDataClient port below, so unit tests can inject an in-memory + * fake and neither Supabase QueryBuilder nor node-postgres shapes leak into + * the API layer. The production entry personal-report-service.ts adds + * `import "server-only"` and re-exports this module. + * + * Ownership: every operation is scoped by userId; completeReady additionally + * verifies the validated document's reportId against the row id and the row's + * stored hash columns against the document (hashes are recomputed here, never + * trusted as model self-report). + * + * Idempotency: unique (user_id, request_id) is the primary lock. Replaying a + * known requestId requires the same requestFingerprint; a different + * fingerprint under the same requestId returns request_conflict instead of + * silently overwriting. Failed records are never implicitly resurrected by + * this service; callers reuse a failed record only via a new requestId. + * + * Privacy: this module never logs, indexes or returns birth details, report + * bodies, prompts, model text or exception stacks. failures carry a stable + * failure_code enum only. + */ + +export const PERSONAL_REPORT_FAILURE_CODES = [ + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", +] as const; +export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[number]; + +export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const; +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 type PersonalReportServiceErrorCode = + | "invalid_request" + | "not_found" + | "invalid_state" + | "generation_in_progress" + | "request_conflict" + | "invalid_document" + | "invalid_failure_code" + | "storage_failed"; + +export class PersonalReportServiceError extends Error { + readonly code: PersonalReportServiceErrorCode; + + constructor(code: PersonalReportServiceErrorCode, message?: string) { + super(message ?? `Personal report service error: ${code}`); + this.name = "PersonalReportServiceError"; + this.code = code; + } +} + +export type PersonalReportRecord = Readonly<{ + id: string; + userId: string; + sessionId: string | null; + chartProfileId: string | null; + requestId: string; + requestFingerprint: string; + reportType: (typeof PERSONAL_REPORT_TYPES)[number]; + status: PersonalReportStatus; + schemaVersion: string; + presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number]; + requestedThemes: readonly string[]; + reportDocument: ReportDocumentV1 | null; + calculationHash: string | null; + evidenceHash: string | null; + skillSourceCommit: string | null; + skillSnapshotSha256: string; + failureCode: PersonalReportFailureCode | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +}>; + +export type CreateGeneratingInput = Readonly<{ + userId: string; + requestId: string; + requestFingerprint: string; + reportType: (typeof PERSONAL_REPORT_TYPES)[number]; + presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number]; + requestedThemes?: readonly string[]; + sessionId?: string | null; + chartProfileId?: string | null; + skillSourceCommit?: string | null; + skillSnapshotSha256: string; +}>; + +export type CreateGeneratingResult = + | Readonly<{ kind: "created"; record: PersonalReportRecord }> + | Readonly<{ kind: "replayed"; record: PersonalReportRecord }> + | Readonly<{ kind: "request_conflict"; record: PersonalReportRecord }> + | Readonly<{ kind: "generation_in_progress"; record: PersonalReportRecord }>; + +export type PersonalReportQueryResult = Readonly<{ + data: unknown; + error: Readonly<{ message: string; code?: string }> | null; + count?: number | null; +}>; + +/** Narrow structural port implemented by the in-memory fake and the adapter. */ +export interface PersonalReportQueryBuilder extends PromiseLike { + select(columns: string): PersonalReportQueryBuilder; + insert(row: Readonly>): PersonalReportQueryBuilder; + update(values: Readonly>): PersonalReportQueryBuilder; + delete(options?: Readonly<{ count?: string }>): PersonalReportQueryBuilder; + eq(column: string, value: unknown): PersonalReportQueryBuilder; + order(column: string, options?: Readonly<{ ascending?: boolean }>): PersonalReportQueryBuilder; + limit(value: number): PersonalReportQueryBuilder; + maybeSingle(): PromiseLike; + single(): PromiseLike; +} + +export interface PersonalReportDataClient { + from(table: "personal_reports"): PersonalReportQueryBuilder; +} + +export type PersonalReportServiceDeps = Readonly<{ + now?: () => Date; +}>; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const sha256Pattern = /^[0-9a-f]{64}$/; +const sha1Pattern = /^[0-9a-f]{40}$/; + +const RECORD_COLUMNS = [ + "id", + "user_id", + "session_id", + "chart_profile_id", + "request_id", + "request_fingerprint", + "report_type", + "status", + "schema_version", + "presentation_mode", + "requested_themes", + "report_document", + "calculation_hash", + "evidence_hash", + "skill_source_commit", + "skill_snapshot_sha256", + "failure_code", + "created_at", + "updated_at", + "completed_at", +].join(","); + +type DbRow = Readonly>; + +function requireUuid(value: unknown, field: string): string { + if (typeof value !== "string" || !uuidPattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a uuid`); + } + return value; +} + +function optionalUuid(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + return requireUuid(value, field); +} + +function requireSha256(value: unknown, field: string): string { + if (typeof value !== "string" || !sha256Pattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a 64-char sha256 hex`); + } + return value; +} + +function optionalHash(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + return requireSha256(value, field); +} + +function optionalCommit(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "string" || !sha1Pattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a 40-char commit sha`); + } + return value; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | null { + if (!row) return null; + const requestedThemes = Array.isArray(row.requested_themes) + ? row.requested_themes.filter((theme): theme is string => typeof theme === "string") + : []; + const reportDocument = row.report_document === null || row.report_document === undefined + ? null + : row.report_document as ReportDocumentV1; + return { + id: stringOrNull(row.id) ?? "", + userId: stringOrNull(row.user_id) ?? "", + sessionId: stringOrNull(row.session_id), + chartProfileId: stringOrNull(row.chart_profile_id), + requestId: stringOrNull(row.request_id) ?? "", + requestFingerprint: stringOrNull(row.request_fingerprint) ?? "", + reportType: row.report_type as PersonalReportRecord["reportType"], + status: row.status as PersonalReportStatus, + schemaVersion: stringOrNull(row.schema_version) ?? "", + presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"], + requestedThemes, + reportDocument, + calculationHash: stringOrNull(row.calculation_hash), + evidenceHash: stringOrNull(row.evidence_hash), + skillSourceCommit: stringOrNull(row.skill_source_commit), + skillSnapshotSha256: stringOrNull(row.skill_snapshot_sha256) ?? "", + failureCode: stringOrNull(row.failure_code) as PersonalReportFailureCode | null, + createdAt: stringOrNull(row.created_at) ?? "", + updatedAt: stringOrNull(row.updated_at) ?? "", + completedAt: stringOrNull(row.completed_at), + }; +} + +function normalizedResult(result: PersonalReportQueryResult): PersonalReportQueryResult { + return { + data: result.data, + error: result.error ? { message: result.error.message, code: result.error.code } : null, + ...(typeof result.count === "number" ? { count: result.count } : {}), + }; +} + +/** + * Wraps any routed client (real Supabase or the local PostgreSQL compatibility + * client) into the narrow port. Every chain step is re-wrapped so callers only + * ever see PersonalReportQueryBuilder shapes. + */ +export function createPersonalReportDataClient(supabase: { + from(table: string): unknown; +}): PersonalReportDataClient { + type AnyBuilder = { + select(columns: string): unknown; + insert(row: unknown): unknown; + update(values: unknown): unknown; + delete(options?: unknown): unknown; + eq(column: string, value: unknown): unknown; + order(column: string, options?: unknown): unknown; + limit(value: number): unknown; + maybeSingle(): PromiseLike; + single(): PromiseLike; + then: PromiseLike["then"]; + }; + + const wrap = (builder: AnyBuilder): PersonalReportQueryBuilder => ({ + select: (columns) => wrap(builder.select(columns) as AnyBuilder), + insert: (row) => wrap(builder.insert(row) as AnyBuilder), + update: (values) => wrap(builder.update(values) as AnyBuilder), + delete: (options) => wrap(builder.delete(options) as AnyBuilder), + eq: (column, value) => wrap(builder.eq(column, value) as AnyBuilder), + order: (column, options) => wrap(builder.order(column, options) as AnyBuilder), + limit: (value) => wrap(builder.limit(value) as AnyBuilder), + maybeSingle: () => builder.maybeSingle().then(normalizedResult), + single: () => builder.single().then(normalizedResult), + then: (onfulfilled, onrejected) => + Promise.resolve(builder.then(normalizedResult)).then(onfulfilled, onrejected), + }); + + return { + from: (table) => wrap(supabase.from(table) as unknown as AnyBuilder), + }; +} + +export function createPersonalReportService( + client: PersonalReportDataClient, + deps: PersonalReportServiceDeps = {}, +): PersonalReportService { + const now = deps.now ?? (() => new Date()); + const records = () => client.from("personal_reports"); + + async function loadRow( + userId: string, + reportId: string, + ): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("id", reportId) + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + async function loadByRequest( + userId: string, + requestId: string, + ): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("user_id", userId) + .eq("request_id", requestId) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + async function anyGeneratingFor(userId: string): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("user_id", userId) + .eq("status", "generating") + .limit(1) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + return { + async createGenerating(input: CreateGeneratingInput): Promise { + const userId = requireUuid(input.userId, "userId"); + const requestId = requireUuid(input.requestId, "requestId"); + const requestFingerprint = requireSha256(input.requestFingerprint, "requestFingerprint"); + if (!PERSONAL_REPORT_TYPES.includes(input.reportType)) { + throw new PersonalReportServiceError("invalid_request", "unsupported reportType"); + } + if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) { + throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode"); + } + optionalUuid(input.sessionId, "sessionId"); + optionalUuid(input.chartProfileId, "chartProfileId"); + optionalHash(input.skillSnapshotSha256, "skillSnapshotSha256"); + optionalCommit(input.skillSourceCommit, "skillSourceCommit"); + const themes = input.requestedThemes ?? []; + if (!Array.isArray(themes) || themes.length > 12 || themes.some((theme) => typeof theme !== "string" || theme.length > 64)) { + throw new PersonalReportServiceError("invalid_request", "invalid requestedThemes"); + } + + const row: Record = { + user_id: userId, + request_id: requestId, + request_fingerprint: requestFingerprint, + report_type: input.reportType, + status: "generating", + schema_version: REPORT_DOCUMENT_SCHEMA_VERSION, + presentation_mode: input.presentationMode, + requested_themes: themes, + skill_snapshot_sha256: input.skillSnapshotSha256, + session_id: input.sessionId ?? null, + chart_profile_id: input.chartProfileId ?? null, + skill_source_commit: input.skillSourceCommit ?? null, + created_at: now().toISOString(), + updated_at: now().toISOString(), + }; + + const { data, error } = await records() + .insert(row) + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return { kind: "created", record: recordFromRow(data as DbRow)! }; + + // The (user_id, request_id) unique constraint already holds this + // request, or the per-user in-flight index rejected a second + // generation. Re-read state instead of trusting backend error codes. + const existing = await loadByRequest(userId, requestId); + if (existing) { + if (existing.request_fingerprint === requestFingerprint) { + return { kind: "replayed", record: recordFromRow(existing)! }; + } + return { kind: "request_conflict", record: recordFromRow(existing)! }; + } + const inFlight = await anyGeneratingFor(userId); + if (inFlight) return { kind: "generation_in_progress", record: recordFromRow(inFlight)! }; + throw new PersonalReportServiceError("storage_failed", error?.message ?? "insert failed"); + }, + + async getByUserAndRequestId(userId: string, requestId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(requestId, "requestId"); + const row = await loadByRequest(userId, requestId); + return recordFromRow(row); + }, + + async getOwnedById(userId: string, reportId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + const row = await loadRow(userId, reportId); + return recordFromRow(row); + }, + + async completeReady( + userId: string, + reportId: string, + document: unknown, + ): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + let parsed: ReportDocumentV1; + try { + parsed = parseServerReportDocument(document); + } catch (error) { + throw new PersonalReportServiceError( + "invalid_document", + error instanceof Error ? error.message : "report document failed contract validation", + ); + } + if (parsed.reportId !== reportId) { + throw new PersonalReportServiceError("invalid_document", "document reportId does not match record id"); + } + const row = await loadRow(userId, reportId); + if (!row) throw new PersonalReportServiceError("not_found"); + if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`); + + // Hashes are recomputed here and cross-checked against the stored row; + // provenance.evidenceHash was already verified by parseServerReportDocument. + const evidenceHash = computeEvidenceHash(parsed.evidenceAppendix); + if (row.evidence_hash !== null && row.evidence_hash !== evidenceHash) { + throw new PersonalReportServiceError("invalid_document", "evidence hash does not match stored row"); + } + if (row.calculation_hash !== null && row.calculation_hash !== parsed.provenance.calculationHash) { + throw new PersonalReportServiceError("invalid_document", "calculation hash does not match stored row"); + } + + const completedAt = now().toISOString(); + const { data, error } = await records() + .update({ + status: "ready", + report_document: parsed, + evidence_hash: evidenceHash, + calculation_hash: parsed.provenance.calculationHash, + completed_at: completedAt, + updated_at: completedAt, + }) + .eq("id", reportId) + .eq("user_id", userId) + .eq("status", "generating") + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return recordFromRow(data as DbRow)!; + const current = await loadRow(userId, reportId); + if (!current) throw new PersonalReportServiceError("not_found"); + throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`); + }, + + async markFailed( + userId: string, + reportId: string, + failureCode: string, + ): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + if (!PERSONAL_REPORT_FAILURE_CODES.includes(failureCode as PersonalReportFailureCode)) { + throw new PersonalReportServiceError("invalid_failure_code", `unsupported failure code ${failureCode}`); + } + const row = await loadRow(userId, reportId); + if (!row) throw new PersonalReportServiceError("not_found"); + if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`); + + const updatedAt = now().toISOString(); + const { data, error } = await records() + .update({ + status: "failed", + failure_code: failureCode, + updated_at: updatedAt, + }) + .eq("id", reportId) + .eq("user_id", userId) + .eq("status", "generating") + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return recordFromRow(data as DbRow)!; + const current = await loadRow(userId, reportId); + if (!current) throw new PersonalReportServiceError("not_found"); + throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`); + }, + + async deleteOwned(userId: string, reportId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + const { error, count } = await records() + .delete({ count: "exact" }) + .eq("id", reportId) + .eq("user_id", userId); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return (count ?? 0) > 0; + }, + }; +} + +export interface PersonalReportService { + createGenerating(input: CreateGeneratingInput): Promise; + getByUserAndRequestId(userId: string, requestId: string): Promise; + getOwnedById(userId: string, reportId: string): Promise; + completeReady(userId: string, reportId: string, document: unknown): Promise; + markFailed(userId: string, reportId: string, failureCode: string): Promise; + deleteOwned(userId: string, reportId: string): Promise; +} + +/** Production wiring: routes through the caller's resolved backend client. */ +export function createSupabasePersonalReportService( + supabase: { from(table: string): unknown }, + deps?: PersonalReportServiceDeps, +): PersonalReportService { + return createPersonalReportService(createPersonalReportDataClient(supabase), deps); +} diff --git a/frontend/src/lib/personal-report-service.ts b/frontend/src/lib/personal-report-service.ts new file mode 100644 index 00000000..2dd2654f --- /dev/null +++ b/frontend/src/lib/personal-report-service.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export * from "./personal-report-service-core"; diff --git a/frontend/supabase/migrations/20260806010000_personal_reports.sql b/frontend/supabase/migrations/20260806010000_personal_reports.sql new file mode 100644 index 00000000..9b34b39c --- /dev/null +++ b/frontend/supabase/migrations/20260806010000_personal_reports.sql @@ -0,0 +1,100 @@ +-- Personal report persistence for Supabase (production). +-- Mirrors db/migrations/20260806000000_personal_reports.sql one-to-one in +-- table shape, constraints, RLS and grants. +-- +-- Ownership model: rows are owned by auth.users(id). authenticated JWTs may +-- select/delete only their own rows through RLS plus the explicit owner +-- grants below, and can never insert/update (creating a generating record +-- and moving it to ready/failed are server-side operations performed only +-- through service_role, which bypasses RLS but still needs explicit grants). +-- +-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a +-- known requestId requires the same request_fingerprint (a sha256 of the +-- caller's request intent), so a different payload under the same requestId +-- surfaces as request_conflict instead of silently overwriting. Failed +-- retries are not implicitly upserted here; callers either reuse the failed +-- record via a new requestId or surface the stable failure. +-- +-- No birth details, report bodies, model prompts or exception stacks are ever +-- written to logs, index columns or audit events; failure_code is a stable +-- enum shared with frontend/src/lib/personal-report-service.ts and +-- scripts/personal_report_contract.py. + +begin; + +create table if not exists public.personal_reports ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid, + chart_profile_id uuid, + request_id uuid not null, + request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), + report_type text not null check (report_type in ('personal_full', 'personal_thematic')), + status text not null check (status in ('generating', 'ready', 'failed')), + schema_version text not null check (schema_version = 'report_document.v1'), + presentation_mode text not null check (presentation_mode in ('default', 'research')), + requested_themes text[] not null default '{}'::text[], + report_document jsonb, + calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), + evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), + skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), + skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + failure_code text check (failure_code in ( + 'profile_incomplete', + 'birth_time_not_usable', + 'report_generation_in_progress', + 'report_rate_limited', + 'calculation_unavailable', + 'model_unavailable', + 'report_schema_invalid', + 'report_guard_rejected', + 'report_not_found' + )), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + check ((status = 'ready') = (report_document is not null)), + check ((status = 'ready') = (completed_at is not null)), + check ((status = 'ready') = (calculation_hash is not null)), + check ((status = 'ready') = (evidence_hash is not null)), + check ((status = 'failed') = (failure_code is not null)), + unique (user_id, request_id) +); + +create index if not exists personal_reports_user_created_idx + on public.personal_reports (user_id, created_at desc); + +-- One in-flight generation per user, enforced by the database so a second +-- request cannot start while the first is still generating. +create unique index if not exists personal_reports_one_generating_per_user + on public.personal_reports (user_id) + where status = 'generating'; + +alter table public.personal_reports enable row level security; +revoke all on table public.personal_reports from public, anon, authenticated; + +drop policy if exists personal_reports_select_own on public.personal_reports; +create policy personal_reports_select_own + on public.personal_reports + for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists personal_reports_delete_own on public.personal_reports; +create policy personal_reports_delete_own + on public.personal_reports + for delete + to authenticated + using (auth.uid() = user_id); + +-- Normal users can never insert or update rows: generation and status writes +-- are server-side operations only. RLS policies alone do not grant table +-- privileges, so the owner read/delete grants below are required for the +-- policies to be reachable. +grant select, delete on table public.personal_reports to authenticated; + +-- admin_runtime intentionally has no direct access to report bodies (least +-- privilege); generation runs through service_role only. +grant select, insert, update, delete on table public.personal_reports to service_role; + +commit; diff --git a/frontend/tests/personal-report-contract.test.ts b/frontend/tests/personal-report-contract.test.ts new file mode 100644 index 00000000..c08faaf7 --- /dev/null +++ b/frontend/tests/personal-report-contract.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + computeEvidenceHash, + safeParseServerReportDocument, + parseServerReportDocument, +} from "../src/lib/personal-report-contract.server-core.ts"; +import { + findBlockedDeterministicClaims, + findChartSetViolations, + findDanglingEvidenceRefs, + findDuplicateEvidenceIds, + findForbiddenContent, + parseReportDocument, + REPORT_DOCUMENT_MAX_BYTES, + safeParseReportDocument, + serializedReportDocumentBytes, + type ReportDocumentV1, +} from "../src/lib/personal-report-contract.ts"; +import { ReportDocumentValidationError } from "../src/lib/personal-report-contract.ts"; + +const fixtureText = readFileSync( + new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url), + "utf8", +); +const fixture: ReportDocumentV1 = JSON.parse(fixtureText); +const clone = () => structuredClone(fixture) as ReportDocumentV1; + +test("fixture passes isomorphic parse and stays under the 1.5 MiB cap", () => { + const result = safeParseReportDocument(fixture); + assert.equal(result.ok, true); + assert.ok(serializedReportDocumentBytes(fixture) <= REPORT_DOCUMENT_MAX_BYTES); + assert.ok(serializedReportDocumentBytes(fixture) < 100_000); +}); + +test("isomorphic parse rejects extra keys, missing keys, and bad enums", () => { + const extra = clone(); + (extra.subject as Record).hometown = "上海"; + assert.equal(safeParseReportDocument(extra).ok, false); + + const missing = clone(); + delete (missing as Partial).disclaimer; + assert.equal(safeParseReportDocument(missing).ok, false); + + const badEnum = clone(); + badEnum.subject.birthTimeStatus = "guessed" as ReportDocumentV1["subject"]["birthTimeStatus"]; + assert.equal(safeParseReportDocument(badEnum).ok, false); +}); + +test("charts must contain exactly one D1 with all twelve houses", () => { + const zeroD1 = clone(); + zeroD1.charts = zeroD1.charts.filter((chart) => chart.id !== "D1"); + assert.deepEqual(findChartSetViolations(zeroD1), ["charts must contain exactly one D1 chart, found 0"]); + assert.equal(safeParseReportDocument(zeroD1).ok, false); + + const twoD1 = clone(); + twoD1.charts.push(structuredClone(twoD1.charts[0])); + const violations = findChartSetViolations(twoD1); + assert.ok(violations.some((v) => v.includes("duplicate chart id"))); + assert.ok(violations.some((v) => v.includes("exactly one D1"))); + assert.equal(safeParseReportDocument(twoD1).ok, false); + + const elevenHouses = clone(); + elevenHouses.charts[0].houses = elevenHouses.charts[0].houses.slice(0, 11); + assert.ok(findChartSetViolations(elevenHouses).some((v) => v.includes("all twelve house numbers"))); + assert.equal(safeParseReportDocument(elevenHouses).ok, false); +}); + +test("duplicate house numbers are rejected per chart", () => { + const duplicated = clone(); + duplicated.charts[0].houses[11].houseNumber = 1; + const result = safeParseReportDocument(duplicated); + assert.equal(result.ok, false); +}); + +test("longitude is [0, 360)", () => { + const at360 = clone(); + at360.charts[0].planets![0].longitudeDegrees = 360; + assert.equal(safeParseReportDocument(at360).ok, false); + + const near360 = clone(); + near360.charts[0].planets![0].longitudeDegrees = 359.999; + assert.equal(safeParseReportDocument(near360).ok, true); +}); + +test("evidence ids must be globally unique across the appendix", () => { + const duplicated = clone(); + duplicated.evidenceAppendix.conflicts[0].id = duplicated.evidenceAppendix.techniqueAudit[0].id; + assert.ok(findDuplicateEvidenceIds(duplicated).some((v) => v.includes("ev-mevg-web"))); + assert.equal(safeParseReportDocument(duplicated).ok, false); +}); + +test("dangling evidenceRefs are rejected", () => { + const dangling = clone(); + dangling.thematicNarrative[0].evidenceRefs = ["ev-no-such-evidence"]; + assert.deepEqual(findDanglingEvidenceRefs(dangling), ["career:ev-no-such-evidence"]); + assert.equal(safeParseReportDocument(dangling).ok, false); +}); + +test("blocked sections reject deterministic predictions", () => { + const deterministic = clone(); + deterministic.thematicNarrative[3].narrative = "这个事件必然会发生在明年,一定会成功。"; + assert.ok(findBlockedDeterministicClaims(deterministic).length > 0); + assert.equal(safeParseReportDocument(deterministic).ok, false); + + const nonDeterministic = clone(); + nonDeterministic.thematicNarrative[3].narrative = "需要更多历史事件校准后才能评估,具体应期暂不提供。"; + assert.equal(findBlockedDeterministicClaims(nonDeterministic).length, 0); + assert.equal(safeParseReportDocument(nonDeterministic).ok, true); +}); + +const forbiddenSamples = [ + "", + "javascript:alert(1)", + "file:///Users/jesse/private/chart.json", + "参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}", + "onerror=alert(1)", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc", + "node:internal/modules/cjs/loader", + "Traceback (most recent call last)", + "__dirname/secret", + "__proto__ pollution", + "C:\\Users\\jesse\\chart.json", + "tool_call_id: call_123", +]; + +test("forbidden content patterns reject executable and internal material", () => { + for (const poison of forbiddenSamples) { + const poisoned = clone(); + poisoned.disclaimer = poison; + assert.ok( + findForbiddenContent(poison).length > 0, + `expected ${poison} to be flagged`, + ); + assert.equal(safeParseReportDocument(poisoned).ok, false, `poison: ${poison}`); + } +}); + +test("ordinary Chinese report text is not flagged as forbidden", () => { + assert.equal(findForbiddenContent("事业与财富主题的多系统证据较一致。").length, 0); + assert.equal(findForbiddenContent("A < B 的比较关系不属于 HTML 标签").length, 0); +}); + +test("serialization size cap rejects oversized documents", () => { + const oversized = clone(); + oversized.disclaimer = "字".repeat(REPORT_DOCUMENT_MAX_BYTES); + assert.equal(safeParseReportDocument(oversized).ok, false); +}); + +test("JSON object key order is not validated (display order is a typed contract)", () => { + const reordered: Record = {}; + for (const key of Object.keys(fixture).reverse()) { + reordered[key] = (fixture as Record)[key]; + } + assert.equal(safeParseReportDocument(reordered).ok, true); +}); + +test("server parse recomputes the evidence hash and rejects self-reported mismatches", () => { + // The fixture hash is the canonical cross-language value; recomputation must agree. + assert.equal(computeEvidenceHash(fixture.evidenceAppendix), fixture.provenance.evidenceHash); + assert.equal( + computeEvidenceHash(fixture.evidenceAppendix), + "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4", + ); + assert.equal(safeParseServerReportDocument(fixture).ok, true); + + // Tampered evidence with the self-reported hash left unchanged must fail. + const tampered = clone(); + tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值"; + const result = safeParseServerReportDocument(tampered); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.path === "provenance.evidenceHash")); + assert.throws(() => parseServerReportDocument(tampered), ReportDocumentValidationError); + + // The isomorphic parse alone does not verify the hash (server-only duty). + assert.equal(safeParseReportDocument(tampered).ok, true); +}); + +test("parseReportDocument throws a typed validation error on guard failures", () => { + const bad = clone(); + bad.thematicNarrative[0].evidenceRefs = ["ev-missing"]; + assert.throws(() => parseReportDocument(bad), ReportDocumentValidationError); + const parsed = parseReportDocument(fixture); + assert.equal(parsed.schemaVersion, "report_document.v1"); +}); diff --git a/frontend/tests/personal-report-migration.test.ts b/frontend/tests/personal-report-migration.test.ts new file mode 100644 index 00000000..fe919c4d --- /dev/null +++ b/frontend/tests/personal-report-migration.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { PERSONAL_REPORT_FAILURE_CODES } from "../src/lib/personal-report-service-core.ts"; + +const localMigration = readFileSync( + new URL("../db/migrations/20260806000000_personal_reports.sql", import.meta.url), + "utf8", +); +const supabaseMigration = readFileSync( + new URL("../supabase/migrations/20260806010000_personal_reports.sql", import.meta.url), + "utf8", +); + +const localMigrations = [ + "20260714000000_local_auth_compatibility.sql", + "20260720000100_backend_foundation.sql", + "20260721000100_self_hosted_identity.sql", + "20260721000200_identity_business_bridge.sql", + "20260727000000_admin_viewer_identity.sql", + "20260806000000_personal_reports.sql", +]; +const supabaseMigrations = [ + "20260805030000_reconcile_rectification_v4_conversational_turns.sql", + "20260806010000_personal_reports.sql", +]; + +test("migration filenames use unique, correctly ordered 14-digit versions", () => { + const pattern = /^\d{14}_[a-z0-9_]+\.sql$/; + assert.match("20260806000000_personal_reports.sql", pattern); + assert.match("20260806010000_personal_reports.sql", pattern); + assert.ok(localMigrations[5] > localMigrations[4], "local migration must sort after existing ones"); + assert.ok(supabaseMigrations[1] > supabaseMigrations[0], "supabase migration must sort after existing ones"); + const versions = [...localMigrations, ...supabaseMigrations].map((name) => name.split("_")[0]); + assert.equal(new Set(versions).size, versions.length, "all migration versions must be unique"); +}); + +test("both migrations define the same table shape with request_fingerprint after request_id", () => { + const parseColumns = (sql: string) => { + const body = sql.split("create table if not exists public.personal_reports")[1].split(");")[0]; + const columns: string[] = []; + let depth = 0; + for (const raw of body.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const before = depth; + for (const ch of line) { + if (ch === "(") depth += 1; + if (ch === ")") depth -= 1; + } + const first = line.split(/\s+/)[0]; + if (before === 1 && /^[a-z_][a-z0-9_]*$/.test(first) && first !== "check" && first !== "unique") { + columns.push(first); + } + } + return columns; + }; + const expected = [ + "id", "user_id", "session_id", "chart_profile_id", "request_id", + "request_fingerprint", "report_type", "status", "schema_version", + "presentation_mode", "requested_themes", "report_document", + "calculation_hash", "evidence_hash", "skill_source_commit", + "skill_snapshot_sha256", "failure_code", "created_at", "updated_at", + "completed_at", + ]; + assert.deepEqual(parseColumns(localMigration), expected); + assert.deepEqual(parseColumns(supabaseMigration), expected); +}); + +test("request_fingerprint is not null and restricted to sha256 hex in both migrations", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /request_fingerprint text not null check \(request_fingerprint ~ '\^\[0-9a-f\]\{64\}\$'\)/); + } +}); + +test("status, report_type, presentation_mode and schema_version checks are identical", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /status text not null check \(status in \('generating', 'ready', 'failed'\)\)/); + assert.match(sql, /report_type text not null check \(report_type in \('personal_full', 'personal_thematic'\)\)/); + assert.match(sql, /presentation_mode text not null check \(presentation_mode in \('default', 'research'\)\)/); + assert.match(sql, /schema_version text not null check \(schema_version = 'report_document\.v1'\)/); + } +}); + +test("ready requires document, completion time and both hashes; failed requires failure_code", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /check \(\(status = 'ready'\) = \(report_document is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(completed_at is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(calculation_hash is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(evidence_hash is not null\)\)/); + assert.match(sql, /check \(\(status = 'failed'\) = \(failure_code is not null\)\)/); + } +}); + +test("failure_code enum in SQL matches the service constant exactly", () => { + for (const sql of [localMigration, supabaseMigration]) { + const block = sql.match(/failure_code text check \(failure_code in \(([\s\S]+?)\)\)/)?.[1] ?? ""; + const sqlCodes = [...block.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]); + assert.deepEqual(sqlCodes, [...PERSONAL_REPORT_FAILURE_CODES]); + } +}); + +test("both migrations keep the (user_id, request_id) lock and one-generating-per-user index", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /unique \(user_id, request_id\)/); + assert.match(sql, /create unique index if not exists personal_reports_one_generating_per_user[\s\S]*where status = 'generating'/); + assert.match(sql, /references auth\.users\(id\) on delete cascade/); + } +}); + +test("RLS policies plus explicit owner grants: select/delete only, never insert/update", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /alter table public\.personal_reports enable row level security/); + assert.match(sql, /create policy personal_reports_select_own[\s\S]*for select[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/); + assert.match(sql, /create policy personal_reports_delete_own[\s\S]*for delete[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/); + // Policies alone do not grant privileges: explicit owner grants are required. + assert.match(sql, /grant select, delete on table public\.personal_reports to authenticated/); + assert.doesNotMatch(sql, /grant (insert|update) on table public\.personal_reports to authenticated/); + assert.match(sql, /grant select, insert, update, delete on table public\.personal_reports to service_role/); + } +}); + +test("least privilege: anon/public revoked and no direct admin_runtime body access", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /revoke all on table public\.personal_reports from public, anon, authenticated/); + } + assert.doesNotMatch(localMigration, /to admin_runtime/); + assert.doesNotMatch(supabaseMigration, /to admin_runtime/); +}); diff --git a/frontend/tests/personal-report-service.test.ts b/frontend/tests/personal-report-service.test.ts new file mode 100644 index 00000000..36919125 --- /dev/null +++ b/frontend/tests/personal-report-service.test.ts @@ -0,0 +1,392 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import type { ReportDocumentV1 } from "../src/lib/personal-report-contract.ts"; +import { + createPersonalReportService, + PersonalReportServiceError, + PERSONAL_REPORT_FAILURE_CODES, + type PersonalReportDataClient, + type PersonalReportQueryBuilder, + type PersonalReportQueryResult, +} from "../src/lib/personal-report-service-core.ts"; + +const fixtureText = readFileSync( + new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url), + "utf8", +); +const fixture: ReportDocumentV1 = JSON.parse(fixtureText); +const clone = () => structuredClone(fixture) as ReportDocumentV1; + +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +type MemoryRow = Record & { + id: string; + user_id: string; + request_id: string; + request_fingerprint: string; +}; + +type LoggedOperation = Readonly<{ + operation: "select" | "insert" | "update" | "delete"; + columns: readonly string[]; + values: Readonly>; +}>; + +class MemoryPersonalReportClient implements PersonalReportDataClient { + rows = new Map(); + private nextId = 0; + /** Recorded operations for owner-scoping privilege assertions. */ + log: LoggedOperation[] = []; + + from(table: "personal_reports"): PersonalReportQueryBuilder { + if (table !== "personal_reports") throw new Error("unexpected table"); + return this.chain(this.newState()); + } + + private newState() { + return { + operation: "select" as "select" | "insert" | "update" | "delete", + values: {} as Record, + filters: [] as { column: string; value: unknown }[], + }; + } + + private chain(state: ReturnType): PersonalReportQueryBuilder { + // One shared state object per chain; every chained method mutates it and + // returns a fresh view so .insert(...).select(...).single() works like the + // real Supabase/local builders. + + const execute = (): PersonalReportQueryResult => { + this.log.push({ operation: state.operation, columns: state.filters.map((f) => f.column), values: state.values }); + const matches = [...this.rows.values()].filter((row) => + state.filters.every(({ column, value }) => row[column] === value), + ); + + if (state.operation === "select") { + return { data: matches, error: null }; + } + if (state.operation === "insert") { + const duplicate = [...this.rows.values()].some((row) => + row.user_id === state.values.user_id && row.request_id === state.values.request_id); + if (duplicate) { + return { data: null, error: { code: "23505", message: "duplicate key (user_id, request_id)" } }; + } + const inflight = [...this.rows.values()].some((row) => + row.user_id === state.values.user_id && row.status === "generating"); + if (inflight) { + return { data: null, error: { code: "23505", message: "one generating per user" } }; + } + const id = typeof state.values.id === "string" + ? state.values.id + : `00000000-0000-4000-8000-${String(this.nextId++).padStart(12, "0")}`; + const row = { + evidence_hash: null, + calculation_hash: null, + failure_code: null, + completed_at: null, + report_document: null, + session_id: null, + chart_profile_id: null, + skill_source_commit: null, + ...state.values, + id, + } as unknown as MemoryRow; + this.rows.set(id, row); + return { data: [row], error: null }; + } + if (state.operation === "update") { + const updated: MemoryRow[] = []; + for (const row of matches) { + const next = { ...row, ...state.values } as MemoryRow; + this.rows.set(row.id, next); + updated.push(next); + } + return { data: updated, error: null }; + } + const deletedCount = matches.length; + for (const row of matches) this.rows.delete(row.id); + return { data: null, error: null, count: deletedCount }; + }; + + const wrap = (operation: typeof state.operation, values: Record = {}) => { + state.operation = operation; + state.values = values; + return this.chain(state); + }; + + return { + select: () => this.chain(state), + insert: (row) => wrap("insert", row as Record), + update: (values) => wrap("update", values as Record), + delete: () => wrap("delete"), + eq: (column, value) => { + state.filters.push({ column, value }); + return this.chain(state); + }, + order: () => this.chain(state), + limit: () => this.chain(state), + maybeSingle: () => { + const result = execute(); + const rows = Array.isArray(result.data) ? result.data : []; + return Promise.resolve(rows.length > 1 + ? { data: null, error: { code: "PGRST116", message: "unexpected row count" } } + : { data: rows[0] ?? null, error: null }); + }, + single: () => { + const result = execute(); + const rows = Array.isArray(result.data) ? result.data : []; + return Promise.resolve(rows.length === 1 + ? { data: rows[0], error: null } + : { data: null, error: { code: "PGRST116", message: "unexpected row count" } }); + }, + then: (onfulfilled, onrejected) => + Promise.resolve(execute()).then(onfulfilled, onrejected), + }; + } +} + +function setup(now = new Date("2026-08-06T08:00:00Z")) { + const client = new MemoryPersonalReportClient(); + const service = createPersonalReportService(client, { now: () => now }); + return { client, service, now }; +} + +const ownerId = "11111111-1111-4111-8111-111111111111"; +const otherId = "22222222-2222-4222-8222-222222222222"; +const requestId = "33333333-3333-4333-8333-333333333333"; +const fingerprintA = sha256("request-intent-a"); +const fingerprintB = sha256("request-intent-b"); + +function generatingInput(overrides: Record = {}) { + return { + userId: ownerId, + requestId, + requestFingerprint: fingerprintA, + reportType: "personal_full" as const, + presentationMode: "default" as const, + requestedThemes: ["career", "marriage"], + skillSnapshotSha256: "a".repeat(64), + skillSourceCommit: "b".repeat(40), + ...overrides, + }; +} + +test("createGenerating inserts a generating record with fingerprint", async () => { + const { service } = setup(); + const result = await service.createGenerating(generatingInput()); + assert.equal(result.kind, "created"); + if (result.kind !== "created") return; + assert.equal(result.record.status, "generating"); + assert.equal(result.record.requestFingerprint, fingerprintA); + assert.equal(result.record.schemaVersion, "report_document.v1"); + assert.deepEqual(result.record.requestedThemes, ["career", "marriage"]); + assert.equal(result.record.reportDocument, null); + assert.equal(result.record.failureCode, null); +}); + +test("same requestId and fingerprint replays idempotently", async () => { + const { service } = setup(); + const first = await service.createGenerating(generatingInput()); + assert.equal(first.kind, "created"); + const replay = await service.createGenerating(generatingInput()); + assert.equal(replay.kind, "replayed"); + if (first.kind !== "created" || replay.kind !== "replayed") return; + assert.equal(replay.record.id, first.record.id); + const byRequest = await service.getByUserAndRequestId(ownerId, requestId); + assert.equal(byRequest?.id, first.record.id); +}); + +test("same requestId with a different fingerprint is request_conflict, never replay", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const conflict = await service.createGenerating(generatingInput({ requestFingerprint: fingerprintB })); + assert.equal(conflict.kind, "request_conflict"); + if (conflict.kind !== "request_conflict") return; + assert.equal(conflict.record.requestFingerprint, fingerprintA); +}); + +test("a second in-flight generation for the same user is generation_in_progress", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const second = await service.createGenerating(generatingInput({ + requestId: "44444444-4444-4444-8444-444444444444", + requestFingerprint: sha256("other-intent"), + })); + assert.equal(second.kind, "generation_in_progress"); +}); + +test("createGenerating validates fingerprints, uuids and hashes", async () => { + const { service } = setup(); + const rejectsWithInvalidRequest = (input: Record) => + assert.rejects( + service.createGenerating(generatingInput(input)), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_request", + ); + await rejectsWithInvalidRequest({ requestFingerprint: "not-a-sha" }); + await rejectsWithInvalidRequest({ requestId: "nope" }); + await rejectsWithInvalidRequest({ skillSnapshotSha256: "short" }); + await rejectsWithInvalidRequest({ reportType: "personal_unknown" }); + await rejectsWithInvalidRequest({ userId: "not-a-uuid" }); +}); + +test("reads are owner-scoped", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const own = await service.getByUserAndRequestId(ownerId, requestId); + if (!own) throw new Error("missing own record"); + assert.equal(await service.getByUserAndRequestId(otherId, requestId), null); + + const byId = await service.getOwnedById(ownerId, own.id); + assert.equal(byId?.id, own.id); + assert.equal(await service.getOwnedById(otherId, own.id), null); +}); + +test("completeReady validates the contract and stores recomputed hashes", async () => { + const { service, now } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + const ready = await service.completeReady(ownerId, created.record.id, document); + assert.equal(ready.status, "ready"); + assert.equal(ready.reportDocument?.schemaVersion, "report_document.v1"); + assert.equal(ready.evidenceHash, fixture.provenance.evidenceHash); + assert.equal(ready.calculationHash, document.provenance.calculationHash); + assert.equal(ready.completedAt, now.toISOString()); +}); + +test("completeReady rejects document reportId mismatch and tampered evidence", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const wrongId = clone(); + wrongId.reportId = requestId; + await assert.rejects( + service.completeReady(ownerId, created.record.id, wrongId), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", + ); + + const tampered = clone(); + tampered.reportId = created.record.id; + tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值"; + await assert.rejects( + service.completeReady(ownerId, created.record.id, tampered), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", + ); +}); + +test("completeReady rejects non-generating states and foreign owners", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + await service.markFailed(ownerId, created.record.id, "model_unavailable"); + + await assert.rejects( + service.completeReady(ownerId, created.record.id, document), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", + ); + await assert.rejects( + service.completeReady(otherId, created.record.id, document), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "not_found", + ); +}); + +test("markFailed uses only stable failure codes", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + await assert.rejects( + service.markFailed(ownerId, created.record.id, "model said something bad"), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_failure_code", + ); + + const failed = await service.markFailed(ownerId, created.record.id, "model_unavailable"); + assert.equal(failed.status, "failed"); + assert.equal(failed.failureCode, "model_unavailable"); + assert.equal(failed.reportDocument, null); + assert.equal(failed.completedAt, null); + + await assert.rejects( + service.markFailed(ownerId, created.record.id, "report_rate_limited"), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", + ); + assert.deepEqual(PERSONAL_REPORT_FAILURE_CODES, [ + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", + ]); +}); + +test("deleteOwned removes only the owner's record", async () => { + const { service, client } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + assert.equal(await service.deleteOwned(otherId, created.record.id), false); + assert.ok(client.rows.has(created.record.id)); + assert.equal(await service.deleteOwned(ownerId, created.record.id), true); + assert.equal(client.rows.has(created.record.id), false); + assert.equal(await service.deleteOwned(ownerId, created.record.id), false); +}); + +test("every write and read operation is owner-scoped", async () => { + const { service, client } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + await service.getOwnedById(ownerId, created.record.id); + await service.getByUserAndRequestId(ownerId, requestId); + await service.completeReady(ownerId, created.record.id, document); + await service.deleteOwned(ownerId, created.record.id); + + for (const entry of client.log) { + if (entry.operation === "insert") { + assert.equal(entry.values.user_id, ownerId, "insert row must carry the caller user_id"); + } else { + assert.ok(entry.columns.includes("user_id"), `${entry.operation} missing user_id filter`); + } + } + const statefulWrites = client.log.filter((entry) => entry.operation === "update" || entry.operation === "delete"); + assert.ok(statefulWrites.length > 0); + for (const entry of statefulWrites) { + assert.ok(entry.columns.includes("id"), `${entry.operation} missing id filter`); + } +}); + +test("failed records are never implicitly resurrected by createGenerating", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + await service.markFailed(ownerId, created.record.id, "model_unavailable"); + + // Same requestId + same fingerprint after failure: replay reports the failed + // record as-is; the service never flips it back to generating. + const replay = await service.createGenerating(generatingInput()); + assert.equal(replay.kind, "replayed"); + if (replay.kind !== "replayed") return; + assert.equal(replay.record.status, "failed"); + assert.equal(replay.record.failureCode, "model_unavailable"); +}); diff --git a/scripts/personal_report_contract.py b/scripts/personal_report_contract.py new file mode 100644 index 00000000..45d6d220 --- /dev/null +++ b/scripts/personal_report_contract.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ReportDocument v1 contract validator (stdlib only). + +Mirrors `contracts/personal-report/report-document.v1.schema.json` and +`frontend/src/lib/personal-report-contract.ts` (Zod). This module deliberately +uses only the Python standard library: the project does not vendor `jsonschema`, +so the validator below implements the schema semantics explicitly so that the +three sides (JSON Schema / Zod / Python) stay aligned and testable. + +Public surface: + SCHEMA_VERSION, REPORT_CONTRACT_VERSION, MAX_SERIALIZED_BYTES + CLAIM_STATUSES, REPORT_TYPES, PRESENTATION_MODES, BIRTH_TIME_STATUSES + TECHNIQUE_STATUSES, CONFLICT_STATUSES, CHART_IDS + FAILURE_CODES (shared stable enum used by the persistence layer) + compute_evidence_hash(document) -> str + validate_report_document(document) -> ValidationResult + is_valid_report_document(document) -> bool + load_report_document(path) -> dict + parse_report_document_json(text) -> ValidationResult + CLI: python3 scripts/personal_report_contract.py +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +SCHEMA_VERSION = "report_document.v1" +REPORT_CONTRACT_VERSION = "1" +MAX_SERIALIZED_BYTES = 1_572_864 # 1.5 MiB hard cap on UTF-8 JSON serialization. + +CLAIM_STATUSES = ( + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked", +) +REPORT_TYPES = ("personal_full", "personal_thematic") +PRESENTATION_MODES = ("default", "research") +BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed") +TECHNIQUE_STATUSES = ("verified", "partial", "blocked") +CONFLICT_STATUSES = ("unresolved", "partial", "resolved") +CHART_IDS = ("D1", "D9", "D10") +HOUSE_NUMBERS = tuple(range(1, 13)) + +# Stable failure-code enum shared with the personal_reports table check +# constraint and frontend/src/lib/personal-report-service.ts. +FAILURE_CODES = ( + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", +) + +UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$") +ISO8601_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" +) +EVIDENCE_ID_PATTERN = re.compile(r"^ev-[a-z0-9_-]{1,63}$") +SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$") + +# ──────────────────────────────────────────────────────────────────────────── +# Semantic guard patterns. These must stay byte-for-byte equivalent to the +# FORBIDDEN_PATTERNS list in frontend/src/lib/personal-report-contract.ts. +# ──────────────────────────────────────────────────────────────────────────── + +FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = ( + ("html_tag_open", r"<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b"), + ("html_tag_close", r""), + ("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="), + ("executable_url", r"\b(?:javascript|vbscript|data:text/html|data:text/javascript|file):"), + ("processing_instruction", r"<\?"), + ("template_literal", r"\$\{"), + ("stack_trace", r"(?:Traceback \(most recent call last\)|node:internal/| at (?:Object|async|node)\.)"), + ("dunder_path", r"__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__"), + ("process_env", r"\bprocess\.env\b"), + ("unix_home_path", r"(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]"), + ("windows_drive_path", r"^[a-zA-Z]:[\\/]"), + ("jwt_token", r"\beyJ[A-Za-z0-9_-]{20,}\b"), + ("secret_marker", r"\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b"), + ("tool_trace", r"\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b"), + ("chain_of_thought", r"\bchain[\s_-]?of[\s_-]?thought\b"), +) + +# Deterministic-prediction phrases that must never appear inside a section whose +# claimStatus is "blocked". Must match the TS side exactly. +DETERMINISTIC_PHRASES: Tuple[str, ...] = ( + "必然", + "必定", + "一定会", + "肯定会", + "绝对会", + "保证会", + "无疑将", + "百分之百", + "确定无疑", + "guaranteed", + "definitely will", + "certainly will", + "will certainly", + "is certain to", +) + +# Bounded text limits shared with the schema. +TEXT_LIMITS: Dict[str, int] = { + "displayName": 120, + "birthPlaceLabel": 200, + "headline": 200, + "summary": 2000, + "priority": 200, + "chartTitle": 120, + "sign": 40, + "occupant": 40, + "planetName": 40, + "sectionTitle": 160, + "narrative": 4000, + "action": 400, + "caveat": 400, + "techniqueName": 160, + "notes": 500, + "conflictDescription": 1000, + "conflictImpact": 500, + "evidenceLabel": 160, + "evidenceValue": 500, + "evidenceSource": 200, + "blockedTechnique": 120, + "disclaimer": 2000, +} + +ARRAY_LIMITS: Dict[str, int] = { + "priorities": 8, + "charts": 3, + "houses": 12, + "planets": 12, + "occupants": 12, + "thematicNarrative": 12, + "actions": 12, + "caveats": 12, + "evidenceRefs": 24, + "techniqueAudit": 100, + "conflicts": 50, + "calculationEvidence": 100, + "blockedTechniques": 100, +} + +_RE = re.compile + + +def _compile(patterns: Tuple[Tuple[str, str], ...]) -> List[Tuple[str, "re.Pattern[str]"]]: + return [(name, _RE(expression, re.IGNORECASE)) for name, expression in patterns] + + +_FORBIDDEN_COMPILED = _compile(FORBIDDEN_PATTERNS) +_DETERMINISTIC_COMPILED = [ + (_RE(phrase, re.IGNORECASE), phrase) for phrase in DETERMINISTIC_PHRASES +] + + +@dataclass +class ValidationResult: + valid: bool + errors: List[str] = field(default_factory=list) + + def add(self, path: str, message: str) -> None: + self.errors.append(f"{path}: {message}") + + +# ──────────────────────────────────────────────────────────────────────────── +# Evidence hash (deterministic, cross-language). +# ──────────────────────────────────────────────────────────────────────────── + +def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]: + """Canonical evidence object. Defensive: malformed rows are reduced to + empty placeholders so validation never raises on arbitrary input; the + canonical hash only matches the TS side for structurally valid documents.""" + appendix = document.get("evidenceAppendix") + appendix = appendix if isinstance(appendix, dict) else {} + + def rows(key: str) -> List[Any]: + value = appendix.get(key) + return value if isinstance(value, list) else [] + + def safe(row: Any, key: str) -> Any: + return row.get(key) if isinstance(row, dict) else None + + canonical = { + "techniqueAudit": [], + "conflicts": [], + "calculationEvidence": [], + } + for row in rows("techniqueAudit"): + entry = { + "id": safe(row, "id"), + "techniqueId": safe(row, "techniqueId"), + "techniqueName": safe(row, "techniqueName"), + "status": safe(row, "status"), + "used": safe(row, "used"), + } + if isinstance(row, dict) and "notes" in row: + entry["notes"] = row["notes"] + canonical["techniqueAudit"].append(entry) + for row in rows("conflicts"): + canonical["conflicts"].append({ + "id": safe(row, "id"), + "description": safe(row, "description"), + "impact": safe(row, "impact"), + "status": safe(row, "status"), + }) + for row in rows("calculationEvidence"): + canonical["calculationEvidence"].append({ + "id": safe(row, "id"), + "label": safe(row, "label"), + "value": safe(row, "value"), + "source": safe(row, "source"), + }) + return canonical + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + + +def compute_evidence_hash(document: Dict[str, Any]) -> str: + """Deterministic SHA-256 over the evidence appendix, matching the TS side.""" + canonical = _canonical_evidence(document) + return hashlib.sha256(_canonical_json(canonical).encode("utf-8")).hexdigest() + + +def serialized_bytes(document: Dict[str, Any]) -> int: + return len(_canonical_json(document).encode("utf-8")) + + +# ──────────────────────────────────────────────────────────────────────────── +# Semantic guards. +# ──────────────────────────────────────────────────────────────────────────── + +def forbidden_content_hits(value: str) -> List[str]: + hits: List[str] = [] + for name, pattern in _FORBIDDEN_COMPILED: + if pattern.search(value): + hits.append(name) + return hits + + +def _text_guard(result: ValidationResult, path: str, value: Any) -> None: + if value is None: + return + if not isinstance(value, str): + result.add(path, f"expected string, got {type(value).__name__}") + return + hits = forbidden_content_hits(value) + if hits: + result.add(path, "forbidden content: " + ", ".join(sorted(set(hits)))) + + +def _blocked_determinism(result: ValidationResult, path: str, claim_status: str, texts: List[Tuple[str, str]]) -> None: + if claim_status != "blocked": + return + for label, text in texts: + if not isinstance(text, str): + continue + for pattern, phrase in _DETERMINISTIC_COMPILED: + if pattern.search(text): + result.add(path, f"blocked section contains deterministic prediction ({label!r} matches {phrase!r})") + + +def _evidence_refs(result: ValidationResult, document: Dict[str, Any]) -> None: + appendix = document["evidenceAppendix"] + + def ids(key: str) -> List[Any]: + value = appendix.get(key) + return value if isinstance(value, list) else [] + + known_ids: List[str] = [] + seen: Dict[str, str] = {} + for key in ("techniqueAudit", "conflicts", "calculationEvidence"): + for index, row in enumerate(ids(key)): + if not isinstance(row, dict) or not isinstance(row.get("id"), str): + continue + evidence_id = row["id"] + if evidence_id in seen: + result.add( + f"evidenceAppendix.{key}[{index}].id", + f"duplicate evidence id {evidence_id!r} (also used in {seen[evidence_id]})", + ) + else: + seen[evidence_id] = f"{key}[{index}]" + known_ids.append(evidence_id) + + for index, section in enumerate(document.get("thematicNarrative", [])): + if not isinstance(section, dict): + continue + path = f"thematicNarrative[{index}].evidenceRefs" + refs = section.get("evidenceRefs") + if not isinstance(refs, list): + continue + for ref in refs: + if ref not in known_ids: + result.add(path, f"unknown evidence id {ref!r}") + + +# ──────────────────────────────────────────────────────────────────────────── +# Structural validation (explicit schema implementation). +# ──────────────────────────────────────────────────────────────────────────── + +def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + result.add(path, f"expected object, got {type(value).__name__}") + return None + return value + + +def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Tuple[str, ...]) -> None: + if value not in allowed: + result.add(path, f"invalid value {value!r}; allowed: {', '.join(allowed)}") + + +def _check_text(result: ValidationResult, path: str, value: Any, max_length: int, min_length: int = 1) -> None: + if not isinstance(value, str): + result.add(path, f"expected string, got {type(value).__name__}") + return + if len(value) < min_length: + result.add(path, f"shorter than minimum length {min_length}") + if len(value) > max_length: + result.add(path, f"longer than maximum length {max_length}") + _text_guard(result, path, value) + + +def _check_uuid(result: ValidationResult, path: str, value: Any) -> None: + if not isinstance(value, str) or not UUID_PATTERN.match(value): + result.add(path, f"invalid uuid {value!r}") + + +def _check_hash(result: ValidationResult, path: str, value: Any, pattern: "re.Pattern[str]", length: int) -> None: + if not isinstance(value, str) or not pattern.match(value) or len(value) != length: + result.add(path, f"invalid hex hash {value!r}") + + +def _check_array( + result: ValidationResult, + path: str, + value: Any, + max_items: int, + min_items: int = 0, +) -> Optional[List[Any]]: + if not isinstance(value, list): + result.add(path, f"expected array, got {type(value).__name__}") + return None + if len(value) > max_items: + result.add(path, f"longer than maximum items {max_items}") + if len(value) < min_items: + result.add(path, f"shorter than minimum items {min_items}") + return value + + +def _check_keys( + result: ValidationResult, + path: str, + value: Dict[str, Any], + required: Tuple[str, ...], + allowed: Optional[Tuple[str, ...]] = None, +) -> None: + permitted = required if allowed is None else allowed + missing = [key for key in required if key not in value] + if missing: + result.add(path, "missing required keys: " + ", ".join(missing)) + extra = [key for key in value if key not in permitted] + if extra: + result.add(path, "unexpected keys: " + ", ".join(extra)) + + +def _validate_subject(result: ValidationResult, path: str, subject: Any) -> None: + value = _expect_object(result, path, subject) + if value is None: + return + _check_keys(result, path, value, ("displayName", "birthTimeStatus", "birthPlaceLabel")) + _check_text(result, f"{path}.displayName", value.get("displayName"), TEXT_LIMITS["displayName"]) + _check_enum(result, f"{path}.birthTimeStatus", value.get("birthTimeStatus"), BIRTH_TIME_STATUSES) + _check_text(result, f"{path}.birthPlaceLabel", value.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"]) + + +def _validate_provenance(result: ValidationResult, path: str, provenance: Any) -> None: + value = _expect_object(result, path, provenance) + if value is None: + return + _check_keys( + result, + path, + value, + ("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"), + ) + commit = value.get("skillSourceCommit") + if commit is not None: + _check_hash(result, f"{path}.skillSourceCommit", commit, SHA1_PATTERN, 40) + _check_hash(result, f"{path}.skillSnapshotSha256", value.get("skillSnapshotSha256"), SHA256_PATTERN, 64) + _check_hash(result, f"{path}.calculationHash", value.get("calculationHash"), SHA256_PATTERN, 64) + _check_hash(result, f"{path}.evidenceHash", value.get("evidenceHash"), SHA256_PATTERN, 64) + if value.get("reportContractVersion") != REPORT_CONTRACT_VERSION: + result.add(f"{path}.reportContractVersion", f"must be {REPORT_CONTRACT_VERSION!r}") + + +def _validate_executive_summary(result: ValidationResult, path: str, summary: Any) -> None: + value = _expect_object(result, path, summary) + if value is None: + return + _check_keys(result, path, value, ("headline", "summary", "priorities", "overallClaimStatus")) + _check_text(result, f"{path}.headline", value.get("headline"), TEXT_LIMITS["headline"]) + _check_text(result, f"{path}.summary", value.get("summary"), TEXT_LIMITS["summary"]) + priorities = _check_array(result, f"{path}.priorities", value.get("priorities"), ARRAY_LIMITS["priorities"]) + if priorities is not None: + for index, priority in enumerate(priorities): + _check_text(result, f"{path}.priorities[{index}]", priority, TEXT_LIMITS["priority"]) + _check_enum(result, f"{path}.overallClaimStatus", value.get("overallClaimStatus"), CLAIM_STATUSES) + + +def _validate_chart(result: ValidationResult, path: str, chart: Any) -> None: + value = _expect_object(result, path, chart) + if value is None: + return + _check_keys(result, path, value, ("id", "title", "houses", "claimStatus"), ("id", "title", "houses", "claimStatus", "planets")) + _check_enum(result, f"{path}.id", value.get("id"), CHART_IDS) + _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["chartTitle"]) + _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) + + houses = _check_array(result, f"{path}.houses", value.get("houses"), ARRAY_LIMITS["houses"]) + seen_houses: List[int] = [] + if houses is not None: + for index, house in enumerate(houses): + house_path = f"{path}.houses[{index}]" + house_value = _expect_object(result, house_path, house) + if house_value is None: + continue + _check_keys(result, house_path, house_value, ("houseNumber", "sign", "occupants")) + house_number = house_value.get("houseNumber") + if house_number in seen_houses: + result.add(house_path, f"duplicate houseNumber {house_number!r}") + if isinstance(house_number, int) and not isinstance(house_number, bool) and house_number in HOUSE_NUMBERS: + seen_houses.append(house_number) + elif not isinstance(house_number, bool): + result.add(f"{house_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") + _check_text(result, f"{house_path}.sign", house_value.get("sign"), TEXT_LIMITS["sign"]) + occupants = _check_array(result, f"{house_path}.occupants", house_value.get("occupants"), ARRAY_LIMITS["occupants"]) + if occupants is not None: + for occupant_index, occupant in enumerate(occupants): + _check_text(result, f"{house_path}.occupants[{occupant_index}]", occupant, TEXT_LIMITS["occupant"]) + + planets = None + if "planets" in value: + planets = _check_array(result, f"{path}.planets", value.get("planets"), ARRAY_LIMITS["planets"]) + if planets is not None: + for index, planet in enumerate(planets): + planet_path = f"{path}.planets[{index}]" + planet_value = _expect_object(result, planet_path, planet) + if planet_value is None: + continue + _check_keys(result, planet_path, planet_value, ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde")) + _check_text(result, f"{planet_path}.name", planet_value.get("name"), TEXT_LIMITS["planetName"]) + _check_text(result, f"{planet_path}.sign", planet_value.get("sign"), TEXT_LIMITS["sign"]) + longitude = planet_value.get("longitudeDegrees") + if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360): + result.add(f"{planet_path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}") + house_number = planet_value.get("houseNumber") + if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS: + result.add(f"{planet_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") + if not isinstance(planet_value.get("retrograde"), bool): + result.add(f"{planet_path}.retrograde", "must be boolean") + + +def _validate_thematic_section(result: ValidationResult, path: str, section: Any) -> None: + value = _expect_object(result, path, section) + if value is None: + return + _check_keys(result, path, value, ("id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs")) + section_id = value.get("id") + if not isinstance(section_id, str) or not SECTION_ID_PATTERN.match(section_id): + result.add(f"{path}.id", f"invalid section id {section_id!r}") + _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["sectionTitle"]) + _check_text(result, f"{path}.narrative", value.get("narrative"), TEXT_LIMITS["narrative"]) + actions = _check_array(result, f"{path}.actions", value.get("actions"), ARRAY_LIMITS["actions"]) + if actions is not None: + for index, action in enumerate(actions): + _check_text(result, f"{path}.actions[{index}]", action, TEXT_LIMITS["action"]) + caveats = _check_array(result, f"{path}.caveats", value.get("caveats"), ARRAY_LIMITS["caveats"]) + if caveats is not None: + for index, caveat in enumerate(caveats): + _check_text(result, f"{path}.caveats[{index}]", caveat, TEXT_LIMITS["caveat"]) + _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) + refs = _check_array(result, f"{path}.evidenceRefs", value.get("evidenceRefs"), ARRAY_LIMITS["evidenceRefs"]) + if refs is not None: + for index, ref in enumerate(refs): + if not isinstance(ref, str) or not EVIDENCE_ID_PATTERN.match(ref): + result.add(f"{path}.evidenceRefs[{index}]", f"invalid evidence id {ref!r}") + + +def _validate_technique_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "techniqueId", "techniqueName", "status", "used"), ("id", "techniqueId", "techniqueName", "status", "used", "notes")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + technique_id = value.get("techniqueId") + if not isinstance(technique_id, str) or not TECHNIQUE_ID_PATTERN.match(technique_id): + result.add(f"{path}.techniqueId", f"invalid technique id {technique_id!r}") + _check_text(result, f"{path}.techniqueName", value.get("techniqueName"), TEXT_LIMITS["techniqueName"]) + _check_enum(result, f"{path}.status", value.get("status"), TECHNIQUE_STATUSES) + if not isinstance(value.get("used"), bool): + result.add(f"{path}.used", "must be boolean") + if "notes" in value: + _check_text(result, f"{path}.notes", value.get("notes"), TEXT_LIMITS["notes"], min_length=0) + + +def _validate_conflict_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "description", "impact", "status")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + _check_text(result, f"{path}.description", value.get("description"), TEXT_LIMITS["conflictDescription"]) + _check_text(result, f"{path}.impact", value.get("impact"), TEXT_LIMITS["conflictImpact"]) + _check_enum(result, f"{path}.status", value.get("status"), CONFLICT_STATUSES) + + +def _validate_calculation_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "label", "value", "source")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + _check_text(result, f"{path}.label", value.get("label"), TEXT_LIMITS["evidenceLabel"]) + _check_text(result, f"{path}.value", value.get("value"), TEXT_LIMITS["evidenceValue"]) + _check_text(result, f"{path}.source", value.get("source"), TEXT_LIMITS["evidenceSource"]) + + +def _validate_evidence_appendix(result: ValidationResult, path: str, appendix: Any) -> None: + value = _expect_object(result, path, appendix) + if value is None: + return + _check_keys( + result, + path, + value, + ("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"), + ) + if not isinstance(value.get("expandedByDefault"), bool): + result.add(f"{path}.expandedByDefault", "must be boolean") + rows = _check_array(result, f"{path}.techniqueAudit", value.get("techniqueAudit"), ARRAY_LIMITS["techniqueAudit"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_technique_row(result, f"{path}.techniqueAudit[{index}]", row) + rows = _check_array(result, f"{path}.conflicts", value.get("conflicts"), ARRAY_LIMITS["conflicts"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_conflict_row(result, f"{path}.conflicts[{index}]", row) + rows = _check_array(result, f"{path}.calculationEvidence", value.get("calculationEvidence"), ARRAY_LIMITS["calculationEvidence"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_calculation_row(result, f"{path}.calculationEvidence[{index}]", row) + blocked = _check_array(result, f"{path}.blockedTechniques", value.get("blockedTechniques"), ARRAY_LIMITS["blockedTechniques"]) + if blocked is not None: + for index, technique in enumerate(blocked): + _check_text(result, f"{path}.blockedTechniques[{index}]", technique, TEXT_LIMITS["blockedTechnique"]) + + +def _validate_chart_set(result: ValidationResult, charts: List[Any]) -> None: + """Runtime chart-set semantics: exactly one D1, unique ids, D1 complete houses.""" + seen_ids: List[str] = [] + d1_chart: Optional[Dict[str, Any]] = None + for index, chart in enumerate(charts): + if not isinstance(chart, dict): + continue + chart_id = chart.get("id") + if isinstance(chart_id, str): + if chart_id in seen_ids: + result.add(f"charts[{index}].id", f"duplicate chart id {chart_id!r}") + seen_ids.append(chart_id) + if chart_id == "D1": + d1_chart = chart + d1_count = sum(1 for chart_id in seen_ids if chart_id == "D1") + if d1_count != 1: + result.add("charts", f"must contain exactly one D1 chart, found {d1_count}") + if d1_chart is not None: + houses = d1_chart.get("houses") + if not isinstance(houses, list): + result.add("charts[D1].houses", "D1 chart must declare houses") + return + numbers = [] + for house in houses: + if isinstance(house, dict) and isinstance(house.get("houseNumber"), int) \ + and not isinstance(house.get("houseNumber"), bool): + numbers.append(house["houseNumber"]) + if sorted(numbers) != list(range(1, 13)): + result.add("charts[D1].houses", "D1 chart must contain all twelve house numbers 1..12 exactly once") + elif len(set(numbers)) != 12: + result.add("charts[D1].houses", "D1 chart contains duplicate house numbers") + + +def validate_report_document(document: Any) -> ValidationResult: + result = ValidationResult(valid=True) + if not isinstance(document, dict): + result.add("(root)", f"expected object, got {type(document).__name__}") + result.valid = False + return result + + _check_keys( + result, + "(root)", + document, + ( + "schemaVersion", + "reportId", + "reportType", + "presentationMode", + "generatedAt", + "subject", + "provenance", + "executiveSummary", + "charts", + "thematicNarrative", + "evidenceAppendix", + "disclaimer", + ), + ) + if document.get("schemaVersion") != SCHEMA_VERSION: + result.add("schemaVersion", f"must be {SCHEMA_VERSION!r}") + _check_uuid(result, "reportId", document.get("reportId")) + _check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES) + _check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES) + generated_at = document.get("generatedAt") + if not isinstance(generated_at, str) or not ISO8601_PATTERN.match(generated_at): + result.add("generatedAt", f"invalid ISO-8601 timestamp {generated_at!r}") + + _validate_subject(result, "subject", document.get("subject")) + _validate_provenance(result, "provenance", document.get("provenance")) + _validate_executive_summary(result, "executiveSummary", document.get("executiveSummary")) + + charts = _check_array(result, "charts", document.get("charts"), ARRAY_LIMITS["charts"], min_items=1) + if charts is not None: + for index, chart in enumerate(charts): + _validate_chart(result, f"charts[{index}]", chart) + _validate_chart_set(result, charts) + + sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), ARRAY_LIMITS["thematicNarrative"]) + seen_section_ids: List[str] = [] + if sections is not None: + for index, section in enumerate(sections): + _validate_thematic_section(result, f"thematicNarrative[{index}]", section) + section_id = section.get("id") if isinstance(section, dict) else None + if isinstance(section_id, str): + if section_id in seen_section_ids: + result.add(f"thematicNarrative[{index}].id", f"duplicate section id {section_id!r}") + seen_section_ids.append(section_id) + + _validate_evidence_appendix(result, "evidenceAppendix", document.get("evidenceAppendix")) + _check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"]) + + # Semantic guards (only when the structural shape is usable; all access is + # defensive so arbitrary/malformed JSON can never raise). + if ( + isinstance(document.get("evidenceAppendix"), dict) + and all( + isinstance(document["evidenceAppendix"].get(key), list) + for key in ("techniqueAudit", "conflicts", "calculationEvidence") + ) + and isinstance(document.get("thematicNarrative"), list) + and isinstance(document.get("provenance"), dict) + and isinstance(document.get("charts"), list) + and isinstance(document.get("executiveSummary"), dict) + ): + _evidence_refs(result, document) + expected_hash = compute_evidence_hash(document) + if document["provenance"].get("evidenceHash") != expected_hash: + result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}") + + blocked_texts: List[Tuple[str, str]] = [] + summary = document["executiveSummary"] + if summary.get("overallClaimStatus") == "blocked": + blocked_texts.append(("headline", summary.get("headline"))) + blocked_texts.append(("summary", summary.get("summary"))) + for index, priority in enumerate(summary.get("priorities", [])): + blocked_texts.append((f"priorities[{index}]", priority)) + for index, chart in enumerate(document["charts"]): + if isinstance(chart, dict) and chart.get("claimStatus") == "blocked": + blocked_texts.append((f"charts[{index}].title", chart.get("title"))) + for index, section in enumerate(document["thematicNarrative"]): + if isinstance(section, dict) and section.get("claimStatus") == "blocked": + blocked_texts.append((f"thematicNarrative[{index}].title", section.get("title"))) + blocked_texts.append((f"thematicNarrative[{index}].narrative", section.get("narrative"))) + for action_index, action in enumerate(section.get("actions", [])): + blocked_texts.append((f"thematicNarrative[{index}].actions[{action_index}]", action)) + for caveat_index, caveat in enumerate(section.get("caveats", [])): + blocked_texts.append((f"thematicNarrative[{index}].caveats[{caveat_index}]", caveat)) + for path, text in blocked_texts: + _blocked_determinism(result, path, "blocked", [(path, text)]) + + size = serialized_bytes(document) + if size > MAX_SERIALIZED_BYTES: + result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}") + + result.valid = not result.errors + return result + + +def is_valid_report_document(document: Any) -> bool: + return validate_report_document(document).valid + + +def load_report_document(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def parse_report_document_json(text: str) -> ValidationResult: + try: + document = json.loads(text) + except json.JSONDecodeError as error: + result = ValidationResult(valid=False) + result.add("(json)", f"invalid JSON: {error}") + return result + return validate_report_document(document) + + +def main(argv: Optional[List[str]] = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + print("usage: python3 scripts/personal_report_contract.py ", file=sys.stderr) + return 2 + path = args[0] + try: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + print(f"unable to read {path}: {error}", file=sys.stderr) + return 2 + result = parse_report_document_json(text) + if result.valid: + try: + size = serialized_bytes(json.loads(text)) + except ValueError: + size = 0 + print(f"valid: {path} ({size} bytes)") + return 0 + print(f"invalid: {path}", file=sys.stderr) + for error in result.errors: + print(f" - {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/personal_report_document.v1.json b/tests/fixtures/personal_report_document.v1.json new file mode 100644 index 00000000..33665222 --- /dev/null +++ b/tests/fixtures/personal_report_document.v1.json @@ -0,0 +1,399 @@ +{ + "schemaVersion": "report_document.v1", + "reportId": "3f2b1c4a-8d6e-4f0a-9c2b-5a7e1d3f8b40", + "reportType": "personal_full", + "presentationMode": "default", + "generatedAt": "2026-08-06T08:00:00Z", + "subject": { + "displayName": "测试用户(合成资料)", + "birthTimeStatus": "confirmed", + "birthPlaceLabel": "北京(合成测试地点)" + }, + "provenance": { + "skillSourceCommit": "9034e1967032d09c0a1b2c3d4e5f60718293a4b5", + "skillSnapshotSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "calculationHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "evidenceHash": "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4", + "reportContractVersion": "1" + }, + "executiveSummary": { + "headline": "事业与财富主题的多系统证据较一致", + "summary": "本报告基于服务端计算的本命盘与分盘证据。事业主题在 D10 与 A10 双层呈现一致信号,财富主题在 D2 与 D11 呈现中等强度信号;婚恋主题需用户历史事件核验;时机主题因外部参照未闭环而降级为 blocked,不给出确定性应期。", + "priorities": [ + "先核验事业主题的三条历史事件证据", + "婚恋主题等待用户提供可核验的过往关系时间点", + "时机主题在外部参照闭环前不做确定性预测" + ], + "overallClaimStatus": "multi_system_consensus" + }, + "charts": [ + { + "id": "D1", + "title": "本命盘 D1(Lahiri Ayanamsa)", + "claimStatus": "multi_system_consensus", + "houses": [ + { + "houseNumber": 1, + "sign": "狮子座", + "occupants": [ + "上升点" + ] + }, + { + "houseNumber": 2, + "sign": "处女座", + "occupants": [] + }, + { + "houseNumber": 3, + "sign": "天秤座", + "occupants": [ + "水星" + ] + }, + { + "houseNumber": 4, + "sign": "天蝎座", + "occupants": [ + "金星" + ] + }, + { + "houseNumber": 5, + "sign": "射手座", + "occupants": [ + "太阳" + ] + }, + { + "houseNumber": 6, + "sign": "摩羯座", + "occupants": [ + "火星" + ] + }, + { + "houseNumber": 7, + "sign": "水瓶座", + "occupants": [] + }, + { + "houseNumber": 8, + "sign": "双鱼座", + "occupants": [ + "木星" + ] + }, + { + "houseNumber": 9, + "sign": "白羊座", + "occupants": [ + "土星" + ] + }, + { + "houseNumber": 10, + "sign": "金牛座", + "occupants": [ + "月亮" + ] + }, + { + "houseNumber": 11, + "sign": "双子座", + "occupants": [] + }, + { + "houseNumber": 12, + "sign": "巨蟹座", + "occupants": [ + "罗睺" + ] + } + ], + "planets": [ + { + "name": "太阳", + "sign": "射手座", + "longitudeDegrees": 248.5, + "houseNumber": 5, + "retrograde": false + }, + { + "name": "月亮", + "sign": "金牛座", + "longitudeDegrees": 42.1, + "houseNumber": 10, + "retrograde": false + }, + { + "name": "火星", + "sign": "摩羯座", + "longitudeDegrees": 288.3, + "houseNumber": 6, + "retrograde": false + }, + { + "name": "水星", + "sign": "天秤座", + "longitudeDegrees": 190.7, + "houseNumber": 3, + "retrograde": false + }, + { + "name": "木星", + "sign": "双鱼座", + "longitudeDegrees": 341.9, + "houseNumber": 8, + "retrograde": false + }, + { + "name": "金星", + "sign": "天蝎座", + "longitudeDegrees": 222.4, + "houseNumber": 4, + "retrograde": false + }, + { + "name": "土星", + "sign": "白羊座", + "longitudeDegrees": 11.8, + "houseNumber": 9, + "retrograde": true + }, + { + "name": "罗睺", + "sign": "巨蟹座", + "longitudeDegrees": 102.6, + "houseNumber": 12, + "retrograde": true + }, + { + "name": "计都", + "sign": "摩羯座", + "longitudeDegrees": 282.6, + "houseNumber": 6, + "retrograde": true + } + ] + } + ], + "thematicNarrative": [ + { + "id": "career", + "title": "事业主题", + "narrative": "事业主题呈现中等偏强的信号:第十宫月亮与金牛座相关领域呼应,D10 与 A10 双层一致性较高。土星逆行提示职业节奏需要长期主义,不适合短期投机路径。", + "actions": [ + "在金牛座相关行业或管理岗位方向收集更多历史证据", + "将晋升或转岗事件的时间点记录下来用于后续校准" + ], + "caveats": [ + "本主题结论依赖出生时间确认状态,当前为 confirmed", + "外部参照引擎未全部闭环,置信度上限为多系统一致而非绝对" + ], + "claimStatus": "multi_system_consensus", + "evidenceRefs": [ + "ev-career-d10-a10", + "ev-career-dasha-boundary", + "ev-shadbala-total" + ] + }, + { + "id": "wealth", + "title": "财富主题", + "narrative": "财富主题在 D2 与 D11 呈现中等强度信号,第二宫与第十一宫的证据链相互印证,但缺乏足够的过往财务事件校准,属于单系统推断加参数敏感的组合。", + "actions": [ + "核对 D2 与 D11 的证据原始值是否与用户实际财务事件吻合" + ], + "caveats": [ + "财富结论不构成投资建议", + "未达到双系统一致时不得表述为确定结果" + ], + "claimStatus": "parameter_sensitive", + "evidenceRefs": [ + "ev-wealth-d2-d11", + "ev-ashtakavarga-wealth" + ] + }, + { + "id": "marriage", + "title": "婚恋主题", + "narrative": "婚恋主题已计算 D9 与 UL 相关证据,但本报告没有足够的用户历史关系事件来核验,需用户提供可核验时间点后重新评估。", + "actions": [ + "提供过往重要关系事件的时间点以完成核验" + ], + "caveats": [ + "未经用户历史事件核验的婚恋结论不得视为最终结论" + ], + "claimStatus": "user_history_verification_required", + "evidenceRefs": [ + "ev-marriage-d9-ul" + ] + }, + { + "id": "timing", + "title": "时机主题", + "narrative": "时机主题需要 Vimshottari 与 Narayana Dasha 双轨交叉,但外部参照引擎尚未闭环,当前不给出具体应期,仅保留已计算的运限边界供后续校准使用。", + "actions": [ + "等待外部参照闭环后重新评估应期" + ], + "caveats": [ + "当前不提供任何确定性时间预测", + "运限边界仅作为校准素材,不作为结论" + ], + "claimStatus": "blocked", + "evidenceRefs": [ + "ev-timing-vd-md-ad", + "ev-timing-narayana" + ] + } + ], + "evidenceAppendix": { + "expandedByDefault": false, + "techniqueAudit": [ + { + "id": "ev-mevg-web", + "techniqueId": "mevg_global_web_evidence", + "techniqueName": "MEVG / Global Web Evidence", + "status": "partial", + "used": true, + "notes": "外部资料采集完成度 60%,来源分级已记录,冲突已进入 conflicts 列表" + }, + { + "id": "ev-real-case", + "techniqueId": "real_case_calibration", + "techniqueName": "Real Case Calibration", + "status": "partial", + "used": true, + "notes": "10 个公开案例可回放:事业 5、婚恋 5;财富案例缺失" + }, + { + "id": "ev-fbm", + "techniqueId": "functional_benefic_malefic", + "techniqueName": "Functional Benefic/Malefic", + "status": "verified", + "used": true + }, + { + "id": "ev-vimshottari", + "techniqueId": "vimshottari_dasha", + "techniqueName": "Vimshottari Dasha", + "status": "verified", + "used": true + }, + { + "id": "ev-narayana", + "techniqueId": "narayana_dasha", + "techniqueName": "Narayana Dasha", + "status": "verified", + "used": true, + "notes": "与 Vimshottari 双轨交叉" + }, + { + "id": "ev-d10-a10", + "techniqueId": "d10_a10", + "techniqueName": "D10 + A10(事业分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-d2-d11", + "techniqueId": "d2_d11", + "techniqueName": "D2 / D11(财富分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-d9-ul", + "techniqueId": "d9_ul", + "techniqueName": "D9 + UL(婚恋分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-shadbala", + "techniqueId": "shadbala", + "techniqueName": "Shadbala", + "status": "partial", + "used": true, + "notes": "内部总量一致;外部绝对数值对照未闭环" + }, + { + "id": "ev-ashtakavarga", + "techniqueId": "ashtakavarga", + "techniqueName": "Ashtakavarga", + "status": "partial", + "used": true + } + ], + "conflicts": [ + { + "id": "ev-conflict-1", + "description": "Vimshottari 与 Narayana Dasha 在 2031 年前后的应期窗口存在分歧", + "impact": "时机主题降级为 blocked,不输出确定性应期", + "status": "unresolved" + }, + { + "id": "ev-conflict-2", + "description": "Shadbala 内部总量与外部参照数值尚未对齐", + "impact": "Shadbala 行标记为 partial,不参与绝对强度结论", + "status": "partial" + } + ], + "calculationEvidence": [ + { + "id": "ev-career-d10-a10", + "label": "D10 与 A10 事业证据", + "value": "D10 月亮入第十宫,A10 同宫主星呼应;双盘一致", + "source": "服务端排盘 varga D10/A10(Lahiri)" + }, + { + "id": "ev-career-dasha-boundary", + "label": "Vimshottari 大运边界", + "value": "当前大运:木星-土星;起始边界已记录", + "source": "服务端 Dasha 计算" + }, + { + "id": "ev-wealth-d2-d11", + "label": "D2 与 D11 财富证据", + "value": "D2 第二宫与 D11 第十一宫证据链相互印证", + "source": "服务端排盘 varga D2/D11" + }, + { + "id": "ev-ashtakavarga-wealth", + "label": "Ashtakavarga 财富相关宫位", + "value": "第二宫与第十一宫 Bhinna Ashtakavarga 点数高于均值", + "source": "服务端 Ashtakavarga 计算" + }, + { + "id": "ev-marriage-d9-ul", + "label": "D9 与 UL 婚恋证据", + "value": "D9 第七宫状态与 UL 指示存在呼应,需用户核验", + "source": "服务端排盘 varga D9 + UL" + }, + { + "id": "ev-timing-vd-md-ad", + "label": "Vimshottari 小运边界", + "value": "木星-土星-月亮 小运边界已计算,仅作校准素材", + "source": "服务端 Dasha 计算" + }, + { + "id": "ev-timing-narayana", + "label": "Narayana Dasha 边界", + "value": "Narayana 大运边界已计算,与 Vimshottari 存在分歧", + "source": "服务端 Narayana Dasha 计算" + }, + { + "id": "ev-shadbala-total", + "label": "Shadbala 总量", + "value": "各星 Shadbala 总量内部一致,外部对照 partial", + "source": "服务端 Shadbala 计算" + } + ], + "blockedTechniques": [ + "Sphuta 判定层(外部数值参照缺失)", + "Tajika 命名组合事件判定(无金标案例)" + ] + }, + "disclaimer": "本报告由计算引擎与模型共同生成,仅用于传统文化研究与个人参考,不构成医疗、法律或投资建议。任何涉及健康、法律、财务的决策请咨询对应领域的专业人士。报告中的时间预测均受证据完整度限制,blocked 内容不代表确定性结论。" +} diff --git a/tests/test_personal_report_contract.py b/tests/test_personal_report_contract.py new file mode 100644 index 00000000..b082d260 --- /dev/null +++ b/tests/test_personal_report_contract.py @@ -0,0 +1,295 @@ +"""ReportDocument v1 contract validator tests (Python side). + +Semantics are shared with contracts/personal-report/report-document.v1.schema.json +and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the +runtime-enforced rules that JSON Schema draft-07 cannot express and prove the +validator never raises on arbitrary/malformed input. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.personal_report_contract import ( + FAILURE_CODES, + MAX_SERIALIZED_BYTES, + compute_evidence_hash, + is_valid_report_document, + load_report_document, + main, + parse_report_document_json, + serialized_bytes, + validate_report_document, +) + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json" +SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql" +LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql" + + +@pytest.fixture(scope="module") +def fixture() -> dict: + return load_report_document(str(FIXTURE)) + + +def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None: + result = validate_report_document(fixture) + assert result.valid, result.errors + # evidenceHash is a deterministic recomputation, not a model self-report. + assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture) + assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES + + +def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None: + # The fixture is read byte-for-byte by the TS tests too; both sides must + # compute the same sha256 over the canonical evidence appendix. + assert fixture["provenance"]["evidenceHash"] == ( + "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4" + ) + + +@pytest.mark.parametrize( + "malformed", + [ + None, + 42, + "text", + [], + {}, + {"schemaVersion": "report_document.v1"}, + {"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}}, + {"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]}, + {"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"}, + ], +) +def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None: + result = validate_report_document(malformed) + assert result.valid is False + assert isinstance(result.errors, list) + + +def test_arbitrary_json_text_never_raises() -> None: + for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]: + result = parse_report_document_json(text) + assert result.valid is False + + +def test_charts_require_exactly_one_d1(fixture: dict) -> None: + without_d1 = json.loads(json.dumps(fixture)) + without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"] + result = validate_report_document(without_d1) + assert result.valid is False + assert any("exactly one D1" in error for error in result.errors) + + two_d1 = json.loads(json.dumps(fixture)) + two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0]))) + result = validate_report_document(two_d1) + assert result.valid is False + assert any("duplicate chart id" in error for error in result.errors) + assert any("exactly one D1" in error for error in result.errors) + + +def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None: + incomplete = json.loads(json.dumps(fixture)) + incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11] + result = validate_report_document(incomplete) + assert result.valid is False + assert any("all twelve house numbers" in error for error in result.errors) + + +def test_duplicate_house_numbers_rejected(fixture: dict) -> None: + duplicated = json.loads(json.dumps(fixture)) + duplicated["charts"][0]["houses"][11]["houseNumber"] = 1 + result = validate_report_document(duplicated) + assert result.valid is False + assert any("duplicate houseNumber" in error for error in result.errors) + + +def test_longitude_is_half_open_interval(fixture: dict) -> None: + at_360 = json.loads(json.dumps(fixture)) + at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0 + result = validate_report_document(at_360) + assert result.valid is False + assert any("longitudeDegrees" in error for error in result.errors) + + near_360 = json.loads(json.dumps(fixture)) + near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999 + near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360) + assert validate_report_document(near_360).valid + + +def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None: + duplicated = json.loads(json.dumps(fixture)) + duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"] + duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated) + result = validate_report_document(duplicated) + assert result.valid is False + assert any("duplicate evidence id" in error for error in result.errors) + + +def test_dangling_evidence_refs_rejected(fixture: dict) -> None: + dangling = json.loads(json.dumps(fixture)) + dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"] + result = validate_report_document(dangling) + assert result.valid is False + assert any("unknown evidence id" in error for error in result.errors) + + +def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None: + tampered = json.loads(json.dumps(fixture)) + tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值" + # Self-reported hash left unchanged: validator must recompute and reject. + result = validate_report_document(tampered) + assert result.valid is False + assert any("does not match computed evidence hash" in error for error in result.errors) + + +def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None: + deterministic = json.loads(json.dumps(fixture)) + deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。" + result = validate_report_document(deterministic) + assert result.valid is False + assert any("blocked section contains deterministic prediction" in error for error in result.errors) + + non_deterministic = json.loads(json.dumps(fixture)) + non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。" + non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic) + assert validate_report_document(non_deterministic).valid + + +@pytest.mark.parametrize( + "poison", + [ + "", + "javascript:alert(1)", + "file:///Users/jesse/private/chart.json", + "参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}", + "onerror=alert(1)", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc", + "node:internal/modules/cjs/loader", + "Traceback (most recent call last)", + "__dirname/secret", + "C:\\Users\\jesse\\chart.json", + "tool_call_id: call_123", + ], +) +def test_forbidden_content_rejected(fixture: dict, poison: str) -> None: + poisoned = json.loads(json.dumps(fixture)) + poisoned["disclaimer"] = poison + result = validate_report_document(poisoned) + assert result.valid is False + assert any("forbidden content" in error for error in result.errors) + + +def test_serialization_size_cap(fixture: dict) -> None: + oversized = json.loads(json.dumps(fixture)) + oversized["disclaimer"] = "字" * (MAX_SERIALIZED_BYTES) + result = validate_report_document(oversized) + assert result.valid is False + assert any("exceeding" in error for error in result.errors) + + +def test_strict_keys_missing_and_extra(fixture: dict) -> None: + missing = json.loads(json.dumps(fixture)) + del missing["disclaimer"] + result = validate_report_document(missing) + assert result.valid is False + assert any("missing required keys" in error for error in result.errors) + + extra = json.loads(json.dumps(fixture)) + extra["disclaimer"] = extra["disclaimer"] + extra["subject"]["hometown"] = "上海" + result = validate_report_document(extra) + assert result.valid is False + assert any("unexpected keys" in error for error in result.errors) + + +def test_json_object_key_order_is_not_validated(fixture: dict) -> None: + # JSON objects are unordered; fixed reader order is a display contract of + # the typed fields, never a key-order condition. + reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))} + assert validate_report_document(reordered).valid + + +def test_failure_code_enum_matches_both_migrations() -> None: + for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION): + sql = path.read_text(encoding="utf-8") + for code in FAILURE_CODES: + assert f"'{code}'" in sql, f"{code} missing from {path.name}" + assert sql.count("failure_code in") == 1 + # Both migrations share the same stable enum. + supabase_codes = set(FAILURE_CODES) + local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8") + assert all(f"'{code}'" in local_sql for code in supabase_codes) + + +def test_cli_exit_codes(fixture: dict) -> None: + assert main([str(FIXTURE)]) == 0 + assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1 + assert main([]) == 2 + assert main([str(ROOT / "does-not-exist.json")]) == 2 + + +def test_validator_accepts_synthetic_producer_output() -> None: + # A minimal-but-complete document produced without the fixture must pass. + from scripts.personal_report_contract import ( + BIRTH_TIME_STATUSES, + CLAIM_STATUSES, + PRESENTATION_MODES, + REPORT_TYPES, + SCHEMA_VERSION, + ) + + houses = [ + {"houseNumber": number, "sign": "狮子座", "occupants": []} + for number in range(1, 13) + ] + document = { + "schemaVersion": SCHEMA_VERSION, + "reportId": "00000000-0000-4000-8000-000000000001", + "reportType": REPORT_TYPES[0], + "presentationMode": PRESENTATION_MODES[0], + "generatedAt": "2026-08-06T08:00:00Z", + "subject": { + "displayName": "合成用户", + "birthTimeStatus": BIRTH_TIME_STATUSES[0], + "birthPlaceLabel": "合成地点", + }, + "provenance": { + "skillSourceCommit": None, + "skillSnapshotSha256": "c" * 64, + "calculationHash": "d" * 64, + "evidenceHash": "0" * 64, + "reportContractVersion": "1", + }, + "executiveSummary": { + "headline": "摘要标题", + "summary": "摘要正文。", + "priorities": ["优先事项"], + "overallClaimStatus": CLAIM_STATUSES[0], + }, + "charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}], + "thematicNarrative": [], + "evidenceAppendix": { + "expandedByDefault": False, + "techniqueAudit": [ + { + "id": "ev-audit", + "techniqueId": "d1_chart", + "techniqueName": "D1 本命盘", + "status": "verified", + "used": True, + } + ], + "conflicts": [], + "calculationEvidence": [], + "blockedTechniques": [], + }, + "disclaimer": "仅供研究参考。", + } + document["provenance"]["evidenceHash"] = compute_evidence_hash(document) + assert validate_report_document(document).valid