MoodHelper / app.py
cassandrasestier's picture
Update app.py
04bcef8 verified
# ================================
# πŸͺž MoodMirror+ β€” Emotion-aware advice
# Tabs: Advice β€’ Emergency numbers β€’ Breathing β€’ Journal
# ================================
import os
import re
import random
import sqlite3
import joblib
import numpy as np
import time
import zipfile
from datetime import datetime
import gradio as gr
from datasets import load_dataset
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.pipeline import Pipeline
# ---------------- Storage paths ----------------
def _pick_data_dir():
if os.path.isdir("/data") and os.access("/data", os.W_OK):
return "/data"
return os.getcwd()
DATA_DIR = _pick_data_dir()
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, "moodmirror.db")
MODEL_PATH = os.path.join(DATA_DIR, "goemo_sklearn.joblib")
MODEL_VERSION = "v13-all-maps-hints"
# ---------------- Crisis & closing ----------------
CRISIS_RE = re.compile(
r"\b(self[- ]?harm|suicid|kill myself|end my life|overdose|cutting|i don.?t want to live|can.?t go on)\b",
re.I,
)
CLOSING_RE = re.compile(
r"\b(thanks?|thank you|bye|goodbye|see you|take care|ok bye|no thanks?)\b",
re.I,
)
CRISIS_NUMBERS_EN = {
"United States": "πŸ“ž **988** (Suicide & Crisis Lifeline, 24/7)",
"Canada": "πŸ“ž **988** (Suicide Crisis Helpline, 24/7)",
"United Kingdom": "πŸ“ž **116 123** (Samaritans, 24/7)",
"Ireland": "πŸ“ž **116 123** (Samaritans Ireland, 24/7)",
"France": "πŸ“ž **3114** (National Suicide Prevention number, 24/7)",
"Belgium": "πŸ“ž **1813** (Zelfmoordlijn, 24/7)",
"Switzerland": "πŸ“ž **143** (La Main Tendue / Heart2Heart)",
"Spain": "πŸ“ž **024** (LΓ­nea 024 β€” AtenciΓ³n a la conducta suicida, 24/7)",
"Germany": "πŸ“ž **0800 111 0 111** / **0800 111 0 222** / **116 123** (TelefonSeelsorge, 24/7)",
"Netherlands": "πŸ“ž **0800-0113** (free) or **113** (standard rate) β€” 113 Suicide Prevention",
"Portugal": "πŸ“ž **213 544 545**, **912 802 669**, **963 524 660** (SOS Voz Amiga)",
"Australia": "πŸ“ž **13 11 14** (Lifeline, 24/7)",
"New Zealand": "πŸ“ž **0508 828 865** (Suicide Crisis Helpline β€” TAUTOKO)",
"India": "πŸ“ž **14416** (Tele MANAS, 24/7) or **1800-599-0019** (KIRAN)",
"South Africa": "πŸ“ž **0800 567 567** (SADAG Suicide Crisis Helpline, 24/7)",
"Other / Not listed": "Call local emergency (**112/911**) or search β€œsuicide hotline” + your country.",
}
# ---------------- Advice library (5 tips each) ----------------
SUGGESTIONS = {
"sadness": [
"Go for a 5-minute outside walk and name three colors you see.",
"Write what hurts, then add one thing you still care about.",
"Take a warm shower and focus on your shoulders relaxing.",
"Text a safe person: β€œCan I vent for 2 minutes?”",
"Wrap in a blanket and slow your exhale for 60 seconds.",
],
"fear": [
"Do 5-4-3-2-1 grounding: 5 see, 4 feel, 3 hear, 2 smell, 1 taste.",
"Make your exhale longer than your inhale for eight breaths.",
"Hold something cool (spoon/ice) for 30 seconds and notice the sensation.",
"Name the fear in one clear sentence out loud.",
"Write the worst case, then the most likely case beside it.",
],
"anger": [
"Take space before replying; set a 10-minute timer.",
"Do ten slow exhales through pursed lips.",
"Squeeze then release your fists ten times.",
"Walk fast for five minutes or do one stair flight.",
"Write the crossed boundary; draft one calm sentence.",
],
"nervousness": [
"4-7-8 breathing: in 4s, hold 7s, out 8s (four rounds).",
"Relax your jaw and lower your shoulders.",
"Write worries down; underline what you can control.",
"Pick one tiny task you can finish in five minutes.",
"Hold a warm mug and notice the heat and weight.",
],
"boredom": [
"Set a 2-minute timer and start anything small.",
"Change your soundtrackβ€”put on one new song.",
"Do 15 jumping jacks or a quick stretch.",
"Clean your phone screen or keyboard.",
"Write five quick ideas without editing.",
],
"grief": [
"Hold a photo or object and say their name softly.",
"Drink water and eat somethingβ€”your body grieves too.",
"Write a short letter to them about today.",
"Create a tiny ritual (song, candle, place).",
"Plan one kind thing for yourself this week.",
],
"love": [
"Send a kind message without expecting a reply.",
"Note three things you appreciate about someone close.",
"Offer yourself one gentle act you needed today.",
"Give a sincere compliment to a stranger.",
"Plan a tiny gesture for tomorrow.",
],
"joy": [
"Pause and take three slow breaths to savor this.",
"Capture itβ€”photo, note, or voice memo.",
"Tell someone why you feel good right now.",
"Move to music for one song.",
"Plan a tiny celebration later today.",
],
"curiosity": [
"Search one concept and read just the first paragraph.",
"Write three quick β€œwhat if…?” ideas.",
"Watch a β€œhow does X work?” video for 3 minutes.",
"Learn one new word and use it once.",
"Sketch a simple diagram of an idea.",
],
"gratitude": [
"List three tiny things that made today easier.",
"Thank someone by name for something specific.",
"Notice an everyday object and appreciate its help.",
"Write β€œI’m lucky that…” and complete it once.",
"Savor your next sip or bite with attention.",
],
"neutral": [
"Take one slow breath and relax your hands.",
"Stand, stretch, and roll your shoulders.",
"Drink a glass of water mindfully.",
"Organize three items in your space.",
"Set a 10-minute timer to focus on one thing.",
],
}
# full GoEmotions β†’ bucket
GOEMO_TO_APP = {
"admiration": "gratitude",
"amusement": "joy",
"anger": "anger",
"annoyance": "anger",
"approval": "gratitude",
"caring": "love",
"confusion": "nervousness",
"curiosity": "curiosity",
"desire": "joy",
"disappointment": "sadness",
"disapproval": "anger",
"disgust": "anger",
"embarrassment": "nervousness",
"excitement": "joy",
"fear": "fear",
"gratitude": "gratitude",
"grief": "grief",
"joy": "joy",
"love": "love",
"nervousness": "nervousness",
"optimism": "joy",
"pride": "joy",
"realization": "neutral",
"relief": "gratitude",
"remorse": "grief",
"sadness": "sadness",
"surprise": "neutral",
"neutral": "neutral",
}
# ---------------- Preprocessing & Hints ----------------
CLEAN_RE = re.compile(r"(https?://\S+)|(@\w+)|(#\w+)|[^a-zA-Z0-9\s']")
EMOJI_HINTS = {"😭": "sadness", "😑": "anger", "πŸ₯°": "love", "😨": "fear", "😴": "boredom"}
HINTS_EN = {
"i'm nervous": "nervousness", "im nervous": "nervousness", "nervous": "nervousness",
"anxious": "nervousness", "anxiety": "nervousness", "panic": "nervousness",
"i'm grieving": "grief", "im grieving": "grief", "grieving": "grief", "grief": "grief",
"sad": "sadness", "depressed": "sadness",
"angry": "anger", "furious": "anger",
"afraid": "fear", "scared": "fear",
}
def clean_text(s: str) -> str:
s = s.lower()
s = CLEAN_RE.sub(" ", s)
s = re.sub(r"\s+", " ", s).strip()
return s
def augment_text(text: str, history=None) -> str:
t = clean_text(text or "")
lt = (text or "").lower()
tags = []
for k, v in EMOJI_HINTS.items():
if k in lt: tags.append(v)
for k, v in HINTS_EN.items():
if k in lt: tags.append(v)
if history and len(t.split()) < 8:
prev_user = history[-1][0] if history and history[-1] else ""
if isinstance(prev_user, str) and prev_user:
t += " " + clean_text(prev_user)
if tags:
t += " " + " ".join(f"emo_{x}" for x in tags)
return t
# ---------------- SQLite ----------------
def get_conn():
return sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
def init_db():
conn = get_conn()
conn.execute("""CREATE TABLE IF NOT EXISTS sessions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT, country TEXT, user_text TEXT, main_emotion TEXT
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS journal(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
emotion TEXT,
title TEXT,
content TEXT
)""")
conn.commit()
conn.close()
def log_session(country, msg, emotion):
conn = get_conn()
conn.execute("INSERT INTO sessions(ts,country,user_text,main_emotion)VALUES(?,?,?,?)",
(datetime.utcnow().isoformat(timespec='seconds'), country, (msg or "")[:500], emotion))
conn.commit()
conn.close()
# ---- Journal helpers ----
def journal_save(title: str, content: str, emotion: str):
title = (title or "").strip()
content = (content or "").strip()
if not content:
return False, "Please write something before saving."
ts = datetime.utcnow().isoformat(timespec='seconds')
conn = get_conn()
conn.execute("INSERT INTO journal(ts, emotion, title, content) VALUES (?,?,?,?)",
(ts, emotion or "", title, content))
conn.commit()
conn.close()
return True, f"Saved βœ“ ({ts} UTC)."
def journal_list(search: str = "", limit: int = 50):
q = "SELECT id, ts, emotion, title, content FROM journal"
params = []
if search:
q += " WHERE (LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(emotion) LIKE ?)"
s = f"%{search.lower()}%"
params = [s, s, s]
q += " ORDER BY ts DESC LIMIT ?"
params.append(int(limit))
conn = get_conn()
rows = list(conn.execute(q, params))
conn.close()
options, table = [], []
for (id_, ts, emo, title, content) in rows:
label = f"{ts} β€” [{(emo or 'neutral')}] {title or (content[:30] + ('…' if len(content) > 30 else ''))}"
options.append((label, id_))
preview = (content or "").replace("\n", " ")
if len(preview) > 120: preview = preview[:120] + "…"
table.append([ts, emo or "β€”", title or "β€”", preview])
return options, table
def journal_get(entry_id: int):
conn = get_conn()
cur = conn.execute("SELECT ts, emotion, title, content FROM journal WHERE id = ?", (int(entry_id),))
row = cur.fetchone()
conn.close()
if not row: return None
ts, emo, title, content = row
return {"ts": ts, "emotion": emo or "", "title": title or "", "content": content or ""}
def journal_export_all_zip():
conn = get_conn()
rows = list(conn.execute("SELECT id, ts, emotion, title, content FROM journal ORDER BY ts"))
conn.close()
if not rows:
return None, "No entries to export."
zip_name = os.path.join(DATA_DIR, "journal_all.zip")
with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as zf:
for (id_, ts, emo, title, content) in rows:
safe_title = re.sub(r"[^a-zA-Z0-9_\- ]", "_", title or "untitled")
fname = f"{ts[:19].replace(':','-')} - {safe_title}.txt"
text = (
f"Title: {title or '(Untitled)'}\n"
f"Emotion: {emo or '-'}\n"
f"Saved (UTC): {ts}\n"
f"{'-'*40}\n"
f"{content or ''}"
)
zf.writestr(fname, text)
return zip_name, f"Exported {len(rows)} entries."
# ---------------- Model ----------------
def load_goemotions_dataset():
ds = load_dataset("google-research-datasets/go_emotions", "simplified")
return ds, ds["train"].features["labels"].feature.names
def train_or_load_model():
if os.path.exists(MODEL_PATH):
bundle = joblib.load(MODEL_PATH)
if bundle.get("version") == MODEL_VERSION:
return bundle["pipeline"], bundle["mlb"], bundle["label_names"]
ds, names = load_goemotions_dataset()
X_train, y_train = ds["train"]["text"], ds["train"]["labels"]
mlb = MultiLabelBinarizer(classes=list(range(len(names))))
Y_train = mlb.fit_transform(y_train)
clf = Pipeline([
("tfidf", TfidfVectorizer(lowercase=True, ngram_range=(1,2), min_df=2, max_df=0.9, strip_accents="unicode")),
("ovr", OneVsRestClassifier(
LogisticRegression(
solver="saga",
penalty="l2",
C=0.5,
tol=1e-3,
max_iter=5000,
class_weight="balanced"
),
n_jobs=-1
))
])
clf.fit(X_train, Y_train)
joblib.dump({"version": MODEL_VERSION, "pipeline": clf, "mlb": mlb, "label_names": names}, MODEL_PATH)
return clf, mlb, names
try:
CLASSIFIER, MLB, LABEL_NAMES = train_or_load_model()
except Exception as e:
print("[ERROR] Model load/train:", e)
CLASSIFIER, MLB, LABEL_NAMES = None, None, None
def classify_text(text_augmented: str):
if not CLASSIFIER: return []
proba = CLASSIFIER.predict_proba([text_augmented])[0]
max_p = float(np.max(proba)) if len(proba) else 0.0
thr = max(0.10, 0.30 * max_p + 0.15)
idxs = [i for i, p in enumerate(proba) if p >= thr] or [int(np.argmax(proba))]
idxs.sort(key=lambda i: proba[i], reverse=True)
return [(LABEL_NAMES[i], float(proba[i])) for i in idxs]
def detect_emotion_text(message: str, history):
labels = classify_text(augment_text(message, history))
if not labels:
return "neutral"
bucket = {}
for lbl, p in labels:
app = GOEMO_TO_APP.get(lbl.lower(), "neutral")
bucket[app] = max(bucket.get(app, 0.0), p)
return max(bucket, key=bucket.get) if bucket else "neutral"
# ---------------- Advice engine ----------------
def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""):
tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"])
entry = pool.get(emotion, {"unused": [], "last": ""})
if not entry["unused"]:
refill = [t for t in tips_all if t != entry.get("last","")] or tips_all[:]
random.shuffle(refill)
entry["unused"] = refill
tip = entry["unused"].pop(0)
entry["last"] = tip
pool[emotion] = entry
return tip, pool
def format_reply(emotion: str, tip: str) -> str:
# removed "why it helps"
return f"Try this now:\nβ€’ {tip}"
def crisis_block_en(country):
msg = CRISIS_NUMBERS_EN.get(country, CRISIS_NUMBERS_EN["Other / Not listed"])
return "πŸ’› You matter. If you're in danger or thinking of harming yourself, please reach out now.\n\n" + msg
def chat_step(user_text, history, country, save_session, advice_pool):
if user_text and CRISIS_RE.search(user_text):
return crisis_block_en(country), "neutral", "neutral", "", advice_pool
if user_text and CLOSING_RE.search(user_text):
emotion = "neutral"
tip, advice_pool = pick_advice_from_pool(emotion, advice_pool)
reply = format_reply(emotion, tip)
return reply, "neutral", emotion, tip, advice_pool
emotion = detect_emotion_text(user_text or "", history)
if save_session:
log_session(country, user_text or "", emotion)
tip, advice_pool = pick_advice_from_pool(emotion, advice_pool)
reply = format_reply(emotion, tip)
return reply, emotion, emotion, tip, advice_pool
# ---------------- UI ----------------
init_db()
with gr.Blocks(title="πŸͺž MoodMirror+") as demo:
gr.Markdown("### πŸͺž MoodMirror+ β€” Emotion-aware advice\n_Not medical advice._")
with gr.Tabs():
# ---- Advice ----
with gr.Tab("Advice"):
with gr.Row():
country = gr.Dropdown(list(CRISIS_NUMBERS_EN.keys()), value="United States", label="Country")
save_ok = gr.Checkbox(False, label="Save anonymized session")
chat = gr.Chatbot(type="tuples", height=380)
msg = gr.Textbox(label="Your message", placeholder="Share how you feel...")
with gr.Row():
send = gr.Button("Send", variant="primary")
regen = gr.Button("πŸ” New advice", variant="secondary")
last_emotion = gr.State("neutral")
last_tip = gr.State("")
advice_pool = gr.State({})
def respond(user_msg, chat_hist, country_choice, save_flag, _emotion, _tip, _pool):
if not user_msg or not user_msg.strip():
return chat_hist + [[user_msg, "Please share how you feel πŸ™‚"]], _emotion, _tip, _pool
reply, _, emotion, tip, _pool = chat_step(
user_msg, chat_hist, country_choice, bool(save_flag), _pool
)
return chat_hist + [[user_msg, reply]], emotion, tip, _pool
def new_advice(chat_hist, _emotion, _tip, _pool):
tip, _pool = pick_advice_from_pool(_emotion, _pool, last_tip=_tip)
reply = format_reply(_emotion, tip)
return chat_hist + [[None, reply]], _emotion, tip, _pool
send.click(
respond,
inputs=[msg, chat, country, save_ok, last_emotion, last_tip, advice_pool],
outputs=[chat, last_emotion, last_tip, advice_pool],
)
msg.submit(
respond,
inputs=[msg, chat, country, save_ok, last_emotion, last_tip, advice_pool],
outputs=[chat, last_emotion, last_tip, advice_pool],
)
regen.click(
new_advice,
inputs=[chat, last_emotion, last_tip, advice_pool],
outputs=[chat, last_emotion, last_tip, advice_pool],
)
# ---- Emergency numbers ----
with gr.Tab("Emergency numbers"):
gr.Markdown("#### πŸ“Ÿ Emergency numbers (English)")
country_view = gr.Dropdown(choices=list(CRISIS_NUMBERS_EN.keys()), value="United States", label="Country")
crisis_info = gr.Markdown(value=crisis_block_en("United States"))
def show_crisis_for_country_en(c): return crisis_block_en(c)
country_view.change(show_crisis_for_country_en, inputs=country_view, outputs=crisis_info)
# ---- Breathing ----
with gr.Tab("Breathing"):
gr.Markdown("#### 🌬️ Guided breathing")
with gr.Row():
pattern = gr.Dropdown(
choices=["4-7-8", "Box (4-4-4-4)", "Coherent (5-5, ~6 breaths/min)"],
value="4-7-8", label="Pattern")
cycles = gr.Slider(1, 10, value=4, step=1, label="Number of cycles")
start_btn = gr.Button("Start", variant="primary")
breathe_out = gr.Markdown()
def _steps_for(p):
if p == "4-7-8": return [("Inhale", 4), ("Hold", 7), ("Exhale", 8)]
elif p.startswith("Box"): return [("Inhale", 4), ("Hold", 4), ("Exhale", 4), ("Hold", 4)]
else: return [("Inhale", 5), ("Exhale", 5)]
def run_breathing(pat, n):
steps = _steps_for(pat)
yield "Starting in 3…"; time.sleep(1)
yield "Starting in 2…"; time.sleep(1)
yield "Starting in 1…"; time.sleep(1)
for c in range(1, int(n) + 1):
for label, secs in steps:
for t in range(secs, 0, -1):
dots = "β€’" * (secs - t + 1)
yield f"**Cycle {c}/{int(n)}** \n**{label}** β€” {t}s \n{dots}"
time.sleep(1)
yield "βœ… Done. Notice how your body feels."
start_btn.click(run_breathing, inputs=[pattern, cycles], outputs=[breathe_out])
# ---- Journal (simple) ----
with gr.Tab("Journal"):
gr.Markdown("#### πŸ“ Journal β€” write, save, export all")
with gr.Row():
j_title = gr.Textbox(label="Title (optional)")
j_emotion = gr.Dropdown(
choices=["neutral","sadness","fear","anger","nervousness","boredom","grief","love","joy","curiosity","gratitude"],
value="neutral", label="Emotion"
)
j_text = gr.Textbox(lines=10, label="Your entry", placeholder="Write whatever you want to remember...")
with gr.Row():
j_save = gr.Button("Save entry", variant="primary")
j_clear = gr.Button("Clear")
j_status = gr.Markdown()
gr.Markdown("##### Your entries")
with gr.Row():
j_search = gr.Textbox(label="Search", placeholder="keyword, emotion, title")
j_refresh = gr.Button("Refresh")
j_entries = gr.Dropdown(label="Entries (newest first)", choices=[], value=None)
j_table = gr.Dataframe(headers=["UTC time","Emotion","Title","Preview"], value=[], interactive=False)
j_view = gr.Markdown()
# export all
j_export_all_btn = gr.Button("⬇️ Export ALL entries (zip)")
j_export_all_file = gr.File(label="Download zip", visible=True)
def _refresh_entries(search):
options, table = journal_list(search or "", 50)
return gr.Dropdown(choices=options, value=None), table
def _save_entry(title, text, emo, search):
ok, msg = journal_save(title, text, emo)
drop, table = _refresh_entries(search)
clear_text = "" if ok else text
clear_title = "" if ok else title
return msg, drop, table, clear_text, clear_title
def _load_entry(entry_id):
if entry_id is None:
return "Select an entry to view it here."
data = journal_get(entry_id)
if not data:
return "Entry not found."
title_line = f"### {data['title']}" if data['title'] else "### (Untitled)"
emo_line = f"**Emotion:** {data['emotion'] or 'β€”'} \n**Saved (UTC):** {data['ts']}"
return f"{title_line}\n\n{emo_line}\n\n---\n\n{data['content']}"
def _export_all():
path, msg = journal_export_all_zip()
return path, msg
j_save.click(_save_entry, inputs=[j_title, j_text, j_emotion, j_search],
outputs=[j_status, j_entries, j_table, j_text, j_title])
j_clear.click(lambda: ("",), outputs=[j_text])
j_refresh.click(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
j_search.submit(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
j_entries.change(_load_entry, inputs=[j_entries], outputs=[j_view])
j_export_all_btn.click(_export_all, outputs=[j_export_all_file, j_status])
if __name__ == "__main__":
demo.queue()
demo.launch()