meta-optimizer/backfill.py
José Manuel Gómez 8fb0b69896 Add Baserow persistence, Slack interactive reports, Streamlit dashboard, and full analysis pipeline
- Migrate from Airtable to Baserow: BaserowClient with snapshots, actions, creatives, logs
- Claude agents (Haiku for decisions/units, Sonnet for creatives) with cost_cap_eur vs CPL comparison
- Slack bot with colored action emojis, effect text before approval buttons, 500-char justifications
- Streamlit dashboard with date-range navigation, campaign drill-down (adsets/ads), Histórico tab
- Approval server (FastAPI + ngrok) for Slack button callbacks
- backfill.py for historical snapshot regeneration with Claude re-analysis
- Margin fix: 0-lead campaigns contribute -spend (not 0) to margin
- CTR fix: Meta returns CTR as percentage already, removed *100
- Parameter fix: pass decision parameter to Slack action for correct budget effect display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:30:13 +02:00

196 lines
8.5 KiB
Python

"""
Backfill: genera snapshots históricos con análisis Claude para un rango de fechas.
Uso:
python backfill.py # mes en curso → ayer
python backfill.py --from 2026-06-01 --to 2026-06-04
python backfill.py --skip-existing # no reprocesa días ya guardados
"""
import sys
import io
import argparse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
from datetime import datetime, timedelta
import config
from meta_ads_client import MetaAdsClient
from agent import decide, analyze_unit
from baserow_client import BaserowClient
_ACTION_MAP = {
"PAUSE": "PAUSE", "REDUCE_BUDGET": "REDUCE_BUDGET",
"INCREASE_BUDGET": "INCREASE_BUDGET", "MAINTAIN": "MAINTAIN",
"REVIEW_CREATIVES": "REVIEW_CREATIVES",
"PAUSAR": "PAUSE", "REDUCIR_PRESUPUESTO": "REDUCE_BUDGET",
"AUMENTAR_PRESUPUESTO": "INCREASE_BUDGET", "MANTENER": "MAINTAIN",
"REVISAR_CREATIVIDADES": "REVIEW_CREATIVES",
}
def _extract_vertical(name: str) -> str:
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"
def run_backfill(date_from: str, date_to: str, skip_existing: bool = False):
meta = MetaAdsClient()
baserow = BaserowClient()
# Vertical CPL targets
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 as e:
print(f"Warning: could not fetch verticals: {e}")
# Build date list
d = datetime.strptime(date_from, "%Y-%m-%d")
d_end = datetime.strptime(date_to, "%Y-%m-%d")
dates = []
while d <= d_end:
dates.append(d.strftime("%Y-%m-%d"))
d += timedelta(days=1)
print(f"\n{'='*60}")
print(f" BACKFILL {date_from}{date_to} ({len(dates)} días)")
print(f"{'='*60}\n")
total_saved = 0
total_skip = 0
for run_date in dates:
print(f"\n── {run_date} ───────────────────────────────────────────────")
# Pre-load existing snapshots for this date if skip_existing
existing_names: set = set()
if skip_existing:
try:
for r in baserow.get_snapshots_for_date(run_date):
existing_names.add(r.get("campaign_name", ""))
except Exception:
pass
campaign_metrics = meta.get_campaign_metrics(run_date, run_date)
if not campaign_metrics:
print(" Sin campañas con gasto.")
continue
print(f" {len(campaign_metrics)} campañas activas.")
# Adset bid configs (current — bid strategy doesn't change day to day)
adset_bids_cache: dict = {}
for cid, metrics in campaign_metrics.items():
camp_name = metrics["name"]
if skip_existing and camp_name in existing_names:
print(f" SKIP {camp_name[:55]}")
total_skip += 1
continue
vertical = _extract_vertical(camp_name)
max_cpl = vertical_cpls.get(vertical, config.META_TARGET_CPL) or config.META_TARGET_CPL
margin = round((max_cpl - metrics["cpl"]) * metrics["leads"], 2) if metrics["leads"] > 0 else round(-metrics["spend"], 2)
print(f" {camp_name[:55]}")
print(f" Spend {metrics['spend']}€ Leads {metrics['leads']} CPL {metrics['cpl']}€ MaxCPL {max_cpl}€ Margen {margin:+.2f}")
# ── Claude: decisión ────────────────────────────────────────────
analysis = {
"campaign_id": cid, "name": camp_name, "status": "ACTIVE",
"spend": metrics["spend"], "leads": metrics["leads"],
"cpl": metrics["cpl"], "max_cpl": max_cpl,
"ctr": metrics["ctr"], "cpm": metrics["cpm"],
"impressions": metrics["impressions"], "clicks": metrics["clicks"],
}
try:
decision = decide(analysis)
action_type = _ACTION_MAP.get(
decision.get("action") or decision.get("accion", "MAINTAIN"),
"MAINTAIN",
)
except Exception as e:
print(f" ERROR decide: {e}")
decision = {"action": "MAINTAIN", "justification": "", "parameter": 1.0}
action_type = "MAINTAIN"
print(f" Decision: {action_type}{(decision.get('justification') or '')[:70]}")
# ── Claude: adsets ──────────────────────────────────────────────
adsets_detail = []
try:
for as_m in meta.get_adset_metrics(cid, run_date, run_date)[:5]:
result = analyze_unit(as_m, "adset")
adsets_detail.append({**as_m, **result})
print(f" [Adset] {as_m['name'][:45]}{result.get('evaluacion','')[:50]}")
except Exception as e:
print(f" ERROR adsets: {e}")
# Add bid configs (cached per campaign)
if cid not in adset_bids_cache:
try:
adset_bids_cache[cid] = meta.get_adset_bid_configs(cid)
except Exception:
adset_bids_cache[cid] = {}
for adset in adsets_detail:
b = adset_bids_cache[cid].get(adset["id"], {})
adset["cost_cap_eur"] = b.get("cost_cap_eur")
adset["bid_strategy"] = b.get("bid_strategy", "")
# ── Claude: anuncios ────────────────────────────────────────────
ads_detail = []
try:
for ad_m in meta.get_ad_metrics(cid, run_date, run_date)[:5]:
result = analyze_unit(ad_m, "ad")
ads_detail.append({**ad_m, **result})
print(f" [Ad] {ad_m['name'][:45]}{result.get('evaluacion','')[:50]}")
except Exception as e:
print(f" ERROR ads: {e}")
# ── Guardar snapshot ────────────────────────────────────────────
try:
baserow.save_daily_snapshot({
"run_date": run_date,
"campaign_id": cid,
"campaign_name": camp_name,
"vertical": vertical,
"spend": metrics["spend"],
"leads": metrics["leads"],
"cpl": metrics["cpl"],
"margin": margin,
"action_type": action_type,
"justification": decision.get("justification") or "",
"adsets": adsets_detail,
"ads": ads_detail,
})
print(f" ✓ Snapshot guardado")
total_saved += 1
except Exception as e:
print(f" ERROR snapshot: {e}")
print(f"\n{'='*60}")
print(f" Backfill completo. Guardados: {total_saved} Saltados: {total_skip}")
print(f"{'='*60}\n")
if __name__ == "__main__":
now = datetime.now()
default_from = f"{now.year}-{now.month:02d}-01"
default_to = (now - timedelta(days=1)).strftime("%Y-%m-%d")
parser = argparse.ArgumentParser(description="Backfill Meta Optimizer snapshots")
parser.add_argument("--from", dest="date_from", default=default_from,
help=f"Fecha inicio YYYY-MM-DD (default: {default_from})")
parser.add_argument("--to", dest="date_to", default=default_to,
help=f"Fecha fin YYYY-MM-DD (default: {default_to})")
parser.add_argument("--skip-existing", action="store_true",
help="No reprocesa campañas que ya tienen snapshot ese día")
args = parser.parse_args()
run_backfill(args.date_from, args.date_to, args.skip_existing)