mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
Merge branch 'main' of https://github.com/mwisnowski/mtg_python_deckbuilder
This commit is contained in:
commit
ea3fae7509
39 changed files with 3643 additions and 198 deletions
14
.env.example
14
.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)
|
||||
|
|
|
|||
29
CHANGELOG.md
29
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.
|
||||
|
|
|
|||
20
DOCKER.md
20
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
|
||||
|
|
|
|||
27
README.md
27
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`.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
227
code/deck_builder/theme_catalog_loader.py
Normal file
227
code/deck_builder/theme_catalog_loader.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Lightweight loader for the supplemental theme catalog CSV."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
from code.logging_util import get_logger
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_CATALOG_PATH = ROOT / "config" / "themes" / "theme_catalog.csv"
|
||||
JSON_FALLBACK_PATH = ROOT / "config" / "themes" / "theme_list.json"
|
||||
REQUIRED_COLUMNS = {"theme", "commander_count", "card_count"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeCatalogEntry:
|
||||
"""Single row from the supplemental theme catalog."""
|
||||
|
||||
theme: str
|
||||
commander_count: int
|
||||
card_count: int
|
||||
|
||||
@property
|
||||
def source_count(self) -> int:
|
||||
return self.commander_count + self.card_count
|
||||
|
||||
|
||||
def _resolve_catalog_path(override: str | os.PathLike[str] | None) -> Path:
|
||||
if override:
|
||||
return Path(override).resolve()
|
||||
env_override = os.environ.get("THEME_CATALOG_PATH")
|
||||
if env_override:
|
||||
return Path(env_override).resolve()
|
||||
return DEFAULT_CATALOG_PATH
|
||||
|
||||
|
||||
def _parse_metadata(line: str) -> Tuple[str, dict[str, str]]:
|
||||
version = "unknown"
|
||||
meta: dict[str, str] = {}
|
||||
cleaned = line.lstrip("#").strip()
|
||||
if not cleaned:
|
||||
return version, meta
|
||||
for token in cleaned.split():
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
meta[key] = value
|
||||
if key == "version":
|
||||
version = value
|
||||
return version, meta
|
||||
|
||||
|
||||
def _to_int(value: object) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return 0
|
||||
return int(text)
|
||||
|
||||
|
||||
def load_theme_catalog(
|
||||
catalog_path: str | os.PathLike[str] | None = None,
|
||||
) -> tuple[list[ThemeCatalogEntry], str]:
|
||||
"""Load the supplemental theme catalog with memoization.
|
||||
|
||||
Args:
|
||||
catalog_path: Optional override path. Defaults to ``config/themes/theme_catalog.csv``
|
||||
or the ``THEME_CATALOG_PATH`` environment variable.
|
||||
|
||||
Returns:
|
||||
A tuple of ``(entries, version)`` where ``entries`` is a list of
|
||||
:class:`ThemeCatalogEntry` and ``version`` is the parsed catalog version.
|
||||
"""
|
||||
|
||||
resolved = _resolve_catalog_path(catalog_path)
|
||||
mtime = 0.0
|
||||
try:
|
||||
mtime = resolved.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
entries, version = _load_catalog_cached(str(resolved), mtime)
|
||||
if entries:
|
||||
return list(entries), version
|
||||
# Fallback to JSON catalog when CSV export unavailable.
|
||||
fallback_entries, fallback_version = _load_json_catalog()
|
||||
if fallback_entries:
|
||||
return list(fallback_entries), fallback_version
|
||||
return list(entries), version
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_catalog_cached(path_str: str, mtime: float) -> tuple[tuple[ThemeCatalogEntry, ...], str]:
|
||||
path = Path(path_str)
|
||||
if not path.exists():
|
||||
LOGGER.warning("theme_catalog_missing path=%s", path)
|
||||
return tuple(), "unknown"
|
||||
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
first_line = handle.readline()
|
||||
version = "unknown"
|
||||
if first_line.startswith("#"):
|
||||
version, _ = _parse_metadata(first_line)
|
||||
else:
|
||||
handle.seek(0)
|
||||
|
||||
reader = csv.DictReader(handle)
|
||||
if reader.fieldnames is None:
|
||||
LOGGER.info("theme_catalog_loaded size=0 version=%s path=%s", version, path)
|
||||
return tuple(), version
|
||||
|
||||
missing = REQUIRED_COLUMNS - set(reader.fieldnames)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"theme_catalog.csv missing required columns: " + ", ".join(sorted(missing))
|
||||
)
|
||||
|
||||
entries: list[ThemeCatalogEntry] = []
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
theme = str(row.get("theme", "")).strip()
|
||||
if not theme:
|
||||
continue
|
||||
try:
|
||||
commander = _to_int(row.get("commander_count"))
|
||||
card = _to_int(row.get("card_count"))
|
||||
except ValueError as exc: # pragma: no cover - defensive, should not happen
|
||||
raise ValueError(f"Invalid numeric values in theme catalog for theme '{theme}'") from exc
|
||||
entries.append(ThemeCatalogEntry(theme=theme, commander_count=commander, card_count=card))
|
||||
|
||||
LOGGER.info("theme_catalog_loaded size=%s version=%s path=%s", len(entries), version, path)
|
||||
return tuple(entries), version
|
||||
|
||||
|
||||
def _load_json_catalog() -> tuple[tuple[ThemeCatalogEntry, ...], str]:
|
||||
if not JSON_FALLBACK_PATH.exists():
|
||||
return tuple(), "unknown"
|
||||
try:
|
||||
mtime = JSON_FALLBACK_PATH.stat().st_mtime
|
||||
except Exception: # pragma: no cover - stat failures
|
||||
mtime = 0.0
|
||||
return _load_json_catalog_cached(str(JSON_FALLBACK_PATH), mtime)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load_json_catalog_cached(path_str: str, mtime: float) -> tuple[tuple[ThemeCatalogEntry, ...], str]:
|
||||
path = Path(path_str)
|
||||
try:
|
||||
raw_text = path.read_text(encoding="utf-8")
|
||||
except Exception as exc: # pragma: no cover - IO edge cases
|
||||
LOGGER.warning("theme_catalog_json_read_error path=%s error=%s", path, exc)
|
||||
return tuple(), "unknown"
|
||||
if not raw_text.strip():
|
||||
return tuple(), "unknown"
|
||||
try:
|
||||
payload = json.loads(raw_text)
|
||||
except Exception as exc: # pragma: no cover - malformed JSON
|
||||
LOGGER.warning("theme_catalog_json_parse_error path=%s error=%s", path, exc)
|
||||
return tuple(), "unknown"
|
||||
themes = _iter_json_themes(payload)
|
||||
entries = tuple(themes)
|
||||
if not entries:
|
||||
return tuple(), "unknown"
|
||||
version = _extract_json_version(payload)
|
||||
LOGGER.info("theme_catalog_loaded_json size=%s version=%s path=%s", len(entries), version, path)
|
||||
return entries, version
|
||||
|
||||
|
||||
def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]:
|
||||
if not isinstance(payload, dict):
|
||||
LOGGER.warning("theme_catalog_json_invalid_root type=%s", type(payload).__name__)
|
||||
return tuple()
|
||||
try:
|
||||
from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path
|
||||
except ImportError: # pragma: no cover - fallback when running as package
|
||||
from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore
|
||||
|
||||
try:
|
||||
catalog = ThemeCatalog.model_validate(payload)
|
||||
except Exception as exc: # pragma: no cover - validation errors
|
||||
LOGGER.warning("theme_catalog_json_validate_error error=%s", exc)
|
||||
return tuple()
|
||||
|
||||
for theme in catalog.themes:
|
||||
commander_count = len(theme.example_commanders or [])
|
||||
# Prefer synergy count, fall back to example cards, ensure non-negative.
|
||||
inferred_card_count = max(len(theme.synergies or []), len(theme.example_cards or []))
|
||||
yield ThemeCatalogEntry(
|
||||
theme=theme.theme,
|
||||
commander_count=int(commander_count),
|
||||
card_count=int(inferred_card_count),
|
||||
)
|
||||
|
||||
|
||||
def _extract_json_version(payload: object) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
return "json"
|
||||
meta = payload.get("metadata_info")
|
||||
if isinstance(meta, dict):
|
||||
version = meta.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
return version.strip()
|
||||
# Fallback to catalog hash if available
|
||||
recorded = None
|
||||
if isinstance(meta, dict):
|
||||
recorded = meta.get("catalog_hash")
|
||||
if isinstance(recorded, str) and recorded.strip():
|
||||
return recorded.strip()
|
||||
provenance = payload.get("provenance")
|
||||
if isinstance(provenance, dict):
|
||||
version = provenance.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
return version.strip()
|
||||
return "json"
|
||||
|
||||
|
||||
__all__ = ["ThemeCatalogEntry", "load_theme_catalog"]
|
||||
318
code/deck_builder/theme_context.py
Normal file
318
code/deck_builder/theme_context.py
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
||||
|
||||
from deck_builder import builder_utils as bu
|
||||
from deck_builder.theme_matcher import normalize_theme
|
||||
from deck_builder.theme_resolution import ThemeResolutionInfo
|
||||
|
||||
import logging_util
|
||||
|
||||
logger = logging_util.logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ThemeTarget",
|
||||
"ThemeContext",
|
||||
"default_user_theme_weight",
|
||||
"build_theme_context",
|
||||
"annotate_theme_matches",
|
||||
"theme_summary_payload",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeTarget:
|
||||
"""Represents a prioritized theme target for selection weighting."""
|
||||
|
||||
role: str
|
||||
display: str
|
||||
slug: str
|
||||
source: str # "commander" | "user"
|
||||
weight: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeContext:
|
||||
"""Captured theme aggregation for card selection and diagnostics."""
|
||||
|
||||
ordered_targets: List[ThemeTarget]
|
||||
combine_mode: str
|
||||
weights: Dict[str, float]
|
||||
commander_slugs: List[str]
|
||||
user_slugs: List[str]
|
||||
resolution: Optional[ThemeResolutionInfo]
|
||||
user_theme_weight: float
|
||||
|
||||
def selected_slugs(self) -> List[str]:
|
||||
return [target.slug for target in self.ordered_targets if target.slug]
|
||||
|
||||
@property
|
||||
def commander_selected(self) -> List[str]:
|
||||
return list(self.commander_slugs)
|
||||
|
||||
@property
|
||||
def user_selected(self) -> List[str]:
|
||||
return list(self.user_slugs)
|
||||
|
||||
@property
|
||||
def match_multiplier(self) -> float:
|
||||
try:
|
||||
value = float(self.user_theme_weight)
|
||||
except Exception:
|
||||
value = 1.0
|
||||
return value if value > 0 else 1.0
|
||||
|
||||
@property
|
||||
def match_bonus(self) -> float:
|
||||
return max(0.0, self.match_multiplier - 1.0)
|
||||
|
||||
|
||||
def default_user_theme_weight() -> float:
|
||||
"""Read the default user theme weighting multiplier from the environment."""
|
||||
|
||||
raw = os.getenv("USER_THEME_WEIGHT")
|
||||
if raw is None:
|
||||
return 1.0
|
||||
try:
|
||||
value = float(raw)
|
||||
except Exception:
|
||||
logger.warning("Invalid USER_THEME_WEIGHT=%s; falling back to 1.0", raw)
|
||||
return 1.0
|
||||
return value if value >= 0 else 0.0
|
||||
|
||||
|
||||
def _normalize_role(role: str) -> str:
|
||||
try:
|
||||
return str(role).strip().lower()
|
||||
except Exception:
|
||||
return str(role)
|
||||
|
||||
|
||||
def _normalize_tag(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return normalize_theme(value)
|
||||
except Exception:
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _theme_weight_factors(
|
||||
commander_targets: Sequence[ThemeTarget],
|
||||
user_targets: Sequence[ThemeTarget],
|
||||
user_theme_weight: float,
|
||||
) -> Dict[str, float]:
|
||||
"""Compute normalized weight allocations for commander and user themes."""
|
||||
|
||||
role_factors = {
|
||||
"primary": 1.0,
|
||||
"secondary": 0.75,
|
||||
"tertiary": 0.5,
|
||||
}
|
||||
raw_weights: Dict[str, float] = {}
|
||||
for target in commander_targets:
|
||||
factor = role_factors.get(_normalize_role(target.role), 0.5)
|
||||
raw_weights[target.role] = max(0.0, factor)
|
||||
user_total = max(0.0, user_theme_weight)
|
||||
per_user = (user_total / len(user_targets)) if user_targets else 0.0
|
||||
for target in user_targets:
|
||||
raw_weights[target.role] = max(0.0, per_user)
|
||||
total = sum(raw_weights.values())
|
||||
if total <= 0:
|
||||
if commander_targets:
|
||||
fallback = 1.0 / len(commander_targets)
|
||||
for target in commander_targets:
|
||||
raw_weights[target.role] = fallback
|
||||
elif user_targets:
|
||||
fallback = 1.0 / len(user_targets)
|
||||
for target in user_targets:
|
||||
raw_weights[target.role] = fallback
|
||||
else:
|
||||
return {}
|
||||
total = sum(raw_weights.values())
|
||||
return {role: weight / total for role, weight in raw_weights.items()}
|
||||
|
||||
|
||||
def build_theme_context(builder: Any) -> ThemeContext:
|
||||
"""Construct theme ordering, weights, and resolution metadata from a builder."""
|
||||
|
||||
commander_targets: List[ThemeTarget] = []
|
||||
for role in ("primary", "secondary", "tertiary"):
|
||||
tag = getattr(builder, f"{role}_tag", None)
|
||||
if not tag:
|
||||
continue
|
||||
slug = _normalize_tag(tag)
|
||||
commander_targets.append(
|
||||
ThemeTarget(role=role, display=str(tag), slug=slug, source="commander")
|
||||
)
|
||||
|
||||
user_resolved: List[str] = []
|
||||
resolution = getattr(builder, "user_theme_resolution", None)
|
||||
if resolution is not None and isinstance(resolution, ThemeResolutionInfo):
|
||||
user_resolved = list(resolution.resolved)
|
||||
else:
|
||||
raw_resolved = getattr(builder, "user_theme_resolved", [])
|
||||
if isinstance(raw_resolved, (list, tuple)):
|
||||
user_resolved = [str(item) for item in raw_resolved if str(item).strip()]
|
||||
user_targets: List[ThemeTarget] = []
|
||||
for index, theme in enumerate(user_resolved):
|
||||
slug = _normalize_tag(theme)
|
||||
role = f"user_{index + 1}"
|
||||
user_targets.append(
|
||||
ThemeTarget(role=role, display=str(theme), slug=slug, source="user")
|
||||
)
|
||||
|
||||
combine_mode = str(getattr(builder, "tag_mode", "AND") or "AND").upper()
|
||||
user_theme_weight = float(getattr(builder, "user_theme_weight", default_user_theme_weight()))
|
||||
weights = _theme_weight_factors(commander_targets, user_targets, user_theme_weight)
|
||||
|
||||
ordered_raw = commander_targets + user_targets
|
||||
ordered = [
|
||||
ThemeTarget(
|
||||
role=target.role,
|
||||
display=target.display,
|
||||
slug=target.slug,
|
||||
source=target.source,
|
||||
weight=weights.get(target.role, 0.0),
|
||||
)
|
||||
for target in ordered_raw
|
||||
]
|
||||
commander_slugs = [target.slug for target in ordered if target.source == "commander" and target.slug]
|
||||
user_slugs = [target.slug for target in ordered if target.source == "user" and target.slug]
|
||||
|
||||
info = resolution if isinstance(resolution, ThemeResolutionInfo) else None
|
||||
|
||||
# Log once per context creation for diagnostics
|
||||
try:
|
||||
logger.debug(
|
||||
"Theme context constructed: commander=%s user=%s mode=%s weight=%.3f",
|
||||
commander_slugs,
|
||||
user_slugs,
|
||||
combine_mode,
|
||||
user_theme_weight,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for target in ordered:
|
||||
if target.source != "user":
|
||||
continue
|
||||
effective_weight = weights.get(target.role, target.weight)
|
||||
logger.info(
|
||||
"user_theme_applied theme='%s' slug=%s role=%s weight=%.3f mode=%s multiplier=%.3f",
|
||||
target.display,
|
||||
target.slug,
|
||||
target.role,
|
||||
float(effective_weight or 0.0),
|
||||
combine_mode,
|
||||
float(user_theme_weight or 0.0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ThemeContext(
|
||||
ordered_targets=ordered,
|
||||
combine_mode=combine_mode,
|
||||
weights=weights,
|
||||
commander_slugs=commander_slugs,
|
||||
user_slugs=user_slugs,
|
||||
resolution=info,
|
||||
user_theme_weight=user_theme_weight,
|
||||
)
|
||||
|
||||
|
||||
def annotate_theme_matches(df, context: ThemeContext):
|
||||
"""Add commander/user match columns to a working dataframe."""
|
||||
|
||||
if df is None or getattr(df, "empty", True):
|
||||
return df
|
||||
if "_parsedThemeTags" not in df.columns:
|
||||
df = df.copy()
|
||||
df["_parsedThemeTags"] = df["themeTags"].apply(bu.normalize_tag_cell)
|
||||
if "_normTags" not in df.columns:
|
||||
df = df.copy()
|
||||
df["_normTags"] = df["_parsedThemeTags"]
|
||||
|
||||
commander_set = set(context.commander_slugs)
|
||||
user_set = set(context.user_slugs)
|
||||
|
||||
def _match_count(tags: Iterable[str], needles: set[str]) -> int:
|
||||
if not tags or not needles:
|
||||
return 0
|
||||
try:
|
||||
return sum(1 for tag in tags if tag in needles)
|
||||
except Exception:
|
||||
total = 0
|
||||
for tag in tags:
|
||||
try:
|
||||
if tag in needles:
|
||||
total += 1
|
||||
except Exception:
|
||||
continue
|
||||
return total
|
||||
|
||||
df["_commanderMatch"] = df["_normTags"].apply(lambda tags: _match_count(tags, commander_set))
|
||||
df["_userMatch"] = df["_normTags"].apply(lambda tags: _match_count(tags, user_set))
|
||||
df["_multiMatch"] = df["_commanderMatch"] + df["_userMatch"]
|
||||
bonus = context.match_bonus
|
||||
if bonus > 0:
|
||||
df["_matchScore"] = df["_multiMatch"] + (df["_userMatch"] * bonus)
|
||||
else:
|
||||
df["_matchScore"] = df["_multiMatch"]
|
||||
|
||||
def _collect_hits(tags: Iterable[str]) -> List[str]:
|
||||
if not tags:
|
||||
return []
|
||||
hits: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for target in context.ordered_targets:
|
||||
slug = target.slug
|
||||
if not slug or slug in seen:
|
||||
continue
|
||||
try:
|
||||
if slug in tags:
|
||||
hits.append(target.display)
|
||||
seen.add(slug)
|
||||
except Exception:
|
||||
continue
|
||||
return hits
|
||||
|
||||
df["_matchTags"] = df["_normTags"].apply(_collect_hits)
|
||||
return df
|
||||
|
||||
|
||||
def theme_summary_payload(context: ThemeContext) -> Dict[str, Any]:
|
||||
"""Produce a structured payload for UI/JSON exports summarizing themes."""
|
||||
|
||||
info = context.resolution
|
||||
requested: List[str] = []
|
||||
resolved: List[str] = []
|
||||
unresolved: List[str] = []
|
||||
matches: List[Dict[str, Any]] = []
|
||||
fuzzy: Dict[str, str] = {}
|
||||
catalog_version: Optional[str] = None
|
||||
if info is not None:
|
||||
requested = list(info.requested)
|
||||
resolved = list(info.resolved)
|
||||
unresolved = [item.get("input", "") for item in info.unresolved]
|
||||
matches = list(info.matches)
|
||||
fuzzy = dict(info.fuzzy_corrections)
|
||||
catalog_version = info.catalog_version
|
||||
else:
|
||||
resolved = [target.display for target in context.ordered_targets if target.source == "user"]
|
||||
|
||||
return {
|
||||
"commanderThemes": [target.display for target in context.ordered_targets if target.source == "commander"],
|
||||
"userThemes": [target.display for target in context.ordered_targets if target.source == "user"],
|
||||
"requested": requested,
|
||||
"resolved": resolved,
|
||||
"unresolved": unresolved,
|
||||
"matches": matches,
|
||||
"fuzzyCorrections": fuzzy,
|
||||
"mode": context.combine_mode,
|
||||
"weight": context.user_theme_weight,
|
||||
"themeCatalogVersion": catalog_version,
|
||||
}
|
||||
257
code/deck_builder/theme_matcher.py
Normal file
257
code/deck_builder/theme_matcher.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""Fuzzy matching utilities for supplemental theme selection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry
|
||||
|
||||
__all__ = [
|
||||
"normalize_theme",
|
||||
"ThemeScore",
|
||||
"ResolutionResult",
|
||||
"ThemeMatcher",
|
||||
"HIGH_MATCH_THRESHOLD",
|
||||
"ACCEPT_MATCH_THRESHOLD",
|
||||
"SUGGEST_MATCH_THRESHOLD",
|
||||
]
|
||||
|
||||
_SPACE_RE = re.compile(r"\s+")
|
||||
_NON_ALNUM_RE = re.compile(r"[^a-z0-9 ]+")
|
||||
|
||||
HIGH_MATCH_THRESHOLD = 90.0
|
||||
ACCEPT_MATCH_THRESHOLD = 80.0
|
||||
SUGGEST_MATCH_THRESHOLD = 60.0
|
||||
MIN_QUERY_LENGTH = 3
|
||||
MAX_SUGGESTIONS = 5
|
||||
|
||||
|
||||
def normalize_theme(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
text = _SPACE_RE.sub(" ", text)
|
||||
return text.casefold()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _IndexedTheme:
|
||||
display: str
|
||||
normalized: str
|
||||
tokens: tuple[str, ...]
|
||||
trigrams: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeScore:
|
||||
theme: str
|
||||
score: float
|
||||
|
||||
def rounded(self) -> float:
|
||||
return round(self.score, 4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
matched_theme: str | None
|
||||
score: float
|
||||
reason: str
|
||||
suggestions: List[ThemeScore]
|
||||
|
||||
|
||||
def _tokenize(text: str) -> tuple[str, ...]:
|
||||
cleaned = _NON_ALNUM_RE.sub(" ", text)
|
||||
parts = [p for p in cleaned.split() if p]
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _trigrams(text: str) -> tuple[str, ...]:
|
||||
text = text.replace(" ", "_")
|
||||
if len(text) < 3:
|
||||
return tuple(text)
|
||||
extended = f"__{text}__"
|
||||
grams = [extended[i : i + 3] for i in range(len(extended) - 2)]
|
||||
return tuple(sorted(set(grams)))
|
||||
|
||||
|
||||
def _build_index(entries: Sequence[ThemeCatalogEntry]) -> tuple[tuple[_IndexedTheme, ...], dict[str, set[int]]]:
|
||||
indexed: list[_IndexedTheme] = []
|
||||
trigram_map: dict[str, set[int]] = {}
|
||||
for idx, entry in enumerate(entries):
|
||||
norm = normalize_theme(entry.theme)
|
||||
tokens = _tokenize(norm)
|
||||
trigrams = _trigrams(norm)
|
||||
indexed.append(
|
||||
_IndexedTheme(
|
||||
display=entry.theme,
|
||||
normalized=norm,
|
||||
tokens=tokens,
|
||||
trigrams=trigrams,
|
||||
)
|
||||
)
|
||||
for gram in trigrams:
|
||||
trigram_map.setdefault(gram, set()).add(idx)
|
||||
return tuple(indexed), trigram_map
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QueryInfo:
|
||||
normalized: str
|
||||
tokens: tuple[str, ...]
|
||||
trigrams: tuple[str, ...]
|
||||
|
||||
|
||||
def _levenshtein(a: str, b: str) -> int:
|
||||
if a == b:
|
||||
return 0
|
||||
if not a:
|
||||
return len(b)
|
||||
if not b:
|
||||
return len(a)
|
||||
if len(a) < len(b):
|
||||
a, b = b, a
|
||||
previous = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, start=1):
|
||||
current = [i]
|
||||
for j, cb in enumerate(b, start=1):
|
||||
insert_cost = current[j - 1] + 1
|
||||
delete_cost = previous[j] + 1
|
||||
replace_cost = previous[j - 1] + (0 if ca == cb else 1)
|
||||
current.append(min(insert_cost, delete_cost, replace_cost))
|
||||
previous = current
|
||||
return previous[-1]
|
||||
|
||||
|
||||
def _similarity(query: _QueryInfo, candidate: _IndexedTheme) -> float:
|
||||
if not candidate.trigrams:
|
||||
return 0.0
|
||||
if query.normalized == candidate.normalized:
|
||||
return 100.0
|
||||
|
||||
query_tokens = set(query.tokens)
|
||||
candidate_tokens = set(candidate.tokens)
|
||||
shared_tokens = len(query_tokens & candidate_tokens)
|
||||
token_base = max(len(query_tokens), len(candidate_tokens), 1)
|
||||
token_score = 100.0 * shared_tokens / token_base
|
||||
|
||||
query_trigrams = set(query.trigrams)
|
||||
candidate_trigrams = set(candidate.trigrams)
|
||||
if not query_trigrams:
|
||||
trigram_score = 0.0
|
||||
else:
|
||||
intersection = len(query_trigrams & candidate_trigrams)
|
||||
union = len(query_trigrams | candidate_trigrams)
|
||||
trigram_score = 100.0 * intersection / union if union else 0.0
|
||||
|
||||
seq_score = 100.0 * difflib.SequenceMatcher(None, query.normalized, candidate.normalized).ratio()
|
||||
distance = _levenshtein(query.normalized, candidate.normalized)
|
||||
max_len = max(len(query.normalized), len(candidate.normalized))
|
||||
distance_score = 100.0 * (1.0 - distance / max_len) if max_len else 0.0
|
||||
|
||||
prefix_bonus = 5.0 if candidate.normalized.startswith(query.normalized) else 0.0
|
||||
token_prefix_bonus = 5.0 if candidate.tokens and query.tokens and candidate.tokens[0].startswith(query.tokens[0]) else 0.0
|
||||
token_similarity_bonus = 0.0
|
||||
if query.tokens and candidate.tokens:
|
||||
token_similarity_bonus = 5.0 * difflib.SequenceMatcher(None, query.tokens[0], candidate.tokens[0]).ratio()
|
||||
distance_bonus = 0.0
|
||||
if distance <= 2:
|
||||
distance_bonus = 10.0 - (3.0 * distance)
|
||||
|
||||
score = (
|
||||
0.3 * trigram_score
|
||||
+ 0.2 * token_score
|
||||
+ 0.3 * seq_score
|
||||
+ 0.2 * distance_score
|
||||
+ prefix_bonus
|
||||
+ token_prefix_bonus
|
||||
+ distance_bonus
|
||||
+ token_similarity_bonus
|
||||
)
|
||||
if distance <= 2:
|
||||
score = max(score, 85.0 - 5.0 * distance)
|
||||
return min(score, 100.0)
|
||||
|
||||
|
||||
class ThemeMatcher:
|
||||
"""Fuzzy matcher backed by a trigram index.
|
||||
|
||||
On dev hardware (2025-10-02) resolving 20 queries against a 400-theme
|
||||
catalog completes in ≈0.65s (~0.03s per query) including Levenshtein
|
||||
scoring.
|
||||
"""
|
||||
|
||||
def __init__(self, entries: Sequence[ThemeCatalogEntry]):
|
||||
self._entries: tuple[_IndexedTheme, ...]
|
||||
self._trigram_index: dict[str, set[int]]
|
||||
self._entries, self._trigram_index = _build_index(entries)
|
||||
|
||||
@classmethod
|
||||
def from_entries(cls, entries: Iterable[ThemeCatalogEntry]) -> "ThemeMatcher":
|
||||
return cls(list(entries))
|
||||
|
||||
def resolve(self, raw_query: str, *, limit: int = MAX_SUGGESTIONS) -> ResolutionResult:
|
||||
normalized = normalize_theme(raw_query)
|
||||
if not normalized:
|
||||
return ResolutionResult(matched_theme=None, score=0.0, reason="empty_input", suggestions=[])
|
||||
|
||||
query = _QueryInfo(
|
||||
normalized=normalized,
|
||||
tokens=_tokenize(normalized),
|
||||
trigrams=_trigrams(normalized),
|
||||
)
|
||||
|
||||
if len(normalized.replace(" ", "")) < MIN_QUERY_LENGTH:
|
||||
exact = next((entry for entry in self._entries if entry.normalized == normalized), None)
|
||||
if exact:
|
||||
return ResolutionResult(
|
||||
matched_theme=exact.display,
|
||||
score=100.0,
|
||||
reason="short_exact",
|
||||
suggestions=[ThemeScore(theme=exact.display, score=100.0)],
|
||||
)
|
||||
return ResolutionResult(matched_theme=None, score=0.0, reason="input_too_short", suggestions=[])
|
||||
|
||||
candidates = self._candidate_indexes(query)
|
||||
if not candidates:
|
||||
return ResolutionResult(matched_theme=None, score=0.0, reason="no_candidates", suggestions=[])
|
||||
|
||||
scored: list[ThemeScore] = []
|
||||
seen: set[str] = set()
|
||||
for idx in candidates:
|
||||
entry = self._entries[idx]
|
||||
score = _similarity(query, entry)
|
||||
if score <= 0 or score < 20.0:
|
||||
continue
|
||||
if entry.display in seen:
|
||||
continue
|
||||
scored.append(ThemeScore(theme=entry.display, score=score))
|
||||
seen.add(entry.display)
|
||||
|
||||
scored.sort(key=lambda item: (-item.score, item.theme.casefold(), item.theme))
|
||||
suggestions = scored[:limit]
|
||||
|
||||
if not suggestions:
|
||||
return ResolutionResult(matched_theme=None, score=0.0, reason="no_match", suggestions=[])
|
||||
|
||||
top = suggestions[0]
|
||||
if top.score >= HIGH_MATCH_THRESHOLD:
|
||||
return ResolutionResult(matched_theme=top.theme, score=top.score, reason="high_confidence", suggestions=suggestions)
|
||||
if top.score >= ACCEPT_MATCH_THRESHOLD:
|
||||
return ResolutionResult(matched_theme=top.theme, score=top.score, reason="accepted_confidence", suggestions=suggestions)
|
||||
if top.score >= SUGGEST_MATCH_THRESHOLD:
|
||||
return ResolutionResult(matched_theme=None, score=top.score, reason="suggestions", suggestions=suggestions)
|
||||
return ResolutionResult(matched_theme=None, score=top.score, reason="no_match", suggestions=suggestions)
|
||||
|
||||
def _candidate_indexes(self, query: _QueryInfo) -> set[int]:
|
||||
if not query.trigrams:
|
||||
return set(range(len(self._entries)))
|
||||
candidates: set[int] = set()
|
||||
for gram in query.trigrams:
|
||||
candidates.update(self._trigram_index.get(gram, ()))
|
||||
return candidates or set(range(len(self._entries)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def build_matcher(entries: tuple[ThemeCatalogEntry, ...]) -> ThemeMatcher:
|
||||
return ThemeMatcher(entries)
|
||||
216
code/deck_builder/theme_resolution.py
Normal file
216
code/deck_builder/theme_resolution.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Shared theme resolution utilities for supplemental user themes.
|
||||
|
||||
This module centralizes the fuzzy resolution logic so both the headless
|
||||
runner and the web UI can reuse a consistent implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Sequence
|
||||
|
||||
from deck_builder.theme_catalog_loader import load_theme_catalog
|
||||
from deck_builder.theme_matcher import (
|
||||
build_matcher,
|
||||
normalize_theme,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ThemeResolutionInfo",
|
||||
"normalize_theme_match_mode",
|
||||
"clean_theme_inputs",
|
||||
"parse_theme_list",
|
||||
"resolve_additional_theme_inputs",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeResolutionInfo:
|
||||
"""Captures the outcome of resolving user-supplied supplemental themes."""
|
||||
|
||||
requested: List[str]
|
||||
mode: str
|
||||
catalog_version: str
|
||||
resolved: List[str]
|
||||
matches: List[Dict[str, Any]]
|
||||
unresolved: List[Dict[str, Any]]
|
||||
fuzzy_corrections: Dict[str, str]
|
||||
|
||||
|
||||
def normalize_theme_match_mode(value: str | None) -> str:
|
||||
"""Normalize theme match mode inputs to ``strict`` or ``permissive``."""
|
||||
|
||||
if value is None:
|
||||
return "permissive"
|
||||
text = str(value).strip().lower()
|
||||
if text in {"strict", "s"}:
|
||||
return "strict"
|
||||
return "permissive"
|
||||
|
||||
|
||||
def clean_theme_inputs(values: Sequence[Any]) -> List[str]:
|
||||
"""Normalize, deduplicate, and filter empty user-provided theme strings."""
|
||||
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values or []:
|
||||
try:
|
||||
text = str(value).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
key = text.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(text)
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_theme_list(raw: str | None) -> List[str]:
|
||||
"""Parse CLI/config style theme lists separated by comma or semicolon."""
|
||||
|
||||
if raw is None:
|
||||
return []
|
||||
try:
|
||||
text = str(raw)
|
||||
except Exception:
|
||||
return []
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
delimiter = ";" if ";" in text else ","
|
||||
parts = [part.strip() for part in text.split(delimiter)]
|
||||
return clean_theme_inputs(parts)
|
||||
|
||||
|
||||
def resolve_additional_theme_inputs(
|
||||
requested: Sequence[str],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str] = (),
|
||||
) -> ThemeResolutionInfo:
|
||||
"""Resolve user-provided additional themes against the catalog.
|
||||
|
||||
Args:
|
||||
requested: Raw user inputs.
|
||||
mode: Strictness mode (``strict`` aborts on unresolved themes).
|
||||
commander_tags: Tags already supplied by the selected commander; these
|
||||
are used to deduplicate resolved results so we do not re-add themes
|
||||
already covered by the commander selection.
|
||||
|
||||
Returns:
|
||||
:class:`ThemeResolutionInfo` describing resolved and unresolved themes.
|
||||
|
||||
Raises:
|
||||
ValueError: When ``mode`` is strict and one or more inputs cannot be
|
||||
resolved with sufficient confidence.
|
||||
"""
|
||||
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
cleaned_inputs = clean_theme_inputs(requested)
|
||||
entries, version = load_theme_catalog(None)
|
||||
|
||||
if not cleaned_inputs:
|
||||
return ThemeResolutionInfo(
|
||||
requested=[],
|
||||
mode=normalized_mode,
|
||||
catalog_version=version,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[],
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
if not entries:
|
||||
unresolved = [
|
||||
{"input": raw, "reason": "catalog_missing", "score": 0.0, "suggestions": []}
|
||||
for raw in cleaned_inputs
|
||||
]
|
||||
if normalized_mode == "strict":
|
||||
raise ValueError(
|
||||
"Unable to resolve additional themes in strict mode: catalog unavailable"
|
||||
)
|
||||
return ThemeResolutionInfo(
|
||||
requested=cleaned_inputs,
|
||||
mode=normalized_mode,
|
||||
catalog_version=version,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=unresolved,
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
matcher = build_matcher(tuple(entries))
|
||||
matches: List[Dict[str, Any]] = []
|
||||
unresolved: List[Dict[str, Any]] = []
|
||||
fuzzy: Dict[str, str] = {}
|
||||
for raw in cleaned_inputs:
|
||||
result = matcher.resolve(raw)
|
||||
suggestions = [
|
||||
{"theme": suggestion.theme, "score": float(round(suggestion.score, 4))}
|
||||
for suggestion in result.suggestions
|
||||
]
|
||||
if result.matched_theme:
|
||||
matches.append(
|
||||
{
|
||||
"input": raw,
|
||||
"matched": result.matched_theme,
|
||||
"score": float(round(result.score, 4)),
|
||||
"reason": result.reason,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
)
|
||||
if normalize_theme(raw) != normalize_theme(result.matched_theme):
|
||||
fuzzy[raw] = result.matched_theme
|
||||
else:
|
||||
unresolved.append(
|
||||
{
|
||||
"input": raw,
|
||||
"reason": result.reason,
|
||||
"score": float(round(result.score, 4)),
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
)
|
||||
|
||||
commander_set = {
|
||||
normalize_theme(tag)
|
||||
for tag in commander_tags
|
||||
if isinstance(tag, str) and tag.strip()
|
||||
}
|
||||
resolved: List[str] = []
|
||||
seen_resolved: set[str] = set()
|
||||
for match in matches:
|
||||
norm = normalize_theme(match["matched"])
|
||||
if norm in seen_resolved:
|
||||
continue
|
||||
if commander_set and norm in commander_set:
|
||||
continue
|
||||
resolved.append(match["matched"])
|
||||
seen_resolved.add(norm)
|
||||
|
||||
if normalized_mode == "strict" and unresolved:
|
||||
parts: List[str] = []
|
||||
for item in unresolved:
|
||||
suggestion_text = ", ".join(
|
||||
f"{s['theme']} ({s['score']:.1f})" for s in item.get("suggestions", [])
|
||||
)
|
||||
if suggestion_text:
|
||||
parts.append(f"{item['input']} (suggestions: {suggestion_text})")
|
||||
else:
|
||||
parts.append(item["input"])
|
||||
raise ValueError(
|
||||
"Unable to resolve additional themes in strict mode: " + "; ".join(parts)
|
||||
)
|
||||
|
||||
return ThemeResolutionInfo(
|
||||
requested=cleaned_inputs,
|
||||
mode=normalized_mode,
|
||||
catalog_version=version,
|
||||
resolved=resolved,
|
||||
matches=matches,
|
||||
unresolved=unresolved,
|
||||
fuzzy_corrections=fuzzy,
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
281
code/scripts/generate_theme_catalog.py
Normal file
281
code/scripts/generate_theme_catalog.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""Generate a normalized theme catalog CSV from card datasets.
|
||||
|
||||
Outputs `theme_catalog.csv` with deterministic ordering, a reproducible version hash,
|
||||
and per-source occurrence counts so supplemental theme workflows can reuse the catalog.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Sequence
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CODE_ROOT = ROOT / "code"
|
||||
if str(CODE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CODE_ROOT))
|
||||
|
||||
try:
|
||||
from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore
|
||||
except Exception: # pragma: no cover - fallback for adhoc execution
|
||||
DEFAULT_CSV_DIRECTORY = "csv_files"
|
||||
|
||||
DEFAULT_OUTPUT_PATH = ROOT / "config" / "themes" / "theme_catalog.csv"
|
||||
HEADER_COMMENT_PREFIX = "# theme_catalog"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CatalogRow:
|
||||
theme: str
|
||||
source_count: int
|
||||
commander_count: int
|
||||
card_count: int
|
||||
last_generated_at: str
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CatalogBuildResult:
|
||||
rows: List[CatalogRow]
|
||||
generated_at: str
|
||||
version: str
|
||||
output_path: Path
|
||||
|
||||
|
||||
def normalize_theme_display(raw: str) -> str:
|
||||
trimmed = " ".join(raw.strip().split())
|
||||
return trimmed
|
||||
|
||||
|
||||
def canonical_key(raw: str) -> str:
|
||||
return normalize_theme_display(raw).casefold()
|
||||
|
||||
|
||||
def parse_theme_tags(value: object) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value if isinstance(v, str) and v.strip()]
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return []
|
||||
# Try JSON parsing first (themeTags often stored as JSON arrays)
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed if isinstance(v, str) and v.strip()]
|
||||
# Fallback to Python literal lists
|
||||
try:
|
||||
literal = ast.literal_eval(candidate)
|
||||
except (ValueError, SyntaxError):
|
||||
literal = None
|
||||
if isinstance(literal, list):
|
||||
return [str(v) for v in literal if isinstance(v, str) and v.strip()]
|
||||
return [candidate]
|
||||
return []
|
||||
|
||||
|
||||
def _load_theme_counts(csv_path: Path, theme_variants: Dict[str, set[str]]) -> Counter[str]:
|
||||
counts: Counter[str] = Counter()
|
||||
if not csv_path.exists():
|
||||
return counts
|
||||
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames or "themeTags" not in reader.fieldnames:
|
||||
return counts
|
||||
for row in reader:
|
||||
raw_value = row.get("themeTags")
|
||||
tags = parse_theme_tags(raw_value)
|
||||
if not tags:
|
||||
continue
|
||||
seen_in_row: set[str] = set()
|
||||
for tag in tags:
|
||||
display = normalize_theme_display(tag)
|
||||
if not display:
|
||||
continue
|
||||
key = canonical_key(display)
|
||||
if key in seen_in_row:
|
||||
continue
|
||||
seen_in_row.add(key)
|
||||
counts[key] += 1
|
||||
theme_variants[key].add(display)
|
||||
return counts
|
||||
|
||||
|
||||
def _select_display_name(options: Sequence[str]) -> str:
|
||||
if not options:
|
||||
return ""
|
||||
|
||||
def ranking(value: str) -> tuple[int, int, str, str]:
|
||||
all_upper = int(value == value.upper())
|
||||
title_case = int(value != value.title())
|
||||
return (all_upper, title_case, value.casefold(), value)
|
||||
|
||||
return min(options, key=ranking)
|
||||
|
||||
|
||||
def _derive_generated_at(now: Optional[datetime] = None) -> str:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
without_microseconds = current.replace(microsecond=0)
|
||||
iso = without_microseconds.isoformat()
|
||||
return iso.replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _compute_version_hash(theme_names: Iterable[str]) -> str:
|
||||
joined = "\n".join(sorted(theme_names)).encode("utf-8")
|
||||
return hashlib.sha256(joined).hexdigest()[:12]
|
||||
|
||||
|
||||
def build_theme_catalog(
|
||||
csv_directory: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
generated_at: Optional[datetime] = None,
|
||||
commander_filename: str = "commander_cards.csv",
|
||||
cards_filename: str = "cards.csv",
|
||||
logs_directory: Optional[Path] = None,
|
||||
) -> CatalogBuildResult:
|
||||
csv_directory = csv_directory.resolve()
|
||||
output_path = output_path.resolve()
|
||||
|
||||
theme_variants: Dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
commander_counts = _load_theme_counts(csv_directory / commander_filename, theme_variants)
|
||||
|
||||
card_counts: Counter[str] = Counter()
|
||||
cards_path = csv_directory / cards_filename
|
||||
if cards_path.exists():
|
||||
card_counts = _load_theme_counts(cards_path, theme_variants)
|
||||
else:
|
||||
# Fallback: scan all *_cards.csv except commander
|
||||
for candidate in csv_directory.glob("*_cards.csv"):
|
||||
if candidate.name == commander_filename:
|
||||
continue
|
||||
card_counts += _load_theme_counts(candidate, theme_variants)
|
||||
|
||||
keys = sorted(set(card_counts.keys()) | set(commander_counts.keys()))
|
||||
generated_at_iso = _derive_generated_at(generated_at)
|
||||
display_names = [_select_display_name(sorted(theme_variants[key])) for key in keys]
|
||||
version_hash = _compute_version_hash(display_names)
|
||||
|
||||
rows: List[CatalogRow] = []
|
||||
for key, display in zip(keys, display_names):
|
||||
if not display:
|
||||
continue
|
||||
card_count = int(card_counts.get(key, 0))
|
||||
commander_count = int(commander_counts.get(key, 0))
|
||||
source_count = card_count + commander_count
|
||||
rows.append(
|
||||
CatalogRow(
|
||||
theme=display,
|
||||
source_count=source_count,
|
||||
commander_count=commander_count,
|
||||
card_count=card_count,
|
||||
last_generated_at=generated_at_iso,
|
||||
version=version_hash,
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row.theme.casefold(), row.theme))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
comment = (
|
||||
f"{HEADER_COMMENT_PREFIX} version={version_hash} "
|
||||
f"generated_at={generated_at_iso} total_themes={len(rows)}\n"
|
||||
)
|
||||
handle.write(comment)
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow([
|
||||
"theme",
|
||||
"source_count",
|
||||
"commander_count",
|
||||
"card_count",
|
||||
"last_generated_at",
|
||||
"version",
|
||||
])
|
||||
for row in rows:
|
||||
writer.writerow([
|
||||
row.theme,
|
||||
row.source_count,
|
||||
row.commander_count,
|
||||
row.card_count,
|
||||
row.last_generated_at,
|
||||
row.version,
|
||||
])
|
||||
|
||||
if logs_directory is not None:
|
||||
logs_directory = logs_directory.resolve()
|
||||
logs_directory.mkdir(parents=True, exist_ok=True)
|
||||
copy_path = logs_directory / output_path.name
|
||||
shutil.copyfile(output_path, copy_path)
|
||||
|
||||
if not rows:
|
||||
raise RuntimeError(
|
||||
"No theme tags found while generating theme catalog; ensure card CSVs contain a themeTags column."
|
||||
)
|
||||
|
||||
return CatalogBuildResult(rows=rows, generated_at=generated_at_iso, version=version_hash, output_path=output_path)
|
||||
|
||||
|
||||
def _resolve_csv_directory(value: Optional[str]) -> Path:
|
||||
if value:
|
||||
return Path(value)
|
||||
env_override = os.environ.get("CSV_FILES_DIR")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return ROOT / DEFAULT_CSV_DIRECTORY
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult:
|
||||
parser = argparse.ArgumentParser(description="Generate a normalized theme catalog CSV.")
|
||||
parser.add_argument(
|
||||
"--csv-dir",
|
||||
dest="csv_dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Directory containing card CSV files (defaults to CSV_FILES_DIR or settings.CSV_DIRECTORY)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
dest="output",
|
||||
type=Path,
|
||||
default=DEFAULT_OUTPUT_PATH,
|
||||
help="Destination CSV path (defaults to config/themes/theme_catalog.csv)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--logs-dir",
|
||||
dest="logs_dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None)
|
||||
result = build_theme_catalog(
|
||||
csv_directory=csv_dir,
|
||||
output_path=args.output,
|
||||
logs_directory=args.logs_dir,
|
||||
)
|
||||
print(
|
||||
f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entrypoint
|
||||
main()
|
||||
69
code/tests/test_additional_theme_config.py
Normal file
69
code/tests/test_additional_theme_config.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from headless_runner import _resolve_additional_theme_inputs, _parse_theme_list
|
||||
|
||||
|
||||
def _write_catalog(path: Path) -> None:
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# theme_catalog version=test_version",
|
||||
"theme,commander_count,card_count",
|
||||
"Lifegain,5,20",
|
||||
"Token Swarm,3,15",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_theme_list_handles_semicolons() -> None:
|
||||
assert _parse_theme_list("Lifegain;Token Swarm ; lifegain") == ["Lifegain", "Token Swarm"]
|
||||
|
||||
|
||||
def test_resolve_additional_themes_permissive(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
resolution = _resolve_additional_theme_inputs(
|
||||
["Lifegain", "Unknown"],
|
||||
mode="permissive",
|
||||
commander_tags=["Lifegain"],
|
||||
)
|
||||
|
||||
assert resolution.mode == "permissive"
|
||||
assert resolution.catalog_version == "test_version"
|
||||
# Lifegain deduped against commander tag
|
||||
assert resolution.resolved == []
|
||||
assert resolution.matches[0]["matched"] == "Lifegain"
|
||||
assert len(resolution.unresolved) == 1
|
||||
assert resolution.unresolved[0]["input"] == "Unknown"
|
||||
assert resolution.unresolved[0]["reason"] in {"no_match", "suggestions", "no_candidates"}
|
||||
|
||||
|
||||
def test_resolve_additional_themes_strict_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
_resolve_additional_theme_inputs(["Mystery"], mode="strict")
|
||||
assert "Mystery" in str(exc.value)
|
||||
|
||||
|
||||
def test_resolve_additional_themes_fuzzy_correction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path)
|
||||
monkeypatch.setenv("THEME_CATALOG_PATH", str(catalog_path))
|
||||
|
||||
resolution = _resolve_additional_theme_inputs(["lifgain"], mode="permissive")
|
||||
|
||||
assert resolution.resolved == ["Lifegain"]
|
||||
assert resolution.fuzzy_corrections == {"lifgain": "Lifegain"}
|
||||
assert not resolution.unresolved
|
||||
102
code/tests/test_custom_theme_htmx.py
Normal file
102
code/tests/test_custom_theme_htmx.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deck_builder.theme_resolution import ThemeResolutionInfo
|
||||
from web.app import app
|
||||
from web.services import custom_theme_manager as ctm
|
||||
|
||||
|
||||
def _make_info(
|
||||
requested: Sequence[str],
|
||||
*,
|
||||
resolved: Sequence[str] | None = None,
|
||||
matches: Sequence[dict[str, object]] | None = None,
|
||||
unresolved: Sequence[dict[str, object]] | None = None,
|
||||
mode: str = "permissive",
|
||||
catalog_version: str = "test-cat",
|
||||
) -> ThemeResolutionInfo:
|
||||
return ThemeResolutionInfo(
|
||||
requested=list(requested),
|
||||
mode=mode,
|
||||
catalog_version=catalog_version,
|
||||
resolved=list(resolved or []),
|
||||
matches=list(matches or []),
|
||||
unresolved=list(unresolved or []),
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
def fake_resolve(
|
||||
requested: Sequence[str],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str] = (),
|
||||
) -> ThemeResolutionInfo:
|
||||
inputs = list(requested)
|
||||
if not inputs:
|
||||
return _make_info([], resolved=[], matches=[], unresolved=[])
|
||||
if inputs == ["lifgian"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"reason": "suggestions",
|
||||
"score": 72.0,
|
||||
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
|
||||
}
|
||||
],
|
||||
)
|
||||
if inputs == ["Lifegain"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=["Lifegain"],
|
||||
matches=[
|
||||
{
|
||||
"input": "Lifegain",
|
||||
"matched": "Lifegain",
|
||||
"score": 91.2,
|
||||
"reason": "suggestion",
|
||||
"suggestions": [],
|
||||
}
|
||||
],
|
||||
unresolved=[],
|
||||
)
|
||||
raise AssertionError(f"Unexpected inputs: {inputs}")
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_remove_theme_updates_htmx_section(client: TestClient) -> None:
|
||||
add_resp = client.post("/build/themes/add", data={"theme": "lifgian"})
|
||||
assert add_resp.status_code == 200
|
||||
add_html = add_resp.text
|
||||
assert "lifgian" in add_html
|
||||
assert "Needs attention" in add_html
|
||||
|
||||
choose_resp = client.post(
|
||||
"/build/themes/choose",
|
||||
data={"original": "lifgian", "choice": "Lifegain"},
|
||||
)
|
||||
assert choose_resp.status_code == 200
|
||||
choose_html = choose_resp.text
|
||||
assert "Lifegain" in choose_html
|
||||
assert "Updated '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
|
||||
145
code/tests/test_custom_theme_manager_smoke.py
Normal file
145
code/tests/test_custom_theme_manager_smoke.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from deck_builder.theme_resolution import ThemeResolutionInfo
|
||||
from web.services import custom_theme_manager as ctm
|
||||
|
||||
|
||||
def _make_info(
|
||||
requested: Sequence[str],
|
||||
*,
|
||||
resolved: Sequence[str] | None = None,
|
||||
matches: Sequence[Dict[str, object]] | None = None,
|
||||
unresolved: Sequence[Dict[str, object]] | None = None,
|
||||
mode: str = "permissive",
|
||||
) -> ThemeResolutionInfo:
|
||||
return ThemeResolutionInfo(
|
||||
requested=list(requested),
|
||||
mode=mode,
|
||||
catalog_version="test-cat",
|
||||
resolved=list(resolved or []),
|
||||
matches=list(matches or []),
|
||||
unresolved=list(unresolved or []),
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
|
||||
|
||||
def test_add_theme_exact_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
assert list(requested) == ["Lifegain"]
|
||||
assert mode == "permissive"
|
||||
return _make_info(
|
||||
requested,
|
||||
resolved=["Lifegain"],
|
||||
matches=[{"input": "Lifegain", "matched": "Lifegain", "score": 100.0, "reason": "exact", "suggestions": []}],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.add_theme(
|
||||
session,
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
limit=ctm.DEFAULT_THEME_LIMIT,
|
||||
)
|
||||
|
||||
assert info is not None
|
||||
assert info.resolved == ["Lifegain"]
|
||||
assert session["custom_theme_inputs"] == ["Lifegain"]
|
||||
assert session["additional_themes"] == ["Lifegain"]
|
||||
assert message == "Added theme 'Lifegain'."
|
||||
assert level == "success"
|
||||
|
||||
|
||||
def test_add_theme_choose_suggestion_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
inputs = list(requested)
|
||||
if inputs == ["lifgian"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"reason": "suggestions",
|
||||
"score": 72.0,
|
||||
"suggestions": [{"theme": "Lifegain", "score": 91.2}],
|
||||
}
|
||||
],
|
||||
)
|
||||
if inputs == ["Lifegain"]:
|
||||
return _make_info(
|
||||
inputs,
|
||||
resolved=["Lifegain"],
|
||||
matches=[
|
||||
{
|
||||
"input": "lifgian",
|
||||
"matched": "Lifegain",
|
||||
"score": 91.2,
|
||||
"reason": "suggestion",
|
||||
"suggestions": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
pytest.fail(f"Unexpected inputs {inputs}")
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.add_theme(
|
||||
session,
|
||||
"lifgian",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
limit=ctm.DEFAULT_THEME_LIMIT,
|
||||
)
|
||||
assert info is not None
|
||||
assert not info.resolved
|
||||
assert session["custom_theme_inputs"] == ["lifgian"]
|
||||
assert message == "Added theme 'lifgian'."
|
||||
assert level == "success"
|
||||
|
||||
info, message, level = ctm.choose_suggestion(
|
||||
session,
|
||||
"lifgian",
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
)
|
||||
assert info is not None
|
||||
assert info.resolved == ["Lifegain"]
|
||||
assert session["custom_theme_inputs"] == ["Lifegain"]
|
||||
assert session["additional_themes"] == ["Lifegain"]
|
||||
assert message == "Updated 'lifgian' to 'Lifegain'."
|
||||
assert level == "success"
|
||||
|
||||
|
||||
def test_remove_theme_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session: Dict[str, object] = {"custom_theme_inputs": ["Lifegain"], "additional_themes": ["Lifegain"]}
|
||||
|
||||
def fake_resolve(requested: Sequence[str], mode: str, *, commander_tags: Iterable[str] = ()) -> ThemeResolutionInfo:
|
||||
assert requested == []
|
||||
return _make_info(requested, resolved=[], matches=[], unresolved=[])
|
||||
|
||||
monkeypatch.setattr(ctm, "resolve_additional_theme_inputs", fake_resolve)
|
||||
|
||||
info, message, level = ctm.remove_theme(
|
||||
session,
|
||||
"Lifegain",
|
||||
commander_tags=(),
|
||||
mode="permissive",
|
||||
)
|
||||
|
||||
assert info is not None
|
||||
assert session["custom_theme_inputs"] == []
|
||||
assert session["additional_themes"] == []
|
||||
assert message == "Theme removed."
|
||||
assert level == "success"
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
61
code/tests/test_theme_catalog_loader.py
Normal file
61
code/tests/test_theme_catalog_loader.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry, load_theme_catalog
|
||||
|
||||
|
||||
def _write_catalog(path: Path, lines: list[str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_load_theme_catalog_basic(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(
|
||||
catalog_path,
|
||||
[
|
||||
"# theme_catalog version=abc123 generated_at=2025-01-02T00:00:00Z",
|
||||
"theme,source_count,commander_count,card_count,last_generated_at,version",
|
||||
"Lifegain,3,1,2,2025-01-02T00:00:00Z,abc123",
|
||||
"Token Swarm,5,2,3,2025-01-02T00:00:00Z,abc123",
|
||||
],
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
entries, version = load_theme_catalog(catalog_path)
|
||||
|
||||
assert version == "abc123"
|
||||
assert entries == [
|
||||
ThemeCatalogEntry(theme="Lifegain", commander_count=1, card_count=2),
|
||||
ThemeCatalogEntry(theme="Token Swarm", commander_count=2, card_count=3),
|
||||
]
|
||||
log_messages = {record.message for record in caplog.records}
|
||||
assert any("theme_catalog_loaded" in message for message in log_messages)
|
||||
|
||||
|
||||
def test_load_theme_catalog_empty_file(tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(catalog_path, ["# theme_catalog version=empty"])
|
||||
|
||||
entries, version = load_theme_catalog(catalog_path)
|
||||
|
||||
assert entries == []
|
||||
assert version == "empty"
|
||||
|
||||
|
||||
def test_load_theme_catalog_missing_columns(tmp_path: Path) -> None:
|
||||
catalog_path = tmp_path / "theme_catalog.csv"
|
||||
_write_catalog(
|
||||
catalog_path,
|
||||
[
|
||||
"# theme_catalog version=missing",
|
||||
"theme,card_count,last_generated_at,version",
|
||||
"Lifegain,2,2025-01-02T00:00:00Z,missing",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
load_theme_catalog(catalog_path)
|
||||
92
code/tests/test_theme_matcher.py
Normal file
92
code/tests/test_theme_matcher.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry
|
||||
from code.deck_builder.theme_matcher import (
|
||||
ACCEPT_MATCH_THRESHOLD,
|
||||
SUGGEST_MATCH_THRESHOLD,
|
||||
ThemeMatcher,
|
||||
normalize_theme,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_entries() -> list[ThemeCatalogEntry]:
|
||||
themes = [
|
||||
"Aristocrats",
|
||||
"Sacrifice Matters",
|
||||
"Life Gain",
|
||||
"Token Swarm",
|
||||
"Control",
|
||||
"Superfriends",
|
||||
"Spellslinger",
|
||||
"Artifact Tokens",
|
||||
"Treasure Storm",
|
||||
"Graveyard Loops",
|
||||
]
|
||||
return [ThemeCatalogEntry(theme=theme, commander_count=0, card_count=0) for theme in themes]
|
||||
|
||||
|
||||
def test_normalize_theme_collapses_spaces() -> None:
|
||||
assert normalize_theme(" Life Gain \t") == "life gain"
|
||||
|
||||
|
||||
def test_exact_match_case_insensitive(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("aristocrats")
|
||||
assert result.matched_theme == "Aristocrats"
|
||||
assert result.score == pytest.approx(100.0)
|
||||
assert result.reason == "high_confidence"
|
||||
|
||||
|
||||
def test_minor_typo_accepts_with_high_score(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("aristrocrats")
|
||||
assert result.matched_theme == "Aristocrats"
|
||||
assert result.score >= ACCEPT_MATCH_THRESHOLD
|
||||
assert result.reason in {"high_confidence", "accepted_confidence"}
|
||||
|
||||
|
||||
def test_multi_typo_only_suggests(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("arzstrcrats")
|
||||
assert result.matched_theme is None
|
||||
assert result.score >= SUGGEST_MATCH_THRESHOLD
|
||||
assert result.reason == "suggestions"
|
||||
assert any(s.theme == "Aristocrats" for s in result.suggestions)
|
||||
|
||||
|
||||
def test_no_match_returns_empty(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("planeship")
|
||||
assert result.matched_theme is None
|
||||
assert result.suggestions == []
|
||||
assert result.reason in {"no_candidates", "no_match"}
|
||||
|
||||
|
||||
def test_short_input_requires_exact(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
matcher = ThemeMatcher(sample_entries)
|
||||
result = matcher.resolve("ar")
|
||||
assert result.matched_theme is None
|
||||
assert result.reason == "input_too_short"
|
||||
|
||||
result_exact = matcher.resolve("lo")
|
||||
assert result_exact.matched_theme is None
|
||||
|
||||
|
||||
def test_resolution_speed(sample_entries: list[ThemeCatalogEntry]) -> None:
|
||||
many_entries = [
|
||||
ThemeCatalogEntry(theme=f"Theme {i}", commander_count=0, card_count=0) for i in range(400)
|
||||
]
|
||||
matcher = ThemeMatcher(many_entries)
|
||||
matcher.resolve("theme 42")
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(20):
|
||||
matcher.resolve("theme 123")
|
||||
duration = time.perf_counter() - start
|
||||
# Observed ~0.03s per resolution (<=0.65s for 20 resolves) on dev machine (2025-10-02).
|
||||
assert duration < 0.7
|
||||
115
code/tests/test_theme_spell_weighting.py
Normal file
115
code/tests/test_theme_spell_weighting.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from deck_builder.theme_context import ThemeContext, ThemeTarget
|
||||
from deck_builder.phases.phase4_spells import SpellAdditionMixin
|
||||
from deck_builder import builder_utils as bu
|
||||
|
||||
|
||||
class DummyRNG:
|
||||
def uniform(self, _a: float, _b: float) -> float:
|
||||
return 1.0
|
||||
|
||||
def random(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def choice(self, seq):
|
||||
return seq[0]
|
||||
|
||||
|
||||
class DummySpellBuilder(SpellAdditionMixin):
|
||||
def __init__(self, df: pd.DataFrame, context: ThemeContext):
|
||||
self._combined_cards_df = df
|
||||
# Pre-populate 99 cards so we target a single filler slot
|
||||
self.card_library: Dict[str, Dict[str, Any]] = {
|
||||
f"Existing{i}": {"Count": 1} for i in range(99)
|
||||
}
|
||||
self.primary_tag = context.ordered_targets[0].display if context.ordered_targets else None
|
||||
self.secondary_tag = None
|
||||
self.tertiary_tag = None
|
||||
self.tag_mode = context.combine_mode
|
||||
self.prefer_owned = False
|
||||
self.owned_card_names: set[str] = set()
|
||||
self.bracket_limits: Dict[str, Any] = {}
|
||||
self.output_log: List[str] = []
|
||||
self.output_func = self.output_log.append
|
||||
self._rng = DummyRNG()
|
||||
self._theme_context = context
|
||||
self.added_cards: List[str] = []
|
||||
|
||||
def _get_rng(self) -> DummyRNG:
|
||||
return self._rng
|
||||
|
||||
@property
|
||||
def rng(self) -> DummyRNG:
|
||||
return self._rng
|
||||
|
||||
def get_theme_context(self) -> ThemeContext: # type: ignore[override]
|
||||
return self._theme_context
|
||||
|
||||
def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override]
|
||||
self.card_library[name] = {"Count": kwargs.get("count", 1)}
|
||||
self.added_cards.append(name)
|
||||
|
||||
|
||||
def make_context(user_theme_weight: float) -> ThemeContext:
|
||||
user = ThemeTarget(
|
||||
role="user_1",
|
||||
display="Angels",
|
||||
slug="angels",
|
||||
source="user",
|
||||
weight=1.0,
|
||||
)
|
||||
return ThemeContext(
|
||||
ordered_targets=[user],
|
||||
combine_mode="AND",
|
||||
weights={"user_1": 1.0},
|
||||
commander_slugs=[],
|
||||
user_slugs=["angels"],
|
||||
resolution=None,
|
||||
user_theme_weight=user_theme_weight,
|
||||
)
|
||||
|
||||
|
||||
def build_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"name": "Angel Song",
|
||||
"type": "Instant",
|
||||
"themeTags": ["Angels"],
|
||||
"manaValue": 2,
|
||||
"edhrecRank": 1400,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_user_theme_bonus_increases_weight(monkeypatch) -> None:
|
||||
captured: List[List[tuple[str, float]]] = []
|
||||
|
||||
def fake_weighted(pool: List[tuple[str, float]], k: int, rng=None) -> List[str]:
|
||||
captured.append(list(pool))
|
||||
ranked = sorted(pool, key=lambda item: item[1], reverse=True)
|
||||
return [name for name, _ in ranked[:k]]
|
||||
|
||||
monkeypatch.setattr(bu, "weighted_sample_without_replacement", fake_weighted)
|
||||
|
||||
def run(user_weight: float) -> Dict[str, float]:
|
||||
start = len(captured)
|
||||
context = make_context(user_weight)
|
||||
builder = DummySpellBuilder(build_dataframe(), context)
|
||||
builder.fill_remaining_theme_spells()
|
||||
assert start < len(captured) # ensure we captured weights
|
||||
pool = captured[start]
|
||||
return dict(pool)
|
||||
|
||||
weights_no_bonus = run(1.0)
|
||||
weights_bonus = run(1.5)
|
||||
|
||||
assert "Angel Song" in weights_no_bonus
|
||||
assert "Angel Song" in weights_bonus
|
||||
assert weights_bonus["Angel Song"] > weights_no_bonus["Angel Song"]
|
||||
61
code/tests/test_theme_summary_telemetry.py
Normal file
61
code/tests/test_theme_summary_telemetry.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from deck_builder.summary_telemetry import (
|
||||
_reset_metrics_for_test,
|
||||
get_theme_metrics,
|
||||
record_theme_summary,
|
||||
)
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def teardown_function() -> None:
|
||||
_reset_metrics_for_test()
|
||||
|
||||
|
||||
def test_record_theme_summary_tracks_user_themes() -> None:
|
||||
payload = {
|
||||
"commanderThemes": ["Lifegain"],
|
||||
"userThemes": ["Angels", "Life Gain"],
|
||||
"requested": ["Angels"],
|
||||
"resolved": ["angels"],
|
||||
"unresolved": [],
|
||||
"mode": "AND",
|
||||
"weight": 1.3,
|
||||
"themeCatalogVersion": "test-cat",
|
||||
}
|
||||
record_theme_summary(payload)
|
||||
metrics = get_theme_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["with_user_themes"] == 1
|
||||
summary = metrics["last_summary"]
|
||||
assert summary is not None
|
||||
assert summary["commanderThemes"] == ["Lifegain"]
|
||||
assert summary["userThemes"] == ["Angels", "Life Gain"]
|
||||
assert summary["mergedThemes"] == ["Lifegain", "Angels", "Life Gain"]
|
||||
assert summary["unresolvedCount"] == 0
|
||||
assert metrics["top_user_themes"][0]["theme"] in {"Angels", "Life Gain"}
|
||||
|
||||
|
||||
def test_record_theme_summary_without_user_themes() -> None:
|
||||
payload = {
|
||||
"commanderThemes": ["Artifacts"],
|
||||
"userThemes": [],
|
||||
"requested": [],
|
||||
"resolved": [],
|
||||
"unresolved": [],
|
||||
"mode": "AND",
|
||||
"weight": 1.0,
|
||||
}
|
||||
record_theme_summary(payload)
|
||||
metrics = get_theme_metrics()
|
||||
assert metrics["total_builds"] == 1
|
||||
assert metrics["with_user_themes"] == 0
|
||||
summary = metrics["last_summary"]
|
||||
assert summary is not None
|
||||
assert summary["commanderThemes"] == ["Artifacts"]
|
||||
assert summary["userThemes"] == []
|
||||
assert summary["mergedThemes"] == ["Artifacts"]
|
||||
assert summary["unresolvedCount"] == 0
|
||||
|
|
@ -16,7 +16,7 @@ from starlette.middleware.gzip import GZipMiddleware
|
|||
from typing import Any, Optional, Dict, Iterable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_theme_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
|
|
@ -112,6 +112,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True)
|
|||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
|
||||
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
|
||||
|
|
@ -130,6 +131,10 @@ RATE_LIMIT_BUILD = _as_int(os.getenv("RANDOM_RATE_LIMIT_BUILD"), 10)
|
|||
RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
|
||||
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
|
||||
RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350)
|
||||
USER_THEME_LIMIT = _as_int(os.getenv("USER_THEME_LIMIT"), 8)
|
||||
|
||||
_THEME_MODE_ENV = (os.getenv("THEME_MATCH_MODE") or "").strip().lower()
|
||||
DEFAULT_THEME_MATCH_MODE = "strict" if _THEME_MODE_ENV in {"strict", "s"} else "permissive"
|
||||
|
||||
# Simple theme input validation constraints
|
||||
_THEME_MAX_LEN = 60
|
||||
|
|
@ -240,6 +245,7 @@ templates.env.globals.update({
|
|||
"enable_themes": ENABLE_THEMES,
|
||||
"enable_pwa": ENABLE_PWA,
|
||||
"enable_presets": ENABLE_PRESETS,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
"random_modes": RANDOM_MODES,
|
||||
|
|
@ -248,6 +254,8 @@ templates.env.globals.update({
|
|||
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
||||
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
||||
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
||||
"user_theme_limit": USER_THEME_LIMIT,
|
||||
"default_theme_match_mode": DEFAULT_THEME_MATCH_MODE,
|
||||
})
|
||||
|
||||
# Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev'
|
||||
|
|
@ -823,10 +831,13 @@ async def status_sys():
|
|||
"SHOW_COMMANDERS": bool(SHOW_COMMANDERS),
|
||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
||||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||
"ENABLE_CUSTOM_THEMES": bool(ENABLE_CUSTOM_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
|
||||
"USER_THEME_LIMIT": int(USER_THEME_LIMIT),
|
||||
"RANDOM_MODES": bool(RANDOM_MODES),
|
||||
"RANDOM_UI": bool(RANDOM_UI),
|
||||
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
|
||||
|
|
@ -834,6 +845,7 @@ async def status_sys():
|
|||
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
|
||||
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
|
||||
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
|
||||
"RATE_LIMIT_ENABLED": bool(RATE_LIMIT_ENABLED),
|
||||
"RATE_LIMIT_WINDOW_S": int(RATE_LIMIT_WINDOW_S),
|
||||
"RANDOM_RATE_LIMIT_RANDOM": int(RATE_LIMIT_RANDOM),
|
||||
"RANDOM_RATE_LIMIT_BUILD": int(RATE_LIMIT_BUILD),
|
||||
|
|
@ -887,6 +899,17 @@ async def status_dfc_metrics():
|
|||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/status/theme_metrics")
|
||||
async def status_theme_metrics():
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
try:
|
||||
return JSONResponse({"ok": True, "metrics": get_theme_metrics()})
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logging.getLogger("web").warning("Failed to fetch theme metrics: %s", exc, exc_info=True)
|
||||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
|
|
@ -2366,13 +2389,17 @@ async def trigger_error(kind: str = Query("http")):
|
|||
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse(
|
||||
"diagnostics/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"merge_summary": load_merge_summary(),
|
||||
},
|
||||
)
|
||||
# Build a sanitized context and pre-render to surface template errors clearly
|
||||
try:
|
||||
summary = load_merge_summary() or {"updated_at": None, "colors": {}}
|
||||
if not isinstance(summary, dict):
|
||||
summary = {"updated_at": None, "colors": {}}
|
||||
if not isinstance(summary.get("colors"), dict):
|
||||
summary["colors"] = {}
|
||||
except Exception:
|
||||
summary = {"updated_at": None, "colors": {}}
|
||||
ctx = {"request": request, "merge_summary": summary}
|
||||
return templates.TemplateResponse("diagnostics/index.html", ctx)
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ from __future__ import annotations
|
|||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from typing import Any
|
||||
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
||||
from ..app import (
|
||||
ALLOW_MUST_HAVES,
|
||||
ENABLE_CUSTOM_THEMES,
|
||||
USER_THEME_LIMIT,
|
||||
DEFAULT_THEME_MATCH_MODE,
|
||||
_sanitize_theme,
|
||||
)
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
|
|
@ -23,6 +29,7 @@ from html import escape as _esc
|
|||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_utils as bu
|
||||
from ..services.combo_utils import detect_all as _detect_all
|
||||
from ..services import custom_theme_manager as theme_mgr
|
||||
from path_util import csv_dir as _csv_dir
|
||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
from ..services.telemetry import log_commander_create_deck
|
||||
|
|
@ -114,6 +121,41 @@ router = APIRouter(prefix="/build")
|
|||
# Alternatives cache moved to services/alts_utils
|
||||
|
||||
|
||||
def _custom_theme_context(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
*,
|
||||
message: str | None = None,
|
||||
level: str = "info",
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the Additional Themes section context for the modal."""
|
||||
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return {
|
||||
"request": request,
|
||||
"theme_state": None,
|
||||
"theme_message": message,
|
||||
"theme_message_level": level,
|
||||
"theme_limit": USER_THEME_LIMIT,
|
||||
"enable_custom_themes": False,
|
||||
}
|
||||
theme_mgr.set_limit(sess, USER_THEME_LIMIT)
|
||||
state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE)
|
||||
return {
|
||||
"request": request,
|
||||
"theme_state": state,
|
||||
"theme_message": message,
|
||||
"theme_message_level": level,
|
||||
"theme_limit": USER_THEME_LIMIT,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
}
|
||||
|
||||
|
||||
_INVALID_THEME_MESSAGE = (
|
||||
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
||||
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
||||
|
||||
|
|
@ -418,12 +460,14 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"""Return the New Deck modal content (for an overlay)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
theme_context = _custom_theme_context(request, sess)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_count": sess.get("combo_target_count"),
|
||||
|
|
@ -434,6 +478,10 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
}
|
||||
for key, value in theme_context.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -575,6 +623,91 @@ async def build_new_multicopy(
|
|||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.post("/themes/add", response_class=HTMLResponse)
|
||||
async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
trimmed = theme.strip()
|
||||
sanitized = _sanitize_theme(trimmed) if trimmed else ""
|
||||
if trimmed and not sanitized:
|
||||
ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error")
|
||||
else:
|
||||
value = sanitized if sanitized is not None else trimmed
|
||||
_, message, level = theme_mgr.add_theme(
|
||||
sess,
|
||||
value,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
limit=USER_THEME_LIMIT,
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/remove", response_class=HTMLResponse)
|
||||
async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
value = _sanitize_theme(theme) or theme
|
||||
_, message, level = theme_mgr.remove_theme(
|
||||
sess,
|
||||
value,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/choose", response_class=HTMLResponse)
|
||||
async def build_theme_choose(
|
||||
request: Request,
|
||||
original: str = Form(""),
|
||||
choice: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
selection = _sanitize_theme(choice) or choice
|
||||
_, message, level = theme_mgr.choose_suggestion(
|
||||
sess,
|
||||
original,
|
||||
selection,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/themes/mode", response_class=HTMLResponse)
|
||||
async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse:
|
||||
if not ENABLE_CUSTOM_THEMES:
|
||||
return HTMLResponse("", status_code=204)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
_, message, level = theme_mgr.set_mode(
|
||||
sess,
|
||||
mode,
|
||||
commander_tags=list(sess.get("tags", [])),
|
||||
)
|
||||
ctx = _custom_theme_context(request, sess, message=message, level=level)
|
||||
resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/new", response_class=HTMLResponse)
|
||||
async def build_new_submit(
|
||||
request: Request,
|
||||
|
|
@ -660,8 +793,14 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(suggested),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -676,8 +815,14 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(commander),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -694,8 +839,34 @@ async def build_new_submit(
|
|||
bracket = 3
|
||||
# Save to session
|
||||
sess["commander"] = sel.get("name") or commander
|
||||
# 1) Start from explicitly selected tags (order preserved)
|
||||
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
|
||||
# If commander has a tag list and primary missing, set first recommended as default
|
||||
user_explicit = bool(tags) # whether the user set any theme in the form
|
||||
# 2) Consider user-added supplemental themes from the Additional Themes UI
|
||||
additional_from_session = []
|
||||
try:
|
||||
# custom_theme_manager stores resolved list here on add/resolve; present before submit
|
||||
additional_from_session = [
|
||||
str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip()
|
||||
]
|
||||
except Exception:
|
||||
additional_from_session = []
|
||||
# 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary
|
||||
if not user_explicit and additional_from_session:
|
||||
# Cap to three and preserve order
|
||||
tags = list(additional_from_session[:3])
|
||||
# 4) If user selected some themes, fill remaining slots with additional themes (deduping)
|
||||
elif user_explicit and additional_from_session:
|
||||
seen = {str(t).strip().casefold() for t in tags}
|
||||
for name in additional_from_session:
|
||||
key = name.strip().casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
tags.append(name)
|
||||
seen.add(key)
|
||||
if len(tags) >= 3:
|
||||
break
|
||||
# 5) If still empty (no explicit and no additional), fall back to commander-recommended default
|
||||
if not tags:
|
||||
try:
|
||||
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
|
||||
|
|
@ -731,6 +902,33 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
sess["ideals"] = ideals
|
||||
if ENABLE_CUSTOM_THEMES:
|
||||
try:
|
||||
theme_mgr.refresh_resolution(
|
||||
sess,
|
||||
commander_tags=tags,
|
||||
mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE),
|
||||
)
|
||||
except ValueError as exc:
|
||||
error_msg = str(exc)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"error": error_msg,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||
"form": _form_state(sess.get("commander", "")),
|
||||
}
|
||||
theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error")
|
||||
for key, value in theme_ctx.items():
|
||||
if key == "request":
|
||||
continue
|
||||
ctx[key] = value
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
# Persist preferences
|
||||
try:
|
||||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
|
|
|
|||
229
code/web/services/custom_theme_manager.py
Normal file
229
code/web/services/custom_theme_manager.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Session helpers for managing supplemental user themes in the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from deck_builder.theme_resolution import (
|
||||
ThemeResolutionInfo,
|
||||
clean_theme_inputs,
|
||||
normalize_theme_match_mode,
|
||||
resolve_additional_theme_inputs,
|
||||
)
|
||||
|
||||
DEFAULT_THEME_LIMIT = 8
|
||||
ADDITION_COOLDOWN_SECONDS = 0.75
|
||||
|
||||
_INPUTS_KEY = "custom_theme_inputs"
|
||||
_RESOLUTION_KEY = "user_theme_resolution"
|
||||
_MODE_KEY = "theme_match_mode"
|
||||
_LAST_ADD_KEY = "custom_theme_last_add_ts"
|
||||
_CATALOG_VERSION_KEY = "theme_catalog_version"
|
||||
|
||||
|
||||
def _sanitize_single(value: str | None) -> str | None:
|
||||
for item in clean_theme_inputs([value] if value is not None else []):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _store_inputs(sess: Dict[str, Any], inputs: List[str]) -> None:
|
||||
sess[_INPUTS_KEY] = list(inputs)
|
||||
|
||||
|
||||
def _current_inputs(sess: Dict[str, Any]) -> List[str]:
|
||||
values = sess.get(_INPUTS_KEY)
|
||||
if isinstance(values, list):
|
||||
return [str(v) for v in values if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _store_resolution(sess: Dict[str, Any], info: ThemeResolutionInfo) -> None:
|
||||
info_dict = asdict(info)
|
||||
sess[_RESOLUTION_KEY] = info_dict
|
||||
sess[_CATALOG_VERSION_KEY] = info.catalog_version
|
||||
sess[_MODE_KEY] = info.mode
|
||||
sess["additional_themes"] = list(info.resolved)
|
||||
|
||||
|
||||
def _default_resolution(mode: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"requested": [],
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"catalog_version": "unknown",
|
||||
"resolved": [],
|
||||
"matches": [],
|
||||
"unresolved": [],
|
||||
"fuzzy_corrections": {},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_and_store(
|
||||
sess: Dict[str, Any],
|
||||
inputs: List[str],
|
||||
mode: str,
|
||||
commander_tags: Iterable[str],
|
||||
) -> ThemeResolutionInfo:
|
||||
info = resolve_additional_theme_inputs(inputs, mode, commander_tags=commander_tags)
|
||||
_store_inputs(sess, inputs)
|
||||
_store_resolution(sess, info)
|
||||
return info
|
||||
|
||||
|
||||
def get_view_state(sess: Dict[str, Any], *, default_mode: str) -> Dict[str, Any]:
|
||||
inputs = _current_inputs(sess)
|
||||
mode = sess.get(_MODE_KEY, default_mode)
|
||||
resolution = sess.get(_RESOLUTION_KEY)
|
||||
if not isinstance(resolution, dict):
|
||||
resolution = _default_resolution(mode)
|
||||
remaining = max(0, int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)) - len(inputs))
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"mode": normalize_theme_match_mode(mode),
|
||||
"resolution": resolution,
|
||||
"limit": int(sess.get("custom_theme_limit", DEFAULT_THEME_LIMIT)),
|
||||
"remaining": remaining,
|
||||
}
|
||||
|
||||
|
||||
def set_limit(sess: Dict[str, Any], limit: int) -> None:
|
||||
sess["custom_theme_limit"] = max(1, int(limit))
|
||||
|
||||
|
||||
def add_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
limit: int = DEFAULT_THEME_LIMIT,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
sanitized = _sanitize_single(value)
|
||||
if not sanitized:
|
||||
return None, "Enter a theme to add.", "error"
|
||||
lower_inputs = {item.casefold() for item in inputs}
|
||||
if sanitized.casefold() in lower_inputs:
|
||||
return None, "That theme is already listed.", "info"
|
||||
if len(inputs) >= limit:
|
||||
return None, f"You can only add up to {limit} themes.", "warning"
|
||||
last_ts = float(sess.get(_LAST_ADD_KEY, 0.0) or 0.0)
|
||||
now = time.time()
|
||||
if now - last_ts < ADDITION_COOLDOWN_SECONDS:
|
||||
return None, "Please wait a moment before adding another theme.", "warning"
|
||||
proposed = inputs + [sanitized]
|
||||
try:
|
||||
info = _resolve_and_store(sess, proposed, normalized_mode, commander_tags)
|
||||
sess[_LAST_ADD_KEY] = now
|
||||
return info, f"Added theme '{sanitized}'.", "success"
|
||||
except ValueError as exc:
|
||||
# Revert when strict mode rejects unresolved entries.
|
||||
_resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def remove_theme(
|
||||
sess: Dict[str, Any],
|
||||
value: str | None,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
if not inputs:
|
||||
return None, "No themes to remove.", "info"
|
||||
key = (value or "").strip().casefold()
|
||||
if not key:
|
||||
return None, "Select a theme to remove.", "error"
|
||||
filtered = [item for item in inputs if item.casefold() != key]
|
||||
if len(filtered) == len(inputs):
|
||||
return None, "Theme not found in your list.", "warning"
|
||||
info = _resolve_and_store(sess, filtered, normalized_mode, commander_tags)
|
||||
return info, "Theme removed.", "success"
|
||||
|
||||
|
||||
def choose_suggestion(
|
||||
sess: Dict[str, Any],
|
||||
original: str,
|
||||
selection: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None,
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
normalized_mode = normalize_theme_match_mode(mode)
|
||||
inputs = _current_inputs(sess)
|
||||
orig_key = (original or "").strip().casefold()
|
||||
if not orig_key:
|
||||
return None, "Original theme missing.", "error"
|
||||
sanitized = _sanitize_single(selection)
|
||||
if not sanitized:
|
||||
return None, "Select a suggestion to apply.", "error"
|
||||
try:
|
||||
index = next(i for i, item in enumerate(inputs) if item.casefold() == orig_key)
|
||||
except StopIteration:
|
||||
return None, "Original theme not found.", "warning"
|
||||
replacement_key = sanitized.casefold()
|
||||
if replacement_key in {item.casefold() for i, item in enumerate(inputs) if i != index}:
|
||||
# Duplicate suggestion: simply drop the original.
|
||||
updated = [item for i, item in enumerate(inputs) if i != index]
|
||||
message = f"Removed duplicate theme '{original}'."
|
||||
else:
|
||||
updated = list(inputs)
|
||||
updated[index] = sanitized
|
||||
message = f"Updated '{original}' to '{sanitized}'."
|
||||
info = _resolve_and_store(sess, updated, normalized_mode, commander_tags)
|
||||
return info, message, "success"
|
||||
|
||||
|
||||
def set_mode(
|
||||
sess: Dict[str, Any],
|
||||
mode: str,
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
) -> Tuple[ThemeResolutionInfo | None, str, str]:
|
||||
new_mode = normalize_theme_match_mode(mode)
|
||||
current_inputs = _current_inputs(sess)
|
||||
previous_mode = sess.get(_MODE_KEY)
|
||||
try:
|
||||
info = _resolve_and_store(sess, current_inputs, new_mode, commander_tags)
|
||||
return info, f"Theme matching set to {new_mode} mode.", "success"
|
||||
except ValueError as exc:
|
||||
if previous_mode is not None:
|
||||
sess[_MODE_KEY] = previous_mode
|
||||
return None, str(exc), "error"
|
||||
|
||||
|
||||
def clear_all(sess: Dict[str, Any]) -> None:
|
||||
for key in (_INPUTS_KEY, _RESOLUTION_KEY, "additional_themes", _LAST_ADD_KEY):
|
||||
if key in sess:
|
||||
del sess[key]
|
||||
|
||||
|
||||
def refresh_resolution(
|
||||
sess: Dict[str, Any],
|
||||
*,
|
||||
commander_tags: Iterable[str],
|
||||
mode: str | None = None,
|
||||
) -> ThemeResolutionInfo | None:
|
||||
inputs = _current_inputs(sess)
|
||||
normalized_mode = normalize_theme_match_mode(mode or sess.get(_MODE_KEY))
|
||||
if not inputs:
|
||||
empty = ThemeResolutionInfo(
|
||||
requested=[],
|
||||
mode=normalized_mode,
|
||||
catalog_version=sess.get(_CATALOG_VERSION_KEY, "unknown"),
|
||||
resolved=[],
|
||||
matches=[],
|
||||
unresolved=[],
|
||||
fuzzy_corrections={},
|
||||
)
|
||||
_store_inputs(sess, [])
|
||||
_store_resolution(sess, empty)
|
||||
return empty
|
||||
info = _resolve_and_store(sess, inputs, normalized_mode, commander_tags)
|
||||
return info
|
||||
|
||||
|
|
@ -1004,6 +1004,19 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
args.append('--force')
|
||||
_run(args, check=True)
|
||||
_emit("Theme catalog (JSON + YAML) refreshed{}.".format(" (fast path)" if fast_path else ""))
|
||||
# Always attempt to generate supplemental CSV catalog from card CSVs for downstream features
|
||||
try:
|
||||
gen_script = os.path.join(script_base, 'generate_theme_catalog.py')
|
||||
if os.path.exists(gen_script):
|
||||
csv_dir = os.getenv('CSV_FILES_DIR') or 'csv_files'
|
||||
output_csv = os.path.join('config', 'themes', 'theme_catalog.csv')
|
||||
gen_args = [_sys.executable, gen_script, '--csv-dir', csv_dir, '--output', output_csv]
|
||||
_run(gen_args, check=True)
|
||||
_emit("Supplemental CSV theme catalog generated (theme_catalog.csv).")
|
||||
else:
|
||||
_emit("generate_theme_catalog.py not found; skipping CSV catalog generation.")
|
||||
except Exception as gerr:
|
||||
_emit(f"CSV theme catalog generation failed: {gerr}")
|
||||
# Mark progress complete
|
||||
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
|
||||
# Append status file enrichment with last export metrics
|
||||
|
|
@ -1869,7 +1882,7 @@ def start_build_ctx(
|
|||
if row.empty:
|
||||
raise ValueError(f"Commander not found: {commander}")
|
||||
b._apply_commander_selection(row.iloc[0])
|
||||
# Tags
|
||||
# Tags (explicit + supplemental applied upstream)
|
||||
b.selected_tags = list(tags or [])
|
||||
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
||||
b.secondary_tag = b.selected_tags[1] if len(b.selected_tags) > 1 else None
|
||||
|
|
|
|||
136
code/web/templates/build/_new_deck_additional_themes.html
Normal file
136
code/web/templates/build/_new_deck_additional_themes.html
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{% set state = theme_state or {} %}
|
||||
{% set resolution = state.get('resolution', {}) %}
|
||||
{% set matches = resolution.get('matches', []) or [] %}
|
||||
{% set unresolved = resolution.get('unresolved', []) or [] %}
|
||||
{% set resolved_labels = resolution.get('resolved', []) or [] %}
|
||||
{% set limit = state.get('limit', 8) %}
|
||||
{% set remaining = state.get('remaining', limit) %}
|
||||
{% set disable_add = remaining <= 0 %}
|
||||
|
||||
<fieldset id="custom-theme-root" style="margin-top:1rem; border:1px solid var(--border); border-radius:8px; padding:0.75rem;" hx-on::afterSwap="const field=this.querySelector('[data-theme-input]'); if(field){field.value=''; field.focus();}">
|
||||
<legend style="font-weight:600;">Additional Themes</legend>
|
||||
<p class="muted" style="margin:0 0 .5rem 0; font-size:12px;">
|
||||
Add up to {{ limit }} supplemental themes to guide the build.
|
||||
<span{% if disable_add %} style="color:#fca5a5;"{% endif %}> {{ remaining }} slot{% if remaining != 1 %}s{% endif %} remaining.</span>
|
||||
</p>
|
||||
<div id="custom-theme-live" role="status" aria-live="polite" class="sr-only">{{ theme_message or '' }}</div>
|
||||
{% if theme_message %}
|
||||
{% if theme_message_level == 'success' %}
|
||||
<div class="theme-alert" data-level="success" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.4); color:#bbf7d0;">{{ theme_message }}</div>
|
||||
{% elif theme_message_level == 'warning' %}
|
||||
<div class="theme-alert" data-level="warning" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(250,204,21,0.15); border:1px solid rgba(250,204,21,0.45); color:#facc15;">{{ theme_message }}</div>
|
||||
{% elif theme_message_level == 'error' %}
|
||||
<div class="theme-alert" data-level="error" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5;">{{ theme_message }}</div>
|
||||
{% else %}
|
||||
<div class="theme-alert" data-level="info" style="margin-bottom:.5rem; padding:.5rem; border-radius:6px; font-size:12px; background:rgba(59,130,246,0.1); border:1px solid rgba(59,130,246,0.35); color:#cbd5f5;">{{ theme_message }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div data-theme-add-container
|
||||
hx-post="/build/themes/add"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="click from:button[data-theme-add-btn]"
|
||||
hx-include="[data-theme-input]"
|
||||
style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label style="flex:1; min-width:220px;">
|
||||
<span class="sr-only">Theme name</span>
|
||||
<input type="text" name="theme" data-theme-input placeholder="e.g., Lifegain" maxlength="60" autocomplete="off" autocapitalize="off" spellcheck="false" style="width:100%; padding:.5rem; border-radius:6px; border:1px solid var(--border); background:var(--input-bg, #161921); color:var(--text-color, #f9fafb);" {% if disable_add %}disabled aria-disabled="true"{% endif %} />
|
||||
</label>
|
||||
<button type="button" data-theme-add-btn class="btn" style="padding:.45rem 1rem;" {% if disable_add %}disabled aria-disabled="true"{% endif %}>Add theme</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:.75rem; display:flex; gap:1rem; flex-wrap:wrap; font-size:12px; align-items:center;">
|
||||
<span class="muted">Matching mode:</span>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem;">
|
||||
<input type="radio" name="mode" value="permissive" {% if state.get('mode') == 'permissive' %}checked{% endif %}
|
||||
hx-trigger="change"
|
||||
hx-post="/build/themes/mode"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"mode":"permissive"}'
|
||||
/> Permissive
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem;">
|
||||
<input type="radio" name="mode" value="strict" {% if state.get('mode') == 'strict' %}checked{% endif %}
|
||||
hx-trigger="change"
|
||||
hx-post="/build/themes/mode"
|
||||
hx-target="#custom-theme-root"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"mode":"strict"}'
|
||||
/> Strict
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem; margin-top:1rem;">
|
||||
<div>
|
||||
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #9ca3af);">Resolved</h4>
|
||||
{% if resolved_labels %}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
|
||||
{% for match in matches %}
|
||||
{% set matched = match.matched if match.matched is defined else match['matched'] %}
|
||||
{% set original = match.input if match.input is defined else match['input'] %}
|
||||
{% set score_val = match.score if match.score is defined else match['score'] %}
|
||||
{% set score_pct = score_val if score_val is not none else None %}
|
||||
{% set reason_code = match.reason if match.reason is defined else match['reason'] %}
|
||||
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;" title="{{ matched }}{% if score_pct is not none %} · {{ '%.0f'|format(score_pct) }}% confidence{% endif %}{% if reason_code %} ({{ reason_code|replace('_',' ')|title }}){% endif %}">
|
||||
<span>{{ matched }}</span>
|
||||
{% if original and original.casefold() != matched.casefold() %}
|
||||
<span class="muted" style="font-size:11px;">(from “{{ original }}”)</span>
|
||||
{% endif %}
|
||||
<button type="button" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': original}|tojson }}" title="Remove theme" style="background:none; border:none; color:inherit; font-weight:bold; cursor:pointer;">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not matches and resolved_labels %}
|
||||
{% for label in resolved_labels %}
|
||||
<div class="theme-chip" style="display:inline-flex; align-items:center; gap:.35rem; padding:.35rem .6rem; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.35); border-radius:999px; font-size:12px;">
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">No supplemental themes yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="font-size:12px; text-transform:uppercase; letter-spacing:.05em; margin:0 0 .5rem 0; color:var(--text-muted, #fbbf24);">Needs attention</h4>
|
||||
{% if unresolved %}
|
||||
<div style="display:flex; flex-direction:column; gap:.5rem;">
|
||||
{% for item in unresolved %}
|
||||
<div style="border:1px solid rgba(234,179,8,0.4); background:rgba(250,204,21,0.08); border-radius:8px; padding:.5rem; font-size:12px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<strong>{{ item.input }}</strong>
|
||||
<button type="button" class="btn" hx-post="/build/themes/remove" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'theme': item.input}|tojson }}" style="padding:.25rem .6rem; font-size:11px; background:#7f1d1d; border-color:#dc2626;">Remove</button>
|
||||
</div>
|
||||
{% if item.reason %}
|
||||
<div class="muted" style="margin-top:.35rem; font-size:11px;">Reason: {{ item.reason|replace('_',' ')|title }}</div>
|
||||
{% endif %}
|
||||
{% if item.suggestions %}
|
||||
<div style="margin-top:.5rem; display:flex; flex-wrap:wrap; gap:.35rem;">
|
||||
{% for suggestion in item.suggestions[:3] %}
|
||||
{% set suggestion_theme = suggestion.theme if suggestion.theme is defined else suggestion.get('theme') %}
|
||||
{% set suggestion_score = suggestion.score if suggestion.score is defined else suggestion.get('score') %}
|
||||
{% if suggestion_theme %}
|
||||
<button type="button" class="btn" hx-post="/build/themes/choose" hx-target="#custom-theme-root" hx-swap="outerHTML" hx-vals="{{ {'original': item.input, 'choice': suggestion_theme}|tojson }}" style="padding:.25rem .5rem; font-size:11px; background:#1d4ed8; border-color:#2563eb;">
|
||||
Use {{ suggestion_theme }}{% if suggestion_score is not none %} ({{ '%.0f'|format(suggestion_score) }}%){% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-top:.35rem; font-size:11px;">No close matches found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px;">All themes resolved.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.75rem; font-size:11px;">
|
||||
Catalog version: {{ resolution.get('catalog_version', 'unknown') }} · Mode: {{ state.get('mode', 'permissive')|title }}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -40,6 +40,9 @@
|
|||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
{% if enable_custom_themes %}
|
||||
{% include "build/_new_deck_additional_themes.html" %}
|
||||
{% endif %}
|
||||
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
<div id="envFlags" style="margin-top:.5rem"></div>
|
||||
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
|
||||
<div id="themeSummary" style="margin-top:.5rem"></div>
|
||||
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
||||
<div style="margin-top:.35rem">
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
|
||||
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
|
||||
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
|
||||
{% if colors %}
|
||||
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
||||
<div style="overflow-x:auto">
|
||||
|
|
@ -30,28 +32,29 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for color, payload in colors.items()|dictsort %}
|
||||
{% for item in colors|dictsort %}
|
||||
{% set color = item[0] %}
|
||||
{% set payload = item[1] | default({}) %}
|
||||
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
|
||||
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">
|
||||
{% set entries = payload.entries or [] %}
|
||||
{% set entries = (payload.entries | default([])) %}
|
||||
{% if entries %}
|
||||
<details>
|
||||
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
||||
{% set preview = entries[:5] %}
|
||||
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
||||
{% for entry in entries %}
|
||||
{% if loop.index0 < 5 %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% elif loop.index0 == 5 %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
|
||||
{% break %}
|
||||
{% endif %}
|
||||
{% for entry in preview %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if entries|length > preview|length %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</details>
|
||||
{% else %}
|
||||
|
|
@ -134,6 +137,125 @@
|
|||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
// Environment flags card
|
||||
(function(){
|
||||
var target = document.getElementById('envFlags');
|
||||
if (!target) return;
|
||||
function renderEnv(data){
|
||||
if (!data || !data.flags) { target.textContent = 'Flags unavailable'; return; }
|
||||
var f = data.flags;
|
||||
function as01(v){ return (v ? '1' : '0'); }
|
||||
var lines = [];
|
||||
lines.push('<div><strong>Homepage & UI:</strong> '
|
||||
+ 'SHOW_SETUP=' + as01(f.SHOW_SETUP)
|
||||
+ ', SHOW_LOGS=' + as01(f.SHOW_LOGS)
|
||||
+ ', SHOW_DIAGNOSTICS=' + as01(f.SHOW_DIAGNOSTICS)
|
||||
+ ', SHOW_COMMANDERS=' + as01(f.SHOW_COMMANDERS)
|
||||
+ ', ENABLE_THEMES=' + as01(f.ENABLE_THEMES)
|
||||
+ ', ENABLE_CUSTOM_THEMES=' + as01(f.ENABLE_CUSTOM_THEMES)
|
||||
+ ', ALLOW_MUST_HAVES=' + as01(f.ALLOW_MUST_HAVES)
|
||||
+ ', THEME=' + String(f.DEFAULT_THEME || '')
|
||||
+ ', THEME_MATCH_MODE=' + String(f.THEME_MATCH_MODE || '')
|
||||
+ ', USER_THEME_LIMIT=' + String(f.USER_THEME_LIMIT || '')
|
||||
+ '</div>');
|
||||
lines.push('<div><strong>Random:</strong> '
|
||||
+ 'RANDOM_MODES=' + as01(f.RANDOM_MODES)
|
||||
+ ', RANDOM_UI=' + as01(f.RANDOM_UI)
|
||||
+ ', RANDOM_MAX_ATTEMPTS=' + String(f.RANDOM_MAX_ATTEMPTS || '')
|
||||
+ ', RANDOM_TIMEOUT_MS=' + String(f.RANDOM_TIMEOUT_MS || '')
|
||||
+ ', RANDOM_REROLL_THROTTLE_MS=' + String(f.RANDOM_REROLL_THROTTLE_MS || '')
|
||||
+ ', RANDOM_TELEMETRY=' + as01(f.RANDOM_TELEMETRY)
|
||||
+ ', RANDOM_STRUCTURED_LOGS=' + as01(f.RANDOM_STRUCTURED_LOGS)
|
||||
+ '</div>');
|
||||
lines.push('<div><strong>Rate limiting (random):</strong> '
|
||||
+ 'RATE_LIMIT_ENABLED=' + as01(f.RATE_LIMIT_ENABLED)
|
||||
+ ', WINDOW_S=' + String(f.RATE_LIMIT_WINDOW_S || '')
|
||||
+ ', RANDOM=' + String(f.RANDOM_RATE_LIMIT_RANDOM || '')
|
||||
+ ', BUILD=' + String(f.RANDOM_RATE_LIMIT_BUILD || '')
|
||||
+ ', SUGGEST=' + String(f.RANDOM_RATE_LIMIT_SUGGEST || '')
|
||||
+ '</div>');
|
||||
target.innerHTML = lines.join('');
|
||||
}
|
||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(renderEnv).catch(function(){ target.textContent='Flags unavailable'; }); } catch(_){ target.textContent='Flags unavailable'; }
|
||||
})();
|
||||
var themeSuppEl = document.getElementById('themeSuppMetrics');
|
||||
function renderThemeSupp(payload){
|
||||
if (!themeSuppEl) return;
|
||||
try {
|
||||
if (!payload || payload.ok !== true) {
|
||||
themeSuppEl.textContent = 'Theme metrics unavailable';
|
||||
return;
|
||||
}
|
||||
var metrics = payload.metrics || {};
|
||||
var total = metrics.total_builds != null ? Number(metrics.total_builds) : 0;
|
||||
if (!total) {
|
||||
themeSuppEl.textContent = 'No deck builds recorded yet.';
|
||||
return;
|
||||
}
|
||||
var withUser = metrics.with_user_themes != null ? Number(metrics.with_user_themes) : 0;
|
||||
var share = metrics.user_theme_share != null ? Number(metrics.user_theme_share) : 0;
|
||||
var sharePct = !Number.isNaN(share) ? (share * 100).toFixed(1) + '%' : '0%';
|
||||
var summary = metrics.last_summary || {};
|
||||
var commander = Array.isArray(summary.commanderThemes) ? summary.commanderThemes : [];
|
||||
var user = Array.isArray(summary.userThemes) ? summary.userThemes : [];
|
||||
var merged = Array.isArray(summary.mergedThemes) ? summary.mergedThemes : [];
|
||||
var unresolvedCount = summary.unresolvedCount != null ? Number(summary.unresolvedCount) : 0;
|
||||
var unresolved = Array.isArray(summary.unresolved) ? summary.unresolved : [];
|
||||
var mode = summary.mode || 'AND';
|
||||
var weight = summary.weight != null ? Number(summary.weight) : 1;
|
||||
var updated = metrics.last_updated || '';
|
||||
var topUser = Array.isArray(metrics.top_user_themes) ? metrics.top_user_themes : [];
|
||||
function joinList(arr){
|
||||
if (!arr || !arr.length) return '—';
|
||||
return arr.join(', ');
|
||||
}
|
||||
var html = '';
|
||||
html += '<div><strong>Total builds:</strong> ' + String(total) + ' (user themes ' + String(withUser) + '\u00a0| ' + sharePct + ')</div>';
|
||||
if (updated) {
|
||||
html += '<div style="font-size:11px;">Last updated: ' + String(updated) + '</div>';
|
||||
}
|
||||
html += '<div><strong>Commander themes:</strong> ' + joinList(commander) + '</div>';
|
||||
html += '<div><strong>User themes:</strong> ' + joinList(user) + '</div>';
|
||||
html += '<div><strong>Merged:</strong> ' + joinList(merged) + '</div>';
|
||||
var unresolvedLabel = '0';
|
||||
if (unresolvedCount > 0) {
|
||||
unresolvedLabel = String(unresolvedCount) + ' (' + joinList(unresolved) + ')';
|
||||
} else {
|
||||
unresolvedLabel = '0';
|
||||
}
|
||||
html += '<div><strong>Unresolved:</strong> ' + unresolvedLabel + '</div>';
|
||||
html += '<div style="font-size:11px;">Mode ' + String(mode) + ' · Weight ' + weight.toFixed(2) + '</div>';
|
||||
if (topUser.length) {
|
||||
var topLine = topUser.slice(0, 5).map(function(item){
|
||||
if (!item) return '';
|
||||
var t = item.theme != null ? String(item.theme) : '';
|
||||
var c = item.count != null ? String(item.count) : '0';
|
||||
return t + ' (' + c + ')';
|
||||
}).filter(Boolean);
|
||||
if (topLine.length) {
|
||||
html += '<div style="font-size:11px; opacity:0.75;">Top user themes: ' + topLine.join(', ') + '</div>';
|
||||
}
|
||||
}
|
||||
themeSuppEl.innerHTML = html;
|
||||
} catch (_){
|
||||
themeSuppEl.textContent = 'Theme metrics unavailable';
|
||||
}
|
||||
}
|
||||
function loadThemeSupp(){
|
||||
if (!themeSuppEl) return;
|
||||
themeSuppEl.textContent = 'Loading theme metrics…';
|
||||
fetch('/status/theme_metrics', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404) {
|
||||
themeSuppEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderThemeSupp(data); })
|
||||
.catch(function(){ themeSuppEl.textContent = 'Theme metrics unavailable'; });
|
||||
}
|
||||
loadThemeSupp();
|
||||
var tokenEl = document.getElementById('themeTokenStats');
|
||||
function renderTokens(payload){
|
||||
if (!tokenEl) return;
|
||||
|
|
|
|||
|
|
@ -23,5 +23,9 @@
|
|||
"exclude_cards": ["Chaos Orb"],
|
||||
"enforcement_mode": "warn",
|
||||
"allow_illegal": false,
|
||||
"fuzzy_matching": true
|
||||
"fuzzy_matching": true,
|
||||
"additional_themes": [],
|
||||
"theme_match_mode": "permissive",
|
||||
"userThemes": [],
|
||||
"themeCatalogVersion": null
|
||||
}
|
||||
|
|
@ -18,9 +18,12 @@ services:
|
|||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
|
||||
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MISC_POOL: "0"
|
||||
|
|
@ -47,6 +50,9 @@ services:
|
|||
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
||||
RANDOM_REROLL_THROTTLE_MS: "350" # minimum ms between reroll requests (client guard)
|
||||
RANDOM_STRUCTURED_LOGS: "0" # 1=emit structured JSON logs for random builds
|
||||
RANDOM_TELEMETRY: "0" # 1=emit lightweight timing/attempt metrics (dev/diagnostics)
|
||||
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
|
||||
|
|
@ -61,8 +67,16 @@ services:
|
|||
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
|
||||
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
|
||||
|
||||
# Random rate limiting (optional; protects from abuse or accidental hammers)
|
||||
RATE_LIMIT_ENABLED: "0" # 1=enable server-side rate limiting for random endpoints
|
||||
RATE_LIMIT_WINDOW_S: "10" # window size in seconds
|
||||
RATE_LIMIT_RANDOM: "10" # max random attempts per window
|
||||
RATE_LIMIT_BUILD: "10" # max builds per window
|
||||
RATE_LIMIT_SUGGEST: "30" # max suggestions per window
|
||||
|
||||
# Theming
|
||||
THEME: "dark" # system|light|dark
|
||||
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Setup / Tagging / Catalog Controls
|
||||
|
|
@ -121,6 +135,8 @@ services:
|
|||
# CSV_FILES_DIR: "/app/csv_files"
|
||||
# Inject a one-off synthetic CSV for index testing without altering shards
|
||||
# CARD_INDEX_EXTRA_CSV: ""
|
||||
# DECK_ADDITIONAL_THEMES: ""
|
||||
# THEME_MATCH_MODE: "permissive"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Headless / Non-interactive Build Configuration
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ services:
|
|||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
||||
SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
|
||||
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
|
||||
SHOW_COMMANDERS: "1" # 1=show Commanders browser/pages
|
||||
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
|
||||
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
|
||||
ENABLE_PRESETS: "0" # 1=show presets section
|
||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
|
|
@ -49,6 +52,9 @@ services:
|
|||
RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
||||
RANDOM_REROLL_THROTTLE_MS: "350" # minimum ms between reroll requests (client guard)
|
||||
RANDOM_STRUCTURED_LOGS: "0" # 1=emit structured JSON logs for random builds
|
||||
RANDOM_TELEMETRY: "0" # 1=emit lightweight timing/attempt metrics (dev/diagnostics)
|
||||
RANDOM_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||
RANDOM_SECONDARY_THEME: "" # optional secondary theme slug override
|
||||
|
|
@ -63,8 +69,16 @@ services:
|
|||
RANDOM_OUTPUT_JSON: "" # path or directory for random build output payload
|
||||
# RANDOM_BUILD_SUPPRESS_INITIAL_EXPORT: "1" # (now defaults to 1 automatically for random builds; set to 0 to force legacy double-export behavior)
|
||||
|
||||
# Random rate limiting (optional; protects from abuse or accidental hammers)
|
||||
RATE_LIMIT_ENABLED: "0" # 1=enable server-side rate limiting for random endpoints
|
||||
RATE_LIMIT_WINDOW_S: "10" # window size in seconds
|
||||
RATE_LIMIT_RANDOM: "10" # max random attempts per window
|
||||
RATE_LIMIT_BUILD: "10" # max builds per window
|
||||
RATE_LIMIT_SUGGEST: "30" # max suggestions per window
|
||||
|
||||
# Theming
|
||||
THEME: "dark" # system|light|dark
|
||||
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Setup / Tagging / Catalog Controls
|
||||
|
|
@ -123,6 +137,8 @@ services:
|
|||
# CSV_FILES_DIR: "/app/csv_files"
|
||||
# Inject a one-off synthetic CSV for index testing without altering shards
|
||||
# CARD_INDEX_EXTRA_CSV: ""
|
||||
# DECK_ADDITIONAL_THEMES: ""
|
||||
# THEME_MATCH_MODE: "permissive"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Headless / Non-interactive Build Configuration
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
Additional details for developers and power users working with the theme catalog, editorial tooling, and diagnostics.
|
||||
|
||||
## Table of contents
|
||||
- [theme_catalog.csv schema](#theme_catalogcsv-schema)
|
||||
- [HTMX API endpoints](#htmx-api-endpoints)
|
||||
- [Caching, diagnostics, and metrics](#caching-diagnostics-and-metrics)
|
||||
- [Governance principles](#governance-principles)
|
||||
|
|
@ -18,7 +19,20 @@ Additional details for developers and power users working with the theme catalog
|
|||
- [Description mapping overrides](#description-mapping-overrides)
|
||||
- [Validation and schema tooling](#validation-and-schema-tooling)
|
||||
|
||||
---
|
||||
|
||||
## theme_catalog.csv schema
|
||||
`theme_catalog.csv` is the normalized artifact consumed by headless builds, supplemental themes, and diagnostics panels. The file starts with a header comment in the format `# theme_catalog version=<hash>` followed by a standard CSV header with these columns:
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
| `theme` | Normalized display label used across the app and JSON exports. |
|
||||
| `commander_count` | Number of commanders tagged with the theme in `commander_cards.csv`. |
|
||||
| `card_count` | Number of non-commander cards carrying the theme tag across primary CSVs. |
|
||||
| `source_count` | Combined count (`commander_count + card_count`) to simplify weighting heuristics. |
|
||||
| `last_generated_at` | ISO-8601 timestamp captured at generation time (UTC). Useful for verifying stale catalogs in diagnostics. |
|
||||
| `version` | Deterministic SHA-256 prefix derived from the ordered theme list; this value flows into exports as `themeCatalogVersion` and `/status/theme_metrics`. |
|
||||
|
||||
Consumers should treat additional columns as experimental. If you add new fields, update this table and the supplemental theme tests that assert schema coverage.
|
||||
|
||||
## HTMX API endpoints
|
||||
The upcoming theme picker UI is powered by two FastAPI endpoints.
|
||||
|
|
|
|||
|
|
@ -99,5 +99,6 @@ Open the Owned tile to manage uploaded inventories:
|
|||
|
||||
## Diagnostics and logs
|
||||
- `SHOW_DIAGNOSTICS=1` unlocks the `/diagnostics` page with system summaries (`/status/sys`), feature flags, and per-request `X-Request-ID` headers.
|
||||
- Supplemental theme telemetry lives at `/status/theme_metrics` (enabled with `ENABLE_CUSTOM_THEMES=1`); the diagnostics page renders commander themes, user-supplied themes, merged totals, and unresolved counts using the `userThemes`/`themeCatalogVersion` metadata exported from builds.
|
||||
- `SHOW_LOGS=1` turns on the `/logs` viewer with level & keyword filters, auto-refresh, and copy-to-clipboard.
|
||||
- Health probes live at `/healthz` and return `{status, version, uptime_seconds}` for integration with uptime monitors.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue