| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import random |
| import re |
| import sys |
| from pathlib import Path |
|
|
| import pandas as pd |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[2] |
| sys.path.insert(0, str(ROOT)) |
|
|
| from app.ads1.prompt_templates import ads_analyst_prompt, keyword_inspector_prompt, search_term_cleaner_prompt |
|
|
|
|
| SYSTEM = "You are a Google Ads analyst. Reply with concise actionable markdown bullets only." |
|
|
|
|
| def money_to_float(value) -> float: |
| if pd.isna(value): |
| return 0.0 |
| text = str(value) |
| text = re.sub(r"[^0-9.\-]", "", text) |
| try: |
| return float(text) if text else 0.0 |
| except ValueError: |
| return 0.0 |
|
|
|
|
| def canonical_text(value: str) -> str: |
| text = str(value or "").strip().lower() |
| text = re.sub(r"\s+", " ", text) |
| return text |
|
|
|
|
| def canonical_campaign(value: str) -> str: |
| text = canonical_text(value) |
| if "data" in text and ("analytic" in text or "anlytic" in text or "analytcis" in text): |
| return "Data Analytics Course" |
| return " ".join(part.capitalize() for part in text.split()) |
|
|
|
|
| def clean_csv(path: Path) -> pd.DataFrame: |
| df = pd.read_csv(path) |
| keep = [ |
| "Ad_ID", |
| "Campaign_Name", |
| "Clicks", |
| "Impressions", |
| "Cost", |
| "Leads", |
| "Conversions", |
| "Sale_Amount", |
| "Ad_Date", |
| "Location", |
| "Device", |
| "Keyword", |
| ] |
| df = df[[col for col in keep if col in df.columns]].copy() |
| df = df.dropna(how="all") |
|
|
| df["campaign_name"] = df["Campaign_Name"].map(canonical_campaign) |
| df["keyword"] = df["Keyword"].map(canonical_text) |
| df["search_term"] = df["keyword"] |
| df["location"] = df["Location"].map(canonical_text) |
| df["device"] = df["Device"].map(lambda x: canonical_text(x).capitalize()) |
| df["date"] = pd.to_datetime(df["Ad_Date"], errors="coerce", dayfirst=True).dt.strftime("%Y-%m-%d") |
|
|
| for col in ["Clicks", "Impressions", "Leads", "Conversions"]: |
| df[col.lower()] = pd.to_numeric(df[col], errors="coerce").fillna(0) |
| df["cost"] = df["Cost"].map(money_to_float) |
| df["sale_amount"] = df["Sale_Amount"].map(money_to_float) if "Sale_Amount" in df.columns else 0.0 |
|
|
| df = df[(df["clicks"] > 0) | (df["impressions"] > 0) | (df["cost"] > 0)] |
| return df[ |
| [ |
| "campaign_name", |
| "keyword", |
| "search_term", |
| "clicks", |
| "impressions", |
| "cost", |
| "leads", |
| "conversions", |
| "sale_amount", |
| "date", |
| "location", |
| "device", |
| ] |
| ].reset_index(drop=True) |
|
|
|
|
| def aggregate_campaign(df: pd.DataFrame, campaign_name: str) -> dict: |
| c = df[df["campaign_name"] == campaign_name] |
| clicks = c["clicks"].sum() |
| impressions = c["impressions"].sum() |
| cost = c["cost"].sum() |
| conversions = c["conversions"].sum() |
| leads = c["leads"].sum() |
| ctr = clicks / impressions * 100 if impressions else 0 |
| cpa = cost / conversions if conversions else cost |
| cvr = conversions / clicks * 100 if clicks else 0 |
| return { |
| "campaign_name": campaign_name, |
| "spend": round(float(cost), 2), |
| "clicks": int(clicks), |
| "impressions": int(impressions), |
| "leads": int(leads), |
| "conversions": int(conversions), |
| "ctr": round(float(ctr), 2), |
| "cvr": round(float(cvr), 2), |
| "cpa": round(float(cpa), 2), |
| } |
|
|
|
|
| def aggregate_terms(df: pd.DataFrame, field: str, max_rows: int = 12) -> list[dict]: |
| grouped = ( |
| df.groupby(field, dropna=False) |
| .agg( |
| clicks=("clicks", "sum"), |
| impressions=("impressions", "sum"), |
| total_cost=("cost", "sum"), |
| leads=("leads", "sum"), |
| conversions=("conversions", "sum"), |
| ) |
| .reset_index() |
| ) |
| grouped["ctr"] = grouped["clicks"] / grouped["impressions"].replace(0, pd.NA) * 100 |
| grouped["cvr"] = grouped["conversions"] / grouped["clicks"].replace(0, pd.NA) * 100 |
| grouped["cpc"] = grouped["total_cost"] / grouped["clicks"].replace(0, pd.NA) |
| grouped["cpa"] = grouped["total_cost"] / grouped["conversions"].replace(0, pd.NA) |
| grouped = grouped.fillna(0) |
|
|
| converters = grouped[grouped["conversions"] > 0].sort_values(["conversions", "cpa"], ascending=[False, True]).head(max_rows // 2) |
| waste = grouped[grouped["conversions"] == 0].sort_values("total_cost", ascending=False).head(max_rows // 2) |
| out = pd.concat([waste, converters], ignore_index=True).drop_duplicates(subset=[field]).head(max_rows) |
| return out.round(2).to_dict("records") |
|
|
|
|
| def ads_context(df: pd.DataFrame, campaign_name: str) -> dict: |
| c = df[df["campaign_name"] == campaign_name] |
| keyword_rows = aggregate_terms(c, "keyword", 8) |
| best = sorted([r for r in keyword_rows if r["conversions"] > 0], key=lambda r: (r["cpa"], -r["conversions"]))[:3] |
| worst = sorted(keyword_rows, key=lambda r: (r["conversions"] > 0, -r["total_cost"]))[:3] |
| return { |
| "campaign_name": campaign_name, |
| "campaign": aggregate_campaign(df, campaign_name), |
| "top_drivers": { |
| "best_keywords": best, |
| "worst_keywords": worst, |
| }, |
| } |
|
|
|
|
| def ads_answer(context: dict) -> str: |
| c = context["campaign"] |
| bullets = [] |
| if c["conversions"] == 0: |
| bullets.append(f"- {c['campaign_name']} has no conversions after {c['clicks']} clicks, so review tracking, landing page quality, and weak-intent traffic before increasing spend.") |
| elif c["cpa"] > 100: |
| bullets.append(f"- {c['campaign_name']} is expensive at CPA {c['cpa']:.2f}, so reduce spend on weak terms and tighten targeting before scaling.") |
| else: |
| bullets.append(f"- {c['campaign_name']} is generating conversions at CPA {c['cpa']:.2f}, so protect the best-performing keyword themes.") |
|
|
| for row in context["top_drivers"]["best_keywords"][:2]: |
| bullets.append(f"- Scale '{row['keyword']}' because it produced {int(row['conversions'])} conversions at CPA {row['cpa']:.2f}.") |
| for row in context["top_drivers"]["worst_keywords"][:2]: |
| if row["conversions"] == 0: |
| bullets.append(f"- Reduce or pause '{row['keyword']}' because it spent {row['total_cost']:.2f} with 0 conversions.") |
| return "\n\n".join(bullets[:5]) |
|
|
|
|
| def keyword_context(df: pd.DataFrame, campaign_name: str) -> dict: |
| c = df[df["campaign_name"] == campaign_name] |
| return { |
| "campaign_name": campaign_name, |
| "keywords": aggregate_terms(c, "keyword", 12), |
| } |
|
|
|
|
| def keyword_answer(context: dict) -> str: |
| bullets = [] |
| for row in context["keywords"][:5]: |
| keyword = row["keyword"] |
| if row["conversions"] > 0: |
| bullets.append(f"- Treat '{keyword}' as a winning keyword because it produced {int(row['conversions'])} conversions at CPA {row['cpa']:.2f} and CVR {row['cvr']:.2f}%.") |
| else: |
| bullets.append(f"- Reduce or pause '{keyword}' because it spent {row['total_cost']:.2f} across {int(row['clicks'])} clicks with 0 conversions.") |
| return "\n\n".join(bullets) |
|
|
|
|
| def search_context(df: pd.DataFrame, campaign_name: str) -> dict: |
| c = df[df["campaign_name"] == campaign_name] |
| rows = aggregate_terms(c, "search_term", 12) |
| for row in rows: |
| row["action_type"] = "add as keyword or scale" if row["conversions"] > 0 else "pause or add as negative" |
| return { |
| "campaign_name": campaign_name, |
| "search_terms": rows, |
| } |
|
|
|
|
| def search_answer(context: dict) -> str: |
| bullets = [] |
| for row in context["search_terms"][:5]: |
| term = row["search_term"] |
| if row["conversions"] > 0: |
| bullets.append(f"- Add or scale '{term}' because it produced {int(row['conversions'])} conversions at CPA {row['cpa']:.2f} and CVR {row['cvr']:.2f}% on {row['total_cost']:.2f} total spend.") |
| else: |
| bullets.append(f"- Pause or add '{term}' as a negative because it spent {row['total_cost']:.2f} across {int(row['clicks'])} clicks with 0 conversions.") |
| return "\n\n".join(bullets) |
|
|
|
|
| def record(user: str, assistant: str, card: str, source: str, campaign_name: str) -> dict: |
| return { |
| "messages": [ |
| {"role": "system", "content": SYSTEM}, |
| {"role": "user", "content": user}, |
| {"role": "assistant", "content": assistant}, |
| ], |
| "metadata": { |
| "card": card, |
| "source": source, |
| "campaign_name": campaign_name, |
| }, |
| } |
|
|
|
|
| def csv_records(df: pd.DataFrame, target_count: int, seed: int) -> list[dict]: |
| rng = random.Random(seed) |
| campaigns = df["campaign_name"].dropna().unique().tolist() |
| records: list[dict] = [] |
|
|
| slices = [] |
| for campaign in campaigns: |
| slices.append((campaign, df[df["campaign_name"] == campaign])) |
| for device in df["device"].dropna().unique().tolist(): |
| part = df[(df["campaign_name"] == campaign) & (df["device"] == device)] |
| if len(part) >= 20: |
| slices.append((f"{campaign} - {device}", part.assign(campaign_name=f"{campaign} - {device}"))) |
| for location in df["location"].dropna().unique().tolist(): |
| part = df[(df["campaign_name"] == campaign) & (df["location"] == location)] |
| if len(part) >= 20: |
| slices.append((f"{campaign} - {location}", part.assign(campaign_name=f"{campaign} - {location}"))) |
|
|
| while len(records) < target_count: |
| name, part = rng.choice(slices) |
| card = rng.choice(["ads_analyst", "keyword_inspector", "search_term_cleaner"]) |
|
|
| if card == "ads_analyst": |
| ctx = ads_context(part, name) |
| user = ads_analyst_prompt(name, json.dumps(ctx, indent=2, default=str)) |
| assistant = ads_answer(ctx) |
| elif card == "keyword_inspector": |
| ctx = keyword_context(part, name) |
| user = keyword_inspector_prompt(json.dumps(ctx, indent=2, default=str)) |
| assistant = keyword_answer(ctx) |
| else: |
| ctx = search_context(part, name) |
| user = search_term_cleaner_prompt(name, json.dumps(ctx, indent=2, default=str)) |
| assistant = search_answer(ctx) |
| records.append(record(user, assistant, card, "csv_cleaned", name)) |
|
|
| return records |
|
|
|
|
| def synthetic_records(count: int, seed: int) -> list[dict]: |
| rng = random.Random(seed) |
| records: list[dict] = [] |
| campaigns = ["Admissions Push", "Course Signup", "Demo Booking", "Lead Generation"] |
| good_terms = ["data analytics course", "analytics certification", "data analyst training", "online analytics class"] |
| bad_terms = ["free jobs", "salary guide", "software crack", "unrelated tutorial"] |
|
|
| while len(records) < count: |
| campaign = rng.choice(campaigns) |
| card = rng.choice(["ads_analyst", "keyword_inspector", "search_term_cleaner"]) |
| rows = [] |
| for term in good_terms + bad_terms: |
| is_good = term in good_terms |
| clicks = rng.randint(40, 240) |
| impressions = clicks * rng.randint(15, 55) |
| conversions = rng.randint(4, 28) if is_good else rng.choice([0, 0, 1]) |
| total_cost = round(clicks * rng.uniform(0.8, 4.5), 2) |
| cpa = total_cost / conversions if conversions else total_cost |
| cvr = conversions / clicks * 100 if clicks else 0 |
| cpc = total_cost / clicks if clicks else 0 |
| rows.append( |
| { |
| "keyword": term, |
| "search_term": term, |
| "clicks": clicks, |
| "impressions": impressions, |
| "total_cost": round(total_cost, 2), |
| "conversions": conversions, |
| "ctr": round(clicks / impressions * 100, 2), |
| "cvr": round(cvr, 2), |
| "cpc": round(cpc, 2), |
| "cpa": round(cpa, 2), |
| } |
| ) |
|
|
| if card == "ads_analyst": |
| total_clicks = sum(r["clicks"] for r in rows) |
| total_impressions = sum(r["impressions"] for r in rows) |
| total_cost = sum(r["total_cost"] for r in rows) |
| total_conversions = sum(r["conversions"] for r in rows) |
| ctx = { |
| "campaign_name": campaign, |
| "campaign": { |
| "campaign_name": campaign, |
| "spend": round(total_cost, 2), |
| "clicks": total_clicks, |
| "impressions": total_impressions, |
| "conversions": total_conversions, |
| "ctr": round(total_clicks / total_impressions * 100, 2), |
| "cpa": round(total_cost / total_conversions, 2) if total_conversions else round(total_cost, 2), |
| }, |
| "top_drivers": { |
| "best_keywords": [r for r in rows if r["conversions"] > 0][:3], |
| "worst_keywords": [r for r in rows if r["conversions"] == 0][:3], |
| }, |
| } |
| user = ads_analyst_prompt(campaign, json.dumps(ctx, indent=2, default=str)) |
| assistant = ads_answer(ctx) |
| elif card == "keyword_inspector": |
| ctx = {"campaign_name": campaign, "keywords": rows} |
| user = keyword_inspector_prompt(json.dumps(ctx, indent=2, default=str)) |
| assistant = keyword_answer(ctx) |
| else: |
| ctx = {"campaign_name": campaign, "search_terms": rows} |
| for row in ctx["search_terms"]: |
| row["action_type"] = "add as keyword or scale" if row["conversions"] > 0 else "pause or add as negative" |
| user = search_term_cleaner_prompt(campaign, json.dumps(ctx, indent=2, default=str)) |
| assistant = search_answer(ctx) |
| records.append(record(user, assistant, card, "synthetic", campaign)) |
|
|
| return records |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--csv", type=Path, required=True) |
| parser.add_argument("--out_dir", type=Path, default=ROOT / "fine_tuning" / "data") |
| parser.add_argument("--csv_count", type=int, default=400) |
| parser.add_argument("--synthetic_count", type=int, default=600) |
| parser.add_argument("--val_ratio", type=float, default=0.1) |
| parser.add_argument("--seed", type=int, default=42) |
| args = parser.parse_args() |
|
|
| cleaned = clean_csv(args.csv) |
| records = csv_records(cleaned, args.csv_count, args.seed) |
| records.extend(synthetic_records(args.synthetic_count, args.seed + 1)) |
| random.Random(args.seed).shuffle(records) |
|
|
| val_size = max(1, int(len(records) * args.val_ratio)) |
| val = records[:val_size] |
| train = records[val_size:] |
|
|
| args.out_dir.mkdir(parents=True, exist_ok=True) |
| cleaned.to_csv(args.out_dir / "csv_cleaned_pruned.csv", index=False) |
| for path, rows in [(args.out_dir / "train.jsonl", train), (args.out_dir / "val.jsonl", val)]: |
| with path.open("w", encoding="utf-8") as handle: |
| for row in rows: |
| handle.write(json.dumps(row, ensure_ascii=False) + "\n") |
|
|
| print(f"Cleaned CSV rows: {len(cleaned)}") |
| print(f"Wrote train: {len(train)} records") |
| print(f"Wrote val: {len(val)} records") |
| print(f"Output dir: {args.out_dir}") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|
|
|