feat(report): ship full-mode longform appendix beside the five-chapter report
Web export now calls the same full pack as the long skill report and caches an owner-only Markdown download. Appendix failure stays unavailable and does not change the main report status. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+986
@@ -0,0 +1,986 @@
|
||||
"""PyJHora annual replay boundary.
|
||||
|
||||
PyJHora/JHora is treated as an external reference engine. This wrapper imports
|
||||
it only inside `replay_pyjhora_annual`, preserves raw return shapes, and never
|
||||
vendors or normalizes it into the native engine as a hard dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
||||
from importlib import import_module as _import_module
|
||||
from inspect import signature
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
ENGINE = "PyJHora/JHora"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
|
||||
_YEAR_LORD_MODULES = (
|
||||
"jhora.horoscope.dhasa.annual.mudda",
|
||||
"jhora.horoscope.dhasa.annual.patyayini",
|
||||
)
|
||||
_YEAR_LORD_CALLABLES = (
|
||||
"panchadhikari_year_lord",
|
||||
"get_panchadhikari_year_lord",
|
||||
"get_year_lord",
|
||||
"year_lord",
|
||||
)
|
||||
_ANNUAL_SURFACE_KEYS = (
|
||||
"annual_chart_snapshot",
|
||||
"annual_chart_speed_snapshot",
|
||||
"mudda_dasha",
|
||||
"patyayini_dasha",
|
||||
"sahams",
|
||||
"solar_return_boundary",
|
||||
"tajika_yogas",
|
||||
)
|
||||
_PYJHORA_SAHAM_CALLABLES = (
|
||||
"apamrithyu_saham", "artha_saham", "asha_saham", "bandhana_saham", "bandhu_saham",
|
||||
"bhratri_saham", "gaurava_saham", "jadya_saham", "jalapatna_saham", "jeeva_saham",
|
||||
"kali_saham", "karma_saham", "karyasiddhi_saham", "laabha_saham", "maathri_saham",
|
||||
"mahatmaya_saham", "mitra_saham", "mrithyu_saham", "paradara_saham", "paradesa_saham",
|
||||
"pithri_saham", "preethi_saham", "punya_saham", "puthra_saham", "rajya_saham",
|
||||
"roga_sagam_1", "roga_saham", "samartha_saham", "santapa_saham", "sastra_saham",
|
||||
"sathru_saham", "sraddha_saham", "vanika_saham", "vidya_saham", "vivaha_saham",
|
||||
"vyaapaara_saham", "yasas_saham",
|
||||
)
|
||||
|
||||
|
||||
def probe_pyjhora_year_lord(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
distribution_version: Callable[[str], str] = _distribution_version,
|
||||
isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API.
|
||||
|
||||
This is deliberately an external-engine observation. PyJHora's annual
|
||||
modules are not assumed to implement Panchadhikari simply because they
|
||||
expose other Varshaphala calculations. The public Tajaka callable is
|
||||
preferred when present; annual-dasha modules remain a legacy fallback.
|
||||
"""
|
||||
|
||||
replay = {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_panchadhikari_year_lord_api_missing",
|
||||
"missing_api": "Panchadhikari/Year Lord callable",
|
||||
"probed_modules": [_TAJAKA_YEAR_LORD_MODULE, *_YEAR_LORD_MODULES],
|
||||
"available_annual_callables": [],
|
||||
"raw": None,
|
||||
"normalized_candidates": [],
|
||||
}
|
||||
result = {
|
||||
"engine": ENGINE,
|
||||
"input_profile_id": profile.get("profile_id"),
|
||||
"pyjhora_version": _pyjhora_version(distribution_version),
|
||||
"license_boundary": {
|
||||
"mode": "external_reference_only",
|
||||
"dependency_required": False,
|
||||
},
|
||||
"evidence_scope": "pyjhora_behavior_only",
|
||||
"parity_status": "not_multiengine_parity",
|
||||
"year_lord_replay": replay,
|
||||
}
|
||||
|
||||
replay_runner = isolated_replay or _run_isolated_tajaka_year_lord
|
||||
isolated = replay_runner(profile, birth_julian_day, place, age)
|
||||
if isolated.get("reason") != "pyjhora_tajaka_api_missing":
|
||||
replay.update(isolated)
|
||||
replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates"))
|
||||
if isolated.get("pyjhora_version"):
|
||||
result["pyjhora_version"] = isolated["pyjhora_version"]
|
||||
return result
|
||||
|
||||
modules: list[Any] = []
|
||||
for module_name in _YEAR_LORD_MODULES:
|
||||
try:
|
||||
modules.append(import_module(module_name))
|
||||
except Exception as exc:
|
||||
replay["status"] = "blocked"
|
||||
replay["reason"] = f"pyjhora_annual_module_import_failed:{module_name}:{exc.__class__.__name__}"
|
||||
replay["missing_api"] = module_name
|
||||
return result
|
||||
|
||||
for module in modules:
|
||||
replay["available_annual_callables"].extend(
|
||||
sorted(name for name in _YEAR_LORD_CALLABLES if callable(getattr(module, name, None)))
|
||||
)
|
||||
for name in _YEAR_LORD_CALLABLES:
|
||||
candidate = getattr(module, name, None)
|
||||
if not callable(candidate):
|
||||
continue
|
||||
return _call_year_lord_candidate(result, replay, candidate, name, birth_julian_day, place, age)
|
||||
return result
|
||||
|
||||
|
||||
def _run_isolated_tajaka_year_lord(
|
||||
profile: dict[str, Any],
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
) -> dict[str, Any]:
|
||||
settings = _replay_settings(profile)
|
||||
request = {
|
||||
"birth_julian_day": birth_julian_day,
|
||||
"place": _place_payload(place),
|
||||
"age": age,
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import json, sys; "
|
||||
"from scripts.annual_pyjhora_replay import _isolated_tajaka_worker; "
|
||||
"print(json.dumps(_isolated_tajaka_worker(json.load(sys.stdin)), sort_keys=True))"
|
||||
),
|
||||
]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
input=json.dumps(request, sort_keys=True),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _isolated_blocked(
|
||||
"pyjhora_isolated_replay_timeout", request_hash, settings
|
||||
)
|
||||
except Exception as exc:
|
||||
return _isolated_blocked(
|
||||
f"pyjhora_isolated_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return _isolated_blocked(
|
||||
f"pyjhora_isolated_replay_failed:exit_{completed.returncode}", request_hash, settings
|
||||
)
|
||||
try:
|
||||
replay = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return _isolated_blocked("pyjhora_isolated_replay_invalid_json", request_hash, settings)
|
||||
if not isinstance(replay, dict):
|
||||
return _isolated_blocked("pyjhora_isolated_replay_invalid_payload", request_hash, settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
|
||||
|
||||
def _isolated_tajaka_worker(request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Run in a child process so PyJHora global Ayanamsa state cannot leak."""
|
||||
|
||||
settings = dict(request.get("settings") or {})
|
||||
node_mode = {
|
||||
"requested": settings.get("node_mode"),
|
||||
"status": "unsupported_by_replay_adapter",
|
||||
}
|
||||
ayanamsa = settings.get("ayanamsa")
|
||||
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa",
|
||||
"settings": settings,
|
||||
"node_mode": node_mode,
|
||||
}
|
||||
try:
|
||||
# PyJHora can print import diagnostics; reserve stdout for the JSON reply.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
from jhora import const
|
||||
from jhora.panchanga import drik
|
||||
from jhora.horoscope.transit import tajaka
|
||||
|
||||
drik.set_ayanamsa_mode(ayanamsa.upper())
|
||||
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
|
||||
if str(effective_ayanamsa).upper() != ayanamsa.upper():
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch",
|
||||
"settings": settings,
|
||||
"effective_ayanamsa": effective_ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": f"pyjhora_ayanamsa_configuration_failed:{exc.__class__.__name__}",
|
||||
"settings": settings,
|
||||
"node_mode": node_mode,
|
||||
}
|
||||
|
||||
try:
|
||||
place_data = dict(request["place"])
|
||||
place = drik.Place(
|
||||
place_data["name"],
|
||||
float(place_data["latitude"]),
|
||||
float(place_data["longitude"]),
|
||||
float(place_data["timezone"]),
|
||||
place_data.get("elevation"),
|
||||
)
|
||||
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
|
||||
tajaka, float(request["birth_julian_day"]), place, request["age"]
|
||||
)
|
||||
raw = tajaka.lord_of_the_year(float(request["birth_julian_day"]), place, request["age"])
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
|
||||
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
|
||||
"settings": settings,
|
||||
"effective_ayanamsa": effective_ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
"raw": None,
|
||||
"raw_candidates": None,
|
||||
}
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"reason": "pyjhora_tajaka_year_lord_callable_observed",
|
||||
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
|
||||
"pyjhora_version": _pyjhora_version(_distribution_version),
|
||||
"settings": settings,
|
||||
"effective_ayanamsa": effective_ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
"year_lord": raw,
|
||||
"raw": raw,
|
||||
"raw_candidates": raw_candidates,
|
||||
"candidate_trace_status": candidate_trace_status,
|
||||
}
|
||||
|
||||
|
||||
def _run_isolated_annual_replay(
|
||||
profile: dict[str, Any],
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
) -> dict[str, Any]:
|
||||
"""Call optional PyJHora annual surfaces with a child-process Place."""
|
||||
|
||||
settings = _replay_settings(profile)
|
||||
request = {
|
||||
"birth_julian_day": birth_julian_day,
|
||||
"place": _place_payload(place),
|
||||
"age": age,
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import json, sys; "
|
||||
"from scripts.annual_pyjhora_replay import _isolated_annual_worker; "
|
||||
"print(json.dumps(_isolated_annual_worker(json.load(sys.stdin)), sort_keys=True, default=str))"
|
||||
),
|
||||
]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
input=json.dumps(request, sort_keys=True),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _annual_replay_blocked("pyjhora_isolated_annual_replay_timeout", request_hash, settings)
|
||||
except Exception as exc:
|
||||
return _annual_replay_blocked(
|
||||
f"pyjhora_isolated_annual_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return _annual_replay_blocked(
|
||||
f"pyjhora_isolated_annual_replay_failed:exit_{completed.returncode}", request_hash, settings
|
||||
)
|
||||
try:
|
||||
replay = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_json", request_hash, settings)
|
||||
if not isinstance(replay, dict):
|
||||
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_payload", request_hash, settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
|
||||
|
||||
def _isolated_annual_worker(request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Construct ``drik.Place`` after setting Ayanamsa in the child process."""
|
||||
|
||||
settings = dict(request.get("settings") or {})
|
||||
node_mode = {"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"}
|
||||
ayanamsa = settings.get("ayanamsa")
|
||||
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa"} for key in _ANNUAL_SURFACE_KEYS},
|
||||
settings=settings,
|
||||
node_mode=node_mode,
|
||||
)
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
from jhora import const
|
||||
from jhora.panchanga import drik
|
||||
from jhora.horoscope.dhasa.annual import mudda, patyayini
|
||||
from jhora.horoscope.transit import saham
|
||||
|
||||
drik.set_ayanamsa_mode(ayanamsa.upper())
|
||||
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
|
||||
if str(effective_ayanamsa).upper() != ayanamsa.upper():
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch"} for key in _ANNUAL_SURFACE_KEYS},
|
||||
settings=settings,
|
||||
node_mode=node_mode,
|
||||
effective_ayanamsa=effective_ayanamsa,
|
||||
)
|
||||
place_data = dict(request["place"])
|
||||
pyjhora_place = drik.Place(
|
||||
place_data["name"], float(place_data["latitude"]), float(place_data["longitude"]),
|
||||
float(place_data["timezone"]), place_data.get("elevation"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": f"pyjhora_annual_adapter_setup_failed:{exc.__class__.__name__}"} for key in _ANNUAL_SURFACE_KEYS},
|
||||
settings=settings,
|
||||
node_mode=node_mode,
|
||||
)
|
||||
|
||||
jd = float(request["birth_julian_day"])
|
||||
years = request["age"]
|
||||
surfaces = {
|
||||
"annual_chart_snapshot": _call_annual_chart_snapshot(jd, pyjhora_place, years),
|
||||
"annual_chart_speed_snapshot": _call_annual_chart_speed_snapshot(jd, pyjhora_place, years),
|
||||
"mudda_dasha": _call_mudda(mudda, jd, pyjhora_place, years),
|
||||
"patyayini_dasha": _call_patyayini(patyayini, jd, pyjhora_place, years),
|
||||
"sahams": _call_sahams(saham, jd, pyjhora_place, years),
|
||||
"solar_return_boundary": _call_solar_return_boundary(saham, jd, pyjhora_place, years),
|
||||
"tajika_yogas": _probe_tajika_yogas(jd, pyjhora_place, years),
|
||||
}
|
||||
return _annual_worker_result(
|
||||
surfaces,
|
||||
settings=settings,
|
||||
node_mode=node_mode,
|
||||
effective_ayanamsa=effective_ayanamsa,
|
||||
)
|
||||
|
||||
|
||||
def _annual_worker_result(
|
||||
surfaces: dict[str, dict[str, Any]],
|
||||
*,
|
||||
settings: dict[str, Any],
|
||||
node_mode: dict[str, Any],
|
||||
effective_ayanamsa: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**{key: surfaces.get(key, {"status": "blocked", "reason": "not_executed"}) for key in _ANNUAL_SURFACE_KEYS},
|
||||
"settings": settings,
|
||||
"effective_ayanamsa": effective_ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
"evidence_scope": "pyjhora_behavior_only",
|
||||
"parity_status": "not_multiengine_parity",
|
||||
}
|
||||
|
||||
|
||||
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
return _annual_worker_result(
|
||||
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
||||
settings=settings,
|
||||
node_mode={"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"},
|
||||
) | {"request_hash": request_hash}
|
||||
|
||||
|
||||
def _place_payload(place: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"name": getattr(place, "name", "External replay place"),
|
||||
"latitude": getattr(place, "latitude"),
|
||||
"longitude": getattr(place, "longitude"),
|
||||
"timezone": getattr(place, "timezone"),
|
||||
"elevation": getattr(place, "elevation", None),
|
||||
}
|
||||
|
||||
|
||||
def _request_hash(request: dict[str, Any]) -> str:
|
||||
payload = json.dumps(request, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _isolated_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": reason,
|
||||
"request_hash": request_hash,
|
||||
"settings": settings,
|
||||
"node_mode": {
|
||||
"requested": settings.get("node_mode"),
|
||||
"status": "unsupported_by_replay_adapter",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _replay_settings(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
settings = profile.get("settings")
|
||||
source = settings if isinstance(settings, dict) else profile
|
||||
return {
|
||||
key: source[key]
|
||||
for key in ("ayanamsa", "node_mode")
|
||||
if source.get(key) is not None
|
||||
}
|
||||
|
||||
|
||||
def _call_tajaka_year_lord(
|
||||
result: dict[str, Any],
|
||||
replay: dict[str, Any],
|
||||
tajaka: Any,
|
||||
*,
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
callable_name = f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year"
|
||||
try:
|
||||
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
|
||||
tajaka, birth_julian_day, place, age
|
||||
)
|
||||
raw = tajaka.lord_of_the_year(birth_julian_day, place, age)
|
||||
except Exception as exc:
|
||||
replay.update(
|
||||
{
|
||||
"status": "blocked",
|
||||
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
|
||||
"missing_api": None,
|
||||
"callable": callable_name,
|
||||
"settings": settings,
|
||||
"raw": None,
|
||||
"raw_candidates": None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
replay.update(
|
||||
{
|
||||
"status": "partial_verified",
|
||||
"reason": "pyjhora_tajaka_year_lord_callable_observed",
|
||||
"missing_api": None,
|
||||
"callable": callable_name,
|
||||
"settings": settings,
|
||||
"year_lord": raw,
|
||||
"raw": raw,
|
||||
"raw_candidates": raw_candidates,
|
||||
"candidate_trace_status": candidate_trace_status,
|
||||
"normalized_candidates": _normalize_tajaka_candidates(raw_candidates),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _tajaka_candidate_trace(
|
||||
tajaka: Any,
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
) -> tuple[Any, str]:
|
||||
"""Collect the source module's candidate list without reimplementing its rule."""
|
||||
|
||||
candidate_function = getattr(tajaka, "_get_lord_candidates", None)
|
||||
if not callable(candidate_function):
|
||||
return None, "unavailable"
|
||||
try:
|
||||
# Lightweight injected test modules can expose a direct trace helper.
|
||||
return candidate_function(), "observed"
|
||||
except TypeError:
|
||||
pass
|
||||
try:
|
||||
rasi_chart = tajaka.charts.divisional_chart(
|
||||
birth_julian_day, place, divisional_chart_factor=1
|
||||
)
|
||||
natal_planet_to_house = tajaka.utils.get_planet_house_dictionary_from_planet_positions(rasi_chart)
|
||||
natal_lagna_house = natal_planet_to_house[tajaka.const._ascendant_symbol]
|
||||
annual_jd = birth_julian_day + float(age) * tajaka.year_value
|
||||
annual_hour = tajaka.drik.jd_to_gregorian(annual_jd)[3]
|
||||
sunrise = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunrise(annual_jd, place)[1])
|
||||
sunset = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunset(annual_jd, place)[1])
|
||||
sunrise_hour = sunrise[0] + sunrise[1] / 60 + sunrise[2] / 3600
|
||||
sunset_hour = sunset[0] + sunset[1] / 60 + sunset[2] / 3600
|
||||
night_time_birth = annual_hour > sunset_hour or annual_hour < sunrise_hour
|
||||
annual_chart = tajaka.charts.divisional_chart(annual_jd, place, divisional_chart_factor=1)
|
||||
return candidate_function(annual_chart, age, natal_lagna_house, night_time_birth), "observed"
|
||||
except Exception as exc:
|
||||
return None, f"blocked:{exc.__class__.__name__}"
|
||||
|
||||
|
||||
def _normalize_tajaka_candidates(raw_candidates: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw_candidates, (list, tuple)):
|
||||
return []
|
||||
return [
|
||||
{"planet_index": candidate, "selection_state": "raw_tajaka_candidate"}
|
||||
for candidate in raw_candidates
|
||||
if isinstance(candidate, int)
|
||||
]
|
||||
|
||||
|
||||
def _pyjhora_version(distribution_version: Callable[[str], str]) -> str | None:
|
||||
try:
|
||||
return distribution_version("PyJHora")
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _call_year_lord_candidate(
|
||||
result: dict[str, Any],
|
||||
replay: dict[str, Any],
|
||||
candidate: Callable[..., Any],
|
||||
callable_name: str,
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
) -> dict[str, Any]:
|
||||
values = {
|
||||
"jd": birth_julian_day,
|
||||
"birth_julian_day": birth_julian_day,
|
||||
"place": place,
|
||||
"age": age,
|
||||
"years": age,
|
||||
}
|
||||
try:
|
||||
params = signature(candidate).parameters
|
||||
kwargs = {name: values[name] for name in params if name in values}
|
||||
required = [
|
||||
name for name, parameter in params.items()
|
||||
if parameter.default is parameter.empty
|
||||
and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
|
||||
and name not in kwargs
|
||||
]
|
||||
if required:
|
||||
replay.update(
|
||||
{
|
||||
"reason": "pyjhora_year_lord_callable_signature_unsupported",
|
||||
"missing_api": ", ".join(required),
|
||||
"callable": callable_name,
|
||||
}
|
||||
)
|
||||
return result
|
||||
raw = candidate(**kwargs)
|
||||
except Exception as exc:
|
||||
replay.update(
|
||||
{
|
||||
"reason": f"pyjhora_year_lord_callable_failed:{callable_name}:{exc.__class__.__name__}",
|
||||
"missing_api": None,
|
||||
"callable": callable_name,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
replay.update(
|
||||
{
|
||||
"status": "partial_verified",
|
||||
"reason": "pyjhora_year_lord_callable_observed",
|
||||
"missing_api": None,
|
||||
"callable": callable_name,
|
||||
"year_lord": raw.get("year_lord") if isinstance(raw, dict) else None,
|
||||
"raw": raw,
|
||||
"normalized_candidates": _normalize_year_lord_candidates(raw),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_year_lord_candidates(raw: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return []
|
||||
candidates = raw.get("candidates") or raw.get("candidate_planets")
|
||||
if not isinstance(candidates, list):
|
||||
return []
|
||||
return [candidate for candidate in candidates if isinstance(candidate, dict)]
|
||||
|
||||
|
||||
def replay_pyjhora_annual(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
year_lord_probe: Callable[..., dict[str, Any]] | None = None,
|
||||
isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Replay annual PyJHora methods for the same profile, if available."""
|
||||
|
||||
year_lord_observer = year_lord_probe or probe_pyjhora_year_lord
|
||||
year_lord_result = year_lord_observer(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
)
|
||||
year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None
|
||||
blocked = _base(profile)
|
||||
blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_year_lord_probe_invalid_payload",
|
||||
}
|
||||
blocked["evidence_scope"] = "pyjhora_behavior_only"
|
||||
blocked["parity_status"] = "not_multiengine_parity"
|
||||
if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None):
|
||||
observer = isolated_annual_replay or _run_isolated_annual_replay
|
||||
observed = observer(profile, birth_julian_day, place, age)
|
||||
if not isinstance(observed, dict):
|
||||
observed = _annual_replay_blocked(
|
||||
"pyjhora_isolated_annual_replay_invalid_payload",
|
||||
_request_hash({"birth_julian_day": birth_julian_day, "place": _place_payload(place), "age": age}),
|
||||
_replay_settings(profile),
|
||||
)
|
||||
result = _base(profile)
|
||||
for key in _ANNUAL_SURFACE_KEYS:
|
||||
value = observed.get(key)
|
||||
result[key] = value if isinstance(value, dict) else {"status": "blocked", "reason": f"{key}_missing_from_isolated_replay"}
|
||||
result["year_lord_replay"] = blocked["year_lord_replay"]
|
||||
for key in ("effective_ayanamsa", "node_mode", "request_hash"):
|
||||
if key in observed:
|
||||
result[key] = observed[key]
|
||||
result["evidence_scope"] = "pyjhora_behavior_only"
|
||||
result["parity_status"] = "not_multiengine_parity"
|
||||
result["status"] = _aggregate_status(result)
|
||||
return result
|
||||
try:
|
||||
mudda = import_module("jhora.horoscope.dhasa.annual.mudda")
|
||||
patyayini = import_module("jhora.horoscope.dhasa.annual.patyayini")
|
||||
saham = import_module("jhora.horoscope.transit.saham")
|
||||
except Exception as exc:
|
||||
reason = f"pyjhora_import_failed:{exc.__class__.__name__}:{exc}"
|
||||
for key in _ANNUAL_SURFACE_KEYS:
|
||||
blocked[key] = {"status": "blocked", "reason": reason}
|
||||
blocked["status"] = "blocked"
|
||||
return blocked
|
||||
|
||||
result = _base(profile)
|
||||
result["annual_chart_snapshot"] = _call_annual_chart_snapshot(birth_julian_day, place, age)
|
||||
result["mudda_dasha"] = _call_mudda(mudda, birth_julian_day, place, age)
|
||||
result["patyayini_dasha"] = _call_patyayini(patyayini, birth_julian_day, place, age)
|
||||
result["sahams"] = _call_sahams(saham, birth_julian_day, place, age)
|
||||
result["solar_return_boundary"] = _call_solar_return_boundary(saham, birth_julian_day, place, age)
|
||||
result["tajika_yogas"] = {"status": "blocked", "reason": "pyjhora_tajika_yogas_requires_isolated_replay"}
|
||||
result["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_year_lord_probe_invalid_payload",
|
||||
}
|
||||
result["evidence_scope"] = "pyjhora_behavior_only"
|
||||
result["parity_status"] = "not_multiengine_parity"
|
||||
result["status"] = _aggregate_status(result)
|
||||
return result
|
||||
|
||||
|
||||
def _base(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"engine": ENGINE,
|
||||
"license_boundary": {
|
||||
"mode": "external_reference_only",
|
||||
"dependency_required": False,
|
||||
"notes": "PyJHora/JHora remains an optional external oracle/replay boundary.",
|
||||
},
|
||||
"input_profile_id": profile.get("profile_id"),
|
||||
"annual_chart_snapshot": {"status": "blocked", "reason": "not_executed"},
|
||||
"annual_chart_speed_snapshot": {"status": "blocked", "reason": "not_executed"},
|
||||
"mudda_dasha": {"status": "blocked", "reason": "not_executed"},
|
||||
"patyayini_dasha": {"status": "blocked", "reason": "not_executed"},
|
||||
"sahams": {"status": "blocked", "reason": "not_executed"},
|
||||
"solar_return_boundary": {"status": "blocked", "reason": "not_executed"},
|
||||
"tajika_yogas": {"status": "blocked", "reason": "not_executed"},
|
||||
"year_lord_replay": {"status": "blocked", "reason": "not_executed"},
|
||||
"evidence_scope": "pyjhora_behavior_only",
|
||||
"parity_status": "not_multiengine_parity",
|
||||
"status": "blocked",
|
||||
}
|
||||
|
||||
|
||||
def _call_mudda(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
try:
|
||||
if hasattr(module, "varsha_vimsottari_dasha_start_date"):
|
||||
payload["varsha_vimsottari_start"] = module.varsha_vimsottari_dasha_start_date(
|
||||
birth_julian_day, place, age
|
||||
)
|
||||
if hasattr(module, "mudda_dhasa_bhukthi"):
|
||||
payload["periods"] = module.mudda_dhasa_bhukthi(birth_julian_day, place, age)
|
||||
return {"status": "partial_verified", "raw": payload}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_mudda_failed:{exc}"}
|
||||
|
||||
|
||||
def _call_patyayini(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
try:
|
||||
jd_for_year = birth_julian_day + float(age) * 365.256364
|
||||
return {"status": "partial_verified", "raw": {"periods": module.get_dhasa_bhukthi(jd_for_year, place)}}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_patyayini_failed:{exc}"}
|
||||
|
||||
|
||||
def _call_sahams(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
"""Replay the current PyJHora per-Saham API against its annual chart.
|
||||
|
||||
Older PyJHora releases exposed an aggregate ``sahams`` function. Current
|
||||
releases expose individual functions which all consume annual-chart planet
|
||||
positions. Keep both paths external-reference-only and preserve the exact
|
||||
source API used for audit.
|
||||
"""
|
||||
try:
|
||||
individual = [name for name in _PYJHORA_SAHAM_CALLABLES if callable(getattr(module, name, None))]
|
||||
if not individual:
|
||||
if hasattr(module, "sahams"):
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"callable": "jhora.horoscope.transit.saham.sahams",
|
||||
"raw": module.sahams(birth_julian_day, place, age),
|
||||
"reason": "legacy_pyjhora_aggregate_sahams_api",
|
||||
}
|
||||
return {"status": "blocked", "reason": "pyjhora_sahams_callable_missing"}
|
||||
|
||||
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
||||
annual_chart = getattr(tajaka, "annual_chart", None)
|
||||
if not callable(annual_chart):
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
||||
chart_payload = annual_chart(birth_julian_day, place, years=age)
|
||||
if not isinstance(chart_payload, tuple) or len(chart_payload) < 2 or not isinstance(chart_payload[0], list):
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
||||
positions, return_marker = chart_payload[0], chart_payload[1]
|
||||
annual_moment = _annual_return_datetime(return_marker)
|
||||
if annual_moment is None:
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
|
||||
try:
|
||||
try:
|
||||
from scripts.saham_daynight import determine_daytime
|
||||
except ImportError: # pragma: no cover - direct script execution
|
||||
from saham_daynight import determine_daytime
|
||||
daynight = determine_daytime(
|
||||
annual_moment,
|
||||
lat=float(place.latitude),
|
||||
lon=float(place.longitude),
|
||||
tz=float(place.timezone),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_sahams_daynight_failed:{exc.__class__.__name__}"}
|
||||
|
||||
values: dict[str, Any] = {}
|
||||
failures: dict[str, str] = {}
|
||||
for name in individual:
|
||||
candidate = getattr(module, name)
|
||||
try:
|
||||
parameters = signature(candidate).parameters
|
||||
if "night_time_birth" in parameters:
|
||||
values[name] = candidate(positions, night_time_birth=not bool(daynight["is_daytime"]))
|
||||
else:
|
||||
values[name] = candidate(positions)
|
||||
except Exception as exc: # External API variations must remain visible.
|
||||
failures[name] = exc.__class__.__name__
|
||||
if not values:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_individual_sahams_all_failed",
|
||||
"failed_callables": failures,
|
||||
}
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
|
||||
"reason": "pyjhora_individual_sahams_from_annual_chart",
|
||||
"daynight": daynight,
|
||||
"raw": {
|
||||
"annual_return": _annual_return_marker(return_marker),
|
||||
"sahams": values,
|
||||
"failed_callables": failures,
|
||||
"available_callables": individual,
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_sahams_failed:{exc}"}
|
||||
|
||||
|
||||
def _call_annual_chart_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
"""Capture PyJHora's annual chart without deriving a local interpretation.
|
||||
|
||||
This is an external-reference snapshot for profile diagnosis. It exists so
|
||||
annual Tajika comparisons can distinguish a chart-input difference from a
|
||||
candidate-rule difference. It is not a report authority or a Yoga result.
|
||||
"""
|
||||
try:
|
||||
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
||||
annual_chart = getattr(tajaka, "annual_chart", None)
|
||||
if not callable(annual_chart):
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
||||
payload = annual_chart(birth_julian_day, place, years=age)
|
||||
if not isinstance(payload, tuple) or len(payload) < 2 or not isinstance(payload[0], list):
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
||||
positions, return_marker = payload[0], payload[1]
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
|
||||
"reason": "pyjhora_annual_chart_snapshot_for_profile_comparison",
|
||||
"raw": {
|
||||
"annual_return": _annual_return_marker(return_marker),
|
||||
"positions": positions,
|
||||
},
|
||||
"claim_boundary": "external_annual_chart_snapshot_is_not_tajika_yoga_or_report_authority",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_annual_chart_snapshot_failed:{exc.__class__.__name__}"}
|
||||
|
||||
|
||||
def _signed_longitude_delta(previous: float, following: float) -> float:
|
||||
return (float(following) - float(previous) + 180.0) % 360.0 - 180.0
|
||||
|
||||
|
||||
def _planet_longitudes_from_positions(positions: Any) -> dict[int, float]:
|
||||
values: dict[int, float] = {}
|
||||
for row in positions if isinstance(positions, list) else []:
|
||||
if not isinstance(row, (list, tuple)) or len(row) < 2 or not isinstance(row[0], int):
|
||||
continue
|
||||
planet, position = row[0], row[1]
|
||||
if planet not in range(7) or not isinstance(position, (list, tuple)) or len(position) < 2:
|
||||
continue
|
||||
sign, degree = position[0], position[1]
|
||||
if isinstance(sign, (int, float)) and isinstance(degree, (int, float)):
|
||||
values[planet] = (float(sign) * 30.0 + float(degree)) % 360.0
|
||||
return values
|
||||
|
||||
|
||||
def _call_annual_chart_speed_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
"""Observe PyJHora annual-chart motion by finite difference.
|
||||
|
||||
The upstream annual-chart snapshot exposes longitudes but not speeds. This
|
||||
child-process-only probe samples one minute on either side of the external
|
||||
return marker so a later diagnostic can distinguish chart coordinates from
|
||||
applying/separating semantics. It is not a native speed source or Tajika
|
||||
Yoga authority.
|
||||
"""
|
||||
|
||||
try:
|
||||
from jhora import utils
|
||||
from jhora.panchanga import drik
|
||||
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
||||
annual_chart = getattr(tajaka, "annual_chart", None)
|
||||
if not callable(annual_chart):
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
||||
payload = annual_chart(birth_julian_day, place, years=age)
|
||||
if not isinstance(payload, tuple) or len(payload) < 2:
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
||||
annual_moment = _annual_return_datetime(payload[1])
|
||||
if annual_moment is None:
|
||||
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
|
||||
|
||||
offset_seconds = 60
|
||||
before = annual_moment - timedelta(seconds=offset_seconds)
|
||||
after = annual_moment + timedelta(seconds=offset_seconds)
|
||||
before_jd = utils.julian_day_number(
|
||||
(before.year, before.month, before.day), (before.hour, before.minute, before.second)
|
||||
)
|
||||
after_jd = utils.julian_day_number(
|
||||
(after.year, after.month, after.day), (after.hour, after.minute, after.second)
|
||||
)
|
||||
before_positions = _planet_longitudes_from_positions(drik.dhasavarga(before_jd, place))
|
||||
after_positions = _planet_longitudes_from_positions(drik.dhasavarga(after_jd, place))
|
||||
if set(before_positions) != set(range(7)) or set(after_positions) != set(range(7)):
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "pyjhora_annual_chart_speed_positions_incomplete",
|
||||
"before_planets": sorted(before_positions),
|
||||
"after_planets": sorted(after_positions),
|
||||
}
|
||||
interval_days = (2.0 * offset_seconds) / 86400.0
|
||||
rates = {
|
||||
planet: round(_signed_longitude_delta(before_positions[planet], after_positions[planet]) / interval_days, 8)
|
||||
for planet in range(7)
|
||||
}
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"callable": "jhora.panchanga.drik.dhasavarga",
|
||||
"reason": "pyjhora_annual_chart_finite_difference_speed_snapshot",
|
||||
"raw": {
|
||||
"annual_return": _annual_return_marker(payload[1]),
|
||||
"sample_offset_seconds": offset_seconds,
|
||||
"planet_rates_deg_per_day": rates,
|
||||
},
|
||||
"claim_boundary": "external_annual_speed_snapshot_is_comparison_only_not_tajika_yoga_or_report_authority",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_annual_chart_speed_snapshot_failed:{exc.__class__.__name__}"}
|
||||
|
||||
|
||||
def _annual_return_marker(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
return {"raw": value}
|
||||
date_value, time_value = value[0], value[1]
|
||||
return {
|
||||
"date": list(date_value) if isinstance(date_value, (list, tuple)) else date_value,
|
||||
"time": str(time_value),
|
||||
}
|
||||
|
||||
|
||||
def _annual_return_datetime(value: Any) -> datetime | None:
|
||||
marker = _annual_return_marker(value)
|
||||
date_value = marker.get("date")
|
||||
time_value = marker.get("time")
|
||||
if not isinstance(date_value, list) or len(date_value) != 3 or not isinstance(time_value, str):
|
||||
return None
|
||||
try:
|
||||
hour, minute, second = [int(part) for part in time_value.split(":")]
|
||||
return datetime(int(date_value[0]), int(date_value[1]), int(date_value[2]), hour, minute, second)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _call_solar_return_boundary(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
try:
|
||||
if not hasattr(module, "solar_return_chart"):
|
||||
return {"status": "blocked", "reason": "pyjhora_solar_return_chart_missing"}
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"raw": module.solar_return_chart(birth_julian_day, place, age),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_solar_return_failed:{exc}"}
|
||||
|
||||
|
||||
def _probe_tajika_yogas(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
||||
"""Probe callable availability without recreating PyJHora yoga rules."""
|
||||
|
||||
try:
|
||||
module = _import_module("jhora.horoscope.transit.tajaka_yoga")
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_module_missing:{exc.__class__.__name__}"}
|
||||
for name in ("tajaka_yoga", "tajika_yoga", "get_tajaka_yogas", "get_tajika_yogas"):
|
||||
candidate = getattr(module, name, None)
|
||||
if not callable(candidate):
|
||||
continue
|
||||
values = {"jd": birth_julian_day, "birth_julian_day": birth_julian_day, "place": place, "age": age, "years": age}
|
||||
try:
|
||||
params = signature(candidate).parameters
|
||||
kwargs = {key: values[key] for key in params if key in values}
|
||||
missing = [key for key, parameter in params.items() if parameter.default is parameter.empty and key not in kwargs]
|
||||
if missing:
|
||||
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_signature_unsupported", "callable": name, "missing": missing}
|
||||
return {"status": "partial_verified", "callable": name, "raw": candidate(**kwargs)}
|
||||
except Exception as exc:
|
||||
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_callable_failed:{exc.__class__.__name__}", "callable": name}
|
||||
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_missing"}
|
||||
|
||||
|
||||
def _aggregate_status(result: dict[str, Any]) -> str:
|
||||
statuses = {
|
||||
result[key].get("status")
|
||||
for key in (*_ANNUAL_SURFACE_KEYS, "year_lord_replay")
|
||||
if isinstance(result.get(key), dict)
|
||||
}
|
||||
if statuses == {"partial_verified"}:
|
||||
return "partial_verified"
|
||||
if "partial_verified" in statuses:
|
||||
return "partial_verified"
|
||||
return "blocked"
|
||||
Executable
+651
@@ -0,0 +1,651 @@
|
||||
"""Annual Varshaphala / Tajika report pack contract.
|
||||
|
||||
The pack normalizes existing annual producers into a stable, audit-friendly
|
||||
shape. It does not adjudicate conflicting annual methods yet; that belongs to
|
||||
the next conflict-gate layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from calculation_profile_contract import build_calculation_profile
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.calculation_profile_contract import build_calculation_profile
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from tajika_named_yoga_authority import build_tajika_named_yoga_authority
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.tajika_named_yoga_authority import build_tajika_named_yoga_authority
|
||||
|
||||
|
||||
SCHEMA = "jyotish.annual_tajika_pack.v1"
|
||||
|
||||
|
||||
def build_annual_tajika_pack(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
solar_return_report: dict[str, Any] | None = None,
|
||||
tajika_report: dict[str, Any] | None = None,
|
||||
pyjhora_replay: dict[str, Any] | None = None,
|
||||
vedastro_reference: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a JSON-safe annual pack for arbitrary birth payloads."""
|
||||
|
||||
profile = build_calculation_profile(payload)
|
||||
args = _args_from_payload(payload)
|
||||
|
||||
solar_report = solar_return_report if solar_return_report is not None else _safe_solar_return(args)
|
||||
tajika_report = tajika_report if tajika_report is not None else _safe_tajika(args)
|
||||
|
||||
solar_return = _field_from_report(solar_report, "solar_return", "Solar Return")
|
||||
annual_chart = _annual_chart_from_solar_report(solar_report)
|
||||
muntha = _field_with_conflict_gate("muntha", solar_report, tajika_report)
|
||||
year_lord = _field_with_conflict_gate("year_lord", solar_report, tajika_report)
|
||||
tajika_yogas = _tajika_yogas_field(solar_report)
|
||||
sahams = _field_from_report(solar_report, "sahams", "Sahams")
|
||||
mudda_dasha = _dasha_field("Mudda Dasha", solar_report, tajika_report, "mudda_dasha")
|
||||
patyayini_dasha = _dasha_field("Patyayini Dasha", solar_report, tajika_report, "patyayini_dasha")
|
||||
|
||||
pack = {
|
||||
"schema": SCHEMA,
|
||||
"profile": {
|
||||
**profile,
|
||||
"target_year": int(payload["target_year"]),
|
||||
},
|
||||
"solar_return": solar_return,
|
||||
"annual_chart": annual_chart,
|
||||
"muntha": muntha,
|
||||
"year_lord": year_lord,
|
||||
"tajika_yogas": tajika_yogas,
|
||||
"sahams": sahams,
|
||||
"mudda_dasha": mudda_dasha,
|
||||
"patyayini_dasha": patyayini_dasha,
|
||||
"monthly_windows": _monthly_windows(mudda_dasha),
|
||||
"external_engine_comparison": _external_engine_comparison(pyjhora_replay, vedastro_reference),
|
||||
"report_sections": _report_sections(
|
||||
profile,
|
||||
muntha=muntha,
|
||||
year_lord=year_lord,
|
||||
tajika_yogas=tajika_yogas,
|
||||
sahams=sahams,
|
||||
),
|
||||
"exports": {},
|
||||
"audit": _audit(profile, solar_report, tajika_report, pyjhora_replay, vedastro_reference),
|
||||
}
|
||||
pack["exports"] = _exports(pack)
|
||||
return pack
|
||||
|
||||
|
||||
def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace:
|
||||
birth = dict(payload.get("birth") or {})
|
||||
settings = dict(payload.get("settings") or {})
|
||||
year, month, day = [int(part) for part in str(birth["date"]).split("-")]
|
||||
time_parts = [int(part) for part in str(birth.get("time") or "00:00:00").split(":")]
|
||||
while len(time_parts) < 3:
|
||||
time_parts.append(0)
|
||||
age = payload.get("age")
|
||||
if age is None and payload.get("target_year") is not None:
|
||||
age = int(payload["target_year"]) - year
|
||||
return SimpleNamespace(
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=time_parts[0],
|
||||
minute=time_parts[1],
|
||||
second=time_parts[2],
|
||||
lat=float(birth["latitude"]),
|
||||
lon=float(birth["longitude"]),
|
||||
tz=_offset_to_hours(birth.get("utc_offset")),
|
||||
target_year=int(payload["target_year"]),
|
||||
age=age,
|
||||
mode="all",
|
||||
ayanamsa=settings.get("ayanamsa", "lahiri"),
|
||||
node_mode=settings.get("node_mode", "mean"),
|
||||
house_system=settings.get("house_system", "whole_sign"),
|
||||
position_mode=settings.get("position_mode", "legacy"),
|
||||
dasha_year_days=settings.get("dasha_year_days", 365.25),
|
||||
solar_return_location_mode=settings.get("solar_return_location_mode", "birth_place"),
|
||||
annual_year_policy=settings.get("annual_year_policy", "solar_return_exact"),
|
||||
)
|
||||
|
||||
|
||||
def _offset_to_hours(offset: Any) -> float:
|
||||
if offset is None:
|
||||
return 0.0
|
||||
if isinstance(offset, (int, float)):
|
||||
return float(offset)
|
||||
text = str(offset).strip()
|
||||
sign = -1 if text.startswith("-") else 1
|
||||
text = text.lstrip("+-")
|
||||
hours, minutes = [int(part) for part in text.split(":")[:2]]
|
||||
return sign * (hours + minutes / 60)
|
||||
|
||||
|
||||
def _safe_solar_return(args: SimpleNamespace) -> dict[str, Any]:
|
||||
try:
|
||||
from scripts.cmd_solar_return import cmd_solar_return
|
||||
except ImportError: # pragma: no cover
|
||||
from cmd_solar_return import cmd_solar_return
|
||||
try:
|
||||
result = cmd_solar_return(args)
|
||||
return result if isinstance(result, dict) else {"error": "solar_return_non_dict"}
|
||||
except Exception as exc: # pragma: no cover - defensive boundary
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def _safe_tajika(args: SimpleNamespace) -> dict[str, Any]:
|
||||
try:
|
||||
from scripts.jyotish_engine import cmd_tajika
|
||||
except ImportError: # pragma: no cover
|
||||
from jyotish_engine import cmd_tajika
|
||||
try:
|
||||
result = cmd_tajika(args)
|
||||
return result if isinstance(result, dict) else {"error": "tajika_non_dict"}
|
||||
except Exception as exc: # pragma: no cover - defensive boundary
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def _field_from_report(report: dict[str, Any], key: str, label: str) -> dict[str, Any]:
|
||||
if not isinstance(report, dict) or report.get("error"):
|
||||
return {"status": "blocked", "producer": label, "reason": report.get("error", "producer_failed")}
|
||||
value = report.get(key)
|
||||
if value is None:
|
||||
return {"status": "blocked", "producer": label, "reason": f"{key}_missing"}
|
||||
if isinstance(value, dict) and value.get("status") in {"blocked", "conflict", "not_applicable", "parameter_sensitive", "partial_verified", "verified"}:
|
||||
normalized = {
|
||||
"status": value.get("status"),
|
||||
"producer": label,
|
||||
"data": value,
|
||||
}
|
||||
if value.get("reason") is not None:
|
||||
normalized["reason"] = value.get("reason")
|
||||
return normalized
|
||||
return {"status": "partial_verified", "producer": label, "data": value}
|
||||
|
||||
|
||||
def _annual_chart_from_solar_report(report: dict[str, Any]) -> dict[str, Any]:
|
||||
field = _field_from_report(report, "sr_chart_info", "Solar Return Annual Chart")
|
||||
if field["status"] == "partial_verified":
|
||||
field["data_contract"] = "normalized_from_sr_chart_info"
|
||||
return field
|
||||
|
||||
|
||||
def _tajika_yogas_field(solar_report: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(solar_report, dict) or solar_report.get("error"):
|
||||
return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas")
|
||||
authority = _build_governed_tajika_named_yoga_authority(solar_report)
|
||||
if authority is None:
|
||||
return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas")
|
||||
return {
|
||||
"status": "partial_verified" if authority.get("status") == "authority_ready" else authority.get("status", "blocked"),
|
||||
"producer": "Tajika Named Yoga Authority",
|
||||
"data": authority,
|
||||
"data_contract": "governed_named_yoga_authority_surface",
|
||||
}
|
||||
|
||||
|
||||
def _build_governed_tajika_named_yoga_authority(solar_report: dict[str, Any]) -> dict[str, Any] | None:
|
||||
planets = _extract_tajika_motion_planets(solar_report)
|
||||
if not planets:
|
||||
return None
|
||||
authority = build_tajika_named_yoga_authority(planets)
|
||||
if not isinstance(authority, dict):
|
||||
return None
|
||||
return authority
|
||||
|
||||
|
||||
def _extract_tajika_motion_planets(solar_report: dict[str, Any]) -> dict[str, dict[str, float]]:
|
||||
chart = solar_report.get("chart") if isinstance(solar_report.get("chart"), dict) else {}
|
||||
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
|
||||
extracted: dict[str, dict[str, float]] = {}
|
||||
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
|
||||
payload = planets.get(name)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
longitude = payload.get("degree_raw", payload.get("degree"))
|
||||
speed = payload.get("speed")
|
||||
if longitude is None or speed is None:
|
||||
continue
|
||||
extracted[name] = {"longitude": float(longitude), "speed": float(speed)}
|
||||
return extracted
|
||||
|
||||
|
||||
def _first_available_field(label: str, *reports_and_key: Any) -> dict[str, Any]:
|
||||
*reports, key = reports_and_key
|
||||
producers = []
|
||||
for report in reports:
|
||||
field = _field_from_report(report, key, label)
|
||||
producers.append(field)
|
||||
if field["status"] == "partial_verified":
|
||||
field["producers_checked"] = producers
|
||||
return field
|
||||
return {"status": "blocked", "producer": label, "reason": f"{key}_missing", "producers_checked": producers}
|
||||
|
||||
|
||||
def _field_with_conflict_gate(key: str, solar_report: dict[str, Any], tajika_report: dict[str, Any]) -> dict[str, Any]:
|
||||
values = []
|
||||
for producer, report in (("solar_return", solar_report), ("tajika", tajika_report)):
|
||||
if isinstance(report, dict) and report.get(key) is not None and not report.get("error"):
|
||||
values.append({"producer": producer, "value": report[key]})
|
||||
if len(values) >= 2 and values[0]["value"] != values[1]["value"]:
|
||||
return {
|
||||
"status": "conflict",
|
||||
"field": key,
|
||||
"values": values,
|
||||
"reason": "same_profile_annual_producers_disagree",
|
||||
}
|
||||
if values:
|
||||
return {
|
||||
"status": "partial_verified",
|
||||
"producer": values[0]["producer"],
|
||||
"data": values[0]["value"],
|
||||
"producers_checked": values,
|
||||
}
|
||||
return {
|
||||
"status": "blocked",
|
||||
"field": key,
|
||||
"reason": f"{key}_missing",
|
||||
"producers_checked": values,
|
||||
}
|
||||
|
||||
|
||||
def _dasha_field(label: str, solar_report: dict[str, Any], tajika_report: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
field = _first_available_field(label, solar_report, tajika_report, key)
|
||||
data = field.get("data") if isinstance(field.get("data"), dict) else {}
|
||||
periods = data.get("periods") or data.get("dasha_sequence") or []
|
||||
field["periods"] = periods if isinstance(periods, list) else []
|
||||
return field
|
||||
|
||||
|
||||
def _monthly_windows(mudda_dasha: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
windows = []
|
||||
for idx, period in enumerate(mudda_dasha.get("periods", []), start=1):
|
||||
if not isinstance(period, dict):
|
||||
continue
|
||||
windows.append(
|
||||
{
|
||||
"index": idx,
|
||||
"source": "mudda_dasha",
|
||||
"lord": period.get("lord"),
|
||||
"duration_months": period.get("months"),
|
||||
"status": "partial_verified",
|
||||
}
|
||||
)
|
||||
return windows
|
||||
|
||||
|
||||
def _report_sections(
|
||||
profile: dict[str, Any],
|
||||
*,
|
||||
muntha: dict[str, Any] | None = None,
|
||||
year_lord: dict[str, Any] | None = None,
|
||||
tajika_yogas: dict[str, Any] | None = None,
|
||||
sahams: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
blocked_fields = [
|
||||
name
|
||||
for name, field in (("muntha", muntha), ("year_lord", year_lord))
|
||||
if isinstance(field, dict) and field.get("status") == "blocked"
|
||||
]
|
||||
conflict_fields = [
|
||||
name
|
||||
for name, field in (("muntha", muntha), ("year_lord", year_lord))
|
||||
if isinstance(field, dict) and field.get("status") == "conflict"
|
||||
]
|
||||
all_flagged_fields = [
|
||||
name
|
||||
for name in (*blocked_fields, *conflict_fields)
|
||||
if name
|
||||
]
|
||||
muntha_brief = _field_brief("Muntha", muntha)
|
||||
year_lord_brief = _field_brief("Year Lord", year_lord)
|
||||
tajika_brief = _field_brief("Tajika Yogas", tajika_yogas)
|
||||
sahams_brief = _field_brief("Sahams", sahams)
|
||||
quick_takeaways = [item for item in (muntha_brief, year_lord_brief) if item]
|
||||
narrative_preview = [
|
||||
"年度层已经有可读壳层,但仍需要带着冲突标签阅读。",
|
||||
muntha_brief,
|
||||
year_lord_brief,
|
||||
]
|
||||
if blocked_fields:
|
||||
summary_status = "blocked"
|
||||
summary_reason = "interpretive annual narrative waits for blocked field closure"
|
||||
thematic_status = "blocked"
|
||||
thematic_reason = "annual technique closure incomplete"
|
||||
elif conflict_fields:
|
||||
summary_status = "parameter_sensitive"
|
||||
summary_reason = "interpretive annual narrative remains conflict-labeled until producer adjudication closes"
|
||||
thematic_status = "parameter_sensitive"
|
||||
thematic_reason = "annual technique conflict requires labeled reading"
|
||||
else:
|
||||
summary_status = "partial_verified"
|
||||
summary_reason = "annual shell is readable but still awaits broader external parity closure"
|
||||
thematic_status = "partial_verified"
|
||||
thematic_reason = "annual technique shell available with current native evidence"
|
||||
return {
|
||||
"executive_summary": {
|
||||
"status": summary_status,
|
||||
"reason": summary_reason,
|
||||
"blocked_fields": all_flagged_fields,
|
||||
"summary_lines": narrative_preview,
|
||||
"quick_takeaways": quick_takeaways,
|
||||
},
|
||||
"thematic_narrative": {
|
||||
"status": thematic_status,
|
||||
"reason": thematic_reason,
|
||||
"highlights": [
|
||||
"Solar Return / Tajika annual shell is available.",
|
||||
"Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.",
|
||||
"The report should read annual structure first, then the evidence appendix.",
|
||||
],
|
||||
},
|
||||
"evidence_appendix": {
|
||||
"status": "partial_verified",
|
||||
"profile_id": profile["profile_id"],
|
||||
"field_briefs": {
|
||||
"muntha": muntha_brief,
|
||||
"year_lord": year_lord_brief,
|
||||
"tajika_yogas": tajika_brief,
|
||||
"sahams": sahams_brief,
|
||||
},
|
||||
"must_not_claim": ["exact_annual_event_prediction"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _exports(pack: dict[str, Any]) -> dict[str, Any]:
|
||||
exports = {
|
||||
"json": {
|
||||
"schema": pack["schema"],
|
||||
"profile_id": pack["profile"]["profile_id"],
|
||||
"field_statuses": {
|
||||
key: value.get("status")
|
||||
for key, value in pack.items()
|
||||
if isinstance(value, dict) and "status" in value
|
||||
},
|
||||
},
|
||||
"markdown": _markdown_summary(pack),
|
||||
"ai_evidence_bundle": {
|
||||
"schema": "jyotish.annual_tajika_pack.ai_evidence.v1",
|
||||
"profile_id": pack["profile"]["profile_id"],
|
||||
"allowed_claim_status": "blocked_until_conflict_gate",
|
||||
"raw_field_paths": [
|
||||
"solar_return",
|
||||
"annual_chart",
|
||||
"muntha",
|
||||
"year_lord",
|
||||
"tajika_yogas",
|
||||
"sahams",
|
||||
"mudda_dasha",
|
||||
"patyayini_dasha",
|
||||
],
|
||||
},
|
||||
}
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from report_pack_contract import normalize_report_pack_contract
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.report_pack_contract import normalize_report_pack_contract
|
||||
exports["unified_report_pack_contract"] = normalize_report_pack_contract(
|
||||
{**pack, "exports": exports},
|
||||
pack_id="annual_tajika_pack",
|
||||
)
|
||||
return exports
|
||||
|
||||
|
||||
def _markdown_summary(pack: dict[str, Any]) -> str:
|
||||
rows = ["| field | status |", "| --- | --- |"]
|
||||
for key in (
|
||||
"solar_return",
|
||||
"annual_chart",
|
||||
"muntha",
|
||||
"year_lord",
|
||||
"tajika_yogas",
|
||||
"sahams",
|
||||
"mudda_dasha",
|
||||
"patyayini_dasha",
|
||||
"external_engine_comparison",
|
||||
):
|
||||
field = pack[key]
|
||||
status = field.get('status')
|
||||
if key == "year_lord":
|
||||
values = field.get("values") or []
|
||||
if values:
|
||||
first = values[0].get("value") if isinstance(values[0], dict) else {}
|
||||
if isinstance(first, dict):
|
||||
status = f"{status}: {first.get('year_lord') or first.get('year_lord_sign')}"
|
||||
rows.append(f"| {key} | {status} |")
|
||||
summary = [
|
||||
"",
|
||||
"### Annual Reading Preview",
|
||||
"",
|
||||
f"- blocked fields: {', '.join((pack['report_sections']['executive_summary'].get('blocked_fields') or [])) or 'none'}",
|
||||
]
|
||||
return "\n".join(rows + summary)
|
||||
|
||||
|
||||
def _external_engine_comparison(
|
||||
pyjhora_replay: dict[str, Any] | None,
|
||||
vedastro_reference: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not pyjhora_replay and not vedastro_reference:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "external annual replay not integrated in annual_tajika_pack.v1",
|
||||
"engines": [],
|
||||
}
|
||||
comparison: dict[str, Any] = {"status": "blocked", "engines": []}
|
||||
statuses = []
|
||||
if pyjhora_replay:
|
||||
comparison["engines"].append("PyJHora/JHora")
|
||||
pyjhora = dict(pyjhora_replay)
|
||||
patyayini = pyjhora.get("patyayini_dasha")
|
||||
if isinstance(patyayini, dict):
|
||||
patyayini = dict(patyayini)
|
||||
patyayini["normalized_rows"] = _normalize_patyayini_replay_rows(patyayini)
|
||||
pyjhora["patyayini_dasha"] = patyayini
|
||||
comparison["pyjhora"] = pyjhora
|
||||
statuses.append(pyjhora_replay.get("status", "blocked"))
|
||||
year_lord_replay = pyjhora_replay.get("year_lord_replay")
|
||||
if isinstance(year_lord_replay, dict):
|
||||
raw_artifact_path = year_lord_replay.get("raw_artifact_path")
|
||||
comparison["evidence_scope"] = "pyjhora_behavior_only"
|
||||
comparison["parity_status"] = "not_multiengine_parity"
|
||||
comparison["raw_evidence_paths"] = [raw_artifact_path] if raw_artifact_path else []
|
||||
comparison["raw_evidence_status"] = (
|
||||
"archived_artifact" if raw_artifact_path else "runtime_observation_not_archived"
|
||||
)
|
||||
comparison["year_lord_replay"] = {
|
||||
key: year_lord_replay[key]
|
||||
for key in (
|
||||
"status",
|
||||
"reason",
|
||||
"callable",
|
||||
"pyjhora_version",
|
||||
"effective_ayanamsa",
|
||||
"request_hash",
|
||||
"node_mode",
|
||||
)
|
||||
if key in year_lord_replay
|
||||
}
|
||||
if vedastro_reference:
|
||||
comparison["engines"].append("VedAstro official")
|
||||
comparison["vedastro"] = _sanitize_vedastro_reference(vedastro_reference)
|
||||
statuses.append(comparison["vedastro"].get("status", "blocked"))
|
||||
comparison["status"] = "partial_verified" if any(str(s).startswith("partial_verified") for s in statuses) else "blocked"
|
||||
return comparison
|
||||
|
||||
|
||||
def _normalize_patyayini_replay_rows(field: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Expose PyJHora tuple shape without inferring a local Patyayini contract."""
|
||||
|
||||
raw = field.get("raw") if isinstance(field.get("raw"), dict) else {}
|
||||
periods = raw.get("periods") if isinstance(raw.get("periods"), list) else []
|
||||
rows = []
|
||||
for order, period in enumerate(periods, start=1):
|
||||
if not isinstance(period, (list, tuple)) or len(period) != 3:
|
||||
continue
|
||||
codes, boundary, duration = period
|
||||
if not isinstance(codes, (list, tuple)) or len(codes) != 2:
|
||||
continue
|
||||
if not isinstance(boundary, (list, tuple)) or len(boundary) != 4:
|
||||
continue
|
||||
try:
|
||||
year, month, day = (int(value) for value in boundary[:3])
|
||||
hour_decimal = float(boundary[3])
|
||||
boundary_display = (datetime(year, month, day) + timedelta(hours=hour_decimal)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
rows.append({
|
||||
"order": order,
|
||||
"main_code": codes[0],
|
||||
"sub_code": codes[1],
|
||||
"boundary_components": {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"hour_decimal": hour_decimal,
|
||||
},
|
||||
"boundary_display": boundary_display,
|
||||
"duration_raw": duration,
|
||||
"timezone_semantics": "not_returned_by_pyjhora_tuple",
|
||||
"boundary_semantics": "unresolved_external_tuple_boundary",
|
||||
"evidence_status": "pyjhora_behavior_only / not_multiengine_parity",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _sanitize_vedastro_reference(reference: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"engine",
|
||||
"status",
|
||||
"chart_core",
|
||||
"dasha_all",
|
||||
"varshaphala_status",
|
||||
"supported_annual_methods",
|
||||
"secret_redaction",
|
||||
}
|
||||
sanitized = {key: value for key, value in reference.items() if key in allowed}
|
||||
sanitized.setdefault("engine", "VedAstro official")
|
||||
sanitized.setdefault("status", "blocked")
|
||||
sanitized.setdefault("varshaphala_status", "blocked")
|
||||
sanitized.setdefault("supported_annual_methods", [])
|
||||
sanitized["secret_redaction"] = {"secret_material_included": False}
|
||||
return sanitized
|
||||
|
||||
|
||||
def _field_brief(label: str, field: dict[str, Any] | None) -> str:
|
||||
if not isinstance(field, dict):
|
||||
return f"{label}: unavailable"
|
||||
status = field.get("status", "blocked")
|
||||
if status == "conflict":
|
||||
values = field.get("values") or []
|
||||
render_values = []
|
||||
for item in values[:2]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = item.get("value")
|
||||
if isinstance(value, dict):
|
||||
render_values.append(
|
||||
", ".join(
|
||||
str(part)
|
||||
for part in (
|
||||
value.get("year_lord"),
|
||||
value.get("year_lord_sign"),
|
||||
value.get("muntha_sign"),
|
||||
value.get("muntha_lord"),
|
||||
)
|
||||
if part
|
||||
)
|
||||
)
|
||||
if render_values:
|
||||
return f"{label}: conflict between { ' vs '.join(render_values) }"
|
||||
if status == "partial_verified":
|
||||
data = field.get("data") if isinstance(field.get("data"), dict) else {}
|
||||
if label == "Muntha":
|
||||
return f"{label}: {data.get('muntha_sign') or data.get('muntha_sign_idx')} / {data.get('muntha_lord') or 'unknown'}"
|
||||
if label == "Year Lord":
|
||||
return f"{label}: {data.get('year_lord') or 'unknown'}"
|
||||
if label == "Tajika Yogas":
|
||||
authority_status = data.get("status")
|
||||
if authority_status == "authority_ready":
|
||||
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
|
||||
row_count = summary.get("row_count")
|
||||
supported = data.get("supported_named_yogas") if isinstance(data.get("supported_named_yogas"), list) else []
|
||||
supported_text = "/".join(str(item) for item in supported) if supported else "governed named yogas"
|
||||
return (
|
||||
f"Tajika Yogas: governed authority surface visible for {supported_text}"
|
||||
+ (f" ({row_count} rows)" if row_count is not None else "")
|
||||
)
|
||||
return "Tajika Yogas: annual candidate structures visible"
|
||||
if label == "Sahams":
|
||||
return "Sahams: annual sensitive points visible"
|
||||
return f"{label}: {status}"
|
||||
|
||||
|
||||
def _audit(
|
||||
profile: dict[str, Any],
|
||||
solar_report: dict[str, Any],
|
||||
tajika_report: dict[str, Any],
|
||||
pyjhora_replay: dict[str, Any] | None = None,
|
||||
vedastro_reference: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
muntha_status = _field_with_conflict_gate("muntha", solar_report, tajika_report).get("status", "blocked")
|
||||
year_lord_status = _field_with_conflict_gate("year_lord", solar_report, tajika_report).get("status", "blocked")
|
||||
audit = {
|
||||
"profile_id": profile["profile_id"],
|
||||
"technique_audit": [
|
||||
{"technique": "Calculation Profile", "status": "verified", "profile_id": profile["profile_id"]},
|
||||
{"technique": "Solar Return", "status": _producer_status(solar_report)},
|
||||
{"technique": "Annual Chart", "status": "partial_verified" if solar_report.get("sr_chart_info") else "blocked"},
|
||||
{"technique": "Muntha", "status": muntha_status},
|
||||
{"technique": "Year Lord", "status": year_lord_status},
|
||||
{"technique": "Tajika Yogas", "status": _tajika_yogas_field(solar_report).get("status", "blocked")},
|
||||
{"technique": "Sahams", "status": "partial_verified" if solar_report.get("sahams") else "blocked"},
|
||||
{"technique": "Mudda Dasha", "status": "partial_verified" if (solar_report.get("mudda_dasha") or tajika_report.get("mudda_dasha")) else "blocked"},
|
||||
{"technique": "Patyayini Dasha", "status": "blocked", "reason": "native producer not integrated"},
|
||||
{"technique": "External Engine Comparison", "status": "blocked", "reason": "pending Task 5/6"},
|
||||
],
|
||||
}
|
||||
if pyjhora_replay:
|
||||
year_lord_replay = pyjhora_replay.get("year_lord_replay")
|
||||
audit["technique_audit"].append(
|
||||
{
|
||||
"technique": "PyJHora Annual Replay",
|
||||
"status": pyjhora_replay.get("status", "blocked"),
|
||||
"license_boundary": pyjhora_replay.get("license_boundary"),
|
||||
**(
|
||||
{
|
||||
"evidence_scope": "pyjhora_behavior_only",
|
||||
"parity_status": "not_multiengine_parity",
|
||||
"raw_artifact_path": year_lord_replay.get("raw_artifact_path"),
|
||||
"raw_evidence_status": (
|
||||
"archived_artifact"
|
||||
if year_lord_replay.get("raw_artifact_path")
|
||||
else "runtime_observation_not_archived"
|
||||
),
|
||||
"year_lord_replay_status": year_lord_replay.get("status", "blocked"),
|
||||
"node_mode": year_lord_replay.get("node_mode"),
|
||||
}
|
||||
if isinstance(year_lord_replay, dict)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
if vedastro_reference:
|
||||
vedastro = _sanitize_vedastro_reference(vedastro_reference)
|
||||
audit["technique_audit"].append(
|
||||
{
|
||||
"technique": "VedAstro Annual Boundary",
|
||||
"status": vedastro.get("status", "blocked"),
|
||||
"varshaphala_status": vedastro.get("varshaphala_status", "blocked"),
|
||||
}
|
||||
)
|
||||
return audit
|
||||
|
||||
|
||||
def _producer_status(report: dict[str, Any]) -> str:
|
||||
return "blocked" if report.get("error") else "partial_verified"
|
||||
Executable
+486
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared canonical calculation-profile contract for the Jyotish runtime.
|
||||
|
||||
Single source of truth for calculation-profile identity. Both
|
||||
``scripts/domain_calculation_service.py`` (CLI/REST/MCP canonical chart) and the
|
||||
CLI wrappers in ``scripts/jyotish_engine.py`` delegate here, so one
|
||||
(birth input, effective settings, observed ephemeris) tuple yields one profile.
|
||||
|
||||
Contract rules
|
||||
--------------
|
||||
1. Deterministic: identical normalized input gives identical ``input_hash`` /
|
||||
``profile_id`` / ``profile_hash`` across runs, processes and machines.
|
||||
2. Privacy (AGENTS 6.6): the profile never stores birth data. Birth
|
||||
date/time/place/coordinates fold into the irreversible ``input_hash``; only
|
||||
calculation settings, ``timezone``, ``engine`` and ``algorithm`` remain.
|
||||
3. One hash algorithm: canonical JSON ``ensure_ascii=True, sort_keys=True,
|
||||
separators=(",",":"), default=str`` — identical to
|
||||
``domain_calculation_service._canonical_hash``.
|
||||
4. Observed-only ephemeris: ``engine`` carries the five observed fields
|
||||
(source/flags_verified/flags/provider/policy); ``ephemeris_path`` is never
|
||||
part of the profile or any hash, so profiles are stable across machine
|
||||
paths. No provider is hard-coded (ERR-042/ERR-121).
|
||||
5. ``profile_id == profile_hash`` (self hash); ``input_hash`` covers the
|
||||
normalized input contract only (no engine observation).
|
||||
6. Structured errors: unparseable input raises ``CalculationProfileError`` (a
|
||||
``ValueError``); IANA timezone names are retained as-is.
|
||||
|
||||
Stdlib-only so it imports identically under direct-script execution and as
|
||||
``scripts.calculation_profile_contract``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
SCHEMA = "jyotish.calculation_profile.v1"
|
||||
PROFILE_VERSION = "1.0"
|
||||
DEFAULT_ALGORITHM = "sidereal_natal_chart"
|
||||
DEFAULTS = {
|
||||
"ayanamsa": "lahiri",
|
||||
"node_mode": "mean",
|
||||
"position_mode": "legacy",
|
||||
"house_system": "whole_sign",
|
||||
"dasha_year_days": 365.25,
|
||||
"solar_return_location_mode": "birth_place",
|
||||
"annual_year_policy": "solar_return_exact",
|
||||
"coordinate_precision": "coordinates",
|
||||
}
|
||||
# single truth source: every setting the profile exposes
|
||||
SETTINGS_KEYS = (
|
||||
"ayanamsa",
|
||||
"node_mode",
|
||||
"position_mode",
|
||||
"house_system",
|
||||
"dasha_year_days",
|
||||
"solar_return_location_mode",
|
||||
"annual_year_policy",
|
||||
)
|
||||
EPHEMERIS_FIELDS = (
|
||||
"ephemeris_source",
|
||||
"ephemeris_flags_verified",
|
||||
"ephemeris_flags",
|
||||
"ephemeris_provider",
|
||||
"ephemeris_policy",
|
||||
)
|
||||
POSITION_MODES = ("legacy", "mean", "apparent")
|
||||
NODE_MODES = ("mean", "true")
|
||||
|
||||
|
||||
class CalculationProfileError(ValueError):
|
||||
"""Raised for inputs that cannot be normalized without data loss."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# canonical hashing (unified with domain_calculation_service)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _canonical_hash(value: Any) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _as_float(value: Any, label: str) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise CalculationProfileError(f"{label} must be numeric, got {value!r}") from None
|
||||
if not math.isfinite(result):
|
||||
raise CalculationProfileError(f"{label} must be finite, got {value!r}")
|
||||
return result
|
||||
|
||||
|
||||
def _as_int(value: Any, label: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise CalculationProfileError(f"{label} must be an integer, got {value!r}")
|
||||
try:
|
||||
result = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise CalculationProfileError(f"{label} must be an integer, got {value!r}") from None
|
||||
if isinstance(value, float) and not value.is_integer():
|
||||
raise CalculationProfileError(f"{label} must be an integer, got {value!r}")
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# input normalization (root and nested birth containers)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _root_or_nested(payload: dict[str, Any], key: str) -> Any:
|
||||
"""Read a key from the payload root, then ``birth``, then ``location``."""
|
||||
for container in (payload, payload.get("birth"), payload.get("location")):
|
||||
if isinstance(container, dict) and container.get(key) is not None:
|
||||
return container[key]
|
||||
return None
|
||||
|
||||
|
||||
def _first_not_none(*values: Any) -> Any:
|
||||
return next((value for value in values if value is not None), None)
|
||||
|
||||
|
||||
def _normalize_date(payload: dict[str, Any]) -> str | None:
|
||||
value = _root_or_nested(payload, "date")
|
||||
if value is not None:
|
||||
parts = [part for part in str(value).strip().split("-") if part != ""]
|
||||
if len(parts) != 3:
|
||||
raise CalculationProfileError(f"invalid date {value!r}, expected YYYY-MM-DD")
|
||||
year, month, day = (_as_int(part, f"date part {part!r}") for part in parts)
|
||||
if not (1 <= year <= 9999 and 1 <= month <= 12 and 1 <= day <= 31):
|
||||
raise CalculationProfileError(f"invalid date {value!r}, parts out of range")
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
year = _root_or_nested(payload, "year")
|
||||
month = _root_or_nested(payload, "month")
|
||||
day = _root_or_nested(payload, "day")
|
||||
if year is None and month is None and day is None:
|
||||
return None
|
||||
if year is None or month is None or day is None:
|
||||
raise CalculationProfileError("incomplete birth date: year/month/day must be given together")
|
||||
return _normalize_date({"date": f"{_as_int(year, 'year')}-{_as_int(month, 'month')}-{_as_int(day, 'day')}"})
|
||||
|
||||
|
||||
def _normalize_time(payload: dict[str, Any]) -> list[float] | None:
|
||||
value = _root_or_nested(payload, "time")
|
||||
if value is not None:
|
||||
parts = [part for part in str(value).strip().split(":") if part != ""]
|
||||
if len(parts) not in (2, 3):
|
||||
raise CalculationProfileError(f"invalid time {value!r}, expected HH:MM[:SS]")
|
||||
hour = _as_int(parts[0], f"time hour {parts[0]!r}")
|
||||
minute = _as_int(parts[1], f"time minute {parts[1]!r}")
|
||||
second = _as_float(parts[2], f"time second {parts[2]!r}") if len(parts) == 3 else 0.0
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second < 60):
|
||||
raise CalculationProfileError(f"invalid time {value!r}, parts out of range")
|
||||
return [float(hour), float(minute), float(second)]
|
||||
hour = _root_or_nested(payload, "hour")
|
||||
minute = _root_or_nested(payload, "minute")
|
||||
second = _root_or_nested(payload, "second")
|
||||
if hour is None and minute is None and second is None:
|
||||
return None
|
||||
if hour is None or minute is None:
|
||||
raise CalculationProfileError("incomplete birth time: hour/minute must be given together")
|
||||
return _normalize_time({"time": f"{_as_int(hour, 'hour')}:{_as_int(minute, 'minute')}:{_as_float(second, 'second') if second is not None else 0.0}"})
|
||||
|
||||
|
||||
def _format_utc_offset(value: Any) -> str:
|
||||
"""Normalize a numeric UTC offset to ``+HH:MM`` (or ``-HH:MM``)."""
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise CalculationProfileError("empty UTC offset")
|
||||
if ":" in text:
|
||||
sign = -1 if text.startswith("-") else 1
|
||||
parts = text.lstrip("+-").split(":")
|
||||
hours = _as_float(parts[0], f"offset hour {parts[0]!r}")
|
||||
minutes = _as_float(parts[1], f"offset minute {parts[1]!r}") if len(parts) > 1 else 0.0
|
||||
total = sign * (hours + minutes / 60.0)
|
||||
else:
|
||||
total = _as_float(text, f"UTC offset {text!r}")
|
||||
elif isinstance(value, bool):
|
||||
raise CalculationProfileError(f"invalid UTC offset {value!r}")
|
||||
else:
|
||||
total = _as_float(value, "UTC offset")
|
||||
if total is None or not -14 <= total <= 14:
|
||||
raise CalculationProfileError(f"invalid UTC offset {value!r}, must be within -14..+14")
|
||||
total_minutes = int(round(total * 60.0))
|
||||
sign = "+" if total_minutes >= 0 else "-"
|
||||
total_minutes = abs(total_minutes)
|
||||
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
|
||||
|
||||
|
||||
def _normalize_timezone(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
"""Return ``(utc_offset, timezone_name)``.
|
||||
|
||||
A numeric ``tz``/``utc_offset`` becomes canonical ``+HH:MM``. A non-numeric
|
||||
``tz`` that looks like an IANA zone (contains ``/``) is retained as the
|
||||
timezone name; an explicit ``timezone`` field wins as the name.
|
||||
"""
|
||||
raw_offset = _first_not_none(_root_or_nested(payload, "tz"), _root_or_nested(payload, "utc_offset"))
|
||||
name = None
|
||||
for key in ("timezone", "timezone_name", "tz_name"):
|
||||
candidate = _root_or_nested(payload, key)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
name = candidate.strip()
|
||||
break
|
||||
if raw_offset is None or raw_offset == "":
|
||||
return None, name
|
||||
try:
|
||||
return _format_utc_offset(raw_offset), name
|
||||
except CalculationProfileError:
|
||||
if isinstance(raw_offset, str) and "/" in raw_offset:
|
||||
return None, raw_offset.strip()
|
||||
raise
|
||||
|
||||
|
||||
def _normalize_birth(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
offset, tz_name = _normalize_timezone(payload)
|
||||
return {
|
||||
"date": _normalize_date(payload),
|
||||
"time": _normalize_time(payload),
|
||||
"utc_offset": offset,
|
||||
"timezone_name": tz_name,
|
||||
"latitude": _as_float(
|
||||
_first_not_none(_root_or_nested(payload, "latitude"), _root_or_nested(payload, "lat")),
|
||||
"latitude",
|
||||
),
|
||||
"longitude": _as_float(
|
||||
_first_not_none(_root_or_nested(payload, "longitude"), _root_or_nested(payload, "lon")),
|
||||
"longitude",
|
||||
),
|
||||
"place": _root_or_nested(payload, "place"),
|
||||
"coordinate_precision": _root_or_nested(payload, "coordinate_precision")
|
||||
or DEFAULTS["coordinate_precision"],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
settings_block = payload.get("settings")
|
||||
settings_block = settings_block if isinstance(settings_block, dict) else {}
|
||||
|
||||
def _camel(key: str) -> str:
|
||||
head, _, tail = key.partition("_")
|
||||
return head + tail[:1].upper() + tail[1:]
|
||||
|
||||
def _setting(key: str) -> Any:
|
||||
if key in settings_block and settings_block[key] is not None:
|
||||
return settings_block[key]
|
||||
return _first_not_none(_root_or_nested(payload, key), _root_or_nested(payload, _camel(key)))
|
||||
|
||||
ayanamsa = str(_setting("ayanamsa") or DEFAULTS["ayanamsa"]).strip().lower()
|
||||
if not ayanamsa:
|
||||
raise CalculationProfileError("ayanamsa must be a non-empty string")
|
||||
node_mode = str(_setting("node_mode") or DEFAULTS["node_mode"]).strip().lower()
|
||||
if node_mode not in NODE_MODES:
|
||||
raise CalculationProfileError(f"node_mode must be one of {NODE_MODES}, got {node_mode!r}")
|
||||
position_mode = str(_setting("position_mode") or DEFAULTS["position_mode"]).strip().lower()
|
||||
if position_mode not in POSITION_MODES:
|
||||
raise CalculationProfileError(f"position_mode must be one of {POSITION_MODES}, got {position_mode!r}")
|
||||
house_system = str(_setting("house_system") or DEFAULTS["house_system"]).strip()
|
||||
if not house_system:
|
||||
raise CalculationProfileError("house_system must be a non-empty string")
|
||||
dasha_year_days_value = _setting("dasha_year_days")
|
||||
if dasha_year_days_value is None:
|
||||
dasha_year_days_value = DEFAULTS["dasha_year_days"]
|
||||
dasha_year_days = _as_float(dasha_year_days_value, "dasha_year_days")
|
||||
if dasha_year_days <= 0:
|
||||
raise CalculationProfileError(f"dasha_year_days must be positive, got {dasha_year_days!r}")
|
||||
solar_return_location_mode = str(
|
||||
_setting("solar_return_location_mode") or DEFAULTS["solar_return_location_mode"]
|
||||
).strip() or DEFAULTS["solar_return_location_mode"]
|
||||
annual_year_policy = str(
|
||||
_setting("annual_year_policy") or DEFAULTS["annual_year_policy"]
|
||||
).strip() or DEFAULTS["annual_year_policy"]
|
||||
return {
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
"position_mode": position_mode,
|
||||
"house_system": house_system,
|
||||
"dasha_year_days": dasha_year_days,
|
||||
"solar_return_location_mode": solar_return_location_mode,
|
||||
"annual_year_policy": annual_year_policy,
|
||||
}
|
||||
|
||||
|
||||
def _effective_ephemeris(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Observed ephemeris provenance only; never fabricate a provider.
|
||||
|
||||
``ephemeris_path`` is excluded: absolute machine paths must not
|
||||
participate in profile identity or hashing.
|
||||
"""
|
||||
observed: dict[str, Any] = {}
|
||||
for container in (payload.get("engine"), payload.get("ephemeris"), payload.get("meta")):
|
||||
if not isinstance(container, dict):
|
||||
continue
|
||||
for field in EPHEMERIS_FIELDS:
|
||||
if field in container and container[field] is not None:
|
||||
observed[field] = container[field]
|
||||
if observed:
|
||||
observed.setdefault("ephemeris_policy", "observed_provider_recorded")
|
||||
return observed
|
||||
return {
|
||||
"ephemeris_provider": "not_observed",
|
||||
"ephemeris_source": None,
|
||||
"ephemeris_flags": None,
|
||||
"ephemeris_flags_verified": False,
|
||||
"ephemeris_policy": "no_hardcoded_provider_observed_provider_required",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# public builders
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_calculation_profile(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the canonical profile for any payload (dict or Namespace-shaped).
|
||||
|
||||
Birth fields may be nested under ``birth``/``location`` or at the root
|
||||
(``year``/``month``/``day``/``hour``/``minute``/``second``/``lat``/``lon``/
|
||||
``tz``). They are only hashed into ``input_hash``; the returned profile
|
||||
carries no birth data. Settings are computed once (single truth) and
|
||||
mirrored flat and under ``effective_settings``.
|
||||
"""
|
||||
birth = _normalize_birth(payload)
|
||||
settings = _normalize_settings(payload)
|
||||
offset = birth["utc_offset"]
|
||||
timezone = {
|
||||
"name": birth["timezone_name"] or (f"UTC{offset}" if offset is not None else None),
|
||||
"utc_offset": offset,
|
||||
}
|
||||
algorithm = str(payload.get("algorithm") or DEFAULT_ALGORITHM).strip() or DEFAULT_ALGORITHM
|
||||
|
||||
input_hash = _canonical_hash({"birth": birth, "settings": settings})
|
||||
|
||||
profile: dict[str, Any] = {
|
||||
"schema": SCHEMA,
|
||||
"profile_version": PROFILE_VERSION,
|
||||
"algorithm": algorithm,
|
||||
"effective_settings": {**settings, "timezone_offset": offset},
|
||||
"timezone": timezone,
|
||||
"engine": _effective_ephemeris(payload),
|
||||
"coordinate_precision": birth["coordinate_precision"],
|
||||
**{key: settings[key] for key in SETTINGS_KEYS},
|
||||
"input_hash": input_hash,
|
||||
"profile_id": None,
|
||||
"profile_hash": None,
|
||||
}
|
||||
profile_hash = _canonical_hash(profile)
|
||||
profile["profile_id"] = profile_hash
|
||||
profile["profile_hash"] = profile_hash
|
||||
return profile
|
||||
|
||||
|
||||
def _payload_from_args(args: Any) -> dict[str, Any]:
|
||||
"""Convert an argparse Namespace (or a plain dict) into a root-style payload."""
|
||||
if isinstance(args, dict):
|
||||
return dict(args)
|
||||
if not all(hasattr(args, name) for name in ("year", "month", "day", "hour", "minute")):
|
||||
raise TypeError(
|
||||
"attach_calculation_profile expects an argparse Namespace or dict, "
|
||||
f"got {type(args).__name__}"
|
||||
)
|
||||
|
||||
def _attr(name: str) -> Any:
|
||||
return getattr(args, name, None)
|
||||
|
||||
return {
|
||||
"year": _attr("year"),
|
||||
"month": _attr("month"),
|
||||
"day": _attr("day"),
|
||||
"hour": _attr("hour"),
|
||||
"minute": _attr("minute"),
|
||||
"second": _attr("second"),
|
||||
"lat": _attr("lat"),
|
||||
"lon": _attr("lon"),
|
||||
"tz": _attr("tz"),
|
||||
"place": _attr("place") or _attr("location_name"),
|
||||
"timezone": _attr("timezone") or _attr("timezone_name"),
|
||||
"settings": {
|
||||
key: _first_not_none(_attr(key), DEFAULTS[key]) for key in SETTINGS_KEYS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _observed_ephemeris_from_result(result: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Lift observed ephemeris provenance from a computed result.
|
||||
|
||||
Only the five contract fields are carried; ``ephemeris_path`` (an absolute
|
||||
machine path) is deliberately excluded from profile identity.
|
||||
"""
|
||||
meta = result.get("meta")
|
||||
if isinstance(meta, dict) and meta.get("ephemeris_provider"):
|
||||
return {key: value for key, value in meta.items() if key in EPHEMERIS_FIELDS}
|
||||
contract = result.get("calculation_contract")
|
||||
if isinstance(contract, dict):
|
||||
effective = contract.get("effective")
|
||||
if isinstance(effective, dict) and effective.get("ephemeris_provider"):
|
||||
return {key: value for key, value in effective.items() if key in EPHEMERIS_FIELDS}
|
||||
return None
|
||||
|
||||
|
||||
def attach_calculation_profile(result: dict[str, Any], args: Any) -> dict[str, Any]:
|
||||
"""Attach the canonical profile to a result in place.
|
||||
|
||||
Profile metadata and deterministic result-binding metadata are written;
|
||||
every pre-existing business key keeps its value. A canonical profile already
|
||||
present (e.g. from ``domain_calculation_service.compute_chart``) is kept
|
||||
unchanged; otherwise the profile is built from ``args`` plus any observed
|
||||
ephemeris provenance in ``result``.
|
||||
"""
|
||||
existing = result.get("calculation_profile") if isinstance(result, dict) else None
|
||||
if (
|
||||
isinstance(existing, dict)
|
||||
and isinstance(existing.get("profile_id"), str)
|
||||
and isinstance(existing.get("profile_hash"), str)
|
||||
):
|
||||
result["calculation_profile_id"] = existing["profile_id"]
|
||||
return result
|
||||
payload = _payload_from_args(args)
|
||||
observed = _observed_ephemeris_from_result(result)
|
||||
if observed:
|
||||
payload["engine"] = observed
|
||||
profile = build_calculation_profile(payload)
|
||||
result["calculation_profile"] = profile
|
||||
result["calculation_profile_id"] = profile["profile_id"]
|
||||
return bind_result_to_profile(result, profile)
|
||||
|
||||
|
||||
def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Bind one concrete calculation result to its normalized input profile."""
|
||||
if not isinstance(result, dict) or not isinstance(profile, dict):
|
||||
raise TypeError("result and calculation profile must be dictionaries")
|
||||
input_hash = profile.get("input_hash")
|
||||
if not isinstance(input_hash, str) or len(input_hash) != 64:
|
||||
raise ValueError("calculation profile is missing a valid input_hash")
|
||||
result_payload = {
|
||||
key: value
|
||||
for key, value in result.items()
|
||||
if key not in {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"}
|
||||
}
|
||||
encoded = json.dumps(
|
||||
canonicalize_result_payload({"input_hash": input_hash, "result": result_payload}),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
result_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
result["result_hash"] = result_hash
|
||||
result["result_binding"] = {"input_hash": input_hash, "result_hash": result_hash}
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_result_payload(value: Any) -> Any:
|
||||
"""Convert producer mapping keys to deterministic JSON keys before hashing."""
|
||||
if isinstance(value, dict):
|
||||
normalized: dict[str, Any] = {}
|
||||
for key, child in value.items():
|
||||
normalized_key = key if isinstance(key, str) else f"__{type(key).__name__}__:{key}"
|
||||
normalized[normalized_key] = canonicalize_result_payload(child)
|
||||
return normalized
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [canonicalize_result_payload(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sample = {
|
||||
"birth": {
|
||||
"date": "1990-01-01",
|
||||
"time": "12:00:00",
|
||||
"utc_offset": "+08:00",
|
||||
"latitude": 39.9,
|
||||
"longitude": 116.4,
|
||||
},
|
||||
"settings": {"ayanamsa": "lahiri", "node_mode": "mean"},
|
||||
}
|
||||
print(json.dumps(build_calculation_profile(sample), ensure_ascii=False, indent=2, default=str))
|
||||
sys.exit(0)
|
||||
Executable
+507
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only quality checks for PL9-grade full personal report packets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA_VERSION = "jyotish.full_report_quality_gate.v1"
|
||||
REPORT_SCHEMA = "pl9_style_professional_export_v1"
|
||||
REQUIRED_PACK_SECTIONS = (
|
||||
"base",
|
||||
"strength",
|
||||
"dasha",
|
||||
"annual",
|
||||
"transit",
|
||||
"d1_d60_ledger",
|
||||
"professional_support",
|
||||
"audit_appendix",
|
||||
)
|
||||
RESTRICTED_MATERIAL_IDS = (
|
||||
"jaimini_special_points",
|
||||
"tajika_named_yoga",
|
||||
"annual_sahams",
|
||||
"kranti",
|
||||
"alternate_ashtottari",
|
||||
)
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _present(value: Any) -> bool:
|
||||
return bool(value) if isinstance(value, (dict, list, tuple, set, str)) else value is not None
|
||||
|
||||
|
||||
def _status(value: Any) -> str:
|
||||
item = _as_dict(value)
|
||||
return str(item.get("status") or item.get("execution_status") or "available")
|
||||
|
||||
|
||||
def _marker_present(markdown: str | None, markers: tuple[str, ...]) -> bool | None:
|
||||
if markdown is None:
|
||||
return None
|
||||
return any(marker in markdown for marker in markers)
|
||||
|
||||
|
||||
def _check(name: str, status: str, detail: str) -> dict[str, str]:
|
||||
return {"name": name, "status": status, "detail": detail}
|
||||
|
||||
|
||||
def _coverage_entry(
|
||||
material_id: str,
|
||||
tier: str,
|
||||
source_reference: str,
|
||||
upstream_value: Any,
|
||||
markdown: str | None,
|
||||
markers: tuple[str, ...],
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
upstream_present = _present(upstream_value)
|
||||
rendered = _marker_present(markdown, markers)
|
||||
status = _status(upstream_value) if upstream_present else "not_available"
|
||||
entry = {
|
||||
"material_id": material_id,
|
||||
"admission_tier": tier,
|
||||
"report_role": "professional_support",
|
||||
"source_reference": source_reference,
|
||||
"upstream_present": upstream_present,
|
||||
"status": status,
|
||||
"source_status": status,
|
||||
"surface_location": (
|
||||
"professional_support_cross_reference"
|
||||
if material_id in {
|
||||
"special_lagnas",
|
||||
"patyayini_annual_support",
|
||||
"narayana_alignment",
|
||||
}
|
||||
else "thematic_or_operator_appendix"
|
||||
),
|
||||
"rendered": rendered,
|
||||
"limitation_reference": None if status not in {"blocked", "partial", "parameter_sensitive", "conflict"} else status,
|
||||
}
|
||||
if upstream_present and rendered is False:
|
||||
return entry, f"required_support_not_rendered:{material_id}"
|
||||
return entry, None
|
||||
|
||||
|
||||
def _patyayini_normalized_rows_present(packet: dict[str, Any]) -> bool | None:
|
||||
annual_section = _as_dict(_as_dict(_as_dict(packet.get("full_report_pack")).get("sections")).get("annual"))
|
||||
annual_data = _as_dict(packet.get("worksheets"))
|
||||
timing = _as_dict(annual_data.get("timing_and_predictive_systems"))
|
||||
annual_pack = _as_dict(timing.get("annual_tajika_pack"))
|
||||
candidates = (
|
||||
_as_dict(_as_dict(annual_pack.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"),
|
||||
_as_dict(_as_dict(annual_section.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"),
|
||||
)
|
||||
saw_patyayini = False
|
||||
for candidate in candidates:
|
||||
if candidate:
|
||||
saw_patyayini = True
|
||||
rows = candidate.get("normalized_rows")
|
||||
if isinstance(rows, list) and rows:
|
||||
return True
|
||||
if saw_patyayini:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _promotion_status(blocking_reasons: list[str], review_reasons: list[str], warning_reasons: list[str]) -> str:
|
||||
if blocking_reasons:
|
||||
return "blocked"
|
||||
if review_reasons:
|
||||
return "review_required"
|
||||
if warning_reasons:
|
||||
return "passed_with_limitations"
|
||||
return "passed"
|
||||
|
||||
|
||||
def evaluate_full_report(packet: dict[str, Any], rendered_markdown: str | None = None) -> dict[str, Any]:
|
||||
"""Evaluate an assembled report without mutating calculation or report content."""
|
||||
packet = _as_dict(packet)
|
||||
checks: list[dict[str, str]] = []
|
||||
blocking_reasons: list[str] = []
|
||||
review_reasons: list[str] = []
|
||||
warning_reasons: list[str] = []
|
||||
|
||||
source_schema = str(packet.get("schema") or "")
|
||||
if source_schema != REPORT_SCHEMA:
|
||||
blocking_reasons.append("report_schema_invalid")
|
||||
checks.append(_check("report_schema", "blocked", f"expected {REPORT_SCHEMA}, got {source_schema or 'missing'}"))
|
||||
else:
|
||||
checks.append(_check("report_schema", "passed", source_schema))
|
||||
|
||||
profile = _as_dict(packet.get("calculation_profile"))
|
||||
for key, value in (
|
||||
("calculation_profile_id", packet.get("calculation_profile_id") or profile.get("profile_id")),
|
||||
("result_hash", packet.get("result_hash")),
|
||||
("ayanamsa", profile.get("ayanamsa") or _as_dict(profile.get("effective_settings")).get("ayanamsa")),
|
||||
("node_mode", profile.get("node_mode") or _as_dict(profile.get("effective_settings")).get("node_mode")),
|
||||
):
|
||||
if value in (None, ""):
|
||||
blocking_reasons.append(f"provenance_missing:{key}")
|
||||
checks.append(_check(f"provenance:{key}", "blocked", "missing"))
|
||||
else:
|
||||
checks.append(_check(f"provenance:{key}", "passed", "present"))
|
||||
|
||||
for key, value in (
|
||||
("input_hash", profile.get("input_hash")),
|
||||
("calculation_source", profile.get("algorithm")),
|
||||
("profile_version", profile.get("profile_version")),
|
||||
("engine_metadata", profile.get("engine")),
|
||||
):
|
||||
if not _present(value):
|
||||
blocking_reasons.append(f"provenance_missing:{key}")
|
||||
checks.append(_check(f"provenance:{key}", "blocked", "missing"))
|
||||
else:
|
||||
checks.append(_check(f"provenance:{key}", "passed", "present"))
|
||||
|
||||
binding = _as_dict(packet.get("result_binding"))
|
||||
expected_input_hash = profile.get("input_hash")
|
||||
expected_result_hash = packet.get("result_hash")
|
||||
if binding.get("input_hash") != expected_input_hash or binding.get("result_hash") != expected_result_hash:
|
||||
blocking_reasons.append("provenance_lineage_binding_invalid")
|
||||
checks.append(_check("provenance:result_binding", "blocked", "does not match profile/result hashes"))
|
||||
else:
|
||||
checks.append(_check("provenance:result_binding", "passed", "input/result hashes match"))
|
||||
|
||||
chart_identity = _as_dict(packet.get("chart_identity"))
|
||||
for key in (
|
||||
"chart_profile_id",
|
||||
"birth_data_status",
|
||||
"rectification_status",
|
||||
"approval_status",
|
||||
):
|
||||
value = chart_identity.get(key)
|
||||
if value in (None, ""):
|
||||
blocking_reasons.append(f"chart_identity_missing:{key}")
|
||||
checks.append(_check(f"chart_identity:{key}", "blocked", "missing"))
|
||||
else:
|
||||
checks.append(_check(f"chart_identity:{key}", "passed", str(value)))
|
||||
|
||||
full_report_pack = _as_dict(packet.get("full_report_pack"))
|
||||
sections = _as_dict(full_report_pack.get("sections"))
|
||||
if not full_report_pack:
|
||||
blocking_reasons.append("full_report_pack_missing")
|
||||
checks.append(_check("full_report_pack", "blocked", "missing"))
|
||||
else:
|
||||
checks.append(_check("full_report_pack", "passed", str(full_report_pack.get("schema") or "present")))
|
||||
for key in REQUIRED_PACK_SECTIONS:
|
||||
section = _as_dict(sections.get(key))
|
||||
if not section:
|
||||
blocking_reasons.append(f"required_pack_section_missing:{key}")
|
||||
checks.append(_check(f"pack_section:{key}", "blocked", "missing"))
|
||||
else:
|
||||
section_status = _status(section)
|
||||
checks.append(_check(f"pack_section:{key}", "passed" if section_status in {"verified", "available"} else "warning", section_status))
|
||||
if section_status in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}:
|
||||
warning_reasons.append(f"pack_section_limited:{key}:{section_status}")
|
||||
|
||||
d1_d60_ledger_section = _as_dict(sections.get("d1_d60_ledger"))
|
||||
d1_d60_ledger = _as_dict(d1_d60_ledger_section.get("d1_to_d60"))
|
||||
d1_d60_summary = _as_dict(d1_d60_ledger_section.get("summary"))
|
||||
if d1_d60_ledger_section:
|
||||
if len(d1_d60_ledger) != 60:
|
||||
blocking_reasons.append("d1_d60_ledger_incomplete")
|
||||
checks.append(_check("d1_d60_ledger:row_count", "blocked", f"expected 60, got {len(d1_d60_ledger)}"))
|
||||
else:
|
||||
checks.append(_check("d1_d60_ledger:row_count", "passed", "60 rows"))
|
||||
expected_summary = {
|
||||
"formal_traditional_division_count": 20,
|
||||
"research_generic_dn_division_count": 40,
|
||||
}
|
||||
if {key: d1_d60_summary.get(key) for key in expected_summary} != expected_summary:
|
||||
review_reasons.append("d1_d60_ledger_classification_invalid")
|
||||
checks.append(_check("d1_d60_ledger:classification", "review_required", str(d1_d60_summary)))
|
||||
else:
|
||||
checks.append(_check("d1_d60_ledger:classification", "passed", "20 formal + 40 research"))
|
||||
|
||||
audit_appendix = _as_dict(sections.get("audit_appendix"))
|
||||
if not audit_appendix:
|
||||
blocking_reasons.append("audit_appendix_missing")
|
||||
elif _status(audit_appendix) in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}:
|
||||
warning_reasons.append(f"audit_appendix_limited:{_status(audit_appendix)}")
|
||||
|
||||
markdown_checks = {
|
||||
"chart_identity": ("基础资料表", "Birth Particulars"),
|
||||
"d1": ("D1 — Rashi Chart", "D1 / Base Chart"),
|
||||
"varga": ("Vargas I", "Varga Analysis"),
|
||||
"d1_d60_ledger": ("D1–D60 完整原始分盘账本", "D1-D60 Complete Raw Divisional Ledger"),
|
||||
"timing": ("大运与时间主线", "Dasha Analysis"),
|
||||
"kp": ("KP 三年流月支持", "KP and Transit Timing Layer"),
|
||||
"annual": ("年度重点", "Annual / Tajika / Yearly Focus"),
|
||||
"professional_support": ("专业支持专题:第二证据轴",),
|
||||
"restricted_materials": ("未闭环专业层:可见但不入判断",),
|
||||
"limitations": ("Blocked / Audit Appendix", "Conflict and Limitation"),
|
||||
"audit_appendix": ("Audit Appendix", "Operator Appendix"),
|
||||
}
|
||||
if rendered_markdown is None:
|
||||
warning_reasons.append("rendered_markdown_not_supplied")
|
||||
checks.append(_check("reader_surface", "not_evaluated", "rendered Markdown was not supplied"))
|
||||
else:
|
||||
for name, markers in markdown_checks.items():
|
||||
if _marker_present(rendered_markdown, markers):
|
||||
checks.append(_check(f"reader_section:{name}", "passed", "rendered"))
|
||||
else:
|
||||
review_reasons.append(f"reader_section_missing:{name}")
|
||||
checks.append(_check(f"reader_section:{name}", "review_required", "not rendered"))
|
||||
|
||||
worksheets = _as_dict(packet.get("worksheets"))
|
||||
divisional = _as_dict(worksheets.get("divisional_and_special_charts"))
|
||||
strengths = _as_dict(worksheets.get("strengths_and_scores"))
|
||||
timing = _as_dict(worksheets.get("timing_and_predictive_systems"))
|
||||
annual = _as_dict(timing.get("annual_tajika_pack"))
|
||||
dasha_master = _as_dict(timing.get("dasha_master_pack"))
|
||||
dasha_families = _as_dict(dasha_master.get("families"))
|
||||
narayana = timing.get("narayana_dasha") or dasha_families.get("narayana")
|
||||
patyayini = annual.get("patyayini_dasha") or annual.get("patyayini")
|
||||
auxiliary_dasha_support = {
|
||||
key: value
|
||||
for key, value in dasha_families.items()
|
||||
if key in {"yogini", "ashtottari", "kala_chakra"} and _present(value)
|
||||
}
|
||||
advanced = _as_dict(worksheets.get("advanced_systems"))
|
||||
kp_monthly_report = _as_dict(advanced.get("kp_monthly_report"))
|
||||
annual_audit = _as_dict(annual.get("audit"))
|
||||
annual_sections = _as_dict(annual.get("report_sections"))
|
||||
if not annual_audit or not annual_sections:
|
||||
review_reasons.append("annual_evidence_status_missing")
|
||||
checks.append(_check("annual_evidence_status", "review_required", "annual audit or report sections missing"))
|
||||
else:
|
||||
checks.append(_check("annual_evidence_status", "passed", "audit and report sections present"))
|
||||
if rendered_markdown is not None and "年度专题证据状态" not in rendered_markdown:
|
||||
review_reasons.append("annual_evidence_status_not_rendered")
|
||||
checks.append(_check("annual_evidence_status:rendered", "review_required", "not rendered"))
|
||||
|
||||
if not kp_monthly_report:
|
||||
review_reasons.append("kp_evidence_status_missing")
|
||||
checks.append(_check("kp_evidence_status", "review_required", "monthly report missing"))
|
||||
else:
|
||||
kp_maturity = _as_dict(kp_monthly_report.get("maturity_profile"))
|
||||
kp_restrictions = kp_monthly_report.get("must_not_claim")
|
||||
if not kp_maturity.get("claim_status") or not isinstance(kp_restrictions, list) or not kp_restrictions:
|
||||
review_reasons.append("kp_evidence_status_missing")
|
||||
checks.append(_check("kp_evidence_status", "review_required", "maturity profile or must_not_claim missing"))
|
||||
else:
|
||||
checks.append(_check("kp_evidence_status", "passed", str(kp_maturity.get("claim_status"))))
|
||||
if rendered_markdown is not None and "KP 使用边界" not in rendered_markdown:
|
||||
review_reasons.append("kp_evidence_status_not_rendered")
|
||||
checks.append(_check("kp_evidence_status:rendered", "review_required", "not rendered"))
|
||||
|
||||
if rendered_markdown is None:
|
||||
checks.append(_check("three_year_kp_monthly", "not_evaluated", "rendered Markdown was not supplied"))
|
||||
else:
|
||||
rendered_years = sorted(
|
||||
{int(year) for year in re.findall(r"^###\s+(\d{4})\s+KP 月度支持\s*$", rendered_markdown, re.MULTILINE)}
|
||||
)
|
||||
consecutive = len(rendered_years) >= 3 and all(
|
||||
year == rendered_years[0] + offset for offset, year in enumerate(rendered_years)
|
||||
)
|
||||
if not consecutive:
|
||||
review_reasons.append("three_year_kp_monthly_incomplete")
|
||||
checks.append(_check("three_year_kp_monthly", "review_required", str(rendered_years)))
|
||||
else:
|
||||
checks.append(_check("three_year_kp_monthly", "passed", f"{rendered_years[0]}-{rendered_years[-1]}"))
|
||||
source_years = sorted(
|
||||
{
|
||||
int(row["year"])
|
||||
for row in kp_monthly_report.get("yearly_highlights", [])
|
||||
if isinstance(row, dict) and isinstance(row.get("year"), int)
|
||||
}
|
||||
)
|
||||
if source_years and source_years != rendered_years:
|
||||
review_reasons.append("three_year_kp_monthly_source_render_mismatch")
|
||||
checks.append(_check("three_year_kp_monthly:source_alignment", "review_required", f"source={source_years}; rendered={rendered_years}"))
|
||||
elif source_years:
|
||||
checks.append(_check("three_year_kp_monthly:source_alignment", "passed", str(source_years)))
|
||||
|
||||
natal_special_factor_support = {
|
||||
"sahams": _as_dict(advanced.get("sahams")),
|
||||
"avasthas": _as_dict(advanced.get("avasthas")),
|
||||
"upagrahas": _as_dict(divisional.get("upagrahas")),
|
||||
}
|
||||
natal_special_factor_support = {
|
||||
key: value for key, value in natal_special_factor_support.items() if _present(value)
|
||||
}
|
||||
|
||||
special_lagnas = _as_dict(divisional.get("special_lagnas"))
|
||||
required_special_lagnas = {
|
||||
"Bhava_Lagna": "Bhava Lagna",
|
||||
"Hora_Lagna": "Hora Lagna",
|
||||
"Ghati_Lagna": "Ghati Lagna",
|
||||
"ViGhati_Lagna": "ViGhati Lagna",
|
||||
"Sree_Lagna": "Sree Lagna",
|
||||
"Indu_Lagna": "Indu Lagna",
|
||||
}
|
||||
normalized_special_lagnas = {
|
||||
str(key).strip().lower().replace(" ", "_"): _as_dict(value)
|
||||
for key, value in special_lagnas.items()
|
||||
}
|
||||
missing_special_lagnas: list[str] = []
|
||||
for key, display_name in required_special_lagnas.items():
|
||||
row = normalized_special_lagnas.get(key.lower(), {})
|
||||
explicit_status = _status(row)
|
||||
has_position = any(row.get(field) not in (None, "") for field in ("sign", "degree", "longitude"))
|
||||
explicitly_blocked = explicit_status in {"blocked", "not_applicable"}
|
||||
if not has_position and not explicitly_blocked:
|
||||
missing_special_lagnas.append(key)
|
||||
checks.append(_check(f"special_lagna:{key}", "review_required", "missing value or blocked status"))
|
||||
else:
|
||||
checks.append(_check(f"special_lagna:{key}", "passed", explicit_status if explicitly_blocked else "position present"))
|
||||
if rendered_markdown is not None and (has_position or explicitly_blocked) and display_name not in rendered_markdown:
|
||||
review_reasons.append(f"special_lagna_not_rendered:{key}")
|
||||
checks.append(_check(f"special_lagna:{key}:rendered", "review_required", "not rendered"))
|
||||
if len(missing_special_lagnas) >= 2:
|
||||
blocking_reasons.append("special_lagnas_incomplete")
|
||||
elif missing_special_lagnas:
|
||||
review_reasons.extend(f"special_lagna_missing:{key}" for key in missing_special_lagnas)
|
||||
|
||||
wealth_vargas = _as_dict(divisional.get("varga_full"))
|
||||
for key, label in (("D2_Hora", "D2"), ("D11_Rudramsa", "D11")):
|
||||
chart = _as_dict(wealth_vargas.get(key))
|
||||
ascendant = _as_dict(chart.get("Ascendant") or chart.get("ascendant"))
|
||||
has_ascendant = any(ascendant.get(field) not in (None, "") for field in ("sign", "degree_in_sign", "longitude"))
|
||||
if not has_ascendant:
|
||||
blocking_reasons.append(f"wealth_varga_structure_missing:{label}")
|
||||
checks.append(_check(f"wealth_varga:{label}", "blocked", "missing ascendant structure"))
|
||||
else:
|
||||
checks.append(_check(f"wealth_varga:{label}", "passed", "ascendant structure present"))
|
||||
|
||||
coverage_inputs = (
|
||||
(
|
||||
"wealth_d2_chart",
|
||||
"required_support",
|
||||
"worksheets.divisional_and_special_charts.varga_full.D2_Hora",
|
||||
_as_dict(_as_dict(divisional.get("varga_full")).get("D2_Hora")),
|
||||
("D2(Hora 财富分盘)",),
|
||||
),
|
||||
(
|
||||
"wealth_d11_gains_chart",
|
||||
"required_support",
|
||||
"worksheets.divisional_and_special_charts.varga_full.D11_Rudramsa",
|
||||
_as_dict(_as_dict(divisional.get("varga_full")).get("D11_Rudramsa")),
|
||||
("D11(Rudramsa 收益分盘)",),
|
||||
),
|
||||
(
|
||||
"special_lagnas",
|
||||
"required_support",
|
||||
"worksheets.divisional_and_special_charts.special_lagnas",
|
||||
divisional.get("special_lagnas"),
|
||||
("Hora Lagna", "Bhava Lagna", "Standard Lagnas", "Special Lagnas"),
|
||||
),
|
||||
(
|
||||
"patyayini_annual_support",
|
||||
"required_support",
|
||||
"worksheets.timing_and_predictive_systems.annual_tajika_pack.patyayini_dasha",
|
||||
patyayini,
|
||||
("Patyayini Dasha", "Patyayini"),
|
||||
),
|
||||
(
|
||||
"narayana_alignment",
|
||||
"required_support",
|
||||
"worksheets.timing_and_predictive_systems.narayana_dasha",
|
||||
narayana,
|
||||
("Narayana Rashi Dasha", "Narayana 参考对齐", "Narayana"),
|
||||
),
|
||||
(
|
||||
"functional_benefic_malefic",
|
||||
"required_support",
|
||||
"worksheets.strengths_and_scores.functional_benefic_malefic",
|
||||
strengths.get("functional_benefic_malefic"),
|
||||
("功能性吉凶", "Functional Benefic/Malefic"),
|
||||
),
|
||||
(
|
||||
"auxiliary_dasha_support",
|
||||
"required_support",
|
||||
"worksheets.timing_and_predictive_systems.dasha_master_pack.families",
|
||||
auxiliary_dasha_support,
|
||||
("辅助大运族:Yogini / Ashtottari / Kala Chakra",),
|
||||
),
|
||||
(
|
||||
"natal_special_factor_support",
|
||||
"required_support",
|
||||
"worksheets.advanced_systems.sahams/avasthas and worksheets.divisional_and_special_charts.upagrahas",
|
||||
natal_special_factor_support,
|
||||
("本命补充因子:Sahams / Avasthas / Upagrahas",),
|
||||
),
|
||||
)
|
||||
professional_coverage_manifest: list[dict[str, Any]] = []
|
||||
for material_id, tier, source_reference, upstream_value, markers in coverage_inputs:
|
||||
entry, review_reason = _coverage_entry(
|
||||
material_id,
|
||||
tier,
|
||||
source_reference,
|
||||
upstream_value,
|
||||
rendered_markdown,
|
||||
markers,
|
||||
)
|
||||
professional_coverage_manifest.append(entry)
|
||||
if review_reason:
|
||||
review_reasons.append(review_reason)
|
||||
|
||||
patyayini_rows_present = _patyayini_normalized_rows_present(packet)
|
||||
if patyayini_rows_present is True:
|
||||
checks.append(_check("patyayini:normalized_rows", "passed", "present"))
|
||||
elif patyayini_rows_present is False:
|
||||
review_reasons.append("patyayini_normalized_rows_missing")
|
||||
checks.append(_check("patyayini:normalized_rows", "review_required", "missing or empty"))
|
||||
|
||||
overrides = _as_dict(packet.get("professional_coverage_overrides"))
|
||||
for material_id in RESTRICTED_MATERIAL_IDS:
|
||||
override = _as_dict(overrides.get(material_id))
|
||||
override_status = str(override.get("status") or "restricted_or_unclosed")
|
||||
professional_coverage_manifest.append(
|
||||
{
|
||||
"material_id": material_id,
|
||||
"admission_tier": "restricted_or_unclosed",
|
||||
"report_role": "restricted_research_material",
|
||||
"source_reference": "professional_coverage_overrides",
|
||||
"upstream_present": bool(override),
|
||||
"status": override_status,
|
||||
"source_status": override_status,
|
||||
"surface_location": "blocked_or_research_appendix",
|
||||
"rendered": None,
|
||||
"limitation_reference": "must_not_generate_result_claim",
|
||||
}
|
||||
)
|
||||
if override_status in {"confirmed", "verified", "approved", "promoted"}:
|
||||
blocking_reasons.append(f"restricted_material_promoted:{material_id}")
|
||||
checks.append(_check(f"restricted:{material_id}", "blocked", override_status))
|
||||
|
||||
status = _promotion_status(blocking_reasons, review_reasons, warning_reasons)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
"blocking_reasons": blocking_reasons,
|
||||
"review_reasons": review_reasons,
|
||||
"warning_reasons": warning_reasons,
|
||||
"professional_coverage_manifest": professional_coverage_manifest,
|
||||
"audit_reference": {
|
||||
"source_schema": source_schema or None,
|
||||
"calculation_profile_id": packet.get("calculation_profile_id") or profile.get("profile_id"),
|
||||
"result_hash": packet.get("result_hash"),
|
||||
"read_only": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Evaluate an assembled PL9 full-report packet without modifying it")
|
||||
parser.add_argument("--packet", required=True, help="PL9 export JSON packet")
|
||||
parser.add_argument("--markdown", help="Optional rendered Markdown to validate reader-facing coverage")
|
||||
args = parser.parse_args()
|
||||
|
||||
packet = json.loads(Path(args.packet).read_text(encoding="utf-8"))
|
||||
markdown = Path(args.markdown).read_text(encoding="utf-8") if args.markdown else None
|
||||
print(json.dumps(evaluate_full_report(packet, markdown), ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -4750,6 +4750,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
lat = self._get_float(body, 'lat', 0, -90, 90)
|
||||
lon = self._get_float(body, 'lon', 0, -180, 180)
|
||||
tz = self._parse_timezone(body, lat, lon, year, month, day, hour, minute, second)
|
||||
today = body.get('today') or body.get('current_date')
|
||||
if not isinstance(today, str) or not today.strip():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
else:
|
||||
today = today.strip()[:10]
|
||||
raw_target = body.get('target_year')
|
||||
if raw_target in (None, ''):
|
||||
target_year = int(today[:4])
|
||||
else:
|
||||
target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400)
|
||||
raw_age = body.get('age')
|
||||
if raw_age in (None, ''):
|
||||
age = target_year - year
|
||||
else:
|
||||
age = self._get_int(body, 'age', target_year - year, 0, 120)
|
||||
return {
|
||||
'year': year,
|
||||
'month': month,
|
||||
@@ -4762,8 +4777,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'tz': tz,
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'today': body.get('today') or body.get('current_date'),
|
||||
'today': today,
|
||||
'transit_date': body.get('transit_date') or body.get('reference_date'),
|
||||
'target_year': target_year,
|
||||
'age': age,
|
||||
'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'),
|
||||
'candidate_range': body.get('candidate_range'),
|
||||
'representative_time': body.get('representative_time'),
|
||||
@@ -6567,6 +6584,22 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}:
|
||||
node_mode = 'mean'
|
||||
|
||||
today = body.get('today') or body.get('current_date')
|
||||
if not isinstance(today, str) or not today.strip():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
else:
|
||||
today = today.strip()[:10]
|
||||
raw_target = body.get('target_year')
|
||||
if raw_target in (None, ''):
|
||||
target_year = int(today[:4])
|
||||
else:
|
||||
target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400)
|
||||
raw_age = body.get('age')
|
||||
if raw_age in (None, ''):
|
||||
age = target_year - year
|
||||
else:
|
||||
age = self._get_int(body, 'age', target_year - year, 0, 120)
|
||||
|
||||
args = type('Args', (), {
|
||||
'year': year,
|
||||
'month': month,
|
||||
@@ -6579,10 +6612,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'tz': tz,
|
||||
'node_mode': node_mode,
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'age': body.get('age'),
|
||||
'today': body.get('today') or body.get('current_date'),
|
||||
'age': age,
|
||||
'today': today,
|
||||
'transit_date': body.get('transit_date'),
|
||||
'target_year': body.get('target_year'),
|
||||
'target_year': target_year,
|
||||
'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'),
|
||||
'candidate_range': body.get('candidate_range'),
|
||||
'representative_time': body.get('representative_time'),
|
||||
|
||||
+217
-19
@@ -76,10 +76,6 @@ def build_report_theme_catalog(report):
|
||||
return []
|
||||
|
||||
|
||||
def attach_calculation_profile(payload, args=None):
|
||||
return payload
|
||||
|
||||
|
||||
def _try_attr_import(modname, attr):
|
||||
for name in (modname, f"scripts.{modname}"):
|
||||
try:
|
||||
@@ -89,10 +85,6 @@ def _try_attr_import(modname, attr):
|
||||
return None
|
||||
|
||||
|
||||
def build_calculation_profile(args=None):
|
||||
return {"status": "blocked", "reason": "calculation_profile_contract_absent"}
|
||||
|
||||
|
||||
from ayanamsa_utils import (
|
||||
AYANAMSA_DISPLAY_NAMES,
|
||||
AYANAMSA_MODES,
|
||||
@@ -110,6 +102,27 @@ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
try:
|
||||
from calculation_profile_contract import (
|
||||
attach_calculation_profile,
|
||||
build_calculation_profile,
|
||||
)
|
||||
except ImportError: # pragma: no cover - package import
|
||||
try:
|
||||
from scripts.calculation_profile_contract import (
|
||||
attach_calculation_profile,
|
||||
build_calculation_profile,
|
||||
)
|
||||
except ImportError:
|
||||
def attach_calculation_profile(payload, args=None):
|
||||
return payload
|
||||
|
||||
def build_calculation_profile(args=None):
|
||||
return {"status": "blocked", "reason": "calculation_profile_contract_absent"}
|
||||
|
||||
HOME_DIR = os.path.expanduser('~')
|
||||
CLAW_DIR = os.path.join(HOME_DIR, 'WorkBuddy', 'Claw')
|
||||
DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db')
|
||||
@@ -2384,7 +2397,12 @@ def _attach_report_governance_contracts(packet: dict, args) -> dict:
|
||||
if birth_time_sensitivity.get('status') == 'candidate_window_only':
|
||||
packet['birth_time_sensitivity'] = birth_time_sensitivity
|
||||
else:
|
||||
packet.pop('birth_time_sensitivity', None)
|
||||
packet['birth_time_sensitivity'] = {
|
||||
'schema': 'jyotish.report_birth_time_sensitivity.v1',
|
||||
'status': 'not_rectified',
|
||||
'accuracy': birth_time_sensitivity.get('accuracy') or _birth_time_accuracy(args),
|
||||
'reason': 'no_candidate_window',
|
||||
}
|
||||
packet['timing_boundary_attribution'] = _build_timing_boundary_attribution(packet)
|
||||
packet['module_execution_audit'] = _build_module_execution_audit(packet)
|
||||
ai_pack = ((packet.get('raw_full_reading') or {}).get('ai_prompt_pack') or {})
|
||||
@@ -2520,6 +2538,34 @@ def _attach_personal_report_producer(packet: dict) -> dict:
|
||||
return packet
|
||||
|
||||
|
||||
def _native_dasha_family_status(module) -> dict:
|
||||
if not isinstance(module, dict) or not module:
|
||||
return {'execution_status': 'blocked', 'reason': 'native_module_absent'}
|
||||
if module.get('error'):
|
||||
return {'execution_status': 'blocked', 'reason': str(module.get('error'))}
|
||||
status = str(module.get('status') or '').strip().lower()
|
||||
if status in {'blocked', 'error'}:
|
||||
return {
|
||||
'execution_status': 'blocked',
|
||||
'reason': module.get('reason') or module.get('status') or 'blocked',
|
||||
}
|
||||
return {
|
||||
'execution_status': 'executed',
|
||||
'status': module.get('status') or 'executed',
|
||||
}
|
||||
|
||||
|
||||
def _native_dasha_master_families(modules: dict) -> dict:
|
||||
modules = modules if isinstance(modules, dict) else {}
|
||||
return {
|
||||
'vimshottari': _native_dasha_family_status(modules.get('dasha')),
|
||||
'narayana': _native_dasha_family_status(modules.get('narayana_dasha')),
|
||||
'yogini': _native_dasha_family_status(modules.get('yogini_dasha')),
|
||||
'ashtottari': _native_dasha_family_status(modules.get('ashtottari_dasha')),
|
||||
'kala_chakra': _native_dasha_family_status(modules.get('kalachakra_dasha')),
|
||||
}
|
||||
|
||||
|
||||
def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict:
|
||||
try:
|
||||
from professional_parity_closure import build_dasha_master_pack
|
||||
@@ -2563,7 +2609,12 @@ def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict:
|
||||
functional_classification,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {'schema': 'dasha_master_report_pack_v1', 'status': 'blocked', 'reason': str(exc)}
|
||||
return {
|
||||
'schema': 'dasha_master_report_pack_v1',
|
||||
'status': 'blocked',
|
||||
'reason': str(exc),
|
||||
'families': _native_dasha_master_families(modules),
|
||||
}
|
||||
|
||||
|
||||
def _attach_profile_id_to_dasha_section(dasha: dict, profile: dict) -> dict:
|
||||
@@ -2832,6 +2883,87 @@ def _render_shared_report_identity_header(packet: dict) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _status_cell(value) -> str:
|
||||
text = str(value if value not in (None, '') else 'blocked').strip()
|
||||
return text.replace('|', '/')
|
||||
|
||||
|
||||
def _render_finished_reading_navigation(packet: dict) -> list[str]:
|
||||
"""Deterministic reading-nav / QA layer. Numbers come from this packet only."""
|
||||
quality = packet.get('report_quality_gate') if isinstance(packet.get('report_quality_gate'), dict) else {}
|
||||
worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {}
|
||||
timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {}
|
||||
advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {}
|
||||
strengths = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {}
|
||||
divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {}
|
||||
annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {}
|
||||
annual_series = timing.get('annual_tajika_series') if isinstance(timing.get('annual_tajika_series'), dict) else {}
|
||||
kp_monthly = advanced.get('kp_monthly_report') if isinstance(advanced.get('kp_monthly_report'), dict) else {}
|
||||
sensitivity = packet.get('birth_time_sensitivity') if isinstance(packet.get('birth_time_sensitivity'), dict) else {}
|
||||
functional = strengths.get('functional_benefic_malefic') if isinstance(strengths.get('functional_benefic_malefic'), dict) else {}
|
||||
checks = quality.get('checks') if isinstance(quality.get('checks'), list) else []
|
||||
manifest = quality.get('professional_coverage_manifest') if isinstance(quality.get('professional_coverage_manifest'), list) else []
|
||||
year_keys = list((annual_series.get('years') or {}).keys()) if isinstance(annual_series.get('years'), dict) else []
|
||||
months = kp_monthly.get('months') if isinstance(kp_monthly.get('months'), list) else []
|
||||
coverage_rows = [
|
||||
('三年年度展开', 'present' if len(year_keys) >= 3 else ('degraded' if year_keys else 'missing'), _status_cell(annual.get('status') or annual_series.get('status'))),
|
||||
('KP 三年流月支持', 'present' if len(months) >= 36 else ('degraded' if months else 'missing'), _status_cell(kp_monthly.get('status') or kp_monthly.get('reason'))),
|
||||
('功能性吉凶 / Yogakaraka', 'present' if functional else 'missing', _status_cell(functional.get('status') if functional else 'blocked')),
|
||||
('Upagraha 表', 'present' if divisional.get('upagrahas') else 'missing', _status_cell('partial_verified' if divisional.get('upagrahas') else 'blocked')),
|
||||
('校时敏感层', 'present' if sensitivity.get('status') == 'candidate_window_only' else 'degraded', _status_cell(sensitivity.get('status') or 'not_rectified')),
|
||||
]
|
||||
if manifest:
|
||||
coverage_rows = [
|
||||
(
|
||||
str(item.get('material_id') or item.get('source_reference') or 'item'),
|
||||
'present' if item.get('rendered') or item.get('upstream_present') else 'missing',
|
||||
_status_cell(item.get('status') or item.get('source_status')),
|
||||
)
|
||||
for item in manifest
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
check_passed = sum(1 for item in checks if isinstance(item, dict) and item.get('status') == 'passed')
|
||||
check_total = len(checks) if checks else 0
|
||||
quality_status = _status_cell(quality.get('status') or quality.get('reason') or 'not_evaluated')
|
||||
lines = [
|
||||
'## 成品阅读导航',
|
||||
'',
|
||||
'### 先读什么',
|
||||
'',
|
||||
'1. 本命主轴与功能性吉凶,确认计算口径。',
|
||||
'2. 大运与时间主线,以及三年年度重点。',
|
||||
'3. 事业 / 财运 / 关系专题中的 KP 小表与月度支持。',
|
||||
'4. 校时敏感层:有候选窗才读矩阵,没有则视为未做校时。',
|
||||
'5. 质量验收矩阵与对照覆盖表,只用来核对装配,不改写正文标签。',
|
||||
'',
|
||||
'### 结论等级规则',
|
||||
'',
|
||||
'- `executed` / `partial_verified`:可阅读,但仍受参数与证据边界约束。',
|
||||
'- `parameter_sensitive`:可交叉核对,不得升级为精确事件。',
|
||||
'- `blocked` / `missing_in_local`:保持原标签,不得涂成可读结论。',
|
||||
'',
|
||||
'### 专题判读协议',
|
||||
'',
|
||||
'- 事业先看 2/6/10/11 宫 KP 结构与 D10,再看三年月度支持。',
|
||||
'- 财运先看 2/5/9/11 宫与 D2/D11,再看年度 Sahams 坐标。',
|
||||
'- 关系先看 2/7/11 宫与 D9 / UL,再看年内换挡点。',
|
||||
'',
|
||||
'### 质量验收矩阵',
|
||||
'',
|
||||
f'- 本次质量门状态:`{quality_status}`',
|
||||
f'- 检查项:{check_passed}/{check_total} passed' if check_total else '- 检查项:本次未返回逐项检查,保持 not_evaluated。',
|
||||
'',
|
||||
'### 对照覆盖表',
|
||||
'',
|
||||
'| 段落 | 装配 | 状态标签 |',
|
||||
'|------|------|----------|',
|
||||
]
|
||||
for name, coverage, status in coverage_rows:
|
||||
lines.append(f'| {_status_cell(name)} | {coverage} | {status} |')
|
||||
lines.append('')
|
||||
return lines
|
||||
|
||||
|
||||
def render_pl9_markdown(packet: dict) -> str:
|
||||
"""Render the full PL9 Markdown report."""
|
||||
birth = packet.get('birth_info', {}) if isinstance(packet, dict) else {}
|
||||
@@ -3158,6 +3290,7 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
lines = ['# 个人印度占星报告', '']
|
||||
lines.extend(_render_shared_report_identity_header(packet))
|
||||
lines.extend(_reader_engine_boundary_notice_markdown())
|
||||
lines.extend(_render_finished_reading_navigation(packet))
|
||||
|
||||
if isinstance(producer, dict) and producer:
|
||||
main_body = producer.get('main_body') if isinstance(producer.get('main_body'), dict) else {}
|
||||
@@ -3577,8 +3710,19 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
annual_pack = timing_sheet.get('annual_tajika_pack') if isinstance(timing_sheet.get('annual_tajika_pack'), dict) else {}
|
||||
annual_sections = annual_pack.get('sections') if isinstance(annual_pack.get('sections'), dict) else {}
|
||||
year_lord = annual_sections.get('year_lord') if isinstance(annual_sections.get('year_lord'), dict) else {}
|
||||
if not year_lord:
|
||||
year_lord = annual_pack.get('year_lord') if isinstance(annual_pack.get('year_lord'), dict) else {}
|
||||
rows: list[tuple[str, str, str, str]] = []
|
||||
|
||||
if not families:
|
||||
families = _native_dasha_master_families({
|
||||
'dasha': timing_sheet.get('dasha') or modules.get('dasha'),
|
||||
'narayana_dasha': timing_sheet.get('narayana_dasha') or modules.get('narayana_dasha'),
|
||||
'yogini_dasha': modules.get('yogini_dasha'),
|
||||
'ashtottari_dasha': modules.get('ashtottari_dasha'),
|
||||
'kalachakra_dasha': modules.get('kalachakra_dasha'),
|
||||
})
|
||||
|
||||
def _family_status_row(label: str, key: str, confidence_when_executed: str, blocked_text: str) -> None:
|
||||
family = families.get(key) if isinstance(families.get(key), dict) else {}
|
||||
if not family:
|
||||
@@ -8590,6 +8734,8 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
lines.extend(yoga_section)
|
||||
|
||||
professional_support = full_report_pack.get('sections', {}).get('professional_support') if isinstance(full_report_pack.get('sections'), dict) else {}
|
||||
if not isinstance(professional_support, dict):
|
||||
professional_support = {}
|
||||
professional_topics = professional_support.get('topics') if isinstance(professional_support.get('topics'), dict) else {}
|
||||
if professional_support:
|
||||
special_topic = professional_topics.get('special_lagnas') if isinstance(professional_topics.get('special_lagnas'), dict) else {}
|
||||
@@ -9307,12 +9453,20 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
lines.append(f"- 边界:{_md_cell(blind_policy.get('boundary'))}")
|
||||
if event_replay.get('family_d12_binding'):
|
||||
lines.append(f"- D12/父母家庭绑定:{_md_cell(event_replay.get('family_d12_binding'))}")
|
||||
if birth_time_sensitivity.get('status') == 'candidate_window_only':
|
||||
if birth_time_sensitivity.get('status') == 'candidate_window_only' and isinstance(birth_time_sensitivity.get('report_projection'), dict):
|
||||
try:
|
||||
from flexible_birth_time_report_section import render_flexible_birth_time_report_section
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.flexible_birth_time_report_section import render_flexible_birth_time_report_section
|
||||
lines.extend(['', render_flexible_birth_time_report_section(birth_time_sensitivity['report_projection']).rstrip()])
|
||||
else:
|
||||
lines.extend([
|
||||
'',
|
||||
'### 出生时间敏感度',
|
||||
'',
|
||||
'未做校时。当前没有可用的候选窗,因此不输出分钟级敏感性矩阵或校时分钟排行。',
|
||||
'',
|
||||
])
|
||||
|
||||
lines.extend(['', '## 校时证据领域合同', ''])
|
||||
lines.append(_md_cell(rectification_evidence_contract.get('claim_boundary', 'rectification_evidence_contract_missing')))
|
||||
@@ -12925,6 +13079,7 @@ def cmd_kp(args):
|
||||
|
||||
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(kp_args)
|
||||
if chart is None:
|
||||
_apply_ayanamsa(requested_report_ayanamsa)
|
||||
return {"error": "swisseph未安装"}
|
||||
|
||||
asc_sign = chart.get("ascendant", {}).get("sign", "Aries")
|
||||
@@ -12999,7 +13154,9 @@ def cmd_kp(args):
|
||||
'The main report calculation profile is not overwritten. External same-input KP replay and timing parity remain unclosed.'
|
||||
),
|
||||
}
|
||||
return _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed')
|
||||
envelope = _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed')
|
||||
_apply_ayanamsa(requested_report_ayanamsa)
|
||||
return envelope
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -14575,6 +14732,19 @@ def cmd_full_reading(args):
|
||||
|
||||
report['chart'] = chart
|
||||
report['modules']['chart'] = chart
|
||||
|
||||
# KP is a distinct cusp/sub-lord system. Retain its raw output so the
|
||||
# unified evidence archive can expose it without treating it as Parashari.
|
||||
# cmd_kp temporarily switches Swiss Ephemeris to KP ayanamsa; restore the
|
||||
# report profile immediately so later full-reading producers stay on Raman.
|
||||
try:
|
||||
report['modules']['kp'] = cmd_kp(args)
|
||||
except Exception as e:
|
||||
report['modules']['kp'] = {"status": "blocked", "reason": f"KP producer failed: {e}"}
|
||||
report['errors'].append(f"kp: {e}")
|
||||
finally:
|
||||
_apply_ayanamsa(_current_ayanamsa_name(args))
|
||||
|
||||
planets = chart.get('planets', {})
|
||||
report['modules']['planetary_friendship'] = _build_planetary_friendship_snapshot(planets)
|
||||
ascendant = chart.get('ascendant', {})
|
||||
@@ -14584,6 +14754,19 @@ def cmd_full_reading(args):
|
||||
planet_degs = {pn: pd.get('degree_in_sign_raw', pd.get('degree_in_sign', pd['degree'] % 30)) for pn, pd in planets.items() if isinstance(pd, dict) and 'degree' in pd}
|
||||
houses = _build_whole_sign_houses(asc_idx, planets)
|
||||
report['modules']['house_map'] = houses
|
||||
try:
|
||||
report['modules'].update(_build_natal_foundation_modules(
|
||||
args,
|
||||
planet_lons,
|
||||
asc_lon=asc_deg,
|
||||
jd=jd,
|
||||
))
|
||||
except Exception as e:
|
||||
report['errors'].append(f"natal-foundation: {e}")
|
||||
try:
|
||||
report['modules']['varga_research_high'] = _build_research_high_varga(planet_lons, asc_deg)
|
||||
except Exception as e:
|
||||
report['errors'].append(f"varga-research-high: {e}")
|
||||
planet_sign_indices = {}
|
||||
for pn, pd in planets.items():
|
||||
if isinstance(pd, dict) and 'sign' in pd:
|
||||
@@ -14593,7 +14776,7 @@ def cmd_full_reading(args):
|
||||
'core_chart_and_setup',
|
||||
stage_started,
|
||||
enabled=profile_stages,
|
||||
details={'modules': ['chart', 'house_map']},
|
||||
details={'modules': ['chart', 'house_map', 'kp', 'upagrahas', 'functional_benefic_malefic']},
|
||||
)
|
||||
|
||||
# ── Step 1.5: Special Lagnas 特殊上升点 (v4.4.0) ──
|
||||
@@ -16340,6 +16523,7 @@ def build_pl9_style_export_packet(full_reading: dict, include_raw: bool = False)
|
||||
dasha_master_pack = {
|
||||
'schema': 'dasha_master_report_pack_v1',
|
||||
'audit': {'status': 'blocked', 'reason': f'Dasha master pack assembly failed: {exc}'},
|
||||
'families': _native_dasha_master_families(modules),
|
||||
}
|
||||
dasha_interpretation_pack = {
|
||||
'schema': 'pl9.dasha_interpretation_pack.v1',
|
||||
@@ -16979,11 +17163,17 @@ def build_professional_report_reference_packet(
|
||||
'reason': 'full_report_quality_gate_absent',
|
||||
}
|
||||
else:
|
||||
public_quality_input = sanitize_professional_report_reference(final_packet)
|
||||
final_packet['report_quality_gate'] = evaluate_full_report(
|
||||
public_quality_input,
|
||||
render_pl9_markdown(public_quality_input),
|
||||
)
|
||||
try:
|
||||
public_quality_input = sanitize_professional_report_reference(final_packet)
|
||||
final_packet['report_quality_gate'] = evaluate_full_report(
|
||||
public_quality_input,
|
||||
render_pl9_markdown(public_quality_input),
|
||||
)
|
||||
except Exception:
|
||||
final_packet['report_quality_gate'] = {
|
||||
'status': 'blocked',
|
||||
'reason': 'full_report_quality_gate_failed',
|
||||
}
|
||||
build_shared_full_report_authority = _try_attr_import(
|
||||
'shared_full_report_authority', 'build_shared_full_report_authority'
|
||||
)
|
||||
@@ -16995,7 +17185,15 @@ def build_professional_report_reference_packet(
|
||||
'read_only': True,
|
||||
}
|
||||
else:
|
||||
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
|
||||
try:
|
||||
final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet)
|
||||
except Exception:
|
||||
final_packet['shared_full_report_authority'] = {
|
||||
'schema_version': 'jyotish.shared_full_report_authority.v1',
|
||||
'status': 'blocked',
|
||||
'reason': 'shared_full_report_authority_failed',
|
||||
'read_only': True,
|
||||
}
|
||||
return sanitize_professional_report_reference(final_packet)
|
||||
|
||||
def cmd_pl9_export(args):
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract helpers for the KP three-year monthly report packet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from kp_system import build_kp_western_support_surface, kp_maturity_profile
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile
|
||||
|
||||
|
||||
def build_kp_monthly_report_contract(
|
||||
*, start_month: str, month_count: int, timezone_offset: float
|
||||
) -> dict:
|
||||
maturity_profile = kp_maturity_profile()
|
||||
return {
|
||||
"schema": "jyotish.kp_monthly_report.v1",
|
||||
"profile": {
|
||||
"ayanamsa": "kp",
|
||||
"house_system": "placidus",
|
||||
"node_mode": "mean",
|
||||
"status": "parameter_sensitive",
|
||||
},
|
||||
"maturity_profile": {
|
||||
"claim_status": maturity_profile.get("claim_status"),
|
||||
"truth_matrix_allowed": maturity_profile.get("truth_matrix_allowed") is True,
|
||||
},
|
||||
"window": {
|
||||
"start_month": start_month,
|
||||
"month_count": month_count,
|
||||
"timezone_offset": timezone_offset,
|
||||
"anchor_policy": "local_month_start_noon",
|
||||
},
|
||||
"supporting_systems": {
|
||||
"western_kp_support": build_kp_western_support_surface(
|
||||
maturity_profile=maturity_profile,
|
||||
)
|
||||
},
|
||||
"must_not_claim": [
|
||||
"exact_event_timing",
|
||||
"specific_event_prediction",
|
||||
],
|
||||
}
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assembler for the KP three-year monthly report packet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from domain_calculation_service import compute_chart
|
||||
from kp_monthly_report_contract import build_kp_monthly_report_contract
|
||||
from kp_monthly_theme_support import build_kp_monthly_theme_support
|
||||
from kp_monthly_transits import build_monthly_transit_snapshot
|
||||
from kp_monthly_vimshottari import build_monthly_vimshottari_snapshot
|
||||
from kp_system import build_kp_western_support_surface, kp_maturity_profile
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.domain_calculation_service import compute_chart
|
||||
from scripts.kp_monthly_report_contract import build_kp_monthly_report_contract
|
||||
from scripts.kp_monthly_theme_support import build_kp_monthly_theme_support
|
||||
from scripts.kp_monthly_transits import build_monthly_transit_snapshot
|
||||
from scripts.kp_monthly_vimshottari import build_monthly_vimshottari_snapshot
|
||||
from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile
|
||||
|
||||
|
||||
_HUMAN_LABELS = {
|
||||
"Jupiter": "木星",
|
||||
"Saturn": "土星",
|
||||
"Rahu": "北交点",
|
||||
"Ketu": "南交点",
|
||||
"Aries": "白羊座",
|
||||
"Taurus": "金牛座",
|
||||
"Gemini": "双子座",
|
||||
"Cancer": "巨蟹座",
|
||||
"Leo": "狮子座",
|
||||
"Virgo": "处女座",
|
||||
"Libra": "天秤座",
|
||||
"Scorpio": "天蝎座",
|
||||
"Sagittarius": "射手座",
|
||||
"Capricorn": "摩羯座",
|
||||
"Aquarius": "水瓶座",
|
||||
"Pisces": "双鱼座",
|
||||
}
|
||||
|
||||
|
||||
def _add_months(start_year: int, start_month: int, offset: int) -> tuple[int, int]:
|
||||
month_index = (start_month - 1) + offset
|
||||
return start_year + (month_index // 12), (month_index % 12) + 1
|
||||
|
||||
|
||||
def _label(value: str | None) -> str:
|
||||
if not value:
|
||||
return "-"
|
||||
return _HUMAN_LABELS.get(value, value)
|
||||
|
||||
|
||||
def _build_slow_planet_signals(
|
||||
current_transits: dict,
|
||||
next_transits: dict | None,
|
||||
) -> list[str]:
|
||||
signals = []
|
||||
current_planets = current_transits.get("planets") if isinstance(current_transits.get("planets"), dict) else {}
|
||||
next_planets = next_transits.get("planets") if isinstance(next_transits, dict) and isinstance(next_transits.get("planets"), dict) else {}
|
||||
for planet in ("Jupiter", "Saturn", "Rahu", "Ketu"):
|
||||
current = current_planets.get(planet) if isinstance(current_planets.get(planet), dict) else {}
|
||||
nxt = next_planets.get(planet) if isinstance(next_planets.get(planet), dict) else {}
|
||||
current_sign = current.get("sign")
|
||||
next_sign = nxt.get("sign")
|
||||
if current_sign and next_sign and current_sign != next_sign:
|
||||
signals.append(f"{_label(planet)}换座:{_label(current_sign)}→{_label(next_sign)}")
|
||||
elif current.get("retrograde"):
|
||||
signals.append(f"{_label(planet)}逆行强调")
|
||||
return signals
|
||||
|
||||
|
||||
def _highlight_reason(row: dict) -> str:
|
||||
signals = row.get("slow_planet_signals") if isinstance(row.get("slow_planet_signals"), list) else []
|
||||
if signals:
|
||||
return ";".join(str(item) for item in signals[:2])
|
||||
levels = row.get("vimshottari_five_levels") if isinstance(row.get("vimshottari_five_levels"), dict) else {}
|
||||
level_rows = levels.get("levels") if isinstance(levels.get("levels"), dict) else {}
|
||||
md = level_rows.get("mahadasha") if isinstance(level_rows.get("mahadasha"), dict) else {}
|
||||
ad = level_rows.get("antardasha") if isinstance(level_rows.get("antardasha"), dict) else {}
|
||||
return f"主运/次运焦点:{_label(md.get('lord'))} / {_label(ad.get('lord'))}"
|
||||
|
||||
|
||||
def _build_yearly_highlights(month_rows: list[dict]) -> list[dict]:
|
||||
years: dict[int, list[dict]] = {}
|
||||
for row in month_rows:
|
||||
month = str(row.get("month") or "")
|
||||
if len(month) < 4:
|
||||
continue
|
||||
year = int(month[:4])
|
||||
years.setdefault(year, []).append(row)
|
||||
yearly = []
|
||||
for year in sorted(years):
|
||||
ranked = sorted(
|
||||
years[year],
|
||||
key=lambda item: (
|
||||
len(item.get("slow_planet_signals") or []),
|
||||
1 if any("换座" in str(signal) for signal in (item.get("slow_planet_signals") or [])) else 0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
picks = []
|
||||
for row in ranked:
|
||||
picks.append({"month": row.get("month"), "reason": _highlight_reason(row), "status": "parameter_sensitive"})
|
||||
if len(picks) == 4:
|
||||
break
|
||||
yearly.append({"year": year, "months": picks, "status": "parameter_sensitive"})
|
||||
return yearly
|
||||
|
||||
|
||||
def _resolve_cmd_kp():
|
||||
try: # pragma: no cover - script execution path
|
||||
from __main__ import cmd_kp as resolved
|
||||
return resolved
|
||||
except ImportError:
|
||||
pass
|
||||
scripts_dir = Path(__file__).resolve().parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
try: # pragma: no cover - pytest/module import path
|
||||
from jyotish_engine import cmd_kp as resolved
|
||||
return resolved
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.jyotish_engine import cmd_kp as resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def build_kp_monthly_report_packet(
|
||||
*,
|
||||
birth_payload: dict,
|
||||
start_month: str,
|
||||
month_count: int,
|
||||
western_support: dict | None = None,
|
||||
) -> dict:
|
||||
start_year, start_month_number = [int(part) for part in start_month.split("-", 1)]
|
||||
natal_chart = compute_chart({**birth_payload, "ayanamsa": "lahiri", "node_mode": "mean"})
|
||||
moon_lon = natal_chart.get("planets", {}).get("Moon", {}).get("degree_raw")
|
||||
kp_args = SimpleNamespace(
|
||||
year=birth_payload["year"],
|
||||
month=birth_payload["month"],
|
||||
day=birth_payload["day"],
|
||||
hour=birth_payload["hour"],
|
||||
minute=birth_payload["minute"],
|
||||
second=birth_payload.get("second", 0),
|
||||
lat=birth_payload["lat"],
|
||||
lon=birth_payload["lon"],
|
||||
tz=birth_payload["tz"],
|
||||
node_mode="mean",
|
||||
ayanamsa="lahiri",
|
||||
)
|
||||
natal_kp = _resolve_cmd_kp()(kp_args)
|
||||
packet = build_kp_monthly_report_contract(
|
||||
start_month=start_month,
|
||||
month_count=month_count,
|
||||
timezone_offset=float(birth_payload["tz"]),
|
||||
)
|
||||
maturity_profile = kp_maturity_profile()
|
||||
western_support_surface = build_kp_western_support_surface(
|
||||
western_support,
|
||||
maturity_profile=maturity_profile,
|
||||
)
|
||||
packet["supporting_systems"]["western_kp_support"] = western_support_surface
|
||||
packet["western_support"] = western_support_surface
|
||||
packet["months"] = []
|
||||
month_rows = []
|
||||
for offset in range(month_count):
|
||||
year, month = _add_months(start_year, start_month_number, offset)
|
||||
anchor_dt = datetime(year, month, 1, 12, 0, 0)
|
||||
monthly_levels = build_monthly_vimshottari_snapshot(
|
||||
birth_dt=datetime(
|
||||
birth_payload["year"],
|
||||
birth_payload["month"],
|
||||
birth_payload["day"],
|
||||
birth_payload["hour"],
|
||||
birth_payload["minute"],
|
||||
birth_payload.get("second", 0),
|
||||
),
|
||||
moon_lon=moon_lon,
|
||||
anchor_dt=anchor_dt,
|
||||
)
|
||||
monthly_transits = build_monthly_transit_snapshot(
|
||||
year=year,
|
||||
month=month,
|
||||
lat=float(birth_payload["lat"]),
|
||||
lon=float(birth_payload["lon"]),
|
||||
tz=float(birth_payload["tz"]),
|
||||
)
|
||||
theme_support = build_kp_monthly_theme_support(
|
||||
natal_kp=natal_kp,
|
||||
monthly_levels=monthly_levels.get("levels") or {},
|
||||
monthly_transits=monthly_transits,
|
||||
)
|
||||
month_rows.append(
|
||||
{
|
||||
"month": f"{year:04d}-{month:02d}",
|
||||
"anchor_local": monthly_transits["anchor_local"],
|
||||
"vimshottari_five_levels": monthly_levels,
|
||||
"monthly_transits": monthly_transits,
|
||||
"theme_support": theme_support,
|
||||
"status": "parameter_sensitive",
|
||||
"must_not_claim": packet["must_not_claim"],
|
||||
}
|
||||
)
|
||||
for index, row in enumerate(month_rows):
|
||||
next_transits = None
|
||||
if index + 1 < len(month_rows):
|
||||
next_transits = month_rows[index + 1].get("monthly_transits")
|
||||
row["slow_planet_signals"] = _build_slow_planet_signals(
|
||||
row.get("monthly_transits") or {},
|
||||
next_transits,
|
||||
)
|
||||
packet["months"] = month_rows
|
||||
packet["yearly_highlights"] = _build_yearly_highlights(month_rows)
|
||||
packet["natal_kp"] = natal_kp
|
||||
return packet
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Theme-level KP monthly support aggregation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_DOMAIN_HOUSES = {
|
||||
"career": (2, 6, 10, 11),
|
||||
"wealth": (2, 5, 9, 11),
|
||||
"relationship": (2, 7, 11),
|
||||
}
|
||||
|
||||
_DOMAIN_LABELS = {
|
||||
"career": "事业",
|
||||
"wealth": "财务",
|
||||
"relationship": "关系",
|
||||
}
|
||||
|
||||
_HOUSE_LABELS = {
|
||||
2: "收入与资源",
|
||||
5: "机会与投入",
|
||||
6: "职责与压力",
|
||||
7: "合作与伴侣",
|
||||
9: "运气与扩张",
|
||||
10: "事业与位置",
|
||||
11: "回报与支持",
|
||||
}
|
||||
|
||||
_DOMAIN_TEMPLATES = {
|
||||
"career": "这段时间更适合把重心放在职责变化、位置调整和回报兑现的节奏里观察。",
|
||||
"wealth": "这段时间更适合把注意力放在现金流、机会选择和回报兑现的节奏里观察。",
|
||||
"relationship": "这段时间更适合把重心放在合作默契、关系回应和现实支持的变化里观察。",
|
||||
}
|
||||
|
||||
_PLANET_CN = {
|
||||
"Sun": "太阳",
|
||||
"Moon": "月亮",
|
||||
"Mars": "火星",
|
||||
"Mercury": "水星",
|
||||
"Jupiter": "木星",
|
||||
"Venus": "金星",
|
||||
"Saturn": "土星",
|
||||
"Rahu": "北交点",
|
||||
"Ketu": "南交点",
|
||||
}
|
||||
|
||||
_SIGN_CN = {
|
||||
"Aries": "白羊座",
|
||||
"Taurus": "金牛座",
|
||||
"Gemini": "双子座",
|
||||
"Cancer": "巨蟹座",
|
||||
"Leo": "狮子座",
|
||||
"Virgo": "处女座",
|
||||
"Libra": "天秤座",
|
||||
"Scorpio": "天蝎座",
|
||||
"Sagittarius": "射手座",
|
||||
"Capricorn": "摩羯座",
|
||||
"Aquarius": "水瓶座",
|
||||
"Pisces": "双鱼座",
|
||||
}
|
||||
|
||||
|
||||
def _collect_active_houses(houses: dict, house_numbers: tuple[int, ...]) -> list[int]:
|
||||
active: list[int] = []
|
||||
for house in house_numbers:
|
||||
row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else {}
|
||||
significators = row.get("significators") if isinstance(row.get("significators"), dict) else {}
|
||||
if significators.get("A") not in (None, "", [], (), set()):
|
||||
active.append(house)
|
||||
return active
|
||||
|
||||
|
||||
def _house_signal_text(active_houses: list[int], fallback_houses: tuple[int, ...]) -> str:
|
||||
if not active_houses:
|
||||
return "相关宫位线索暂时偏弱"
|
||||
labels = [f"{house}宫{_HOUSE_LABELS.get(house, '')}" for house in active_houses]
|
||||
return "、".join(labels)
|
||||
|
||||
|
||||
def _summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str, saturn_sign: str) -> str:
|
||||
label = _DOMAIN_LABELS.get(domain, domain)
|
||||
focus = _house_signal_text(active_houses, fallback_houses)
|
||||
template = _DOMAIN_TEMPLATES.get(domain, "这段时间更适合先看结构支持,再看外部触发。")
|
||||
md_label = _PLANET_CN.get(md_lord, md_lord)
|
||||
saturn_label = _SIGN_CN.get(saturn_sign, saturn_sign)
|
||||
if active_houses:
|
||||
return (
|
||||
f"{label}线本月先看 {focus} 的本命承诺;"
|
||||
f"当前主运星为{md_label},土星行运落在{saturn_label},{template}"
|
||||
)
|
||||
fallback_text = "、".join(
|
||||
f"{house}宫{_HOUSE_LABELS.get(house, '')}" for house in fallback_houses
|
||||
)
|
||||
return (
|
||||
f"{label}线本月更适合先按 {fallback_text} 这组标准宫位阅读,再等待更强触发出现;"
|
||||
f"当前主运星为{md_label},土星行运落在{saturn_label},{template}"
|
||||
)
|
||||
|
||||
|
||||
def _brief_summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str) -> str:
|
||||
label = _DOMAIN_LABELS.get(domain, domain)
|
||||
md_label = _PLANET_CN.get(md_lord, md_lord)
|
||||
focus_houses = active_houses or list(fallback_houses[:2])
|
||||
focus_text = "、".join(f"{house}宫" for house in focus_houses)
|
||||
return f"本命焦点:{focus_text};月运主轴:{label}线跟随{md_label}主运推进。"
|
||||
|
||||
|
||||
def build_kp_monthly_theme_support(
|
||||
*, natal_kp: dict, monthly_levels: dict, monthly_transits: dict
|
||||
) -> dict:
|
||||
houses = natal_kp.get("houses") if isinstance(natal_kp.get("houses"), dict) else {}
|
||||
ruling = natal_kp.get("ruling_planets") if isinstance(natal_kp.get("ruling_planets"), dict) else {}
|
||||
md_lord = ((monthly_levels.get("mahadasha") or {}) if isinstance(monthly_levels, dict) else {}).get("lord") or "-"
|
||||
saturn_sign = ((((monthly_transits.get("planets") or {}) if isinstance(monthly_transits, dict) else {}).get("Saturn") or {}) if isinstance((monthly_transits.get("planets") or {}).get("Saturn"), dict) else {}).get("sign") or "-"
|
||||
out = {}
|
||||
for domain, house_numbers in _DOMAIN_HOUSES.items():
|
||||
active_houses = _collect_active_houses(houses, house_numbers)
|
||||
out[domain] = {
|
||||
"houses": list(house_numbers),
|
||||
"active_houses": active_houses,
|
||||
"promise_code": " / ".join(str(h) for h in active_houses) if active_houses else "-",
|
||||
"summary": _summary_text(domain, active_houses, house_numbers, md_lord, saturn_sign),
|
||||
"brief_summary": _brief_summary_text(domain, active_houses, house_numbers, md_lord),
|
||||
"ruling_planet_snapshot": ruling.get("day_lord") or "-",
|
||||
"status": "parameter_sensitive",
|
||||
"must_not_claim": ["exact_event_timing", "specific_event_prediction"],
|
||||
}
|
||||
return out
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monthly transit snapshots for the KP three-year report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from domain_calculation_service import compute_chart
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.domain_calculation_service import compute_chart
|
||||
|
||||
|
||||
def build_monthly_transit_snapshot(
|
||||
*, year: int, month: int, lat: float, lon: float, tz: float
|
||||
) -> dict:
|
||||
chart = compute_chart(
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"day": 1,
|
||||
"hour": 12,
|
||||
"minute": 0,
|
||||
"second": 0,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"tz": tz,
|
||||
"ayanamsa": "kp",
|
||||
"node_mode": "mean",
|
||||
"position_mode": "apparent",
|
||||
}
|
||||
)
|
||||
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
|
||||
snapshot = {}
|
||||
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"):
|
||||
row = planets.get(name) if isinstance(planets.get(name), dict) else {}
|
||||
snapshot[name] = {
|
||||
"sign": row.get("sign"),
|
||||
"longitude": row.get("degree_raw", row.get("degree")),
|
||||
"retrograde": bool(row.get("retrograde")),
|
||||
"speed": row.get("speed"),
|
||||
}
|
||||
return {
|
||||
"month": f"{year:04d}-{month:02d}",
|
||||
"anchor_local": f"{year:04d}-{month:02d}-01T12:00:00",
|
||||
"planets": snapshot,
|
||||
"status": "parameter_sensitive",
|
||||
"source": "local_kp_month_anchor_chart",
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monthly five-level Vimshottari snapshots for the KP monthly report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
try: # pragma: no cover - import path differs under CLI vs pytest
|
||||
from dasha_calculator_enhanced import calculate_five_level_dasha
|
||||
from domain_calculation_service import compute_vimshottari_timeline
|
||||
except ImportError: # pragma: no cover
|
||||
from scripts.dasha_calculator_enhanced import calculate_five_level_dasha
|
||||
from scripts.domain_calculation_service import compute_vimshottari_timeline
|
||||
|
||||
|
||||
def build_monthly_vimshottari_snapshot(
|
||||
*, birth_dt: datetime, moon_lon: float, anchor_dt: datetime
|
||||
) -> dict:
|
||||
timeline = compute_vimshottari_timeline(
|
||||
birth_dt=birth_dt,
|
||||
moon_lon=moon_lon,
|
||||
current_date=anchor_dt,
|
||||
)
|
||||
current = timeline.get("current_dasha") or {}
|
||||
start_text = current.get("start")
|
||||
if start_text:
|
||||
current_start = datetime.strptime(start_text, "%Y-%m-%d")
|
||||
elapsed_years = max((anchor_dt - current_start).days / 365.25, 0.0)
|
||||
else:
|
||||
elapsed_years = 0.0
|
||||
md_lord = current.get("lord") or timeline.get("birth_balance", {}).get("lord")
|
||||
levels = calculate_five_level_dasha(md_lord, elapsed_years)
|
||||
return {
|
||||
"anchor_date": anchor_dt.strftime("%Y-%m-%d"),
|
||||
"timeline": {
|
||||
"current_dasha": current,
|
||||
"birth_balance": timeline.get("birth_balance"),
|
||||
},
|
||||
"levels": {
|
||||
"mahadasha": levels.get("mahadasha"),
|
||||
"antardasha": levels.get("bhukti"),
|
||||
"pratyantardasha": levels.get("pratyantar"),
|
||||
"sookshma": levels.get("sookshma"),
|
||||
"prana": levels.get("prana"),
|
||||
},
|
||||
"status": "parameter_sensitive",
|
||||
"must_not_claim": ["exact_event_timing", "specific_event_prediction"],
|
||||
}
|
||||
@@ -7,6 +7,7 @@ calculation, and delegates packet assembly/rendering to ``jyotish_engine``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -57,19 +58,32 @@ def _load_engine():
|
||||
|
||||
|
||||
def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
|
||||
normalized_birth = {
|
||||
today = birth.get("today") or datetime.now().strftime("%Y-%m-%d")
|
||||
raw_target = birth.get("target_year")
|
||||
if raw_target in (None, ""):
|
||||
target_year = int(str(today)[:4])
|
||||
else:
|
||||
target_year = int(raw_target)
|
||||
raw_age = birth.get("age")
|
||||
if raw_age in (None, ""):
|
||||
try:
|
||||
age = int(target_year) - int(birth["year"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
age = None
|
||||
else:
|
||||
age = int(raw_age)
|
||||
payload = {
|
||||
**birth,
|
||||
"hour": int(birth["hour"]),
|
||||
"minute": int(birth["minute"]),
|
||||
"second": int(birth.get("second", 0)),
|
||||
"second": int(birth.get("second", 0) or 0),
|
||||
"today": today,
|
||||
"target_year": target_year,
|
||||
"age": age,
|
||||
"visual_chart_observations": birth.get("visual_chart_observations"),
|
||||
"startrack_language_bridge": bool(birth.get("startrack_language_bridge")),
|
||||
}
|
||||
return SimpleNamespace(
|
||||
**normalized_birth,
|
||||
age=None,
|
||||
target_year=None,
|
||||
visual_chart_observations=None,
|
||||
startrack_language_bridge=False,
|
||||
)
|
||||
return SimpleNamespace(**payload)
|
||||
|
||||
|
||||
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
|
||||
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
"""Build the governed reference object for a PL9-grade full personal report.
|
||||
|
||||
The authority is intentionally a compact, read-only reference envelope. It
|
||||
does not duplicate raw worksheets, render Markdown, alter quality-gate results,
|
||||
or publish a report. Consumers use its stable identity and references to locate
|
||||
the already assembled PL9 export packet through an approved transport layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
AUTHORITY_SCHEMA_VERSION = "jyotish.shared_full_report_authority.v1"
|
||||
REPORT_SCHEMA_VERSION = "expert_report_output.v2"
|
||||
DEFAULT_REPORT_VERSION = "pl9_personal_long_report.v2"
|
||||
PL9_EXPORT_SCHEMA = "pl9_style_professional_export_v1"
|
||||
QUALITY_GATE_SCHEMA = "jyotish.full_report_quality_gate.v1"
|
||||
|
||||
|
||||
def build_shared_full_report_authority(
|
||||
packet: dict[str, Any],
|
||||
*,
|
||||
expert_judgment_output: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a compact authority envelope without mutating an export packet.
|
||||
|
||||
A quality-gate result is mandatory, but it is not a publication approval.
|
||||
Every non-blocked authority remains ``review_required`` until a future
|
||||
governed review/promotion flow authorizes a consumer surface.
|
||||
"""
|
||||
source = _require_mapping(packet, "packet")
|
||||
_require_value(source.get("schema"), "packet.schema")
|
||||
if source.get("schema") != PL9_EXPORT_SCHEMA:
|
||||
raise ValueError(f"packet.schema must be {PL9_EXPORT_SCHEMA}")
|
||||
|
||||
profile = _require_mapping(source.get("calculation_profile"), "calculation_profile")
|
||||
profile_id = _require_value(
|
||||
source.get("calculation_profile_id") or profile.get("profile_id"),
|
||||
"calculation_profile_id",
|
||||
)
|
||||
result_hash = _require_value(source.get("result_hash"), "result_hash")
|
||||
full_report_pack = _require_mapping(source.get("full_report_pack"), "full_report_pack")
|
||||
full_report_pack_schema = _require_value(full_report_pack.get("schema"), "full_report_pack.schema")
|
||||
quality_gate = _require_mapping(source.get("report_quality_gate"), "report_quality_gate")
|
||||
if quality_gate.get("schema_version") != QUALITY_GATE_SCHEMA:
|
||||
raise ValueError(f"report_quality_gate.schema_version must be {QUALITY_GATE_SCHEMA}")
|
||||
|
||||
report_version = str(source.get("report_version") or DEFAULT_REPORT_VERSION)
|
||||
chart_identity = _build_chart_identity(source, profile, profile_id)
|
||||
identity_seed = {
|
||||
"report_schema_version": REPORT_SCHEMA_VERSION,
|
||||
"report_version": report_version,
|
||||
"calculation_profile_id": profile_id,
|
||||
"result_hash": result_hash,
|
||||
"full_report_pack_schema": full_report_pack_schema,
|
||||
# The same calculation payload can be reached from different chart
|
||||
# identity states. Keep those authorities distinct rather than letting
|
||||
# an unreviewed and an approved chart share one report reference.
|
||||
"chart_profile_id": chart_identity["chart_profile_id"],
|
||||
"rectification_status": chart_identity["rectification_status"],
|
||||
"approval_status": chart_identity["approval_status"],
|
||||
}
|
||||
identity_digest = _digest(identity_seed)
|
||||
report_id = f"full-report://{profile_id}/{identity_digest[:24]}"
|
||||
quality_gate_reference = _build_quality_gate_reference(quality_gate)
|
||||
status, publication_status = _derive_status(quality_gate_reference["status"])
|
||||
|
||||
sections = _require_mapping(full_report_pack.get("sections"), "full_report_pack.sections")
|
||||
report_pack_manifest = _as_mapping(source.get("report_pack_manifest"))
|
||||
pack_ids = [
|
||||
str(pack.get("id"))
|
||||
for pack in report_pack_manifest.get("packs", [])
|
||||
if isinstance(pack, dict) and pack.get("id")
|
||||
]
|
||||
judgment_lineage = _build_judgment_lineage(expert_judgment_output)
|
||||
limitations = _build_limitations(source, quality_gate_reference, chart_identity, judgment_lineage)
|
||||
|
||||
return {
|
||||
"schema_version": AUTHORITY_SCHEMA_VERSION,
|
||||
"authority_id": f"shared-full-report://{identity_digest[:24]}",
|
||||
"report_id": report_id,
|
||||
"report_version": report_version,
|
||||
"report_contract_version": REPORT_SCHEMA_VERSION,
|
||||
"status": status,
|
||||
"publication_status": publication_status,
|
||||
"read_only": True,
|
||||
"report_metadata": {
|
||||
"generated_at": source.get("generated_at"),
|
||||
"generated_at_status": "recorded" if source.get("generated_at") else "not_recorded",
|
||||
},
|
||||
"chart_identity": chart_identity,
|
||||
"lineage": {
|
||||
"source_schema": source.get("schema"),
|
||||
"calculation_profile_id": profile_id,
|
||||
"result_hash": result_hash,
|
||||
"full_report_pack_schema": full_report_pack_schema,
|
||||
"report_pack_ids": pack_ids,
|
||||
"source_reference": "pl9_export_packet",
|
||||
},
|
||||
"quality_gate_reference": quality_gate_reference,
|
||||
"content_reference": {
|
||||
"full_report_pack_schema": full_report_pack_schema,
|
||||
"section_keys": list(sections.keys()),
|
||||
"professional_support_reference": _build_professional_support_reference(
|
||||
sections.get("professional_support")
|
||||
),
|
||||
"professional_coverage_reference": _build_professional_coverage_reference(
|
||||
quality_gate.get("professional_coverage_manifest")
|
||||
),
|
||||
"full_report_body_authority": _build_full_report_body_authority(
|
||||
sections=sections,
|
||||
professional_support=sections.get("professional_support"),
|
||||
professional_coverage_manifest=quality_gate.get("professional_coverage_manifest"),
|
||||
),
|
||||
"available_render_formats": ["markdown", "reader-markdown", "pdf"],
|
||||
"contains_raw_calculation": False,
|
||||
"contains_shadow_artifact": False,
|
||||
},
|
||||
"surface_slices": _build_surface_slices(report_id),
|
||||
"limitations": limitations,
|
||||
"judgment_lineage": judgment_lineage,
|
||||
"publication_boundary": (
|
||||
"This reference does not publish, promote, render, or replace any "
|
||||
"existing report consumer. It remains review-only until a governed "
|
||||
"promotion path explicitly authorizes a surface."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _require_mapping(value: Any, name: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
return value
|
||||
|
||||
|
||||
def _as_mapping(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _require_value(value: Any, name: str) -> str:
|
||||
if value in (None, ""):
|
||||
raise ValueError(f"{name} is required")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _digest(value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _build_quality_gate_reference(quality_gate: dict[str, Any]) -> dict[str, Any]:
|
||||
status = str(quality_gate.get("status") or "review_required")
|
||||
reference_payload = {
|
||||
"schema_version": quality_gate.get("schema_version"),
|
||||
"status": status,
|
||||
"blocking_reasons": list(quality_gate.get("blocking_reasons") or []),
|
||||
"review_reasons": list(quality_gate.get("review_reasons") or []),
|
||||
"warning_reasons": list(quality_gate.get("warning_reasons") or []),
|
||||
"audit_reference": _as_mapping(quality_gate.get("audit_reference")),
|
||||
}
|
||||
return {
|
||||
**reference_payload,
|
||||
"quality_gate_reference_id": f"report-quality-gate://{_digest(reference_payload)[:24]}",
|
||||
}
|
||||
|
||||
|
||||
def _derive_status(quality_gate_status: str) -> tuple[str, str]:
|
||||
if quality_gate_status == "blocked":
|
||||
return "blocked", "blocked"
|
||||
return "review_required", "pending_review"
|
||||
|
||||
|
||||
def _build_chart_identity(
|
||||
packet: dict[str, Any],
|
||||
profile: dict[str, Any],
|
||||
profile_id: str,
|
||||
) -> dict[str, Any]:
|
||||
existing = _as_mapping(packet.get("chart_identity"))
|
||||
return {
|
||||
"chart_profile_id": existing.get("chart_profile_id") or profile_id,
|
||||
"birth_data_status": existing.get("birth_data_status") or packet.get("birth_data_status") or "user_provided",
|
||||
"rectification_status": existing.get("rectification_status") or packet.get("rectification_status") or "not_reviewed",
|
||||
"approval_status": existing.get("approval_status") or packet.get("approval_status") or "not_approved",
|
||||
"calculation_profile_reference": profile.get("profile_id") or profile_id,
|
||||
}
|
||||
|
||||
|
||||
def _build_limitations(
|
||||
packet: dict[str, Any],
|
||||
quality_gate_reference: dict[str, Any],
|
||||
chart_identity: dict[str, Any],
|
||||
judgment_lineage: dict[str, Any],
|
||||
) -> list[dict[str, str]]:
|
||||
limitations = [
|
||||
{
|
||||
"type": "publication_review_required",
|
||||
"detail": "A quality-gate result is advisory and cannot publish a report automatically.",
|
||||
},
|
||||
]
|
||||
if judgment_lineage.get("status") != "attached":
|
||||
limitations.append(
|
||||
{
|
||||
"type": "judgment_lineage_not_attached",
|
||||
"detail": "The current PL9 packet is not yet linked to a production ExpertJudgmentOutput reference.",
|
||||
}
|
||||
)
|
||||
if quality_gate_reference.get("status") != "passed":
|
||||
limitations.append(
|
||||
{
|
||||
"type": "quality_gate_not_clean_pass",
|
||||
"detail": f"Quality gate status is {quality_gate_reference.get('status')}.",
|
||||
}
|
||||
)
|
||||
if chart_identity.get("approval_status") != "approved":
|
||||
limitations.append(
|
||||
{
|
||||
"type": "chart_identity_not_approved",
|
||||
"detail": f"Chart approval status is {chart_identity.get('approval_status')}.",
|
||||
}
|
||||
)
|
||||
if not packet.get("generated_at"):
|
||||
limitations.append(
|
||||
{
|
||||
"type": "generation_timestamp_not_recorded",
|
||||
"detail": "The source export did not supply a generated_at timestamp.",
|
||||
}
|
||||
)
|
||||
return limitations
|
||||
|
||||
|
||||
def _build_judgment_lineage(expert_judgment_output: dict[str, Any] | None) -> dict[str, Any]:
|
||||
output = expert_judgment_output if isinstance(expert_judgment_output, dict) else {}
|
||||
if not output:
|
||||
return {
|
||||
"status": "not_attached",
|
||||
"boundary": (
|
||||
"Current PL9 export is a governed report candidate. A future "
|
||||
"ExpertJudgmentOutput reference is required before it can claim "
|
||||
"judgment-backed production authority."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"status": "attached",
|
||||
"schema_version": output.get("schema_version"),
|
||||
"judgment_id": output.get("judgment_id"),
|
||||
"judgment_status": output.get("status"),
|
||||
"promotion_state": ((output.get("review_metadata") or {}).get("promotion_state") if isinstance(output.get("review_metadata"), dict) else None) or "unknown",
|
||||
"review_status": ((output.get("review_metadata") or {}).get("review_status") if isinstance(output.get("review_metadata"), dict) else None) or "unknown",
|
||||
"audit_reference": _as_mapping(output.get("audit_reference")),
|
||||
"boundary": (
|
||||
"A governed ExpertJudgmentOutput reference is attached. Publication and promotion still require "
|
||||
"their own review boundary."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_surface_slices(report_id: str) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"simple_view": {
|
||||
"authority_mode": "reference_only",
|
||||
"report_reference": report_id,
|
||||
"allowed_sections": ["Executive Summary", "Chart Identity", "Conflict and Limitation"],
|
||||
},
|
||||
"deep_analysis_view": {
|
||||
"authority_mode": "reference_only",
|
||||
"report_reference": report_id,
|
||||
"allowed_sections": [
|
||||
"Core Promise Analysis",
|
||||
"Strength Analysis",
|
||||
"Domain Analysis",
|
||||
"Varga Analysis",
|
||||
"Dasha Analysis",
|
||||
"KP and Transit Timing Layer",
|
||||
"Annual / Tajika / Yearly Focus",
|
||||
"Three-Year Ephemeris and Predictive Outlook",
|
||||
"Conflict and Limitation",
|
||||
"Evidence Summary",
|
||||
],
|
||||
},
|
||||
"expert_workspace": {
|
||||
"authority_mode": "reference_only",
|
||||
"report_reference": report_id,
|
||||
"allowed_sections": ["all_governed_sections", "Operator Appendix / Audit Appendix"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_professional_support_reference(section: Any) -> dict[str, Any]:
|
||||
topic_section = section if isinstance(section, dict) else {}
|
||||
topics = topic_section.get("topics") if isinstance(topic_section.get("topics"), dict) else {}
|
||||
topic_statuses = {
|
||||
str(key): str(value.get("status") or "blocked")
|
||||
for key, value in topics.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
restricted = topics.get("restricted_research_materials") if isinstance(topics.get("restricted_research_materials"), dict) else {}
|
||||
materials = restricted.get("materials") if isinstance(restricted.get("materials"), dict) else {}
|
||||
restricted_material_visibility = sorted(
|
||||
key for key, value in materials.items()
|
||||
if isinstance(value, dict) and value.get("status") not in (None, "", "not_available")
|
||||
)
|
||||
body_authority_topics = sorted(
|
||||
key
|
||||
for key, value in topics.items()
|
||||
if key != "restricted_research_materials"
|
||||
and isinstance(value, dict)
|
||||
and value.get("status") not in (None, "", "blocked", "not_available")
|
||||
)
|
||||
appendix_only_topics = sorted(
|
||||
key
|
||||
for key, value in topics.items()
|
||||
if isinstance(value, dict)
|
||||
and (
|
||||
key == "restricted_research_materials"
|
||||
or value.get("status") in (None, "", "blocked", "not_available")
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": topic_section.get("status") or "blocked",
|
||||
"topic_statuses": topic_statuses,
|
||||
"body_authority_topics": body_authority_topics,
|
||||
"appendix_only_topics": appendix_only_topics,
|
||||
"restricted_material_visibility": restricted_material_visibility,
|
||||
}
|
||||
|
||||
|
||||
def _build_professional_coverage_reference(manifest: Any) -> list[dict[str, str]]:
|
||||
rows = manifest if isinstance(manifest, list) else []
|
||||
normalized: list[dict[str, str]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
material_id = row.get("material_id")
|
||||
status = row.get("status")
|
||||
source_reference = row.get("source_reference")
|
||||
if material_id in (None, ""):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"material_id": str(material_id),
|
||||
"status": str(status or "unknown"),
|
||||
"source_reference": str(source_reference or ""),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_full_report_body_authority(
|
||||
*,
|
||||
sections: dict[str, Any],
|
||||
professional_support: Any,
|
||||
professional_coverage_manifest: Any,
|
||||
) -> dict[str, Any]:
|
||||
section_statuses = {
|
||||
str(key): str(value.get("status") or "blocked")
|
||||
for key, value in sections.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
professional_support_reference = _build_professional_support_reference(professional_support)
|
||||
coverage_rows = _build_professional_coverage_reference(professional_coverage_manifest)
|
||||
body_sections = [
|
||||
key
|
||||
for key, status in section_statuses.items()
|
||||
if key not in {"audit_appendix", "professional_support"}
|
||||
and status not in {"blocked", "not_available"}
|
||||
]
|
||||
restricted_topics = list(professional_support_reference.get("restricted_material_visibility") or [])
|
||||
body_authority_topics = list(professional_support_reference.get("body_authority_topics") or [])
|
||||
appendix_only_topics = list(professional_support_reference.get("appendix_only_topics") or [])
|
||||
topic_promotion_state = {
|
||||
topic: (
|
||||
"body_authority"
|
||||
if topic in body_authority_topics
|
||||
else "restricted_registry"
|
||||
if topic in restricted_topics
|
||||
else "appendix_only"
|
||||
)
|
||||
for topic in sorted(set(body_authority_topics + restricted_topics + appendix_only_topics))
|
||||
}
|
||||
return {
|
||||
"status": "authority_topics_promoted" if body_sections or body_authority_topics or coverage_rows else "blocked",
|
||||
"body_sections": body_sections,
|
||||
"body_authority_topics": body_authority_topics,
|
||||
"restricted_professional_topics": restricted_topics,
|
||||
"appendix_only_topics": appendix_only_topics,
|
||||
"topic_promotion_state": topic_promotion_state,
|
||||
"coverage_manifest_material_ids": [row["material_id"] for row in coverage_rows],
|
||||
"coverage_manifest_authority_materials": [
|
||||
{
|
||||
"material_id": row["material_id"],
|
||||
"status": row["status"],
|
||||
"source_reference": row["source_reference"],
|
||||
"promotion_state": "body_authority_reference" if row["status"] not in {"blocked", "not_available"} else "appendix_only",
|
||||
}
|
||||
for row in coverage_rows
|
||||
],
|
||||
"boundary": (
|
||||
"This layer tracks which high-value support and coverage materials have an explicit body-authority "
|
||||
"or restricted-professional registration inside the long-report contract. It does not auto-promote "
|
||||
"parameter-sensitive or blocked material into judgment authority."
|
||||
),
|
||||
}
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
"""Governed authority builder for auditable Tajika named yogas.
|
||||
|
||||
This module promotes only the two named yogas whose full rule chain already
|
||||
shares one vocabulary with the seven-planet Tajika kernel: Ithasala and
|
||||
Easarapha. It does not infer broader Tajika event verdicts or unsupported
|
||||
named-yoga chains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft
|
||||
|
||||
|
||||
SCHEMA_VERSION = "jyotish.tajika_named_yoga_authority.v1"
|
||||
SUPPORTED_NAMED_YOGAS = ("Ithasala", "Easarapha")
|
||||
|
||||
|
||||
def build_tajika_named_yoga_authority(planets: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Promote governed Tajika draft rows into a narrow authority layer."""
|
||||
draft = build_tajika_named_yoga_governed_draft(planets)
|
||||
if draft.get("status") == "blocked":
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": "blocked",
|
||||
"authority_ready": False,
|
||||
"supported_named_yogas": list(SUPPORTED_NAMED_YOGAS),
|
||||
"rows": [],
|
||||
"reason": draft.get("reason") or "tajika_named_yoga_draft_blocked",
|
||||
"missing": list(draft.get("missing") or []),
|
||||
"claim_boundary": "named_yoga_authority_requires_complete_governed_draft_inputs",
|
||||
"limitations": [
|
||||
"kernel_input_missing",
|
||||
"no_event_verdict",
|
||||
"unsupported_named_yogas_remain_blocked",
|
||||
],
|
||||
}
|
||||
|
||||
rows = []
|
||||
for item in draft.get("rows") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
named_yoga = item.get("named_yoga")
|
||||
if named_yoga not in SUPPORTED_NAMED_YOGAS:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
**item,
|
||||
"review_status": "governed_authority_ready",
|
||||
"resolution_state": "authority_promoted_from_shared_motion_contract",
|
||||
"authority_status": "supported",
|
||||
"claim_boundary": "named_yoga_present_absent_only_no_event_verdict",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": "authority_ready",
|
||||
"authority_ready": True,
|
||||
"supported_named_yogas": list(SUPPORTED_NAMED_YOGAS),
|
||||
"blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola"],
|
||||
"kernel_status": draft.get("kernel_status"),
|
||||
"kernel_boundary": draft.get("kernel_boundary"),
|
||||
"rows": rows,
|
||||
"summary": {
|
||||
"row_count": len(rows),
|
||||
"named_yoga_counts": _count(rows, "named_yoga"),
|
||||
"motion_counts": _count(rows, "motion"),
|
||||
},
|
||||
"claim_boundary": "governed_named_yoga_authority_limited_to_present_absent_rows_for_ithasala_easarapha",
|
||||
"limitations": [
|
||||
"no_event_verdict",
|
||||
"unsupported_named_yogas_remain_blocked",
|
||||
"annual_product_surface_not_promoted_here",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
value = row.get(key)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
value = str(value)
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
return counts
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
"""Governed named-yoga draft built from shared Tajika motion evidence.
|
||||
|
||||
This module does not promote named yogas into authority. It packages kernel
|
||||
candidate relations into a review-ready draft so the remaining closure can be
|
||||
resolved under one governed vocabulary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from tajika_kernel import calculate_tajika_interactions
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.tajika_kernel import calculate_tajika_interactions
|
||||
|
||||
|
||||
SCHEMA_VERSION = "jyotish.tajika_named_yoga_governed_draft.v1"
|
||||
_CANDIDATE_TO_NAMED = {
|
||||
"Ithasala_candidate": "Ithasala",
|
||||
"Easarapha_candidate": "Easarapha",
|
||||
}
|
||||
|
||||
|
||||
def build_tajika_named_yoga_governed_draft(planets: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Package Tajika kernel candidate relations as a review-only draft."""
|
||||
if not isinstance(planets, dict):
|
||||
raise ValueError("planets_required")
|
||||
|
||||
kernel = calculate_tajika_interactions(planets)
|
||||
if kernel.get("status") == "blocked":
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": "blocked",
|
||||
"authority_ready": False,
|
||||
"claim_boundary": "governed_named_yoga_draft_requires_complete_seven_planet_kernel_inputs",
|
||||
"reason": kernel.get("reason") or "tajika_kernel_blocked",
|
||||
"missing": list(kernel.get("missing") or []),
|
||||
"rows": [],
|
||||
"summary": {
|
||||
"row_count": 0,
|
||||
"named_yoga_counts": {},
|
||||
"motion_counts": {},
|
||||
},
|
||||
"limitations": [
|
||||
"kernel_input_missing",
|
||||
"no_named_yoga_authority",
|
||||
"draft_does_not_promote_verdict",
|
||||
],
|
||||
}
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in kernel.get("candidate_yogas") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
named_yoga = _CANDIDATE_TO_NAMED.get(str(item.get("name") or ""))
|
||||
if not named_yoga:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"named_yoga": named_yoga,
|
||||
"candidate_name": item.get("name"),
|
||||
"planets": list(item.get("planets") or []),
|
||||
"aspect": item.get("aspect"),
|
||||
"motion": item.get("motion"),
|
||||
"average_deeptamsa": item.get("average_deeptamsa"),
|
||||
"residual": item.get("residual"),
|
||||
"rule_source": item.get("rule_source"),
|
||||
"review_status": "governed_seed_resolution_review_required",
|
||||
"resolution_state": "governed_seed_resolution_draft",
|
||||
"claim_boundary": "candidate_relation_only_no_named_yoga_authority",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"status": "draft_only",
|
||||
"authority_ready": False,
|
||||
"claim_boundary": "governed_named_yoga_draft_only_no_named_yoga_verdict_or_promotion",
|
||||
"kernel_status": kernel.get("status"),
|
||||
"kernel_boundary": kernel.get("boundary"),
|
||||
"rows": rows,
|
||||
"summary": {
|
||||
"row_count": len(rows),
|
||||
"named_yoga_counts": _count(rows, "named_yoga"),
|
||||
"motion_counts": _count(rows, "motion"),
|
||||
},
|
||||
"limitations": [
|
||||
"candidate_relations_only",
|
||||
"no_named_yoga_authority",
|
||||
"no_final_judgment",
|
||||
"governed_seed_review_required_before_promotion",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
value = row.get(key)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
value = str(value)
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
return counts
|
||||
Reference in New Issue
Block a user