Add AI-powered intelligent player selector using Gemini#211
Add AI-powered intelligent player selector using Gemini#211
Conversation
- Create ai_player_matcher.py with AI-powered name matching - Add /api/match-players endpoint for player name similarity search - Add import list button and modal to armar_equipos page - Add CSS styles for import modal and results display - Implement JavaScript logic for importing and selecting players Co-authored-by: fedecarboni7 <37885214+fedecarboni7@users.noreply.github.com>
Co-authored-by: fedecarboni7 <37885214+fedecarboni7@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements an AI-powered player import feature that uses Google's Gemini API to intelligently match user-provided names (including nicknames, abbreviations, and partial names) against existing players in the system. The feature adds a convenient "Importar Lista" button to the team builder page, allowing users to paste a list of names and automatically select matching players.
Changes:
- Added AI-powered name matching using LangChain and Gemini 2.0 Flash Lite model
- Created new
/api/match-playersendpoint to handle matching requests - Implemented import modal UI with textarea input, confidence indicators, and selection checkboxes
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
app/utils/ai_player_matcher.py |
New utility module implementing LangChain/Gemini integration for intelligent name matching with confidence levels |
app/routes/main_routes.py |
Added /api/match-players endpoint that accepts input names and returns matched players with confidence indicators |
templates/armar_equipos.html |
Added import modal HTML structure with textarea, results display, and action buttons |
static/js/armar_equipos.js |
Implemented modal controls, API integration, results rendering, and player selection application logic |
static/css/armar_equipos.css |
Added comprehensive styling for import button, modal, loading states, confidence indicators, and responsive design |
| <textarea id="import-players-textarea" placeholder="Escribí o pegá los nombres de los jugadores, uno por línea o separados por comas. | ||
|
|
||
| Ejemplo: | ||
| Nahue | ||
| Nico | ||
| Martin | ||
| Pedro"></textarea> |
There was a problem hiding this comment.
The textarea element lacks a label element associated with it. While it has a placeholder, this is not sufficient for accessibility as placeholders disappear when users start typing and aren't always announced by screen readers. Add a label element with a for attribute pointing to the textarea's id, or use an aria-label attribute to provide an accessible name for the textarea.
| # Formatear la lista de jugadores disponibles para el prompt | ||
| players_formatted = "\n".join([f"- ID: {p['id']}, Nombre: {p['name']}" for p in available_players]) | ||
| names_formatted = "\n".join([f"- {name}" for name in input_names]) | ||
|
|
||
| result = await player_matching_chain.ainvoke({ | ||
| "input_names": names_formatted, | ||
| "available_players": players_formatted | ||
| }) |
There was a problem hiding this comment.
When there's a large number of players in the system, formatting all players as a string and sending them in the prompt could exceed token limits or result in expensive API calls. The prompt includes all available players in the system, which could be hundreds or thousands of entries. Consider implementing pagination or limiting the number of players sent to the AI, or using a more efficient matching approach for large player lists (e.g., pre-filtering by first letter or using embeddings).
| async def match_players(input_names: list[str], available_players: list[dict]) -> dict: | ||
| """ | ||
| Usa IA para encontrar coincidencias entre nombres ingresados y jugadores disponibles. | ||
|
|
||
| Args: | ||
| input_names: Lista de nombres ingresados por el usuario | ||
| available_players: Lista de diccionarios con {id, name} de jugadores disponibles | ||
|
|
||
| Returns: | ||
| Dict con matches encontrados y nombres no encontrados | ||
| """ | ||
| # Formatear la lista de jugadores disponibles para el prompt | ||
| players_formatted = "\n".join([f"- ID: {p['id']}, Nombre: {p['name']}" for p in available_players]) | ||
| names_formatted = "\n".join([f"- {name}" for name in input_names]) | ||
|
|
||
| result = await player_matching_chain.ainvoke({ | ||
| "input_names": names_formatted, | ||
| "available_players": players_formatted | ||
| }) | ||
|
|
||
| return result |
There was a problem hiding this comment.
The function lacks validation of the AI response structure. If the LLM returns a malformed JSON or unexpected structure (e.g., missing 'matches' or 'not_found' keys, or incorrect data types for matched_player_id), this will cause runtime errors when the response is processed in the frontend or API endpoint. Consider adding validation to ensure the response contains the expected keys and data types, and handle cases where the AI returns an unexpected format.
| from langchain_core.prompts import PromptTemplate | ||
| from langchain_core.output_parsers import JsonOutputParser | ||
|
|
||
| llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash-lite") |
There was a problem hiding this comment.
The LLM instance is initialized globally, which means it's created once when the module is imported. This approach doesn't allow for configuration changes (like API key rotation) without restarting the application, and it may cause issues if initialization fails during module import. Consider moving the LLM initialization inside the function or using a factory pattern that allows for lazy initialization and better error handling.
|
|
||
| llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash-lite") | ||
|
|
There was a problem hiding this comment.
The model name "gemini-2.0-flash-lite" is hardcoded without configuration option. This makes it difficult to switch models or test with different models without code changes. Consider making this configurable through environment variables or a configuration file, following the pattern used elsewhere in the application.
| llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash-lite") | |
| import os | |
| PLAYER_MATCHER_MODEL_NAME = os.getenv("AI_PLAYER_MATCHER_MODEL", "gemini-2.0-flash-lite") | |
| llm = ChatGoogleGenerativeAI(model=PLAYER_MATCHER_MODEL_NAME) |
| alert('No hay jugadores seleccionados para agregar'); | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
When applying imported players, the current selection is completely cleared and replaced with the imported players. This behavior might be unexpected for users who have already selected some players manually. Consider either appending to the existing selection, or providing a clear warning/confirmation dialog explaining that the current selection will be replaced before proceeding.
| // If there is an existing selection, confirm replacement | |
| if (selectedPlayers.size > 0) { | |
| const shouldReplace = window.confirm( | |
| 'Ya tenés jugadores seleccionados manualmente.\n' + | |
| 'Al aplicar la importación se va a reemplazar la selección actual por los jugadores importados.\n\n' + | |
| '¿Querés continuar y reemplazar la selección actual?' | |
| ); | |
| if (!shouldReplace) { | |
| return; | |
| } | |
| } | |
| @router.post("/api/match-players", response_class=JSONResponse) | ||
| async def match_players_api( | ||
| request: Request, | ||
| db: Session = Depends(get_db), | ||
| current_user: User = Depends(get_current_user) | ||
| ): | ||
| """ | ||
| Endpoint para buscar coincidencias entre nombres de jugadores ingresados | ||
| y jugadores disponibles en el sistema usando IA. | ||
| """ | ||
| if not current_user: | ||
| return JSONResponse(content={"error": "No autenticado"}, status_code=401) | ||
|
|
||
| try: | ||
| data = await request.json() | ||
| input_names = data.get('input_names', []) | ||
| club_id = data.get('club_id') | ||
| scale = data.get('scale', '1-5') | ||
|
|
||
| if not input_names: | ||
| return JSONResponse(content={"error": "No se proporcionaron nombres"}, status_code=400) | ||
|
|
||
| # Obtener jugadores disponibles según el contexto | ||
| current_user_id = current_user.id | ||
|
|
||
| all_players = execute_with_retries(query_players, db, current_user_id, club_id, scale) | ||
|
|
||
| if not all_players: | ||
| return JSONResponse(content={"error": "No hay jugadores disponibles"}, status_code=400) | ||
|
|
||
| # Formatear jugadores para el matcher | ||
| available_players = [{"id": p.id, "name": p.name} for p in all_players] | ||
|
|
||
| # Usar IA para hacer el matching | ||
| result = await match_players(input_names, available_players) | ||
|
|
||
| return JSONResponse(content=result) | ||
|
|
||
| except Exception as e: | ||
| logging.exception("Error matching players: %s", str(e)) | ||
| return JSONResponse(content={"error": "Error al buscar coincidencias de jugadores"}, status_code=500) No newline at end of file |
There was a problem hiding this comment.
The new AI-powered player matching endpoint and functionality lacks test coverage. The repository has comprehensive tests for other endpoints (as seen in test_main_routes.py, test_player_routes.py), but no tests exist for the /api/match-players endpoint or the match_players function. Add tests to verify correct behavior with valid inputs, malformed AI responses, empty player lists, and error scenarios.
| alert('Por favor, ingresá una lista de nombres de jugadores'); | ||
| return; | ||
| } | ||
|
|
||
| // Parse names from text (handle both commas and newlines) | ||
| const inputNames = text | ||
| .split(/[\n,]+/) | ||
| .map(name => name.trim()) | ||
| .filter(name => name.length > 0); | ||
|
|
||
| if (inputNames.length === 0) { | ||
| alert('No se encontraron nombres válidos en la lista'); | ||
| return; | ||
| } | ||
|
|
||
| // Show loading state | ||
| processBtn.disabled = true; | ||
| processBtn.innerHTML = '<span class="spinner"></span> Buscando...'; | ||
| resultsDiv.style.display = 'block'; | ||
| matchesDiv.innerHTML = '<div class="import-loading"><div class="spinner"></div>Buscando coincidencias con IA...</div>'; | ||
| notFoundDiv.style.display = 'none'; | ||
|
|
||
| try { | ||
| // Build request data | ||
| const requestData = { | ||
| input_names: inputNames, | ||
| scale: currentScale === 5 ? '1-5' : '1-10' | ||
| }; | ||
|
|
||
| // Add club_id if using a club context | ||
| const clubId = getCurrentClubId(); | ||
| if (clubId !== 'my-players') { | ||
| requestData.club_id = parseInt(clubId); | ||
| } | ||
|
|
||
| // Call API | ||
| const response = await fetch('/api/match-players', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| credentials: 'include', | ||
| body: JSON.stringify(requestData) | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorData = await response.json(); | ||
| throw new Error(errorData.error || 'Error al buscar coincidencias'); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| importMatchResults = data; | ||
|
|
||
| // Render results | ||
| renderImportResults(data); | ||
|
|
||
| // Show apply button if there are matches | ||
| if (data.matches && data.matches.length > 0) { | ||
| processBtn.style.display = 'none'; | ||
| applyBtn.style.display = 'flex'; | ||
| } | ||
|
|
||
| } catch (error) { | ||
| console.error('Error processing import:', error); | ||
| matchesDiv.innerHTML = `<div style="color: #ff6b6b; padding: 20px; text-align: center;"> | ||
| ❌ ${error.message} | ||
| </div>`; | ||
| } finally { | ||
| processBtn.disabled = false; | ||
| processBtn.innerHTML = '<i class="fa-solid fa-magnifying-glass"></i> Buscar coincidencias'; | ||
| } | ||
| } | ||
|
|
||
| function renderImportResults(data) { | ||
| const matchesDiv = document.getElementById('import-matches'); | ||
| const notFoundDiv = document.getElementById('import-not-found'); | ||
| const notFoundList = document.getElementById('not-found-list'); | ||
|
|
||
| // Clear previous results | ||
| matchesDiv.innerHTML = ''; | ||
|
|
||
| // Render matches | ||
| if (data.matches && data.matches.length > 0) { | ||
| data.matches.forEach((match, index) => { | ||
| const confidenceClass = match.confidence === 'low' ? 'low-confidence' : ''; | ||
| const matchItem = document.createElement('div'); | ||
| matchItem.className = `import-match-item ${confidenceClass}`; | ||
| matchItem.innerHTML = ` | ||
| <div class="import-match-info"> | ||
| <span class="import-match-input">"${escapeHTML(match.input_name)}" →</span> | ||
| <span class="import-match-result">${escapeHTML(match.matched_player_name)}</span> | ||
| </div> | ||
| <div style="display: flex; align-items: center; gap: 10px;"> | ||
| <span class="import-match-confidence ${match.confidence}">${getConfidenceLabel(match.confidence)}</span> | ||
| <input type="checkbox" class="import-match-checkbox" data-player-id="${match.matched_player_id}" data-player-name="${escapeHTML(match.matched_player_name)}" checked> | ||
| </div> | ||
| `; | ||
| matchesDiv.appendChild(matchItem); | ||
| }); | ||
| } else { | ||
| matchesDiv.innerHTML = '<div style="color: #999; padding: 20px; text-align: center;">No se encontraron coincidencias</div>'; | ||
| } | ||
|
|
||
| // Render not found | ||
| if (data.not_found && data.not_found.length > 0) { | ||
| notFoundDiv.style.display = 'block'; | ||
| notFoundList.innerHTML = ''; | ||
| data.not_found.forEach(name => { | ||
| const li = document.createElement('li'); | ||
| li.textContent = name; | ||
| notFoundList.appendChild(li); | ||
| }); | ||
| } else { | ||
| notFoundDiv.style.display = 'none'; | ||
| } | ||
| } | ||
|
|
||
| function getConfidenceLabel(confidence) { | ||
| switch (confidence) { | ||
| case 'high': return '✓ Seguro'; | ||
| case 'medium': return '~ Probable'; | ||
| case 'low': return '? Posible'; | ||
| default: return confidence; | ||
| } | ||
| } | ||
|
|
||
| function applyImportedPlayers() { | ||
| // Get all checked matches | ||
| const checkboxes = document.querySelectorAll('.import-match-checkbox:checked'); | ||
|
|
||
| if (checkboxes.length === 0) { | ||
| alert('No hay jugadores seleccionados para agregar'); | ||
| return; | ||
| } | ||
|
|
||
| // Clear current selection | ||
| selectedPlayers.clear(); | ||
|
|
||
| // Add matched players to selection | ||
| checkboxes.forEach(checkbox => { | ||
| const playerName = checkbox.dataset.playerName; | ||
| if (playerName) { | ||
| selectedPlayers.add(playerName); | ||
| } | ||
| }); | ||
|
|
||
| // Render updated player list | ||
| renderPlayers(); | ||
|
|
||
| // Close modal | ||
| closeImportModal(); | ||
|
|
||
| // Show success message | ||
| const count = selectedPlayers.size; | ||
| const notFoundCount = importMatchResults?.not_found?.length || 0; | ||
|
|
||
| let message = `✅ Se seleccionaron ${count} jugador${count !== 1 ? 'es' : ''}`; | ||
| if (notFoundCount > 0) { | ||
| message += `\n⚠️ ${notFoundCount} nombre${notFoundCount !== 1 ? 's' : ''} no ${notFoundCount !== 1 ? 'fueron encontrados' : 'fue encontrado'}`; | ||
| } | ||
|
|
||
| alert(message); |
There was a problem hiding this comment.
The alert() function is used for user feedback, which is considered poor UX in modern web applications. Consider replacing alert() calls with a more user-friendly notification system (e.g., toast notifications or inline messages) that doesn't block the UI and provides a better user experience.
| <div class="import-modal-content"> | ||
| <div class="import-modal-header"> | ||
| <h2><i class="fa-solid fa-wand-magic-sparkles"></i> Importar Lista con IA</h2> | ||
| <button class="close-btn" id="close-import-modal">×</button> |
There was a problem hiding this comment.
The close button uses the HTML entity × without an accessible label or aria-label. Screen reader users won't know what this button does. Add an aria-label attribute (e.g., aria-label="Cerrar modal") to make the button accessible to users with screen readers.
| <button class="close-btn" id="close-import-modal">×</button> | |
| <button class="close-btn" id="close-import-modal" aria-label="Cerrar modal">×</button> |
| const resultsDiv = document.getElementById('import-results'); | ||
| const matchesDiv = document.getElementById('import-matches'); | ||
| const notFoundDiv = document.getElementById('import-not-found'); | ||
| const notFoundList = document.getElementById('not-found-list'); |
There was a problem hiding this comment.
Unused variable notFoundList.
| const notFoundList = document.getElementById('not-found-list'); |
Implements an AI-powered player import feature that matches user-provided names (nicknames, abbreviations, partial names) against existing players using Gemini's similarity matching.
Changes
Backend
app/utils/ai_player_matcher.py- LangChain/Gemini integration for name matching with confidence levels (high/medium/low)/api/match-playersendpoint inmain_routes.py- accepts input names, returns matches and unmatched namesFrontend
Usage
Screenshots
Import Button

Import Modal

Loading State

Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
api.sendinblue.com/home/REDACTED/.local/bin/uvicorn uvicorn app.main:app --host 127.0.0.1 --port 8000(dns block)/usr/bin/python python -m pytest tests/ -v(dns block)generativelanguage.googleapis.com/usr/bin/python python -m uvicorn app.main:app --host 127.0.0.1 --port 8000(dns block)If you need me to access, download, or install something from one of these locations, you can either:
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.