Seth0330 commited on
Commit
4d4ab01
·
verified ·
1 Parent(s): 6257445

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +402 -335
app.py CHANGED
@@ -1,5 +1,6 @@
1
  import os
2
  import io
 
3
  import base64
4
  import textwrap
5
  import requests
@@ -7,8 +8,13 @@ import streamlit as st
7
  from PIL import Image
8
  from openai import OpenAI
9
 
 
 
 
 
 
10
  # =========================
11
- # CONFIG
12
  # =========================
13
 
14
  st.set_page_config(
@@ -18,25 +24,31 @@ st.set_page_config(
18
  )
19
 
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" # vision-capable model
24
- REASONING_MODEL = "gpt-4.1-mini" # reasoning / agent model
25
- ASR_MODEL = "whisper-1" # transcription model
 
26
 
27
- # Health Canada Drug Product Database API
28
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
29
 
30
- # Recalls & Safety Alerts JSON feed (can override with env variable if path changes)
31
  RECALLS_FEED_URL = os.getenv(
32
  "RECALLS_FEED_URL",
33
  "https://recalls-rappels.canada.ca/static-data/items-en.json"
34
  )
35
 
36
- # CIHI wait-times info (general reference)
37
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
38
 
39
- # Postal code to province mapping (by first letter)
 
 
 
40
  POSTAL_PREFIX_TO_PROVINCE = {
41
  "A": "Newfoundland and Labrador",
42
  "B": "Nova Scotia",
@@ -58,14 +70,20 @@ POSTAL_PREFIX_TO_PROVINCE = {
58
  "Y": "Yukon",
59
  }
60
 
 
 
61
 
62
  # =========================
63
  # GENERIC HELPERS
64
  # =========================
65
 
66
- def safe_get_json(url, params=None, timeout=6):
67
  try:
68
- resp = requests.get(url, params=params, timeout=timeout)
 
 
 
 
69
  if resp.ok:
70
  return resp.json()
71
  except Exception:
@@ -73,7 +91,7 @@ def safe_get_json(url, params=None, timeout=6):
73
  return None
74
 
75
 
76
- def shorten(text, max_chars=280):
77
  if not text:
78
  return ""
79
  text = text.strip()
@@ -82,9 +100,16 @@ def shorten(text, max_chars=280):
82
  return text[: max_chars - 3].rstrip() + "..."
83
 
84
 
85
- # =========================
86
- # REGION / POSTAL HELPERS
87
- # =========================
 
 
 
 
 
 
 
88
 
89
  def infer_province_from_postal(postal_code: str):
90
  if not postal_code:
@@ -95,108 +120,183 @@ def infer_province_from_postal(postal_code: str):
95
  return POSTAL_PREFIX_TO_PROVINCE.get(pc[0])
96
 
97
 
98
- def tool_region_context(postal_code: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  province = infer_province_from_postal(postal_code)
100
  if not postal_code:
101
- return "No postal code provided. Use general Canada-wide guidance."
102
 
103
  if not province:
104
  return (
105
  f"Postal code '{postal_code}' could not be mapped reliably. "
106
- "Provide Canada-wide guidance and advise checking local provincial health websites and Telehealth services."
107
  )
108
 
109
  telehealth_note = (
110
- f"In {province}, advise the user to use the official provincial Telehealth or 811-style nurse advice line "
111
- "for real-time clinical advice."
112
  )
113
  wait_note = (
114
- "For non-urgent issues, they can check provincial or regional health authority websites and CIHI indicators "
115
- f"for access/wait-time information. Reference (general): {WAIT_TIMES_INFO_URL}"
116
  )
117
 
118
  return (
119
- f"User postal code: '{postal_code}' mapped to {province}.\n"
120
  f"{telehealth_note}\n"
121
  f"{wait_note}"
122
  )
123
 
124
 
125
- def tool_find_nearby_clinics_and_waits(postal_code: str) -> str:
 
126
  """
127
- Province-aware pointers to official 'find a clinic' / wait-time resources.
128
- Not real-time; we send the user to authoritative tools.
129
  """
130
- province = infer_province_from_postal(postal_code)
131
- if not postal_code or not province:
132
  return (
133
- "No precise local clinic data. Suggest the user search 'walk-in clinic near me' plus their city, and use "
134
- "their provincial health website or health authority finder for verified locations and hours."
135
  )
136
 
137
- base = f"For users in {province}, suggest these options to find nearby care and typical waits:\n"
138
- province_links = []
139
-
140
- # Very high-level, non-exhaustive hints (you can refine for your production app)
141
- if province == "Ontario":
142
- province_links.append(
143
- "- Use Ontario's official 'Health811' and 'Find a clinic/hospital' tools from Ontario Health."
144
- )
145
- elif province == "British Columbia":
146
- province_links.append(
147
- "- Use HealthLink BC (811) and the 'Find Health Services' directory for clinics and urgent care."
148
- )
149
- elif province == "Alberta":
150
- province_links.append(
151
- "- Use Alberta Health Services 'Find Healthcare' directory for walk-ins and urgent care clinics."
152
- )
153
- elif province == "Quebec":
154
- province_links.append(
155
- "- Use your local CISSS/CIUSSS websites and provincial resources (e.g., Bonjour-santé) for clinic bookings."
156
- )
157
- elif province == "Manitoba":
158
- province_links.append(
159
- "- Use Manitoba Health / regional health authority sites to locate walk-in and primary care clinics."
160
- )
161
- elif province == "Saskatchewan":
162
- province_links.append(
163
- "- Use Saskatchewan Health Authority tools to find urgent care and clinics."
164
- )
165
- elif province == "Nova Scotia":
166
- province_links.append(
167
- "- Use Nova Scotia Health 'Find a Location/Service' for primary care and walk-in options."
168
- )
169
- elif province == "New Brunswick":
170
- province_links.append(
171
- "- Use Horizon / Vitalité Health Network resources for clinics and after-hours care."
172
- )
173
- elif province == "Newfoundland and Labrador":
174
- province_links.append(
175
- "- Use provincial health authority websites to locate clinics and ERs."
176
- )
177
- elif province == "Prince Edward Island":
178
- province_links.append(
179
- "- Use Health PEI resources to find walk-in clinics and primary care."
180
  )
181
- else:
182
- province_links.append(
183
- "- Use your territorial/provincial health authority website and Telehealth for locations and typical waits."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  )
185
 
186
- province_links.append(
187
- "- For emergencies or red-flag symptoms, emphasize going to the nearest emergency department or calling 911 immediately."
188
- )
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- return base + "\n".join(province_links)
191
 
192
 
193
- # =========================
194
- # DRUG PRODUCT LOOKUP (DPD)
195
- # =========================
 
 
 
 
 
196
 
197
- def tool_lookup_drug_products(candidates):
 
 
 
 
 
 
198
  if not candidates:
199
- return "No clear medication/product names detected that require a Drug Product Database lookup."
200
 
201
  found_lines = []
202
  for name in candidates[:5]:
@@ -204,36 +304,34 @@ def tool_lookup_drug_products(candidates):
204
  data = safe_get_json(DPD_BASE_URL, params=params)
205
  if not data:
206
  continue
207
-
208
- unique_brands = set()
209
  for item in data[:3]:
210
  b = item.get("brand_name") or item.get("brandname") or name
211
  if b:
212
- unique_brands.add(b)
213
-
214
- if unique_brands:
215
  found_lines.append(
216
- f"- Found Health Canada Drug Product Database entries related to '{name}' "
217
- f"(examples: {', '.join(sorted(unique_brands))})."
218
  )
219
 
220
  if not found_lines:
221
  return (
222
- "No strong matches found in the Drug Product Database for the detected terms. "
223
- "Users should verify drug details directly via the official Health Canada DPD search."
224
  )
225
 
226
  found_lines.append(
227
- "For definitive product information, they must consult the official Health Canada Drug Product Database."
228
  )
229
  return "\n".join(found_lines)
230
 
231
 
232
- # =========================
233
- # RECALLS & SAFETY ALERTS
234
- # =========================
235
-
236
- def tool_get_recent_recalls_snippet():
237
  data = safe_get_json(RECALLS_FEED_URL)
238
  items = []
239
 
@@ -244,8 +342,8 @@ def tool_get_recent_recalls_snippet():
244
 
245
  if not items:
246
  return (
247
- "Unable to load recent recalls. Direct users to the official Government of Canada "
248
- "Recalls and Safety Alerts website for up-to-date information."
249
  )
250
 
251
  lines = ["Recent recalls & safety alerts (snapshot):"]
@@ -259,46 +357,30 @@ def tool_get_recent_recalls_snippet():
259
  date = item.get("date_published") or item.get("date") or ""
260
  category = item.get("category") or item.get("type") or ""
261
  lines.append(f"- {shorten(title)} ({category}, {date})")
262
-
263
  lines.append(
264
- "For details or to check a specific product, direct users to the official Recalls and Safety Alerts portal."
265
  )
266
  return "\n".join(lines)
267
 
268
 
269
- # =========================
270
- # WAIT TIMES AWARENESS (GENERIC)
271
- # =========================
272
-
273
- def tool_get_wait_times_awareness():
274
  return textwrap.dedent(f"""
275
- Use Canadian open-data wait-time indicators conceptually:
276
- - CIHI and several provinces publish average wait-time dashboards for ER, surgeries, diagnostics.
277
- - These are not real-time triage tools.
278
  Guidance:
279
- - For mild issues: local clinics/urgent care may be suitable; check provincial wait-time/clinic-finder tools.
280
- - For red-flag or severe symptoms: skip online tools and go directly to ER or call 911.
281
  General reference: {WAIT_TIMES_INFO_URL}
282
  """).strip()
283
 
284
 
285
  # =========================
286
- # TERM EXTRACTION
287
- # =========================
288
-
289
- def extract_candidate_terms(text: str):
290
- if not text:
291
- return []
292
- tokens = []
293
- for tok in text.split():
294
- clean = "".join(ch for ch in tok if ch.isalnum())
295
- if len(clean) > 3 and clean[0].isalpha() and clean[0].isupper():
296
- tokens.append(clean)
297
- return sorted(set(tokens))[:10]
298
-
299
-
300
- # =========================
301
- # OPENAI CALLS
302
  # =========================
303
 
304
  def call_vision_summarizer(image_bytes: bytes) -> str:
@@ -309,31 +391,27 @@ def call_vision_summarizer(image_bytes: bytes) -> str:
309
 
310
  prompt = (
311
  "You are a cautious Canadian health-information assistant.\n"
312
- "Describe only visible features in neutral terms.\n"
313
- "- If skin/toenail etc.: describe colour, texture, dryness, cracks, swelling, but DO NOT name diseases.\n"
314
- "- If medication label: read visible drug name/strength if legible.\n"
315
- "- If health letter/report: say what type it appears to be, without copying identifiers.\n"
316
  "- If unclear: say it is not medically interpretable.\n"
317
- "Never diagnose. Never prescribe. 80-100 words max."
318
  )
319
 
320
  try:
321
  resp = client.chat.completions.create(
322
  model=VISION_MODEL,
323
- messages=[
324
- {
325
- "role": "user",
326
- "content": [
327
- {"type": "text", "text": prompt},
328
- {
329
- "type": "image_url",
330
- "image_url": {
331
- "url": f"data:image/jpeg;base64,{b64}"
332
- },
333
- },
334
- ],
335
- }
336
- ],
337
  temperature=0.2,
338
  )
339
  return resp.choices[0].message.content.strip()
@@ -344,12 +422,10 @@ def call_vision_summarizer(image_bytes: bytes) -> str:
344
  def call_asr(audio_file) -> str:
345
  if not client or not audio_file:
346
  return ""
347
-
348
  try:
349
  audio_bytes = audio_file.read()
350
  bio = io.BytesIO(audio_bytes)
351
  bio.name = audio_file.name or "voice.wav"
352
-
353
  transcript = client.audio.transcriptions.create(
354
  model=ASR_MODEL,
355
  file=bio
@@ -362,127 +438,150 @@ def call_asr(audio_file) -> str:
362
  return f"(Transcription unavailable: {e})"
363
 
364
 
365
- def call_reasoning_agent(
366
- user_text: str,
367
- voice_text: str,
368
- vision_summary: str,
369
- dpd_context: str,
370
- recalls_context: str,
371
- wait_times_context: str,
372
- region_context: str,
373
- nearby_services_context: str,
374
- ) -> str:
375
- if not client:
376
- return (
377
- "AI backend not configured. With a valid API key, this agent would combine your inputs and "
378
- "open-data context into cautious, structured guidance."
379
- )
 
 
 
380
 
381
- narrative_parts = []
382
- if user_text.strip():
383
- narrative_parts.append("Typed description:\n" + user_text.strip())
384
- if voice_text.strip():
385
- narrative_parts.append("Transcribed voice note:\n" + voice_text.strip())
386
- if not narrative_parts:
387
- narrative_parts.append("No textual description provided.")
388
- narrative = "\n\n".join(narrative_parts)
389
 
390
  system_prompt = """
391
  You are CareCall AI, an agentic Canadian health information assistant.
392
 
393
- HARD RULES:
394
- - DO NOT give a medical diagnosis.
395
- - DO NOT prescribe or specify individual dosing.
396
- - DO NOT guarantee exact real-time wait times or locations.
397
- - Use ONLY the provided content and tool outputs (vision summary, DPD, recalls, wait-times context, region info, nearby services guide).
398
- - You MUST classify guidance into a clear, practical structure without pretending to be a doctor.
399
-
400
- WHAT YOU SHOULD DO:
401
-
402
- 1. Identify red-flag indicators (e.g., chest pain, trouble breathing, stroke signs, rapidly spreading infection,
403
- severe pain, heavy bleeding, high fever with stiff neck, suicidal thoughts, major trauma).
404
- - If any such red flags are present or strongly implied:
405
- PRIMARY PATHWAY = ER/911.
406
- 2. If no red flags and issue seems mild/local (e.g., minor rash, mild toe pain, dry skin, chipped nail, etc.):
407
- - You may choose PRIMARY PATHWAY = Home care + possibly pharmacist.
408
- 3. If there is uncertainty, ongoing pain, risk factors (diabetes, immune issues, poor circulation),
409
- or symptoms that are not clearly mild:
410
- - You may choose PRIMARY PATHWAY = Clinic / family doctor / Telehealth.
411
-
412
- ALWAYS:
413
-
414
- - Output a section called "Primary Recommended Pathway" with ONE of:
415
- * "Home care likely reasonable (monitor closely)"
416
- * "Pharmacist / Walk-in clinic / Family doctor recommended"
417
- * "Call provincial Telehealth / nurse line for guidance"
418
- * "Go to Emergency / Call 911 now"
419
- - Then:
420
- * Give a short explanation for WHY that pathway was chosen.
421
- * Provide a "Home Care (if appropriate)" section with 3–6 simple, generic measures
422
- (e.g., keep area clean/dry, rest, elevation, cold compress, OTC pain relief as per label, avoid friction),
423
- ONLY when not clearly contradicted.
424
- * Provide "When to seek clinic/Telehealth" with concrete triggers.
425
- * Provide "When this is an emergency" with explicit red-flag bullets.
426
- * Mention provincial/nearby resources using the region and nearby-services context.
427
- * If medications/recalls context is relevant, remind the user to verify via Health Canada DPD and Recalls portal.
428
- - Keep total answer under ~350 words.
429
- - Reiterate that this is informational only, not medical care, and that professionals/Telehealth/ER override all advice.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  """
431
 
432
- user_prompt = f"""
433
- INPUT CONTEXT
434
-
435
- 1) User narrative:
436
- {narrative}
437
-
438
- 2) Vision summary:
439
- {vision_summary or "No image or no usable visual details."}
440
-
441
- 3) Drug Product Database context:
442
- {dpd_context}
443
-
444
- 4) Recalls & Safety Alerts context:
445
- {recalls_context}
446
-
447
- 5) Wait-time awareness context:
448
- {wait_times_context}
449
 
450
- 6) Region (postal code) context:
451
- {region_context}
 
452
 
453
- 7) Nearby services & finder resources:
454
- {nearby_services_context}
455
 
456
- TASK
 
 
 
 
 
457
 
458
- Using ONLY the above, produce a structured response with these sections:
 
459
 
460
- 1. "Primary Recommended Pathway": one of the four options (see system rules) + 1–2 sentence reasoning.
461
- 2. "What this might relate to (in general terms)": 3–5 bullets of broad categories, no diagnoses.
462
- 3. "Home Care (if appropriate)": 3–6 safe, generic self-care steps OR a note that home care alone is not advised.
463
- 4. "When to see a Pharmacist / Clinic / Telehealth": 3–6 concrete triggers.
464
- 5. "When this is an Emergency (ER/911)": bullet list of red-flag situations.
465
- 6. "Local & Official Resources": 2–5 bullets using the region + nearby-services context and open-data style info.
466
- 7. "Important Disclaimer": 2–4 lines stating this is informational only and not medical care.
467
 
468
- Be firm and clear but never overconfident.
 
 
 
469
  """
470
-
471
- try:
472
- resp = client.chat.completions.create(
473
- model=REASONING_MODEL,
474
- messages=[
475
- {"role": "system", "content": system_prompt},
476
- {"role": "user", "content": user_prompt},
477
- ],
478
- temperature=0.35,
479
- )
480
- return resp.choices[0].message.content.strip()
481
- except Exception as e:
482
- return (
483
- f"(Reasoning agent unavailable: {e})\n\n"
484
- "Please contact a licensed provider, Telehealth, or emergency services as appropriate."
485
- )
486
 
487
 
488
  # =========================
@@ -490,46 +589,45 @@ Be firm and clear but never overconfident.
490
  # =========================
491
 
492
  st.title("CareCall AI (Canada)")
493
- st.caption("Agentic, vision + voice + postal-aware health info companion. Not a doctor. Not a diagnosis.")
494
-
495
- if not OPENAI_API_KEY:
496
- st.warning("Set OPENAI_API_KEY in your Space secrets to enable the AI backend.")
497
 
498
  st.markdown(
499
  """
500
- **How to use**
501
-
502
- 1. Enter your **Canadian postal code** (optional but helps tailor guidance).
503
- 2. Upload a **photo** related to your concern (e.g., rash, nail, pill bottle, letter) — optional.
504
- 3. Upload a **voice note** and/or type a **short description**.
505
- 4. Click **Run CareCall Agent** to get:
506
- - A clear **primary recommended pathway**,
507
- - Safe **home-care tips** when appropriate,
508
- - Triggers for **clinic/Telehealth**,
509
- - **Emergency red-flag guidance**,
510
- - Links to **official & provincial resources**.
 
511
  """
512
  )
513
 
514
- # 1) Postal code
 
 
 
515
  st.markdown("### 1. Postal code (optional, Canada only)")
516
  postal_code = st.text_input(
517
  "Canadian postal code (e.g., M5V 2T6). Leave blank for general guidance.",
518
  max_chars=7,
519
  )
520
 
521
- # 2) Image upload
522
  st.markdown("### 2. Upload an image (optional)")
523
  image_file = st.file_uploader(
524
  "Upload a photo (JPG/PNG). Avoid highly identifying or explicit content.",
525
  type=["jpg", "jpeg", "png"],
526
  accept_multiple_files=False,
527
  )
528
-
529
  image_bytes = None
530
  if image_file is not None:
531
  try:
532
- # Convert to RGB so we can safely save as JPEG (avoids RGBA error)
533
  img = Image.open(image_file).convert("RGB")
534
  st.image(img, caption="Image received", use_container_width=True)
535
  buf = io.BytesIO()
@@ -539,7 +637,7 @@ if image_file is not None:
539
  st.warning(f"Could not read that image: {e}")
540
  image_bytes = None
541
 
542
- # 3) Audio upload
543
  st.markdown("### 3. Add a voice note (optional)")
544
  audio_file = st.file_uploader(
545
  "Upload a short voice note (wav/mp3/m4a).",
@@ -547,68 +645,37 @@ audio_file = st.file_uploader(
547
  accept_multiple_files=False,
548
  )
549
 
550
- # 4) Text description
551
  st.markdown("### 4. Or type a short description")
552
  user_text = st.text_area(
553
- "Describe whats going on:",
554
- placeholder='Example: "Pain and discoloration around big toenail when wearing shoes for 1 week, no fever, no major swelling."',
555
  height=120,
556
  )
557
 
558
- # Run agent
559
  if st.button("Run CareCall Agent"):
560
- if not client:
561
- st.error("OPENAI_API_KEY is not set. Configure it in your Space settings.")
562
- else:
563
- with st.spinner("Running vision + voice + open-data tools + reasoning..."):
564
-
565
- # Vision
566
- vision_summary = ""
567
- if image_bytes:
568
- vision_summary = call_vision_summarizer(image_bytes)
569
-
570
- # ASR
571
- voice_text = ""
572
- if audio_file is not None:
573
- voice_text = call_asr(audio_file)
574
-
575
- # Candidate terms for Drug DB
576
- combined_for_terms = " ".join(
577
- t for t in [user_text or "", voice_text or "", vision_summary or ""] if t
578
- )
579
- candidates = extract_candidate_terms(combined_for_terms)
580
- dpd_context = tool_lookup_drug_products(candidates)
581
-
582
- # Recalls
583
- recalls_context = tool_get_recent_recalls_snippet()
584
-
585
- # Wait-time awareness
586
- wait_times_context = tool_get_wait_times_awareness()
587
-
588
- # Region context
589
- region_context = (
590
- tool_region_context(postal_code)
591
- if postal_code
592
- else "No postal code provided. Use Canada-wide guidance and mention provincial resources generally."
593
- )
594
-
595
- # Nearby clinics / wait finder resources
596
- nearby_services_context = tool_find_nearby_clinics_and_waits(postal_code) if postal_code else (
597
- "No postal code. Suggest searchable options like 'walk-in clinic near me' plus city, "
598
- "and provincial clinic/ER finder tools."
599
- )
600
-
601
- # Final reasoning
602
- final_answer = call_reasoning_agent(
603
- user_text=user_text or "",
604
- voice_text=voice_text or "",
605
- vision_summary=vision_summary,
606
- dpd_context=dpd_context,
607
- recalls_context=recalls_context,
608
- wait_times_context=wait_times_context,
609
- region_context=region_context,
610
- nearby_services_context=nearby_services_context,
611
- )
612
 
613
- st.markdown("### CareCall AI Summary (Informational Only)")
614
- st.write(final_answer)
 
1
  import os
2
  import io
3
+ import math
4
  import base64
5
  import textwrap
6
  import requests
 
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_core.prompts import ChatPromptTemplate
14
+ from langchain.agents import create_tool_calling_agent, AgentExecutor
15
+
16
  # =========================
17
+ # STREAMLIT + OPENAI CONFIG
18
  # =========================
19
 
20
  st.set_page_config(
 
24
  )
25
 
26
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
27
+ if not OPENAI_API_KEY:
28
+ st.warning("Set OPENAI_API_KEY in your Space secrets to enable AI features.")
29
  client = OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
30
 
31
+ # OpenAI models
32
+ VISION_MODEL = "gpt-4.1-mini" # vision-capable
33
+ REASONING_MODEL = "gpt-4.1-mini" # main agent model
34
+ ASR_MODEL = "whisper-1" # audio transcription
35
 
36
+ # Health Canada Drug Product Database
37
  DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
38
 
39
+ # Recalls & Safety Alerts (public JSON feed; can override with env)
40
  RECALLS_FEED_URL = os.getenv(
41
  "RECALLS_FEED_URL",
42
  "https://recalls-rappels.canada.ca/static-data/items-en.json"
43
  )
44
 
45
+ # CIHI wait-times info (context link, not live triage)
46
  WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
47
 
48
+ # Optional: your own normalized wait-time API
49
+ WAIT_TIMES_API_BASE = os.getenv("WAIT_TIMES_API_BASE", "").rstrip("/")
50
+
51
+ # Postal prefix -> province map
52
  POSTAL_PREFIX_TO_PROVINCE = {
53
  "A": "Newfoundland and Labrador",
54
  "B": "Nova Scotia",
 
70
  "Y": "Yukon",
71
  }
72
 
73
+ USER_AGENT = "carecall-ai-langchain-demo/1.0"
74
+
75
 
76
  # =========================
77
  # GENERIC HELPERS
78
  # =========================
79
 
80
+ def safe_get_json(url, params=None, method="GET", data=None, timeout=8):
81
  try:
82
+ headers = {"User-Agent": USER_AGENT}
83
+ if method == "GET":
84
+ resp = requests.get(url, params=params, headers=headers, timeout=timeout)
85
+ else:
86
+ resp = requests.post(url, data=data, headers=headers, timeout=timeout)
87
  if resp.ok:
88
  return resp.json()
89
  except Exception:
 
91
  return None
92
 
93
 
94
+ def shorten(text, max_chars=260):
95
  if not text:
96
  return ""
97
  text = text.strip()
 
100
  return text[: max_chars - 3].rstrip() + "..."
101
 
102
 
103
+ def haversine_km(lat1, lon1, lat2, lon2):
104
+ R = 6371.0
105
+ from math import radians, sin, cos, atan2, sqrt
106
+ phi1, phi2 = radians(lat1), radians(lat2)
107
+ dphi = radians(lat2 - lat1)
108
+ dlambda = radians(lon2 - lon1)
109
+ a = sin(dphi / 2) ** 2 + cos(phi1) * cos(phi2) * sin(dlambda / 2) ** 2
110
+ c = 2 * atan2(sqrt(a), sqrt(1 - a))
111
+ return R * c
112
+
113
 
114
  def infer_province_from_postal(postal_code: str):
115
  if not postal_code:
 
120
  return POSTAL_PREFIX_TO_PROVINCE.get(pc[0])
121
 
122
 
123
+ def geocode_postal(postal_code: str):
124
+ if not postal_code:
125
+ return None
126
+ q = f"{postal_code}, Canada"
127
+ url = "https://nominatim.openstreetmap.org/search"
128
+ try:
129
+ resp = requests.get(
130
+ url,
131
+ params={"q": q, "format": "json", "limit": 1},
132
+ headers={"User-Agent": USER_AGENT},
133
+ timeout=8,
134
+ )
135
+ if resp.ok:
136
+ data = resp.json()
137
+ if data:
138
+ lat = float(data[0]["lat"])
139
+ lon = float(data[0]["lon"])
140
+ return lat, lon
141
+ except Exception:
142
+ return None
143
+ return None
144
+
145
+
146
+ # =========================
147
+ # LANGCHAIN TOOLS
148
+ # =========================
149
+
150
+ @tool
151
+ def get_region_context(postal_code: str) -> str:
152
+ """
153
+ Given a Canadian postal code, return high-level provincial context:
154
+ - inferred province (if possible)
155
+ - Telehealth / nurse-line style advice
156
+ - pointer to wait-time info sources (CIHI/provincial).
157
+ """
158
  province = infer_province_from_postal(postal_code)
159
  if not postal_code:
160
+ return "No postal code provided. Use general Canada-wide guidance and mention provincial resources."
161
 
162
  if not province:
163
  return (
164
  f"Postal code '{postal_code}' could not be mapped reliably. "
165
+ "Use Canada-wide guidance and suggest checking local provincial health ministry and Telehealth services."
166
  )
167
 
168
  telehealth_note = (
169
+ f"In {province}, advise the user to use the official provincial Telehealth or 811-style nurse line "
170
+ "for real-time nurse assessment."
171
  )
172
  wait_note = (
173
+ "For non-urgent issues, they may check provincial/health authority resources and CIHI indicators "
174
+ f"for access and wait-time context (not real-time). Ref: {WAIT_TIMES_INFO_URL}"
175
  )
176
 
177
  return (
178
+ f"User postal code '{postal_code}' mapped to {province}.\n"
179
  f"{telehealth_note}\n"
180
  f"{wait_note}"
181
  )
182
 
183
 
184
+ @tool
185
+ def get_nearby_facilities(postal_code: str) -> str:
186
  """
187
+ Use open map data (OSM/Overpass) to list a few nearby clinics/hospitals/doctors.
188
+ If WAIT_TIMES_API_BASE is set, also attach estimated wait times when available.
189
  """
190
+ coords = geocode_postal(postal_code)
191
+ if not coords:
192
  return (
193
+ "Could not geocode postal code. Suggest the user search for walk-in clinics, urgent care, or hospitals "
194
+ "near them using maps and official provincial tools."
195
  )
196
 
197
+ lat, lon = coords
198
+
199
+ overpass_url = "https://overpass-api.de/api/interpreter"
200
+ query = f"""
201
+ [out:json][timeout:10];
202
+ (
203
+ node["amenity"="clinic"](around:7000,{lat},{lon});
204
+ node["amenity"="hospital"](around:7000,{lat},{lon});
205
+ node["amenity"="doctors"](around:7000,{lat},{lon});
206
+ node["amenity"="urgent_care"](around:7000,{lat},{lon});
207
+ );
208
+ out center 12;
209
+ """
210
+ data = safe_get_json(overpass_url, method="POST", data=query)
211
+ if not data or "elements" not in data:
212
+ return (
213
+ "Nearby facility lookup unavailable from open map data. "
214
+ "Suggest using map search (e.g., 'walk-in clinic near me') and provincial find-a-clinic tools."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  )
216
+
217
+ facilities = []
218
+ for el in data["elements"]:
219
+ tags = el.get("tags", {})
220
+ name = tags.get("name")
221
+ if not name:
222
+ continue
223
+ amenity = tags.get("amenity", "clinic")
224
+ lat2 = el.get("lat") or (el.get("center") or {}).get("lat")
225
+ lon2 = el.get("lon") or (el.get("center") or {}).get("lon")
226
+ if lat2 is None or lon2 is None:
227
+ continue
228
+ dist = haversine_km(lat, lon, float(lat2), float(lon2))
229
+ addr_parts = []
230
+ for k in ("addr:street", "addr:city"):
231
+ if tags.get(k):
232
+ addr_parts.append(tags[k])
233
+ addr = ", ".join(addr_parts)
234
+
235
+ # Default line
236
+ line = {
237
+ "name": name,
238
+ "type": amenity,
239
+ "distance_km": dist,
240
+ "address": addr,
241
+ "wait": None,
242
+ }
243
+
244
+ # Optional: call your own wait-time API
245
+ if WAIT_TIMES_API_BASE:
246
+ jt = safe_get_json(
247
+ f"{WAIT_TIMES_API_BASE}/wait",
248
+ params={"name": name},
249
+ timeout=4
250
+ )
251
+ if jt and isinstance(jt, dict) and jt.get("estimated_minutes") is not None:
252
+ line["wait"] = int(jt["estimated_minutes"])
253
+
254
+ facilities.append(line)
255
+
256
+ facilities = sorted(facilities, key=lambda x: x["distance_km"])[:8]
257
+ if not facilities:
258
+ return (
259
+ "No nearby facilities found in open map data. "
260
+ "Suggest using maps and provincial health websites to locate clinics and ERs."
261
  )
262
 
263
+ out_lines = ["Nearby health facilities (approximate from open data):"]
264
+ for f in facilities:
265
+ s = f"- {f['name']} ({f['type']}, ~{f['distance_km']:.1f} km"
266
+ if f["address"]:
267
+ s += f", {f['address']}"
268
+ if f["wait"] is not None:
269
+ s += f", est. wait ~{f['wait']} min"
270
+ s += ")"
271
+ out_lines.append(s)
272
+
273
+ if not WAIT_TIMES_API_BASE:
274
+ out_lines.append(
275
+ "Note: No live wait-time API configured; wait times above are not shown. "
276
+ "You can integrate an official or custom wait-time API via WAIT_TIMES_API_BASE."
277
+ )
278
 
279
+ return "\n".join(out_lines)
280
 
281
 
282
+ @tool
283
+ def get_drug_info(text: str) -> str:
284
+ """
285
+ Extracts possible medication/product names from the text and checks Health Canada's DPD.
286
+ Returns a high-level note; no dosing or personal advice.
287
+ """
288
+ if not text:
289
+ return "No text provided for medication lookup."
290
 
291
+ # naive extraction: capitalized tokens length>3
292
+ tokens = []
293
+ for tok in text.split():
294
+ clean = "".join(ch for ch in tok if ch.isalnum())
295
+ if len(clean) > 3 and clean[0].isalpha() and clean[0].isupper():
296
+ tokens.append(clean)
297
+ candidates = sorted(set(tokens))[:10]
298
  if not candidates:
299
+ return "No likely medication names detected that require Drug Product Database lookup."
300
 
301
  found_lines = []
302
  for name in candidates[:5]:
 
304
  data = safe_get_json(DPD_BASE_URL, params=params)
305
  if not data:
306
  continue
307
+ unique = set()
 
308
  for item in data[:3]:
309
  b = item.get("brand_name") or item.get("brandname") or name
310
  if b:
311
+ unique.add(b)
312
+ if unique:
 
313
  found_lines.append(
314
+ f"- Health Canada DPD has entries related to '{name}' "
315
+ f"(e.g., {', '.join(sorted(unique))})."
316
  )
317
 
318
  if not found_lines:
319
  return (
320
+ "No strong DPD matches found for detected terms. "
321
+ "Users should confirm medication details directly via the official Health Canada DPD."
322
  )
323
 
324
  found_lines.append(
325
+ "For definitive information on any medication, refer to the official Health Canada Drug Product Database."
326
  )
327
  return "\n".join(found_lines)
328
 
329
 
330
+ @tool
331
+ def get_recalls(_: str) -> str:
332
+ """
333
+ Returns a snapshot of recent recalls & safety alerts from the public JSON feed.
334
+ """
335
  data = safe_get_json(RECALLS_FEED_URL)
336
  items = []
337
 
 
342
 
343
  if not items:
344
  return (
345
+ "Unable to load recent recalls. Refer users to the official Government of Canada "
346
+ "Recalls and Safety Alerts website for current information."
347
  )
348
 
349
  lines = ["Recent recalls & safety alerts (snapshot):"]
 
357
  date = item.get("date_published") or item.get("date") or ""
358
  category = item.get("category") or item.get("type") or ""
359
  lines.append(f"- {shorten(title)} ({category}, {date})")
 
360
  lines.append(
361
+ "For details or specific products, visit the official Recalls and Safety Alerts portal."
362
  )
363
  return "\n".join(lines)
364
 
365
 
366
+ @tool
367
+ def get_wait_times_awareness(_: str) -> str:
368
+ """
369
+ Returns general guidance about using official wait-time dashboards (CIHI / provinces).
370
+ """
371
  return textwrap.dedent(f"""
372
+ Use open-data wait-time information conceptually:
373
+ - CIHI and some provinces publish average wait-time dashboards for ER and procedures.
374
+ - These are NOT real-time triage tools.
375
  Guidance:
376
+ - For mild issues: clinic/urgent care may be appropriate; check provincial tools if available.
377
+ - For red flags or severe symptoms: skip tools and go directly to ER or call 911.
378
  General reference: {WAIT_TIMES_INFO_URL}
379
  """).strip()
380
 
381
 
382
  # =========================
383
+ # OPENAI: VISION + ASR
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  # =========================
385
 
386
  def call_vision_summarizer(image_bytes: bytes) -> str:
 
391
 
392
  prompt = (
393
  "You are a cautious Canadian health-information assistant.\n"
394
+ "Describe ONLY what is visible in neutral terms.\n"
395
+ "- For skin/nails: describe colour, dryness, cracks, swelling, etc. Do NOT name diseases.\n"
396
+ "- For medication labels: read visible drug names/strengths if clear.\n"
397
+ "- For letters/reports: say what type of document it appears to be; do not copy identifiers.\n"
398
  "- If unclear: say it is not medically interpretable.\n"
399
+ "Never diagnose or prescribe. Keep to ~80-100 words."
400
  )
401
 
402
  try:
403
  resp = client.chat.completions.create(
404
  model=VISION_MODEL,
405
+ messages=[{
406
+ "role": "user",
407
+ "content": [
408
+ {"type": "text", "text": prompt},
409
+ {
410
+ "type": "image_url",
411
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
412
+ },
413
+ ],
414
+ }],
 
 
 
 
415
  temperature=0.2,
416
  )
417
  return resp.choices[0].message.content.strip()
 
422
  def call_asr(audio_file) -> str:
423
  if not client or not audio_file:
424
  return ""
 
425
  try:
426
  audio_bytes = audio_file.read()
427
  bio = io.BytesIO(audio_bytes)
428
  bio.name = audio_file.name or "voice.wav"
 
429
  transcript = client.audio.transcriptions.create(
430
  model=ASR_MODEL,
431
  file=bio
 
438
  return f"(Transcription unavailable: {e})"
439
 
440
 
441
+ # =========================
442
+ # LANGCHAIN AGENT
443
+ # =========================
444
+
445
+ def build_agent():
446
+ """
447
+ Constructs a LangChain tool-calling agent that:
448
+ - reads narrative + vision summary + postal code,
449
+ - can call tools: get_region_context, get_nearby_facilities, get_drug_info,
450
+ get_recalls, get_wait_times_awareness,
451
+ - outputs a structured, safe triage-style answer with:
452
+ Primary Recommended Pathway, Home Care, Clinic/Telehealth, ER, Resources, Disclaimer.
453
+ """
454
+ llm = ChatOpenAI(
455
+ model=REASONING_MODEL,
456
+ temperature=0.25,
457
+ openai_api_key=OPENAI_API_KEY,
458
+ )
459
 
460
+ tools = [
461
+ get_region_context,
462
+ get_nearby_facilities,
463
+ get_drug_info,
464
+ get_recalls,
465
+ get_wait_times_awareness,
466
+ ]
 
467
 
468
  system_prompt = """
469
  You are CareCall AI, an agentic Canadian health information assistant.
470
 
471
+ You have access to tools to:
472
+ - get_region_context(postal_code)
473
+ - get_nearby_facilities(postal_code)
474
+ - get_drug_info(text)
475
+ - get_recalls(_)
476
+ - get_wait_times_awareness(_)
477
+
478
+ Follow these rules:
479
+
480
+ - NEVER provide a medical diagnosis.
481
+ - NEVER prescribe or give dosing.
482
+ - NEVER fabricate real-time wait times.
483
+ - Use tools to ground your answer in Canadian, open, trusted sources.
484
+ - Stay within ~350 words.
485
+
486
+ PRIMARY PATHWAY LOGIC:
487
+
488
+ 1. If strong red-flag symptoms are present:
489
+ (chest pain, difficulty breathing, stroke signs, heavy bleeding,
490
+ major trauma, high fever with stiff neck, severe abdominal pain,
491
+ rapidly spreading infection with systemic symptoms, suicidal thoughts)
492
+ => Primary Recommended Pathway: "Go to Emergency / Call 911 now".
493
+
494
+ 2. If NO red flags and description suggests:
495
+ - localized mild/moderate issue (e.g., minor rash, mild toe pain,
496
+ discomfort, dryness, irritation, small wound),
497
+ - no major risk factors clearly stated,
498
+ => Primary Recommended Pathway: "Home care likely reasonable (monitor closely)",
499
+ plus pharmacist/clinic as backup if not improving.
500
+
501
+ 3. If symptoms are persistent, unclear, moderate, concerning,
502
+ or risk factors are present but not clearly ER:
503
+ => Primary Recommended Pathway:
504
+ "Pharmacist / Walk-in clinic / Family doctor recommended"
505
+ OR
506
+ "Call provincial Telehealth / nurse line for guidance".
507
+
508
+ OUTPUT FORMAT (exact sections, in this order):
509
+
510
+ 1. **Primary Recommended Pathway**
511
+ - One bullet with exactly one of:
512
+ * "Home care likely reasonable (monitor closely)"
513
+ * "Pharmacist / Walk-in clinic / Family doctor recommended"
514
+ * "Call provincial Telehealth / nurse line for guidance"
515
+ * "Go to Emergency / Call 911 now"
516
+ - One short sentence explaining why.
517
+
518
+ 2. **What this might relate to (general categories)**
519
+ - 3–5 bullets with broad categories only (irritation, minor trauma,
520
+ nail/skin infection risk, allergy, medication question, etc.).
521
+ - Do NOT assert a specific disease name as fact.
522
+
523
+ 3. **Home Care (if appropriate)**
524
+ - If home care is primary or a safe initial option:
525
+ 3–6 simple measures (gentle cleaning, keep area dry, avoid friction,
526
+ protective padding, OTC pain relief per label, monitor changes).
527
+ - If not advised: say that clearly.
528
+
529
+ 4. **When to see a Pharmacist / Clinic / Telehealth**
530
+ - 3–6 triggers (pain not improving, spreading redness, pus, trouble walking,
531
+ recurrent issues, underlying conditions like diabetes, confusion about meds).
532
+
533
+ 5. **When this is an Emergency (ER/911)**
534
+ - 3–8 clear red-flag bullets.
535
+
536
+ 6. **Local & Official Resources**
537
+ - 2–6 bullets using:
538
+ get_region_context,
539
+ get_nearby_facilities,
540
+ get_wait_times_awareness,
541
+ and mention Health Canada / PHAC / CIHI / provincial sites.
542
+
543
+ 7. **Important Disclaimer**
544
+ - 2–4 lines:
545
+ This is informational only,
546
+ not medical care,
547
+ not a substitute for a professional or emergency services.
548
+
549
+ Use tools whenever they can improve accuracy (especially for region, facilities, recalls, meds).
550
  """
551
 
552
+ prompt = ChatPromptTemplate.from_messages(
553
+ [
554
+ ("system", system_prompt),
555
+ ("human", "{input}"),
556
+ ]
557
+ )
 
 
 
 
 
 
 
 
 
 
 
558
 
559
+ agent = create_tool_calling_agent(llm, tools, prompt)
560
+ executor = AgentExecutor(agent=agent, tools=tools, verbose=False)
561
+ return executor
562
 
 
 
563
 
564
+ def run_agent(narrative: str, vision_summary: str, postal_code: str) -> str:
565
+ agent = build_agent()
566
+ # Build a single input string that the agent will use + tools can refine.
567
+ user_input = f"""
568
+ User narrative:
569
+ {narrative or "(none provided)"}
570
 
571
+ Vision summary:
572
+ {vision_summary or "(no image or no usable visual details)"}
573
 
574
+ Postal code:
575
+ {postal_code or "(not provided)"}
 
 
 
 
 
576
 
577
+ Notes:
578
+ - If relevant, consider calling get_drug_info with this combined text.
579
+ - If postal_code is provided, consider get_region_context and get_nearby_facilities.
580
+ - You may call get_recalls and get_wait_times_awareness for additional safety/awareness.
581
  """
582
+ result = agent.invoke({"input": user_input})
583
+ # AgentExecutor returns dict with 'output' key by default
584
+ return result.get("output", str(result))
 
 
 
 
 
 
 
 
 
 
 
 
 
585
 
586
 
587
  # =========================
 
589
  # =========================
590
 
591
  st.title("CareCall AI (Canada)")
592
+ st.caption("LangChain agent • Vision + Voice + Postal-aware Non-diagnostic health info")
 
 
 
593
 
594
  st.markdown(
595
  """
596
+ This demo:
597
+ - Lets you upload a **photo** (rash, nail, medication, letter),
598
+ - Add a **voice note** or **typed description**,
599
+ - Optionally enter your **Canadian postal code**,
600
+ - Then an **agentic AI** calls tools (open data, nearby clinics, etc.) and returns:
601
+
602
+ - A clear **Primary Recommended Pathway** (including home care when safe),
603
+ - Generic **home-care guidance**,
604
+ - **Clinic / Telehealth / ER** triggers,
605
+ - **Nearby facilities** & official resource pointers.
606
+
607
+ It does **not** diagnose or prescribe.
608
  """
609
  )
610
 
611
+ if not OPENAI_API_KEY:
612
+ st.stop()
613
+
614
+ # Postal code
615
  st.markdown("### 1. Postal code (optional, Canada only)")
616
  postal_code = st.text_input(
617
  "Canadian postal code (e.g., M5V 2T6). Leave blank for general guidance.",
618
  max_chars=7,
619
  )
620
 
621
+ # Image
622
  st.markdown("### 2. Upload an image (optional)")
623
  image_file = st.file_uploader(
624
  "Upload a photo (JPG/PNG). Avoid highly identifying or explicit content.",
625
  type=["jpg", "jpeg", "png"],
626
  accept_multiple_files=False,
627
  )
 
628
  image_bytes = None
629
  if image_file is not None:
630
  try:
 
631
  img = Image.open(image_file).convert("RGB")
632
  st.image(img, caption="Image received", use_container_width=True)
633
  buf = io.BytesIO()
 
637
  st.warning(f"Could not read that image: {e}")
638
  image_bytes = None
639
 
640
+ # Audio
641
  st.markdown("### 3. Add a voice note (optional)")
642
  audio_file = st.file_uploader(
643
  "Upload a short voice note (wav/mp3/m4a).",
 
645
  accept_multiple_files=False,
646
  )
647
 
648
+ # Text
649
  st.markdown("### 4. Or type a short description")
650
  user_text = st.text_area(
651
+ "Describe what's going on:",
652
+ placeholder='Example: "Big toenail is sore and a bit red for 5 days when wearing tight shoes, no fever, I can walk okay."',
653
  height=120,
654
  )
655
 
656
+ # Run
657
  if st.button("Run CareCall Agent"):
658
+ with st.spinner("Running vision + voice + tools + agentic reasoning..."):
659
+
660
+ # Vision summary
661
+ vision_summary = call_vision_summarizer(image_bytes) if image_bytes else ""
662
+
663
+ # Transcription
664
+ voice_text = call_asr(audio_file) if audio_file is not None else ""
665
+
666
+ # Combine narrative
667
+ narrative_parts = []
668
+ if user_text.strip():
669
+ narrative_parts.append("Typed: " + user_text.strip())
670
+ if voice_text.strip():
671
+ narrative_parts.append("Voice (transcribed): " + voice_text.strip())
672
+ narrative = "\n".join(narrative_parts)
673
+
674
+ response = run_agent(
675
+ narrative=narrative,
676
+ vision_summary=vision_summary,
677
+ postal_code=postal_code or "",
678
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
 
680
+ st.markdown("### CareCall AI Summary (Informational Only)")
681
+ st.write(response)