From c46baad5026ea2244fa0578ee655ef6980a30ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Tue, 16 Jun 2026 16:32:54 +0200 Subject: [PATCH] Fix ad analysis: exclude paused ads, dual 3d/7d window, clearer Slack labels - meta_ads_client: filter currently-paused ads from analysis (they have historic spend in the window but shouldn't get PAUSE recommendations) - run.py: fetch both 3d and 7d ad metrics; merge cpl_3d/cpl_7d into each ad - agent.py AD_SYSTEM: base PAUSE on 7d window (more stable for low-volume ads); treat high cpl_3d with acceptable cpl_7d as statistical noise - slack_notifier: campaign header shows yesterday's spend/leads explicitly; ad table shows CPL(3d) and CPL(7d) side by side; labels include time period Co-Authored-By: Claude Sonnet 4.6 --- agent.py | 13 ++++++++++--- meta_ads_client.py | 12 ++++++++++++ run.py | 22 +++++++++++++++++++--- send_slack_report.py | 10 ++++++---- slack_notifier.py | 40 ++++++++++++++++++++++++++++------------ 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/agent.py b/agent.py index 564db94..33060dd 100644 --- a/agent.py +++ b/agent.py @@ -84,11 +84,18 @@ Responde SOLO con JSON válido (sin markdown): AD_SYSTEM = """ Eres un analista experto en Meta Ads. Analiza las métricas del anuncio indicado. -Los datos corresponden a los últimos 3 días (ventana estándar de análisis). +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. 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 €", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"} -Usa "accion": "PAUSE" solo si el gasto supera 3 veces el CPL objetivo (campo max_cpl en los datos; si no existe, usa 15€) con 0 conversiones, o si el CPL supera el doble del objetivo Y el gasto ya alcanza 3 veces el CPL objetivo. +{"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 spend de 7 días supera 3 veces el max_cpl (o 15€ si no existe max_cpl). +- 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". """ diff --git a/meta_ads_client.py b/meta_ads_client.py index 7391b38..7dfe912 100644 --- a/meta_ads_client.py +++ b/meta_ads_client.py @@ -146,6 +146,18 @@ class MetaAdsClient: "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: diff --git a/run.py b/run.py index 40ed713..38ab8e2 100644 --- a/run.py +++ b/run.py @@ -271,11 +271,25 @@ def run(): except Exception as e: errors.append(f"Adsets {metrics['name']}: {e}") - # ── Ad analysis ──────────────────────────────────────────────────── + # ── Ad analysis (3d + 7d merged) ─────────────────────────────────── ads_detail = [] try: - for ad_m in meta.get_period_ad_metrics(cid, days=3)[:5]: - ad_m["max_cpl"] = max_cpl + 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["max_cpl"] = max_cpl result = analyze_unit(ad_m, "ad") ad_entry = {**ad_m, **result} # Propose ad pause if Claude recommends it @@ -303,6 +317,8 @@ def run(): "name": metrics["name"], "vertical": vertical, "margin": margin, + "spend_1d": metrics["spend"], + "leads_1d": metrics["leads"], "adsets": adsets_detail, "ads": ads_detail, "bid_config": campaign_bid, diff --git a/send_slack_report.py b/send_slack_report.py index d49d407..85d28cc 100644 --- a/send_slack_report.py +++ b/send_slack_report.py @@ -121,11 +121,13 @@ def main(): ads = [] campaign_details[cid] = { - "name": name, + "name": name, "vertical": vertical, - "margin": margin, - "adsets": adsets, - "ads": ads, + "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} diff --git a/slack_notifier.py b/slack_notifier.py index f57059e..781bb2d 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -105,13 +105,16 @@ def _ad_action_blocks(ads: list) -> list: return blocks -def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: +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_bid: + 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: @@ -120,15 +123,26 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: for it in items: name = _table_name(it["name"], 45) leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else " —" - cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " —" - if show_bid: + 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: - cap_str = "" - lines.append( - f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}" - ) + 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("```") # Evaluations below the table for it in items: @@ -303,6 +317,8 @@ def send_daily_report( name = detail["name"] vertical = detail["vertical"] margin = detail["margin"] + spend_1d = detail.get("spend_1d", 0.0) + leads_1d = detail.get("leads_1d", 0) adsets = detail.get("adsets", []) ads = detail.get("ads", []) bid_cfg = detail.get("bid_config", {}) @@ -319,8 +335,8 @@ def send_daily_report( header_text = ( f"{emoji} *{name}*\n" - f"Vertical: _{vertical}_ | Margen: *{m_str}* | " - f"Estrategia: `{strategy_label}` | Presupuesto: {budget_str}\n" + f"Vertical: _{vertical}_ | Ayer: *{spend_1d:.0f}€ / {leads_1d} leads* | " + f"Margen: *{m_str}* | Estrategia: `{strategy_label}` | Presupuesto: {budget_str}\n" f"*Decisión: {alabel}*" ) if action: @@ -361,8 +377,8 @@ def send_daily_report( }) # Tabla adsets + anuncios - adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True) - ad_table = _adset_ad_table(ads, "Anuncios") + adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios (últimos 3 días)", show_bid=True) + ad_table = _adset_ad_table(ads, "Anuncios (últimos 7 días)", show_7d=True) combined = "\n\n".join(filter(None, [adset_table, ad_table])) for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]: camp_blocks.append({