feat(report): add personal report contract and persistence
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export * from "./personal-report-contract.server-core";
|
||||
@@ -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<typeof reportDocumentSchema>;
|
||||
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<string, unknown> {
|
||||
return {
|
||||
techniqueAudit: appendix.techniqueAudit.map((row) => ({
|
||||
id: row.id,
|
||||
techniqueId: row.techniqueId,
|
||||
techniqueName: row.techniqueName,
|
||||
status: row.status,
|
||||
used: row.used,
|
||||
...(row.notes !== undefined ? { notes: row.notes } : {}),
|
||||
})),
|
||||
conflicts: appendix.conflicts.map((row) => ({
|
||||
id: row.id,
|
||||
description: row.description,
|
||||
impact: row.impact,
|
||||
status: row.status,
|
||||
})),
|
||||
calculationEvidence: appendix.calculationEvidence.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
value: row.value,
|
||||
source: row.source,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializedReportDocumentBytes(document: 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<string>([
|
||||
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
|
||||
...document.evidenceAppendix.conflicts.map((row) => row.id),
|
||||
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
|
||||
]);
|
||||
return document.thematicNarrative.flatMap((section) =>
|
||||
section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Evidence ids must be globally unique across the whole appendix. */
|
||||
export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] {
|
||||
const locations = new Map<string, string>();
|
||||
const duplicates: string[] = [];
|
||||
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
|
||||
...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })),
|
||||
...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })),
|
||||
...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })),
|
||||
];
|
||||
for (const { key, index, id } of rows) {
|
||||
const location = `${key}[${index}]`;
|
||||
const first = locations.get(id);
|
||||
if (first !== undefined) {
|
||||
duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
|
||||
} else {
|
||||
locations.set(id, location);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
export function findChartSetViolations(document: 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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<PersonalReportQueryResult> {
|
||||
select(columns: string): PersonalReportQueryBuilder;
|
||||
insert(row: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
|
||||
update(values: Readonly<Record<string, unknown>>): 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<PersonalReportQueryResult>;
|
||||
single(): PromiseLike<PersonalReportQueryResult>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
|
||||
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<PersonalReportQueryResult>;
|
||||
single(): PromiseLike<PersonalReportQueryResult>;
|
||||
then: PromiseLike<PersonalReportQueryResult>["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<DbRow | null> {
|
||||
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<DbRow | null> {
|
||||
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<DbRow | null> {
|
||||
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<CreateGeneratingResult> {
|
||||
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<string, unknown> = {
|
||||
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<PersonalReportRecord | null> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(requestId, "requestId");
|
||||
const row = await loadByRequest(userId, requestId);
|
||||
return recordFromRow(row);
|
||||
},
|
||||
|
||||
async getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(reportId, "reportId");
|
||||
const row = await loadRow(userId, reportId);
|
||||
return recordFromRow(row);
|
||||
},
|
||||
|
||||
async completeReady(
|
||||
userId: string,
|
||||
reportId: string,
|
||||
document: unknown,
|
||||
): Promise<PersonalReportRecord> {
|
||||
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<PersonalReportRecord> {
|
||||
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<boolean> {
|
||||
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<CreateGeneratingResult>;
|
||||
getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null>;
|
||||
getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null>;
|
||||
completeReady(userId: string, reportId: string, document: unknown): Promise<PersonalReportRecord>;
|
||||
markFailed(userId: string, reportId: string, failureCode: string): Promise<PersonalReportRecord>;
|
||||
deleteOwned(userId: string, reportId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export * from "./personal-report-service-core";
|
||||
@@ -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;
|
||||
@@ -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<string, unknown>).hometown = "上海";
|
||||
assert.equal(safeParseReportDocument(extra).ok, false);
|
||||
|
||||
const missing = clone();
|
||||
delete (missing as Partial<ReportDocumentV1>).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 = [
|
||||
"<script>alert(1)</script>",
|
||||
"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<string, unknown> = {};
|
||||
for (const key of Object.keys(fixture).reverse()) {
|
||||
reordered[key] = (fixture as Record<string, unknown>)[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");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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<string, unknown> & {
|
||||
id: string;
|
||||
user_id: string;
|
||||
request_id: string;
|
||||
request_fingerprint: string;
|
||||
};
|
||||
|
||||
type LoggedOperation = Readonly<{
|
||||
operation: "select" | "insert" | "update" | "delete";
|
||||
columns: readonly string[];
|
||||
values: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
class MemoryPersonalReportClient implements PersonalReportDataClient {
|
||||
rows = new Map<string, MemoryRow>();
|
||||
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<string, unknown>,
|
||||
filters: [] as { column: string; value: unknown }[],
|
||||
};
|
||||
}
|
||||
|
||||
private chain(state: ReturnType<MemoryPersonalReportClient["newState"]>): 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<string, unknown> = {}) => {
|
||||
state.operation = operation;
|
||||
state.values = values;
|
||||
return this.chain(state);
|
||||
};
|
||||
|
||||
return {
|
||||
select: () => this.chain(state),
|
||||
insert: (row) => wrap("insert", row as Record<string, unknown>),
|
||||
update: (values) => wrap("update", values as Record<string, unknown>),
|
||||
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<string, unknown> = {}) {
|
||||
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<string, unknown>) =>
|
||||
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");
|
||||
});
|
||||
@@ -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 <path-to-json>
|
||||
"""
|
||||
|
||||
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"</\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>"),
|
||||
("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 <report-document.json>", 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())
|
||||
+399
@@ -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 内容不代表确定性结论。"
|
||||
}
|
||||
@@ -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",
|
||||
[
|
||||
"<script>alert(1)</script>",
|
||||
"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
|
||||
Reference in New Issue
Block a user