Fix lead counting, call tracking, pause thresholds, and ABO budget detection

- _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 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-06-13 21:34:48 +02:00
parent e5c0c9f6a6
commit 8b6ec106ee
3 changed files with 36 additions and 24 deletions

View File

@ -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".
"""

View File

@ -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, ""),

33
run.py
View File

@ -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