ctizzzy0 commited on
Commit
38e5187
Β·
verified Β·
1 Parent(s): c9236ad

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +211 -68
app.py CHANGED
@@ -1,9 +1,9 @@
1
- # app.py β€” Multi-Modal Emotion AI (Text β€’ Voice β€’ Face)
2
- # College-showcase build: fusion (weighted), PDF reports, safety + CBT tips,
3
- # trends logging, auto face-crop, version banner, demo button, API endpoint (via api_name).
4
- # Works on Hugging Face Spaces free CPU (GPU faster, but not required).
5
 
6
- import os, json, datetime
7
  from typing import Dict, List, Optional, Tuple
8
 
9
  import numpy as np
@@ -17,10 +17,10 @@ from fpdf import FPDF
17
  from transformers import pipeline
18
 
19
  # =========================
20
- # Config / metadata
21
  # =========================
22
  APP_NAME = "Multi-Modal Emotion AI"
23
- APP_VERSION = "v1.3"
24
  RUN_LOG = "runs.csv"
25
  CHARTS_DIR = "charts"
26
  os.makedirs(CHARTS_DIR, exist_ok=True)
@@ -29,12 +29,13 @@ if not os.path.exists(RUN_LOG):
29
  "timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"
30
  ]).to_csv(RUN_LOG, index=False)
31
 
32
- # Public models (kept light; all public)
 
 
33
  TEXT_MODEL = "SamLowe/roberta-base-go_emotions" # text emotions (GoEmotions, 27)
34
  VOICE_MODEL = "superb/wav2vec2-base-superb-er" # voice emotion recognition
35
  FACE_MODEL = "trpakov/vit-face-expression" # facial expression (ViT)
36
 
37
- # Pipelines (cached once)
38
  text_pipe = pipeline("text-classification", model=TEXT_MODEL, top_k=None)
39
  voice_pipe = pipeline("audio-classification", model=VOICE_MODEL, top_k=None)
40
  face_pipe = pipeline("image-classification", model=FACE_MODEL, top_k=None)
@@ -141,7 +142,7 @@ def save_chart(prob: Dict[str,float], title: str, path: str):
141
  plt.close(fig)
142
 
143
  # =========================
144
- # Computer vision: face crop
145
  # =========================
146
  HAAR = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
147
  def crop_face(image_path: str) -> Image.Image:
@@ -158,24 +159,41 @@ def crop_face(image_path: str) -> Image.Image:
158
  except Exception:
159
  return Image.open(image_path).convert("RGB")
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  # =========================
162
  # Per-modality inference
163
  # =========================
164
  def analyze_text(text: str):
165
  if not text or not text.strip():
166
- return gr.Error("Please enter text."), None, None
167
  with gr.Progress() as p:
168
- p(0.15, desc="Analyzing text…")
169
  probs = pipe_to_probs(text_pipe(text))
170
  msg = f"**Top Text Emotion:** {top_item(probs)} | **Positivity Index:** {positivity_index(probs):.2f}"
171
  fig = bar_fig(probs, "Text Emotions")
172
- return msg, fig, json.dumps(probs)
 
 
 
173
 
174
  def analyze_voice(audio_path: Optional[str]):
175
  if not audio_path:
176
  return "No audio provided.", None, None
177
  with gr.Progress() as p:
178
- p(0.15, desc="Analyzing voice…")
179
  probs = pipe_to_probs(voice_pipe(audio_path))
180
  msg = f"**Top Voice Emotion:** {top_item(probs)}"
181
  fig = bar_fig(probs, "Voice Emotions")
@@ -185,16 +203,66 @@ def analyze_face(image_path: Optional[str]):
185
  if not image_path:
186
  return "No image provided.", None, None
187
  with gr.Progress() as p:
188
- p(0.15, desc="Detecting face…")
189
  face_img = crop_face(image_path)
190
- p(0.6, desc="Analyzing facial expression…")
191
  probs = pipe_to_probs(face_pipe(face_img))
192
  msg = f"**Top Face Emotion:** {top_item(probs)}"
193
  fig = bar_fig(probs, "Face Emotions")
194
  return msg, fig, json.dumps(probs)
195
 
196
  # =========================
197
- # PDF Report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  # =========================
199
  def build_pdf(text_in: str,
200
  text_prob: Optional[Dict[str,float]],
@@ -202,7 +270,8 @@ def build_pdf(text_in: str,
202
  face_prob: Optional[Dict[str,float]],
203
  fused_prob: Optional[Dict[str,float]],
204
  safety_level: str, safety_hits: Dict[str,List[str]],
205
- distortions: List[str], tips: List[str]) -> str:
 
206
 
207
  # save charts
208
  paths = []
@@ -211,6 +280,7 @@ def build_pdf(text_in: str,
211
  if face_prob: save_chart(face_prob, "Face Emotions", os.path.join(CHARTS_DIR,"face.png")); paths.append(os.path.join(CHARTS_DIR,"face.png"))
212
  if fused_prob: save_chart(fused_prob, "Fused Profile", os.path.join(CHARTS_DIR,"fused.png")); paths.append(os.path.join(CHARTS_DIR,"fused.png"))
213
 
 
214
  pdf = FPDF()
215
  pdf.add_page()
216
  pdf.set_font("Arial", size=16)
@@ -231,8 +301,8 @@ def build_pdf(text_in: str,
231
  pdf.set_text_color(0,0,0)
232
  pdf.ln(2)
233
 
234
- if distortions:
235
- pdf.cell(0, 8, f"Cognitive distortions: {', '.join(distortions)}", ln=True)
236
  if tips:
237
  pdf.cell(0, 8, "Reframe suggestions:", ln=True)
238
  for t in tips:
@@ -244,14 +314,34 @@ def build_pdf(text_in: str,
244
  pdf.image(pth, w=180)
245
  pdf.ln(4)
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  out = "emotion_report.pdf"
248
  pdf.output(out)
249
  return out
250
 
251
  # =========================
252
- # Trends logging
253
  # =========================
254
- def log_run(row: dict):
 
 
255
  df = pd.read_csv(RUN_LOG)
256
  df.loc[len(df)] = row
257
  df.to_csv(RUN_LOG, index=False)
@@ -272,10 +362,14 @@ def plot_trends():
272
  plt.xticks(rotation=25, ha="right"); plt.tight_layout()
273
  return fig
274
 
 
 
 
 
275
  # =========================
276
- # Fusion + API handler
277
  # =========================
278
- def fuse_and_report(text_json, voice_json, face_json, text_raw, w_text, w_voice, w_face):
279
  te = json.loads(text_json) if text_json else None
280
  ve = json.loads(voice_json) if voice_json else None
281
  fe = json.loads(face_json) if face_json else None
@@ -285,81 +379,109 @@ def fuse_and_report(text_json, voice_json, face_json, text_raw, w_text, w_voice,
285
  fused = merge_probs([te, ve, fe], weights) if (te or ve or fe) else None
286
 
287
  safety_level, safety_hits = safety_screen(text_raw or "")
288
- distos = detect_distortions(text_raw or "")
289
- tips = reframe_tips(distos)
 
290
 
291
- pdf_path = build_pdf(text_raw, te, ve, fe, fused, safety_level, safety_hits, distos, tips)
 
 
 
292
 
293
- pi_val = positivity_index(te)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  log_run({
295
  "timestamp": datetime.datetime.now().isoformat(sep=" ", timespec="seconds"),
296
  "text": text_raw or "",
297
  "text_top": top_item(te),
298
  "voice_top": top_item(ve),
299
  "face_top": top_item(fe),
300
- "fused_top": top_item(fused),
301
- "pos_index": pi_val
302
- })
303
 
304
- msg = f"**Fused Top:** {top_item(fused) or '(insufficient inputs)'}"
305
- msg += f" | Weights β†’ Text:{weights[0]:.2f}, Voice:{weights[1]:.2f}, Face:{weights[2]:.2f}"
306
- plot = bar_fig(fused, "Fused Emotional Profile") if fused else None
307
- return msg, plot, pdf_path
308
 
309
- # Optional text-only JSON API (returns distribution) β€” exposed as /run/text_api via api_name
310
  def text_api(text: str):
311
  if not text or not text.strip():
312
  return {"error":"text required"}
313
  probs = pipe_to_probs(text_pipe(text))
 
314
  return {
315
  "top": top_item(probs),
316
  "positivity_index": positivity_index(probs),
317
- "distribution": probs
 
318
  }
319
 
320
  # =========================
321
  # Gradio UI
322
  # =========================
323
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
324
- # Version banner
325
  gr.HTML(f"""
326
  <div style="padding:10px 12px;border:1px solid #eee;border-radius:10px;
327
  display:flex;justify-content:space-between;align-items:center;">
328
- <div><b>🧠 {APP_NAME}</b> β€” <span style="opacity:.8">Text β€’ Voice β€’ Face</span></div>
329
  <div style="opacity:.7">{APP_VERSION} Β· MIT Β· Made with πŸ€—</div>
330
  </div>
331
  """)
332
  gr.Markdown("Analyze emotions across **text, voice, and face**, detect **safety risks** and **cognitive distortions**, "
333
- "tune **fusion weights**, and download a **PDF report**. Audio/image are optional.")
334
-
335
- # State
336
- st_text_json = gr.State()
337
- st_voice_json = gr.State()
338
- st_face_json = gr.State()
339
- st_text_raw = gr.State()
 
 
 
 
340
 
341
  with gr.Row():
342
  demo_btn = gr.Button("Load demo text", variant="secondary")
343
- reset_btn = gr.Button("Reset Weights", variant="secondary")
344
 
 
345
  with gr.Tab("πŸ“ Text"):
346
  t_in = gr.Textbox(label="Your text", lines=3, placeholder="How are you feeling today?")
347
  t_btn = gr.Button("Analyze Text", variant="primary")
348
  t_msg = gr.Markdown()
349
  t_plot = gr.Plot()
 
 
350
  def _t_chain(txt):
351
- msg, fig, j = analyze_text(txt)
352
- return msg, fig, j, txt
353
- t_btn.click(_t_chain, inputs=t_in, outputs=[t_msg, t_plot, st_text_json, st_text_raw])
354
 
 
355
  with gr.Tab("🎀 Voice"):
356
- a_in = gr.Audio(sources=["microphone","upload"], type="filepath",
357
- label="Record or upload audio (optional)")
358
  a_btn = gr.Button("Analyze Voice", variant="primary")
359
  a_msg = gr.Markdown()
360
  a_plot = gr.Plot()
361
  a_btn.click(analyze_voice, inputs=a_in, outputs=[a_msg, a_plot, st_voice_json])
362
 
 
363
  with gr.Tab("πŸ“· Face"):
364
  f_in = gr.Image(type="filepath", label="Upload a face image (optional)")
365
  f_btn = gr.Button("Analyze Face", variant="primary")
@@ -367,39 +489,60 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
367
  f_plot = gr.Plot()
368
  f_btn.click(analyze_face, inputs=f_in, outputs=[f_msg, f_plot, st_face_json])
369
 
370
- with gr.Tab("🧩 Fusion + Report"):
 
371
  with gr.Row():
372
- w_text = gr.Slider(0, 1, value=0.50, step=0.05, label="Text weight")
373
  w_voice = gr.Slider(0, 1, value=0.30, step=0.05, label="Voice weight")
374
- w_face = gr.Slider(0, 1, value=0.20, step=0.05, label="Face weight")
375
- fuse_btn = gr.Button("Fuse & Generate PDF", variant="primary")
376
  fuse_msg = gr.Markdown()
377
  fuse_plot = gr.Plot()
378
- fuse_pdf = gr.File(label="Download Report")
 
379
  fuse_btn.click(
380
- fuse_and_report,
381
- inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, w_text, w_voice, w_face],
382
- outputs=[fuse_msg, fuse_plot, fuse_pdf],
383
- api_name="predict" # public API endpoint for this action
 
 
384
  )
385
- def _reset():
386
- return 0.50, 0.30, 0.20
387
- reset_btn.click(_reset, None, [w_text, w_voice, w_face])
388
 
389
- with gr.Tab("πŸ“ˆ Trends"):
 
 
 
 
 
 
 
 
 
 
 
 
390
  tr_btn = gr.Button("Refresh Positivity Trend")
391
  tr_plot = gr.Plot()
392
  tr_btn.click(plot_trends, inputs=None, outputs=tr_plot)
393
-
 
 
 
 
 
 
 
 
394
  with gr.Tab("πŸ”Œ API"):
395
- gr.Markdown("**Text-only JSON API** (for quick programmatic use).")
396
  api_in = gr.Textbox(label="Text")
397
  api_out = gr.JSON(label="Response")
398
  gr.Button("Run API").click(text_api, inputs=api_in, outputs=api_out, api_name="text_api")
399
 
400
  # Demo filler
401
  def load_demo():
402
- return "I’m stressed about deadlines but also excited for the opportunity."
403
  demo_btn.click(load_demo, None, t_in)
404
 
405
  app = demo
 
1
+ # app.py β€” Multi-Modal Emotion AI (Text β€’ Voice β€’ Face) β€” "Super Useful" Edition
2
+ # Big upgrades: Fusion (weighted), PDF report, Safety + CBT, Coaching Plan generator,
3
+ # Privacy controls (opt-out logging + clear data), Trends, Face auto-crop, Demo button,
4
+ # Lightweight keyword "triggers", simple JSON API. CPU-friendly for Hugging Face Spaces.
5
 
6
+ import os, json, datetime, re
7
  from typing import Dict, List, Optional, Tuple
8
 
9
  import numpy as np
 
17
  from transformers import pipeline
18
 
19
  # =========================
20
+ # App config / metadata
21
  # =========================
22
  APP_NAME = "Multi-Modal Emotion AI"
23
+ APP_VERSION = "v2.0"
24
  RUN_LOG = "runs.csv"
25
  CHARTS_DIR = "charts"
26
  os.makedirs(CHARTS_DIR, exist_ok=True)
 
29
  "timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"
30
  ]).to_csv(RUN_LOG, index=False)
31
 
32
+ # =========================
33
+ # Public, lightweight models
34
+ # =========================
35
  TEXT_MODEL = "SamLowe/roberta-base-go_emotions" # text emotions (GoEmotions, 27)
36
  VOICE_MODEL = "superb/wav2vec2-base-superb-er" # voice emotion recognition
37
  FACE_MODEL = "trpakov/vit-face-expression" # facial expression (ViT)
38
 
 
39
  text_pipe = pipeline("text-classification", model=TEXT_MODEL, top_k=None)
40
  voice_pipe = pipeline("audio-classification", model=VOICE_MODEL, top_k=None)
41
  face_pipe = pipeline("image-classification", model=FACE_MODEL, top_k=None)
 
142
  plt.close(fig)
143
 
144
  # =========================
145
+ # Computer vision: face auto-crop
146
  # =========================
147
  HAAR = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
148
  def crop_face(image_path: str) -> Image.Image:
 
159
  except Exception:
160
  return Image.open(image_path).convert("RGB")
161
 
162
+ # =========================
163
+ # Lightweight keyword "triggers"
164
+ # =========================
165
+ STOP = set("""a an and the of to in is it for on with as that this i im i'm are was were be been being by from at or but if then so very really just kind quite rather about into up down over under again further once do does did doing have has had having not no nor only own same than too s t can will don should now""".split())
166
+ def extract_triggers(text: str, top_k: int = 6) -> List[str]:
167
+ if not text: return []
168
+ tokens = re.findall(r"[a-zA-Z']{3,}", text.lower())
169
+ words = [w for w in tokens if w not in STOP]
170
+ if not words: return []
171
+ counts = {}
172
+ for w in words: counts[w] = counts.get(w,0) + 1
173
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
174
+ return [w for w,_ in ranked[:top_k]]
175
+
176
  # =========================
177
  # Per-modality inference
178
  # =========================
179
  def analyze_text(text: str):
180
  if not text or not text.strip():
181
+ return gr.Error("Please enter text."), None, None, None, None
182
  with gr.Progress() as p:
183
+ p(0.2, desc="Analyzing text emotions…")
184
  probs = pipe_to_probs(text_pipe(text))
185
  msg = f"**Top Text Emotion:** {top_item(probs)} | **Positivity Index:** {positivity_index(probs):.2f}"
186
  fig = bar_fig(probs, "Text Emotions")
187
+ distos = detect_distortions(text)
188
+ tips = reframe_tips(distos)
189
+ triggers = extract_triggers(text)
190
+ return msg, fig, json.dumps(probs), json.dumps({"distortions":distos,"tips":tips}), json.dumps({"triggers":triggers})
191
 
192
  def analyze_voice(audio_path: Optional[str]):
193
  if not audio_path:
194
  return "No audio provided.", None, None
195
  with gr.Progress() as p:
196
+ p(0.2, desc="Analyzing voice emotions…")
197
  probs = pipe_to_probs(voice_pipe(audio_path))
198
  msg = f"**Top Voice Emotion:** {top_item(probs)}"
199
  fig = bar_fig(probs, "Voice Emotions")
 
203
  if not image_path:
204
  return "No image provided.", None, None
205
  with gr.Progress() as p:
206
+ p(0.2, desc="Detecting face…")
207
  face_img = crop_face(image_path)
208
+ p(0.7, desc="Analyzing facial expression…")
209
  probs = pipe_to_probs(face_pipe(face_img))
210
  msg = f"**Top Face Emotion:** {top_item(probs)}"
211
  fig = bar_fig(probs, "Face Emotions")
212
  return msg, fig, json.dumps(probs)
213
 
214
  # =========================
215
+ # Coaching plan (actionable)
216
+ # =========================
217
+ def generate_coaching_plan(text_prob, voice_prob, face_prob, disto_json, safety_level, triggers):
218
+ # Parse inputs
219
+ te = json.loads(text_prob) if text_prob else {}
220
+ ve = json.loads(voice_prob) if voice_prob else {}
221
+ fe = json.loads(face_prob) if face_prob else {}
222
+ distos = json.loads(disto_json).get("distortions", []) if disto_json else []
223
+ tips = json.loads(disto_json).get("tips", []) if disto_json else []
224
+ trig_list = json.loads(triggers).get("triggers", []) if triggers else []
225
+
226
+ # Determine dominant emotions (top across modalities)
227
+ def topk(d, k=3): return sorted(d.items(), key=lambda kv: kv[1], reverse=True)[:k]
228
+ dominant = [lab for lab,_ in topk(te,1)+topk(ve,1)+topk(fe,1)]
229
+ dom_set = set(dominant)
230
+
231
+ # Recipe library
232
+ exercises = []
233
+ if "fear" in dom_set or "nervousness" in dom_set:
234
+ exercises += ["4-7-8 breathing (5 rounds)", "Name 5-4-3-2-1 sensory objects", "Write worst/best/most-likely outcomes"]
235
+ if "sadness" in dom_set or "grief" in dom_set:
236
+ exercises += ["Text one friend to check in", "10-minute sunlight walk", "Gratitude list (3 items)"]
237
+ if "anger" in dom_set or "disgust" in dom_set:
238
+ exercises += ["2-minute cold water face splash", "Box breathing 4x4x4x4", "Delay response 20 minutes + draft message"]
239
+ if "joy" in dom_set or "excitement" in dom_set or "admiration" in dom_set:
240
+ exercises += ["Savoring: write 3 details you enjoyed", "Share win with someone", "Schedule repeat of the activity"]
241
+
242
+ # Personalization via distortions & triggers
243
+ reframe_block = tips[:3]
244
+ trigger_block = [f"Avoid or prepare for: {t}" for t in trig_list]
245
+
246
+ # Safety append
247
+ crisis_block = []
248
+ if safety_level == "high":
249
+ crisis_block = ["⚠ If in danger: contact local emergency services.",
250
+ "US: 988 (Suicide & Crisis Lifeline)"]
251
+
252
+ # Structure plan
253
+ plan = {
254
+ "today": list(dict.fromkeys(exercises))[:5] or ["5-minute mindful breathing", "Short walk outside"],
255
+ "reframes": reframe_block or ["Reframe: What evidence supports/against my thought?"],
256
+ "triggers": trigger_block[:5],
257
+ "sleep": ["Wind-down alarm + devices off 30m before bed", "Keep consistent wake time"],
258
+ "movement": ["10-15m easy cardio or stretching"],
259
+ "social": ["Send a supportive text to someone"],
260
+ "safety": crisis_block
261
+ }
262
+ return json.dumps(plan, ensure_ascii=False)
263
+
264
+ # =========================
265
+ # PDF Report (analysis + plan)
266
  # =========================
267
  def build_pdf(text_in: str,
268
  text_prob: Optional[Dict[str,float]],
 
270
  face_prob: Optional[Dict[str,float]],
271
  fused_prob: Optional[Dict[str,float]],
272
  safety_level: str, safety_hits: Dict[str,List[str]],
273
+ distos: List[str], tips: List[str],
274
+ plan_json: Optional[str]) -> str:
275
 
276
  # save charts
277
  paths = []
 
280
  if face_prob: save_chart(face_prob, "Face Emotions", os.path.join(CHARTS_DIR,"face.png")); paths.append(os.path.join(CHARTS_DIR,"face.png"))
281
  if fused_prob: save_chart(fused_prob, "Fused Profile", os.path.join(CHARTS_DIR,"fused.png")); paths.append(os.path.join(CHARTS_DIR,"fused.png"))
282
 
283
+ # build PDF
284
  pdf = FPDF()
285
  pdf.add_page()
286
  pdf.set_font("Arial", size=16)
 
301
  pdf.set_text_color(0,0,0)
302
  pdf.ln(2)
303
 
304
+ if distos:
305
+ pdf.cell(0, 8, f"Cognitive distortions: {', '.join(distos)}", ln=True)
306
  if tips:
307
  pdf.cell(0, 8, "Reframe suggestions:", ln=True)
308
  for t in tips:
 
314
  pdf.image(pth, w=180)
315
  pdf.ln(4)
316
 
317
+ # Coaching plan section
318
+ if plan_json:
319
+ try:
320
+ plan = json.loads(plan_json)
321
+ pdf.set_font("Arial", size=13)
322
+ pdf.cell(0, 10, "Personalized Coaching Plan", ln=True)
323
+ pdf.set_font("Arial", size=12)
324
+ for sec in ["today","reframes","triggers","movement","sleep","social","safety"]:
325
+ items = plan.get(sec, [])
326
+ if not items: continue
327
+ title = sec.capitalize()
328
+ pdf.cell(0, 8, f"{title}:", ln=True)
329
+ for i in items:
330
+ pdf.multi_cell(0, 7, f" β€’ {i}")
331
+ pdf.ln(1)
332
+ except Exception:
333
+ pass
334
+
335
  out = "emotion_report.pdf"
336
  pdf.output(out)
337
  return out
338
 
339
  # =========================
340
+ # Trends & data controls
341
  # =========================
342
+ def log_run(row: dict, enable_logging: bool):
343
+ if not enable_logging:
344
+ return
345
  df = pd.read_csv(RUN_LOG)
346
  df.loc[len(df)] = row
347
  df.to_csv(RUN_LOG, index=False)
 
362
  plt.xticks(rotation=25, ha="right"); plt.tight_layout()
363
  return fig
364
 
365
+ def clear_history():
366
+ pd.DataFrame(columns=["timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"]).to_csv(RUN_LOG, index=False)
367
+ return "History cleared."
368
+
369
  # =========================
370
+ # Fusion + end-to-end actions
371
  # =========================
372
+ def fuse_and_plan(text_json, voice_json, face_json, text_raw, w_text, w_voice, w_face, disto_json, triggers_json):
373
  te = json.loads(text_json) if text_json else None
374
  ve = json.loads(voice_json) if voice_json else None
375
  fe = json.loads(face_json) if face_json else None
 
379
  fused = merge_probs([te, ve, fe], weights) if (te or ve or fe) else None
380
 
381
  safety_level, safety_hits = safety_screen(text_raw or "")
382
+ distos = json.loads(disto_json).get("distortions", []) if disto_json else []
383
+ tips = json.loads(disto_json).get("tips", []) if disto_json else []
384
+ plan = generate_coaching_plan(text_json, voice_json, face_json, disto_json, safety_level, triggers_json)
385
 
386
+ msg = f"**Fused Top:** {top_item(fused) or '(insufficient inputs)'}"
387
+ msg += f" | Weights β†’ Text:{weights[0]:.2f}, Voice:{weights[1]:.2f}, Face:{weights[2]:.2f}"
388
+ plot = bar_fig(fused, "Fused Emotional Profile") if fused else None
389
+ return msg, plot, json.dumps({"safety":safety_level,"hits":safety_hits}), plan
390
 
391
+ def full_report(text_json, voice_json, face_json, text_raw, fusion_plot, safety_json, disto_json, plan_json, enable_logging):
392
+ te = json.loads(text_json) if text_json else None
393
+ ve = json.loads(voice_json) if voice_json else None
394
+ fe = json.loads(face_json) if face_json else None
395
+ safety = json.loads(safety_json) if safety_json else {"safety":"low","hits":{}}
396
+ distos = json.loads(disto_json).get("distortions", []) if disto_json else []
397
+ tips = json.loads(disto_json).get("tips", []) if disto_json else []
398
+
399
+ pdf_path = build_pdf(
400
+ text_in=text_raw,
401
+ text_prob=te, voice_prob=ve, face_prob=fe,
402
+ fused_prob=None, # fused chart already shown; omit to keep PDF lighter or replace with fused if desired
403
+ safety_level=safety.get("safety","low"), safety_hits=safety.get("hits",{}),
404
+ distos=distos, tips=tips,
405
+ plan_json=plan_json
406
+ )
407
+
408
+ # Log compact row
409
  log_run({
410
  "timestamp": datetime.datetime.now().isoformat(sep=" ", timespec="seconds"),
411
  "text": text_raw or "",
412
  "text_top": top_item(te),
413
  "voice_top": top_item(ve),
414
  "face_top": top_item(fe),
415
+ "fused_top": "", # could compute again if needed
416
+ "pos_index": positivity_index(te)
417
+ }, enable_logging=enable_logging)
418
 
419
+ return pdf_path
 
 
 
420
 
421
+ # Simple text-only JSON API (for quick programmatic use)
422
  def text_api(text: str):
423
  if not text or not text.strip():
424
  return {"error":"text required"}
425
  probs = pipe_to_probs(text_pipe(text))
426
+ distos = detect_distortions(text)
427
  return {
428
  "top": top_item(probs),
429
  "positivity_index": positivity_index(probs),
430
+ "distribution": probs,
431
+ "distortions": distos
432
  }
433
 
434
  # =========================
435
  # Gradio UI
436
  # =========================
437
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
438
+ # Header
439
  gr.HTML(f"""
440
  <div style="padding:10px 12px;border:1px solid #eee;border-radius:10px;
441
  display:flex;justify-content:space-between;align-items:center;">
442
+ <div><b>🧠 {APP_NAME}</b> β€” <span style="opacity:.8">Text β€’ Voice β€’ Face β€’ Coaching</span></div>
443
  <div style="opacity:.7">{APP_VERSION} Β· MIT Β· Made with πŸ€—</div>
444
  </div>
445
  """)
446
  gr.Markdown("Analyze emotions across **text, voice, and face**, detect **safety risks** and **cognitive distortions**, "
447
+ "generate a **personalized coaching plan**, and download a **PDF report**. Audio/image optional.")
448
+
449
+ # App state
450
+ st_text_json = gr.State()
451
+ st_voice_json = gr.State()
452
+ st_face_json = gr.State()
453
+ st_text_raw = gr.State()
454
+ st_disto_json = gr.State()
455
+ st_triggers = gr.State()
456
+ st_safety_json = gr.State()
457
+ st_plan_json = gr.State()
458
 
459
  with gr.Row():
460
  demo_btn = gr.Button("Load demo text", variant="secondary")
461
+ enable_log = gr.Checkbox(value=True, label="Enable logging for Trends (privacy toggle)")
462
 
463
+ # ---------- Text ----------
464
  with gr.Tab("πŸ“ Text"):
465
  t_in = gr.Textbox(label="Your text", lines=3, placeholder="How are you feeling today?")
466
  t_btn = gr.Button("Analyze Text", variant="primary")
467
  t_msg = gr.Markdown()
468
  t_plot = gr.Plot()
469
+ t_dist = gr.JSON(label="CBT Distortions & Tips")
470
+ t_trig = gr.JSON(label="Possible Triggers")
471
  def _t_chain(txt):
472
+ msg, fig, j, djson, trig = analyze_text(txt)
473
+ return msg, fig, j, txt, djson, trig
474
+ t_btn.click(_t_chain, inputs=t_in, outputs=[t_msg, t_plot, st_text_json, st_text_raw, st_disto_json, st_triggers])
475
 
476
+ # ---------- Voice ----------
477
  with gr.Tab("🎀 Voice"):
478
+ a_in = gr.Audio(sources=["microphone","upload"], type="filepath", label="Record or upload audio (optional)")
 
479
  a_btn = gr.Button("Analyze Voice", variant="primary")
480
  a_msg = gr.Markdown()
481
  a_plot = gr.Plot()
482
  a_btn.click(analyze_voice, inputs=a_in, outputs=[a_msg, a_plot, st_voice_json])
483
 
484
+ # ---------- Face ----------
485
  with gr.Tab("πŸ“· Face"):
486
  f_in = gr.Image(type="filepath", label="Upload a face image (optional)")
487
  f_btn = gr.Button("Analyze Face", variant="primary")
 
489
  f_plot = gr.Plot()
490
  f_btn.click(analyze_face, inputs=f_in, outputs=[f_msg, f_plot, st_face_json])
491
 
492
+ # ---------- Fusion & Plan ----------
493
+ with gr.Tab("🧩 Fusion + Plan"):
494
  with gr.Row():
495
+ w_text = gr.Slider(0, 1, value=0.55, step=0.05, label="Text weight")
496
  w_voice = gr.Slider(0, 1, value=0.30, step=0.05, label="Voice weight")
497
+ w_face = gr.Slider(0, 1, value=0.15, step=0.05, label="Face weight")
498
+ fuse_btn = gr.Button("Fuse & Build Coaching Plan", variant="primary")
499
  fuse_msg = gr.Markdown()
500
  fuse_plot = gr.Plot()
501
+ safety_box = gr.JSON(label="Safety screen")
502
+ plan_box = gr.JSON(label="Coaching plan (today + reframes + triggers)")
503
  fuse_btn.click(
504
+ fuse_and_plan,
505
+ inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, w_text, w_voice, w_face, st_disto_json, st_triggers],
506
+ outputs=[fuse_msg, fuse_plot, st_safety_json, st_plan_json],
507
+ api_name="fuse" # expose endpoint
508
+ ).then(
509
+ lambda s,p: (s,p), inputs=[st_safety_json, st_plan_json], outputs=[safety_box, plan_box]
510
  )
 
 
 
511
 
512
+ # ---------- Report ----------
513
+ with gr.Tab("πŸ“„ Report"):
514
+ report_btn = gr.Button("Generate PDF Report")
515
+ pdf_out = gr.File(label="Download Report")
516
+ report_btn.click(
517
+ full_report,
518
+ inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, fuse_plot, st_safety_json, st_disto_json, st_plan_json, enable_log],
519
+ outputs=pdf_out,
520
+ api_name="report"
521
+ )
522
+
523
+ # ---------- Trends & Data ----------
524
+ with gr.Tab("πŸ“ˆ Trends & Data"):
525
  tr_btn = gr.Button("Refresh Positivity Trend")
526
  tr_plot = gr.Plot()
527
  tr_btn.click(plot_trends, inputs=None, outputs=tr_plot)
528
+ with gr.Row():
529
+ dl_btn = gr.Button("Download CSV")
530
+ dl_out = gr.File()
531
+ clr_btn = gr.Button("Clear History", variant="stop")
532
+ clr_msg = gr.Markdown()
533
+ dl_btn.click(lambda: RUN_LOG if os.path.exists(RUN_LOG) else None, inputs=None, outputs=dl_out)
534
+ clr_btn.click(lambda: clear_history(), inputs=None, outputs=clr_msg)
535
+
536
+ # ---------- API (text-only quick JSON) ----------
537
  with gr.Tab("πŸ”Œ API"):
538
+ gr.Markdown("**Text-only JSON API** (quick programmatic use).")
539
  api_in = gr.Textbox(label="Text")
540
  api_out = gr.JSON(label="Response")
541
  gr.Button("Run API").click(text_api, inputs=api_in, outputs=api_out, api_name="text_api")
542
 
543
  # Demo filler
544
  def load_demo():
545
+ return "I'm overwhelmed by school deadlines, but I'm also excited for the new opportunities."
546
  demo_btn.click(load_demo, None, t_in)
547
 
548
  app = demo