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:
parent
769d86c896
commit
788c00256f
12
agent.py
12
agent.py
@ -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
2
run.py
@ -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]}")
|
||||||
|
|||||||
@ -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} {'L·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}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user