feat(report): add personal report contract and persistence
This commit is contained in:
+399
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"schemaVersion": "report_document.v1",
|
||||
"reportId": "3f2b1c4a-8d6e-4f0a-9c2b-5a7e1d3f8b40",
|
||||
"reportType": "personal_full",
|
||||
"presentationMode": "default",
|
||||
"generatedAt": "2026-08-06T08:00:00Z",
|
||||
"subject": {
|
||||
"displayName": "测试用户(合成资料)",
|
||||
"birthTimeStatus": "confirmed",
|
||||
"birthPlaceLabel": "北京(合成测试地点)"
|
||||
},
|
||||
"provenance": {
|
||||
"skillSourceCommit": "9034e1967032d09c0a1b2c3d4e5f60718293a4b5",
|
||||
"skillSnapshotSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"calculationHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"evidenceHash": "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4",
|
||||
"reportContractVersion": "1"
|
||||
},
|
||||
"executiveSummary": {
|
||||
"headline": "事业与财富主题的多系统证据较一致",
|
||||
"summary": "本报告基于服务端计算的本命盘与分盘证据。事业主题在 D10 与 A10 双层呈现一致信号,财富主题在 D2 与 D11 呈现中等强度信号;婚恋主题需用户历史事件核验;时机主题因外部参照未闭环而降级为 blocked,不给出确定性应期。",
|
||||
"priorities": [
|
||||
"先核验事业主题的三条历史事件证据",
|
||||
"婚恋主题等待用户提供可核验的过往关系时间点",
|
||||
"时机主题在外部参照闭环前不做确定性预测"
|
||||
],
|
||||
"overallClaimStatus": "multi_system_consensus"
|
||||
},
|
||||
"charts": [
|
||||
{
|
||||
"id": "D1",
|
||||
"title": "本命盘 D1(Lahiri Ayanamsa)",
|
||||
"claimStatus": "multi_system_consensus",
|
||||
"houses": [
|
||||
{
|
||||
"houseNumber": 1,
|
||||
"sign": "狮子座",
|
||||
"occupants": [
|
||||
"上升点"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 2,
|
||||
"sign": "处女座",
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"houseNumber": 3,
|
||||
"sign": "天秤座",
|
||||
"occupants": [
|
||||
"水星"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 4,
|
||||
"sign": "天蝎座",
|
||||
"occupants": [
|
||||
"金星"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 5,
|
||||
"sign": "射手座",
|
||||
"occupants": [
|
||||
"太阳"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 6,
|
||||
"sign": "摩羯座",
|
||||
"occupants": [
|
||||
"火星"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 7,
|
||||
"sign": "水瓶座",
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"houseNumber": 8,
|
||||
"sign": "双鱼座",
|
||||
"occupants": [
|
||||
"木星"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 9,
|
||||
"sign": "白羊座",
|
||||
"occupants": [
|
||||
"土星"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 10,
|
||||
"sign": "金牛座",
|
||||
"occupants": [
|
||||
"月亮"
|
||||
]
|
||||
},
|
||||
{
|
||||
"houseNumber": 11,
|
||||
"sign": "双子座",
|
||||
"occupants": []
|
||||
},
|
||||
{
|
||||
"houseNumber": 12,
|
||||
"sign": "巨蟹座",
|
||||
"occupants": [
|
||||
"罗睺"
|
||||
]
|
||||
}
|
||||
],
|
||||
"planets": [
|
||||
{
|
||||
"name": "太阳",
|
||||
"sign": "射手座",
|
||||
"longitudeDegrees": 248.5,
|
||||
"houseNumber": 5,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "月亮",
|
||||
"sign": "金牛座",
|
||||
"longitudeDegrees": 42.1,
|
||||
"houseNumber": 10,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "火星",
|
||||
"sign": "摩羯座",
|
||||
"longitudeDegrees": 288.3,
|
||||
"houseNumber": 6,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "水星",
|
||||
"sign": "天秤座",
|
||||
"longitudeDegrees": 190.7,
|
||||
"houseNumber": 3,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "木星",
|
||||
"sign": "双鱼座",
|
||||
"longitudeDegrees": 341.9,
|
||||
"houseNumber": 8,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "金星",
|
||||
"sign": "天蝎座",
|
||||
"longitudeDegrees": 222.4,
|
||||
"houseNumber": 4,
|
||||
"retrograde": false
|
||||
},
|
||||
{
|
||||
"name": "土星",
|
||||
"sign": "白羊座",
|
||||
"longitudeDegrees": 11.8,
|
||||
"houseNumber": 9,
|
||||
"retrograde": true
|
||||
},
|
||||
{
|
||||
"name": "罗睺",
|
||||
"sign": "巨蟹座",
|
||||
"longitudeDegrees": 102.6,
|
||||
"houseNumber": 12,
|
||||
"retrograde": true
|
||||
},
|
||||
{
|
||||
"name": "计都",
|
||||
"sign": "摩羯座",
|
||||
"longitudeDegrees": 282.6,
|
||||
"houseNumber": 6,
|
||||
"retrograde": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"thematicNarrative": [
|
||||
{
|
||||
"id": "career",
|
||||
"title": "事业主题",
|
||||
"narrative": "事业主题呈现中等偏强的信号:第十宫月亮与金牛座相关领域呼应,D10 与 A10 双层一致性较高。土星逆行提示职业节奏需要长期主义,不适合短期投机路径。",
|
||||
"actions": [
|
||||
"在金牛座相关行业或管理岗位方向收集更多历史证据",
|
||||
"将晋升或转岗事件的时间点记录下来用于后续校准"
|
||||
],
|
||||
"caveats": [
|
||||
"本主题结论依赖出生时间确认状态,当前为 confirmed",
|
||||
"外部参照引擎未全部闭环,置信度上限为多系统一致而非绝对"
|
||||
],
|
||||
"claimStatus": "multi_system_consensus",
|
||||
"evidenceRefs": [
|
||||
"ev-career-d10-a10",
|
||||
"ev-career-dasha-boundary",
|
||||
"ev-shadbala-total"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wealth",
|
||||
"title": "财富主题",
|
||||
"narrative": "财富主题在 D2 与 D11 呈现中等强度信号,第二宫与第十一宫的证据链相互印证,但缺乏足够的过往财务事件校准,属于单系统推断加参数敏感的组合。",
|
||||
"actions": [
|
||||
"核对 D2 与 D11 的证据原始值是否与用户实际财务事件吻合"
|
||||
],
|
||||
"caveats": [
|
||||
"财富结论不构成投资建议",
|
||||
"未达到双系统一致时不得表述为确定结果"
|
||||
],
|
||||
"claimStatus": "parameter_sensitive",
|
||||
"evidenceRefs": [
|
||||
"ev-wealth-d2-d11",
|
||||
"ev-ashtakavarga-wealth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "marriage",
|
||||
"title": "婚恋主题",
|
||||
"narrative": "婚恋主题已计算 D9 与 UL 相关证据,但本报告没有足够的用户历史关系事件来核验,需用户提供可核验时间点后重新评估。",
|
||||
"actions": [
|
||||
"提供过往重要关系事件的时间点以完成核验"
|
||||
],
|
||||
"caveats": [
|
||||
"未经用户历史事件核验的婚恋结论不得视为最终结论"
|
||||
],
|
||||
"claimStatus": "user_history_verification_required",
|
||||
"evidenceRefs": [
|
||||
"ev-marriage-d9-ul"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "timing",
|
||||
"title": "时机主题",
|
||||
"narrative": "时机主题需要 Vimshottari 与 Narayana Dasha 双轨交叉,但外部参照引擎尚未闭环,当前不给出具体应期,仅保留已计算的运限边界供后续校准使用。",
|
||||
"actions": [
|
||||
"等待外部参照闭环后重新评估应期"
|
||||
],
|
||||
"caveats": [
|
||||
"当前不提供任何确定性时间预测",
|
||||
"运限边界仅作为校准素材,不作为结论"
|
||||
],
|
||||
"claimStatus": "blocked",
|
||||
"evidenceRefs": [
|
||||
"ev-timing-vd-md-ad",
|
||||
"ev-timing-narayana"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceAppendix": {
|
||||
"expandedByDefault": false,
|
||||
"techniqueAudit": [
|
||||
{
|
||||
"id": "ev-mevg-web",
|
||||
"techniqueId": "mevg_global_web_evidence",
|
||||
"techniqueName": "MEVG / Global Web Evidence",
|
||||
"status": "partial",
|
||||
"used": true,
|
||||
"notes": "外部资料采集完成度 60%,来源分级已记录,冲突已进入 conflicts 列表"
|
||||
},
|
||||
{
|
||||
"id": "ev-real-case",
|
||||
"techniqueId": "real_case_calibration",
|
||||
"techniqueName": "Real Case Calibration",
|
||||
"status": "partial",
|
||||
"used": true,
|
||||
"notes": "10 个公开案例可回放:事业 5、婚恋 5;财富案例缺失"
|
||||
},
|
||||
{
|
||||
"id": "ev-fbm",
|
||||
"techniqueId": "functional_benefic_malefic",
|
||||
"techniqueName": "Functional Benefic/Malefic",
|
||||
"status": "verified",
|
||||
"used": true
|
||||
},
|
||||
{
|
||||
"id": "ev-vimshottari",
|
||||
"techniqueId": "vimshottari_dasha",
|
||||
"techniqueName": "Vimshottari Dasha",
|
||||
"status": "verified",
|
||||
"used": true
|
||||
},
|
||||
{
|
||||
"id": "ev-narayana",
|
||||
"techniqueId": "narayana_dasha",
|
||||
"techniqueName": "Narayana Dasha",
|
||||
"status": "verified",
|
||||
"used": true,
|
||||
"notes": "与 Vimshottari 双轨交叉"
|
||||
},
|
||||
{
|
||||
"id": "ev-d10-a10",
|
||||
"techniqueId": "d10_a10",
|
||||
"techniqueName": "D10 + A10(事业分盘)",
|
||||
"status": "verified",
|
||||
"used": true
|
||||
},
|
||||
{
|
||||
"id": "ev-d2-d11",
|
||||
"techniqueId": "d2_d11",
|
||||
"techniqueName": "D2 / D11(财富分盘)",
|
||||
"status": "verified",
|
||||
"used": true
|
||||
},
|
||||
{
|
||||
"id": "ev-d9-ul",
|
||||
"techniqueId": "d9_ul",
|
||||
"techniqueName": "D9 + UL(婚恋分盘)",
|
||||
"status": "verified",
|
||||
"used": true
|
||||
},
|
||||
{
|
||||
"id": "ev-shadbala",
|
||||
"techniqueId": "shadbala",
|
||||
"techniqueName": "Shadbala",
|
||||
"status": "partial",
|
||||
"used": true,
|
||||
"notes": "内部总量一致;外部绝对数值对照未闭环"
|
||||
},
|
||||
{
|
||||
"id": "ev-ashtakavarga",
|
||||
"techniqueId": "ashtakavarga",
|
||||
"techniqueName": "Ashtakavarga",
|
||||
"status": "partial",
|
||||
"used": true
|
||||
}
|
||||
],
|
||||
"conflicts": [
|
||||
{
|
||||
"id": "ev-conflict-1",
|
||||
"description": "Vimshottari 与 Narayana Dasha 在 2031 年前后的应期窗口存在分歧",
|
||||
"impact": "时机主题降级为 blocked,不输出确定性应期",
|
||||
"status": "unresolved"
|
||||
},
|
||||
{
|
||||
"id": "ev-conflict-2",
|
||||
"description": "Shadbala 内部总量与外部参照数值尚未对齐",
|
||||
"impact": "Shadbala 行标记为 partial,不参与绝对强度结论",
|
||||
"status": "partial"
|
||||
}
|
||||
],
|
||||
"calculationEvidence": [
|
||||
{
|
||||
"id": "ev-career-d10-a10",
|
||||
"label": "D10 与 A10 事业证据",
|
||||
"value": "D10 月亮入第十宫,A10 同宫主星呼应;双盘一致",
|
||||
"source": "服务端排盘 varga D10/A10(Lahiri)"
|
||||
},
|
||||
{
|
||||
"id": "ev-career-dasha-boundary",
|
||||
"label": "Vimshottari 大运边界",
|
||||
"value": "当前大运:木星-土星;起始边界已记录",
|
||||
"source": "服务端 Dasha 计算"
|
||||
},
|
||||
{
|
||||
"id": "ev-wealth-d2-d11",
|
||||
"label": "D2 与 D11 财富证据",
|
||||
"value": "D2 第二宫与 D11 第十一宫证据链相互印证",
|
||||
"source": "服务端排盘 varga D2/D11"
|
||||
},
|
||||
{
|
||||
"id": "ev-ashtakavarga-wealth",
|
||||
"label": "Ashtakavarga 财富相关宫位",
|
||||
"value": "第二宫与第十一宫 Bhinna Ashtakavarga 点数高于均值",
|
||||
"source": "服务端 Ashtakavarga 计算"
|
||||
},
|
||||
{
|
||||
"id": "ev-marriage-d9-ul",
|
||||
"label": "D9 与 UL 婚恋证据",
|
||||
"value": "D9 第七宫状态与 UL 指示存在呼应,需用户核验",
|
||||
"source": "服务端排盘 varga D9 + UL"
|
||||
},
|
||||
{
|
||||
"id": "ev-timing-vd-md-ad",
|
||||
"label": "Vimshottari 小运边界",
|
||||
"value": "木星-土星-月亮 小运边界已计算,仅作校准素材",
|
||||
"source": "服务端 Dasha 计算"
|
||||
},
|
||||
{
|
||||
"id": "ev-timing-narayana",
|
||||
"label": "Narayana Dasha 边界",
|
||||
"value": "Narayana 大运边界已计算,与 Vimshottari 存在分歧",
|
||||
"source": "服务端 Narayana Dasha 计算"
|
||||
},
|
||||
{
|
||||
"id": "ev-shadbala-total",
|
||||
"label": "Shadbala 总量",
|
||||
"value": "各星 Shadbala 总量内部一致,外部对照 partial",
|
||||
"source": "服务端 Shadbala 计算"
|
||||
}
|
||||
],
|
||||
"blockedTechniques": [
|
||||
"Sphuta 判定层(外部数值参照缺失)",
|
||||
"Tajika 命名组合事件判定(无金标案例)"
|
||||
]
|
||||
},
|
||||
"disclaimer": "本报告由计算引擎与模型共同生成,仅用于传统文化研究与个人参考,不构成医疗、法律或投资建议。任何涉及健康、法律、财务的决策请咨询对应领域的专业人士。报告中的时间预测均受证据完整度限制,blocked 内容不代表确定性结论。"
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""ReportDocument v1 contract validator tests (Python side).
|
||||
|
||||
Semantics are shared with contracts/personal-report/report-document.v1.schema.json
|
||||
and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the
|
||||
runtime-enforced rules that JSON Schema draft-07 cannot express and prove the
|
||||
validator never raises on arbitrary/malformed input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.personal_report_contract import (
|
||||
FAILURE_CODES,
|
||||
MAX_SERIALIZED_BYTES,
|
||||
compute_evidence_hash,
|
||||
is_valid_report_document,
|
||||
load_report_document,
|
||||
main,
|
||||
parse_report_document_json,
|
||||
serialized_bytes,
|
||||
validate_report_document,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json"
|
||||
SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql"
|
||||
LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def fixture() -> dict:
|
||||
return load_report_document(str(FIXTURE))
|
||||
|
||||
|
||||
def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None:
|
||||
result = validate_report_document(fixture)
|
||||
assert result.valid, result.errors
|
||||
# evidenceHash is a deterministic recomputation, not a model self-report.
|
||||
assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture)
|
||||
assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES
|
||||
|
||||
|
||||
def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None:
|
||||
# The fixture is read byte-for-byte by the TS tests too; both sides must
|
||||
# compute the same sha256 over the canonical evidence appendix.
|
||||
assert fixture["provenance"]["evidenceHash"] == (
|
||||
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed",
|
||||
[
|
||||
None,
|
||||
42,
|
||||
"text",
|
||||
[],
|
||||
{},
|
||||
{"schemaVersion": "report_document.v1"},
|
||||
{"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}},
|
||||
{"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]},
|
||||
{"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"},
|
||||
],
|
||||
)
|
||||
def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None:
|
||||
result = validate_report_document(malformed)
|
||||
assert result.valid is False
|
||||
assert isinstance(result.errors, list)
|
||||
|
||||
|
||||
def test_arbitrary_json_text_never_raises() -> None:
|
||||
for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]:
|
||||
result = parse_report_document_json(text)
|
||||
assert result.valid is False
|
||||
|
||||
|
||||
def test_charts_require_exactly_one_d1(fixture: dict) -> None:
|
||||
without_d1 = json.loads(json.dumps(fixture))
|
||||
without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"]
|
||||
result = validate_report_document(without_d1)
|
||||
assert result.valid is False
|
||||
assert any("exactly one D1" in error for error in result.errors)
|
||||
|
||||
two_d1 = json.loads(json.dumps(fixture))
|
||||
two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0])))
|
||||
result = validate_report_document(two_d1)
|
||||
assert result.valid is False
|
||||
assert any("duplicate chart id" in error for error in result.errors)
|
||||
assert any("exactly one D1" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None:
|
||||
incomplete = json.loads(json.dumps(fixture))
|
||||
incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11]
|
||||
result = validate_report_document(incomplete)
|
||||
assert result.valid is False
|
||||
assert any("all twelve house numbers" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_duplicate_house_numbers_rejected(fixture: dict) -> None:
|
||||
duplicated = json.loads(json.dumps(fixture))
|
||||
duplicated["charts"][0]["houses"][11]["houseNumber"] = 1
|
||||
result = validate_report_document(duplicated)
|
||||
assert result.valid is False
|
||||
assert any("duplicate houseNumber" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_longitude_is_half_open_interval(fixture: dict) -> None:
|
||||
at_360 = json.loads(json.dumps(fixture))
|
||||
at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0
|
||||
result = validate_report_document(at_360)
|
||||
assert result.valid is False
|
||||
assert any("longitudeDegrees" in error for error in result.errors)
|
||||
|
||||
near_360 = json.loads(json.dumps(fixture))
|
||||
near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999
|
||||
near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360)
|
||||
assert validate_report_document(near_360).valid
|
||||
|
||||
|
||||
def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None:
|
||||
duplicated = json.loads(json.dumps(fixture))
|
||||
duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"]
|
||||
duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated)
|
||||
result = validate_report_document(duplicated)
|
||||
assert result.valid is False
|
||||
assert any("duplicate evidence id" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_dangling_evidence_refs_rejected(fixture: dict) -> None:
|
||||
dangling = json.loads(json.dumps(fixture))
|
||||
dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"]
|
||||
result = validate_report_document(dangling)
|
||||
assert result.valid is False
|
||||
assert any("unknown evidence id" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None:
|
||||
tampered = json.loads(json.dumps(fixture))
|
||||
tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值"
|
||||
# Self-reported hash left unchanged: validator must recompute and reject.
|
||||
result = validate_report_document(tampered)
|
||||
assert result.valid is False
|
||||
assert any("does not match computed evidence hash" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None:
|
||||
deterministic = json.loads(json.dumps(fixture))
|
||||
deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。"
|
||||
result = validate_report_document(deterministic)
|
||||
assert result.valid is False
|
||||
assert any("blocked section contains deterministic prediction" in error for error in result.errors)
|
||||
|
||||
non_deterministic = json.loads(json.dumps(fixture))
|
||||
non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。"
|
||||
non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic)
|
||||
assert validate_report_document(non_deterministic).valid
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"poison",
|
||||
[
|
||||
"<script>alert(1)</script>",
|
||||
"javascript:alert(1)",
|
||||
"file:///Users/jesse/private/chart.json",
|
||||
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
|
||||
"onerror=alert(1)",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
|
||||
"node:internal/modules/cjs/loader",
|
||||
"Traceback (most recent call last)",
|
||||
"__dirname/secret",
|
||||
"C:\\Users\\jesse\\chart.json",
|
||||
"tool_call_id: call_123",
|
||||
],
|
||||
)
|
||||
def test_forbidden_content_rejected(fixture: dict, poison: str) -> None:
|
||||
poisoned = json.loads(json.dumps(fixture))
|
||||
poisoned["disclaimer"] = poison
|
||||
result = validate_report_document(poisoned)
|
||||
assert result.valid is False
|
||||
assert any("forbidden content" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_serialization_size_cap(fixture: dict) -> None:
|
||||
oversized = json.loads(json.dumps(fixture))
|
||||
oversized["disclaimer"] = "字" * (MAX_SERIALIZED_BYTES)
|
||||
result = validate_report_document(oversized)
|
||||
assert result.valid is False
|
||||
assert any("exceeding" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_strict_keys_missing_and_extra(fixture: dict) -> None:
|
||||
missing = json.loads(json.dumps(fixture))
|
||||
del missing["disclaimer"]
|
||||
result = validate_report_document(missing)
|
||||
assert result.valid is False
|
||||
assert any("missing required keys" in error for error in result.errors)
|
||||
|
||||
extra = json.loads(json.dumps(fixture))
|
||||
extra["disclaimer"] = extra["disclaimer"]
|
||||
extra["subject"]["hometown"] = "上海"
|
||||
result = validate_report_document(extra)
|
||||
assert result.valid is False
|
||||
assert any("unexpected keys" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_json_object_key_order_is_not_validated(fixture: dict) -> None:
|
||||
# JSON objects are unordered; fixed reader order is a display contract of
|
||||
# the typed fields, never a key-order condition.
|
||||
reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))}
|
||||
assert validate_report_document(reordered).valid
|
||||
|
||||
|
||||
def test_failure_code_enum_matches_both_migrations() -> None:
|
||||
for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION):
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
for code in FAILURE_CODES:
|
||||
assert f"'{code}'" in sql, f"{code} missing from {path.name}"
|
||||
assert sql.count("failure_code in") == 1
|
||||
# Both migrations share the same stable enum.
|
||||
supabase_codes = set(FAILURE_CODES)
|
||||
local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8")
|
||||
assert all(f"'{code}'" in local_sql for code in supabase_codes)
|
||||
|
||||
|
||||
def test_cli_exit_codes(fixture: dict) -> None:
|
||||
assert main([str(FIXTURE)]) == 0
|
||||
assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1
|
||||
assert main([]) == 2
|
||||
assert main([str(ROOT / "does-not-exist.json")]) == 2
|
||||
|
||||
|
||||
def test_validator_accepts_synthetic_producer_output() -> None:
|
||||
# A minimal-but-complete document produced without the fixture must pass.
|
||||
from scripts.personal_report_contract import (
|
||||
BIRTH_TIME_STATUSES,
|
||||
CLAIM_STATUSES,
|
||||
PRESENTATION_MODES,
|
||||
REPORT_TYPES,
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
houses = [
|
||||
{"houseNumber": number, "sign": "狮子座", "occupants": []}
|
||||
for number in range(1, 13)
|
||||
]
|
||||
document = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"reportId": "00000000-0000-4000-8000-000000000001",
|
||||
"reportType": REPORT_TYPES[0],
|
||||
"presentationMode": PRESENTATION_MODES[0],
|
||||
"generatedAt": "2026-08-06T08:00:00Z",
|
||||
"subject": {
|
||||
"displayName": "合成用户",
|
||||
"birthTimeStatus": BIRTH_TIME_STATUSES[0],
|
||||
"birthPlaceLabel": "合成地点",
|
||||
},
|
||||
"provenance": {
|
||||
"skillSourceCommit": None,
|
||||
"skillSnapshotSha256": "c" * 64,
|
||||
"calculationHash": "d" * 64,
|
||||
"evidenceHash": "0" * 64,
|
||||
"reportContractVersion": "1",
|
||||
},
|
||||
"executiveSummary": {
|
||||
"headline": "摘要标题",
|
||||
"summary": "摘要正文。",
|
||||
"priorities": ["优先事项"],
|
||||
"overallClaimStatus": CLAIM_STATUSES[0],
|
||||
},
|
||||
"charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}],
|
||||
"thematicNarrative": [],
|
||||
"evidenceAppendix": {
|
||||
"expandedByDefault": False,
|
||||
"techniqueAudit": [
|
||||
{
|
||||
"id": "ev-audit",
|
||||
"techniqueId": "d1_chart",
|
||||
"techniqueName": "D1 本命盘",
|
||||
"status": "verified",
|
||||
"used": True,
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"calculationEvidence": [],
|
||||
"blockedTechniques": [],
|
||||
},
|
||||
"disclaimer": "仅供研究参考。",
|
||||
}
|
||||
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
|
||||
assert validate_report_document(document).valid
|
||||
Reference in New Issue
Block a user