-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrunner.py
More file actions
353 lines (302 loc) · 13.9 KB
/
Copy pathrunner.py
File metadata and controls
353 lines (302 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""Main orchestrator — daily cron loop for the AI Google Ads optimization system.
Entry point: python -m ai_ads_loop.runner [--dry-run] [--campaign-id ID] [--status]
The loop:
For each active campaign:
1. Pull today's metrics snapshot
2. If experiment collecting and has enough data -> evaluate winner
3. Elif cooldown elapsed -> generate copy variant and start experiment
4. Check kill switch conditions
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from datetime import datetime, timedelta
from . import config
from .ads_client import AdsClient, DryRunAdsClient
from .copy_generator import generate_initial_pool, generate_variant
from .evaluator import compare_generations
from .kill_switch import check_kill_switch, trigger_kill_switch
from .repository import Repository
logger = logging.getLogger(__name__)
def _hil_gate(copy_display: str, campaign_name: str) -> bool:
"""Human-in-the-loop gate. Returns True if approved.
If running interactively (TTY), prompts for approval.
If not TTY, prints instructions for manual DB approval and returns False.
"""
print("\n" + "=" * 60)
print(f"HIL GATE — Generation 1 for campaign: {campaign_name}")
print("=" * 60)
print(copy_display)
print("=" * 60)
if sys.stdin.isatty():
answer = input("Approve this ad copy? [yes/no]: ").strip().lower()
return answer in ("yes", "y")
else:
logger.warning(
"HIL gate: not running interactively. "
"Set generations.approved=1 in the DB to proceed."
)
return False
def _format_copy_for_display(headlines: list[str], descriptions: list[str]) -> str:
lines = ["HEADLINES:"]
for i, h in enumerate(headlines, 1):
lines.append(f" {i:2}. {h} ({len(h)} chars)")
lines.append("\nDESCRIPTIONS:")
for i, d in enumerate(descriptions, 1):
lines.append(f" {i}. {d} ({len(d)} chars)")
return "\n".join(lines)
def run_loop(
dry_run: bool = False,
campaign_id_filter: int | None = None,
) -> None:
"""Main orchestration loop. Processes all active campaigns once."""
repo = Repository()
ads = DryRunAdsClient() if dry_run else AdsClient()
campaigns = repo.get_active_campaigns()
if campaign_id_filter:
campaigns = [c for c in campaigns if c["id"] == campaign_id_filter]
if not campaigns:
logger.info("No active campaigns to process.")
return
logger.info("Processing %d campaign(s)", len(campaigns))
for campaign in campaigns:
cid = campaign["id"]
name = campaign["name"]
google_campaign_id = campaign["google_campaign_id"]
google_ad_group_id = campaign["google_ad_group_id"]
daily_budget_cents = campaign["daily_budget_cents"]
logger.info("=" * 50)
logger.info("Campaign: %s (id=%d)", name, cid)
# 1. Pull metrics snapshot
try:
metrics = ads.get_campaign_metrics(google_campaign_id, days_back=1)
except Exception as e:
logger.error("Failed to pull metrics for campaign %d: %s", cid, e)
continue
current_gen = repo.get_current_generation(cid)
if current_gen:
repo.record_metrics_snapshot(
campaign_id=cid,
generation_id=current_gen["id"],
snapshot_date=str(datetime.now().date()),
impressions=metrics.impressions,
clicks=metrics.clicks,
ctr=metrics.ctr,
avg_cpc_cents=metrics.avg_cpc_cents,
conversions=metrics.conversions,
cost_cents=metrics.cost_cents,
roas=metrics.roas,
)
logger.info(
"Snapshot: impressions=%d clicks=%d ctr=%.3f cost=%dc",
metrics.impressions, metrics.clicks, metrics.ctr, metrics.cost_cents,
)
# 2. Kill switch check
if check_kill_switch(
campaign_id=str(google_campaign_id),
daily_spend_cents=metrics.cost_cents,
daily_budget_cents=daily_budget_cents,
threshold_pct=campaign["kill_switch_threshold_pct"],
):
if not dry_run:
trigger_kill_switch(
campaign_id=str(google_campaign_id),
reason="Daily spend exceeded kill switch threshold",
metric_value=metrics.cost_cents,
threshold_value=daily_budget_cents * (campaign["kill_switch_threshold_pct"] / 100),
ads_client=ads,
repo=repo,
)
repo.update_campaign_status(cid, "killed")
else:
logger.warning("[DRY RUN] Would trigger kill switch for campaign %d", cid)
continue
# 3. Check active experiment
active_experiment = repo.get_active_experiment(cid)
if active_experiment:
exp_id = active_experiment["id"]
baseline_gen_id = active_experiment["baseline_generation_id"]
variant_gen_id = active_experiment["variant_generation_id"]
# Get aggregate metrics for each generation
baseline_metrics_agg = repo.get_generation_metrics(baseline_gen_id)
variant_metrics_agg = repo.get_generation_metrics(variant_gen_id)
total_impressions = (
(baseline_metrics_agg["impressions"] or 0)
+ (variant_metrics_agg["impressions"] or 0)
)
if total_impressions >= config.MIN_IMPRESSIONS_FOR_EVAL:
repo.update_experiment_field(exp_id, "min_impressions_reached", 1)
# Build CampaignMetrics objects from aggregated DB data
from .ads_client import CampaignMetrics as CM
bm = CM(
campaign_id=str(google_campaign_id),
date_range_start="",
date_range_end="",
impressions=baseline_metrics_agg["impressions"] or 0,
clicks=baseline_metrics_agg["clicks"] or 0,
ctr=baseline_metrics_agg["ctr"] or 0.0,
avg_cpc_cents=baseline_metrics_agg["avg_cpc_cents"] or 0,
conversions=baseline_metrics_agg["conversions"] or 0.0,
cost_cents=baseline_metrics_agg["cost_cents"] or 0,
roas=baseline_metrics_agg["roas"] or 0.0,
)
vm = CM(
campaign_id=str(google_campaign_id),
date_range_start="",
date_range_end="",
impressions=variant_metrics_agg["impressions"] or 0,
clicks=variant_metrics_agg["clicks"] or 0,
ctr=variant_metrics_agg["ctr"] or 0.0,
avg_cpc_cents=variant_metrics_agg["avg_cpc_cents"] or 0,
conversions=variant_metrics_agg["conversions"] or 0.0,
cost_cents=variant_metrics_agg["cost_cents"] or 0,
roas=variant_metrics_agg["roas"] or 0.0,
)
comparison = compare_generations(
campaign_id=str(cid),
baseline_gen_id=baseline_gen_id,
variant_gen_id=variant_gen_id,
baseline_metrics=bm,
variant_metrics=vm,
metric=active_experiment["metric_compared"],
)
logger.info("Experiment result: %s", comparison.reason)
if comparison.winner == "variant":
logger.info("Promoting variant gen %d", variant_gen_id)
repo.update_experiment_status(exp_id, "promoted", winner="variant")
repo.update_campaign_current_generation(cid, variant_gen_id)
# Pause baseline ad (if tracked)
# Future: track ad_ids per generation for fine-grained pausing
elif comparison.winner == "baseline":
logger.info("Keeping baseline gen %d, rolling back variant", baseline_gen_id)
repo.update_experiment_status(exp_id, "rolled_back", winner="baseline")
else:
logger.info("Inconclusive — continuing data collection")
repo.update_experiment_status(exp_id, "inconclusive", winner="inconclusive")
else:
logger.info(
"Experiment collecting: %d/%d impressions so far",
total_impressions, config.MIN_IMPRESSIONS_FOR_EVAL,
)
continue
# 4. Check if cooldown has elapsed for next mutation
last_mutation_at = repo.get_last_mutation_time(cid)
if last_mutation_at:
elapsed_hours = (datetime.now() - last_mutation_at).total_seconds() / 3600
if elapsed_hours < config.MUTATION_COOLDOWN_HOURS:
logger.info(
"Cooldown: %.1fh elapsed / %dh required. Skipping mutation.",
elapsed_hours, config.MUTATION_COOLDOWN_HOURS,
)
continue
# 5. Generate new variant
current_gen = repo.get_current_generation(cid)
generation_number = (current_gen["generation_number"] + 1) if current_gen else 1
is_first_generation = generation_number == 1
if is_first_generation:
# Get campaign brief from DB
brief = campaign.get("campaign_brief", name)
logger.info("Generating initial copy pool (gen 1) for '%s'", name)
copy = generate_initial_pool(campaign_brief=brief)
else:
# Use performance data to inform the variant
gen_metrics = repo.get_generation_metrics(current_gen["id"])
performance_data = {
"impressions": gen_metrics.get("impressions", 0),
"clicks": gen_metrics.get("clicks", 0),
"ctr": f"{(gen_metrics.get('ctr', 0) or 0):.3f}",
"conversions": gen_metrics.get("conversions", 0),
"roas": f"{(gen_metrics.get('roas', 0) or 0):.2f}",
}
import json
current_headlines = json.loads(current_gen["headlines"])
current_descriptions = json.loads(current_gen["descriptions"])
current_keywords = json.loads(current_gen["keywords"])
logger.info("Generating variant (gen %d) for '%s'", generation_number, name)
copy = generate_variant(
current_headlines=current_headlines,
current_descriptions=current_descriptions,
current_keywords=current_keywords,
performance_data=performance_data,
)
# 6. HIL gate on first generation
approved = True
if is_first_generation:
display = _format_copy_for_display(copy.headlines, copy.descriptions)
approved = _hil_gate(display, name)
if not approved:
logger.warning("HIL gate: copy not approved. Saving as unapproved for manual review.")
import json
new_gen_id = repo.create_generation(
campaign_id=cid,
generation_number=generation_number,
headlines=copy.headlines,
descriptions=copy.descriptions,
keywords=copy.keywords,
rationale=copy.rationale,
approved=approved,
)
logger.info(
"Created generation %d (id=%d) approved=%s: %s",
generation_number, new_gen_id, approved, copy.rationale[:100],
)
if approved and not dry_run:
# Apply to Google Ads
created_ad = ads.create_rsa(
ad_group_id=google_ad_group_id,
headlines=copy.headlines,
descriptions=copy.descriptions,
)
repo.mark_generation_applied(new_gen_id)
logger.info("Applied gen %d to Google Ads (ad_id=%s)", new_gen_id, created_ad.ad_id)
# Create experiment record (if not first gen)
if current_gen:
repo.create_experiment(
campaign_id=cid,
baseline_generation_id=current_gen["id"],
variant_generation_id=new_gen_id,
metric_compared="ctr",
)
logger.info(
"Started experiment: baseline gen %d vs variant gen %d",
current_gen["id"], new_gen_id,
)
time.sleep(2) # rate limit between campaigns
logger.info("Loop complete.")
def show_status() -> None:
"""Print campaign status summary and exit."""
repo = Repository()
campaigns = repo.get_all_campaigns()
if not campaigns:
print("No campaigns found.")
return
print(f"\n{'Campaign':<30} {'Status':<10} {'Gen':<5} {'Experiments':<12} {'Last Run'}")
print("-" * 75)
for c in campaigns:
gen = repo.get_current_generation(c["id"])
gen_num = gen["generation_number"] if gen else 0
exp_count = repo.get_experiment_count(c["id"])
last_snap = repo.get_last_snapshot_date(c["id"]) or "never"
print(f"{c['name']:<30} {c['status']:<10} {gen_num:<5} {exp_count:<12} {last_snap}")
print()
def main() -> None:
parser = argparse.ArgumentParser(
description="AI Google Ads Loop — daily optimization runner"
)
parser.add_argument("--dry-run", action="store_true", help="No real API calls")
parser.add_argument("--campaign-id", type=int, default=None, help="Run only this campaign ID")
parser.add_argument("--status", action="store_true", help="Show campaign status and exit")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if args.status:
show_status()
return
run_loop(dry_run=args.dry_run, campaign_id_filter=args.campaign_id)
if __name__ == "__main__":
main()