Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import pandas as pd | |
| import numpy as np | |
| import joblib | |
| import shap | |
| import os | |
| import base64 | |
| import random | |
| import tempfile | |
| import plotly.graph_objects as go | |
| from io import BytesIO | |
| # ============================================================================== | |
| # 1. KNOWLEDGE BASE & MAPPINGS | |
| # ============================================================================== | |
| CODE_TO_NAME = { | |
| 'ENG': 'England', 'FRA': 'France', 'ESP': 'Spain', 'ITA': 'Italy', 'GER': 'Germany', | |
| 'NED': 'Netherlands', 'POR': 'Portugal', 'BRA': 'Brazil', 'ARG': 'Argentina', | |
| 'BEL': 'Belgium', 'CRO': 'Croatia', 'URU': 'Uruguay', 'USA': 'USA', | |
| 'TUR': 'Türkiye', 'COD': 'Congo DR', 'GAM': 'The Gambia', 'IRN': 'IR Iran', | |
| 'KOR': 'Korea Republic', 'CHN': 'China PR', 'KGZ': 'Kyrgyz Republic', 'CIV': 'Côte d\'Ivoire', | |
| 'MAR': 'Morocco', 'COL': 'Colombia', 'MEX': 'Mexico', 'JPN': 'Japan', 'SUI': 'Switzerland', | |
| 'DEN': 'Denmark', 'SEN': 'Senegal', 'AUS': 'Australia', 'SWE': 'Sweden', 'HUN': 'Hungary', | |
| 'TUN': 'Tunisia', 'WAL': 'Wales', 'ALG': 'Algeria', 'POL': 'Poland', 'ECU': 'Ecuador', | |
| 'EGY': 'Egypt', 'SRB': 'Serbia', 'SCO': 'Scotland', 'CZE': 'Czechia', 'AUT': 'Austria', | |
| 'NOR': 'Norway', 'NGA': 'Nigeria', 'CMR': 'Cameroon', 'CAN': 'Canada', 'GRE': 'Greece', | |
| 'MLI': 'Mali', 'CRC': 'Costa Rica', 'KSA': 'Saudi Arabia', 'RSA': 'South Africa', | |
| 'GHA': 'Ghana', 'SVK': 'Slovakia', 'FIN': 'Finland', 'IRL': 'Republic of Ireland', | |
| 'NIR': 'Northern Ireland', 'BIH': 'Bosnia and Herzegovina', 'ISL': 'Iceland', | |
| 'UKR': 'Ukraine', 'SVN': 'Slovenia', 'ALB': 'Albania', 'MKD': 'North Macedonia', | |
| 'MNE': 'Montenegro', 'JAM': 'Jamaica', 'QAT': 'Qatar', 'BFA': 'Burkina Faso', | |
| 'GUI': 'Guinea', 'GAB': 'Gabon', 'ZAM': 'Zambia', 'HON': 'Honduras', 'ESA': 'El Salvador', | |
| 'PAN': 'Panama', 'NZL': 'New Zealand', 'GEO': 'Georgia', 'ROU': 'Romania', | |
| 'VEN': 'Venezuela', 'PAR': 'Paraguay', 'CHI': 'Chile', 'PER': 'Peru', 'BOL': 'Bolivia', | |
| 'UZB': 'Uzbekistan', 'IRQ': 'Iraq', 'UAE': 'UAE', 'OMA': 'Oman', 'JOR': 'Jordan', | |
| 'BHR': 'Bahrain', 'SYR': 'Syria', 'PLE': 'Palestine', 'VIE': 'Vietnam', 'THA': 'Thailand', | |
| 'LBN': 'Lebanon', 'IND': 'India', 'TJK': 'Tajikistan', 'SLE': 'Sierra Leone', | |
| 'TOG': 'Togo', 'BEN': 'Benin', 'CGO': 'Congo', 'UGA': 'Uganda', 'CPV': 'Cabo Verde', | |
| 'GNB': 'Guinea-Bissau', 'EQG': 'Equatorial Guinea', 'ANG': 'Angola', 'MOZ': 'Mozambique', | |
| 'NAM': 'Namibia', 'ZIM': 'Zimbabwe', 'MAD': 'Madagascar', 'MWI': 'Malawi', 'KEN': 'Kenya', | |
| 'LBY': 'Libya', 'SDN': 'Sudan', 'ETH': 'Ethiopia', 'RWA': 'Rwanda', 'TAN': 'Tanzania', | |
| 'BDI': 'Burundi', 'LBR': 'Liberia', 'CTA': 'Central African Republic', 'COM': 'Comoros', | |
| 'SEY': 'Seychelles', 'LES': 'Lesotho', 'SWZ': 'Eswatini', 'BOT': 'Botswana', | |
| 'MRI': 'Mauritius', 'HAI': 'Haiti', 'TRI': 'Trinidad and Tobago', 'GUA': 'Guatemala', | |
| 'SKN': 'St Kitts and Nevis', 'SUR': 'Suriname', 'GUY': 'Guyana', 'DOM': 'Dominican Republic', | |
| 'CUB': 'Cuba', 'PUR': 'Puerto Rico', 'GRN': 'Grenada', 'BRB': 'Barbados', | |
| 'VIN': 'St Vincent and the Grenadines', 'LCA': 'St Lucia', 'DMA': 'Dominica', | |
| 'MSR': 'Montserrat', 'BER': 'Bermuda', 'CYP': 'Cyprus', 'ISR': 'Israel', 'ARM': 'Armenia', | |
| 'BLR': 'Belarus', 'KAZ': 'Kazakhstan', 'AZE': 'Azerbaijan', 'EST': 'Estonia', | |
| 'LTU': 'Lithuania', 'LVA': 'Latvia', 'FRO': 'Faroe Islands', 'LUX': 'Luxembourg', | |
| 'MDA': 'Moldova', 'MLT': 'Malta', 'AND': 'Andorra', 'SMR': 'San Marino', 'GIB': 'Gibraltar', | |
| 'LIE': 'Liechtenstein', 'RUS': 'Russia', 'KVX': 'Kosovo', 'KOS': 'Kosovo', | |
| 'BUL': 'Bulgaria', 'IDN': 'Indonesia', 'PHI': 'Philippines', 'MAS': 'Malaysia', | |
| 'CHA': 'Chad', 'CUW': 'Curaçao' | |
| } | |
| LEAGUE_MAP = { | |
| 'Premier League': 0.8263, 'Serie A': 0.4332, 'Bundesliga': 0.3599, 'La Liga': 0.3448, | |
| 'Ligue 1': 0.2680, 'Brazilian Serie A': 0.1337, 'Mexican Liga MX': 0.0813, | |
| 'Primeira Liga': 0.0703, 'Saudi Pro League': 0.0639, 'Eredivise': 0.0571, | |
| 'Argentine Primera': 0.0418, 'Japanese J1 League': 0.0106, 'Betway Premiership': 0.0000 | |
| } | |
| try: | |
| fifa_scores = pd.read_csv('fifa_scores.csv') | |
| NATION_MAP = fifa_scores.set_index('FIFA_Nation')['Total Points'].to_dict() | |
| except Exception: | |
| NATION_MAP = {'England': 1800.0, 'France': 1845.4} | |
| PRETTY_NAMES = { | |
| 'Age': 'Player Age', 'GLVS': 'League Prestige', 'FIFA_Points': 'National Team Quality', | |
| 'Min': 'Minutes Played', 'Gls_per_90': 'Goals (per 90)', 'Ast_per_90': 'Assists (per 90)', | |
| 'xG_per_90': 'Goal Threat (xG)', 'xAG_per_90': 'Playmaking (xAG)', | |
| 'npxG_per_90': 'Non-Pen Goal Threat (npxG)', | |
| 'PrgP_per_90': 'Prog. Passes (per 90)', 'PrgR_per_90': 'Prog. Runs (per 90)', | |
| 'PrgC_per_90': 'Prog. Carries (per 90)', 'TklW_per_90': 'Tackles Won (per 90)', | |
| 'Int_per_90': 'Interceptions (per 90)', 'CrdY_per_90': 'Yellow Cards (per 90)', | |
| 'CrdR_per_90': 'Red Cards (per 90)', 'Fld_per_90': 'Fouls Drawn (per 90)', | |
| 'G+A_per_90': 'Goals + Assists (per 90)', | |
| 'Pos_Group_Forwards': 'Position: Forward', 'Pos_Group_Midfielders': 'Position: Midfielder', | |
| 'Pos_Group_Defenders': 'Position: Defender', 'Pos_Group_Goalkeepers': 'Position: Goalkeeper', | |
| 'Player_Type_De_0': 'Role: Veteran Stopper', 'Player_Type_De_1': 'Role: Dev. Defender', | |
| 'Player_Type_De_2': 'Role: Ball-Playing Defender', | |
| 'Player_Type_Fo_0': 'Role: Wide Attacker', 'Player_Type_Fo_1': 'Role: Veteran Poacher', | |
| 'Player_Type_Fo_2': 'Role: Complete Forward', | |
| 'Player_Type_Mi_0': 'Role: Dev. Midfielder', 'Player_Type_Mi_1': 'Role: Deep-Lying Playmaker', | |
| 'Player_Type_Mi_2': 'Role: Adv. Playmaker', | |
| 'Player_Type_N/A': 'Role: Unclassified' | |
| } | |
| ROLE_CHOICES = [ | |
| "Complete Forward", "Veteran Poacher", "Developmental/Wide Attacker", | |
| "Advanced Playmaker", "Deep-Lying Playmaker", "Developmental Midfielder", | |
| "Ball-Playing Defender", "Veteran Stopper", "Developmental Defender", | |
| "Standard Goalkeeper" | |
| ] | |
| ROLE_TO_CLUSTER = { | |
| 'Complete Forward': 'Fo_2', 'Veteran Poacher': 'Fo_1', 'Developmental/Wide Attacker': 'Fo_0', | |
| 'Advanced Playmaker': 'Mi_2', 'Deep-Lying Playmaker': 'Mi_1', 'Developmental Midfielder': 'Mi_0', | |
| 'Ball-Playing Defender': 'De_2', 'Veteran Stopper': 'De_0', 'Developmental Defender': 'De_1', | |
| 'Standard Goalkeeper': 'N/A' | |
| } | |
| # ============================================================================== | |
| # 2. DYNAMIC LOADING | |
| # ============================================================================== | |
| try: | |
| xgb_pipeline_full = joblib.load('xgb_pipeline_full.joblib') | |
| xgb_pipeline_blind = joblib.load('xgb_pipeline_blind.joblib') | |
| preprocessor_full = xgb_pipeline_full.named_steps['preprocessor'] | |
| xgb_model_full = xgb_pipeline_full.named_steps['regressor'] | |
| preprocessor_blind = xgb_pipeline_blind.named_steps['preprocessor'] | |
| xgb_model_blind = xgb_pipeline_blind.named_steps['regressor'] | |
| PIPELINE_FEATURES = ( | |
| list(preprocessor_full.feature_names_in_) | |
| if hasattr(preprocessor_full, 'feature_names_in_') else [] | |
| ) | |
| GLOBAL_SHAP_EXPLAINER = shap.TreeExplainer(xgb_model_full) | |
| except Exception: | |
| PIPELINE_FEATURES = [] | |
| GLOBAL_SHAP_EXPLAINER = None | |
| try: | |
| db = pd.read_csv('app_database.csv') | |
| all_leagues = sorted(list(LEAGUE_MAP.keys())) | |
| db_qualified = db[db['Min'] >= 450].copy() | |
| if 'Nation' in db.columns: | |
| all_nations = sorted(list(set( | |
| [CODE_TO_NAME.get(code, code) for code in db['Nation'].dropna().unique()] | |
| ))) | |
| else: | |
| all_nations = sorted(list(NATION_MAP.keys())) | |
| if "England" not in all_nations: | |
| all_nations.append("England") | |
| POS_MEDIANS = {} | |
| _median_cols = ['Save%', 'CS%', 'PK_per_90', 'CrdR_per_90', 'Fld_per_90', | |
| 'Off_per_90', 'OG_per_90', 'Saves_per_90', 'CS_per_90'] | |
| for pos_group in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: | |
| pos_data = db_qualified[db_qualified['Pos_Group'] == pos_group] | |
| medians = {} | |
| for col in _median_cols: | |
| if col in pos_data.columns: | |
| val = pd.to_numeric(pos_data[col], errors='coerce').median() | |
| medians[col] = val if pd.notna(val) else 0.0 | |
| else: | |
| medians[col] = 0.0 | |
| POS_MEDIANS[pos_group] = medians | |
| if not db_qualified.empty and 'Player' in db_qualified.columns: | |
| _all_names = db_qualified['Player'].dropna().unique() | |
| default_player = str(np.random.choice(_all_names)) | |
| player_count = len(_all_names) | |
| p_a_pos = db_qualified[db_qualified['Player'] == default_player].iloc[0].get('Pos_Group', 'Forwards') | |
| pool_b = db_qualified[(db_qualified['Pos_Group'] == p_a_pos) & (db_qualified['Player'] != default_player)] | |
| default_player_b = ( | |
| str(np.random.choice(pool_b['Player'].dropna().unique())) | |
| if not pool_b.empty else default_player | |
| ) | |
| else: | |
| default_player = "CUSTOM PROFILE" | |
| default_player_b = "CUSTOM PROFILE" | |
| player_count = 0 | |
| except Exception: | |
| db = pd.DataFrame() | |
| db_qualified = pd.DataFrame() | |
| all_nations = ["England", "France", "Brazil"] | |
| default_player = "CUSTOM PROFILE" | |
| default_player_b = "CUSTOM PROFILE" | |
| player_count = 0 | |
| POS_MEDIANS = {} | |
| # ============================================================================== | |
| # 2b. RADAR LIMITS (95th percentile per position) | |
| # ============================================================================== | |
| RADAR_COLS = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', | |
| 'PrgP_per_90', 'PrgC_per_90', 'PrgR_per_90', 'TklW_per_90', 'Int_per_90'] | |
| _RADAR_FALLBACK = [1.0, 0.5, 1.0, 0.5, 10.0, 8.0, 6.0, 4.0, 3.0] | |
| POS_RADAR_LIMITS = {} | |
| for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: | |
| _f = db_qualified[db_qualified['Pos_Group'] == _pg] if not db_qualified.empty else pd.DataFrame() | |
| POS_RADAR_LIMITS[_pg] = [ | |
| max(pd.to_numeric(_f[c], errors='coerce').quantile(0.95), 0.01) | |
| if (not _f.empty and c in _f.columns) else fb | |
| for c, fb in zip(RADAR_COLS, _RADAR_FALLBACK) | |
| ] | |
| POS_RADAR_LIMITS['Combined'] = [ | |
| max(POS_RADAR_LIMITS[_pg][i] for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']) | |
| for i in range(9) | |
| ] | |
| # ============================================================================== | |
| # 2c. PERCENTILE CACHE (computed once at startup) | |
| # ============================================================================== | |
| PERCENTILE_COLS = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', | |
| 'PrgP_per_90', 'TklW_per_90', 'Int_per_90'] | |
| PERCENTILE_PRETTY = { | |
| 'Gls_per_90': 'Goals', 'Ast_per_90': 'Assists', 'xG_per_90': 'xG', | |
| 'xAG_per_90': 'xAG', 'PrgP_per_90': 'Prog Passes', | |
| 'TklW_per_90': 'Tackles', 'Int_per_90': 'Interceptions' | |
| } | |
| PERCENTILE_CACHE = {} | |
| if not db_qualified.empty: | |
| for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: | |
| PERCENTILE_CACHE[_pg] = {} | |
| _subset = db_qualified[db_qualified['Pos_Group'] == _pg] | |
| for _col in PERCENTILE_COLS: | |
| if _col in _subset.columns: | |
| PERCENTILE_CACHE[_pg][_col] = np.sort( | |
| pd.to_numeric(_subset[_col], errors='coerce').dropna().values | |
| ) | |
| else: | |
| PERCENTILE_CACHE[_pg][_col] = np.array([]) | |
| def get_percentile(value, pos_group, col): | |
| arr = PERCENTILE_CACHE.get(pos_group, {}).get(col, np.array([])) | |
| if len(arr) == 0: | |
| return None | |
| return int(np.searchsorted(arr, value) / len(arr) * 100) | |
| def build_percentile_html(gls, ast, xg, xag, prgp, tklw, intc, pos_txt): | |
| stat_map = { | |
| 'Gls_per_90': gls, 'Ast_per_90': ast, 'xG_per_90': xg, | |
| 'xAG_per_90': xag, 'PrgP_per_90': prgp, | |
| 'TklW_per_90': tklw, 'Int_per_90': intc | |
| } | |
| # Filter to position-relevant stats | |
| pos_relevant = { | |
| 'Forwards': ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90'], | |
| 'Midfielders': ['Ast_per_90', 'xAG_per_90', 'PrgP_per_90', 'TklW_per_90', 'Int_per_90'], | |
| 'Defenders': ['TklW_per_90', 'Int_per_90', 'PrgP_per_90'], | |
| 'Goalkeepers': ['TklW_per_90', 'Int_per_90'], | |
| } | |
| relevant = pos_relevant.get(pos_txt, list(stat_map.keys())) | |
| badges = [] | |
| for col in relevant: | |
| val = stat_map.get(col) | |
| if val is None: | |
| continue | |
| pct = get_percentile(val, pos_txt, col) | |
| if pct is None: | |
| continue | |
| if pct >= 75: | |
| color, bg = '#059669', '#ecfdf5' | |
| tier = 'Top 25%' | |
| elif pct >= 40: | |
| color, bg = '#d97706', '#fffbeb' | |
| tier = f'{pct}th %ile' | |
| else: | |
| color, bg = '#dc2626', '#fef2f2' | |
| tier = f'{pct}th %ile' | |
| label = PERCENTILE_PRETTY.get(col, col) | |
| badges.append( | |
| f"<div style='display:inline-flex;flex-direction:column;align-items:center;" | |
| f"background:{bg};border:1.5px solid {color}30;border-radius:10px;" | |
| f"padding:6px 12px;margin:3px;min-width:70px;'>" | |
| f"<span style='font-size:13px;font-weight:800;color:{color};'>{tier}</span>" | |
| f"<span style='font-size:10px;font-weight:600;color:#64748b;margin-top:1px;'>{label}</span>" | |
| f"</div>" | |
| ) | |
| if not badges: | |
| return "" | |
| return ( | |
| "<div style='margin-top:12px;'>" | |
| "<div style='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;" | |
| "color:#94a3b8;margin-bottom:6px;'>Percentile vs Position Peers</div>" | |
| "<div style='display:flex;flex-wrap:wrap;gap:2px;'>" | |
| + "".join(badges) + | |
| "</div></div>" | |
| ) | |
| # ============================================================================== | |
| # 3. PLAYER LIST & SEARCH | |
| # ============================================================================== | |
| ALL_PLAYER_NAMES = sorted(db_qualified['Player'].unique().tolist()) if not db_qualified.empty else [] | |
| _DROPDOWN_CAP = 20 | |
| def filter_players_tab1(query: str) -> list: | |
| if not query or not query.strip(): | |
| results = ALL_PLAYER_NAMES[:_DROPDOWN_CAP] | |
| else: | |
| q = query.strip().lower() | |
| results = [p for p in ALL_PLAYER_NAMES if q in p.lower()][:_DROPDOWN_CAP] | |
| return ["CUSTOM PROFILE"] + results | |
| def filter_players_tab2(query: str) -> list: | |
| if not query or not query.strip(): | |
| return ALL_PLAYER_NAMES[:_DROPDOWN_CAP] | |
| q = query.strip().lower() | |
| return [p for p in ALL_PLAYER_NAMES if q in p.lower()][:_DROPDOWN_CAP] | |
| _init_tab1 = ["CUSTOM PROFILE"] + ALL_PLAYER_NAMES[:_DROPDOWN_CAP] | |
| if default_player != "CUSTOM PROFILE" and default_player not in _init_tab1: | |
| _init_tab1.append(default_player) | |
| _init_tab2 = ALL_PLAYER_NAMES[:_DROPDOWN_CAP] | |
| if default_player not in _init_tab2: _init_tab2.append(default_player) | |
| if default_player_b not in _init_tab2: _init_tab2.append(default_player_b) | |
| # ============================================================================== | |
| # 4. HELPER FUNCTIONS | |
| # ============================================================================== | |
| def fmt(val): | |
| if abs(val) < 1_000_000: return f"€{val/1000:.0f}K" | |
| return f"€{val/1e6:.1f}M" | |
| def image_to_base64(path): | |
| try: | |
| with open(path, "rb") as f: | |
| return f"data:image/png;base64,{base64.b64encode(f.read()).decode()}" | |
| except Exception: | |
| return None | |
| def generate_badges(league, nation_input): | |
| nation_clean = CODE_TO_NAME.get(nation_input, nation_input) | |
| league_b64 = image_to_base64(os.path.join("assets", "leagues", f"{league}.png")) | |
| nation_b64 = image_to_base64(os.path.join("assets", "flags", f"{nation_clean}.png")) | |
| s = "height:56px;width:auto;object-fit:contain;" | |
| html = '<div style="display:flex;gap:12px;align-items:center;">' | |
| if league_b64: html += f'<img src="{league_b64}" style="{s}" title="{league}">' | |
| if nation_b64: html += f'<img src="{nation_b64}" style="{s}border:1px solid #e2e8f0;border-radius:4px;" title="{nation_clean}">' | |
| return html + "</div>" | |
| def safe_val(val, default, cap): | |
| if pd.isna(val) or val is None: return default | |
| try: return min(float(val), cap) | |
| except: return default | |
| def get_player_data_tuple(name): | |
| _defaults = (24, "Premier League", "England", "Forwards", "Complete Forward", 2000, | |
| 0.4, 0.2, 0.4, 0.2, 4.0, 5.0, 3.0, 1.5, 1.0, 0.1) | |
| if not name or name == "CUSTOM PROFILE" or db.empty: return _defaults | |
| matches = db[db['Player'] == name] | |
| if matches.empty: return _defaults | |
| p = matches.iloc[0] | |
| raw_role = str(p.get('Player_Type', 'N/A')) | |
| role_fallback = { | |
| 'De_0': 'Veteran Stopper', 'De_1': 'Developmental Defender', 'De_2': 'Ball-Playing Defender', | |
| 'Fo_0': 'Developmental/Wide Attacker', 'Fo_1': 'Veteran Poacher', 'Fo_2': 'Complete Forward', | |
| 'Mi_0': 'Developmental Midfielder', 'Mi_1': 'Deep-Lying Playmaker', 'Mi_2': 'Advanced Playmaker', | |
| 'nan': 'Standard Goalkeeper', 'N/A': 'Standard Goalkeeper' | |
| } | |
| safe_role = raw_role if raw_role in ROLE_CHOICES else role_fallback.get(raw_role, "Complete Forward") | |
| if str(p.get('Pos_Group', '')) == 'Goalkeepers': safe_role = 'Standard Goalkeeper' | |
| elif raw_role in ('nan', 'N/A', 'None') or pd.isna(p.get('Player_Type')): safe_role = role_fallback.get('N/A', 'Complete Forward') | |
| n_full = CODE_TO_NAME.get(p.get('Nation'), p.get('Nation')) | |
| pos_grp = str(p.get('Pos_Group', 'Forwards')) | |
| lims = POS_RADAR_LIMITS.get(pos_grp, [1.0, 0.5, 1.0, 0.5, 10.0, 8.0, 6.0, 4.0, 3.0]) | |
| return ( | |
| int(safe_val(p.get('Age'), 24, 40)), | |
| p['league'] if p.get('league') in LEAGUE_MAP else "Premier League", | |
| n_full if n_full in all_nations else "England", | |
| pos_grp, safe_role, | |
| int(safe_val(p.get('Min'), 2000, 3420)), | |
| safe_val(p.get('Gls_per_90'), 0.0, lims[0]), | |
| safe_val(p.get('Ast_per_90'), 0.0, lims[1]), | |
| safe_val(p.get('xG_per_90'), 0.0, lims[2]), | |
| safe_val(p.get('xAG_per_90'), 0.0, lims[3]), | |
| safe_val(p.get('PrgP_per_90'), 0.0, lims[4]), | |
| safe_val(p.get('PrgR_per_90'), 0.0, lims[6]), | |
| safe_val(p.get('PrgC_per_90'), 0.0, lims[5]), | |
| safe_val(p.get('TklW_per_90'), 0.0, lims[7]), | |
| safe_val(p.get('Int_per_90'), 0.0, lims[8]), | |
| safe_val(p.get('CrdY_per_90'), 0.0, 2.0) | |
| ) | |
| def _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): | |
| nation_clean = CODE_TO_NAME.get(nation_txt, nation_txt) | |
| row = pd.DataFrame(index=[0]) | |
| for col in PIPELINE_FEATURES: | |
| row[col] = "Unknown" if col in ('Pos_Group','Player_Type','General_Pos','Nation','league') else 0.0 | |
| if player_name and player_name != "CUSTOM PROFILE" and not db.empty: | |
| matches = db[db['Player'] == player_name] | |
| if not matches.empty: | |
| p = matches.iloc[0] | |
| for col in PIPELINE_FEATURES: | |
| if col in p: row.at[0, col] = p[col] | |
| if (not player_name or player_name == "CUSTOM PROFILE") and pos_txt in POS_MEDIANS: | |
| for col, median_val in POS_MEDIANS[pos_txt].items(): | |
| if col in row.columns: row.at[0, col] = median_val | |
| row.at[0, 'Age'] = age; row.at[0, 'Min'] = mins | |
| row.at[0, 'Gls_per_90'] = gls; row.at[0, 'Ast_per_90'] = ast | |
| row.at[0, 'G+A_per_90'] = gls + ast; row.at[0, 'npxG_per_90'] = xg | |
| row.at[0, 'xAG_per_90'] = xag; row.at[0, 'PrgP_per_90'] = prgp | |
| row.at[0, 'PrgR_per_90'] = prgr; row.at[0, 'PrgC_per_90'] = prgc | |
| row.at[0, 'TklW_per_90'] = tklw; row.at[0, 'Int_per_90'] = intc | |
| row.at[0, 'CrdY_per_90'] = crdy | |
| row.at[0, 'GLVS'] = LEAGUE_MAP.get(league_txt, 0.1) | |
| row.at[0, 'FIFA_Points'] = NATION_MAP.get(nation_clean, 1500) | |
| row.at[0, 'Pos_Group'] = pos_txt | |
| row.at[0, 'Player_Type'] = ROLE_TO_CLUSTER.get(role_txt, 'N/A') | |
| for col in PIPELINE_FEATURES: | |
| if col not in ('Pos_Group','Player_Type','General_Pos','Nation','league'): | |
| row[col] = pd.to_numeric(row[col], errors='coerce').fillna(0.0) | |
| return row, nation_clean | |
| def fast_predict(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): | |
| try: | |
| row, _ = _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy) | |
| val = np.expm1(xgb_pipeline_full.predict(row)[0]) | |
| if val < 1000: val *= 1_000_000 | |
| try: | |
| blind_cols = list(xgb_pipeline_blind.feature_names_in_) | |
| cf = np.expm1(xgb_pipeline_blind.predict(row[blind_cols])[0]) | |
| if cf < 1000: cf *= 1_000_000 | |
| except Exception: cf = 0 | |
| return val, cf | |
| except Exception: return 0, 0 | |
| # ============================================================================== | |
| # 5. PLOTLY CHART BUILDERS | |
| # ============================================================================== | |
| _PLOTLY_LAYOUT = dict( | |
| paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', | |
| font=dict(family="DM Sans, system-ui, sans-serif", color='#334155'), | |
| margin=dict(l=20, r=20, t=30, b=20), | |
| autosize=True | |
| ) | |
| def build_radar_plotly(r_labels, r_vals, r_limits, avg_vals, pos_txt, player_name=None): | |
| n_vals = [min(v/l, 1.0) for v, l in zip(r_vals, r_limits)] | |
| avg_n = [min(v/l, 1.0) for v, l in zip(avg_vals, r_limits)] | |
| hover_player = [f"<b>{lab}</b><br>{v:.2f} per 90<br>({n:.0%} of elite)" for lab, v, n in zip(r_labels, r_vals, n_vals)] | |
| hover_avg = [f"<b>{lab}</b><br>Avg: {v:.2f} per 90" for lab, v in zip(r_labels, avg_vals)] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatterpolar( | |
| r=avg_n + [avg_n[0]], theta=r_labels + [r_labels[0]], | |
| fill='toself', fillcolor='rgba(148,163,184,0.08)', | |
| line=dict(color='#94a3b8', width=1.5, dash='dot'), | |
| name=f'{pos_txt} Avg', hovertext=hover_avg + [hover_avg[0]], hoverinfo='text' | |
| )) | |
| fig.add_trace(go.Scatterpolar( | |
| r=n_vals + [n_vals[0]], theta=r_labels + [r_labels[0]], | |
| fill='toself', fillcolor='rgba(59,130,246,0.15)', | |
| line=dict(color='#3b82f6', width=2.5), | |
| name=player_name or 'Player', hovertext=hover_player + [hover_player[0]], hoverinfo='text', | |
| marker=dict(size=7, color='#1e40af') | |
| )) | |
| fig.update_layout( | |
| **_PLOTLY_LAYOUT, | |
| polar=dict( | |
| bgcolor='rgba(0,0,0,0)', | |
| radialaxis=dict(visible=False, range=[0, 1]), | |
| angularaxis=dict( | |
| tickfont=dict(size=11, color='#475569', family="DM Sans, system-ui, sans-serif"), | |
| gridcolor='#e2e8f0', linecolor='#e2e8f0' | |
| ) | |
| ), | |
| legend=dict(orientation='h', yanchor='bottom', y=-0.15, xanchor='center', x=0.5, font=dict(size=11)), | |
| height=380 | |
| ) | |
| return fig | |
| def build_waterfall_plotly(df_p, base_eur, eur_final): | |
| colors_list = ['#10b981' if imp >= 0 else '#ef4444' for imp in df_p['impact']] | |
| hover_texts = [f"<b>{lab.split(chr(10))[0]}</b><br>Impact: {'+' if imp >= 0 else ''}{fmt(imp)}" for lab, imp in zip(df_p['label'], df_p['impact'])] | |
| clean_labels = [lab.replace('\n', '<br><span style="font-size:10px;color:#94a3b8">') + '</span>' if '\n' in lab else lab for lab in df_p['label']] | |
| fig = go.Figure() | |
| running = base_eur | |
| for i, (_, r) in enumerate(df_p.iterrows()): | |
| imp = r['impact'] | |
| fig.add_trace(go.Bar( | |
| y=[clean_labels[i]], x=[imp], base=[running], orientation='h', | |
| marker=dict(color=colors_list[i], line=dict(width=0), cornerradius=4), | |
| hovertext=hover_texts[i], hoverinfo='text', showlegend=False, | |
| text=f" {'+' if imp >= 0 else ''}{fmt(imp)} ", | |
| textposition='outside', textfont=dict(size=12, color='#1e293b', family="DM Sans, system-ui, sans-serif"), | |
| )) | |
| running += imp | |
| all_x = [base_eur, eur_final] | |
| r2 = base_eur | |
| for _, r in df_p.iterrows(): | |
| all_x += [r2, r2 + r['impact']]; r2 += r['impact'] | |
| xmin, xmax = min(all_x), max(all_x); pad = (xmax - xmin) * 0.4 | |
| fig.add_vline(x=base_eur, line=dict(color='#94a3b8', width=1.5, dash='solid'), | |
| annotation=dict(text=f"Baseline<br>{fmt(base_eur)}", font=dict(size=10, color='#64748b'), showarrow=False, yshift=10)) | |
| fig.add_vline(x=eur_final, line=dict(color='#0f172a', width=2, dash='dash'), | |
| annotation=dict(text=f"Prediction<br><b>{fmt(eur_final)}</b>", font=dict(size=11, color='#0f172a'), showarrow=False, yshift=10)) | |
| fig.update_layout( | |
| **_PLOTLY_LAYOUT, | |
| xaxis=dict(range=[xmin - pad, xmax + pad], showgrid=True, gridcolor='#f1f5f9', | |
| tickfont=dict(size=11), zeroline=False, | |
| tickprefix='€', tickformat=',.0f'), | |
| yaxis=dict(autorange='reversed', tickfont=dict(size=12)), | |
| height=max(300, len(df_p) * 55 + 80), | |
| bargap=0.35, | |
| ) | |
| return fig | |
| def build_comparison_radar_plotly(labels, rva, rvb, limits, name_a, name_b): | |
| na_ = [min(v/l, 1.0) for v, l in zip(rva, limits)] | |
| nb_ = [min(v/l, 1.0) for v, l in zip(rvb, limits)] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatterpolar( | |
| r=na_ + [na_[0]], theta=labels + [labels[0]], | |
| fill='toself', fillcolor='rgba(59,130,246,0.15)', line=dict(color='#3b82f6', width=2.5), | |
| name=name_a, marker=dict(size=6, color='#1e40af') | |
| )) | |
| fig.add_trace(go.Scatterpolar( | |
| r=nb_ + [nb_[0]], theta=labels + [labels[0]], | |
| fill='toself', fillcolor='rgba(249,115,22,0.15)', line=dict(color='#f97316', width=2.5), | |
| name=name_b, marker=dict(size=6, color='#c2410c') | |
| )) | |
| fig.update_layout( | |
| **_PLOTLY_LAYOUT, | |
| polar=dict(bgcolor='rgba(0,0,0,0)', gridshape='circular', | |
| radialaxis=dict(visible=False, range=[0, 1]), | |
| angularaxis=dict(tickfont=dict(size=11, color='#475569'), gridcolor='#e2e8f0', linecolor='#e2e8f0')), | |
| legend=dict(orientation='h', yanchor='bottom', y=-0.15, xanchor='center', x=0.5, font=dict(size=12)), | |
| height=420 | |
| ) | |
| return fig | |
| # ============================================================================== | |
| # 6. PREDICTION FUNCTION | |
| # ============================================================================== | |
| def predict_valuation(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): | |
| row, nation_clean = _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy) | |
| log_val = xgb_pipeline_full.predict(row)[0] | |
| eur_final = np.expm1(log_val) | |
| if eur_final < 1000: eur_final *= 1_000_000 | |
| lower = np.expm1(log_val - 0.58); upper = np.expm1(log_val + 0.58) | |
| if lower < 1000: lower *= 1_000_000 | |
| if upper < 1000: upper *= 1_000_000 | |
| try: | |
| blind_cols = list(xgb_pipeline_blind.feature_names_in_) | |
| eur_cf = np.expm1(xgb_pipeline_blind.predict(row[blind_cols])[0]) | |
| if eur_cf < 1000: eur_cf *= 1_000_000 | |
| gap = eur_final - eur_cf | |
| eur_cf_str = fmt(eur_cf) | |
| if abs(gap) < 500_000: | |
| gap_color, gap_str, gap_exp = "#64748b", "Minimal", "Market and performance valuations are closely aligned." | |
| elif gap > 0: | |
| gap_color = "#10b981" | |
| gap_str = f"+{fmt(gap)}" | |
| gap_exp = f"League prestige and national ranking add {fmt(gap)} to this player's market price." | |
| else: | |
| gap_color = "#ef4444" | |
| gap_str = fmt(gap) | |
| gap_exp = f"League and national standing suppress this player's market price by {fmt(abs(gap))}." | |
| except Exception: | |
| eur_cf = eur_final | |
| eur_cf_str = "N/A"; gap_color = "#64748b"; gap_str = "N/A"; gap_exp = "Performance-only model unavailable." | |
| gap = 0 | |
| display_name = player_name if player_name and player_name != "CUSTOM PROFILE" else "Custom Profile" | |
| badge_html = generate_badges(league_txt, nation_txt) | |
| # ── Percentile badges ── | |
| pct_html = build_percentile_html(gls, ast, xg, xag, prgp, tklw, intc, pos_txt) | |
| # ── Hero card HTML ── | |
| name_html = f""" | |
| <div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;"> | |
| <div style="flex:1;min-width:200px;"> | |
| <div style="font-size:13px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:4px;">Player Report</div> | |
| <div style="font-size:28px;font-weight:800;color:#0f172a;line-height:1.1;letter-spacing:-0.5px;">{display_name}</div> | |
| <div style="font-size:13px;color:#94a3b8;margin-top:4px;">{pos_txt} · {role_txt} · Age {int(age)}</div> | |
| </div> | |
| </div>""" | |
| # ── Valuation card ── | |
| val_html = f""" | |
| <div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:4px;"> | |
| <div style="flex:1;min-width:160px;background:#ffffff;border:2px solid #3b82f6;border-radius:16px;padding:24px;"> | |
| <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#64748b;">Market Valuation</div> | |
| <div style="font-size:36px;font-weight:800;color:#3b82f6;margin-top:6px;letter-spacing:-1px;">{fmt(eur_final)}</div> | |
| <div style="font-size:11px;color:#94a3b8;margin-top:4px;">Range: {fmt(lower)} - {fmt(upper)}</div> | |
| </div> | |
| <div style="flex:1;min-width:160px;background:#ffffff;border:2px solid #0f172a;border-radius:16px;padding:24px;"> | |
| <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#64748b;">Performance-Only</div> | |
| <div style="font-size:11px;color:#94a3b8;">Excludes league & national ranking</div> | |
| <div style="font-size:36px;font-weight:800;color:#0f172a;margin-top:6px;letter-spacing:-1px;">{eur_cf_str}</div> | |
| </div> | |
| <div style="flex:1;min-width:160px;background:#ffffff;border:2px solid {gap_color}40;border-radius:16px;padding:24px;"> | |
| <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#64748b;">Structural Bias Gap</div> | |
| <div style="font-size:36px;font-weight:800;color:{gap_color};margin-top:6px;letter-spacing:-1px;">{gap_str}</div> | |
| <div style="font-size:11px;color:#64748b;margin-top:4px;">{gap_exp}</div> | |
| </div> | |
| </div> | |
| {pct_html}""" | |
| # ── Radar ── | |
| r_labels = ['Goals', 'Assists', 'npxG', 'xAG', 'Prog Passes', 'Prog Carries', 'Prog Runs', 'Tackles Won', 'Interceptions'] | |
| r_limits = POS_RADAR_LIMITS.get(pos_txt, _RADAR_FALLBACK) | |
| r_vals = [gls, ast, xg, xag, prgp, prgc, prgr, tklw, intc] | |
| try: | |
| if not db_qualified.empty and pos_txt in db_qualified['Pos_Group'].values: | |
| pos_data = db_qualified[db_qualified['Pos_Group'] == pos_txt] | |
| radar_db_cols = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90', 'PrgC_per_90', 'PrgR_per_90', 'TklW_per_90', 'Int_per_90'] | |
| avg = [pd.to_numeric(pos_data[c], errors='coerce').mean() if c in pos_data.columns else 0.0 for c in radar_db_cols] | |
| defaults = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] | |
| avg = [a if pd.notna(a) else d for a, d in zip(avg, defaults)] | |
| else: | |
| avg = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] | |
| except Exception: | |
| avg = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] | |
| fig_radar = build_radar_plotly(r_labels, r_vals, r_limits, avg, pos_txt, display_name) | |
| # ── SHAP waterfall ── | |
| arr = preprocessor_full.transform(row) | |
| feat_names = preprocessor_full.get_feature_names_out() | |
| tf_df = pd.DataFrame(arr, columns=feat_names) | |
| explainer = GLOBAL_SHAP_EXPLAINER or shap.TreeExplainer(xgb_model_full) | |
| shap_vals = explainer(tf_df) | |
| top_drivers = [] | |
| try: | |
| base_eur = np.expm1(shap_vals.base_values[0]) | |
| if base_eur < 1000: base_eur *= 1_000_000 | |
| gap_eur = eur_final - base_eur | |
| tot_log = np.sum(shap_vals.values[0]) | |
| labels_w, impacts_w = [], [] | |
| for i, feat_item in enumerate(feat_names): | |
| cf = feat_item.split('__')[-1] | |
| if cf == 'GLVS': vs = f"{LEAGUE_MAP.get(league_txt,0):.3f}" | |
| elif cf == 'FIFA_Points': vs = f"{NATION_MAP.get(nation_clean,0):.0f}" | |
| elif cf == 'Age': vs = f"{int(age)}" | |
| elif cf == 'Min': vs = f"{int(mins)}" | |
| elif cf in row.columns: vs = f"{float(row.at[0,cf]):.2f}" | |
| elif cf.startswith('Pos_Group_'): vs = "Yes" if pos_txt == cf.replace('Pos_Group_','') else "No" | |
| elif cf.startswith('Player_Type_'): vs = "Yes" if ROLE_TO_CLUSTER.get(role_txt,'N/A') == cf.replace('Player_Type_','') else "No" | |
| else: vs = "N/A" | |
| labels_w.append(f"{PRETTY_NAMES.get(cf, cf)}\n({vs})") | |
| imp = (gap_eur * shap_vals.values[0][i] / tot_log) if abs(tot_log) > 1e-9 else 0 | |
| impacts_w.append(imp) | |
| top_drivers.append((PRETTY_NAMES.get(cf, cf), imp)) | |
| df_p = (pd.DataFrame({'label': labels_w, 'impact': impacts_w}) | |
| .assign(abs_impact=lambda d: d.impact.abs()) | |
| .sort_values('abs_impact', ascending=False).head(8) | |
| .sort_values('abs_impact', ascending=True)) | |
| fig_waterfall = build_waterfall_plotly(df_p, base_eur, eur_final) | |
| top_drivers = sorted(top_drivers, key=lambda x: abs(x[1]), reverse=True)[:3] | |
| except Exception: | |
| fig_waterfall = go.Figure() | |
| fig_waterfall.add_annotation(text="Insufficient data for Price Driver Analysis", showarrow=False, font=dict(size=14, color='#94a3b8')) | |
| fig_waterfall.update_layout(**_PLOTLY_LAYOUT, height=200) | |
| # Store last prediction context for scout report / PDF generation | |
| _store_last_prediction( | |
| display_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, | |
| eur_final, lower, upper, eur_cf, gap, gap_str, gap_exp, | |
| top_drivers, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy | |
| ) | |
| return (name_html, val_html, fig_waterfall, badge_html, fig_radar) | |
| def master_load_and_predict(player_name): | |
| stats = get_player_data_tuple(player_name) | |
| ui_elements = predict_valuation(player_name, *stats) | |
| acc_update = gr.Accordion(open=(player_name == "CUSTOM PROFILE")) | |
| return (*stats, acc_update, *ui_elements) | |
| # ============================================================================== | |
| # 6b. LAST PREDICTION STATE STORE | |
| # Holds the context needed by the AI report and PDF export without | |
| # requiring Gradio State (avoids concurrency issues for a demo app). | |
| # ============================================================================== | |
| _LAST_PREDICTION = {} | |
| def _store_last_prediction(name, age, league, nation, pos, role, mins, | |
| eur_final, lower, upper, eur_cf, gap, gap_str, gap_exp, | |
| top_drivers, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): | |
| _LAST_PREDICTION.update(dict( | |
| name=name, age=age, league=league, nation=nation, pos=pos, role=role, mins=mins, | |
| eur_final=eur_final, lower=lower, upper=upper, eur_cf=eur_cf, | |
| gap=gap, gap_str=gap_str, gap_exp=gap_exp, | |
| top_drivers=top_drivers, | |
| gls=gls, ast=ast, xg=xg, xag=xag, prgp=prgp, | |
| prgr=prgr, prgc=prgc, tklw=tklw, intc=intc, crdy=crdy | |
| )) | |
| # ============================================================================== | |
| # 7. AI SCOUT REPORT (Google Gemini — free tier, no credit card) | |
| # ============================================================================== | |
| def generate_scout_report(): | |
| """Calls Gemini Flash to write a 3-paragraph scout report from the last prediction.""" | |
| api_key = os.environ.get("GEMINI_API_KEY", "") | |
| if not api_key: | |
| return ( | |
| "⚠️ No API key found. Add GEMINI_API_KEY to your Hugging Face Space secrets " | |
| "(Settings → Variables and Secrets) and restart the Space." | |
| ) | |
| if not _LAST_PREDICTION: | |
| return "Run a valuation first, then click Generate Scout Report." | |
| p = _LAST_PREDICTION | |
| drivers_text = "\n".join( | |
| [f" • {name}: {'+' if val > 0 else ''}{fmt(val)}" for name, val in p.get('top_drivers', [])] | |
| ) or " • Insufficient SHAP data" | |
| bias_note = "" | |
| gap = p.get('gap', 0) | |
| if abs(gap) >= 500_000: | |
| direction = "above" if gap > 0 else "below" | |
| bias_note = ( | |
| f"The bias-blind performance model values this player at {fmt(p['eur_cf'])}, " | |
| f"which is {fmt(abs(gap))} {direction} the full market estimate. " | |
| f"{'This premium reflects league and passport prestige rather than on-pitch output.' if gap > 0 else 'This discount suggests the market undervalues the player due to league or nationality factors.'}" | |
| ) | |
| prompt = f"""You are a senior professional football scout writing a concise, data-driven scouting report for a sporting director. | |
| Player Profile: | |
| - Name: {p['name']} | |
| - Position: {p['role']} ({p['pos']}) | |
| - Age: {int(p['age'])} | League: {p['league']} | Nationality: {p['nation']} | |
| - Minutes played this season: {int(p['mins'])} | |
| Model Outputs: | |
| - Full pipeline market valuation: {fmt(p['eur_final'])} (95% range: {fmt(p['lower'])} – {fmt(p['upper'])}) | |
| - Performance-only (bias-blind) valuation: {fmt(p['eur_cf'])} | |
| - Structural bias gap: {p['gap_str']} — {p['gap_exp']} | |
| Top price drivers (SHAP analysis): | |
| {drivers_text} | |
| Key per-90 stats: Goals {p['gls']:.2f} | Assists {p['ast']:.2f} | npxG {p['xg']:.2f} | xAG {p['xag']:.2f} | Prog Passes {p['prgp']:.1f} | Tackles Won {p['tklw']:.2f} | Interceptions {p['intc']:.2f} | |
| Bias context: {bias_note if bias_note else 'No significant structural bias detected between market and performance valuations.'} | |
| Write exactly 3 paragraphs: | |
| 1. Opening summary: player identity, current valuation, and what it reflects about their profile and career stage. | |
| 2. Performance analysis: what the price drivers and stats reveal about their strengths and any weaknesses or risks. | |
| 3. Transfer recommendation: a concrete recommendation for a sporting director, referencing the bias-adjusted figure where relevant and suggesting the type of club or context where this player would add most value. | |
| Write in professional but direct English. Be specific with the numbers provided. Do not invent statistics not given above. Keep each paragraph to 3-4 sentences.""" | |
| try: | |
| from google import genai | |
| client = genai.Client(api_key=api_key) | |
| response = client.models.generate_content( | |
| model="gemini-2.5-flash", | |
| contents=prompt | |
| ) | |
| return response.text | |
| except Exception as e: | |
| return f"⚠️ Error generating report: {str(e)}" | |
| # ============================================================================== | |
| # 8. PDF EXPORT | |
| # ============================================================================== | |
| def export_pdf(scout_report_text): | |
| """Generates a branded PDF scouting report and returns the filepath.""" | |
| try: | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib import colors | |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable | |
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
| from reportlab.lib.units import mm | |
| from reportlab.lib.enums import TA_LEFT, TA_CENTER | |
| if not _LAST_PREDICTION: | |
| return None | |
| p = _LAST_PREDICTION | |
| buf = BytesIO() | |
| doc = SimpleDocTemplate( | |
| buf, pagesize=A4, | |
| rightMargin=20*mm, leftMargin=20*mm, | |
| topMargin=16*mm, bottomMargin=16*mm | |
| ) | |
| # ── Styles ── | |
| styles = getSampleStyleSheet() | |
| def sty(name, **kwargs): | |
| return ParagraphStyle(name, parent=styles['Normal'], **kwargs) | |
| hdr_title = sty('HdrTitle', fontSize=22, fontName='Helvetica-Bold', | |
| textColor=colors.HexColor('#0f172a'), spaceAfter=2) | |
| hdr_sub = sty('HdrSub', fontSize=10, fontName='Helvetica', | |
| textColor=colors.HexColor('#64748b'), spaceAfter=12) | |
| sec_label = sty('SecLabel', fontSize=8, fontName='Helvetica-Bold', | |
| textColor=colors.HexColor('#94a3b8'), | |
| spaceBefore=14, spaceAfter=4) | |
| body_style = sty('Body', fontSize=10, fontName='Helvetica', | |
| textColor=colors.HexColor('#1e293b'), leading=16, spaceAfter=6) | |
| report_sty = sty('Report', fontSize=10, fontName='Helvetica', | |
| textColor=colors.HexColor('#1e293b'), leading=17, | |
| spaceBefore=4, spaceAfter=8) | |
| story = [] | |
| # ── Header bar ── | |
| header_data = [[ | |
| Paragraph(f"<b>{p['name']}</b>", hdr_title), | |
| Paragraph("Transfer Valuation Engine<br/><font color='#64748b'>Bias-Aware XGBoost Pipeline</font>", hdr_sub) | |
| ]] | |
| header_table = Table(header_data, colWidths=[110*mm, 60*mm]) | |
| header_table.setStyle(TableStyle([ | |
| ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), | |
| ('BACKGROUND', (0,0), (-1,-1), colors.HexColor('#f8fafc')), | |
| ('ROUNDEDCORNERS', [8, 8, 8, 8]), | |
| ('BOX', (0,0), (-1,-1), 1, colors.HexColor('#e2e8f0')), | |
| ('TOPPADDING', (0,0), (-1,-1), 12), | |
| ('BOTTOMPADDING', (0,0), (-1,-1), 12), | |
| ('LEFTPADDING', (0,0), (-1,-1), 14), | |
| ])) | |
| story.append(header_table) | |
| story.append(Spacer(1, 10)) | |
| # ── Profile row ── | |
| story.append(Paragraph("PLAYER PROFILE", sec_label)) | |
| profile_data = [ | |
| ['Position', f"{p['role']} ({p['pos']})", 'Age', str(int(p['age']))], | |
| ['League', p['league'], 'Nationality', p['nation']], | |
| ['Minutes', str(int(p['mins'])), '', ''], | |
| ] | |
| pt = Table(profile_data, colWidths=[30*mm, 65*mm, 30*mm, 45*mm]) | |
| pt.setStyle(TableStyle([ | |
| ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), | |
| ('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'), | |
| ('FONTNAME', (2,0), (2,-1), 'Helvetica-Bold'), | |
| ('FONTSIZE', (0,0), (-1,-1), 9), | |
| ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#64748b')), | |
| ('TEXTCOLOR', (2,0), (2,-1), colors.HexColor('#64748b')), | |
| ('TEXTCOLOR', (1,0), (1,-1), colors.HexColor('#0f172a')), | |
| ('TEXTCOLOR', (3,0), (3,-1), colors.HexColor('#0f172a')), | |
| ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), | |
| ('TOPPADDING', (0,0), (-1,-1), 5), | |
| ('BOTTOMPADDING',(0,0), (-1,-1), 5), | |
| ('LEFTPADDING', (0,0), (-1,-1), 8), | |
| ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), | |
| ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), | |
| ])) | |
| story.append(pt) | |
| story.append(Spacer(1, 8)) | |
| # ── Valuation cards ── | |
| story.append(Paragraph("VALUATION SUMMARY", sec_label)) | |
| val_data = [ | |
| [Paragraph('<font color="#64748b"><b>MARKET VALUATION</b></font>', sty('vc', fontSize=8, fontName='Helvetica-Bold')), | |
| Paragraph('<font color="#64748b"><b>PERFORMANCE-ONLY</b></font>', sty('vc2', fontSize=8, fontName='Helvetica-Bold')), | |
| Paragraph('<font color="#64748b"><b>STRUCTURAL BIAS GAP</b></font>', sty('vc3', fontSize=8, fontName='Helvetica-Bold'))], | |
| [Paragraph(f'<font color="#3b82f6"><b>{fmt(p["eur_final"])}</b></font>', | |
| sty('vv', fontSize=20, fontName='Helvetica-Bold')), | |
| Paragraph(f'<font color="#0f172a"><b>{fmt(p["eur_cf"])}</b></font>', | |
| sty('vv2', fontSize=20, fontName='Helvetica-Bold')), | |
| Paragraph(f'<font color="{"#10b981" if p["gap"] > 0 else "#ef4444" if p["gap"] < 0 else "#64748b"}"><b>{p["gap_str"]}</b></font>', | |
| sty('vv3', fontSize=20, fontName='Helvetica-Bold'))], | |
| [Paragraph(f'<font color="#94a3b8">Range: {fmt(p["lower"])} – {fmt(p["upper"])}</font>', | |
| sty('vs', fontSize=8, fontName='Helvetica')), | |
| Paragraph(f'<font color="#94a3b8">Excl. league & nationality</font>', | |
| sty('vs2', fontSize=8, fontName='Helvetica')), | |
| Paragraph(f'<font color="#94a3b8">{p["gap_exp"]}</font>', | |
| sty('vs3', fontSize=8, fontName='Helvetica', leading=10))], | |
| ] | |
| vt = Table(val_data, colWidths=[57*mm, 57*mm, 56*mm]) | |
| vt.setStyle(TableStyle([ | |
| ('BACKGROUND', (0,0), (0,-1), colors.HexColor('#eff6ff')), | |
| ('BACKGROUND', (1,0), (1,-1), colors.HexColor('#f8fafc')), | |
| ('BACKGROUND', (2,0), (2,-1), colors.HexColor('#f8fafc')), | |
| ('BOX', (0,0), (-1,-1), 1, colors.HexColor('#e2e8f0')), | |
| ('INNERGRID', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), | |
| ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), | |
| ('TOPPADDING', (0,0), (-1,-1), 8), | |
| ('BOTTOMPADDING',(0,0), (-1,-1), 8), | |
| ('LEFTPADDING', (0,0), (-1,-1), 10), | |
| ('ROUNDEDCORNERS', [6, 6, 6, 6]), | |
| ])) | |
| story.append(vt) | |
| story.append(Spacer(1, 8)) | |
| # ── Key stats ── | |
| story.append(Paragraph("KEY STATISTICS (PER 90)", sec_label)) | |
| stat_rows = [ | |
| ['Goals', f"{p['gls']:.2f}", 'Assists', f"{p['ast']:.2f}", 'npxG', f"{p['xg']:.2f}"], | |
| ['xAG', f"{p['xag']:.2f}", 'Prog Passes', f"{p['prgp']:.1f}", 'Prog Carries', f"{p['prgc']:.1f}"], | |
| ['Tackles Won', f"{p['tklw']:.2f}", 'Interceptions', f"{p['intc']:.2f}", 'Yellow Cards', f"{p['crdy']:.2f}"], | |
| ] | |
| st = Table(stat_rows, colWidths=[32*mm, 20*mm, 32*mm, 20*mm, 32*mm, 34*mm]) | |
| st.setStyle(TableStyle([ | |
| ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), | |
| ('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'), | |
| ('FONTNAME', (2,0), (2,-1), 'Helvetica-Bold'), | |
| ('FONTNAME', (4,0), (4,-1), 'Helvetica-Bold'), | |
| ('FONTSIZE', (0,0), (-1,-1), 9), | |
| ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#64748b')), | |
| ('TEXTCOLOR', (2,0), (2,-1), colors.HexColor('#64748b')), | |
| ('TEXTCOLOR', (4,0), (4,-1), colors.HexColor('#64748b')), | |
| ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), | |
| ('TOPPADDING', (0,0), (-1,-1), 5), | |
| ('BOTTOMPADDING',(0,0), (-1,-1), 5), | |
| ('LEFTPADDING', (0,0), (-1,-1), 8), | |
| ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), | |
| ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), | |
| ])) | |
| story.append(st) | |
| story.append(Spacer(1, 8)) | |
| # ── Top price drivers ── | |
| if p.get('top_drivers'): | |
| story.append(Paragraph("TOP PRICE DRIVERS (SHAP)", sec_label)) | |
| drv_rows = [[name, f"{'+' if val > 0 else ''}{fmt(val)}"] for name, val in p['top_drivers']] | |
| dt = Table(drv_rows, colWidths=[120*mm, 50*mm]) | |
| dt.setStyle(TableStyle([ | |
| ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), | |
| ('FONTSIZE', (0,0), (-1,-1), 9), | |
| ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#334155')), | |
| ('TEXTCOLOR', (1,0), (1,-1), colors.HexColor('#0f172a')), | |
| ('FONTNAME', (1,0), (1,-1), 'Helvetica-Bold'), | |
| ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), | |
| ('TOPPADDING', (0,0), (-1,-1), 5), | |
| ('BOTTOMPADDING',(0,0), (-1,-1), 5), | |
| ('LEFTPADDING', (0,0), (-1,-1), 8), | |
| ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), | |
| ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), | |
| ])) | |
| story.append(dt) | |
| story.append(Spacer(1, 8)) | |
| # ── AI Scout Report ── | |
| if scout_report_text and not scout_report_text.startswith("⚠️") and not scout_report_text.startswith("Run a"): | |
| story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'), spaceAfter=8)) | |
| story.append(Paragraph("AI SCOUT ASSESSMENT", sec_label)) | |
| # Split into paragraphs and render each | |
| for para in scout_report_text.strip().split('\n\n'): | |
| if para.strip(): | |
| story.append(Paragraph(para.strip().replace('\n', ' '), report_sty)) | |
| # ── Footer ── | |
| story.append(Spacer(1, 12)) | |
| story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#e2e8f0'))) | |
| story.append(Paragraph( | |
| "Generated by Transfer Valuation Engine · Bias-Aware XGBoost Pipeline · For internal use only", | |
| sty('footer', fontSize=7, fontName='Helvetica', textColor=colors.HexColor('#94a3b8'), | |
| alignment=TA_CENTER, spaceBefore=6) | |
| )) | |
| doc.build(story) | |
| buf.seek(0) | |
| safe_name = p['name'].replace(' ', '_').replace('/', '_') | |
| tmp = tempfile.NamedTemporaryFile( | |
| delete=False, suffix='.pdf', | |
| prefix=f"scout_{safe_name}_" | |
| ) | |
| tmp.write(buf.read()) | |
| tmp.close() | |
| return tmp.name | |
| except Exception as e: | |
| print(f"PDF export error: {e}") | |
| return None | |
| # ============================================================================== | |
| # 9. COMPARISON FUNCTION | |
| # ============================================================================== | |
| def compare_players(player_a_name, player_b_name): | |
| if not player_a_name or not player_b_name: | |
| e = "<div style='padding:40px;text-align:center;color:#94a3b8;font-size:16px;'>Select two players to compare</div>" | |
| return e, e, None, None, None | |
| stats_a = get_player_data_tuple(player_a_name) | |
| stats_b = get_player_data_tuple(player_b_name) | |
| age_a, lea_a, nat_a, pos_a = stats_a[0], stats_a[1], stats_a[2], stats_a[3] | |
| age_b, lea_b, nat_b, pos_b = stats_b[0], stats_b[1], stats_b[2], stats_b[3] | |
| va, vba = fast_predict(player_a_name, *stats_a) | |
| vb, vbb = fast_predict(player_b_name, *stats_b) | |
| ba = generate_badges(lea_a, nat_a) | |
| bb = generate_badges(lea_b, nat_b) | |
| def card(name, badge, val, blind, age, pos, accent): | |
| gap = val - blind | |
| gap_sign = "+" if gap > 0 else "" | |
| return f""" | |
| <div style="background:#fff;border-radius:16px;padding:24px;border:2px solid {accent}20;box-shadow:0 1px 3px rgba(0,0,0,0.04);"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> | |
| <div> | |
| <div style="font-size:22px;font-weight:800;color:#0f172a;letter-spacing:-0.5px;">{name}</div> | |
| <div style="font-size:12px;color:#94a3b8;margin-top:2px;">{pos} · Age {age}</div> | |
| </div> | |
| {badge} | |
| </div> | |
| <div style="display:flex;gap:12px;flex-wrap:wrap;"> | |
| <div style="flex:1;min-width:120px;background:#ffffff;border:2px solid {accent};border-radius:12px;padding:16px;"> | |
| <div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#64748b;">Market</div> | |
| <div style="font-size:26px;font-weight:800;color:{accent};margin-top:4px;">{fmt(val)}</div> | |
| </div> | |
| <div style="flex:1;min-width:120px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:16px;"> | |
| <div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#64748b;">Performance</div> | |
| <div style="font-size:26px;font-weight:800;color:#0f172a;margin-top:4px;">{fmt(blind)}</div> | |
| </div> | |
| </div> | |
| <div style="margin-top:12px;padding:10px 14px;background:#f8fafc;border-radius:8px;font-size:12px;color:#64748b;"> | |
| Structural Bias: <b style="color:{'#10b981' if gap > 0 else '#ef4444' if gap < 0 else '#64748b'}">{gap_sign}{fmt(gap)}</b> | |
| </div> | |
| </div>""" | |
| sum_a = card(player_a_name, ba, va, vba, age_a, pos_a, '#3b82f6') | |
| sum_b = card(player_b_name, bb, vb, vbb, age_b, pos_b, '#f97316') | |
| labels = ['Goals', 'Assists', 'npxG', 'xAG', 'Prog Passes', 'Prog Carries', 'Prog Runs', 'Tackles Won', 'Interceptions'] | |
| limits = (POS_RADAR_LIMITS.get(pos_a, _RADAR_FALLBACK) | |
| if pos_a == pos_b | |
| else POS_RADAR_LIMITS.get('Combined', _RADAR_FALLBACK)) | |
| rva = [stats_a[6], stats_a[7], stats_a[8], stats_a[9], stats_a[10], stats_a[12], stats_a[11], stats_a[13], stats_a[14]] | |
| rvb = [stats_b[6], stats_b[7], stats_b[8], stats_b[9], stats_b[10], stats_b[12], stats_b[11], stats_b[13], stats_b[14]] | |
| fig = build_comparison_radar_plotly(labels, rva, rvb, limits, player_a_name, player_b_name) | |
| metrics = ['Goals per 90', 'Assists per 90', 'npxG per 90', 'xAG per 90', 'Prog Passes', 'Prog Carries', 'Tackles Won', 'Interceptions', 'Minutes', 'Age'] | |
| va_list = [stats_a[6], stats_a[7], stats_a[8], stats_a[9], stats_a[10], stats_a[12], stats_a[13], stats_a[14], stats_a[5], age_a] | |
| vb_list = [stats_b[6], stats_b[7], stats_b[8], stats_b[9], stats_b[10], stats_b[12], stats_b[13], stats_b[14], stats_b[5], age_b] | |
| advs = [] | |
| for i, (a, b) in enumerate(zip(va_list, vb_list)): | |
| if i == len(metrics) - 1: | |
| advs.append(f"{player_a_name} (-{b-a:.0f} yrs)" if a < b else f"{player_b_name} (-{a-b:.0f} yrs)" if b < a else "Equal") | |
| else: | |
| if a > b: advs.append(f"{player_a_name} (+{(a-b)/b*100:.0f}%)" if b > 0 else player_a_name) | |
| elif b > a: advs.append(f"{player_b_name} (+{(b-a)/a*100:.0f}%)" if a > 0 else player_b_name) | |
| else: advs.append("Equal") | |
| va_rounded = [round(v, 2) for v in va_list] | |
| vb_rounded = [round(v, 2) for v in vb_list] | |
| df_cmp = pd.DataFrame({'Metric': metrics, player_a_name: va_rounded, player_b_name: vb_rounded, 'Advantage': advs}) | |
| diff = va - vb | |
| cmp_html = f""" | |
| <div style="background:#ffffff;border:2px solid #e2e8f0;border-radius:16px;padding:32px;margin-top:20px;color:#0f172a;"> | |
| <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#64748b;margin-bottom:16px;">Value Comparison</div> | |
| <div style="display:flex;gap:24px;flex-wrap:wrap;align-items:center;justify-content:center;"> | |
| <div style="text-align:center;flex:1;min-width:140px;"> | |
| <div style="font-size:13px;color:#64748b;margin-bottom:4px;">{player_a_name}</div> | |
| <div style="font-size:32px;font-weight:800;color:#3b82f6;letter-spacing:-1px;">{fmt(va)}</div> | |
| </div> | |
| <div style="font-size:20px;font-weight:800;color:#475569;">vs</div> | |
| <div style="text-align:center;flex:1;min-width:140px;"> | |
| <div style="font-size:13px;color:#64748b;margin-bottom:4px;">{player_b_name}</div> | |
| <div style="font-size:32px;font-weight:800;color:#f97316;letter-spacing:-1px;">{fmt(vb)}</div> | |
| </div> | |
| </div> | |
| <div style="text-align:center;margin-top:20px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:15px;color:#0f172a;"> | |
| Difference: <b style="font-size:20px;">{fmt(abs(diff))}</b> | |
| {f'<span style="color:#64748b;"> · {player_b_name} is cheaper</span>' if diff > 0 else f'<span style="color:#64748b;"> · {player_a_name} is cheaper</span>' if diff < 0 else ''} | |
| </div> | |
| </div>""" | |
| return sum_a, sum_b, fig, df_cmp, cmp_html | |
| # ============================================================================== | |
| # 10. FRONTEND | |
| # ============================================================================== | |
| custom_css = """ | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&display=swap'); | |
| :root { | |
| --slate-50: #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0; --slate-300: #cbd5e1; | |
| --slate-400: #94a3b8; --slate-500: #64748b; --slate-600: #475569; --slate-700: #334155; | |
| --slate-800: #1e293b; --slate-900: #0f172a; | |
| --emerald: #10b981; --rose: #ef4444; --blue: #3b82f6; | |
| } | |
| .gradio-container { | |
| background-color: var(--slate-50) !important; | |
| font-family: 'DM Sans', system-ui, -apple-system, sans-serif !important; | |
| max-width: 1400px !important; | |
| } | |
| h1, h2, h3 { font-family: 'DM Sans', system-ui, sans-serif !important; letter-spacing: -0.5px; } | |
| h1 { color: var(--slate-900) !important; font-weight: 800 !important; font-size: 28px !important; | |
| border: none !important; padding-bottom: 0 !important; margin-bottom: 8px !important; } | |
| .panel, .gr-panel { | |
| background: #ffffff !important; border: 1px solid var(--slate-200) !important; | |
| border-radius: 16px !important; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02) !important; | |
| padding: 24px !important; | |
| } | |
| input, select, .wrap { border-radius: 10px !important; } | |
| .gr-input, .gr-dropdown { border: 1.5px solid var(--slate-200) !important; border-radius: 10px !important; | |
| font-family: 'DM Sans', system-ui, sans-serif !important; transition: border-color 0.2s ease !important; } | |
| .gr-input:focus, .gr-dropdown:focus { border-color: var(--blue) !important; box-shadow: 0 0 0 3px rgba(59,130,246,0.1) !important; } | |
| input[type="range"] { accent-color: var(--slate-800) !important; } | |
| .gr-slider .wrap { padding: 8px 0 !important; } | |
| .primary-btn { | |
| background: linear-gradient(135deg, var(--slate-800), var(--slate-700)) !important; | |
| color: white !important; font-weight: 700 !important; border-radius: 12px !important; | |
| padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; | |
| border: none !important; box-shadow: 0 2px 8px rgba(15,23,42,0.15) !important; | |
| transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; | |
| } | |
| .primary-btn:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.25) !important; transform: translateY(-1px) !important; } | |
| .ai-btn { | |
| background: linear-gradient(135deg, #1e40af, #3b82f6) !important; | |
| color: white !important; font-weight: 700 !important; border-radius: 12px !important; | |
| padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; | |
| border: none !important; box-shadow: 0 2px 8px rgba(59,130,246,0.25) !important; | |
| transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; | |
| } | |
| .ai-btn:hover { box-shadow: 0 4px 16px rgba(59,130,246,0.4) !important; transform: translateY(-1px) !important; } | |
| .export-btn { | |
| background: linear-gradient(135deg, #065f46, #10b981) !important; | |
| color: white !important; font-weight: 700 !important; border-radius: 12px !important; | |
| padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; | |
| border: none !important; box-shadow: 0 2px 8px rgba(16,185,129,0.25) !important; | |
| transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; | |
| } | |
| .export-btn:hover { box-shadow: 0 4px 16px rgba(16,185,129,0.4) !important; transform: translateY(-1px) !important; } | |
| .gradio-container .tab-nav { border-bottom: 2px solid var(--slate-200) !important; gap: 4px !important; margin-bottom: 24px !important; } | |
| .gradio-container .tab-nav > button { | |
| color: var(--slate-500) !important; background: transparent !important; font-weight: 600 !important; | |
| font-size: 15px !important; padding: 12px 24px !important; border: none !important; | |
| border-bottom: 3px solid transparent !important; border-radius: 0 !important; | |
| transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; | |
| } | |
| .gradio-container .tab-nav > button:hover { color: var(--slate-700) !important; } | |
| .gradio-container .tab-nav > button.selected, | |
| .gradio-container div.tab-nav > button[aria-selected="true"] { | |
| color: var(--slate-900) !important; border-bottom: 3px solid var(--slate-900) !important; | |
| font-weight: 800 !important; background: transparent !important; | |
| } | |
| .control-accordion > button { | |
| background: var(--slate-50) !important; border: 1.5px solid var(--slate-200) !important; | |
| border-radius: 12px !important; color: var(--slate-600) !important; font-weight: 700 !important; | |
| font-size: 13px !important; text-transform: uppercase !important; letter-spacing: 0.8px !important; | |
| padding: 14px 18px !important; transition: all 0.2s ease !important; | |
| font-family: 'DM Sans', system-ui, sans-serif !important; | |
| } | |
| .control-accordion > button:hover { border-color: var(--slate-300) !important; background: white !important; } | |
| .section-label { | |
| font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; | |
| color: var(--slate-400); margin-bottom: 12px; padding-bottom: 8px; | |
| border-bottom: 1px solid var(--slate-100); | |
| } | |
| .search-hint { font-size: 11px; color: var(--slate-400); font-style: italic; margin-top: 2px; margin-bottom: 6px; } | |
| /* Scout report text box */ | |
| .scout-report textarea { | |
| font-family: 'DM Sans', system-ui, sans-serif !important; | |
| font-size: 14px !important; | |
| line-height: 1.7 !important; | |
| color: #1e293b !important; | |
| background: #f8fafc !important; | |
| border: 1.5px solid #e2e8f0 !important; | |
| border-radius: 12px !important; | |
| padding: 16px !important; | |
| } | |
| @media (max-width: 768px) { | |
| .gradio-container { padding: 8px !important; } | |
| .panel { padding: 16px !important; border-radius: 12px !important; } | |
| h1 { font-size: 22px !important; } | |
| } | |
| .gr-plot { border-radius: 12px !important; overflow: hidden !important; } | |
| """ | |
| theme = gr.themes.Soft(primary_hue="slate", neutral_hue="slate").set( | |
| body_background_fill="#f8fafc", block_background_fill="#ffffff", | |
| block_border_color="#e2e8f0", block_radius="16px", | |
| input_background_fill="#f8fafc", input_border_color="#e2e8f0", | |
| input_border_color_hover="#cbd5e1", input_border_color_focus="#3b82f6", | |
| input_radius="10px", slider_color="#1e293b" | |
| ) | |
| with gr.Blocks(title="Transfer Valuation Audit", css=custom_css, theme=theme) as app: | |
| gr.HTML(""" | |
| <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;"> | |
| <div style="width:6px;height:36px;background:linear-gradient(180deg,#3b82f6,#1e40af);border-radius:3px;"></div> | |
| <div> | |
| <div style="font-size:24px;font-weight:800;color:#0f172a;letter-spacing:-0.5px;">Transfer Valuation Engine</div> | |
| <div style="font-size:13px;color:#64748b;">Audited XGBoost Pipeline · Bias-Aware Dual-Model Architecture</div> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Tabs(): | |
| # ===================================================================== | |
| # TAB 1: PLAYER ANALYSIS | |
| # ===================================================================== | |
| with gr.Tab("Player Analysis"): | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=320): | |
| with gr.Column(variant="panel"): | |
| player_count_text = f" ({player_count:,} players)" if player_count > 0 else "" | |
| player_search_box = gr.Textbox( | |
| placeholder="Type a name to search…", | |
| label=f"Search Database{player_count_text}", | |
| value="" | |
| ) | |
| gr.HTML("<div class='search-hint'>Type to filter, then select below</div>") | |
| player_search = gr.Dropdown(choices=_init_tab1, value=default_player, label="Select Player", show_label=False) | |
| with gr.Column(variant="panel"): | |
| gr.HTML("<div class='section-label'>Player Profile</div>") | |
| league = gr.Dropdown(choices=sorted(list(LEAGUE_MAP.keys())), value="Premier League", label="League") | |
| nation = gr.Dropdown(choices=all_nations, value="England", label="Nationality") | |
| with gr.Row(): | |
| age = gr.Slider(16, 40, value=24, label="Age", step=1) | |
| pos = gr.Dropdown(choices=["Forwards","Midfielders","Defenders","Goalkeepers"], value="Forwards", label="Position") | |
| role = gr.Dropdown(choices=ROLE_CHOICES, value="Complete Forward", label="Tactical Role") | |
| with gr.Accordion("Stat Override", open=False, elem_classes=["control-accordion"]) as advanced_accordion: | |
| with gr.Column(variant="panel"): | |
| gr.HTML("<div class='section-label'>Attacking Output (per 90)</div>") | |
| with gr.Row(): | |
| gls = gr.Slider(0, 2.0, value=0.4, label="Goals", info="Elite: 0.7+") | |
| ast = gr.Slider(0, 2.0, value=0.2, label="Assists", info="Elite: 0.4+") | |
| with gr.Row(): | |
| xg = gr.Slider(0, 2.0, value=0.4, label="npxG", info="Elite: 0.6+") | |
| xag = gr.Slider(0, 1.0, value=0.2, label="xAG", info="Elite: 0.3+") | |
| gr.HTML("<div class='section-label' style='margin-top:12px;'>Progression (per 90)</div>") | |
| with gr.Row(): | |
| prgp = gr.Slider(0, 15, value=4.0, label="Prog Passes", info="Elite: 8+") | |
| prgr = gr.Slider(0, 15, value=5.0, label="Prog Runs", info="Elite: 6+") | |
| prgc = gr.Slider(0, 15, value=3.0, label="Prog Carries", info="Elite: 5+") | |
| gr.HTML("<div class='section-label' style='margin-top:12px;'>Defensive & Availability</div>") | |
| with gr.Row(): | |
| tklw = gr.Slider(0, 10.0, value=1.5, label="Tackles Won", info="Elite: 3.5+") | |
| intc = gr.Slider(0, 10.0, value=1.0, label="Interceptions", info="Elite: 2.0+") | |
| with gr.Row(): | |
| mins = gr.Slider(0, 3420, value=2000, label="Minutes", info="Full season: 2500+") | |
| crdy = gr.Slider(0, 2.0, value=0.1, label="Yellow Cards", info="Per 90") | |
| predict_btn = gr.Button("Recalculate Valuation", elem_classes=["primary-btn"]) | |
| # ── Right: Results ── | |
| with gr.Column(scale=2, min_width=400): | |
| with gr.Column(variant="panel"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| out_name = gr.HTML("<div style='font-size:13px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:1.5px;'>Player Report</div><div style='font-size:28px;font-weight:800;color:#0f172a;'>Awaiting Selection</div>") | |
| with gr.Column(scale=1): | |
| out_badges = gr.HTML("") | |
| out_val = gr.HTML("") | |
| with gr.Column(variant="panel"): | |
| gr.HTML("<div class='section-label'>Performance Radar</div>") | |
| out_radar = gr.Plot() | |
| with gr.Column(variant="panel"): | |
| gr.HTML("""<div class='section-label'>Price Driver Analysis</div> | |
| <div style='font-size:12px;color:#94a3b8;margin-bottom:8px;'> | |
| <span style='color:#10b981;font-weight:700;'>■</span> Increases value | |
| <span style='margin-left:12px;color:#ef4444;font-weight:700;'>■</span> Decreases value | |
| · Brackets show actual stat | |
| </div>""") | |
| out_plot = gr.Plot() | |
| # ── AI Scout Report panel ── | |
| with gr.Column(variant="panel"): | |
| gr.HTML(""" | |
| <div style='display:flex;align-items:center;gap:10px;margin-bottom:4px;'> | |
| <div style='width:4px;height:24px;background:linear-gradient(180deg,#3b82f6,#1e40af);border-radius:2px;'></div> | |
| <div class='section-label' style='margin-bottom:0;padding-bottom:0;border:none;'>AI Scout Assessment</div> | |
| </div> | |
| <div style='font-size:12px;color:#94a3b8;margin-bottom:12px;'> | |
| Powered by Google Gemini · Synthesises the model outputs into a scout-ready narrative | |
| </div>""") | |
| with gr.Row(): | |
| scout_btn = gr.Button("✦ Generate Scout Report", elem_classes=["ai-btn"], scale=2) | |
| export_btn = gr.Button("⬇ Export PDF Report", elem_classes=["export-btn"], scale=1) | |
| out_report = gr.Textbox( | |
| label="", | |
| lines=9, | |
| interactive=False, | |
| placeholder="Click 'Generate Scout Report' after running a valuation…", | |
| elem_classes=["scout-report"] | |
| ) | |
| out_pdf = gr.File(label="Download PDF", visible=False) | |
| # ===================================================================== | |
| # TAB 2: COMPARISON | |
| # ===================================================================== | |
| with gr.Tab("Compare Players"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| player_a_search_box = gr.Textbox(placeholder="Search Player A…", label="Player A", value="") | |
| gr.HTML("<div class='search-hint'>Type to filter, then select below</div>") | |
| player_a_dropdown = gr.Dropdown(choices=_init_tab2, value=default_player, label="Player A", show_label=False) | |
| with gr.Column(): | |
| player_b_search_box = gr.Textbox(placeholder="Search Player B…", label="Player B", value="") | |
| gr.HTML("<div class='search-hint'>Type to filter, then select below</div>") | |
| player_b_dropdown = gr.Dropdown(choices=_init_tab2, value=default_player_b, label="Player B", show_label=False) | |
| compare_btn = gr.Button("Compare Players", elem_classes=["primary-btn"], size="lg") | |
| with gr.Row(): | |
| with gr.Column(): comp_summary_a = gr.HTML() | |
| with gr.Column(): comp_summary_b = gr.HTML() | |
| with gr.Column(variant="panel"): | |
| gr.HTML("<div class='section-label'>Statistical Overlap</div>") | |
| comp_radar = gr.Plot() | |
| with gr.Column(variant="panel"): | |
| gr.HTML("<div class='section-label'>Stat Breakdown</div>") | |
| comp_table = gr.Dataframe() | |
| comp_value = gr.HTML() | |
| # ========================================================================= | |
| # EVENT LISTENERS | |
| # ========================================================================= | |
| player_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab1(q)), inputs=[player_search_box], outputs=[player_search]) | |
| player_a_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab2(q)), inputs=[player_a_search_box], outputs=[player_a_dropdown]) | |
| player_b_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab2(q)), inputs=[player_b_search_box], outputs=[player_b_dropdown]) | |
| core_inputs = [player_search, age, league, nation, pos, role, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy] | |
| core_outputs = [out_name, out_val, out_plot, out_badges, out_radar] | |
| data_outputs = [age, league, nation, pos, role, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy, advanced_accordion] | |
| app.load(master_load_and_predict, inputs=[player_search], outputs=data_outputs + core_outputs) | |
| player_search.change(master_load_and_predict, inputs=[player_search], outputs=data_outputs + core_outputs) | |
| predict_btn.click(predict_valuation, inputs=core_inputs, outputs=core_outputs) | |
| # AI Scout Report button | |
| scout_btn.click( | |
| fn=generate_scout_report, | |
| inputs=[], | |
| outputs=[out_report] | |
| ) | |
| # PDF Export button — uses whatever text is currently in the report box | |
| def handle_export(report_text): | |
| path = export_pdf(report_text) | |
| if path: | |
| return gr.File(value=path, visible=True) | |
| return gr.File(visible=False) | |
| export_btn.click( | |
| fn=handle_export, | |
| inputs=[out_report], | |
| outputs=[out_pdf] | |
| ) | |
| app.load(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) | |
| player_a_dropdown.change(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) | |
| player_b_dropdown.change(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) | |
| compare_btn.click(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) | |
| if __name__ == "__main__": | |
| app.launch(allowed_paths=["assets"]) |