commit 9239e2f67fd1450ef8f06f64a1aa4e5ed422f157 Author: José Manuel Gómez Date: Tue Jul 7 16:53:03 2026 +0200 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c85d5a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml new file mode 100644 index 0000000..bbbff82 --- /dev/null +++ b/.github/workflows/daily.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..029e910 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +logs/ +__pycache__/ +*.pyc +.venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d0b1b30 --- /dev/null +++ b/CLAUDE.md @@ -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`). diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..8d875b1 --- /dev/null +++ b/agent.py @@ -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}"} diff --git a/airtable_client.py b/airtable_client.py new file mode 100644 index 0000000..7fabfde --- /dev/null +++ b/airtable_client.py @@ -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) diff --git a/analyze_creatives.py b/analyze_creatives.py new file mode 100644 index 0000000..c6bb53a --- /dev/null +++ b/analyze_creatives.py @@ -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() diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 0000000..cec39d6 --- /dev/null +++ b/analyzer.py @@ -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"), + } diff --git a/approval_server.py b/approval_server.py new file mode 100644 index 0000000..1f3c693 --- /dev/null +++ b/approval_server.py @@ -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}) diff --git a/backfill.py b/backfill.py new file mode 100644 index 0000000..1f6e2f5 --- /dev/null +++ b/backfill.py @@ -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) diff --git a/baserow_client.py b/baserow_client.py new file mode 100644 index 0000000..0008f60 --- /dev/null +++ b/baserow_client.py @@ -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), + }) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1e5db7c --- /dev/null +++ b/config.py @@ -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" diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..cbaa2f0 --- /dev/null +++ b/dashboard.py @@ -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(""" + +""", 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.") diff --git a/meta_ads_client.py b/meta_ads_client.py new file mode 100644 index 0000000..9286c5b --- /dev/null +++ b/meta_ads_client.py @@ -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}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af89361 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..ab30707 --- /dev/null +++ b/run.py @@ -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 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..cb6779c --- /dev/null +++ b/run.sh @@ -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 "$@" diff --git a/run_analyze_creatives.sh b/run_analyze_creatives.sh new file mode 100644 index 0000000..8a448fe --- /dev/null +++ b/run_analyze_creatives.sh @@ -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 "$@" diff --git a/send_slack_report.py b/send_slack_report.py new file mode 100644 index 0000000..dcc0675 --- /dev/null +++ b/send_slack_report.py @@ -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() diff --git a/setup_airtable_meta_tables.py b/setup_airtable_meta_tables.py new file mode 100644 index 0000000..55a4a11 --- /dev/null +++ b/setup_airtable_meta_tables.py @@ -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} +""") diff --git a/setup_baserow.py b/setup_baserow.py new file mode 100644 index 0000000..4ad1bbd --- /dev/null +++ b/setup_baserow.py @@ -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} +""") diff --git a/slack_notifier.py b/slack_notifier.py new file mode 100644 index 0000000..b86a4e8 --- /dev/null +++ b/slack_notifier.py @@ -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)