mktgtech commited on
Commit
0707624
ยท
verified ยท
1 Parent(s): 5ce74ad

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +79 -83
app.py CHANGED
@@ -9,15 +9,15 @@ import plotly.express as px
9
  from plotly.subplots import make_subplots
10
  import plotly.graph_objects as go
11
  from datetime import date, timedelta
12
- from huggingface_hub import HfApi # ADDED: For permanent saving to Hugging Face
13
 
14
  # =====================================================
15
  # CONFIG / SECRETS
16
  # =====================================================
17
  API_TOKEN = os.getenv("LEADFEEDER_API_TOKEN")
18
  APP_PASSWORD = os.getenv("APP_PASSWORD")
19
- HF_TOKEN = os.getenv("HF_TOKEN") # ADDED: Your Hugging Face write token
20
- SPACE_ID = os.getenv("SPACE_ID") # ADDED: Automatically provided by HF Spaces
21
 
22
  if not API_TOKEN or not APP_PASSWORD:
23
  print("โš ๏ธ WARNING: Secrets missing. App will launch but API calls will fail.")
@@ -27,45 +27,36 @@ if not API_TOKEN or not APP_PASSWORD:
27
  ACCOUNT_ID = "255333"
28
  BASE_URL = "https://api.leadfeeder.com"
29
  PAGE_SIZE = 100
 
30
  HEADERS = {
31
  "Authorization": f"Token token={API_TOKEN}",
32
  "Accept": "application/json"
33
  }
 
34
  CAMPAIGN_CONFIG_FILE = "campaign_rules.json"
35
 
36
  # =====================================================
37
  # 1. CAMPAIGN MANAGER & LOGIC
38
  # =====================================================
39
- DEFAULT_CAMPAIGNS = {
40
- "MS Tech": [
41
- "microsoft", "power", "dynamics", "crm", "365", "xamarin", "sharepoint",
42
- "dot", ".net", "asp.net", "azure", "copilot", "real-estate-website-development",
43
- "manufacturing-production", "legacy-hrms", "healthcare-appointment", "eld-fleet",
44
- "legacy-erp", "clinical-trial", "digital-e-learning", "bi-analytics",
45
- "application-modernization", "data-warehouse", "workforce", "contingent"
46
- ],
47
- "Fintech": [
48
- "fintech", "finance", "money", "credit", "debit", "card", "invest", "bank",
49
- "wallet", "account", "bookkeeping", "payment", "loan", "nfc", "lend", "debt",
50
- "tax", "invoice", "quot", "borrow", "bill", "coin", "currency", "aml", "kyc",
51
- "blockchain", "cryptocurrency", "budget", "expense", "wealth", "sox", "sarbanes"
52
- ],
53
- "Adtech": [
54
- "programmatic", "publisher", "media", "entertainment", "trading", "ott",
55
- "email", "news", "social", "marketing", "video", "audio", "radio", "livestream",
56
- "bid", "anime", "dooh", "vod", "rtb", "supply-side-platform", "demand-side-platform",
57
- "data-management-platform"
58
- ]
59
- }
60
-
61
  def load_campaign_rules():
 
62
  if os.path.exists(CAMPAIGN_CONFIG_FILE):
63
  try:
64
  with open(CAMPAIGN_CONFIG_FILE, "r") as f:
65
- return json.load(f)
66
- except:
67
- return DEFAULT_CAMPAIGNS
68
- return DEFAULT_CAMPAIGNS
 
 
 
 
 
 
 
 
 
 
69
 
70
  def categorize_quality(score):
71
  if pd.isna(score): return "Unknown"
@@ -81,30 +72,42 @@ def categorize_quality(score):
81
  def get_campaign_match(text, rules):
82
  if not text or not isinstance(text, str):
83
  return None
 
84
  text_lower = text.lower()
85
- for campaign_name, keywords in rules.items():
86
- for kw in keywords:
87
- if kw.lower() in text_lower:
88
- return campaign_name
 
 
 
 
 
 
 
 
 
 
 
89
  return None
90
 
91
  def apply_business_logic(df):
92
  if df is None or df.empty: return df
93
-
94
  # 1. Quality Group
95
  if "lead_quality_score" in df.columns:
96
  df["Quality_Group"] = df["lead_quality_score"].apply(categorize_quality)
97
-
98
- # 2. Campaign Logic (Smart Fallback)
99
- rules = load_campaign_rules()
100
 
 
 
 
101
  def resolve_campaign(row):
102
  # Priority 1: Landing Page
103
  if row.get("landing_page_path"):
104
  match = get_campaign_match(row["landing_page_path"], rules)
105
  if match: return match
106
 
107
- # Priority 2: Exit Page (NEW LOGIC)
108
  if row.get("exit_page_path"):
109
  match = get_campaign_match(row["exit_page_path"], rules)
110
  if match: return match
@@ -118,56 +121,40 @@ def apply_business_logic(df):
118
  if row.get("primary_industry"):
119
  match = get_campaign_match(row["primary_industry"], rules)
120
  if match: return match
121
-
122
  return "Uncategorized"
123
 
124
  df["Campaign"] = df.apply(resolve_campaign, axis=1)
125
  return df
126
 
127
  # =====================================================
128
- # 2. PRESETS (UPDATED WITH COUNTRY/CITY/INDUSTRY)
129
  # =====================================================
130
  DASHBOARD_PRESETS = {
131
- # -- SPECIAL VIEWS --
132
- "Key Campaigns Bifurcation (MS Tech, Fintech, Adtech)": ("SPECIAL_KEY_BIFURCATION", None, None),
133
  "All Campaigns Performance": ("SPECIAL_ALL_PERFORMANCE", None, None),
134
-
135
- # -- CAMPAIGN & QUALITY --
136
  "Visits by Campaign": ("Campaign", "total_visits", "sum"),
137
  "Leads by Campaign": ("Campaign", "company_name", "count"),
138
  "Leads by Quality Group": ("Quality_Group", "company_name", "count"),
139
-
140
- # -- GEOGRAPHY (NEW) --
141
  "Visits by Country": ("country", "total_visits", "sum"),
142
  "Leads by Country": ("country", "company_name", "count"),
143
  "Visits by City": ("city", "total_visits", "sum"),
144
  "Leads by City": ("city", "company_name", "count"),
145
-
146
- # -- INDUSTRY (NEW) --
147
  "Visits by Industry": ("primary_industry", "total_visits", "sum"),
148
  "Leads by Industry": ("primary_industry", "company_name", "count"),
149
-
150
- # -- OTHERS --
151
  "Top Accounts by Visits": ("company_name", "total_visits", "sum"),
152
  }
153
 
154
  TREND_PRESETS = {
155
- # -- CAMPAIGN & QUALITY --
156
  "Visits Trend by Campaign": ("total_visits", "sum", "Campaign"),
157
  "Leads Trend by Campaign": ("company_name", "count", "Campaign"),
158
  "Visits Trend by Quality": ("total_visits", "sum", "Quality_Group"),
159
-
160
- # -- GEOGRAPHY (NEW) --
161
  "Visits Trend by Country": ("total_visits", "sum", "country"),
162
  "Leads Trend by Country": ("company_name", "count", "country"),
163
  "Visits Trend by City": ("total_visits", "sum", "city"),
164
  "Leads Trend by City": ("company_name", "count", "city"),
165
-
166
- # -- INDUSTRY (NEW) --
167
  "Visits Trend by Industry": ("total_visits", "sum", "primary_industry"),
168
  "Leads Trend by Industry": ("company_name", "count", "primary_industry"),
169
-
170
- # -- GENERAL --
171
  "Total Visits Trend": ("total_visits", "sum", None),
172
  "Active Accounts Trend": ("company_name", "count", None),
173
  }
@@ -281,7 +268,7 @@ def enrich_leads_with_visits(df, start_date, end_date, max_rows=None, progress=g
281
  if not landing:
282
  first_step = visit_route[0]
283
  landing = first_step.get("page_path") or first_step.get("page_url")
284
-
285
  if not landing or not exit_p:
286
  pv_map = {p["id"]: p["attributes"] for p in included if p["type"] == "page_views"}
287
  pv_ids = [r["id"] for r in visit.get("relationships", {}).get("page_views", {}).get("data", [])]
@@ -353,8 +340,9 @@ def build_dashboard(df, preset, top_n):
353
  if df is None or df.empty: return px.bar(title="No Data")
354
  if "Campaign" not in df.columns: df = apply_business_logic(df)
355
 
356
- if preset == "Key Campaigns Bifurcation (MS Tech, Fintech, Adtech)":
357
- target = ["MS Tech", "Fintech", "Adtech"]
 
358
  filtered = df[df["Campaign"].isin(target)].copy()
359
  if filtered.empty: return px.bar(title="No Data for Key Campaigns")
360
 
@@ -423,17 +411,15 @@ def get_trend_filter_options(df, preset):
423
  metric, agg, segment = TREND_PRESETS[preset]
424
  if not segment: return gr.update(choices=[], value=None, visible=False)
425
 
426
- # FORCE LOGIC
427
  if segment not in df.columns:
428
  df = apply_business_logic(df)
429
 
430
  options = sorted(df[segment].astype(str).unique().tolist())
431
 
432
- # Smart Defaults for specific columns
433
  if segment == "Campaign":
434
- defaults = [o for o in options if o in ["MS Tech", "Fintech", "Adtech"]]
435
  else:
436
- defaults = options[:5] # Top 5 for City/Country/Industry
437
 
438
  if not defaults: defaults = options[:5]
439
  return gr.update(choices=options, value=defaults, visible=True, label=f"Filter {segment}")
@@ -447,9 +433,10 @@ with gr.Blocks(title="Leadfeeder Campaign Pro") as demo:
447
  with gr.Row():
448
  pwd = gr.Textbox(type="password", label="App Password")
449
  gr.Button("Auth").click(lambda p: gr.Info("Success") if p==APP_PASSWORD else gr.Error("Invalid"), pwd, None)
450
- df_state = gr.State()
451
- status = gr.Markdown()
452
-
 
453
  with gr.Tabs():
454
  # --- TAB 1: DATA ---
455
  with gr.Tab("๐Ÿ“‹ Data & Report"):
@@ -462,6 +449,7 @@ with gr.Blocks(title="Leadfeeder Campaign Pro") as demo:
462
  file_dl = gr.File(label="Download Excel")
463
 
464
  table = gr.Dataframe(label="Preview (Top 50 Enriched)", interactive=True)
 
465
  btn_load.click(load_preview, [start, end], [df_state, table, status])
466
  btn_dl.click(download_full_excel, [df_state, start, end], file_dl)
467
 
@@ -472,7 +460,7 @@ with gr.Blocks(title="Leadfeeder Campaign Pro") as demo:
472
 
473
  gr.Markdown("### ๐Ÿ“Š Charts")
474
  with gr.Row():
475
- preset = gr.Dropdown(choices=list(DASHBOARD_PRESETS.keys()), label="Chart View", value="Key Campaigns Bifurcation (MS Tech, Fintech, Adtech)")
476
  top_n = gr.Slider(5, 50, value=10, label="Top N Items")
477
  chart = gr.Plot()
478
  gr.Button("Build View").click(build_dashboard, [df_state, preset, top_n], chart)
@@ -493,41 +481,49 @@ with gr.Blocks(title="Leadfeeder Campaign Pro") as demo:
493
  gr.Markdown("### Manage Campaign Groups")
494
 
495
  init_rules = load_campaign_rules()
496
- camp_choices = list(init_rules.keys()) + ["+ Create New Campaign"]
497
- default_kws = ", ".join(init_rules.get(camp_choices[0], [])) if init_rules else ""
 
 
 
 
 
498
 
499
  with gr.Row():
500
  camp_dropdown = gr.Dropdown(choices=camp_choices, label="Select Campaign to Edit", value=camp_choices[0])
501
- new_camp_name = gr.Textbox(label="New Campaign Name", visible=False)
 
 
 
 
502
 
503
- kw_input = gr.Textbox(label="Keywords (comma separated)", lines=5, value=default_kws)
504
-
505
  def update_ui_on_select(selected_camp):
506
  rules = load_campaign_rules()
507
  if selected_camp == "+ Create New Campaign":
508
- return gr.update(visible=True, value=""), gr.update(value="")
509
  else:
510
- kws = rules.get(selected_camp, [])
511
- return gr.update(visible=False), gr.update(value=", ".join(kws))
 
512
 
513
- camp_dropdown.change(update_ui_on_select, inputs=[camp_dropdown], outputs=[new_camp_name, kw_input])
514
 
515
  save_config_btn = gr.Button("๐Ÿ’พ Save Configuration to Hugging Face", variant="primary")
516
  config_status = gr.Markdown()
517
 
518
- def save_easy_config(selected_camp, new_name, kw_string):
519
  rules = load_campaign_rules()
520
 
521
- raw_kws = kw_string.split(",")
522
- clean_kws = [k.strip().lower() for k in raw_kws if k.strip()]
523
 
524
  target_camp = new_name.strip() if selected_camp == "+ Create New Campaign" else selected_camp
525
 
526
  if not target_camp:
527
  return "โŒ Error: Campaign name cannot be empty.", gr.update()
528
 
529
- # 1. Update the dictionary
530
- rules[target_camp] = clean_kws
531
 
532
  # 2. Save file locally first
533
  with open(CAMPAIGN_CONFIG_FILE, "w") as f:
@@ -554,7 +550,7 @@ with gr.Blocks(title="Leadfeeder Campaign Pro") as demo:
554
  updated_choices = list(rules.keys()) + ["+ Create New Campaign"]
555
  return status_message, gr.update(choices=updated_choices, value=target_camp)
556
 
557
- save_config_btn.click(save_easy_config, inputs=[camp_dropdown, new_camp_name, kw_input], outputs=[config_status, camp_dropdown])
558
 
559
  # --- TAB 5: DEBUGGER ---
560
  with gr.Tab("๐Ÿ› ๏ธ Debugger"):
 
9
  from plotly.subplots import make_subplots
10
  import plotly.graph_objects as go
11
  from datetime import date, timedelta
12
+ from huggingface_hub import HfApi
13
 
14
  # =====================================================
15
  # CONFIG / SECRETS
16
  # =====================================================
17
  API_TOKEN = os.getenv("LEADFEEDER_API_TOKEN")
18
  APP_PASSWORD = os.getenv("APP_PASSWORD")
19
+ HF_TOKEN = os.getenv("HF_TOKEN")
20
+ SPACE_ID = os.getenv("SPACE_ID")
21
 
22
  if not API_TOKEN or not APP_PASSWORD:
23
  print("โš ๏ธ WARNING: Secrets missing. App will launch but API calls will fail.")
 
27
  ACCOUNT_ID = "255333"
28
  BASE_URL = "https://api.leadfeeder.com"
29
  PAGE_SIZE = 100
30
+
31
  HEADERS = {
32
  "Authorization": f"Token token={API_TOKEN}",
33
  "Accept": "application/json"
34
  }
35
+
36
  CAMPAIGN_CONFIG_FILE = "campaign_rules.json"
37
 
38
  # =====================================================
39
  # 1. CAMPAIGN MANAGER & LOGIC
40
  # =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def load_campaign_rules():
42
+ """Loads rules from JSON. Auto-migrates old list-based format to new include/exclude format."""
43
  if os.path.exists(CAMPAIGN_CONFIG_FILE):
44
  try:
45
  with open(CAMPAIGN_CONFIG_FILE, "r") as f:
46
+ rules = json.load(f)
47
+
48
+ # Backward compatibility: Convert flat lists into include/exclude dicts
49
+ for campaign, config in rules.items():
50
+ if isinstance(config, list):
51
+ rules[campaign] = {
52
+ "include": config,
53
+ "exclude": []
54
+ }
55
+ return rules
56
+ except Exception as e:
57
+ print(f"Error loading {CAMPAIGN_CONFIG_FILE}: {e}")
58
+ return {}
59
+ return {}
60
 
61
  def categorize_quality(score):
62
  if pd.isna(score): return "Unknown"
 
72
  def get_campaign_match(text, rules):
73
  if not text or not isinstance(text, str):
74
  return None
75
+
76
  text_lower = text.lower()
77
+
78
+ for campaign_name, config in rules.items():
79
+ includes = config.get("include", [])
80
+ excludes = config.get("exclude", [])
81
+
82
+ # 1st: Check Exclusions. If an excluded word is present, skip this campaign entirely
83
+ has_exclusion = any(ex.lower() in text_lower for ex in excludes if ex.strip())
84
+ if has_exclusion:
85
+ continue
86
+
87
+ # 2nd: Check Inclusions. If an included word is present, it's a match
88
+ has_inclusion = any(inc.lower() in text_lower for inc in includes if inc.strip())
89
+ if has_inclusion:
90
+ return campaign_name
91
+
92
  return None
93
 
94
  def apply_business_logic(df):
95
  if df is None or df.empty: return df
96
+
97
  # 1. Quality Group
98
  if "lead_quality_score" in df.columns:
99
  df["Quality_Group"] = df["lead_quality_score"].apply(categorize_quality)
 
 
 
100
 
101
+ # 2. Campaign Logic (Strict Fallback)
102
+ rules = load_campaign_rules()
103
+
104
  def resolve_campaign(row):
105
  # Priority 1: Landing Page
106
  if row.get("landing_page_path"):
107
  match = get_campaign_match(row["landing_page_path"], rules)
108
  if match: return match
109
 
110
+ # Priority 2: Exit Page (Only checked if Landing Page DID NOT match)
111
  if row.get("exit_page_path"):
112
  match = get_campaign_match(row["exit_page_path"], rules)
113
  if match: return match
 
121
  if row.get("primary_industry"):
122
  match = get_campaign_match(row["primary_industry"], rules)
123
  if match: return match
124
+
125
  return "Uncategorized"
126
 
127
  df["Campaign"] = df.apply(resolve_campaign, axis=1)
128
  return df
129
 
130
  # =====================================================
131
+ # 2. PRESETS
132
  # =====================================================
133
  DASHBOARD_PRESETS = {
134
+ "Key Campaigns Bifurcation (Top Campaigns)": ("SPECIAL_KEY_BIFURCATION", None, None),
 
135
  "All Campaigns Performance": ("SPECIAL_ALL_PERFORMANCE", None, None),
 
 
136
  "Visits by Campaign": ("Campaign", "total_visits", "sum"),
137
  "Leads by Campaign": ("Campaign", "company_name", "count"),
138
  "Leads by Quality Group": ("Quality_Group", "company_name", "count"),
 
 
139
  "Visits by Country": ("country", "total_visits", "sum"),
140
  "Leads by Country": ("country", "company_name", "count"),
141
  "Visits by City": ("city", "total_visits", "sum"),
142
  "Leads by City": ("city", "company_name", "count"),
 
 
143
  "Visits by Industry": ("primary_industry", "total_visits", "sum"),
144
  "Leads by Industry": ("primary_industry", "company_name", "count"),
 
 
145
  "Top Accounts by Visits": ("company_name", "total_visits", "sum"),
146
  }
147
 
148
  TREND_PRESETS = {
 
149
  "Visits Trend by Campaign": ("total_visits", "sum", "Campaign"),
150
  "Leads Trend by Campaign": ("company_name", "count", "Campaign"),
151
  "Visits Trend by Quality": ("total_visits", "sum", "Quality_Group"),
 
 
152
  "Visits Trend by Country": ("total_visits", "sum", "country"),
153
  "Leads Trend by Country": ("company_name", "count", "country"),
154
  "Visits Trend by City": ("total_visits", "sum", "city"),
155
  "Leads Trend by City": ("company_name", "count", "city"),
 
 
156
  "Visits Trend by Industry": ("total_visits", "sum", "primary_industry"),
157
  "Leads Trend by Industry": ("company_name", "count", "primary_industry"),
 
 
158
  "Total Visits Trend": ("total_visits", "sum", None),
159
  "Active Accounts Trend": ("company_name", "count", None),
160
  }
 
268
  if not landing:
269
  first_step = visit_route[0]
270
  landing = first_step.get("page_path") or first_step.get("page_url")
271
+
272
  if not landing or not exit_p:
273
  pv_map = {p["id"]: p["attributes"] for p in included if p["type"] == "page_views"}
274
  pv_ids = [r["id"] for r in visit.get("relationships", {}).get("page_views", {}).get("data", [])]
 
340
  if df is None or df.empty: return px.bar(title="No Data")
341
  if "Campaign" not in df.columns: df = apply_business_logic(df)
342
 
343
+ if preset == "Key Campaigns Bifurcation (Top Campaigns)":
344
+ rules = load_campaign_rules()
345
+ target = list(rules.keys())[:3] if rules else []
346
  filtered = df[df["Campaign"].isin(target)].copy()
347
  if filtered.empty: return px.bar(title="No Data for Key Campaigns")
348
 
 
411
  metric, agg, segment = TREND_PRESETS[preset]
412
  if not segment: return gr.update(choices=[], value=None, visible=False)
413
 
 
414
  if segment not in df.columns:
415
  df = apply_business_logic(df)
416
 
417
  options = sorted(df[segment].astype(str).unique().tolist())
418
 
 
419
  if segment == "Campaign":
420
+ defaults = options[:3] # Pick top 3 dynamic campaigns automatically
421
  else:
422
+ defaults = options[:5]
423
 
424
  if not defaults: defaults = options[:5]
425
  return gr.update(choices=options, value=defaults, visible=True, label=f"Filter {segment}")
 
433
  with gr.Row():
434
  pwd = gr.Textbox(type="password", label="App Password")
435
  gr.Button("Auth").click(lambda p: gr.Info("Success") if p==APP_PASSWORD else gr.Error("Invalid"), pwd, None)
436
+
437
+ df_state = gr.State()
438
+ status = gr.Markdown()
439
+
440
  with gr.Tabs():
441
  # --- TAB 1: DATA ---
442
  with gr.Tab("๐Ÿ“‹ Data & Report"):
 
449
  file_dl = gr.File(label="Download Excel")
450
 
451
  table = gr.Dataframe(label="Preview (Top 50 Enriched)", interactive=True)
452
+
453
  btn_load.click(load_preview, [start, end], [df_state, table, status])
454
  btn_dl.click(download_full_excel, [df_state, start, end], file_dl)
455
 
 
460
 
461
  gr.Markdown("### ๐Ÿ“Š Charts")
462
  with gr.Row():
463
+ preset = gr.Dropdown(choices=list(DASHBOARD_PRESETS.keys()), label="Chart View", value="Key Campaigns Bifurcation (Top Campaigns)")
464
  top_n = gr.Slider(5, 50, value=10, label="Top N Items")
465
  chart = gr.Plot()
466
  gr.Button("Build View").click(build_dashboard, [df_state, preset, top_n], chart)
 
481
  gr.Markdown("### Manage Campaign Groups")
482
 
483
  init_rules = load_campaign_rules()
484
+ camp_choices = list(init_rules.keys()) + ["+ Create New Campaign"] if init_rules else ["+ Create New Campaign"]
485
+
486
+ default_inc = ""
487
+ default_exc = ""
488
+ if init_rules and camp_choices[0] in init_rules:
489
+ default_inc = ", ".join(init_rules[camp_choices[0]].get("include", []))
490
+ default_exc = ", ".join(init_rules[camp_choices[0]].get("exclude", []))
491
 
492
  with gr.Row():
493
  camp_dropdown = gr.Dropdown(choices=camp_choices, label="Select Campaign to Edit", value=camp_choices[0])
494
+ new_camp_name = gr.Textbox(label="New Campaign Name", visible=(not init_rules))
495
+
496
+ with gr.Row():
497
+ inc_kw_input = gr.Textbox(label="Include Keywords (comma separated)", lines=3, value=default_inc)
498
+ exc_kw_input = gr.Textbox(label="Exclude Keywords (comma separated)", lines=3, value=default_exc)
499
 
 
 
500
  def update_ui_on_select(selected_camp):
501
  rules = load_campaign_rules()
502
  if selected_camp == "+ Create New Campaign":
503
+ return gr.update(visible=True, value=""), gr.update(value=""), gr.update(value="")
504
  else:
505
+ inc_kws = rules.get(selected_camp, {}).get("include", [])
506
+ exc_kws = rules.get(selected_camp, {}).get("exclude", [])
507
+ return gr.update(visible=False), gr.update(value=", ".join(inc_kws)), gr.update(value=", ".join(exc_kws))
508
 
509
+ camp_dropdown.change(update_ui_on_select, inputs=[camp_dropdown], outputs=[new_camp_name, inc_kw_input, exc_kw_input])
510
 
511
  save_config_btn = gr.Button("๐Ÿ’พ Save Configuration to Hugging Face", variant="primary")
512
  config_status = gr.Markdown()
513
 
514
+ def save_easy_config(selected_camp, new_name, inc_string, exc_string):
515
  rules = load_campaign_rules()
516
 
517
+ clean_inc = [k.strip().lower() for k in inc_string.split(",") if k.strip()]
518
+ clean_exc = [k.strip().lower() for k in exc_string.split(",") if k.strip()]
519
 
520
  target_camp = new_name.strip() if selected_camp == "+ Create New Campaign" else selected_camp
521
 
522
  if not target_camp:
523
  return "โŒ Error: Campaign name cannot be empty.", gr.update()
524
 
525
+ # 1. Update the dictionary format
526
+ rules[target_camp] = {"include": clean_inc, "exclude": clean_exc}
527
 
528
  # 2. Save file locally first
529
  with open(CAMPAIGN_CONFIG_FILE, "w") as f:
 
550
  updated_choices = list(rules.keys()) + ["+ Create New Campaign"]
551
  return status_message, gr.update(choices=updated_choices, value=target_camp)
552
 
553
+ save_config_btn.click(save_easy_config, inputs=[camp_dropdown, new_camp_name, inc_kw_input, exc_kw_input], outputs=[config_status, camp_dropdown])
554
 
555
  # --- TAB 5: DEBUGGER ---
556
  with gr.Tab("๐Ÿ› ๏ธ Debugger"):