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.
9.5 KiB
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 mismoCursoIDque usaleads-optimizeren Airtable. - Los leads de Meta ya llegan hoy a la tabla
Leads Lakede Airtable (attr_utm_source='Lead ads',attr_cursoidresuelto) — 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(desdeFamilias) en la tablaCursosde 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 CampaignsyMetaCampaignMes(análogas aGoogle 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
- Carga PPL/capping/familia por curso desde Airtable (
build_campaign_lookups). - Sincroniza el catálogo Meta → Airtable (
Meta Ads Campaigns,MetaCampaignMes). - 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).
- Cuenta leads del mes vía
- Envía informe a Slack agrupado por Familia.
- 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 deanalyzer.pya las mismas acciones que ya usa Viviful (PAUSE/REDUCE_BUDGET/INCREASE_BUDGET/MAINTAIN), para no tener que tocarbaserow_client.py,slack_notifier.pyniapproval_server.py.analyze_unit(metrics, level): igual que Viviful — granularidad táctica de adset/anuncio a 3d/7d, comparando contracpa_maximo(= PPL × 0.70) en vez demax_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 Admitidolo 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-formacionyleads-optimizer). marginvsmargen_pct: enrun.py,margin(€) es un proxy diario tipo Viviful (leads × PPL − gasto) para las tablas de Slack/Baserow;margen_pctes la rentabilidad acumulada del mes que calculaanalyzer.py((ingreso − gasto) / ingreso). No son la misma magnitud, no sumar una con otra.send_slack_report.pyreconstruye desde snapshots de Baserow, que no guardanurgencia/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 quebid_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, ajustarextract_cursoid()enairtable_client.py(usado también porrun.py,backfill.py,send_slack_report.py,dashboard.py,analyze_creatives.py).