feat(random): multi-theme groundwork, locked reroll export parity, duplicate export fix, expanded diagnostics and test coverage

This commit is contained in:
matt 2025-09-25 15:14:15 -07:00
parent a029d430c5
commit 73685f22c8
39 changed files with 2671 additions and 271 deletions

View file

@ -12,10 +12,11 @@ import uuid
import logging
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware
from typing import Any
from typing import Any, Optional, Dict
from contextlib import asynccontextmanager
from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -116,6 +117,41 @@ def _as_int(val: str | None, default: int) -> int:
return default
RANDOM_MAX_ATTEMPTS = _as_int(os.getenv("RANDOM_MAX_ATTEMPTS"), 5)
RANDOM_TIMEOUT_MS = _as_int(os.getenv("RANDOM_TIMEOUT_MS"), 5000)
RANDOM_TELEMETRY = _as_bool(os.getenv("RANDOM_TELEMETRY"), False)
RATE_LIMIT_ENABLED = _as_bool(os.getenv("RANDOM_RATE_LIMIT"), False)
RATE_LIMIT_WINDOW_S = _as_int(os.getenv("RATE_LIMIT_WINDOW_S"), 10)
RATE_LIMIT_RANDOM = _as_int(os.getenv("RANDOM_RATE_LIMIT_RANDOM"), 10)
RATE_LIMIT_BUILD = _as_int(os.getenv("RANDOM_RATE_LIMIT_BUILD"), 10)
RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
# Simple theme input validation constraints
_THEME_MAX_LEN = 60
_THEME_ALLOWED_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -'_")
def _sanitize_theme(raw: Optional[str]) -> Optional[str]:
"""Return a sanitized theme string or None if invalid.
Rules (minimal by design):
- Strip leading/trailing whitespace
- Reject if empty after strip
- Reject if length > _THEME_MAX_LEN
- Reject if any disallowed character present
"""
if raw is None:
return None
try:
s = str(raw).strip()
except Exception:
return None
if not s:
return None
if len(s) > _THEME_MAX_LEN:
return None
for ch in s:
if ch not in _THEME_ALLOWED_CHARS:
return None
return s
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
@ -157,6 +193,102 @@ def _load_catalog_hash() -> str:
templates.env.globals["catalog_hash"] = _load_catalog_hash()
# --- Optional in-memory telemetry for Random Modes ---
_RANDOM_METRICS: dict[str, dict[str, int]] = {
"build": {"success": 0, "constraints_impossible": 0, "error": 0},
"full_build": {"success": 0, "fallback": 0, "constraints_impossible": 0, "error": 0},
"reroll": {"success": 0, "fallback": 0, "constraints_impossible": 0, "error": 0},
}
def _record_random_event(kind: str, *, success: bool = False, fallback: bool = False, constraints_impossible: bool = False, error: bool = False) -> None:
if not RANDOM_TELEMETRY:
return
try:
k = _RANDOM_METRICS.get(kind)
if not k:
return
if success:
k["success"] = int(k.get("success", 0)) + 1
if fallback:
k["fallback"] = int(k.get("fallback", 0)) + 1
if constraints_impossible:
k["constraints_impossible"] = int(k.get("constraints_impossible", 0)) + 1
if error:
k["error"] = int(k.get("error", 0)) + 1
except Exception:
pass
# --- Optional structured logging for Random Modes ---
def _log_random_event(kind: str, request: Request, status: str, **fields: Any) -> None:
if not RANDOM_STRUCTURED_LOGS:
return
try:
rid = getattr(request.state, "request_id", None)
payload = {
"event": "random_mode",
"kind": kind,
"status": status,
"request_id": rid,
"path": str(request.url.path),
"ip": _client_ip(request),
}
for k, v in (fields or {}).items():
# keep payload concise
if isinstance(v, (str, int, float, bool)) or v is None:
payload[k] = v
logging.getLogger("web.random").info(_json.dumps(payload, separators=(",", ":")))
except Exception:
# Never break a request due to logging
pass
# --- Optional in-memory rate limiting (best-effort, per-IP, per-group) ---
_RL_COUNTS: dict[tuple[str, str, int], int] = {}
def _client_ip(request: Request) -> str:
try:
ip = getattr(getattr(request, "client", None), "host", None) or request.headers.get("X-Forwarded-For")
if isinstance(ip, str) and ip.strip():
# If XFF has multiple, use first
return ip.split(",")[0].strip()
except Exception:
pass
return "unknown"
def rate_limit_check(request: Request, group: str) -> tuple[int, int] | None:
"""Check and increment rate limit for (ip, group).
Returns (remaining, reset_epoch) if enabled, else None.
Raises HTTPException(429) when exceeded.
"""
if not RATE_LIMIT_ENABLED:
return None
limit = 0
if group == "random":
limit = int(RATE_LIMIT_RANDOM)
elif group == "build":
limit = int(RATE_LIMIT_BUILD)
elif group == "suggest":
limit = int(RATE_LIMIT_SUGGEST)
if limit <= 0:
return None
win = max(1, int(RATE_LIMIT_WINDOW_S))
now = int(time.time())
window_id = now // win
reset_epoch = (window_id + 1) * win
key = (_client_ip(request), group, window_id)
count = int(_RL_COUNTS.get(key, 0)) + 1
_RL_COUNTS[key] = count
remaining = max(0, limit - count)
if count > limit:
# Too many
retry_after = max(0, reset_epoch - now)
raise HTTPException(status_code=429, detail="rate_limited", headers={
"Retry-After": str(retry_after),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_epoch),
})
return (remaining, reset_epoch)
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
_FRAGMENT_CACHE: dict[tuple[str, str], tuple[float, str]] = {}
_FRAGMENT_TTL_SECONDS = 60.0
@ -181,6 +313,61 @@ def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> s
except Exception:
return templates.get_template(template_name).render(**ctx)
# --- Session helpers for Random Modes ---
def _ensure_session(request: Request) -> tuple[str, dict[str, Any], bool]:
"""Get or create a session for the incoming request.
Returns (sid, session_dict, had_existing_cookie)
"""
sid = request.cookies.get("sid")
had_cookie = bool(sid)
if not sid:
sid = new_sid()
sess = get_session(sid)
return sid, sess, had_cookie
def _update_random_session(request: Request, *, seed: int, theme: Any, constraints: Any) -> tuple[str, bool]:
"""Update session with latest random build seed/theme/constraints and maintain a bounded recent list."""
sid, sess, had_cookie = _ensure_session(request)
rb = dict(sess.get("random_build") or {})
rb["seed"] = int(seed)
if theme is not None:
rb["theme"] = theme
if constraints is not None:
rb["constraints"] = constraints
recent = list(rb.get("recent_seeds") or [])
# Append and keep last 10 unique (most-recent-first)
recent.append(int(seed))
# Dedupe while preserving order from the right (most recent)
seen = set()
dedup_rev: list[int] = []
for s in reversed(recent):
if s in seen:
continue
seen.add(s)
dedup_rev.append(s)
dedup = list(reversed(dedup_rev))
rb["recent_seeds"] = dedup[-10:]
set_session_value(sid, "random_build", rb)
return sid, had_cookie
def _toggle_seed_favorite(sid: str, seed: int) -> list[int]:
"""Toggle a seed in the favorites list and persist. Returns updated favorites."""
sess = get_session(sid)
rb = dict(sess.get("random_build") or {})
favs = list(rb.get("favorite_seeds") or [])
if seed in favs:
favs = [s for s in favs if s != seed]
else:
favs.append(seed)
# Keep stable ordering (insertion order) and cap to last 50
favs = favs[-50:]
rb["favorite_seeds"] = favs
set_session_value(sid, "random_build", rb)
return favs
templates.env.globals["render_cached"] = render_cached
# --- Diagnostics: request-id and uptime ---
@ -241,11 +428,29 @@ async def status_sys():
"RANDOM_UI": bool(RANDOM_UI),
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
"RANDOM_TIMEOUT_MS": int(RANDOM_TIMEOUT_MS),
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
"RATE_LIMIT_WINDOW_S": int(RATE_LIMIT_WINDOW_S),
"RANDOM_RATE_LIMIT_RANDOM": int(RATE_LIMIT_RANDOM),
"RANDOM_RATE_LIMIT_BUILD": int(RATE_LIMIT_BUILD),
"RANDOM_RATE_LIMIT_SUGGEST": int(RATE_LIMIT_SUGGEST),
},
}
except Exception:
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
@app.get("/status/random_metrics")
async def status_random_metrics():
try:
if not RANDOM_TELEMETRY:
return JSONResponse({"ok": False, "error": "telemetry_disabled"}, status_code=403)
# Return a shallow copy to avoid mutation from clients
out = {k: dict(v) for k, v in _RANDOM_METRICS.items()}
return JSONResponse({"ok": True, "metrics": out})
except Exception:
return JSONResponse({"ok": False, "metrics": {}}, status_code=500)
def random_modes_enabled() -> bool:
"""Dynamic check so tests that set env after import still work.
@ -259,6 +464,9 @@ async def api_random_build(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
# Optional rate limiting (count this request per-IP)
rl = rate_limit_check(request, "build")
body = {}
try:
body = await request.json()
@ -267,6 +475,7 @@ async def api_random_build(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
seed = body.get("seed")
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
@ -277,7 +486,7 @@ async def api_random_build(request: Request):
except Exception:
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Import on-demand to avoid heavy costs at module import time
from deck_builder.random_entrypoint import build_random_deck # type: ignore
from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore
res = build_random_deck(
theme=theme,
constraints=constraints,
@ -286,7 +495,19 @@ async def api_random_build(request: Request):
timeout_s=float(timeout_s),
)
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("build", success=True)
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"build",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
)
payload = {
"seed": int(res.seed),
"commander": res.commander,
"theme": res.theme,
@ -295,10 +516,25 @@ async def api_random_build(request: Request):
"timeout_ms": int(timeout_ms),
"request_id": rid,
}
resp = JSONResponse(payload)
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
return resp
except HTTPException:
raise
except RandomConstraintsImpossibleError as ex:
_record_random_event("build", constraints_impossible=True)
_log_random_event("build", request, "constraints_impossible")
raise HTTPException(status_code=422, detail={"error": "constraints_impossible", "message": str(ex), "constraints": ex.constraints, "pool_size": ex.pool_size})
except Exception as ex:
logging.getLogger("web").error(f"random_build failed: {ex}")
_record_random_event("build", error=True)
_log_random_event("build", request, "error")
raise HTTPException(status_code=500, detail="random_build failed")
@ -308,6 +544,8 @@ async def api_random_full_build(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
rl = rate_limit_check(request, "build")
body = {}
try:
body = await request.json()
@ -316,6 +554,7 @@ async def api_random_full_build(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
seed = body.get("seed")
attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS))
@ -327,7 +566,7 @@ async def api_random_full_build(request: Request):
timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0)
# Build a full deck deterministically
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore
res = build_random_full_deck(
theme=theme,
constraints=constraints,
@ -354,8 +593,23 @@ async def api_random_full_build(request: Request):
except Exception:
permalink = None
# Persist to session (so recent seeds includes initial seed)
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("full_build", success=True, fallback=bool(getattr(res, "theme_fallback", False)))
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"full_build",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
fallback=bool(getattr(res, "theme_fallback", False)),
)
resp = JSONResponse({
"seed": int(res.seed),
"commander": res.commander,
"decklist": res.decklist or [],
@ -364,21 +618,48 @@ async def api_random_full_build(request: Request):
"permalink": permalink,
"attempts": int(attempts),
"timeout_ms": int(timeout_ms),
"diagnostics": res.diagnostics or {},
"fallback": bool(getattr(res, "theme_fallback", False)),
"original_theme": getattr(res, "original_theme", None),
"summary": getattr(res, "summary", None),
"csv_path": getattr(res, "csv_path", None),
"txt_path": getattr(res, "txt_path", None),
"compliance": getattr(res, "compliance", None),
"request_id": rid,
}
})
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except HTTPException:
raise
except RandomConstraintsImpossibleError as ex:
_record_random_event("full_build", constraints_impossible=True)
_log_random_event("full_build", request, "constraints_impossible")
raise HTTPException(status_code=422, detail={"error": "constraints_impossible", "message": str(ex), "constraints": ex.constraints, "pool_size": ex.pool_size})
except Exception as ex:
logging.getLogger("web").error(f"random_full_build failed: {ex}")
_record_random_event("full_build", error=True)
_log_random_event("full_build", request, "error")
raise HTTPException(status_code=500, detail="random_full_build failed")
@app.post("/api/random_reroll")
async def api_random_reroll(request: Request):
# Gate behind feature flag
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
t0 = time.time()
rl = rate_limit_check(request, "random")
body = {}
try:
body = await request.json()
@ -387,6 +668,7 @@ async def api_random_reroll(request: Request):
except Exception:
body = {}
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
last_seed = body.get("seed")
# Simple deterministic reroll policy: increment prior seed when provided; else generate fresh
@ -431,8 +713,24 @@ async def api_random_reroll(request: Request):
except Exception:
permalink = None
# Persist in session and set sid cookie if we just created it
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
rid = getattr(request.state, "request_id", None)
return {
_record_random_event("reroll", success=True, fallback=bool(getattr(res, "theme_fallback", False)))
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"reroll",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(attempts),
timeout_ms=int(timeout_ms),
elapsed_ms=elapsed_ms,
prev_seed=(int(last_seed) if isinstance(last_seed, int) or (isinstance(last_seed, str) and str(last_seed).isdigit()) else None),
fallback=bool(getattr(res, "theme_fallback", False)),
)
resp = JSONResponse({
"previous_seed": (int(last_seed) if isinstance(last_seed, int) or (isinstance(last_seed, str) and str(last_seed).isdigit()) else None),
"seed": int(res.seed),
"commander": res.commander,
@ -442,12 +740,29 @@ async def api_random_reroll(request: Request):
"permalink": permalink,
"attempts": int(attempts),
"timeout_ms": int(timeout_ms),
"diagnostics": res.diagnostics or {},
"summary": getattr(res, "summary", None),
"request_id": rid,
}
})
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except HTTPException:
raise
except Exception as ex:
logging.getLogger("web").error(f"random_reroll failed: {ex}")
_record_random_event("reroll", error=True)
_log_random_event("reroll", request, "error")
raise HTTPException(status_code=500, detail="random_reroll failed")
@ -456,16 +771,39 @@ async def hx_random_reroll(request: Request):
# Small HTMX endpoint returning a partial HTML fragment for in-page updates
if not RANDOM_UI or not RANDOM_MODES:
raise HTTPException(status_code=404, detail="Random UI disabled")
body = {}
rl = rate_limit_check(request, "random")
body: Dict[str, Any] = {}
raw_text = ""
# Primary: attempt JSON
try:
body = await request.json()
if not isinstance(body, dict):
body = {}
except Exception:
body = {}
# Fallback: form/urlencoded (htmx default) or stray query-like payload
if not body:
try:
raw_bytes = await request.body()
raw_text = raw_bytes.decode("utf-8", errors="ignore")
from urllib.parse import parse_qs
parsed = parse_qs(raw_text, keep_blank_values=True)
flat: Dict[str, Any] = {}
for k, v in parsed.items():
if not v:
continue
flat[k] = v[0] if len(v) == 1 else v
body = flat or {}
except Exception:
body = {}
last_seed = body.get("seed")
mode = body.get("mode") # "surprise" (default) vs "reroll_same_commander"
locked_commander = body.get("commander") if mode == "reroll_same_commander" else None
theme = body.get("theme")
theme = _sanitize_theme(theme)
constraints = body.get("constraints")
attempts_override = body.get("attempts")
timeout_ms_override = body.get("timeout_ms")
try:
new_seed = int(last_seed) + 1 if last_seed is not None else None
except Exception:
@ -473,19 +811,167 @@ async def hx_random_reroll(request: Request):
if new_seed is None:
from random_util import generate_seed # type: ignore
new_seed = int(generate_seed())
# Import outside conditional to avoid UnboundLocalError when branch not taken
from deck_builder.random_entrypoint import build_random_full_deck # type: ignore
res = build_random_full_deck(
theme=theme,
constraints=constraints,
seed=new_seed,
attempts=int(RANDOM_MAX_ATTEMPTS),
timeout_s=float(RANDOM_TIMEOUT_MS) / 1000.0,
)
try:
t0 = time.time()
_attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS)
try:
_timeout_ms = int(timeout_ms_override) if timeout_ms_override is not None else int(RANDOM_TIMEOUT_MS)
except Exception:
_timeout_ms = int(RANDOM_TIMEOUT_MS)
_timeout_s = max(0.1, float(_timeout_ms) / 1000.0)
if locked_commander:
build_t0 = time.time()
from headless_runner import run as _run # type: ignore
# Suppress builder's internal initial export to control artifact generation (matches full random path logic)
try:
import os as _os
if _os.getenv('RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT') is None:
_os.environ['RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT'] = '1'
except Exception:
pass
builder = _run(command_name=str(locked_commander), seed=new_seed)
elapsed_ms = int(round((time.time() - build_t0) * 1000))
summary = None
try:
if hasattr(builder, 'build_deck_summary'):
summary = builder.build_deck_summary() # type: ignore[attr-defined]
except Exception:
summary = None
decklist = []
try:
if hasattr(builder, 'deck_list_final'):
decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined]
except Exception:
decklist = []
# Controlled artifact export (single pass)
csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined]
txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined]
compliance = None
try:
import os as _os
import json as _json
# Perform exactly one export sequence now
if not csv_path and hasattr(builder, 'export_decklist_csv'):
try:
csv_path = builder.export_decklist_csv() # type: ignore[attr-defined]
except Exception:
csv_path = None
if csv_path and isinstance(csv_path, str):
base_path, _ = _os.path.splitext(csv_path)
# Ensure txt exists (create if missing)
if (not txt_path or not _os.path.isfile(str(txt_path))):
try:
base_name = _os.path.basename(base_path) + '.txt'
if hasattr(builder, 'export_decklist_text'):
txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined]
except Exception:
# Fallback: if a txt already exists from a prior build reuse it
if _os.path.isfile(base_path + '.txt'):
txt_path = base_path + '.txt'
comp_path = base_path + '_compliance.json'
if _os.path.isfile(comp_path):
try:
with open(comp_path, 'r', encoding='utf-8') as _cf:
compliance = _json.load(_cf)
except Exception:
compliance = None
else:
try:
if hasattr(builder, 'compute_and_print_compliance'):
compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined]
except Exception:
compliance = None
if summary:
sidecar = base_path + '.summary.json'
if not _os.path.isfile(sidecar):
meta = {
"commander": getattr(builder, 'commander_name', '') or getattr(builder, 'commander', ''),
"tags": list(getattr(builder, 'selected_tags', []) or []) or [t for t in [getattr(builder, 'primary_tag', None), getattr(builder, 'secondary_tag', None), getattr(builder, 'tertiary_tag', None)] if t],
"bracket_level": getattr(builder, 'bracket_level', None),
"csv": csv_path,
"txt": txt_path,
"random_seed": int(new_seed),
"random_theme": theme,
"random_constraints": constraints or {},
"locked_commander": True,
}
try:
custom_base = getattr(builder, 'custom_export_base', None)
except Exception:
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
try:
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump({"meta": meta, "summary": summary}, f, ensure_ascii=False, indent=2)
except Exception:
pass
except Exception:
compliance = None
class _Res: # minimal object with expected attrs
pass
res = _Res()
res.seed = int(new_seed)
res.commander = locked_commander
res.theme = theme
res.constraints = constraints or {}
res.diagnostics = {"locked_commander": True, "attempts": 1, "elapsed_ms": elapsed_ms}
res.summary = summary
res.decklist = decklist
res.csv_path = csv_path
res.txt_path = txt_path
res.compliance = compliance
else:
res = build_random_full_deck(
theme=theme,
constraints=constraints,
seed=new_seed,
attempts=int(_attempts),
timeout_s=float(_timeout_s),
)
except Exception as ex:
# Map constraints-impossible to a friendly fragment; other errors to a plain note
msg = ""
if ex.__class__.__name__ == "RandomConstraintsImpossibleError":
_record_random_event("reroll", constraints_impossible=True)
_log_random_event("reroll", request, "constraints_impossible")
msg = "<div class=\"error\">Constraints impossible — try loosening filters.</div>"
else:
_record_random_event("reroll", error=True)
_log_random_event("reroll", request, "error")
msg = "<div class=\"error\">Reroll failed. Please try again.</div>"
return HTMLResponse(msg, status_code=200)
# Persist to session
sid, had_cookie = _update_random_session(request, seed=int(res.seed), theme=res.theme, constraints=res.constraints or {})
# Render minimal fragment via Jinja2
try:
return templates.TemplateResponse(
elapsed_ms = int(round((time.time() - t0) * 1000))
_log_random_event(
"reroll",
request,
"success",
seed=int(res.seed),
theme=(res.theme or None),
attempts=int(RANDOM_MAX_ATTEMPTS),
timeout_ms=int(RANDOM_TIMEOUT_MS),
elapsed_ms=elapsed_ms,
)
# Build permalink token for fragment copy button
try:
import base64 as _b64
_raw = _json.dumps({
"commander": res.commander,
"random": {"seed": int(res.seed), "theme": res.theme, "constraints": res.constraints or {}},
}, separators=(",", ":"))
_token = _b64.urlsafe_b64encode(_raw.encode("utf-8")).decode("ascii").rstrip("=")
_permalink = f"/build/from?state={_token}"
except Exception:
_permalink = None
resp = templates.TemplateResponse(
"partials/random_result.html", # type: ignore
{
"request": request,
@ -494,20 +980,91 @@ async def hx_random_reroll(request: Request):
"decklist": res.decklist or [],
"theme": res.theme,
"constraints": res.constraints or {},
"diagnostics": res.diagnostics or {},
"permalink": _permalink,
"show_diagnostics": SHOW_DIAGNOSTICS,
"fallback": bool(getattr(res, "theme_fallback", False)),
"summary": getattr(res, "summary", None),
},
)
if rl:
remaining, reset_epoch = rl
try:
resp.headers["X-RateLimit-Remaining"] = str(remaining)
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
except Exception:
pass
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
except Exception as ex:
logging.getLogger("web").error(f"hx_random_reroll template error: {ex}")
# Fallback to JSON to avoid total failure
return JSONResponse(
resp = JSONResponse(
{
"seed": int(res.seed),
"commander": res.commander,
"decklist": res.decklist or [],
"theme": res.theme,
"constraints": res.constraints or {},
"diagnostics": res.diagnostics or {},
}
)
if not had_cookie:
try:
resp.set_cookie("sid", sid, max_age=60*60*8, httponly=True, samesite="lax")
except Exception:
pass
return resp
@app.get("/api/random/seeds")
async def api_random_recent_seeds(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
sid, sess, _ = _ensure_session(request)
rb = sess.get("random_build") or {}
seeds = list(rb.get("recent_seeds") or [])
last = rb.get("seed")
favorites = list(rb.get("favorite_seeds") or [])
rid = getattr(request.state, "request_id", None)
return {"seeds": seeds, "last": last, "favorites": favorites, "request_id": rid}
@app.post("/api/random/seed_favorite")
async def api_random_seed_favorite(request: Request):
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
sid, sess, _ = _ensure_session(request)
try:
body = await request.json()
if not isinstance(body, dict):
body = {}
except Exception:
body = {}
seed = body.get("seed")
try:
seed_int = int(seed)
except Exception:
raise HTTPException(status_code=400, detail="invalid seed")
favs = _toggle_seed_favorite(sid, seed_int)
rid = getattr(request.state, "request_id", None)
return {"ok": True, "favorites": favs, "request_id": rid}
@app.get("/status/random_metrics_ndjson")
async def status_random_metrics_ndjson():
if not RANDOM_TELEMETRY:
return PlainTextResponse("{}\n", media_type="application/x-ndjson")
lines = []
try:
for kind, buckets in _RANDOM_METRICS.items():
rec = {"kind": kind}
rec.update(buckets)
lines.append(_json.dumps(rec, separators=(",", ":")))
except Exception:
lines.append(_json.dumps({"error": True}))
return PlainTextResponse("\n".join(lines) + "\n", media_type="application/x-ndjson")
# Logs tail endpoint (read-only)
@app.get("/status/logs")
@ -620,18 +1177,35 @@ async def http_exception_handler(request: Request, exc: HTTPException):
# Friendly HTML page
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers=headers)
except Exception:
# Fallback plain text
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers=headers)
# JSON structure for HTMX/API
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
}, headers=headers)
# Also handle Starlette's HTTPException (e.g., 404 route not found)
@ -644,16 +1218,34 @@ async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPE
if _wants_html(request):
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers=headers)
except Exception:
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers=headers)
headers = {"X-Request-ID": rid}
try:
if getattr(exc, "headers", None):
headers.update(exc.headers) # type: ignore[arg-type]
except Exception:
pass
return JSONResponse(status_code=exc.status_code, content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
}, headers=headers)
@app.exception_handler(Exception)
@ -675,6 +1267,13 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
"path": str(request.url.path),
}, headers={"X-Request-ID": rid})
# --- Random Modes page (minimal shell) ---
@app.get("/random", response_class=HTMLResponse)
async def random_modes_page(request: Request) -> HTMLResponse:
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
return templates.TemplateResponse("random/index.html", {"request": request, "random_ui": bool(RANDOM_UI)})
# Lightweight file download endpoint for exports
@app.get("/files")
async def get_file(path: str):