José Manuel Gómez 9239e2f67f Initial scaffold: Meta Optimizer for RoiFormacion campaigns
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.
2026-07-07 16:53:03 +02:00

194 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`).