Search term optimizer logic simplified for direct llm analysis
Browse files
app/ads1/search_term_optimizer.py
CHANGED
|
@@ -1,99 +1,110 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
from app.recs.generate import TARGET_CPL, generate_explanation, is_bad_llm_output
|
| 3 |
import json
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
def build_search_term_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 6 |
df = df.copy()
|
| 7 |
|
| 8 |
df["cost"] = df["cost"].fillna(0)
|
| 9 |
df["clicks"] = df["clicks"].fillna(0)
|
| 10 |
df["impressions"] = df["impressions"].fillna(0)
|
|
|
|
| 11 |
if "conversions" not in df.columns:
|
| 12 |
df["conversions"] = 0
|
| 13 |
|
| 14 |
df["conversions"] = df["conversions"].fillna(0)
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
df["cpc"] = df["cost"] / df["clicks"].replace(0, 1)
|
| 18 |
df["cpa"] = df["cost"] / df["conversions"].replace(0, 1)
|
| 19 |
|
| 20 |
return df
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
df = dfs["search_terms"].copy()
|
| 24 |
-
if (campaign_name
|
| 25 |
-
and "campaign_name" in df.columns):
|
| 26 |
-
df = df[df["campaign_name"] == campaign_name]
|
| 27 |
-
return df
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
(df["clicks"] >= 20) &
|
| 32 |
-
(df["cost"] >= 5) &
|
| 33 |
-
(df["conversions"] == 0)
|
| 34 |
-
]
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
(df["clicks"] >= 10) &
|
| 39 |
-
(df["ctr"] < 2) &
|
| 40 |
-
(df["conversions"] == 0)
|
| 41 |
-
]
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
def build_search_optimizer_prompt(context: dict) -> str:
|
| 47 |
-
# Convert data to clean JSON string strings for better LLM readability
|
| 48 |
-
neg_json = json.dumps(context['negative_keywords'], indent=2)
|
| 49 |
-
rev_json = json.dumps(context['review_terms'], indent=2)
|
| 50 |
-
win_json = json.dumps(context['winning_terms'], indent=2)
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
{rev_json}
|
| 60 |
|
| 61 |
-
|
| 62 |
-
{win_json}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
{{
|
| 66 |
-
"waste_reduction_actions": ["action 1", "action 2"],
|
| 67 |
-
"scaling_opportunities": ["opportunity 1", "opportunity 2"],
|
| 68 |
-
"immediate_negative_keywords": ["keyword 1", "keyword 2"]
|
| 69 |
-
}}
|
| 70 |
-
Return ONLY valid JSON. Do not include markdown code blocks, explanations, or extra text."""
|
| 71 |
|
| 72 |
-
|
| 73 |
-
df = _search_terms_for_campaign(dfs, campaign_name)
|
| 74 |
-
df = build_search_term_features(df)
|
| 75 |
-
negatives = detect_negative_terms(df)
|
| 76 |
-
review = detect_review_terms(df)
|
| 77 |
-
winners = detect_scaling_terms(df)
|
| 78 |
|
| 79 |
-
|
| 80 |
-
"campaign_name": campaign_name,
|
| 81 |
-
"negative_keywords": negatives.head(10).to_dict("records"),
|
| 82 |
-
"review_terms": review.head(10).to_dict("records"),
|
| 83 |
-
"winning_terms": winners.head(10).to_dict("records"),
|
| 84 |
-
}
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
if not dfs:
|
| 89 |
return (
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
)
|
| 93 |
-
|
| 94 |
-
print("
|
| 95 |
-
prompt = build_search_optimizer_prompt(context)
|
| 96 |
-
print("✍️ [search_term_card] prompt built",flush=True)
|
| 97 |
-
result = generate_explanation(prompt)
|
| 98 |
-
print("📤 [search_term_card] result received", flush=True)
|
| 99 |
return result
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from app.recs.generate import generate_explanation, is_bad_llm_output
|
| 4 |
|
| 5 |
+
# -------------------------
|
| 6 |
+
# Feature engineering only
|
| 7 |
+
# -------------------------
|
| 8 |
def build_search_term_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 9 |
df = df.copy()
|
| 10 |
|
| 11 |
df["cost"] = df["cost"].fillna(0)
|
| 12 |
df["clicks"] = df["clicks"].fillna(0)
|
| 13 |
df["impressions"] = df["impressions"].fillna(0)
|
| 14 |
+
|
| 15 |
if "conversions" not in df.columns:
|
| 16 |
df["conversions"] = 0
|
| 17 |
|
| 18 |
df["conversions"] = df["conversions"].fillna(0)
|
| 19 |
+
|
| 20 |
+
# Core metrics
|
| 21 |
+
df["ctr"] = (df["clicks"] / df["impressions"].replace(0, 1)) * 100
|
| 22 |
+
df["cvr"] = (df["conversions"] / df["clicks"].replace(0, 1)) * 100
|
| 23 |
df["cpc"] = df["cost"] / df["clicks"].replace(0, 1)
|
| 24 |
df["cpa"] = df["cost"] / df["conversions"].replace(0, 1)
|
| 25 |
|
| 26 |
return df
|
| 27 |
|
| 28 |
+
|
| 29 |
+
# -------------------------
|
| 30 |
+
# Optional: lightweight pruning (NOT rule-based logic)
|
| 31 |
+
# -------------------------
|
| 32 |
+
def prepare_context_df(df: pd.DataFrame, max_rows: int = 200) -> pd.DataFrame:
|
| 33 |
+
"""
|
| 34 |
+
Instead of semantic filtering, we just cap size for token control.
|
| 35 |
+
Keeps high-variance distribution intact.
|
| 36 |
+
"""
|
| 37 |
+
return df.sort_values("cost", ascending=False).head(max_rows)
|
| 38 |
+
|
| 39 |
+
# -------------------------
|
| 40 |
+
# Prompt (LLM owns all reasoning now)
|
| 41 |
+
# -------------------------
|
| 42 |
+
def build_search_optimizer_prompt(context: dict) -> str:
|
| 43 |
+
payload = json.dumps(context, indent=2, default=str)
|
| 44 |
+
name = context.get("campaign_name", "this campaign")
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
f"You are an expert Google Ads search term optimization strategist.\n\n"
|
| 48 |
+
f"Analyze search term performance for {name}.\n\n"
|
| 49 |
+
"Your job is to provide 3 to 5 actionable insights.\n"
|
| 50 |
+
"Focus on:\n"
|
| 51 |
+
"- wasted spend search terms\n"
|
| 52 |
+
"- high intent converting terms\n"
|
| 53 |
+
"- scaling opportunities\n"
|
| 54 |
+
"- terms to pause or reduce bids\n"
|
| 55 |
+
"- unusual CTR / CPA / conversion patterns\n\n"
|
| 56 |
+
"Rules:\n"
|
| 57 |
+
"- Be specific and actionable\n"
|
| 58 |
+
"- Use simple language\n"
|
| 59 |
+
"- One insight per bullet point\n"
|
| 60 |
+
"- Start each line with '- '\n"
|
| 61 |
+
"- No intro sentence, no summary, no JSON\n\n"
|
| 62 |
+
f"Data:\n{payload}"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# -------------------------
|
| 66 |
+
# Context builder (FULL DATA approach)
|
| 67 |
+
# -------------------------
|
| 68 |
+
def build_search_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
| 69 |
df = dfs["search_terms"].copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
if campaign_name and "campaign_name" in df.columns:
|
| 72 |
+
df = df[df["campaign_name"] == campaign_name]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
df = build_search_term_features(df)
|
| 75 |
+
df = prepare_context_df(df)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
return {
|
| 78 |
+
"campaign_name": campaign_name,
|
| 79 |
+
"search_terms": df.to_dict("records") # FULL dataset context (bounded)
|
| 80 |
+
}
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
# -------------------------
|
| 84 |
+
# Runner
|
| 85 |
+
# -------------------------
|
| 86 |
+
def run_search_term_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
| 87 |
+
print("\n🚀 [search_term_optimizer] STARTED", flush=True)
|
| 88 |
|
| 89 |
+
if not dfs or "search_terms" not in dfs:
|
| 90 |
+
return "⚠️ No search term data — select a campaign first."
|
| 91 |
|
| 92 |
+
context = build_search_optimizer_context(dfs, campaign_name)
|
|
|
|
| 93 |
|
| 94 |
+
print("🧠 [search_term_optimizer] context built", flush=True)
|
|
|
|
| 95 |
|
| 96 |
+
prompt = build_search_optimizer_prompt(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
+
print("✍️ [search_term_optimizer] prompt built", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
+
result = generate_explanation(prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
if is_bad_llm_output(result):
|
| 103 |
+
print("⚠️ [search_term_optimizer] LLM fallback triggered", flush=True)
|
|
|
|
| 104 |
return (
|
| 105 |
+
"- Unable to generate insights right now.\n"
|
| 106 |
+
"- Try again or check data quality."
|
| 107 |
)
|
| 108 |
+
|
| 109 |
+
print("📤 [search_term_optimizer] result received", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return result
|