cassandrasestier commited on
Commit
a24bbf8
·
verified ·
1 Parent(s): 91719f9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +133 -65
app.py CHANGED
@@ -1,9 +1,9 @@
1
  # ================================
2
- # 🪞 MoodMirror+ — Dataset-only Emotion + Advice
3
- # - GoEmotions (scikit-learn TF-IDF + OneVsRest LR)
4
- # - Advice stored in-code (not from dataset)
5
- # - Always returns exactly one advice (no headers/quotes)
6
- # - "New advice" gives a different tip and cycles the pool
7
  # ================================
8
  import os
9
  import re
@@ -21,6 +21,10 @@ from sklearn.linear_model import LogisticRegression
21
  from sklearn.multiclass import OneVsRestClassifier
22
  from sklearn.pipeline import Pipeline
23
 
 
 
 
 
24
  # ---------------- Storage paths ----------------
25
  def _pick_data_dir():
26
  if os.path.isdir("/data") and os.access("/data", os.W_OK):
@@ -31,7 +35,7 @@ DATA_DIR = _pick_data_dir()
31
  os.makedirs(DATA_DIR, exist_ok=True)
32
  DB_PATH = os.path.join(DATA_DIR, "moodmirror.db")
33
  MODEL_PATH = os.path.join(DATA_DIR, "goemo_sklearn.joblib")
34
- MODEL_VERSION = "v7-prompt-spec"
35
 
36
  # ---------------- Crisis & closing ----------------
37
  CRISIS_RE = re.compile(
@@ -52,7 +56,7 @@ CRISIS_NUMBERS = {
52
  "Other / Not listed": "Call local emergency (**112/911**) or search “suicide hotline” for your country.",
53
  }
54
 
55
- # ---------------- Advice library (concise, actionable; 12+ per emotion) ----------------
56
  SUGGESTIONS = {
57
  "sadness": [
58
  "Go for a 5-minute outside walk and notice three colors.",
@@ -63,7 +67,7 @@ SUGGESTIONS = {
63
  "Listen to a song that matches your mood, not one that hides it.",
64
  "Wrap yourself in a blanket and slow your exhale for 60 seconds.",
65
  "List 3 small things that kept you going today.",
66
- "Tidy one tiny area (desk corner, sink) to regain a sense of control.",
67
  "Watch something gentle/nostalgic for 10 minutes.",
68
  "Place a hand on your chest and say: ‘This will pass.’",
69
  "Write a note to your future self: ‘You made it through this day.’",
@@ -113,7 +117,7 @@ SUGGESTIONS = {
113
  "boredom": [
114
  "Set a 2-minute timer and start anything small.",
115
  "Change your soundtrack; one new song shifts mood.",
116
- "Move furniture or objects slightly — new perspective.",
117
  "Read one paragraph on a random topic.",
118
  "Sketch, doodle, or hum for 90 seconds.",
119
  "Step outside; look up and find three shapes.",
@@ -133,7 +137,7 @@ SUGGESTIONS = {
133
  "Let tears come when they need to.",
134
  "Light a candle and sit quietly for two minutes.",
135
  "Walk somewhere meaningful and notice what you feel.",
136
- "Create a small ritual (song, place, phrase) to honor them.",
137
  "Schedule one kind plan for yourself this week.",
138
  "Say: ‘Missing you means I loved you.’",
139
  "Place your feet firmly and breathe into your belly.",
@@ -144,7 +148,7 @@ SUGGESTIONS = {
144
  "Offer yourself one gentle act you needed today.",
145
  "Listen fully to someone for one minute.",
146
  "Give a sincere compliment to a stranger.",
147
- "Cook or prepare something with care for someone.",
148
  "Say ‘thank you’ out loud for something small.",
149
  "Ask a caring question and wait for the answer.",
150
  "Write what love means to you in 3 lines.",
@@ -177,7 +181,7 @@ SUGGESTIONS = {
177
  "Try a new route or view in your space.",
178
  "Learn a word and use it once.",
179
  "List five topics you’d like to explore.",
180
- "Join a community/forum and read one thread.",
181
  "Sketch a simple diagram of an idea.",
182
  ],
183
  "gratitude": [
@@ -205,7 +209,7 @@ SUGGESTIONS = {
205
  "Organize three items in your space.",
206
  "Set a 10-minute timer to focus on one thing.",
207
  "Do a gentle neck stretch for 30 seconds.",
208
- "Check in with your posture; support your back.",
209
  "Open a window and take three breaths.",
210
  ],
211
  }
@@ -230,7 +234,17 @@ GOEMO_TO_APP = {
230
  "sadness": "sadness", "surprise": "neutral", "neutral": "neutral",
231
  }
232
 
233
- # ---------------- Preprocessing & thresholds ----------------
 
 
 
 
 
 
 
 
 
 
234
  THRESHOLD_BASE = 0.30
235
  MIN_THRESHOLD = 0.10
236
 
@@ -247,39 +261,27 @@ NEGATION_HINTS_EN = {
247
  "not happy": "sadness", "not ok": "sadness", "no energy": "boredom",
248
  "can't focus": "nervousness", "cannot focus": "nervousness"
249
  }
250
- HINTS_FR = {
251
  "pas bien": "sadness", "triste": "sadness", "j'ai peur": "fear",
252
  "angoisse": "nervousness", "anxieux": "nervousness",
253
  "fatigué": "sadness", "épuisé": "sadness",
254
  }
255
 
256
  def augment_text(text: str, history=None) -> str:
257
- """Clean + add emoji/negation hints and short-context when very short."""
258
  t = clean_text(text)
259
  hints = []
260
-
261
- # emoji hints
262
  for k, v in EMOJI_HINTS.items():
263
- if k in text:
264
- hints.append(v)
265
-
266
- # english negations
267
  for k, v in NEGATION_HINTS_EN.items():
268
- if k in t:
269
- hints.append(v)
270
-
271
- # french cues
272
  lt = text.lower()
273
  for k, v in HINTS_FR.items():
274
- if k in lt:
275
- hints.append(v)
276
-
277
- # short-context boost: if message < 8 words, append previous user line
278
  if history and len(t.split()) < 8:
279
  prev_user = history[-1][0] if history and history[-1] else ""
280
  if isinstance(prev_user, str) and prev_user:
281
  t = t + " " + clean_text(prev_user)
282
-
283
  if hints:
284
  t = t + " " + " ".join([f"emo_{h}" for h in hints])
285
  return t
@@ -300,11 +302,11 @@ def init_db():
300
  def log_session(country, msg, emotion):
301
  conn = get_conn()
302
  conn.execute("INSERT INTO sessions(ts,country,user_text,main_emotion)VALUES(?,?,?,?)",
303
- (datetime.utcnow().isoformat(timespec='seconds'), country, msg[:500], emotion))
304
  conn.commit()
305
  conn.close()
306
 
307
- # ---------------- Model: GoEmotions dataset-only ----------------
308
  def load_goemotions_dataset():
309
  ds = load_dataset("google-research-datasets/go_emotions", "simplified")
310
  return ds, ds["train"].features["labels"].feature.names
@@ -316,10 +318,8 @@ def train_or_load_model():
316
  return bundle["pipeline"], bundle["mlb"], bundle["label_names"]
317
  ds, names = load_goemotions_dataset()
318
  X_train, y_train = ds["train"]["text"], ds["train"]["labels"]
319
-
320
  mlb = MultiLabelBinarizer(classes=list(range(len(names))))
321
  Y_train = mlb.fit_transform(y_train)
322
-
323
  clf = Pipeline([
324
  ("tfidf", TfidfVectorizer(lowercase=True, ngram_range=(1,2), min_df=2, max_df=0.9, strip_accents="unicode")),
325
  ("ovr", OneVsRestClassifier(
@@ -328,7 +328,6 @@ def train_or_load_model():
328
  ))
329
  ])
330
  clf.fit(X_train, Y_train)
331
-
332
  joblib.dump({"version": MODEL_VERSION, "pipeline": clf, "mlb": mlb, "label_names": names}, MODEL_PATH)
333
  return clf, mlb, names
334
 
@@ -338,11 +337,10 @@ except Exception as e:
338
  print("[ERROR] Model load/train:", e)
339
  CLASSIFIER, MLB, LABEL_NAMES = None, None, None
340
 
341
- # ---------------- Inference (dataset-only) ----------------
342
  def classify_text(text_augmented: str):
343
- """Return list[(label_name, prob)] using adaptive threshold; fallback top1."""
344
- if not CLASSIFIER:
345
- return []
346
  proba = CLASSIFIER.predict_proba([text_augmented])[0]
347
  max_p = float(np.max(proba)) if len(proba) else 0.0
348
  thr = max(MIN_THRESHOLD, THRESHOLD_BASE * max_p + 0.15)
@@ -350,8 +348,8 @@ def classify_text(text_augmented: str):
350
  idxs.sort(key=lambda i: proba[i], reverse=True)
351
  return [(LABEL_NAMES[i], float(proba[i])) for i in idxs]
352
 
353
- def detect_emotion(message: str, history):
354
- labels = classify_text(augment_text(message, history))
355
  if not labels:
356
  return "neutral"
357
  bucket = {}
@@ -360,18 +358,60 @@ def detect_emotion(message: str, history):
360
  bucket[app] = max(bucket.get(app, 0.0), p)
361
  return max(bucket, key=bucket.get) if bucket else "neutral"
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  # ---------------- Advice selection with pool (no immediate repeats) ----------------
364
  def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""):
365
  """Pool structure: {emotion: {'unused': [tips], 'last': str}}"""
366
  tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"])
367
  entry = pool.get(emotion, {"unused": [], "last": ""})
368
-
369
- # Refill when empty, avoid immediate repeat of last
370
  if not entry["unused"]:
371
- refill = [t for t in tips_all if t != entry.get("last", "")] or tips_all[:]
372
  random.shuffle(refill)
373
  entry["unused"] = refill
374
-
375
  tip = entry["unused"].pop(0)
376
  entry["last"] = tip
377
  pool[emotion] = entry
@@ -382,31 +422,54 @@ def crisis_block(country):
382
  msg = CRISIS_NUMBERS.get(country, CRISIS_NUMBERS["Other / Not listed"])
383
  return f"💛 You matter. If you're in danger or thinking of harming yourself, please reach out now.\n\n{msg}"
384
 
385
- def chat_step(message, history, country, save_session, advice_pool):
386
- if CRISIS_RE.search(message):
 
387
  return crisis_block(country), "#FFD6E7", "neutral", "", advice_pool
388
 
389
- if CLOSING_RE.search(message):
390
- # Gentle closing with one final neutral tip
391
  tip, advice_pool = pick_advice_from_pool("neutral", advice_pool)
392
  return f"• {tip}", "#FFFFFF", "neutral", tip, advice_pool
393
 
394
- emotion = detect_emotion(message, history)
 
 
 
 
 
 
 
 
 
395
  color = COLOR_MAP.get(emotion, "#F5F5F5")
396
  if save_session:
397
- log_session(country, message, emotion)
 
398
 
399
  tip, advice_pool = pick_advice_from_pool(emotion, advice_pool)
400
  return f"• {tip}", color, emotion, tip, advice_pool
401
 
402
  # ---------------- UI ----------------
 
 
 
 
 
 
 
 
 
 
 
 
403
  init_db()
404
 
405
- with gr.Blocks(title="🪞 MoodMirror+ — Emotion & Advice") as demo:
406
  style = gr.HTML("")
407
  gr.Markdown(
408
- "### 🪞 MoodMirror+ — Emotion-aware advice (dataset-only)\n"
409
- "Offline classifier trained on GoEmotions. Always one concrete tip.\n\n"
410
  "_Not medical advice. If unsafe, please reach out for help._"
411
  )
412
 
@@ -414,26 +477,31 @@ with gr.Blocks(title="🪞 MoodMirror+ — Emotion & Advice") as demo:
414
  country = gr.Dropdown(list(CRISIS_NUMBERS.keys()), value="Other / Not listed", label="Country")
415
  save_ok = gr.Checkbox(False, label="Save anonymized session")
416
 
417
- chat = gr.Chatbot(height=380)
418
- msg = gr.Textbox(label="Your message", placeholder="Share how you feel...")
 
 
 
 
 
419
  with gr.Row():
420
  send = gr.Button("Send", variant="primary")
421
  regen = gr.Button("🔁 New advice", variant="secondary")
422
 
423
- # State: last detected emotion, last tip, and per-emotion advice pool
424
  last_emotion = gr.State("neutral")
425
  last_tip = gr.State("")
426
  advice_pool = gr.State({}) # emotion -> {"unused":[...], "last":""}
427
 
428
- def respond(user_msg, chat_hist, country_choice, save_flag, _emotion, _tip, _pool):
429
- if not user_msg or not user_msg.strip():
430
- return chat_hist + [[user_msg, "Please share how you feel 🙂"]], "", _emotion, _tip, _pool
 
431
 
432
  reply, color, emotion, tip, _pool = chat_step(
433
- user_msg, chat_hist, country_choice, bool(save_flag), _pool
434
  )
435
  style_tag = f"<style>:root,body,.gradio-container{{background:{color}!important;}}</style>"
436
- return chat_hist + [[user_msg, reply]], style_tag, emotion, tip, _pool
437
 
438
  def new_advice(chat_hist, _emotion, _tip, _pool):
439
  tip, _pool = pick_advice_from_pool(_emotion, _pool, last_tip=_tip)
@@ -442,13 +510,13 @@ with gr.Blocks(title="🪞 MoodMirror+ — Emotion & Advice") as demo:
442
 
443
  send.click(
444
  respond,
445
- inputs=[msg, chat, country, save_ok, last_emotion, last_tip, advice_pool],
446
  outputs=[chat, style, last_emotion, last_tip, advice_pool],
447
  queue=True
448
  )
449
  msg.submit(
450
  respond,
451
- inputs=[msg, chat, country, save_ok, last_emotion, last_tip, advice_pool],
452
  outputs=[chat, style, last_emotion, last_tip, advice_pool],
453
  queue=True
454
  )
 
1
  # ================================
2
+ # 🪞 MoodMirror+ — Text + Voice Emotion Detector Advice-only
3
+ # - Text: GoEmotions (TF-IDF + OneVsRest LR, dataset-only)
4
+ # - Audio: HF pipeline "firdhokk/speech-emotion-recognition-with-openai-whisper-large-v3"
5
+ # - Advice stored in-code (not from a database)
6
+ # - Always returns exactly one advice; "New advice" avoids duplicates
7
  # ================================
8
  import os
9
  import re
 
21
  from sklearn.multiclass import OneVsRestClassifier
22
  from sklearn.pipeline import Pipeline
23
 
24
+ # Optional audio pipeline (lazy import)
25
+ AUDIO_MODEL_ID = "firdhokk/speech-emotion-recognition-with-openai-whisper-large-v3"
26
+ AUDIO_PIPE = None # loaded on first use
27
+
28
  # ---------------- Storage paths ----------------
29
  def _pick_data_dir():
30
  if os.path.isdir("/data") and os.access("/data", os.W_OK):
 
35
  os.makedirs(DATA_DIR, exist_ok=True)
36
  DB_PATH = os.path.join(DATA_DIR, "moodmirror.db")
37
  MODEL_PATH = os.path.join(DATA_DIR, "goemo_sklearn.joblib")
38
+ MODEL_VERSION = "v9-text+audio"
39
 
40
  # ---------------- Crisis & closing ----------------
41
  CRISIS_RE = re.compile(
 
56
  "Other / Not listed": "Call local emergency (**112/911**) or search “suicide hotline” for your country.",
57
  }
58
 
59
+ # ---------------- Advice library (concise, actionable) ----------------
60
  SUGGESTIONS = {
61
  "sadness": [
62
  "Go for a 5-minute outside walk and notice three colors.",
 
67
  "Listen to a song that matches your mood, not one that hides it.",
68
  "Wrap yourself in a blanket and slow your exhale for 60 seconds.",
69
  "List 3 small things that kept you going today.",
70
+ "Tidy one tiny area to regain a sense of control.",
71
  "Watch something gentle/nostalgic for 10 minutes.",
72
  "Place a hand on your chest and say: ‘This will pass.’",
73
  "Write a note to your future self: ‘You made it through this day.’",
 
117
  "boredom": [
118
  "Set a 2-minute timer and start anything small.",
119
  "Change your soundtrack; one new song shifts mood.",
120
+ "Move objects slightly — new perspective.",
121
  "Read one paragraph on a random topic.",
122
  "Sketch, doodle, or hum for 90 seconds.",
123
  "Step outside; look up and find three shapes.",
 
137
  "Let tears come when they need to.",
138
  "Light a candle and sit quietly for two minutes.",
139
  "Walk somewhere meaningful and notice what you feel.",
140
+ "Create a small ritual to honor them.",
141
  "Schedule one kind plan for yourself this week.",
142
  "Say: ‘Missing you means I loved you.’",
143
  "Place your feet firmly and breathe into your belly.",
 
148
  "Offer yourself one gentle act you needed today.",
149
  "Listen fully to someone for one minute.",
150
  "Give a sincere compliment to a stranger.",
151
+ "Prepare something with care for someone.",
152
  "Say ‘thank you’ out loud for something small.",
153
  "Ask a caring question and wait for the answer.",
154
  "Write what love means to you in 3 lines.",
 
181
  "Try a new route or view in your space.",
182
  "Learn a word and use it once.",
183
  "List five topics you’d like to explore.",
184
+ "Join a community and read one thread.",
185
  "Sketch a simple diagram of an idea.",
186
  ],
187
  "gratitude": [
 
209
  "Organize three items in your space.",
210
  "Set a 10-minute timer to focus on one thing.",
211
  "Do a gentle neck stretch for 30 seconds.",
212
+ "Check your posture; support your back.",
213
  "Open a window and take three breaths.",
214
  ],
215
  }
 
234
  "sadness": "sadness", "surprise": "neutral", "neutral": "neutral",
235
  }
236
 
237
+ # Audio model label -> buckets (best effort; covers typical SER labels)
238
+ AUDIO_LABEL_TO_BUCKET = {
239
+ "anger": "anger", "angry": "anger", "disgust": "anger",
240
+ "fear": "fear", "scared": "fear", "afraid": "fear",
241
+ "happy": "joy", "happiness": "joy", "joy": "joy",
242
+ "sad": "sadness", "sadness": "sadness",
243
+ "neutral": "neutral", "surprise": "neutral", "calm": "neutral",
244
+ "boredom": "boredom", "love": "love"
245
+ }
246
+
247
+ # ---------------- Preprocessing & thresholds (text) ----------------
248
  THRESHOLD_BASE = 0.30
249
  MIN_THRESHOLD = 0.10
250
 
 
261
  "not happy": "sadness", "not ok": "sadness", "no energy": "boredom",
262
  "can't focus": "nervousness", "cannot focus": "nervousness"
263
  }
264
+ HINTS_FR = { # lightweight FR cues (dataset stays EN)
265
  "pas bien": "sadness", "triste": "sadness", "j'ai peur": "fear",
266
  "angoisse": "nervousness", "anxieux": "nervousness",
267
  "fatigué": "sadness", "épuisé": "sadness",
268
  }
269
 
270
  def augment_text(text: str, history=None) -> str:
271
+ """Clean + emoji/negation hints + short-context when short."""
272
  t = clean_text(text)
273
  hints = []
 
 
274
  for k, v in EMOJI_HINTS.items():
275
+ if k in text: hints.append(v)
 
 
 
276
  for k, v in NEGATION_HINTS_EN.items():
277
+ if k in t: hints.append(v)
 
 
 
278
  lt = text.lower()
279
  for k, v in HINTS_FR.items():
280
+ if k in lt: hints.append(v)
 
 
 
281
  if history and len(t.split()) < 8:
282
  prev_user = history[-1][0] if history and history[-1] else ""
283
  if isinstance(prev_user, str) and prev_user:
284
  t = t + " " + clean_text(prev_user)
 
285
  if hints:
286
  t = t + " " + " ".join([f"emo_{h}" for h in hints])
287
  return t
 
302
  def log_session(country, msg, emotion):
303
  conn = get_conn()
304
  conn.execute("INSERT INTO sessions(ts,country,user_text,main_emotion)VALUES(?,?,?,?)",
305
+ (datetime.utcnow().isoformat(timespec='seconds'), country, (msg or "")[:500], emotion))
306
  conn.commit()
307
  conn.close()
308
 
309
+ # ---------------- Text model: GoEmotions dataset-only ----------------
310
  def load_goemotions_dataset():
311
  ds = load_dataset("google-research-datasets/go_emotions", "simplified")
312
  return ds, ds["train"].features["labels"].feature.names
 
318
  return bundle["pipeline"], bundle["mlb"], bundle["label_names"]
319
  ds, names = load_goemotions_dataset()
320
  X_train, y_train = ds["train"]["text"], ds["train"]["labels"]
 
321
  mlb = MultiLabelBinarizer(classes=list(range(len(names))))
322
  Y_train = mlb.fit_transform(y_train)
 
323
  clf = Pipeline([
324
  ("tfidf", TfidfVectorizer(lowercase=True, ngram_range=(1,2), min_df=2, max_df=0.9, strip_accents="unicode")),
325
  ("ovr", OneVsRestClassifier(
 
328
  ))
329
  ])
330
  clf.fit(X_train, Y_train)
 
331
  joblib.dump({"version": MODEL_VERSION, "pipeline": clf, "mlb": mlb, "label_names": names}, MODEL_PATH)
332
  return clf, mlb, names
333
 
 
337
  print("[ERROR] Model load/train:", e)
338
  CLASSIFIER, MLB, LABEL_NAMES = None, None, None
339
 
340
+ # ---------------- Inference: TEXT ----------------
341
  def classify_text(text_augmented: str):
342
+ """Return list[(label_name, prob)] with adaptive threshold; fallback top1."""
343
+ if not CLASSIFIER: return []
 
344
  proba = CLASSIFIER.predict_proba([text_augmented])[0]
345
  max_p = float(np.max(proba)) if len(proba) else 0.0
346
  thr = max(MIN_THRESHOLD, THRESHOLD_BASE * max_p + 0.15)
 
348
  idxs.sort(key=lambda i: proba[i], reverse=True)
349
  return [(LABEL_NAMES[i], float(proba[i])) for i in idxs]
350
 
351
+ def detect_emotion_text(message: str, history):
352
+ labels = classify_text(augment_text(message or "", history))
353
  if not labels:
354
  return "neutral"
355
  bucket = {}
 
358
  bucket[app] = max(bucket.get(app, 0.0), p)
359
  return max(bucket, key=bucket.get) if bucket else "neutral"
360
 
361
+ # ---------------- Inference: AUDIO ----------------
362
+ def get_audio_pipe():
363
+ global AUDIO_PIPE
364
+ if AUDIO_PIPE is not None:
365
+ return AUDIO_PIPE
366
+ try:
367
+ from transformers import pipeline as hf_pipeline
368
+ AUDIO_PIPE = hf_pipeline("audio-classification", model=AUDIO_MODEL_ID)
369
+ except Exception as e:
370
+ print("[WARN] Audio pipeline not available:", e)
371
+ AUDIO_PIPE = None
372
+ return AUDIO_PIPE
373
+
374
+ def detect_emotion_audio(audio_np_tuple):
375
+ """
376
+ Gradio Audio (type='numpy') returns (sample_rate:int, data:np.ndarray) or None.
377
+ We return a bucket string or 'neutral' if unavailable.
378
+ """
379
+ if not audio_np_tuple:
380
+ return None # signal to fallback
381
+ sr, data = audio_np_tuple
382
+ if data is None or (isinstance(data, np.ndarray) and data.size == 0):
383
+ return None
384
+ pipe = get_audio_pipe()
385
+ if pipe is None:
386
+ return None
387
+ try:
388
+ # transformers pipeline accepts (sr, data) tuple directly for recent versions
389
+ # If needed, you can pass a dict: {"array": data, "sampling_rate": sr}
390
+ out = pipe({"array": data, "sampling_rate": int(sr)})
391
+ # out is list of dicts sorted by score desc: [{"label":"happy","score":0.8},...]
392
+ if not out:
393
+ return None
394
+ top = out[0]["label"].lower()
395
+ # map to bucket
396
+ for k, v in AUDIO_LABEL_TO_BUCKET.items():
397
+ if k in top:
398
+ return v
399
+ # fallback guess
400
+ return "neutral"
401
+ except Exception as e:
402
+ print("[WARN] Audio infer failed:", e)
403
+ return None
404
+
405
  # ---------------- Advice selection with pool (no immediate repeats) ----------------
406
  def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""):
407
  """Pool structure: {emotion: {'unused': [tips], 'last': str}}"""
408
  tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"])
409
  entry = pool.get(emotion, {"unused": [], "last": ""})
410
+ # Refill when empty, avoid immediate repeat
 
411
  if not entry["unused"]:
412
+ refill = [t for t in tips_all if t != entry.get("last","")] or tips_all[:]
413
  random.shuffle(refill)
414
  entry["unused"] = refill
 
415
  tip = entry["unused"].pop(0)
416
  entry["last"] = tip
417
  pool[emotion] = entry
 
422
  msg = CRISIS_NUMBERS.get(country, CRISIS_NUMBERS["Other / Not listed"])
423
  return f"💛 You matter. If you're in danger or thinking of harming yourself, please reach out now.\n\n{msg}"
424
 
425
+ def chat_step(user_text, user_audio, history, country, save_session, advice_pool):
426
+ # Crisis only on clear text cues
427
+ if user_text and CRISIS_RE.search(user_text):
428
  return crisis_block(country), "#FFD6E7", "neutral", "", advice_pool
429
 
430
+ # Closing minimal
431
+ if user_text and CLOSING_RE.search(user_text):
432
  tip, advice_pool = pick_advice_from_pool("neutral", advice_pool)
433
  return f"• {tip}", "#FFFFFF", "neutral", tip, advice_pool
434
 
435
+ # Prefer audio if provided; else text; else neutral
436
+ emotion = None
437
+ audio_bucket = detect_emotion_audio(user_audio)
438
+ if audio_bucket:
439
+ emotion = audio_bucket
440
+ elif user_text and user_text.strip():
441
+ emotion = detect_emotion_text(user_text, history)
442
+ else:
443
+ emotion = "neutral"
444
+
445
  color = COLOR_MAP.get(emotion, "#F5F5F5")
446
  if save_session:
447
+ # store text (if any) so DB stays light
448
+ log_session(country, user_text or "[audio only]", emotion)
449
 
450
  tip, advice_pool = pick_advice_from_pool(emotion, advice_pool)
451
  return f"• {tip}", color, emotion, tip, advice_pool
452
 
453
  # ---------------- UI ----------------
454
+ def get_conn():
455
+ return sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
456
+
457
+ def init_db():
458
+ conn = get_conn()
459
+ conn.execute("""CREATE TABLE IF NOT EXISTS sessions(
460
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
461
+ ts TEXT, country TEXT, user_text TEXT, main_emotion TEXT
462
+ )""")
463
+ conn.commit()
464
+ conn.close()
465
+
466
  init_db()
467
 
468
+ with gr.Blocks(title="🪞 MoodMirror+ — Text & Voice Emotion • Advice-only") as demo:
469
  style = gr.HTML("")
470
  gr.Markdown(
471
+ "### 🪞 MoodMirror+ — Emotion-aware advice (text + voice)\n"
472
+ "Text model: GoEmotions (dataset-only). Voice model: speech emotion pipeline.\n\n"
473
  "_Not medical advice. If unsafe, please reach out for help._"
474
  )
475
 
 
477
  country = gr.Dropdown(list(CRISIS_NUMBERS.keys()), value="Other / Not listed", label="Country")
478
  save_ok = gr.Checkbox(False, label="Save anonymized session")
479
 
480
+ chat = gr.Chatbot(height=400)
481
+
482
+ with gr.Row():
483
+ msg = gr.Textbox(label="Your message (text)", placeholder="Share how you feel...")
484
+ with gr.Row():
485
+ audio = gr.Audio(sources=["microphone", "upload"], type="numpy", label="Or speak (optional)")
486
+
487
  with gr.Row():
488
  send = gr.Button("Send", variant="primary")
489
  regen = gr.Button("🔁 New advice", variant="secondary")
490
 
 
491
  last_emotion = gr.State("neutral")
492
  last_tip = gr.State("")
493
  advice_pool = gr.State({}) # emotion -> {"unused":[...], "last":""}
494
 
495
+ def respond(user_msg, user_audio, chat_hist, country_choice, save_flag, _emotion, _tip, _pool):
496
+ # If both text and audio empty:
497
+ if (not user_msg or not user_msg.strip()) and (user_audio is None):
498
+ return chat_hist + [[user_msg, "Please share a short message or record audio 🙂"]], "", _emotion, _tip, _pool
499
 
500
  reply, color, emotion, tip, _pool = chat_step(
501
+ user_msg, user_audio, chat_hist, country_choice, bool(save_flag), _pool
502
  )
503
  style_tag = f"<style>:root,body,.gradio-container{{background:{color}!important;}}</style>"
504
+ return chat_hist + [[user_msg if user_msg else "[voice]", reply]], style_tag, emotion, tip, _pool
505
 
506
  def new_advice(chat_hist, _emotion, _tip, _pool):
507
  tip, _pool = pick_advice_from_pool(_emotion, _pool, last_tip=_tip)
 
510
 
511
  send.click(
512
  respond,
513
+ inputs=[msg, audio, chat, country, save_ok, last_emotion, last_tip, advice_pool],
514
  outputs=[chat, style, last_emotion, last_tip, advice_pool],
515
  queue=True
516
  )
517
  msg.submit(
518
  respond,
519
+ inputs=[msg, audio, chat, country, save_ok, last_emotion, last_tip, advice_pool],
520
  outputs=[chat, style, last_emotion, last_tip, advice_pool],
521
  queue=True
522
  )