Add deep creative analysis: standalone script, dashboard tab, compact Slack scorecard

- analyze_creatives.py: nuevo script independiente que analiza visualmente todos los
  anuncios activos, detecta fatiga creativa (CTR 3d vs 7d) y compara creatividades
  dentro del mismo adset usando Claude Sonnet con visión
- agent.py: analyze_creative_deep() con métricas de rendimiento + detección de fatiga,
  compare_adset_creatives() para comparativa multi-imagen, fallback de descarga de
  imágenes por lista de URLs, prompts en español
- meta_ads_client.py: get_ads_with_creatives() incluye adset_id, image_url separado de
  thumbnail_url, y video_thumbnail_url via AdVideo.picture para vídeos
- baserow_client.py: get_all_creative_analyses() y get_creative_history_by_ad()
- dashboard.py: nueva pestaña Creatividades con tabla seleccionable, panel lateral con
  thumbnail + análisis + recomendaciones + gráfico de evolución del score
- slack_notifier.py: scorecard compacto (una línea por anuncio con acción breve),
  fix del límite de 50 bloques via flush proactivo antes de cada adset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-06-19 12:03:10 +02:00
parent 8480e530a0
commit deed1db80e
6 changed files with 802 additions and 195 deletions

178
agent.py
View File

@ -192,3 +192,181 @@ def analyze_creative(image_url: str, ad_name: str) -> dict:
return {"score": 0, "analysis": "Error parsing creative analysis.", "recommendations": ""} return {"score": 0, "analysis": "Error parsing creative analysis.", "recommendations": ""}
except Exception as e: except Exception as e:
return {"score": 0, "analysis": f"Creative analysis failed: {e}", "recommendations": ""} 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"- Objetivo CPL máximo: {metrics.get('max_cpl', 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}"}

213
analyze_creatives.py Normal file
View File

@ -0,0 +1,213 @@
"""
Análisis profundo de creatividades de Meta Ads.
Analiza visualmente cada anuncio activo, correlaciona con métricas de rendimiento,
detecta fatiga creativa y compara anuncios dentro del mismo adset.
Uso:
python analyze_creatives.py # todas las campañas
python analyze_creatives.py --campaign VIVIFUL_5 # filtrar por nombre
python analyze_creatives.py --no-slack # sin envío a Slack
"""
import argparse
import sys
import time
from datetime import datetime
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import config
from meta_ads_client import MetaAdsClient
from baserow_client import BaserowClient
from agent import analyze_creative_deep, compare_adset_creatives
from slack_notifier import send_creative_analysis_report
def _get_max_cpl(campaign_name: str, verticals_data: dict) -> float:
name_lower = campaign_name.lower()
for vert_name, vert_cfg in verticals_data.items():
if vert_name.lower() in name_lower:
return float(vert_cfg.get("target_cpl") or 0)
return float(config.META_TARGET_CPL or 6.0)
def main():
parser = argparse.ArgumentParser(description="Deep creative analysis for Meta Ads")
parser.add_argument("--campaign", help="Filter campaigns by name substring (case-insensitive)")
parser.add_argument("--no-slack", action="store_true", help="Skip Slack report")
args = parser.parse_args()
meta = MetaAdsClient()
db = BaserowClient()
print(f"\n{'='*60}")
print(f" ANÁLISIS DE CREATIVIDADES — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*60}\n")
# Active campaigns (last 7 days)
campaigns = meta.get_period_campaign_metrics(7)
if args.campaign:
campaigns = {k: v for k, v in campaigns.items()
if args.campaign.upper() in v["name"].upper()}
if not campaigns:
print("No hay campañas activas en los últimos 7 días.")
return
print(f"{len(campaigns)} campañas a analizar\n")
verticals_data = {v["Nombre"]: v for v in db.get_all_verticals()}
all_results: dict = {}
total_analyzed = 0
total_errors = 0
for cid, camp_metrics in campaigns.items():
campaign_name = camp_metrics["name"]
max_cpl = _get_max_cpl(campaign_name, verticals_data)
print(f"{campaign_name} (objetivo CPL: {max_cpl:.2f}€)")
ads_with_creatives = meta.get_ads_with_creatives(cid)
if not ads_with_creatives:
print(" — sin anuncios activos con creatividades, omitiendo\n")
continue
# Metrics for both windows
ads_7d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, 7)}
ads_3d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, 3)}
# Adset name lookup from 7d metrics
adset_names = {a["id"]: a["name"] for a in meta.get_period_adset_metrics(cid, 7)}
# Group ads by adset
adset_groups: dict = {}
for ad in ads_with_creatives:
if not ad.get("thumbnail_url"):
continue
ad_id = ad["ad_id"]
adset_id = ad.get("adset_id", "unknown")
adset_name = adset_names.get(adset_id, adset_id)
m7 = ads_7d.get(ad_id, {})
m3 = ads_3d.get(ad_id, {})
metrics = {
"spend_7d": m7.get("spend", 0),
"leads_7d": m7.get("leads", 0),
"cpl_7d": m7.get("cpl", 0),
"ctr_7d": m7.get("ctr", 0),
"spend_3d": m3.get("spend", 0),
"leads_3d": m3.get("leads", 0),
"cpl_3d": m3.get("cpl", 0),
"ctr_3d": m3.get("ctr", 0),
"max_cpl": max_cpl,
}
if adset_id not in adset_groups:
adset_groups[adset_id] = {"name": adset_name, "ads": []}
adset_groups[adset_id]["ads"].append({
"ad_id": ad_id,
"ad_name": ad["ad_name"],
"campaign_id": cid,
"adset_id": adset_id,
"adset_name": adset_name,
# Fallback chain: signed thumbnail → permanent video picture → static image_url
"image_url": [ad["thumbnail_url"], ad["video_thumbnail_url"], ad["image_url"]],
**metrics,
})
# Analyze each ad individually
analyzed_adsets: dict = {}
for adset_id, adset_data in adset_groups.items():
adset_name = adset_data["name"]
analyzed_ads = []
for ad in adset_data["ads"]:
short_name = ad["ad_name"][:50]
print(f" [{adset_name[:30]}] {short_name}...", end=" ", flush=True)
result = analyze_creative_deep(
image_url=ad["image_url"],
ad_name=ad["ad_name"],
metrics={k: ad[k] for k in (
"spend_7d", "leads_7d", "cpl_7d", "ctr_7d",
"spend_3d", "leads_3d", "cpl_3d", "ctr_3d", "max_cpl"
)},
)
score = result.get("score", 0)
fatigue_flag = "FATIGA" if result.get("fatigue") else ""
print(f"score={score:.1f}{fatigue_flag}")
ad_result = {**ad, **result}
analyzed_ads.append(ad_result)
# Save to Baserow
analysis_text = result.get("analysis", "")
if result.get("fatigue") and result.get("fatigue_reason"):
analysis_text += f"\n\n⚠️ FATIGA CREATIVA: {result['fatigue_reason']}"
try:
urls = ad["image_url"]
saved_url = urls[0] if isinstance(urls, list) else urls
db.save_creative_analysis({
"ad_id": ad["ad_id"],
"ad_name": ad["ad_name"],
"campaign_id": cid,
"image_url": saved_url,
"analysis": analysis_text,
"score": score,
"recommendations": result.get("recommendations", ""),
})
total_analyzed += 1
except Exception as e:
print(f" [WARN] Baserow: {e}")
total_errors += 1
time.sleep(0.3)
# Comparative analysis for adsets with 2+ ads
comparison = None
if len(analyzed_ads) >= 2:
print(f" [comparativa] {adset_name[:40]}...", end=" ", flush=True)
comparison = compare_adset_creatives(analyzed_ads)
winner = comparison.get("winner", "")
print(f"ganador: {winner[:40]}")
analyzed_adsets[adset_id] = {
"name": adset_name,
"ads": analyzed_ads,
"comparison": comparison,
}
all_results[cid] = {
"name": campaign_name,
"max_cpl": max_cpl,
"adsets": analyzed_adsets,
}
print()
# Summary
total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values())
total_fatigue = sum(
1 for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("fatigue")
)
print(f"{'='*60}")
print(f" Finalizado: {len(all_results)} campañas, {total_ads} anuncios analizados")
if total_fatigue:
print(f" ⚠️ {total_fatigue} anuncios con fatiga creativa detectada")
if total_errors:
print(f" ⚠️ {total_errors} errores al guardar en Baserow")
print(f"{'='*60}\n")
# Slack report
if not args.no_slack and all_results:
print("→ Enviando informe a Slack...")
send_creative_analysis_report(all_results)
print(" ✓ Informe enviado.")
if __name__ == "__main__":
main()

View File

@ -116,6 +116,30 @@ class BaserowClient:
# ── creative_analyses ───────────────────────────────────────────────────── # ── creative_analyses ─────────────────────────────────────────────────────
def get_all_creative_analyses(self, filters: dict = None) -> list:
"""Returns creative analyses from Baserow, up to 200 rows."""
params = {"user_field_names": "true", "page_size": 200, "order_by": "-created_at"}
if filters:
params.update(filters)
try:
resp = requests.get(
self._url(config.BASEROW_TABLE_CREATIVES),
headers=self._headers, params=params, timeout=15,
)
if not resp.ok:
return []
return resp.json().get("results", [])
except requests.RequestException:
return []
def get_creative_history_by_ad(self, ad_id: str) -> list:
"""Returns all analyses for an ad_id sorted by date ascending (for score evolution)."""
rows = self._get_rows(
config.BASEROW_TABLE_CREATIVES,
{"filter__ad_id__equal": ad_id},
)
return sorted(rows, key=lambda r: r.get("created_at", ""))
def save_creative_analysis(self, analysis: dict) -> dict: def save_creative_analysis(self, analysis: dict) -> dict:
return self._create_row(config.BASEROW_TABLE_CREATIVES, { return self._create_row(config.BASEROW_TABLE_CREATIVES, {
"ad_id": analysis["ad_id"], "ad_id": analysis["ad_id"],

View File

@ -170,7 +170,7 @@ st.divider()
# ── Tabs ────────────────────────────────────────────────────────────────────── # ── Tabs ──────────────────────────────────────────────────────────────────────
tab1, tab2, tab3, tab4 = st.tabs(["📅 Por día", "📊 Campañas", "🏷️ Verticales", "🗂️ Histórico"]) tab1, tab2, tab3, tab4, tab5 = st.tabs(["📅 Por día", "📊 Campañas", "🏷️ Verticales", "🗂️ Histórico", "🎨 Creatividades"])
# ── Tab 1: Por día ──────────────────────────────────────────────────────────── # ── Tab 1: Por día ────────────────────────────────────────────────────────────
@ -473,3 +473,131 @@ with tab4:
st.write(f"_{a['evaluacion']}_") st.write(f"_{a['evaluacion']}_")
if a.get("recomendacion"): if a.get("recomendacion"):
st.write(f"{a['recomendacion']}") st.write(f"{a['recomendacion']}")
# ── Tab 5: Creatividades ──────────────────────────────────────────────────────
with tab5:
@st.cache_data(ttl=300, show_spinner="Cargando análisis de creatividades...")
def _load_creatives():
return BaserowClient().get_all_creative_analyses()
creatives_raw = _load_creatives()
if not creatives_raw:
st.info("No hay análisis de creatividades. Ejecuta `python analyze_creatives.py` para generar datos.")
st.stop()
# Build dataframe
df_all = pd.DataFrame(creatives_raw)
df_all["score"] = pd.to_numeric(df_all.get("score", 0), errors="coerce").fillna(0)
df_all["created_at"] = df_all.get("created_at", pd.Series(dtype=str))
# ── Filters ───────────────────────────────────────────────────────────────
f1, f2, f3 = st.columns([2, 2, 2])
dates_available = sorted(df_all["created_at"].dropna().unique(), reverse=True)
sel_date = f1.selectbox("Fecha análisis", dates_available)
camp_ids = sorted(df_all["campaign_id"].dropna().unique().tolist()) if "campaign_id" in df_all.columns else []
sel_camp = f2.selectbox("Campaña", ["Todas"] + camp_ids)
score_min = f3.slider("Score mínimo", 0.0, 10.0, 0.0, step=0.5)
# Apply filters
df = df_all.copy()
if sel_date:
df = df[df["created_at"] == sel_date]
if sel_camp != "Todas" and "campaign_id" in df.columns:
df = df[df["campaign_id"] == sel_camp]
if score_min > 0:
df = df[df["score"] >= score_min]
# ── KPIs ──────────────────────────────────────────────────────────────────
scored_df = df[df["score"] > 0]
avg_sc = round(scored_df["score"].mean(), 1) if not scored_df.empty else 0.0
fatigue_n = int(df["analysis"].str.contains("FATIGA", na=False).sum()) if "analysis" in df.columns else 0
last_run = df_all["created_at"].max() if not df_all.empty else ""
k1, k2, k3, k4 = st.columns(4)
k1.metric("Anuncios", len(df))
k2.metric("Score medio", f"{avg_sc}/10")
k3.metric("Con fatiga", fatigue_n)
k4.metric("Última ejecución", last_run)
# ── Fatigue alerts ────────────────────────────────────────────────────────
if fatigue_n:
fatigued = df[df["analysis"].str.contains("FATIGA", na=False)]
with st.expander(f"⚠️ {fatigue_n} anuncios con fatiga creativa", expanded=True):
for _, row in fatigued.iterrows():
st.warning(f"**{row.get('ad_name', '')}** — Score {row.get('score', 0):.1f}/10")
st.divider()
# ── Table + Detail panel ──────────────────────────────────────────────────
rename_map = {
"campaign_id": "Campaña ID",
"ad_name": "Anuncio",
"score": "Score",
"created_at": "Fecha",
"analysis": "Análisis",
"recommendations": "Recomendaciones",
}
display_cols = [c for c in rename_map if c in df.columns]
df_display = df[display_cols].rename(columns=rename_map).reset_index(drop=True)
col_table, col_detail = st.columns([3, 2])
with col_table:
st.caption("Haz clic en una fila para ver el detalle →")
event = st.dataframe(
df_display,
use_container_width=True,
selection_mode="single-row",
on_select="rerun",
column_config={
"Score": st.column_config.ProgressColumn(
"Score", min_value=0, max_value=10, format="%.1f"
),
},
hide_index=True,
)
selected_rows = event.selection.rows if hasattr(event, "selection") else []
with col_detail:
if selected_rows:
row = df.iloc[selected_rows[0]]
score = float(row.get("score", 0))
sc_emoji = "🟢" if score >= 8 else "🟡" if score >= 6 else "🟠" if score >= 4 else "🔴"
st.markdown(f"### {row.get('ad_name', '')}")
st.markdown(f"{sc_emoji} **Score: {score:.1f} / 10**")
img_url = str(row.get("image_url", ""))
if img_url.startswith("http"):
try:
st.image(img_url, use_container_width=True)
except Exception:
st.caption("_Imagen no disponible_")
analysis = str(row.get("analysis", ""))
if analysis:
st.markdown("**Análisis**")
st.write(analysis)
rec = str(row.get("recommendations", ""))
if rec:
st.markdown("**Recomendaciones**")
st.info(rec)
# Score evolution across runs
ad_id = str(row.get("ad_id", ""))
if ad_id:
history = BaserowClient().get_creative_history_by_ad(ad_id)
if len(history) >= 2:
st.markdown("**Evolución del score**")
hist_df = pd.DataFrame(history)[["created_at", "score"]].dropna()
hist_df["score"] = pd.to_numeric(hist_df["score"], errors="coerce")
hist_df = hist_df[hist_df["score"] > 0].set_index("created_at")
st.line_chart(hist_df)
else:
st.info("← Selecciona un anuncio en la tabla para ver el detalle.")

View File

@ -9,6 +9,7 @@ from facebook_business.adobjects.campaign import Campaign
from facebook_business.adobjects.adset import AdSet from facebook_business.adobjects.adset import AdSet
from facebook_business.adobjects.ad import Ad from facebook_business.adobjects.ad import Ad
from facebook_business.adobjects.adcreative import AdCreative from facebook_business.adobjects.adcreative import AdCreative
from facebook_business.adobjects.advideo import AdVideo
import config import config
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -209,30 +210,45 @@ class MetaAdsClient:
""" """
campaign = Campaign(campaign_id) campaign = Campaign(campaign_id)
ads = campaign.get_ads( ads = campaign.get_ads(
fields=[Ad.Field.id, Ad.Field.name, Ad.Field.status, Ad.Field.creative], fields=[Ad.Field.id, Ad.Field.name, Ad.Field.status, Ad.Field.creative, "adset_id"],
params={"effective_status": ["ACTIVE"]}, params={"effective_status": ["ACTIVE"]},
) )
result = [] result = []
for ad in ads: for ad in ads:
creative_ref = ad.get("creative", {}) creative_ref = ad.get("creative", {})
creative_id = creative_ref.get("id") if creative_ref else None creative_id = creative_ref.get("id") if creative_ref else None
thumbnail = "" thumbnail_url = ""
image_url = ""
video_thumbnail_url = ""
if creative_id: if creative_id:
try: try:
creative = AdCreative(creative_id).api_get( creative = AdCreative(creative_id).api_get(
fields=["thumbnail_url", "image_url"] fields=["thumbnail_url", "image_url", "video_id"]
) )
thumbnail = creative.get("thumbnail_url") or creative.get("image_url", "") thumbnail_url = creative.get("thumbnail_url", "")
image_url = creative.get("image_url", "")
video_id = creative.get("video_id", "")
# For video creatives: fetch a permanent thumbnail via AdVideo.picture
if video_id and not image_url:
try:
vdata = AdVideo(video_id).api_get(fields=["picture"])
video_thumbnail_url = vdata.get("picture", "")
except Exception:
pass
except Exception: except Exception:
pass pass
result.append({ result.append({
"ad_id": ad["id"], "ad_id": ad["id"],
"ad_name": ad["name"], "ad_name": ad["name"],
"campaign_id": campaign_id, "campaign_id": campaign_id,
"thumbnail_url": thumbnail, "adset_id": ad.get("adset_id", ""),
"thumbnail_url": thumbnail_url,
"image_url": image_url,
"video_thumbnail_url": video_thumbnail_url,
}) })
return result return result

View File

@ -157,14 +157,12 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo
return "\n".join(lines) return "\n".join(lines)
def _vertical_status_emoji(v_cpl: float, v_obj: float, has_action: bool) -> str: def _vert_status(v_cpl: float, v_obj: float, has_issues: bool) -> str:
if v_cpl == 0 or v_obj == 0: if v_cpl == 0 or v_obj == 0:
return "" return ""
if v_cpl <= v_obj: if has_issues:
return "⚠️" if has_action else "" return "🚨" if v_cpl > v_obj * 1.3 else "⚠️"
if v_cpl <= v_obj * 1.3: return "" if v_cpl <= v_obj else "⚠️"
return "⚠️"
return ""
def send_daily_report( def send_daily_report(
@ -186,33 +184,34 @@ def send_daily_report(
prefix = config.META_CAMPAIGN_PREFIX prefix = config.META_CAMPAIGN_PREFIX
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN" mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
action_map = {a["campaign_name"]: a for a in actions} action_map = {a["campaign_name"]: a for a in actions}
details_map = campaign_details or {} details_map = campaign_details or {}
name_to_cid = {d["name"]: cid for cid, d in details_map.items()}
# ── Classify campaigns: issues vs OK ─────────────────────────────────────
camps_issues: dict = {} # name → (cid, detail, action_or_None)
camps_ok: dict = {} # name → (cid, detail)
# Group ALL campaigns by vertical
by_vertical: dict = {}
for cid, detail in details_map.items(): for cid, detail in details_map.items():
name = detail["name"] act = action_map.get(detail["name"])
act = action_map.get(name) by_vertical.setdefault(detail["vertical"], []).append((cid, detail, act))
has_action = bool(act and act["action_type"] != "MAINTAIN")
has_ad_pause = any( def _has_issues(camp_list):
ad.get("accion") == "PAUSE" and ad.get("row_id") return any(
for ad in detail.get("ads", []) (act and act["action_type"] != "MAINTAIN") or
any(ad.get("accion") == "PAUSE" and ad.get("row_id")
for ad in detail.get("ads", []))
for _, detail, act in camp_list
) )
if has_action or has_ad_pause:
camps_issues[name] = (cid, detail, act)
else:
camps_ok[name] = (cid, detail)
# Group issues by vertical # Sort verticals: issues first (by margin asc), then OK (by margin desc)
by_vertical_issues: dict = {} def _vert_sort_key(item):
for name, (cid, detail, act) in camps_issues.items(): v, cl = item
by_vertical_issues.setdefault(detail["vertical"], []).append((cid, detail, act)) v_data = (verticals or {}).get(v, {})
margin = v_data.get("margin", 0)
has_iss = _has_issues(cl)
return (0 if has_iss else 1, margin if has_iss else -margin)
# ── Message 1: Executive dashboard ─────────────────────────────────────── sorted_verticals = sorted(by_vertical.items(), key=_vert_sort_key)
# ── Message 1: Dashboard ─────────────────────────────────────────────────
blocks: list = [ blocks: list = [
{ {
"type": "header", "type": "header",
@ -228,12 +227,12 @@ def send_daily_report(
if monthly_verticals else [] if monthly_verticals else []
) )
cw = 7 cw = 7
header = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}" hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}"
for v in v_order: for v in v_order:
header += f" {v[:6]:>{cw}}" hdr += f" {v[:6]:>{cw}}"
header += " Est" hdr += " Est"
sep = "" * len(header) sep = "" * len(hdr)
lines = [header, sep] lines = [hdr, sep]
total_spend = total_leads = total_margin = 0.0 total_spend = total_leads = total_margin = 0.0
total_v = {v: 0.0 for v in v_order} total_v = {v: 0.0 for v in v_order}
for d in daily_totals: for d in daily_totals:
@ -276,111 +275,31 @@ def send_daily_report(
# Vertical scorecard # Vertical scorecard
if verticals: if verticals:
lines = [f"{'Vertical':<16} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Obj':>7} {'Margen':>9}"] lines = [f"{'':>2} {'Vertical':<14} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Obj':>7} {'Margen':>9}"]
lines.append("" * 58) lines.append("" * 60)
for v, data in sorted(verticals.items(), key=lambda x: -x[1]["margin"]): for v, cl in sorted_verticals:
v_leads = data["leads"] data = (verticals or {}).get(v, {})
v_spend = data["spend"] v_leads = data.get("leads", 0)
v_spend = data.get("spend", 0)
v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0 v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0
v_m = data["margin"] v_m = data.get("margin", 0)
v_obj = data.get("target_cpl", 0) v_obj = data.get("target_cpl", 0)
m_sign = f"+{v_m:.0f}" if v_m >= 0 else f"{v_m:.0f}" m_sign = f"+{v_m:.0f}" if v_m >= 0 else f"{v_m:.0f}"
obj_str = f"{v_obj:.2f}" if v_obj else "" obj_str = f"{v_obj:.2f}" if v_obj else ""
has_act = v in by_vertical_issues st = _vert_status(v_cpl, v_obj, _has_issues(cl))
st = _vertical_status_emoji(v_cpl, v_obj, has_act)
lines.append( lines.append(
f"{st} {v:<14} {v_spend:>5.0f}{v_leads:>5} {v_cpl:>6.2f}{obj_str:>7} {m_sign:>9}" f"{st} {v:<14} {v_spend:>5.0f}{v_leads:>5} {v_cpl:>6.2f}{obj_str:>7} {m_sign:>9}"
) )
blocks.append({ blocks.append({
"type": "section", "type": "section",
"text": {"type": "mrkdwn", "text": {"type": "mrkdwn",
"text": "*Verticales · ayer*\n```" + "\n".join(lines) + "```"}, "text": "*Resumen · ayer*\n```" + "\n".join(lines) + "```"},
})
# Actions section
if actions:
blocks.append({"type": "divider"})
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "*🚨 Acciones recomendadas*"},
})
for act in actions:
atype = act["action_type"]
emoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype))
cid = name_to_cid.get(act["campaign_name"])
bid_cfg = details_map.get(cid, {}).get("bid_config", {}) if cid else {}
budget = bid_cfg.get("daily_budget_eur")
text = f"{emoji} *{act['campaign_name'][:60]}* → *{alabel}*"
if act.get("justification"):
text += f"\n_{act['justification'][:200]}_"
if act.get("alert"):
text += f"\n:warning: {act['alert'][:150]}"
effect = _effect_text(act, budget)
if effect:
text += f"\n{effect}"
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
if atype in _ACTIONABLE:
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": f"approve:{act['row_id']}",
"action_id": f"approve_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
},
],
})
# Ad pause count notice
total_ad_pauses = sum(
1 for _, detail, _ in camps_issues.values()
for ad in detail.get("ads", [])
if ad.get("accion") == "PAUSE" and ad.get("row_id")
)
if total_ad_pauses > 0:
noun = "anuncio" if total_ad_pauses == 1 else "anuncios"
blocks.append({
"type": "context",
"elements": [{"type": "mrkdwn",
"text": f"{total_ad_pauses} {noun} recomendados para pausar — ver detalle por vertical"}],
})
# "Todo en orden" summary — grouped by vertical
if camps_ok:
blocks.append({"type": "divider"})
ok_by_v: dict = {}
for name, (_, detail) in camps_ok.items():
ok_by_v.setdefault(detail["vertical"], []).append((name, detail))
ok_lines = ["*Todo en orden*"]
for vert in sorted(ok_by_v.keys()):
v_data = (verticals or {}).get(vert, {})
v_spend = v_data.get("spend", 0)
v_leads = v_data.get("leads", 0)
v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0
v_obj = v_data.get("target_cpl", 0)
ok_lines.append(f"\n✅ *{vert.upper()}* · CPL {v_cpl:.2f}€ obj {v_obj:.2f}")
for name, detail in sorted(ok_by_v[vert], key=lambda x: -x[1].get("spend_1d", 0)):
ok_lines.append(
f"{name} · "
f"{detail.get('spend_1d', 0):.0f}€ / {detail.get('leads_1d', 0)} leads ayer"
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "\n".join(ok_lines)},
}) })
blocks.append({ blocks.append({
"type": "context", "type": "context",
"elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}], "elements": [{"type": "mrkdwn",
"text": f"{campaigns_analyzed} campañas analizadas — detalle por vertical a continuación"}],
}) })
result = _post( result = _post(
@ -391,8 +310,8 @@ def send_daily_report(
) )
ts = result.get("ts") ts = result.get("ts")
# ── Messages 2-N: one per vertical with issues ──────────────────────────── # ── One message per vertical ──────────────────────────────────────────────
for v, camp_list in sorted(by_vertical_issues.items()): for v, camp_list in sorted_verticals:
v_data = (verticals or {}).get(v, {}) v_data = (verticals or {}).get(v, {})
v_spend = v_data.get("spend", 0) v_spend = v_data.get("spend", 0)
v_leads = v_data.get("leads", 0) v_leads = v_data.get("leads", 0)
@ -401,11 +320,13 @@ def send_daily_report(
v_margin = v_data.get("margin", 0) v_margin = v_data.get("margin", 0)
m_str = f"+{v_margin:.0f}" if v_margin >= 0 else f"{v_margin:.0f}" m_str = f"+{v_margin:.0f}" if v_margin >= 0 else f"{v_margin:.0f}"
obj_str = f" · obj {v_obj:.2f}" if v_obj else "" obj_str = f" · obj {v_obj:.2f}" if v_obj else ""
has_iss = _has_issues(camp_list)
st = _vert_status(v_cpl, v_obj, has_iss)
v_blocks: list = [ v_blocks: list = [
{ {
"type": "header", "type": "header",
"text": {"type": "plain_text", "text": f"📊 {v.upper()}"}, "text": {"type": "plain_text", "text": f"{st} {v.upper()}"},
}, },
{ {
"type": "section", "type": "section",
@ -417,6 +338,7 @@ def send_daily_report(
), ),
}, },
}, },
{"type": "divider"},
] ]
for i, (cid, detail, act) in enumerate( for i, (cid, detail, act) in enumerate(
@ -424,79 +346,205 @@ def send_daily_report(
): ):
if i > 0: if i > 0:
v_blocks.append({"type": "divider"}) v_blocks.append({"type": "divider"})
name = detail["name"]
spend_1d = detail.get("spend_1d", 0.0) name = detail["name"]
leads_1d = detail.get("leads_1d", 0) spend_1d = detail.get("spend_1d", 0.0)
margin = detail["margin"] leads_1d = detail.get("leads_1d", 0)
m_str2 = f"+{margin:.2f}" if margin >= 0 else f"{margin:.2f}" margin = detail["margin"]
adsets = detail.get("adsets", []) m_str2 = f"+{margin:.2f}" if margin >= 0 else f"{margin:.2f}"
ads = detail.get("ads", []) adsets = detail.get("adsets", [])
bid_cfg = detail.get("bid_config", {}) ads = detail.get("ads", [])
budget = bid_cfg.get("daily_budget_eur") bid_cfg = detail.get("bid_config", {})
strategy = bid_cfg.get("bid_strategy", "") budget = bid_cfg.get("daily_budget_eur")
strategy = bid_cfg.get("bid_strategy", "")
strat_label = _STRATEGY_LABELS.get(strategy, strategy or "") strat_label = _STRATEGY_LABELS.get(strategy, strategy or "")
atype = act["action_type"] if act else "MAINTAIN"
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype))
atype = act["action_type"] if act else "MAINTAIN" if atype == "MAINTAIN" and not any(
emoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype)) ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads
):
camp_text = ( # Compact header for clean campaigns
f"{emoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
f"`{strat_label}`" + (f" · {budget:.0f}€/día" if budget else "") +
f"\n*{alabel}*"
)
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Approve/Reject buttons for campaign action
if act and atype in _ACTIONABLE:
effect = _effect_text(act, budget)
if effect:
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
v_blocks.append({ v_blocks.append({
"type": "actions", "type": "section",
"elements": [ "text": {
{ "type": "mrkdwn",
"type": "button", "text": (
"text": {"type": "plain_text", "text": "✅ Aprobar"}, f"{cemoji} *{name}*\n"
"style": "primary", f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
"value": f"approve:{act['row_id']}", f"Margen: {m_str2} · `{strat_label}`"
"action_id": f"approve_{act['row_id']}", + (f" · {budget:.0f}€/día" if budget else "")
}, ),
{ },
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
},
],
}) })
# Still show adset breakdown for context
if adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
else:
# Full block for campaigns with action or ad pauses
camp_text = (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
f"`{strat_label}`" + (f" · {budget:.0f}€/día" if budget else "") +
f"\n*{alabel}*"
)
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Adset table — top 3, only for non-MAINTAIN campaigns # Approve/Reject buttons
if atype != "MAINTAIN" and adsets: if act and atype in _ACTIONABLE:
adset_table = _adset_ad_table(adsets[:3], "Conjuntos de anuncios (3 días)", show_bid=True) effect = _effect_text(act, budget)
if adset_table: if effect:
for chunk in [adset_table[i:i+2900] for i in range(0, len(adset_table), 2900)]: v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) v_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": f"approve:{act['row_id']}",
"action_id": f"approve_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
},
],
})
# Ad pause buttons # Adset table (top 3) — only for non-MAINTAIN
v_blocks.extend(_ad_action_blocks(ads)) if atype != "MAINTAIN" and adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
# Ad pause buttons
v_blocks.extend(_ad_action_blocks(ads))
_post( _post(
"chat.postMessage", "chat.postMessage",
channel=config.SLACK_CHANNEL_ID, channel=config.SLACK_CHANNEL_ID,
blocks=v_blocks, blocks=v_blocks,
text=f"Detalle vertical: {v}", text=f"{v.upper()} · {v_spend:.0f}€ · {v_leads} leads",
) )
return ts return ts
def _score_emoji(score: float) -> str:
if score >= 8: return "🟢"
if score >= 6: return "🟡"
if score >= 4: return "🟠"
return "🔴"
def _brief_action(score: float, fatigue: bool) -> str:
if score == 0: return "sin imagen"
if fatigue: return "renovar urgente"
if score >= 8: return "mantener"
if score >= 6: return "optimizar"
if score >= 4: return "renovar"
return "reemplazar"
def send_creative_analysis_report(all_results: dict) -> None:
"""Envía scorecard compacto de creatividades a Slack. Un mensaje por campaña."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y %H:%M")
total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values())
total_fatigue = sum(
1 for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("fatigue")
)
scored = [
ad.get("score", 0) for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("score", 0) > 0
]
avg_score = round(sum(scored) / len(scored), 1) if scored else 0.0
summary = f"*{len(all_results)} campañas* · *{total_ads} anuncios* · score medio *{avg_score}/10*"
if total_fatigue:
summary += f"\n⚠️ *{total_fatigue} con fatiga creativa detectada*"
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": f"Creatividades — {date_label}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": summary}},
],
text=f"Creatividades — {date_label}",
)
# ── One message per campaign ──────────────────────────────────────────────
for cid, camp_data in all_results.items():
camp_name = camp_data["name"]
adsets = camp_data.get("adsets", {})
if not adsets:
continue
def _flush(buf: list) -> list:
if len(buf) > 1:
try:
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID,
blocks=buf, text=camp_name)
except RuntimeError as e:
print(f" [WARN] Slack: {e}")
return [{"type": "header", "text": {"type": "plain_text", "text": f"{camp_name} (cont.)"}}]
blocks: list = [{"type": "header", "text": {"type": "plain_text", "text": camp_name}}]
for as_data in adsets.values():
adset_name = as_data["name"]
ads = as_data["ads"]
ads_sorted = sorted(ads, key=lambda x: -x.get("score", 0))
# Compact monospace table — one line per ad
lines = [
f"*{adset_name}* _({len(ads)} anuncios)_",
"```",
f"{'Nombre':<33} {'Sc':>4} {'CTR':>5} {'CPL':>6} Acción",
"" * 63,
]
for ad in ads_sorted:
name = _table_name(ad["ad_name"], 33)
score = ad.get("score", 0)
ctr = ad.get("ctr_7d", 0)
cpl = ad.get("cpl_7d", 0)
fat = "" if ad.get("fatigue") else " "
action = _brief_action(score, ad.get("fatigue", False))
cpl_s = f"{cpl:.2f}" if cpl > 0 else ""
sc_s = f"{score:.1f}" if score > 0 else ""
lines.append(f"{name:<33}{fat} {sc_s:>4} {ctr:>4.1f}% {cpl_s:>6} {action}")
lines.append("```")
winner = as_data.get("comparison", {}) or {}
if winner.get("winner"):
lines.append(f"🏆 _{winner['winner'][:70]}_")
ab = [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
if len(blocks) + len(ab) > 48:
blocks = _flush(blocks)
blocks.extend(ab)
_flush(blocks)
def send_execution_summary(log: dict): def send_execution_summary(log: dict):
"""Resumen plano de ejecución (fallback).""" """Resumen plano de ejecución (fallback)."""
mode_label = "DRY RUN" if log.get("mode") == "DRY_RUN" else "PRODUCCIÓN" mode_label = "DRY RUN" if log.get("mode") == "DRY_RUN" else "PRODUCCIÓN"