- Rename table abbreviations that Slack misread as domains: 'CPL.AT' was auto-unfurled as a link to the .AT (Austria) TLD, posting an unrelated business's ad copy into the channel. Switch '.' to '·' in all L/€/CPL abbreviations, and set unfurl_links=False/unfurl_media=False on every postMessage as defense in depth. - Pass ppl/cpa_maximo into adset analysis too (ads already had it) so Claude's adset evaluation compares CPL against the course's PPL-derived rentability threshold, not just Meta's own bid cost cap. - Add PPL and Margen columns to the adset numeric table in Slack.
535 lines
23 KiB
Python
535 lines
23 KiB
Python
"""Meta Optimizer Formación — main entry point."""
|
|
import sys
|
|
import io
|
|
import os
|
|
import time
|
|
|
|
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, portfolio_daily_analysis
|
|
from baserow_client import BaserowClient
|
|
import analyzer
|
|
import slack_notifier
|
|
|
|
|
|
_ACTION_MAP = {
|
|
"PAUSE": "PAUSE",
|
|
"REDUCE_BUDGET": "REDUCE_BUDGET",
|
|
"INCREASE_BUDGET": "INCREASE_BUDGET",
|
|
"MAINTAIN": "MAINTAIN",
|
|
# legacy Spanish names, por si el modelo responde en español
|
|
"PAUSAR": "PAUSE",
|
|
"REDUCIR_PRESUPUESTO": "REDUCE_BUDGET",
|
|
"AUMENTAR_PRESUPUESTO": "INCREASE_BUDGET",
|
|
"MANTENER": "MAINTAIN",
|
|
}
|
|
|
|
|
|
def _criticidad(urgencia: str, action_type: str) -> str:
|
|
if urgencia in ("PAUSAR", "SPRINT"):
|
|
return "Crítico"
|
|
if action_type != "MAINTAIN":
|
|
return "Peligro"
|
|
return "Mantener"
|
|
|
|
|
|
def _priority(urgencia: str, action_type: str) -> int:
|
|
if urgencia in ("PAUSAR", "SPRINT"):
|
|
return 0
|
|
if action_type != "MAINTAIN":
|
|
return 1
|
|
return 2
|
|
|
|
|
|
class Tee:
|
|
def __init__(self, filepath: str):
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
self._file = open(filepath, "w", encoding="utf-8")
|
|
self._stdout = sys.stdout
|
|
|
|
def write(self, data):
|
|
self._stdout.write(data)
|
|
self._file.write(data)
|
|
|
|
def flush(self):
|
|
self._stdout.flush()
|
|
if not self._file.closed:
|
|
self._file.flush()
|
|
|
|
def close(self):
|
|
self._file.close()
|
|
|
|
|
|
def _execute_action(meta: MetaAdsClient, action: dict):
|
|
"""Apply an approved action via Meta API."""
|
|
action_type = action.get("action_type", "")
|
|
cid = action.get("campaign_id", "")
|
|
parameter = float(action.get("parameter") or 1.0)
|
|
|
|
if action_type == "PAUSE":
|
|
if cid.startswith("ad:"):
|
|
meta.pause_ad(cid[3:])
|
|
else:
|
|
meta.pause_campaign(cid)
|
|
|
|
elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"):
|
|
try:
|
|
bid_cfg = meta.get_campaign_bid_config(cid)
|
|
current_budget = bid_cfg.get("daily_budget_eur")
|
|
if current_budget:
|
|
meta.set_campaign_budget(cid, int(current_budget * parameter * 100))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run():
|
|
start_ts = time.time()
|
|
now = datetime.now()
|
|
|
|
print(f"\n{'='*55}")
|
|
print(f" META OPTIMIZER FORMACIÓN — {now.strftime('%d/%m/%Y %H:%M')}")
|
|
print(f" Prefix: {config.META_CAMPAIGN_PREFIX} | Modelo: PPL + capping mensual por curso")
|
|
print(f" Mode: {'DRY RUN (no changes)' if config.DRY_RUN else 'PRODUCTION'}")
|
|
print(f"{'='*55}\n")
|
|
|
|
meta = MetaAdsClient()
|
|
baserow = BaserowClient()
|
|
airtable = AirtableClient()
|
|
|
|
# ── Lookups de negocio (PPL, capping, familia) desde Airtable ──────────────
|
|
print("→ Cargando PPL/capping/familia por curso desde Airtable...")
|
|
ppl_lookup, cap_lookup, familia_lookup = airtable.build_campaign_lookups()
|
|
print(f" ✓ {len(ppl_lookup)} cursos con PPL, {len(cap_lookup)} con capping este mes.\n")
|
|
|
|
# ── Execute previously approved actions ───────────────────────────────────
|
|
actions_executed = 0
|
|
if not config.DRY_RUN:
|
|
approved = baserow.get_approved_actions()
|
|
print(f"→ Executing {len(approved)} approved actions...\n")
|
|
for action in approved:
|
|
try:
|
|
_execute_action(meta, action)
|
|
baserow.update_action_status(action["id"], "executed")
|
|
actions_executed += 1
|
|
print(f" ✓ {action.get('campaign_name')} — {action.get('action_type')}")
|
|
except Exception as e:
|
|
print(f" ✗ Error on action {action['id']}: {e}")
|
|
|
|
# ── Catálogo Meta -> Airtable (Meta Ads Campaigns) ─────────────────────────
|
|
print(f"→ Sincronizando catálogo de campañas {config.META_CAMPAIGN_PREFIX} con Airtable...")
|
|
meta_campaigns = meta.get_all_campaigns()
|
|
sync_result = airtable.sync_campaigns_from_meta_ads(meta_campaigns, ppl_lookup)
|
|
at_by_cid = sync_result["at_by_cid"]
|
|
print(f" ✓ {len(sync_result['created'])} creadas, {len(sync_result['updated'])} actualizadas "
|
|
f"(de {len(meta_campaigns)} campañas totales).\n")
|
|
|
|
# ── Métricas mes-a-la-fecha (para MetaCampaignMes y para el análisis) ──────
|
|
print("→ Fetching month-to-date metrics...")
|
|
month_start = f"{now.year}-{now.month:02d}-01"
|
|
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
monthly_metrics_meta = meta.get_campaign_metrics(month_start, yesterday)
|
|
print(f" ✓ {len(monthly_metrics_meta)} campañas con gasto este mes.\n")
|
|
|
|
mcm_sync = airtable.sync_metacampaignmes(
|
|
meta_campaigns, monthly_metrics_meta, ppl_lookup, cap_lookup, at_by_cid,
|
|
)
|
|
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: 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 = {}
|
|
for row in daily_rows:
|
|
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
|
ppl = ppl_lookup.get(cursoid, 0)
|
|
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_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()
|
|
print(f" ✓ {len(metrics_yesterday)} campaigns active yesterday.\n")
|
|
|
|
# ── 3-day and 7-day metrics (capa táctica adset/anuncio) ───────────────────
|
|
print("→ Fetching 3-day and 7-day metrics...")
|
|
metrics_3d = meta.get_period_campaign_metrics(days=3)
|
|
metrics_7d = meta.get_period_campaign_metrics(days=7)
|
|
print(" ✓ Multi-window data ready.\n")
|
|
|
|
# ── Analyze active campaigns & propose actions ─────────────────────────────
|
|
active_campaigns = [mc for mc in meta_campaigns if mc["status"] == "ACTIVE"]
|
|
|
|
actions_proposed_list = []
|
|
campaign_details = {} # {cid: {familia, margin, adsets, ads, ...}}
|
|
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)]
|
|
|
|
for mc in active_campaigns:
|
|
cid, name = mc["id"], mc["name"]
|
|
cursoid = extract_cursoid(name) or ""
|
|
familia = familia_lookup.get(cursoid, "Sin familia")
|
|
ppl = ppl_lookup.get(cursoid, 0)
|
|
cap = cap_lookup.get(cursoid, 0)
|
|
cpa_max = round(ppl * 0.70, 2)
|
|
|
|
leads_entregados, _ = airtable.get_leads_this_month_meta(name)
|
|
|
|
m1 = metrics_yesterday.get(cid, {})
|
|
mmes = monthly_metrics_meta.get(cid, {})
|
|
campaign_bid = {}
|
|
try:
|
|
campaign_bid = meta.get_campaign_bid_config(cid)
|
|
except Exception as e:
|
|
errors.append(f"Bid config {name}: {e}")
|
|
|
|
ads_metrics = {
|
|
"spend": mmes.get("spend", 0.0),
|
|
"leads": mmes.get("leads", 0),
|
|
"ctr": m1.get("ctr", 0.0),
|
|
"clicks": m1.get("clicks", 0),
|
|
"budget_daily": campaign_bid.get("daily_budget_eur", 0) or 0,
|
|
"status": mc["status"],
|
|
}
|
|
campaign_config = {
|
|
"curso": name,
|
|
"meta_campaign_id": cid,
|
|
"ppl": ppl,
|
|
"cpa_maximo": cpa_max,
|
|
"capping_mensual": cap,
|
|
"conv_leads_lake_mes": leads_entregados,
|
|
}
|
|
analysis = analyzer.analyze(campaign_config, leads_entregados, ads_metrics)
|
|
|
|
try:
|
|
decision = decide(analysis)
|
|
except Exception as e:
|
|
errors.append(f"{name}: {e}")
|
|
continue
|
|
|
|
action_type = _ACTION_MAP.get(decision.get("action", "MAINTAIN"), "MAINTAIN")
|
|
|
|
adset_bids = {}
|
|
try:
|
|
adset_bids = meta.get_adset_bid_configs(cid)
|
|
except Exception as e:
|
|
errors.append(f"Adset bids {name}: {e}")
|
|
|
|
# ABO campaigns (presupuesto solo a nivel de conjunto): omitir ajustes de campaña
|
|
is_cbo = campaign_bid.get("daily_budget_eur") is not None
|
|
if action_type in ("INCREASE_BUDGET", "REDUCE_BUDGET") and not is_cbo:
|
|
action_type = "MAINTAIN"
|
|
|
|
print(f" {name[:52]}")
|
|
print(f" Curso: {cursoid} Familia: {familia} PPL: {ppl}€ CPAmax: {cpa_max}€")
|
|
print(f" Urgencia: {analysis['urgencia']} Ritmo: {analysis['ritmo']:+.2f} "
|
|
f"Leads mes: {leads_entregados}/{cap or '∞'} Margen: {analysis['margen']*100:.0f}%")
|
|
print(f" Decision: {action_type} — {(decision.get('justification') or '')[:70]}")
|
|
if decision.get("alert"):
|
|
print(f" ALERT: {decision['alert']}")
|
|
print()
|
|
|
|
if action_type != "MAINTAIN":
|
|
try:
|
|
row = baserow.save_action({
|
|
"campaign_id": cid,
|
|
"campaign_name": name,
|
|
"action_type": action_type,
|
|
"parameter": decision.get("parameter") or 1.0,
|
|
"justification": decision.get("justification") or "",
|
|
"advice": decision.get("advice") or "",
|
|
"alert": decision.get("alert") or "",
|
|
"confidence": decision.get("confidence") or 0.0,
|
|
})
|
|
actions_proposed_list.append({
|
|
"campaign_name": name,
|
|
"action_type": action_type,
|
|
"parameter": decision.get("parameter") or 1.0,
|
|
"justification": decision.get("justification") or "",
|
|
"advice": decision.get("advice") or "",
|
|
"alert": decision.get("alert") or "",
|
|
"confidence": decision.get("confidence") or 0.0,
|
|
"cpa_actual": analysis["cpa_actual"],
|
|
"cpa_maximo": cpa_max,
|
|
"row_id": row["id"],
|
|
})
|
|
except Exception as e:
|
|
errors.append(f"Save action {name}: {e}")
|
|
|
|
# ── Ad set analysis (3d) ────────────────────────────────────────────
|
|
adsets_detail = []
|
|
try:
|
|
for as_m in meta.get_period_adset_metrics(cid, days=3)[:5]:
|
|
bid = adset_bids.get(as_m["id"], {})
|
|
as_m["bid_strategy"] = bid.get("bid_strategy", "")
|
|
as_m["cost_cap_eur"] = bid.get("cost_cap_eur")
|
|
as_m["ppl"] = ppl
|
|
as_m["cpa_maximo"] = cpa_max
|
|
result = analyze_unit(as_m, "adset")
|
|
adsets_detail.append({**as_m, **result})
|
|
print(f" [Adset] {as_m['name'][:45]} — {result.get('evaluacion','')[:60]}")
|
|
except Exception as e:
|
|
errors.append(f"Adsets {name}: {e}")
|
|
|
|
# ── Ad analysis (3d + 7d merged) ────────────────────────────────────
|
|
ads_detail = []
|
|
try:
|
|
ads_3d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=3)}
|
|
ads_7d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=7)}
|
|
ordered_ids = list(dict.fromkeys(
|
|
[a["id"] for a in sorted(ads_7d.values(), key=lambda x: -x["spend"])] +
|
|
[a["id"] for a in sorted(ads_3d.values(), key=lambda x: -x["spend"])]
|
|
))[:5]
|
|
for ad_id in ordered_ids:
|
|
a3 = ads_3d.get(ad_id, {})
|
|
a7 = ads_7d.get(ad_id, {})
|
|
ad_m = dict(a7) if a7 else dict(a3)
|
|
ad_m["cpl_3d"] = a3.get("cpl", 0.0)
|
|
ad_m["leads_3d"] = a3.get("leads", 0)
|
|
ad_m["spend_3d"] = a3.get("spend", 0.0)
|
|
ad_m["cpl_7d"] = a7.get("cpl", 0.0)
|
|
ad_m["leads_7d"] = a7.get("leads", 0)
|
|
ad_m["ppl"] = ppl
|
|
ad_m["cpa_maximo"] = cpa_max
|
|
result = analyze_unit(ad_m, "ad")
|
|
ad_entry = {**ad_m, **result}
|
|
if result.get("accion") == "PAUSE":
|
|
try:
|
|
ad_row = baserow.save_action({
|
|
"campaign_id": f"ad:{ad_m['id']}",
|
|
"campaign_name": ad_m["name"],
|
|
"action_type": "PAUSE",
|
|
"parameter": 1.0,
|
|
"justification": result.get("recomendacion", ""),
|
|
"advice": result.get("evaluacion", ""),
|
|
"confidence": 0.8,
|
|
})
|
|
ad_entry["row_id"] = ad_row["id"]
|
|
except Exception as e:
|
|
errors.append(f"Ad action {ad_m['name']}: {e}")
|
|
ads_detail.append(ad_entry)
|
|
action_tag = " ⛔PAUSE" if result.get("accion") == "PAUSE" else ""
|
|
print(f" [Ad] {ad_m['name'][:45]} — {result.get('evaluacion','')[:60]}{action_tag}")
|
|
except Exception as e:
|
|
errors.append(f"Ads {name}: {e}")
|
|
|
|
# margin_eur: proxy diario de rentabilidad (leads*PPL - gasto), igual unidad
|
|
# que las tablas de Slack; margen_pct: rentabilidad acumulada del mes (analyzer).
|
|
margin_eur = round(m1.get("leads", 0) * ppl - m1.get("spend", 0.0), 2)
|
|
|
|
campaign_details[cid] = {
|
|
"name": name,
|
|
"familia": familia,
|
|
"urgencia": analysis["urgencia"],
|
|
"margen_pct": analysis["margen"],
|
|
"margin": margin_eur,
|
|
"leads_mes": leads_entregados,
|
|
"capping": cap,
|
|
"ppl": ppl,
|
|
"spend_1d": m1.get("spend", 0.0),
|
|
"leads_1d": m1.get("leads", 0),
|
|
"adsets": adsets_detail,
|
|
"ads": ads_detail,
|
|
"bid_config": campaign_bid,
|
|
}
|
|
|
|
# ── Daily snapshot (persists analysis to Baserow for dashboard) ───────
|
|
try:
|
|
baserow.save_daily_snapshot({
|
|
"run_date": now.strftime("%Y-%m-%d"),
|
|
"campaign_id": cid,
|
|
"campaign_name": name,
|
|
"familia": familia,
|
|
"spend": m1.get("spend", 0.0),
|
|
"leads": m1.get("leads", 0),
|
|
"cpl": m1.get("cpl", 0.0),
|
|
"margin": margin_eur,
|
|
"action_type": action_type,
|
|
"justification": decision.get("justification") or "",
|
|
"adsets": adsets_detail,
|
|
"ads": ads_detail,
|
|
})
|
|
except Exception as e:
|
|
errors.append(f"Snapshot {name}: {e}")
|
|
|
|
# ── 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)
|
|
if mcm:
|
|
criticidad = _criticidad(analysis["urgencia"], action_type)
|
|
log_text = decision.get("alert") or ""
|
|
advice_updates.append((mcm["airtable_id"], decision.get("advice") or "", criticidad, log_text))
|
|
final_leads_updates.append((mcm["airtable_id"], leads_entregados))
|
|
|
|
if advice_updates:
|
|
airtable.batch_update_metacampaignmes_advice(advice_updates)
|
|
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]
|
|
|
|
all_active = list(metrics_yesterday.values())
|
|
worst_10 = sorted(
|
|
all_active,
|
|
key=lambda x: (x["leads"] > 0, -x["cpl"] if x["leads"] > 0 else 0),
|
|
)[:10]
|
|
|
|
# ── Send consolidated Slack report ────────────────────────────────────────
|
|
duration = round(time.time() - start_ts, 1)
|
|
|
|
try:
|
|
slack_notifier.send_daily_report(
|
|
daily_totals=daily_totals,
|
|
best_campaigns=best_10,
|
|
worst_campaigns=worst_10,
|
|
actions=actions_proposed_list,
|
|
campaigns_analyzed=len(active_campaigns),
|
|
mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION",
|
|
campaign_details=campaign_details,
|
|
curso_summary=curso_summary,
|
|
portfolio_analysis_text=portfolio_text,
|
|
)
|
|
except Exception as e:
|
|
print(f" Warning: Slack notification failed: {e}")
|
|
|
|
# ── Execution log ─────────────────────────────────────────────────────────
|
|
summary = (
|
|
f"{len(active_campaigns)} campaigns analyzed, "
|
|
f"{len(actions_proposed_list)} actions proposed, "
|
|
f"{actions_executed} executed."
|
|
)
|
|
try:
|
|
baserow.save_execution_log({
|
|
"mode": "DRY_RUN" if config.DRY_RUN else "PRODUCTION",
|
|
"campaigns_analyzed": len(active_campaigns),
|
|
"actions_proposed": len(actions_proposed_list),
|
|
"actions_executed": actions_executed,
|
|
"errors": "\n".join(errors),
|
|
"summary": summary,
|
|
"duration_seconds": duration,
|
|
})
|
|
except Exception as e:
|
|
print(f" Warning: could not save execution log: {e}")
|
|
|
|
print(f"{'='*55}")
|
|
print(f" Done in {duration}s. {summary}")
|
|
if errors:
|
|
print(f" Errors ({len(errors)}): {'; '.join(errors[:3])}")
|
|
print(f"{'='*55}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
log_path = os.path.join("logs", f"{timestamp}.log")
|
|
tee = Tee(log_path)
|
|
sys.stdout = tee
|
|
try:
|
|
run()
|
|
finally:
|
|
tee.close()
|
|
sys.stdout = tee._stdout
|