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:
parent
e5c0c9f6a6
commit
8b6ec106ee
8
agent.py
8
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.
|
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".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
33
run.py
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user