# Meta Optimizer Formación — Documentación del Proyecto Agente autónomo de optimización de campañas Meta Ads (Facebook/Instagram) para los cursos de formación (`RoiFormacion_*`), hermano de `meta-optimizer` (que gestiona las campañas `VIVIFUL_*` de la misma cuenta de Meta). A diferencia de Viviful, aquí el modelo de negocio no es "CPL objetivo por vertical" sino **PPL (precio por lead) + capping mensual por curso**, el mismo modelo que usa `leads-optimizer` para gestionar los mismos cursos en Google Ads. --- ## Por qué existe este proyecto (y no una copia más de Viviful) - El número que sigue a `RoiFormacion_` en el nombre de la campaña de Meta (`RoiFormacion_884_Curso_Desarrollador_leadads` → `884`) es el mismo `CursoID` que usa `leads-optimizer` en Airtable. - Los leads de Meta **ya llegan hoy** a la tabla `Leads Lake` de Airtable (`attr_utm_source='Lead ads'`, `attr_cursoid` resuelto) — el capping mensual por curso (`CursoMes.Caping Admitido`) ya se consume con leads de Meta y de Google combinados, aunque hasta ahora nada lo controlaba desde el lado de Meta. - El campo `Familia` (desde `Familias`) en la tabla `Cursos` de Airtable ya da la segmentación temática que sustituye al concepto de "vertical" de Viviful. Por eso la arquitectura se reparte en dos sistemas, no uno: - **Airtable** (compartido con `leads-optimizer`, misma base): la capa de negocio — `Cursos`, `Familias`, `CentroCurso`, `CursoMes` (capping, solo lectura), `Leads Lake` (solo lectura), y las tablas nuevas específicas de Meta: `Meta Ads Campaigns` y `MetaCampaignMes` (análogas a `Google Ads Campaigns` / `GACampaignMes`). El capping es un recurso **compartido entre canales** — vive en un solo sitio para que Meta y Google no se pisen al consumirlo. - **Baserow** (misma instancia self-hosted que Viviful, tablas nuevas): todo lo operativo específico de Meta que Google no tiene — desglose diario por adset/anuncio, propuestas de acción (incluida la pausa de anuncios individuales), análisis visual de creatividades, logs de ejecución. --- ## Arquitectura general ``` Meta Ads API Airtable (compartido con leads-optimizer) │ │ Cursos / Familias / CentroCurso ▼ │ CursoMes (capping) / Leads Lake run.py ──► agent.py (Claude Haiku) ◄───────┤ (solo lectura) │ │ │ │ ▼ └─► Meta Ads Campaigns / MetaCampaignMes │ analyzer.py (PPL + capping, (catálogo + estado mensual, r/w) │ urgencia/ritmo/margen) │ └──────────────────────────────────► Baserow (snapshots, acciones, creatividades, logs) │ Slack (informe + aprobación) │ approval_server.py (FastAPI, puerto propio) │ Baserow (ejecutar acción) │ Meta Ads API (aplicar) ``` --- ## Componentes ### `run.py` — Orquestador principal 1. Carga PPL/capping/familia por curso desde Airtable (`build_campaign_lookups`). 2. Sincroniza el catálogo Meta → Airtable (`Meta Ads Campaigns`, `MetaCampaignMes`). 3. Para cada campaña `RoiFormacion_*` ACTIVA en Meta: - Cuenta leads del mes vía `Leads Lake` (`get_leads_this_month_meta`). - `analyzer.analyze()` calcula urgencia/ritmo/margen/rentabilidad. - `agent.decide()` propone acción (PAUSE / REDUCE_BUDGET / INCREASE_BUDGET / MAINTAIN). - Analiza top-5 adsets (3d) y top-5 anuncios activos (3d+7d) — igual que Viviful. - Guarda snapshot diario en Baserow, actualiza `MetaCampaignMes` (consejo/criticidad/leads). 4. Envía informe a Slack agrupado por Familia. 5. Si `DRY_RUN=false`: ejecuta acciones aprobadas del día anterior. **Modo:** `DRY_RUN=true` por defecto. --- ### `analyzer.py` — Motor de negocio (puerto de leads-optimizer) Fórmulas de capping/PPL/ritmo, agnósticas de canal: - `ratio_leads = leads_entregados / capping`, `ritmo = ratio_leads - ratio_mes`. - `margen = (leads_entregados × PPL − gasto) / (leads_entregados × PPL)`. - **Urgencia:** `PAUSAR` (cap consumido) → `SPRINT` (atrasado, quedan pocos días) → `ACELERAR` / `FRENAR` (según ritmo) → `EN_RITMO`. ### `agent.py` — Agente de decisión (Claude Haiku/Sonnet) - **`decide(analysis)`**: traduce la urgencia/rentabilidad de `analyzer.py` a las mismas acciones que ya usa Viviful (`PAUSE/REDUCE_BUDGET/INCREASE_BUDGET/MAINTAIN`), para no tener que tocar `baserow_client.py`, `slack_notifier.py` ni `approval_server.py`. - **`analyze_unit(metrics, level)`**: igual que Viviful — granularidad táctica de adset/anuncio a 3d/7d, comparando contra `cpa_maximo` (= PPL × 0.70) en vez de `max_cpl`. - **`analyze_creative` / `analyze_creative_deep` / `compare_adset_creatives`**: idénticas a Viviful, no dependen del modelo de negocio. ### `meta_ads_client.py` Copia casi literal de Viviful. Único añadido: `get_all_campaigns()` (lista completa de campañas `RoiFormacion_*` independientemente del gasto, necesaria para sincronizar el catálogo de Airtable). ### `airtable_client.py` Cliente de la base compartida con `leads-optimizer`. **Solo lee** `Cursos` / `CentroCurso` / `CursoMes` (el catálogo de cursos se mantiene externamente, igual que en `leads-optimizer`). **Lee y escribe** `Meta Ads Campaigns` y `MetaCampaignMes`. `extract_cursoid(campaign_name)` — regex `roiformaci[oó]n_?(\d+)`, tolerante a variantes sin guion bajo tras el número (`RoiFormacion_1281Instaladores_...`). ### `baserow_client.py` Igual que Viviful salvo que **no existen las tablas `campaigns` ni `verticals`** (sustituidas por Airtable). `daily_snapshots` usa el campo `familia` en vez de `vertical`. ### `slack_notifier.py` Mismo patrón multi-mensaje que Viviful, agrupado por **Familia** en vez de vertical. No hay un "CPL objetivo" único por familia (cada curso tiene su propio PPL), así que las tarjetas de campaña muestran urgencia, ritmo y leads-consumidos/capping en vez de "CPL vs objetivo". ### `approval_server.py` Copia literal de Viviful. **Se despliega por separado**, en otro puerto de roiserver.com, apuntando a las tablas Baserow de este proyecto. --- ## Base de datos ### Airtable (compartida con leads-optimizer) | Tabla | Uso | Acceso | |-------|-----|--------| | `Cursos` | Catálogo de cursos, `CursoID`, `Familia` | solo lectura | | `CentroCurso` | PPL y % invalidación por centro | solo lectura | | `CursoMes` | Capping mensual por curso (compartido Meta+Google) | solo lectura | | `Leads Lake` | Leads de todos los canales (`attr_utm_source='Lead ads'` = Meta) | solo lectura | | `Meta Ads Campaigns` | Catálogo de campañas de Meta | lectura/escritura | | `MetaCampaignMes` | Estado mensual: PPL, cap, coste, leads, consejo, criticidad | lectura/escritura | Aprovisionar con `python setup_airtable_meta_tables.py` (una sola vez; requiere token con scope `schema.bases:write`; ⚠️ modifica una base ya en producción). ### Baserow (misma instancia self-hosted que Viviful, tablas nuevas) | Tabla | Contenido | |-------|-----------| | `proposed_actions` | Acciones propuestas (campañas y anuncios). Estados: `pending → approved/rejected → executed` | | `creative_analyses` | Análisis visual de creatividades | | `daily_snapshots` | Snapshot diario por campaña: métricas + decisión + `familia` + adsets_json + ads_json | | `execution_logs` | Log de cada ejecución | Aprovisionar con `python setup_baserow.py` (una sola vez). --- ## Variables de entorno (.env) Ver `.env.example`. Reutiliza las credenciales de Meta/Anthropic/Slack de `meta-optimizer` (misma cuenta de Meta, mismo bot de Slack con canal distinto) y las de Airtable de `leads-optimizer` (misma base). --- ## Automatización (GitHub Actions) `.github/workflows/daily.yml` — cron propuesto unas horas antes que el de Viviful para no competir por rate limit de la misma cuenta de Meta. `schedule` comentado por defecto (ejecución manual con `workflow_dispatch`). --- ## Notas de implementación importantes - **Capping compartido entre canales:** `CursoMes.Caping Admitido` lo consumen Meta y Google a la vez. Si un curso corre en ambos canales simultáneamente, cualquier cambio en cómo se cuenta el consumo debe revisarse en los dos proyectos (`meta-optimizer-formacion` y `leads-optimizer`). - **`margin` vs `margen_pct`:** en `run.py`, `margin` (€) es un proxy diario tipo Viviful (`leads × PPL − gasto`) para las tablas de Slack/Baserow; `margen_pct` es la rentabilidad acumulada del mes que calcula `analyzer.py` (`(ingreso − gasto) / ingreso`). No son la misma magnitud, no sumar una con otra. - **`send_slack_report.py`** reconstruye desde snapshots de Baserow, que no guardan `urgencia`/`leads_mes`/`capping` — al reenviar un informe esos campos salen con su valor por defecto (no es un bug, es una limitación conocida, igual que `bid_config={}` en la versión de Viviful). - **Regex de CursoID:** `roiformaci[oó]n_?(\d+)` — tolera nombres sin guion bajo tras el número. Si aparecen nuevas variantes de nomenclatura, ajustar `extract_cursoid()` en `airtable_client.py` (usado también por `run.py`, `backfill.py`, `send_slack_report.py`, `dashboard.py`, `analyze_creatives.py`).