mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +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
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,
|
||||
)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue