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:
commit
9239e2f67f
37
.env.example
Normal file
37
.env.example
Normal 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
52
.github/workflows/daily.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
logs/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
193
CLAUDE.md
Normal file
193
CLAUDE.md
Normal 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
390
agent.py
Normal 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
347
airtable_client.py
Normal 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
208
analyze_creatives.py
Normal 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
72
analyzer.py
Normal 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
84
approval_server.py
Normal 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
196
backfill.py
Normal 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
217
baserow_client.py
Normal 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
42
config.py
Normal 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
643
dashboard.py
Normal 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
323
meta_ads_client.py
Normal 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
9
requirements.txt
Normal 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
463
run.py
Normal 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
12
run.sh
Normal 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
12
run_analyze_creatives.sh
Normal 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
168
send_slack_report.py
Normal 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()
|
||||||
94
setup_airtable_meta_tables.py
Normal file
94
setup_airtable_meta_tables.py
Normal 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
174
setup_baserow.py
Normal 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
554
slack_notifier.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user