rwgertdthdht commited on
Commit
8ed644f
·
verified ·
1 Parent(s): 7c197cd

Upload Space app files

Browse files
Files changed (1) hide show
  1. app.py +766 -0
app.py ADDED
@@ -0,0 +1,766 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+
8
+ LABELS = {
9
+ 0: "Normal",
10
+ 1: "Hate/Offensive",
11
+ }
12
+
13
+ WEIGHT_FILENAMES = ("model.safetensors", "pytorch_model.bin")
14
+ TOKENIZER_FILENAMES = (
15
+ "tokenizer.json",
16
+ "tokenizer_config.json",
17
+ "vocab.txt",
18
+ "special_tokens_map.json",
19
+ )
20
+
21
+ DEFAULT_MODEL_PATH = "/Users/qqq/Downloads/fine_tuned_hatebert_model"
22
+
23
+ LIGHT_KEYNOTE_CSS = """
24
+ :root {
25
+ --paper: #f7fbff;
26
+ --paper-2: #eef5fb;
27
+ --paper-3: #e8eef5;
28
+ --ink: #15202a;
29
+ --muted: #5f6f7d;
30
+ --accent: #4f87b3;
31
+ --accent-soft: #d8e7f4;
32
+ --clear: #2d7a57;
33
+ --danger: #b65745;
34
+ --line: rgba(35, 71, 102, 0.12);
35
+ }
36
+
37
+ .gradio-container {
38
+ background:
39
+ radial-gradient(circle at 86% 14%, rgba(107, 177, 226, 0.18), transparent 18%),
40
+ linear-gradient(90deg, rgba(35, 71, 102, 0.05) 1px, transparent 1px),
41
+ linear-gradient(rgba(35, 71, 102, 0.04) 1px, transparent 1px),
42
+ linear-gradient(150deg, var(--paper) 0%, var(--paper-2) 46%, var(--paper-3) 100%) !important;
43
+ background-size: auto, 40px 40px, 40px 40px, auto !important;
44
+ color: var(--ink) !important;
45
+ font-family: "Aptos", "Segoe UI", sans-serif !important;
46
+ }
47
+
48
+ .prototype-shell {
49
+ max-width: 1320px;
50
+ margin: 0 auto;
51
+ padding: 18px 18px 28px;
52
+ }
53
+
54
+ .topbar {
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ gap: 20px;
59
+ margin-bottom: 16px;
60
+ }
61
+
62
+ .brand {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 12px;
66
+ }
67
+
68
+ .brand-mark {
69
+ width: 36px;
70
+ height: 36px;
71
+ border-radius: 12px;
72
+ background: linear-gradient(135deg, #14212b, #36556e);
73
+ color: #9fd0f7;
74
+ display: grid;
75
+ place-items: center;
76
+ font-weight: 700;
77
+ font-size: 15px;
78
+ }
79
+
80
+ .brand-copy {
81
+ min-width: 0;
82
+ }
83
+
84
+ .brand-title {
85
+ font-family: Georgia, "Times New Roman", serif;
86
+ font-size: 19px;
87
+ }
88
+
89
+ .brand-subtitle {
90
+ margin-top: 2px;
91
+ color: rgba(21, 32, 42, 0.56);
92
+ font-size: 11px;
93
+ letter-spacing: 0.14em;
94
+ text-transform: uppercase;
95
+ }
96
+
97
+ .status-badge {
98
+ padding: 10px 12px;
99
+ border: 1px solid rgba(35, 71, 102, 0.1);
100
+ background: rgba(255, 255, 255, 0.52);
101
+ color: #2e5b82;
102
+ font-size: 11px;
103
+ letter-spacing: 0.14em;
104
+ text-transform: uppercase;
105
+ }
106
+
107
+ .prototype-stage {
108
+ display: grid;
109
+ grid-template-columns: minmax(0, 1.12fr) minmax(410px, 0.88fr);
110
+ gap: 24px;
111
+ align-items: start;
112
+ }
113
+
114
+ .stage-copy {
115
+ padding: 10px 0 6px;
116
+ }
117
+
118
+ .stage-kicker {
119
+ color: var(--accent);
120
+ font-size: 12px;
121
+ font-weight: 700;
122
+ letter-spacing: 0.18em;
123
+ text-transform: uppercase;
124
+ }
125
+
126
+ .stage-title {
127
+ margin: 14px 0 0;
128
+ max-width: 9em;
129
+ font-family: Georgia, "Times New Roman", serif;
130
+ font-size: clamp(42px, 5.2vw, 68px);
131
+ line-height: 0.96;
132
+ letter-spacing: -0.05em;
133
+ }
134
+
135
+ .stage-subtitle {
136
+ max-width: 35rem;
137
+ margin-top: 14px;
138
+ color: var(--muted);
139
+ font-size: 15px;
140
+ line-height: 1.55;
141
+ }
142
+
143
+ .stage-proof {
144
+ display: grid;
145
+ grid-template-columns: repeat(3, minmax(0, 1fr));
146
+ gap: 12px;
147
+ margin-top: 18px;
148
+ max-width: 700px;
149
+ }
150
+
151
+ .stage-proof-item {
152
+ border-top: 1px solid var(--line);
153
+ padding-top: 12px;
154
+ }
155
+
156
+ .stage-proof-label {
157
+ color: var(--muted);
158
+ font-size: 10px;
159
+ letter-spacing: 0.14em;
160
+ text-transform: uppercase;
161
+ }
162
+
163
+ .stage-proof-value {
164
+ margin-top: 6px;
165
+ font-family: Georgia, "Times New Roman", serif;
166
+ font-size: 22px;
167
+ }
168
+
169
+ .stage-chips {
170
+ margin-top: 14px;
171
+ display: flex;
172
+ gap: 8px;
173
+ flex-wrap: wrap;
174
+ }
175
+
176
+ .stage-chip {
177
+ padding: 8px 10px;
178
+ border-radius: 999px;
179
+ border: 1px solid rgba(37, 85, 123, 0.1);
180
+ background: rgba(37, 85, 123, 0.06);
181
+ color: #25557b;
182
+ font-size: 10px;
183
+ letter-spacing: 0.12em;
184
+ text-transform: uppercase;
185
+ }
186
+
187
+ .queue {
188
+ margin-top: 16px;
189
+ display: grid;
190
+ gap: 12px;
191
+ max-width: 640px;
192
+ }
193
+
194
+ .queue-card {
195
+ display: grid;
196
+ grid-template-columns: 68px 1fr auto;
197
+ gap: 14px;
198
+ align-items: center;
199
+ padding: 14px;
200
+ border: 1px solid rgba(35, 71, 102, 0.08);
201
+ background: rgba(255, 255, 255, 0.62);
202
+ box-shadow: 0 16px 34px rgba(114, 145, 175, 0.08);
203
+ }
204
+
205
+ .queue-score {
206
+ font-family: Georgia, "Times New Roman", serif;
207
+ font-size: 24px;
208
+ color: #25557b;
209
+ }
210
+
211
+ .queue-title {
212
+ font-size: 12px;
213
+ letter-spacing: 0.14em;
214
+ text-transform: uppercase;
215
+ color: rgba(21, 32, 42, 0.52);
216
+ }
217
+
218
+ .queue-text {
219
+ margin-top: 4px;
220
+ color: rgba(21, 32, 42, 0.78);
221
+ line-height: 1.42;
222
+ }
223
+
224
+ .queue-pill {
225
+ padding: 8px 10px;
226
+ border-radius: 999px;
227
+ border: 1px solid rgba(37, 85, 123, 0.1);
228
+ background: rgba(37, 85, 123, 0.06);
229
+ color: #25557b;
230
+ font-size: 10px;
231
+ letter-spacing: 0.12em;
232
+ text-transform: uppercase;
233
+ }
234
+
235
+ .stage-note {
236
+ margin-top: 14px;
237
+ padding: 12px 14px;
238
+ border-left: 3px solid #5a8db5;
239
+ background: rgba(255, 255, 255, 0.48);
240
+ color: rgba(21, 32, 42, 0.76);
241
+ line-height: 1.5;
242
+ }
243
+
244
+ .prototype-panel {
245
+ background: rgba(255, 255, 255, 0.58);
246
+ border: 1px solid rgba(35, 71, 102, 0.1);
247
+ box-shadow: 0 22px 50px rgba(96, 132, 166, 0.12);
248
+ backdrop-filter: blur(16px);
249
+ padding: 24px;
250
+ }
251
+
252
+ .panel-head {
253
+ display: flex;
254
+ justify-content: space-between;
255
+ align-items: flex-start;
256
+ gap: 14px;
257
+ }
258
+
259
+ .panel-kicker {
260
+ color: rgba(21, 32, 42, 0.55);
261
+ font-size: 11px;
262
+ letter-spacing: 0.16em;
263
+ text-transform: uppercase;
264
+ }
265
+
266
+ .panel-title {
267
+ margin-top: 8px;
268
+ font-family: Georgia, "Times New Roman", serif;
269
+ font-size: 34px;
270
+ line-height: 0.98;
271
+ }
272
+
273
+ .panel-badge {
274
+ padding: 8px 10px;
275
+ border: 1px solid rgba(79, 135, 179, 0.16);
276
+ background: rgba(79, 135, 179, 0.08);
277
+ color: #4076a1;
278
+ font-size: 10px;
279
+ letter-spacing: 0.14em;
280
+ text-transform: uppercase;
281
+ }
282
+
283
+ .prototype-panel textarea,
284
+ .prototype-panel input,
285
+ .prototype-panel .gradio-textbox textarea {
286
+ background: rgba(243, 249, 255, 0.82) !important;
287
+ border: 1px solid rgba(35, 71, 102, 0.1) !important;
288
+ color: rgba(21, 32, 42, 0.78) !important;
289
+ }
290
+
291
+ .prototype-panel label,
292
+ .prototype-panel .gradio-form label {
293
+ color: rgba(21, 32, 42, 0.55) !important;
294
+ font-size: 11px !important;
295
+ letter-spacing: 0.14em !important;
296
+ text-transform: uppercase !important;
297
+ }
298
+
299
+ .prototype-panel button.primary,
300
+ .prototype-panel button.lg.primary {
301
+ background: linear-gradient(135deg, #1f4e72, #5a8db5) !important;
302
+ border: 0 !important;
303
+ color: white !important;
304
+ }
305
+
306
+ .prototype-panel button.secondary {
307
+ border: 1px solid rgba(35, 71, 102, 0.1) !important;
308
+ background: rgba(255, 255, 255, 0.54) !important;
309
+ color: #234966 !important;
310
+ }
311
+
312
+ .prototype-result {
313
+ margin-top: 22px;
314
+ padding-top: 22px;
315
+ border-top: 1px solid rgba(35, 71, 102, 0.1);
316
+ display: grid;
317
+ grid-template-columns: 1fr 168px;
318
+ gap: 16px;
319
+ align-items: center;
320
+ }
321
+
322
+ .result-copy {
323
+ min-width: 0;
324
+ }
325
+
326
+ .result-kicker {
327
+ color: rgba(21, 32, 42, 0.52);
328
+ font-size: 10px;
329
+ letter-spacing: 0.16em;
330
+ text-transform: uppercase;
331
+ }
332
+
333
+ .result-label {
334
+ margin-top: 8px;
335
+ font-family: Georgia, "Times New Roman", serif;
336
+ font-size: 48px;
337
+ line-height: 0.95;
338
+ }
339
+
340
+ .result-confidence {
341
+ margin-top: 10px;
342
+ max-width: 17rem;
343
+ color: rgba(21, 32, 42, 0.66);
344
+ font-size: 15px;
345
+ line-height: 1.55;
346
+ }
347
+
348
+ .result-ring {
349
+ width: 160px;
350
+ height: 160px;
351
+ border-radius: 50%;
352
+ display: grid;
353
+ place-items: center;
354
+ justify-self: end;
355
+ background: conic-gradient(from -90deg, #5c93c0 0 0%, rgba(35, 71, 102, 0.08) 0% 100%);
356
+ }
357
+
358
+ .result-ring-inner {
359
+ width: 114px;
360
+ height: 114px;
361
+ border-radius: 50%;
362
+ background: rgba(246, 250, 255, 0.96);
363
+ display: grid;
364
+ place-items: center;
365
+ text-align: center;
366
+ }
367
+
368
+ .result-ring-value {
369
+ font-family: Georgia, "Times New Roman", serif;
370
+ font-size: 30px;
371
+ }
372
+
373
+ .result-ring-label {
374
+ margin-top: 4px;
375
+ color: rgba(21, 32, 42, 0.54);
376
+ font-size: 10px;
377
+ letter-spacing: 0.14em;
378
+ text-transform: uppercase;
379
+ }
380
+
381
+ .normal-result {
382
+ --result-accent: var(--clear);
383
+ }
384
+
385
+ .harmful-result {
386
+ --result-accent: var(--danger);
387
+ }
388
+
389
+ .error-result {
390
+ --result-accent: var(--danger);
391
+ }
392
+
393
+ .neutral-result {
394
+ --result-accent: var(--accent);
395
+ }
396
+
397
+ @media (max-width: 1100px) {
398
+ .topbar {
399
+ align-items: flex-start;
400
+ flex-direction: column;
401
+ }
402
+
403
+ .prototype-stage {
404
+ grid-template-columns: 1fr;
405
+ }
406
+
407
+ .queue {
408
+ max-width: 100%;
409
+ }
410
+ }
411
+
412
+ @media (max-width: 680px) {
413
+ .prototype-result {
414
+ grid-template-columns: 1fr;
415
+ }
416
+
417
+ .result-ring {
418
+ justify-self: start;
419
+ }
420
+ }
421
+ """
422
+
423
+ Classifier = Callable[[str], tuple[str, float]]
424
+
425
+
426
+ def detect_missing_model_artifacts(model_dir: Path | str) -> list[str]:
427
+ path = Path(model_dir)
428
+ missing: list[str] = []
429
+
430
+ if not (path / "config.json").exists():
431
+ missing.append("config.json")
432
+
433
+ if not any((path / name).exists() for name in WEIGHT_FILENAMES):
434
+ missing.append("model weights (model.safetensors or pytorch_model.bin)")
435
+
436
+ if not any((path / name).exists() for name in TOKENIZER_FILENAMES):
437
+ missing.append("tokenizer assets")
438
+
439
+ return missing
440
+
441
+
442
+ def build_colab_mount_hint() -> str:
443
+ return (
444
+ "Mount Google Drive first with: "
445
+ "from google.colab import drive; drive.mount('/content/drive')"
446
+ )
447
+
448
+
449
+ def build_model_path_hint(model_path: Path | str) -> str:
450
+ path = str(model_path)
451
+ if path.startswith("/content/drive/"):
452
+ return build_colab_mount_hint()
453
+ return (
454
+ "Check that the local model path is correct or set "
455
+ "HATEBERT_MODEL_PATH to your model directory."
456
+ )
457
+
458
+
459
+ def get_bundled_model_path(base_dir: Path | str | None = None) -> Path:
460
+ root = Path(base_dir) if base_dir is not None else Path(__file__).resolve().parent
461
+ return root / "fine_tuned_hatebert_model"
462
+
463
+
464
+ def resolve_model_path(base_dir: Path | str | None = None) -> str:
465
+ env_path = os.getenv("HATEBERT_MODEL_PATH")
466
+ if env_path:
467
+ return env_path
468
+
469
+ bundled = get_bundled_model_path(base_dir)
470
+ if bundled.exists():
471
+ return str(bundled)
472
+
473
+ return DEFAULT_MODEL_PATH
474
+
475
+
476
+ def get_transformers_load_kwargs() -> dict[str, bool]:
477
+ return {"local_files_only": True}
478
+
479
+
480
+ def build_launch_kwargs() -> dict[str, object]:
481
+ env_port = os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT")
482
+ server_port = int(env_port) if env_port else None
483
+ running_on_space = bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST"))
484
+ return {
485
+ "share": not running_on_space,
486
+ "debug": not running_on_space,
487
+ "server_name": "0.0.0.0",
488
+ "server_port": server_port,
489
+ "show_error": True,
490
+ }
491
+
492
+
493
+ def validate_model_path(model_path: Path | str) -> tuple[bool, str]:
494
+ path = Path(model_path)
495
+
496
+ if not path.exists():
497
+ return False, f"Model path does not exist: {path}. {build_model_path_hint(path)}"
498
+
499
+ if not path.is_dir():
500
+ return False, f"Model path is not a directory: {path}"
501
+
502
+ missing = detect_missing_model_artifacts(path)
503
+ if missing:
504
+ joined = ", ".join(missing)
505
+ return False, f"Missing required files in model directory: {joined}"
506
+
507
+ return True, f"Model directory ready: {path}"
508
+
509
+
510
+ def render_empty_state() -> str:
511
+ return """
512
+ <div class="prototype-result neutral-result">
513
+ <div class="result-copy">
514
+ <div class="result-kicker">Prediction</div>
515
+ <div class="result-label">Awaiting input</div>
516
+ <div class="result-confidence">Enter a short online comment to run inference.</div>
517
+ </div>
518
+ <div class="result-ring" style="background: conic-gradient(from -90deg, var(--accent) 0 0%, rgba(35, 71, 102, 0.08) 0% 100%);">
519
+ <div class="result-ring-inner">
520
+ <div>
521
+ <div class="result-ring-value">0%</div>
522
+ <div class="result-ring-label">Confidence</div>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ """
528
+
529
+
530
+ def render_error_state(message: str) -> str:
531
+ return f"""
532
+ <div class="prototype-result error-result">
533
+ <div class="result-copy">
534
+ <div class="result-kicker">Prediction unavailable</div>
535
+ <div class="result-label">Startup issue</div>
536
+ <div class="result-confidence">{message}</div>
537
+ </div>
538
+ <div class="result-ring" style="background: conic-gradient(from -90deg, var(--danger) 0 0%, rgba(35, 71, 102, 0.08) 0% 100%);">
539
+ <div class="result-ring-inner">
540
+ <div>
541
+ <div class="result-ring-value">0%</div>
542
+ <div class="result-ring-label">Confidence</div>
543
+ </div>
544
+ </div>
545
+ </div>
546
+ </div>
547
+ """
548
+
549
+
550
+ def render_result_state(label: str, confidence: float) -> str:
551
+ state_class = "normal-result" if label == "Normal" else "harmful-result"
552
+ accent = "var(--clear)" if label == "Normal" else "var(--danger)"
553
+ confidence_pct = confidence * 100
554
+ return f"""
555
+ <div class="prototype-result {state_class}">
556
+ <div class="result-copy">
557
+ <div class="result-kicker">Prediction</div>
558
+ <div class="result-label">{label}</div>
559
+ <div class="result-confidence">Model confidence: {confidence:.2%}</div>
560
+ </div>
561
+ <div class="result-ring" style="background: conic-gradient(from -90deg, {accent} 0 {confidence_pct:.2f}%, rgba(35, 71, 102, 0.08) {confidence_pct:.2f}% 100%);">
562
+ <div class="result-ring-inner">
563
+ <div>
564
+ <div class="result-ring-value">{confidence:.2%}</div>
565
+ <div class="result-ring-label">Confidence</div>
566
+ </div>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ """
571
+
572
+
573
+ def predict_text(text: str | None, classify: Classifier) -> str:
574
+ cleaned = (text or "").strip()
575
+ if not cleaned:
576
+ return render_empty_state()
577
+
578
+ try:
579
+ label, confidence = classify(cleaned)
580
+ except Exception as exc:
581
+ return render_error_state(f"Inference unavailable: {exc}")
582
+
583
+ return render_result_state(label, confidence)
584
+
585
+
586
+ def build_hero_markup() -> str:
587
+ return """
588
+ <div class="topbar">
589
+ <div class="brand">
590
+ <div class="brand-mark">H</div>
591
+ <div class="brand-copy">
592
+ <div class="brand-title">HateBERT Moderation Prototype</div>
593
+ <div class="brand-subtitle">Platform Safety / Live Inference System</div>
594
+ </div>
595
+ </div>
596
+ <div class="status-badge">Binary classifier / Real-time review</div>
597
+ </div>
598
+
599
+ <section class="prototype-stage">
600
+ <div class="stage-copy">
601
+ <div class="stage-kicker">Content safety intelligence</div>
602
+ <h1 class="stage-title">Moderation infrastructure for live social risk screening.</h1>
603
+ <p class="stage-subtitle">
604
+ A polished live review surface for harmful content detection. This interface frames
605
+ the model as a platform moderation tool rather than a plain classroom demo.
606
+ </p>
607
+ <div class="stage-proof">
608
+ <div class="stage-proof-item">
609
+ <div class="stage-proof-label">System</div>
610
+ <div class="stage-proof-value">Moderation</div>
611
+ </div>
612
+ <div class="stage-proof-item">
613
+ <div class="stage-proof-label">Model</div>
614
+ <div class="stage-proof-value">HateBERT</div>
615
+ </div>
616
+ <div class="stage-proof-item">
617
+ <div class="stage-proof-label">Mode</div>
618
+ <div class="stage-proof-value">Live Review</div>
619
+ </div>
620
+ </div>
621
+
622
+ <div class="stage-chips">
623
+ <div class="stage-chip">Binary classifier</div>
624
+ <div class="stage-chip">Confidence output</div>
625
+ <div class="stage-chip">2 classes</div>
626
+ </div>
627
+
628
+ <div class="queue">
629
+ <div class="queue-card">
630
+ <div class="queue-score">82%</div>
631
+ <div>
632
+ <div class="queue-title">Flagged Comment</div>
633
+ <div class="queue-text">User-submitted social content is routed into a lightweight review layer with confidence evidence.</div>
634
+ </div>
635
+ <div class="queue-pill">Queued</div>
636
+ </div>
637
+
638
+ <div class="queue-card">
639
+ <div class="queue-score">2</div>
640
+ <div>
641
+ <div class="queue-title">Output Classes</div>
642
+ <div class="queue-text">The interface returns a binary moderation decision designed for clear academic demonstration and platform governance storytelling.</div>
643
+ </div>
644
+ <div class="queue-pill">Normal / Harmful</div>
645
+ </div>
646
+ </div>
647
+
648
+ <div class="stage-note">
649
+ Designed to look and behave like a real platform safety product while staying lightweight enough for sharing and live presentation.
650
+ </div>
651
+ </div>
652
+ </section>
653
+ """
654
+
655
+
656
+ def load_runtime(model_path: str) -> Classifier:
657
+ ok, message = validate_model_path(model_path)
658
+ if not ok:
659
+ raise RuntimeError(message)
660
+
661
+ import torch
662
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
663
+
664
+ load_kwargs = get_transformers_load_kwargs()
665
+ tokenizer = AutoTokenizer.from_pretrained(model_path, **load_kwargs)
666
+ model = AutoModelForSequenceClassification.from_pretrained(model_path, **load_kwargs)
667
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
668
+ model.to(device)
669
+ model.eval()
670
+
671
+ def classify(cleaned_text: str) -> tuple[str, float]:
672
+ inputs = tokenizer(
673
+ cleaned_text,
674
+ return_tensors="pt",
675
+ truncation=True,
676
+ padding=True,
677
+ max_length=128,
678
+ )
679
+ inputs = {key: value.to(device) for key, value in inputs.items()}
680
+
681
+ with torch.no_grad():
682
+ logits = model(**inputs).logits
683
+ probabilities = torch.softmax(logits, dim=-1)[0]
684
+ predicted_id = int(torch.argmax(probabilities).item())
685
+ confidence = float(probabilities[predicted_id].item())
686
+
687
+ label = LABELS.get(predicted_id, f"LABEL_{predicted_id}")
688
+ return label, confidence
689
+
690
+ return classify
691
+
692
+
693
+ def build_demo(classify: Classifier):
694
+ import gradio as gr
695
+
696
+ with gr.Blocks(title="Moderation Demo", css=LIGHT_KEYNOTE_CSS) as demo:
697
+ with gr.Column(elem_classes=["prototype-shell"]):
698
+ gr.HTML(build_hero_markup())
699
+
700
+ with gr.Column(elem_classes=["prototype-panel"]):
701
+ gr.HTML(
702
+ """
703
+ <div class="panel-head">
704
+ <div>
705
+ <div class="panel-kicker">Inference Workspace</div>
706
+ <div class="panel-title">Live Comment Review</div>
707
+ </div>
708
+ <div class="panel-badge">Fine-tuned model</div>
709
+ </div>
710
+ """
711
+ )
712
+
713
+ text_input = gr.Textbox(
714
+ label="Comment Input",
715
+ lines=7,
716
+ placeholder="Enter an online comment for classification...",
717
+ )
718
+ with gr.Row():
719
+ analyze_button = gr.Button("Run Moderation", variant="primary")
720
+ clear_button = gr.Button("Clear", variant="secondary")
721
+ output = gr.HTML(value=render_empty_state())
722
+
723
+ gr.Examples(
724
+ examples=[
725
+ ["I really enjoy spending time with my friends at the park."],
726
+ ["You are absolutely useless and I hate everything about you."],
727
+ ["This is a neutral statement about the weather."],
728
+ ],
729
+ inputs=text_input,
730
+ label="Live Review Examples",
731
+ )
732
+
733
+ analyze_button.click(
734
+ fn=lambda text: predict_text(text, classify),
735
+ inputs=text_input,
736
+ outputs=output,
737
+ )
738
+ clear_button.click(
739
+ fn=lambda: ("", render_empty_state()),
740
+ outputs=[text_input, output],
741
+ )
742
+ text_input.submit(
743
+ fn=lambda text: predict_text(text, classify),
744
+ inputs=text_input,
745
+ outputs=output,
746
+ )
747
+
748
+ return demo
749
+
750
+
751
+ def main() -> None:
752
+ """
753
+ Local quick start:
754
+ 1. Put the fine-tuned model directory on this machine.
755
+ 2. Set HATEBERT_MODEL_PATH if your model folder is not the default path.
756
+ 3. Run this script to launch the Gradio demo.
757
+ 4. Share the temporary public link with your teammates.
758
+ """
759
+
760
+ classify = load_runtime(resolve_model_path())
761
+ demo = build_demo(classify)
762
+ demo.launch(**build_launch_kwargs())
763
+
764
+
765
+ if __name__ == "__main__":
766
+ main()