"""Disk cache for foreground VedAstro gateway snapshots. Keyed by birth data + ayanamsa + node + UTC reference date. This is a separate store from ``_api_chart_cache`` (BUG-161): different directory and a different key function. Freshness is the reference date itself plus a 7-day stale window — there is no independent TTL environment variable. Filenames are sha256 hex only. Cache files must not contain names, emails, or user ids. """ from __future__ import annotations import hashlib import json import os import re import tempfile from datetime import datetime, timedelta from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] CACHE_SCHEMA = "vedastro_snapshot_cache.v1" MAX_STALE_DAYS = 7 _SHA256_NAME = re.compile(r"^[0-9a-f]{64}\.json$") _IDENTITY_KEYS = { "name", "email", "user_id", "userid", "user_email", "session_id", "sessionid", "full_name", "display_name", } def snapshot_cache_dir() -> Path: raw = str(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR") or "").strip() path = Path(raw) if raw else ROOT / "scratch" / "local" / "vedastro_snapshot_cache" path.mkdir(parents=True, exist_ok=True) return path def official_snapshot_reference_date(case: dict[str, Any] | None) -> str: """Same date rule as ``vedastro_service_adapter._official_snapshot_reference_date``. Duplicated so this module does not import the adapter (and its optional network stack) at cache-lookup time. """ payload = case if isinstance(case, dict) else {} for key in ("reference_date", "today", "transit_date", "current_date"): value = payload.get(key) if not value: continue raw = str(value)[:10] try: datetime.strptime(raw, "%Y-%m-%d") return raw except ValueError: continue return datetime.utcnow().strftime("%Y-%m-%d") def requires_today_snapshot(body: dict[str, Any] | None) -> bool: payload = body if isinstance(body, dict) else {} entrypoint = str(payload.get("entrypoint") or payload.get("consult_entrypoint") or "").strip() return entrypoint == "daily_starlanguage" def snapshot_cache_key(body: dict[str, Any] | None, *, reference_date: str) -> str: payload = body if isinstance(body, dict) else {} ayanamsa = ( payload.get("ayanamsa") or payload.get("ayanamsa_name") or payload.get("ayanamsa_policy") or "raman" ) node = payload.get("node_mode") or payload.get("nodeMode") or "mean" material = { "year": payload.get("year"), "month": payload.get("month"), "day": payload.get("day"), "hour": payload.get("hour"), "minute": payload.get("minute"), "second": payload.get("second", 0), "lat": payload.get("lat"), "lon": payload.get("lon"), "tz": payload.get("tz"), "ayanamsa_policy": str(ayanamsa).strip().lower(), "node_policy": str(node).strip().lower(), "reference_date": str(reference_date)[:10], } canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def snapshot_cache_path(cache_key: str) -> Path: if not re.fullmatch(r"[0-9a-f]{64}", cache_key): raise ValueError("vedastro snapshot cache key must be sha256 hex") return snapshot_cache_dir() / f"{cache_key}.json" def is_cacheable_gateway(packet: Any) -> bool: if not isinstance(packet, dict): return False state = packet.get("official_closure_state") or packet.get("status") if state != "official_verified": return False raw = packet.get("official_raw_response") or packet.get("raw_response") return bool(raw) def _strip_identity(value: Any) -> Any: if isinstance(value, dict): cleaned = {} for key, item in value.items(): if str(key).strip().lower() in _IDENTITY_KEYS: continue cleaned[key] = _strip_identity(item) return cleaned if isinstance(value, list): return [_strip_identity(item) for item in value] return value def _load_record(cache_key: str) -> dict[str, Any] | None: path = snapshot_cache_path(cache_key) if not _SHA256_NAME.match(path.name) or not path.is_file(): return None try: record = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(record, dict) or record.get("schema") != CACHE_SCHEMA: return None gateway = record.get("gateway") if not isinstance(gateway, dict): return None reference_date = str(record.get("reference_date") or "")[:10] try: datetime.strptime(reference_date, "%Y-%m-%d") except ValueError: return None return record def lookup_snapshot( body: dict[str, Any] | None, *, today: str | None = None, require_today: bool | None = None, ) -> dict[str, Any] | None: payload = body if isinstance(body, dict) else {} served = today or official_snapshot_reference_date(payload) must_be_today = requires_today_snapshot(payload) if require_today is None else bool(require_today) today_key = snapshot_cache_key(payload, reference_date=served) record = _load_record(today_key) if record is not None: return { "freshness": "fresh", "record": record, "served_on_utc_date": served, } if must_be_today: return None current = datetime.strptime(served, "%Y-%m-%d").date() for days in range(1, MAX_STALE_DAYS + 1): past = (current - timedelta(days=days)).isoformat() record = _load_record(snapshot_cache_key(payload, reference_date=past)) if record is None: continue return { "freshness": "stale", "record": record, "served_on_utc_date": served, } return None def store_snapshot( body: dict[str, Any] | None, gateway: dict[str, Any], *, reference_date: str | None = None, ) -> dict[str, Any] | None: if not is_cacheable_gateway(gateway): return None payload = body if isinstance(body, dict) else {} stored_date = (reference_date or official_snapshot_reference_date(payload))[:10] cache_key = snapshot_cache_key(payload, reference_date=stored_date) record = { "schema": CACHE_SCHEMA, "cache_key": cache_key, "reference_date": stored_date, "stored_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "gateway": _strip_identity(gateway), } path = snapshot_cache_path(cache_key) path.parent.mkdir(parents=True, exist_ok=True) encoded = json.dumps(record, ensure_ascii=False, sort_keys=True) fd, tmp_name = tempfile.mkstemp(prefix=f"{cache_key}.", suffix=".tmp", dir=str(path.parent)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(encoded) os.replace(tmp_name, path) except Exception: try: os.unlink(tmp_name) except OSError: pass raise return record def annotate_stale_gateway(gateway: dict[str, Any], *, reference_date: str, served_on_utc_date: str) -> dict[str, Any]: """Shallow copy that shows which day a stale snapshot is from. Does not change ``official_closure_state`` / ``status`` — a previously delivered official layer stays whatever it was (BUG-301). """ packet = dict(gateway) packet["snapshot_reference_date"] = reference_date packet["snapshot_freshness"] = "stale" packet["snapshot_served_on_utc_date"] = served_on_utc_date return packet