cassandrasestier commited on
Commit
2c8d91a
·
verified ·
1 Parent(s): 5677abd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +176 -94
app.py CHANGED
@@ -1,7 +1,6 @@
1
  # ================================
2
- # 🪞 MoodMirror+ — Text Emotion Advice-only + brief intros & reasons
3
- # - Tabs: Advice • Emergency numbers • Breathing • Journal (edit + PDF export + export all)
4
- # - Gradio + sklearn compatibility fixes
5
  # ================================
6
  import os
7
  import re
@@ -21,7 +20,7 @@ from sklearn.linear_model import LogisticRegression
21
  from sklearn.multiclass import OneVsRestClassifier
22
  from sklearn.pipeline import Pipeline
23
 
24
- # --- Optional PDF deps ---
25
  try:
26
  from reportlab.lib.pagesizes import A4
27
  from reportlab.pdfgen import canvas
@@ -40,7 +39,7 @@ DATA_DIR = _pick_data_dir()
40
  os.makedirs(DATA_DIR, exist_ok=True)
41
  DB_PATH = os.path.join(DATA_DIR, "moodmirror.db")
42
  MODEL_PATH = os.path.join(DATA_DIR, "goemo_sklearn.joblib")
43
- MODEL_VERSION = "v11-text-only-intro-reason"
44
 
45
  # ---------------- Crisis & closing ----------------
46
  CRISIS_RE = re.compile(
@@ -52,7 +51,7 @@ CLOSING_RE = re.compile(
52
  re.I,
53
  )
54
 
55
- # English crisis numbers (expanded)
56
  CRISIS_NUMBERS_EN = {
57
  "United States": "📞 **988** (Suicide & Crisis Lifeline, 24/7)",
58
  "Canada": "📞 **988** (Suicide Crisis Helpline, 24/7)",
@@ -72,19 +71,85 @@ CRISIS_NUMBERS_EN = {
72
  "Other / Not listed": "Call local emergency (**112/911**) or search “suicide hotline” + your country.",
73
  }
74
 
75
- # ---------------- Advice library (concise) ----------------
76
  SUGGESTIONS = {
77
- "sadness": ["Go for a 5-minute outside walk and name three colors you see."],
78
- "fear": ["Do 5-4-3-2-1 grounding: 5 see, 4 feel, 3 hear, 2 smell, 1 taste."],
79
- "anger": ["Take space before replying; set a 10-minute timer."],
80
- "nervousness": ["4-7-8 breathing: in 4s, hold 7s, out 8s (four rounds)."],
81
- "boredom": ["Set a 2-minute timer and start anything small."],
82
- "grief": ["Hold a photo or object and say their name softly."],
83
- "love": ["Send a kind message without expecting a reply."],
84
- "joy": ["Pause and take three slow breaths to savor this."],
85
- "curiosity": ["Search one concept and read just the first paragraph."],
86
- "gratitude": ["List three tiny things that made today easier."],
87
- "neutral": ["Take one slow breath and relax your hands."],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
  WHY_BY_EMOTION = {
@@ -109,15 +174,51 @@ COLOR_MAP = {
109
  "neutral": "#F5F5F5", "curiosity": "#E6EE9C",
110
  }
111
 
 
112
  GOEMO_TO_APP = {
113
- "sadness": "sadness", "joy": "joy", "fear": "fear", "anger": "anger", "neutral": "neutral",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
- # ---------------- Preprocessing ----------------
117
- THRESHOLD_BASE = 0.30
118
- MIN_THRESHOLD = 0.10
119
  CLEAN_RE = re.compile(r"(https?://\S+)|(@\w+)|(#\w+)|[^a-zA-Z0-9\s']")
120
 
 
 
 
 
 
 
 
 
 
 
121
  def clean_text(s: str) -> str:
122
  s = s.lower()
123
  s = CLEAN_RE.sub(" ", s)
@@ -125,7 +226,22 @@ def clean_text(s: str) -> str:
125
  return s
126
 
127
  def augment_text(text: str, history=None) -> str:
128
- return clean_text(text or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  # ---------------- SQLite ----------------
131
  def get_conn():
@@ -133,18 +249,13 @@ def get_conn():
133
 
134
  def init_db():
135
  conn = get_conn()
136
- # sessions
137
  conn.execute("""CREATE TABLE IF NOT EXISTS sessions(
138
  id INTEGER PRIMARY KEY AUTOINCREMENT,
139
  ts TEXT, country TEXT, user_text TEXT, main_emotion TEXT
140
  )""")
141
- # journal
142
  conn.execute("""CREATE TABLE IF NOT EXISTS journal(
143
  id INTEGER PRIMARY KEY AUTOINCREMENT,
144
- ts TEXT NOT NULL,
145
- emotion TEXT,
146
- title TEXT,
147
- content TEXT
148
  )""")
149
  conn.commit()
150
  conn.close()
@@ -165,7 +276,7 @@ def journal_save(title: str, content: str, emotion: str):
165
  ts = datetime.utcnow().isoformat(timespec='seconds')
166
  conn = get_conn()
167
  conn.execute("INSERT INTO journal(ts, emotion, title, content) VALUES (?,?,?,?)",
168
- (ts, emotion or "", title, content))
169
  conn.commit()
170
  conn.close()
171
  return True, f"Saved ✓ ({ts} UTC)."
@@ -175,15 +286,10 @@ def journal_list(search: str = "", limit: int = 50):
175
  params = []
176
  if search:
177
  q += " WHERE (LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(emotion) LIKE ?)"
178
- s = f"%{search.lower()}%"
179
- params = [s, s, s]
180
- q += " ORDER BY ts DESC LIMIT ?"
181
- params.append(int(limit))
182
- conn = get_conn()
183
- rows = list(conn.execute(q, params))
184
- conn.close()
185
- options = []
186
- table = []
187
  for (id_, ts, emo, title, content) in rows:
188
  label = f"{ts} — [{(emo or 'neutral')}] {title or (content[:30] + ('…' if len(content) > 30 else ''))}"
189
  options.append((label, id_))
@@ -195,34 +301,28 @@ def journal_list(search: str = "", limit: int = 50):
195
  def journal_get(entry_id: int):
196
  conn = get_conn()
197
  cur = conn.execute("SELECT ts, emotion, title, content FROM journal WHERE id = ?", (int(entry_id),))
198
- row = cur.fetchone()
199
- conn.close()
200
  if not row: return None
201
  ts, emo, title, content = row
202
  return {"ts": ts, "emotion": emo or "", "title": title or "", "content": content or ""}
203
 
204
  def journal_update(entry_id: int, title: str, content: str, emotion: str):
205
  if entry_id is None: return False, "No entry selected."
206
- title = (title or "").strip()
207
- content = (content or "").strip()
208
- if not content:
209
- return False, "Entry content cannot be empty."
210
  conn = get_conn()
211
- cur = conn.execute("UPDATE journal SET title=?, content=?, emotion=? WHERE id=?", (title, content, emotion or "", int(entry_id)))
212
- conn.commit()
213
- ok = (cur.rowcount or 0) > 0
214
- conn.close()
215
  return ok, ("Updated ✓" if ok else "Entry not found.")
216
 
217
  def journal_delete(entry_id: int):
218
  conn = get_conn()
219
  cur = conn.execute("DELETE FROM journal WHERE id = ?", (int(entry_id),))
220
- conn.commit()
221
- changes = conn.total_changes
222
- conn.close()
223
  return changes > 0
224
 
225
- # --- PDF helpers ---
226
  def _wrap_text(text, max_chars=90):
227
  lines = []
228
  for para in (text or "").split("\n"):
@@ -230,42 +330,32 @@ def _wrap_text(text, max_chars=90):
230
  while len(para) > max_chars:
231
  cut = para.rfind(" ", 0, max_chars)
232
  if cut == -1: cut = max_chars
233
- lines.append(para[:cut])
234
- para = para[cut:].lstrip()
235
  lines.append(para)
236
  return lines
237
 
238
  def _pdf_from_entry(path, data):
239
- # Minimal PDF render with reportlab
240
  c = canvas.Canvas(path, pagesize=A4)
241
  width, height = A4
242
- x_margin = 20*mm
243
- y_margin = 20*mm
244
- y = height - y_margin
245
  def draw_line(s, size=11, bold=False, leading=14):
246
  nonlocal y
247
  if y < 30*mm:
248
- c.showPage()
249
- y = height - y_margin
250
  c.setFont("Helvetica-Bold" if bold else "Helvetica", size)
251
- c.drawString(x_margin, y, s)
252
- y -= leading
253
- # Header
254
  title = data["title"] or "(Untitled)"
255
  draw_line(f"Title: {title}", size=14, bold=True, leading=18)
256
  draw_line(f"Emotion: {data['emotion'] or '-'}")
257
  draw_line(f"Saved (UTC): {data['ts']}")
258
  draw_line("-"*80)
259
- # Body
260
  for ln in _wrap_text(data["content"], 95):
261
  draw_line(ln)
262
- c.showPage()
263
- c.save()
264
 
265
  def journal_export_txt(entry_id: int):
266
  data = journal_get(entry_id)
267
- if not data:
268
- return None, "Entry not found."
269
  fname = f"journal_{entry_id}_{data['ts'].replace(':','-')}.txt"
270
  fpath = os.path.join(DATA_DIR, fname)
271
  lines = [
@@ -275,16 +365,14 @@ def journal_export_txt(entry_id: int):
275
  "-" * 40,
276
  data["content"] or ""
277
  ]
278
- with open(fpath, "w", encoding="utf-8") as f:
279
- f.write("\n".join(lines))
280
  return fpath, f"Ready: {fname}"
281
 
282
  def journal_export_pdf(entry_id: int):
283
  if not REPORTLAB_OK:
284
  return None, "PDF export requires 'reportlab' (pip install reportlab)."
285
  data = journal_get(entry_id)
286
- if not data:
287
- return None, "Entry not found."
288
  safe_title = re.sub(r"[^a-zA-Z0-9_\- ]", "_", data["title"] or "untitled")
289
  fname = f"journal_{entry_id}_{data['ts'][:19].replace(':','-')} - {safe_title}.pdf"
290
  fpath = os.path.join(DATA_DIR, fname)
@@ -292,27 +380,20 @@ def journal_export_pdf(entry_id: int):
292
  return fpath, f"PDF ready: {fname}"
293
 
294
  def journal_export_all_zip(pdf_first=True):
295
- conn = get_conn()
296
- rows = list(conn.execute("SELECT id FROM journal ORDER BY ts"))
297
- conn.close()
298
- if not rows:
299
- return None, "No entries to export."
300
- # Try PDFs if requested & available, else fall back to txt
301
  use_pdf = pdf_first and REPORTLAB_OK
302
  zip_name = os.path.join(DATA_DIR, "journal_all.zip")
303
  with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as zf:
304
  for (entry_id,) in rows:
305
  data = journal_get(entry_id)
306
- if not data:
307
- continue
308
  safe_title = re.sub(r"[^a-zA-Z0-9_\- ]", "_", data["title"] or "untitled")
309
  stamp = data["ts"][:19].replace(":", "-")
310
  if use_pdf:
311
- # write to a temp pdf then add
312
  tmp_pdf = os.path.join(DATA_DIR, f"_tmp_{entry_id}.pdf")
313
  _pdf_from_entry(tmp_pdf, data)
314
- arcname = f"{stamp} - {safe_title}.pdf"
315
- zf.write(tmp_pdf, arcname=arcname)
316
  try: os.remove(tmp_pdf)
317
  except: pass
318
  else:
@@ -323,12 +404,11 @@ def journal_export_all_zip(pdf_first=True):
323
  f"{'-'*40}\n"
324
  f"{data['content'] or ''}"
325
  )
326
- arcname = f"{stamp} - {safe_title}.txt"
327
- zf.writestr(arcname, txt)
328
  msg = "Exported all as PDF." if use_pdf else "Exported all as TXT (install 'reportlab' for PDF)."
329
  return zip_name, msg
330
 
331
- # ---------------- Model ----------------
332
  def load_goemotions_dataset():
333
  ds = load_dataset("google-research-datasets/go_emotions", "simplified")
334
  return ds, ds["train"].features["labels"].feature.names
@@ -346,7 +426,12 @@ def train_or_load_model():
346
  ("tfidf", TfidfVectorizer(lowercase=True, ngram_range=(1,2), min_df=2, max_df=0.9, strip_accents="unicode")),
347
  ("ovr", OneVsRestClassifier(
348
  LogisticRegression(
349
- solver="saga", penalty="l2", C=0.5, tol=1e-3, max_iter=5000, class_weight="balanced"
 
 
 
 
 
350
  ),
351
  n_jobs=-1
352
  ))
@@ -365,22 +450,21 @@ def classify_text(text_augmented: str):
365
  if not CLASSIFIER: return []
366
  proba = CLASSIFIER.predict_proba([text_augmented])[0]
367
  max_p = float(np.max(proba)) if len(proba) else 0.0
368
- thr = max(MIN_THRESHOLD, THRESHOLD_BASE * max_p + 0.15)
369
  idxs = [i for i, p in enumerate(proba) if p >= thr] or [int(np.argmax(proba))]
370
  idxs.sort(key=lambda i: proba[i], reverse=True)
371
  return [(LABEL_NAMES[i], float(proba[i])) for i in idxs]
372
 
373
  def detect_emotion_text(message: str, history):
374
  labels = classify_text(augment_text(message, history))
375
- if not labels:
376
- return "neutral"
377
  bucket = {}
378
  for lbl, p in labels:
379
  app = GOEMO_TO_APP.get(lbl.lower(), "neutral")
380
  bucket[app] = max(bucket.get(app, 0.0), p)
381
  return max(bucket, key=bucket.get) if bucket else "neutral"
382
 
383
- # ---------------- Advice logic ----------------
384
  def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""):
385
  tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"])
386
  entry = pool.get(emotion, {"unused": [], "last": ""})
@@ -554,7 +638,6 @@ with gr.Blocks(title="🪞 MoodMirror+ — Text Emotion • Advice-only") as dem
554
  return msg, drop, table
555
 
556
  def _load_entry_fill(entry_id):
557
- """Load selected entry into editor + preview."""
558
  if entry_id is None:
559
  return "", "neutral", "", ""
560
  data = journal_get(entry_id)
@@ -606,7 +689,6 @@ with gr.Blocks(title="🪞 MoodMirror+ — Text Emotion • Advice-only") as dem
606
  j_refresh.click(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
607
  j_search.submit(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
608
 
609
- # Selecting an entry both fills the editor and shows preview
610
  j_entries.change(_load_entry_fill, inputs=[j_entries],
611
  outputs=[j_title, j_emotion, j_text, j_view])
612
 
 
1
  # ================================
2
+ # 🪞 MoodMirror+ — Emotion-aware Advice Breathing Journal
3
+ # - Tabs: Advice • Emergency numbers • Breathing • Journal (edit + PDF + export all)
 
4
  # ================================
5
  import os
6
  import re
 
20
  from sklearn.multiclass import OneVsRestClassifier
21
  from sklearn.pipeline import Pipeline
22
 
23
+ # ---- Optional PDF deps ----
24
  try:
25
  from reportlab.lib.pagesizes import A4
26
  from reportlab.pdfgen import canvas
 
39
  os.makedirs(DATA_DIR, exist_ok=True)
40
  DB_PATH = os.path.join(DATA_DIR, "moodmirror.db")
41
  MODEL_PATH = os.path.join(DATA_DIR, "goemo_sklearn.joblib")
42
+ MODEL_VERSION = "v13-all-maps-hints"
43
 
44
  # ---------------- Crisis & closing ----------------
45
  CRISIS_RE = re.compile(
 
51
  re.I,
52
  )
53
 
54
+ # English crisis numbers
55
  CRISIS_NUMBERS_EN = {
56
  "United States": "📞 **988** (Suicide & Crisis Lifeline, 24/7)",
57
  "Canada": "📞 **988** (Suicide Crisis Helpline, 24/7)",
 
71
  "Other / Not listed": "Call local emergency (**112/911**) or search “suicide hotline” + your country.",
72
  }
73
 
74
+ # ---------------- Advice library (5 tips each) ----------------
75
  SUGGESTIONS = {
76
+ "sadness": [
77
+ "Go for a 5-minute outside walk and name three colors you see.",
78
+ "Write what hurts, then add one thing you still care about.",
79
+ "Take a warm shower and focus on your shoulders relaxing.",
80
+ "Text a safe person: “Can I vent for 2 minutes?”",
81
+ "Wrap in a blanket and slow your exhale for 60 seconds.",
82
+ ],
83
+ "fear": [
84
+ "Do 5-4-3-2-1 grounding: 5 see, 4 feel, 3 hear, 2 smell, 1 taste.",
85
+ "Make your exhale longer than your inhale for eight breaths.",
86
+ "Hold something cool (spoon/ice) for 30 seconds and notice the sensation.",
87
+ "Name the fear in one clear sentence out loud.",
88
+ "Write the worst case, then the most likely case beside it.",
89
+ ],
90
+ "anger": [
91
+ "Take space before replying; set a 10-minute timer.",
92
+ "Do ten slow exhales through pursed lips.",
93
+ "Squeeze then release your fists ten times.",
94
+ "Walk fast for five minutes or do one stair flight.",
95
+ "Write the crossed boundary; draft one calm sentence.",
96
+ ],
97
+ "nervousness": [
98
+ "4-7-8 breathing: in 4s, hold 7s, out 8s (four rounds).",
99
+ "Relax your jaw and lower your shoulders.",
100
+ "Write worries down; underline what you can control.",
101
+ "Pick one tiny task you can finish in five minutes.",
102
+ "Hold a warm mug and notice the heat and weight.",
103
+ ],
104
+ "boredom": [
105
+ "Set a 2-minute timer and start anything small.",
106
+ "Change your soundtrack—put on one new song.",
107
+ "Do 15 jumping jacks or a quick stretch.",
108
+ "Clean your phone screen or keyboard.",
109
+ "Write five quick ideas without editing.",
110
+ ],
111
+ "grief": [
112
+ "Hold a photo or object and say their name softly.",
113
+ "Drink water and eat something—your body grieves too.",
114
+ "Write a short letter to them about today.",
115
+ "Create a tiny ritual (song, candle, place).",
116
+ "Plan one kind thing for yourself this week.",
117
+ ],
118
+ "love": [
119
+ "Send a kind message without expecting a reply.",
120
+ "Note three things you appreciate about someone close.",
121
+ "Offer yourself one gentle act you needed today.",
122
+ "Give a sincere compliment to a stranger.",
123
+ "Plan a tiny gesture for tomorrow.",
124
+ ],
125
+ "joy": [
126
+ "Pause and take three slow breaths to savor this.",
127
+ "Capture it—photo, note, or voice memo.",
128
+ "Tell someone why you feel good right now.",
129
+ "Move to music for one song.",
130
+ "Plan a tiny celebration later today.",
131
+ ],
132
+ "curiosity": [
133
+ "Search one concept and read just the first paragraph.",
134
+ "Write three quick “what if…?” ideas.",
135
+ "Watch a “how does X work?” video for 3 minutes.",
136
+ "Learn one new word and use it once.",
137
+ "Sketch a simple diagram of an idea.",
138
+ ],
139
+ "gratitude": [
140
+ "List three tiny things that made today easier.",
141
+ "Thank someone by name for something specific.",
142
+ "Notice an everyday object and appreciate its help.",
143
+ "Write “I’m lucky that…” and complete it once.",
144
+ "Savor your next sip or bite with attention.",
145
+ ],
146
+ "neutral": [
147
+ "Take one slow breath and relax your hands.",
148
+ "Stand, stretch, and roll your shoulders.",
149
+ "Drink a glass of water mindfully.",
150
+ "Organize three items in your space.",
151
+ "Set a 10-minute timer to focus on one thing.",
152
+ ],
153
  }
154
 
155
  WHY_BY_EMOTION = {
 
174
  "neutral": "#F5F5F5", "curiosity": "#E6EE9C",
175
  }
176
 
177
+ # Map all 28 GoEmotions -> UI buckets (fixes "always neutral")
178
  GOEMO_TO_APP = {
179
+ "admiration": "gratitude",
180
+ "amusement": "joy",
181
+ "anger": "anger",
182
+ "annoyance": "anger",
183
+ "approval": "gratitude",
184
+ "caring": "love",
185
+ "confusion": "nervousness",
186
+ "curiosity": "curiosity",
187
+ "desire": "joy",
188
+ "disappointment": "sadness",
189
+ "disapproval": "anger",
190
+ "disgust": "anger",
191
+ "embarrassment": "nervousness",
192
+ "excitement": "joy",
193
+ "fear": "fear",
194
+ "gratitude": "gratitude",
195
+ "grief": "grief",
196
+ "joy": "joy",
197
+ "love": "love",
198
+ "nervousness": "nervousness",
199
+ "optimism": "joy",
200
+ "pride": "joy",
201
+ "realization": "neutral",
202
+ "relief": "gratitude",
203
+ "remorse": "grief",
204
+ "sadness": "sadness",
205
+ "surprise": "neutral",
206
+ "neutral": "neutral",
207
  }
208
 
209
+ # ---------------- Preprocessing & Hints ----------------
 
 
210
  CLEAN_RE = re.compile(r"(https?://\S+)|(@\w+)|(#\w+)|[^a-zA-Z0-9\s']")
211
 
212
+ EMOJI_HINTS = {"😭": "sadness", "😡": "anger", "🥰": "love", "😨": "fear", "😴": "boredom"}
213
+ HINTS_EN = {
214
+ "i'm nervous": "nervousness", "im nervous": "nervousness", "nervous": "nervousness",
215
+ "anxious": "nervousness", "anxiety": "nervousness", "panic": "nervousness",
216
+ "i'm grieving": "grief", "im grieving": "grief", "grieving": "grief", "grief": "grief",
217
+ "sad": "sadness", "depressed": "sadness",
218
+ "angry": "anger", "furious": "anger",
219
+ "afraid": "fear", "scared": "fear",
220
+ }
221
+
222
  def clean_text(s: str) -> str:
223
  s = s.lower()
224
  s = CLEAN_RE.sub(" ", s)
 
226
  return s
227
 
228
  def augment_text(text: str, history=None) -> str:
229
+ t = clean_text(text or "")
230
+ lt = (text or "").lower()
231
+ tags = []
232
+ for k, v in EMOJI_HINTS.items():
233
+ if k in lt:
234
+ tags.append(v)
235
+ for k, v in HINTS_EN.items():
236
+ if k in lt:
237
+ tags.append(v)
238
+ if history and len(t.split()) < 8:
239
+ prev_user = history[-1][0] if history and history[-1] else ""
240
+ if isinstance(prev_user, str) and prev_user:
241
+ t += " " + clean_text(prev_user)
242
+ if tags:
243
+ t += " " + " ".join(f"emo_{x}" for x in tags)
244
+ return t
245
 
246
  # ---------------- SQLite ----------------
247
  def get_conn():
 
249
 
250
  def init_db():
251
  conn = get_conn()
 
252
  conn.execute("""CREATE TABLE IF NOT EXISTS sessions(
253
  id INTEGER PRIMARY KEY AUTOINCREMENT,
254
  ts TEXT, country TEXT, user_text TEXT, main_emotion TEXT
255
  )""")
 
256
  conn.execute("""CREATE TABLE IF NOT EXISTS journal(
257
  id INTEGER PRIMARY KEY AUTOINCREMENT,
258
+ ts TEXT NOT NULL, emotion TEXT, title TEXT, content TEXT
 
 
 
259
  )""")
260
  conn.commit()
261
  conn.close()
 
276
  ts = datetime.utcnow().isoformat(timespec='seconds')
277
  conn = get_conn()
278
  conn.execute("INSERT INTO journal(ts, emotion, title, content) VALUES (?,?,?,?)",
279
+ (ts, emotion or "", title, content))
280
  conn.commit()
281
  conn.close()
282
  return True, f"Saved ✓ ({ts} UTC)."
 
286
  params = []
287
  if search:
288
  q += " WHERE (LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(emotion) LIKE ?)"
289
+ s = f"%{search.lower()}%"; params = [s, s, s]
290
+ q += " ORDER BY ts DESC LIMIT ?"; params.append(int(limit))
291
+ conn = get_conn(); rows = list(conn.execute(q, params)); conn.close()
292
+ options, table = [], []
 
 
 
 
 
293
  for (id_, ts, emo, title, content) in rows:
294
  label = f"{ts} — [{(emo or 'neutral')}] {title or (content[:30] + ('…' if len(content) > 30 else ''))}"
295
  options.append((label, id_))
 
301
  def journal_get(entry_id: int):
302
  conn = get_conn()
303
  cur = conn.execute("SELECT ts, emotion, title, content FROM journal WHERE id = ?", (int(entry_id),))
304
+ row = cur.fetchone(); conn.close()
 
305
  if not row: return None
306
  ts, emo, title, content = row
307
  return {"ts": ts, "emotion": emo or "", "title": title or "", "content": content or ""}
308
 
309
  def journal_update(entry_id: int, title: str, content: str, emotion: str):
310
  if entry_id is None: return False, "No entry selected."
311
+ title = (title or "").strip(); content = (content or "").strip()
312
+ if not content: return False, "Entry content cannot be empty."
 
 
313
  conn = get_conn()
314
+ cur = conn.execute("UPDATE journal SET title=?, content=?, emotion=? WHERE id=?",
315
+ (title, content, emotion or "", int(entry_id)))
316
+ conn.commit(); ok = (cur.rowcount or 0) > 0; conn.close()
 
317
  return ok, ("Updated ✓" if ok else "Entry not found.")
318
 
319
  def journal_delete(entry_id: int):
320
  conn = get_conn()
321
  cur = conn.execute("DELETE FROM journal WHERE id = ?", (int(entry_id),))
322
+ conn.commit(); changes = conn.total_changes; conn.close()
 
 
323
  return changes > 0
324
 
325
+ # ---- PDF helpers ----
326
  def _wrap_text(text, max_chars=90):
327
  lines = []
328
  for para in (text or "").split("\n"):
 
330
  while len(para) > max_chars:
331
  cut = para.rfind(" ", 0, max_chars)
332
  if cut == -1: cut = max_chars
333
+ lines.append(para[:cut]); para = para[cut:].lstrip()
 
334
  lines.append(para)
335
  return lines
336
 
337
  def _pdf_from_entry(path, data):
 
338
  c = canvas.Canvas(path, pagesize=A4)
339
  width, height = A4
340
+ x_margin = 20*mm; y_margin = 20*mm; y = height - y_margin
 
 
341
  def draw_line(s, size=11, bold=False, leading=14):
342
  nonlocal y
343
  if y < 30*mm:
344
+ c.showPage(); y = height - y_margin
 
345
  c.setFont("Helvetica-Bold" if bold else "Helvetica", size)
346
+ c.drawString(x_margin, y, s); y -= leading
 
 
347
  title = data["title"] or "(Untitled)"
348
  draw_line(f"Title: {title}", size=14, bold=True, leading=18)
349
  draw_line(f"Emotion: {data['emotion'] or '-'}")
350
  draw_line(f"Saved (UTC): {data['ts']}")
351
  draw_line("-"*80)
 
352
  for ln in _wrap_text(data["content"], 95):
353
  draw_line(ln)
354
+ c.showPage(); c.save()
 
355
 
356
  def journal_export_txt(entry_id: int):
357
  data = journal_get(entry_id)
358
+ if not data: return None, "Entry not found."
 
359
  fname = f"journal_{entry_id}_{data['ts'].replace(':','-')}.txt"
360
  fpath = os.path.join(DATA_DIR, fname)
361
  lines = [
 
365
  "-" * 40,
366
  data["content"] or ""
367
  ]
368
+ with open(fpath, "w", encoding="utf-8") as f: f.write("\n".join(lines))
 
369
  return fpath, f"Ready: {fname}"
370
 
371
  def journal_export_pdf(entry_id: int):
372
  if not REPORTLAB_OK:
373
  return None, "PDF export requires 'reportlab' (pip install reportlab)."
374
  data = journal_get(entry_id)
375
+ if not data: return None, "Entry not found."
 
376
  safe_title = re.sub(r"[^a-zA-Z0-9_\- ]", "_", data["title"] or "untitled")
377
  fname = f"journal_{entry_id}_{data['ts'][:19].replace(':','-')} - {safe_title}.pdf"
378
  fpath = os.path.join(DATA_DIR, fname)
 
380
  return fpath, f"PDF ready: {fname}"
381
 
382
  def journal_export_all_zip(pdf_first=True):
383
+ conn = get_conn(); rows = list(conn.execute("SELECT id FROM journal ORDER BY ts")); conn.close()
384
+ if not rows: return None, "No entries to export."
 
 
 
 
385
  use_pdf = pdf_first and REPORTLAB_OK
386
  zip_name = os.path.join(DATA_DIR, "journal_all.zip")
387
  with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as zf:
388
  for (entry_id,) in rows:
389
  data = journal_get(entry_id)
390
+ if not data: continue
 
391
  safe_title = re.sub(r"[^a-zA-Z0-9_\- ]", "_", data["title"] or "untitled")
392
  stamp = data["ts"][:19].replace(":", "-")
393
  if use_pdf:
 
394
  tmp_pdf = os.path.join(DATA_DIR, f"_tmp_{entry_id}.pdf")
395
  _pdf_from_entry(tmp_pdf, data)
396
+ zf.write(tmp_pdf, arcname=f"{stamp} - {safe_title}.pdf")
 
397
  try: os.remove(tmp_pdf)
398
  except: pass
399
  else:
 
404
  f"{'-'*40}\n"
405
  f"{data['content'] or ''}"
406
  )
407
+ zf.writestr(f"{stamp} - {safe_title}.txt", txt)
 
408
  msg = "Exported all as PDF." if use_pdf else "Exported all as TXT (install 'reportlab' for PDF)."
409
  return zip_name, msg
410
 
411
+ # ---------------- Text model ----------------
412
  def load_goemotions_dataset():
413
  ds = load_dataset("google-research-datasets/go_emotions", "simplified")
414
  return ds, ds["train"].features["labels"].feature.names
 
426
  ("tfidf", TfidfVectorizer(lowercase=True, ngram_range=(1,2), min_df=2, max_df=0.9, strip_accents="unicode")),
427
  ("ovr", OneVsRestClassifier(
428
  LogisticRegression(
429
+ solver="saga",
430
+ penalty="l2",
431
+ C=0.5,
432
+ tol=1e-3,
433
+ max_iter=5000,
434
+ class_weight="balanced"
435
  ),
436
  n_jobs=-1
437
  ))
 
450
  if not CLASSIFIER: return []
451
  proba = CLASSIFIER.predict_proba([text_augmented])[0]
452
  max_p = float(np.max(proba)) if len(proba) else 0.0
453
+ thr = max(0.10, 0.30 * max_p + 0.15)
454
  idxs = [i for i, p in enumerate(proba) if p >= thr] or [int(np.argmax(proba))]
455
  idxs.sort(key=lambda i: proba[i], reverse=True)
456
  return [(LABEL_NAMES[i], float(proba[i])) for i in idxs]
457
 
458
  def detect_emotion_text(message: str, history):
459
  labels = classify_text(augment_text(message, history))
460
+ if not labels: return "neutral"
 
461
  bucket = {}
462
  for lbl, p in labels:
463
  app = GOEMO_TO_APP.get(lbl.lower(), "neutral")
464
  bucket[app] = max(bucket.get(app, 0.0), p)
465
  return max(bucket, key=bucket.get) if bucket else "neutral"
466
 
467
+ # ---------------- Advice engine ----------------
468
  def pick_advice_from_pool(emotion: str, pool: dict, last_tip: str = ""):
469
  tips_all = SUGGESTIONS.get(emotion, SUGGESTIONS["neutral"])
470
  entry = pool.get(emotion, {"unused": [], "last": ""})
 
638
  return msg, drop, table
639
 
640
  def _load_entry_fill(entry_id):
 
641
  if entry_id is None:
642
  return "", "neutral", "", ""
643
  data = journal_get(entry_id)
 
689
  j_refresh.click(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
690
  j_search.submit(_refresh_entries, inputs=[j_search], outputs=[j_entries, j_table])
691
 
 
692
  j_entries.change(_load_entry_fill, inputs=[j_entries],
693
  outputs=[j_title, j_emotion, j_text, j_view])
694