feat: add supplemental theme catalog tooling, additional theme selection, and custom theme selection

This commit is contained in:
matt 2025-10-03 10:43:24 -07:00
parent 3a1b011dbc
commit 9428e09cef
39 changed files with 3643 additions and 198 deletions

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