Prompts and card logics improved
Browse files- app/ads1/budget_optimizer.py +41 -14
- app/ads1/growth_finder.py +29 -19
- app/ads1/search_term_optimizer.py +42 -9
app/ads1/budget_optimizer.py
CHANGED
|
@@ -36,16 +36,16 @@ def build_budget_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
|
| 36 |
total_conversions = dfs["campaigns"]["conversions"].fillna(0).sum()
|
| 37 |
account_cpa = dfs["campaigns"]["cost"].fillna(0).sum() / total_conversions if total_conversions else 0
|
| 38 |
|
| 39 |
-
def
|
| 40 |
if row["conversions"] == 0:
|
| 41 |
-
return "
|
| 42 |
if account_cpa and row["cpa"] <= account_cpa * 0.8:
|
| 43 |
-
return "
|
| 44 |
if account_cpa and row["cpa"] >= account_cpa * 1.25:
|
| 45 |
-
return "
|
| 46 |
-
return "
|
| 47 |
|
| 48 |
-
df["budget_action"] = df.apply(
|
| 49 |
df["segment"] = "Campaign"
|
| 50 |
df = df.sort_values(["conv_per_cost", "conversions"], ascending=[False, False]).head(8)
|
| 51 |
|
|
@@ -64,7 +64,7 @@ def build_budget_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
|
| 64 |
kw = build_budget_features(kw)
|
| 65 |
kw["name"] = "Keyword: " + kw["keyword"].astype(str)
|
| 66 |
kw["segment"] = "Keyword"
|
| 67 |
-
kw["budget_action"] = kw.apply(
|
| 68 |
kw = kw.sort_values(["conv_per_cost", "conversions", "cost"], ascending=[False, False, False]).head(6)
|
| 69 |
kw_cols = [col for col in keep_cols if col in kw.columns]
|
| 70 |
action_rows = pd.concat([action_rows, kw[kw_cols]], ignore_index=True)
|
|
@@ -81,10 +81,40 @@ def build_budget_optimizer_prompt(context: dict) -> str:
|
|
| 81 |
|
| 82 |
return (
|
| 83 |
f"Write 3 to 5 bullet points of actionable budget optimization insights for {name}.\n"
|
| 84 |
-
"Use the budget_actions list only.
|
| 85 |
-
"Use simple language. One
|
| 86 |
f"Data (JSON):\n{payload}"
|
| 87 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
|
@@ -103,12 +133,9 @@ def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 103 |
|
| 104 |
result = generate_explanation(prompt)
|
| 105 |
|
| 106 |
-
if is_bad_llm_output(result):
|
| 107 |
print("⚠️ [budget_optimizer] fallback triggered", flush=True)
|
| 108 |
-
return (
|
| 109 |
-
"- Unable to generate budget recommendations right now.\n"
|
| 110 |
-
"- Try again or check campaign data quality."
|
| 111 |
-
)
|
| 112 |
|
| 113 |
print("📤 [budget_optimizer] result received", flush=True)
|
| 114 |
return result
|
|
|
|
| 36 |
total_conversions = dfs["campaigns"]["conversions"].fillna(0).sum()
|
| 37 |
account_cpa = dfs["campaigns"]["cost"].fillna(0).sum() / total_conversions if total_conversions else 0
|
| 38 |
|
| 39 |
+
def action_type_for(row):
|
| 40 |
if row["conversions"] == 0:
|
| 41 |
+
return "reduce"
|
| 42 |
if account_cpa and row["cpa"] <= account_cpa * 0.8:
|
| 43 |
+
return "increase"
|
| 44 |
if account_cpa and row["cpa"] >= account_cpa * 1.25:
|
| 45 |
+
return "reduce"
|
| 46 |
+
return "hold"
|
| 47 |
|
| 48 |
+
df["budget_action"] = df.apply(action_type_for, axis=1)
|
| 49 |
df["segment"] = "Campaign"
|
| 50 |
df = df.sort_values(["conv_per_cost", "conversions"], ascending=[False, False]).head(8)
|
| 51 |
|
|
|
|
| 64 |
kw = build_budget_features(kw)
|
| 65 |
kw["name"] = "Keyword: " + kw["keyword"].astype(str)
|
| 66 |
kw["segment"] = "Keyword"
|
| 67 |
+
kw["budget_action"] = kw.apply(action_type_for, axis=1)
|
| 68 |
kw = kw.sort_values(["conv_per_cost", "conversions", "cost"], ascending=[False, False, False]).head(6)
|
| 69 |
kw_cols = [col for col in keep_cols if col in kw.columns]
|
| 70 |
action_rows = pd.concat([action_rows, kw[kw_cols]], ignore_index=True)
|
|
|
|
| 81 |
|
| 82 |
return (
|
| 83 |
f"Write 3 to 5 bullet points of actionable budget optimization insights for {name}.\n"
|
| 84 |
+
"Use the budget_actions list only. Each bullet must mention the campaign or keyword name, the budget action, and the evidence.\n"
|
| 85 |
+
"Use simple language. One self-contained budget action per bullet. Start each line with '- '. No intro sentence. Do not quote JSON values by themselves.\n\n"
|
| 86 |
f"Data (JSON):\n{payload}"
|
| 87 |
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def rule_based_budget_actions(context: dict) -> str:
|
| 91 |
+
rows = context.get("budget_actions", [])
|
| 92 |
+
if not rows:
|
| 93 |
+
return "- Hold budget for this campaign because no usable budget rows were found; verify campaign and keyword data before changing spend."
|
| 94 |
+
|
| 95 |
+
bullets = []
|
| 96 |
+
for row in rows[:5]:
|
| 97 |
+
name = row.get("name", "this segment")
|
| 98 |
+
action = row.get("budget_action", "hold")
|
| 99 |
+
cost = row.get("cost", 0)
|
| 100 |
+
conversions = row.get("conversions", 0)
|
| 101 |
+
cpa = row.get("cpa", 0)
|
| 102 |
+
cpc = row.get("cpc", 0)
|
| 103 |
+
conv_per_cost = row.get("conv_per_cost", 0)
|
| 104 |
+
|
| 105 |
+
if action == "increase":
|
| 106 |
+
bullets.append(
|
| 107 |
+
f"- Increase budget cautiously on {name} because it has {conversions} conversions at CPA {cpa:.2f}, CPC {cpc:.2f}, and conversion efficiency {conv_per_cost:.3f} on {cost:.2f} spend."
|
| 108 |
+
)
|
| 109 |
+
elif action == "reduce":
|
| 110 |
+
bullets.append(
|
| 111 |
+
f"- Reduce or cap budget on {name} because it has {conversions} conversions at CPA {cpa:.2f} after {cost:.2f} spend, making it a weaker use of budget."
|
| 112 |
+
)
|
| 113 |
+
else:
|
| 114 |
+
bullets.append(
|
| 115 |
+
f"- Hold budget on {name} because performance is near benchmark with {conversions} conversions, CPA {cpa:.2f}, and CPC {cpc:.2f}."
|
| 116 |
+
)
|
| 117 |
+
return "\n\n".join(bullets)
|
| 118 |
|
| 119 |
|
| 120 |
def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
|
|
|
| 133 |
|
| 134 |
result = generate_explanation(prompt)
|
| 135 |
|
| 136 |
+
if is_bad_llm_output(result) or not result.strip().startswith("-") or result.count('"') >= 4:
|
| 137 |
print("⚠️ [budget_optimizer] fallback triggered", flush=True)
|
| 138 |
+
return rule_based_budget_actions(context)
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
print("📤 [budget_optimizer] result received", flush=True)
|
| 141 |
return result
|
app/ads1/growth_finder.py
CHANGED
|
@@ -46,13 +46,7 @@ def build_growth_finder_context(dfs: dict, campaign_name: str | None = None):
|
|
| 46 |
ascending=[False, False, False],
|
| 47 |
).head(8)
|
| 48 |
|
| 49 |
-
df["growth_action"] =
|
| 50 |
-
lambda row: (
|
| 51 |
-
f"Scale '{row['keyword']}' with a cautious bid or budget increase because it has "
|
| 52 |
-
f"{int(row['conversions'])} conversions at CPA {row['cpa']:.2f}."
|
| 53 |
-
),
|
| 54 |
-
axis=1,
|
| 55 |
-
)
|
| 56 |
|
| 57 |
keep_cols = [
|
| 58 |
col
|
|
@@ -73,11 +67,34 @@ def build_growth_finder_prompt(context: dict) -> str:
|
|
| 73 |
return (
|
| 74 |
f"Write 3 to 5 bullet points of actionable growth opportunities for {name}.\n"
|
| 75 |
"Use the growth_candidates list only. Suggest ways to scale winners, expand related intent, or increase budget on efficient areas.\n"
|
| 76 |
-
"
|
| 77 |
-
"Use simple language. One growth opportunity per bullet. Start each line with '- '. No intro sentence.\n\n"
|
| 78 |
f"Data (JSON):\n{payload}"
|
| 79 |
)
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
| 82 |
print("\n🚀 [growth_finder] STARTED", flush=True)
|
| 83 |
|
|
@@ -86,11 +103,7 @@ def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 86 |
|
| 87 |
context = build_growth_finder_context(dfs, campaign_name)
|
| 88 |
if not context.get("growth_candidates"):
|
| 89 |
-
|
| 90 |
-
return (
|
| 91 |
-
f"- No safe scale-up candidate found for {campaign_name} because its converting keywords are not efficient enough versus the account average CPA of {account_cpa}.\n"
|
| 92 |
-
"- Improve growth readiness first by cutting weak intent, tightening match types, and waiting for lower CPA before increasing budget."
|
| 93 |
-
)
|
| 94 |
|
| 95 |
print("🧠 [growth_finder] context built", flush=True)
|
| 96 |
|
|
@@ -100,12 +113,9 @@ def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 100 |
|
| 101 |
result = generate_explanation(prompt)
|
| 102 |
|
| 103 |
-
if is_bad_llm_output(result):
|
| 104 |
print("⚠️ [growth_finder] fallback triggered", flush=True)
|
| 105 |
-
return (
|
| 106 |
-
"- Unable to generate scaling opportunities right now.\n"
|
| 107 |
-
"- Try again or check data quality."
|
| 108 |
-
)
|
| 109 |
|
| 110 |
print("📤 [growth_finder] result received", flush=True)
|
| 111 |
return result
|
|
|
|
| 46 |
ascending=[False, False, False],
|
| 47 |
).head(8)
|
| 48 |
|
| 49 |
+
df["growth_action"] = "scale"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
keep_cols = [
|
| 52 |
col
|
|
|
|
| 67 |
return (
|
| 68 |
f"Write 3 to 5 bullet points of actionable growth opportunities for {name}.\n"
|
| 69 |
"Use the growth_candidates list only. Suggest ways to scale winners, expand related intent, or increase budget on efficient areas.\n"
|
| 70 |
+
"Each bullet must mention the keyword, the growth action, and the evidence. Do not list weak keywords or diagnose poor performance.\n"
|
| 71 |
+
"Use simple language. One self-contained growth opportunity per bullet. Start each line with '- '. No intro sentence. Do not quote JSON values by themselves.\n\n"
|
| 72 |
f"Data (JSON):\n{payload}"
|
| 73 |
)
|
| 74 |
|
| 75 |
+
|
| 76 |
+
def rule_based_growth_actions(context: dict) -> str:
|
| 77 |
+
rows = context.get("growth_candidates", [])
|
| 78 |
+
if not rows:
|
| 79 |
+
account_cpa = context.get("account_average_cpa", 0)
|
| 80 |
+
campaign_name = context.get("campaign_name", "this campaign")
|
| 81 |
+
return (
|
| 82 |
+
f"- No safe scale-up candidate found for {campaign_name} because its converting keywords are not efficient enough versus the account average CPA of {account_cpa}.\n"
|
| 83 |
+
"- Improve growth readiness first by cutting weak intent, tightening match types, and waiting for lower CPA before increasing budget."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
bullets = []
|
| 87 |
+
for row in rows[:5]:
|
| 88 |
+
keyword = row.get("keyword", "this keyword")
|
| 89 |
+
conversions = row.get("conversions", 0)
|
| 90 |
+
cpa = row.get("cpa", 0)
|
| 91 |
+
cvr = row.get("cvr", 0)
|
| 92 |
+
ctr = row.get("ctr", 0)
|
| 93 |
+
bullets.append(
|
| 94 |
+
f"- Scale '{keyword}' because it has {conversions} conversions at CPA {cpa:.2f}, CVR {cvr:.2f}%, and CTR {ctr:.2f}%; test a cautious bid or budget increase."
|
| 95 |
+
)
|
| 96 |
+
return "\n\n".join(bullets)
|
| 97 |
+
|
| 98 |
def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
| 99 |
print("\n🚀 [growth_finder] STARTED", flush=True)
|
| 100 |
|
|
|
|
| 103 |
|
| 104 |
context = build_growth_finder_context(dfs, campaign_name)
|
| 105 |
if not context.get("growth_candidates"):
|
| 106 |
+
return rule_based_growth_actions(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
print("🧠 [growth_finder] context built", flush=True)
|
| 109 |
|
|
|
|
| 113 |
|
| 114 |
result = generate_explanation(prompt)
|
| 115 |
|
| 116 |
+
if is_bad_llm_output(result) or not result.strip().startswith("-") or result.count('"') >= 4:
|
| 117 |
print("⚠️ [growth_finder] fallback triggered", flush=True)
|
| 118 |
+
return rule_based_growth_actions(context)
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
print("📤 [growth_finder] result received", flush=True)
|
| 121 |
return result
|
app/ads1/search_term_optimizer.py
CHANGED
|
@@ -34,7 +34,9 @@ def prepare_context_df(df: pd.DataFrame, max_rows: int = 200) -> pd.DataFrame:
|
|
| 34 |
Instead of semantic filtering, we just cap size for token control.
|
| 35 |
Keeps high-variance distribution intact.
|
| 36 |
"""
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
|
| 39 |
# -------------------------
|
| 40 |
# Prompt (LLM owns all reasoning now)
|
|
@@ -65,8 +67,8 @@ def build_search_optimizer_prompt(context: dict) -> str:
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
f"Write 3 to 5 bullet points of actionable search term cleanup insights for {name}.\n"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
f"Data (JSON):\n{payload}"
|
| 71 |
)
|
| 72 |
|
|
@@ -82,13 +84,47 @@ def build_search_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
|
| 82 |
|
| 83 |
df = build_search_term_features(df)
|
| 84 |
df = prepare_context_df(df)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
return {
|
| 87 |
"campaign_name": campaign_name,
|
| 88 |
-
"search_terms": df.to_dict("records")
|
| 89 |
}
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
# -------------------------
|
| 93 |
# Runner
|
| 94 |
# -------------------------
|
|
@@ -108,12 +144,9 @@ def run_search_term_optimizer(dfs: dict, campaign_name: str | None = None) -> st
|
|
| 108 |
|
| 109 |
result = generate_explanation(prompt)
|
| 110 |
|
| 111 |
-
if is_bad_llm_output(result):
|
| 112 |
print("⚠️ [search_term_optimizer] LLM fallback triggered", flush=True)
|
| 113 |
-
return (
|
| 114 |
-
"- Unable to generate insights right now.\n"
|
| 115 |
-
"- Try again or check data quality."
|
| 116 |
-
)
|
| 117 |
|
| 118 |
print("📤 [search_term_optimizer] result received", flush=True)
|
| 119 |
return result
|
|
|
|
| 34 |
Instead of semantic filtering, we just cap size for token control.
|
| 35 |
Keeps high-variance distribution intact.
|
| 36 |
"""
|
| 37 |
+
waste = df[df["conversions"] == 0].sort_values("cost", ascending=False).head(10)
|
| 38 |
+
converters = df[df["conversions"] > 0].sort_values(["conversions", "cpa"], ascending=[False, True]).head(10)
|
| 39 |
+
return pd.concat([waste, converters], ignore_index=True).drop_duplicates(subset=["search_term"]).head(max_rows)
|
| 40 |
|
| 41 |
# -------------------------
|
| 42 |
# Prompt (LLM owns all reasoning now)
|
|
|
|
| 67 |
|
| 68 |
return (
|
| 69 |
f"Write 3 to 5 bullet points of actionable search term cleanup insights for {name}.\n"
|
| 70 |
+
"Use the search_terms list only. total_cost is total spend for that search term; cpc is cost per click.\n"
|
| 71 |
+
"Each bullet must mention the search term, the action, and the evidence. Use simple language. Start each line with '- '. No intro sentence.\n\n"
|
| 72 |
f"Data (JSON):\n{payload}"
|
| 73 |
)
|
| 74 |
|
|
|
|
| 84 |
|
| 85 |
df = build_search_term_features(df)
|
| 86 |
df = prepare_context_df(df)
|
| 87 |
+
df["action_type"] = df.apply(
|
| 88 |
+
lambda row: "add as keyword or scale" if row["conversions"] > 0 else "pause or add as negative",
|
| 89 |
+
axis=1,
|
| 90 |
+
)
|
| 91 |
+
df = df.rename(columns={"cost": "total_cost"})
|
| 92 |
+
keep_cols = [
|
| 93 |
+
col
|
| 94 |
+
for col in ["search_term", "action_type", "total_cost", "clicks", "impressions", "conversions", "ctr", "cvr", "cpc", "cpa"]
|
| 95 |
+
if col in df.columns
|
| 96 |
+
]
|
| 97 |
|
| 98 |
return {
|
| 99 |
"campaign_name": campaign_name,
|
| 100 |
+
"search_terms": df[keep_cols].round(2).to_dict("records")
|
| 101 |
}
|
| 102 |
|
| 103 |
|
| 104 |
+
def rule_based_search_actions(context: dict) -> str:
|
| 105 |
+
rows = context.get("search_terms", [])
|
| 106 |
+
if not rows:
|
| 107 |
+
return "- No search term cleanup action found because no usable search term rows were available."
|
| 108 |
+
|
| 109 |
+
bullets = []
|
| 110 |
+
for row in rows[:5]:
|
| 111 |
+
term = row.get("search_term", "this search term")
|
| 112 |
+
total_cost = row.get("total_cost", 0)
|
| 113 |
+
clicks = row.get("clicks", 0)
|
| 114 |
+
conversions = row.get("conversions", 0)
|
| 115 |
+
cpa = row.get("cpa", 0)
|
| 116 |
+
cvr = row.get("cvr", 0)
|
| 117 |
+
if conversions > 0:
|
| 118 |
+
bullets.append(
|
| 119 |
+
f"- Add or scale '{term}' because it produced {conversions} conversions from {clicks} clicks at CPA {cpa:.2f} and CVR {cvr:.2f}% on {total_cost:.2f} total spend."
|
| 120 |
+
)
|
| 121 |
+
else:
|
| 122 |
+
bullets.append(
|
| 123 |
+
f"- Pause or add '{term}' as a negative because it spent {total_cost:.2f} across {clicks} clicks with 0 conversions."
|
| 124 |
+
)
|
| 125 |
+
return "\n\n".join(bullets)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
# -------------------------
|
| 129 |
# Runner
|
| 130 |
# -------------------------
|
|
|
|
| 144 |
|
| 145 |
result = generate_explanation(prompt)
|
| 146 |
|
| 147 |
+
if is_bad_llm_output(result) or not result.strip().startswith("-") or "cost is" in result.lower():
|
| 148 |
print("⚠️ [search_term_optimizer] LLM fallback triggered", flush=True)
|
| 149 |
+
return rule_based_search_actions(context)
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
print("📤 [search_term_optimizer] result received", flush=True)
|
| 152 |
return result
|