feat(report): add personal report contract and persistence

This commit is contained in:
Jesse
2026-08-06 12:43:10 +08:00
parent 03d3b58ff7
commit 82dab96b07
14 changed files with 3796 additions and 0 deletions
+399
View File
@@ -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": "本命盘 D1Lahiri 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/A10Lahiri"
},
{
"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 内容不代表确定性结论。"
}
+295
View File
@@ -0,0 +1,295 @@
"""ReportDocument v1 contract validator tests (Python side).
Semantics are shared with contracts/personal-report/report-document.v1.schema.json
and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the
runtime-enforced rules that JSON Schema draft-07 cannot express and prove the
validator never raises on arbitrary/malformed input.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.personal_report_contract import (
FAILURE_CODES,
MAX_SERIALIZED_BYTES,
compute_evidence_hash,
is_valid_report_document,
load_report_document,
main,
parse_report_document_json,
serialized_bytes,
validate_report_document,
)
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json"
SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql"
LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql"
@pytest.fixture(scope="module")
def fixture() -> dict:
return load_report_document(str(FIXTURE))
def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None:
result = validate_report_document(fixture)
assert result.valid, result.errors
# evidenceHash is a deterministic recomputation, not a model self-report.
assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture)
assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES
def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None:
# The fixture is read byte-for-byte by the TS tests too; both sides must
# compute the same sha256 over the canonical evidence appendix.
assert fixture["provenance"]["evidenceHash"] == (
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4"
)
@pytest.mark.parametrize(
"malformed",
[
None,
42,
"text",
[],
{},
{"schemaVersion": "report_document.v1"},
{"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}},
{"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]},
{"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"},
],
)
def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None:
result = validate_report_document(malformed)
assert result.valid is False
assert isinstance(result.errors, list)
def test_arbitrary_json_text_never_raises() -> None:
for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]:
result = parse_report_document_json(text)
assert result.valid is False
def test_charts_require_exactly_one_d1(fixture: dict) -> None:
without_d1 = json.loads(json.dumps(fixture))
without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"]
result = validate_report_document(without_d1)
assert result.valid is False
assert any("exactly one D1" in error for error in result.errors)
two_d1 = json.loads(json.dumps(fixture))
two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0])))
result = validate_report_document(two_d1)
assert result.valid is False
assert any("duplicate chart id" in error for error in result.errors)
assert any("exactly one D1" in error for error in result.errors)
def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None:
incomplete = json.loads(json.dumps(fixture))
incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11]
result = validate_report_document(incomplete)
assert result.valid is False
assert any("all twelve house numbers" in error for error in result.errors)
def test_duplicate_house_numbers_rejected(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["charts"][0]["houses"][11]["houseNumber"] = 1
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate houseNumber" in error for error in result.errors)
def test_longitude_is_half_open_interval(fixture: dict) -> None:
at_360 = json.loads(json.dumps(fixture))
at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0
result = validate_report_document(at_360)
assert result.valid is False
assert any("longitudeDegrees" in error for error in result.errors)
near_360 = json.loads(json.dumps(fixture))
near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999
near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360)
assert validate_report_document(near_360).valid
def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"]
duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated)
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate evidence id" in error for error in result.errors)
def test_dangling_evidence_refs_rejected(fixture: dict) -> None:
dangling = json.loads(json.dumps(fixture))
dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"]
result = validate_report_document(dangling)
assert result.valid is False
assert any("unknown evidence id" in error for error in result.errors)
def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None:
tampered = json.loads(json.dumps(fixture))
tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值"
# Self-reported hash left unchanged: validator must recompute and reject.
result = validate_report_document(tampered)
assert result.valid is False
assert any("does not match computed evidence hash" in error for error in result.errors)
def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None:
deterministic = json.loads(json.dumps(fixture))
deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。"
result = validate_report_document(deterministic)
assert result.valid is False
assert any("blocked section contains deterministic prediction" in error for error in result.errors)
non_deterministic = json.loads(json.dumps(fixture))
non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。"
non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic)
assert validate_report_document(non_deterministic).valid
@pytest.mark.parametrize(
"poison",
[
"<script>alert(1)</script>",
"javascript:alert(1)",
"file:///Users/jesse/private/chart.json",
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
"onerror=alert(1)",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
"node:internal/modules/cjs/loader",
"Traceback (most recent call last)",
"__dirname/secret",
"C:\\Users\\jesse\\chart.json",
"tool_call_id: call_123",
],
)
def test_forbidden_content_rejected(fixture: dict, poison: str) -> None:
poisoned = json.loads(json.dumps(fixture))
poisoned["disclaimer"] = poison
result = validate_report_document(poisoned)
assert result.valid is False
assert any("forbidden content" in error for error in result.errors)
def test_serialization_size_cap(fixture: dict) -> None:
oversized = json.loads(json.dumps(fixture))
oversized["disclaimer"] = "" * (MAX_SERIALIZED_BYTES)
result = validate_report_document(oversized)
assert result.valid is False
assert any("exceeding" in error for error in result.errors)
def test_strict_keys_missing_and_extra(fixture: dict) -> None:
missing = json.loads(json.dumps(fixture))
del missing["disclaimer"]
result = validate_report_document(missing)
assert result.valid is False
assert any("missing required keys" in error for error in result.errors)
extra = json.loads(json.dumps(fixture))
extra["disclaimer"] = extra["disclaimer"]
extra["subject"]["hometown"] = "上海"
result = validate_report_document(extra)
assert result.valid is False
assert any("unexpected keys" in error for error in result.errors)
def test_json_object_key_order_is_not_validated(fixture: dict) -> None:
# JSON objects are unordered; fixed reader order is a display contract of
# the typed fields, never a key-order condition.
reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))}
assert validate_report_document(reordered).valid
def test_failure_code_enum_matches_both_migrations() -> None:
for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION):
sql = path.read_text(encoding="utf-8")
for code in FAILURE_CODES:
assert f"'{code}'" in sql, f"{code} missing from {path.name}"
assert sql.count("failure_code in") == 1
# Both migrations share the same stable enum.
supabase_codes = set(FAILURE_CODES)
local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8")
assert all(f"'{code}'" in local_sql for code in supabase_codes)
def test_cli_exit_codes(fixture: dict) -> None:
assert main([str(FIXTURE)]) == 0
assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1
assert main([]) == 2
assert main([str(ROOT / "does-not-exist.json")]) == 2
def test_validator_accepts_synthetic_producer_output() -> None:
# A minimal-but-complete document produced without the fixture must pass.
from scripts.personal_report_contract import (
BIRTH_TIME_STATUSES,
CLAIM_STATUSES,
PRESENTATION_MODES,
REPORT_TYPES,
SCHEMA_VERSION,
)
houses = [
{"houseNumber": number, "sign": "狮子座", "occupants": []}
for number in range(1, 13)
]
document = {
"schemaVersion": SCHEMA_VERSION,
"reportId": "00000000-0000-4000-8000-000000000001",
"reportType": REPORT_TYPES[0],
"presentationMode": PRESENTATION_MODES[0],
"generatedAt": "2026-08-06T08:00:00Z",
"subject": {
"displayName": "合成用户",
"birthTimeStatus": BIRTH_TIME_STATUSES[0],
"birthPlaceLabel": "合成地点",
},
"provenance": {
"skillSourceCommit": None,
"skillSnapshotSha256": "c" * 64,
"calculationHash": "d" * 64,
"evidenceHash": "0" * 64,
"reportContractVersion": "1",
},
"executiveSummary": {
"headline": "摘要标题",
"summary": "摘要正文。",
"priorities": ["优先事项"],
"overallClaimStatus": CLAIM_STATUSES[0],
},
"charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}],
"thematicNarrative": [],
"evidenceAppendix": {
"expandedByDefault": False,
"techniqueAudit": [
{
"id": "ev-audit",
"techniqueId": "d1_chart",
"techniqueName": "D1 本命盘",
"status": "verified",
"used": True,
}
],
"conflicts": [],
"calculationEvidence": [],
"blockedTechniques": [],
},
"disclaimer": "仅供研究参考。",
}
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
assert validate_report_document(document).valid