Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """ | |
| Connecticut Hospital Financial Assistance Screener v4 | |
| Deployed on Hugging Face Spaces | |
| Single-file Gradio application that screens CT residents for hospital | |
| financial assistance eligibility based on FPL thresholds and PA 24-81. | |
| """ | |
| # === IMPORTS AND CONFIGURATION === | |
| import os | |
| import re | |
| import tempfile | |
| import gradio as gr | |
| from openai import OpenAI | |
| from geopy.geocoders import Nominatim | |
| from geopy.distance import geodesic | |
| from fpdf import FPDF | |
| import time | |
| OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] | |
| client = OpenAI(api_key=OPENAI_API_KEY) | |
| DEMO_PASSWORD = os.environ["DEMO_PASSWORD"] | |
| geolocator = Nominatim(user_agent="ct_hospital_screener") | |
| # Simple in-memory cache for ZIP code geocoding results | |
| _zip_geocode_cache = {} | |
| # === HOSPITAL DATA === | |
| HOSPITALS = { | |
| "Yale New Haven Health": { | |
| "free_care_threshold": 250, | |
| "sliding_scale_max": 550, | |
| "asset_limit": None, | |
| "contact": "877-442-2455", | |
| "special_notes": "No asset limit", | |
| "location": (41.3083, -72.9279), | |
| "city": "New Haven", | |
| "fap_url": "https://www.ynhhs.org/patient-care/billing-insurance/FAP-guidelines" | |
| }, | |
| "Hartford HealthCare": { | |
| "free_care_threshold": 250, | |
| "sliding_scale_max": 550, | |
| "asset_limit": "Liquid asset review", | |
| "contact": "877-442-2455", | |
| "special_notes": "Asset review required", | |
| "location": (41.7658, -72.6734), | |
| "city": "Hartford", | |
| "fap_url": "https://hartfordhealthcare.org/patients-visitors/patients/billing-insurance/financial-assistance" | |
| }, | |
| "Trinity Health of New England": { | |
| "free_care_threshold": 200, | |
| "sliding_scale_max": 400, | |
| "asset_limit": None, | |
| "contact": "860-714-1657", | |
| "special_notes": "Medicaid exhaustion required", | |
| "location": (41.7726, -72.6932), # Saint Francis Hospital, Hartford | |
| "city": "Hartford", | |
| "fap_url": "https://www.trinityhealthofne.org/for-patients/billing-and-financial-resources" | |
| }, | |
| "Stamford Health": { | |
| "free_care_threshold": 250, | |
| "sliding_scale_max": 400, | |
| "asset_limit": None, | |
| "contact": "203-276-7572", | |
| "special_notes": "No carve-outs", | |
| "location": (41.0534, -73.5387), | |
| "city": "Stamford", | |
| "fap_url": "https://www.stamfordhealth.org/patients/fap/" | |
| }, | |
| "Bristol Hospital": { | |
| "free_care_threshold": 250, | |
| "sliding_scale_max": 400, | |
| "asset_limit": "$7,500 / $15,000", | |
| "contact": "860-585-3035", | |
| "special_notes": "Strict asset limits", | |
| "location": (41.6718, -72.9493), | |
| "city": "Bristol", | |
| "fap_url": "https://www.bristolhealth.org/patients-and-visitors/cost-care-and-financial-assistance" | |
| }, | |
| "Day Kimball Healthcare": { | |
| "free_care_threshold": 200, | |
| "sliding_scale_max": 400, | |
| "asset_limit": "$100,000", | |
| "contact": "860-928-7024", | |
| "special_notes": "240-day window", | |
| "location": (41.8528, -71.9004), | |
| "city": "Putnam", | |
| "fap_url": "https://www.daykimball.org/resources/financial-services/" | |
| }, | |
| "UConn Health": { | |
| "free_care_threshold": 400, | |
| "sliding_scale_max": 400, | |
| "asset_limit": None, | |
| "contact": "860-679-4120", | |
| "special_notes": "Most generous in CT", | |
| "location": (41.7295, -72.7935), | |
| "city": "Farmington", | |
| "fap_url": "https://www.uconnhealth.org/patients-visitors/patient-resources/billing-costs-insurance" | |
| }, | |
| "Nuvance Health": { | |
| "free_care_threshold": 250, | |
| "sliding_scale_max": 400, | |
| "asset_limit": "Not specified", | |
| "contact": "845-788-9012", | |
| "special_notes": "Multi-location", | |
| "location": (41.3948, -73.4540), | |
| "city": "Danbury", | |
| "fap_url": "https://www.nuvancehealth.org/patients-and-visitors/billing-and-insurance/patient-financial-assistance" | |
| }, | |
| "Middlesex Health": { | |
| "free_care_threshold": 200, | |
| "sliding_scale_max": 400, | |
| "asset_limit": "Not specified", | |
| "contact": "860-358-6150", | |
| "special_notes": "Limited docs", | |
| "location": (41.5623, -72.6506), | |
| "city": "Middletown", | |
| "fap_url": "https://middlesexhealth.org/patients-and-visitors/financial-assistance-services" | |
| } | |
| } | |
| # === FEDERAL POVERTY LEVEL DATA === | |
| # Source: https://aspe.hhs.gov/topics/poverty-economic-mobility/poverty-guidelines | |
| # Updated: January 2025. Check annually for new guidelines. | |
| FPL_2025 = {1: 15650, 2: 21150, 3: 26650, 4: 32150, 5: 37650, 6: 43150, 7: 48650, 8: 54150} | |
| # === TRANSLATIONS === | |
| TRANSLATIONS = { | |
| "en": { | |
| "title": "Connecticut Hospital Financial Assistance Screener", | |
| "beta_tag": "BETA", | |
| "subtitle": "Check your eligibility for financial assistance", | |
| "help_choose": "Help me find a hospital", | |
| "know_hospital": "I know my hospital", | |
| "zip_label": "Enter your ZIP code", | |
| "search_button": "Search Hospitals", | |
| "select_hospital": "Select Hospital", | |
| "income_label": "Annual Household Income ($)", | |
| "household_label": "Number of People in Household", | |
| "snap_label": "Enrolled in SNAP or WIC?", | |
| "insurance_label": "Do you have health insurance?", | |
| "yes": "Yes", | |
| "no": "No", | |
| "check_button": "Check Eligibility", | |
| "continue_button": "Continue", | |
| "back_button": "Back", | |
| "miles_away": "miles away", | |
| "num_hospitals": "Number of hospitals to show", | |
| "free_care": "Free care up to", | |
| "sliding_scale": "Sliding scale up to", | |
| "status": "Status", | |
| "contact": "Contact", | |
| "sources": "Source Documents", | |
| "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.", | |
| "eligible": "LIKELY ELIGIBLE", | |
| "not_eligible": "MAY NOT QUALIFY", | |
| "download_button": "Download Results" | |
| }, | |
| "es": { | |
| "title": "Evaluador de Asistencia Financiera Hospitalaria de Connecticut", | |
| "beta_tag": "BETA", | |
| "subtitle": "Verifique su elegibilidad para asistencia financiera", | |
| "help_choose": "Ayúdame a encontrar un hospital", | |
| "know_hospital": "Conozco mi hospital", | |
| "zip_label": "Ingrese su código postal", | |
| "search_button": "Buscar Hospitales", | |
| "select_hospital": "Seleccione Hospital", | |
| "income_label": "Ingreso Anual del Hogar ($)", | |
| "household_label": "Número de Personas en el Hogar", | |
| "snap_label": "¿Inscrito en SNAP o WIC?", | |
| "insurance_label": "¿Tiene seguro de salud?", | |
| "yes": "Sí", | |
| "no": "No", | |
| "check_button": "Verificar Elegibilidad", | |
| "continue_button": "Continuar", | |
| "back_button": "Atrás", | |
| "miles_away": "millas de distancia", | |
| "num_hospitals": "Número de hospitales a mostrar", | |
| "free_care": "Atención gratuita hasta", | |
| "sliding_scale": "Escala móvil hasta", | |
| "status": "Estado", | |
| "contact": "Contacto", | |
| "sources": "Documentos Fuente", | |
| "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.", | |
| "eligible": "PROBABLEMENTE ELEGIBLE", | |
| "not_eligible": "PUEDE NO CALIFICAR", | |
| "download_button": "Descargar Resultados" | |
| } | |
| } | |
| # === ELIGIBILITY LOGIC === | |
| def calculate_fpl_percentage(income, household_size): | |
| """Calculate income as a percentage of the Federal Poverty Level. | |
| For households larger than 8, adds $5,500 per additional person | |
| to the base FPL amount per HHS guidelines. | |
| """ | |
| if household_size <= 8: | |
| fpl_base = FPL_2025[household_size] | |
| else: | |
| fpl_base = FPL_2025[8] + (5500 * (household_size - 8)) | |
| return round((income / fpl_base) * 100, 1) | |
| # === HOSPITAL SEARCH === | |
| def _geocode_zip(zip_code): | |
| """Geocode a CT ZIP code with in-memory caching to avoid redundant API calls.""" | |
| zip_code = str(zip_code).strip() | |
| if zip_code in _zip_geocode_cache: | |
| return _zip_geocode_cache[zip_code] | |
| location = geolocator.geocode(f"{zip_code}, Connecticut, USA") | |
| _zip_geocode_cache[zip_code] = location | |
| return location | |
| def find_nearby_hospitals(zip_code, lang, max_results=5): | |
| """Find the nearest CT hospitals to a given ZIP code. | |
| Validates the ZIP is a CT format (060xx-069xx), geocodes it, | |
| then returns markdown cards and choice labels sorted by distance. | |
| """ | |
| t = TRANSLATIONS[lang] | |
| try: | |
| if not re.match(r'^06[0-9]{3}$', str(zip_code).strip()): | |
| 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)." | |
| return msg, [] | |
| location = _geocode_zip(zip_code) | |
| if not location: | |
| return "Invalid ZIP code" if lang == "en" else "C\u00f3digo postal inv\u00e1lido", [] | |
| user_coords = (location.latitude, location.longitude) | |
| distances = [] | |
| for name, data in HOSPITALS.items(): | |
| dist = geodesic(user_coords, data["location"]).miles | |
| distances.append((name, dist, data)) | |
| distances.sort(key=lambda x: x[1]) | |
| nearest = distances[:int(max_results)] | |
| choices = [] | |
| cards = "" | |
| for name, dist, data in nearest: | |
| choice_label = f"{name} ({data['city']}, {dist:.1f} mi)" | |
| choices.append(choice_label) | |
| cards += f"""### {name} | |
| 📍 Location: {data['city']} — {dist:.1f} {t['miles_away']} | |
| 💰 Assistance: {t['free_care']} {data['free_care_threshold']}% FPL | {t['sliding_scale']} {data['sliding_scale_max']}% FPL | |
| 📞 Phone: {data['contact']} | |
| --- | |
| """ | |
| return cards, choices | |
| except Exception as e: | |
| return f"Error: {str(e)}", [] | |
| def determine_eligibility(hospital_name, income, household_size, has_snap_wic, lang, has_insurance=False): | |
| """Determine financial assistance eligibility for a given hospital. | |
| Checks PA 24-81 presumptive eligibility first, then free care and | |
| sliding scale thresholds. Returns a result dict with all data needed | |
| for the explanation and display. | |
| """ | |
| if "(" in hospital_name: | |
| hospital_name = hospital_name.split(" (")[0] | |
| t = TRANSLATIONS[lang] | |
| if hospital_name not in HOSPITALS: | |
| error_msg = "Hospital not found. Please go back and select a valid hospital." if lang == "en" else "Hospital no encontrado. Vuelva atrás y seleccione un hospital válido." | |
| return { | |
| "hospital": hospital_name, | |
| "income": income, | |
| "household_size": household_size, | |
| "fpl_percentage": 0, | |
| "has_snap_wic": has_snap_wic, | |
| "has_insurance": has_insurance, | |
| "pa_24_81_eligible": False, | |
| "contact": "N/A", | |
| "special_notes": "", | |
| "asset_limit": None, | |
| "fap_url": "", | |
| "lang": lang, | |
| "eligibility_status": error_msg, | |
| "discount_level": "N/A", | |
| "error": True | |
| } | |
| hospital = HOSPITALS[hospital_name] | |
| fpl_percentage = calculate_fpl_percentage(income, household_size) | |
| result = { | |
| "hospital": hospital_name, | |
| "income": income, | |
| "household_size": household_size, | |
| "fpl_percentage": fpl_percentage, | |
| "has_snap_wic": has_snap_wic, | |
| "has_insurance": has_insurance, | |
| "pa_24_81_eligible": False, | |
| "contact": hospital["contact"], | |
| "special_notes": hospital["special_notes"], | |
| "asset_limit": hospital["asset_limit"], | |
| "fap_url": hospital["fap_url"], | |
| "lang": lang | |
| } | |
| if has_snap_wic and fpl_percentage <= 250: | |
| result["pa_24_81_eligible"] = True | |
| result["eligibility_status"] = t["eligible"] | |
| result["discount_level"] = "Presumptive Eligibility (PA 24-81)" if lang == "en" else "Elegibilidad Presuntiva (PA 24-81)" | |
| elif fpl_percentage <= hospital["free_care_threshold"]: | |
| result["eligibility_status"] = t["eligible"] | |
| result["discount_level"] = "100% Discount" if lang == "en" else "100% de Descuento" | |
| elif fpl_percentage <= hospital["sliding_scale_max"]: | |
| result["eligibility_status"] = t["eligible"] | |
| result["discount_level"] = "Partial Discount" if lang == "en" else "Descuento Parcial" | |
| else: | |
| result["eligibility_status"] = t["not_eligible"] | |
| result["discount_level"] = "Income exceeds thresholds" if lang == "en" else "Ingresos superan umbrales" | |
| return result | |
| # === AI EXPLANATION GENERATION === | |
| def generate_explanation_streaming(data): | |
| """Stream an AI-generated explanation of the eligibility result. | |
| Selects a confidence tier (statutory / strong / moderate / negative) | |
| to calibrate the language used by the LLM, then streams the response | |
| token-by-token. Falls back to a static message on API failure. | |
| """ | |
| lang = data["lang"] | |
| t = TRANSLATIONS[lang] | |
| # Determine confidence level for language calibration | |
| is_pa_24_81 = data["pa_24_81_eligible"] | |
| is_eligible = data["eligibility_status"] == t["eligible"] | |
| has_asset_limit = data["asset_limit"] is not None | |
| fpl_pct = data["fpl_percentage"] | |
| # Confidence-based language selection | |
| hospital_threshold = HOSPITALS[data["hospital"]]["free_care_threshold"] if data["hospital"] in HOSPITALS else 200 | |
| if is_pa_24_81: | |
| confidence = "statutory" # Law-based, highest confidence | |
| elif is_eligible and not has_asset_limit and fpl_pct < hospital_threshold * 0.8: | |
| confidence = "strong" # Clear match, no complications | |
| elif is_eligible and has_asset_limit: | |
| confidence = "moderate" # Additional requirements exist | |
| elif is_eligible: | |
| confidence = "strong" # Generally eligible | |
| else: | |
| confidence = "negative" # Not eligible | |
| # Build the system message with appropriate language guidance | |
| if lang == "es": | |
| if confidence == "statutory": | |
| lang_guide = """LENGUAJE DEFINITIVO - Este es un derecho estatutario: | |
| - Usa "califica" o "es elegible" con confianza | |
| - Enfatiza que es una ley estatal (PA 24-81) | |
| - No uses "puede" - esto es definitivo | |
| - IMPORTANTE: Siempre di "hasta un X% de descuento", nunca "un X% de descuento" directamente""" | |
| elif confidence == "strong": | |
| lang_guide = """LENGUAJE CONFIADO: | |
| - Usa "parece ser elegible" o "aparentemente califica" | |
| - Sé positivo pero incluye "basado en la información proporcionada" | |
| - Anima a confirmar con el hospital | |
| - IMPORTANTE: Siempre di "hasta un X% de descuento", nunca "un X% de descuento" directamente""" | |
| elif confidence == "moderate": | |
| lang_guide = """LENGUAJE CAUTELOSO: | |
| - Usa "puede calificar" o "podría ser elegible" | |
| - IMPORTANTE: Menciona los requisitos adicionales (límites de activos, etc.) | |
| - Enfatiza la necesidad de contactar al hospital | |
| - IMPORTANTE: Siempre di "hasta un X% de descuento", nunca "un X% de descuento" directamente""" | |
| else: | |
| lang_guide = """LENGUAJE AMABLE PERO DIRECTO: | |
| - Sé claro: "sus ingresos superan los umbrales" | |
| - Sugiere alternativas (otros hospitales, otros programas) | |
| - Mantén un tono esperanzador""" | |
| system_msg = f"""Eres un defensor de la salud que ayuda a explicar la elegibilidad para asistencia financiera. | |
| {lang_guide} | |
| REGLA CRÍTICA: Cuando menciones descuentos, SIEMPRE usa "hasta" — por ejemplo "hasta un 100% de descuento", NUNCA "un 100% de descuento". | |
| Explica en 2 párrafos claros y cálidos. Máximo 150 palabras.""" | |
| else: | |
| if confidence == "statutory": | |
| lang_guide = """DEFINITIVE LANGUAGE - This is a statutory right: | |
| - Use "you qualify" or "you are eligible" confidently | |
| - Emphasize this is Connecticut state law (PA 24-81) | |
| - Don't use "may" - this is definitive | |
| - IMPORTANT: Always say "up to a X% discount", NEVER "a X% discount" directly""" | |
| elif confidence == "strong": | |
| lang_guide = """CONFIDENT LANGUAGE: | |
| - Use "you appear to be eligible" or "you likely qualify" | |
| - Be positive but include "based on the information provided" | |
| - Encourage hospital confirmation | |
| - IMPORTANT: Always say "up to a X% discount", NEVER "a X% discount" directly""" | |
| elif confidence == "moderate": | |
| lang_guide = """CAUTIOUS LANGUAGE: | |
| - Use "you may qualify" or "you could be eligible" | |
| - IMPORTANT: Mention additional requirements (asset limits, etc.) | |
| - Emphasize need to contact hospital | |
| - IMPORTANT: Always say "up to a X% discount", NEVER "a X% discount" directly""" | |
| else: | |
| lang_guide = """GENTLE BUT DIRECT LANGUAGE: | |
| - Be clear: "your income exceeds the eligibility thresholds" | |
| - Suggest alternatives (other hospitals, other programs) | |
| - Keep tone hopeful""" | |
| system_msg = f"""You are a healthcare advocate helping explain financial assistance eligibility. | |
| {lang_guide} | |
| CRITICAL RULE: When mentioning discounts, ALWAYS use "up to" — e.g. "up to a 100% discount", NEVER "a 100% discount". This applies to all discount percentages. | |
| Explain in 2 clear, warm paragraphs. Maximum 150 words.""" | |
| insurance_note = "" | |
| if data.get("has_insurance"): | |
| insurance_note = "\nInsurance: Yes — Note that the hospital will bill insurance first; financial assistance applies to remaining balances." | |
| else: | |
| insurance_note = "\nInsurance: No" | |
| user_prompt = f"""Hospital: {data['hospital']} | |
| Income: ${data['income']:,.0f} ({data['fpl_percentage']}% FPL) | |
| Household: {data['household_size']} people | |
| SNAP/WIC: {'Yes' if data['has_snap_wic'] else 'No'}{insurance_note} | |
| Status: {data['eligibility_status']} | |
| Discount: {data['discount_level']} | |
| Contact: {data['contact']} | |
| Asset Limit: {data['asset_limit'] or 'None'} | |
| PA 24-81 Eligible: {'Yes' if data['pa_24_81_eligible'] else 'No'}""" | |
| try: | |
| stream = client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[ | |
| {"role": "system", "content": system_msg}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| temperature=0.7, | |
| max_tokens=300, | |
| stream=True | |
| ) | |
| collected_text = "" | |
| for chunk in stream: | |
| if chunk.choices[0].delta.content: | |
| collected_text += chunk.choices[0].delta.content | |
| yield collected_text | |
| except Exception: | |
| 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." | |
| yield fallback | |
| # === RESULTS FORMATTING === | |
| def format_results_streaming(data, lang): | |
| """Build and stream the full results page as markdown. | |
| Renders a disclaimer banner, eligibility header, streamed AI explanation, | |
| and a footer with contact info and source links. The footer is always | |
| appended regardless of whether the AI call succeeded. | |
| """ | |
| t = TRANSLATIONS[lang] | |
| # Disclaimer banner at top | |
| disclaimer_banner = f"""> {t['disclaimer']} | |
| --- | |
| """ | |
| # Status badge | |
| is_eligible = data['eligibility_status'] == t['eligible'] | |
| badge_class = "badge-eligible" if is_eligible else "badge-not-eligible" | |
| badge_html = f'<span class="{badge_class}">{data["eligibility_status"]}</span>' | |
| # Header | |
| header = f"""## {data['hospital']} | |
| ### {t['status']}: {badge_html} | |
| Income: ${data['income']:,.0f} ({data['fpl_percentage']}% FPL) | |
| --- | |
| """ | |
| # Footer (always shown regardless of AI explanation success) | |
| footer = f""" | |
| --- | |
| **Contact:** {data['contact']} | |
| --- | |
| ### {t['sources']} | |
| - [**{data['hospital']} Financial Assistance Policy**]({data['fap_url']}) | |
| - [**Connecticut Public Act 24-81**](https://www.cga.ct.gov/2024/ba/pdf/2024HB-05320-R000149-BA.pdf) | |
| - [**2025 Federal Poverty Level Guidelines**](https://www.healthcare.gov/glossary/federal-poverty-level-fpl/) | |
| - [**CT Office of the Healthcare Advocate**](https://portal.ct.gov/oha) | |
| --- | |
| {t['disclaimer']} | |
| """ | |
| base = disclaimer_banner + header | |
| # Stream the explanation | |
| partial_explanation = "" | |
| for partial_text in generate_explanation_streaming(data): | |
| partial_explanation = partial_text | |
| yield base + partial_explanation | |
| # Always append footer after streaming completes | |
| yield base + partial_explanation + footer | |
| def generate_download_content(data, lang): | |
| """Generate a plain-text summary of the eligibility result for download.""" | |
| t = TRANSLATIONS[lang] | |
| lines = [ | |
| f"Connecticut Hospital Financial Assistance — Eligibility Summary", | |
| f"={'=' * 59}", | |
| f"", | |
| f"Hospital: {data['hospital']}", | |
| f"Status: {data['eligibility_status']}", | |
| f"Discount Level: {data['discount_level']}", | |
| f"", | |
| f"Annual Household Income: ${data['income']:,.0f}", | |
| f"Household Size: {data['household_size']}", | |
| f"FPL Percentage: {data['fpl_percentage']}%", | |
| f"SNAP/WIC Enrolled: {'Yes' if data['has_snap_wic'] else 'No'}", | |
| f"Has Insurance: {'Yes' if data.get('has_insurance') else 'No'}", | |
| f"PA 24-81 Eligible: {'Yes' if data['pa_24_81_eligible'] else 'No'}", | |
| f"", | |
| f"Contact: {data['contact']}", | |
| f"Asset Limit: {data['asset_limit'] or 'None'}", | |
| f"FAP URL: {data['fap_url']}", | |
| f"", | |
| f"---", | |
| f"", | |
| t['disclaimer'], | |
| ] | |
| return "\n".join(lines) | |
| # === FOLLOW-UP CHAT === | |
| CHAT_SYSTEM_PROMPT = ( | |
| "You are a helpful assistant for the Connecticut Office of the Healthcare Advocate. " | |
| "You ONLY answer questions about Connecticut hospital financial assistance programs, " | |
| "eligibility, the application process, required documents, appeal rights, and related " | |
| "topics under Connecticut Public Act 24-81. If asked about anything unrelated, politely " | |
| "redirect: 'I can only help with questions about Connecticut hospital financial assistance. " | |
| "For other questions, please contact the Office of the Healthcare Advocate at portal.ct.gov/oha.' " | |
| "CRITICAL RULE: When mentioning discounts, ALWAYS say 'up to' — e.g. 'up to a 100% discount', " | |
| "NEVER 'a 100% discount'. This applies to all discount percentages. " | |
| "Keep answers concise — 2-3 paragraphs maximum. Be warm and supportive." | |
| ) | |
| CHAT_MESSAGE_LIMIT = 10 | |
| SUGGESTED_PROMPTS = [ | |
| "What documents do I need to apply?", | |
| "Generate a phone script", | |
| "Does this cover emergency visits?", | |
| "How do I contact the financial assistance office?", | |
| ] | |
| def build_chat_system_message(eligibility_data): | |
| """Build a system message that includes the user's eligibility context.""" | |
| if not eligibility_data: | |
| return CHAT_SYSTEM_PROMPT | |
| context = ( | |
| f"\n\nThe user just screened for financial assistance with these results:\n" | |
| f"- Hospital: {eligibility_data['hospital']}\n" | |
| f"- Annual Income: ${eligibility_data['income']:,.0f}\n" | |
| f"- Household Size: {eligibility_data['household_size']}\n" | |
| f"- FPL Percentage: {eligibility_data['fpl_percentage']}%\n" | |
| f"- Eligibility Status: {eligibility_data['eligibility_status']}\n" | |
| f"- Discount Level: {eligibility_data['discount_level']}\n" | |
| f"- Contact: {eligibility_data['contact']}\n" | |
| f"- SNAP/WIC: {'Yes' if eligibility_data['has_snap_wic'] else 'No'}\n" | |
| f"- Insurance: {'Yes' if eligibility_data.get('has_insurance') else 'No'}\n" | |
| f"- PA 24-81 Eligible: {'Yes' if eligibility_data['pa_24_81_eligible'] else 'No'}\n" | |
| f"\nUse this context to give personalized answers about their situation." | |
| ) | |
| return CHAT_SYSTEM_PROMPT + context | |
| def stream_chat_response(message, chat_history, eligibility_data): | |
| """Stream a chat response from OpenAI given the conversation history. | |
| Returns a generator that yields (updated_history, "") tuples as tokens | |
| arrive, where the assistant message is progressively built up. | |
| """ | |
| system_msg = build_chat_system_message(eligibility_data) | |
| messages = [{"role": "system", "content": system_msg}] | |
| for entry in chat_history: | |
| messages.append({"role": entry["role"], "content": entry["content"]}) | |
| messages.append({"role": "user", "content": message}) | |
| # Append user message to display history | |
| chat_history = chat_history + [ | |
| {"role": "user", "content": message}, | |
| {"role": "assistant", "content": ""}, | |
| ] | |
| try: | |
| stream = client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=messages, | |
| temperature=0.7, | |
| max_tokens=500, | |
| stream=True, | |
| ) | |
| collected = "" | |
| for chunk in stream: | |
| if chunk.choices[0].delta.content: | |
| collected += chunk.choices[0].delta.content | |
| chat_history[-1]["content"] = collected | |
| yield chat_history | |
| except Exception: | |
| chat_history[-1]["content"] = ( | |
| "I'm sorry, I'm having trouble connecting right now. " | |
| "Please contact the Office of the Healthcare Advocate at " | |
| "portal.ct.gov/oha or call 1-866-466-4446." | |
| ) | |
| yield chat_history | |
| # === CUSTOM CSS === | |
| custom_css = """ | |
| /* ===== CT.GOV / OHA Design System ===== */ | |
| /* ---------- GLOBAL ---------- */ | |
| .gradio-container { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
| "Helvetica Neue", Arial, sans-serif !important; | |
| max-width: 1400px !important; | |
| width: 100% !important; | |
| margin: 0 auto !important; | |
| padding: 0 32px !important; | |
| background: #FFFFFF !important; | |
| color: #212529 !important; | |
| font-size: 24px !important; | |
| line-height: 1.7 !important; | |
| min-width: 320px !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* Kill ALL flex gap on the top-level Gradio column so hidden children | |
| leave no trace. Spacing comes from component margins instead. */ | |
| .gradio-container > div, | |
| .gradio-container > div > div { | |
| gap: 0 !important; | |
| } | |
| /* ---------- CT.GOV TOP BAR ---------- */ | |
| .ct-gov-bar { | |
| background: #003478; | |
| height: 40px; | |
| display: flex; | |
| align-items: center; | |
| padding: 0 32px; | |
| margin: -16px -32px 0 -32px; /* bleed to edges */ | |
| } | |
| .ct-gov-bar a, | |
| .ct-gov-bar a span, | |
| .ct-gov-bar a strong { | |
| color: #FFFFFF !important; | |
| text-decoration: none !important; | |
| font-size: 15px; | |
| } | |
| .ct-gov-bar a:hover span { text-decoration: underline !important; } | |
| /* ---------- OHA HEADER BAND ---------- */ | |
| .oha-header { | |
| background: #0046AD; | |
| padding: 24px 32px; | |
| margin: 0 -32px 0 -32px; /* bleed to edges */ | |
| } | |
| .oha-header a { | |
| color: #FFFFFF !important; | |
| text-decoration: none !important; | |
| display: block; | |
| } | |
| .oha-header a:hover { opacity: 0.92; } | |
| .oha-header h1 { | |
| color: #FFFFFF !important; | |
| font-size: 36px !important; | |
| font-weight: 300 !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| border: none !important; | |
| line-height: 1.2; | |
| } | |
| .oha-header .oha-subtitle { | |
| color: rgba(255,255,255,0.90); | |
| font-size: 18px; | |
| margin-top: 4px; | |
| font-weight: 400; | |
| } | |
| .oha-header .oha-beta { | |
| display: inline-block; | |
| background: #FFC107; | |
| color: #664D03; | |
| padding: 2px 10px; | |
| border-radius: 10px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.05em; | |
| margin-left: 12px; | |
| vertical-align: middle; | |
| } | |
| /* Thin separator below header */ | |
| .oha-separator { | |
| height: 1px; | |
| background: #E0E0E0; | |
| margin: 0 -32px 16px -32px; | |
| } | |
| /* ---------- FOOTER ---------- */ | |
| .oha-footer { | |
| background: #003478; | |
| color: #FFFFFF !important; | |
| font-size: 15px; | |
| padding: 20px 32px; | |
| margin: 2rem -32px -16px -32px; | |
| text-align: center; | |
| line-height: 1.6; | |
| } | |
| .oha-footer, | |
| .oha-footer span, | |
| .oha-footer p { | |
| color: #FFFFFF !important; | |
| } | |
| .oha-footer a { | |
| color: #FFFFFF !important; | |
| text-decoration: underline !important; | |
| } | |
| /* ---------- LANGUAGE TOGGLE ---------- */ | |
| .lang-row { | |
| justify-content: flex-end !important; | |
| margin: 8px 0 12px 0 !important; | |
| min-height: 0 !important; | |
| gap: 0 !important; | |
| padding: 0 !important; | |
| border: none !important; | |
| background: transparent !important; | |
| } | |
| /* ---------- GROUPS (default = invisible wrapper) ---------- */ | |
| .gr-group { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| margin: 0 0 8px 0 !important; | |
| } | |
| /* Active step gets a subtle card treatment */ | |
| .step-group { | |
| background: #FFFFFF !important; | |
| padding: 32px !important; | |
| border-radius: 4px !important; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.08) !important; | |
| margin-bottom: 16px !important; | |
| border: none !important; | |
| } | |
| /* ---------- HIDDEN STEP COLLAPSE ---------- */ | |
| /* Gradio wraps every component in a div. When a Group is hidden the | |
| wrapper div often keeps its layout contribution (flex gap, min-height). | |
| Nuke everything. */ | |
| .step-group[style*="display: none"], | |
| .step-group[style*="display:none"], | |
| .step-group.hidden, .step-group.hide { | |
| display: none !important; | |
| padding: 0 !important; margin: 0 !important; | |
| border: none !important; box-shadow: none !important; | |
| min-height: 0 !important; max-height: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| /* Wrapper div around a hidden step-group */ | |
| div:has(> .step-group[style*="display: none"]), | |
| div:has(> .step-group[style*="display:none"]), | |
| div:has(> .step-group.hidden), | |
| div:has(> .step-group.hide) { | |
| display: none !important; | |
| padding: 0 !important; margin: 0 !important; | |
| min-height: 0 !important; max-height: 0 !important; | |
| gap: 0 !important; overflow: hidden !important; | |
| } | |
| /* Belt-and-suspenders: any hidden div inside the app */ | |
| .gradio-container div[style*="display: none"], | |
| .gradio-container div[style*="display:none"] { | |
| padding: 0 !important; margin: 0 !important; | |
| border: none !important; box-shadow: none !important; | |
| max-height: 0 !important; overflow: hidden !important; | |
| } | |
| /* ---------- TYPOGRAPHY ---------- */ | |
| h1 { | |
| color: #003478 !important; | |
| font-weight: 300 !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| } | |
| h2 { | |
| color: #003478 !important; | |
| font-weight: 400 !important; | |
| font-size: 2rem !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| margin: 0 0 0.75rem 0 !important; | |
| } | |
| h3 { | |
| color: #333333 !important; | |
| font-weight: 500 !important; | |
| font-size: 27px !important; | |
| margin: 0 0 0.5rem 0 !important; | |
| } | |
| p, span, li { color: #212529; font-size: 24px; } | |
| /* ---------- LINKS ---------- */ | |
| a { color: #0046AD !important; text-decoration: none; } | |
| a:hover { text-decoration: underline; color: #003478 !important; } | |
| /* ---------- LABELS ---------- */ | |
| label { | |
| color: #212529 !important; | |
| font-weight: 500 !important; | |
| font-size: 21px !important; | |
| margin-bottom: 8px !important; | |
| } | |
| /* ---------- FORM INPUTS ---------- */ | |
| input[type="text"], input[type="number"], textarea, select, | |
| .gr-box, .gr-input, .gr-dropdown { | |
| border: 1px solid #CED4DA !important; | |
| border-radius: 4px !important; | |
| padding: 14px 16px !important; | |
| font-size: 24px !important; | |
| color: #212529 !important; | |
| background: #FFFFFF !important; | |
| } | |
| input:focus, textarea:focus, select:focus { | |
| border-color: #0046AD !important; | |
| box-shadow: 0 0 0 2px rgba(0,70,173,0.15) !important; | |
| outline: none !important; | |
| } | |
| /* ---------- PRIMARY BUTTONS ---------- */ | |
| .gr-button-primary, button.primary { | |
| background: #0046AD !important; | |
| color: #FFFFFF !important; | |
| border: none !important; | |
| border-radius: 4px !important; | |
| font-weight: 500 !important; | |
| font-size: 23px !important; | |
| padding: 16px 32px !important; | |
| transition: background 0.15s ease !important; | |
| min-height: 0 !important; | |
| } | |
| .gr-button-primary:hover, button.primary:hover { | |
| background: #003478 !important; | |
| } | |
| /* ---------- SECONDARY BUTTONS ---------- */ | |
| .gr-button-secondary, button.secondary { | |
| background: #FFFFFF !important; | |
| color: #0046AD !important; | |
| border: 1px solid #0046AD !important; | |
| border-radius: 4px !important; | |
| font-weight: 500 !important; | |
| font-size: 23px !important; | |
| padding: 16px 32px !important; | |
| transition: all 0.15s ease !important; | |
| min-height: 0 !important; | |
| } | |
| .gr-button-secondary:hover, button.secondary:hover { | |
| background: #F0F4FA !important; | |
| } | |
| /* ---------- SMALL / BACK BUTTONS ---------- */ | |
| button.sm { | |
| font-size: 20px !important; | |
| padding: 10px 20px !important; | |
| color: #495057 !important; | |
| background: transparent !important; | |
| border: 1px solid #CED4DA !important; | |
| border-radius: 4px !important; | |
| min-height: 0 !important; | |
| } | |
| button.sm:hover { background: #F5F5F5 !important; color: #212529 !important; } | |
| /* ---------- SUGGESTED-PROMPT PILLS ---------- */ | |
| .prompt-pill button { | |
| background: #FFFFFF !important; | |
| color: #333333 !important; | |
| border: 1px solid #CED4DA !important; | |
| border-radius: 20px !important; | |
| font-size: 20px !important; | |
| font-weight: 400 !important; | |
| padding: 12px 22px !important; | |
| transition: background 0.15s ease !important; | |
| min-height: 0 !important; | |
| } | |
| .prompt-pill button:hover { | |
| background: #F0F4FA !important; | |
| } | |
| /* ---------- RESULTS / DISCLAIMER ---------- */ | |
| blockquote { | |
| background: #FFF8E1 !important; | |
| border-left: 4px solid #FFC107 !important; | |
| padding: 14px 18px !important; | |
| border-radius: 4px !important; | |
| font-size: 21px !important; | |
| color: #495057 !important; | |
| margin: 0 0 16px 0 !important; | |
| } | |
| /* Status badges (rendered as <span> in markdown) */ | |
| .badge-eligible { | |
| display: inline-block; | |
| background: #D4EDDA; | |
| color: #155724; | |
| padding: 8px 18px; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| font-size: 21px; | |
| } | |
| .badge-not-eligible { | |
| display: inline-block; | |
| background: #FFF3CD; | |
| color: #856404; | |
| padding: 8px 18px; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| font-size: 21px; | |
| } | |
| /* Streaming reflow prevention */ | |
| .results-area { | |
| width: 100% !important; | |
| min-height: 300px; | |
| box-sizing: border-box !important; | |
| } | |
| .results-area .prose { | |
| overflow-wrap: break-word; | |
| word-break: break-word; | |
| } | |
| /* ---------- CHATBOT ---------- */ | |
| .chatbot { | |
| border: 1px solid #E0E0E0 !important; | |
| border-radius: 4px !important; | |
| } | |
| /* Hide the share button in chatbot (removed in Gradio 6 API) */ | |
| .chatbot button[aria-label="Share"], | |
| .chatbot .share-button, | |
| .chatbot svg.share-icon { display: none !important; } | |
| /* ---------- MAP ---------- */ | |
| .map-embed iframe { | |
| border-radius: 4px; | |
| border: 1px solid #E0E0E0; | |
| } | |
| /* ---------- HORIZONTAL RULES ---------- */ | |
| hr { | |
| border: none !important; | |
| border-top: 1px solid #E0E0E0 !important; | |
| margin: 16px 0 !important; | |
| } | |
| /* ---------- FILE DOWNLOAD ---------- */ | |
| .gr-file { | |
| border: 1px solid #E0E0E0 !important; | |
| border-radius: 4px !important; | |
| } | |
| /* ---------- RESOURCES BANNER ---------- */ | |
| .resources-banner { | |
| background: #F0F4FA; | |
| border-left: 5px solid #0046AD; | |
| padding: 32px 36px; | |
| margin: 0 0 24px 0; | |
| border-radius: 4px; | |
| font-size: 29px; | |
| line-height: 1.75; | |
| color: #212529; | |
| } | |
| .resources-banner p { | |
| margin: 0; | |
| } | |
| /* ---------- QUICK LINKS ---------- */ | |
| .quick-links { | |
| display: flex; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| margin: 0 0 16px 0; | |
| } | |
| .quick-links a { | |
| display: inline-block; | |
| background: #0046AD !important; | |
| color: #FFFFFF !important; | |
| text-decoration: none !important; | |
| font-size: 18px; | |
| font-weight: 500; | |
| padding: 12px 24px; | |
| border-radius: 4px; | |
| transition: background 0.15s ease; | |
| } | |
| .quick-links a:hover { | |
| background: #003478 !important; | |
| text-decoration: none !important; | |
| } | |
| /* ---------- MOBILE ---------- */ | |
| @media (max-width: 640px) { | |
| .gradio-container { padding: 0 12px !important; } | |
| .ct-gov-bar { padding: 0 12px; margin-left: -12px; margin-right: -12px; } | |
| .oha-header { padding: 16px 12px; margin-left: -12px; margin-right: -12px; } | |
| .oha-header h1 { font-size: 24px !important; } | |
| .oha-separator { margin-left: -12px; margin-right: -12px; } | |
| .oha-footer { padding: 12px; margin-left: -12px; margin-right: -12px; } | |
| .step-group { padding: 20px !important; } | |
| .prompt-pill button { font-size: 18px !important; padding: 10px 16px !important; } | |
| } | |
| """ | |
| # === GRADIO UI === | |
| def create_interface(): | |
| """Build and return the Gradio Blocks interface with all steps and event wiring.""" | |
| with gr.Blocks( | |
| theme=gr.themes.Default( | |
| primary_hue=gr.themes.Color( | |
| c50="#F0F4FA", c100="#D6E2F5", c200="#AECAEF", | |
| c300="#7BAEE6", c400="#4A8FDB", c500="#0046AD", | |
| c600="#003D96", c700="#003478", c800="#002B63", | |
| c900="#001B3A", c950="#001028", | |
| ), | |
| neutral_hue=gr.themes.Color( | |
| c50="#F5F5F5", c100="#E0E0E0", c200="#CED4DA", | |
| c300="#ADB5BD", c400="#6C757D", c500="#495057", | |
| c600="#343A40", c700="#212529", c800="#1A1E21", | |
| c900="#111315", c950="#0A0C0D", | |
| ), | |
| font=["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"], | |
| ), | |
| css=custom_css | |
| ) as demo: | |
| lang_state = gr.State("en") | |
| selected_hospital = gr.State("") | |
| last_result_state = gr.State(None) # Stores the most recent eligibility result dict | |
| chat_history_state = gr.State([]) # List of {"role":..., "content":...} dicts | |
| chat_count_state = gr.State(0) # Message counter for rate limiting | |
| # CT.GOV top bar (decorative, links to ct.gov) | |
| gr.HTML( | |
| '<div class="ct-gov-bar">' | |
| '<a href="https://portal.ct.gov" target="_blank" rel="noopener">' | |
| '<span><strong>CT.GOV</strong> | State of Connecticut</span>' | |
| '</a>' | |
| '</div>' | |
| ) | |
| # OHA blue header band (links to OHA homepage) | |
| gr.HTML( | |
| '<div class="oha-header">' | |
| '<a href="https://portal.ct.gov/oha" target="_blank" rel="noopener">' | |
| '<h1>Office of the Healthcare Advocate</h1>' | |
| '<div class="oha-subtitle">Hospital Financial Assistance Eligibility Screener <span class="oha-beta">BETA</span></div>' | |
| '</a>' | |
| '</div>' | |
| ) | |
| # Thin separator line below header | |
| gr.HTML('<div class="oha-separator"></div>') | |
| # Resources banner | |
| gr.HTML( | |
| '<div class="resources-banner">' | |
| '<p>Did you know your hospital bill may be reducible — or even forgiven entirely? ' | |
| 'Connecticut hospitals are required by law to provide financial assistance to patients ' | |
| 'enrolled in SNAP or WIC with household incomes at or below 250% of the Federal Poverty ' | |
| 'Level (about $51,100 for a family of two) — regardless of immigration status. Many ' | |
| 'hospitals go even further, offering sliding-scale discounts to households earning ' | |
| 'significantly more. This tool can help you determine whether you and your family are eligible.</p>' | |
| '</div>' | |
| ) | |
| # Quick-link buttons | |
| gr.HTML( | |
| '<div class="quick-links">' | |
| '<a href="https://portal.ct.gov/oha/Financial-Assistance" target="_blank" rel="noopener">What is Financial Assistance?</a>' | |
| '<a href="https://portal.ct.gov/oha" target="_blank" rel="noopener">FAQ</a>' | |
| '<a href="https://www.accesshealthct.com" target="_blank" rel="noopener">I Need Help Enrolling in Health Insurance</a>' | |
| '<a href="https://portal.ct.gov/oha" target="_blank" rel="noopener">About This Tool</a>' | |
| '</div>' | |
| ) | |
| with gr.Row(elem_classes="lang-row"): | |
| lang_toggle = gr.Radio( | |
| choices=["English", "Espa\u00f1ol"], | |
| value="English", | |
| label="Language", | |
| container=False | |
| ) | |
| # Google Maps embed — visible during hospital selection steps only | |
| map_group = gr.Group(visible=True) | |
| with map_group: | |
| gr.HTML( | |
| '<div class="map-embed" style="margin-top:1rem;">' | |
| '<iframe src="https://www.google.com/maps/d/u/0/embed?mid=1UVO9r7ZS26kZr8Q62dY0147v3Milu_s&ll=41.508444988744984%2C-72.7719992&z=9" ' | |
| 'width="100%" height="480" style="border:0; border-radius: 6px;" ' | |
| 'allowfullscreen="" loading="lazy" title="Map: Connecticut Hospital Locations"></iframe>' | |
| '</div>' | |
| ) | |
| # Step 1: Choose path | |
| step1 = gr.Group(visible=True, elem_classes="step-group") | |
| with step1: | |
| gr.Markdown("### How would you like to start?") | |
| with gr.Row(): | |
| help_btn = gr.Button("Find a Hospital by ZIP Code", variant="primary", size="lg") | |
| know_btn = gr.Button("I Know My Hospital", variant="secondary", size="lg") | |
| # Step 2a: Find hospital by ZIP | |
| step2a = gr.Group(visible=False, elem_classes="step-group") | |
| with step2a: | |
| gr.Markdown("### Enter your ZIP code") | |
| zip_input = gr.Textbox(label="ZIP Code", placeholder="e.g., 06511", max_lines=1) | |
| num_hospitals_slider = gr.Slider( | |
| minimum=3, maximum=9, value=5, step=1, | |
| label="Number of hospitals to show" | |
| ) | |
| search_btn = gr.Button("Search", variant="primary") | |
| nearby_cards = gr.Markdown() | |
| hospital_radio = gr.Radio(label="Select a hospital", choices=[], visible=False) | |
| continue_btn_a = gr.Button("Continue", variant="primary", visible=False) | |
| back_btn_a = gr.Button("Back", size="sm") | |
| # Step 2b: Select known hospital | |
| step2b = gr.Group(visible=False, elem_classes="step-group") | |
| with step2b: | |
| gr.Markdown("### Select your hospital") | |
| hospital_dropdown = gr.Dropdown( | |
| choices=list(HOSPITALS.keys()), | |
| label="Hospital" | |
| ) | |
| continue_btn_b = gr.Button("Continue", variant="primary") | |
| back_btn_b = gr.Button("Back", size="sm") | |
| # Step 3: Eligibility form | |
| step3 = gr.Group(visible=False, elem_classes="step-group") | |
| with step3: | |
| selected_hospital_display = gr.Markdown() | |
| income_input = gr.Number(label="Annual Household Income ($)", info="An estimate is fine for now.", minimum=1, value=35000) | |
| household_input = gr.Number(label="Household Size", info="Include yourself and all dependents.", minimum=1, value=3, precision=0) | |
| snap_radio = gr.Radio( | |
| choices=["Yes", "No"], | |
| label="Enrolled in SNAP or WIC?", | |
| info='<a href="https://portal.ct.gov/dss/SNAP" target="_blank">SNAP</a> is food assistance (food stamps). <a href="https://portal.ct.gov/dph/WIC" target="_blank">WIC</a> is nutrition support for women, infants, and children.', | |
| value="No" | |
| ) | |
| insurance_radio = gr.Radio(choices=["Yes", "No"], label="Do you have health insurance?", info="If insured, financial assistance applies to remaining balances after insurance.", value="No") | |
| check_btn = gr.Button("Check Eligibility", variant="primary", size="lg") | |
| back_btn_c = gr.Button("Back", size="sm") | |
| # Step 4: Results (with streaming) + follow-up chat | |
| step4 = gr.Group(visible=False, elem_classes="step-group") | |
| with step4: | |
| results_output = gr.Markdown(elem_classes="results-area") | |
| with gr.Row(): | |
| restart_btn = gr.Button("Check Another Hospital", variant="secondary") | |
| download_btn = gr.Button("Download Results", variant="secondary") | |
| download_file = gr.File(visible=False, label="Download") | |
| # --- Follow-up chat section --- | |
| gr.HTML('<hr style="border:none; border-top:1px solid #E0E0E0; margin:1.5rem 0 1rem 0;">') | |
| gr.Markdown("### Follow-Up Questions") | |
| gr.Markdown("Have additional questions? Ask about eligibility, the application process, required documents, or appeal rights.") | |
| chatbot = gr.Chatbot( | |
| label="Conversation", | |
| height=350, | |
| ) | |
| # Suggested prompt buttons (pill-shaped via CSS) | |
| with gr.Row(elem_classes="prompt-pill"): | |
| prompt_btn_1 = gr.Button("What documents do I need to apply?", size="sm") | |
| prompt_btn_2 = gr.Button("Generate a phone script", size="sm") | |
| with gr.Row(elem_classes="prompt-pill"): | |
| prompt_btn_3 = gr.Button("Does this cover emergency visits?", size="sm") | |
| prompt_btn_4 = gr.Button("How do I contact the financial assistance office?", size="sm") | |
| with gr.Row(): | |
| chat_input = gr.Textbox( | |
| label="Your question", | |
| placeholder="Type your question here...", | |
| max_lines=2, | |
| scale=4, | |
| ) | |
| chat_send_btn = gr.Button("Send", variant="primary", scale=1) | |
| chat_limit_msg = gr.Markdown(visible=False) | |
| # --- Navigation functions --- | |
| def show_find_path(): | |
| """Show the ZIP code search path (Step 2a).""" | |
| return [ | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| ] | |
| def show_know_path(): | |
| """Show the hospital dropdown path (Step 2b).""" | |
| return [ | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| ] | |
| def back_to_start(): | |
| """Return to Step 1.""" | |
| return [ | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| ] | |
| def search_hospitals_wrapper(zip_code, lang, max_results): | |
| """Geocode a ZIP and return nearby hospital cards and radio choices.""" | |
| cards, choices = find_nearby_hospitals(zip_code, lang, max_results=max_results) | |
| if choices: | |
| return cards, gr.update(choices=choices, visible=True, value=choices[0]), gr.update(visible=True) | |
| return cards, gr.update(visible=False), gr.update(visible=False) | |
| def continue_from_search(hospital_choice, lang): | |
| """Advance from ZIP search results to the eligibility form.""" | |
| t = TRANSLATIONS[lang] | |
| hospital_name = hospital_choice.split(" (")[0] if "(" in hospital_choice else hospital_choice | |
| return [ | |
| hospital_name, | |
| f"### Selected: {hospital_name}", | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(choices=[t['yes'], t['no']], value=t['no']), | |
| gr.update(choices=[t['yes'], t['no']], value=t['no']), | |
| gr.update(visible=False), | |
| ] | |
| def continue_from_dropdown(hospital_name, lang): | |
| """Advance from hospital dropdown to the eligibility form.""" | |
| t = TRANSLATIONS[lang] | |
| return [ | |
| hospital_name, | |
| f"### Selected: {hospital_name}", | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| gr.update(visible=False), | |
| gr.update(choices=[t['yes'], t['no']], value=t['no']), | |
| gr.update(choices=[t['yes'], t['no']], value=t['no']), | |
| gr.update(visible=False), | |
| ] | |
| def check_eligibility_wrapper(hospital, income, household, snap, insurance, lang): | |
| """Validate inputs, run eligibility check, and stream results. | |
| Also resets the follow-up chat for the new screening session. | |
| """ | |
| t = TRANSLATIONS[lang] | |
| # Input validation | |
| if income is None or income <= 0: | |
| gr.Warning("Please enter a valid income greater than $0." if lang == "en" else "Ingrese un ingreso v\u00e1lido mayor que $0.") | |
| yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), None, | |
| gr.update(), 0, gr.update(), gr.update()] | |
| return | |
| if household is None or int(household) < 1: | |
| gr.Warning("Household size must be at least 1." if lang == "en" else "El tama\u00f1o del hogar debe ser al menos 1.") | |
| yield [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), None, | |
| gr.update(), 0, gr.update(), gr.update()] | |
| return | |
| has_snap = (snap == t['yes']) | |
| has_insurance = (insurance == t['yes']) | |
| data = determine_eligibility(hospital, income, int(household), has_snap, lang, has_insurance=has_insurance) | |
| yield [ | |
| "", | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| gr.update(visible=True), | |
| data, | |
| [], # reset chatbot | |
| 0, # reset chat counter | |
| gr.update(visible=False), # hide limit message | |
| gr.update(value="", interactive=True), # reset chat input | |
| ] | |
| for partial_result in format_results_streaming(data, lang): | |
| yield [ | |
| partial_result, | |
| gr.update(), | |
| gr.update(), | |
| gr.update(), | |
| gr.update(), | |
| data, | |
| gr.update(), | |
| gr.update(), | |
| gr.update(), | |
| gr.update(), | |
| ] | |
| def download_results(last_result, lang): | |
| """Generate a PDF with the eligibility summary for download.""" | |
| if not last_result: | |
| gr.Warning("No results to download." if lang == "en" else "No hay resultados para descargar.") | |
| return gr.update(visible=False) | |
| t = TRANSLATIONS[lang] | |
| data = last_result | |
| pdf = FPDF() | |
| pdf.add_page() | |
| pdf.set_auto_page_break(auto=True, margin=20) | |
| # Header | |
| pdf.set_font("Helvetica", "B", 18) | |
| pdf.set_text_color(0, 52, 120) # #003478 | |
| pdf.cell(0, 12, "CT Hospital Financial Assistance", new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "", 12) | |
| pdf.set_text_color(33, 37, 41) | |
| pdf.cell(0, 8, "Eligibility Summary", new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(4) | |
| # Hospital & Status | |
| pdf.set_font("Helvetica", "B", 14) | |
| pdf.cell(0, 10, data['hospital'], new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.cell(0, 8, f"{t['status']}: {data['eligibility_status']}", new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(2) | |
| # Details table | |
| pdf.set_font("Helvetica", "", 11) | |
| fields = [ | |
| ("Annual Household Income" if lang == "en" else "Ingreso anual del hogar", f"${data['income']:,.0f}"), | |
| ("Household Size" if lang == "en" else "Tamaño del hogar", str(data['household_size'])), | |
| ("FPL Percentage" if lang == "en" else "Porcentaje FPL", f"{data['fpl_percentage']}%"), | |
| ("Discount Level" if lang == "en" else "Nivel de descuento", data['discount_level']), | |
| ("SNAP/WIC Enrolled" if lang == "en" else "Inscrito en SNAP/WIC", "Yes" if data['has_snap_wic'] else "No"), | |
| ("Has Insurance" if lang == "en" else "Tiene seguro", "Yes" if data.get('has_insurance') else "No"), | |
| ("PA 24-81 Eligible" if lang == "en" else "Elegible PA 24-81", "Yes" if data['pa_24_81_eligible'] else "No"), | |
| ] | |
| for label, value in fields: | |
| pdf.set_font("Helvetica", "B", 10) | |
| pdf.cell(80, 7, label) | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 7, value, new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(4) | |
| # Contact | |
| pdf.set_font("Helvetica", "B", 11) | |
| pdf.cell(0, 8, f"Contact: {data['contact']}", new_x="LMARGIN", new_y="NEXT") | |
| if data['asset_limit']: | |
| pdf.cell(0, 8, f"Asset Limit: {data['asset_limit']}", new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(4) | |
| # Disclaimer | |
| pdf.set_font("Helvetica", "I", 9) | |
| pdf.set_text_color(100, 100, 100) | |
| pdf.multi_cell(0, 5, t['disclaimer']) | |
| tmp = tempfile.NamedTemporaryFile(suffix='.pdf', prefix='eligibility_summary_', delete=False) | |
| pdf.output(tmp.name) | |
| tmp.close() | |
| return gr.update(value=tmp.name, visible=True) | |
| def update_lang(choice): | |
| """Convert the language toggle selection to a language code.""" | |
| return "es" if choice == "Espa\u00f1ol" else "en" | |
| # --- Chat handler functions --- | |
| def handle_chat_message(message, chat_history, chat_count, eligibility_data, lang): | |
| """Process a chat message: check rate limit, stream response, update counter. | |
| Yields: (chatbot_history, new_count, limit_msg_update, chat_input_update) | |
| """ | |
| if not message or not message.strip(): | |
| yield chat_history, chat_count, gr.update(), gr.update() | |
| return | |
| if chat_count >= CHAT_MESSAGE_LIMIT: | |
| limit_text = ( | |
| "You've reached the message limit for this session. " | |
| "For additional questions, please contact OHA at " | |
| "portal.ct.gov/oha or call 1-866-466-4446." | |
| ) | |
| yield chat_history, chat_count, gr.update(visible=True, value=limit_text), gr.update(value="", interactive=False) | |
| return | |
| new_count = chat_count + 1 | |
| # Stream the response | |
| for updated_history in stream_chat_response(message, chat_history, eligibility_data): | |
| remaining = CHAT_MESSAGE_LIMIT - new_count | |
| if remaining <= 2 and remaining > 0: | |
| hint = f"*{remaining} question{'s' if remaining != 1 else ''} remaining in this session.*" | |
| yield updated_history, new_count, gr.update(visible=True, value=hint), gr.update(value="") | |
| elif new_count >= CHAT_MESSAGE_LIMIT: | |
| limit_text = ( | |
| "You've reached the message limit for this session. " | |
| "For additional questions, please contact OHA at " | |
| "portal.ct.gov/oha or call 1-866-466-4446." | |
| ) | |
| yield updated_history, new_count, gr.update(visible=True, value=limit_text), gr.update(value="", interactive=False) | |
| else: | |
| yield updated_history, new_count, gr.update(visible=False), gr.update(value="") | |
| def send_suggested_prompt(prompt_text, chat_history, chat_count, eligibility_data, lang): | |
| """Handle a suggested prompt button click — same as typing and sending.""" | |
| yield from handle_chat_message(prompt_text, chat_history, chat_count, eligibility_data, lang) | |
| def reset_chat(): | |
| """Reset chat state when starting over.""" | |
| return [], 0, gr.update(visible=False), gr.update(value="", interactive=True) | |
| # Wire up events | |
| lang_toggle.change(fn=update_lang, inputs=[lang_toggle], outputs=[lang_state]) | |
| step_outputs = [step1, step2a, step2b, step3, step4, map_group] | |
| help_btn.click(fn=show_find_path, outputs=step_outputs) | |
| know_btn.click(fn=show_know_path, outputs=step_outputs) | |
| back_btn_a.click(fn=back_to_start, outputs=step_outputs) | |
| back_btn_b.click(fn=back_to_start, outputs=step_outputs) | |
| restart_btn.click(fn=back_to_start, outputs=step_outputs) | |
| restart_btn.click(fn=reset_chat, outputs=[chatbot, chat_count_state, chat_limit_msg, chat_input]) | |
| search_btn.click( | |
| fn=search_hospitals_wrapper, | |
| inputs=[zip_input, lang_state, num_hospitals_slider], | |
| outputs=[nearby_cards, hospital_radio, continue_btn_a] | |
| ) | |
| continue_btn_a.click( | |
| fn=continue_from_search, | |
| inputs=[hospital_radio, lang_state], | |
| outputs=[selected_hospital, selected_hospital_display, step1, step2a, step3, step4, snap_radio, insurance_radio, map_group] | |
| ) | |
| continue_btn_b.click( | |
| fn=continue_from_dropdown, | |
| inputs=[hospital_dropdown, lang_state], | |
| outputs=[selected_hospital, selected_hospital_display, step1, step2b, step3, step4, snap_radio, insurance_radio, map_group] | |
| ) | |
| check_btn.click( | |
| fn=check_eligibility_wrapper, | |
| inputs=[selected_hospital, income_input, household_input, snap_radio, insurance_radio, lang_state], | |
| outputs=[results_output, step1, step2a, step3, step4, last_result_state, | |
| chatbot, chat_count_state, chat_limit_msg, chat_input], | |
| api_name=False, | |
| concurrency_limit=5 | |
| ) | |
| download_btn.click( | |
| fn=download_results, | |
| inputs=[last_result_state, lang_state], | |
| outputs=[download_file] | |
| ) | |
| # --- Chat event wiring --- | |
| chat_inputs = [chat_input, chat_history_state, chat_count_state, last_result_state, lang_state] | |
| chat_outputs = [chatbot, chat_count_state, chat_limit_msg, chat_input] | |
| # Send button and Enter key | |
| chat_send_btn.click( | |
| fn=handle_chat_message, | |
| inputs=chat_inputs, | |
| outputs=chat_outputs, | |
| ) | |
| chat_input.submit( | |
| fn=handle_chat_message, | |
| inputs=chat_inputs, | |
| outputs=chat_outputs, | |
| ) | |
| # Suggested prompt buttons | |
| for btn, prompt_text in [ | |
| (prompt_btn_1, SUGGESTED_PROMPTS[0]), | |
| (prompt_btn_2, SUGGESTED_PROMPTS[1]), | |
| (prompt_btn_3, SUGGESTED_PROMPTS[2]), | |
| (prompt_btn_4, SUGGESTED_PROMPTS[3]), | |
| ]: | |
| btn.click( | |
| fn=send_suggested_prompt, | |
| inputs=[gr.State(prompt_text), chat_history_state, chat_count_state, last_result_state, lang_state], | |
| outputs=chat_outputs, | |
| ) | |
| # Keep chat_history_state in sync with chatbot display | |
| chatbot.change(fn=lambda h: h, inputs=[chatbot], outputs=[chat_history_state]) | |
| # Footer | |
| gr.HTML( | |
| '<div class="oha-footer">' | |
| 'Office of the Healthcare Advocate | ' | |
| '153 Market Street, 6th Floor, Hartford, CT 06103 | ' | |
| '1-866-466-4446 | ' | |
| '<a href="https://portal.ct.gov/oha" target="_blank" rel="noopener">portal.ct.gov/oha</a>' | |
| '</div>' | |
| ) | |
| return demo | |
| # === LAUNCH === | |
| # HF Spaces auto-detects the `demo` Blocks instance. | |
| # The explicit launch() is guarded so pytest can import without starting a server. | |
| demo = create_interface() | |
| if __name__ == "__main__": | |
| demo.launch( | |
| auth=("oha", DEMO_PASSWORD), | |
| ssr_mode=False, | |
| theme=gr.themes.Default( | |
| primary_hue=gr.themes.Color( | |
| c50="#F0F4FA", c100="#D6E2F5", c200="#AECAEF", | |
| c300="#7BAEE6", c400="#4A8FDB", c500="#0046AD", | |
| c600="#003D96", c700="#003478", c800="#002B63", | |
| c900="#001B3A", c950="#001028", | |
| ), | |
| neutral_hue=gr.themes.Color( | |
| c50="#F5F5F5", c100="#E0E0E0", c200="#CED4DA", | |
| c300="#ADB5BD", c400="#6C757D", c500="#495057", | |
| c600="#343A40", c700="#212529", c800="#1A1E21", | |
| c900="#111315", c950="#0A0C0D", | |
| ), | |
| font=["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"], | |
| ), | |
| css=custom_css, | |
| ) | |
| # === TESTS (pytest-compatible) === | |
| # Run with: OPENAI_API_KEY=test pytest app.py -v | |
| def test_calculate_fpl_percentage_single_person(): | |
| """FPL for a single person earning exactly the FPL amount should be 100%.""" | |
| result = calculate_fpl_percentage(15650, 1) | |
| assert result == 100.0 | |
| def test_calculate_fpl_percentage_household_of_4(): | |
| """A family of 4 at $32,150 should be exactly 100% FPL.""" | |
| result = calculate_fpl_percentage(32150, 4) | |
| assert result == 100.0 | |
| def test_calculate_fpl_percentage_large_household(): | |
| """Household of 10 should use the overflow formula: FPL_8 + 5500*(10-8).""" | |
| expected_base = 54150 + (5500 * 2) # 65150 | |
| income = expected_base | |
| result = calculate_fpl_percentage(income, 10) | |
| assert result == 100.0 | |
| def test_calculate_fpl_percentage_half(): | |
| """Income at half of FPL for household of 1 should be ~50%.""" | |
| result = calculate_fpl_percentage(15650 / 2, 1) | |
| assert result == 50.0 | |
| def test_determine_eligibility_free_care(): | |
| """Income well below the free care threshold should yield 100% Discount.""" | |
| result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "en") | |
| assert result["eligibility_status"] == "LIKELY ELIGIBLE" | |
| assert result["discount_level"] == "100% Discount" | |
| def test_determine_eligibility_sliding_scale(): | |
| """Income between free care and sliding scale thresholds → Partial Discount.""" | |
| # Yale: free_care=250%, sliding=550%. For household of 1, FPL=$15,650. | |
| # 300% FPL = $46,950 → above 250% but below 550%. | |
| result = determine_eligibility("Yale New Haven Health", 46950, 1, False, "en") | |
| assert result["eligibility_status"] == "LIKELY ELIGIBLE" | |
| assert result["discount_level"] == "Partial Discount" | |
| def test_determine_eligibility_not_eligible(): | |
| """Income above sliding scale max should not qualify.""" | |
| # 600% FPL for household of 1 = $93,900 → above Yale's 550%. | |
| result = determine_eligibility("Yale New Haven Health", 93900, 1, False, "en") | |
| assert result["eligibility_status"] == "MAY NOT QUALIFY" | |
| def test_determine_eligibility_pa_24_81(): | |
| """SNAP/WIC enrollee under 250% FPL triggers PA 24-81 presumptive eligibility.""" | |
| result = determine_eligibility("Yale New Haven Health", 20000, 1, True, "en") | |
| assert result["pa_24_81_eligible"] is True | |
| assert "PA 24-81" in result["discount_level"] | |
| def test_determine_eligibility_unknown_hospital(): | |
| """An unknown hospital name should return an error result, not crash.""" | |
| result = determine_eligibility("Nonexistent Hospital", 30000, 2, False, "en") | |
| assert result.get("error") is True | |
| assert "not found" in result["eligibility_status"].lower() | |
| def test_determine_eligibility_insurance_flag(): | |
| """The has_insurance flag should be passed through to the result dict.""" | |
| result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "en", has_insurance=True) | |
| assert result["has_insurance"] is True | |
| def test_determine_eligibility_spanish(): | |
| """Spanish language should return Spanish status strings.""" | |
| result = determine_eligibility("Yale New Haven Health", 10000, 1, False, "es") | |
| assert result["eligibility_status"] == "PROBABLEMENTE ELEGIBLE" | |