Unified Formación report: leadform+landing leads, AT/Meta daily table, per-curso contrast, strategic diagnosis

- Broaden Airtable lead counting to attr_utm_source IN ('Lead ads','landingpage')
  — the 'landingpage' leads (100% fbclid, 0% gclid) were being missed entirely,
  undercounting real leads for '_web' suffixed campaigns and skewing
  capping/pacing decisions since yesterday's first production run.
- Add airtable_client.get_meta_leads_bulk() for day/curso-level aggregation.
- Drop per-familia Slack sectioning in favor of a single Formación block,
  chunked by campaign batches instead.
- Add daily AT-vs-Meta table, per-curso PPL/CPL contrast table (leadform vs
  landing breakdown), and a Claude-generated portfolio strategic diagnosis
  (ported from leads-optimizer's portfolio_daily_analysis).
- Persist daily aggregate totals to a new Baserow table (daily_metrics) so
  the dashboard and future reports don't depend on Meta's historical API
  access remaining available indefinitely.
- Surface adset/ad-level recommendations in the campaign cards instead of
  only numeric tables.
This commit is contained in:
Jose Manuel 2026-07-09 11:02:19 +02:00
parent f8cbd5f29e
commit 769d86c896
9 changed files with 742 additions and 352 deletions

View File

@ -0,0 +1,95 @@
"""
One-time script: adds the 'daily_metrics' table to the EXISTING
meta_optimizer_formacion database in Baserow (created by setup_baserow.py).
Un registro por día con los totales agregados de todo el bloque Formación
(spend, leads_meta, leads_at, ing_meta, ing_at, margin, margin_pct). Se
persiste cada día al ejecutar run.py para no depender de poder volver a
pedirle a Meta el histórico diario más adelante (la API no garantiza
retención ilimitada), y para que el dashboard pueda leerlo sin llamar a
Meta/Airtable cada vez.
Usage:
python add_daily_metrics_table.py
"""
import os
import sys
import requests
from dotenv import load_dotenv
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
load_dotenv()
BASE_URL = os.environ.get("BASEROW_URL", "").rstrip("/")
EMAIL = os.environ.get("BASEROW_EMAIL", "")
PASSWORD = os.environ.get("BASEROW_PASSWORD", "")
DB_NAME = "meta_optimizer_formacion"
if not BASE_URL or not EMAIL or not PASSWORD:
print("Error: BASEROW_URL, BASEROW_EMAIL and BASEROW_PASSWORD must be set in .env")
sys.exit(1)
auth = requests.post(f"{BASE_URL}/api/user/token-auth/",
json={"email": EMAIL, "password": PASSWORD}, timeout=10)
if not auth.ok:
print(f"Auth error: {auth.text}")
sys.exit(1)
JWT = auth.json()["access_token"]
HEADERS = {"Authorization": f"JWT {JWT}", "Content-Type": "application/json"}
def api(method, path, **kwargs):
resp = requests.request(method, f"{BASE_URL}/api{path}", headers=HEADERS, **kwargs)
if not resp.ok:
print(f" API error {resp.status_code} {method} {path}: {resp.text[:300]}")
resp.raise_for_status()
return resp.json()
db_id = None
for ws in api("GET", "/workspaces/"):
for app in api("GET", f"/applications/workspace/{ws['id']}/"):
if app.get("name") == DB_NAME:
db_id = app["id"]
break
if db_id:
break
if not db_id:
print(f"Error: no se encontró la base '{DB_NAME}'. Ejecuta setup_baserow.py primero.")
sys.exit(1)
print(f"Database: {DB_NAME} (id={db_id})")
existing_tables = api("GET", f"/database/tables/database/{db_id}/")
if any(t["name"] == "daily_metrics" for t in existing_tables):
print("La tabla 'daily_metrics' ya existe. Nada que hacer.")
sys.exit(0)
t = api("POST", f"/database/tables/database/{db_id}/", json={"name": "daily_metrics"})
table_id = t["id"]
print(f"Table: daily_metrics (id={table_id})")
primary_id = api("GET", f"/database/fields/table/{table_id}/")[0]["id"]
api("PATCH", f"/database/fields/{primary_id}/", json={"name": "date", "type": "text"})
print(" ~ primary field: date")
for f in [
{"name": "spend", "type": "number", "number_decimal_places": 2},
{"name": "leads_meta", "type": "number"},
{"name": "leads_at", "type": "number"},
{"name": "ing_meta", "type": "number", "number_decimal_places": 2},
{"name": "ing_at", "type": "number", "number_decimal_places": 2},
{"name": "margin", "type": "number", "number_decimal_places": 2, "number_negative": True},
{"name": "margin_pct", "type": "number", "number_decimal_places": 1, "number_negative": True},
]:
api("POST", f"/database/fields/table/{table_id}/", json=f)
print(f" + {f['name']}")
print(f"""
{'='*50}
Añade esto a tu .env:
BASEROW_TABLE_DAILY_METRICS={table_id}
{'='*50}
""")

102
agent.py
View File

@ -6,6 +6,108 @@ import config
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
PORTFOLIO_SYSTEM = """
Eres un experto en marketing de performance para una agencia de generación de leads en formación.
Recibes datos agregados del portfolio de campañas de Meta Ads (RoiFormacion_*).
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
"""
def _classify_type(curso: str) -> str:
c = curso.lower()
if "leadads" in c or "leadsads" in c:
return "leadform"
if "_web" in c:
return "landing"
return "otro"
def portfolio_daily_analysis(collected: list) -> str:
"""Análisis estratégico diario del portfolio RoiFormacion_. Devuelve texto plano para Slack."""
from datetime import datetime
now = datetime.now()
tipos: dict = {}
leadform_detail = []
alertas_tracking = 0
campañas_perdida = 0
for item in collected:
t = _classify_type(item["campaign"]["curso"])
m = item["metrics"]
a = item["analysis"]
cost = m.get("cost", 0)
conv = a["conversiones_meta"]
ppl = item["campaign"]["ppl"]
rev = a["revenue_estimado"]
margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0
if t not in tipos:
tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0}
tipos[t]["campañas"] += 1
tipos[t]["inversion"] += cost
tipos[t]["conversiones"] += conv
tipos[t]["ingreso"] += rev
if a.get("alerta_tracking"):
alertas_tracking += 1
if rev > 0 and cost > rev:
campañas_perdida += 1
if t == "leadform":
leadform_detail.append({
"curso": item["campaign"]["curso"][:40],
"cpa_meta": round(cost / conv, 2) if conv > 0 else None,
"conv_meta": int(conv),
"conv_airtable": item["leads"],
"margen_pct": margen_pct,
})
resumen_tipos = {}
for t, d in tipos.items():
cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None
ing = d["ingreso"]
margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0
resumen_tipos[t] = {
"campañas": d["campañas"],
"inversion": round(d["inversion"], 2),
"conversiones": int(d["conversiones"]),
"cpa_medio": cpa,
"margen_pct": margen,
}
data = {
"fecha": now.strftime("%d/%m/%Y"),
"dia_del_mes": now.day,
"campañas_totales": len(collected),
"campañas_en_perdida": campañas_perdida,
"alertas_tracking": alertas_tracking,
"rendimiento_por_tipo": resumen_tipos,
"detalle_leadform": leadform_detail,
}
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
system=PORTFOLIO_SYSTEM,
messages=[{
"role": "user",
"content": (
"Analiza estos datos del portfolio y proporciona:\n"
"1. Diagnóstico en 2 frases\n"
"2. Problemas principales (máx 3, con guión)\n"
"3. Acciones prioritarias (máx 3, muy concretas, con guión)\n"
"Si hay campañas leadform, evalúa específicamente su situación.\n\n"
f"{json.dumps(data, ensure_ascii=False, indent=2)}"
),
}],
)
return response.content[0].text.strip()
except Exception as e:
return f"Error generando análisis: {e}"
DECIDE_SYSTEM = """
Eres un experto en optimización de campañas de Meta Ads para cursos de formación.
Modelo de negocio: Ingreso = leads_entregados × PPL. Margen = (Ingreso - Gasto) / Ingreso.

View File

@ -8,10 +8,16 @@ creates/updates "Meta Ads Campaigns" and "MetaCampaignMes", the Meta-specific
tables that sit alongside "Google Ads Campaigns" / "GACampaignMes".
"""
import re
from datetime import datetime
from datetime import datetime, timedelta
from pyairtable import Api
import config
# Los leads de Meta llegan a Leads Lake por dos vías, ambas confirmadas 100%
# atribuibles a Meta (fbclid presente, 0% gclid) aunque la web las etiqueta
# distinto: 'Lead ads' = leadform nativo de Meta (attr_referer = nombre de
# campaña); 'landingpage' = clic a landing (attr_referer = URL con fbclid).
META_UTM_SOURCES = ("Lead ads", "landingpage")
MESES_ES = {
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
@ -108,13 +114,10 @@ class AirtableClient:
def get_leads_this_month_meta(self, campaign_name: str, as_of_date: str = None) -> tuple[int, list[str]]:
"""
Leads acumulados en el mes atribuidos a un curso vía Meta Lead Ads,
hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se indica
(as_of_date lo usa backfill.py para reconstruir el estado histórico
del mes en una fecha pasada).
Los leads de Meta ya llegan a Leads Lake con attr_utm_source='Lead ads'
y attr_cursoid resuelto (confirmado con datos reales) a diferencia de
Google, aquí solo hay una vía de atribución, no cinco.
Leads acumulados en el mes atribuidos a un curso vía Meta (leadform +
landing), hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se
indica (as_of_date lo usa backfill.py para reconstruir el estado
histórico del mes en una fecha pasada).
"""
course_num = extract_cursoid(campaign_name)
if not course_num:
@ -122,8 +125,9 @@ class AirtableClient:
ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now()
mes_inicio = f"{ref.year}-{ref.month:02d}-01"
fin_clause = f",{{creado}}<'{(ref).strftime('%Y-%m-%d')}T23:59:59.999Z'" if as_of_date else ""
utm_clause = "OR(" + ",".join(f"{{attr_utm_source}}='{s}'" for s in META_UTM_SOURCES) + ")"
formula = (
f"AND({{attr_utm_source}}='Lead ads',"
f"AND({utm_clause},"
f"{{attr_cursoid}}='{course_num}',"
f"{{creado}}>='{mes_inicio}'{fin_clause})"
)
@ -131,6 +135,35 @@ class AirtableClient:
ids = [r["id"] for r in records]
return len(ids), ids
def get_meta_leads_bulk(self, date_from: str, date_to: str) -> list[dict]:
"""
Todos los leads de Meta (leadform + landing) creados en [date_from, date_to]
(inclusive), sin restringir a un curso concreto una sola llamada bulk
para poder agregar por día y/o por curso en el informe (igual patrón que
get_leads_by_campaign_on_date en leads-optimizer).
Devuelve [{"cursoid": str, "date": "YYYY-MM-DD", "utm_source": str}].
"""
next_day = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
utm_clause = "OR(" + ",".join(f"{{attr_utm_source}}='{s}'" for s in META_UTM_SOURCES) + ")"
formula = f"AND({utm_clause},{{creado}}>='{date_from}',{{creado}}<'{next_day}')"
records = self.leads.all(formula=formula, fields=["attr_cursoid", "attr_utm_source", "creado"])
result = []
for r in records:
f = r["fields"]
cursoid = f.get("attr_cursoid")
if cursoid is None:
continue
cursoid_text = str(int(cursoid)) if isinstance(cursoid, (int, float)) else str(cursoid).strip()
creado = f.get("creado", "")
if not cursoid_text or not creado:
continue
result.append({
"cursoid": cursoid_text,
"date": creado[:10],
"utm_source": f.get("attr_utm_source", ""),
})
return result
# ------------------------------------------------------------------ #
# Meta Ads Campaigns (catálogo) #
# ------------------------------------------------------------------ #
@ -187,7 +220,11 @@ class AirtableClient:
updated.append({"name": mc["name"], "id": cid, "changes": changes})
for i in range(0, len(to_create), 10):
self.campaigns.batch_create(to_create[i:i + 10])
new_records = self.campaigns.batch_create(to_create[i:i + 10])
for r in new_records:
cid = str(r["fields"].get("CampaignID", "")).strip()
if cid:
at_by_cid[cid] = r
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]]
self.campaigns.batch_update(batch)

View File

@ -202,6 +202,39 @@ class BaserowClient:
rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS)
return sorted({r["run_date"] for r in rows if r.get("run_date")}, reverse=True)
# ── daily_metrics (totales agregados del bloque Formación, uno por día) ────
# Se persisten para no depender de poder re-pedirle a Meta el histórico
# diario más adelante, y para que el dashboard los lea sin llamar a la API.
def save_daily_metrics(self, row: dict) -> dict:
existing = self._get_rows(
config.BASEROW_TABLE_DAILY_METRICS,
{"filter__date__equal": row["date"]},
)
for r in existing:
try:
self._delete_row(config.BASEROW_TABLE_DAILY_METRICS, r["id"])
except Exception:
pass
return self._create_row(config.BASEROW_TABLE_DAILY_METRICS, {
"date": row["date"],
"spend": float(row.get("spend", 0)),
"leads_meta": int(row.get("leads_meta", 0)),
"leads_at": int(row.get("leads_at", 0)),
"ing_meta": float(row.get("ing_meta", 0)),
"ing_at": float(row.get("ing_at", 0)),
"margin": float(row.get("margin", 0)),
"margin_pct": float(row.get("margin_pct", 0)),
})
def get_daily_metrics(self, date_from: str = None, date_to: str = None) -> list:
rows = self._get_rows(config.BASEROW_TABLE_DAILY_METRICS)
if date_from:
rows = [r for r in rows if r.get("date", "") >= date_from]
if date_to:
rows = [r for r in rows if r.get("date", "") <= date_to]
return sorted(rows, key=lambda r: r.get("date", ""))
# ── execution_logs ────────────────────────────────────────────────────────
def save_execution_log(self, log: dict) -> dict:

View File

@ -16,10 +16,11 @@ ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
BASEROW_URL = os.environ["BASEROW_URL"]
BASEROW_TOKEN = os.environ["BASEROW_TOKEN"]
BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"])
BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"])
BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"])
BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"])
BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"])
BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"])
BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"])
BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"])
BASEROW_TABLE_DAILY_METRICS = int(os.environ["BASEROW_TABLE_DAILY_METRICS"])
# Airtable (misma base que leads-optimizer) — negocio: PPL, capping, Cursos, Familias
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]

View File

@ -96,6 +96,26 @@ def _load_data(date_from: str, date_to: str):
return daily_rows, campaign_metrics
@st.cache_data(ttl=300, show_spinner="Cargando métricas diarias (Baserow)...")
def _load_daily_metrics(date_from: str, date_to: str):
"""Totales diarios persistidos por run.py (Leads Meta vs Leads Airtable) —
no depende de volver a pedirle el histórico a Meta."""
rows = BaserowClient().get_daily_metrics(date_from, date_to)
return [
{
"date": r["date"],
"spend": float(r.get("spend") or 0),
"leads_meta": int(r.get("leads_meta") or 0),
"leads_at": int(r.get("leads_at") or 0),
"ing_meta": float(r.get("ing_meta") or 0),
"ing_at": float(r.get("ing_at") or 0),
"margin": float(r.get("margin") or 0),
"margin_pct": float(r.get("margin_pct") or 0),
}
for r in rows
]
@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()
@ -182,59 +202,52 @@ with tab1:
if d_from_1 > d_to_1:
st.error("La fecha inicio debe ser anterior a la fecha fin.")
else:
try:
daily_totals = _load_daily_metrics(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando daily_metrics de Baserow: {e}")
daily_totals = []
try:
daily_rows, _cm1 = _load_data(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando datos de Meta API: {e}")
daily_rows = []
_daily: dict = {}
for row in daily_rows:
ppl = ppl_lookup.get(extract_cursoid(row["campaign_name"]) or "", 0)
margin = round(row["leads"] * ppl - 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
total_spend = sum(d["spend"] for d in daily_totals)
total_leads_m = sum(d["leads_meta"] for d in daily_totals)
total_leads_at = sum(d["leads_at"] for d in daily_totals)
total_ing_m = sum(d["ing_meta"] for d in daily_totals)
total_margin = total_ing_m - total_spend
total_pct = round(total_margin / total_ing_m * 100, 1) if total_ing_m > 0 else 0.0
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())
]
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))
k1, k2, k3, k4, k5 = st.columns(5)
k1.metric("Gasto total", _eur(total_spend))
k2.metric("Leads Meta", f"{total_leads_m:,}")
k3.metric("Leads Airtable", f"{total_leads_at:,}")
k4.metric("Margen (Meta)", _margin(total_margin))
k5.metric("% Margen", f"{total_pct:+.1f}%")
st.divider()
if not daily_totals:
st.info("Sin datos para el período seleccionado.")
st.info("Sin datos persistidos para el período seleccionado — ejecuta run.py o amplía el rango.")
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"]),
"Día": d["date"][8:10] + "/" + d["date"][5:7],
"L. AT": d["leads_at"],
"L. Meta": d["leads_meta"],
"Gasto": _eur(d["spend"]),
"€ AT": _eur(d["ing_at"]),
"€ Meta": _eur(d["ing_meta"]),
"Margen": _margin(d["margin"]),
"% Margen": f"{d['margin_pct']:+.1f}%",
"Est": _status(d["leads_meta"], d["spend"]),
}
for d in daily_totals
])
st.dataframe(df_daily, use_container_width=True, hide_index=True)
st.caption("L. AT = leads Airtable (leadform + landing) · L. Meta = conversión propia de Meta · "
"€ AT / € Meta = leads × PPL de cada fuente · el margen oficial usa el tracking de Meta.")
st.subheader("Desglose por campaña")
day_opts = [d["date"] for d in reversed(daily_totals)]

127
run.py
View File

@ -9,7 +9,7 @@ from datetime import datetime, timedelta
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from agent import decide, analyze_unit
from agent import decide, analyze_unit, portfolio_daily_analysis
from baserow_client import BaserowClient
import analyzer
import slack_notifier
@ -139,38 +139,59 @@ def run():
print(f"→ MetaCampaignMes: {mcm_sync['created']} creadas, {mcm_sync['updated']} actualizadas.\n")
mcm_by_meta_cid = {r["meta_campaign_id"]: r for r in airtable.get_active_metacampaignmes()}
# ── Monthly daily totals (per-campaign rows → agregado por familia) ────────
# ── Monthly daily totals: Leads Meta (tracking propio) vs Leads Airtable ───
# (leadform + landing, ambos confirmados 100% atribuibles a Meta) ──────────
print(f"→ Fetching monthly daily totals for {config.META_CAMPAIGN_PREFIX}...")
daily_rows = meta.get_daily_campaign_rows(month_start, yesterday)
daily_at_leads = airtable.get_meta_leads_bulk(month_start, yesterday)
print(f"{len(daily_rows)} filas Meta, {len(daily_at_leads)} leads Airtable este mes.\n")
_daily: dict = {}
monthly_familias: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
mf["spend"] += row["spend"]
mf["leads"] += row["leads"]
mf["margin"] += margin
d = _daily.setdefault(row["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["spend"] += row["spend"]
d["leads_meta"] += row["leads"]
d["ing_meta"] += row["leads"] * ppl
for lead in daily_at_leads:
ppl = ppl_lookup.get(lead["cursoid"], 0)
d = _daily.setdefault(lead["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["leads_at"] += 1
d["ing_at"] += ppl
daily_totals = [
{
"date": date,
"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),
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
"date": date,
"spend": round(d["spend"], 2),
"leads_meta": int(d["leads_meta"]),
"leads_at": int(d["leads_at"]),
"ing_meta": round(d["ing_meta"], 2),
"ing_at": round(d["ing_at"], 2),
"margin": round(d["ing_meta"] - d["spend"], 2),
"margin_pct": round((d["ing_meta"] - d["spend"]) / d["ing_meta"] * 100, 1) if d["ing_meta"] > 0 else 0.0,
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} days with data.\n")
# ── Persistir daily_totals en Baserow ───────────────────────────────────────
# No solo para el dashboard: si Meta llegase a limitar el acceso al
# histórico diario más adelante, este es el único registro que sobreviviría.
errors: list = []
print("→ Guardando daily_metrics en Baserow...")
daily_metrics_saved = 0
for d in daily_totals:
try:
baserow.save_daily_metrics(d)
daily_metrics_saved += 1
except Exception as e:
errors.append(f"daily_metrics {d['date']}: {e}")
print(f"{daily_metrics_saved}/{len(daily_totals)} días guardados.\n")
# ── Yesterday metrics (contexto 1d para el informe) ────────────────────────
print(f"→ Fetching yesterday metrics ({config.META_CAMPAIGN_PREFIX} only, spend > 0)...")
metrics_yesterday = meta.get_yesterday_metrics()
@ -187,10 +208,9 @@ def run():
actions_proposed_list = []
campaign_details = {} # {cid: {familia, margin, adsets, ads, ...}}
familias = {} # {familia: {spend, leads, margin}}
collected = [] # para el diagnóstico estratégico (agent.portfolio_daily_analysis)
advice_updates = [] # [(mcm_id, consejo, criticidad, log)]
final_leads_updates = [] # [(mcm_id, leads_entregados)]
errors = []
for mc in active_campaigns:
cid, name = mc["id"], mc["name"]
@ -377,11 +397,13 @@ def run():
except Exception as e:
errors.append(f"Snapshot {name}: {e}")
# ── Familia aggregation ──────────────────────────────────────────────
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
f["spend"] += m1.get("spend", 0.0)
f["leads"] += m1.get("leads", 0)
f["margin"] += margin_eur
# ── Para el diagnóstico estratégico global (agent.portfolio_daily_analysis) ─
collected.append({
"campaign": {"curso": name, "ppl": ppl},
"metrics": {"cost": mmes.get("spend", 0.0)},
"analysis": analysis,
"leads": leads_entregados,
})
# ── MetaCampaignMes: consejo/criticidad/log + leads confirmados ───────
mcm = mcm_by_meta_cid.get(cid)
@ -396,6 +418,53 @@ def run():
if final_leads_updates:
airtable.batch_update_metacampaignmes_final_leads(final_leads_updates)
# ── Resumen y contraste por curso: Meta vs Airtable, leadform vs landing ───
# (agregado por CursoID, no por campaña literal — un curso puede tener a la
# vez una campaña _leadads y otra _web, y Airtable no distingue con certeza
# a cuál de las dos pertenece un lead 'landingpage').
print("→ Calculando resumen y contraste por curso...")
def _new_curso_entry(cid_: str) -> dict:
return {
"campaigns": [], "familia": familia_lookup.get(cid_, "Sin familia"),
"ppl": ppl_lookup.get(cid_, 0), "spend": 0.0, "leads_meta": 0,
"leads_at_leadform": 0, "leads_at_landing": 0,
}
name_by_cid = {mc["id"]: mc["name"] for mc in meta_campaigns}
curso_summary: dict = {}
for mcid, m in monthly_metrics_meta.items():
name = name_by_cid.get(mcid, mcid)
cursoid = extract_cursoid(name) or ""
if not cursoid:
continue
cs = curso_summary.setdefault(cursoid, _new_curso_entry(cursoid))
cs["campaigns"].append(name)
cs["spend"] += m.get("spend", 0.0)
cs["leads_meta"] += m.get("leads", 0)
for lead in daily_at_leads:
cs = curso_summary.setdefault(lead["cursoid"], _new_curso_entry(lead["cursoid"]))
if lead["utm_source"] == "Lead ads":
cs["leads_at_leadform"] += 1
else:
cs["leads_at_landing"] += 1
for cursoid, cs in curso_summary.items():
leads_at_total = cs["leads_at_leadform"] + cs["leads_at_landing"]
cs["leads_at_total"] = leads_at_total
cs["cpl_meta"] = round(cs["spend"] / cs["leads_meta"], 2) if cs["leads_meta"] > 0 else 0.0
cs["cpl_at"] = round(cs["spend"] / leads_at_total, 2) if leads_at_total > 0 else 0.0
cs["discrepancia"] = cs["leads_meta"] - leads_at_total
print(f"{len(curso_summary)} cursos con actividad este mes.\n")
# ── Diagnóstico estratégico global (Claude) ─────────────────────────────────
print("→ Generando diagnóstico estratégico...")
try:
portfolio_text = portfolio_daily_analysis(collected)
except Exception as e:
portfolio_text = None
errors.append(f"Portfolio analysis: {e}")
print(" ✓ Diagnóstico listo.\n")
# ── Top 10 best and worst (por CPL de ayer) ─────────────────────────────────
with_leads = [m for m in metrics_yesterday.values() if m["leads"] > 0]
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
@ -417,9 +486,9 @@ def run():
actions=actions_proposed_list,
campaigns_analyzed=len(active_campaigns),
mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION",
familias=familias,
campaign_details=campaign_details,
monthly_familias=monthly_familias,
curso_summary=curso_summary,
portfolio_analysis_text=portfolio_text,
)
except Exception as e:
print(f" Warning: Slack notification failed: {e}")

View File

@ -1,4 +1,5 @@
"""Re-send a day's Slack report from Baserow snapshots (sin llamar a Meta por campaña)."""
"""Re-send a day's Slack report (tabla diaria/resumen por curso frescos de
Meta+Airtable; tarjetas por campaña reconstruidas desde snapshots de Baserow)."""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
@ -21,41 +22,82 @@ def main():
baserow = BaserowClient()
airtable = AirtableClient()
ppl_lookup, _, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
ppl_lookup, cap_lookup, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
# ── Monthly daily totals (fresh de Meta, no se persisten por campaña) ─────
print("Obteniendo datos mensuales de Meta...")
# ── Monthly daily totals: Leads Meta vs Leads Airtable (fresco, no se persiste)
print("Obteniendo datos mensuales de Meta y Airtable...")
month_start = f"{run_date[:7]}-01"
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
daily_at_leads = airtable.get_meta_leads_bulk(month_start, run_date)
_daily: dict = {}
monthly_familias: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
mf["spend"] += row["spend"]
mf["leads"] += row["leads"]
mf["margin"] += margin
d = _daily.setdefault(row["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["spend"] += row["spend"]
d["leads_meta"] += row["leads"]
d["ing_meta"] += row["leads"] * ppl
for lead in daily_at_leads:
ppl = ppl_lookup.get(lead["cursoid"], 0)
d = _daily.setdefault(lead["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["leads_at"] += 1
d["ing_at"] += ppl
daily_totals = [
{
"date": date,
"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),
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
"date": date,
"spend": round(d["spend"], 2),
"leads_meta": int(d["leads_meta"]),
"leads_at": int(d["leads_at"]),
"ing_meta": round(d["ing_meta"], 2),
"ing_at": round(d["ing_at"], 2),
"margin": round(d["ing_meta"] - d["spend"], 2),
"margin_pct": round((d["ing_meta"] - d["spend"]) / d["ing_meta"] * 100, 1) if d["ing_meta"] > 0 else 0.0,
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} días con datos")
# ── Resumen y contraste por curso (mismo cálculo que run.py) ────────────────
monthly_metrics_meta = meta.get_campaign_metrics(month_start, run_date)
name_by_cid = {}
for row in meta.get_all_campaigns():
name_by_cid[row["id"]] = row["name"]
def _new_curso_entry(cid_: str) -> dict:
return {
"campaigns": [], "familia": familia_lookup.get(cid_, "Sin familia"),
"ppl": ppl_lookup.get(cid_, 0), "spend": 0.0, "leads_meta": 0,
"leads_at_leadform": 0, "leads_at_landing": 0,
}
curso_summary: dict = {}
for mcid, m in monthly_metrics_meta.items():
name = name_by_cid.get(mcid, mcid)
cursoid = extract_cursoid(name) or ""
if not cursoid:
continue
cs = curso_summary.setdefault(cursoid, _new_curso_entry(cursoid))
cs["campaigns"].append(name)
cs["spend"] += m.get("spend", 0.0)
cs["leads_meta"] += m.get("leads", 0)
for lead in daily_at_leads:
cs = curso_summary.setdefault(lead["cursoid"], _new_curso_entry(lead["cursoid"]))
if lead["utm_source"] == "Lead ads":
cs["leads_at_leadform"] += 1
else:
cs["leads_at_landing"] += 1
for cursoid, cs in curso_summary.items():
leads_at_total = cs["leads_at_leadform"] + cs["leads_at_landing"]
cs["leads_at_total"] = leads_at_total
cs["cpl_meta"] = round(cs["spend"] / cs["leads_meta"], 2) if cs["leads_meta"] > 0 else 0.0
cs["cpl_at"] = round(cs["spend"] / leads_at_total, 2) if leads_at_total > 0 else 0.0
cs["discrepancia"] = cs["leads_meta"] - leads_at_total
# ── Load proposed actions (to get parameter values) ──────────────────────
action_params: dict = {} # campaign_name → parameter
try:
@ -86,8 +128,6 @@ def main():
# defecto (slack_notifier ya los trata con .get(...)).
campaign_details: dict = {}
actions: list = []
familias: dict = {}
metrics_all: dict = {}
for snap in snapshots:
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
@ -96,7 +136,6 @@ def main():
margin = float(snap.get("margin") or 0)
spend = float(snap.get("spend") or 0)
leads = int(snap.get("leads") or 0)
cpl = float(snap.get("cpl") or 0)
action_type = snap.get("action_type") or "MAINTAIN"
try:
@ -118,7 +157,6 @@ def main():
"ads": ads,
"bid_config": {},
}
metrics_all[cid] = {"name": name, "spend": spend, "leads": leads, "cpl": cpl}
if action_type != "MAINTAIN":
actions.append({
@ -132,31 +170,18 @@ def main():
"row_id": snap["id"],
})
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
f["spend"] += spend
f["leads"] += leads
f["margin"] += margin
# ── Best / worst ──────────────────────────────────────────────────────────
with_leads = [m for m in metrics_all.values() if m["leads"] > 0]
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
worst_10 = sorted(
list(metrics_all.values()),
key=lambda x: (x["leads"] > 0, -x["cpl"] if x["leads"] > 0 else 0),
)[:10]
# ── Send ──────────────────────────────────────────────────────────────────
# ── Send (sin diagnóstico estratégico: reenviar no vuelve a llamar a Claude) ─
print("Enviando a Slack...")
ts = slack_notifier.send_daily_report(
daily_totals=daily_totals,
best_campaigns=best_10,
worst_campaigns=worst_10,
best_campaigns=[],
worst_campaigns=[],
actions=actions,
campaigns_analyzed=len(snapshots),
mode="DRY_RUN",
familias=familias,
campaign_details=campaign_details,
monthly_familias=monthly_familias,
curso_summary=curso_summary,
portfolio_analysis_text=None,
)
if ts:
print(f" ✓ Mensaje enviado (ts={ts})")

View File

@ -81,36 +81,6 @@ def update_message(channel: str, ts: str, text: str):
_post("chat.update", channel=channel, ts=ts, text=text, blocks=[])
def _ad_action_blocks(ads: list) -> list:
"""Genera bloques Slack con botón de pausa para anuncios que Claude recomienda pausar."""
blocks = []
for ad in ads:
if not ad.get("row_id"):
continue
name = ad["name"]
text = f"⛔ *{name[:80]}* _(0 leads · 7d)_"
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
"style": "danger",
"value": f"approve:{ad['row_id']}",
"action_id": f"approve_{ad['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"value": f"reject:{ad['row_id']}",
"action_id": f"reject_{ad['row_id']}",
},
],
})
return blocks
def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bool = False) -> str:
"""Genera tabla monoespaciada de adsets o anuncios para Slack."""
if not items:
@ -153,12 +123,98 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo
return "\n".join(lines)
def _familia_status(margin: float, has_issues: bool, no_data: bool) -> str:
if no_data:
return ""
if has_issues:
return "🚨" if margin < 0 else "⚠️"
return "" if margin >= 0 else "⚠️"
def _marg(v: float) -> str:
v_int = round(v)
return ("+" if v_int >= 0 else "") + f"{v_int:,.0f}".replace(",", ".")
def _pct(v: float) -> str:
return ("+" if v >= 0 else "") + f"{v:.1f}%"
def _curso_label(cs: dict, width: int = 26) -> str:
names = cs.get("campaigns", [])
if not names:
return "?"
label = names[0]
if len(names) > 1:
label += f" (+{len(names) - 1})"
return _table_name(label, width)
CURSO_TABLE_TOP_N = 25
def _curso_summary_blocks(curso_summary: dict) -> list[dict]:
"""
Resumen y contraste por curso: PPL, CPL según Meta vs según Airtable, y el
desglose de leads de Airtable por vía (LF=leadform nativo, Land=landing page)
ambas 100% atribuibles a Meta, confirmado con datos reales (fbclid en todos
los 'landingpage', cero 'gclid'). Δ = leads_meta - leads_airtable_total
(discrepancia de tracking, +N = Meta ve más que Airtable).
"""
if not curso_summary:
return []
rows = sorted(curso_summary.items(), key=lambda kv: -kv[1]["spend"])
overflow = max(0, len(rows) - CURSO_TABLE_TOP_N)
rows = rows[:CURSO_TABLE_TOP_N]
hdr = f"{'Cod':>5} {'Curso':<26} {'PPL':>6} {'L.Meta':>6} {'L.LF':>5} {'L.Land':>6} {'L.AT':>5} {'CPL.Meta':>8} {'CPL.AT':>7} {'Δ':>4}"
sep = "" * len(hdr)
lines = [hdr, sep]
for cursoid, cs in rows:
label = _curso_label(cs)
cpl_meta_s = f"{cs['cpl_meta']:.2f}" if cs["cpl_meta"] else ""
cpl_at_s = f"{cs['cpl_at']:.2f}" if cs["cpl_at"] else ""
lines.append(
f"{cursoid:>5} {label:<26} {cs['ppl']:>5.2f}{cs['leads_meta']:>6} "
f"{cs['leads_at_leadform']:>5} {cs['leads_at_landing']:>6} {cs['leads_at_total']:>5} "
f"{cpl_meta_s:>8} {cpl_at_s:>7} {cs['discrepancia']:>+4}"
)
footer = f"\n_+{overflow} cursos más con menos gasto, no mostrados_" if overflow else ""
text = (
"*Resumen y contraste por curso — Meta vs Airtable* "
"_(LF=leadform nativo · Land=landing page, ambas atribuibles a Meta · "
"Δ=leads_metaleads_airtable)_\n```\n" + "\n".join(lines) + "\n```" + footer
)
return [{"type": "section", "text": {"type": "mrkdwn", "text": chunk}}
for chunk in [text[j:j + 2900] for j in range(0, len(text), 2900)]]
def _daily_table_block(daily_totals: list, month_name: str) -> dict | None:
"""Tabla diaria Leads Airtable vs Leads Meta, coste, ingreso×PPL de cada
fuente, y margen (calculado sobre el tracking propio de Meta, igual
convención que leads-optimizer usa con Google Ads; Airtable se muestra en
paralelo para contrastar discrepancias de tracking, no sustituye el margen oficial)."""
if not daily_totals:
return None
hdr = f"{'Día':<5} {'L.AT':>5} {'L.Meta':>6} {'Coste':>7} {'€.AT':>7} {'€.Meta':>7} {'Margen':>9} {'%':>7}"
sep = "" * len(hdr)
lines = [hdr, sep]
tot = {"spend": 0.0, "leads_at": 0, "leads_meta": 0, "ing_at": 0.0, "ing_meta": 0.0}
for d in daily_totals:
day = d["date"][8:10] + "/" + d["date"][5:7]
for k in tot:
tot[k] += d.get(k, 0)
lines.append(
f"{day:<5} {d['leads_at']:>5} {d['leads_meta']:>6} "
f"{d['spend']:>6.0f}{d['ing_at']:>6.0f}{d['ing_meta']:>6.0f}"
f"{_marg(d['margin']):>9} {_pct(d['margin_pct']):>7}"
)
lines.append(sep)
tot_margin = tot["ing_meta"] - tot["spend"]
tot_pct = round(tot_margin / tot["ing_meta"] * 100, 1) if tot["ing_meta"] > 0 else 0.0
lines.append(
f"{'TOT':<5} {tot['leads_at']:>5} {tot['leads_meta']:>6} "
f"{tot['spend']:>6.0f}{tot['ing_at']:>6.0f}{tot['ing_meta']:>6.0f}"
f"{_marg(tot_margin):>9} {_pct(tot_pct):>7}"
)
text = (
f"*Métricas por día — {month_name}* "
"_(L.AT=leads Airtable · L.Meta=conversión propia Meta · €.AT/€.Meta=leads×PPL de cada fuente)_\n"
"```\n" + "\n".join(lines) + "\n```"
)
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
def send_daily_report(
@ -168,131 +224,67 @@ def send_daily_report(
actions: list,
campaigns_analyzed: int,
mode: str = "DRY_RUN",
familias: dict = None,
campaign_details: dict = None,
monthly_familias: dict = None,
curso_summary: dict = None,
portfolio_analysis_text: str = None,
) -> str | None:
"""Envía el informe diario consolidado. Devuelve el ts del mensaje."""
"""Envía el informe diario consolidado (un único bloque de Formación,
sin agrupar por familia). Devuelve el ts del primer mensaje."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y")
month_name = now.strftime("%B %Y").capitalize()
prefix = config.META_CAMPAIGN_PREFIX
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
action_map = {a["campaign_name"]: a for a in actions}
details_map = campaign_details or {}
# Group ALL campaigns by familia
by_familia: dict = {}
for cid, detail in details_map.items():
act = action_map.get(detail["name"])
by_familia.setdefault(detail["familia"], []).append((cid, detail, act))
def _has_issues(camp_list):
return any(
(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
)
# Sort familias: issues first (by margin asc), then OK (by margin desc)
def _familia_sort_key(item):
f, cl = item
f_data = (familias or {}).get(f, {})
margin = f_data.get("margin", 0)
has_iss = _has_issues(cl)
return (0 if has_iss else 1, margin if has_iss else -margin)
sorted_familias = sorted(by_familia.items(), key=_familia_sort_key)
campaigns = [(cid, detail, action_map.get(detail["name"])) for cid, detail in details_map.items()]
# ── Message 1: Dashboard ─────────────────────────────────────────────────
blocks: list = [
{
"type": "header",
"text": {"type": "plain_text",
"text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
"text": {"type": "plain_text", "text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
},
]
# Monthly profitability table
if daily_totals:
f_order = (
sorted(monthly_familias.keys(), key=lambda f: -monthly_familias[f]["margin"])
if monthly_familias else []
)
cw = 7
hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}"
for f in f_order:
hdr += f" {f[:6]:>{cw}}"
hdr += " Est"
sep = "" * len(hdr)
lines = [hdr, sep]
total_spend = total_leads = total_margin = 0.0
total_f = {f: 0.0 for f in f_order}
for d in daily_totals:
day = d["date"][8:10] + "/" + d["date"][5:7]
margin = d.get("margin", 0.0)
total_spend += d["spend"]
total_leads += d["leads"]
total_margin += margin
f_day = d.get("f_margins", {})
icon = "" if d["leads"] > 0 else ("" if d["spend"] > 0 else "")
row = f"{day:<5} {d['spend']:>5.0f}{d['leads']:>5} {d['cpl']:>6.2f}"
for f in f_order:
fm = f_day.get(f, 0.0)
total_f[f] += fm
fm_s = (f"+{fm:.0f}" if fm >= 0 else f"{fm:.0f}") if round(fm) != 0 else ""
row += f" {fm_s:>{cw}}"
row += f" {icon}"
lines.append(row)
lines.append(sep)
total_row = f"{'TOTAL':<5} {total_spend:>5.0f}{int(total_leads):>5} {'':>7}"
for f in f_order:
tf = total_f[f]
tf_s = f"+{tf:.0f}" if tf >= 0 else f"{tf:.0f}"
total_row += f" {tf_s:>{cw}}"
lines.append(total_row)
tot_spend = sum(d["spend"] for d in daily_totals)
tot_leads_m = sum(d["leads_meta"] for d in daily_totals)
tot_leads_at = sum(d["leads_at"] for d in daily_totals)
tot_ing_m = sum(d["ing_meta"] for d in daily_totals)
tot_ing_at = sum(d["ing_at"] for d in daily_totals)
margen_m = tot_ing_m - tot_spend
margen_at = tot_ing_at - tot_spend
pct_m = round(margen_m / tot_ing_m * 100, 1) if tot_ing_m > 0 else 0.0
pct_at = round(margen_at / tot_ing_at * 100, 1) if tot_ing_at > 0 else 0.0
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Rentabilidad {month_name}*\n```" + "\n".join(lines) + "```",
"text": (
f"📊 *RESUMEN {month_name.upper()}*\n"
f"Inversión: *{tot_spend:,.0f}€* | Leads Meta: *{int(tot_leads_m)}* | "
f"Leads Airtable: *{int(tot_leads_at)}*\n"
f"Ingreso según Meta: *{tot_ing_m:,.0f}€* | Margen: *{_marg(margen_m)}* ({_pct(pct_m)})\n"
f"Ingreso según Airtable: *{tot_ing_at:,.0f}€* | Margen: *{_marg(margen_at)}* ({_pct(pct_at)})\n"
f"_El margen oficial usa el tracking propio de Meta; Airtable se muestra en paralelo "
f"para contrastar discrepancias de tracking._"
).replace(",", "."),
},
})
blocks.append({"type": "divider"})
daily_block = _daily_table_block(daily_totals, month_name)
if daily_block:
blocks.append(daily_block)
else:
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"},
})
blocks.append({"type": "divider"})
# Familia scorecard
if familias:
lines = [f"{'':>2} {'Familia':<20} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Margen':>9}"]
lines.append("" * 56)
for f, cl in sorted_familias:
data = (familias or {}).get(f, {})
f_leads = data.get("leads", 0)
f_spend = data.get("spend", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_m = data.get("margin", 0)
m_sign = f"+{f_m:.0f}" if f_m >= 0 else f"{f_m:.0f}"
st = _familia_status(f_m, _has_issues(cl), f_leads == 0 and f_spend == 0)
lines.append(
f"{st} {f[:20]:<20} {f_spend:>5.0f}{f_leads:>5} {f_cpl:>6.2f}{m_sign:>9}"
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn",
"text": "*Resumen · ayer*\n```" + "\n".join(lines) + "```"},
})
blocks.append({
"type": "context",
"elements": [{"type": "mrkdwn",
"text": f"{campaigns_analyzed} campañas analizadas — detalle por familia a continuación"}],
"elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}],
})
result = _post(
@ -303,39 +295,53 @@ def send_daily_report(
)
ts = result.get("ts")
# ── One message per familia ──────────────────────────────────────────────
for f, camp_list in sorted_familias:
f_data = (familias or {}).get(f, {})
f_spend = f_data.get("spend", 0)
f_leads = f_data.get("leads", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_margin = f_data.get("margin", 0)
m_str = f"+{f_margin:.0f}" if f_margin >= 0 else f"{f_margin:.0f}"
has_iss = _has_issues(camp_list)
st = _familia_status(f_margin, has_iss, f_leads == 0 and f_spend == 0)
# ── Message 2: resumen y contraste por curso ───────────────────────────────
curso_blocks = _curso_summary_blocks(curso_summary or {})
if curso_blocks:
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=curso_blocks,
text="Resumen y contraste por curso")
f_blocks: list = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"{st} {f.upper()}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"{f_spend:.0f}€ · {f_leads} leads · CPL {f_cpl:.2f}€ · Margen {m_str}",
},
},
{"type": "divider"},
]
# ── Message 3: diagnóstico estratégico ──────────────────────────────────────
if portfolio_analysis_text:
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": "🤖 Diagnóstico estratégico"}},
{"type": "section", "text": {"type": "mrkdwn", "text": portfolio_analysis_text[:2950]}},
],
text="Diagnóstico estratégico",
)
for i, (cid, detail, act) in enumerate(
sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0))
):
# ── Mensajes 4..N: tarjetas por campaña, en lotes (sin agrupar por familia) ─
def _has_pause_ads(detail):
return any(ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in detail.get("ads", []))
def _priority_key(item):
_, detail, act = item
urgencia = detail.get("urgencia", "EN_RITMO")
atype = act["action_type"] if act else "MAINTAIN"
if urgencia in ("PAUSAR", "SPRINT"):
p = 0
elif atype != "MAINTAIN" or _has_pause_ads(detail):
p = 1
else:
p = 2
return (p, -detail.get("spend_1d", 0))
sorted_campaigns = sorted(campaigns, key=_priority_key)
BATCH_SIZE = 6
for batch_start in range(0, len(sorted_campaigns), BATCH_SIZE):
batch = sorted_campaigns[batch_start:batch_start + BATCH_SIZE]
c_blocks: list = []
for i, (cid, detail, act) in enumerate(batch):
if i > 0:
f_blocks.append({"type": "divider"})
c_blocks.append({"type": "divider"})
name = detail["name"]
familia = detail.get("familia", "")
spend_1d = detail.get("spend_1d", 0.0)
leads_1d = detail.get("leads_1d", 0)
margin = detail["margin"]
@ -354,85 +360,94 @@ def send_daily_report(
atype = act["action_type"] if act else "MAINTAIN"
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
f_blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
f"Margen: {m_str2} · {u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "")
),
},
camp_text = (
f"{cemoji} *{name}*" + (f" _{familia}_" if familia and familia != "Sin familia" else "") + "\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
f"{u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "")
)
if atype != "MAINTAIN":
camp_text += f"\n*{alabel}*"
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("advice"):
camp_text += f"\n💡 {act['advice'][:160]}"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Approve/Reject buttons
if act and atype in _ACTIONABLE:
effect = _effect_text(act, budget)
if effect:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
c_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']}",
},
],
})
# 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)]:
f_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"{u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (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]}"
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Approve/Reject buttons
if act and atype in _ACTIONABLE:
effect = _effect_text(act, budget)
if effect:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
f_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']}",
},
],
})
# Adsets: tabla + recomendación de cada uno
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)]:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
rec_lines = [
f"• _{_table_name(a['name'], 40)}_: {a['recomendacion'][:110]}"
for a in adsets[:3] if a.get("recomendacion")
]
if rec_lines:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn",
"text": "*Recomendaciones (conjuntos):*\n" + "\n".join(rec_lines)}})
# Adset table (top 3) — only for non-MAINTAIN
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)]:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
# Ad pause buttons
f_blocks.extend(_ad_action_blocks(ads))
# Anuncios: recomendaciones de los marcados para pausa + botón
pause_ads = [a for a in ads if a.get("accion") == "PAUSE" and a.get("row_id")]
for ad in pause_ads:
rec = ad.get("recomendacion") or "Sin leads en 7 días."
c_blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": f"⛔ *{ad['name'][:80]}*\n{rec[:150]}"},
})
c_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
"style": "danger",
"value": f"approve:{ad['row_id']}",
"action_id": f"approve_{ad['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"value": f"reject:{ad['row_id']}",
"action_id": f"reject_{ad['row_id']}",
},
],
})
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=f_blocks,
text=f"{f.upper()} · {f_spend:.0f}€ · {f_leads} leads",
blocks=c_blocks,
text=f"Campañas {batch_start + 1}{batch_start + len(batch)} de {len(sorted_campaigns)}",
)
return ts