mtg_python_deckbuilder/code/deck_builder/theme_resolution.py

216 lines
5.3 KiB
Python

"""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,
)