Add daily portfolio analysis and weekly strategic report

- agent.py: portfolio_daily_analysis() for daily Slack block,
  weekly_strategic_analysis() for deep weekly report
- run.py: call portfolio analysis before Slack send
- slack_reporter.py: add strategic diagnosis block at end of daily report
- weekly_report.py: standalone weekly report script
- .github/workflows/weekly.yml: runs Mondays at 9am (CEST)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-05-21 20:22:37 +02:00
parent aa9225d338
commit a94c18c13c
5 changed files with 362 additions and 3 deletions

35
.github/workflows/weekly.yml vendored Normal file
View File

@ -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

177
agent.py
View File

@ -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",

6
run.py
View File

@ -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.")

View File

@ -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)

134
weekly_report.py Normal file
View File

@ -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()