Fix Slack link-unfurl bug and add PPL/Margen to adset table

- Rename table abbreviations that Slack misread as domains: 'CPL.AT' was
  auto-unfurled as a link to the .AT (Austria) TLD, posting an unrelated
  business's ad copy into the channel. Switch '.' to '·' in all L/€/CPL
  abbreviations, and set unfurl_links=False/unfurl_media=False on every
  postMessage as defense in depth.
- Pass ppl/cpa_maximo into adset analysis too (ads already had it) so
  Claude's adset evaluation compares CPL against the course's PPL-derived
  rentability threshold, not just Meta's own bid cost cap.
- Add PPL and Margen columns to the adset numeric table in Slack.
This commit is contained in:
Jose Manuel 2026-07-09 13:07:37 +02:00
parent 769d86c896
commit 788c00256f
3 changed files with 28 additions and 12 deletions

View File

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

2
run.py
View File

@ -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]}")

View File

@ -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} {'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}}