Initial HF Space deployment
Browse files- .gitignore +8 -0
- ads.db +0 -0
- app/__init__.py +0 -0
- app/ads1/ads_analyst.py +144 -0
- app/ads1/ads_queries.py +79 -0
- app/ads1/budget_optimizer.py +134 -0
- app/ads1/connector.py +87 -0
- app/ads1/fetch_ads_data.py +156 -0
- app/controller/session_loader.py +21 -0
- app/db/__init__.py +0 -0
- app/db/models.py +41 -0
- app/db/repo.py +61 -0
- app/models/llm.py +24 -0
- app/recs/generate.py +44 -0
- app/recs/rules.py +53 -0
- app/ui/dashboard.py +69 -0
- app/ui/recommendations.py +77 -0
- docs/superpowers/plans/2026-06-03-ads-automation-prd.md +285 -0
- docs/superpowers/plans/simplified.md +481 -0
- docs/superpowers/specs/2026-06-03-ads-automation-design.md +100 -0
- get_refresh_token.py +14 -0
- main.py +99 -0
- requirements.txt +8 -0
- run_ads_data_pipeline.py +23 -0
- run_inspect.py +25 -0
- scripts/seed_demo.py +144 -0
- test.py +19 -0
- test_model.py +83 -0
- tests/test_connector.py +28 -0
- tests/test_e2e.py +125 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.adv/
|
| 4 |
+
adv/
|
| 5 |
+
.env
|
| 6 |
+
.DS_Store
|
| 7 |
+
data/app.db
|
| 8 |
+
client_secret.json
|
ads.db
ADDED
|
Binary file (16.4 kB). View file
|
|
|
app/__init__.py
ADDED
|
File without changes
|
app/ads1/ads_analyst.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from typing import Dict
|
| 3 |
+
|
| 4 |
+
from app.recs.generate import generate_explanation
|
| 5 |
+
|
| 6 |
+
TARGET_CPL = 20.0
|
| 7 |
+
|
| 8 |
+
# -------------------------
|
| 9 |
+
# 1. DATA BUILDERS
|
| 10 |
+
# -------------------------
|
| 11 |
+
|
| 12 |
+
def build_campaign_snapshot(dfs: dict) -> dict:
|
| 13 |
+
df = dfs["campaigns"]
|
| 14 |
+
|
| 15 |
+
total_spend = df["cost"].sum()
|
| 16 |
+
total_clicks = df["clicks"].sum()
|
| 17 |
+
total_impr = df["impressions"].sum()
|
| 18 |
+
total_leads = df["conversions"].sum()
|
| 19 |
+
|
| 20 |
+
ctr = (total_clicks / total_impr * 100) if total_impr else 0
|
| 21 |
+
cpl = (total_spend / total_leads) if total_leads else 0
|
| 22 |
+
|
| 23 |
+
return {
|
| 24 |
+
"spend": round(total_spend, 2),
|
| 25 |
+
"clicks": int(total_clicks),
|
| 26 |
+
"impressions": int(total_impr),
|
| 27 |
+
"leads": int(total_leads),
|
| 28 |
+
"ctr": round(ctr, 2),
|
| 29 |
+
"cpl": round(cpl, 2),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def build_simple_trend(df_hourly: pd.DataFrame) -> dict:
|
| 34 |
+
df = df_hourly.copy()
|
| 35 |
+
df["date"] = pd.to_datetime(df["date"])
|
| 36 |
+
df = df.sort_values("date")
|
| 37 |
+
|
| 38 |
+
mid = len(df) // 2
|
| 39 |
+
first, second = df.iloc[:mid], df.iloc[mid:]
|
| 40 |
+
|
| 41 |
+
def agg(x):
|
| 42 |
+
return {
|
| 43 |
+
"cost": x["cost"].sum(),
|
| 44 |
+
"clicks": x["clicks"].sum(),
|
| 45 |
+
"impressions": x["impressions"].sum(),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
a, b = agg(first), agg(second)
|
| 49 |
+
|
| 50 |
+
def pct(old, new):
|
| 51 |
+
return ((new - old) / old * 100) if old else 0
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"spend_change_pct": round(pct(a["cost"], b["cost"]), 1),
|
| 55 |
+
"clicks_change_pct": round(pct(a["clicks"], b["clicks"]), 1),
|
| 56 |
+
"impressions_change_pct": round(pct(a["impressions"], b["impressions"]), 1),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def build_top_drivers(dfs: dict) -> dict:
|
| 61 |
+
kw = dfs["keywords"].copy()
|
| 62 |
+
|
| 63 |
+
kw["cpl"] = kw["cost"] / kw["conversions"].replace(0, 1)
|
| 64 |
+
|
| 65 |
+
worst = kw.sort_values("cpl", ascending=False).head(3)
|
| 66 |
+
best = kw.sort_values("cpl", ascending=True).head(3)
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"best_keywords": best[["keyword", "cpl", "conversions"]].to_dict("records"),
|
| 70 |
+
"worst_keywords": worst[["keyword", "cpl", "conversions"]].to_dict("records"),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_signals(dfs: dict) -> dict:
|
| 75 |
+
kw = dfs["keywords"].copy()
|
| 76 |
+
|
| 77 |
+
kw["ctr"] = kw["clicks"] / kw["impressions"].replace(0, 1)
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
"low_ctr_ratio": round((kw["ctr"] < 0.02).mean(), 2),
|
| 81 |
+
"wasted_spend_ratio": round((kw["conversions"] == 0).mean(), 2),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# -------------------------
|
| 86 |
+
# 2. CONTEXT BUILDER
|
| 87 |
+
# -------------------------
|
| 88 |
+
|
| 89 |
+
def build_ads_analyst_context(dfs: dict) -> dict:
|
| 90 |
+
return {
|
| 91 |
+
"campaign": build_campaign_snapshot(dfs),
|
| 92 |
+
"trend": build_simple_trend(dfs["hourly"]),
|
| 93 |
+
"top_drivers": build_top_drivers(dfs),
|
| 94 |
+
"signals": build_signals(dfs),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# -------------------------
|
| 99 |
+
# 3. PROMPT BUILDER
|
| 100 |
+
# -------------------------
|
| 101 |
+
|
| 102 |
+
def build_ads_analyst_prompt(context: dict) -> str:
|
| 103 |
+
return f"""
|
| 104 |
+
You are an Ads performance analyst.
|
| 105 |
+
|
| 106 |
+
Return ONLY 3–5 insights.
|
| 107 |
+
No reasoning. No explanation of steps.
|
| 108 |
+
|
| 109 |
+
Campaign:
|
| 110 |
+
{context["campaign"]}
|
| 111 |
+
|
| 112 |
+
Trend:
|
| 113 |
+
{context["trend"]}
|
| 114 |
+
|
| 115 |
+
Top drivers:
|
| 116 |
+
{context["top_drivers"]}
|
| 117 |
+
|
| 118 |
+
Signals:
|
| 119 |
+
{context["signals"]}
|
| 120 |
+
|
| 121 |
+
Format:
|
| 122 |
+
- short bullet points only
|
| 123 |
+
- Simple language, no jargon
|
| 124 |
+
- Focus on actionable insights
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# -------------------------
|
| 129 |
+
# 4. MAIN ORCHESTRATOR (THIS IS WHAT MAIN.PY CALLS)
|
| 130 |
+
# -------------------------
|
| 131 |
+
|
| 132 |
+
def run_ads_analyst_card(dfs: dict) -> str:
|
| 133 |
+
context = build_ads_analyst_context(dfs)
|
| 134 |
+
prompt = build_ads_analyst_prompt(context)
|
| 135 |
+
|
| 136 |
+
print("\n========== PROMPT ==========")
|
| 137 |
+
print(prompt)
|
| 138 |
+
|
| 139 |
+
result = generate_explanation(prompt)
|
| 140 |
+
|
| 141 |
+
print("\n========== LLM OUTPUT ==========")
|
| 142 |
+
print(result)
|
| 143 |
+
|
| 144 |
+
return result
|
app/ads1/ads_queries.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ads1/reports.py
|
| 2 |
+
|
| 3 |
+
CAMPAIGNS_QUERY = """
|
| 4 |
+
SELECT
|
| 5 |
+
campaign.id,
|
| 6 |
+
campaign.name,
|
| 7 |
+
campaign.status,
|
| 8 |
+
metrics.impressions,
|
| 9 |
+
metrics.clicks,
|
| 10 |
+
metrics.cost_micros,
|
| 11 |
+
metrics.conversions,
|
| 12 |
+
metrics.ctr
|
| 13 |
+
FROM campaign
|
| 14 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
DEVICES_QUERY = """
|
| 18 |
+
SELECT
|
| 19 |
+
segments.device,
|
| 20 |
+
metrics.impressions,
|
| 21 |
+
metrics.clicks,
|
| 22 |
+
metrics.cost_micros
|
| 23 |
+
FROM campaign
|
| 24 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
HOURLY_QUERY = """
|
| 28 |
+
SELECT
|
| 29 |
+
segments.date,
|
| 30 |
+
segments.hour,
|
| 31 |
+
metrics.impressions,
|
| 32 |
+
metrics.clicks,
|
| 33 |
+
metrics.cost_micros
|
| 34 |
+
FROM campaign
|
| 35 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
GEO_QUERY = """
|
| 39 |
+
SELECT
|
| 40 |
+
geographic_view.country_criterion_id,
|
| 41 |
+
metrics.impressions,
|
| 42 |
+
metrics.clicks,
|
| 43 |
+
metrics.cost_micros
|
| 44 |
+
FROM geographic_view
|
| 45 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
SEARCH_TERMS_QUERY = """
|
| 49 |
+
SELECT
|
| 50 |
+
search_term_view.search_term,
|
| 51 |
+
metrics.impressions,
|
| 52 |
+
metrics.clicks,
|
| 53 |
+
metrics.cost_micros
|
| 54 |
+
FROM search_term_view
|
| 55 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
KEYWORDS_QUERY = """
|
| 59 |
+
SELECT
|
| 60 |
+
campaign.id,
|
| 61 |
+
campaign.name,
|
| 62 |
+
ad_group.id,
|
| 63 |
+
ad_group.name,
|
| 64 |
+
ad_group_criterion.keyword.text,
|
| 65 |
+
metrics.impressions,
|
| 66 |
+
metrics.clicks,
|
| 67 |
+
metrics.cost_micros,
|
| 68 |
+
metrics.conversions
|
| 69 |
+
FROM keyword_view
|
| 70 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
RECOMMENDATIONS_QUERY = """
|
| 74 |
+
SELECT
|
| 75 |
+
recommendation.type,
|
| 76 |
+
recommendation.resource_name,
|
| 77 |
+
recommendation.campaign
|
| 78 |
+
FROM recommendation
|
| 79 |
+
"""
|
app/ads1/budget_optimizer.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.recs.generate import generate_explanation
|
| 2 |
+
|
| 3 |
+
def build_campaign_summary(dfs: dict) -> dict:
|
| 4 |
+
df = dfs["keywords"]
|
| 5 |
+
|
| 6 |
+
total_cost = df["cost"].sum()
|
| 7 |
+
total_conv = df["conversions"].sum()
|
| 8 |
+
|
| 9 |
+
avg_cpl = (
|
| 10 |
+
total_cost / total_conv
|
| 11 |
+
if total_conv > 0
|
| 12 |
+
else 0
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
return {
|
| 16 |
+
"total_spend": round(total_cost, 2),
|
| 17 |
+
"total_conversions": int(total_conv),
|
| 18 |
+
"avg_cpl": round(avg_cpl, 2),
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
def build_scale_candidates(dfs: dict):
|
| 22 |
+
kw = dfs["keywords"].copy()
|
| 23 |
+
|
| 24 |
+
kw["cpl"] = (
|
| 25 |
+
kw["cost"] /
|
| 26 |
+
kw["conversions"].replace(0, 1)
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
account_avg = (
|
| 30 |
+
kw["cost"].sum() /
|
| 31 |
+
max(kw["conversions"].sum(), 1)
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
winners = kw[
|
| 35 |
+
(kw["conversions"] > 0)
|
| 36 |
+
& (kw["cpl"] < account_avg * 0.7)
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
winners = winners.sort_values(
|
| 40 |
+
"conversions",
|
| 41 |
+
ascending=False
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
return winners.head(3)[[
|
| 45 |
+
"keyword",
|
| 46 |
+
"ad_group_name",
|
| 47 |
+
"cpl",
|
| 48 |
+
"conversions"
|
| 49 |
+
]].to_dict("records")
|
| 50 |
+
|
| 51 |
+
def build_cut_candidates(dfs: dict):
|
| 52 |
+
kw = dfs["keywords"].copy()
|
| 53 |
+
|
| 54 |
+
kw["cpl"] = (
|
| 55 |
+
kw["cost"] /
|
| 56 |
+
kw["conversions"].replace(0, 1)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
account_avg = (
|
| 60 |
+
kw["cost"].sum() /
|
| 61 |
+
max(kw["conversions"].sum(), 1)
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
losers = kw[
|
| 65 |
+
(kw["cost"] > 0)
|
| 66 |
+
& (
|
| 67 |
+
(kw["conversions"] == 0)
|
| 68 |
+
|
|
| 69 |
+
(kw["cpl"] > account_avg * 1.5)
|
| 70 |
+
)
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
losers = losers.sort_values(
|
| 74 |
+
"cost",
|
| 75 |
+
ascending=False
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return losers.head(3)[[
|
| 79 |
+
"keyword",
|
| 80 |
+
"ad_group_name",
|
| 81 |
+
"cost",
|
| 82 |
+
"conversions",
|
| 83 |
+
"cpl"
|
| 84 |
+
]].to_dict("records")
|
| 85 |
+
|
| 86 |
+
def build_budget_optimizer_context(dfs: dict):
|
| 87 |
+
|
| 88 |
+
summary = build_campaign_summary(dfs)
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"summary": summary,
|
| 92 |
+
"scale_candidates": build_scale_candidates(dfs),
|
| 93 |
+
"cut_candidates": build_cut_candidates(dfs),
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def build_budget_optimizer_prompt(context):
|
| 97 |
+
|
| 98 |
+
return f"""
|
| 99 |
+
You are a Google Ads budget optimization expert.
|
| 100 |
+
|
| 101 |
+
Your goal is to identify where budget should be increased and where it should be reduced.
|
| 102 |
+
|
| 103 |
+
Campaign Summary:
|
| 104 |
+
{context["summary"]}
|
| 105 |
+
|
| 106 |
+
Best Opportunities To Scale:
|
| 107 |
+
{context["scale_candidates"]}
|
| 108 |
+
|
| 109 |
+
Worst Budget Drains:
|
| 110 |
+
{context["cut_candidates"]}
|
| 111 |
+
|
| 112 |
+
Rules:
|
| 113 |
+
- Return exactly 3 to 5 bullet points.
|
| 114 |
+
- Use simple business language.
|
| 115 |
+
- Mention where budget should increase.
|
| 116 |
+
- Mention where budget should decrease.
|
| 117 |
+
- Focus on efficiency and lead generation.
|
| 118 |
+
- No reasoning process.
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
def run_budget_optimizer_card(dfs):
|
| 122 |
+
|
| 123 |
+
context = build_budget_optimizer_context(dfs)
|
| 124 |
+
|
| 125 |
+
prompt = build_budget_optimizer_prompt(context)
|
| 126 |
+
|
| 127 |
+
rec = {
|
| 128 |
+
"campaign_id": "BUDGET_OPTIMIZER",
|
| 129 |
+
"type": "budget_optimization",
|
| 130 |
+
"action": "reallocate_budget",
|
| 131 |
+
"reason": prompt,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
return generate_explanation(rec)
|
app/ads1/connector.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging import config
|
| 2 |
+
from xmlrpc import client
|
| 3 |
+
import os
|
| 4 |
+
from google.ads.googleads.client import GoogleAdsClient
|
| 5 |
+
|
| 6 |
+
def get_client():
|
| 7 |
+
required = [
|
| 8 |
+
"GOOGLE_ADS_DEVELOPER_TOKEN",
|
| 9 |
+
"GOOGLE_ADS_CLIENT_ID",
|
| 10 |
+
"GOOGLE_ADS_CLIENT_SECRET",
|
| 11 |
+
"GOOGLE_ADS_REFRESH_TOKEN"
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
for r in required:
|
| 15 |
+
if not os.getenv(r):
|
| 16 |
+
raise ValueError(f"Missing env var: {r}")
|
| 17 |
+
|
| 18 |
+
config = {
|
| 19 |
+
"developer_token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN"),
|
| 20 |
+
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID"),
|
| 21 |
+
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET"),
|
| 22 |
+
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN"),
|
| 23 |
+
"login_customer_id": os.getenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID"),
|
| 24 |
+
"use_proto_plus": True
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
client = GoogleAdsClient.load_from_dict(config)
|
| 28 |
+
return client
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def list_campaigns(customer_id):
|
| 32 |
+
client = get_client()
|
| 33 |
+
ga_service = client.get_service("GoogleAdsService")
|
| 34 |
+
|
| 35 |
+
query = """
|
| 36 |
+
SELECT
|
| 37 |
+
campaign.id,
|
| 38 |
+
campaign.name,
|
| 39 |
+
campaign.status
|
| 40 |
+
FROM campaign
|
| 41 |
+
LIMIT 20
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
response = ga_service.search(customer_id=customer_id, query=query)
|
| 45 |
+
|
| 46 |
+
results = []
|
| 47 |
+
|
| 48 |
+
for row in response:
|
| 49 |
+
results.append({
|
| 50 |
+
"id": row.campaign.id,
|
| 51 |
+
"name": row.campaign.name,
|
| 52 |
+
"status": row.campaign.status.name
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
return results
|
| 56 |
+
|
| 57 |
+
def get_campaign_metrics(customer_id):
|
| 58 |
+
client = get_client()
|
| 59 |
+
ga_service = client.get_service("GoogleAdsService")
|
| 60 |
+
|
| 61 |
+
query = """
|
| 62 |
+
SELECT
|
| 63 |
+
campaign.id,
|
| 64 |
+
metrics.impressions,
|
| 65 |
+
metrics.clicks,
|
| 66 |
+
metrics.cost_micros,
|
| 67 |
+
metrics.conversions,
|
| 68 |
+
metrics.ctr
|
| 69 |
+
FROM campaign
|
| 70 |
+
WHERE segments.date DURING LAST_30_DAYS
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
response = ga_service.search(customer_id=customer_id, query=query)
|
| 74 |
+
|
| 75 |
+
data = []
|
| 76 |
+
|
| 77 |
+
for row in response:
|
| 78 |
+
data.append({
|
| 79 |
+
"campaign_id": row.campaign.id,
|
| 80 |
+
"impressions": row.metrics.impressions,
|
| 81 |
+
"clicks": row.metrics.clicks,
|
| 82 |
+
"cost": row.metrics.cost_micros / 1e6,
|
| 83 |
+
"conversions": row.metrics.conversions,
|
| 84 |
+
"ctr": row.metrics.ctr,
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
return data
|
app/ads1/fetch_ads_data.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/ads1/runner.py
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from app.ads1.connector import get_client
|
| 5 |
+
from app.ads1.ads_queries import (
|
| 6 |
+
CAMPAIGNS_QUERY,
|
| 7 |
+
DEVICES_QUERY,
|
| 8 |
+
HOURLY_QUERY,
|
| 9 |
+
GEO_QUERY,
|
| 10 |
+
SEARCH_TERMS_QUERY,
|
| 11 |
+
KEYWORDS_QUERY,
|
| 12 |
+
RECOMMENDATIONS_QUERY,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
def run_query(client, customer_id, query):
|
| 16 |
+
service = client.get_service("GoogleAdsService")
|
| 17 |
+
response = service.search(customer_id=customer_id, query=query)
|
| 18 |
+
|
| 19 |
+
rows = []
|
| 20 |
+
for r in response:
|
| 21 |
+
rows.append(r)
|
| 22 |
+
|
| 23 |
+
return rows
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def fetch_all_data(customer_id):
|
| 27 |
+
client = get_client()
|
| 28 |
+
|
| 29 |
+
service = client.get_service("GoogleAdsService")
|
| 30 |
+
|
| 31 |
+
def execute(query):
|
| 32 |
+
response = service.search(customer_id=customer_id, query=query)
|
| 33 |
+
return list(response)
|
| 34 |
+
|
| 35 |
+
print("🔄 Fetching campaigns...")
|
| 36 |
+
campaigns = execute(CAMPAIGNS_QUERY)
|
| 37 |
+
|
| 38 |
+
print("🔄 Fetching devices...")
|
| 39 |
+
devices = execute(DEVICES_QUERY)
|
| 40 |
+
|
| 41 |
+
print("🔄 Fetching hourly data...")
|
| 42 |
+
hourly = execute(HOURLY_QUERY)
|
| 43 |
+
|
| 44 |
+
print("🔄 Fetching geo data...")
|
| 45 |
+
geo = execute(GEO_QUERY)
|
| 46 |
+
|
| 47 |
+
print("🔄 Fetching search terms...")
|
| 48 |
+
search_terms = execute(SEARCH_TERMS_QUERY)
|
| 49 |
+
|
| 50 |
+
print("🔄 Fetching keywords...")
|
| 51 |
+
keywords = execute(KEYWORDS_QUERY)
|
| 52 |
+
|
| 53 |
+
print("🔄 Fetching recommendations...")
|
| 54 |
+
recommendations = execute(RECOMMENDATIONS_QUERY)
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"campaigns": campaigns,
|
| 58 |
+
"devices": devices,
|
| 59 |
+
"hourly": hourly,
|
| 60 |
+
"geo": geo,
|
| 61 |
+
"search_terms": search_terms,
|
| 62 |
+
"keywords": keywords,
|
| 63 |
+
"recommendations": recommendations
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def to_dataframes(raw_data):
|
| 68 |
+
dfs = {}
|
| 69 |
+
|
| 70 |
+
# Campaigns
|
| 71 |
+
dfs["campaigns"] = pd.DataFrame([
|
| 72 |
+
{
|
| 73 |
+
"id": r.campaign.id,
|
| 74 |
+
"name": r.campaign.name,
|
| 75 |
+
"status": r.campaign.status.name,
|
| 76 |
+
"impressions": r.metrics.impressions,
|
| 77 |
+
"clicks": r.metrics.clicks,
|
| 78 |
+
"cost": r.metrics.cost_micros / 1e6,
|
| 79 |
+
"ctr": r.metrics.ctr,
|
| 80 |
+
"conversions": r.metrics.conversions or 0
|
| 81 |
+
}
|
| 82 |
+
for r in raw_data["campaigns"]
|
| 83 |
+
])
|
| 84 |
+
|
| 85 |
+
# Devices
|
| 86 |
+
dfs["devices"] = pd.DataFrame([
|
| 87 |
+
{
|
| 88 |
+
"device": r.segments.device.name,
|
| 89 |
+
"clicks": r.metrics.clicks,
|
| 90 |
+
"impressions": r.metrics.impressions,
|
| 91 |
+
"cost": r.metrics.cost_micros / 1e6
|
| 92 |
+
}
|
| 93 |
+
for r in raw_data["devices"]
|
| 94 |
+
])
|
| 95 |
+
|
| 96 |
+
# Hourly
|
| 97 |
+
dfs["hourly"] = pd.DataFrame([
|
| 98 |
+
{
|
| 99 |
+
"date": r.segments.date,
|
| 100 |
+
"hour": r.segments.hour,
|
| 101 |
+
"clicks": r.metrics.clicks,
|
| 102 |
+
"impressions": r.metrics.impressions,
|
| 103 |
+
"cost": r.metrics.cost_micros / 1e6
|
| 104 |
+
}
|
| 105 |
+
for r in raw_data["hourly"]
|
| 106 |
+
])
|
| 107 |
+
|
| 108 |
+
# Geo
|
| 109 |
+
dfs["geo"] = pd.DataFrame([
|
| 110 |
+
{
|
| 111 |
+
"country_id": r.geographic_view.country_criterion_id,
|
| 112 |
+
"clicks": r.metrics.clicks,
|
| 113 |
+
"impressions": r.metrics.impressions,
|
| 114 |
+
"cost": r.metrics.cost_micros / 1e6
|
| 115 |
+
}
|
| 116 |
+
for r in raw_data["geo"]
|
| 117 |
+
])
|
| 118 |
+
|
| 119 |
+
# Search terms
|
| 120 |
+
dfs["search_terms"] = pd.DataFrame([
|
| 121 |
+
{
|
| 122 |
+
"search_term": r.search_term_view.search_term,
|
| 123 |
+
"clicks": r.metrics.clicks,
|
| 124 |
+
"impressions": r.metrics.impressions,
|
| 125 |
+
"cost": r.metrics.cost_micros / 1e6
|
| 126 |
+
}
|
| 127 |
+
for r in raw_data["search_terms"]
|
| 128 |
+
])
|
| 129 |
+
|
| 130 |
+
# Keywords
|
| 131 |
+
dfs["keywords"] = pd.DataFrame([
|
| 132 |
+
{
|
| 133 |
+
"campaign_id": r.campaign.id,
|
| 134 |
+
"campaign_name": r.campaign.name,
|
| 135 |
+
"ad_group_id": r.ad_group.id if r.ad_group else None,
|
| 136 |
+
"ad_group_name": r.ad_group.name if r.ad_group else None,
|
| 137 |
+
"keyword": r.ad_group_criterion.keyword.text if r.ad_group_criterion.keyword else None,
|
| 138 |
+
"clicks": r.metrics.clicks,
|
| 139 |
+
"impressions": r.metrics.impressions,
|
| 140 |
+
"cost": r.metrics.cost_micros / 1e6,
|
| 141 |
+
"conversions": r.metrics.conversions,
|
| 142 |
+
"ctr": r.metrics.ctr,
|
| 143 |
+
}
|
| 144 |
+
for r in raw_data["keywords"]
|
| 145 |
+
])
|
| 146 |
+
|
| 147 |
+
dfs["recommendations"] = pd.DataFrame([
|
| 148 |
+
{
|
| 149 |
+
"type": r.recommendation.type.name,
|
| 150 |
+
"resource_name": r.recommendation.resource_name,
|
| 151 |
+
"campaign": r.recommendation.campaign
|
| 152 |
+
}
|
| 153 |
+
for r in raw_data["recommendations"]
|
| 154 |
+
])
|
| 155 |
+
|
| 156 |
+
return dfs
|
app/controller/session_loader.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.ads1.fetch_ads_data import fetch_all_data, to_dataframes
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
_cached_dfs = None
|
| 5 |
+
|
| 6 |
+
def load_google_ads_data(force_refresh=False):
|
| 7 |
+
global _cached_dfs
|
| 8 |
+
|
| 9 |
+
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID")
|
| 10 |
+
|
| 11 |
+
if not customer_id:
|
| 12 |
+
raise ValueError("GOOGLE_ADS_CUSTOMER_ID missing")
|
| 13 |
+
|
| 14 |
+
if _cached_dfs is not None and not force_refresh:
|
| 15 |
+
return _cached_dfs
|
| 16 |
+
|
| 17 |
+
raw = fetch_all_data(customer_id)
|
| 18 |
+
dfs = to_dataframes(raw)
|
| 19 |
+
|
| 20 |
+
_cached_dfs = dfs
|
| 21 |
+
return dfs
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
| 2 |
+
from sqlalchemy.orm import declarative_base, relationship
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
Base = declarative_base()
|
| 6 |
+
|
| 7 |
+
class Campaign(Base):
|
| 8 |
+
__tablename__ = "campaigns"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True)
|
| 11 |
+
google_campaign_id = Column(String, unique=True)
|
| 12 |
+
name = Column(String)
|
| 13 |
+
|
| 14 |
+
budget = Column(Float)
|
| 15 |
+
spend = Column(Float)
|
| 16 |
+
clicks = Column(Integer)
|
| 17 |
+
impressions = Column(Integer)
|
| 18 |
+
|
| 19 |
+
ctr = Column(Float)
|
| 20 |
+
leads = Column(Integer)
|
| 21 |
+
cpl = Column(Float)
|
| 22 |
+
|
| 23 |
+
last_synced = Column(DateTime, default=datetime.utcnow)
|
| 24 |
+
|
| 25 |
+
recommendations = relationship("Recommendation", back_populates="campaign")
|
| 26 |
+
|
| 27 |
+
class Recommendation(Base):
|
| 28 |
+
__tablename__ = "recommendations"
|
| 29 |
+
|
| 30 |
+
id = Column(Integer, primary_key=True)
|
| 31 |
+
|
| 32 |
+
campaign_id = Column(Integer, ForeignKey("campaigns.id"))
|
| 33 |
+
|
| 34 |
+
recommendation_type = Column(String)
|
| 35 |
+
action = Column(String)
|
| 36 |
+
reason = Column(String)
|
| 37 |
+
|
| 38 |
+
status = Column(String, default="Pending") # Pending / Approved / Rejected
|
| 39 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 40 |
+
|
| 41 |
+
campaign = relationship("Campaign", back_populates="recommendations")
|
app/db/repo.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker
|
| 3 |
+
|
| 4 |
+
from app.db.models import Base, Campaign, Recommendation
|
| 5 |
+
|
| 6 |
+
engine = create_engine("sqlite:///ads.db")
|
| 7 |
+
|
| 8 |
+
SessionLocal = sessionmaker(
|
| 9 |
+
autocommit=False,
|
| 10 |
+
autoflush=False,
|
| 11 |
+
bind=engine
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
def init_db():
|
| 15 |
+
Base.metadata.create_all(bind=engine)
|
| 16 |
+
|
| 17 |
+
def get_campaigns():
|
| 18 |
+
session = SessionLocal()
|
| 19 |
+
try:
|
| 20 |
+
return session.query(Campaign).all()
|
| 21 |
+
finally:
|
| 22 |
+
session.close()
|
| 23 |
+
|
| 24 |
+
def get_recommendations():
|
| 25 |
+
session = SessionLocal()
|
| 26 |
+
try:
|
| 27 |
+
return session.query(Recommendation).all()
|
| 28 |
+
finally:
|
| 29 |
+
session.close()
|
| 30 |
+
|
| 31 |
+
def approve_recommendation(recommendation_id: int):
|
| 32 |
+
session = SessionLocal()
|
| 33 |
+
try:
|
| 34 |
+
recommendation = (
|
| 35 |
+
session.query(Recommendation)
|
| 36 |
+
.filter(Recommendation.id == recommendation_id)
|
| 37 |
+
.first()
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
if recommendation:
|
| 41 |
+
recommendation.status = "Approved"
|
| 42 |
+
session.commit()
|
| 43 |
+
|
| 44 |
+
return "Recommendation approved"
|
| 45 |
+
finally:
|
| 46 |
+
session.close()
|
| 47 |
+
|
| 48 |
+
def reject_recommendation(recommendation_id: int):
|
| 49 |
+
session = SessionLocal()
|
| 50 |
+
try:
|
| 51 |
+
recommendation = (
|
| 52 |
+
session.query(Recommendation)
|
| 53 |
+
.filter(Recommendation.id == recommendation_id)
|
| 54 |
+
.first()
|
| 55 |
+
)
|
| 56 |
+
if recommendation:
|
| 57 |
+
recommendation.status = "Rejected"
|
| 58 |
+
session.commit()
|
| 59 |
+
return "Recommendation rejected"
|
| 60 |
+
finally:
|
| 61 |
+
session.close()
|
app/models/llm.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from huggingface_hub import hf_hub_download
|
| 2 |
+
from llama_cpp import Llama
|
| 3 |
+
|
| 4 |
+
_model = None
|
| 5 |
+
|
| 6 |
+
def load_model():
|
| 7 |
+
global _model
|
| 8 |
+
|
| 9 |
+
if _model is not None:
|
| 10 |
+
return _model
|
| 11 |
+
|
| 12 |
+
model_path = hf_hub_download(
|
| 13 |
+
repo_id="Abiray/MiniCPM5-1B-GGUF",
|
| 14 |
+
filename="minicpm5-1b-Q4_K_M.gguf",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
_model = Llama(
|
| 18 |
+
model_path=model_path,
|
| 19 |
+
n_ctx=4096,
|
| 20 |
+
n_threads=4,
|
| 21 |
+
verbose=False,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
return _model
|
app/recs/generate.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Iterator
|
| 2 |
+
import re
|
| 3 |
+
from app.models.llm import load_model
|
| 4 |
+
|
| 5 |
+
TARGET_CPL = 20.0
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def fallback_explanation(rec: Dict = None) -> str:
|
| 9 |
+
return "This recommendation was generated from campaign performance metrics."
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def sanitize_explanation(text: str, rec: Dict = None) -> str:
|
| 13 |
+
cleaned = re.sub(r"\s+", " ", text).strip()
|
| 14 |
+
|
| 15 |
+
if not cleaned or len(cleaned) < 10:
|
| 16 |
+
return fallback_explanation(rec)
|
| 17 |
+
|
| 18 |
+
return cleaned
|
| 19 |
+
|
| 20 |
+
def generate_explanation(prompt: str, rec: Dict = None, stream: bool = False):
|
| 21 |
+
print("🔥 LLM CALLED")
|
| 22 |
+
|
| 23 |
+
llm = load_model()
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
|
| 27 |
+
response = llm.create_chat_completion(
|
| 28 |
+
messages=[
|
| 29 |
+
{"role": "system", "content": "You are an expert marketing analyst for Google Ads.You MUST NOT output reasoning, thinking, or tags like <think>.You MUST ONLY output final answer."},
|
| 30 |
+
{"role": "user", "content": prompt}
|
| 31 |
+
],
|
| 32 |
+
temperature=0.7,)
|
| 33 |
+
raw = response["choices"][0]["message"]["content"]
|
| 34 |
+
|
| 35 |
+
clean = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
|
| 36 |
+
clean = re.sub(r"(?s).*?Reasoning:.*?\n", "", clean)
|
| 37 |
+
clean = re.sub(r"(?s).*?Step \d+.*?\n", "", clean)
|
| 38 |
+
|
| 39 |
+
print(clean)
|
| 40 |
+
return clean
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print("❌ LLM ERROR:", e)
|
| 44 |
+
return fallback_explanation(rec)
|
app/recs/rules.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List
|
| 2 |
+
|
| 3 |
+
TARGET_CPL = 20.0
|
| 4 |
+
CTR_THRESHOLD = 2.0
|
| 5 |
+
|
| 6 |
+
def generate_recommendations(metrics: List[Dict]) -> List[Dict]:
|
| 7 |
+
"""
|
| 8 |
+
Rule engine that converts metrics → recommendations
|
| 9 |
+
"""
|
| 10 |
+
recommendations = []
|
| 11 |
+
|
| 12 |
+
for m in metrics:
|
| 13 |
+
campaign_id = m["campaign_id"]
|
| 14 |
+
cpl = m["cpl"]
|
| 15 |
+
ctr = m["ctr"]
|
| 16 |
+
|
| 17 |
+
# Rule 1: High CPL
|
| 18 |
+
if cpl > TARGET_CPL * 1.5:
|
| 19 |
+
recommendations.append({
|
| 20 |
+
"campaign_id": campaign_id,
|
| 21 |
+
"type": "high_cpl",
|
| 22 |
+
"action": "reduce_budget",
|
| 23 |
+
"reason": f"CPL {cpl} is significantly above target {TARGET_CPL}",
|
| 24 |
+
"cpl": cpl,
|
| 25 |
+
"target_cpl": TARGET_CPL,
|
| 26 |
+
"ctr": ctr,
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
# Rule 2: Strong campaign
|
| 30 |
+
elif cpl < TARGET_CPL * 0.8:
|
| 31 |
+
recommendations.append({
|
| 32 |
+
"campaign_id": campaign_id,
|
| 33 |
+
"type": "strong_campaign",
|
| 34 |
+
"action": "increase_budget",
|
| 35 |
+
"reason": f"CPL {cpl} is well below target {TARGET_CPL}",
|
| 36 |
+
"cpl": cpl,
|
| 37 |
+
"target_cpl": TARGET_CPL,
|
| 38 |
+
"ctr": ctr,
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
# Rule 3: Low CTR
|
| 42 |
+
if ctr < CTR_THRESHOLD:
|
| 43 |
+
recommendations.append({
|
| 44 |
+
"campaign_id": campaign_id,
|
| 45 |
+
"type": "low_ctr",
|
| 46 |
+
"action": "review_ad_copy",
|
| 47 |
+
"reason": f"CTR {ctr}% is below threshold {CTR_THRESHOLD}%",
|
| 48 |
+
"cpl": cpl,
|
| 49 |
+
"target_cpl": TARGET_CPL,
|
| 50 |
+
"ctr": ctr,
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
return recommendations
|
app/ui/dashboard.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from app.controller.session_loader import load_google_ads_data
|
| 4 |
+
|
| 5 |
+
# DATA LOADER
|
| 6 |
+
# -------------------------
|
| 7 |
+
def load_dashboard():
|
| 8 |
+
dfs = load_google_ads_data()
|
| 9 |
+
df = dfs["campaigns"].copy()
|
| 10 |
+
|
| 11 |
+
if df.empty:
|
| 12 |
+
return (
|
| 13 |
+
0, 0, 0, 0,
|
| 14 |
+
pd.DataFrame(columns=[
|
| 15 |
+
"Campaign", "Spend", "Leads", "CPL", "CTR"
|
| 16 |
+
])
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# derive missing fields safely
|
| 20 |
+
df["leads"] = df["conversions"] if "conversions" in df.columns else 0
|
| 21 |
+
df["cpl"] = df["cost"] / df["leads"].replace(0, 1)
|
| 22 |
+
df["ctr"] = df["ctr"]
|
| 23 |
+
|
| 24 |
+
formatted = pd.DataFrame({
|
| 25 |
+
"Campaign": df["name"],
|
| 26 |
+
"Spend": df["cost"],
|
| 27 |
+
"Leads": df["leads"],
|
| 28 |
+
"CPL": df["cpl"],
|
| 29 |
+
"CTR": df["ctr"],
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
round(formatted["Spend"].sum(), 2),
|
| 34 |
+
int(formatted["Leads"].sum()),
|
| 35 |
+
round(formatted["CPL"].mean(), 2),
|
| 36 |
+
len(formatted),
|
| 37 |
+
formatted
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# UI BUILDER
|
| 41 |
+
# -------------------------
|
| 42 |
+
def build_dashboard():
|
| 43 |
+
gr.Markdown("## Campaign Dashboard")
|
| 44 |
+
|
| 45 |
+
with gr.Row():
|
| 46 |
+
total_spend = gr.Number(label="Total Spend")
|
| 47 |
+
total_leads = gr.Number(label="Total Leads")
|
| 48 |
+
average_cpl = gr.Number(label="Average CPL")
|
| 49 |
+
active_campaigns = gr.Number(label="Active Campaigns")
|
| 50 |
+
|
| 51 |
+
campaign_table = gr.Dataframe(
|
| 52 |
+
label="Campaign Performance",
|
| 53 |
+
interactive=True # IMPORTANT: needed for row click
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
refresh_btn = gr.Button("Refresh Dashboard")
|
| 57 |
+
refresh_btn.click(
|
| 58 |
+
fn=load_dashboard,
|
| 59 |
+
outputs=[
|
| 60 |
+
total_spend,
|
| 61 |
+
total_leads,
|
| 62 |
+
average_cpl,
|
| 63 |
+
active_campaigns,
|
| 64 |
+
campaign_table,
|
| 65 |
+
],
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# ✅ IMPORTANT FIX: return table so main.py can attach .select()
|
| 69 |
+
return campaign_table
|
app/ui/recommendations.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
from app.db.repo import (
|
| 5 |
+
get_recommendations,
|
| 6 |
+
approve_recommendation,
|
| 7 |
+
reject_recommendation,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
def load_recommendations():
|
| 11 |
+
recommendations = get_recommendations()
|
| 12 |
+
data = []
|
| 13 |
+
for recommendation in recommendations:
|
| 14 |
+
campaign_name = ""
|
| 15 |
+
if recommendation.campaign:
|
| 16 |
+
campaign_name = recommendation.campaign.name
|
| 17 |
+
data.append(
|
| 18 |
+
{
|
| 19 |
+
"ID": recommendation.id,
|
| 20 |
+
"Campaign": campaign_name,
|
| 21 |
+
"Recommendation": recommendation.action,
|
| 22 |
+
"Status": recommendation.status,
|
| 23 |
+
}
|
| 24 |
+
)
|
| 25 |
+
return pd.DataFrame(data)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def approve_action(rec_id):
|
| 29 |
+
approve_recommendation(int(rec_id))
|
| 30 |
+
return (
|
| 31 |
+
"Recommendation approved",
|
| 32 |
+
load_recommendations(),
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def reject_action(rec_id):
|
| 36 |
+
reject_recommendation(int(rec_id))
|
| 37 |
+
return (
|
| 38 |
+
"Recommendation rejected",
|
| 39 |
+
load_recommendations(),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def build_recommendations_page():
|
| 43 |
+
gr.Markdown("## Recommendations")
|
| 44 |
+
recommendation_table = gr.Dataframe(
|
| 45 |
+
label="Recommendations",
|
| 46 |
+
interactive=False,
|
| 47 |
+
)
|
| 48 |
+
recommendation_id = gr.Number(
|
| 49 |
+
label="Recommendation ID"
|
| 50 |
+
)
|
| 51 |
+
status_message = gr.Textbox(
|
| 52 |
+
label="Status"
|
| 53 |
+
)
|
| 54 |
+
with gr.Row():
|
| 55 |
+
approve_btn = gr.Button("Approve")
|
| 56 |
+
reject_btn = gr.Button("Reject")
|
| 57 |
+
refresh_btn = gr.Button("Refresh")
|
| 58 |
+
refresh_btn.click(
|
| 59 |
+
fn=load_recommendations,
|
| 60 |
+
outputs=recommendation_table,
|
| 61 |
+
)
|
| 62 |
+
approve_btn.click(
|
| 63 |
+
fn=approve_action,
|
| 64 |
+
inputs=recommendation_id,
|
| 65 |
+
outputs=[
|
| 66 |
+
status_message,
|
| 67 |
+
recommendation_table,
|
| 68 |
+
],
|
| 69 |
+
)
|
| 70 |
+
reject_btn.click(
|
| 71 |
+
fn=reject_action,
|
| 72 |
+
inputs=recommendation_id,
|
| 73 |
+
outputs=[
|
| 74 |
+
status_message,
|
| 75 |
+
recommendation_table,
|
| 76 |
+
],
|
| 77 |
+
)
|
docs/superpowers/plans/2026-06-03-ads-automation-prd.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ads Automation Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED: Use the `subagent-driven-development` agent (recommended) or `executing-plans` agent to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Build v1 of a Google Ads recommendation-and-approval system for a preschool that monitors campaigns, generates structured recommendations using MiniCPM5-1B, and applies approved safe changes (budget adjustments, pause/resume, add negative keywords). Deploy as a Gradio app on a Hugging Face Space (CPU) with SQLite as the app source of truth.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Single Gradio app with an embedded scheduler (APScheduler + SQLite jobstore). Deterministic rule engine produces candidate deltas; MiniCPM5 generates human-readable recommendation text. Google Ads integration uses `google-ads` Python client with admin OAuth. App persists to SQLite and syncs to Google Sheets for visibility.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3.10+, Gradio, SQLAlchemy, APScheduler, google-ads, google-auth, llama-cpp-python, pandas, requests, pytest.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
### Task 1: Scaffold project layout
|
| 14 |
+
|
| 15 |
+
**Files:**
|
| 16 |
+
- Create: `app/__init__.py`
|
| 17 |
+
- Create: `app/main.py` (Gradio entrypoint)
|
| 18 |
+
- Create: `app/ads/connector.py`
|
| 19 |
+
- Create: `app/db/models.py`
|
| 20 |
+
- Create: `app/db/repo.py`
|
| 21 |
+
- Create: `app/recs/rules.py`
|
| 22 |
+
- Create: `app/models/llm.py`
|
| 23 |
+
- Create: `app/scheduler/jobs.py`
|
| 24 |
+
- Create: `scripts/seed_demo.py`
|
| 25 |
+
- Create: `requirements.txt`
|
| 26 |
+
- Create: `README.md`
|
| 27 |
+
|
| 28 |
+
- [ ] Step 1: Create repository layout and `requirements.txt`.
|
| 29 |
+
|
| 30 |
+
Create `requirements.txt` with:
|
| 31 |
+
|
| 32 |
+
```text
|
| 33 |
+
gradio
|
| 34 |
+
sqlalchemy
|
| 35 |
+
alembic
|
| 36 |
+
apscheduler
|
| 37 |
+
google-ads
|
| 38 |
+
google-auth
|
| 39 |
+
pandas
|
| 40 |
+
requests
|
| 41 |
+
llama-cpp-python
|
| 42 |
+
pytest
|
| 43 |
+
python-dotenv
|
| 44 |
+
gspread
|
| 45 |
+
oauth2client
|
| 46 |
+
|
| 47 |
+
tqdm
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
Run locally to verify environment installs:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python -m venv .venv
|
| 54 |
+
.venv\Scripts\activate
|
| 55 |
+
pip install -r requirements.txt
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Expected: packages install without fatal errors.
|
| 59 |
+
|
| 60 |
+
- [ ] Step 2: Commit the scaffold files (empty imports and module docstrings acceptable) and run a smoke start of `app/main.py` to ensure import graph is valid.
|
| 61 |
+
|
| 62 |
+
Run:
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
python -c "import app; print('scaffold ok')"
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
Expected: prints `scaffold ok`.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
### Task 2: Implement SQLite schema + ORM
|
| 73 |
+
|
| 74 |
+
**Files:**
|
| 75 |
+
- Modify: `app/db/models.py`
|
| 76 |
+
- Create: `app/db/migrate.py` (simple create-tables script)
|
| 77 |
+
- Create: `app/db/repo.py` (CRUD helpers)
|
| 78 |
+
|
| 79 |
+
- [ ] Step 1: Define SQLAlchemy models for `Campaign`, `AdGroup`, `Keyword`, `Lead`, `Recommendation`, `AuditLog`.
|
| 80 |
+
|
| 81 |
+
Example `Campaign` model snippet (to include in file):
|
| 82 |
+
|
| 83 |
+
```python
|
| 84 |
+
from sqlalchemy import Column, Integer, String, Boolean, Float, JSON, DateTime
|
| 85 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 86 |
+
from datetime import datetime
|
| 87 |
+
|
| 88 |
+
Base = declarative_base()
|
| 89 |
+
|
| 90 |
+
class Campaign(Base):
|
| 91 |
+
__tablename__ = 'campaigns'
|
| 92 |
+
id = Column(Integer, primary_key=True)
|
| 93 |
+
google_campaign_id = Column(String, unique=True, nullable=False)
|
| 94 |
+
name = Column(String)
|
| 95 |
+
managed = Column(Boolean, default=False)
|
| 96 |
+
budget = Column(Float)
|
| 97 |
+
target_cpl_override = Column(Float, nullable=True)
|
| 98 |
+
last_synced = Column(DateTime, default=datetime.utcnow)
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
- [ ] Step 2: Implement `migrate.py` to create tables.
|
| 102 |
+
|
| 103 |
+
Run to verify:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
python app/db/migrate.py
|
| 107 |
+
python - <<'PY'
|
| 108 |
+
from app.db.repo import SessionLocal
|
| 109 |
+
print('db ok')
|
| 110 |
+
PY
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Expected: DB file created and `db ok` printed.
|
| 114 |
+
|
| 115 |
+
- [ ] Step 3: Add unit tests for model creation in `tests/test_db.py` using `pytest`.
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
### Task 3: Google Ads connector + admin OAuth
|
| 120 |
+
|
| 121 |
+
**Files:**
|
| 122 |
+
- Create: `app/ads/connector.py` (wraps `google-ads` client)
|
| 123 |
+
- Create: `app/ads/oauth.py` (helper for OAuth flow)
|
| 124 |
+
- Modify: `app/main.py` (admin settings UI to start OAuth or paste tokens)
|
| 125 |
+
|
| 126 |
+
- [ ] Step 1: Implement OAuth helper that can accept client_id/client_secret and produce a refresh_token (manual paste fallback supported).
|
| 127 |
+
|
| 128 |
+
- [ ] Step 2: Implement connector functions:
|
| 129 |
+
- `list_campaigns()` (returns campaigns metadata)
|
| 130 |
+
- `get_campaign_metrics(campaign_ids, start_date, end_date)` (returns spend, clicks, impressions, CTR, conversions/leads)
|
| 131 |
+
- `apply_budget_change(campaign_id, new_budget)`
|
| 132 |
+
- `pause_keyword(keyword_id)`
|
| 133 |
+
- `add_negative_keyword(campaign_id, phrase)`
|
| 134 |
+
|
| 135 |
+
- [ ] Step 3: Mock Google Ads in tests `tests/test_ads_connector.py` using recorded fixtures or a simple interface stub.
|
| 136 |
+
|
| 137 |
+
Verification:
|
| 138 |
+
|
| 139 |
+
- Run `python -c "from app.ads.connector import list_campaigns; print(list_campaigns()[:1])"` with mocked creds to ensure no crashes.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
### Task 4: Rule engine (deterministic signals)
|
| 144 |
+
|
| 145 |
+
**Files:**
|
| 146 |
+
- Modify: `app/recs/rules.py`
|
| 147 |
+
|
| 148 |
+
- [ ] Step 1: Implement moving-average smoothing (3-day simple moving average) and min-sample guards.
|
| 149 |
+
|
| 150 |
+
- [ ] Step 2: Implement the default rules from the spec. Each rule returns a candidate delta dict when fired.
|
| 151 |
+
|
| 152 |
+
- [ ] Step 3: Unit tests in `tests/test_rules.py` covering each rule with synthetic metric inputs and expected candidate deltas.
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
### Task 5: MiniCPM5 wrapper and prompt pipeline
|
| 157 |
+
|
| 158 |
+
**Files:**
|
| 159 |
+
- Modify/Create: `app/models/llm.py`
|
| 160 |
+
- Modify: `app/recs/generate.py` (build prompt, call llm, validate JSON)
|
| 161 |
+
|
| 162 |
+
- [ ] Step 1: Implement a thin wrapper using `llama_cpp` from `llama-cpp-python` to load GGUF from HF Hub path. Support a `load_model(llm_repo_id, llm_filename, cache_dir)` call.
|
| 163 |
+
|
| 164 |
+
- [ ] Step 2: Implement prompt template that accepts a JSON payload and instructs the model to return a single `recommendation` JSON object following the schema. Example instructions must enforce JSON-only output.
|
| 165 |
+
|
| 166 |
+
- [ ] Step 3: Implement output validation using `jsonschema` or Python checks; reject and retry + fallback to deterministic textual reason if parsing fails.
|
| 167 |
+
|
| 168 |
+
- [ ] Step 4: Unit tests for `generate.py` to assert correct parseable output given a mocked model runner.
|
| 169 |
+
|
| 170 |
+
Notes: target inference via `llama-cpp-python` with the quantized GGUF. For Space CPU mode, expect slower responses; implement a request timeout and a cached most-recent recommendation for UI responsiveness.
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
### Task 6: Gradio UI pages
|
| 175 |
+
|
| 176 |
+
**Files:**
|
| 177 |
+
- Modify/Create: `app/main.py` (Gradio app)
|
| 178 |
+
- Modify/Create: `app/ui/dashboard.py`
|
| 179 |
+
- Modify/Create: `app/ui/campaigns.py`
|
| 180 |
+
- Modify/Create: `app/ui/recommendations.py`
|
| 181 |
+
- Modify/Create: `app/ui/leads.py`
|
| 182 |
+
- Modify/Create: `app/ui/admin.py`
|
| 183 |
+
|
| 184 |
+
- [ ] Step 1: Implement `Main Dashboard` with summary cards and a small time-series chart (use `pandas` to prepare data and `gradio` components to display). Include a button to trigger on-demand review.
|
| 185 |
+
|
| 186 |
+
- [ ] Step 2: `Campaigns` page: table with per-campaign KPIs and a toggle to mark campaign as `managed`.
|
| 187 |
+
|
| 188 |
+
- [ ] Step 3: `Recommendations` page: list recommendations, view details, Approve/Reject controls with scheduling and staged rollout UI.
|
| 189 |
+
|
| 190 |
+
- [ ] Step 4: `Lead Manager` page: table to mark leads as `booked`, manual-add lead form, and export button to Google Sheets.
|
| 191 |
+
|
| 192 |
+
- [ ] Step 5: `Admin` page: set global `target_cpl`, manage HF Secrets link, manual OAuth flow start.
|
| 193 |
+
|
| 194 |
+
Verification: start the Gradio app locally:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
python app/main.py
|
| 198 |
+
# visit http://localhost:7860
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Expected: App loads, pages render, no JS errors.
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
### Task 7: Scheduler + Auto-apply
|
| 206 |
+
|
| 207 |
+
**Files:**
|
| 208 |
+
- Modify/Create: `app/scheduler/jobs.py`
|
| 209 |
+
- Modify/Create: `app/scheduler/bootstrap.py`
|
| 210 |
+
|
| 211 |
+
- [ ] Step 1: Wire APScheduler with SQLite jobstore and add daily job to run the review loop.
|
| 212 |
+
|
| 213 |
+
- [ ] Step 2: Implement apply jobs that call the Ads connector to enact approved changes and write audit logs and rollback snapshots.
|
| 214 |
+
|
| 215 |
+
- [ ] Step 3: Tests: `tests/test_scheduler.py` with in-memory jobstore verifying a job runs and changes recorded in DB.
|
| 216 |
+
|
| 217 |
+
---
|
| 218 |
+
|
| 219 |
+
### Task 8: Google Sheets sync & Lead capture
|
| 220 |
+
|
| 221 |
+
**Files:**
|
| 222 |
+
- Create: `scripts/sheet_sync.py`
|
| 223 |
+
- Modify: `app/leads/sync.py`
|
| 224 |
+
|
| 225 |
+
- [ ] Step 1: Implement Google Sheets API write-only sync for leads; use service-account or OAuth depending on deployment constraints.
|
| 226 |
+
|
| 227 |
+
- [ ] Step 2: Implement landing page form that writes to DB and triggers immediate UI visibility.
|
| 228 |
+
|
| 229 |
+
- [ ] Step 3: Tests: `tests/test_sheets.py` with a mocked sheets client.
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
### Task 9: Seeded demo data & testing
|
| 234 |
+
|
| 235 |
+
**Files:**
|
| 236 |
+
- Modify/Create: `scripts/seed_demo.py`
|
| 237 |
+
|
| 238 |
+
- [ ] Step 1: Implement seed script that creates sample campaigns, keywords, synthetic metrics and leads to exercise rules and model pipeline.
|
| 239 |
+
|
| 240 |
+
- [ ] Step 2: Add end-to-end smoke test `tests/test_e2e.py` that runs seed, triggers a review, generates recommendations (mocked LLM), and simulates approval + apply (with Ads connector mocked).
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
### Task 10: Documentation & HF Space deploy
|
| 245 |
+
|
| 246 |
+
**Files:**
|
| 247 |
+
- Modify/Create: `README.md` (run/deploy instructions)
|
| 248 |
+
- Modify/Create: `Dockerfile` or HF `requirements.txt` and `runtime.txt` if needed
|
| 249 |
+
|
| 250 |
+
- [ ] Step 1: Write deployment steps for HF Space (including HF Secrets setup and model cache instructions).
|
| 251 |
+
|
| 252 |
+
- [ ] Step 2: Provide a small `try it` section in `README.md` showing how to run locally and how to seed demo data.
|
| 253 |
+
|
| 254 |
+
Example local run commands:
|
| 255 |
+
|
| 256 |
+
```bash
|
| 257 |
+
# local dev
|
| 258 |
+
python -m venv .venv
|
| 259 |
+
.venv\Scripts\activate
|
| 260 |
+
pip install -r requirements.txt
|
| 261 |
+
python app/db/migrate.py
|
| 262 |
+
python scripts/seed_demo.py
|
| 263 |
+
python app/main.py
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
**Expected:** Developer can run seeded demo locally and browse to the Gradio app.
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## Self-Review Checklist
|
| 271 |
+
|
| 272 |
+
1. Spec coverage: every requirement in the design spec maps to Tasks 1–10 above.
|
| 273 |
+
2. No placeholders: each step includes the commands/files needed to implement and test.
|
| 274 |
+
3. Type consistency: models, repo, and file names used consistently above.
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## Handoff / Execution choices
|
| 279 |
+
|
| 280 |
+
Plan complete and saved to `docs/superpowers/plans/2026-06-03-ads-automation-prd.md`. Two execution options:
|
| 281 |
+
|
| 282 |
+
1. Subagent-Driven (recommended) — run `subagent-driven-development` agent per task, review between tasks.
|
| 283 |
+
2. Inline Execution — I (or the `executing-plans` agent) implement tasks in this session according to the checklist.
|
| 284 |
+
|
| 285 |
+
Which approach do you want? Reply with `subagent` or `inline`.
|
docs/superpowers/plans/simplified.md
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ads Automation Hackathon Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED: Use the `subagent-driven-development` agent (recommended) or `executing-plans` agent to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Build a Google Ads recommendation dashboard for a preschool that monitors campaign performance, generates AI-powered recommendations using MiniCPM5-1B, and allows a human to review, approve, or reject recommendations. Deploy as a Gradio app on a Hugging Face Space (CPU) with SQLite as the source of truth.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Single Gradio application. Google Ads metrics are imported into SQLite. A deterministic rule engine identifies opportunities and issues. MiniCPM5 generates human-readable explanations for recommendations. Recommendations are displayed in a dashboard where users can approve or reject them. No automatic ad changes are performed.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3.10+, Gradio, SQLAlchemy, google-ads, pandas, llama-cpp-python, pytest, python-dotenv.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
### Task 1: Scaffold Project Layout
|
| 14 |
+
|
| 15 |
+
**Files:**
|
| 16 |
+
|
| 17 |
+
* Create: `app/__init__.py`
|
| 18 |
+
|
| 19 |
+
* Create: `app/main.py`
|
| 20 |
+
|
| 21 |
+
* Create: `app/ads/connector.py`
|
| 22 |
+
|
| 23 |
+
* Create: `app/db/models.py`
|
| 24 |
+
|
| 25 |
+
* Create: `app/db/repo.py`
|
| 26 |
+
|
| 27 |
+
* Create: `app/recs/rules.py`
|
| 28 |
+
|
| 29 |
+
* Create: `app/recs/generate.py`
|
| 30 |
+
|
| 31 |
+
* Create: `app/models/llm.py`
|
| 32 |
+
|
| 33 |
+
* Create: `app/ui/dashboard.py`
|
| 34 |
+
|
| 35 |
+
* Create: `app/ui/recommendations.py`
|
| 36 |
+
|
| 37 |
+
* Create: `scripts/seed_demo.py`
|
| 38 |
+
|
| 39 |
+
* Create: `requirements.txt`
|
| 40 |
+
|
| 41 |
+
* Create: `README.md`
|
| 42 |
+
|
| 43 |
+
* [ ] Step 1: Create repository structure and install dependencies.
|
| 44 |
+
|
| 45 |
+
Requirements:
|
| 46 |
+
|
| 47 |
+
```text
|
| 48 |
+
gradio
|
| 49 |
+
sqlalchemy
|
| 50 |
+
google-ads
|
| 51 |
+
pandas
|
| 52 |
+
llama-cpp-python
|
| 53 |
+
pytest
|
| 54 |
+
python-dotenv
|
| 55 |
+
requests
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Verify:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
python -m venv .venv
|
| 62 |
+
pip install -r requirements.txt
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Expected: all packages install successfully.
|
| 66 |
+
|
| 67 |
+
* [ ] Step 2: Verify imports.
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python -c "import app; print('scaffold ok')"
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Expected:
|
| 74 |
+
|
| 75 |
+
```text
|
| 76 |
+
scaffold ok
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
### Task 2: SQLite Models
|
| 82 |
+
|
| 83 |
+
**Files:**
|
| 84 |
+
|
| 85 |
+
* Modify: `app/db/models.py`
|
| 86 |
+
|
| 87 |
+
* Modify: `app/db/repo.py`
|
| 88 |
+
|
| 89 |
+
* [ ] Step 1: Create `Campaign` model.
|
| 90 |
+
|
| 91 |
+
Fields:
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
id
|
| 95 |
+
google_campaign_id
|
| 96 |
+
name
|
| 97 |
+
budget
|
| 98 |
+
spend
|
| 99 |
+
clicks
|
| 100 |
+
impressions
|
| 101 |
+
ctr
|
| 102 |
+
leads
|
| 103 |
+
cpl
|
| 104 |
+
last_synced
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
* [ ] Step 2: Create `Recommendation` model.
|
| 108 |
+
|
| 109 |
+
Fields:
|
| 110 |
+
|
| 111 |
+
```python
|
| 112 |
+
id
|
| 113 |
+
campaign_id
|
| 114 |
+
recommendation_type
|
| 115 |
+
action
|
| 116 |
+
reason
|
| 117 |
+
status
|
| 118 |
+
created_at
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Status values:
|
| 122 |
+
|
| 123 |
+
```text
|
| 124 |
+
Pending
|
| 125 |
+
Approved
|
| 126 |
+
Rejected
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
* [ ] Step 3: Create database initialization helper.
|
| 130 |
+
|
| 131 |
+
Verify:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
python -c "from app.db.repo import init_db; init_db(); print('db ok')"
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
Expected:
|
| 138 |
+
|
| 139 |
+
```text
|
| 140 |
+
db ok
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### Task 3: Google Ads Read-Only Connector
|
| 146 |
+
|
| 147 |
+
**Files:**
|
| 148 |
+
|
| 149 |
+
* Modify: `app/ads/connector.py`
|
| 150 |
+
|
| 151 |
+
* [ ] Step 1: Implement:
|
| 152 |
+
|
| 153 |
+
```python
|
| 154 |
+
list_campaigns()
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
|
| 159 |
+
```python
|
| 160 |
+
[
|
| 161 |
+
{
|
| 162 |
+
"id": "...",
|
| 163 |
+
"name": "...",
|
| 164 |
+
"budget": ...
|
| 165 |
+
}
|
| 166 |
+
]
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
* [ ] Step 2: Implement:
|
| 170 |
+
|
| 171 |
+
```python
|
| 172 |
+
get_campaign_metrics()
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
|
| 177 |
+
```python
|
| 178 |
+
[
|
| 179 |
+
{
|
| 180 |
+
"campaign_id": "...",
|
| 181 |
+
"spend": ...,
|
| 182 |
+
"clicks": ...,
|
| 183 |
+
"impressions": ...,
|
| 184 |
+
"ctr": ...,
|
| 185 |
+
"leads": ...,
|
| 186 |
+
"cpl": ...
|
| 187 |
+
}
|
| 188 |
+
]
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
* [ ] Step 3: Add mock tests for connector responses.
|
| 192 |
+
|
| 193 |
+
Expected:
|
| 194 |
+
|
| 195 |
+
```bash
|
| 196 |
+
$env:PYTHONPATH="."
|
| 197 |
+
pytest
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
passes.
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
### Task 4: Rule Engine
|
| 205 |
+
|
| 206 |
+
**Files:**
|
| 207 |
+
|
| 208 |
+
* Modify: `app/recs/rules.py`
|
| 209 |
+
|
| 210 |
+
* [ ] Step 1: Implement High CPL Rule.
|
| 211 |
+
|
| 212 |
+
Condition:
|
| 213 |
+
|
| 214 |
+
```text
|
| 215 |
+
CPL > Target CPL × 1.5
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
Recommendation:
|
| 219 |
+
|
| 220 |
+
```text
|
| 221 |
+
Reduce budget allocation
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
* [ ] Step 2: Implement Strong Campaign Rule.
|
| 225 |
+
|
| 226 |
+
Condition:
|
| 227 |
+
|
| 228 |
+
```text
|
| 229 |
+
CPL < Target CPL × 0.8
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
Recommendation:
|
| 233 |
+
|
| 234 |
+
```text
|
| 235 |
+
Increase budget allocation
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
* [ ] Step 3: Implement Low CTR Rule.
|
| 239 |
+
|
| 240 |
+
Condition:
|
| 241 |
+
|
| 242 |
+
```text
|
| 243 |
+
CTR < 2%
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
Recommendation:
|
| 247 |
+
|
| 248 |
+
```text
|
| 249 |
+
Review ad copy and keywords
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
* [ ] Step 4: Return structured recommendation objects.
|
| 253 |
+
|
| 254 |
+
Example:
|
| 255 |
+
|
| 256 |
+
```json
|
| 257 |
+
{
|
| 258 |
+
"campaign":"Preschool Search",
|
| 259 |
+
"type":"high_cpl",
|
| 260 |
+
"action":"reduce_budget"
|
| 261 |
+
}
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
### Task 5: MiniCPM5 Recommendation Generator
|
| 267 |
+
|
| 268 |
+
**Files:**
|
| 269 |
+
|
| 270 |
+
* Modify: `app/models/llm.py`
|
| 271 |
+
|
| 272 |
+
* Modify: `app/recs/generate.py`
|
| 273 |
+
|
| 274 |
+
* [ ] Step 1: Load MiniCPM5 GGUF using `llama-cpp-python`.
|
| 275 |
+
|
| 276 |
+
Implement:
|
| 277 |
+
|
| 278 |
+
```python
|
| 279 |
+
load_model()
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
* [ ] Step 2: Generate explanations from recommendation payloads.
|
| 283 |
+
|
| 284 |
+
Input:
|
| 285 |
+
|
| 286 |
+
```json
|
| 287 |
+
{
|
| 288 |
+
"campaign":"Preschool Search",
|
| 289 |
+
"cpl":42,
|
| 290 |
+
"target_cpl":20,
|
| 291 |
+
"action":"reduce_budget"
|
| 292 |
+
}
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
Output:
|
| 296 |
+
|
| 297 |
+
```text
|
| 298 |
+
This campaign's cost per lead is significantly above target. Consider reducing budget allocation until conversion efficiency improves.
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
* [ ] Step 3: Validate output and provide fallback text if model response fails.
|
| 302 |
+
|
| 303 |
+
* [ ] Step 4: Add mocked tests.
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
### Task 6: Dashboard UI
|
| 308 |
+
|
| 309 |
+
**Files:**
|
| 310 |
+
|
| 311 |
+
* Modify: `app/main.py`
|
| 312 |
+
|
| 313 |
+
* Modify: `app/ui/dashboard.py`
|
| 314 |
+
|
| 315 |
+
* Modify: `app/ui/recommendations.py`
|
| 316 |
+
|
| 317 |
+
* [ ] Step 1: Build Campaign Dashboard.
|
| 318 |
+
|
| 319 |
+
Display:
|
| 320 |
+
|
| 321 |
+
| Campaign | Spend | Leads | CPL | CTR |
|
| 322 |
+
| -------- | ----- | ----- | --- | --- |
|
| 323 |
+
|
| 324 |
+
* [ ] Step 2: Add dashboard summary cards.
|
| 325 |
+
|
| 326 |
+
Examples:
|
| 327 |
+
|
| 328 |
+
```text
|
| 329 |
+
Total Spend
|
| 330 |
+
Total Leads
|
| 331 |
+
Average CPL
|
| 332 |
+
Active Campaigns
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
* [ ] Step 3: Add Recommendations Page.
|
| 336 |
+
|
| 337 |
+
Display:
|
| 338 |
+
|
| 339 |
+
| Campaign | Recommendation | Status |
|
| 340 |
+
| -------- | -------------- | ------ |
|
| 341 |
+
|
| 342 |
+
* [ ] Step 4: Add Approve button.
|
| 343 |
+
|
| 344 |
+
Updates:
|
| 345 |
+
|
| 346 |
+
```text
|
| 347 |
+
Pending → Approved
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
* [ ] Step 5: Add Reject button.
|
| 351 |
+
|
| 352 |
+
Updates:
|
| 353 |
+
|
| 354 |
+
```text
|
| 355 |
+
Pending → Rejected
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
Verification:
|
| 359 |
+
|
| 360 |
+
```bash
|
| 361 |
+
python app/main.py
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
Expected:
|
| 365 |
+
|
| 366 |
+
Dashboard loads successfully.
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
### Task 7: Demo Data
|
| 371 |
+
|
| 372 |
+
**Files:**
|
| 373 |
+
|
| 374 |
+
* Modify: `scripts/seed_demo.py`
|
| 375 |
+
|
| 376 |
+
* [ ] Step 1: Generate sample campaigns.
|
| 377 |
+
|
| 378 |
+
Create:
|
| 379 |
+
|
| 380 |
+
```text
|
| 381 |
+
5 campaigns
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
* [ ] Step 2: Generate synthetic metrics.
|
| 385 |
+
|
| 386 |
+
Create:
|
| 387 |
+
|
| 388 |
+
```text
|
| 389 |
+
30 days of data
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
* [ ] Step 3: Generate recommendations.
|
| 393 |
+
|
| 394 |
+
Ensure dashboard always contains examples.
|
| 395 |
+
|
| 396 |
+
Verification:
|
| 397 |
+
|
| 398 |
+
```bash
|
| 399 |
+
python scripts/seed_demo.py
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
Expected:
|
| 403 |
+
|
| 404 |
+
Database populated with demo content.
|
| 405 |
+
|
| 406 |
+
---
|
| 407 |
+
|
| 408 |
+
### Task 8: End-to-End Testing
|
| 409 |
+
|
| 410 |
+
**Files:**
|
| 411 |
+
|
| 412 |
+
* Create: `tests/test_e2e.py`
|
| 413 |
+
|
| 414 |
+
* [ ] Step 1: Seed demo data.
|
| 415 |
+
|
| 416 |
+
* [ ] Step 2: Run rule engine.
|
| 417 |
+
|
| 418 |
+
* [ ] Step 3: Generate MiniCPM explanations using mocked model.
|
| 419 |
+
|
| 420 |
+
* [ ] Step 4: Verify recommendations appear in database.
|
| 421 |
+
|
| 422 |
+
Expected:
|
| 423 |
+
|
| 424 |
+
```bash
|
| 425 |
+
pytest
|
| 426 |
+
```
|
| 427 |
+
|
| 428 |
+
passes.
|
| 429 |
+
|
| 430 |
+
---
|
| 431 |
+
|
| 432 |
+
### Task 9: Hugging Face Space Deployment
|
| 433 |
+
|
| 434 |
+
**Files:**
|
| 435 |
+
|
| 436 |
+
* Modify: `README.md`
|
| 437 |
+
|
| 438 |
+
* Modify: `requirements.txt`
|
| 439 |
+
|
| 440 |
+
* [ ] Step 1: Add deployment instructions.
|
| 441 |
+
|
| 442 |
+
* [ ] Step 2: Document model download procedure.
|
| 443 |
+
|
| 444 |
+
* [ ] Step 3: Document local development workflow.
|
| 445 |
+
|
| 446 |
+
Example:
|
| 447 |
+
|
| 448 |
+
```bash
|
| 449 |
+
pip install -r requirements.txt
|
| 450 |
+
python scripts/seed_demo.py
|
| 451 |
+
python app/main.py
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
Expected:
|
| 455 |
+
|
| 456 |
+
Developer can run locally and deploy to HF Spaces.
|
| 457 |
+
|
| 458 |
+
---
|
| 459 |
+
|
| 460 |
+
## Self-Review Checklist
|
| 461 |
+
|
| 462 |
+
1. Google Ads metrics can be viewed.
|
| 463 |
+
2. Rule engine generates recommendations.
|
| 464 |
+
3. MiniCPM generates explanations.
|
| 465 |
+
4. Recommendations can be approved/rejected.
|
| 466 |
+
5. Dashboard works with seeded demo data.
|
| 467 |
+
6. No automatic campaign modifications.
|
| 468 |
+
7. No scheduler required.
|
| 469 |
+
8. No Google Sheets integration required.
|
| 470 |
+
9. Deployable on Hugging Face Spaces.
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## Handoff / Execution Choices
|
| 475 |
+
|
| 476 |
+
Plan complete. Two execution options:
|
| 477 |
+
|
| 478 |
+
1. Subagent-Driven (recommended) — run `subagent-driven-development` task-by-task.
|
| 479 |
+
2. Inline Execution — implement tasks sequentially in a single session.
|
| 480 |
+
|
| 481 |
+
Recommended for hackathon: **subagent-driven-development**.
|
docs/superpowers/specs/2026-06-03-ads-automation-design.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ads Automation — v1 Design
|
| 2 |
+
|
| 3 |
+
Date: 2026-06-03
|
| 4 |
+
Project: Preschool Ads Automation (Google Ads)
|
| 5 |
+
Scope: Hugging Face Space deploy, CPU runtime, recommendation-and-approval automation for Google Search campaigns.
|
| 6 |
+
|
| 7 |
+
**Summary**
|
| 8 |
+
- Deliver a single Gradio app that monitors Google Ads campaigns, generates structured recommendations using a local MiniCPM5-1B model (GGUF via `llama-cpp-python`), and applies approved, limited safe changes (budget adjustments, pause/resume, add negative keywords).
|
| 9 |
+
- App source-of-truth: SQLite. Syncs to Google Sheets for visibility.
|
| 10 |
+
- Scheduler: APScheduler in-process with SQLite jobstore; daily review loop + manual runs.
|
| 11 |
+
- Auth/secrets: HF Secrets for deployed Space; `.env` for local dev. Admin access via single password.
|
| 12 |
+
|
| 13 |
+
**High-level Architecture**
|
| 14 |
+
- Frontend: Gradio app with pages: Dashboard, Campaigns, Recommendations, Lead Manager, Audit & Logs, Settings.
|
| 15 |
+
- Backend modules (Python):
|
| 16 |
+
- `app/ads/`: Google Ads connector (official `google-ads` Python client) + admin OAuth flow to obtain refresh token.
|
| 17 |
+
- `app/db/`: SQLAlchemy models and repository layer (SQLite).
|
| 18 |
+
- `app/recs/`: deterministic rule engine (thresholds, smoothing) that produces candidate deltas.
|
| 19 |
+
- `app/models/`: MiniCPM5 wrapper using `llama-cpp-python` that accepts a JSON instruction and returns structured JSON recommendations.
|
| 20 |
+
- `app/scheduler/`: APScheduler bootstrap + job definitions (daily review, retry logic, on-demand runs).
|
| 21 |
+
- `scripts/`: utilities (seed demo data, sheet sync, admin tasks).
|
| 22 |
+
|
| 23 |
+
**Data Model (core tables)**
|
| 24 |
+
- `campaigns` {id, google_campaign_id, name, managed:bool, budget, target_cpl_override, last_synced}
|
| 25 |
+
- `ad_groups` {id, campaign_id, google_ad_group_id, name}
|
| 26 |
+
- `keywords` {id, ad_group_id, google_keyword_id, text, match_type, active, last_metrics}
|
| 27 |
+
- `leads` {id, source, campaign_id, utm, name, phone, email, created_at, status(booked/visited/other)}
|
| 28 |
+
- `recommendations` {id, timestamp, campaign_id, entity_type, action, delta (json), reason, risk, confidence, origin_rule, apply_options, approved_by, applied_by, audit_json}
|
| 29 |
+
- `audit_logs` {id, user, action, payload, timestamp}
|
| 30 |
+
|
| 31 |
+
**Recommendation Pipeline**
|
| 32 |
+
1. Metrics ingestion: request last N days of metrics via Google Ads API (default 7/30-day windows). Store snapshots in SQLite.
|
| 33 |
+
2. Rule engine: evaluate deterministic rules (3-day smoothed moving averages, min-sample guards). If a rule fires, create a candidate delta.
|
| 34 |
+
3. Model prompt: build JSON-only prompt with: recent aggregates, supporting time-series summary, candidate delta, and entity metadata.
|
| 35 |
+
4. MiniCPM5 (local GGUF via `llama-cpp-python`) returns structured recommendation JSON following agreed schema.
|
| 36 |
+
5. Persist recommendation, send in-app notification/email/webhook.
|
| 37 |
+
6. Approver (admin) reviews in Recommendations page, chooses immediate/scheduled/staged apply or rejects.
|
| 38 |
+
7. If approved and auto-apply allowed, scheduler enqueues the apply job; apply executes via Google Ads client and writes audit and rollback metadata.
|
| 39 |
+
|
| 40 |
+
**Rule Defaults (v1)**
|
| 41 |
+
- Pause keyword: CPL > 1.5×target CPL for 3 consecutive days AND clicks ≥ 10.
|
| 42 |
+
- Add negative keyword: impressions ≥ 500, clicks ≥ 20, leads = 0, CTR < 0.5% for 7 days.
|
| 43 |
+
- Increase budget: CPL < 0.8×target CPL for 3 days AND leads ≥ 3 → +10% budget (cap per-campaign).
|
| 44 |
+
- Decrease budget: CPL > 1.25×target CPL for 3 days AND spend ≥ $20/day → −15% budget.
|
| 45 |
+
|
| 46 |
+
**Recommendation Schema (v1)**
|
| 47 |
+
- `id`, `timestamp`, `campaign_id`, `campaign_name`, `entity_type`, `action`, `delta`, `estimated_impact`, `reason`, `risk`, `confidence`, `origin_rule`, `supporting_metrics`, `apply_options`, `rollback_plan`, `audit`.
|
| 48 |
+
|
| 49 |
+
**Auto-apply Safety**
|
| 50 |
+
- Allowed actions: budget adjustments, pause/resume, negative keyword additions only.
|
| 51 |
+
- Approval required for any apply; app supports immediate, scheduled, or staged rollout (e.g., 25→50→100% over 48h).
|
| 52 |
+
- All changes include rollback metadata; maintain previous setting snapshot and a reversible job.
|
| 53 |
+
|
| 54 |
+
**Model/Prompting**
|
| 55 |
+
- Use `Abiray/MiniCPM5-1B-GGUF` (GGUF file). Load via `llama-cpp-python` at startup; cache in Space environment.
|
| 56 |
+
- Prompt must be strict: return only the `recommendation` JSON. Include a short human-readable summary for UI display.
|
| 57 |
+
- Model role: generate human-friendly `reason`, `risk`, `confidence`, and `estimated_impact` text. Deterministic numbers (spend/lead deltas) come from rule engine heuristics.
|
| 58 |
+
|
| 59 |
+
**Lead Capture & Attribution**
|
| 60 |
+
- Primary lead capture: single landing page form that writes to app DB (captures UTM parameters).
|
| 61 |
+
- Leads sync to Google Sheets (read-only audit view). Approver marks `booked` visits in Lead Manager; app updates lead status and syncs back to Sheets.
|
| 62 |
+
|
| 63 |
+
**Auth & Secrets**
|
| 64 |
+
- Deploy-time secrets stored in HF Secrets (Google OAuth client_id/secret, developer token, admin password). Local dev: `.env` only.
|
| 65 |
+
- Admin auth: single admin password for approving changes; optional read-only viewer links.
|
| 66 |
+
|
| 67 |
+
**Deployment**
|
| 68 |
+
- Target: Hugging Face Space (CPU-enabled). Use `requirements.txt` including `gradio`, `sqlalchemy`, `google-ads`, `google-auth`, `apscheduler`, `llama-cpp-python`.
|
| 69 |
+
- At startup: download GGUF from HF Hub if not cached, load model via `llama-cpp-python` with quantized Q4_K_M file.
|
| 70 |
+
- Provide `scripts/seed_demo.py` to populate seeded demo mode and `scripts/sheet_sync.py` for manual sync.
|
| 71 |
+
|
| 72 |
+
**Testing & Demo Mode**
|
| 73 |
+
- Seeded demo data for presentation mode when no live campaigns are available.
|
| 74 |
+
- Unit tests for rule engine, recommendation schema validation, DB migrations, and Google Ads connector mocks.
|
| 75 |
+
|
| 76 |
+
**Privacy & Safety**
|
| 77 |
+
- No secrets in repo. Provide clear audit trail for any change applied to Google Ads. Approver must explicitly approve before any change that modifies live campaigns.
|
| 78 |
+
|
| 79 |
+
**Next Steps (implementation checklist)**
|
| 80 |
+
- [ ] Scaffold repo layout and `requirements.txt`
|
| 81 |
+
- [ ] Implement SQLite schema + SQLAlchemy models
|
| 82 |
+
- [ ] Implement admin OAuth flow and `google-ads` connector (read-only + mutating calls)
|
| 83 |
+
- [ ] Implement rule engine and recommendation generator (model wrapper)
|
| 84 |
+
- [ ] Build Gradio UI pages and flows (approve/apply)
|
| 85 |
+
- [ ] Implement APScheduler loop + job persistence
|
| 86 |
+
- [ ] Add Google Sheets sync and `scripts/`
|
| 87 |
+
- [ ] Write README, HF Space instructions, and seeded demo data
|
| 88 |
+
|
| 89 |
+
**Appendix — Key decisions (v1)**
|
| 90 |
+
- Mode: Recommendation-and-approval (no fully autonomous without approval)
|
| 91 |
+
- KPI: Qualified leads / booked campus visits (CPL optimization)
|
| 92 |
+
- Channels: Google Search Ads only (v1)
|
| 93 |
+
- Auto-apply actions: budgets, pause/resume, negative keywords
|
| 94 |
+
- Model: MiniCPM5-1B (GGUF) used for textual recommendation generation only
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
Spec authored by: GitHub Copilot assistant (design session)
|
| 99 |
+
|
| 100 |
+
Please review this spec file and tell me if you want any edits before I write the implementation plan.
|
get_refresh_token.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
| 2 |
+
|
| 3 |
+
SCOPES = ["https://www.googleapis.com/auth/adwords"]
|
| 4 |
+
|
| 5 |
+
flow = InstalledAppFlow.from_client_secrets_file(
|
| 6 |
+
"client_secret.json", # 👈 your downloaded file name
|
| 7 |
+
SCOPES
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
creds = flow.run_local_server(port=0)
|
| 11 |
+
|
| 12 |
+
print("\n===== REFRESH TOKEN =====\n")
|
| 13 |
+
print(creds.refresh_token)
|
| 14 |
+
print("\n=========================\n")
|
main.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
from app.db.repo import init_db
|
| 6 |
+
from app.ui.dashboard import load_dashboard, build_dashboard
|
| 7 |
+
from app.controller.session_loader import load_google_ads_data
|
| 8 |
+
|
| 9 |
+
from app.ads1.ads_analyst import run_ads_analyst_card
|
| 10 |
+
from app.ads1.budget_optimizer import run_budget_optimizer_card
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
init_db()
|
| 14 |
+
|
| 15 |
+
# HELPERS
|
| 16 |
+
def on_campaign_select(campaign_name):
|
| 17 |
+
dfs = load_google_ads_data()
|
| 18 |
+
|
| 19 |
+
filtered = dfs.copy()
|
| 20 |
+
filtered["campaigns"] = dfs["campaigns"][
|
| 21 |
+
dfs["campaigns"]["name"] == campaign_name
|
| 22 |
+
]
|
| 23 |
+
return filtered
|
| 24 |
+
|
| 25 |
+
def run_ads_card(state):
|
| 26 |
+
if not state:
|
| 27 |
+
return "⚠️ Please select a campaign from the Dashboard first."
|
| 28 |
+
return run_ads_analyst_card(state["dfs"])
|
| 29 |
+
|
| 30 |
+
def run_budget_card(state):
|
| 31 |
+
if not state:
|
| 32 |
+
return "⚠️ Please select a campaign from the Dashboard first."
|
| 33 |
+
return run_budget_optimizer_card(state["dfs"])
|
| 34 |
+
|
| 35 |
+
def campaign_row_selected(evt: gr.SelectData):
|
| 36 |
+
"""
|
| 37 |
+
Triggered when user clicks a row in the dashboard table
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
df = load_dashboard()[4] # campaign table returned by load_dashboard()
|
| 41 |
+
campaign_name = df.iloc[evt.index[0]]["Campaign"]
|
| 42 |
+
dfs = on_campaign_select(campaign_name)
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
{
|
| 46 |
+
"campaign_name": campaign_name,
|
| 47 |
+
"dfs": dfs
|
| 48 |
+
},
|
| 49 |
+
f"## 📊 Selected Campaign: {campaign_name}"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# GRADIO APP
|
| 53 |
+
with gr.Blocks(title="Ads Assistant") as demo:
|
| 54 |
+
|
| 55 |
+
campaign_state = gr.State()
|
| 56 |
+
gr.Markdown("# 🎯 Preschool Ads Dashboard")
|
| 57 |
+
|
| 58 |
+
# TAB 1: DASHBOARD
|
| 59 |
+
# -------------------------
|
| 60 |
+
with gr.Tab("Dashboard"):
|
| 61 |
+
|
| 62 |
+
campaign_table = build_dashboard()
|
| 63 |
+
|
| 64 |
+
# TAB 2: CAMPAIGN ANALYSIS
|
| 65 |
+
# -------------------------
|
| 66 |
+
with gr.Tab("Campaign Analysis"):
|
| 67 |
+
|
| 68 |
+
selected_campaign = gr.Markdown(
|
| 69 |
+
"👈 Select a campaign from the Dashboard tab"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
analyst_btn = gr.Button("Run Ads Analysis")
|
| 73 |
+
budget_btn = gr.Button("Run Budget Optimization")
|
| 74 |
+
|
| 75 |
+
output = gr.Markdown()
|
| 76 |
+
|
| 77 |
+
analyst_btn.click(
|
| 78 |
+
fn=run_ads_card,
|
| 79 |
+
inputs=campaign_state,
|
| 80 |
+
outputs=output
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
budget_btn.click(
|
| 84 |
+
fn=run_budget_card,
|
| 85 |
+
inputs=campaign_state,
|
| 86 |
+
outputs=output
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# CONNECT TABLE CLICK → STATE
|
| 90 |
+
# -------------------------
|
| 91 |
+
campaign_table.select(
|
| 92 |
+
fn=campaign_row_selected,
|
| 93 |
+
outputs=[
|
| 94 |
+
campaign_state,
|
| 95 |
+
selected_campaign
|
| 96 |
+
]
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
demo.launch()
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
sqlalchemy # for wat is this ?
|
| 3 |
+
google-ads>=27.0.0
|
| 4 |
+
pandas
|
| 5 |
+
llama-cpp-python==0.2.90
|
| 6 |
+
pytest
|
| 7 |
+
python-dotenv
|
| 8 |
+
requests
|
run_ads_data_pipeline.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
load_dotenv()
|
| 3 |
+
|
| 4 |
+
from app.ads1.fetch_ads_data import fetch_all_data, to_dataframes
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
CUSTOMER_ID = os.getenv("GOOGLE_ADS_CUSTOMER_ID")
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
raw = fetch_all_data(CUSTOMER_ID)
|
| 11 |
+
dfs = to_dataframes(raw)
|
| 12 |
+
|
| 13 |
+
for name, df in dfs.items():
|
| 14 |
+
print("\n====================")
|
| 15 |
+
print(name.upper())
|
| 16 |
+
print("====================")
|
| 17 |
+
print(df.head())
|
| 18 |
+
|
| 19 |
+
return dfs
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
dfs = main()
|
run_inspect.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
load_dotenv()
|
| 3 |
+
from app.ads1.connector import list_campaigns, get_campaign_metrics
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
CUSTOMER_ID = os.getenv("GOOGLE_ADS_CUSTOMER_ID")
|
| 8 |
+
|
| 9 |
+
def inspect_google_ads():
|
| 10 |
+
campaigns = list_campaigns(CUSTOMER_ID)
|
| 11 |
+
|
| 12 |
+
print("4️⃣ Campaigns received:", len(campaigns))
|
| 13 |
+
|
| 14 |
+
print(pd.DataFrame(campaigns))
|
| 15 |
+
|
| 16 |
+
metrics = get_campaign_metrics(CUSTOMER_ID)
|
| 17 |
+
|
| 18 |
+
print("6️⃣ Metrics received:", len(metrics))
|
| 19 |
+
|
| 20 |
+
print(pd.DataFrame(metrics))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
inspect_google_ads()
|
| 25 |
+
|
scripts/seed_demo.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# ✅ FIX: ensure project root is first in path BEFORE imports
|
| 5 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 6 |
+
sys.path.insert(0, str(ROOT))
|
| 7 |
+
|
| 8 |
+
import random
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from app.db.repo import init_db, SessionLocal
|
| 12 |
+
from app.db.models import Campaign, Recommendation
|
| 13 |
+
from app.recs.rules import generate_recommendations
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
CAMPAIGN_NAMES = [
|
| 17 |
+
"Preschool Search",
|
| 18 |
+
"Brand Awareness",
|
| 19 |
+
"Local Leads",
|
| 20 |
+
"Early Education Ads",
|
| 21 |
+
"Enrollment Push"
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# -------------------------
|
| 26 |
+
# Campaign generation
|
| 27 |
+
# -------------------------
|
| 28 |
+
def generate_campaigns(session):
|
| 29 |
+
campaigns = []
|
| 30 |
+
|
| 31 |
+
for i, name in enumerate(CAMPAIGN_NAMES):
|
| 32 |
+
campaign = Campaign(
|
| 33 |
+
google_campaign_id=f"gc_{1000+i}",
|
| 34 |
+
name=name,
|
| 35 |
+
budget=random.randint(50, 200),
|
| 36 |
+
spend=0,
|
| 37 |
+
clicks=0,
|
| 38 |
+
impressions=0,
|
| 39 |
+
ctr=0.0,
|
| 40 |
+
leads=0,
|
| 41 |
+
cpl=0.0,
|
| 42 |
+
last_synced=datetime.utcnow()
|
| 43 |
+
)
|
| 44 |
+
session.add(campaign)
|
| 45 |
+
campaigns.append(campaign)
|
| 46 |
+
|
| 47 |
+
session.commit()
|
| 48 |
+
return campaigns
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# -------------------------
|
| 52 |
+
# Metrics simulation
|
| 53 |
+
# -------------------------
|
| 54 |
+
def simulate_metrics(session, campaigns):
|
| 55 |
+
for campaign in campaigns:
|
| 56 |
+
spend = 0
|
| 57 |
+
clicks = 0
|
| 58 |
+
impressions = 0
|
| 59 |
+
leads = 0
|
| 60 |
+
|
| 61 |
+
for _ in range(30):
|
| 62 |
+
daily_impressions = random.randint(50, 500)
|
| 63 |
+
daily_clicks = int(daily_impressions * random.uniform(0.01, 0.1))
|
| 64 |
+
daily_spend = daily_clicks * random.uniform(0.5, 3.0)
|
| 65 |
+
daily_leads = int(daily_clicks * random.uniform(0.05, 0.3))
|
| 66 |
+
|
| 67 |
+
impressions += daily_impressions
|
| 68 |
+
clicks += daily_clicks
|
| 69 |
+
spend += daily_spend
|
| 70 |
+
leads += daily_leads
|
| 71 |
+
|
| 72 |
+
ctr = clicks / impressions if impressions else 0
|
| 73 |
+
cpl = spend / leads if leads else 0
|
| 74 |
+
|
| 75 |
+
campaign.spend = round(spend, 2)
|
| 76 |
+
campaign.clicks = clicks
|
| 77 |
+
campaign.impressions = impressions
|
| 78 |
+
campaign.leads = leads
|
| 79 |
+
campaign.ctr = round(ctr, 4)
|
| 80 |
+
campaign.cpl = round(cpl, 2)
|
| 81 |
+
campaign.last_synced = datetime.utcnow()
|
| 82 |
+
|
| 83 |
+
session.commit()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# -------------------------
|
| 87 |
+
# Recommendations via rule engine
|
| 88 |
+
# -------------------------
|
| 89 |
+
def seed_recommendations(session, campaigns):
|
| 90 |
+
# ✅ prevent duplicate entries on re-run
|
| 91 |
+
session.query(Recommendation).delete()
|
| 92 |
+
session.commit()
|
| 93 |
+
|
| 94 |
+
metrics = [
|
| 95 |
+
{
|
| 96 |
+
"campaign_id": c.id,
|
| 97 |
+
"cpl": c.cpl,
|
| 98 |
+
"ctr": c.ctr,
|
| 99 |
+
}
|
| 100 |
+
for c in campaigns
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
recs = generate_recommendations(metrics)
|
| 104 |
+
|
| 105 |
+
for r in recs:
|
| 106 |
+
session.add(Recommendation(
|
| 107 |
+
campaign_id=r["campaign_id"],
|
| 108 |
+
recommendation_type=r["type"],
|
| 109 |
+
action=r["action"],
|
| 110 |
+
reason=r["reason"],
|
| 111 |
+
status="Pending",
|
| 112 |
+
created_at=datetime.utcnow()
|
| 113 |
+
))
|
| 114 |
+
|
| 115 |
+
session.commit()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# -------------------------
|
| 119 |
+
# Main pipeline
|
| 120 |
+
# -------------------------
|
| 121 |
+
def main():
|
| 122 |
+
print("Initializing DB...")
|
| 123 |
+
init_db()
|
| 124 |
+
|
| 125 |
+
session = SessionLocal()
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
print("Seeding campaigns...")
|
| 129 |
+
campaigns = generate_campaigns(session)
|
| 130 |
+
|
| 131 |
+
print("Simulating metrics...")
|
| 132 |
+
simulate_metrics(session, campaigns)
|
| 133 |
+
|
| 134 |
+
print("Generating recommendations...")
|
| 135 |
+
seed_recommendations(session, campaigns)
|
| 136 |
+
|
| 137 |
+
print("✅ Demo database seeded successfully!")
|
| 138 |
+
|
| 139 |
+
finally:
|
| 140 |
+
session.close()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
test.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.recs.generate import generate_explanation
|
| 2 |
+
|
| 3 |
+
rec = {
|
| 4 |
+
"campaign_id": "Test",
|
| 5 |
+
"type": "high_cpl",
|
| 6 |
+
"action": "reduce_budget",
|
| 7 |
+
"reason": "CPL too high",
|
| 8 |
+
"cpl": 42,
|
| 9 |
+
"target_cpl": 20,
|
| 10 |
+
"ctr": 1.2,
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
# for x in generate_explanation(rec, stream=True):
|
| 14 |
+
# print(x)
|
| 15 |
+
|
| 16 |
+
gen = generate_explanation(rec, stream=True)
|
| 17 |
+
|
| 18 |
+
for i, chunk in enumerate(gen):
|
| 19 |
+
print(f"[{i}]", chunk)
|
test_model.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from llama_cpp import Llama
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
print("Script started")
|
| 5 |
+
|
| 6 |
+
# llm = Llama.from_pretrained(
|
| 7 |
+
# repo_id="mradermacher/MiniCPM4.1-8B-GGUF",
|
| 8 |
+
# filename="MiniCPM4.1-8B.IQ4_XS.gguf",
|
| 9 |
+
# n_ctx=4096,
|
| 10 |
+
# verbose=False
|
| 11 |
+
# )
|
| 12 |
+
|
| 13 |
+
llm = Llama.from_pretrained(
|
| 14 |
+
repo_id="Abiray/MiniCPM5-1B-GGUF",
|
| 15 |
+
filename="minicpm5-1b-Q4_K_M.gguf",
|
| 16 |
+
n_ctx=3048,
|
| 17 |
+
verbose=True
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
prompt = """
|
| 21 |
+
You are a senior Google Ads performance analyst.
|
| 22 |
+
|
| 23 |
+
You must output ONLY 3–5 bullet insights.
|
| 24 |
+
|
| 25 |
+
STRICT RULES:
|
| 26 |
+
- Do NOT include reasoning
|
| 27 |
+
- Do NOT include calculations
|
| 28 |
+
- Do NOT include step-by-step analysis
|
| 29 |
+
- Do NOT use <think> tags
|
| 30 |
+
- Do NOT show working or explanations
|
| 31 |
+
- Only final insights allowed
|
| 32 |
+
|
| 33 |
+
Use only the provided data. Do not derive new metrics.
|
| 34 |
+
|
| 35 |
+
DATA:
|
| 36 |
+
|
| 37 |
+
Campaign:
|
| 38 |
+
- Name: Preschool Search
|
| 39 |
+
- Spend: 1200
|
| 40 |
+
- Clicks: 300
|
| 41 |
+
- Impressions: 15000
|
| 42 |
+
- Conversions: 30
|
| 43 |
+
|
| 44 |
+
Trends:
|
| 45 |
+
- Spend increasing steadily over last 10 days
|
| 46 |
+
- Clicks increasing steadily
|
| 47 |
+
- Impressions increasing slightly faster than clicks
|
| 48 |
+
|
| 49 |
+
Keywords:
|
| 50 |
+
- preschool near me → strong performance (15 conversions, low cost)
|
| 51 |
+
- nursery admission → moderate (5 conversions)
|
| 52 |
+
- best preschool london → poor (0 conversions, high cost)
|
| 53 |
+
- early learning center → good (8 conversions)
|
| 54 |
+
|
| 55 |
+
Signals:
|
| 56 |
+
- CTR: 0.35 (low)
|
| 57 |
+
- Wasted spend: 0.25 (high)
|
| 58 |
+
|
| 59 |
+
Business targets:
|
| 60 |
+
- Target CPL: 20
|
| 61 |
+
- Current CPL: 40
|
| 62 |
+
|
| 63 |
+
OUTPUT RULES:
|
| 64 |
+
- Exactly 3–5 bullets
|
| 65 |
+
- No numbering
|
| 66 |
+
- No explanations
|
| 67 |
+
- No thinking traces
|
| 68 |
+
- Each bullet must be independently useful for decision-making
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
response = llm.create_chat_completion(
|
| 72 |
+
messages=[
|
| 73 |
+
{"role": "system", "content": "You are an expert marketing analyst for Google Ads."},
|
| 74 |
+
{"role": "user", "content": prompt}
|
| 75 |
+
],
|
| 76 |
+
temperature=0.7,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
raw = response["choices"][0]["message"]["content"]
|
| 80 |
+
|
| 81 |
+
clean = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
| 82 |
+
|
| 83 |
+
print(clean)
|
tests/test_connector.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.ads.connector import list_campaigns, get_campaign_metrics
|
| 2 |
+
|
| 3 |
+
def test_list_campaigns():
|
| 4 |
+
campaigns = list_campaigns()
|
| 5 |
+
|
| 6 |
+
assert isinstance(campaigns, list)
|
| 7 |
+
assert len(campaigns) > 0
|
| 8 |
+
|
| 9 |
+
for c in campaigns:
|
| 10 |
+
assert "id" in c
|
| 11 |
+
assert "name" in c
|
| 12 |
+
assert "budget" in c
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_get_campaign_metrics():
|
| 16 |
+
metrics = get_campaign_metrics()
|
| 17 |
+
|
| 18 |
+
assert isinstance(metrics, list)
|
| 19 |
+
assert len(metrics) > 0
|
| 20 |
+
|
| 21 |
+
for m in metrics:
|
| 22 |
+
assert "campaign_id" in m
|
| 23 |
+
assert "spend" in m
|
| 24 |
+
assert "clicks" in m
|
| 25 |
+
assert "impressions" in m
|
| 26 |
+
assert "ctr" in m
|
| 27 |
+
assert "leads" in m
|
| 28 |
+
assert "cpl" in m
|
tests/test_e2e.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
from app.recs.rules import generate_recommendations
|
| 5 |
+
from app.recs.generate import generate_explanation
|
| 6 |
+
from app.db.repo import init_db, SessionLocal
|
| 7 |
+
from app.db.models import Campaign, Recommendation
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# -------------------------
|
| 11 |
+
# DB fixture
|
| 12 |
+
# -------------------------
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def session():
|
| 15 |
+
init_db()
|
| 16 |
+
db = SessionLocal()
|
| 17 |
+
yield db
|
| 18 |
+
db.close()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# -------------------------
|
| 22 |
+
# Step 1: seed campaigns (DB → metrics extraction simulation)
|
| 23 |
+
# -------------------------
|
| 24 |
+
def seed_campaign_metrics(session):
|
| 25 |
+
campaigns = [
|
| 26 |
+
Campaign(
|
| 27 |
+
google_campaign_id="c1",
|
| 28 |
+
name="High CPL Campaign",
|
| 29 |
+
budget=100,
|
| 30 |
+
spend=500,
|
| 31 |
+
clicks=100,
|
| 32 |
+
impressions=2000,
|
| 33 |
+
ctr=5.0,
|
| 34 |
+
leads=5,
|
| 35 |
+
cpl=100.0,
|
| 36 |
+
),
|
| 37 |
+
Campaign(
|
| 38 |
+
google_campaign_id="c2",
|
| 39 |
+
name="Low CPL Campaign",
|
| 40 |
+
budget=100,
|
| 41 |
+
spend=200,
|
| 42 |
+
clicks=150,
|
| 43 |
+
impressions=3000,
|
| 44 |
+
ctr=5.0,
|
| 45 |
+
leads=20,
|
| 46 |
+
cpl=10.0,
|
| 47 |
+
),
|
| 48 |
+
Campaign(
|
| 49 |
+
google_campaign_id="c3",
|
| 50 |
+
name="Low CTR Campaign",
|
| 51 |
+
budget=100,
|
| 52 |
+
spend=300,
|
| 53 |
+
clicks=20,
|
| 54 |
+
impressions=3000,
|
| 55 |
+
ctr=1.0,
|
| 56 |
+
leads=5,
|
| 57 |
+
cpl=60.0,
|
| 58 |
+
),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
session.add_all(campaigns)
|
| 62 |
+
session.commit()
|
| 63 |
+
|
| 64 |
+
return campaigns
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# -------------------------
|
| 68 |
+
# Convert DB → rule engine input format
|
| 69 |
+
# -------------------------
|
| 70 |
+
def extract_metrics(session):
|
| 71 |
+
campaigns = session.query(Campaign).all()
|
| 72 |
+
|
| 73 |
+
return [
|
| 74 |
+
{
|
| 75 |
+
"campaign_id": c.google_campaign_id,
|
| 76 |
+
"cpl": c.cpl,
|
| 77 |
+
"ctr": c.ctr,
|
| 78 |
+
}
|
| 79 |
+
for c in campaigns
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# -------------------------
|
| 84 |
+
# E2E TEST
|
| 85 |
+
# -------------------------
|
| 86 |
+
def test_e2e_pipeline(session):
|
| 87 |
+
|
| 88 |
+
# STEP 1: seed DB
|
| 89 |
+
seed_campaign_metrics(session)
|
| 90 |
+
|
| 91 |
+
metrics = extract_metrics(session)
|
| 92 |
+
|
| 93 |
+
# STEP 2: rule engine
|
| 94 |
+
recs = generate_recommendations(metrics)
|
| 95 |
+
|
| 96 |
+
assert len(recs) > 0, "Rule engine returned no recommendations"
|
| 97 |
+
|
| 98 |
+
# (Optional sanity check)
|
| 99 |
+
assert any(r["type"] == "high_cpl" for r in recs)
|
| 100 |
+
assert any(r["type"] == "low_ctr" for r in recs)
|
| 101 |
+
|
| 102 |
+
# STEP 3: mock LLM (MiniCPM)
|
| 103 |
+
def fake_llm_response(rec):
|
| 104 |
+
return f"Mock explanation for {rec['campaign_id']}"
|
| 105 |
+
|
| 106 |
+
with patch("app.recs.generate.load_model", return_value=None), \
|
| 107 |
+
patch("app.recs.generate.generate_explanation") as mocked:
|
| 108 |
+
|
| 109 |
+
mocked.side_effect = fake_llm_response
|
| 110 |
+
|
| 111 |
+
enriched = [
|
| 112 |
+
{
|
| 113 |
+
**r,
|
| 114 |
+
"explanation": generate_explanation(r)
|
| 115 |
+
}
|
| 116 |
+
for r in recs
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
# STEP 4: verify enrichment
|
| 120 |
+
assert len(enriched) == len(recs)
|
| 121 |
+
|
| 122 |
+
for e in enriched:
|
| 123 |
+
assert "explanation" in e
|
| 124 |
+
assert e["explanation"] is not None
|
| 125 |
+
assert isinstance(e["explanation"], str)
|