mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
216 lines
5.3 KiB
Python
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,
|
|
)
|
|
|