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. USA SIEMPRE como unidad de moneda. Responde SIEMPRE en español.
REGLAS DE DECISIÓN: 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. 2. CPL <= max_cpl con bajo volumen INCREASE_BUDGET si hay margen.
3. Frecuencia > 3.0 considera rotar creatividades o ampliar audiencia. 3. Frecuencia > 3.0 considera rotar creatividades o ampliar audiencia.
4. CTR < 1% problema de creatividad, revisar anuncios. 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: 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. USA SIEMPRE como unidad de moneda. Responde SIEMPRE en español.
Responde SOLO con JSON válido (sin markdown): 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"} {"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. 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 caso contrario usa "accion": "MAINTAIN". En cualquier otro caso usa "accion": "MAINTAIN".
""" """

View File

@ -22,14 +22,23 @@ class MetaAdsClient:
) )
self.account = AdAccount(config.META_AD_ACCOUNT_ID) 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: def _parse_insights_row(self, row: dict) -> dict:
spend = float(row.get("spend", 0)) spend = float(row.get("spend", 0))
impressions = int(row.get("impressions", 0)) impressions = int(row.get("impressions", 0))
clicks = int(row.get("clicks", 0)) clicks = int(row.get("clicks", 0))
ctr = float(row.get("ctr", 0)) ctr = float(row.get("ctr", 0))
cpm = float(row.get("cpm", 0)) cpm = float(row.get("cpm", 0))
leads = sum(float(a["value"]) for a in row.get("actions", []) leads = self._count_conversions(row.get("actions", []))
if a["action_type"] in ("lead", "onsite_conversion.lead_grouped"))
cpl = round(spend / leads, 2) if leads > 0 else 0.0 cpl = round(spend / leads, 2) if leads > 0 else 0.0
return { return {
"campaign_id": row.get("campaign_id", ""), "campaign_id": row.get("campaign_id", ""),
@ -89,8 +98,7 @@ class MetaAdsClient:
if not row.get("campaign_name", "").upper().startswith(prefix): if not row.get("campaign_name", "").upper().startswith(prefix):
continue continue
spend = float(row.get("spend", 0)) spend = float(row.get("spend", 0))
leads = sum(float(a["value"]) for a in row.get("actions", []) leads = self._count_conversions(row.get("actions", []))
if a["action_type"] in ("lead", "onsite_conversion.lead_grouped"))
result.append({ result.append({
"date": row["date_start"], "date": row["date_start"],
"campaign_name": row.get("campaign_name", ""), "campaign_name": row.get("campaign_name", ""),
@ -120,8 +128,7 @@ class MetaAdsClient:
spend = float(row.get("spend", 0)) spend = float(row.get("spend", 0))
if spend == 0: if spend == 0:
continue continue
leads = sum(float(a["value"]) for a in row.get("actions", []) leads = self._count_conversions(row.get("actions", []))
if a["action_type"] in ("lead", "onsite_conversion.lead_grouped"))
cpl = round(spend / leads, 2) if leads > 0 else 0.0 cpl = round(spend / leads, 2) if leads > 0 else 0.0
result.append({ result.append({
"id": row.get(id_field, ""), "id": row.get(id_field, ""),

33
run.py
View File

@ -181,6 +181,24 @@ def run():
"MAINTAIN", "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" {metrics['name'][:52]}")
print(f" Spend: {metrics['spend']}€ Leads: {metrics['leads']} CPL: {metrics['cpl']}€ MaxCPL: {max_cpl}€ Margen: {margin:+.2f}") 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]}") 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}") errors.append(f"Save action {metrics['name']}: {e}")
# ── Ad set analysis ──────────────────────────────────────────────── # ── 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 = [] adsets_detail = []
try: try:
for as_m in meta.get_yesterday_adset_metrics(cid)[:5]: for as_m in meta.get_yesterday_adset_metrics(cid)[:5]:
@ -246,6 +250,7 @@ def run():
ads_detail = [] ads_detail = []
try: try:
for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]: for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]:
ad_m["max_cpl"] = max_cpl
result = analyze_unit(ad_m, "ad") result = analyze_unit(ad_m, "ad")
ad_entry = {**ad_m, **result} ad_entry = {**ad_m, **result}
# Propose ad pause if Claude recommends it # Propose ad pause if Claude recommends it