feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection

This commit is contained in:
matt 2025-10-03 10:43:24 -07:00
parent 3a1b011dbc
commit 9428e09cef
39 changed files with 3643 additions and 198 deletions

View file

@ -37,6 +37,8 @@ SHOW_SETUP=1 # dockerhub: SHOW_SETUP="1"
SHOW_LOGS=1 # dockerhub: SHOW_LOGS="1"
SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1"
ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1"
ENABLE_CUSTOM_THEMES=1 # dockerhub: ENABLE_CUSTOM_THEMES="1"
USER_THEME_LIMIT=8 # dockerhub: USER_THEME_LIMIT="8"
ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
@ -50,6 +52,9 @@ RANDOM_MODES=1 # Enable backend random build endpoints
RANDOM_UI=1 # Show Surprise/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS=5 # Cap retry attempts for constrained random builds
# RANDOM_TIMEOUT_MS=5000 # Per-attempt timeout (ms)
# RANDOM_REROLL_THROTTLE_MS=350 # Minimum ms between reroll requests
# RANDOM_STRUCTURED_LOGS=0 # 1=emit structured JSON logs for random builds
# RANDOM_TELEMETRY=0 # 1=emit lightweight timing/attempt metrics
# HEADLESS_RANDOM_MODE=1 # Force headless runner to invoke random flow instead of scripted build
# RANDOM_THEME=Treasure # Legacy single-theme alias (maps to primary theme if others unset)
# RANDOM_PRIMARY_THEME=Treasure # Primary theme slug (case-insensitive)
@ -64,6 +69,13 @@ RANDOM_AUTO_FILL_TERTIARY=1 # Explicit tertiary auto-fill override (fallb
# RANDOM_SEED= # Optional deterministic seed (int or string)
# RANDOM_OUTPUT_JSON=deck_files/random_build.json # Where to write random build metadata payload
# Optional server rate limiting (random endpoints)
# RATE_LIMIT_ENABLED=0 # 1=enable server-side rate limiting for random endpoints
# RATE_LIMIT_WINDOW_S=10 # window size in seconds
# RATE_LIMIT_RANDOM=10 # max random attempts per window
# RATE_LIMIT_BUILD=10 # max builds per window
# RATE_LIMIT_SUGGEST=30 # max suggestions per window
############################
# Automation & Performance (Web)
############################
@ -95,6 +107,8 @@ WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0"
# DECK_SECONDARY_TAG=Treasure
# DECK_TERTIARY_TAG=Sacrifice
# DECK_BRACKET_LEVEL=3 # 15 Power/Bracket selection.
# DECK_ADDITIONAL_THEMES=Lifegain;Tokens Matter # Supplemental themes (comma/semicolon separated list) resolved via theme catalog.
# THEME_MATCH_MODE=permissive # permissive|strict fuzzy resolution (strict aborts on unresolved themes).
############################
# Category Toggles (Spell / Creature / Land Inclusion)

View file

@ -14,7 +14,33 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Summary
- _No unreleased changes yet._
- Theme catalog groundwork for supplemental/custom themes now ships with a generator script and focused test coverage.
- Web builder gains an Additional Themes section with fuzzy suggestions and strict/permissive toggles for user-supplied tags.
- Compose manifests and docs include new environment toggles for random reroll throttling, telemetry/logging, homepage commander tile, and optional random rate limiting.
### Added
- Script `python -m code.scripts.generate_theme_catalog` emits a normalized `theme_catalog.csv` with commander/card counts, deterministic ordering, and a reproducible version hash for supplemental theme inputs.
- Unit tests cover catalog generation on fixture CSVs and verify normalization removes duplicate theme variants.
- Loader `load_theme_catalog()` memoizes CSV parsing, validates required columns, and exposes typed entries plus version metadata for runtime integrations.
- Unit tests exercise loader success, empty-file fallback, and malformed-column scenarios.
- Fuzzy theme matcher builds a trigram-backed index with Levenshtein + Sequence similarity scoring, threshold constants, and resolution utilities for supplemental theme inputs.
- Unit tests validate normalization, typo recovery, suggestion quality, and enforce a basic performance ceiling for 400+ theme catalogs.
- Headless configs accept `additional_themes` + `theme_match_mode` with catalog-backed fuzzy resolution, strict/permissive enforcement, and persistence into exported run configs and diagnostics.
- Added targeted tests for additional theme parsing, strict failure handling, and permissive warning coverage.
- Web New Deck modal renders an “Additional Themes” HTMX partial supporting add/remove, suggestion adoption, mode switching, limit enforcement, and accessible live messaging (gated by `ENABLE_CUSTOM_THEMES`).
- Supplemental theme telemetry now records commander/user/merged theme payloads, exposes `/status/theme_metrics` for diagnostics, and surfaces user theme weighting via structured `user_theme_applied` logs and the diagnostics dashboard panel.
- Environment variables surfaced in compose, `.env.example`, and docs:
- `SHOW_COMMANDERS` (default `1`): show the Commanders browser tile.
- `RANDOM_REROLL_THROTTLE_MS` (default `350`): client guard to prevent rapid rerolls.
- `RANDOM_STRUCTURED_LOGS` (default `0`): emit structured JSON logs for random builds.
- `RANDOM_TELEMETRY` (default `0`): enable lightweight timing/attempt counters for diagnostics.
- `RATE_LIMIT_ENABLED` (default `0`), `RATE_LIMIT_WINDOW_S` (`10`), `RATE_LIMIT_RANDOM` (`10`), `RATE_LIMIT_BUILD` (`10`), `RATE_LIMIT_SUGGEST` (`30`): optional server-side rate limiting for random endpoints.
### Changed
- Run-config exports now surface `userThemes` and `themeCatalogVersion` metadata while retaining legacy fields; headless imports accept both aliases without changing hash-equivalent payloads when no user themes are present.
### Fixed
- Additional Themes now falls back to `theme_list.json` when `theme_catalog.csv` is absent, restoring resolution, removal, and build application for user-supplied themes across web and headless flows.
## [2.4.0] - 2025-10-02
### Summary
@ -154,6 +180,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Optional multi-pass performance CI variant (`preview_perf_ci_check.py --multi-pass`) to collect cold vs warm pass stats when diagnosing divergence.
### Changed
- Deck builder theme spell filler now consumes the shared ThemeContext weighting so user-supplied supplemental themes influence both creature and non-creature selections, with user weight multipliers boosting spell picks in parity with creatures.
- Random theme pool builder loads manual exclusions and always emits `auto_filled_themes` as a list (empty when unused), while enhanced metadata powers diagnostics telemetry.
- Random build summaries normalize multi-theme metadata before embedding in summary payloads and sidecar exports (trimming whitespace, deduplicating/normalizing resolved theme lists).
- Random Mode strict-theme toggle is now fully stateful: the checkbox and hidden field keep session/local storage in sync, HTMX rerolls reuse the flag, and API/full-build responses plus permalinks carry `strict_theme_match` through exports and sidecars.

View file

@ -253,6 +253,9 @@ See `.env.example` for the full catalog. Common knobs:
| `RANDOM_UI` | _(unset)_ | Show the Random Build homepage tile. |
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget for constrained random rolls. |
| `RANDOM_TIMEOUT_MS` | `5000` | Per-attempt timeout in milliseconds. |
| `RANDOM_REROLL_THROTTLE_MS` | `350` | Minimum ms between reroll requests (client guard). |
| `RANDOM_STRUCTURED_LOGS` | `0` | Emit structured JSON logs for random builds. |
| `RANDOM_TELEMETRY` | `0` | Enable lightweight timing/attempt counters. |
| `RANDOM_PRIMARY_THEME` / `RANDOM_SECONDARY_THEME` / `RANDOM_TERTIARY_THEME` | _(blank)_ | Override theme slots for random runs. |
| `RANDOM_SEED` | _(blank)_ | Deterministic seed. |
| `RANDOM_AUTO_FILL` | `1` | Allow automatic backfill of missing theme slots. |
@ -277,6 +280,23 @@ See `.env.example` for the full catalog. Common knobs:
| `OWNED_CARDS_DIR` / `CARD_LIBRARY_DIR` | `/app/owned_cards` | Override owned library directory. |
| `CARD_INDEX_EXTRA_CSV` | _(blank)_ | Inject a synthetic CSV into the card index for testing. |
### Supplemental themes
| Variable | Default | Purpose |
| --- | --- | --- |
| `DECK_ADDITIONAL_THEMES` | _(blank)_ | Comma/semicolon separated list of supplemental themes for headless builds (JSON exports also include the camelCase `userThemes` alias and `themeCatalogVersion` metadata; either alias is accepted on import). |
| `THEME_MATCH_MODE` | `permissive` | Controls fuzzy theme resolution (`strict` blocks unresolved inputs). |
### Random rate limiting (optional)
| Variable | Default | Purpose |
| --- | --- | --- |
| `RATE_LIMIT_ENABLED` | `0` | Enable server-side rate limiting for random endpoints. |
| `RATE_LIMIT_WINDOW_S` | `10` | Rolling window in seconds. |
| `RATE_LIMIT_RANDOM` | `10` | Max random attempts per window. |
| `RATE_LIMIT_BUILD` | `10` | Max full builds per window. |
| `RATE_LIMIT_SUGGEST` | `30` | Max suggestion calls per window. |
Advanced editorial and theme-catalog knobs (`EDITORIAL_*`, `SPLASH_ADAPTIVE`, etc.) are documented inline in `docker-compose.yml` and `.env.example`.
## Shared volumes

View file

@ -78,6 +78,7 @@ Every tile on the homepage connects to a workflow. Use these sections as your to
### Build a Deck
Start here for interactive deck creation.
- Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal.
- Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`.
- Locks, Replace, Compare, and Permalinks live in Step 5.
- Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set.
- `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement.
@ -88,6 +89,7 @@ Execute saved configs without manual input.
- Place JSON configs under `config/` (see `config/deck.json` for a template).
- Launch via homepage button or by running the container with `APP_MODE=cli` and `DECK_MODE=headless`.
- Respect include/exclude, owned, and theme overrides defined in the config file or env vars.
- Supplemental themes: add `"additional_themes": ["Theme A", "Theme B"]` plus `"theme_match_mode": "permissive"|"strict"`. Strict mode stops the build when a theme cannot be resolved; permissive keeps going and prints suggestions. Exported configs also include a camelCase alias (`"userThemes"`) and the active catalog version (`"themeCatalogVersion"`); either field name is accepted on import.
### Initial Setup
Refresh data and caches when formats shift.
@ -124,6 +126,11 @@ Investigate theme synergies and diagnostics.
```powershell
docker compose run --rm --entrypoint bash web -lc "python -m code.scripts.build_theme_catalog"
```
- Generate the normalized supplemental theme catalog (commander & card counts) for user-added themes:
```powershell
python -m code.scripts.generate_theme_catalog --output config/themes/theme_catalog.csv
```
Add `--logs-dir logs/generated` to mirror the CSV for diffing, or `--csv-dir` to point at alternate datasets.
- Advanced editorial knobs (`EDITORIAL_*`, `SPLASH_ADAPTIVE`, etc.) live in `.env.example` and are summarized in the env table below.
### Finished Decks
@ -195,6 +202,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `SHOW_DIAGNOSTICS` | `1` | Unlock diagnostics views and overlays. |
| `SHOW_COMMANDERS` | `1` | Enable the commander browser. |
| `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. |
| `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. |
| `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. |
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
| `THEME` | `dark` | Default UI theme (`system`, `light`, or `dark`). |
@ -206,10 +214,22 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `RANDOM_UI` | _(unset)_ | Show the Random Build homepage tile. |
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget when constraints are tight. |
| `RANDOM_TIMEOUT_MS` | `5000` | Per-attempt timeout in milliseconds. |
| `RANDOM_REROLL_THROTTLE_MS` | `350` | Minimum milliseconds between reroll requests (client-side guard). |
| `RANDOM_STRUCTURED_LOGS` | `0` | Emit structured JSON logs for random builds. |
| `RANDOM_TELEMETRY` | `0` | Enable lightweight timing/attempt metrics for diagnostics. |
| `RANDOM_PRIMARY_THEME` / `RANDOM_SECONDARY_THEME` / `RANDOM_TERTIARY_THEME` | _(blank)_ | Override selected themes. |
| `RANDOM_SEED` | _(blank)_ | Deterministic seed for reproducible builds. |
| `RANDOM_AUTO_FILL` | `1` | Allow auto-fill of missing theme slots. |
### Random rate limiting (optional)
| Variable | Default | Purpose |
| --- | --- | --- |
| `RATE_LIMIT_ENABLED` | `0` | Enable server-side rate limiting for random endpoints. |
| `RATE_LIMIT_WINDOW_S` | `10` | Rolling window size in seconds. |
| `RATE_LIMIT_RANDOM` | `10` | Max random attempts per window. |
| `RATE_LIMIT_BUILD` | `10` | Max full builds per window. |
| `RATE_LIMIT_SUGGEST` | `30` | Max suggestion calls per window. |
### Automation & performance
| Variable | Default | Purpose |
| --- | --- | --- |
@ -228,6 +248,13 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `OWNED_CARDS_DIR` / `CARD_LIBRARY_DIR` | `/app/owned_cards` | Override owned library path. |
| `CARD_INDEX_EXTRA_CSV` | _(blank)_ | Inject extra CSV data into the card index. |
### Supplemental themes
| Variable | Default | Purpose |
| --- | --- | --- |
| `DECK_ADDITIONAL_THEMES` | _(blank)_ | Comma/semicolon separated list of supplemental themes to apply in headless builds. |
| `THEME_MATCH_MODE` | `permissive` | Controls fuzzy resolution strictness (`strict` blocks unresolved themes) and seeds the web UI default. |
| `USER_THEME_LIMIT` | `8` | Maximum number of user-supplied themes allowed in the web builder. |
Refer to `.env.example` for advanced editorial, taxonomy, and experimentation knobs (`EDITORIAL_*`, `SPLASH_ADAPTIVE`, `WEB_THEME_FILTER_PREWARM`, etc.). Document any newly introduced variables in the README, DOCKER guide, compose files, and `.env.example`.
---

View file

@ -1,13 +1,30 @@
# MTG Python Deckbuilder ${VERSION}
## Summary
- _TBD_
- Theme catalog groundwork for supplemental/custom themes now ships with a generator script and focused test coverage.
- Web builder gains an Additional Themes section with fuzzy suggestions and strict/permissive toggles for user-supplied tags.
- Compose manifests and docs include new environment toggles for random reroll throttling, telemetry/logging, homepage commander tile, and optional random rate limiting.
## Added
- _TBD_
- Script `python -m code.scripts.generate_theme_catalog` emits a normalized `theme_catalog.csv` with commander/card counts, deterministic ordering, and a reproducible version hash for supplemental theme inputs.
- Unit tests cover catalog generation on fixture CSVs and verify normalization removes duplicate theme variants.
- Loader `load_theme_catalog()` memoizes CSV parsing, validates required columns, and exposes typed entries plus version metadata for runtime integrations.
- Unit tests exercise loader success, empty-file fallback, and malformed-column scenarios.
- Fuzzy theme matcher builds a trigram-backed index with Levenshtein + Sequence similarity scoring, threshold constants, and resolution utilities for supplemental theme inputs.
- Unit tests validate normalization, typo recovery, suggestion quality, and enforce a basic performance ceiling for 400+ theme catalogs.
- Headless configs accept `additional_themes` + `theme_match_mode` with catalog-backed fuzzy resolution, strict/permissive enforcement, and persistence into exported run configs and diagnostics.
- Added targeted tests for additional theme parsing, strict failure handling, and permissive warning coverage.
- Web New Deck modal renders an “Additional Themes” HTMX partial supporting add/remove, suggestion adoption, mode switching, limit enforcement, and accessible live messaging (gated by `ENABLE_CUSTOM_THEMES`).
- Supplemental theme telemetry now records commander/user/merged theme payloads, exposes `/status/theme_metrics` for diagnostics, and surfaces user theme weighting via structured `user_theme_applied` logs and the diagnostics dashboard panel.
- Environment variables surfaced in compose, `.env.example`, and docs:
- `SHOW_COMMANDERS` (default `1`): show the Commanders browser tile.
- `RANDOM_REROLL_THROTTLE_MS` (default `350`): client guard to prevent rapid rerolls.
- `RANDOM_STRUCTURED_LOGS` (default `0`): emit structured JSON logs for random builds.
- `RANDOM_TELEMETRY` (default `0`): enable lightweight timing/attempt counters for diagnostics.
- `RATE_LIMIT_ENABLED` (default `0`), `RATE_LIMIT_WINDOW_S` (`10`), `RATE_LIMIT_RANDOM` (`10`), `RATE_LIMIT_BUILD` (`10`), `RATE_LIMIT_SUGGEST` (`30`): optional server-side rate limiting for random endpoints.
## Changed
- _TBD_
- Run-config exports now surface `userThemes` and `themeCatalogVersion` metadata while retaining legacy fields; headless imports accept both aliases without changing hash-equivalent payloads when no user themes are present.
## Fixed
- _TBD_
- Additional Themes now falls back to `theme_list.json` when `theme_catalog.csv` is absent, restoring resolution, removal, and build application for user-supplied themes across web and headless flows.

View file

@ -41,6 +41,13 @@ from .phases.phase6_reporting import ReportingMixin
# Local application imports
from . import builder_constants as bc
from . import builder_utils as bu
from deck_builder.theme_context import (
ThemeContext,
build_theme_context,
default_user_theme_weight,
theme_summary_payload,
)
from deck_builder.theme_resolution import ThemeResolutionInfo
import os
from settings import CSV_DIRECTORY
from file_setup.setup import initial_setup
@ -113,6 +120,35 @@ class DeckBuilder(
except Exception:
# Leave RNG as-is on unexpected error
pass
def _theme_context_signature(self) -> Tuple[Any, ...]:
resolved = tuple(
str(tag) for tag in getattr(self, 'user_theme_resolved', []) if isinstance(tag, str)
)
resolution = getattr(self, 'user_theme_resolution', None)
resolution_id = id(resolution) if resolution is not None else None
return (
str(getattr(self, 'primary_tag', '') or ''),
str(getattr(self, 'secondary_tag', '') or ''),
str(getattr(self, 'tertiary_tag', '') or ''),
tuple(str(tag) for tag in getattr(self, 'selected_tags', []) if isinstance(tag, str)),
resolved,
str(getattr(self, 'tag_mode', 'AND') or 'AND').upper(),
round(float(getattr(self, 'user_theme_weight', 1.0)), 4),
resolution_id,
)
def get_theme_context(self) -> ThemeContext:
signature = self._theme_context_signature()
if self._theme_context_cache is None or self._theme_context_cache_key != signature:
context = build_theme_context(self)
self._theme_context_cache = context
self._theme_context_cache_key = signature
return self._theme_context_cache
def get_theme_summary_payload(self) -> Dict[str, Any]:
context = self.get_theme_context()
return theme_summary_payload(context)
def build_deck_full(self):
"""Orchestrate the full deck build process, chaining all major phases."""
start_ts = datetime.datetime.now()
@ -424,6 +460,19 @@ class DeckBuilder(
# Diagnostics storage for include/exclude processing
include_exclude_diagnostics: Optional[Dict[str, Any]] = None
# Supplemental user themes (M4: Config & Headless Support)
user_theme_requested: List[str] = field(default_factory=list)
user_theme_resolved: List[str] = field(default_factory=list)
user_theme_matches: List[Dict[str, Any]] = field(default_factory=list)
user_theme_unresolved: List[Dict[str, Any]] = field(default_factory=list)
user_theme_fuzzy_corrections: Dict[str, str] = field(default_factory=dict)
theme_match_mode: str = "permissive"
theme_catalog_version: Optional[str] = None
user_theme_weight: float = field(default_factory=default_user_theme_weight)
user_theme_resolution: Optional[ThemeResolutionInfo] = None
_theme_context_cache: Optional[ThemeContext] = field(default=None, init=False, repr=False)
_theme_context_cache_key: Optional[Tuple[Any, ...]] = field(default=None, init=False, repr=False)
# Deck library (cards added so far) mapping name->record
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
# Tag tracking: counts of unique cards per tag (not per copy)

View file

@ -5,6 +5,7 @@ from typing import List, Dict
from .. import builder_constants as bc
from .. import builder_utils as bu
from ..theme_context import annotate_theme_matches
import logging_util
logger = logging_util.logging.getLogger(__name__)
@ -31,48 +32,20 @@ class CreatureAdditionMixin:
if 'type' not in df.columns:
self.output_func("Card pool missing 'type' column; cannot add creatures.")
return
themes_ordered: List[tuple[str, str]] = []
if self.primary_tag:
themes_ordered.append(('primary', self.primary_tag))
if self.secondary_tag:
themes_ordered.append(('secondary', self.secondary_tag))
if self.tertiary_tag:
themes_ordered.append(('tertiary', self.tertiary_tag))
if not themes_ordered:
try:
context = self.get_theme_context() # type: ignore[attr-defined]
except Exception:
context = None
if context is None or not getattr(context, 'ordered_targets', []):
self.output_func("No themes selected; skipping creature addition.")
return
themes_ordered = list(context.ordered_targets)
selected_tags_lower = context.selected_slugs()
if not themes_ordered or not selected_tags_lower:
self.output_func("No themes selected; skipping creature addition.")
return
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
n_themes = len(themes_ordered)
if n_themes == 1:
base_map = {'primary': 1.0}
elif n_themes == 2:
base_map = {'primary': 0.6, 'secondary': 0.4}
else:
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
weights: Dict[str, float] = {}
boosted_roles: set[str] = set()
if n_themes > 1:
for role, tag in themes_ordered:
w = base_map.get(role, 0.0)
lt = tag.lower()
if 'kindred' in lt or 'tribal' in lt:
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
w *= mult
boosted_roles.add(role)
weights[role] = w
total = sum(weights.values())
if total > 1.0:
for r in list(weights):
weights[r] /= total
else:
rem = 1.0 - total
base_sum_unboosted = sum(base_map[r] for r,_t in themes_ordered if r not in boosted_roles)
if rem > 1e-6 and base_sum_unboosted > 0:
for r,_t in themes_ordered:
if r not in boosted_roles:
weights[r] += rem * (base_map[r] / base_sum_unboosted)
else:
weights['primary'] = 1.0
weights: Dict[str, float] = dict(getattr(context, 'weights', {}))
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
if commander_name and 'name' in creature_df.columns:
@ -80,12 +53,9 @@ class CreatureAdditionMixin:
if creature_df.empty:
self.output_func("No creature rows in dataset; skipping.")
return
selected_tags_lower = [t.lower() for _r,t in themes_ordered]
if '_parsedThemeTags' not in creature_df.columns:
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
creature_df['_normTags'] = creature_df['_parsedThemeTags']
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
combine_mode = getattr(self, 'tag_mode', 'AND')
creature_df = annotate_theme_matches(creature_df, context)
selected_tags_lower = context.selected_slugs()
combine_mode = context.combine_mode
base_top = 30
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
@ -116,10 +86,20 @@ class CreatureAdditionMixin:
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
bonus = getattr(context, 'match_bonus', 0.0)
user_matches = subset_all['_userMatch'] if '_userMatch' in subset_all.columns else None
names_list = subset_all['name'].tolist()
for idx, nm in enumerate(names_list):
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
if user_matches is not None:
try:
u_count = max(0.0, float(user_matches.iloc[idx]))
except Exception:
u_count = 0.0
if bonus > 1e-9 and u_count > 0:
w *= (1.0 + bonus * u_count)
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
for nm in chosen_all:
@ -127,12 +107,13 @@ class CreatureAdditionMixin:
continue
row = subset_all[subset_all['name'] == nm].iloc[0]
# Which selected themes does this card hit?
selected_display_tags = [t for _r, t in themes_ordered]
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
hits = row.get('_matchTags', [])
if not isinstance(hits, list):
try:
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
hits = list(hits)
except Exception:
hits = selected_display_tags
hits = []
match_score = row.get('_matchScore', row.get('_multiMatch', all_cnt))
self.add_card(
nm,
card_type=row.get('type','Creature'),
@ -144,7 +125,7 @@ class CreatureAdditionMixin:
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
synergy=int(round(match_score)) if match_score is not None else int(row.get('_multiMatch', all_cnt))
)
added_names.append(nm)
all_theme_added.append((nm, hits))
@ -153,30 +134,42 @@ class CreatureAdditionMixin:
break
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
# Per-theme distribution
per_theme_added: Dict[str, List[str]] = {r: [] for r,_t in themes_ordered}
for role, tag in themes_ordered:
w = weights.get(role, 0.0)
per_theme_added: Dict[str, List[str]] = {target.role: [] for target in themes_ordered}
for target in themes_ordered:
role = target.role
tag = target.display
slug = target.slug or (str(tag).lower() if tag else "")
w = weights.get(role, target.weight if hasattr(target, 'weight') else 0.0)
if w <= 0:
continue
remaining = max(0, desired_total - total_added)
if remaining == 0:
break
target = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
target = min(target, remaining)
if target <= 0:
target_count = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
target_count = min(target_count, remaining)
if target_count <= 0:
continue
tnorm = tag.lower()
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=slug: (tn in lst) or any(tn in (item or '') for item in lst))]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
if (creature_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty:
self.output_func(f"Theme '{tag}' produced no creature candidates.")
continue
sort_cols: List[str] = []
asc: List[bool] = []
if '_matchScore' in subset.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in subset.columns:
sort_cols.append('manaValue')
asc.append(True)
subset = subset.sort_values(by=sort_cols, ascending=asc, na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
@ -187,25 +180,51 @@ class CreatureAdditionMixin:
continue
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
bonus = getattr(context, 'match_bonus', 0.0)
if combine_mode == 'AND':
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus * 1.3 if mm_val >= 2 else (1.1 if mm_val >= 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
else:
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus if mm >= 2 else 1.0)
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus if mm_val >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target, rng=getattr(self, 'rng', None))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target_count, rng=getattr(self, 'rng', None))
for nm in chosen:
if commander_name and nm == commander_name:
continue
row = pool[pool['name']==nm].iloc[0]
match_score = row.get('_matchScore', row.get('_multiMatch', 0))
self.add_card(
nm,
card_type=row.get('type','Creature'),
@ -217,14 +236,15 @@ class CreatureAdditionMixin:
sub_role=role,
added_by='creature_add',
trigger_tag=tag,
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
synergy=int(round(match_score)) if match_score is not None else int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added_names.append(nm)
per_theme_added[role].append(nm)
total_added += 1
if total_added >= desired_total:
break
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
source_label = 'User' if target.source == 'user' else role.title()
self.output_func(f"Added {len(per_theme_added[role])} creatures for {source_label} theme '{tag}' (target {target_count}).")
if total_added >= desired_total:
break
# Fill remaining if still short
@ -239,10 +259,20 @@ class CreatureAdditionMixin:
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
sort_cols: List[str] = []
asc: List[bool] = []
if '_matchScore' in multi_pool.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in multi_pool.columns:
sort_cols.append('manaValue')
asc.append(True)
multi_pool = multi_pool.sort_values(by=sort_cols, ascending=asc, na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
@ -262,7 +292,7 @@ class CreatureAdditionMixin:
role='creature',
sub_role='fill',
added_by='creature_fill',
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
synergy=int(round(row.get('_matchScore', row.get('_multiMatch', 0)))) if '_matchScore' in row else int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
)
added_names.append(nm)
total_added += 1
@ -278,14 +308,18 @@ class CreatureAdditionMixin:
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
else:
self.output_func(f" - {nm}")
for role, tag in themes_ordered:
for target in themes_ordered:
role = target.role
tag = target.display
lst = per_theme_added.get(role, [])
if lst:
self.output_func(f" {role.title()} '{tag}': {len(lst)}")
label = 'User' if target.source == 'user' else role.title()
self.output_func(f" {label} '{tag}': {len(lst)}")
for nm in lst:
self.output_func(f" - {nm}")
else:
self.output_func(f" {role.title()} '{tag}': 0")
label = 'User' if target.source == 'user' else role.title()
self.output_func(f" {label} '{tag}': 0")
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
def add_creatures_phase(self):

View file

@ -6,6 +6,7 @@ import os
from .. import builder_utils as bu
from .. import builder_constants as bc
from ..theme_context import annotate_theme_matches
import logging_util
logger = logging_util.logging.getLogger(__name__)
@ -620,46 +621,17 @@ class SpellAdditionMixin:
df = getattr(self, '_combined_cards_df', None)
if df is None or df.empty or 'type' not in df.columns:
return
themes_ordered: List[tuple[str, str]] = []
if self.primary_tag:
themes_ordered.append(('primary', self.primary_tag))
if self.secondary_tag:
themes_ordered.append(('secondary', self.secondary_tag))
if self.tertiary_tag:
themes_ordered.append(('tertiary', self.tertiary_tag))
if not themes_ordered:
try:
context = self.get_theme_context() # type: ignore[attr-defined]
except Exception:
context = None
if context is None or not getattr(context, 'ordered_targets', []):
return
n_themes = len(themes_ordered)
if n_themes == 1:
base_map = {'primary': 1.0}
elif n_themes == 2:
base_map = {'primary': 0.6, 'secondary': 0.4}
else:
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
weights: Dict[str, float] = {}
boosted: set[str] = set()
if n_themes > 1:
for role, tag in themes_ordered:
w = base_map.get(role, 0.0)
lt = tag.lower()
if 'kindred' in lt or 'tribal' in lt:
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
w *= mult
boosted.add(role)
weights[role] = w
tot = sum(weights.values())
if tot > 1.0:
for r in weights:
weights[r] /= tot
else:
rem = 1.0 - tot
base_sum_unboosted = sum(base_map[r] for r, _ in themes_ordered if r not in boosted)
if rem > 1e-6 and base_sum_unboosted > 0:
for r, _ in themes_ordered:
if r not in boosted:
weights[r] += rem * (base_map[r] / base_sum_unboosted)
else:
weights['primary'] = 1.0
themes_ordered = list(context.ordered_targets)
selected_tags_lower = context.selected_slugs()
if not themes_ordered or not selected_tags_lower:
return
weights: Dict[str, float] = dict(getattr(context, 'weights', {}))
spells_df = df[
~df['type'].str.contains('Land', case=False, na=False)
& ~df['type'].str.contains('Creature', case=False, na=False)
@ -667,33 +639,33 @@ class SpellAdditionMixin:
spells_df = self._apply_bracket_pre_filters(spells_df)
if spells_df.empty:
return
selected_tags_lower = [t.lower() for _r, t in themes_ordered]
if '_parsedThemeTags' not in spells_df.columns:
spells_df['_parsedThemeTags'] = spells_df['themeTags'].apply(bu.normalize_tag_cell)
spells_df['_normTags'] = spells_df['_parsedThemeTags']
spells_df['_multiMatch'] = spells_df['_normTags'].apply(
lambda lst: sum(1 for t in selected_tags_lower if t in lst)
)
combine_mode = getattr(self, 'tag_mode', 'AND')
spells_df = annotate_theme_matches(spells_df, context)
combine_mode = context.combine_mode
base_top = 40
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
per_theme_added: Dict[str, List[str]] = {r: [] for r, _t in themes_ordered}
per_theme_added: Dict[str, List[str]] = {target.role: [] for target in themes_ordered}
total_added = 0
for role, tag in themes_ordered:
bonus = getattr(context, 'match_bonus', 0.0)
for target in themes_ordered:
role = target.role
tag = target.display
slug = target.slug or (str(tag).lower() if tag else "")
if not slug:
continue
if remaining - total_added <= 0:
break
w = weights.get(role, 0.0)
w = weights.get(role, target.weight if hasattr(target, 'weight') else 0.0)
if w <= 0:
continue
target = int(math.ceil(remaining * w * self._get_rng().uniform(1.0, 1.1)))
target = min(target, remaining - total_added)
if target <= 0:
available = remaining - total_added
target_count = int(math.ceil(available * w * self._get_rng().uniform(1.0, 1.1)))
target_count = min(target_count, available)
if target_count <= 0:
continue
tnorm = tag.lower()
subset = spells_df[
spells_df['_normTags'].apply(
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
lambda lst, tn=slug: (tn in lst) or any(tn in (item or '') for item in lst)
)
]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
@ -701,18 +673,20 @@ class SpellAdditionMixin:
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty:
continue
sort_cols: List[str] = []
asc: List[bool] = []
if '_matchScore' in subset.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in subset.columns:
subset = subset.sort_values(
by=['_multiMatch', 'edhrecRank', 'manaValue'],
ascending=[False, True, True],
na_position='last',
)
elif 'manaValue' in subset.columns:
subset = subset.sort_values(
by=['_multiMatch', 'manaValue'],
ascending=[False, True],
na_position='last',
)
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in subset.columns:
sort_cols.append('manaValue')
asc.append(True)
subset = subset.sort_values(by=sort_cols, ascending=asc, na_position='last')
# Prefer-owned: stable reorder before trimming to top_n
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
@ -726,23 +700,60 @@ class SpellAdditionMixin:
# Build weighted pool with optional owned multiplier
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
weighted_pool: list[tuple[str, float]] = []
if combine_mode == 'AND':
for nm, mm in base_pairs:
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus * 1.3 if mm_val >= 2 else (1.1 if mm_val >= 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
else:
for nm, mm in base_pairs:
base_w = (synergy_bonus if mm >= 2 else 1.0)
for idx, nm in enumerate(pool['name']):
mm = pool.iloc[idx].get('_matchScore', pool.iloc[idx].get('_multiMatch', 0))
try:
mm_val = float(mm)
except Exception:
mm_val = 0.0
base_w = (synergy_bonus if mm_val >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
if bonus > 1e-9:
try:
u_match = float(pool.iloc[idx].get('_userMatch', 0))
except Exception:
u_match = 0.0
if u_match > 0:
base_w *= (1.0 + bonus * u_match)
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target, rng=getattr(self, 'rng', None))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target_count, rng=getattr(self, 'rng', None))
for nm in chosen:
row = pool[pool['name'] == nm].iloc[0]
match_score = row.get('_matchScore', row.get('_multiMatch', 0))
synergy_value = None
try:
if match_score is not None:
val = float(match_score)
if not math.isnan(val):
synergy_value = int(round(val))
except Exception:
synergy_value = None
if synergy_value is None and '_multiMatch' in row:
try:
synergy_value = int(row.get('_multiMatch', 0))
except Exception:
synergy_value = None
self.add_card(
nm,
card_type=row.get('type', ''),
@ -753,7 +764,7 @@ class SpellAdditionMixin:
sub_role=role,
added_by='spell_theme_fill',
trigger_tag=tag,
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
synergy=synergy_value
)
per_theme_added[role].append(nm)
total_added += 1
@ -771,18 +782,20 @@ class SpellAdditionMixin:
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty:
sort_cols = []
asc = []
if '_matchScore' in multi_pool.columns:
sort_cols.append('_matchScore')
asc.append(False)
sort_cols.append('_multiMatch')
asc.append(False)
if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(
by=['_multiMatch', 'edhrecRank', 'manaValue'],
ascending=[False, True, True],
na_position='last',
)
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(
by=['_multiMatch', 'manaValue'],
ascending=[False, True],
na_position='last',
)
sort_cols.append('edhrecRank')
asc.append(True)
if 'manaValue' in multi_pool.columns:
sort_cols.append('manaValue')
asc.append(True)
multi_pool = multi_pool.sort_values(by=sort_cols, ascending=asc, na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
@ -790,6 +803,20 @@ class SpellAdditionMixin:
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
row = multi_pool[multi_pool['name'] == nm].iloc[0]
match_score = row.get('_matchScore', row.get('_multiMatch', 0))
synergy_value = None
try:
if match_score is not None:
val = float(match_score)
if not math.isnan(val):
synergy_value = int(round(val))
except Exception:
synergy_value = None
if synergy_value is None and '_multiMatch' in row:
try:
synergy_value = int(row.get('_multiMatch', 0))
except Exception:
synergy_value = None
self.add_card(
nm,
card_type=row.get('type', ''),
@ -799,7 +826,7 @@ class SpellAdditionMixin:
role='theme_spell',
sub_role='fill_multi',
added_by='spell_theme_fill',
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
synergy=synergy_value
)
total_added += 1
if total_added >= remaining:
@ -875,10 +902,16 @@ class SpellAdditionMixin:
self.output_func(f" - {nm}")
if total_added:
self.output_func("\nFinal Theme Spell Fill:")
for role, tag in themes_ordered:
for target in themes_ordered:
role = target.role
tag = target.display
lst = per_theme_added.get(role, [])
if lst:
self.output_func(f" {role.title()} '{tag}': {len(lst)}")
if target.source == 'user':
label = target.role.replace('_', ' ').title()
else:
label = role.title()
self.output_func(f" {label} '{tag}': {len(lst)}")
for nm in lst:
self.output_func(f" - {nm}")
self.output_func(f" Total Theme Spells Added: {total_added}")

View file

@ -7,7 +7,7 @@ import datetime as _dt
import re as _re
import logging_util
from code.deck_builder.summary_telemetry import record_land_summary
from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary
from code.deck_builder.shared_copy import build_land_headline, dfc_card_note
logger = logging_util.logging.getLogger(__name__)
@ -627,6 +627,12 @@ class ReportingMixin:
record_land_summary(land_summary)
except Exception: # pragma: no cover - diagnostics only
logger.debug("Failed to record MDFC telemetry", exc_info=True)
try:
theme_payload = self.get_theme_summary_payload() if hasattr(self, "get_theme_summary_payload") else None
if theme_payload:
record_theme_summary(theme_payload)
except Exception: # pragma: no cover - diagnostics only
logger.debug("Failed to record theme telemetry", exc_info=True)
return summary_payload
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
"""Export current decklist to CSV (enriched).
@ -1046,6 +1052,13 @@ class ReportingMixin:
# Capture fetch count (others vary run-to-run and are intentionally not recorded)
chosen_fetch = getattr(self, 'fetch_count', None)
user_themes: List[str] = [
str(theme)
for theme in getattr(self, 'user_theme_requested', [])
if isinstance(theme, str) and theme.strip()
]
theme_catalog_version = getattr(self, 'theme_catalog_version', None)
payload = {
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
"primary_tag": getattr(self, 'primary_tag', None),
@ -1067,6 +1080,12 @@ class ReportingMixin:
"enforcement_mode": getattr(self, 'enforcement_mode', 'warn'),
"allow_illegal": bool(getattr(self, 'allow_illegal', False)),
"fuzzy_matching": bool(getattr(self, 'fuzzy_matching', True)),
"additional_themes": user_themes,
"theme_match_mode": getattr(self, 'theme_match_mode', 'permissive'),
"theme_catalog_version": theme_catalog_version,
# CamelCase aliases for downstream consumers (web diagnostics, external tooling)
"userThemes": user_themes,
"themeCatalogVersion": theme_catalog_version,
# chosen fetch land count (others intentionally omitted for variance)
"fetch_count": chosen_fetch,
# actual ideal counts used for this run

View file

@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable
__all__ = [
"record_land_summary",
"get_mdfc_metrics",
"record_theme_summary",
"get_theme_metrics",
]
@ -22,6 +24,16 @@ _metrics: Dict[str, Any] = {
}
_top_cards: Counter[str] = Counter()
_theme_metrics: Dict[str, Any] = {
"total_builds": 0,
"with_user_themes": 0,
"last_updated": None,
"last_updated_iso": None,
"last_summary": None,
}
_user_theme_counter: Counter[str] = Counter()
_user_theme_labels: Dict[str, str] = {}
def _to_int(value: Any) -> int:
try:
@ -120,3 +132,110 @@ def _reset_metrics_for_test() -> None:
}
)
_top_cards.clear()
_theme_metrics.update(
{
"total_builds": 0,
"with_user_themes": 0,
"last_updated": None,
"last_updated_iso": None,
"last_summary": None,
}
)
_user_theme_counter.clear()
_user_theme_labels.clear()
def _sanitize_theme_list(values: Iterable[Any]) -> list[str]:
sanitized: list[str] = []
seen: set[str] = set()
for raw in values or []: # type: ignore[arg-type]
text = str(raw or "").strip()
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
sanitized.append(text)
return sanitized
def record_theme_summary(theme_summary: Dict[str, Any] | None) -> None:
if not isinstance(theme_summary, dict):
return
commander_themes = _sanitize_theme_list(theme_summary.get("commanderThemes") or [])
user_themes = _sanitize_theme_list(theme_summary.get("userThemes") or [])
requested = _sanitize_theme_list(theme_summary.get("requested") or [])
resolved = _sanitize_theme_list(theme_summary.get("resolved") or [])
unresolved_raw = theme_summary.get("unresolved") or []
if isinstance(unresolved_raw, (list, tuple)):
unresolved = [str(item).strip() for item in unresolved_raw if str(item).strip()]
else:
unresolved = []
mode = str(theme_summary.get("mode") or "AND")
try:
weight = float(theme_summary.get("weight", 1.0) or 1.0)
except Exception:
weight = 1.0
catalog_version = theme_summary.get("themeCatalogVersion")
matches = theme_summary.get("matches") if isinstance(theme_summary.get("matches"), list) else []
fuzzy = theme_summary.get("fuzzyCorrections") if isinstance(theme_summary.get("fuzzyCorrections"), dict) else {}
merged: list[str] = []
seen_merge: set[str] = set()
for collection in (commander_themes, user_themes):
for item in collection:
key = item.casefold()
if key in seen_merge:
continue
seen_merge.add(key)
merged.append(item)
timestamp = time.time()
iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp))
with _lock:
_theme_metrics["total_builds"] = int(_theme_metrics.get("total_builds", 0) or 0) + 1
if user_themes:
_theme_metrics["with_user_themes"] = int(_theme_metrics.get("with_user_themes", 0) or 0) + 1
for label in user_themes:
key = label.casefold()
_user_theme_counter[key] += 1
if key not in _user_theme_labels:
_user_theme_labels[key] = label
_theme_metrics["last_summary"] = {
"commanderThemes": commander_themes,
"userThemes": user_themes,
"mergedThemes": merged,
"requested": requested,
"resolved": resolved,
"unresolved": unresolved,
"unresolvedCount": len(unresolved),
"mode": mode,
"weight": weight,
"matches": matches,
"fuzzyCorrections": fuzzy,
"themeCatalogVersion": catalog_version,
}
_theme_metrics["last_updated"] = timestamp
_theme_metrics["last_updated_iso"] = iso
def get_theme_metrics() -> Dict[str, Any]:
with _lock:
total = int(_theme_metrics.get("total_builds", 0) or 0)
with_user = int(_theme_metrics.get("with_user_themes", 0) or 0)
share = (with_user / total) if total else 0.0
top_user: list[Dict[str, Any]] = []
for key, count in _user_theme_counter.most_common(10):
label = _user_theme_labels.get(key, key)
top_user.append({"theme": label, "count": int(count)})
return {
"total_builds": total,
"with_user_themes": with_user,
"user_theme_share": share,
"last_summary": _theme_metrics.get("last_summary"),
"last_updated": _theme_metrics.get("last_updated_iso"),
"top_user_themes": top_user,
}

View file

@ -0,0 +1,227 @@
"""Lightweight loader for the supplemental theme catalog CSV."""
from __future__ import annotations
import csv
import json
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Iterable, Tuple
from code.logging_util import get_logger
LOGGER = get_logger(__name__)
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CATALOG_PATH = ROOT / "config" / "themes" / "theme_catalog.csv"
JSON_FALLBACK_PATH = ROOT / "config" / "themes" / "theme_list.json"
REQUIRED_COLUMNS = {"theme", "commander_count", "card_count"}
@dataclass(frozen=True)
class ThemeCatalogEntry:
"""Single row from the supplemental theme catalog."""
theme: str
commander_count: int
card_count: int
@property
def source_count(self) -> int:
return self.commander_count + self.card_count
def _resolve_catalog_path(override: str | os.PathLike[str] | None) -> Path:
if override:
return Path(override).resolve()
env_override = os.environ.get("THEME_CATALOG_PATH")
if env_override:
return Path(env_override).resolve()
return DEFAULT_CATALOG_PATH
def _parse_metadata(line: str) -> Tuple[str, dict[str, str]]:
version = "unknown"
meta: dict[str, str] = {}
cleaned = line.lstrip("#").strip()
if not cleaned:
return version, meta
for token in cleaned.split():
if "=" not in token:
continue
key, value = token.split("=", 1)
meta[key] = value
if key == "version":
version = value
return version, meta
def _to_int(value: object) -> int:
if value is None:
return 0
if isinstance(value, int):
return value
text = str(value).strip()
if not text:
return 0
return int(text)
def load_theme_catalog(
catalog_path: str | os.PathLike[str] | None = None,
) -> tuple[list[ThemeCatalogEntry], str]:
"""Load the supplemental theme catalog with memoization.
Args:
catalog_path: Optional override path. Defaults to ``config/themes/theme_catalog.csv``
or the ``THEME_CATALOG_PATH`` environment variable.
Returns:
A tuple of ``(entries, version)`` where ``entries`` is a list of
:class:`ThemeCatalogEntry` and ``version`` is the parsed catalog version.
"""
resolved = _resolve_catalog_path(catalog_path)
mtime = 0.0
try:
mtime = resolved.stat().st_mtime
except FileNotFoundError:
pass
entries, version = _load_catalog_cached(str(resolved), mtime)
if entries:
return list(entries), version
# Fallback to JSON catalog when CSV export unavailable.
fallback_entries, fallback_version = _load_json_catalog()
if fallback_entries:
return list(fallback_entries), fallback_version
return list(entries), version
@lru_cache(maxsize=4)
def _load_catalog_cached(path_str: str, mtime: float) -> tuple[tuple[ThemeCatalogEntry, ...], str]:
path = Path(path_str)
if not path.exists():
LOGGER.warning("theme_catalog_missing path=%s", path)
return tuple(), "unknown"
with path.open("r", encoding="utf-8") as handle:
first_line = handle.readline()
version = "unknown"
if first_line.startswith("#"):
version, _ = _parse_metadata(first_line)
else:
handle.seek(0)
reader = csv.DictReader(handle)
if reader.fieldnames is None:
LOGGER.info("theme_catalog_loaded size=0 version=%s path=%s", version, path)
return tuple(), version
missing = REQUIRED_COLUMNS - set(reader.fieldnames)
if missing:
raise ValueError(
"theme_catalog.csv missing required columns: " + ", ".join(sorted(missing))
)
entries: list[ThemeCatalogEntry] = []
for row in reader:
if not row:
continue
theme = str(row.get("theme", "")).strip()
if not theme:
continue
try:
commander = _to_int(row.get("commander_count"))
card = _to_int(row.get("card_count"))
except ValueError as exc: # pragma: no cover - defensive, should not happen
raise ValueError(f"Invalid numeric values in theme catalog for theme '{theme}'") from exc
entries.append(ThemeCatalogEntry(theme=theme, commander_count=commander, card_count=card))
LOGGER.info("theme_catalog_loaded size=%s version=%s path=%s", len(entries), version, path)
return tuple(entries), version
def _load_json_catalog() -> tuple[tuple[ThemeCatalogEntry, ...], str]:
if not JSON_FALLBACK_PATH.exists():
return tuple(), "unknown"
try:
mtime = JSON_FALLBACK_PATH.stat().st_mtime
except Exception: # pragma: no cover - stat failures
mtime = 0.0
return _load_json_catalog_cached(str(JSON_FALLBACK_PATH), mtime)
@lru_cache(maxsize=2)
def _load_json_catalog_cached(path_str: str, mtime: float) -> tuple[tuple[ThemeCatalogEntry, ...], str]:
path = Path(path_str)
try:
raw_text = path.read_text(encoding="utf-8")
except Exception as exc: # pragma: no cover - IO edge cases
LOGGER.warning("theme_catalog_json_read_error path=%s error=%s", path, exc)
return tuple(), "unknown"
if not raw_text.strip():
return tuple(), "unknown"
try:
payload = json.loads(raw_text)
except Exception as exc: # pragma: no cover - malformed JSON
LOGGER.warning("theme_catalog_json_parse_error path=%s error=%s", path, exc)
return tuple(), "unknown"
themes = _iter_json_themes(payload)
entries = tuple(themes)
if not entries:
return tuple(), "unknown"
version = _extract_json_version(payload)
LOGGER.info("theme_catalog_loaded_json size=%s version=%s path=%s", len(entries), version, path)
return entries, version
def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]:
if not isinstance(payload, dict):
LOGGER.warning("theme_catalog_json_invalid_root type=%s", type(payload).__name__)
return tuple()
try:
from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path
except ImportError: # pragma: no cover - fallback when running as package
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
try:
catalog = ThemeCatalog.model_validate(payload)
except Exception as exc: # pragma: no cover - validation errors
LOGGER.warning("theme_catalog_json_validate_error error=%s", exc)
return tuple()
for theme in catalog.themes:
commander_count = len(theme.example_commanders or [])
# Prefer synergy count, fall back to example cards, ensure non-negative.
inferred_card_count = max(len(theme.synergies or []), len(theme.example_cards or []))
yield ThemeCatalogEntry(
theme=theme.theme,
commander_count=int(commander_count),
card_count=int(inferred_card_count),
)
def _extract_json_version(payload: object) -> str:
if not isinstance(payload, dict):
return "json"
meta = payload.get("metadata_info")
if isinstance(meta, dict):
version = meta.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
# Fallback to catalog hash if available
recorded = None
if isinstance(meta, dict):
recorded = meta.get("catalog_hash")
if isinstance(recorded, str) and recorded.strip():
return recorded.strip()
provenance = payload.get("provenance")
if isinstance(provenance, dict):
version = provenance.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
return "json"
__all__ = ["ThemeCatalogEntry", "load_theme_catalog"]

View file

@ -0,0 +1,318 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence
from deck_builder import builder_utils as bu
from deck_builder.theme_matcher import normalize_theme
from deck_builder.theme_resolution import ThemeResolutionInfo
import logging_util
logger = logging_util.logging.getLogger(__name__)
__all__ = [
"ThemeTarget",
"ThemeContext",
"default_user_theme_weight",
"build_theme_context",
"annotate_theme_matches",
"theme_summary_payload",
]
@dataclass(frozen=True)
class ThemeTarget:
"""Represents a prioritized theme target for selection weighting."""
role: str
display: str
slug: str
source: str # "commander" | "user"
weight: float = 0.0
@dataclass
class ThemeContext:
"""Captured theme aggregation for card selection and diagnostics."""
ordered_targets: List[ThemeTarget]
combine_mode: str
weights: Dict[str, float]
commander_slugs: List[str]
user_slugs: List[str]
resolution: Optional[ThemeResolutionInfo]
user_theme_weight: float
def selected_slugs(self) -> List[str]:
return [target.slug for target in self.ordered_targets if target.slug]
@property
def commander_selected(self) -> List[str]:
return list(self.commander_slugs)
@property
def user_selected(self) -> List[str]:
return list(self.user_slugs)
@property
def match_multiplier(self) -> float:
try:
value = float(self.user_theme_weight)
except Exception:
value = 1.0
return value if value > 0 else 1.0
@property
def match_bonus(self) -> float:
return max(0.0, self.match_multiplier - 1.0)
def default_user_theme_weight() -> float:
"""Read the default user theme weighting multiplier from the environment."""
raw = os.getenv("USER_THEME_WEIGHT")
if raw is None:
return 1.0
try:
value = float(raw)
except Exception:
logger.warning("Invalid USER_THEME_WEIGHT=%s; falling back to 1.0", raw)
return 1.0
return value if value >= 0 else 0.0
def _normalize_role(role: str) -> str:
try:
return str(role).strip().lower()
except Exception:
return str(role)
def _normalize_tag(value: str | None) -> str:
if not value:
return ""
try:
return normalize_theme(value)
except Exception:
return str(value).strip().lower()
def _theme_weight_factors(
commander_targets: Sequence[ThemeTarget],
user_targets: Sequence[ThemeTarget],
user_theme_weight: float,
) -> Dict[str, float]:
"""Compute normalized weight allocations for commander and user themes."""
role_factors = {
"primary": 1.0,
"secondary": 0.75,
"tertiary": 0.5,
}
raw_weights: Dict[str, float] = {}
for target in commander_targets:
factor = role_factors.get(_normalize_role(target.role), 0.5)
raw_weights[target.role] = max(0.0, factor)
user_total = max(0.0, user_theme_weight)
per_user = (user_total / len(user_targets)) if user_targets else 0.0
for target in user_targets:
raw_weights[target.role] = max(0.0, per_user)
total = sum(raw_weights.values())
if total <= 0:
if commander_targets:
fallback = 1.0 / len(commander_targets)
for target in commander_targets:
raw_weights[target.role] = fallback
elif user_targets:
fallback = 1.0 / len(user_targets)
for target in user_targets:
raw_weights[target.role] = fallback
else:
return {}
total = sum(raw_weights.values())
return {role: weight / total for role, weight in raw_weights.items()}
def build_theme_context(builder: Any) -> ThemeContext:
"""Construct theme ordering, weights, and resolution metadata from a builder."""
commander_targets: List[ThemeTarget] = []
for role in ("primary", "secondary", "tertiary"):
tag = getattr(builder, f"{role}_tag", None)
if not tag:
continue
slug = _normalize_tag(tag)
commander_targets.append(
ThemeTarget(role=role, display=str(tag), slug=slug, source="commander")
)
user_resolved: List[str] = []
resolution = getattr(builder, "user_theme_resolution", None)
if resolution is not None and isinstance(resolution, ThemeResolutionInfo):
user_resolved = list(resolution.resolved)
else:
raw_resolved = getattr(builder, "user_theme_resolved", [])
if isinstance(raw_resolved, (list, tuple)):
user_resolved = [str(item) for item in raw_resolved if str(item).strip()]
user_targets: List[ThemeTarget] = []
for index, theme in enumerate(user_resolved):
slug = _normalize_tag(theme)
role = f"user_{index + 1}"
user_targets.append(
ThemeTarget(role=role, display=str(theme), slug=slug, source="user")
)
combine_mode = str(getattr(builder, "tag_mode", "AND") or "AND").upper()
user_theme_weight = float(getattr(builder, "user_theme_weight", default_user_theme_weight()))
weights = _theme_weight_factors(commander_targets, user_targets, user_theme_weight)
ordered_raw = commander_targets + user_targets
ordered = [
ThemeTarget(
role=target.role,
display=target.display,
slug=target.slug,
source=target.source,
weight=weights.get(target.role, 0.0),
)
for target in ordered_raw
]
commander_slugs = [target.slug for target in ordered if target.source == "commander" and target.slug]
user_slugs = [target.slug for target in ordered if target.source == "user" and target.slug]
info = resolution if isinstance(resolution, ThemeResolutionInfo) else None
# Log once per context creation for diagnostics
try:
logger.debug(
"Theme context constructed: commander=%s user=%s mode=%s weight=%.3f",
commander_slugs,
user_slugs,
combine_mode,
user_theme_weight,
)
except Exception:
pass
try:
for target in ordered:
if target.source != "user":
continue
effective_weight = weights.get(target.role, target.weight)
logger.info(
"user_theme_applied theme='%s' slug=%s role=%s weight=%.3f mode=%s multiplier=%.3f",
target.display,
target.slug,
target.role,
float(effective_weight or 0.0),
combine_mode,
float(user_theme_weight or 0.0),
)
except Exception:
pass
return ThemeContext(
ordered_targets=ordered,
combine_mode=combine_mode,
weights=weights,
commander_slugs=commander_slugs,
user_slugs=user_slugs,
resolution=info,
user_theme_weight=user_theme_weight,
)
def annotate_theme_matches(df, context: ThemeContext):
"""Add commander/user match columns to a working dataframe."""
if df is None or getattr(df, "empty", True):
return df
if "_parsedThemeTags" not in df.columns:
df = df.copy()
df["_parsedThemeTags"] = df["themeTags"].apply(bu.normalize_tag_cell)
if "_normTags" not in df.columns:
df = df.copy()
df["_normTags"] = df["_parsedThemeTags"]
commander_set = set(context.commander_slugs)
user_set = set(context.user_slugs)
def _match_count(tags: Iterable[str], needles: set[str]) -> int:
if not tags or not needles:
return 0
try:
return sum(1 for tag in tags if tag in needles)
except Exception:
total = 0
for tag in tags:
try:
if tag in needles:
total += 1
except Exception:
continue
return total
df["_commanderMatch"] = df["_normTags"].apply(lambda tags: _match_count(tags, commander_set))
df["_userMatch"] = df["_normTags"].apply(lambda tags: _match_count(tags, user_set))
df["_multiMatch"] = df["_commanderMatch"] + df["_userMatch"]
bonus = context.match_bonus
if bonus > 0:
df["_matchScore"] = df["_multiMatch"] + (df["_userMatch"] * bonus)
else:
df["_matchScore"] = df["_multiMatch"]
def _collect_hits(tags: Iterable[str]) -> List[str]:
if not tags:
return []
hits: List[str] = []
seen: set[str] = set()
for target in context.ordered_targets:
slug = target.slug
if not slug or slug in seen:
continue
try:
if slug in tags:
hits.append(target.display)
seen.add(slug)
except Exception:
continue
return hits
df["_matchTags"] = df["_normTags"].apply(_collect_hits)
return df
def theme_summary_payload(context: ThemeContext) -> Dict[str, Any]:
"""Produce a structured payload for UI/JSON exports summarizing themes."""
info = context.resolution
requested: List[str] = []
resolved: List[str] = []
unresolved: List[str] = []
matches: List[Dict[str, Any]] = []
fuzzy: Dict[str, str] = {}
catalog_version: Optional[str] = None
if info is not None:
requested = list(info.requested)
resolved = list(info.resolved)
unresolved = [item.get("input", "") for item in info.unresolved]
matches = list(info.matches)
fuzzy = dict(info.fuzzy_corrections)
catalog_version = info.catalog_version
else:
resolved = [target.display for target in context.ordered_targets if target.source == "user"]
return {
"commanderThemes": [target.display for target in context.ordered_targets if target.source == "commander"],
"userThemes": [target.display for target in context.ordered_targets if target.source == "user"],
"requested": requested,
"resolved": resolved,
"unresolved": unresolved,
"matches": matches,
"fuzzyCorrections": fuzzy,
"mode": context.combine_mode,
"weight": context.user_theme_weight,
"themeCatalogVersion": catalog_version,
}

View file

@ -0,0 +1,257 @@
"""Fuzzy matching utilities for supplemental theme selection."""
from __future__ import annotations
import difflib
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Iterable, List, Sequence
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry
__all__ = [
"normalize_theme",
"ThemeScore",
"ResolutionResult",
"ThemeMatcher",
"HIGH_MATCH_THRESHOLD",
"ACCEPT_MATCH_THRESHOLD",
"SUGGEST_MATCH_THRESHOLD",
]
_SPACE_RE = re.compile(r"\s+")
_NON_ALNUM_RE = re.compile(r"[^a-z0-9 ]+")
HIGH_MATCH_THRESHOLD = 90.0
ACCEPT_MATCH_THRESHOLD = 80.0
SUGGEST_MATCH_THRESHOLD = 60.0
MIN_QUERY_LENGTH = 3
MAX_SUGGESTIONS = 5
def normalize_theme(value: str) -> str:
text = (value or "").strip()
text = _SPACE_RE.sub(" ", text)
return text.casefold()
@dataclass(frozen=True)
class _IndexedTheme:
display: str
normalized: str
tokens: tuple[str, ...]
trigrams: tuple[str, ...]
@dataclass(frozen=True)
class ThemeScore:
theme: str
score: float
def rounded(self) -> float:
return round(self.score, 4)
@dataclass(frozen=True)
class ResolutionResult:
matched_theme: str | None
score: float
reason: str
suggestions: List[ThemeScore]
def _tokenize(text: str) -> tuple[str, ...]:
cleaned = _NON_ALNUM_RE.sub(" ", text)
parts = [p for p in cleaned.split() if p]
return tuple(parts)
def _trigrams(text: str) -> tuple[str, ...]:
text = text.replace(" ", "_")
if len(text) < 3:
return tuple(text)
extended = f"__{text}__"
grams = [extended[i : i + 3] for i in range(len(extended) - 2)]
return tuple(sorted(set(grams)))
def _build_index(entries: Sequence[ThemeCatalogEntry]) -> tuple[tuple[_IndexedTheme, ...], dict[str, set[int]]]:
indexed: list[_IndexedTheme] = []
trigram_map: dict[str, set[int]] = {}
for idx, entry in enumerate(entries):
norm = normalize_theme(entry.theme)
tokens = _tokenize(norm)
trigrams = _trigrams(norm)
indexed.append(
_IndexedTheme(
display=entry.theme,
normalized=norm,
tokens=tokens,
trigrams=trigrams,
)
)
for gram in trigrams:
trigram_map.setdefault(gram, set()).add(idx)
return tuple(indexed), trigram_map
@dataclass
class _QueryInfo:
normalized: str
tokens: tuple[str, ...]
trigrams: tuple[str, ...]
def _levenshtein(a: str, b: str) -> int:
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
if len(a) < len(b):
a, b = b, a
previous = list(range(len(b) + 1))
for i, ca in enumerate(a, start=1):
current = [i]
for j, cb in enumerate(b, start=1):
insert_cost = current[j - 1] + 1
delete_cost = previous[j] + 1
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
current.append(min(insert_cost, delete_cost, replace_cost))
previous = current
return previous[-1]
def _similarity(query: _QueryInfo, candidate: _IndexedTheme) -> float:
if not candidate.trigrams:
return 0.0
if query.normalized == candidate.normalized:
return 100.0
query_tokens = set(query.tokens)
candidate_tokens = set(candidate.tokens)
shared_tokens = len(query_tokens & candidate_tokens)
token_base = max(len(query_tokens), len(candidate_tokens), 1)
token_score = 100.0 * shared_tokens / token_base
query_trigrams = set(query.trigrams)
candidate_trigrams = set(candidate.trigrams)
if not query_trigrams:
trigram_score = 0.0
else:
intersection = len(query_trigrams & candidate_trigrams)
union = len(query_trigrams | candidate_trigrams)
trigram_score = 100.0 * intersection / union if union else 0.0
seq_score = 100.0 * difflib.SequenceMatcher(None, query.normalized, candidate.normalized).ratio()
distance = _levenshtein(query.normalized, candidate.normalized)
max_len = max(len(query.normalized), len(candidate.normalized))
distance_score = 100.0 * (1.0 - distance / max_len) if max_len else 0.0
prefix_bonus = 5.0 if candidate.normalized.startswith(query.normalized) else 0.0
token_prefix_bonus = 5.0 if candidate.tokens and query.tokens and candidate.tokens[0].startswith(query.tokens[0]) else 0.0
token_similarity_bonus = 0.0
if query.tokens and candidate.tokens:
token_similarity_bonus = 5.0 * difflib.SequenceMatcher(None, query.tokens[0], candidate.tokens[0]).ratio()
distance_bonus = 0.0
if distance <= 2:
distance_bonus = 10.0 - (3.0 * distance)
score = (
0.3 * trigram_score
+ 0.2 * token_score
+ 0.3 * seq_score
+ 0.2 * distance_score
+ prefix_bonus
+ token_prefix_bonus
+ distance_bonus
+ token_similarity_bonus
)
if distance <= 2:
score = max(score, 85.0 - 5.0 * distance)
return min(score, 100.0)
class ThemeMatcher:
"""Fuzzy matcher backed by a trigram index.
On dev hardware (2025-10-02) resolving 20 queries against a 400-theme
catalog completes in 0.65s (~0.03s per query) including Levenshtein
scoring.
"""
def __init__(self, entries: Sequence[ThemeCatalogEntry]):
self._entries: tuple[_IndexedTheme, ...]
self._trigram_index: dict[str, set[int]]
self._entries, self._trigram_index = _build_index(entries)
@classmethod
def from_entries(cls, entries: Iterable[ThemeCatalogEntry]) -> "ThemeMatcher":
return cls(list(entries))
def resolve(self, raw_query: str, *, limit: int = MAX_SUGGESTIONS) -> ResolutionResult:
normalized = normalize_theme(raw_query)
if not normalized:
return ResolutionResult(matched_theme=None, score=0.0, reason="empty_input", suggestions=[])
query = _QueryInfo(
normalized=normalized,
tokens=_tokenize(normalized),
trigrams=_trigrams(normalized),
)
if len(normalized.replace(" ", "")) < MIN_QUERY_LENGTH:
exact = next((entry for entry in self._entries if entry.normalized == normalized), None)
if exact:
return ResolutionResult(
matched_theme=exact.display,
score=100.0,
reason="short_exact",
suggestions=[ThemeScore(theme=exact.display, score=100.0)],
)
return ResolutionResult(matched_theme=None, score=0.0, reason="input_too_short", suggestions=[])
candidates = self._candidate_indexes(query)
if not candidates:
return ResolutionResult(matched_theme=None, score=0.0, reason="no_candidates", suggestions=[])
scored: list[ThemeScore] = []
seen: set[str] = set()
for idx in candidates:
entry = self._entries[idx]
score = _similarity(query, entry)
if score <= 0 or score < 20.0:
continue
if entry.display in seen:
continue
scored.append(ThemeScore(theme=entry.display, score=score))
seen.add(entry.display)
scored.sort(key=lambda item: (-item.score, item.theme.casefold(), item.theme))
suggestions = scored[:limit]
if not suggestions:
return ResolutionResult(matched_theme=None, score=0.0, reason="no_match", suggestions=[])
top = suggestions[0]
if top.score >= HIGH_MATCH_THRESHOLD:
return ResolutionResult(matched_theme=top.theme, score=top.score, reason="high_confidence", suggestions=suggestions)
if top.score >= ACCEPT_MATCH_THRESHOLD:
return ResolutionResult(matched_theme=top.theme, score=top.score, reason="accepted_confidence", suggestions=suggestions)
if top.score >= SUGGEST_MATCH_THRESHOLD:
return ResolutionResult(matched_theme=None, score=top.score, reason="suggestions", suggestions=suggestions)
return ResolutionResult(matched_theme=None, score=top.score, reason="no_match", suggestions=suggestions)
def _candidate_indexes(self, query: _QueryInfo) -> set[int]:
if not query.trigrams:
return set(range(len(self._entries)))
candidates: set[int] = set()
for gram in query.trigrams:
candidates.update(self._trigram_index.get(gram, ()))
return candidates or set(range(len(self._entries)))
@lru_cache(maxsize=128)
def build_matcher(entries: tuple[ThemeCatalogEntry, ...]) -> ThemeMatcher:
return ThemeMatcher(entries)

View file

@ -0,0 +1,216 @@
"""Shared theme resolution utilities for supplemental user themes.
This module centralizes the fuzzy resolution logic so both the headless
runner and the web UI can reuse a consistent implementation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Sequence
from deck_builder.theme_catalog_loader import load_theme_catalog
from deck_builder.theme_matcher import (
build_matcher,
normalize_theme,
)
__all__ = [
"ThemeResolutionInfo",
"normalize_theme_match_mode",
"clean_theme_inputs",
"parse_theme_list",
"resolve_additional_theme_inputs",
]
@dataclass
class ThemeResolutionInfo:
"""Captures the outcome of resolving user-supplied supplemental themes."""
requested: List[str]
mode: str
catalog_version: str
resolved: List[str]
matches: List[Dict[str, Any]]
unresolved: List[Dict[str, Any]]
fuzzy_corrections: Dict[str, str]
def normalize_theme_match_mode(value: str | None) -> str:
"""Normalize theme match mode inputs to ``strict`` or ``permissive``."""
if value is None:
return "permissive"
text = str(value).strip().lower()
if text in {"strict", "s"}:
return "strict"
return "permissive"
def clean_theme_inputs(values: Sequence[Any]) -> List[str]:
"""Normalize, deduplicate, and filter empty user-provided theme strings."""
cleaned: List[str] = []
seen: set[str] = set()
for value in values or []:
try:
text = str(value).strip()
except Exception:
continue
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
cleaned.append(text)
return cleaned
def parse_theme_list(raw: str | None) -> List[str]:
"""Parse CLI/config style theme lists separated by comma or semicolon."""
if raw is None:
return []
try:
text = str(raw)
except Exception:
return []
text = text.strip()
if not text:
return []
delimiter = ";" if ";" in text else ","
parts = [part.strip() for part in text.split(delimiter)]
return clean_theme_inputs(parts)
def resolve_additional_theme_inputs(
requested: Sequence[str],
mode: str,
*,
commander_tags: Iterable[str] = (),
) -> ThemeResolutionInfo:
"""Resolve user-provided additional themes against the catalog.
Args:
requested: Raw user inputs.
mode: Strictness mode (``strict`` aborts on unresolved themes).
commander_tags: Tags already supplied by the selected commander; these
are used to deduplicate resolved results so we do not re-add themes
already covered by the commander selection.
Returns:
:class:`ThemeResolutionInfo` describing resolved and unresolved themes.
Raises:
ValueError: When ``mode`` is strict and one or more inputs cannot be
resolved with sufficient confidence.
"""
normalized_mode = normalize_theme_match_mode(mode)
cleaned_inputs = clean_theme_inputs(requested)
entries, version = load_theme_catalog(None)
if not cleaned_inputs:
return ThemeResolutionInfo(
requested=[],
mode=normalized_mode,
catalog_version=version,
resolved=[],
matches=[],
unresolved=[],
fuzzy_corrections={},
)
if not entries:
unresolved = [
{"input": raw, "reason": "catalog_missing", "score": 0.0, "suggestions": []}
for raw in cleaned_inputs
]
if normalized_mode == "strict":
raise ValueError(
"Unable to resolve additional themes in strict mode: catalog unavailable"
)
return ThemeResolutionInfo(
requested=cleaned_inputs,
mode=normalized_mode,
catalog_version=version,
resolved=[],
matches=[],
unresolved=unresolved,
fuzzy_corrections={},
)
matcher = build_matcher(tuple(entries))
matches: List[Dict[str, Any]] = []
unresolved: List[Dict[str, Any]] = []
fuzzy: Dict[str, str] = {}
for raw in cleaned_inputs:
result = matcher.resolve(raw)
suggestions = [
{"theme": suggestion.theme, "score": float(round(suggestion.score, 4))}
for suggestion in result.suggestions
]
if result.matched_theme:
matches.append(
{
"input": raw,
"matched": result.matched_theme,
"score": float(round(result.score, 4)),
"reason": result.reason,
"suggestions": suggestions,
}
)
if normalize_theme(raw) != normalize_theme(result.matched_theme):
fuzzy[raw] = result.matched_theme
else:
unresolved.append(
{
"input": raw,
"reason": result.reason,
"score": float(round(result.score, 4)),
"suggestions": suggestions,
}
)
commander_set = {
normalize_theme(tag)
for tag in commander_tags
if isinstance(tag, str) and tag.strip()
}
resolved: List[str] = []
seen_resolved: set[str] = set()
for match in matches:
norm = normalize_theme(match["matched"])
if norm in seen_resolved:
continue
if commander_set and norm in commander_set:
continue
resolved.append(match["matched"])
seen_resolved.add(norm)
if normalized_mode == "strict" and unresolved:
parts: List[str] = []
for item in unresolved:
suggestion_text = ", ".join(
f"{s['theme']} ({s['score']:.1f})" for s in item.get("suggestions", [])
)
if suggestion_text:
parts.append(f"{item['input']} (suggestions: {suggestion_text})")
else:
parts.append(item["input"])
raise ValueError(
"Unable to resolve additional themes in strict mode: " + "; ".join(parts)
)
return ThemeResolutionInfo(
requested=cleaned_inputs,
mode=normalized_mode,
catalog_version=version,
resolved=resolved,
matches=matches,
unresolved=unresolved,
fuzzy_corrections=fuzzy,
)

View file

@ -10,6 +10,13 @@ from typing import Any, Dict, List, Optional, Tuple
from deck_builder.builder import DeckBuilder
from deck_builder import builder_constants as bc
from deck_builder.theme_resolution import (
ThemeResolutionInfo,
clean_theme_inputs,
normalize_theme_match_mode,
parse_theme_list,
resolve_additional_theme_inputs,
)
from file_setup.setup import initial_setup
from tagging import tagger
from exceptions import CommanderValidationError
@ -81,6 +88,7 @@ def _tokenize_commander_name(value: Any) -> List[str]:
return [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
@lru_cache(maxsize=1)
def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]:
builder = DeckBuilder(
@ -193,6 +201,10 @@ def run(
allow_illegal: bool = False,
fuzzy_matching: bool = True,
seed: Optional[int | str] = None,
additional_themes: Optional[List[str]] = None,
theme_match_mode: str = "permissive",
user_theme_resolution: Optional[ThemeResolutionInfo] = None,
user_theme_weight: Optional[float] = None,
) -> DeckBuilder:
"""Run a scripted non-interactive deck build and return the DeckBuilder instance."""
trimmed_commander = (command_name or "").strip()
@ -275,6 +287,34 @@ def run(
except Exception:
pass
normalized_theme_mode = normalize_theme_match_mode(theme_match_mode)
theme_resolution = user_theme_resolution
if theme_resolution is None:
theme_resolution = resolve_additional_theme_inputs(
additional_themes or [],
normalized_theme_mode,
)
else:
if theme_resolution.mode != normalized_theme_mode:
theme_resolution = resolve_additional_theme_inputs(
theme_resolution.requested,
normalized_theme_mode,
)
try:
builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined]
builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined]
builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined]
builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined]
builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined]
builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined]
builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined]
builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined]
if user_theme_weight is not None:
builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined]
except Exception:
pass
# If ideal_counts are provided (from JSON), use them as the current defaults
# so the step 2 prompts will show these values and our blank entries will accept them.
if isinstance(ideal_counts, dict) and ideal_counts:
@ -1207,6 +1247,32 @@ def _build_arg_parser() -> argparse.ArgumentParser:
include_group.add_argument("--fuzzy-matching", metavar="BOOL", type=_parse_bool, default=None,
help="Enable fuzzy card name matching (bool: true/false/1/0)")
theme_group = p.add_argument_group(
"Additional Themes",
"Supplement commander themes with catalog-backed user inputs",
)
theme_group.add_argument(
"--additional-themes",
metavar="THEMES",
type=parse_theme_list,
default=None,
help="Additional theme names (comma or semicolon separated)",
)
theme_group.add_argument(
"--theme-match-mode",
metavar="MODE",
choices=["strict", "permissive"],
default=None,
help="Theme resolution strategy (strict requires all matches)",
)
theme_group.add_argument(
"--user-theme-weight",
metavar="FLOAT",
type=float,
default=None,
help="Weight multiplier applied to supplemental themes (default 1.0)",
)
# Random mode configuration (parity with web random builder)
random_group = p.add_argument_group(
"Random Mode",
@ -1428,6 +1494,9 @@ def _main() -> int:
resolved_primary_choice = args.primary_choice
resolved_secondary_choice = args.secondary_choice
resolved_tertiary_choice = args.tertiary_choice
primary_tag_name: Optional[str] = None
secondary_tag_name: Optional[str] = None
tertiary_tag_name: Optional[str] = None
try:
# Collect tag names from CLI, JSON, and environment (CLI takes precedence)
@ -1511,6 +1580,69 @@ def _main() -> int:
except Exception:
pass
additional_themes_json: List[str] = []
try:
collected: List[str] = []
for key in ("additional_themes", "userThemes"):
raw_value = json_cfg.get(key)
if isinstance(raw_value, list):
collected.extend(raw_value)
if collected:
additional_themes_json = clean_theme_inputs(collected)
except Exception:
additional_themes_json = []
cli_additional_themes: List[str] = []
if hasattr(args, "additional_themes") and args.additional_themes:
if isinstance(args.additional_themes, list):
cli_additional_themes = clean_theme_inputs(args.additional_themes)
else:
cli_additional_themes = parse_theme_list(str(args.additional_themes))
env_additional_themes = parse_theme_list(os.getenv("DECK_ADDITIONAL_THEMES"))
additional_theme_inputs = (
cli_additional_themes
or env_additional_themes
or additional_themes_json
)
theme_mode_value = getattr(args, "theme_match_mode", None)
if not theme_mode_value:
theme_mode_value = os.getenv("THEME_MATCH_MODE")
if not theme_mode_value:
theme_mode_value = json_cfg.get("theme_match_mode") or json_cfg.get("themeMatchMode")
normalized_theme_mode = normalize_theme_match_mode(theme_mode_value)
weight_value: Optional[float]
if hasattr(args, "user_theme_weight") and args.user_theme_weight is not None:
weight_value = args.user_theme_weight
else:
cfg_weight = json_cfg.get("user_theme_weight")
if cfg_weight is not None:
try:
weight_value = float(cfg_weight)
except Exception:
weight_value = None
else:
weight_value = None
commander_tag_names = [
str(tag)
for tag in (primary_tag_name, secondary_tag_name, tertiary_tag_name)
if isinstance(tag, str) and tag and str(tag).strip()
]
try:
theme_resolution = resolve_additional_theme_inputs(
additional_theme_inputs,
normalized_theme_mode,
commander_tags=commander_tag_names,
)
except ValueError as exc:
print(str(exc))
return 2
resolved = {
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
"add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]),
@ -1536,18 +1668,45 @@ def _main() -> int:
"enforcement_mode": args.enforcement_mode or json_cfg.get("enforcement_mode", "warn"),
"allow_illegal": args.allow_illegal if args.allow_illegal is not None else bool(json_cfg.get("allow_illegal", False)),
"fuzzy_matching": args.fuzzy_matching if args.fuzzy_matching is not None else bool(json_cfg.get("fuzzy_matching", True)),
"additional_themes": list(theme_resolution.requested),
"theme_match_mode": theme_resolution.mode,
"user_theme_weight": weight_value,
}
if args.dry_run:
print(json.dumps(resolved, indent=2))
preview = dict(resolved)
preview["additional_themes_resolved"] = list(theme_resolution.resolved)
preview["additional_themes_unresolved"] = list(theme_resolution.unresolved)
preview["theme_catalog_version"] = theme_resolution.catalog_version
preview["fuzzy_corrections"] = dict(theme_resolution.fuzzy_corrections)
preview["user_theme_weight"] = weight_value
print(json.dumps(preview, indent=2))
return 0
if not str(resolved.get("command_name", "")).strip():
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
return 2
if theme_resolution.requested:
if theme_resolution.fuzzy_corrections:
print("Fuzzy theme corrections applied:")
for original, corrected in theme_resolution.fuzzy_corrections.items():
print(f"{original}{corrected}")
if theme_resolution.unresolved and theme_resolution.mode != "strict":
print("Warning: unresolved additional themes (permissive mode):")
for item in theme_resolution.unresolved:
suggestion_text = ", ".join(
f"{s['theme']} ({s['score']:.1f})" for s in item.get("suggestions", [])
)
if suggestion_text:
print(f"{item['input']} → suggestions: {suggestion_text}")
else:
print(f"{item['input']} (no suggestions)")
try:
run(**resolved)
run_kwargs = dict(resolved)
run_kwargs["user_theme_resolution"] = theme_resolution
run(**run_kwargs)
except CommanderValidationError as exc:
print(str(exc))
return 2

View file

@ -0,0 +1,281 @@
"""Generate a normalized theme catalog CSV from card datasets.
Outputs `theme_catalog.csv` with deterministic ordering, a reproducible version hash,
and per-source occurrence counts so supplemental theme workflows can reuse the catalog.
"""
from __future__ import annotations
import argparse
import ast
import csv
import hashlib
import json
import os
import shutil
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence
ROOT = Path(__file__).resolve().parents[2]
CODE_ROOT = ROOT / "code"
if str(CODE_ROOT) not in sys.path:
sys.path.insert(0, str(CODE_ROOT))
try:
from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore
except Exception: # pragma: no cover - fallback for adhoc execution
DEFAULT_CSV_DIRECTORY = "csv_files"
DEFAULT_OUTPUT_PATH = ROOT / "config" / "themes" / "theme_catalog.csv"
HEADER_COMMENT_PREFIX = "# theme_catalog"
@dataclass(slots=True)
class CatalogRow:
theme: str
source_count: int
commander_count: int
card_count: int
last_generated_at: str
version: str
@dataclass(slots=True)
class CatalogBuildResult:
rows: List[CatalogRow]
generated_at: str
version: str
output_path: Path
def normalize_theme_display(raw: str) -> str:
trimmed = " ".join(raw.strip().split())
return trimmed
def canonical_key(raw: str) -> str:
return normalize_theme_display(raw).casefold()
def parse_theme_tags(value: object) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value if isinstance(v, str) and v.strip()]
if isinstance(value, str):
candidate = value.strip()
if not candidate:
return []
# Try JSON parsing first (themeTags often stored as JSON arrays)
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
return [str(v) for v in parsed if isinstance(v, str) and v.strip()]
# Fallback to Python literal lists
try:
literal = ast.literal_eval(candidate)
except (ValueError, SyntaxError):
literal = None
if isinstance(literal, list):
return [str(v) for v in literal if isinstance(v, str) and v.strip()]
return [candidate]
return []
def _load_theme_counts(csv_path: Path, theme_variants: Dict[str, set[str]]) -> Counter[str]:
counts: Counter[str] = Counter()
if not csv_path.exists():
return counts
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames or "themeTags" not in reader.fieldnames:
return counts
for row in reader:
raw_value = row.get("themeTags")
tags = parse_theme_tags(raw_value)
if not tags:
continue
seen_in_row: set[str] = set()
for tag in tags:
display = normalize_theme_display(tag)
if not display:
continue
key = canonical_key(display)
if key in seen_in_row:
continue
seen_in_row.add(key)
counts[key] += 1
theme_variants[key].add(display)
return counts
def _select_display_name(options: Sequence[str]) -> str:
if not options:
return ""
def ranking(value: str) -> tuple[int, int, str, str]:
all_upper = int(value == value.upper())
title_case = int(value != value.title())
return (all_upper, title_case, value.casefold(), value)
return min(options, key=ranking)
def _derive_generated_at(now: Optional[datetime] = None) -> str:
current = now or datetime.now(timezone.utc)
without_microseconds = current.replace(microsecond=0)
iso = without_microseconds.isoformat()
return iso.replace("+00:00", "Z")
def _compute_version_hash(theme_names: Iterable[str]) -> str:
joined = "\n".join(sorted(theme_names)).encode("utf-8")
return hashlib.sha256(joined).hexdigest()[:12]
def build_theme_catalog(
csv_directory: Path,
output_path: Path,
*,
generated_at: Optional[datetime] = None,
commander_filename: str = "commander_cards.csv",
cards_filename: str = "cards.csv",
logs_directory: Optional[Path] = None,
) -> CatalogBuildResult:
csv_directory = csv_directory.resolve()
output_path = output_path.resolve()
theme_variants: Dict[str, set[str]] = defaultdict(set)
commander_counts = _load_theme_counts(csv_directory / commander_filename, theme_variants)
card_counts: Counter[str] = Counter()
cards_path = csv_directory / cards_filename
if cards_path.exists():
card_counts = _load_theme_counts(cards_path, theme_variants)
else:
# Fallback: scan all *_cards.csv except commander
for candidate in csv_directory.glob("*_cards.csv"):
if candidate.name == commander_filename:
continue
card_counts += _load_theme_counts(candidate, theme_variants)
keys = sorted(set(card_counts.keys()) | set(commander_counts.keys()))
generated_at_iso = _derive_generated_at(generated_at)
display_names = [_select_display_name(sorted(theme_variants[key])) for key in keys]
version_hash = _compute_version_hash(display_names)
rows: List[CatalogRow] = []
for key, display in zip(keys, display_names):
if not display:
continue
card_count = int(card_counts.get(key, 0))
commander_count = int(commander_counts.get(key, 0))
source_count = card_count + commander_count
rows.append(
CatalogRow(
theme=display,
source_count=source_count,
commander_count=commander_count,
card_count=card_count,
last_generated_at=generated_at_iso,
version=version_hash,
)
)
rows.sort(key=lambda row: (row.theme.casefold(), row.theme))
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8", newline="") as handle:
comment = (
f"{HEADER_COMMENT_PREFIX} version={version_hash} "
f"generated_at={generated_at_iso} total_themes={len(rows)}\n"
)
handle.write(comment)
writer = csv.writer(handle)
writer.writerow([
"theme",
"source_count",
"commander_count",
"card_count",
"last_generated_at",
"version",
])
for row in rows:
writer.writerow([
row.theme,
row.source_count,
row.commander_count,
row.card_count,
row.last_generated_at,
row.version,
])
if logs_directory is not None:
logs_directory = logs_directory.resolve()
logs_directory.mkdir(parents=True, exist_ok=True)
copy_path = logs_directory / output_path.name
shutil.copyfile(output_path, copy_path)
if not rows:
raise RuntimeError(
"No theme tags found while generating theme catalog; ensure card CSVs contain a themeTags column."
)
return CatalogBuildResult(rows=rows, generated_at=generated_at_iso, version=version_hash, output_path=output_path)
def _resolve_csv_directory(value: Optional[str]) -> Path:
if value:
return Path(value)
env_override = os.environ.get("CSV_FILES_DIR")
if env_override:
return Path(env_override)
return ROOT / DEFAULT_CSV_DIRECTORY
def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult:
parser = argparse.ArgumentParser(description="Generate a normalized theme catalog CSV.")
parser.add_argument(
"--csv-dir",
dest="csv_dir",
type=Path,
default=None,
help="Directory containing card CSV files (defaults to CSV_FILES_DIR or settings.CSV_DIRECTORY)",
)
parser.add_argument(
"--output",
dest="output",
type=Path,
default=DEFAULT_OUTPUT_PATH,
help="Destination CSV path (defaults to config/themes/theme_catalog.csv)",
)
parser.add_argument(
"--logs-dir",
dest="logs_dir",
type=Path,
default=None,
help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)",
)
args = parser.parse_args(argv)
csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None)
result = build_theme_catalog(
csv_directory=csv_dir,
output_path=args.output,
logs_directory=args.logs_dir,
)
print(
f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})",
file=sys.stderr,
)
return result
if __name__ == "__main__": # pragma: no cover - CLI entrypoint
main()

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import pytest
from headless_runner import _resolve_additional_theme_inputs, _parse_theme_list
def _write_catalog(path: Path) -> None:
path.write_text(
"\n".join(
[
"# theme_catalog version=test_version",
"theme,commander_count,card_count",
"Lifegain,5,20",
"Token Swarm,3,15",
]
)
+ "\n",
encoding="utf-8",
)
def test_parse_theme_list_handles_semicolons() -> None:
assert _parse_theme_list("Lifegain;Token Swarm ; lifegain") == ["Lifegain", "Token Swarm"]
def test_resolve_additional_themes_permissive(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(catalog_path)
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
resolution = _resolve_additional_theme_inputs(
["Lifegain", "Unknown"],
mode="permissive",
commander_tags=["Lifegain"],
)
assert resolution.mode == "permissive"
assert resolution.catalog_version == "test_version"
# Lifegain deduped against commander tag
assert resolution.resolved == []
assert resolution.matches[0]["matched"] == "Lifegain"
assert len(resolution.unresolved) == 1
assert resolution.unresolved[0]["input"] == "Unknown"
assert resolution.unresolved[0]["reason"] in {"no_match", "suggestions", "no_candidates"}
def test_resolve_additional_themes_strict_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(catalog_path)
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
with pytest.raises(ValueError) as exc:
_resolve_additional_theme_inputs(["Mystery"], mode="strict")
assert "Mystery" in str(exc.value)
def test_resolve_additional_themes_fuzzy_correction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(catalog_path)
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
resolution = _resolve_additional_theme_inputs(["lifgain"], mode="permissive")
assert resolution.resolved == ["Lifegain"]
assert resolution.fuzzy_corrections == {"lifgain": "Lifegain"}
assert not resolution.unresolved

View file

@ -0,0 +1,102 @@
from __future__ import annotations
from typing import Iterable, Sequence
import pytest
from fastapi.testclient import TestClient
from deck_builder.theme_resolution import ThemeResolutionInfo
from web.app import app
from web.services import custom_theme_manager as ctm
def _make_info(
requested: Sequence[str],
*,
resolved: Sequence[str] | None = None,
matches: Sequence[dict[str, object]] | None = None,
unresolved: Sequence[dict[str, object]] | None = None,
mode: str = "permissive",
catalog_version: str = "test-cat",
) -> ThemeResolutionInfo:
return ThemeResolutionInfo(
requested=list(requested),
mode=mode,
catalog_version=catalog_version,
resolved=list(resolved or []),
matches=list(matches or []),
unresolved=list(unresolved or []),
fuzzy_corrections={},
)
@pytest.fixture()
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
def fake_resolve(
requested: Sequence[str],
mode: str,
*,
commander_tags: Iterable[str] = (),
) -> ThemeResolutionInfo:
inputs = list(requested)
if not inputs:
return _make_info([], resolved=[], matches=[], unresolved=[])
if inputs == ["lifgian"]:
return _make_info(
inputs,
resolved=[],
matches=[],
unresolved=[
{
"input": "lifgian",
"reason": "suggestions",
"score": 72.0,
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
}
],
)
if inputs == ["Lifegain"]:
return _make_info(
inputs,
resolved=["Lifegain"],
matches=[
{
"input": "Lifegain",
"matched": "Lifegain",
"score": 91.2,
"reason": "suggestion",
"suggestions": [],
}
],
unresolved=[],
)
raise AssertionError(f"Unexpected inputs: {inputs}")
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
return TestClient(app)
def test_remove_theme_updates_htmx_section(client: TestClient) -> None:
add_resp = client.post("/build/themes/add", data={"theme": "lifgian"})
assert add_resp.status_code == 200
add_html = add_resp.text
assert "lifgian" in add_html
assert "Needs attention" in add_html
choose_resp = client.post(
"/build/themes/choose",
data={"original": "lifgian", "choice": "Lifegain"},
)
assert choose_resp.status_code == 200
choose_html = choose_resp.text
assert "Lifegain" in choose_html
assert "Updated &#39;lifgian&#39; to &#39;Lifegain&#39;." in choose_html
remove_resp = client.post("/build/themes/remove", data={"theme": "Lifegain"})
assert remove_resp.status_code == 200
remove_html = remove_resp.text
assert "Theme removed." in remove_html
assert "No supplemental themes yet." in remove_html
assert "All themes resolved." in remove_html
assert "Use Lifegain" not in remove_html
assert "theme-chip" not in remove_html

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Dict, Iterable, Sequence
import pytest
from deck_builder.theme_resolution import ThemeResolutionInfo
from web.services import custom_theme_manager as ctm
def _make_info(
requested: Sequence[str],
*,
resolved: Sequence[str] | None = None,
matches: Sequence[Dict[str, object]] | None = None,
unresolved: Sequence[Dict[str, object]] | None = None,
mode: str = "permissive",
) -> ThemeResolutionInfo:
return ThemeResolutionInfo(
requested=list(requested),
mode=mode,
catalog_version="test-cat",
resolved=list(resolved or []),
matches=list(matches or []),
unresolved=list(unresolved or []),
fuzzy_corrections={},
)
def test_add_theme_exact_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
session: Dict[str, object] = {}
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
assert list(requested) == ["Lifegain"]
assert mode == "permissive"
return _make_info(
requested,
resolved=["Lifegain"],
matches=[{"input": "Lifegain", "matched": "Lifegain", "score": 100.0, "reason": "exact", "suggestions": []}],
)
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
info, message, level = ctm.add_theme(
session,
"Lifegain",
commander_tags=(),
mode="permissive",
limit=ctm.DEFAULT_THEME_LIMIT,
)
assert info is not None
assert info.resolved == ["Lifegain"]
assert session["custom_theme_inputs"] == ["Lifegain"]
assert session["additional_themes"] == ["Lifegain"]
assert message == "Added theme 'Lifegain'."
assert level == "success"
def test_add_theme_choose_suggestion_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
session: Dict[str, object] = {}
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
inputs = list(requested)
if inputs == ["lifgian"]:
return _make_info(
inputs,
resolved=[],
matches=[],
unresolved=[
{
"input": "lifgian",
"reason": "suggestions",
"score": 72.0,
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
}
],
)
if inputs == ["Lifegain"]:
return _make_info(
inputs,
resolved=["Lifegain"],
matches=[
{
"input": "lifgian",
"matched": "Lifegain",
"score": 91.2,
"reason": "suggestion",
"suggestions": [],
}
],
)
pytest.fail(f"Unexpected inputs {inputs}")
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
info, message, level = ctm.add_theme(
session,
"lifgian",
commander_tags=(),
mode="permissive",
limit=ctm.DEFAULT_THEME_LIMIT,
)
assert info is not None
assert not info.resolved
assert session["custom_theme_inputs"] == ["lifgian"]
assert message == "Added theme 'lifgian'."
assert level == "success"
info, message, level = ctm.choose_suggestion(
session,
"lifgian",
"Lifegain",
commander_tags=(),
mode="permissive",
)
assert info is not None
assert info.resolved == ["Lifegain"]
assert session["custom_theme_inputs"] == ["Lifegain"]
assert session["additional_themes"] == ["Lifegain"]
assert message == "Updated 'lifgian' to 'Lifegain'."
assert level == "success"
def test_remove_theme_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
session: Dict[str, object] = {"custom_theme_inputs": ["Lifegain"], "additional_themes": ["Lifegain"]}
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
assert requested == []
return _make_info(requested, resolved=[], matches=[], unresolved=[])
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
info, message, level = ctm.remove_theme(
session,
"Lifegain",
commander_tags=(),
mode="permissive",
)
assert info is not None
assert session["custom_theme_inputs"] == []
assert session["additional_themes"] == []
assert message == "Theme removed."
assert level == "success"

View file

@ -6,6 +6,7 @@ back with full fidelity, supporting the persistence layer of the include/exclude
"""
import json
import hashlib
import tempfile
import os
@ -88,6 +89,11 @@ class TestJSONRoundTrip:
assert re_exported_config["enforcement_mode"] == "strict"
assert re_exported_config["allow_illegal"] is True
assert re_exported_config["fuzzy_matching"] is False
assert re_exported_config["additional_themes"] == []
assert re_exported_config["theme_match_mode"] == "permissive"
assert re_exported_config["theme_catalog_version"] is None
assert re_exported_config["userThemes"] == []
assert re_exported_config["themeCatalogVersion"] is None
def test_empty_lists_round_trip(self):
"""Test that empty include/exclude lists are handled correctly."""
@ -113,6 +119,8 @@ class TestJSONRoundTrip:
assert exported_config["enforcement_mode"] == "warn"
assert exported_config["allow_illegal"] is False
assert exported_config["fuzzy_matching"] is True
assert exported_config["userThemes"] == []
assert exported_config["themeCatalogVersion"] is None
def test_default_values_export(self):
"""Test that default values are exported correctly."""
@ -134,6 +142,9 @@ class TestJSONRoundTrip:
assert exported_config["enforcement_mode"] == "warn"
assert exported_config["allow_illegal"] is False
assert exported_config["fuzzy_matching"] is True
assert exported_config["additional_themes"] == []
assert exported_config["theme_match_mode"] == "permissive"
assert exported_config["theme_catalog_version"] is None
def test_backward_compatibility_no_include_exclude_fields(self):
"""Test that configs without include/exclude fields still work."""
@ -167,6 +178,63 @@ class TestJSONRoundTrip:
assert "enforcement_mode" not in loaded_config
assert "allow_illegal" not in loaded_config
assert "fuzzy_matching" not in loaded_config
assert "additional_themes" not in loaded_config
assert "theme_match_mode" not in loaded_config
assert "theme_catalog_version" not in loaded_config
assert "userThemes" not in loaded_config
assert "themeCatalogVersion" not in loaded_config
def test_export_backward_compatibility_hash(self):
"""Ensure exports without user themes remain hash-compatible with legacy payload."""
builder = DeckBuilder()
builder.commander_name = "Test Commander"
builder.include_cards = ["Sol Ring"]
builder.exclude_cards = []
builder.enforcement_mode = "warn"
builder.allow_illegal = False
builder.fuzzy_matching = True
with tempfile.TemporaryDirectory() as temp_dir:
exported_path = builder.export_run_config_json(directory=temp_dir, suppress_output=True)
with open(exported_path, 'r', encoding='utf-8') as f:
exported_config = json.load(f)
legacy_expected = {
"commander": "Test Commander",
"primary_tag": None,
"secondary_tag": None,
"tertiary_tag": None,
"bracket_level": None,
"tag_mode": "AND",
"use_multi_theme": True,
"add_lands": True,
"add_creatures": True,
"add_non_creature_spells": True,
"prefer_combos": False,
"combo_target_count": None,
"combo_balance": None,
"include_cards": ["Sol Ring"],
"exclude_cards": [],
"enforcement_mode": "warn",
"allow_illegal": False,
"fuzzy_matching": True,
"additional_themes": [],
"theme_match_mode": "permissive",
"theme_catalog_version": None,
"fetch_count": None,
"ideal_counts": {},
}
sanitized_payload = {k: exported_config.get(k) for k in legacy_expected.keys()}
assert sanitized_payload == legacy_expected
assert exported_config["userThemes"] == []
assert exported_config["themeCatalogVersion"] is None
legacy_hash = hashlib.sha256(json.dumps(legacy_expected, sort_keys=True).encode("utf-8")).hexdigest()
sanitized_hash = hashlib.sha256(json.dumps(sanitized_payload, sort_keys=True).encode("utf-8")).hexdigest()
assert sanitized_hash == legacy_hash
if __name__ == "__main__":

View file

@ -264,6 +264,8 @@ class TestJSONRoundTrip:
assert exported_data['enforcement_mode'] == "strict"
assert exported_data['allow_illegal'] is True
assert exported_data['fuzzy_matching'] is False
assert exported_data['userThemes'] == []
assert exported_data['themeCatalogVersion'] is None
if __name__ == "__main__":

View file

@ -89,6 +89,8 @@ def test_enforce_and_reexport_includes_json_reexport():
assert 'include_cards' in json_data, "JSON should contain include_cards field"
assert 'exclude_cards' in json_data, "JSON should contain exclude_cards field"
assert 'enforcement_mode' in json_data, "JSON should contain enforcement_mode field"
assert 'userThemes' in json_data, "JSON should surface userThemes alias"
assert 'themeCatalogVersion' in json_data, "JSON should surface themeCatalogVersion alias"
except Exception:
# If enforce_and_reexport fails completely, that's also fine for this test

View file

@ -1,8 +1,14 @@
import csv
import json
import os
from datetime import datetime, timezone
from pathlib import Path
import subprocess
import pytest
from code.scripts import generate_theme_catalog as new_catalog
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / 'code' / 'scripts' / 'build_theme_catalog.py'
@ -60,3 +66,129 @@ def test_catalog_schema_contains_descriptions(tmp_path):
data = json.loads(out_path.read_text(encoding='utf-8'))
assert all('description' in t for t in data['themes'])
assert all(t['description'] for t in data['themes'])
@pytest.fixture()
def fixed_now() -> datetime:
return datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
def _write_csv(path: Path, rows: list[dict[str, object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not rows:
path.write_text('', encoding='utf-8')
return
fieldnames = sorted({field for row in rows for field in row.keys()})
with path.open('w', encoding='utf-8', newline='') as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
def _read_catalog_rows(path: Path) -> list[dict[str, str]]:
with path.open('r', encoding='utf-8') as handle:
header_comment = handle.readline()
assert header_comment.startswith(new_catalog.HEADER_COMMENT_PREFIX)
reader = csv.DictReader(handle)
return list(reader)
def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> None:
csv_dir = tmp_path / 'csv_files'
cards = csv_dir / 'cards.csv'
commander = csv_dir / 'commander_cards.csv'
_write_csv(
cards,
[
{
'name': 'Card A',
'themeTags': '["Lifegain", "Token Swarm"]',
},
{
'name': 'Card B',
'themeTags': '[" lifegain ", "Control"]',
},
{
'name': 'Card C',
'themeTags': '[]',
},
],
)
_write_csv(
commander,
[
{
'name': 'Commander 1',
'themeTags': '["Lifegain", " Voltron "]',
}
],
)
output_path = tmp_path / 'theme_catalog.csv'
result = new_catalog.build_theme_catalog(
csv_directory=csv_dir,
output_path=output_path,
generated_at=fixed_now,
)
assert result.output_path == output_path
assert result.generated_at == '2025-01-01T12:00:00Z'
rows = _read_catalog_rows(output_path)
assert [row['theme'] for row in rows] == ['Control', 'Lifegain', 'Token Swarm', 'Voltron']
lifegain = next(row for row in rows if row['theme'] == 'Lifegain')
assert lifegain['card_count'] == '2'
assert lifegain['commander_count'] == '1'
assert lifegain['source_count'] == '3'
assert all(row['last_generated_at'] == result.generated_at for row in rows)
assert all(row['version'] == result.version for row in rows)
expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined]
assert result.version == expected_hash
def test_generate_theme_catalog_deduplicates_variants(tmp_path: Path, fixed_now: datetime) -> None:
csv_dir = tmp_path / 'csv_files'
cards = csv_dir / 'cards.csv'
commander = csv_dir / 'commander_cards.csv'
_write_csv(
cards,
[
{
'name': 'Card A',
'themeTags': '[" Token Swarm ", "Combo"]',
},
{
'name': 'Card B',
'themeTags': '["token swarm"]',
},
],
)
_write_csv(
commander,
[
{
'name': 'Commander 1',
'themeTags': '["TOKEN SWARM"]',
}
],
)
output_path = tmp_path / 'theme_catalog.csv'
result = new_catalog.build_theme_catalog(
csv_directory=csv_dir,
output_path=output_path,
generated_at=fixed_now,
)
rows = _read_catalog_rows(output_path)
assert [row['theme'] for row in rows] == ['Combo', 'Token Swarm']
token_row = next(row for row in rows if row['theme'] == 'Token Swarm')
assert token_row['card_count'] == '2'
assert token_row['commander_count'] == '1'
assert token_row['source_count'] == '3'
assert result.output_path.exists()

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from pathlib import Path
import pytest
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry, load_theme_catalog
def _write_catalog(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_load_theme_catalog_basic(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(
catalog_path,
[
"# theme_catalog version=abc123 generated_at=2025-01-02T00:00:00Z",
"theme,source_count,commander_count,card_count,last_generated_at,version",
"Lifegain,3,1,2,2025-01-02T00:00:00Z,abc123",
"Token Swarm,5,2,3,2025-01-02T00:00:00Z,abc123",
],
)
with caplog.at_level("INFO"):
entries, version = load_theme_catalog(catalog_path)
assert version == "abc123"
assert entries == [
ThemeCatalogEntry(theme="Lifegain", commander_count=1, card_count=2),
ThemeCatalogEntry(theme="Token Swarm", commander_count=2, card_count=3),
]
log_messages = {record.message for record in caplog.records}
assert any("theme_catalog_loaded" in message for message in log_messages)
def test_load_theme_catalog_empty_file(tmp_path: Path) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(catalog_path, ["# theme_catalog version=empty"])
entries, version = load_theme_catalog(catalog_path)
assert entries == []
assert version == "empty"
def test_load_theme_catalog_missing_columns(tmp_path: Path) -> None:
catalog_path = tmp_path / "theme_catalog.csv"
_write_catalog(
catalog_path,
[
"# theme_catalog version=missing",
"theme,card_count,last_generated_at,version",
"Lifegain,2,2025-01-02T00:00:00Z,missing",
],
)
with pytest.raises(ValueError):
load_theme_catalog(catalog_path)

View file

@ -0,0 +1,92 @@
from __future__ import annotations
import time
import pytest
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry
from code.deck_builder.theme_matcher import (
ACCEPT_MATCH_THRESHOLD,
SUGGEST_MATCH_THRESHOLD,
ThemeMatcher,
normalize_theme,
)
@pytest.fixture()
def sample_entries() -> list[ThemeCatalogEntry]:
themes = [
"Aristocrats",
"Sacrifice Matters",
"Life Gain",
"Token Swarm",
"Control",
"Superfriends",
"Spellslinger",
"Artifact Tokens",
"Treasure Storm",
"Graveyard Loops",
]
return [ThemeCatalogEntry(theme=theme, commander_count=0, card_count=0) for theme in themes]
def test_normalize_theme_collapses_spaces() -> None:
assert normalize_theme(" Life Gain \t") == "life gain"
def test_exact_match_case_insensitive(sample_entries: list[ThemeCatalogEntry]) -> None:
matcher = ThemeMatcher(sample_entries)
result = matcher.resolve("aristocrats")
assert result.matched_theme == "Aristocrats"
assert result.score == pytest.approx(100.0)
assert result.reason == "high_confidence"
def test_minor_typo_accepts_with_high_score(sample_entries: list[ThemeCatalogEntry]) -> None:
matcher = ThemeMatcher(sample_entries)
result = matcher.resolve("aristrocrats")
assert result.matched_theme == "Aristocrats"
assert result.score >= ACCEPT_MATCH_THRESHOLD
assert result.reason in {"high_confidence", "accepted_confidence"}
def test_multi_typo_only_suggests(sample_entries: list[ThemeCatalogEntry]) -> None:
matcher = ThemeMatcher(sample_entries)
result = matcher.resolve("arzstrcrats")
assert result.matched_theme is None
assert result.score >= SUGGEST_MATCH_THRESHOLD
assert result.reason == "suggestions"
assert any(s.theme == "Aristocrats" for s in result.suggestions)
def test_no_match_returns_empty(sample_entries: list[ThemeCatalogEntry]) -> None:
matcher = ThemeMatcher(sample_entries)
result = matcher.resolve("planeship")
assert result.matched_theme is None
assert result.suggestions == []
assert result.reason in {"no_candidates", "no_match"}
def test_short_input_requires_exact(sample_entries: list[ThemeCatalogEntry]) -> None:
matcher = ThemeMatcher(sample_entries)
result = matcher.resolve("ar")
assert result.matched_theme is None
assert result.reason == "input_too_short"
result_exact = matcher.resolve("lo")
assert result_exact.matched_theme is None
def test_resolution_speed(sample_entries: list[ThemeCatalogEntry]) -> None:
many_entries = [
ThemeCatalogEntry(theme=f"Theme {i}", commander_count=0, card_count=0) for i in range(400)
]
matcher = ThemeMatcher(many_entries)
matcher.resolve("theme 42")
start = time.perf_counter()
for _ in range(20):
matcher.resolve("theme 123")
duration = time.perf_counter() - start
# Observed ~0.03s per resolution (<=0.65s for 20 resolves) on dev machine (2025-10-02).
assert duration < 0.7

View file

@ -0,0 +1,115 @@
from __future__ import annotations
from typing import Any, Dict, List
import pandas as pd
from deck_builder.theme_context import ThemeContext, ThemeTarget
from deck_builder.phases.phase4_spells import SpellAdditionMixin
from deck_builder import builder_utils as bu
class DummyRNG:
def uniform(self, _a: float, _b: float) -> float:
return 1.0
def random(self) -> float:
return 0.0
def choice(self, seq):
return seq[0]
class DummySpellBuilder(SpellAdditionMixin):
def __init__(self, df: pd.DataFrame, context: ThemeContext):
self._combined_cards_df = df
# Pre-populate 99 cards so we target a single filler slot
self.card_library: Dict[str, Dict[str, Any]] = {
f"Existing{i}": {"Count": 1} for i in range(99)
}
self.primary_tag = context.ordered_targets[0].display if context.ordered_targets else None
self.secondary_tag = None
self.tertiary_tag = None
self.tag_mode = context.combine_mode
self.prefer_owned = False
self.owned_card_names: set[str] = set()
self.bracket_limits: Dict[str, Any] = {}
self.output_log: List[str] = []
self.output_func = self.output_log.append
self._rng = DummyRNG()
self._theme_context = context
self.added_cards: List[str] = []
def _get_rng(self) -> DummyRNG:
return self._rng
@property
def rng(self) -> DummyRNG:
return self._rng
def get_theme_context(self) -> ThemeContext: # type: ignore[override]
return self._theme_context
def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override]
self.card_library[name] = {"Count": kwargs.get("count", 1)}
self.added_cards.append(name)
def make_context(user_theme_weight: float) -> ThemeContext:
user = ThemeTarget(
role="user_1",
display="Angels",
slug="angels",
source="user",
weight=1.0,
)
return ThemeContext(
ordered_targets=[user],
combine_mode="AND",
weights={"user_1": 1.0},
commander_slugs=[],
user_slugs=["angels"],
resolution=None,
user_theme_weight=user_theme_weight,
)
def build_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"name": "Angel Song",
"type": "Instant",
"themeTags": ["Angels"],
"manaValue": 2,
"edhrecRank": 1400,
},
]
)
def test_user_theme_bonus_increases_weight(monkeypatch) -> None:
captured: List[List[tuple[str, float]]] = []
def fake_weighted(pool: List[tuple[str, float]], k: int, rng=None) -> List[str]:
captured.append(list(pool))
ranked = sorted(pool, key=lambda item: item[1], reverse=True)
return [name for name, _ in ranked[:k]]
monkeypatch.setattr(bu, "weighted_sample_without_replacement", fake_weighted)
def run(user_weight: float) -> Dict[str, float]:
start = len(captured)
context = make_context(user_weight)
builder = DummySpellBuilder(build_dataframe(), context)
builder.fill_remaining_theme_spells()
assert start < len(captured) # ensure we captured weights
pool = captured[start]
return dict(pool)
weights_no_bonus = run(1.0)
weights_bonus = run(1.5)
assert "Angel Song" in weights_no_bonus
assert "Angel Song" in weights_bonus
assert weights_bonus["Angel Song"] > weights_no_bonus["Angel Song"]

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from deck_builder.summary_telemetry import (
_reset_metrics_for_test,
get_theme_metrics,
record_theme_summary,
)
def setup_function() -> None:
_reset_metrics_for_test()
def teardown_function() -> None:
_reset_metrics_for_test()
def test_record_theme_summary_tracks_user_themes() -> None:
payload = {
"commanderThemes": ["Lifegain"],
"userThemes": ["Angels", "Life Gain"],
"requested": ["Angels"],
"resolved": ["angels"],
"unresolved": [],
"mode": "AND",
"weight": 1.3,
"themeCatalogVersion": "test-cat",
}
record_theme_summary(payload)
metrics = get_theme_metrics()
assert metrics["total_builds"] == 1
assert metrics["with_user_themes"] == 1
summary = metrics["last_summary"]
assert summary is not None
assert summary["commanderThemes"] == ["Lifegain"]
assert summary["userThemes"] == ["Angels", "Life Gain"]
assert summary["mergedThemes"] == ["Lifegain", "Angels", "Life Gain"]
assert summary["unresolvedCount"] == 0
assert metrics["top_user_themes"][0]["theme"] in {"Angels", "Life Gain"}
def test_record_theme_summary_without_user_themes() -> None:
payload = {
"commanderThemes": ["Artifacts"],
"userThemes": [],
"requested": [],
"resolved": [],
"unresolved": [],
"mode": "AND",
"weight": 1.0,
}
record_theme_summary(payload)
metrics = get_theme_metrics()
assert metrics["total_builds"] == 1
assert metrics["with_user_themes"] == 0
summary = metrics["last_summary"]
assert summary is not None
assert summary["commanderThemes"] == ["Artifacts"]
assert summary["userThemes"] == []
assert summary["mergedThemes"] == ["Artifacts"]
assert summary["unresolvedCount"] == 0

View file

@ -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)

View file

@ -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)

View 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

View file

@ -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

View 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>

View file

@ -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">

View file

@ -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 %}
{% for entry in preview %}
<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 %}
{% 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;

View file

@ -23,5 +23,9 @@
"exclude_cards": ["Chaos Orb"],
"enforcement_mode": "warn",
"allow_illegal": false,
"fuzzy_matching": true
"fuzzy_matching": true,
"additional_themes": [],
"theme_match_mode": "permissive",
"userThemes": [],
"themeCatalogVersion": null
}

View file

@ -18,9 +18,12 @@ services:
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
ENABLE_PRESETS: "0" # 1=show presets section
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
SHOW_MISC_POOL: "0"
@ -47,6 +50,9 @@ services:
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_REROLL_THROTTLE_MS: "350" # minimum ms between reroll requests (client guard)
RANDOM_STRUCTURED_LOGS: "0" # 1=emit structured JSON logs for random builds
RANDOM_TELEMETRY: "0" # 1=emit lightweight timing/attempt metrics (dev/diagnostics)
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
@ -61,8 +67,16 @@ services:
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
# Random rate limiting (optional; protects from abuse or accidental hammers)
RATE_LIMIT_ENABLED: "0" # 1=enable server-side rate limiting for random endpoints
RATE_LIMIT_WINDOW_S: "10" # window size in seconds
RATE_LIMIT_RANDOM: "10" # max random attempts per window
RATE_LIMIT_BUILD: "10" # max builds per window
RATE_LIMIT_SUGGEST: "30" # max suggestions per window
# Theming
THEME: "dark" # system|light|dark
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
# ------------------------------------------------------------------
# Setup / Tagging / Catalog Controls
@ -121,6 +135,8 @@ services:
# CSV_FILES_DIR: "/app/csv_files"
# Inject a one-off synthetic CSV for index testing without altering shards
# CARD_INDEX_EXTRA_CSV: ""
# DECK_ADDITIONAL_THEMES: ""
# THEME_MATCH_MODE: "permissive"
# ------------------------------------------------------------------
# Headless / Non-interactive Build Configuration

View file

@ -20,8 +20,11 @@ services:
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
ENABLE_PRESETS: "0" # 1=show presets section
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
@ -49,6 +52,9 @@ services:
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
RANDOM_REROLL_THROTTLE_MS: "350" # minimum ms between reroll requests (client guard)
RANDOM_STRUCTURED_LOGS: "0" # 1=emit structured JSON logs for random builds
RANDOM_TELEMETRY: "0" # 1=emit lightweight timing/attempt metrics (dev/diagnostics)
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
@ -63,8 +69,16 @@ services:
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
# Random rate limiting (optional; protects from abuse or accidental hammers)
RATE_LIMIT_ENABLED: "0" # 1=enable server-side rate limiting for random endpoints
RATE_LIMIT_WINDOW_S: "10" # window size in seconds
RATE_LIMIT_RANDOM: "10" # max random attempts per window
RATE_LIMIT_BUILD: "10" # max builds per window
RATE_LIMIT_SUGGEST: "30" # max suggestions per window
# Theming
THEME: "dark" # system|light|dark
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
# ------------------------------------------------------------------
# Setup / Tagging / Catalog Controls
@ -123,6 +137,8 @@ services:
# CSV_FILES_DIR: "/app/csv_files"
# Inject a one-off synthetic CSV for index testing without altering shards
# CARD_INDEX_EXTRA_CSV: ""
# DECK_ADDITIONAL_THEMES: ""
# THEME_MATCH_MODE: "permissive"
# ------------------------------------------------------------------
# Headless / Non-interactive Build Configuration

View file

@ -3,6 +3,7 @@
Additional details for developers and power users working with the theme catalog, editorial tooling, and diagnostics.
## Table of contents
- [theme_catalog.csv schema](#theme_catalogcsv-schema)
- [HTMX API endpoints](#htmx-api-endpoints)
- [Caching, diagnostics, and metrics](#caching-diagnostics-and-metrics)
- [Governance principles](#governance-principles)
@ -18,7 +19,20 @@ Additional details for developers and power users working with the theme catalog
- [Description mapping overrides](#description-mapping-overrides)
- [Validation and schema tooling](#validation-and-schema-tooling)
---
## theme_catalog.csv schema
`theme_catalog.csv` is the normalized artifact consumed by headless builds, supplemental themes, and diagnostics panels. The file starts with a header comment in the format `# theme_catalog version=<hash>` followed by a standard CSV header with these columns:
| Column | Description |
| --- | --- |
| `theme` | Normalized display label used across the app and JSON exports. |
| `commander_count` | Number of commanders tagged with the theme in `commander_cards.csv`. |
| `card_count` | Number of non-commander cards carrying the theme tag across primary CSVs. |
| `source_count` | Combined count (`commander_count + card_count`) to simplify weighting heuristics. |
| `last_generated_at` | ISO-8601 timestamp captured at generation time (UTC). Useful for verifying stale catalogs in diagnostics. |
| `version` | Deterministic SHA-256 prefix derived from the ordered theme list; this value flows into exports as `themeCatalogVersion` and `/status/theme_metrics`. |
Consumers should treat additional columns as experimental. If you add new fields, update this table and the supplemental theme tests that assert schema coverage.
## HTMX API endpoints
The upcoming theme picker UI is powered by two FastAPI endpoints.

View file

@ -99,5 +99,6 @@ Open the Owned tile to manage uploaded inventories:
## Diagnostics and logs
- `SHOW_DIAGNOSTICS=1` unlocks the `/diagnostics` page with system summaries (`/status/sys`), feature flags, and per-request `X-Request-ID` headers.
- Supplemental theme telemetry lives at `/status/theme_metrics` (enabled with `ENABLE_CUSTOM_THEMES=1`); the diagnostics page renders commander themes, user-supplied themes, merged totals, and unresolved counts using the `userThemes`/`themeCatalogVersion` metadata exported from builds.
- `SHOW_LOGS=1` turns on the `/logs` viewer with level & keyword filters, auto-refresh, and copy-to-clipboard.
- Health probes live at `/healthz` and return `{status, version, uptime_seconds}` for integration with uptime monitors.