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