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.
This commit is contained in:
@@ -227,6 +227,58 @@ def _d9(view: dict, name: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
return sign, dignity
|
||||
|
||||
|
||||
def _house_aspect_rows(view: dict) -> list[dict] | None:
|
||||
rows = dig(view, "worksheets.graha_drishti.house_aspects")
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _aspect_targets(row: dict) -> list[int]:
|
||||
targets = row.get("aspects_to")
|
||||
if not isinstance(targets, dict):
|
||||
return []
|
||||
numbers = []
|
||||
for key in targets:
|
||||
try:
|
||||
numbers.append(int(key))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return numbers
|
||||
|
||||
|
||||
def _aspected_by(view: dict, house: Any) -> dict[str, Any]:
|
||||
path = "worksheets.graha_drishti.house_aspects"
|
||||
rows = _house_aspect_rows(view)
|
||||
if rows is None or not isinstance(house, int):
|
||||
return _gap(path)
|
||||
names = []
|
||||
for row in rows:
|
||||
planet = row.get("planet")
|
||||
if isinstance(planet, str) and house in _aspect_targets(row) and planet not in names:
|
||||
names.append(planet)
|
||||
return {"status": "value", "path": path, "value": names, "check": "selection"}
|
||||
|
||||
|
||||
def _aspects_houses(view: dict, name: str) -> dict[str, Any]:
|
||||
path = "worksheets.graha_drishti.house_aspects"
|
||||
rows = _house_aspect_rows(view)
|
||||
if rows is None:
|
||||
return _gap(path)
|
||||
for row in rows:
|
||||
if row.get("planet") == name:
|
||||
return {"status": "value", "path": path, "value": _aspect_targets(row), "check": "selection"}
|
||||
return {"status": "value", "path": path, "value": [], "check": "selection"}
|
||||
|
||||
|
||||
def _parashari_aspects(view: dict) -> dict[str, Any]:
|
||||
citation = "references/signs-and-houses.md §六 行星相位"
|
||||
leaf = _leaf(view, "worksheets.graha_drishti.house_aspects")
|
||||
if leaf["status"] != "value":
|
||||
return {"id": "parashari_aspects", "status": "gap", "citation": citation, "check": "path", "path": leaf.get("path")}
|
||||
return _sourced("parashari_aspects", citation, leaf)
|
||||
|
||||
|
||||
def _co_tenants(view: dict, name: str, house: Any) -> dict[str, Any]:
|
||||
if not isinstance(house, int):
|
||||
return {"status": "gap", "check": "selection"}
|
||||
@@ -254,7 +306,8 @@ def _planet_record(view: dict, name: str, varga: str | None, minute_ok: bool) ->
|
||||
"d1_dignity": _leaf(view, f"{base}.status"),
|
||||
"rasi_lord": _leaf(view, f"{base}.rasi_lord"),
|
||||
"combust": _leaf(view, f"{base}.combust"),
|
||||
"aspected_by": _leaf(view, f"{base}.aspected_by"),
|
||||
"aspected_by": _aspected_by(view, house),
|
||||
"aspects_houses": _aspects_houses(view, name),
|
||||
"functional_role": _functional_role(view, name),
|
||||
"shadbala_rupas": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.total_rupas"),
|
||||
"shadbala_min": _leaf(view, f"worksheets.strengths_and_scores.shadbala.planets.{name}.min_required"),
|
||||
@@ -287,6 +340,7 @@ def _house_record(view: dict, number: int) -> dict[str, Any]:
|
||||
f"worksheets.strengths_and_scores.shadbala.bhava_bala.{number}",
|
||||
]),
|
||||
"occupants": {"status": "value", "check": "selection", "value": occupants},
|
||||
"aspected_by": _aspected_by(view, number),
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +420,7 @@ def domain_source_catalog() -> dict[str, list[dict[str, str]]]:
|
||||
("vimsopaka", "sourced", f"{DEEP} Vimsopaka"),
|
||||
("ashtakavarga", "sourced", f"{ROUTER} §2 Ashtakavarga"),
|
||||
("functional", "sourced", f"{ROUTER} §2 Functional benefic/malefic"),
|
||||
("parashari_aspects", "blocked", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
|
||||
("parashari_aspects", "sourced", "references/signs-and-houses.md §六 行星相位"),
|
||||
("jaimini_aspects", "sourced", f"{JAIMINI} Jaimini Drishti"),
|
||||
("argala", "sourced", "references/argala-complete-guide.md"),
|
||||
("dispositor_chain", "sourced", f"{ROUTER} §2 dispositor chain"),
|
||||
@@ -529,7 +583,7 @@ def _shared_participation(view: dict, minute_ok: bool) -> list[dict[str, Any]]:
|
||||
_sourced("vimsopaka", f"{DEEP} Vimsopaka", _leaf(view, "worksheets.strengths_and_scores.vimsopaka")),
|
||||
_sourced("ashtakavarga", f"{ROUTER} §2 Ashtakavarga", _leaf(view, "worksheets.strengths_and_scores.ashtakavarga")),
|
||||
_sourced("functional", f"{ROUTER} §2 Functional benefic/malefic", _leaf(view, "worksheets.strengths_and_scores.functional_benefic_malefic")),
|
||||
_blocked("parashari_aspects", f"{ROUTER} 未找到把 Parashari 星照单独列为参与层的段落"),
|
||||
_parashari_aspects(view),
|
||||
_sourced("jaimini_aspects", f"{JAIMINI} Jaimini Drishti", _first(view, [
|
||||
"worksheets.advanced_systems.jaimini.rashi_aspects",
|
||||
"worksheets.advanced_systems.jaimini.drishti",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Cache the full-data report packet for the consult card.
|
||||
|
||||
The key is chart identity + ayanamsa + node mode + engine version.
|
||||
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
|
||||
@@ -9,13 +11,15 @@ 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-v1"
|
||||
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]]
|
||||
|
||||
@@ -41,7 +45,19 @@ def _number(value: Any) -> int | float:
|
||||
return number
|
||||
|
||||
|
||||
def identity_from_body(body: dict[str, Any]) -> dict[str, Any]:
|
||||
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")
|
||||
@@ -60,6 +76,7 @@ def identity_from_body(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"ayanamsa": str(ayanamsa),
|
||||
"node_mode": str(node or "mean"),
|
||||
"engine_version": engine_version(),
|
||||
"reference_date": reference_date_from_body(body, today=today),
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +89,21 @@ 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,
|
||||
@@ -79,6 +111,7 @@ def get_or_build(
|
||||
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)
|
||||
@@ -101,14 +134,29 @@ def get_or_build(
|
||||
}
|
||||
|
||||
|
||||
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 datetime import date
|
||||
|
||||
from calculation_profile_contract import attach_calculation_profile
|
||||
from jyotish_engine import build_pl9_style_export_packet, cmd_full_reading
|
||||
|
||||
today = date.today()
|
||||
ref = date.fromisoformat(str(identity["reference_date"]))
|
||||
args = SimpleNamespace(
|
||||
year=int(identity["year"]),
|
||||
month=int(identity["month"]),
|
||||
@@ -121,19 +169,41 @@ def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
|
||||
tz=float(identity["tz"]),
|
||||
ayanamsa=identity["ayanamsa"],
|
||||
node_mode=identity["node_mode"],
|
||||
today=today.isoformat(),
|
||||
target_year=today.year,
|
||||
age=today.year - int(identity["year"]),
|
||||
transit_date=today.isoformat(),
|
||||
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")
|
||||
|
||||
@@ -4331,16 +4331,23 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
})
|
||||
|
||||
def _compute_consultation_workflow(self, body):
|
||||
payload = dict(body or {})
|
||||
if payload.get('consult_card_warm_only') is True:
|
||||
try:
|
||||
from scripts.consult_full_data_cache import warm_consult_packet
|
||||
except ImportError:
|
||||
from consult_full_data_cache import warm_consult_packet
|
||||
return warm_consult_packet(payload)
|
||||
result = execute_consultation_workflow(
|
||||
self,
|
||||
body=dict(body or {}),
|
||||
surface=body.get('surface', 'api_web'),
|
||||
body=payload,
|
||||
surface=payload.get('surface', 'api_web'),
|
||||
)
|
||||
try:
|
||||
from scripts.consult_full_data_cache import attach_consult_full_data
|
||||
except ImportError:
|
||||
from consult_full_data_cache import attach_consult_full_data
|
||||
return attach_consult_full_data(result, body or {})
|
||||
return attach_consult_full_data(result, payload)
|
||||
|
||||
def _build_audited_remedies_from_guided_topics(self, guided_topics):
|
||||
if not isinstance(guided_topics, list):
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Freeze consult-card facts from the real engine.
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
python scripts/research/freeze_consult_card_facts_fixture.py
|
||||
|
||||
The reference date is pinned to 2026-09-27 so the fixture matches the
|
||||
biography golden. The file is the server copy (paths included). The model
|
||||
card drops those paths.
|
||||
|
||||
python scripts/research/freeze_consult_card_facts_fixture.py --biography-out <path>
|
||||
|
||||
also writes the nine public biography charts for parents, marriage, health
|
||||
and career. That file stays outside the repo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from consult_card_domain_facts import CONSULTATION_DOMAINS, build_domain_facts # noqa: E402
|
||||
from consult_full_data_cache import get_or_build, identity_from_body # noqa: E402
|
||||
|
||||
PUBLIC = ROOT / "references" / "public_oracle_cases.json"
|
||||
TAYLOR = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json"
|
||||
FICTIONAL = ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json"
|
||||
BIOGRAPHY = ROOT / "frontend" / "tests" / "fixtures" / "consult-biography-backtest-golden.json"
|
||||
OUT = ROOT / "frontend" / "tests" / "fixtures" / "consult-card-facts-20261009.json"
|
||||
REFERENCE_DATE = "2026-09-27"
|
||||
BIOGRAPHY_DOMAINS = ("parents", "marriage", "health", "career")
|
||||
|
||||
|
||||
def _body_from_parts(year, month, day, hour, minute, second, lat, lon, tz) -> dict:
|
||||
return {
|
||||
"year": int(year),
|
||||
"month": int(month),
|
||||
"day": int(day),
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"second": second,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"tz": tz,
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"today": REFERENCE_DATE,
|
||||
}
|
||||
|
||||
|
||||
def _public(case_id: str) -> dict:
|
||||
data = json.loads(PUBLIC.read_text(encoding="utf-8"))
|
||||
birth = next(case["birth"] for case in data["cases"] if case.get("id") == case_id)
|
||||
return _body_from_parts(
|
||||
birth["year"], birth["month"], birth["day"],
|
||||
birth["hour"], birth["minute"], birth.get("second", 0),
|
||||
birth["lat"], birth["lon"], birth["tz"],
|
||||
)
|
||||
|
||||
|
||||
def _taylor() -> dict:
|
||||
data = json.loads(TAYLOR.read_text(encoding="utf-8"))
|
||||
birth = next(case["birth"] for case in data["cases"] if case.get("case_id") == "elizabeth_taylor_1932_aa_v4_holdout")
|
||||
year, month, day = (int(part) for part in str(birth["date"]).split("-"))
|
||||
clock = str(birth["time"]).split(":")
|
||||
return _body_from_parts(
|
||||
year, month, day,
|
||||
int(clock[0]), int(clock[1]), int(clock[2]) if len(clock) > 2 else 0,
|
||||
birth["latitude"], birth["longitude"], birth["timezone_offset"],
|
||||
)
|
||||
|
||||
|
||||
def _fictional() -> dict:
|
||||
data = json.loads(FICTIONAL.read_text(encoding="utf-8"))
|
||||
birth = data["cases"]["fictional_reader_main"]["birth"]
|
||||
return _body_from_parts(
|
||||
birth["year"], birth["month"], birth["day"],
|
||||
birth["hour"], birth["minute"], birth.get("second", 0),
|
||||
birth["lat"], birth["lon"], birth["tz"],
|
||||
)
|
||||
|
||||
|
||||
def _zone(value) -> float:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip()
|
||||
if text.upper().startswith("UTC"):
|
||||
text = text[3:]
|
||||
text = text.replace("+-", "+").replace("-+", "-")
|
||||
return float(text)
|
||||
|
||||
|
||||
def _biography_bodies() -> list[tuple[str, dict]]:
|
||||
data = json.loads(BIOGRAPHY.read_text(encoding="utf-8"))
|
||||
bodies = []
|
||||
for figure in data["figures"]:
|
||||
birth = figure["shared_chart"]["birth"]
|
||||
year, month, day = (int(part) for part in str(birth["date"]).split("-"))
|
||||
bodies.append((figure["id"], _body_from_parts(
|
||||
year, month, day,
|
||||
birth["hour"], birth["minute"], birth.get("second", 0),
|
||||
birth["lat"], birth["lon"], _zone(birth["tz"]),
|
||||
)))
|
||||
return bodies
|
||||
|
||||
|
||||
def _charts(pairs: list[tuple[str, dict]], domains: tuple[str, ...]) -> dict:
|
||||
charts = {}
|
||||
for label, body in pairs:
|
||||
cached = get_or_build(identity_from_body(body))
|
||||
built = {
|
||||
domain: build_domain_facts(cached["packet"], domain, minute_confirmed=True)
|
||||
for domain in domains
|
||||
}
|
||||
charts[label] = {"hit": cached["hit"], "elapsed_s": round(cached["elapsed_s"], 3), "domains": built}
|
||||
print(f"{label} hit={cached['hit']} packet_s={cached['elapsed_s']:.3f}", flush=True)
|
||||
return charts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--biography-out")
|
||||
args = parser.parse_args()
|
||||
charts = _charts([
|
||||
("jobs", _public("steve_jobs_1955_aa")),
|
||||
("obama", _public("barack_obama_1961_aa")),
|
||||
("taylor", _taylor()),
|
||||
("fictional", _fictional()),
|
||||
], CONSULTATION_DOMAINS)
|
||||
payload = {
|
||||
"source": "scripts/research/freeze_consult_card_facts_fixture.py",
|
||||
"command": "python scripts/research/freeze_consult_card_facts_fixture.py",
|
||||
"reference_date": REFERENCE_DATE,
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"note": "Real engine packets. Server copy keeps paths. The model card must not.",
|
||||
"charts": charts,
|
||||
}
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
print(OUT, flush=True)
|
||||
if args.biography_out:
|
||||
biography = {
|
||||
"source": "scripts/research/freeze_consult_card_facts_fixture.py",
|
||||
"reference_date": REFERENCE_DATE,
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"figures": _charts(_biography_bodies(), BIOGRAPHY_DOMAINS),
|
||||
}
|
||||
destination = Path(args.biography_out)
|
||||
destination.write_text(json.dumps(biography, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
print(destination, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user