Seth0330 commited on
Commit
c6aa5bd
·
verified ·
1 Parent(s): 937d0ae

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +233 -213
app.py CHANGED
@@ -3,6 +3,7 @@ import io
3
  import base64
4
  import textwrap
5
  import csv
 
6
  import requests
7
  import streamlit as st
8
  from PIL import Image
@@ -19,9 +20,9 @@ st.set_page_config(
19
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
20
  client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
21
 
22
- VISION_MODEL = "gpt-4.1-mini" # image-capable model
23
- REASONING_MODEL = "gpt-4.1-mini" # reasoning model
24
- ASR_MODEL = "whisper-1" # transcription
25
 
26
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
27
  RECALLS_FEED_URL = os.getenv(
@@ -30,10 +31,8 @@ RECALLS_FEED_URL = os.getenv(
30
  )
31
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
32
 
33
- # Optional: your own wait-time microservice (if you create one)
34
  WAIT_TIMES_API_BASE = os.getenv("WAIT_TIMES_API_BASE", "").rstrip("/")
35
 
36
- # Google Custom Search (JSON API)
37
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
38
  GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID")
39
 
@@ -58,16 +57,12 @@ POSTAL_PREFIX_TO_PROVINCE = {
58
  "Y": "Yukon",
59
  }
60
 
61
- USER_AGENT = "carecall-ai-googlecse-hqo-city/1.0"
62
 
63
 
64
- # ============== POSTAL -> CITY MAP USING canadacities.csv ==============
65
 
66
  def load_postal_city_map(path: str = "canadacities.csv"):
67
- """
68
- Build a map: first 3 chars of postal code (FSA) -> (city, province).
69
- Tries to auto-detect column names in the CSV.
70
- """
71
  mapping = {}
72
  if not os.path.exists(path):
73
  return mapping
@@ -78,7 +73,6 @@ def load_postal_city_map(path: str = "canadacities.csv"):
78
  header = next(reader, None)
79
  if not header:
80
  return mapping
81
-
82
  lower = [h.strip().lower() for h in header]
83
 
84
  def find_col(candidates):
@@ -113,10 +107,8 @@ def load_postal_city_map(path: str = "canadacities.csv"):
113
  else ""
114
  )
115
  if prefix and (city or prov):
116
- # first entry wins; that's fine at FSA granularity
117
  mapping.setdefault(prefix, (city, prov))
118
  except Exception:
119
- # On any parsing error, just fall back silently to province-only logic
120
  return mapping
121
 
122
  return mapping
@@ -131,8 +123,7 @@ def get_city_from_postal(postal_code: str):
131
  pc = postal_code.strip().upper().replace(" ", "")
132
  if len(pc) < 3:
133
  return None, None
134
- prefix = pc[:3]
135
- return POSTAL_CITY_MAP.get(prefix, (None, None))
136
 
137
 
138
  # ============== GENERIC HELPERS ==============
@@ -203,7 +194,16 @@ def geocode_postal(postal_code: str):
203
  return None
204
 
205
 
206
- # ============== GOOGLE CUSTOM SEARCH (CITY-BASED) ==============
 
 
 
 
 
 
 
 
 
207
 
208
  def google_cse_search(query: str, num: int = 5):
209
  if not GOOGLE_API_KEY or not GOOGLE_CSE_ID:
@@ -218,27 +218,64 @@ def google_cse_search(query: str, num: int = 5):
218
  return safe_get_json(url, params=params)
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  def tool_search_wait_times_google(postal_code: str) -> str:
222
  """
223
- Build a query biased to the user's city (from canadacities.csv) instead of
224
- just generic province, so we get 'hospitals/clinics in <city>'.
225
  """
226
  if not postal_code:
227
  return "Postal code missing; Google Custom Search not used."
228
 
229
  city, prov = get_city_from_postal(postal_code)
230
  if city:
231
- location_phrase = f"{city}, {prov or 'Canada'}"
232
  else:
233
- location_phrase = f"{postal_code} Canada"
234
 
235
  data = google_cse_search(
236
- query=(
237
- f"hospital OR walk-in clinic OR urgent care OR emergency department in {location_phrase}"
238
- ),
239
- num=6,
240
  )
241
-
242
  if data is None:
243
  return (
244
  "Google Custom Search is not configured (set GOOGLE_API_KEY and GOOGLE_CSE_ID). "
@@ -248,15 +285,15 @@ def tool_search_wait_times_google(postal_code: str) -> str:
248
  items = data.get("items", [])
249
  if not items:
250
  return (
251
- f"No strong Google CSE matches for {location_phrase}. "
252
  "Check provincial or hospital websites directly."
253
  )
254
 
255
- lines = [f"Google CSE results for clinics/hospitals around {location_phrase} (verify directly):"]
256
- for it in items[:6]:
257
  title = it.get("title", "Result")
258
  link = it.get("link", "")
259
- snippet = shorten(it.get("snippet", "") or it.get("htmlSnippet", ""), 180)
260
  lines.append(f"- {title} — {link} — {snippet}")
261
 
262
  return "\n".join(lines)
@@ -269,10 +306,9 @@ def tool_hqontario_context(postal_code: str) -> str:
269
  if province != "Ontario":
270
  return ""
271
  return (
272
- "For Ontario-specific emergency department locations and typical wait times near your postal code, "
273
- "use Ontario Health's official tool: "
274
  "https://www.hqontario.ca/System-Performance/Time-Spent-in-Emergency-Departments "
275
- "(select 'Area by Postal Code', enter your code, and review nearby hospitals)."
276
  )
277
 
278
 
@@ -288,31 +324,33 @@ def tool_region_context(postal_code: str) -> str:
288
  "Use Canada-wide guidance and suggest checking local provincial health ministry and Telehealth services."
289
  )
290
  telehealth_note = (
291
- f"In {province}, advise use of the official provincial Telehealth or 811-style nurse line "
292
- "for real-time nurse assessment."
293
  )
294
  wait_note = (
295
  "Users can check provincial/health authority resources and CIHI indicators "
296
  f"for average wait-time context (not real-time). Ref: {WAIT_TIMES_INFO_URL}"
297
  )
298
- return (
299
- f"User postal code '{postal_code}' mapped to {province}.\n"
300
- f"{telehealth_note}\n"
301
- f"{wait_note}"
302
- )
303
 
304
 
305
- def tool_find_nearby_clinics_osm(postal_code: str) -> str:
306
- coords = geocode_postal(postal_code)
307
- if not coords:
308
- return (
309
- "Could not geocode the postal code. Suggest using maps or provincial clinic finders."
310
- )
 
 
311
 
312
- lat, lon = coords
313
- radius_m = 3000
314
- overpass_url = "https://overpass-api.de/api/interpreter"
315
- query = f"""[out:json][timeout:8];
 
 
 
 
 
316
  (
317
  node["amenity"="clinic"](around:{radius_m},{lat},{lon});
318
  node["amenity"="hospital"](around:{radius_m},{lat},{lon});
@@ -321,76 +359,74 @@ def tool_find_nearby_clinics_osm(postal_code: str) -> str:
321
  );
322
  out center;
323
  """
324
- data = safe_get_json(overpass_url, method="POST", data=query)
325
- if not data or "elements" not in data:
326
- return (
327
- "Nearby facility lookup via open map data is unavailable. "
328
- "Use maps or provincial tools to find the closest clinic or ER."
329
- )
330
-
331
- facilities = []
332
- for el in data["elements"]:
333
- tags = el.get("tags", {})
334
- name = tags.get("name")
335
- if not name:
336
- continue
337
-
338
- amenity = tags.get("amenity", "clinic")
339
- lat2 = el.get("lat") or (el.get("center") or {}).get("lat")
340
- lon2 = el.get("lon") or (el.get("center") or {}).get("lon")
341
- if lat2 is None or lon2 is None:
342
- continue
343
-
344
- dist = haversine_km(lat, lon, float(lat2), float(lon2))
345
-
346
- addr_parts = []
347
- for k in ("addr:street", "addr:city"):
348
- if tags.get(k):
349
- addr_parts.append(tags[k])
350
- addr = ", ".join(addr_parts)
351
-
352
- wait = None
353
- if WAIT_TIMES_API_BASE:
354
- jt = safe_get_json(
355
- f"{WAIT_TIMES_API_BASE}/wait",
356
- params={"name": name},
357
- timeout=4
358
- )
359
- if jt and isinstance(jt, dict) and jt.get("estimated_minutes") is not None:
360
- wait = int(jt["estimated_minutes"])
361
-
362
- facilities.append(
363
- {
364
- "name": name,
365
- "type": amenity,
366
- "distance_km": dist,
367
- "address": addr,
368
- "wait": wait,
369
- }
370
- )
371
-
372
- facilities = sorted(facilities, key=lambda x: x["distance_km"])[:5]
373
- if not facilities:
 
 
 
 
 
 
 
374
  return (
375
- "No nearby facilities found within about 3 km. Use maps or provincial tools to locate the closest clinic/ER."
 
376
  )
377
 
378
- lines = ["Closest facilities (approximate from open data, within ~3 km):"]
379
- for f in facilities:
380
- s = f"- {f['name']} ({f['type']}, ~{f['distance_km']:.1f} km"
381
- if f["address"]:
382
- s += f", {f['address']}"
383
- if f["wait"] is not None:
384
- s += f", est. wait ~{f['wait']} min"
385
- s += ")"
386
- lines.append(s)
387
-
388
  if not WAIT_TIMES_API_BASE:
389
- lines.append(
390
- "Note: Live wait times are not provided here. Check the facility or provincial website directly."
391
  )
392
 
393
- return "\n".join(lines)
394
 
395
 
396
  def tool_lookup_drug_products(text: str) -> str:
@@ -483,19 +519,13 @@ def tool_get_wait_times_awareness() -> str:
483
  def call_vision_summarizer(image_bytes: bytes) -> str:
484
  if not client or not image_bytes:
485
  return ""
486
-
487
  b64 = base64.b64encode(image_bytes).decode("utf-8")
488
-
489
  prompt = (
490
  "You are a cautious Canadian health-information assistant.\n"
491
  "Describe ONLY what is visible in neutral terms.\n"
492
- "- For skin/nails: describe colour, dryness, cracks, swelling, etc. Do NOT name diseases.\n"
493
- "- For medication labels: mention visible drug names/strengths if legible.\n"
494
- "- For letters/reports: say what type of document it appears to be; avoid identifiers.\n"
495
- "- If unclear: say it is not medically interpretable.\n"
496
- "Never diagnose or prescribe. Keep to ~80-100 words."
497
  )
498
-
499
  try:
500
  resp = client.chat.completions.create(
501
  model=VISION_MODEL,
@@ -528,9 +558,7 @@ def call_asr(audio_file) -> str:
528
  file=bio
529
  )
530
  txt = getattr(transcript, "text", None)
531
- if isinstance(txt, str):
532
- return txt.strip()
533
- return str(transcript)
534
  except Exception as e:
535
  return f"(Transcription unavailable: {e})"
536
 
@@ -543,8 +571,8 @@ def call_reasoning_agent(
543
  recalls_context: str,
544
  wait_awareness_context: str,
545
  region_context: str,
546
- nearby_osm_context: str,
547
- google_search_context: str,
548
  hqontario_context: str,
549
  ) -> str:
550
  if not client:
@@ -553,55 +581,68 @@ def call_reasoning_agent(
553
  "into cautious, structured guidance."
554
  )
555
 
556
- system_prompt = """You are CareCall AI, an agentic Canadian health information assistant.
 
557
 
558
- You receive:
559
- - Narrative, vision summary, postal code.
560
- - Drug Product Database context.
561
- - Recalls & Safety Alerts snapshot.
562
- - Wait-time awareness notes.
563
- - Region context (province + Telehealth).
564
- - Nearby facilities from OSM.
565
- - Google Custom Search context (official pages).
566
- - HQOntario/Ontario Health tool context when applicable.
567
 
568
  Rules:
569
- - Do NOT give a diagnosis.
570
- - Do NOT prescribe or specify dosing.
571
- - Do NOT invent real-time wait times.
572
- - Use ONLY the provided context.
573
- - Keep response under ~350 words.
574
- - Be clear, neutral, practical.
575
-
576
- Pick EXACTLY ONE primary pathway:
577
  A) "Home care likely reasonable (monitor closely)"
578
  B) "Pharmacist / Walk-in clinic / Family doctor recommended"
579
  C) "Call provincial Telehealth / nurse line for guidance"
580
  D) "Go to Emergency / Call 911 now"
581
 
582
- Conditional sections:
583
- - If A: include Home Care; When to see Pharmacist/Clinic/Telehealth; When this is an Emergency (ER/911);
584
- Local & Official Resources; Important Disclaimer.
585
- - If B or C: include When to see Pharmacist/Clinic/Telehealth (as primary); When this is an Emergency (ER/911);
586
- Local & Official Resources; Important Disclaimer. Do NOT include a Home Care block.
587
- - If D: include ONLY When this is an Emergency (ER/911); Local & Official Resources; Important Disclaimer.
588
-
589
- In Local & Official Resources:
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  - Use region_context.
591
- - Use nearby_osm_context.
592
- - Include hqontario_context when relevant (Ontario).
593
- - Optionally include google_search_context (brief).
594
- - Mention Health Canada / PHAC / Recalls / DPD where useful.
595
-
596
- Use only broad, non-diagnostic categories (e.g., "possible infection risk", "nail or soft tissue injury")."""
597
 
598
- user_prompt = f"""INPUT CONTEXT
 
599
 
 
600
  User narrative:
601
  {narrative or "(none provided)"}
602
 
603
  Vision summary:
604
- {vision_summary or "(no image or no usable visual details)"}
605
 
606
  Postal code:
607
  {postal_code or "(not provided)"}
@@ -609,25 +650,25 @@ Postal code:
609
  Drug Product Database context:
610
  {dpd_context}
611
 
612
- Recalls & Safety Alerts context:
613
  {recalls_context}
614
 
615
- Wait-time awareness (general):
616
  {wait_awareness_context}
617
 
618
  Region context:
619
  {region_context}
620
 
621
- Nearby facilities (OSM-based):
622
- {nearby_osm_context}
623
 
624
- Google search context:
625
- {google_search_context}
626
 
627
- HQOntario/Ontario Health context:
628
  {hqontario_context or "(not applicable)"}
629
 
630
- Now follow the system instructions and generate the final structured response.
631
  """
632
 
633
  try:
@@ -650,34 +691,30 @@ Now follow the system instructions and generate the final structured response.
650
  # ============== STREAMLIT UI ==============
651
 
652
  st.title("CareCall AI (Canada)")
653
- st.caption("Vision + Voice + Postal + City-aware search • Pathway-specific non-diagnostic guidance")
654
 
655
  if not OPENAI_API_KEY:
656
  st.stop()
657
 
658
  st.markdown(
659
  """
660
- Upload a photo, add a voice note, and/or type what's going on.
661
- Optionally enter your **Canadian postal code** so we can:
662
- - infer province and Telehealth options,
663
- - look up your city from `canadacities.csv`,
664
- - search for nearby clinics/hospitals in that city,
665
- - and suggest local official resources.
666
-
667
- The agent chooses **one** primary pathway and only shows sections for that pathway.
668
  """
669
  )
670
 
671
- # Postal code
672
  postal_code = st.text_input(
673
- "Canadian postal code (e.g., M5V 2T6). Leave blank for general guidance.",
674
  max_chars=7,
675
  )
676
 
677
- # Image
678
  st.markdown("### Image (optional)")
679
  image_file = st.file_uploader(
680
- "Upload a photo (JPG/PNG). Avoid highly identifying content.",
681
  type=["jpg", "jpeg", "png"],
682
  accept_multiple_files=False,
683
  )
@@ -693,7 +730,6 @@ if image_file is not None:
693
  st.warning(f"Could not read that image: {e}")
694
  image_bytes = None
695
 
696
- # Audio
697
  st.markdown("### Voice note (optional)")
698
  audio_file = st.file_uploader(
699
  "Upload a short voice note (wav/mp3/m4a).",
@@ -701,15 +737,12 @@ audio_file = st.file_uploader(
701
  accept_multiple_files=False,
702
  )
703
 
704
- # Text
705
  st.markdown("### Description")
706
  user_text = st.text_area(
707
- "Describe what's going on:",
708
- placeholder='Example: "Big toenail is sore and a bit red for 5 days when wearing tight shoes, no fever, I can walk okay."',
709
  height=120,
710
  )
711
 
712
- # Run
713
  if st.button("Run CareCall Agent"):
714
  with st.spinner("Analyzing inputs and generating guidance..."):
715
  vision_summary = call_vision_summarizer(image_bytes) if image_bytes else ""
@@ -723,33 +756,20 @@ if st.button("Run CareCall Agent"):
723
  narrative = "\n".join(parts)
724
 
725
  combined_for_drugs = " ".join(x for x in [narrative, vision_summary] if x)
726
- dpd_context = (
727
- tool_lookup_drug_products(combined_for_drugs)
728
- if combined_for_drugs else "No medication context."
729
- )
730
-
731
  recalls_context = tool_get_recent_recalls_snippet()
732
  wait_awareness_context = tool_get_wait_times_awareness()
733
 
734
- region_context = (
735
- tool_region_context(postal_code)
736
- if postal_code
737
- else "No postal code provided. Use Canada-wide guidance and mention provincial resources."
738
- )
739
-
740
- nearby_osm_context = (
741
- tool_find_nearby_clinics_osm(postal_code)
742
- if postal_code
743
- else "No postal code. Suggest using maps and provincial clinic/ER finder tools."
744
- )
745
-
746
- google_search_context = (
747
- tool_search_wait_times_google(postal_code)
748
- if postal_code
749
- else "Postal code missing; Google Custom Search not used."
750
- )
751
-
752
- hqontario_context = tool_hqontario_context(postal_code) if postal_code else ""
753
 
754
  final_answer = call_reasoning_agent(
755
  narrative=narrative,
@@ -759,8 +779,8 @@ if st.button("Run CareCall Agent"):
759
  recalls_context=recalls_context,
760
  wait_awareness_context=wait_awareness_context,
761
  region_context=region_context,
762
- nearby_osm_context=nearby_osm_context,
763
- google_search_context=google_search_context,
764
  hqontario_context=hqontario_context,
765
  )
766
 
 
3
  import base64
4
  import textwrap
5
  import csv
6
+ import re
7
  import requests
8
  import streamlit as st
9
  from PIL import Image
 
20
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
  client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
22
 
23
+ VISION_MODEL = "gpt-4.1-mini"
24
+ REASONING_MODEL = "gpt-4.1-mini"
25
+ ASR_MODEL = "whisper-1"
26
 
27
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
28
  RECALLS_FEED_URL = os.getenv(
 
31
  )
32
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
33
 
 
34
  WAIT_TIMES_API_BASE = os.getenv("WAIT_TIMES_API_BASE", "").rstrip("/")
35
 
 
36
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
37
  GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID")
38
 
 
57
  "Y": "Yukon",
58
  }
59
 
60
+ USER_AGENT = "carecall-ai-cityfacilities/1.0"
61
 
62
 
63
+ # ============== POSTAL -> CITY MAP (canadacities.csv) ==============
64
 
65
  def load_postal_city_map(path: str = "canadacities.csv"):
 
 
 
 
66
  mapping = {}
67
  if not os.path.exists(path):
68
  return mapping
 
73
  header = next(reader, None)
74
  if not header:
75
  return mapping
 
76
  lower = [h.strip().lower() for h in header]
77
 
78
  def find_col(candidates):
 
107
  else ""
108
  )
109
  if prefix and (city or prov):
 
110
  mapping.setdefault(prefix, (city, prov))
111
  except Exception:
 
112
  return mapping
113
 
114
  return mapping
 
123
  pc = postal_code.strip().upper().replace(" ", "")
124
  if len(pc) < 3:
125
  return None, None
126
+ return POSTAL_CITY_MAP.get(pc[:3], (None, None))
 
127
 
128
 
129
  # ============== GENERIC HELPERS ==============
 
194
  return None
195
 
196
 
197
+ def extract_phone(text: str):
198
+ if not text:
199
+ return ""
200
+ # simple North American phone pattern
201
+ pattern = r"(\(?\d{3}\)?[\s\-\.]?\d{3}[\s\-\.]?\d{4})"
202
+ m = re.search(pattern, text)
203
+ return m.group(1) if m else ""
204
+
205
+
206
+ # ============== GOOGLE CSE HELPERS ==============
207
 
208
  def google_cse_search(query: str, num: int = 5):
209
  if not GOOGLE_API_KEY or not GOOGLE_CSE_ID:
 
218
  return safe_get_json(url, params=params)
219
 
220
 
221
+ def tool_city_facilities_google(postal_code: str) -> str:
222
+ """
223
+ City-level fallback using Google CSE:
224
+ - Find city from canadacities.csv.
225
+ - Search for hospitals/clinics in that city.
226
+ - Extract name, rough location, phone if visible.
227
+ """
228
+ if not postal_code:
229
+ return ""
230
+
231
+ city, prov = get_city_from_postal(postal_code)
232
+ if city:
233
+ loc = f"{city}, {prov or 'Canada'}"
234
+ else:
235
+ loc = f"{postal_code} Canada"
236
+
237
+ data = google_cse_search(
238
+ query=f"hospital OR walk-in clinic OR urgent care in {loc}",
239
+ num=5,
240
+ )
241
+ if not data or "items" not in data:
242
+ return ""
243
+
244
+ lines = [f"City-based facilities from official/clinical search results around {loc} (verify details):"]
245
+ for it in data["items"][:5]:
246
+ title = it.get("title", "Result")
247
+ link = it.get("link", "")
248
+ snippet = shorten(it.get("snippet", "") or it.get("htmlSnippet", ""), 200)
249
+ phone = extract_phone(title + " " + snippet)
250
+ entry = f"- {title}"
251
+ if phone:
252
+ entry += f" | Phone: {phone}"
253
+ if snippet:
254
+ entry += f" | Info: {snippet}"
255
+ if link:
256
+ entry += f" | {link}"
257
+ lines.append(entry)
258
+
259
+ return "\n".join(lines)
260
+
261
+
262
  def tool_search_wait_times_google(postal_code: str) -> str:
263
  """
264
+ For context links only; separate from facilities list.
 
265
  """
266
  if not postal_code:
267
  return "Postal code missing; Google Custom Search not used."
268
 
269
  city, prov = get_city_from_postal(postal_code)
270
  if city:
271
+ loc = f"{city}, {prov or 'Canada'}"
272
  else:
273
+ loc = f"{postal_code} Canada"
274
 
275
  data = google_cse_search(
276
+ query=f"emergency department wait times {loc}",
277
+ num=5,
 
 
278
  )
 
279
  if data is None:
280
  return (
281
  "Google Custom Search is not configured (set GOOGLE_API_KEY and GOOGLE_CSE_ID). "
 
285
  items = data.get("items", [])
286
  if not items:
287
  return (
288
+ f"No clear wait-time dashboards found for {loc}. "
289
  "Check provincial or hospital websites directly."
290
  )
291
 
292
+ lines = [f"Google CSE links for wait-time / access info near {loc} (verify sources):"]
293
+ for it in items[:5]:
294
  title = it.get("title", "Result")
295
  link = it.get("link", "")
296
+ snippet = shorten(it.get("snippet", "") or it.get("htmlSnippet", ""), 160)
297
  lines.append(f"- {title} — {link} — {snippet}")
298
 
299
  return "\n".join(lines)
 
306
  if province != "Ontario":
307
  return ""
308
  return (
309
+ "For Ontario ED locations and typical wait times near your postal code, use Ontario Health's official tool: "
 
310
  "https://www.hqontario.ca/System-Performance/Time-Spent-in-Emergency-Departments "
311
+ "(select 'Area by Postal Code')."
312
  )
313
 
314
 
 
324
  "Use Canada-wide guidance and suggest checking local provincial health ministry and Telehealth services."
325
  )
326
  telehealth_note = (
327
+ f"In {province}, advise use of the official provincial Telehealth or 811-style nurse line."
 
328
  )
329
  wait_note = (
330
  "Users can check provincial/health authority resources and CIHI indicators "
331
  f"for average wait-time context (not real-time). Ref: {WAIT_TIMES_INFO_URL}"
332
  )
333
+ return f"User postal code '{postal_code}' mapped to {province}.\n{telehealth_note}\n{wait_note}"
 
 
 
 
334
 
335
 
336
+ def tool_find_nearby_clinics(postal_code: str) -> str:
337
+ """
338
+ Main facility tool:
339
+ 1) Try OSM within ~3km.
340
+ 2) If none, fall back to city-based Google CSE facilities.
341
+ """
342
+ if not postal_code:
343
+ return "No postal code. Suggest using maps and provincial clinic/ER finders."
344
 
345
+ coords = geocode_postal(postal_code)
346
+ facilities_lines = []
347
+
348
+ # 1) OSM-based if geocoding worked
349
+ if coords:
350
+ lat, lon = coords
351
+ radius_m = 3000
352
+ overpass_url = "https://overpass-api.de/api/interpreter"
353
+ query = f"""[out:json][timeout:8];
354
  (
355
  node["amenity"="clinic"](around:{radius_m},{lat},{lon});
356
  node["amenity"="hospital"](around:{radius_m},{lat},{lon});
 
359
  );
360
  out center;
361
  """
362
+ data = safe_get_json(overpass_url, method="POST", data=query)
363
+ if data and "elements" in data:
364
+ facilities = []
365
+ for el in data["elements"]:
366
+ tags = el.get("tags", {})
367
+ name = tags.get("name")
368
+ if not name:
369
+ continue
370
+ amenity = tags.get("amenity", "clinic")
371
+ lat2 = el.get("lat") or (el.get("center") or {}).get("lat")
372
+ lon2 = el.get("lon") or (el.get("center") or {}).get("lon")
373
+ if lat2 is None or lon2 is None:
374
+ continue
375
+ dist = haversine_km(lat, lon, float(lat2), float(lon2))
376
+ addr_parts = []
377
+ for k in ("addr:street", "addr:city"):
378
+ if tags.get(k):
379
+ addr_parts.append(tags[k])
380
+ addr = ", ".join(addr_parts)
381
+ wait = None
382
+ if WAIT_TIMES_API_BASE:
383
+ jt = safe_get_json(
384
+ f"{WAIT_TIMES_API_BASE}/wait",
385
+ params={"name": name},
386
+ timeout=4
387
+ )
388
+ if jt and isinstance(jt, dict) and jt.get("estimated_minutes") is not None:
389
+ wait = int(jt["estimated_minutes"])
390
+ facilities.append(
391
+ {
392
+ "name": name,
393
+ "type": amenity,
394
+ "distance_km": dist,
395
+ "address": addr,
396
+ "wait": wait,
397
+ }
398
+ )
399
+ facilities = sorted(facilities, key=lambda x: x["distance_km"])[:5]
400
+ if facilities:
401
+ facilities_lines.append("Closest facilities from open map data (approx., within ~3 km):")
402
+ for f in facilities:
403
+ s = f"- {f['name']} ({f['type']}, ~{f['distance_km']:.1f} km"
404
+ if f["address"]:
405
+ s += f", {f['address']}"
406
+ if f["wait"] is not None:
407
+ s += f", est. wait ~{f['wait']} min"
408
+ s += ")"
409
+ facilities_lines.append(s)
410
+
411
+ # 2) City-based Google CSE fallback (if OSM empty or as extra signal)
412
+ city_facilities = tool_city_facilities_google(postal_code)
413
+ if city_facilities:
414
+ if facilities_lines:
415
+ facilities_lines.append("") # spacing
416
+ facilities_lines.append(city_facilities)
417
+
418
+ if not facilities_lines:
419
  return (
420
+ "Unable to list nearby facilities automatically. "
421
+ "Please use provincial or hospital 'find a clinic/ER' tools or maps with your postal code."
422
  )
423
 
 
 
 
 
 
 
 
 
 
 
424
  if not WAIT_TIMES_API_BASE:
425
+ facilities_lines.append(
426
+ "Note: Any wait-time or status information must be checked directly on the facility or provincial website."
427
  )
428
 
429
+ return "\n".join(facilities_lines)
430
 
431
 
432
  def tool_lookup_drug_products(text: str) -> str:
 
519
  def call_vision_summarizer(image_bytes: bytes) -> str:
520
  if not client or not image_bytes:
521
  return ""
 
522
  b64 = base64.b64encode(image_bytes).decode("utf-8")
 
523
  prompt = (
524
  "You are a cautious Canadian health-information assistant.\n"
525
  "Describe ONLY what is visible in neutral terms.\n"
526
+ "No diagnoses or prescriptions.\n"
527
+ "Keep to about 80-100 words."
 
 
 
528
  )
 
529
  try:
530
  resp = client.chat.completions.create(
531
  model=VISION_MODEL,
 
558
  file=bio
559
  )
560
  txt = getattr(transcript, "text", None)
561
+ return txt.strip() if isinstance(txt, str) else str(transcript)
 
 
562
  except Exception as e:
563
  return f"(Transcription unavailable: {e})"
564
 
 
571
  recalls_context: str,
572
  wait_awareness_context: str,
573
  region_context: str,
574
+ facilities_context: str,
575
+ wait_links_context: str,
576
  hqontario_context: str,
577
  ) -> str:
578
  if not client:
 
581
  "into cautious, structured guidance."
582
  )
583
 
584
+ system_prompt = """
585
+ You are CareCall AI, an agentic Canadian health information assistant.
586
 
587
+ Use ONLY the provided context:
588
+ - narrative, vision summary, postal;
589
+ - drug product info;
590
+ - recalls info;
591
+ - wait-time awareness;
592
+ - region context;
593
+ - facilities context (OSM + city-based Google CSE);
594
+ - wait-time links context;
595
+ - HQOntario context.
596
 
597
  Rules:
598
+ - NO diagnosis.
599
+ - NO prescribing or exact dosing.
600
+ - NO fabricated real-time wait times.
601
+ - Max ~350 words.
602
+ - Clear, neutral, practical.
603
+
604
+ Choose EXACTLY ONE primary pathway:
 
605
  A) "Home care likely reasonable (monitor closely)"
606
  B) "Pharmacist / Walk-in clinic / Family doctor recommended"
607
  C) "Call provincial Telehealth / nurse line for guidance"
608
  D) "Go to Emergency / Call 911 now"
609
 
610
+ Sections:
611
+ - Always start with **Primary Recommended Pathway** (one bullet + short reason).
612
+ - If A: then show
613
+ **Home Care**,
614
+ **When to see a Pharmacist / Clinic / Telehealth**,
615
+ **When this is an Emergency (ER/911)**,
616
+ **Local & Official Resources**,
617
+ **Important Disclaimer**.
618
+ - If B or C: then show
619
+ **When to see a Pharmacist / Clinic / Telehealth** (primary),
620
+ **When this is an Emergency (ER/911)**,
621
+ **Local & Official Resources**,
622
+ **Important Disclaimer**.
623
+ (No Home Care block.)
624
+ - If D: then show ONLY
625
+ **When this is an Emergency (ER/911)**,
626
+ **Local & Official Resources**,
627
+ **Important Disclaimer**.
628
+ (No Home Care or Walk-in blocks.)
629
+
630
+ In **Local & Official Resources**:
631
  - Use region_context.
632
+ - Use facilities_context (names/locations/phones/links).
633
+ - Include hqontario_context when present.
634
+ - Optionally summarize wait_links_context.
635
+ - Mention Health Canada / Recalls / DPD if relevant.
 
 
636
 
637
+ Use broad categories only (e.g., "possible infection risk", "soft tissue or nail injury").
638
+ """
639
 
640
+ user_prompt = f"""
641
  User narrative:
642
  {narrative or "(none provided)"}
643
 
644
  Vision summary:
645
+ {vision_summary or "(none)"}
646
 
647
  Postal code:
648
  {postal_code or "(not provided)"}
 
650
  Drug Product Database context:
651
  {dpd_context}
652
 
653
+ Recalls context:
654
  {recalls_context}
655
 
656
+ Wait-time awareness:
657
  {wait_awareness_context}
658
 
659
  Region context:
660
  {region_context}
661
 
662
+ Facilities context (OSM + city-based Google CSE):
663
+ {facilities_context}
664
 
665
+ Wait-time links context:
666
+ {wait_links_context}
667
 
668
+ HQOntario context:
669
  {hqontario_context or "(not applicable)"}
670
 
671
+ Now follow the system instructions exactly.
672
  """
673
 
674
  try:
 
691
  # ============== STREAMLIT UI ==============
692
 
693
  st.title("CareCall AI (Canada)")
694
+ st.caption("Vision + Voice + Postal + City-aware facilities • Pathway-specific non-diagnostic guidance")
695
 
696
  if not OPENAI_API_KEY:
697
  st.stop()
698
 
699
  st.markdown(
700
  """
701
+ Enter your details below. The assistant will:
702
+ - interpret your description and image (non-diagnostically),
703
+ - detect your city from the postal code (via an internal table),
704
+ - list nearby hospitals/clinics using open map data and trusted Google CSE results,
705
+ - link to official wait-time tools where appropriate,
706
+ - and give ONE clear primary pathway (home care vs clinic vs ER), with only relevant sections shown.
 
 
707
  """
708
  )
709
 
 
710
  postal_code = st.text_input(
711
+ "Canadian postal code (e.g., M5V 2T6).",
712
  max_chars=7,
713
  )
714
 
 
715
  st.markdown("### Image (optional)")
716
  image_file = st.file_uploader(
717
+ "Upload a photo (JPG/PNG).",
718
  type=["jpg", "jpeg", "png"],
719
  accept_multiple_files=False,
720
  )
 
730
  st.warning(f"Could not read that image: {e}")
731
  image_bytes = None
732
 
 
733
  st.markdown("### Voice note (optional)")
734
  audio_file = st.file_uploader(
735
  "Upload a short voice note (wav/mp3/m4a).",
 
737
  accept_multiple_files=False,
738
  )
739
 
 
740
  st.markdown("### Description")
741
  user_text = st.text_area(
742
+ "Describe what is happening:",
 
743
  height=120,
744
  )
745
 
 
746
  if st.button("Run CareCall Agent"):
747
  with st.spinner("Analyzing inputs and generating guidance..."):
748
  vision_summary = call_vision_summarizer(image_bytes) if image_bytes else ""
 
756
  narrative = "\n".join(parts)
757
 
758
  combined_for_drugs = " ".join(x for x in [narrative, vision_summary] if x)
759
+ dpd_context = tool_lookup_drug_products(combined_for_drugs) if combined_for_drugs else "No medication context."
 
 
 
 
760
  recalls_context = tool_get_recent_recalls_snippet()
761
  wait_awareness_context = tool_get_wait_times_awareness()
762
 
763
+ if postal_code:
764
+ region_context = tool_region_context(postal_code)
765
+ facilities_context = tool_find_nearby_clinics(postal_code)
766
+ wait_links_context = tool_search_wait_times_google(postal_code)
767
+ hqontario_context = tool_hqontario_context(postal_code)
768
+ else:
769
+ region_context = "No postal code provided. Use Canada-wide guidance."
770
+ facilities_context = "No postal code; cannot list nearby facilities."
771
+ wait_links_context = "No postal code; no local wait-time links."
772
+ hqontario_context = ""
 
 
 
 
 
 
 
 
 
773
 
774
  final_answer = call_reasoning_agent(
775
  narrative=narrative,
 
779
  recalls_context=recalls_context,
780
  wait_awareness_context=wait_awareness_context,
781
  region_context=region_context,
782
+ facilities_context=facilities_context,
783
+ wait_links_context=wait_links_context,
784
  hqontario_context=hqontario_context,
785
  )
786