mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
160 lines
6 KiB
Python
160 lines
6 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Iterable, List, Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from deck_builder.combined_commander import PartnerMode
|
|
|
|
from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS
|
|
from ..services.partner_suggestions import get_partner_suggestions
|
|
from ..services.telemetry import log_partner_suggestions_generated
|
|
|
|
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
|
|
|
|
|
|
def _parse_modes(values: Optional[Iterable[str]]) -> list[PartnerMode]:
|
|
if not values:
|
|
return []
|
|
modes: list[PartnerMode] = []
|
|
seen: set[str] = set()
|
|
for value in values:
|
|
if not value:
|
|
continue
|
|
normalized = str(value).strip().replace("-", "_").lower()
|
|
if not normalized or normalized in seen:
|
|
continue
|
|
seen.add(normalized)
|
|
for mode in PartnerMode:
|
|
if mode.value == normalized:
|
|
modes.append(mode)
|
|
break
|
|
return modes
|
|
|
|
|
|
def _coerce_name_list(values: Optional[Iterable[str]]) -> list[str]:
|
|
if not values:
|
|
return []
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for value in values:
|
|
if value is None:
|
|
continue
|
|
text = str(value).strip()
|
|
if not text:
|
|
continue
|
|
key = text.casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(text)
|
|
return out
|
|
|
|
|
|
@router.get("/suggestions")
|
|
async def partner_suggestions_api(
|
|
request: Request,
|
|
commander: str = Query(..., min_length=1, description="Primary commander display name"),
|
|
limit: int = Query(5, ge=1, le=20, description="Maximum suggestions per partner mode"),
|
|
visible_limit: int = Query(3, ge=0, le=10, description="Number of suggestions to mark as visible"),
|
|
include_hidden: bool = Query(False, description="When true, include hidden suggestions in the response"),
|
|
partner: Optional[List[str]] = Query(None, description="Available partner commander names"),
|
|
background: Optional[List[str]] = Query(None, description="Available background names"),
|
|
mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"),
|
|
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
|
|
):
|
|
if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS):
|
|
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
|
|
|
|
commander_name = (commander or "").strip()
|
|
if not commander_name:
|
|
raise HTTPException(status_code=400, detail="Commander name is required")
|
|
|
|
include_modes = _parse_modes(mode)
|
|
result = get_partner_suggestions(
|
|
commander_name,
|
|
limit_per_mode=limit,
|
|
include_modes=include_modes or None,
|
|
refresh_dataset=refresh,
|
|
)
|
|
if result is None:
|
|
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
|
|
|
|
partner_names = _coerce_name_list(partner)
|
|
background_names = _coerce_name_list(background)
|
|
|
|
# If the client didn't provide select options, fall back to the suggestions themselves.
|
|
if not partner_names:
|
|
for key, entries in result.by_mode.items():
|
|
if key == PartnerMode.BACKGROUND.value:
|
|
continue
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name_value = entry.get("name")
|
|
if isinstance(name_value, str) and name_value.strip():
|
|
partner_names.append(name_value)
|
|
if not background_names:
|
|
background_entries = result.by_mode.get(PartnerMode.BACKGROUND.value, [])
|
|
for entry in background_entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name_value = entry.get("name")
|
|
if isinstance(name_value, str) and name_value.strip():
|
|
background_names.append(name_value)
|
|
|
|
partner_names = _coerce_name_list(partner_names)
|
|
background_names = _coerce_name_list(background_names)
|
|
|
|
visible, hidden = result.flatten(partner_names, background_names, visible_limit=visible_limit)
|
|
visible_count = len(visible)
|
|
hidden_count = len(hidden)
|
|
if include_hidden:
|
|
combined_visible = visible + hidden
|
|
remaining = []
|
|
else:
|
|
combined_visible = visible
|
|
remaining = hidden
|
|
|
|
payload = {
|
|
"commander": {
|
|
"display_name": result.display_name,
|
|
"canonical": result.canonical,
|
|
},
|
|
"metadata": result.metadata,
|
|
"modes": result.by_mode,
|
|
"visible": combined_visible,
|
|
"hidden": remaining,
|
|
"total": result.total,
|
|
"limit": {
|
|
"per_mode": limit,
|
|
"visible": visible_limit,
|
|
},
|
|
"available_modes": [mode_key for mode_key, entries in result.by_mode.items() if entries],
|
|
"has_hidden": bool(remaining),
|
|
}
|
|
|
|
headers = {"Cache-Control": "no-store"}
|
|
try:
|
|
mode_counts = {mode_key: len(entries) for mode_key, entries in result.by_mode.items()}
|
|
available_modes = [mode_key for mode_key, count in mode_counts.items() if count]
|
|
log_partner_suggestions_generated(
|
|
request,
|
|
commander_display=result.display_name,
|
|
commander_canonical=result.canonical,
|
|
include_modes=[mode.value for mode in include_modes] if include_modes else [],
|
|
available_modes=available_modes,
|
|
total=result.total,
|
|
mode_counts=mode_counts,
|
|
visible_count=visible_count,
|
|
hidden_count=hidden_count,
|
|
limit_per_mode=limit,
|
|
visible_limit=visible_limit,
|
|
include_hidden=include_hidden,
|
|
refresh_requested=refresh,
|
|
dataset_metadata=result.metadata,
|
|
)
|
|
except Exception: # pragma: no cover - telemetry should not break responses
|
|
pass
|
|
return JSONResponse(payload, headers=headers)
|