Spaces:
Running
Running
| 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")) | |
| ) | |
| 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, | |
| ) | |
| 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") | |
| async def index(request: Request): | |
| return templates.TemplateResponse( | |
| request, | |
| "index.html", | |
| { | |
| "min_safe_score": settings.min_safe_score, | |
| "model_version": MODEL_VERSION, | |
| }, | |
| ) | |
| 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(), | |
| } | |
| async def state(): | |
| state = store.load_state() | |
| cleaned, _removed = _prune_expired_picks(state) | |
| return cleaned | |
| 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, | |
| }, | |
| ) | |
| 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} | |