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.
This commit is contained in:
Jose Manuel 2026-07-07 16:53:03 +02:00
commit 9239e2f67f
22 changed files with 4295 additions and 0 deletions

37
.env.example Normal file
View File

@ -0,0 +1,37 @@
# Meta Ads (misma cuenta que meta-optimizer/Viviful)
META_APP_ID=your_app_id
META_APP_SECRET=your_app_secret
META_ACCESS_TOKEN=your_long_lived_access_token
META_AD_ACCOUNT_ID=act_XXXXXXXXXX
# Anthropic
ANTHROPIC_API_KEY=your_anthropic_key
# Baserow (self-hosted) — solo lo operativo de Meta: acciones, snapshots,
# creatividades y logs. Run setup_baserow.py once to get the IDs below:
BASEROW_URL=https://baserow.yourdomain.com
BASEROW_TOKEN=your_baserow_api_token
BASEROW_TABLE_ACTIONS=0
BASEROW_TABLE_CREATIVES=0
BASEROW_TABLE_LOGS=0
BASEROW_TABLE_SNAPSHOTS=0
# Solo necesarias para ejecutar setup_baserow.py una vez:
# BASEROW_EMAIL=
# BASEROW_PASSWORD=
# Airtable (misma base que leads-optimizer) — PPL, capping y Familia por curso.
# Run setup_airtable_meta_tables.py once to create "Meta Ads Campaigns" y
# "MetaCampaignMes" en esa base (requiere token con scope schema.bases:write).
AIRTABLE_TOKEN=your_airtable_personal_access_token
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
# Slack App (mismo bot que Viviful, canal dedicado a Formación)
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your_signing_secret
SLACK_CHANNEL_ID=C0XXXXXXXXX
# Campaign filter
META_CAMPAIGN_PREFIX=RoiFormacion
# Operation (set to false in production to actually execute actions)
DRY_RUN=true

52
.github/workflows/daily.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Daily Meta Optimizer Formación
on:
# schedule:
# - cron: '0 4 * * *' # 6:00 AM hora española — antes que Viviful (07:00), evita competir por rate limit de la misma cuenta Meta
workflow_dispatch:
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run optimizer
env:
META_APP_ID: ${{ secrets.META_APP_ID }}
META_APP_SECRET: ${{ secrets.META_APP_SECRET }}
META_ACCESS_TOKEN: ${{ secrets.META_ACCESS_TOKEN }}
META_AD_ACCOUNT_ID: ${{ secrets.META_AD_ACCOUNT_ID }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
BASEROW_URL: ${{ secrets.BASEROW_URL }}
BASEROW_TOKEN: ${{ secrets.BASEROW_TOKEN }}
BASEROW_TABLE_ACTIONS: ${{ secrets.BASEROW_TABLE_ACTIONS }}
BASEROW_TABLE_CREATIVES: ${{ secrets.BASEROW_TABLE_CREATIVES }}
BASEROW_TABLE_LOGS: ${{ secrets.BASEROW_TABLE_LOGS }}
BASEROW_TABLE_SNAPSHOTS: ${{ secrets.BASEROW_TABLE_SNAPSHOTS }}
AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }}
AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
META_CAMPAIGN_PREFIX: RoiFormacion
DRY_RUN: ${{ vars.DRY_RUN }}
run: python run.py
- name: Upload log
if: always()
uses: actions/upload-artifact@v4
with:
name: meta-optimizer-formacion-log-${{ github.run_id }}
path: logs/
retention-days: 30

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
logs/
__pycache__/
*.pyc
.venv/

193
CLAUDE.md Normal file
View File

@ -0,0 +1,193 @@
# 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`).

390
agent.py Normal file
View File

@ -0,0 +1,390 @@
import json
import base64
import requests
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
DECIDE_SYSTEM = """
Eres un experto en optimización de campañas de Meta Ads para cursos de formación.
Modelo de negocio: Ingreso = leads_entregados × PPL. Margen = (Ingreso - Gasto) / Ingreso.
El capping mensual admitido por curso es un recurso COMPARTIDO entre Meta Ads y Google Ads
para el mismo curso: cuando se alcanza, hay que dejar de comprar leads en TODOS los canales,
no solo en Meta.
Recibirás un análisis ya calculado con estos campos clave:
- urgencia: PAUSAR | SPRINT | ACELERAR | FRENAR | EN_RITMO (señal principal de decisión)
- rentable: true/false (cpa_actual <= cpa_maximo)
- ritmo: positivo = adelantado sobre el ritmo del capping, negativo = atrasado
- margen, leads_restantes, dias_restantes, capping, ppl, cpa_maximo, cpa_actual
- alerta_tracking: true si hay discrepancia grande entre los leads contados por Meta y los
de Leads Lake (Airtable) puede indicar leads fantasma o un problema de tracking/píxel
- status_meta: estado actual de la campaña en Meta (ACTIVE / PAUSED)
REGLAS DE DECISIÓN (según urgencia):
1. urgencia=PAUSAR (capping mensual ya consumido) action=PAUSE siempre, sin excepción.
2. urgencia=SPRINT (atrasado respecto al cap y quedan pocos días de mes) action=INCREASE_BUDGET,
parameter entre 1.3 y 1.5.
3. urgencia=ACELERAR y rentable=true action=INCREASE_BUDGET, parameter entre 1.1 y 1.25.
4. urgencia=ACELERAR y rentable=false action=MAINTAIN (no subir presupuesto perdiendo margen).
5. urgencia=FRENAR (muy adelantado sobre el ritmo del cap) action=REDUCE_BUDGET,
parameter entre 0.75 y 0.9.
6. urgencia=EN_RITMO y rentable=true action=MAINTAIN.
7. urgencia=EN_RITMO y rentable=false action=REDUCE_BUDGET, parameter=0.85.
8. Si capping=0 (sin límite definido este mes), ignora el ritmo respecto al cap y decide
solo por rentabilidad (rentable/cpa_actual vs cpa_maximo).
9. Si alerta_tracking=true, menciónalo explícitamente en "alert" sea cual sea la acción elegida.
10. Nunca propongas INCREASE_BUDGET si status_meta=PAUSED indica en "advice" que hay que
reactivar la campaña manualmente primero.
USA SIEMPRE como unidad de moneda en justification/advice. Responde SIEMPRE en español en esos campos.
Responde SOLO con JSON válido, sin texto adicional ni markdown:
{
"action": "PAUSE | REDUCE_BUDGET | INCREASE_BUDGET | MAINTAIN",
"parameter": 1.0,
"justification": "explicación breve en español usando €",
"advice": "acción concreta y específica a realizar",
"alert": "texto crítico si lo hay, null si no",
"confidence": 0.0
}
"""
def decide(analysis: dict) -> dict:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=400,
system=DECIDE_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analyze this Meta Ads campaign and return the decision as JSON:\n\n"
+ json.dumps(analysis, ensure_ascii=False, indent=2)
),
}],
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
import re as _re
m = _re.search(r"\{.*\}", clean, _re.DOTALL)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
return {
"action": "MAINTAIN",
"parameter": 1.0,
"justification": "Error parsing agent response.",
"advice": "",
"alert": f"Invalid JSON: {raw[:200]}",
"confidence": 0.0,
}
UNIT_SYSTEM = """
Eres un analista experto en Meta Ads. Analiza las métricas del conjunto de anuncios indicado.
Los datos corresponden a los últimos 3 días (ventana estándar de análisis).
USA SIEMPRE como unidad de moneda. Responde SIEMPRE en español.
Si el conjunto tiene cost_cap_eur (cap de coste), compara el CPL actual con ese cap e indica si está
por encima, dentro o por debajo del límite, y cuánto margen queda (o cuánto se supera).
Responde SOLO con JSON válido (sin markdown):
{"evaluacion": "resumen del rendimiento en 2 frases usando €", "recomendacion": "una acción concreta"}
"""
AD_SYSTEM = """
Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del anuncio indicado.
Los datos incluyen dos ventanas temporales:
- cpl_3d / leads_3d / spend_3d: últimos 3 días (puede ser volátil con poco volumen)
- cpl_7d / leads_7d: últimos 7 días (más estable, úsala como referencia principal)
Los campos spend/leads/cpl sin sufijo corresponden a 7 días.
cpa_maximo es el coste por lead máximo rentable para el curso (PPL × 0.70).
USA SIEMPRE como unidad de moneda. Responde SIEMPRE en español.
Responde SOLO con JSON válido (sin markdown):
{"evaluacion": "resumen del rendimiento en 2 frases usando €, mencionando diferencia 3d/7d si es relevante", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"}
Reglas para "accion": "PAUSE":
- SOLO si leads_7d == 0 Y el gasto de 7 días supera 3 veces cpa_maximo (o el PPL si no hay
cpa_maximo, o 15 si tampoco hay PPL).
- Si cpl_3d es alto pero cpl_7d está dentro del objetivo, usa "MAINTAIN" (ruido estadístico de período corto).
- Si el anuncio tiene leads en 7 días aunque el CPL sea alto, usa "MAINTAIN" y recomienda optimizar.
En cualquier otro caso usa "accion": "MAINTAIN".
"""
def analyze_unit(metrics: dict, level: str = "adset") -> dict:
"""Análisis rápido de un conjunto de anuncios o anuncio individual."""
nivel = "conjunto de anuncios" if level == "adset" else "anuncio"
system = AD_SYSTEM if level == "ad" else UNIT_SYSTEM
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=250,
system=system,
messages=[{
"role": "user",
"content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False),
}],
)
raw = response.content[0].text.strip()
import re
clean = re.sub(r"```json\s*", "", raw)
clean = re.sub(r"```\s*", "", clean).strip()
clean = clean.replace("", '"').replace("", '"') # normalize smart quotes
# Strategy 1: direct parse
try:
return json.loads(clean)
except json.JSONDecodeError:
pass
# Strategy 2: extract first JSON object by brace boundaries
start, end = clean.find("{"), clean.rfind("}")
if start != -1 and end > start:
try:
return json.loads(clean[start:end + 1])
except json.JSONDecodeError:
pass
# Strategy 3: extract fields individually with regex
ev_m = re.search(r'"evaluacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
rec_m = re.search(r'"recomendacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
if ev_m or rec_m:
return {
"evaluacion": ev_m.group(1) if ev_m else "",
"recomendacion": rec_m.group(1) if rec_m else "",
}
return {"evaluacion": clean[:150], "recomendacion": ""}
CREATIVE_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis de creatividades de Meta Ads.
Analiza la imagen publicitaria y devuelve SOLO JSON válido sin markdown:
{
"score": 7.5,
"analysis": "análisis conciso en español: mensaje, diseño, CTA, atractivo visual",
"recommendations": "mejoras concretas en español para mejorar CTR y conversiones"
}
Score 1-10: 1-3 crítico, 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
"""
def analyze_creative(image_url: str, ad_name: str) -> dict:
try:
resp = requests.get(image_url, timeout=15)
resp.raise_for_status()
image_data = base64.standard_b64encode(resp.content).decode("utf-8")
media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0]
except Exception as e:
return {"score": 0, "analysis": f"Failed to download image: {e}", "recommendations": ""}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=600,
system=CREATIVE_SYSTEM,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_data,
},
},
{
"type": "text",
"text": f'Ad name: "{ad_name}". Analyze this creative.',
},
],
}],
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
return json.loads(clean)
except json.JSONDecodeError:
return {"score": 0, "analysis": "Error parsing creative analysis.", "recommendations": ""}
except Exception as e:
return {"score": 0, "analysis": f"Creative analysis failed: {e}", "recommendations": ""}
CREATIVE_DEEP_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis de creatividades de Meta Ads con conocimiento de neuromarketing y diseño persuasivo.
Recibirás una imagen publicitaria junto con sus métricas de rendimiento reales.
Evalúa considerando:
1. CALIDAD VISUAL: claridad del mensaje, jerarquía visual, CTA, copy, atractivo y relevancia
2. CORRELACIÓN CON RENDIMIENTO: ¿el CTR y CPL reales son consistentes con la calidad visual?
3. SEÑAL DE FATIGA: si CTR 3d < CTR 7d × 0.75 indica saturación de audiencia
4. RECOMENDACIONES: mejoras concretas y priorizadas para mejorar CTR y conversiones
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
{
"score": 7.5,
"analysis": "análisis conciso en español: qué funciona, qué no, correlación con rendimiento real",
"recommendations": "mejoras concretas en español en orden de impacto esperado",
"fatigue": false,
"fatigue_reason": null
}
Score 1-10: 1-3 crítico (pausar), 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
Si el anuncio tiene buen rendimiento real (CPL bajo, CTR alto) pero diseño mediocre, sube el score.
Si el diseño parece bueno pero el rendimiento es pobre, baja el score y explica la desconexión.
"""
CREATIVE_COMPARE_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis comparativo de creatividades de Meta Ads.
Recibirás varios anuncios del mismo adset con sus imágenes y métricas de rendimiento.
Evalúa cuál funciona mejor considerando tanto calidad visual como rendimiento real.
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
{
"winner": "nombre exacto del anuncio ganador",
"ranking": [
{"name": "nombre completo del anuncio", "rank": 1, "reason": "razón en español"}
],
"insights": "observación comparativa clave en español: ¿qué diferencia visualmente al ganador del resto?"
}
"""
def _download_image(image_url) -> tuple | None:
"""Returns (base64_data, media_type) or None. Accepts str or list of URLs (tries in order)."""
urls = [image_url] if isinstance(image_url, str) else image_url
for url in urls:
if not url:
continue
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
content_type = resp.headers.get("content-type", "image/jpeg")
if not content_type.startswith("image/"):
continue
data = base64.standard_b64encode(resp.content).decode("utf-8")
return data, content_type.split(";")[0]
except Exception:
continue
return None
def _parse_json_response(raw: str) -> dict:
import re
clean = re.sub(r"```json\s*", "", raw.strip())
clean = re.sub(r"```\s*", "", clean).strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
start, end = clean.find("{"), clean.rfind("}")
if start != -1 and end > start:
try:
return json.loads(clean[start:end + 1])
except json.JSONDecodeError:
pass
return {}
def analyze_creative_deep(image_url: str, ad_name: str, metrics: dict) -> dict:
"""Deep creative analysis combining visual quality with performance data and fatigue detection."""
_default = {"score": 0, "analysis": "", "recommendations": "", "fatigue": False, "fatigue_reason": None}
downloaded = _download_image(image_url)
if not downloaded:
return {**_default, "analysis": "Error descargando imagen."}
image_data, media_type = downloaded
ctr_7d = metrics.get("ctr_7d", 0)
ctr_3d = metrics.get("ctr_3d", 0)
fatigue_hint = ""
if ctr_7d > 0 and ctr_3d > 0 and ctr_3d < ctr_7d * 0.75:
fatigue_hint = f"\n⚠️ SEÑAL DE FATIGA DETECTADA: CTR cayó de {ctr_7d:.2f}% (7d) a {ctr_3d:.2f}% (3d) — posible saturación"
context = (
f'Anuncio: "{ad_name}"\n\n'
f"Métricas reales:\n"
f"- 7 días: gasto {metrics.get('spend_7d', 0):.0f}€, "
f"{metrics.get('leads_7d', 0)} leads, "
f"CPL {metrics.get('cpl_7d', 0):.2f}€, "
f"CTR {ctr_7d:.2f}%\n"
f"- 3 días: gasto {metrics.get('spend_3d', 0):.0f}€, "
f"{metrics.get('leads_3d', 0)} leads, "
f"CPL {metrics.get('cpl_3d', 0):.2f}€, "
f"CTR {ctr_3d:.2f}%\n"
f"- Coste por lead máximo rentable (cpa_maximo): {metrics.get('cpa_maximo', 0):.2f}"
f"{fatigue_hint}\n\n"
f"Analiza esta creatividad:"
)
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=700,
system=CREATIVE_DEEP_SYSTEM,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": media_type, "data": image_data}},
{"type": "text", "text": context},
],
}],
)
result = _parse_json_response(response.content[0].text)
if not result:
return {**_default, "analysis": "Error parseando respuesta."}
result.setdefault("fatigue", False)
result.setdefault("fatigue_reason", None)
return result
except Exception as e:
return {**_default, "analysis": f"Error en análisis: {e}"}
def compare_adset_creatives(ads: list) -> dict:
"""Compare up to 4 ads within the same adset. ads: list of analyzed ad dicts."""
_default = {"winner": "", "ranking": [], "insights": "Sin datos suficientes para comparar."}
top_ads = sorted(ads, key=lambda x: -x.get("spend_7d", 0))[:4]
content_blocks = []
for i, ad in enumerate(top_ads, 1):
downloaded = _download_image(ad["image_url"])
if downloaded:
image_data, media_type = downloaded
content_blocks.append({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": image_data},
})
fatigue_note = f" ⚠️ Fatiga: {ad.get('fatigue_reason','')}" if ad.get("fatigue") else ""
content_blocks.append({
"type": "text",
"text": (
f"Anuncio {i}: \"{ad['ad_name']}\"\n"
f"Score: {ad.get('score', 0):.1f}/10 | "
f"CTR 7d: {ad.get('ctr_7d', 0):.2f}% | "
f"CPL 7d: {ad.get('cpl_7d', 0):.2f}€ | "
f"Leads 7d: {ad.get('leads_7d', 0)} | "
f"Gasto 7d: {ad.get('spend_7d', 0):.0f}"
f"{fatigue_note}"
),
})
if not content_blocks:
return _default
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=600,
system=CREATIVE_COMPARE_SYSTEM,
messages=[{"role": "user", "content": content_blocks}],
)
result = _parse_json_response(response.content[0].text)
return result if result else _default
except Exception as e:
return {**_default, "insights": f"Error en comparativa: {e}"}

347
airtable_client.py Normal file
View File

@ -0,0 +1,347 @@
"""
Client for the shared Airtable base (same base used by leads-optimizer).
Reused as-is from leads-optimizer: Cursos / CentroCurso / CursoMes / Leads Lake
are READ-ONLY here the course catalog, PPL per center and monthly capping are
maintained externally (manually, or by leads-optimizer). This client only
creates/updates "Meta Ads Campaigns" and "MetaCampaignMes", the Meta-specific
tables that sit alongside "Google Ads Campaigns" / "GACampaignMes".
"""
import re
from datetime import datetime
from pyairtable import Api
import config
MESES_ES = {
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre",
}
def extract_cursoid(campaign_name: str) -> str | None:
"""
RoiFormacion_884_Curso_Desarrollador_leadads -> "884"
RoiFormacion_1281Instaladores_24_leadads -> "1281" (sin guion bajo tras el id)
Tolera también la variante con tilde (RoiFormación) por si reaparece.
"""
m = re.search(r'roiformaci[oó]n_?(\d+)', campaign_name, re.IGNORECASE)
return m.group(1) if m else None
class AirtableClient:
def __init__(self):
self.api = Api(config.AIRTABLE_TOKEN)
self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE)
self.cursos = self.api.table(config.AIRTABLE_BASE_ID, config.CURSOS_TABLE)
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, config.CENTROCURSO_TABLE)
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, config.CURSOMES_TABLE)
self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.META_CAMPAIGNS_TABLE)
self.metacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, config.META_CAMPAIGNMES_TABLE)
# ------------------------------------------------------------------ #
# Lookups de negocio (PPL, capping, familia) — solo lectura #
# ------------------------------------------------------------------ #
def build_campaign_lookups(self, as_of_date: str = None) -> tuple[dict, dict, dict]:
"""
3 llamadas bulk, igual que leads-optimizer, más el lookup de Familia
(no existe en leads-optimizer porque Google no lo necesitaba para nada
operativo; aquí sustituye al concepto de "vertical" de Viviful).
as_of_date (YYYY-MM-DD): usa el capping del mes de esa fecha en vez del
mes en curso (lo usa backfill.py para reconstruir estados pasados).
Devuelve (ppl_lookup, cap_lookup, familia_lookup), todos keyed por
cursoid_text.
"""
ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now()
mes_nombre = MESES_ES[ref.month]
anio_str = str(ref.year)
cc_records = self.centrocurso.all(
formula="{Estado ROI}='ABIERTO'",
fields=["PPL", "% invalidación (from Centros)"],
)
cc_data = {}
for r in cc_records:
ppl_val = float(r["fields"].get("PPL") or 0)
pct_raw = r["fields"].get("% invalidación (from Centros)", 0)
pct = float(pct_raw[0]) if isinstance(pct_raw, list) and pct_raw else float(pct_raw or 0)
cc_data[r["id"]] = {"ppl": ppl_val, "pct": pct}
cursos_records = self.cursos.all(
fields=["CursoID", "CentroCurso", "Familia (from Familias)"]
)
curso_by_recordid = {}
curso_to_cc = {}
familia_lookup = {}
for r in cursos_records:
cid = r["fields"].get("CursoID")
if cid is None:
continue
cursoid_text = str(int(cid))
curso_by_recordid[r["id"]] = cursoid_text
curso_to_cc[cursoid_text] = r["fields"].get("CentroCurso", [])
familia = r["fields"].get("Familia (from Familias)")
familia_lookup[cursoid_text] = familia[0] if isinstance(familia, list) and familia else (familia or "Sin familia")
ppl_lookup = {}
for cursoid_text, cc_ids in curso_to_cc.items():
total_ppl = sum(
cc_data[cc_id]["ppl"] * cc_data[cc_id]["pct"]
for cc_id in cc_ids if cc_id in cc_data
)
ppl_lookup[cursoid_text] = round(total_ppl, 2)
cap_formula = f"AND({{Mes}}='{mes_nombre}',{{Año}}='{anio_str}')"
cursomes_records = self.cursomes.all(formula=cap_formula, fields=["CursoID", "Caping Admitido"])
cap_lookup = {}
for r in cursomes_records:
cap = int(r["fields"].get("Caping Admitido") or 0)
for curso_rec_id in r["fields"].get("CursoID", []):
cursoid_text = curso_by_recordid.get(curso_rec_id)
if cursoid_text:
cap_lookup[cursoid_text] = cap
return ppl_lookup, cap_lookup, familia_lookup
def get_leads_this_month_meta(self, campaign_name: str, as_of_date: str = None) -> tuple[int, list[str]]:
"""
Leads acumulados en el mes atribuidos a un curso vía Meta Lead Ads,
hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se indica
(as_of_date lo usa backfill.py para reconstruir el estado histórico
del mes en una fecha pasada).
Los leads de Meta ya llegan a Leads Lake con attr_utm_source='Lead ads'
y attr_cursoid resuelto (confirmado con datos reales) a diferencia de
Google, aquí solo hay una vía de atribución, no cinco.
"""
course_num = extract_cursoid(campaign_name)
if not course_num:
return 0, []
ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now()
mes_inicio = f"{ref.year}-{ref.month:02d}-01"
fin_clause = f",{{creado}}<'{(ref).strftime('%Y-%m-%d')}T23:59:59.999Z'" if as_of_date else ""
formula = (
f"AND({{attr_utm_source}}='Lead ads',"
f"{{attr_cursoid}}='{course_num}',"
f"{{creado}}>='{mes_inicio}'{fin_clause})"
)
records = self.leads.all(formula=formula, fields=["attr_cursoid"])
ids = [r["id"] for r in records]
return len(ids), ids
# ------------------------------------------------------------------ #
# Meta Ads Campaigns (catálogo) #
# ------------------------------------------------------------------ #
def sync_campaigns_from_meta_ads(self, meta_campaigns: list[dict], ppl_lookup: dict = None) -> dict:
"""
Sincroniza el catálogo de campañas de Meta -> Airtable.
meta_campaigns: [{id, name, status}] (status: "ACTIVE"|"PAUSED"|...)
A diferencia de "Google Ads Campaigns" (donde 'CursoID Text' ya existe
como campo pre-poblado), aquí lo calculamos y escribimos nosotros mismos
al crear la tabla desde cero.
"""
STATUS_MAP = {"ACTIVE": "Activa"}
all_records = self.campaigns.all()
at_by_cid = {
str(r["fields"].get("CampaignID", "")).strip(): r
for r in all_records if r["fields"].get("CampaignID")
}
created, updated = [], []
to_create, to_update = [], []
for mc in meta_campaigns:
cid = mc["id"]
at_status = STATUS_MAP.get(mc["status"], "Pausada")
at_record = at_by_cid.get(cid)
cursoid_text = extract_cursoid(mc["name"]) or ""
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
if at_record is None:
fields = {
"Campaign Name": mc["name"],
"CampaignID": cid,
"Status": at_status,
"CursoID Text": cursoid_text,
}
if ppl_lookup is not None:
fields["PPL"] = ppl
to_create.append(fields)
created.append({"name": mc["name"], "id": cid, "status": at_status})
else:
changes = {}
if at_record["fields"].get("Campaign Name") != mc["name"]:
changes["Campaign Name"] = mc["name"]
if at_record["fields"].get("Status") != at_status:
changes["Status"] = at_status
if at_record["fields"].get("CursoID Text") != cursoid_text:
changes["CursoID Text"] = cursoid_text
if ppl_lookup is not None and at_record["fields"].get("PPL") != ppl:
changes["PPL"] = ppl
if changes:
to_update.append((at_record["id"], changes))
updated.append({"name": mc["name"], "id": cid, "changes": changes})
for i in range(0, len(to_create), 10):
self.campaigns.batch_create(to_create[i:i + 10])
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]]
self.campaigns.batch_update(batch)
return {"created": created, "updated": updated, "at_by_cid": at_by_cid}
# ------------------------------------------------------------------ #
# MetaCampaignMes (estado mensual) #
# ------------------------------------------------------------------ #
def sync_metacampaignmes(
self,
meta_campaigns: list[dict],
monthly_metrics: dict,
ppl_lookup: dict,
cap_lookup: dict,
at_by_cid: dict,
) -> dict:
"""
Crea/actualiza MetaCampaignMes para el mes/año en curso.
monthly_metrics: {campaign_id: {spend, leads, ...}} mes-a-la-fecha
(viene de meta.get_campaign_metrics(inicio_mes, ayer)).
"""
STATUS_MAP = {"ACTIVE": "Activa"}
now = datetime.now()
mes_num, anio_str = str(now.month), str(now.year)
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
existing = self.metacampaignmes.all(formula=formula)
mcm_by_at_cid = {}
for r in existing:
at_cids = r["fields"].get("CampaignID", [])
if at_cids:
mcm_by_at_cid[at_cids[0]] = r
to_create, to_update = [], []
for mc in meta_campaigns:
cid = mc["id"]
at_record = at_by_cid.get(cid)
if not at_record:
continue
at_cid = at_record["id"]
cursoid_text = extract_cursoid(mc["name"]) or ""
metrics = (monthly_metrics or {}).get(cid, {})
conv = round(metrics.get("leads", 0), 2)
cost = round(metrics.get("spend", 0), 2)
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
cpa_max = round(ppl * 0.70, 2)
cap = int((cap_lookup or {}).get(cursoid_text, 0))
at_status = STATUS_MAP.get(mc["status"], "Pausada")
mcm_record = mcm_by_at_cid.get(at_cid)
if mcm_record is None:
to_create.append({
"CampaignID": [at_cid],
"Mes": mes_num,
"Año": anio_str,
"PPL": ppl,
"CPAMax": cpa_max,
"CapTotalMes": cap,
"CosteMes": cost,
"ConvMes": conv,
"Status": at_status,
})
else:
f = mcm_record["fields"]
changes = {}
if f.get("CosteMes") != cost:
changes["CosteMes"] = cost
if f.get("ConvMes") != conv:
changes["ConvMes"] = conv
if f.get("PPL") != ppl:
changes["PPL"] = ppl
if f.get("CPAMax") != cpa_max:
changes["CPAMax"] = cpa_max
if f.get("CapTotalMes") != cap:
changes["CapTotalMes"] = cap
if f.get("Status") != at_status:
changes["Status"] = at_status
if changes:
to_update.append((mcm_record["id"], changes))
for i in range(0, len(to_create), 10):
self.metacampaignmes.batch_create(to_create[i:i + 10], typecast=True)
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": f} for rid, f in to_update[i:i + 10]]
self.metacampaignmes.batch_update(batch, typecast=True)
return {"created": len(to_create), "updated": len(to_update)}
def get_active_metacampaignmes(self) -> list[dict]:
"""Lee MetaCampaignMes del mes/año en curso, resolviendo el Meta campaign ID."""
now = datetime.now()
mes_num, anio_str = str(now.month), str(now.year)
campaigns_records = self.campaigns.all(fields=["CampaignID"])
at_id_to_cid, cid_to_at_id = {}, {}
for r in campaigns_records:
cid = str(r["fields"].get("CampaignID", "")).strip()
if cid:
at_id_to_cid[r["id"]] = cid
cid_to_at_id[cid] = r["id"]
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
records = self.metacampaignmes.all(formula=formula)
result = []
for r in records:
f = r["fields"]
at_cids = f.get("CampaignID", [])
cid = at_id_to_cid.get(at_cids[0], "") if at_cids else ""
if not cid:
continue
result.append({
"airtable_id": r["id"],
"campaign_at_id": cid_to_at_id.get(cid, ""),
"meta_campaign_id": cid,
"ppl": float(f.get("PPL") or 0),
"capping_mensual": int(f.get("CapTotalMes") or 0),
"cpa_maximo": float(f.get("CPAMax") or 0),
"conv_mes": float(f.get("ConvMes") or 0),
})
return result
def batch_update_status(self, updates: list[tuple[str, str, str]]) -> None:
"""updates: [(mcm_record_id, campaign_at_id, meta_status)], meta_status: 'ACTIVE'|'PAUSED'."""
STATUS_MAP = {"ACTIVE": "Activa"}
mcm_batch, cat_batch = [], []
for mcm_id, cat_id, meta_status in updates:
at_status = STATUS_MAP.get(meta_status, "Pausada")
if mcm_id:
mcm_batch.append({"id": mcm_id, "fields": {"Status": at_status}})
if cat_id:
cat_batch.append({"id": cat_id, "fields": {"Status": at_status}})
for i in range(0, len(mcm_batch), 10):
self.metacampaignmes.batch_update(mcm_batch[i:i + 10])
for i in range(0, len(cat_batch), 10):
self.campaigns.batch_update(cat_batch[i:i + 10])
def batch_update_metacampaignmes_advice(self, updates: list[tuple[str, str, str, str]]) -> None:
"""updates: [(mcm_record_id, consejo, criticidad, log_text)]."""
batch = [
{"id": rid, "fields": {"Consejo": consejo, "Criticidad": criticidad, "Log": log_text}}
for rid, consejo, criticidad, log_text in updates
]
for i in range(0, len(batch), 10):
self.metacampaignmes.batch_update(batch[i:i + 10], typecast=True)
def batch_update_metacampaignmes_final_leads(self, updates: list[tuple[str, int]]) -> None:
"""updates: [(mcm_record_id, leads_lake_count)] -> ConvLeadsLakeMesFinal."""
batch = [{"id": rid, "fields": {"ConvLeadsLakeMesFinal": leads}} for rid, leads in updates]
for i in range(0, len(batch), 10):
self.metacampaignmes.batch_update(batch[i:i + 10], typecast=True)

208
analyze_creatives.py Normal file
View File

@ -0,0 +1,208 @@
"""
Análisis profundo de creatividades de Meta Ads.
Analiza visualmente cada anuncio activo, correlaciona con métricas de rendimiento,
detecta fatiga creativa y compara anuncios dentro del mismo adset.
Uso:
python analyze_creatives.py # todas las campañas
python analyze_creatives.py --campaign RoiFormacion_884 # filtrar por nombre
python analyze_creatives.py --no-slack # sin envío a Slack
"""
import argparse
import sys
import time
from datetime import datetime
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from baserow_client import BaserowClient
from agent import analyze_creative_deep, compare_adset_creatives
from slack_notifier import send_creative_analysis_report
def main():
parser = argparse.ArgumentParser(description="Deep creative analysis for Meta Ads")
parser.add_argument("--campaign", help="Filter campaigns by name substring (case-insensitive)")
parser.add_argument("--no-slack", action="store_true", help="Skip Slack report")
args = parser.parse_args()
meta = MetaAdsClient()
db = BaserowClient()
airtable = AirtableClient()
print(f"\n{'='*60}")
print(f" ANÁLISIS DE CREATIVIDADES FORMACIÓN — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*60}\n")
ppl_lookup, _, _ = airtable.build_campaign_lookups()
# Active campaigns (last 7 days)
campaigns = meta.get_period_campaign_metrics(7)
if args.campaign:
campaigns = {k: v for k, v in campaigns.items()
if args.campaign.upper() in v["name"].upper()}
if not campaigns:
print("No hay campañas activas en los últimos 7 días.")
return
print(f"{len(campaigns)} campañas a analizar\n")
all_results: dict = {}
total_analyzed = 0
total_errors = 0
for cid, camp_metrics in campaigns.items():
campaign_name = camp_metrics["name"]
ppl = ppl_lookup.get(extract_cursoid(campaign_name) or "", 0)
cpa_maximo = round(ppl * 0.70, 2) if ppl else 0.0
print(f"{campaign_name} (PPL: {ppl:.2f}€ · CPA máximo: {cpa_maximo:.2f}€)")
ads_with_creatives = meta.get_ads_with_creatives(cid)
if not ads_with_creatives:
print(" — sin anuncios activos con creatividades, omitiendo\n")
continue
# Metrics for both windows
ads_7d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, 7)}
ads_3d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, 3)}
# Adset name lookup from 7d metrics
adset_names = {a["id"]: a["name"] for a in meta.get_period_adset_metrics(cid, 7)}
# Group ads by adset
adset_groups: dict = {}
for ad in ads_with_creatives:
if not ad.get("thumbnail_url"):
continue
ad_id = ad["ad_id"]
adset_id = ad.get("adset_id", "unknown")
adset_name = adset_names.get(adset_id, adset_id)
m7 = ads_7d.get(ad_id, {})
m3 = ads_3d.get(ad_id, {})
metrics = {
"spend_7d": m7.get("spend", 0),
"leads_7d": m7.get("leads", 0),
"cpl_7d": m7.get("cpl", 0),
"ctr_7d": m7.get("ctr", 0),
"spend_3d": m3.get("spend", 0),
"leads_3d": m3.get("leads", 0),
"cpl_3d": m3.get("cpl", 0),
"ctr_3d": m3.get("ctr", 0),
"cpa_maximo": cpa_maximo,
}
if adset_id not in adset_groups:
adset_groups[adset_id] = {"name": adset_name, "ads": []}
adset_groups[adset_id]["ads"].append({
"ad_id": ad_id,
"ad_name": ad["ad_name"],
"campaign_id": cid,
"adset_id": adset_id,
"adset_name": adset_name,
# Fallback chain: signed thumbnail → permanent video picture → static image_url
"image_url": [ad["thumbnail_url"], ad["video_thumbnail_url"], ad["image_url"]],
**metrics,
})
# Analyze each ad individually
analyzed_adsets: dict = {}
for adset_id, adset_data in adset_groups.items():
adset_name = adset_data["name"]
analyzed_ads = []
for ad in adset_data["ads"]:
short_name = ad["ad_name"][:50]
print(f" [{adset_name[:30]}] {short_name}...", end=" ", flush=True)
result = analyze_creative_deep(
image_url=ad["image_url"],
ad_name=ad["ad_name"],
metrics={k: ad[k] for k in (
"spend_7d", "leads_7d", "cpl_7d", "ctr_7d",
"spend_3d", "leads_3d", "cpl_3d", "ctr_3d", "cpa_maximo"
)},
)
score = result.get("score", 0)
fatigue_flag = "FATIGA" if result.get("fatigue") else ""
print(f"score={score:.1f}{fatigue_flag}")
ad_result = {**ad, **result}
analyzed_ads.append(ad_result)
# Save to Baserow
analysis_text = result.get("analysis", "")
if result.get("fatigue") and result.get("fatigue_reason"):
analysis_text += f"\n\n⚠️ FATIGA CREATIVA: {result['fatigue_reason']}"
try:
urls = ad["image_url"]
saved_url = urls[0] if isinstance(urls, list) else urls
db.save_creative_analysis({
"ad_id": ad["ad_id"],
"ad_name": ad["ad_name"],
"campaign_id": cid,
"image_url": saved_url,
"analysis": analysis_text,
"score": score,
"recommendations": result.get("recommendations", ""),
})
total_analyzed += 1
except Exception as e:
print(f" [WARN] Baserow: {e}")
total_errors += 1
time.sleep(0.3)
# Comparative analysis for adsets with 2+ ads
comparison = None
if len(analyzed_ads) >= 2:
print(f" [comparativa] {adset_name[:40]}...", end=" ", flush=True)
comparison = compare_adset_creatives(analyzed_ads)
winner = comparison.get("winner", "")
print(f"ganador: {winner[:40]}")
analyzed_adsets[adset_id] = {
"name": adset_name,
"ads": analyzed_ads,
"comparison": comparison,
}
all_results[cid] = {
"name": campaign_name,
"cpa_maximo": cpa_maximo,
"adsets": analyzed_adsets,
}
print()
# Summary
total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values())
total_fatigue = sum(
1 for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("fatigue")
)
print(f"{'='*60}")
print(f" Finalizado: {len(all_results)} campañas, {total_ads} anuncios analizados")
if total_fatigue:
print(f" ⚠️ {total_fatigue} anuncios con fatiga creativa detectada")
if total_errors:
print(f" ⚠️ {total_errors} errores al guardar en Baserow")
print(f"{'='*60}\n")
# Slack report
if not args.no_slack and all_results:
print("→ Enviando informe a Slack...")
send_creative_analysis_report(all_results)
print(" ✓ Informe enviado.")
if __name__ == "__main__":
main()

72
analyzer.py Normal file
View File

@ -0,0 +1,72 @@
"""
Calcula métricas derivadas y urgencia para una campaña de Meta Ads ligada a un
curso. Puerto directo de leads-optimizer/analyzer.py: las fórmulas de PPL,
capping y ritmo son agnósticas de canal (Google/Meta), solo cambian los nombres
de los campos de origen de las métricas de gasto/conversiones.
"""
from datetime import datetime
import calendar
def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> dict:
now = datetime.now()
dias_mes = calendar.monthrange(now.year, now.month)[1]
dia_actual = now.day
ratio_mes = dia_actual / dias_mes
capping = campaign_config["capping_mensual"]
ppl = campaign_config["ppl"]
cpa_max = campaign_config["cpa_maximo"]
gasto = ads_metrics.get("spend", 0)
conversiones_meta = ads_metrics.get("leads", 0)
ratio_leads = leads_entregados / capping if capping > 0 else 0
cpa_actual = gasto / leads_entregados if leads_entregados > 0 else 0
revenue = leads_entregados * ppl
margen = (revenue - gasto) / revenue if revenue > 0 else 0
leads_restantes = capping - leads_entregados
dias_restantes = dias_mes - dia_actual
ritmo = ratio_leads - ratio_mes # positivo = adelantado, negativo = atrasado
if ratio_leads >= 1.0:
urgencia = "PAUSAR"
elif capping > 0 and ratio_leads < ratio_mes - 0.15 and dias_restantes <= 5:
urgencia = "SPRINT"
elif ritmo < -0.15:
urgencia = "ACELERAR"
elif ritmo > 0.15:
urgencia = "FRENAR"
else:
urgencia = "EN_RITMO"
# Discrepancia en ambas direcciones entre el conteo propio de Meta y el de
# Leads Lake (Airtable) — puede indicar leads fantasma o tracking roto.
conv_leads_lake_mes = campaign_config.get("conv_leads_lake_mes", leads_entregados)
discrepancia = abs(conversiones_meta - conv_leads_lake_mes)
return {
"curso": campaign_config["curso"],
"campaign_id": campaign_config["meta_campaign_id"],
"ppl": ppl,
"cpa_maximo": cpa_max,
"capping": capping,
"leads_entregados": leads_entregados,
"leads_restantes": leads_restantes,
"dias_restantes": dias_restantes,
"ratio_leads": round(ratio_leads, 3),
"ratio_mes": round(ratio_mes, 3),
"ritmo": round(ritmo, 3),
"urgencia": urgencia,
"cpa_actual": round(cpa_actual, 2),
"rentable": cpa_actual <= cpa_max if cpa_actual > 0 else True,
"margen": round(margen, 3),
"revenue_estimado": round(revenue, 2),
"gasto_acumulado": round(gasto, 2),
"budget_diario_actual": ads_metrics.get("budget_daily", 0),
"ctr": ads_metrics.get("ctr", 0),
"clicks": ads_metrics.get("clicks", 0),
"conversiones_meta": conversiones_meta,
"discrepancia_tracking": discrepancia,
"alerta_tracking": discrepancia > 10,
"status_meta": ads_metrics.get("status", "UNKNOWN"),
}

84
approval_server.py Normal file
View File

@ -0,0 +1,84 @@
"""
FastAPI server that receives Slack interactive button callbacks (Approve / Reject).
Setup:
1. Create a Slack App, enable Interactivity, set Request URL to:
https://your-domain.com/slack/actions
2. Set SLACK_SIGNING_SECRET in your .env
3. Run: uvicorn approval_server:app --host 0.0.0.0 --port 3000
(for local dev: use ngrok to expose port 3000)
"""
import hashlib
import hmac
import json
import time
import urllib.parse
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import config
from baserow_client import BaserowClient
import slack_notifier
app = FastAPI()
baserow = BaserowClient()
def _verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
basestring = f"v0:{timestamp}:{body.decode()}"
computed = "v0=" + hmac.new(
config.SLACK_SIGNING_SECRET.encode(),
basestring.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, signature)
@app.post("/slack/actions")
async def slack_actions(request: Request):
body = await request.body()
timestamp = request.headers.get("X-Slack-Request-Timestamp", "0")
signature = request.headers.get("X-Slack-Signature", "")
if not _verify_slack_signature(body, timestamp, signature):
raise HTTPException(status_code=401, detail="Invalid Slack signature")
form = urllib.parse.parse_qs(body.decode())
payload = json.loads(form.get("payload", ["{}"])[0])
actions = payload.get("actions", [])
if not actions:
return JSONResponse({"ok": True})
action = actions[0]
value = action.get("value", "") # "approve:42" or "reject:42"
channel = payload.get("channel", {}).get("id", config.SLACK_CHANNEL_ID)
message_ts = payload.get("message", {}).get("ts")
try:
verb, row_id_str = value.split(":", 1)
row_id = int(row_id_str)
except ValueError:
raise HTTPException(status_code=400, detail=f"Unexpected action value: {value}")
if verb == "approve":
baserow.update_action_status(row_id, "approved")
status_text = "aprobada"
elif verb == "reject":
baserow.update_action_status(row_id, "rejected")
status_text = "rechazada"
else:
raise HTTPException(status_code=400, detail=f"Unknown verb: {verb}")
if message_ts:
user = payload.get("user", {}).get("name", "unknown")
slack_notifier.update_message(
channel,
message_ts,
f"Accion {status_text} por {user}.",
)
return JSONResponse({"ok": True})

196
backfill.py Normal file
View File

@ -0,0 +1,196 @@
"""
Backfill: genera snapshots históricos con análisis Claude para un rango de fechas.
Usa ventana de 1 día (no 3d/7d, los datos históricos ya están fijados) y
reconstruye el capping/PPL/familia y los leads acumulados del curso tal como
estaban en cada fecha histórica (as_of_date), no el estado actual.
Uso:
python backfill.py # mes en curso → ayer
python backfill.py --from 2026-06-01 --to 2026-06-04
python backfill.py --skip-existing # no reprocesa días ya guardados
"""
import sys
import io
import argparse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
from datetime import datetime, timedelta
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from agent import decide, analyze_unit
from baserow_client import BaserowClient
import analyzer
_ACTION_MAP = {
"PAUSE": "PAUSE", "REDUCE_BUDGET": "REDUCE_BUDGET",
"INCREASE_BUDGET": "INCREASE_BUDGET", "MAINTAIN": "MAINTAIN",
"PAUSAR": "PAUSE", "REDUCIR_PRESUPUESTO": "REDUCE_BUDGET",
"AUMENTAR_PRESUPUESTO": "INCREASE_BUDGET", "MANTENER": "MAINTAIN",
}
def run_backfill(date_from: str, date_to: str, skip_existing: bool = False):
meta = MetaAdsClient()
baserow = BaserowClient()
airtable = AirtableClient()
# Build date list
d = datetime.strptime(date_from, "%Y-%m-%d")
d_end = datetime.strptime(date_to, "%Y-%m-%d")
dates = []
while d <= d_end:
dates.append(d.strftime("%Y-%m-%d"))
d += timedelta(days=1)
print(f"\n{'='*60}")
print(f" BACKFILL FORMACIÓN {date_from}{date_to} ({len(dates)} días)")
print(f"{'='*60}\n")
total_saved = 0
total_skip = 0
_lookups_cache: dict = {} # {mes_año: (ppl_lookup, cap_lookup, familia_lookup)}
for run_date in dates:
print(f"\n── {run_date} ───────────────────────────────────────────────")
mes_key = run_date[:7]
if mes_key not in _lookups_cache:
_lookups_cache[mes_key] = airtable.build_campaign_lookups(as_of_date=run_date)
ppl_lookup, cap_lookup, familia_lookup = _lookups_cache[mes_key]
# Pre-load existing snapshots for this date if skip_existing
existing_names: set = set()
if skip_existing:
try:
for r in baserow.get_snapshots_for_date(run_date):
existing_names.add(r.get("campaign_name", ""))
except Exception:
pass
campaign_metrics = meta.get_campaign_metrics(run_date, run_date)
if not campaign_metrics:
print(" Sin campañas con gasto.")
continue
print(f" {len(campaign_metrics)} campañas activas.")
adset_bids_cache: dict = {}
for cid, metrics in campaign_metrics.items():
camp_name = metrics["name"]
if skip_existing and camp_name in existing_names:
print(f" SKIP {camp_name[:55]}")
total_skip += 1
continue
cursoid = extract_cursoid(camp_name) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
cap = cap_lookup.get(cursoid, 0)
cpa_max = round(ppl * 0.70, 2)
leads_entregados, _ = airtable.get_leads_this_month_meta(camp_name, as_of_date=run_date)
print(f" {camp_name[:55]}")
print(f" Spend {metrics['spend']}€ Leads {metrics['leads']} PPL {ppl}"
f"CPAmax {cpa_max}€ Leads mes {leads_entregados}/{cap or ''}")
campaign_config = {
"curso": camp_name, "meta_campaign_id": cid, "ppl": ppl,
"cpa_maximo": cpa_max, "capping_mensual": cap,
"conv_leads_lake_mes": leads_entregados,
}
ads_metrics = {
"spend": metrics["spend"], "leads": metrics["leads"],
"ctr": metrics["ctr"], "clicks": metrics["clicks"], "status": "ACTIVE",
}
analysis = analyzer.analyze(campaign_config, leads_entregados, ads_metrics)
try:
decision = decide(analysis)
action_type = _ACTION_MAP.get(decision.get("action", "MAINTAIN"), "MAINTAIN")
except Exception as e:
print(f" ERROR decide: {e}")
decision = {"action": "MAINTAIN", "justification": "", "parameter": 1.0}
action_type = "MAINTAIN"
print(f" Urgencia: {analysis['urgencia']} Decision: {action_type}"
f"{(decision.get('justification') or '')[:70]}")
# ── Claude: adsets ──────────────────────────────────────────────
adsets_detail = []
try:
for as_m in meta.get_adset_metrics(cid, run_date, run_date)[:5]:
result = analyze_unit(as_m, "adset")
adsets_detail.append({**as_m, **result})
print(f" [Adset] {as_m['name'][:45]}{result.get('evaluacion','')[:50]}")
except Exception as e:
print(f" ERROR adsets: {e}")
if cid not in adset_bids_cache:
try:
adset_bids_cache[cid] = meta.get_adset_bid_configs(cid)
except Exception:
adset_bids_cache[cid] = {}
for adset in adsets_detail:
b = adset_bids_cache[cid].get(adset["id"], {})
adset["cost_cap_eur"] = b.get("cost_cap_eur")
adset["bid_strategy"] = b.get("bid_strategy", "")
# ── Claude: anuncios ────────────────────────────────────────────
ads_detail = []
try:
for ad_m in meta.get_ad_metrics(cid, run_date, run_date)[:5]:
ad_m["ppl"] = ppl
ad_m["cpa_maximo"] = cpa_max
result = analyze_unit(ad_m, "ad")
ads_detail.append({**ad_m, **result})
print(f" [Ad] {ad_m['name'][:45]}{result.get('evaluacion','')[:50]}")
except Exception as e:
print(f" ERROR ads: {e}")
# margin en € (mismo proxy que usa run.py): leads*PPL - gasto
margin = round(metrics["leads"] * ppl - metrics["spend"], 2)
try:
baserow.save_daily_snapshot({
"run_date": run_date,
"campaign_id": cid,
"campaign_name": camp_name,
"familia": familia,
"spend": metrics["spend"],
"leads": metrics["leads"],
"cpl": metrics["cpl"],
"margin": margin,
"action_type": action_type,
"justification": decision.get("justification") or "",
"adsets": adsets_detail,
"ads": ads_detail,
})
print(" ✓ Snapshot guardado")
total_saved += 1
except Exception as e:
print(f" ERROR snapshot: {e}")
print(f"\n{'='*60}")
print(f" Backfill completo. Guardados: {total_saved} Saltados: {total_skip}")
print(f"{'='*60}\n")
if __name__ == "__main__":
now = datetime.now()
default_from = f"{now.year}-{now.month:02d}-01"
default_to = (now - timedelta(days=1)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="Backfill Meta Optimizer Formación snapshots")
parser.add_argument("--from", dest="date_from", default=default_from,
help=f"Fecha inicio YYYY-MM-DD (default: {default_from})")
parser.add_argument("--to", dest="date_to", default=default_to,
help=f"Fecha fin YYYY-MM-DD (default: {default_to})")
parser.add_argument("--skip-existing", action="store_true",
help="No reprocesa campañas que ya tienen snapshot ese día")
args = parser.parse_args()
run_backfill(args.date_from, args.date_to, args.skip_existing)

217
baserow_client.py Normal file
View File

@ -0,0 +1,217 @@
"""Baserow REST API client for meta_optimizer_formacion tables.
A diferencia de meta-optimizer (Viviful), aquí NO hay tablas 'campaigns' ni
'verticals' esa capa de negocio (PPL, capping, familia) vive en Airtable
(ver airtable_client.py). Baserow solo guarda lo operativo de Meta: acciones
propuestas, snapshots diarios, análisis de creatividades y logs de ejecución.
"""
import requests
from datetime import datetime
import config
class BaserowClient:
def __init__(self):
self._base = config.BASEROW_URL.rstrip("/")
self._headers = {
"Authorization": f"Token {config.BASEROW_TOKEN}",
"Content-Type": "application/json",
}
def _url(self, table_id: int, row_id: int = None) -> str:
path = f"/api/database/rows/table/{table_id}/"
if row_id:
path += f"{row_id}/"
return self._base + path
def _get_rows(self, table_id: int, filters: dict = None) -> list:
params = {"user_field_names": "true"}
if filters:
params.update(filters)
try:
resp = requests.get(self._url(table_id), headers=self._headers, params=params, timeout=15)
if not resp.ok:
return []
return resp.json().get("results", [])
except requests.RequestException:
return []
def _create_row(self, table_id: int, data: dict) -> dict:
resp = requests.post(
self._url(table_id),
headers=self._headers,
params={"user_field_names": "true"},
json=data,
timeout=15,
)
resp.raise_for_status()
return resp.json()
def _delete_row(self, table_id: int, row_id: int):
resp = requests.delete(
self._url(table_id, row_id),
headers=self._headers,
params={"user_field_names": "true"},
timeout=15,
)
resp.raise_for_status()
def _update_row(self, table_id: int, row_id: int, data: dict) -> dict:
resp = requests.patch(
self._url(table_id, row_id),
headers=self._headers,
params={"user_field_names": "true"},
json=data,
timeout=15,
)
resp.raise_for_status()
return resp.json()
# ── proposed_actions ──────────────────────────────────────────────────────
def save_action(self, action: dict) -> dict:
return self._create_row(config.BASEROW_TABLE_ACTIONS, {
"campaign_id": action["campaign_id"],
"campaign_name": action["campaign_name"],
"action_type": action["action_type"],
"parameter": action.get("parameter", 1.0),
"justification": action.get("justification", ""),
"advice": action.get("advice", ""),
"alert": action.get("alert") or "",
"confidence": action.get("confidence", 0.0),
"status": "pending",
"proposed_at": datetime.now().strftime("%Y-%m-%d"),
})
def get_approved_actions(self) -> list:
return self._get_rows(
config.BASEROW_TABLE_ACTIONS,
{"filter__status__single_select_equal": "approved"},
)
def update_action_status(
self, row_id: int, status: str, slack_message_ts: str = None
) -> dict:
data: dict = {"status": status}
if status == "executed":
data["executed_at"] = datetime.now().strftime("%Y-%m-%d")
if slack_message_ts is not None:
data["slack_message_ts"] = slack_message_ts
return self._update_row(config.BASEROW_TABLE_ACTIONS, row_id, data)
# ── creative_analyses ─────────────────────────────────────────────────────
def get_all_creative_analyses(self, filters: dict = None) -> list:
"""Returns creative analyses from Baserow, up to 200 rows."""
params = {"user_field_names": "true", "page_size": 200, "order_by": "-created_at"}
if filters:
params.update(filters)
try:
resp = requests.get(
self._url(config.BASEROW_TABLE_CREATIVES),
headers=self._headers, params=params, timeout=15,
)
if not resp.ok:
return []
return resp.json().get("results", [])
except requests.RequestException:
return []
def get_creative_history_by_ad(self, ad_id: str) -> list:
"""Returns all analyses for an ad_id sorted by date ascending (for score evolution)."""
rows = self._get_rows(
config.BASEROW_TABLE_CREATIVES,
{"filter__ad_id__equal": ad_id},
)
return sorted(rows, key=lambda r: r.get("created_at", ""))
def save_creative_analysis(self, analysis: dict) -> dict:
return self._create_row(config.BASEROW_TABLE_CREATIVES, {
"ad_id": analysis["ad_id"],
"ad_name": analysis["ad_name"],
"campaign_id": analysis["campaign_id"],
"image_url": analysis["image_url"],
"analysis": analysis.get("analysis", ""),
"score": analysis.get("score", 0),
"recommendations": analysis.get("recommendations", ""),
"created_at": datetime.now().strftime("%Y-%m-%d"),
})
# ── daily_snapshots ───────────────────────────────────────────────────────
def save_daily_snapshot(self, snapshot: dict) -> dict:
import json
# Remove existing snapshots for same day + campaign before saving new one
existing = self._get_rows(
config.BASEROW_TABLE_SNAPSHOTS,
{
"filter__run_date__equal": snapshot["run_date"],
"filter__campaign_name__equal": snapshot["campaign_name"],
},
)
for row in existing:
try:
self._delete_row(config.BASEROW_TABLE_SNAPSHOTS, row["id"])
except Exception:
pass
def _safe_json(items: list) -> str:
cleaned = []
for item in items:
cleaned.append({
k: (str(v)[:500] if isinstance(v, str) else v)
for k, v in item.items()
if isinstance(v, (str, int, float, bool, type(None)))
})
s = json.dumps(cleaned, ensure_ascii=False)
return s[:60000] # Baserow long_text practical limit
resp = requests.post(
self._url(config.BASEROW_TABLE_SNAPSHOTS),
headers=self._headers,
params={"user_field_names": "true"},
json={
"run_date": snapshot["run_date"],
"campaign_id": snapshot["campaign_id"],
"campaign_name": snapshot["campaign_name"],
"familia": snapshot["familia"],
"spend": float(snapshot["spend"]),
"leads": int(snapshot["leads"]),
"cpl": float(snapshot["cpl"]),
"margin": float(snapshot["margin"]),
"action_type": snapshot.get("action_type", "MAINTAIN"),
"justification": (snapshot.get("justification") or "")[:2000],
"adsets_json": _safe_json(snapshot.get("adsets", [])),
"ads_json": _safe_json(snapshot.get("ads", [])),
},
timeout=15,
)
if not resp.ok:
raise Exception(f"{resp.status_code} {resp.text[:300]}")
return resp.json()
def get_snapshots_for_date(self, run_date: str) -> list:
return self._get_rows(
config.BASEROW_TABLE_SNAPSHOTS,
{"filter__run_date__equal": run_date},
)
def get_snapshot_dates(self) -> list:
"""Return sorted list of distinct run_date values that have snapshots."""
rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS)
return sorted({r["run_date"] for r in rows if r.get("run_date")}, reverse=True)
# ── execution_logs ────────────────────────────────────────────────────────
def save_execution_log(self, log: dict) -> dict:
return self._create_row(config.BASEROW_TABLE_LOGS, {
"executed_at": datetime.now().strftime("%Y-%m-%d"),
"mode": log.get("mode", "DRY_RUN"),
"campaigns_analyzed": log.get("campaigns_analyzed", 0),
"actions_proposed": log.get("actions_proposed", 0),
"actions_executed": log.get("actions_executed", 0),
"errors": log.get("errors", ""),
"summary": log.get("summary", ""),
"duration_seconds": log.get("duration_seconds", 0.0),
})

42
config.py Normal file
View File

@ -0,0 +1,42 @@
import os
from dotenv import load_dotenv
load_dotenv()
# Meta Ads
META_APP_ID = os.environ["META_APP_ID"]
META_APP_SECRET = os.environ["META_APP_SECRET"]
META_ACCESS_TOKEN = os.environ["META_ACCESS_TOKEN"]
META_AD_ACCOUNT_ID = os.environ["META_AD_ACCOUNT_ID"]
# Anthropic
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
# Baserow (self-hosted) — solo lo operativo de Meta (snapshots, acciones, creatividades, logs)
BASEROW_URL = os.environ["BASEROW_URL"]
BASEROW_TOKEN = os.environ["BASEROW_TOKEN"]
BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"])
BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"])
BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"])
BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"])
# Airtable (misma base que leads-optimizer) — negocio: PPL, capping, Cursos, Familias
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"]
LEADS_TABLE = "Leads Lake"
CURSOS_TABLE = "Cursos"
CENTROCURSO_TABLE = "CentroCurso"
CURSOMES_TABLE = "CursoMes"
META_CAMPAIGNS_TABLE = "Meta Ads Campaigns"
META_CAMPAIGNMES_TABLE = "MetaCampaignMes"
# Slack (Bot Token, no webhook) — canal dedicado a Formación
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
SLACK_CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"]
# Configuración
META_CAMPAIGN_PREFIX = os.environ.get("META_CAMPAIGN_PREFIX", "RoiFormacion")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() != "false"

643
dashboard.py Normal file
View File

@ -0,0 +1,643 @@
"""Interactive Meta Optimizer Formación dashboard — Streamlit."""
import streamlit as st
from datetime import date, timedelta
import pandas as pd
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from baserow_client import BaserowClient
import config
st.set_page_config(
page_title=f"Meta Optimizer — {config.META_CAMPAIGN_PREFIX}",
layout="wide",
initial_sidebar_state="collapsed",
)
import streamlit.components.v1 as components
components.html("""
<script>
// Ping cada 30s para mantener el WebSocket activo y evitar el error 401 por inactividad
setInterval(function() {
fetch('/_stcore/health').catch(function() {});
}, 30000);
</script>
""", height=0)
_STRATEGY_LABELS = {
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
"COST_CAP": "Cap. coste",
"MINIMUM_ROAS": "ROAS mín.",
}
_ACTION_COLORS = {
"INCREASE_BUDGET": "🟢",
"REDUCE_BUDGET": "🟠",
"PAUSE": "🔴",
"MAINTAIN": "",
}
_today = date.today()
_yesterday = _today - timedelta(days=1)
_default_from = _yesterday - timedelta(days=6)
def _eur(val: float) -> str:
return f"{val:.2f}"
def _margin(val: float) -> str:
return f"+{val:.0f}" if val >= 0 else f"{val:.0f}"
def _status(leads: int, spend: float) -> str:
if leads > 0:
return ""
if spend > 0:
return ""
return ""
def _familia_of(name: str, familia_lookup: dict) -> str:
return familia_lookup.get(extract_cursoid(name) or "", "Sin familia")
def _date_row(key: str, n_extra_cols: int = 0) -> tuple:
"""Renders [Desde | Hasta | ...extra... | 🔄] columns. Returns (date_from, date_to, *extra_cols)."""
cols = st.columns([2, 2] + [2] * n_extra_cols + [1])
d_from = cols[0].date_input("Desde", value=_default_from, max_value=_yesterday, key=f"{key}_from")
d_to = cols[1].date_input("Hasta", value=_yesterday, min_value=d_from, max_value=_yesterday, key=f"{key}_to")
if cols[-1].button("🔄", key=f"{key}_ref", use_container_width=True, help="Limpiar caché"):
st.cache_data.clear()
st.rerun()
extra = tuple(cols[2:-1])
return (d_from, d_to) + extra
# ── Cached data loaders ───────────────────────────────────────────────────────
@st.cache_data(ttl=300, show_spinner="Cargando PPL/familia de Airtable...")
def _load_lookups():
ppl_lookup, _, familia_lookup = AirtableClient().build_campaign_lookups()
return ppl_lookup, familia_lookup
@st.cache_data(ttl=300, show_spinner="Cargando datos de Meta API...")
def _load_data(date_from: str, date_to: str):
meta = MetaAdsClient()
daily_rows = meta.get_daily_campaign_rows(date_from, date_to)
campaign_metrics = meta.get_campaign_metrics(date_from, date_to)
return daily_rows, campaign_metrics
@st.cache_data(ttl=300, show_spinner="Cargando detalle de campaña...")
def _load_detail(campaign_id: str, date_from: str, date_to: str):
meta = MetaAdsClient()
adsets = meta.get_adset_metrics(campaign_id, date_from, date_to)
ads = meta.get_ad_metrics(campaign_id, date_from, date_to)
bid = meta.get_campaign_bid_config(campaign_id)
bids = meta.get_adset_bid_configs(campaign_id)
for adset in adsets:
b = bids.get(adset["id"], {})
adset["cost_cap_eur"] = b.get("cost_cap_eur")
adset["bid_strategy"] = b.get("bid_strategy", "")
return adsets, ads, bid
@st.cache_data(ttl=3600, show_spinner=False)
def _load_campaign_names() -> dict:
"""Returns {campaign_id: campaign_name} for the last 30 days. Cached 1h."""
meta = MetaAdsClient()
end = _yesterday.strftime("%Y-%m-%d")
start = (_yesterday - timedelta(days=29)).strftime("%Y-%m-%d")
try:
metrics = meta.get_campaign_metrics(start, end)
return {cid: m["name"] for cid, m in metrics.items()}
except Exception:
return {}
@st.cache_data(ttl=120, show_spinner="Cargando fechas disponibles...")
def _load_snapshot_dates():
return BaserowClient().get_snapshot_dates()
@st.cache_data(ttl=120, show_spinner="Cargando análisis del día...")
def _load_snapshots(run_date: str):
import json
rows = BaserowClient().get_snapshots_for_date(run_date)
result = []
for r in rows:
try:
adsets = json.loads(r.get("adsets_json") or "[]")
except Exception:
adsets = []
try:
ads = json.loads(r.get("ads_json") or "[]")
except Exception:
ads = []
result.append({
"campaign_name": r.get("campaign_name", ""),
"familia": r.get("familia", ""),
"spend": float(r.get("spend") or 0),
"leads": int(r.get("leads") or 0),
"cpl": float(r.get("cpl") or 0),
"margin": float(r.get("margin") or 0),
"action_type": r.get("action_type", "MAINTAIN"),
"justification": r.get("justification", ""),
"adsets": adsets,
"ads": ads,
})
return sorted(result, key=lambda x: -x["spend"])
@st.cache_data(ttl=300, show_spinner="Cargando análisis de creatividades...")
def _load_creatives():
return BaserowClient().get_all_creative_analyses()
# ── Header ────────────────────────────────────────────────────────────────────
st.title(f"Meta Optimizer — {config.META_CAMPAIGN_PREFIX}")
ppl_lookup, familia_lookup = _load_lookups()
# ── Tabs ──────────────────────────────────────────────────────────────────────
tab1, tab2, tab3, tab4, tab5 = st.tabs(
["📅 Por día", "📊 Campañas", "🏷️ Familias", "🗂️ Histórico", "🎨 Creatividades"]
)
# ── Tab 1: Por día ────────────────────────────────────────────────────────────
with tab1:
d_from_1, d_to_1 = _date_row("t1")
if d_from_1 > d_to_1:
st.error("La fecha inicio debe ser anterior a la fecha fin.")
else:
try:
daily_rows, _cm1 = _load_data(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando datos de Meta API: {e}")
daily_rows = []
_daily: dict = {}
for row in daily_rows:
ppl = ppl_lookup.get(extract_cursoid(row["campaign_name"]) or "", 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
daily_totals = [
{
"date": dt,
"spend": round(d["spend"], 2),
"leads": int(d["leads"]),
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
"margin": round(d["margin"], 2),
}
for dt, d in sorted(_daily.items())
]
total_spend = sum(d["spend"] for d in daily_totals)
total_leads = sum(d["leads"] for d in daily_totals)
total_cpl = round(total_spend / total_leads, 2) if total_leads > 0 else 0.0
total_margin = sum(d["margin"] for d in daily_totals)
k1, k2, k3, k4 = st.columns(4)
k1.metric("Gasto total", _eur(total_spend))
k2.metric("Leads totales", f"{total_leads:,}")
k3.metric("CPL medio", _eur(total_cpl))
k4.metric("Margen total", _margin(total_margin))
st.divider()
if not daily_totals:
st.info("Sin datos para el período seleccionado.")
else:
df_daily = pd.DataFrame([
{
"Día": d["date"][8:10] + "/" + d["date"][5:7],
"Gasto": _eur(d["spend"]),
"Leads": d["leads"],
"CPL": _eur(d["cpl"]),
"Margen": _margin(d["margin"]),
"Est": _status(d["leads"], d["spend"]),
}
for d in daily_totals
])
st.dataframe(df_daily, use_container_width=True, hide_index=True)
st.subheader("Desglose por campaña")
day_opts = [d["date"] for d in reversed(daily_totals)]
selected_day = st.selectbox(
"Selecciona un día",
day_opts,
format_func=lambda s: s[8:10] + "/" + s[5:7] + "/" + s[:4],
key="t1_day",
)
if selected_day:
day_camp: dict = {}
for row in daily_rows:
if row["date"] != selected_day:
continue
k = row["campaign_name"]
if k not in day_camp:
ppl = ppl_lookup.get(extract_cursoid(k) or "", 0)
day_camp[k] = {"name": k, "familia": _familia_of(k, familia_lookup),
"spend": 0.0, "leads": 0, "ppl": ppl}
day_camp[k]["spend"] += row["spend"]
day_camp[k]["leads"] += row["leads"]
camp_rows = []
for c in sorted(day_camp.values(), key=lambda x: -x["spend"]):
cpl = round(c["spend"] / c["leads"], 2) if c["leads"] > 0 else 0.0
margin = round(c["leads"] * c["ppl"] - c["spend"], 2)
camp_rows.append({
"Campaña": c["name"],
"Familia": c["familia"],
"Gasto": _eur(c["spend"]),
"Leads": c["leads"],
"CPL": _eur(cpl) if c["leads"] > 0 else "",
"PPL": _eur(c["ppl"]) if c["ppl"] else "",
"Margen": _margin(margin),
})
if camp_rows:
st.dataframe(pd.DataFrame(camp_rows), use_container_width=True, hide_index=True)
else:
st.info("Sin campañas activas ese día.")
# ── Tab 2: Campañas ───────────────────────────────────────────────────────────
with tab2:
d_from_2, d_to_2, col_fam_2 = _date_row("t2", n_extra_cols=1)
if d_from_2 > d_to_2:
st.error("La fecha inicio debe ser anterior a la fecha fin.")
else:
try:
_dr2, campaign_metrics_2 = _load_data(d_from_2.strftime("%Y-%m-%d"), d_to_2.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando datos de Meta API: {e}")
campaign_metrics_2 = {}
fam_opts_2 = ["Todas"] + sorted({_familia_of(m["name"], familia_lookup) for m in campaign_metrics_2.values()})
sel_fam_2 = col_fam_2.selectbox("Familia", fam_opts_2, key="t2_fam")
if sel_fam_2 != "Todas":
campaign_metrics_2 = {
cid: m for cid, m in campaign_metrics_2.items()
if _familia_of(m["name"], familia_lookup) == sel_fam_2
}
if not campaign_metrics_2:
st.info("Sin campañas para el período seleccionado.")
else:
camp_rows = []
for cid, m in sorted(campaign_metrics_2.items(), key=lambda x: -x[1]["spend"]):
ppl = ppl_lookup.get(extract_cursoid(m["name"]) or "", 0)
margin = round(m["leads"] * ppl - m["spend"], 2)
camp_rows.append({
"Campaña": m["name"],
"Familia": _familia_of(m["name"], familia_lookup),
"Gasto": _eur(m["spend"]),
"Leads": m["leads"],
"CPL": _eur(m["cpl"]) if m["leads"] > 0 else "",
"PPL": _eur(ppl) if ppl else "",
"Margen": _margin(margin),
"CTR": f"{m['ctr']:.1f}%",
"_cid": cid,
})
df_camps = pd.DataFrame([{k: v for k, v in r.items() if k != "_cid"} for r in camp_rows])
st.dataframe(df_camps, use_container_width=True, hide_index=True)
st.subheader("Detalle de campaña")
camp_id_map = {r["Campaña"]: r["_cid"] for r in camp_rows}
selected_camp = st.selectbox("Selecciona una campaña", list(camp_id_map.keys()), key="t2_camp")
if selected_camp:
selected_cid = camp_id_map[selected_camp]
adsets, ads, bid_cfg = _load_detail(
selected_cid,
d_from_2.strftime("%Y-%m-%d"),
d_to_2.strftime("%Y-%m-%d"),
)
strategy = bid_cfg.get("bid_strategy", "")
strat_label = _STRATEGY_LABELS.get(strategy, strategy or "")
budget = bid_cfg.get("daily_budget_eur")
budget_str = f"{budget:.0f}€/día" if budget else ""
st.caption(f"Estrategia: **{strat_label}** | Presupuesto: **{budget_str}**")
if adsets:
st.markdown("**Conjuntos de anuncios**")
df_adsets = pd.DataFrame([
{
"Nombre": a["name"],
"Gasto": _eur(a["spend"]),
"Leads": a["leads"],
"CPL": _eur(a["cpl"]) if a["leads"] > 0 else "",
"CTR": f"{a['ctr']:.1f}%",
"Cap": _eur(a["cost_cap_eur"]) if a.get("cost_cap_eur") else "Auto",
}
for a in adsets
])
st.dataframe(df_adsets, use_container_width=True, hide_index=True)
else:
st.info("Sin conjuntos de anuncios con gasto en este período.")
if ads:
st.markdown("**Anuncios**")
df_ads = pd.DataFrame([
{
"Nombre": a["name"],
"Gasto": _eur(a["spend"]),
"Leads": a["leads"],
"CPL": _eur(a["cpl"]) if a["leads"] > 0 else "",
"CTR": f"{a['ctr']:.1f}%",
"CPM": _eur(a["cpm"]),
}
for a in ads
])
st.dataframe(df_ads, use_container_width=True, hide_index=True)
else:
st.info("Sin anuncios con gasto en este período.")
# ── Tab 3: Familias ───────────────────────────────────────────────────────────
with tab3:
d_from_3, d_to_3 = _date_row("t3")
if d_from_3 > d_to_3:
st.error("La fecha inicio debe ser anterior a la fecha fin.")
else:
try:
_dr3, campaign_metrics_3 = _load_data(d_from_3.strftime("%Y-%m-%d"), d_to_3.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando datos de Meta API: {e}")
campaign_metrics_3 = {}
familias_3: dict = {}
for cid, m in campaign_metrics_3.items():
fam = _familia_of(m["name"], familia_lookup)
ppl = ppl_lookup.get(extract_cursoid(m["name"]) or "", 0)
margin = round(m["leads"] * ppl - m["spend"], 2)
if fam not in familias_3:
familias_3[fam] = {"spend": 0.0, "leads": 0, "margin": 0.0}
familias_3[fam]["spend"] += m["spend"]
familias_3[fam]["leads"] += m["leads"]
familias_3[fam]["margin"] += margin
if not familias_3:
st.info("Sin datos de familias.")
else:
fam_rows = []
for fam, data in sorted(familias_3.items(), key=lambda x: -x[1]["margin"]):
f_leads = data["leads"]
f_spend = data["spend"]
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
fam_rows.append({
"Familia": fam,
"Gasto": _eur(f_spend),
"Leads": f_leads,
"CPL": _eur(f_cpl),
"Margen": _margin(data["margin"]),
})
st.dataframe(pd.DataFrame(fam_rows), use_container_width=True, hide_index=True)
# ── Tab 4: Histórico ──────────────────────────────────────────────────────────
with tab4:
dates = _load_snapshot_dates()
if not dates:
st.info("Sin análisis guardados aún. Los snapshots se generan al ejecutar run.py.")
else:
c1, c2 = st.columns([3, 1])
fmt_date = lambda s: s[8:10] + "/" + s[5:7] + "/" + s[:4]
selected_date = c1.selectbox("Fecha del análisis", dates, format_func=fmt_date, key="t4_date")
if c2.button("🔄 Recargar", key="t4_ref", use_container_width=True):
st.cache_data.clear()
st.rerun()
snapshots = _load_snapshots(selected_date)
if not snapshots:
st.info("Sin datos para esa fecha.")
else:
d_spend = sum(s["spend"] for s in snapshots)
d_leads = sum(s["leads"] for s in snapshots)
d_cpl = round(d_spend / d_leads, 2) if d_leads > 0 else 0.0
d_margin = sum(s["margin"] for s in snapshots)
h1, h2, h3, h4 = st.columns(4)
h1.metric("Gasto", _eur(d_spend))
h2.metric("Leads", f"{d_leads:,}")
h3.metric("CPL", _eur(d_cpl))
h4.metric("Margen", _margin(d_margin))
st.divider()
df_snap = pd.DataFrame([
{
"Acción": _ACTION_COLORS.get(s["action_type"], "") + " " + s["action_type"],
"Campaña": s["campaign_name"],
"Familia": s["familia"],
"Gasto": _eur(s["spend"]),
"Leads": s["leads"],
"CPL": _eur(s["cpl"]) if s["leads"] > 0 else "",
"Margen": _margin(s["margin"]),
}
for s in snapshots
])
event = st.dataframe(
df_snap,
use_container_width=True,
hide_index=True,
on_select="rerun",
selection_mode="single-row",
)
sel_rows = event.selection.rows
if sel_rows:
snap = snapshots[sel_rows[0]]
st.subheader(snap["campaign_name"])
st.caption(
f"Familia: **{snap['familia']}** | "
f"Decisión: **{snap['action_type']}** | "
f"Margen: **{_margin(snap['margin'])}**"
)
if snap["justification"]:
st.info(snap["justification"])
adsets = snap["adsets"]
if adsets:
st.markdown("**Conjuntos de anuncios** _(últimos 3 días)_")
for a in adsets:
label = (
f"{a['name']}"
f"{_eur(a['spend'])} · {a['leads']} leads · "
f"CPL {_eur(a['cpl']) if a['leads'] > 0 else ''} · "
f"CTR {a.get('ctr', 0):.1f}%"
)
with st.expander(label):
if a.get("cost_cap_eur"):
st.caption(f"Cap: {_eur(a['cost_cap_eur'])}")
if a.get("evaluacion"):
st.write(f"_{a['evaluacion']}_")
if a.get("recomendacion"):
st.write(f"{a['recomendacion']}")
ads = snap["ads"]
if ads:
st.markdown("**Anuncios** _(últimos 7 días)_")
for a in ads:
label = (
f"{a['name']}"
f"{_eur(a['spend'])} · {a['leads']} leads · "
f"CPL {_eur(a['cpl']) if a['leads'] > 0 else ''} · "
f"CTR {a.get('ctr', 0):.1f}% · "
f"CPM {_eur(a.get('cpm', 0))}"
)
with st.expander(label):
if a.get("evaluacion"):
st.write(f"_{a['evaluacion']}_")
if a.get("recomendacion"):
st.write(f"{a['recomendacion']}")
# ── Tab 5: Creatividades ──────────────────────────────────────────────────────
with tab5:
creatives_raw = _load_creatives()
if not creatives_raw:
st.info("No hay análisis de creatividades. Ejecuta `python analyze_creatives.py` para generar datos.")
else:
df_all = pd.DataFrame(creatives_raw)
df_all["score"] = pd.to_numeric(df_all.get("score", 0), errors="coerce").fillna(0)
df_all["created_at"] = df_all.get("created_at", pd.Series(dtype=str))
# Map campaign_id → name using a dedicated cached call (last 30d)
camp_id_to_name = _load_campaign_names()
df_all["campaign_name"] = df_all["campaign_id"].map(
lambda cid: camp_id_to_name.get(str(cid), str(cid))
)
df_all["familia"] = df_all["campaign_name"].map(lambda n: _familia_of(n, familia_lookup))
# ── Filters ───────────────────────────────────────────────────────────
f1, f2, f3, f4 = st.columns([2, 2, 2, 2])
dates_available = sorted(df_all["created_at"].dropna().unique(), reverse=True)
sel_date = f1.selectbox("Fecha análisis", dates_available, key="cr_date")
fams_available = sorted(df_all["familia"].dropna().unique().tolist())
sel_fam_cr = f2.selectbox("Familia", ["Todas"] + fams_available, key="cr_fam")
camp_names = sorted(df_all["campaign_name"].dropna().unique().tolist())
sel_camp = f3.selectbox("Campaña", ["Todas"] + camp_names, key="cr_camp")
score_min = f4.slider("Score mínimo", 0.0, 10.0, 0.0, step=0.5, key="cr_score")
# Apply filters
df = df_all.copy()
if sel_date:
df = df[df["created_at"] == sel_date]
if sel_fam_cr != "Todas":
df = df[df["familia"] == sel_fam_cr]
if sel_camp != "Todas":
df = df[df["campaign_name"] == sel_camp]
if score_min > 0:
df = df[df["score"] >= score_min]
# ── KPIs ──────────────────────────────────────────────────────────────
scored_df = df[df["score"] > 0]
avg_sc = round(scored_df["score"].mean(), 1) if not scored_df.empty else 0.0
fatigue_n = int(df["analysis"].str.contains("FATIGA", na=False).sum()) if "analysis" in df.columns else 0
last_run = df_all["created_at"].max() if not df_all.empty else ""
k1, k2, k3, k4 = st.columns(4)
k1.metric("Anuncios", len(df))
k2.metric("Score medio", f"{avg_sc}/10")
k3.metric("Con fatiga", fatigue_n)
k4.metric("Última ejecución", last_run)
if fatigue_n:
fatigued = df[df["analysis"].str.contains("FATIGA", na=False)]
with st.expander(f"⚠️ {fatigue_n} anuncios con fatiga creativa", expanded=True):
for _, row in fatigued.iterrows():
st.warning(f"**{row.get('ad_name', '')}** — Score {row.get('score', 0):.1f}/10")
st.divider()
# ── Table + Detail panel ──────────────────────────────────────────────
rename_map = {
"campaign_name": "Campaña",
"familia": "Familia",
"ad_name": "Anuncio",
"score": "Score",
"created_at": "Fecha",
}
display_cols = [c for c in rename_map if c in df.columns]
df_display = df[display_cols].rename(columns=rename_map).reset_index(drop=True)
col_table, col_detail = st.columns([3, 2])
with col_table:
st.caption("Haz clic en una fila para ver el detalle →")
event = st.dataframe(
df_display,
use_container_width=True,
selection_mode="single-row",
on_select="rerun",
column_config={
"Score": st.column_config.ProgressColumn(
"Score", min_value=0, max_value=10, format="%.1f"
),
},
hide_index=True,
)
selected_rows = event.selection.rows if hasattr(event, "selection") else []
with col_detail:
if selected_rows:
row = df.iloc[selected_rows[0]]
score = float(row.get("score", 0))
sc_emoji = "🟢" if score >= 8 else "🟡" if score >= 6 else "🟠" if score >= 4 else "🔴"
st.markdown(f"### {row.get('ad_name', '')}")
st.markdown(f"{sc_emoji} **Score: {score:.1f} / 10**")
img_url = str(row.get("image_url", ""))
if img_url.startswith("http"):
try:
st.image(img_url, use_container_width=True)
except Exception:
st.caption("_Imagen no disponible_")
analysis = str(row.get("analysis", ""))
if analysis:
st.markdown("**Análisis**")
st.write(analysis)
rec = str(row.get("recommendations", ""))
if rec:
st.markdown("**Recomendaciones**")
st.info(rec)
ad_id = str(row.get("ad_id", ""))
if ad_id:
history = BaserowClient().get_creative_history_by_ad(ad_id)
if len(history) >= 2:
st.markdown("**Evolución del score**")
hist_df = pd.DataFrame(history)[["created_at", "score"]].dropna()
hist_df["score"] = pd.to_numeric(hist_df["score"], errors="coerce")
hist_df = hist_df[hist_df["score"] > 0].set_index("created_at")
st.line_chart(hist_df)
else:
st.info("← Selecciona un anuncio en la tabla para ver el detalle.")

323
meta_ads_client.py Normal file
View File

@ -0,0 +1,323 @@
"""
Client for Meta Marketing API.
Docs: https://developers.facebook.com/docs/marketing-api
SDK: facebook-business
"""
from facebook_business.api import FacebookAdsApi
from facebook_business.adobjects.adaccount import AdAccount
from facebook_business.adobjects.campaign import Campaign
from facebook_business.adobjects.adset import AdSet
from facebook_business.adobjects.ad import Ad
from facebook_business.adobjects.adcreative import AdCreative
from facebook_business.adobjects.advideo import AdVideo
import config
from datetime import datetime, timedelta
class MetaAdsClient:
def __init__(self):
FacebookAdsApi.init(
app_id=config.META_APP_ID,
app_secret=config.META_APP_SECRET,
access_token=config.META_ACCESS_TOKEN,
)
self.account = AdAccount(config.META_AD_ACCOUNT_ID)
@staticmethod
def _count_conversions(actions: list) -> float:
"""Prioritize 'lead' to avoid double-counting with lead_grouped; include click-to-call."""
by_type = {a["action_type"]: float(a["value"]) for a in actions}
if "lead" in by_type:
return by_type["lead"]
if "onsite_conversion.lead_grouped" in by_type:
return by_type["onsite_conversion.lead_grouped"]
# Click-to-call campaigns (Llamadas): click_to_call_call_confirm o call_confirm_grouped
if "click_to_call_call_confirm" in by_type:
return by_type["click_to_call_call_confirm"]
if "call_confirm_grouped" in by_type:
return by_type["call_confirm_grouped"]
return by_type.get("call_confirm", by_type.get("contact", 0.0))
def _parse_insights_row(self, row: dict) -> dict:
spend = float(row.get("spend", 0))
impressions = int(row.get("impressions", 0))
clicks = int(row.get("clicks", 0))
ctr = float(row.get("ctr", 0))
cpm = float(row.get("cpm", 0))
leads = self._count_conversions(row.get("actions", []))
cpl = round(spend / leads, 2) if leads > 0 else 0.0
return {
"campaign_id": row.get("campaign_id", ""),
"name": row.get("campaign_name", ""),
"status": "ACTIVE",
"spend": round(spend, 2),
"impressions": impressions,
"clicks": clicks,
"ctr": round(ctr, 4),
"cpm": round(cpm, 2),
"leads": int(leads),
"cpl": cpl,
}
def get_all_campaigns(self) -> list:
"""
All campaigns matching the prefix, regardless of spend/status
(for catalog sync insights-based methods only return campaigns
with spend > 0 in the queried window).
"""
prefix = config.META_CAMPAIGN_PREFIX.upper()
campaigns = self.account.get_campaigns(
fields=["id", "name", "effective_status"],
params={"limit": 500},
)
result = []
for c in campaigns:
name = c.get("name", "")
if not name.upper().startswith(prefix):
continue
result.append({"id": c["id"], "name": name, "status": c.get("effective_status", "PAUSED")})
return result
def get_campaign_metrics(self, date_from: str, date_to: str) -> dict:
"""
Campaign-level metrics aggregated over a date range.
Returns {campaign_id: metrics}, spend > 0 only.
"""
prefix = config.META_CAMPAIGN_PREFIX.upper()
insights = self.account.get_insights(
fields=["campaign_id", "campaign_name", "spend", "impressions",
"clicks", "ctr", "cpm", "actions"],
params={
"level": "campaign",
"time_range": {"since": date_from, "until": date_to},
}
)
result = {}
for row in insights:
if not row.get("campaign_name", "").upper().startswith(prefix):
continue
m = self._parse_insights_row(row)
if m["spend"] == 0:
continue
result[m["campaign_id"]] = m
return result
def get_daily_campaign_rows(self, date_from: str, date_to: str) -> list:
"""
Per-campaign per-day rows for a date range.
Returns [{date, campaign_name, spend, leads}] sorted by date.
"""
if date_from > date_to:
return []
prefix = config.META_CAMPAIGN_PREFIX.upper()
insights = self.account.get_insights(
fields=["date_start", "campaign_name", "spend", "actions"],
params={
"level": "campaign",
"time_range": {"since": date_from, "until": date_to},
"time_increment": 1,
}
)
result = []
for row in insights:
if not row.get("campaign_name", "").upper().startswith(prefix):
continue
spend = float(row.get("spend", 0))
leads = self._count_conversions(row.get("actions", []))
result.append({
"date": row["date_start"],
"campaign_name": row.get("campaign_name", ""),
"spend": round(spend, 2),
"leads": int(leads),
})
return sorted(result, key=lambda x: x["date"])
def _get_sub_insights_range(self, campaign_id: str, level: str,
date_from: str, date_to: str) -> list:
"""Ad set or ad level insights for a date range, spend > 0, sorted by spend desc."""
id_field = f"{level}_id"
name_field = f"{level}_name"
try:
insights = Campaign(campaign_id).get_insights(
fields=[id_field, name_field, "spend", "impressions",
"clicks", "ctr", "cpm", "actions"],
params={
"level": level,
"time_range": {"since": date_from, "until": date_to},
}
)
except Exception:
return []
result = []
for row in insights:
spend = float(row.get("spend", 0))
if spend == 0:
continue
leads = self._count_conversions(row.get("actions", []))
cpl = round(spend / leads, 2) if leads > 0 else 0.0
result.append({
"id": row.get(id_field, ""),
"name": row.get(name_field, ""),
"spend": round(spend, 2),
"impressions": int(row.get("impressions", 0)),
"clicks": int(row.get("clicks", 0)),
"ctr": round(float(row.get("ctr", 0)), 4),
"cpm": round(float(row.get("cpm", 0)), 2),
"leads": int(leads),
"cpl": cpl,
})
# For ads, exclude currently paused ads (they may have historic spend in the window)
if level == "ad":
try:
active_ads = Campaign(campaign_id).get_ads(
fields=["id"],
params={"effective_status": ["ACTIVE"]},
)
active_ids = {a["id"] for a in active_ads}
result = [r for r in result if r["id"] in active_ids]
except Exception:
pass
return sorted(result, key=lambda x: -x["spend"])
def get_yesterday_metrics(self) -> dict:
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return self.get_campaign_metrics(yesterday, yesterday)
def get_monthly_daily_totals(self) -> list:
"""Per-campaign daily rows for the current month (used by run.py)."""
now = datetime.now()
date_start = f"{now.year}-{now.month:02d}-01"
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
return self.get_daily_campaign_rows(date_start, yesterday)
def get_adset_metrics(self, campaign_id: str, date_from: str, date_to: str) -> list:
return self._get_sub_insights_range(campaign_id, "adset", date_from, date_to)
def get_ad_metrics(self, campaign_id: str, date_from: str, date_to: str) -> list:
return self._get_sub_insights_range(campaign_id, "ad", date_from, date_to)
def get_yesterday_adset_metrics(self, campaign_id: str) -> list:
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return self.get_adset_metrics(campaign_id, yesterday, yesterday)
def get_yesterday_ad_metrics(self, campaign_id: str) -> list:
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return self.get_ad_metrics(campaign_id, yesterday, yesterday)
def get_period_campaign_metrics(self, days: int) -> dict:
today = datetime.now()
date_to = (today - timedelta(days=1)).strftime("%Y-%m-%d")
date_from = (today - timedelta(days=days)).strftime("%Y-%m-%d")
return self.get_campaign_metrics(date_from, date_to)
def get_period_adset_metrics(self, campaign_id: str, days: int) -> list:
today = datetime.now()
date_to = (today - timedelta(days=1)).strftime("%Y-%m-%d")
date_from = (today - timedelta(days=days)).strftime("%Y-%m-%d")
return self.get_adset_metrics(campaign_id, date_from, date_to)
def get_period_ad_metrics(self, campaign_id: str, days: int) -> list:
today = datetime.now()
date_to = (today - timedelta(days=1)).strftime("%Y-%m-%d")
date_from = (today - timedelta(days=days)).strftime("%Y-%m-%d")
return self.get_ad_metrics(campaign_id, date_from, date_to)
def get_ads_with_creatives(self, campaign_id: str) -> list:
"""
Returns active ads for a campaign with their thumbnail URLs for creative analysis.
"""
campaign = Campaign(campaign_id)
ads = campaign.get_ads(
fields=[Ad.Field.id, Ad.Field.name, Ad.Field.status, Ad.Field.creative, "adset_id"],
params={"effective_status": ["ACTIVE"]},
)
result = []
for ad in ads:
creative_ref = ad.get("creative", {})
creative_id = creative_ref.get("id") if creative_ref else None
thumbnail_url = ""
image_url = ""
video_thumbnail_url = ""
if creative_id:
try:
creative = AdCreative(creative_id).api_get(
fields=["thumbnail_url", "image_url", "video_id"]
)
thumbnail_url = creative.get("thumbnail_url", "")
image_url = creative.get("image_url", "")
video_id = creative.get("video_id", "")
# For video creatives: fetch a permanent thumbnail via AdVideo.picture
if video_id and not image_url:
try:
vdata = AdVideo(video_id).api_get(fields=["picture"])
video_thumbnail_url = vdata.get("picture", "")
except Exception:
pass
except Exception:
pass
result.append({
"ad_id": ad["id"],
"ad_name": ad["name"],
"campaign_id": campaign_id,
"adset_id": ad.get("adset_id", ""),
"thumbnail_url": thumbnail_url,
"image_url": image_url,
"video_thumbnail_url": video_thumbnail_url,
})
return result
def get_campaign_bid_config(self, campaign_id: str) -> dict:
"""Fetch bid strategy and daily/lifetime budget at campaign level."""
try:
data = Campaign(campaign_id).api_get(
fields=["bid_strategy", "daily_budget", "lifetime_budget"]
)
daily = float(data.get("daily_budget", 0) or 0)
lifetime = float(data.get("lifetime_budget", 0) or 0)
return {
"bid_strategy": data.get("bid_strategy", ""),
"daily_budget_eur": round(daily / 100, 2) if daily else None,
"lifetime_budget_eur": round(lifetime / 100, 2) if lifetime else None,
}
except Exception:
return {}
def get_adset_bid_configs(self, campaign_id: str) -> dict:
"""Returns {adset_id: {bid_strategy, cost_cap_eur, daily_budget_eur}}."""
try:
adsets = Campaign(campaign_id).get_ad_sets(
fields=[AdSet.Field.id, AdSet.Field.bid_strategy,
AdSet.Field.bid_amount, AdSet.Field.daily_budget]
)
result = {}
for as_obj in adsets:
bid_amount = float(as_obj.get("bid_amount", 0) or 0)
daily = float(as_obj.get("daily_budget", 0) or 0)
result[as_obj["id"]] = {
"bid_strategy": as_obj.get("bid_strategy", ""),
"cost_cap_eur": round(bid_amount / 100, 2) if bid_amount else None,
"daily_budget_eur": round(daily / 100, 2) if daily else None,
}
return result
except Exception:
return {}
def set_campaign_budget(self, campaign_id: str, daily_budget_cents: int):
"""Set campaign daily budget (amount in cents)."""
campaign = Campaign(campaign_id)
campaign.api_update(params={"daily_budget": daily_budget_cents})
def pause_ad(self, ad_id: str):
ad = Ad(fbid=ad_id)
ad.api_update(params={"status": Ad.Status.paused})
def pause_campaign(self, campaign_id: str):
"""Pause a campaign."""
campaign = Campaign(campaign_id)
campaign.api_update(params={"status": Campaign.Status.paused})

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
anthropic==0.95.0
facebook-business>=19.0.0
pyairtable==3.3.0
python-dotenv==1.2.2
requests>=2.32.0
fastapi>=0.111.0
uvicorn>=0.29.0
streamlit>=1.35.0
pandas>=2.0.0

463
run.py Normal file
View File

@ -0,0 +1,463 @@
"""Meta Optimizer Formación — main entry point."""
import sys
import io
import os
import time
from datetime import datetime, timedelta
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from agent import decide, analyze_unit
from baserow_client import BaserowClient
import analyzer
import slack_notifier
_ACTION_MAP = {
"PAUSE": "PAUSE",
"REDUCE_BUDGET": "REDUCE_BUDGET",
"INCREASE_BUDGET": "INCREASE_BUDGET",
"MAINTAIN": "MAINTAIN",
# legacy Spanish names, por si el modelo responde en español
"PAUSAR": "PAUSE",
"REDUCIR_PRESUPUESTO": "REDUCE_BUDGET",
"AUMENTAR_PRESUPUESTO": "INCREASE_BUDGET",
"MANTENER": "MAINTAIN",
}
def _criticidad(urgencia: str, action_type: str) -> str:
if urgencia in ("PAUSAR", "SPRINT"):
return "Crítico"
if action_type != "MAINTAIN":
return "Peligro"
return "Mantener"
def _priority(urgencia: str, action_type: str) -> int:
if urgencia in ("PAUSAR", "SPRINT"):
return 0
if action_type != "MAINTAIN":
return 1
return 2
class Tee:
def __init__(self, filepath: str):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
self._file = open(filepath, "w", encoding="utf-8")
self._stdout = sys.stdout
def write(self, data):
self._stdout.write(data)
self._file.write(data)
def flush(self):
self._stdout.flush()
if not self._file.closed:
self._file.flush()
def close(self):
self._file.close()
def _execute_action(meta: MetaAdsClient, action: dict):
"""Apply an approved action via Meta API."""
action_type = action.get("action_type", "")
cid = action.get("campaign_id", "")
parameter = float(action.get("parameter") or 1.0)
if action_type == "PAUSE":
if cid.startswith("ad:"):
meta.pause_ad(cid[3:])
else:
meta.pause_campaign(cid)
elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"):
try:
bid_cfg = meta.get_campaign_bid_config(cid)
current_budget = bid_cfg.get("daily_budget_eur")
if current_budget:
meta.set_campaign_budget(cid, int(current_budget * parameter * 100))
except Exception:
pass
def run():
start_ts = time.time()
now = datetime.now()
print(f"\n{'='*55}")
print(f" META OPTIMIZER FORMACIÓN — {now.strftime('%d/%m/%Y %H:%M')}")
print(f" Prefix: {config.META_CAMPAIGN_PREFIX} | Modelo: PPL + capping mensual por curso")
print(f" Mode: {'DRY RUN (no changes)' if config.DRY_RUN else 'PRODUCTION'}")
print(f"{'='*55}\n")
meta = MetaAdsClient()
baserow = BaserowClient()
airtable = AirtableClient()
# ── Lookups de negocio (PPL, capping, familia) desde Airtable ──────────────
print("→ Cargando PPL/capping/familia por curso desde Airtable...")
ppl_lookup, cap_lookup, familia_lookup = airtable.build_campaign_lookups()
print(f"{len(ppl_lookup)} cursos con PPL, {len(cap_lookup)} con capping este mes.\n")
# ── Execute previously approved actions ───────────────────────────────────
actions_executed = 0
if not config.DRY_RUN:
approved = baserow.get_approved_actions()
print(f"→ Executing {len(approved)} approved actions...\n")
for action in approved:
try:
_execute_action(meta, action)
baserow.update_action_status(action["id"], "executed")
actions_executed += 1
print(f"{action.get('campaign_name')}{action.get('action_type')}")
except Exception as e:
print(f" ✗ Error on action {action['id']}: {e}")
# ── Catálogo Meta -> Airtable (Meta Ads Campaigns) ─────────────────────────
print(f"→ Sincronizando catálogo de campañas {config.META_CAMPAIGN_PREFIX} con Airtable...")
meta_campaigns = meta.get_all_campaigns()
sync_result = airtable.sync_campaigns_from_meta_ads(meta_campaigns, ppl_lookup)
at_by_cid = sync_result["at_by_cid"]
print(f"{len(sync_result['created'])} creadas, {len(sync_result['updated'])} actualizadas "
f"(de {len(meta_campaigns)} campañas totales).\n")
# ── Métricas mes-a-la-fecha (para MetaCampaignMes y para el análisis) ──────
print("→ Fetching month-to-date metrics...")
month_start = f"{now.year}-{now.month:02d}-01"
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
monthly_metrics_meta = meta.get_campaign_metrics(month_start, yesterday)
print(f"{len(monthly_metrics_meta)} campañas con gasto este mes.\n")
mcm_sync = airtable.sync_metacampaignmes(
meta_campaigns, monthly_metrics_meta, ppl_lookup, cap_lookup, at_by_cid,
)
print(f"→ MetaCampaignMes: {mcm_sync['created']} creadas, {mcm_sync['updated']} actualizadas.\n")
mcm_by_meta_cid = {r["meta_campaign_id"]: r for r in airtable.get_active_metacampaignmes()}
# ── Monthly daily totals (per-campaign rows → agregado por familia) ────────
print(f"→ Fetching monthly daily totals for {config.META_CAMPAIGN_PREFIX}...")
daily_rows = meta.get_daily_campaign_rows(month_start, yesterday)
_daily: dict = {}
monthly_familias: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
mf["spend"] += row["spend"]
mf["leads"] += row["leads"]
mf["margin"] += margin
daily_totals = [
{
"date": date,
"spend": round(d["spend"], 2),
"leads": int(d["leads"]),
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
"margin": round(d["margin"], 2),
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} days with data.\n")
# ── Yesterday metrics (contexto 1d para el informe) ────────────────────────
print(f"→ Fetching yesterday metrics ({config.META_CAMPAIGN_PREFIX} only, spend > 0)...")
metrics_yesterday = meta.get_yesterday_metrics()
print(f"{len(metrics_yesterday)} campaigns active yesterday.\n")
# ── 3-day and 7-day metrics (capa táctica adset/anuncio) ───────────────────
print("→ Fetching 3-day and 7-day metrics...")
metrics_3d = meta.get_period_campaign_metrics(days=3)
metrics_7d = meta.get_period_campaign_metrics(days=7)
print(" ✓ Multi-window data ready.\n")
# ── Analyze active campaigns & propose actions ─────────────────────────────
active_campaigns = [mc for mc in meta_campaigns if mc["status"] == "ACTIVE"]
actions_proposed_list = []
campaign_details = {} # {cid: {familia, margin, adsets, ads, ...}}
familias = {} # {familia: {spend, leads, margin}}
advice_updates = [] # [(mcm_id, consejo, criticidad, log)]
final_leads_updates = [] # [(mcm_id, leads_entregados)]
errors = []
for mc in active_campaigns:
cid, name = mc["id"], mc["name"]
cursoid = extract_cursoid(name) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
cap = cap_lookup.get(cursoid, 0)
cpa_max = round(ppl * 0.70, 2)
leads_entregados, _ = airtable.get_leads_this_month_meta(name)
m1 = metrics_yesterday.get(cid, {})
mmes = monthly_metrics_meta.get(cid, {})
campaign_bid = {}
try:
campaign_bid = meta.get_campaign_bid_config(cid)
except Exception as e:
errors.append(f"Bid config {name}: {e}")
ads_metrics = {
"spend": mmes.get("spend", 0.0),
"leads": mmes.get("leads", 0),
"ctr": m1.get("ctr", 0.0),
"clicks": m1.get("clicks", 0),
"budget_daily": campaign_bid.get("daily_budget_eur", 0) or 0,
"status": mc["status"],
}
campaign_config = {
"curso": name,
"meta_campaign_id": cid,
"ppl": ppl,
"cpa_maximo": cpa_max,
"capping_mensual": cap,
"conv_leads_lake_mes": leads_entregados,
}
analysis = analyzer.analyze(campaign_config, leads_entregados, ads_metrics)
try:
decision = decide(analysis)
except Exception as e:
errors.append(f"{name}: {e}")
continue
action_type = _ACTION_MAP.get(decision.get("action", "MAINTAIN"), "MAINTAIN")
adset_bids = {}
try:
adset_bids = meta.get_adset_bid_configs(cid)
except Exception as e:
errors.append(f"Adset bids {name}: {e}")
# ABO campaigns (presupuesto solo a nivel de conjunto): omitir ajustes de campaña
is_cbo = campaign_bid.get("daily_budget_eur") is not None
if action_type in ("INCREASE_BUDGET", "REDUCE_BUDGET") and not is_cbo:
action_type = "MAINTAIN"
print(f" {name[:52]}")
print(f" Curso: {cursoid} Familia: {familia} PPL: {ppl}€ CPAmax: {cpa_max}")
print(f" Urgencia: {analysis['urgencia']} Ritmo: {analysis['ritmo']:+.2f} "
f"Leads mes: {leads_entregados}/{cap or ''} Margen: {analysis['margen']*100:.0f}%")
print(f" Decision: {action_type}{(decision.get('justification') or '')[:70]}")
if decision.get("alert"):
print(f" ALERT: {decision['alert']}")
print()
if action_type != "MAINTAIN":
try:
row = baserow.save_action({
"campaign_id": cid,
"campaign_name": name,
"action_type": action_type,
"parameter": decision.get("parameter") or 1.0,
"justification": decision.get("justification") or "",
"advice": decision.get("advice") or "",
"alert": decision.get("alert") or "",
"confidence": decision.get("confidence") or 0.0,
})
actions_proposed_list.append({
"campaign_name": name,
"action_type": action_type,
"parameter": decision.get("parameter") or 1.0,
"justification": decision.get("justification") or "",
"advice": decision.get("advice") or "",
"alert": decision.get("alert") or "",
"confidence": decision.get("confidence") or 0.0,
"cpa_actual": analysis["cpa_actual"],
"cpa_maximo": cpa_max,
"row_id": row["id"],
})
except Exception as e:
errors.append(f"Save action {name}: {e}")
# ── Ad set analysis (3d) ────────────────────────────────────────────
adsets_detail = []
try:
for as_m in meta.get_period_adset_metrics(cid, days=3)[:5]:
bid = adset_bids.get(as_m["id"], {})
as_m["bid_strategy"] = bid.get("bid_strategy", "")
as_m["cost_cap_eur"] = bid.get("cost_cap_eur")
result = analyze_unit(as_m, "adset")
adsets_detail.append({**as_m, **result})
print(f" [Adset] {as_m['name'][:45]}{result.get('evaluacion','')[:60]}")
except Exception as e:
errors.append(f"Adsets {name}: {e}")
# ── Ad analysis (3d + 7d merged) ────────────────────────────────────
ads_detail = []
try:
ads_3d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=3)}
ads_7d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=7)}
ordered_ids = list(dict.fromkeys(
[a["id"] for a in sorted(ads_7d.values(), key=lambda x: -x["spend"])] +
[a["id"] for a in sorted(ads_3d.values(), key=lambda x: -x["spend"])]
))[:5]
for ad_id in ordered_ids:
a3 = ads_3d.get(ad_id, {})
a7 = ads_7d.get(ad_id, {})
ad_m = dict(a7) if a7 else dict(a3)
ad_m["cpl_3d"] = a3.get("cpl", 0.0)
ad_m["leads_3d"] = a3.get("leads", 0)
ad_m["spend_3d"] = a3.get("spend", 0.0)
ad_m["cpl_7d"] = a7.get("cpl", 0.0)
ad_m["leads_7d"] = a7.get("leads", 0)
ad_m["ppl"] = ppl
ad_m["cpa_maximo"] = cpa_max
result = analyze_unit(ad_m, "ad")
ad_entry = {**ad_m, **result}
if result.get("accion") == "PAUSE":
try:
ad_row = baserow.save_action({
"campaign_id": f"ad:{ad_m['id']}",
"campaign_name": ad_m["name"],
"action_type": "PAUSE",
"parameter": 1.0,
"justification": result.get("recomendacion", ""),
"advice": result.get("evaluacion", ""),
"confidence": 0.8,
})
ad_entry["row_id"] = ad_row["id"]
except Exception as e:
errors.append(f"Ad action {ad_m['name']}: {e}")
ads_detail.append(ad_entry)
action_tag = " ⛔PAUSE" if result.get("accion") == "PAUSE" else ""
print(f" [Ad] {ad_m['name'][:45]}{result.get('evaluacion','')[:60]}{action_tag}")
except Exception as e:
errors.append(f"Ads {name}: {e}")
# margin_eur: proxy diario de rentabilidad (leads*PPL - gasto), igual unidad
# que las tablas de Slack; margen_pct: rentabilidad acumulada del mes (analyzer).
margin_eur = round(m1.get("leads", 0) * ppl - m1.get("spend", 0.0), 2)
campaign_details[cid] = {
"name": name,
"familia": familia,
"urgencia": analysis["urgencia"],
"margen_pct": analysis["margen"],
"margin": margin_eur,
"leads_mes": leads_entregados,
"capping": cap,
"ppl": ppl,
"spend_1d": m1.get("spend", 0.0),
"leads_1d": m1.get("leads", 0),
"adsets": adsets_detail,
"ads": ads_detail,
"bid_config": campaign_bid,
}
# ── Daily snapshot (persists analysis to Baserow for dashboard) ───────
try:
baserow.save_daily_snapshot({
"run_date": now.strftime("%Y-%m-%d"),
"campaign_id": cid,
"campaign_name": name,
"familia": familia,
"spend": m1.get("spend", 0.0),
"leads": m1.get("leads", 0),
"cpl": m1.get("cpl", 0.0),
"margin": margin_eur,
"action_type": action_type,
"justification": decision.get("justification") or "",
"adsets": adsets_detail,
"ads": ads_detail,
})
except Exception as e:
errors.append(f"Snapshot {name}: {e}")
# ── Familia aggregation ──────────────────────────────────────────────
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
f["spend"] += m1.get("spend", 0.0)
f["leads"] += m1.get("leads", 0)
f["margin"] += margin_eur
# ── MetaCampaignMes: consejo/criticidad/log + leads confirmados ───────
mcm = mcm_by_meta_cid.get(cid)
if mcm:
criticidad = _criticidad(analysis["urgencia"], action_type)
log_text = decision.get("alert") or ""
advice_updates.append((mcm["airtable_id"], decision.get("advice") or "", criticidad, log_text))
final_leads_updates.append((mcm["airtable_id"], leads_entregados))
if advice_updates:
airtable.batch_update_metacampaignmes_advice(advice_updates)
if final_leads_updates:
airtable.batch_update_metacampaignmes_final_leads(final_leads_updates)
# ── Top 10 best and worst (por CPL de ayer) ─────────────────────────────────
with_leads = [m for m in metrics_yesterday.values() if m["leads"] > 0]
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
all_active = list(metrics_yesterday.values())
worst_10 = sorted(
all_active,
key=lambda x: (x["leads"] > 0, -x["cpl"] if x["leads"] > 0 else 0),
)[:10]
# ── Send consolidated Slack report ────────────────────────────────────────
duration = round(time.time() - start_ts, 1)
try:
slack_notifier.send_daily_report(
daily_totals=daily_totals,
best_campaigns=best_10,
worst_campaigns=worst_10,
actions=actions_proposed_list,
campaigns_analyzed=len(active_campaigns),
mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION",
familias=familias,
campaign_details=campaign_details,
monthly_familias=monthly_familias,
)
except Exception as e:
print(f" Warning: Slack notification failed: {e}")
# ── Execution log ─────────────────────────────────────────────────────────
summary = (
f"{len(active_campaigns)} campaigns analyzed, "
f"{len(actions_proposed_list)} actions proposed, "
f"{actions_executed} executed."
)
try:
baserow.save_execution_log({
"mode": "DRY_RUN" if config.DRY_RUN else "PRODUCTION",
"campaigns_analyzed": len(active_campaigns),
"actions_proposed": len(actions_proposed_list),
"actions_executed": actions_executed,
"errors": "\n".join(errors),
"summary": summary,
"duration_seconds": duration,
})
except Exception as e:
print(f" Warning: could not save execution log: {e}")
print(f"{'='*55}")
print(f" Done in {duration}s. {summary}")
if errors:
print(f" Errors ({len(errors)}): {'; '.join(errors[:3])}")
print(f"{'='*55}\n")
if __name__ == "__main__":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = os.path.join("logs", f"{timestamp}.log")
tee = Tee(log_path)
sys.stdout = tee
try:
run()
finally:
tee.close()
sys.stdout = tee._stdout

12
run.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a
source .env
set +a
fi
python run.py "$@"

12
run_analyze_creatives.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a
source .env
set +a
fi
python analyze_creatives.py "$@"

168
send_slack_report.py Normal file
View File

@ -0,0 +1,168 @@
"""Re-send a day's Slack report from Baserow snapshots (sin llamar a Meta por campaña)."""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
import json
from datetime import datetime
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from baserow_client import BaserowClient
import slack_notifier
def main():
run_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
print(f"Reenviando informe para {run_date}...")
meta = MetaAdsClient()
baserow = BaserowClient()
airtable = AirtableClient()
ppl_lookup, _, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
# ── Monthly daily totals (fresh de Meta, no se persisten por campaña) ──────
print("Obteniendo datos mensuales de Meta...")
month_start = f"{run_date[:7]}-01"
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
_daily: dict = {}
monthly_familias: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
mf["spend"] += row["spend"]
mf["leads"] += row["leads"]
mf["margin"] += margin
daily_totals = [
{
"date": date,
"spend": round(d["spend"], 2),
"leads": int(d["leads"]),
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
"margin": round(d["margin"], 2),
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} días con datos")
# ── Load proposed actions (to get parameter values) ──────────────────────
action_params: dict = {} # campaign_name → parameter
try:
all_actions = baserow._get_rows(config.BASEROW_TABLE_ACTIONS, {
"filter__proposed_at__equal": run_date,
})
for a in all_actions:
cname = a.get("campaign_name", "")
param = a.get("parameter")
if cname and param:
action_params[cname] = float(param)
print(f"{len(action_params)} parámetros de acción cargados")
except Exception as e:
print(f" Aviso: no se pudieron cargar parámetros de acción: {e}")
# ── Load snapshots from Baserow ───────────────────────────────────────────
print(f"Cargando snapshots de Baserow para {run_date}...")
snapshots = baserow.get_snapshots_for_date(run_date)
print(f"{len(snapshots)} snapshots encontrados")
if not snapshots:
print("ERROR: No hay snapshots en Baserow para esta fecha. Ejecuta run.py primero.")
return
# ── Reconstruct data structures ───────────────────────────────────────────
# Nota: urgencia/leads_mes/capping no se persisten en daily_snapshots, así
# que al reenviar desde snapshots esos campos salen con su valor por
# defecto (slack_notifier ya los trata con .get(...)).
campaign_details: dict = {}
actions: list = []
familias: dict = {}
metrics_all: dict = {}
for snap in snapshots:
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
name = snap["campaign_name"]
familia = snap.get("familia") or familia_lookup.get(extract_cursoid(name) or "", "Sin familia")
margin = float(snap.get("margin") or 0)
spend = float(snap.get("spend") or 0)
leads = int(snap.get("leads") or 0)
cpl = float(snap.get("cpl") or 0)
action_type = snap.get("action_type") or "MAINTAIN"
try:
adsets = json.loads(snap.get("adsets_json") or "[]")
except Exception:
adsets = []
try:
ads = json.loads(snap.get("ads_json") or "[]")
except Exception:
ads = []
campaign_details[cid] = {
"name": name,
"familia": familia,
"margin": margin,
"spend_1d": spend,
"leads_1d": leads,
"adsets": adsets,
"ads": ads,
"bid_config": {},
}
metrics_all[cid] = {"name": name, "spend": spend, "leads": leads, "cpl": cpl}
if action_type != "MAINTAIN":
actions.append({
"campaign_name": name,
"action_type": action_type,
"justification": snap.get("justification") or "",
"advice": "",
"alert": "",
"confidence": 0.8,
"parameter": action_params.get(name, 1.0),
"row_id": snap["id"],
})
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
f["spend"] += spend
f["leads"] += leads
f["margin"] += margin
# ── Best / worst ──────────────────────────────────────────────────────────
with_leads = [m for m in metrics_all.values() if m["leads"] > 0]
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
worst_10 = sorted(
list(metrics_all.values()),
key=lambda x: (x["leads"] > 0, -x["cpl"] if x["leads"] > 0 else 0),
)[:10]
# ── Send ──────────────────────────────────────────────────────────────────
print("Enviando a Slack...")
ts = slack_notifier.send_daily_report(
daily_totals=daily_totals,
best_campaigns=best_10,
worst_campaigns=worst_10,
actions=actions,
campaigns_analyzed=len(snapshots),
mode="DRY_RUN",
familias=familias,
campaign_details=campaign_details,
monthly_familias=monthly_familias,
)
if ts:
print(f" ✓ Mensaje enviado (ts={ts})")
else:
print(" ✗ Error al enviar (revisa token y canal)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,94 @@
"""
One-time script: adds "Meta Ads Campaigns" and "MetaCampaignMes" tables to the
EXISTING Airtable base shared with leads-optimizer (AIRTABLE_BASE_ID), next to
"Google Ads Campaigns" / "GACampaignMes".
This mutates a shared, already-in-production Airtable base used by
leads-optimizer. Run it deliberately, not as part of routine deploys.
Requires an Airtable Personal Access Token with the `schema.bases:write`
scope over that base (AIRTABLE_TOKEN in .env).
Usage:
python setup_airtable_meta_tables.py
"""
import os
import sys
import requests
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.environ.get("AIRTABLE_TOKEN", "")
BASE_ID = os.environ.get("AIRTABLE_BASE_ID", "")
if not TOKEN or not BASE_ID:
print("Error: AIRTABLE_TOKEN and AIRTABLE_BASE_ID must be set in your .env file.")
sys.exit(1)
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
META_URL = f"https://api.airtable.com/v0/meta/bases/{BASE_ID}/tables"
def create_table(name: str, fields: list) -> dict:
resp = requests.post(META_URL, headers=HEADERS, json={"name": name, "fields": fields}, timeout=15)
if not resp.ok:
print(f" API error {resp.status_code}: {resp.text[:500]}")
resp.raise_for_status()
data = resp.json()
print(f" ✓ Table '{name}' created (id={data['id']})")
return data
existing = requests.get(META_URL, headers=HEADERS, timeout=15)
existing.raise_for_status()
existing_names = {t["name"] for t in existing.json().get("tables", [])}
if "Meta Ads Campaigns" in existing_names or "MetaCampaignMes" in existing_names:
print("Error: 'Meta Ads Campaigns' or 'MetaCampaignMes' already exist in this base. Aborting.")
sys.exit(1)
print(f"Creando tablas en la base {BASE_ID}...")
campaigns_table = create_table("Meta Ads Campaigns", [
{"name": "Campaign Name", "type": "singleLineText"},
{"name": "CampaignID", "type": "singleLineText"},
{"name": "CursoID Text", "type": "singleLineText"},
{
"name": "Status", "type": "singleSelect",
"options": {"choices": [{"name": "Activa"}, {"name": "Pausada"}]},
},
{"name": "PPL", "type": "number", "options": {"precision": 2}},
])
metacampaignmes_table = create_table("MetaCampaignMes", [
{"name": "Mes", "type": "singleLineText"},
{"name": "Año", "type": "singleLineText"},
{
"name": "CampaignID", "type": "multipleRecordLinks",
"options": {"linkedTableId": campaigns_table["id"]},
},
{"name": "PPL", "type": "number", "options": {"precision": 2}},
{"name": "CPAMax", "type": "number", "options": {"precision": 2}},
{"name": "CapTotalMes", "type": "number", "options": {"precision": 0}},
{"name": "CosteMes", "type": "number", "options": {"precision": 2}},
{"name": "ConvMes", "type": "number", "options": {"precision": 2}},
{
"name": "Status", "type": "singleSelect",
"options": {"choices": [{"name": "Activa"}, {"name": "Pausada"}]},
},
{"name": "Consejo", "type": "multilineText"},
{
"name": "Criticidad", "type": "singleSelect",
"options": {"choices": [{"name": "Crítico"}, {"name": "Peligro"}, {"name": "Mantener"}]},
},
{"name": "Log", "type": "multilineText"},
{"name": "ConvLeadsLakeMesFinal", "type": "number", "options": {"precision": 0}},
])
print(f"""
{'='*55}
Listo. En Airtable ya existen 'Meta Ads Campaigns' y 'MetaCampaignMes'.
No hace falta añadir nada a .env: AIRTABLE_TOKEN / AIRTABLE_BASE_ID
ya son los mismos que usa leads-optimizer.
{'='*55}
""")

174
setup_baserow.py Normal file
View File

@ -0,0 +1,174 @@
"""
One-time script: creates the meta_optimizer_formacion database in Baserow
with all tables and fields. Run once, then paste the printed IDs into your
.env file.
A diferencia de meta-optimizer (Viviful), NO se crean tablas 'campaigns' ni
'verticals' esa capa de negocio (PPL, capping, familia) vive en Airtable
(ver setup_airtable_meta_tables.py). daily_snapshots usa 'familia' en vez de
'vertical'.
Usage:
python setup_baserow.py
"""
import os
import sys
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.environ.get("BASEROW_URL", "").rstrip("/")
EMAIL = os.environ.get("BASEROW_EMAIL", "")
PASSWORD = os.environ.get("BASEROW_PASSWORD", "")
if not BASE_URL or not EMAIL or not PASSWORD:
print("Error: BASEROW_URL, BASEROW_EMAIL and BASEROW_PASSWORD must be set in your .env file.")
sys.exit(1)
# Authenticate to get a JWT token
_auth = requests.post(f"{BASE_URL}/api/user/token-auth/",
json={"email": EMAIL, "password": PASSWORD}, timeout=10)
if not _auth.ok:
print(f"Auth error: {_auth.text}")
sys.exit(1)
JWT = _auth.json()["access_token"]
HEADERS = {
"Authorization": f"JWT {JWT}",
"Content-Type": "application/json",
}
def api(method: str, path: str, **kwargs) -> dict:
url = f"{BASE_URL}/api{path}"
resp = requests.request(method, url, headers=HEADERS, **kwargs)
if not resp.ok:
print(f" API error {resp.status_code} {method} {path}: {resp.text[:300]}")
resp.raise_for_status()
return resp.json()
def setup_table(db_id: int, table_name: str, fields: list) -> int:
"""Create table, rename the auto-created primary field, then add remaining fields."""
t = api("POST", f"/database/tables/database/{db_id}/", json={"name": table_name})
table_id = t["id"]
print(f"\n Table: {table_name} (id={table_id})")
existing = api("GET", f"/database/fields/table/{table_id}/")
primary_id = existing[0]["id"]
first = fields[0]
api("PATCH", f"/database/fields/{primary_id}/", json={"name": first["name"], "type": first["type"]})
print(f" ~ primary field renamed to: {first['name']}")
for field in fields[1:]:
api("POST", f"/database/fields/table/{table_id}/", json=field)
print(f" + {field['name']}")
return table_id
# ── 1. Workspace ──────────────────────────────────────────────────────────────
workspaces = api("GET", "/workspaces/")
if not workspaces:
print("No workspaces found. Create one in Baserow first.")
sys.exit(1)
workspace_id = workspaces[0]["id"]
print(f"Workspace: {workspaces[0]['name']} (id={workspace_id})")
# ── 2. Database ───────────────────────────────────────────────────────────────
db = api("POST", f"/applications/workspace/{workspace_id}/", json={
"type": "database",
"name": "meta_optimizer_formacion",
})
db_id = db["id"]
print(f"Database: meta_optimizer_formacion (id={db_id})")
# ── 3. Tables ─────────────────────────────────────────────────────────────────
tid_actions = setup_table(db_id, "proposed_actions", [
{"name": "campaign_id", "type": "text"},
{"name": "campaign_name", "type": "text"},
{
"name": "action_type",
"type": "single_select",
"select_options": [
{"value": "PAUSE", "color": "red"},
{"value": "REDUCE_BUDGET", "color": "orange"},
{"value": "INCREASE_BUDGET", "color": "green"},
{"value": "MAINTAIN", "color": "blue"},
],
},
{"name": "parameter", "type": "number", "number_decimal_places": 2},
{"name": "justification", "type": "long_text"},
{"name": "advice", "type": "long_text"},
{"name": "alert", "type": "long_text"},
{"name": "confidence", "type": "number", "number_decimal_places": 2},
{
"name": "status",
"type": "single_select",
"select_options": [
{"value": "pending", "color": "yellow"},
{"value": "approved", "color": "green"},
{"value": "rejected", "color": "red"},
{"value": "executed", "color": "blue"},
],
},
{"name": "proposed_at", "type": "date", "date_format": "ISO", "date_include_time": True},
{"name": "executed_at", "type": "date", "date_format": "ISO", "date_include_time": True},
{"name": "slack_message_ts", "type": "text"},
])
tid_creatives = setup_table(db_id, "creative_analyses", [
{"name": "ad_id", "type": "text"},
{"name": "ad_name", "type": "text"},
{"name": "campaign_id", "type": "text"},
{"name": "image_url", "type": "url"},
{"name": "analysis", "type": "long_text"},
{"name": "score", "type": "number", "number_decimal_places": 1},
{"name": "recommendations", "type": "long_text"},
{"name": "created_at", "type": "date", "date_format": "ISO", "date_include_time": True},
])
tid_logs = setup_table(db_id, "execution_logs", [
{"name": "executed_at", "type": "date", "date_format": "ISO", "date_include_time": True},
{"name": "mode", "type": "text"},
{"name": "campaigns_analyzed", "type": "number"},
{"name": "actions_proposed", "type": "number"},
{"name": "actions_executed", "type": "number"},
{"name": "errors", "type": "long_text"},
{"name": "summary", "type": "long_text"},
{"name": "duration_seconds", "type": "number", "number_decimal_places": 1},
])
tid_snapshots = setup_table(db_id, "daily_snapshots", [
{"name": "run_date", "type": "text"},
{"name": "campaign_id", "type": "text"},
{"name": "campaign_name", "type": "text"},
{"name": "familia", "type": "text"},
{"name": "spend", "type": "number", "number_decimal_places": 2},
{"name": "leads", "type": "number"},
{"name": "cpl", "type": "number", "number_decimal_places": 2},
{"name": "margin", "type": "number", "number_decimal_places": 2, "number_negative": True},
{"name": "action_type", "type": "text"},
{"name": "justification", "type": "long_text"},
{"name": "adsets_json", "type": "long_text"},
{"name": "ads_json", "type": "long_text"},
])
# ── 4. Output env vars ────────────────────────────────────────────────────────
print(f"""
{'='*55}
Add these to your .env file:
{'='*55}
BASEROW_DB_ID={db_id}
BASEROW_TABLE_ACTIONS={tid_actions}
BASEROW_TABLE_CREATIVES={tid_creatives}
BASEROW_TABLE_LOGS={tid_logs}
BASEROW_TABLE_SNAPSHOTS={tid_snapshots}
{'='*55}
""")

554
slack_notifier.py Normal file
View File

@ -0,0 +1,554 @@
"""Slack notifier — Web API (Bot Token) con botones interactivos.
Agrupa por Familia (campo de Airtable/Cursos) en vez de por vertical, y
muestra PPL/capping mensual en vez de un CPL objetivo único: cada curso tiene
su propio PPL, así que ya no hay un número de "objetivo" comparable entre
campañas de la misma familia.
"""
from datetime import datetime
import unicodedata
import requests
import config
def _table_name(name: str, width: int) -> str:
"""Strip diacritics so accented chars don't break Slack's monospace alignment."""
normalized = unicodedata.normalize("NFKD", name)
ascii_name = "".join(c for c in normalized if not unicodedata.combining(c))
return ascii_name[:width]
_SLACK_API = "https://slack.com/api"
_STRATEGY_LABELS = {
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
"COST_CAP": "Cap. coste",
"MINIMUM_ROAS": "ROAS mín.",
}
_ACTION_DISPLAY = {
"INCREASE_BUDGET": ("🟢", "AUMENTAR PRESUPUESTO"),
"REDUCE_BUDGET": ("🔴", "REDUCIR PRESUPUESTO"),
"PAUSE": ("", "PAUSAR CAMPAÑA"),
"MAINTAIN": ("", "MANTENER"),
}
# Solo estas acciones ejecutan algo real en Meta API → botones
_ACTIONABLE = {"INCREASE_BUDGET", "REDUCE_BUDGET", "PAUSE"}
_URGENCIA_EMOJI = {
"PAUSAR": "",
"SPRINT": "🚨",
"ACELERAR": "🟢",
"FRENAR": "🔴",
"EN_RITMO": "",
}
def _effect_text(action: dict, budget: float | None) -> str:
"""Texto que describe exactamente qué ocurrirá si se aprueba la acción."""
atype = action.get("action_type", "")
param = float(action.get("parameter") or 1.0)
if atype == "PAUSE":
return "⚠️ *La campaña será pausada en Meta Ads*"
if atype in ("INCREASE_BUDGET", "REDUCE_BUDGET"):
pct = round((param - 1) * 100)
if pct == 0:
return "" # parameter not available, skip misleading text
sign = "+" if pct >= 0 else ""
if budget:
new_b = round(budget * param, 2)
return f"📊 Presupuesto diario: *{budget:.0f}€ → {new_b:.0f}€* ({sign}{pct}%)"
return f"📊 Ajuste de presupuesto: *{sign}{pct}%*"
return ""
def _post(method: str, **payload) -> dict:
resp = requests.post(
f"{_SLACK_API}/{method}",
headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"},
json=payload,
timeout=10,
)
data = resp.json()
if not data.get("ok"):
raise RuntimeError(f"Slack {method} error: {data.get('error')}{data.get('response_metadata', {}).get('messages', '')}")
return data
def update_message(channel: str, ts: str, text: str):
"""Reemplaza un mensaje con texto plano tras aprobación/rechazo."""
_post("chat.update", channel=channel, ts=ts, text=text, blocks=[])
def _ad_action_blocks(ads: list) -> list:
"""Genera bloques Slack con botón de pausa para anuncios que Claude recomienda pausar."""
blocks = []
for ad in ads:
if not ad.get("row_id"):
continue
name = ad["name"]
text = f"⛔ *{name[:80]}* _(0 leads · 7d)_"
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
"style": "danger",
"value": f"approve:{ad['row_id']}",
"action_id": f"approve_{ad['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"value": f"reject:{ad['row_id']}",
"action_id": f"reject_{ad['row_id']}",
},
],
})
return blocks
def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bool = False) -> str:
"""Genera tabla monoespaciada de adsets o anuncios para Slack."""
if not items:
return ""
lines = [f"*{label}*"]
lines.append("```")
if show_7d:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL(3d)':>8} {'CPL(7d)':>8} {'CTR':>5}")
lines.append("" * 82)
elif show_bid:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}")
lines.append("" * 79)
else:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}")
lines.append("" * 71)
for it in items:
name = _table_name(it["name"], 45)
leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else ""
if show_7d:
cpl_3d = it.get("cpl_3d", 0.0)
cpl_7d = it.get("cpl_7d", it.get("cpl", 0.0))
cpl_3d_str = f"{cpl_3d:>6.2f}" if cpl_3d > 0 else ""
cpl_7d_str = f"{cpl_7d:>6.2f}" if cpl_7d > 0 else ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_3d_str:>8} {cpl_7d_str:>8} {it['ctr']:>4.1f}%"
)
elif show_bid:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
cost_cap = it.get("cost_cap_eur")
cap_str = f" {cost_cap:>5.2f}" if cost_cap else " Auto"
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}"
)
else:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_str} {it['ctr']:>4.1f}%"
)
lines.append("```")
return "\n".join(lines)
def _familia_status(margin: float, has_issues: bool, no_data: bool) -> str:
if no_data:
return ""
if has_issues:
return "🚨" if margin < 0 else "⚠️"
return "" if margin >= 0 else "⚠️"
def send_daily_report(
daily_totals: list,
best_campaigns: list,
worst_campaigns: list,
actions: list,
campaigns_analyzed: int,
mode: str = "DRY_RUN",
familias: dict = None,
campaign_details: dict = None,
monthly_familias: dict = None,
) -> str | None:
"""Envía el informe diario consolidado. Devuelve el ts del mensaje."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y")
month_name = now.strftime("%B %Y").capitalize()
prefix = config.META_CAMPAIGN_PREFIX
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
action_map = {a["campaign_name"]: a for a in actions}
details_map = campaign_details or {}
# Group ALL campaigns by familia
by_familia: dict = {}
for cid, detail in details_map.items():
act = action_map.get(detail["name"])
by_familia.setdefault(detail["familia"], []).append((cid, detail, act))
def _has_issues(camp_list):
return any(
(act and act["action_type"] != "MAINTAIN") or
any(ad.get("accion") == "PAUSE" and ad.get("row_id")
for ad in detail.get("ads", []))
for _, detail, act in camp_list
)
# Sort familias: issues first (by margin asc), then OK (by margin desc)
def _familia_sort_key(item):
f, cl = item
f_data = (familias or {}).get(f, {})
margin = f_data.get("margin", 0)
has_iss = _has_issues(cl)
return (0 if has_iss else 1, margin if has_iss else -margin)
sorted_familias = sorted(by_familia.items(), key=_familia_sort_key)
# ── Message 1: Dashboard ─────────────────────────────────────────────────
blocks: list = [
{
"type": "header",
"text": {"type": "plain_text",
"text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
},
]
# Monthly profitability table
if daily_totals:
f_order = (
sorted(monthly_familias.keys(), key=lambda f: -monthly_familias[f]["margin"])
if monthly_familias else []
)
cw = 7
hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}"
for f in f_order:
hdr += f" {f[:6]:>{cw}}"
hdr += " Est"
sep = "" * len(hdr)
lines = [hdr, sep]
total_spend = total_leads = total_margin = 0.0
total_f = {f: 0.0 for f in f_order}
for d in daily_totals:
day = d["date"][8:10] + "/" + d["date"][5:7]
margin = d.get("margin", 0.0)
total_spend += d["spend"]
total_leads += d["leads"]
total_margin += margin
f_day = d.get("f_margins", {})
icon = "" if d["leads"] > 0 else ("" if d["spend"] > 0 else "")
row = f"{day:<5} {d['spend']:>5.0f}{d['leads']:>5} {d['cpl']:>6.2f}"
for f in f_order:
fm = f_day.get(f, 0.0)
total_f[f] += fm
fm_s = (f"+{fm:.0f}" if fm >= 0 else f"{fm:.0f}") if round(fm) != 0 else ""
row += f" {fm_s:>{cw}}"
row += f" {icon}"
lines.append(row)
lines.append(sep)
total_row = f"{'TOTAL':<5} {total_spend:>5.0f}{int(total_leads):>5} {'':>7}"
for f in f_order:
tf = total_f[f]
tf_s = f"+{tf:.0f}" if tf >= 0 else f"{tf:.0f}"
total_row += f" {tf_s:>{cw}}"
lines.append(total_row)
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Rentabilidad {month_name}*\n```" + "\n".join(lines) + "```",
},
})
else:
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"},
})
blocks.append({"type": "divider"})
# Familia scorecard
if familias:
lines = [f"{'':>2} {'Familia':<20} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Margen':>9}"]
lines.append("" * 56)
for f, cl in sorted_familias:
data = (familias or {}).get(f, {})
f_leads = data.get("leads", 0)
f_spend = data.get("spend", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_m = data.get("margin", 0)
m_sign = f"+{f_m:.0f}" if f_m >= 0 else f"{f_m:.0f}"
st = _familia_status(f_m, _has_issues(cl), f_leads == 0 and f_spend == 0)
lines.append(
f"{st} {f[:20]:<20} {f_spend:>5.0f}{f_leads:>5} {f_cpl:>6.2f}{m_sign:>9}"
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn",
"text": "*Resumen · ayer*\n```" + "\n".join(lines) + "```"},
})
blocks.append({
"type": "context",
"elements": [{"type": "mrkdwn",
"text": f"{campaigns_analyzed} campañas analizadas — detalle por familia a continuación"}],
})
result = _post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=blocks,
text=f"Meta Optimizer Formación — {date_label}",
)
ts = result.get("ts")
# ── One message per familia ──────────────────────────────────────────────
for f, camp_list in sorted_familias:
f_data = (familias or {}).get(f, {})
f_spend = f_data.get("spend", 0)
f_leads = f_data.get("leads", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_margin = f_data.get("margin", 0)
m_str = f"+{f_margin:.0f}" if f_margin >= 0 else f"{f_margin:.0f}"
has_iss = _has_issues(camp_list)
st = _familia_status(f_margin, has_iss, f_leads == 0 and f_spend == 0)
f_blocks: list = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"{st} {f.upper()}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"{f_spend:.0f}€ · {f_leads} leads · CPL {f_cpl:.2f}€ · Margen {m_str}",
},
},
{"type": "divider"},
]
for i, (cid, detail, act) in enumerate(
sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0))
):
if i > 0:
f_blocks.append({"type": "divider"})
name = detail["name"]
spend_1d = detail.get("spend_1d", 0.0)
leads_1d = detail.get("leads_1d", 0)
margin = detail["margin"]
m_str2 = f"+{margin:.2f}" if margin >= 0 else f"{margin:.2f}"
urgencia = detail.get("urgencia", "EN_RITMO")
u_emoji = _URGENCIA_EMOJI.get(urgencia, "")
leads_mes = detail.get("leads_mes", 0)
capping = detail.get("capping", 0)
cap_str = f"{leads_mes}/{capping}" if capping else f"{leads_mes}/∞"
adsets = detail.get("adsets", [])
ads = detail.get("ads", [])
bid_cfg = detail.get("bid_config", {})
budget = bid_cfg.get("daily_budget_eur")
strategy = bid_cfg.get("bid_strategy", "")
strat_label = _STRATEGY_LABELS.get(strategy, strategy or "")
atype = act["action_type"] if act else "MAINTAIN"
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype))
if atype == "MAINTAIN" and not any(
ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads
):
# Compact header for clean campaigns
f_blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
f"Margen: {m_str2} · {u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "")
),
},
})
# Still show adset breakdown for context
if adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
else:
# Full block for campaigns with action or ad pauses
camp_text = (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
f"{u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "") +
f"\n*{alabel}*"
)
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Approve/Reject buttons
if act and atype in _ACTIONABLE:
effect = _effect_text(act, budget)
if effect:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
f_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": f"approve:{act['row_id']}",
"action_id": f"approve_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
},
],
})
# Adset table (top 3) — only for non-MAINTAIN
if atype != "MAINTAIN" and adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
# Ad pause buttons
f_blocks.extend(_ad_action_blocks(ads))
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=f_blocks,
text=f"{f.upper()} · {f_spend:.0f}€ · {f_leads} leads",
)
return ts
def _score_emoji(score: float) -> str:
if score >= 8: return "🟢"
if score >= 6: return "🟡"
if score >= 4: return "🟠"
return "🔴"
def _brief_action(score: float, fatigue: bool) -> str:
if score == 0: return "sin imagen"
if fatigue: return "renovar urgente"
if score >= 8: return "mantener"
if score >= 6: return "optimizar"
if score >= 4: return "renovar"
return "reemplazar"
def send_creative_analysis_report(all_results: dict) -> None:
"""Envía scorecard compacto de creatividades a Slack. Un mensaje por campaña."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y %H:%M")
total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values())
total_fatigue = sum(
1 for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("fatigue")
)
scored = [
ad.get("score", 0) for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("score", 0) > 0
]
avg_score = round(sum(scored) / len(scored), 1) if scored else 0.0
summary = f"*{len(all_results)} campañas* · *{total_ads} anuncios* · score medio *{avg_score}/10*"
if total_fatigue:
summary += f"\n⚠️ *{total_fatigue} con fatiga creativa detectada*"
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": f"Creatividades — {date_label}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": summary}},
],
text=f"Creatividades — {date_label}",
)
# ── One message per campaign ──────────────────────────────────────────────
for cid, camp_data in all_results.items():
camp_name = camp_data["name"]
adsets = camp_data.get("adsets", {})
if not adsets:
continue
def _flush(buf: list) -> list:
if len(buf) > 1:
try:
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID,
blocks=buf, text=camp_name)
except RuntimeError as e:
print(f" [WARN] Slack: {e}")
return [{"type": "header", "text": {"type": "plain_text", "text": f"{camp_name} (cont.)"}}]
blocks: list = [{"type": "header", "text": {"type": "plain_text", "text": camp_name}}]
for as_data in adsets.values():
adset_name = as_data["name"]
ads = as_data["ads"]
ads_sorted = sorted(ads, key=lambda x: -x.get("score", 0))
# Compact monospace table — one line per ad
lines = [
f"*{adset_name}* _({len(ads)} anuncios)_",
"```",
f"{'Nombre':<33} {'Sc':>4} {'CTR':>5} {'CPL':>6} Acción",
"" * 63,
]
for ad in ads_sorted:
name = _table_name(ad["ad_name"], 33)
score = ad.get("score", 0)
ctr = ad.get("ctr_7d", 0)
cpl = ad.get("cpl_7d", 0)
fat = "" if ad.get("fatigue") else " "
action = _brief_action(score, ad.get("fatigue", False))
cpl_s = f"{cpl:.2f}" if cpl > 0 else ""
sc_s = f"{score:.1f}" if score > 0 else ""
lines.append(f"{name:<33}{fat} {sc_s:>4} {ctr:>4.1f}% {cpl_s:>6} {action}")
lines.append("```")
winner = as_data.get("comparison", {}) or {}
if winner.get("winner"):
lines.append(f"🏆 _{winner['winner'][:70]}_")
ab = [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
if len(blocks) + len(ab) > 48:
blocks = _flush(blocks)
blocks.extend(ab)
_flush(blocks)
def send_execution_summary(log: dict):
"""Resumen plano de ejecución (fallback)."""
mode_label = "DRY RUN" if log.get("mode") == "DRY_RUN" else "PRODUCCIÓN"
text = (
f":bar_chart: *Meta Optimizer Formación — Resumen diario* ({mode_label})\n"
f"• Campañas analizadas: {log.get('campaigns_analyzed', 0)}\n"
f"• Acciones propuestas: {log.get('actions_proposed', 0)}\n"
f"• Acciones ejecutadas: {log.get('actions_executed', 0)}\n"
f"• Duración: {log.get('duration_seconds', 0):.1f}s"
)
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, text=text)