Seth0330 commited on
Commit
ef2c989
·
verified ·
1 Parent(s): 6a1de54

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +290 -217
app.py CHANGED
@@ -8,16 +8,12 @@ import streamlit as st
8
  from PIL import Image
9
  from openai import OpenAI
10
 
11
- from langchain_openai import ChatOpenAI
12
- from langchain_core.tools import tool
13
- from langchain.agents import initialize_agent, AgentType
14
-
15
  # =========================
16
- # STREAMLIT + OPENAI CONFIG
17
  # =========================
18
 
19
  st.set_page_config(
20
- page_title="CareCall AI (Canada) - Agentic Health Info",
21
  page_icon="🩺",
22
  layout="centered"
23
  )
@@ -27,27 +23,27 @@ if not OPENAI_API_KEY:
27
  st.warning("Set OPENAI_API_KEY in your Space secrets to enable AI features.")
28
  client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
29
 
30
- # OpenAI models
31
- VISION_MODEL = "gpt-4.1-mini" # vision-capable
32
- REASONING_MODEL = "gpt-4.1-mini" # main agent model
33
- ASR_MODEL = "whisper-1" # audio transcription
34
 
35
- # Health Canada Drug Product Database
36
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
37
-
38
- # Recalls & Safety Alerts (public JSON feed; can override with env)
39
  RECALLS_FEED_URL = os.getenv(
40
  "RECALLS_FEED_URL",
41
  "https://recalls-rappels.canada.ca/static-data/items-en.json"
42
  )
43
-
44
- # CIHI wait-times info (context link, not live triage)
45
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
46
 
47
- # Optional: your own normalized wait-time API
48
  WAIT_TIMES_API_BASE = os.getenv("WAIT_TIMES_API_BASE", "").rstrip("/")
49
 
50
- # Postal prefix -> province map
 
 
 
 
51
  POSTAL_PREFIX_TO_PROVINCE = {
52
  "A": "Newfoundland and Labrador",
53
  "B": "Nova Scotia",
@@ -69,7 +65,7 @@ POSTAL_PREFIX_TO_PROVINCE = {
69
  "Y": "Yukon",
70
  }
71
 
72
- USER_AGENT = "carecall-ai-langchain-demo/1.0"
73
 
74
 
75
  # =========================
@@ -93,10 +89,10 @@ def safe_get_json(url, params=None, method="GET", data=None, timeout=8):
93
  def shorten(text, max_chars=260):
94
  if not text:
95
  return ""
96
- text = text.strip()
97
- if len(text) <= max_chars:
98
- return text
99
- return text[: max_chars - 3].rstrip() + "..."
100
 
101
 
102
  def haversine_km(lat1, lon1, lat2, lon2):
@@ -143,20 +139,85 @@ def geocode_postal(postal_code: str):
143
 
144
 
145
  # =========================
146
- # LANGCHAIN TOOLS
147
  # =========================
148
 
149
- @tool
150
- def get_region_context(postal_code: str) -> str:
 
 
 
 
 
 
151
  """
152
- Given a Canadian postal code, return high-level provincial context:
153
- - inferred province (if possible)
154
- - Telehealth / nurse-line style advice
155
- - pointer to wait-time info sources (CIHI/provincial).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  province = infer_province_from_postal(postal_code)
158
  if not postal_code:
159
- return "No postal code provided. Use general Canada-wide guidance and mention provincial resources."
160
 
161
  if not province:
162
  return (
@@ -165,12 +226,12 @@ def get_region_context(postal_code: str) -> str:
165
  )
166
 
167
  telehealth_note = (
168
- f"In {province}, advise the user to use the official provincial Telehealth or 811-style nurse line "
169
  "for real-time nurse assessment."
170
  )
171
  wait_note = (
172
- "For non-urgent issues, they may check provincial/health authority resources and CIHI indicators "
173
- f"for access and wait-time context (not real-time). Ref: {WAIT_TIMES_INFO_URL}"
174
  )
175
 
176
  return (
@@ -180,24 +241,18 @@ def get_region_context(postal_code: str) -> str:
180
  )
181
 
182
 
183
- @tool
184
- def get_nearby_facilities(postal_code: str) -> str:
185
- """
186
- Use open map data (OSM/Overpass) to list a few nearby clinics/hospitals/doctors.
187
- If WAIT_TIMES_API_BASE is set, also attach estimated wait times when available.
188
- """
189
  coords = geocode_postal(postal_code)
190
  if not coords:
191
  return (
192
- "Could not geocode postal code. Suggest the user search for walk-in clinics, urgent care, or hospitals "
193
- "near them using maps and official provincial tools."
194
  )
195
 
196
  lat, lon = coords
197
-
198
  overpass_url = "https://overpass-api.de/api/interpreter"
199
  query = f"""
200
- [out:json][timeout:10];
201
  (
202
  node["amenity"="clinic"](around:7000,{lat},{lon});
203
  node["amenity"="hospital"](around:7000,{lat},{lon});
@@ -206,11 +261,12 @@ def get_nearby_facilities(postal_code: str) -> str:
206
  );
207
  out center 12;
208
  """
 
209
  data = safe_get_json(overpass_url, method="POST", data=query)
210
  if not data or "elements" not in data:
211
  return (
212
- "Nearby facility lookup unavailable from open map data. "
213
- "Suggest using map search (e.g., 'walk-in clinic near me') and provincial find-a-clinic tools."
214
  )
215
 
216
  facilities = []
@@ -219,26 +275,22 @@ def get_nearby_facilities(postal_code: str) -> str:
219
  name = tags.get("name")
220
  if not name:
221
  continue
 
222
  amenity = tags.get("amenity", "clinic")
223
  lat2 = el.get("lat") or (el.get("center") or {}).get("lat")
224
  lon2 = el.get("lon") or (el.get("center") or {}).get("lon")
225
  if lat2 is None or lon2 is None:
226
  continue
 
227
  dist = haversine_km(lat, lon, float(lat2), float(lon2))
 
228
  addr_parts = []
229
  for k in ("addr:street", "addr:city"):
230
  if tags.get(k):
231
  addr_parts.append(tags[k])
232
  addr = ", ".join(addr_parts)
233
 
234
- line = {
235
- "name": name,
236
- "type": amenity,
237
- "distance_km": dist,
238
- "address": addr,
239
- "wait": None,
240
- }
241
-
242
  if WAIT_TIMES_API_BASE:
243
  jt = safe_get_json(
244
  f"{WAIT_TIMES_API_BASE}/wait",
@@ -246,18 +298,26 @@ def get_nearby_facilities(postal_code: str) -> str:
246
  timeout=4
247
  )
248
  if jt and isinstance(jt, dict) and jt.get("estimated_minutes") is not None:
249
- line["wait"] = int(jt["estimated_minutes"])
250
-
251
- facilities.append(line)
 
 
 
 
 
 
 
 
252
 
253
  facilities = sorted(facilities, key=lambda x: x["distance_km"])[:8]
254
  if not facilities:
255
  return (
256
- "No nearby facilities found in open map data. "
257
  "Suggest using maps and provincial health websites to locate clinics and ERs."
258
  )
259
 
260
- out_lines = ["Nearby health facilities (approximate from open data):"]
261
  for f in facilities:
262
  s = f"- {f['name']} ({f['type']}, ~{f['distance_km']:.1f} km"
263
  if f["address"]:
@@ -265,23 +325,18 @@ def get_nearby_facilities(postal_code: str) -> str:
265
  if f["wait"] is not None:
266
  s += f", est. wait ~{f['wait']} min"
267
  s += ")"
268
- out_lines.append(s)
269
 
270
  if not WAIT_TIMES_API_BASE:
271
- out_lines.append(
272
- "Note: No live wait-time API configured; estimated waits are not shown. "
273
  "You can integrate an official/custom wait-time API via WAIT_TIMES_API_BASE."
274
  )
275
 
276
- return "\n".join(out_lines)
277
 
278
 
279
- @tool
280
- def get_drug_info(text: str) -> str:
281
- """
282
- Extracts possible medication/product names from the text and checks Health Canada's DPD.
283
- Returns a high-level note; no dosing or personal advice.
284
- """
285
  if not text:
286
  return "No text provided for medication lookup."
287
 
@@ -292,9 +347,9 @@ def get_drug_info(text: str) -> str:
292
  tokens.append(clean)
293
  candidates = sorted(set(tokens))[:10]
294
  if not candidates:
295
- return "No likely medication names detected that require Drug Product Database lookup."
296
 
297
- found_lines = []
298
  for name in candidates[:5]:
299
  params = {"lang": "en", "type": "json", "brandname": name}
300
  data = safe_get_json(DPD_BASE_URL, params=params)
@@ -306,28 +361,24 @@ def get_drug_info(text: str) -> str:
306
  if b:
307
  unique.add(b)
308
  if unique:
309
- found_lines.append(
310
  f"- Health Canada DPD has entries related to '{name}' "
311
  f"(e.g., {', '.join(sorted(unique))})."
312
  )
313
 
314
- if not found_lines:
315
  return (
316
- "No strong DPD matches found for detected terms. "
317
  "Users should confirm medication details directly via the official Health Canada DPD."
318
  )
319
 
320
- found_lines.append(
321
- "For definitive information on any medication, refer to the official Health Canada Drug Product Database."
322
  )
323
- return "\n".join(found_lines)
324
 
325
 
326
- @tool
327
- def get_recalls(_: str) -> str:
328
- """
329
- Returns a snapshot of recent recalls & safety alerts from the public JSON feed.
330
- """
331
  data = safe_get_json(RECALLS_FEED_URL)
332
  items = []
333
 
@@ -353,24 +404,21 @@ def get_recalls(_: str) -> str:
353
  date = item.get("date_published") or item.get("date") or ""
354
  category = item.get("category") or item.get("type") or ""
355
  lines.append(f"- {shorten(title)} ({category}, {date})")
 
356
  lines.append(
357
  "For details or specific products, visit the official Recalls and Safety Alerts portal."
358
  )
359
  return "\n".join(lines)
360
 
361
 
362
- @tool
363
- def get_wait_times_awareness(_: str) -> str:
364
- """
365
- Returns general guidance about using official wait-time dashboards (CIHI / provinces).
366
- """
367
  return textwrap.dedent(f"""
368
  Use open-data wait-time information conceptually:
369
- - CIHI and some provinces publish average wait-time dashboards for ER and procedures.
370
- - These are NOT real-time triage tools.
371
  Guidance:
372
  - For mild issues: clinic/urgent care may be appropriate; check provincial tools if available.
373
- - For red flags or severe symptoms: go directly to ER or call 911.
374
  General reference: {WAIT_TIMES_INFO_URL}
375
  """).strip()
376
 
@@ -389,8 +437,8 @@ def call_vision_summarizer(image_bytes: bytes) -> str:
389
  "You are a cautious Canadian health-information assistant.\n"
390
  "Describe ONLY what is visible in neutral terms.\n"
391
  "- For skin/nails: describe colour, dryness, cracks, swelling, etc. Do NOT name diseases.\n"
392
- "- For medication labels: read visible drug names/strengths if clear.\n"
393
- "- For letters/reports: say what type of document it appears to be; do not copy identifiers.\n"
394
  "- If unclear: say it is not medically interpretable.\n"
395
  "Never diagnose or prescribe. Keep to ~80-100 words."
396
  )
@@ -426,130 +474,117 @@ def call_asr(audio_file) -> str:
426
  model=ASR_MODEL,
427
  file=bio
428
  )
429
- text = getattr(transcript, "text", None)
430
- if isinstance(text, str):
431
- return text.strip()
432
  return str(transcript)
433
  except Exception as e:
434
  return f"(Transcription unavailable: {e})"
435
 
436
 
437
  # =========================
438
- # LANGCHAIN AGENT (initialize_agent)
439
  # =========================
440
 
441
- def build_agent():
442
- llm = ChatOpenAI(
443
- model=REASONING_MODEL,
444
- temperature=0.25,
445
- openai_api_key=OPENAI_API_KEY,
446
- )
447
-
448
- tools = [
449
- get_region_context,
450
- get_nearby_facilities,
451
- get_drug_info,
452
- get_recalls,
453
- get_wait_times_awareness,
454
- ]
455
-
456
- agent = initialize_agent(
457
- tools=tools,
458
- llm=llm,
459
- agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
460
- verbose=False,
461
- handle_parsing_errors=True,
462
- )
463
- return agent
464
-
465
-
466
- def run_agent(narrative: str, vision_summary: str, postal_code: str) -> str:
467
- agent = build_agent()
468
 
469
- user_input = f"""
470
  You are CareCall AI, an agentic Canadian health information assistant.
471
 
472
- You have tools:
473
- - get_region_context(postal_code)
474
- - get_nearby_facilities(postal_code)
475
- - get_drug_info(text)
476
- - get_recalls(_)
477
- - get_wait_times_awareness(_)
 
 
 
 
478
 
479
  Rules:
480
- - NEVER give a diagnosis.
481
- - NEVER prescribe or give dosing.
482
- - NEVER fabricate real-time wait times.
483
- - Stay under about 350 words.
484
- - Use tools to ground your answer (especially for postal_code, facilities, medications, recalls, wait-time awareness).
485
-
486
- PRIMARY PATHWAY LOGIC:
487
- 1. If any strong red-flag symptoms are present (chest pain, trouble breathing, stroke signs,
488
- heavy bleeding, major trauma, high fever with stiff neck, severe abdominal pain,
489
- rapidly spreading infection with systemic symptoms, suicidal thoughts, etc.):
490
- -> Primary Recommended Pathway = "Go to Emergency / Call 911 now".
491
- 2. If NO red flags and the concern is localized, mild/moderate (e.g., minor rash, toe pain,
492
- dryness, irritation, chipped/broken nail) and no major risk factors clearly stated:
493
- -> Primary Recommended Pathway = "Home care likely reasonable (monitor closely)" + pharmacist/clinic only if not improving.
494
- 3. If symptoms are persistent, unclear, moderate, or risk factors are present (e.g., diabetes, immune issues),
495
- but not clearly emergency:
496
- -> Primary Recommended Pathway = "Pharmacist / Walk-in clinic / Family doctor recommended"
497
- or "Call provincial Telehealth / nurse line for guidance".
498
-
499
- CONTEXT:
500
- - User narrative (typed + transcribed):
501
- {narrative or "(none provided)"}
502
 
503
- - Vision summary:
504
- {vision_summary or "(no image or no usable visual details)"}
505
 
506
- - Postal code:
507
- {postal_code or "(not provided)"}
 
 
 
 
 
 
 
508
 
509
- If useful:
510
- - Call get_drug_info with the narrative + vision summary text.
511
- - If postal code is present, call get_region_context and get_nearby_facilities.
512
- - You may also call get_recalls and get_wait_times_awareness for extra safety/awareness.
513
 
514
- OUTPUT (exact sections, in this order):
 
515
 
516
- 1. **Primary Recommended Pathway**
517
- - One bullet with exactly one of:
518
- * "Home care likely reasonable (monitor closely)"
519
- * "Pharmacist / Walk-in clinic / Family doctor recommended"
520
- * "Call provincial Telehealth / nurse line for guidance"
521
- * "Go to Emergency / Call 911 now"
522
- - One short sentence explaining why.
523
 
524
- 2. **What this might relate to (general categories)**
525
- - 3–5 bullets, broad categories only. No firm disease labels.
526
 
527
- 3. **Home Care (if appropriate)**
528
- - If home care is primary or reasonable initial option:
529
- 3–6 simple steps (cleaning, keep dry, avoid friction, protective padding,
530
- OTC pain relief per label, monitor).
531
- - Otherwise, say home care alone is not advised.
532
 
533
- 4. **When to see a Pharmacist / Clinic / Telehealth**
534
- - 3–6 concrete triggers (worsening pain, spreading redness, pus, difficulty walking,
535
- recurring issue, chronic illnesses, medication questions).
536
 
537
- 5. **When this is an Emergency (ER/911)**
538
- - 3–8 clear red-flag bullets.
539
 
540
- 6. **Local & Official Resources**
541
- - 2–6 bullets using outputs from region/facility/wait-time tools,
542
- and mentioning Health Canada, PHAC, CIHI, and provincial sites.
543
 
544
- 7. **Important Disclaimer**
545
- - 2–4 lines: informational only, not medical care, not a substitute for professionals or emergency services.
546
 
547
- Now follow this format and use tools as needed.
 
 
 
548
  """
549
 
550
- # ZERO_SHOT_REACT_DESCRIPTION agents use .run with a single string
551
- result = agent.run(user_input)
552
- return result
 
 
 
 
 
 
 
 
 
 
 
 
553
 
554
 
555
  # =========================
@@ -557,43 +592,47 @@ Now follow this format and use tools as needed.
557
  # =========================
558
 
559
  st.title("CareCall AI (Canada)")
560
- st.caption("LangChain agent • Vision + Voice + Postal-aware • Non-diagnostic health info")
561
 
562
  st.markdown(
563
  """
564
- This demo:
565
- - Lets a user upload a **photo**, a **voice note**, and/or a **typed description**.
566
- - Optionally uses their **Canadian postal code**.
567
- - An agentic AI then:
568
- - analyzes the image safely,
569
- - transcribes audio,
570
- - calls tools (Health Canada DPD, Recalls, open-map facilities, regional context),
571
- - and returns a structured, cautious recommendation:
572
- - clear **Primary Recommended Pathway** (home vs clinic vs Telehealth vs ER),
573
- - simple **home-care tips** when appropriate,
574
- - **when to escalate**, and
575
- - **nearby facilities & official resources**.
 
 
 
 
 
576
  """
577
  )
578
 
579
  if not OPENAI_API_KEY:
580
  st.stop()
581
 
582
- # 1) Postal code
583
  st.markdown("### 1. Postal code (optional, Canada only)")
584
  postal_code = st.text_input(
585
- "Canadian postal code (e.g., M5V 2T6). Leave blank for general Canada-wide guidance.",
586
  max_chars=7,
587
  )
588
 
589
- # 2) Image upload
590
  st.markdown("### 2. Upload an image (optional)")
591
  image_file = st.file_uploader(
592
  "Upload a photo (JPG/PNG). Avoid highly identifying or explicit content.",
593
  type=["jpg", "jpeg", "png"],
594
  accept_multiple_files=False,
595
  )
596
-
597
  image_bytes = None
598
  if image_file is not None:
599
  try:
@@ -606,7 +645,7 @@ if image_file is not None:
606
  st.warning(f"Could not read that image: {e}")
607
  image_bytes = None
608
 
609
- # 3) Audio upload
610
  st.markdown("### 3. Add a voice note (optional)")
611
  audio_file = st.file_uploader(
612
  "Upload a short voice note (wav/mp3/m4a).",
@@ -614,7 +653,7 @@ audio_file = st.file_uploader(
614
  accept_multiple_files=False,
615
  )
616
 
617
- # 4) Text description
618
  st.markdown("### 4. Or type a short description")
619
  user_text = st.text_area(
620
  "Describe what's going on:",
@@ -622,29 +661,63 @@ user_text = st.text_area(
622
  height=120,
623
  )
624
 
625
- # Run agent
626
  if st.button("Run CareCall Agent"):
627
- with st.spinner("Running vision + voice + tools + agentic reasoning..."):
628
 
629
  # Vision summary
630
  vision_summary = call_vision_summarizer(image_bytes) if image_bytes else ""
631
 
632
- # Transcription
633
  voice_text = call_asr(audio_file) if audio_file is not None else ""
634
 
635
- # Combine narrative text for tools
636
- narrative_parts = []
637
  if user_text.strip():
638
- narrative_parts.append("Typed: " + user_text.strip())
639
  if voice_text.strip():
640
- narrative_parts.append("Voice (transcribed): " + voice_text.strip())
641
- narrative = "\n".join(narrative_parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
 
643
- response = run_agent(
644
  narrative=narrative,
645
  vision_summary=vision_summary,
646
  postal_code=postal_code or "",
 
 
 
 
 
 
647
  )
648
 
649
  st.markdown("### CareCall AI Summary (Informational Only)")
650
- st.write(response)
 
8
  from PIL import Image
9
  from openai import OpenAI
10
 
 
 
 
 
11
  # =========================
12
+ # CONFIG
13
  # =========================
14
 
15
  st.set_page_config(
16
+ page_title="CareCall AI (Canada)",
17
  page_icon="🩺",
18
  layout="centered"
19
  )
 
23
  st.warning("Set OPENAI_API_KEY in your Space secrets to enable AI features.")
24
  client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
25
 
26
+ # Models
27
+ VISION_MODEL = "gpt-4.1-mini" # image-capable OpenAI model
28
+ REASONING_MODEL = "gpt-4.1-mini" # reasoning model
29
+ ASR_MODEL = "whisper-1" # transcription model
30
 
31
+ # External data sources
32
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
 
 
33
  RECALLS_FEED_URL = os.getenv(
34
  "RECALLS_FEED_URL",
35
  "https://recalls-rappels.canada.ca/static-data/items-en.json"
36
  )
 
 
37
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
38
 
39
+ # Optional: your own official/partner wait time API
40
  WAIT_TIMES_API_BASE = os.getenv("WAIT_TIMES_API_BASE", "").rstrip("/")
41
 
42
+ # Google Custom Search (this is your “Google search”)
43
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") # from Google Cloud
44
+ GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID") # your Custom Search Engine ID (cx)
45
+
46
+ # Postal code prefix -> province
47
  POSTAL_PREFIX_TO_PROVINCE = {
48
  "A": "Newfoundland and Labrador",
49
  "B": "Nova Scotia",
 
65
  "Y": "Yukon",
66
  }
67
 
68
+ USER_AGENT = "carecall-ai-googlecse/1.0"
69
 
70
 
71
  # =========================
 
89
  def shorten(text, max_chars=260):
90
  if not text:
91
  return ""
92
+ t = text.strip()
93
+ if len(t) <= max_chars:
94
+ return t
95
+ return t[: max_chars - 3].rstrip() + "..."
96
 
97
 
98
  def haversine_km(lat1, lon1, lat2, lon2):
 
139
 
140
 
141
  # =========================
142
+ # GOOGLE CUSTOM SEARCH TOOL
143
  # =========================
144
 
145
+ def google_cse_search(query: str, num: int = 5):
146
+ """
147
+ Uses Google Custom Search JSON API (CSE) to run queries.
148
+ This is the ToS-compliant way to 'use Google search'.
149
+
150
+ Requires:
151
+ - GOOGLE_API_KEY
152
+ - GOOGLE_CSE_ID (cx)
153
  """
154
+ if not GOOGLE_API_KEY or not GOOGLE_CSE_ID:
155
+ return None
156
+
157
+ url = "https://www.googleapis.com/customsearch/v1"
158
+ params = {
159
+ "key": GOOGLE_API_KEY,
160
+ "cx": GOOGLE_CSE_ID,
161
+ "q": query,
162
+ "num": max(1, min(num, 10)),
163
+ }
164
+ return safe_get_json(url, params=params)
165
+
166
+
167
+ def tool_search_wait_times_google(postal_code: str) -> str:
168
+ """
169
+ Use Google Custom Search to find official pages about:
170
+ - nearby clinics/hospitals,
171
+ - ER / urgent care wait times or 'current status' pages.
172
+
173
+ You should configure your CSE (cx) to prioritize:
174
+ gov / health authority / hospital domains in Canada.
175
  """
176
+ if not postal_code:
177
+ return "Postal code missing; Google-based search not attempted."
178
+
179
+ data = google_cse_search(
180
+ query=(
181
+ f"walk-in clinic OR hospital OR emergency room wait times near {postal_code} Canada"
182
+ ),
183
+ num=6,
184
+ )
185
+
186
+ if data is None:
187
+ return (
188
+ "Google Custom Search is not configured (set GOOGLE_API_KEY and GOOGLE_CSE_ID). "
189
+ "Rely on OSM + provincial/hospital websites."
190
+ )
191
+
192
+ items = data.get("items", [])
193
+ if not items:
194
+ return (
195
+ "Google Custom Search did not return clear official wait-time pages. "
196
+ "User should check provincial or hospital websites directly."
197
+ )
198
+
199
+ lines = ["Google search results for clinics / hospitals / wait-time info (review directly):"]
200
+ for it in items[:6]:
201
+ title = it.get("title", "Result")
202
+ link = it.get("link", "")
203
+ snippet = shorten(it.get("snippet", "") or it.get("htmlSnippet", ""), 180)
204
+ lines.append(f"- {title} — {link} — {snippet}")
205
+
206
+ lines.append(
207
+ "These links may include official clinic/hospital finders or wait-time dashboards. "
208
+ "Users should always verify details on the source site."
209
+ )
210
+ return "\n".join(lines)
211
+
212
+
213
+ # =========================
214
+ # OTHER TOOL-LIKE FUNCTIONS
215
+ # =========================
216
+
217
+ def tool_region_context(postal_code: str) -> str:
218
  province = infer_province_from_postal(postal_code)
219
  if not postal_code:
220
+ return "No postal code provided. Use Canada-wide guidance and mention provincial resources."
221
 
222
  if not province:
223
  return (
 
226
  )
227
 
228
  telehealth_note = (
229
+ f"In {province}, advise use of the official provincial Telehealth or 811-style nurse line "
230
  "for real-time nurse assessment."
231
  )
232
  wait_note = (
233
+ "For non-urgent issues, users can check provincial/health authority resources and CIHI indicators "
234
+ f"for access/wait-time context (not real-time). Ref: {WAIT_TIMES_INFO_URL}"
235
  )
236
 
237
  return (
 
241
  )
242
 
243
 
244
+ def tool_find_nearby_clinics_osm(postal_code: str) -> str:
 
 
 
 
 
245
  coords = geocode_postal(postal_code)
246
  if not coords:
247
  return (
248
+ "Could not geocode the postal code. Suggest searching for walk-in clinics, urgent care, or hospitals "
249
+ "near the user using maps and official provincial tools."
250
  )
251
 
252
  lat, lon = coords
 
253
  overpass_url = "https://overpass-api.de/api/interpreter"
254
  query = f"""
255
+ [out:json][timeout:8];
256
  (
257
  node["amenity"="clinic"](around:7000,{lat},{lon});
258
  node["amenity"="hospital"](around:7000,{lat},{lon});
 
261
  );
262
  out center 12;
263
  """
264
+
265
  data = safe_get_json(overpass_url, method="POST", data=query)
266
  if not data or "elements" not in data:
267
  return (
268
+ "Nearby facility lookup via open map data is unavailable. "
269
+ "Suggest using maps and provincial find-a-clinic tools."
270
  )
271
 
272
  facilities = []
 
275
  name = tags.get("name")
276
  if not name:
277
  continue
278
+
279
  amenity = tags.get("amenity", "clinic")
280
  lat2 = el.get("lat") or (el.get("center") or {}).get("lat")
281
  lon2 = el.get("lon") or (el.get("center") or {}).get("lon")
282
  if lat2 is None or lon2 is None:
283
  continue
284
+
285
  dist = haversine_km(lat, lon, float(lat2), float(lon2))
286
+
287
  addr_parts = []
288
  for k in ("addr:street", "addr:city"):
289
  if tags.get(k):
290
  addr_parts.append(tags[k])
291
  addr = ", ".join(addr_parts)
292
 
293
+ wait = None
 
 
 
 
 
 
 
294
  if WAIT_TIMES_API_BASE:
295
  jt = safe_get_json(
296
  f"{WAIT_TIMES_API_BASE}/wait",
 
298
  timeout=4
299
  )
300
  if jt and isinstance(jt, dict) and jt.get("estimated_minutes") is not None:
301
+ wait = int(jt["estimated_minutes"])
302
+
303
+ facilities.append(
304
+ {
305
+ "name": name,
306
+ "type": amenity,
307
+ "distance_km": dist,
308
+ "address": addr,
309
+ "wait": wait,
310
+ }
311
+ )
312
 
313
  facilities = sorted(facilities, key=lambda x: x["distance_km"])[:8]
314
  if not facilities:
315
  return (
316
+ "No nearby facilities found via open map data. "
317
  "Suggest using maps and provincial health websites to locate clinics and ERs."
318
  )
319
 
320
+ lines = ["Nearby health facilities (approximate from open data):"]
321
  for f in facilities:
322
  s = f"- {f['name']} ({f['type']}, ~{f['distance_km']:.1f} km"
323
  if f["address"]:
 
325
  if f["wait"] is not None:
326
  s += f", est. wait ~{f['wait']} min"
327
  s += ")"
328
+ lines.append(s)
329
 
330
  if not WAIT_TIMES_API_BASE:
331
+ lines.append(
332
+ "Note: No live wait-time API configured; estimated waits (if any) are not shown. "
333
  "You can integrate an official/custom wait-time API via WAIT_TIMES_API_BASE."
334
  )
335
 
336
+ return "\n".join(lines)
337
 
338
 
339
+ def tool_lookup_drug_products(text: str) -> str:
 
 
 
 
 
340
  if not text:
341
  return "No text provided for medication lookup."
342
 
 
347
  tokens.append(clean)
348
  candidates = sorted(set(tokens))[:10]
349
  if not candidates:
350
+ return "No likely medication names detected for Drug Product Database lookup."
351
 
352
+ found = []
353
  for name in candidates[:5]:
354
  params = {"lang": "en", "type": "json", "brandname": name}
355
  data = safe_get_json(DPD_BASE_URL, params=params)
 
361
  if b:
362
  unique.add(b)
363
  if unique:
364
+ found.append(
365
  f"- Health Canada DPD has entries related to '{name}' "
366
  f"(e.g., {', '.join(sorted(unique))})."
367
  )
368
 
369
+ if not found:
370
  return (
371
+ "No strong matches found in the Drug Product Database for detected terms. "
372
  "Users should confirm medication details directly via the official Health Canada DPD."
373
  )
374
 
375
+ found.append(
376
+ "For definitive medication information, users must consult the official Health Canada Drug Product Database."
377
  )
378
+ return "\n".join(found)
379
 
380
 
381
+ def tool_get_recent_recalls_snippet() -> str:
 
 
 
 
382
  data = safe_get_json(RECALLS_FEED_URL)
383
  items = []
384
 
 
404
  date = item.get("date_published") or item.get("date") or ""
405
  category = item.get("category") or item.get("type") or ""
406
  lines.append(f"- {shorten(title)} ({category}, {date})")
407
+
408
  lines.append(
409
  "For details or specific products, visit the official Recalls and Safety Alerts portal."
410
  )
411
  return "\n".join(lines)
412
 
413
 
414
+ def tool_get_wait_times_awareness() -> str:
 
 
 
 
415
  return textwrap.dedent(f"""
416
  Use open-data wait-time information conceptually:
417
+ - CIHI and some provinces publish average wait-time dashboards.
418
+ - These are NOT real-time triage tools or guarantees.
419
  Guidance:
420
  - For mild issues: clinic/urgent care may be appropriate; check provincial tools if available.
421
+ - For red-flag or severe symptoms: go directly to ER or call 911.
422
  General reference: {WAIT_TIMES_INFO_URL}
423
  """).strip()
424
 
 
437
  "You are a cautious Canadian health-information assistant.\n"
438
  "Describe ONLY what is visible in neutral terms.\n"
439
  "- For skin/nails: describe colour, dryness, cracks, swelling, etc. Do NOT name diseases.\n"
440
+ "- For medication labels: mention visible drug names/strengths if legible.\n"
441
+ "- For letters/reports: say what type of document it appears to be; avoid identifiers.\n"
442
  "- If unclear: say it is not medically interpretable.\n"
443
  "Never diagnose or prescribe. Keep to ~80-100 words."
444
  )
 
474
  model=ASR_MODEL,
475
  file=bio
476
  )
477
+ txt = getattr(transcript, "text", None)
478
+ if isinstance(txt, str):
479
+ return txt.strip()
480
  return str(transcript)
481
  except Exception as e:
482
  return f"(Transcription unavailable: {e})"
483
 
484
 
485
  # =========================
486
+ # REASONING (FINAL ANSWER)
487
  # =========================
488
 
489
+ def call_reasoning_agent(
490
+ narrative: str,
491
+ vision_summary: str,
492
+ postal_code: str,
493
+ dpd_context: str,
494
+ recalls_context: str,
495
+ wait_awareness_context: str,
496
+ region_context: str,
497
+ nearby_osm_context: str,
498
+ google_search_context: str,
499
+ ) -> str:
500
+ if not client:
501
+ return (
502
+ "AI backend not configured. With a valid API key, this agent would combine your inputs "
503
+ "into cautious, structured guidance."
504
+ )
 
 
 
 
 
 
 
 
 
 
 
505
 
506
+ system_prompt = """
507
  You are CareCall AI, an agentic Canadian health information assistant.
508
 
509
+ You receive:
510
+ - User narrative,
511
+ - Vision summary,
512
+ - Postal code,
513
+ - Drug Product Database context,
514
+ - Recalls & Safety Alerts snapshot,
515
+ - Wait-time awareness notes,
516
+ - Region context (province + Telehealth),
517
+ - OSM-based nearby facilities,
518
+ - Google Custom Search context with official pages.
519
 
520
  Rules:
521
+ - Do NOT provide a diagnosis.
522
+ - Do NOT prescribe or specify dosing.
523
+ - Do NOT invent real-time wait times or guarantees.
524
+ - Use ONLY the context given (no extra browsing).
525
+ - Keep response under ~350 words.
526
+ - Be clear, neutral, and practical.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
+ Primary Pathway Logic:
529
+ (identical as previously described; choose Home vs Clinic vs Telehealth vs ER based on severity and red flags.)
530
 
531
+ Output sections (in this exact order and headings):
532
+ 1. **Primary Recommended Pathway**
533
+ 2. **What this might relate to (general categories)**
534
+ 3. **Home Care (if appropriate)**
535
+ 4. **When to see a Pharmacist / Clinic / Telehealth**
536
+ 5. **When this is an Emergency (ER/911)**
537
+ 6. **Local & Official Resources**
538
+ 7. **Important Disclaimer**
539
+ """
540
 
541
+ user_prompt = f"""
542
+ INPUT CONTEXT
 
 
543
 
544
+ User narrative:
545
+ {narrative or "(none provided)"}
546
 
547
+ Vision summary:
548
+ {vision_summary or "(no image or no usable visual details)"}
 
 
 
 
 
549
 
550
+ Postal code: {postal_code or "(not provided)"}
 
551
 
552
+ Drug Product Database context:
553
+ {dpd_context}
 
 
 
554
 
555
+ Recalls & Safety Alerts context:
556
+ {recalls_context}
 
557
 
558
+ Wait-time awareness (general):
559
+ {wait_awareness_context}
560
 
561
+ Region context:
562
+ {region_context}
 
563
 
564
+ Nearby facilities (OSM-based):
565
+ {nearby_osm_context}
566
 
567
+ Google search context (clinics/hospitals/wait-times):
568
+ {google_search_context}
569
+
570
+ Now generate the structured response following the system instructions.
571
  """
572
 
573
+ try:
574
+ resp = client.chat.completions.create(
575
+ model=REASONING_MODEL,
576
+ messages=[
577
+ {"role": "system", "content": system_prompt},
578
+ {"role": "user", "content": user_prompt},
579
+ ],
580
+ temperature=0.25,
581
+ )
582
+ return resp.choices[0].message.content.strip()
583
+ except Exception as e:
584
+ return (
585
+ f"(Reasoning agent unavailable: {e})\n\n"
586
+ "Please contact a licensed provider, Telehealth, or emergency services as appropriate."
587
+ )
588
 
589
 
590
  # =========================
 
592
  # =========================
593
 
594
  st.title("CareCall AI (Canada)")
595
+ st.caption("Vision + Voice + Postal + Google CSE • Non-diagnostic agentic helper")
596
 
597
  st.markdown(
598
  """
599
+ This app:
600
+ - Accepts an image, voice note, and/or typed description,
601
+ - (Optionally) uses your **Canadian postal code**,
602
+ - Uses:
603
+ - OpenAI vision + Whisper,
604
+ - Health Canada Drug Product DB,
605
+ - Recalls & Safety Alerts,
606
+ - CIHI wait-time awareness,
607
+ - OpenStreetMap for nearby clinics/hospitals,
608
+ - **Google Custom Search (CSE)** for official pages (if configured),
609
+ - And returns:
610
+ - a clear **Primary Recommended Pathway** (including *home care likely reasonable* when safe),
611
+ - simple **home-care steps**,
612
+ - when to see **pharmacist/clinic/Telehealth**,
613
+ - when it’s **ER/911**,
614
+ - local & official resource pointers,
615
+ - a strong disclaimer.
616
  """
617
  )
618
 
619
  if not OPENAI_API_KEY:
620
  st.stop()
621
 
622
+ # Postal code
623
  st.markdown("### 1. Postal code (optional, Canada only)")
624
  postal_code = st.text_input(
625
+ "Canadian postal code (e.g., M5V 2T6). Leave blank for general guidance.",
626
  max_chars=7,
627
  )
628
 
629
+ # Image upload
630
  st.markdown("### 2. Upload an image (optional)")
631
  image_file = st.file_uploader(
632
  "Upload a photo (JPG/PNG). Avoid highly identifying or explicit content.",
633
  type=["jpg", "jpeg", "png"],
634
  accept_multiple_files=False,
635
  )
 
636
  image_bytes = None
637
  if image_file is not None:
638
  try:
 
645
  st.warning(f"Could not read that image: {e}")
646
  image_bytes = None
647
 
648
+ # Audio upload
649
  st.markdown("### 3. Add a voice note (optional)")
650
  audio_file = st.file_uploader(
651
  "Upload a short voice note (wav/mp3/m4a).",
 
653
  accept_multiple_files=False,
654
  )
655
 
656
+ # Text description
657
  st.markdown("### 4. Or type a short description")
658
  user_text = st.text_area(
659
  "Describe what's going on:",
 
661
  height=120,
662
  )
663
 
664
+ # Run
665
  if st.button("Run CareCall Agent"):
666
+ with st.spinner("Analyzing inputs and generating guidance..."):
667
 
668
  # Vision summary
669
  vision_summary = call_vision_summarizer(image_bytes) if image_bytes else ""
670
 
671
+ # Voice transcription
672
  voice_text = call_asr(audio_file) if audio_file is not None else ""
673
 
674
+ # Combined narrative
675
+ parts = []
676
  if user_text.strip():
677
+ parts.append("Typed: " + user_text.strip())
678
  if voice_text.strip():
679
+ parts.append("Voice (transcribed): " + voice_text.strip())
680
+ narrative = "\n".join(parts)
681
+
682
+ # Tool contexts
683
+ combined_for_drugs = " ".join(x for x in [narrative, vision_summary] if x)
684
+ dpd_context = (
685
+ tool_lookup_drug_products(combined_for_drugs)
686
+ if combined_for_drugs else "No medication context."
687
+ )
688
+
689
+ recalls_context = tool_get_recent_recalls_snippet()
690
+ wait_awareness_context = tool_get_wait_times_awareness()
691
+
692
+ region_context = (
693
+ tool_region_context(postal_code)
694
+ if postal_code
695
+ else "No postal code provided. Use Canada-wide guidance and mention provincial resources."
696
+ )
697
+
698
+ nearby_osm_context = (
699
+ tool_find_nearby_clinics_osm(postal_code)
700
+ if postal_code
701
+ else "No postal code. Suggest using maps and provincial clinic/ER finder tools."
702
+ )
703
+
704
+ google_search_context = (
705
+ tool_search_wait_times_google(postal_code)
706
+ if postal_code
707
+ else "Postal code missing; Google Custom Search not used."
708
+ )
709
 
710
+ final_answer = call_reasoning_agent(
711
  narrative=narrative,
712
  vision_summary=vision_summary,
713
  postal_code=postal_code or "",
714
+ dpd_context=dpd_context,
715
+ recalls_context=recalls_context,
716
+ wait_awareness_context=wait_awareness_context,
717
+ region_context=region_context,
718
+ nearby_osm_context=nearby_osm_context,
719
+ google_search_context=google_search_context,
720
  )
721
 
722
  st.markdown("### CareCall AI Summary (Informational Only)")
723
+ st.write(final_answer)