rocky250 commited on
Commit
1e7da4b
·
verified ·
1 Parent(s): 214dad1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +266 -439
app.py CHANGED
@@ -1,8 +1,3 @@
1
- """
2
- app.py — Misinformation Detection & Public Engagement (Gradio 6.x)
3
-
4
- """
5
-
6
  import os
7
  import pandas as pd
8
  import gradio as gr
@@ -32,38 +27,31 @@ from charts import (
32
  )
33
 
34
 
35
- # CSS — Stormy Morning & Ink Wash palette
36
-
37
-
38
  CSS = """
39
- @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Nunito:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
40
 
41
  :root {
42
- --bg: #FFFFE3;
43
- --card: #FFFFFF;
44
- --border: #BDDDFC;
45
- --text: #2d2d2d;
46
- --dim: #555555;
47
- --primary: #269ccc;
48
- --pos: #88BDF2;
49
- --neg: #6A89A7;
50
- --neu: #CBCBCB;
51
- --tag-bg: #BDDDFC;
52
- --tag-text: #384959;
53
- --green: #16a34a;
54
- --red: #dc2626;
55
- --amber: #d97706;
56
- --primary-light: #e8f5fc;
57
- --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.03);
58
- --shadow-md: 0 4px 14px rgba(38,156,204,0.14), 0 1px 3px rgba(0,0,0,0.05);
59
  }
60
 
61
  html, body {
62
  background: var(--bg) !important;
63
- color: var(--text) !important;
64
  margin: 0; padding: 0;
65
  }
66
- .gradio-container, #root, #app, main, .main, .wrap {
67
  background: var(--bg) !important;
68
  max-width: 100% !important;
69
  width: 100% !important;
@@ -79,19 +67,17 @@ div[class*="panel"], div[class*="gap"],
79
  .gr-group, .gr-box, .vv-section {
80
  background: var(--card) !important;
81
  border: 1px solid var(--border) !important;
82
- border-radius: 14px !important;
83
  padding: 1rem 1.25rem !important;
84
- box-shadow: var(--shadow-sm) !important;
85
  }
86
 
87
  .tab-nav button {
88
  background: transparent !important;
89
  border: none !important;
90
  color: var(--dim) !important;
91
- font-family: 'DM Sans', sans-serif !important;
92
- font-size: 0.84rem !important;
93
- font-weight: 500 !important;
94
- letter-spacing: 0.02em !important;
95
  border-bottom: 2px solid transparent !important;
96
  padding: 0.5rem 1.2rem !important;
97
  transition: color 0.18s;
@@ -103,451 +89,336 @@ div[class*="panel"], div[class*="gap"],
103
  .tab-nav { border-bottom: 1px solid var(--border) !important; }
104
 
105
  input[type="text"], input[type="password"], input[type="number"], textarea, select {
106
- background: #FFFFE3 !important;
107
- border: 1.5px solid var(--border) !important;
108
  color: var(--text) !important;
109
- border-radius: 10px !important;
110
- font-family: 'DM Sans', sans-serif !important;
111
  font-size: 0.88rem !important;
112
  }
113
  input:focus, textarea:focus, select:focus {
114
  border-color: var(--primary) !important;
115
- box-shadow: 0 0 0 3px rgba(38,156,204,0.15) !important;
116
  outline: none !important;
117
  }
118
-
119
- label:not(.vv-metric-label):not(.vv-info-label),
120
- .gr-label,
121
- span.svelte-1b6s6s {
122
  color: var(--dim) !important;
123
- font-family: 'DM Sans', sans-serif !important;
124
  font-size: 0.75rem !important;
125
- letter-spacing: 0.05em !important;
126
  text-transform: uppercase;
127
- font-weight: 600 !important;
128
  }
129
 
130
  input[type="range"] { accent-color: var(--primary); }
131
 
132
  button.primary, button[variant="primary"], .primary {
133
- background: linear-gradient(135deg, var(--primary), #1a7faa) !important;
134
  border: none !important;
135
- color: #FFFFE3 !important;
136
- font-weight: 600 !important;
137
- font-family: 'DM Sans', sans-serif !important;
138
- border-radius: 10px !important;
139
- letter-spacing: 0.03em !important;
140
- box-shadow: 0 2px 8px rgba(38,156,204,0.35) !important;
141
  }
142
  button.secondary {
143
- background: var(--primary-light) !important;
144
- border: 1.5px solid var(--primary) !important;
145
  color: var(--primary) !important;
146
- border-radius: 10px !important;
147
- font-family: 'DM Sans', sans-serif !important;
148
- font-weight: 500 !important;
149
  }
150
  button:hover { opacity: 0.88; transform: translateY(-1px); transition: all 0.15s; }
151
 
152
  .dropdown, ul[role="listbox"], li[role="option"] {
153
- background: #FFFFE3 !important;
154
  border-color: var(--border) !important;
155
  color: var(--text) !important;
156
  }
157
- li[role="option"]:hover { background: var(--primary-light) !important; }
158
 
159
- .gr-dataframe, table {
160
- background: var(--card) !important;
161
- border-radius: 10px !important;
162
- overflow: hidden;
163
- }
164
  .gr-dataframe th {
165
- background: var(--primary-light) !important;
166
  color: var(--primary) !important;
167
- font-family: 'DM Sans', sans-serif !important;
168
  font-size: 0.72rem !important;
169
- padding: 8px 12px;
170
  border-bottom: 1px solid var(--border);
171
  text-transform: uppercase;
172
- letter-spacing: 0.07em;
173
- font-weight: 700;
174
  }
175
  .gr-dataframe td {
176
  color: var(--text) !important;
177
- font-size: 0.8rem !important;
178
- padding: 7px 12px;
179
  border-bottom: 1px solid var(--border);
180
  }
181
- .gr-dataframe tr:hover td { background: var(--primary-light) !important; }
182
 
183
  details > summary {
184
  color: var(--dim) !important;
185
- font-family: 'DM Sans', sans-serif !important;
186
- font-size: 0.84rem !important;
187
  cursor: pointer;
188
  list-style: none;
189
- font-weight: 500;
190
  }
191
  details[open] > summary { color: var(--primary) !important; }
192
 
193
  .js-plotly-plot, .plotly { background: transparent !important; }
194
  .modebar { display: none !important; }
195
 
196
- .js-plotly-plot text,
197
- .js-plotly-plot .gtitle,
198
- .js-plotly-plot .xtitle,
199
- .js-plotly-plot .ytitle,
200
- .js-plotly-plot .xtick text,
201
- .js-plotly-plot .ytick text,
202
- .js-plotly-plot .legendtext {
203
- fill: #2d2d2d !important;
204
- }
205
-
206
  ::-webkit-scrollbar { width: 6px; height: 6px; }
207
  ::-webkit-scrollbar-track { background: var(--bg); }
208
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
209
  ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
210
 
211
-
212
  .vv-hero {
213
- font-family: 'Nunito', sans-serif !important;
214
- font-size: 1.7rem !important;
215
  font-weight: 800 !important;
216
- background: linear-gradient(135deg, #269ccc, #88BDF2);
217
- -webkit-background-clip: text !important;
218
- -webkit-text-fill-color: transparent !important;
219
- background-clip: text !important;
220
- letter-spacing: -0.02em !important;
221
- line-height: 1.2 !important;
222
  }
223
-
224
  .vv-section-title {
225
- font-family: 'DM Sans', sans-serif !important;
226
  font-size: 0.68rem !important;
227
  font-weight: 700 !important;
228
- letter-spacing: 0.16em !important;
229
  text-transform: uppercase !important;
230
- color: #2d2d2d !important;
231
  margin-bottom: 0.5rem !important;
232
  margin-top: 0 !important;
233
  }
234
 
 
 
 
 
 
 
 
 
235
  .vv-metric-grid {
236
  display: grid !important;
237
  grid-template-columns: repeat(4, 1fr) !important;
238
- gap: 10px !important;
239
- margin: 0.5rem 0 1rem !important;
240
  }
241
  .vv-metric-card {
242
  background: #FFFFFF !important;
243
- border: 1.5px solid #BDDDFC !important;
244
- border-radius: 14px !important;
245
- padding: 1rem 0.7rem !important;
246
  text-align: center !important;
 
247
  cursor: default !important;
248
- transition: all 0.22s ease !important;
249
- box-shadow: 0 1px 4px rgba(189,221,252,0.25) !important;
250
- position: relative !important;
251
- overflow: hidden !important;
252
- }
253
- .vv-metric-card::before {
254
- content: '' !important;
255
- position: absolute !important;
256
- top: 0; left: 0; right: 0 !important;
257
- height: 3px !important;
258
- background: linear-gradient(90deg, #269ccc, #88BDF2) !important;
259
- opacity: 0 !important;
260
- transition: opacity 0.22s ease !important;
261
  }
262
  .vv-metric-card:hover {
263
  transform: translateY(-4px) !important;
264
- box-shadow: 0 10px 26px rgba(38,156,204,0.2) !important;
265
- border-color: #269ccc !important;
266
- }
267
- .vv-metric-card:hover::before { opacity: 1 !important; }
268
- .vv-metric-icon {
269
- font-size: 1.4rem !important;
270
- margin-bottom: 4px !important;
271
- display: block !important;
272
  }
273
  .vv-metric-value {
274
- font-family: 'Nunito', sans-serif !important;
275
- font-size: 1.25rem !important;
276
- font-weight: 800 !important;
 
277
  color: #269ccc !important;
278
  margin: 0 !important;
279
  line-height: 1.2 !important;
280
- -webkit-text-fill-color: #269ccc !important;
281
  }
282
  .vv-metric-label {
283
- font-family: 'DM Sans', sans-serif !important;
284
- font-size: 0.62rem !important;
285
- font-weight: 600 !important;
286
- letter-spacing: 0.1em !important;
287
- text-transform: uppercase !important;
288
- color: #555555 !important;
289
- -webkit-text-fill-color: #555555 !important;
290
- margin-top: 2px !important;
291
- }
292
-
293
- .vv-info-grid {
294
- display: grid !important;
295
- grid-template-columns: 1fr 1fr !important;
296
- gap: 8px !important;
297
- margin: 0.5rem 0 !important;
298
- }
299
- .vv-info-item {
300
- background: #FFFFE3 !important;
301
- border: 1px solid #BDDDFC !important;
302
- border-radius: 9px !important;
303
- padding: 0.55rem 0.75rem !important;
304
- }
305
- .vv-info-label {
306
  display: block !important;
307
- font-family: 'DM Sans', sans-serif !important;
308
  font-size: 0.62rem !important;
309
- font-weight: 700 !important;
310
- letter-spacing: 0.12em !important;
311
  text-transform: uppercase !important;
312
- color: #555555 !important;
313
- -webkit-text-fill-color: #555555 !important;
314
- margin-bottom: 2px !important;
315
- }
316
- .vv-info-value {
317
- display: block !important;
318
- font-family: 'DM Sans', sans-serif !important;
319
- font-size: 0.82rem !important;
320
- font-weight: 600 !important;
321
- color: #2d2d2d !important;
322
- -webkit-text-fill-color: #2d2d2d !important;
323
- white-space: nowrap !important;
324
- overflow: hidden !important;
325
- text-overflow: ellipsis !important;
326
  }
327
 
328
- .vv-tags-grid {
329
- display: flex !important;
330
- flex-wrap: wrap !important;
331
- gap: 5px !important;
332
- margin-top: 0.4rem !important;
333
- }
334
- .vv-tag {
335
  display: inline-block !important;
336
- background: #BDDDFC !important;
337
- border: 1px solid #9dcbf7 !important;
338
- border-radius: 20px !important;
339
- padding: 3px 12px !important;
340
- font-family: 'DM Sans', sans-serif !important;
341
- font-size: 0.7rem !important;
342
- font-weight: 600 !important;
343
- color: #384959 !important;
344
- -webkit-text-fill-color: #384959 !important;
345
- transition: background 0.15s, transform 0.12s !important;
346
- }
347
- .vv-tag:hover { background: #a5cef8 !important; transform: translateY(-1px) !important; }
348
-
349
- .vv-card {
350
- background: #FFFFFF !important;
351
  border: 1px solid #BDDDFC !important;
352
- border-radius: 12px !important;
353
- padding: 1rem 1.1rem !important;
354
- margin-bottom: 0.7rem !important;
355
- box-shadow: 0 1px 4px rgba(189,221,252,0.2) !important;
 
 
356
  }
 
357
  .vv-badge-green {
358
  display: inline-block !important;
359
- background: #dcfce7 !important;
360
- border: 1.5px solid #16a34a !important;
361
- color: #15803d !important;
362
- -webkit-text-fill-color: #15803d !important;
363
  border-radius: 20px !important;
364
  padding: 0.32rem 1.1rem !important;
365
  font-size: 0.85rem !important;
366
- font-family: 'DM Sans', sans-serif !important;
367
- font-weight: 700 !important;
368
  }
369
  .vv-badge-red {
370
  display: inline-block !important;
371
- background: #fee2e2 !important;
372
- border: 1.5px solid #dc2626 !important;
373
- color: #b91c1c !important;
374
- -webkit-text-fill-color: #b91c1c !important;
375
  border-radius: 20px !important;
376
  padding: 0.32rem 1.1rem !important;
377
  font-size: 0.85rem !important;
378
- font-family: 'DM Sans', sans-serif !important;
379
- font-weight: 700 !important;
380
  }
381
  .vv-badge-amber {
382
  display: inline-block !important;
383
- background: #fef3c7 !important;
384
- border: 1.5px solid #d97706 !important;
385
- color: #b45309 !important;
386
- -webkit-text-fill-color: #b45309 !important;
387
  border-radius: 20px !important;
388
  padding: 0.32rem 1.1rem !important;
389
  font-size: 0.85rem !important;
390
- font-family: 'DM Sans', sans-serif !important;
391
- font-weight: 700 !important;
392
  }
 
393
  .vv-reasoning {
394
- background: #fefce8 !important;
395
- border-left: 3px solid #d97706 !important;
396
  padding: 0.8rem 1rem !important;
397
- border-radius: 0 10px 10px 0 !important;
398
  font-size: 0.83rem !important;
399
- color: #78350f !important;
400
- -webkit-text-fill-color: #78350f !important;
401
  line-height: 1.65 !important;
402
- font-family: 'DM Sans', sans-serif !important;
403
  margin-top: 8px !important;
404
  }
405
 
406
- .vv-stat-big-pos {
407
- font-family: 'Nunito', sans-serif !important;
408
- font-size: 1.6rem !important;
409
- font-weight: 800 !important;
410
- color: #3a8fd1 !important;
411
- -webkit-text-fill-color: #3a8fd1 !important;
412
- margin: 0 !important;
413
- }
414
- .vv-stat-big-neg {
415
- font-family: 'Nunito', sans-serif !important;
416
- font-size: 1.6rem !important;
417
- font-weight: 800 !important;
418
- color: #4a6a87 !important;
419
- -webkit-text-fill-color: #4a6a87 !important;
420
- margin: 0 !important;
421
  }
422
- .vv-stat-big-dim {
423
- font-family: 'Nunito', sans-serif !important;
 
424
  font-size: 1.6rem !important;
425
- font-weight: 800 !important;
426
- color: #888888 !important;
427
- -webkit-text-fill-color: #888888 !important;
428
  margin: 0 !important;
429
  }
430
- .vv-stat-big-green {
431
- font-family: 'Nunito', sans-serif !important;
432
  font-size: 1.6rem !important;
433
- font-weight: 800 !important;
434
- color: #3a8fd1 !important;
435
- -webkit-text-fill-color: #3a8fd1 !important;
436
  margin: 0 !important;
437
  }
438
- .vv-stat-big-red {
439
- font-family: 'Nunito', sans-serif !important;
440
  font-size: 1.6rem !important;
441
- font-weight: 800 !important;
442
- color: #4a6a87 !important;
443
- -webkit-text-fill-color: #4a6a87 !important;
444
  margin: 0 !important;
445
  }
446
-
447
  .vv-log-line {
448
  font-size: 0.72rem !important;
449
- color: #555555 !important;
450
- -webkit-text-fill-color: #555555 !important;
451
- font-family: 'JetBrains Mono', monospace !important;
452
  margin: 2px 0 !important;
453
  }
454
- .vv-hr { border: none !important; border-top: 1px solid #BDDDFC !important; margin: 1.1rem 0 !important; }
455
  """
456
 
457
 
458
- # HELPERS
459
-
460
-
461
  def _empty_plotly(msg: str = "Run analysis to see data", h: int = 230):
462
  import plotly.graph_objects as go
463
  fig = go.Figure()
464
  fig.update_layout(
465
- paper_bgcolor="rgba(255,255,227,0)", plot_bgcolor="rgba(189,221,252,0.13)",
466
- font=dict(color="#2d2d2d"), margin=dict(l=10, r=10, t=10, b=10), height=h,
467
  )
468
  fig.add_annotation(
469
  text=msg, x=0.5, y=0.5, xref="paper", yref="paper",
470
- showarrow=False, font=dict(size=12, color="#555555"),
471
  )
472
  return fig
473
 
474
 
475
  def _blank_outputs(status_msg: str):
476
- """19-tuple for ALL_OUTPUTS when nothing has run."""
477
  ep = _empty_plotly()
478
  return (
479
- f'<p style="color:#dc2626;font-family:DM Sans,sans-serif;padding:8px;font-weight:500">{status_msg}</p>', # 0 status
480
- "<p class='vv-log-line'>—</p>", # 1 log
481
- "<div style='padding:3rem;text-align:center;color:#7b7b7b;font-family:DM Sans,sans-serif'>No data yet.</div>", # 2 left panel
482
- "", "", # 3 badge, 4 reasoning
483
- ep, ep, ep, # 5 modality_dist, 6 trust, 7 uncertainty
484
- ep, ep, ep, ep, # 8 donut, 9 timeline, 10 kw_bar, 11 kw_comp
485
- "", "", "", # 12 stat_pos, 13 stat_neg, 14 stat_neu
486
- pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), # 15 df_all, 16 df_pos, 17 df_neg, 18 df_top
487
  )
488
 
489
 
490
- # PIPELINE
491
-
492
-
493
  def run_pipeline(
494
  url_or_id: str,
495
  sentiment_method: str,
496
  max_comments: int,
497
  progress=gr.Progress(track_tqdm=False),
498
  ):
499
- # Read API key from environment (NEVER from UI)
500
  api_key = os.environ.get("YT_API_KEY", "").strip()
501
 
502
- # Guards
503
  if not (url_or_id or "").strip():
504
- yield _blank_outputs(" Please enter a YouTube URL or video ID.")
505
  return
506
 
507
  video_id = extract_video_id(url_or_id.strip())
508
  if not video_id:
509
- yield _blank_outputs(" Could not parse a valid YouTube video ID.")
510
  return
511
 
512
  if not api_key:
513
  yield _blank_outputs(
514
- " YouTube API key not found. "
515
  "Set the <code>YT_API_KEY</code> environment variable / Space secret."
516
  )
517
  return
518
 
519
- # 1 — Metadata
520
  progress(0.05, desc="Fetching video metadata…")
521
  meta, err = fetch_video_metadata(video_id, api_key)
522
  if err:
523
- yield _blank_outputs(f" {err}")
524
  return
525
 
526
- # 2 — Transcript
527
  progress(0.20, desc="Fetching transcript…")
528
  transcript, t_status = fetch_transcript(video_id)
529
 
530
- # 3 — Comments
531
  progress(0.35, desc=f"Fetching up to {max_comments} comments…")
532
  comments_df, c_status = fetch_comments(video_id, api_key, max_comments=int(max_comments))
533
 
534
- # 4 — Misinformation
535
-
536
  progress(0.50, desc="Running misinformation detection…")
537
  misinfo = detect_misinformation(
538
  text=f"{meta['title']} {meta['description']}",
539
  tags=meta["tags"],
540
- audio_transcript=transcript, # speech/audio stream
541
- video_transcript=transcript, # enriched inside analyzer with title+tags
542
  )
543
 
544
- # 5 — Keywords
545
  keywords = extract_keywords(
546
  f"{meta['title']} {meta['description']} {transcript}",
547
  meta["tags"],
548
  )
549
 
550
- # 6 — Sentiment
551
  sentiments, sent_sum, pos_kw, neg_kw = [], {}, [], []
552
 
553
  if not comments_df.empty:
@@ -562,7 +433,6 @@ def run_pipeline(
562
  sent_sum = sentiment_summary(sentiments)
563
  pos_kw, neg_kw = sentiment_weighted_keywords(comments_df, sentiments)
564
 
565
- # 7 — Build outputs
566
  progress(0.97, desc="Building charts…")
567
  yield _build_outputs(
568
  meta=meta, video_id=video_id, transcript=transcript,
@@ -570,7 +440,7 @@ def run_pipeline(
570
  sentiments=sentiments, sent_sum=sent_sum,
571
  pos_kw=pos_kw, neg_kw=neg_kw,
572
  status_log=[
573
- f" Metadata: {meta['title'][:55]}",
574
  t_status,
575
  c_status,
576
  f"🔬 Misinfo score: {misinfo['confidence_pct']}%",
@@ -583,27 +453,20 @@ def run_pipeline(
583
  )
584
 
585
 
586
- # OUTPUT BUILDER
587
-
588
-
589
  def _build_outputs(
590
  meta, video_id, transcript, comments_df,
591
  misinfo, keywords, sentiments, sent_sum, pos_kw, neg_kw, status_log,
592
  ):
593
- # Status
594
  status_html = (
595
- '<p style="color:#16a34a;font-family:DM Sans,sans-serif;font-size:0.85rem;'
596
- 'font-weight:600;padding:6px 0"> Analysis complete</p>'
597
  )
598
 
599
- # Log
600
  log_html = "".join(f'<p class="vv-log-line">{line}</p>' for line in status_log)
601
 
602
- # Left panel
603
  thumb_html = (
604
  f'<img src="{meta["thumbnail_url"]}" '
605
- 'style="width:100%;border-radius:10px;margin-bottom:8px;display:block;'
606
- 'box-shadow:0 4px 14px rgba(189,221,252,0.4)">'
607
  if meta.get("thumbnail_url") else ""
608
  )
609
  tag_html = "".join(f'<span class="vv-tag">#{t}</span>' for t in meta.get("tags", [])[:20])
@@ -614,75 +477,60 @@ def _build_outputs(
614
  left_html = f"""
615
  {thumb_html}
616
  <a href="https://www.youtube.com/watch?v={video_id}" target="_blank"
617
- style="display:block;text-align:center;font-family:'DM Sans',sans-serif;
618
- font-size:0.78rem;color:#269ccc;text-decoration:none;margin:4px 0 10px;
619
- font-weight:600;letter-spacing:0.03em">
620
  ▶ Open on YouTube
621
  </a>
622
  <div class="vv-card">
623
  <p class="vv-section-title">Video</p>
624
- <p style="font-family:'Nunito',sans-serif;font-size:1.05rem;font-weight:800;margin:0 0 6px;color:#4A4A4A;line-height:1.35">
625
  {meta['title']}
626
  </p>
627
- <div class="vv-info-grid">
628
- <div class="vv-info-item">
629
- <span class="vv-info-label">Author</span>
630
- <span class="vv-info-value" title="{meta['channel_title']}">{meta['channel_title']}</span>
631
- </div>
632
- <div class="vv-info-item">
633
- <span class="vv-info-label">Published</span>
634
- <span class="vv-info-value">{meta['published_at']}</span>
635
- </div>
636
- </div>
637
  </div>
638
 
639
  <p class="vv-section-title">Metrics</p>
640
  <div class="vv-metric-grid">
641
  <div class="vv-metric-card">
642
- <span class="vv-metric-icon">👁</span>
643
- <div class="vv-metric-value">{meta['view_count']:,}</div>
644
- <div class="vv-metric-label">Views</div>
645
  </div>
646
  <div class="vv-metric-card">
647
- <span class="vv-metric-icon">👍</span>
648
- <div class="vv-metric-value">{meta['like_count']:,}</div>
649
- <div class="vv-metric-label">Likes</div>
650
  </div>
651
  <div class="vv-metric-card">
652
- <span class="vv-metric-icon">💬</span>
653
- <div class="vv-metric-value">{meta['comment_count']:,}</div>
654
- <div class="vv-metric-label">Comments</div>
655
  </div>
656
  <div class="vv-metric-card">
657
- <span class="vv-metric-icon">⏱</span>
658
- <div class="vv-metric-value" style="font-size:1.0rem">{meta['duration']}</div>
659
- <div class="vv-metric-label">Duration</div>
660
  </div>
661
  </div>
662
 
663
  <p class="vv-section-title" style="margin-top:0.8rem">Tags</p>
664
- <div class="vv-tags-grid">
665
- {tag_html or '<span style="color:#7b7b7b;font-size:0.78rem">(none)</span>'}
666
- </div>
667
 
668
  <details style="margin-top:1rem">
669
  <summary>📄 Description</summary>
670
- <p style="font-size:0.79rem;color:#4A4A4A;line-height:1.7;white-space:pre-wrap;margin-top:8px;
671
- background:#FFFFE3;border:1px solid #BDDDFC;border-radius:8px;padding:0.7rem">{desc_short}</p>
672
  </details>
673
  <details style="margin-top:0.5rem">
674
  <summary>📝 Transcript ({word_count} words)</summary>
675
- <p style="font-size:0.76rem;color:#4A4A4A;line-height:1.7;margin-top:8px;
676
- background:#FFFFE3;border:1px solid #BDDDFC;border-radius:8px;padding:0.7rem">{transcript_short}</p>
677
  </details>
678
  """
679
 
680
- # Misinfo badge
681
  score = misinfo["score"]
682
  if score < 0.35:
683
- badge_html = '<span class="vv-badge-green"> Appears Credible</span>'
684
  elif score < 0.65:
685
- badge_html = '<span class="vv-badge-amber"> Uncertain / Mixed Signals</span>'
686
  else:
687
  badge_html = '<span class="vv-badge-red">🚨 Likely Misinformation</span>'
688
 
@@ -690,7 +538,6 @@ def _build_outputs(
690
  f'<div class="vv-reasoning">🧠 <b>Reasoning:</b> {misinfo["reasoning"]}</div>'
691
  )
692
 
693
- # Three new modality charts — derived from model logit/softmax/entropy
694
  mod_analysis = misinfo.get("modality_analysis", {})
695
 
696
  try:
@@ -708,7 +555,6 @@ def _build_outputs(
708
  except Exception:
709
  fig_uncert = _empty_plotly("Uncertainty analysis unavailable")
710
 
711
- # Sentiment charts (unchanged)
712
  try:
713
  fig_donut = sentiment_donut(sent_sum) if sent_sum else _empty_plotly("No comments analysed")
714
  except Exception:
@@ -737,96 +583,99 @@ def _build_outputs(
737
  except Exception:
738
  fig_kw_comp = _empty_plotly()
739
 
740
- # Sentiment stat boxes — Stormy Morning palette
741
  if sent_sum:
742
  stat_pos = (
743
- f'<div class="vv-card" style="text-align:center;border-top:3px solid #88BDF2">'
744
- f'<p class="vv-stat-big-pos">{sent_sum["pos_pct"]}%</p>'
745
- f'<p style="color:#7b7b7b;font-size:0.75rem;margin:4px 0 0;font-family:DM Sans,sans-serif;font-weight:600">Positively Engagement</p></div>'
746
  )
747
  stat_neg = (
748
- f'<div class="vv-card" style="text-align:center;border-top:3px solid #6A89A7">'
749
- f'<p class="vv-stat-big-neg">{sent_sum["neg_pct"]}%</p>'
750
- f'<p style="color:#7b7b7b;font-size:0.75rem;margin:4px 0 0;font-family:DM Sans,sans-serif;font-weight:600">Negatively Engagement</p></div>'
751
  )
752
  stat_neu = (
753
- f'<div class="vv-card" style="text-align:center;border-top:3px solid #CBCBCB">'
754
  f'<p class="vv-stat-big-dim">{sent_sum["neu_pct"]}%</p>'
755
- f'<p style="color:#7b7b7b;font-size:0.75rem;margin:4px 0 0;font-family:DM Sans,sans-serif;font-weight:600">Neutral</p></div>'
756
  )
757
  else:
758
  placeholder = (
759
- '<div class="vv-card" style="text-align:center;color:#7b7b7b;'
760
- 'font-family:DM Sans,sans-serif;font-size:0.8rem;padding:1.2rem">N/A</div>'
761
  )
762
  stat_pos = stat_neg = stat_neu = placeholder
763
 
764
- # Comment DataFrames (unchanged)
765
  show_cols = ["author", "text", "likes", "published_at"]
766
  df_all = df_pos = df_neg = df_top = pd.DataFrame()
767
 
768
  if not comments_df.empty:
769
  display_df = comments_df.copy()
770
  if sentiments:
771
- display_df["sentiment"] = [s["label"] for s in sentiments]
772
- display_df["compound"] = [round(s.get("compound", 0), 3) for s in sentiments]
773
  cols = show_cols + ["sentiment", "compound"]
774
  else:
775
  cols = show_cols
776
 
 
 
 
 
 
 
 
 
 
 
 
777
  df_all = display_df[cols].head(100).reset_index(drop=True)
778
  df_top = (
779
  display_df.sort_values("likes", ascending=False)
780
  .head(20)[cols]
781
  .reset_index(drop=True)
782
  )
783
- if "sentiment" in display_df.columns:
784
- df_pos = display_df[display_df["sentiment"] == "Positively Engagement"][cols].head(50).reset_index(drop=True)
785
- df_neg = display_df[display_df["sentiment"] == "Negatively Engagement"][cols].head(50).reset_index(drop=True)
786
 
787
  return (
788
- status_html, # 0 status_box
789
- log_html, # 1 log_html_out
790
- left_html, # 2 left_panel_html
791
- badge_html, # 3 misinfo_badge_html
792
- reasoning_html, # 4 misinfo_reasoning_html
793
- fig_mod_dist, # 5 modality_dist_plot
794
- fig_trust, # 6 trust_score_plot
795
- fig_uncert, # 7 uncertainty_plot
796
- fig_donut, # 8 donut_plot
797
- fig_timeline, # 9 timeline_plot
798
- fig_kw, # 10 kw_bar_plot
799
- fig_kw_comp, # 11 kw_comp_plot
800
- stat_pos, # 12 stat_pos_html
801
- stat_neg, # 13 stat_neg_html
802
- stat_neu, # 14 stat_neu_html
803
- df_all, # 15 df_all_out
804
- df_pos, # 16 df_pos_out
805
- df_neg, # 17 df_neg_out
806
- df_top, # 18 df_top_out
807
  )
808
 
809
 
810
- # UPLOAD / SEARCH HELPERS
811
-
812
-
813
  def do_search(keyword: str):
814
  api_key = os.environ.get("YT_API_KEY", "").strip()
815
  if not api_key:
816
  return (
817
- "<p style='color:#dc2626;font-family:DM Sans,sans-serif;font-weight:500'> YT_API_KEY secret not set.</p>",
818
  gr.update(choices=[], value=None, visible=False),
819
  )
820
  if not (keyword or "").strip():
821
  return (
822
- "<p style='color:#d97706;font-family:DM Sans,sans-serif;font-weight:500'>Enter a keyword to search.</p>",
823
  gr.update(choices=[], value=None, visible=False),
824
  )
825
 
826
  results = search_videos_by_title(keyword.strip(), api_key, max_results=5)
827
  if not results:
828
  return (
829
- "<p style='color:#d97706;font-family:DM Sans,sans-serif;font-weight:500'>No results found.</p>",
830
  gr.update(choices=[], value=None, visible=False),
831
  )
832
 
@@ -839,12 +688,12 @@ def do_search(keyword: str):
839
  html += (
840
  f'<div class="vv-card" style="display:flex;align-items:center;gap:12px;margin-bottom:6px">'
841
  f'<img src="{r["thumbnail_url"]}" '
842
- f' style="width:72px;height:54px;object-fit:cover;border-radius:8px;flex-shrink:0">'
843
  f'<div>'
844
- f'<p style="margin:0;font-size:0.85rem;font-weight:700;color:#4A4A4A">{r["title"][:80]}</p>'
845
- f'<p style="margin:0;font-size:0.75rem;color:#7b7b7b">'
846
  f'{r["channel_title"]} · {r["published_at"]} · '
847
- f'<code style="color:#269ccc;background:#e8f5fc;padding:1px 4px;border-radius:4px">v={vid}</code></p>'
848
  f'</div></div>'
849
  )
850
  return html, gr.update(choices=choices, value=None, visible=True)
@@ -857,26 +706,23 @@ def pick_and_analyze(selected_url, sentiment_method, max_comments):
857
  yield from run_pipeline(selected_url, sentiment_method, max_comments)
858
 
859
 
860
- # GRADIO BLOCKS UI
861
-
862
 
863
- with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
864
-
865
- # Header
866
  gr.HTML("""
867
- <div style="padding:1.5rem 0 0.8rem;border-bottom:1.5px solid #BDDDFC;margin-bottom:1.2rem">
868
- <h1 class="vv-hero">🔬 Misinformation Detection & Public Engagement</h1>
 
 
 
869
  </div>
870
  """)
871
 
872
- # Settings — NO API key field
873
  with gr.Accordion("⚙️ Settings", open=False):
874
  gr.HTML("""
875
- <div style="background:#e8f5fc;border:1px solid #BDDDFC;border-radius:10px;
876
- padding:0.7rem 1rem;margin-bottom:0.8rem;font-family:'DM Sans',sans-serif;
877
- font-size:0.78rem;color:#1a7faa;font-weight:500">
878
- 🔑 YouTube API key is read from the <code style="color:#269ccc;background:#d0ebf7;
879
- padding:1px 5px;border-radius:4px">YT_API_KEY</code>
880
  Space secret — it is never exposed in the UI.
881
  </div>
882
  """)
@@ -897,7 +743,6 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
897
  info="YouTube API quota: ~1 unit per comment request",
898
  )
899
 
900
- #Input tabs
901
  with gr.Tabs():
902
 
903
  with gr.TabItem("🔗 YouTube URL"):
@@ -928,35 +773,27 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
928
  search_results_html = gr.HTML()
929
  search_radio = gr.Radio(label="Select a video to analyze", choices=[], visible=False)
930
 
931
- # Status
932
  status_box = gr.HTML(
933
- '<p style="color:#7b7b7b;font-family:DM Sans,sans-serif;font-size:0.82rem;'
934
- 'font-weight:500;padding:6px 0">'
935
  "Enter a URL above and click Analyze.</p>"
936
  )
937
 
938
- # Main results layout
939
  with gr.Row(equal_height=False):
940
 
941
- # LEFT — video info
942
  with gr.Column(scale=2):
943
  left_panel_html = gr.HTML(
944
  "<div style='padding:3rem;text-align:center;color:#7b7b7b;"
945
- "font-family:DM Sans,sans-serif'>No data yet.</div>"
946
  )
947
 
948
- # RIGHT — analytics
949
  with gr.Column(scale=3):
950
 
951
- # ── Misinformation Analysis ───────────────────────────────────────
952
  gr.HTML('<p class="vv-section-title" style="margin-top:0">🔬 Misinformation Analysis</p>')
953
  misinfo_badge_html = gr.HTML()
954
 
955
- # Row 1 — Modality Misinformation Distribution (full width)
956
  with gr.Row():
957
  modality_dist_plot = gr.Plot(label="", show_label=False)
958
 
959
- # Row 2 — Trust Score | Uncertainty Analysis (side by side)
960
  with gr.Row():
961
  trust_score_plot = gr.Plot(label="", show_label=False)
962
  uncertainty_plot = gr.Plot(label="", show_label=False)
@@ -965,7 +802,6 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
965
 
966
  gr.HTML('<hr class="vv-hr">')
967
 
968
- # ── Comment Sentiment ─────────────────────────────────────────────
969
  gr.HTML('<p class="vv-section-title">💬 Comment Sentiment</p>')
970
  with gr.Row():
971
  stat_pos_html = gr.HTML()
@@ -980,7 +816,6 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
980
 
981
  gr.HTML('<hr class="vv-hr">')
982
 
983
- # ── Comments Deep-Dive ────────────────────────────────────────────
984
  gr.HTML('<p class="vv-section-title">📊 Comments Deep-Dive</p>')
985
  with gr.Tabs():
986
  with gr.TabItem("All"):
@@ -997,50 +832,44 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
997
  with gr.TabItem("Most Liked"):
998
  df_top_out = gr.Dataframe(wrap=True, max_height=320)
999
 
1000
- # Activity log
1001
  with gr.Accordion("📜 Activity Log", open=False):
1002
  log_html_out = gr.HTML('<p class="vv-log-line">—</p>')
1003
 
1004
- # Footer
1005
  gr.HTML("""
1006
  <div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #BDDDFC;
1007
- text-align:center;font-family:'DM Sans',sans-serif;font-size:0.72rem;color:#88BDF2">
1008
  4-stream SeTa-Attention BiGRU · CCM / DMTE / Uncertainty Fusion ·
1009
  Test ROC-AUC 0.967
1010
  </div>
1011
  """)
1012
 
1013
- # ── Output list — order must match _build_outputs / _blank_outputs exactly ─
1014
  ALL_OUTPUTS = [
1015
- status_box, # 0
1016
- log_html_out, # 1
1017
- left_panel_html, # 2
1018
- misinfo_badge_html, # 3
1019
- misinfo_reasoning_html, # 4
1020
- modality_dist_plot, # 5
1021
- trust_score_plot, # 6
1022
- uncertainty_plot, # 7
1023
- donut_plot, # 8
1024
- timeline_plot, # 9
1025
- kw_bar_plot, # 10
1026
- kw_comp_plot, # 11
1027
- stat_pos_html, # 12
1028
- stat_neg_html, # 13
1029
- stat_neu_html, # 14
1030
- df_all_out, # 15
1031
- df_pos_out, # 16
1032
- df_neg_out, # 17
1033
- df_top_out, # 18
1034
  ]
1035
 
1036
- # Pipeline inputs (no api_key_input — read from env)
1037
  _pipeline_inputs = [url_input, sentiment_selector, max_comments_slider]
1038
 
1039
- # Events: URL tab
1040
  analyze_btn.click(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
1041
  url_input.submit(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
1042
 
1043
- # Events: Upload/Search tab
1044
  search_btn.click(
1045
  fn=do_search,
1046
  inputs=[kw_input],
@@ -1053,14 +882,12 @@ with gr.Blocks(title="Misinformation Detection & Public Engagement ") as demo:
1053
  )
1054
 
1055
 
1056
- # Launch — css and theme go HERE in Gradio 6.x (NOT in gr.Blocks)
1057
-
1058
  if __name__ == "__main__":
1059
  demo.launch(
1060
  css=CSS,
1061
  theme=gr.themes.Base(
1062
- primary_hue=gr.themes.colors.sky,
1063
  neutral_hue=gr.themes.colors.slate,
1064
- font=[gr.themes.GoogleFont("DM Sans"), "sans-serif"],
1065
  ),
1066
  )
 
 
 
 
 
 
1
  import os
2
  import pandas as pd
3
  import gradio as gr
 
27
  )
28
 
29
 
 
 
 
30
  CSS = """
31
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
32
 
33
  :root {
34
+ --bg: #FFFFE3;
35
+ --card: #FFFFFF;
36
+ --border: #BDDDFC;
37
+ --text: #4A4A4A;
38
+ --dim: #7b7b7b;
39
+ --primary: #269ccc;
40
+ --ink-dark: #384959;
41
+ --stormy-sky: #88BDF2;
42
+ --stormy-slate:#6A89A7;
43
+ --ink-grey: #CBCBCB;
44
+ --green: #2e9e6b;
45
+ --red: #c0392b;
46
+ --amber: #d4841a;
 
 
 
 
47
  }
48
 
49
  html, body {
50
  background: var(--bg) !important;
51
+ color: var(--text) !important;
52
  margin: 0; padding: 0;
53
  }
54
+ .gradio-container, #root, #app, main, .main, .wrap, .svelte-1kyws56 {
55
  background: var(--bg) !important;
56
  max-width: 100% !important;
57
  width: 100% !important;
 
67
  .gr-group, .gr-box, .vv-section {
68
  background: var(--card) !important;
69
  border: 1px solid var(--border) !important;
70
+ border-radius: 12px !important;
71
  padding: 1rem 1.25rem !important;
 
72
  }
73
 
74
  .tab-nav button {
75
  background: transparent !important;
76
  border: none !important;
77
  color: var(--dim) !important;
78
+ font-family: 'DM Mono', monospace !important;
79
+ font-size: 0.82rem !important;
80
+ letter-spacing: 0.05em !important;
 
81
  border-bottom: 2px solid transparent !important;
82
  padding: 0.5rem 1.2rem !important;
83
  transition: color 0.18s;
 
89
  .tab-nav { border-bottom: 1px solid var(--border) !important; }
90
 
91
  input[type="text"], input[type="password"], input[type="number"], textarea, select {
92
+ background: #f5f7fa !important;
93
+ border: 1px solid var(--border) !important;
94
  color: var(--text) !important;
95
+ border-radius: 8px !important;
96
+ font-family: 'DM Mono', monospace !important;
97
  font-size: 0.88rem !important;
98
  }
99
  input:focus, textarea:focus, select:focus {
100
  border-color: var(--primary) !important;
101
+ box-shadow: 0 0 0 2px rgba(38,156,204,0.18) !important;
102
  outline: none !important;
103
  }
104
+ label, .gr-label, span.svelte-1b6s6s {
 
 
 
105
  color: var(--dim) !important;
106
+ font-family: 'DM Mono', monospace !important;
107
  font-size: 0.75rem !important;
108
+ letter-spacing: 0.08em !important;
109
  text-transform: uppercase;
 
110
  }
111
 
112
  input[type="range"] { accent-color: var(--primary); }
113
 
114
  button.primary, button[variant="primary"], .primary {
115
+ background: linear-gradient(135deg, var(--primary), #1a7aaa) !important;
116
  border: none !important;
117
+ color: #ffffff !important;
118
+ font-weight: 700 !important;
119
+ font-family: 'DM Mono', monospace !important;
120
+ border-radius: 8px !important;
121
+ letter-spacing: 0.06em !important;
 
122
  }
123
  button.secondary {
124
+ background: rgba(38,156,204,0.08) !important;
125
+ border: 1px solid var(--primary) !important;
126
  color: var(--primary) !important;
127
+ border-radius: 8px !important;
128
+ font-family: 'DM Mono', monospace !important;
 
129
  }
130
  button:hover { opacity: 0.88; transform: translateY(-1px); transition: all 0.15s; }
131
 
132
  .dropdown, ul[role="listbox"], li[role="option"] {
133
+ background: #f5f7fa !important;
134
  border-color: var(--border) !important;
135
  color: var(--text) !important;
136
  }
137
+ li[role="option"]:hover { background: #e8f4fb !important; }
138
 
139
+ .gr-dataframe, table { background: var(--card) !important; }
 
 
 
 
140
  .gr-dataframe th {
141
+ background: #EEF6FD !important;
142
  color: var(--primary) !important;
143
+ font-family: 'DM Mono', monospace !important;
144
  font-size: 0.72rem !important;
145
+ padding: 6px 10px;
146
  border-bottom: 1px solid var(--border);
147
  text-transform: uppercase;
148
+ letter-spacing: 0.08em;
 
149
  }
150
  .gr-dataframe td {
151
  color: var(--text) !important;
152
+ font-size: 0.77rem !important;
153
+ padding: 5px 10px;
154
  border-bottom: 1px solid var(--border);
155
  }
156
+ .gr-dataframe tr:hover td { background: rgba(38,156,204,0.05) !important; }
157
 
158
  details > summary {
159
  color: var(--dim) !important;
160
+ font-family: 'DM Mono', monospace !important;
161
+ font-size: 0.82rem !important;
162
  cursor: pointer;
163
  list-style: none;
 
164
  }
165
  details[open] > summary { color: var(--primary) !important; }
166
 
167
  .js-plotly-plot, .plotly { background: transparent !important; }
168
  .modebar { display: none !important; }
169
 
 
 
 
 
 
 
 
 
 
 
170
  ::-webkit-scrollbar { width: 6px; height: 6px; }
171
  ::-webkit-scrollbar-track { background: var(--bg); }
172
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
173
  ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
174
 
 
175
  .vv-hero {
176
+ font-family: 'Syne', sans-serif !important;
177
+ font-size: 1.65rem !important;
178
  font-weight: 800 !important;
179
+ background: linear-gradient(135deg, #269ccc, #384959);
180
+ -webkit-background-clip: text;
181
+ -webkit-text-fill-color: transparent;
182
+ background-clip: text;
183
+ letter-spacing: -0.02em;
184
+ line-height: 1.2;
185
  }
 
186
  .vv-section-title {
187
+ font-family: 'Syne', sans-serif !important;
188
  font-size: 0.68rem !important;
189
  font-weight: 700 !important;
190
+ letter-spacing: 0.18em !important;
191
  text-transform: uppercase !important;
192
+ color: #384959 !important;
193
  margin-bottom: 0.5rem !important;
194
  margin-top: 0 !important;
195
  }
196
 
197
+ .vv-card {
198
+ background: #FFFFFF !important;
199
+ border: 1px solid #BDDDFC !important;
200
+ border-radius: 12px !important;
201
+ padding: 1.1rem 1.3rem !important;
202
+ margin-bottom: 0.7rem !important;
203
+ }
204
+
205
  .vv-metric-grid {
206
  display: grid !important;
207
  grid-template-columns: repeat(4, 1fr) !important;
208
+ gap: 0.55rem !important;
209
+ margin: 0.4rem 0 1rem !important;
210
  }
211
  .vv-metric-card {
212
  background: #FFFFFF !important;
213
+ border: 1px solid #BDDDFC !important;
214
+ border-radius: 12px !important;
215
+ padding: 0.8rem 0.7rem !important;
216
  text-align: center !important;
217
+ transition: transform 0.18s ease, box-shadow 0.18s ease !important;
218
  cursor: default !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
  .vv-metric-card:hover {
221
  transform: translateY(-4px) !important;
222
+ box-shadow: 0 8px 24px rgba(38,156,204,0.18) !important;
 
 
 
 
 
 
 
223
  }
224
  .vv-metric-value {
225
+ display: block !important;
226
+ font-family: 'DM Mono', monospace !important;
227
+ font-size: 1.15rem !important;
228
+ font-weight: 700 !important;
229
  color: #269ccc !important;
230
  margin: 0 !important;
231
  line-height: 1.2 !important;
 
232
  }
233
  .vv-metric-label {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  display: block !important;
235
+ font-family: 'DM Mono', monospace !important;
236
  font-size: 0.62rem !important;
237
+ letter-spacing: 0.1em !important;
 
238
  text-transform: uppercase !important;
239
+ color: #7b7b7b !important;
240
+ margin: 4px 0 0 !important;
 
 
 
 
 
 
 
 
 
 
 
 
241
  }
242
 
243
+ .vv-stat {
 
 
 
 
 
 
244
  display: inline-block !important;
245
+ background: #EEF6FD !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  border: 1px solid #BDDDFC !important;
247
+ border-radius: 6px !important;
248
+ padding: 0.25rem 0.75rem !important;
249
+ font-family: 'DM Mono', monospace !important;
250
+ font-size: 0.77rem !important;
251
+ color: #269ccc !important;
252
+ margin: 0.15rem 0.2rem !important;
253
  }
254
+
255
  .vv-badge-green {
256
  display: inline-block !important;
257
+ background: rgba(46,158,107,0.10) !important;
258
+ border: 1px solid #2e9e6b !important;
259
+ color: #2e9e6b !important;
 
260
  border-radius: 20px !important;
261
  padding: 0.32rem 1.1rem !important;
262
  font-size: 0.85rem !important;
263
+ font-family: 'DM Mono', monospace !important;
264
+ font-weight: 600 !important;
265
  }
266
  .vv-badge-red {
267
  display: inline-block !important;
268
+ background: rgba(192,57,43,0.10) !important;
269
+ border: 1px solid #c0392b !important;
270
+ color: #c0392b !important;
 
271
  border-radius: 20px !important;
272
  padding: 0.32rem 1.1rem !important;
273
  font-size: 0.85rem !important;
274
+ font-family: 'DM Mono', monospace !important;
275
+ font-weight: 600 !important;
276
  }
277
  .vv-badge-amber {
278
  display: inline-block !important;
279
+ background: rgba(212,132,26,0.10) !important;
280
+ border: 1px solid #d4841a !important;
281
+ color: #d4841a !important;
 
282
  border-radius: 20px !important;
283
  padding: 0.32rem 1.1rem !important;
284
  font-size: 0.85rem !important;
285
+ font-family: 'DM Mono', monospace !important;
286
+ font-weight: 600 !important;
287
  }
288
+
289
  .vv-reasoning {
290
+ background: #f7f9fb !important;
291
+ border-left: 3px solid #d4841a !important;
292
  padding: 0.8rem 1rem !important;
293
+ border-radius: 0 8px 8px 0 !important;
294
  font-size: 0.83rem !important;
295
+ color: #4A4A4A !important;
 
296
  line-height: 1.65 !important;
297
+ font-family: 'IBM Plex Sans', sans-serif !important;
298
  margin-top: 8px !important;
299
  }
300
 
301
+ .vv-tag {
302
+ display: inline-block !important;
303
+ background: #BDDDFC !important;
304
+ border: none !important;
305
+ border-radius: 20px !important;
306
+ padding: 3px 10px !important;
307
+ font-family: 'DM Mono', monospace !important;
308
+ font-size: 0.7rem !important;
309
+ color: #384959 !important;
310
+ margin: 2px !important;
311
+ font-weight: 500 !important;
 
 
 
 
312
  }
313
+
314
+ .vv-stat-big-green {
315
+ font-family: 'DM Mono', monospace !important;
316
  font-size: 1.6rem !important;
317
+ font-weight: 700 !important;
318
+ color: #2e9e6b !important;
 
319
  margin: 0 !important;
320
  }
321
+ .vv-stat-big-red {
322
+ font-family: 'DM Mono', monospace !important;
323
  font-size: 1.6rem !important;
324
+ font-weight: 700 !important;
325
+ color: #c0392b !important;
 
326
  margin: 0 !important;
327
  }
328
+ .vv-stat-big-dim {
329
+ font-family: 'DM Mono', monospace !important;
330
  font-size: 1.6rem !important;
331
+ font-weight: 700 !important;
332
+ color: #7b7b7b !important;
 
333
  margin: 0 !important;
334
  }
 
335
  .vv-log-line {
336
  font-size: 0.72rem !important;
337
+ color: #7b7b7b !important;
338
+ font-family: 'DM Mono', monospace !important;
 
339
  margin: 2px 0 !important;
340
  }
341
+ .vv-hr { border: none; border-top: 1px solid #BDDDFC; margin: 1.1rem 0; }
342
  """
343
 
344
 
 
 
 
345
  def _empty_plotly(msg: str = "Run analysis to see data", h: int = 230):
346
  import plotly.graph_objects as go
347
  fig = go.Figure()
348
  fig.update_layout(
349
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(189,221,252,0.13)",
350
+ font=dict(color="#7b7b7b"), margin=dict(l=10, r=10, t=10, b=10), height=h,
351
  )
352
  fig.add_annotation(
353
  text=msg, x=0.5, y=0.5, xref="paper", yref="paper",
354
+ showarrow=False, font=dict(size=12, color="#7b7b7b"),
355
  )
356
  return fig
357
 
358
 
359
  def _blank_outputs(status_msg: str):
 
360
  ep = _empty_plotly()
361
  return (
362
+ f'<p style="color:#c0392b;font-family:DM Mono,monospace;padding:8px">{status_msg}</p>',
363
+ "<p class='vv-log-line'>—</p>",
364
+ "<div style='padding:3rem;text-align:center;color:#7b7b7b;font-family:DM Mono,monospace'>No data yet.</div>",
365
+ "", "",
366
+ ep, ep, ep,
367
+ ep, ep, ep, ep,
368
+ "", "", "",
369
+ pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(),
370
  )
371
 
372
 
 
 
 
373
  def run_pipeline(
374
  url_or_id: str,
375
  sentiment_method: str,
376
  max_comments: int,
377
  progress=gr.Progress(track_tqdm=False),
378
  ):
 
379
  api_key = os.environ.get("YT_API_KEY", "").strip()
380
 
 
381
  if not (url_or_id or "").strip():
382
+ yield _blank_outputs("⚠️ Please enter a YouTube URL or video ID.")
383
  return
384
 
385
  video_id = extract_video_id(url_or_id.strip())
386
  if not video_id:
387
+ yield _blank_outputs(" Could not parse a valid YouTube video ID.")
388
  return
389
 
390
  if not api_key:
391
  yield _blank_outputs(
392
+ "⚠️ YouTube API key not found. "
393
  "Set the <code>YT_API_KEY</code> environment variable / Space secret."
394
  )
395
  return
396
 
 
397
  progress(0.05, desc="Fetching video metadata…")
398
  meta, err = fetch_video_metadata(video_id, api_key)
399
  if err:
400
+ yield _blank_outputs(f" {err}")
401
  return
402
 
 
403
  progress(0.20, desc="Fetching transcript…")
404
  transcript, t_status = fetch_transcript(video_id)
405
 
 
406
  progress(0.35, desc=f"Fetching up to {max_comments} comments…")
407
  comments_df, c_status = fetch_comments(video_id, api_key, max_comments=int(max_comments))
408
 
 
 
409
  progress(0.50, desc="Running misinformation detection…")
410
  misinfo = detect_misinformation(
411
  text=f"{meta['title']} {meta['description']}",
412
  tags=meta["tags"],
413
+ audio_transcript=transcript,
414
+ video_transcript=transcript,
415
  )
416
 
 
417
  keywords = extract_keywords(
418
  f"{meta['title']} {meta['description']} {transcript}",
419
  meta["tags"],
420
  )
421
 
 
422
  sentiments, sent_sum, pos_kw, neg_kw = [], {}, [], []
423
 
424
  if not comments_df.empty:
 
433
  sent_sum = sentiment_summary(sentiments)
434
  pos_kw, neg_kw = sentiment_weighted_keywords(comments_df, sentiments)
435
 
 
436
  progress(0.97, desc="Building charts…")
437
  yield _build_outputs(
438
  meta=meta, video_id=video_id, transcript=transcript,
 
440
  sentiments=sentiments, sent_sum=sent_sum,
441
  pos_kw=pos_kw, neg_kw=neg_kw,
442
  status_log=[
443
+ f" Metadata: {meta['title'][:55]}",
444
  t_status,
445
  c_status,
446
  f"🔬 Misinfo score: {misinfo['confidence_pct']}%",
 
453
  )
454
 
455
 
 
 
 
456
  def _build_outputs(
457
  meta, video_id, transcript, comments_df,
458
  misinfo, keywords, sentiments, sent_sum, pos_kw, neg_kw, status_log,
459
  ):
 
460
  status_html = (
461
+ '<p style="color:#2e9e6b;font-family:DM Mono,monospace;font-size:0.82rem;padding:6px 0">'
462
+ " Analysis complete</p>"
463
  )
464
 
 
465
  log_html = "".join(f'<p class="vv-log-line">{line}</p>' for line in status_log)
466
 
 
467
  thumb_html = (
468
  f'<img src="{meta["thumbnail_url"]}" '
469
+ 'style="width:100%;border-radius:8px;margin-bottom:8px;display:block">'
 
470
  if meta.get("thumbnail_url") else ""
471
  )
472
  tag_html = "".join(f'<span class="vv-tag">#{t}</span>' for t in meta.get("tags", [])[:20])
 
477
  left_html = f"""
478
  {thumb_html}
479
  <a href="https://www.youtube.com/watch?v={video_id}" target="_blank"
480
+ style="display:block;text-align:center;font-family:'DM Mono',monospace;
481
+ font-size:0.75rem;color:#7b7b7b;text-decoration:none;margin:4px 0 10px">
 
482
  ▶ Open on YouTube
483
  </a>
484
  <div class="vv-card">
485
  <p class="vv-section-title">Video</p>
486
+ <p style="font-family:'Syne',sans-serif;font-size:1.05rem;font-weight:700;margin:0 0 6px;color:#4A4A4A !important">
487
  {meta['title']}
488
  </p>
489
+ <p style="font-size:0.82rem;color:#7b7b7b !important;margin:0">
490
+ by <b style="color:#384959 !important">{meta['channel_title']}</b>
491
+ &nbsp;·&nbsp;
492
+ <span style="color:#7b7b7b !important">{meta['published_at']}</span>
493
+ </p>
 
 
 
 
 
494
  </div>
495
 
496
  <p class="vv-section-title">Metrics</p>
497
  <div class="vv-metric-grid">
498
  <div class="vv-metric-card">
499
+ <span class="vv-metric-value">👁 {meta['view_count']:,}</span>
500
+ <span class="vv-metric-label">Views</span>
 
501
  </div>
502
  <div class="vv-metric-card">
503
+ <span class="vv-metric-value">👍 {meta['like_count']:,}</span>
504
+ <span class="vv-metric-label">Likes</span>
 
505
  </div>
506
  <div class="vv-metric-card">
507
+ <span class="vv-metric-value">💬 {meta['comment_count']:,}</span>
508
+ <span class="vv-metric-label">Comments</span>
 
509
  </div>
510
  <div class="vv-metric-card">
511
+ <span class="vv-metric-value">⏱ {meta['duration']}</span>
512
+ <span class="vv-metric-label">Duration</span>
 
513
  </div>
514
  </div>
515
 
516
  <p class="vv-section-title" style="margin-top:0.8rem">Tags</p>
517
+ {tag_html or '<span style="color:#7b7b7b;font-size:0.78rem">(none)</span>'}
 
 
518
 
519
  <details style="margin-top:1rem">
520
  <summary>📄 Description</summary>
521
+ <p style="font-size:0.78rem;color:#7b7b7b;line-height:1.65;white-space:pre-wrap;margin-top:6px">{desc_short}</p>
 
522
  </details>
523
  <details style="margin-top:0.5rem">
524
  <summary>📝 Transcript ({word_count} words)</summary>
525
+ <p style="font-size:0.75rem;color:#7b7b7b;line-height:1.65;margin-top:6px">{transcript_short}</p>
 
526
  </details>
527
  """
528
 
 
529
  score = misinfo["score"]
530
  if score < 0.35:
531
+ badge_html = '<span class="vv-badge-green"> Appears Credible</span>'
532
  elif score < 0.65:
533
+ badge_html = '<span class="vv-badge-amber">⚠️ Uncertain / Mixed Signals</span>'
534
  else:
535
  badge_html = '<span class="vv-badge-red">🚨 Likely Misinformation</span>'
536
 
 
538
  f'<div class="vv-reasoning">🧠 <b>Reasoning:</b> {misinfo["reasoning"]}</div>'
539
  )
540
 
 
541
  mod_analysis = misinfo.get("modality_analysis", {})
542
 
543
  try:
 
555
  except Exception:
556
  fig_uncert = _empty_plotly("Uncertainty analysis unavailable")
557
 
 
558
  try:
559
  fig_donut = sentiment_donut(sent_sum) if sent_sum else _empty_plotly("No comments analysed")
560
  except Exception:
 
583
  except Exception:
584
  fig_kw_comp = _empty_plotly()
585
 
 
586
  if sent_sum:
587
  stat_pos = (
588
+ f'<div class="vv-card" style="text-align:center">'
589
+ f'<p class="vv-stat-big-green">{sent_sum["pos_pct"]}%</p>'
590
+ f'<p style="color:#7b7b7b !important;font-size:0.75rem;margin:4px 0 0;font-family:DM Mono,monospace">Positively Engagement</p></div>'
591
  )
592
  stat_neg = (
593
+ f'<div class="vv-card" style="text-align:center">'
594
+ f'<p class="vv-stat-big-red">{sent_sum["neg_pct"]}%</p>'
595
+ f'<p style="color:#7b7b7b !important;font-size:0.75rem;margin:4px 0 0;font-family:DM Mono,monospace">Negatively Engagement</p></div>'
596
  )
597
  stat_neu = (
598
+ f'<div class="vv-card" style="text-align:center">'
599
  f'<p class="vv-stat-big-dim">{sent_sum["neu_pct"]}%</p>'
600
+ f'<p style="color:#7b7b7b !important;font-size:0.75rem;margin:4px 0 0;font-family:DM Mono,monospace">Neutral</p></div>'
601
  )
602
  else:
603
  placeholder = (
604
+ '<div class="vv-card" style="text-align:center;color:#7b7b7b !important;'
605
+ 'font-family:DM Mono,monospace;font-size:0.8rem;padding:1.2rem">N/A</div>'
606
  )
607
  stat_pos = stat_neg = stat_neu = placeholder
608
 
 
609
  show_cols = ["author", "text", "likes", "published_at"]
610
  df_all = df_pos = df_neg = df_top = pd.DataFrame()
611
 
612
  if not comments_df.empty:
613
  display_df = comments_df.copy()
614
  if sentiments:
615
+ display_df["sentiment"] = [s["label"] for s in sentiments]
616
+ display_df["compound"] = [round(s.get("compound", 0), 3) for s in sentiments]
617
  cols = show_cols + ["sentiment", "compound"]
618
  else:
619
  cols = show_cols
620
 
621
+ if "sentiment" in display_df.columns:
622
+ df_pos = display_df[display_df["sentiment"] == "POSITIVE"][cols].head(50).reset_index(drop=True)
623
+ df_neg = display_df[display_df["sentiment"] == "NEGATIVE"][cols].head(50).reset_index(drop=True)
624
+ display_df["sentiment"] = display_df["sentiment"].replace({
625
+ "POSITIVE": "Positively Engagement",
626
+ "NEGATIVE": "Negatively Engagement",
627
+ "NEUTRAL": "Neutral",
628
+ })
629
+ df_pos["sentiment"] = "Positively Engagement"
630
+ df_neg["sentiment"] = "Negatively Engagement"
631
+
632
  df_all = display_df[cols].head(100).reset_index(drop=True)
633
  df_top = (
634
  display_df.sort_values("likes", ascending=False)
635
  .head(20)[cols]
636
  .reset_index(drop=True)
637
  )
 
 
 
638
 
639
  return (
640
+ status_html,
641
+ log_html,
642
+ left_html,
643
+ badge_html,
644
+ reasoning_html,
645
+ fig_mod_dist,
646
+ fig_trust,
647
+ fig_uncert,
648
+ fig_donut,
649
+ fig_timeline,
650
+ fig_kw,
651
+ fig_kw_comp,
652
+ stat_pos,
653
+ stat_neg,
654
+ stat_neu,
655
+ df_all,
656
+ df_pos,
657
+ df_neg,
658
+ df_top,
659
  )
660
 
661
 
 
 
 
662
  def do_search(keyword: str):
663
  api_key = os.environ.get("YT_API_KEY", "").strip()
664
  if not api_key:
665
  return (
666
+ "<p style='color:#c0392b;font-family:DM Mono,monospace'>⚠️ YT_API_KEY secret not set.</p>",
667
  gr.update(choices=[], value=None, visible=False),
668
  )
669
  if not (keyword or "").strip():
670
  return (
671
+ "<p style='color:#d4841a;font-family:DM Mono,monospace'>Enter a keyword to search.</p>",
672
  gr.update(choices=[], value=None, visible=False),
673
  )
674
 
675
  results = search_videos_by_title(keyword.strip(), api_key, max_results=5)
676
  if not results:
677
  return (
678
+ "<p style='color:#d4841a;font-family:DM Mono,monospace'>No results found.</p>",
679
  gr.update(choices=[], value=None, visible=False),
680
  )
681
 
 
688
  html += (
689
  f'<div class="vv-card" style="display:flex;align-items:center;gap:12px;margin-bottom:6px">'
690
  f'<img src="{r["thumbnail_url"]}" '
691
+ f' style="width:72px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0">'
692
  f'<div>'
693
+ f'<p style="margin:0;font-size:0.85rem;font-weight:600;color:#4A4A4A !important">{r["title"][:80]}</p>'
694
+ f'<p style="margin:0;font-size:0.75rem;color:#7b7b7b !important">'
695
  f'{r["channel_title"]} · {r["published_at"]} · '
696
+ f'<code style="color:#269ccc">v={vid}</code></p>'
697
  f'</div></div>'
698
  )
699
  return html, gr.update(choices=choices, value=None, visible=True)
 
706
  yield from run_pipeline(selected_url, sentiment_method, max_comments)
707
 
708
 
709
+ with gr.Blocks(title="VideoVerifier — MHMisinfo") as demo:
 
710
 
 
 
 
711
  gr.HTML("""
712
+ <div style="padding:1.5rem 0 0.8rem;border-bottom:1px solid #BDDDFC;margin-bottom:1.2rem">
713
+ <h1 class="vv-hero">🔬 Video Verifier &amp; Sentiment Analyzer</h1>
714
+ <p style="color:#7b7b7b;font-size:0.85rem;margin-top:4px;font-family:'DM Mono',monospace">
715
+ mental health misinformation detection
716
+ </p>
717
  </div>
718
  """)
719
 
 
720
  with gr.Accordion("⚙️ Settings", open=False):
721
  gr.HTML("""
722
+ <div style="background:#f5f7fa;border:1px solid #BDDDFC;border-radius:8px;
723
+ padding:0.7rem 1rem;margin-bottom:0.8rem;font-family:'DM Mono',monospace;
724
+ font-size:0.78rem;color:#7b7b7b">
725
+ 🔑 YouTube API key is read from the <code style="color:#269ccc">YT_API_KEY</code>
 
726
  Space secret — it is never exposed in the UI.
727
  </div>
728
  """)
 
743
  info="YouTube API quota: ~1 unit per comment request",
744
  )
745
 
 
746
  with gr.Tabs():
747
 
748
  with gr.TabItem("🔗 YouTube URL"):
 
773
  search_results_html = gr.HTML()
774
  search_radio = gr.Radio(label="Select a video to analyze", choices=[], visible=False)
775
 
 
776
  status_box = gr.HTML(
777
+ '<p style="color:#7b7b7b;font-family:DM Mono,monospace;font-size:0.8rem;padding:6px 0">'
 
778
  "Enter a URL above and click Analyze.</p>"
779
  )
780
 
 
781
  with gr.Row(equal_height=False):
782
 
 
783
  with gr.Column(scale=2):
784
  left_panel_html = gr.HTML(
785
  "<div style='padding:3rem;text-align:center;color:#7b7b7b;"
786
+ "font-family:DM Mono,monospace'>No data yet.</div>"
787
  )
788
 
 
789
  with gr.Column(scale=3):
790
 
 
791
  gr.HTML('<p class="vv-section-title" style="margin-top:0">🔬 Misinformation Analysis</p>')
792
  misinfo_badge_html = gr.HTML()
793
 
 
794
  with gr.Row():
795
  modality_dist_plot = gr.Plot(label="", show_label=False)
796
 
 
797
  with gr.Row():
798
  trust_score_plot = gr.Plot(label="", show_label=False)
799
  uncertainty_plot = gr.Plot(label="", show_label=False)
 
802
 
803
  gr.HTML('<hr class="vv-hr">')
804
 
 
805
  gr.HTML('<p class="vv-section-title">💬 Comment Sentiment</p>')
806
  with gr.Row():
807
  stat_pos_html = gr.HTML()
 
816
 
817
  gr.HTML('<hr class="vv-hr">')
818
 
 
819
  gr.HTML('<p class="vv-section-title">📊 Comments Deep-Dive</p>')
820
  with gr.Tabs():
821
  with gr.TabItem("All"):
 
832
  with gr.TabItem("Most Liked"):
833
  df_top_out = gr.Dataframe(wrap=True, max_height=320)
834
 
 
835
  with gr.Accordion("📜 Activity Log", open=False):
836
  log_html_out = gr.HTML('<p class="vv-log-line">—</p>')
837
 
 
838
  gr.HTML("""
839
  <div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #BDDDFC;
840
+ text-align:center;font-family:'DM Mono',monospace;font-size:0.72rem;color:#7b7b7b">
841
  4-stream SeTa-Attention BiGRU · CCM / DMTE / Uncertainty Fusion ·
842
  Test ROC-AUC 0.967
843
  </div>
844
  """)
845
 
 
846
  ALL_OUTPUTS = [
847
+ status_box,
848
+ log_html_out,
849
+ left_panel_html,
850
+ misinfo_badge_html,
851
+ misinfo_reasoning_html,
852
+ modality_dist_plot,
853
+ trust_score_plot,
854
+ uncertainty_plot,
855
+ donut_plot,
856
+ timeline_plot,
857
+ kw_bar_plot,
858
+ kw_comp_plot,
859
+ stat_pos_html,
860
+ stat_neg_html,
861
+ stat_neu_html,
862
+ df_all_out,
863
+ df_pos_out,
864
+ df_neg_out,
865
+ df_top_out,
866
  ]
867
 
 
868
  _pipeline_inputs = [url_input, sentiment_selector, max_comments_slider]
869
 
 
870
  analyze_btn.click(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
871
  url_input.submit(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
872
 
 
873
  search_btn.click(
874
  fn=do_search,
875
  inputs=[kw_input],
 
882
  )
883
 
884
 
 
 
885
  if __name__ == "__main__":
886
  demo.launch(
887
  css=CSS,
888
  theme=gr.themes.Base(
889
+ primary_hue=gr.themes.colors.blue,
890
  neutral_hue=gr.themes.colors.slate,
891
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"],
892
  ),
893
  )