#!/usr/bin/env python3 """Native 七政四余 adapter over vendored @4n6h4x0r/stem-branch 0.8.0. Only the seven-governors library entry is invoked. The CLI subcommands --pillars, --luck, --polaris, --qimen, and --liuren are never called. """ from __future__ import annotations import json import os import shutil import subprocess from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] CLI_PATH = REPO_ROOT / "vendor" / "stem-branch" / "dist" / "cli.cjs" LIB_PATH = REPO_ROOT / "vendor" / "stem-branch" / "dist" / "index.cjs" ENGINE_TIMEOUT_SECONDS = 20 COORDINATE_SYSTEM = "qizheng_mansion_degrees_from_jiao" DEFAULT_KETU_MODE = "apogee" DEFAULT_SIDEREAL_MODE: dict[str, Any] = {"type": "modern"} ENGINE_NAME = "@4n6h4x0r/stem-branch" ENGINE_VERSION = "0.8.0" REQUIRED_BODIES = ( "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "rahu", "ketu", "yuebei", "purpleQi", ) ALLOWED_KETU_MODES = frozenset({"apogee", "descending-node"}) FORBIDDEN_CLI_FLAGS = frozenset({ "--pillars", "--luck", "--polaris", "--qimen", "--liuren", "--chuanren", }) DIGNITY_CLOSURE = { "status": "unclosed", "may_enter_conclusions": False, "runtime_promotable_count": 0, "closed_cells": 9, "total_cells": 132, "closure_ref": "references/oracle/qizheng_runtime_truth_closure_status_2026_08_29.json", } BOUNDARY = ( "已生成七政、四余、二十八宿、十二宫、命宫与相位。" "宿度自角宿初度起算,不是 Raman/Lahiri 恒星黄经,也不是回归黄道;" "三套坐标系不得互相换算,也不得叠加成双重印证。" "庙旺判定未闭合(十一体×十二宫共 132 格仅 9 格有直接证据," "runtime_promotable_count = 0),不得进入解读、报告或咨询结论。" ) _NODE_EVAL = ( "const fs=require('fs');" "const payload=JSON.parse(fs.readFileSync(0,'utf8'));" "const lib=require(payload.library);" "const chart=lib.getSevenGovernorsChart(" "new Date(payload.iso)," "{lat:payload.lat,lon:payload.lon}," "{ketuMode:payload.ketuMode,siderealMode:payload.siderealMode}" ");" "process.stdout.write(JSON.stringify(chart));" ) class QizhengChartError(ValueError): """Structured seven-governors adapter failure. Callers map this to HTTP 400.""" def __init__(self, message: str, *, error_code: str = "ERR_QIZHENG_ENGINE") -> None: super().__init__(message) self.error_code = error_code def _field(body: dict[str, Any], *keys: str, required: bool = True) -> Any: for key in keys: if key in body and body[key] not in (None, ""): return body[key] if required: raise QizhengChartError(f"{keys[0]} is required", error_code="ERR_QIZHENG_INPUT") return None def _as_int(value: Any, key: str) -> int: try: return int(value) except (TypeError, ValueError) as exc: raise QizhengChartError(f"{key} must be an integer", error_code="ERR_QIZHENG_INPUT") from exc def _as_float(value: Any, key: str) -> float: try: number = float(value) except (TypeError, ValueError) as exc: raise QizhengChartError(f"{key} must be a number", error_code="ERR_QIZHENG_INPUT") from exc if number != number or number in {float("inf"), float("-inf")}: raise QizhengChartError(f"{key} must be finite", error_code="ERR_QIZHENG_INPUT") return number def _iso_local(year: int, month: int, day: int, hour: int, minute: int, second: int, tz: float) -> str: """ISO local civil time with numeric offset. Correct for astronomy; unsafe for pillars.""" total_minutes = int(round(tz * 60)) sign = "+" if total_minutes >= 0 else "-" abs_minutes = abs(total_minutes) offset_hours, offset_minutes = divmod(abs_minutes, 60) return ( f"{year:04d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}" f"{sign}{offset_hours:02d}:{offset_minutes:02d}" ) def _ketu_mode(raw: Any) -> str: if raw in (None, ""): return DEFAULT_KETU_MODE key = str(raw).strip().lower().replace("_", "-") if key not in ALLOWED_KETU_MODES: raise QizhengChartError("ketu_mode must be apogee or descending-node", error_code="ERR_QIZHENG_INPUT") return key def _sidereal_mode(raw: Any) -> dict[str, Any]: if raw in (None, ""): return dict(DEFAULT_SIDEREAL_MODE) if isinstance(raw, str): key = raw.strip().lower() if key == "modern": return {"type": "modern"} if key in {"classical", "kaiyuan"}: return {"type": "classical", "epoch": "kaiyuan"} if key == "chongzhen": return {"type": "classical", "epoch": "chongzhen"} raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT") if not isinstance(raw, dict): raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT") kind = str(raw.get("type") or "").strip().lower() if kind == "modern": return {"type": "modern"} if kind == "classical": epoch = raw.get("epoch", "kaiyuan") if epoch not in {"kaiyuan", "chongzhen"} and not isinstance(epoch, (int, float)): raise QizhengChartError("sidereal_mode epoch is not supported", error_code="ERR_QIZHENG_INPUT") return {"type": "classical", "epoch": epoch} if kind == "ayanamsa": try: return {"type": "ayanamsa", "value": float(raw.get("value"))} except (TypeError, ValueError) as exc: raise QizhengChartError("sidereal_mode value must be a number", error_code="ERR_QIZHENG_INPUT") from exc raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT") def _sanitize_raw(value: Any) -> Any: if isinstance(value, dict): dropped = {"cwd", "path", "argv", "command", "stderr", "stdout", "library", "cli", "executable"} return {key: _sanitize_raw(item) for key, item in value.items() if key not in dropped} if isinstance(value, list): return [_sanitize_raw(item) for item in value] if isinstance(value, str) and ("\\" in value or value.startswith("/")) and ("vendor" in value or "stem-branch" in value): return "[redacted-path]" return value def _node_bin() -> str: found = shutil.which("node") or shutil.which("node.exe") if not found: raise QizhengChartError("node runtime is not available", error_code="ERR_QIZHENG_NODE_MISSING") return found def _run_engine(payload: dict[str, Any]) -> dict[str, Any]: if not CLI_PATH.is_file() or not LIB_PATH.is_file(): raise QizhengChartError("vendored seven-governors engine is missing", error_code="ERR_QIZHENG_CLI_MISSING") node_bin = _node_bin() argv = [node_bin, "-e", _NODE_EVAL] if FORBIDDEN_CLI_FLAGS.intersection(argv): raise QizhengChartError("forbidden engine subcommand blocked", error_code="ERR_QIZHENG_ENGINE") try: completed = subprocess.run( argv, input=json.dumps(payload, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", timeout=ENGINE_TIMEOUT_SECONDS, cwd=str(REPO_ROOT), check=False, ) except FileNotFoundError as exc: raise QizhengChartError("node runtime is not available", error_code="ERR_QIZHENG_NODE_MISSING") from exc except subprocess.TimeoutExpired as exc: raise QizhengChartError("seven-governors engine timed out", error_code="ERR_QIZHENG_TIMEOUT") from exc if completed.returncode != 0: raise QizhengChartError("seven-governors engine exited with an error", error_code="ERR_QIZHENG_ENGINE_EXIT") stdout = (completed.stdout or "").strip() if not stdout: raise QizhengChartError("seven-governors engine returned empty output", error_code="ERR_QIZHENG_BAD_JSON") try: parsed = json.loads(stdout) except json.JSONDecodeError as exc: raise QizhengChartError("seven-governors engine returned non-JSON output", error_code="ERR_QIZHENG_BAD_JSON") from exc if not isinstance(parsed, dict): raise QizhengChartError("seven-governors engine returned non-JSON output", error_code="ERR_QIZHENG_BAD_JSON") return parsed def build_qizheng_natal_chart(body: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(body, dict): raise QizhengChartError("JSON body must be an object", error_code="ERR_QIZHENG_INPUT") year = _as_int(_field(body, "year"), "year") month = _as_int(_field(body, "month"), "month") day = _as_int(_field(body, "day"), "day") hour = _as_int(_field(body, "hour"), "hour") minute = _as_int(_field(body, "minute"), "minute") second = _as_int(_field(body, "second", required=False) or 0, "second") lat = _as_float(_field(body, "lat", "latitude"), "lat") lon = _as_float(_field(body, "lon", "longitude", "lng"), "lon") tz = _as_float(_field(body, "tz", "timezone"), "tz") ketu_mode = _ketu_mode(body.get("ketu_mode", body.get("ketuMode"))) sidereal_mode = _sidereal_mode(body.get("sidereal_mode", body.get("siderealMode"))) iso = _iso_local(year, month, day, hour, minute, second, tz) raw = _run_engine({ "library": os.fspath(LIB_PATH), "iso": iso, "lat": lat, "lon": lon, "ketuMode": ketu_mode, "siderealMode": sidereal_mode, }) bodies = raw.get("bodies") if isinstance(raw.get("bodies"), dict) else {} palaces = raw.get("palaces") if isinstance(raw.get("palaces"), list) else [] missing = [name for name in REQUIRED_BODIES if name not in bodies] if missing: raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE") if len(palaces) != 12: raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE") if not isinstance(raw.get("ascendant"), dict): raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE") dignities = dict(DIGNITY_CLOSURE) dignities["values"] = raw.get("dignities") if isinstance(raw.get("dignities"), dict) else {} return { "success": True, "endpoint": "qizheng", "coordinate_system": COORDINATE_SYSTEM, "calculation": { "engine": ENGINE_NAME, "engine_version": ENGINE_VERSION, "ketu_mode": ketu_mode, "sidereal_mode": sidereal_mode, "engine_ketu_mode": raw.get("ketuMode"), "engine_sidereal_mode": raw.get("siderealMode"), "iso_local": iso, "timeout_seconds": ENGINE_TIMEOUT_SECONDS, }, "bodies": bodies, "palaces": palaces, "ascendant": raw.get("ascendant"), "aspects": raw.get("aspects") if isinstance(raw.get("aspects"), list) else [], "dignities": dignities, "boundary": BOUNDARY, "raw_engine_output": _sanitize_raw(raw), }