Update medication search and patient-friendly copy
Browse files- assets/apple.css +7 -0
- data/medications/rxterms_medications_common.json +0 -0
- shared_app.py +48 -14
- src/medications.py +102 -8
assets/apple.css
CHANGED
|
@@ -358,6 +358,13 @@ body,
|
|
| 358 |
opacity: 1 !important;
|
| 359 |
}
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
.button-row {
|
| 362 |
gap: 12px;
|
| 363 |
margin-top: 12px;
|
|
|
|
| 358 |
opacity: 1 !important;
|
| 359 |
}
|
| 360 |
|
| 361 |
+
.medication-picker .options[role="listbox"] {
|
| 362 |
+
max-height: min(360px, calc(100vh - 160px)) !important;
|
| 363 |
+
overflow-y: auto !important;
|
| 364 |
+
overscroll-behavior: contain;
|
| 365 |
+
scrollbar-width: thin;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
.button-row {
|
| 369 |
gap: 12px;
|
| 370 |
margin-top: 12px;
|
data/medications/rxterms_medications_common.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
shared_app.py
CHANGED
|
@@ -14,8 +14,13 @@ from src.model_catalog import (
|
|
| 14 |
)
|
| 15 |
from src.prompts import build_prep_report_prompt
|
| 16 |
from src.processor import validate_inputs, parse_prep_report
|
| 17 |
-
from src.medications import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from config_loader import load_settings
|
|
|
|
| 19 |
|
| 20 |
settings = load_settings()
|
| 21 |
|
|
@@ -216,14 +221,14 @@ def run_inference(
|
|
| 216 |
return parse_prep_report(report_raw)
|
| 217 |
|
| 218 |
|
| 219 |
-
def add_medication_entry(medication_name: str, instructions: str, current_medications: str):
|
| 220 |
"""Append a selected or custom medication line to the medication list."""
|
| 221 |
medication_name = (medication_name or "").strip()
|
| 222 |
instructions = (instructions or "").strip()
|
| 223 |
current_medications = (current_medications or "").strip()
|
| 224 |
|
| 225 |
if not medication_name and not instructions:
|
| 226 |
-
return current_medications, "", "", None
|
| 227 |
|
| 228 |
if medication_name and instructions:
|
| 229 |
entry = f"{medication_name} - {instructions}"
|
|
@@ -231,7 +236,22 @@ def add_medication_entry(medication_name: str, instructions: str, current_medica
|
|
| 231 |
entry = medication_name or instructions
|
| 232 |
|
| 233 |
updated = f"{current_medications}\n{entry}".strip() if current_medications else entry
|
| 234 |
-
return updated, "", "", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
def populate_medication_name(selected_medication: str, current_name: str):
|
|
@@ -239,6 +259,11 @@ def populate_medication_name(selected_medication: str, current_name: str):
|
|
| 239 |
return (selected_medication or current_name or "").strip()
|
| 240 |
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def create_ui() -> gr.Blocks:
|
| 243 |
app_cfg = settings.get("app", {})
|
| 244 |
model_cfg = settings.get("model", {})
|
|
@@ -257,7 +282,7 @@ def create_ui() -> gr.Blocks:
|
|
| 257 |
temperature = _temperature_choice_value(
|
| 258 |
selected_model_cfg.get("temperature", model_cfg.get("temperature", 0.3))
|
| 259 |
)
|
| 260 |
-
|
| 261 |
medication_summary = medication_index_summary()
|
| 262 |
deployment = app_cfg.get("deployment", "local")
|
| 263 |
is_local_deployment = deployment == "local"
|
|
@@ -312,8 +337,7 @@ def create_ui() -> gr.Blocks:
|
|
| 312 |
gr.HTML(
|
| 313 |
"""
|
| 314 |
<div class="pane-heading">
|
| 315 |
-
<
|
| 316 |
-
<h2>Appointment details</h2>
|
| 317 |
</div>
|
| 318 |
"""
|
| 319 |
)
|
|
@@ -340,7 +364,7 @@ def create_ui() -> gr.Blocks:
|
|
| 340 |
medication_picker = gr.Dropdown(
|
| 341 |
label="Find a Medication",
|
| 342 |
show_label=False,
|
| 343 |
-
choices=
|
| 344 |
value=None,
|
| 345 |
filterable=True,
|
| 346 |
allow_custom_value=True,
|
|
@@ -350,7 +374,7 @@ def create_ui() -> gr.Blocks:
|
|
| 350 |
medication_name = gr.Textbox(
|
| 351 |
label="Medication Name",
|
| 352 |
show_label=False,
|
| 353 |
-
placeholder="Select
|
| 354 |
lines=1,
|
| 355 |
max_lines=2,
|
| 356 |
elem_classes=["apple-input"],
|
|
@@ -394,8 +418,7 @@ def create_ui() -> gr.Blocks:
|
|
| 394 |
gr.HTML(
|
| 395 |
"""
|
| 396 |
<div class="pane-heading">
|
| 397 |
-
<
|
| 398 |
-
<h2>Prep report</h2>
|
| 399 |
</div>
|
| 400 |
"""
|
| 401 |
)
|
|
@@ -512,6 +535,17 @@ def create_ui() -> gr.Blocks:
|
|
| 512 |
outputs=[model_status, download_model_btn],
|
| 513 |
api_visibility="private",
|
| 514 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
medication_picker.change(
|
| 516 |
fn=populate_medication_name,
|
| 517 |
inputs=[medication_picker, medication_name],
|
|
@@ -520,13 +554,13 @@ def create_ui() -> gr.Blocks:
|
|
| 520 |
)
|
| 521 |
add_medication_btn.click(
|
| 522 |
fn=add_medication_entry,
|
| 523 |
-
inputs=[medication_name, medication_instructions, medications],
|
| 524 |
outputs=[medications, medication_name, medication_instructions, medication_picker],
|
| 525 |
api_visibility="private",
|
| 526 |
)
|
| 527 |
clear_btn.click(
|
| 528 |
-
fn=
|
| 529 |
-
inputs=[],
|
| 530 |
outputs=[
|
| 531 |
symptoms,
|
| 532 |
notes,
|
|
|
|
| 14 |
)
|
| 15 |
from src.prompts import build_prep_report_prompt
|
| 16 |
from src.processor import validate_inputs, parse_prep_report
|
| 17 |
+
from src.medications import (
|
| 18 |
+
filter_medication_choices,
|
| 19 |
+
load_medication_choices,
|
| 20 |
+
medication_index_summary,
|
| 21 |
+
)
|
| 22 |
from config_loader import load_settings
|
| 23 |
+
import gradio as gr
|
| 24 |
|
| 25 |
settings = load_settings()
|
| 26 |
|
|
|
|
| 221 |
return parse_prep_report(report_raw)
|
| 222 |
|
| 223 |
|
| 224 |
+
def add_medication_entry(medication_name: str, instructions: str, current_medications: str, all_choices: list = None):
|
| 225 |
"""Append a selected or custom medication line to the medication list."""
|
| 226 |
medication_name = (medication_name or "").strip()
|
| 227 |
instructions = (instructions or "").strip()
|
| 228 |
current_medications = (current_medications or "").strip()
|
| 229 |
|
| 230 |
if not medication_name and not instructions:
|
| 231 |
+
return current_medications, "", "", gr.update(choices=all_choices or [], value=None)
|
| 232 |
|
| 233 |
if medication_name and instructions:
|
| 234 |
entry = f"{medication_name} - {instructions}"
|
|
|
|
| 236 |
entry = medication_name or instructions
|
| 237 |
|
| 238 |
updated = f"{current_medications}\n{entry}".strip() if current_medications else entry
|
| 239 |
+
return updated, "", "", gr.update(choices=all_choices or [], value=None)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def clear_all_inputs(all_choices: list):
|
| 243 |
+
"""Clear all input fields and reset medication picker."""
|
| 244 |
+
return (
|
| 245 |
+
"", # symptoms
|
| 246 |
+
"", # notes
|
| 247 |
+
"", # medications
|
| 248 |
+
"", # medication_name
|
| 249 |
+
"", # medication_instructions
|
| 250 |
+
gr.update(choices=all_choices or [], value=None), # medication_picker
|
| 251 |
+
DEFAULT_OUTPUT, # timeline_output
|
| 252 |
+
DEFAULT_OUTPUT, # questions_output
|
| 253 |
+
DEFAULT_OUTPUT, # relevant_output
|
| 254 |
+
)
|
| 255 |
|
| 256 |
|
| 257 |
def populate_medication_name(selected_medication: str, current_name: str):
|
|
|
|
| 259 |
return (selected_medication or current_name or "").strip()
|
| 260 |
|
| 261 |
|
| 262 |
+
def filter_medication_picker_choices(all_choices: list[str], evt: gr.KeyUpData):
|
| 263 |
+
"""Return fuzzy medication choices using the dropdown's current typed text."""
|
| 264 |
+
return gr.update(choices=filter_medication_choices(evt.input_value, all_choices))
|
| 265 |
+
|
| 266 |
+
|
| 267 |
def create_ui() -> gr.Blocks:
|
| 268 |
app_cfg = settings.get("app", {})
|
| 269 |
model_cfg = settings.get("model", {})
|
|
|
|
| 282 |
temperature = _temperature_choice_value(
|
| 283 |
selected_model_cfg.get("temperature", model_cfg.get("temperature", 0.3))
|
| 284 |
)
|
| 285 |
+
all_medication_choices = load_medication_choices()
|
| 286 |
medication_summary = medication_index_summary()
|
| 287 |
deployment = app_cfg.get("deployment", "local")
|
| 288 |
is_local_deployment = deployment == "local"
|
|
|
|
| 337 |
gr.HTML(
|
| 338 |
"""
|
| 339 |
<div class="pane-heading">
|
| 340 |
+
<h2>Tell us about your visit</h2>
|
|
|
|
| 341 |
</div>
|
| 342 |
"""
|
| 343 |
)
|
|
|
|
| 364 |
medication_picker = gr.Dropdown(
|
| 365 |
label="Find a Medication",
|
| 366 |
show_label=False,
|
| 367 |
+
choices=all_medication_choices,
|
| 368 |
value=None,
|
| 369 |
filterable=True,
|
| 370 |
allow_custom_value=True,
|
|
|
|
| 374 |
medication_name = gr.Textbox(
|
| 375 |
label="Medication Name",
|
| 376 |
show_label=False,
|
| 377 |
+
placeholder="Select a medication above, or type any medication, vitamin, or supplement",
|
| 378 |
lines=1,
|
| 379 |
max_lines=2,
|
| 380 |
elem_classes=["apple-input"],
|
|
|
|
| 418 |
gr.HTML(
|
| 419 |
"""
|
| 420 |
<div class="pane-heading">
|
| 421 |
+
<h2>Your appointment prep</h2>
|
|
|
|
| 422 |
</div>
|
| 423 |
"""
|
| 424 |
)
|
|
|
|
| 535 |
outputs=[model_status, download_model_btn],
|
| 536 |
api_visibility="private",
|
| 537 |
)
|
| 538 |
+
# Create a state variable to hold all medication choices for filtering
|
| 539 |
+
medication_choices_state = gr.State(all_medication_choices)
|
| 540 |
+
|
| 541 |
+
medication_picker.key_up(
|
| 542 |
+
fn=filter_medication_picker_choices,
|
| 543 |
+
inputs=[medication_choices_state],
|
| 544 |
+
outputs=[medication_picker],
|
| 545 |
+
show_progress="hidden",
|
| 546 |
+
trigger_mode="always_last",
|
| 547 |
+
api_visibility="private",
|
| 548 |
+
)
|
| 549 |
medication_picker.change(
|
| 550 |
fn=populate_medication_name,
|
| 551 |
inputs=[medication_picker, medication_name],
|
|
|
|
| 554 |
)
|
| 555 |
add_medication_btn.click(
|
| 556 |
fn=add_medication_entry,
|
| 557 |
+
inputs=[medication_name, medication_instructions, medications, medication_choices_state],
|
| 558 |
outputs=[medications, medication_name, medication_instructions, medication_picker],
|
| 559 |
api_visibility="private",
|
| 560 |
)
|
| 561 |
clear_btn.click(
|
| 562 |
+
fn=clear_all_inputs,
|
| 563 |
+
inputs=[medication_choices_state],
|
| 564 |
outputs=[
|
| 565 |
symptoms,
|
| 566 |
notes,
|
src/medications.py
CHANGED
|
@@ -5,12 +5,17 @@ Medication autocomplete data helpers.
|
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
import json
|
|
|
|
|
|
|
| 8 |
from functools import lru_cache
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
|
| 12 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
INDEX_PATH = ROOT_DIR / "data" / "medications" / "rxterms_medications.json"
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
COMMON_SUPPLEMENTS = [
|
|
@@ -35,11 +40,14 @@ COMMON_SUPPLEMENTS = [
|
|
| 35 |
|
| 36 |
@lru_cache(maxsize=1)
|
| 37 |
def load_medication_choices() -> list[str]:
|
| 38 |
-
"""Return local RxTerms choices plus common supplement fallback choices."""
|
| 39 |
choices: list[str] = []
|
| 40 |
|
| 41 |
-
if
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
payload = json.load(handle)
|
| 44 |
choices.extend(item["label"] for item in payload.get("medications", []))
|
| 45 |
|
|
@@ -52,13 +60,99 @@ def load_medication_choices() -> list[str]:
|
|
| 52 |
return choices
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def medication_index_summary() -> str:
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
with
|
| 60 |
payload = json.load(handle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return (
|
| 62 |
-
|
| 63 |
-
|
| 64 |
)
|
|
|
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
import json
|
| 8 |
+
import re
|
| 9 |
+
from difflib import SequenceMatcher
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
|
| 14 |
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 15 |
INDEX_PATH = ROOT_DIR / "data" / "medications" / "rxterms_medications.json"
|
| 16 |
+
COMMON_INDEX_PATH = ROOT_DIR / "data" / "medications" / "rxterms_medications_common.json"
|
| 17 |
+
DEFAULT_MEDICATION_SEARCH_LIMIT = 120
|
| 18 |
+
_NON_WORD_RE = re.compile(r"[^0-9a-z]+")
|
| 19 |
|
| 20 |
|
| 21 |
COMMON_SUPPLEMENTS = [
|
|
|
|
| 40 |
|
| 41 |
@lru_cache(maxsize=1)
|
| 42 |
def load_medication_choices() -> list[str]:
|
| 43 |
+
"""Return local RxTerms choices (pruned to common medications) plus common supplement fallback choices."""
|
| 44 |
choices: list[str] = []
|
| 45 |
|
| 46 |
+
# Use pruned common medications list if available, otherwise fall back to full list
|
| 47 |
+
index_to_use = COMMON_INDEX_PATH if COMMON_INDEX_PATH.exists() else INDEX_PATH
|
| 48 |
+
|
| 49 |
+
if index_to_use.exists():
|
| 50 |
+
with index_to_use.open("r", encoding="utf-8") as handle:
|
| 51 |
payload = json.load(handle)
|
| 52 |
choices.extend(item["label"] for item in payload.get("medications", []))
|
| 53 |
|
|
|
|
| 60 |
return choices
|
| 61 |
|
| 62 |
|
| 63 |
+
def _normalize_search_text(value: str) -> str:
|
| 64 |
+
normalized = _NON_WORD_RE.sub(" ", value.casefold())
|
| 65 |
+
return " ".join(normalized.split())
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _compact_search_text(value: str) -> str:
|
| 69 |
+
return _normalize_search_text(value).replace(" ", "")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _best_sequence_ratio(query: str, choice: str) -> float:
|
| 73 |
+
search_terms = {_compact_search_text(choice)}
|
| 74 |
+
search_terms.update(
|
| 75 |
+
_compact_search_text(part)
|
| 76 |
+
for part in re.split(r"[\s()/,-]+", choice)
|
| 77 |
+
if len(part) >= 4
|
| 78 |
+
)
|
| 79 |
+
search_terms.discard("")
|
| 80 |
+
if not search_terms:
|
| 81 |
+
return 0.0
|
| 82 |
+
|
| 83 |
+
return max(SequenceMatcher(None, query, term).ratio() for term in search_terms)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _medication_match_score(query: str, choice: str) -> tuple[float, ...] | None:
|
| 87 |
+
normalized_query = _normalize_search_text(query)
|
| 88 |
+
compact_query = normalized_query.replace(" ", "")
|
| 89 |
+
normalized_choice = _normalize_search_text(choice)
|
| 90 |
+
compact_choice = normalized_choice.replace(" ", "")
|
| 91 |
+
|
| 92 |
+
if not normalized_query:
|
| 93 |
+
return (0, 0)
|
| 94 |
+
|
| 95 |
+
if normalized_choice.startswith(normalized_query):
|
| 96 |
+
return (0, len(normalized_choice))
|
| 97 |
+
|
| 98 |
+
words = normalized_choice.split()
|
| 99 |
+
word_prefix_positions = [
|
| 100 |
+
index for index, word in enumerate(words) if word.startswith(normalized_query)
|
| 101 |
+
]
|
| 102 |
+
if word_prefix_positions:
|
| 103 |
+
return (1, word_prefix_positions[0], len(normalized_choice))
|
| 104 |
+
|
| 105 |
+
substring_index = normalized_choice.find(normalized_query)
|
| 106 |
+
if substring_index != -1:
|
| 107 |
+
return (2, substring_index, len(normalized_choice))
|
| 108 |
+
|
| 109 |
+
compact_index = compact_choice.find(compact_query)
|
| 110 |
+
if compact_index != -1:
|
| 111 |
+
return (3, compact_index, len(compact_choice))
|
| 112 |
+
|
| 113 |
+
sequence_ratio = _best_sequence_ratio(compact_query, choice)
|
| 114 |
+
if sequence_ratio >= 0.72:
|
| 115 |
+
return (4, -sequence_ratio, len(compact_choice))
|
| 116 |
+
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def filter_medication_choices(
|
| 121 |
+
query: str,
|
| 122 |
+
choices: list[str],
|
| 123 |
+
limit: int | None = DEFAULT_MEDICATION_SEARCH_LIMIT,
|
| 124 |
+
) -> list[str]:
|
| 125 |
+
"""Return case-insensitive substring and fuzzy medication matches."""
|
| 126 |
+
if not query or not query.strip():
|
| 127 |
+
return list(choices)
|
| 128 |
+
|
| 129 |
+
matches = []
|
| 130 |
+
for index, choice in enumerate(choices):
|
| 131 |
+
score = _medication_match_score(query, choice)
|
| 132 |
+
if score is not None:
|
| 133 |
+
matches.append((score, index, choice))
|
| 134 |
+
|
| 135 |
+
matches.sort()
|
| 136 |
+
filtered = [choice for _score, _index, choice in matches]
|
| 137 |
+
return filtered[:limit] if limit is not None else filtered
|
| 138 |
+
|
| 139 |
+
|
| 140 |
def medication_index_summary() -> str:
|
| 141 |
+
# Prefer common index if available
|
| 142 |
+
index_to_use = COMMON_INDEX_PATH if COMMON_INDEX_PATH.exists() else INDEX_PATH
|
| 143 |
+
|
| 144 |
+
if not index_to_use.exists():
|
| 145 |
+
return "You can type any medication, vitamin, or supplement below."
|
| 146 |
|
| 147 |
+
with index_to_use.open("r", encoding="utf-8") as handle:
|
| 148 |
payload = json.load(handle)
|
| 149 |
+
count = int(payload.get("count", 0))
|
| 150 |
+
if count >= 2000:
|
| 151 |
+
return (
|
| 152 |
+
"Over 2,000 common medications are included. "
|
| 153 |
+
"You can also type any medication, vitamin, or supplement below."
|
| 154 |
+
)
|
| 155 |
return (
|
| 156 |
+
"Common medications are included. "
|
| 157 |
+
"You can also type any medication, vitamin, or supplement below."
|
| 158 |
)
|