mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection
This commit is contained in:
parent
3a1b011dbc
commit
9428e09cef
39 changed files with 3643 additions and 198 deletions
|
|
@ -16,7 +16,7 @@ from starlette.middleware.gzip import GZipMiddleware
|
|||
from typing import Any, Optional, Dict, Iterable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_theme_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
|
|
@ -112,6 +112,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
|||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
|
||||
|
|
@ -130,6 +131,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)
|
||||
RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350)
|
||||
USER_THEME_LIMIT = _as_int(os.getenv("USER_THEME_LIMIT"), 8)
|
||||
|
||||
_THEME_MODE_ENV = (os.getenv("THEME_MATCH_MODE") or "").strip().lower()
|
||||
DEFAULT_THEME_MATCH_MODE = "strict" if _THEME_MODE_ENV in {"strict", "s"} else "permissive"
|
||||
|
||||
# Simple theme input validation constraints
|
||||
_THEME_MAX_LEN = 60
|
||||
|
|
@ -240,6 +245,7 @@ templates.env.globals.update({
|
|||
"enable_themes": ENABLE_THEMES,
|
||||
"enable_pwa": ENABLE_PWA,
|
||||
"enable_presets": ENABLE_PRESETS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
"random_modes": RANDOM_MODES,
|
||||
|
|
@ -248,6 +254,8 @@ templates.env.globals.update({
|
|||
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
||||
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
||||
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
||||
"user_theme_limit": USER_THEME_LIMIT,
|
||||
"default_theme_match_mode": DEFAULT_THEME_MATCH_MODE,
|
||||
})
|
||||
|
||||
# Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev'
|
||||
|
|
@ -823,10 +831,13 @@ async def status_sys():
|
|||
"SHOW_COMMANDERS": bool(SHOW_COMMANDERS),
|
||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
||||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||
"ENABLE_CUSTOM_THEMES": bool(ENABLE_CUSTOM_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
|
||||
"USER_THEME_LIMIT": int(USER_THEME_LIMIT),
|
||||
"RANDOM_MODES": bool(RANDOM_MODES),
|
||||
"RANDOM_UI": bool(RANDOM_UI),
|
||||
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
|
||||
|
|
@ -834,6 +845,7 @@ async def status_sys():
|
|||
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
|
||||
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
|
||||
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
|
||||
"RATE_LIMIT_ENABLED": 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),
|
||||
|
|
@ -887,6 +899,17 @@ async def status_dfc_metrics():
|
|||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/status/theme_metrics")
|
||||
async def status_theme_metrics():
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
try:
|
||||
return JSONResponse({"ok": True, "metrics": get_theme_metrics()})
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logging.getLogger("web").warning("Failed to fetch theme metrics: %s", exc, exc_info=True)
|
||||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
|
|
@ -2366,13 +2389,17 @@ async def trigger_error(kind: str = Query("http")):
|
|||
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse(
|
||||
"diagnostics/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"merge_summary": load_merge_summary(),
|
||||
},
|
||||
)
|
||||
# Build a sanitized context and pre-render to surface template errors clearly
|
||||
try:
|
||||
summary = load_merge_summary() or {"updated_at": None, "colors": {}}
|
||||
if not isinstance(summary, dict):
|
||||
summary = {"updated_at": None, "colors": {}}
|
||||
if not isinstance(summary.get("colors"), dict):
|
||||
summary["colors"] = {}
|
||||
except Exception:
|
||||
summary = {"updated_at": None, "colors": {}}
|
||||
ctx = {"request": request, "merge_summary": summary}
|
||||
return templates.TemplateResponse("diagnostics/index.html", ctx)
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any
|
||||
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
||||
from ..app import (
|
||||
ALLOW_MUST_HAVES,
|
||||
ENABLE_CUSTOM_THEMES,
|
||||
USER_THEME_LIMIT,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
_sanitize_theme,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
|
|
@ -23,6 +29,7 @@ from html import escape as _esc
|
|||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_utils as bu
|
||||
from ..services.combo_utils import detect_all as _detect_all
|
||||
from ..services import custom_theme_manager as theme_mgr
|
||||
from path_util import csv_dir as _csv_dir
|
||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
from ..services.telemetry import log_commander_create_deck
|
||||
|
|
@ -114,6 +121,41 @@ router = APIRouter(prefix="/build")
|
|||
# Alternatives cache moved to services/alts_utils
|
||||
|
||||
|
||||
def _custom_theme_context(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
*,
|
||||
message: str | None = None,
|
||||
level: str = "info",
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the Additional Themes section context for the modal."""
|
||||
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return {
|
||||
"request": request,
|
||||
"theme_state": None,
|
||||
"theme_message": message,
|
||||
"theme_message_level": level,
|
||||
"theme_limit": USER_THEME_LIMIT,
|
||||
"enable_custom_themes": False,
|
||||
}
|
||||
theme_mgr.set_limit(sess, USER_THEME_LIMIT)
|
||||
state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE)
|
||||
return {
|
||||
"request": request,
|
||||
"theme_state": state,
|
||||
"theme_message": message,
|
||||
"theme_message_level": level,
|
||||
"theme_limit": USER_THEME_LIMIT,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
}
|
||||
|
||||
|
||||
_INVALID_THEME_MESSAGE = (
|
||||
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
||||
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
||||
|
||||
|
|
@ -418,12 +460,14 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"""Return the New Deck modal content (for an overlay)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
theme_context = _custom_theme_context(request, sess)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_count": sess.get("combo_target_count"),
|
||||
|
|
@ -434,6 +478,10 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
}
|
||||
for key, value in theme_context.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -575,6 +623,91 @@ async def build_new_multicopy(
|
|||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.post("/themes/add", response_class=HTMLResponse)
|
||||
async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
trimmed = theme.strip()
|
||||
sanitized = _sanitize_theme(trimmed) if trimmed else ""
|
||||
if trimmed and not sanitized:
|
||||
ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error")
|
||||
else:
|
||||
value = sanitized if sanitized is not None else trimmed
|
||||
_, message, level = theme_mgr.add_theme(
|
||||
sess,
|
||||
value,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
limit=USER_THEME_LIMIT,
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/remove", response_class=HTMLResponse)
|
||||
async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
value = _sanitize_theme(theme) or theme
|
||||
_, message, level = theme_mgr.remove_theme(
|
||||
sess,
|
||||
value,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/choose", response_class=HTMLResponse)
|
||||
async def build_theme_choose(
|
||||
request: Request,
|
||||
original: str = Form(""),
|
||||
choice: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
selection = _sanitize_theme(choice) or choice
|
||||
_, message, level = theme_mgr.choose_suggestion(
|
||||
sess,
|
||||
original,
|
||||
selection,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/mode", response_class=HTMLResponse)
|
||||
async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
_, message, level = theme_mgr.set_mode(
|
||||
sess,
|
||||
mode,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/new", response_class=HTMLResponse)
|
||||
async def build_new_submit(
|
||||
request: Request,
|
||||
|
|
@ -660,8 +793,14 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(suggested),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -676,8 +815,14 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(commander),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -694,8 +839,34 @@ async def build_new_submit(
|
|||
bracket = 3
|
||||
# Save to session
|
||||
sess["commander"] = sel.get("name") or commander
|
||||
# 1) Start from explicitly selected tags (order preserved)
|
||||
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||
# If commander has a tag list and primary missing, set first recommended as default
|
||||
user_explicit = bool(tags) # whether the user set any theme in the form
|
||||
# 2) Consider user-added supplemental themes from the Additional Themes UI
|
||||
additional_from_session = []
|
||||
try:
|
||||
# custom_theme_manager stores resolved list here on add/resolve; present before submit
|
||||
additional_from_session = [
|
||||
str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip()
|
||||
]
|
||||
except Exception:
|
||||
additional_from_session = []
|
||||
# 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary
|
||||
if not user_explicit and additional_from_session:
|
||||
# Cap to three and preserve order
|
||||
tags = list(additional_from_session[:3])
|
||||
# 4) If user selected some themes, fill remaining slots with additional themes (deduping)
|
||||
elif user_explicit and additional_from_session:
|
||||
seen = {str(t).strip().casefold() for t in tags}
|
||||
for name in additional_from_session:
|
||||
key = name.strip().casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
tags.append(name)
|
||||
seen.add(key)
|
||||
if len(tags) >= 3:
|
||||
break
|
||||
# 5) If still empty (no explicit and no additional), fall back to commander-recommended default
|
||||
if not tags:
|
||||
try:
|
||||
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
|
||||
|
|
@ -731,6 +902,33 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
sess["ideals"] = ideals
|
||||
if ENABLE_CUSTOM_THEMES:
|
||||
try:
|
||||
theme_mgr.refresh_resolution(
|
||||
sess,
|
||||
commander_tags=tags,
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
except ValueError as exc:
|
||||
error_msg = str(exc)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"error": error_msg,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
# Persist preferences
|
||||
try:
|
||||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
|
|
|
|||
229
code/web/services/custom_theme_manager.py
Normal file
229
code/web/services/custom_theme_manager.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Session helpers for managing supplemental user themes in the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from deck_builder.theme_resolution import (
|
||||
ThemeResolutionInfo,
|
||||
clean_theme_inputs,
|
||||
normalize_theme_match_mode,
|
||||
resolve_additional_theme_inputs,
|
||||
)
|
||||
|
||||
DEFAULT_THEME_LIMIT = 8
|
||||
ADDITION_COOLDOWN_SECONDS = 0.75
|
||||
|
||||
_INPUTS_KEY = "custom_theme_inputs"
|
||||
_RESOLUTION_KEY = "user_theme_resolution"
|
||||
_MODE_KEY = "theme_match_mode"
|
||||
_LAST_ADD_KEY = "custom_theme_last_add_ts"
|
||||
_CATALOG_VERSION_KEY = "theme_catalog_version"
|
||||
|
||||
|
||||
def _sanitize_single(value: str | None) -> str | None:
|
||||
for item in clean_theme_inputs([value] if value is not None else []):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _store_inputs(sess: Dict[str, Any], inputs: List[str]) -> None:
|
||||
sess[_INPUTS_KEY] = list(inputs)
|
||||
|
||||
|
||||
def _current_inputs(sess: Dict[str, Any]) -> List[str]:
|
||||
values = sess.get(_INPUTS_KEY)
|
||||
if isinstance(values, list):
|
||||
return [str(v) for v in values if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _store_resolution(sess: Dict[str, Any], info: ThemeResolutionInfo) -> None:
|
||||
info_dict = asdict(info)
|
||||
sess[_RESOLUTION_KEY] = info_dict
|
||||
sess[_CATALOG_VERSION_KEY] = info.catalog_version
|
||||
sess[_MODE_KEY] = info.mode
|
||||
sess["additional_themes"] = list(info.resolved)
|
||||
|
||||
|
||||
def _default_resolution(mode: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"requested": [],
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"catalog_version": "unknown",
|
||||
"resolved": [],
|
||||
"matches": [],
|
||||
"unresolved": [],
|
||||
"fuzzy_corrections": {},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_and_store(
|
||||
sess: Dict[str, Any],
|
||||
inputs: List[str],
|
||||
mode: str,
|
||||
commander_tags: Iterable[str],
|
||||
) -> ThemeResolutionInfo:
|
||||
info = resolve_additional_theme_inputs(inputs, mode, commander_tags=commander_tags)
|
||||
_store_inputs(sess, inputs)
|
||||
_store_resolution(sess, info)
|
||||
return info
|
||||
|
||||
|
||||
def get_view_state(sess: Dict[str, Any], *, default_mode: str) -> Dict[str, Any]:
|
||||
inputs = _current_inputs(sess)
|
||||
mode = sess.get(_MODE_KEY, default_mode)
|
||||
resolution = sess.get(_RESOLUTION_KEY)
|
||||
if not isinstance(resolution, dict):
|
||||
resolution = _default_resolution(mode)
|
||||
remaining = max(0, int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)) - len(inputs))
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"resolution": resolution,
|
||||
"limit": int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)),
|
||||
"remaining": remaining,
|
||||
}
|
||||
|
||||
|
||||
def set_limit(sess: Dict[str, Any], limit: int) -> None:
|
||||
sess["custom_theme_limit"] = max(1, int(limit))
|
||||
|
||||
|
||||
def add_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
limit: int = DEFAULT_THEME_LIMIT,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
sanitized = _sanitize_single(value)
|
||||
if not sanitized:
|
||||
return None, "Enter a theme to add.", "error"
|
||||
lower_inputs = {item.casefold() for item in inputs}
|
||||
if sanitized.casefold() in lower_inputs:
|
||||
return None, "That theme is already listed.", "info"
|
||||
if len(inputs) >= limit:
|
||||
return None, f"You can only add up to {limit} themes.", "warning"
|
||||
last_ts = float(sess.get(_LAST_ADD_KEY, 0.0) or 0.0)
|
||||
now = time.time()
|
||||
if now - last_ts < ADDITION_COOLDOWN_SECONDS:
|
||||
return None, "Please wait a moment before adding another theme.", "warning"
|
||||
proposed = inputs + [sanitized]
|
||||
try:
|
||||
info = _resolve_and_store(sess, proposed, normalized_mode, commander_tags)
|
||||
sess[_LAST_ADD_KEY] = now
|
||||
return info, f"Added theme '{sanitized}'.", "success"
|
||||
except ValueError as exc:
|
||||
# Revert when strict mode rejects unresolved entries.
|
||||
_resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def remove_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
if not inputs:
|
||||
return None, "No themes to remove.", "info"
|
||||
key = (value or "").strip().casefold()
|
||||
if not key:
|
||||
return None, "Select a theme to remove.", "error"
|
||||
filtered = [item for item in inputs if item.casefold() != key]
|
||||
if len(filtered) == len(inputs):
|
||||
return None, "Theme not found in your list.", "warning"
|
||||
info = _resolve_and_store(sess, filtered, normalized_mode, commander_tags)
|
||||
return info, "Theme removed.", "success"
|
||||
|
||||
|
||||
def choose_suggestion(
|
||||
sess: Dict[str, Any],
|
||||
original: str,
|
||||
selection: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
orig_key = (original or "").strip().casefold()
|
||||
if not orig_key:
|
||||
return None, "Original theme missing.", "error"
|
||||
sanitized = _sanitize_single(selection)
|
||||
if not sanitized:
|
||||
return None, "Select a suggestion to apply.", "error"
|
||||
try:
|
||||
index = next(i for i, item in enumerate(inputs) if item.casefold() == orig_key)
|
||||
except StopIteration:
|
||||
return None, "Original theme not found.", "warning"
|
||||
replacement_key = sanitized.casefold()
|
||||
if replacement_key in {item.casefold() for i, item in enumerate(inputs) if i != index}:
|
||||
# Duplicate suggestion: simply drop the original.
|
||||
updated = [item for i, item in enumerate(inputs) if i != index]
|
||||
message = f"Removed duplicate theme '{original}'."
|
||||
else:
|
||||
updated = list(inputs)
|
||||
updated[index] = sanitized
|
||||
message = f"Updated '{original}' to '{sanitized}'."
|
||||
info = _resolve_and_store(sess, updated, normalized_mode, commander_tags)
|
||||
return info, message, "success"
|
||||
|
||||
|
||||
def set_mode(
|
||||
sess: Dict[str, Any],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
new_mode = normalize_theme_match_mode(mode)
|
||||
current_inputs = _current_inputs(sess)
|
||||
previous_mode = sess.get(_MODE_KEY)
|
||||
try:
|
||||
info = _resolve_and_store(sess, current_inputs, new_mode, commander_tags)
|
||||
return info, f"Theme matching set to {new_mode} mode.", "success"
|
||||
except ValueError as exc:
|
||||
if previous_mode is not None:
|
||||
sess[_MODE_KEY] = previous_mode
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def clear_all(sess: Dict[str, Any]) -> None:
|
||||
for key in (_INPUTS_KEY, _RESOLUTION_KEY, "additional_themes", _LAST_ADD_KEY):
|
||||
if key in sess:
|
||||
del sess[key]
|
||||
|
||||
|
||||
def refresh_resolution(
|
||||
sess: Dict[str, Any],
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None = None,
|
||||
) -> ThemeResolutionInfo | None:
|
||||
inputs = _current_inputs(sess)
|
||||
normalized_mode = normalize_theme_match_mode(mode or sess.get(_MODE_KEY))
|
||||
if not inputs:
|
||||
empty = ThemeResolutionInfo(
|
||||
requested=[],
|
||||
mode=normalized_mode,
|
||||
catalog_version=sess.get(_CATALOG_VERSION_KEY, "unknown"),
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[],
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
_store_inputs(sess, [])
|
||||
_store_resolution(sess, empty)
|
||||
return empty
|
||||
info = _resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return info
|
||||
|
||||
|
|
@ -1004,6 +1004,19 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
args.append('--force')
|
||||
_run(args, check=True)
|
||||
_emit("Theme catalog (JSON + YAML) refreshed{}.".format(" (fast path)" if fast_path else ""))
|
||||
# Always attempt to generate supplemental CSV catalog from card CSVs for downstream features
|
||||
try:
|
||||
gen_script = os.path.join(script_base, 'generate_theme_catalog.py')
|
||||
if os.path.exists(gen_script):
|
||||
csv_dir = os.getenv('CSV_FILES_DIR') or 'csv_files'
|
||||
output_csv = os.path.join('config', 'themes', 'theme_catalog.csv')
|
||||
gen_args = [_sys.executable, gen_script, '--csv-dir', csv_dir, '--output', output_csv]
|
||||
_run(gen_args, check=True)
|
||||
_emit("Supplemental CSV theme catalog generated (theme_catalog.csv).")
|
||||
else:
|
||||
_emit("generate_theme_catalog.py not found; skipping CSV catalog generation.")
|
||||
except Exception as gerr:
|
||||
_emit(f"CSV theme catalog generation failed: {gerr}")
|
||||
# Mark progress complete
|
||||
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
|
||||
# Append status file enrichment with last export metrics
|
||||
|
|
@ -1869,7 +1882,7 @@ def start_build_ctx(
|
|||
if row.empty:
|
||||
raise ValueError(f"Commander not found: {commander}")
|
||||
b._apply_commander_selection(row.iloc[0])
|
||||
# Tags
|
||||
# Tags (explicit + supplemental applied upstream)
|
||||
b.selected_tags = list(tags or [])
|
||||
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
||||
b.secondary_tag = b.selected_tags[1] if len(b.selected_tags) > 1 else None
|
||||
|
|
|
|||
136
code/web/templates/build/_new_deck_additional_themes.html
Normal file
136
code/web/templates/build/_new_deck_additional_themes.html
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{% set state = theme_state or {} %}
|
||||
{% set resolution = state.get('resolution', {}) %}
|
||||
{% set matches = resolution.get('matches', []) or [] %}
|
||||
{% set unresolved = resolution.get('unresolved', []) or [] %}
|
||||
{% set resolved_labels = resolution.get('resolved', []) or [] %}
|
||||
{% set limit = state.get('limit', 8) %}
|
||||
{% set remaining = state.get('remaining', limit) %}
|
||||
{% set disable_add = remaining <= 0 %}
|
||||
|
||||
<fieldset id="custom-theme-root" style="margin-top:1rem; border:1px solid var(--border); border-radius:8px; padding:0.75rem;" hx-on::afterSwap="const field=this.querySelector('[data-theme-input]'); if(field){field.value=''; field.focus();}">
|
||||
<legend style="font-weight:600;">Additional Themes</legend>
|
||||
<p class="muted" style="margin:0 0 .5rem 0; font-size:12px;">
|
||||
Add up to {{ limit }} supplemental themes to guide the build.
|
||||
<span{% if disable_add %} style="color:#fca5a5;"{% endif %}> {{ remaining }} slot{% if remaining != 1 %}s{% endif %} remaining.</span>
|
||||
</p>
|
||||
<div id="custom-theme-live" role="status" aria-live="polite" class="sr-only">{{ theme_message or '' }}</div>
|
||||
{% if theme_message %}
|
||||
{% if theme_message_level == 'success' %}
|
||||
<div class="theme-alert" data-level="success" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.4); color:#bbf7d0;">{{ theme_message }}</div>
|
||||
{% elif theme_message_level == 'warning' %}
|
||||
<div class="theme-alert" data-level="warning" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(250,204,21,0.15); border:1px solid rgba(250,204,21,0.45); color:#facc15;">{{ theme_message }}</div>
|
||||
{% elif theme_message_level == 'error' %}
|
||||
<div class="theme-alert" data-level="error" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5;">{{ theme_message }}</div>
|
||||
{% else %}
|
||||
<div class="theme-alert" data-level="info" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(59,130,246,0.1); border:1px solid rgba(59,130,246,0.35); color:#cbd5f5;">{{ theme_message }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div data-theme-add-container
|
||||
hx-post="/build/themes/add"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="click from:button[data-theme-add-btn]"
|
||||
hx-include="[data-theme-input]"
|
||||
style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label style="flex:1; min-width:220px;">
|
||||
<span class="sr-only">Theme name</span>
|
||||
<input type="text" name="theme" data-theme-input placeholder="e.g., Lifegain" maxlength="60" autocomplete="off" autocapitalize="off" spellcheck="false" style="width:100%; padding:.5rem; border-radius:6px; border:1px solid var(--border); background:var(--input-bg, #161921); color:var(--text-color, #f9fafb);" {% if disable_add %}disabled aria-disabled="true"{% endif %} />
|
||||
</label>
|
||||
<button type="button" data-theme-add-btn class="btn" style="padding:.45rem 1rem;" {% if disable_add %}disabled aria-disabled="true"{% endif %}>Add theme</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:.75rem; display:flex; gap:1rem; flex-wrap:wrap; font-size:12px; align-items:center;">
|
||||
<span class="muted">Matching mode:</span>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem;">
|
||||
<input type="radio" name="mode" value="permissive" {% if state.get('mode') == 'permissive' %}checked{% endif %}
|
||||
hx-trigger="change"
|
||||
hx-post="/build/themes/mode"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"mode":"permissive"}'
|
||||
/> Permissive
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem;">
|
||||
<input type="radio" name="mode" value="strict" {% if state.get('mode') == 'strict' %}checked{% endif %}
|
||||
hx-trigger="change"
|
||||
hx-post="/build/themes/mode"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"mode":"strict"}'
|
||||
/> Strict
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem; margin-top:1rem;">
|
||||
<div>
|
||||
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #9ca3af);">Resolved</h4>
|
||||
{% if resolved_labels %}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
|
||||
{% for match in matches %}
|
||||
{% set matched = match.matched if match.matched is defined else match['matched'] %}
|
||||
{% set original = match.input if match.input is defined else match['input'] %}
|
||||
{% set score_val = match.score if match.score is defined else match['score'] %}
|
||||
{% set score_pct = score_val if score_val is not none else None %}
|
||||
{% set reason_code = match.reason if match.reason is defined else match['reason'] %}
|
||||
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;" title="{{ matched }}{% if score_pct is not none %} · {{ '%.0f'|format(score_pct) }}% confidence{% endif %}{% if reason_code %} ({{ reason_code|replace('_',' ')|title }}){% endif %}">
|
||||
<span>{{ matched }}</span>
|
||||
{% if original and original.casefold() != matched.casefold() %}
|
||||
<span class="muted" style="font-size:11px;">(from “{{ original }}”)</span>
|
||||
{% endif %}
|
||||
<button type="button" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': original}|tojson }}" title="Remove theme" style="background:none; border:none; color:inherit; font-weight:bold; cursor:pointer;">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not matches and resolved_labels %}
|
||||
{% for label in resolved_labels %}
|
||||
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;">
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">No supplemental themes yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #fbbf24);">Needs attention</h4>
|
||||
{% if unresolved %}
|
||||
<div style="display:flex; flex-direction:column; gap:.5rem;">
|
||||
{% for item in unresolved %}
|
||||
<div style="border:1px solid rgba(234,179,8,0.4); background:rgba(250,204,21,0.08); border-radius:8px; padding:.5rem; font-size:12px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<strong>{{ item.input }}</strong>
|
||||
<button type="button" class="btn" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': item.input}|tojson }}" style="padding:.25rem .6rem; font-size:11px; background:#7f1d1d; border-color:#dc2626;">Remove</button>
|
||||
</div>
|
||||
{% if item.reason %}
|
||||
<div class="muted" style="margin-top:.35rem; font-size:11px;">Reason: {{ item.reason|replace('_',' ')|title }}</div>
|
||||
{% endif %}
|
||||
{% if item.suggestions %}
|
||||
<div style="margin-top:.5rem; display:flex; flex-wrap:wrap; gap:.35rem;">
|
||||
{% for suggestion in item.suggestions[:3] %}
|
||||
{% set suggestion_theme = suggestion.theme if suggestion.theme is defined else suggestion.get('theme') %}
|
||||
{% set suggestion_score = suggestion.score if suggestion.score is defined else suggestion.get('score') %}
|
||||
{% if suggestion_theme %}
|
||||
<button type="button" class="btn" hx-post="/build/themes/choose" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'original': item.input, 'choice': suggestion_theme}|tojson }}" style="padding:.25rem .5rem; font-size:11px; background:#1d4ed8; border-color:#2563eb;">
|
||||
Use {{ suggestion_theme }}{% if suggestion_score is not none %} ({{ '%.0f'|format(suggestion_score) }}%){% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-top:.35rem; font-size:11px;">No close matches found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">All themes resolved.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.75rem; font-size:11px;">
|
||||
Catalog version: {{ resolution.get('catalog_version', 'unknown') }} · Mode: {{ state.get('mode', 'permissive')|title }}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -40,6 +40,9 @@
|
|||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
{% if enable_custom_themes %}
|
||||
{% include "build/_new_deck_additional_themes.html" %}
|
||||
{% endif %}
|
||||
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
<div id="envFlags" style="margin-top:.5rem"></div>
|
||||
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
|
||||
<div id="themeSummary" style="margin-top:.5rem"></div>
|
||||
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
||||
<div style="margin-top:.35rem">
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
|
||||
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
|
||||
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
|
||||
{% if colors %}
|
||||
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
||||
<div style="overflow-x:auto">
|
||||
|
|
@ -30,28 +32,29 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for color, payload in colors.items()|dictsort %}
|
||||
{% for item in colors|dictsort %}
|
||||
{% set color = item[0] %}
|
||||
{% set payload = item[1] | default({}) %}
|
||||
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
|
||||
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">
|
||||
{% set entries = payload.entries or [] %}
|
||||
{% set entries = (payload.entries | default([])) %}
|
||||
{% if entries %}
|
||||
<details>
|
||||
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
||||
{% set preview = entries[:5] %}
|
||||
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
||||
{% for entry in entries %}
|
||||
{% if loop.index0 < 5 %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% elif loop.index0 == 5 %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
|
||||
{% break %}
|
||||
{% endif %}
|
||||
{% for entry in preview %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if entries|length > preview|length %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</details>
|
||||
{% else %}
|
||||
|
|
@ -134,6 +137,125 @@
|
|||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
// Environment flags card
|
||||
(function(){
|
||||
var target = document.getElementById('envFlags');
|
||||
if (!target) return;
|
||||
function renderEnv(data){
|
||||
if (!data || !data.flags) { target.textContent = 'Flags unavailable'; return; }
|
||||
var f = data.flags;
|
||||
function as01(v){ return (v ? '1' : '0'); }
|
||||
var lines = [];
|
||||
lines.push('<div><strong>Homepage & UI:</strong> '
|
||||
+ 'SHOW_SETUP=' + as01(f.SHOW_SETUP)
|
||||
+ ', SHOW_LOGS=' + as01(f.SHOW_LOGS)
|
||||
+ ', SHOW_DIAGNOSTICS=' + as01(f.SHOW_DIAGNOSTICS)
|
||||
+ ', SHOW_COMMANDERS=' + as01(f.SHOW_COMMANDERS)
|
||||
+ ', ENABLE_THEMES=' + as01(f.ENABLE_THEMES)
|
||||
+ ', ENABLE_CUSTOM_THEMES=' + as01(f.ENABLE_CUSTOM_THEMES)
|
||||
+ ', ALLOW_MUST_HAVES=' + as01(f.ALLOW_MUST_HAVES)
|
||||
+ ', THEME=' + String(f.DEFAULT_THEME || '')
|
||||
+ ', THEME_MATCH_MODE=' + String(f.THEME_MATCH_MODE || '')
|
||||
+ ', USER_THEME_LIMIT=' + String(f.USER_THEME_LIMIT || '')
|
||||
+ '</div>');
|
||||
lines.push('<div><strong>Random:</strong> '
|
||||
+ 'RANDOM_MODES=' + as01(f.RANDOM_MODES)
|
||||
+ ', RANDOM_UI=' + as01(f.RANDOM_UI)
|
||||
+ ', RANDOM_MAX_ATTEMPTS=' + String(f.RANDOM_MAX_ATTEMPTS || '')
|
||||
+ ', RANDOM_TIMEOUT_MS=' + String(f.RANDOM_TIMEOUT_MS || '')
|
||||
+ ', RANDOM_REROLL_THROTTLE_MS=' + String(f.RANDOM_REROLL_THROTTLE_MS || '')
|
||||
+ ', RANDOM_TELEMETRY=' + as01(f.RANDOM_TELEMETRY)
|
||||
+ ', RANDOM_STRUCTURED_LOGS=' + as01(f.RANDOM_STRUCTURED_LOGS)
|
||||
+ '</div>');
|
||||
lines.push('<div><strong>Rate limiting (random):</strong> '
|
||||
+ 'RATE_LIMIT_ENABLED=' + as01(f.RATE_LIMIT_ENABLED)
|
||||
+ ', WINDOW_S=' + String(f.RATE_LIMIT_WINDOW_S || '')
|
||||
+ ', RANDOM=' + String(f.RANDOM_RATE_LIMIT_RANDOM || '')
|
||||
+ ', BUILD=' + String(f.RANDOM_RATE_LIMIT_BUILD || '')
|
||||
+ ', SUGGEST=' + String(f.RANDOM_RATE_LIMIT_SUGGEST || '')
|
||||
+ '</div>');
|
||||
target.innerHTML = lines.join('');
|
||||
}
|
||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(renderEnv).catch(function(){ target.textContent='Flags unavailable'; }); } catch(_){ target.textContent='Flags unavailable'; }
|
||||
})();
|
||||
var themeSuppEl = document.getElementById('themeSuppMetrics');
|
||||
function renderThemeSupp(payload){
|
||||
if (!themeSuppEl) return;
|
||||
try {
|
||||
if (!payload || payload.ok !== true) {
|
||||
themeSuppEl.textContent = 'Theme metrics unavailable';
|
||||
return;
|
||||
}
|
||||
var metrics = payload.metrics || {};
|
||||
var total = metrics.total_builds != null ? Number(metrics.total_builds) : 0;
|
||||
if (!total) {
|
||||
themeSuppEl.textContent = 'No deck builds recorded yet.';
|
||||
return;
|
||||
}
|
||||
var withUser = metrics.with_user_themes != null ? Number(metrics.with_user_themes) : 0;
|
||||
var share = metrics.user_theme_share != null ? Number(metrics.user_theme_share) : 0;
|
||||
var sharePct = !Number.isNaN(share) ? (share * 100).toFixed(1) + '%' : '0%';
|
||||
var summary = metrics.last_summary || {};
|
||||
var commander = Array.isArray(summary.commanderThemes) ? summary.commanderThemes : [];
|
||||
var user = Array.isArray(summary.userThemes) ? summary.userThemes : [];
|
||||
var merged = Array.isArray(summary.mergedThemes) ? summary.mergedThemes : [];
|
||||
var unresolvedCount = summary.unresolvedCount != null ? Number(summary.unresolvedCount) : 0;
|
||||
var unresolved = Array.isArray(summary.unresolved) ? summary.unresolved : [];
|
||||
var mode = summary.mode || 'AND';
|
||||
var weight = summary.weight != null ? Number(summary.weight) : 1;
|
||||
var updated = metrics.last_updated || '';
|
||||
var topUser = Array.isArray(metrics.top_user_themes) ? metrics.top_user_themes : [];
|
||||
function joinList(arr){
|
||||
if (!arr || !arr.length) return '—';
|
||||
return arr.join(', ');
|
||||
}
|
||||
var html = '';
|
||||
html += '<div><strong>Total builds:</strong> ' + String(total) + ' (user themes ' + String(withUser) + '\u00a0| ' + sharePct + ')</div>';
|
||||
if (updated) {
|
||||
html += '<div style="font-size:11px;">Last updated: ' + String(updated) + '</div>';
|
||||
}
|
||||
html += '<div><strong>Commander themes:</strong> ' + joinList(commander) + '</div>';
|
||||
html += '<div><strong>User themes:</strong> ' + joinList(user) + '</div>';
|
||||
html += '<div><strong>Merged:</strong> ' + joinList(merged) + '</div>';
|
||||
var unresolvedLabel = '0';
|
||||
if (unresolvedCount > 0) {
|
||||
unresolvedLabel = String(unresolvedCount) + ' (' + joinList(unresolved) + ')';
|
||||
} else {
|
||||
unresolvedLabel = '0';
|
||||
}
|
||||
html += '<div><strong>Unresolved:</strong> ' + unresolvedLabel + '</div>';
|
||||
html += '<div style="font-size:11px;">Mode ' + String(mode) + ' · Weight ' + weight.toFixed(2) + '</div>';
|
||||
if (topUser.length) {
|
||||
var topLine = topUser.slice(0, 5).map(function(item){
|
||||
if (!item) return '';
|
||||
var t = item.theme != null ? String(item.theme) : '';
|
||||
var c = item.count != null ? String(item.count) : '0';
|
||||
return t + ' (' + c + ')';
|
||||
}).filter(Boolean);
|
||||
if (topLine.length) {
|
||||
html += '<div style="font-size:11px; opacity:0.75;">Top user themes: ' + topLine.join(', ') + '</div>';
|
||||
}
|
||||
}
|
||||
themeSuppEl.innerHTML = html;
|
||||
} catch (_){
|
||||
themeSuppEl.textContent = 'Theme metrics unavailable';
|
||||
}
|
||||
}
|
||||
function loadThemeSupp(){
|
||||
if (!themeSuppEl) return;
|
||||
themeSuppEl.textContent = 'Loading theme metrics…';
|
||||
fetch('/status/theme_metrics', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404) {
|
||||
themeSuppEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderThemeSupp(data); })
|
||||
.catch(function(){ themeSuppEl.textContent = 'Theme metrics unavailable'; });
|
||||
}
|
||||
loadThemeSupp();
|
||||
var tokenEl = document.getElementById('themeTokenStats');
|
||||
function renderTokens(payload){
|
||||
if (!tokenEl) return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue