BUG-727:同日 VedAstro 快照零等待,跨日先用旧的并后台刷新;join 超时必须 cancel,budget 不超过 2×join。BUG-728:western_evidence_packet 无读取点,默认不再进咨询响应。jyotish_api_server.py 未增长(11334→11291)。
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
"""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
|