from __future__ import annotations import asyncio from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone import hmac import logging from pathlib import Path import time from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app.config import MODEL_VERSION, settings from app.core.tickets import build_ticket_set from app.logging_config import configure_logging from app.pipeline import DailyPipeline from app.storage import StateStore configure_logging() logger = logging.getLogger(__name__) BASE_DIR = Path(__file__).resolve().parent store = StateStore(settings.data_dir, settings.hf_token, settings.hf_dataset_repo) pipeline = DailyPipeline(settings, store) def _state_age_minutes(state: dict) -> float | None: generated_at = state.get("generated_at") if not generated_at: return None try: generated = datetime.fromisoformat(str(generated_at).replace("Z", "+00:00")) except Exception: return None if generated.tzinfo is None: generated = generated.replace(tzinfo=timezone.utc) return max(0.0, (datetime.now(timezone.utc) - generated).total_seconds() / 60.0) def _parse_datetime(raw: object) -> datetime | None: if not raw: return None try: parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) except Exception: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed def _expired_pick(pick: object, now: datetime | None = None) -> bool: if not isinstance(pick, dict): return False kickoff = _parse_datetime(pick.get("kickoff")) if kickoff is None: return False now = now or datetime.now(timezone.utc) return kickoff <= now - timedelta(hours=2) def _state_has_expired_picks(state: dict) -> bool: picks = state.get("picks") if not isinstance(picks, list): return False now = datetime.now(timezone.utc) return any(_expired_pick(pick, now) for pick in picks) def _prune_expired_picks(state: dict) -> tuple[dict, int]: picks = state.get("picks") if not isinstance(picks, list): return dict(state), 0 now = datetime.now(timezone.utc) active_picks = [pick for pick in picks if not _expired_pick(pick, now)] removed = len(picks) - len(active_picks) if removed <= 0: return dict(state), 0 cleaned = dict(state) cleaned["picks"] = active_picks summary = dict(cleaned.get("summary") or {}) summary["approved"] = len(active_picks) cleaned["summary"] = summary cleaned["tickets"] = build_ticket_set(active_picks) warnings = list(cleaned.get("warnings") or []) warnings = warnings[-4:] warnings.append( f"{removed} palpite(s) expirado(s) foram ocultados do painel; um novo scan foi disparado." ) cleaned["warnings"] = warnings return cleaned, removed def _authorized(secret: str | None) -> bool: expected = settings.cron_secret return bool( expected and secret and hmac.compare_digest(expected.encode("utf-8"), secret.encode("utf-8")) ) @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Safe Bet AI %s iniciando", MODEL_VERSION) try: await asyncio.wait_for( asyncio.to_thread(store.restore_from_hub_if_needed), timeout=10.0, ) except TimeoutError: logger.warning("Restauração HF excedeu o tempo limite; seguindo com o estado local") except Exception: logger.exception("Restauração HF falhou; seguindo com o estado local") state = store.load_state() expired_removed = 0 if _state_has_expired_picks(state): state, expired_removed = _prune_expired_picks(state) store.save_state(state) if state.get("status") == "scanning": state["status"] = "interrupted" state["last_error"] = "O processo anterior foi reiniciado durante um scan." state["last_error_at"] = datetime.now(timezone.utc).isoformat() state["warnings"] = list(state.get("warnings") or [])[-4:] + [ "Scan anterior interrompido por reinício; execute uma nova varredura." ] store.save_state(state) should_refresh = bool(expired_removed) or not pipeline.recent_success(settings.min_scan_interval_minutes) if settings.required_ready and should_refresh: if pipeline.trigger_background(): logger.info( "Scan automático disparado na inicialização para renovar o painel" ) try: yield finally: await pipeline.shutdown() logger.info("Safe Bet AI encerrando") app = FastAPI( title="Safe Bet AI Precision", version=MODEL_VERSION, docs_url="/docs", redoc_url=None, lifespan=lifespan, ) @app.middleware("http") async def harden_responses(request: Request, call_next): response = await call_next(request) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") response.headers.setdefault( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()", ) if request.url.path.startswith(("/api/", "/static/")): response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin") if request.url.path.startswith("/api/"): response.headers["Cache-Control"] = "no-store" elif not request.url.path.startswith(("/docs", "/openapi.json")): response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; base-uri 'self'; " "frame-ancestors https://huggingface.co https://*.huggingface.co; " "form-action 'self'; img-src 'self' data:; style-src 'self'; " "script-src 'self'; connect-src 'self'", ) return response app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") templates = Jinja2Templates(directory=BASE_DIR / "templates") @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse( request, "index.html", { "min_safe_score": settings.min_safe_score, "model_version": MODEL_VERSION, }, ) @app.get("/api/health") async def health(): state = store.load_state() state_age_minutes = _state_age_minutes(state) stale_open_picks = _state_has_expired_picks(state) state_stale = ( state_age_minutes is None or state_age_minutes >= settings.min_scan_interval_minutes or stale_open_picks ) configured = { "football_data": bool(settings.football_data_token), "odds_api": bool(settings.odds_api_key), "cron_secret": bool(settings.cron_secret), "hub_backup": bool(settings.hf_token and settings.hf_dataset_repo), } return { "ok": True, "ready": settings.required_ready, "service": "safe-bet-ai", "version": MODEL_VERSION, "pipeline_running": pipeline.running, "configured": configured, "last_status": state.get("status"), "generated_at": state.get("generated_at"), "state_age_minutes": round(state_age_minutes, 2) if state_age_minutes is not None else None, "state_stale": state_stale, "stale_open_picks": stale_open_picks, "time": time.time(), } @app.get("/api/state") async def state(): state = store.load_state() cleaned, _removed = _prune_expired_picks(state) return cleaned @app.post("/api/cron/daily") async def cron_daily(x_cron_secret: str | None = Header(default=None)): if not _authorized(x_cron_secret): raise HTTPException(status_code=401, detail="X-Cron-Secret inválido") if pipeline.recent_success(settings.min_scan_interval_minutes): return JSONResponse( status_code=200, content={ "accepted": False, "message": ( "scan recente já concluído; execução duplicada bloqueada para preservar quota" ), "model_version": MODEL_VERSION, }, ) accepted = pipeline.trigger_background() return JSONResponse( status_code=202 if accepted else 200, content={ "accepted": accepted, "message": "scan iniciado" if accepted else "scan já estava em execução", "model_version": MODEL_VERSION, }, ) @app.post("/api/admin/scan") async def admin_scan( wait: bool = False, force: bool = False, x_cron_secret: str | None = Header(default=None), ): if not _authorized(x_cron_secret): raise HTTPException(status_code=401, detail="X-Cron-Secret inválido") if not force and pipeline.recent_success(settings.min_scan_interval_minutes): return { "accepted": False, "message": "scan recente já concluído; use force=1 somente se necessário", "model_version": MODEL_VERSION, } if wait: return await pipeline.trigger_and_wait() accepted = pipeline.trigger_background() return {"accepted": accepted, "model_version": MODEL_VERSION}