CULTIVA IA
huggingface-webhooks v1.0.0
MLOps ยท IA Ingenieria

HuggingFace Webhook Handler

Servidor FastAPI para recibir y enrutar eventos del Hub โ€” re-entrenamiento automatico, alertas Slack y auditoria de configuracion.
๐Ÿ Python 3.11 + FastAPI
๐Ÿ”’ secrets.compare_digest
โšก 5 scopes de eventos
๐Ÿ” 1,000 triggers / 24h
๐Ÿ›ก๏ธ Timing-safe verification
Implementacion
Eventos
Tests
Despliegue
Handlers activos
3
Re-train ยท Slack ยท Audit
Scopes cubiertos
4 / 5
repo, repo.content, repo.config, discussion
Seguridad
100%
Timing-safe compare_digest
๐Ÿ”„ Flujo de procesamiento de evento
๐ŸŒ
HF Hub POST
X-Webhook-Secret
โ†’
๐Ÿ”
Verificacion
compare_digest()
โ†’
๐Ÿ“‹
Parse Body
scope + action
โ†’
โšก
Router
match scope.action
โ†’
โœ…
Handler
retrain / slack / audit
โ†’
๐Ÿ“จ
200 OK
{received: true}
๐Ÿ”„
Re-entrenamiento Auto
repo.content ยท update
Detecta nuevos commits en cultiva-ia/dataset-emails-marketing y encola job de fine-tuning del modelo base.
Activo en commits a main
๐Ÿ“ข
Alerta Slack
discussion ยท create
Envia notificacion a #mlops-alerts cuando alguien abre una discusion o PR en el modelo modelo-email-gen.
Solo en isPullRequest=true
๐Ÿ“‹
Auditoria Config
repo.config ยท update
Registra en base de datos cada cambio de privacidad, secrets o DOI del Space space-demo-clientes.
Log persistente en PostgreSQL
๐Ÿ”ง Variables de entorno requeridas
HUGGINGFACE_WEBHOOK_SECRET = "hf_secret_cultiva_2024" Configurado en HF Hub โ†’ Settings โ†’ Webhooks
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/T.../B.../..." Canal #mlops-alerts
DATABASE_URL = "postgresql://cultiva:pass@db:5432/mlops" Audit log de cambios de config
TRAINING_QUEUE_URL = "redis://localhost:6379/0" Cola de jobs de fine-tuning
webhook_server.py python
๐Ÿ“‹ Copiar
# Generated with: huggingface-webhooks skill โ€” Cultiva IA MLOps
# https://github.com/hookdeck/webhook-skills/tree/main/skills/huggingface-webhooks

import secrets
import logging
import os
from datetime import datetime
from typing import Optional

import httpx
from fastapi import FastAPI, Request, HTTPException, Header
from pydantic import BaseModel

app = FastAPI(title="Cultiva IA โ€” HuggingFace Webhook Handler", version="1.0.0")
logger = logging.getLogger("cultiva.webhooks.hf")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ENV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
HF_SECRET      = os.getenv("HUGGINGFACE_WEBHOOK_SECRET")
SLACK_URL       = os.getenv("SLACK_WEBHOOK_URL")
DB_URL          = os.getenv("DATABASE_URL")
TRAINING_QUEUE  = os.getenv("TRAINING_QUEUE_URL")

# Repos de Cultiva IA monitorizados
DATASET_REPO    = "cultiva-ia/dataset-emails-marketing"
MODEL_REPO      = "cultiva-ia/modelo-email-gen"
SPACE_REPO      = "cultiva-ia/space-demo-clientes"

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ VERIFICACION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def verify_hf_secret(header_secret: Optional[str], env_secret: Optional[str]) -> bool:
    """
    Hugging Face NO usa HMAC. Envia el secreto verbatim en X-Webhook-Secret.
    Siempre usar secrets.compare_digest para evitar timing attacks.
    """
    if not header_secret or not env_secret:
        return False
    return secrets.compare_digest(header_secret, env_secret)

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ MODELOS PYDANTIC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class HFEvent(BaseModel):
    action: str
    scope: str

class HFRepo(BaseModel):
    type: str
    name: str
    id: str
    private: bool
    headSha: Optional[str] = None

class HFWebhookPayload(BaseModel):
    event: HFEvent
    repo: HFRepo
    discussion: Optional[dict] = None
    comment: Optional[dict] = None
    updatedRefs: Optional[list] = None
    updatedConfig: Optional[dict] = None
    webhook: Optional[dict] = None

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HANDLERS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async def handle_repo_content_update(payload: HFWebhookPayload):
    """repo.content.update โ€” encola fine-tuning si es el dataset de marketing."""
    if payload.repo.name != DATASET_REPO:
        return

    refs = payload.updatedRefs or []
    main_updated = any(r.get("ref") == "refs/heads/main" for r in refs)

    if main_updated:
        job = {
            "type": "finetune",
            "dataset": payload.repo.name,
            "base_model": "mistralai/Mistral-7B-Instruct-v0.3",
            "triggered_by": "webhook",
            "timestamp": datetime.utcnow().isoformat(),
        }
        # Encolar en Redis / Celery / ARQ โ€” simulado aqui
        logger.info(f"[RETRAIN] Encolando fine-tuning job: {job}")
        # await queue.enqueue("tasks.finetune", job)

async def handle_discussion_create(payload: HFWebhookPayload):
    """discussion.create โ€” alerta Slack solo si es un PR en el modelo."""
    if payload.repo.name != MODEL_REPO:
        return

    disc = payload.discussion or {}
    is_pr = disc.get("isPullRequest", False)
    title = disc.get("title", "Sin titulo")
    num   = disc.get("num", 0)
    tipo  = "PR" if is_pr else "Discussion"

    msg = {
        "text": f":robot_face: *Nuevo {tipo} #{num} en {MODEL_REPO}*\n> {title}",
    }
    if SLACK_URL:
        async with httpx.AsyncClient() as client:
            await client.post(SLACK_URL, json=msg)
    logger.info(f"[SLACK] {tipo} #{num} notificado: {title}")

async def handle_repo_config_update(payload: HFWebhookPayload):
    """repo.config.update โ€” log de auditoria para el Space."""
    if payload.repo.name != SPACE_REPO:
        return

    entry = {
        "repo": payload.repo.name,
        "changes": payload.updatedConfig,
        "repo_private": payload.repo.private,
        "timestamp": datetime.utcnow().isoformat(),
    }
    # await db.execute("INSERT INTO audit_log ...", entry)
    logger.warning(f"[AUDIT] Config cambiada en Space: {entry}")

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ENDPOINT PRINCIPAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/webhooks/huggingface")
async def huggingface_webhook(
    payload: HFWebhookPayload,
    x_webhook_secret: Optional[str] = Header(None),
):
    # 1. Verificar secreto โ€” SIEMPRE primero
    if not verify_hf_secret(x_webhook_secret, HF_SECRET):
        logger.error("[SECURITY] Verificacion de secreto fallida")
        raise HTTPException(status_code=401, detail="Unauthorized")

    # 2. Enrutar por scope + action
    scope  = payload.event.scope
    action = payload.event.action
    logger.info(f"[EVENT] {scope}.{action} en {payload.repo.name} ({payload.repo.type})")

    if   scope == "repo.content":  await handle_repo_content_update(payload)
    elif scope == "repo.config":   await handle_repo_config_update(payload)
    elif scope == "discussion" and action == "create":
        await handle_discussion_create(payload)
    else:
        # Forward-compat: scopes no reconocidos -> log y 200 OK
        logger.info(f"[SKIP] scope={scope} action={action} sin handler registrado")

    return {"received": True, "scope": scope, "action": action}

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ HEALTHCHECK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/")
def health():
    return {"status": "ok", "service": "cultiva-hf-webhooks"}
โšก Eventos soportados y handlers mapeados
event.scope event.action Repo objetivo Handler Accion
repo.content update dataset-emails-marketing handle_repo_content_update Encola job fine-tuning โ†’ Redis
repo.config update space-demo-clientes handle_repo_config_update INSERT en audit_log (PostgreSQL)
discussion create modelo-email-gen handle_discussion_create POST Slack #mlops-alerts
repo create / update / delete cualquiera โ€” (log + 200 OK) Forward-compat pasivo
discussion.comment create / update cualquiera โ€” (log + 200 OK) Extendible en v2
โš ๏ธ
Rate limit: Cada webhook de HuggingFace permite hasta 1,000 disparos por 24 horas. El historial de entregas y replay esta disponible en HF Hub โ†’ Settings โ†’ Webhooks. Para desarrollo local, usar npx hookdeck-cli listen 8000 huggingface --path /webhooks/huggingface.