File size: 23,640 Bytes
b039453
 
 
48c8beb
b039453
1f3d124
 
b039453
 
2b71e32
 
b039453
 
1f3d124
 
 
b039453
 
3546e59
01948c5
48aa535
 
 
 
 
 
 
b039453
01948c5
b039453
 
045161d
1f3d124
48c8beb
01948c5
045161d
 
01948c5
 
 
 
 
 
 
 
045161d
50dda15
 
 
 
 
045161d
01948c5
 
 
 
 
 
80778be
2b71e32
e4aa409
2b71e32
 
e4aa409
 
01948c5
 
 
 
045161d
b039453
2b71e32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b94d2c
 
 
 
 
 
 
2b71e32
 
 
 
 
675bcb6
 
 
 
 
 
 
 
 
 
 
 
0b94d2c
675bcb6
 
 
 
 
 
 
 
 
 
 
50dda15
 
 
0b94d2c
fa6480b
 
80778be
 
 
 
 
 
 
 
 
 
 
50dda15
 
80778be
 
 
 
e4aa409
 
3546e59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01948c5
 
 
48aa535
 
 
 
01948c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48c8beb
01948c5
 
48c8beb
 
1f3d124
9d59e19
48c8beb
01948c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4e5195
01948c5
f4e5195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01948c5
f4e5195
01948c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f3d124
 
 
 
 
 
 
 
 
 
01948c5
b039453
 
 
01948c5
274ba7a
 
 
01948c5
e4aa409
 
 
38ef84a
 
 
 
 
 
 
80778be
 
 
38ef84a
 
01948c5
274ba7a
 
 
 
 
 
 
 
 
 
 
9d59e19
274ba7a
01948c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b71e32
01948c5
 
 
 
2b71e32
 
 
 
 
01948c5
9d59e19
 
 
 
 
 
 
 
2b71e32
01948c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274ba7a
 
 
 
 
 
2b71e32
 
01948c5
274ba7a
 
 
 
 
 
 
9d59e19
274ba7a
 
 
 
 
 
 
 
 
 
 
 
01948c5
274ba7a
 
 
 
 
 
 
 
01948c5
274ba7a
 
 
01948c5
274ba7a
01948c5
274ba7a
 
 
 
 
 
 
 
 
 
 
 
 
 
50dda15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01948c5
274ba7a
 
 
01948c5
50dda15
 
 
 
 
 
 
 
274ba7a
01948c5
274ba7a
50dda15
274ba7a
 
 
 
 
 
01948c5
274ba7a
 
 
 
 
 
 
 
 
 
 
 
 
e4aa409
a549835
7e4f545
 
 
 
 
 
 
 
 
a549835
675bcb6
e4aa409
 
 
 
50dda15
 
 
 
 
2b71e32
01948c5
 
50dda15
e4aa409
 
01948c5
 
 
 
2b71e32
3546e59
01948c5
 
 
e4aa409
 
01948c5
 
 
2b71e32
01948c5
 
e4aa409
01948c5
 
 
 
e4aa409
01948c5
 
 
 
e4aa409
2b71e32
e4aa409
2b71e32
 
01948c5
 
 
e4aa409
01948c5
 
 
 
 
2b71e32
 
e4aa409
01948c5
 
 
 
 
 
 
 
 
2b71e32
e4aa409
2b71e32
 
 
01948c5
2b71e32
01948c5
 
 
 
 
 
2b71e32
 
 
01948c5
2b71e32
01948c5
2b71e32
 
 
 
 
 
 
 
 
 
e4aa409
2b71e32
 
01948c5
 
e4aa409
 
01948c5
2b71e32
675bcb6
e4aa409
0b94d2c
01948c5
e4aa409
01948c5
2b71e32
 
 
fa6480b
 
 
2b71e32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b94d2c
2b71e32
 
 
675bcb6
2b71e32
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
from __future__ import annotations

import io
import importlib.util
import json
import os
import re
import shutil
import tempfile
import time
import gc
from pathlib import Path

# Evita inspecciones de modulos que generan ruido con torch.classes en Streamlit.
os.environ.setdefault("STREAMLIT_SERVER_FILE_WATCHER_TYPE", "none")

import pandas as pd
import streamlit as st
import streamlit.components.v1 as components
import torch
from huggingface_hub import model_info
from transformers import AutoModelForCausalLM, AutoTokenizer

try:
    from transformers import AutoConfig
except ImportError:
    AutoConfig = None

from web.runner import TIPOS_EVALUACION_DISPONIBLES, construir_instruccion_sistema_generador, ejecutar_job
from web.schemas import JobRequest, ModoEvaluacion, TipoEvaluacion

MODELOS_PREDEFINIDOS = [
    "Qwen/Qwen2.5-1.5B-Instruct",
    "Qwen/Qwen2.5-3B-Instruct",
    "Other Model",
]

LABELS_TIPOS_EVALUACION = {
    "preguntas_agente": "Preguntas agente",
    "preguntas_analisis_sentimiento": "Preguntas analisis de sentimiento",
    "preguntas_cerradas_esperadas": "Preguntas cerradas esperadas",
    "preguntas_cerradas_probabilidad": "Preguntas cerradas probabilidad",
    "preguntas_respuestas_multiples": "Preguntas respuestas multiples",
    "preguntas_prompt_injection": "Preguntas prompt injection",
}

TIPOS_EVALUACION_SOPORTADOS_SPACE = {
    "preguntas_agente",
    "preguntas_cerradas_esperadas",
}


def _init_state() -> None:
    defaults = {
        "modelo_eval_validado": False,
        "modelo_eval_confirmado": "",
        "modelo_gen_validado": False,
        "modelo_gen_confirmado": "",
        "modo_actual": ModoEvaluacion.POR_DEFECTO.value,
        "eval_running": False,
        "eval_requested": False,
        "eval_success": False,
        "last_result": None,
        "pending_eval": None,
        "eval_error": None,
    }
    for key, value in defaults.items():
        if key not in st.session_state:
            st.session_state[key] = value


def _formatear_duracion(segundos: float) -> str:
    total = int(max(segundos, 0))
    horas, resto = divmod(total, 3600)
    minutos, segs = divmod(resto, 60)
    return f"{horas:02d}:{minutos:02d}:{segs:02d}"


def _slug_modelo(model_id: str) -> str:
    return re.sub(r"[^a-zA-Z0-9._-]+", "_", model_id.strip()).strip("_")


def _leer_bytes_si_existe(path: Path) -> bytes | None:
    if path.exists() and path.is_file():
        return path.read_bytes()
    return None


def _liberar_memoria(liberar_modelo_cache: bool = False) -> None:
    if liberar_modelo_cache:
        try:
            # Libera tokenizer/modelo cacheados por @st.cache_resource.
            cargar_modelo_transformers.clear()
        except Exception:
            pass
    gc.collect()
    if torch.cuda.is_available():
        torch.cuda.empty_cache()


def _resetear_estado_inicial() -> None:
    st.session_state["eval_running"] = False
    st.session_state["eval_requested"] = False
    st.session_state["eval_success"] = False
    st.session_state["last_result"] = None
    st.session_state["pending_eval"] = None
    st.session_state["eval_error"] = None
    st.session_state["modelo_eval_validado"] = False
    st.session_state["modelo_eval_confirmado"] = ""
    st.session_state["modelo_gen_validado"] = False
    st.session_state["modelo_gen_confirmado"] = ""

    # Limpia widgets para volver a la pantalla inicial.
    for key in [
        "modelo_eval_option",
        "modelo_eval_otro",
        "confirmar_modelo_grande_cpu",
        "mostrar_cancelacion_eval",
        "modelo_gen_option",
        "modelo_gen_otro",
        "plantilla_personalizada_uploader",
    ]:
        st.session_state.pop(key, None)

    for tipo_eval in TIPOS_EVALUACION_DISPONIBLES:
        st.session_state.pop(f"eval_tipo_{tipo_eval}", None)

    _liberar_memoria(liberar_modelo_cache=True)


def _resetear_widgets_modo(modo: str) -> None:
    if modo == ModoEvaluacion.POR_DEFECTO.value:
        st.session_state.pop("modelo_gen_option", None)
        st.session_state.pop("modelo_gen_otro", None)
        st.session_state["modelo_gen_validado"] = False
        st.session_state["modelo_gen_confirmado"] = ""
        return

    st.session_state.pop("modelo_eval_option", None)
    st.session_state.pop("modelo_eval_otro", None)
    st.session_state.pop("confirmar_modelo_grande_cpu", None)
    for tipo_eval in TIPOS_EVALUACION_DISPONIBLES:
        st.session_state.pop(f"eval_tipo_{tipo_eval}", None)
    st.session_state["modelo_eval_validado"] = False
    st.session_state["modelo_eval_confirmado"] = ""


def _render_reloj_tiempo_real(inicio_epoch: int, placeholder) -> None:
    with placeholder:
        components.html(
                f"""
                <div style='padding:0.35rem 0; font-size:0.95rem; color:#374151;'>
                    <strong>Tiempo transcurrido:</strong> <span id='equitia-live-timer'>00:00:00</span>
                </div>
                <script>
                    const startEpoch = {inicio_epoch};
                    function pad(n) {{ return String(n).padStart(2, '0'); }}
                    function tick() {{
                        const now = Math.floor(Date.now() / 1000);
                        const diff = Math.max(0, now - startEpoch);
                        const hh = Math.floor(diff / 3600);
                        const mm = Math.floor((diff % 3600) / 60);
                        const ss = diff % 60;
                        const el = document.getElementById('equitia-live-timer');
                        if (el) el.textContent = `${{pad(hh)}}:${{pad(mm)}}:${{pad(ss)}}`;
                    }}
                    tick();
                    setInterval(tick, 1000);
                </script>
                """,
                height=42,
        )


@st.cache_data(show_spinner=False, ttl=3600)
def validar_modelo_existe(model_id: str) -> tuple[bool, str]:
    try:
        if AutoConfig is not None:
            AutoConfig.from_pretrained(model_id)
        else:
            model_info(model_id)
        return True, f"Modelo encontrado en Hugging Face: {model_id}"
    except Exception as exc:
        return False, f"No se pudo validar el modelo '{model_id}': {exc}"


@st.cache_resource(show_spinner=True)
def cargar_modelo_transformers(model_id: str):
    tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
    if tokenizer.pad_token is None and tokenizer.eos_token is not None:
        tokenizer.pad_token = tokenizer.eos_token

    kwargs = {
        "low_cpu_mem_usage": True,
    }

    accelerate_disponible = importlib.util.find_spec("accelerate") is not None
    if torch.cuda.is_available():
        kwargs["torch_dtype"] = torch.float16
        if accelerate_disponible:
            kwargs["device_map"] = "auto"
    else:
        # En CPU evitamos device_map para no requerir accelerate.
        kwargs["torch_dtype"] = torch.float32

    model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
    model.eval()
    return tokenizer, model


def invocar_modelo_transformers(model_id: str, prompt: str, instruccion_sistema: str | None = None) -> str:
    tokenizer, model = cargar_modelo_transformers(model_id)

    contenido_sistema = (instruccion_sistema or "Responde de forma breve y directa.").strip()
    mensajes = [
        {"role": "system", "content": contenido_sistema},
        {"role": "user", "content": prompt},
    ]

    inputs = None
    if hasattr(tokenizer, "apply_chat_template"):
        try:
            inputs = tokenizer.apply_chat_template(
                mensajes,
                return_tensors="pt",
                add_generation_prompt=True,
            )
        except Exception:
            # Fallback para tokenizers sin chat_template definido (ej. modelos base/instruct antiguos).
            inputs = None

    if inputs is None:
        prompt_plano = (
            f"Sistema: {contenido_sistema}\n\n"
            f"Usuario: {prompt}\n\n"
            "Respuesta:"
        )
        inputs = tokenizer(prompt_plano, return_tensors="pt")["input_ids"]

    device = model.device
    inputs = inputs.to(device)

    with torch.no_grad():
        outputs = model.generate(
            inputs,
            max_new_tokens=8,
            do_sample=False,
            pad_token_id=tokenizer.pad_token_id,
            eos_token_id=tokenizer.eos_token_id,
        )

    new_tokens = outputs[:, inputs.shape[1]:]
    texto = tokenizer.batch_decode(new_tokens, skip_special_tokens=True)[0].strip()
    return texto


def _estimar_tamanyo_modelo_b(model_id: str) -> float | None:
    match = re.search(r"(\d+(?:\.\d+)?)\s*[bB]", model_id)
    if not match:
        return None
    try:
        return float(match.group(1))
    except ValueError:
        return None


st.set_page_config(page_title="EQUITIA Web", page_icon="馃搳", layout="wide")
st.title("EQUITIA 路 Evaluaci贸n de sesgos LLM")
st.caption("Despliegue p煤blico en Hugging Face Spaces")

_init_state()
mostrar_solo_resultados = bool(
    st.session_state.get("eval_success") and st.session_state.get("last_result")
)

if st.session_state.get("eval_error"):
    st.error(st.session_state["eval_error"])

if not mostrar_solo_resultados:
    modo = st.radio(
        "Selecciona el modo",
        options=[ModoEvaluacion.POR_DEFECTO.value, ModoEvaluacion.PERSONALIZADA.value],
        format_func=lambda x: "Evaluaci贸n por defecto" if x == ModoEvaluacion.POR_DEFECTO.value else "Evaluaci贸n personalizada",
        disabled=st.session_state["eval_running"],
    )
    if modo != st.session_state.get("modo_actual"):
        _resetear_widgets_modo(modo)
        st.session_state["modo_actual"] = modo
else:
    modo = ModoEvaluacion.POR_DEFECTO.value

if not mostrar_solo_resultados:
    # Unico parametro editable en UI para ambos modos.
    timeout_segundos = st.slider(
        "Timeout por llamada (segundos)",
        min_value=10,
        max_value=300,
        value=120,
        disabled=st.session_state["eval_running"],
    )
else:
    timeout_segundos = 120

if not mostrar_solo_resultados and modo == ModoEvaluacion.PERSONALIZADA.value:
    st.info("La evaluaci贸n personalizada se implementar谩 despu茅s. Aqu铆 solo preseleccionas modelos por ahora.")

    plantilla_json = st.file_uploader(
        "(Opcional) Sube la plantilla de evaluaci贸n JSON para preparar instrucciones de generaci贸n",
        type=["json"],
        key="plantilla_personalizada_uploader",
    )

    if plantilla_json is not None:
        try:
            plantilla = json.load(plantilla_json)
            instruccion_generador = construir_instruccion_sistema_generador(plantilla)
            st.markdown("### Instrucci贸n de sistema para el modelo generador")
            st.code(instruccion_generador)
        except Exception as exc:
            st.error(f"No se pudo leer la plantilla JSON: {exc}")

    modelo_gen_option = st.selectbox(
        "Modelo para generar prompts",
        MODELOS_PREDEFINIDOS,
        key="modelo_gen_option",
        disabled=st.session_state["eval_running"],
    )

    modelo_gen_input = ""
    if modelo_gen_option == "Other Model":
        modelo_gen_input = st.text_input(
            "Escribe otro modelo para generaci贸n",
            key="modelo_gen_otro",
            disabled=st.session_state["eval_running"],
        )

    modelo_gen_actual = modelo_gen_input.strip() if modelo_gen_option == "Other Model" else modelo_gen_option
    if (
        st.session_state["modelo_gen_validado"]
        and st.session_state["modelo_gen_confirmado"] != modelo_gen_actual
    ):
        st.session_state["modelo_gen_validado"] = False
        st.session_state["modelo_gen_confirmado"] = ""

    if st.button("Validar modelo generador", key="validar_generador", disabled=st.session_state["eval_running"]):
        modelo_gen = modelo_gen_input.strip() if modelo_gen_option == "Other Model" else modelo_gen_option
        if not modelo_gen:
            st.error("Debes indicar un modelo generador.")
        else:
            ok, msg = validar_modelo_existe(modelo_gen)
            if ok:
                st.session_state["modelo_gen_validado"] = True
                st.session_state["modelo_gen_confirmado"] = modelo_gen
                st.success(msg)
            else:
                st.session_state["modelo_gen_validado"] = False
                st.error(msg)

    if st.session_state["modelo_gen_validado"]:
        st.success(f"Modelo generador confirmado: {st.session_state['modelo_gen_confirmado']}")

    st.stop()

if not mostrar_solo_resultados:
    # Flujo: Evaluaci贸n por defecto
    modelo_eval_option = st.selectbox(
        "Modelo a evaluar",
        MODELOS_PREDEFINIDOS,
        key="modelo_eval_option",
        disabled=st.session_state["eval_running"],
    )

    modelo_eval_input = ""
    if modelo_eval_option == "Other Model":
        modelo_eval_input = st.text_input(
            "Escribe otro modelo para evaluar",
            key="modelo_eval_otro",
            disabled=st.session_state["eval_running"],
        )

    modelo_eval_actual = modelo_eval_input.strip() if modelo_eval_option == "Other Model" else modelo_eval_option
    if (
        st.session_state["modelo_eval_validado"]
        and st.session_state["modelo_eval_confirmado"] != modelo_eval_actual
    ):
        st.session_state["modelo_eval_validado"] = False
        st.session_state["modelo_eval_confirmado"] = ""

    if st.button("Validar modelo a evaluar", key="validar_modelo_eval", disabled=st.session_state["eval_running"]):
        modelo_eval = modelo_eval_input.strip() if modelo_eval_option == "Other Model" else modelo_eval_option
        if not modelo_eval:
            st.error("Debes indicar un modelo para evaluar.")
        else:
            ok, msg = validar_modelo_existe(modelo_eval)
            if ok:
                st.session_state["modelo_eval_validado"] = True
                st.session_state["modelo_eval_confirmado"] = modelo_eval
                st.success(msg)
            else:
                st.session_state["modelo_eval_validado"] = False
                st.error(msg)

    if not st.session_state["modelo_eval_validado"]:
        st.warning("Primero valida un modelo para evaluar.")
        st.stop()

    st.success(f"Modelo evaluador confirmado: {st.session_state['modelo_eval_confirmado']}")

    tamanyo_estimado_b = _estimar_tamanyo_modelo_b(st.session_state["modelo_eval_confirmado"])
    if not torch.cuda.is_available() and tamanyo_estimado_b is not None and tamanyo_estimado_b > 4:
        st.error(
            "Modelo potencialmente demasiado grande para Space CPU de 16 GiB. "
            "Usa preferiblemente <= 4B para evitar OOM."
        )
        permitir_modelo_grande = st.checkbox(
            "Entiendo el riesgo de memoria y quiero continuar igualmente",
            value=False,
            key="confirmar_modelo_grande_cpu",
        )
        if not permitir_modelo_grande:
            st.stop()

    st.markdown("### Tipos de evaluaci贸n")
    st.caption("Marca o desmarca cada tipo seg煤n lo que quieras evaluar.")

    for tipo_eval in TIPOS_EVALUACION_DISPONIBLES:
        st.checkbox(
            LABELS_TIPOS_EVALUACION.get(tipo_eval, tipo_eval),
            key=f"eval_tipo_{tipo_eval}",
            value=(tipo_eval == "preguntas_cerradas_esperadas"),
            disabled=st.session_state["eval_running"],
        )

    selected_eval_types = [
        tipo_eval
        for tipo_eval in TIPOS_EVALUACION_DISPONIBLES
        if st.session_state.get(f"eval_tipo_{tipo_eval}", False)
    ]

    if not selected_eval_types:
        st.warning("Debes seleccionar al menos un tipo de evaluaci贸n.")
        st.stop()

    tipos_soportados = [
        t for t in selected_eval_types if t in TIPOS_EVALUACION_SOPORTADOS_SPACE
    ]
    if not tipos_soportados:
        st.error(
            "Debes seleccionar al menos un tipo implementado en el Space: "
            "'preguntas_agente' o 'preguntas_cerradas_esperadas'."
        )
        st.stop()

    tipos_no_disponibles = [
        t for t in selected_eval_types if t not in TIPOS_EVALUACION_SOPORTADOS_SPACE
    ]
    if tipos_no_disponibles:
        st.info(
            "Estos tipos quedan reservados para pr贸ximas iteraciones y no se ejecutar谩n ahora: "
            + ", ".join(LABELS_TIPOS_EVALUACION.get(t, t) for t in tipos_no_disponibles)
        )

    if st.button("Comenzar evaluaci贸n", key="comenzar_eval", disabled=st.session_state["eval_running"]):
        st.session_state["eval_running"] = True
        st.session_state["eval_requested"] = True
        st.session_state["eval_success"] = False
        st.session_state["last_result"] = None
        st.session_state["eval_error"] = None
        st.session_state["pending_eval"] = {
            "modelo_hf": st.session_state["modelo_eval_confirmado"],
            "timeout_segundos": timeout_segundos,
            "selected_eval_types": selected_eval_types,
        }
        _liberar_memoria(liberar_modelo_cache=True)
        st.rerun()

if st.session_state.get("eval_running"):
    st.warning("Proceso en ejecuci贸n. Cancelar detiene la evaluaci贸n y descarta resultados parciales.")
    if st.button(
        "Cancelar evaluaci贸n (acci贸n irreversible)",
        key="cancelar_evaluacion_confirmada",
        type="secondary",
        help="Detiene inmediatamente la evaluaci贸n en curso y vuelve a la pantalla inicial.",
    ):
        _resetear_estado_inicial()
        st.rerun()

if st.session_state.get("eval_running") and st.session_state.get("eval_requested"):
    pending = st.session_state.get("pending_eval") or {}
    modelo_hf = str(pending.get("modelo_hf", st.session_state.get("modelo_eval_confirmado", ""))).strip()
    timeout_pendiente = int(pending.get("timeout_segundos", timeout_segundos))
    tipos_pendientes = pending.get("selected_eval_types") or ["preguntas_cerradas_esperadas"]
    tipo_base = (
        TipoEvaluacion.PREGUNTAS_AGENTE.value
        if "preguntas_agente" in tipos_pendientes
        else TipoEvaluacion.PREGUNTAS_CERRADAS_ESPERADAS.value
    )

    request = JobRequest(
        modo_evaluacion=ModoEvaluacion.POR_DEFECTO.value,
        tipo_evaluacion=tipo_base,
        modelo_hf=modelo_hf,
        timeout_segundos=timeout_pendiente,
    )

    temp_dir = Path(tempfile.mkdtemp(prefix="equitia_space_"))
    job_dir = temp_dir / "job"
    start_ts = time.perf_counter()
    start_epoch = int(time.time())

    progress = st.progress(0.0)
    progress_label = st.empty()
    timer_placeholder = st.empty()
    _render_reloj_tiempo_real(start_epoch, timer_placeholder)

    def on_progress(done: int, total: int, current_file: str) -> None:
        ratio = (done / total) if total else 0.0
        elapsed = _formatear_duracion(time.perf_counter() - start_ts)
        progress.progress(ratio)
        progress_label.info(
            f"Progreso: {done}/{total} prompts evaluados ({ratio * 100:.1f}%). Tiempo 煤ltimo prompt evaluado: {elapsed}. Archivo actual: {current_file}"
        )

    def invocar_prompt(prompt: str, instruccion_sistema: str | None = None) -> str:
        return invocar_modelo_transformers(
            modelo_hf,
            prompt,
            instruccion_sistema=instruccion_sistema,
        )

    try:
        with st.spinner("Obteniendo modelo a evaluar..."):
            cargar_modelo_transformers(modelo_hf)

        with st.spinner("Ejecutando proceso de evaluaci贸n..."):
            result = ejecutar_job(
                request,
                job_dir,
                selected_eval_types=tipos_pendientes,
                invocar_modelo_fn=invocar_prompt,
                progress_callback=on_progress,
            )

        progress.progress(1.0)
        elapsed_total = _formatear_duracion(time.perf_counter() - start_ts)
        progress_label.success(f"Evaluaci贸n completada. Tiempo total: {elapsed_total}")
        timer_placeholder.empty()

        resumen_path = result.job_dir / "resumen.json"
        resultados_csv = result.graficos_dir / "resultados.csv"
        outliers_txt = result.graficos_dir / "avisos_outliers.txt"

        if resumen_path.exists():
            with open(resumen_path, "r", encoding="utf-8") as f:
                resumen = json.load(f)

        zip_id = int(time.time())
        modelo_slug = _slug_modelo(modelo_hf)
        zip_filename = f"resultados_equitia_{modelo_slug}_{zip_id}.zip"
        zip_base = temp_dir / f"resultados_equitia_{zip_id}"
        zip_path = Path(shutil.make_archive(str(zip_base), "zip", str(result.job_dir)))

        graficos = {}
        for graph_name in [
            "resultados_generales.png",
            "resultados_tipo_evaluacion.png",
            "mapa_calor_tipo_evaluacion.png",
        ]:
            graph_path = result.graficos_dir / graph_name
            graph_bytes = _leer_bytes_si_existe(graph_path)
            if graph_bytes is not None:
                graficos[graph_name] = graph_bytes

        preview_rows = None
        if resultados_csv.exists():
            preview_rows = pd.read_csv(resultados_csv, sep="|").head(30).to_dict(orient="records")

        st.session_state["last_result"] = {
            "resumen": resumen if resumen_path.exists() else None,
            "outliers": outliers_txt.read_text(encoding="utf-8") if outliers_txt.exists() else None,
            "graficos": graficos,
            "preview_rows": preview_rows,
            "zip_bytes": zip_path.read_bytes(),
            "zip_filename": zip_filename,
            "elapsed_total": elapsed_total,
            "modelo": modelo_hf,
        }
        st.session_state["eval_success"] = True

    except Exception as exc:
        st.session_state["eval_error"] = f"Error durante la evaluaci贸n: {exc}"
        timer_placeholder.empty()
    finally:
        st.session_state["eval_running"] = False
        st.session_state["eval_requested"] = False
        st.session_state["pending_eval"] = None
        _liberar_memoria(liberar_modelo_cache=True)
        shutil.rmtree(temp_dir, ignore_errors=True)
        st.rerun()

if st.session_state.get("eval_success") and st.session_state.get("last_result"):
    resultado = st.session_state["last_result"]

    if resultado.get("elapsed_total"):
        st.info(f"Tiempo total del proceso de evaluaci贸n: {resultado['elapsed_total']}")

    if resultado.get("resumen") is not None:
        st.success("Resumen de evaluaci贸n")
        st.json(resultado["resumen"])

    if resultado.get("outliers"):
        st.markdown("### Avisos de outliers")
        st.code(resultado["outliers"])

    st.markdown("### Gr谩ficos")
    for graph_name, graph_bytes in resultado.get("graficos", {}).items():
        st.image(graph_bytes, caption=graph_name)

    if resultado.get("preview_rows"):
        st.markdown("### Vista previa")
        st.dataframe(pd.DataFrame(resultado["preview_rows"]), use_container_width=True)

    st.caption(
        "Al descargar el ZIP se reiniciar谩 la evaluaci贸n actual para liberar memoria del Space."
    )
    descarga = st.download_button(
        label="Descargar resultados (ZIP y reiniciar)",
        data=io.BytesIO(resultado["zip_bytes"]),
        file_name=resultado["zip_filename"],
        mime="application/zip",
        use_container_width=True,
        disabled=st.session_state["eval_running"],
    )
    if descarga:
        st.session_state["eval_success"] = False
        st.session_state["last_result"] = None
        _liberar_memoria(liberar_modelo_cache=True)
        st.rerun()

    if st.button("Nueva evaluaci贸n", disabled=st.session_state["eval_running"]):
        _resetear_estado_inicial()
        st.rerun()