mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection
This commit is contained in:
parent
3a1b011dbc
commit
9428e09cef
39 changed files with 3643 additions and 198 deletions
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_LOGS=1 # dockerhub: SHOW_LOGS="1"
|
||||||
SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1"
|
SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1"
|
||||||
ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="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_PWA=0 # dockerhub: ENABLE_PWA="0"
|
||||||
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
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_UI=1 # Show Surprise/Reroll/Share controls in UI
|
||||||
RANDOM_MAX_ATTEMPTS=5 # Cap retry attempts for constrained random builds
|
RANDOM_MAX_ATTEMPTS=5 # Cap retry attempts for constrained random builds
|
||||||
# RANDOM_TIMEOUT_MS=5000 # Per-attempt timeout (ms)
|
# 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
|
# 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_THEME=Treasure # Legacy single-theme alias (maps to primary theme if others unset)
|
||||||
# RANDOM_PRIMARY_THEME=Treasure # Primary theme slug (case-insensitive)
|
# 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_SEED= # Optional deterministic seed (int or string)
|
||||||
# RANDOM_OUTPUT_JSON=deck_files/random_build.json # Where to write random build metadata payload
|
# 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)
|
# Automation & Performance (Web)
|
||||||
############################
|
############################
|
||||||
|
|
@ -95,6 +107,8 @@ WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0"
|
||||||
# DECK_SECONDARY_TAG=Treasure
|
# DECK_SECONDARY_TAG=Treasure
|
||||||
# DECK_TERTIARY_TAG=Sacrifice
|
# DECK_TERTIARY_TAG=Sacrifice
|
||||||
# DECK_BRACKET_LEVEL=3 # 1–5 Power/Bracket selection.
|
# 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)
|
# 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]
|
## [Unreleased]
|
||||||
### Summary
|
### 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
|
## [2.4.0] - 2025-10-02
|
||||||
### Summary
|
### 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.
|
- Optional multi-pass performance CI variant (`preview_perf_ci_check.py --multi-pass`) to collect cold vs warm pass stats when diagnosing divergence.
|
||||||
|
|
||||||
### Changed
|
### 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 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 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.
|
- 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_UI` | _(unset)_ | Show the Random Build homepage tile. |
|
||||||
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget for constrained random rolls. |
|
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget for constrained random rolls. |
|
||||||
| `RANDOM_TIMEOUT_MS` | `5000` | Per-attempt timeout in milliseconds. |
|
| `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_PRIMARY_THEME` / `RANDOM_SECONDARY_THEME` / `RANDOM_TERTIARY_THEME` | _(blank)_ | Override theme slots for random runs. |
|
||||||
| `RANDOM_SEED` | _(blank)_ | Deterministic seed. |
|
| `RANDOM_SEED` | _(blank)_ | Deterministic seed. |
|
||||||
| `RANDOM_AUTO_FILL` | `1` | Allow automatic backfill of missing theme slots. |
|
| `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. |
|
| `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. |
|
| `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`.
|
Advanced editorial and theme-catalog knobs (`EDITORIAL_*`, `SPLASH_ADAPTIVE`, etc.) are documented inline in `docker-compose.yml` and `.env.example`.
|
||||||
|
|
||||||
## Shared volumes
|
## 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
|
### Build a Deck
|
||||||
Start here for interactive deck creation.
|
Start here for interactive deck creation.
|
||||||
- Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal.
|
- 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.
|
- 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.
|
- 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.
|
- `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).
|
- 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`.
|
- 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.
|
- 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
|
### Initial Setup
|
||||||
Refresh data and caches when formats shift.
|
Refresh data and caches when formats shift.
|
||||||
|
|
@ -124,6 +126,11 @@ Investigate theme synergies and diagnostics.
|
||||||
```powershell
|
```powershell
|
||||||
docker compose run --rm --entrypoint bash web -lc "python -m code.scripts.build_theme_catalog"
|
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.
|
- Advanced editorial knobs (`EDITORIAL_*`, `SPLASH_ADAPTIVE`, etc.) live in `.env.example` and are summarized in the env table below.
|
||||||
|
|
||||||
### Finished Decks
|
### 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_DIAGNOSTICS` | `1` | Unlock diagnostics views and overlays. |
|
||||||
| `SHOW_COMMANDERS` | `1` | Enable the commander browser. |
|
| `SHOW_COMMANDERS` | `1` | Enable the commander browser. |
|
||||||
| `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. |
|
| `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. |
|
| `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. |
|
||||||
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
|
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
|
||||||
| `THEME` | `dark` | Default UI theme (`system`, `light`, or `dark`). |
|
| `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_UI` | _(unset)_ | Show the Random Build homepage tile. |
|
||||||
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget when constraints are tight. |
|
| `RANDOM_MAX_ATTEMPTS` | `5` | Retry budget when constraints are tight. |
|
||||||
| `RANDOM_TIMEOUT_MS` | `5000` | Per-attempt timeout in milliseconds. |
|
| `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_PRIMARY_THEME` / `RANDOM_SECONDARY_THEME` / `RANDOM_TERTIARY_THEME` | _(blank)_ | Override selected themes. |
|
||||||
| `RANDOM_SEED` | _(blank)_ | Deterministic seed for reproducible builds. |
|
| `RANDOM_SEED` | _(blank)_ | Deterministic seed for reproducible builds. |
|
||||||
| `RANDOM_AUTO_FILL` | `1` | Allow auto-fill of missing theme slots. |
|
| `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
|
### Automation & performance
|
||||||
| Variable | Default | Purpose |
|
| 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. |
|
| `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. |
|
| `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`.
|
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}
|
# MTG Python Deckbuilder ${VERSION}
|
||||||
|
|
||||||
## Summary
|
## 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
|
## 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
|
## 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
|
## 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
|
# Local application imports
|
||||||
from . import builder_constants as bc
|
from . import builder_constants as bc
|
||||||
from . import builder_utils as bu
|
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
|
import os
|
||||||
from settings import CSV_DIRECTORY
|
from settings import CSV_DIRECTORY
|
||||||
from file_setup.setup import initial_setup
|
from file_setup.setup import initial_setup
|
||||||
|
|
@ -113,6 +120,35 @@ class DeckBuilder(
|
||||||
except Exception:
|
except Exception:
|
||||||
# Leave RNG as-is on unexpected error
|
# Leave RNG as-is on unexpected error
|
||||||
pass
|
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):
|
def build_deck_full(self):
|
||||||
"""Orchestrate the full deck build process, chaining all major phases."""
|
"""Orchestrate the full deck build process, chaining all major phases."""
|
||||||
start_ts = datetime.datetime.now()
|
start_ts = datetime.datetime.now()
|
||||||
|
|
@ -424,6 +460,19 @@ class DeckBuilder(
|
||||||
# Diagnostics storage for include/exclude processing
|
# Diagnostics storage for include/exclude processing
|
||||||
include_exclude_diagnostics: Optional[Dict[str, Any]] = None
|
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
|
# Deck library (cards added so far) mapping name->record
|
||||||
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
# Tag tracking: counts of unique cards per tag (not per copy)
|
# 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_constants as bc
|
||||||
from .. import builder_utils as bu
|
from .. import builder_utils as bu
|
||||||
|
from ..theme_context import annotate_theme_matches
|
||||||
import logging_util
|
import logging_util
|
||||||
|
|
||||||
logger = logging_util.logging.getLogger(__name__)
|
logger = logging_util.logging.getLogger(__name__)
|
||||||
|
|
@ -31,48 +32,20 @@ class CreatureAdditionMixin:
|
||||||
if 'type' not in df.columns:
|
if 'type' not in df.columns:
|
||||||
self.output_func("Card pool missing 'type' column; cannot add creatures.")
|
self.output_func("Card pool missing 'type' column; cannot add creatures.")
|
||||||
return
|
return
|
||||||
themes_ordered: List[tuple[str, str]] = []
|
try:
|
||||||
if self.primary_tag:
|
context = self.get_theme_context() # type: ignore[attr-defined]
|
||||||
themes_ordered.append(('primary', self.primary_tag))
|
except Exception:
|
||||||
if self.secondary_tag:
|
context = None
|
||||||
themes_ordered.append(('secondary', self.secondary_tag))
|
if context is None or not getattr(context, 'ordered_targets', []):
|
||||||
if self.tertiary_tag:
|
self.output_func("No themes selected; skipping creature addition.")
|
||||||
themes_ordered.append(('tertiary', self.tertiary_tag))
|
return
|
||||||
if not themes_ordered:
|
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.")
|
self.output_func("No themes selected; skipping creature addition.")
|
||||||
return
|
return
|
||||||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
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)
|
weights: Dict[str, float] = dict(getattr(context, 'weights', {}))
|
||||||
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
|
|
||||||
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
|
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)
|
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
|
||||||
if commander_name and 'name' in creature_df.columns:
|
if commander_name and 'name' in creature_df.columns:
|
||||||
|
|
@ -80,12 +53,9 @@ class CreatureAdditionMixin:
|
||||||
if creature_df.empty:
|
if creature_df.empty:
|
||||||
self.output_func("No creature rows in dataset; skipping.")
|
self.output_func("No creature rows in dataset; skipping.")
|
||||||
return
|
return
|
||||||
selected_tags_lower = [t.lower() for _r,t in themes_ordered]
|
creature_df = annotate_theme_matches(creature_df, context)
|
||||||
if '_parsedThemeTags' not in creature_df.columns:
|
selected_tags_lower = context.selected_slugs()
|
||||||
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
combine_mode = context.combine_mode
|
||||||
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')
|
|
||||||
base_top = 30
|
base_top = 30
|
||||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
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_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)
|
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||||
weighted_pool = []
|
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
|
w = weight_strong
|
||||||
if owned_lower and str(nm).lower() in owned_lower:
|
if owned_lower and str(nm).lower() in owned_lower:
|
||||||
w *= owned_mult
|
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))
|
weighted_pool.append((nm, w))
|
||||||
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
|
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap, rng=getattr(self, 'rng', None))
|
||||||
for nm in chosen_all:
|
for nm in chosen_all:
|
||||||
|
|
@ -127,12 +107,13 @@ class CreatureAdditionMixin:
|
||||||
continue
|
continue
|
||||||
row = subset_all[subset_all['name'] == nm].iloc[0]
|
row = subset_all[subset_all['name'] == nm].iloc[0]
|
||||||
# Which selected themes does this card hit?
|
# Which selected themes does this card hit?
|
||||||
selected_display_tags = [t for _r, t in themes_ordered]
|
hits = row.get('_matchTags', [])
|
||||||
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
|
if not isinstance(hits, list):
|
||||||
try:
|
try:
|
||||||
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
|
hits = list(hits)
|
||||||
except Exception:
|
except Exception:
|
||||||
hits = selected_display_tags
|
hits = []
|
||||||
|
match_score = row.get('_matchScore', row.get('_multiMatch', all_cnt))
|
||||||
self.add_card(
|
self.add_card(
|
||||||
nm,
|
nm,
|
||||||
card_type=row.get('type','Creature'),
|
card_type=row.get('type','Creature'),
|
||||||
|
|
@ -144,7 +125,7 @@ class CreatureAdditionMixin:
|
||||||
sub_role='all_theme',
|
sub_role='all_theme',
|
||||||
added_by='creature_all_theme',
|
added_by='creature_all_theme',
|
||||||
trigger_tag=", ".join(hits) if hits else None,
|
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)
|
added_names.append(nm)
|
||||||
all_theme_added.append((nm, hits))
|
all_theme_added.append((nm, hits))
|
||||||
|
|
@ -153,30 +134,42 @@ class CreatureAdditionMixin:
|
||||||
break
|
break
|
||||||
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
|
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 distribution
|
||||||
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}
|
||||||
for role, tag in themes_ordered:
|
for target in themes_ordered:
|
||||||
w = weights.get(role, 0.0)
|
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:
|
if w <= 0:
|
||||||
continue
|
continue
|
||||||
remaining = max(0, desired_total - total_added)
|
remaining = max(0, desired_total - total_added)
|
||||||
if remaining == 0:
|
if remaining == 0:
|
||||||
break
|
break
|
||||||
target = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
|
target_count = int(math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
|
||||||
target = min(target, remaining)
|
target_count = min(target_count, remaining)
|
||||||
if target <= 0:
|
if target_count <= 0:
|
||||||
continue
|
continue
|
||||||
tnorm = tag.lower()
|
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=slug: (tn in lst) or any(tn in (item or '') for item in lst))]
|
||||||
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
|
||||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||||
if (creature_df['_multiMatch'] >= 2).any():
|
if (creature_df['_multiMatch'] >= 2).any():
|
||||||
subset = subset[subset['_multiMatch'] >= 2]
|
subset = subset[subset['_multiMatch'] >= 2]
|
||||||
if subset.empty:
|
if subset.empty:
|
||||||
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
||||||
continue
|
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:
|
if 'edhrecRank' in subset.columns:
|
||||||
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
sort_cols.append('edhrecRank')
|
||||||
elif 'manaValue' in subset.columns:
|
asc.append(True)
|
||||||
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
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):
|
if getattr(self, 'prefer_owned', False):
|
||||||
owned_set = getattr(self, 'owned_card_names', None)
|
owned_set = getattr(self, 'owned_card_names', None)
|
||||||
if owned_set:
|
if owned_set:
|
||||||
|
|
@ -187,25 +180,51 @@ class CreatureAdditionMixin:
|
||||||
continue
|
continue
|
||||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
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)
|
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||||
|
bonus = getattr(context, 'match_bonus', 0.0)
|
||||||
if combine_mode == 'AND':
|
if combine_mode == 'AND':
|
||||||
weighted_pool = []
|
weighted_pool = []
|
||||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
for idx, nm in enumerate(pool['name']):
|
||||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
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:
|
if owned_lower and str(nm).lower() in owned_lower:
|
||||||
base_w *= owned_mult
|
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))
|
weighted_pool.append((nm, base_w))
|
||||||
else:
|
else:
|
||||||
weighted_pool = []
|
weighted_pool = []
|
||||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
for idx, nm in enumerate(pool['name']):
|
||||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
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:
|
if owned_lower and str(nm).lower() in owned_lower:
|
||||||
base_w *= owned_mult
|
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))
|
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:
|
for nm in chosen:
|
||||||
if commander_name and nm == commander_name:
|
if commander_name and nm == commander_name:
|
||||||
continue
|
continue
|
||||||
row = pool[pool['name']==nm].iloc[0]
|
row = pool[pool['name']==nm].iloc[0]
|
||||||
|
match_score = row.get('_matchScore', row.get('_multiMatch', 0))
|
||||||
self.add_card(
|
self.add_card(
|
||||||
nm,
|
nm,
|
||||||
card_type=row.get('type','Creature'),
|
card_type=row.get('type','Creature'),
|
||||||
|
|
@ -217,14 +236,15 @@ class CreatureAdditionMixin:
|
||||||
sub_role=role,
|
sub_role=role,
|
||||||
added_by='creature_add',
|
added_by='creature_add',
|
||||||
trigger_tag=tag,
|
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)
|
added_names.append(nm)
|
||||||
per_theme_added[role].append(nm)
|
per_theme_added[role].append(nm)
|
||||||
total_added += 1
|
total_added += 1
|
||||||
if total_added >= desired_total:
|
if total_added >= desired_total:
|
||||||
break
|
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:
|
if total_added >= desired_total:
|
||||||
break
|
break
|
||||||
# Fill remaining if still short
|
# Fill remaining if still short
|
||||||
|
|
@ -239,10 +259,20 @@ class CreatureAdditionMixin:
|
||||||
else:
|
else:
|
||||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||||
if not multi_pool.empty:
|
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:
|
if 'edhrecRank' in multi_pool.columns:
|
||||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
sort_cols.append('edhrecRank')
|
||||||
elif 'manaValue' in multi_pool.columns:
|
asc.append(True)
|
||||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
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):
|
if getattr(self, 'prefer_owned', False):
|
||||||
owned_set = getattr(self, 'owned_card_names', None)
|
owned_set = getattr(self, 'owned_card_names', None)
|
||||||
if owned_set:
|
if owned_set:
|
||||||
|
|
@ -262,7 +292,7 @@ class CreatureAdditionMixin:
|
||||||
role='creature',
|
role='creature',
|
||||||
sub_role='fill',
|
sub_role='fill',
|
||||||
added_by='creature_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)
|
added_names.append(nm)
|
||||||
total_added += 1
|
total_added += 1
|
||||||
|
|
@ -278,14 +308,18 @@ class CreatureAdditionMixin:
|
||||||
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
|
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
|
||||||
else:
|
else:
|
||||||
self.output_func(f" - {nm}")
|
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, [])
|
lst = per_theme_added.get(role, [])
|
||||||
if lst:
|
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:
|
for nm in lst:
|
||||||
self.output_func(f" - {nm}")
|
self.output_func(f" - {nm}")
|
||||||
else:
|
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 ''}")
|
self.output_func(f" Total {total_added}/{desired_total}{' (dataset shortfall)' if total_added < desired_total else ''}")
|
||||||
|
|
||||||
def add_creatures_phase(self):
|
def add_creatures_phase(self):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import os
|
||||||
|
|
||||||
from .. import builder_utils as bu
|
from .. import builder_utils as bu
|
||||||
from .. import builder_constants as bc
|
from .. import builder_constants as bc
|
||||||
|
from ..theme_context import annotate_theme_matches
|
||||||
import logging_util
|
import logging_util
|
||||||
|
|
||||||
logger = logging_util.logging.getLogger(__name__)
|
logger = logging_util.logging.getLogger(__name__)
|
||||||
|
|
@ -620,46 +621,17 @@ class SpellAdditionMixin:
|
||||||
df = getattr(self, '_combined_cards_df', None)
|
df = getattr(self, '_combined_cards_df', None)
|
||||||
if df is None or df.empty or 'type' not in df.columns:
|
if df is None or df.empty or 'type' not in df.columns:
|
||||||
return
|
return
|
||||||
themes_ordered: List[tuple[str, str]] = []
|
try:
|
||||||
if self.primary_tag:
|
context = self.get_theme_context() # type: ignore[attr-defined]
|
||||||
themes_ordered.append(('primary', self.primary_tag))
|
except Exception:
|
||||||
if self.secondary_tag:
|
context = None
|
||||||
themes_ordered.append(('secondary', self.secondary_tag))
|
if context is None or not getattr(context, 'ordered_targets', []):
|
||||||
if self.tertiary_tag:
|
|
||||||
themes_ordered.append(('tertiary', self.tertiary_tag))
|
|
||||||
if not themes_ordered:
|
|
||||||
return
|
return
|
||||||
n_themes = len(themes_ordered)
|
themes_ordered = list(context.ordered_targets)
|
||||||
if n_themes == 1:
|
selected_tags_lower = context.selected_slugs()
|
||||||
base_map = {'primary': 1.0}
|
if not themes_ordered or not selected_tags_lower:
|
||||||
elif n_themes == 2:
|
return
|
||||||
base_map = {'primary': 0.6, 'secondary': 0.4}
|
weights: Dict[str, float] = dict(getattr(context, 'weights', {}))
|
||||||
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
|
|
||||||
spells_df = df[
|
spells_df = df[
|
||||||
~df['type'].str.contains('Land', case=False, na=False)
|
~df['type'].str.contains('Land', case=False, na=False)
|
||||||
& ~df['type'].str.contains('Creature', 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)
|
spells_df = self._apply_bracket_pre_filters(spells_df)
|
||||||
if spells_df.empty:
|
if spells_df.empty:
|
||||||
return
|
return
|
||||||
selected_tags_lower = [t.lower() for _r, t in themes_ordered]
|
spells_df = annotate_theme_matches(spells_df, context)
|
||||||
if '_parsedThemeTags' not in spells_df.columns:
|
combine_mode = context.combine_mode
|
||||||
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')
|
|
||||||
base_top = 40
|
base_top = 40
|
||||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
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
|
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:
|
if remaining - total_added <= 0:
|
||||||
break
|
break
|
||||||
w = weights.get(role, 0.0)
|
w = weights.get(role, target.weight if hasattr(target, 'weight') else 0.0)
|
||||||
if w <= 0:
|
if w <= 0:
|
||||||
continue
|
continue
|
||||||
target = int(math.ceil(remaining * w * self._get_rng().uniform(1.0, 1.1)))
|
available = remaining - total_added
|
||||||
target = min(target, remaining - total_added)
|
target_count = int(math.ceil(available * w * self._get_rng().uniform(1.0, 1.1)))
|
||||||
if target <= 0:
|
target_count = min(target_count, available)
|
||||||
|
if target_count <= 0:
|
||||||
continue
|
continue
|
||||||
tnorm = tag.lower()
|
|
||||||
subset = spells_df[
|
subset = spells_df[
|
||||||
spells_df['_normTags'].apply(
|
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:
|
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||||
|
|
@ -701,18 +673,20 @@ class SpellAdditionMixin:
|
||||||
subset = subset[subset['_multiMatch'] >= 2]
|
subset = subset[subset['_multiMatch'] >= 2]
|
||||||
if subset.empty:
|
if subset.empty:
|
||||||
continue
|
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:
|
if 'edhrecRank' in subset.columns:
|
||||||
subset = subset.sort_values(
|
sort_cols.append('edhrecRank')
|
||||||
by=['_multiMatch', 'edhrecRank', 'manaValue'],
|
asc.append(True)
|
||||||
ascending=[False, True, True],
|
if 'manaValue' in subset.columns:
|
||||||
na_position='last',
|
sort_cols.append('manaValue')
|
||||||
)
|
asc.append(True)
|
||||||
elif 'manaValue' in subset.columns:
|
subset = subset.sort_values(by=sort_cols, ascending=asc, na_position='last')
|
||||||
subset = subset.sort_values(
|
|
||||||
by=['_multiMatch', 'manaValue'],
|
|
||||||
ascending=[False, True],
|
|
||||||
na_position='last',
|
|
||||||
)
|
|
||||||
# Prefer-owned: stable reorder before trimming to top_n
|
# Prefer-owned: stable reorder before trimming to top_n
|
||||||
if getattr(self, 'prefer_owned', False):
|
if getattr(self, 'prefer_owned', False):
|
||||||
owned_set = getattr(self, 'owned_card_names', None)
|
owned_set = getattr(self, 'owned_card_names', None)
|
||||||
|
|
@ -726,23 +700,60 @@ class SpellAdditionMixin:
|
||||||
# Build weighted pool with optional owned multiplier
|
# 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_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)
|
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||||
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
|
|
||||||
weighted_pool: list[tuple[str, float]] = []
|
weighted_pool: list[tuple[str, float]] = []
|
||||||
if combine_mode == 'AND':
|
if combine_mode == 'AND':
|
||||||
for nm, mm in base_pairs:
|
for idx, nm in enumerate(pool['name']):
|
||||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
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:
|
if owned_lower and str(nm).lower() in owned_lower:
|
||||||
base_w *= owned_mult
|
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))
|
weighted_pool.append((nm, base_w))
|
||||||
else:
|
else:
|
||||||
for nm, mm in base_pairs:
|
for idx, nm in enumerate(pool['name']):
|
||||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
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:
|
if owned_lower and str(nm).lower() in owned_lower:
|
||||||
base_w *= owned_mult
|
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))
|
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:
|
for nm in chosen:
|
||||||
row = pool[pool['name'] == nm].iloc[0]
|
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(
|
self.add_card(
|
||||||
nm,
|
nm,
|
||||||
card_type=row.get('type', ''),
|
card_type=row.get('type', ''),
|
||||||
|
|
@ -753,7 +764,7 @@ class SpellAdditionMixin:
|
||||||
sub_role=role,
|
sub_role=role,
|
||||||
added_by='spell_theme_fill',
|
added_by='spell_theme_fill',
|
||||||
trigger_tag=tag,
|
trigger_tag=tag,
|
||||||
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
synergy=synergy_value
|
||||||
)
|
)
|
||||||
per_theme_added[role].append(nm)
|
per_theme_added[role].append(nm)
|
||||||
total_added += 1
|
total_added += 1
|
||||||
|
|
@ -771,18 +782,20 @@ class SpellAdditionMixin:
|
||||||
else:
|
else:
|
||||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||||
if not multi_pool.empty:
|
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:
|
if 'edhrecRank' in multi_pool.columns:
|
||||||
multi_pool = multi_pool.sort_values(
|
sort_cols.append('edhrecRank')
|
||||||
by=['_multiMatch', 'edhrecRank', 'manaValue'],
|
asc.append(True)
|
||||||
ascending=[False, True, True],
|
if 'manaValue' in multi_pool.columns:
|
||||||
na_position='last',
|
sort_cols.append('manaValue')
|
||||||
)
|
asc.append(True)
|
||||||
elif 'manaValue' in multi_pool.columns:
|
multi_pool = multi_pool.sort_values(by=sort_cols, ascending=asc, na_position='last')
|
||||||
multi_pool = multi_pool.sort_values(
|
|
||||||
by=['_multiMatch', 'manaValue'],
|
|
||||||
ascending=[False, True],
|
|
||||||
na_position='last',
|
|
||||||
)
|
|
||||||
if getattr(self, 'prefer_owned', False):
|
if getattr(self, 'prefer_owned', False):
|
||||||
owned_set = getattr(self, 'owned_card_names', None)
|
owned_set = getattr(self, 'owned_card_names', None)
|
||||||
if owned_set:
|
if owned_set:
|
||||||
|
|
@ -790,6 +803,20 @@ class SpellAdditionMixin:
|
||||||
fill = multi_pool['name'].tolist()[:need]
|
fill = multi_pool['name'].tolist()[:need]
|
||||||
for nm in fill:
|
for nm in fill:
|
||||||
row = multi_pool[multi_pool['name'] == nm].iloc[0]
|
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(
|
self.add_card(
|
||||||
nm,
|
nm,
|
||||||
card_type=row.get('type', ''),
|
card_type=row.get('type', ''),
|
||||||
|
|
@ -799,7 +826,7 @@ class SpellAdditionMixin:
|
||||||
role='theme_spell',
|
role='theme_spell',
|
||||||
sub_role='fill_multi',
|
sub_role='fill_multi',
|
||||||
added_by='spell_theme_fill',
|
added_by='spell_theme_fill',
|
||||||
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
synergy=synergy_value
|
||||||
)
|
)
|
||||||
total_added += 1
|
total_added += 1
|
||||||
if total_added >= remaining:
|
if total_added >= remaining:
|
||||||
|
|
@ -875,10 +902,16 @@ class SpellAdditionMixin:
|
||||||
self.output_func(f" - {nm}")
|
self.output_func(f" - {nm}")
|
||||||
if total_added:
|
if total_added:
|
||||||
self.output_func("\nFinal Theme Spell Fill:")
|
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, [])
|
lst = per_theme_added.get(role, [])
|
||||||
if lst:
|
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:
|
for nm in lst:
|
||||||
self.output_func(f" - {nm}")
|
self.output_func(f" - {nm}")
|
||||||
self.output_func(f" Total Theme Spells Added: {total_added}")
|
self.output_func(f" Total Theme Spells Added: {total_added}")
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import datetime as _dt
|
||||||
import re as _re
|
import re as _re
|
||||||
import logging_util
|
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
|
from code.deck_builder.shared_copy import build_land_headline, dfc_card_note
|
||||||
|
|
||||||
logger = logging_util.logging.getLogger(__name__)
|
logger = logging_util.logging.getLogger(__name__)
|
||||||
|
|
@ -627,6 +627,12 @@ class ReportingMixin:
|
||||||
record_land_summary(land_summary)
|
record_land_summary(land_summary)
|
||||||
except Exception: # pragma: no cover - diagnostics only
|
except Exception: # pragma: no cover - diagnostics only
|
||||||
logger.debug("Failed to record MDFC telemetry", exc_info=True)
|
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
|
return summary_payload
|
||||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||||
"""Export current decklist to CSV (enriched).
|
"""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)
|
# Capture fetch count (others vary run-to-run and are intentionally not recorded)
|
||||||
chosen_fetch = getattr(self, 'fetch_count', None)
|
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 = {
|
payload = {
|
||||||
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
|
"commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '',
|
||||||
"primary_tag": getattr(self, 'primary_tag', None),
|
"primary_tag": getattr(self, 'primary_tag', None),
|
||||||
|
|
@ -1067,6 +1080,12 @@ class ReportingMixin:
|
||||||
"enforcement_mode": getattr(self, 'enforcement_mode', 'warn'),
|
"enforcement_mode": getattr(self, 'enforcement_mode', 'warn'),
|
||||||
"allow_illegal": bool(getattr(self, 'allow_illegal', False)),
|
"allow_illegal": bool(getattr(self, 'allow_illegal', False)),
|
||||||
"fuzzy_matching": bool(getattr(self, 'fuzzy_matching', True)),
|
"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)
|
# chosen fetch land count (others intentionally omitted for variance)
|
||||||
"fetch_count": chosen_fetch,
|
"fetch_count": chosen_fetch,
|
||||||
# actual ideal counts used for this run
|
# actual ideal counts used for this run
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from typing import Any, Dict, Iterable
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"record_land_summary",
|
"record_land_summary",
|
||||||
"get_mdfc_metrics",
|
"get_mdfc_metrics",
|
||||||
|
"record_theme_summary",
|
||||||
|
"get_theme_metrics",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,6 +24,16 @@ _metrics: Dict[str, Any] = {
|
||||||
}
|
}
|
||||||
_top_cards: Counter[str] = Counter()
|
_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:
|
def _to_int(value: Any) -> int:
|
||||||
try:
|
try:
|
||||||
|
|
@ -120,3 +132,110 @@ def _reset_metrics_for_test() -> None:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_top_cards.clear()
|
_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.builder import DeckBuilder
|
||||||
from deck_builder import builder_constants as bc
|
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 file_setup.setup import initial_setup
|
||||||
from tagging import tagger
|
from tagging import tagger
|
||||||
from exceptions import CommanderValidationError
|
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]
|
return [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]:
|
def _load_commander_name_lookup() -> Tuple[set[str], Tuple[str, ...]]:
|
||||||
builder = DeckBuilder(
|
builder = DeckBuilder(
|
||||||
|
|
@ -193,6 +201,10 @@ def run(
|
||||||
allow_illegal: bool = False,
|
allow_illegal: bool = False,
|
||||||
fuzzy_matching: bool = True,
|
fuzzy_matching: bool = True,
|
||||||
seed: Optional[int | str] = None,
|
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:
|
) -> DeckBuilder:
|
||||||
"""Run a scripted non-interactive deck build and return the DeckBuilder instance."""
|
"""Run a scripted non-interactive deck build and return the DeckBuilder instance."""
|
||||||
trimmed_commander = (command_name or "").strip()
|
trimmed_commander = (command_name or "").strip()
|
||||||
|
|
@ -274,6 +286,34 @@ def run(
|
||||||
builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined]
|
builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# 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.
|
# 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,
|
include_group.add_argument("--fuzzy-matching", metavar="BOOL", type=_parse_bool, default=None,
|
||||||
help="Enable fuzzy card name matching (bool: true/false/1/0)")
|
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 mode configuration (parity with web random builder)
|
||||||
random_group = p.add_argument_group(
|
random_group = p.add_argument_group(
|
||||||
"Random Mode",
|
"Random Mode",
|
||||||
|
|
@ -1428,6 +1494,9 @@ def _main() -> int:
|
||||||
resolved_primary_choice = args.primary_choice
|
resolved_primary_choice = args.primary_choice
|
||||||
resolved_secondary_choice = args.secondary_choice
|
resolved_secondary_choice = args.secondary_choice
|
||||||
resolved_tertiary_choice = args.tertiary_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:
|
try:
|
||||||
# Collect tag names from CLI, JSON, and environment (CLI takes precedence)
|
# Collect tag names from CLI, JSON, and environment (CLI takes precedence)
|
||||||
|
|
@ -1511,6 +1580,69 @@ def _main() -> int:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = {
|
resolved = {
|
||||||
"command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]),
|
"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"]),
|
"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"),
|
"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)),
|
"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)),
|
"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:
|
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
|
return 0
|
||||||
|
|
||||||
if not str(resolved.get("command_name", "")).strip():
|
if not str(resolved.get("command_name", "")).strip():
|
||||||
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
|
print("Error: commander is required. Provide --commander or a JSON config with a 'commander' field.")
|
||||||
return 2
|
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:
|
try:
|
||||||
run(**resolved)
|
run_kwargs = dict(resolved)
|
||||||
|
run_kwargs["user_theme_resolution"] = theme_resolution
|
||||||
|
run(**run_kwargs)
|
||||||
except CommanderValidationError as exc:
|
except CommanderValidationError as exc:
|
||||||
print(str(exc))
|
print(str(exc))
|
||||||
return 2
|
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 json
|
||||||
|
import hashlib
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
@ -88,6 +89,11 @@ class TestJSONRoundTrip:
|
||||||
assert re_exported_config["enforcement_mode"] == "strict"
|
assert re_exported_config["enforcement_mode"] == "strict"
|
||||||
assert re_exported_config["allow_illegal"] is True
|
assert re_exported_config["allow_illegal"] is True
|
||||||
assert re_exported_config["fuzzy_matching"] is False
|
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):
|
def test_empty_lists_round_trip(self):
|
||||||
"""Test that empty include/exclude lists are handled correctly."""
|
"""Test that empty include/exclude lists are handled correctly."""
|
||||||
|
|
@ -113,6 +119,8 @@ class TestJSONRoundTrip:
|
||||||
assert exported_config["enforcement_mode"] == "warn"
|
assert exported_config["enforcement_mode"] == "warn"
|
||||||
assert exported_config["allow_illegal"] is False
|
assert exported_config["allow_illegal"] is False
|
||||||
assert exported_config["fuzzy_matching"] is True
|
assert exported_config["fuzzy_matching"] is True
|
||||||
|
assert exported_config["userThemes"] == []
|
||||||
|
assert exported_config["themeCatalogVersion"] is None
|
||||||
|
|
||||||
def test_default_values_export(self):
|
def test_default_values_export(self):
|
||||||
"""Test that default values are exported correctly."""
|
"""Test that default values are exported correctly."""
|
||||||
|
|
@ -134,6 +142,9 @@ class TestJSONRoundTrip:
|
||||||
assert exported_config["enforcement_mode"] == "warn"
|
assert exported_config["enforcement_mode"] == "warn"
|
||||||
assert exported_config["allow_illegal"] is False
|
assert exported_config["allow_illegal"] is False
|
||||||
assert exported_config["fuzzy_matching"] is True
|
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):
|
def test_backward_compatibility_no_include_exclude_fields(self):
|
||||||
"""Test that configs without include/exclude fields still work."""
|
"""Test that configs without include/exclude fields still work."""
|
||||||
|
|
@ -167,6 +178,63 @@ class TestJSONRoundTrip:
|
||||||
assert "enforcement_mode" not in loaded_config
|
assert "enforcement_mode" not in loaded_config
|
||||||
assert "allow_illegal" not in loaded_config
|
assert "allow_illegal" not in loaded_config
|
||||||
assert "fuzzy_matching" 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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,8 @@ class TestJSONRoundTrip:
|
||||||
assert exported_data['enforcement_mode'] == "strict"
|
assert exported_data['enforcement_mode'] == "strict"
|
||||||
assert exported_data['allow_illegal'] is True
|
assert exported_data['allow_illegal'] is True
|
||||||
assert exported_data['fuzzy_matching'] is False
|
assert exported_data['fuzzy_matching'] is False
|
||||||
|
assert exported_data['userThemes'] == []
|
||||||
|
assert exported_data['themeCatalogVersion'] is None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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 'include_cards' in json_data, "JSON should contain include_cards field"
|
||||||
assert 'exclude_cards' in json_data, "JSON should contain exclude_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 '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:
|
except Exception:
|
||||||
# If enforce_and_reexport fails completely, that's also fine for this test
|
# If enforce_and_reexport fails completely, that's also fine for this test
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from code.scripts import generate_theme_catalog as new_catalog
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
SCRIPT = ROOT / 'code' / 'scripts' / 'build_theme_catalog.py'
|
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'))
|
data = json.loads(out_path.read_text(encoding='utf-8'))
|
||||||
assert all('description' in t for t in data['themes'])
|
assert all('description' in t for t in data['themes'])
|
||||||
assert all(t['description'] 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 typing import Any, Optional, Dict, Iterable, Mapping
|
||||||
from contextlib import asynccontextmanager
|
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 tagging.multi_face_merger import load_merge_summary
|
||||||
from .services.combo_utils import detect_all as _detect_all
|
from .services.combo_utils import detect_all as _detect_all
|
||||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
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_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
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_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy)
|
||||||
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True)
|
||||||
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
|
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)
|
RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30)
|
||||||
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
|
RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False)
|
||||||
RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350)
|
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
|
# Simple theme input validation constraints
|
||||||
_THEME_MAX_LEN = 60
|
_THEME_MAX_LEN = 60
|
||||||
|
|
@ -240,6 +245,7 @@ templates.env.globals.update({
|
||||||
"enable_themes": ENABLE_THEMES,
|
"enable_themes": ENABLE_THEMES,
|
||||||
"enable_pwa": ENABLE_PWA,
|
"enable_pwa": ENABLE_PWA,
|
||||||
"enable_presets": ENABLE_PRESETS,
|
"enable_presets": ENABLE_PRESETS,
|
||||||
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||||
"default_theme": DEFAULT_THEME,
|
"default_theme": DEFAULT_THEME,
|
||||||
"random_modes": RANDOM_MODES,
|
"random_modes": RANDOM_MODES,
|
||||||
|
|
@ -248,6 +254,8 @@ templates.env.globals.update({
|
||||||
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
"random_timeout_ms": RANDOM_TIMEOUT_MS,
|
||||||
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
"random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS),
|
||||||
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
"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'
|
# 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_COMMANDERS": bool(SHOW_COMMANDERS),
|
||||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
||||||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||||
|
"ENABLE_CUSTOM_THEMES": bool(ENABLE_CUSTOM_THEMES),
|
||||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||||
"DEFAULT_THEME": DEFAULT_THEME,
|
"DEFAULT_THEME": DEFAULT_THEME,
|
||||||
|
"THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE,
|
||||||
|
"USER_THEME_LIMIT": int(USER_THEME_LIMIT),
|
||||||
"RANDOM_MODES": bool(RANDOM_MODES),
|
"RANDOM_MODES": bool(RANDOM_MODES),
|
||||||
"RANDOM_UI": bool(RANDOM_UI),
|
"RANDOM_UI": bool(RANDOM_UI),
|
||||||
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
|
"RANDOM_MAX_ATTEMPTS": int(RANDOM_MAX_ATTEMPTS),
|
||||||
|
|
@ -834,6 +845,7 @@ async def status_sys():
|
||||||
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
|
"RANDOM_TELEMETRY": bool(RANDOM_TELEMETRY),
|
||||||
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
|
"RANDOM_STRUCTURED_LOGS": bool(RANDOM_STRUCTURED_LOGS),
|
||||||
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
|
"RANDOM_RATE_LIMIT": bool(RATE_LIMIT_ENABLED),
|
||||||
|
"RATE_LIMIT_ENABLED": bool(RATE_LIMIT_ENABLED),
|
||||||
"RATE_LIMIT_WINDOW_S": int(RATE_LIMIT_WINDOW_S),
|
"RATE_LIMIT_WINDOW_S": int(RATE_LIMIT_WINDOW_S),
|
||||||
"RANDOM_RATE_LIMIT_RANDOM": int(RATE_LIMIT_RANDOM),
|
"RANDOM_RATE_LIMIT_RANDOM": int(RATE_LIMIT_RANDOM),
|
||||||
"RANDOM_RATE_LIMIT_BUILD": int(RATE_LIMIT_BUILD),
|
"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)
|
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:
|
def random_modes_enabled() -> bool:
|
||||||
"""Dynamic check so tests that set env after import still work.
|
"""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:
|
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||||
if not SHOW_DIAGNOSTICS:
|
if not SHOW_DIAGNOSTICS:
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
return templates.TemplateResponse(
|
# Build a sanitized context and pre-render to surface template errors clearly
|
||||||
"diagnostics/index.html",
|
try:
|
||||||
{
|
summary = load_merge_summary() or {"updated_at": None, "colors": {}}
|
||||||
"request": request,
|
if not isinstance(summary, dict):
|
||||||
"merge_summary": load_merge_summary(),
|
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)
|
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ from __future__ import annotations
|
||||||
from fastapi import APIRouter, Request, Form, Query
|
from fastapi import APIRouter, Request, Form, Query
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from typing import Any
|
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 (
|
from ..services.build_utils import (
|
||||||
step5_ctx_from_result,
|
step5_ctx_from_result,
|
||||||
step5_error_ctx,
|
step5_error_ctx,
|
||||||
|
|
@ -23,6 +29,7 @@ from html import escape as _esc
|
||||||
from deck_builder.builder import DeckBuilder
|
from deck_builder.builder import DeckBuilder
|
||||||
from deck_builder import builder_utils as bu
|
from deck_builder import builder_utils as bu
|
||||||
from ..services.combo_utils import detect_all as _detect_all
|
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 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.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||||
from ..services.telemetry import log_commander_create_deck
|
from ..services.telemetry import log_commander_create_deck
|
||||||
|
|
@ -114,6 +121,41 @@ router = APIRouter(prefix="/build")
|
||||||
# Alternatives cache moved to services/alts_utils
|
# 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:
|
def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
||||||
"""Rebuild the staged context so Multi-Copy runs first, avoiding overfill.
|
"""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)."""
|
"""Return the New Deck modal content (for an overlay)."""
|
||||||
sid = request.cookies.get("sid") or new_sid()
|
sid = request.cookies.get("sid") or new_sid()
|
||||||
sess = get_session(sid)
|
sess = get_session(sid)
|
||||||
|
theme_context = _custom_theme_context(request, sess)
|
||||||
ctx = {
|
ctx = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"brackets": orch.bracket_options(),
|
"brackets": orch.bracket_options(),
|
||||||
"labels": orch.ideal_labels(),
|
"labels": orch.ideal_labels(),
|
||||||
"defaults": orch.ideal_defaults(),
|
"defaults": orch.ideal_defaults(),
|
||||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||||
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"form": {
|
"form": {
|
||||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||||
"combo_count": sess.get("combo_target_count"),
|
"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")),
|
"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 = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
|
|
@ -575,6 +623,91 @@ async def build_new_multicopy(
|
||||||
return HTMLResponse("")
|
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)
|
@router.post("/new", response_class=HTMLResponse)
|
||||||
async def build_new_submit(
|
async def build_new_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -660,8 +793,14 @@ async def build_new_submit(
|
||||||
"labels": orch.ideal_labels(),
|
"labels": orch.ideal_labels(),
|
||||||
"defaults": orch.ideal_defaults(),
|
"defaults": orch.ideal_defaults(),
|
||||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||||
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"form": _form_state(suggested),
|
"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 = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
|
|
@ -676,8 +815,14 @@ async def build_new_submit(
|
||||||
"labels": orch.ideal_labels(),
|
"labels": orch.ideal_labels(),
|
||||||
"defaults": orch.ideal_defaults(),
|
"defaults": orch.ideal_defaults(),
|
||||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||||
|
"enable_custom_themes": ENABLE_CUSTOM_THEMES,
|
||||||
"form": _form_state(commander),
|
"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 = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||||
return resp
|
return resp
|
||||||
|
|
@ -694,8 +839,34 @@ async def build_new_submit(
|
||||||
bracket = 3
|
bracket = 3
|
||||||
# Save to session
|
# Save to session
|
||||||
sess["commander"] = sel.get("name") or commander
|
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]
|
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:
|
if not tags:
|
||||||
try:
|
try:
|
||||||
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
|
rec = orch.recommended_tags_for_commander(sess["commander"]) or []
|
||||||
|
|
@ -731,6 +902,33 @@ async def build_new_submit(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
sess["ideals"] = ideals
|
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
|
# Persist preferences
|
||||||
try:
|
try:
|
||||||
sess["prefer_combos"] = bool(prefer_combos)
|
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')
|
args.append('--force')
|
||||||
_run(args, check=True)
|
_run(args, check=True)
|
||||||
_emit("Theme catalog (JSON + YAML) refreshed{}.".format(" (fast path)" if fast_path else ""))
|
_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
|
# Mark progress complete
|
||||||
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
|
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
|
||||||
# Append status file enrichment with last export metrics
|
# Append status file enrichment with last export metrics
|
||||||
|
|
@ -1869,7 +1882,7 @@ def start_build_ctx(
|
||||||
if row.empty:
|
if row.empty:
|
||||||
raise ValueError(f"Commander not found: {commander}")
|
raise ValueError(f"Commander not found: {commander}")
|
||||||
b._apply_commander_selection(row.iloc[0])
|
b._apply_commander_selection(row.iloc[0])
|
||||||
# Tags
|
# Tags (explicit + supplemental applied upstream)
|
||||||
b.selected_tags = list(tags or [])
|
b.selected_tags = list(tags or [])
|
||||||
b.primary_tag = b.selected_tags[0] if len(b.selected_tags) > 0 else None
|
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
|
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" />
|
<input type="hidden" name="tag_mode" value="AND" />
|
||||||
</div>
|
</div>
|
||||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></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">
|
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
|
||||||
<label>Bracket
|
<label>Bracket
|
||||||
<select name="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">
|
<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>
|
<h3 style="margin-top:0">System summary</h3>
|
||||||
<div id="sysSummary" class="muted">Loading…</div>
|
<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="themeSummary" style="margin-top:.5rem"></div>
|
||||||
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
|
||||||
<div style="margin-top:.35rem">
|
<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">
|
<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>
|
<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>
|
<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 %}
|
{% if colors %}
|
||||||
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
|
|
@ -30,28 +32,29 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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);">
|
<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; font-weight:600;">{{ color|title }}</td>
|
||||||
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</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.faces_dropped or 0 }}</td>
|
||||||
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
||||||
<td style="padding:.35rem .25rem;">
|
<td style="padding:.35rem .25rem;">
|
||||||
{% set entries = payload.entries or [] %}
|
{% set entries = (payload.entries | default([])) %}
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
<details>
|
<details>
|
||||||
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
<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;">
|
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
||||||
{% for entry in entries %}
|
{% for entry in preview %}
|
||||||
{% if loop.index0 < 5 %}
|
<li style="margin-bottom:.25rem;">
|
||||||
<li style="margin-bottom:.25rem;">
|
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
</li>
|
||||||
</li>
|
|
||||||
{% elif loop.index0 == 5 %}
|
|
||||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
|
|
||||||
{% break %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if entries|length > preview|length %}
|
||||||
|
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{% else %}
|
{% 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'; }
|
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();
|
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');
|
var tokenEl = document.getElementById('themeTokenStats');
|
||||||
function renderTokens(payload){
|
function renderTokens(payload){
|
||||||
if (!tokenEl) return;
|
if (!tokenEl) return;
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,9 @@
|
||||||
"exclude_cards": ["Chaos Orb"],
|
"exclude_cards": ["Chaos Orb"],
|
||||||
"enforcement_mode": "warn",
|
"enforcement_mode": "warn",
|
||||||
"allow_illegal": false,
|
"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_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_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_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_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
||||||
ENABLE_PRESETS: "0" # 1=show presets section
|
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
|
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||||
SHOW_MISC_POOL: "0"
|
SHOW_MISC_POOL: "0"
|
||||||
|
|
@ -47,6 +50,9 @@ services:
|
||||||
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
# RANDOM_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
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_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||||
RANDOM_SECONDARY_THEME: "" # optional secondary 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_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_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
|
# Theming
|
||||||
THEME: "dark" # system|light|dark
|
THEME: "dark" # system|light|dark
|
||||||
|
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Setup / Tagging / Catalog Controls
|
# Setup / Tagging / Catalog Controls
|
||||||
|
|
@ -121,6 +135,8 @@ services:
|
||||||
# CSV_FILES_DIR: "/app/csv_files"
|
# CSV_FILES_DIR: "/app/csv_files"
|
||||||
# Inject a one-off synthetic CSV for index testing without altering shards
|
# Inject a one-off synthetic CSV for index testing without altering shards
|
||||||
# CARD_INDEX_EXTRA_CSV: ""
|
# CARD_INDEX_EXTRA_CSV: ""
|
||||||
|
# DECK_ADDITIONAL_THEMES: ""
|
||||||
|
# THEME_MATCH_MODE: "permissive"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Headless / Non-interactive Build Configuration
|
# Headless / Non-interactive Build Configuration
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ services:
|
||||||
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
|
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_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_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_PWA: "0" # 1=serve manifest/service worker (experimental)
|
||||||
ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
|
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
|
ENABLE_PRESETS: "0" # 1=show presets section
|
||||||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
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_UI: "1" # 1=show Surprise/Theme/Reroll/Share controls in UI
|
||||||
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
RANDOM_MAX_ATTEMPTS: "5" # cap retry attempts
|
||||||
RANDOM_TIMEOUT_MS: "5000" # per-build timeout in ms
|
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_THEME: "" # optional legacy theme alias (maps to primary theme)
|
||||||
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
RANDOM_PRIMARY_THEME: "" # optional primary theme slug override
|
||||||
RANDOM_SECONDARY_THEME: "" # optional secondary 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_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_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
|
# Theming
|
||||||
THEME: "dark" # system|light|dark
|
THEME: "dark" # system|light|dark
|
||||||
|
THEME_MATCH_MODE: "permissive" # permissive|strict fuzzy match mode for supplemental themes
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Setup / Tagging / Catalog Controls
|
# Setup / Tagging / Catalog Controls
|
||||||
|
|
@ -123,6 +137,8 @@ services:
|
||||||
# CSV_FILES_DIR: "/app/csv_files"
|
# CSV_FILES_DIR: "/app/csv_files"
|
||||||
# Inject a one-off synthetic CSV for index testing without altering shards
|
# Inject a one-off synthetic CSV for index testing without altering shards
|
||||||
# CARD_INDEX_EXTRA_CSV: ""
|
# CARD_INDEX_EXTRA_CSV: ""
|
||||||
|
# DECK_ADDITIONAL_THEMES: ""
|
||||||
|
# THEME_MATCH_MODE: "permissive"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Headless / Non-interactive Build Configuration
|
# 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.
|
Additional details for developers and power users working with the theme catalog, editorial tooling, and diagnostics.
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
- [theme_catalog.csv schema](#theme_catalogcsv-schema)
|
||||||
- [HTMX API endpoints](#htmx-api-endpoints)
|
- [HTMX API endpoints](#htmx-api-endpoints)
|
||||||
- [Caching, diagnostics, and metrics](#caching-diagnostics-and-metrics)
|
- [Caching, diagnostics, and metrics](#caching-diagnostics-and-metrics)
|
||||||
- [Governance principles](#governance-principles)
|
- [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)
|
- [Description mapping overrides](#description-mapping-overrides)
|
||||||
- [Validation and schema tooling](#validation-and-schema-tooling)
|
- [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
|
## HTMX API endpoints
|
||||||
The upcoming theme picker UI is powered by two FastAPI 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
|
## Diagnostics and logs
|
||||||
- `SHOW_DIAGNOSTICS=1` unlocks the `/diagnostics` page with system summaries (`/status/sys`), feature flags, and per-request `X-Request-ID` headers.
|
- `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.
|
- `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.
|
- 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