major-domo-v2/utils/autocomplete.py
Cal Corum c8ed4dee38
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s
perf: cache user team lookup in player_autocomplete, reduce limit to 25
Closes #99

- Add module-level `_user_team_cache` dict with 60-second TTL so
  `get_user_major_league_team` is called at most once per minute per
  user instead of on every keystroke.
- Reduce `search_players(limit=50)` to `limit=25` to match Discord's
  25-choice display cap and avoid fetching unused results.
- Add `TestGetCachedUserTeam` class covering cache hit, TTL expiry, and
  None caching; add `clear_user_team_cache` autouse fixture to prevent
  test interference via module-level state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 08:34:32 -05:00

195 lines
6.1 KiB
Python

"""
Autocomplete Utilities
Shared autocomplete functions for Discord slash commands.
"""
import time
from typing import Dict, List, Optional, Tuple
import discord
from discord import app_commands
from config import get_config
from models.team import RosterType, Team
from services.player_service import player_service
from services.team_service import team_service
from utils.team_utils import get_user_major_league_team
# Cache for user team lookups: user_id -> (team, cached_at)
_user_team_cache: Dict[int, Tuple[Optional[Team], float]] = {}
_USER_TEAM_CACHE_TTL = 60 # seconds
async def _get_cached_user_team(interaction: discord.Interaction) -> Optional[Team]:
"""Return the user's major league team, cached for 60 seconds per user."""
user_id = interaction.user.id
if user_id in _user_team_cache:
team, cached_at = _user_team_cache[user_id]
if time.time() - cached_at < _USER_TEAM_CACHE_TTL:
return team
team = await get_user_major_league_team(user_id)
_user_team_cache[user_id] = (team, time.time())
return team
async def player_autocomplete(
interaction: discord.Interaction, current: str
) -> List[app_commands.Choice[str]]:
"""
Autocomplete for player names with team context prioritization.
Prioritizes players from the user's team first, then shows other players.
Args:
interaction: Discord interaction object
current: Current input from user
Returns:
List of player name choices (user's team players first)
"""
if len(current) < 2:
return []
try:
# Get user's team for prioritization (cached per user, 60s TTL)
user_team = await _get_cached_user_team(interaction)
# Search for players using the search endpoint
players = await player_service.search_players(
current, limit=25, season=get_config().sba_season
)
# Separate players by team (user's team vs others)
user_team_players = []
other_players = []
for player in players:
# Check if player belongs to user's team (any roster section)
is_users_player = False
if user_team and hasattr(player, "team") and player.team:
# Check if player is from user's major league team or has same base team
if player.team.id == user_team.id or (
hasattr(player, "team_id") and player.team_id == user_team.id
):
is_users_player = True
if is_users_player:
user_team_players.append(player)
else:
other_players.append(player)
# Format choices with team context
choices = []
# Add user's team players first (prioritized)
for player in user_team_players[:15]: # Limit user team players
team_info = f"{player.primary_position}"
if hasattr(player, "team") and player.team:
team_info += f" - {player.team.abbrev}"
choice_name = f"{player.name} ({team_info})"
choices.append(app_commands.Choice(name=choice_name, value=player.name))
# Add other players (remaining slots)
remaining_slots = 25 - len(choices)
for player in other_players[:remaining_slots]:
team_info = f"{player.primary_position}"
if hasattr(player, "team") and player.team:
team_info += f" - {player.team.abbrev}"
choice_name = f"{player.name} ({team_info})"
choices.append(app_commands.Choice(name=choice_name, value=player.name))
return choices
except Exception:
# Silently fail on autocomplete errors to avoid disrupting user experience
return []
async def team_autocomplete(
interaction: discord.Interaction, current: str
) -> List[app_commands.Choice[str]]:
"""
Autocomplete for team abbreviations.
Args:
interaction: Discord interaction object
current: Current input from user
Returns:
List of team abbreviation choices
"""
if len(current) < 1:
return []
try:
# Get all teams for current season
teams = await team_service.get_teams_by_season(get_config().sba_season)
# Filter teams by current input and limit to 25
matching_teams = [
team
for team in teams
if current.lower() in team.abbrev.lower()
or current.lower() in team.sname.lower()
][:25]
choices = []
for team in matching_teams:
choice_name = f"{team.abbrev} - {team.sname}"
choices.append(app_commands.Choice(name=choice_name, value=team.abbrev))
return choices
except Exception:
# Silently fail on autocomplete errors
return []
async def major_league_team_autocomplete(
interaction: discord.Interaction, current: str
) -> List[app_commands.Choice[str]]:
"""
Autocomplete for Major League team abbreviations only.
Used for trade commands where only ML team owners should be able to initiate trades.
Args:
interaction: Discord interaction object
current: Current input from user
Returns:
List of Major League team abbreviation choices
"""
if len(current) < 1:
return []
try:
# Get all teams for current season
all_teams = await team_service.get_teams_by_season(get_config().sba_season)
# Filter to only Major League teams using the model's helper method
ml_teams = [
team for team in all_teams if team.roster_type() == RosterType.MAJOR_LEAGUE
]
# Filter teams by current input and limit to 25
matching_teams = [
team
for team in ml_teams
if current.lower() in team.abbrev.lower()
or current.lower() in team.sname.lower()
][:25]
choices = []
for team in matching_teams:
choice_name = f"{team.abbrev} - {team.sname}"
choices.append(app_commands.Choice(name=choice_name, value=team.abbrev))
return choices
except Exception:
# Silently fail on autocomplete errors
return []