ctizzzy0 commited on
Commit
c9236ad
·
verified ·
1 Parent(s): 13af33f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +127 -84
app.py CHANGED
@@ -1,8 +1,9 @@
1
- # app.py — Multi-Modal Emotion AI (Text + Voice + Face)
2
- # Features: per-modality analysis, fusion (weighted), safety screen, CBT distortions,
3
- # PDF report with charts, trends logging, face auto-crop. CPU-friendly for HF Spaces.
 
4
 
5
- import os, io, json, datetime
6
  from typing import Dict, List, Optional, Tuple
7
 
8
  import numpy as np
@@ -15,34 +16,36 @@ import gradio as gr
15
  from fpdf import FPDF
16
  from transformers import pipeline
17
 
18
- # -----------------------------
19
- # Public, lightweight models
20
- # -----------------------------
21
- TEXT_MODEL = "SamLowe/roberta-base-go_emotions" # 27 emotions
22
- VOICE_MODEL = "superb/wav2vec2-base-superb-er" # speech emotion recognition
 
 
 
 
 
 
 
 
 
 
 
23
  FACE_MODEL = "trpakov/vit-face-expression" # facial expression (ViT)
24
 
 
25
  text_pipe = pipeline("text-classification", model=TEXT_MODEL, top_k=None)
26
  voice_pipe = pipeline("audio-classification", model=VOICE_MODEL, top_k=None)
27
  face_pipe = pipeline("image-classification", model=FACE_MODEL, top_k=None)
28
 
29
- # -----------------------------
30
- # Files / persistence
31
- # -----------------------------
32
- RUN_LOG = "runs.csv"
33
- if not os.path.exists(RUN_LOG):
34
- pd.DataFrame(columns=["timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"]).to_csv(RUN_LOG, index=False)
35
-
36
- os.makedirs("charts", exist_ok=True)
37
-
38
- # -----------------------------
39
- # Safety & CBT
40
- # -----------------------------
41
  RISK_TERMS = {
42
  "self_harm": ["kill myself","end it","suicide","self harm","cutting","overdose"],
43
  "violence": ["hurt them","attack","kill them","shoot","stab","revenge"]
44
  }
45
-
46
  DISTORTIONS = {
47
  "catastrophizing": ["ruined","disaster","worst ever","nothing will work","everything is over"],
48
  "all_or_nothing": ["always","never","completely","totally","entirely"],
@@ -79,13 +82,15 @@ def detect_distortions(text: str) -> List[str]:
79
  def reframe_tips(names: List[str]) -> List[str]:
80
  return [REFRAMES[n] for n in names if n in REFRAMES]
81
 
82
- # -----------------------------
83
- # Emotion utilities
84
- # -----------------------------
85
- POSITIVE = set(["admiration","amusement","approval","gratitude","joy","love","optimism","relief","pride","excitement"])
86
- NEGATIVE = set(["anger","annoyance","disappointment","disapproval","disgust","embarrassment","fear","grief","nervousness","remorse","sadness"])
 
 
87
 
88
- def to_probs(outputs) -> Dict[str,float]:
89
  # pipelines return list[list[{"label","score"}]] when top_k=None
90
  if isinstance(outputs, list) and outputs and isinstance(outputs[0], list):
91
  outputs = outputs[0]
@@ -104,7 +109,7 @@ def positivity_index(prob: Optional[Dict[str,float]]) -> float:
104
  neg = sum(prob.get(k,0.0) for k in NEGATIVE)
105
  return round((pos - neg + 1)/2, 4) # [-1,1] -> [0,1]
106
 
107
- def union_merge(dicts: List[Optional[Dict[str,float]]], weights: List[float]) -> Dict[str,float]:
108
  labels = set()
109
  for d in dicts:
110
  if d: labels |= set(d.keys())
@@ -119,7 +124,7 @@ def union_merge(dicts: List[Optional[Dict[str,float]]], weights: List[float]) ->
119
  def bar_fig(prob: Dict[str,float], title: str):
120
  labels = list(prob.keys())
121
  vals = [prob[k]*100 for k in labels]
122
- fig, ax = plt.subplots(figsize=(7.0, 3.6))
123
  ax.bar(labels, vals)
124
  ax.set_ylim(0, 100)
125
  ax.set_ylabel("Probability (%)")
@@ -135,14 +140,14 @@ def save_chart(prob: Dict[str,float], title: str, path: str):
135
  fig.savefig(path, dpi=160, bbox_inches="tight")
136
  plt.close(fig)
137
 
138
- # -----------------------------
139
  # Computer vision: face crop
140
- # -----------------------------
141
  HAAR = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
142
  def crop_face(image_path: str) -> Image.Image:
143
  try:
144
  img = cv2.imread(image_path)
145
- if img is None: # fallback
146
  return Image.open(image_path).convert("RGB")
147
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
148
  faces = HAAR.detectMultiScale(gray, scaleFactor=1.2, minNeighbors=5, minSize=(80,80))
@@ -153,13 +158,15 @@ def crop_face(image_path: str) -> Image.Image:
153
  except Exception:
154
  return Image.open(image_path).convert("RGB")
155
 
156
- # -----------------------------
157
  # Per-modality inference
158
- # -----------------------------
159
  def analyze_text(text: str):
160
  if not text or not text.strip():
161
  return gr.Error("Please enter text."), None, None
162
- probs = to_probs(text_pipe(text))
 
 
163
  msg = f"**Top Text Emotion:** {top_item(probs)} | **Positivity Index:** {positivity_index(probs):.2f}"
164
  fig = bar_fig(probs, "Text Emotions")
165
  return msg, fig, json.dumps(probs)
@@ -167,7 +174,9 @@ def analyze_text(text: str):
167
  def analyze_voice(audio_path: Optional[str]):
168
  if not audio_path:
169
  return "No audio provided.", None, None
170
- probs = to_probs(voice_pipe(audio_path))
 
 
171
  msg = f"**Top Voice Emotion:** {top_item(probs)}"
172
  fig = bar_fig(probs, "Voice Emotions")
173
  return msg, fig, json.dumps(probs)
@@ -175,15 +184,18 @@ def analyze_voice(audio_path: Optional[str]):
175
  def analyze_face(image_path: Optional[str]):
176
  if not image_path:
177
  return "No image provided.", None, None
178
- face_img = crop_face(image_path)
179
- probs = to_probs(face_pipe(face_img))
 
 
 
180
  msg = f"**Top Face Emotion:** {top_item(probs)}"
181
  fig = bar_fig(probs, "Face Emotions")
182
  return msg, fig, json.dumps(probs)
183
 
184
- # -----------------------------
185
  # PDF Report
186
- # -----------------------------
187
  def build_pdf(text_in: str,
188
  text_prob: Optional[Dict[str,float]],
189
  voice_prob: Optional[Dict[str,float]],
@@ -194,17 +206,18 @@ def build_pdf(text_in: str,
194
 
195
  # save charts
196
  paths = []
197
- if text_prob: save_chart(text_prob, "Text Emotions", "charts/text.png"); paths.append("charts/text.png")
198
- if voice_prob: save_chart(voice_prob, "Voice Emotions", "charts/voice.png"); paths.append("charts/voice.png")
199
- if face_prob: save_chart(face_prob, "Face Emotions", "charts/face.png"); paths.append("charts/face.png")
200
- if fused_prob: save_chart(fused_prob, "Fused Profile", "charts/fused.png"); paths.append("charts/fused.png")
201
 
202
  pdf = FPDF()
203
  pdf.add_page()
204
  pdf.set_font("Arial", size=16)
205
- pdf.cell(0, 10, "Multi-Modal Emotion Report", ln=True, align="C")
206
 
207
  pdf.set_font("Arial", size=12)
 
208
  pdf.cell(0, 8, f"Timestamp: {datetime.datetime.now().isoformat(sep=' ', timespec='seconds')}", ln=True)
209
  pdf.multi_cell(0, 8, f"Input Text: {text_in or '(none)'}")
210
  pdf.ln(2)
@@ -226,18 +239,18 @@ def build_pdf(text_in: str,
226
  pdf.multi_cell(0, 7, f" • {t}")
227
  pdf.ln(2)
228
 
229
- for p in paths:
230
- if os.path.exists(p):
231
- pdf.image(p, w=180)
232
  pdf.ln(4)
233
 
234
  out = "emotion_report.pdf"
235
  pdf.output(out)
236
  return out
237
 
238
- # -----------------------------
239
- # Trends
240
- # -----------------------------
241
  def log_run(row: dict):
242
  df = pd.read_csv(RUN_LOG)
243
  df.loc[len(df)] = row
@@ -248,37 +261,35 @@ def plot_trends():
248
  return None
249
  df = pd.read_csv(RUN_LOG)
250
  if df.empty: return None
251
- df["date"] = pd.to_datetime(df["timestamp"]).dt.date
 
252
  daily = df.groupby("date")["pos_index"].mean().reset_index()
253
- fig, ax = plt.subplots(figsize=(7,3.2))
254
  ax.plot(daily["date"], daily["pos_index"], marker="o")
255
  ax.set_ylim(0,1)
256
- ax.set_ylabel("Positivity Index (0-1)")
257
  ax.set_title("Positivity Trend")
258
  plt.xticks(rotation=25, ha="right"); plt.tight_layout()
259
  return fig
260
 
261
- # -----------------------------
262
- # Fusion handler
263
- # -----------------------------
264
  def fuse_and_report(text_json, voice_json, face_json, text_raw, w_text, w_voice, w_face):
265
  te = json.loads(text_json) if text_json else None
266
  ve = json.loads(voice_json) if voice_json else None
267
  fe = json.loads(face_json) if face_json else None
268
- weights = [w_text, w_voice, w_face]
269
- s = sum(weights) or 1.0
270
- weights = [w/s for w in weights]
271
- fused = union_merge([te, ve, fe], weights) if (te or ve or fe) else None
272
 
273
- # safety + CBT
274
  safety_level, safety_hits = safety_screen(text_raw or "")
275
  distos = detect_distortions(text_raw or "")
276
- tips = reframe_tips(distos)
277
 
278
- # pdf
279
  pdf_path = build_pdf(text_raw, te, ve, fe, fused, safety_level, safety_hits, distos, tips)
280
 
281
- # log
282
  pi_val = positivity_index(te)
283
  log_run({
284
  "timestamp": datetime.datetime.now().isoformat(sep=" ", timespec="seconds"),
@@ -290,26 +301,49 @@ def fuse_and_report(text_json, voice_json, face_json, text_raw, w_text, w_voice,
290
  "pos_index": pi_val
291
  })
292
 
293
- msg = f"**Fused Top:** {top_item(fused) or '(insufficient inputs)'} | Weights → Text:{weights[0]:.2f}, Voice:{weights[1]:.2f}, Face:{weights[2]:.2f}"
 
294
  plot = bar_fig(fused, "Fused Emotional Profile") if fused else None
295
  return msg, plot, pdf_path
296
 
297
- # -----------------------------
 
 
 
 
 
 
 
 
 
 
 
298
  # Gradio UI
299
- # -----------------------------
300
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
301
- gr.Markdown("# 🧠 Multi-Modal Emotion AI (Text + Voice + Face)")
 
 
 
 
 
 
 
302
  gr.Markdown("Analyze emotions across **text, voice, and face**, detect **safety risks** and **cognitive distortions**, "
303
  "tune **fusion weights**, and download a **PDF report**. Audio/image are optional.")
304
 
305
- # state holders
306
  st_text_json = gr.State()
307
  st_voice_json = gr.State()
308
  st_face_json = gr.State()
309
  st_text_raw = gr.State()
310
 
 
 
 
 
311
  with gr.Tab("📝 Text"):
312
- t_in = gr.Textbox(label="Your text", lines=3, placeholder="How are you feeling today?")
313
  t_btn = gr.Button("Analyze Text", variant="primary")
314
  t_msg = gr.Markdown()
315
  t_plot = gr.Plot()
@@ -319,14 +353,15 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
319
  t_btn.click(_t_chain, inputs=t_in, outputs=[t_msg, t_plot, st_text_json, st_text_raw])
320
 
321
  with gr.Tab("🎤 Voice"):
322
- a_in = gr.Audio(sources=["microphone","upload"], type="filepath", label="Record or upload audio (optional)")
 
323
  a_btn = gr.Button("Analyze Voice", variant="primary")
324
  a_msg = gr.Markdown()
325
  a_plot = gr.Plot()
326
  a_btn.click(analyze_voice, inputs=a_in, outputs=[a_msg, a_plot, st_voice_json])
327
 
328
  with gr.Tab("📷 Face"):
329
- f_in = gr.Image(type="filepath", label="Upload a face image (optional)")
330
  f_btn = gr.Button("Analyze Face", variant="primary")
331
  f_msg = gr.Markdown()
332
  f_plot = gr.Plot()
@@ -334,9 +369,9 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
334
 
335
  with gr.Tab("🧩 Fusion + Report"):
336
  with gr.Row():
337
- w_text = gr.Slider(0, 1, value=0.5, step=0.05, label="Text weight")
338
- w_voice = gr.Slider(0, 1, value=0.3, step=0.05, label="Voice weight")
339
- w_face = gr.Slider(0, 1, value=0.2, step=0.05, label="Face weight")
340
  fuse_btn = gr.Button("Fuse & Generate PDF", variant="primary")
341
  fuse_msg = gr.Markdown()
342
  fuse_plot = gr.Plot()
@@ -344,20 +379,28 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
344
  fuse_btn.click(
345
  fuse_and_report,
346
  inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, w_text, w_voice, w_face],
347
- outputs=[fuse_msg, fuse_plot, fuse_pdf]
 
348
  )
 
 
 
349
 
350
  with gr.Tab("📈 Trends"):
351
  tr_btn = gr.Button("Refresh Positivity Trend")
352
  tr_plot = gr.Plot()
353
  tr_btn.click(plot_trends, inputs=None, outputs=tr_plot)
354
 
355
- with gr.Tab("ℹ️ About"):
356
- gr.Markdown(
357
- "Models: **GoEmotions (text)**, **Wav2Vec2-ER (audio)**, **ViT-Face-Expression (image)**. "
358
- "Privacy: inputs are processed in-session; reports are generated client-side on this Space. "
359
- "This is an educational demo — not medical advice."
360
- )
 
 
 
 
361
 
362
  app = demo
363
 
 
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
 
16
  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)
27
+ if not os.path.exists(RUN_LOG):
28
+ pd.DataFrame(columns=[
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)
41
 
42
+ # =========================
43
+ # Safety & CBT utilities
44
+ # =========================
 
 
 
 
 
 
 
 
 
45
  RISK_TERMS = {
46
  "self_harm": ["kill myself","end it","suicide","self harm","cutting","overdose"],
47
  "violence": ["hurt them","attack","kill them","shoot","stab","revenge"]
48
  }
 
49
  DISTORTIONS = {
50
  "catastrophizing": ["ruined","disaster","worst ever","nothing will work","everything is over"],
51
  "all_or_nothing": ["always","never","completely","totally","entirely"],
 
82
  def reframe_tips(names: List[str]) -> List[str]:
83
  return [REFRAMES[n] for n in names if n in REFRAMES]
84
 
85
+ # =========================
86
+ # Emotion helpers
87
+ # =========================
88
+ POSITIVE = set(["admiration","amusement","approval","gratitude","joy","love",
89
+ "optimism","relief","pride","excitement"])
90
+ NEGATIVE = set(["anger","annoyance","disappointment","disapproval","disgust",
91
+ "embarrassment","fear","grief","nervousness","remorse","sadness"])
92
 
93
+ def pipe_to_probs(outputs) -> Dict[str,float]:
94
  # pipelines return list[list[{"label","score"}]] when top_k=None
95
  if isinstance(outputs, list) and outputs and isinstance(outputs[0], list):
96
  outputs = outputs[0]
 
109
  neg = sum(prob.get(k,0.0) for k in NEGATIVE)
110
  return round((pos - neg + 1)/2, 4) # [-1,1] -> [0,1]
111
 
112
+ def merge_probs(dicts: List[Optional[Dict[str,float]]], weights: List[float]) -> Dict[str,float]:
113
  labels = set()
114
  for d in dicts:
115
  if d: labels |= set(d.keys())
 
124
  def bar_fig(prob: Dict[str,float], title: str):
125
  labels = list(prob.keys())
126
  vals = [prob[k]*100 for k in labels]
127
+ fig, ax = plt.subplots(figsize=(7.2, 3.6))
128
  ax.bar(labels, vals)
129
  ax.set_ylim(0, 100)
130
  ax.set_ylabel("Probability (%)")
 
140
  fig.savefig(path, dpi=160, bbox_inches="tight")
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:
148
  try:
149
  img = cv2.imread(image_path)
150
+ if img is None:
151
  return Image.open(image_path).convert("RGB")
152
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
153
  faces = HAAR.detectMultiScale(gray, scaleFactor=1.2, minNeighbors=5, minSize=(80,80))
 
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)
 
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")
182
  return msg, fig, json.dumps(probs)
 
184
  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]],
201
  voice_prob: Optional[Dict[str,float]],
 
206
 
207
  # save charts
208
  paths = []
209
+ if text_prob: save_chart(text_prob, "Text Emotions", os.path.join(CHARTS_DIR,"text.png")); paths.append(os.path.join(CHARTS_DIR,"text.png"))
210
+ if voice_prob: save_chart(voice_prob, "Voice Emotions", os.path.join(CHARTS_DIR,"voice.png")); paths.append(os.path.join(CHARTS_DIR,"voice.png"))
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)
217
+ pdf.cell(0, 10, f"{APP_NAME} Report", ln=True, align="C")
218
 
219
  pdf.set_font("Arial", size=12)
220
+ pdf.cell(0, 8, f"Version: {APP_VERSION}", ln=True)
221
  pdf.cell(0, 8, f"Timestamp: {datetime.datetime.now().isoformat(sep=' ', timespec='seconds')}", ln=True)
222
  pdf.multi_cell(0, 8, f"Input Text: {text_in or '(none)'}")
223
  pdf.ln(2)
 
239
  pdf.multi_cell(0, 7, f" • {t}")
240
  pdf.ln(2)
241
 
242
+ for pth in paths:
243
+ if os.path.exists(pth):
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
 
261
  return None
262
  df = pd.read_csv(RUN_LOG)
263
  if df.empty: return None
264
+ df["timestamp"] = pd.to_datetime(df["timestamp"])
265
+ df["date"] = df["timestamp"].dt.date
266
  daily = df.groupby("date")["pos_index"].mean().reset_index()
267
+ fig, ax = plt.subplots(figsize=(7.2,3.2))
268
  ax.plot(daily["date"], daily["pos_index"], marker="o")
269
  ax.set_ylim(0,1)
270
+ ax.set_ylabel("Positivity Index (01)")
271
  ax.set_title("Positivity Trend")
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
282
+ ws = [w_text, w_voice, w_face]
283
+ s = sum(ws) or 1.0
284
+ weights = [w/s for w in ws]
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"),
 
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()
 
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")
366
  f_msg = gr.Markdown()
367
  f_plot = gr.Plot()
 
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()
 
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
406