Spaces:
Sleeping
Sleeping
Alex Amari commited on
Commit ·
718b72b
1
Parent(s): d37d00d
Tiers 3-4: code organization, map placeholder, download, accessibility
Browse files- __pycache__/app.cpython-312-pytest-7.4.4.pyc +0 -0
- app.py +249 -37
__pycache__/app.cpython-312-pytest-7.4.4.pyc
ADDED
|
Binary file (44.3 kB). View file
|
|
|
app.py
CHANGED
|
@@ -2,23 +2,33 @@
|
|
| 2 |
"""
|
| 3 |
Connecticut Hospital Financial Assistance Screener v4
|
| 4 |
Deployed on Hugging Face Spaces
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
|
|
|
|
|
|
| 7 |
import os
|
| 8 |
import re
|
|
|
|
| 9 |
import gradio as gr
|
| 10 |
from openai import OpenAI
|
| 11 |
from geopy.geocoders import Nominatim
|
| 12 |
from geopy.distance import geodesic
|
| 13 |
import time
|
| 14 |
|
| 15 |
-
# Load API key from environment variable (set in HF Spaces secrets)
|
| 16 |
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
|
| 17 |
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 18 |
DEMO_PASSWORD = "ct2026"
|
| 19 |
|
| 20 |
geolocator = Nominatim(user_agent="ct_hospital_screener")
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
HOSPITALS = {
|
| 23 |
"Yale New Haven Health": {
|
| 24 |
"free_care_threshold": 250,
|
|
@@ -46,7 +56,7 @@ HOSPITALS = {
|
|
| 46 |
"asset_limit": None,
|
| 47 |
"contact": "860-714-1657",
|
| 48 |
"special_notes": "Medicaid exhaustion required",
|
| 49 |
-
"location": (41.
|
| 50 |
"city": "Hartford",
|
| 51 |
"fap_url": "https://www.trinityhealthofne.org/for-patients/billing-and-financial-resources"
|
| 52 |
},
|
|
@@ -112,11 +122,14 @@ HOSPITALS = {
|
|
| 112 |
}
|
| 113 |
}
|
| 114 |
|
| 115 |
-
#
|
| 116 |
# Source: https://aspe.hhs.gov/topics/poverty-economic-mobility/poverty-guidelines
|
| 117 |
# Updated: January 2025. Check annually for new guidelines.
|
|
|
|
| 118 |
FPL_2025 = {1: 15650, 2: 21150, 3: 26650, 4: 32150, 5: 37650, 6: 43150, 7: 48650, 8: 54150}
|
| 119 |
|
|
|
|
|
|
|
| 120 |
TRANSLATIONS = {
|
| 121 |
"en": {
|
| 122 |
"title": "Connecticut Hospital Financial Assistance Screener",
|
|
@@ -144,7 +157,8 @@ TRANSLATIONS = {
|
|
| 144 |
"sources": "Source Documents",
|
| 145 |
"disclaimer": "⚠️ This result was generated by AI and is for informational purposes only. It does not guarantee accuracy or constitute legal or financial advice. Please contact the hospital directly to confirm eligibility and complete the formal application process.",
|
| 146 |
"eligible": "LIKELY ELIGIBLE",
|
| 147 |
-
"not_eligible": "MAY NOT QUALIFY"
|
|
|
|
| 148 |
},
|
| 149 |
"es": {
|
| 150 |
"title": "Evaluador de Asistencia Financiera Hospitalaria de Connecticut",
|
|
@@ -172,27 +186,51 @@ TRANSLATIONS = {
|
|
| 172 |
"sources": "Documentos Fuente",
|
| 173 |
"disclaimer": "⚠️ Este resultado fue generado por IA y es solo para fines informativos. No garantiza precisión ni constituye asesoramiento legal o financiero. Comuníquese directamente con el hospital para confirmar la elegibilidad y completar el proceso de solicitud formal.",
|
| 174 |
"eligible": "PROBABLEMENTE ELEGIBLE",
|
| 175 |
-
"not_eligible": "PUEDE NO CALIFICAR"
|
|
|
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
|
|
|
|
|
|
| 179 |
def calculate_fpl_percentage(income, household_size):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
if household_size <= 8:
|
| 181 |
fpl_base = FPL_2025[household_size]
|
| 182 |
else:
|
| 183 |
fpl_base = FPL_2025[8] + (5500 * (household_size - 8))
|
| 184 |
return round((income / fpl_base) * 100, 1)
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
def find_nearby_hospitals(zip_code, lang):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
t = TRANSLATIONS[lang]
|
| 188 |
try:
|
| 189 |
if not re.match(r'^06[0-9]{3}$', str(zip_code).strip()):
|
| 190 |
-
msg = "Please enter a valid Connecticut ZIP code (060xx
|
| 191 |
return msg, []
|
| 192 |
|
| 193 |
-
location =
|
| 194 |
if not location:
|
| 195 |
-
return "Invalid ZIP code" if lang == "en" else "
|
| 196 |
|
| 197 |
user_coords = (location.latitude, location.longitude)
|
| 198 |
distances = []
|
|
@@ -209,9 +247,9 @@ def find_nearby_hospitals(zip_code, lang):
|
|
| 209 |
choice_label = f"{name} ({data['city']}, {dist:.1f} mi)"
|
| 210 |
choices.append(choice_label)
|
| 211 |
cards += f"""### {name}
|
| 212 |
-
📍 {data['city']} — {dist:.1f} {t['miles_away']}
|
| 213 |
-
💰 {t['free_care']} {data['free_care_threshold']}% FPL | {t['sliding_scale']} {data['sliding_scale_max']}% FPL
|
| 214 |
-
📞 {data['contact']}
|
| 215 |
|
| 216 |
---
|
| 217 |
"""
|
|
@@ -221,6 +259,12 @@ def find_nearby_hospitals(zip_code, lang):
|
|
| 221 |
return f"Error: {str(e)}", []
|
| 222 |
|
| 223 |
def determine_eligibility(hospital_name, income, household_size, has_snap_wic, lang, has_insurance=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
if "(" in hospital_name:
|
| 225 |
hospital_name = hospital_name.split(" (")[0]
|
| 226 |
|
|
@@ -280,10 +324,14 @@ def determine_eligibility(hospital_name, income, household_size, has_snap_wic, l
|
|
| 280 |
|
| 281 |
return result
|
| 282 |
|
|
|
|
|
|
|
| 283 |
def generate_explanation_streaming(data):
|
| 284 |
-
"""
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
"""
|
| 288 |
lang = data["lang"]
|
| 289 |
t = TRANSLATIONS[lang]
|
|
@@ -401,9 +449,14 @@ PA 24-81 Eligible: {'Yes' if data['pa_24_81_eligible'] else 'No'}"""
|
|
| 401 |
fallback = "Detailed explanation temporarily unavailable. Please review the eligibility summary above and contact the hospital directly." if lang == "en" else "La explicación detallada no está disponible temporalmente. Revise el resumen de elegibilidad anterior y comuníquese directamente con el hospital."
|
| 402 |
yield fallback
|
| 403 |
|
|
|
|
|
|
|
| 404 |
def format_results_streaming(data, lang):
|
| 405 |
-
"""
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
| 407 |
"""
|
| 408 |
t = TRANSLATIONS[lang]
|
| 409 |
|
|
@@ -419,7 +472,7 @@ def format_results_streaming(data, lang):
|
|
| 419 |
|
| 420 |
### {t['status']}: {data['eligibility_status']}
|
| 421 |
|
| 422 |
-
💵 ${data['income']:,.0f} ({data['fpl_percentage']}% FPL)
|
| 423 |
|
| 424 |
---
|
| 425 |
|
|
@@ -430,19 +483,19 @@ def format_results_streaming(data, lang):
|
|
| 430 |
|
| 431 |
---
|
| 432 |
|
| 433 |
-
📞
|
| 434 |
|
| 435 |
---
|
| 436 |
|
| 437 |
-
### {t['sources']}
|
| 438 |
|
| 439 |
-
📄 [**{data['hospital']} Financial Assistance Policy**]({data['fap_url']})
|
| 440 |
|
| 441 |
-
📄 [**Connecticut Public Act 24-81**](https://www.cga.ct.gov/2024/ba/pdf/2024HB-05320-R000149-BA.pdf)
|
| 442 |
|
| 443 |
-
📄 [**2025 Federal Poverty Level Guidelines**](https://www.healthcare.gov/glossary/federal-poverty-level-fpl/)
|
| 444 |
|
| 445 |
-
📄 [**CT Office of the Healthcare Advocate**](https://portal.ct.gov/oha)
|
| 446 |
|
| 447 |
---
|
| 448 |
|
|
@@ -460,7 +513,37 @@ def format_results_streaming(data, lang):
|
|
| 460 |
# Always append footer after streaming completes
|
| 461 |
yield base + partial_explanation + footer
|
| 462 |
|
| 463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
custom_css = """
|
| 465 |
/* CT.gov inspired styling */
|
| 466 |
.gradio-container {
|
|
@@ -538,7 +621,10 @@ label {
|
|
| 538 |
}
|
| 539 |
"""
|
| 540 |
|
|
|
|
|
|
|
| 541 |
def create_interface():
|
|
|
|
| 542 |
with gr.Blocks(
|
| 543 |
theme=gr.themes.Default(primary_hue="blue", neutral_hue="slate"),
|
| 544 |
css=custom_css
|
|
@@ -546,6 +632,7 @@ def create_interface():
|
|
| 546 |
|
| 547 |
lang_state = gr.State("en")
|
| 548 |
selected_hospital = gr.State("")
|
|
|
|
| 549 |
|
| 550 |
# Header with Beta tag
|
| 551 |
with gr.Row():
|
|
@@ -564,8 +651,17 @@ def create_interface():
|
|
| 564 |
with step1:
|
| 565 |
gr.Markdown("### How would you like to start?")
|
| 566 |
with gr.Row():
|
| 567 |
-
help_btn = gr.Button("🔍 Help me find a hospital", variant="primary", size="lg")
|
| 568 |
-
know_btn = gr.Button("🏥 I know my hospital", variant="secondary", size="lg")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
|
| 570 |
# Step 2a: Find hospital by ZIP
|
| 571 |
step2a = gr.Group(visible=False)
|
|
@@ -607,10 +703,15 @@ def create_interface():
|
|
| 607 |
step4 = gr.Group(visible=False)
|
| 608 |
with step4:
|
| 609 |
results_output = gr.Markdown()
|
| 610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
|
| 612 |
-
# Navigation functions
|
| 613 |
def show_find_path():
|
|
|
|
| 614 |
return [
|
| 615 |
gr.update(visible=False),
|
| 616 |
gr.update(visible=True),
|
|
@@ -620,6 +721,7 @@ def create_interface():
|
|
| 620 |
]
|
| 621 |
|
| 622 |
def show_know_path():
|
|
|
|
| 623 |
return [
|
| 624 |
gr.update(visible=False),
|
| 625 |
gr.update(visible=False),
|
|
@@ -629,6 +731,7 @@ def create_interface():
|
|
| 629 |
]
|
| 630 |
|
| 631 |
def back_to_start():
|
|
|
|
| 632 |
return [
|
| 633 |
gr.update(visible=True),
|
| 634 |
gr.update(visible=False),
|
|
@@ -638,12 +741,14 @@ def create_interface():
|
|
| 638 |
]
|
| 639 |
|
| 640 |
def search_hospitals_wrapper(zip_code, lang):
|
|
|
|
| 641 |
cards, choices = find_nearby_hospitals(zip_code, lang)
|
| 642 |
if choices:
|
| 643 |
return cards, gr.update(choices=choices, visible=True, value=choices[0]), gr.update(visible=True)
|
| 644 |
return cards, gr.update(visible=False), gr.update(visible=False)
|
| 645 |
|
| 646 |
def continue_from_search(hospital_choice, lang):
|
|
|
|
| 647 |
t = TRANSLATIONS[lang]
|
| 648 |
hospital_name = hospital_choice.split(" (")[0] if "(" in hospital_choice else hospital_choice
|
| 649 |
return [
|
|
@@ -658,6 +763,7 @@ def create_interface():
|
|
| 658 |
]
|
| 659 |
|
| 660 |
def continue_from_dropdown(hospital_name, lang):
|
|
|
|
| 661 |
t = TRANSLATIONS[lang]
|
| 662 |
return [
|
| 663 |
hospital_name,
|
|
@@ -671,16 +777,17 @@ def create_interface():
|
|
| 671 |
]
|
| 672 |
|
| 673 |
def check_eligibility_wrapper(hospital, income, household, snap, insurance, lang):
|
|
|
|
| 674 |
t = TRANSLATIONS[lang]
|
| 675 |
|
| 676 |
# Input validation
|
| 677 |
if income is None or income <= 0:
|
| 678 |
-
gr.Warning("Please enter a valid income greater than $0." if lang == "en" else "Ingrese un ingreso
|
| 679 |
-
yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update()]
|
| 680 |
return
|
| 681 |
if household is None or int(household) < 1:
|
| 682 |
-
gr.Warning("Household size must be at least 1." if lang == "en" else "El
|
| 683 |
-
yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update()]
|
| 684 |
return
|
| 685 |
|
| 686 |
has_snap = (snap == t['yes'])
|
|
@@ -692,7 +799,8 @@ def create_interface():
|
|
| 692 |
gr.update(visible=False),
|
| 693 |
gr.update(visible=False),
|
| 694 |
gr.update(visible=False),
|
| 695 |
-
gr.update(visible=True)
|
|
|
|
| 696 |
]
|
| 697 |
|
| 698 |
for partial_result in format_results_streaming(data, lang):
|
|
@@ -701,11 +809,24 @@ def create_interface():
|
|
| 701 |
gr.update(),
|
| 702 |
gr.update(),
|
| 703 |
gr.update(),
|
| 704 |
-
gr.update()
|
|
|
|
| 705 |
]
|
| 706 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
def update_lang(choice):
|
| 708 |
-
|
|
|
|
| 709 |
|
| 710 |
# Wire up events
|
| 711 |
lang_toggle.change(fn=update_lang, inputs=[lang_toggle], outputs=[lang_state])
|
|
@@ -738,12 +859,103 @@ def create_interface():
|
|
| 738 |
check_btn.click(
|
| 739 |
fn=check_eligibility_wrapper,
|
| 740 |
inputs=[selected_hospital, income_input, household_input, snap_radio, insurance_radio, lang_state],
|
| 741 |
-
outputs=[results_output, step1, step2a, step3, step4],
|
| 742 |
api_name=False,
|
| 743 |
concurrency_limit=5
|
| 744 |
)
|
| 745 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
return demo
|
| 747 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
demo = create_interface()
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
"""
|
| 3 |
Connecticut Hospital Financial Assistance Screener v4
|
| 4 |
Deployed on Hugging Face Spaces
|
| 5 |
+
|
| 6 |
+
Single-file Gradio application that screens CT residents for hospital
|
| 7 |
+
financial assistance eligibility based on FPL thresholds and PA 24-81.
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
# === IMPORTS AND CONFIGURATION ===
|
| 11 |
+
|
| 12 |
import os
|
| 13 |
import re
|
| 14 |
+
import tempfile
|
| 15 |
import gradio as gr
|
| 16 |
from openai import OpenAI
|
| 17 |
from geopy.geocoders import Nominatim
|
| 18 |
from geopy.distance import geodesic
|
| 19 |
import time
|
| 20 |
|
|
|
|
| 21 |
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
|
| 22 |
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 23 |
DEMO_PASSWORD = "ct2026"
|
| 24 |
|
| 25 |
geolocator = Nominatim(user_agent="ct_hospital_screener")
|
| 26 |
|
| 27 |
+
# Simple in-memory cache for ZIP code geocoding results
|
| 28 |
+
_zip_geocode_cache = {}
|
| 29 |
+
|
| 30 |
+
# === HOSPITAL DATA ===
|
| 31 |
+
|
| 32 |
HOSPITALS = {
|
| 33 |
"Yale New Haven Health": {
|
| 34 |
"free_care_threshold": 250,
|
|
|
|
| 56 |
"asset_limit": None,
|
| 57 |
"contact": "860-714-1657",
|
| 58 |
"special_notes": "Medicaid exhaustion required",
|
| 59 |
+
"location": (41.7726, -72.6932), # Saint Francis Hospital, Hartford
|
| 60 |
"city": "Hartford",
|
| 61 |
"fap_url": "https://www.trinityhealthofne.org/for-patients/billing-and-financial-resources"
|
| 62 |
},
|
|
|
|
| 122 |
}
|
| 123 |
}
|
| 124 |
|
| 125 |
+
# === FEDERAL POVERTY LEVEL DATA ===
|
| 126 |
# Source: https://aspe.hhs.gov/topics/poverty-economic-mobility/poverty-guidelines
|
| 127 |
# Updated: January 2025. Check annually for new guidelines.
|
| 128 |
+
|
| 129 |
FPL_2025 = {1: 15650, 2: 21150, 3: 26650, 4: 32150, 5: 37650, 6: 43150, 7: 48650, 8: 54150}
|
| 130 |
|
| 131 |
+
# === TRANSLATIONS ===
|
| 132 |
+
|
| 133 |
TRANSLATIONS = {
|
| 134 |
"en": {
|
| 135 |
"title": "Connecticut Hospital Financial Assistance Screener",
|
|
|
|
| 157 |
"sources": "Source Documents",
|
| 158 |
"disclaimer": "⚠️ This result was generated by AI and is for informational purposes only. It does not guarantee accuracy or constitute legal or financial advice. Please contact the hospital directly to confirm eligibility and complete the formal application process.",
|
| 159 |
"eligible": "LIKELY ELIGIBLE",
|
| 160 |
+
"not_eligible": "MAY NOT QUALIFY",
|
| 161 |
+
"download_button": "Download Results"
|
| 162 |
},
|
| 163 |
"es": {
|
| 164 |
"title": "Evaluador de Asistencia Financiera Hospitalaria de Connecticut",
|
|
|
|
| 186 |
"sources": "Documentos Fuente",
|
| 187 |
"disclaimer": "⚠️ Este resultado fue generado por IA y es solo para fines informativos. No garantiza precisión ni constituye asesoramiento legal o financiero. Comuníquese directamente con el hospital para confirmar la elegibilidad y completar el proceso de solicitud formal.",
|
| 188 |
"eligible": "PROBABLEMENTE ELEGIBLE",
|
| 189 |
+
"not_eligible": "PUEDE NO CALIFICAR",
|
| 190 |
+
"download_button": "Descargar Resultados"
|
| 191 |
}
|
| 192 |
}
|
| 193 |
|
| 194 |
+
# === ELIGIBILITY LOGIC ===
|
| 195 |
+
|
| 196 |
def calculate_fpl_percentage(income, household_size):
|
| 197 |
+
"""Calculate income as a percentage of the Federal Poverty Level.
|
| 198 |
+
|
| 199 |
+
For households larger than 8, adds $5,500 per additional person
|
| 200 |
+
to the base FPL amount per HHS guidelines.
|
| 201 |
+
"""
|
| 202 |
if household_size <= 8:
|
| 203 |
fpl_base = FPL_2025[household_size]
|
| 204 |
else:
|
| 205 |
fpl_base = FPL_2025[8] + (5500 * (household_size - 8))
|
| 206 |
return round((income / fpl_base) * 100, 1)
|
| 207 |
|
| 208 |
+
# === HOSPITAL SEARCH ===
|
| 209 |
+
|
| 210 |
+
def _geocode_zip(zip_code):
|
| 211 |
+
"""Geocode a CT ZIP code with in-memory caching to avoid redundant API calls."""
|
| 212 |
+
zip_code = str(zip_code).strip()
|
| 213 |
+
if zip_code in _zip_geocode_cache:
|
| 214 |
+
return _zip_geocode_cache[zip_code]
|
| 215 |
+
location = geolocator.geocode(f"{zip_code}, Connecticut, USA")
|
| 216 |
+
_zip_geocode_cache[zip_code] = location
|
| 217 |
+
return location
|
| 218 |
+
|
| 219 |
def find_nearby_hospitals(zip_code, lang):
|
| 220 |
+
"""Find the 3 nearest CT hospitals to a given ZIP code.
|
| 221 |
+
|
| 222 |
+
Validates the ZIP is a CT format (060xx-069xx), geocodes it,
|
| 223 |
+
then returns markdown cards and choice labels sorted by distance.
|
| 224 |
+
"""
|
| 225 |
t = TRANSLATIONS[lang]
|
| 226 |
try:
|
| 227 |
if not re.match(r'^06[0-9]{3}$', str(zip_code).strip()):
|
| 228 |
+
msg = "Please enter a valid Connecticut ZIP code (060xx\u2013069xx)." if lang == "en" else "Ingrese un c\u00f3digo postal v\u00e1lido de Connecticut (060xx\u2013069xx)."
|
| 229 |
return msg, []
|
| 230 |
|
| 231 |
+
location = _geocode_zip(zip_code)
|
| 232 |
if not location:
|
| 233 |
+
return "Invalid ZIP code" if lang == "en" else "C\u00f3digo postal inv\u00e1lido", []
|
| 234 |
|
| 235 |
user_coords = (location.latitude, location.longitude)
|
| 236 |
distances = []
|
|
|
|
| 247 |
choice_label = f"{name} ({data['city']}, {dist:.1f} mi)"
|
| 248 |
choices.append(choice_label)
|
| 249 |
cards += f"""### {name}
|
| 250 |
+
📍 Location: {data['city']} — {dist:.1f} {t['miles_away']}
|
| 251 |
+
💰 Assistance: {t['free_care']} {data['free_care_threshold']}% FPL | {t['sliding_scale']} {data['sliding_scale_max']}% FPL
|
| 252 |
+
📞 Phone: {data['contact']}
|
| 253 |
|
| 254 |
---
|
| 255 |
"""
|
|
|
|
| 259 |
return f"Error: {str(e)}", []
|
| 260 |
|
| 261 |
def determine_eligibility(hospital_name, income, household_size, has_snap_wic, lang, has_insurance=False):
|
| 262 |
+
"""Determine financial assistance eligibility for a given hospital.
|
| 263 |
+
|
| 264 |
+
Checks PA 24-81 presumptive eligibility first, then free care and
|
| 265 |
+
sliding scale thresholds. Returns a result dict with all data needed
|
| 266 |
+
for the explanation and display.
|
| 267 |
+
"""
|
| 268 |
if "(" in hospital_name:
|
| 269 |
hospital_name = hospital_name.split(" (")[0]
|
| 270 |
|
|
|
|
| 324 |
|
| 325 |
return result
|
| 326 |
|
| 327 |
+
# === AI EXPLANATION GENERATION ===
|
| 328 |
+
|
| 329 |
def generate_explanation_streaming(data):
|
| 330 |
+
"""Stream an AI-generated explanation of the eligibility result.
|
| 331 |
+
|
| 332 |
+
Selects a confidence tier (statutory / strong / moderate / negative)
|
| 333 |
+
to calibrate the language used by the LLM, then streams the response
|
| 334 |
+
token-by-token. Falls back to a static message on API failure.
|
| 335 |
"""
|
| 336 |
lang = data["lang"]
|
| 337 |
t = TRANSLATIONS[lang]
|
|
|
|
| 449 |
fallback = "Detailed explanation temporarily unavailable. Please review the eligibility summary above and contact the hospital directly." if lang == "en" else "La explicación detallada no está disponible temporalmente. Revise el resumen de elegibilidad anterior y comuníquese directamente con el hospital."
|
| 450 |
yield fallback
|
| 451 |
|
| 452 |
+
# === RESULTS FORMATTING ===
|
| 453 |
+
|
| 454 |
def format_results_streaming(data, lang):
|
| 455 |
+
"""Build and stream the full results page as markdown.
|
| 456 |
+
|
| 457 |
+
Renders a disclaimer banner, eligibility header, streamed AI explanation,
|
| 458 |
+
and a footer with contact info and source links. The footer is always
|
| 459 |
+
appended regardless of whether the AI call succeeded.
|
| 460 |
"""
|
| 461 |
t = TRANSLATIONS[lang]
|
| 462 |
|
|
|
|
| 472 |
|
| 473 |
### {t['status']}: {data['eligibility_status']}
|
| 474 |
|
| 475 |
+
💵 Income: ${data['income']:,.0f} ({data['fpl_percentage']}% FPL)
|
| 476 |
|
| 477 |
---
|
| 478 |
|
|
|
|
| 483 |
|
| 484 |
---
|
| 485 |
|
| 486 |
+
📞 Contact: **{data['contact']}**
|
| 487 |
|
| 488 |
---
|
| 489 |
|
| 490 |
+
### 📄 {t['sources']}
|
| 491 |
|
| 492 |
+
📄 Document: [**{data['hospital']} Financial Assistance Policy**]({data['fap_url']})
|
| 493 |
|
| 494 |
+
📄 Document: [**Connecticut Public Act 24-81**](https://www.cga.ct.gov/2024/ba/pdf/2024HB-05320-R000149-BA.pdf)
|
| 495 |
|
| 496 |
+
📄 Document: [**2025 Federal Poverty Level Guidelines**](https://www.healthcare.gov/glossary/federal-poverty-level-fpl/)
|
| 497 |
|
| 498 |
+
📄 Document: [**CT Office of the Healthcare Advocate**](https://portal.ct.gov/oha)
|
| 499 |
|
| 500 |
---
|
| 501 |
|
|
|
|
| 513 |
# Always append footer after streaming completes
|
| 514 |
yield base + partial_explanation + footer
|
| 515 |
|
| 516 |
+
|
| 517 |
+
def generate_download_content(data, lang):
|
| 518 |
+
"""Generate a plain-text summary of the eligibility result for download."""
|
| 519 |
+
t = TRANSLATIONS[lang]
|
| 520 |
+
lines = [
|
| 521 |
+
f"Connecticut Hospital Financial Assistance — Eligibility Summary",
|
| 522 |
+
f"={'=' * 59}",
|
| 523 |
+
f"",
|
| 524 |
+
f"Hospital: {data['hospital']}",
|
| 525 |
+
f"Status: {data['eligibility_status']}",
|
| 526 |
+
f"Discount Level: {data['discount_level']}",
|
| 527 |
+
f"",
|
| 528 |
+
f"Annual Household Income: ${data['income']:,.0f}",
|
| 529 |
+
f"Household Size: {data['household_size']}",
|
| 530 |
+
f"FPL Percentage: {data['fpl_percentage']}%",
|
| 531 |
+
f"SNAP/WIC Enrolled: {'Yes' if data['has_snap_wic'] else 'No'}",
|
| 532 |
+
f"Has Insurance: {'Yes' if data.get('has_insurance') else 'No'}",
|
| 533 |
+
f"PA 24-81 Eligible: {'Yes' if data['pa_24_81_eligible'] else 'No'}",
|
| 534 |
+
f"",
|
| 535 |
+
f"Contact: {data['contact']}",
|
| 536 |
+
f"Asset Limit: {data['asset_limit'] or 'None'}",
|
| 537 |
+
f"FAP URL: {data['fap_url']}",
|
| 538 |
+
f"",
|
| 539 |
+
f"---",
|
| 540 |
+
f"",
|
| 541 |
+
t['disclaimer'],
|
| 542 |
+
]
|
| 543 |
+
return "\n".join(lines)
|
| 544 |
+
|
| 545 |
+
# === CUSTOM CSS ===
|
| 546 |
+
|
| 547 |
custom_css = """
|
| 548 |
/* CT.gov inspired styling */
|
| 549 |
.gradio-container {
|
|
|
|
| 621 |
}
|
| 622 |
"""
|
| 623 |
|
| 624 |
+
# === GRADIO UI ===
|
| 625 |
+
|
| 626 |
def create_interface():
|
| 627 |
+
"""Build and return the Gradio Blocks interface with all steps and event wiring."""
|
| 628 |
with gr.Blocks(
|
| 629 |
theme=gr.themes.Default(primary_hue="blue", neutral_hue="slate"),
|
| 630 |
css=custom_css
|
|
|
|
| 632 |
|
| 633 |
lang_state = gr.State("en")
|
| 634 |
selected_hospital = gr.State("")
|
| 635 |
+
last_result_state = gr.State(None) # Stores the most recent eligibility result dict
|
| 636 |
|
| 637 |
# Header with Beta tag
|
| 638 |
with gr.Row():
|
|
|
|
| 651 |
with step1:
|
| 652 |
gr.Markdown("### How would you like to start?")
|
| 653 |
with gr.Row():
|
| 654 |
+
help_btn = gr.Button("🔍 Search: Help me find a hospital", variant="primary", size="lg")
|
| 655 |
+
know_btn = gr.Button("🏥 Select: I know my hospital", variant="secondary", size="lg")
|
| 656 |
+
|
| 657 |
+
# TODO: Replace with actual OHA Google Map embed code
|
| 658 |
+
gr.HTML(
|
| 659 |
+
'<div style="background: #e9ecef; border: 2px dashed #adb5bd; border-radius: 8px; '
|
| 660 |
+
'padding: 2rem; text-align: center; color: #6c757d; margin-top: 1rem;">'
|
| 661 |
+
'<p style="font-size: 1.1rem; margin: 0;">🗺️ Map: Connecticut Hospital Locations</p>'
|
| 662 |
+
'<p style="font-size: 0.85rem; margin: 0.5rem 0 0 0;">Interactive map coming soon</p>'
|
| 663 |
+
'</div>'
|
| 664 |
+
)
|
| 665 |
|
| 666 |
# Step 2a: Find hospital by ZIP
|
| 667 |
step2a = gr.Group(visible=False)
|
|
|
|
| 703 |
step4 = gr.Group(visible=False)
|
| 704 |
with step4:
|
| 705 |
results_output = gr.Markdown()
|
| 706 |
+
with gr.Row():
|
| 707 |
+
restart_btn = gr.Button("🔄 Restart: Check Another Hospital", variant="secondary")
|
| 708 |
+
download_btn = gr.Button("📥 Download: Save Results", variant="secondary")
|
| 709 |
+
download_file = gr.File(visible=False, label="Download")
|
| 710 |
+
|
| 711 |
+
# --- Navigation functions ---
|
| 712 |
|
|
|
|
| 713 |
def show_find_path():
|
| 714 |
+
"""Show the ZIP code search path (Step 2a)."""
|
| 715 |
return [
|
| 716 |
gr.update(visible=False),
|
| 717 |
gr.update(visible=True),
|
|
|
|
| 721 |
]
|
| 722 |
|
| 723 |
def show_know_path():
|
| 724 |
+
"""Show the hospital dropdown path (Step 2b)."""
|
| 725 |
return [
|
| 726 |
gr.update(visible=False),
|
| 727 |
gr.update(visible=False),
|
|
|
|
| 731 |
]
|
| 732 |
|
| 733 |
def back_to_start():
|
| 734 |
+
"""Return to Step 1."""
|
| 735 |
return [
|
| 736 |
gr.update(visible=True),
|
| 737 |
gr.update(visible=False),
|
|
|
|
| 741 |
]
|
| 742 |
|
| 743 |
def search_hospitals_wrapper(zip_code, lang):
|
| 744 |
+
"""Geocode a ZIP and return nearby hospital cards and radio choices."""
|
| 745 |
cards, choices = find_nearby_hospitals(zip_code, lang)
|
| 746 |
if choices:
|
| 747 |
return cards, gr.update(choices=choices, visible=True, value=choices[0]), gr.update(visible=True)
|
| 748 |
return cards, gr.update(visible=False), gr.update(visible=False)
|
| 749 |
|
| 750 |
def continue_from_search(hospital_choice, lang):
|
| 751 |
+
"""Advance from ZIP search results to the eligibility form."""
|
| 752 |
t = TRANSLATIONS[lang]
|
| 753 |
hospital_name = hospital_choice.split(" (")[0] if "(" in hospital_choice else hospital_choice
|
| 754 |
return [
|
|
|
|
| 763 |
]
|
| 764 |
|
| 765 |
def continue_from_dropdown(hospital_name, lang):
|
| 766 |
+
"""Advance from hospital dropdown to the eligibility form."""
|
| 767 |
t = TRANSLATIONS[lang]
|
| 768 |
return [
|
| 769 |
hospital_name,
|
|
|
|
| 777 |
]
|
| 778 |
|
| 779 |
def check_eligibility_wrapper(hospital, income, household, snap, insurance, lang):
|
| 780 |
+
"""Validate inputs, run eligibility check, and stream results."""
|
| 781 |
t = TRANSLATIONS[lang]
|
| 782 |
|
| 783 |
# Input validation
|
| 784 |
if income is None or income <= 0:
|
| 785 |
+
gr.Warning("Please enter a valid income greater than $0." if lang == "en" else "Ingrese un ingreso v\u00e1lido mayor que $0.")
|
| 786 |
+
yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), None]
|
| 787 |
return
|
| 788 |
if household is None or int(household) < 1:
|
| 789 |
+
gr.Warning("Household size must be at least 1." if lang == "en" else "El tama\u00f1o del hogar debe ser al menos 1.")
|
| 790 |
+
yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), None]
|
| 791 |
return
|
| 792 |
|
| 793 |
has_snap = (snap == t['yes'])
|
|
|
|
| 799 |
gr.update(visible=False),
|
| 800 |
gr.update(visible=False),
|
| 801 |
gr.update(visible=False),
|
| 802 |
+
gr.update(visible=True),
|
| 803 |
+
data
|
| 804 |
]
|
| 805 |
|
| 806 |
for partial_result in format_results_streaming(data, lang):
|
|
|
|
| 809 |
gr.update(),
|
| 810 |
gr.update(),
|
| 811 |
gr.update(),
|
| 812 |
+
gr.update(),
|
| 813 |
+
data
|
| 814 |
]
|
| 815 |
|
| 816 |
+
def download_results(last_result, lang):
|
| 817 |
+
"""Generate a temporary text file with the eligibility summary for download."""
|
| 818 |
+
if not last_result:
|
| 819 |
+
gr.Warning("No results to download." if lang == "en" else "No hay resultados para descargar.")
|
| 820 |
+
return gr.update(visible=False)
|
| 821 |
+
content = generate_download_content(last_result, lang)
|
| 822 |
+
tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', prefix='eligibility_summary_', delete=False)
|
| 823 |
+
tmp.write(content)
|
| 824 |
+
tmp.close()
|
| 825 |
+
return gr.update(value=tmp.name, visible=True)
|
| 826 |
+
|
| 827 |
def update_lang(choice):
|
| 828 |
+
"""Convert the language toggle selection to a language code."""
|
| 829 |
+
return "es" if choice == "Espa\u00f1ol" else "en"
|
| 830 |
|
| 831 |
# Wire up events
|
| 832 |
lang_toggle.change(fn=update_lang, inputs=[lang_toggle], outputs=[lang_state])
|
|
|
|
| 859 |
check_btn.click(
|
| 860 |
fn=check_eligibility_wrapper,
|
| 861 |
inputs=[selected_hospital, income_input, household_input, snap_radio, insurance_radio, lang_state],
|
| 862 |
+
outputs=[results_output, step1, step2a, step3, step4, last_result_state],
|
| 863 |
api_name=False,
|
| 864 |
concurrency_limit=5
|
| 865 |
)
|
| 866 |
|
| 867 |
+
download_btn.click(
|
| 868 |
+
fn=download_results,
|
| 869 |
+
inputs=[last_result_state, lang_state],
|
| 870 |
+
outputs=[download_file]
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
return demo
|
| 874 |
|
| 875 |
+
# === LAUNCH ===
|
| 876 |
+
# HF Spaces auto-detects the `demo` Blocks instance.
|
| 877 |
+
# The explicit launch() is guarded so pytest can import without starting a server.
|
| 878 |
+
|
| 879 |
demo = create_interface()
|
| 880 |
+
|
| 881 |
+
if __name__ == "__main__":
|
| 882 |
+
demo.launch(auth=("oha", DEMO_PASSWORD))
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
# === TESTS (pytest-compatible) ===
|
| 886 |
+
# Run with: OPENAI_API_KEY=test pytest app.py -v
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
def test_calculate_fpl_percentage_single_person():
|
| 890 |
+
"""FPL for a single person earning exactly the FPL amount should be 100%."""
|
| 891 |
+
result = calculate_fpl_percentage(15650, 1)
|
| 892 |
+
assert result == 100.0
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def test_calculate_fpl_percentage_household_of_4():
|
| 896 |
+
"""A family of 4 at $32,150 should be exactly 100% FPL."""
|
| 897 |
+
result = calculate_fpl_percentage(32150, 4)
|
| 898 |
+
assert result == 100.0
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
def test_calculate_fpl_percentage_large_household():
|
| 902 |
+
"""Household of 10 should use the overflow formula: FPL_8 + 5500*(10-8)."""
|
| 903 |
+
expected_base = 54150 + (5500 * 2) # 65150
|
| 904 |
+
income = expected_base
|
| 905 |
+
result = calculate_fpl_percentage(income, 10)
|
| 906 |
+
assert result == 100.0
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
def test_calculate_fpl_percentage_half():
|
| 910 |
+
"""Income at half of FPL for household of 1 should be ~50%."""
|
| 911 |
+
result = calculate_fpl_percentage(15650 / 2, 1)
|
| 912 |
+
assert result == 50.0
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def test_determine_eligibility_free_care():
|
| 916 |
+
"""Income well below the free care threshold should yield 100% Discount."""
|
| 917 |
+
result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "en")
|
| 918 |
+
assert result["eligibility_status"] == "LIKELY ELIGIBLE"
|
| 919 |
+
assert result["discount_level"] == "100% Discount"
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
def test_determine_eligibility_sliding_scale():
|
| 923 |
+
"""Income between free care and sliding scale thresholds → Partial Discount."""
|
| 924 |
+
# Yale: free_care=250%, sliding=550%. For household of 1, FPL=$15,650.
|
| 925 |
+
# 300% FPL = $46,950 → above 250% but below 550%.
|
| 926 |
+
result = determine_eligibility("Yale New Haven Health", 46950, 1, False, "en")
|
| 927 |
+
assert result["eligibility_status"] == "LIKELY ELIGIBLE"
|
| 928 |
+
assert result["discount_level"] == "Partial Discount"
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def test_determine_eligibility_not_eligible():
|
| 932 |
+
"""Income above sliding scale max should not qualify."""
|
| 933 |
+
# 600% FPL for household of 1 = $93,900 → above Yale's 550%.
|
| 934 |
+
result = determine_eligibility("Yale New Haven Health", 93900, 1, False, "en")
|
| 935 |
+
assert result["eligibility_status"] == "MAY NOT QUALIFY"
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def test_determine_eligibility_pa_24_81():
|
| 939 |
+
"""SNAP/WIC enrollee under 250% FPL triggers PA 24-81 presumptive eligibility."""
|
| 940 |
+
result = determine_eligibility("Yale New Haven Health", 20000, 1, True, "en")
|
| 941 |
+
assert result["pa_24_81_eligible"] is True
|
| 942 |
+
assert "PA 24-81" in result["discount_level"]
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
def test_determine_eligibility_unknown_hospital():
|
| 946 |
+
"""An unknown hospital name should return an error result, not crash."""
|
| 947 |
+
result = determine_eligibility("Nonexistent Hospital", 30000, 2, False, "en")
|
| 948 |
+
assert result.get("error") is True
|
| 949 |
+
assert "not found" in result["eligibility_status"].lower()
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
def test_determine_eligibility_insurance_flag():
|
| 953 |
+
"""The has_insurance flag should be passed through to the result dict."""
|
| 954 |
+
result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "en", has_insurance=True)
|
| 955 |
+
assert result["has_insurance"] is True
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def test_determine_eligibility_spanish():
|
| 959 |
+
"""Spanish language should return Spanish status strings."""
|
| 960 |
+
result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "es")
|
| 961 |
+
assert result["eligibility_status"] == "PROBABLEMENTE ELEGIBLE"
|