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:
parent
8480e530a0
commit
deed1db80e
178
agent.py
178
agent.py
@ -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
213
analyze_creatives.py
Normal 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()
|
||||||
@ -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"],
|
||||||
|
|||||||
130
dashboard.py
130
dashboard.py
@ -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.")
|
||||||
|
|||||||
@ -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,7 +210,7 @@ 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"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -217,14 +218,26 @@ class MetaAdsClient:
|
|||||||
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
|
||||||
|
|
||||||
@ -232,7 +245,10 @@ class MetaAdsClient:
|
|||||||
"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
|
||||||
|
|||||||
@ -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(
|
||||||
@ -188,31 +186,32 @@ def send_daily_report(
|
|||||||
|
|
||||||
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({
|
blocks.append({
|
||||||
"type": "context",
|
"type": "context",
|
||||||
"elements": [{"type": "mrkdwn",
|
"elements": [{"type": "mrkdwn",
|
||||||
"text": f"⛔ {total_ad_pauses} {noun} recomendados para pausar — ver detalle por vertical"}],
|
"text": f"{campaigns_analyzed} campañas analizadas — detalle por vertical a continuación"}],
|
||||||
})
|
|
||||||
|
|
||||||
# "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({
|
|
||||||
"type": "context",
|
|
||||||
"elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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,6 +346,7 @@ def send_daily_report(
|
|||||||
):
|
):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
v_blocks.append({"type": "divider"})
|
v_blocks.append({"type": "divider"})
|
||||||
|
|
||||||
name = detail["name"]
|
name = detail["name"]
|
||||||
spend_1d = detail.get("spend_1d", 0.0)
|
spend_1d = detail.get("spend_1d", 0.0)
|
||||||
leads_1d = detail.get("leads_1d", 0)
|
leads_1d = detail.get("leads_1d", 0)
|
||||||
@ -435,12 +358,35 @@ def send_daily_report(
|
|||||||
budget = bid_cfg.get("daily_budget_eur")
|
budget = bid_cfg.get("daily_budget_eur")
|
||||||
strategy = bid_cfg.get("bid_strategy", "")
|
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"
|
atype = act["action_type"] if act else "MAINTAIN"
|
||||||
emoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype))
|
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype))
|
||||||
|
|
||||||
|
if atype == "MAINTAIN" and not any(
|
||||||
|
ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads
|
||||||
|
):
|
||||||
|
# Compact header for clean campaigns
|
||||||
|
v_blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"{cemoji} *{name}*\n"
|
||||||
|
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
|
||||||
|
f"Margen: {m_str2} · `{strat_label}`"
|
||||||
|
+ (f" · {budget:.0f}€/día" if budget else "")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
# 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 = (
|
camp_text = (
|
||||||
f"{emoji} *{name}*\n"
|
f"{cemoji} *{name}*\n"
|
||||||
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
|
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
|
||||||
f"`{strat_label}`" + (f" · {budget:.0f}€/día" if budget else "") +
|
f"`{strat_label}`" + (f" · {budget:.0f}€/día" if budget else "") +
|
||||||
f"\n*{alabel}*"
|
f"\n*{alabel}*"
|
||||||
@ -449,10 +395,9 @@ def send_daily_report(
|
|||||||
camp_text += f" — _{act['justification'][:160]}_"
|
camp_text += f" — _{act['justification'][:160]}_"
|
||||||
if act and act.get("alert"):
|
if act and act.get("alert"):
|
||||||
camp_text += f"\n:warning: {act['alert'][:130]}"
|
camp_text += f"\n:warning: {act['alert'][:130]}"
|
||||||
|
|
||||||
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
|
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
|
||||||
|
|
||||||
# Approve/Reject buttons for campaign action
|
# Approve/Reject buttons
|
||||||
if act and atype in _ACTIONABLE:
|
if act and atype in _ACTIONABLE:
|
||||||
effect = _effect_text(act, budget)
|
effect = _effect_text(act, budget)
|
||||||
if effect:
|
if effect:
|
||||||
@ -477,11 +422,11 @@ def send_daily_report(
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Adset table — top 3, only for non-MAINTAIN campaigns
|
# Adset table (top 3) — only for non-MAINTAIN
|
||||||
if atype != "MAINTAIN" and adsets:
|
if atype != "MAINTAIN" and adsets:
|
||||||
adset_table = _adset_ad_table(adsets[:3], "Conjuntos de anuncios (3 días)", show_bid=True)
|
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
|
||||||
if adset_table:
|
if tbl:
|
||||||
for chunk in [adset_table[i:i+2900] for i in range(0, len(adset_table), 2900)]:
|
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}})
|
v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
|
||||||
|
|
||||||
# Ad pause buttons
|
# Ad pause buttons
|
||||||
@ -491,12 +436,115 @@ def send_daily_report(
|
|||||||
"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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user