Files
iddaai-be/ai-engine/main.py
T
fahricansecer 21e05148c8
Deploy Iddaai Backend / build-and-deploy (push) Failing after 3m56s
feat: league tier system + retrained V25 models (48 quality leagues)
- Add LeagueTier DB model and Prisma schema
- Add league-tiers service (CRUD, sync, retrain trigger)
- Add league-tiers controller with admin API endpoints
- Add /v1/admin/retrain endpoint in AI engine (extract→train→reload pipeline)
- Retrain V25 Pro with 48 quality leagues (MS accuracy: 26.9%→51.4%)
- Update qualified_leagues.json (443→48 leagues)
- Include V25 model files in repo for Docker deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 21:57:15 +03:00

431 lines
15 KiB
Python
Executable File

from __future__ import annotations
import os
import sys
import asyncio
import time
from contextlib import asynccontextmanager
from typing import Any
from datetime import datetime
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import subprocess
from pydantic import BaseModel
try:
from models.basketball_v25 import get_basketball_v25_predictor
HAS_BASKETBALL = True
except ImportError:
HAS_BASKETBALL = False
from services.single_match_orchestrator import get_single_match_orchestrator
from services.v26_shadow_engine import get_v26_shadow_engine
from models.league_model import get_league_model_loader
load_dotenv()
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
class CouponRequest(BaseModel):
match_ids: list[str]
strategy: str | None = "BALANCED"
max_matches: int | None = None
min_confidence: float | None = None
class RetrainRequest(BaseModel):
reason: str | None = "manual"
markets: str | None = None # comma-separated, e.g. "MS,OU25,BTTS"
trials: int | None = 50
# ─── Retrain state tracking ──────────────────────────────────
_retrain_state: dict[str, Any] = {
"running": False,
"last_started": None,
"last_completed": None,
"last_status": None,
"last_error": None,
"pid": None,
}
@asynccontextmanager
async def lifespan(_: FastAPI):
try:
print("🚀 Initializing V28 orchestrator...", flush=True)
get_single_match_orchestrator()
get_v26_shadow_engine()
print("✅ V28 orchestrator ready", flush=True)
except Exception as error:
print(f"❌ Failed to initialize orchestrator: {error}", flush=True)
import traceback
traceback.print_exc()
yield
app = FastAPI(
title="Suggest-Bet AI Engine",
version="28.0.0",
description="V28 Single Match Prediction Package API",
lifespan=lifespan,
)
def _parse_cors_origins() -> list[str]:
raw = os.getenv("CORS_ALLOW_ORIGINS", "").strip()
if raw:
return [item.strip() for item in raw.split(",") if item.strip()]
# Dev-safe defaults + production domains.
return [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://localhost:3005",
"http://127.0.0.1:3005",
"https://ui-suggestbet.bilgich.com",
"https://suggestbet.bilgich.com",
"https://iddaai.com",
"https://www.iddaai.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=_parse_cors_origins(),
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(_: Request, exc: Exception):
import traceback
print(f"💥 ERROR: {exc}", flush=True)
traceback.print_exc()
return JSONResponse(
status_code=500,
content={"message": f"Internal Server Error: {str(exc)}"},
)
@app.get("/")
def read_root() -> dict[str, Any]:
return {
"status": "Suggest-Bet AI Engine v28",
"engine": "V28 Single Match Orchestrator",
"mode": os.getenv("AI_ENGINE_MODE", "v28"),
"routes": [
"POST /v20plus/analyze/{match_id}",
"GET /v20plus/analyze-htms/{match_id}",
"GET /v20plus/analyze-htft/{match_id}",
"GET /v20plus/reversal-watchlist",
"POST /v20plus/coupon",
"GET /v20plus/daily-banker",
"POST /v1/admin/retrain",
"GET /v1/admin/retrain/status",
],
}
@app.get("/health")
def health_check() -> dict[str, Any]:
try:
orchestrator = get_single_match_orchestrator()
shadow_engine = get_v26_shadow_engine()
# Per-market V25 model status
v25_readiness: dict[str, Any] = {"fully_loaded": False}
try:
v25_predictor = orchestrator._get_v25_predictor()
v25_readiness = v25_predictor.readiness_summary()
except Exception as v25_err:
v25_readiness = {"fully_loaded": False, "error": str(v25_err)}
if HAS_BASKETBALL:
basketball_predictor = get_basketball_v25_predictor()
basketball_readiness = basketball_predictor.readiness_summary()
ready = bool(basketball_readiness.get("fully_loaded", True))
else:
basketball_readiness = {"fully_loaded": False, "error": "Basketball module not found"}
ready = True
league_readiness = get_league_model_loader().readiness_summary()
overall_ready = ready and v25_readiness.get("fully_loaded", False)
return {
"status": "healthy" if overall_ready else "degraded",
"engine": "v28.main",
"mode": os.getenv("AI_ENGINE_MODE", "v28"),
"ready": overall_ready,
"v25_football": v25_readiness,
"league_specific": league_readiness,
"basketball_v25": basketball_readiness,
"v26_shadow": shadow_engine.readiness_summary(),
"prediction_service_ready": True,
"model_loaded": overall_ready,
"orchestrator_mode": getattr(orchestrator, "engine_mode", "v28"),
}
except Exception as error:
return {"status": "unhealthy", "ready": False, "error": str(error)}
_REQUIRED_RESPONSE_FIELDS = ("match_info", "market_board", "main_pick", "bet_summary", "data_quality")
@app.post("/v20plus/analyze/{match_id}")
async def analyze_match_v20plus(match_id: str) -> dict[str, Any]:
started_at = time.time()
orchestrator = get_single_match_orchestrator()
result = await asyncio.to_thread(orchestrator.analyze_match, match_id)
elapsed_ms = int((time.time() - started_at) * 1000)
if not result:
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
# Response validation: log missing required fields (non-fatal)
missing_fields = [f for f in _REQUIRED_RESPONSE_FIELDS if f not in result]
if missing_fields:
print(f"⚠️ [API] analyze/{match_id} response missing fields: {missing_fields} ({elapsed_ms}ms)")
result["timing_ms"] = elapsed_ms
return result
@app.get("/v20plus/analyze-htms/{match_id}")
async def analyze_match_htms_v20plus(match_id: str) -> dict[str, Any]:
orchestrator = get_single_match_orchestrator()
result = await asyncio.to_thread(orchestrator.analyze_match_htms, match_id)
if not result:
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
return result
@app.get("/v20plus/analyze-htft/{match_id}")
async def analyze_match_htft_v20plus(match_id: str, timeout_sec: int = 30) -> dict[str, Any]:
# Small, explicit endpoint for HT/FT inspection and debugging in FE/Postman.
if timeout_sec < 3 or timeout_sec > 120:
raise HTTPException(status_code=400, detail="timeout_sec must be between 3 and 120")
orchestrator = get_single_match_orchestrator()
started_at = time.time()
try:
result = await asyncio.wait_for(
asyncio.to_thread(orchestrator.analyze_match, match_id),
timeout=float(timeout_sec),
)
except asyncio.TimeoutError as error:
raise HTTPException(
status_code=504,
detail=f"Analyze timeout after {timeout_sec}s for match_id={match_id}",
) from error
if not result:
raise HTTPException(status_code=404, detail=f"Match not found: {match_id}")
risk = result.get("risk", {})
market_board = result.get("market_board", {})
htft_probs = market_board.get("HTFT", {}).get("probs", {}) or risk.get("ht_ft_probs", {})
top_reversal_pick = None
top_reversal_prob = 0.0
if htft_probs:
prob_12 = float(htft_probs.get("1/2", 0.0))
prob_21 = float(htft_probs.get("2/1", 0.0))
if prob_21 >= prob_12:
top_reversal_pick = "2/1"
top_reversal_prob = prob_21
else:
top_reversal_pick = "1/2"
top_reversal_prob = prob_12
overall_htft_pick = None
overall_htft_prob = 0.0
if htft_probs:
overall_htft_pick, overall_htft_prob = max(
htft_probs.items(),
key=lambda item: float(item[1]),
)
return {
"engine": "v28.main",
"match_info": result.get("match_info", {}),
"timing_ms": int((time.time() - started_at) * 1000),
"ht_ft_probs": htft_probs,
"top_reversal_pick": top_reversal_pick,
"top_reversal_prob": round(float(top_reversal_prob), 4),
"overall_htft_pick": overall_htft_pick,
"overall_htft_pick_prob": round(float(overall_htft_prob), 4),
"surprise_hunter": result.get("surprise_hunter", {}),
"ht_ft_reversal_radar": result.get("ht_ft_reversal_radar", {}),
"first_half_result": result.get("market_board", {}).get("first_half_result", {}),
"main_pick": result.get("main_pick", {}),
"bet_summary": result.get("bet_summary", {}),
}
@app.post("/v20plus/coupon")
async def generate_coupon_v20plus(request: CouponRequest) -> dict[str, Any]:
orchestrator = get_single_match_orchestrator()
return await asyncio.to_thread(
orchestrator.build_coupon,
request.match_ids,
request.strategy or "BALANCED",
request.max_matches,
request.min_confidence,
)
@app.get("/v20plus/daily-banker")
async def get_daily_banker_v20plus(count: int = 3) -> dict[str, Any]:
if count < 1:
raise HTTPException(status_code=400, detail="count must be >= 1")
orchestrator = get_single_match_orchestrator()
bankers = await asyncio.to_thread(orchestrator.get_daily_bankers, count)
return {"count": len(bankers), "bankers": bankers}
@app.get("/v20plus/reversal-watchlist")
async def get_reversal_watchlist_v20plus(
count: int = 20,
horizon_hours: int = 72,
min_score: float = 45.0,
top_leagues_only: bool = False,
) -> dict[str, Any]:
if count < 1 or count > 100:
raise HTTPException(status_code=400, detail="count must be between 1 and 100")
if horizon_hours < 6 or horizon_hours > 168:
raise HTTPException(status_code=400, detail="horizon_hours must be between 6 and 168")
if min_score < 0 or min_score > 100:
raise HTTPException(status_code=400, detail="min_score must be between 0 and 100")
orchestrator = get_single_match_orchestrator()
return await asyncio.to_thread(
orchestrator.get_reversal_watchlist,
count,
horizon_hours,
min_score,
top_leagues_only,
)
# ─── ADMIN: Retrain Pipeline ─────────────────────────────────
def _run_retrain_pipeline(markets: str | None, trials: int):
"""Background function: extract data → train model → reload."""
global _retrain_state
ai_dir = os.path.dirname(os.path.abspath(__file__))
scripts_dir = os.path.join(ai_dir, "scripts")
python = os.path.join(ai_dir, "venv", "bin", "python3")
if not os.path.exists(python):
python = sys.executable # fallback
try:
# Step 1: Extract training data
print("🔄 [RETRAIN] Step 1/3: Extracting training data...", flush=True)
result = subprocess.run(
[python, os.path.join(scripts_dir, "extract_training_data.py")],
capture_output=True, text=True, timeout=600, cwd=ai_dir,
)
if result.returncode != 0:
raise RuntimeError(f"Extract failed:\n{result.stderr[-500:]}")
print(f"✅ [RETRAIN] Extract done", flush=True)
# Step 2: Train V25 Pro
print("🔄 [RETRAIN] Step 2/3: Training V25 Pro model...", flush=True)
train_cmd = [python, os.path.join(scripts_dir, "train_v25_pro.py")]
if markets:
train_cmd += ["--markets", markets]
train_cmd += ["--trials", str(trials)]
result = subprocess.run(
train_cmd, capture_output=True, text=True, timeout=3600, cwd=ai_dir,
)
if result.returncode != 0:
raise RuntimeError(f"Training failed:\n{result.stderr[-500:]}")
print(f"✅ [RETRAIN] Training done", flush=True)
# Step 3: Reload models in memory
print("🔄 [RETRAIN] Step 3/3: Reloading models...", flush=True)
try:
orchestrator = get_single_match_orchestrator()
v25 = orchestrator._get_v25_predictor()
v25._loaded = False
v25.load_models()
print("✅ [RETRAIN] Models reloaded in memory", flush=True)
except Exception as reload_err:
print(f"⚠️ [RETRAIN] Hot reload failed (restart needed): {reload_err}", flush=True)
_retrain_state.update({
"running": False,
"last_completed": datetime.now().isoformat(),
"last_status": "success",
"last_error": None,
})
print("🎉 [RETRAIN] Pipeline complete!", flush=True)
except Exception as err:
_retrain_state.update({
"running": False,
"last_completed": datetime.now().isoformat(),
"last_status": "failed",
"last_error": str(err),
})
print(f"❌ [RETRAIN] Pipeline failed: {err}", flush=True)
@app.post("/v1/admin/retrain")
async def admin_retrain(request: RetrainRequest) -> dict[str, Any]:
"""Trigger full retrain pipeline: extract → train → reload."""
if _retrain_state["running"]:
return {
"status": "already_running",
"message": f"Retrain in progress since {_retrain_state['last_started']}",
}
_retrain_state.update({
"running": True,
"last_started": datetime.now().isoformat(),
"last_status": "running",
"last_error": None,
})
# Run in background thread
import threading
thread = threading.Thread(
target=_run_retrain_pipeline,
args=(request.markets, request.trials or 50),
daemon=True,
)
thread.start()
return {
"status": "triggered",
"message": "Retrain pipeline started in background",
"reason": request.reason,
"markets": request.markets or "all",
"trials": request.trials or 50,
}
@app.get("/v1/admin/retrain/status")
async def admin_retrain_status() -> dict[str, Any]:
"""Check retrain pipeline status."""
return {**_retrain_state}
if __name__ == "__main__":
port = int(os.getenv("PORT", "8000"))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)