José Manuel Gómez 788c00256f 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.
2026-07-09 13:07:37 +02:00

495 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import base64
import requests
import anthropic
import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
PORTFOLIO_SYSTEM = """
Eres un experto en marketing de performance para una agencia de generación de leads en formación.
Recibes datos agregados del portfolio de campañas de Meta Ads (RoiFormacion_*).
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
"""
def _classify_type(curso: str) -> str:
c = curso.lower()
if "leadads" in c or "leadsads" in c:
return "leadform"
if "_web" in c:
return "landing"
return "otro"
def portfolio_daily_analysis(collected: list) -> str:
"""Análisis estratégico diario del portfolio RoiFormacion_. Devuelve texto plano para Slack."""
from datetime import datetime
now = datetime.now()
tipos: dict = {}
leadform_detail = []
alertas_tracking = 0
campañas_perdida = 0
for item in collected:
t = _classify_type(item["campaign"]["curso"])
m = item["metrics"]
a = item["analysis"]
cost = m.get("cost", 0)
conv = a["conversiones_meta"]
ppl = item["campaign"]["ppl"]
rev = a["revenue_estimado"]
margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0
if t not in tipos:
tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0}
tipos[t]["campañas"] += 1
tipos[t]["inversion"] += cost
tipos[t]["conversiones"] += conv
tipos[t]["ingreso"] += rev
if a.get("alerta_tracking"):
alertas_tracking += 1
if rev > 0 and cost > rev:
campañas_perdida += 1
if t == "leadform":
leadform_detail.append({
"curso": item["campaign"]["curso"][:40],
"cpa_meta": round(cost / conv, 2) if conv > 0 else None,
"conv_meta": int(conv),
"conv_airtable": item["leads"],
"margen_pct": margen_pct,
})
resumen_tipos = {}
for t, d in tipos.items():
cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None
ing = d["ingreso"]
margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0
resumen_tipos[t] = {
"campañas": d["campañas"],
"inversion": round(d["inversion"], 2),
"conversiones": int(d["conversiones"]),
"cpa_medio": cpa,
"margen_pct": margen,
}
data = {
"fecha": now.strftime("%d/%m/%Y"),
"dia_del_mes": now.day,
"campañas_totales": len(collected),
"campañas_en_perdida": campañas_perdida,
"alertas_tracking": alertas_tracking,
"rendimiento_por_tipo": resumen_tipos,
"detalle_leadform": leadform_detail,
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
system=PORTFOLIO_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analiza estos datos del portfolio y proporciona:\n"
"1. Diagnóstico en 2 frases\n"
"2. Problemas principales (máx 3, con guión)\n"
"3. Acciones prioritarias (máx 3, muy concretas, con guión)\n"
"Si hay campañas leadform, evalúa específicamente su situación.\n\n"
f"{json.dumps(data, ensure_ascii=False, indent=2)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis: {e}"
DECIDE_SYSTEM = """
Eres un experto en optimización de campañas de Meta Ads para cursos de formación.
Modelo de negocio: Ingreso = leads_entregados × PPL. Margen = (Ingreso - Gasto) / Ingreso.
El capping mensual admitido por curso es un recurso COMPARTIDO entre Meta Ads y Google Ads
para el mismo curso: cuando se alcanza, hay que dejar de comprar leads en TODOS los canales,
no solo en Meta.
Recibirás un análisis ya calculado con estos campos clave:
- urgencia: PAUSAR | SPRINT | ACELERAR | FRENAR | EN_RITMO (señal principal de decisión)
- rentable: true/false (cpa_actual <= cpa_maximo)
- ritmo: positivo = adelantado sobre el ritmo del capping, negativo = atrasado
- margen, leads_restantes, dias_restantes, capping, ppl, cpa_maximo, cpa_actual
- alerta_tracking: true si hay discrepancia grande entre los leads contados por Meta y los
de Leads Lake (Airtable) — puede indicar leads fantasma o un problema de tracking/píxel
- status_meta: estado actual de la campaña en Meta (ACTIVE / PAUSED)
REGLAS DE DECISIÓN (según urgencia):
1. urgencia=PAUSAR (capping mensual ya consumido) → action=PAUSE siempre, sin excepción.
2. urgencia=SPRINT (atrasado respecto al cap y quedan pocos días de mes) → action=INCREASE_BUDGET,
parameter entre 1.3 y 1.5.
3. urgencia=ACELERAR y rentable=true → action=INCREASE_BUDGET, parameter entre 1.1 y 1.25.
4. urgencia=ACELERAR y rentable=false → action=MAINTAIN (no subir presupuesto perdiendo margen).
5. urgencia=FRENAR (muy adelantado sobre el ritmo del cap) → action=REDUCE_BUDGET,
parameter entre 0.75 y 0.9.
6. urgencia=EN_RITMO y rentable=true → action=MAINTAIN.
7. urgencia=EN_RITMO y rentable=false → action=REDUCE_BUDGET, parameter=0.85.
8. Si capping=0 (sin límite definido este mes), ignora el ritmo respecto al cap y decide
solo por rentabilidad (rentable/cpa_actual vs cpa_maximo).
9. Si alerta_tracking=true, menciónalo explícitamente en "alert" sea cual sea la acción elegida.
10. Nunca propongas INCREASE_BUDGET si status_meta=PAUSED — indica en "advice" que hay que
reactivar la campaña manualmente primero.
USA SIEMPRE € como unidad de moneda en justification/advice. Responde SIEMPRE en español en esos campos.
Responde SOLO con JSON válido, sin texto adicional ni markdown:
{
"action": "PAUSE | REDUCE_BUDGET | INCREASE_BUDGET | MAINTAIN",
"parameter": 1.0,
"justification": "explicación breve en español usando €",
"advice": "acción concreta y específica a realizar",
"alert": "texto crítico si lo hay, null si no",
"confidence": 0.0
}
"""
def decide(analysis: dict) -> dict:
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=400,
system=DECIDE_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analyze this Meta Ads campaign and return the decision as JSON:\n\n"
+ json.dumps(analysis, ensure_ascii=False, indent=2)
),
}],
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
import re as _re
m = _re.search(r"\{.*\}", clean, _re.DOTALL)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
return {
"action": "MAINTAIN",
"parameter": 1.0,
"justification": "Error parsing agent response.",
"advice": "",
"alert": f"Invalid JSON: {raw[:200]}",
"confidence": 0.0,
}
UNIT_SYSTEM = """
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.
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 €, comparando CPL con cpa_maximo (PPL)", "recomendacion": "una acción concreta"}
"""
AD_SYSTEM = """
Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del anuncio indicado.
Los datos incluyen dos ventanas temporales:
- cpl_3d / leads_3d / spend_3d: últimos 3 días (puede ser volátil con poco volumen)
- cpl_7d / leads_7d: últimos 7 días (más estable, úsala como referencia principal)
Los campos spend/leads/cpl sin sufijo corresponden a 7 días.
cpa_maximo es el coste por lead máximo rentable para el curso (PPL × 0.70).
USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español.
Responde SOLO con JSON válido (sin markdown):
{"evaluacion": "resumen del rendimiento en 2 frases usando €, mencionando diferencia 3d/7d si es relevante", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"}
Reglas para "accion": "PAUSE":
- SOLO si leads_7d == 0 Y el gasto de 7 días supera 3 veces cpa_maximo (o el PPL si no hay
cpa_maximo, o 15€ si tampoco hay PPL).
- Si cpl_3d es alto pero cpl_7d está dentro del objetivo, usa "MAINTAIN" (ruido estadístico de período corto).
- Si el anuncio tiene leads en 7 días aunque el CPL sea alto, usa "MAINTAIN" y recomienda optimizar.
En cualquier otro caso usa "accion": "MAINTAIN".
"""
def analyze_unit(metrics: dict, level: str = "adset") -> dict:
"""Análisis rápido de un conjunto de anuncios o anuncio individual."""
nivel = "conjunto de anuncios" if level == "adset" else "anuncio"
system = AD_SYSTEM if level == "ad" else UNIT_SYSTEM
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=250,
system=system,
messages=[{
"role": "user",
"content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False),
}],
)
raw = response.content[0].text.strip()
import re
clean = re.sub(r"```json\s*", "", raw)
clean = re.sub(r"```\s*", "", clean).strip()
clean = clean.replace("", '"').replace("", '"') # normalize smart quotes
# Strategy 1: direct parse
try:
return json.loads(clean)
except json.JSONDecodeError:
pass
# Strategy 2: extract first JSON object by brace boundaries
start, end = clean.find("{"), clean.rfind("}")
if start != -1 and end > start:
try:
return json.loads(clean[start:end + 1])
except json.JSONDecodeError:
pass
# Strategy 3: extract fields individually with regex
ev_m = re.search(r'"evaluacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
rec_m = re.search(r'"recomendacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
if ev_m or rec_m:
return {
"evaluacion": ev_m.group(1) if ev_m else "",
"recomendacion": rec_m.group(1) if rec_m else "",
}
return {"evaluacion": clean[:150], "recomendacion": ""}
CREATIVE_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis de creatividades de Meta Ads.
Analiza la imagen publicitaria y devuelve SOLO JSON válido sin markdown:
{
"score": 7.5,
"analysis": "análisis conciso en español: mensaje, diseño, CTA, atractivo visual",
"recommendations": "mejoras concretas en español para mejorar CTR y conversiones"
}
Score 1-10: 1-3 crítico, 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
"""
def analyze_creative(image_url: str, ad_name: str) -> dict:
try:
resp = requests.get(image_url, timeout=15)
resp.raise_for_status()
image_data = base64.standard_b64encode(resp.content).decode("utf-8")
media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0]
except Exception as e:
return {"score": 0, "analysis": f"Failed to download image: {e}", "recommendations": ""}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=600,
system=CREATIVE_SYSTEM,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_data,
},
},
{
"type": "text",
"text": f'Ad name: "{ad_name}". Analyze this creative.',
},
],
}],
)
raw = response.content[0].text.strip()
clean = raw.replace("```json", "").replace("```", "").strip()
return json.loads(clean)
except json.JSONDecodeError:
return {"score": 0, "analysis": "Error parsing creative analysis.", "recommendations": ""}
except Exception as e:
return {"score": 0, "analysis": f"Creative analysis failed: {e}", "recommendations": ""}
CREATIVE_DEEP_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis de creatividades de Meta Ads con conocimiento de neuromarketing y diseño persuasivo.
Recibirás una imagen publicitaria junto con sus métricas de rendimiento reales.
Evalúa considerando:
1. CALIDAD VISUAL: claridad del mensaje, jerarquía visual, CTA, copy, atractivo y relevancia
2. CORRELACIÓN CON RENDIMIENTO: ¿el CTR y CPL reales son consistentes con la calidad visual?
3. SEÑAL DE FATIGA: si CTR 3d < CTR 7d × 0.75 indica saturación de audiencia
4. RECOMENDACIONES: mejoras concretas y priorizadas para mejorar CTR y conversiones
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
{
"score": 7.5,
"analysis": "análisis conciso en español: qué funciona, qué no, correlación con rendimiento real",
"recommendations": "mejoras concretas en español en orden de impacto esperado",
"fatigue": false,
"fatigue_reason": null
}
Score 1-10: 1-3 crítico (pausar), 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
Si el anuncio tiene buen rendimiento real (CPL bajo, CTR alto) pero diseño mediocre, sube el score.
Si el diseño parece bueno pero el rendimiento es pobre, baja el score y explica la desconexión.
"""
CREATIVE_COMPARE_SYSTEM = """
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
Eres un experto en análisis comparativo de creatividades de Meta Ads.
Recibirás varios anuncios del mismo adset con sus imágenes y métricas de rendimiento.
Evalúa cuál funciona mejor considerando tanto calidad visual como rendimiento real.
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
{
"winner": "nombre exacto del anuncio ganador",
"ranking": [
{"name": "nombre completo del anuncio", "rank": 1, "reason": "razón en español"}
],
"insights": "observación comparativa clave en español: ¿qué diferencia visualmente al ganador del resto?"
}
"""
def _download_image(image_url) -> tuple | None:
"""Returns (base64_data, media_type) or None. Accepts str or list of URLs (tries in order)."""
urls = [image_url] if isinstance(image_url, str) else image_url
for url in urls:
if not url:
continue
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
content_type = resp.headers.get("content-type", "image/jpeg")
if not content_type.startswith("image/"):
continue
data = base64.standard_b64encode(resp.content).decode("utf-8")
return data, content_type.split(";")[0]
except Exception:
continue
return None
def _parse_json_response(raw: str) -> dict:
import re
clean = re.sub(r"```json\s*", "", raw.strip())
clean = re.sub(r"```\s*", "", clean).strip()
try:
return json.loads(clean)
except json.JSONDecodeError:
start, end = clean.find("{"), clean.rfind("}")
if start != -1 and end > start:
try:
return json.loads(clean[start:end + 1])
except json.JSONDecodeError:
pass
return {}
def analyze_creative_deep(image_url: str, ad_name: str, metrics: dict) -> dict:
"""Deep creative analysis combining visual quality with performance data and fatigue detection."""
_default = {"score": 0, "analysis": "", "recommendations": "", "fatigue": False, "fatigue_reason": None}
downloaded = _download_image(image_url)
if not downloaded:
return {**_default, "analysis": "Error descargando imagen."}
image_data, media_type = downloaded
ctr_7d = metrics.get("ctr_7d", 0)
ctr_3d = metrics.get("ctr_3d", 0)
fatigue_hint = ""
if ctr_7d > 0 and ctr_3d > 0 and ctr_3d < ctr_7d * 0.75:
fatigue_hint = f"\n⚠️ SEÑAL DE FATIGA DETECTADA: CTR cayó de {ctr_7d:.2f}% (7d) a {ctr_3d:.2f}% (3d) — posible saturación"
context = (
f'Anuncio: "{ad_name}"\n\n'
f"Métricas reales:\n"
f"- 7 días: gasto {metrics.get('spend_7d', 0):.0f}€, "
f"{metrics.get('leads_7d', 0)} leads, "
f"CPL {metrics.get('cpl_7d', 0):.2f}€, "
f"CTR {ctr_7d:.2f}%\n"
f"- 3 días: gasto {metrics.get('spend_3d', 0):.0f}€, "
f"{metrics.get('leads_3d', 0)} leads, "
f"CPL {metrics.get('cpl_3d', 0):.2f}€, "
f"CTR {ctr_3d:.2f}%\n"
f"- Coste por lead máximo rentable (cpa_maximo): {metrics.get('cpa_maximo', 0):.2f}"
f"{fatigue_hint}\n\n"
f"Analiza esta creatividad:"
)
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=700,
system=CREATIVE_DEEP_SYSTEM,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": media_type, "data": image_data}},
{"type": "text", "text": context},
],
}],
)
result = _parse_json_response(response.content[0].text)
if not result:
return {**_default, "analysis": "Error parseando respuesta."}
result.setdefault("fatigue", False)
result.setdefault("fatigue_reason", None)
return result
except Exception as e:
return {**_default, "analysis": f"Error en análisis: {e}"}
def compare_adset_creatives(ads: list) -> dict:
"""Compare up to 4 ads within the same adset. ads: list of analyzed ad dicts."""
_default = {"winner": "", "ranking": [], "insights": "Sin datos suficientes para comparar."}
top_ads = sorted(ads, key=lambda x: -x.get("spend_7d", 0))[:4]
content_blocks = []
for i, ad in enumerate(top_ads, 1):
downloaded = _download_image(ad["image_url"])
if downloaded:
image_data, media_type = downloaded
content_blocks.append({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": image_data},
})
fatigue_note = f" ⚠️ Fatiga: {ad.get('fatigue_reason','')}" if ad.get("fatigue") else ""
content_blocks.append({
"type": "text",
"text": (
f"Anuncio {i}: \"{ad['ad_name']}\"\n"
f"Score: {ad.get('score', 0):.1f}/10 | "
f"CTR 7d: {ad.get('ctr_7d', 0):.2f}% | "
f"CPL 7d: {ad.get('cpl_7d', 0):.2f}€ | "
f"Leads 7d: {ad.get('leads_7d', 0)} | "
f"Gasto 7d: {ad.get('spend_7d', 0):.0f}"
f"{fatigue_note}"
),
})
if not content_blocks:
return _default
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=600,
system=CREATIVE_COMPARE_SYSTEM,
messages=[{"role": "user", "content": content_blocks}],
)
result = _parse_json_response(response.content[0].text)
return result if result else _default
except Exception as e:
return {**_default, "insights": f"Error en comparativa: {e}"}