Spaces:
Sleeping
Sleeping
| """ | |
| Medical Appointment Prep Assistant | |
| Main Gradio application entry point. | |
| """ | |
| import os | |
| import re | |
| import subprocess | |
| import threading | |
| import time | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from src.model import get_model, is_ollama_model_available, pull_ollama_model | |
| from src.model_catalog import ( | |
| canonical_backend, | |
| get_default_model_preset_id, | |
| resolve_model_settings, | |
| ) | |
| from src.prompts import ( | |
| build_questions_prompt, | |
| build_relevant_info_prompt, | |
| build_timeline_prompt, | |
| ) | |
| from src.processor import clean_section_output, validate_inputs | |
| from src.medications import ( | |
| filter_medication_choices, | |
| load_medication_choices, | |
| medication_index_summary, | |
| ) | |
| from config_loader import load_settings | |
| settings = load_settings() | |
| _GPU_RUNTIME_LOGGED = False | |
| _RECENT_TIMING_RE = re.compile( | |
| r"\b(today|tonight|this morning|this afternoon|this evening|yesterday|last night)\b", | |
| flags=re.IGNORECASE, | |
| ) | |
| _MODEL_WARMUP_LOCK = threading.Lock() | |
| _MODEL_WARMUP_STARTED = False | |
| DEFAULT_OUTPUT = "_Your prep report will appear here._" | |
| APPLE_CSS_PATH = "assets/apple.css" | |
| CUSTOM_UI_CSS_PATH = "assets/server.css" | |
| CUSTOM_UI_JS_PATH = "assets/server.js" | |
| CUSTOM_UI_ASSET_VERSION = "2026-06-14-project-links" | |
| ROBOT_IMAGE_PATH = "assets/assistant-robot.jpeg" | |
| APPLE_THEME = gr.themes.Soft() | |
| DEFAULT_CONTEXT_LENGTH = "8192" | |
| DEFAULT_TEMPERATURE = "0.3" | |
| DOWNLOAD_MODEL_BUTTON_LABEL = "Download Model" | |
| ROOT_DIR = Path(__file__).resolve().parent | |
| GITHUB_REPO_URL = "https://github.com/bisonbet/medical-appt-prep" | |
| FIELD_NOTES_URL = "https://huggingface.co/blog/bisonnetworking/medical-asst-prep-june26" | |
| VIDEO_DEMO_URL = "https://youtu.be/kRHPwQGC6aU" | |
| LINKEDIN_POST_URL = "https://www.linkedin.com/posts/timothy-champ_build-small-hackathon-starts-in-6-hours-activity-7471918851132207104-Wh7T?utm_source=share&utm_medium=member_desktop&rcm=ACoAAEBVNSMBksj_L93Oketlrphm5iSG9BGmBqU" | |
| THEME_MODE_HEAD = """ | |
| <script> | |
| (() => { | |
| const storageKey = "medical-appt-prep-theme"; | |
| const validModes = new Set(["system", "light", "dark"]); | |
| function currentMode() { | |
| const storedMode = localStorage.getItem(storageKey); | |
| return validModes.has(storedMode) ? storedMode : "system"; | |
| } | |
| function applyTheme(mode) { | |
| const resolvedMode = validModes.has(mode) ? mode : "system"; | |
| document.documentElement.dataset.themeMode = resolvedMode; | |
| if (resolvedMode === "system") { | |
| document.documentElement.dataset.theme = window.matchMedia("(prefers-color-scheme: dark)").matches | |
| ? "dark" | |
| : "light"; | |
| localStorage.removeItem(storageKey); | |
| } else { | |
| document.documentElement.dataset.theme = resolvedMode; | |
| localStorage.setItem(storageKey, resolvedMode); | |
| } | |
| document.querySelectorAll("[data-theme-option]").forEach((button) => { | |
| const isSelected = button.dataset.themeOption === resolvedMode; | |
| button.classList.toggle("selected", isSelected); | |
| button.setAttribute("aria-pressed", String(isSelected)); | |
| }); | |
| } | |
| function bindThemeSelector() { | |
| document.querySelectorAll("[data-theme-option]").forEach((button) => { | |
| if (button.dataset.boundThemeSelector === "true") { | |
| return; | |
| } | |
| button.dataset.boundThemeSelector = "true"; | |
| button.addEventListener("click", () => applyTheme(button.dataset.themeOption)); | |
| }); | |
| applyTheme(currentMode()); | |
| } | |
| function startThemeMode() { | |
| bindThemeSelector(); | |
| window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { | |
| if (currentMode() === "system") { | |
| applyTheme("system"); | |
| } | |
| }); | |
| new MutationObserver(bindThemeSelector).observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| } | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", startThemeMode); | |
| } else { | |
| startThemeMode(); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| def _context_choice_value(context_length: object) -> str: | |
| value = str(context_length or DEFAULT_CONTEXT_LENGTH) | |
| return value if value.isdigit() else DEFAULT_CONTEXT_LENGTH | |
| def _temperature_choice_value(temperature: object) -> str: | |
| value = str(temperature or DEFAULT_TEMPERATURE) | |
| try: | |
| float(value) | |
| except ValueError: | |
| return DEFAULT_TEMPERATURE | |
| return value | |
| def _default_model_preset_id() -> str: | |
| return get_default_model_preset_id(settings, settings.get("model", {}).get("backend", "ollama")) | |
| def _model_settings_for_selection( | |
| model_preset_id: str = "", | |
| context_length: str = DEFAULT_CONTEXT_LENGTH, | |
| temperature: str = DEFAULT_TEMPERATURE, | |
| ) -> dict: | |
| model_settings = resolve_model_settings(settings, model_preset_id or _default_model_preset_id()) | |
| model_settings.setdefault("model", {})["context_length"] = int(_context_choice_value(context_length)) | |
| model_settings.setdefault("model", {})["temperature"] = float(_temperature_choice_value(temperature)) | |
| return model_settings | |
| def _ollama_model_prompt(model_name: str) -> str: | |
| return ( | |
| f"**{model_name} is not downloaded.** Ask the app owner to download it with Ollama " | |
| f"before generating a report." | |
| ) | |
| def _with_zero_gpu(fn): | |
| """Request ZeroGPU only in hosted Spaces; keep local imports lightweight.""" | |
| if settings.get("app", {}).get("deployment") != "huggingface": | |
| return fn | |
| try: | |
| import spaces # type: ignore[import] | |
| except ImportError: | |
| return fn | |
| return spaces.GPU(duration=120)(fn) | |
| def _log_gpu_runtime_once(model_cfg: dict) -> None: | |
| """Emit non-private GPU diagnostics while inside the generation call.""" | |
| global _GPU_RUNTIME_LOGGED | |
| if _GPU_RUNTIME_LOGGED or settings.get("app", {}).get("deployment") != "huggingface": | |
| return | |
| _GPU_RUNTIME_LOGGED = True | |
| def _model_warmup_enabled() -> bool: | |
| if os.getenv("SPACE_DISABLE_MODEL_WARMUP", "").strip().lower() in {"1", "true", "yes"}: | |
| return False | |
| deployment = settings.get("app", {}).get("deployment") | |
| return deployment == "huggingface" or os.getenv("SPACE_ENABLE_MODEL_WARMUP", "").strip().lower() in { | |
| "1", | |
| "true", | |
| "yes", | |
| } | |
| def _warmup_model_worker(reason: str = "startup") -> None: | |
| try: | |
| model_settings = _model_settings_for_selection() | |
| model_cfg = model_settings.get("model", {}) | |
| backend = canonical_backend(model_cfg.get("backend", "ollama")) | |
| print( | |
| "[warmup] starting " | |
| f"reason={reason} " | |
| f"backend={backend} " | |
| f"preset={model_cfg.get('selected_preset', '')} " | |
| f"model={model_cfg.get('name', '')}", | |
| flush=True, | |
| ) | |
| model = get_model(model_settings) | |
| warmup = getattr(model, "warmup", None) | |
| if callable(warmup): | |
| warmup() | |
| else: | |
| model.health_check() | |
| print("[warmup] ready", flush=True) | |
| except Exception as exc: | |
| print(f"[warmup] failed: {exc}", flush=True) | |
| def warmup_model_async(reason: str = "startup") -> bool: | |
| """Start hosted model download/load/warmup in the background once.""" | |
| global _MODEL_WARMUP_STARTED | |
| if not _model_warmup_enabled(): | |
| return False | |
| with _MODEL_WARMUP_LOCK: | |
| if _MODEL_WARMUP_STARTED: | |
| return False | |
| _MODEL_WARMUP_STARTED = True | |
| target = _with_zero_gpu(_warmup_model_worker) | |
| thread = threading.Thread(target=target, args=(reason,), daemon=True) | |
| thread.start() | |
| return True | |
| def _avoid_unreported_today_timing(timeline: str, *source_texts: str) -> str: | |
| """Avoid presenting invented same-day timing when the user did not report it.""" | |
| if any(_RECENT_TIMING_RE.search(text or "") for text in source_texts): | |
| return timeline | |
| return re.sub(r"\bStarted today\b", "Timing not specified", timeline, flags=re.IGNORECASE) | |
| print( | |
| "[gpu-check] " | |
| f"backend={canonical_backend(model_cfg.get('backend', 'ollama'))} " | |
| f"preset={model_cfg.get('selected_preset', '')} " | |
| f"n_gpu_layers={model_cfg.get('n_gpu_layers', '')} " | |
| f"CUDA_VISIBLE_DEVICES={os.getenv('CUDA_VISIBLE_DEVICES', '')!r}", | |
| flush=True, | |
| ) | |
| try: | |
| result = subprocess.run( | |
| [ | |
| "nvidia-smi", | |
| "--query-gpu=name,memory.total,driver_version", | |
| "--format=csv,noheader", | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| check=False, | |
| ) | |
| except Exception as exc: | |
| print(f"[gpu-check] nvidia-smi unavailable: {exc}", flush=True) | |
| return | |
| if result.returncode == 0: | |
| print(f"[gpu-check] nvidia-smi={result.stdout.strip()}", flush=True) | |
| else: | |
| print(f"[gpu-check] nvidia-smi failed: {result.stderr.strip()}", flush=True) | |
| def model_download_status(model_preset_id: str): | |
| """Return model status text and download-button visibility for internal tools.""" | |
| model_settings = resolve_model_settings(settings, model_preset_id) | |
| model_cfg = model_settings.get("model", {}) | |
| backend = canonical_backend(model_cfg.get("backend", "ollama")) | |
| model_name = model_cfg.get("name", "medgemma1.5:4b") | |
| if backend == "ollama": | |
| try: | |
| if is_ollama_model_available(model_name, model_cfg.get("ollama_base_url", "http://localhost:11434")): | |
| return f"**Ready.** {model_name} is available locally.", gr.update(visible=False) | |
| except RuntimeError as exc: | |
| return f"**Ollama unavailable.** {exc}", gr.update(visible=False) | |
| return ( | |
| f"**{model_name} is not downloaded.** Download it now with Ollama?", | |
| gr.update(visible=True), | |
| ) | |
| if backend == "hf_transformers": | |
| return "**Ready.** Hugging Face will download and cache the selected model automatically.", gr.update(visible=False) | |
| return f"**Ready.** The configured {backend} backend will handle model access.", gr.update(visible=False) | |
| def download_selected_model(model_preset_id: str): | |
| """Download the selected Ollama model after user confirmation via button click.""" | |
| model_settings = resolve_model_settings(settings, model_preset_id) | |
| model_cfg = model_settings.get("model", {}) | |
| backend = canonical_backend(model_cfg.get("backend", "ollama")) | |
| model_name = model_cfg.get("name", "medgemma1.5:4b") | |
| if backend != "ollama": | |
| return "**No download needed.** This backend handles model access automatically.", gr.update(visible=False) | |
| try: | |
| status = pull_ollama_model(model_name, model_cfg.get("ollama_base_url", "http://localhost:11434")) | |
| except RuntimeError as exc: | |
| return f"**Download failed.** {exc}", gr.update(visible=True) | |
| return f"**Ready.** {model_name} is available locally. Ollama status: {status}.", gr.update(visible=False) | |
| def run_inference( | |
| symptoms: str, | |
| notes: str, | |
| medications: str, | |
| ): | |
| """Run one LLM inference pass and return the three report sections.""" | |
| errors = validate_inputs(symptoms=symptoms, notes=notes, medications=medications) | |
| if errors: | |
| error_msg = "\n".join(errors) | |
| return error_msg, error_msg, error_msg | |
| model_settings = _model_settings_for_selection() | |
| model_cfg = model_settings.get("model", {}) | |
| _log_gpu_runtime_once(model_cfg) | |
| backend = canonical_backend(model_cfg.get("backend", "ollama")) | |
| model_name = model_cfg.get("name", "medgemma1.5:4b") | |
| if backend == "ollama": | |
| try: | |
| if not is_ollama_model_available(model_name, model_cfg.get("ollama_base_url", "http://localhost:11434")): | |
| prompt = _ollama_model_prompt(model_name) | |
| return prompt, prompt, prompt | |
| except RuntimeError as exc: | |
| error_msg = f"**Ollama unavailable.** {exc}" | |
| return error_msg, error_msg, error_msg | |
| model_start = time.perf_counter() | |
| model = get_model(model_settings) | |
| model_elapsed = time.perf_counter() - model_start | |
| timeline_prompt = build_timeline_prompt(symptoms, notes, medications) | |
| questions_prompt = build_questions_prompt(symptoms, notes, medications) | |
| relevant_prompt = build_relevant_info_prompt(symptoms, notes, medications) | |
| generate_start = time.perf_counter() | |
| timeline_raw = model.generate_report(timeline_prompt) | |
| questions_raw = model.generate_report(questions_prompt) | |
| relevant_raw = model.generate_report(relevant_prompt) | |
| generate_elapsed = time.perf_counter() - generate_start | |
| output_chars = len(timeline_raw) + len(questions_raw) + len(relevant_raw) | |
| if settings.get("app", {}).get("deployment") == "huggingface": | |
| print( | |
| "[timing] " | |
| f"model_ready_s={model_elapsed:.2f} " | |
| f"generate_s={generate_elapsed:.2f} " | |
| f"output_chars={output_chars}", | |
| flush=True, | |
| ) | |
| timeline = clean_section_output(timeline_raw, max_items=8) | |
| timeline = _avoid_unreported_today_timing(timeline, symptoms, notes) | |
| return ( | |
| timeline, | |
| clean_section_output(questions_raw, max_items=5), | |
| clean_section_output( | |
| relevant_raw, | |
| max_items=4, | |
| required_suffix=( | |
| "- This is informational only and not a substitute for " | |
| "professional medical advice." | |
| ), | |
| ), | |
| ) | |
| def add_medication_entry(medication_name: str, instructions: str, current_medications: str, all_choices: list = None): | |
| """Append a selected or custom medication line to the medication list.""" | |
| medication_name = (medication_name or "").strip() | |
| instructions = (instructions or "").strip() | |
| current_medications = (current_medications or "").strip() | |
| if not medication_name and not instructions: | |
| return current_medications, "", "", gr.update(choices=all_choices or [], value=None) | |
| if medication_name and instructions: | |
| entry = f"{medication_name} - {instructions}" | |
| else: | |
| entry = medication_name or instructions | |
| updated = f"{current_medications}\n{entry}".strip() if current_medications else entry | |
| return updated, "", "", gr.update(choices=all_choices or [], value=None) | |
| def clear_all_inputs(all_choices: list): | |
| """Clear all input fields and reset medication picker.""" | |
| return ( | |
| "", # symptoms | |
| "", # notes | |
| "", # medications | |
| "", # medication_name | |
| "", # medication_instructions | |
| gr.update(choices=all_choices or [], value=None), # medication_picker | |
| DEFAULT_OUTPUT, # timeline_output | |
| DEFAULT_OUTPUT, # questions_output | |
| DEFAULT_OUTPUT, # relevant_output | |
| ) | |
| def populate_medication_name(selected_medication: str, current_name: str): | |
| """Copy a selected RxTerms choice into the editable medication-name field.""" | |
| return (selected_medication or current_name or "").strip() | |
| def filter_medication_picker_choices(all_choices: list[str], evt: gr.KeyUpData): | |
| """Return fuzzy medication choices using the dropdown's current typed text.""" | |
| return gr.update(choices=filter_medication_choices(evt.input_value, all_choices)) | |
| def _page_head(title: str = "Medical Appointment Prep") -> str: | |
| return f"""<head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>{title}</title> | |
| <link rel="stylesheet" href="/assets/server.css?v={CUSTOM_UI_ASSET_VERSION}" /> | |
| <script type="module" src="/assets/server.js?v={CUSTOM_UI_ASSET_VERSION}"></script> | |
| </head>""" | |
| def _topbar_html(active_page: str = "prep") -> str: | |
| prep_active = " selected" if active_page == "prep" else "" | |
| about_active = " selected" if active_page == "about" else "" | |
| return f"""<header class="topbar" aria-label="Application"> | |
| <a class="brand" href="/" aria-label="Medical Appointment Prep home"> | |
| <span class="brand-mark" aria-hidden="true"> | |
| <span></span> | |
| </span> | |
| <span>Medical Appointment Prep</span> | |
| </a> | |
| <nav class="top-nav" aria-label="Primary"> | |
| <a class="nav-link{prep_active}" href="/">Prep</a> | |
| <a class="nav-link{about_active}" href="/about">About</a> | |
| </nav> | |
| <div class="theme-switcher" aria-label="Appearance"> | |
| <button type="button" data-theme-option="system" aria-pressed="true">System</button> | |
| <button type="button" data-theme-option="light" aria-pressed="false">Light</button> | |
| <button type="button" data-theme-option="dark" aria-pressed="false">Dark</button> | |
| </div> | |
| </header>""" | |
| def _custom_frontend_html() -> str: | |
| return f"""<!doctype html> | |
| <html lang="en" data-theme="system"> | |
| {_page_head()} | |
| <body> | |
| {_topbar_html("prep")} | |
| <main class="page-shell"> | |
| <section class="hero-workspace" aria-labelledby="page-title"> | |
| <div class="guide-panel"> | |
| <div class="guide-visual"> | |
| <img src="/assets/assistant-robot.jpeg" alt="Friendly robot assistant in a medical room" /> | |
| <div class="guide-bubble"> | |
| <span class="pulse-dot" aria-hidden="true"></span> | |
| <span>Ready when you are</span> | |
| </div> | |
| </div> | |
| <div class="guide-copy"> | |
| <p class="eyebrow">A calm visit-planning helper</p> | |
| <h1 id="page-title">Bring the important details into the room.</h1> | |
| <p> | |
| Share what has been happening, what you have noticed, and what you take. | |
| The assistant turns it into a clear timeline, useful questions, and background notes. | |
| </p> | |
| <div class="trust-strip" aria-label="Privacy and safety notes"> | |
| <span>Informational only</span> | |
| <span>No diagnosis</span> | |
| <span>MedGemma 4B</span> | |
| <span>llama.cpp ready</span> | |
| </div> | |
| <a class="video-link" href="{VIDEO_DEMO_URL}" target="_blank" rel="noopener noreferrer"> | |
| Watch a short walkthrough | |
| </a> | |
| </div> | |
| </div> | |
| <form class="prep-form" id="prep-form"> | |
| <div class="form-heading"> | |
| <div> | |
| <p class="eyebrow">Step 1</p> | |
| <h2>Tell me what you want your clinician to know.</h2> | |
| </div> | |
| <button class="demo-action" type="button" id="demo-button">Fill sample data to test</button> | |
| </div> | |
| <label class="field-label" for="symptoms">What symptoms or concerns are you having?</label> | |
| <textarea id="symptoms" name="symptoms" rows="6" required | |
| placeholder="Example: Headache behind my eyes for 3 days. Worse in the morning. Mild nausea."></textarea> | |
| <label class="field-label" for="notes">Anything else that might matter?</label> | |
| <textarea id="notes" name="notes" rows="4" | |
| placeholder="Example: New stress, sleep changes, recent travel, diet changes, or what makes it better or worse."></textarea> | |
| <div class="medication-card"> | |
| <div> | |
| <p class="eyebrow">Step 2</p> | |
| <h3>Add medications, vitamins, or supplements.</h3> | |
| </div> | |
| <div class="medication-row"> | |
| <div class="combo-field"> | |
| <label class="field-label" for="medication-name">Medication name</label> | |
| <input id="medication-name" autocomplete="off" | |
| placeholder="Search or type anything" /> | |
| <div id="medication-suggestions" class="suggestions" role="listbox" hidden></div> | |
| </div> | |
| <div> | |
| <label class="field-label" for="medication-instructions">How you take it</label> | |
| <input id="medication-instructions" | |
| placeholder="Example: 10 mg once daily" /> | |
| </div> | |
| <button class="secondary-action" type="button" id="add-medication">Add</button> | |
| </div> | |
| <textarea id="medications" name="medications" rows="3" | |
| placeholder="Your medication list will appear here. You can edit it directly."></textarea> | |
| </div> | |
| <div class="form-actions"> | |
| <button class="primary-action" type="submit" id="generate-button"> | |
| <span class="button-label">Generate Prep Report</span> | |
| <span class="button-spinner" aria-hidden="true"></span> | |
| </button> | |
| <button class="ghost-action" type="button" id="clear-button">Clear</button> | |
| </div> | |
| <p class="form-note"> | |
| This tool helps organize appointment notes. It is not a medical diagnosis, | |
| treatment recommendation, or substitute for a qualified clinician. | |
| </p> | |
| </form> | |
| </section> | |
| <section class="results-shell" aria-labelledby="results-title"> | |
| <div class="results-heading"> | |
| <p class="eyebrow">Step 3</p> | |
| <h2 id="results-title">Your appointment prep</h2> | |
| <p id="status-message" role="status">Your report will appear after you generate it.</p> | |
| </div> | |
| <div class="result-tabs" role="tablist" aria-label="Report sections"> | |
| <button type="button" class="selected" role="tab" aria-selected="true" data-tab="timeline">Timeline</button> | |
| <button type="button" role="tab" aria-selected="false" data-tab="questions">Questions</button> | |
| <button type="button" role="tab" aria-selected="false" data-tab="relevant">Relevant Info</button> | |
| </div> | |
| <article class="report-panel selected" id="timeline-panel" data-panel="timeline"> | |
| <div class="panel-icon" aria-hidden="true">1</div> | |
| <div class="markdown-body" id="timeline-output">Your timeline will appear here.</div> | |
| </article> | |
| <article class="report-panel" id="questions-panel" data-panel="questions"> | |
| <div class="panel-icon" aria-hidden="true">2</div> | |
| <div class="markdown-body" id="questions-output">Questions for your visit will appear here.</div> | |
| </article> | |
| <article class="report-panel" id="relevant-panel" data-panel="relevant"> | |
| <div class="panel-icon" aria-hidden="true">3</div> | |
| <div class="markdown-body" id="relevant-output">Relevant background information will appear here.</div> | |
| </article> | |
| <div class="export-card" aria-labelledby="export-title"> | |
| <div class="export-heading"> | |
| <div> | |
| <p class="eyebrow">Take it with you</p> | |
| <h3 id="export-title">Share or save the full prep report</h3> | |
| </div> | |
| <p id="export-status" role="status">Generate a report to enable full-report exports.</p> | |
| </div> | |
| <div class="export-actions" aria-label="Export options"> | |
| <button type="button" class="export-action primary-export" id="email-report" disabled> | |
| <span class="export-icon" aria-hidden="true">@</span> | |
| <span>Email Full</span> | |
| </button> | |
| <button type="button" class="export-action" id="pdf-report" disabled> | |
| <span class="export-icon" aria-hidden="true">PDF</span> | |
| <span>Full PDF</span> | |
| </button> | |
| <button type="button" class="export-action" id="print-report" disabled> | |
| <span class="export-icon" aria-hidden="true">PRN</span> | |
| <span>Print Full</span> | |
| </button> | |
| <button type="button" class="export-action" id="copy-report" disabled> | |
| <span class="export-icon" aria-hidden="true">TXT</span> | |
| <span>Copy Full</span> | |
| </button> | |
| <button type="button" class="export-action" id="portal-copy" disabled> | |
| <span class="export-icon" aria-hidden="true">PT</span> | |
| <span>Portal Copy</span> | |
| </button> | |
| <button type="button" class="export-action" id="download-report" disabled> | |
| <span class="export-icon" aria-hidden="true">DL</span> | |
| <span>Download Full</span> | |
| </button> | |
| </div> | |
| <p class="export-note"> | |
| Export actions include the symptoms, notes, medications, timeline, questions, | |
| and relevant info. Review before sending, saving, or pasting into another service. | |
| </p> | |
| </div> | |
| </section> | |
| </main> | |
| </body> | |
| </html>""" | |
| def _about_frontend_html() -> str: | |
| return f"""<!doctype html> | |
| <html lang="en" data-theme="system"> | |
| {_page_head("About Medical Appointment Prep")} | |
| <body> | |
| {_topbar_html("about")} | |
| <main class="page-shell about-page"> | |
| <section class="about-hero" aria-labelledby="about-title"> | |
| <p class="eyebrow">About</p> | |
| <h1 id="about-title">A calmer way to prepare for a medical visit.</h1> | |
| <p> | |
| Medical Appointment Prep helps people turn symptoms, notes, and | |
| medications into a visit-ready timeline, questions, and background | |
| information they can review with a clinician. | |
| </p> | |
| <div class="trust-strip" aria-label="Safety notes"> | |
| <span>Informational only</span> | |
| <span>No diagnosis</span> | |
| <span>Patient-controlled export</span> | |
| <span>4B small-model build</span> | |
| <span>llama.cpp Space runtime</span> | |
| </div> | |
| <a class="article-link" href="{FIELD_NOTES_URL}" target="_blank" rel="noopener noreferrer"> | |
| Read the Field Notes article | |
| </a> | |
| </section> | |
| <section class="about-grid" aria-label="Project details"> | |
| <article class="about-card"> | |
| <p class="eyebrow">Purpose</p> | |
| <h2>Built for regular people</h2> | |
| <p> | |
| The interface is designed to feel familiar and approachable, with | |
| plain-language prompts and report sections that match how people | |
| prepare for appointments. | |
| </p> | |
| </article> | |
| <article class="about-card"> | |
| <p class="eyebrow">Safety</p> | |
| <h2>Organization, not advice</h2> | |
| <p> | |
| The app helps organize what the user provides. It does not diagnose, | |
| choose treatment, or replace a qualified healthcare professional. | |
| </p> | |
| </article> | |
| <article class="about-card source-card"> | |
| <p class="eyebrow">Hackathon fit</p> | |
| <h2>Small model, custom Gradio UI</h2> | |
| <p> | |
| The app is designed for the Backyard AI track: a specific appointment-prep | |
| workflow, a custom Gradio Server frontend, and a MedGemma 1.5 4B GGUF | |
| runtime through llama.cpp in the hosted Space. | |
| </p> | |
| <a class="github-link" href="{GITHUB_REPO_URL}" target="_blank" rel="noopener noreferrer"> | |
| Open GitHub repository | |
| </a> | |
| </article> | |
| </section> | |
| <section class="field-note-quote" aria-labelledby="field-note-title"> | |
| <p class="eyebrow" id="field-note-title">Field note</p> | |
| <blockquote> | |
| I always find it hard to remember what to ask or mention in the middle | |
| of an appointment. Helping a parent with appointments can be even more | |
| confusing, and this helps ensure we get the right help needed. Having | |
| this sheet to read from or share with my doctor will help me in my | |
| future appointments. | |
| </blockquote> | |
| <p class="quote-attribution">- My Spouse</p> | |
| </section> | |
| <section class="acknowledgements" aria-labelledby="acknowledgements-title"> | |
| <div> | |
| <p class="eyebrow">Acknowledgements</p> | |
| <h2 id="acknowledgements-title">Thank you to the Build Small Hackathon sponsors.</h2> | |
| <p> | |
| This project was built for the Hugging Face Build Small Hackathon. | |
| Thanks to the sponsors for supporting small-model experiments, and | |
| especially to OpenAI's Codex for helping shape, test, and ship the app. | |
| </p> | |
| </div> | |
| <div class="sponsor-links" aria-label="Sponsor links"> | |
| <a href="https://huggingface.co/" target="_blank" rel="noopener noreferrer">Hugging Face</a> | |
| <a href="https://openai.com/codex/" target="_blank" rel="noopener noreferrer">OpenAI Codex</a> | |
| <a href="https://www.nvidia.com/" target="_blank" rel="noopener noreferrer">NVIDIA</a> | |
| <a href="https://modal.com/" target="_blank" rel="noopener noreferrer">Modal</a> | |
| <a href="https://www.openbmb.cn/" target="_blank" rel="noopener noreferrer">OpenBMB</a> | |
| <a href="https://cohere.com/" target="_blank" rel="noopener noreferrer">Cohere</a> | |
| <a href="https://www.jetbrains.com/" target="_blank" rel="noopener noreferrer">JetBrains</a> | |
| <a href="https://blackforestlabs.ai/" target="_blank" rel="noopener noreferrer">Black Forest Labs</a> | |
| </div> | |
| <p class="about-video-note"> | |
| Video walkthrough: | |
| <a href="{VIDEO_DEMO_URL}" target="_blank" rel="noopener noreferrer">watch on YouTube</a> | |
| </p> | |
| <p class="about-video-note"> | |
| Project update: | |
| <a href="{LINKEDIN_POST_URL}" target="_blank" rel="noopener noreferrer">read the LinkedIn post</a> | |
| </p> | |
| </section> | |
| </main> | |
| </body> | |
| </html>""" | |
| def create_server_app() -> gr.Server: | |
| """Create a custom Server-mode app with Gradio's queued API backend.""" | |
| server = gr.Server(title="Medical Appointment Prep Assistant") | |
| def generate(symptoms: str, notes: str, medications: str) -> tuple[str, str, str]: | |
| return run_inference(symptoms, notes, medications) | |
| async def homepage(): | |
| return _custom_frontend_html() | |
| async def about_page(): | |
| return _about_frontend_html() | |
| async def asset(asset_name: str): | |
| allowed_assets = { | |
| "server.css": CUSTOM_UI_CSS_PATH, | |
| "server.js": CUSTOM_UI_JS_PATH, | |
| "assistant-robot.jpeg": ROBOT_IMAGE_PATH, | |
| } | |
| asset_path = allowed_assets.get(asset_name) | |
| if asset_path is None: | |
| return HTMLResponse("Not found", status_code=404) | |
| return FileResponse(ROOT_DIR / asset_path) | |
| async def medication_suggestions(q: str = ""): | |
| return {"choices": filter_medication_choices(q, load_medication_choices(), limit=20)} | |
| return server | |
| def launch_app() -> None: | |
| server_host = settings.get("server", {}).get("host", "127.0.0.1") | |
| server_port = settings.get("server", {}).get("port", 7860) | |
| if os.getenv("APP_UI_MODE", "").strip().lower() == "blocks": | |
| ui = create_ui() | |
| ui.launch( | |
| server_name=server_host, | |
| server_port=server_port, | |
| theme=APPLE_THEME, | |
| css_paths=[APPLE_CSS_PATH], | |
| head=THEME_MODE_HEAD, | |
| footer_links=["api"], | |
| share=False, | |
| inbrowser=True, | |
| ) | |
| return | |
| server = create_server_app() | |
| server.launch( | |
| server_name=server_host, | |
| server_port=server_port, | |
| show_error=True, | |
| inbrowser=True, | |
| ) | |
| def create_ui() -> gr.Blocks: | |
| app_cfg = settings.get("app", {}) | |
| model_cfg = settings.get("model", {}) | |
| backend = model_cfg.get("backend", "ollama") | |
| selected_model_preset_id = get_default_model_preset_id(settings, backend) | |
| selected_model_settings = _model_settings_for_selection(selected_model_preset_id) | |
| all_medication_choices = load_medication_choices() | |
| medication_summary = medication_index_summary() | |
| deployment = app_cfg.get("deployment", "local") | |
| is_local_deployment = deployment == "local" | |
| if is_local_deployment: | |
| about_heading = "Local medical appointment preparation." | |
| about_copy = "This tool organizes appointment notes with a local language model." | |
| elif deployment == "huggingface": | |
| about_heading = "Space-local medical appointment preparation." | |
| about_copy = ( | |
| "This tool organizes appointment notes with a language model running " | |
| "inside this Hugging Face Space." | |
| ) | |
| else: | |
| about_heading = "Hosted medical appointment preparation." | |
| about_copy = "This tool organizes appointment notes with a hosted language model backend." | |
| if backend.lower() in ("hf_transformers", "huggingface", "transformers"): | |
| get_model(selected_model_settings) | |
| with gr.Blocks( | |
| title="Medical Appointment Prep Assistant", | |
| elem_id="app-shell", | |
| fill_width=True, | |
| ) as app: | |
| gr.HTML( | |
| f""" | |
| <header class="global-nav" aria-label="Application"> | |
| <div class="nav-inner"> | |
| <span class="nav-mark" aria-hidden="true"></span> | |
| <span class="nav-title">Medical Appointment Prep</span> | |
| <div class="theme-switcher" aria-label="Appearance"> | |
| <button type="button" data-theme-option="system">System</button> | |
| <button type="button" data-theme-option="light">Light</button> | |
| <button type="button" data-theme-option="dark">Dark</button> | |
| </div> | |
| </div> | |
| </header> | |
| <section class="hero-tile"> | |
| <h1>Medical Appointment Prep</h1> | |
| <h2>Arrive clear, organized, and ready.</h2> | |
| <p class="hero-copy"> | |
| Turn symptoms, notes, and medications into a concise timeline, | |
| visit questions, and relevant background information. | |
| </p> | |
| </section> | |
| """ | |
| ) | |
| with gr.Tab("Prepare", elem_classes=["main-tabs"]): | |
| with gr.Row(elem_classes=["prep-grid"]): | |
| with gr.Column(scale=1, elem_classes=["input-pane"]): | |
| gr.HTML( | |
| """ | |
| <div class="pane-heading"> | |
| <h2>Tell us about your visit</h2> | |
| </div> | |
| """ | |
| ) | |
| gr.HTML('<p class="input-label">Symptoms</p>') | |
| symptoms = gr.Textbox( | |
| label="Symptoms", | |
| show_label=False, | |
| placeholder="Headache behind eyes for 3 days, worse in the morning, mild nausea", | |
| lines=6, | |
| max_lines=12, | |
| elem_classes=["apple-input"], | |
| ) | |
| gr.HTML('<p class="input-label">Additional Notes</p>') | |
| notes = gr.Textbox( | |
| label="Additional Notes", | |
| show_label=False, | |
| placeholder="Recent travel, stress, dietary changes, sleep changes", | |
| lines=4, | |
| max_lines=8, | |
| elem_classes=["apple-input"], | |
| ) | |
| gr.HTML('<p class="input-label">Current Medications</p>') | |
| gr.HTML(f'<p class="fine-print medication-source">{medication_summary}</p>') | |
| medication_picker = gr.Dropdown( | |
| label="Find a Medication", | |
| show_label=False, | |
| choices=all_medication_choices, | |
| value=None, | |
| filterable=True, | |
| allow_custom_value=True, | |
| elem_classes=["apple-input", "medication-picker"], | |
| ) | |
| gr.HTML('<p class="input-label">Medication Name</p>') | |
| medication_name = gr.Textbox( | |
| label="Medication Name", | |
| show_label=False, | |
| placeholder="Select a medication above, or type any medication, vitamin, or supplement", | |
| lines=1, | |
| max_lines=2, | |
| elem_classes=["apple-input"], | |
| ) | |
| gr.HTML('<p class="input-label">How You Take It</p>') | |
| medication_instructions = gr.Textbox( | |
| label="Medication Instructions", | |
| show_label=False, | |
| placeholder="How you take it, e.g. once daily, as needed, with food", | |
| lines=2, | |
| max_lines=4, | |
| elem_classes=["apple-input"], | |
| ) | |
| add_medication_btn = gr.Button( | |
| "Add Medication", | |
| variant="secondary", | |
| elem_classes=["secondary-pill", "add-medication-button"], | |
| ) | |
| medications = gr.Textbox( | |
| label="Current Medications", | |
| show_label=False, | |
| placeholder="Selected medications will appear here. You can also edit this list directly.", | |
| lines=4, | |
| max_lines=8, | |
| elem_classes=["apple-input"], | |
| ) | |
| with gr.Row(elem_classes=["button-row"]): | |
| submit_btn = gr.Button( | |
| "Generate Prep Report", | |
| variant="primary", | |
| size="lg", | |
| elem_classes=["primary-pill"], | |
| ) | |
| clear_btn = gr.Button( | |
| "Clear", | |
| variant="secondary", | |
| elem_classes=["secondary-pill"], | |
| ) | |
| with gr.Column(scale=1, elem_classes=["output-pane"]): | |
| gr.HTML( | |
| """ | |
| <div class="pane-heading"> | |
| <h2>Your appointment prep</h2> | |
| </div> | |
| """ | |
| ) | |
| with gr.Tabs(elem_classes=["output-tabs"]): | |
| with gr.Tab("Timeline"): | |
| timeline_output = gr.Markdown( | |
| label="Symptom Timeline", | |
| value=DEFAULT_OUTPUT, | |
| elem_classes=["report-output"], | |
| ) | |
| with gr.Tab("Questions"): | |
| questions_output = gr.Markdown( | |
| label="Questions to Ask", | |
| value=DEFAULT_OUTPUT, | |
| elem_classes=["report-output"], | |
| ) | |
| with gr.Tab("Relevant Info"): | |
| relevant_output = gr.Markdown( | |
| label="Relevant Medical Information", | |
| value=DEFAULT_OUTPUT, | |
| elem_classes=["report-output"], | |
| ) | |
| with gr.Tab("About", elem_classes=["main-tabs"]): | |
| gr.HTML( | |
| f""" | |
| <section class="about-tile"> | |
| <p class="section-kicker">About</p> | |
| <h2>{about_heading}</h2> | |
| <p> | |
| {about_copy} | |
| It is for informational and organizational purposes only, not diagnosis, | |
| treatment, or a substitute for professional medical advice. | |
| </p> | |
| <p class="about-meta"> | |
| Field Notes: | |
| <a class="about-article-link" href="{FIELD_NOTES_URL}" target="_blank" rel="noopener noreferrer"> | |
| Read the Hugging Face article | |
| </a> | |
| </p> | |
| <p class="about-meta"> | |
| Source code: | |
| <a href="https://github.com/bisonbet/medical-appt-prep" target="_blank" rel="noopener noreferrer"> | |
| GitHub repository | |
| </a> | |
| </p> | |
| <p class="about-meta">AI assisted by Codex.</p> | |
| <hr /> | |
| <h3>Field note</h3> | |
| <blockquote> | |
| I always find it hard to remember what to ask or mention in the middle | |
| of an appointment. Helping a parent with appointments can be even more | |
| confusing, and this helps ensure we get the right help needed. Having | |
| this sheet to read from or share with my doctor will help me in my | |
| future appointments. | |
| </blockquote> | |
| <p class="about-meta">- My Spouse</p> | |
| <hr /> | |
| <h3>Acknowledgements</h3> | |
| <p> | |
| Built for the Hugging Face Build Small Hackathon. Thank you to the | |
| sponsors supporting small-model work, especially | |
| <a href="https://openai.com/codex/" target="_blank" rel="noopener noreferrer">OpenAI Codex</a>. | |
| </p> | |
| <p class="about-meta"> | |
| Sponsors: | |
| <a href="https://huggingface.co/" target="_blank" rel="noopener noreferrer">Hugging Face</a>, | |
| <a href="https://openai.com/codex/" target="_blank" rel="noopener noreferrer">OpenAI Codex</a>, | |
| <a href="https://www.nvidia.com/" target="_blank" rel="noopener noreferrer">NVIDIA</a>, | |
| <a href="https://modal.com/" target="_blank" rel="noopener noreferrer">Modal</a>, | |
| <a href="https://www.openbmb.cn/" target="_blank" rel="noopener noreferrer">OpenBMB</a>, | |
| <a href="https://cohere.com/" target="_blank" rel="noopener noreferrer">Cohere</a>, | |
| <a href="https://www.jetbrains.com/" target="_blank" rel="noopener noreferrer">JetBrains</a>, | |
| <a href="https://blackforestlabs.ai/" target="_blank" rel="noopener noreferrer">Black Forest Labs</a>. | |
| </p> | |
| <p class="about-meta"> | |
| Video walkthrough: | |
| <a href="{VIDEO_DEMO_URL}" target="_blank" rel="noopener noreferrer">watch on YouTube</a> | |
| </p> | |
| <p class="about-meta"> | |
| Project update: | |
| <a href="{LINKEDIN_POST_URL}" target="_blank" rel="noopener noreferrer">read the LinkedIn post</a> | |
| </p> | |
| </section> | |
| """ | |
| ) | |
| submit_btn.click( | |
| fn=run_inference, | |
| inputs=[symptoms, notes, medications], | |
| outputs=[timeline_output, questions_output, relevant_output], | |
| api_name="generate", | |
| concurrency_limit=1, | |
| concurrency_id="llama-cpp-gpu", | |
| api_visibility="public", | |
| ) | |
| # Create a state variable to hold all medication choices for filtering | |
| medication_choices_state = gr.State(all_medication_choices) | |
| medication_picker.key_up( | |
| fn=filter_medication_picker_choices, | |
| inputs=[medication_choices_state], | |
| outputs=[medication_picker], | |
| show_progress="hidden", | |
| trigger_mode="always_last", | |
| api_visibility="private", | |
| ) | |
| medication_picker.change( | |
| fn=populate_medication_name, | |
| inputs=[medication_picker, medication_name], | |
| outputs=[medication_name], | |
| api_visibility="private", | |
| ) | |
| add_medication_btn.click( | |
| fn=add_medication_entry, | |
| inputs=[medication_name, medication_instructions, medications, medication_choices_state], | |
| outputs=[medications, medication_name, medication_instructions, medication_picker], | |
| api_visibility="private", | |
| ) | |
| clear_btn.click( | |
| fn=clear_all_inputs, | |
| inputs=[medication_choices_state], | |
| outputs=[ | |
| symptoms, | |
| notes, | |
| medications, | |
| medication_name, | |
| medication_instructions, | |
| medication_picker, | |
| timeline_output, | |
| questions_output, | |
| relevant_output, | |
| ], | |
| api_visibility="private", | |
| ) | |
| return app | |
| if __name__ == "__main__": | |
| launch_app() | |