Ports meta-optimizer's Meta Ads execution/approval/creative-analysis layer (agent.py, meta_ads_client.py, baserow_client.py, slack_notifier.py, approval_server.py) and replaces the per-vertical CPL model with the PPL + monthly-capping-per-course model already used by leads-optimizer, via a new airtable_client.py that shares Cursos/Familias/CentroCurso/ CursoMes/Leads Lake with that project and adds Meta Ads Campaigns / MetaCampaignMes alongside its Google Ads Campaigns / GACampaignMes.
194 lines
9.5 KiB
Markdown
194 lines
9.5 KiB
Markdown
# 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`).
|