diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml new file mode 100644 index 0000000..1c7958a --- /dev/null +++ b/.github/workflows/weekly.yml @@ -0,0 +1,35 @@ +name: Weekly Strategic Report + +on: + schedule: + - cron: '0 7 * * 1' # Lunes 9:00 AM hora española (CEST/UTC+2) + workflow_dispatch: + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run weekly report + env: + AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }} + AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }} + GOOGLE_ADS_DEVELOPER_TOKEN: ${{ secrets.GOOGLE_ADS_DEVELOPER_TOKEN }} + GOOGLE_ADS_CLIENT_ID: ${{ secrets.GOOGLE_ADS_CLIENT_ID }} + GOOGLE_ADS_CLIENT_SECRET: ${{ secrets.GOOGLE_ADS_CLIENT_SECRET }} + GOOGLE_ADS_REFRESH_TOKEN: ${{ secrets.GOOGLE_ADS_REFRESH_TOKEN }} + GOOGLE_ADS_LOGIN_CUSTOMER_ID: ${{ secrets.GOOGLE_ADS_LOGIN_CUSTOMER_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: python weekly_report.py diff --git a/agent.py b/agent.py index 1a548da..b859fc9 100644 --- a/agent.py +++ b/agent.py @@ -1,9 +1,23 @@ import json +from datetime import datetime import anthropic 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 Google Ads (solo campañas fco_). +Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-). +""" + +WEEKLY_SYSTEM = """ +Eres un consultor senior de marketing de performance especializado en generación de leads para formación. +Recibes el análisis semanal del portfolio de campañas de Google Ads (solo campañas fco_). +Tu análisis debe ser estratégico, comparando la semana actual con la anterior, identificando tendencias y proponiendo acciones concretas. +Responde siempre en español. Sin markdown, sin bullet symbols especiales, usa guiones simples (-). +""" + SYSTEM_PROMPT = """ Eres un agente experto en optimización de campañas de generación de leads para centros de formación. Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores. @@ -48,6 +62,169 @@ El campo consejo debe ser accionable y específico: qué revisar, qué cambiar, """ +def _classify_type(curso: str) -> str: + c = curso.lower() + if "_leadform" in c: + return "leadform" + if "_pmx" in c or "pmx_" in c: + return "pmx" + if "search" in c: + return "search" + return "otro" + + +def portfolio_daily_analysis(collected: list) -> str: + """Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack.""" + from datetime import datetime + now = datetime.now() + fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")] + + tipos: dict = {} + leadforms_detail = [] + alertas_tracking = 0 + campañas_perdida = 0 + + for item in fco: + t = _classify_type(item["campaign"]["curso"]) + m = item["metrics"] + a = item["analysis"] + cost = m.get("cost", 0) + conv = a["conversiones_google"] + 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": + leadforms_detail.append({ + "curso": item["campaign"]["curso"][:40], + "cpa_google": round(cost / conv, 2) if conv > 0 else None, + "conv_google": 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(fco), + "campañas_en_perdida": campañas_perdida, + "alertas_tracking": alertas_tracking, + "rendimiento_por_tipo": resumen_tipos, + "detalle_leadforms": leadforms_detail, + } + + try: + response = client.messages.create( + model="claude-sonnet-4-5", + max_tokens=500, + 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}" + + +def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict, + collected: list, mes_nombre: str) -> str: + """ + Análisis estratégico semanal profundo. + games_md_this: MetricasDiarias de GAMes de los últimos 7 días (esta semana). + games_md_prev_week: MetricasDiarias de GAMes de los 7 días anteriores. + collected: lista de campañas del optimizer. + """ + def _week_summary(md: dict) -> dict: + coste = ing = leads = leads_lake = 0.0 + for v in md.values(): + coste += v.get("coste", 0) + ing += v.get("ingreso_sum", 0) + leads += v.get("leads", 0) + leads_lake += v.get("leads_lake", 0) + margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0 + cpa = round(coste / leads, 2) if leads > 0 else None + return {"coste": round(coste, 2), "ingreso": round(ing, 2), + "leads_google": int(leads), "leads_airtable": int(leads_lake), + "margen_pct": margen, "cpa": cpa} + + fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")] + + # Top 5 peores por CPA del mes + peores = sorted( + [{"curso": i["campaign"]["curso"][:40], + "cpa": i["analysis"]["cpa_actual"], + "conv": int(i["analysis"]["conversiones_google"]), + "margen_pct": round(i["analysis"]["margen"] * 100, 1)} + for i in fco if i["analysis"]["cpa_actual"] > 0], + key=lambda x: x["cpa"], reverse=True + )[:5] + + data = { + "mes": mes_nombre, + "semana_actual": _week_summary(games_md_this), + "semana_anterior": _week_summary(games_md_prev_week), + "top5_peor_cpa_mes": peores, + "leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()), + "campañas_totales": len(fco), + } + + try: + response = client.messages.create( + model="claude-sonnet-4-5", + max_tokens=900, + system=WEEKLY_SYSTEM, + messages=[{ + "role": "user", + "content": ( + "Genera el informe estratégico semanal con:\n" + "1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n" + "2. Tendencias clave detectadas (máx 4, con guión)\n" + "3. Situación campañas leadform y qué hacer con ellas\n" + "4. Acciones estratégicas prioritarias para la próxima semana (máx 4, muy concretas, con guión)\n" + "5. Una frase de conclusión sobre si el portfolio va en la dirección correcta\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 semanal: {e}" + + def decide(analysis: dict) -> dict: response = client.messages.create( model="claude-sonnet-4-20250514", diff --git a/run.py b/run.py index 402cb68..2f5ddc1 100644 --- a/run.py +++ b/run.py @@ -8,7 +8,7 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_bufferin from airtable_client import AirtableClient from google_ads_client import GoogleAdsClient from analyzer import analyze -from agent import decide +from agent import decide, portfolio_daily_analysis from optimizer import apply_decision from slack_reporter import build_and_send import config @@ -423,9 +423,11 @@ def run(): print() # Enviar resumen a Slack + print("→ Generando análisis estratégico del portfolio...") + portfolio_text = portfolio_daily_analysis(collected) print("→ Enviando resumen a Slack...") prev_month_metricas = at.get_metricas_diarias_prev_month() if (cambio_mes or datetime.now().day <= 5) else {} - build_and_send(collected, config.DRY_RUN, prev_month_metricas) + build_and_send(collected, config.DRY_RUN, prev_month_metricas, portfolio_text) print(" ✓ Resumen enviado a Slack.") diff --git a/slack_reporter.py b/slack_reporter.py index 4956b4a..b89c6fe 100644 --- a/slack_reporter.py +++ b/slack_reporter.py @@ -63,7 +63,8 @@ def _curso(name: str, max_len: int = 40) -> str: return name[:max_len] + ("…" if len(name) > max_len else "") -def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None) -> None: +def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None, + portfolio_analysis_text: str = None) -> None: if not config.SLACK_WEBHOOK_URL: print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.") return @@ -315,6 +316,16 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N ], }) + if portfolio_analysis_text: + blocks.append({"type": "divider"}) + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🤖 *DIAGNÓSTICO ESTRATÉGICO*\n{portfolio_analysis_text}", + }, + }) + payload = {"blocks": blocks} try: resp = requests.post(config.SLACK_WEBHOOK_URL, json=payload, timeout=10) diff --git a/weekly_report.py b/weekly_report.py new file mode 100644 index 0000000..6966b21 --- /dev/null +++ b/weekly_report.py @@ -0,0 +1,134 @@ +""" +Informe estratégico semanal de Leads Optimizer. +Se ejecuta los lunes via GitHub Actions y envía un análisis profundo a Slack. +""" +import json +import sys +import os +import requests +from datetime import datetime, timedelta + +import config +from airtable_client import AirtableClient +from google_ads_client import GoogleAdsClient +from agent import weekly_strategic_analysis +from analyzer import analyze +from slack_reporter import MESES_ES + +MESES_ES_LOCAL = MESES_ES + + +def _get_week_days(offset_weeks: int = 0) -> list[str]: + """Devuelve los 7 días YYYY-MM-DD de la semana offset_weeks atrás (0=esta, 1=anterior).""" + today = datetime.now().date() + monday = today - timedelta(days=today.weekday()) - timedelta(weeks=offset_weeks) + return [(monday + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)] + + +def _filter_games_md_by_days(games_md: dict, days: list[str]) -> dict: + """Filtra MetricasDiarias de GAMes a los días indicados (formato 'DD').""" + day_keys = {d[8:10] for d in days} + return {k: v for k, v in games_md.items() if k in day_keys} + + +def run_weekly(): + now = datetime.now() + print(f"\n{'='*55}") + print(f" INFORME SEMANAL — {now.strftime('%d/%m/%Y %H:%M')}") + print(f"{'='*55}\n") + + at = AirtableClient() + gads = GoogleAdsClient() + + # Obtener MetricasDiarias de GAMes del mes en curso + mes, anio = now.month, now.year + print("Cargando GAMes...") + games_rid = at.get_or_create_games_record(mes, anio) + games_md = at.get_games_metricas(games_rid) + + # Calcular semanas + this_week_days = _get_week_days(0) + prev_week_days = _get_week_days(1) + + games_this = _filter_games_md_by_days(games_md, this_week_days) + games_prev = _filter_games_md_by_days(games_md, prev_week_days) + + # Si la semana anterior cruza con el mes pasado, intentar cargar ese GAMes también + prev_month_days = [d for d in prev_week_days if d[:7] != now.strftime("%Y-%m")] + if prev_month_days: + prev_mes = mes - 1 if mes > 1 else 12 + prev_anio = anio if mes > 1 else anio - 1 + prev_records = at.games.all(formula=f"AND({{Mes}}='{prev_mes}',{{Año}}='{prev_anio}')") + if prev_records: + try: + prev_md = json.loads(prev_records[0]["fields"].get("MetricasDiarias") or "{}") + except Exception: + prev_md = {} + extra = _filter_games_md_by_days(prev_md, prev_month_days) + games_prev.update(extra) + + # Cargar métricas de campañas activas para el análisis de portfolio + print("Cargando campañas activas...") + campaigns = at.get_active_gacampaignmes() + monthly_metrics = gads.get_monthly_metrics_all() + + collected = [] + for campaign in campaigns: + cid = campaign["google_campaign_id"] + metrics = monthly_metrics.get(cid, {"cost": 0, "conversions": 0, "clicks": 0, + "impressions": 0, "ctr": 0, "budget_daily": 0, + "status": "UNKNOWN"}) + leads = campaign.get("conv_leads_lake_mes", 0) + analysis = analyze(campaign, leads, metrics) + collected.append({"campaign": campaign, "metrics": metrics, + "analysis": analysis, "leads": leads}) + + print("Generando análisis estratégico semanal con IA...") + mes_nombre = MESES_ES_LOCAL.get(mes, str(mes)) + analysis_text = weekly_strategic_analysis(games_this, games_prev, collected, mes_nombre) + + # Construir mensaje Slack + if not config.SLACK_WEBHOOK_URL: + print("SLACK_WEBHOOK_URL no configurada.") + print("\n--- ANÁLISIS ---\n") + print(analysis_text) + return + + this_range = f"{this_week_days[0][8:10]}/{this_week_days[0][5:7]}–{this_week_days[-1][8:10]}/{this_week_days[-1][5:7]}" + prev_range = f"{prev_week_days[0][8:10]}/{prev_week_days[0][5:7]}–{prev_week_days[-1][8:10]}/{prev_week_days[-1][5:7]}" + + blocks = [ + { + "type": "header", + "text": {"type": "plain_text", + "text": f"INFORME SEMANAL — {now.strftime('%d/%m/%Y')}"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"_Semana actual: {this_range} | Semana anterior: {prev_range}_", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🤖 *ANÁLISIS ESTRATÉGICO SEMANAL*\n{analysis_text}", + }, + }, + ] + + try: + resp = requests.post(config.SLACK_WEBHOOK_URL, json={"blocks": blocks}, timeout=10) + if resp.status_code != 200: + print(f"⚠️ Slack respondió {resp.status_code}: {resp.text[:200]}") + else: + print("✓ Informe semanal enviado a Slack.") + except Exception as e: + print(f"⚠️ Error enviando a Slack: {e}") + + +if __name__ == "__main__": + run_weekly()