AJC1's picture
Update app.py
c513f95 verified
Raw
History Blame Contribute Delete
73.9 kB
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 &amp; 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"])