From 685ed00e2f31d97c0bb49a0bad4024daecd494f5 Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 11:25:34 +0800 Subject: [PATCH 1/9] feat(sync): enforce one-way yinduzhanxing import policy --- ...yinduzhanxing_one_way_import_2026_08_06.md | 24 ++ .../import_manifest.schema.json | 66 ++++ .../sync_policy.v2.json | 52 +++ scripts/import_yinduzhanxing.py | 348 ++++++++++++++++++ tests/test_import_yinduzhanxing.py | 174 +++++++++ 5 files changed, 664 insertions(+) create mode 100644 docs/research/yinduzhanxing_one_way_import_2026_08_06.md create mode 100644 references/cross_project_contract/import_manifest.schema.json create mode 100644 references/cross_project_contract/sync_policy.v2.json create mode 100644 scripts/import_yinduzhanxing.py create mode 100644 tests/test_import_yinduzhanxing.py diff --git a/docs/research/yinduzhanxing_one_way_import_2026_08_06.md b/docs/research/yinduzhanxing_one_way_import_2026_08_06.md new file mode 100644 index 00000000..19c7bde9 --- /dev/null +++ b/docs/research/yinduzhanxing_one_way_import_2026_08_06.md @@ -0,0 +1,24 @@ +# Yinduzhanxing -> Jyotisha one-way import + +## Authority boundary + +- Direction: `732642856/yinduzhanxing` research source -> `root/Jyotisha` commercial target only. +- Reverse synchronization is forbidden and is not expressible by the v2 policy or importer CLI. +- The user-provided source at `../yinduzhanxing-main` is a snapshot without usable Git metadata. Imports from it must record `source_commit=unknown` and a deterministic `source_tree_hash`; they must not claim parity with a GitHub commit. +- Commercial frontend, identity, billing, database, deployment and commercial truth overlays remain protected. + +## Review flow + +```bash +.venv/bin/python scripts/import_yinduzhanxing.py \ + --source ../yinduzhanxing-main \ + --policy references/cross_project_contract/sync_policy.v2.json \ + --dry-run \ + --output artifacts/yinduzhanxing-import-plan.json +``` + +A reviewer must inspect semantic merge rows. Only then may the mirror allowlist be applied. `SKILL.md`, `AGENTS.md`, orchestrators and API entrypoints are semantic-merge paths and are never overwritten by the importer. + +## Rollback + +Revert the commercial import commit. Preserve the manifest for audit history. Never modify or push the research snapshot as part of rollback. diff --git a/references/cross_project_contract/import_manifest.schema.json b/references/cross_project_contract/import_manifest.schema.json new file mode 100644 index 00000000..eb6e0ca7 --- /dev/null +++ b/references/cross_project_contract/import_manifest.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://jyotisha.chat/contracts/yinduzhanxing-import-manifest.v1.schema.json", + "title": "Yinduzhanxing one-way import manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "source_repository", "source_commit", "source_tree_hash", + "source_mode", "target_repository", "target_base_commit", "policy_version", + "generated_at", "mirror_files", "semantic_merge_files", "protected_rejections", + "tests_run", "privacy_scan", "operator_review_required" + ], + "properties": { + "schema_version": {"const": 1}, + "source_repository": {"type": "string", "minLength": 1}, + "source_commit": {"type": "string", "pattern": "^(unknown|[0-9a-f]{40})$"}, + "source_tree_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "source_mode": {"enum": ["git", "snapshot"]}, + "target_repository": {"type": "string", "minLength": 1}, + "target_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "policy_version": {"const": 2}, + "generated_at": {"type": "string", "format": "date-time"}, + "mirror_files": {"type": "array", "items": {"$ref": "#/$defs/file"}}, + "semantic_merge_files": {"type": "array", "items": {"$ref": "#/$defs/semantic"}}, + "protected_rejections": {"type": "array", "items": {"type": "string"}}, + "tests_run": {"type": "array", "items": {"type": "string"}}, + "privacy_scan": { + "type": "object", + "additionalProperties": false, + "required": ["status", "scanned_files", "rejections"], + "properties": { + "status": {"enum": ["pass", "rejected"]}, + "scanned_files": {"type": "integer", "minimum": 0}, + "rejections": {"type": "array", "items": {"type": "string"}} + } + }, + "operator_review_required": {"type": "boolean"} + }, + "$defs": { + "file": { + "type": "object", + "additionalProperties": false, + "required": ["source", "target", "status", "source_sha256", "target_sha256_before", "target_sha256_after", "license"], + "properties": { + "source": {"type": "string"}, "target": {"type": "string"}, + "status": {"enum": ["new", "update", "unchanged", "applied"]}, + "source_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "target_sha256_before": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "target_sha256_after": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "license": {"type": "string"} + } + }, + "semantic": { + "type": "object", + "additionalProperties": false, + "required": ["path", "status", "source_sha256", "target_sha256", "diff_summary"], + "properties": { + "path": {"type": "string"}, + "status": {"enum": ["unchanged", "source_missing", "target_missing", "review_required"]}, + "source_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "target_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "diff_summary": {"type": "string"} + } + } + } +} diff --git a/references/cross_project_contract/sync_policy.v2.json b/references/cross_project_contract/sync_policy.v2.json new file mode 100644 index 00000000..6a6805bc --- /dev/null +++ b/references/cross_project_contract/sync_policy.v2.json @@ -0,0 +1,52 @@ +{ + "schema_version": 2, + "direction": "research_to_commercial_only", + "source_repository": "732642856/yinduzhanxing", + "target_repository": "root/Jyotisha", + "reverse_sync": "forbidden", + "modes": { + "mirror": [ + { + "source": "SKILL.md", + "target": "references/upstream/yinduzhanxing/SKILL.md", + "license": "MIT" + } + ], + "semantic_merge": [ + "SKILL.md", + "AGENTS.md", + "references/strict-workflow-router.md", + "scripts/unified_consultation_orchestrator.py", + "scripts/report_orchestrator.py", + "scripts/jyotish_api_server.py" + ], + "protected": [ + "frontend/**", + "deploy/**", + ".gitea/**", + ".github/**", + "frontend/db/**", + "frontend/supabase/**", + "references/oracle/commercial_skill_truth_overlay.v1.json", + "**/.env", + "**/.env.*", + "**/*payment*", + "**/*billing*", + "**/*subscription*", + "**/*entitlement*", + "**/*admin*", + "**/*service_role*" + ] + }, + "privacy": { + "forbidden_extensions": [".pem", ".key", ".p12", ".pfx", ".dump", ".sql.gz", ".pdf"], + "forbidden_basenames": [".env", "cookies.txt", "id_rsa", "id_ed25519"], + "content_markers": ["-----BEGIN PRIVATE KEY-----", "SUPABASE_SERVICE_ROLE_KEY=", "VEDASTRO_API_KEY=", "OPENAI_API_KEY=", "DATABASE_URL=postgres"] + }, + "notes": [ + "Only explicit mirror mappings may be copied byte-for-byte.", + "Semantic-merge paths are review inputs and are never overwritten by the importer.", + "Commercial product, identity, billing, database and deployment surfaces are protected.", + "There is no commercial-to-research mode or gate in schema v2." + ] +} diff --git a/scripts/import_yinduzhanxing.py b/scripts/import_yinduzhanxing.py new file mode 100644 index 00000000..e19e2d6e --- /dev/null +++ b/scripts/import_yinduzhanxing.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Safely import an explicit research allowlist into the commercial repository. + +The importer is deliberately one-way and offline. It never writes semantic-merge +or protected paths, and defaults to a dry run. A source without usable Git +metadata is recorded as an auditable snapshot with ``source_commit=unknown``. +""" + +from __future__ import annotations + +import argparse +import difflib +import fnmatch +import hashlib +import json +import os +import subprocess +import sys +import tempfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json" +EXIT_OK = 0 +EXIT_SEMANTIC_REVIEW = 2 +EXIT_POLICY_REJECTED = 3 +EXIT_VALIDATION_FAILED = 4 + + +class ImportRejected(ValueError): + """The requested import violates a direction, path, privacy or repository guard.""" + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def _safe_relative(value: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ImportRejected("empty_path") + candidate = PurePosixPath(value.replace("\\", "/")) + if candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts: + raise ImportRejected(f"unsafe_path:{value}") + normalized = candidate.as_posix() + if normalized.startswith("/") or "\x00" in normalized: + raise ImportRejected(f"unsafe_path:{value}") + return normalized + + +def _resolved_file(root: Path, relative: str, *, must_exist: bool) -> Path: + path = root / _safe_relative(relative) + root_resolved = root.resolve() + if must_exist and not path.exists(): + return path + resolved = path.resolve(strict=must_exist) + if resolved != root_resolved and root_resolved not in resolved.parents: + raise ImportRejected(f"symlink_escape:{relative}") + if must_exist and (not path.is_file() or path.is_symlink()): + raise ImportRejected(f"non_regular_or_symlink:{relative}") + return path + + +def _matches(path: str, patterns: list[str]) -> bool: + lowered = path.casefold() + return any(fnmatch.fnmatchcase(lowered, pattern.casefold()) for pattern in patterns) + + +def load_policy(path: Path) -> dict[str, Any]: + policy = json.loads(path.read_text(encoding="utf-8")) + if policy.get("schema_version") != 2: + raise ImportRejected("policy_schema_version_must_be_2") + if policy.get("direction") != "research_to_commercial_only": + raise ImportRejected("direction_must_be_research_to_commercial_only") + if policy.get("reverse_sync") != "forbidden": + raise ImportRejected("reverse_sync_must_be_forbidden") + if "commercial_to_research" in json.dumps(policy, sort_keys=True): + raise ImportRejected("reverse_direction_is_not_expressible") + modes = policy.get("modes") + if not isinstance(modes, dict) or set(modes) != {"mirror", "semantic_merge", "protected"}: + raise ImportRejected("policy_modes_invalid") + if not isinstance(modes["mirror"], list) or not isinstance(modes["semantic_merge"], list): + raise ImportRejected("policy_allowlists_invalid") + protected = [_safe_relative(value) if "*" not in value else value.replace("\\", "/") for value in modes["protected"]] + for mapping in modes["mirror"]: + if not isinstance(mapping, dict) or not {"source", "target", "license"} <= set(mapping): + raise ImportRejected("mirror_mapping_invalid") + _safe_relative(mapping["source"]) + target = _safe_relative(mapping["target"]) + if _matches(target, protected): + raise ImportRejected(f"protected_rejected:{target}") + for value in modes["semantic_merge"]: + _safe_relative(value) + return policy + + +def _git_output(root: Path, *args: str) -> str | None: + try: + return subprocess.check_output( + ["git", "-C", str(root), *args], stderr=subprocess.DEVNULL, text=True, timeout=8 + ).strip() + except (OSError, subprocess.SubprocessError): + return None + + +def source_identity(source: Path) -> tuple[str, str]: + inside = _git_output(source, "rev-parse", "--show-toplevel") + commit = _git_output(source, "rev-parse", "HEAD") if inside and Path(inside).resolve() == source.resolve() else None + if commit and len(commit) == 40 and all(ch in "0123456789abcdef" for ch in commit.lower()): + return "git", commit.lower() + return "snapshot", "unknown" + + +def _normalized_remote(root: Path) -> str | None: + remote = _git_output(root, "remote", "get-url", "origin") + if not remote: + return None + value = remote.strip().lower().removesuffix(".git").replace("git@", "ssh://git@") + return value.rstrip("/") + + +def validate_roots(source: Path, target: Path) -> None: + source = source.expanduser().resolve(strict=True) + target = target.expanduser().resolve(strict=True) + if not source.is_dir() or not target.is_dir(): + raise ImportRejected("source_and_target_must_be_directories") + if source == target or source in target.parents or target in source.parents: + raise ImportRejected("source_and_target_must_not_overlap") + source_remote = _normalized_remote(source) + target_remote = _normalized_remote(target) + if source_remote and target_remote and source_remote == target_remote: + raise ImportRejected("source_and_target_git_remote_must_differ") + + +def source_tree_hash(source: Path) -> str: + digest = hashlib.sha256() + for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()): + relative = path.relative_to(source).as_posix() + if ".git" in PurePosixPath(relative).parts or path.is_symlink() or not path.is_file(): + continue + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(bytes.fromhex(sha256_file(path))) + digest.update(b"\0") + return digest.hexdigest() + + +def _privacy_rejection(path: Path, relative: str, privacy: dict[str, Any]) -> str | None: + lowered = relative.casefold() + basenames = {str(value).casefold() for value in privacy.get("forbidden_basenames", [])} + extensions = [str(value).casefold() for value in privacy.get("forbidden_extensions", [])] + if path.name.casefold() in basenames or any(lowered.endswith(ext) for ext in extensions): + return f"sensitive_filename:{relative}" + data = path.read_bytes() + if b"\x00" in data[:8192]: + return f"binary_content_rejected:{relative}" + text = data.decode("utf-8", errors="replace") + for marker in privacy.get("content_markers", []): + if str(marker).casefold() in text.casefold(): + return f"sensitive_content:{relative}" + return None + + +def _diff_summary(source: Path | None, target: Path | None) -> str: + if source is None: + return "source missing" + if target is None: + return "target missing; manual semantic merge required" + try: + before = target.read_text(encoding="utf-8").splitlines() + after = source.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + return "binary difference; manual semantic merge required" + added = removed = 0 + for line in difflib.ndiff(before, after): + added += line.startswith("+ ") + removed += line.startswith("- ") + return f"manual semantic merge required: +{added}/-{removed} lines" + + +def build_manifest( + source: Path, + *, + policy_path: Path = DEFAULT_POLICY, + target: Path = ROOT, + apply: bool = False, +) -> dict[str, Any]: + source = source.expanduser().resolve(strict=True) + target = target.expanduser().resolve(strict=True) + validate_roots(source, target) + policy = load_policy(policy_path) + protected = list(policy["modes"]["protected"]) + privacy = policy.get("privacy") if isinstance(policy.get("privacy"), dict) else {} + source_mode, source_commit = source_identity(source) + target_commit = _git_output(target, "rev-parse", "HEAD") + if not target_commit or len(target_commit) != 40: + raise ImportRejected("target_base_commit_unavailable") + + mirror_files: list[dict[str, Any]] = [] + privacy_rejections: list[str] = [] + pending_writes: list[tuple[Path, bytes, dict[str, Any]]] = [] + for mapping in policy["modes"]["mirror"]: + source_rel = _safe_relative(mapping["source"]) + target_rel = _safe_relative(mapping["target"]) + if _matches(target_rel, protected): + raise ImportRejected(f"protected_rejected:{target_rel}") + source_file = _resolved_file(source, source_rel, must_exist=True) + if not source_file.exists(): + raise ImportRejected(f"mirror_source_missing:{source_rel}") + target_file = _resolved_file(target, target_rel, must_exist=False) + rejection = _privacy_rejection(source_file, source_rel, privacy) + if rejection: + privacy_rejections.append(rejection) + continue + data = source_file.read_bytes() + source_hash = sha256_bytes(data) + before_hash = sha256_file(target_file) if target_file.exists() and target_file.is_file() else None + status = "unchanged" if source_hash == before_hash else "update" if before_hash else "new" + row = { + "source": source_rel, + "target": target_rel, + "status": status, + "source_sha256": source_hash, + "target_sha256_before": before_hash, + "target_sha256_after": source_hash, + "license": str(mapping["license"]), + } + mirror_files.append(row) + if status != "unchanged": + pending_writes.append((target_file, data, row)) + + if privacy_rejections: + raise ImportRejected(";".join(privacy_rejections)) + + semantic_files: list[dict[str, Any]] = [] + for relative_value in policy["modes"]["semantic_merge"]: + relative = _safe_relative(relative_value) + source_file = _resolved_file(source, relative, must_exist=False) + target_file = _resolved_file(target, relative, must_exist=False) + source_exists = source_file.exists() and source_file.is_file() and not source_file.is_symlink() + target_exists = target_file.exists() and target_file.is_file() and not target_file.is_symlink() + source_hash = sha256_file(source_file) if source_exists else None + target_hash = sha256_file(target_file) if target_exists else None + if not source_exists: + status = "source_missing" + elif not target_exists: + status = "target_missing" + elif source_hash == target_hash: + status = "unchanged" + else: + status = "review_required" + semantic_files.append({ + "path": relative, + "status": status, + "source_sha256": source_hash, + "target_sha256": target_hash, + "diff_summary": _diff_summary(source_file if source_exists else None, target_file if target_exists else None), + }) + + if apply and pending_writes: + backups: list[tuple[Path, bytes | None]] = [] + try: + for destination, data, row in pending_writes: + destination.parent.mkdir(parents=True, exist_ok=True) + backups.append((destination, destination.read_bytes() if destination.exists() else None)) + fd, temp_name = tempfile.mkstemp(prefix=f".{destination.name}.", dir=destination.parent) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temp_name, 0o644) + os.replace(temp_name, destination) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + row["status"] = "applied" + except Exception: + for destination, previous in reversed(backups): + if previous is None: + destination.unlink(missing_ok=True) + else: + destination.write_bytes(previous) + raise + + return { + "schema_version": 1, + "source_repository": policy["source_repository"], + "source_commit": source_commit, + "source_tree_hash": source_tree_hash(source), + "source_mode": source_mode, + "target_repository": policy["target_repository"], + "target_base_commit": target_commit.lower(), + "policy_version": 2, + "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "mirror_files": mirror_files, + "semantic_merge_files": semantic_files, + "protected_rejections": [], + "tests_run": [], + "privacy_scan": {"status": "pass", "scanned_files": len(mirror_files), "rejections": []}, + "operator_review_required": any(row["status"] in {"review_required", "target_missing"} for row in semantic_files), + } + + +def write_json_atomic(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", action="store_true") + mode.add_argument("--apply", action="store_true") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + manifest = build_manifest(args.source, policy_path=args.policy, apply=args.apply) + write_json_atomic(args.output, manifest) + except ImportRejected as error: + print(json.dumps({"status": "rejected", "reason": str(error)}, ensure_ascii=False), file=sys.stderr) + return EXIT_POLICY_REJECTED + except (OSError, ValueError, json.JSONDecodeError) as error: + print(json.dumps({"status": "invalid", "reason": str(error)}, ensure_ascii=False), file=sys.stderr) + return EXIT_VALIDATION_FAILED + print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)) + return EXIT_SEMANTIC_REVIEW if manifest["operator_review_required"] else EXIT_OK + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_import_yinduzhanxing.py b/tests/test_import_yinduzhanxing.py new file mode 100644 index 00000000..b0dd0c3d --- /dev/null +++ b/tests/test_import_yinduzhanxing.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import import_yinduzhanxing as importer # noqa: E402 + +POLICY = ROOT / "references/cross_project_contract/sync_policy.v2.json" + + +def _git(root: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def _target(tmp_path: Path) -> Path: + target = tmp_path / "commercial" + target.mkdir() + subprocess.run(["git", "init", "-q", str(target)], check=True) + subprocess.run(["git", "-C", str(target), "config", "user.email", "tests@example.invalid"], check=True) + subprocess.run(["git", "-C", str(target), "config", "user.name", "Tests"], check=True) + (target / "README.md").write_text("commercial\n", encoding="utf-8") + subprocess.run(["git", "-C", str(target), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(target), "commit", "-qm", "base"], check=True) + return target + + +def _source(tmp_path: Path) -> Path: + source = tmp_path / "snapshot" + (source / "scripts").mkdir(parents=True) + (source / "references").mkdir() + (source / "SKILL.md").write_text("# Research skill\n", encoding="utf-8") + (source / "AGENTS.md").write_text("research agent\n", encoding="utf-8") + (source / "references/strict-workflow-router.md").write_text("router\n", encoding="utf-8") + (source / "scripts/unified_consultation_orchestrator.py").write_text("# source\n", encoding="utf-8") + (source / "scripts/report_orchestrator.py").write_text("# report\n", encoding="utf-8") + (source / "scripts/jyotish_api_server.py").write_text("# api\n", encoding="utf-8") + return source + + +def _policy(tmp_path: Path, *, mirror: list[dict[str, str]] | None = None) -> Path: + value = json.loads(POLICY.read_text(encoding="utf-8")) + if mirror is not None: + value["modes"]["mirror"] = mirror + path = tmp_path / "policy.json" + path.write_text(json.dumps(value), encoding="utf-8") + return path + + +def test_v2_policy_is_strictly_one_way_and_has_no_reverse_gate() -> None: + policy = importer.load_policy(POLICY) + assert policy["direction"] == "research_to_commercial_only" + assert policy["reverse_sync"] == "forbidden" + assert "commercial_to_research" not in json.dumps(policy, sort_keys=True) + assert policy["modes"]["protected"] + + +def test_dry_run_writes_no_target_file_and_snapshot_commit_is_unknown(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + before = _git(target, "status", "--porcelain=v1") + manifest = importer.build_manifest(source, policy_path=POLICY, target=target) + after = _git(target, "status", "--porcelain=v1") + + assert before == after == "" + assert manifest["source_mode"] == "snapshot" + assert manifest["source_commit"] == "unknown" + assert manifest["mirror_files"][0]["status"] == "new" + assert not (target / "references/upstream/yinduzhanxing/SKILL.md").exists() + + +def test_apply_handles_new_update_and_unchanged_with_recomputable_hashes(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + destination = target / "references/upstream/yinduzhanxing/SKILL.md" + + first = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True) + assert first["mirror_files"][0]["status"] == "applied" + assert destination.read_bytes() == (source / "SKILL.md").read_bytes() + + unchanged = importer.build_manifest(source, policy_path=POLICY, target=target) + assert unchanged["mirror_files"][0]["status"] == "unchanged" + assert unchanged["mirror_files"][0]["source_sha256"] == hashlib.sha256(destination.read_bytes()).hexdigest() + + (source / "SKILL.md").write_text("# Updated research skill\n", encoding="utf-8") + update = importer.build_manifest(source, policy_path=POLICY, target=target) + assert update["mirror_files"][0]["status"] == "update" + assert update["mirror_files"][0]["target_sha256_before"] != update["mirror_files"][0]["target_sha256_after"] + + +def test_semantic_files_never_overwrite_target(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + (target / "SKILL.md").write_text("commercial skill\n", encoding="utf-8") + before = (target / "SKILL.md").read_bytes() + + manifest = importer.build_manifest(source, policy_path=POLICY, target=target, apply=True) + + assert (target / "SKILL.md").read_bytes() == before + row = next(item for item in manifest["semantic_merge_files"] if item["path"] == "SKILL.md") + assert row["status"] == "review_required" + assert manifest["operator_review_required"] is True + + +def test_protected_mapping_is_rejected_instead_of_silently_skipped(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": "frontend/src/stolen.ts", "license": "MIT"}]) + with pytest.raises(importer.ImportRejected, match="protected_rejected"): + importer.build_manifest(source, policy_path=policy, target=target, apply=True) + assert not (target / "frontend/src/stolen.ts").exists() + + +@pytest.mark.parametrize("path", ["../escape", "/absolute/path", "safe/../../escape"]) +def test_path_traversal_and_absolute_paths_are_rejected(tmp_path: Path, path: str) -> None: + source, target = _source(tmp_path), _target(tmp_path) + policy = _policy(tmp_path, mirror=[{"source": "SKILL.md", "target": path, "license": "MIT"}]) + with pytest.raises(importer.ImportRejected, match="unsafe_path"): + importer.build_manifest(source, policy_path=policy, target=target) + + +def test_symlink_escape_is_rejected(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + outside = tmp_path / "outside.md" + outside.write_text("outside\n", encoding="utf-8") + (source / "linked.md").symlink_to(outside) + policy = _policy(tmp_path, mirror=[{"source": "linked.md", "target": "references/upstream/linked.md", "license": "MIT"}]) + with pytest.raises(importer.ImportRejected, match="symlink_escape|non_regular_or_symlink"): + importer.build_manifest(source, policy_path=policy, target=target) + + +def test_sensitive_source_is_rejected(tmp_path: Path) -> None: + source, target = _source(tmp_path), _target(tmp_path) + (source / ".env").write_text("OPENAI_API_KEY=not-a-real-key\n", encoding="utf-8") + policy = _policy(tmp_path, mirror=[{"source": ".env", "target": "references/upstream/env.txt", "license": "MIT"}]) + with pytest.raises(importer.ImportRejected, match="sensitive_filename|sensitive_content"): + importer.build_manifest(source, policy_path=policy, target=target) + + +def test_overlapping_source_and_target_are_rejected(tmp_path: Path) -> None: + target = _target(tmp_path) + source = target / "source" + source.mkdir() + with pytest.raises(importer.ImportRejected, match="must_not_overlap"): + importer.build_manifest(source, policy_path=POLICY, target=target) + + +def test_apply_rolls_back_earlier_files_when_later_replace_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + source, target = _source(tmp_path), _target(tmp_path) + (source / "second.md").write_text("second\n", encoding="utf-8") + policy = _policy(tmp_path, mirror=[ + {"source": "SKILL.md", "target": "references/upstream/one.md", "license": "MIT"}, + {"source": "second.md", "target": "references/upstream/two.md", "license": "MIT"}, + ]) + original_replace = importer.os.replace + calls = 0 + + def fail_second(source_path: str, target_path: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("simulated replace failure") + original_replace(source_path, target_path) + + monkeypatch.setattr(importer.os, "replace", fail_second) + with pytest.raises(OSError, match="simulated"): + importer.build_manifest(source, policy_path=policy, target=target, apply=True) + assert not (target / "references/upstream/one.md").exists() + assert not (target / "references/upstream/two.md").exists() -- 2.54.0 From 03d3b58ff75d32da73db77ff979b0f9b7e06594d Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 11:31:51 +0800 Subject: [PATCH 2/9] feat(skill): merge upstream reader-report contract --- SKILL.md | 11 + .../imports/snapshot-9034e1967032d09c.json | 78 ++ references/upstream/yinduzhanxing/SKILL.md | 733 ++++++++++++++++++ .../yinduzhanxing/source-manifest.json | 11 + scripts/report_orchestrator.py | 36 + scripts/unified_consultation_orchestrator.py | 195 ++++- ...est_report_orchestrator_reader_contract.py | 53 ++ .../test_unified_consultation_orchestrator.py | 48 +- 8 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 references/cross_project_contract/imports/snapshot-9034e1967032d09c.json create mode 100644 references/upstream/yinduzhanxing/SKILL.md create mode 100644 references/upstream/yinduzhanxing/source-manifest.json create mode 100644 tests/test_report_orchestrator_reader_contract.py diff --git a/SKILL.md b/SKILL.md index b602dc71..a4a8d1c2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -6,6 +6,17 @@ description: "印度占星(Jyotish)商业解盘与推运系统。核心能 # 印度占星专业解盘与推运系统 +## 商业运行时路由(最高优先级) + +本文件是 Mastra 实际加载的商业 Skill 入口,**不是上游研究 Skill 的镜像**。执行时按以下顺序渐进读取: + +1. `references/upstream/yinduzhanxing/SKILL.md`:只读研究快照;来源身份与哈希见同目录 `source-manifest.json`。 +2. `references/strict-workflow-router.md`:问题域与严格技法路由。 +3. `references/oracle/commercial_skill_truth_overlay.v1.json`:商业声明和受限技法的最终覆盖层。 +4. 服务端 `consumer_context.answer_policy`:当前请求可回答范围的最终合同。 + +若研究快照、商业覆盖层和服务端回执冲突,以商业覆盖层和服务端回执为准。真实计算只能来自服务端工具,模型不得重算或发明行星位置。候选出生时间不得写成 confirmed;`blocked`、参数敏感、外部验证未闭环和多体系冲突必须原样保留。正式个人报告固定按 `executive_summary -> thematic_narrative -> evidence_appendix` 排列,Technique Audit Table 位于附录,不得置于摘要之前。医疗、法律、投资、安全关键结论及确定性死亡/诊断/妊娠预测均禁止。 + > **版本**:v6.9.14 | **详细变更**:`CHANGELOG.md` > **对标状态**:中文用户端与技法覆盖领先;D1/D9/AV/Chara 等有守门,Dasha/Shadbala 外部 oracle 扩充仍在进行。 > diff --git a/references/cross_project_contract/imports/snapshot-9034e1967032d09c.json b/references/cross_project_contract/imports/snapshot-9034e1967032d09c.json new file mode 100644 index 00000000..13dbdd54 --- /dev/null +++ b/references/cross_project_contract/imports/snapshot-9034e1967032d09c.json @@ -0,0 +1,78 @@ +{ + "generated_at": "2026-08-06T03:26:00.577384Z", + "mirror_files": [ + { + "license": "MIT", + "source": "SKILL.md", + "source_sha256": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb", + "status": "applied", + "target": "references/upstream/yinduzhanxing/SKILL.md", + "target_sha256_after": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb", + "target_sha256_before": null + } + ], + "operator_review_required": true, + "policy_version": 2, + "privacy_scan": { + "rejections": [], + "scanned_files": 1, + "status": "pass" + }, + "protected_rejections": [], + "schema_version": 1, + "semantic_merge_files": [ + { + "diff_summary": "manual semantic merge required: +41/-20 lines", + "path": "SKILL.md", + "source_sha256": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb", + "status": "review_required", + "target_sha256": "9eb4dc62d3c4a7399c8b64deb89706dc6f42521f00eb78baf1686547ef97ff85" + }, + { + "diff_summary": "manual semantic merge required: +65/-52 lines", + "path": "AGENTS.md", + "source_sha256": "a1ff71472c6312000b8aef3f099d5499c0b27d1fd4f61594ef06a56c48533dd2", + "status": "review_required", + "target_sha256": "37fbd9fa4102483d39aa0d09d0d18d00f30ae9f05351328d8d12116a2f71b76f" + }, + { + "diff_summary": "manual semantic merge required: +13/-1 lines", + "path": "references/strict-workflow-router.md", + "source_sha256": "afb83e0b1b290166cf7ecdcabcae8221d94b14138abc223e9cf57d27596f82d4", + "status": "review_required", + "target_sha256": "2dbab0179c6001c3c8b51e425af323f214e5e912afe7840821f6c38c3546c905" + }, + { + "diff_summary": "manual semantic merge required: +308/-116 lines", + "path": "scripts/unified_consultation_orchestrator.py", + "source_sha256": "f5acb3f5a675e84f560ac5264972ae1fe16cd216d858a6c5e5dd8d47061448e7", + "status": "review_required", + "target_sha256": "3ec50993dad0bd6f3ee74342407a6f0a149a8ecb27390fb9f6c1b4d4e97bd7f0" + }, + { + "diff_summary": "manual semantic merge required: +27/-0 lines", + "path": "scripts/report_orchestrator.py", + "source_sha256": "cd67d23cf9df68d200167ac25af8473bfabb1a344a5f0a1e1254de14ba426daf", + "status": "review_required", + "target_sha256": "e6a668608d0540dbc79797e8eebbbb6e4c96cb7b70c4c020317162201f274aa7" + }, + { + "diff_summary": "manual semantic merge required: +1242/-1521 lines", + "path": "scripts/jyotish_api_server.py", + "source_sha256": "b60ceecd914ece04dca8eb5b254983be81ce62536ad60922a40d4f27d86ea629", + "status": "review_required", + "target_sha256": "7b58a9d9cc82f6f50d48bbf51c70691a9d014ead2f77e9d8d507a43b41222af7" + } + ], + "source_commit": "unknown", + "source_mode": "snapshot", + "source_repository": "732642856/yinduzhanxing", + "source_tree_hash": "9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb", + "target_base_commit": "685ed00e2f31d97c0bb49a0bad4024daecd494f5", + "target_repository": "root/Jyotisha", + "tests_run": [ + "tests/test_cross_project_contract.py", + "tests/test_cross_project_sync_status.py", + "tests/test_import_yinduzhanxing.py" + ] +} diff --git a/references/upstream/yinduzhanxing/SKILL.md b/references/upstream/yinduzhanxing/SKILL.md new file mode 100644 index 00000000..822f06c7 --- /dev/null +++ b/references/upstream/yinduzhanxing/SKILL.md @@ -0,0 +1,733 @@ +--- +name: jyotish-vedic-astrology +version: 6.9.14 +description: 印度占星(Jyotish)专业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→推运候选输出。35种Dasha、405+Yoga规则、Prashna卜卦、16因子合盘、Remedies补救、Sudarshana三参考点、PMC完整检测、案例验证+误区纠正;KP/Muhurta/Gochara/Sahams/Sphuta/Tajika等高阶分支必须按 skill truth overlay 降级使用。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga。GitHub: https://github.com/732642856/yinduzhanxing +--- + +# 印度占星专业解盘与推运系统 + +> **版本**:v6.9.14 | **详细变更**:`CHANGELOG.md` +> **对标状态**:中文用户端与技法覆盖领先;D1/D9/AV/Chara 等有守门,Dasha/Shadbala 外部 oracle 扩充仍在进行。 +> +> **真相边界**:当前问题已不是“完全缺技法名称”,而是少数高价值传统深度仍未闭环;请优先修复精度与裁决链,而不是继续表面堆功能名。读取能力状态时必须优先使用 `references/oracle/effective_skill_capability_view_2026_07_19.json` 与 `references/oracle/skill_truth_overlay_2026_07_19.json`,不得直接把 `references/technique_registry.json` 的旧 `covered` 当作完整闭环。 +> **执行总控**:`references/quick-reference-guide.md` +> **严格路由**:`references/strict-workflow-router.md`(涉及事业/婚恋/财务/应期/技法验证时必须优先读取) +> **机器注册表**:`references/oracle/effective_skill_capability_view_2026_07_19.json` + `references/oracle/skill_truth_overlay_2026_07_19.json` + `references/technique_registry.json` + `scripts/audit_capabilities.py` +> **文章级细节模板**:`references/interpretation_template_registry.json` + `scripts/validate_interpretation_templates.py` + +## v6.9.14 核心能力 + +| 维度 | 数据 | +|------|:--:| +| Dasha系统 | 35种(含Vimshottari/Chara/Kalachakra/Narayana/Yogini等;成熟度不完全一致,以 registry 边界说明为准) | +| Yoga规则 | 405+条(BPHS数据驱动架构,Yoga精度Benchmark 100%) | +| 分盘 | D1-D144 + D2/D3变体 + 复合D-m×n + 自定义D-N(2-300) | +| Bhava Chalit | Sripati/Porphyry/Equal/Whole Sign/Placidus/Koch 不等宫位调整 | +| Sudarshana | Asc/Moon/Sun 三参考点盘 + 宫位收敛分析 | +| Shadbala | absolute Rupa 分量求和;内部不变量通过,外部绝对值 oracle 扩充中 | +| Ashtakavarga | BAV+SAV+PAV(展开式)+Sodhita(净化式) | +| KP系统 | reference-only / partial;不得按完整主链能力宣称,须以 `skill_truth_overlay` 为准 | +| 合盘 | 16因子36分制(Ashtakoot+Kuta) | +| 补救 | 5类(宝石/咒语/捐赠/斋戒/Dosha专项) | +| 自动化测试 | pytest/quality gate 分层守门;以当前仓库质量门输出为准 | +| Git commits | v6.1.12→v6.9.14 持续推进 | + +**独有能力**:中文AI解读引擎、Career/Love结构化分析、验前事反推管道、误区自动纠正、名人+普通人案例双轨验证。 + +## 关联技法完整调取 + +用户的每个解读或推运问题都必须先建立“问题 -> 关联技法”映射,并真实调用可运行的全部关联技法,不得因篇幅、成本或实现方便静默省略。输出须先展示统一参数与原始结构,再并列各体系结果、冲突与证据状态,最后给出条件性综合推理。 + +- 共同基础:D1、功能性吉凶星、相关 Yoga、Dasha、行运、Shadbala、Ashtakavarga 与计算 profile。 +- 领域关联:事业至少 `D10 + A10`,财富至少 `D2 / D11`,婚恋至少 `D9 + UL`;综合问题或用户明确要求全量分盘时,列出 D1-D60,其中 20 张正式传统分盘与 40 张研究型通用 D-N 运算分别说明。 +- 特殊体系:KP、Tajika、Saham、Gulika/Maandi、Panchanga、Muhurta、Prashna、Sphuta、合婚等,按问题关联逐项调用或解释 `blocked / not_applicable` 原因。 +- Technique Audit Table 对每项关联技法使用 `Used / blocked / not_applicable`,保留输入、原始结构、冲突和结论影响。 +- 未闭环技法仍完整展示其原始结构、条件和限制,但不得把未闭环技法写成确定事件,也不得用多数引擎投票替代真值仲裁。 + +## Yoga 逻辑验证指标 + +| 指标 | v6.0.45(旧基线) | v6.9.14(当前) | +|---|---:|---:| +| Precision | 83.26% | **96.48%** | +| Recall | 91.52% | **93.99%** | +| **F1 Score** | 87.19% | **95.22%** | +| 规则库 | 82条 | **405条** | +| Yoga精度Benchmark | — | **100%** (8/8) | + +--- + +## ⚠️ 核心定位 + +**三种输入 → 严谨解盘 → 精确推运应期输出** + +| 路径 | 用户输入 | AI行为 | +|------|---------|--------| +| **A:精准出生信息** | 日期+时间+地点 | `full-reading` 引擎全链路计算 | +| **B:PDF/文字星盘** | PDF/详细文字描述 | 提取数据+Quality Gate → `references/pdf-chart-reading-guide.md` | +| **C:时间不明确** | "不知道几点出生" | 互动式出生时间矫正 → 确认后走路径A | + +### 用户不会提问时的默认行为 + +用户只给出生信息、没有具体问题时,不要反问“你想看什么”,也不要只输出模板解读。 +默认先运行统一主链生成 `evidence_packet`、`guided_topics` 与 `Technique Audit Table`,再把 `guided_topics` +按优先级展示为可直接选择的问题。 + +执行顺序: + +1. MCP/Skill 环境优先调用 `strict_workflow` 或统一 consultation workflow。 +2. `question` 可先填:`请先生成 guided_topics 并推荐我最值得看的问题`。 +3. 输出 3-5 个系统建议主题,每个主题必须带:数据依据、置信度、blocked/partial 项、可直接继续问的问题。 +4. 用户选择主题后,再按 career / relationship / wealth / health / timing strict workflow 进入专题。 +5. VedAstro 没有 `raw_response` 时,只能标 `official_blocked` 或 `local_fallback`,不得声称云端闭环。 + +普通用户 / AI 应用调用前,先运行: + +```bash +python3 scripts/user_invocation_acceptance_check.py +``` + +该命令必须返回 `"status": "pass"`,并显式列出 VedAstro / PyJHora-JHora / jyotishganit 的可用、partial 或 blocked 状态;否则不得声称云端 Git 仓库调用已可高质量使用。 + +### 首次调用与降级合同 + +普通用户不必先理解 API、MCP、分盘或校时方法。Skill/MCP 首次调用必须先使用 +`skill_onboarding`:缺出生字段时只收集日期、时间、经纬度;出生时间有误差时返回 +`rectification` 的选择题问卷;时间明确时进入 `direct_chart`。不得要求用户先提交长篇 +人生事件表。 + +安装或运行异常时调用 `skill_doctor`。它只报告本地资产与外部适配器 readiness,不得把 +adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit raw-oracle 校验。 + +每个工作流结果必须包含 `execution_status`: + +- `official_verified`:仅此状态可说 VedAstro 官方 raw evidence 已被使用; +- `official_blocked`:官方请求失败、额度/网络/超时受阻; +- `local_fallback`:本地计算继续可用,但不能称为官方云端闭环。 + +### Web/API 任务存储 + +默认 `JYOTISH_ASYNC_JOB_BACKEND=file` 使用本机受限权限的临时任务文件。单机部署可设 +`JYOTISH_ASYNC_JOB_BACKEND=sqlite`,使用 `scratch/local/async_jobs.sqlite3` 保存 token-hash +与 TTL 任务记录。两种后端都不是 Redis、多节点队列或跨主机 worker;不得把它们描述为分布式恢复能力。 + +**强制工作流**(完整规范 → `references/ai-reading-workflow-prompt.md` v5.1.0): + +0. **阶段负一**:问题类型路由(事业/婚恋/财务/应期/历史验证/综合解盘)→ 必须先读 `references/strict-workflow-router.md`,按对应 strict checklist 执行;用户不需要主动点名高级技法。 +0.0.1 **全谱系真实调用**:事业、财富、年度推运、事件应期、校时或综合解盘,必须按 `strict-workflow-router.md` 的 `Full-Spectrum Invocation Contract` 实际运行全部可用且问题相关的印度与西方技法。不得把“仓库存在”“UI可见”或“默认未传参数”伪装成已调用;每一项必须输出 `executed`、`blocked` 或 `not_applicable`,保留原始数据、分盘、dasha、推运窗口及差异。限制只作用于确定性措辞,不得削减信息密度。 +0.1 **事件判定骨架**:凡涉及 marriage / career / wealth / event verify,必须执行 `事件判定骨架 v1.0`,按 `Route -> Evidence Ledger -> Adjudication -> Output Contract` 顺序输出;不得再凭直觉跳模块或随口给置信度。详见 `references/ai-reading-workflow-prompt.md`、`references/event_judgment_skeleton.md`、`references/event_judgment_marriage.md` 与 `references/event_judgment_examples.md`。 +1. **阶段零**:入口路由(A/B/C自动判断) +2. **阶段一**(仅B):PDF/图片提取 + Quality Gate +3. **阶段二**:意图识别 → 路由目标宫位(无明确意图→Level 2综合解盘) +4. **阶段二点五**:若 `full-reading` 或网页/API 返回 `ai_prompt_pack`,必须优先读取 `prompt_zh`、`evidence_snapshot`、`retrieval_plan` 作为 AI/RAG 主上下文;若没有该字段,再退回传统 JSON 摘要。 +4.1 **VedAstro 官方优先级**:用户给出生信息后,网页、Skill、MCP 都必须默认走同一条数据优先级:`VedAstro official snapshot -> local supplemental modules -> local fallback only when official blocked`。用户不需要主动要求“调用 VedAstro”。若 `evidence_snapshot.vedastro_official_full_snapshot.status` 为 `ok/partial` 且官方 chart 可用,D1/分盘/官方返回的原始字段以 VedAstro 为主;本地引擎只做补充、交叉检查或官方 blocked 时 fallback。 +4. **阶段三**:静态分析10步(宫位→承诺→Yoga→Argala→逆行→NK→Shadbala→AV→Ketu→分盘) +5. **阶段四**:动态推运7步(Dasha→五系统Convergence→Transit→Double Transit→Jaimini→KP→Varshaphala) +6. **阶段五**:应期输出(五层验证→时间窗口→Actionable Output+案例检索) +7. **阶段六**:补救措施(可选) +8. **阶段七**:现代措辞包装 +9. **阶段八**:输出 Technique Audit Table,逐项声明已调用/未调用/部分可用/缺失模块及其对置信度的影响。 + +### P0/P1 技法调用回填(v6.9.17-skill-invocation-backfill) + +以下技法已经在 `full_technique_invocation_matrix_2026_07_22.json` 中确认存在资料、代码或测试,但历史调用链容易漏挂。Skill、MCP、API 和网页结果必须显式显示其调用状态与 claim boundary;没有外部 oracle 的项目只能作为 observation/factor/component,不得升格为 truth。 + +Technique Audit Table 必须新增或保留这些行: + +- `A7 / UL / A10 / KP Boundary`:事业看 A10,婚恋看 UL/A7,生时校正看 KP exact cusp/star-sub/sub-sub;当前三引擎外部一致性未闭环,只能作为候选/观察层。 +- `Gulika / Mandi Boundary`:Prashna、timing、rectification 可展示 Gulika/Mandi;缺独立 numeric oracle 时不得作为最终断事证据。 +- `Muhurta Factor Boundary`:Tarabala、Chandrabala、Rahu Kalam、Abhijit、Yamaganda、Gulika Kalam、Panchaka、Sankranti、Vyatipata、Vaidhriti 只作为 factor scoring/observation;不得输出最终择日 verdict。 +- `Shadbala Component Status`:必须显示 Chesta/Sthana/Dig/Drik/Kala/Naisargika 的 component-level 状态;Chesta/Sthana 等仍按 formula/unit/method_variant 仲裁,不得说绝对 Virupa truth 已闭环。 +- `Ashtakavarga Kakshya Boundary`:Kakshya transit 可作为 AV 高阶 timing observation;未通过公开 worked examples 与负样本 holdout 前不得用于 verified day/month timing。 +- `Ashtakoota UI Boundary`:合盘/婚配入口必须能显示 Ashtakoota/Guna Milan 的 UI 入口与边界;它只能辅助 D9/UL/DK/relationship judgement,不能单独裁决关系。 +- `Adhana / Niseka Research Boundary`:Adhana/Niseka conception chart 只保留 research-only registry;不得面向普通用户输出生育、性别或确定性受孕断语。 + +执行规则: + +1. career / relationship / finance / timing / rectification 路由必须读取 `domain_invocation_contract` 或 `domain_invocation_contracts`。 +2. UI 可以先展示边界与状态,不必一次性做深功能页。 +3. 若某项 evidence key 不存在,显示 `not_executed` 或 `observation_only`,不得静默省略。 +4. 商业同步只能同步 observation/boundary contract;不得把这些行包装成已验证预测能力。 + +### ⚙️ 事件判定骨架(总入口) + +涉及 `marriage / career / wealth / health / generic event verification` 的问题,不得只按关键词随意调模块,必须进入事件判定骨架。 + +总骨架固定为四段: + +1. `Route` + - 先判断 **问题域**(婚恋 / 事业 / 财富 / 健康 / 泛事件) + - 再判断 **任务类型**(预测 / 回测 / 校时辅助 / 多方案裁决) + - 再判断 **目标粒度**(趋势 / 窗口 / 月份 / 具体事件验证) +2. `Evidence Ledger` + - 每个模块都要落成结构化证据块,不得只写散文式描述 +3. `Adjudication` + - 必须按 `Promise -> Activation -> Manifestation -> Timing` 裁决 +4. `Output Contract` + - 最终只允许输出 `verdict + confidence + conflicts + audit + raw evidence` + +硬规则: + +- timing / event 不得只看 `Vimshottari`,必须 `Vimshottari + Narayana` +- 事业必须 `D10 + A10` +- 财富必须 `D2 / D11` +- 婚恋必须 `D9 + UL` +- 必须显式给出 `Functional Benefic/Malefic` +- 缺少关键层时必须 `blocked` 或降置信度 +- 必须交付原始依据:度数、Dasha 边界、Shadbala、AV、Ayanamsa、Node mode、模板/案例引用 + +详细执行文档: + +- [`references/event_judgment_skeleton.md`](/references/event_judgment_skeleton.md) +- [`references/event_judgment_marriage.md`](/references/event_judgment_marriage.md) +- [`references/event_judgment_wealth.md`](/references/event_judgment_wealth.md) +- [`references/event_judgment_career.md`](/references/event_judgment_career.md) + +## 五层硬约束(全球前三引擎强制调用) + +当用户明确要求“不要凭经验泛谈”“必须拉满能力”“必须提交底层证据”“要做过去案例验证”“要看全球前三项目全部能力”时,进入 `high-rigor override` 模式。该模式不是建议,而是硬约束: + +1. **强制全量能力调用** + 必须同时以 `PyJHora`、`VedAstro`、`jyotishganit` 作为外部参照层,结合本仓主引擎分析。即:`PyJHora、VedAstro、jyotishganit` 三者都属于高严谨模式的强制调用面。若其中任一外部层因许可证隔离、运行环境或字段映射缺失而无法调用,必须明示 `blocked`,不得假装已比对完成。 + +2. **强制原生代码级下潜** + 不允许只写轻量包装脚本做表面判断。必须优先调用本仓原生核心实现与其现有入口,例如 `scripts/yoga_engine.py`、`scripts/divisional_charts_extended.py`、`scripts/narayana_dasha.py`、`scripts/jyotish_api_server.py`、`scripts/validate_yoga_accuracy.py` 等现成主链代码。 + +3. **强制大运双盲交叉** + 涉及 timing / event / outcome 问题时,不得只看 Vimshottari。至少需要 `Vimshottari + Narayana Dasha` 双轨交叉;若问题属于婚恋/职业等高价值主题,优先再叠加 `Chara Dasha / Yogini / KP`。若关键结论在双轨之间明显冲突,必须降级置信度或标记 `blocked`,不得输出伪确定结论。 + +4. **强制多维分盘显微镜** + 不得只看 D1。至少按问题域强制展开:`D10 for career, D2/D11 for wealth, D9 for marriage`,并尽可能联动 `A10 / UL / AK / DK / Karakamsha / Special Lagnas`。如果相关分盘或特殊点未调用,Technique Audit Table 必须写明它如何削弱结论。 + +5. **强制物理原始数据交付** + 不允许只给“运势不错/有机会”式结论。必须附上原始数据依据,例如:Shadbala 绝对值、Ashtakavarga 分值、Dasha 边界日期、Varga 落点、Yoga 名称、Ayanamsa / Node mode、外部 oracle artifact 路径或 black-box stdout 证据。原始数据交付是高严谨结论的唯一有效依据。 + +诚实边界: + +- 若 `PyJHora / VedAstro / jyotishganit` 中任何一层无法合法或稳定调用,必须明确说明缺口来源。 +- 若外部 oracle 尚未闭环,不得把内部一致性伪装成“全球第一级精度”。 +- 若用户问题只给出模糊数据,必须先降低结论等级,而不是脑补。 + +--- + +## ⚠️ 强制规则(与"不跳步"同级) + + +### 用户隐私与个案资料隔离(v6.0.4-privacy) + +**严禁把真实用户个人信息写入 skill 文件或公开仓库。** + +包括但不限于:姓名/称呼、出生日期时间地点、星盘度数、人生事件、关系状态、职业经历、项目背景、历史回测结论、当前会话中的个案分析。 + +允许的资料来源只有三类: +1. 公开 AA 级名人案例; +2. 明确标注为虚构的 smoke test / template; +3. 用户在当前会话中主动提供的数据,但只能在当前会话中使用,不得持久化到 skill、tests、CHANGELOG 或公开仓库。 + +如需沉淀方法论,只能抽象为通用规则,不得保留可识别个人轨迹的细节。 + +### Strict Workflow Router(v6.0.1-orchestration) + +**凡是用户询问事业、婚恋、财务、事件应期、历史回测或技法可靠性,必须先读取 `references/strict-workflow-router.md`。** + +核心要求: +1. 先判断问题类型,再自动选择 `career-timing-strict` / `relationship-timing-strict` / `wealth-timing-strict` / `event-timing-strict` / `event-verification-strict`。 +2. 用户不需要知道 Chara Dasha、A10、Argala、Shadbala、Ashtakavarga 等技法名称;AI 必须按问题类型自动调用。 +3. 输出末尾必须给出 Technique Audit Table,说明每项高级技法是否调用、结果是什么、缺失会如何降低置信度。 +4. 不得把未实现或未调用的技法静默省略;A10/Karma Pada、Pushkara、Vargottama、Dasha Sandhi 已进入 full-reading 输出;Bhava Chalit 与 Sudarshana Chakra 已进入 complete,可正常纳入 Technique Audit Table。 + +### MEVG 强制外部验证门控(v4.2.0+) + +**所有解读结论必须经过外部权威来源验证,禁止仅凭 AI 训练记忆输出。** + +| 门控 | 位置 | 职责 | +|------|------|------| +| Step 3.11 | 静态分析后 | 验证 Yoga/尊严/Shadbala/SAV | +| Step 4.10 | 动态推运后 | 验证 Transit/Dasha/天文现象 | +| Step 5.5 | 预测输出前 | 确认每条预测有来源+置信度一致 | + +**三步验证法**:V1 构建英文查询词 → V2 web_search ≥3个独立来源 → V3 交叉验证仲裁分歧 + +→ 完整协议:`references/mandatory-verification-gate-protocol.md` + +### Transit Actionable Output(v4.1.0+) + +**每条 Transit 预测必须输出三要素**: +1. **时间段**(精确到日/周/月) +2. **具体行动类型**(做什么) +3. **置信度** [A]=已验证 / [B]=高概率(3+维度) / [C]=推断(单一维度) + +→ 完整规范:`references/transit-actionable-output-guide.md` + +### Rahu/Ketu 节点口径冻结(v6.0.7-node-mode) + +**所有 benchmark 与解盘输出必须显式声明 Rahu/Ketu 使用 Mean Node 还是 True Node。** + +- 当前 skill 默认:`--node-mode mean`(Swiss Ephemeris Mean Node)。 +- 可选:`--node-mode true`(Swiss Ephemeris True Node,用于对齐 PyJHora 默认口径)。 +- PyJHora 4.8.6 的 `rasi_chart()` 默认使用 True Node;第三轮 benchmark 的 Rahu/Ketu 差异已由第四轮仲裁确认为 Mean/True Node 口径差异,不应再误判为 D9/D10 计算 bug。 +- 输出 `birth_info.node_mode` 与 `node_mode_note` 必须保留,作为参数冻结证据。 + +### Multi-Ayanamsa 与 Prompt Pack 冻结(v6.9.15-ai-native) + +**所有排盘、网页/app 和 AI 解读必须显式携带 Ayanamsa 与 Prompt Pack 证据。** + +- `full-reading --ayanamsa lahiri|raman|kp` 与 `/api/chart` 的 `ayanamsa` payload 会影响黄经计算;不得在用户选择 Raman/KP 时仍假定 Lahiri。 +- 输出优先读取 `birth_info.ayanamsa_name`、`birth_info.ayanamsa_display`、`birth_info.ayanamsa`;网页/app 的 `birth.ayanamsa_display` 同样视为参数真源。 +- AI 解读必须优先消费 `ai_prompt_pack.prompt_zh`、`ai_prompt_pack.evidence_snapshot`、`ai_prompt_pack.retrieval_plan`,并在结论中保留“不要仅凭单一配置下结论”的证据交叉要求。 +- 若浏览器 fallback 无法实时切换 Raman/KP,应明确提示需启动本地 API 服务;不得把 fallback 结果伪装成已按目标 Ayanamsa 重算。 + +### Dasha/Shadbala 外部校准边界(v6.9.15-oracle-evidence) + +**普通用户解释时必须显式区分基础排盘高可信与高阶绝对值待外部校准。** + +- Dasha-only 外部证据当前目标集已闭环:`dasha_external_oracle_evidence_validation.valid_dasha_packets: 3/3`;Steve Jobs / Lahiri、synthetic Lahiri template 与 1800 Delhi historical epoch 的 Vimshottari 起始边界来自 PyJHora 4.8.7 隔离黑盒 stdout artifact。 +- 全局 Dasha/Shadbala Calibration Status 仍未完成:`external_oracle_evidence_validation.valid_packets: 4`,`ready_for_calibration: 4`;Shadbala 外部绝对值当前目标集已通过 4/4,Raman 扩展样本与非 Dasha 靶点尚未封顶。 +- 历史 UI 静态门禁仍保留旧提示 `ready_for_calibration: 0` 作为“不得过度宣称”的保守文案;实际进度必须以当前 `oracle_collection_queue.py` / `oracle_evidence_validator.py` 输出为准。 +- Tajika/Sahams 年运外部样本已开始闭环:`tajika_sahams_annual_benchmark_dashboard.ready_for_calibration: 1/5`;Steve Jobs 1984 Varshaphala/Lahiri 的 solar return、Varsha Lagna、Muntha、Year Lord、Mudda Dasha 首主、三项 Sahams 与 Tajika Yogas 已由 PyJHora 4.8.7 隔离黑盒 artifact 验证,下一优先级为 Einstein 1905。仍不得声称 Tajika/Sahams 年运体系已全局封顶。 +- D1/D9/SAV 高可信;Dasha 精细日期可引用已验证 Dasha-only 样本的局部进度,但不得把全部大运边界、Shadbala 绝对值或全局精度说成已完成外部校准。 +- 不得把大运起点或 Shadbala 绝对值说成已完成外部校准;涉及具体日期/绝对力量值时,必须同时报告 `Dasha/Shadbala Calibration Status`、`external_oracle_evidence_validation` 与 `production_tuning_allowed: false` 边界。 +- `production_tuning_allowed: false` 前,禁止为了贴合单份 PDF、单个 JHora 截图或本仓库本地输出而改生产常数。 +- 对普通用户的建议话术:基础落座、D9、SAV 可作为稳定证据;当前 Dasha-only 目标集已完成外部黑盒验证,但多 Dasha 家族、Antardasha/Pratyantar 细边界仍需扩展;Shadbala 当前目标集已有四个外部六分量样本,绝对值断语可引用 4/4 闭合进度;但跨软件差异、Raman 扩展样本与公开书例仍需更多证据后再提升到全局置信。 +- 工作流要求:Dasha-only packet 用 `python3 scripts/dasha_oracle_evidence_validator.py --queue-file ` 验证;全局校准仍必须跑 `python3 scripts/oracle_evidence_validator.py --queue-file `,两者不可混用。 + +### Ashtakavarga 口径冻结(v6.0.8-av-calibration) + +**Ashtakavarga 默认使用 BPHS/PVR 书例校准口径,必须保留 SAV=337 与 full SAV=386 不变量。** + +- `scripts/ashtakavarga.py` 当前为 v2.1:经第六轮 PyJHora/PVR 公开书例仲裁,校准 Moon/Venus 的 7 个贡献表项。 +- 输出 `method` 应显示 `Ashtakavarga八分法(BPHS/PVR书例校准v2.1)`。 +- benchmark 若与其他软件不一致,先比较贡献表项和 SAV 总量,不得直接把口径差异判为运行 bug。 + +### Chara Dasha 能力升级(v6.1.12 benchmark验证通过) + +**Chara Dasha KN Rao Method 正式 benchmark 通过(95.83% ≥ 95%),可作为标准应期模块使用。** + +- v6.1.12: PyJHora oracle benchmark **10案例×12星座=120对**: Sign 100%, Dur 91.67%, Overall 95.83% ✅ PASS +- v6.1.11: 重写为完整 KN Rao Method(序列基于第9宫方向,时长基于宫主所在宫位+尊贵调整) +- 剩余~4.2%差异: Aquarius/Scorpio 的 Rahu/Ketu 共主动态判定(需复制 PyJHora _stronger_planet_new) +- `jaimini` 输出中的 Chara Karaka、AK/AmK、Karakamsha 继续可用。 + +### 开源复用边界冻结(v6.9.16-reuse-whitelist) + +**后续 skill 深化优先复用 MIT 资产,禁止继续对 AGPL/闭源项目做“看着像就手写一份”的低效重复工作。** + +- 可直接复用主来源: + - `jyotishganit`(MIT):Shadbala / Bhava Bala / Panchanga / Vimshottari 常数与实现思路 + - `VedicAstro`(MIT):KP / Horary / API workflow + - `jaimini-tropical`(MIT):Jaimini / Arudha / Chara Dasha 方向常数与细分规则 + - `dashaflow`(MIT):合盘 / Muhurta / 部分 Jaimini / dignity / Yoga 规则 +- 仅允许黑盒对标、禁止复制实现: + - `PyJHora`(AGPL) + - JHora(闭源) + - `hora-prakash`(AGPL) +- 本仓已落地的 MIT 复用点包括:`kp_system.py`、`synastry.py`、`muhurtha_election.py`、`bhava_bala.py`、`dasha_calculator_enhanced.py`、`jaimini.py`、`constants/mit_imported_constants.py` +- 继续扩 skill 前,先查 `/docs/research/reuse_license_whitelist_for_skill_2026_06_26.md`,避免重复造轮子或踩许可证边界。 + +### Transit 真实过境冻结(v6.0.10-true-transit) + +**full-reading 中的 Transit 多参考点分析必须使用真实过境行星位置,不得复用本命行星位置。** + +- `modules.transit_positions` 必须输出 `data_layer: true_transit_positions`、`target_date`、`node_mode` 和 Swiss Ephemeris 计算的过境行星位置。 +- `modules.transit_multi_reference` 必须读取 `transit_positions.planets`,并输出同样的 `data_layer: true_transit_positions`。 +- `--transit-date YYYY-MM-DD` 可显式指定过境日期;若未提供,则跟随 `--today`,再否则使用当前日期。 +- 第八轮 benchmark 已用 10 个公开/虚构 smoke case 对齐 Swiss Ephemeris:340/340 字段匹配,0 mismatch。 + +### Shadbala 能力边界(v6.9.14-shadbala) + +**当前 Shadbala 在注册表中为 covered,主输出为 absolute Rupa 分量求和,可作为内部一致的相对强弱参考;首个外部绝对值六分量样本已通过,但不得声称全部 Shadbala 绝对值已完成校准。** + +- 当前 benchmark 验证 `shadbala` 子命令与 `full-reading.modules.shadbala` 的六重分量求和、Virupa/Rupa 换算和 total invariant;用户样本已输出 absolute Rupa。 +- v6.9.12 已升级 Nathonnata Bala 连续化与 Drik Bala Sputa Drishti 精确相位,v6.9.14 注册表状态为 `covered`。 +- 通过项包括:结构完整性、六重力量组件范围、总分聚合、Virupa/Rupa 换算、排名、full-reading 一致性。 +- 仍需保留边界:部分 Saptavargaja 子分盘与 Chesta Bala 速度分档仍需更多外部绝对值对标。 +- 因此 `technique_registry.json` 中 Shadbala 状态为 `covered`,但涉及精确力量断语时必须加置信度上限,直到更多 JHora/公开书例等完整外部绝对值对标通过。 + +--- + +## 核心能力速查 + +> 详细说明和参考文件索引 → `references/quick-reference-guide.md` + +| 能力域 | 核心内容 | 主要参考文件 | +|--------|---------|------------| +| **静态分析** | 行星配置、Yoga、NK、宫位、Argala、Shadbala、AV、Badhaka、Raman方法论 | `planets.md` `yoga_list.md` `argala-complete-guide.md` `badhaka-obstacle-planet-guide.md` `raman-house-judgment-methodology.md` | +| **动态推运** | Vimshottari、Chara Dasha(KN Rao Method, covered)、KP、Double Transit、Varshaphala、替代Dasha | `vimshottari_dasha_guide.md` `dasa-convergence-methodology.md` `alternative-dasha-systems.md` | +| **Jaimini静态层** | Chara Karaka、Karakamsha、A1-A12/UL、Graha Pada、Argala/Virodhargala、Special Lagnas(部分) | `jaimini-complete-system.md` `argala-complete-guide.md` `technique-capability-matrix.md` | +| **关系占星** | Koota 36分、Mahendra/Stree Deergha/Vedha/Rajju、D9伴侣、DK、Mangal Dosha、Papasamya、配偶六层确认 | `spouse-multi-layer-methodology.md` `darakaraka-complete-guide.md` `relationship-astrology-guide.md` | +| **出生时间矫正** | 八大方法、自动化流程、验证报告、分盘调用决策树 | `birth-time-rectification-advanced.md` `birth-time-rectification-decision-tree.md` | +| **PDF读取** | JH/PL PDF全量提取、完整性门、交叉校验 | `pdf-chart-reading-guide.md` `data-bridge-mapping.md` | +| **Prashna问事** | 十步断卦、AL、Sphuta、Sahams、失物查询 | `prashna-complete-guide.md` `single-event-inquiry-protocol.md` | +| **多元技法** | Yogi/Ava Yogi、Tithi Lord、Rashi Tulya Navamsa、BCP、Bhrigu Pada、Pancha Pakshi、Tara Bala、Deha/Jeeva、Moolatrikona、Shodasavarga/Vimsopaka、Ashwini/Abhijit/Ketu星宿专题(需保留成熟度边界) | `yogi-avayogi-system.md` `yogi-asc-tight-orb-wealth-freeze-guide.md` `tithi-lord-relationship-system.md` `tithi-lord-freeze-execution-guide.md` `rtn-high-order-d9-freeze-execution-guide.md` `bhrigu-pada-all-event-freeze-execution-guide.md` `ashwini-abhijit-ketu-nakshatra-freeze-guide.md` `bhrigu-chakra-paddhati.md` `shodasavarga-complete-guide.md` `planetary-dignity-complete-reference.md` `alternative-dasha-systems.md` | +| **精准方法论** | PACDARES框架、九层复合方法、L3矛盾检查、三级置信度 | `precision-reading-methodology.md` | +| **解读质检** | 真实解读结构质检、参数冻结、分盘强制展开、oracle 诚信边界 | `real-reading-quality-checklist.md` | +| **现代解读** | 现代措辞映射、现代生活场景、常见误判纠错 | `modern-language-guide.md` `common-misconceptions.md` | +| **实战智慧** | ⭐反教条主义经验精华(全球占星师真实案例反馈总结) | `practitioner-wisdom-anti-dogma.md` | +| **验证与错题** | 深度数据审计、技法缺陷与修复、推运反思、15+名人验证案例 | `audit-*` `lessons-learned-*` `verified-celebrity-cases-*` | + +## 文章级细节模板入口(v6.9.17-template-registry) + +用户问到“吉祥天女/财富点、Yogi Point、娄宿 Ashwini 天赋、上升点度数定位、紧密合相、RTN/D9、Bhrigu Pada、Tithi Lord、Pancha Pakshi/Swara”等细颗粒技法时,不得临场凭记忆发挥,也不得把网上文章断语直接当权威。 + +必须先查: + +```bash +python3 scripts/validate_interpretation_templates.py --format markdown +``` + +注册表真源: + +`references/interpretation_template_registry.json` + +当前已冻结 6 个可复用模板: + +1. `yogi_asc_tight_orb_wealth`:Yogi Point / 上升度数 / `<1°` 紧密合相 / 财富激活 +2. `ashwini_talent_profile`:Ashwini / Ketu 系星宿 / Abhijit 择时边界 +3. `rtn_high_order_d9`:Rashi Tulya Navamsa / 高阶 D9 异象 +4. `bhrigu_pada_all_event`:Bhrigu Pada / Arudha Pada 全事件推进 +5. `tithi_lord_relationship`:Tithi Lord 关系与情绪节奏 +6. `pancha_pakshi_swara_boundary`:Pancha Pakshi / Swara 择时边界 + +使用规则: + +- 这些模板是“细节解释层”,不能替代 D1/D9/Dasha/Transit/相关分盘。 +- `<1°` 紧密合相只能提高敏感度或置信度,不能单独断财富、婚姻、事故或成就。 +- Ashwini/Abhijit/Ketu 星宿只能作为天赋、行动风格或择时偏好,不可单独断职业、财富或灵性高低。 +- Bhrigu Pada / RTN / Tithi Lord / Pancha Pakshi 必须作为辅助确认层;若没有主承诺和推运激活,输出置信度不得超过 C。 +- 所有文章/课程/网上说法默认归入 B/C 级线索,必须经过注册表中的 `required_cross_checks` 与 `forbidden_claims` 过滤。 + +## 当前最硬的未闭环点 + +> 这部分比“再加几个技法名”更重要,决定 skill 距离传统软件级深度还有多远。 + +1. **Dasha 外部绝对边界闭环** + - 当前 `ready_for_calibration: 4` + - Dasha-only 目标集已由 PyJHora 黑盒证据推进到 3/3,但全局 Dasha/Shadbala 队列仍有非 Dasha 靶点缺字段,`production_tuning_allowed: false` +2. **Shadbala 外部绝对值闭环** + - 当前 absolute Rupa 结构自洽,但还不是外部绝对值完全校准 +3. **Chara Dasha 共主仲裁尾差** + - KN Rao benchmark 已过,但 Aquarius/Scorpio 的 Rahu/Ketu 共主强弱仲裁仍有尾差 +4. **KP ruling planets / 事件裁决细节** + - KP 表层输出可用,但传统工作流深度仍需继续闭环 +5. **Prashna 分支工作流** + - 问事类型分支、裁决链、时机判断仍需更稳定的传统链路 +6. **Varshaphala / Tajika / Sahams 年运裁决深度** + - 年盘骨架已在,第一条 Steve Jobs 1984 外部年运样本已闭环;但整体仍只有 `1/5`,事件裁决、权重层、Einstein 1905 等后续样本仍需继续成熟 +7. **Kalachakra / Narayana 等替代 Dasha 的成熟度边界** + - 已有覆盖,但部分子层、边界口径、外部黑盒对照仍需继续收紧 +8. **高阶解释层整合** + - `Pushkara / Vargottama / Avastha / RTN / Inter-chart linkage` 已存在,但还未形成传统高手式稳定裁决层 + +完整排序见 `/docs/research/current_skill_core_gap_rerank_2026_06_26.md`。 + +### Skill Gap Truth Audit(严禁过度声明) + +当用户问“是否已经全球第一”“是否包含所有印度占星技法”“过去案例哪里错了”“还差什么硬任务”时,必须先运行: + +```bash +python3 scripts/skill_gap_truth_audit.py --format markdown +``` + +真源文件: + +`references/skill_gap_truth_registry.json` + +此审计的结论优先级高于口头记忆: + +- 若 `can_claim_global_first: false`,不得宣称全球无争议第一。 +- 若 `can_claim_all_skills_complete: false`,不得宣称所有技法已完全封顶。 +- 若 `can_claim_perfect_accuracy: false`,不得宣称排盘、Dasha、Shadbala、年运等已达到完美精度。 +- 若某技法为 `covered`,只能说“有稳定入口或可用层”,不能自动说成 `complete`。 +- 过去案例分析若触及 `past_case_analysis_corrections` 中的误判类型,必须主动修正并降低置信度。 + +## 全球开源定位 + +**当前还不能诚实地说这是全球开源印度占星 / 吠陀占星项目里的无争议第一。** + +更准确的判断是: + +- 在**中文 skill 工作流、本地可用性、产品化组织、MIT 资产整合**上,已经处于第一梯队。 +- 在**长期黑盒 benchmark、传统软件级精度闭环、全球社区势能**上,仍落后于部分头部项目。 + +### 主要对标对象 + +1. **PyJHora** + - 优势:47 Dasha、300+ 分盘、284+ Yoga、6800+ 级别验证与 JHora 对照壁垒 + - 边界:AGPL,只能黑盒 benchmark,不可复制实现 +2. **VedAstro** + - 优势:全球社区势能更强,API/Web/AI 平台生态更成熟 + - 边界:更偏平台化,全局离线本地 skill 体验不一定更优 +3. **VedicAstro / jyotishganit / jaimini-tropical / dashaflow** + - 价值:MIT,可直接作为继续补深的合法资产来源 + +完整定位分析见 `/docs/research/global_open_source_positioning_of_skill_2026_06_26.md`。 + +## 冲顶路线 + +> 如果目标是冲击“全球开源第一梯队”,后续优先级必须按这个顺序推进。 + +### P0 - 精度护城河(不完成就不能宣称“精准度完美”) + +1. 冻结 `Dasha` 外部 oracle +2. 冻结 `Shadbala` 外部绝对值 oracle +3. 建立可重复、可公开的 benchmark 报表与案例链 + +### P1 - 传统裁决深度(不是补技法名,而是补传统工作流) + +4. 收紧 `Chara Dasha` 共主仲裁尾差 +5. 补深 `KP ruling planets / Horary workflow` +6. 补深 `Prashna` 问事分支裁决链 +7. 补深 `Varshaphala / Tajika / Sahams` 的年度解释层 +8. 收紧 `Kalachakra / Narayana` 的成熟度边界与黑盒一致性说明 + +### P2 - 老练度与口感(决定“像不像老练传统占星师”) + +9. 继续补 `Pancha Pakshi` Tamil 细则 +10. 整合 `Pushkara / Vargottama / Avastha / RTN / Inter-chart linkage` 的高阶解释层 +11. 统一各输出面的边界表达,避免 `covered` 被误读成 `complete` + +## 施工判断原则 + +> 后续所有优化都按这个判断,避免重复劳动或把“已覆盖但不够成熟”误判成“完全缺失”。 + +1. **先判断是不是已经存在** + - 若 `scripts/`、`references/`、`skills/` 已有主体实现或执行链,优先视为“补成熟度/补入口”,不是重写。 +2. **再判断是不是可合法复用** + - MIT / Apache / BSD 资产优先复用;GPL / AGPL / 闭源只做黑盒对照,不复制实现。 +3. **再判断是不是必须依赖外部 oracle** + - 凡涉及 `Dasha` 精确日期边界、`Shadbala` 绝对值、传统软件口径冻结,必须走 JHora / PyJHora / 公开样本闭环。 +4. **最后才决定是否新增技法** + - 如果只是入口缺失、索引缺失、解释层不够厚,优先补入口和执行链,不先堆新名词。 + +--- + +## 计算引擎 + +**统一入口**:`scripts/jyotish_engine.py`(基于 Swiss Ephemeris) + +```bash +PYTHON=python3 +SCRIPT=~/.workbuddy/skills/jyotish-vedic-astrology/scripts/jyotish_engine.py +$PYTHON $SCRIPT <子命令> [参数] +``` + +### 37大子命令速查 + +| 子命令 | 功能 | +|--------|------| +| `full-reading` | ⭐全自动综合解盘(47模块一键出,含五系统Dasha收敛) | +| `chart` | 星盘计算+`--validate`附加R1-R10验证 | +| `dasha` | Vimshottari大运时间线+小运展开 | +| `yoga` | Yoga格局识别 | +| `predict` | 三层验证法事件预测+`--past-verify`验前事 | +| `varga` | 分盘计算(D9/D10等) | +| `varga-full` | BPHS十六分盘精确计算(D2-D60) | +| `celebrity` | 名人案例查询 | +| `db-stats` | 验证数据库统计 | +| `transit` | 行星过境查询 | +| `shadbala` | 六重力量计算(covered;absolute Rupa 输出,外部绝对值 oracle 完成前须保留置信度上限) | +| `ashtakavarga` | 八分法计算(SAV=337) | +| `memory` | Hermes记忆系统 | +| `validate` | R1-R10数学验证 | +| `audit` | P1-P12行星审计管线 | +| `aspects` | 度数精确相位系统 | +| `jaimini` | Jaimini Karaka/Karakamsha、A1-A12/UL、Graha Pada、Special Lagnas;Chara Dasha timing 为 KN Rao Method(covered;仍需保留共主仲裁与外部对标边界) | +| `nakshatra-adv` | 高级Nakshatra(Tara Bala+Chandra Bala+Sub-Lord) | +| `nakshatra-dasha` | 星宿大运推演(Ashtottari + Nakshatra-level Vimshottari) | +| `nakshatra-full` | 星宿综合报告(本命 + 大运 + 过境星宿) | +| `argala` | Argala门闩系统:主 Argala + Virodhargala + Rajayoga 分类 | +| `tajika` | Tajika年运盘(Muntha+YearLord+Mudda Dasha) | +| `synastry` | 合盘分析:Ashta Koota 36分 + Mahendra/Stree Deergha/Vedha/Rajju 等附加Kuta | +| `report` | MD→HTML报告生成(羊皮纸主题) | +| `prashna` | Prashna问事占星 | +| `double-transit-pac` | KN Rao Double Transit PAC+D9层 | +| `transit-ll7l` | Transit LL/7L连接+互换 | +| `planetary-congregation` | 行星聚集检测 | +| `vivah-saham` | Vivah Saham婚姻敏感点 | +| `audit-capabilities` | technique registry 校验 + route 审计表输出 | +| `kp` | KP完整分析(SubLord+SubSubLord+ABCD Significator) | +| `ashtakoot` | 36点合婚(8标准Kuta+7附加+Kuja Dosha) | +| `solar-return` | 太阳返照盘年运分析(Newton迭代精确返照) | +| `narayana-dasha` | Narayana Dasha星座大运 | +| `muhurta` | Muhurta择时分析 | + +→ 完整参数和示例 → `references/quick-reference-guide.md` + +--- + +## 核心方法论 + +### 三层验证法 +1. **本命征象**:静态星盘中的征象 +2. **大运激活**:Dasha系统激活相关宫位 +3. **过境触发**:Transit系统触发具体事件(⚠️必须多参考点检查) + +### 精准解盘方法论(v3.12.1) + +**六大共识原则**:功能吉凶因盘而异 | 单一技法不做结论 | 规则前提先查 | 案例验证>经典引述 | 先整体后细节 | 先验证过去再预测未来 + +**PACDARES框架**:P位置→A相位→C合相→D财富Yoga→A灾厄Yoga→R皇家Yoga→E互换→S特殊 + +**九层复合方法**:L1 PACDARES → L2 分盘 → L3 矛盾检查(关键) → L4 Vimshottari → L5 AV+Transit → L6 条件Dasha → L7 Jaimini → L8 其他Jaimini → L9 Tajika + +**三级置信度**:✅[A]已验证 / ⭐[B]强推断(3+维度) / ⚡[C]假设(单一维度) + +→ 详见 `references/precision-reading-methodology.md` + +--- + +## 强制规范速查 + +| 规范 | 版本 | 核心要求 | 参考文件 | +|------|------|---------|---------| +| MEVG外部验证 | v4.2.0 | 所有解读必须web_search验证 | `mandatory-verification-gate-protocol.md` | +| Transit Actionable | v4.1.0 | 预测必须输出时间段+行动+置信度 | `transit-actionable-output-guide.md` | +| 过境多参考点 | v1.9.0 | Lagna+Chandra Lagna双参考点(强制) | `transit-multi-reference-guide.md` | +| Ketu双属性 | v2.0.0 | 必须同时评估"放手"和"突破" | `ketu-dual-nature-guide.md` | +| Shadbala评估 | v6.9.15 | absolute Rupa 分量求和;外部绝对值 oracle 完成前须保留置信度上限 | `shadbala-complete-methodology.md` | +| Yoga Phala Timing | v2.1.0 | 识别Yoga后必须预测何时发生 | `yoga-phala-timing-guide.md` | +| 逆行/燃烧/战争 | v2.1.0 | 每颗行星检查三重叠加 | `retrograde-combustion-war-guide.md` | +| 精准方法论 | v3.12.1 | PACDARES+九层+L3矛盾检查 | `precision-reading-methodology.md` | + +--- + +## 预测清单 + +- [ ] **Strict Router**:已读取 `references/strict-workflow-router.md`,并声明本轮使用的 strict route +- [ ] **Technique Audit Table**:输出末尾已列出已调用/未调用/complete/covered/仍需外部校准技法及置信度影响 +- [ ] **MEVG-静态门控**:所有静态解读声明必须web_search验证 +- [ ] 静态星盘分析(行星配置、Yoga、Nakshatra、宫位) +- [ ] Argala检查(2/4/5/8/11宫干预+Virodha) +- [ ] 逆行/燃烧/行星战争检查(三重叠加) +- [ ] Shadbala评估(absolute Rupa 分量求和;外部绝对值 oracle 完成前保留置信度上限) +- [ ] Ashtakavarga评估(BAV+SAV聚合校验337点) +- [ ] Ketu双重属性检查 +- [ ] **MEVG-动态门控**:Transit/Dasha/天文现象必须验证 +- [ ] Dasha推运(大运+小运+Pratyantar) +- [ ] Dasa Convergence五系统交叉验证 +- [ ] Jaimini分析(Karaka/Karakamsha;Chara Dasha 已通过 KN Rao Method benchmark,剩余共主仲裁差异需声明) +- [ ] KP系统分析(Significator+Sub-Lord) +- [ ] Transit分析(多参考点强制) +- [ ] **Transit Actionable Output**(时间段+行动+置信度+案例检索) +- [ ] 分盘验证 +- [ ] 预测边界检查(置信度标注,禁止绝对断言) +- [ ] **案例检索**:动态预测必须先检索真实案例 +- [ ] **MEVG-预测门控**:确认每条预测有来源+置信度一致 +- [ ] **缺口声明**:A10/Karma Pada、Pushkara、Vargottama、Dasha Sandhi 应从 full-reading 读取;若完整Bhava Chalit/传统Sudarshana等未计算,已说明原因与影响 + +--- + +## 参考资料索引 + +> 完整描述和版本信息 → `references/quick-reference-guide.md` §参考资料完整索引 + +共 **100+ 个文件**,按功能分组: + +| 分组 | 数量 | 核心文件 | +|------|------|---------| +| AI工作流 | 2 | `ai-reading-workflow-prompt.md` ⭐ `quick-reference-guide.md` ⭐ | +| 核心方法论 | 9 | `common-misconceptions.md` `modern-language-guide.md` `pdf-chart-reading-guide.md` `prediction-boundary-protocol.md` | +| 基础知识 | 7 | `planets.md` `signs-and-houses.md` `nakshatra_deities.md` `vimshottari_dasha_guide.md` | +| Yoga体系 | 5 | `yoga_list.md` `neechabhanga-raja-yoga.md` `yoga-phala-timing-guide.md` | +| 宫位/场景 | 3 | `house-modern-mapping.md` `house-domain-planet-mapping.md` | +| 占星系统 | 5 | `jaimini-complete-system.md` `kp-astrology-complete-system.md` `remedies-complete-system.md` | +| 分盘/力量 | 7 | `ashtakavarga-complete-system.md` `shadbala-complete-methodology.md` `shodasavarga-complete-guide.md` | +| 过境/推运 | 9 | `transit-comprehensive-guide.md` `dasa-convergence-methodology.md` `alternative-dasha-systems.md` | +| 关系占星 | 5+ | `spouse-multi-layer-methodology.md` `darakaraka-complete-guide.md` `marc-boney-marriage-six-step.md` | +| 综合框架 | 5 | `comprehensive-reading-workflow.md` `deep-analysis-complete-workflow.md` | +| 高级技法 | 5 | `advanced-techniques.md` `global-astrologer-practical-methodology.md` | +| 案例库 | 13 | `famous-case-library.md` `verified-celebrity-cases.md` | +| 多元技法 | 5 | `yogi-avayogi-system.md` `bhrigu-chakra-paddhati.md` `pancha-pakshi-nakshatra-systems.md` | +| 高阶执行补充 | 8 | `deep-varga-avastha-execution-guide.md` `sahams-execution-guide.md` `high-order-d9-execution-guide.md` `tithi-lord-freeze-execution-guide.md` `rtn-high-order-d9-freeze-execution-guide.md` `bhrigu-pada-all-event-freeze-execution-guide.md` `yogi-asc-tight-orb-wealth-freeze-guide.md` `ashwini-abhijit-ketu-nakshatra-freeze-guide.md` | +| BPHS/Raman/Goel | 5 | `badhaka-obstacle-planet-guide.md` `raman-house-judgment-methodology.md` `vp-goel-jaimini-dasha-systems.md` | +| MEVG | 1 | `mandatory-verification-gate-protocol.md` | + +--- + +## 注意事项 + +1. **出生时间精度**:±2分钟内最佳,可通过矫正提高 +2. **三层验证法**:所有预测必须Dasha+Transit+Varga交叉验证 +3. **现代场景优先**:所有解读使用现代措辞和现代生活场景映射 +4. **解盘深度**:默认Level 2(专项),复杂问题自动升级Level 3 +5. **不凭记忆**:禁止仅凭AI训练记忆输出解读结论,必须MEVG验证 + +--- + +**版本**:v6.9.15-calibration-boundary +**创建日期**:2026-04-20 +**最后更新**:2026-06-26(skill 真源碎片已重新归拢并同步到 WorkBuddy;MIT 复用白名单已冻结;Chara/KP/Shadbala/Prashna/Tajika 的核心未闭环点已重排;外部 oracle 扩充仍在进行。) + +--- + +## 验证与错题体系 + +> 基于万级案例库(15,807条AA级名人数据)和迭代验证沉淀的知识体系 + +### 数据资源 + +| 资源 | 规模 | 位置 | +|------|------|------| +| 名人案例库 | 15,807条(全部AA级) | `Claw/vedastro_data/PersonList-15k.csv` | +| 验证数据库 | 15,840 cases | `Claw/vedic_astrology_validation.db` | +| 验证结果JSON | v5/v6/v6.1 共325KB | `tests/test-data/` | + +### 深度审计报告 + +| 文件 | 内容 | +|------|------| +| `audit-deep-data-audit-2026-05-04.md` | 逐字段对比pyswisseph,发现5个P0级Bug(Jaimini Karaka全错/Chara Dasha全0/Vimsopaka 16分盘全用D1/Yoga返回0/Arudha off-by-one) | +| `audit-skill-full-test-2026-05-04.md` | 27子命令逐项测试,full-reading 19模块全OK | +| `audit-kimi-optimization-review.md` | 外部AI优化建议审计,发现多处事实性错误 | +| `COVERAGE_AUDIT_REPORT.md` | 覆盖矩阵审计,综合覆盖率97.8%(90/92) | + +### 经验教训(Lesssons Learned) + +| 文件 | 核心教训 | +|------|---------| +| ⭐`practitioner-wisdom-anti-dogma.md` | **整合精华**:反教条主义十大死穴+技法盲区+全球占星师语录+验证规律(去重后统一入口) | +| `lessons-learned-misconceptions-reflection.md` | 解盘与推运常见误区(落陷≠失败/Rahu=非传统突破/12宫≠纯负面) | +| `lessons-learned-timing-reflection.md` | 推运应期判断的反思与修正经验 | +| `lessons-learned-technique-defects.md` | 技法缺陷全面分析 | +| `lessons-learned-technique-fixes.md` | 技法缺陷解决方案 | +| `lessons-learned-technique-patches-p1.md` | 技法漏洞修正方案 | +| `lessons-learned-technique-optimization.md` | 技法优化完整报告 | + +### 已验证名人案例(平均吻合度93%) + +| 文件 | 人物 | 吻合度 | +|------|------|--------| +| `verified-celebrity-cases-summary.md` | 10名人总览 | 平均93% | +| `verified-celebrity-cases-obama-web.md` | Obama | 95% | +| `verified-celebrity-cases-trump.md` | Trump | 94% | +| `verified-celebrity-cases-einstein.md` | Einstein | 92% | +| `verified-celebrity-cases-picasso.md` | Picasso | 93% | +| `verified-celebrity-cases-curie.md` | Curie | 94% | +| `verified-celebrity-cases-indira-gandhi.md` | Indira Gandhi | full-reading测试 | +| `verified-celebrity-cases-elvis.md` | Elvis | 93% | +| `verified-celebrity-cases-marilyn-monroe.md` | Monroe | - | +| `verified-celebrity-cases-michael-jackson.md` | M.Jackson | - | +| `verified-celebrity-cases-leonardo-dicaprio.md` | DiCaprio | - | +| `verified-case-reasoning-report.md` | 案例推理验证(修正版) | - | + +### 星盘分析(7部分完整分析) + +`analysis-natal-full-part1~7`:核心配置 / 宫位强度 / Ashtakavarga / PlanetActivity / VimsopakaBala / Dasa系统 / 综合预测 + +### 验证方法论 + +| 文件 | 内容 | +|------|------| +| `validation-methodology-batch-celebrity.md` | 批量名人验证方案 | +| `marriage-timing-validation-methodology.md` | 婚姻应期技法验证方法论 | +| `mandatory-verification-gate-protocol.md` | MEVG强制验证门控协议 | +| `verified-patterns-marriage-timing-v5.md` | 婚姻验证模式v5(含v5→v6重大Bug说明) | +| `verified-patterns-marriage-timing-v6.md` | 婚姻验证模式v6.1(18名人/26婚姻/66事件) | + +### Bug 修复历史 + +`CHANGELOG.md` 中记录了 61 条 Bug 修复,关键修复包括: +- v6.0: UTC时区转换Bug(导致16/18案例上升星座错误) +- v4.3: Dasha浮点边界Bug +- v4.2: MEVG强制验证门控 +- v3.7.2: Antardasha(次级大运)只为当前大运计算→改为全部9个大运 +- v3.7.2: Moon Chesta Bala溢出(>60分上限)、Exalted D1分数、Paksha Bala归一化 diff --git a/references/upstream/yinduzhanxing/source-manifest.json b/references/upstream/yinduzhanxing/source-manifest.json new file mode 100644 index 00000000..76c1b2a8 --- /dev/null +++ b/references/upstream/yinduzhanxing/source-manifest.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "source_repository": "732642856/yinduzhanxing", + "source_commit": null, + "source_mode": "snapshot", + "source_tree_sha256": "9034e1967032d09c7fbae83fc2205f7e75e8ad482c5f9eba1bf309fe30aef5bb", + "skill_sha256": "1be8beafbd5f4b6df7f95afec39e87033cd70ef6bf582fe7d9f1eb62fb5d00fb", + "license": "MIT", + "imported_at": "2026-08-06T00:00:00Z", + "boundary": "The provided source had no usable Git metadata. This manifest does not claim parity with a GitHub commit." +} diff --git a/scripts/report_orchestrator.py b/scripts/report_orchestrator.py index e8fc8c10..11650b87 100644 --- a/scripts/report_orchestrator.py +++ b/scripts/report_orchestrator.py @@ -1024,6 +1024,42 @@ class MockDataFactory: ] +# ═══════════════════════════════════════════════════════════════ +# Reader report output contract +# ═══════════════════════════════════════════════════════════════ + +def render_reader_report(reader_report: Dict[str, Any]) -> Dict[str, Any]: + """Return the stable reader order without recalculating report facts. + + The evidence appendix is collapsed for normal users and expanded by default + only in explicit research mode. Unknown commercial extensions are retained + after the three reader-facing sections for backward compatibility. + """ + executive_summary = reader_report.get("executive_summary") + thematic_narrative = reader_report.get("thematic_narrative") + supplied_appendix = reader_report.get("evidence_appendix") + evidence_appendix = dict(supplied_appendix) if isinstance(supplied_appendix, dict) else {} + presentation_mode = reader_report.get("presentation_mode") + if not presentation_mode and isinstance(executive_summary, dict): + presentation_mode = executive_summary.get("presentation_mode") + presentation_mode = presentation_mode or "default" + evidence_appendix["expanded"] = presentation_mode == "research" + + extensions = { + key: value + for key, value in reader_report.items() + if key not in {"presentation_mode", "executive_summary", "thematic_narrative", "evidence_appendix"} + } + rendered = { + "presentation_mode": presentation_mode, + "executive_summary": executive_summary, + "thematic_narrative": thematic_narrative, + "evidence_appendix": evidence_appendix, + } + rendered.update(extensions) + return rendered + + # ═══════════════════════════════════════════════════════════════ # 演示与测试入口 # ═══════════════════════════════════════════════════════════════ diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index 2289397b..806a1c95 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -1,14 +1,23 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """Shared orchestration contract for skill/MCP and web/API surfaces.""" from __future__ import annotations import json +import re from dataclasses import dataclass from pathlib import Path from typing import Any + +def _formal_divisions() -> tuple[int, ...]: + registry = Path(__file__).resolve().parents[1] / "references/oracle/d1_d60_varga_mapping_registry_2026_07_19.json" + rows = json.loads(registry.read_text(encoding="utf-8"))["rows"] + return tuple(int(row["number"]) for row in rows if row.get("formal_name_present")) + + +FORMAL_DIVISIONS = _formal_divisions() + try: from diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report except Exception: # pragma: no cover - import path varies in tests/CLI @@ -204,6 +213,55 @@ class UnifiedConsultationOrchestrator: "official_event_radar_expansion", "extended_prompt_pack_refresh", ] + _DOMAIN_PROFILE_SECTIONS = { + "relationship": ["core_partner_profile", "temperament_and_compatibility", "timing_windows", "red_flags", "verification_questions"], + "career": ["career_direction", "role_and_responsibility", "income_and_recognition", "opportunity_windows", "risks_and_verification_questions"], + "finance": ["wealth_path", "income_structure", "asset_and_cashflow_pattern", "opportunity_windows", "verification_questions"], + "health": ["non_medical_pattern", "pressure_factors", "protective_factors", "verification_questions"], + "migration": ["relocation_pattern", "foreign_link", "candidate_windows", "verification_questions"], + "family": ["family_structure", "home_and_care", "children_boundary", "verification_questions"], + "education": ["study_vs_work_fit", "exam_and_degree_path", "candidate_windows", "verification_questions"], + "annual": ["annual_themes", "candidate_windows", "claim_boundaries", "verification_questions"], + "timing": ["active_themes", "candidate_windows", "triggering_techniques", "verification_questions"], + "general": ["life_themes", "strengths_and_pressures", "candidate_windows", "verification_questions"], + } + _THEME_VARGA_DISPATCH = { + "career": ("D1", "D10", "D24"), + "marriage": ("D1", "D9", "D7", "D12"), + "wealth": ("D1", "D2", "D11", "D4"), + "health": ("D1", "D6", "D8", "D30"), + "migration": ("D1", "D4", "D12"), + "family": ("D1", "D7", "D12"), + "education": ("D1", "D5", "D24"), + "annual": ("D1", "D9", "D10"), + "spirituality": ("D1", "D20", "D24", "D60"), + } + _THEME_TECHNIQUE_IDENTIFIERS = { + "career": {"D10", "A10"}, + "marriage": {"D9", "UL", "UPAPADA", "VIVAH"}, + "wealth": {"D2", "D11"}, + "health": {"D6", "D8", "D30"}, + "migration": {"D4", "D12"}, + "family": {"D7", "D12"}, + "education": {"D5", "D24"}, + "annual": {"DASHA", "TRANSIT", "TAJIKA"}, + "spirituality": {"D20", "D60"}, + } + + @classmethod + def route_profile_contract(cls, route_name: str) -> dict[str, Any]: + """Expose reader sections without replacing runtime technique audit.""" + route = route_name if route_name in cls._DOMAIN_PROFILE_SECTIONS else "general" + return { + "version": "domain_profile_v1", + "route": route, + "sections": list(cls._DOMAIN_PROFILE_SECTIONS[route]), + "assertion_levels": [ + "multi_system_consensus", "single_system_inference", "parameter_sensitive", + "unclosed_divisional_chart", "user_history_verification_required", "blocked", + ], + "execution_boundary": "Profile labels do not replace Technique Audit Table execution status.", + } def normalize_themes(self, raw: Any) -> list[str]: if raw in (None, "", "all"): @@ -281,6 +339,140 @@ class UnifiedConsultationOrchestrator: "display_label": route.display_label, } + def route_profile(self, question: str, themes: list[str] | None = None) -> dict[str, Any]: + """Select presentation depth and on-demand Vargas without changing routing.""" + normalized_themes = self.normalize_themes(themes) + request = (question or "").lower() + is_research = any(token in request for token in ("研究模式", "research_mode", "原始数据", "全量数据", "raw_data")) + selected: list[str] = [] + for theme in normalized_themes: + for code in self._THEME_VARGA_DISPATCH.get(theme, ("D1",)): + if code not in selected: + selected.append(code) + formal = [f"D{division}" for division in FORMAL_DIVISIONS] + return { + "question": question or "", + "themes": normalized_themes, + "presentation_mode": "research" if is_research else "default", + "appendix_expanded": is_research, + "varga_dispatch": { + "mode": "on_demand", + "selected_theme_vargas": selected, + "all_formal_vargas": formal, + "deferred_vargas": [code for code in formal if code not in selected], + "rule": "先调用与主题直接相关的分盘;其余正式分盘只在追问或冲突时展开。", + }, + } + + @staticmethod + def _deduplicate_sentences(text: str) -> str: + parts = re.split(r"(?<=[.!?。!?])", text) + seen: set[str] = set() + kept: list[str] = [] + for part in parts: + key = part.strip() + if key and key not in seen: + seen.add(key) + kept.append(part) + return "".join(kept) + + @classmethod + def _suppress_definitive_claims(cls, text: str) -> str: + conditional = text.replace("确定", "尚无法确认").replace("必然", "未必").replace("一定", "尚无法确认") + conditional = re.sub(r"\bwill\s+(?:definitely|certainly|inevitably)\b", "may", conditional, flags=re.IGNORECASE) + return re.sub(r"\b(?:definitely|certainly|certain|inevitably|guaranteed)(?:\s+(?:definitely|certainly|certain|inevitably|guaranteed))*\b", "not yet verified", conditional, flags=re.IGNORECASE) + + def _audit_applies_to_theme(self, row: dict[str, Any], theme: str) -> bool: + for field in ("theme", "domain"): + if str(row.get(field) or "").lower() == theme: + return True + for field in ("themes", "domains", "applicable_themes"): + values = row.get(field) + if isinstance(values, str) and values.lower() == theme: + return True + if isinstance(values, list) and theme in {str(value).lower() for value in values}: + return True + technique = str(row.get("technique") or row.get("name") or "").upper() + identifiers = set(re.findall(r"\b[A-Z]+\d*\b", technique)) + return bool(identifiers & self._THEME_TECHNIQUE_IDENTIFIERS.get(theme, set())) + + @staticmethod + def _infer_technique_system(row: dict[str, Any]) -> str: + technique = str(row.get("technique") or row.get("name") or "").lower() + if "cross-system" in technique or "cross system" in technique: + return "cross_system" + if any(token in technique for token in ("western", "solar return", "secondary progression", "solar arc", "midpoint")): + return "western" + return "jyotish" + + def _normalize_audit_row(self, row: dict[str, Any]) -> dict[str, Any]: + normalized = dict(row) + status = str(normalized.get("status") or "unknown").lower() + normalized["system"] = str(normalized.get("system") or self._infer_technique_system(normalized)) + normalized["confidence_label"] = str(normalized.get("confidence_label") or { + "executed": "multi_system_consensus", "used": "multi_system_consensus", "complete": "multi_system_consensus", + "partial": "parameter_sensitive", "research_only": "parameter_sensitive", "blocked": "blocked", + }.get(status, "single_system_inference")) + normalized["user_visible_summary"] = str(normalized.get("user_visible_summary") or f"{normalized.get('technique') or normalized.get('name') or 'Technique'} · {normalized['system']} · {status}") + return normalized + + @staticmethod + def _audit_overview(rows: list[dict[str, Any]]) -> dict[str, Any]: + statuses: dict[str, int] = {} + systems: dict[str, int] = {} + for row in rows: + status, system = str(row.get("status") or "unknown").lower(), str(row.get("system") or "unknown").lower() + statuses[status] = statuses.get(status, 0) + 1 + systems[system] = systems.get(system, 0) + 1 + return {"status_counts": statuses, "system_counts": systems, "blocked_count": statuses.get("blocked", 0)} + + def build_reader_report( + self, + route_profile: dict[str, Any], + theme_reports: dict[str, Any], + raw_data: Any = None, + technique_audit: list[dict[str, Any]] | None = None, + conflicts: list[Any] | None = None, + ) -> dict[str, Any]: + """Build summary -> narrative -> appendix while preserving blocked truth.""" + audit_rows = [self._normalize_audit_row(row) for row in list(technique_audit or [])] + narrative: dict[str, Any] = {} + for theme, report in theme_reports.items(): + item = dict(report) if isinstance(report, dict) else {"summary": str(report)} + blocked = str(item.get("status") or "").lower() == "blocked" or any( + str(row.get("status") or "").lower() == "blocked" and self._audit_applies_to_theme(row, theme) + for row in audit_rows + ) + for field, value in list(item.items()): + if isinstance(value, str): + item[field] = self._deduplicate_sentences(self._suppress_definitive_claims(value) if blocked else value) + views = { + "jyotish": item.get("jyotish_summary") or item.get("vedic_summary"), + "western": item.get("western_summary"), + "consensus": item.get("consensus_summary") or item.get("cross_system_summary"), + } + if any(views.values()): + item["system_views"] = {key: value for key, value in views.items() if value} + narrative[theme] = item + dispatch = route_profile.get("varga_dispatch") if isinstance(route_profile.get("varga_dispatch"), dict) else {} + return { + "executive_summary": { + "themes": list(route_profile.get("themes") or []), + "presentation_mode": route_profile.get("presentation_mode") or "default", + "selected_theme_vargas": list(dispatch.get("selected_theme_vargas") or []), + }, + "thematic_narrative": narrative, + "evidence_appendix": { + "expanded": bool(route_profile.get("appendix_expanded")), + "raw_data": raw_data, + "technique_audit": audit_rows, + "audit_overview": self._audit_overview(audit_rows), + "blocked_techniques": [str(row.get("technique") or row.get("name") or "unknown") for row in audit_rows if str(row.get("status") or "").lower() == "blocked"], + "conflicts": list(conflicts or []), + "varga_dispatch": dispatch, + }, + } + def shared_contract( self, *, @@ -297,6 +489,7 @@ class UnifiedConsultationOrchestrator: "question": question or "", "themes": list(themes), "route": dict(route_packet), + "route_profile_contract": self.route_profile_contract(str(route_packet.get("question_type") or "general")), "source_priority": { "mode": self.SOURCE_PRIORITY["mode"], "priority": list(self.SOURCE_PRIORITY["priority"]), diff --git a/tests/test_report_orchestrator_reader_contract.py b/tests/test_report_orchestrator_reader_contract.py new file mode 100644 index 00000000..855ce41f --- /dev/null +++ b/tests/test_report_orchestrator_reader_contract.py @@ -0,0 +1,53 @@ +"""Reader-report and commercial Skill import contracts.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from scripts.report_orchestrator import render_reader_report + +ROOT = Path(__file__).resolve().parents[1] + + +def test_render_reader_report_defaults_to_collapsed_appendix_and_keeps_order() -> None: + rendered = render_reader_report({ + "thematic_narrative": {"career": {"text": "Provided narrative"}}, + "evidence_appendix": {"raw_data": {"Sun": 12.3}}, + "executive_summary": {"headline": "Provided summary"}, + }) + assert list(rendered) == ["presentation_mode", "executive_summary", "thematic_narrative", "evidence_appendix"] + assert rendered["presentation_mode"] == "default" + assert rendered["evidence_appendix"]["expanded"] is False + + +def test_render_reader_report_expands_only_research_and_preserves_extensions() -> None: + rendered = render_reader_report({ + "presentation_mode": "research", + "executive_summary": {"title": "Research"}, + "thematic_narrative": {}, + "evidence_appendix": {"raw_data": {"Moon": 3.4}}, + "commercial_extension": {"health": True}, + }) + assert list(rendered) == [ + "presentation_mode", "executive_summary", "thematic_narrative", "evidence_appendix", "commercial_extension", + ] + assert rendered["evidence_appendix"]["expanded"] is True + assert rendered["commercial_extension"] == {"health": True} + + +def test_upstream_skill_snapshot_matches_manifest_and_commercial_root_remains_router() -> None: + snapshot = ROOT / "references/upstream/yinduzhanxing/SKILL.md" + manifest = json.loads((snapshot.parent / "source-manifest.json").read_text(encoding="utf-8")) + root_skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") + linked_skill = ROOT / "skills/jyotish-vedic-astrology/SKILL.md" + + assert hashlib.sha256(snapshot.read_bytes()).hexdigest() == manifest["skill_sha256"] + assert manifest["source_mode"] == "snapshot" + assert manifest["source_commit"] is None + assert linked_skill.resolve() == (ROOT / "SKILL.md").resolve() + assert "商业运行时路由(最高优先级)" in root_skill + assert "references/upstream/yinduzhanxing/SKILL.md" in root_skill + assert "consumer_context.answer_policy" in root_skill + assert "executive_summary -> thematic_narrative -> evidence_appendix" in root_skill + assert (ROOT / "SKILL.md").read_bytes() != snapshot.read_bytes() diff --git a/tests/test_unified_consultation_orchestrator.py b/tests/test_unified_consultation_orchestrator.py index ed009ae7..c26fd40e 100644 --- a/tests/test_unified_consultation_orchestrator.py +++ b/tests/test_unified_consultation_orchestrator.py @@ -3,8 +3,52 @@ from __future__ import annotations -from scripts.western_evidence_packet import build_western_evidence_packet from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator +from scripts.western_evidence_packet import build_western_evidence_packet + + +def test_route_profile_and_reader_report_preserve_commercial_themes_and_blocked_boundary() -> None: + orchestrator = UnifiedConsultationOrchestrator() + commercial_themes = ["health", "migration", "family", "annual"] + for theme in commercial_themes: + profile = orchestrator.route_profile("研究模式原始数据", [theme]) + assert profile["themes"] == [theme] + assert profile["presentation_mode"] == "research" + assert profile["appendix_expanded"] is True + + report = orchestrator.build_reader_report( + orchestrator.route_profile("请看事业", ["career"]), + {"career": {"summary": "事业一定会成功。事业一定会成功。", "status": "used"}}, + raw_data={"calculation_hash": "synthetic"}, + technique_audit=[{"technique": "D10", "status": "blocked"}], + conflicts=[{"reason": "synthetic conflict"}], + ) + assert list(report) == ["executive_summary", "thematic_narrative", "evidence_appendix"] + assert "一定" not in report["thematic_narrative"]["career"]["summary"] + assert report["thematic_narrative"]["career"]["summary"].count("事业") == 1 + assert report["evidence_appendix"]["blocked_techniques"] == ["D10"] + assert report["evidence_appendix"]["technique_audit"][0]["confidence_label"] == "blocked" + + +def test_reader_report_exposes_parallel_system_views_without_majority_vote() -> None: + report = UnifiedConsultationOrchestrator().build_reader_report( + {"themes": ["career"], "presentation_mode": "default"}, + {"career": { + "summary": "条件性结论", + "jyotish_summary": "D10 视图", + "western_summary": "Solar Return 视图", + "consensus_summary": "只记录共同方向,不作多数投票", + }}, + technique_audit=[ + {"technique": "D10", "status": "used"}, + {"technique": "Solar Return", "status": "partial"}, + {"technique": "Cross-System Arbitration", "status": "used"}, + ], + ) + views = report["thematic_narrative"]["career"]["system_views"] + assert views == {"jyotish": "D10 视图", "western": "Solar Return 视图", "consensus": "只记录共同方向,不作多数投票"} + rows = report["evidence_appendix"]["technique_audit"] + assert [row["system"] for row in rows] == ["jyotish", "western", "cross_system"] def test_unified_consultation_orchestrator_normalizes_themes_and_route() -> None: @@ -63,6 +107,8 @@ def test_unified_consultation_orchestrator_exposes_surface_agnostic_contract() - assert contract["themes"] == ["marriage"] assert contract["source_priority"]["mode"] == "vedastro_official_snapshot_first" assert contract["source_priority"]["priority"][0] == "vedastro_official_snapshot" + assert contract["route_profile_contract"]["route"] == "relationship" + assert "Technique Audit Table" in contract["route_profile_contract"]["execution_boundary"] def test_unified_consultation_orchestrator_builds_runtime_planner() -> None: -- 2.54.0 From 82dab96b073a6a1e0451c3ed83b8d67c4eeb0387 Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 12:43:10 +0800 Subject: [PATCH 3/9] feat(report): add personal report contract and persistence --- .../report-document.v1.schema.json | 432 ++++++++++ .../20260806000000_personal_reports.sql | 104 +++ .../personal-report-contract.server-core.ts | 51 ++ .../lib/personal-report-contract.server.ts | 3 + frontend/src/lib/personal-report-contract.ts | 413 ++++++++++ .../src/lib/personal-report-service-core.ts | 518 ++++++++++++ frontend/src/lib/personal-report-service.ts | 3 + .../20260806010000_personal_reports.sql | 100 +++ .../tests/personal-report-contract.test.ts | 186 +++++ .../tests/personal-report-migration.test.ts | 129 +++ .../tests/personal-report-service.test.ts | 392 +++++++++ scripts/personal_report_contract.py | 771 ++++++++++++++++++ .../fixtures/personal_report_document.v1.json | 399 +++++++++ tests/test_personal_report_contract.py | 295 +++++++ 14 files changed, 3796 insertions(+) create mode 100644 contracts/personal-report/report-document.v1.schema.json create mode 100644 frontend/db/migrations/20260806000000_personal_reports.sql create mode 100644 frontend/src/lib/personal-report-contract.server-core.ts create mode 100644 frontend/src/lib/personal-report-contract.server.ts create mode 100644 frontend/src/lib/personal-report-contract.ts create mode 100644 frontend/src/lib/personal-report-service-core.ts create mode 100644 frontend/src/lib/personal-report-service.ts create mode 100644 frontend/supabase/migrations/20260806010000_personal_reports.sql create mode 100644 frontend/tests/personal-report-contract.test.ts create mode 100644 frontend/tests/personal-report-migration.test.ts create mode 100644 frontend/tests/personal-report-service.test.ts create mode 100644 scripts/personal_report_contract.py create mode 100644 tests/fixtures/personal_report_document.v1.json create mode 100644 tests/test_personal_report_contract.py diff --git a/contracts/personal-report/report-document.v1.schema.json b/contracts/personal-report/report-document.v1.schema.json new file mode 100644 index 00000000..5a15a950 --- /dev/null +++ b/contracts/personal-report/report-document.v1.schema.json @@ -0,0 +1,432 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://jyotisha.chat/contracts/personal-report/report-document.v1.schema.json", + "title": "ReportDocument v1", + "description": "Server-issued personal astrology report document. This contract is enforced identically by the JSON Schema below, frontend/src/lib/personal-report-contract.ts (Zod), and scripts/personal_report_contract.py (stdlib Python validator). Semantics that JSON Schema draft-07 cannot express are enforced by both runtime validators and their tests: (1) charts must contain exactly one D1 chart, chart ids must be unique, and the D1 chart must contain all twelve house numbers 1..12 (enough real houses to render without fabrication); (2) every houseNumber must be unique within its chart; (3) evidence ids (id fields of techniqueAudit, conflicts and calculationEvidence rows) must be globally unique across the whole evidence appendix so evidenceRefs are never ambiguous; (4) provenance.evidenceHash is a deterministic recomputation over the evidence appendix (techniqueAudit, conflicts, calculationEvidence in canonical field order) - it is never trusted as a model self-report; the cryptographic hash is verified by the server runtime (frontend/src/lib/personal-report-contract.server.ts) and by the Python validator (scripts/personal_report_contract.py), and a document whose evidenceHash does not equal the recomputed value is rejected; the isomorphic frontend contract validates structure only and never recomputes the hash; (5) every entry in evidenceRefs must reference an id present in evidenceAppendix.techniqueAudit, evidenceAppendix.conflicts, or evidenceAppendix.calculationEvidence; (6) sections whose claimStatus is blocked must not contain deterministic predictions (e.g. 必然, 必定, 一定会, 肯定会, 绝对会, guaranteed, definitely will); (7) the UTF-8 JSON serialization of the whole document must not exceed 1572864 bytes (1.5 MiB). Fixed reader order: this schema defines the display sequence executiveSummary, thematicNarrative, evidenceAppendix as a UI/type-level presentation contract; JSON object key order is not validated (objects are unordered by definition). Privacy rule: subject/provenance metadata must never repeat full birth date, precise coordinates, or a raw chart payload. Content rule: no HTML/JS/CSS, no executable URLs (javascript:, vbscript:, data:text/html, file:), no internal filesystem paths, no prompt/tool traces or exception stacks, no model secrets or JWTs.", + "type": "object", + "definitions": { + "claimStatus": { + "type": "string", + "enum": [ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked" + ] + }, + "evidenceId": { + "type": "string", + "pattern": "^ev-[a-z0-9_-]{1,63}$", + "minLength": 4, + "maxLength": 67 + }, + "sha256Hex": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "iso8601": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "minLength": 20, + "maxLength": 40 + }, + "house": { + "type": "object", + "additionalProperties": false, + "properties": { + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "occupants": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "maxItems": 12 + } + }, + "required": ["houseNumber", "sign", "occupants"] + }, + "planet": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "sign": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "longitudeDegrees": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 360 + }, + "houseNumber": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "retrograde": { + "type": "boolean" + } + }, + "required": ["name", "sign", "longitudeDegrees", "houseNumber", "retrograde"] + }, + "chart": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "enum": ["D1", "D9", "D10"] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "houses": { + "type": "array", + "items": { + "$ref": "#/definitions/house" + }, + "maxItems": 12 + }, + "planets": { + "type": "array", + "items": { + "$ref": "#/definitions/planet" + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["id", "title", "houses", "claimStatus"] + }, + "thematicSection": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$", + "minLength": 1, + "maxLength": 64 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "narrative": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "actions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "caveats": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "maxItems": 12 + }, + "claimStatus": { + "$ref": "#/definitions/claimStatus" + }, + "evidenceRefs": { + "type": "array", + "items": { + "$ref": "#/definitions/evidenceId" + }, + "maxItems": 24 + } + }, + "required": ["id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"] + }, + "techniqueAuditRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "techniqueId": { + "type": "string", + "pattern": "^[a-z0-9_.-]{1,80}$", + "minLength": 1, + "maxLength": 80 + }, + "techniqueName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "status": { + "type": "string", + "enum": ["verified", "partial", "blocked"] + }, + "used": { + "type": "boolean" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + }, + "required": ["id", "techniqueId", "techniqueName", "status", "used"] + }, + "conflictRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "impact": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "status": { + "type": "string", + "enum": ["unresolved", "partial", "resolved"] + } + }, + "required": ["id", "description", "impact", "status"] + }, + "calculationEvidenceRow": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/evidenceId" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["id", "label", "value", "source"] + } + }, + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "const": "report_document.v1" + }, + "reportId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "reportType": { + "type": "string", + "enum": ["personal_full", "personal_thematic"] + }, + "presentationMode": { + "type": "string", + "enum": ["default", "research"] + }, + "generatedAt": { + "$ref": "#/definitions/iso8601" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "birthTimeStatus": { + "type": "string", + "enum": ["reported", "candidate", "accepted", "confirmed"] + }, + "birthPlaceLabel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": ["displayName", "birthTimeStatus", "birthPlaceLabel"] + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "skillSourceCommit": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{40}$", + "minLength": 40, + "maxLength": 40 + }, + "skillSnapshotSha256": { + "$ref": "#/definitions/sha256Hex" + }, + "calculationHash": { + "$ref": "#/definitions/sha256Hex" + }, + "evidenceHash": { + "$ref": "#/definitions/sha256Hex" + }, + "reportContractVersion": { + "type": "string", + "const": "1" + } + }, + "required": ["skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"] + }, + "executiveSummary": { + "type": "object", + "additionalProperties": false, + "properties": { + "headline": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "priorities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "maxItems": 8 + }, + "overallClaimStatus": { + "$ref": "#/definitions/claimStatus" + } + }, + "required": ["headline", "summary", "priorities", "overallClaimStatus"] + }, + "charts": { + "type": "array", + "items": { + "$ref": "#/definitions/chart" + }, + "minItems": 1, + "maxItems": 3 + }, + "thematicNarrative": { + "type": "array", + "items": { + "$ref": "#/definitions/thematicSection" + }, + "maxItems": 12 + }, + "evidenceAppendix": { + "type": "object", + "additionalProperties": false, + "properties": { + "expandedByDefault": { + "type": "boolean" + }, + "techniqueAudit": { + "type": "array", + "items": { + "$ref": "#/definitions/techniqueAuditRow" + }, + "maxItems": 100 + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/definitions/conflictRow" + }, + "maxItems": 50 + }, + "calculationEvidence": { + "type": "array", + "items": { + "$ref": "#/definitions/calculationEvidenceRow" + }, + "maxItems": 100 + }, + "blockedTechniques": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "maxItems": 100 + } + }, + "required": ["expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"] + }, + "disclaimer": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": [ + "schemaVersion", + "reportId", + "reportType", + "presentationMode", + "generatedAt", + "subject", + "provenance", + "executiveSummary", + "charts", + "thematicNarrative", + "evidenceAppendix", + "disclaimer" + ] +} diff --git a/frontend/db/migrations/20260806000000_personal_reports.sql b/frontend/db/migrations/20260806000000_personal_reports.sql new file mode 100644 index 00000000..2a8a7ee7 --- /dev/null +++ b/frontend/db/migrations/20260806000000_personal_reports.sql @@ -0,0 +1,104 @@ +-- Personal report persistence for self-hosted PostgreSQL (staging). +-- Mirrors supabase/migrations/20260806010000_personal_reports.sql +-- one-to-one in table shape, constraints, RLS and grants. +-- +-- Ownership model: rows are owned by auth.users(id) (the business-auth +-- mirror kept in sync by identity.sync_user_to_business_auth). Normal +-- application sessions connect as app_runtime (member of authenticated) and +-- set local role authenticated; they may select/delete only their own rows +-- through RLS plus the explicit owner grants below, and can never +-- insert/update (generation and status writes are performed exclusively +-- through service_role, which has BYPASSRLS and full table privileges). +-- admin_runtime has no direct access to report bodies (least privilege); +-- server-side generation runs through service_role, which admin_runtime may +-- SET ROLE to. +-- +-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a +-- known requestId requires the same request_fingerprint (a sha256 of the +-- caller's request intent), so a different payload under the same requestId +-- surfaces as request_conflict instead of silently overwriting. Failed +-- retries are not implicitly upserted here; callers either reuse the failed +-- record via a new requestId or surface the stable failure. +-- +-- No birth details, report bodies, model prompts or exception stacks are ever +-- written to logs, index columns or audit events; failure_code is a stable +-- enum shared with frontend/src/lib/personal-report-service.ts and +-- scripts/personal_report_contract.py. + +create table if not exists public.personal_reports ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid, + chart_profile_id uuid, + request_id uuid not null, + request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), + report_type text not null check (report_type in ('personal_full', 'personal_thematic')), + status text not null check (status in ('generating', 'ready', 'failed')), + schema_version text not null check (schema_version = 'report_document.v1'), + presentation_mode text not null check (presentation_mode in ('default', 'research')), + requested_themes text[] not null default '{}'::text[], + report_document jsonb, + calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), + evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), + skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), + skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + failure_code text check (failure_code in ( + 'profile_incomplete', + 'birth_time_not_usable', + 'report_generation_in_progress', + 'report_rate_limited', + 'calculation_unavailable', + 'model_unavailable', + 'report_schema_invalid', + 'report_guard_rejected', + 'report_not_found' + )), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + check ((status = 'ready') = (report_document is not null)), + check ((status = 'ready') = (completed_at is not null)), + check ((status = 'ready') = (calculation_hash is not null)), + check ((status = 'ready') = (evidence_hash is not null)), + check ((status = 'failed') = (failure_code is not null)), + unique (user_id, request_id) +); + +create index if not exists personal_reports_user_created_idx + on public.personal_reports (user_id, created_at desc); + +-- One in-flight generation per user, enforced by the database so a second +-- request cannot start while the first is still generating. +create unique index if not exists personal_reports_one_generating_per_user + on public.personal_reports (user_id) + where status = 'generating'; + +alter table public.personal_reports enable row level security; + +revoke all on table public.personal_reports from public, anon, authenticated, service_role; +revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader; + +drop policy if exists personal_reports_select_own on public.personal_reports; +create policy personal_reports_select_own + on public.personal_reports + for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists personal_reports_delete_own on public.personal_reports; +create policy personal_reports_delete_own + on public.personal_reports + for delete + to authenticated + using (auth.uid() = user_id); + +-- Normal users can never insert or update rows: creating a generating record +-- and moving it to ready/failed are server-side operations only. RLS +-- policies alone do not grant table privileges, so the owner read/delete +-- grants below are required for the policies to be reachable. +grant select, delete on table public.personal_reports to authenticated; + +-- admin_runtime intentionally has no direct access to report bodies (least +-- privilege); server-side generation runs through service_role, which +-- admin_runtime may SET ROLE to. +grant select, insert, update, delete on table public.personal_reports to service_role; diff --git a/frontend/src/lib/personal-report-contract.server-core.ts b/frontend/src/lib/personal-report-contract.server-core.ts new file mode 100644 index 00000000..0095a0f4 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.server-core.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import { + canonicalEvidence, + safeParseReportDocument, + ReportDocumentValidationError, + type EvidenceAppendix, + type ReportDocumentParseResult, + type ReportDocumentV1, +} from "./personal-report-contract.ts"; + +/** + * Server hash core for ReportDocument v1. + * + * Pure Node implementation (node:crypto) without the server-only marker so + * tests can import it directly; the production entry + * personal-report-contract.server.ts adds `import "server-only"` and + * re-exports this module. Client bundles must never import this file: besides + * the marker on the production entry, node:crypto fails Next.js client builds. + * + * Flow per the architecture ruling: canonical isomorphic parse first, then + * verify provenance.evidenceHash against the recomputed hash. The hash is + * never trusted as a model self-report. The Python validator + * (scripts/personal_report_contract.py) performs the same recomputation. + */ + +export function computeEvidenceHash(appendix: EvidenceAppendix): string { + const canonical = canonicalEvidence(appendix); + return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex"); +} + +export function safeParseServerReportDocument(input: unknown): ReportDocumentParseResult { + const parsed = safeParseReportDocument(input); + if (!parsed.ok) return parsed; + const recomputed = computeEvidenceHash(parsed.document.evidenceAppendix); + if (parsed.document.provenance.evidenceHash !== recomputed) { + return { + ok: false, + errors: [{ + path: "provenance.evidenceHash", + message: `does not match recomputed evidence hash ${recomputed}`, + }], + }; + } + return parsed; +} + +export function parseServerReportDocument(input: unknown): ReportDocumentV1 { + const result = safeParseServerReportDocument(input); + if (!result.ok) throw new ReportDocumentValidationError(result.errors); + return result.document; +} diff --git a/frontend/src/lib/personal-report-contract.server.ts b/frontend/src/lib/personal-report-contract.server.ts new file mode 100644 index 00000000..2b341bc2 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.server.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export * from "./personal-report-contract.server-core"; diff --git a/frontend/src/lib/personal-report-contract.ts b/frontend/src/lib/personal-report-contract.ts new file mode 100644 index 00000000..0d551db9 --- /dev/null +++ b/frontend/src/lib/personal-report-contract.ts @@ -0,0 +1,413 @@ +/** + * ReportDocument v1 contract (isomorphic Zod side). + * + * This file is importable from server and client bundles: it contains no + * node:crypto and no hash recomputation. Semantics are shared with: + * - contracts/personal-report/report-document.v1.schema.json (JSON Schema) + * - scripts/personal_report_contract.py (stdlib Python validator) + * - frontend/src/lib/personal-report-contract.server-core.ts (server hash) + * + * JSON Schema draft-07 cannot express every rule; the runtime-enforced + * semantics below (chart-set invariants, evidenceRefs existence, blocked + * non-determinism, forbidden content, evidence-id uniqueness, serialization + * cap) are implemented identically in this file and in the Python validator, + * with tests on both sides. The cryptographic evidence hash is verified only + * by the server runtime (personal-report-contract.server.ts) and the Python + * validator; it is never part of this isomorphic parse. + */ + +import { z } from "zod"; + +export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const; +export const REPORT_CONTRACT_VERSION = "1" as const; +export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap. + +export const CLAIM_STATUSES = [ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked", +] as const; +export type ClaimStatus = (typeof CLAIM_STATUSES)[number]; + +export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const; +export const PRESENTATION_MODES = ["default", "research"] as const; +export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const; +export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const; +export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const; +export const CHART_IDS = ["D1", "D9", "D10"] as const; + +const claimStatusSchema = z.enum(CLAIM_STATUSES); +const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id"); +const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex"); +const iso8601Schema = z.string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp"); + +const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength); +const textArray = (maxItems: number, maxLength: number) => z.array(text(maxLength)).max(maxItems); + +const houseSchema = z.strictObject({ + houseNumber: z.number().int().min(1).max(12), + sign: text(40), + occupants: textArray(12, 40), +}); + +const planetSchema = z.strictObject({ + name: text(40), + sign: text(40), + longitudeDegrees: z.number().min(0).lt(360), + houseNumber: z.number().int().min(1).max(12), + retrograde: z.boolean(), +}); + +const chartSchema = z.strictObject({ + id: z.enum(CHART_IDS), + title: text(120), + houses: z.array(houseSchema).max(12), + planets: z.array(planetSchema).max(12).optional(), + claimStatus: claimStatusSchema, +}); + +const thematicSectionSchema = z.strictObject({ + id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"), + title: text(160), + narrative: text(4000), + actions: textArray(12, 400), + caveats: textArray(12, 400), + claimStatus: claimStatusSchema, + evidenceRefs: z.array(evidenceIdSchema).max(24), +}); + +const techniqueAuditRowSchema = z.strictObject({ + id: evidenceIdSchema, + techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"), + techniqueName: text(160), + status: z.enum(TECHNIQUE_STATUSES), + used: z.boolean(), + notes: z.string().max(500).optional(), +}); + +const conflictRowSchema = z.strictObject({ + id: evidenceIdSchema, + description: text(1000), + impact: text(500), + status: z.enum(CONFLICT_STATUSES), +}); + +const calculationEvidenceRowSchema = z.strictObject({ + id: evidenceIdSchema, + label: text(160), + value: text(500), + source: text(200), +}); + +const evidenceAppendixSchema = z.strictObject({ + expandedByDefault: z.boolean(), + techniqueAudit: z.array(techniqueAuditRowSchema).max(100), + conflicts: z.array(conflictRowSchema).max(50), + calculationEvidence: z.array(calculationEvidenceRowSchema).max(100), + blockedTechniques: textArray(100, 120), +}); + +const reportDocumentShape = { + schemaVersion: z.literal(REPORT_DOCUMENT_SCHEMA_VERSION), + reportId: z.string().uuid(), + reportType: z.enum(REPORT_TYPES), + presentationMode: z.enum(PRESENTATION_MODES), + generatedAt: iso8601Schema, + subject: z.strictObject({ + displayName: text(120), + birthTimeStatus: z.enum(BIRTH_TIME_STATUSES), + birthPlaceLabel: text(200), + }), + provenance: z.strictObject({ + skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(), + skillSnapshotSha256: sha256HexSchema, + calculationHash: sha256HexSchema, + evidenceHash: sha256HexSchema, + reportContractVersion: z.literal(REPORT_CONTRACT_VERSION), + }), + executiveSummary: z.strictObject({ + headline: text(200), + summary: text(2000), + priorities: textArray(8, 200), + overallClaimStatus: claimStatusSchema, + }), + charts: z.array(chartSchema).min(1).max(3), + thematicNarrative: z.array(thematicSectionSchema).max(12), + evidenceAppendix: evidenceAppendixSchema, + disclaimer: text(2000), +}; + +export const reportDocumentSchema = z.strictObject(reportDocumentShape); + +export type ReportDocumentV1 = z.infer; +export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"]; +export type ChartV1 = ReportDocumentV1["charts"][number]; +export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][number]; + +export type ReportDocumentParseError = Readonly<{ + path: string; + message: string; +}>; + +export class ReportDocumentValidationError extends Error { + readonly errors: readonly ReportDocumentParseError[]; + + constructor(errors: readonly ReportDocumentParseError[]) { + super(errors.map((error) => `${error.path}: ${error.message}`).join("; ")); + this.name = "ReportDocumentValidationError"; + this.errors = errors; + } +} + +/** Canonical evidence object used by the evidence hash on both language sides. */ +export function canonicalEvidence(appendix: EvidenceAppendix): Record { + return { + techniqueAudit: appendix.techniqueAudit.map((row) => ({ + id: row.id, + techniqueId: row.techniqueId, + techniqueName: row.techniqueName, + status: row.status, + used: row.used, + ...(row.notes !== undefined ? { notes: row.notes } : {}), + })), + conflicts: appendix.conflicts.map((row) => ({ + id: row.id, + description: row.description, + impact: row.impact, + status: row.status, + })), + calculationEvidence: appendix.calculationEvidence.map((row) => ({ + id: row.id, + label: row.label, + value: row.value, + source: row.source, + })), + }; +} + +export function serializedReportDocumentBytes(document: ReportDocumentV1): number { + return new TextEncoder().encode(JSON.stringify(document)).length; +} + +/** + * Forbidden content patterns. Keep byte-for-byte equivalent to + * FORBIDDEN_PATTERNS in scripts/personal_report_contract.py. + */ +export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [ + { name: "html_tag_open", pattern: /<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b/i }, + { name: "html_tag_close", pattern: /<\/\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>/i }, + { name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i }, + { name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i }, + { name: "processing_instruction", pattern: /<\?/i }, + { name: "template_literal", pattern: /\$\{/i }, + { name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i }, + { name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i }, + { name: "process_env", pattern: /\bprocess\.env\b/i }, + { name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i }, + { name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i }, + { name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i }, + { name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i }, + { name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i }, + { name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i }, +]; + +/** + * Deterministic-prediction phrases forbidden inside blocked sections. + * Keep equivalent to DETERMINISTIC_PHRASES in the Python validator. + */ +export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [ + "必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑", + "guaranteed", "definitely will", "certainly will", "will certainly", "is certain to", +]; + +export function findForbiddenContent(value: string): readonly string[] { + return FORBIDDEN_CONTENT_PATTERNS + .filter(({ pattern }) => pattern.test(value)) + .map(({ name }) => name); +} + +function blockedTexts(document: ReportDocumentV1): readonly Readonly<{ path: string; text: string }>[] { + const entries: { path: string; text: string }[] = []; + if (document.executiveSummary.overallClaimStatus === "blocked") { + entries.push({ path: "executiveSummary.headline", text: document.executiveSummary.headline }); + entries.push({ path: "executiveSummary.summary", text: document.executiveSummary.summary }); + document.executiveSummary.priorities.forEach((priority, index) => { + entries.push({ path: `executiveSummary.priorities[${index}]`, text: priority }); + }); + } + document.charts.forEach((chart, index) => { + if (chart.claimStatus === "blocked") { + entries.push({ path: `charts[${index}].title`, text: chart.title }); + } + }); + document.thematicNarrative.forEach((section, index) => { + if (section.claimStatus !== "blocked") return; + entries.push({ path: `thematicNarrative[${index}].title`, text: section.title }); + entries.push({ path: `thematicNarrative[${index}].narrative`, text: section.narrative }); + section.actions.forEach((action, actionIndex) => { + entries.push({ path: `thematicNarrative[${index}].actions[${actionIndex}]`, text: action }); + }); + section.caveats.forEach((caveat, caveatIndex) => { + entries.push({ path: `thematicNarrative[${index}].caveats[${caveatIndex}]`, text: caveat }); + }); + }); + return entries; +} + +export function findBlockedDeterministicClaims(document: ReportDocumentV1): readonly string[] { + const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i")); + return blockedTexts(document) + .filter(({ text }) => phrases.some((pattern) => pattern.test(text))) + .map(({ path }) => path); +} + +export function findDanglingEvidenceRefs(document: ReportDocumentV1): readonly string[] { + const knownIds = new Set([ + ...document.evidenceAppendix.techniqueAudit.map((row) => row.id), + ...document.evidenceAppendix.conflicts.map((row) => row.id), + ...document.evidenceAppendix.calculationEvidence.map((row) => row.id), + ]); + return document.thematicNarrative.flatMap((section) => + section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`), + ); +} + +/** Evidence ids must be globally unique across the whole appendix. */ +export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] { + const locations = new Map(); + const duplicates: string[] = []; + const rows: Readonly<{ key: string; index: number; id: string }>[] = [ + ...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })), + ...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })), + ...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })), + ]; + for (const { key, index, id } of rows) { + const location = `${key}[${index}]`; + const first = locations.get(id); + if (first !== undefined) { + duplicates.push(`evidence id ${id} used in both ${first} and ${location}`); + } else { + locations.set(id, location); + } + } + return duplicates; +} + +export function findChartSetViolations(document: ReportDocumentV1): readonly string[] { + const violations: string[] = []; + const ids = document.charts.map((chart) => chart.id); + const d1Count = ids.filter((id) => id === "D1").length; + if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`); + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) violations.push(`duplicate chart id ${id}`); + seen.add(id); + } + const d1 = document.charts.find((chart) => chart.id === "D1"); + if (d1) { + const numbers = d1.houses.map((house) => house.houseNumber); + const unique = new Set(numbers); + if (unique.size !== numbers.length) violations.push("D1 chart contains duplicate house numbers"); + const expected = Array.from({ length: 12 }, (_, index) => index + 1); + if (numbers.length !== 12 || expected.some((number) => !unique.has(number))) { + violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once"); + } + } + return violations; +} + +export function validateReportDocumentGuards(document: ReportDocumentV1): readonly string[] { + const errors: string[] = []; + errors.push(...findChartSetViolations(document)); + errors.push(...findDuplicateEvidenceIds(document)); + errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`)); + errors.push(...findDanglingEvidenceRefs(document).map((ref) => `thematicNarrative.evidenceRefs: unknown evidence id ${ref}`)); + + const forbidden: { path: string; hits: readonly string[] }[] = []; + const collectTexts = (path: string, value: string) => { + const hits = findForbiddenContent(value); + if (hits.length > 0) forbidden.push({ path, hits }); + }; + collectTexts("subject.displayName", document.subject.displayName); + collectTexts("subject.birthPlaceLabel", document.subject.birthPlaceLabel); + collectTexts("executiveSummary.headline", document.executiveSummary.headline); + collectTexts("executiveSummary.summary", document.executiveSummary.summary); + document.executiveSummary.priorities.forEach((priority, index) => collectTexts(`executiveSummary.priorities[${index}]`, priority)); + document.charts.forEach((chart, chartIndex) => { + collectTexts(`charts[${chartIndex}].title`, chart.title); + chart.houses.forEach((house, houseIndex) => { + collectTexts(`charts[${chartIndex}].houses[${houseIndex}].sign`, house.sign); + house.occupants.forEach((occupant, occupantIndex) => collectTexts(`charts[${chartIndex}].houses[${houseIndex}].occupants[${occupantIndex}]`, occupant)); + }); + (chart.planets ?? []).forEach((planet, planetIndex) => { + collectTexts(`charts[${chartIndex}].planets[${planetIndex}].name`, planet.name); + collectTexts(`charts[${chartIndex}].planets[${planetIndex}].sign`, planet.sign); + }); + }); + document.thematicNarrative.forEach((section, sectionIndex) => { + collectTexts(`thematicNarrative[${sectionIndex}].title`, section.title); + collectTexts(`thematicNarrative[${sectionIndex}].narrative`, section.narrative); + section.actions.forEach((action, actionIndex) => collectTexts(`thematicNarrative[${sectionIndex}].actions[${actionIndex}]`, action)); + section.caveats.forEach((caveat, caveatIndex) => collectTexts(`thematicNarrative[${sectionIndex}].caveats[${caveatIndex}]`, caveat)); + }); + document.evidenceAppendix.techniqueAudit.forEach((row, rowIndex) => { + collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].techniqueName`, row.techniqueName); + if (row.notes !== undefined) collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].notes`, row.notes); + }); + document.evidenceAppendix.conflicts.forEach((row, rowIndex) => { + collectTexts(`evidenceAppendix.conflicts[${rowIndex}].description`, row.description); + collectTexts(`evidenceAppendix.conflicts[${rowIndex}].impact`, row.impact); + }); + document.evidenceAppendix.calculationEvidence.forEach((row, rowIndex) => { + collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].label`, row.label); + collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].value`, row.value); + collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].source`, row.source); + }); + document.evidenceAppendix.blockedTechniques.forEach((technique, rowIndex) => { + collectTexts(`evidenceAppendix.blockedTechniques[${rowIndex}]`, technique); + }); + collectTexts("disclaimer", document.disclaimer); + forbidden.forEach(({ path, hits }) => errors.push(`${path}: forbidden content ${hits.join(",")}`)); + + const size = serializedReportDocumentBytes(document); + if (size > REPORT_DOCUMENT_MAX_BYTES) { + errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`); + } + return errors; +} + +export type ReportDocumentParseResult = + | Readonly<{ ok: true; document: ReportDocumentV1 }> + | Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>; + +export function safeParseReportDocument(input: unknown): ReportDocumentParseResult { + const parsed = reportDocumentSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + errors: parsed.error.issues.map((issue) => ({ + path: issue.path.join(".") || "(root)", + message: issue.message, + })), + }; + } + const document = parsed.data; + const guardErrors = validateReportDocumentGuards(document); + if (guardErrors.length > 0) { + return { + ok: false, + errors: guardErrors.map((message) => ({ path: "(guard)", message })), + }; + } + return { ok: true, document }; +} + +export function parseReportDocument(input: unknown): ReportDocumentV1 { + const result = safeParseReportDocument(input); + if (!result.ok) throw new ReportDocumentValidationError(result.errors); + return result.document; +} diff --git a/frontend/src/lib/personal-report-service-core.ts b/frontend/src/lib/personal-report-service-core.ts new file mode 100644 index 00000000..538dae6b --- /dev/null +++ b/frontend/src/lib/personal-report-service-core.ts @@ -0,0 +1,518 @@ +import { + parseServerReportDocument, + computeEvidenceHash, +} from "./personal-report-contract.server-core.ts"; +import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts"; +import type { ReportDocumentV1 } from "./personal-report-contract.ts"; + +/** + * Server-only persistence layer for personal reports (pure core). + * + * Routing: production callers pass the result of createServerSupabaseClient() + * (which already resolves self-hosted PostgreSQL vs Supabase). This module + * never inspects query-builder or pg specifics: it depends on the narrow + * PersonalReportDataClient port below, so unit tests can inject an in-memory + * fake and neither Supabase QueryBuilder nor node-postgres shapes leak into + * the API layer. The production entry personal-report-service.ts adds + * `import "server-only"` and re-exports this module. + * + * Ownership: every operation is scoped by userId; completeReady additionally + * verifies the validated document's reportId against the row id and the row's + * stored hash columns against the document (hashes are recomputed here, never + * trusted as model self-report). + * + * Idempotency: unique (user_id, request_id) is the primary lock. Replaying a + * known requestId requires the same requestFingerprint; a different + * fingerprint under the same requestId returns request_conflict instead of + * silently overwriting. Failed records are never implicitly resurrected by + * this service; callers reuse a failed record only via a new requestId. + * + * Privacy: this module never logs, indexes or returns birth details, report + * bodies, prompts, model text or exception stacks. failures carry a stable + * failure_code enum only. + */ + +export const PERSONAL_REPORT_FAILURE_CODES = [ + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", +] as const; +export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[number]; + +export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const; +export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number]; + +export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const; +export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const; + +export type PersonalReportServiceErrorCode = + | "invalid_request" + | "not_found" + | "invalid_state" + | "generation_in_progress" + | "request_conflict" + | "invalid_document" + | "invalid_failure_code" + | "storage_failed"; + +export class PersonalReportServiceError extends Error { + readonly code: PersonalReportServiceErrorCode; + + constructor(code: PersonalReportServiceErrorCode, message?: string) { + super(message ?? `Personal report service error: ${code}`); + this.name = "PersonalReportServiceError"; + this.code = code; + } +} + +export type PersonalReportRecord = Readonly<{ + id: string; + userId: string; + sessionId: string | null; + chartProfileId: string | null; + requestId: string; + requestFingerprint: string; + reportType: (typeof PERSONAL_REPORT_TYPES)[number]; + status: PersonalReportStatus; + schemaVersion: string; + presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number]; + requestedThemes: readonly string[]; + reportDocument: ReportDocumentV1 | null; + calculationHash: string | null; + evidenceHash: string | null; + skillSourceCommit: string | null; + skillSnapshotSha256: string; + failureCode: PersonalReportFailureCode | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +}>; + +export type CreateGeneratingInput = Readonly<{ + userId: string; + requestId: string; + requestFingerprint: string; + reportType: (typeof PERSONAL_REPORT_TYPES)[number]; + presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number]; + requestedThemes?: readonly string[]; + sessionId?: string | null; + chartProfileId?: string | null; + skillSourceCommit?: string | null; + skillSnapshotSha256: string; +}>; + +export type CreateGeneratingResult = + | Readonly<{ kind: "created"; record: PersonalReportRecord }> + | Readonly<{ kind: "replayed"; record: PersonalReportRecord }> + | Readonly<{ kind: "request_conflict"; record: PersonalReportRecord }> + | Readonly<{ kind: "generation_in_progress"; record: PersonalReportRecord }>; + +export type PersonalReportQueryResult = Readonly<{ + data: unknown; + error: Readonly<{ message: string; code?: string }> | null; + count?: number | null; +}>; + +/** Narrow structural port implemented by the in-memory fake and the adapter. */ +export interface PersonalReportQueryBuilder extends PromiseLike { + select(columns: string): PersonalReportQueryBuilder; + insert(row: Readonly>): PersonalReportQueryBuilder; + update(values: Readonly>): PersonalReportQueryBuilder; + delete(options?: Readonly<{ count?: string }>): PersonalReportQueryBuilder; + eq(column: string, value: unknown): PersonalReportQueryBuilder; + order(column: string, options?: Readonly<{ ascending?: boolean }>): PersonalReportQueryBuilder; + limit(value: number): PersonalReportQueryBuilder; + maybeSingle(): PromiseLike; + single(): PromiseLike; +} + +export interface PersonalReportDataClient { + from(table: "personal_reports"): PersonalReportQueryBuilder; +} + +export type PersonalReportServiceDeps = Readonly<{ + now?: () => Date; +}>; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const sha256Pattern = /^[0-9a-f]{64}$/; +const sha1Pattern = /^[0-9a-f]{40}$/; + +const RECORD_COLUMNS = [ + "id", + "user_id", + "session_id", + "chart_profile_id", + "request_id", + "request_fingerprint", + "report_type", + "status", + "schema_version", + "presentation_mode", + "requested_themes", + "report_document", + "calculation_hash", + "evidence_hash", + "skill_source_commit", + "skill_snapshot_sha256", + "failure_code", + "created_at", + "updated_at", + "completed_at", +].join(","); + +type DbRow = Readonly>; + +function requireUuid(value: unknown, field: string): string { + if (typeof value !== "string" || !uuidPattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a uuid`); + } + return value; +} + +function optionalUuid(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + return requireUuid(value, field); +} + +function requireSha256(value: unknown, field: string): string { + if (typeof value !== "string" || !sha256Pattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a 64-char sha256 hex`); + } + return value; +} + +function optionalHash(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + return requireSha256(value, field); +} + +function optionalCommit(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "string" || !sha1Pattern.test(value)) { + throw new PersonalReportServiceError("invalid_request", `${field} must be a 40-char commit sha`); + } + return value; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | null { + if (!row) return null; + const requestedThemes = Array.isArray(row.requested_themes) + ? row.requested_themes.filter((theme): theme is string => typeof theme === "string") + : []; + const reportDocument = row.report_document === null || row.report_document === undefined + ? null + : row.report_document as ReportDocumentV1; + return { + id: stringOrNull(row.id) ?? "", + userId: stringOrNull(row.user_id) ?? "", + sessionId: stringOrNull(row.session_id), + chartProfileId: stringOrNull(row.chart_profile_id), + requestId: stringOrNull(row.request_id) ?? "", + requestFingerprint: stringOrNull(row.request_fingerprint) ?? "", + reportType: row.report_type as PersonalReportRecord["reportType"], + status: row.status as PersonalReportStatus, + schemaVersion: stringOrNull(row.schema_version) ?? "", + presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"], + requestedThemes, + reportDocument, + calculationHash: stringOrNull(row.calculation_hash), + evidenceHash: stringOrNull(row.evidence_hash), + skillSourceCommit: stringOrNull(row.skill_source_commit), + skillSnapshotSha256: stringOrNull(row.skill_snapshot_sha256) ?? "", + failureCode: stringOrNull(row.failure_code) as PersonalReportFailureCode | null, + createdAt: stringOrNull(row.created_at) ?? "", + updatedAt: stringOrNull(row.updated_at) ?? "", + completedAt: stringOrNull(row.completed_at), + }; +} + +function normalizedResult(result: PersonalReportQueryResult): PersonalReportQueryResult { + return { + data: result.data, + error: result.error ? { message: result.error.message, code: result.error.code } : null, + ...(typeof result.count === "number" ? { count: result.count } : {}), + }; +} + +/** + * Wraps any routed client (real Supabase or the local PostgreSQL compatibility + * client) into the narrow port. Every chain step is re-wrapped so callers only + * ever see PersonalReportQueryBuilder shapes. + */ +export function createPersonalReportDataClient(supabase: { + from(table: string): unknown; +}): PersonalReportDataClient { + type AnyBuilder = { + select(columns: string): unknown; + insert(row: unknown): unknown; + update(values: unknown): unknown; + delete(options?: unknown): unknown; + eq(column: string, value: unknown): unknown; + order(column: string, options?: unknown): unknown; + limit(value: number): unknown; + maybeSingle(): PromiseLike; + single(): PromiseLike; + then: PromiseLike["then"]; + }; + + const wrap = (builder: AnyBuilder): PersonalReportQueryBuilder => ({ + select: (columns) => wrap(builder.select(columns) as AnyBuilder), + insert: (row) => wrap(builder.insert(row) as AnyBuilder), + update: (values) => wrap(builder.update(values) as AnyBuilder), + delete: (options) => wrap(builder.delete(options) as AnyBuilder), + eq: (column, value) => wrap(builder.eq(column, value) as AnyBuilder), + order: (column, options) => wrap(builder.order(column, options) as AnyBuilder), + limit: (value) => wrap(builder.limit(value) as AnyBuilder), + maybeSingle: () => builder.maybeSingle().then(normalizedResult), + single: () => builder.single().then(normalizedResult), + then: (onfulfilled, onrejected) => + Promise.resolve(builder.then(normalizedResult)).then(onfulfilled, onrejected), + }); + + return { + from: (table) => wrap(supabase.from(table) as unknown as AnyBuilder), + }; +} + +export function createPersonalReportService( + client: PersonalReportDataClient, + deps: PersonalReportServiceDeps = {}, +): PersonalReportService { + const now = deps.now ?? (() => new Date()); + const records = () => client.from("personal_reports"); + + async function loadRow( + userId: string, + reportId: string, + ): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("id", reportId) + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + async function loadByRequest( + userId: string, + requestId: string, + ): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("user_id", userId) + .eq("request_id", requestId) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + async function anyGeneratingFor(userId: string): Promise { + const { data, error } = await records() + .select(RECORD_COLUMNS) + .eq("user_id", userId) + .eq("status", "generating") + .limit(1) + .maybeSingle(); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return data as DbRow | null; + } + + return { + async createGenerating(input: CreateGeneratingInput): Promise { + const userId = requireUuid(input.userId, "userId"); + const requestId = requireUuid(input.requestId, "requestId"); + const requestFingerprint = requireSha256(input.requestFingerprint, "requestFingerprint"); + if (!PERSONAL_REPORT_TYPES.includes(input.reportType)) { + throw new PersonalReportServiceError("invalid_request", "unsupported reportType"); + } + if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) { + throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode"); + } + optionalUuid(input.sessionId, "sessionId"); + optionalUuid(input.chartProfileId, "chartProfileId"); + optionalHash(input.skillSnapshotSha256, "skillSnapshotSha256"); + optionalCommit(input.skillSourceCommit, "skillSourceCommit"); + const themes = input.requestedThemes ?? []; + if (!Array.isArray(themes) || themes.length > 12 || themes.some((theme) => typeof theme !== "string" || theme.length > 64)) { + throw new PersonalReportServiceError("invalid_request", "invalid requestedThemes"); + } + + const row: Record = { + user_id: userId, + request_id: requestId, + request_fingerprint: requestFingerprint, + report_type: input.reportType, + status: "generating", + schema_version: REPORT_DOCUMENT_SCHEMA_VERSION, + presentation_mode: input.presentationMode, + requested_themes: themes, + skill_snapshot_sha256: input.skillSnapshotSha256, + session_id: input.sessionId ?? null, + chart_profile_id: input.chartProfileId ?? null, + skill_source_commit: input.skillSourceCommit ?? null, + created_at: now().toISOString(), + updated_at: now().toISOString(), + }; + + const { data, error } = await records() + .insert(row) + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return { kind: "created", record: recordFromRow(data as DbRow)! }; + + // The (user_id, request_id) unique constraint already holds this + // request, or the per-user in-flight index rejected a second + // generation. Re-read state instead of trusting backend error codes. + const existing = await loadByRequest(userId, requestId); + if (existing) { + if (existing.request_fingerprint === requestFingerprint) { + return { kind: "replayed", record: recordFromRow(existing)! }; + } + return { kind: "request_conflict", record: recordFromRow(existing)! }; + } + const inFlight = await anyGeneratingFor(userId); + if (inFlight) return { kind: "generation_in_progress", record: recordFromRow(inFlight)! }; + throw new PersonalReportServiceError("storage_failed", error?.message ?? "insert failed"); + }, + + async getByUserAndRequestId(userId: string, requestId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(requestId, "requestId"); + const row = await loadByRequest(userId, requestId); + return recordFromRow(row); + }, + + async getOwnedById(userId: string, reportId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + const row = await loadRow(userId, reportId); + return recordFromRow(row); + }, + + async completeReady( + userId: string, + reportId: string, + document: unknown, + ): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + let parsed: ReportDocumentV1; + try { + parsed = parseServerReportDocument(document); + } catch (error) { + throw new PersonalReportServiceError( + "invalid_document", + error instanceof Error ? error.message : "report document failed contract validation", + ); + } + if (parsed.reportId !== reportId) { + throw new PersonalReportServiceError("invalid_document", "document reportId does not match record id"); + } + const row = await loadRow(userId, reportId); + if (!row) throw new PersonalReportServiceError("not_found"); + if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`); + + // Hashes are recomputed here and cross-checked against the stored row; + // provenance.evidenceHash was already verified by parseServerReportDocument. + const evidenceHash = computeEvidenceHash(parsed.evidenceAppendix); + if (row.evidence_hash !== null && row.evidence_hash !== evidenceHash) { + throw new PersonalReportServiceError("invalid_document", "evidence hash does not match stored row"); + } + if (row.calculation_hash !== null && row.calculation_hash !== parsed.provenance.calculationHash) { + throw new PersonalReportServiceError("invalid_document", "calculation hash does not match stored row"); + } + + const completedAt = now().toISOString(); + const { data, error } = await records() + .update({ + status: "ready", + report_document: parsed, + evidence_hash: evidenceHash, + calculation_hash: parsed.provenance.calculationHash, + completed_at: completedAt, + updated_at: completedAt, + }) + .eq("id", reportId) + .eq("user_id", userId) + .eq("status", "generating") + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return recordFromRow(data as DbRow)!; + const current = await loadRow(userId, reportId); + if (!current) throw new PersonalReportServiceError("not_found"); + throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`); + }, + + async markFailed( + userId: string, + reportId: string, + failureCode: string, + ): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + if (!PERSONAL_REPORT_FAILURE_CODES.includes(failureCode as PersonalReportFailureCode)) { + throw new PersonalReportServiceError("invalid_failure_code", `unsupported failure code ${failureCode}`); + } + const row = await loadRow(userId, reportId); + if (!row) throw new PersonalReportServiceError("not_found"); + if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`); + + const updatedAt = now().toISOString(); + const { data, error } = await records() + .update({ + status: "failed", + failure_code: failureCode, + updated_at: updatedAt, + }) + .eq("id", reportId) + .eq("user_id", userId) + .eq("status", "generating") + .select(RECORD_COLUMNS) + .single(); + if (!error && data) return recordFromRow(data as DbRow)!; + const current = await loadRow(userId, reportId); + if (!current) throw new PersonalReportServiceError("not_found"); + throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`); + }, + + async deleteOwned(userId: string, reportId: string): Promise { + requireUuid(userId, "userId"); + requireUuid(reportId, "reportId"); + const { error, count } = await records() + .delete({ count: "exact" }) + .eq("id", reportId) + .eq("user_id", userId); + if (error) throw new PersonalReportServiceError("storage_failed", error.message); + return (count ?? 0) > 0; + }, + }; +} + +export interface PersonalReportService { + createGenerating(input: CreateGeneratingInput): Promise; + getByUserAndRequestId(userId: string, requestId: string): Promise; + getOwnedById(userId: string, reportId: string): Promise; + completeReady(userId: string, reportId: string, document: unknown): Promise; + markFailed(userId: string, reportId: string, failureCode: string): Promise; + deleteOwned(userId: string, reportId: string): Promise; +} + +/** Production wiring: routes through the caller's resolved backend client. */ +export function createSupabasePersonalReportService( + supabase: { from(table: string): unknown }, + deps?: PersonalReportServiceDeps, +): PersonalReportService { + return createPersonalReportService(createPersonalReportDataClient(supabase), deps); +} diff --git a/frontend/src/lib/personal-report-service.ts b/frontend/src/lib/personal-report-service.ts new file mode 100644 index 00000000..2dd2654f --- /dev/null +++ b/frontend/src/lib/personal-report-service.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export * from "./personal-report-service-core"; diff --git a/frontend/supabase/migrations/20260806010000_personal_reports.sql b/frontend/supabase/migrations/20260806010000_personal_reports.sql new file mode 100644 index 00000000..9b34b39c --- /dev/null +++ b/frontend/supabase/migrations/20260806010000_personal_reports.sql @@ -0,0 +1,100 @@ +-- Personal report persistence for Supabase (production). +-- Mirrors db/migrations/20260806000000_personal_reports.sql one-to-one in +-- table shape, constraints, RLS and grants. +-- +-- Ownership model: rows are owned by auth.users(id). authenticated JWTs may +-- select/delete only their own rows through RLS plus the explicit owner +-- grants below, and can never insert/update (creating a generating record +-- and moving it to ready/failed are server-side operations performed only +-- through service_role, which bypasses RLS but still needs explicit grants). +-- +-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a +-- known requestId requires the same request_fingerprint (a sha256 of the +-- caller's request intent), so a different payload under the same requestId +-- surfaces as request_conflict instead of silently overwriting. Failed +-- retries are not implicitly upserted here; callers either reuse the failed +-- record via a new requestId or surface the stable failure. +-- +-- No birth details, report bodies, model prompts or exception stacks are ever +-- written to logs, index columns or audit events; failure_code is a stable +-- enum shared with frontend/src/lib/personal-report-service.ts and +-- scripts/personal_report_contract.py. + +begin; + +create table if not exists public.personal_reports ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid, + chart_profile_id uuid, + request_id uuid not null, + request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), + report_type text not null check (report_type in ('personal_full', 'personal_thematic')), + status text not null check (status in ('generating', 'ready', 'failed')), + schema_version text not null check (schema_version = 'report_document.v1'), + presentation_mode text not null check (presentation_mode in ('default', 'research')), + requested_themes text[] not null default '{}'::text[], + report_document jsonb, + calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'), + evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'), + skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'), + skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'), + failure_code text check (failure_code in ( + 'profile_incomplete', + 'birth_time_not_usable', + 'report_generation_in_progress', + 'report_rate_limited', + 'calculation_unavailable', + 'model_unavailable', + 'report_schema_invalid', + 'report_guard_rejected', + 'report_not_found' + )), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + check ((status = 'ready') = (report_document is not null)), + check ((status = 'ready') = (completed_at is not null)), + check ((status = 'ready') = (calculation_hash is not null)), + check ((status = 'ready') = (evidence_hash is not null)), + check ((status = 'failed') = (failure_code is not null)), + unique (user_id, request_id) +); + +create index if not exists personal_reports_user_created_idx + on public.personal_reports (user_id, created_at desc); + +-- One in-flight generation per user, enforced by the database so a second +-- request cannot start while the first is still generating. +create unique index if not exists personal_reports_one_generating_per_user + on public.personal_reports (user_id) + where status = 'generating'; + +alter table public.personal_reports enable row level security; +revoke all on table public.personal_reports from public, anon, authenticated; + +drop policy if exists personal_reports_select_own on public.personal_reports; +create policy personal_reports_select_own + on public.personal_reports + for select + to authenticated + using (auth.uid() = user_id); + +drop policy if exists personal_reports_delete_own on public.personal_reports; +create policy personal_reports_delete_own + on public.personal_reports + for delete + to authenticated + using (auth.uid() = user_id); + +-- Normal users can never insert or update rows: generation and status writes +-- are server-side operations only. RLS policies alone do not grant table +-- privileges, so the owner read/delete grants below are required for the +-- policies to be reachable. +grant select, delete on table public.personal_reports to authenticated; + +-- admin_runtime intentionally has no direct access to report bodies (least +-- privilege); generation runs through service_role only. +grant select, insert, update, delete on table public.personal_reports to service_role; + +commit; diff --git a/frontend/tests/personal-report-contract.test.ts b/frontend/tests/personal-report-contract.test.ts new file mode 100644 index 00000000..c08faaf7 --- /dev/null +++ b/frontend/tests/personal-report-contract.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + computeEvidenceHash, + safeParseServerReportDocument, + parseServerReportDocument, +} from "../src/lib/personal-report-contract.server-core.ts"; +import { + findBlockedDeterministicClaims, + findChartSetViolations, + findDanglingEvidenceRefs, + findDuplicateEvidenceIds, + findForbiddenContent, + parseReportDocument, + REPORT_DOCUMENT_MAX_BYTES, + safeParseReportDocument, + serializedReportDocumentBytes, + type ReportDocumentV1, +} from "../src/lib/personal-report-contract.ts"; +import { ReportDocumentValidationError } from "../src/lib/personal-report-contract.ts"; + +const fixtureText = readFileSync( + new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url), + "utf8", +); +const fixture: ReportDocumentV1 = JSON.parse(fixtureText); +const clone = () => structuredClone(fixture) as ReportDocumentV1; + +test("fixture passes isomorphic parse and stays under the 1.5 MiB cap", () => { + const result = safeParseReportDocument(fixture); + assert.equal(result.ok, true); + assert.ok(serializedReportDocumentBytes(fixture) <= REPORT_DOCUMENT_MAX_BYTES); + assert.ok(serializedReportDocumentBytes(fixture) < 100_000); +}); + +test("isomorphic parse rejects extra keys, missing keys, and bad enums", () => { + const extra = clone(); + (extra.subject as Record).hometown = "上海"; + assert.equal(safeParseReportDocument(extra).ok, false); + + const missing = clone(); + delete (missing as Partial).disclaimer; + assert.equal(safeParseReportDocument(missing).ok, false); + + const badEnum = clone(); + badEnum.subject.birthTimeStatus = "guessed" as ReportDocumentV1["subject"]["birthTimeStatus"]; + assert.equal(safeParseReportDocument(badEnum).ok, false); +}); + +test("charts must contain exactly one D1 with all twelve houses", () => { + const zeroD1 = clone(); + zeroD1.charts = zeroD1.charts.filter((chart) => chart.id !== "D1"); + assert.deepEqual(findChartSetViolations(zeroD1), ["charts must contain exactly one D1 chart, found 0"]); + assert.equal(safeParseReportDocument(zeroD1).ok, false); + + const twoD1 = clone(); + twoD1.charts.push(structuredClone(twoD1.charts[0])); + const violations = findChartSetViolations(twoD1); + assert.ok(violations.some((v) => v.includes("duplicate chart id"))); + assert.ok(violations.some((v) => v.includes("exactly one D1"))); + assert.equal(safeParseReportDocument(twoD1).ok, false); + + const elevenHouses = clone(); + elevenHouses.charts[0].houses = elevenHouses.charts[0].houses.slice(0, 11); + assert.ok(findChartSetViolations(elevenHouses).some((v) => v.includes("all twelve house numbers"))); + assert.equal(safeParseReportDocument(elevenHouses).ok, false); +}); + +test("duplicate house numbers are rejected per chart", () => { + const duplicated = clone(); + duplicated.charts[0].houses[11].houseNumber = 1; + const result = safeParseReportDocument(duplicated); + assert.equal(result.ok, false); +}); + +test("longitude is [0, 360)", () => { + const at360 = clone(); + at360.charts[0].planets![0].longitudeDegrees = 360; + assert.equal(safeParseReportDocument(at360).ok, false); + + const near360 = clone(); + near360.charts[0].planets![0].longitudeDegrees = 359.999; + assert.equal(safeParseReportDocument(near360).ok, true); +}); + +test("evidence ids must be globally unique across the appendix", () => { + const duplicated = clone(); + duplicated.evidenceAppendix.conflicts[0].id = duplicated.evidenceAppendix.techniqueAudit[0].id; + assert.ok(findDuplicateEvidenceIds(duplicated).some((v) => v.includes("ev-mevg-web"))); + assert.equal(safeParseReportDocument(duplicated).ok, false); +}); + +test("dangling evidenceRefs are rejected", () => { + const dangling = clone(); + dangling.thematicNarrative[0].evidenceRefs = ["ev-no-such-evidence"]; + assert.deepEqual(findDanglingEvidenceRefs(dangling), ["career:ev-no-such-evidence"]); + assert.equal(safeParseReportDocument(dangling).ok, false); +}); + +test("blocked sections reject deterministic predictions", () => { + const deterministic = clone(); + deterministic.thematicNarrative[3].narrative = "这个事件必然会发生在明年,一定会成功。"; + assert.ok(findBlockedDeterministicClaims(deterministic).length > 0); + assert.equal(safeParseReportDocument(deterministic).ok, false); + + const nonDeterministic = clone(); + nonDeterministic.thematicNarrative[3].narrative = "需要更多历史事件校准后才能评估,具体应期暂不提供。"; + assert.equal(findBlockedDeterministicClaims(nonDeterministic).length, 0); + assert.equal(safeParseReportDocument(nonDeterministic).ok, true); +}); + +const forbiddenSamples = [ + "", + "javascript:alert(1)", + "file:///Users/jesse/private/chart.json", + "参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}", + "onerror=alert(1)", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc", + "node:internal/modules/cjs/loader", + "Traceback (most recent call last)", + "__dirname/secret", + "__proto__ pollution", + "C:\\Users\\jesse\\chart.json", + "tool_call_id: call_123", +]; + +test("forbidden content patterns reject executable and internal material", () => { + for (const poison of forbiddenSamples) { + const poisoned = clone(); + poisoned.disclaimer = poison; + assert.ok( + findForbiddenContent(poison).length > 0, + `expected ${poison} to be flagged`, + ); + assert.equal(safeParseReportDocument(poisoned).ok, false, `poison: ${poison}`); + } +}); + +test("ordinary Chinese report text is not flagged as forbidden", () => { + assert.equal(findForbiddenContent("事业与财富主题的多系统证据较一致。").length, 0); + assert.equal(findForbiddenContent("A < B 的比较关系不属于 HTML 标签").length, 0); +}); + +test("serialization size cap rejects oversized documents", () => { + const oversized = clone(); + oversized.disclaimer = "字".repeat(REPORT_DOCUMENT_MAX_BYTES); + assert.equal(safeParseReportDocument(oversized).ok, false); +}); + +test("JSON object key order is not validated (display order is a typed contract)", () => { + const reordered: Record = {}; + for (const key of Object.keys(fixture).reverse()) { + reordered[key] = (fixture as Record)[key]; + } + assert.equal(safeParseReportDocument(reordered).ok, true); +}); + +test("server parse recomputes the evidence hash and rejects self-reported mismatches", () => { + // The fixture hash is the canonical cross-language value; recomputation must agree. + assert.equal(computeEvidenceHash(fixture.evidenceAppendix), fixture.provenance.evidenceHash); + assert.equal( + computeEvidenceHash(fixture.evidenceAppendix), + "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4", + ); + assert.equal(safeParseServerReportDocument(fixture).ok, true); + + // Tampered evidence with the self-reported hash left unchanged must fail. + const tampered = clone(); + tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值"; + const result = safeParseServerReportDocument(tampered); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.path === "provenance.evidenceHash")); + assert.throws(() => parseServerReportDocument(tampered), ReportDocumentValidationError); + + // The isomorphic parse alone does not verify the hash (server-only duty). + assert.equal(safeParseReportDocument(tampered).ok, true); +}); + +test("parseReportDocument throws a typed validation error on guard failures", () => { + const bad = clone(); + bad.thematicNarrative[0].evidenceRefs = ["ev-missing"]; + assert.throws(() => parseReportDocument(bad), ReportDocumentValidationError); + const parsed = parseReportDocument(fixture); + assert.equal(parsed.schemaVersion, "report_document.v1"); +}); diff --git a/frontend/tests/personal-report-migration.test.ts b/frontend/tests/personal-report-migration.test.ts new file mode 100644 index 00000000..fe919c4d --- /dev/null +++ b/frontend/tests/personal-report-migration.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { PERSONAL_REPORT_FAILURE_CODES } from "../src/lib/personal-report-service-core.ts"; + +const localMigration = readFileSync( + new URL("../db/migrations/20260806000000_personal_reports.sql", import.meta.url), + "utf8", +); +const supabaseMigration = readFileSync( + new URL("../supabase/migrations/20260806010000_personal_reports.sql", import.meta.url), + "utf8", +); + +const localMigrations = [ + "20260714000000_local_auth_compatibility.sql", + "20260720000100_backend_foundation.sql", + "20260721000100_self_hosted_identity.sql", + "20260721000200_identity_business_bridge.sql", + "20260727000000_admin_viewer_identity.sql", + "20260806000000_personal_reports.sql", +]; +const supabaseMigrations = [ + "20260805030000_reconcile_rectification_v4_conversational_turns.sql", + "20260806010000_personal_reports.sql", +]; + +test("migration filenames use unique, correctly ordered 14-digit versions", () => { + const pattern = /^\d{14}_[a-z0-9_]+\.sql$/; + assert.match("20260806000000_personal_reports.sql", pattern); + assert.match("20260806010000_personal_reports.sql", pattern); + assert.ok(localMigrations[5] > localMigrations[4], "local migration must sort after existing ones"); + assert.ok(supabaseMigrations[1] > supabaseMigrations[0], "supabase migration must sort after existing ones"); + const versions = [...localMigrations, ...supabaseMigrations].map((name) => name.split("_")[0]); + assert.equal(new Set(versions).size, versions.length, "all migration versions must be unique"); +}); + +test("both migrations define the same table shape with request_fingerprint after request_id", () => { + const parseColumns = (sql: string) => { + const body = sql.split("create table if not exists public.personal_reports")[1].split(");")[0]; + const columns: string[] = []; + let depth = 0; + for (const raw of body.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const before = depth; + for (const ch of line) { + if (ch === "(") depth += 1; + if (ch === ")") depth -= 1; + } + const first = line.split(/\s+/)[0]; + if (before === 1 && /^[a-z_][a-z0-9_]*$/.test(first) && first !== "check" && first !== "unique") { + columns.push(first); + } + } + return columns; + }; + const expected = [ + "id", "user_id", "session_id", "chart_profile_id", "request_id", + "request_fingerprint", "report_type", "status", "schema_version", + "presentation_mode", "requested_themes", "report_document", + "calculation_hash", "evidence_hash", "skill_source_commit", + "skill_snapshot_sha256", "failure_code", "created_at", "updated_at", + "completed_at", + ]; + assert.deepEqual(parseColumns(localMigration), expected); + assert.deepEqual(parseColumns(supabaseMigration), expected); +}); + +test("request_fingerprint is not null and restricted to sha256 hex in both migrations", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /request_fingerprint text not null check \(request_fingerprint ~ '\^\[0-9a-f\]\{64\}\$'\)/); + } +}); + +test("status, report_type, presentation_mode and schema_version checks are identical", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /status text not null check \(status in \('generating', 'ready', 'failed'\)\)/); + assert.match(sql, /report_type text not null check \(report_type in \('personal_full', 'personal_thematic'\)\)/); + assert.match(sql, /presentation_mode text not null check \(presentation_mode in \('default', 'research'\)\)/); + assert.match(sql, /schema_version text not null check \(schema_version = 'report_document\.v1'\)/); + } +}); + +test("ready requires document, completion time and both hashes; failed requires failure_code", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /check \(\(status = 'ready'\) = \(report_document is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(completed_at is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(calculation_hash is not null\)\)/); + assert.match(sql, /check \(\(status = 'ready'\) = \(evidence_hash is not null\)\)/); + assert.match(sql, /check \(\(status = 'failed'\) = \(failure_code is not null\)\)/); + } +}); + +test("failure_code enum in SQL matches the service constant exactly", () => { + for (const sql of [localMigration, supabaseMigration]) { + const block = sql.match(/failure_code text check \(failure_code in \(([\s\S]+?)\)\)/)?.[1] ?? ""; + const sqlCodes = [...block.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]); + assert.deepEqual(sqlCodes, [...PERSONAL_REPORT_FAILURE_CODES]); + } +}); + +test("both migrations keep the (user_id, request_id) lock and one-generating-per-user index", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /unique \(user_id, request_id\)/); + assert.match(sql, /create unique index if not exists personal_reports_one_generating_per_user[\s\S]*where status = 'generating'/); + assert.match(sql, /references auth\.users\(id\) on delete cascade/); + } +}); + +test("RLS policies plus explicit owner grants: select/delete only, never insert/update", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /alter table public\.personal_reports enable row level security/); + assert.match(sql, /create policy personal_reports_select_own[\s\S]*for select[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/); + assert.match(sql, /create policy personal_reports_delete_own[\s\S]*for delete[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/); + // Policies alone do not grant privileges: explicit owner grants are required. + assert.match(sql, /grant select, delete on table public\.personal_reports to authenticated/); + assert.doesNotMatch(sql, /grant (insert|update) on table public\.personal_reports to authenticated/); + assert.match(sql, /grant select, insert, update, delete on table public\.personal_reports to service_role/); + } +}); + +test("least privilege: anon/public revoked and no direct admin_runtime body access", () => { + for (const sql of [localMigration, supabaseMigration]) { + assert.match(sql, /revoke all on table public\.personal_reports from public, anon, authenticated/); + } + assert.doesNotMatch(localMigration, /to admin_runtime/); + assert.doesNotMatch(supabaseMigration, /to admin_runtime/); +}); diff --git a/frontend/tests/personal-report-service.test.ts b/frontend/tests/personal-report-service.test.ts new file mode 100644 index 00000000..36919125 --- /dev/null +++ b/frontend/tests/personal-report-service.test.ts @@ -0,0 +1,392 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import type { ReportDocumentV1 } from "../src/lib/personal-report-contract.ts"; +import { + createPersonalReportService, + PersonalReportServiceError, + PERSONAL_REPORT_FAILURE_CODES, + type PersonalReportDataClient, + type PersonalReportQueryBuilder, + type PersonalReportQueryResult, +} from "../src/lib/personal-report-service-core.ts"; + +const fixtureText = readFileSync( + new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url), + "utf8", +); +const fixture: ReportDocumentV1 = JSON.parse(fixtureText); +const clone = () => structuredClone(fixture) as ReportDocumentV1; + +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +type MemoryRow = Record & { + id: string; + user_id: string; + request_id: string; + request_fingerprint: string; +}; + +type LoggedOperation = Readonly<{ + operation: "select" | "insert" | "update" | "delete"; + columns: readonly string[]; + values: Readonly>; +}>; + +class MemoryPersonalReportClient implements PersonalReportDataClient { + rows = new Map(); + private nextId = 0; + /** Recorded operations for owner-scoping privilege assertions. */ + log: LoggedOperation[] = []; + + from(table: "personal_reports"): PersonalReportQueryBuilder { + if (table !== "personal_reports") throw new Error("unexpected table"); + return this.chain(this.newState()); + } + + private newState() { + return { + operation: "select" as "select" | "insert" | "update" | "delete", + values: {} as Record, + filters: [] as { column: string; value: unknown }[], + }; + } + + private chain(state: ReturnType): PersonalReportQueryBuilder { + // One shared state object per chain; every chained method mutates it and + // returns a fresh view so .insert(...).select(...).single() works like the + // real Supabase/local builders. + + const execute = (): PersonalReportQueryResult => { + this.log.push({ operation: state.operation, columns: state.filters.map((f) => f.column), values: state.values }); + const matches = [...this.rows.values()].filter((row) => + state.filters.every(({ column, value }) => row[column] === value), + ); + + if (state.operation === "select") { + return { data: matches, error: null }; + } + if (state.operation === "insert") { + const duplicate = [...this.rows.values()].some((row) => + row.user_id === state.values.user_id && row.request_id === state.values.request_id); + if (duplicate) { + return { data: null, error: { code: "23505", message: "duplicate key (user_id, request_id)" } }; + } + const inflight = [...this.rows.values()].some((row) => + row.user_id === state.values.user_id && row.status === "generating"); + if (inflight) { + return { data: null, error: { code: "23505", message: "one generating per user" } }; + } + const id = typeof state.values.id === "string" + ? state.values.id + : `00000000-0000-4000-8000-${String(this.nextId++).padStart(12, "0")}`; + const row = { + evidence_hash: null, + calculation_hash: null, + failure_code: null, + completed_at: null, + report_document: null, + session_id: null, + chart_profile_id: null, + skill_source_commit: null, + ...state.values, + id, + } as unknown as MemoryRow; + this.rows.set(id, row); + return { data: [row], error: null }; + } + if (state.operation === "update") { + const updated: MemoryRow[] = []; + for (const row of matches) { + const next = { ...row, ...state.values } as MemoryRow; + this.rows.set(row.id, next); + updated.push(next); + } + return { data: updated, error: null }; + } + const deletedCount = matches.length; + for (const row of matches) this.rows.delete(row.id); + return { data: null, error: null, count: deletedCount }; + }; + + const wrap = (operation: typeof state.operation, values: Record = {}) => { + state.operation = operation; + state.values = values; + return this.chain(state); + }; + + return { + select: () => this.chain(state), + insert: (row) => wrap("insert", row as Record), + update: (values) => wrap("update", values as Record), + delete: () => wrap("delete"), + eq: (column, value) => { + state.filters.push({ column, value }); + return this.chain(state); + }, + order: () => this.chain(state), + limit: () => this.chain(state), + maybeSingle: () => { + const result = execute(); + const rows = Array.isArray(result.data) ? result.data : []; + return Promise.resolve(rows.length > 1 + ? { data: null, error: { code: "PGRST116", message: "unexpected row count" } } + : { data: rows[0] ?? null, error: null }); + }, + single: () => { + const result = execute(); + const rows = Array.isArray(result.data) ? result.data : []; + return Promise.resolve(rows.length === 1 + ? { data: rows[0], error: null } + : { data: null, error: { code: "PGRST116", message: "unexpected row count" } }); + }, + then: (onfulfilled, onrejected) => + Promise.resolve(execute()).then(onfulfilled, onrejected), + }; + } +} + +function setup(now = new Date("2026-08-06T08:00:00Z")) { + const client = new MemoryPersonalReportClient(); + const service = createPersonalReportService(client, { now: () => now }); + return { client, service, now }; +} + +const ownerId = "11111111-1111-4111-8111-111111111111"; +const otherId = "22222222-2222-4222-8222-222222222222"; +const requestId = "33333333-3333-4333-8333-333333333333"; +const fingerprintA = sha256("request-intent-a"); +const fingerprintB = sha256("request-intent-b"); + +function generatingInput(overrides: Record = {}) { + return { + userId: ownerId, + requestId, + requestFingerprint: fingerprintA, + reportType: "personal_full" as const, + presentationMode: "default" as const, + requestedThemes: ["career", "marriage"], + skillSnapshotSha256: "a".repeat(64), + skillSourceCommit: "b".repeat(40), + ...overrides, + }; +} + +test("createGenerating inserts a generating record with fingerprint", async () => { + const { service } = setup(); + const result = await service.createGenerating(generatingInput()); + assert.equal(result.kind, "created"); + if (result.kind !== "created") return; + assert.equal(result.record.status, "generating"); + assert.equal(result.record.requestFingerprint, fingerprintA); + assert.equal(result.record.schemaVersion, "report_document.v1"); + assert.deepEqual(result.record.requestedThemes, ["career", "marriage"]); + assert.equal(result.record.reportDocument, null); + assert.equal(result.record.failureCode, null); +}); + +test("same requestId and fingerprint replays idempotently", async () => { + const { service } = setup(); + const first = await service.createGenerating(generatingInput()); + assert.equal(first.kind, "created"); + const replay = await service.createGenerating(generatingInput()); + assert.equal(replay.kind, "replayed"); + if (first.kind !== "created" || replay.kind !== "replayed") return; + assert.equal(replay.record.id, first.record.id); + const byRequest = await service.getByUserAndRequestId(ownerId, requestId); + assert.equal(byRequest?.id, first.record.id); +}); + +test("same requestId with a different fingerprint is request_conflict, never replay", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const conflict = await service.createGenerating(generatingInput({ requestFingerprint: fingerprintB })); + assert.equal(conflict.kind, "request_conflict"); + if (conflict.kind !== "request_conflict") return; + assert.equal(conflict.record.requestFingerprint, fingerprintA); +}); + +test("a second in-flight generation for the same user is generation_in_progress", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const second = await service.createGenerating(generatingInput({ + requestId: "44444444-4444-4444-8444-444444444444", + requestFingerprint: sha256("other-intent"), + })); + assert.equal(second.kind, "generation_in_progress"); +}); + +test("createGenerating validates fingerprints, uuids and hashes", async () => { + const { service } = setup(); + const rejectsWithInvalidRequest = (input: Record) => + assert.rejects( + service.createGenerating(generatingInput(input)), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_request", + ); + await rejectsWithInvalidRequest({ requestFingerprint: "not-a-sha" }); + await rejectsWithInvalidRequest({ requestId: "nope" }); + await rejectsWithInvalidRequest({ skillSnapshotSha256: "short" }); + await rejectsWithInvalidRequest({ reportType: "personal_unknown" }); + await rejectsWithInvalidRequest({ userId: "not-a-uuid" }); +}); + +test("reads are owner-scoped", async () => { + const { service } = setup(); + await service.createGenerating(generatingInput()); + const own = await service.getByUserAndRequestId(ownerId, requestId); + if (!own) throw new Error("missing own record"); + assert.equal(await service.getByUserAndRequestId(otherId, requestId), null); + + const byId = await service.getOwnedById(ownerId, own.id); + assert.equal(byId?.id, own.id); + assert.equal(await service.getOwnedById(otherId, own.id), null); +}); + +test("completeReady validates the contract and stores recomputed hashes", async () => { + const { service, now } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + const ready = await service.completeReady(ownerId, created.record.id, document); + assert.equal(ready.status, "ready"); + assert.equal(ready.reportDocument?.schemaVersion, "report_document.v1"); + assert.equal(ready.evidenceHash, fixture.provenance.evidenceHash); + assert.equal(ready.calculationHash, document.provenance.calculationHash); + assert.equal(ready.completedAt, now.toISOString()); +}); + +test("completeReady rejects document reportId mismatch and tampered evidence", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const wrongId = clone(); + wrongId.reportId = requestId; + await assert.rejects( + service.completeReady(ownerId, created.record.id, wrongId), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", + ); + + const tampered = clone(); + tampered.reportId = created.record.id; + tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值"; + await assert.rejects( + service.completeReady(ownerId, created.record.id, tampered), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document", + ); +}); + +test("completeReady rejects non-generating states and foreign owners", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + await service.markFailed(ownerId, created.record.id, "model_unavailable"); + + await assert.rejects( + service.completeReady(ownerId, created.record.id, document), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", + ); + await assert.rejects( + service.completeReady(otherId, created.record.id, document), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "not_found", + ); +}); + +test("markFailed uses only stable failure codes", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + await assert.rejects( + service.markFailed(ownerId, created.record.id, "model said something bad"), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_failure_code", + ); + + const failed = await service.markFailed(ownerId, created.record.id, "model_unavailable"); + assert.equal(failed.status, "failed"); + assert.equal(failed.failureCode, "model_unavailable"); + assert.equal(failed.reportDocument, null); + assert.equal(failed.completedAt, null); + + await assert.rejects( + service.markFailed(ownerId, created.record.id, "report_rate_limited"), + (error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state", + ); + assert.deepEqual(PERSONAL_REPORT_FAILURE_CODES, [ + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", + ]); +}); + +test("deleteOwned removes only the owner's record", async () => { + const { service, client } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + assert.equal(await service.deleteOwned(otherId, created.record.id), false); + assert.ok(client.rows.has(created.record.id)); + assert.equal(await service.deleteOwned(ownerId, created.record.id), true); + assert.equal(client.rows.has(created.record.id), false); + assert.equal(await service.deleteOwned(ownerId, created.record.id), false); +}); + +test("every write and read operation is owner-scoped", async () => { + const { service, client } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + + const document = clone(); + document.reportId = created.record.id; + await service.getOwnedById(ownerId, created.record.id); + await service.getByUserAndRequestId(ownerId, requestId); + await service.completeReady(ownerId, created.record.id, document); + await service.deleteOwned(ownerId, created.record.id); + + for (const entry of client.log) { + if (entry.operation === "insert") { + assert.equal(entry.values.user_id, ownerId, "insert row must carry the caller user_id"); + } else { + assert.ok(entry.columns.includes("user_id"), `${entry.operation} missing user_id filter`); + } + } + const statefulWrites = client.log.filter((entry) => entry.operation === "update" || entry.operation === "delete"); + assert.ok(statefulWrites.length > 0); + for (const entry of statefulWrites) { + assert.ok(entry.columns.includes("id"), `${entry.operation} missing id filter`); + } +}); + +test("failed records are never implicitly resurrected by createGenerating", async () => { + const { service } = setup(); + const created = await service.createGenerating(generatingInput()); + assert.equal(created.kind, "created"); + if (created.kind !== "created") return; + await service.markFailed(ownerId, created.record.id, "model_unavailable"); + + // Same requestId + same fingerprint after failure: replay reports the failed + // record as-is; the service never flips it back to generating. + const replay = await service.createGenerating(generatingInput()); + assert.equal(replay.kind, "replayed"); + if (replay.kind !== "replayed") return; + assert.equal(replay.record.status, "failed"); + assert.equal(replay.record.failureCode, "model_unavailable"); +}); diff --git a/scripts/personal_report_contract.py b/scripts/personal_report_contract.py new file mode 100644 index 00000000..45d6d220 --- /dev/null +++ b/scripts/personal_report_contract.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ReportDocument v1 contract validator (stdlib only). + +Mirrors `contracts/personal-report/report-document.v1.schema.json` and +`frontend/src/lib/personal-report-contract.ts` (Zod). This module deliberately +uses only the Python standard library: the project does not vendor `jsonschema`, +so the validator below implements the schema semantics explicitly so that the +three sides (JSON Schema / Zod / Python) stay aligned and testable. + +Public surface: + SCHEMA_VERSION, REPORT_CONTRACT_VERSION, MAX_SERIALIZED_BYTES + CLAIM_STATUSES, REPORT_TYPES, PRESENTATION_MODES, BIRTH_TIME_STATUSES + TECHNIQUE_STATUSES, CONFLICT_STATUSES, CHART_IDS + FAILURE_CODES (shared stable enum used by the persistence layer) + compute_evidence_hash(document) -> str + validate_report_document(document) -> ValidationResult + is_valid_report_document(document) -> bool + load_report_document(path) -> dict + parse_report_document_json(text) -> ValidationResult + CLI: python3 scripts/personal_report_contract.py +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +SCHEMA_VERSION = "report_document.v1" +REPORT_CONTRACT_VERSION = "1" +MAX_SERIALIZED_BYTES = 1_572_864 # 1.5 MiB hard cap on UTF-8 JSON serialization. + +CLAIM_STATUSES = ( + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked", +) +REPORT_TYPES = ("personal_full", "personal_thematic") +PRESENTATION_MODES = ("default", "research") +BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed") +TECHNIQUE_STATUSES = ("verified", "partial", "blocked") +CONFLICT_STATUSES = ("unresolved", "partial", "resolved") +CHART_IDS = ("D1", "D9", "D10") +HOUSE_NUMBERS = tuple(range(1, 13)) + +# Stable failure-code enum shared with the personal_reports table check +# constraint and frontend/src/lib/personal-report-service.ts. +FAILURE_CODES = ( + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", +) + +UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$") +ISO8601_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" +) +EVIDENCE_ID_PATTERN = re.compile(r"^ev-[a-z0-9_-]{1,63}$") +SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$") + +# ──────────────────────────────────────────────────────────────────────────── +# Semantic guard patterns. These must stay byte-for-byte equivalent to the +# FORBIDDEN_PATTERNS list in frontend/src/lib/personal-report-contract.ts. +# ──────────────────────────────────────────────────────────────────────────── + +FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = ( + ("html_tag_open", r"<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b"), + ("html_tag_close", r""), + ("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="), + ("executable_url", r"\b(?:javascript|vbscript|data:text/html|data:text/javascript|file):"), + ("processing_instruction", r"<\?"), + ("template_literal", r"\$\{"), + ("stack_trace", r"(?:Traceback \(most recent call last\)|node:internal/| at (?:Object|async|node)\.)"), + ("dunder_path", r"__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__"), + ("process_env", r"\bprocess\.env\b"), + ("unix_home_path", r"(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]"), + ("windows_drive_path", r"^[a-zA-Z]:[\\/]"), + ("jwt_token", r"\beyJ[A-Za-z0-9_-]{20,}\b"), + ("secret_marker", r"\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b"), + ("tool_trace", r"\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b"), + ("chain_of_thought", r"\bchain[\s_-]?of[\s_-]?thought\b"), +) + +# Deterministic-prediction phrases that must never appear inside a section whose +# claimStatus is "blocked". Must match the TS side exactly. +DETERMINISTIC_PHRASES: Tuple[str, ...] = ( + "必然", + "必定", + "一定会", + "肯定会", + "绝对会", + "保证会", + "无疑将", + "百分之百", + "确定无疑", + "guaranteed", + "definitely will", + "certainly will", + "will certainly", + "is certain to", +) + +# Bounded text limits shared with the schema. +TEXT_LIMITS: Dict[str, int] = { + "displayName": 120, + "birthPlaceLabel": 200, + "headline": 200, + "summary": 2000, + "priority": 200, + "chartTitle": 120, + "sign": 40, + "occupant": 40, + "planetName": 40, + "sectionTitle": 160, + "narrative": 4000, + "action": 400, + "caveat": 400, + "techniqueName": 160, + "notes": 500, + "conflictDescription": 1000, + "conflictImpact": 500, + "evidenceLabel": 160, + "evidenceValue": 500, + "evidenceSource": 200, + "blockedTechnique": 120, + "disclaimer": 2000, +} + +ARRAY_LIMITS: Dict[str, int] = { + "priorities": 8, + "charts": 3, + "houses": 12, + "planets": 12, + "occupants": 12, + "thematicNarrative": 12, + "actions": 12, + "caveats": 12, + "evidenceRefs": 24, + "techniqueAudit": 100, + "conflicts": 50, + "calculationEvidence": 100, + "blockedTechniques": 100, +} + +_RE = re.compile + + +def _compile(patterns: Tuple[Tuple[str, str], ...]) -> List[Tuple[str, "re.Pattern[str]"]]: + return [(name, _RE(expression, re.IGNORECASE)) for name, expression in patterns] + + +_FORBIDDEN_COMPILED = _compile(FORBIDDEN_PATTERNS) +_DETERMINISTIC_COMPILED = [ + (_RE(phrase, re.IGNORECASE), phrase) for phrase in DETERMINISTIC_PHRASES +] + + +@dataclass +class ValidationResult: + valid: bool + errors: List[str] = field(default_factory=list) + + def add(self, path: str, message: str) -> None: + self.errors.append(f"{path}: {message}") + + +# ──────────────────────────────────────────────────────────────────────────── +# Evidence hash (deterministic, cross-language). +# ──────────────────────────────────────────────────────────────────────────── + +def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]: + """Canonical evidence object. Defensive: malformed rows are reduced to + empty placeholders so validation never raises on arbitrary input; the + canonical hash only matches the TS side for structurally valid documents.""" + appendix = document.get("evidenceAppendix") + appendix = appendix if isinstance(appendix, dict) else {} + + def rows(key: str) -> List[Any]: + value = appendix.get(key) + return value if isinstance(value, list) else [] + + def safe(row: Any, key: str) -> Any: + return row.get(key) if isinstance(row, dict) else None + + canonical = { + "techniqueAudit": [], + "conflicts": [], + "calculationEvidence": [], + } + for row in rows("techniqueAudit"): + entry = { + "id": safe(row, "id"), + "techniqueId": safe(row, "techniqueId"), + "techniqueName": safe(row, "techniqueName"), + "status": safe(row, "status"), + "used": safe(row, "used"), + } + if isinstance(row, dict) and "notes" in row: + entry["notes"] = row["notes"] + canonical["techniqueAudit"].append(entry) + for row in rows("conflicts"): + canonical["conflicts"].append({ + "id": safe(row, "id"), + "description": safe(row, "description"), + "impact": safe(row, "impact"), + "status": safe(row, "status"), + }) + for row in rows("calculationEvidence"): + canonical["calculationEvidence"].append({ + "id": safe(row, "id"), + "label": safe(row, "label"), + "value": safe(row, "value"), + "source": safe(row, "source"), + }) + return canonical + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + + +def compute_evidence_hash(document: Dict[str, Any]) -> str: + """Deterministic SHA-256 over the evidence appendix, matching the TS side.""" + canonical = _canonical_evidence(document) + return hashlib.sha256(_canonical_json(canonical).encode("utf-8")).hexdigest() + + +def serialized_bytes(document: Dict[str, Any]) -> int: + return len(_canonical_json(document).encode("utf-8")) + + +# ──────────────────────────────────────────────────────────────────────────── +# Semantic guards. +# ──────────────────────────────────────────────────────────────────────────── + +def forbidden_content_hits(value: str) -> List[str]: + hits: List[str] = [] + for name, pattern in _FORBIDDEN_COMPILED: + if pattern.search(value): + hits.append(name) + return hits + + +def _text_guard(result: ValidationResult, path: str, value: Any) -> None: + if value is None: + return + if not isinstance(value, str): + result.add(path, f"expected string, got {type(value).__name__}") + return + hits = forbidden_content_hits(value) + if hits: + result.add(path, "forbidden content: " + ", ".join(sorted(set(hits)))) + + +def _blocked_determinism(result: ValidationResult, path: str, claim_status: str, texts: List[Tuple[str, str]]) -> None: + if claim_status != "blocked": + return + for label, text in texts: + if not isinstance(text, str): + continue + for pattern, phrase in _DETERMINISTIC_COMPILED: + if pattern.search(text): + result.add(path, f"blocked section contains deterministic prediction ({label!r} matches {phrase!r})") + + +def _evidence_refs(result: ValidationResult, document: Dict[str, Any]) -> None: + appendix = document["evidenceAppendix"] + + def ids(key: str) -> List[Any]: + value = appendix.get(key) + return value if isinstance(value, list) else [] + + known_ids: List[str] = [] + seen: Dict[str, str] = {} + for key in ("techniqueAudit", "conflicts", "calculationEvidence"): + for index, row in enumerate(ids(key)): + if not isinstance(row, dict) or not isinstance(row.get("id"), str): + continue + evidence_id = row["id"] + if evidence_id in seen: + result.add( + f"evidenceAppendix.{key}[{index}].id", + f"duplicate evidence id {evidence_id!r} (also used in {seen[evidence_id]})", + ) + else: + seen[evidence_id] = f"{key}[{index}]" + known_ids.append(evidence_id) + + for index, section in enumerate(document.get("thematicNarrative", [])): + if not isinstance(section, dict): + continue + path = f"thematicNarrative[{index}].evidenceRefs" + refs = section.get("evidenceRefs") + if not isinstance(refs, list): + continue + for ref in refs: + if ref not in known_ids: + result.add(path, f"unknown evidence id {ref!r}") + + +# ──────────────────────────────────────────────────────────────────────────── +# Structural validation (explicit schema implementation). +# ──────────────────────────────────────────────────────────────────────────── + +def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]: + if not isinstance(value, dict): + result.add(path, f"expected object, got {type(value).__name__}") + return None + return value + + +def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Tuple[str, ...]) -> None: + if value not in allowed: + result.add(path, f"invalid value {value!r}; allowed: {', '.join(allowed)}") + + +def _check_text(result: ValidationResult, path: str, value: Any, max_length: int, min_length: int = 1) -> None: + if not isinstance(value, str): + result.add(path, f"expected string, got {type(value).__name__}") + return + if len(value) < min_length: + result.add(path, f"shorter than minimum length {min_length}") + if len(value) > max_length: + result.add(path, f"longer than maximum length {max_length}") + _text_guard(result, path, value) + + +def _check_uuid(result: ValidationResult, path: str, value: Any) -> None: + if not isinstance(value, str) or not UUID_PATTERN.match(value): + result.add(path, f"invalid uuid {value!r}") + + +def _check_hash(result: ValidationResult, path: str, value: Any, pattern: "re.Pattern[str]", length: int) -> None: + if not isinstance(value, str) or not pattern.match(value) or len(value) != length: + result.add(path, f"invalid hex hash {value!r}") + + +def _check_array( + result: ValidationResult, + path: str, + value: Any, + max_items: int, + min_items: int = 0, +) -> Optional[List[Any]]: + if not isinstance(value, list): + result.add(path, f"expected array, got {type(value).__name__}") + return None + if len(value) > max_items: + result.add(path, f"longer than maximum items {max_items}") + if len(value) < min_items: + result.add(path, f"shorter than minimum items {min_items}") + return value + + +def _check_keys( + result: ValidationResult, + path: str, + value: Dict[str, Any], + required: Tuple[str, ...], + allowed: Optional[Tuple[str, ...]] = None, +) -> None: + permitted = required if allowed is None else allowed + missing = [key for key in required if key not in value] + if missing: + result.add(path, "missing required keys: " + ", ".join(missing)) + extra = [key for key in value if key not in permitted] + if extra: + result.add(path, "unexpected keys: " + ", ".join(extra)) + + +def _validate_subject(result: ValidationResult, path: str, subject: Any) -> None: + value = _expect_object(result, path, subject) + if value is None: + return + _check_keys(result, path, value, ("displayName", "birthTimeStatus", "birthPlaceLabel")) + _check_text(result, f"{path}.displayName", value.get("displayName"), TEXT_LIMITS["displayName"]) + _check_enum(result, f"{path}.birthTimeStatus", value.get("birthTimeStatus"), BIRTH_TIME_STATUSES) + _check_text(result, f"{path}.birthPlaceLabel", value.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"]) + + +def _validate_provenance(result: ValidationResult, path: str, provenance: Any) -> None: + value = _expect_object(result, path, provenance) + if value is None: + return + _check_keys( + result, + path, + value, + ("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"), + ) + commit = value.get("skillSourceCommit") + if commit is not None: + _check_hash(result, f"{path}.skillSourceCommit", commit, SHA1_PATTERN, 40) + _check_hash(result, f"{path}.skillSnapshotSha256", value.get("skillSnapshotSha256"), SHA256_PATTERN, 64) + _check_hash(result, f"{path}.calculationHash", value.get("calculationHash"), SHA256_PATTERN, 64) + _check_hash(result, f"{path}.evidenceHash", value.get("evidenceHash"), SHA256_PATTERN, 64) + if value.get("reportContractVersion") != REPORT_CONTRACT_VERSION: + result.add(f"{path}.reportContractVersion", f"must be {REPORT_CONTRACT_VERSION!r}") + + +def _validate_executive_summary(result: ValidationResult, path: str, summary: Any) -> None: + value = _expect_object(result, path, summary) + if value is None: + return + _check_keys(result, path, value, ("headline", "summary", "priorities", "overallClaimStatus")) + _check_text(result, f"{path}.headline", value.get("headline"), TEXT_LIMITS["headline"]) + _check_text(result, f"{path}.summary", value.get("summary"), TEXT_LIMITS["summary"]) + priorities = _check_array(result, f"{path}.priorities", value.get("priorities"), ARRAY_LIMITS["priorities"]) + if priorities is not None: + for index, priority in enumerate(priorities): + _check_text(result, f"{path}.priorities[{index}]", priority, TEXT_LIMITS["priority"]) + _check_enum(result, f"{path}.overallClaimStatus", value.get("overallClaimStatus"), CLAIM_STATUSES) + + +def _validate_chart(result: ValidationResult, path: str, chart: Any) -> None: + value = _expect_object(result, path, chart) + if value is None: + return + _check_keys(result, path, value, ("id", "title", "houses", "claimStatus"), ("id", "title", "houses", "claimStatus", "planets")) + _check_enum(result, f"{path}.id", value.get("id"), CHART_IDS) + _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["chartTitle"]) + _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) + + houses = _check_array(result, f"{path}.houses", value.get("houses"), ARRAY_LIMITS["houses"]) + seen_houses: List[int] = [] + if houses is not None: + for index, house in enumerate(houses): + house_path = f"{path}.houses[{index}]" + house_value = _expect_object(result, house_path, house) + if house_value is None: + continue + _check_keys(result, house_path, house_value, ("houseNumber", "sign", "occupants")) + house_number = house_value.get("houseNumber") + if house_number in seen_houses: + result.add(house_path, f"duplicate houseNumber {house_number!r}") + if isinstance(house_number, int) and not isinstance(house_number, bool) and house_number in HOUSE_NUMBERS: + seen_houses.append(house_number) + elif not isinstance(house_number, bool): + result.add(f"{house_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") + _check_text(result, f"{house_path}.sign", house_value.get("sign"), TEXT_LIMITS["sign"]) + occupants = _check_array(result, f"{house_path}.occupants", house_value.get("occupants"), ARRAY_LIMITS["occupants"]) + if occupants is not None: + for occupant_index, occupant in enumerate(occupants): + _check_text(result, f"{house_path}.occupants[{occupant_index}]", occupant, TEXT_LIMITS["occupant"]) + + planets = None + if "planets" in value: + planets = _check_array(result, f"{path}.planets", value.get("planets"), ARRAY_LIMITS["planets"]) + if planets is not None: + for index, planet in enumerate(planets): + planet_path = f"{path}.planets[{index}]" + planet_value = _expect_object(result, planet_path, planet) + if planet_value is None: + continue + _check_keys(result, planet_path, planet_value, ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde")) + _check_text(result, f"{planet_path}.name", planet_value.get("name"), TEXT_LIMITS["planetName"]) + _check_text(result, f"{planet_path}.sign", planet_value.get("sign"), TEXT_LIMITS["sign"]) + longitude = planet_value.get("longitudeDegrees") + if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360): + result.add(f"{planet_path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}") + house_number = planet_value.get("houseNumber") + if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS: + result.add(f"{planet_path}.houseNumber", f"must be integer 1..12, got {house_number!r}") + if not isinstance(planet_value.get("retrograde"), bool): + result.add(f"{planet_path}.retrograde", "must be boolean") + + +def _validate_thematic_section(result: ValidationResult, path: str, section: Any) -> None: + value = _expect_object(result, path, section) + if value is None: + return + _check_keys(result, path, value, ("id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs")) + section_id = value.get("id") + if not isinstance(section_id, str) or not SECTION_ID_PATTERN.match(section_id): + result.add(f"{path}.id", f"invalid section id {section_id!r}") + _check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["sectionTitle"]) + _check_text(result, f"{path}.narrative", value.get("narrative"), TEXT_LIMITS["narrative"]) + actions = _check_array(result, f"{path}.actions", value.get("actions"), ARRAY_LIMITS["actions"]) + if actions is not None: + for index, action in enumerate(actions): + _check_text(result, f"{path}.actions[{index}]", action, TEXT_LIMITS["action"]) + caveats = _check_array(result, f"{path}.caveats", value.get("caveats"), ARRAY_LIMITS["caveats"]) + if caveats is not None: + for index, caveat in enumerate(caveats): + _check_text(result, f"{path}.caveats[{index}]", caveat, TEXT_LIMITS["caveat"]) + _check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES) + refs = _check_array(result, f"{path}.evidenceRefs", value.get("evidenceRefs"), ARRAY_LIMITS["evidenceRefs"]) + if refs is not None: + for index, ref in enumerate(refs): + if not isinstance(ref, str) or not EVIDENCE_ID_PATTERN.match(ref): + result.add(f"{path}.evidenceRefs[{index}]", f"invalid evidence id {ref!r}") + + +def _validate_technique_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "techniqueId", "techniqueName", "status", "used"), ("id", "techniqueId", "techniqueName", "status", "used", "notes")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + technique_id = value.get("techniqueId") + if not isinstance(technique_id, str) or not TECHNIQUE_ID_PATTERN.match(technique_id): + result.add(f"{path}.techniqueId", f"invalid technique id {technique_id!r}") + _check_text(result, f"{path}.techniqueName", value.get("techniqueName"), TEXT_LIMITS["techniqueName"]) + _check_enum(result, f"{path}.status", value.get("status"), TECHNIQUE_STATUSES) + if not isinstance(value.get("used"), bool): + result.add(f"{path}.used", "must be boolean") + if "notes" in value: + _check_text(result, f"{path}.notes", value.get("notes"), TEXT_LIMITS["notes"], min_length=0) + + +def _validate_conflict_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "description", "impact", "status")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + _check_text(result, f"{path}.description", value.get("description"), TEXT_LIMITS["conflictDescription"]) + _check_text(result, f"{path}.impact", value.get("impact"), TEXT_LIMITS["conflictImpact"]) + _check_enum(result, f"{path}.status", value.get("status"), CONFLICT_STATUSES) + + +def _validate_calculation_row(result: ValidationResult, path: str, row: Any) -> None: + value = _expect_object(result, path, row) + if value is None: + return + _check_keys(result, path, value, ("id", "label", "value", "source")) + evidence_id = value.get("id") + if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id): + result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}") + _check_text(result, f"{path}.label", value.get("label"), TEXT_LIMITS["evidenceLabel"]) + _check_text(result, f"{path}.value", value.get("value"), TEXT_LIMITS["evidenceValue"]) + _check_text(result, f"{path}.source", value.get("source"), TEXT_LIMITS["evidenceSource"]) + + +def _validate_evidence_appendix(result: ValidationResult, path: str, appendix: Any) -> None: + value = _expect_object(result, path, appendix) + if value is None: + return + _check_keys( + result, + path, + value, + ("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"), + ) + if not isinstance(value.get("expandedByDefault"), bool): + result.add(f"{path}.expandedByDefault", "must be boolean") + rows = _check_array(result, f"{path}.techniqueAudit", value.get("techniqueAudit"), ARRAY_LIMITS["techniqueAudit"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_technique_row(result, f"{path}.techniqueAudit[{index}]", row) + rows = _check_array(result, f"{path}.conflicts", value.get("conflicts"), ARRAY_LIMITS["conflicts"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_conflict_row(result, f"{path}.conflicts[{index}]", row) + rows = _check_array(result, f"{path}.calculationEvidence", value.get("calculationEvidence"), ARRAY_LIMITS["calculationEvidence"]) + if rows is not None: + for index, row in enumerate(rows): + _validate_calculation_row(result, f"{path}.calculationEvidence[{index}]", row) + blocked = _check_array(result, f"{path}.blockedTechniques", value.get("blockedTechniques"), ARRAY_LIMITS["blockedTechniques"]) + if blocked is not None: + for index, technique in enumerate(blocked): + _check_text(result, f"{path}.blockedTechniques[{index}]", technique, TEXT_LIMITS["blockedTechnique"]) + + +def _validate_chart_set(result: ValidationResult, charts: List[Any]) -> None: + """Runtime chart-set semantics: exactly one D1, unique ids, D1 complete houses.""" + seen_ids: List[str] = [] + d1_chart: Optional[Dict[str, Any]] = None + for index, chart in enumerate(charts): + if not isinstance(chart, dict): + continue + chart_id = chart.get("id") + if isinstance(chart_id, str): + if chart_id in seen_ids: + result.add(f"charts[{index}].id", f"duplicate chart id {chart_id!r}") + seen_ids.append(chart_id) + if chart_id == "D1": + d1_chart = chart + d1_count = sum(1 for chart_id in seen_ids if chart_id == "D1") + if d1_count != 1: + result.add("charts", f"must contain exactly one D1 chart, found {d1_count}") + if d1_chart is not None: + houses = d1_chart.get("houses") + if not isinstance(houses, list): + result.add("charts[D1].houses", "D1 chart must declare houses") + return + numbers = [] + for house in houses: + if isinstance(house, dict) and isinstance(house.get("houseNumber"), int) \ + and not isinstance(house.get("houseNumber"), bool): + numbers.append(house["houseNumber"]) + if sorted(numbers) != list(range(1, 13)): + result.add("charts[D1].houses", "D1 chart must contain all twelve house numbers 1..12 exactly once") + elif len(set(numbers)) != 12: + result.add("charts[D1].houses", "D1 chart contains duplicate house numbers") + + +def validate_report_document(document: Any) -> ValidationResult: + result = ValidationResult(valid=True) + if not isinstance(document, dict): + result.add("(root)", f"expected object, got {type(document).__name__}") + result.valid = False + return result + + _check_keys( + result, + "(root)", + document, + ( + "schemaVersion", + "reportId", + "reportType", + "presentationMode", + "generatedAt", + "subject", + "provenance", + "executiveSummary", + "charts", + "thematicNarrative", + "evidenceAppendix", + "disclaimer", + ), + ) + if document.get("schemaVersion") != SCHEMA_VERSION: + result.add("schemaVersion", f"must be {SCHEMA_VERSION!r}") + _check_uuid(result, "reportId", document.get("reportId")) + _check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES) + _check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES) + generated_at = document.get("generatedAt") + if not isinstance(generated_at, str) or not ISO8601_PATTERN.match(generated_at): + result.add("generatedAt", f"invalid ISO-8601 timestamp {generated_at!r}") + + _validate_subject(result, "subject", document.get("subject")) + _validate_provenance(result, "provenance", document.get("provenance")) + _validate_executive_summary(result, "executiveSummary", document.get("executiveSummary")) + + charts = _check_array(result, "charts", document.get("charts"), ARRAY_LIMITS["charts"], min_items=1) + if charts is not None: + for index, chart in enumerate(charts): + _validate_chart(result, f"charts[{index}]", chart) + _validate_chart_set(result, charts) + + sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), ARRAY_LIMITS["thematicNarrative"]) + seen_section_ids: List[str] = [] + if sections is not None: + for index, section in enumerate(sections): + _validate_thematic_section(result, f"thematicNarrative[{index}]", section) + section_id = section.get("id") if isinstance(section, dict) else None + if isinstance(section_id, str): + if section_id in seen_section_ids: + result.add(f"thematicNarrative[{index}].id", f"duplicate section id {section_id!r}") + seen_section_ids.append(section_id) + + _validate_evidence_appendix(result, "evidenceAppendix", document.get("evidenceAppendix")) + _check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"]) + + # Semantic guards (only when the structural shape is usable; all access is + # defensive so arbitrary/malformed JSON can never raise). + if ( + isinstance(document.get("evidenceAppendix"), dict) + and all( + isinstance(document["evidenceAppendix"].get(key), list) + for key in ("techniqueAudit", "conflicts", "calculationEvidence") + ) + and isinstance(document.get("thematicNarrative"), list) + and isinstance(document.get("provenance"), dict) + and isinstance(document.get("charts"), list) + and isinstance(document.get("executiveSummary"), dict) + ): + _evidence_refs(result, document) + expected_hash = compute_evidence_hash(document) + if document["provenance"].get("evidenceHash") != expected_hash: + result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}") + + blocked_texts: List[Tuple[str, str]] = [] + summary = document["executiveSummary"] + if summary.get("overallClaimStatus") == "blocked": + blocked_texts.append(("headline", summary.get("headline"))) + blocked_texts.append(("summary", summary.get("summary"))) + for index, priority in enumerate(summary.get("priorities", [])): + blocked_texts.append((f"priorities[{index}]", priority)) + for index, chart in enumerate(document["charts"]): + if isinstance(chart, dict) and chart.get("claimStatus") == "blocked": + blocked_texts.append((f"charts[{index}].title", chart.get("title"))) + for index, section in enumerate(document["thematicNarrative"]): + if isinstance(section, dict) and section.get("claimStatus") == "blocked": + blocked_texts.append((f"thematicNarrative[{index}].title", section.get("title"))) + blocked_texts.append((f"thematicNarrative[{index}].narrative", section.get("narrative"))) + for action_index, action in enumerate(section.get("actions", [])): + blocked_texts.append((f"thematicNarrative[{index}].actions[{action_index}]", action)) + for caveat_index, caveat in enumerate(section.get("caveats", [])): + blocked_texts.append((f"thematicNarrative[{index}].caveats[{caveat_index}]", caveat)) + for path, text in blocked_texts: + _blocked_determinism(result, path, "blocked", [(path, text)]) + + size = serialized_bytes(document) + if size > MAX_SERIALIZED_BYTES: + result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}") + + result.valid = not result.errors + return result + + +def is_valid_report_document(document: Any) -> bool: + return validate_report_document(document).valid + + +def load_report_document(path: str) -> Dict[str, Any]: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def parse_report_document_json(text: str) -> ValidationResult: + try: + document = json.loads(text) + except json.JSONDecodeError as error: + result = ValidationResult(valid=False) + result.add("(json)", f"invalid JSON: {error}") + return result + return validate_report_document(document) + + +def main(argv: Optional[List[str]] = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + print("usage: python3 scripts/personal_report_contract.py ", file=sys.stderr) + return 2 + path = args[0] + try: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + print(f"unable to read {path}: {error}", file=sys.stderr) + return 2 + result = parse_report_document_json(text) + if result.valid: + try: + size = serialized_bytes(json.loads(text)) + except ValueError: + size = 0 + print(f"valid: {path} ({size} bytes)") + return 0 + print(f"invalid: {path}", file=sys.stderr) + for error in result.errors: + print(f" - {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/personal_report_document.v1.json b/tests/fixtures/personal_report_document.v1.json new file mode 100644 index 00000000..33665222 --- /dev/null +++ b/tests/fixtures/personal_report_document.v1.json @@ -0,0 +1,399 @@ +{ + "schemaVersion": "report_document.v1", + "reportId": "3f2b1c4a-8d6e-4f0a-9c2b-5a7e1d3f8b40", + "reportType": "personal_full", + "presentationMode": "default", + "generatedAt": "2026-08-06T08:00:00Z", + "subject": { + "displayName": "测试用户(合成资料)", + "birthTimeStatus": "confirmed", + "birthPlaceLabel": "北京(合成测试地点)" + }, + "provenance": { + "skillSourceCommit": "9034e1967032d09c0a1b2c3d4e5f60718293a4b5", + "skillSnapshotSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "calculationHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "evidenceHash": "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4", + "reportContractVersion": "1" + }, + "executiveSummary": { + "headline": "事业与财富主题的多系统证据较一致", + "summary": "本报告基于服务端计算的本命盘与分盘证据。事业主题在 D10 与 A10 双层呈现一致信号,财富主题在 D2 与 D11 呈现中等强度信号;婚恋主题需用户历史事件核验;时机主题因外部参照未闭环而降级为 blocked,不给出确定性应期。", + "priorities": [ + "先核验事业主题的三条历史事件证据", + "婚恋主题等待用户提供可核验的过往关系时间点", + "时机主题在外部参照闭环前不做确定性预测" + ], + "overallClaimStatus": "multi_system_consensus" + }, + "charts": [ + { + "id": "D1", + "title": "本命盘 D1(Lahiri Ayanamsa)", + "claimStatus": "multi_system_consensus", + "houses": [ + { + "houseNumber": 1, + "sign": "狮子座", + "occupants": [ + "上升点" + ] + }, + { + "houseNumber": 2, + "sign": "处女座", + "occupants": [] + }, + { + "houseNumber": 3, + "sign": "天秤座", + "occupants": [ + "水星" + ] + }, + { + "houseNumber": 4, + "sign": "天蝎座", + "occupants": [ + "金星" + ] + }, + { + "houseNumber": 5, + "sign": "射手座", + "occupants": [ + "太阳" + ] + }, + { + "houseNumber": 6, + "sign": "摩羯座", + "occupants": [ + "火星" + ] + }, + { + "houseNumber": 7, + "sign": "水瓶座", + "occupants": [] + }, + { + "houseNumber": 8, + "sign": "双鱼座", + "occupants": [ + "木星" + ] + }, + { + "houseNumber": 9, + "sign": "白羊座", + "occupants": [ + "土星" + ] + }, + { + "houseNumber": 10, + "sign": "金牛座", + "occupants": [ + "月亮" + ] + }, + { + "houseNumber": 11, + "sign": "双子座", + "occupants": [] + }, + { + "houseNumber": 12, + "sign": "巨蟹座", + "occupants": [ + "罗睺" + ] + } + ], + "planets": [ + { + "name": "太阳", + "sign": "射手座", + "longitudeDegrees": 248.5, + "houseNumber": 5, + "retrograde": false + }, + { + "name": "月亮", + "sign": "金牛座", + "longitudeDegrees": 42.1, + "houseNumber": 10, + "retrograde": false + }, + { + "name": "火星", + "sign": "摩羯座", + "longitudeDegrees": 288.3, + "houseNumber": 6, + "retrograde": false + }, + { + "name": "水星", + "sign": "天秤座", + "longitudeDegrees": 190.7, + "houseNumber": 3, + "retrograde": false + }, + { + "name": "木星", + "sign": "双鱼座", + "longitudeDegrees": 341.9, + "houseNumber": 8, + "retrograde": false + }, + { + "name": "金星", + "sign": "天蝎座", + "longitudeDegrees": 222.4, + "houseNumber": 4, + "retrograde": false + }, + { + "name": "土星", + "sign": "白羊座", + "longitudeDegrees": 11.8, + "houseNumber": 9, + "retrograde": true + }, + { + "name": "罗睺", + "sign": "巨蟹座", + "longitudeDegrees": 102.6, + "houseNumber": 12, + "retrograde": true + }, + { + "name": "计都", + "sign": "摩羯座", + "longitudeDegrees": 282.6, + "houseNumber": 6, + "retrograde": true + } + ] + } + ], + "thematicNarrative": [ + { + "id": "career", + "title": "事业主题", + "narrative": "事业主题呈现中等偏强的信号:第十宫月亮与金牛座相关领域呼应,D10 与 A10 双层一致性较高。土星逆行提示职业节奏需要长期主义,不适合短期投机路径。", + "actions": [ + "在金牛座相关行业或管理岗位方向收集更多历史证据", + "将晋升或转岗事件的时间点记录下来用于后续校准" + ], + "caveats": [ + "本主题结论依赖出生时间确认状态,当前为 confirmed", + "外部参照引擎未全部闭环,置信度上限为多系统一致而非绝对" + ], + "claimStatus": "multi_system_consensus", + "evidenceRefs": [ + "ev-career-d10-a10", + "ev-career-dasha-boundary", + "ev-shadbala-total" + ] + }, + { + "id": "wealth", + "title": "财富主题", + "narrative": "财富主题在 D2 与 D11 呈现中等强度信号,第二宫与第十一宫的证据链相互印证,但缺乏足够的过往财务事件校准,属于单系统推断加参数敏感的组合。", + "actions": [ + "核对 D2 与 D11 的证据原始值是否与用户实际财务事件吻合" + ], + "caveats": [ + "财富结论不构成投资建议", + "未达到双系统一致时不得表述为确定结果" + ], + "claimStatus": "parameter_sensitive", + "evidenceRefs": [ + "ev-wealth-d2-d11", + "ev-ashtakavarga-wealth" + ] + }, + { + "id": "marriage", + "title": "婚恋主题", + "narrative": "婚恋主题已计算 D9 与 UL 相关证据,但本报告没有足够的用户历史关系事件来核验,需用户提供可核验时间点后重新评估。", + "actions": [ + "提供过往重要关系事件的时间点以完成核验" + ], + "caveats": [ + "未经用户历史事件核验的婚恋结论不得视为最终结论" + ], + "claimStatus": "user_history_verification_required", + "evidenceRefs": [ + "ev-marriage-d9-ul" + ] + }, + { + "id": "timing", + "title": "时机主题", + "narrative": "时机主题需要 Vimshottari 与 Narayana Dasha 双轨交叉,但外部参照引擎尚未闭环,当前不给出具体应期,仅保留已计算的运限边界供后续校准使用。", + "actions": [ + "等待外部参照闭环后重新评估应期" + ], + "caveats": [ + "当前不提供任何确定性时间预测", + "运限边界仅作为校准素材,不作为结论" + ], + "claimStatus": "blocked", + "evidenceRefs": [ + "ev-timing-vd-md-ad", + "ev-timing-narayana" + ] + } + ], + "evidenceAppendix": { + "expandedByDefault": false, + "techniqueAudit": [ + { + "id": "ev-mevg-web", + "techniqueId": "mevg_global_web_evidence", + "techniqueName": "MEVG / Global Web Evidence", + "status": "partial", + "used": true, + "notes": "外部资料采集完成度 60%,来源分级已记录,冲突已进入 conflicts 列表" + }, + { + "id": "ev-real-case", + "techniqueId": "real_case_calibration", + "techniqueName": "Real Case Calibration", + "status": "partial", + "used": true, + "notes": "10 个公开案例可回放:事业 5、婚恋 5;财富案例缺失" + }, + { + "id": "ev-fbm", + "techniqueId": "functional_benefic_malefic", + "techniqueName": "Functional Benefic/Malefic", + "status": "verified", + "used": true + }, + { + "id": "ev-vimshottari", + "techniqueId": "vimshottari_dasha", + "techniqueName": "Vimshottari Dasha", + "status": "verified", + "used": true + }, + { + "id": "ev-narayana", + "techniqueId": "narayana_dasha", + "techniqueName": "Narayana Dasha", + "status": "verified", + "used": true, + "notes": "与 Vimshottari 双轨交叉" + }, + { + "id": "ev-d10-a10", + "techniqueId": "d10_a10", + "techniqueName": "D10 + A10(事业分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-d2-d11", + "techniqueId": "d2_d11", + "techniqueName": "D2 / D11(财富分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-d9-ul", + "techniqueId": "d9_ul", + "techniqueName": "D9 + UL(婚恋分盘)", + "status": "verified", + "used": true + }, + { + "id": "ev-shadbala", + "techniqueId": "shadbala", + "techniqueName": "Shadbala", + "status": "partial", + "used": true, + "notes": "内部总量一致;外部绝对数值对照未闭环" + }, + { + "id": "ev-ashtakavarga", + "techniqueId": "ashtakavarga", + "techniqueName": "Ashtakavarga", + "status": "partial", + "used": true + } + ], + "conflicts": [ + { + "id": "ev-conflict-1", + "description": "Vimshottari 与 Narayana Dasha 在 2031 年前后的应期窗口存在分歧", + "impact": "时机主题降级为 blocked,不输出确定性应期", + "status": "unresolved" + }, + { + "id": "ev-conflict-2", + "description": "Shadbala 内部总量与外部参照数值尚未对齐", + "impact": "Shadbala 行标记为 partial,不参与绝对强度结论", + "status": "partial" + } + ], + "calculationEvidence": [ + { + "id": "ev-career-d10-a10", + "label": "D10 与 A10 事业证据", + "value": "D10 月亮入第十宫,A10 同宫主星呼应;双盘一致", + "source": "服务端排盘 varga D10/A10(Lahiri)" + }, + { + "id": "ev-career-dasha-boundary", + "label": "Vimshottari 大运边界", + "value": "当前大运:木星-土星;起始边界已记录", + "source": "服务端 Dasha 计算" + }, + { + "id": "ev-wealth-d2-d11", + "label": "D2 与 D11 财富证据", + "value": "D2 第二宫与 D11 第十一宫证据链相互印证", + "source": "服务端排盘 varga D2/D11" + }, + { + "id": "ev-ashtakavarga-wealth", + "label": "Ashtakavarga 财富相关宫位", + "value": "第二宫与第十一宫 Bhinna Ashtakavarga 点数高于均值", + "source": "服务端 Ashtakavarga 计算" + }, + { + "id": "ev-marriage-d9-ul", + "label": "D9 与 UL 婚恋证据", + "value": "D9 第七宫状态与 UL 指示存在呼应,需用户核验", + "source": "服务端排盘 varga D9 + UL" + }, + { + "id": "ev-timing-vd-md-ad", + "label": "Vimshottari 小运边界", + "value": "木星-土星-月亮 小运边界已计算,仅作校准素材", + "source": "服务端 Dasha 计算" + }, + { + "id": "ev-timing-narayana", + "label": "Narayana Dasha 边界", + "value": "Narayana 大运边界已计算,与 Vimshottari 存在分歧", + "source": "服务端 Narayana Dasha 计算" + }, + { + "id": "ev-shadbala-total", + "label": "Shadbala 总量", + "value": "各星 Shadbala 总量内部一致,外部对照 partial", + "source": "服务端 Shadbala 计算" + } + ], + "blockedTechniques": [ + "Sphuta 判定层(外部数值参照缺失)", + "Tajika 命名组合事件判定(无金标案例)" + ] + }, + "disclaimer": "本报告由计算引擎与模型共同生成,仅用于传统文化研究与个人参考,不构成医疗、法律或投资建议。任何涉及健康、法律、财务的决策请咨询对应领域的专业人士。报告中的时间预测均受证据完整度限制,blocked 内容不代表确定性结论。" +} diff --git a/tests/test_personal_report_contract.py b/tests/test_personal_report_contract.py new file mode 100644 index 00000000..b082d260 --- /dev/null +++ b/tests/test_personal_report_contract.py @@ -0,0 +1,295 @@ +"""ReportDocument v1 contract validator tests (Python side). + +Semantics are shared with contracts/personal-report/report-document.v1.schema.json +and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the +runtime-enforced rules that JSON Schema draft-07 cannot express and prove the +validator never raises on arbitrary/malformed input. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.personal_report_contract import ( + FAILURE_CODES, + MAX_SERIALIZED_BYTES, + compute_evidence_hash, + is_valid_report_document, + load_report_document, + main, + parse_report_document_json, + serialized_bytes, + validate_report_document, +) + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json" +SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql" +LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql" + + +@pytest.fixture(scope="module") +def fixture() -> dict: + return load_report_document(str(FIXTURE)) + + +def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None: + result = validate_report_document(fixture) + assert result.valid, result.errors + # evidenceHash is a deterministic recomputation, not a model self-report. + assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture) + assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES + + +def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None: + # The fixture is read byte-for-byte by the TS tests too; both sides must + # compute the same sha256 over the canonical evidence appendix. + assert fixture["provenance"]["evidenceHash"] == ( + "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4" + ) + + +@pytest.mark.parametrize( + "malformed", + [ + None, + 42, + "text", + [], + {}, + {"schemaVersion": "report_document.v1"}, + {"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}}, + {"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]}, + {"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"}, + ], +) +def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None: + result = validate_report_document(malformed) + assert result.valid is False + assert isinstance(result.errors, list) + + +def test_arbitrary_json_text_never_raises() -> None: + for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]: + result = parse_report_document_json(text) + assert result.valid is False + + +def test_charts_require_exactly_one_d1(fixture: dict) -> None: + without_d1 = json.loads(json.dumps(fixture)) + without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"] + result = validate_report_document(without_d1) + assert result.valid is False + assert any("exactly one D1" in error for error in result.errors) + + two_d1 = json.loads(json.dumps(fixture)) + two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0]))) + result = validate_report_document(two_d1) + assert result.valid is False + assert any("duplicate chart id" in error for error in result.errors) + assert any("exactly one D1" in error for error in result.errors) + + +def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None: + incomplete = json.loads(json.dumps(fixture)) + incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11] + result = validate_report_document(incomplete) + assert result.valid is False + assert any("all twelve house numbers" in error for error in result.errors) + + +def test_duplicate_house_numbers_rejected(fixture: dict) -> None: + duplicated = json.loads(json.dumps(fixture)) + duplicated["charts"][0]["houses"][11]["houseNumber"] = 1 + result = validate_report_document(duplicated) + assert result.valid is False + assert any("duplicate houseNumber" in error for error in result.errors) + + +def test_longitude_is_half_open_interval(fixture: dict) -> None: + at_360 = json.loads(json.dumps(fixture)) + at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0 + result = validate_report_document(at_360) + assert result.valid is False + assert any("longitudeDegrees" in error for error in result.errors) + + near_360 = json.loads(json.dumps(fixture)) + near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999 + near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360) + assert validate_report_document(near_360).valid + + +def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None: + duplicated = json.loads(json.dumps(fixture)) + duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"] + duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated) + result = validate_report_document(duplicated) + assert result.valid is False + assert any("duplicate evidence id" in error for error in result.errors) + + +def test_dangling_evidence_refs_rejected(fixture: dict) -> None: + dangling = json.loads(json.dumps(fixture)) + dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"] + result = validate_report_document(dangling) + assert result.valid is False + assert any("unknown evidence id" in error for error in result.errors) + + +def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None: + tampered = json.loads(json.dumps(fixture)) + tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值" + # Self-reported hash left unchanged: validator must recompute and reject. + result = validate_report_document(tampered) + assert result.valid is False + assert any("does not match computed evidence hash" in error for error in result.errors) + + +def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None: + deterministic = json.loads(json.dumps(fixture)) + deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。" + result = validate_report_document(deterministic) + assert result.valid is False + assert any("blocked section contains deterministic prediction" in error for error in result.errors) + + non_deterministic = json.loads(json.dumps(fixture)) + non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。" + non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic) + assert validate_report_document(non_deterministic).valid + + +@pytest.mark.parametrize( + "poison", + [ + "", + "javascript:alert(1)", + "file:///Users/jesse/private/chart.json", + "参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}", + "onerror=alert(1)", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc", + "node:internal/modules/cjs/loader", + "Traceback (most recent call last)", + "__dirname/secret", + "C:\\Users\\jesse\\chart.json", + "tool_call_id: call_123", + ], +) +def test_forbidden_content_rejected(fixture: dict, poison: str) -> None: + poisoned = json.loads(json.dumps(fixture)) + poisoned["disclaimer"] = poison + result = validate_report_document(poisoned) + assert result.valid is False + assert any("forbidden content" in error for error in result.errors) + + +def test_serialization_size_cap(fixture: dict) -> None: + oversized = json.loads(json.dumps(fixture)) + oversized["disclaimer"] = "字" * (MAX_SERIALIZED_BYTES) + result = validate_report_document(oversized) + assert result.valid is False + assert any("exceeding" in error for error in result.errors) + + +def test_strict_keys_missing_and_extra(fixture: dict) -> None: + missing = json.loads(json.dumps(fixture)) + del missing["disclaimer"] + result = validate_report_document(missing) + assert result.valid is False + assert any("missing required keys" in error for error in result.errors) + + extra = json.loads(json.dumps(fixture)) + extra["disclaimer"] = extra["disclaimer"] + extra["subject"]["hometown"] = "上海" + result = validate_report_document(extra) + assert result.valid is False + assert any("unexpected keys" in error for error in result.errors) + + +def test_json_object_key_order_is_not_validated(fixture: dict) -> None: + # JSON objects are unordered; fixed reader order is a display contract of + # the typed fields, never a key-order condition. + reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))} + assert validate_report_document(reordered).valid + + +def test_failure_code_enum_matches_both_migrations() -> None: + for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION): + sql = path.read_text(encoding="utf-8") + for code in FAILURE_CODES: + assert f"'{code}'" in sql, f"{code} missing from {path.name}" + assert sql.count("failure_code in") == 1 + # Both migrations share the same stable enum. + supabase_codes = set(FAILURE_CODES) + local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8") + assert all(f"'{code}'" in local_sql for code in supabase_codes) + + +def test_cli_exit_codes(fixture: dict) -> None: + assert main([str(FIXTURE)]) == 0 + assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1 + assert main([]) == 2 + assert main([str(ROOT / "does-not-exist.json")]) == 2 + + +def test_validator_accepts_synthetic_producer_output() -> None: + # A minimal-but-complete document produced without the fixture must pass. + from scripts.personal_report_contract import ( + BIRTH_TIME_STATUSES, + CLAIM_STATUSES, + PRESENTATION_MODES, + REPORT_TYPES, + SCHEMA_VERSION, + ) + + houses = [ + {"houseNumber": number, "sign": "狮子座", "occupants": []} + for number in range(1, 13) + ] + document = { + "schemaVersion": SCHEMA_VERSION, + "reportId": "00000000-0000-4000-8000-000000000001", + "reportType": REPORT_TYPES[0], + "presentationMode": PRESENTATION_MODES[0], + "generatedAt": "2026-08-06T08:00:00Z", + "subject": { + "displayName": "合成用户", + "birthTimeStatus": BIRTH_TIME_STATUSES[0], + "birthPlaceLabel": "合成地点", + }, + "provenance": { + "skillSourceCommit": None, + "skillSnapshotSha256": "c" * 64, + "calculationHash": "d" * 64, + "evidenceHash": "0" * 64, + "reportContractVersion": "1", + }, + "executiveSummary": { + "headline": "摘要标题", + "summary": "摘要正文。", + "priorities": ["优先事项"], + "overallClaimStatus": CLAIM_STATUSES[0], + }, + "charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}], + "thematicNarrative": [], + "evidenceAppendix": { + "expandedByDefault": False, + "techniqueAudit": [ + { + "id": "ev-audit", + "techniqueId": "d1_chart", + "techniqueName": "D1 本命盘", + "status": "verified", + "used": True, + } + ], + "conflicts": [], + "calculationEvidence": [], + "blockedTechniques": [], + }, + "disclaimer": "仅供研究参考。", + } + document["provenance"]["evidenceHash"] = compute_evidence_hash(document) + assert validate_report_document(document).valid -- 2.54.0 From 47e829e9714633815252ecf53f2ed2610aa5ebfa Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 12:43:30 +0800 Subject: [PATCH 4/9] feat(report): generate grounded reports with Mastra --- .../src/app/api/reports/[reportId]/route.ts | 133 ++ frontend/src/app/api/reports/route.ts | 148 ++ frontend/src/lib/personal-report-codes.ts | 23 + .../src/lib/personal-report-entitlement.ts | 103 ++ .../src/lib/personal-report-generation.ts | 1215 +++++++++++++++++ .../src/lib/personal-report-route-core.ts | 508 +++++++ frontend/src/mastra/personal-report.ts | 286 ++++ frontend/tests/personal-report-api.test.ts | 819 +++++++++++ .../tests/personal-report-entitlement.test.ts | 117 ++ .../tests/personal-report-generation.test.ts | 748 ++++++++++ 10 files changed, 4100 insertions(+) create mode 100644 frontend/src/app/api/reports/[reportId]/route.ts create mode 100644 frontend/src/app/api/reports/route.ts create mode 100644 frontend/src/lib/personal-report-codes.ts create mode 100644 frontend/src/lib/personal-report-entitlement.ts create mode 100644 frontend/src/lib/personal-report-generation.ts create mode 100644 frontend/src/lib/personal-report-route-core.ts create mode 100644 frontend/src/mastra/personal-report.ts create mode 100644 frontend/tests/personal-report-api.test.ts create mode 100644 frontend/tests/personal-report-entitlement.test.ts create mode 100644 frontend/tests/personal-report-generation.test.ts diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 00000000..f1f31de7 --- /dev/null +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement"; +import { + resolveReportDelete, + resolveReportRead, +} from "@/lib/personal-report-route-core"; +import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server"; +import { + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ reportId: string }> }; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * GET/DELETE use the AUTHENTICATED client: RLS permits owners to select and + * delete their own rows only, and the persistence service additionally scopes + * every query by userId (least privilege — no service role here). + */ +async function resolvePersistenceForUser() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return { userId: null as string | null, persistence: null as PersonalReportService | null }; + } + const persistence = createSupabasePersonalReportService(supabase); + return { userId: user.id, persistence }; +} + +export async function GET(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportRead({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + }, + // Defense in depth: a stored ready document is re-validated through the + // canonical server parse (schema + guards + evidence hash recompute) + // before it is returned to the browser. Client-side validation is never + // a substitute. + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok + ? { ok: true, document: parsed.document } + : { ok: false }; + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] read failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportDelete({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + async deleteOwned() { + return false; + }, + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] delete failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法删除", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts new file mode 100644 index 00000000..588e7470 --- /dev/null +++ b/frontend/src/app/api/reports/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { runConsultationWorkflow } from "@/mastra"; +import { createPersonalReportAgent } from "@/mastra/personal-report"; +import { defaultLanguageModel } from "@/mastra/model"; +import { + resolveSkillSnapshot, +} from "@/lib/personal-report-generation"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { + isPersonalReportFeatureEnabled, + readPersonalReportDailyLimit, + resolveAllowedReportOrigins, +} from "@/lib/personal-report-entitlement"; +import { + resolveReportCreate, + type ReportCreateCoreDeps, +} from "@/lib/personal-report-route-core"; +import { + createPersonalReportDataClient, + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +export async function POST(request: Request) { + try { + // Authenticated client: auth, profile, session/chart-profile owner reads. + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + const userId = authError || !user ? null : user.id; + + // Admin client (service_role / self-hosted admin DB): generation writes + // and counting. The authenticated client is forbidden by migration grants + // from inserting/updating personal_reports. + const admin = createAdminSupabaseClient(); + const persistence: PersonalReportService = createSupabasePersonalReportService(admin); + const adminDataClient = createPersonalReportDataClient(admin); + + let profile: unknown = null; + let profileError: unknown = null; + if (userId) { + const result = await supabase + .from("profiles") + .select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label") + .eq("id", userId) + .maybeSingle(); + profile = result.data ?? null; + profileError = result.error; + } + + const deps: ReportCreateCoreDeps = { + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + rawBody: await request.json().catch(() => null), + profile, + checkSessionOwned: async (sessionId) => { + const { data, error } = await supabase + .from("chat_sessions") + .select("id") + .eq("id", sessionId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + checkChartProfileOwned: async (chartProfileId) => { + const { data, error } = await supabase + .from("chart_profiles") + .select("id") + .eq("id", chartProfileId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + featureEnabled: isPersonalReportFeatureEnabled(process.env), + dailyLimit: readPersonalReportDailyLimit(process.env), + counts: { + countGenerating: async () => { + const { data, error } = await adminDataClient.from("personal_reports") + .select("id") + .eq("user_id", userId as string) + .eq("status", "generating") + .limit(2); + if (error) throw error; + return Array.isArray(data) ? data.length : 0; + }, + countCreatedToday: async () => { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const { data, error } = await adminDataClient.from("personal_reports") + .select("id,created_at") + .eq("user_id", userId as string); + if (error) throw error; + if (!Array.isArray(data)) return 0; + const startIso = todayStart.toISOString(); + return data.filter((row) => { + const createdAt = row && typeof row === "object" + ? (row as Record).created_at + : null; + return typeof createdAt === "string" && createdAt >= startIso; + }).length; + }, + }, + persistence, + model: defaultLanguageModel(), + runWorkflow: (input) => runConsultationWorkflow(input), + createAgent: (model) => createPersonalReportAgent(model as Parameters[0]), + skillSnapshot: resolveSkillSnapshot(), + }; + + const response = await resolveReportCreate(deps); + if (response.status >= 500 && profileError) { + console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object" + ? (deps.rawBody as Record).requestId ?? "unknown" + : "unknown")} reason=${sanitizedErrorCode(profileError)}`); + } + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/lib/personal-report-codes.ts b/frontend/src/lib/personal-report-codes.ts new file mode 100644 index 00000000..790d4712 --- /dev/null +++ b/frontend/src/lib/personal-report-codes.ts @@ -0,0 +1,23 @@ +/** + * Stable error/failure codes for the personal report API. Kept in a pure + * dependency-free module (no filesystem, no path, no crypto imports) so route + * handlers that only need codes never pull the skill-snapshot scanner or any + * other generation logic into their bundle/trace. + */ + +export const REPORT_STABLE_CODES = { + profileIncomplete: "profile_incomplete", + birthTimeNotUsable: "birth_time_not_usable", + generationInProgress: "report_generation_in_progress", + rateLimited: "report_rate_limited", + calculationUnavailable: "calculation_unavailable", + modelUnavailable: "model_unavailable", + schemaInvalid: "report_schema_invalid", + guardRejected: "report_guard_rejected", + notFound: "report_not_found", + requestConflict: "report_request_conflict", + exportDisabled: "report_export_disabled", + invalidRequest: "invalid_request", + resourceForbidden: "report_resource_forbidden", + generationFailed: "report_generation_failed", +} as const; diff --git a/frontend/src/lib/personal-report-entitlement.ts b/frontend/src/lib/personal-report-entitlement.ts new file mode 100644 index 00000000..99c304b0 --- /dev/null +++ b/frontend/src/lib/personal-report-entitlement.ts @@ -0,0 +1,103 @@ +/** + * Personal report export entitlement — independent from the "spend 1 credit" + * consultation RPC. Capability key: report.export.personal. + * + * Staging free policy: login-only, single concurrent generation per user, + * daily limit read from environment configuration (never hardcoded in UI). + * If the feature is later priced, a reserve/refund flow plugs in behind the + * same interface; this module stays billing-agnostic. + */ + +export const REPORT_EXPORT_PERSONAL_CAPABILITY_KEY = "report.export.personal"; + +export const REPORT_FEATURE_ENV = "PERSONAL_REPORT_ENABLED"; +export const REPORT_DAILY_LIMIT_ENV = "PERSONAL_REPORT_DAILY_LIMIT"; +export const REPORT_ALLOWED_ORIGINS_ENV = "PERSONAL_REPORT_ALLOWED_ORIGINS"; + +/** + * Server-side default when the env variable is absent. The UI must never + * hardcode this number; it is configurable per deployment. + */ +export const DEFAULT_PERSONAL_REPORT_DAILY_LIMIT = 5; + +export type Environment = Readonly>; + +export function readPersonalReportDailyLimit(environment: Environment): number { + const raw = environment[REPORT_DAILY_LIMIT_ENV]?.trim(); + if (!raw) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT; + return parsed; +} + +export function isPersonalReportFeatureEnabled(environment: Environment): boolean { + return environment[REPORT_FEATURE_ENV]?.trim() === "true"; +} + +export function resolveAllowedReportOrigins(environment: Environment): readonly string[] { + return (environment[REPORT_ALLOWED_ORIGINS_ENV] ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +export type SameOriginDecision = Readonly< + { ok: true } | { ok: false; code: "cross_origin_forbidden" } +>; + +/** + * Same-origin check for report APIs. An absent Origin header (curl, server + * tests, same-origin fetch from the browser never sends Origin for GET but + * does for POST) is accepted; a matching request origin is accepted; a + * configured trusted-proxy/test allowlist is accepted; anything else is + * rejected. + */ +export function checkSameOrigin( + requestUrl: string | URL, + originHeader: string | null, + allowedOrigins: readonly string[], +): SameOriginDecision { + const origin = originHeader?.trim(); + if (!origin) return { ok: true }; + let requestOrigin: string; + try { + requestOrigin = new URL(requestUrl).origin; + } catch { + return { ok: false, code: "cross_origin_forbidden" }; + } + if (origin === requestOrigin) return { ok: true }; + if (allowedOrigins.includes(origin)) return { ok: true }; + return { ok: false, code: "cross_origin_forbidden" }; +} + +export type PersonalReportEntitlementResult = Readonly< + | { allowed: true } + | { allowed: false; code: "report_export_disabled"; httpStatus: 403 } + | { allowed: false; code: "report_generation_in_progress"; httpStatus: 409 } + | { allowed: false; code: "report_rate_limited"; httpStatus: 429 } +>; + +export type PersonalReportEntitlementDeps = Readonly<{ + userId: string; + featureEnabled: boolean; + dailyLimit: number; + countGenerating: (userId: string) => Promise; + countCreatedToday: (userId: string) => Promise; +}>; + +export async function checkPersonalReportEntitlement( + deps: PersonalReportEntitlementDeps, +): Promise { + if (!deps.featureEnabled) { + return { allowed: false, code: "report_export_disabled", httpStatus: 403 }; + } + const generating = await deps.countGenerating(deps.userId); + if (generating > 0) { + return { allowed: false, code: "report_generation_in_progress", httpStatus: 409 }; + } + const createdToday = await deps.countCreatedToday(deps.userId); + if (createdToday >= deps.dailyLimit) { + return { allowed: false, code: "report_rate_limited", httpStatus: 429 }; + } + return { allowed: true }; +} diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts new file mode 100644 index 00000000..7fc3e1e4 --- /dev/null +++ b/frontend/src/lib/personal-report-generation.ts @@ -0,0 +1,1215 @@ +import { createHash } from "node:crypto"; +import { + computeEvidenceHash, + safeParseServerReportDocument, +} from "./personal-report-contract.server-core.ts"; +import type { + EvidenceAppendix, + ReportDocumentV1, +} from "./personal-report-contract.ts"; +import type { + EvidenceRefStatus, + PersonalReportAgentOutput, + ReportAgentPort, + ReportEvidencePacket, + ReportPlanetFact, +} from "@/mastra/personal-report"; +import upstreamSourceManifest from "../../../references/upstream/yinduzhanxing/source-manifest.json"; +// Compatibility re-export: prefer importing from ./personal-report-codes.ts +// directly (the pure, dependency-free codes module). +export { REPORT_STABLE_CODES } from "./personal-report-codes.ts"; + +/** + * Personal report generation: workflow evidence -> minimal packet -> report + * agent -> candidate document -> deterministic guard -> canonical server + * parse. + * + * Contract and persistence are the canonical shared modules (p3): + * - `personal-report-contract.ts` (isomorphic) + `personal-report-contract.server-core.ts` + * / `personal-report-contract.server.ts` (server hash + parse entry). + * - `personal-report-service-core.ts` / `personal-report-service.ts` + * (persistence, fingerprint idempotency). + * + * This module never duplicates the schema and never falls back to + * mock/example/random/sample data. Missing real evidence fails closed. + * Stable API codes live in the dependency-free ./personal-report-codes.ts. + */ + +// --------------------------------------------------------------------------- +// Stable failure codes live in ./personal-report-codes.ts (imported above). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Canonical serialization + fingerprints +// --------------------------------------------------------------------------- + +export function canonicalSerialize(value: unknown): string { + if (value === undefined) return "null"; + if (Array.isArray(value)) { + return `[${value.map(canonicalSerialize).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const source = value as Record; + const keys = Object.keys(source).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${canonicalSerialize(source[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function sha256Hex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +const sha256Pattern = /^[0-9a-f]{64}$/; +const sha1Pattern = /^[0-9a-f]{40}$/; + +/** + * Canonical request fingerprint for idempotency. Represents ONLY the request + * payload (reportType, presentationMode, sorted/deduped themes, sessionId, + * chartProfileId). requestId is deliberately excluded: (user_id, request_id) + * is already the unique key and the fingerprint exists solely to detect a + * different payload under the same requestId (409 report_request_conflict). + */ +export function computeRequestFingerprint(input: Readonly<{ + reportType: string; + presentationMode: string; + themes: readonly string[]; + sessionId: string | null; + chartProfileId: string | null; +}>): string { + return sha256Hex(canonicalSerialize({ + reportType: input.reportType, + presentationMode: input.presentationMode, + themes: [...new Set(input.themes)].sort(), + sessionId: input.sessionId, + chartProfileId: input.chartProfileId, + })); +} + +// --------------------------------------------------------------------------- +// Skill snapshot provenance (real server-side value, never "unknown") +// --------------------------------------------------------------------------- + +export type SkillSnapshot = Readonly<{ + sha256: string; + sourceCommit: string | null; +}>; + +export class SkillSnapshotUnavailableError extends Error { + readonly code = "calculation_unavailable"; + + constructor(reason: string) { + super(`Skill snapshot unavailable: ${reason}`); + this.name = "SkillSnapshotUnavailableError"; + } +} + +/** + * Static upstream import manifest (packaged at build time; Docker copies + * references/ into the image). Only the skill snapshot fields are read. + */ +const upstreamManifest = upstreamSourceManifest as Readonly<{ + skill_sha256?: string; + source_commit?: string | null; +}>; + +let cachedSkillSnapshot: SkillSnapshot | null = null; + +/** + * Real skill snapshot provenance, in order: + * 1. env pin JYOTISH_SKILL_SNAPSHOT_SHA256 (validated 64-hex); + * 2. the statically packaged upstream source-manifest skill_sha256. + * sourceCommit is the validated env pin JYOTISH_SKILL_SOURCE_COMMIT, falling + * back to the manifest source_commit when it is a valid 40-hex sha. When no + * valid sha is available this THROWS — reports must never carry a hashed + * sentinel pretending to be a real snapshot. + */ +export function resolveSkillSnapshot(): SkillSnapshot { + if (cachedSkillSnapshot) return cachedSkillSnapshot; + const envSha = process.env.JYOTISH_SKILL_SNAPSHOT_SHA256?.trim(); + const envCommit = process.env.JYOTISH_SKILL_SOURCE_COMMIT?.trim(); + const manifestSha = typeof upstreamManifest.skill_sha256 === "string" + ? upstreamManifest.skill_sha256 + : null; + const manifestCommit = typeof upstreamManifest.source_commit === "string" + ? upstreamManifest.source_commit + : null; + + const sha = envSha && sha256Pattern.test(envSha) + ? envSha + : manifestSha && sha256Pattern.test(manifestSha) + ? manifestSha + : null; + if (!sha) { + throw new SkillSnapshotUnavailableError( + "no valid env pin and no valid static manifest skill_sha256", + ); + } + const sourceCommit = envCommit && sha1Pattern.test(envCommit) + ? envCommit + : manifestCommit && sha1Pattern.test(manifestCommit) + ? manifestCommit + : null; + cachedSkillSnapshot = { sha256: sha, sourceCommit }; + return cachedSkillSnapshot; +} + +// --------------------------------------------------------------------------- +// Allowlist evidence packet builder (workflow response -> minimal packet) +// --------------------------------------------------------------------------- + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function booleanValue(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(text).filter((item): item is string => item !== null); +} + +const SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] as const; + +const SIGN_INDEX = new Map(SIGNS.map((sign, index) => [sign, index])); +const SIGN_INDEX_CN = new Map([ + ["白羊座", 0], ["金牛座", 1], ["双子座", 2], ["巨蟹座", 3], ["狮子座", 4], ["处女座", 5], + ["天秤座", 6], ["天蝎座", 7], ["射手座", 8], ["摩羯座", 9], ["水瓶座", 10], ["双鱼座", 11], +]); + +function signIndex(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 11) return value; + if (typeof value === "string") { + return SIGN_INDEX.get(value) ?? SIGN_INDEX_CN.get(value) ?? null; + } + return null; +} + +/** + * Mirrors the orchestrator's base_chart selection exactly: + * modules.chart (dict) -> chart_data.chart (nested) -> chart_data itself. + */ +function resolveBaseChart(chartData: JsonRecord): JsonRecord { + const modules = record(chartData.modules); + const modulesChart = modules ? record(modules.chart) : null; + if (modulesChart) return modulesChart; + const nested = record(chartData.chart); + if (nested) return nested; + return chartData; +} + +/** + * Planets accept the real engine's object map ({Sun: {...}, ...}) and the + * legacy array shape. Absolute longitude comes from degree_raw / longitude_deg + * / lon / absolute_degree / degree (the engine's planet degree is the absolute + * 0-360 longitude; degree_in_sign is the in-sign offset and is NOT used here). + * Entries without a complete fact set are skipped (allowlist of full facts). + */ +function readPlanets(value: unknown): ReportPlanetFact[] { + const planets: ReportPlanetFact[] = []; + const entries: Readonly<[string, unknown]>[] = Array.isArray(value) + ? value.map((item, index) => [String(index), item] as const) + : Object.entries(record(value) ?? {}); + for (const [key, item] of entries) { + const row = record(item); + if (!row) continue; + const id = text(row?.id ?? row?.name ?? row?.planet) ?? key; + const sign = text(row?.sign ?? row?.sign_name); + const degree = finiteNumber( + row?.degree_raw ?? row?.longitude_deg ?? row?.lon ?? row?.absolute_degree ?? row?.degree, + ); + if (!sign || degree === null) continue; + planets.push({ + id, + sign, + degree, + house: finiteNumber(row?.house ?? row?.house_number), + retrograde: booleanValue(row?.retrograde ?? row?.is_retrograde), + }); + } + return planets; +} + +/** + * Houses accept the real engine's object map ({house_1: {cusp_sign, ...}}) and + * the legacy array shape ({number, sign}). The real map has NO whole-sign + * `sign` field (only Placidus cusp_sign); house signs are therefore derived + * deterministically from the ascendant sign + house number (whole-sign, + * matching the engine's own whole-sign planet-house numbering) and marked + * signDerived. Array entries that carry a real sign keep it. Occupants are + * filled from planet whole-sign house numbers. + */ +function readHouses( + value: unknown, + ascendantSignIndex: number | null, + planets: readonly ReportPlanetFact[], +): ReportEvidencePacket["chart"]["houses"] { + const rows: Readonly<{ number: number; sign: string | null }>[] = []; + if (Array.isArray(value)) { + for (const item of value) { + const row = record(item); + const number = finiteNumber(row?.number ?? row?.house ?? row?.index ?? row?.house_number); + if (number === null) continue; + rows.push({ number, sign: text(row?.sign ?? row?.sign_name) }); + } + } else { + const map = record(value) ?? {}; + for (const [key, item] of Object.entries(map)) { + const number = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null); + if (!number) continue; + const parsed = Number.parseInt(number, 10); + if (parsed < 1 || parsed > 12) continue; + const row = record(item); + rows.push({ number: parsed, sign: row ? text(row.sign ?? row.sign_name) : null }); + } + } + const houses: ReportEvidencePacket["chart"]["houses"] = rows.map((row) => { + const realSign = row.sign; + const derivedSign = ascendantSignIndex !== null + ? SIGNS[((ascendantSignIndex + row.number - 1) % 12 + 12) % 12] + : null; + const sign = realSign ?? derivedSign; + if (!sign) return { number: row.number, sign: "", signDerived: true, occupants: [] }; + return { + number: row.number, + sign, + signDerived: realSign === null, + occupants: planets + .filter((planet) => planet.house === row.number) + .map((planet) => planet.id) + .slice(0, 12), + }; + }); + return houses; +} + +/** + * Divisional charts in the real engine live under modules.varga_full with keys + * D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx| + * sign}, : {sign_idx|sign}, _meta, _dignity, ...} — there are no house + * arrays. House signs are derived whole-sign from the divisional ascendant and + * occupants from each planet's sign index (the same derivation the engine uses + * for D11). Nothing is fabricated; missing varga data simply omits the chart. + */ +function readVargaHouses( + vargaFull: JsonRecord | null, +): ReportEvidencePacket["chart"]["vargaHouses"] { + if (!vargaFull) return []; + const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = []; + const variants: Readonly> = { + D9: ["D9_Navamsa", "D9"], + D10: ["D10_Dasamsa", "D10"], + }; + for (const [id, keys] of Object.entries(variants) as ReadonlyArray) { + const varga = keys + .map((key) => record(vargaFull[key])) + .find((entry): entry is JsonRecord => entry !== null); + if (!varga) continue; + const ascendant = record(varga.Ascendant); + const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; + if (ascIndex === null) continue; + const occupants: string[][] = Array.from({ length: 12 }, () => []); + for (const [name, item] of Object.entries(varga)) { + if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; + const row = record(item); + const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; + if (planetIndex === null) continue; + const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; + occupants[house - 1].push(name); + } + const houses: ReportEvidencePacket["chart"]["houses"] = Array.from( + { length: 12 }, + (_, index) => ({ + number: index + 1, + sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], + signDerived: true, + occupants: occupants[index].slice(0, 12), + }), + ); + result.push({ id, houses }); + } + return result; +} + +function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] { + if (!Array.isArray(rows)) return null; + const periods: { lord: string; start: string; end: string }[] = []; + for (const item of rows) { + const row = record(item); + const lord = text(row?.lord ?? row?.planet ?? row?.name); + const start = text(row?.start ?? row?.start_date); + const end = text(row?.end ?? row?.end_date); + if (lord && start && end) periods.push({ lord, start, end }); + } + return periods.length > 0 ? periods : null; +} + +function readVimshottari(chart: JsonRecord | null): ReportEvidencePacket["chart"]["vimshottari"] { + const dasha = record(chart?.dasha); + if (!dasha) return null; + const mahadashas = Array.isArray(dasha.mahadashas) ? dasha.mahadashas : null; + return mahadashas ? readDashaPeriods(mahadashas) : null; +} + +function readNarayana(modules: JsonRecord | null): ReportEvidencePacket["chart"]["narayana"] { + const narayana = record(modules?.narayana_dasha); + if (!narayana) return null; + const rows = Array.isArray(narayana.periods) ? narayana.periods + : Array.isArray(narayana.mahadashas) ? narayana.mahadashas : null; + return rows ? readDashaPeriods(rows) : null; +} + +/** + * machine_evidence_packet.sections is an object map in the real engine + * ({D1: {status: "used"|"missing", source_path}, planet_degrees: {...}, ...}); + * the legacy array shape is also accepted. + */ +function readSections(machinePacket: JsonRecord | null): { + name: string; + status: string; + sourcePath: string; +}[] { + const raw = machinePacket?.sections; + const entries = Array.isArray(raw) + ? raw.map((item, index) => [String(index), item] as const) + : Object.entries(record(raw) ?? {}); + const sections: { name: string; status: string; sourcePath: string }[] = []; + for (const [key, item] of entries) { + const row = record(item) ?? {}; + sections.push({ + name: text(row?.name ?? row?.technique) ?? key, + status: text(row?.status) ?? "unknown", + sourcePath: text(row?.source_path) ?? "", + }); + } + return sections; +} + +/** + * Deterministic evidence-status rule for machine-packet sections. The real + * engine only emits used/missing; "verified" as a literal string must never be + * required or the gate would always fail. Core calculation sections (D1, + * planet_degrees, house_degrees) with an internal source path map to verified; + * everything else internal is partial; external oracle sections are capped at + * partial; missing/blocked stay blocked. + */ +const VERIFIED_CALCULATION_SECTIONS = new Set(["D1", "planet_degrees", "house_degrees"]); +const EXTERNAL_SECTIONS = new Set([ + "external_oracle_status", + "vedastro_official_raw_response", + "vedastro_official_raw_archive_manifest", +]); + +function sectionEvidenceStatus( + status: string, + name: string, + sourcePath: string, +): "verified" | "partial" | "blocked" { + if (status === "missing" || status === "blocked") return "blocked"; + if (status === "verified") return "verified"; + if (status === "partial") return "partial"; + // used / available / received_unverified / unknown / undefined + const internal = sourcePath.startsWith("chart.") + || sourcePath.startsWith("modules.") + || sourcePath.startsWith("scripts."); + if (VERIFIED_CALCULATION_SECTIONS.has(name) && internal) return "verified"; + if (EXTERNAL_SECTIONS.has(name) || sourcePath.startsWith("vedastro_")) return "partial"; + return "partial"; +} + +export type BuildEvidencePacketInput = Readonly<{ + workflow: unknown; + subject: ReportEvidencePacket["subject"]; + requestedThemes: readonly string[]; + reportType: "personal_full" | "personal_thematic"; + presentationMode: "default" | "research"; + candidateRange: Readonly<{ start: string; end: string }> | null; + skillSnapshot: SkillSnapshot; +}>; + +/** Raised when the real workflow evidence cannot support an honest report. */ +export class ReportEvidenceInsufficientError extends Error { + readonly code = "calculation_unavailable"; + + constructor(reason: string) { + super(`Report evidence insufficient: ${reason}`); + this.name = "ReportEvidenceInsufficientError"; + } +} + +function assertUsablePacket(packet: ReportEvidencePacket): void { + if (!packet.chart.ascendant) { + throw new ReportEvidenceInsufficientError("ascendant_missing"); + } + const houseNumbers = packet.chart.houses.map((house) => house.number); + const unique = new Set(houseNumbers); + if (houseNumbers.length !== 12 || unique.size !== 12 + || houseNumbers.some((number) => number < 1 || number > 12)) { + throw new ReportEvidenceInsufficientError("d1_houses_incomplete"); + } + if (packet.chart.planets.length === 0) { + throw new ReportEvidenceInsufficientError("planets_missing"); + } + if (packet.chart.planets.some((planet) => planet.house === null || planet.retrograde === null)) { + throw new ReportEvidenceInsufficientError("planet_fact_incomplete"); + } + if (packet.evidenceRefs.length === 0) { + throw new ReportEvidenceInsufficientError("evidence_refs_missing"); + } + // At least one ref must be backed by an explicit verified fact; pure layer + // names (partial) are not enough to claim evidence-backed sections. + if (!packet.evidenceRefs.some((ref) => ref.status === "verified")) { + throw new ReportEvidenceInsufficientError("no_verified_evidence_ref"); + } +} + +/** + * Extracts ONLY allowlisted facts from the real workflow response. Internal + * paths, prompts, exception stacks, chat history and unrelated raw objects are + * structurally excluded: unknown keys are never copied. Fails closed when the + * evidence cannot support a report. + */ +export function buildReportEvidencePacket(input: BuildEvidencePacketInput): ReportEvidencePacket { + const workflow = record(input.workflow) ?? {}; + const chartData = record(workflow.chart) ?? {}; + const modules = record(chartData.modules) ?? {}; + const consumerContext = record(workflow.consumer_context) ?? {}; + const machinePacket = record(workflow.machine_evidence_packet) ?? {}; + const answerPolicy = record(consumerContext.answer_policy) ?? {}; + + // Real base chart selection mirrors the orchestrator: modules.chart (dict) + // -> chart_data.chart (nested) -> chart_data itself. Houses fall back to the + // top-level chart like the orchestrator's house_degrees section does. + const baseChart = resolveBaseChart(chartData); + + const ascendant = record(baseChart.ascendant); + const ascendantSign = text(ascendant?.sign ?? ascendant?.sign_name); + const ascendantSignIndex = ascendantSign ? signIndex(ascendantSign) : null; + const ascendantDegree = finiteNumber( + ascendant?.degree_in_sign ?? ascendant?.degree ?? ascendant?.longitude_deg ?? ascendant?.lon, + ); + + const planets = readPlanets(baseChart.planets); + const houses = readHouses( + baseChart.houses ?? chartData.houses, + ascendantSignIndex, + planets, + ); + const vimshottari = readVimshottari(baseChart); + const narayana = readNarayana(modules); + const vargaHouses = readVargaHouses(record(modules.varga_full)); + + const availableLayers = stringArray(consumerContext.available_layers); + const missingLayers = stringArray(consumerContext.missing_route_layers); + const hardBlockers = stringArray(consumerContext.hard_blockers); + + const techniqueAudit: { technique: string; status: string; note: string }[] = []; + const seenTechniques = new Set(); + const sections = readSections(machinePacket); + const sectionsByName = new Map(sections.map((section) => [section.name, section])); + // Layer names alone are route availability, not verified facts: they map to + // partial at best. + for (const technique of [...availableLayers, ...missingLayers, ...hardBlockers]) { + if (!technique || seenTechniques.has(technique)) continue; + seenTechniques.add(technique); + const status = hardBlockers.includes(technique) + ? "blocked" + : missingLayers.includes(technique) + ? "missing" + : "available"; + techniqueAudit.push({ + technique, + status, + note: status === "missing" + ? "not computed for this route" + : status === "blocked" + ? "hard blocker" + : "", + }); + } + // Machine-packet sections (object map in the real engine, array accepted). + // The section name is the technique key; status comes from section.status + // via the deterministic sectionEvidenceStatus rule. + for (const section of sections) { + if (seenTechniques.has(section.name)) continue; + seenTechniques.add(section.name); + techniqueAudit.push({ + technique: section.name, + status: section.status, + note: section.sourcePath ? `source: ${section.sourcePath}` : "", + }); + } + + const blockedTechniques = hardBlockers.length > 0 + ? hardBlockers + : techniqueAudit.filter((row) => row.status === "blocked").map((row) => row.technique); + + const conflicts: { techniques: string[]; summary: string }[] = []; + const rawConflicts = Array.isArray(machinePacket.conflicts) + ? machinePacket.conflicts + : Array.isArray(consumerContext.conflicts) + ? consumerContext.conflicts + : []; + for (const item of rawConflicts) { + const row = record(item); + const summary = text(row?.summary ?? row?.message ?? row?.description); + const techniques = stringArray(row?.techniques ?? row?.layers); + if (summary) conflicts.push({ techniques, summary }); + } + + const evidenceRefs: { id: string; technique: string; status: EvidenceRefStatus }[] = []; + techniqueAudit.forEach((row, index) => { + const section = sectionsByName.get(row.technique); + const status = section + ? sectionEvidenceStatus(section.status, section.name, section.sourcePath) + : canonicalTechniqueStatus(row.status); + evidenceRefs.push({ + id: `ev-audit-${index + 1}`, + technique: row.technique, + status, + }); + }); + conflicts.forEach((conflict, index) => { + evidenceRefs.push({ + id: `ev-conflict-${index + 1}`, + technique: conflict.techniques.join("+") || "conflict", + status: "blocked", + }); + }); + + const deterministicForbidden = stringArray(answerPolicy.deterministic_claims_forbidden_for); + const canAnswerPreciseTiming = answerPolicy.can_answer_precise_timing === true; + + const calculationFacts = { + ascendant: ascendantSign && ascendantDegree !== null + ? { sign: ascendantSign, degree: ascendantDegree } + : null, + planets, + houses, + vimshottari, + narayana, + }; + const engineHash = text(baseChart.result_hash) ?? text(chartData.result_hash) ?? text(machinePacket.calculation_hash); + const calculationHash = engineHash && sha256Pattern.test(engineHash) + ? engineHash + : sha256Hex(canonicalSerialize(calculationFacts)); + + const packet: ReportEvidencePacket = { + schemaVersion: "report_evidence_packet.v1", + subject: input.subject, + requestedThemes: [...input.requestedThemes], + reportType: input.reportType, + presentationMode: input.presentationMode, + chart: { + calculationHash, + calculationHashDerived: !(engineHash && sha256Pattern.test(engineHash)), + ascendant: ascendantSign && ascendantDegree !== null + ? { sign: ascendantSign, degree: ascendantDegree } + : null, + planets, + houses, + vimshottari, + narayana, + vargaHouses, + }, + techniqueAudit, + conflicts, + blockedTechniques: [...new Set(blockedTechniques)], + evidenceRefs, + candidateRange: input.candidateRange, + answerPolicy: { + canAnswerPreciseTiming: canAnswerPreciseTiming && input.candidateRange === null, + deterministicClaimsForbiddenFor: [...new Set(deterministicForbidden)], + }, + skillSnapshotSha256: input.skillSnapshot.sha256, + skillSourceCommit: input.skillSnapshot.sourceCommit, + }; + assertUsablePacket(packet); + return packet; +} + +export function canonicalTechniqueStatus(status: string): "verified" | "partial" | "blocked" { + if (status === "verified") return "verified"; + // "available" is only a route-layer name, never verified evidence. + if (status === "partial" || status === "available" || status === "unknown") return "partial"; + return "blocked"; +} + +function techniqueSlug(name: string, fallback: string): string { + const slug = name.toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80); + return /^[a-z0-9_.-]{1,80}$/.test(slug) ? slug : fallback; +} + +// --------------------------------------------------------------------------- +// Document assembly (deterministic; agent writes narrative only) +// --------------------------------------------------------------------------- + +export const PERSONAL_REPORT_DISCLAIMER = + "本报告基于所提供出生信息与服务器计算的排盘证据生成,属于解释性参考,不构成医疗、法律或投资建议。出生时间未经确认时,报告中的时间相关表述仅为方向性参考。"; + +export type AssembleReportDocumentInput = Readonly<{ + reportId: string; + generatedAt: string; + packet: ReportEvidencePacket; + agentOutput: PersonalReportAgentOutput; +}>; + +function canonicalChartHouses( + houses: readonly ReportEvidencePacket["chart"]["houses"][number][], +): ReportDocumentV1["charts"][number]["houses"] { + return houses.map((house) => ({ + houseNumber: house.number, + sign: house.sign, + occupants: [...house.occupants].slice(0, 12), + })); +} + +function canonicalPlanets( + planets: readonly ReportPlanetFact[], +): ReportDocumentV1["charts"][number]["planets"] { + return planets.map((planet) => ({ + name: planet.id, + sign: planet.sign, + longitudeDegrees: planet.degree, + houseNumber: planet.house as number, + retrograde: planet.retrograde as boolean, + })); +} + +/** + * Builds the candidate ReportDocument v1. Order is strict: appendix first, + * then evidenceHash = computeEvidenceHash(appendix) (canonical server hash, + * never a model self-report), then the final document. + */ +export function assembleReportDocument( + input: AssembleReportDocumentInput, +): ReportDocumentV1 { + const { packet } = input; + if (!packet.chart.ascendant) { + throw new ReportEvidenceInsufficientError("ascendant_missing"); + } + + const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map( + (row, index) => ({ + id: `ev-audit-${index + 1}`, + techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`), + techniqueName: row.technique, + status: canonicalTechniqueStatus(row.status), + used: canonicalTechniqueStatus(row.status) === "verified", + ...(row.note ? { notes: row.note.slice(0, 500) } : {}), + }), + ); + + const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({ + id: `ev-conflict-${index + 1}`, + description: conflict.summary.slice(0, 1000), + impact: "多技法结果不一致,相关结论已按确定性边界降级", + status: "unresolved", + })); + + const calculationEvidence: EvidenceAppendix["calculationEvidence"] = []; + if (packet.chart.calculationHashDerived) { + calculationEvidence.push({ + id: "ev-calc-derived", + label: "calculation_hash", + value: packet.chart.calculationHash, + source: "derived_server_sha256_over_allowlisted_calculation_facts", + }); + } + if (packet.chart.houses.some((house) => house.signDerived) + || packet.chart.vargaHouses.some((varga) => varga.houses.some((house) => house.signDerived))) { + calculationEvidence.push({ + id: "ev-calc-house-signs", + label: "house_sign_derivation", + value: "whole_sign_from_ascendant_for_houses_without_a_source_sign", + source: "server_derived", + }); + } + calculationEvidence.push({ + id: "ev-calc-ascendant", + label: "ascendant", + value: `${packet.chart.ascendant.sign} ${packet.chart.ascendant.degree.toFixed(2)}°`, + source: "server_calculation", + }); + packet.chart.vimshottari?.forEach((period, index) => { + calculationEvidence.push({ + id: `ev-calc-vimshottari-${index + 1}`, + label: `Vimshottari 大运:${period.lord}`, + value: `${period.start} – ${period.end}`, + source: "server_calculation", + }); + }); + packet.chart.narayana?.forEach((period, index) => { + calculationEvidence.push({ + id: `ev-calc-narayana-${index + 1}`, + label: `Narayana 大运:${period.lord}`, + value: `${period.start} – ${period.end}`, + source: "server_calculation", + }); + }); + + const appendix: EvidenceAppendix = { + expandedByDefault: false, + techniqueAudit, + conflicts, + calculationEvidence, + blockedTechniques: packet.blockedTechniques + .map((technique) => technique.slice(0, 120)) + .slice(0, 100), + }; + const evidenceHash = computeEvidenceHash(appendix); + + const charts: ReportDocumentV1["charts"] = [{ + id: "D1", + title: "本命盘 D1", + houses: canonicalChartHouses(packet.chart.houses), + planets: canonicalPlanets(packet.chart.planets), + claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference", + }]; + for (const varga of packet.chart.vargaHouses) { + if (varga.houses.length === 0) continue; + charts.push({ + id: varga.id, + title: varga.id === "D9" ? "九分盘 D9" : "事业盘 D10", + houses: canonicalChartHouses(varga.houses), + claimStatus: "single_system_inference", + }); + } + + const document: ReportDocumentV1 = { + schemaVersion: "report_document.v1", + reportId: input.reportId, + reportType: packet.reportType, + presentationMode: packet.presentationMode, + generatedAt: input.generatedAt, + subject: { + displayName: packet.subject.displayName, + birthTimeStatus: packet.subject.birthTimeStatus, + birthPlaceLabel: packet.subject.birthPlaceLabel, + }, + provenance: { + skillSourceCommit: packet.skillSourceCommit, + skillSnapshotSha256: packet.skillSnapshotSha256, + calculationHash: packet.chart.calculationHash, + evidenceHash, + reportContractVersion: "1", + }, + executiveSummary: { + headline: input.agentOutput.executiveSummary.headline, + summary: input.agentOutput.executiveSummary.summary, + priorities: [...input.agentOutput.executiveSummary.priorities], + overallClaimStatus: input.agentOutput.thematicNarrative.some( + (section) => section.claimStatus === "blocked", + ) + ? "blocked" + : "single_system_inference", + }, + charts, + thematicNarrative: input.agentOutput.thematicNarrative.map((section) => ({ + id: section.id, + title: section.title, + narrative: section.narrative, + actions: [...section.actions], + caveats: [...section.caveats], + claimStatus: section.claimStatus, + evidenceRefs: [...section.evidenceRefs], + })), + evidenceAppendix: appendix, + disclaimer: PERSONAL_REPORT_DISCLAIMER, + }; + return document; +} + +// --------------------------------------------------------------------------- +// Deterministic post-generation guard +// --------------------------------------------------------------------------- + +export const PRECISE_TIMING_PATTERNS: readonly RegExp[] = [ + /20\d{2}\s*年\s*[0-90-9一二三四五六七八九十]{1,2}\s*月(?:\s*[0-90-9一二三四五六七八九十]{1,2}\s*日)?/, + /[0-90-9一二三四五六七八九十]{1,2}\s*月\s*[0-90-9一二三四五六七八九十]{1,2}\s*日/, + /(?:今年|明年|后年|本月|下月)\s*[0-90-9一二三四五六七八九十]{1,2}\s*月/, + /(?:今年|明年|后年)\s*(?:上旬|中旬|下旬)/, +]; + +export const MEDICAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:得|患|染)(?:上)?(?:癌症|肿瘤|心脏病|糖尿病|绝症|重病|白血病)/, + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:不孕|流产|难产|残疾|瘫痪|失明|早逝|夭折|猝死)/, + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:治愈|康复|痊愈|好转)/, + /必死|必生男|必生女|必然不孕|命中注定(?:会)?(?:死|得病)/, +]; + +export const LEGAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:胜诉|败诉|无罪|获释|判刑|坐牢|诉讼成功|官司(?:能|会)?赢)/, +]; + +export const INVESTMENT_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证|稳|必)\s*(?:会|能|将)?\s*(?:赚钱|盈利|回本|大涨|暴涨|涨停|翻倍|暴富|亏光)/, + /稳赚不赔|必涨|必跌|保本保息|包赚/, +]; + +export type ForbiddenClaimDomain = "medical" | "legal" | "investment" | "timing"; + +export type ForbiddenClaim = Readonly<{ domain: ForbiddenClaimDomain; matchedText: string }>; + +export function findForbiddenDeterministicClaims(textValue: string): ForbiddenClaim[] { + const claims: ForbiddenClaim[] = []; + const scan = (domain: ForbiddenClaimDomain, patterns: readonly RegExp[]) => { + for (const pattern of patterns) { + const match = textValue.match(pattern); + if (match) claims.push({ domain, matchedText: match[0] }); + } + }; + scan("timing", PRECISE_TIMING_PATTERNS); + scan("medical", MEDICAL_DETERMINISTIC_PATTERNS); + scan("legal", LEGAL_DETERMINISTIC_PATTERNS); + scan("investment", INVESTMENT_DETERMINISTIC_PATTERNS); + return claims; +} + +export function splitSentences(textValue: string): string[] { + return textValue + .split(/(?<=[。!?!?;;])\s*|\n+/u) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +export type RedactionResult = Readonly<{ text: string; removedCount: number }>; + +/** + * Removes sentences that match the given forbidden patterns. Deterministic + * and locale-independent: sentence splitting is punctuation/newline based. + */ +export function redactDeterministicSentences( + textValue: string, + patterns: readonly RegExp[], +): RedactionResult { + const sentences = splitSentences(textValue); + const kept: string[] = []; + let removedCount = 0; + for (const sentence of sentences) { + if (patterns.some((pattern) => pattern.test(sentence))) { + removedCount += 1; + } else { + kept.push(sentence); + } + } + return { text: kept.join(""), removedCount }; +} + +const BLOCKED_SECTION_CAVEAT = "该部分证据受限,已按确定性边界降级,仅保留方向性描述。"; + +type GuardSection = { + id: string; + narrative: string; + claimStatus: string; + evidenceRefs: string[]; + caveats: string[]; +}; + +export type ReportGuardReadModel = { + executiveSummary: { headline: string; summary: string; overallClaimStatus: string }; + sections: GuardSection[]; +}; + +/** Structural projection of a parsed document for guard purposes only. */ +export function projectReportGuardReadModel(document: unknown): ReportGuardReadModel | null { + const root = record(document); + if (!root) return null; + const executiveSummary = record(root.executiveSummary); + if (!executiveSummary) return null; + const headline = text(executiveSummary.headline); + const summary = text(executiveSummary.summary); + const overallClaimStatus = text(executiveSummary.overallClaimStatus); + if (!headline || !summary || !overallClaimStatus) return null; + if (!Array.isArray(root.thematicNarrative)) return null; + const sections: GuardSection[] = []; + for (const item of root.thematicNarrative) { + const row = record(item); + const id = text(row?.id); + const narrative = text(row?.narrative); + const claimStatus = text(row?.claimStatus); + if (!id || !narrative || !claimStatus) return null; + sections.push({ + id, + narrative, + claimStatus, + evidenceRefs: stringArray(row?.evidenceRefs), + caveats: stringArray(row?.caveats), + }); + } + if (sections.length === 0) return null; + return { executiveSummary: { headline, summary, overallClaimStatus }, sections }; +} + +export type GuardResult = + | { ok: true; document: D } + | { ok: false; code: "report_guard_rejected"; reason: string }; + +function effectiveClaimStatus( + claimed: string, + refs: readonly ReportEvidencePacket["evidenceRefs"][number][], +): string { + if (refs.length === 0) return "blocked"; + const refById = new Map(refs.map((ref) => [ref.id, ref])); + let hasBlocked = false; + for (const sectionRef of refs) { + if (!refById.has(sectionRef.id) || refById.get(sectionRef.id)!.status === "blocked") { + hasBlocked = true; + } + } + if (hasBlocked && refs.every((ref) => refById.get(ref.id)?.status === "blocked")) { + return "blocked"; + } + if (hasBlocked) return "parameter_sensitive"; + return claimed; +} + +/** + * Deterministic post-generation guard. It never invents data; it only + * downgrades, redacts, or rejects. Runs BEFORE the final canonical server + * parse, so every mutation is re-validated by the contract. + */ +export function applyReportGuard( + document: D, + packet: ReportEvidencePacket, +): GuardResult { + const readModel = projectReportGuardReadModel(document); + if (!readModel) { + return { ok: false, code: "report_guard_rejected", reason: "report_document_unreadable" }; + } + + // 1. evidenceRefs existence: every section ref must exist in the packet + // (the packet refs ARE the canonical appendix ids). + const packetRefIds = new Set(packet.evidenceRefs.map((ref) => ref.id)); + for (const section of readModel.sections) { + for (const ref of section.evidenceRefs) { + if (!packetRefIds.has(ref)) { + return { ok: false, code: "report_guard_rejected", reason: `unresolved_evidence_ref:${ref}` }; + } + } + } + + const next = structuredClone(document) as JsonRecord; + const narrative = Array.isArray(next.thematicNarrative) ? next.thematicNarrative : []; + const summary = record(next.executiveSummary); + const timingBlocked = !packet.answerPolicy.canAnswerPreciseTiming; + + const summaryPriorities = Array.isArray(summary?.priorities) ? summary.priorities : []; + const summaryTextForScan = [ + readModel.executiveSummary.summary, + ...summaryPriorities.map(String), + ].join("\n"); + + // 2. Medical / legal / investment deterministic claims are never shippable. + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + const sectionText = [ + text(target?.narrative) ?? section.narrative, + ...(Array.isArray(target?.actions) ? target.actions.map(String) : []), + ...(Array.isArray(target?.caveats) ? target.caveats.map(String) : []), + ].join("\n"); + const hardDomain = findForbiddenDeterministicClaims(sectionText).find( + (claim) => claim.domain !== "timing", + ); + if (hardDomain) { + return { + ok: false, + code: "report_guard_rejected", + reason: `deterministic_${hardDomain.domain}_claim`, + }; + } + } + if (findForbiddenDeterministicClaims(summaryTextForScan).some((claim) => claim.domain !== "timing")) { + return { + ok: false, + code: "report_guard_rejected", + reason: "deterministic_claim_in_summary", + }; + } + if (findForbiddenDeterministicClaims(readModel.executiveSummary.headline).length > 0) { + return { + ok: false, + code: "report_guard_rejected", + reason: "deterministic_claim_in_headline", + }; + } + + // 3. Precise timing restriction: redact future precise timing from sections + // and summary, downgrade affected sections to blocked. + const step3Blocked = new Set(); + if (timingBlocked) { + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + if (!target) continue; + const redacted = redactDeterministicSentences( + typeof target.narrative === "string" ? target.narrative : "", + PRECISE_TIMING_PATTERNS, + ); + let changed = false; + if (redacted.removedCount > 0) { + target.narrative = redacted.text; + changed = true; + } + if (Array.isArray(target.actions)) { + const keptActions = target.actions + .map(String) + .filter((action) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(action))); + if (keptActions.length !== target.actions.length) { + target.actions = keptActions; + changed = true; + } + } + if (Array.isArray(target.caveats)) { + const keptCaveats = target.caveats + .map(String) + .filter((caveat) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(caveat))); + if (keptCaveats.length !== target.caveats.length) { + target.caveats = keptCaveats; + changed = true; + } + } + if (changed) { + step3Blocked.add(section.id); + target.claimStatus = "blocked"; + const caveats = stringArray(target.caveats); + if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT); + target.caveats = caveats; + } + } + const summaryRedacted = redactDeterministicSentences( + readModel.executiveSummary.summary, + PRECISE_TIMING_PATTERNS, + ); + if (summary) { + if (summaryRedacted.removedCount > 0) { + summary.summary = summaryRedacted.text; + summary.overallClaimStatus = "blocked"; + } + if (Array.isArray(summary.priorities)) { + const keptPriorities = summary.priorities + .map(String) + .filter((priority) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(priority))); + if (keptPriorities.length !== summary.priorities.length) { + summary.priorities = keptPriorities; + } + } + } + } + + // 4. Blocked downgrade from evidence refs: a section whose refs are all + // blocked must be blocked; any blocked ref caps the section at + // parameter_sensitive. Blocked sections must not keep deterministic + // phrasing of any domain. If every section ends blocked, the whole + // report is blocked. + const refStatuses = new Map(packet.evidenceRefs.map((ref) => [ref.id, ref.status])); + let blockedSectionCount = 0; + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + if (!target) continue; + const sectionRefs = section.evidenceRefs.map((ref) => ( + refStatuses.has(ref) ? packet.evidenceRefs.find((candidate) => candidate.id === ref)! : null + )).filter((ref): ref is ReportEvidencePacket["evidenceRefs"][number] => ref !== null); + target.claimStatus = step3Blocked.has(section.id) + ? "blocked" + : effectiveClaimStatus(section.claimStatus, sectionRefs); + if (target.claimStatus === "blocked") blockedSectionCount += 1; + const finalStatus = target.claimStatus as string; + if (finalStatus === "blocked") { + const narrativeText = typeof target.narrative === "string" ? target.narrative : ""; + const blockedClaims = findForbiddenDeterministicClaims(narrativeText); + const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing"); + if (hardDomain) { + return { + ok: false, + code: "report_guard_rejected", + reason: `deterministic_${hardDomain.domain}_claim_in_blocked_section`, + }; + } + const redacted = redactDeterministicSentences(narrativeText, PRECISE_TIMING_PATTERNS); + if (redacted.removedCount > 0) { + target.narrative = redacted.text; + } + } + } + if (summary && blockedSectionCount === readModel.sections.length) { + summary.overallClaimStatus = "blocked"; + } + + return { ok: true, document: next as D }; +} + +// --------------------------------------------------------------------------- +// Generation pipeline: agent -> document -> guard -> canonical server parse +// --------------------------------------------------------------------------- + +export type GeneratePersonalReportDeps = Readonly<{ + reportId: string; + packet: ReportEvidencePacket; + agent: ReportAgentPort; + now?: () => Date; +}>; + +export type GeneratePersonalReportResult = Readonly< + | { status: "ready"; document: ReportDocumentV1; evidenceHash: string } + | { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" } +>; + +/** + * Runs the dedicated report agent exactly once (plus its single internal + * repair retry), assembles the candidate document, applies the deterministic + * guard, then runs the FINAL canonical server parse on the guarded document. + * The evidence hash is recomputed from the canonical evidence appendix by the + * server contract — never a model self-report. Never falls back to mock, + * example, random or sample data. + */ +export async function generatePersonalReport( + deps: GeneratePersonalReportDeps, +): Promise { + const agentOutput = await deps.agent.generate(deps.packet); + const candidate = assembleReportDocument({ + reportId: deps.reportId, + generatedAt: (deps.now ?? (() => new Date()))().toISOString(), + packet: deps.packet, + agentOutput, + }); + const guarded = applyReportGuard(candidate, deps.packet); + if (!guarded.ok) { + return { status: "failed", failureCode: "report_guard_rejected" }; + } + const parsed = safeParseServerReportDocument(guarded.document); + if (!parsed.ok) { + return { status: "failed", failureCode: "report_schema_invalid" }; + } + return { + status: "ready", + document: parsed.document, + evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), + }; +} diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts new file mode 100644 index 00000000..cbb1962c --- /dev/null +++ b/frontend/src/lib/personal-report-route-core.ts @@ -0,0 +1,508 @@ +/** + * Personal report route handlers as a dependency-injected core (no + * next/server, no network, no model): fully executable in unit tests with + * fakes. The API routes are thin adapters that resolve the real production + * dependencies (authenticated Supabase reads, admin-backed persistence, + * existing auth/profile/workflow) and map the returned { status, body } to + * NextResponse. + * + * Ownership is enforced twice: the authenticated client/RLS scopes reads and + * deletes, and the persistence service scopes every query by userId. + */ + +import { z } from "zod"; +import type { ConsultationInput } from "@/mastra"; +import type { + ReportAgentPort, + ReportEvidencePacket, +} from "@/mastra/personal-report"; +import { + buildReportEvidencePacket, + computeRequestFingerprint, + generatePersonalReport, + type GeneratePersonalReportResult, + type SkillSnapshot, +} from "./personal-report-generation"; +import { REPORT_STABLE_CODES } from "./personal-report-codes"; +import { checkSameOrigin } from "./personal-report-entitlement"; +import type { + CreateGeneratingInput, + CreateGeneratingResult, + PersonalReportRecord, + PersonalReportService, +} from "./personal-report-service-core"; + +const reportRequestThemes = z.enum(["career", "marriage", "wealth", "timing", "general"]); + +export const personalReportCreateRequestSchema = z.object({ + requestId: z.string().uuid(), + sessionId: z.string().uuid().nullable().optional(), + chartProfileId: z.string().uuid().nullable().optional(), + reportType: z.enum(["personal_full", "personal_thematic"]), + presentationMode: z.enum(["default", "research"]).default("default"), + themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]), +}).strict(); + +export type PersonalReportCreateRequest = z.infer; + +export type ReportRouteResponse = Readonly<{ status: number; body: Record }>; + +export type ReportServicePort = Pick< + PersonalReportService, + | "getByUserAndRequestId" + | "createGenerating" + | "completeReady" + | "markFailed" + | "getOwnedById" + | "deleteOwned" +>; + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +const USABLE_BIRTH_TIME_STATUSES = new Set(["accepted", "confirmed"]); + +function parseClockMinutes(value: unknown): { hour: number; minute: number } | null { + const clock = text(value); + if (!clock) return null; + const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { hour, minute }; +} + +function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null { + const date = text(value); + if (!date) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return null; + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null; + return { year, month, day }; +} + +export function reportView(row: PersonalReportRecord) { + return { + id: row.id, + requestId: row.requestId, + reportType: row.reportType, + presentationMode: row.presentationMode, + status: row.status, + failureCode: row.failureCode, + createdAt: row.createdAt, + completedAt: row.completedAt, + }; +} + +function replayOrConflict( + existing: PersonalReportRecord, + fingerprint: string, +): ReportRouteResponse { + if (existing.requestFingerprint !== fingerprint) { + return { + status: 409, + body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict }, + }; + } + if (existing.status === "ready") { + return { + status: 200, + body: { report: reportView(existing), reportDocument: existing.reportDocument }, + }; + } + if (existing.status === "generating") { + return { + status: 409, + body: { error: "该报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + // A failed record is never silently resurrected: surface the stable + // failure. Retrying requires a new requestId. + return { status: 200, body: { report: reportView(existing) } }; +} + +export type ReportCreateCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + rawBody: unknown; + profile: unknown | null; + checkSessionOwned: (sessionId: string) => Promise; + checkChartProfileOwned: (chartProfileId: string) => Promise; + featureEnabled: boolean; + dailyLimit: number; + counts: Readonly<{ + countGenerating: () => Promise; + countCreatedToday: () => Promise; + }>; + persistence: ReportServicePort; + model: Readonly<{ id: string }> | null; + runWorkflow: (input: ConsultationInput) => Promise; + createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort; + skillSnapshot: SkillSnapshot; + now?: () => Date; +}>; + +export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise { + // Same-origin gate first (CSRF), then auth. + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const userId = deps.userId; + + const parsed = personalReportCreateRequestSchema.safeParse(deps.rawBody); + if (!parsed.success) { + return { + status: 400, + body: { error: "报告请求格式不正确", code: REPORT_STABLE_CODES.invalidRequest }, + }; + } + const payload = parsed.data; + + // Profile truth + birth status. Missing or unusable profile is 422. + const profile = record(deps.profile); + if (!profile) { + return { + status: 422, + body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete }, + }; + } + const birthTimeStatus = text(profile.birth_time_status); + const activeBirthTime = text(profile.active_birth_time); + const birthDate = parseBirthDate(profile.birth_date); + const birthClock = parseClockMinutes(activeBirthTime); + const latitude = finiteNumber(profile.latitude); + const longitude = finiteNumber(profile.longitude); + const timezoneOffset = finiteNumber(profile.timezone_offset); + const displayName = text(profile.name) ?? "我的报告"; + const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地"; + + if (!birthTimeStatus || !USABLE_BIRTH_TIME_STATUSES.has(birthTimeStatus)) { + return { + status: 422, + body: { error: "出生时间尚未达到可用状态", code: REPORT_STABLE_CODES.birthTimeNotUsable }, + }; + } + if (!birthDate || !birthClock || latitude === null || longitude === null + || timezoneOffset === null) { + return { + status: 422, + body: { error: "出生资料不完整,无法生成报告", code: REPORT_STABLE_CODES.birthTimeNotUsable }, + }; + } + + // Session / chart-profile ownership (when provided). + if (payload.sessionId && !(await deps.checkSessionOwned(payload.sessionId))) { + return { + status: 403, + body: { error: "会话不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (payload.chartProfileId && !(await deps.checkChartProfileOwned(payload.chartProfileId))) { + return { + status: 403, + body: { error: "星盘资料不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + + if (!deps.featureEnabled) { + return { + status: 403, + body: { error: "个人报告功能暂未开放", code: REPORT_STABLE_CODES.exportDisabled }, + }; + } + + // Canonical request fingerprint: payload identity only, requestId excluded. + const fingerprint = computeRequestFingerprint({ + reportType: payload.reportType, + presentationMode: payload.presentationMode, + themes: payload.themes, + sessionId: payload.sessionId ?? null, + chartProfileId: payload.chartProfileId ?? null, + }); + + // Idempotent replay: an existing row with the same fingerprint returns the + // stored state; a different payload under the same requestId is a 409 + // request conflict — never treated as a replay. + const existing = await deps.persistence.getByUserAndRequestId(userId, payload.requestId); + if (existing) { + return replayOrConflict(existing, fingerprint); + } + + const generating = await deps.counts.countGenerating(); + if (generating > 0) { + return { + status: 409, + body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + const createdToday = await deps.counts.countCreatedToday(); + if (createdToday >= deps.dailyLimit) { + return { + status: 429, + body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited }, + }; + } + + const createInput: CreateGeneratingInput = { + userId, + requestId: payload.requestId, + requestFingerprint: fingerprint, + reportType: payload.reportType, + presentationMode: payload.presentationMode, + requestedThemes: payload.themes, + sessionId: payload.sessionId ?? null, + chartProfileId: payload.chartProfileId ?? null, + skillSourceCommit: deps.skillSnapshot.sourceCommit, + skillSnapshotSha256: deps.skillSnapshot.sha256, + }; + const begun: CreateGeneratingResult = await deps.persistence.createGenerating(createInput); + if (begun.kind === "generation_in_progress") { + return { + status: 409, + body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + if (begun.kind === "request_conflict") { + return { + status: 409, + body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict }, + }; + } + if (begun.kind === "replayed") { + return replayOrConflict(begun.record, fingerprint); + } + const row = begun.record; + + // Real workflow evidence (main chain), never mock/example/random data. + if (!deps.model) { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable); + return { + status: 502, + body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable }, + }; + } + + const workflowTheme = payload.reportType === "personal_thematic" + ? payload.themes[0] + : "general"; + const workflowInput: ConsultationInput = { + year: birthDate.year, + month: birthDate.month, + day: birthDate.day, + hour: birthClock.hour, + minute: birthClock.minute, + lat: latitude, + lon: longitude, + tz: timezoneOffset, + city: birthPlaceLabel, + question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")})`, + theme: workflowTheme, + entryMode: "direct_chart", + }; + + let workflow: unknown; + try { + workflow = await deps.runWorkflow(workflowInput); + } catch { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 502, + body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + const workflowRecord = record(workflow); + if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 502, + body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + + let packet: ReportEvidencePacket; + try { + packet = buildReportEvidencePacket({ + workflow, + subject: { + displayName, + birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted", + birthPlaceLabel, + }, + requestedThemes: payload.themes, + reportType: payload.reportType, + presentationMode: payload.presentationMode, + candidateRange: birthTimeStatus === "accepted" + ? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" } + : null, + skillSnapshot: deps.skillSnapshot, + }); + } catch (error) { + // Real evidence could not support an honest report: fail closed, never + // generate an empty or sample-backed report. + if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 422, + body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + throw error; + } + + const result: GeneratePersonalReportResult = await generatePersonalReport({ + reportId: row.id, + packet, + agent: deps.createAgent(deps.model), + now: deps.now, + }); + + if (result.status === "failed") { + await deps.persistence.markFailed(userId, row.id, result.failureCode); + return { + status: 422, + body: { + error: result.failureCode === REPORT_STABLE_CODES.guardRejected + ? "报告未通过确定性校验" + : "报告内容未通过结构校验", + code: result.failureCode, + }, + }; + } + + try { + const readyRow = await deps.persistence.completeReady(userId, row.id, result.document); + return { + status: 201, + body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument }, + }; + } catch { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid); + return { + status: 422, + body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid }, + }; + } +} + +export type ReportReadCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + reportId: string; + persistence: Pick; + /** + * Canonical server re-validation of a stored ready document (defense in + * depth: a polluted DB row must never reach the browser). Production wires + * safeParseServerReportDocument; tests inject fakes or the real parser. + */ + validateReadyDocument: ( + document: unknown, + ) => { ok: true; document: unknown } | { ok: false }; +}>; + +export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId); + if (!row) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + if (row.status === "ready") { + // Re-validate the stored document through the canonical server parse + // before it is allowed to leave the server; an invalid stored document is + // surfaced as a stable failure WITHOUT the document body. + const validated = deps.validateReadyDocument(row.reportDocument); + if (!validated.ok) { + return { + status: 422, + body: { + error: "报告内容未通过合同校验", + code: REPORT_STABLE_CODES.schemaInvalid, + report: reportView(row), + }, + }; + } + return { + status: 200, + body: { report: reportView(row), reportDocument: validated.document }, + }; + } + return { status: 200, body: { report: reportView(row) } }; +} + +export type ReportDeleteCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + reportId: string; + persistence: Pick; +}>; + +export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise { + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId); + if (!row) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + const removed = await deps.persistence.deleteOwned(deps.userId, row.id); + if (!removed) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + return { status: 200, body: { ok: true } }; +} diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts new file mode 100644 index 00000000..2fc42181 --- /dev/null +++ b/frontend/src/mastra/personal-report.ts @@ -0,0 +1,286 @@ +import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; +import type { ResolvedLanguageModel } from "./model"; + +/** + * Personal Report Agent — dedicated report writer, deliberately separate from + * the chat agent. It has NO skills, NO tools and NO memory: the model receives + * only the allowlisted facts inside `ReportEvidencePacket`. Chat history, + * SKILL.md source text, system prompts, tool traces, internal paths and error + * stacks must never reach this agent. + */ + +export type ClaimStatus = + | "multi_system_consensus" + | "single_system_inference" + | "parameter_sensitive" + | "unclosed_divisional_chart" + | "user_history_verification_required" + | "blocked"; + +export const claimStatusSchema = z.enum([ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked", +]); + +export type EvidenceRefStatus = "verified" | "partial" | "blocked"; + +export type ReportEvidenceRef = Readonly<{ + /** Canonical appendix id: `ev-audit-` / `ev-conflict-` / `ev-calc-`. */ + id: string; + technique: string; + status: EvidenceRefStatus; +}>; + +export type ReportDashaPeriod = Readonly<{ + lord: string; + start: string; + end: string; +}>; + +export type ReportChartHouse = Readonly<{ + number: number; + sign: string; + /** Whole-sign derivation from the ascendant when the source lacks a sign. */ + signDerived: boolean; + /** Planet names occupying this house (whole-sign house numbers). */ + occupants: readonly string[]; +}>; + +export type ReportVargaHouses = Readonly<{ + id: "D9" | "D10"; + houses: readonly ReportChartHouse[]; +}>; + +export type ReportPlanetFact = Readonly<{ + id: string; + sign: string; + degree: number; + house: number | null; + retrograde: boolean | null; +}>; + +export type ReportEvidencePacket = Readonly<{ + schemaVersion: "report_evidence_packet.v1"; + subject: Readonly<{ + displayName: string; + birthTimeStatus: "reported" | "candidate" | "accepted" | "confirmed"; + birthPlaceLabel: string; + }>; + requestedThemes: readonly string[]; + reportType: "personal_full" | "personal_thematic"; + presentationMode: "default" | "research"; + chart: Readonly<{ + /** Canonical 64-hex calculation hash; derived server-side when absent. */ + calculationHash: string; + /** True when the hash was derived from allowlisted facts, not the engine. */ + calculationHashDerived: boolean; + ascendant: Readonly<{ sign: string; degree: number }> | null; + planets: readonly ReportPlanetFact[]; + /** D1 houses; must cover 1..12 for a usable report. */ + houses: readonly ReportChartHouse[]; + vimshottari: readonly ReportDashaPeriod[] | null; + narayana: readonly ReportDashaPeriod[] | null; + /** Real divisional houses only; empty arrays mean the chart is omitted. */ + vargaHouses: readonly ReportVargaHouses[]; + }>; + techniqueAudit: readonly Readonly<{ + technique: string; + status: string; + note: string; + }>[]; + conflicts: readonly Readonly<{ + techniques: readonly string[]; + summary: string; + }>[]; + blockedTechniques: readonly string[]; + /** Canonical appendix ids the agent may cite (ev-audit/ev-conflict/ev-calc). */ + evidenceRefs: readonly ReportEvidenceRef[]; + candidateRange: Readonly<{ start: string; end: string }> | null; + answerPolicy: Readonly<{ + canAnswerPreciseTiming: boolean; + deterministicClaimsForbiddenFor: readonly string[]; + }>; + skillSnapshotSha256: string; + skillSourceCommit: string | null; +}>; + +const reportSectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"); +const evidenceRefIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id"); + +export const personalReportAgentOutputSchema = z.object({ + executiveSummary: z.object({ + headline: z.string().trim().min(1).max(200), + summary: z.string().trim().min(1).max(2000), + priorities: z.array(z.string().trim().min(1).max(200)).max(8).default([]), + }), + thematicNarrative: z.array( + z.object({ + id: reportSectionIdSchema, + title: z.string().trim().min(1).max(160), + narrative: z.string().trim().min(1).max(4000), + actions: z.array(z.string().trim().min(1).max(400)).max(12).default([]), + caveats: z.array(z.string().trim().min(1).max(400)).max(12).default([]), + claimStatus: claimStatusSchema, + evidenceRefs: z.array(evidenceRefIdSchema).min(1).max(24), + }), + ).min(1).max(12), +}).strict(); + +export type PersonalReportAgentOutput = z.infer; + +export type PersonalReportAgentTelemetry = Readonly<{ + modelId: string; + outcome: "resolved" | "aborted" | "failed"; + elapsedMs: number; + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + repairAttempted: boolean; +}>; + +const personalReportInstructions = `You are the dedicated Personal Report writer for a Vedic astrology product. You write long structured report sections in Simplified Chinese. This is a report, not a chat: do not use chat-style short paragraphs, do not ask follow-up questions, and do not append hidden blocks. + +The user message contains the ONLY allowed facts: a minimal server-computed evidence packet. Use those facts exclusively. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in the packet. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or any methodology detail unless the packet's technique audit requires disclosure. + +Truth boundaries are hard output contracts: +- A technique listed in blockedTechniques or with audit status blocked/partial in the packet must never be described as used or confirmed. If the packet answerPolicy.canAnswerPreciseTiming is false, give direction and structure only: never state a month, a date, a specific year, or a guaranteed timing outcome. Do not claim certainty or guaranteed outcomes anywhere. +- A candidate birth-time range is not a confirmed birth time. Never present it as confirmed, never pick a midpoint minute, and never give precise timing from it. +- Do not provide medical, legal, investment or safety-critical advice. Never predict death, diagnosis, pregnancy outcomes, or guaranteed financial/legal outcomes, even as "必定/一定/肯定/保证/必然/百分之百" phrasing. +- Keep the disclaimer boundary: astrology is interpretive, not deterministic. + +Structure rules: +- Produce exactly the JSON object described by the requested output schema. No Markdown fences, no commentary, no hidden fields. +- executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims. +- Section ids must be the lowercase theme keys (career, marriage, wealth, timing, general, or derived keys such as career_overview). Each thematicNarrative section must reference evidenceRefs using the exact "ev-..." ids listed in the packet's evidenceRefs. Every claim in a section must be traceable to those refs. If a section's evidence is only partial or blocked, choose claimStatus accordingly (blocked when the packet marks the underlying techniques blocked). +- Keep actions concrete and cautious; caveats must state limits honestly. +- Write formal, readable Simplified Chinese for a printed report.`; + +function readUsage(value: unknown) { + const record = value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + const numberOrNull = (key: string) => ( + typeof record[key] === "number" && Number.isFinite(record[key]) ? record[key] as number : null + ); + return { + inputTokens: numberOrNull("inputTokens"), + outputTokens: numberOrNull("outputTokens"), + totalTokens: numberOrNull("totalTokens"), + }; +} + +export class PersonalReportAgentOutputError extends Error { + readonly code = "report_schema_invalid"; + + constructor() { + super("report_schema_invalid"); + this.name = "PersonalReportAgentOutputError"; + } +} + +/** + * Builds the only user-message content sent to the model: the serialized + * minimal evidence packet. Nothing else is appended; chat history and skill + * text are structurally excluded by the agent definition (no skills, no tools, + * no memory). + */ +export function buildReportPrompt(packet: ReportEvidencePacket): string { + return `请根据以下唯一的事实包生成个人报告 JSON。只使用该事实包中的内容,严格按输出 schema 返回 JSON。 +${JSON.stringify(packet)}`; +} + +export type ReportAgentPort = Readonly<{ + modelId: string; + generate( + packet: ReportEvidencePacket, + signal?: AbortSignal, + ): Promise; +}>; + +const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。"; + +export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort { + const agent = new Agent({ + id: `personal-report-${model.id}`, + name: "Personal Report Writer", + model: model.model, + instructions: personalReportInstructions, + }); + + return { + modelId: model.id, + async generate(packet, signal) { + const startedAt = Date.now(); + const prompt = buildReportPrompt(packet); + let repairAttempted = false; + try { + const first = await agent.generate( + [{ role: "user", content: prompt }], + { + abortSignal: signal, + structuredOutput: { + schema: personalReportAgentOutputSchema, + jsonPromptInjection: "inline", + }, + }, + ); + const firstParsed = personalReportAgentOutputSchema.safeParse(first.object); + if (firstParsed.success) { + logTelemetry(model.id, startedAt, false, "resolved", first.usage); + return firstParsed.data; + } + + // Exactly one repair retry is allowed. A second failure is terminal. + repairAttempted = true; + const repaired = await agent.generate( + [{ role: "user", content: `${prompt}${REPAIR_PROMPT_SUFFIX}` }], + { + abortSignal: signal, + structuredOutput: { + schema: personalReportAgentOutputSchema, + jsonPromptInjection: "inline", + }, + }, + ); + const repairedParsed = personalReportAgentOutputSchema.safeParse(repaired.object); + if (repairedParsed.success) { + logTelemetry(model.id, startedAt, true, "resolved", repaired.usage); + return repairedParsed.data; + } + logTelemetry(model.id, startedAt, true, "failed", repaired.usage); + throw new PersonalReportAgentOutputError(); + } catch (error) { + if (error instanceof PersonalReportAgentOutputError) throw error; + logTelemetry(model.id, startedAt, repairAttempted, "failed", null); + throw error; + } + }, + }; +} + +function logTelemetry( + modelId: string, + startedAt: number, + repairAttempted: boolean, + outcome: "resolved" | "failed", + usage: unknown, +) { + const tokens = readUsage(usage); + const telemetry: PersonalReportAgentTelemetry = { + modelId, + outcome, + elapsedMs: Math.max(0, Date.now() - startedAt), + inputTokens: tokens.inputTokens, + outputTokens: tokens.outputTokens, + totalTokens: tokens.totalTokens, + repairAttempted, + }; + // Telemetry must never include the prompt, the packet, birth data or the + // report body. + console.info("[personal-report-agent]", JSON.stringify(telemetry)); +} diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts new file mode 100644 index 00000000..b1ebbf7e --- /dev/null +++ b/frontend/tests/personal-report-api.test.ts @@ -0,0 +1,819 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { computeRequestFingerprint } from "../src/lib/personal-report-generation.ts"; +import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts"; +import { + resolveReportCreate, + resolveReportDelete, + resolveReportRead, + type ReportCreateCoreDeps, + type ReportServicePort, +} from "../src/lib/personal-report-route-core.ts"; +import type { + CreateGeneratingInput, + CreateGeneratingResult, + PersonalReportRecord, +} from "../src/lib/personal-report-service-core.ts"; +import type { ReportAgentPort, PersonalReportAgentOutput } from "../src/mastra/personal-report.ts"; + +const createRoute = readFileSync( + new URL("../src/app/api/reports/route.ts", import.meta.url), + "utf8", +); +const itemRoute = readFileSync( + new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url), + "utf8", +); +const generationSource = readFileSync( + new URL("../src/lib/personal-report-generation.ts", import.meta.url), + "utf8", +); +const codesSource = readFileSync( + new URL("../src/lib/personal-report-codes.ts", import.meta.url), + "utf8", +); +const coreSource = readFileSync( + new URL("../src/lib/personal-report-route-core.ts", import.meta.url), + "utf8", +); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const UUID_A = "11111111-1111-4111-8111-111111111111"; +const UUID_B = "22222222-2222-4222-8222-222222222222"; +const REPORT_ID = "33333333-3333-4333-8333-333333333333"; +const SESSION_ID = "44444444-4444-4444-8444-444444444444"; + +function chartPayload() { + const planets = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] + .map((name, index) => ({ + id: name, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius"][index], + degree: 12.5 + index * 10, + house: index + 1, + retrograde: index === 6, + })); + const houses = Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index], + })); + return { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 12.5 }, + planets, + houses, + dasha: { mahadashas: [{ lord: "Moon", start: "2019-01-01", end: "2029-01-01" }] }, + modules: { varga_full: { d9: { houses } }, narayana_dasha: { periods: [] } }, + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1", "Vimshottari"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] }, + }, + machine_evidence_packet: { + conflicts: [], + sections: [{ name: "Functional Benefic/Malefic", status: "verified", note: "" }], + }, + }; +} + +function agentOutput(): PersonalReportAgentOutput { + return { + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "事业结构稳定,财富与婚恋需结合分盘审慎解读。", + priorities: ["先聚焦职业方向"], + }, + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定。", + actions: ["在稳定领域深耕"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }, + ], + }; +} + +const fakeAgent: ReportAgentPort = { + modelId: "test-model", + async generate() { + return agentOutput(); + }, +}; + +const SKILL_SNAPSHOT = { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) }; + +function profileFixture(overrides: Record = {}) { + return { + name: "测试用户", + birth_date: "1997-08-08", + active_birth_time: "05:30:00", + birth_time_status: "confirmed", + latitude: 39.9, + longitude: 116.4, + timezone_offset: 8, + birth_place_label: "北京", + ...overrides, + }; +} + +class MemoryPersistence implements ReportServicePort { + rows = new Map(); + + constructor(seed: PersonalReportRecord[] = []) { + for (const row of seed) this.rows.set(row.id, row); + } + + record(input: CreateGeneratingInput, id: string, status: "generating" | "failed"): PersonalReportRecord { + return { + id, + userId: input.userId, + sessionId: input.sessionId ?? null, + chartProfileId: input.chartProfileId ?? null, + requestId: input.requestId, + requestFingerprint: input.requestFingerprint, + reportType: input.reportType, + status, + schemaVersion: "report_document.v1", + presentationMode: input.presentationMode, + requestedThemes: input.requestedThemes ?? [], + reportDocument: null, + calculationHash: null, + evidenceHash: null, + skillSourceCommit: input.skillSourceCommit ?? null, + skillSnapshotSha256: input.skillSnapshotSha256, + failureCode: status === "failed" ? "calculation_unavailable" : null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: null, + }; + } + + async getByUserAndRequestId(userId: string, requestId: string) { + for (const row of this.rows.values()) { + if (row.userId === userId && row.requestId === requestId) return row; + } + return null; + } + + async createGenerating(input: CreateGeneratingInput): Promise { + const existing = await this.getByUserAndRequestId(input.userId, input.requestId); + if (existing) { + if (existing.requestFingerprint === input.requestFingerprint) { + return { kind: "replayed", record: existing }; + } + return { kind: "request_conflict", record: existing }; + } + const inFlight = [...this.rows.values()].find( + (row) => row.userId === input.userId && row.status === "generating", + ); + if (inFlight) return { kind: "generation_in_progress", record: inFlight }; + const row = this.record(input, REPORT_ID, "generating"); + this.rows.set(row.id, row); + return { kind: "created", record: row }; + } + + async completeReady(userId: string, reportId: string, document: unknown) { + const row = this.rows.get(reportId); + assert.ok(row && row.userId === userId && row.status === "generating"); + const readyRow: PersonalReportRecord = { + ...row, + status: "ready", + reportDocument: document as PersonalReportRecord["reportDocument"], + completedAt: "2026-08-06T00:01:00.000Z", + }; + this.rows.set(reportId, readyRow); + return readyRow; + } + + async markFailed(userId: string, reportId: string, failureCode: string) { + const row = this.rows.get(reportId); + assert.ok(row && row.userId === userId); + const failedRow: PersonalReportRecord = { + ...row, + status: "failed", + failureCode: failureCode as PersonalReportRecord["failureCode"], + completedAt: "2026-08-06T00:01:00.000Z", + }; + this.rows.set(reportId, failedRow); + return failedRow; + } + + async getOwnedById(userId: string, reportId: string) { + const row = this.rows.get(reportId); + return row && row.userId === userId ? row : null; + } + + async deleteOwned(userId: string, reportId: string) { + const row = this.rows.get(reportId); + if (!row || row.userId !== userId) return false; + this.rows.delete(reportId); + return true; + } +} + +function baseDeps(overrides: Partial = {}): ReportCreateCoreDeps { + const persistence = new MemoryPersistence(); + return { + requestUrl: "https://jyotisha.chat/api/reports", + origin: "https://jyotisha.chat", + allowedOrigins: [], + userId: UUID_A, + rawBody: { + requestId: UUID_B, + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + }, + profile: profileFixture(), + checkSessionOwned: async () => true, + checkChartProfileOwned: async () => true, + featureEnabled: true, + dailyLimit: 5, + counts: { + countGenerating: async () => 0, + countCreatedToday: async () => 0, + }, + persistence, + model: { id: "test-model" }, + runWorkflow: async () => chartPayload(), + createAgent: () => fakeAgent, + skillSnapshot: SKILL_SNAPSHOT, + now: () => new Date("2026-08-06T00:00:00.000Z"), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Executable route core behavior (no network, no model) +// --------------------------------------------------------------------------- + +test("core create: 401 when not logged in", async () => { + const response = await resolveReportCreate(baseDeps({ userId: null })); + assert.equal(response.status, 401); +}); + +test("core create: 403 on cross-origin", async () => { + const response = await resolveReportCreate(baseDeps({ origin: "https://evil.example" })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_resource_forbidden"); +}); + +test("core create: 400 on invalid payload", async () => { + const response = await resolveReportCreate(baseDeps({ rawBody: { requestId: "not-a-uuid" } })); + assert.equal(response.status, 400); + assert.equal(response.body.code, "invalid_request"); +}); + +test("core create: 422 profile_incomplete without a profile", async () => { + const response = await resolveReportCreate(baseDeps({ profile: null })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "profile_incomplete"); +}); + +test("core create: 422 birth_time_not_usable for reported status", async () => { + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ birth_time_status: "reported", active_birth_time: "05:30:00" }), + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "birth_time_not_usable"); +}); + +test("core create: 422 birth_time_not_usable for incomplete profile fields", async () => { + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ latitude: null, longitude: null }), + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "birth_time_not_usable"); +}); + +test("core create: 403 when the session or chart profile is not owned", async () => { + const response = await resolveReportCreate(baseDeps({ + rawBody: { + requestId: UUID_B, + reportType: "personal_full", + presentationMode: "default", + themes: ["career"], + sessionId: SESSION_ID, + }, + checkSessionOwned: async () => false, + })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_resource_forbidden"); +}); + +test("core create: 403 when the feature is disabled", async () => { + const response = await resolveReportCreate(baseDeps({ featureEnabled: false })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_export_disabled"); +}); + +test("core create: 409 request conflict for a different payload under the same requestId", async () => { + const fingerprintA = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const fingerprintB = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career"], + sessionId: null, + chartProfileId: null, + }); + assert.notEqual(fingerprintA, fingerprintB); + const seeded = new MemoryPersistence([{ + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: fingerprintA, + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }]); + const response = await resolveReportCreate(baseDeps({ + rawBody: { requestId: UUID_B, reportType: "personal_full", presentationMode: "default", themes: ["career"] }, + persistence: seeded, + })); + assert.equal(response.status, 409); + assert.equal(response.body.code, "report_request_conflict"); +}); + +test("core create: 200 replay for a ready row with the same fingerprint", async () => { + const fingerprint = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const seeded = new MemoryPersistence([{ + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: fingerprint, + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }]); + const response = await resolveReportCreate(baseDeps({ persistence: seeded })); + assert.equal(response.status, 200); + assert.deepEqual(response.body.reportDocument, { ok: true }); +}); + +test("core create: 409 when a generation is already in progress", async () => { + const generating = new MemoryPersistence(); + generating.rows.set("55555555-5555-4555-8555-555555555555", { + id: "55555555-5555-4555-8555-555555555555", + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: "66666666-6666-4666-8666-666666666666", + requestFingerprint: "e".repeat(64), + reportType: "personal_full", + status: "generating", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: [], + reportDocument: null, + calculationHash: null, + evidenceHash: null, + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: null, + }); + const response = await resolveReportCreate(baseDeps({ persistence: generating })); + assert.equal(response.status, 409); + assert.equal(response.body.code, "report_generation_in_progress"); +}); + +test("core create: 429 at the daily limit", async () => { + const response = await resolveReportCreate(baseDeps({ + counts: { countGenerating: async () => 0, countCreatedToday: async () => 5 }, + })); + assert.equal(response.status, 429); + assert.equal(response.body.code, "report_rate_limited"); +}); + +test("core create: 502 model_unavailable when no model is configured", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ + model: null, + persistence, + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "model_unavailable"); + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.status, "failed"); + assert.equal(row?.failureCode, "model_unavailable"); +}); + +test("core create: 502 calculation_unavailable when the workflow throws", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ + persistence, + runWorkflow: async () => { + throw new Error("engine down"); + }, + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "calculation_unavailable"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core create: 502 when the workflow returns no usable chart", async () => { + const response = await resolveReportCreate(baseDeps({ + runWorkflow: async () => ({ success: false }), + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "calculation_unavailable"); +}); + +test("core create: 422 when the real evidence cannot support a report (fail closed)", async () => { + const persistence = new MemoryPersistence(); + const workflow = chartPayload() as Record; + const chart = workflow.chart as Record; + chart.houses = (chart.houses as unknown[]).slice(0, 6); + const response = await resolveReportCreate(baseDeps({ + persistence, + runWorkflow: async () => workflow, + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "calculation_unavailable"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core create: 201 ready with a document on the happy path", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ persistence })); + assert.equal(response.status, 201); + assert.ok(response.body.reportDocument); + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.status, "ready"); + assert.ok(row?.reportDocument); +}); + +test("core create: 422 report_guard_rejected when the agent output violates the guard", async () => { + const persistence = new MemoryPersistence(); + const violatingAgent: ReportAgentPort = { + modelId: "test-model", + async generate(): Promise { + return { + executiveSummary: { + headline: "综合盘面", + summary: "结构稳定。", + priorities: [], + }, + thematicNarrative: [{ + id: "career", + title: "事业", + narrative: "你必定会胜诉。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }], + }; + }, + }; + const response = await resolveReportCreate(baseDeps({ + persistence, + createAgent: () => violatingAgent, + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "report_guard_rejected"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core read: 401 without user, 404 for non-owned or missing reports", async () => { + const persistence = new MemoryPersistence(); + const readyRow = await createReadyRow(persistence); + const unauthenticated = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: null, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(unauthenticated.status, 401); + + const otherUser = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_B, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(otherUser.status, 404); + + const missing = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: "99999999-9999-4999-8999-999999999999", + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(missing.status, 404); + assert.equal(missing.body.code, "report_not_found"); + assert.equal(readyRow, true); +}); + +test("core read: ready returns the document, generating returns status only", async () => { + const persistence = new MemoryPersistence(); + const row = await createReadyRow(persistence); + const ready = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(ready.status, 200); + assert.ok(ready.body.reportDocument); + assert.equal(row, true); + + const generatingPersistence = new MemoryPersistence(); + generatingPersistence.rows.set(REPORT_ID, { + ...seedRecord(), + status: "generating", + reportDocument: null, + completedAt: null, + }); + const generating = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence: generatingPersistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(generating.status, 200); + assert.equal("reportDocument" in generating.body, false); + assert.equal((generating.body.report as { status: string }).status, "generating"); +}); + +test("core read: rejects a polluted stored ready document via canonical re-validation", async () => { + const persistence = new MemoryPersistence(); + persistence.rows.set(REPORT_ID, { + ...seedRecord(), + reportDocument: { hacked: true } as unknown as PersonalReportRecord["reportDocument"], + }); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok ? { ok: true, document: parsed.document } : { ok: false }; + }, + }); + assert.equal(response.status, 422); + assert.equal(response.body.code, "report_schema_invalid"); + assert.equal("reportDocument" in response.body, false); + assert.equal((response.body.report as { status: string }).status, "ready"); +}); + +test("core read: returns a legitimate ready document after canonical re-validation", async () => { + const persistence = new MemoryPersistence(); + await createReadyRow(persistence); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok ? { ok: true, document: parsed.document } : { ok: false }; + }, + }); + assert.equal(response.status, 200); + assert.ok(response.body.reportDocument); +}); + +test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => { + const persistence = new MemoryPersistence(); + await createReadyRow(persistence); + const otherUser = await resolveReportDelete({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_B, + reportId: REPORT_ID, + persistence, + }); + assert.equal(otherUser.status, 404); + + const owner = await resolveReportDelete({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + }); + assert.equal(owner.status, 200); + assert.deepEqual(owner.body, { ok: true }); + assert.equal(persistence.rows.has(REPORT_ID), false); +}); + +function acceptAnyDocument(document: unknown): { ok: true; document: unknown } | { ok: false } { + return { ok: true, document }; +} + +async function createReadyRow(persistence: MemoryPersistence): Promise { + const fingerprint = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const response = await resolveReportCreate(baseDeps({ persistence })); + if (response.status !== 201) return false; + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.requestFingerprint, fingerprint); + return true; +} + +function seedRecord(): PersonalReportRecord { + return { + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: "f".repeat(64), + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }; +} + +// --------------------------------------------------------------------------- +// Source-level production wiring checks +// --------------------------------------------------------------------------- + +test("POST route uses dual clients: authenticated reads + admin persistence", () => { + assert.match(createRoute, /createServerSupabaseClient\(\)/); + assert.match(createRoute, /\.from\("profiles"\)/); + assert.match(createRoute, /createAdminSupabaseClient\(\)/); + assert.match(createRoute, /createSupabasePersonalReportService\(admin\)/); + assert.match(createRoute, /createPersonalReportDataClient\(admin\)/); + assert.doesNotMatch(createRoute, /resolveReportPersistencePort|resolveReportContractPort/); + assert.doesNotMatch(createRoute, /ReportPersistenceUnavailableError|ReportContractUnavailableError/); + assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/); +}); + +test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => { + assert.match(itemRoute, /createServerSupabaseClient\(\)/); + assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/); + assert.match(itemRoute, /resolveReportRead/); + assert.match(itemRoute, /resolveReportDelete/); + assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/); +}); + +test("route core enforces same-origin and never leaks raw exception text", () => { + assert.match(coreSource, /checkSameOrigin/); + assert.match(coreSource, /REPORT_STABLE_CODES\.resourceForbidden/); + assert.doesNotMatch(createRoute, /error\.message\)/); + assert.doesNotMatch(itemRoute, /error\.message\)/); + assert.doesNotMatch(createRoute, /\.stack/); + assert.doesNotMatch(itemRoute, /\.stack/); +}); + +test("POST route reads the daily limit from env and resolves a real skill snapshot", () => { + assert.match(createRoute, /readPersonalReportDailyLimit\(process\.env\)/); + assert.match(createRoute, /resolveSkillSnapshot\(\)/); + assert.doesNotMatch(createRoute, /每日.*上限.*\d|PERSONAL_REPORT_DAILY_LIMIT.*\?\?\s*["']\d/); +}); + +test("GET route re-validates stored ready documents through the canonical server parse", () => { + assert.match(itemRoute, /safeParseServerReportDocument\(document\)/); + assert.match(itemRoute, /validateReadyDocument/); + assert.match(coreSource, /validateReadyDocument/); + assert.match(coreSource, /canonical server parse/); + assert.match(coreSource, /REPORT_STABLE_CODES\.schemaInvalid/); + assert.doesNotMatch(coreSource, /client.*validation|validate.*client/i); +}); + +test("POST route never generates HTML/PDF/base64 or local paths", () => { + assert.doesNotMatch(createRoute, /window\.print|html2canvas|jsPDF|base64|\.pdf/); + assert.doesNotMatch(createRoute, /sendFile|createWriteStream|\/opt\/|\/var\/|\/Users\//); +}); + +test("stable error codes live in the dependency-free codes module", () => { + for (const code of [ + "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", + "report_request_conflict", + ]) { + assert.ok(codesSource.includes(`"${code}"`), `missing stable code ${code}`); + } + // The codes module must stay free of heavy imports so route handlers that + // only need codes never trace the generation/skill-snapshot logic. + assert.doesNotMatch(codesSource, /node:fs|node:path|node:crypto|readdirSync|readFileSync/); + assert.doesNotMatch(createRoute, /dangerouslySetInnerHTML/); +}); + +test("GET route imports codes from the pure module, never the generation module", () => { + assert.match(itemRoute, /personal-report-codes/); + assert.doesNotMatch(itemRoute, /personal-report-generation/); + assert.doesNotMatch(itemRoute, /resolveSkillSnapshot|buildReportEvidencePacket|canonicalSerialize/); + assert.match(generationSource, /personal-report-codes/); +}); + +test("generation pipeline re-validates with the canonical server parse after the guard", () => { + assert.match(generationSource, /safeParseServerReportDocument\(guarded\.document\)/); + assert.match(generationSource, /assembleReportDocument/); +}); + +test("evidence hash is the canonical appendix hash, never a model self-report", () => { + assert.match(generationSource, /computeEvidenceHash\(parsed\.document\.evidenceAppendix\)/); + assert.match(generationSource, /computeEvidenceHash\(appendix\)/); +}); + +test("skill snapshot resolution fails closed without a real source", () => { + assert.match(generationSource, /SkillSnapshotUnavailableError/); + assert.match(generationSource, /source-manifest\.json/); + assert.doesNotMatch(generationSource, /skill_snapshot_unavailable.*digest/); +}); + +test("generation module has no filesystem/path scanning (static manifest import only)", () => { + assert.doesNotMatch(generationSource, /node:fs|node:path|readdirSync|readFileSync/); + assert.doesNotMatch(generationSource, /\bskillDirectory\b|\brepoRoot\b|turbopackIgnore/); + assert.match(generationSource, /source-manifest\.json/); +}); diff --git a/frontend/tests/personal-report-entitlement.test.ts b/frontend/tests/personal-report-entitlement.test.ts new file mode 100644 index 00000000..0f3baf36 --- /dev/null +++ b/frontend/tests/personal-report-entitlement.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + DEFAULT_PERSONAL_REPORT_DAILY_LIMIT, + REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, + checkPersonalReportEntitlement, + checkSameOrigin, + isPersonalReportFeatureEnabled, + readPersonalReportDailyLimit, + resolveAllowedReportOrigins, +} from "../src/lib/personal-report-entitlement.ts"; + +test("entitlement exposes the report.export.personal capability key", () => { + assert.equal(REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, "report.export.personal"); +}); + +test("feature flag is enabled only by explicit env true", () => { + assert.equal(isPersonalReportFeatureEnabled({}), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "false" }), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "TRUE" }), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "true" }), true); +}); + +test("daily limit is read from env and never hardcoded in the module UI surface", () => { + assert.equal(readPersonalReportDailyLimit({}), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "3" }), 3); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "0" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "-1" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "abc" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); +}); + +test("allowed origins are parsed from the comma-separated env list", () => { + assert.deepEqual(resolveAllowedReportOrigins({}), []); + assert.deepEqual( + resolveAllowedReportOrigins({ PERSONAL_REPORT_ALLOWED_ORIGINS: " https://a.example ,https://b.example, " }), + ["https://a.example", "https://b.example"], + ); +}); + +test("same-origin check accepts absent origin and same request origin", () => { + assert.deepEqual(checkSameOrigin("https://jyotisha.chat/api/reports", null, []), { ok: true }); + assert.deepEqual( + checkSameOrigin("https://jyotisha.chat/api/reports", "https://jyotisha.chat", []), + { ok: true }, + ); +}); + +test("same-origin check rejects cross-origin and accepts a trusted allowlist", () => { + assert.deepEqual( + checkSameOrigin("https://jyotisha.chat/api/reports", "https://evil.example", []), + { ok: false, code: "cross_origin_forbidden" }, + ); + assert.deepEqual( + checkSameOrigin( + "https://jyotisha.chat/api/reports", + "https://trusted-proxy.example", + ["https://trusted-proxy.example"], + ), + { ok: true }, + ); +}); + +test("entitlement blocks when the feature is disabled", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: false, + dailyLimit: 5, + countGenerating: async () => 0, + countCreatedToday: async () => 0, + }); + assert.deepEqual(result, { allowed: false, code: "report_export_disabled", httpStatus: 403 }); +}); + +test("entitlement blocks a second concurrent generation with 409", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 5, + countGenerating: async () => 1, + countCreatedToday: async () => 0, + }); + assert.deepEqual(result, { allowed: false, code: "report_generation_in_progress", httpStatus: 409 }); +}); + +test("entitlement blocks at the daily limit with 429", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 2, + countGenerating: async () => 0, + countCreatedToday: async () => 2, + }); + assert.deepEqual(result, { allowed: false, code: "report_rate_limited", httpStatus: 429 }); +}); + +test("entitlement allows a fresh generation within limits", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 5, + countGenerating: async () => 0, + countCreatedToday: async () => 1, + }); + assert.deepEqual(result, { allowed: true }); +}); + +test("report API routes never hardcode the daily limit in the UI-facing module", () => { + const entitlementSource = readFileSync( + new URL("../src/lib/personal-report-entitlement.ts", import.meta.url), + "utf8", + ); + assert.match(entitlementSource, /REPORT_DAILY_LIMIT_ENV/); + // The limit must be read from env at request time, not baked as a literal + // default inside the route response mapping. + assert.doesNotMatch(entitlementSource, /每日|上限/); +}); diff --git a/frontend/tests/personal-report-generation.test.ts b/frontend/tests/personal-report-generation.test.ts new file mode 100644 index 00000000..cff3bd82 --- /dev/null +++ b/frontend/tests/personal-report-generation.test.ts @@ -0,0 +1,748 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { safeParseServerReportDocument, computeEvidenceHash } from "../src/lib/personal-report-contract.server-core.ts"; +import upstreamSourceManifest from "../../references/upstream/yinduzhanxing/source-manifest.json"; +import { + REPORT_STABLE_CODES, + ReportEvidenceInsufficientError, + applyReportGuard, + assembleReportDocument, + buildReportEvidencePacket, + canonicalSerialize, + computeRequestFingerprint, + findForbiddenDeterministicClaims, + generatePersonalReport, + redactDeterministicSentences, + resolveSkillSnapshot, + sha256Hex, + type SkillSnapshot, +} from "../src/lib/personal-report-generation.ts"; +import { PRECISE_TIMING_PATTERNS } from "../src/lib/personal-report-generation.ts"; +import type { + PersonalReportAgentOutput, + ReportAgentPort, + ReportEvidencePacket, +} from "../src/mastra/personal-report.ts"; + +// --------------------------------------------------------------------------- +// Fixtures: a real-shaped workflow response matching the Python main chain +// (object-map planets/houses/sections, varga_full D9_Navamsa/D10_Dasamsa) +// --------------------------------------------------------------------------- + +function pythonStyleChartPayload() { + const signNames = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]; + const ascIndex = 4; // Leo + const planetsMap: Record> = { + Sun: { sign: "Leo", degree: 142.5, degree_raw: 142.5, degree_in_sign: 22.5, house: 1, retrograde: false, speed: 1.0 }, + Moon: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: false, speed: 13.0 }, + Mars: { sign: "Libra", degree: 202.5, degree_raw: 202.5, degree_in_sign: 22.5, house: 3, retrograde: false, speed: 0.6 }, + Mercury: { sign: "Scorpio", degree: 232.5, degree_raw: 232.5, degree_in_sign: 22.5, house: 4, retrograde: true, speed: -0.5 }, + Jupiter: { sign: "Sagittarius", degree: 262.5, degree_raw: 262.5, degree_in_sign: 22.5, house: 5, retrograde: false, speed: 0.2 }, + Venus: { sign: "Capricorn", degree: 292.5, degree_raw: 292.5, degree_in_sign: 22.5, house: 6, retrograde: false, speed: 1.1 }, + Saturn: { sign: "Aquarius", degree: 322.5, degree_raw: 322.5, degree_in_sign: 22.5, house: 7, retrograde: true, speed: -0.1 }, + Rahu: { sign: "Pisces", degree: 352.5, degree_raw: 352.5, degree_in_sign: 22.5, house: 8, retrograde: true, speed: -0.05 }, + Ketu: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: true, speed: -0.05 }, + }; + const housesMap: Record> = {}; + for (let index = 0; index < 12; index += 1) { + housesMap[`house_${index + 1}`] = { + cusp_sign: signNames[(ascIndex + index) % 12], + cusp_degree: 140.5 + index * 30, + lord: signNames[(ascIndex + index) % 12], + }; + } + const d9Planets: Record> = { + Sun: { sign: "Leo", sign_idx: 4 }, + Moon: { sign: "Virgo", sign_idx: 5 }, + Mars: { sign: "Cancer", sign_idx: 3 }, + }; + const d10Planets: Record> = { + Sun: { sign: "Taurus", sign_idx: 1 }, + Moon: { sign: "Gemini", sign_idx: 2 }, + }; + return { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 20.5, degree_raw: 140.5, lon: 140.5, sign_cn: "狮子座", lord: "Sun" }, + planets: planetsMap, + houses: housesMap, + dasha: { + mahadashas: [ + { lord: "Moon", start: "2019-01-01", end: "2029-01-01" }, + { lord: "Mars", start: "2029-01-01", end: "2036-01-01" }, + ], + }, + modules: { + varga_full: { + D9_Navamsa: { + _meta: { div: 9 }, + Ascendant: { sign: "Leo", sign_idx: 4 }, + ...d9Planets, + _dignity: {}, + }, + D10_Dasamsa: { + _meta: { div: 10 }, + Ascendant: { sign: "Taurus", sign_idx: 1 }, + ...d10Planets, + }, + }, + narayana_dasha: { + periods: [{ lord: "Sun", start: "2023-01-01", end: "2026-01-01" }], + }, + }, + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1", "D9", "D10", "Vimshottari", "Narayana"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { + can_answer_precise_timing: true, + deterministic_claims_forbidden_for: [], + }, + }, + machine_evidence_packet: { + conflicts: [ + { techniques: ["Vimshottari", "Narayana"], summary: "两个大运系统给出的阶段边界不一致" }, + ], + sections: { + D1: { status: "used", source_path: "chart.planets+chart.ascendant" }, + D9: { status: "used", source_path: "modules.varga_full.D9" }, + D10: { status: "used", source_path: "modules.varga_full.D10" }, + D2: { status: "missing", source_path: "modules.varga_full.D2" }, + planet_degrees: { status: "used", source_path: "chart.planets" }, + house_degrees: { status: "used", source_path: "chart.houses" }, + dasha_boundaries: { status: "used", source_path: "modules.dasha" }, + narayana_dasha: { status: "used", source_path: "modules.narayana_dasha" }, + external_oracle_status: { status: "used", source_path: "vedastro_official.runtime_truth" }, + vedastro_official_raw_response: { status: "missing", source_path: "vedastro_official.raw_response" }, + }, + }, + }; +} + +function buildPacket(overrides: Partial = {}): ReportEvidencePacket { + const workflow = overrides.workflow ?? pythonStyleChartPayload(); + return buildReportEvidencePacket({ + workflow, + subject: { + displayName: "测试用户", + birthTimeStatus: "confirmed", + birthPlaceLabel: "北京", + }, + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportType: "personal_full", + presentationMode: "default", + candidateRange: null, + skillSnapshot: { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) }, + }); +} + +type BuildPacketOverrides = { + workflow: unknown; +}; + +function agentOutput(overrides: Partial = {}): PersonalReportAgentOutput { + return { + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "命盘显示事业层面具备稳定的结构,财富与婚恋需结合分盘审慎解读。", + priorities: ["先聚焦职业方向", "再核对感情与财富主题"], + }, + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定,具体应期需要结合大运边界观察。", + actions: ["在稳定领域深耕"], + caveats: ["该部分为方向性描述"], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1", "ev-audit-3"], + }, + { + id: "marriage", + title: "婚恋", + narrative: "婚恋部分以七宫与 D9 为主,呈现结构特征,不构成确定性结论。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }, + ], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Packet builder: allowlist + fail-closed +// --------------------------------------------------------------------------- + +test("packet builder extracts only allowlisted facts, never internal noise", () => { + const workflow = pythonStyleChartPayload(); + (workflow as Record).internal_path = "/opt/app/private/engine.py"; + (workflow as Record).prompt_text = "系统提示词原文"; + (workflow as Record).traceback = "Traceback (most recent call last)"; + (workflow as Record).raw_payload = { anything: true }; + const packet = buildPacket({ workflow }); + const serialized = JSON.stringify(packet); + assert.doesNotMatch(serialized, /internal_path|prompt_text|traceback|raw_payload|\/opt\/app/); + assert.match(serialized, /ev-audit-/); + assert.match(serialized, /ev-conflict-/); +}); + +test("packet builder computes a canonical 64-hex calculation hash and marks derivation", () => { + const packet = buildPacket(); + assert.match(packet.chart.calculationHash, /^[0-9a-f]{64}$/); + assert.equal(packet.chart.calculationHashDerived, true); +}); + +test("packet builder keeps a real engine hash when present", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + chart.result_hash = "c".repeat(64); + const packet = buildPacket({ workflow }); + assert.equal(packet.chart.calculationHash, "c".repeat(64)); + assert.equal(packet.chart.calculationHashDerived, false); +}); + +test("packet builder fails closed without real D1 houses", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const houses = chart.houses as Record; + delete houses["house_9"]; + delete houses["house_10"]; + delete houses["house_11"]; + delete houses["house_12"]; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); +}); + +test("packet builder fails closed without retrograde facts", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const planets = chart.planets as Record>; + planets.Sun.retrograde = undefined; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); +}); + +test("packet builder fails closed without ascendant or evidence refs", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + delete chart.ascendant; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); + + const workflow2 = pythonStyleChartPayload() as Record; + const consumer = workflow2.consumer_context as Record; + consumer.available_layers = []; + const machine = workflow2.machine_evidence_packet as Record; + machine.sections = {}; + machine.conflicts = []; + assert.throws(() => buildPacket({ workflow: workflow2 }), ReportEvidenceInsufficientError); +}); + +test("no mock fallback: an empty workflow never yields a usable packet", () => { + assert.throws(() => buildPacket({ workflow: {} }), ReportEvidenceInsufficientError); +}); + +test("packet builder normalizes the real Python object-map shapes", () => { + const packet = buildPacket(); + // planets object map -> facts, absolute longitude from degree/degree_raw. + assert.equal(packet.chart.planets.length, 9); + const sun = packet.chart.planets.find((planet) => planet.id === "Sun"); + assert.ok(sun); + assert.equal(sun.sign, "Leo"); + assert.equal(sun.degree, 142.5); + assert.equal(sun.house, 1); + assert.equal(sun.retrograde, false); + assert.equal(packet.chart.planets.find((planet) => planet.id === "Saturn")?.retrograde, true); + // houses object map (cusp_sign only) -> whole-sign derived signs, marked. + assert.equal(packet.chart.houses.length, 12); + assert.equal(packet.chart.houses[0].sign, "Leo"); + assert.equal(packet.chart.houses[0].signDerived, true); + assert.deepEqual(packet.chart.houses[0].occupants, ["Sun"]); + assert.equal(packet.chart.houses[1].sign, "Virgo"); + assert.deepEqual([...packet.chart.houses[1].occupants].sort(), ["Ketu", "Moon"]); + // sections object map -> deterministic statuses: core calculation sections + // verified, internal layers partial, external/missing degraded. + const refs = packet.evidenceRefs; + const verified = refs.filter((ref) => ref.status === "verified").map((ref) => ref.technique); + assert.ok(verified.includes("planet_degrees")); + assert.ok(verified.includes("house_degrees")); + const blocked = refs.filter((ref) => ref.status === "blocked").map((ref) => ref.technique); + assert.ok(blocked.includes("D2")); + assert.ok(blocked.includes("vedastro_official_raw_response")); + const partial = refs.filter((ref) => ref.status === "partial").map((ref) => ref.technique); + assert.ok(partial.includes("dasha_boundaries")); + assert.ok(partial.includes("external_oracle_status")); + // conflicts produce canonical ev-conflict refs. + assert.ok(refs.some((ref) => ref.id.startsWith("ev-conflict-"))); +}); + +test("packet builder resolves the base chart from modules.chart and nested chart", () => { + const base = pythonStyleChartPayload() as Record; + const chartData = base.chart as Record; + // modules.chart wins over nested chart over top level. + const modulesChart = { + ...chartData, + planets: { Sun: { sign: "Aries", degree: 10.5, degree_raw: 10.5, house: 1, retrograde: false } }, + }; + const modules = chartData.modules as Record; + modules.chart = modulesChart; + const viaModules = buildPacket({ workflow: base }); + assert.equal(viaModules.chart.planets.length, 1); + assert.equal(viaModules.chart.planets[0].sign, "Aries"); + delete modules.chart; + + // nested chart_data.chart is the orchestrator's second choice. + const nested = { + ...chartData, + planets: { Moon: { sign: "Pisces", degree: 350.5, degree_raw: 350.5, house: 12, retrograde: false } }, + }; + chartData.chart = nested; + const viaNested = buildPacket({ workflow: base }); + assert.equal(viaNested.chart.planets.length, 1); + assert.equal(viaNested.chart.planets[0].id, "Moon"); +}); + +test("varga houses are whole-sign derived from the divisional ascendant, never fabricated", () => { + const packet = buildPacket(); + const d9 = packet.chart.vargaHouses.find((varga) => varga.id === "D9"); + assert.ok(d9); + assert.equal(d9.houses.length, 12); + // D9 ascendant is Leo (index 4): house 1 Leo, house 2 Virgo. + assert.equal(d9.houses[0].sign, "Leo"); + assert.equal(d9.houses[1].sign, "Virgo"); + // Moon sits in Virgo (index 5) -> whole-sign house 2 of D9. + assert.ok(d9.houses[1].occupants.includes("Moon")); + assert.ok(d9.houses.every((house) => house.signDerived === true)); + const d10 = packet.chart.vargaHouses.find((varga) => varga.id === "D10"); + assert.ok(d10); + // D10 ascendant is Taurus (index 1): house 1 Taurus, house 2 Gemini. + assert.equal(d10.houses[0].sign, "Taurus"); + assert.equal(d10.houses[1].sign, "Gemini"); + assert.ok(d10.houses[1].occupants.includes("Moon")); +}); + +test("array-shaped chart data remains supported", () => { + const workflow = { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 12.5 }, + planets: [ + { id: "Sun", sign: "Leo", degree: 142.5, house: 1, retrograde: false }, + { id: "Moon", sign: "Virgo", degree: 172.5, house: 2, retrograde: false }, + ], + houses: Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index], + })), + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] }, + }, + machine_evidence_packet: { + conflicts: [], + sections: [{ name: "planet_degrees", status: "verified", note: "" }], + }, + }; + const packet = buildPacket({ workflow }); + assert.equal(packet.chart.planets.length, 2); + assert.equal(packet.chart.houses.length, 12); + // Array houses carry a real sign: not derived. + assert.equal(packet.chart.houses[0].signDerived, false); + assert.equal(packet.chart.houses[0].sign, "Aries"); + assert.ok(packet.evidenceRefs.some((ref) => ref.status === "verified")); +}); + +test("section status mapping is deterministic (used core sections verified, external degraded)", () => { + const packet = buildPacket(); + const byTechnique = new Map(packet.evidenceRefs.map((ref) => [ref.technique, ref.status])); + assert.equal(byTechnique.get("D1"), "verified"); + assert.equal(byTechnique.get("planet_degrees"), "verified"); + assert.equal(byTechnique.get("house_degrees"), "verified"); + assert.equal(byTechnique.get("dasha_boundaries"), "partial"); + assert.equal(byTechnique.get("external_oracle_status"), "partial"); + assert.equal(byTechnique.get("D2"), "blocked"); + assert.equal(byTechnique.get("vedastro_official_raw_response"), "blocked"); +}); + +// --------------------------------------------------------------------------- +// Fingerprint +// --------------------------------------------------------------------------- + +test("request fingerprint is canonical: sorted, deduped themes, no requestId", () => { + const base = { + reportType: "personal_full", + presentationMode: "default", + themes: ["wealth", "career", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }; + const fingerprintA = computeRequestFingerprint(base); + const fingerprintB = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "timing", "wealth"], + sessionId: null, + chartProfileId: null, + }); + assert.equal(fingerprintA, fingerprintB); + assert.equal(fingerprintA, sha256Hex(canonicalSerialize({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "timing", "wealth"], + sessionId: null, + chartProfileId: null, + }))); + const differentType = computeRequestFingerprint({ + ...base, + reportType: "personal_thematic", + }); + assert.notEqual(fingerprintA, differentType); + const withSession = computeRequestFingerprint({ + ...base, + sessionId: "11111111-1111-4111-8111-111111111111", + }); + assert.notEqual(fingerprintA, withSession); +}); + +// --------------------------------------------------------------------------- +// Assembly: canonical contract shape +// --------------------------------------------------------------------------- + +test("assembled document passes the canonical server parse with a D1 of 12 houses", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput(), + }); + const parsed = safeParseServerReportDocument(document); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(parsed.document.charts.length, 3); + const d1 = parsed.document.charts.find((chart) => chart.id === "D1"); + assert.ok(d1); + assert.equal(d1.houses.length, 12); + assert.deepEqual( + d1.houses.map((house) => house.houseNumber).sort((a, b) => a - b), + Array.from({ length: 12 }, (_, index) => index + 1), + ); + assert.ok(d1.planets && d1.planets.length === 9); + assert.equal(d1.planets[0].retrograde, false); + assert.equal(d1.planets[6].retrograde, true); + // Appendix ids are canonical ev- ids and globally unique. + const ids = [ + ...parsed.document.evidenceAppendix.techniqueAudit.map((row) => row.id), + ...parsed.document.evidenceAppendix.conflicts.map((row) => row.id), + ...parsed.document.evidenceAppendix.calculationEvidence.map((row) => row.id), + ]; + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.every((id) => /^ev-[a-z0-9_-]{1,63}$/.test(id))); + // evidenceHash matches the canonical recomputation from the appendix. + assert.equal( + parsed.document.provenance.evidenceHash, + computeEvidenceHash(parsed.document.evidenceAppendix), + ); +}); + +test("D9/D10 charts are omitted when no real divisional houses exist", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const modules = chart.modules as Record; + modules.varga_full = {}; + const packet = buildPacket({ workflow }); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput(), + }); + const parsed = safeParseServerReportDocument(document); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.deepEqual(parsed.document.charts.map((chartRow) => chartRow.id), ["D1"]); +}); + +// --------------------------------------------------------------------------- +// Deterministic guard +// --------------------------------------------------------------------------- + +test("guard rejects dangling evidence refs", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "稳定结构。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-999"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /unresolved_evidence_ref/); +}); + +test("guard redacts precise timing and downgrades the section when timing is blocked", () => { + const packet = buildPacket(); + const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } }; + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet: timingBlockedPacket, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。", + actions: ["2027年3月跳槽"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, timingBlockedPacket); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const section = (guarded.document as unknown as { thematicNarrative: { narrative: string; claimStatus: string; caveats: string[] }[] }) + .thematicNarrative[0]; + assert.doesNotMatch(section.narrative, /2027年3月/); + assert.equal(section.claimStatus, "blocked"); + assert.ok(section.caveats.some((caveat) => caveat.includes("确定性边界"))); +}); + +test("guard rejects medical deterministic claims outright", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "你一定会患上心脏病。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /deterministic_medical_claim/); +}); + +test("guard rejects investment deterministic claims hidden in actions", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "wealth", + title: "财富", + narrative: "财富结构稳定。", + actions: ["买入股票必然大涨"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /deterministic_investment_claim/); +}); + +test("guard forces blocked when every evidence ref is blocked", () => { + const workflow = pythonStyleChartPayload() as Record; + const consumer = workflow.consumer_context as Record; + consumer.hard_blockers = ["Narayana"]; + consumer.available_layers = ["D1", "Vimshottari"]; + const packet = buildPacket({ workflow }); + const blockedRef = packet.evidenceRefs.find((ref) => ref.status === "blocked"); + assert.ok(blockedRef); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "timing", + title: "时机", + narrative: "该部分仅保留方向性说明。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: [blockedRef.id], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const section = (guarded.document as unknown as { thematicNarrative: { id: string; claimStatus: string }[] }) + .thematicNarrative[0]; + assert.equal(section.claimStatus, "blocked"); +}); + +test("guard redacts timing from the summary and blocks the report-level status", () => { + const packet = buildPacket(); + const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } }; + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet: timingBlockedPacket, + agentOutput: agentOutput({ + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "明年3月将迎来关键转折,整体结构稳定。", + priorities: ["先聚焦职业方向"], + }, + }), + }); + const guarded = applyReportGuard(document, timingBlockedPacket); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const summary = (guarded.document as unknown as { executiveSummary: { summary: string; overallClaimStatus: string } }) + .executiveSummary; + assert.doesNotMatch(summary.summary, /明年3月/); + assert.equal(summary.overallClaimStatus, "blocked"); +}); + +test("findForbiddenDeterministicClaims and redaction are deterministic", () => { + assert.ok(findForbiddenDeterministicClaims("2027年3月会发生转折").some((claim) => claim.domain === "timing")); + assert.ok(findForbiddenDeterministicClaims("投资必然赚钱").some((claim) => claim.domain === "investment")); + assert.equal(findForbiddenDeterministicClaims("方向性判断稳定").length, 0); + const redacted = redactDeterministicSentences("方向稳定。2027年3月转折。", PRECISE_TIMING_PATTERNS); + assert.equal(redacted.removedCount, 1); + assert.doesNotMatch(redacted.text, /2027年3月/); +}); + +// --------------------------------------------------------------------------- +// Generation pipeline (fake agent, real canonical parse) +// --------------------------------------------------------------------------- + +function fakeAgent(output: PersonalReportAgentOutput, calls: { count: number }): ReportAgentPort { + return { + modelId: "test-model", + async generate() { + calls.count += 1; + return output; + }, + }; +} + +test("generatePersonalReport returns a ready document that passes the server parse", async () => { + const packet = buildPacket(); + const calls = { count: 0 }; + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput(), calls), + now: () => new Date("2026-08-06T00:00:00.000Z"), + }); + assert.equal(calls.count, 1); + assert.equal(result.status, "ready"); + if (result.status !== "ready") return; + assert.match(result.evidenceHash, /^[0-9a-f]{64}$/); + const reparsed = safeParseServerReportDocument(result.document); + assert.equal(reparsed.ok, true); +}); + +test("generatePersonalReport fails with report_guard_rejected on guard rejection", async () => { + const packet = buildPacket(); + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "你必定会胜诉。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), { count: 0 }), + }); + assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" }); +}); + +test("generatePersonalReport fails with report_schema_invalid when the final parse rejects", async () => { + const workflow = pythonStyleChartPayload() as Record; + const consumer = workflow.consumer_context as Record; + consumer.hard_blockers = ["Narayana"]; + consumer.available_layers = ["D1", "Vimshottari"]; + const packet = buildPacket({ workflow }); + const blockedRef = packet.evidenceRefs.find((ref) => ref.status === "blocked"); + assert.ok(blockedRef); + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput({ + thematicNarrative: [ + { + id: "timing", + title: "时机", + narrative: "该部分必然会成功,结构稳定。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: [blockedRef.id], + }, + ], + }), { count: 0 }), + now: () => new Date("2026-08-06T00:00:00.000Z"), + }); + // The guard downgrades the section to blocked (all refs blocked); the + // blocked section still contains the deterministic phrase 必然, so the FINAL + // canonical server parse rejects it. Guard mutations are always re-validated. + assert.equal(result.status, "failed"); + if (result.status === "failed") assert.equal(result.failureCode, "report_schema_invalid"); +}); + +test("skill snapshot is the real packaged manifest sha256, never the literal unknown", async () => { + const snapshot: SkillSnapshot = resolveSkillSnapshot(); + const manifest = upstreamSourceManifest as { skill_sha256?: string }; + assert.match(snapshot.sha256, /^[0-9a-f]{64}$/); + assert.notEqual(snapshot.sha256, "unknown"); + assert.equal(snapshot.sha256, manifest.skill_sha256); + const again: SkillSnapshot = resolveSkillSnapshot(); + assert.equal(snapshot.sha256, again.sha256); +}); + +test("stable codes include the request-conflict mapping", () => { + assert.equal(REPORT_STABLE_CODES.requestConflict, "report_request_conflict"); +}); -- 2.54.0 From d89073d296279d30617a2f6bcfc0ad2a74ac194a Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 12:44:36 +0800 Subject: [PATCH 5/9] feat(report): add browser-rendered report and PDF print --- docs/BUG_HISTORY.md | 15 + frontend/src/app/globals.css | 52 ++ frontend/src/app/page.tsx | 28 + frontend/src/app/reports/[reportId]/error.tsx | 33 ++ .../src/app/reports/[reportId]/loading.tsx | 12 + .../src/app/reports/[reportId]/not-found.tsx | 19 + frontend/src/app/reports/[reportId]/page.tsx | 19 + .../generate-personal-report-button.tsx | 233 ++++++++ .../personal-report-document-view.tsx | 496 ++++++++++++++++++ .../personal-report/personal-report-page.tsx | 255 +++++++++ .../personal-report/report-actions.tsx | 91 ++++ .../personal-report/vedic-chart-svg.tsx | 160 ++++++ frontend/src/lib/client-report-export.ts | 72 +++ frontend/tests/personal-report-entry.test.ts | 243 +++++++++ frontend/tests/personal-report-export.test.ts | 80 +++ frontend/tests/personal-report-view.test.ts | 239 +++++++++ 16 files changed, 2047 insertions(+) create mode 100644 frontend/src/app/reports/[reportId]/error.tsx create mode 100644 frontend/src/app/reports/[reportId]/loading.tsx create mode 100644 frontend/src/app/reports/[reportId]/not-found.tsx create mode 100644 frontend/src/app/reports/[reportId]/page.tsx create mode 100644 frontend/src/components/personal-report/generate-personal-report-button.tsx create mode 100644 frontend/src/components/personal-report/personal-report-document-view.tsx create mode 100644 frontend/src/components/personal-report/personal-report-page.tsx create mode 100644 frontend/src/components/personal-report/report-actions.tsx create mode 100644 frontend/src/components/personal-report/vedic-chart-svg.tsx create mode 100644 frontend/src/lib/client-report-export.ts create mode 100644 frontend/tests/personal-report-entry.test.ts create mode 100644 frontend/tests/personal-report-export.test.ts create mode 100644 frontend/tests/personal-report-view.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ea97b5ab..5118d926 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2161,3 +2161,18 @@ - 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder、RPC 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。套餐与易支付配置契约必须显式拒绝 Supabase builder/RPC 并锁定参数化 SQL、404、原子函数写入和安全错误响应;支付配置必须默认折叠,后台必须拥有独立滚动容器且不得放宽聊天的全局 `overflow:hidden`。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。 - 相关记录:BUG-122、BUG-123 - 修复版本:`d44a414`(权限迁移),staging 部署 `1f44892a2cf210797e7dc74f49721a8f10c8849d` + +## BUG-125 | 个人报告入口对不可用出生时间状态错误开放 + +- 状态:resolved(local,pending staging deployment) +- 首次发现:2026-08-06 +- 最近更新:2026-08-06 +- 影响面:首页个人报告 CTA、`POST /api/reports` 出生时间门槛 +- 用户现象:资料流程已经完成、但出生时间仍为 `reported` 或 `candidate` 的用户会看到“生成个人报告”,点击后服务端必然返回 `422 birth_time_not_usable`。 +- 触发条件:用户有咨询会话和消息,`profileComplete=true`,但当前排盘时间尚未被用户采用或引擎确认。 +- 根因:首页只用资料完整度判断入口可见性,没有镜像报告 API 的 `accepted/confirmed + 有效 active time` 门槛;UI 与服务端各自正确但组合后形成误导入口。 +- 修复:首页复用既有 `isBirthTimeReadyForConsultation(profile)`,只有 `accepted` 或 `confirmed` 且当前排盘时间有效时才显示个人报告入口;服务端门槛保持不变,不把候选范围或填报时间伪装成已采用时间。 +- 验证:`frontend/tests/personal-report-entry.test.ts` 15/15 通过,新增回归直接覆盖 `reported=false`、`candidate=false`、`accepted=true`、`confirmed=true` 及缺失 active time 为 false;目标 TypeScript、ESLint 和 `git diff --check` 通过。 +- 防复发:任何报告出生时间状态扩展必须同时更新服务端事实门槛和客户端可见性测试;客户端不得仅以资料表单完成度推导报告可生成。 +- 相关记录:BUG-117、BUG-119 +- 修复版本:本次个人报告 staging 发布提交 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 92ff7adc..6b369c65 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1787,3 +1787,55 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; } .payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); } .payment-qr-badge svg { display: block; width: 100%; height: 100%; } + +/* ============================================================ + personal-report: unique block — report reader + A4 print + Owned by the report UI worker. Only used by /reports/[reportId]. + Screen layout uses Tailwind utilities; this block only adds + print-critical and report-specific rules. + ============================================================ */ +@page { + size: A4; + margin: 14mm 12mm; +} + +/* Small cards / short tables / charts avoid page breaks; long themes + (personal-report-theme) intentionally paginate. */ +.personal-report-avoid-break { + break-inside: avoid-page; +} + +.personal-report-avoid-break-row { + break-inside: avoid; +} + +@media print { + html, + body { + background: #fff !important; + } + + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + /* Navigation, disclosure toggle and other screen-only chrome. */ + .personal-report-screen-only { + display: none !important; + } + + /* Collapsed appendix is printed in full (product decision). */ + .personal-report-print-always { + display: block !important; + } + + .personal-report-document { + max-width: none !important; + padding: 0 !important; + } + + .personal-report-chart-svg { + max-width: 120mm; + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c1b4b62b..499a4453 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -16,6 +16,7 @@ import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; +import { GeneratePersonalReportButton } from "@/components/personal-report/generate-personal-report-button"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; import { @@ -35,6 +36,7 @@ import { birthTimePersistenceValues, declaredBirthInputChanged, describeBirthTimeDraft, + isBirthTimeReadyForConsultation, isDeclaredBirthProfileComplete, isBirthTimeDraftReady, normalizePersistedBirthDate, @@ -1126,6 +1128,26 @@ export default function Home() { const profileComplete = isProfileComplete(profile); const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); const personalChartAvailable = birthTimeRoute.kind === "consult" && birthTimeRoute.mode !== "general_no_birth_time"; + + // Client-side display hint only: an in-memory workflow receipt on the latest + // assistant answer. After a reload receipts are gone, so the state falls back + // to "unknown" (the button copy says the server will verify) instead of + // fabricating an evidence boolean. The server owns real evidence validation. + const latestAssistantMessage = [...(activeSession?.messages ?? [])] + .reverse() + .find((message) => message.role === "assistant"); + const reportEvidenceState: "ready" | "unknown" = latestAssistantMessage?.workflowReceipt + ? "ready" + : "unknown"; + // Mirror the server-side birth-time gate (accepted/confirmed + usable active + // time): reported/candidate users must not see the entry, since the API would + // reject them with 422. profileComplete alone is not enough. + const reportBirthTimeUsable = isBirthTimeReadyForConsultation(profile); + const reportEntryVisible = !rectificationSurfaceOpen + && profileComplete + && reportBirthTimeUsable + && activeSession?.sessionType === "consultation" + && activeSession.messages.length > 0; const starterThemes = personalChartAvailable ? themes : generalGuidedJyotishTopics; const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null); const onboardingPending = profileComplete && !onboarding && !onboardingError; @@ -2819,6 +2841,12 @@ export default function Home() { : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}
+ {reportEntryVisible && activeSession && ( + + )} {account.isAdmin && account.adminUrl ? (