diff --git a/agent.py b/agent.py index 12cbdd0..a70dad8 100644 --- a/agent.py +++ b/agent.py @@ -189,13 +189,15 @@ def decide(analysis: dict) -> dict: UNIT_SYSTEM = """ -Eres un analista experto en Meta Ads. Analiza las métricas del conjunto de anuncios indicado. -Los datos corresponden a los últimos 3 días (ventana estándar de análisis). +Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del conjunto +de anuncios indicado. Los datos corresponden a los últimos 3 días (ventana estándar de análisis). +ppl es el precio por lead del curso; cpa_maximo es el coste por lead máximo rentable (PPL × 0.70). USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español. -Si el conjunto tiene cost_cap_eur (cap de coste), compara el CPL actual con ese cap e indica si está -por encima, dentro o por debajo del límite, y cuánto margen queda (o cuánto se supera). +Compara el CPL actual (spend/leads) con cpa_maximo e indica si el conjunto es rentable, cuánto +margen queda (o cuánto se supera). Si además tiene cost_cap_eur (cap de coste de Meta), menciona +también si el CPL está dentro de ese cap — son dos límites independientes, no los confundas. Responde SOLO con JSON válido (sin markdown): -{"evaluacion": "resumen del rendimiento en 2 frases usando €", "recomendacion": "una acción concreta"} +{"evaluacion": "resumen del rendimiento en 2 frases usando €, comparando CPL con cpa_maximo (PPL)", "recomendacion": "una acción concreta"} """ AD_SYSTEM = """ diff --git a/run.py b/run.py index 07756c9..e50938b 100644 --- a/run.py +++ b/run.py @@ -310,6 +310,8 @@ def run(): bid = adset_bids.get(as_m["id"], {}) as_m["bid_strategy"] = bid.get("bid_strategy", "") as_m["cost_cap_eur"] = bid.get("cost_cap_eur") + as_m["ppl"] = ppl + as_m["cpa_maximo"] = cpa_max result = analyze_unit(as_m, "adset") adsets_detail.append({**as_m, **result}) print(f" [Adset] {as_m['name'][:45]} — {result.get('evaluacion','')[:60]}") diff --git a/slack_notifier.py b/slack_notifier.py index f50e087..d05f8c5 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -64,6 +64,13 @@ def _effect_text(action: dict, budget: float | None) -> str: def _post(method: str, **payload) -> dict: + # Las tablas monoespaciadas usan abreviaturas tipo "CPL.AT" que Slack puede + # confundir con un dominio (.AT = Austria) y expandir como link preview + # (ver caso real: "CPL.AT" → unfurl de cpl.at). Desactivar unfurl siempre, + # como defensa adicional a renombrar las abreviaturas problemáticas. + if method == "chat.postMessage": + payload.setdefault("unfurl_links", False) + payload.setdefault("unfurl_media", False) resp = requests.post( f"{_SLACK_API}/{method}", headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"}, @@ -91,8 +98,8 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo 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) + lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'PPL':>6} {'Margen':>8} {'Cap':>7}") + lines.append("─" * 95) else: lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") lines.append("─" * 71) @@ -110,9 +117,14 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo 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" + cap_str = f"{cost_cap:>6.2f}€" if cost_cap else " Auto" + ppl = it.get("ppl", 0) or 0 + margin = it["leads"] * ppl - it["spend"] + ppl_str = f"{ppl:>5.2f}€" if ppl else " —" + margin_str = f"{margin:>+7.2f}€" if ppl else " —" lines.append( - f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}" + f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}% " + f"{ppl_str} {margin_str} {cap_str}" ) else: cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " —" @@ -159,7 +171,7 @@ def _curso_summary_blocks(curso_summary: dict) -> list[dict]: overflow = max(0, len(rows) - CURSO_TABLE_TOP_N) rows = rows[:CURSO_TABLE_TOP_N] - hdr = f"{'Cod':>5} {'Curso':<26} {'PPL':>6} {'L.Meta':>6} {'L.LF':>5} {'L.Land':>6} {'L.AT':>5} {'CPL.Meta':>8} {'CPL.AT':>7} {'Δ':>4}" + hdr = f"{'Cod':>5} {'Curso':<26} {'PPL':>6} {'L·Meta':>6} {'L·LF':>5} {'L·Land':>6} {'L·AT':>5} {'CPL·Meta':>8} {'CPL·AT':>7} {'Δ':>4}" sep = "─" * len(hdr) lines = [hdr, sep] for cursoid, cs in rows: @@ -188,7 +200,7 @@ def _daily_table_block(daily_totals: list, month_name: str) -> dict | None: paralelo para contrastar discrepancias de tracking, no sustituye el margen oficial).""" if not daily_totals: return None - hdr = f"{'Día':<5} {'L.AT':>5} {'L.Meta':>6} {'Coste':>7} {'€.AT':>7} {'€.Meta':>7} {'Margen':>9} {'%':>7}" + hdr = f"{'Día':<5} {'L·AT':>5} {'L·Meta':>6} {'Coste':>7} {'€·AT':>7} {'€·Meta':>7} {'Margen':>9} {'%':>7}" sep = "─" * len(hdr) lines = [hdr, sep] tot = {"spend": 0.0, "leads_at": 0, "leads_meta": 0, "ing_at": 0.0, "ing_meta": 0.0} @@ -211,7 +223,7 @@ def _daily_table_block(daily_totals: list, month_name: str) -> dict | None: ) text = ( f"*Métricas por día — {month_name}* " - "_(L.AT=leads Airtable · L.Meta=conversión propia Meta · €.AT/€.Meta=leads×PPL de cada fuente)_\n" + "_(L·AT=leads Airtable · L·Meta=conversión propia Meta · €·AT/€·Meta=leads×PPL de cada fuente)_\n" "```\n" + "\n".join(lines) + "\n```" ) return {"type": "section", "text": {"type": "mrkdwn", "text": text}}