from __future__ import annotations import os import sys import asyncio import time from contextlib import asynccontextmanager from typing import Any import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel from models.basketball_v25 import get_basketball_v25_predictor from services.single_match_orchestrator import get_single_match_orchestrator from services.v26_shadow_engine import get_v26_shadow_engine from data.database import dispose_engine 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 @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 # Cleanup async DB connections on shutdown await dispose_engine() 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", ], } @app.get("/health") def health_check() -> dict[str, Any]: try: orchestrator = get_single_match_orchestrator() shadow_engine = get_v26_shadow_engine() basketball_predictor = get_basketball_v25_predictor() basketball_readiness = basketball_predictor.readiness_summary() ready = bool(basketball_readiness["fully_loaded"]) return { "status": "healthy" if ready else "degraded", "engine": "v28.main", "mode": os.getenv("AI_ENGINE_MODE", "v28"), "ready": ready, "basketball_v25": basketball_readiness, "v26_shadow": shadow_engine.readiness_summary(), "prediction_service_ready": True, "model_loaded": ready, "orchestrator_mode": getattr(orchestrator, "engine_mode", "v28"), } except Exception as error: return {"status": "unhealthy", "ready": False, "error": str(error)} @app.post("/v20plus/analyze/{match_id}") async def analyze_match_v20plus(match_id: str) -> dict[str, Any]: orchestrator = get_single_match_orchestrator() result = orchestrator.analyze_match(match_id) if not result: raise HTTPException(status_code=404, detail=f"Match not found: {match_id}") 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 = 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 orchestrator.build_coupon( match_ids=request.match_ids, strategy=request.strategy or "BALANCED", max_matches=request.max_matches, min_confidence=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 = orchestrator.get_daily_bankers(count=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 orchestrator.get_reversal_watchlist( count=count, horizon_hours=horizon_hours, min_score=min_score, top_leagues_only=top_leagues_only, ) if __name__ == "__main__": port = int(os.getenv("PORT", "8000")) uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)