from __future__ import annotations from datetime import datetime from html import escape import gradio as gr import spaces from drama import card_counts, competitors, h2h, observed_index, stat, train_and_score from feed import FeedError, MATCH_PAGE, find_event, fixture_choices, summary, tournament @spaces.GPU(duration=1) def zero_gpu_marker(): return True CSS = """ :root, .dark { --body-background-fill: #090b0d !important; --body-text-color: #f5f3ed !important; --block-background-fill: #12161a !important; --block-border-color: #2b3238 !important; --input-background-fill: #0d1013 !important; --input-border-color: #384149 !important; --input-placeholder-color: #7f898f !important; --button-secondary-background-fill: #171c20 !important; --button-secondary-text-color: #f5f3ed !important; } html, body, .gradio-container { background: #090b0d !important; color: #f5f3ed !important; } .gradio-container { max-width: 1180px !important; padding: 0 22px 60px !important; } .gradio-container label, .gradio-container .label-wrap span, .gradio-container .prose, .gradio-container p, .gradio-container h1, .gradio-container h2, .gradio-container h3 { color: #f5f3ed !important; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 26px; border-bottom: 1px solid #2b3238; } .brand { font: 900 clamp(2rem, 5vw, 4.4rem)/.85 Arial Black, sans-serif; letter-spacing: -.075em; text-transform: uppercase; } .brand span { color: #c7ff2f; } .tagline { max-width: 340px; color: #aab3b8; font-size: .9rem; line-height: 1.45; text-align: right; } .live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ff503f; margin-right: 8px; box-shadow: 0 0 0 5px #ff503f22; } .control-row { margin: 24px 0 18px; } #fixture { flex: 1; } #refresh { max-width: 180px; align-self: end; } button#refresh { min-height: 48px; background: #c7ff2f !important; color: #090b0d !important; border: 0 !important; font-weight: 900 !important; } .match-shell { border: 1px solid #30373d; background: #101418; overflow: hidden; } .match-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 15px 20px; border-bottom: 1px solid #30373d; color: #aab3b8; font-size: .76rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .status-live { color: #ff6557; } .scoreboard { display: grid; grid-template-columns: 1fr minmax(170px, .6fr) 1fr; align-items: center; gap: 24px; padding: 34px 28px 30px; } .team { display: flex; align-items: center; gap: 16px; min-width: 0; } .team.away { flex-direction: row-reverse; text-align: right; } .team img { width: 66px; height: 66px; object-fit: contain; } .team-name { font-size: clamp(1.1rem, 2.5vw, 1.85rem); font-weight: 900; } .team-form { color: #8c979d; font-size: .78rem; margin-top: 5px; } .score { text-align: center; font: 900 clamp(3rem, 8vw, 6.2rem)/.8 Arial Black, sans-serif; letter-spacing: -.08em; white-space: nowrap; } .clock { color: #c7ff2f; font-size: .78rem; font-weight: 900; letter-spacing: .08em; margin-top: 14px; } .index-band { display: grid; grid-template-columns: 230px 1fr; gap: 28px; padding: 26px 28px; background: #c7ff2f; color: #090b0d; } .index-band, .index-band * { color: #090b0d !important; } .index-number { font: 900 5rem/.8 Arial Black, sans-serif; letter-spacing: -.08em; } .index-label { font-weight: 900; font-size: .78rem; letter-spacing: .08em; margin-top: 14px; } .meter-track { height: 13px; background: #090b0d33; margin: 14px 0 12px; } .meter-fill { height: 100%; background: #090b0d; } .index-copy { font-weight: 750; line-height: 1.45; max-width: 630px; } .stat-grid { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid #30373d; border-bottom: 1px solid #30373d; } .stat { padding: 19px 16px; border-right: 1px solid #30373d; } .stat:last-child { border-right: 0; } .stat b { display: block; font-size: 1.45rem; } .stat span { color: #879198; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; } .detail { padding: 26px 28px 30px; } .detail + .detail { border-left: 1px solid #30373d; } .detail h3 { margin: 0 0 18px; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; } .signal { display: flex; justify-content: space-between; gap: 20px; padding: 11px 0; border-bottom: 1px solid #262d32; color: #aeb7bc; } .signal b { color: #f5f3ed; } .event { display: grid; grid-template-columns: 58px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid #262d32; } .event time { color: #c7ff2f; font-weight: 900; } .event p { color: #c8ced1 !important; margin: 0; font-size: .86rem; line-height: 1.4; } .source-line { padding: 16px 28px; color: #778187; font-size: .72rem; border-top: 1px solid #30373d; } .source-line a { color: #c7ff2f !important; } .model-card { color: #bdc5c9; line-height: 1.55; } .model-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #30373d; border: 1px solid #30373d; margin: 16px 0 20px; } .model-metric { background: #101418; padding: 18px; } .model-metric b { display: block; color: #f5f3ed; font-size: 1.55rem; } .model-metric span { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; } .error-card { border: 1px solid #ff6557; padding: 24px; color: #ffb0a9; background: #281411; } .footer-note { color: #6f797f; font-size: .72rem; padding: 18px 0; } @media (max-width: 760px) { .topbar { align-items: flex-start; padding-top: 24px; } .tagline { display: none; } .scoreboard { grid-template-columns: 1fr 120px 1fr; padding: 26px 16px; gap: 8px; } .team, .team.away { flex-direction: column; text-align: center; gap: 8px; } .team img { width: 48px; height: 48px; } .team-form { display: none; } .index-band { grid-template-columns: 1fr; gap: 18px; } .stat-grid { grid-template-columns: repeat(3, 1fr); } .stat:nth-child(3) { border-right: 0; } .detail-grid { grid-template-columns: 1fr; } .detail + .detail { border-left: 0; border-top: 1px solid #30373d; } .model-grid { grid-template-columns: repeat(2, 1fr); } } """ def logo(team: dict) -> str: return escape(team.get("team", {}).get("logo", ""), quote=True) def timeline(summary_data: dict) -> str: wanted = {"goal", "goal---header", "penalty---scored", "yellow-card", "red-card", "own-goal"} events = [item for item in summary_data.get("keyEvents", []) if item.get("type", {}).get("type") in wanted] rows = [] for item in events[-8:]: minute = escape(item.get("clock", {}).get("displayValue", "—")) text = escape(item.get("shortText") or item.get("text") or item.get("type", {}).get("text", "Match event")) rows.append(f'
{text}
This is a live, explainable model — not an LLM guess. A gradient-boosted tree ensemble is retrained from scratch on completed 2026 World Cup matches using only information available before each kickoff.
Target: an observed match-drama proxy derived from fouls, cards, goals, score closeness, late goals, knockout stage and extra time. Forecast inputs: rolling team PPG, goals, fouls, cards, shots, stage and tournament experience. The target is an editorial index, not social-media ground truth or betting advice.