From 8b6ec106ee4cf91cbdf7b4417da1c38f2393b480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Sat, 13 Jun 2026 21:34:48 +0200 Subject: [PATCH] Fix lead counting, call tracking, pause thresholds, and ABO budget detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _count_conversions: prioritize 'lead' over 'lead_grouped' to fix double-counting (optimizer was showing 2x actual leads on all campaigns) - Add call_confirm/contact fallbacks for call-objective campaigns (Vodafone, Lowi) - PAUSE at campaign level only when leads==0 AND spend >= 3x max_cpl (technical failure) - Ad-level PAUSE threshold raised from 5€ fixed to 3x max_cpl (context-aware) - Pass max_cpl to ad analysis so Claude uses the correct campaign target - Skip INCREASE/REDUCE_BUDGET for ABO campaigns (no campaign-level daily budget) - Fetch bid config before action save to enable ABO detection pre-Baserow Co-Authored-By: Claude Sonnet 4.6 --- agent.py | 8 ++++---- meta_ads_client.py | 19 +++++++++++++------ run.py | 33 +++++++++++++++++++-------------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/agent.py b/agent.py index d1d2ca0..bfee12c 100644 --- a/agent.py +++ b/agent.py @@ -12,11 +12,11 @@ Cada campaña tiene un CPL máximo (coste por lead objetivo) que define el lími USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español. REGLAS DE DECISIÓN: -1. CPL > max_cpl → REDUCE_BUDGET o revisar creatividades/audiencias. +1. CPL > max_cpl → REDUCE_BUDGET (nunca PAUSE por CPL alto). 2. CPL <= max_cpl con bajo volumen → INCREASE_BUDGET si hay margen. 3. Frecuencia > 3.0 → considera rotar creatividades o ampliar audiencia. 4. CTR < 1% → problema de creatividad, revisar anuncios. -5. Sin leads tras varios días de inversión → revisar configuración de conversiones. +5. PAUSE solo si: leads == 0 Y gasto >= 3 × max_cpl (fallo técnico grave de tracking/píxel). En cualquier otro caso de bajo rendimiento usa REDUCE_BUDGET, nunca PAUSE. Responde SOLO con JSON válido, sin texto adicional ni markdown: { @@ -79,8 +79,8 @@ Eres un analista experto en Meta Ads. Analiza las métricas del anuncio indicado 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 anuncio ha gastado más de 5€ con 0 leads, o su CPL es más del doble del objetivo sin signos de mejora. -En caso contrario usa "accion": "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. +En cualquier otro caso usa "accion": "MAINTAIN". """ diff --git a/meta_ads_client.py b/meta_ads_client.py index a6559a2..c317856 100644 --- a/meta_ads_client.py +++ b/meta_ads_client.py @@ -22,14 +22,23 @@ class MetaAdsClient: ) 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 call_confirm.""" + 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"] + 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 = sum(float(a["value"]) for a in row.get("actions", []) - if a["action_type"] in ("lead", "onsite_conversion.lead_grouped")) + 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", ""), @@ -89,8 +98,7 @@ class MetaAdsClient: if not row.get("campaign_name", "").upper().startswith(prefix): continue spend = float(row.get("spend", 0)) - leads = sum(float(a["value"]) for a in row.get("actions", []) - if a["action_type"] in ("lead", "onsite_conversion.lead_grouped")) + leads = self._count_conversions(row.get("actions", [])) result.append({ "date": row["date_start"], "campaign_name": row.get("campaign_name", ""), @@ -120,8 +128,7 @@ class MetaAdsClient: spend = float(row.get("spend", 0)) if spend == 0: continue - leads = sum(float(a["value"]) for a in row.get("actions", []) - if a["action_type"] in ("lead", "onsite_conversion.lead_grouped")) + 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, ""), diff --git a/run.py b/run.py index 268d5e8..9ac50fb 100644 --- a/run.py +++ b/run.py @@ -181,6 +181,24 @@ def run(): "MAINTAIN", ) + # ── Bid config (campaña + adsets) ───────────────────────────────── + campaign_bid = {} + try: + campaign_bid = meta.get_campaign_bid_config(cid) + except Exception as e: + errors.append(f"Bid config {metrics['name']}: {e}") + + adset_bids = {} + try: + adset_bids = meta.get_adset_bid_configs(cid) + except Exception as e: + errors.append(f"Adset bids {metrics['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" {metrics['name'][:52]}") print(f" Spend: {metrics['spend']}€ Leads: {metrics['leads']} CPL: {metrics['cpl']}€ MaxCPL: {max_cpl}€ Margen: {margin:+.2f}€") print(f" Vertical: {vertical} Decision: {action_type} — {(decision.get('justification') or '')[:70]}") @@ -216,20 +234,6 @@ def run(): errors.append(f"Save action {metrics['name']}: {e}") # ── Ad set analysis ──────────────────────────────────────────────── - # ── Bid config (campaña + adsets) — antes del análisis para pasarlo a Claude ── - campaign_bid = {} - try: - campaign_bid = meta.get_campaign_bid_config(cid) - except Exception as e: - errors.append(f"Bid config {metrics['name']}: {e}") - - adset_bids = {} - try: - adset_bids = meta.get_adset_bid_configs(cid) - except Exception as e: - errors.append(f"Adset bids {metrics['name']}: {e}") - - # ── Ad set analysis (con cost_cap_eur disponible para Claude) ────────── adsets_detail = [] try: for as_m in meta.get_yesterday_adset_metrics(cid)[:5]: @@ -246,6 +250,7 @@ def run(): ads_detail = [] try: for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]: + ad_m["max_cpl"] = max_cpl result = analyze_unit(ad_m, "ad") ad_entry = {**ad_m, **result} # Propose ad pause if Claude recommends it