phanny commited on
Commit
78b92dc
·
1 Parent(s): de10a75

add google maps API

Browse files
.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
- Gradio Web Interface for Boston School Chatbot
3
-
4
- This script creates a web interface for your chatbot using Gradio.
5
- You only need to implement the chat function.
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
- from src.chat import Chatbot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- def create_chatbot():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  """
26
- Creates and configures the chatbot interface.
 
 
 
 
27
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  chatbot = Chatbot()
29
-
30
- def chat(message, history):
31
- """
32
- TODO:Generate a response for the current message in a Gradio chat interface.
33
-
34
- This function is called by Gradio's ChatInterface every time a user sends a message.
35
- You only need to generate and return the assistant's response - Gradio handles the
36
- chat display and history management automatically.
37
-
38
- Args:
39
- message (str): The current message from the user
40
- history (list): List of previous message pairs, where each pair is
41
- [user_message, assistant_message]
42
- Example:
43
- [
44
- ["What schools offer Spanish?", "The Hernandez School..."],
45
- ["Where is it located?", "The Hernandez School is in Roxbury..."]
46
- ]
47
-
48
- Returns:
49
- str: The assistant's response to the current message.
50
-
51
-
52
- Note:
53
- - Gradio automatically:
54
- - Displays the user's message
55
- - Displays your returned response
56
- - Updates the chat history
57
- - Maintains the chat interface
58
- - You only need to:
59
- - Generate an appropriate response to the current message
60
- - Return that response as a string
61
- """
62
- # TODO: Generate and return response
63
- return chatbot.get_response(message)
64
-
65
-
66
- # Create Gradio interface. Customize the interface however you'd like!
67
- demo = gr.ChatInterface(
68
- chat,
69
- title="6.C395",
70
- description="Ask me anything about [topic]! Since I am a free tier chatbot, I may give a 503 error when I'm busy. If that happens, please try again a few seconds later.",
71
- examples=[
72
- "What options are available for someone in my situation?"
73
- ]
74
- )
75
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  return demo
77
 
 
78
  if __name__ == "__main__":
79
- demo = create_chatbot()
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("<", "&lt;").replace(">", "&gt;")
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
- from config import BASE_MODEL, MY_MODEL, HF_TOKEN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  class Chatbot:
5
  """
6
- This class is extra scaffolding around a model. Modify this class to specify how the model recieves prompts and generates responses.
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
- def format_prompt(self, user_input):
21
- """
22
- TODO: Implement this method to format the user's input into a proper prompt.
23
-
24
- This method should:
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
- return f"You are a helpful assistant that specializes in finding appropriate substance use and mental health treatment facilities in the Boston area. User: {user_input} Assistant:"
42
-
43
- def get_response(self, user_input):
 
 
 
44
  """
45
- TODO: Implement this method to generate responses to user questions.
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
- prompt = self.format_prompt(user_input)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  response = self.client.chat.completions.create(
64
  model=self.client.model,
65
- messages=[{"role": "user", "content": prompt}],
66
- max_tokens=1000,
67
- temperature=0.7,
68
  )
69
- return response.choices[0].message.content
 
 
 
 
 
 
 
 
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