| """ |
| 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 = """ |
| /* ββ 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 = 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)", |
| ) |
|
|
| |
|
|
| HEADER_MD = """ |
| <div class='harbor-logo'>β Harbor</div> |
| <div class='harbor-tagline'>Come in from the storm.</div> |
| """ |
|
|
| CRISIS_CALLOUT_HTML = """ |
| <div class='harbor-callout'> |
| <div class='harbor-callout-text'> |
| <strong>π€ Talk to a Human</strong> β Trained counselors available |
| <strong>24/7</strong> through the Behavioral Health Help Line.<br> |
| <span style='font-size:0.82rem; color:#6b8e8e;'> |
| In immediate danger? <strong style='color:#6b4e4e;'>Call 911.</strong> |
| </span> |
| </div> |
| <div class='harbor-phone-inline'><a href='tel:8337732445'>π 833-773-2445</a></div> |
| </div> |
| """ |
|
|
| CHATBOT_CARD_MD = """ |
| <div class='harbor-card-title'>π¬ Get Personalized Guidance</div> |
| <p> |
| Want to explore options in more depth? Our chatbot can factor in your insurance, |
| preferences, treatment history, and more β no judgment, no pressure. |
| </p> |
| """ |
|
|
| FOOTER_MD = """ |
| <div class='harbor-footer'> |
| Harbor does not provide medical advice, diagnosis, or treatment.<br> |
| If you are in crisis, please call 911 or the BHHL at 833-773-2445. |
| </div> |
| """ |
|
|
| |
|
|
| 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"<div class='harbor-results'>" |
| f"<div class='harbor-results-title'>Results near {zipcode}</div>" |
| f"<p style='color:#5a7a7a; font-size:0.93rem;'>" |
| f"No results found for that zip code yet. Try the chatbot below for " |
| f"more personalised help.</p>" |
| f"</div>" |
| ) |
|
|
| items_html = "" |
| for r in results: |
| name = r.get("name", "Unknown Facility") |
| |
| 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() |
| |
| focus = r.get("primary_focus", "").strip() |
| type_label = ", ".join( |
| v.strip().replace("_", " ").title() for v in focus.split("|") |
| ) if focus else "" |
|
|
| items_html += ( |
| f"<div style='margin-bottom:0.75rem; padding:0.75rem; background:#f8fffd; " |
| f"border-radius:10px; border:1px solid #c8e6e6;'>" |
| f"<strong style='color:#0d6e6e;'>{name}</strong><br>" |
| ) |
| if type_label or address: |
| items_html += ( |
| f"<span style='font-size:0.88rem; color:#5a7a7a;'>" |
| f"{type_label + ' Β· ' if type_label else ''}{address}</span><br>" |
| ) |
| if phone: |
| items_html += ( |
| f"<a href='tel:{phone}' style='font-size:0.88rem; color:#0d9e8f;'>{phone}</a>" |
| ) |
| items_html += "</div>" |
| return ( |
| f"<div class='harbor-results'>" |
| f"<div class='harbor-results-title'>π Options near {zipcode}</div>" |
| f"{items_html}" |
| f"</div>" |
| ) |
|
|
|
|
| |
|
|
| def create_chatbot(): |
| """Creates the Harbor interface with a landing page and chatbot.""" |
| _load_resources_once() |
|
|
| 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="<div class='harbor-error'>β οΈ Please enter a valid 5-digit zip code.</div>", |
| visible=True, |
| ) |
| results = get_recommendations(zipcode) |
|
|
| |
| 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: |
|
|
| |
| with gr.Column(visible=True) as landing_page: |
| with gr.Column(elem_classes="harbor-wrap"): |
| gr.HTML(HEADER_MD) |
| gr.HTML("<div class='harbor-banner'>π Find options near you in the Greater Boston, Massachusetts area.</div>") |
|
|
| |
| with gr.Group(elem_classes="harbor-card harbor-card-featured"): |
| gr.HTML("<div class='harbor-card-title'>π Enter Your Zip Code</div>") |
| gr.HTML( |
| "<p>We'll show you nearby treatment programs right away, or talk to our chatbot below for better recommendations.</p>" |
| ) |
| 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_html = gr.HTML(visible=False, elem_id="zip-results") |
|
|
| |
| gr.HTML(CRISIS_CALLOUT_HTML) |
|
|
| |
| 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) |
|
|
| |
| with gr.Column(visible=False) as chat_page: |
| |
| |
| |
| 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) |
|
|
| |
| 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) |
|
|