| | import os |
| | import io |
| | import csv |
| | import re |
| | import base64 |
| | import textwrap |
| |
|
| | import requests |
| | import streamlit as st |
| | from PIL import Image |
| | from audio_recorder_streamlit import audio_recorder |
| |
|
| | |
| | |
| | |
| |
|
| | st.set_page_config( |
| | page_title="CareCall AI (Canada)", |
| | page_icon="🩺", |
| | layout="centered" |
| | ) |
| |
|
| | |
| |
|
| | OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") |
| | if not OPENROUTER_API_KEY: |
| | st.warning("Set OPENROUTER_API_KEY in your Space secrets (OpenRouter) to enable AI features.") |
| | st.stop() |
| |
|
| | HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HF_API_TOKEN") |
| |
|
| | OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" |
| |
|
| | |
| | VISION_MODEL = os.getenv("VISION_MODEL", "nvidia/nemotron-nano-12b-v2-vl:free") |
| | REASONING_MODEL = os.getenv("REASONING_MODEL", "nvidia/nemotron-nano-12b-v2-vl:free") |
| |
|
| | |
| | HF_WHISPER_URL = os.getenv( |
| | "HF_WHISPER_URL", |
| | "https://router.huggingface.co/hf-inference/models/openai/whisper-large-v3", |
| | ) |
| |
|
| | DPD_BASE_URL = "https://health-products.canada.ca/api/drug/drugproduct" |
| | RECALLS_FEED_URL = os.getenv( |
| | "RECALLS_FEED_URL", |
| | "https://recalls-rappels.canada.ca/static-data/items-en.json" |
| | ) |
| | WAIT_TIMES_INFO_URL = "https://www.cihi.ca/en/topics/access-and-wait-times" |
| |
|
| | GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_API_KEY") |
| | USER_AGENT = "carecall-ai-places-city/1.0" |
| |
|
| | PROVINCES = { |
| | "AB": "Alberta", |
| | "BC": "British Columbia", |
| | "MB": "Manitoba", |
| | "NB": "New Brunswick", |
| | "NL": "Newfoundland and Labrador", |
| | "NS": "Nova Scotia", |
| | "NT": "Northwest Territories", |
| | "NU": "Nunavut", |
| | "ON": "Ontario", |
| | "PE": "Prince Edward Island", |
| | "QC": "Quebec", |
| | "SK": "Saskatchewan", |
| | "YT": "Yukon", |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | st.markdown( |
| | """ |
| | <style> |
| | .block-container { |
| | padding-top: 1.2rem; |
| | padding-bottom: 1.2rem; |
| | max-width: 480px; |
| | } |
| | |
| | .step-indicator { |
| | display: flex; |
| | justify-content: center; |
| | gap: 8px; |
| | margin-bottom: 0.8rem; |
| | background: transparent !important; |
| | box-shadow: none !important; |
| | padding: 0 !important; |
| | border-radius: 0 !important; |
| | border: none !important; |
| | } |
| | |
| | .step-dot { |
| | width: 8px; |
| | height: 8px; |
| | border-radius: 999px; |
| | background-color: #e0e0e0; |
| | } |
| | |
| | .step-dot.active { |
| | width: 22px; |
| | background-color: #2563eb; |
| | } |
| | |
| | .card { |
| | background-color: #ffffff; |
| | padding: 0.6rem 1.1rem 1.0rem 1.1rem; |
| | border-radius: 18px; |
| | box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); |
| | margin-bottom: 0.9rem; |
| | } |
| | |
| | .label-soft { |
| | font-size: 0.85rem; |
| | color: #6b7280; |
| | margin-bottom: 0.25rem; |
| | } |
| | |
| | .title-xs { |
| | font-size: 1.05rem; |
| | font-weight: 600; |
| | margin-bottom: 0.4rem; |
| | } |
| | |
| | .summary-title { |
| | font-size: 1.05rem; |
| | font-weight: 600; |
| | margin-bottom: 0.4rem; |
| | color: #111827; |
| | } |
| | |
| | textarea { |
| | border-radius: 14px !important; |
| | } |
| | </style> |
| | """, |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | |
| | |
| | |
| |
|
| | def safe_get_json(url, params=None, method="GET", data=None, timeout=8): |
| | try: |
| | headers = {"User-Agent": USER_AGENT} |
| | if method == "GET": |
| | r = requests.get(url, params=params, headers=headers, timeout=timeout) |
| | else: |
| | r = requests.post(url, data=data, headers=headers, timeout=timeout) |
| | if r.ok: |
| | return r.json() |
| | except Exception: |
| | return None |
| | return None |
| |
|
| |
|
| | def shorten(text, max_chars=260): |
| | if not text: |
| | return "" |
| | t = text.strip() |
| | if len(t) <= max_chars: |
| | return t |
| | return t[: max_chars - 3].rstrip() + "..." |
| |
|
| |
|
| | def extract_phone(text: str) -> str: |
| | if not text: |
| | return "" |
| | m = re.search(r"(\(?\d{3}\)?[\s\-\.]?\d{3}[\s\-\.]?\d{4})", text) |
| | return m.group(1) if m else "" |
| |
|
| |
|
| | def format_opening_hours(opening_hours: dict) -> str: |
| | if not opening_hours: |
| | return "" |
| | pieces = [] |
| | if opening_hours.get("open_now") is True: |
| | pieces.append("Open now") |
| | elif opening_hours.get("open_now") is False: |
| | pieces.append("Currently closed") |
| | weekday = opening_hours.get("weekday_text") or [] |
| | if weekday: |
| | pieces.append("; ".join(weekday[:2])) |
| | return " | ".join(pieces) |
| |
|
| | |
| | |
| | |
| |
|
| | def load_city_options(path: str = "canadacities.csv"): |
| | options = set() |
| | if not os.path.exists(path): |
| | return [] |
| |
|
| | try: |
| | with open(path, "r", encoding="utf-8") as f: |
| | reader = csv.reader(f) |
| | header = next(reader, None) |
| | if not header: |
| | return [] |
| | lower = [h.strip().lower() for h in header] |
| |
|
| | def find_col(cands): |
| | for i, name in enumerate(lower): |
| | for c in cands: |
| | if c in name: |
| | return i |
| | return None |
| |
|
| | city_idx = find_col(["city", "place"]) |
| | prov_idx = find_col(["province", "prov", "state"]) |
| |
|
| | if city_idx is None or prov_idx is None: |
| | return [] |
| |
|
| | for row in reader: |
| | if not row: |
| | continue |
| | if len(row) <= max(city_idx, prov_idx): |
| | continue |
| | city = row[city_idx].strip() |
| | prov = row[prov_idx].strip() |
| | if not city or not prov: |
| | continue |
| | prov_short = prov.strip() |
| | if len(prov_short) > 3: |
| | for code, full in PROVINCES.items(): |
| | if prov_short.lower().startswith(full.lower()): |
| | prov_short = code |
| | break |
| | label = f"{city}, {prov_short}" |
| | options.add(label) |
| | except Exception: |
| | return [] |
| |
|
| | return sorted(options) |
| |
|
| |
|
| | CITY_OPTIONS = load_city_options() |
| | if not CITY_OPTIONS: |
| | st.warning( |
| | "Could not load cities from canadacities.csv. " |
| | "Autocomplete list will be empty until that file is available." |
| | ) |
| |
|
| |
|
| | def split_city_label(label: str): |
| | if not label: |
| | return None, None |
| | parts = [p.strip() for p in label.split(",")] |
| | if len(parts) == 2: |
| | return parts[0], parts[1] |
| | return label.strip(), None |
| |
|
| | |
| | |
| | |
| |
|
| | def places_text_search(query: str): |
| | if not GOOGLE_MAPS_API_KEY: |
| | return None |
| | url = "https://maps.googleapis.com/maps/api/place/textsearch/json" |
| | params = { |
| | "key": GOOGLE_MAPS_API_KEY, |
| | "query": query, |
| | } |
| | return safe_get_json(url, params=params) |
| |
|
| |
|
| | def place_details(place_id: str): |
| | if not GOOGLE_MAPS_API_KEY or not place_id: |
| | return None |
| | url = "https://maps.googleapis.com/maps/api/place/details/json" |
| | params = { |
| | "key": GOOGLE_MAPS_API_KEY, |
| | "place_id": place_id, |
| | "fields": "formatted_phone_number,international_phone_number,opening_hours,website,url", |
| | } |
| | return safe_get_json(url, params=params) |
| |
|
| |
|
| | def search_places_in_city(city_label: str, main_query: str, max_results: int = 5): |
| | if not GOOGLE_MAPS_API_KEY: |
| | return [] |
| | q = f"{main_query} in {city_label}, Canada" |
| | data = places_text_search(q) |
| | if not data or "results" not in data: |
| | return [] |
| | facilities = [] |
| | for r in data.get("results", [])[:max_results]: |
| | name = r.get("name") |
| | address = r.get("formatted_address") |
| | place_id = r.get("place_id") |
| | if not name: |
| | continue |
| | phone = "" |
| | hours_str = "" |
| | maps_url = "" |
| | if place_id: |
| | det = place_details(place_id) |
| | if det and det.get("result"): |
| | res = det["result"] |
| | phone = ( |
| | res.get("formatted_phone_number") |
| | or res.get("international_phone_number") |
| | or "" |
| | ) |
| | hours_str = format_opening_hours(res.get("opening_hours") or {}) |
| | maps_url = res.get("url") or res.get("website") or "" |
| | if not maps_url: |
| | maps_url = ( |
| | "https://www.google.com/maps/search/?api=1&query=" |
| | + requests.utils.quote(name + " " + (address or city_label)) |
| | ) |
| | facilities.append( |
| | { |
| | "name": name, |
| | "address": address or city_label, |
| | "phone": phone, |
| | "hours": hours_str, |
| | "url": maps_url, |
| | } |
| | ) |
| | return facilities |
| |
|
| |
|
| | def tool_list_facilities_places(city_label: str) -> str: |
| | if not city_label: |
| | return "No city selected; cannot list local facilities." |
| |
|
| | if not GOOGLE_MAPS_API_KEY: |
| | return ( |
| | "Google Maps / Places API key is not configured. " |
| | "Set GOOGLE_MAPS_API_KEY (or GOOGLE_API_KEY) to enable local facility listings." |
| | ) |
| |
|
| | er_list = search_places_in_city(city_label, "emergency department", max_results=5) |
| | clinic_list = search_places_in_city( |
| | city_label, |
| | "walk-in clinic OR urgent care OR family practice clinic", |
| | max_results=8, |
| | ) |
| |
|
| | lines = [] |
| | if er_list: |
| | lines.append(f"Emergency Departments / Hospitals near {city_label}:") |
| | for f in er_list: |
| | s = f"- {f['name']} — {f['address']}" |
| | if f["phone"]: |
| | s += f" — Phone: {f['phone']}" |
| | if f["hours"]: |
| | s += f" — Hours: {f['hours']}" |
| | if f["url"]: |
| | s += f" — {f['url']}" |
| | lines.append(s) |
| | lines.append("") |
| | if clinic_list: |
| | lines.append(f"Walk-in / Urgent Care / Family Clinics near {city_label}:") |
| | for f in clinic_list: |
| | s = f"- {f['name']} — {f['address']}" |
| | if f["phone"]: |
| | s += f" — Phone: {f['phone']}" |
| | if f["hours"]: |
| | s += f" — Hours: {f['hours']}" |
| | if f["url"]: |
| | s += f" — {f['url']}" |
| | lines.append(s) |
| |
|
| | if not lines: |
| | return ( |
| | f"No facilities found via Places API for {city_label}. " |
| | "Advise user to open Google Maps and search 'emergency department' or 'walk-in clinic' in their city." |
| | ) |
| |
|
| | lines.append( |
| | "Note: Facility details (distance, hours, availability) can change. " |
| | "Users must confirm on the facility or provincial website / Google Maps." |
| | ) |
| | return "\n".join(lines) |
| |
|
| |
|
| | def tool_region_context_city(city_label: str) -> str: |
| | if not city_label: |
| | return "No city selected; provide Canada-wide guidance." |
| | city, prov = split_city_label(city_label) |
| | if not city: |
| | return "City not recognized; use general Canadian guidance." |
| | prov_name = None |
| | if prov and prov in PROVINCES: |
| | prov_name = PROVINCES[prov] |
| | elif prov: |
| | prov_name = prov |
| | if prov_name: |
| | tele = f"In {prov_name}, users can contact the provincial Telehealth/811 nurse line (where available) for real-time guidance." |
| | else: |
| | tele = "Users can contact their provincial/territorial Telehealth or nurse advice line where available." |
| | wait = ( |
| | f"Average system-level wait-time indicators (not real-time) may be available via CIHI or {prov_name or 'provincial'} dashboards: " |
| | f"{WAIT_TIMES_INFO_URL}." |
| | ) |
| | return f"User location: {city_label}.\n{tele}\n{wait}" |
| |
|
| |
|
| | def tool_hqontario_context_city(city_label: str) -> str: |
| | _, prov = split_city_label(city_label) |
| | if prov == "ON": |
| | return ( |
| | "For Ontario ED locations and typical wait times by region, use Ontario Health's tool: " |
| | "https://www.hqontario.ca/System-Performance/Time-Spent-in-Emergency-Departments." |
| | ) |
| | return "" |
| |
|
| | |
| | |
| | |
| |
|
| | def tool_lookup_drug_products(text: str) -> str: |
| | if not text: |
| | return "No text provided for medication lookup." |
| |
|
| | tokens = [] |
| | for tok in text.split(): |
| | clean = "".join(ch for ch in tok if ch.isalnum()) |
| | if len(clean) > 3 and clean[0].isupper(): |
| | tokens.append(clean) |
| | candidates = sorted(set(tokens))[:10] |
| | if not candidates: |
| | return "No likely medication names detected for Drug Product Database lookup." |
| |
|
| | found = [] |
| | for name in candidates[:5]: |
| | params = {"lang": "en", "type": "json", "brandname": name} |
| | data = safe_get_json(DPD_BASE_URL, params=params) |
| | if not data: |
| | continue |
| | uniq = { |
| | (it.get("brand_name") or it.get("brandname") or name) |
| | for it in data[:3] |
| | } |
| | uniq = {u for u in uniq if u} |
| | if uniq: |
| | found.append( |
| | f"- Health Canada DPD has entries related to '{name}' " |
| | f"(e.g., {', '.join(sorted(uniq))})." |
| | ) |
| |
|
| | if not found: |
| | return ( |
| | "No strong matches found in the Drug Product Database for detected terms. " |
| | "Users should confirm medications directly in the official DPD." |
| | ) |
| |
|
| | found.append( |
| | "For definitive medication information, consult the official Health Canada Drug Product Database." |
| | ) |
| | return "\n".join(found) |
| |
|
| |
|
| | def tool_get_recent_recalls_snippet() -> str: |
| | data = safe_get_json(RECALLS_FEED_URL) |
| | items = [] |
| | if isinstance(data, list): |
| | items = data[:5] |
| | elif isinstance(data, dict): |
| | items = (data.get("items") or data.get("results") or [])[:5] |
| |
|
| | if not items: |
| | return ( |
| | "Unable to load recalls. See the Government of Canada Recalls and Safety Alerts website." |
| | ) |
| |
|
| | lines = ["Recent recalls & safety alerts (snapshot):"] |
| | for item in items: |
| | title = ( |
| | item.get("title") |
| | or item.get("english_title") |
| | or item.get("name") |
| | or "Recall / Alert" |
| | ) |
| | date = item.get("date_published") or item.get("date") or "" |
| | category = item.get("category") or item.get("type") or "" |
| | lines.append(f"- {shorten(title)} ({category}, {date})") |
| | lines.append("For details, see the official Recalls and Safety Alerts portal.") |
| | return "\n".join(lines) |
| |
|
| |
|
| | def tool_get_wait_times_awareness() -> str: |
| | return textwrap.dedent(f""" |
| | Use wait-time info conceptually: |
| | - CIHI/provincial dashboards show average waits (not real-time guarantees). |
| | - Severe or red-flag symptoms should go to ER/911 regardless. |
| | Reference: {WAIT_TIMES_INFO_URL} |
| | """).strip() |
| |
|
| | |
| | |
| | |
| |
|
| | def call_openrouter_chat(model: str, messages, temperature: float = 0.3): |
| | headers = { |
| | "Authorization": f"Bearer {OPENROUTER_API_KEY}", |
| | "Content-Type": "application/json", |
| | } |
| |
|
| | app_url = os.getenv("APP_URL", "").strip() |
| | if app_url: |
| | headers["HTTP-Referer"] = app_url |
| | headers["X-Title"] = "CareCall AI (Canada)" |
| |
|
| | payload = { |
| | "model": model, |
| | "messages": messages, |
| | "temperature": float(temperature), |
| | } |
| |
|
| | try: |
| | r = requests.post(OPENROUTER_URL, headers=headers, json=payload, timeout=90) |
| | if r.status_code != 200: |
| | text_snippet = r.text[:300].replace("\n", " ") |
| | return f"(Model call error: {r.status_code} — {text_snippet})" |
| | data = r.json() |
| | choices = data.get("choices") |
| | if not choices: |
| | return "(Model call error: empty response.)" |
| | msg = choices[0].get("message", {}) |
| | content = msg.get("content", "") |
| | return content.strip() if isinstance(content, str) else str(content) |
| | except Exception as e: |
| | return f"(Model call unavailable: {e})" |
| |
|
| | |
| | |
| | |
| |
|
| | def call_vision_summarizer(image_bytes: bytes) -> str: |
| | if not image_bytes: |
| | return "" |
| | b64 = base64.b64encode(image_bytes).decode("utf-8") |
| |
|
| | prompt = """ |
| | You are a cautious Canadian health-information assistant. |
| | |
| | Your job is to LOOK CLOSELY at the image and produce a detailed, objective visual assessment ONLY. |
| | Do not guess internal causes. Do not diagnose. Do not recommend specific drugs. |
| | |
| | Describe clearly and systematically: |
| | |
| | 1. Skin/area colour and any colour changes. |
| | 2. Swelling or asymmetry (none / mild / moderate / severe). |
| | 3. Visible borders or edges (sharp, blurred, irregular). |
| | 4. Surface changes (dry, cracked, shiny, blistered, open wound, scab, rash pattern, etc.). |
| | 5. Size/extent in plain-language terms (e.g., "covers a small patch", "covers most of the visible toe/hand/area"). |
| | 6. Any visible discharge, bleeding, or crusting. |
| | 7. Any objects/devices/dressings in view (e.g., ring, bandage, splint). |
| | 8. Any visually concerning features that might suggest higher risk |
| | (e.g., blackened tissue, spreading redness, deep open wound, thick pus) — describe ONLY what is seen. |
| | |
| | Keep it about 120–180 words. |
| | Write as if supporting a separate triage system, not directly reassuring or diagnosing the patient. |
| | """ |
| |
|
| | messages = [ |
| | { |
| | "role": "user", |
| | "content": [ |
| | {"type": "text", "text": prompt}, |
| | { |
| | "type": "image_url", |
| | "image_url": {"url": f"data:image/jpeg;base64,{b64}"}, |
| | }, |
| | ], |
| | } |
| | ] |
| |
|
| | return call_openrouter_chat(VISION_MODEL, messages, temperature=0.2) |
| |
|
| | |
| | |
| | |
| |
|
| | def call_asr(audio_source) -> str: |
| | """ |
| | Use Hugging Face router + openai/whisper-large-v3 for transcription. |
| | - No local model load. |
| | - Returns transcript or "". |
| | - Shows friendly warnings, never raw HTTP/JSON in the text area. |
| | """ |
| | if not audio_source: |
| | return "" |
| |
|
| | if not HF_TOKEN: |
| | st.warning("Voice transcription is not configured. Please type your description.") |
| | return "" |
| |
|
| | |
| | if isinstance(audio_source, bytes): |
| | audio_bytes = audio_source |
| | else: |
| | audio_bytes = audio_source.read() |
| |
|
| | headers = { |
| | "Authorization": f"Bearer {HF_TOKEN}", |
| | |
| | "Content-Type": "audio/wav", |
| | } |
| |
|
| | try: |
| | resp = requests.post( |
| | HF_WHISPER_URL, |
| | headers=headers, |
| | data=audio_bytes, |
| | timeout=120, |
| | ) |
| | except Exception as e: |
| | print(f"Whisper HF router request error: {e}") |
| | st.warning("Voice transcription is temporarily unavailable. Please type your description.") |
| | return "" |
| |
|
| | if resp.status_code != 200: |
| | |
| | print(f"Whisper HF router HTTP {resp.status_code}: {resp.text[:300]}") |
| | st.warning("We couldn't transcribe that recording. Please type or edit your description.") |
| | return "" |
| |
|
| | try: |
| | data = resp.json() |
| | except Exception as e: |
| | print(f"Whisper HF router JSON error: {e}, body: {resp.text[:300]}") |
| | st.warning("We couldn't transcribe that recording. Please type or edit your description.") |
| | return "" |
| |
|
| | |
| | text_val = "" |
| |
|
| | if isinstance(data, dict): |
| | |
| | if "error" in data: |
| | print(f"Whisper HF router error field: {data['error']}") |
| | text_val = data.get("text") or data.get("generated_text") or "" |
| | elif isinstance(data, list) and data: |
| | |
| | first = data[0] |
| | if isinstance(first, dict): |
| | text_val = first.get("text") or first.get("generated_text") or "" |
| | elif isinstance(first, str): |
| | text_val = first |
| |
|
| | text_val = (text_val or "").strip() |
| |
|
| | if not text_val: |
| | st.warning("We couldn't clearly understand that recording. Please check or type your description.") |
| | return text_val |
| |
|
| | |
| | |
| | |
| |
|
| | def call_reasoning_agent( |
| | narrative: str, |
| | vision_summary: str = "", |
| | city_label: str = "", |
| | dpd_context: str = "", |
| | recalls_context: str = "", |
| | wait_awareness_context: str = "", |
| | region_context: str = "", |
| | facilities_context: str = "", |
| | hqontario_context: str = "", |
| | ) -> str: |
| | system_prompt = """ |
| | You are CareCall AI, an agentic Canadian health information assistant. |
| | |
| | You MUST base your answer only on the structured context provided: |
| | - User narrative and vision_summary |
| | - City_label or region info |
| | - dpd_context (Health Canada Drug Product Database lookups) |
| | - recalls_context (recent recalls & safety alerts) |
| | - wait_awareness_context (general notes on wait-time data) |
| | - region_context (province / Telehealth context) |
| | - facilities_context (local facilities from trusted APIs) |
| | - hqontario_context (Ontario-specific ED tool when present) |
| | |
| | You are an INFORMATIONAL tool, not a clinician. |
| | |
| | HARD SAFETY RULES |
| | ----------------- |
| | 1. Do NOT give a medical diagnosis. |
| | 2. Do NOT prescribe or specify exact medication doses. |
| | 3. Do NOT claim to provide real-time wait times. |
| | 4. Do NOT contradict emergency advice from standard red-flag criteria. |
| | 5. DO NOT invent or guess facility names. |
| | - You may ONLY list specific hospitals/clinics/ERs that appear LITERALLY in: |
| | - facilities_context, or |
| | - hqontario_context. |
| | - If facilities_context says no specific local facilities were found, you MUST NOT make up examples. |
| | Instead, direct users to Google Maps or official provincial "find a clinic/ER" tools. |
| | 6. Keep the whole answer under about 350 words. |
| | 7. Use clear, neutral, plain language that a non-clinician can understand. |
| | |
| | IMAGE USE (VERY IMPORTANT) |
| | -------------------------- |
| | - If a vision_summary is provided, you MUST treat it as a primary source of information. |
| | - Cross-check the user narrative against the vision_summary. |
| | - If the image description suggests more serious concern (e.g., black tissue, deep open wound, |
| | marked swelling, spreading redness, thick pus, obvious deformity), escalate the triage tier |
| | even if the user's text sounds mild. |
| | - If there is a conflict between what is described and what is seen, favour caution and explain that |
| | visually it appears safer to seek a higher level of assessment. |
| | - You still MUST NOT assign a diagnosis or name specific diseases based on the image. |
| | |
| | TRIAGE LOGIC (INTERNAL) |
| | ----------------------- |
| | You must internally choose EXACTLY ONE primary pathway, but DO NOT show letters or labels to the user. |
| | |
| | (1) "No current action needed (you appear well, continue normal care)": |
| | - Use when the user clearly reports feeling well or "perfect", with no symptoms or concerns. |
| | - You may gently remind them of general wellness habits. |
| | - Phrase as "based on what you've shared, you appear well"; this is NOT a diagnosis. |
| | |
| | (2) "Home care reasonable (self-care + monitor)": |
| | - Use when symptoms are: |
| | - mild, |
| | - short-lived, |
| | - non-progressive, |
| | - and NOT limiting normal activities. |
| | - Provide clear, safe self-care suggestions and "watch for these changes" rules. |
| | |
| | (3) "Pharmacist / Walk-in clinic / Family doctor or Telehealth recommended": |
| | - Use when: |
| | - symptoms are persistent or recurrent, |
| | - moderate in intensity, |
| | - affecting function, |
| | - or there is diagnostic uncertainty but NOT clear emergency. |
| | - Explain why an in-person/phone assessment is appropriate. |
| | |
| | (4) "Go to Emergency / Call 911 now": |
| | - Use when there are red-flag features: |
| | - severe or rapidly worsening pain, |
| | - major trauma, |
| | - uncontrolled bleeding, |
| | - chest pain, trouble breathing, |
| | - stroke signs, |
| | - signs of sepsis or systemic illness, |
| | - significant numbness/weakness, |
| | - concerning changes after surgery or known serious conditions, |
| | - or visually very concerning findings on the image. |
| | - Be direct and clear. |
| | |
| | OUTPUT FORMAT (WHAT USER SEES) |
| | ------------------------------ |
| | 1. **Primary Recommended Pathway** |
| | - One brief sentence stating the recommendation WITHOUT internal labels. |
| | |
| | 2. Conditional sections depending on your chosen pathway: |
| | |
| | If you chose "No current action needed": |
| | - **Home Care / Wellness** |
| | - **When to seek medical advice** |
| | - **When this is an Emergency (ER/911)** |
| | - **Local & Official Resources** |
| | - **Important Disclaimer** |
| | |
| | If you chose "Home care reasonable (self-care + monitor)": |
| | - **Home Care** |
| | - **When to see a Pharmacist / Clinic / Telehealth** |
| | - **When this is an Emergency (ER/911)** |
| | - **Local & Official Resources** |
| | - **Important Disclaimer** |
| | |
| | If you chose "Pharmacist / Walk-in clinic / Family doctor or Telehealth recommended": |
| | - **When to see a Pharmacist / Clinic / Telehealth** |
| | - **When this is an Emergency (ER/911)** |
| | - **Local & Official Resources** |
| | - **Important Disclaimer** |
| | |
| | If you chose "Go to Emergency / Call 911 now": |
| | - **When this is an Emergency (ER/911)** |
| | - **Local & Official Resources** |
| | - **Important Disclaimer** |
| | - Do NOT include home-care reassurance. |
| | |
| | LOCAL & OFFICIAL RESOURCES |
| | -------------------------- |
| | - If facilities_context lists facilities: |
| | - Summarize them as bullet points: |
| | - name, |
| | - city or address, |
| | - phone (if present), |
| | - hours/“open now” (if present), |
| | - link/URL. |
| | - Do NOT alter names or add new ones. |
| | - If facilities_context indicates that no specific facilities were found: |
| | - Say that directly. |
| | - Instruct users to: |
| | - use Google Maps with their city, |
| | - or use official provincial "find a clinic/ER" / Telehealth tools. |
| | - Do NOT fabricate facility names. |
| | - Include hqontario_context when relevant (for Ontario users). |
| | - Optionally remind users: |
| | - Health Canada Drug Product Database and Recalls site for medication/product safety. |
| | - CIHI/provincial dashboards for general (non-real-time) wait-time information. |
| | |
| | TONE |
| | ---- |
| | - Calm, supportive, non-alarming. |
| | - No legalese walls of text; keep it tight and readable. |
| | - Always remind: this is informational, not a diagnosis; if worried, they should seek real care. |
| | """ |
| |
|
| | user_message = f""" |
| | User narrative: |
| | {narrative or "(none provided)"} |
| | |
| | Vision summary: |
| | {vision_summary or "(none)"} |
| | |
| | City/region: |
| | {city_label or "(not provided)"} |
| | |
| | DPD context: |
| | {dpd_context or "(none)"} |
| | |
| | Recalls context: |
| | {recalls_context or "(none)"} |
| | |
| | Wait awareness: |
| | {wait_awareness_context or "(none)"} |
| | |
| | Region info: |
| | {region_context or "(none)"} |
| | |
| | Facilities info: |
| | {facilities_context or "(none)"} |
| | |
| | HQ Ontario info: |
| | {hqontario_context or "(not applicable)"} |
| | """ |
| |
|
| | messages = [ |
| | {"role": "system", "content": system_prompt}, |
| | {"role": "user", "content": user_message}, |
| | ] |
| |
|
| | return call_openrouter_chat(REASONING_MODEL, messages, temperature=0.3) |
| |
|
| | |
| | |
| | |
| |
|
| | if "step" not in st.session_state: |
| | st.session_state.step = 1 |
| | if "city_label" not in st.session_state: |
| | st.session_state.city_label = "" |
| | if "image_bytes" not in st.session_state: |
| | st.session_state.image_bytes = None |
| | if "audio_bytes" not in st.session_state: |
| | st.session_state.audio_bytes = None |
| | if "last_audio" not in st.session_state: |
| | st.session_state.last_audio = None |
| | if "user_text" not in st.session_state: |
| | st.session_state.user_text = "" |
| | if "final_answer" not in st.session_state: |
| | st.session_state.final_answer = "" |
| |
|
| |
|
| | def go_to_step(n: int): |
| | st.session_state.step = n |
| |
|
| |
|
| | def render_steps(): |
| | dots = [] |
| | for i in [1, 2, 3]: |
| | cls = "step-dot active" if st.session_state.step == i else "step-dot" |
| | dots.append(f'<div class="{cls}"></div>') |
| | st.markdown( |
| | f'<div class="step-indicator">{"".join(dots)}</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | |
| | |
| | |
| |
|
| | st.markdown( |
| | """ |
| | <div class="title-xs">CareCall AI (Canada)</div> |
| | <div class="label-soft"> |
| | Simple 3-step check-in for non-emergency situations. Widely accepted Canadian health guidelines, no CTAS/eCTAS. |
| | Not a diagnosis. Not for 911 emergencies. Zero Retention - No info saved. |
| | </div> |
| | """, |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | render_steps() |
| |
|
| | |
| | |
| | |
| |
|
| | if st.session_state.step == 1: |
| | st.markdown('<div class="card">', unsafe_allow_html=True) |
| | st.markdown( |
| | '<div class="summary-title">Step 1 · Where & what are we looking at?</div>', |
| | unsafe_allow_html=True, |
| | ) |
| | st.markdown( |
| | '<div class="label-soft">Select your city and (optionally) take a clear photo of the area you are worried about.</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | city = st.selectbox( |
| | "Your city / town", |
| | options=[""] + CITY_OPTIONS, |
| | index=0, |
| | help="Used only to suggest nearby care options.", |
| | ) |
| | st.session_state.city_label = city |
| |
|
| | photo = st.camera_input( |
| | "Tap to open camera (optional)", |
| | label_visibility="visible", |
| | ) |
| |
|
| | if photo is not None: |
| | try: |
| | img = Image.open(photo).convert("RGB") |
| | buf = io.BytesIO() |
| | img.save(buf, format="JPEG") |
| | st.session_state.image_bytes = buf.getvalue() |
| | st.success("Photo captured.") |
| | except Exception as e: |
| | st.warning(f"Could not read that image: {e}") |
| | st.session_state.image_bytes = None |
| |
|
| | col1, col2 = st.columns(2) |
| | with col1: |
| | if st.button("Next", use_container_width=True): |
| | go_to_step(2) |
| | st.rerun() |
| | with col2: |
| | if st.button("Skip photo", use_container_width=True): |
| | st.session_state.image_bytes = None |
| | go_to_step(2) |
| | st.rerun() |
| |
|
| | st.markdown("</div>", unsafe_allow_html=True) |
| |
|
| | |
| | |
| | |
| |
|
| | elif st.session_state.step == 2: |
| | st.markdown('<div class="card">', unsafe_allow_html=True) |
| | st.markdown( |
| | '<div class="summary-title">Step 2 · Tell us what is happening</div>', |
| | unsafe_allow_html=True, |
| | ) |
| | st.markdown( |
| | '<div class="label-soft">Use the mic to describe your concern, or type instead. ' |
| | 'We auto-fill the text box with your recording so you can review and edit.</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | st.markdown('<div class="label-soft">Speak (optional)</div>', unsafe_allow_html=True) |
| |
|
| | |
| | audio_bytes = audio_recorder( |
| | text="Tap to record", |
| | recording_color="#ef4444", |
| | neutral_color="#e5e7eb", |
| | icon_name="microphone", |
| | icon_size="1.3x", |
| | ) |
| |
|
| | |
| | if audio_bytes: |
| | st.session_state.audio_bytes = audio_bytes |
| | if st.session_state.last_audio != audio_bytes: |
| | st.success("Voice note captured. Transcribing...") |
| | transcript = call_asr(audio_bytes) |
| | if transcript: |
| | st.session_state.user_text = transcript |
| | st.session_state.last_audio = audio_bytes |
| |
|
| | |
| | user_text = st.text_area( |
| | "Or type / edit your description here", |
| | value=st.session_state.user_text, |
| | height=120, |
| | placeholder='Example: "Painful big toe for 3 days, mild redness, no fever, can walk but hurts in shoes."', |
| | ) |
| | st.session_state.user_text = user_text |
| |
|
| | st.markdown( |
| | '<div class="label-soft">When you are ready, tap below to get your one recommended pathway.</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | |
| | col1, col2 = st.columns(2) |
| | with col1: |
| | back_clicked = st.button( |
| | "Back", |
| | use_container_width=True, |
| | key="step2_back", |
| | ) |
| | with col2: |
| | go_clicked = st.button( |
| | "Get my recommendation", |
| | use_container_width=True, |
| | key="step2_go", |
| | ) |
| |
|
| | |
| | if back_clicked: |
| | go_to_step(1) |
| | st.rerun() |
| |
|
| | |
| | if go_clicked: |
| | spinner_placeholder = st.empty() |
| | with spinner_placeholder, st.spinner("Analyzing.Might take a minute..."): |
| | image_bytes = st.session_state.image_bytes |
| | city_label = (st.session_state.city_label or "").strip() |
| |
|
| | vision_summary = call_vision_summarizer(image_bytes) if image_bytes else "" |
| | narrative_text = st.session_state.user_text.strip() |
| |
|
| | combined_for_drugs = " ".join( |
| | x for x in [narrative_text, vision_summary] if x |
| | ) |
| |
|
| | dpd_context = ( |
| | tool_lookup_drug_products(combined_for_drugs) |
| | if combined_for_drugs |
| | else "No medication context." |
| | ) |
| | recalls_context = tool_get_recent_recalls_snippet() |
| | wait_awareness_context = tool_get_wait_times_awareness() |
| |
|
| | if city_label: |
| | facilities_context = tool_list_facilities_places(city_label) |
| | region_context = tool_region_context_city(city_label) |
| | hqontario_context = tool_hqontario_context_city(city_label) |
| | else: |
| | facilities_context = "No city selected; cannot list local facilities." |
| | region_context = "No city selected; use Canada-wide guidance." |
| | hqontario_context = "" |
| |
|
| | final_answer = call_reasoning_agent( |
| | narrative=narrative_text, |
| | vision_summary=vision_summary, |
| | city_label=city_label, |
| | dpd_context=dpd_context, |
| | recalls_context=recalls_context, |
| | wait_awareness_context=wait_awareness_context, |
| | region_context=region_context, |
| | facilities_context=facilities_context, |
| | hqontario_context=hqontario_context, |
| | ) |
| |
|
| | st.session_state.final_answer = final_answer |
| |
|
| | go_to_step(3) |
| | st.rerun() |
| |
|
| | st.markdown("</div>", unsafe_allow_html=True) |
| |
|
| | |
| | |
| | |
| |
|
| | elif st.session_state.step == 3: |
| | st.markdown('<div class="card">', unsafe_allow_html=True) |
| | st.markdown( |
| | '<div class="summary-title">Step 3 · Your guidance (informational only)</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | if not st.session_state.final_answer: |
| | st.info("No summary available yet. Please go back and complete steps 1 and 2.") |
| | else: |
| | st.markdown(st.session_state.final_answer) |
| |
|
| | st.markdown( |
| | '<div class="label-soft">CareCall AI does not replace a clinician or emergency services. ' |
| | 'If symptoms are severe, worsening, or worrying, seek in-person care or call emergency services.</div>', |
| | unsafe_allow_html=True, |
| | ) |
| |
|
| | col1, col2 = st.columns(2) |
| | with col1: |
| | if st.button("Back", use_container_width=True): |
| | go_to_step(2) |
| | st.rerun() |
| | with col2: |
| | if st.button("Start over", use_container_width=True): |
| | st.session_state.image_bytes = None |
| | st.session_state.audio_bytes = None |
| | st.session_state.last_audio = None |
| | st.session_state.user_text = "" |
| | st.session_state.final_answer = "" |
| | go_to_step(1) |
| | st.rerun() |
| |
|
| | st.markdown("</div>", unsafe_allow_html=True) |
| |
|