rocky250 commited on
Commit
27c3779
Β·
verified Β·
1 Parent(s): 1bc8327

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +462 -229
app.py CHANGED
@@ -1,12 +1,14 @@
1
  """
2
- app.py β€” Video Verifier & Sentiment Analyzer
3
- Gradio dark-mode application.
4
- fetcher.py, analyzer.py, charts.py are UNCHANGED β€” only this file replaces the Streamlit version.
5
-
6
- Gradio 6.x compatibility notes:
7
- - css= and theme= go on launch(), NOT on gr.Blocks()
8
- - gr.Dataframe uses max_height= (not height=)
9
- - show_api= has been removed from launch() in Gradio 6.x
 
 
10
  """
11
 
12
  import os
@@ -36,13 +38,14 @@ from charts import (
36
  keyword_comparison,
37
  )
38
 
39
- # ══════════════════════════════════════════════════════════════════════════════
40
- # CSS β€” same dark palette as the Streamlit version
41
- # ══════════════════════════════════════════════════════════════════════════════
42
 
43
  CSS = """
44
  @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');
45
 
 
46
  :root {
47
  --bg: #0d0f14;
48
  --card: #13161e;
@@ -56,130 +59,326 @@ CSS = """
56
  --blue: #4a8eff;
57
  }
58
 
59
- body, .gradio-container { background: var(--bg) !important; font-family: 'IBM Plex Sans', sans-serif !important; color: var(--text) !important; max-width: 1400px !important; margin: 0 auto; }
60
- footer { display: none !important; }
61
-
62
- /* Panels */
63
- .gr-group, .gr-box, .gr-panel, div[class*="block"] { background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 12px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
- /* Tabs */
66
- .tab-nav button { background: transparent !important; border: none !important; color: var(--dim) !important; font-family: 'DM Mono', monospace !important; font-size: 0.82rem !important; letter-spacing: 0.05em !important; border-bottom: 2px solid transparent !important; padding: 0.5rem 1.2rem !important; transition: color 0.2s; }
67
- .tab-nav button.selected { color: var(--cyan) !important; border-bottom-color: var(--cyan) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  .tab-nav { border-bottom: 1px solid var(--border) !important; }
69
 
70
- /* Inputs */
71
- input[type="text"], input[type="password"], textarea { background: #1a1d27 !important; border: 1px solid var(--border) !important; color: var(--text) !important; border-radius: 8px !important; font-family: 'DM Mono', monospace !important; font-size: 0.88rem !important; }
72
- input:focus, textarea:focus { border-color: var(--cyan) !important; box-shadow: 0 0 0 2px rgba(0,212,255,0.15) !important; outline: none !important; }
73
- label, .gr-label { color: var(--dim) !important; font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important; letter-spacing: 0.08em !important; text-transform: uppercase; }
74
-
75
- /* Buttons */
76
- button.primary, button[variant="primary"] { background: linear-gradient(135deg, var(--cyan), var(--blue)) !important; border: none !important; color: var(--bg) !important; font-weight: 700 !important; font-family: 'DM Mono', monospace !important; border-radius: 8px !important; letter-spacing: 0.06em !important; }
77
- button.secondary { background: rgba(0,212,255,0.08) !important; border: 1px solid var(--cyan) !important; color: var(--cyan) !important; border-radius: 8px !important; font-family: 'DM Mono', monospace !important; }
78
- button:hover { opacity: 0.88; transform: translateY(-1px); transition: all 0.15s; }
79
-
80
- /* Dropdown */
81
- select { background: #1a1d27 !important; border: 1px solid var(--border) !important; color: var(--text) !important; border-radius: 8px !important; }
 
 
 
 
 
 
 
 
 
82
 
83
- /* Slider */
84
  input[type="range"] { accent-color: var(--cyan); }
85
 
86
- /* Dataframe */
87
- .gr-dataframe table { background: var(--card) !important; border-collapse: collapse; width: 100%; }
88
- .gr-dataframe th { background: #1a1d27 !important; color: var(--cyan) !important; font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important; padding: 6px 10px; border-bottom: 1px solid var(--border); }
89
- .gr-dataframe td { color: var(--text) !important; font-size: 0.78rem !important; padding: 5px 10px; border-bottom: 1px solid var(--border); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  .gr-dataframe tr:hover td { background: rgba(0,212,255,0.04) !important; }
91
 
92
- /* Shared HTML helpers */
93
- .vv-hero { font-family: 'Syne', sans-serif; font-size: 1.6rem; font-weight: 800; background: linear-gradient(135deg, #00d4ff, #4a8eff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.02em; line-height: 1.2; }
94
- .vv-section-title { font-family: 'Syne', sans-serif; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #5a6070; margin-bottom: 0.5rem; }
95
- .vv-card { background: #13161e; border: 1px solid #1e2330; border-radius: 12px; padding: 1.2rem 1.4rem; margin-bottom: 0.8rem; }
96
- .vv-stat { display: inline-block; background: #1a1d27; border: 1px solid #1e2330; border-radius: 6px; padding: 0.25rem 0.75rem; font-family: 'DM Mono', monospace; font-size: 0.78rem; color: #00d4ff; margin: 0.15rem 0.2rem; }
97
- .vv-badge-green { display: inline-block; background: rgba(0,229,160,0.12); border: 1px solid #00e5a0; color: #00e5a0; border-radius: 20px; padding: 0.3rem 1rem; font-size: 0.82rem; font-family: 'DM Mono', monospace; }
98
- .vv-badge-red { display: inline-block; background: rgba(255,71,87,0.12); border: 1px solid #ff4757; color: #ff4757; border-radius: 20px; padding: 0.3rem 1rem; font-size: 0.82rem; font-family: 'DM Mono', monospace; }
99
- .vv-badge-amber { display: inline-block; background: rgba(255,179,71,0.12); border: 1px solid #ffb347; color: #ffb347; border-radius: 20px; padding: 0.3rem 1rem; font-size: 0.82rem; font-family: 'DM Mono', monospace; }
100
- .vv-reasoning { background: #0d1119; border-left: 3px solid #ffb347; padding: 0.75rem 1rem; border-radius: 0 8px 8px 0; font-size: 0.83rem; color: #c0c4cc; line-height: 1.65; font-family: 'IBM Plex Sans', sans-serif; }
101
- .vv-tag { display: inline-block; background: #1a1d27; border: 1px solid #1e2330; border-radius: 4px; padding: 2px 8px; font-family: 'DM Mono', monospace; font-size: 0.7rem; color: #8090a0; margin: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  .vv-stat-big-green { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #00e5a0; margin: 0; }
103
  .vv-stat-big-red { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #ff4757; margin: 0; }
104
  .vv-stat-big-dim { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #5a6070; margin: 0; }
105
- .vv-log-line { font-size: 0.72rem; color: #5a6070; font-family: 'DM Mono', monospace; margin: 2px 0; }
 
106
  """
107
 
108
- # ══════════════════════════════════════════════════════════════════════════════
109
- # SHARED HELPERS
110
- # ══════════════════════════════════════════════════════════════════════════════
111
 
112
- def _empty_plotly():
113
  import plotly.graph_objects as go
114
  fig = go.Figure()
115
  fig.update_layout(
116
  paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
117
- font=dict(color="#5a6070"), margin=dict(l=10, r=10, t=10, b=10), height=200,
 
 
 
 
118
  )
119
- fig.add_annotation(text="Run analysis to see data", x=0.5, y=0.5,
120
- showarrow=False, font=dict(size=13, color="#5a6070"))
121
  return fig
122
 
123
 
124
  def _blank_outputs(status_msg: str):
125
- """18-element tuple matching ALL_OUTPUTS when nothing has run yet."""
126
  ep = _empty_plotly()
127
  return (
128
- f'<p style="color:#ff4757;font-family:DM Mono,monospace;padding:8px">{status_msg}</p>', # status
129
- "<p class='vv-log-line'>β€”</p>", # log
130
- "<div style='padding:3rem;text-align:center;color:#5a6070'>No data yet.</div>", # left panel
131
- "", "", # badge, reasoning
132
- ep, ep, ep, ep, ep, ep, # 6 charts
133
- "", "", "", # 3 stat boxes
134
- pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), # 4 dataframes
135
  )
136
 
137
 
138
- # ══════════════════════════════════════════════════════════════════════════════
139
  # PIPELINE
140
- # ══════════════════════════════════════════════════════════════════════════════
141
 
142
  def run_pipeline(
143
  url_or_id: str,
144
- api_key: str,
145
  sentiment_method: str,
146
  max_comments: int,
147
  progress=gr.Progress(track_tqdm=False),
148
  ):
149
- """
150
- Generator function β€” yields one final tuple when all work is done.
151
- gr.Progress() gives the user an animated progress bar while waiting.
152
- """
153
- # ── Input guards ──────────────────────────────────────────────────────────
154
  if not (url_or_id or "").strip():
155
  yield _blank_outputs("⚠️ Please enter a YouTube URL or video ID.")
156
  return
157
 
158
  video_id = extract_video_id(url_or_id.strip())
159
  if not video_id:
160
- yield _blank_outputs("❌ Could not parse a valid YouTube video ID from that input.")
161
  return
162
 
163
- if not (api_key or "").strip():
164
- yield _blank_outputs("⚠️ YouTube API key is required. Set it in the βš™οΈ Settings tab.")
 
 
 
165
  return
166
 
167
- # 1 ── Metadata ─────────────────────────────────────────────────────────────
168
  progress(0.05, desc="Fetching video metadata…")
169
- meta, err_msg = fetch_video_metadata(video_id, api_key)
170
- if err_msg:
171
- yield _blank_outputs(f"❌ {err_msg}")
172
  return
173
 
174
- # 2 ── Transcript ────────────────────────────────────────────────────────────
175
  progress(0.20, desc="Fetching transcript…")
176
  transcript, t_status = fetch_transcript(video_id)
177
 
178
- # 3 ── Comments ──────────────────────────────────────────────────────────────
179
  progress(0.35, desc=f"Fetching up to {max_comments} comments…")
180
  comments_df, c_status = fetch_comments(video_id, api_key, max_comments=int(max_comments))
181
 
182
- # 4 ── Misinformation detection ──────────────────────────────────────────────
183
  progress(0.50, desc="Running misinformation detection…")
184
  misinfo = detect_misinformation(
185
  text=f"{meta['title']} {meta['description']}",
@@ -188,28 +387,28 @@ def run_pipeline(
188
  video_transcript=transcript,
189
  )
190
 
191
- # 5 ── Keywords ──────────────────────────────────────────────────────────────
192
  keywords = extract_keywords(
193
  f"{meta['title']} {meta['description']} {transcript}",
194
  meta["tags"],
195
  )
196
 
197
- # 6 ── Sentiment (batched) ───────────────────────────────────────────────────
198
  sentiments, sent_sum, pos_kw, neg_kw = [], {}, [], []
199
 
200
  if not comments_df.empty:
201
  texts = comments_df["text"].fillna("").tolist()
202
- batch_size = 64
203
- for i in range(0, len(texts), batch_size):
204
- chunk = texts[i: i + batch_size]
205
- sentiments += analyze_sentiment_batch(chunk, method=sentiment_method, batch_size=batch_size)
206
- frac = 0.60 + 0.30 * min((i + batch_size) / max(len(texts), 1), 1.0)
207
- progress(frac, desc=f"Sentiment: {min(i+batch_size, len(texts))}/{len(texts)}…")
208
 
209
  sent_sum = sentiment_summary(sentiments)
210
  pos_kw, neg_kw = sentiment_weighted_keywords(comments_df, sentiments)
211
 
212
- # 7 ── Assemble and yield ────────────────────────────────────────────────────
213
  progress(0.97, desc="Building charts…")
214
  yield _build_outputs(
215
  meta=meta, video_id=video_id, transcript=transcript,
@@ -218,50 +417,51 @@ def run_pipeline(
218
  pos_kw=pos_kw, neg_kw=neg_kw,
219
  status_log=[
220
  f"βœ… Metadata: {meta['title'][:55]}",
221
- t_status, c_status,
 
222
  f"πŸ”¬ Misinfo score: {misinfo['confidence_pct']}%",
223
  *(
224
  [f"πŸ’¬ Sentiment: {sent_sum['pos_pct']}% pos / {sent_sum['neg_pct']}% neg"]
225
- if sent_sum else ["πŸ’¬ Skipped (no comments available)"]
 
226
  ),
227
  ],
228
  )
229
 
230
 
231
- # ══════════════════════════════════════════════════════════════════════════════
232
  # OUTPUT BUILDER
233
- # ══════════════════════════════════════════════════════════════════════════════
234
 
235
  def _build_outputs(
236
  meta, video_id, transcript, comments_df,
237
  misinfo, keywords, sentiments, sent_sum, pos_kw, neg_kw, status_log,
238
  ):
239
- # ── Status ────────────────────────────────────────────────────────────────
240
  status_html = (
241
  '<p style="color:#00e5a0;font-family:DM Mono,monospace;font-size:0.82rem;padding:6px 0">'
242
- 'βœ… Analysis complete</p>'
243
  )
244
 
245
- # ── Log ───────────────────────────────────────────────────────────────────
246
  log_html = "".join(f'<p class="vv-log-line">{line}</p>' for line in status_log)
247
 
248
- # ── Left panel (video info) ────────────────────────────────────────────────
249
  thumb_html = (
250
  f'<img src="{meta["thumbnail_url"]}" '
251
- f'style="width:100%;border-radius:8px;margin-bottom:8px">'
252
  if meta.get("thumbnail_url") else ""
253
  )
254
  tag_html = "".join(f'<span class="vv-tag">#{t}</span>' for t in meta.get("tags", [])[:20])
255
- desc_text = meta.get("description", "")
256
- desc_short = desc_text[:1200] + ("…" if len(desc_text) > 1200 else "")
257
  word_count = len(transcript.split()) if transcript else 0
258
- transcript_short = (transcript[:2500] + ("…" if len(transcript) > 2500 else "")) if transcript else "(not available)"
259
 
260
  left_html = f"""
261
  {thumb_html}
262
  <a href="https://www.youtube.com/watch?v={video_id}" target="_blank"
263
  style="display:block;text-align:center;font-family:'DM Mono',monospace;
264
- font-size:0.76rem;color:#5a6070;text-decoration:none;margin:4px 0 10px">
265
  β–Ά Open on YouTube
266
  </a>
267
  <div class="vv-card">
@@ -273,32 +473,27 @@ def _build_outputs(
273
  by <b style="color:#b0b4c0">{meta['channel_title']}</b> Β· {meta['published_at']}
274
  </p>
275
  </div>
 
276
  <p class="vv-section-title">Metrics</p>
277
  <span class="vv-stat">πŸ‘ {meta['view_count']:,}</span>
278
  <span class="vv-stat">πŸ‘ {meta['like_count']:,}</span>
279
  <span class="vv-stat">πŸ’¬ {meta['comment_count']:,}</span>
280
  <span class="vv-stat">⏱ {meta['duration']}</span>
 
281
  <p class="vv-section-title" style="margin-top:1rem">Tags</p>
282
  {tag_html or '<span style="color:#5a6070;font-size:0.78rem">(none)</span>'}
 
283
  <details style="margin-top:1rem">
284
- <summary style="cursor:pointer;font-family:'DM Mono',monospace;font-size:0.78rem;color:#5a6070">
285
- πŸ“„ Description
286
- </summary>
287
- <p style="font-size:0.78rem;color:#8090a0;line-height:1.65;white-space:pre-wrap;margin-top:6px">
288
- {desc_short}
289
- </p>
290
  </details>
291
  <details style="margin-top:0.5rem">
292
- <summary style="cursor:pointer;font-family:'DM Mono',monospace;font-size:0.78rem;color:#5a6070">
293
- πŸ“ Transcript ({word_count} words)
294
- </summary>
295
- <p style="font-size:0.75rem;color:#8090a0;line-height:1.65;margin-top:6px">
296
- {transcript_short}
297
- </p>
298
  </details>
299
  """
300
 
301
- # ── Misinfo badge + reasoning ──────────────────────────────────────────────
302
  score = misinfo["score"]
303
  if score < 0.35:
304
  badge_html = '<span class="vv-badge-green">βœ… Appears Credible</span>'
@@ -308,90 +503,138 @@ def _build_outputs(
308
  badge_html = '<span class="vv-badge-red">🚨 Likely Misinformation</span>'
309
 
310
  reasoning_html = (
311
- f'<div class="vv-reasoning" style="margin-top:8px">'
312
- f'🧠 <b>Reasoning:</b> {misinfo["reasoning"]}'
313
- f'</div>'
314
  )
315
 
316
- # ── Plotly charts ──────────────────────────────────────────────────────────
317
- fig_gauge = misinfo_gauge(score, "Misinfo Confidence")
318
- fig_streams = stream_trust_bars(misinfo["stream_details"])
319
- fig_donut = sentiment_donut(sent_sum) if sent_sum else _empty_plotly()
320
- fig_timeline = sentiment_timeline(comments_df, sentiments) if (sent_sum and not comments_df.empty) else _empty_plotly()
321
- fig_kw = keyword_bar(keywords, title="Top Video Keywords", color="#00d4ff")
322
- fig_kw_comp = keyword_comparison(pos_kw, neg_kw) if (pos_kw or neg_kw) else _empty_plotly()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
 
324
  # ── Sentiment stat boxes ───────────────────────────────────────────────────
325
  if sent_sum:
326
- stat_pos = (f'<div class="vv-card" style="text-align:center">'
327
- f'<p class="vv-stat-big-green">{sent_sum["pos_pct"]}%</p>'
328
- f'<p style="color:#5a6070;font-size:0.75rem;margin:0">Positive</p></div>')
329
- stat_neg = (f'<div class="vv-card" style="text-align:center">'
330
- f'<p class="vv-stat-big-red">{sent_sum["neg_pct"]}%</p>'
331
- f'<p style="color:#5a6070;font-size:0.75rem;margin:0">Negative</p></div>')
332
- stat_neu = (f'<div class="vv-card" style="text-align:center">'
333
- f'<p class="vv-stat-big-dim">{sent_sum["neu_pct"]}%</p>'
334
- f'<p style="color:#5a6070;font-size:0.75rem;margin:0">Neutral</p></div>')
 
 
 
 
 
 
335
  else:
336
- placeholder = '<div class="vv-card" style="text-align:center;color:#5a6070;font-size:0.8rem">N/A</div>'
 
 
 
337
  stat_pos = stat_neg = stat_neu = placeholder
338
 
339
- # ── Comment DataFrames for the 4 tabs ─────────────────────────────────────
340
  show_cols = ["author", "text", "likes", "published_at"]
341
  df_all = df_pos = df_neg = df_top = pd.DataFrame()
342
 
343
  if not comments_df.empty:
344
  display_df = comments_df.copy()
345
  if sentiments:
346
- display_df["sentiment"] = [s["label"] for s in sentiments]
347
- display_df["compound"] = [round(s.get("compound", 0), 3) for s in sentiments]
348
  cols = show_cols + ["sentiment", "compound"]
349
  else:
350
  cols = show_cols
351
 
352
  df_all = display_df[cols].head(100).reset_index(drop=True)
353
- df_top = display_df.sort_values("likes", ascending=False).head(20)[cols].reset_index(drop=True)
354
-
 
 
 
355
  if "sentiment" in display_df.columns:
356
  df_pos = display_df[display_df["sentiment"] == "POSITIVE"][cols].head(50).reset_index(drop=True)
357
  df_neg = display_df[display_df["sentiment"] == "NEGATIVE"][cols].head(50).reset_index(drop=True)
358
 
359
  return (
360
- status_html, # 0 status_box
361
- log_html, # 1 log_html_out
362
- left_html, # 2 left_panel_html
363
- badge_html, # 3 misinfo_badge_html
364
- reasoning_html, # 4 misinfo_reasoning_html
365
- fig_gauge, # 5 misinfo_gauge_plot
366
- fig_streams, # 6 stream_bars_plot
367
- fig_donut, # 7 donut_plot
368
- fig_timeline, # 8 timeline_plot
369
- fig_kw, # 9 kw_bar_plot
370
- fig_kw_comp, # 10 kw_comp_plot
371
- stat_pos, # 11 stat_pos_html
372
- stat_neg, # 12 stat_neg_html
373
- stat_neu, # 13 stat_neu_html
374
- df_all, # 14 df_all_out
375
- df_pos, # 15 df_pos_out
376
- df_neg, # 16 df_neg_out
377
- df_top, # 17 df_top_out
378
  )
379
 
380
 
381
- # ══════════════════════════════════════════════════════════════════════════════
382
- # UPLOAD TAB β€” search by keyword helper
383
- # ══════════════════════════════════════════════════════════════════════════════
384
 
385
- def do_search(keyword: str, api_key: str):
386
- if not (api_key or "").strip():
 
387
  return (
388
- "<p style='color:#ff4757;font-family:DM Mono,monospace'>⚠️ API key required.</p>",
389
  gr.update(choices=[], value=None, visible=False),
390
  )
391
- results = search_videos_by_title(keyword, api_key, max_results=5)
 
 
 
 
 
 
392
  if not results:
393
  return (
394
- "<p style='color:#ffb347;font-family:DM Mono,monospace'>No results found. Try a different keyword.</p>",
395
  gr.update(choices=[], value=None, visible=False),
396
  )
397
 
@@ -403,36 +646,32 @@ def do_search(keyword: str, api_key: str):
403
  choices.append((r["title"][:70], url))
404
  html += (
405
  f'<div class="vv-card" style="display:flex;align-items:center;gap:12px;margin-bottom:6px">'
406
- f'<img src="{r["thumbnail_url"]}" style="width:72px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0">'
407
- f'<div><p style="margin:0;font-size:0.85rem;font-weight:600;color:#e8eaf0">{r["title"][:80]}</p>'
408
- f'<p style="margin:0;font-size:0.75rem;color:#5a6070">{r["channel_title"]} Β· {r["published_at"]} Β· '
409
- f'<code style="color:#00d4ff">v={vid}</code></p></div></div>'
 
 
 
 
410
  )
411
  return html, gr.update(choices=choices, value=None, visible=True)
412
 
413
 
414
- def pick_and_analyze(selected_url, api_key, sentiment_method, max_comments):
415
- """When user picks a search result, run the full pipeline on it."""
416
  if not selected_url:
417
  yield _blank_outputs("Select a video from the search results above.")
418
  return
419
- yield from run_pipeline(selected_url, api_key, sentiment_method, max_comments)
420
 
421
 
422
- # ══════════════════════════════════════════════════════════════════════════════
423
  # GRADIO BLOCKS UI
424
- #
425
- # Gradio 6.x rules:
426
- # βœ… gr.Blocks(title="…") ← title only; no css/theme here
427
- # βœ… gr.Dataframe(max_height=320, wrap=True, …) ← max_height valid in 6.x
428
- # βœ… demo.launch(css=CSS, theme=…) ← css/theme move to launch()
429
- # ❌ demo.launch(show_api=False) ← show_api removed in 6.x
430
- # ══════════════════════════════════════════════════════════════════════════════
431
-
432
- # FIX 1: gr.Blocks() takes title only β€” css= and theme= are passed to launch() below.
433
  with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
434
 
435
- # ── Header ────────────────────────────────────────────────────────────────
436
  gr.HTML("""
437
  <div style="padding:1.5rem 0 0.8rem;border-bottom:1px solid #1e2330;margin-bottom:1.2rem">
438
  <h1 class="vv-hero">πŸ”¬ Video Verifier & Sentiment Analyzer</h1>
@@ -442,17 +681,17 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
442
  </div>
443
  """)
444
 
445
- # ── Settings row ──────────────────────────────────────────────────────────
446
  with gr.Accordion("βš™οΈ Settings", open=False):
 
 
 
 
 
 
 
 
447
  with gr.Row():
448
- api_key_input = gr.Textbox(
449
- value=os.environ.get("YT_API_KEY", ""),
450
- placeholder="AIza…",
451
- label="YouTube Data API v3 Key",
452
- type="password",
453
- scale=3,
454
- info="Get a free key at console.cloud.google.com β†’ Enable YouTube Data API v3",
455
- )
456
  sentiment_selector = gr.Dropdown(
457
  choices=[
458
  ("VADER β€” fast, CPU-only (~5 000 comments/sec)", "vader"),
@@ -460,12 +699,12 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
460
  ],
461
  value="vader",
462
  label="Sentiment Engine",
463
- scale=2,
464
  )
465
  max_comments_slider = gr.Slider(
466
  minimum=10, maximum=500, value=150, step=10,
467
  label="Max comments to fetch",
468
- scale=2,
469
  info="YouTube API quota: ~1 unit per comment request",
470
  )
471
 
@@ -484,10 +723,9 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
484
  with gr.TabItem("πŸ“ Upload / Search by Title"):
485
  gr.HTML("""
486
  <div class="vv-card" style="margin-bottom:8px">
487
- <p class="vv-section-title">Upload a video file β†’ find matching YouTube metadata</p>
488
  <p style="font-size:0.82rem;color:#5a6070;line-height:1.6;margin:0">
489
- ⚠️ The YouTube Data API cannot search by raw video bytes.
490
- Upload your file, then type the title or a keyword below to find the matching YouTube entry.
491
  </p>
492
  </div>
493
  """)
@@ -499,16 +737,12 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
499
  kw_input = gr.Textbox(placeholder="Enter video title or keyword…", label="Search keyword", scale=4)
500
  search_btn = gr.Button("πŸ”Ž Find on YouTube", scale=1)
501
  search_results_html = gr.HTML()
502
- search_radio = gr.Radio(
503
- label="Select a video to analyze",
504
- choices=[],
505
- visible=False,
506
- )
507
 
508
- # ── Status bar ─────────────────────────────────────────────────────────────
509
  status_box = gr.HTML(
510
  '<p style="color:#5a6070;font-family:DM Mono,monospace;font-size:0.8rem;padding:6px 0">'
511
- 'Enter a URL above and click Analyze.</p>'
512
  )
513
 
514
  # ── Main results layout ────────────────────────────────────────────────────
@@ -517,14 +751,14 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
517
  # LEFT β€” video info
518
  with gr.Column(scale=2):
519
  left_panel_html = gr.HTML(
520
- '<div style="padding:3rem;text-align:center;color:#5a6070;'
521
- 'font-family:DM Mono,monospace">No data yet.</div>'
522
  )
523
 
524
  # RIGHT β€” analytics
525
  with gr.Column(scale=3):
526
 
527
- # Misinfo block
528
  gr.HTML('<p class="vv-section-title" style="margin-top:0">πŸ”¬ Misinformation Analysis</p>')
529
  misinfo_badge_html = gr.HTML()
530
  with gr.Row():
@@ -532,9 +766,9 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
532
  stream_bars_plot = gr.Plot(label="", show_label=False)
533
  misinfo_reasoning_html = gr.HTML()
534
 
535
- gr.HTML('<hr style="border-color:#1e2330;margin:1rem 0">')
536
 
537
- # Sentiment block
538
  gr.HTML('<p class="vv-section-title">πŸ’¬ Comment Sentiment</p>')
539
  with gr.Row():
540
  stat_pos_html = gr.HTML()
@@ -547,12 +781,11 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
547
  kw_bar_plot = gr.Plot(label="", show_label=False)
548
  kw_comp_plot = gr.Plot(label="", show_label=False)
549
 
550
- gr.HTML('<hr style="border-color:#1e2330;margin:1rem 0">')
551
 
552
  # Comments deep-dive
553
  gr.HTML('<p class="vv-section-title">πŸ“Š Comments Deep-Dive</p>')
554
  with gr.Tabs():
555
- # FIX 2: max_height= is the correct param in Gradio 5.x/6.x
556
  with gr.TabItem("All"):
557
  df_all_out = gr.Dataframe(
558
  headers=["author", "text", "likes", "published_at", "sentiment", "compound"],
@@ -567,7 +800,7 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
567
  with gr.TabItem("Most Liked"):
568
  df_top_out = gr.Dataframe(wrap=True, max_height=320)
569
 
570
- # ── Activity log ──────────────────────────────────────────────────────────
571
  with gr.Accordion("πŸ“œ Activity Log", open=False):
572
  log_html_out = gr.HTML('<p class="vv-log-line">β€”</p>')
573
 
@@ -576,15 +809,11 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
576
  <div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #1e2330;
577
  text-align:center;font-family:'DM Mono',monospace;font-size:0.72rem;color:#3a3f50">
578
  4-stream SeTa-Attention BiGRU Β· CCM / DMTE / Uncertainty Fusion Β·
579
- Plug your checkpoint into
580
- <code style="color:#00d4ff">detect_misinformation()</code> in analyzer.py Β·
581
  Test ROC-AUC 0.967
582
  </div>
583
  """)
584
 
585
- # ══════════════════════════════════════════════════════════════════════════
586
- # OUTPUT LIST β€” order must exactly match _build_outputs / _blank_outputs
587
- # ══════════════════════════════════════════════════════════════════════════
588
  ALL_OUTPUTS = [
589
  status_box, # 0
590
  log_html_out, # 1
@@ -606,31 +835,35 @@ with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
606
  df_top_out, # 17
607
  ]
608
 
609
- # ── Events: URL tab ───────────────────────────────────────────────────────
610
- _pipeline_inputs = [url_input, api_key_input, sentiment_selector, max_comments_slider]
611
 
 
612
  analyze_btn.click(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
613
  url_input.submit(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
614
 
615
- # ── Events: Upload/Search tab ─────────────────────────────────────────────
616
  search_btn.click(
617
  fn=do_search,
618
- inputs=[kw_input, api_key_input],
619
  outputs=[search_results_html, search_radio],
620
  )
621
-
622
  search_radio.change(
623
  fn=pick_and_analyze,
624
- inputs=[search_radio, api_key_input, sentiment_selector, max_comments_slider],
625
  outputs=ALL_OUTPUTS,
626
  )
627
 
628
- # ══════════════════════════════════════════════════════════════════════════════
629
- # FIX 1: css= and theme= move to launch() in Gradio 6.x
630
- # FIX 2: show_api= removed entirely β€” no longer a valid launch() param in 6.x
631
- # ══════════════════════════════════════════════════════════════════════════════
632
  if __name__ == "__main__":
633
  demo.launch(
634
  css=CSS,
635
- theme=gr.themes.Base(),
 
 
 
 
636
  )
 
1
  """
2
+ app.py β€” Video Verifier & Sentiment Analyzer (Gradio 6.x)
3
+
4
+ Fixes applied vs. original:
5
+ 1. API key NEVER displayed in the UI β€” read from YT_API_KEY env var only.
6
+ 2. All chart/plot outputs guaranteed to return valid go.Figure objects.
7
+ 3. Dark theme enforced across the entire viewport (no white half-screen).
8
+ 4. Layout fills the full browser width.
9
+ 5. Gradio 6.x compatibility: css/theme go to launch(), not Blocks().
10
+ 6. show_api removed (not valid in Gradio 6.x).
11
+ 7. gr.Dataframe uses max_height= (not height=).
12
  """
13
 
14
  import os
 
38
  keyword_comparison,
39
  )
40
 
41
+ # ═══════════════════════════════════════════════════════════════════════════════
42
+ # CSS β€” full-viewport dark theme, zero white bleed
43
+ # ═══════════════════════════════════════════════════════════════════════════════
44
 
45
  CSS = """
46
  @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');
47
 
48
+ /* ── Variables ─────────────────────────────────────────────────────────────── */
49
  :root {
50
  --bg: #0d0f14;
51
  --card: #13161e;
 
59
  --blue: #4a8eff;
60
  }
61
 
62
+ /* ── Force dark everywhere β€” prevent white bleed ────────────────────────────── */
63
+ html, body {
64
+ background: var(--bg) !important;
65
+ color: var(--text) !important;
66
+ margin: 0; padding: 0;
67
+ }
68
+ .gradio-container, #root, #app, main, .main, .wrap, .svelte-1kyws56 {
69
+ background: var(--bg) !important;
70
+ max-width: 100% !important;
71
+ width: 100% !important;
72
+ margin: 0 auto !important;
73
+ padding: 0 1.5rem !important;
74
+ box-sizing: border-box !important;
75
+ }
76
+ /* kill Gradio's default white blocks */
77
+ .block, .wrap, .panel, .padded, div.form,
78
+ div[class*="block"], div[class*="wrap"],
79
+ div[class*="panel"], div[class*="gap"],
80
+ .gap { background: transparent !important; border: none !important; }
81
+
82
+ /* ── Cards / Groups ─────────────────────────────────────────────────────────── */
83
+ .gr-group, .gr-box, .vv-section {
84
+ background: var(--card) !important;
85
+ border: 1px solid var(--border) !important;
86
+ border-radius: 12px !important;
87
+ padding: 1rem 1.25rem !important;
88
+ }
89
 
90
+ /* ── Tabs ──────────────────────────────────────────────────────────────────── */
91
+ .tab-nav button {
92
+ background: transparent !important;
93
+ border: none !important;
94
+ color: var(--dim) !important;
95
+ font-family: 'DM Mono', monospace !important;
96
+ font-size: 0.82rem !important;
97
+ letter-spacing: 0.05em !important;
98
+ border-bottom: 2px solid transparent !important;
99
+ padding: 0.5rem 1.2rem !important;
100
+ transition: color 0.18s;
101
+ }
102
+ .tab-nav button.selected {
103
+ color: var(--cyan) !important;
104
+ border-bottom-color: var(--cyan) !important;
105
+ }
106
  .tab-nav { border-bottom: 1px solid var(--border) !important; }
107
 
108
+ /* ── Inputs ────────────────────────────────────────────────────────────────── */
109
+ input[type="text"], input[type="password"], input[type="number"], textarea, select {
110
+ background: #1a1d27 !important;
111
+ border: 1px solid var(--border) !important;
112
+ color: var(--text) !important;
113
+ border-radius: 8px !important;
114
+ font-family: 'DM Mono', monospace !important;
115
+ font-size: 0.88rem !important;
116
+ }
117
+ input:focus, textarea:focus, select:focus {
118
+ border-color: var(--cyan) !important;
119
+ box-shadow: 0 0 0 2px rgba(0,212,255,0.15) !important;
120
+ outline: none !important;
121
+ }
122
+ label, .gr-label, span.svelte-1b6s6s {
123
+ color: var(--dim) !important;
124
+ font-family: 'DM Mono', monospace !important;
125
+ font-size: 0.75rem !important;
126
+ letter-spacing: 0.08em !important;
127
+ text-transform: uppercase;
128
+ }
129
 
130
+ /* ── Slider ────────────────────────────────────────────────────────────────── */
131
  input[type="range"] { accent-color: var(--cyan); }
132
 
133
+ /* ── Buttons ───────────────────────────────────────────────────────────────── */
134
+ button.primary, button[variant="primary"], .primary {
135
+ background: linear-gradient(135deg, var(--cyan), var(--blue)) !important;
136
+ border: none !important;
137
+ color: #0d0f14 !important;
138
+ font-weight: 700 !important;
139
+ font-family: 'DM Mono', monospace !important;
140
+ border-radius: 8px !important;
141
+ letter-spacing: 0.06em !important;
142
+ }
143
+ button.secondary {
144
+ background: rgba(0,212,255,0.08) !important;
145
+ border: 1px solid var(--cyan) !important;
146
+ color: var(--cyan) !important;
147
+ border-radius: 8px !important;
148
+ font-family: 'DM Mono', monospace !important;
149
+ }
150
+ button:hover { opacity: 0.88; transform: translateY(-1px); transition: all 0.15s; }
151
+
152
+ /* ── Dropdowns ─────────────────────────────────────────────────────────────── */
153
+ .dropdown, ul[role="listbox"], li[role="option"] {
154
+ background: #1a1d27 !important;
155
+ border-color: var(--border) !important;
156
+ color: var(--text) !important;
157
+ }
158
+ li[role="option"]:hover { background: #242736 !important; }
159
+
160
+ /* ── Dataframe ─────────────────────────────────────────────────────────────── */
161
+ .gr-dataframe, table { background: var(--card) !important; }
162
+ .gr-dataframe th {
163
+ background: #1a1d27 !important;
164
+ color: var(--cyan) !important;
165
+ font-family: 'DM Mono', monospace !important;
166
+ font-size: 0.72rem !important;
167
+ padding: 6px 10px;
168
+ border-bottom: 1px solid var(--border);
169
+ text-transform: uppercase;
170
+ letter-spacing: 0.08em;
171
+ }
172
+ .gr-dataframe td {
173
+ color: var(--text) !important;
174
+ font-size: 0.77rem !important;
175
+ padding: 5px 10px;
176
+ border-bottom: 1px solid var(--border);
177
+ }
178
  .gr-dataframe tr:hover td { background: rgba(0,212,255,0.04) !important; }
179
 
180
+ /* ── Accordion ─────────────────────────────────────────────────────────────── */
181
+ details > summary {
182
+ color: var(--dim) !important;
183
+ font-family: 'DM Mono', monospace !important;
184
+ font-size: 0.82rem !important;
185
+ cursor: pointer;
186
+ list-style: none;
187
+ }
188
+ details[open] > summary { color: var(--cyan) !important; }
189
+
190
+ /* ── Plot containers ───────────────────────────────────────────────────────── */
191
+ .js-plotly-plot, .plotly { background: transparent !important; }
192
+ .modebar { display: none !important; }
193
+
194
+ /* ── Scrollbar ─────────────────────────────────────────────────────────────── */
195
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
196
+ ::-webkit-scrollbar-track { background: var(--bg); }
197
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
198
+ ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
199
+
200
+ /* ════════════════════════════════════════════════════════════════════════════ */
201
+ /* Shared HTML component classes */
202
+ /* ════════════════════════════════════════════════════════════════════════════ */
203
+ .vv-hero {
204
+ font-family: 'Syne', sans-serif;
205
+ font-size: 1.65rem;
206
+ font-weight: 800;
207
+ background: linear-gradient(135deg, #00d4ff, #4a8eff);
208
+ -webkit-background-clip: text;
209
+ -webkit-text-fill-color: transparent;
210
+ background-clip: text;
211
+ letter-spacing: -0.02em;
212
+ line-height: 1.2;
213
+ }
214
+ .vv-section-title {
215
+ font-family: 'Syne', sans-serif;
216
+ font-size: 0.68rem;
217
+ font-weight: 700;
218
+ letter-spacing: 0.18em;
219
+ text-transform: uppercase;
220
+ color: #5a6070;
221
+ margin-bottom: 0.5rem;
222
+ margin-top: 0;
223
+ }
224
+ .vv-card {
225
+ background: #13161e;
226
+ border: 1px solid #1e2330;
227
+ border-radius: 12px;
228
+ padding: 1.1rem 1.3rem;
229
+ margin-bottom: 0.7rem;
230
+ }
231
+ .vv-stat {
232
+ display: inline-block;
233
+ background: #1a1d27;
234
+ border: 1px solid #1e2330;
235
+ border-radius: 6px;
236
+ padding: 0.25rem 0.75rem;
237
+ font-family: 'DM Mono', monospace;
238
+ font-size: 0.77rem;
239
+ color: #00d4ff;
240
+ margin: 0.15rem 0.2rem;
241
+ }
242
+ .vv-badge-green {
243
+ display: inline-block;
244
+ background: rgba(0,229,160,0.12);
245
+ border: 1px solid #00e5a0;
246
+ color: #00e5a0;
247
+ border-radius: 20px;
248
+ padding: 0.32rem 1.1rem;
249
+ font-size: 0.85rem;
250
+ font-family: 'DM Mono', monospace;
251
+ font-weight: 600;
252
+ }
253
+ .vv-badge-red {
254
+ display: inline-block;
255
+ background: rgba(255,71,87,0.12);
256
+ border: 1px solid #ff4757;
257
+ color: #ff4757;
258
+ border-radius: 20px;
259
+ padding: 0.32rem 1.1rem;
260
+ font-size: 0.85rem;
261
+ font-family: 'DM Mono', monospace;
262
+ font-weight: 600;
263
+ }
264
+ .vv-badge-amber {
265
+ display: inline-block;
266
+ background: rgba(255,179,71,0.12);
267
+ border: 1px solid #ffb347;
268
+ color: #ffb347;
269
+ border-radius: 20px;
270
+ padding: 0.32rem 1.1rem;
271
+ font-size: 0.85rem;
272
+ font-family: 'DM Mono', monospace;
273
+ font-weight: 600;
274
+ }
275
+ .vv-reasoning {
276
+ background: #0d1119;
277
+ border-left: 3px solid #ffb347;
278
+ padding: 0.8rem 1rem;
279
+ border-radius: 0 8px 8px 0;
280
+ font-size: 0.83rem;
281
+ color: #c0c4cc;
282
+ line-height: 1.65;
283
+ font-family: 'IBM Plex Sans', sans-serif;
284
+ margin-top: 8px;
285
+ }
286
+ .vv-tag {
287
+ display: inline-block;
288
+ background: #1a1d27;
289
+ border: 1px solid #1e2330;
290
+ border-radius: 4px;
291
+ padding: 2px 8px;
292
+ font-family: 'DM Mono', monospace;
293
+ font-size: 0.7rem;
294
+ color: #8090a0;
295
+ margin: 2px;
296
+ }
297
  .vv-stat-big-green { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #00e5a0; margin: 0; }
298
  .vv-stat-big-red { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #ff4757; margin: 0; }
299
  .vv-stat-big-dim { font-family: 'DM Mono', monospace; font-size: 1.6rem; font-weight: 700; color: #5a6070; margin: 0; }
300
+ .vv-log-line { font-size: 0.72rem; color: #5a6070; font-family: 'DM Mono', monospace; margin: 2px 0; }
301
+ .vv-hr { border: none; border-top: 1px solid #1e2330; margin: 1.1rem 0; }
302
  """
303
 
304
+ # ═══════════════════════════════════════════════════════════════════════════════
305
+ # HELPERS
306
+ # ═══════════════════════════════════════════════════════════════════════════════
307
 
308
+ def _empty_plotly(msg: str = "Run analysis to see data", h: int = 230):
309
  import plotly.graph_objects as go
310
  fig = go.Figure()
311
  fig.update_layout(
312
  paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
313
+ font=dict(color="#5a6070"), margin=dict(l=10, r=10, t=10, b=10), height=h,
314
+ )
315
+ fig.add_annotation(
316
+ text=msg, x=0.5, y=0.5, xref="paper", yref="paper",
317
+ showarrow=False, font=dict(size=12, color="#5a6070"),
318
  )
 
 
319
  return fig
320
 
321
 
322
  def _blank_outputs(status_msg: str):
323
+ """18-tuple for ALL_OUTPUTS when nothing has run."""
324
  ep = _empty_plotly()
325
  return (
326
+ f'<p style="color:#ff4757;font-family:DM Mono,monospace;padding:8px">{status_msg}</p>',
327
+ "<p class='vv-log-line'>οΏ½οΏ½</p>",
328
+ "<div style='padding:3rem;text-align:center;color:#5a6070;font-family:DM Mono,monospace'>No data yet.</div>",
329
+ "", "",
330
+ ep, ep, ep, ep, ep, ep,
331
+ "", "", "",
332
+ pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(),
333
  )
334
 
335
 
336
+ # ═══════════════════════════════════════════════════════════════════════════════
337
  # PIPELINE
338
+ # ═══════════════════════════════════════════════════════════════════════════════
339
 
340
  def run_pipeline(
341
  url_or_id: str,
 
342
  sentiment_method: str,
343
  max_comments: int,
344
  progress=gr.Progress(track_tqdm=False),
345
  ):
346
+ # ── Read API key from environment (NEVER from UI) ──────────────────────────
347
+ api_key = os.environ.get("YT_API_KEY", "").strip()
348
+
349
+ # ── Guards ─────────────────────────────────────────────────────────────────
 
350
  if not (url_or_id or "").strip():
351
  yield _blank_outputs("⚠️ Please enter a YouTube URL or video ID.")
352
  return
353
 
354
  video_id = extract_video_id(url_or_id.strip())
355
  if not video_id:
356
+ yield _blank_outputs("❌ Could not parse a valid YouTube video ID.")
357
  return
358
 
359
+ if not api_key:
360
+ yield _blank_outputs(
361
+ "⚠️ YouTube API key not found. "
362
+ "Set the <code>YT_API_KEY</code> environment variable / Space secret."
363
+ )
364
  return
365
 
366
+ # 1 β€” Metadata ──────────────────────────────────────────────────────────────
367
  progress(0.05, desc="Fetching video metadata…")
368
+ meta, err = fetch_video_metadata(video_id, api_key)
369
+ if err:
370
+ yield _blank_outputs(f"❌ {err}")
371
  return
372
 
373
+ # 2 β€” Transcript ────────────────────────────────────────────────────────────
374
  progress(0.20, desc="Fetching transcript…")
375
  transcript, t_status = fetch_transcript(video_id)
376
 
377
+ # 3 β€” Comments ──────────────────────────────────────────────────────────────
378
  progress(0.35, desc=f"Fetching up to {max_comments} comments…")
379
  comments_df, c_status = fetch_comments(video_id, api_key, max_comments=int(max_comments))
380
 
381
+ # 4 β€” Misinformation ──────────────────────────────────────���─────────────────
382
  progress(0.50, desc="Running misinformation detection…")
383
  misinfo = detect_misinformation(
384
  text=f"{meta['title']} {meta['description']}",
 
387
  video_transcript=transcript,
388
  )
389
 
390
+ # 5 β€” Keywords ──────────────────────────────────────────────────────────────
391
  keywords = extract_keywords(
392
  f"{meta['title']} {meta['description']} {transcript}",
393
  meta["tags"],
394
  )
395
 
396
+ # 6 β€” Sentiment ─────────────────────────────────────────────────────────────
397
  sentiments, sent_sum, pos_kw, neg_kw = [], {}, [], []
398
 
399
  if not comments_df.empty:
400
  texts = comments_df["text"].fillna("").tolist()
401
+ batch = 64
402
+ for i in range(0, len(texts), batch):
403
+ chunk = texts[i: i + batch]
404
+ sentiments += analyze_sentiment_batch(chunk, method=sentiment_method, batch_size=batch)
405
+ frac = 0.60 + 0.30 * min((i + batch) / max(len(texts), 1), 1.0)
406
+ progress(frac, desc=f"Sentiment {min(i+batch, len(texts))}/{len(texts)}…")
407
 
408
  sent_sum = sentiment_summary(sentiments)
409
  pos_kw, neg_kw = sentiment_weighted_keywords(comments_df, sentiments)
410
 
411
+ # 7 β€” Build outputs ─────────────────────────────────────────────────────────
412
  progress(0.97, desc="Building charts…")
413
  yield _build_outputs(
414
  meta=meta, video_id=video_id, transcript=transcript,
 
417
  pos_kw=pos_kw, neg_kw=neg_kw,
418
  status_log=[
419
  f"βœ… Metadata: {meta['title'][:55]}",
420
+ t_status,
421
+ c_status,
422
  f"πŸ”¬ Misinfo score: {misinfo['confidence_pct']}%",
423
  *(
424
  [f"πŸ’¬ Sentiment: {sent_sum['pos_pct']}% pos / {sent_sum['neg_pct']}% neg"]
425
+ if sent_sum
426
+ else ["πŸ’¬ No comments β€” sentiment skipped"]
427
  ),
428
  ],
429
  )
430
 
431
 
432
+ # ═══════════════════════════════════════════════════════════════════════════════
433
  # OUTPUT BUILDER
434
+ # ═══════════════════════════════════════════════════════════════════════════════
435
 
436
  def _build_outputs(
437
  meta, video_id, transcript, comments_df,
438
  misinfo, keywords, sentiments, sent_sum, pos_kw, neg_kw, status_log,
439
  ):
440
+ # ── Status ─────────────────────────────────────────────────────────────────
441
  status_html = (
442
  '<p style="color:#00e5a0;font-family:DM Mono,monospace;font-size:0.82rem;padding:6px 0">'
443
+ "βœ… Analysis complete</p>"
444
  )
445
 
446
+ # ── Log ────────────────────────────────────────────────────────────────────
447
  log_html = "".join(f'<p class="vv-log-line">{line}</p>' for line in status_log)
448
 
449
+ # ── Left panel ─────────────────────────────────────────────────────────────
450
  thumb_html = (
451
  f'<img src="{meta["thumbnail_url"]}" '
452
+ 'style="width:100%;border-radius:8px;margin-bottom:8px;display:block">'
453
  if meta.get("thumbnail_url") else ""
454
  )
455
  tag_html = "".join(f'<span class="vv-tag">#{t}</span>' for t in meta.get("tags", [])[:20])
456
+ desc_short = meta.get("description", "")[:1200]
 
457
  word_count = len(transcript.split()) if transcript else 0
458
+ transcript_short = (transcript[:2500] + "…" if len(transcript) > 2500 else transcript) if transcript else "(not available)"
459
 
460
  left_html = f"""
461
  {thumb_html}
462
  <a href="https://www.youtube.com/watch?v={video_id}" target="_blank"
463
  style="display:block;text-align:center;font-family:'DM Mono',monospace;
464
+ font-size:0.75rem;color:#5a6070;text-decoration:none;margin:4px 0 10px">
465
  β–Ά Open on YouTube
466
  </a>
467
  <div class="vv-card">
 
473
  by <b style="color:#b0b4c0">{meta['channel_title']}</b> Β· {meta['published_at']}
474
  </p>
475
  </div>
476
+
477
  <p class="vv-section-title">Metrics</p>
478
  <span class="vv-stat">πŸ‘ {meta['view_count']:,}</span>
479
  <span class="vv-stat">πŸ‘ {meta['like_count']:,}</span>
480
  <span class="vv-stat">πŸ’¬ {meta['comment_count']:,}</span>
481
  <span class="vv-stat">⏱ {meta['duration']}</span>
482
+
483
  <p class="vv-section-title" style="margin-top:1rem">Tags</p>
484
  {tag_html or '<span style="color:#5a6070;font-size:0.78rem">(none)</span>'}
485
+
486
  <details style="margin-top:1rem">
487
+ <summary>πŸ“„ Description</summary>
488
+ <p style="font-size:0.78rem;color:#8090a0;line-height:1.65;white-space:pre-wrap;margin-top:6px">{desc_short}</p>
 
 
 
 
489
  </details>
490
  <details style="margin-top:0.5rem">
491
+ <summary>πŸ“ Transcript ({word_count} words)</summary>
492
+ <p style="font-size:0.75rem;color:#8090a0;line-height:1.65;margin-top:6px">{transcript_short}</p>
 
 
 
 
493
  </details>
494
  """
495
 
496
+ # ── Misinfo badge ──────────────────────────────────────────────────────────
497
  score = misinfo["score"]
498
  if score < 0.35:
499
  badge_html = '<span class="vv-badge-green">βœ… Appears Credible</span>'
 
503
  badge_html = '<span class="vv-badge-red">🚨 Likely Misinformation</span>'
504
 
505
  reasoning_html = (
506
+ f'<div class="vv-reasoning">🧠 <b>Reasoning:</b> {misinfo["reasoning"]}</div>'
 
 
507
  )
508
 
509
+ # ── Charts β€” all wrapped in try/except so one failure can't break the rest ──
510
+ try:
511
+ fig_gauge = misinfo_gauge(score, "Misinfo Confidence")
512
+ except Exception:
513
+ fig_gauge = _empty_plotly()
514
+
515
+ try:
516
+ fig_streams = stream_trust_bars(misinfo["stream_details"])
517
+ except Exception:
518
+ fig_streams = _empty_plotly()
519
+
520
+ try:
521
+ fig_donut = sentiment_donut(sent_sum) if sent_sum else _empty_plotly("No comments analysed")
522
+ except Exception:
523
+ fig_donut = _empty_plotly()
524
+
525
+ try:
526
+ fig_timeline = (
527
+ sentiment_timeline(comments_df, sentiments)
528
+ if (sent_sum and not comments_df.empty)
529
+ else _empty_plotly("No comments analysed")
530
+ )
531
+ except Exception:
532
+ fig_timeline = _empty_plotly()
533
+
534
+ try:
535
+ fig_kw = keyword_bar(keywords, title="Top Video Keywords", color="#00d4ff")
536
+ except Exception:
537
+ fig_kw = _empty_plotly()
538
+
539
+ try:
540
+ fig_kw_comp = (
541
+ keyword_comparison(pos_kw, neg_kw)
542
+ if (pos_kw or neg_kw)
543
+ else _empty_plotly("No keyword comparison β€” no comments")
544
+ )
545
+ except Exception:
546
+ fig_kw_comp = _empty_plotly()
547
 
548
  # ── Sentiment stat boxes ───────────────────────────────────────────────────
549
  if sent_sum:
550
+ stat_pos = (
551
+ f'<div class="vv-card" style="text-align:center">'
552
+ f'<p class="vv-stat-big-green">{sent_sum["pos_pct"]}%</p>'
553
+ f'<p style="color:#5a6070;font-size:0.75rem;margin:4px 0 0">Positive</p></div>'
554
+ )
555
+ stat_neg = (
556
+ f'<div class="vv-card" style="text-align:center">'
557
+ f'<p class="vv-stat-big-red">{sent_sum["neg_pct"]}%</p>'
558
+ f'<p style="color:#5a6070;font-size:0.75rem;margin:4px 0 0">Negative</p></div>'
559
+ )
560
+ stat_neu = (
561
+ f'<div class="vv-card" style="text-align:center">'
562
+ f'<p class="vv-stat-big-dim">{sent_sum["neu_pct"]}%</p>'
563
+ f'<p style="color:#5a6070;font-size:0.75rem;margin:4px 0 0">Neutral</p></div>'
564
+ )
565
  else:
566
+ placeholder = (
567
+ '<div class="vv-card" style="text-align:center;color:#5a6070;'
568
+ 'font-family:DM Mono,monospace;font-size:0.8rem;padding:1.2rem">N/A</div>'
569
+ )
570
  stat_pos = stat_neg = stat_neu = placeholder
571
 
572
+ # ── Comment DataFrames ─────────────────────────────────────────────────────
573
  show_cols = ["author", "text", "likes", "published_at"]
574
  df_all = df_pos = df_neg = df_top = pd.DataFrame()
575
 
576
  if not comments_df.empty:
577
  display_df = comments_df.copy()
578
  if sentiments:
579
+ display_df["sentiment"] = [s["label"] for s in sentiments]
580
+ display_df["compound"] = [round(s.get("compound", 0), 3) for s in sentiments]
581
  cols = show_cols + ["sentiment", "compound"]
582
  else:
583
  cols = show_cols
584
 
585
  df_all = display_df[cols].head(100).reset_index(drop=True)
586
+ df_top = (
587
+ display_df.sort_values("likes", ascending=False)
588
+ .head(20)[cols]
589
+ .reset_index(drop=True)
590
+ )
591
  if "sentiment" in display_df.columns:
592
  df_pos = display_df[display_df["sentiment"] == "POSITIVE"][cols].head(50).reset_index(drop=True)
593
  df_neg = display_df[display_df["sentiment"] == "NEGATIVE"][cols].head(50).reset_index(drop=True)
594
 
595
  return (
596
+ status_html, # 0
597
+ log_html, # 1
598
+ left_html, # 2
599
+ badge_html, # 3
600
+ reasoning_html, # 4
601
+ fig_gauge, # 5
602
+ fig_streams, # 6
603
+ fig_donut, # 7
604
+ fig_timeline, # 8
605
+ fig_kw, # 9
606
+ fig_kw_comp, # 10
607
+ stat_pos, # 11
608
+ stat_neg, # 12
609
+ stat_neu, # 13
610
+ df_all, # 14
611
+ df_pos, # 15
612
+ df_neg, # 16
613
+ df_top, # 17
614
  )
615
 
616
 
617
+ # ═══════════════════════════════════════════════════════════════════════════════
618
+ # UPLOAD / SEARCH HELPERS
619
+ # ═══════════════════════════════════════════════════════════════════════════════
620
 
621
+ def do_search(keyword: str):
622
+ api_key = os.environ.get("YT_API_KEY", "").strip()
623
+ if not api_key:
624
  return (
625
+ "<p style='color:#ff4757;font-family:DM Mono,monospace'>⚠️ YT_API_KEY secret not set.</p>",
626
  gr.update(choices=[], value=None, visible=False),
627
  )
628
+ if not (keyword or "").strip():
629
+ return (
630
+ "<p style='color:#ffb347;font-family:DM Mono,monospace'>Enter a keyword to search.</p>",
631
+ gr.update(choices=[], value=None, visible=False),
632
+ )
633
+
634
+ results = search_videos_by_title(keyword.strip(), api_key, max_results=5)
635
  if not results:
636
  return (
637
+ "<p style='color:#ffb347;font-family:DM Mono,monospace'>No results found.</p>",
638
  gr.update(choices=[], value=None, visible=False),
639
  )
640
 
 
646
  choices.append((r["title"][:70], url))
647
  html += (
648
  f'<div class="vv-card" style="display:flex;align-items:center;gap:12px;margin-bottom:6px">'
649
+ f'<img src="{r["thumbnail_url"]}" '
650
+ f' style="width:72px;height:54px;object-fit:cover;border-radius:6px;flex-shrink:0">'
651
+ f'<div>'
652
+ f'<p style="margin:0;font-size:0.85rem;font-weight:600;color:#e8eaf0">{r["title"][:80]}</p>'
653
+ f'<p style="margin:0;font-size:0.75rem;color:#5a6070">'
654
+ f'{r["channel_title"]} Β· {r["published_at"]} Β· '
655
+ f'<code style="color:#00d4ff">v={vid}</code></p>'
656
+ f'</div></div>'
657
  )
658
  return html, gr.update(choices=choices, value=None, visible=True)
659
 
660
 
661
+ def pick_and_analyze(selected_url, sentiment_method, max_comments):
 
662
  if not selected_url:
663
  yield _blank_outputs("Select a video from the search results above.")
664
  return
665
+ yield from run_pipeline(selected_url, sentiment_method, max_comments)
666
 
667
 
668
+ # ═══════════════════════════════════════════════════════════════════════════════
669
  # GRADIO BLOCKS UI
670
+ # ═══════════════════════════════════════════════════════════════════════════════
671
+
 
 
 
 
 
 
 
672
  with gr.Blocks(title="VideoVerifier β€” MHMisinfo") as demo:
673
 
674
+ # ── Header ─────────────────────────────────────────────────────────────────
675
  gr.HTML("""
676
  <div style="padding:1.5rem 0 0.8rem;border-bottom:1px solid #1e2330;margin-bottom:1.2rem">
677
  <h1 class="vv-hero">πŸ”¬ Video Verifier & Sentiment Analyzer</h1>
 
681
  </div>
682
  """)
683
 
684
+ # ── Settings β€” NO API key field ────────────────────────────────────────────
685
  with gr.Accordion("βš™οΈ Settings", open=False):
686
+ gr.HTML("""
687
+ <div style="background:#0d1119;border:1px solid #1e2330;border-radius:8px;
688
+ padding:0.7rem 1rem;margin-bottom:0.8rem;font-family:'DM Mono',monospace;
689
+ font-size:0.78rem;color:#5a6070">
690
+ πŸ”‘ YouTube API key is read from the <code style="color:#00d4ff">YT_API_KEY</code>
691
+ Space secret β€” it is never exposed in the UI.
692
+ </div>
693
+ """)
694
  with gr.Row():
 
 
 
 
 
 
 
 
695
  sentiment_selector = gr.Dropdown(
696
  choices=[
697
  ("VADER β€” fast, CPU-only (~5 000 comments/sec)", "vader"),
 
699
  ],
700
  value="vader",
701
  label="Sentiment Engine",
702
+ scale=3,
703
  )
704
  max_comments_slider = gr.Slider(
705
  minimum=10, maximum=500, value=150, step=10,
706
  label="Max comments to fetch",
707
+ scale=3,
708
  info="YouTube API quota: ~1 unit per comment request",
709
  )
710
 
 
723
  with gr.TabItem("πŸ“ Upload / Search by Title"):
724
  gr.HTML("""
725
  <div class="vv-card" style="margin-bottom:8px">
726
+ <p class="vv-section-title">Search by video title or keyword</p>
727
  <p style="font-size:0.82rem;color:#5a6070;line-height:1.6;margin:0">
728
+ Upload your file, then type the title or keyword below to locate the matching YouTube entry.
 
729
  </p>
730
  </div>
731
  """)
 
737
  kw_input = gr.Textbox(placeholder="Enter video title or keyword…", label="Search keyword", scale=4)
738
  search_btn = gr.Button("πŸ”Ž Find on YouTube", scale=1)
739
  search_results_html = gr.HTML()
740
+ search_radio = gr.Radio(label="Select a video to analyze", choices=[], visible=False)
 
 
 
 
741
 
742
+ # ── Status ─────────────────────────────────────────────────────────────────
743
  status_box = gr.HTML(
744
  '<p style="color:#5a6070;font-family:DM Mono,monospace;font-size:0.8rem;padding:6px 0">'
745
+ "Enter a URL above and click Analyze.</p>"
746
  )
747
 
748
  # ── Main results layout ────────────────────────────────────────────────────
 
751
  # LEFT β€” video info
752
  with gr.Column(scale=2):
753
  left_panel_html = gr.HTML(
754
+ "<div style='padding:3rem;text-align:center;color:#5a6070;"
755
+ "font-family:DM Mono,monospace'>No data yet.</div>"
756
  )
757
 
758
  # RIGHT β€” analytics
759
  with gr.Column(scale=3):
760
 
761
+ # Misinfo
762
  gr.HTML('<p class="vv-section-title" style="margin-top:0">πŸ”¬ Misinformation Analysis</p>')
763
  misinfo_badge_html = gr.HTML()
764
  with gr.Row():
 
766
  stream_bars_plot = gr.Plot(label="", show_label=False)
767
  misinfo_reasoning_html = gr.HTML()
768
 
769
+ gr.HTML('<hr class="vv-hr">')
770
 
771
+ # Sentiment
772
  gr.HTML('<p class="vv-section-title">πŸ’¬ Comment Sentiment</p>')
773
  with gr.Row():
774
  stat_pos_html = gr.HTML()
 
781
  kw_bar_plot = gr.Plot(label="", show_label=False)
782
  kw_comp_plot = gr.Plot(label="", show_label=False)
783
 
784
+ gr.HTML('<hr class="vv-hr">')
785
 
786
  # Comments deep-dive
787
  gr.HTML('<p class="vv-section-title">πŸ“Š Comments Deep-Dive</p>')
788
  with gr.Tabs():
 
789
  with gr.TabItem("All"):
790
  df_all_out = gr.Dataframe(
791
  headers=["author", "text", "likes", "published_at", "sentiment", "compound"],
 
800
  with gr.TabItem("Most Liked"):
801
  df_top_out = gr.Dataframe(wrap=True, max_height=320)
802
 
803
+ # ── Activity log ───────────────────────────────────────────────────────────
804
  with gr.Accordion("πŸ“œ Activity Log", open=False):
805
  log_html_out = gr.HTML('<p class="vv-log-line">β€”</p>')
806
 
 
809
  <div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #1e2330;
810
  text-align:center;font-family:'DM Mono',monospace;font-size:0.72rem;color:#3a3f50">
811
  4-stream SeTa-Attention BiGRU Β· CCM / DMTE / Uncertainty Fusion Β·
 
 
812
  Test ROC-AUC 0.967
813
  </div>
814
  """)
815
 
816
+ # ── Output list β€” order must match _build_outputs / _blank_outputs exactly ─
 
 
817
  ALL_OUTPUTS = [
818
  status_box, # 0
819
  log_html_out, # 1
 
835
  df_top_out, # 17
836
  ]
837
 
838
+ # ── Pipeline inputs (no api_key_input β€” read from env) ────────────────────
839
+ _pipeline_inputs = [url_input, sentiment_selector, max_comments_slider]
840
 
841
+ # ── Events: URL tab ────────────────────────────────────────────────────────
842
  analyze_btn.click(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
843
  url_input.submit(fn=run_pipeline, inputs=_pipeline_inputs, outputs=ALL_OUTPUTS)
844
 
845
+ # ── Events: Upload/Search tab ──────────────────────────────────────────────
846
  search_btn.click(
847
  fn=do_search,
848
+ inputs=[kw_input],
849
  outputs=[search_results_html, search_radio],
850
  )
 
851
  search_radio.change(
852
  fn=pick_and_analyze,
853
+ inputs=[search_radio, sentiment_selector, max_comments_slider],
854
  outputs=ALL_OUTPUTS,
855
  )
856
 
857
+
858
+ # ═══════════════════════════════════════════════════════════════════════════════
859
+ # Launch β€” css and theme go HERE in Gradio 6.x (NOT in gr.Blocks)
860
+ # ═══════════════════════════════════════════════════════════════════════════════
861
  if __name__ == "__main__":
862
  demo.launch(
863
  css=CSS,
864
+ theme=gr.themes.Base(
865
+ primary_hue=gr.themes.colors.cyan,
866
+ neutral_hue=gr.themes.colors.gray,
867
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"],
868
+ ),
869
  )