JonnyBP commited on
Commit
b53eb67
·
1 Parent(s): fcb21ef

feat: add streamlit base. #12

Browse files
.gitignore CHANGED
@@ -54,9 +54,6 @@ mlruns/
54
  mlartifacts/
55
 
56
  #jony
57
- src/
58
- src/app/app.py
59
-
60
  #modelos no subidos
61
  models/roberta_hate_results/
62
  models/distilbert_results/
 
54
  mlartifacts/
55
 
56
  #jony
 
 
 
57
  #modelos no subidos
58
  models/roberta_hate_results/
59
  models/distilbert_results/
notebooks/08_transformers_clean_v2.ipynb CHANGED
The diff for this file is too large to render. See raw diff
 
src/app/app.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/app/streamlit_app.py
3
+
4
+ App SignalMod — detección de hate speech estilo YouTube.
5
+ Ejecutar: streamlit run src/app/streamlit_app.py
6
+ """
7
+
8
+ import html
9
+ import sys
10
+ import random
11
+ import datetime
12
+ from pathlib import Path
13
+
14
+ import streamlit as st
15
+ import pandas as pd
16
+
17
+ from transformers.utils import logging
18
+ logging.set_verbosity_error()
19
+
20
+ # ── Paths ─────────────────────────────────────────────────────────────────────
21
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
22
+ sys.path.insert(0, str(PROJECT_ROOT))
23
+
24
+ try:
25
+ from src.service.model_service import ModelService, AVAILABLE_MODELS
26
+ except ImportError:
27
+ sys.path.insert(0, str(Path(__file__).parent.parent))
28
+ from service.model_service import ModelService, AVAILABLE_MODELS
29
+
30
+ # ── Config ────────────────────────────────────────────────────────────────────
31
+ st.set_page_config(
32
+ page_title="SignalMod",
33
+ page_icon="🎬",
34
+ layout="wide",
35
+ initial_sidebar_state="expanded",
36
+ )
37
+
38
+ # ── CSS ───────────────────────────────────────────────────────────────────────
39
+ # Nota: NO ocultamos el header completo para preservar el botón de toggle del sidebar.
40
+ # Solo ocultamos el menú hamburguesa y el footer de Streamlit.
41
+ st.markdown("""
42
+ <style>
43
+ @import url('https://fonts.googleapis.com/css2?family=YouTube+Sans:wght@400;600;700&display=swap');
44
+
45
+ /* ── Ocultar solo elementos de branding, NO el header completo ── */
46
+ #MainMenu { visibility: hidden; }
47
+ footer { visibility: hidden; }
48
+
49
+ /* ── Fondo de la app: blanco limpio ── */
50
+ .stApp { background: #ffffff; }
51
+
52
+ /* ── Sidebar oscuro (como YouTube) ── */
53
+ section[data-testid="stSidebar"] {
54
+ background-color: #0f0f0f !important;
55
+ }
56
+ section[data-testid="stSidebar"] > div {
57
+ background-color: #0f0f0f !important;
58
+ }
59
+ /* Texto del sidebar en blanco */
60
+ section[data-testid="stSidebar"] p,
61
+ section[data-testid="stSidebar"] span,
62
+ section[data-testid="stSidebar"] label,
63
+ section[data-testid="stSidebar"] div {
64
+ color: #ffffff !important;
65
+ }
66
+ /* Botones del sidebar */
67
+ section[data-testid="stSidebar"] .stButton button {
68
+ background: transparent !important;
69
+ color: #e0e0e0 !important;
70
+ border: none !important;
71
+ text-align: left !important;
72
+ justify-content: flex-start !important;
73
+ border-radius: 10px !important;
74
+ padding: 0.5rem 0.75rem !important;
75
+ font-size: 0.9rem !important;
76
+ font-weight: 400 !important;
77
+ width: 100% !important;
78
+ }
79
+ section[data-testid="stSidebar"] .stButton button:hover {
80
+ background: rgba(255,255,255,0.1) !important;
81
+ color: #ffffff !important;
82
+ }
83
+ /* Botón activo en el sidebar */
84
+ section[data-testid="stSidebar"] .stButton button[data-active="true"] {
85
+ background: rgba(255,255,255,0.15) !important;
86
+ color: #ffffff !important;
87
+ font-weight: 600 !important;
88
+ }
89
+ /* Divider del sidebar */
90
+ section[data-testid="stSidebar"] hr {
91
+ border-color: rgba(255,255,255,0.15) !important;
92
+ }
93
+ /* Badge de modelo activo en sidebar */
94
+ .sidebar-model-info {
95
+ background: rgba(255,255,255,0.08);
96
+ border-radius: 8px;
97
+ padding: 8px 12px;
98
+ margin: 8px 0;
99
+ font-size: 0.75rem;
100
+ color: #aaaaaa;
101
+ }
102
+ .sidebar-model-info strong { color: #ffffff; }
103
+
104
+ /* ── Área principal: fondo blanco, texto oscuro ── */
105
+ .main-area { background: #ffffff; }
106
+
107
+ /* ── Video thumbnail ── */
108
+ .video-thumb {
109
+ background: linear-gradient(135deg, #0d0d1a 0%, #1a0a2e 50%, #0d1a1a 100%);
110
+ border-radius: 12px;
111
+ height: 340px;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+ .play-btn {
117
+ width: 72px; height: 72px;
118
+ background: rgba(255,255,255,0.9);
119
+ border-radius: 50%;
120
+ display: flex; align-items: center; justify-content: center;
121
+ font-size: 2rem; cursor: pointer;
122
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
123
+ }
124
+
125
+ /* ── Títulos de video ── */
126
+ .video-title {
127
+ font-size: 1.15rem; font-weight: 700;
128
+ color: #0f0f0f; margin: 0.75rem 0 0.3rem;
129
+ line-height: 1.4;
130
+ }
131
+ .video-meta { font-size: 0.82rem; color: #606060; }
132
+ .channel-name { font-weight: 600; font-size: 0.9rem; color: #0f0f0f; }
133
+
134
+ /* ── Badges ── */
135
+ .badge {
136
+ display: inline-block;
137
+ padding: 2px 9px; border-radius: 12px;
138
+ font-size: 0.72rem; font-weight: 700;
139
+ margin-left: 6px; vertical-align: middle;
140
+ }
141
+ .badge-toxic { background: #cc0000; color: #ffffff; }
142
+ .badge-safe { background: #00c853; color: #ffffff; }
143
+
144
+ /* ── Comentarios ── */
145
+ .comment-wrap {
146
+ display: flex; gap: 12px;
147
+ padding: 12px 0; border-bottom: 1px solid #f0f0f0;
148
+ }
149
+ .c-avatar {
150
+ width: 36px; height: 36px; min-width: 36px;
151
+ border-radius: 50%; background: #cc0000;
152
+ display: flex; align-items: center; justify-content: center;
153
+ color: #ffffff; font-weight: 700; font-size: 0.85rem;
154
+ flex-shrink: 0;
155
+ }
156
+ .c-avatar.safe { background: #606060; }
157
+ .c-body { flex: 1; min-width: 0; }
158
+ .c-header { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
159
+ .c-user { font-size: 0.84rem; font-weight: 600; color: #0f0f0f; }
160
+ .c-time { font-size: 0.75rem; color: #909090; margin-left: 4px; }
161
+ .c-text { font-size: 0.88rem; color: #2d2d2d; margin-top: 4px; line-height: 1.55; }
162
+ .c-text.toxic {
163
+ background: #fff5f5;
164
+ border-left: 3px solid #cc0000;
165
+ padding: 6px 10px; border-radius: 0 6px 6px 0;
166
+ margin-top: 6px;
167
+ }
168
+ .c-flagged { font-size: 0.77rem; color: #cc0000; font-weight: 500; margin-top: 4px; }
169
+
170
+ /* ── Toxicity bar inline ── */
171
+ .tox-row {
172
+ display: flex; align-items: center; gap: 8px;
173
+ font-size: 0.8rem; color: #606060; margin-top: 6px; flex-wrap: wrap;
174
+ }
175
+ .tox-bar-bg {
176
+ flex: 1; max-width: 120px;
177
+ background: #e5e5e5; border-radius: 4px; height: 6px;
178
+ }
179
+ .tox-bar-fill { height: 6px; border-radius: 4px; }
180
+
181
+ /* ── Sugeridos ── */
182
+ .sug-card {
183
+ display: flex; gap: 8px; margin-bottom: 10px;
184
+ cursor: pointer;
185
+ }
186
+ .sug-thumb {
187
+ width: 120px; min-width: 120px; height: 68px;
188
+ background: #1a1a2e; border-radius: 6px;
189
+ display: flex; align-items: center; justify-content: center;
190
+ font-size: 1.4rem; flex-shrink: 0;
191
+ }
192
+ .sug-title { font-size: 0.82rem; font-weight: 600; color: #0f0f0f; line-height: 1.3; }
193
+ .sug-ch { font-size: 0.75rem; color: #606060; margin-top: 2px; }
194
+ .sug-meta { font-size: 0.72rem; color: #909090; }
195
+
196
+ /* ── Section header ── */
197
+ .sec-title {
198
+ font-size: 1rem; font-weight: 700; color: #0f0f0f;
199
+ margin: 1.25rem 0 0.75rem; padding-bottom: 0.5rem;
200
+ border-bottom: 1px solid #e5e5e5;
201
+ }
202
+
203
+ /* ── Modal body fixes ── */
204
+ [data-testid="stDialog"] { background: #ffffff; }
205
+
206
+ /* ── Hub cards ── */
207
+ .hub-card {
208
+ background: #ffffff; border: 1px solid #e5e5e5;
209
+ border-radius: 12px; padding: 1rem;
210
+ }
211
+ .hub-kpi-label { font-size: 0.72rem; color: #606060; text-transform: uppercase;
212
+ letter-spacing: 0.5px; margin-bottom: 4px; }
213
+ .hub-kpi-val { font-size: 1.8rem; font-weight: 700; color: #0f0f0f; }
214
+
215
+ /* ── Model cards (settings) ── */
216
+ .model-card {
217
+ background: #ffffff; border: 1.5px solid #e5e5e5;
218
+ border-radius: 10px; padding: 14px 16px; margin-bottom: 8px;
219
+ }
220
+ .model-card.active {
221
+ border-color: #cc0000; background: #fff5f5;
222
+ }
223
+ .model-card-name { font-size: 0.95rem; font-weight: 600; color: #0f0f0f; }
224
+ .model-card-desc { font-size: 0.8rem; color: #606060; margin-top: 3px; }
225
+ .model-pill {
226
+ display: inline-block; background: #f0f0f0; color: #333;
227
+ border-radius: 6px; padding: 2px 8px; font-size: 0.73rem; margin-right: 4px;
228
+ }
229
+ </style>
230
+ """, unsafe_allow_html=True)
231
+
232
+
233
+ # ── Session state init ────────────────────────────────────────────────────────
234
+ def _init_state():
235
+ defaults = {
236
+ "page" : "Home",
237
+ "selected_model": list(AVAILABLE_MODELS.keys())[0],
238
+ "threshold" : 0.5,
239
+ "pending_modal" : None, # dict con el comentario pendiente de decisión
240
+ "comments": [
241
+ {"user": "user_prime", "initial": "U",
242
+ "text": "Excelente video, muy informativo!", "time": "1 h",
243
+ "is_toxic": False, "probability": 0.04, "labels": []},
244
+ {"user": "troll_master", "initial": "T",
245
+ "text": "Esto es una basura completa", "time": "30 min",
246
+ "is_toxic": True, "probability": 0.91, "labels": ["Insulto","Agresividad"]},
247
+ {"user": "curious_viewer", "initial": "C",
248
+ "text": "¿Alguien puede explicar esto mejor?", "time": "15 min",
249
+ "is_toxic": False, "probability": 0.07, "labels": []},
250
+ ],
251
+ "hub_history": [
252
+ {"Usuario": "@user_992", "Comentario": '"No puedo creer que seas tan..."', "Score": 0.94, "Acción": "🚫 Bloqueado"},
253
+ {"Usuario": "@alpha_mod", "Comentario": '"Spam repetitivo de enlaces."', "Score": 0.82, "Acción": "🚩 Revisión"},
254
+ {"Usuario": "@anon_404", "Comentario": '"Discurso de odio en contexto."', "Score": 0.98, "Acción": "📋 Archivado"},
255
+ {"Usuario": "@user_123", "Comentario": '"¡Gran contenido, sigan!"', "Score": 0.03, "Acción": "✅ Aprobado"},
256
+ {"Usuario": "@viewer_x", "Comentario": '"Esta gente debería desaparecer."',"Score": 0.97, "Acción": "🚫 Bloqueado"},
257
+ ],
258
+ }
259
+ for k, v in defaults.items():
260
+ if k not in st.session_state:
261
+ st.session_state[k] = v
262
+
263
+ _init_state()
264
+
265
+
266
+ # ── Model cache ───────────────────────────────────────────────────────────────
267
+ @st.cache_resource(show_spinner="Cargando modelo...")
268
+ def get_service(model_name: str) -> ModelService:
269
+ return ModelService(model_name, PROJECT_ROOT)
270
+
271
+
272
+ # ══════════════════════════════════════════════════════════════════════════════
273
+ # SIDEBAR
274
+ # ══════════════════════════════════════════════════════════════════════════════
275
+ def render_sidebar():
276
+ with st.sidebar:
277
+ # Logo
278
+ st.markdown(
279
+ "<div style='padding:0.5rem 0 0.25rem; font-size:1.3rem; font-weight:700;'>"
280
+ "🎬 <span style='color:#cc0000'>Signal</span>Mod</div>"
281
+ "<div style='font-size:0.65rem; color:#aaa; margin-bottom:1.2rem;'>"
282
+ "Signal within the Noise</div>",
283
+ unsafe_allow_html=True,
284
+ )
285
+
286
+ nav = {"Home": "🏠", "Moderator Hub": "📊", "Settings": "⚙️"}
287
+ for page, icon in nav.items():
288
+ label = f"{icon} {page}"
289
+ clicked = st.button(label, key=f"nav_{page}", use_container_width=True)
290
+ if clicked:
291
+ st.session_state.page = page
292
+ st.rerun()
293
+
294
+ st.divider()
295
+
296
+ # Info modelo activo
297
+ model_short = st.session_state.selected_model.split("(")[0].strip()
298
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"])
299
+ total_c = len(st.session_state.comments)
300
+
301
+ st.markdown(
302
+ f"<div class='sidebar-model-info'>"
303
+ f"Modelo activo<br><strong>{html.escape(model_short)}</strong>"
304
+ f"<br><br>Comentarios: <strong>{total_c}</strong>"
305
+ f" · Tóxicos: <strong style='color:#cc0000'>{tox_cnt}</strong>"
306
+ f"</div>",
307
+ unsafe_allow_html=True,
308
+ )
309
+
310
+
311
+ # ══════════════════════════════════════════════════════════════════════════════
312
+ # MODAL — toxicidad detectada
313
+ # ══════════════════════════════════════════════════════════════════════════════
314
+ @st.dialog("⚠️ Aviso de Toxicidad Detectada")
315
+ def show_toxicity_modal():
316
+ """
317
+ @st.dialog crea una ventana modal nativa de Streamlit (1.32+).
318
+ Cuando se llama a la función decorada, Streamlit renderiza el contenido
319
+ dentro de un overlay modal y pausa la ejecución normal del script.
320
+ """
321
+ data = st.session_state.pending_modal
322
+ if not data:
323
+ st.rerun()
324
+ return
325
+
326
+ text = data["text"]
327
+ prob = data["probability"]
328
+ lbls = data["labels"]
329
+ pct = int(prob * 100)
330
+ color = "#cc0000" if pct >= 70 else "#ff6d00" if pct >= 40 else "#f5a623"
331
+
332
+ st.markdown(
333
+ "<div style='text-align:center; font-size:3rem; color:#cc0000'>⚠️</div>",
334
+ unsafe_allow_html=True,
335
+ )
336
+ st.markdown(
337
+ f"<div style='background:#f8f8f8; border-radius:8px; padding:12px 16px;"
338
+ f"font-style:italic; color:#333; text-align:center; margin:8px 0;'>"
339
+ f"&quot;{html.escape(text[:140])}{'...' if len(text)>140 else ''}&quot;</div>",
340
+ unsafe_allow_html=True,
341
+ )
342
+
343
+ # Barra de toxicidad
344
+ st.markdown(
345
+ f"<div style='display:flex; justify-content:space-between; "
346
+ f"font-size:0.82rem; color:#606060; margin-top:12px;'>"
347
+ f"<span>ÍNDICE DE TOXICIDAD</span>"
348
+ f"<span style='color:{color}; font-weight:700'>{pct}%</span></div>"
349
+ f"<div style='background:#e5e5e5; border-radius:4px; height:8px; margin-top:4px;'>"
350
+ f"<div style='width:{pct}%; background:{color}; height:8px; border-radius:4px;'></div>"
351
+ f"</div>",
352
+ unsafe_allow_html=True,
353
+ )
354
+
355
+ # Etiquetas
356
+ if lbls:
357
+ tags = " ".join(
358
+ f"<span style='background:#ffe5e5; color:#cc0000; border-radius:14px;"
359
+ f"padding:3px 10px; font-size:0.76rem; font-weight:600; margin:3px;'>"
360
+ f"🚩 {html.escape(l)}</span>"
361
+ for l in lbls
362
+ )
363
+ st.markdown(f"<div style='margin-top:10px'>{tags}</div>", unsafe_allow_html=True)
364
+
365
+ st.markdown("<br>", unsafe_allow_html=True)
366
+
367
+ col1, col2 = st.columns(2)
368
+ with col1:
369
+ if st.button("✏️ Editar comentario", use_container_width=True, type="primary"):
370
+ st.session_state.pending_modal = None
371
+ st.rerun()
372
+ with col2:
373
+ if st.button("Publicar de todas maneras", use_container_width=True):
374
+ # Publicar aunque sea tóxico
375
+ c = st.session_state.pending_modal
376
+ st.session_state.comments.append(c)
377
+ st.session_state.hub_history.insert(0, {
378
+ "Usuario" : "@usuario",
379
+ "Comentario": f'"{c["text"][:45]}..."',
380
+ "Score" : round(c["probability"], 2),
381
+ "Acción" : "⚠️ Override usuario",
382
+ })
383
+ st.session_state.pending_modal = None
384
+ st.rerun()
385
+
386
+
387
+ # ══════════════════════════════════════════════════════════════════════════════
388
+ # HOME — interfaz estilo YouTube
389
+ # ══════════════════════════════════════════════════════════════════════════════
390
+ def render_home():
391
+ # Disparar modal si hay comentario pendiente
392
+ if st.session_state.pending_modal:
393
+ show_toxicity_modal()
394
+
395
+ col_main, col_right = st.columns([2.8, 1], gap="large")
396
+
397
+ with col_main:
398
+ # Video
399
+ st.markdown(
400
+ "<div class='video-thumb'><div class='play-btn'>▶</div></div>",
401
+ unsafe_allow_html=True,
402
+ )
403
+ st.markdown(
404
+ "<div class='video-title'>AI Moderation Demo — Detección de Hate Speech en tiempo real</div>"
405
+ "<div class='video-meta'>15k vistas · 2 horas atrás</div>",
406
+ unsafe_allow_html=True,
407
+ )
408
+ row_ch, row_sub = st.columns([3, 1])
409
+ with row_ch:
410
+ st.markdown(
411
+ "<div style='display:flex; align-items:center; gap:10px; margin:10px 0;'>"
412
+ "<div style='width:36px; height:36px; border-radius:50%; background:#cc0000;"
413
+ "display:flex; align-items:center; justify-content:center; color:#fff;"
414
+ "font-weight:700;'>S</div>"
415
+ "<div><div class='channel-name'>SignalMod AI</div>"
416
+ "<div class='video-meta'>1.2M suscriptores</div></div></div>",
417
+ unsafe_allow_html=True,
418
+ )
419
+
420
+ st.divider()
421
+
422
+ # ── Comentarios ────────────────────────────────────────────────────
423
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"])
424
+ st.markdown(
425
+ f"<div class='sec-title'>{len(st.session_state.comments)} Comentarios "
426
+ f"<span style='font-size:0.8rem; color:#cc0000;'>· {tox_cnt} detectados</span></div>",
427
+ unsafe_allow_html=True,
428
+ )
429
+
430
+ # Input de nuevo comentario
431
+ new_text = st.text_area(
432
+ "Escribe un comentario...",
433
+ height=80, label_visibility="collapsed",
434
+ key="comment_input",
435
+ placeholder="Escribe un comentario...",
436
+ )
437
+
438
+ # Análisis en tiempo real (solo cuando hay texto)
439
+ analysis = None
440
+ if new_text.strip():
441
+ svc = get_service(st.session_state.selected_model)
442
+ analysis = svc.predict(new_text)
443
+ pct = int(analysis["probability"] * 100)
444
+ color = "#cc0000" if pct >= 70 else "#f5a623" if pct >= 40 else "#00c853"
445
+ verdict = "TÓXICO" if analysis["is_toxic"] else "SEGURO"
446
+ v_color = "#cc0000" if analysis["is_toxic"] else "#00c853"
447
+ st.markdown(
448
+ f"<div class='tox-row'>"
449
+ f"<span>🔍 Analizando...</span>"
450
+ f"<span style='background:{v_color}; color:#fff; border-radius:10px;"
451
+ f"padding:1px 9px; font-size:0.72rem; font-weight:700;'>{verdict}</span>"
452
+ f"<span style='color:{color}; font-weight:600;'>Toxicidad: {pct}%</span>"
453
+ f"<div class='tox-bar-bg'>"
454
+ f"<div class='tox-bar-fill' style='width:{pct}%; background:{color};'></div>"
455
+ f"</div></div>",
456
+ unsafe_allow_html=True,
457
+ )
458
+
459
+ col_c, col_p = st.columns([1, 1])
460
+ with col_c:
461
+ if st.button("Cancelar", use_container_width=True):
462
+ st.rerun()
463
+ with col_p:
464
+ post = st.button("Comentar", type="primary", use_container_width=True)
465
+
466
+ # Procesar envío
467
+ if post and new_text.strip():
468
+ if analysis is None:
469
+ svc = get_service(st.session_state.selected_model)
470
+ analysis = svc.predict(new_text)
471
+
472
+ comment_obj = {
473
+ "user" : "usuario",
474
+ "initial" : "U",
475
+ "text" : new_text.strip(),
476
+ "time" : "ahora",
477
+ "is_toxic" : analysis["is_toxic"],
478
+ "probability": analysis["probability"],
479
+ "labels" : analysis["labels"],
480
+ }
481
+
482
+ if analysis["is_toxic"]:
483
+ # Guardar en pendiente y mostrar modal en el próximo render
484
+ st.session_state.pending_modal = comment_obj
485
+ st.rerun()
486
+ else:
487
+ # Publicar directamente
488
+ st.session_state.comments.append(comment_obj)
489
+ st.session_state.hub_history.insert(0, {
490
+ "Usuario" : "@usuario",
491
+ "Comentario": f'"{new_text.strip()[:45]}{"..." if len(new_text)>45 else ""}"',
492
+ "Score" : round(analysis["probability"], 2),
493
+ "Acción" : "✅ Aprobado",
494
+ })
495
+ st.rerun()
496
+
497
+ # ── Lista de comentarios ───────────────────────────────────────────
498
+ for c in reversed(st.session_state.comments):
499
+ is_tox = c["is_toxic"]
500
+ pct = int(c["probability"] * 100)
501
+ av_class = "c-avatar" if is_tox else "c-avatar safe"
502
+ badge = (
503
+ "<span class='badge badge-toxic'>TÓXICO</span>" if is_tox
504
+ else "<span class='badge badge-safe'>SEGURO</span>"
505
+ )
506
+ text_class = "c-text toxic" if is_tox else "c-text"
507
+ flagged = "<div class='c-flagged'>🚩 Flagged for review</div>" if is_tox else ""
508
+
509
+ # html.escape() protege contra caracteres que rompen el HTML
510
+ safe_text = html.escape(c["text"])
511
+ safe_user = html.escape(c["user"])
512
+ initial = html.escape(c.get("initial", c["user"][0].upper()))
513
+
514
+ st.markdown(
515
+ f"<div class='comment-wrap'>"
516
+ f" <div class='{av_class}'>{initial}</div>"
517
+ f" <div class='c-body'>"
518
+ f" <div class='c-header'>"
519
+ f" <span class='c-user'>@{safe_user}</span>"
520
+ f" <span class='c-time'>{c['time']}</span>"
521
+ f" {badge}"
522
+ f" </div>"
523
+ f" <div class='{text_class}'>{safe_text}</div>"
524
+ f" {flagged}"
525
+ f" </div>"
526
+ f"</div>",
527
+ unsafe_allow_html=True,
528
+ )
529
+
530
+ # ── Columna derecha ────────────────────────────────────────────────────
531
+ with col_right:
532
+ st.markdown("**Sugeridos**")
533
+ suggested = [
534
+ ("🤖", "Understanding Transformer Models...", "Neural Systems", "89k · 1 día"),
535
+ ("🎓", "The Future of Content Moderation", "Tech Ethics Pro", "1.4M · 2 sem"),
536
+ ("📡", "Signal vs Noise: SignalMod Deep Dive","SignalMod AI", "250k · 3 días"),
537
+ ("💡", "Why AI Moderation is Harder Than...", "Ethics in Code", "45k · 5 h"),
538
+ ("🔬", "Hate Speech Detection 2024", "AI Research Lab", "12k · 1 sem"),
539
+ ]
540
+ for emoji, title, ch, meta in suggested:
541
+ st.markdown(
542
+ f"<div class='sug-card'>"
543
+ f" <div class='sug-thumb'>{emoji}</div>"
544
+ f" <div>"
545
+ f" <div class='sug-title'>{html.escape(title)}</div>"
546
+ f" <div class='sug-ch'>{html.escape(ch)}</div>"
547
+ f" <div class='sug-meta'>{html.escape(meta)}</div>"
548
+ f" </div>"
549
+ f"</div>",
550
+ unsafe_allow_html=True,
551
+ )
552
+
553
+
554
+ # ══════════════════════════════════════════════════════════════════════════════
555
+ # MODERATOR HUB
556
+ # ══════════════════════════════════════════════════════════════════════════════
557
+ def render_hub():
558
+ try:
559
+ import plotly.graph_objects as go
560
+ except ImportError:
561
+ st.error("Instala plotly: pip install plotly")
562
+ return
563
+
564
+ st.markdown("## 📊 Panel de Estadísticas")
565
+
566
+ # ── Cards de configuración ──────────────────────────────────────────────
567
+ model_short = st.session_state.selected_model.split("(")[0].strip()
568
+ c1, c2, c3 = st.columns(3)
569
+ for col, label, val in [
570
+ (c1, "MODEL ARCHITECTURE", model_short),
571
+ (c2, "CONFIDENCE THRESHOLD", f"{st.session_state.threshold:.2f} Alpha"),
572
+ (c3, "LANGUAGE COVERAGE", "English"),
573
+ ]:
574
+ with col:
575
+ st.markdown(
576
+ f"<div class='hub-card'>"
577
+ f"<div class='hub-kpi-label'>{label}</div>"
578
+ f"<div style='font-weight:600; font-size:0.95rem; color:#0f0f0f;'>"
579
+ f"{html.escape(str(val))}</div></div>",
580
+ unsafe_allow_html=True,
581
+ )
582
+
583
+ st.write("")
584
+
585
+ # ── KPIs ────────��──────────────────────────────────────────────────────
586
+ total = len(st.session_state.comments) + 100
587
+ tox_cnt = sum(1 for c in st.session_state.comments if c["is_toxic"]) + 5
588
+ tox_rate = tox_cnt / total * 100
589
+ m1, m2, m3 = st.columns(3)
590
+ m1.metric("💬 Total comentarios", f"{total:,}", "+12%")
591
+ m2.metric("☠️ Tasa de toxicidad", f"{tox_rate:.1f}%",
592
+ f"+0.8%", delta_color="inverse")
593
+ m3.metric("🎯 F1 Score", "0.7579", "Stable")
594
+
595
+ st.divider()
596
+
597
+ # ── Gráficos ───────────────────────────────────────────────────────────
598
+ gcol, pcol = st.columns([2.2, 1])
599
+
600
+ with gcol:
601
+ days = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]
602
+ vals = [random.randint(30, 80) for _ in days]
603
+ vals[3] = max(vals) + 25
604
+ colors = ["#cc0000" if i == 3 else "#b3c6ff" for i in range(7)]
605
+ fig = go.Figure(go.Bar(x=days, y=vals, marker_color=colors, width=0.55))
606
+ fig.update_layout(
607
+ title="Tendencias de Toxicidad (7D)",
608
+ paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
609
+ margin=dict(l=20, r=20, t=40, b=20), height=260,
610
+ font=dict(size=11, color="#0f0f0f"),
611
+ )
612
+ fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
613
+ fig.update_xaxes(showgrid=False)
614
+ st.plotly_chart(fig, use_container_width=True)
615
+
616
+ with pcol:
617
+ fig2 = go.Figure(go.Pie(
618
+ labels=["Hate Speech","Insulto","Agresividad"],
619
+ values=[45, 35, 20],
620
+ hole=0.58,
621
+ marker_colors=["#cc0000","#0f0f0f","#909090"],
622
+ textfont_size=11,
623
+ ))
624
+ fig2.update_layout(
625
+ title="Categorías",
626
+ paper_bgcolor="#ffffff",
627
+ margin=dict(l=10, r=10, t=40, b=10), height=260,
628
+ legend=dict(font=dict(size=10), orientation="v"),
629
+ font=dict(size=11, color="#0f0f0f"),
630
+ )
631
+ st.plotly_chart(fig2, use_container_width=True)
632
+
633
+ # ── Historial ──────────────────────────────────────────────────────────
634
+ st.markdown("### Historial Reciente")
635
+ df = pd.DataFrame(st.session_state.hub_history)
636
+ if not df.empty:
637
+ st.dataframe(
638
+ df, use_container_width=True, hide_index=True,
639
+ column_config={
640
+ "Score": st.column_config.ProgressColumn(
641
+ "Score", min_value=0, max_value=1, format="%.2f"
642
+ )
643
+ },
644
+ )
645
+
646
+
647
+ # ══════════════════════════════════════════════════════════════════════════════
648
+ # SETTINGS
649
+ # ══════════════════════════════════════════════════════════════════════════════
650
+ def render_settings():
651
+ st.markdown("## ⚙️ Ajustes")
652
+
653
+ # ── Selección de modelo ─────────────────────────────────────────────────
654
+ st.markdown("### 🤖 Modelo de detección",)
655
+ st.caption(
656
+ "Los modelos HuggingFace se descargan la primera vez (~300–600 MB). "
657
+ "Requieren: `pip install transformers torch sentencepiece`"
658
+ )
659
+ st.write("")
660
+
661
+ # Usamos st.radio para la selección — sin bugs de HTML
662
+ model_names = list(AVAILABLE_MODELS.keys())
663
+ current_idx = model_names.index(st.session_state.selected_model) \
664
+ if st.session_state.selected_model in model_names else 0
665
+
666
+ chosen = st.radio(
667
+ "Seleccionar modelo",
668
+ model_names,
669
+ index=current_idx,
670
+ label_visibility="collapsed",
671
+ )
672
+
673
+ if chosen != st.session_state.selected_model:
674
+ st.session_state.selected_model = chosen
675
+ st.rerun()
676
+
677
+ # Ficha del modelo seleccionado
678
+ info = AVAILABLE_MODELS[st.session_state.selected_model]
679
+ st.markdown(
680
+ f"<div class='model-card active'>"
681
+ f"<div class='model-card-name'>{info['icon']} {html.escape(st.session_state.selected_model)}</div>"
682
+ f"<div class='model-card-desc'>{html.escape(info['description'])}</div>"
683
+ f"<div style='margin-top:8px;'>"
684
+ f"<span class='model-pill'>⚡ {html.escape(info['speed'])}</span>"
685
+ f"<span class='model-pill'>🎯 {html.escape(info['accuracy'])}</span>"
686
+ f"<span class='model-pill'>📦 {html.escape(info['requires'])}</span>"
687
+ f"</div></div>",
688
+ unsafe_allow_html=True,
689
+ )
690
+
691
+ # Info sobre modelo fine-tuneado
692
+ if st.session_state.selected_model == "Modelo fine-tuneado (local)":
693
+ path = PROJECT_ROOT / "models" / "finetuned_hf"
694
+ if path.exists():
695
+ st.success(f"✅ Modelo encontrado en `{path}`")
696
+ else:
697
+ st.warning(
698
+ f"⚠️ No se encontró el modelo en `{path}`. "
699
+ f"Ejecuta el **notebook 08** para generar el modelo fine-tuneado."
700
+ )
701
+
702
+ st.divider()
703
+
704
+ # ── Umbral de confianza ─────────────────────────────────────────────────
705
+ st.markdown("### 🎚️ Umbral de confianza")
706
+ st.caption("Probabilidad mínima para marcar un comentario como tóxico.")
707
+
708
+ new_thr = st.slider(
709
+ "Umbral",
710
+ min_value=0.3, max_value=0.9, step=0.05,
711
+ value=st.session_state.threshold,
712
+ label_visibility="collapsed",
713
+ format="%.2f",
714
+ )
715
+ if new_thr != st.session_state.threshold:
716
+ st.session_state.threshold = new_thr
717
+ st.info(f"Umbral actualizado: **{new_thr:.2f}**")
718
+
719
+ ta, tb = st.columns(2)
720
+ ta.info(f"⬇️ **{new_thr:.2f}** bajo → más FP (más censura)", icon="⚠️")
721
+ tb.info(f"⬆️ **{new_thr:.2f}** alto → más FN (más escapes)", icon="⚠️")
722
+
723
+ st.divider()
724
+
725
+ # ── Test rápido ─────────────────────────────────────────────────────────
726
+ st.markdown("### 🧪 Probar modelo")
727
+ test_txt = st.text_input(
728
+ "Texto a analizar",
729
+ placeholder="Ej: This is absolutely stupid and racist...",
730
+ label_visibility="collapsed",
731
+ )
732
+ if st.button("Analizar", type="primary") and test_txt.strip():
733
+ with st.spinner("Analizando..."):
734
+ svc = get_service(st.session_state.selected_model)
735
+ res = svc.predict(test_txt)
736
+
737
+ pct = int(res["probability"] * 100)
738
+ verdict = "🔴 TÓXICO" if res["is_toxic"] else "🟢 SEGURO"
739
+ st.markdown(f"**{verdict}** — {pct}% de toxicidad")
740
+ st.progress(res["probability"])
741
+ if res["labels"]:
742
+ st.markdown(f"**Categorías:** {', '.join(res['labels'])}")
743
+ if "error" in res:
744
+ st.error(f"Error: {res['error']}")
745
+ st.caption(f"Modelo: {res['model_used']}")
746
+
747
+
748
+ # ══════════════════════════════════════════════════════════════════════════════
749
+ # MAIN
750
+ # ══════════════════════════════════════════════════════════════════════════════
751
+ def main():
752
+ render_sidebar()
753
+
754
+ page = st.session_state.page
755
+ if page == "Home":
756
+ render_home()
757
+ elif page == "Moderator Hub":
758
+ render_hub()
759
+ elif page == "Settings":
760
+ render_settings()
761
+
762
+
763
+ if __name__ == "__main__":
764
+ main()
src/service/model_service.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/services/model_service.py
3
+
4
+ Servicio centralizado de predicción de toxicidad.
5
+
6
+ Modelos soportados:
7
+ local → models/final_model.joblib (LR + TF-IDF, instantáneo)
8
+ hf_remote → HuggingFace Hub (requiere internet + transformers)
9
+ hf_local → modelo HF fine-tuneado localmente (notebook 08)
10
+
11
+ Instalación para modelos HF:
12
+ pip install transformers torch sentencepiece accelerate
13
+ """
14
+
15
+ import re
16
+ import yaml
17
+ import joblib
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ # ─── Catálogo de modelos ──────────────────────────────────────────────────────
22
+ AVAILABLE_MODELS = {
23
+ "LR + TF-IDF (local)": {
24
+ "type" : "local",
25
+ "icon" : "⚡",
26
+ "description": "Modelo del proyecto. Sin GPU, instantáneo.",
27
+ "speed" : "< 50ms",
28
+ "accuracy" : "F1 0.76",
29
+ "requires" : "Solo joblib",
30
+ },
31
+ "DistilBERT Toxicity": {
32
+ "type" : "hf_remote",
33
+ "icon" : "🤖",
34
+ "model_id" : "martin-ha/toxic-comment-model",
35
+ "description": "DistilBERT fine-tuned en comentarios tóxicos.",
36
+ "speed" : "~200ms CPU",
37
+ "accuracy" : "F1 0.85",
38
+ "requires" : "transformers torch",
39
+ },
40
+ "toxic-bert (multilabel)": {
41
+ "type" : "hf_remote",
42
+ "icon" : "🧠",
43
+ "model_id" : "unitary/toxic-bert",
44
+ "description": "BERT multi-label (Jigsaw). Detecta 6 categorías.",
45
+ "speed" : "~400ms CPU",
46
+ "accuracy" : "F1 0.88",
47
+ "requires" : "transformers torch",
48
+ },
49
+ "RoBERTa Toxicity": {
50
+ "type" : "hf_remote",
51
+ "icon" : "🔬",
52
+ "model_id" : "s-nlp/roberta_toxicity_classifier",
53
+ "description": "RoBERTa fine-tuned para toxicidad general.",
54
+ "speed" : "~350ms CPU",
55
+ "accuracy" : "F1 0.87",
56
+ "requires" : "transformers torch",
57
+ },
58
+ "Modelo fine-tuneado (local)": {
59
+ "type" : "hf_local",
60
+ "icon" : "✨",
61
+ "model_path" : "models/finetuned_hf",
62
+ "description": "Tu modelo fine-tuneado en el notebook 08.",
63
+ "speed" : "Depende del hardware",
64
+ "accuracy" : "A evaluar",
65
+ "requires" : "transformers torch",
66
+ },
67
+ }
68
+
69
+ HF_LABEL_MAP = {
70
+ "toxic": "Tóxico", "severe_toxic": "Muy ofensivo",
71
+ "obscene": "Obsceno", "threat": "Amenaza",
72
+ "insult": "Insulto", "identity_hate": "Odio racial",
73
+ "label_1": "Tóxico",
74
+ }
75
+
76
+ _KEYWORD_LABELS = {
77
+ "Insulto" : ["idiot","stupid","dumb","fool","moron","loser"],
78
+ "Odio racial": ["thug","racist","race","criminal"],
79
+ "Amenaza" : ["kill","shoot","die","dead","hurt","attack"],
80
+ "Obsceno" : ["fuck","shit","ass","bitch","cunt","bastard"],
81
+ "Agresividad": ["hate","despise","disgusting","pathetic","worthless"],
82
+ }
83
+
84
+
85
+ def _labels_from_keywords(text: str, probability: float) -> list:
86
+ t = text.lower()
87
+ found = [lbl for lbl, kws in _KEYWORD_LABELS.items() if any(k in t for k in kws)]
88
+ return found if found else (["Contenido ofensivo"] if probability >= 0.5 else [])
89
+
90
+
91
+ class _FallbackPreprocessor:
92
+ _SW = {"the","a","an","and","or","but","in","on","at","to","for",
93
+ "of","with","is","it","this","that","are","was","be","have",
94
+ "has","he","she","they","we","you","i","not","do","did",
95
+ "will","can","would","should","could","from","by","as","if"}
96
+ def transform(self, text):
97
+ t = re.sub(r"http\S+|www\.\S+|@\w+", " ", str(text).lower())
98
+ t = re.sub(r"[^\x00-\x7F]+", " ", t)
99
+ t = re.sub(r"[^a-z\s]", " ", t)
100
+ t = re.sub(r"\s+", " ", t).strip()
101
+ return " ".join(w for w in t.split() if w not in self._SW and len(w) > 2)
102
+
103
+
104
+ class ModelService:
105
+ def __init__(self, model_name: str, project_root: Optional[Path] = None):
106
+ self.model_name = model_name
107
+ self.cfg = AVAILABLE_MODELS.get(model_name) or list(AVAILABLE_MODELS.values())[0]
108
+ self.project_root = project_root or Path.cwd()
109
+ self._model = None
110
+ self._preprocessor = None
111
+
112
+ def _get_model(self):
113
+ if self._model is None:
114
+ t = self.cfg["type"]
115
+ if t == "local":
116
+ self._load_local()
117
+ elif t == "hf_remote":
118
+ self._load_hf(self.cfg["model_id"])
119
+ elif t == "hf_local":
120
+ path = self.project_root / self.cfg["model_path"]
121
+ if not path.exists():
122
+ raise FileNotFoundError(
123
+ f"Modelo no encontrado en {path}. Ejecuta el notebook 08 primero."
124
+ )
125
+ self._load_hf(str(path))
126
+ return self._model
127
+
128
+ def _load_local(self):
129
+ for name in ["final_model.joblib","lr_tuned.joblib",
130
+ "lr_baseline.joblib","best_ensemble.joblib"]:
131
+ p = self.project_root / "models" / name
132
+ if p.exists():
133
+ self._model = joblib.load(p)
134
+ break
135
+ if self._model is None:
136
+ raise FileNotFoundError(f"No hay modelo en {self.project_root / 'models'}")
137
+ try:
138
+ import sys; sys.path.insert(0, str(self.project_root))
139
+ from src.features.text_preprocessor import TextPreprocessor
140
+ self._preprocessor = TextPreprocessor(
141
+ config_path=str(self.project_root / "configs" / "features.yaml")
142
+ )
143
+ except Exception:
144
+ self._preprocessor = _FallbackPreprocessor()
145
+
146
+ def _load_hf(self, model_id_or_path: str):
147
+ try:
148
+ from transformers import pipeline as hf_pipeline
149
+ except ImportError:
150
+ raise ImportError("Instala: pip install transformers torch sentencepiece")
151
+ self._model = hf_pipeline(
152
+ "text-classification", model=model_id_or_path,
153
+ return_all_scores=True, truncation=True, max_length=512,
154
+ )
155
+
156
+ def predict(self, text: str) -> dict:
157
+ if not text or not text.strip():
158
+ return {"is_toxic": False, "probability": 0.0,
159
+ "labels": [], "model_used": self.model_name}
160
+ try:
161
+ model = self._get_model()
162
+ if self.cfg["type"] == "local":
163
+ return self._pred_local(text, model)
164
+ return self._pred_hf(text, model)
165
+ except Exception as e:
166
+ return {"is_toxic": False, "probability": 0.0,
167
+ "labels": [], "model_used": self.model_name, "error": str(e)}
168
+
169
+ def _pred_local(self, text, model):
170
+ clean = self._preprocessor.transform(text) or text
171
+ proba = float(model.predict_proba([clean])[0][1])
172
+ tox = proba >= 0.5
173
+ return {"is_toxic": tox, "probability": proba,
174
+ "labels": _labels_from_keywords(text, proba) if tox else [],
175
+ "model_used": self.model_name}
176
+
177
+ def _pred_hf(self, text, pipeline_fn):
178
+ raw = pipeline_fn(text[:512])
179
+ smap = {s["label"].lower(): s["score"] for s in (raw[0] if isinstance(raw[0], list) else raw)}
180
+ for key in ("label_1","toxic","toxic_1"):
181
+ if key in smap:
182
+ proba = smap[key]; break
183
+ else:
184
+ neg = {"label_0","non_toxic","not_toxic","not toxic"}
185
+ vals = [v for k,v in smap.items() if k not in neg]
186
+ proba = max(vals) if vals else 0.0
187
+ tox = proba >= 0.5
188
+ labels = []
189
+ if tox:
190
+ for k,v in smap.items():
191
+ if k not in ("label_0","non_toxic") and v >= 0.35:
192
+ friendly = HF_LABEL_MAP.get(k, k.replace("_"," ").title())
193
+ if "no tóxico" not in friendly.lower():
194
+ labels.append(friendly)
195
+ if not labels:
196
+ labels = ["Contenido ofensivo"]
197
+ return {"is_toxic": tox, "probability": proba,
198
+ "labels": labels, "model_used": self.model_name}
199
+
200
+ @staticmethod
201
+ def get_available_models(): return AVAILABLE_MODELS
202
+ def get_model_info(self): return self.cfg