Gamortsey commited on
Commit
bbd0f3e
·
verified ·
1 Parent(s): cf26748

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +170 -88
app.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import os
3
  import time
4
  import re
@@ -26,7 +26,6 @@ from email.mime.text import MIMEText
26
  # ============================
27
  # CONFIG (ENV VARS recommended)
28
  # ============================
29
- # IMPORTANT: set these as Space "Secrets" (see README below)
30
  API_KEY = os.environ.get("GOOGLE_API_KEY", "YOUR_GOOGLE_API_KEY")
31
  CX = os.environ.get("GOOGLE_CSE_ID", "YOUR_CSE_ID")
32
  DEFAULT_COUNTRY = "Ghana"
@@ -38,11 +37,9 @@ ALLY_AI_NAME = os.environ.get("ALLY_AI_NAME", "Ally AI Assistant")
38
  ALLY_AI_LOGO_URL_DEFAULT = os.environ.get("ALLY_AI_LOGO_URL",
39
  "https://i.ibb.co/7nZqz0H/ai-logo.png")
40
 
41
- # Optional country maps for search bias & phone parsing
42
  COUNTRY_TLD_MAP = {"Ghana":"gh","Nigeria":"ng","Kenya":"ke","South Africa":"za","USA":"us","United Kingdom":"uk"}
43
  COUNTRY_REGION_MAP= {"Ghana":"GH","Nigeria":"NG","Kenya":"KE","South Africa":"ZA","USA":"US","United Kingdom":"GB"}
44
 
45
- # HTTP + Regex
46
  HEADERS = {"User-Agent":"Mozilla/5.0 (X11; Linux x86_64)"}
47
  EMAIL_REGEX = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
48
 
@@ -124,19 +121,67 @@ def extract_phones(text, region="GH"):
124
  pass
125
  return list(set(phones))
126
 
 
 
 
 
 
 
127
  def scrape_contacts(url, region="GH"):
 
 
 
 
128
  try:
129
  res = requests.get(url, headers=HEADERS, timeout=12)
130
  if not res.ok or not res.text:
131
- return {"emails": [], "phones": []}
132
- text = BeautifulSoup(res.text, "html.parser").get_text(separator=" ")
 
 
 
133
  text = " ".join(text.split())[:300000]
 
 
134
  emails = list(set(EMAIL_REGEX.findall(text)))
135
  phones = extract_phones(text, region)
136
- return {"emails": emails, "phones": phones}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  except Exception as e:
138
  print(f"[scrape error] {url} -> {e}")
139
- return {"emails": [], "phones": []}
140
 
141
  # ============================
142
  # NER + STORY → PROFESSIONS
@@ -172,7 +217,7 @@ def build_queries(story: str, country: str):
172
  if p == "gbv":
173
  cores += ["GBV support organizations", "gender based violence help"]
174
  else:
175
- cores += [f"{p} for GBV", f"{p} for sexual assault"]
176
  unique_cores, seen = [], set()
177
  for c in cores:
178
  if c not in seen:
@@ -230,29 +275,44 @@ def find_professionals_from_story(story, country=DEFAULT_COUNTRY, results_per_qu
230
  return {"summary":"No results found. Try a different country or wording.",
231
  "professionals":[], "queries_used":queries}
232
 
233
- # NER on titles/snippets
234
  all_people, all_orgs, all_locs = [], [], []
235
  for r in search_results:
236
  ctx = f"{r.get('title','')}. {r.get('snippet','')}"
237
  p,o,l = extract_entities(ctx)
238
  all_people += p; all_orgs += o; all_locs += l
239
 
240
- # Scrape contacts concurrently
241
  professionals = []
242
  with ThreadPoolExecutor(max_workers=MAX_SCRAPE_WORKERS) as ex:
243
  futures = {ex.submit(scrape_contacts, r["link"], region): r for r in search_results}
244
  for fut in as_completed(futures):
245
  r = futures[fut]
246
- contacts = {"emails": [], "phones": []}
247
  try:
248
  contacts = fut.result()
249
  except Exception as e:
250
  print("[scrape future error]", r["link"], e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  professionals.append({
252
- "title": r.get("title",""),
253
  "url": r.get("link",""),
254
- "email": contacts["emails"][0] if contacts["emails"] else "Not found",
255
- "phone": contacts["phones"][0] if contacts["phones"] else "Not found",
 
256
  "source_query": r.get("query","")
257
  })
258
 
@@ -268,26 +328,57 @@ def find_professionals_from_story(story, country=DEFAULT_COUNTRY, results_per_qu
268
  # DRAFT (mailto + .eml)
269
  # ============================
270
  def build_mailto_and_eml(to_addr, subject, body, default_from="noreply@ally.ai"):
271
- from email.message import EmailMessage
272
- import time
 
 
 
 
 
 
273
 
 
274
  msg = EmailMessage()
275
  msg["From"] = default_from
276
  msg["To"] = to_addr
277
  msg["Subject"] = subject
278
  msg.set_content(body)
279
 
280
- # Save to a writable directory (current working dir or "tmp")
281
- os.makedirs("tmp", exist_ok=True)
282
- fname = os.path.join("tmp", f"email_draft_{int(time.time())}.eml")
283
 
284
- with open(fname, "wb") as f:
285
- f.write(msg.as_bytes())
286
 
287
- # Create mailto link (this part is fine)
288
- mailto = f"mailto:{to_addr}?subject={subject}&body={body}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
- return mailto, fname
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  # ============================
293
  # SENDER (SMTP) — Ally AI branding
@@ -296,16 +387,8 @@ def send_ally_ai_email(to_email, subject, body, user_email,
296
  sender_email, sender_password,
297
  ai_name=ALLY_AI_NAME,
298
  logo_url=ALLY_AI_LOGO_URL_DEFAULT):
299
- """
300
- Sends an HTML email branded as Ally AI.
301
- to_email: recipient (organization)
302
- subject: subject line
303
- body: main message (already anonymized or full text)
304
- user_email: survivor's email (included for reply inside body)
305
- sender_email/sender_password: SMTP credentials (use Gmail App Password with Gmail)
306
- """
307
  if not to_email or to_email == "Not found":
308
- return "❌ No recipient email found — choose a contact with an email."
309
 
310
  msg = MIMEMultipart("alternative")
311
  msg["Subject"] = subject or "Request for support"
@@ -336,7 +419,7 @@ def send_ally_ai_email(to_email, subject, body, user_email,
336
  try:
337
  server = smtplib.SMTP("smtp.gmail.com", 587)
338
  server.starttls()
339
- server.login(sender_email, sender_password) # Gmail App Password recommended
340
  server.sendmail(sender_email, [to_email], msg.as_string())
341
  server.quit()
342
  return f"✅ Email sent successfully to {to_email}"
@@ -346,13 +429,7 @@ def send_ally_ai_email(to_email, subject, body, user_email,
346
  # ============================
347
  # GRADIO UI
348
  # ============================
349
- # ------- Replace existing run_search and _on_search with these -------
350
-
351
  def run_search(story, country):
352
- """
353
- Robust search wrapper: returns (summary, table_records, dropdown_options, anonymized_text).
354
- Avoids returning gr.update(...) to prevent KeyError during serialization.
355
- """
356
  try:
357
  out = find_professionals_from_story(story, country=country, results_per_query=RESULTS_PER_QUERY)
358
  except Exception as e:
@@ -362,18 +439,18 @@ def run_search(story, country):
362
 
363
  pros = out.get("professionals", []) or []
364
 
365
- # build table records
366
  try:
367
  records = pd.DataFrame(pros).to_dict(orient="records") if pros else []
368
  except Exception:
369
  records = []
370
 
371
- # build dropdown options as list of strings (guarantee at least one)
372
  options = []
373
  for i, r in enumerate(pros):
374
  label_contact = r.get("email") if r.get("email") and r.get("email") != "Not found" else (r.get("phone", "No contact"))
375
- title = r.get("title") or r.get("url") or "(no title)"
376
- label = f"{i} — {title} ({label_contact})"
377
  options.append(label)
378
 
379
  if not options:
@@ -389,30 +466,8 @@ def run_search(story, country):
389
  summary = out.get("summary", "No results found.")
390
  return summary, records, options, anon
391
 
392
-
393
- def _on_search(story, country):
394
- """
395
- Function wired to the search button.
396
- Returns exactly 5 outputs to match:
397
- [summary_out, results_table, dropdown_sel, anon_out, message_in]
398
- """
399
- summary, records, options, anon = run_search(story, country)
400
-
401
- # pre-fill message body with anonymized text (user email left empty for now)
402
- prefill = make_body(anon, story, True, "")
403
-
404
- # Return plain serializable values (not gr.update)
405
- # summary -> str
406
- # records -> list[dict] (or [])
407
- # options -> list[str] for dropdown (Gradio will accept it)
408
- # anon -> str
409
- # prefill -> str (message body)
410
- return summary, records, options, anon, prefill
411
-
412
-
413
  def make_body(anon_text, full_story, use_anon, user_email):
414
  core = (anon_text or "").strip() if use_anon else (full_story or "").strip()
415
- # polite template with user email included in body
416
  lines = [
417
  core,
418
  "",
@@ -422,31 +477,41 @@ def make_body(anon_text, full_story, use_anon, user_email):
422
  ]
423
  return "\n".join([l for l in lines if l is not None])
424
 
425
- def preview_contact(dropdown_value, df_json, subject, message_text):
426
  if not dropdown_value:
427
  return "No contact selected.", ""
428
  try:
429
  idx = int(str(dropdown_value).split(" — ")[0])
430
  rows = pd.DataFrame(df_json)
431
  contact = rows.iloc[idx].to_dict()
432
- recipient = contact.get("email") if contact.get("email") and contact.get("email")!="Not found" else "[no email]"
 
 
 
 
 
 
 
 
433
  html = f"""
434
  <h3>Preview</h3>
435
  <b>To:</b> {recipient}<br/>
436
- <b>Organization:</b> <a href="{contact.get('url')}" target="_blank" rel="noopener">{contact.get('title')}</a><br/>
 
437
  <b>Subject:</b> {subject}<br/>
438
  <hr/>
439
  <pre style="white-space:pre-wrap;">{message_text}</pre>
440
  """
441
- text = f"To: {recipient}\nSubject: {subject}\n\n{message_text[:600]}{'...' if len(message_text)>600 else ''}"
442
  return text, html
443
  except Exception as e:
444
  return f"Preview error: {e}", ""
445
 
446
  def confirm_action(mode, dropdown_value, df_json, subject, message_text,
447
- user_email, sender_email, sender_password, logo_url):
448
  """
449
  mode: "Draft only" or "Send via SMTP (Gmail)"
 
450
  """
451
  if not dropdown_value:
452
  return "❌ No contact selected.", "", None
@@ -459,11 +524,18 @@ def confirm_action(mode, dropdown_value, df_json, subject, message_text,
459
  except Exception as e:
460
  return f"❌ Selection error: {e}", "", None
461
 
462
- recipient = contact.get("email")
 
 
 
 
 
 
 
463
  if mode.startswith("Send"):
464
  # Validate required fields
465
- if not recipient or recipient == "Not found":
466
- return "❌ This contact has no email address. Choose another contact.", "", None
467
  if not user_email or "@" not in user_email:
468
  return "❌ Please enter your email (so the organisation can contact you).", "", None
469
  if not sender_email or not sender_password:
@@ -479,21 +551,27 @@ def confirm_action(mode, dropdown_value, df_json, subject, message_text,
479
  ai_name=ALLY_AI_NAME,
480
  logo_url=logo_url or ALLY_AI_LOGO_URL_DEFAULT
481
  )
482
- # also provide an .eml draft copy (optional)
483
  _, eml_path = build_mailto_and_eml(recipient, subject, message_text, default_from=sender_email)
484
  file_out = eml_path if eml_path and os.path.exists(eml_path) else None
485
  return status, "", file_out
486
  else:
487
- # Draft-only path
488
- recip_for_draft = recipient if (recipient and recipient!="Not found") else ""
489
  mailto, eml_path = build_mailto_and_eml(recip_for_draft, subject, message_text, default_from="noreply@ally.ai")
490
- html_link = f'<a href="{mailto}" target="_blank" rel="noopener">Open draft in email client</a>'
491
- file_out = eml_path if eml_path and os.path.exists(eml_path) else None
492
- return "✅ Draft created (no email sent).", html_link, file_out
 
 
 
 
 
 
 
493
 
494
  with gr.Blocks() as demo:
495
  gr.Markdown("## Ally AI — GBV Help Finder & Email Assistant\n"
496
- "This tool searches local organizations, lets you select a contact, and creates an email draft or sends a branded email via SMTP.\n"
497
  "**Privacy tip:** Prefer anonymized summaries unless you’re comfortable sharing details.")
498
 
499
  with gr.Row():
@@ -502,7 +580,8 @@ with gr.Blocks() as demo:
502
 
503
  search_btn = gr.Button("Search for professionals")
504
  summary_out = gr.Textbox(label="Search summary (AI)", interactive=False)
505
- results_table = gr.Dataframe(headers=["title","url","email","phone","source_query"], label="Search results")
 
506
 
507
  dropdown_sel = gr.Dropdown(label="Select organization (from results)", choices=[])
508
 
@@ -515,6 +594,9 @@ with gr.Blocks() as demo:
515
  subject_in = gr.Textbox(value="Request for GBV support", label="Email subject")
516
  message_in = gr.Textbox(label="Message body", lines=10)
517
 
 
 
 
518
  with gr.Accordion("Sending options (for automatic sending via Ally AI SMTP)", open=False):
519
  mode = gr.Radio(choices=["Draft only (mailto + .eml)", "Send via SMTP (Gmail)"], value="Draft only (mailto + .eml)", label="Delivery mode")
520
  sender_email_in = gr.Textbox(label="Ally AI sender email (SMTP account)")
@@ -534,8 +616,8 @@ with gr.Blocks() as demo:
534
  # Wire: Search
535
  def _on_search(story, country):
536
  s, records, options, anon = run_search(story, country)
537
- # set dropdown + anonymized text and prefill message
538
  prefill = make_body(anon, story, True, "") # user email unknown yet
 
539
  return s, records, gr.update(choices=options, value=(options[0] if options else None)), anon, prefill
540
 
541
  search_btn.click(_on_search,
@@ -553,13 +635,13 @@ with gr.Blocks() as demo:
553
 
554
  # Preview
555
  preview_btn.click(preview_contact,
556
- inputs=[dropdown_sel, results_table, subject_in, message_in],
557
  outputs=[preview_text_out, preview_html_out])
558
 
559
- # Confirm (create draft or send)
560
  confirm_btn.click(confirm_action,
561
  inputs=[mode, dropdown_sel, results_table, subject_in, message_in,
562
- user_email_in, sender_email_in, sender_pass_in, logo_url_in],
563
  outputs=[status_out, mailto_html_out, eml_file_out])
564
 
565
- demo.launch(share=False)
 
1
+ # (paste this into your existing file, replacing the old content)
2
  import os
3
  import time
4
  import re
 
26
  # ============================
27
  # CONFIG (ENV VARS recommended)
28
  # ============================
 
29
  API_KEY = os.environ.get("GOOGLE_API_KEY", "YOUR_GOOGLE_API_KEY")
30
  CX = os.environ.get("GOOGLE_CSE_ID", "YOUR_CSE_ID")
31
  DEFAULT_COUNTRY = "Ghana"
 
37
  ALLY_AI_LOGO_URL_DEFAULT = os.environ.get("ALLY_AI_LOGO_URL",
38
  "https://i.ibb.co/7nZqz0H/ai-logo.png")
39
 
 
40
  COUNTRY_TLD_MAP = {"Ghana":"gh","Nigeria":"ng","Kenya":"ke","South Africa":"za","USA":"us","United Kingdom":"uk"}
41
  COUNTRY_REGION_MAP= {"Ghana":"GH","Nigeria":"NG","Kenya":"KE","South Africa":"ZA","USA":"US","United Kingdom":"GB"}
42
 
 
43
  HEADERS = {"User-Agent":"Mozilla/5.0 (X11; Linux x86_64)"}
44
  EMAIL_REGEX = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
45
 
 
121
  pass
122
  return list(set(phones))
123
 
124
+ def _domain_from_url(url):
125
+ try:
126
+ return urllib.parse.urlparse(url).netloc
127
+ except Exception:
128
+ return url
129
+
130
  def scrape_contacts(url, region="GH"):
131
+ """
132
+ Extended scraping: returns emails, phones, and a guessed 'org' name
133
+ extracted from meta tags, headings, or via NER on page text.
134
+ """
135
  try:
136
  res = requests.get(url, headers=HEADERS, timeout=12)
137
  if not res.ok or not res.text:
138
+ return {"emails": [], "phones": [], "org": None}
139
+ soup = BeautifulSoup(res.text, "html.parser")
140
+
141
+ # raw text for phone/email/NER
142
+ text = soup.get_text(separator=" ")
143
  text = " ".join(text.split())[:300000]
144
+
145
+ # emails & phones
146
  emails = list(set(EMAIL_REGEX.findall(text)))
147
  phones = extract_phones(text, region)
148
+
149
+ # try meta og:site_name or twitter site meta
150
+ org_name = None
151
+ meta_og = soup.find("meta", property="og:site_name") or soup.find("meta", attrs={"name":"og:site_name"})
152
+ if meta_og and meta_og.get("content"):
153
+ org_name = meta_og.get("content").strip()
154
+
155
+ # fallback to <title> or first <h1>
156
+ if not org_name:
157
+ title_tag = soup.find("title")
158
+ if title_tag and title_tag.get_text(strip=True):
159
+ org_name = title_tag.get_text(strip=True)
160
+ if not org_name:
161
+ h1 = soup.find("h1")
162
+ if h1 and h1.get_text(strip=True):
163
+ org_name = h1.get_text(strip=True)
164
+
165
+ # run NER to find ORG mentions in the page text and prefer that if short and clean
166
+ try:
167
+ people, orgs, locs = extract_entities(text)
168
+ if orgs:
169
+ # choose first short/clean org
170
+ for o in orgs:
171
+ if len(o) > 1 and len(o) < 80:
172
+ org_name = o
173
+ break
174
+ except Exception:
175
+ pass
176
+
177
+ # final fallback: domain
178
+ if not org_name:
179
+ org_name = _domain_from_url(url)
180
+
181
+ return {"emails": emails, "phones": phones, "org": org_name}
182
  except Exception as e:
183
  print(f"[scrape error] {url} -> {e}")
184
+ return {"emails": [], "phones": [], "org": _domain_from_url(url)}
185
 
186
  # ============================
187
  # NER + STORY → PROFESSIONS
 
217
  if p == "gbv":
218
  cores += ["GBV support organizations", "gender based violence help"]
219
  else:
220
+ cores += [f"{p} for GBV", f"{p} for sexual assault", f"{p} near me {p} {country}"]
221
  unique_cores, seen = [], set()
222
  for c in cores:
223
  if c not in seen:
 
275
  return {"summary":"No results found. Try a different country or wording.",
276
  "professionals":[], "queries_used":queries}
277
 
278
+ # NER on titles/snippets for context
279
  all_people, all_orgs, all_locs = [], [], []
280
  for r in search_results:
281
  ctx = f"{r.get('title','')}. {r.get('snippet','')}"
282
  p,o,l = extract_entities(ctx)
283
  all_people += p; all_orgs += o; all_locs += l
284
 
285
+ # Scrape contacts concurrently, extracting org names from page content
286
  professionals = []
287
  with ThreadPoolExecutor(max_workers=MAX_SCRAPE_WORKERS) as ex:
288
  futures = {ex.submit(scrape_contacts, r["link"], region): r for r in search_results}
289
  for fut in as_completed(futures):
290
  r = futures[fut]
291
+ contacts = {"emails": [], "phones": [], "org": None}
292
  try:
293
  contacts = fut.result()
294
  except Exception as e:
295
  print("[scrape future error]", r["link"], e)
296
+
297
+ # choose a single email/phone if available
298
+ email = contacts["emails"][0] if contacts.get("emails") else None
299
+ phone = contacts["phones"][0] if contacts.get("phones") else None
300
+ org_name = contacts.get("org") or ""
301
+ # attempt to choose profession tag from the query used
302
+ prof_tag = None
303
+ qlower = (r.get("query") or "").lower()
304
+ for p in professions_from_story(story):
305
+ if p in qlower:
306
+ prof_tag = p
307
+ break
308
+ prof_tag = prof_tag or (professions_from_story(story)[0] if professions_from_story(story) else "gbv")
309
+
310
  professionals.append({
311
+ "org": org_name,
312
  "url": r.get("link",""),
313
+ "email": email if email else "Not found",
314
+ "phone": phone if phone else "Not found",
315
+ "profession": prof_tag,
316
  "source_query": r.get("query","")
317
  })
318
 
 
328
  # DRAFT (mailto + .eml)
329
  # ============================
330
  def build_mailto_and_eml(to_addr, subject, body, default_from="noreply@ally.ai"):
331
+ """
332
+ Creates a proper .eml file and returns (mailto_link, absolute_eml_path).
333
+ Ensures the file is actually written and non-empty. If .eml fails, writes a .txt fallback.
334
+ """
335
+ # sanitize inputs
336
+ to_addr = (to_addr or "").strip()
337
+ subject = subject or ""
338
+ body = body or ""
339
 
340
+ # Create EmailMessage object
341
  msg = EmailMessage()
342
  msg["From"] = default_from
343
  msg["To"] = to_addr
344
  msg["Subject"] = subject
345
  msg.set_content(body)
346
 
347
+ # ensure output dir exists and use absolute path (more robust for HF Spaces / Colab)
348
+ out_dir = os.path.abspath("tmp")
349
+ os.makedirs(out_dir, exist_ok=True)
350
 
351
+ fname = os.path.join(out_dir, f"email_draft_{int(time.time())}.eml")
 
352
 
353
+ try:
354
+ # write bytes
355
+ with open(fname, "wb") as f:
356
+ f.write(msg.as_bytes())
357
+
358
+ # verify file exists and is non-empty
359
+ if os.path.exists(fname) and os.path.getsize(fname) > 0:
360
+ mailto = f"mailto:{urllib.parse.quote(to_addr)}?subject={urllib.parse.quote(subject)}&body={urllib.parse.quote(body)}"
361
+ return mailto, fname
362
+
363
+ # fallback: create a plain text copy (useful for debugging)
364
+ fallback = fname + ".txt"
365
+ with open(fallback, "w", encoding="utf-8") as f:
366
+ f.write(f"To: {to_addr}\nSubject: {subject}\n\n{body}")
367
+ mailto = f"mailto:{urllib.parse.quote(to_addr)}?subject={urllib.parse.quote(subject)}&body={urllib.parse.quote(body)}"
368
+ return mailto, fallback
369
 
370
+ except Exception as e:
371
+ # If writing .eml fails entirely, create a .txt fallback and return that path
372
+ fallback = os.path.join(out_dir, f"email_draft_{int(time.time())}.txt")
373
+ try:
374
+ with open(fallback, "w", encoding="utf-8") as f:
375
+ f.write(f"Error writing .eml: {e}\n\nTo: {to_addr}\nSubject: {subject}\n\n{body}")
376
+ mailto = f"mailto:{urllib.parse.quote(to_addr)}?subject={urllib.parse.quote(subject)}&body={urllib.parse.quote(body)}"
377
+ return mailto, fallback
378
+ except Exception as e2:
379
+ # ultimate fallback: return no file and an informative mailto
380
+ mailto = f"mailto:{urllib.parse.quote(to_addr)}?subject={urllib.parse.quote(subject)}&body={urllib.parse.quote(body)}"
381
+ return mailto, None
382
 
383
  # ============================
384
  # SENDER (SMTP) — Ally AI branding
 
387
  sender_email, sender_password,
388
  ai_name=ALLY_AI_NAME,
389
  logo_url=ALLY_AI_LOGO_URL_DEFAULT):
 
 
 
 
 
 
 
 
390
  if not to_email or to_email == "Not found":
391
+ return "❌ No recipient email found — choose a contact or provide a manual email."
392
 
393
  msg = MIMEMultipart("alternative")
394
  msg["Subject"] = subject or "Request for support"
 
419
  try:
420
  server = smtplib.SMTP("smtp.gmail.com", 587)
421
  server.starttls()
422
+ server.login(sender_email, sender_password)
423
  server.sendmail(sender_email, [to_email], msg.as_string())
424
  server.quit()
425
  return f"✅ Email sent successfully to {to_email}"
 
429
  # ============================
430
  # GRADIO UI
431
  # ============================
 
 
432
  def run_search(story, country):
 
 
 
 
433
  try:
434
  out = find_professionals_from_story(story, country=country, results_per_query=RESULTS_PER_QUERY)
435
  except Exception as e:
 
439
 
440
  pros = out.get("professionals", []) or []
441
 
442
+ # build table records with org instead of article title
443
  try:
444
  records = pd.DataFrame(pros).to_dict(orient="records") if pros else []
445
  except Exception:
446
  records = []
447
 
448
+ # build dropdown options as list of strings
449
  options = []
450
  for i, r in enumerate(pros):
451
  label_contact = r.get("email") if r.get("email") and r.get("email") != "Not found" else (r.get("phone", "No contact"))
452
+ org_label = r.get("org") or r.get("url") or "(no org)"
453
+ label = f"{i} — {org_label} ({label_contact})"
454
  options.append(label)
455
 
456
  if not options:
 
466
  summary = out.get("summary", "No results found.")
467
  return summary, records, options, anon
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  def make_body(anon_text, full_story, use_anon, user_email):
470
  core = (anon_text or "").strip() if use_anon else (full_story or "").strip()
 
471
  lines = [
472
  core,
473
  "",
 
477
  ]
478
  return "\n".join([l for l in lines if l is not None])
479
 
480
+ def preview_contact(dropdown_value, df_json, subject, message_text, manual_email):
481
  if not dropdown_value:
482
  return "No contact selected.", ""
483
  try:
484
  idx = int(str(dropdown_value).split(" — ")[0])
485
  rows = pd.DataFrame(df_json)
486
  contact = rows.iloc[idx].to_dict()
487
+
488
+ # choose recipient from manual_email if provided & valid, else scraped email
489
+ recipient = None
490
+ if manual_email and EMAIL_REGEX.search(manual_email):
491
+ recipient = manual_email.strip()
492
+ else:
493
+ recipient = contact.get("email") if contact.get("email") and contact.get("email")!="Not found" else "[no email]"
494
+
495
+ org_display = contact.get('org') or contact.get('url') or "(no org)"
496
  html = f"""
497
  <h3>Preview</h3>
498
  <b>To:</b> {recipient}<br/>
499
+ <b>Organization:</b> <a href="{contact.get('url')}" target="_blank" rel="noopener">{org_display}</a><br/>
500
+ <b>Profession tag:</b> {contact.get('profession')}<br/>
501
  <b>Subject:</b> {subject}<br/>
502
  <hr/>
503
  <pre style="white-space:pre-wrap;">{message_text}</pre>
504
  """
505
+ text = f"To: {recipient}\nOrganization: {org_display}\nSubject: {subject}\n\n{message_text[:600]}{'...' if len(message_text)>600 else ''}"
506
  return text, html
507
  except Exception as e:
508
  return f"Preview error: {e}", ""
509
 
510
  def confirm_action(mode, dropdown_value, df_json, subject, message_text,
511
+ user_email, sender_email, sender_password, logo_url, manual_email):
512
  """
513
  mode: "Draft only" or "Send via SMTP (Gmail)"
514
+ manual_email: optional override to use when scraped email not found
515
  """
516
  if not dropdown_value:
517
  return "❌ No contact selected.", "", None
 
524
  except Exception as e:
525
  return f"❌ Selection error: {e}", "", None
526
 
527
+ scraped_recipient = contact.get("email")
528
+ # use manual if valid
529
+ recipient = None
530
+ if manual_email and EMAIL_REGEX.search(manual_email):
531
+ recipient = manual_email.strip()
532
+ elif scraped_recipient and scraped_recipient != "Not found":
533
+ recipient = scraped_recipient
534
+
535
  if mode.startswith("Send"):
536
  # Validate required fields
537
+ if not recipient:
538
+ return "❌ No recipient email found — either pick a contact with an email or provide a manual email.", "", None
539
  if not user_email or "@" not in user_email:
540
  return "❌ Please enter your email (so the organisation can contact you).", "", None
541
  if not sender_email or not sender_password:
 
551
  ai_name=ALLY_AI_NAME,
552
  logo_url=logo_url or ALLY_AI_LOGO_URL_DEFAULT
553
  )
 
554
  _, eml_path = build_mailto_and_eml(recipient, subject, message_text, default_from=sender_email)
555
  file_out = eml_path if eml_path and os.path.exists(eml_path) else None
556
  return status, "", file_out
557
  else:
558
+ # Draft-only path (mailto + .eml)
559
+ recip_for_draft = recipient or ""
560
  mailto, eml_path = build_mailto_and_eml(recip_for_draft, subject, message_text, default_from="noreply@ally.ai")
561
+ if eml_path and os.path.exists(eml_path) and os.path.getsize(eml_path) > 0:
562
+ html_link = f'<a href="{mailto}" target="_blank" rel="noopener">Open draft in email client</a>'
563
+ file_out = eml_path
564
+ return "✅ Draft created (no email sent).", html_link, file_out
565
+ elif eml_path and os.path.exists(eml_path):
566
+ # file exists but is empty
567
+ return "⚠️ Draft file created but it's empty. Check the message body or try manual email.", "", eml_path
568
+ else:
569
+ return "❌ Failed to create draft file.", "", None
570
+
571
 
572
  with gr.Blocks() as demo:
573
  gr.Markdown("## Ally AI — GBV Help Finder & Email Assistant\n"
574
+ "This tool searches local professionals/organizations (not article pages), lets you select a contact or enter an email manually, and creates an email draft or sends a branded email via SMTP.\n"
575
  "**Privacy tip:** Prefer anonymized summaries unless you’re comfortable sharing details.")
576
 
577
  with gr.Row():
 
580
 
581
  search_btn = gr.Button("Search for professionals")
582
  summary_out = gr.Textbox(label="Search summary (AI)", interactive=False)
583
+ # updated headers: use org (organization name) instead of article title
584
+ results_table = gr.Dataframe(headers=["org","url","email","phone","profession","source_query"], label="Search results")
585
 
586
  dropdown_sel = gr.Dropdown(label="Select organization (from results)", choices=[])
587
 
 
594
  subject_in = gr.Textbox(value="Request for GBV support", label="Email subject")
595
  message_in = gr.Textbox(label="Message body", lines=10)
596
 
597
+ # Manual override for organization email (new)
598
+ manual_email_in = gr.Textbox(label="Manual org email (optional) - used when a scraped email isn't available")
599
+
600
  with gr.Accordion("Sending options (for automatic sending via Ally AI SMTP)", open=False):
601
  mode = gr.Radio(choices=["Draft only (mailto + .eml)", "Send via SMTP (Gmail)"], value="Draft only (mailto + .eml)", label="Delivery mode")
602
  sender_email_in = gr.Textbox(label="Ally AI sender email (SMTP account)")
 
616
  # Wire: Search
617
  def _on_search(story, country):
618
  s, records, options, anon = run_search(story, country)
 
619
  prefill = make_body(anon, story, True, "") # user email unknown yet
620
+ # return updated dropdown choices (value is first option)
621
  return s, records, gr.update(choices=options, value=(options[0] if options else None)), anon, prefill
622
 
623
  search_btn.click(_on_search,
 
635
 
636
  # Preview
637
  preview_btn.click(preview_contact,
638
+ inputs=[dropdown_sel, results_table, subject_in, message_in, manual_email_in],
639
  outputs=[preview_text_out, preview_html_out])
640
 
641
+ # Confirm (create draft or send) - manual_email_in passed as last arg
642
  confirm_btn.click(confirm_action,
643
  inputs=[mode, dropdown_sel, results_table, subject_in, message_in,
644
+ user_email_in, sender_email_in, sender_pass_in, logo_url_in, manual_email_in],
645
  outputs=[status_out, mailto_html_out, eml_file_out])
646
 
647
+ demo.launch(share=False)