ps1811 commited on
Commit
39b31c0
·
1 Parent(s): f1e9b96

Initial HF Space deployment

Browse files
.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)