Files
Jyotisha/tests/test_report_reading_facts.py
T
Jesse_ChenandClaude Opus 5.5 cad28ddc04 报告前面加一段模型写的解读(issue #113 R1)
引擎在同一份 packet 上给出与对话同源卡相同的事实(include_reading_facts),
报告任务交付前用一次结构化模型调用写八节解读,校验不过或失败就不出解读、
报告照常交付。解读存在计算快照里,读接口一起返回,页面放在引擎正文之前,
后面标「计算依据」。用量按实际 token 记账(BUG-1460、BUG-1461)。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-27 11:17:56 +08:00

168 lines
7.4 KiB
Python

"""Reading facts for the model-written report reading (issue #113 R1).
Clearly fictional chart only. The facts must be copies of the report's own
packet (no new algorithm), the flag must default off with the response
unchanged, and the flag must be part of the report cache key.
"""
from __future__ import annotations
import copy
import json
import pytest
import scripts.professional_report_reference as reference
from scripts.consult_card_domain_facts import dig
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.professional_report_reference import _export_args, build_professional_report_reference
from scripts.report_render.reading_facts import READING_DOMAINS, build_report_reading_facts
from tests.test_report_reader_main import FICTIONAL
@pytest.fixture(scope="module")
def chart():
case = dict(FICTIONAL)
reading = cmd_full_reading(type("Args", (), case)())
packet = dict(build_professional_report_reference_packet(reading, _export_args(case), []))
packet["report_version"] = "pl9_personal_long_report.v3"
return case, reading, packet
def test_reading_facts_are_packet_leaves(chart) -> None:
_case, reading, packet = chart
before = json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str)
facts = build_report_reading_facts(packet, reading, minute_confirmed=True, today="2026-04-07")
# Building the facts never touches the report's packet.
assert json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str) == before
assert facts["schema"] == "report-reading-facts-v1"
assert sorted(facts["domains"]) == sorted(READING_DOMAINS)
for name, planet in facts["planets"].items():
assert planet["d1_sign"] == dig(packet, f"core_chart.planets.{name}.sign")
assert planet["d1_house"] == dig(packet, f"core_chart.planets.{name}.house")
assert planet["d1_dignity"] == dig(packet, f"core_chart.planets.{name}.status")
for key in ("sign", "lord", "nakshatra"):
assert facts["ascendant"][key] == dig(packet, f"core_chart.ascendant.{key}")
current = dig(packet, "worksheets.timing_and_predictive_systems.dasha.current_dasha")
assert facts["timing"]["mahadasha"] == {"lord": current["lord"], "start": current["start"], "end": current["end"]}
rows = current["antardasha_timeline"]
index = next(i for i, row in enumerate(rows) if row.get("is_current") is True)
assert facts["timing"]["antardasha"]["lord"] == rows[index]["lord"]
assert [row["lord"] for row in facts["timing"]["next_antardashas"]] == [row["lord"] for row in rows[index + 1:index + 3]]
# Paths, checks and gaps stay out of what the model reads.
encoded = json.dumps(facts, ensure_ascii=False)
for marker in ('"path"', '"check"', '"status"', '"citation"', "references/"):
assert marker not in encoded
assert len(encoded) < 20_000
def test_withheld_varga_houses_stay_out_without_a_confirmed_minute(chart) -> None:
_case, reading, packet = chart
facts = build_report_reading_facts(packet, reading, minute_confirmed=False)
assert facts["minute_confirmed"] is False
assert "出生时间精度不够" not in json.dumps(facts, ensure_ascii=False)
class _Handler:
def __init__(self, case: dict, reading: dict) -> None:
self.case = case
self.reading = reading
self.calls = 0
def _high_rigor_birth_payload(self, body: dict) -> dict:
payload = {key: value for key, value in self.case.items() if key != "name"}
if body.get("today"):
payload["today"] = body["today"]
return payload
def _compute_full_reading_for_thematic(self, _birth: dict) -> dict:
self.calls += 1
return self.reading
@pytest.fixture
def stubbed(chart, monkeypatch, tmp_path):
import scripts.consult_full_data_cache as cache
case, reading, packet = chart
def _dir(name: str = "consult_full_data_cache"):
path = tmp_path / name
path.mkdir(parents=True, exist_ok=True)
return path
class _Engine:
def build_professional_report_reference_packet(self, _reading, _args, _packs, **_kwargs):
return copy.deepcopy(packet)
def render_pl9_markdown(self, _packet):
return "reference"
monkeypatch.setattr(cache, "cache_dir", _dir)
monkeypatch.setattr(reference, "render_full_data_markdown", lambda p: "en-body" if p.get("report_language") == "en" else "zh-body")
monkeypatch.setattr(reference, "report_density_packet", lambda _packet: {"facts": 1})
monkeypatch.setattr(reference, "render_dasha_applicability", lambda _packet, language="zh": f"app-{language}")
monkeypatch.setattr(reference, "clean_reader_appendix_markdown", lambda text: text)
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
return _Handler(case, reading), _Engine()
def _body(**extra) -> dict:
body = {"format": "markdown", "edition": "full_data", "include_fact_tables": True, "languages": ["zh"], "today": "2026-04-07"}
body.update(extra)
return body
def _dumps(result: dict) -> str:
return json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
def test_flag_off_response_is_unchanged(stubbed) -> None:
handler, engine = stubbed
absent = build_professional_report_reference(handler, _body(), engine=engine)
off = build_professional_report_reference(handler, _body(include_reading_facts=False), engine=engine)
assert _dumps(absent) == _dumps(off)
assert "reading_facts" not in absent
assert sorted(absent) == ["edition", "fact_table_packet", "format", "markdown", "reader_dasha_applicability", "report_version"]
def test_flag_on_adds_reading_facts_and_keeps_the_report(stubbed) -> None:
handler, engine = stubbed
plain = build_professional_report_reference(handler, _body(), engine=engine)
with_facts = build_professional_report_reference(
handler, _body(include_reading_facts=True, birth_time_accuracy="confirmed", hour=9, minute=15), engine=engine,
)
facts = with_facts.pop("reading_facts")
assert _dumps(with_facts) == _dumps(plain)
assert facts["schema"] == "report-reading-facts-v1"
assert facts["minute_confirmed"] is True
assert facts["today"] == "2026-04-07"
assert "gaps" not in facts
def test_reading_facts_failure_never_fails_the_report(stubbed, monkeypatch) -> None:
import scripts.report_render.reading_facts as module
handler, engine = stubbed
def broken(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(module, "build_report_reading_facts", broken)
result = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert result["markdown"] == "zh-body"
assert result["reading_facts"]["gaps"] == ["reading_facts_unavailable"]
def test_reading_flag_is_part_of_the_report_cache_key(stubbed, monkeypatch) -> None:
handler, engine = stubbed
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
monkeypatch.setenv("GITHUB_SHA", "reading-cache-test")
build_professional_report_reference(handler, _body(), engine=engine)
build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert handler.calls == 2
cached = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert handler.calls == 2
assert "reading_facts" in cached