""" Gradio Web Interface for Harbor Treatment Navigation Chatbot Landing page offers three paths: 1. Quick Recommendations — enter a zip code, get nearby options inline 2. Talk to a Human — compact crisis callout with phone number 3. Get Personalized Advice — leads to the AI chatbot Run locally: python app.py Access in browser: http://localhost:7860 """ import os import re import gradio as gr from src.chat import Chatbot from src.utils.profile import create_empty_profile from src.utils.resources import load_resources, filter_resources, score_resources # ── CSS ─────────────────────────────────────────────────────────────────────── CSS = """ /* ── Layout ── */ .harbor-wrap { max-width: 680px; margin: 0 auto; padding: 2.5rem 1.25rem 1.5rem; font-family: 'Inter', sans-serif; } /* ── Header ── */ .harbor-logo { text-align: center; font-size: 2.75rem; font-weight: 800; letter-spacing: -1px; color: #0d6e6e; margin-bottom: 0.2rem; line-height: 1; } .harbor-tagline { text-align: center; font-size: 1.1rem; color: #5a7a7a; margin-bottom: 2.25rem; font-style: italic; } /* ── Location Banner ── */ .harbor-banner { text-align: center; font-size: 0.92rem; font-weight: 600; color: #0d6e6e; background: #e6f7f7; border: 1px solid #c8e6e6; border-radius: 10px; padding: 0.55rem 1rem; margin-bottom: 1.5rem; letter-spacing: 0.1px; } /* ── Cards ── */ .harbor-card { background: #ffffff; border: 1.5px solid #c8e6e6; border-radius: 16px; padding: 1.5rem 1.75rem; margin-bottom: 1.1rem; box-shadow: 0 2px 12px rgba(13, 110, 110, 0.06); } .harbor-card-title { font-size: 1.15rem; font-weight: 700; color: #0d6e6e; margin-bottom: 0.6rem; display: flex; align-items: center; gap: 0.5rem; } .harbor-card p { color: #3d5a5a; line-height: 1.65; margin: 0 0 0.75rem; font-size: 0.97rem; } .harbor-card p:last-child { margin-bottom: 0; } /* ── Quick Rec card — larger, featured ── */ .harbor-card-featured { background: linear-gradient(145deg, #f0fafa, #e6f7f7); border: 2px solid #0d9e8f; } .harbor-card-featured .harbor-card-title { font-size: 1.25rem; } /* ── Crisis callout — compact ── */ .harbor-callout { background: #f8fffd; border: 1.5px solid #c8e6e6; border-radius: 12px; padding: 0.9rem 1.25rem; margin-bottom: 1.1rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; box-shadow: 0 1px 6px rgba(13, 110, 110, 0.05); } .harbor-callout-text { flex: 1; min-width: 200px; font-size: 0.9rem; color: #3d5a5a; line-height: 1.5; } .harbor-callout-text strong { color: #0d6e6e; } .harbor-phone-inline { font-size: 1.2rem; font-weight: 800; color: #0d6e6e; white-space: nowrap; } .harbor-phone-inline a { color: inherit; text-decoration: none; } .harbor-phone-inline a:hover { text-decoration: underline; } /* ── Zip results area ── */ .harbor-results { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #c8e6e6; } .harbor-results-title { font-size: 1rem; font-weight: 600; color: #0d6e6e; margin-bottom: 0.5rem; } .harbor-error { color: #c0392b; font-size: 0.9rem; margin-top: 0.4rem; } #zip-results .pending, #zip-results .generating, #zip-results > .wrap, #zip-results > .svelte-spinner, #zip-results .eta-bar { display: none !important; } /* ── Buttons ── */ .harbor-start-btn button, .harbor-zip-btn button { background: linear-gradient(135deg, #0d9e8f, #0d6e6e) !important; border: none !important; border-radius: 12px !important; font-size: 1.05rem !important; font-weight: 600 !important; letter-spacing: 0.2px !important; padding: 0.85rem 1.5rem !important; transition: opacity 0.2s ease !important; box-shadow: 0 4px 14px rgba(13, 110, 110, 0.25) !important; } .harbor-start-btn button:hover, .harbor-zip-btn button:hover { opacity: 0.9 !important; } /* ── Footer ── */ .harbor-footer { text-align: center; font-size: 0.8rem; color: #8fa8a8; margin-top: 1.75rem; line-height: 1.6; } /* ── Chat page ── */ .chat-header { max-width: 680px; margin: 0 auto; padding: 1.25rem 1.25rem 0; } .chat-back-btn button { background: transparent !important; border: 1.5px solid #c8e6e6 !important; color: #0d6e6e !important; border-radius: 8px !important; font-size: 0.9rem !important; font-weight: 500 !important; padding: 0.4rem 0.9rem !important; } .chat-back-btn button:hover { background: #f0fafa !important; } /* ── Chat input area ── */ .gradio-chatinterface > div:last-child, footer, .chatbot-input-row, [data-testid="chatbot"] ~ div { padding: 0 1.25rem 1.25rem !important; } .gradio-chatinterface .input-row, .gradio-chatinterface form { margin: 0.75rem 2rem 1.5rem !important; border: 1.5px solid #c8e6e6 !important; border-radius: 14px !important; padding: 0.5rem !important; box-shadow: 0 2px 10px rgba(13, 110, 110, 0.07) !important; background: #ffffff !important; } """ # ── Theme ───────────────────────────────────────────────────────────────────── THEME = gr.themes.Soft( primary_hue="teal", secondary_hue="cyan", neutral_hue="slate", font=[gr.themes.GoogleFont("Inter"), "sans-serif"], ).set( button_primary_background_fill="linear-gradient(135deg, #0d9e8f, #0d6e6e)", button_primary_background_fill_hover="linear-gradient(135deg, #0bb8a8, #0d9e8f)", button_primary_text_color="#ffffff", block_border_color="#c8e6e6", block_shadow="0 2px 12px rgba(13,110,110,0.06)", ) # ── Static HTML snippets ─────────────────────────────────────────────────────── HEADER_MD = """
Come in from the storm.
""" CRISIS_CALLOUT_HTML = """
🤝 Talk to a Human — Trained counselors available 24/7 through the Behavioral Health Help Line.
In immediate danger? Call 911.
📞 833-773-2445
""" CHATBOT_CARD_MD = """
💬 Get Personalized Guidance

Want to explore options in more depth? Our chatbot can factor in your insurance, preferences, treatment history, and more — no judgment, no pressure.

""" FOOTER_MD = """ """ # ── Helpers ─────────────────────────────────────────────────────────────────── ZIPCODE_RE = re.compile(r"^\d{5}$") def is_valid_zip(zipcode: str) -> bool: """Return True if zipcode is exactly 5 digits.""" return bool(ZIPCODE_RE.match(zipcode.strip())) def _load_resources_once(): """Load resource CSVs once and cache.""" if not hasattr(_load_resources_once, "_cache"): current_dir = os.path.dirname(os.path.abspath(__file__)) paths = [ os.path.join(current_dir, "references", "knowledge", "ma_resources.csv"), os.path.join(current_dir, "references", "knowledge", "resources", "boston_resources.csv"), ] _load_resources_once._cache = load_resources(paths) return _load_resources_once._cache def get_recommendations(zipcode: str) -> list[dict]: """ Return a list of treatment recommendations for the given zip code. Uses the same filter/score logic as the chatbot, but with a minimal profile containing only the zipcode. """ profile = create_empty_profile() profile["logistics"]["zipcode"] = zipcode.strip() resources = _load_resources_once() filtered = filter_resources(resources, profile) top = score_resources(filtered, profile) return top def format_recommendations(zipcode: str, results: list[dict]) -> str: """Render recommendations as an HTML snippet for display.""" if not results: return ( f"
" f"
Results near {zipcode}
" f"

" f"No results found for that zip code yet. Try the chatbot below for " f"more personalised help.

" f"
" ) items_html = "" for r in results: name = r.get("name", "Unknown Facility") # Build address from parts addr_parts = [r.get("address", ""), r.get("city", ""), r.get("state", ""), r.get("zip", "")] address = ", ".join(p.strip() for p in addr_parts if p.strip()) phone = r.get("phone", "").strip() # Type from primary_focus focus = r.get("primary_focus", "").strip() type_label = ", ".join( v.strip().replace("_", " ").title() for v in focus.split("|") ) if focus else "" items_html += ( f"
" f"{name}
" ) if type_label or address: items_html += ( f"" f"{type_label + ' · ' if type_label else ''}{address}
" ) if phone: items_html += ( f"{phone}" ) items_html += "
" return ( f"
" f"
📍 Options near {zipcode}
" f"{items_html}" f"
" ) # ── App ─────────────────────────────────────────────────────────────────────── def create_chatbot(): """Creates the Harbor interface with a landing page and chatbot.""" _load_resources_once() # pre-load CSVs so first zip lookup is fast def chat(message, history, bot): """ Generate a response for the current message using a per-session Chatbot. Args: message (str): The current message from the user history (list): List of previous message dicts for this session bot (Chatbot): The per-session Chatbot instance (held in gr.State) Returns: tuple: (updated history, cleared input, bot) """ response = bot.get_response(message, history) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": response}, ] return history, gr.update(value=""), bot def handle_zip_submit(zipcode: str): """Validate zip and return inline results HTML.""" zipcode = zipcode.strip() if not is_valid_zip(zipcode): return gr.update( value="
⚠️ Please enter a valid 5-digit zip code.
", visible=True, ) results = get_recommendations(zipcode) # Log recommendations to console if results: print(f"[Harbor] Zip lookup ({zipcode}) — {len(results)} recommendation(s):") for i, r in enumerate(results, 1): print(f" {i}. {r.get('name', 'Unknown')} — {r.get('city', '')}, {r.get('state', '')} {r.get('zip', '')}") else: print(f"[Harbor] Zip lookup ({zipcode}) — no results found.") return gr.update(value=format_recommendations(zipcode, results), visible=True) def show_landing(): return gr.update(visible=True), gr.update(visible=False) OPENING_MESSAGE = ( "How can I support you today? You can share anything about what you're dealing with—mental health concerns, alcohol or drug use, support for a loved one, or help finding treatment resources." ) with gr.Blocks(title="Harbor", theme=THEME, css=CSS) as demo: # ── Landing Page ────────────────────────────────────────────── with gr.Column(visible=True) as landing_page: with gr.Column(elem_classes="harbor-wrap"): gr.HTML(HEADER_MD) gr.HTML("
📍 Find options near you in the Greater Boston, Massachusetts area.
") # Card 1 — Quick Recommendations (featured) with gr.Group(elem_classes="harbor-card harbor-card-featured"): gr.HTML("
🏠 Enter Your Zip Code
") gr.HTML( "

We'll show you nearby treatment programs right away, or talk to our chatbot below for better recommendations.

" ) with gr.Row(): zip_input = gr.Textbox( placeholder="e.g. 02134", max_lines=1, show_label=False, container=False, scale=3, ) zip_btn = gr.Button( "Find Options →", variant="primary", scale=1, elem_classes="harbor-zip-btn", ) # Results rendered outside the card so the loading spinner # does not overlay the input card above. results_html = gr.HTML(visible=False, elem_id="zip-results") # Card 2 — Crisis callout (compact) gr.HTML(CRISIS_CALLOUT_HTML) # Card 3 — Chatbot with gr.Group(elem_classes="harbor-card"): gr.HTML(CHATBOT_CARD_MD) start_chat_btn = gr.Button( "Start a Conversation →", variant="primary", size="lg", elem_classes="harbor-start-btn", ) gr.HTML(FOOTER_MD) # ── Chat Page ───────────────────────────────────────────────── with gr.Column(visible=False) as chat_page: # Per-session state: a fresh Chatbot() is created for each browser session. # Clicking "Start a Conversation" also resets it, so no data carries over # between conversations on the same tab. chatbot_state = gr.State(Chatbot) with gr.Column(elem_classes="chat-header"): back_btn = gr.Button( "← Back to Home", size="sm", variant="secondary", elem_classes="chat-back-btn", ) chatbot_display = gr.Chatbot( value=[{"role": "assistant", "content": OPENING_MESSAGE}], label="⚓ Harbor", type="messages", ) with gr.Row(): msg_input = gr.Textbox( placeholder="Type your message here…", show_label=False, scale=8, container=False, ) send_btn = gr.Button("Send →", variant="primary", scale=1) def reset_bot_history(bot): """Reset bot state and chatbot history while chat page is still hidden.""" bot.reset() return bot, [{"role": "assistant", "content": OPENING_MESSAGE}] def show_chat_page(): """Reveal chat page after bot has been reset — chatbot not in outputs, so no thinking indicator.""" return gr.update(visible=False), gr.update(visible=True) # ── Events ──────────────────────────────────────────────────── zip_btn.click(handle_zip_submit, inputs=zip_input, outputs=results_html) zip_input.submit(handle_zip_submit, inputs=zip_input, outputs=results_html) start_chat_btn.click( reset_bot_history, inputs=[chatbot_state], outputs=[chatbot_state, chatbot_display], ).then( show_chat_page, outputs=[landing_page, chat_page], ) back_btn.click(show_landing, outputs=[landing_page, chat_page]) send_btn.click( chat, inputs=[msg_input, chatbot_display, chatbot_state], outputs=[chatbot_display, msg_input, chatbot_state], ) msg_input.submit( chat, inputs=[msg_input, chatbot_display, chatbot_state], outputs=[chatbot_display, msg_input, chatbot_state], ) return demo if __name__ == "__main__": demo = create_chatbot() demo.launch(share=True)