Prompts updated
Browse files- app/ads1/ads_analyst.py +8 -3
- app/ads1/budget_optimizer.py +46 -8
- app/ads1/growth_finder.py +37 -28
- app/ads1/keyword_inspector.py +52 -6
- app/ads1/search_term_optimizer.py +52 -7
- app/recs/generate.py +19 -4
app/ads1/ads_analyst.py
CHANGED
|
@@ -134,9 +134,14 @@ def build_ads_analyst_prompt(context: dict) -> str:
|
|
| 134 |
payload = json.dumps(context, indent=2, default=str)
|
| 135 |
name = context.get("campaign_name", "this campaign")
|
| 136 |
return (
|
| 137 |
-
f"Write 3 to 5
|
| 138 |
-
"
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
)
|
| 141 |
|
| 142 |
|
|
|
|
| 134 |
payload = json.dumps(context, indent=2, default=str)
|
| 135 |
name = context.get("campaign_name", "this campaign")
|
| 136 |
return (
|
| 137 |
+
f"Write 3 to 5 executive Google Ads recommendations for {name}.\n"
|
| 138 |
+
"Return only markdown bullets. Start every line with '- '.\n"
|
| 139 |
+
"Each bullet must follow this exact pattern:\n"
|
| 140 |
+
"- <finding> β Evidence: <specific metric from data> β Action: <specific next step>\n"
|
| 141 |
+
"Do not restate the campaign name, raw data, prompt, task, or JSON keys.\n"
|
| 142 |
+
"Do not think aloud. Do not say what you can calculate. Do not invent causes.\n"
|
| 143 |
+
"If leads are 0, focus on conversion tracking, landing-page quality, and wasted spend risk.\n\n"
|
| 144 |
+
f"Data:\n{payload}"
|
| 145 |
)
|
| 146 |
|
| 147 |
|
app/ads1/budget_optimizer.py
CHANGED
|
@@ -28,12 +28,16 @@ def build_budget_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
|
| 28 |
|
| 29 |
df = build_budget_features(df)
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
return {
|
| 35 |
"campaign_name": campaign_name,
|
| 36 |
-
"campaigns": df.to_dict("records")
|
| 37 |
}
|
| 38 |
|
| 39 |
def build_budget_optimizer_prompt(context: dict) -> str:
|
|
@@ -64,6 +68,43 @@ def build_budget_optimizer_prompt(context: dict) -> str:
|
|
| 64 |
)
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
| 68 |
print("\nπ [budget_optimizer] STARTED", flush=True)
|
| 69 |
|
|
@@ -82,10 +123,7 @@ def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 82 |
|
| 83 |
if is_bad_llm_output(result):
|
| 84 |
print("β οΈ [budget_optimizer] fallback triggered", flush=True)
|
| 85 |
-
return (
|
| 86 |
-
"- Unable to generate budget recommendations right now.\n"
|
| 87 |
-
"- Try again or check campaign data quality."
|
| 88 |
-
)
|
| 89 |
|
| 90 |
print("π€ [budget_optimizer] result received", flush=True)
|
| 91 |
-
return result
|
|
|
|
| 28 |
|
| 29 |
df = build_budget_features(df)
|
| 30 |
|
| 31 |
+
keep_cols = [
|
| 32 |
+
col
|
| 33 |
+
for col in ["name", "cost", "clicks", "impressions", "conversions", "ctr", "cpa", "cpc", "conv_per_cost"]
|
| 34 |
+
if col in df.columns
|
| 35 |
+
]
|
| 36 |
+
df = df.sort_values(["conversions", "conv_per_cost", "cost"], ascending=[False, False, False]).head(15)
|
| 37 |
|
| 38 |
return {
|
| 39 |
"campaign_name": campaign_name,
|
| 40 |
+
"campaigns": df[keep_cols].round(2).to_dict("records")
|
| 41 |
}
|
| 42 |
|
| 43 |
def build_budget_optimizer_prompt(context: dict) -> str:
|
|
|
|
| 68 |
)
|
| 69 |
|
| 70 |
|
| 71 |
+
def build_budget_optimizer_prompt(context: dict) -> str:
|
| 72 |
+
payload = json.dumps(context, indent=2, default=str)
|
| 73 |
+
name = context.get("campaign_name", "this account")
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
f"Write 3 to 5 budget allocation recommendations for {name}.\n"
|
| 77 |
+
"Return only markdown bullets. Start every line with '- '.\n"
|
| 78 |
+
"Each bullet must follow this exact pattern:\n"
|
| 79 |
+
"- <budget action> β Evidence: <cost, conversions, CPA/CPC/CTR from data> β Expected impact: <short outcome>\n"
|
| 80 |
+
"Use only the supplied campaign metrics. Do not repeat the prompt or data. Do not invent missing campaigns.\n"
|
| 81 |
+
"If there is only one campaign, recommend within-campaign caution instead of cross-campaign reallocation.\n\n"
|
| 82 |
+
f"Data:\n{payload}"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def rule_based_budget_actions(context: dict) -> str:
|
| 87 |
+
rows = context.get("campaigns", [])
|
| 88 |
+
if not rows:
|
| 89 |
+
return "- Hold budget β Evidence: no campaign rows available β Expected impact: prevents blind budget changes until data sync is fixed."
|
| 90 |
+
|
| 91 |
+
bullets = []
|
| 92 |
+
for row in rows[:5]:
|
| 93 |
+
name = row.get("name") or context.get("campaign_name") or "Selected campaign"
|
| 94 |
+
cost = row.get("cost", 0)
|
| 95 |
+
conversions = row.get("conversions", 0)
|
| 96 |
+
cpa = row.get("cpa", 0)
|
| 97 |
+
if conversions > 0:
|
| 98 |
+
bullets.append(
|
| 99 |
+
f"- Protect budget on {name} β Evidence: ${cost:.2f} cost, {conversions} conversions, ${cpa:.2f} CPA β Expected impact: keeps spend on proven demand."
|
| 100 |
+
)
|
| 101 |
+
else:
|
| 102 |
+
bullets.append(
|
| 103 |
+
f"- Cap budget on {name} β Evidence: ${cost:.2f} cost and 0 conversions β Expected impact: limits wasted spend while tracking or targeting is reviewed."
|
| 104 |
+
)
|
| 105 |
+
return "\n\n".join(bullets[:5])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def run_budget_optimizer(dfs: dict, campaign_name: str | None = None) -> str:
|
| 109 |
print("\nπ [budget_optimizer] STARTED", flush=True)
|
| 110 |
|
|
|
|
| 123 |
|
| 124 |
if is_bad_llm_output(result):
|
| 125 |
print("β οΈ [budget_optimizer] fallback triggered", flush=True)
|
| 126 |
+
return rule_based_budget_actions(context)
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
print("π€ [budget_optimizer] result received", flush=True)
|
| 129 |
+
return result
|
app/ads1/growth_finder.py
CHANGED
|
@@ -28,12 +28,19 @@ def build_growth_finder_context(dfs: dict, campaign_name: str | None = None):
|
|
| 28 |
|
| 29 |
df = build_growth_finder_features(df)
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
return {
|
| 35 |
"campaign_name": campaign_name,
|
| 36 |
-
"keywords": df.to_dict("records")
|
| 37 |
}
|
| 38 |
|
| 39 |
def build_growth_finder_prompt(context: dict) -> str:
|
|
@@ -41,28 +48,33 @@ def build_growth_finder_prompt(context: dict) -> str:
|
|
| 41 |
name = context.get("campaign_name", "this account")
|
| 42 |
|
| 43 |
return (
|
| 44 |
-
f""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
- If performance is weak, DO NOT suggest scaling.
|
| 52 |
-
- Do not infer missing ad groups or structures.
|
| 53 |
-
- No generic marketing theory.
|
| 54 |
-
|
| 55 |
-
OUTPUT FORMAT:
|
| 56 |
-
- Opportunity: <keyword / segment>
|
| 57 |
-
Evidence: <CTR, conversions, CPA>
|
| 58 |
-
Why it works: <short justification>
|
| 59 |
-
Action: <scale suggestion (budget / bids / match type expansion)>
|
| 60 |
-
|
| 61 |
-
DATA:
|
| 62 |
-
{payload}
|
| 63 |
-
"""
|
| 64 |
)
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
| 67 |
print("\nπ [growth_finder] STARTED", flush=True)
|
| 68 |
|
|
@@ -81,10 +93,7 @@ def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 81 |
|
| 82 |
if is_bad_llm_output(result):
|
| 83 |
print("β οΈ [growth_finder] fallback triggered", flush=True)
|
| 84 |
-
return (
|
| 85 |
-
"- Unable to generate scaling opportunities right now.\n"
|
| 86 |
-
"- Try again or check data quality."
|
| 87 |
-
)
|
| 88 |
|
| 89 |
print("π€ [growth_finder] result received", flush=True)
|
| 90 |
-
return result
|
|
|
|
| 28 |
|
| 29 |
df = build_growth_finder_features(df)
|
| 30 |
|
| 31 |
+
df = df[df["conversions"] > 0].sort_values(
|
| 32 |
+
["efficiency_score", "conversions", "ctr"],
|
| 33 |
+
ascending=[False, False, False],
|
| 34 |
+
).head(20)
|
| 35 |
+
keep_cols = [
|
| 36 |
+
col
|
| 37 |
+
for col in ["keyword", "cost", "clicks", "impressions", "conversions", "ctr", "cvr", "cpa", "efficiency_score"]
|
| 38 |
+
if col in df.columns
|
| 39 |
+
]
|
| 40 |
|
| 41 |
return {
|
| 42 |
"campaign_name": campaign_name,
|
| 43 |
+
"keywords": df[keep_cols].round(2).to_dict("records")
|
| 44 |
}
|
| 45 |
|
| 46 |
def build_growth_finder_prompt(context: dict) -> str:
|
|
|
|
| 48 |
name = context.get("campaign_name", "this account")
|
| 49 |
|
| 50 |
return (
|
| 51 |
+
f"Write 3 to 5 data-backed scaling opportunities for {name}.\n"
|
| 52 |
+
"Return only markdown bullets. Start every line with '- '.\n"
|
| 53 |
+
"Each bullet must follow this exact pattern:\n"
|
| 54 |
+
"- Scale: '<keyword>' β Evidence: <conversions, CPA, CTR/CVR from data> β Action: <specific budget, bid, or match-type expansion>\n"
|
| 55 |
+
"Only recommend scaling when conversions are greater than 0. If no scalable rows exist, say '- No scaling candidate β Evidence: no converting keyword rows β Action: fix tracking or demand quality first.'\n"
|
| 56 |
+
"Use only supplied metrics. Do not infer ad groups. Do not repeat the prompt. Do not think aloud.\n\n"
|
| 57 |
+
f"Data:\n{payload}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
+
|
| 61 |
+
def rule_based_growth_actions(context: dict) -> str:
|
| 62 |
+
rows = context.get("keywords", [])
|
| 63 |
+
if not rows:
|
| 64 |
+
return "- No scaling candidate β Evidence: no converting keyword rows β Action: fix tracking or demand quality before increasing budget."
|
| 65 |
+
|
| 66 |
+
bullets = []
|
| 67 |
+
for row in rows[:5]:
|
| 68 |
+
keyword = row.get("keyword", "Unknown keyword")
|
| 69 |
+
conversions = row.get("conversions", 0)
|
| 70 |
+
cpa = row.get("cpa", 0)
|
| 71 |
+
ctr = row.get("ctr", 0)
|
| 72 |
+
bullets.append(
|
| 73 |
+
f"- Scale: '{keyword}' β Evidence: {conversions} conversions, ${cpa:.2f} CPA, {ctr:.2f}% CTR β Action: test a small bid or budget increase while monitoring CPA."
|
| 74 |
+
)
|
| 75 |
+
return "\n\n".join(bullets)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def run_growth_finder(dfs: dict, campaign_name: str | None = None) -> str:
|
| 79 |
print("\nπ [growth_finder] STARTED", flush=True)
|
| 80 |
|
|
|
|
| 93 |
|
| 94 |
if is_bad_llm_output(result):
|
| 95 |
print("β οΈ [growth_finder] fallback triggered", flush=True)
|
| 96 |
+
return rule_based_growth_actions(context)
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
print("π€ [growth_finder] result received", flush=True)
|
| 99 |
+
return result
|
app/ads1/keyword_inspector.py
CHANGED
|
@@ -47,6 +47,43 @@ def build_keyword_prompt(context: dict) -> str:
|
|
| 47 |
DATA:
|
| 48 |
{payload}
|
| 49 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# -------------------------
|
| 51 |
# Main runner
|
| 52 |
# -------------------------
|
|
@@ -63,9 +100,21 @@ def run_keyword_inspector(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 63 |
if campaign_name and "campaign_name" in df.columns:
|
| 64 |
df = df[df["campaign_name"] == campaign_name]
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
context = {
|
| 67 |
"campaign_name": campaign_name,
|
| 68 |
-
"keywords": df.to_dict("records")
|
| 69 |
}
|
| 70 |
|
| 71 |
print("π§ [keyword_inspector] context built", flush=True)
|
|
@@ -77,10 +126,7 @@ def run_keyword_inspector(dfs: dict, campaign_name: str | None = None) -> str:
|
|
| 77 |
|
| 78 |
if is_bad_llm_output(result):
|
| 79 |
print("β οΈ [keyword_inspector] LLM fallback triggered", flush=True)
|
| 80 |
-
return (
|
| 81 |
-
"- Unable to generate LLM insights right now.\n"
|
| 82 |
-
"- Check keyword data quality or retry."
|
| 83 |
-
)
|
| 84 |
|
| 85 |
print("π€ [keyword_inspector] result received", flush=True)
|
| 86 |
-
return result
|
|
|
|
| 47 |
DATA:
|
| 48 |
{payload}
|
| 49 |
"""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_keyword_prompt(context: dict) -> str:
|
| 53 |
+
payload = json.dumps(context, indent=2, default=str)
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
"Write 3 to 5 keyword recommendations for a preschool Google Ads campaign.\n"
|
| 57 |
+
"Return only markdown bullets. Start every line with '- '.\n"
|
| 58 |
+
"Each bullet must follow this exact pattern:\n"
|
| 59 |
+
"- <Label>: '<keyword>' β Evidence: <clicks, cost, conversions, CTR/CPA from data> β Action: <specific bid, pause, or scale action>\n"
|
| 60 |
+
"Allowed labels: Winning, Wasted Spend, Scale, Reduce, Investigate.\n"
|
| 61 |
+
"Use only supplied keyword metrics. Do not mention ad groups if ad group data is missing. Do not think aloud.\n\n"
|
| 62 |
+
f"Data:\n{payload}"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def rule_based_keyword_actions(context: dict) -> str:
|
| 67 |
+
bullets = []
|
| 68 |
+
for row in context.get("keywords", []):
|
| 69 |
+
keyword = row.get("keyword", "Unknown keyword")
|
| 70 |
+
cost = row.get("cost", 0)
|
| 71 |
+
clicks = row.get("clicks", 0)
|
| 72 |
+
conversions = row.get("conversions", 0)
|
| 73 |
+
cpa = row.get("cpa", 0)
|
| 74 |
+
if conversions > 0:
|
| 75 |
+
bullets.append(
|
| 76 |
+
f"- Winning: '{keyword}' β Evidence: {clicks} clicks, ${cost:.2f} cost, {conversions} conversions, ${cpa:.2f} CPA β Action: protect budget and test a modest bid increase."
|
| 77 |
+
)
|
| 78 |
+
elif cost > 0:
|
| 79 |
+
bullets.append(
|
| 80 |
+
f"- Wasted Spend: '{keyword}' β Evidence: {clicks} clicks, ${cost:.2f} cost, 0 conversions β Action: reduce bid or pause until intent is proven."
|
| 81 |
+
)
|
| 82 |
+
if len(bullets) >= 5:
|
| 83 |
+
break
|
| 84 |
+
return "\n\n".join(bullets) or "- Investigate: no keyword rows available β Evidence: no usable data β Action: verify keyword data sync."
|
| 85 |
+
|
| 86 |
+
|
| 87 |
# -------------------------
|
| 88 |
# Main runner
|
| 89 |
# -------------------------
|
|
|
|
| 100 |
if campaign_name and "campaign_name" in df.columns:
|
| 101 |
df = df[df["campaign_name"] == campaign_name]
|
| 102 |
|
| 103 |
+
keep_cols = [
|
| 104 |
+
col
|
| 105 |
+
for col in ["keyword", "cost", "clicks", "impressions", "conversions", "ctr", "cpa"]
|
| 106 |
+
if col in df.columns
|
| 107 |
+
]
|
| 108 |
+
winners = df[df["conversions"] > 0].sort_values(["conversions", "cpa"], ascending=[False, True]).head(8)
|
| 109 |
+
waste = df[df["conversions"] == 0].sort_values("cost", ascending=False).head(8)
|
| 110 |
+
df = pd.concat([winners, waste], ignore_index=True)
|
| 111 |
+
if "keyword" in df.columns:
|
| 112 |
+
df = df.drop_duplicates(subset=["keyword"])
|
| 113 |
+
df = df.head(20)
|
| 114 |
+
|
| 115 |
context = {
|
| 116 |
"campaign_name": campaign_name,
|
| 117 |
+
"keywords": df[keep_cols].round(2).to_dict("records")
|
| 118 |
}
|
| 119 |
|
| 120 |
print("π§ [keyword_inspector] context built", flush=True)
|
|
|
|
| 126 |
|
| 127 |
if is_bad_llm_output(result):
|
| 128 |
print("β οΈ [keyword_inspector] LLM fallback triggered", flush=True)
|
| 129 |
+
return rule_based_keyword_actions(context)
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
print("π€ [keyword_inspector] result received", flush=True)
|
| 132 |
+
return result
|
app/ads1/search_term_optimizer.py
CHANGED
|
@@ -34,7 +34,13 @@ 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)
|
|
@@ -66,6 +72,22 @@ def build_search_optimizer_prompt(context: dict) -> str:
|
|
| 66 |
"""
|
| 67 |
)
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# -------------------------
|
| 70 |
# Context builder (FULL DATA approach)
|
| 71 |
# -------------------------
|
|
@@ -77,13 +99,39 @@ def build_search_optimizer_context(dfs: dict, campaign_name: str | None = None):
|
|
| 77 |
|
| 78 |
df = build_search_term_features(df)
|
| 79 |
df = prepare_context_df(df)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
return {
|
| 82 |
"campaign_name": campaign_name,
|
| 83 |
-
"search_terms": df.to_dict("records")
|
| 84 |
}
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
# -------------------------
|
| 88 |
# Runner
|
| 89 |
# -------------------------
|
|
@@ -105,10 +153,7 @@ def run_search_term_optimizer(dfs: dict, campaign_name: str | None = None) -> st
|
|
| 105 |
|
| 106 |
if is_bad_llm_output(result):
|
| 107 |
print("β οΈ [search_term_optimizer] LLM fallback triggered", flush=True)
|
| 108 |
-
return (
|
| 109 |
-
"- Unable to generate insights right now.\n"
|
| 110 |
-
"- Try again or check data quality."
|
| 111 |
-
)
|
| 112 |
|
| 113 |
print("π€ [search_term_optimizer] result received", flush=True)
|
| 114 |
-
return result
|
|
|
|
| 34 |
Instead of semantic filtering, we just cap size for token control.
|
| 35 |
Keeps high-variance distribution intact.
|
| 36 |
"""
|
| 37 |
+
converters = df[df["conversions"] > 0].sort_values(["conversions", "cpa"], ascending=[False, True]).head(12)
|
| 38 |
+
waste = df[df["conversions"] == 0].sort_values("cost", ascending=False).head(12)
|
| 39 |
+
high_intent = df[(df["conversions"] > 0) & (df["cvr"] > 0)].sort_values("cvr", ascending=False).head(8)
|
| 40 |
+
out = pd.concat([converters, waste, high_intent], ignore_index=True)
|
| 41 |
+
if "search_term" in out.columns:
|
| 42 |
+
out = out.drop_duplicates(subset=["search_term"])
|
| 43 |
+
return out.head(max_rows)
|
| 44 |
|
| 45 |
# -------------------------
|
| 46 |
# Prompt (LLM owns all reasoning now)
|
|
|
|
| 72 |
"""
|
| 73 |
)
|
| 74 |
|
| 75 |
+
|
| 76 |
+
def build_search_optimizer_prompt(context: dict) -> str:
|
| 77 |
+
payload = json.dumps(context, indent=2, default=str)
|
| 78 |
+
name = context.get("campaign_name", "this campaign")
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
f"Write 3 to 5 search term optimization actions for {name}.\n"
|
| 82 |
+
"Return only markdown bullets. Start every line with '- '.\n"
|
| 83 |
+
"Each bullet must follow this exact pattern:\n"
|
| 84 |
+
"- <Category>: '<search term>' β Evidence: <cost, clicks, conversions, CPA/CVR from data> β Action: <pause, reduce bid, add as keyword, or add as negative>\n"
|
| 85 |
+
"Allowed categories: Wasted Spend, High Intent, Scale, Negative Keyword Candidate, Investigate.\n"
|
| 86 |
+
"Use only supplied search terms. Do not explain calculations. Do not repeat the prompt. Do not think aloud.\n\n"
|
| 87 |
+
f"Data:\n{payload}"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
# -------------------------
|
| 92 |
# Context builder (FULL DATA approach)
|
| 93 |
# -------------------------
|
|
|
|
| 99 |
|
| 100 |
df = build_search_term_features(df)
|
| 101 |
df = prepare_context_df(df)
|
| 102 |
+
keep_cols = [
|
| 103 |
+
col
|
| 104 |
+
for col in ["search_term", "cost", "clicks", "impressions", "conversions", "ctr", "cvr", "cpc", "cpa"]
|
| 105 |
+
if col in df.columns
|
| 106 |
+
]
|
| 107 |
|
| 108 |
return {
|
| 109 |
"campaign_name": campaign_name,
|
| 110 |
+
"search_terms": df[keep_cols].round(2).to_dict("records")
|
| 111 |
}
|
| 112 |
|
| 113 |
|
| 114 |
+
def rule_based_search_actions(context: dict) -> str:
|
| 115 |
+
bullets = []
|
| 116 |
+
for row in context.get("search_terms", []):
|
| 117 |
+
term = row.get("search_term", "Unknown term")
|
| 118 |
+
cost = row.get("cost", 0)
|
| 119 |
+
clicks = row.get("clicks", 0)
|
| 120 |
+
conversions = row.get("conversions", 0)
|
| 121 |
+
cvr = row.get("cvr", 0)
|
| 122 |
+
if conversions > 0:
|
| 123 |
+
bullets.append(
|
| 124 |
+
f"- High Intent: '{term}' β Evidence: {clicks} clicks, ${cost:.2f} cost, {conversions} conversions, {cvr:.2f}% CVR β Action: add as keyword and test higher bid."
|
| 125 |
+
)
|
| 126 |
+
elif cost > 0:
|
| 127 |
+
bullets.append(
|
| 128 |
+
f"- Wasted Spend: '{term}' β Evidence: {clicks} clicks, ${cost:.2f} cost, 0 conversions β Action: reduce bid or add as negative if intent is poor."
|
| 129 |
+
)
|
| 130 |
+
if len(bullets) >= 5:
|
| 131 |
+
break
|
| 132 |
+
return "\n\n".join(bullets) or "- Investigate: no search terms available β Evidence: no usable rows β Action: verify search term data sync."
|
| 133 |
+
|
| 134 |
+
|
| 135 |
# -------------------------
|
| 136 |
# Runner
|
| 137 |
# -------------------------
|
|
|
|
| 153 |
|
| 154 |
if is_bad_llm_output(result):
|
| 155 |
print("β οΈ [search_term_optimizer] LLM fallback triggered", flush=True)
|
| 156 |
+
return rule_based_search_actions(context)
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
print("π€ [search_term_optimizer] result received", flush=True)
|
| 159 |
+
return result
|
app/recs/generate.py
CHANGED
|
@@ -18,9 +18,10 @@ _infer_lock = threading.Lock()
|
|
| 18 |
|
| 19 |
_SYSTEM = (
|
| 20 |
"You are a Google Ads analyst. "
|
| 21 |
-
"Reply with
|
| 22 |
-
"Each bullet must be one short,
|
| 23 |
-
"
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
|
@@ -45,6 +46,20 @@ def _looks_like_garbage(text: str) -> bool:
|
|
| 45 |
lower = text.lower()
|
| 46 |
if "return only" in lower or "no reasoning" in lower or "no explanation" in lower:
|
| 47 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if "google ads analyst" in lower and text.count("-") < 2:
|
| 49 |
return True
|
| 50 |
if "ads performance analyst" in lower and text.count("-") < 2:
|
|
@@ -107,7 +122,7 @@ def _message_text(message: dict) -> str:
|
|
| 107 |
|
| 108 |
def _infer(llm, messages: list[dict[str, str]]) -> str:
|
| 109 |
max_tokens = int(os.getenv("LLAMA_MAX_TOKENS", "384"))
|
| 110 |
-
temperature = float(os.getenv("LLAMA_TEMPERATURE", "0.
|
| 111 |
|
| 112 |
try:
|
| 113 |
out = llm.create_chat_completion(
|
|
|
|
| 18 |
|
| 19 |
_SYSTEM = (
|
| 20 |
"You are a Google Ads analyst. "
|
| 21 |
+
"Reply with markdown bullets only. "
|
| 22 |
+
"Each bullet must be one short, data-backed recommendation. "
|
| 23 |
+
"Do not repeat the prompt, do not explain your reasoning process, and do not invent missing fields. "
|
| 24 |
+
"Use only the evidence supplied by the user."
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
|
|
| 46 |
lower = text.lower()
|
| 47 |
if "return only" in lower or "no reasoning" in lower or "no explanation" in lower:
|
| 48 |
return True
|
| 49 |
+
echo_markers = [
|
| 50 |
+
"task:",
|
| 51 |
+
"strict rules:",
|
| 52 |
+
"output format:",
|
| 53 |
+
"focus on:",
|
| 54 |
+
"data:",
|
| 55 |
+
"we can calculate",
|
| 56 |
+
"i assume",
|
| 57 |
+
"actually,",
|
| 58 |
+
"however, the data",
|
| 59 |
+
"let's",
|
| 60 |
+
]
|
| 61 |
+
if any(marker in lower for marker in echo_markers):
|
| 62 |
+
return True
|
| 63 |
if "google ads analyst" in lower and text.count("-") < 2:
|
| 64 |
return True
|
| 65 |
if "ads performance analyst" in lower and text.count("-") < 2:
|
|
|
|
| 122 |
|
| 123 |
def _infer(llm, messages: list[dict[str, str]]) -> str:
|
| 124 |
max_tokens = int(os.getenv("LLAMA_MAX_TOKENS", "384"))
|
| 125 |
+
temperature = float(os.getenv("LLAMA_TEMPERATURE", "0.15"))
|
| 126 |
|
| 127 |
try:
|
| 128 |
out = llm.create_chat_completion(
|