Download app.py from nexusbert/F1: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/spaces/nexusbert/F1/resolve/main/app.py
- Command line
-
hf download hf://spaces/nexusbert/F1/app.py
-
curl -L -o app.py https://huggingface.co/spaces/nexusbert/F1/resolve/main/app.py
18.6 kB
| import os | |
| import math | |
| import asyncio | |
| from datetime import date, timedelta | |
| from typing import List, Optional | |
| from fastapi import FastAPI, Query | |
| from pydantic import BaseModel | |
| import httpx | |
| from bs4 import BeautifulSoup | |
| from dotenv import load_dotenv | |
| # Models are blocking -> run in threadpool | |
| load_dotenv() | |
| # === Config & environment === | |
| HF_CACHE = os.getenv("HF_HOME", os.path.expanduser("~/.cache/huggingface")) | |
| os.environ["HF_HOME"] = HF_CACHE | |
| FOOTBALL_API_KEY = os.getenv("FOOTBALL_API_KEY", "") | |
| FOOTBALL_ENDPOINT = "https://api.football-data.org/v4/matches" | |
| SOCCER_LEAGUES = {"EPL": 2021, "LaLiga": 2014, "Bundesliga": 2002} | |
| BALLDONTLIE_ENDPOINT = "https://www.balldontlie.io/api/v1/games" | |
| ESPN_SCOREBOARD_JSON = "https://site.web.api.espn.com/apis/v2/sports/basketball/nba/scoreboard?dates={ymd}" | |
| # concurrency | |
| MAX_CONCURRENT_REQUESTS = int(os.getenv("MAX_CONCURRENCY", "8")) | |
| REQUEST_TIMEOUT = 12.0 | |
| # === FastAPI app === | |
| app = FastAPI(title="SafeBet AI v2", description="Soccer + NBA predictions (safe/aggressive) — async & robust") | |
| # === Lazy model loading === | |
| _sentiment_model = None | |
| _reasoning_model = None | |
| _similarity_model = None | |
| _model_lock = asyncio.Lock() | |
| def _load_models_blocking(): | |
| """Blocking load of models. Called within threadpool.""" | |
| global _sentiment_model, _reasoning_model, _similarity_model | |
| from transformers import pipeline | |
| from sentence_transformers import SentenceTransformer | |
| if _sentiment_model is None: | |
| _sentiment_model = pipeline( | |
| "zero-shot-classification", | |
| model="valhalla/distilbart-mnli-12-1", | |
| device=-1 | |
| ) | |
| if _reasoning_model is None: | |
| _reasoning_model = pipeline( | |
| "text2text-generation", | |
| model="google/flan-t5-base", | |
| device=-1 | |
| ) | |
| if _similarity_model is None: | |
| _similarity_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") | |
| async def ensure_models(): | |
| async with _model_lock: | |
| if _sentiment_model is None or _reasoning_model is None or _similarity_model is None: | |
| await asyncio.to_thread(_load_models_blocking) | |
| # === Utilities: async http + parsing === | |
| sem = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) | |
| async def fetch_text(url: str, client: httpx.AsyncClient, params=None, headers=None) -> Optional[str]: | |
| try: | |
| async with sem: | |
| r = await client.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT) | |
| r.raise_for_status() | |
| return r.text | |
| except Exception: | |
| return None | |
| async def fetch_json(url: str, client: httpx.AsyncClient, params=None, headers=None): | |
| try: | |
| async with sem: | |
| r = await client.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT) | |
| r.raise_for_status() | |
| return r.json() | |
| except Exception: | |
| return None | |
| async def parse_google_news_headlines(html_text: str, max_h=5) -> str: | |
| # BeautifulSoup is blocking; wrap in thread | |
| def _parse(html): | |
| soup = BeautifulSoup(html, "html.parser") | |
| hs = [h.get_text(separator=" ", strip=True) for h in soup.select("h3")[:max_h]] | |
| return " ".join(hs) | |
| return await asyncio.to_thread(_parse, html_text) if html_text else "" | |
| # === Aggressive news fetch chain === | |
| async def aggressive_team_news(team: str, sport: str = "football") -> str: | |
| """ | |
| Best procedures: | |
| 1. Google News search | |
| 2. Bing News search | |
| 3. ESPN (if sport-specific) | |
| 4. Fallback: short default text | |
| """ | |
| async with httpx.AsyncClient() as client: | |
| # Google News | |
| q = f"{team} {sport}" | |
| url = f"https://news.google.com/search" | |
| params = {"q": q, "hl": "en"} | |
| html = await fetch_text(url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) | |
| text = await parse_google_news_headlines(html, max_h=6) | |
| if text and len(text.strip()) >= 20: | |
| return text | |
| # Bing News fallback (lightweight) | |
| try: | |
| bing_url = "https://www.bing.com/news/search" | |
| params = {"q": q} | |
| html = await fetch_text(bing_url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) | |
| if html: | |
| parsed = await parse_google_news_headlines(html, max_h=6) | |
| if parsed and len(parsed.strip()) >= 20: | |
| return parsed | |
| except Exception: | |
| pass | |
| # ESPN headlines (sport-specific) | |
| try: | |
| if sport.lower() in ("football", "soccer"): | |
| # ESPN soccer site search | |
| search_url = f"https://www.espn.com/search/results" | |
| params = {"search": q} | |
| html = await fetch_text(search_url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) | |
| parsed = await parse_google_news_headlines(html, max_h=6) | |
| if parsed and len(parsed.strip()) >= 20: | |
| return parsed | |
| else: | |
| # NBA-specific ESPN scoreboard headlines | |
| espn_search = f"https://www.espn.com/search/results?q={q}" | |
| html = await fetch_text(espn_search, client, headers={"User-Agent": "Mozilla/5.0"}) | |
| parsed = await parse_google_news_headlines(html, max_h=6) | |
| if parsed and len(parsed.strip()) >= 20: | |
| return parsed | |
| except Exception: | |
| pass | |
| # final fallback | |
| return f"No recent updates found for {team} ({sport})." | |
| # === Sentiment & similarity wrappers (run in threadpool) === | |
| async def sentiment_and_signal(text: str): | |
| await ensure_models() | |
| labels = ["positive", "negative", "injury", "motivation", "transfer"] | |
| # call blocking pipeline in thread | |
| def _call(): | |
| try: | |
| res = _sentiment_model(text, labels) | |
| # HF zero-shot returns dict with labels & scores | |
| labels_out = res.get("labels", []) | |
| scores_out = res.get("scores", []) | |
| table = {lbl: float(scores_out[i]) for i, lbl in enumerate(labels_out)} | |
| net = table.get("positive", 0.0) - table.get("negative", 0.0) - 0.4 * table.get("injury", 0.0) + 0.2 * table.get("motivation", 0.0) | |
| return {**table, "net": net} | |
| except Exception: | |
| return {"positive": 0.0, "negative": 0.0, "injury": 0.0, "motivation": 0.0, "transfer": 0.0, "net": 0.0} | |
| return await asyncio.to_thread(_call) | |
| async def similarity_score(a: str, b: str) -> float: | |
| await ensure_models() | |
| def _call(): | |
| try: | |
| emb_a = _similarity_model.encode(a, convert_to_tensor=True) | |
| emb_b = _similarity_model.encode(b, convert_to_tensor=True) | |
| from sentence_transformers import util as st_util | |
| return float(st_util.pytorch_cos_sim(emb_a, emb_b).item()) | |
| except Exception: | |
| return 0.0 | |
| return await asyncio.to_thread(_call) | |
| async def reasoning_text(context: str, sport: str, mode: str) -> str: | |
| await ensure_models() | |
| def _call(): | |
| try: | |
| prompt = ( | |
| f"Mode: {mode}. Sport: {sport}.\n" | |
| f"Context:\n{context}\n\n" | |
| "Provide a concise betting-style verdict and short safe reasoning." | |
| ) | |
| out = _reasoning_model(prompt, max_new_tokens=120) | |
| return out[0].get("generated_text", "").strip() | |
| except Exception: | |
| return "" | |
| return await asyncio.to_thread(_call) | |
| # === Odds math helpers === | |
| def softmax_pair(h: float, a: float): | |
| """Simple softmax over two values to get probabilities.""" | |
| exph = math.exp(h) | |
| expa = math.exp(a) | |
| s = exph + expa | |
| if s == 0: | |
| return 0.5, 0.5 | |
| return exph / s, expa / s | |
| def apply_bet_bias(p_home: float, p_draw: float, p_away: float, mode: str): | |
| """Apply bias: safe increases draw/home; aggressive favors wins.""" | |
| if mode == "safe": | |
| p_draw += 0.15 | |
| p_home += 0.10 | |
| else: | |
| p_home += 0.20 | |
| p_away += 0.05 | |
| total = max(1e-6, p_home + p_draw + p_away) | |
| return p_home / total, p_draw / total, p_away / total | |
| def confidence_to_decimal_odds(conf: float, cap_min: float = 0.02): | |
| # avoid division by zero, cap at min confidence | |
| c = max(conf, cap_min) | |
| odds = 1.0 / c | |
| # sensible rounding and bounds | |
| if odds < 1.01: | |
| odds = 1.01 | |
| if odds > 1000: | |
| odds = 1000.0 | |
| return round(odds, 2) | |
| # === Soccer prediction pipeline === | |
| async def predict_soccer(home: str, away: str, mode: str): | |
| # aggressive news fetches in parallel | |
| home_news_task = asyncio.create_task(aggressive_team_news(home, "football")) | |
| away_news_task = asyncio.create_task(aggressive_team_news(away, "football")) | |
| home_news, away_news = await asyncio.gather(home_news_task, away_news_task) | |
| home_sig_task = asyncio.create_task(sentiment_and_signal(home_news)) | |
| away_sig_task = asyncio.create_task(sentiment_and_signal(away_news)) | |
| sim_task = asyncio.create_task(similarity_score(home + " " + home_news, away + " " + away_news)) | |
| home_sig, away_sig, sim = await asyncio.gather(home_sig_task, away_sig_task, sim_task) | |
| # core probability (home vs away) | |
| diff = home_sig.get("net", 0.0) - away_sig.get("net", 0.0) | |
| p_home = 1 / (1 + math.exp(-3 * diff)) | |
| p_away = 1 - p_home | |
| p_draw = max(0.05, 1 - abs(p_home - p_away)) | |
| p_home, p_draw, p_away = apply_bet_bias(p_home, p_draw, p_away, mode) | |
| # determine pick (safe heuristics) | |
| if mode == "safe": | |
| if p_draw >= max(p_home, p_away) and p_draw >= 0.35: | |
| pick = "Draw / Under 2.5 (safe)" | |
| conf = p_draw | |
| elif p_home >= 0.45: | |
| pick = f"{home} or Draw (1X)" | |
| conf = p_home | |
| else: | |
| pick = f"Draw or {away} (X2)" | |
| conf = max(p_draw, p_away) | |
| else: | |
| # aggressive: pick the higher of home/away, but give reasoning if margin small | |
| if abs(p_home - p_away) < 0.07: | |
| pick = "Small margin — consider Double Chance" | |
| conf = max(p_home, p_away) | |
| else: | |
| pick = home if p_home > p_away else away | |
| conf = max(p_home, p_away) | |
| # reasoning via LLM | |
| context = ( | |
| f"{home} vs {away}\nhome_net={home_sig.get('net'):.3f} away_net={away_sig.get('net'):.3f}\n" | |
| f"similarity={sim:.3f}\nhome_news_excerpt={home_news[:200]}\naway_news_excerpt={away_news[:200]}" | |
| ) | |
| reasoning = await reasoning_text(context, "football", mode) | |
| # implied odds | |
| decimal_odds = confidence_to_decimal_odds(conf) | |
| return { | |
| "match": f"{home} vs {away}", | |
| "pick": pick, | |
| "confidence": round(conf, 3), | |
| "decimal_odds": decimal_odds, | |
| "similarity": round(sim, 3), | |
| "reasoning": reasoning, | |
| "home_news": home_news[:500], | |
| "away_news": away_news[:500], | |
| "home_signal": home_sig, | |
| "away_signal": away_sig, | |
| } | |
| # === NBA pipeline with aggressive fixture fallback === | |
| async def fetch_nba_fixtures_for_date(ymd: str): | |
| """Try: balldontlie -> ESPN JSON scoreboard -> Google search fallback (light).""" | |
| fixtures = [] | |
| async with httpx.AsyncClient() as client: | |
| # 1) balldontlie | |
| try: | |
| params = {"dates[]": ymd} | |
| r = await client.get(BALLDONTLIE_ENDPOINT, params=params, timeout=REQUEST_TIMEOUT) | |
| if r.status_code == 200: | |
| j = r.json() | |
| data = j.get("data", []) | |
| for g in data: | |
| home = g.get("home_team", {}).get("full_name") | |
| away = g.get("visitor_team", {}).get("full_name") | |
| if home and away: | |
| fixtures.append({"home": home, "away": away}) | |
| except Exception: | |
| pass | |
| if fixtures: | |
| return fixtures | |
| # 2) ESPN scoreboard JSON (dates as YYYYMMDD) | |
| try: | |
| # ESPN endpoint expects YYYYMMDD | |
| ymd_es = ymd.replace("-", "") | |
| url = ESPN_SCOREBOARD_JSON.format(ymd=ymd_es) | |
| j = await fetch_json(url, client) | |
| events = (j or {}).get("events", []) if isinstance(j, dict) else [] | |
| for ev in events: | |
| comps = (ev.get("competitions"), None) or ev.get("competitions") or [] | |
| if comps: | |
| comps0 = comps[0] | |
| comps_list = comps0.get("competitors") or [] | |
| home = next((c for c in comps_list if c.get("homeAway") == "home"), None) | |
| away = next((c for c in comps_list if c.get("homeAway") == "away"), None) | |
| if home and away: | |
| fixtures.append({ | |
| "home": home.get("team", {}).get("displayName") or home.get("team", {}).get("name"), | |
| "away": away.get("team", {}).get("displayName") or away.get("team", {}).get("name"), | |
| }) | |
| except Exception: | |
| pass | |
| if fixtures: | |
| return fixtures | |
| # 3) Lightweight Google search fallback for the date | |
| try: | |
| search_q = f"NBA schedule {ymd}" | |
| google = await fetch_text("https://www.google.com/search", client, params={"q": search_q}, headers={"User-Agent": "Mozilla/5.0"}) | |
| parsed = await parse_google_news_headlines(google, max_h=30) | |
| # extract "Team A vs Team B" lines | |
| lines = parsed.split(" ") | |
| from re import findall | |
| for ln in lines: | |
| matches = findall(r"([A-Za-z .&]+) (?:v|vs|vs.|at) ([A-Za-z .&]+)", ln, flags=0) | |
| for m in matches: | |
| fixtures.append({"home": m[0].strip(), "away": m[1].strip()}) | |
| except Exception: | |
| pass | |
| return fixtures | |
| async def predict_nba(home: str, away: str, mode: str): | |
| # aggressive news fetch | |
| home_news_task = asyncio.create_task(aggressive_team_news(home, "NBA")) | |
| away_news_task = asyncio.create_task(aggressive_team_news(away, "NBA")) | |
| home_news, away_news = await asyncio.gather(home_news_task, away_news_task) | |
| home_sig_task = asyncio.create_task(sentiment_and_signal(home_news)) | |
| away_sig_task = asyncio.create_task(sentiment_and_signal(away_news)) | |
| home_sig, away_sig = await asyncio.gather(home_sig_task, away_sig_task) | |
| # compute probabilities | |
| diff = home_sig.get("net", 0.0) - away_sig.get("net", 0.0) | |
| p_home = 1 / (1 + math.exp(-3 * diff)) | |
| p_away = 1 - p_home | |
| p_draw = 0.02 | |
| p_home, p_draw, p_away = apply_bet_bias(p_home, p_draw, p_away, mode) | |
| # pick | |
| if mode == "safe": | |
| # safe: lean to close margin and safe margin suggestion | |
| pick = f"{home} Win (safe margin < 10 pts)" | |
| conf = p_home if p_home > p_away else p_away | |
| else: | |
| pick = home if p_home > p_away else away | |
| conf = max(p_home, p_away) | |
| context = ( | |
| f"{home} vs {away}\nhome_net={home_sig.get('net'):.3f} away_net={away_sig.get('net'):.3f}\n" | |
| f"home_news_excerpt={home_news[:200]}\naway_news_excerpt={away_news[:200]}" | |
| ) | |
| reasoning = await reasoning_text(context, "NBA", mode) | |
| decimal_odds = confidence_to_decimal_odds(conf) | |
| return { | |
| "match": f"{home} vs {away}", | |
| "pick": pick, | |
| "confidence": round(conf, 3), | |
| "decimal_odds": decimal_odds, | |
| "reasoning": reasoning, | |
| "home_news": home_news[:500], | |
| "away_news": away_news[:500], | |
| "home_signal": home_sig, | |
| "away_signal": away_sig, | |
| } | |
| # === API models & endpoints === | |
| class PredictionsResponse(BaseModel): | |
| date: str | |
| mode: str | |
| sport: str | |
| predictions: List[dict] | |
| count: int | |
| async def predictions( | |
| mode: str = Query("safe", enum=["safe", "aggressive"]), | |
| sport: str = Query("both", enum=["soccer", "nba", "both"]), | |
| ): | |
| await ensure_models() | |
| today = date.today() | |
| results = [] | |
| # soccer | |
| if sport in ("soccer", "both"): | |
| date_from = (today - timedelta(days=1)).isoformat() | |
| date_to = (today + timedelta(days=1)).isoformat() | |
| headers = {"X-Auth-Token": FOOTBALL_API_KEY} if FOOTBALL_API_KEY else {} | |
| async with httpx.AsyncClient() as client: | |
| for league, code in SOCCER_LEAGUES.items(): | |
| try: | |
| params = {"competitions": code, "dateFrom": date_from, "dateTo": date_to} | |
| r = await client.get(FOOTBALL_ENDPOINT, params=params, headers=headers, timeout=REQUEST_TIMEOUT) | |
| if r.status_code == 200: | |
| j = r.json() | |
| matches = j.get("matches", []) | |
| # limit to first 3 per league for speed | |
| tasks = [] | |
| for m in matches[:3]: | |
| home = m.get("homeTeam", {}).get("name") | |
| away = m.get("awayTeam", {}).get("name") | |
| if home and away: | |
| tasks.append(asyncio.create_task(predict_soccer(home, away, mode))) | |
| if tasks: | |
| res = await asyncio.gather(*tasks) | |
| results.extend(res) | |
| except Exception as e: | |
| results.append({"league": league, "error": str(e)}) | |
| # nba | |
| if sport in ("nba", "both"): | |
| # try balldontlie and aggressive fallback | |
| ymd = today.isoformat() | |
| fixtures = await fetch_nba_fixtures_for_date(ymd) | |
| if fixtures: | |
| # predict top N (limit) | |
| tasks = [] | |
| for f in fixtures[:6]: | |
| tasks.append(asyncio.create_task(predict_nba(f["home"], f["away"], mode))) | |
| res = await asyncio.gather(*tasks) | |
| results.extend(res) | |
| else: | |
| # nothing found | |
| results.append({"league": "NBA", "notice": "No fixtures found for today via API or fallbacks."}) | |
| return PredictionsResponse( | |
| date=today.isoformat(), | |
| mode=mode, | |
| sport=sport, | |
| predictions=results, | |
| count=len(results) | |
| ) | |
| def root(): | |
| return { | |
| "message": "SafeBet AI v2 — async, aggressive news, NBA fallback, safe/aggressive modes", | |
| "endpoints": { | |
| "/predictions?mode=safe|aggressive&sport=soccer|nba|both": "Main predictions endpoint" | |
| }, | |
| } | |
| # === Run guard === | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("safebet_v2:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")), reload=False) |