- 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>
209 lines
8.8 KiB
Python
209 lines
8.8 KiB
Python
"""Baserow REST API client for meta_optimizer tables."""
|
|
import requests
|
|
from datetime import datetime
|
|
import config
|
|
|
|
|
|
class BaserowClient:
|
|
def __init__(self):
|
|
self._base = config.BASEROW_URL.rstrip("/")
|
|
self._headers = {
|
|
"Authorization": f"Token {config.BASEROW_TOKEN}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
def _url(self, table_id: int, row_id: int = None) -> str:
|
|
path = f"/api/database/rows/table/{table_id}/"
|
|
if row_id:
|
|
path += f"{row_id}/"
|
|
return self._base + path
|
|
|
|
def _get_rows(self, table_id: int, filters: dict = None) -> list:
|
|
params = {"user_field_names": "true"}
|
|
if filters:
|
|
params.update(filters)
|
|
try:
|
|
resp = requests.get(self._url(table_id), headers=self._headers, params=params, timeout=15)
|
|
if not resp.ok:
|
|
return []
|
|
return resp.json().get("results", [])
|
|
except requests.RequestException:
|
|
return []
|
|
|
|
def _create_row(self, table_id: int, data: dict) -> dict:
|
|
resp = requests.post(
|
|
self._url(table_id),
|
|
headers=self._headers,
|
|
params={"user_field_names": "true"},
|
|
json=data,
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def _delete_row(self, table_id: int, row_id: int):
|
|
resp = requests.delete(
|
|
self._url(table_id, row_id),
|
|
headers=self._headers,
|
|
params={"user_field_names": "true"},
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _update_row(self, table_id: int, row_id: int, data: dict) -> dict:
|
|
resp = requests.patch(
|
|
self._url(table_id, row_id),
|
|
headers=self._headers,
|
|
params={"user_field_names": "true"},
|
|
json=data,
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
# ── verticals ─────────────────────────────────────────────────────────────
|
|
|
|
def get_all_verticals(self) -> list:
|
|
return self._get_rows(config.BASEROW_TABLE_VERTICALS)
|
|
|
|
def get_vertical_config(self, vertical_name: str) -> dict | None:
|
|
rows = self._get_rows(
|
|
config.BASEROW_TABLE_VERTICALS,
|
|
{"filter__Nombre__equal": vertical_name},
|
|
)
|
|
return rows[0] if rows else None
|
|
|
|
# ── campaigns ─────────────────────────────────────────────────────────────
|
|
|
|
def get_campaign_config(self, campaign_id: str) -> dict | None:
|
|
rows = self._get_rows(
|
|
config.BASEROW_TABLE_CAMPAIGNS,
|
|
{"filter__campaign_id__equal": campaign_id},
|
|
)
|
|
return rows[0] if rows else None
|
|
|
|
# ── proposed_actions ──────────────────────────────────────────────────────
|
|
|
|
def save_action(self, action: dict) -> dict:
|
|
return self._create_row(config.BASEROW_TABLE_ACTIONS, {
|
|
"campaign_id": action["campaign_id"],
|
|
"campaign_name": action["campaign_name"],
|
|
"action_type": action["action_type"],
|
|
"parameter": action.get("parameter", 1.0),
|
|
"justification": action.get("justification", ""),
|
|
"advice": action.get("advice", ""),
|
|
"alert": action.get("alert") or "",
|
|
"confidence": action.get("confidence", 0.0),
|
|
"status": "pending",
|
|
"proposed_at": datetime.now().strftime("%Y-%m-%d"),
|
|
})
|
|
|
|
def get_approved_actions(self) -> list:
|
|
return self._get_rows(
|
|
config.BASEROW_TABLE_ACTIONS,
|
|
{"filter__status__single_select_equal": "approved"},
|
|
)
|
|
|
|
def update_action_status(
|
|
self, row_id: int, status: str, slack_message_ts: str = None
|
|
) -> dict:
|
|
data: dict = {"status": status}
|
|
if status == "executed":
|
|
data["executed_at"] = datetime.now().strftime("%Y-%m-%d")
|
|
if slack_message_ts is not None:
|
|
data["slack_message_ts"] = slack_message_ts
|
|
return self._update_row(config.BASEROW_TABLE_ACTIONS, row_id, data)
|
|
|
|
# ── creative_analyses ─────────────────────────────────────────────────────
|
|
|
|
def save_creative_analysis(self, analysis: dict) -> dict:
|
|
return self._create_row(config.BASEROW_TABLE_CREATIVES, {
|
|
"ad_id": analysis["ad_id"],
|
|
"ad_name": analysis["ad_name"],
|
|
"campaign_id": analysis["campaign_id"],
|
|
"image_url": analysis["image_url"],
|
|
"analysis": analysis.get("analysis", ""),
|
|
"score": analysis.get("score", 0),
|
|
"recommendations": analysis.get("recommendations", ""),
|
|
"created_at": datetime.now().strftime("%Y-%m-%d"),
|
|
})
|
|
|
|
# ── daily_snapshots ───────────────────────────────────────────────────────
|
|
|
|
def save_daily_snapshot(self, snapshot: dict) -> dict:
|
|
import json
|
|
|
|
# Remove existing snapshots for same day + campaign before saving new one
|
|
existing = self._get_rows(
|
|
config.BASEROW_TABLE_SNAPSHOTS,
|
|
{
|
|
"filter__run_date__equal": snapshot["run_date"],
|
|
"filter__campaign_name__equal": snapshot["campaign_name"],
|
|
},
|
|
)
|
|
for row in existing:
|
|
try:
|
|
self._delete_row(config.BASEROW_TABLE_SNAPSHOTS, row["id"])
|
|
except Exception:
|
|
pass
|
|
|
|
def _safe_json(items: list) -> str:
|
|
cleaned = []
|
|
for item in items:
|
|
cleaned.append({
|
|
k: (str(v)[:500] if isinstance(v, str) else v)
|
|
for k, v in item.items()
|
|
if isinstance(v, (str, int, float, bool, type(None)))
|
|
})
|
|
s = json.dumps(cleaned, ensure_ascii=False)
|
|
return s[:60000] # Baserow long_text practical limit
|
|
|
|
resp = requests.post(
|
|
self._url(config.BASEROW_TABLE_SNAPSHOTS),
|
|
headers=self._headers,
|
|
params={"user_field_names": "true"},
|
|
json={
|
|
"run_date": snapshot["run_date"],
|
|
"campaign_id": snapshot["campaign_id"],
|
|
"campaign_name": snapshot["campaign_name"],
|
|
"vertical": snapshot["vertical"],
|
|
"spend": float(snapshot["spend"]),
|
|
"leads": int(snapshot["leads"]),
|
|
"cpl": float(snapshot["cpl"]),
|
|
"margin": float(snapshot["margin"]),
|
|
"action_type": snapshot.get("action_type", "MAINTAIN"),
|
|
"justification": (snapshot.get("justification") or "")[:2000],
|
|
"adsets_json": _safe_json(snapshot.get("adsets", [])),
|
|
"ads_json": _safe_json(snapshot.get("ads", [])),
|
|
},
|
|
timeout=15,
|
|
)
|
|
if not resp.ok:
|
|
raise Exception(f"{resp.status_code} {resp.text[:300]}")
|
|
return resp.json()
|
|
|
|
def get_snapshots_for_date(self, run_date: str) -> list:
|
|
return self._get_rows(
|
|
config.BASEROW_TABLE_SNAPSHOTS,
|
|
{"filter__run_date__equal": run_date},
|
|
)
|
|
|
|
def get_snapshot_dates(self) -> list:
|
|
"""Return sorted list of distinct run_date values that have snapshots."""
|
|
rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS)
|
|
return sorted({r["run_date"] for r in rows if r.get("run_date")}, reverse=True)
|
|
|
|
# ── execution_logs ────────────────────────────────────────────────────────
|
|
|
|
def save_execution_log(self, log: dict) -> dict:
|
|
return self._create_row(config.BASEROW_TABLE_LOGS, {
|
|
"executed_at": datetime.now().strftime("%Y-%m-%d"),
|
|
"mode": log.get("mode", "DRY_RUN"),
|
|
"campaigns_analyzed": log.get("campaigns_analyzed", 0),
|
|
"actions_proposed": log.get("actions_proposed", 0),
|
|
"actions_executed": log.get("actions_executed", 0),
|
|
"errors": log.get("errors", ""),
|
|
"summary": log.get("summary", ""),
|
|
"duration_seconds": log.get("duration_seconds", 0.0),
|
|
})
|