Vscode / app /main.py
Erinaldorodrigues's picture
Hide expired picks and refresh stale dashboard
a20823c
Raw
History Blame Contribute Delete
9.47 kB
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}