"""Interactive Meta Optimizer dashboard — Streamlit.""" import streamlit as st from datetime import date, timedelta import pandas as pd import sys import os sys.path.insert(0, os.path.dirname(__file__)) from meta_ads_client import MetaAdsClient from baserow_client import BaserowClient import config def _extract_vertical(name: str) -> str: """VIVIFUL_13_telefonia_leadads → telefonia""" prefix = config.META_CAMPAIGN_PREFIX rest = name[len(prefix):].lstrip("_") parts = rest.split("_") start = 1 if parts and parts[0].isdigit() else 0 return parts[start].lower() if start < len(parts) else "otros" st.set_page_config( page_title=f"Meta Optimizer — {config.META_CAMPAIGN_PREFIX}", layout="wide", initial_sidebar_state="expanded", ) _STRATEGY_LABELS = { "LOWEST_COST_WITHOUT_CAP": "Menor coste", "LOWEST_COST_WITH_BID_CAP": "Cap. puja", "COST_CAP": "Cap. coste", "MINIMUM_ROAS": "ROAS mín.", } def _eur(val: float) -> str: return f"{val:.2f}€" def _margin(val: float) -> str: return f"+{val:.0f}€" if val >= 0 else f"{val:.0f}€" def _status(leads: int, spend: float) -> str: if leads > 0: return "✅" if spend > 0: return "❌" return "—" @st.cache_data(ttl=300, show_spinner="Cargando datos de Meta API...") def _load_data(date_from: str, date_to: str): meta = MetaAdsClient() baserow = BaserowClient() vertical_cpls: dict = {} try: for v in baserow.get_all_verticals(): name = (v.get("Nombre") or "").strip().lower() cpl = float(v.get("target_cpl") or 0) if name and cpl: vertical_cpls[name] = cpl except Exception: pass daily_rows = meta.get_daily_campaign_rows(date_from, date_to) campaign_metrics = meta.get_campaign_metrics(date_from, date_to) return daily_rows, campaign_metrics, vertical_cpls @st.cache_data(ttl=300, show_spinner="Cargando detalle de campaña...") def _load_detail(campaign_id: str, date_from: str, date_to: str): meta = MetaAdsClient() adsets = meta.get_adset_metrics(campaign_id, date_from, date_to) ads = meta.get_ad_metrics(campaign_id, date_from, date_to) bid = meta.get_campaign_bid_config(campaign_id) bids = meta.get_adset_bid_configs(campaign_id) for adset in adsets: b = bids.get(adset["id"], {}) adset["cost_cap_eur"] = b.get("cost_cap_eur") adset["bid_strategy"] = b.get("bid_strategy", "") return adsets, ads, bid # ── Sidebar ─────────────────────────────────────────────────────────────────── st.sidebar.title("Filtros") today = date.today() yesterday = today - timedelta(days=1) default_from = yesterday - timedelta(days=6) # últimos 7 días por defecto first_of_month = today.replace(day=1) c1, c2 = st.sidebar.columns(2) date_from = c1.date_input("Desde", value=default_from, max_value=yesterday) date_to = c2.date_input("Hasta", value=yesterday, min_value=date_from, max_value=yesterday) if st.sidebar.button("🔄 Actualizar", use_container_width=True): st.cache_data.clear() date_from_str = date_from.strftime("%Y-%m-%d") date_to_str = date_to.strftime("%Y-%m-%d") if date_from > date_to: st.error("La fecha inicio debe ser anterior a la fecha fin.") st.stop() # ── Data ────────────────────────────────────────────────────────────────────── try: daily_rows, campaign_metrics, vertical_cpls = _load_data(date_from_str, date_to_str) except Exception as _e: st.error(f"Error cargando datos de Meta API: {_e}") st.stop() # Aggregate daily totals with per-vertical margins _daily: dict = {} for row in daily_rows: v = _extract_vertical(row["campaign_name"]) target = vertical_cpls.get(v, config.META_TARGET_CPL) margin = round((target - row["spend"] / row["leads"]) * row["leads"], 2) if row["leads"] > 0 else round(-row["spend"], 2) d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0}) d["spend"] += row["spend"] d["leads"] += row["leads"] d["margin"] += margin daily_totals = [ { "date": dt, "spend": round(d["spend"], 2), "leads": int(d["leads"]), "cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0, "margin": round(d["margin"], 2), } for dt, d in sorted(_daily.items()) ] # Aggregate verticals verticals: dict = {} for cid, m in campaign_metrics.items(): v = _extract_vertical(m["name"]) target = vertical_cpls.get(v, config.META_TARGET_CPL) margin = round((target - m["cpl"]) * m["leads"], 2) if m["leads"] > 0 else round(-m["spend"], 2) if v not in verticals: verticals[v] = {"spend": 0.0, "leads": 0, "margin": 0.0, "target_cpl": target} verticals[v]["spend"] += m["spend"] verticals[v]["leads"] += m["leads"] verticals[v]["margin"] += margin # Vertical filter (populated after load) v_options = ["Todos"] + sorted(verticals.keys()) selected_vertical = st.sidebar.selectbox("Vertical", v_options) if selected_vertical != "Todos": campaign_metrics = { cid: m for cid, m in campaign_metrics.items() if _extract_vertical(m["name"]) == selected_vertical } # ── Header ──────────────────────────────────────────────────────────────────── st.title(f"Meta Optimizer — {config.META_CAMPAIGN_PREFIX}") st.caption(f"Período: **{date_from.strftime('%d/%m/%Y')}** → **{date_to.strftime('%d/%m/%Y')}**") total_spend = sum(d["spend"] for d in daily_totals) total_leads = sum(d["leads"] for d in daily_totals) total_cpl = round(total_spend / total_leads, 2) if total_leads > 0 else 0.0 total_margin = sum(d["margin"] for d in daily_totals) k1, k2, k3, k4 = st.columns(4) k1.metric("Gasto total", _eur(total_spend)) k2.metric("Leads totales", f"{total_leads:,}") k3.metric("CPL medio", _eur(total_cpl)) k4.metric("Margen total", _margin(total_margin)) st.divider() # ── Tabs ────────────────────────────────────────────────────────────────────── tab1, tab2, tab3, tab4, tab5 = st.tabs(["📅 Por día", "📊 Campañas", "🏷️ Verticales", "🗂️ Histórico", "🎨 Creatividades"]) # ── Tab 1: Por día ──────────────────────────────────────────────────────────── with tab1: if not daily_totals: st.info("Sin datos para el período seleccionado.") else: df_daily = pd.DataFrame([ { "Día": d["date"][8:10] + "/" + d["date"][5:7], "Gasto": _eur(d["spend"]), "Leads": d["leads"], "CPL": _eur(d["cpl"]), "Margen": _margin(d["margin"]), "Est": _status(d["leads"], d["spend"]), } for d in daily_totals ]) st.dataframe(df_daily, use_container_width=True, hide_index=True) st.subheader("Desglose por campaña") day_opts = [d["date"] for d in reversed(daily_totals)] selected_day = st.selectbox( "Selecciona un día", day_opts, format_func=lambda s: s[8:10] + "/" + s[5:7] + "/" + s[:4], ) if selected_day: day_camp: dict = {} for row in daily_rows: if row["date"] != selected_day: continue key = row["campaign_name"] if key not in day_camp: v = _extract_vertical(key) target = vertical_cpls.get(v, config.META_TARGET_CPL) day_camp[key] = { "name": key, "vertical": v, "spend": 0.0, "leads": 0, "target_cpl": target, } day_camp[key]["spend"] += row["spend"] day_camp[key]["leads"] += row["leads"] rows = [] for c in sorted(day_camp.values(), key=lambda x: -x["spend"]): cpl = round(c["spend"] / c["leads"], 2) if c["leads"] > 0 else 0.0 margin = round((c["target_cpl"] - cpl) * c["leads"], 2) if c["leads"] > 0 else round(-c["spend"], 2) rows.append({ "Campaña": c["name"], "Vertical": c["vertical"], "Gasto": _eur(c["spend"]), "Leads": c["leads"], "CPL": _eur(cpl) if c["leads"] > 0 else "—", "Obj": _eur(c["target_cpl"]), "Margen": _margin(margin), }) if rows: st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) else: st.info("Sin campañas activas ese día.") # ── Tab 2: Campañas ─────────────────────────────────────────────────────────── with tab2: if not campaign_metrics: st.info("Sin campañas para el período seleccionado.") else: camp_rows = [] for cid, m in sorted(campaign_metrics.items(), key=lambda x: -x[1]["spend"]): v = _extract_vertical(m["name"]) target = vertical_cpls.get(v, config.META_TARGET_CPL) margin = round((target - m["cpl"]) * m["leads"], 2) if m["leads"] > 0 else round(-m["spend"], 2) camp_rows.append({ "Campaña": m["name"], "Vertical": v, "Gasto": _eur(m["spend"]), "Leads": m["leads"], "CPL": _eur(m["cpl"]) if m["leads"] > 0 else "—", "Obj": _eur(target), "Margen": _margin(margin), "CTR": f"{m['ctr']:.1f}%", "_cid": cid, }) df_camps = pd.DataFrame([{k: v for k, v in r.items() if k != "_cid"} for r in camp_rows]) st.dataframe(df_camps, use_container_width=True, hide_index=True) st.subheader("Detalle de campaña") camp_id_map = {r["Campaña"]: r["_cid"] for r in camp_rows} selected_camp = st.selectbox("Selecciona una campaña", list(camp_id_map.keys())) if selected_camp: selected_cid = camp_id_map[selected_camp] adsets, ads, bid_cfg = _load_detail(selected_cid, date_from_str, date_to_str) strategy = bid_cfg.get("bid_strategy", "") strat_label = _STRATEGY_LABELS.get(strategy, strategy or "—") budget = bid_cfg.get("daily_budget_eur") budget_str = f"{budget:.0f}€/día" if budget else "—" st.caption(f"Estrategia: **{strat_label}** | Presupuesto: **{budget_str}**") if adsets: st.markdown("**Conjuntos de anuncios**") df_adsets = pd.DataFrame([ { "Nombre": a["name"], "Gasto": _eur(a["spend"]), "Leads": a["leads"], "CPL": _eur(a["cpl"]) if a["leads"] > 0 else "—", "CTR": f"{a['ctr']:.1f}%", "Cap": _eur(a["cost_cap_eur"]) if a.get("cost_cap_eur") else "Auto", } for a in adsets ]) st.dataframe(df_adsets, use_container_width=True, hide_index=True) else: st.info("Sin conjuntos de anuncios con gasto en este período.") if ads: st.markdown("**Anuncios**") df_ads = pd.DataFrame([ { "Nombre": a["name"], "Gasto": _eur(a["spend"]), "Leads": a["leads"], "CPL": _eur(a["cpl"]) if a["leads"] > 0 else "—", "CTR": f"{a['ctr']:.1f}%", "CPM": _eur(a["cpm"]), } for a in ads ]) st.dataframe(df_ads, use_container_width=True, hide_index=True) else: st.info("Sin anuncios con gasto en este período.") # ── Tab 3: Verticales ───────────────────────────────────────────────────────── with tab3: if not verticals: st.info("Sin datos de verticales.") else: vert_rows = [] for v, data in sorted(verticals.items(), key=lambda x: -x[1]["margin"]): v_leads = data["leads"] v_spend = data["spend"] v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0 vert_rows.append({ "Vertical": v, "Gasto": _eur(v_spend), "Leads": v_leads, "CPL": _eur(v_cpl), "Obj": _eur(data["target_cpl"]) if data.get("target_cpl") else "—", "Margen": _margin(data["margin"]), }) st.dataframe(pd.DataFrame(vert_rows), use_container_width=True, hide_index=True) # ── Tab 4: Histórico ────────────────────────────────────────────────────────── _ACTION_COLORS = { "INCREASE_BUDGET": "🟢", "REDUCE_BUDGET": "🟠", "PAUSE": "🔴", "REVIEW_CREATIVES": "🟣", "MAINTAIN": "⚪", } @st.cache_data(ttl=120, show_spinner="Cargando fechas disponibles...") def _load_snapshot_dates(): return BaserowClient().get_snapshot_dates() @st.cache_data(ttl=120, show_spinner="Cargando análisis del día...") def _load_snapshots(run_date: str): import json rows = BaserowClient().get_snapshots_for_date(run_date) result = [] for r in rows: try: adsets = json.loads(r.get("adsets_json") or "[]") except Exception: adsets = [] try: ads = json.loads(r.get("ads_json") or "[]") except Exception: ads = [] result.append({ "campaign_name": r.get("campaign_name", ""), "vertical": r.get("vertical", ""), "spend": float(r.get("spend") or 0), "leads": int(r.get("leads") or 0), "cpl": float(r.get("cpl") or 0), "margin": float(r.get("margin") or 0), "action_type": r.get("action_type", "MAINTAIN"), "justification": r.get("justification", ""), "adsets": adsets, "ads": ads, }) return sorted(result, key=lambda x: -x["spend"]) with tab4: dates = _load_snapshot_dates() if not dates: st.info("Sin análisis guardados aún. Los snapshots se generan al ejecutar run.py.") else: fmt_date = lambda s: s[8:10] + "/" + s[5:7] + "/" + s[:4] selected_date = st.selectbox( "Fecha del análisis", dates, format_func=fmt_date, ) if st.button("🔄 Recargar", key="reload_hist"): st.cache_data.clear() snapshots = _load_snapshots(selected_date) if not snapshots: st.info("Sin datos para esa fecha.") else: # ── Resumen del día ─────────────────────────────────────────────── d_spend = sum(s["spend"] for s in snapshots) d_leads = sum(s["leads"] for s in snapshots) d_cpl = round(d_spend / d_leads, 2) if d_leads > 0 else 0.0 d_margin = sum(s["margin"] for s in snapshots) h1, h2, h3, h4 = st.columns(4) h1.metric("Gasto", _eur(d_spend)) h2.metric("Leads", f"{d_leads:,}") h3.metric("CPL", _eur(d_cpl)) h4.metric("Margen", _margin(d_margin)) st.divider() # ── Tabla de campañas clicable ──────────────────────────────────── df_snap = pd.DataFrame([ { "Acción": _ACTION_COLORS.get(s["action_type"], "⚪") + " " + s["action_type"], "Campaña": s["campaign_name"], "Vertical": s["vertical"], "Gasto": _eur(s["spend"]), "Leads": s["leads"], "CPL": _eur(s["cpl"]) if s["leads"] > 0 else "—", "Margen": _margin(s["margin"]), } for s in snapshots ]) event = st.dataframe( df_snap, use_container_width=True, hide_index=True, on_select="rerun", selection_mode="single-row", ) sel_rows = event.selection.rows if sel_rows: snap = snapshots[sel_rows[0]] st.subheader(snap["campaign_name"]) st.caption( f"Vertical: **{snap['vertical']}** | " f"Decisión: **{snap['action_type']}** | " f"Margen: **{_margin(snap['margin'])}**" ) if snap["justification"]: st.info(snap["justification"]) # ── Adsets — expanders con evaluación visible ───────────────── adsets = snap["adsets"] if adsets: st.markdown("**Conjuntos de anuncios**") for a in adsets: label = ( f"{a['name']} — " f"{_eur(a['spend'])} · {a['leads']} leads · " f"CPL {_eur(a['cpl']) if a['leads'] > 0 else '—'} · " f"CTR {a.get('ctr', 0):.1f}%" ) with st.expander(label): if a.get("cost_cap_eur"): st.caption(f"Cap: {_eur(a['cost_cap_eur'])}") if a.get("evaluacion"): st.write(f"_{a['evaluacion']}_") if a.get("recomendacion"): st.write(f"→ {a['recomendacion']}") # ── Anuncios — expanders con evaluación visible ─────────────── ads = snap["ads"] if ads: st.markdown("**Anuncios**") for a in ads: label = ( f"{a['name']} — " f"{_eur(a['spend'])} · {a['leads']} leads · " f"CPL {_eur(a['cpl']) if a['leads'] > 0 else '—'} · " f"CTR {a.get('ctr', 0):.1f}% · " f"CPM {_eur(a.get('cpm', 0))}" ) with st.expander(label): if a.get("evaluacion"): st.write(f"_{a['evaluacion']}_") if a.get("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.")