fix(consult): limit career direction and single-flight chart warm

The career checklist now says the direction section only covers strength, resistance, timing, and three events. Warm builds one packet per cache key and skips when its own cap is full.

A three-repeat biography run still has one career answer that names a direction from a planet nature, so this is not accepted. Not pushed.
This commit is contained in:
jesse-ux
2026-10-09 15:53:51 +08:00
parent b4eedb7725
commit 85c3030e96
8 changed files with 336 additions and 44 deletions
+108 -29
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import hashlib
import json
import threading
import time
from datetime import date
from pathlib import Path
@@ -23,6 +24,38 @@ KEEP_SECONDS = 2 * 24 * 60 * 60
PacketBuilder = Callable[[dict[str, Any]], dict[str, Any]]
# One build per cache key. A later caller waits and reads the stored packet.
class _Flight:
def __init__(self) -> None:
self.done = threading.Event()
self.error: BaseException | None = None
_flights: dict[str, _Flight] = {}
_flights_guard = threading.Lock()
# Chart-save warm has its own cap. When it is full the warm is skipped.
# A user request does not take this slot and does not receive 429 from it.
WARM_CONCURRENCY_LIMIT = 1
_warm_guard = threading.Lock()
_warm_inflight = 0
def _acquire_warm_slot() -> bool:
global _warm_inflight
with _warm_guard:
if _warm_inflight >= WARM_CONCURRENCY_LIMIT:
return False
_warm_inflight += 1
return True
def _release_warm_slot() -> None:
global _warm_inflight
with _warm_guard:
if _warm_inflight > 0:
_warm_inflight -= 1
def cache_dir() -> Path:
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
@@ -104,34 +137,63 @@ def prune_cache(now: float | None = None) -> int:
return removed
def _read_cached(path: Path, started: float, now: Callable[[], float]) -> dict[str, Any]:
packet = json.loads(path.read_text(encoding="utf-8"))
return {"packet": packet, "hit": True, "elapsed_s": now() - started, "slow": False}
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."""
"""Return the packet. Concurrent callers for one key share one build."""
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"))
return _read_cached(path, started, now)
key = cache_key(identity)
with _flights_guard:
flight = _flights.get(key)
owner = flight is None
if owner:
flight = _Flight()
_flights[key] = flight
assert flight is not None
if not owner:
flight.done.wait()
if flight.error is not None:
raise flight.error
if path.is_file():
return _read_cached(path, started, now)
raise RuntimeError("consult_full_data_unavailable")
try:
if path.is_file():
return _read_cached(path, started, now)
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": 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,
}
return {
"packet": packet,
"hit": False,
"elapsed_s": elapsed,
"slow": elapsed > SLOW_MISS_SECONDS,
}
except Exception as exc:
flight.error = exc
raise
finally:
flight.done.set()
with _flights_guard:
if _flights.get(key) is flight:
_flights.pop(key, None)
def _attach_graha_drishti(packet: dict[str, Any], reading: dict[str, Any]) -> dict[str, Any]:
@@ -186,22 +248,39 @@ def build_full_data_packet(identity: dict[str, Any]) -> dict[str, Any]:
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:
"""Build today's packet after a chart save. The response stays small.
At most WARM_CONCURRENCY_LIMIT warms run at once. A full cap skips this
warm. The skip is not a user-request 429, and a failure here does not
fail the chart save (the save route does not wait on this response).
"""
if not _acquire_warm_slot():
return {
"success": False,
"success": True,
"endpoint": "consult_card_warm",
"error_type": type(exc).__name__,
"skipped": True,
"hit": False,
"elapsed_s": 0.0,
"slow": False,
}
return {
"success": True,
"endpoint": "consult_card_warm",
"hit": bool(cached["hit"]),
"elapsed_s": cached["elapsed_s"],
"slow": bool(cached["slow"]),
}
try:
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"]),
}
finally:
_release_warm_slot()
def _requested_domains(body: dict[str, Any]) -> list[str]: