Update app.py
Browse files
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 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
# Health Canada Drug Product Database
|
| 28 |
DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct"
|
| 29 |
|
| 30 |
-
# Recalls & Safety Alerts JSON feed
|
| 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 (
|
| 37 |
WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times"
|
| 38 |
|
| 39 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 67 |
try:
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 107 |
)
|
| 108 |
|
| 109 |
telehealth_note = (
|
| 110 |
-
f"In {province}, advise the user to use the official provincial Telehealth or 811-style nurse
|
| 111 |
-
"for real-time
|
| 112 |
)
|
| 113 |
wait_note = (
|
| 114 |
-
"For non-urgent issues, they
|
| 115 |
-
f"for access
|
| 116 |
)
|
| 117 |
|
| 118 |
return (
|
| 119 |
-
f"User postal code
|
| 120 |
f"{telehealth_note}\n"
|
| 121 |
f"{wait_note}"
|
| 122 |
)
|
| 123 |
|
| 124 |
|
| 125 |
-
|
|
|
|
| 126 |
"""
|
| 127 |
-
|
| 128 |
-
|
| 129 |
"""
|
| 130 |
-
|
| 131 |
-
if not
|
| 132 |
return (
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
)
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
return
|
| 191 |
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
if not candidates:
|
| 199 |
-
return "No
|
| 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 |
-
|
| 213 |
-
|
| 214 |
-
if unique_brands:
|
| 215 |
found_lines.append(
|
| 216 |
-
f"-
|
| 217 |
-
f"(
|
| 218 |
)
|
| 219 |
|
| 220 |
if not found_lines:
|
| 221 |
return (
|
| 222 |
-
"No strong matches found
|
| 223 |
-
"Users should
|
| 224 |
)
|
| 225 |
|
| 226 |
found_lines.append(
|
| 227 |
-
"For definitive
|
| 228 |
)
|
| 229 |
return "\n".join(found_lines)
|
| 230 |
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 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.
|
| 248 |
-
"Recalls and Safety Alerts website for
|
| 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
|
| 265 |
)
|
| 266 |
return "\n".join(lines)
|
| 267 |
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
return textwrap.dedent(f"""
|
| 275 |
-
Use
|
| 276 |
-
- CIHI and
|
| 277 |
-
- These are
|
| 278 |
Guidance:
|
| 279 |
-
- For mild issues:
|
| 280 |
-
- For red
|
| 281 |
General reference: {WAIT_TIMES_INFO_URL}
|
| 282 |
""").strip()
|
| 283 |
|
| 284 |
|
| 285 |
# =========================
|
| 286 |
-
#
|
| 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
|
| 313 |
-
"-
|
| 314 |
-
"-
|
| 315 |
-
"-
|
| 316 |
"- If unclear: say it is not medically interpretable.\n"
|
| 317 |
-
"Never diagnose
|
| 318 |
)
|
| 319 |
|
| 320 |
try:
|
| 321 |
resp = client.chat.completions.create(
|
| 322 |
model=VISION_MODEL,
|
| 323 |
-
messages=[
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
"
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
narrative = "\n\n".join(narrative_parts)
|
| 389 |
|
| 390 |
system_prompt = """
|
| 391 |
You are CareCall AI, an agentic Canadian health information assistant.
|
| 392 |
|
| 393 |
-
|
| 394 |
-
-
|
| 395 |
-
-
|
| 396 |
-
-
|
| 397 |
-
-
|
| 398 |
-
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
"""
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 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 |
-
|
| 451 |
-
|
|
|
|
| 452 |
|
| 453 |
-
7) Nearby services & finder resources:
|
| 454 |
-
{nearby_services_context}
|
| 455 |
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
-
|
|
|
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 469 |
"""
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 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("
|
| 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 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
| 511 |
"""
|
| 512 |
)
|
| 513 |
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 551 |
st.markdown("### 4. Or type a short description")
|
| 552 |
user_text = st.text_area(
|
| 553 |
-
"Describe what
|
| 554 |
-
placeholder='Example: "
|
| 555 |
height=120,
|
| 556 |
)
|
| 557 |
|
| 558 |
-
# Run
|
| 559 |
if st.button("Run CareCall Agent"):
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 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 |
-
|
| 614 |
-
|
|
|
|
| 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)
|