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 = """ UNIT_SYSTEM = """
Eres un analista experto en Meta Ads. Analiza las métricas del conjunto de anuncios indicado. Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del conjunto
Los datos corresponden a los últimos 3 días (ventana estándar de análisis). 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. 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á Compara el CPL actual (spend/leads) con cpa_maximo e indica si el conjunto es rentable, cuánto
por encima, dentro o por debajo del límite, y cuánto margen queda (o cuánto se supera). 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): 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 = """ AD_SYSTEM = """

2
run.py
View File

@ -310,6 +310,8 @@ def run():
bid = adset_bids.get(as_m["id"], {}) bid = adset_bids.get(as_m["id"], {})
as_m["bid_strategy"] = bid.get("bid_strategy", "") as_m["bid_strategy"] = bid.get("bid_strategy", "")
as_m["cost_cap_eur"] = bid.get("cost_cap_eur") 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") result = analyze_unit(as_m, "adset")
adsets_detail.append({**as_m, **result}) adsets_detail.append({**as_m, **result})
print(f" [Adset] {as_m['name'][:45]}{result.get('evaluacion','')[:60]}") 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: 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( resp = requests.post(
f"{_SLACK_API}/{method}", f"{_SLACK_API}/{method}",
headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"}, 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(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL(3d)':>8} {'CPL(7d)':>8} {'CTR':>5}")
lines.append("" * 82) lines.append("" * 82)
elif show_bid: elif show_bid:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'PPL':>6} {'Margen':>8} {'Cap':>7}")
lines.append("" * 79) lines.append("" * 95)
else: else:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}")
lines.append("" * 71) 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: elif show_bid:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else "" cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
cost_cap = it.get("cost_cap_eur") 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( 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: else:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 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) overflow = max(0, len(rows) - CURSO_TABLE_TOP_N)
rows = 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) sep = "" * len(hdr)
lines = [hdr, sep] lines = [hdr, sep]
for cursoid, cs in rows: 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).""" paralelo para contrastar discrepancias de tracking, no sustituye el margen oficial)."""
if not daily_totals: if not daily_totals:
return None 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) sep = "" * len(hdr)
lines = [hdr, sep] lines = [hdr, sep]
tot = {"spend": 0.0, "leads_at": 0, "leads_meta": 0, "ing_at": 0.0, "ing_meta": 0.0} 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 = ( text = (
f"*Métricas por día — {month_name}* " 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```" "```\n" + "\n".join(lines) + "\n```"
) )
return {"type": "section", "text": {"type": "mrkdwn", "text": text}} return {"type": "section", "text": {"type": "mrkdwn", "text": text}}