mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 07:30:13 +01:00
892 lines
36 KiB
Python
892 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime as _dt
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any
|
|
|
|
from fastapi import APIRouter, Request, HTTPException, Query
|
|
from fastapi import BackgroundTasks
|
|
from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore
|
|
from fastapi.responses import JSONResponse, HTMLResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from ..services.theme_catalog_loader import (
|
|
load_index,
|
|
project_detail,
|
|
slugify,
|
|
filter_slugs_fast,
|
|
summaries_for_slugs,
|
|
)
|
|
from ..services.theme_preview import get_theme_preview # type: ignore
|
|
from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore
|
|
from ..services.theme_preview import preview_metrics # type: ignore
|
|
from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters
|
|
import os
|
|
from fastapi import Body
|
|
|
|
# In-memory client metrics & structured log counters (diagnostics only)
|
|
CLIENT_PERF: dict[str, list[float]] = {
|
|
"list_render_ms": [], # list_ready - list_render_start
|
|
"preview_load_ms": [], # optional future measure (not yet emitted)
|
|
}
|
|
LOG_COUNTS: dict[str, int] = {}
|
|
MAX_CLIENT_SAMPLES = 500 # cap to avoid unbounded growth
|
|
|
|
router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
|
|
|
|
# Reuse the main app's template environment so nav globals stay consistent.
|
|
try: # circular-safe import: app defines templates before importing this router
|
|
from ..app import templates as _templates # type: ignore
|
|
except Exception: # Fallback (tests/minimal contexts)
|
|
_templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates'))
|
|
|
|
THEME_LIST_PATH = Path("config/themes/theme_list.json")
|
|
CATALOG_DIR = Path("config/themes/catalog")
|
|
STATUS_PATH = Path("csv_files/.setup_status.json")
|
|
TAG_FLAG_PATH = Path("csv_files/.tagging_complete.json")
|
|
|
|
|
|
def _iso(ts: float | int | None) -> Optional[str]:
|
|
if ts is None or ts <= 0:
|
|
return None
|
|
try:
|
|
return _dt.fromtimestamp(ts).isoformat(timespec="seconds")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _load_status() -> Dict[str, Any]:
|
|
try:
|
|
if STATUS_PATH.exists():
|
|
return json.loads(STATUS_PATH.read_text(encoding="utf-8") or "{}") or {}
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _load_fast_theme_list() -> Optional[list[dict[str, Any]]]:
|
|
"""Load precomputed lightweight theme list JSON if available.
|
|
|
|
Expected structure: {"themes": [{"id": str, "theme": str, "short_description": str, ...}, ...]}
|
|
Returns list or None on failure.
|
|
"""
|
|
try:
|
|
if THEME_LIST_PATH.exists():
|
|
raw = json.loads(THEME_LIST_PATH.read_text(encoding="utf-8") or "{}")
|
|
if isinstance(raw, dict):
|
|
arr = raw.get("themes")
|
|
if isinstance(arr, list):
|
|
# Shallow copy to avoid mutating original reference
|
|
# NOTE: Regression fix (2025-09-20): theme_list.json produced by current
|
|
# build pipeline does NOT include an explicit 'id' per theme (only 'theme').
|
|
# Earlier implementation required e.get('id') causing the fast path to
|
|
# treat the catalog as empty and show "No themes found." even though
|
|
# hundreds of themes exist. We now derive the id via slugify(theme) when
|
|
# missing, and also opportunistically compute a short_description snippet
|
|
# if absent (trim description to ~110 chars mirroring project_summary logic).
|
|
out: list[dict[str, Any]] = []
|
|
for e in arr:
|
|
if not isinstance(e, dict):
|
|
continue
|
|
theme_name = e.get("theme")
|
|
if not theme_name or not isinstance(theme_name, str):
|
|
continue
|
|
_id = e.get("id") or slugify(theme_name)
|
|
short_desc = e.get("short_description")
|
|
if not short_desc:
|
|
desc = e.get("description")
|
|
if isinstance(desc, str) and desc.strip():
|
|
sd = desc.strip()
|
|
if len(sd) > 110:
|
|
sd = sd[:107].rstrip() + "…"
|
|
short_desc = sd
|
|
out.append({
|
|
"id": _id,
|
|
"theme": theme_name,
|
|
"short_description": short_desc,
|
|
})
|
|
# If we ended up with zero items (unexpected) fall back to None so caller
|
|
# will use full index logic instead of rendering empty state incorrectly.
|
|
if not out:
|
|
return None
|
|
return out
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
@router.get("/suggest")
|
|
@router.get("/api/suggest")
|
|
async def theme_suggest(
|
|
request: Request,
|
|
q: str | None = None,
|
|
limit: int | None = Query(10, ge=1, le=50),
|
|
):
|
|
"""Lightweight theme name suggestions for typeahead.
|
|
|
|
Prefers the precomputed fast path (theme_list.json). Falls back to full index if unavailable.
|
|
Returns a compact JSON: {"themes": ["<name>", ...]}.
|
|
"""
|
|
try:
|
|
# Optional rate limit using app helper if available
|
|
rl_result = None
|
|
try:
|
|
from ..app import rate_limit_check # type: ignore
|
|
rl_result = rate_limit_check(request, "suggest")
|
|
except HTTPException as http_ex: # propagate 429 with headers
|
|
raise http_ex
|
|
except Exception:
|
|
rl_result = None
|
|
lim = int(limit or 10)
|
|
names: list[str] = []
|
|
fast = _load_fast_theme_list()
|
|
if fast is not None:
|
|
try:
|
|
items = fast
|
|
if q:
|
|
ql = q.lower()
|
|
items = [e for e in items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()]
|
|
for e in items[: lim * 3]: # pre-slice before unique
|
|
nm = e.get("theme")
|
|
if isinstance(nm, str):
|
|
names.append(nm)
|
|
except Exception:
|
|
names = []
|
|
if not names:
|
|
# Fallback to full index
|
|
try:
|
|
idx = load_index()
|
|
slugs = filter_slugs_fast(idx, q=q)
|
|
# summaries_for_slugs returns dicts including 'theme'
|
|
infos = summaries_for_slugs(idx, slugs[: lim * 3])
|
|
for inf in infos:
|
|
nm = inf.get("theme")
|
|
if isinstance(nm, str):
|
|
names.append(nm)
|
|
except Exception:
|
|
names = []
|
|
# Deduplicate preserving order, then clamp
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for nm in names:
|
|
if nm in seen:
|
|
continue
|
|
seen.add(nm)
|
|
out.append(nm)
|
|
if len(out) >= lim:
|
|
break
|
|
resp = JSONResponse({"themes": out})
|
|
if rl_result:
|
|
remaining, reset_epoch = rl_result
|
|
try:
|
|
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
resp.headers["X-RateLimit-Reset"] = str(reset_epoch)
|
|
except Exception:
|
|
pass
|
|
return resp
|
|
except HTTPException as e:
|
|
# Propagate FastAPI HTTPException (e.g., 429 with headers)
|
|
raise e
|
|
except Exception as e:
|
|
return JSONResponse({"themes": [], "error": str(e)}, status_code=500)
|
|
|
|
|
|
def _load_tag_flag_time() -> Optional[float]:
|
|
try:
|
|
if TAG_FLAG_PATH.exists():
|
|
data = json.loads(TAG_FLAG_PATH.read_text(encoding="utf-8") or "{}") or {}
|
|
t = data.get("tagged_at")
|
|
if isinstance(t, str) and t.strip():
|
|
try:
|
|
return _dt.fromisoformat(t.strip()).timestamp()
|
|
except Exception:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
@router.get("/status")
|
|
async def theme_status():
|
|
"""Return current theme export status for the UI.
|
|
|
|
Provides counts, mtimes, and freshness vs. tagging flag.
|
|
"""
|
|
try:
|
|
status = _load_status()
|
|
theme_list_exists = THEME_LIST_PATH.exists()
|
|
theme_list_mtime_s = THEME_LIST_PATH.stat().st_mtime if theme_list_exists else None
|
|
theme_count: Optional[int] = None
|
|
parse_error: Optional[str] = None
|
|
if theme_list_exists:
|
|
try:
|
|
raw = json.loads(THEME_LIST_PATH.read_text(encoding="utf-8") or "{}") or {}
|
|
if isinstance(raw, dict):
|
|
themes = raw.get("themes")
|
|
if isinstance(themes, list):
|
|
theme_count = len(themes)
|
|
except Exception as e: # pragma: no cover
|
|
parse_error = f"parse_error: {e}" # keep short
|
|
yaml_catalog_exists = CATALOG_DIR.exists() and CATALOG_DIR.is_dir()
|
|
yaml_file_count = 0
|
|
if yaml_catalog_exists:
|
|
try:
|
|
yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type]
|
|
except Exception:
|
|
yaml_file_count = -1
|
|
tagged_time = _load_tag_flag_time()
|
|
stale = False
|
|
if tagged_time and theme_list_mtime_s:
|
|
# Stale if tagging flag is newer by > 1 second
|
|
stale = tagged_time > (theme_list_mtime_s + 1)
|
|
# Also stale if we expect a catalog (after any tagging) but have suspiciously few YAMLs (< 100)
|
|
if yaml_catalog_exists and yaml_file_count >= 0 and yaml_file_count < 100:
|
|
stale = True
|
|
last_export_at = status.get("themes_last_export_at") or _iso(theme_list_mtime_s) or None
|
|
resp = {
|
|
"ok": True,
|
|
"theme_list_exists": theme_list_exists,
|
|
"theme_list_mtime": _iso(theme_list_mtime_s),
|
|
"theme_count": theme_count,
|
|
"yaml_catalog_exists": yaml_catalog_exists,
|
|
"yaml_file_count": yaml_file_count,
|
|
"stale": stale,
|
|
"last_export_at": last_export_at,
|
|
"last_export_fast_path": status.get("themes_last_export_fast_path"),
|
|
"phase": status.get("phase"),
|
|
"running": status.get("running"),
|
|
}
|
|
if parse_error:
|
|
resp["parse_error"] = parse_error
|
|
return JSONResponse(resp)
|
|
except Exception as e: # pragma: no cover
|
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/refresh")
|
|
async def theme_refresh(background: BackgroundTasks):
|
|
"""Force a theme export refresh without re-tagging if not needed.
|
|
|
|
Runs setup readiness with force=False (fast-path export fallback will run). Returns immediately.
|
|
"""
|
|
try:
|
|
def _runner():
|
|
try:
|
|
_ensure_setup_ready(lambda _m: None, force=False)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
_run_theme_metadata_enrichment()
|
|
except Exception:
|
|
pass
|
|
background.add_task(_runner)
|
|
return JSONResponse({"ok": True, "started": True})
|
|
except Exception as e: # pragma: no cover
|
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
# --- Phase E Theme Catalog APIs ---
|
|
|
|
def _diag_enabled() -> bool:
|
|
return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
@router.get("/metrics")
|
|
async def theme_metrics():
|
|
if not _diag_enabled():
|
|
raise HTTPException(status_code=403, detail="diagnostics_disabled")
|
|
try:
|
|
idx = load_index()
|
|
prewarm_common_filters()
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"etag": idx.etag,
|
|
"catalog": catalog_metrics(),
|
|
"preview": preview_metrics(),
|
|
"client_perf": {
|
|
"list_render_avg_ms": round(sum(CLIENT_PERF["list_render_ms"]) / len(CLIENT_PERF["list_render_ms"])) if CLIENT_PERF["list_render_ms"] else 0,
|
|
"list_render_count": len(CLIENT_PERF["list_render_ms"]),
|
|
"preview_load_avg_ms": round(sum(CLIENT_PERF["preview_load_ms"]) / len(CLIENT_PERF["preview_load_ms"])) if CLIENT_PERF["preview_load_ms"] else 0,
|
|
"preview_load_batch_count": len(CLIENT_PERF["preview_load_ms"]),
|
|
},
|
|
"log_counts": LOG_COUNTS,
|
|
})
|
|
except Exception as e:
|
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
async def theme_catalog_simple(request: Request):
|
|
"""Simplified catalog: list + search only (no per-row heavy data)."""
|
|
return _templates.TemplateResponse("themes/catalog_simple.html", {"request": request})
|
|
|
|
|
|
@router.get("/{theme_id}", response_class=HTMLResponse)
|
|
async def theme_catalog_detail_page(theme_id: str, request: Request):
|
|
"""Full detail page for a single theme (standalone route)."""
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
|
|
slug = slugify(theme_id)
|
|
entry = idx.slug_to_entry.get(slug)
|
|
if not entry:
|
|
return HTMLResponse("<div class='error'>Not found.</div>", status_code=404)
|
|
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False)
|
|
# Strip diagnostics-only fields for public page
|
|
detail.pop('has_fallback_description', None)
|
|
detail.pop('editorial_quality', None)
|
|
detail.pop('uncapped_synergies', None)
|
|
# Build example + synergy commanders (reuse logic from preview)
|
|
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
|
|
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
|
|
seen = set(example_commanders)
|
|
synergy_commanders: list[str] = []
|
|
for c in synergy_commanders_raw:
|
|
if c not in seen:
|
|
synergy_commanders.append(c)
|
|
seen.add(c)
|
|
# Render via reuse of detail fragment inside a page shell
|
|
return _templates.TemplateResponse(
|
|
"themes/detail_page.html",
|
|
{
|
|
"request": request,
|
|
"theme": detail,
|
|
"diagnostics": False,
|
|
"uncapped": False,
|
|
"yaml_available": False,
|
|
"example_commanders": example_commanders,
|
|
"synergy_commanders": synergy_commanders,
|
|
"standalone_page": True,
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/fragment/list", response_class=HTMLResponse)
|
|
async def theme_list_fragment(
|
|
request: Request,
|
|
q: str | None = None,
|
|
archetype: str | None = None,
|
|
bucket: str | None = None,
|
|
colors: str | None = None,
|
|
diagnostics: bool | None = None,
|
|
synergy_mode: str | None = Query(None, description="Synergy display mode: 'capped' (default) or 'full'"),
|
|
limit: int | None = Query(20, ge=1, le=100),
|
|
offset: int | None = Query(0, ge=0),
|
|
):
|
|
import time as _t
|
|
t0 = _t.time()
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
|
|
color_list = [c.strip() for c in colors.split(',')] if colors else None
|
|
# Fast filtering (falls back only for legacy logic differences if needed)
|
|
slugs = filter_slugs_fast(idx, q=q, archetype=archetype, bucket=bucket, colors=color_list)
|
|
diag = _diag_enabled() and bool(diagnostics)
|
|
lim = int(limit or 30)
|
|
off = int(offset or 0)
|
|
total = len(slugs)
|
|
slice_slugs = slugs[off: off + lim]
|
|
items = summaries_for_slugs(idx, slice_slugs)
|
|
# Synergy display logic: default 'capped' mode (cap at 6) unless diagnostics & user explicitly requests full
|
|
# synergy_mode can be 'full' to force uncapped in list (still diagnostics-gated to prevent layout spam in prod)
|
|
mode = (synergy_mode or '').strip().lower()
|
|
allow_full = (mode == 'full') and diag # only diagnostics may request full
|
|
SYNERGY_CAP = 6
|
|
if not allow_full:
|
|
for it in items:
|
|
syns = it.get("synergies") or []
|
|
if isinstance(syns, list) and len(syns) > SYNERGY_CAP:
|
|
it["synergies_capped"] = True
|
|
it["synergies_full"] = syns
|
|
it["synergies"] = syns[:SYNERGY_CAP]
|
|
if not diag:
|
|
for it in items:
|
|
it.pop('has_fallback_description', None)
|
|
it.pop('editorial_quality', None)
|
|
duration_ms = int(((_t.time() - t0) * 1000))
|
|
resp = _templates.TemplateResponse(
|
|
"themes/list_fragment.html",
|
|
{
|
|
"request": request,
|
|
"items": items,
|
|
"diagnostics": diag,
|
|
"total": total,
|
|
"limit": lim,
|
|
"offset": off,
|
|
"next_offset": off + lim if (off + lim) < total else None,
|
|
"prev_offset": off - lim if off - lim >= 0 else None,
|
|
},
|
|
)
|
|
resp.headers["X-ThemeCatalog-Filter-Duration-ms"] = str(duration_ms)
|
|
resp.headers["X-ThemeCatalog-Index-ETag"] = idx.etag
|
|
return resp
|
|
|
|
|
|
@router.get("/fragment/list_simple", response_class=HTMLResponse)
|
|
async def theme_list_simple_fragment(
|
|
request: Request,
|
|
q: str | None = None,
|
|
limit: int | None = Query(100, ge=1, le=300),
|
|
offset: int | None = Query(0, ge=0),
|
|
):
|
|
"""Lightweight list: only id, theme, short_description (for speed).
|
|
|
|
Attempts fast path using precomputed theme_list.json; falls back to full index.
|
|
"""
|
|
import time as _t
|
|
t0 = _t.time()
|
|
lim = int(limit or 100)
|
|
off = int(offset or 0)
|
|
fast_items = _load_fast_theme_list()
|
|
fast_used = False
|
|
items: list[dict[str, Any]] = []
|
|
total = 0
|
|
if fast_items is not None:
|
|
fast_used = True
|
|
# Filter (substring on theme only) if q provided
|
|
if q:
|
|
ql = q.lower()
|
|
fast_items = [e for e in fast_items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()]
|
|
total = len(fast_items)
|
|
slice_items = fast_items[off: off + lim]
|
|
for e in slice_items:
|
|
items.append({
|
|
"id": e.get("id"),
|
|
"theme": e.get("theme"),
|
|
"short_description": e.get("short_description"),
|
|
})
|
|
else:
|
|
# Fallback: load full index
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
|
|
slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=None, colors=None)
|
|
total = len(slugs)
|
|
slice_slugs = slugs[off: off + lim]
|
|
items_raw = summaries_for_slugs(idx, slice_slugs)
|
|
for it in items_raw:
|
|
items.append({
|
|
"id": it.get("id"),
|
|
"theme": it.get("theme"),
|
|
"short_description": it.get("short_description"),
|
|
})
|
|
duration_ms = int(((_t.time() - t0) * 1000))
|
|
resp = _templates.TemplateResponse(
|
|
"themes/list_simple_fragment.html",
|
|
{
|
|
"request": request,
|
|
"items": items,
|
|
"total": total,
|
|
"limit": lim,
|
|
"offset": off,
|
|
"next_offset": off + lim if (off + lim) < total else None,
|
|
"prev_offset": off - lim if off - lim >= 0 else None,
|
|
},
|
|
)
|
|
resp.headers['X-ThemeCatalog-Simple-Duration-ms'] = str(duration_ms)
|
|
resp.headers['X-ThemeCatalog-Simple-Fast'] = '1' if fast_used else '0'
|
|
# Consistency: expose same filter duration style header used by full list fragment so
|
|
# tooling / DevTools inspection does not depend on which catalog view is active.
|
|
resp.headers['X-ThemeCatalog-Filter-Duration-ms'] = str(duration_ms)
|
|
return resp
|
|
|
|
|
|
@router.get("/fragment/detail/{theme_id}", response_class=HTMLResponse)
|
|
async def theme_detail_fragment(
|
|
theme_id: str,
|
|
diagnostics: bool | None = None,
|
|
uncapped: bool | None = None,
|
|
request: Request = None,
|
|
):
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
|
|
slug = slugify(theme_id)
|
|
entry = idx.slug_to_entry.get(slug)
|
|
if not entry:
|
|
return HTMLResponse("<div class='error'>Not found.</div>", status_code=404)
|
|
diag = _diag_enabled() and bool(diagnostics)
|
|
uncapped_enabled = bool(uncapped) and diag
|
|
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=uncapped_enabled)
|
|
if not diag:
|
|
detail.pop('has_fallback_description', None)
|
|
detail.pop('editorial_quality', None)
|
|
detail.pop('uncapped_synergies', None)
|
|
return _templates.TemplateResponse(
|
|
"themes/detail_fragment.html",
|
|
{
|
|
"request": request,
|
|
"theme": detail,
|
|
"diagnostics": diag,
|
|
"uncapped": uncapped_enabled,
|
|
"yaml_available": diag, # gate by diagnostics flag
|
|
},
|
|
)
|
|
|
|
|
|
## (moved metrics route earlier to avoid collision with catch-all /{theme_id})
|
|
|
|
|
|
@router.get("/yaml/{theme_id}")
|
|
async def theme_yaml(theme_id: str):
|
|
"""Return raw YAML file for a theme (diagnostics/dev only)."""
|
|
if not _diag_enabled():
|
|
raise HTTPException(status_code=403, detail="diagnostics_disabled")
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=503, detail="catalog_unavailable")
|
|
slug = slugify(theme_id)
|
|
# Attempt to locate via slug -> YAML map, fallback path guess
|
|
y = idx.slug_to_yaml.get(slug)
|
|
if not y:
|
|
raise HTTPException(status_code=404, detail="yaml_not_found")
|
|
# Reconstruct minimal YAML (we have dict already)
|
|
import yaml as _yaml # local import to keep top-level lean
|
|
text = _yaml.safe_dump(y, sort_keys=False) # type: ignore
|
|
headers = {"Content-Type": "text/plain; charset=utf-8"}
|
|
return HTMLResponse(text, headers=headers)
|
|
|
|
|
|
@router.get("/api/themes")
|
|
async def api_themes(
|
|
request: Request,
|
|
q: str | None = Query(None, description="Substring filter on theme or synergies"),
|
|
archetype: str | None = Query(None, description="Filter by deck_archetype"),
|
|
bucket: str | None = Query(None, description="Filter by popularity bucket"),
|
|
colors: str | None = Query(None, description="Comma-separated color initials (e.g. G,W)"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
diagnostics: bool | None = Query(None, description="Force diagnostics mode (allowed only if flag enabled)"),
|
|
):
|
|
import time as _t
|
|
t0 = _t.time()
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=503, detail="catalog_unavailable")
|
|
color_list = [c.strip() for c in colors.split(",") if c.strip()] if colors else None
|
|
# Validate archetype quickly (fast path uses underlying entries anyway)
|
|
if archetype:
|
|
present_archetypes = {e.deck_archetype for e in idx.catalog.themes if e.deck_archetype}
|
|
if archetype not in present_archetypes:
|
|
slugs: list[str] = []
|
|
else:
|
|
slugs = filter_slugs_fast(idx, q=q, archetype=archetype, bucket=bucket, colors=color_list)
|
|
else:
|
|
slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=bucket, colors=color_list)
|
|
total = len(slugs)
|
|
slice_slugs = slugs[offset: offset + limit]
|
|
items = summaries_for_slugs(idx, slice_slugs)
|
|
diag = _diag_enabled() and bool(diagnostics)
|
|
if not diag:
|
|
# Strip diagnostics-only fields
|
|
for it in items:
|
|
# has_fallback_description is diagnostics-only
|
|
it.pop("has_fallback_description", None)
|
|
it.pop("editorial_quality", None)
|
|
duration_ms = int(((_t.time() - t0) * 1000))
|
|
headers = {
|
|
"ETag": idx.etag,
|
|
"Cache-Control": "no-cache", # Clients may still conditional GET using ETag
|
|
"X-ThemeCatalog-Filter-Duration-ms": str(duration_ms),
|
|
}
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"count": total,
|
|
"items": items,
|
|
"next_offset": offset + limit if (offset + limit) < total else None,
|
|
"stale": False, # status already exposed elsewhere; keep placeholder for UI
|
|
"generated_at": idx.catalog.metadata_info.generated_at if idx.catalog.metadata_info else None,
|
|
"diagnostics": diag,
|
|
}, headers=headers)
|
|
|
|
|
|
@router.get("/api/search")
|
|
async def api_theme_search(
|
|
q: str = Query(..., min_length=1, description="Search query"),
|
|
limit: int = Query(15, ge=1, le=50),
|
|
include_synergies: bool = Query(False, description="Also match synergies (slower)"),
|
|
):
|
|
"""Lightweight search with tiered matching (exact > prefix > substring).
|
|
|
|
Performance safeguards:
|
|
- Stop scanning once we have >= limit and at least one exact/prefix.
|
|
- Substring phase limited to first 250 themes unless still under limit.
|
|
- Optional synergy search (off by default) to avoid wide fan-out of matches like 'aggro' in many synergy lists.
|
|
"""
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
return JSONResponse({"ok": False, "error": "catalog_unavailable"}, status_code=503)
|
|
qnorm = q.strip()
|
|
if not qnorm:
|
|
return JSONResponse({"ok": True, "items": []})
|
|
qlower = qnorm.lower()
|
|
exact: list[dict[str, Any]] = []
|
|
prefix: list[dict[str, Any]] = []
|
|
substr: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined]
|
|
# Phase 1 + 2: exact / prefix
|
|
for t in themes_iter:
|
|
name = t.theme
|
|
slug = slugify(name)
|
|
lower_name = name.lower()
|
|
if lower_name == qlower or slug == qlower:
|
|
if slug not in seen:
|
|
exact.append({"id": slug, "theme": name})
|
|
seen.add(slug)
|
|
continue
|
|
if lower_name.startswith(qlower):
|
|
if slug not in seen:
|
|
prefix.append({"id": slug, "theme": name})
|
|
seen.add(slug)
|
|
if len(exact) + len(prefix) >= limit:
|
|
break
|
|
# Phase 3: substring (only if still room)
|
|
if (len(exact) + len(prefix)) < limit:
|
|
scan_limit = 250 # cap scan for responsiveness
|
|
for t in themes_iter[:scan_limit]:
|
|
name = t.theme
|
|
slug = slugify(name)
|
|
if slug in seen:
|
|
continue
|
|
if qlower in name.lower():
|
|
substr.append({"id": slug, "theme": name})
|
|
seen.add(slug)
|
|
if (len(exact) + len(prefix) + len(substr)) >= limit:
|
|
break
|
|
ordered = exact + prefix + substr
|
|
# Optional synergy search fill (lowest priority) if still space
|
|
if include_synergies and len(ordered) < limit:
|
|
remaining = limit - len(ordered)
|
|
for t in themes_iter:
|
|
if remaining <= 0:
|
|
break
|
|
slug = slugify(t.theme)
|
|
if slug in seen:
|
|
continue
|
|
syns = getattr(t, 'synergies', None) or []
|
|
try:
|
|
# Only a quick any() scan to keep it cheap
|
|
if any(qlower in s.lower() for s in syns):
|
|
ordered.append({"id": slug, "theme": t.theme})
|
|
seen.add(slug)
|
|
remaining -= 1
|
|
except Exception:
|
|
continue
|
|
if len(ordered) > limit:
|
|
ordered = ordered[:limit]
|
|
return JSONResponse({"ok": True, "items": ordered})
|
|
|
|
|
|
@router.get("/api/theme/{theme_id}")
|
|
async def api_theme_detail(
|
|
theme_id: str,
|
|
uncapped: bool | None = Query(False, description="Return uncapped synergy set (diagnostics mode only)"),
|
|
diagnostics: bool | None = Query(None, description="Diagnostics mode gating extra fields"),
|
|
):
|
|
try:
|
|
idx = load_index()
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=503, detail="catalog_unavailable")
|
|
slug = slugify(theme_id)
|
|
entry = idx.slug_to_entry.get(slug)
|
|
if not entry:
|
|
raise HTTPException(status_code=404, detail="theme_not_found")
|
|
diag = _diag_enabled() and bool(diagnostics)
|
|
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=bool(uncapped) and diag)
|
|
if not diag:
|
|
# Remove diagnostics-only fields
|
|
detail.pop("has_fallback_description", None)
|
|
detail.pop("editorial_quality", None)
|
|
detail.pop("uncapped_synergies", None)
|
|
headers = {"ETag": idx.etag, "Cache-Control": "no-cache"}
|
|
return JSONResponse({"ok": True, "theme": detail, "diagnostics": diag}, headers=headers)
|
|
|
|
|
|
@router.get("/api/theme/{theme_id}/preview")
|
|
async def api_theme_preview(
|
|
theme_id: str,
|
|
limit: int = Query(12, ge=1, le=30),
|
|
colors: str | None = Query(None, description="Comma separated color filter (currently placeholder)"),
|
|
commander: str | None = Query(None, description="Commander name to bias sampling (future)"),
|
|
):
|
|
try:
|
|
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="theme_not_found")
|
|
return JSONResponse({"ok": True, "preview": payload})
|
|
|
|
|
|
|
|
|
|
@router.get("/fragment/list", response_class=HTMLResponse)
|
|
|
|
|
|
# --- Preview Export Endpoints (CSV / JSON) ---
|
|
@router.get("/preview/{theme_id}/export.json")
|
|
async def export_preview_json(
|
|
theme_id: str,
|
|
limit: int = Query(12, ge=1, le=60),
|
|
colors: str | None = None,
|
|
commander: str | None = None,
|
|
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries returned"),
|
|
):
|
|
try:
|
|
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="theme_not_found")
|
|
items = payload.get("sample", [])
|
|
if curated_only:
|
|
items = [i for i in items if any(r in {"example", "curated_synergy", "synthetic"} for r in (i.get("roles") or []))]
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"theme": payload.get("theme"),
|
|
"theme_id": payload.get("theme_id"),
|
|
"curated_only": bool(curated_only),
|
|
"generated_at": payload.get("generated_at"),
|
|
"limit": limit,
|
|
"count": len(items),
|
|
"items": items,
|
|
})
|
|
|
|
|
|
@router.get("/preview/{theme_id}/export.csv")
|
|
async def export_preview_csv(
|
|
theme_id: str,
|
|
limit: int = Query(12, ge=1, le=60),
|
|
colors: str | None = None,
|
|
commander: str | None = None,
|
|
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries returned"),
|
|
):
|
|
import csv as _csv
|
|
import io as _io
|
|
try:
|
|
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="theme_not_found")
|
|
rows = payload.get("sample", [])
|
|
if curated_only:
|
|
rows = [r for r in rows if any(role in {"example", "curated_synergy", "synthetic"} for role in (r.get("roles") or []))]
|
|
buf = _io.StringIO()
|
|
fieldnames = ["name", "roles", "score", "rarity", "mana_cost", "color_identity_list", "pip_colors", "reasons", "tags"]
|
|
w = _csv.DictWriter(buf, fieldnames=fieldnames)
|
|
w.writeheader()
|
|
for r in rows:
|
|
w.writerow({
|
|
"name": r.get("name"),
|
|
"roles": ";".join(r.get("roles") or []),
|
|
"score": r.get("score"),
|
|
"rarity": r.get("rarity"),
|
|
"mana_cost": r.get("mana_cost"),
|
|
"color_identity_list": ";".join(r.get("color_identity_list") or []),
|
|
"pip_colors": ";".join(r.get("pip_colors") or []),
|
|
"reasons": ";".join(r.get("reasons") or []),
|
|
"tags": ";".join(r.get("tags") or []),
|
|
})
|
|
csv_text = buf.getvalue()
|
|
from fastapi.responses import Response
|
|
filename = f"preview_{theme_id}.csv"
|
|
headers = {
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"Content-Type": "text/csv; charset=utf-8",
|
|
}
|
|
return Response(content=csv_text, media_type="text/csv", headers=headers)
|
|
|
|
|
|
# --- Export preview as deck seed (lightweight) ---
|
|
@router.get("/preview/{theme_id}/export_seed.json")
|
|
async def export_preview_seed(
|
|
theme_id: str,
|
|
limit: int = Query(12, ge=1, le=60),
|
|
colors: str | None = None,
|
|
commander: str | None = None,
|
|
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries influence seed list"),
|
|
):
|
|
"""Return a minimal structure usable to bootstrap a deck build flow.
|
|
|
|
Output:
|
|
theme_id, theme, commander (if any), cards (list of names), curated (subset), generated_at.
|
|
"""
|
|
try:
|
|
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="theme_not_found")
|
|
items = payload.get("sample", [])
|
|
def _is_curated(it: dict) -> bool:
|
|
roles = it.get("roles") or []
|
|
return any(r in {"example","curated_synergy"} for r in roles)
|
|
if curated_only:
|
|
items = [i for i in items if _is_curated(i)]
|
|
card_names = [i.get("name") for i in items if i.get("name") and not i.get("name").startswith("[")]
|
|
curated_names = [i.get("name") for i in items if _is_curated(i) and i.get("name")] # exclude synthetic placeholders
|
|
return JSONResponse({
|
|
"ok": True,
|
|
"theme": payload.get("theme"),
|
|
"theme_id": payload.get("theme_id"),
|
|
"commander": commander,
|
|
"limit": limit,
|
|
"curated_only": bool(curated_only),
|
|
"generated_at": payload.get("generated_at"),
|
|
"count": len(card_names),
|
|
"cards": card_names,
|
|
"curated": curated_names,
|
|
})
|
|
|
|
|
|
# --- New: Client performance marks ingestion (Section E) ---
|
|
@router.post("/metrics/client")
|
|
async def ingest_client_metrics(request: Request, payload: dict[str, Any] = Body(...)):
|
|
if not _diag_enabled():
|
|
raise HTTPException(status_code=403, detail="diagnostics_disabled")
|
|
try:
|
|
events = payload.get("events")
|
|
if not isinstance(events, list):
|
|
return JSONResponse({"ok": False, "error": "invalid_events"}, status_code=400)
|
|
for ev in events:
|
|
if not isinstance(ev, dict):
|
|
continue
|
|
name = ev.get("name")
|
|
dur = ev.get("duration_ms")
|
|
if name == "list_render" and isinstance(dur, (int, float)) and dur >= 0:
|
|
CLIENT_PERF["list_render_ms"].append(float(dur))
|
|
if len(CLIENT_PERF["list_render_ms"]) > MAX_CLIENT_SAMPLES:
|
|
# Drop oldest half to keep memory bounded
|
|
CLIENT_PERF["list_render_ms"] = CLIENT_PERF["list_render_ms"][len(CLIENT_PERF["list_render_ms"])//2:]
|
|
elif name == "preview_load_batch":
|
|
# Aggregate average into samples list (store avg redundantly for now)
|
|
avg_ms = ev.get("avg_ms")
|
|
if isinstance(avg_ms, (int, float)) and avg_ms >= 0:
|
|
CLIENT_PERF["preview_load_ms"].append(float(avg_ms))
|
|
if len(CLIENT_PERF["preview_load_ms"]) > MAX_CLIENT_SAMPLES:
|
|
CLIENT_PERF["preview_load_ms"] = CLIENT_PERF["preview_load_ms"][len(CLIENT_PERF["preview_load_ms"])//2:]
|
|
return JSONResponse({"ok": True, "ingested": len(events)})
|
|
except Exception as e: # pragma: no cover
|
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
|
|
|
|
|
# --- New: Structured logging ingestion for cache/prefetch events (Section E) ---
|
|
@router.post("/log")
|
|
async def ingest_structured_log(request: Request, payload: dict[str, Any] = Body(...)):
|
|
if not _diag_enabled():
|
|
raise HTTPException(status_code=403, detail="diagnostics_disabled")
|
|
try:
|
|
event = payload.get("event")
|
|
if not isinstance(event, str) or not event:
|
|
return JSONResponse({"ok": False, "error": "missing_event"}, status_code=400)
|
|
LOG_COUNTS[event] = LOG_COUNTS.get(event, 0) + 1
|
|
if event == "preview_fetch_error": # client-side fetch failure
|
|
try:
|
|
_theme_preview_mod._PREVIEW_REQUEST_ERROR_COUNT += 1 # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
# Lightweight echo back
|
|
return JSONResponse({"ok": True, "count": LOG_COUNTS[event]})
|
|
except Exception as e: # pragma: no cover
|
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|