Spaces:
Sleeping
Sleeping
| # ================================ | |
| # 🪞 MoodMirror+ — Text Emotion • Advice-only + brief intros & reasons | |
| # - Text: GoEmotions (TF-IDF + OneVsRest LR, dataset-only) | |
| # - Always one tip per message; "New advice" gives one different tip | |
| # - Each message is re-analysed (emotion can change each turn) | |
| # - Adds a short intro and a one-line "why it helps" per emotion | |
| # ================================ | |
| import os | |
| import re | |
| import random | |
| import sqlite3 | |
| import joblib | |
| import numpy as np | |
| 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 = "v11-text-only-intro-reason" | |
| # ---------------- 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 = { | |
| "France": "📞 **3114** (Numéro national de prévention du suicide, 24/7)", | |
| "United States": "📞 **988** (Suicide & Crisis Lifeline, 24/7)", | |
| "Canada": "📞 **988** (Suicide Crisis Helpline, 24/7)", | |
| "United Kingdom / ROI": "📞 **116 123** (Samaritans, 24/7)", | |
| "Australia": "📞 **13 11 14** (Lifeline, 24/7)", | |
| "Other / Not listed": "Call local emergency (**112/911**) or search “suicide hotline” for your country.", | |
| } | |
| # ---------------- Advice library (concise, actionable) ---------------- | |
| 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 the feeling on your shoulders.", | |
| "Message a safe person: “Can I vent for 2 minutes?”", | |
| "Eat something simple and drink a big glass of water.", | |
| "Play one song that matches your mood — don’t hide it.", | |
| "Wrap yourself in a blanket and slow your exhale for 60 seconds.", | |
| "List three small things that kept you going today.", | |
| "Tidy one tiny area (corner of the desk or sink).", | |
| "Watch something gentle or nostalgic for 10 minutes.", | |
| "Place a hand on your chest and repeat: “This will pass.”", | |
| "Write a note to your future self: “You made it through today.”", | |
| ], | |
| "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.", | |
| "Turn on a light or soft music to signal safety to your body.", | |
| "Name the fear out loud in one sentence.", | |
| "Hold something cold in your hand for 30 seconds.", | |
| "Tell yourself: “Just the next minute — that’s all.”", | |
| "Stand up and shake out your hands and arms.", | |
| "Write the worst case, then the most likely case beside it.", | |
| "Walk while counting your steps slowly to 100.", | |
| "Mute news/scrolling for the next 30 minutes.", | |
| "Sit with back supported and feet flat; feel the contact points.", | |
| "Repeat softly: “This feeling is temporary.”", | |
| ], | |
| "anger": [ | |
| "Take space before replying; set a 10-minute timer.", | |
| "Do ten slow exhales through pursed lips.", | |
| "Write an uncensored note and delete it afterward.", | |
| "Splash cool water on your face or wrists.", | |
| "Walk fast for five minutes or climb one set of stairs.", | |
| "Name the crossed boundary and craft one calm sentence.", | |
| "Squeeze then release your fists ten times.", | |
| "Clean a small area to discharge energy.", | |
| "Postpone the decision until you feel steady again.", | |
| "Say: “I’m not ready to talk yet; I’ll come back later.”", | |
| "Ask yourself: “What hurt sits under this anger?”", | |
| "Drop your shoulders and unclench your jaw.", | |
| ], | |
| "nervousness": [ | |
| "4-7-8 breathing: in 4s, hold 7s, out 8s (four rounds).", | |
| "Relax your jaw and lower your shoulders.", | |
| "Write worries down; cross out what you can’t control.", | |
| "Pick one tiny action you can finish in five minutes.", | |
| "Trace a square with your finger; breathe in on each side.", | |
| "Walk while matching steps to slower breaths.", | |
| "Sit by daylight or open a window for fresh air.", | |
| "Say: “I can do this one step at a time.”", | |
| "Pause caffeine for the next few hours.", | |
| "Give yourself a 2-minute pause — eyes on one calm point.", | |
| "Hold a warm mug and notice the heat.", | |
| "Stretch your arms overhead and widen your posture.", | |
| ], | |
| "boredom": [ | |
| "Set a 2-minute timer and start anything small.", | |
| "Change your soundtrack — put on one new song.", | |
| "Shift a few objects on your desk for a new view.", | |
| "Read one paragraph on a random topic.", | |
| "Doodle or hum for 90 seconds without judging it.", | |
| "Step outside; look up and find three shapes in the sky.", | |
| "Write five ideas quickly without editing.", | |
| "Do 15 jumping jacks or a quick stretch.", | |
| "Clean your phone screen or keyboard.", | |
| "Try a different drink or snack.", | |
| "Learn one keyboard shortcut you’ll use today.", | |
| "Send a simple “how are you?” to someone.", | |
| ], | |
| "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.", | |
| "Rest without guilt — sorrow is heavy work.", | |
| "Share one memory you want to keep vivid.", | |
| "Let tears come when they need to.", | |
| "Light a candle and sit quietly for two minutes.", | |
| "Walk somewhere meaningful and notice what rises.", | |
| "Create a small ritual to honor them (song, place, phrase).", | |
| "Plan one kind thing for yourself this week.", | |
| "Say: “Missing you means I loved you.”", | |
| "Plant your feet and breathe into your belly.", | |
| ], | |
| "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.", | |
| "Listen fully to someone for one uninterrupted minute.", | |
| "Give a sincere compliment to a stranger.", | |
| "Prepare something with care for someone you value.", | |
| "Say “thank you” out loud for one small thing.", | |
| "Ask a caring question and wait for the answer.", | |
| "Write what love means to you in three lines.", | |
| "Plan a tiny gesture for tomorrow.", | |
| "Look at your face kindly in the mirror for 10 seconds.", | |
| "Let yourself accept help today.", | |
| ], | |
| "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.", | |
| "Notice where the joy sits in your body.", | |
| "Prepare a small treat to celebrate.", | |
| "Write one line starting with “I’m glad that…”.", | |
| "Do one kind act while you feel resourced.", | |
| "Enjoy one minute of quiet appreciation.", | |
| "Plan a tiny celebration later today.", | |
| "Share a smile with someone nearby.", | |
| "Thank yourself for the steps that led here.", | |
| ], | |
| "curiosity": [ | |
| "Search one concept and read just the first paragraph.", | |
| "Ask a question you’ve never asked a friend.", | |
| "Watch a “how does X work?” video for 3 minutes.", | |
| "Write three quick “what if…?” ideas.", | |
| "Take apart a small idea or object (safely) and observe.", | |
| "Teach someone one thing you learned today.", | |
| "Open a random article and summarize it in one line.", | |
| "Try a new route or viewpoint in your space.", | |
| "Learn one new word and use it once.", | |
| "List five topics you’d like to explore.", | |
| "Read one thread in a community you care about.", | |
| "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.", | |
| "Take a photo of a small comfort.", | |
| "Write “I’m lucky that…” and finish it once.", | |
| "Send a short “thinking of you”.", | |
| "Savor your next sip or bite with attention.", | |
| "Name one privilege you have today.", | |
| "Say thank you silently to your body.", | |
| "Share one good thing with a friend.", | |
| "Keep a short gratitude note in your phone.", | |
| "Place a small reminder where you’ll see it tomorrow.", | |
| ], | |
| "neutral": [ | |
| "Take one slow breath and relax your hands.", | |
| "Notice a color, a texture, and a sound around you.", | |
| "Plan one tiny task to finish today.", | |
| "Drink a glass of water mindfully.", | |
| "Stand, stretch, and roll your shoulders.", | |
| "Step outside for two minutes of fresh air.", | |
| "Wipe your screen or desk for a reset.", | |
| "Organize three items in your space.", | |
| "Set a 10-minute timer to focus on one thing.", | |
| "Do a gentle 30-second neck stretch.", | |
| "Check your posture; support your back.", | |
| "Open a window and take three breaths.", | |
| ], | |
| } | |
| # One-line reasons (emotion science, simple & supportive) | |
| WHY_BY_EMOTION = { | |
| "sadness": "Small sensory and connection cues can ease low mood and restore momentum.", | |
| "fear": "Grounding + longer exhales calm the threat system and signal safety.", | |
| "anger": "Space + movement lower adrenaline so you can respond, not react.", | |
| "nervousness": "Slow breathing and micro-actions reduce anxious energy and create control.", | |
| "boredom": "Novelty and tiny starts re-engage attention and kick-off motivation.", | |
| "grief": "Rituals and gentle care help your body carry love and loss together.", | |
| "love": "Expressing and receiving care strengthens bonds and self-kindness.", | |
| "joy": "Savoring and sharing consolidate positive memories and resilience.", | |
| "curiosity": "Small explorations feed learning circuits and open perspective.", | |
| "gratitude": "Noticing support shifts attention toward resources and strengths.", | |
| "neutral": "Simple body care keeps your baseline steady for the rest of the day.", | |
| } | |
| COLOR_MAP = { | |
| "joy": "#FFF9C4", "love": "#F8BBD0", "gratitude": "#FFF176", | |
| "sadness": "#BBDEFB", "grief": "#B3E5FC", | |
| "fear": "#E1BEE7", "nervousness": "#E1BEE7", | |
| "anger": "#FFCCBC", "boredom": "#E0E0E0", | |
| "neutral": "#F5F5F5", "curiosity": "#E6EE9C", | |
| } | |
| # Map 28 GoEmotions -> our UI buckets | |
| 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 & thresholds (text) ---------------- | |
| THRESHOLD_BASE = 0.30 | |
| MIN_THRESHOLD = 0.10 | |
| CLEAN_RE = re.compile(r"(https?://\S+)|(@\w+)|(#\w+)|[^a-zA-Z0-9\s']") | |
| def clean_text(s: str) -> str: | |
| s = s.lower() | |
| s = CLEAN_RE.sub(" ", s) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| return s | |
| EMOJI_HINTS = {"😭": "sadness", "😡": "anger", "🥰": "love", "😨": "fear", "😴": "boredom"} | |
| NEGATION_HINTS_EN = { | |
| "not happy": "sadness", "not ok": "sadness", "no energy": "boredom", | |
| "can't focus": "nervousness", "cannot focus": "nervousness" | |
| } | |
| HINTS_FR = { # lightweight FR cues | |
| "pas bien": "sadness", "triste": "sadness", "j'ai peur": "fear", | |
| "angoisse": "nervousness", "anxieux": "nervousness", | |
| "fatigué": "sadness", "épuisé": "sadness", | |
| } | |
| def augment_text(text: str, history=None) -> str: | |
| """ | |
| Clean + emoji/negation hints. | |
| Re-run detection on EACH message (emotion may change every turn). | |
| Short-context boost only for very short inputs. | |
| """ | |
| t = clean_text(text or "") | |
| hints = [] | |
| for k in EMOJI_HINTS: | |
| if k in (text or ""): | |
| hints.append(EMOJI_HINTS[k]) | |
| for k in NEGATION_HINTS_EN: | |
| if k in t: | |
| hints.append(NEGATION_HINTS_EN[k]) | |
| lt = (text or "").lower() | |
| for k in HINTS_FR: | |
| if k in lt: | |
| hints.append(HINTS_FR[k]) | |
| 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 = t + " " + clean_text(prev_user) | |
| if hints: | |
| t = t + " " + " ".join([f"emo_{h}" for h in hints]) | |
| 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.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() | |
| # ---------------- Text model: GoEmotions dataset-only ---------------- | |
| 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", max_iter=1000, 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 | |
| # ---------------- Inference: TEXT ---------------- | |
| def classify_text(text_augmented: str): | |
| """Return list[(label_name, prob)] with adaptive threshold; fallback top1.""" | |
| 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(MIN_THRESHOLD, THRESHOLD_BASE * 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 selection with pool (no immediate repeats) ---------------- | |
| def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""): | |
| """Pool structure: {emotion: {'unused': [tips], 'last': str}}""" | |
| tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"]) | |
| entry = pool.get(emotion, {"unused": [], "last": ""}) | |
| # Refill when empty, avoid immediate repeat | |
| 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: | |
| """Short intro + one bullet tip + one-line reason (no emotion label shown).""" | |
| why = WHY_BY_EMOTION.get(emotion, WHY_BY_EMOTION["neutral"]) | |
| return f"Try this now:\n• {tip}\n_(Why it helps: {why})_" | |
| # ---------------- Replies ---------------- | |
| def crisis_block(country): | |
| msg = CRISIS_NUMBERS.get(country, CRISIS_NUMBERS["Other / Not listed"]) | |
| return f"💛 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): | |
| # Crisis only on clear text cues | |
| if user_text and CRISIS_RE.search(user_text): | |
| return crisis_block(country), "#FFD6E7", "neutral", "", advice_pool | |
| # Closing minimal (still provides a neutral tip so UX stays consistent) | |
| 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, "#FFFFFF", emotion, tip, advice_pool | |
| # Re-run detection on EACH message (emotion may change every turn) | |
| emotion = detect_emotion_text(user_text or "", history) | |
| color = COLOR_MAP.get(emotion, "#F5F5F5") | |
| 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, color, emotion, tip, advice_pool | |
| # ---------------- UI ---------------- | |
| 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.commit() | |
| conn.close() | |
| init_db() | |
| with gr.Blocks(title="🪞 MoodMirror+ — Text Emotion • Advice-only") as demo: | |
| style = gr.HTML("") | |
| gr.Markdown( | |
| "### 🪞 MoodMirror+ — Emotion-aware advice (text-only)\n" | |
| "Classifier trained on GoEmotions (dataset-only). Each message is analysed anew.\n\n" | |
| "_Not medical advice. If unsafe, please reach out for help._" | |
| ) | |
| with gr.Row(): | |
| country = gr.Dropdown(list(CRISIS_NUMBERS.keys()), value="Other / Not listed", label="Country") | |
| save_ok = gr.Checkbox(False, label="Save anonymized session") | |
| chat = gr.Chatbot(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") | |
| # State: last detected emotion, last tip, and per-emotion advice pool | |
| last_emotion = gr.State("neutral") | |
| last_tip = gr.State("") | |
| advice_pool = gr.State({}) # emotion -> {"unused":[...], "last":""} | |
| 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, color, emotion, tip, _pool = chat_step( | |
| user_msg, chat_hist, country_choice, bool(save_flag), _pool | |
| ) | |
| style_tag = f"<style>:root,body,.gradio-container{{background:{color}!important;}}</style>" | |
| return chat_hist + [[user_msg, reply]], style_tag, 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, style, last_emotion, last_tip, advice_pool], | |
| queue=True | |
| ) | |
| msg.submit( | |
| respond, | |
| inputs=[msg, chat, country, save_ok, last_emotion, last_tip, advice_pool], | |
| outputs=[chat, style, last_emotion, last_tip, advice_pool], | |
| queue=True | |
| ) | |
| regen.click( | |
| new_advice, | |
| inputs=[chat, last_emotion, last_tip, advice_pool], | |
| outputs=[chat, style, last_emotion, last_tip, advice_pool], | |
| queue=True | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue() | |
| demo.launch() | |