Files
Jyotisha/scripts/consult_full_data_cache.py
T
jesse-ux 16f9f86843 fix(consult): date the full-data cache and slim the model card
The cache key now includes the request date, and files older than two days are removed. The model card keeps values only. Parashari aspects are copied from the engine. Saving a chart warms the packet without blocking the response.

Biography severe conflicts rose from 1 to 3, so this is not accepted for merge. Not pushed.
2026-10-09 10:00:45 +08:00

253 lines
8.6 KiB
Python

"""Cache the full-data report packet for the consult card.
The key is chart identity + ayanamsa + node mode + engine version
+ the reference date used for current dasha, transits and the year chart.
The file name is a hash. Birth data stays inside the scratch file.
Files older than two days are removed.
"""
from __future__ import annotations
import hashlib
import json
import time
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[1]
CACHE_SCHEMA = "consult-full-data-cache-v2"
SLOW_MISS_SECONDS = 20.0
KEEP_SECONDS = 2 * 24 * 60 * 60
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
def cache_dir() -> Path:
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
path.mkdir(parents=True, exist_ok=True)
return path
def engine_version() -> str:
try:
from jyotish_vedic import __version__ as version
except Exception:
return "unknown"
return str(version)
def _number(value: Any) -> int | float:
number = float(value)
if number == int(number):
return int(number)
return number
def reference_date_from_body(body: dict[str, Any], *, today: date | None = None) -> str:
"""One date for dasha, transits and the year chart. Request first, else the server day."""
for key in ("today", "transit_date"):
raw = body.get(key)
if isinstance(raw, str) and len(raw) >= 10:
try:
return date.fromisoformat(raw[:10]).isoformat()
except ValueError:
continue
return (today or date.today()).isoformat()
def identity_from_body(body: dict[str, Any], *, today: date | None = None) -> dict[str, Any]:
"""Birth fields are required. Missing keys raise KeyError so the caller can gap."""
node = body.get("node_mode", body.get("nodeMode", "mean"))
ayanamsa = body.get("ayanamsa", "lahiri")
second = body.get("second", 0)
return {
"schema": CACHE_SCHEMA,
"year": int(body["year"]),
"month": int(body["month"]),
"day": int(body["day"]),
"hour": _number(body["hour"]),
"minute": _number(body["minute"]),
"second": _number(second if second is not None else 0),
"lat": _number(body["lat"]),
"lon": _number(body["lon"]),
"tz": _number(body["tz"]),
"ayanamsa": str(ayanamsa),
"node_mode": str(node or "mean"),
"engine_version": engine_version(),
"reference_date": reference_date_from_body(body, today=today),
}
def cache_key(identity: dict[str, Any]) -> str:
canonical = json.dumps(identity, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _cache_path(identity: dict[str, Any]) -> Path:
return cache_dir() / f"{cache_key(identity)}.json"
def prune_cache(now: float | None = None) -> int:
"""Drop cache files last written more than two days ago."""
cutoff = (time.time() if now is None else now) - KEEP_SECONDS
removed = 0
folder = cache_dir()
for path in folder.glob("*.json"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
except OSError:
continue
return removed
def get_or_build(
identity: dict[str, Any],
builder: PacketBuilder | None = None,
*,
clock: Callable[[], float] | None = None,
) -> dict[str, Any]:
"""Return the packet. A miss calls builder once and stores the packet."""
prune_cache()
now = clock or time.perf_counter
started = now()
path = _cache_path(identity)
if path.is_file():
packet = json.loads(path.read_text(encoding="utf-8"))
elapsed = now() - started
return {"packet": packet, "hit": True, "elapsed_s": elapsed, "slow": False}
if builder is None:
builder = build_full_data_packet
packet = builder(identity)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(path)
elapsed = now() - started
return {
"packet": packet,
"hit": False,
"elapsed_s": elapsed,
"slow": elapsed > SLOW_MISS_SECONDS,
}
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
"""Copy the engine's whole-sign aspects onto the consult packet. Do not recompute."""
modules = reading.get("modules") if isinstance(reading, dict) else None
aspects = modules.get("aspects") if isinstance(modules, dict) else None
rows = aspects.get("house_aspects") if isinstance(aspects, dict) else None
worksheets = packet.get("worksheets")
if not isinstance(rows, list) or not isinstance(worksheets, dict):
return packet
if "graha_drishti" not in worksheets:
worksheets["graha_drishti"] = {
"source": "modules.aspects.house_aspects",
"citation": "references/signs-and-houses.md",
"house_aspects": rows,
}
return packet
def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
"""Packet-only build. No markdown render and no sanitized rewrite."""
from calculation_profile_contract import attach_calculation_profile
from jyotish_engine import build_pl9_style_export_packet, cmd_full_reading
ref = date.fromisoformat(str(identity["reference_date"]))
args = SimpleNamespace(
year=int(identity["year"]),
month=int(identity["month"]),
day=int(identity["day"]),
hour=float(identity["hour"]),
minute=float(identity["minute"]),
second=float(identity["second"]),
lat=float(identity["lat"]),
lon=float(identity["lon"]),
tz=float(identity["tz"]),
ayanamsa=identity["ayanamsa"],
node_mode=identity["node_mode"],
today=ref.isoformat(),
target_year=ref.year,
age=ref.year - int(identity["year"]),
transit_date=ref.isoformat(),
birth_time_accuracy="confirmed",
)
reading = cmd_full_reading(args)
if not isinstance(reading, dict) or reading.get("error"):
raise RuntimeError("full_reading_unavailable")
packet = build_pl9_style_export_packet(reading)
if not isinstance(packet, dict):
raise RuntimeError("full_reading_unavailable")
_attach_graha_drishti(packet, reading)
return attach_calculation_profile(packet, args)
def warm_consult_packet(body: dict[str, Any] | None) -> dict[str, Any]:
"""Build today's packet after a chart save. The response stays small."""
try:
cached = get_or_build(identity_from_body(dict(body or {})))
except Exception as exc:
return {
"success": False,
"endpoint": "consult_card_warm",
"error_type": type(exc).__name__,
}
return {
"success": True,
"endpoint": "consult_card_warm",
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
def _requested_domains(body: dict[str, Any]) -> list[str]:
# Card domains (parents, children) are not the engine theme (family).
raw = body.get("consult_card_domains")
if raw is None:
raw = body.get("themes", body.get("theme"))
if raw is None:
return []
if isinstance(raw, str):
return [raw]
if isinstance(raw, list):
return [str(item) for item in raw if isinstance(item, str)]
return []
def attach_consult_full_data(result: dict[str, Any], body: dict[str, Any] | None) -> dict[str, Any]:
"""Thin hook for the consultation response. Failures do not break the chat."""
payload = dict(body or {})
if payload.get("include_consult_card_facts") is not True:
return result
try:
from consult_card_domain_facts import build_consult_card_facts
except ImportError:
from scripts.consult_card_domain_facts import build_consult_card_facts
try:
identity = identity_from_body(payload)
cached = get_or_build(identity)
facts = build_consult_card_facts(
cached["packet"],
_requested_domains(payload),
minute_confirmed=None,
body=payload,
)
facts["timing"] = {
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
except Exception as exc:
facts = {
"schema": "consult-card-facts-v1",
"gaps": ["consult_full_data_unavailable"],
"error_type": type(exc).__name__,
}
attached = dict(result)
attached["consult_card_facts"] = facts
return attached