ps1811 commited on
Commit
49f15ae
·
1 Parent(s): 3a35e18

Files changed.

Browse files
app.py CHANGED
@@ -24,12 +24,13 @@ def run_ads_card(state):
24
 
25
  print("📦 [run_ads_card] state received", type(state), flush=True)
26
  dfs = state.get("full_dfs")
 
27
  print("📊 [run_ads_card] extracted dfs:", type(dfs), flush=True)
28
 
29
  if not dfs:
30
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
31
 
32
- result = run_ads_analyst_card(dfs)
33
  print("✅ [run_ads_card] returning result", flush=True)
34
  return result
35
  except Exception as e:
@@ -46,10 +47,11 @@ def run_budget_card(state):
46
  return "⚠️ Select a campaign first"
47
 
48
  dfs = state.get("full_dfs")
 
49
  if not dfs:
50
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
51
 
52
- result = run_budget_optimizer_card(dfs)
53
  print("✅ [run_budget_card] returning result", flush=True)
54
  return result
55
  except Exception as e:
@@ -61,10 +63,8 @@ def startup():
61
  try:
62
  init_db()
63
  print("✅ DB initialized successfully", flush=True)
64
- return "ok"
65
  except Exception as e:
66
  print("⚠️ DB failed:", e, flush=True)
67
- return "error"
68
 
69
 
70
  print("🔥 STEP 2: DB init done", flush=True)
@@ -132,7 +132,7 @@ with gr.Blocks() as demo:
132
  fn=initial_data_load,
133
  outputs=[full_state, campaign_table, total_spend, total_leads, average_cpl, active_campaigns],
134
  )
135
- demo.load(fn=startup, outputs=[])
136
 
137
  demo.queue()
138
 
 
24
 
25
  print("📦 [run_ads_card] state received", type(state), flush=True)
26
  dfs = state.get("full_dfs")
27
+ campaign_name = state.get("campaign_name")
28
  print("📊 [run_ads_card] extracted dfs:", type(dfs), flush=True)
29
 
30
  if not dfs:
31
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
32
 
33
+ result = run_ads_analyst_card(dfs, campaign_name=campaign_name)
34
  print("✅ [run_ads_card] returning result", flush=True)
35
  return result
36
  except Exception as e:
 
47
  return "⚠️ Select a campaign first"
48
 
49
  dfs = state.get("full_dfs")
50
+ campaign_name = state.get("campaign_name")
51
  if not dfs:
52
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
53
 
54
+ result = run_budget_optimizer_card(dfs, campaign_name=campaign_name)
55
  print("✅ [run_budget_card] returning result", flush=True)
56
  return result
57
  except Exception as e:
 
63
  try:
64
  init_db()
65
  print("✅ DB initialized successfully", flush=True)
 
66
  except Exception as e:
67
  print("⚠️ DB failed:", e, flush=True)
 
68
 
69
 
70
  print("🔥 STEP 2: DB init done", flush=True)
 
132
  fn=initial_data_load,
133
  outputs=[full_state, campaign_table, total_spend, total_leads, average_cpl, active_campaigns],
134
  )
135
+ demo.load(fn=startup)
136
 
137
  demo.queue()
138
 
app/ads1/ads_analyst.py CHANGED
@@ -1,17 +1,35 @@
1
- import pandas as pd
2
- from typing import Dict
3
 
4
- from app.recs.generate import generate_explanation
5
 
6
- TARGET_CPL = 20.0
7
 
8
  # -------------------------
9
  # 1. DATA BUILDERS
10
  # -------------------------
11
 
12
 
 
 
 
 
 
 
 
 
 
 
13
  def build_campaign_snapshot(dfs: dict) -> dict:
14
  df = dfs["campaigns"]
 
 
 
 
 
 
 
 
 
15
 
16
  total_spend = df["cost"].sum()
17
  total_clicks = df["clicks"].sum()
@@ -22,21 +40,24 @@ def build_campaign_snapshot(dfs: dict) -> dict:
22
  cpl = (total_spend / total_leads) if total_leads else 0
23
 
24
  return {
25
- "spend": round(total_spend, 2),
26
  "clicks": int(total_clicks),
27
  "impressions": int(total_impr),
28
  "leads": int(total_leads),
29
- "ctr": round(ctr, 2),
30
- "cpl": round(cpl, 2),
31
  }
32
 
33
 
34
  def build_simple_trend(df_hourly: pd.DataFrame) -> dict:
 
 
 
35
  df = df_hourly.copy()
36
  df["date"] = pd.to_datetime(df["date"])
37
  df = df.sort_values("date")
38
 
39
- mid = len(df) // 2
40
  first, second = df.iloc[:mid], df.iloc[mid:]
41
 
42
  def agg(x):
@@ -60,11 +81,13 @@ def build_simple_trend(df_hourly: pd.DataFrame) -> dict:
60
 
61
  def build_top_drivers(dfs: dict) -> dict:
62
  kw = dfs["keywords"].copy()
 
 
63
 
64
  kw["cpl"] = kw["cost"] / kw["conversions"].replace(0, 1)
65
 
66
  worst = kw.sort_values("cpl", ascending=False).head(3)
67
- best = kw.sort_values("cpl", ascending=True).head(3)
68
 
69
  return {
70
  "best_keywords": best[["keyword", "cpl", "conversions"]].to_dict("records"),
@@ -74,12 +97,14 @@ def build_top_drivers(dfs: dict) -> dict:
74
 
75
  def build_signals(dfs: dict) -> dict:
76
  kw = dfs["keywords"].copy()
 
 
77
 
78
  kw["ctr"] = kw["clicks"] / kw["impressions"].replace(0, 1)
79
 
80
  return {
81
- "low_ctr_ratio": round((kw["ctr"] < 0.02).mean(), 2),
82
- "wasted_spend_ratio": round((kw["conversions"] == 0).mean(), 2),
83
  }
84
 
85
 
@@ -88,59 +113,89 @@ def build_signals(dfs: dict) -> dict:
88
  # -------------------------
89
 
90
 
91
- def build_ads_analyst_context(dfs: dict) -> dict:
92
- return {
93
  "campaign": build_campaign_snapshot(dfs),
94
  "trend": build_simple_trend(dfs["hourly"]),
95
  "top_drivers": build_top_drivers(dfs),
96
  "signals": build_signals(dfs),
97
  }
 
 
 
98
 
99
 
100
  # -------------------------
101
- # 3. PROMPT BUILDER
102
  # -------------------------
103
 
104
 
105
  def build_ads_analyst_prompt(context: dict) -> str:
106
- return f"""
107
- You are an Ads performance analyst.
108
-
109
- Return ONLY 35 insights.
110
- No reasoning. No explanation of steps.
111
-
112
- Campaign:
113
- {context["campaign"]}
114
-
115
- Trend:
116
- {context["trend"]}
117
-
118
- Top drivers:
119
- {context["top_drivers"]}
120
-
121
- Signals:
122
- {context["signals"]}
123
-
124
- Format:
125
- - short bullet points only
126
- - Simple language, no jargon
127
- - Focus on actionable insights
128
- """
129
-
130
-
131
- def run_ads_analyst_card(dfs: dict) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  print("\n🚀 [analyst_card] STARTED", flush=True)
133
 
134
  if not dfs:
135
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
136
 
137
- context = build_ads_analyst_context(dfs)
 
138
  print("🧠 [analyst_card] context built", flush=True)
139
 
140
  prompt = build_ads_analyst_prompt(context)
141
  print("✍️ [analyst_card] prompt built", flush=True)
142
 
143
  result = generate_explanation(prompt)
144
- print("\n📤 [analyst_card] LLM result received", flush=True)
 
 
145
 
 
146
  return result
 
1
+ import json
 
2
 
3
+ import pandas as pd
4
 
5
+ from app.recs.generate import TARGET_CPL, generate_explanation, is_bad_llm_output
6
 
7
  # -------------------------
8
  # 1. DATA BUILDERS
9
  # -------------------------
10
 
11
 
12
+ def _dfs_for_campaign(dfs: dict, campaign_name: str | None) -> dict:
13
+ if not campaign_name:
14
+ return dfs
15
+ out = dict(dfs)
16
+ out["campaigns"] = dfs["campaigns"][dfs["campaigns"]["name"] == campaign_name]
17
+ if "keywords" in dfs and "campaign_name" in dfs["keywords"].columns:
18
+ out["keywords"] = dfs["keywords"][dfs["keywords"]["campaign_name"] == campaign_name]
19
+ return out
20
+
21
+
22
  def build_campaign_snapshot(dfs: dict) -> dict:
23
  df = dfs["campaigns"]
24
+ if df.empty:
25
+ return {
26
+ "spend": 0.0,
27
+ "clicks": 0,
28
+ "impressions": 0,
29
+ "leads": 0,
30
+ "ctr": 0.0,
31
+ "cpl": 0.0,
32
+ }
33
 
34
  total_spend = df["cost"].sum()
35
  total_clicks = df["clicks"].sum()
 
40
  cpl = (total_spend / total_leads) if total_leads else 0
41
 
42
  return {
43
+ "spend": round(float(total_spend), 2),
44
  "clicks": int(total_clicks),
45
  "impressions": int(total_impr),
46
  "leads": int(total_leads),
47
+ "ctr": round(float(ctr), 2),
48
+ "cpl": round(float(cpl), 2),
49
  }
50
 
51
 
52
  def build_simple_trend(df_hourly: pd.DataFrame) -> dict:
53
+ if df_hourly.empty:
54
+ return {"spend_change_pct": 0.0, "clicks_change_pct": 0.0, "impressions_change_pct": 0.0}
55
+
56
  df = df_hourly.copy()
57
  df["date"] = pd.to_datetime(df["date"])
58
  df = df.sort_values("date")
59
 
60
+ mid = max(len(df) // 2, 1)
61
  first, second = df.iloc[:mid], df.iloc[mid:]
62
 
63
  def agg(x):
 
81
 
82
  def build_top_drivers(dfs: dict) -> dict:
83
  kw = dfs["keywords"].copy()
84
+ if kw.empty:
85
+ return {"best_keywords": [], "worst_keywords": []}
86
 
87
  kw["cpl"] = kw["cost"] / kw["conversions"].replace(0, 1)
88
 
89
  worst = kw.sort_values("cpl", ascending=False).head(3)
90
+ best = kw[kw["conversions"] > 0].sort_values("cpl", ascending=True).head(3)
91
 
92
  return {
93
  "best_keywords": best[["keyword", "cpl", "conversions"]].to_dict("records"),
 
97
 
98
  def build_signals(dfs: dict) -> dict:
99
  kw = dfs["keywords"].copy()
100
+ if kw.empty:
101
+ return {"low_ctr_ratio": 0.0, "wasted_spend_ratio": 0.0}
102
 
103
  kw["ctr"] = kw["clicks"] / kw["impressions"].replace(0, 1)
104
 
105
  return {
106
+ "low_ctr_ratio": round(float((kw["ctr"] < 0.02).mean()), 2),
107
+ "wasted_spend_ratio": round(float((kw["conversions"] == 0).mean()), 2),
108
  }
109
 
110
 
 
113
  # -------------------------
114
 
115
 
116
+ def build_ads_analyst_context(dfs: dict, campaign_name: str | None = None) -> dict:
117
+ ctx = {
118
  "campaign": build_campaign_snapshot(dfs),
119
  "trend": build_simple_trend(dfs["hourly"]),
120
  "top_drivers": build_top_drivers(dfs),
121
  "signals": build_signals(dfs),
122
  }
123
+ if campaign_name:
124
+ ctx["campaign_name"] = campaign_name
125
+ return ctx
126
 
127
 
128
  # -------------------------
129
+ # 3. PROMPT + FALLBACK
130
  # -------------------------
131
 
132
 
133
  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 bullet points of actionable Google Ads insights for {name}.\n"
138
+ "Use simple language. One insight per bullet. Start each line with '- '. No intro sentence.\n\n"
139
+ f"Data (JSON):\n{payload}"
140
+ )
141
+
142
+
143
+ def rule_based_insights(context: dict) -> str:
144
+ c = context["campaign"]
145
+ trend = context["trend"]
146
+ signals = context["signals"]
147
+ drivers = context["top_drivers"]
148
+ bullets: list[str] = []
149
+
150
+ if c["leads"] == 0:
151
+ bullets.append("- No conversions recorded — review targeting, landing page, and conversion tracking.")
152
+ elif c["cpl"] > TARGET_CPL:
153
+ bullets.append(
154
+ f"- CPL is ${c['cpl']:.2f}, above the ${TARGET_CPL:.0f} target — tighten bids on expensive terms."
155
+ )
156
+ else:
157
+ bullets.append(f"- CPL is ${c['cpl']:.2f} with {c['leads']} leads — performance is within a workable range.")
158
+
159
+ if trend["spend_change_pct"] > 10 and trend["clicks_change_pct"] < trend["spend_change_pct"]:
160
+ bullets.append("- Spend is rising faster than clicks — efficiency is slipping; audit keyword bids.")
161
+
162
+ if signals["wasted_spend_ratio"] > 0.2:
163
+ bullets.append(
164
+ f"- About {signals['wasted_spend_ratio'] * 100:.0f}% of keywords have zero conversions — pause or cut budget there."
165
+ )
166
+
167
+ for row in drivers.get("best_keywords", [])[:1]:
168
+ bullets.append(
169
+ f"- '{row['keyword']}' is a top performer ({row['conversions']} conversions) — consider scaling budget."
170
+ )
171
+
172
+ for row in drivers.get("worst_keywords", [])[:1]:
173
+ if row.get("conversions", 0) == 0:
174
+ bullets.append(f"- '{row['keyword']}' spent without converting — reduce bids or pause.")
175
+
176
+ if len(bullets) < 3:
177
+ bullets.append(f"- CTR is {c['ctr']:.2f}% across {c['impressions']:,} impressions — test stronger ad copy if CTR is low.")
178
+
179
+ return "\n\n".join(bullets[:5])
180
+
181
+
182
+ def run_ads_analyst_card(dfs: dict, campaign_name: str | None = None) -> str:
183
  print("\n🚀 [analyst_card] STARTED", flush=True)
184
 
185
  if not dfs:
186
  return "⚠️ No campaign data — select a campaign on the Dashboard tab first."
187
 
188
+ scoped = _dfs_for_campaign(dfs, campaign_name)
189
+ context = build_ads_analyst_context(scoped, campaign_name)
190
  print("🧠 [analyst_card] context built", flush=True)
191
 
192
  prompt = build_ads_analyst_prompt(context)
193
  print("✍️ [analyst_card] prompt built", flush=True)
194
 
195
  result = generate_explanation(prompt)
196
+ if is_bad_llm_output(result):
197
+ print("⚠️ [analyst_card] LLM fallback — using rule-based insights", flush=True)
198
+ result = rule_based_insights(context)
199
 
200
+ print("\n📤 [analyst_card] LLM result received", flush=True)
201
  return result
app/ads1/budget_optimizer.py CHANGED
@@ -1,4 +1,7 @@
1
- from app.recs.generate import generate_explanation
 
 
 
2
 
3
  def build_campaign_summary(dfs: dict) -> dict:
4
  df = dfs["keywords"]
@@ -6,122 +9,94 @@ def build_campaign_summary(dfs: dict) -> dict:
6
  total_cost = df["cost"].sum()
7
  total_conv = df["conversions"].sum()
8
 
9
- avg_cpl = (
10
- total_cost / total_conv
11
- if total_conv > 0
12
- else 0
13
- )
14
 
15
  return {
16
- "total_spend": round(total_cost, 2),
17
  "total_conversions": int(total_conv),
18
- "avg_cpl": round(avg_cpl, 2),
19
  }
20
 
 
21
  def build_scale_candidates(dfs: dict):
22
  kw = dfs["keywords"].copy()
 
 
23
 
24
- kw["cpl"] = (
25
- kw["cost"] /
26
- kw["conversions"].replace(0, 1)
27
- )
28
 
29
- account_avg = (
30
- kw["cost"].sum() /
31
- max(kw["conversions"].sum(), 1)
32
- )
33
 
34
- winners = kw[
35
- (kw["conversions"] > 0)
36
- & (kw["cpl"] < account_avg * 0.7)
37
- ]
38
 
39
- winners = winners.sort_values(
40
- "conversions",
41
- ascending=False
42
- )
43
 
44
- return winners.head(3)[[
45
- "keyword",
46
- "ad_group_name",
47
- "cpl",
48
- "conversions"
49
- ]].to_dict("records")
50
 
51
  def build_cut_candidates(dfs: dict):
52
  kw = dfs["keywords"].copy()
 
 
53
 
54
- kw["cpl"] = (
55
- kw["cost"] /
56
- kw["conversions"].replace(0, 1)
57
- )
58
 
59
- account_avg = (
60
- kw["cost"].sum() /
61
- max(kw["conversions"].sum(), 1)
62
- )
63
 
64
- losers = kw[
65
- (kw["cost"] > 0)
66
- & (
67
- (kw["conversions"] == 0)
68
- |
69
- (kw["cpl"] > account_avg * 1.5)
70
- )
71
- ]
72
 
73
- losers = losers.sort_values(
74
- "cost",
75
- ascending=False
76
- )
77
 
78
- return losers.head(3)[[
79
- "keyword",
80
- "ad_group_name",
81
- "cost",
82
- "conversions",
83
- "cpl"
84
- ]].to_dict("records")
85
 
86
- def build_budget_optimizer_context(dfs: dict):
 
 
 
 
 
 
87
 
88
- summary = build_campaign_summary(dfs)
89
 
90
- return {
91
- "summary": summary,
 
92
  "scale_candidates": build_scale_candidates(dfs),
93
  "cut_candidates": build_cut_candidates(dfs),
94
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- def build_budget_optimizer_prompt(context):
97
-
98
- return f"""
99
- You are a Google Ads budget optimization expert.
100
-
101
- Your goal is to identify where budget should be increased and where it should be reduced.
102
-
103
- Campaign Summary:
104
- {context["summary"]}
105
-
106
- Best Opportunities To Scale:
107
- {context["scale_candidates"]}
108
-
109
- Worst Budget Drains:
110
- {context["cut_candidates"]}
111
-
112
- Rules:
113
- - Return exactly 3 to 5 bullet points.
114
- - Use simple business language.
115
- - Mention where budget should increase.
116
- - Mention where budget should decrease.
117
- - Focus on efficiency and lead generation.
118
- - No reasoning process.
119
- """
120
-
121
- def run_budget_optimizer_card(dfs):
122
-
123
- context = build_budget_optimizer_context(dfs)
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  prompt = build_budget_optimizer_prompt(context)
126
 
127
  rec = {
@@ -131,4 +106,8 @@ def run_budget_optimizer_card(dfs):
131
  "reason": prompt,
132
  }
133
 
134
- return generate_explanation(prompt, rec=rec)
 
 
 
 
 
1
+ import json
2
+
3
+ from app.recs.generate import generate_explanation, is_bad_llm_output
4
+
5
 
6
  def build_campaign_summary(dfs: dict) -> dict:
7
  df = dfs["keywords"]
 
9
  total_cost = df["cost"].sum()
10
  total_conv = df["conversions"].sum()
11
 
12
+ avg_cpl = total_cost / total_conv if total_conv > 0 else 0
 
 
 
 
13
 
14
  return {
15
+ "total_spend": round(float(total_cost), 2),
16
  "total_conversions": int(total_conv),
17
+ "avg_cpl": round(float(avg_cpl), 2),
18
  }
19
 
20
+
21
  def build_scale_candidates(dfs: dict):
22
  kw = dfs["keywords"].copy()
23
+ if kw.empty:
24
+ return []
25
 
26
+ kw["cpl"] = kw["cost"] / kw["conversions"].replace(0, 1)
 
 
 
27
 
28
+ account_avg = kw["cost"].sum() / max(kw["conversions"].sum(), 1)
 
 
 
29
 
30
+ winners = kw[(kw["conversions"] > 0) & (kw["cpl"] < account_avg * 0.7)]
31
+ winners = winners.sort_values("conversions", ascending=False)
 
 
32
 
33
+ return winners.head(3)[["keyword", "ad_group_name", "cpl", "conversions"]].to_dict("records")
 
 
 
34
 
 
 
 
 
 
 
35
 
36
  def build_cut_candidates(dfs: dict):
37
  kw = dfs["keywords"].copy()
38
+ if kw.empty:
39
+ return []
40
 
41
+ kw["cpl"] = kw["cost"] / kw["conversions"].replace(0, 1)
 
 
 
42
 
43
+ account_avg = kw["cost"].sum() / max(kw["conversions"].sum(), 1)
 
 
 
44
 
45
+ losers = kw[(kw["cost"] > 0) & ((kw["conversions"] == 0) | (kw["cpl"] > account_avg * 1.5))]
46
+ losers = losers.sort_values("cost", ascending=False)
 
 
 
 
 
 
47
 
48
+ return losers.head(3)[["keyword", "ad_group_name", "cost", "conversions", "cpl"]].to_dict("records")
 
 
 
49
 
 
 
 
 
 
 
 
50
 
51
+ def _dfs_for_campaign(dfs: dict, campaign_name: str | None) -> dict:
52
+ if not campaign_name:
53
+ return dfs
54
+ out = dict(dfs)
55
+ if "keywords" in dfs and "campaign_name" in dfs["keywords"].columns:
56
+ out["keywords"] = dfs["keywords"][dfs["keywords"]["campaign_name"] == campaign_name]
57
+ return out
58
 
 
59
 
60
+ def build_budget_optimizer_context(dfs: dict, campaign_name: str | None = None) -> dict:
61
+ ctx = {
62
+ "summary": build_campaign_summary(dfs),
63
  "scale_candidates": build_scale_candidates(dfs),
64
  "cut_candidates": build_cut_candidates(dfs),
65
  }
66
+ if campaign_name:
67
+ ctx["campaign_name"] = campaign_name
68
+ return ctx
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 campaign")
74
+ return (
75
+ f"Write 3 to 5 bullet points on where to increase or cut budget for {name}.\n"
76
+ "Use simple business language. Start each line with '- '. No intro sentence.\n\n"
77
+ f"Data (JSON):\n{payload}"
78
+ )
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
+ def rule_based_budget(context: dict) -> str:
82
+ bullets: list[str] = []
83
+ for row in context.get("scale_candidates", [])[:2]:
84
+ bullets.append(
85
+ f"- Increase budget on '{row['keyword']}' — {row['conversions']} conversions at CPL ${row['cpl']:.2f}."
86
+ )
87
+ for row in context.get("cut_candidates", [])[:2]:
88
+ if row.get("conversions", 0) == 0:
89
+ bullets.append(f"- Cut spend on '{row['keyword']}' — ${row['cost']:.2f} spent with no conversions.")
90
+ else:
91
+ bullets.append(f"- Reduce budget on '{row['keyword']}' — CPL ${row['cpl']:.2f} is above average.")
92
+ if not bullets:
93
+ bullets.append("- Review keyword-level spend and shift budget toward terms with the lowest CPL.")
94
+ return "\n\n".join(bullets[:5])
95
+
96
+
97
+ def run_budget_optimizer_card(dfs, campaign_name: str | None = None):
98
+ scoped = _dfs_for_campaign(dfs, campaign_name)
99
+ context = build_budget_optimizer_context(scoped, campaign_name)
100
  prompt = build_budget_optimizer_prompt(context)
101
 
102
  rec = {
 
106
  "reason": prompt,
107
  }
108
 
109
+ result = generate_explanation(prompt, rec=rec)
110
+ if is_bad_llm_output(result):
111
+ print("⚠️ [budget_optimizer] LLM fallback — using rule-based budget tips", flush=True)
112
+ result = rule_based_budget(context)
113
+ return result
app/models/__init__.py ADDED
File without changes
app/models/llm.py CHANGED
@@ -44,10 +44,7 @@ def load_model() -> Llama:
44
  return _model
45
 
46
  print("⬇️ [load_model] downloading model...", flush=True)
47
- model_path = hf_hub_download(
48
- repo_id=HF_REPO,
49
- filename=HF_FILENAME,
50
- )
51
  print(f"✅ [load_model] model downloaded at {model_path}", flush=True)
52
 
53
  _preload_cuda_libs()
 
44
  return _model
45
 
46
  print("⬇️ [load_model] downloading model...", flush=True)
47
+ model_path = hf_hub_download(repo_id=HF_REPO, filename=HF_FILENAME)
 
 
 
48
  print(f"✅ [load_model] model downloaded at {model_path}", flush=True)
49
 
50
  _preload_cuda_libs()
app/recs/generate.py CHANGED
@@ -1,7 +1,9 @@
1
  from __future__ import annotations
2
 
 
3
  import os
4
  import re
 
5
  import traceback
6
  from typing import Dict
7
 
@@ -12,16 +14,77 @@ TARGET_CPL = 20.0
12
  _IM_END = "<|im_end|>"
13
  _STOP_SEQUENCES = [_IM_END, "<|im_start|>", "</s>"]
14
 
 
 
 
 
 
 
 
 
 
15
 
16
  def fallback_explanation(rec: Dict | None = None) -> str:
17
  return "This recommendation was generated from campaign performance metrics."
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def sanitize_explanation(text: str, rec: Dict | None = None) -> str:
21
- cleaned = re.sub(r"\s+", " ", text).strip()
22
- if not cleaned or len(cleaned) < 10:
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return fallback_explanation(rec)
24
- return cleaned
25
 
26
 
27
  def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
@@ -34,89 +97,93 @@ def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
34
  return "".join(chunks)
35
 
36
 
37
- def _strip_thinking(text: str) -> str:
38
- text = re.sub(r"<\s*think\s*>.*?<\s*/\s*think\s*>", "", text, flags=re.DOTALL | re.IGNORECASE)
39
- text = re.sub(
40
- r"<think>.*?</think>",
41
- "",
42
- text,
43
- flags=re.DOTALL | re.IGNORECASE,
44
- )
45
- return re.sub(r"\s+", " ", text).strip()
46
-
47
-
48
  def _message_text(message: dict) -> str:
49
  content = (message.get("content") or "").strip()
50
  reasoning = (message.get("reasoning_content") or "").strip()
51
- if content and reasoning:
52
- return content if len(content) >= len(reasoning) else reasoning
53
  return content or reasoning
54
 
55
 
56
- def _run_completion(llm, messages: list[dict[str, str]]) -> str:
57
- prompt = _messages_to_prompt(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  out = llm(
59
- prompt,
60
- max_tokens=int(os.getenv("LLAMA_MAX_TOKENS", "512")),
61
- temperature=0.7,
62
  stop=_STOP_SEQUENCES,
63
  echo=False,
64
  )
65
  return (out["choices"][0].get("text") or "").strip()
66
 
67
 
68
- def generate_explanation(prompt: str, rec: Dict | None = None, stream: bool = False) -> str:
 
 
 
 
 
 
 
 
 
 
69
  print("\n🔥 [generate_explanation] CALLED", flush=True)
70
 
71
  try:
 
72
  print(
73
  f"🧾 [generate_explanation] prompt type={type(prompt).__name__} "
74
- f"len={len(str(prompt))}",
75
  flush=True,
76
  )
77
-
78
- llm = load_model()
79
- print("🧠 [generate_explanation] model loaded", flush=True)
80
-
81
- user_content = str(prompt).rstrip()
82
  if "/no_think" not in user_content:
83
- user_content = f"{user_content} /no_think"
84
 
85
  messages = [
86
- {
87
- "role": "system",
88
- "content": "You are an expert marketing analyst. Output only the final answer.",
89
- },
90
  {"role": "user", "content": user_content},
91
  ]
92
 
93
- print("🚀 [generate_explanation] calling LLM...", flush=True)
94
- raw = _run_completion(llm, messages)
95
-
96
- if not raw:
97
- print("⚠️ [generate_explanation] raw empty — trying create_chat_completion", flush=True)
98
- try:
99
- out = llm.create_chat_completion(
100
- messages=messages,
101
- max_tokens=int(os.getenv("LLAMA_MAX_TOKENS", "512")),
102
- temperature=0.7,
103
- )
104
- raw = _message_text(out["choices"][0]["message"])
105
- except TypeError as exc:
106
- print(f"⚠️ [generate_explanation] chat_completion failed: {exc}", flush=True)
107
 
108
  print("📡 [generate_explanation] response received", flush=True)
109
  print("📄 [generate_explanation] raw output length:", len(raw), flush=True)
110
  if raw:
111
  print("📄 [generate_explanation] raw preview:", raw[:400], flush=True)
112
 
113
- clean = _strip_thinking(raw)
114
- clean = sanitize_explanation(clean, rec)
115
-
116
  print("✨ [generate_explanation] cleaned output ready", flush=True)
 
 
117
  return clean
118
 
119
  except Exception as e:
120
  print("❌ [generate_explanation] ERROR:", repr(e), flush=True)
121
  traceback.print_exc()
122
- return f"⚠️ Analysis failed: {e}"
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import json
4
  import os
5
  import re
6
+ import threading
7
  import traceback
8
  from typing import Dict
9
 
 
14
  _IM_END = "<|im_end|>"
15
  _STOP_SEQUENCES = [_IM_END, "<|im_start|>", "</s>"]
16
 
17
+ _infer_lock = threading.Lock()
18
+
19
+ _SYSTEM = (
20
+ "You are a Google Ads analyst. "
21
+ "Reply with 3 to 5 markdown bullet points only. "
22
+ "Each bullet must be one short, actionable insight about the campaign data. "
23
+ "No introduction, no numbered lists, no step-by-step reasoning."
24
+ )
25
+
26
 
27
  def fallback_explanation(rec: Dict | None = None) -> str:
28
  return "This recommendation was generated from campaign performance metrics."
29
 
30
 
31
+ def _strip_thinking(text: str) -> str:
32
+ text = re.sub(r"<\s*think\s*>.*?<\s*/\s*think\s*>", "", text, flags=re.DOTALL | re.IGNORECASE)
33
+ text = re.sub(
34
+ r"<think>.*?</think>",
35
+ "",
36
+ text,
37
+ flags=re.DOTALL | re.IGNORECASE,
38
+ )
39
+ return text.strip()
40
+
41
+
42
+ def _looks_like_garbage(text: str) -> bool:
43
+ if not text:
44
+ return True
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:
51
+ return True
52
+ if re.search(r"(?:\d[\s\n]+){6,}", text):
53
+ return True
54
+ digit_ratio = sum(ch.isdigit() for ch in text) / max(len(text), 1)
55
+ return digit_ratio > 0.22
56
+
57
+
58
+ def is_fallback_output(text: str) -> bool:
59
+ return (
60
+ not text
61
+ or text.startswith("⚠️")
62
+ or text.startswith("This recommendation was generated")
63
+ )
64
+
65
+
66
+ def is_bad_llm_output(text: str) -> bool:
67
+ return is_fallback_output(text) or _looks_like_garbage(text)
68
+
69
+
70
  def sanitize_explanation(text: str, rec: Dict | None = None) -> str:
71
+ text = _strip_thinking(text)
72
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
73
+
74
+ bullets: list[str] = []
75
+ for ln in lines:
76
+ if re.match(r"^[-•*]\s+\S", ln):
77
+ bullets.append(ln)
78
+ elif re.match(r"^\d+\.\s+\S", ln):
79
+ bullets.append(re.sub(r"^\d+\.\s+", "- ", ln))
80
+
81
+ if len(bullets) >= 2:
82
+ return "\n\n".join(bullets[:5])
83
+
84
+ flat = re.sub(r"[ \t]+", " ", text).strip()
85
+ if len(flat) < 20 or _looks_like_garbage(flat):
86
  return fallback_explanation(rec)
87
+ return flat
88
 
89
 
90
  def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
 
97
  return "".join(chunks)
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
100
  def _message_text(message: dict) -> str:
101
  content = (message.get("content") or "").strip()
102
  reasoning = (message.get("reasoning_content") or "").strip()
103
+ if content and reasoning and _looks_like_garbage(content):
104
+ return reasoning
105
  return content or reasoning
106
 
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.35"))
111
+
112
+ try:
113
+ out = llm.create_chat_completion(
114
+ messages=messages,
115
+ max_tokens=max_tokens,
116
+ temperature=temperature,
117
+ )
118
+ raw = _message_text(out["choices"][0]["message"])
119
+ if raw and not _looks_like_garbage(raw):
120
+ print("✅ [generate_explanation] via create_chat_completion", flush=True)
121
+ return raw
122
+ print("⚠️ [generate_explanation] chat_completion empty/garbage — raw fallback", flush=True)
123
+ except TypeError as exc:
124
+ print(f"⚠️ [generate_explanation] chat_completion failed: {exc}", flush=True)
125
+
126
  out = llm(
127
+ _messages_to_prompt(messages),
128
+ max_tokens=max_tokens,
129
+ temperature=temperature,
130
  stop=_STOP_SEQUENCES,
131
  echo=False,
132
  )
133
  return (out["choices"][0].get("text") or "").strip()
134
 
135
 
136
+ def _coerce_prompt(prompt: str | Dict, rec: Dict | None) -> tuple[str, Dict | None]:
137
+ if isinstance(prompt, dict):
138
+ rec = rec or prompt
139
+ reason = prompt.get("reason")
140
+ if reason:
141
+ return str(reason).strip(), rec
142
+ return json.dumps(prompt, default=str), rec
143
+ return str(prompt).strip(), rec
144
+
145
+
146
+ def generate_explanation(prompt: str | Dict, rec: Dict | None = None, stream: bool = False):
147
  print("\n🔥 [generate_explanation] CALLED", flush=True)
148
 
149
  try:
150
+ user_content, rec = _coerce_prompt(prompt, rec)
151
  print(
152
  f"🧾 [generate_explanation] prompt type={type(prompt).__name__} "
153
+ f"len={len(user_content)}",
154
  flush=True,
155
  )
 
 
 
 
 
156
  if "/no_think" not in user_content:
157
+ user_content = f"{user_content}\n/no_think"
158
 
159
  messages = [
160
+ {"role": "system", "content": _SYSTEM},
 
 
 
161
  {"role": "user", "content": user_content},
162
  ]
163
 
164
+ with _infer_lock:
165
+ llm = load_model()
166
+ print("🧠 [generate_explanation] model loaded", flush=True)
167
+ print("🚀 [generate_explanation] calling LLM...", flush=True)
168
+ raw = _infer(llm, messages)
 
 
 
 
 
 
 
 
 
169
 
170
  print("📡 [generate_explanation] response received", flush=True)
171
  print("📄 [generate_explanation] raw output length:", len(raw), flush=True)
172
  if raw:
173
  print("📄 [generate_explanation] raw preview:", raw[:400], flush=True)
174
 
175
+ clean = sanitize_explanation(raw, rec)
176
+ if is_bad_llm_output(clean):
177
+ clean = fallback_explanation(rec)
178
  print("✨ [generate_explanation] cleaned output ready", flush=True)
179
+ if stream:
180
+ return iter([clean])
181
  return clean
182
 
183
  except Exception as e:
184
  print("❌ [generate_explanation] ERROR:", repr(e), flush=True)
185
  traceback.print_exc()
186
+ err = f"⚠️ Analysis failed: {e}"
187
+ if stream:
188
+ return iter([err])
189
+ return err
requirements.txt CHANGED
@@ -1,8 +1,13 @@
1
  gradio>=5.0.0
2
- sqlalchemy # for wat is this ?
 
3
  google-ads>=27.0.0
4
  pandas
5
- llama-cpp-python==0.2.90
6
- pytest
7
  python-dotenv
8
- requests
 
 
 
 
 
 
 
1
  gradio>=5.0.0
2
+ spaces>=0.43.0
3
+ sqlalchemy
4
  google-ads>=27.0.0
5
  pandas
 
 
6
  python-dotenv
7
+ requests
8
+ huggingface_hub>=0.24.0
9
+ pytest
10
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
11
+ llama-cpp-python==0.3.23
12
+ nvidia-cuda-runtime-cu12
13
+ nvidia-cublas-cu12