Spaces:
Sleeping
Sleeping
phanny commited on
Commit ·
78b92dc
1
Parent(s): de10a75
add google maps API
Browse files- .cursor/plans/samhsa_treatment_locator_chatbot_572923b7.plan.md +118 -0
- README.md +5 -1
- app.py +540 -69
- data/README.md +17 -0
- docs/MEMO.md +57 -0
- requirements.txt +8 -0
- scripts/download_findtreatment_data.py +80 -0
- scripts/eval_chatbot.py +207 -0
- scripts/ingest_facilities.py +433 -0
- src/chat.py +243 -55
- src/facilities.py +180 -0
.cursor/plans/samhsa_treatment_locator_chatbot_572923b7.plan.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: SAMHSA Treatment Locator Chatbot
|
| 3 |
+
overview: "Build the SAMHSA Treatment Locator as a single, polished Gradio app on HuggingFace: data-grounded chatbot with conversation design matching the example, clear evaluation for the memo, trust/inclusivity and data story, and a professional Gradio UI—structured so a future React/Vercel frontend can reuse the same backend."
|
| 4 |
+
todos: []
|
| 5 |
+
isProject: false
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# SAMHSA Treatment Locator – Gradio/HF Focus (Memo-Ready)
|
| 9 |
+
|
| 10 |
+
## Goal
|
| 11 |
+
|
| 12 |
+
Deliver one strong product: a **Gradio chatbot on HuggingFace** that helps users find treatment facilities by conversation, with **no hallucinated info**, a **memo-friendly evaluation**, and a **good-looking UI**. Design the backend so that, if time allows, a React app on Vercel can call the same logic later.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. Core product (unchanged from before)
|
| 17 |
+
|
| 18 |
+
- **Data:** Load SAMHSA facility data (CSV from N-SUMHSS or National Directory); implement `search(criteria)` in `src/facilities.py`.
|
| 19 |
+
- **State:** Conversation state (criteria + last results) in Gradio; extract/merge criteria from turns; run search when location (and optionally other filters) are present.
|
| 20 |
+
- **Accuracy:** System prompt + only pass real search results to the model; model never invents facilities, addresses, or phones.
|
| 21 |
+
- **Files:** [requirements.txt](requirements.txt) (+ pandas), `data/` + CSV, [src/facilities.py](src/facilities.py), [src/chat.py](src/chat.py), [app.py](app.py), [config.py](config.py).
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 2. Conversation design (match the example)
|
| 26 |
+
|
| 27 |
+
Align the flow with [samhsa_chatbot_conversation_example.txt](samhsa_chatbot_conversation_example.txt):
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
| Phase | Behavior |
|
| 31 |
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 32 |
+
| **Greet / clarify** | First message: acknowledge, then ask for location, treatment type (inpatient/outpatient/residential/telehealth), and payment (insurance, Medicaid, sliding scale, free). |
|
| 33 |
+
| **First results** | Once we have at least location (and ideally type + payment), return 2–3 facilities by name with 1–2 sentence descriptions (from data only). Offer to give more details or other options. |
|
| 34 |
+
| **Follow-up** | If user asks about a specific facility (e.g. “Do they offer MAT?”), answer from the same facility record only; offer next steps (e.g. how to contact). |
|
| 35 |
+
| **Closing** | If user thanks or says they’re done, brief supportive close and invite them to return. |
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
Implementation: encode this in the **system prompt** and, if needed, short **rules** (e.g. “if no location in state, ask for location before searching”). Keep responses concise and actionable.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## 3. Trust and inclusivity in the product
|
| 43 |
+
|
| 44 |
+
- **Disclaimer (in UI):** In Gradio description or a static text block: *“Information is from SAMHSA data. Always verify with the facility or [findtreatment.gov](https://findtreatment.gov) before making decisions. This tool does not endorse any facility.”*
|
| 45 |
+
- **Tone:** Supportive, non-judgmental, clear (reflected in system prompt).
|
| 46 |
+
- **Memo:** In the memo, mention accessibility (e.g. keyboard use, clear labels) and any limitations (e.g. English-only for now, data as of [date]).
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 4. Data story (for the memo)
|
| 51 |
+
|
| 52 |
+
- **Source:** Name the dataset (e.g. N-SUMHSS 2024 or National Directory 2024), where you got it, and how you converted/processed it (e.g. “SAS → CSV, kept facilities with non-missing location”).
|
| 53 |
+
- **Scope:** What’s included (e.g. states covered, which attributes: treatment type, payment, populations, therapies, languages).
|
| 54 |
+
- **Limitations:** One or two sentences (e.g. “Data as of [date]; facility details may have changed; always confirm with the provider.”).
|
| 55 |
+
- **In code:** Optional `data/README.md` or a short comment in `src/facilities.py` with the same bullets so the memo can reference the repo.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## 5. Evaluation that’s easy to describe in the memo
|
| 60 |
+
|
| 61 |
+
- **Scenarios:** Define 15–20 test scenarios (e.g. “outpatient, Boston, Medicaid, MAT”; “veterans, California, residential”). Cover variety: locations, treatment types, payment, special populations.
|
| 62 |
+
- **Metrics:**
|
| 63 |
+
- **Hallucination:** For each run, check that every facility name (and contact info) in the bot’s reply appears in your dataset. Target: 0 invented facilities.
|
| 64 |
+
- **Match:** Check that returned facilities actually match the scenario’s criteria (e.g. accepts Medicaid, offers outpatient). Report e.g. “18/20 runs had all suggested facilities matching criteria.”
|
| 65 |
+
- **Artifact:** A small script (e.g. `scripts/eval_chatbot.py`) or notebook that runs these scenarios (or a subset) and outputs a table: scenario, facilities returned, hallucination? (Y/N), all match? (Y/N). Use that table (or a summary) in the memo.
|
| 66 |
+
- **Memo section:** “Evaluation” with method (scenarios + checks) and results (numbers + 1–2 example outcomes). Makes the “we do not provide inaccurate information” claim concrete.
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## 6. Memo that’s easy to grade
|
| 71 |
+
|
| 72 |
+
Structure the 1–2 page memo as:
|
| 73 |
+
|
| 74 |
+
- **Design:** How the chatbot works (data → criteria extraction → search → response); why this flow; how you avoid hallucination.
|
| 75 |
+
- **Data:** Data story (source, scope, limitations) as above.
|
| 76 |
+
- **Evaluation:** Method (scenarios, hallucination + match checks) and results (table or bullet list).
|
| 77 |
+
- **Limitations:** 2–3 short points (e.g. data freshness, English-only, no medical advice).
|
| 78 |
+
|
| 79 |
+
Optional: one figure (e.g. table of eval results or a short dialogue snippet showing good behavior).
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 7. Make Gradio good-looking
|
| 84 |
+
|
| 85 |
+
- **Theme:** Use a cohesive theme, e.g. `gr.themes.Soft()` or `gr.themes.Glass()` (or a custom theme) in [app.py](app.py) so the Space doesn’t look like the default.
|
| 86 |
+
- **Title and description:** Clear title (e.g. “SAMHSA Treatment Locator”) and a short description: what it does, that it uses SAMHSA data, and the disclaimer (or link to it).
|
| 87 |
+
- **Examples:** 2–3 example prompts that mirror the conversation flow (e.g. “I’m looking for outpatient alcohol treatment in Boston with Medicaid”; “Do you have options for veterans in Texas?”).
|
| 88 |
+
- **Layout (optional):** If useful, use `gr.Blocks()` and place the disclaimer in a visible box above or below the chat; keep the chat as the main focus.
|
| 89 |
+
- **Copy:** Friendly, consistent button/label text (e.g. “Send” or “Ask”) and placeholder if applicable.
|
| 90 |
+
|
| 91 |
+
No second UI codebase; all “beautiful” effort stays in this one Gradio app.
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## 8. Future React/Vercel (if time allows)
|
| 96 |
+
|
| 97 |
+
To make a later React app on Vercel easy:
|
| 98 |
+
|
| 99 |
+
- **Logic in one place:** Keep all “business logic” (criteria extraction, search, response generation) in Python (e.g. `src/chat.py` + `src/facilities.py`). The Gradio app in [app.py](app.py) should only call into that (e.g. `chatbot.get_response(message, history, state)`).
|
| 100 |
+
- **Optional API later:** If you add a small FastAPI (or Flask) wrapper that exposes a single endpoint (e.g. `POST /chat` with `{ "message": "...", "history": [...], "state": {...} }` and returns `{ "response": "...", "state": {...} }`), the same backend can serve both Gradio and a React frontend. For this phase, **no API is required**; just avoid putting critical logic inside Gradio-specific code so that a thin API layer can be added later without refactoring.
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## File and task summary
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
| Item | Action |
|
| 108 |
+
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
| 109 |
+
| Data | Add `data/` + facility CSV; optional `data/README.md` for data story |
|
| 110 |
+
| [requirements.txt](requirements.txt) | Add `pandas` |
|
| 111 |
+
| `src/facilities.py` | Load CSV, `search(criteria)`, column mapping |
|
| 112 |
+
| [src/chat.py](src/chat.py) | Stateful flow, system prompt (no hallucination, conversation phases), criteria + search results in context |
|
| 113 |
+
| [app.py](app.py) | State + history; theme, title, description, examples, disclaimer; pass history/state to chatbot |
|
| 114 |
+
| Eval | `scripts/eval_chatbot.py` or notebook: run 15–20 scenarios, record hallucination + match, output table |
|
| 115 |
+
| Memo | 1–2 pages: Design, Data, Evaluation, Limitations (and optional figure) |
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
Implementation order: data → facilities.py → chat.py (with conversation design + trust in prompt) → app.py (state, UI, disclaimer) → eval script → memo.
|
README.md
CHANGED
|
@@ -43,7 +43,11 @@ pip install -r requirements.txt
|
|
| 43 |
- In config.py, set the BASE_MODEL variable to your base model of choice from HuggingFace.
|
| 44 |
- Keep in mind it's better to have a small, lightweight model if you plan on finetuning.
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
## Repository Organization
|
| 49 |
|
|
|
|
| 43 |
- In config.py, set the BASE_MODEL variable to your base model of choice from HuggingFace.
|
| 44 |
- Keep in mind it's better to have a small, lightweight model if you plan on finetuning.
|
| 45 |
|
| 46 |
+
4. **Use all FindTreatment.gov data (optional):** The app loads facilities from `data/facilities.csv`. By default the repo may include a small sample. To use the full dataset (same as [FindTreatment.gov](https://findtreatment.gov)), run:
|
| 47 |
+
```bash
|
| 48 |
+
python scripts/download_findtreatment_data.py
|
| 49 |
+
```
|
| 50 |
+
This downloads the official SAMHSA National Directory and builds `data/facilities.csv`. Requires `openpyxl` (in `requirements.txt`).
|
| 51 |
|
| 52 |
## Repository Organization
|
| 53 |
|
app.py
CHANGED
|
@@ -1,80 +1,551 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Key Features:
|
| 8 |
-
- Creates a web UI for your chatbot
|
| 9 |
-
- Handles conversation history
|
| 10 |
-
- Provides example questions
|
| 11 |
-
- Can be deployed to Hugging Face Spaces
|
| 12 |
-
|
| 13 |
-
Example Usage:
|
| 14 |
-
# Run locally:
|
| 15 |
-
python app.py
|
| 16 |
-
|
| 17 |
-
# Access in browser:
|
| 18 |
-
# http://localhost:7860
|
| 19 |
"""
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import gradio as gr
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
chatbot = Chatbot()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
return demo
|
| 77 |
|
|
|
|
| 78 |
if __name__ == "__main__":
|
| 79 |
-
demo =
|
| 80 |
-
demo.launch(
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
SAMHSA Treatment Locator – Gradio app for HuggingFace Spaces.
|
| 3 |
+
|
| 4 |
+
Two-pane layout: map (left) + chat (right). When GOOGLE_MAPS_API_KEY is set in .env,
|
| 5 |
+
the map uses Google Maps (JavaScript API); otherwise Folium/OpenStreetMap.
|
| 6 |
+
Facility markers and search results are shown on the map.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
import base64
|
| 10 |
+
import html as html_module
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
import folium
|
| 16 |
import gradio as gr
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
+
# Load .env from project root so GOOGLE_MAPS_API_KEY is set regardless of launch cwd
|
| 20 |
+
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
_ENV_PATH = os.path.join(_APP_DIR, ".env")
|
| 22 |
+
try:
|
| 23 |
+
from dotenv import load_dotenv
|
| 24 |
+
load_dotenv(_ENV_PATH)
|
| 25 |
+
except ImportError:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
from src.chat import DEFAULT_STATE, Chatbot
|
| 29 |
+
|
| 30 |
+
# Use Google Maps when key is set; otherwise Folium/OSM. Enable "Maps JavaScript API" in Google Cloud.
|
| 31 |
+
GOOGLE_MAPS_API_KEY = (os.environ.get("GOOGLE_MAPS_API_KEY") or "").strip() or None
|
| 32 |
+
if not GOOGLE_MAPS_API_KEY and os.path.isfile(_ENV_PATH):
|
| 33 |
+
with open(_ENV_PATH) as f:
|
| 34 |
+
for line in f:
|
| 35 |
+
line = line.strip()
|
| 36 |
+
if line.startswith("GOOGLE_MAPS_API_KEY=") and "=" in line:
|
| 37 |
+
key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
| 38 |
+
if key:
|
| 39 |
+
GOOGLE_MAPS_API_KEY = key
|
| 40 |
+
break
|
| 41 |
+
|
| 42 |
+
# City/place -> (lat, lon) for map pins (fast lookup). Add more as needed.
|
| 43 |
+
CITY_COORDS = {
|
| 44 |
+
"boston": (42.36, -71.06),
|
| 45 |
+
"belmont": (42.40, -71.18),
|
| 46 |
+
"roxbury": (42.33, -71.08),
|
| 47 |
+
"allston": (42.35, -71.13),
|
| 48 |
+
"amesbury": (42.86, -70.93),
|
| 49 |
+
"austin": (30.27, -97.74),
|
| 50 |
+
"san antonio": (29.42, -98.49),
|
| 51 |
+
"san francisco": (37.77, -122.42),
|
| 52 |
+
"los angeles": (34.05, -118.25),
|
| 53 |
+
"lake view terrace": (34.27, -118.37),
|
| 54 |
+
"chicago": (41.88, -87.63),
|
| 55 |
+
}
|
| 56 |
+
# Geocode cache so any city/state from search results can be shown on the map.
|
| 57 |
+
_GEOCODE_CACHE = {}
|
| 58 |
+
# Show all proposed facilities (chat returns up to 5; allow more for geocoding).
|
| 59 |
+
_MAX_GEOCODE_PER_MAP = 20
|
| 60 |
+
|
| 61 |
+
MAP_HEIGHT_PX = 420
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _geocode(location_str):
|
| 65 |
+
"""Resolve address/city to (lat, lon) using Nominatim. Returns None on failure. Uses cache."""
|
| 66 |
+
if not location_str or not location_str.strip():
|
| 67 |
+
return None
|
| 68 |
+
key = location_str.strip().lower()
|
| 69 |
+
if key in _GEOCODE_CACHE:
|
| 70 |
+
return _GEOCODE_CACHE[key]
|
| 71 |
+
try:
|
| 72 |
+
from geopy.geocoders import Nominatim
|
| 73 |
+
from geopy.extra.rate_limiter import RateLimiter
|
| 74 |
+
geocoder = Nominatim(user_agent="samhsa-treatment-locator")
|
| 75 |
+
geocode = RateLimiter(geocoder.geocode, min_delay_seconds=1.0)
|
| 76 |
+
result = geocode(location_str.strip(), country_codes="us")
|
| 77 |
+
if result:
|
| 78 |
+
coord = (result.latitude, result.longitude)
|
| 79 |
+
_GEOCODE_CACHE[key] = coord
|
| 80 |
+
return coord
|
| 81 |
+
except Exception:
|
| 82 |
+
pass
|
| 83 |
+
return CITY_COORDS.get(key)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _decode_google_polyline(encoded: str):
|
| 87 |
+
"""Decode Google's encoded polyline string to list of (lat, lon)."""
|
| 88 |
+
# https://developers.google.com/maps/documentation/utilities/polylinealgorithm
|
| 89 |
+
coords = []
|
| 90 |
+
i = 0
|
| 91 |
+
lat = 0
|
| 92 |
+
lon = 0
|
| 93 |
+
while i < len(encoded):
|
| 94 |
+
b = 0
|
| 95 |
+
shift = 0
|
| 96 |
+
result = 0
|
| 97 |
+
while True:
|
| 98 |
+
b = ord(encoded[i]) - 63
|
| 99 |
+
i += 1
|
| 100 |
+
result |= (b & 0x1F) << shift
|
| 101 |
+
shift += 5
|
| 102 |
+
if b < 0x20:
|
| 103 |
+
break
|
| 104 |
+
dlat = ~(result >> 1) if result & 1 else result >> 1
|
| 105 |
+
lat += dlat
|
| 106 |
+
shift = 0
|
| 107 |
+
result = 0
|
| 108 |
+
while True:
|
| 109 |
+
b = ord(encoded[i]) - 63
|
| 110 |
+
i += 1
|
| 111 |
+
result |= (b & 0x1F) << shift
|
| 112 |
+
shift += 5
|
| 113 |
+
if b < 0x20:
|
| 114 |
+
break
|
| 115 |
+
dlon = ~(result >> 1) if result & 1 else result >> 1
|
| 116 |
+
lon += dlon
|
| 117 |
+
coords.append((lat * 1e-5, lon * 1e-5))
|
| 118 |
+
return coords
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _get_route(lat1, lon1, lat2, lon2):
|
| 122 |
+
"""Return list of (lat, lon) for driving route. Uses Google Directions if GOOGLE_MAPS_API_KEY set, else OSRM."""
|
| 123 |
+
if GOOGLE_MAPS_API_KEY:
|
| 124 |
+
try:
|
| 125 |
+
url = (
|
| 126 |
+
"https://maps.googleapis.com/maps/api/directions/json"
|
| 127 |
+
f"?origin={lat1},{lon1}&destination={lat2},{lon2}&key={GOOGLE_MAPS_API_KEY}"
|
| 128 |
+
)
|
| 129 |
+
r = requests.get(url, timeout=10)
|
| 130 |
+
if r.status_code != 200:
|
| 131 |
+
return _get_route_osrm(lat1, lon1, lat2, lon2)
|
| 132 |
+
data = r.json()
|
| 133 |
+
if data.get("status") != "OK" or not data.get("routes"):
|
| 134 |
+
return _get_route_osrm(lat1, lon1, lat2, lon2)
|
| 135 |
+
points = data["routes"][0].get("overview_polyline", {}).get("points")
|
| 136 |
+
if points:
|
| 137 |
+
return _decode_google_polyline(points)
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
return _get_route_osrm(lat1, lon1, lat2, lon2)
|
| 141 |
+
return _get_route_osrm(lat1, lon1, lat2, lon2)
|
| 142 |
+
|
| 143 |
|
| 144 |
+
def _get_route_osrm(lat1, lon1, lat2, lon2):
|
| 145 |
+
"""Return list of (lat, lon) for driving route from OSRM (free), or None."""
|
| 146 |
+
try:
|
| 147 |
+
url = (
|
| 148 |
+
"https://router.project-osrm.org/route/v1/driving/"
|
| 149 |
+
f"{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson"
|
| 150 |
+
)
|
| 151 |
+
r = requests.get(url, timeout=5)
|
| 152 |
+
if r.status_code != 200:
|
| 153 |
+
return None
|
| 154 |
+
data = r.json()
|
| 155 |
+
if not data.get("routes"):
|
| 156 |
+
return None
|
| 157 |
+
coords = data["routes"][0]["geometry"]["coordinates"]
|
| 158 |
+
return [(c[1], c[0]) for c in coords]
|
| 159 |
+
except Exception:
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _facility_coord(f, geocode_count=None):
|
| 164 |
+
"""(lat, lon) for a facility: CITY_COORDS first, then geocode city+state (cached). f uses CSV keys."""
|
| 165 |
+
city = (f.get("city") or "").strip()
|
| 166 |
+
state = (f.get("state") or "").strip()
|
| 167 |
+
if not city and not state:
|
| 168 |
+
return None
|
| 169 |
+
city_lower = city.lower()
|
| 170 |
+
state_lower = state.lower()
|
| 171 |
+
coord = CITY_COORDS.get(city_lower) or (CITY_COORDS.get(state_lower) if state_lower else None)
|
| 172 |
+
if coord:
|
| 173 |
+
return coord
|
| 174 |
+
location_str = f"{city}, {state}".strip(", ")
|
| 175 |
+
if not location_str:
|
| 176 |
+
return None
|
| 177 |
+
if geocode_count is not None and len(geocode_count) == 1 and location_str.lower() not in _GEOCODE_CACHE:
|
| 178 |
+
if geocode_count[0] >= _MAX_GEOCODE_PER_MAP:
|
| 179 |
+
return None
|
| 180 |
+
geocode_count[0] += 1
|
| 181 |
+
return _geocode(location_str)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _popup_html(f):
|
| 185 |
+
"""Short HTML for Folium popup. f uses CSV keys (facility_name, address, etc.)."""
|
| 186 |
+
name = f.get("facility_name") or f.get("name") or "Facility"
|
| 187 |
+
addr = f.get("address") or ""
|
| 188 |
+
city = f.get("city") or ""
|
| 189 |
+
st = f.get("state") or ""
|
| 190 |
+
phone = f.get("phone") or ""
|
| 191 |
+
t = f.get("treatment_type") or ""
|
| 192 |
+
parts = [f"<b>{name}</b>", f"{addr}, {city} {st}".strip(", ")]
|
| 193 |
+
if phone:
|
| 194 |
+
parts.append(f"📞 {phone}")
|
| 195 |
+
if t:
|
| 196 |
+
parts.append(f"Type: {t}")
|
| 197 |
+
return "<br>".join(parts)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _get_facility_coords(facilities):
|
| 201 |
+
"""Return list of (lat, lon, facility_dict) for facilities that have coordinates."""
|
| 202 |
+
result = []
|
| 203 |
+
geocode_count = [0]
|
| 204 |
+
for f in facilities:
|
| 205 |
+
if not isinstance(f, dict):
|
| 206 |
+
continue
|
| 207 |
+
coord = _facility_coord(f, geocode_count)
|
| 208 |
+
if coord:
|
| 209 |
+
result.append((coord[0], coord[1], f))
|
| 210 |
+
return result
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _build_google_map_html(facilities, force_update_id=None, selected_facility_name=None):
|
| 214 |
+
"""Build Google Maps in an iframe via srcdoc so scripts run (interactive map). Requires GOOGLE_MAPS_API_KEY."""
|
| 215 |
+
if not GOOGLE_MAPS_API_KEY:
|
| 216 |
+
return _build_folium_map_html(facilities, None, force_update_id, selected_facility_name)
|
| 217 |
+
try:
|
| 218 |
+
facility_coords = _get_facility_coords(facilities)
|
| 219 |
+
center_lat, center_lon, zoom = 39.5, -98.5, 3
|
| 220 |
+
if facility_coords:
|
| 221 |
+
lats = [c[0] for c in facility_coords]
|
| 222 |
+
lons = [c[1] for c in facility_coords]
|
| 223 |
+
center_lat = sum(lats) / len(lats)
|
| 224 |
+
center_lon = sum(lons) / len(lons)
|
| 225 |
+
zoom = 10
|
| 226 |
+
markers_data = []
|
| 227 |
+
for lat, lon, f in facility_coords:
|
| 228 |
+
name = (f.get("facility_name") or f.get("name") or "Facility").replace("<", "<").replace(">", ">")
|
| 229 |
+
info = _popup_html(f)
|
| 230 |
+
sel = selected_facility_name and (f.get("facility_name") or f.get("name") or "") == selected_facility_name
|
| 231 |
+
markers_data.append({"lat": lat, "lng": lon, "name": name, "info": info, "selected": sel})
|
| 232 |
+
# Base64-encode markers so srcdoc HTML escaping cannot break the JSON
|
| 233 |
+
markers_json = json.dumps(markers_data)
|
| 234 |
+
markers_b64 = base64.b64encode(markers_json.encode("utf-8")).decode("ascii")
|
| 235 |
+
script_url = f"https://maps.googleapis.com/maps/api/js?key={GOOGLE_MAPS_API_KEY}&callback=init"
|
| 236 |
+
doc = f"""<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0">
|
| 237 |
+
<div id="map" style="width:100%;height:100%;min-height:{MAP_HEIGHT_PX}px;"></div>
|
| 238 |
+
<script>
|
| 239 |
+
var center = {{ lat: {center_lat}, lng: {center_lon} }};
|
| 240 |
+
var zoom = {zoom};
|
| 241 |
+
var markersData = JSON.parse(atob("{markers_b64}"));
|
| 242 |
+
function init() {{
|
| 243 |
+
var map = new google.maps.Map(document.getElementById("map"), {{ center: center, zoom: zoom, mapTypeControl: true, fullscreenControl: true, zoomControl: true, scaleControl: true }});
|
| 244 |
+
var infowindow = new google.maps.InfoWindow();
|
| 245 |
+
var bounds = null;
|
| 246 |
+
if (markersData && markersData.length) {{
|
| 247 |
+
markersData.forEach(function(m) {{
|
| 248 |
+
var pos = {{ lat: m.lat, lng: m.lng }};
|
| 249 |
+
var opts = {{ position: pos, map: map, title: m.name }};
|
| 250 |
+
if (m.selected) {{
|
| 251 |
+
opts.icon = {{ path: google.maps.SymbolPath.CIRCLE, scale: 14, fillColor: "#c62828", fillOpacity: 1, strokeColor: "#fff", strokeWeight: 3 }};
|
| 252 |
+
}}
|
| 253 |
+
var marker = new google.maps.Marker(opts);
|
| 254 |
+
marker.addListener("click", function() {{ infowindow.setContent(m.info); infowindow.open(map, marker); }});
|
| 255 |
+
if (!bounds) bounds = new google.maps.LatLngBounds(pos, pos);
|
| 256 |
+
else bounds.extend(pos);
|
| 257 |
+
}});
|
| 258 |
+
if (bounds && markersData.length > 0) {{
|
| 259 |
+
map.fitBounds(bounds, {{ top: 40, right: 40, bottom: 40, left: 40 }});
|
| 260 |
+
if (markersData.length === 1) map.setZoom(12);
|
| 261 |
+
}}
|
| 262 |
+
}}
|
| 263 |
+
}}
|
| 264 |
+
</script>
|
| 265 |
+
<script src="{script_url}" async defer></script>
|
| 266 |
+
</body></html>"""
|
| 267 |
+
# Embed in iframe via srcdoc so the document runs in its own context and scripts execute
|
| 268 |
+
escaped = html_module.escape(doc, quote=True)
|
| 269 |
+
html = f'<iframe srcdoc="{escaped}" style="width:100%;height:{MAP_HEIGHT_PX}px;border:0;border-radius:12px;" title="Google Map"></iframe>'
|
| 270 |
+
if force_update_id is not None:
|
| 271 |
+
html += f"<!-- map-update:{force_update_id} -->"
|
| 272 |
+
return html
|
| 273 |
+
except Exception as e:
|
| 274 |
+
return (
|
| 275 |
+
f'<div style="width:100%;height:{MAP_HEIGHT_PX}px;display:flex;align-items:center;justify-content:center;'
|
| 276 |
+
f'background:#f5f5f5;border-radius:12px;color:#666;">'
|
| 277 |
+
f'Map could not be loaded. ({str(e)[:80]})</div>'
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _build_map_html(facilities, user_location_str=None, force_update_id=None, selected_facility_name=None):
|
| 282 |
+
"""Build map HTML: Google Maps (iframe) when key is set, else Folium/OSM."""
|
| 283 |
+
if GOOGLE_MAPS_API_KEY:
|
| 284 |
+
return _build_google_map_html(facilities, force_update_id, selected_facility_name)
|
| 285 |
+
return _build_folium_map_html(facilities, user_location_str, force_update_id, selected_facility_name)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _build_folium_map_html(facilities, user_location_str=None, force_update_id=None, selected_facility_name=None):
|
| 289 |
"""
|
| 290 |
+
Build Folium (Leaflet) map as HTML. Scroll over map to zoom, drag to pan.
|
| 291 |
+
If user_location_str is set, center on it and show a route to the first facility.
|
| 292 |
+
selected_facility_name: if set, the matching facility is shown with a red star icon.
|
| 293 |
+
force_update_id: optional unique value (e.g. timestamp) so Gradio re-renders the HTML each time.
|
| 294 |
+
Returns safe fallback HTML on any error.
|
| 295 |
"""
|
| 296 |
+
try:
|
| 297 |
+
center_lat, center_lon, zoom = 39.5, -98.5, 3
|
| 298 |
+
user_lat_lon = _geocode(user_location_str) if user_location_str else None
|
| 299 |
+
facility_coords = [(c[0], c[1], c[2]) for c in _get_facility_coords(facilities)]
|
| 300 |
+
facility_coords = [((c[0], c[1]), c[2]) for c in facility_coords]
|
| 301 |
+
|
| 302 |
+
if user_lat_lon:
|
| 303 |
+
center_lat, center_lon = user_lat_lon
|
| 304 |
+
zoom = 11
|
| 305 |
+
elif facility_coords:
|
| 306 |
+
lats = [c[0] for c, _ in facility_coords]
|
| 307 |
+
lons = [c[1] for c, _ in facility_coords]
|
| 308 |
+
center_lat = sum(lats) / len(lats)
|
| 309 |
+
center_lon = sum(lons) / len(lons)
|
| 310 |
+
zoom = 10
|
| 311 |
+
|
| 312 |
+
m = folium.Map(
|
| 313 |
+
location=[center_lat, center_lon],
|
| 314 |
+
zoom_start=zoom,
|
| 315 |
+
tiles="OpenStreetMap",
|
| 316 |
+
control_scale=True,
|
| 317 |
+
zoom_control=True,
|
| 318 |
+
)
|
| 319 |
+
m.options["scrollWheelZoom"] = True
|
| 320 |
+
m.options["touchZoom"] = True
|
| 321 |
+
m.options["dragging"] = True
|
| 322 |
+
|
| 323 |
+
if user_lat_lon:
|
| 324 |
+
folium.Marker(
|
| 325 |
+
user_lat_lon,
|
| 326 |
+
popup="You",
|
| 327 |
+
tooltip="Your location",
|
| 328 |
+
icon=folium.Icon(color="blue", icon="info-sign"),
|
| 329 |
+
).add_to(m)
|
| 330 |
+
if user_lat_lon and facility_coords:
|
| 331 |
+
dest_lat, dest_lon = facility_coords[0][0][0], facility_coords[0][0][1]
|
| 332 |
+
route = _get_route(user_lat_lon[0], user_lat_lon[1], dest_lat, dest_lon)
|
| 333 |
+
if route:
|
| 334 |
+
folium.PolyLine(route, color="teal", weight=4, opacity=0.8).add_to(m)
|
| 335 |
+
|
| 336 |
+
for (lat, lon), f in facility_coords:
|
| 337 |
+
is_selected = selected_facility_name and (f.get("facility_name") or f.get("name") or "") == selected_facility_name
|
| 338 |
+
folium.Marker(
|
| 339 |
+
[lat, lon],
|
| 340 |
+
popup=folium.Popup(_popup_html(f), max_width=280),
|
| 341 |
+
tooltip=f.get("facility_name") or f.get("name") or "Facility",
|
| 342 |
+
icon=folium.Icon(color="red" if is_selected else "green", icon="star" if is_selected else "plus-sign"),
|
| 343 |
+
).add_to(m)
|
| 344 |
+
|
| 345 |
+
# Center and zoom to show all proposed locations
|
| 346 |
+
if facility_coords:
|
| 347 |
+
lats = [c[0] for c, _ in facility_coords]
|
| 348 |
+
lons = [c[1] for c, _ in facility_coords]
|
| 349 |
+
sw = [min(lats), min(lons)]
|
| 350 |
+
ne = [max(lats), max(lons)]
|
| 351 |
+
# Avoid zero-size bounds (single marker): add small buffer
|
| 352 |
+
buf = 0.01
|
| 353 |
+
if ne[0] - sw[0] < buf:
|
| 354 |
+
sw[0] -= buf
|
| 355 |
+
ne[0] += buf
|
| 356 |
+
if ne[1] - sw[1] < buf:
|
| 357 |
+
sw[1] -= buf
|
| 358 |
+
ne[1] += buf
|
| 359 |
+
m.fit_bounds([sw, ne])
|
| 360 |
+
|
| 361 |
+
html = m._repr_html_()
|
| 362 |
+
wrapper = f'<div style="width:100%;height:{MAP_HEIGHT_PX}px;overflow:hidden;border-radius:12px;">{html}</div>'
|
| 363 |
+
# Force Gradio to re-render: append a unique comment so the value always changes
|
| 364 |
+
if force_update_id is not None:
|
| 365 |
+
wrapper += f"<!-- map-update:{force_update_id} -->"
|
| 366 |
+
return wrapper
|
| 367 |
+
except Exception as e:
|
| 368 |
+
return (
|
| 369 |
+
f'<div style="width:100%;height:{MAP_HEIGHT_PX}px;display:flex;align-items:center;justify-content:center;'
|
| 370 |
+
f'background:#f5f5f5;border-radius:12px;color:#666;font-family:sans-serif;">'
|
| 371 |
+
f'Map could not be loaded. ({str(e)[:80]})</div>'
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
DISCLAIMER = (
|
| 375 |
+
"**Disclaimer:** Information is from SAMHSA data. Always verify with the facility or "
|
| 376 |
+
"[findtreatment.gov](https://findtreatment.gov) before making decisions. This tool does not endorse any facility."
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
DESCRIPTION = (
|
| 380 |
+
"Find treatment facilities by chatting: say where you are (city or state), what type of care you need, "
|
| 381 |
+
"and payment (e.g. Medicaid, insurance). Results show on the map. Data from SAMHSA only."
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
EXAMPLES = [
|
| 385 |
+
"I'm looking for outpatient alcohol treatment in Boston with Medicaid.",
|
| 386 |
+
"Do you have options for veterans in Texas?",
|
| 387 |
+
"Hi, I need help finding a residential program in California that accepts Medicaid.",
|
| 388 |
+
]
|
| 389 |
+
|
| 390 |
+
CSS = """
|
| 391 |
+
.disclaimer { font-size: 0.85em; color: #555; padding: 0.5rem 0.75rem; background: #f8f9fa; border-radius: 8px; margin-bottom: 0.75rem; }
|
| 392 |
+
.map-pane { padding: 0.25rem 0 0 0; }
|
| 393 |
+
.map-pane .map-html { border-radius: 12px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
| 394 |
+
.map-pane iframe { border-radius: 12px; }
|
| 395 |
+
.try-label { font-size: 0.9em; margin-bottom: 0.25rem; }
|
| 396 |
+
"""
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _messages_to_tuples(history):
|
| 400 |
+
"""Convert Gradio 6 messages format to [(user, assistant), ...] for get_response."""
|
| 401 |
+
if not history:
|
| 402 |
+
return []
|
| 403 |
+
out = []
|
| 404 |
+
for item in history:
|
| 405 |
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
| 406 |
+
out.append([item[0], item[1]])
|
| 407 |
+
elif isinstance(item, dict):
|
| 408 |
+
role, content = item.get("role"), item.get("content", "")
|
| 409 |
+
if role == "user":
|
| 410 |
+
out.append([content, ""])
|
| 411 |
+
elif role == "assistant":
|
| 412 |
+
if out:
|
| 413 |
+
out[-1][1] = content
|
| 414 |
+
else:
|
| 415 |
+
out.append(["", content])
|
| 416 |
+
else:
|
| 417 |
+
out.append(["", str(item)])
|
| 418 |
+
return out
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _tuples_to_messages(history):
|
| 422 |
+
"""Convert [(user, assistant), ...] to Gradio 6 messages format."""
|
| 423 |
+
out = []
|
| 424 |
+
for user_msg, assistant_msg in history or []:
|
| 425 |
+
if user_msg:
|
| 426 |
+
out.append({"role": "user", "content": user_msg})
|
| 427 |
+
if assistant_msg:
|
| 428 |
+
out.append({"role": "assistant", "content": assistant_msg})
|
| 429 |
+
return out
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def create_demo():
|
| 433 |
chatbot = Chatbot()
|
| 434 |
+
|
| 435 |
+
with gr.Blocks(title="SAMHSA Treatment Locator") as demo:
|
| 436 |
+
gr.Markdown("# SAMHSA Treatment Locator")
|
| 437 |
+
gr.Markdown(DESCRIPTION)
|
| 438 |
+
gr.Markdown(f"<div class='disclaimer'>{DISCLAIMER}</div>", elem_classes=["disclaimer"])
|
| 439 |
+
|
| 440 |
+
state = gr.State(DEFAULT_STATE)
|
| 441 |
+
|
| 442 |
+
with gr.Row():
|
| 443 |
+
# Left: Folium (Leaflet) map — scroll over map to zoom, drag to pan
|
| 444 |
+
with gr.Column(scale=5, min_width=320, elem_classes=["map-pane"]):
|
| 445 |
+
gr.Markdown("**Map** — scroll over map to zoom, drag to pan. Search in chat to see facilities.")
|
| 446 |
+
map_html = gr.HTML(
|
| 447 |
+
value=_build_map_html([], None),
|
| 448 |
+
elem_classes=["map-html"],
|
| 449 |
+
)
|
| 450 |
+
# Right: chat
|
| 451 |
+
with gr.Column(scale=5, min_width=320):
|
| 452 |
+
gr.Markdown("**Chat** — tell me location, treatment type, and payment.")
|
| 453 |
+
chat = gr.Chatbot(
|
| 454 |
+
label="Conversation",
|
| 455 |
+
placeholder="E.g. I'm in Boston, need outpatient treatment with Medicaid.",
|
| 456 |
+
height=420,
|
| 457 |
+
show_label=False,
|
| 458 |
+
)
|
| 459 |
+
facility_dropdown = gr.Dropdown(
|
| 460 |
+
choices=[],
|
| 461 |
+
value=None,
|
| 462 |
+
label="Choose a treatment center (pin updates on map)",
|
| 463 |
+
allow_custom_value=False,
|
| 464 |
+
)
|
| 465 |
+
with gr.Row():
|
| 466 |
+
msg = gr.Textbox(
|
| 467 |
+
placeholder="Type a message…",
|
| 468 |
+
show_label=False,
|
| 469 |
+
container=False,
|
| 470 |
+
scale=8,
|
| 471 |
+
)
|
| 472 |
+
submit_btn = gr.Button("Send", variant="primary", scale=1)
|
| 473 |
+
gr.Markdown("**Try:**", elem_classes=["try-label"])
|
| 474 |
+
gr.Examples(
|
| 475 |
+
examples=EXAMPLES,
|
| 476 |
+
inputs=msg,
|
| 477 |
+
label=None,
|
| 478 |
+
examples_per_page=6,
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
def _facility_names(facilities):
|
| 482 |
+
return [f.get("facility_name") or f.get("name") or "Facility" for f in facilities]
|
| 483 |
+
|
| 484 |
+
def user_submit(message, history, state):
|
| 485 |
+
update_id = str(time.time())
|
| 486 |
+
if not message or not message.strip():
|
| 487 |
+
facilities = list(state.get("last_results") or [])
|
| 488 |
+
sel = state.get("selected_facility_name")
|
| 489 |
+
map_html_out = _build_map_html(facilities, None, update_id, sel)
|
| 490 |
+
return history, state, "", map_html_out, gr.update(choices=_facility_names(facilities))
|
| 491 |
+
try:
|
| 492 |
+
history_tuples = _messages_to_tuples(history)
|
| 493 |
+
reply, new_state = chatbot.get_response(message, history_tuples, state)
|
| 494 |
+
new_state = dict(new_state)
|
| 495 |
+
new_state["selected_facility_name"] = None # clear selection when new results
|
| 496 |
+
new_history_tuples = history_tuples + [[message, reply]]
|
| 497 |
+
new_history_messages = _tuples_to_messages(new_history_tuples)
|
| 498 |
+
facilities = list(new_state.get("last_results") or [])
|
| 499 |
+
map_html_out = _build_map_html(facilities, None, update_id, None)
|
| 500 |
+
return new_history_messages, new_state, "", map_html_out, gr.update(choices=_facility_names(facilities), value=None)
|
| 501 |
+
except Exception as e:
|
| 502 |
+
err_msg = str(e)[:200]
|
| 503 |
+
reply = f"Sorry, something went wrong: {err_msg}"
|
| 504 |
+
if "token" in err_msg.lower() or "auth" in err_msg.lower():
|
| 505 |
+
reply += " Check that HF_TOKEN is set in .env for the chat model."
|
| 506 |
+
history_tuples = _messages_to_tuples(history)
|
| 507 |
+
new_history_tuples = history_tuples + [[message, reply]]
|
| 508 |
+
new_history_messages = _tuples_to_messages(new_history_tuples)
|
| 509 |
+
facilities = list(state.get("last_results") or [])
|
| 510 |
+
sel = state.get("selected_facility_name")
|
| 511 |
+
map_html_out = _build_map_html(facilities, None, update_id, sel)
|
| 512 |
+
return new_history_messages, state, "", map_html_out, gr.update()
|
| 513 |
+
|
| 514 |
+
def on_facility_select(choice, state):
|
| 515 |
+
if not choice:
|
| 516 |
+
state = dict(state or {})
|
| 517 |
+
state["selected_facility_name"] = None
|
| 518 |
+
facilities = list(state.get("last_results") or [])
|
| 519 |
+
map_html_out = _build_map_html(facilities, None, str(time.time()), None)
|
| 520 |
+
return map_html_out, state
|
| 521 |
+
state = dict(state or {})
|
| 522 |
+
state["selected_facility_name"] = choice
|
| 523 |
+
facilities = list(state.get("last_results") or [])
|
| 524 |
+
map_html_out = _build_map_html(facilities, None, str(time.time()), choice)
|
| 525 |
+
return map_html_out, state
|
| 526 |
+
|
| 527 |
+
submit_btn.click(
|
| 528 |
+
user_submit,
|
| 529 |
+
inputs=[msg, chat, state],
|
| 530 |
+
outputs=[chat, state, msg, map_html, facility_dropdown],
|
| 531 |
+
)
|
| 532 |
+
msg.submit(
|
| 533 |
+
user_submit,
|
| 534 |
+
inputs=[msg, chat, state],
|
| 535 |
+
outputs=[chat, state, msg, map_html, facility_dropdown],
|
| 536 |
+
)
|
| 537 |
+
facility_dropdown.change(
|
| 538 |
+
on_facility_select,
|
| 539 |
+
inputs=[facility_dropdown, state],
|
| 540 |
+
outputs=[map_html, state],
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
return demo
|
| 544 |
|
| 545 |
+
|
| 546 |
if __name__ == "__main__":
|
| 547 |
+
demo = create_demo()
|
| 548 |
+
demo.launch(
|
| 549 |
+
theme=gr.themes.Soft(primary_hue="teal", secondary_hue="slate"),
|
| 550 |
+
css=CSS,
|
| 551 |
+
)
|
data/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Facility Data – Data Story (for memo)
|
| 2 |
+
|
| 3 |
+
## Source
|
| 4 |
+
|
| 5 |
+
- **Dataset:** N-SUMHSS (National Substance Use and Mental Health Services Survey) / National Directory of Drug and Alcohol Use Treatment Facilities. This is the same data that powers [FindTreatment.gov](https://findtreatment.gov).
|
| 6 |
+
- **Where:** SAMHSA CBHSQ Data – [N-SUMHSS data files](https://www.samhsa.gov/data/data-we-collect/n-sumhss-national-substance-use-and-mental-health-services-survey/datafiles) (SAS/CSV). National Directory also available as Excel/PDF from [National Directories](https://www.samhsa.gov/data/data-we-collect/n-sumhss-national-substance-use-and-mental-health-services-survey/national-directories).
|
| 7 |
+
- **Processing:** For development and demo, `facilities.csv` may be a small subset. **To use all data from FindTreatment.gov**, run: `pip install -r requirements.txt` then `python scripts/download_findtreatment_data.py`. That script downloads the official SAMHSA National Directory (same data as FindTreatment.gov) and builds `data/facilities.csv`. Alternatively, download the Excel/CSV from SAMHSA yourself and run `python scripts/ingest_facilities.py path/to/file.xlsx -o data/facilities.csv`. The ingest script maps source columns to the internal schema; see the script and N-SUMHSS codebook for variable mapping.
|
| 8 |
+
|
| 9 |
+
## Scope
|
| 10 |
+
|
| 11 |
+
- **Geography:** Sample includes facilities in MA (Boston area), TX, CA, IL. Full N-SUMHSS covers all states.
|
| 12 |
+
- **Attributes:** Facility name, address, city, state, zip, phone; treatment type (inpatient, outpatient, residential, telehealth); payment options (Medicaid/MassHealth, insurance, sliding scale, free, VA); MAT (medication-assisted treatment); services; **substances addressed** (e.g. alcohol, opioids); languages; populations (e.g. adults, adolescents, veterans, LGBTQ+, pregnant women); description. The chatbot helps users describe their situation and filters by these attributes.
|
| 13 |
+
|
| 14 |
+
## Limitations
|
| 15 |
+
|
| 16 |
+
- Data as of survey/publication date; facility details (phone, hours, availability) may have changed. Always confirm with the provider or [findtreatment.gov](https://findtreatment.gov) before making decisions.
|
| 17 |
+
|
docs/MEMO.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SAMHSA Treatment Locator Chatbot – Memo
|
| 2 |
+
|
| 3 |
+
## Design
|
| 4 |
+
|
| 5 |
+
The chatbot helps users find substance use and mental health treatment facilities in the U.S. through a conversational flow. It works as follows:
|
| 6 |
+
|
| 7 |
+
1. **Data** – Facility records are loaded from a CSV (see Data section) and stored in memory.
|
| 8 |
+
2. **Criteria extraction** – From each user message, the system extracts location (state or city), treatment type (inpatient, outpatient, residential, telehealth), payment (Medicaid, insurance, sliding scale, free, veterans), MAT (medication-assisted treatment), populations (e.g. veterans, adolescents, LGBTQ+, pregnant women), languages (e.g. Spanish), substances (e.g. alcohol, opioids), and therapies (e.g. CBT, 12-step).
|
| 9 |
+
3. **Search** – When at least a location is present, the backend runs a search over the facility data and returns only facilities that match all provided criteria.
|
| 10 |
+
4. **Response** – The model receives the **current conversation history** plus **only the real search results** (or a single facility record for follow-up questions). It never receives fabricated data, so it cannot invent facility names, addresses, or phone numbers. The system prompt enforces conversation phases: greet/clarify (ask for location, type, payment), first results (2–3 facilities with short descriptions), follow-up (answer from the facility record only), and closing (supportive sign-off).
|
| 11 |
+
|
| 12 |
+
This design avoids hallucination by construction: the model is restricted to describing facilities that appear in the provided data.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Data
|
| 17 |
+
|
| 18 |
+
- **Source:** N-SUMHSS (National Substance Use and Mental Health Services Survey) / National Directory of Drug and Alcohol Use Treatment Facilities. Data files are available from SAMHSA CBHSQ (e.g. [N-SUMHSS data files](https://www.samhsa.gov/data/data-we-collect/n-sumhss-national-substance-use-and-mental-health-services-survey/datafiles)); the National Directory is also available as Excel/PDF from SAMHSA’s National Directories page.
|
| 19 |
+
- **Processing:** The app uses a CSV of facilities with non-missing location (city, state). For development and demo, the repo includes a subset; in production this can be replaced with the full N-SUMHSS export (e.g. SAS converted to CSV) using the same column mapping in `src/facilities.py`.
|
| 20 |
+
- **Scope:** Data is aligned with FindTreatment.gov (source: N-SUMHSS/National Directory). Attributes include facility name, address, city, state, zip, phone; treatment type; payment options; MAT; services; substances addressed; languages; populations; description. The chatbot helps users describe their situation and find facilities that match their needs (treatment type, substances, payment, special populations, therapies, languages). The sample data covers multiple states (e.g. MA, TX, CA, IL); the full dataset covers all states.
|
| 21 |
+
- **Limitations:** Data are as of the survey/publication date. Facility details (phone, hours, availability) may have changed. Users should always confirm with the provider or [findtreatment.gov](https://findtreatment.gov) before making decisions.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Evaluation
|
| 26 |
+
|
| 27 |
+
**Method:** We defined 18 test scenarios covering a variety of locations (Boston, Texas, California, Illinois), treatment types (outpatient, residential, inpatient), payment (Medicaid, sliding scale, veterans), and special populations (veterans). For each scenario we:
|
| 28 |
+
|
| 29 |
+
1. Run the backend **search** with the scenario’s criteria and record which facilities are returned.
|
| 30 |
+
2. **Match check:** Verify that every returned facility satisfies the scenario’s criteria (e.g. accepts Medicaid, offers outpatient). We report how many runs had all suggested facilities matching (e.g. “18/18”).
|
| 31 |
+
3. **Hallucination check (optional):** When running the full chatbot with the API, we parse the bot’s reply for facility names and verify that each name appears in the dataset. Target: 0 invented facilities.
|
| 32 |
+
|
| 33 |
+
**Artifact:** The script `scripts/eval_chatbot.py` runs these scenarios and prints a table: scenario, facilities returned, count, all match? (Y/N), and (if run with `--with-chatbot`) hallucination? (Y/N). Example:
|
| 34 |
+
|
| 35 |
+
```
|
| 36 |
+
Scenario Count All match? Hallucination?
|
| 37 |
+
------------------------------------------------------------------------
|
| 38 |
+
Outpatient, Boston, Medicaid 3 Y N
|
| 39 |
+
Veterans, Texas 1 Y N
|
| 40 |
+
...
|
| 41 |
+
Summary: 18/18 runs had all suggested facilities matching criteria.
|
| 42 |
+
Hallucination: 18/18 runs had no invented facility names in the reply.
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
This table (or a summary) can be pasted into the memo or a report to make the “we do not provide inaccurate information” claim concrete.
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Limitations
|
| 50 |
+
|
| 51 |
+
1. **Data freshness** – Facility information is as of the source dataset date. Phone numbers, hours, and availability may have changed; users should confirm with the facility or findtreatment.gov.
|
| 52 |
+
2. **English-only** – The current interface and criteria extraction are in English. Expanding to other languages would require additional design and data (e.g. language attributes in the dataset).
|
| 53 |
+
3. **No medical advice** – The tool only helps users find facilities; it does not provide clinical or medical advice. The UI includes a disclaimer to that effect and directs users to verify information with the provider.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
*Optional figure:* A short dialogue snippet (e.g. user asks for outpatient in Boston with Medicaid; bot returns 2–3 named facilities with descriptions from the data) or a table of evaluation results can be included to illustrate design and evaluation.
|
requirements.txt
CHANGED
|
@@ -2,3 +2,11 @@
|
|
| 2 |
# Keep this light so the build succeeds.
|
| 3 |
huggingface_hub>=0.19.0
|
| 4 |
python-dotenv>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
# Keep this light so the build succeeds.
|
| 3 |
huggingface_hub>=0.19.0
|
| 4 |
python-dotenv>=1.0.0
|
| 5 |
+
pandas>=2.0.0
|
| 6 |
+
gradio>=4.0.0
|
| 7 |
+
# For scripts/ingest_facilities.py Excel support (optional)
|
| 8 |
+
openpyxl>=3.0.0
|
| 9 |
+
plotly>=5.0.0
|
| 10 |
+
folium>=0.15.0
|
| 11 |
+
geopy>=2.4.0
|
| 12 |
+
requests>=2.28.0
|
scripts/download_findtreatment_data.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download the full facility dataset that powers FindTreatment.gov.
|
| 3 |
+
|
| 4 |
+
SAMHSA publishes the National Directory of Drug and Alcohol Use Treatment
|
| 5 |
+
Facilities (same data source as FindTreatment.gov). This script downloads the
|
| 6 |
+
official Excel file and runs the ingest to produce data/facilities.csv so the
|
| 7 |
+
chatbot uses all treatment centers, not just the sample in the repo.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/download_findtreatment_data.py
|
| 11 |
+
|
| 12 |
+
Downloads to data/National_Directory_SU_2024.xlsx (or current year), then
|
| 13 |
+
runs the ingest to write data/facilities.csv. Requires: requests, pandas, openpyxl.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 20 |
+
DATA_DIR = REPO_ROOT / "data"
|
| 21 |
+
|
| 22 |
+
# Official SAMHSA 2024 National Directory (substance use treatment facilities).
|
| 23 |
+
# Same data that powers https://findtreatment.gov
|
| 24 |
+
NATIONAL_DIRECTORY_URL = (
|
| 25 |
+
"https://www.samhsa.gov/data/sites/default/files/reports/rpt53015/"
|
| 26 |
+
"National%20Directory%20SU%202024_Final.xlsx"
|
| 27 |
+
)
|
| 28 |
+
DOWNLOAD_FILENAME = "National_Directory_SU_2024.xlsx"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def download_file(url: str, dest: Path) -> None:
|
| 32 |
+
"""Download url to dest using urllib (no extra deps)."""
|
| 33 |
+
try:
|
| 34 |
+
from urllib.request import urlretrieve
|
| 35 |
+
urlretrieve(url, dest)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
# Fallback: try requests if available
|
| 38 |
+
try:
|
| 39 |
+
import requests
|
| 40 |
+
r = requests.get(url, timeout=60)
|
| 41 |
+
r.raise_for_status()
|
| 42 |
+
dest.write_bytes(r.content)
|
| 43 |
+
except ImportError:
|
| 44 |
+
raise RuntimeError(
|
| 45 |
+
f"Download failed: {e}. Install requests (pip install requests) and try again."
|
| 46 |
+
) from e
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main():
|
| 50 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
dest = DATA_DIR / DOWNLOAD_FILENAME
|
| 52 |
+
print(f"Downloading FindTreatment.gov dataset from SAMHSA...", file=sys.stderr)
|
| 53 |
+
print(f" URL: {NATIONAL_DIRECTORY_URL}", file=sys.stderr)
|
| 54 |
+
try:
|
| 55 |
+
download_file(NATIONAL_DIRECTORY_URL, dest)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"Error: {e}", file=sys.stderr)
|
| 58 |
+
sys.exit(1)
|
| 59 |
+
print(f"Saved to {dest}", file=sys.stderr)
|
| 60 |
+
# Run ingest to produce facilities.csv
|
| 61 |
+
print("Running ingest to build data/facilities.csv...", file=sys.stderr)
|
| 62 |
+
import subprocess
|
| 63 |
+
result = subprocess.run(
|
| 64 |
+
[sys.executable, str(REPO_ROOT / "scripts" / "ingest_facilities.py"), str(dest), "-o", str(DATA_DIR / "facilities.csv")],
|
| 65 |
+
cwd=str(REPO_ROOT),
|
| 66 |
+
)
|
| 67 |
+
if result.returncode != 0:
|
| 68 |
+
print(
|
| 69 |
+
"\nIngest failed (often due to missing openpyxl). Install dependencies:\n"
|
| 70 |
+
" pip install -r requirements.txt\n"
|
| 71 |
+
"Then run ingest manually:\n"
|
| 72 |
+
f" python scripts/ingest_facilities.py {dest} -o data/facilities.csv",
|
| 73 |
+
file=sys.stderr,
|
| 74 |
+
)
|
| 75 |
+
sys.exit(result.returncode)
|
| 76 |
+
print("Done. The chatbot now uses the full FindTreatment.gov dataset.", file=sys.stderr)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
scripts/eval_chatbot.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation script for SAMHSA Treatment Locator chatbot.
|
| 3 |
+
|
| 4 |
+
Runs 15–20 test scenarios. For each:
|
| 5 |
+
- Runs search(criteria) to get facilities returned.
|
| 6 |
+
- Hallucination check: if --with-chatbot, call the bot and verify every facility name
|
| 7 |
+
(and contact info) in the reply appears in the dataset. Target: 0 invented facilities.
|
| 8 |
+
- Match check: verify returned facilities match the scenario criteria (e.g. accepts Medicaid, offers outpatient).
|
| 9 |
+
|
| 10 |
+
Outputs a table: scenario, facilities returned, hallucination? (Y/N), all match? (Y/N).
|
| 11 |
+
Use the table or summary in the memo.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import re
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
# Project root
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 21 |
+
|
| 22 |
+
from src.facilities import load_facilities, search
|
| 23 |
+
|
| 24 |
+
# --- Scenarios: (description, criteria dict, optional user message for chatbot run) ---
|
| 25 |
+
SCENARIOS = [
|
| 26 |
+
("Outpatient, Boston, Medicaid", {"state": "ma", "location": "Boston", "treatment_type": "outpatient", "payment": "Medicaid"}, "I need outpatient treatment in Boston with Medicaid."),
|
| 27 |
+
("Outpatient, Boston, MassHealth", {"state": "ma", "location": "Boston", "payment": "Medicaid"}, "Looking for outpatient in Boston with MassHealth."),
|
| 28 |
+
("Outpatient, Boston, MAT", {"state": "ma", "location": "Boston", "treatment_type": "outpatient", "mat": True}, "Outpatient in Boston with medication-assisted treatment."),
|
| 29 |
+
("Residential, Massachusetts", {"state": "ma", "treatment_type": "residential"}, "Residential treatment in Massachusetts."),
|
| 30 |
+
("Veterans, Texas", {"state": "tx", "populations": "veterans", "payment": "veterans"}, "Do you have options for veterans in Texas?"),
|
| 31 |
+
("Veterans, San Antonio", {"state": "tx", "location": "San Antonio", "populations": "veterans"}, "Veterans programs in San Antonio."),
|
| 32 |
+
("Outpatient, Austin", {"state": "tx", "location": "Austin"}, "Outpatient substance use treatment in Austin."),
|
| 33 |
+
("California, Medicaid", {"state": "ca", "payment": "Medicaid"}, "California facilities that accept Medicaid."),
|
| 34 |
+
("California, residential", {"state": "ca", "treatment_type": "residential"}, "Residential treatment in California."),
|
| 35 |
+
("San Francisco, outpatient", {"state": "ca", "location": "San Francisco", "treatment_type": "outpatient"}, "Outpatient in San Francisco."),
|
| 36 |
+
("Los Angeles area", {"state": "ca", "location": "Los Angeles"}, "Treatment options in Los Angeles area."),
|
| 37 |
+
("Chicago, outpatient", {"state": "il", "location": "Chicago", "treatment_type": "outpatient"}, "Outpatient in Chicago."),
|
| 38 |
+
("Chicago, MAT", {"state": "il", "location": "Chicago", "mat": True}, "Chicago programs with MAT."),
|
| 39 |
+
("Illinois, Medicaid", {"state": "il", "payment": "Medicaid"}, "Illinois facilities accepting Medicaid."),
|
| 40 |
+
("Boston, sliding scale", {"state": "ma", "location": "Boston", "payment": "sliding scale"}, "Boston programs with sliding scale fees."),
|
| 41 |
+
("Outpatient, Boston, Spanish", {"state": "ma", "location": "Boston", "treatment_type": "outpatient", "languages": "Spanish"}, "Outpatient in Boston, Spanish-speaking."),
|
| 42 |
+
("Residential, Texas", {"state": "tx", "treatment_type": "residential"}, "Residential treatment in Texas."),
|
| 43 |
+
("MA, inpatient", {"state": "ma", "treatment_type": "inpatient"}, "Inpatient treatment in MA."),
|
| 44 |
+
("Boston, alcohol", {"state": "ma", "location": "Boston", "substances": "alcohol"}, "Boston facilities for alcohol treatment."),
|
| 45 |
+
("Chicago, opioids", {"state": "il", "location": "Chicago", "substances": "opioids"}, "Opioid treatment in Chicago."),
|
| 46 |
+
("Boston, CBT", {"state": "ma", "location": "Boston", "therapies": "CBT"}, "Boston programs that offer CBT."),
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
# All facility names and phones from dataset (for hallucination check)
|
| 50 |
+
def _all_facility_names_and_phones():
|
| 51 |
+
df = load_facilities()
|
| 52 |
+
names = set()
|
| 53 |
+
phones = set()
|
| 54 |
+
for _, row in df.iterrows():
|
| 55 |
+
n = row.get("facility_name")
|
| 56 |
+
if n and str(n).strip():
|
| 57 |
+
names.add(str(n).strip().lower())
|
| 58 |
+
p = row.get("phone")
|
| 59 |
+
if p and str(p).strip():
|
| 60 |
+
phones.add(str(p).strip())
|
| 61 |
+
return names, phones
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _facility_matches_criteria(fac: dict, criteria: dict) -> bool:
|
| 65 |
+
"""Check that a facility record matches the scenario criteria. Falls back to services when attribute column missing."""
|
| 66 |
+
def norm(s):
|
| 67 |
+
if s is None or (isinstance(s, float) and (s != s)): # NaN
|
| 68 |
+
return ""
|
| 69 |
+
return str(s).lower().strip()
|
| 70 |
+
|
| 71 |
+
def col_or_services(col: str) -> str:
|
| 72 |
+
v = fac.get(col, "")
|
| 73 |
+
if v and str(v).strip():
|
| 74 |
+
return norm(v)
|
| 75 |
+
return norm(fac.get("services", ""))
|
| 76 |
+
|
| 77 |
+
state = criteria.get("state")
|
| 78 |
+
if state and norm(fac.get("state")) != norm(state):
|
| 79 |
+
return False
|
| 80 |
+
tt = criteria.get("treatment_type")
|
| 81 |
+
if tt and norm(tt) not in col_or_services("treatment_type"):
|
| 82 |
+
return False
|
| 83 |
+
pay = criteria.get("payment")
|
| 84 |
+
if pay:
|
| 85 |
+
pay_norm = norm(pay)
|
| 86 |
+
pop_text = col_or_services("populations")
|
| 87 |
+
pay_text = col_or_services("payment_options")
|
| 88 |
+
if pay_norm in ("veterans", "va"):
|
| 89 |
+
if "veteran" not in pop_text and "veteran" not in pay_text:
|
| 90 |
+
return False
|
| 91 |
+
elif pay_norm not in pay_text:
|
| 92 |
+
return False
|
| 93 |
+
if criteria.get("mat") is True and norm(fac.get("mat")) != "yes":
|
| 94 |
+
return False
|
| 95 |
+
pop = criteria.get("populations")
|
| 96 |
+
if pop and norm(pop) not in col_or_services("populations"):
|
| 97 |
+
return False
|
| 98 |
+
lang = criteria.get("languages")
|
| 99 |
+
if lang and norm(lang) not in col_or_services("languages"):
|
| 100 |
+
return False
|
| 101 |
+
substances = criteria.get("substances")
|
| 102 |
+
if substances and norm(substances) not in col_or_services("substances_addressed"):
|
| 103 |
+
return False
|
| 104 |
+
therapies = criteria.get("therapies")
|
| 105 |
+
if therapies:
|
| 106 |
+
t = norm(therapies)
|
| 107 |
+
svc = norm(fac.get("services", ""))
|
| 108 |
+
if t == "cbt":
|
| 109 |
+
if "cbt" not in svc:
|
| 110 |
+
return False
|
| 111 |
+
elif "12" in t or "twelve" in t:
|
| 112 |
+
if "12-step" not in svc and "12 step" not in svc:
|
| 113 |
+
return False
|
| 114 |
+
elif t not in svc:
|
| 115 |
+
return False
|
| 116 |
+
return True
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _extract_facility_names_from_text(text: str) -> list[str]:
|
| 120 |
+
"""Heuristic: find likely facility names in bot reply (e.g. 'X —' or 'X —' or numbered list '1. X —')."""
|
| 121 |
+
if not text:
|
| 122 |
+
return []
|
| 123 |
+
# Split on common delimiters and look for title-case or known patterns
|
| 124 |
+
names = set()
|
| 125 |
+
# Pattern: "1. Facility Name —" or "Facility Name —" or "- Facility Name"
|
| 126 |
+
for part in re.split(r"\n|\.|;", text):
|
| 127 |
+
m = re.match(r"^(?:\d+\.?\s*)?([A-Za-z][^—\-:]*?)(?:\s*[—\-:]|$)", part.strip())
|
| 128 |
+
if m:
|
| 129 |
+
cand = m.group(1).strip()
|
| 130 |
+
if len(cand) > 5 and cand.lower() not in ("yes", "no", "that", "they", "there", "here", "would", "could", "please", "contact", "phone", "address"):
|
| 131 |
+
names.add(cand)
|
| 132 |
+
return list(names)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def run_search_eval():
|
| 136 |
+
"""Run search() for each scenario; compute facilities returned and all_match."""
|
| 137 |
+
df = load_facilities()
|
| 138 |
+
rows = []
|
| 139 |
+
for desc, criteria, _ in SCENARIOS:
|
| 140 |
+
results = search(criteria, df=df, limit=5)
|
| 141 |
+
names = [r.get("facility_name", "") for r in results if r.get("facility_name")]
|
| 142 |
+
all_match = all(_facility_matches_criteria(r, criteria) for r in results)
|
| 143 |
+
rows.append({
|
| 144 |
+
"scenario": desc,
|
| 145 |
+
"criteria": str(criteria),
|
| 146 |
+
"facilities_returned": "; ".join(names) if names else "(none)",
|
| 147 |
+
"count": len(results),
|
| 148 |
+
"all_match": "Y" if all_match else "N",
|
| 149 |
+
})
|
| 150 |
+
return rows
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def run_hallucination_check(rows, with_chatbot: bool):
|
| 154 |
+
"""If with_chatbot, call the bot for each scenario and set hallucination? (Y/N)."""
|
| 155 |
+
for r in rows:
|
| 156 |
+
r["hallucination"] = "(skip)"
|
| 157 |
+
if not with_chatbot:
|
| 158 |
+
return rows
|
| 159 |
+
from src.chat import Chatbot
|
| 160 |
+
names_ok, _ = _all_facility_names_and_phones()
|
| 161 |
+
chatbot = Chatbot()
|
| 162 |
+
for i, (desc, criteria, user_msg) in enumerate(SCENARIOS):
|
| 163 |
+
reply, _ = chatbot.get_response(user_msg, [], {"criteria": {}, "last_results": [], "last_facility_detail": None})
|
| 164 |
+
mentioned = _extract_facility_names_from_text(reply)
|
| 165 |
+
hallucinated = False
|
| 166 |
+
for n in mentioned:
|
| 167 |
+
n_lower = n.lower()
|
| 168 |
+
if n_lower in names_ok:
|
| 169 |
+
continue
|
| 170 |
+
if any(n_lower in db for db in names_ok) or any(db in n_lower for db in names_ok):
|
| 171 |
+
continue
|
| 172 |
+
hallucinated = True
|
| 173 |
+
break
|
| 174 |
+
rows[i]["hallucination"] = "Y" if hallucinated else "N"
|
| 175 |
+
return rows
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def main():
|
| 179 |
+
ap = argparse.ArgumentParser(description="Evaluate SAMHSA chatbot: scenarios, match, optional hallucination check.")
|
| 180 |
+
ap.add_argument("--with-chatbot", action="store_true", help="Run chatbot and check for hallucinated facility names (requires API).")
|
| 181 |
+
ap.add_argument("--format", choices=["table", "csv"], default="table", help="Output format.")
|
| 182 |
+
args = ap.parse_args()
|
| 183 |
+
|
| 184 |
+
rows = run_search_eval()
|
| 185 |
+
rows = run_hallucination_check(rows, args.with_chatbot)
|
| 186 |
+
|
| 187 |
+
if args.format == "csv":
|
| 188 |
+
import csv
|
| 189 |
+
w = csv.DictWriter(sys.stdout, fieldnames=["scenario", "facilities_returned", "count", "all_match", "hallucination"])
|
| 190 |
+
w.writeheader()
|
| 191 |
+
w.writerows(rows)
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
# Table
|
| 195 |
+
print(f"{'Scenario':<40} {'Count':<6} {'All match?':<10} {'Hallucination?':<14}")
|
| 196 |
+
print("-" * 72)
|
| 197 |
+
for r in rows:
|
| 198 |
+
print(f"{r['scenario']:<40} {r['count']:<6} {r['all_match']:<10} {r.get('hallucination', '(n/a)'):<14}")
|
| 199 |
+
match_ok = sum(1 for r in rows if r["all_match"] == "Y")
|
| 200 |
+
print(f"\nSummary: {match_ok}/{len(rows)} runs had all suggested facilities matching criteria.")
|
| 201 |
+
if args.with_chatbot:
|
| 202 |
+
hall_ok = sum(1 for r in rows if r.get("hallucination") == "N")
|
| 203 |
+
print(f"Hallucination: {hall_ok}/{len(rows)} runs had no invented facility names in the reply.")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
main()
|
scripts/ingest_facilities.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ingest facility data from N-SUMHSS / National Directory into app schema.
|
| 3 |
+
|
| 4 |
+
Reads a CSV or Excel file (e.g. downloaded from SAMHSA N-SUMHSS or National
|
| 5 |
+
Directory), maps columns to the internal schema using a configurable mapping,
|
| 6 |
+
and writes data/facilities.csv. If the source uses codes, extend SOURCE_TO_APP
|
| 7 |
+
or add a code-resolution step using the N-SUMHSS codebook.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/ingest_facilities.py [path_to_source.csv]
|
| 11 |
+
python scripts/ingest_facilities.py path/to/national_directory.xlsx
|
| 12 |
+
|
| 13 |
+
If no path is given, reads from stdin (CSV) or exits with usage.
|
| 14 |
+
Output: data/facilities.csv (same directory as this script: repo root/data/).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import sys
|
| 19 |
+
import warnings
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
# Repo root (parent of scripts/)
|
| 25 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 26 |
+
DATA_DIR = REPO_ROOT / "data"
|
| 27 |
+
OUTPUT_CSV = DATA_DIR / "facilities.csv"
|
| 28 |
+
|
| 29 |
+
# Internal schema: only columns we need (no duplicate/redundant attribute columns).
|
| 30 |
+
# Search matches against "services" for treatment type, payment, languages, populations, substances.
|
| 31 |
+
APP_COLUMNS = [
|
| 32 |
+
"facility_name",
|
| 33 |
+
"address",
|
| 34 |
+
"city",
|
| 35 |
+
"state",
|
| 36 |
+
"zip",
|
| 37 |
+
"phone",
|
| 38 |
+
"mat",
|
| 39 |
+
"services",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
# Map source column names (lowercase) -> app column name.
|
| 43 |
+
# N-SUMHSS / National Directory use different names; adjust per codebook.
|
| 44 |
+
# National Directory Excel may use "Facility/Program Name", "Street", "City", "State", etc.
|
| 45 |
+
# See data/README.md for the data story and mapping notes.
|
| 46 |
+
SOURCE_TO_APP = {
|
| 47 |
+
"facility_name": "facility_name",
|
| 48 |
+
"facility name": "facility_name",
|
| 49 |
+
"facility/program name": "facility_name",
|
| 50 |
+
"program name": "facility_name",
|
| 51 |
+
"name": "facility_name",
|
| 52 |
+
"name1": "facility_name",
|
| 53 |
+
"name2": "facility_name",
|
| 54 |
+
"provider name": "facility_name",
|
| 55 |
+
"organization": "facility_name",
|
| 56 |
+
"treatment facility name": "facility_name",
|
| 57 |
+
"location name": "facility_name",
|
| 58 |
+
"facility": "facility_name",
|
| 59 |
+
"address": "address",
|
| 60 |
+
"street": "address",
|
| 61 |
+
"street address": "address",
|
| 62 |
+
"address1": "address",
|
| 63 |
+
"address line 1": "address",
|
| 64 |
+
"street1": "address",
|
| 65 |
+
"street2": "address",
|
| 66 |
+
"physical address": "address",
|
| 67 |
+
"location address": "address",
|
| 68 |
+
"city": "city",
|
| 69 |
+
"state": "state",
|
| 70 |
+
"state abbreviation": "state",
|
| 71 |
+
"zip": "zip",
|
| 72 |
+
"zipcode": "zip",
|
| 73 |
+
"zip code": "zip",
|
| 74 |
+
"phone": "phone",
|
| 75 |
+
"telephone": "phone",
|
| 76 |
+
"phone number": "phone",
|
| 77 |
+
"treatment_type": "treatment_type",
|
| 78 |
+
"treatment type": "treatment_type",
|
| 79 |
+
"type of care": "treatment_type",
|
| 80 |
+
"care type": "treatment_type",
|
| 81 |
+
"service setting": "treatment_type",
|
| 82 |
+
"treatment setting": "treatment_type",
|
| 83 |
+
"level of care": "treatment_type",
|
| 84 |
+
"payment_options": "payment_options",
|
| 85 |
+
"payment": "payment_options",
|
| 86 |
+
"payment options": "payment_options",
|
| 87 |
+
"payment accepted": "payment_options",
|
| 88 |
+
"accepted payment": "payment_options",
|
| 89 |
+
"insurance accepted": "payment_options",
|
| 90 |
+
"sliding fee": "payment_options",
|
| 91 |
+
"fee scale": "payment_options",
|
| 92 |
+
"mat": "mat",
|
| 93 |
+
"medication_assisted": "mat",
|
| 94 |
+
"medication assisted": "mat",
|
| 95 |
+
"medication assisted treatment": "mat",
|
| 96 |
+
"buprenorphine": "mat",
|
| 97 |
+
"services": "services",
|
| 98 |
+
"services offered": "services",
|
| 99 |
+
"service codes": "services",
|
| 100 |
+
"types of care": "services",
|
| 101 |
+
"substances_addressed": "substances_addressed",
|
| 102 |
+
"substances": "substances_addressed",
|
| 103 |
+
"substances addressed": "substances_addressed",
|
| 104 |
+
"primary focus": "substances_addressed",
|
| 105 |
+
"substance focus": "substances_addressed",
|
| 106 |
+
"drugs treated": "substances_addressed",
|
| 107 |
+
"languages": "languages",
|
| 108 |
+
"language": "languages",
|
| 109 |
+
"languages spoken": "languages",
|
| 110 |
+
"non-english languages": "languages",
|
| 111 |
+
"language services": "languages",
|
| 112 |
+
"populations": "populations",
|
| 113 |
+
"population": "populations",
|
| 114 |
+
"population served": "populations",
|
| 115 |
+
"special populations": "populations",
|
| 116 |
+
"ages served": "populations",
|
| 117 |
+
"age group": "populations",
|
| 118 |
+
"description": "description",
|
| 119 |
+
"comments": "description",
|
| 120 |
+
"notes": "description",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _normalize_mat(val) -> str:
|
| 125 |
+
"""Map various MAT values to yes/no."""
|
| 126 |
+
if pd.isna(val):
|
| 127 |
+
return ""
|
| 128 |
+
s = str(val).lower().strip()
|
| 129 |
+
if s in ("yes", "1", "true", "y"):
|
| 130 |
+
return "yes"
|
| 131 |
+
if s in ("no", "0", "false", "n", ""):
|
| 132 |
+
return "no"
|
| 133 |
+
return "yes" if "yes" in s or "offer" in s else "no"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def load_code_key(path: str | Path) -> dict[str, str] | None:
|
| 137 |
+
"""Load the code reference sheet from a National Directory Excel and return code -> description dict.
|
| 138 |
+
SAMHSA 2024 uses sheet 'Service Code Reference' with service_code and service_name columns.
|
| 139 |
+
"""
|
| 140 |
+
path = Path(path)
|
| 141 |
+
if path.suffix.lower() not in (".xlsx", ".xls"):
|
| 142 |
+
return None
|
| 143 |
+
if not path.exists():
|
| 144 |
+
return None
|
| 145 |
+
with warnings.catch_warnings():
|
| 146 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 147 |
+
xl = pd.ExcelFile(path)
|
| 148 |
+
key_df = None
|
| 149 |
+
for name in xl.sheet_names:
|
| 150 |
+
nlower = name.lower()
|
| 151 |
+
if "service code reference" in nlower or "code reference" in nlower or ("key" in nlower and "code" in nlower):
|
| 152 |
+
with warnings.catch_warnings():
|
| 153 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 154 |
+
key_df = pd.read_excel(path, sheet_name=name)
|
| 155 |
+
break
|
| 156 |
+
if key_df is None:
|
| 157 |
+
for name in xl.sheet_names:
|
| 158 |
+
with warnings.catch_warnings():
|
| 159 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 160 |
+
sheet = pd.read_excel(path, sheet_name=name)
|
| 161 |
+
if 2 <= len(sheet) <= 600 and len(sheet.columns) >= 2:
|
| 162 |
+
cols_lower = [str(c).lower() for c in sheet.columns]
|
| 163 |
+
if "service_code" in cols_lower and "service_name" in cols_lower:
|
| 164 |
+
key_df = sheet
|
| 165 |
+
break
|
| 166 |
+
if key_df is None or len(key_df) == 0:
|
| 167 |
+
return None
|
| 168 |
+
key_df.columns = [str(c).strip() for c in key_df.columns]
|
| 169 |
+
cols_lower = [c.lower() for c in key_df.columns]
|
| 170 |
+
code_col = None
|
| 171 |
+
desc_col = None
|
| 172 |
+
if "service_code" in cols_lower:
|
| 173 |
+
code_col = key_df.columns[cols_lower.index("service_code")]
|
| 174 |
+
if "service_name" in cols_lower:
|
| 175 |
+
desc_col = key_df.columns[cols_lower.index("service_name")]
|
| 176 |
+
if not code_col or not desc_col:
|
| 177 |
+
code_col = key_df.columns[0]
|
| 178 |
+
desc_col = key_df.columns[1] if len(key_df.columns) > 1 else key_df.columns[0]
|
| 179 |
+
code_key = {}
|
| 180 |
+
for _, row in key_df.iterrows():
|
| 181 |
+
k = str(row.get(code_col, "")).strip()
|
| 182 |
+
v = str(row.get(desc_col, "")).strip()
|
| 183 |
+
if k and v and k != "nan" and v != "nan" and len(k) <= 20:
|
| 184 |
+
code_key[k] = v
|
| 185 |
+
return code_key if code_key else None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _decode_service_codes(series: pd.Series, code_key: dict[str, str]) -> pd.Series:
|
| 189 |
+
"""Replace code tokens with descriptions; join with ', '. Skip * and unknown tokens (only output decoded)."""
|
| 190 |
+
def decode_one(cell: str) -> str:
|
| 191 |
+
if pd.isna(cell) or not str(cell).strip():
|
| 192 |
+
return ""
|
| 193 |
+
parts = []
|
| 194 |
+
for token in str(cell).split():
|
| 195 |
+
token = token.strip()
|
| 196 |
+
if not token or token == "*":
|
| 197 |
+
continue
|
| 198 |
+
if token in code_key:
|
| 199 |
+
parts.append(code_key[token])
|
| 200 |
+
return ", ".join(parts) if parts else ""
|
| 201 |
+
return series.apply(decode_one)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def load_source(path: str | Path) -> pd.DataFrame:
|
| 205 |
+
"""Load CSV or Excel into a DataFrame with lowercase column names.
|
| 206 |
+
For Excel with multiple sheets (e.g. National Directory + Key), uses the
|
| 207 |
+
sheet that looks like facility data (has facility name or state, and many rows).
|
| 208 |
+
"""
|
| 209 |
+
path = Path(path)
|
| 210 |
+
if not path.exists():
|
| 211 |
+
raise FileNotFoundError(path)
|
| 212 |
+
suf = path.suffix.lower()
|
| 213 |
+
if suf == ".csv":
|
| 214 |
+
df = pd.read_csv(path)
|
| 215 |
+
elif suf in (".xlsx", ".xls"):
|
| 216 |
+
# Suppress openpyxl header/footer parse warnings (harmless; SAMHSA Excel often has them)
|
| 217 |
+
with warnings.catch_warnings():
|
| 218 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 219 |
+
xl = pd.ExcelFile(path)
|
| 220 |
+
if len(xl.sheet_names) == 1:
|
| 221 |
+
with warnings.catch_warnings():
|
| 222 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 223 |
+
df = pd.read_excel(path)
|
| 224 |
+
else:
|
| 225 |
+
# Pick the sheet that has facility data: prefer one with a facility-name-like column and many rows
|
| 226 |
+
def sheet_has_facility_name_col(sheet: pd.DataFrame) -> bool:
|
| 227 |
+
cols_lower = [str(c).lower().strip() for c in sheet.columns]
|
| 228 |
+
if "facility name" in cols_lower or "facility_name" in cols_lower:
|
| 229 |
+
return True
|
| 230 |
+
if "program name" in cols_lower or "facility/program name" in cols_lower:
|
| 231 |
+
return True
|
| 232 |
+
if any(("facility" in c or "program" in c) and "name" in c for c in cols_lower):
|
| 233 |
+
return True
|
| 234 |
+
if "organization" in cols_lower or "provider name" in cols_lower:
|
| 235 |
+
return True
|
| 236 |
+
return False
|
| 237 |
+
|
| 238 |
+
best = None
|
| 239 |
+
best_score = -1
|
| 240 |
+
for name in xl.sheet_names:
|
| 241 |
+
with warnings.catch_warnings():
|
| 242 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 243 |
+
sheet = pd.read_excel(path, sheet_name=name)
|
| 244 |
+
if len(sheet) < 10:
|
| 245 |
+
continue
|
| 246 |
+
cols_lower = [str(c).lower().strip() for c in sheet.columns]
|
| 247 |
+
has_state_city = "state" in cols_lower and "city" in cols_lower
|
| 248 |
+
has_name_col = sheet_has_facility_name_col(sheet)
|
| 249 |
+
# Strongly prefer sheet that has a facility name column; then state/city; then row count
|
| 250 |
+
score = (1000 if has_name_col else 0) + (10 if has_state_city else 0) + min(len(sheet), 5000)
|
| 251 |
+
if score > best_score:
|
| 252 |
+
best_score = score
|
| 253 |
+
best = sheet
|
| 254 |
+
if best is not None:
|
| 255 |
+
df = best
|
| 256 |
+
else:
|
| 257 |
+
with warnings.catch_warnings():
|
| 258 |
+
warnings.filterwarnings("ignore", message=".*Cannot parse header or footer.*")
|
| 259 |
+
df = pd.read_excel(path, sheet_name=0)
|
| 260 |
+
else:
|
| 261 |
+
raise ValueError(f"Unsupported format: {suf}. Use .csv or .xlsx")
|
| 262 |
+
df.columns = [str(c).lower().strip() for c in df.columns]
|
| 263 |
+
return df
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _guess_facility_name_column(df: pd.DataFrame, col_map: dict) -> str | None:
|
| 267 |
+
"""If no facility_name mapping, find a column that likely holds facility/program name."""
|
| 268 |
+
if "facility_name" in col_map:
|
| 269 |
+
return None
|
| 270 |
+
for src_col in df.columns:
|
| 271 |
+
c = str(src_col).lower().strip()
|
| 272 |
+
if "program" in c and "name" in c:
|
| 273 |
+
return src_col
|
| 274 |
+
if "facility" in c and "name" in c:
|
| 275 |
+
return src_col
|
| 276 |
+
if c in ("organization", "provider name", "location name"):
|
| 277 |
+
return src_col
|
| 278 |
+
# First column is often the name in directory layouts
|
| 279 |
+
if list(df.columns)[0] == src_col and ("name" in c or "facility" in c or "program" in c):
|
| 280 |
+
return src_col
|
| 281 |
+
return None
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _guess_address_column(df: pd.DataFrame, col_map: dict) -> str | None:
|
| 285 |
+
"""If no address mapping, find a column that likely holds street address."""
|
| 286 |
+
if "address" in col_map:
|
| 287 |
+
return None
|
| 288 |
+
for src_col in df.columns:
|
| 289 |
+
c = str(src_col).lower().strip()
|
| 290 |
+
if "street" in c or ("address" in c and "line" in c):
|
| 291 |
+
return src_col
|
| 292 |
+
if c in ("physical address", "location address"):
|
| 293 |
+
return src_col
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# Keywords to try when guessing unmapped columns (app_col -> list of substrings; any match in column name).
|
| 298 |
+
_GUESS_COLUMN_KEYWORDS = {
|
| 299 |
+
"treatment_type": ["treatment type", "type of care", "care type", "service setting", "level of care", "setting"],
|
| 300 |
+
"payment_options": ["payment", "insurance", "fee", "sliding", "medicaid", "accepted payment"],
|
| 301 |
+
"services": ["services", "service codes", "types of care", "offered", "treatment modalities"],
|
| 302 |
+
"substances_addressed": ["substance", "primary focus", "drug", "alcohol", "opioid"],
|
| 303 |
+
"languages": ["language", "non-english", "spanish", "bilingual"],
|
| 304 |
+
"populations": ["population", "age", "special population", "veteran", "gender", "served"],
|
| 305 |
+
"description": ["description", "comments", "notes", "remarks"],
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _guess_column_by_keywords(df: pd.DataFrame, col_map: dict, app_col: str) -> str | None:
|
| 310 |
+
"""If app_col not yet mapped, find a source column whose name contains any of the keywords."""
|
| 311 |
+
if app_col in col_map:
|
| 312 |
+
return None
|
| 313 |
+
keywords = _GUESS_COLUMN_KEYWORDS.get(app_col, [])
|
| 314 |
+
for src_col in df.columns:
|
| 315 |
+
c = str(src_col).lower().strip()
|
| 316 |
+
for kw in keywords:
|
| 317 |
+
if kw in c:
|
| 318 |
+
return src_col
|
| 319 |
+
return None
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def map_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 323 |
+
"""Map source columns to app schema; add missing app columns as empty."""
|
| 324 |
+
out = {}
|
| 325 |
+
for app_col in APP_COLUMNS:
|
| 326 |
+
out[app_col] = []
|
| 327 |
+
# Find which source column maps to each app column
|
| 328 |
+
col_map = {}
|
| 329 |
+
for src_col in df.columns:
|
| 330 |
+
src_lower = str(src_col).lower().strip()
|
| 331 |
+
if src_lower in SOURCE_TO_APP:
|
| 332 |
+
app_col = SOURCE_TO_APP[src_lower]
|
| 333 |
+
if app_col not in col_map:
|
| 334 |
+
col_map[app_col] = src_col
|
| 335 |
+
# Fallbacks for National Directory Excel when headers differ
|
| 336 |
+
guess_name = _guess_facility_name_column(df, col_map)
|
| 337 |
+
if guess_name and "facility_name" not in col_map:
|
| 338 |
+
col_map["facility_name"] = guess_name
|
| 339 |
+
guess_addr = _guess_address_column(df, col_map)
|
| 340 |
+
if guess_addr and "address" not in col_map:
|
| 341 |
+
col_map["address"] = guess_addr
|
| 342 |
+
for app_col in ("treatment_type", "payment_options", "services", "substances_addressed", "languages", "populations", "description"):
|
| 343 |
+
guess = _guess_column_by_keywords(df, col_map, app_col)
|
| 344 |
+
if guess and app_col not in col_map:
|
| 345 |
+
col_map[app_col] = guess
|
| 346 |
+
for app_col in APP_COLUMNS:
|
| 347 |
+
if app_col in col_map:
|
| 348 |
+
out[app_col] = df[col_map[app_col]].astype(str).replace("nan", "").tolist()
|
| 349 |
+
else:
|
| 350 |
+
out[app_col] = [""] * len(df)
|
| 351 |
+
result = pd.DataFrame(out)
|
| 352 |
+
|
| 353 |
+
# National Directory format: merge name1+name2 -> facility_name, street1+street2 -> address
|
| 354 |
+
cols_lower = [str(c).lower().strip() for c in df.columns]
|
| 355 |
+
if "name1" in cols_lower and "name2" in cols_lower:
|
| 356 |
+
n1 = df["name1"].astype(str).replace("nan", "").str.strip()
|
| 357 |
+
n2 = df["name2"].astype(str).replace("nan", "").str.strip()
|
| 358 |
+
merged = (n1 + " " + n2).str.strip()
|
| 359 |
+
result["facility_name"] = merged.where(merged != "", result["facility_name"])
|
| 360 |
+
if "street1" in cols_lower and "street2" in cols_lower:
|
| 361 |
+
s1 = df["street1"].astype(str).replace("nan", "").str.strip()
|
| 362 |
+
s2 = df["street2"].astype(str).replace("nan", "").str.strip()
|
| 363 |
+
merged = (s1 + " " + s2).str.strip()
|
| 364 |
+
result["address"] = merged.where(merged != "", result["address"])
|
| 365 |
+
# service_code_info is decoded in main() using the Key sheet when available (see load_code_key).
|
| 366 |
+
|
| 367 |
+
# Normalize MAT to yes/no
|
| 368 |
+
if "mat" in result.columns:
|
| 369 |
+
result["mat"] = result["mat"].apply(_normalize_mat)
|
| 370 |
+
return result
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def drop_missing_location(df: pd.DataFrame) -> pd.DataFrame:
|
| 374 |
+
"""Keep only rows with non-empty city and state."""
|
| 375 |
+
if "city" not in df.columns or "state" not in df.columns:
|
| 376 |
+
return df
|
| 377 |
+
return df[
|
| 378 |
+
df["city"].notna() & (df["city"].astype(str).str.strip() != "")
|
| 379 |
+
& df["state"].notna() & (df["state"].astype(str).str.strip() != "")
|
| 380 |
+
].copy()
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def main():
|
| 384 |
+
ap = argparse.ArgumentParser(description="Ingest N-SUMHSS/National Directory data into facilities.csv")
|
| 385 |
+
ap.add_argument("source", nargs="?", help="Path to source CSV or Excel file. If omitted, print usage and exit.")
|
| 386 |
+
ap.add_argument("-o", "--output", default=str(OUTPUT_CSV), help="Output CSV path")
|
| 387 |
+
args = ap.parse_args()
|
| 388 |
+
if not args.source:
|
| 389 |
+
ap.print_help()
|
| 390 |
+
sys.exit(0)
|
| 391 |
+
path = Path(args.source)
|
| 392 |
+
raw_df = load_source(path)
|
| 393 |
+
df = map_columns(raw_df)
|
| 394 |
+
if df["facility_name"].str.strip().eq("").all():
|
| 395 |
+
print(
|
| 396 |
+
"Warning: no facility names were mapped. Source columns were:\n "
|
| 397 |
+
+ ", ".join(repr(c) for c in raw_df.columns),
|
| 398 |
+
file=sys.stderr,
|
| 399 |
+
)
|
| 400 |
+
# Decode service_code_info using the Key sheet; store only in services (search uses it for all filters).
|
| 401 |
+
if "service_code_info" in raw_df.columns and path.suffix.lower() in (".xlsx", ".xls"):
|
| 402 |
+
code_key = load_code_key(path)
|
| 403 |
+
if code_key:
|
| 404 |
+
decoded = _decode_service_codes(raw_df["service_code_info"], code_key)
|
| 405 |
+
df["services"] = decoded
|
| 406 |
+
# Report if services is still empty (couldn't decode)
|
| 407 |
+
empty_attrs = [c for c in ("services",) if c in df.columns and (df[c].astype(str).str.strip() == "").all()]
|
| 408 |
+
if empty_attrs and "service_code_info" in raw_df.columns:
|
| 409 |
+
print(
|
| 410 |
+
"Note: " + ", ".join(empty_attrs) + " had no data (source has coded service_code_info; "
|
| 411 |
+
"Key sheet not found or could not be parsed for decoding).",
|
| 412 |
+
file=sys.stderr,
|
| 413 |
+
)
|
| 414 |
+
elif empty_attrs:
|
| 415 |
+
print(
|
| 416 |
+
"Note: these attributes had no data after mapping: " + ", ".join(empty_attrs) + ".",
|
| 417 |
+
file=sys.stderr,
|
| 418 |
+
)
|
| 419 |
+
df = drop_missing_location(df)
|
| 420 |
+
# Deduplicate by facility_name + address + city + state (keep first occurrence)
|
| 421 |
+
key_cols = ["facility_name", "address", "city", "state"]
|
| 422 |
+
if all(c in df.columns for c in key_cols):
|
| 423 |
+
before = len(df)
|
| 424 |
+
df = df.drop_duplicates(subset=key_cols, keep="first").reset_index(drop=True)
|
| 425 |
+
if len(df) < before:
|
| 426 |
+
print(f"Dropped {before - len(df)} duplicate rows (same name+address+city+state).", file=sys.stderr)
|
| 427 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 428 |
+
df.to_csv(args.output, index=False)
|
| 429 |
+
print(f"Wrote {len(df)} rows to {args.output}", file=sys.stderr)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
if __name__ == "__main__":
|
| 433 |
+
main()
|
src/chat.py
CHANGED
|
@@ -1,69 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from huggingface_hub import InferenceClient
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
class Chatbot:
|
| 5 |
"""
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Example usage:
|
| 9 |
-
chatbot = Chatbot()
|
| 10 |
-
response = chatbot.get_response("What options are available for me?")
|
| 11 |
"""
|
| 12 |
|
| 13 |
def __init__(self):
|
| 14 |
-
|
| 15 |
-
Initialize the chatbot with a HF model ID
|
| 16 |
-
"""
|
| 17 |
-
model_id = MY_MODEL if MY_MODEL else BASE_MODEL # define MY_MODEL in config.py if you create a new model in the HuggingFace Hub
|
| 18 |
self.client = InferenceClient(model=model_id, token=HF_TOKEN)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
1. Add any necessary system context or instructions
|
| 26 |
-
2. Format the user's input appropriately
|
| 27 |
-
3. Add any special tokens or formatting the model expects
|
| 28 |
-
|
| 29 |
-
Args:
|
| 30 |
-
user_input (str): The user's question
|
| 31 |
-
|
| 32 |
-
Returns:
|
| 33 |
-
str: A formatted prompt ready for the model
|
| 34 |
-
|
| 35 |
-
Example prompt format:
|
| 36 |
-
"You are a helpful assistant that specializes in...
|
| 37 |
-
User: {user_input}
|
| 38 |
-
Assistant:"
|
| 39 |
-
"""
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
"""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
This method should:
|
| 48 |
-
1. Use format_prompt() to prepare the input
|
| 49 |
-
2. Generate a response using the model
|
| 50 |
-
3. Clean up and return the response
|
| 51 |
-
|
| 52 |
-
Args:
|
| 53 |
-
user_input (str): The user's question
|
| 54 |
-
|
| 55 |
-
Returns:
|
| 56 |
-
str: The chatbot's response
|
| 57 |
-
|
| 58 |
-
Implementation tips:
|
| 59 |
-
- Use self.format_prompt() to format the user's input
|
| 60 |
-
- Use self.client to generate responses
|
| 61 |
"""
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
response = self.client.chat.completions.create(
|
| 64 |
model=self.client.model,
|
| 65 |
-
messages=
|
| 66 |
-
max_tokens=
|
| 67 |
-
temperature=0.
|
| 68 |
)
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stateful SAMHSA Treatment Locator chatbot.
|
| 3 |
+
|
| 4 |
+
Business logic: criteria extraction, search, response generation. No hallucination:
|
| 5 |
+
only real facility data is passed to the model. Conversation design matches
|
| 6 |
+
samhsa_chatbot_conversation_example.txt (greet/clarify → first results → follow-up → closing).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
from huggingface_hub import InferenceClient
|
| 13 |
+
|
| 14 |
+
from config import BASE_MODEL, HF_TOKEN, MY_MODEL
|
| 15 |
+
from src.facilities import get_facility_by_name, load_facilities, search
|
| 16 |
+
|
| 17 |
+
# --- Conversation state (criteria + last results for context) ---
|
| 18 |
+
DEFAULT_STATE = {
|
| 19 |
+
"criteria": {},
|
| 20 |
+
"last_results": [],
|
| 21 |
+
"last_facility_detail": None,
|
| 22 |
+
"selected_facility_name": None,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
SYSTEM_PROMPT = """You are a supportive, non-judgmental assistant that helps people find substance use and mental health treatment facilities in the United States. You use only the facility information provided to you in this conversation—never invent facility names, addresses, phone numbers, or details. Your role is to help users describe their situation and find facilities that match their needs.
|
| 26 |
+
|
| 27 |
+
Conversation flow:
|
| 28 |
+
1. Greet / clarify: If the user has not yet given a location (state or city), ask for: (a) state or city, (b) treatment type (inpatient, outpatient, residential, telehealth), (c) payment (insurance, Medicaid/MassHealth, sliding scale, free), and as appropriate: substances they're concerned about (e.g. alcohol, opioids), special populations (veterans, LGBTQ+, adolescents, pregnant women), therapies (e.g. MAT, CBT, 12-step), and languages spoken. Do not search until you have at least a location.
|
| 29 |
+
2. First results: When you have at least location (and ideally type and payment), present 2–3 facilities by name with 1–2 sentence descriptions using ONLY the data in the "Current facility data" section below. Mention relevant attributes (payment, languages, populations, substances, therapies) when they match what the user asked for. Offer to give more details or other options.
|
| 30 |
+
3. Follow-up: If the user asks about a specific facility (e.g. "Do they offer MAT?" or "Tell me about Boston Medical Center"), answer ONLY from the facility record provided. Offer next steps (e.g. how to contact).
|
| 31 |
+
4. Closing: If the user thanks you or says they're done, give a brief supportive close and invite them to return.
|
| 32 |
+
|
| 33 |
+
Rules:
|
| 34 |
+
- Never make up facility names, addresses, phones, or services. If the data does not say something, do not say it.
|
| 35 |
+
- The "Services" field for each facility contains the full list of what they offer (treatment types, payment options, languages, populations, therapies, etc.). Use this field when describing what a facility offers or when answering follow-up questions (e.g. "Do they offer outpatient?", "Do they take Medicaid?").
|
| 36 |
+
- Keep responses concise and actionable.
|
| 37 |
+
- Be supportive and clear. Do not give medical advice.
|
| 38 |
+
- If no location has been provided, ask for location before suggesting any facilities.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _extract_criteria(text: str) -> dict[str, Any]:
|
| 43 |
+
"""Extract location, treatment_type, payment, mat, populations, languages, substances, therapies from user message."""
|
| 44 |
+
text_lower = (text or "").lower().strip()
|
| 45 |
+
criteria = {}
|
| 46 |
+
|
| 47 |
+
# State / city patterns
|
| 48 |
+
state_abbr = re.findall(r"\b(ma|mass|massachusetts|tx|texas|ca|california|il|illinois)\b", text_lower)
|
| 49 |
+
if state_abbr:
|
| 50 |
+
m = {"ma": "ma", "mass": "ma", "massachusetts": "ma", "tx": "tx", "texas": "tx", "ca": "ca", "california": "ca", "il": "il", "illinois": "il"}
|
| 51 |
+
criteria["state"] = m.get(state_abbr[0], state_abbr[0])
|
| 52 |
+
if "boston" in text_lower:
|
| 53 |
+
criteria["location"] = "Boston"
|
| 54 |
+
criteria["state"] = "ma"
|
| 55 |
+
if "austin" in text_lower or "san antonio" in text_lower:
|
| 56 |
+
criteria["state"] = "tx"
|
| 57 |
+
if "chicago" in text_lower:
|
| 58 |
+
criteria["state"] = "il"
|
| 59 |
+
if "san francisco" in text_lower or "los angeles" in text_lower or "california" in text_lower:
|
| 60 |
+
criteria["state"] = "ca"
|
| 61 |
+
if not criteria.get("state") and not criteria.get("location"):
|
| 62 |
+
# Generic "location" for short state abbrev
|
| 63 |
+
two_letter = re.search(r"\b([a-z]{2})\b", text_lower)
|
| 64 |
+
if two_letter and two_letter.group(1) in ("ma", "tx", "ca", "il"):
|
| 65 |
+
criteria["state"] = two_letter.group(1)
|
| 66 |
+
|
| 67 |
+
# Treatment type
|
| 68 |
+
if any(w in text_lower for w in ["inpatient", "residential"]):
|
| 69 |
+
criteria["treatment_type"] = "inpatient" if "inpatient" in text_lower else "residential"
|
| 70 |
+
elif "outpatient" in text_lower:
|
| 71 |
+
criteria["treatment_type"] = "outpatient"
|
| 72 |
+
elif "telehealth" in text_lower:
|
| 73 |
+
criteria["treatment_type"] = "telehealth"
|
| 74 |
+
|
| 75 |
+
# Payment
|
| 76 |
+
if "medicaid" in text_lower or "masshealth" in text_lower:
|
| 77 |
+
criteria["payment"] = "Medicaid"
|
| 78 |
+
if "insurance" in text_lower and "payment" not in criteria:
|
| 79 |
+
criteria["payment"] = "insurance"
|
| 80 |
+
if "sliding scale" in text_lower:
|
| 81 |
+
criteria["payment"] = "sliding scale"
|
| 82 |
+
if "free" in text_lower and "payment" not in criteria:
|
| 83 |
+
criteria["payment"] = "free"
|
| 84 |
+
if "veteran" in text_lower or "va " in text_lower:
|
| 85 |
+
criteria["payment"] = "veterans"
|
| 86 |
+
criteria["populations"] = "veterans"
|
| 87 |
+
|
| 88 |
+
# MAT
|
| 89 |
+
if "mat" in text_lower or "medication-assisted" in text_lower or "medication assisted" in text_lower:
|
| 90 |
+
criteria["mat"] = True
|
| 91 |
+
|
| 92 |
+
# Populations: veterans, adolescents, LGBTQ+, pregnant women
|
| 93 |
+
if "veteran" in text_lower and "populations" not in criteria:
|
| 94 |
+
criteria["populations"] = "veterans"
|
| 95 |
+
if "adolescent" in text_lower or "youth" in text_lower:
|
| 96 |
+
criteria["populations"] = "adolescents"
|
| 97 |
+
if "lgbtq" in text_lower or "lgbt" in text_lower or "queer" in text_lower:
|
| 98 |
+
criteria["populations"] = "LGBTQ+"
|
| 99 |
+
if "pregnant" in text_lower or "pregnancy" in text_lower:
|
| 100 |
+
criteria["populations"] = "pregnant women"
|
| 101 |
+
|
| 102 |
+
# Languages
|
| 103 |
+
if "spanish" in text_lower or "spanish-speaking" in text_lower or "spanish speaking" in text_lower:
|
| 104 |
+
criteria["languages"] = "Spanish"
|
| 105 |
+
if "vietnamese" in text_lower:
|
| 106 |
+
criteria["languages"] = "Vietnamese"
|
| 107 |
+
if "mandarin" in text_lower or "chinese" in text_lower:
|
| 108 |
+
criteria["languages"] = "Mandarin"
|
| 109 |
+
if "bilingual" in text_lower and "languages" not in criteria:
|
| 110 |
+
criteria["languages"] = "Spanish" # common with "bilingual" in this context
|
| 111 |
+
|
| 112 |
+
# Substances
|
| 113 |
+
if "alcohol" in text_lower:
|
| 114 |
+
criteria["substances"] = "alcohol"
|
| 115 |
+
if "opioid" in text_lower or "opioids" in text_lower:
|
| 116 |
+
criteria["substances"] = "opioids"
|
| 117 |
+
if "substance use" in text_lower or "substance abuse" in text_lower and "substances" not in criteria:
|
| 118 |
+
criteria["substances"] = "substance use"
|
| 119 |
+
|
| 120 |
+
# Therapies: CBT, 12-step (MAT handled above)
|
| 121 |
+
if "cbt" in text_lower or "cognitive behavioral" in text_lower:
|
| 122 |
+
criteria["therapies"] = "CBT"
|
| 123 |
+
if "12-step" in text_lower or "12 step" in text_lower or "twelve step" in text_lower:
|
| 124 |
+
criteria["therapies"] = "12-step"
|
| 125 |
+
|
| 126 |
+
return criteria
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _merge_criteria(existing: dict, new: dict) -> dict:
|
| 130 |
+
"""Merge new criteria into existing; new values override."""
|
| 131 |
+
out = dict(existing)
|
| 132 |
+
for k, v in new.items():
|
| 133 |
+
if v is not None and v != "":
|
| 134 |
+
out[k] = v
|
| 135 |
+
return out
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _format_facilities_for_prompt(facilities: list[dict]) -> str:
|
| 139 |
+
"""Format facility list for inclusion in system context (model must only use this)."""
|
| 140 |
+
if not facilities:
|
| 141 |
+
return "(No facilities in context. Do not name or describe any facility not listed here.)"
|
| 142 |
+
lines = []
|
| 143 |
+
for i, f in enumerate(facilities, 1):
|
| 144 |
+
name = f.get("facility_name", "Unknown")
|
| 145 |
+
desc = f.get("description", "") or f.get("services", "")
|
| 146 |
+
addr = f.get("address", "")
|
| 147 |
+
city = f.get("city", "")
|
| 148 |
+
state = f.get("state", "")
|
| 149 |
+
phone = f.get("phone", "")
|
| 150 |
+
mat = f.get("mat", "")
|
| 151 |
+
services = f.get("services", "")
|
| 152 |
+
parts = [f"{i}. {name} — {desc} Address: {addr}, {city}, {state}. Phone: {phone}. MAT: {mat}. Services: {services}."]
|
| 153 |
+
for key, label in (("payment_options", "Payment"), ("substances_addressed", "Substances"), ("languages", "Languages"), ("populations", "Populations")):
|
| 154 |
+
val = f.get(key, "")
|
| 155 |
+
if val and str(val).strip():
|
| 156 |
+
parts.append(f" {label}: {val}.")
|
| 157 |
+
lines.append("".join(parts))
|
| 158 |
+
return "\n".join(lines)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _detect_facility_mention(text: str, last_results: list[dict]) -> str | None:
|
| 162 |
+
"""If user is asking about a specific facility, return a name fragment to look up."""
|
| 163 |
+
if not last_results or not text or not text.strip():
|
| 164 |
+
return None
|
| 165 |
+
text_lower = text.lower()
|
| 166 |
+
for f in last_results:
|
| 167 |
+
name = (f.get("facility_name") or "").lower()
|
| 168 |
+
if name and (name in text_lower or any(word in text_lower for word in name.split() if len(word) > 3)):
|
| 169 |
+
return f.get("facility_name")
|
| 170 |
+
# Common patterns: "the one at X", "Boston Medical Center", "AdCare"
|
| 171 |
+
if "boston medical" in text_lower or "bmc" in text_lower or "cope" in text_lower:
|
| 172 |
+
return "Boston Medical Center"
|
| 173 |
+
if "adcare" in text_lower:
|
| 174 |
+
return "AdCare"
|
| 175 |
+
if "bay cove" in text_lower:
|
| 176 |
+
return "Bay Cove"
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
|
| 180 |
class Chatbot:
|
| 181 |
"""
|
| 182 |
+
Stateful chatbot: criteria extraction, search when location present, only real data to model.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
"""
|
| 184 |
|
| 185 |
def __init__(self):
|
| 186 |
+
model_id = MY_MODEL if MY_MODEL else BASE_MODEL
|
|
|
|
|
|
|
|
|
|
| 187 |
self.client = InferenceClient(model=model_id, token=HF_TOKEN)
|
| 188 |
+
self._df = None # cache for facilities
|
| 189 |
+
|
| 190 |
+
def _get_df(self):
|
| 191 |
+
if self._df is None:
|
| 192 |
+
self._df = load_facilities()
|
| 193 |
+
return self._df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
def get_response(
|
| 196 |
+
self,
|
| 197 |
+
message: str,
|
| 198 |
+
history: list[list[str]] | None = None,
|
| 199 |
+
state: dict | None = None,
|
| 200 |
+
) -> tuple[str, dict]:
|
| 201 |
"""
|
| 202 |
+
Generate response and updated state. Use only this entrypoint from Gradio (or a future API).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
"""
|
| 204 |
+
state = state if state is not None else dict(DEFAULT_STATE)
|
| 205 |
+
history = history or []
|
| 206 |
+
criteria = state.get("criteria", {})
|
| 207 |
+
last_results = state.get("last_results", [])
|
| 208 |
+
last_facility_detail = state.get("last_facility_detail")
|
| 209 |
+
|
| 210 |
+
# Extract criteria from current message and merge
|
| 211 |
+
new_criteria = _extract_criteria(message)
|
| 212 |
+
criteria = _merge_criteria(criteria, new_criteria)
|
| 213 |
+
|
| 214 |
+
# Check if user is asking about a specific facility (follow-up)
|
| 215 |
+
facility_mention = _detect_facility_mention(message, last_results)
|
| 216 |
+
if facility_mention:
|
| 217 |
+
single = get_facility_by_name(facility_mention, self._get_df())
|
| 218 |
+
if single:
|
| 219 |
+
last_facility_detail = single
|
| 220 |
+
context_data = "Current facility data (use ONLY this for your answer):\n" + _format_facilities_for_prompt([single])
|
| 221 |
+
else:
|
| 222 |
+
context_data = "No matching facility found in data. Say you don't have details for that facility and offer to search again or clarify."
|
| 223 |
+
else:
|
| 224 |
+
last_facility_detail = None
|
| 225 |
+
# Run search when we have at least location
|
| 226 |
+
has_location = bool(criteria.get("state") or criteria.get("location"))
|
| 227 |
+
if has_location:
|
| 228 |
+
results = search(criteria, df=self._get_df(), limit=5)
|
| 229 |
+
last_results = results
|
| 230 |
+
context_data = "Current facility data (suggest ONLY these; do not invent any other facility):\n" + _format_facilities_for_prompt(results)
|
| 231 |
+
else:
|
| 232 |
+
context_data = "No search has been run yet (user has not provided a location). Ask for state or city, and optionally treatment type, payment, substances, populations, therapies, and languages, before suggesting facilities."
|
| 233 |
+
|
| 234 |
+
# Build messages for API: system (with context) + history + current user
|
| 235 |
+
system_content = SYSTEM_PROMPT + "\n\n" + context_data
|
| 236 |
+
|
| 237 |
+
messages = [{"role": "system", "content": system_content}]
|
| 238 |
+
for pair in history:
|
| 239 |
+
if len(pair) >= 2:
|
| 240 |
+
messages.append({"role": "user", "content": pair[0]})
|
| 241 |
+
messages.append({"role": "assistant", "content": pair[1]})
|
| 242 |
+
messages.append({"role": "user", "content": message})
|
| 243 |
+
|
| 244 |
response = self.client.chat.completions.create(
|
| 245 |
model=self.client.model,
|
| 246 |
+
messages=messages,
|
| 247 |
+
max_tokens=800,
|
| 248 |
+
temperature=0.5,
|
| 249 |
)
|
| 250 |
+
reply = (response.choices[0].message.content or "").strip()
|
| 251 |
+
|
| 252 |
+
new_state = {
|
| 253 |
+
"criteria": criteria,
|
| 254 |
+
"last_results": last_results,
|
| 255 |
+
"last_facility_detail": last_facility_detail,
|
| 256 |
+
}
|
| 257 |
+
return reply, new_state
|
src/facilities.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SAMHSA facility data loading and search.
|
| 3 |
+
|
| 4 |
+
Data story: See data/README.md (source: N-SUMHSS / National Directory; scope and limitations).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
# Column mapping: internal names -> CSV columns
|
| 12 |
+
FACILITY_COLUMNS = {
|
| 13 |
+
"name": "facility_name",
|
| 14 |
+
"address": "address",
|
| 15 |
+
"city": "city",
|
| 16 |
+
"state": "state",
|
| 17 |
+
"zip": "zip",
|
| 18 |
+
"phone": "phone",
|
| 19 |
+
"treatment_type": "treatment_type",
|
| 20 |
+
"payment_options": "payment_options",
|
| 21 |
+
"mat": "mat",
|
| 22 |
+
"services": "services",
|
| 23 |
+
"substances_addressed": "substances_addressed",
|
| 24 |
+
"languages": "languages",
|
| 25 |
+
"populations": "populations",
|
| 26 |
+
"description": "description",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _data_path() -> str:
|
| 31 |
+
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 32 |
+
return os.path.join(base, "data", "facilities.csv")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_facilities() -> pd.DataFrame:
|
| 36 |
+
"""Load facility CSV; keep rows with non-missing city and state."""
|
| 37 |
+
path = _data_path()
|
| 38 |
+
if not os.path.exists(path):
|
| 39 |
+
return pd.DataFrame()
|
| 40 |
+
df = pd.read_csv(path)
|
| 41 |
+
for col in ["city", "state"]:
|
| 42 |
+
if col in df.columns:
|
| 43 |
+
df = df[df[col].notna() & (df[col].astype(str).str.strip() != "")]
|
| 44 |
+
return df
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def search(criteria: dict[str, Any], df: pd.DataFrame | None = None, limit: int = 10) -> list[dict[str, Any]]:
|
| 48 |
+
"""
|
| 49 |
+
Search facilities by criteria. Only returns facilities that match all provided filters.
|
| 50 |
+
Criteria keys (all optional): location (state or city name), state, city, treatment_type,
|
| 51 |
+
payment (e.g. Medicaid, MassHealth, insurance, sliding scale, free, veterans), mat (bool),
|
| 52 |
+
populations (e.g. veterans, adolescents, LGBTQ+, pregnant women), languages (e.g. Spanish),
|
| 53 |
+
substances (e.g. alcohol, opioids), therapies (e.g. CBT, 12-step; MAT is separate via mat=True).
|
| 54 |
+
Missing columns (e.g. substances_addressed) are skipped for that filter.
|
| 55 |
+
"""
|
| 56 |
+
if df is None:
|
| 57 |
+
df = load_facilities()
|
| 58 |
+
if df.empty:
|
| 59 |
+
return []
|
| 60 |
+
|
| 61 |
+
out = df.copy()
|
| 62 |
+
|
| 63 |
+
# Normalize for matching: lowercase string
|
| 64 |
+
def norm(s: Any) -> str:
|
| 65 |
+
if pd.isna(s):
|
| 66 |
+
return ""
|
| 67 |
+
return str(s).lower().strip()
|
| 68 |
+
|
| 69 |
+
# State: exact match (e.g. "ma", "MA" -> Massachusetts or state abbrev)
|
| 70 |
+
state = criteria.get("state") or (criteria.get("location") if isinstance(criteria.get("location"), str) and len(criteria.get("location", "").strip()) == 2 else None)
|
| 71 |
+
if not state and isinstance(criteria.get("location"), str):
|
| 72 |
+
loc = criteria["location"].strip()
|
| 73 |
+
# US state abbreviations (common)
|
| 74 |
+
abbr = {"ma": "ma", "mass": "ma", "massachusetts": "ma", "tx": "tx", "texas": "tx", "ca": "ca", "california": "ca", "il": "il", "illinois": "il"}
|
| 75 |
+
for k, v in abbr.items():
|
| 76 |
+
if loc.lower().startswith(k) or k in loc.lower():
|
| 77 |
+
state = v
|
| 78 |
+
break
|
| 79 |
+
if not state and "boston" in loc.lower():
|
| 80 |
+
state = "ma"
|
| 81 |
+
if not state and "austin" in loc.lower():
|
| 82 |
+
state = "tx"
|
| 83 |
+
if not state and "san antonio" in loc.lower():
|
| 84 |
+
state = "tx"
|
| 85 |
+
if not state and "chicago" in loc.lower():
|
| 86 |
+
state = "il"
|
| 87 |
+
if not state and ("california" in loc.lower() or "san francisco" in loc.lower() or "los angeles" in loc.lower()):
|
| 88 |
+
state = "ca"
|
| 89 |
+
if state:
|
| 90 |
+
out = out[out["state"].astype(str).str.lower().str.strip() == norm(state)]
|
| 91 |
+
|
| 92 |
+
# City or location text in city/name
|
| 93 |
+
city = criteria.get("city")
|
| 94 |
+
location_text = criteria.get("location") if not state else None
|
| 95 |
+
if city:
|
| 96 |
+
out = out[out["city"].apply(norm).str.contains(norm(city), na=False)]
|
| 97 |
+
elif location_text and isinstance(location_text, str) and len(location_text) > 2:
|
| 98 |
+
loc = norm(location_text)
|
| 99 |
+
if loc not in ("ma", "tx", "ca", "il", "mass", "massachusetts", "texas", "california", "illinois"):
|
| 100 |
+
out = out[
|
| 101 |
+
out["city"].apply(norm).str.contains(loc, na=False)
|
| 102 |
+
| out["facility_name"].apply(norm).str.contains(loc, na=False)
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
# Helper: match term in col, or in services/description when col is empty (decoded data stored there)
|
| 106 |
+
def col_or_services_contains(col: str, term: str) -> pd.Series:
|
| 107 |
+
col_vals = out[col].apply(norm) if col in out.columns else pd.Series([""] * len(out), index=out.index)
|
| 108 |
+
if "services" in out.columns:
|
| 109 |
+
fallback = out["services"].apply(norm).str.contains(term, na=False)
|
| 110 |
+
if "description" in out.columns:
|
| 111 |
+
fallback = fallback | out["description"].apply(norm).str.contains(term, na=False)
|
| 112 |
+
else:
|
| 113 |
+
fallback = pd.Series(False, index=out.index)
|
| 114 |
+
return col_vals.str.contains(term, na=False) | ((col_vals.str.strip() == "") & fallback)
|
| 115 |
+
|
| 116 |
+
# Treatment type: inpatient, outpatient, residential, telehealth
|
| 117 |
+
treatment = criteria.get("treatment_type")
|
| 118 |
+
if treatment and ("treatment_type" in out.columns or "services" in out.columns):
|
| 119 |
+
t = norm(treatment)
|
| 120 |
+
out = out[col_or_services_contains("treatment_type", t)]
|
| 121 |
+
|
| 122 |
+
# Payment: Medicaid, MassHealth, insurance, sliding scale, free, veterans
|
| 123 |
+
payment = criteria.get("payment")
|
| 124 |
+
if payment and ("payment_options" in out.columns or "services" in out.columns):
|
| 125 |
+
p = norm(payment)
|
| 126 |
+
out = out[col_or_services_contains("payment_options", p)]
|
| 127 |
+
|
| 128 |
+
# MAT
|
| 129 |
+
if criteria.get("mat") is True:
|
| 130 |
+
out = out[out["mat"].apply(norm) == "yes"]
|
| 131 |
+
|
| 132 |
+
# Populations: veterans, adolescents, LGBTQ+, pregnant women, etc.
|
| 133 |
+
pop = criteria.get("populations")
|
| 134 |
+
if pop and ("populations" in out.columns or "services" in out.columns):
|
| 135 |
+
p = norm(pop)
|
| 136 |
+
out = out[col_or_services_contains("populations", p)]
|
| 137 |
+
|
| 138 |
+
# Languages: e.g. Spanish, Vietnamese
|
| 139 |
+
lang = criteria.get("languages")
|
| 140 |
+
if lang and ("languages" in out.columns or "services" in out.columns):
|
| 141 |
+
l = norm(lang)
|
| 142 |
+
out = out[col_or_services_contains("languages", l)]
|
| 143 |
+
|
| 144 |
+
# Substances addressed: e.g. alcohol, opioids
|
| 145 |
+
substances = criteria.get("substances")
|
| 146 |
+
if substances and ("substances_addressed" in out.columns or "services" in out.columns):
|
| 147 |
+
s = norm(substances)
|
| 148 |
+
out = out[col_or_services_contains("substances_addressed", s)]
|
| 149 |
+
|
| 150 |
+
# Therapies: CBT, 12-step, etc. (MAT has dedicated filter above). Search in services and description.
|
| 151 |
+
therapies = criteria.get("therapies")
|
| 152 |
+
if therapies:
|
| 153 |
+
t = norm(therapies)
|
| 154 |
+
# Normalize 12-step variants for matching
|
| 155 |
+
t_alt = "12-step" if "12" in t or "twelve" in t else t
|
| 156 |
+
def has_therapy(row: pd.Series) -> bool:
|
| 157 |
+
svc = norm(row.get("services", ""))
|
| 158 |
+
desc = norm(row.get("description", ""))
|
| 159 |
+
if t == "cbt" or t_alt == "12-step":
|
| 160 |
+
if t == "cbt":
|
| 161 |
+
return "cbt" in svc or "cbt" in desc
|
| 162 |
+
return "12-step" in svc or "12 step" in svc or "12-step" in desc or "12 step" in desc
|
| 163 |
+
return t in svc or t in desc
|
| 164 |
+
out = out[out.apply(has_therapy, axis=1)]
|
| 165 |
+
|
| 166 |
+
out = out.head(limit)
|
| 167 |
+
return out.to_dict(orient="records")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def get_facility_by_name(name_fragment: str, df: pd.DataFrame | None = None) -> dict[str, Any] | None:
|
| 171 |
+
"""Return the first facility whose name contains the given fragment (for follow-up questions)."""
|
| 172 |
+
if df is None:
|
| 173 |
+
df = load_facilities()
|
| 174 |
+
if df.empty or not name_fragment or not name_fragment.strip():
|
| 175 |
+
return None
|
| 176 |
+
frag = name_fragment.lower().strip()
|
| 177 |
+
for _, row in df.iterrows():
|
| 178 |
+
if frag in str(row.get("facility_name", "")).lower():
|
| 179 |
+
return row.to_dict()
|
| 180 |
+
return None
|