Spaces:
Sleeping
Sleeping
File size: 20,794 Bytes
8aa3867 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | # ============================================================
# FILE: app/components/chat_ui.py
# PURPOSE: Renders the entire Chat Companion page.
# After every bot response it now:
# 1. Runs SHAP to explain which words drove the emotion
# 2. Reads the predicted emotion + risk level from the DB
# 3. Displays them as colored badges under the message
# 4. Embeds the SHAP HTML report in a collapsible expander
# ============================================================
import streamlit as st # Core UI framework
import os # For building file paths
import streamlit.components.v1 as components # Lets us embed raw HTML (the SHAP report)
# Import our two cached AI loaders from api.py
# get_mindguard_bot() β returns the chatbot (LLM + Whisper + DB)
# get_shap_explainer() β returns the SHAP XAI engine (XLM-R model)
from api import get_mindguard_bot, get_shap_explainer
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# COLOR CONFIGURATION
# These dictionaries map risk levels and emotion names to
# hex color codes so we can render styled HTML badges.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Maps risk level strings β (background_color, text_color) tuples
RISK_COLORS = {
"High": ("#ff4b4b", "white"), # Bright red β urgent / crisis
"Medium": ("#ffa500", "white"), # Orange β elevated concern
"Low": ("#21c354", "white"), # Green β safe / normal
}
# Maps individual emotion label strings β a single hex background color
# Grouped by emotional valence for clarity:
EMOTION_COLORS = {
# ββ Clinical / high-severity emotions (red family) ββββββββββββββ
"Suicidal": "#ff4b4b", # Maximum urgency β bright red
"Depression": "#e05260", # Dark rose
"Anxiety": "#e07052", # Warm red-orange
"Bipolar": "#c0392b", # Deep crimson
"Stress": "#e67e22", # Amber-orange
"Personality disorder": "#9b59b6", # Purple β complex/clinical
# ββ Positive emotions (green family) ββββββββββββββββββββββββββββ
"joy": "#21c354",
"love": "#2ecc71",
"gratitude": "#27ae60",
"admiration": "#1abc9c",
"optimism": "#16a085",
"relief": "#52be80",
"excitement": "#58d68d",
"pride": "#a9cce3", # Soft blue-green
# ββ Neutral emotions (blue) ββββββββββββββββββββββββββββββββββββββ
"Normal": "#3498db",
"neutral": "#3498db",
# ββ Negative / distressed emotions (amber-brown family) βββββββββ
"sadness": "#e08000",
"grief": "#ca6f1e",
"fear": "#e74c3c",
"anger": "#c0392b",
"annoyance": "#d35400",
"disappointment": "#ca6f1e",
"remorse": "#a04000",
"disgust": "#7d6608",
}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# HELPER: Build an HTML emotion badge string
# Returns a colored pill-shaped <span> tag with the emotion name.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _emotion_badge(emotion: str) -> str:
# Look up the color; fall back to grey if emotion isn't in our map
color = EMOTION_COLORS.get(emotion, "#555555")
# Build and return a self-contained inline HTML span element
# unsafe_allow_html=True must be used in the st.markdown call to render this
return (
f'<span style="'
f'background:{color};' # Background color from our map
f'color:white;' # White text for contrast
f'padding:3px 10px;' # Pill-style inner spacing
f'border-radius:12px;' # Rounded corners
f'font-size:13px;'
f'font-weight:600;' # Semi-bold text
f'">π§ {emotion}</span>' # Brain emoji + emotion label
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# HELPER: Build an HTML risk-level badge string
# Similar to emotion badge but with icons matching severity.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _risk_badge(risk: str) -> str:
# Unpack background and text color from our tuple map
bg, fg = RISK_COLORS.get(risk, ("#888888", "white"))
# Choose a severity icon to reinforce the color signal visually
icons = {"High": "π¨", "Medium": "β οΈ", "Low": "β
"}
icon = icons.get(risk, "β’") # Default bullet if risk level is unexpected
return (
f'<span style="'
f'background:{bg};'
f'color:{fg};'
f'padding:3px 10px;'
f'border-radius:12px;'
f'font-size:13px;'
f'font-weight:600;'
f'">{icon} Risk: {risk}</span>'
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# HELPER: Render the SHAP HTML report inside the chat message
# Uses streamlit.components.v1.html() to embed arbitrary HTML
# inside a collapsible expander so it doesn't clutter the chat.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _render_shap_inline(html_path: str):
# Only attempt to render if the file actually exists on disk.
# The file is written by shap_explainer.generate_visual_report().
if os.path.exists(html_path):
# Read the entire SHAP HTML file into a string
with open(html_path, "r", encoding="utf-8") as f:
shap_html = f.read()
# Wrap in a Streamlit expander so it's hidden by default.
# expanded=False means the user must click to open it.
with st.expander("π¬ View XAI Word-Level Explanation (SHAP)", expanded=False):
# components.html() injects raw HTML into an iframe inside Streamlit.
# height=300 sets the iframe height in pixels.
# scrolling=True enables vertical scroll inside the iframe.
components.html(shap_html, height=300, scrolling=True)
else:
# If no report exists yet, show a subtle placeholder message
st.caption("_SHAP report not yet generated._")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# HELPER: Query DB for the most recent emotion + risk level
# Called immediately after bot.generate_response() because the
# bot writes the diagnosed_emotion and risk_level to SQLite during
# response generation. We read it back to display in the UI.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _get_last_emotion_risk(bot) -> tuple:
try:
# Import the database class from src (path was fixed in api.py's sys.path setup)
from src.database.db_operations import MindGuardDatabase
db = MindGuardDatabase() # Open a new DB connection
# Run a SQL query to get the single most recent row, ordered newest-first
db.cursor.execute(
"SELECT diagnosed_emotion, risk_level "
"FROM chat_history "
"ORDER BY timestamp DESC "
"LIMIT 1"
)
row = db.cursor.fetchone() # Fetch the one result row (or None if empty)
db.close() # Always close the DB connection to avoid leaks
if row:
# dict(row) converts the sqlite3.Row object to a regular Python dict
# so we can access columns by name like a dictionary
return dict(row)["diagnosed_emotion"], dict(row)["risk_level"]
except Exception:
# Silently swallow any DB errors (e.g., table doesn't exist yet)
# so a DB issue never crashes the entire chat UI
pass
# Fallback values if DB is empty or an error occurred
return "Unknown", "Unknown"
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# MAIN RENDER FUNCTION
# Called by main.py when the user selects "π¬ Chat Companion"
# in the sidebar navigation radio buttons.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def render_chat():
# Page title and subtitle at the top of the main content area
st.title("π§ MindGuard Companion")
st.markdown("Your clinical-grade, empathetic AI. Type a message or upload a voice note.")
# ββ Load the cached AI objects ββββββββββββββββββββββββββββ
# These calls are instant after the first load because of @st.cache_resource
bot = get_mindguard_bot() # The Groq chatbot
shap_ex = get_shap_explainer() # The SHAP XAI explainer
# ββ Session State Initialization ββββββββββββββββββββββββββ
# st.session_state persists values across Streamlit reruns
# within the same browser session. We use it as our "memory".
if "messages" not in st.session_state:
# messages: a list of dicts. Each dict has at minimum:
# { "role": "user"|"assistant", "content": "..." }
# Assistant messages may also carry:
# { "emotion": "...", "risk": "...", "shap_path": "..." }
st.session_state.messages = []
if "session_id" not in st.session_state:
# session_id is passed to the bot so it can group DB records
# per user/session. Hardcoded for demo; replace with auth later.
st.session_state.session_id = "demo_user_001"
# ββ SHAP Report Path ββββββββββββββββββββββββββββββββββββββ
# shap_explainer.py always writes to artifacts/shap_report.html
# at the project root. We build that path here once so we don't
# repeat the logic in multiple places.
SHAP_HTML_PATH = os.path.join(
# Start from this file's location: app/components/
# Go up TWO levels (components/ β app/ β project_root/)
os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")),
"artifacts", # The artifacts/ folder at project root
"shap_report.html" # The fixed filename shap_explainer.py writes to
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# SIDEBAR SECTION
# st.sidebar renders everything inside it in the left panel.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with st.sidebar:
st.header("ποΈ Voice Input")
st.write("Feeling overwhelmed? Talk to MindGuard directly.")
# st.audio_input() renders a mic button in the sidebar.
# Returns a BytesIO-like object when a recording is complete,
# or None if no recording has been made yet.
audio_value = st.audio_input("Record a voice note")
if audio_value:
# ββ Infinite Loop Prevention ββββββββββββββββββββββ
# Streamlit reruns on every state change. Without this guard,
# processing the audio would trigger a rerun, which would
# process the same audio again β infinitely.
# FIX: Track the BYTE SIZE of the audio as a unique fingerprint.
# If the size matches last time, we already processed this recording.
current_audio_size = len(audio_value.getvalue())
if ("last_audio_size" not in st.session_state or
st.session_state.last_audio_size != current_audio_size):
# Lock this audio's size into memory to prevent reprocessing
st.session_state.last_audio_size = current_audio_size
# Write the audio bytes to a temp .wav file on disk.
# Whisper needs a file path, not a BytesIO object.
temp_path = os.path.join("data", "raw", "temp_streamlit.wav")
with open(temp_path, "wb") as f:
f.write(audio_value.getbuffer()) # getbuffer() gives raw bytes
# Transcribe the audio file using Whisper, then get a response
with st.spinner("Transcribing and analyzing via Whisperβ¦"):
# bot.audio_processor.transcribe() runs Whisper on the .wav file
transcribed_text = bot.audio_processor.transcribe(temp_path)
# bot.generate_response() sends the text to Groq LLM + writes to DB
response = bot.generate_response(
user_input=transcribed_text,
session_id=st.session_state.session_id
)
# Run SHAP on the transcribed text.
# This writes a new shap_report.html to artifacts/
with st.spinner("Generating XAI explanationβ¦"):
shap_ex.generate_visual_report(transcribed_text)
# Pull the emotion + risk level that the bot just saved to the DB
emotion, risk = _get_last_emotion_risk(bot)
# Append the user's (transcribed) voice message to the chat history
st.session_state.messages.append({
"role": "user",
# Format it visually to indicate it came from voice, not typing
"content": f"π€ **Voice Note:** *{transcribed_text}*"
})
# Append the assistant's response WITH the analysis metadata
st.session_state.messages.append({
"role": "assistant",
"content": response,
"emotion": emotion, # e.g. "Anxiety"
"risk": risk, # e.g. "High"
"shap_path": SHAP_HTML_PATH, # Path to render the SHAP report
})
# NOTE: No st.rerun() here. Streamlit will naturally continue
# down the script and render the new messages in the next section.
st.divider() # A horizontal line separator in the sidebar
# Clear button: wipes all chat history and resets the audio lock
if st.button("ποΈ Clear Chat History"):
st.session_state.messages = [] # Empty the message list
st.session_state.pop("last_audio_size", None) # Reset audio fingerprint
st.rerun() # Force a full page refresh to visually clear the chat window
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# MAIN CHAT WINDOW β Render Historical Messages
# Loop through all messages stored in session_state and
# re-render them. This is how Streamlit "remembers" the chat.
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
for msg in st.session_state.messages:
# st.chat_message() renders a chat bubble with the correct
# avatar: human avatar for "user", robot avatar for "assistant"
with st.chat_message(msg["role"]):
# Render the message text (supports markdown formatting)
st.markdown(msg["content"])
# Only assistant messages carry the analysis metadata.
# We check for the "emotion" key to know if this is an
# analyzed message (not all assistant messages will have it
# if the DB was empty or an error occurred).
if msg["role"] == "assistant" and "emotion" in msg:
# Place the two badges side-by-side using columns
col1, col2 = st.columns([1, 1]) # Equal-width columns
with col1:
# unsafe_allow_html=True is required because our badge
# is a raw HTML string, not standard Markdown
st.markdown(_emotion_badge(msg["emotion"]), unsafe_allow_html=True)
with col2:
st.markdown(_risk_badge(msg["risk"]), unsafe_allow_html=True)
# Render the SHAP explanation report below the badges
_render_shap_inline(msg.get("shap_path", ""))
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# MAIN CHAT WINDOW β Handle New Text Input
# st.chat_input() renders a fixed input bar at the bottom.
# The walrus operator (:=) assigns AND checks in one step:
# "if there is a new prompt, assign it to 'prompt' and enter the block"
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if prompt := st.chat_input("How are you feeling right now?"):
# 1. Immediately show the user's message (before the bot responds)
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# 2. Generate bot response + analysis inside the assistant bubble
with st.chat_message("assistant"):
# Step A: Get the LLM response (heavy β show spinner to user)
with st.spinner("Diagnosing emotion and retrieving clinical strategyβ¦"):
response = bot.generate_response(prompt, st.session_state.session_id)
# Step B: Run SHAP AFTER the response so the chat feels fast.
# The user sees the reply first, then waits briefly for XAI.
with st.spinner("Generating XAI word-level explanationβ¦"):
# Overwrites artifacts/shap_report.html with a fresh analysis
shap_ex.generate_visual_report(prompt)
# Step C: Read the emotion + risk that the bot stored in SQLite
# during generate_response() β this is a near-instant DB read
emotion, risk = _get_last_emotion_risk(bot)
# Step D: Render the response text in the chat bubble
st.markdown(response)
# Step E: Render the emotion and risk badges side by side
col1, col2 = st.columns([1, 1])
with col1:
st.markdown(_emotion_badge(emotion), unsafe_allow_html=True)
with col2:
st.markdown(_risk_badge(risk), unsafe_allow_html=True)
# Step F: Embed the SHAP HTML report in a collapsible expander
_render_shap_inline(SHAP_HTML_PATH)
# 3. Save the complete message (with metadata) to session_state
# so it re-renders correctly on the next Streamlit rerun
st.session_state.messages.append({
"role": "assistant",
"content": response,
"emotion": emotion, # Predicted emotion label
"risk": risk, # Risk level (High/Medium/Low)
"shap_path": SHAP_HTML_PATH, # Path to the saved SHAP HTML report
}) |