21e05148c8
Deploy Iddaai Backend / build-and-deploy (push) Failing after 3m56s
- 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>
431 lines
15 KiB
Python
Executable File
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)
|