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:
parent
aa9225d338
commit
a94c18c13c
35
.github/workflows/weekly.yml
vendored
Normal file
35
.github/workflows/weekly.yml
vendored
Normal 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
177
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",
|
||||
|
||||
6
run.py
6
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.")
|
||||
|
||||
|
||||
|
||||
@ -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
134
weekly_report.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user