diff --git a/.env.example b/.env.example index 9e4be9e..b704c1d 100644 --- a/.env.example +++ b/.env.example @@ -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 # 1–5 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a94c8..2ffe99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/DOCKER.md b/DOCKER.md index 169471a..f3bca3b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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 diff --git a/README.md b/README.md index 2c127aa..4b72ee0 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 32e1bd5..6db5533 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -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. diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index c13b383..093ce48 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -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) diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index 5576cc8..bbf5f60 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -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 [] - try: - hits = [t for t in selected_display_tags if str(t).lower() in norm_tags] - except Exception: - hits = selected_display_tags + hits = row.get('_matchTags', []) + if not isinstance(hits, list): + try: + hits = list(hits) + except Exception: + 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): diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index af8f0e0..76ff0c9 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -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}") diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 6dd4e8d..7b1df2b 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -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 diff --git a/code/deck_builder/summary_telemetry.py b/code/deck_builder/summary_telemetry.py index 6182a5e..1897d73 100644 --- a/code/deck_builder/summary_telemetry.py +++ b/code/deck_builder/summary_telemetry.py @@ -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, + } diff --git a/code/deck_builder/theme_catalog_loader.py b/code/deck_builder/theme_catalog_loader.py new file mode 100644 index 0000000..cddf9b3 --- /dev/null +++ b/code/deck_builder/theme_catalog_loader.py @@ -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"] diff --git a/code/deck_builder/theme_context.py b/code/deck_builder/theme_context.py new file mode 100644 index 0000000..6c9e090 --- /dev/null +++ b/code/deck_builder/theme_context.py @@ -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, + } diff --git a/code/deck_builder/theme_matcher.py b/code/deck_builder/theme_matcher.py new file mode 100644 index 0000000..fa92d86 --- /dev/null +++ b/code/deck_builder/theme_matcher.py @@ -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) diff --git a/code/deck_builder/theme_resolution.py b/code/deck_builder/theme_resolution.py new file mode 100644 index 0000000..547fa0e --- /dev/null +++ b/code/deck_builder/theme_resolution.py @@ -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, + ) + diff --git a/code/headless_runner.py b/code/headless_runner.py index ceabb2e..1c8dd2c 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -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() @@ -274,6 +286,34 @@ def run( builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined] 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. @@ -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 diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py new file mode 100644 index 0000000..622de89 --- /dev/null +++ b/code/scripts/generate_theme_catalog.py @@ -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() diff --git a/code/tests/test_additional_theme_config.py b/code/tests/test_additional_theme_config.py new file mode 100644 index 0000000..1d3dc80 --- /dev/null +++ b/code/tests/test_additional_theme_config.py @@ -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 diff --git a/code/tests/test_custom_theme_htmx.py b/code/tests/test_custom_theme_htmx.py new file mode 100644 index 0000000..390905b --- /dev/null +++ b/code/tests/test_custom_theme_htmx.py @@ -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 'lifgian' to 'Lifegain'." 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 diff --git a/code/tests/test_custom_theme_manager_smoke.py b/code/tests/test_custom_theme_manager_smoke.py new file mode 100644 index 0000000..e6f0a9f --- /dev/null +++ b/code/tests/test_custom_theme_manager_smoke.py @@ -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" \ No newline at end of file diff --git a/code/tests/test_include_exclude_persistence.py b/code/tests/test_include_exclude_persistence.py index 9828080..5520194 100644 --- a/code/tests/test_include_exclude_persistence.py +++ b/code/tests/test_include_exclude_persistence.py @@ -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__": diff --git a/code/tests/test_include_exclude_validation.py b/code/tests/test_include_exclude_validation.py index abca625..61811c8 100644 --- a/code/tests/test_include_exclude_validation.py +++ b/code/tests/test_include_exclude_validation.py @@ -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__": diff --git a/code/tests/test_json_reexport_enforcement.py b/code/tests/test_json_reexport_enforcement.py index 864c6e5..efd3839 100644 --- a/code/tests/test_json_reexport_enforcement.py +++ b/code/tests/test_json_reexport_enforcement.py @@ -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 diff --git a/code/tests/test_theme_catalog_generation.py b/code/tests/test_theme_catalog_generation.py index fc2b923..81f6634 100644 --- a/code/tests/test_theme_catalog_generation.py +++ b/code/tests/test_theme_catalog_generation.py @@ -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() diff --git a/code/tests/test_theme_catalog_loader.py b/code/tests/test_theme_catalog_loader.py new file mode 100644 index 0000000..31efc73 --- /dev/null +++ b/code/tests/test_theme_catalog_loader.py @@ -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) diff --git a/code/tests/test_theme_matcher.py b/code/tests/test_theme_matcher.py new file mode 100644 index 0000000..0c8390f --- /dev/null +++ b/code/tests/test_theme_matcher.py @@ -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 diff --git a/code/tests/test_theme_spell_weighting.py b/code/tests/test_theme_spell_weighting.py new file mode 100644 index 0000000..e95d60b --- /dev/null +++ b/code/tests/test_theme_spell_weighting.py @@ -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"] diff --git a/code/tests/test_theme_summary_telemetry.py b/code/tests/test_theme_summary_telemetry.py new file mode 100644 index 0000000..3ae5f37 --- /dev/null +++ b/code/tests/test_theme_summary_telemetry.py @@ -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 diff --git a/code/web/app.py b/code/web/app.py index e2ff826..464b6e0 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -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) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index db64b22..1db21ab 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -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) diff --git a/code/web/services/custom_theme_manager.py b/code/web/services/custom_theme_manager.py new file mode 100644 index 0000000..4d0eaa6 --- /dev/null +++ b/code/web/services/custom_theme_manager.py @@ -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 + diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index d0c2415..1f0c0bc 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -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 diff --git a/code/web/templates/build/_new_deck_additional_themes.html b/code/web/templates/build/_new_deck_additional_themes.html new file mode 100644 index 0000000..190a02e --- /dev/null +++ b/code/web/templates/build/_new_deck_additional_themes.html @@ -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 %} + +
+ Additional Themes +

+ Add up to {{ limit }} supplemental themes to guide the build. + {{ remaining }} slot{% if remaining != 1 %}s{% endif %} remaining. +

+
{{ theme_message or '' }}
+ {% if theme_message %} + {% if theme_message_level == 'success' %} +
{{ theme_message }}
+ {% elif theme_message_level == 'warning' %} +
{{ theme_message }}
+ {% elif theme_message_level == 'error' %} +
{{ theme_message }}
+ {% else %} +
{{ theme_message }}
+ {% endif %} + {% endif %} + +
+ + +
+ +
+ Matching mode: + + +
+ +
+
+

Resolved

+ {% if resolved_labels %} +
+ {% 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'] %} +
+ {{ matched }} + {% if original and original.casefold() != matched.casefold() %} + (from “{{ original }}”) + {% endif %} + +
+ {% endfor %} + {% if not matches and resolved_labels %} + {% for label in resolved_labels %} +
+ {{ label }} +
+ {% endfor %} + {% endif %} +
+ {% else %} +
No supplemental themes yet.
+ {% endif %} +
+ +
+

Needs attention

+ {% if unresolved %} +
+ {% for item in unresolved %} +
+
+ {{ item.input }} + +
+ {% if item.reason %} +
Reason: {{ item.reason|replace('_',' ')|title }}
+ {% endif %} + {% if item.suggestions %} +
+ {% 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 %} + + {% endif %} + {% endfor %} +
+ {% else %} +
No close matches found.
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
All themes resolved.
+ {% endif %} +
+
+
+ Catalog version: {{ resolution.get('catalog_version', 'unknown') }} · Mode: {{ state.get('mode', 'permissive')|title }} +
+
diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index a24e474..cd9f021 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -40,6 +40,9 @@
+ {% if enable_custom_themes %} + {% include "build/_new_deck_additional_themes.html" %} + {% endif %}