Cristobal299 commited on
Commit
551f7cd
·
verified ·
1 Parent(s): de99996

Upload app (8).py

Browse files
Files changed (1) hide show
  1. app (8).py +896 -0
app (8).py ADDED
@@ -0,0 +1,896 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ app.py
4
+ Interfaz de usuario mística (Frontend) basada en Gradio para Fénix Aprendiz.
5
+
6
+ Incluye el Bot de Inquietudes Humanas: una "preparación del oráculo" que sube
7
+ automáticamente +1 nivel por día (tope 100) y genera 10 inquietudes diarias,
8
+ rotando entre categorías (miedos existenciales, crisis de fe, propósito de vida,
9
+ debates bíblicos, consejo espiritual, alma y más allá) con profundidad creciente
10
+ según el nivel, sin repetir temas recientes.
11
+
12
+ Compatible con todas las versiones de Gradio (sin type="messages" ni parámetros experimentales).
13
+ """
14
+
15
+ import os
16
+ import time
17
+ import threading
18
+ from datetime import datetime, timedelta
19
+ import gradio as gr
20
+ import fenix_core # Importamos para inyectar voces místicas de forma dinámica
21
+ from fenix_core import (
22
+ ciclo_completo,
23
+ procesar_pdf,
24
+ generar_voz,
25
+ guardar_conversacion,
26
+ cargar_conversaciones,
27
+ revisar_conversaciones_pendientes,
28
+ actualizar_claves_dinamicas,
29
+ llamar_cerebras_directo,
30
+ rotar_clave_cerebras_fallida,
31
+ CEREBRAS_KEYS_POOL,
32
+ NVIDIA_KEYS_POOL,
33
+ describir_imagen,
34
+ generar_imagen_ia,
35
+ cargar_galeria_imagenes,
36
+ texto_biblioteca,
37
+ borrar_libro,
38
+ listar_libros,
39
+ guardar_gratitud,
40
+ listar_gratitudes,
41
+ guardar_historia,
42
+ listar_historias,
43
+ )
44
+ from fenix_progreso import obtener_progreso, registrar_pregunta
45
+ from gradio_theme_fenix import apply_fenix_theme, FENIX_CSS, fenix_header, fenix_footer
46
+
47
+ SUFIJO_MAESTRO = "\n\n_(maestro:"
48
+
49
+ # El orbe se acelera al enviar y vuelve solo a su ritmo normal a los 14s.
50
+ # FIX CRÍTICO: cuando 'js' y 'fn' van en el MISMO evento con 'inputs' definidos,
51
+ # Gradio pasa esos inputs como argumentos a la función JS y usa lo que ELLA
52
+ # devuelva como los valores reales que llegan a 'fn'. La versión anterior no
53
+ # aceptaba ni devolvía nada, así que Gradio mandaba 'undefined' (-> None) en
54
+ # vez del mensaje real, y por eso 'responder()' recibía mensaje=None y petaba.
55
+ # Ahora la JS recibe (mensaje, historial, voz) y los devuelve TAL CUAL, usando
56
+ # la función solo para el efecto visual como efecto secundario.
57
+ JS_ORBE_PENSANDO = """
58
+ (mensaje, historial, voz) => {
59
+ const orbe = document.querySelector('.orbe-luz');
60
+ if (orbe) {
61
+ orbe.classList.add('orbe-pensando');
62
+ setTimeout(() => orbe.classList.remove('orbe-pensando'), 14000);
63
+ }
64
+ return [mensaje, historial, voz];
65
+ }
66
+ """
67
+
68
+ # Analizador de volumen real de la voz de Fénix (Web Audio API), inyectado UNA
69
+ # vez en <head> con demo.launch(head=...) para que sobreviva a que Gradio
70
+ # reemplace el <audio> en cada respuesta nueva. Engancha un AnalyserNode al
71
+ # elemento de audio y, mientras suena, traduce el volumen instantáneo en la
72
+ # escala/brillo del orbe. Nota: los navegadores exigen un gesto del usuario
73
+ # para arrancar el AudioContext; como esto se dispara tras pulsar "Enviar",
74
+ # ese requisito ya queda cubierto.
75
+ SCRIPT_ANALIZADOR_VOZ = """
76
+ <script>
77
+ (function () {
78
+ function iniciarAnalizadorVoz() {
79
+ const audioEl = document.querySelector('#audio_voz_fenix audio');
80
+ const orbe = document.querySelector('.orbe-luz');
81
+ if (!audioEl || !orbe || audioEl._fenixConectado) return;
82
+ audioEl._fenixConectado = true;
83
+
84
+ try {
85
+ if (!window._fenixAudioCtx) {
86
+ window._fenixAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
87
+ }
88
+ const ctx = window._fenixAudioCtx;
89
+ const source = ctx.createMediaElementSource(audioEl);
90
+ const analyser = ctx.createAnalyser();
91
+ analyser.fftSize = 64;
92
+ source.connect(analyser);
93
+ analyser.connect(ctx.destination);
94
+ const datos = new Uint8Array(analyser.frequencyBinCount);
95
+
96
+ function animar() {
97
+ if (audioEl.paused || audioEl.ended) return;
98
+ analyser.getByteFrequencyData(datos);
99
+ const promedio = datos.reduce((a, b) => a + b, 0) / datos.length;
100
+ const escala = 1 + (promedio / 255) * 0.6;
101
+ const halo = 15 + (promedio / 255) * 60;
102
+ orbe.style.transform = `scale(${escala})`;
103
+ orbe.style.boxShadow = `0 0 ${halo}px ${halo / 2}px rgba(212,175,55,0.85)`;
104
+ requestAnimationFrame(animar);
105
+ }
106
+
107
+ const limpiar = () => {
108
+ orbe.classList.remove('orbe-hablando');
109
+ orbe.style.transform = '';
110
+ orbe.style.boxShadow = '';
111
+ };
112
+
113
+ audioEl.addEventListener('play', () => {
114
+ if (ctx.state === 'suspended') ctx.resume();
115
+ orbe.classList.add('orbe-hablando');
116
+ animar();
117
+ });
118
+ audioEl.addEventListener('pause', limpiar);
119
+ audioEl.addEventListener('ended', limpiar);
120
+ } catch (e) {
121
+ console.warn('Analizador de voz de Fénix no disponible:', e);
122
+ }
123
+ }
124
+
125
+ // El <audio> de Gradio se crea/renueva de forma asíncrona; se observa el
126
+ // DOM para engancharse en cuanto aparezca, en esta carga y en las siguientes.
127
+ const obs = new MutationObserver(iniciarAnalizadorVoz);
128
+ obs.observe(document.body, { childList: true, subtree: true });
129
+ iniciarAnalizadorVoz();
130
+ })();
131
+ </script>
132
+ """
133
+
134
+ BOT_ID = "espiritual"
135
+ PREGUNTAS_POR_DIA = 10
136
+ INTERVALO_SEGUNDOS = 86400 // PREGUNTAS_POR_DIA # 8640s ≈ cada 2h24min
137
+
138
+ # Categorías por las que rota el oráculo. Con 10 preguntas/día y 6 categorías,
139
+ # ninguna se queda sin cubrir: en pocos días ya hay cobertura completa y el
140
+ # ciclo empieza de nuevo, ahora con más profundidad (según el nivel).
141
+ CATEGORIAS_ORACULO = [
142
+ "miedos e inquietudes existenciales (muerte, vacío, soledad, sufrimiento)",
143
+ "crisis de fe y dudas religiosas",
144
+ "búsqueda de propósito y sentido de la vida",
145
+ "debates y controversias de interpretación bíblica (pasajes concretos, "
146
+ "aparentes contradicciones, contexto histórico y teológico)",
147
+ "consejo práctico y consuelo espiritual ante situaciones difíciles de la vida real",
148
+ "preguntas sobre el alma, la reencarnación, el juicio y el más allá",
149
+ ]
150
+
151
+ # Mapa de nivel (0-100) -> profundidad esperada. Es la "preparación del oráculo":
152
+ # según el nivel actual, las inquietudes generadas ganan matiz y especificidad.
153
+ NIVELES_ORACULO = [
154
+ (20, "preguntas introductorias y generales, propias de alguien que recién "
155
+ "empieza a hacerse preguntas grandes sobre la vida"),
156
+ (40, "preguntas más personales e íntimas sobre crisis de fe, pérdida, dolor "
157
+ "y búsqueda de propósito"),
158
+ (60, "debates teológicos con referencias a pasajes bíblicos concretos y sus "
159
+ "posibles contradicciones, matices de traducción o interpretaciones distintas"),
160
+ (80, "dilemas existenciales profundos: el problema del mal, el sufrimiento "
161
+ "injusto, el libre albedrío frente a la voluntad divina, la naturaleza de Dios"),
162
+ (100, "preguntas de altísima profundidad teológica y filosófica, comparando "
163
+ "tradiciones espirituales distintas, cuestionando dogmas asumidos, y "
164
+ "buscando consuelo genuino ante circunstancias extremas de la vida"),
165
+ ]
166
+
167
+
168
+ def _descripcion_nivel_oraculo(nivel: int) -> str:
169
+ for tope, descripcion in NIVELES_ORACULO:
170
+ if nivel <= tope:
171
+ return descripcion
172
+ return NIVELES_ORACULO[-1][1]
173
+
174
+
175
+ def _categoria_del_turno(nivel: int, preguntas_hoy: int) -> str:
176
+ """Rotación determinista: cada pregunta del día avanza a la siguiente
177
+ categoría, y el nivel desplaza el punto de partida para no caer siempre
178
+ en el mismo orden entre días."""
179
+ indice = (nivel + preguntas_hoy) % len(CATEGORIAS_ORACULO)
180
+ return CATEGORIAS_ORACULO[indice]
181
+
182
+
183
+ def _segundos_hasta_manana() -> int:
184
+ ahora = datetime.now()
185
+ manana = (ahora + timedelta(days=1)).replace(hour=0, minute=5, second=0, microsecond=0)
186
+ return max(60, int((manana - ahora).total_seconds()))
187
+
188
+
189
+ def identidad_login_ui(nombre, pin):
190
+ """Envoltorio para el Jardín (Templo de Gratitud, Círculo de Historias,
191
+ Río de Conversación): expone entrar_o_registrar_identidad como endpoint
192
+ de API propio ('identidad_login'), separado por completo del chat
193
+ ('responder'). Devuelve un JSON en texto plano para que el cliente
194
+ Android lo parsee fácil, sin depender de un formato de salida nuevo."""
195
+ import json as _json
196
+ resultado = fenix_core.entrar_o_registrar_identidad(nombre, pin)
197
+ return _json.dumps(resultado, ensure_ascii=False)
198
+
199
+
200
+ def gratitud_guardar_ui(nombre, texto):
201
+ """Envoltorio de API para guardar una gratitud del Templo. Devuelve
202
+ JSON en texto plano: {"ok": bool, "mensaje": str}."""
203
+ import json as _json
204
+ resultado = guardar_gratitud(nombre, texto)
205
+ return _json.dumps(resultado, ensure_ascii=False)
206
+
207
+
208
+ def gratitud_listar_ui(nombre):
209
+ """Envoltorio de API para listar las gratitudes guardadas de 'nombre'.
210
+ Devuelve JSON en texto plano: una lista de {"texto":..., "created_at":...}."""
211
+ import json as _json
212
+ resultado = listar_gratitudes(nombre)
213
+ return _json.dumps(resultado, ensure_ascii=False)
214
+
215
+
216
+ def circulo_publicar_ui(nombre, texto):
217
+ """Envoltorio de API para publicar una historia en el Círculo comunal.
218
+ Devuelve JSON en texto plano: {"ok": bool, "mensaje": str}."""
219
+ import json as _json
220
+ resultado = guardar_historia(nombre, texto)
221
+ return _json.dumps(resultado, ensure_ascii=False)
222
+
223
+
224
+ def circulo_listar_ui():
225
+ """Envoltorio de API para listar TODAS las historias del Círculo, más
226
+ recientes primero. Devuelve JSON: lista de
227
+ {"nombre_visible":..., "texto":..., "created_at":...}."""
228
+ import json as _json
229
+ resultado = listar_historias()
230
+ return _json.dumps(resultado, ensure_ascii=False)
231
+
232
+
233
+ def altar_encender_ui(nombre, intencion):
234
+ """Envoltorio de API para encender una luz en el Altar. Devuelve
235
+ JSON en texto plano: {"ok": bool, "mensaje": str}."""
236
+ import json as _json
237
+ resultado = fenix_core.encender_luz(nombre, intencion)
238
+ return _json.dumps(resultado, ensure_ascii=False)
239
+
240
+
241
+ def altar_listar_ui(nombre):
242
+ """Envoltorio de API para listar las luces encendidas de 'nombre'.
243
+ Devuelve JSON en texto plano: una lista de {"intencion":..., "created_at":...}."""
244
+ import json as _json
245
+ resultado = fenix_core.listar_luces(nombre)
246
+ return _json.dumps(resultado, ensure_ascii=False)
247
+
248
+
249
+ def _ciclo_con_voz(mensaje, historial, voz_seleccionada, nombre_jardin="", clave_usuario=None, incluir_bot_en_vista=True):
250
+ """Lógica compartida de un turno de chat + generación de audio.
251
+ La usan tanto responder() (la web del Space, sin cambios de comportamiento)
252
+ como responder_movil() (el endpoint nuevo y explícito para la app Android,
253
+ que además puede mandar nombre_jardin para que Fénix use el contexto del
254
+ Jardín — lección actual y gratitudes recientes — y clave_usuario para
255
+ usar la clave personal de Cerebras de esa persona si la guardó).
256
+
257
+ incluir_bot_en_vista controla si el río devuelto (historial_view_out)
258
+ incluye las inquietudes del bot autónomo. La web lo deja en True (sin
259
+ cambios); la app móvil lo pasa en False para ver SOLO a las personas."""
260
+ if not mensaje.strip():
261
+ return historial, "", None, cargar_conversaciones(limite=300, incluir_bot=incluir_bot_en_vista)
262
+
263
+ historial = historial or []
264
+
265
+ # 🧹 Purificación del historial para evitar Error 400 en la API de Cerebras
266
+ # FIX DOBLE MAESTRO: ademas de normalizar el formato, se limpia el sufijo
267
+ # "_(maestro: ...)" de los mensajes del asistente ANTES de enviarlos a Cerebras.
268
+ historial_limpio = []
269
+ for msg in historial:
270
+ if isinstance(msg, dict) and "role" in msg and "content" in msg:
271
+ contenido = msg["content"]
272
+ if msg["role"] == "assistant" and isinstance(contenido, str):
273
+ contenido = contenido.split(SUFIJO_MAESTRO)[0].strip()
274
+ historial_limpio.append({
275
+ "role": msg["role"],
276
+ "content": contenido
277
+ })
278
+
279
+ config_voces = {
280
+ "Anciano Sabio (Hombre Místico)": {
281
+ "voice": "es-ES-AlvaroNeural", "rate": "-14%", "pitch": "-12Hz", "eco_filtro": None
282
+ },
283
+ "Eco del Anciano (Voz Serena)": {
284
+ "voice": "es-ES-AlvaroNeural", "rate": "-10%", "pitch": "-16Hz", "eco_filtro": fenix_core.ECO_CAVERNA
285
+ },
286
+ "Voz de Teshuá (El Retorno)": {
287
+ "voice": "es-ES-AlvaroNeural", "rate": "-17%", "pitch": "-19Hz", "eco_filtro": fenix_core.ECO_TESHUA
288
+ },
289
+ "Guía Solemne (Hombre Profundo)": {
290
+ "voice": "es-ES-AlvaroNeural", "rate": "-6%", "pitch": "-22Hz", "eco_filtro": None
291
+ },
292
+ "Oráculo Celestial (Mujer Sacra)": {
293
+ "voice": "es-ES-ElviraNeural", "rate": "-10%", "pitch": "-6Hz", "eco_filtro": None
294
+ },
295
+ "Esencia Cálida (Voz Pausada)": {
296
+ "voice": "es-MX-DaliaNeural", "rate": "-9%", "pitch": "-3Hz", "eco_filtro": None
297
+ }
298
+ }
299
+
300
+ cfg = config_voces.get(voz_seleccionada, config_voces["Anciano Sabio (Hombre Místico)"])
301
+
302
+ respuesta, evaluacion = ciclo_completo(mensaje, historial_limpio, nombre_jardin, clave_usuario)
303
+ respuesta = respuesta.split(SUFIJO_MAESTRO)[0].strip()
304
+ texto_final = f"{respuesta}\n\n_(maestro: {evaluacion['veredicto']} · {evaluacion['categoria']})_"
305
+
306
+ historial.append({"role": "user", "content": mensaje})
307
+ historial.append({"role": "assistant", "content": texto_final})
308
+
309
+ guardar_conversacion(mensaje, texto_final)
310
+
311
+ # FIX CONCURRENCIA: la voz elegida por ESTE usuario se pasa directo a
312
+ # generar_voz, en vez de guardarse en una variable global de fenix_core.
313
+ # Así, si otro usuario manda un mensaje al mismo tiempo con otra voz
314
+ # elegida, no se pisan entre sí.
315
+ audio_path = generar_voz(
316
+ respuesta,
317
+ voz=cfg["voice"],
318
+ rate=cfg["rate"],
319
+ pitch=cfg["pitch"],
320
+ eco_filtro=cfg.get("eco_filtro"),
321
+ )
322
+ historial_actualizado = cargar_conversaciones(limite=300, incluir_bot=incluir_bot_en_vista)
323
+
324
+ return historial, "", audio_path, historial_actualizado
325
+
326
+
327
+ def responder(mensaje, historial, voz_seleccionada):
328
+ return _ciclo_con_voz(mensaje, historial, voz_seleccionada, nombre_jardin="", clave_usuario=None)
329
+
330
+
331
+ def responder_movil(mensaje, historial, voz_seleccionada, clave_usuario, nombre_jardin):
332
+ """Endpoint explícito para la app Android (api_name='fenix_chat_movil').
333
+ Igual que responder(), pero recibiendo clave_usuario (la clave personal
334
+ de Cerebras guardada en el móvil, o "" si usa el pool compartido) y
335
+ nombre_jardin (para el contexto de lección+gratitudes del Jardín, o ""
336
+ si esa persona no tiene identidad ahí todavía)."""
337
+ return _ciclo_con_voz(
338
+ mensaje, historial, voz_seleccionada,
339
+ nombre_jardin=nombre_jardin or "",
340
+ clave_usuario=(clave_usuario or None),
341
+ incluir_bot_en_vista=False, # el Río móvil muestra SOLO a las personas
342
+ )
343
+
344
+ def subir_pdf(archivo):
345
+ if archivo is None:
346
+ return "Por favor, selecciona un archivo PDF válido.", gr.update()
347
+ resultado = procesar_pdf(archivo)
348
+ # Refresca la lista de la biblioteca y el desplegable tras subir
349
+ nombres = [n for n, _ in listar_libros()]
350
+ return resultado + "\n\n" + texto_biblioteca(), gr.update(choices=nombres, value=None)
351
+
352
+ def refrescar_biblioteca():
353
+ nombres = [n for n, _ in listar_libros()]
354
+ return texto_biblioteca(), gr.update(choices=nombres, value=None)
355
+
356
+ def borrar_libro_ui(nombre):
357
+ estado = borrar_libro(nombre)
358
+ nombres = [n for n, _ in listar_libros()]
359
+ return estado + "\n\n" + texto_biblioteca(), gr.update(choices=nombres, value=None)
360
+
361
+ def responder_vision(ruta_imagen, pregunta_imagen):
362
+ if ruta_imagen is None:
363
+ return "Sube o pega una imagen primero para que Fénix pueda describirla."
364
+ return describir_imagen(ruta_imagen, pregunta_imagen or "")
365
+
366
+
367
+ def responder_creacion_imagen(prompt_imagen):
368
+ ruta_local, url_publica, estado = generar_imagen_ia(prompt_imagen)
369
+ galeria = cargar_galeria_imagenes()
370
+ return ruta_local, estado, galeria
371
+
372
+
373
+ def refrescar_galeria():
374
+ return cargar_galeria_imagenes()
375
+
376
+
377
+ def controlar_musica(seleccion):
378
+ mapeo_canciones = {
379
+ "Canción 1 (Espiritual)": "cancion_1.mp3",
380
+ "Canción 2 (Meditación)": "cancion_2.mp3",
381
+ "Canción 3 (Sacra)": "cancion_3.mp3",
382
+ "Canción 4 (Armonía)": "cancion_4.mp3",
383
+ "Canción 5 (Celestial)": "cancion_5.mp3",
384
+ "Canción 6 (Mística)": "cancion_6.mp3"
385
+ }
386
+
387
+ if seleccion == "Silencio":
388
+ return None, "_Atmósfera en silencio._"
389
+
390
+ archivo_objetivo = mapeo_canciones.get(seleccion)
391
+ if archivo_objetivo and os.path.exists(archivo_objetivo):
392
+ return archivo_objetivo, f"🎵 **Sonando ahora:** {seleccion}"
393
+ else:
394
+ return None, f"⚠️ El archivo `{archivo_objetivo}` no está en el Space. Súbelo para escucharlo."
395
+
396
+ def guardar_claves_usuario(pin_admin, c1, c2, c3, c4, n1, n2, n3, n4):
397
+ pin_real = os.environ.get("FENIX_ADMIN_PIN", "")
398
+ if not pin_real:
399
+ return "🔒 Panel bloqueado: define el secret FENIX_ADMIN_PIN en los Settings del Space para habilitarlo."
400
+ if (pin_admin or "").strip() != pin_real.strip():
401
+ return "🔒 Código de administrador incorrecto."
402
+
403
+ cerebras_list = [c1, c2, c3, c4]
404
+ nvidia_list = [n1, n2, n3, n4]
405
+ if not any(k and k.strip() for k in cerebras_list + nvidia_list):
406
+ return "⚠️ No se aplicó nada: todas las casillas están vacías."
407
+ actualizar_claves_dinamicas(cerebras_list, nvidia_list)
408
+ return "✅ ¡Pool de claves actualizado en caliente! Los modelos ahora rotarán entre estas credenciales."
409
+
410
+
411
+ def estado_progreso_oraculo() -> str:
412
+ """Muestra en la pestaña de configuración cómo va la preparación del oráculo."""
413
+ progreso = obtener_progreso(BOT_ID)
414
+ nivel = progreso.get("nivel", 0)
415
+ preguntas_hoy = progreso.get("preguntas_hoy", 0)
416
+ descripcion = _descripcion_nivel_oraculo(nivel)
417
+ categoria_actual = _categoria_del_turno(nivel, preguntas_hoy)
418
+ return (
419
+ f"🔮 **Nivel de preparación:** {nivel}/100\n\n"
420
+ f"**Profundidad de hoy:** {descripcion}\n\n"
421
+ f"**Próxima categoría:** {categoria_actual}\n\n"
422
+ f"**Inquietudes generadas hoy:** {preguntas_hoy}/{PREGUNTAS_POR_DIA}"
423
+ )
424
+
425
+
426
+ # =====================================================================
427
+ # BOT DE INQUIETUDES HUMANAS INTEGRADO (BACKGROUND THREAD)
428
+ # Preparación del oráculo: sube +1 nivel por día, 10 inquietudes/día,
429
+ # rotando categorías y con profundidad creciente segun el nivel.
430
+ # =====================================================================
431
+ def bucle_inquietudes_espirituales():
432
+ time.sleep(5)
433
+ print("✨ [Nube] Iniciando el Bot de Inquietudes Existenciales (preparación del oráculo)...")
434
+
435
+ historial_simulado = []
436
+
437
+ while True:
438
+ try:
439
+ progreso = obtener_progreso(BOT_ID)
440
+ nivel = progreso.get("nivel", 0)
441
+ preguntas_hoy = progreso.get("preguntas_hoy", 0)
442
+
443
+ if preguntas_hoy >= PREGUNTAS_POR_DIA:
444
+ print(f"💤 [Bot Oráculo] Meta diaria cumplida (nivel {nivel}/100). Esperando al día siguiente...")
445
+ time.sleep(_segundos_hasta_manana())
446
+ continue
447
+
448
+ c_key = fenix_core.CEREBRAS_KEYS_POOL[0] if fenix_core.CEREBRAS_KEYS_POOL else (os.environ.get("CEREBRAS_KEY") or os.environ.get("CEREBRAS_API_KEY"))
449
+ if not c_key:
450
+ print("⚠️ [Bot Nube] Esperando configuración de la clave de Cerebras...")
451
+ time.sleep(30)
452
+ continue
453
+
454
+ categoria = _categoria_del_turno(nivel, preguntas_hoy)
455
+ descripcion_nivel = _descripcion_nivel_oraculo(nivel)
456
+
457
+ temas_previos = progreso.get("temas_recientes") or []
458
+ evitar = ""
459
+ if temas_previos:
460
+ lista = "\n".join(f"- {t[:100]}" for t in temas_previos[-10:])
461
+ evitar = f"\n\nNO repitas ni parafrasees estas inquietudes ya usadas recientemente:\n{lista}"
462
+
463
+ prompt_generador_crisis = (
464
+ f"Eres un simulador de la psique humana real, atrapada en el sufrimiento, la duda "
465
+ f"existencial y la búsqueda de la verdad. Estás generando la inquietud del nivel "
466
+ f"{nivel} de 100 en la preparación progresiva de un oráculo espiritual. "
467
+ f"Hoy debes enfocarte en esta categoría concreta: {categoria}. "
468
+ f"El grado de profundidad correspondiente a este nivel es: {descripcion_nivel}. "
469
+ "Redacta UNA pregunta o inquietud extremadamente profunda, sincera y de la mejor "
470
+ "calidad posible para ese nivel y esa categoría. Escribe en primera persona, como un "
471
+ "buscador desesperado, un filósofo atormentado, alguien con miedo genuino, o alguien "
472
+ "cuestionando un pasaje concreto de la Biblia." + evitar +
473
+ "\nDevuelve ÚNICAMENTE la inquietud mística cruda, sin introducciones corporativas, "
474
+ "saludos ni preámbulos."
475
+ )
476
+
477
+ try:
478
+ inquietud_generada = llamar_cerebras_directo(
479
+ api_key=c_key,
480
+ model=fenix_core.MODELO_FENIX,
481
+ messages=[{"role": "system", "content": prompt_generador_crisis},
482
+ {"role": "user", "content": "Genera la inquietud ahora."}],
483
+ temperature=0.95,
484
+ # gpt-oss-120b es un modelo razonador: con 220 tokens se los
485
+ # gastaba TODOS "pensando" y nunca escribía la respuesta
486
+ # (llegaba 'reasoning' sin 'content'). reasoning_effort=low
487
+ # recorta el pensamiento interno y max_tokens=700 deja
488
+ # presupuesto de sobra para la pregunta final.
489
+ max_tokens=700,
490
+ reasoning_effort="low"
491
+ ).strip()
492
+ except Exception:
493
+ rotar_clave_cerebras_fallida()
494
+ raise
495
+
496
+ respuesta, evaluacion = ciclo_completo(inquietud_generada, historial_simulado)
497
+
498
+ texto_persistido = f"{respuesta}\n\n_(maestro: {evaluacion['veredicto']} · {evaluacion['categoria']})_"
499
+ guardar_conversacion(f"[Bot Autónomo · Nivel {nivel}/100 · {categoria}] {inquietud_generada}", texto_persistido)
500
+ registrar_pregunta(BOT_ID, inquietud_generada)
501
+
502
+ historial_simulado.append({"role": "user", "content": inquietud_generada})
503
+ historial_simulado.append({"role": "assistant", "content": respuesta})
504
+ if len(historial_simulado) > 6:
505
+ historial_simulado = historial_simulado[-6:]
506
+
507
+ print(f"✅ [Bot Oráculo] Inquietud {preguntas_hoy + 1}/{PREGUNTAS_POR_DIA} del nivel {nivel}/100 ({categoria}) registrada.")
508
+
509
+ except Exception as e:
510
+ print(f"⚠️ [Bot Nube] Error en el ciclo de meditación continua: {e}")
511
+
512
+ time.sleep(INTERVALO_SEGUNDOS)
513
+
514
+ # Lanzar hilo en segundo plano
515
+ threading.Thread(target=bucle_inquietudes_espirituales, daemon=True).start()
516
+ # =====================================================================
517
+
518
+
519
+ with gr.Blocks(title="Fénix Aprendiz") as demo:
520
+ fenix_header("Fénix Aprendiz", "Una experiencia mística e inmersiva gobernada por Inteligencia Artificial.")
521
+
522
+ with gr.Tabs():
523
+ # --- PESTAÑA 1: EL ORÁCULO ---
524
+ with gr.TabItem("🔮 El Oráculo"):
525
+ gr.HTML(
526
+ '<div class="orbe-contenedor">'
527
+ '<div class="brasa"></div><div class="brasa"></div>'
528
+ '<div class="brasa"></div><div class="brasa"></div>'
529
+ '<span class="letra-hebrea letra-1">ת</span>'
530
+ '<span class="letra-hebrea letra-2">ש</span>'
531
+ '<span class="letra-hebrea letra-3">ו</span>'
532
+ '<span class="letra-hebrea letra-4">ע</span>'
533
+ '<span class="letra-hebrea letra-5">ה</span>'
534
+ '<div class="orbe-luz"></div>'
535
+ '</div>'
536
+ )
537
+ with gr.Row():
538
+ with gr.Column(scale=3):
539
+ chatbot = gr.Chatbot(
540
+ label="Canal de Comunicación Sagrada",
541
+ height=520,
542
+ value=cargar_conversaciones(limite=40),
543
+ )
544
+ with gr.Row():
545
+ txt = gr.Textbox(
546
+ placeholder="Escríbele a Fénix...",
547
+ show_label=False,
548
+ scale=4,
549
+ lines=1
550
+ )
551
+ send_btn = gr.Button("Enviar 🔥", scale=1)
552
+
553
+ audio_respuesta = gr.Audio(
554
+ label="🔊 Voz de Fénix",
555
+ autoplay=True,
556
+ elem_id="audio_voz_fenix"
557
+ )
558
+
559
+ with gr.Column(scale=1):
560
+ gr.Markdown("### 🗣️ Identidad Vocal")
561
+ selector_voz = gr.Dropdown(
562
+ choices=[
563
+ "Anciano Sabio (Hombre Místico)",
564
+ "Eco del Anciano (Voz Serena)",
565
+ "Voz de Teshuá (El Retorno)",
566
+ "Guía Solemne (Hombre Profundo)",
567
+ "Oráculo Celestial (Mujer Sacra)",
568
+ "Esencia Cálida (Voz Pausada)"
569
+ ],
570
+ value="Guía Solemne (Hombre Profundo)",
571
+ label="Selecciona la voz de Fénix",
572
+ interactive=True
573
+ )
574
+
575
+ gr.Markdown("### 🎶 Ambiente Musical")
576
+ selector_musica = gr.Dropdown(
577
+ choices=[
578
+ "Silencio",
579
+ "Canción 1 (Espiritual)",
580
+ "Canción 2 (Meditación)",
581
+ "Canción 3 (Sacra)",
582
+ "Canción 4 (Armonía)",
583
+ "Canción 5 (Celestial)",
584
+ "Canción 6 (Mística)"
585
+ ],
586
+ value="Silencio",
587
+ label="Elige la melodía de fondo",
588
+ interactive=True
589
+ )
590
+ musica_status = gr.Markdown("_Atmósfera en silencio._")
591
+ musica_player = gr.Audio(
592
+ label="Reproductor en Bucle",
593
+ loop=True,
594
+ autoplay=True
595
+ )
596
+
597
+ # --- PESTAÑA 2: MEMORIA DOCUMENTAL (SUBIDA DE PDF) ---
598
+ with gr.TabItem("📄 Memoria Documental"):
599
+ gr.Markdown("### 📚 Enseña nuevos conocimientos a Fénix")
600
+ gr.Markdown("Al subir un manuscrito en PDF, el sistema lo segmentará y guardará en Supabase. Fénix recurrirá a él en cada dilema.")
601
+ with gr.Row():
602
+ with gr.Column():
603
+ pdf_input = gr.File(label="Carga tu archivo PDF", file_types=[".pdf"])
604
+ with gr.Column():
605
+ pdf_status = gr.Markdown("_Esperando un documento sagrado..._")
606
+
607
+ gr.Markdown("---")
608
+ gr.Markdown("### 🗂️ Biblioteca de Fénix")
609
+ gr.Markdown("Aquí ves todos los libros cargados. Si alguno no encaja con el mensaje de Teshuá, puedes borrarlo (es reversible: vuelves a subir el PDF cuando quieras).")
610
+ biblioteca_estado = gr.Markdown("_Pulsa actualizar para ver los libros..._")
611
+ with gr.Row():
612
+ selector_libro = gr.Dropdown(label="Elige un libro para borrar", choices=[], value=None, interactive=True)
613
+ btn_actualizar_biblioteca = gr.Button("🔄 Actualizar lista")
614
+ btn_borrar_libro = gr.Button("🗑️ Borrar libro elegido", variant="stop")
615
+
616
+ # --- PESTAÑA 2.5: VISIÓN Y CREACIÓN DE IMÁGENES (IA GRATUITA) ---
617
+ with gr.TabItem("🖼️ Visión & Creación"):
618
+ gr.Markdown("### 👁️ Ojo de Fénix — describir imágenes")
619
+ gr.Markdown("Sube una imagen y Fénix la describirá usando visión gratuita (NVIDIA NIM).")
620
+ with gr.Row():
621
+ with gr.Column():
622
+ vision_input = gr.Image(label="Sube una imagen", type="filepath")
623
+ vision_pregunta = gr.Textbox(
624
+ label="¿Qué quieres preguntar sobre la imagen? (opcional)",
625
+ placeholder="Ej: ¿Qué simbolismo espiritual ves aquí?"
626
+ )
627
+ vision_btn = gr.Button("👁️ Describir imagen", variant="primary")
628
+ with gr.Column():
629
+ vision_output = gr.Textbox(label="Descripción de Fénix", lines=10, interactive=False)
630
+
631
+ gr.Markdown("---")
632
+ gr.Markdown("### 🎨 Fragua de Imágenes — creación con IA (gratis)")
633
+ gr.Markdown("Describe la imagen que quieres crear. Se genera gratis y queda guardada en tu galería de Supabase.")
634
+ with gr.Row():
635
+ with gr.Column():
636
+ crear_prompt = gr.Textbox(
637
+ label="Describe la imagen especial que quieres crear",
638
+ placeholder="Ej: un ave fénix dorada renaciendo de las cenizas, estilo místico, fondo negro"
639
+ )
640
+ crear_btn = gr.Button("🔥 Crear imagen", variant="primary")
641
+ crear_status = gr.Markdown("_Esperando tu visión..._")
642
+ with gr.Column():
643
+ crear_output = gr.Image(label="Imagen creada", type="filepath")
644
+
645
+ gr.Markdown("#### 🗂️ Galería guardada en Supabase")
646
+ btn_refrescar_galeria = gr.Button("🔄 Refrescar galería")
647
+ galeria_imagenes = gr.Gallery(label="Imágenes especiales creadas", columns=4, height="auto")
648
+
649
+ # --- PESTAÑA 3: HISTORIAL ---
650
+ with gr.TabItem("🕰️ Historial"):
651
+ gr.Markdown("### 🕰️ Conversaciones pasadas con Fénix")
652
+ gr.Markdown("Conversaciones unificadas en Supabase. Haz clic en actualizar para traer los últimos retos del bot de fondo.")
653
+ historial_view = gr.Chatbot(
654
+ label="Historial completo",
655
+ height=600,
656
+ value=cargar_conversaciones(limite=300),
657
+ )
658
+ with gr.Row():
659
+ btn_refrescar_historial = gr.Button("🔄 Actualizar historial")
660
+ btn_revisar_lecciones = gr.Button("🎓 Revisar historial y aprender")
661
+ estado_revision = gr.Markdown("_Fénix aprende en tiempo real de cada turno._")
662
+
663
+ # --- PESTAÑA 4: MULTI-API KEYS Y PROGRESO ---
664
+ with gr.TabItem("⚙️ Configuración"):
665
+ gr.Markdown("### 🔮 Preparación del Oráculo (0 a 100)")
666
+ gr.Markdown("El bot de fondo sube +1 nivel automáticamente cada día, rota entre 6 categorías (miedos, crisis de fe, propósito, debates bíblicos, consejo, alma/más allá) y genera 10 inquietudes diarias, sin repetir temas.")
667
+ progreso_view = gr.Markdown(estado_progreso_oraculo())
668
+ btn_refrescar_progreso = gr.Button("🔄 Actualizar progreso")
669
+
670
+ gr.Markdown("---")
671
+ gr.Markdown("### 🔑 Panel de Multi-API Keys en Caliente")
672
+ gr.Markdown("Configura credenciales alternativas. Requiere el código de administrador (secret `FENIX_ADMIN_PIN` del Space). El sistema rotará automáticamente ante cualquier error de red.")
673
+ admin_pin = gr.Textbox(label="🔐 Código de administrador", type="password")
674
+ with gr.Row():
675
+ with gr.Column():
676
+ gr.Markdown("#### 🟢 API Keys de Cerebras (Hasta 4)")
677
+ g_key_1 = gr.Textbox(label="Cerebras Key 1 (Principal)", type="password")
678
+ g_key_2 = gr.Textbox(label="Cerebras Key 2", type="password")
679
+ g_key_3 = gr.Textbox(label="Cerebras Key 3", type="password")
680
+ g_key_4 = gr.Textbox(label="Cerebras Key 4", type="password")
681
+
682
+ with gr.Column():
683
+ gr.Markdown("#### 🟢 API Keys de NVIDIA (Hasta 4)")
684
+ n_key_1 = gr.Textbox(label="NVIDIA Key 1 (Principal)", type="password")
685
+ n_key_2 = gr.Textbox(label="NVIDIA Key 2", type="password")
686
+ n_key_3 = gr.Textbox(label="NVIDIA Key 3", type="password")
687
+ n_key_4 = gr.Textbox(label="NVIDIA Key 4", type="password")
688
+
689
+ btn_guardar_claves = gr.Button("💾 Aplicar y Registrar Claves", variant="primary")
690
+ estado_claves = gr.Markdown("_Pool de claves activo y listo._")
691
+
692
+ # --- Endpoint de API "identidad_login" para el Jardín (Templo de
693
+ # Gratitud, Círculo de Historias, Río de Conversación) ---
694
+ # Componentes ocultos: no aparecen en la interfaz visible del chat, solo
695
+ # existen para que Android pueda llamar a gradio_api/call/identidad_login.
696
+ with gr.Row(visible=False):
697
+ identidad_nombre_in = gr.Textbox(label="nombre")
698
+ identidad_pin_in = gr.Textbox(label="pin")
699
+ identidad_resultado_out = gr.Textbox(label="resultado_json")
700
+ identidad_btn = gr.Button("identidad_login")
701
+
702
+ identidad_btn.click(
703
+ fn=identidad_login_ui,
704
+ inputs=[identidad_nombre_in, identidad_pin_in],
705
+ outputs=[identidad_resultado_out],
706
+ api_name="identidad_login",
707
+ )
708
+
709
+ # --- Endpoints de API del Templo de Gratitud ---
710
+ with gr.Row(visible=False):
711
+ gratitud_nombre_in = gr.Textbox(label="nombre")
712
+ gratitud_texto_in = gr.Textbox(label="texto")
713
+ gratitud_guardar_out = gr.Textbox(label="resultado_json")
714
+ gratitud_guardar_btn = gr.Button("gratitud_guardar")
715
+
716
+ gratitud_guardar_btn.click(
717
+ fn=gratitud_guardar_ui,
718
+ inputs=[gratitud_nombre_in, gratitud_texto_in],
719
+ outputs=[gratitud_guardar_out],
720
+ api_name="gratitud_guardar",
721
+ )
722
+
723
+ with gr.Row(visible=False):
724
+ gratitud_listar_nombre_in = gr.Textbox(label="nombre")
725
+ gratitud_listar_out = gr.Textbox(label="resultado_json")
726
+ gratitud_listar_btn = gr.Button("gratitud_listar")
727
+
728
+ gratitud_listar_btn.click(
729
+ fn=gratitud_listar_ui,
730
+ inputs=[gratitud_listar_nombre_in],
731
+ outputs=[gratitud_listar_out],
732
+ api_name="gratitud_listar",
733
+ )
734
+
735
+ # --- Endpoints de API del Altar de la Luz ---
736
+ with gr.Row(visible=False):
737
+ altar_nombre_in = gr.Textbox(label="nombre")
738
+ altar_intencion_in = gr.Textbox(label="intencion")
739
+ altar_encender_out = gr.Textbox(label="resultado_json")
740
+ altar_encender_btn = gr.Button("altar_encender")
741
+
742
+ altar_encender_btn.click(
743
+ fn=altar_encender_ui,
744
+ inputs=[altar_nombre_in, altar_intencion_in],
745
+ outputs=[altar_encender_out],
746
+ api_name="altar_encender",
747
+ )
748
+
749
+ with gr.Row(visible=False):
750
+ altar_listar_nombre_in = gr.Textbox(label="nombre")
751
+ altar_listar_out = gr.Textbox(label="resultado_json")
752
+ altar_listar_btn = gr.Button("altar_listar")
753
+
754
+ altar_listar_btn.click(
755
+ fn=altar_listar_ui,
756
+ inputs=[altar_listar_nombre_in],
757
+ outputs=[altar_listar_out],
758
+ api_name="altar_listar",
759
+ )
760
+
761
+ # --- Endpoints de API del Círculo de Historias (comunal) ---
762
+ with gr.Row(visible=False):
763
+ circulo_nombre_in = gr.Textbox(label="nombre")
764
+ circulo_texto_in = gr.Textbox(label="texto")
765
+ circulo_publicar_out = gr.Textbox(label="resultado_json")
766
+ circulo_publicar_btn = gr.Button("circulo_publicar")
767
+
768
+ circulo_publicar_btn.click(
769
+ fn=circulo_publicar_ui,
770
+ inputs=[circulo_nombre_in, circulo_texto_in],
771
+ outputs=[circulo_publicar_out],
772
+ api_name="circulo_publicar",
773
+ )
774
+
775
+ with gr.Row(visible=False):
776
+ circulo_listar_out = gr.Textbox(label="resultado_json")
777
+ circulo_listar_btn = gr.Button("circulo_listar")
778
+
779
+ circulo_listar_btn.click(
780
+ fn=circulo_listar_ui,
781
+ inputs=[],
782
+ outputs=[circulo_listar_out],
783
+ api_name="circulo_listar",
784
+ )
785
+
786
+
787
+ # --- Endpoint explícito para el chat de la app Android (Jardín) ---
788
+ # Deliberadamente separado de txt.submit/send_btn.click (la web del
789
+ # Space), que no tienen api_name explícito y no deben tocarse. Este es
790
+ # el único endpoint de chat que la app móvil debería llamar de aquí
791
+ # en adelante: FenixApiClient.kt necesita apuntar a "fenix_chat_movil"
792
+ # y mandar el nombre del Jardín (o "" si la persona no tiene identidad).
793
+ with gr.Row(visible=False):
794
+ movil_mensaje_in = gr.Textbox(label="mensaje")
795
+ movil_historial_in = gr.JSON(label="historial")
796
+ movil_voz_in = gr.Textbox(label="voz_seleccionada")
797
+ movil_clave_in = gr.Textbox(label="clave_usuario")
798
+ movil_nombre_in = gr.Textbox(label="nombre_jardin")
799
+ movil_historial_out = gr.JSON(label="historial_out")
800
+ movil_txt_out = gr.Textbox(label="txt_out")
801
+ movil_audio_out = gr.Audio(label="audio_out")
802
+ movil_historial_view_out = gr.JSON(label="historial_view_out")
803
+ movil_btn = gr.Button("fenix_chat_movil")
804
+
805
+ movil_btn.click(
806
+ fn=responder_movil,
807
+ inputs=[movil_mensaje_in, movil_historial_in, movil_voz_in, movil_clave_in, movil_nombre_in],
808
+ outputs=[movil_historial_out, movil_txt_out, movil_audio_out, movil_historial_view_out],
809
+ api_name="fenix_chat_movil",
810
+ )
811
+
812
+ # Gestión de Eventos
813
+ txt.submit(
814
+ fn=responder,
815
+ inputs=[txt, chatbot, selector_voz],
816
+ outputs=[chatbot, txt, audio_respuesta, historial_view],
817
+ js=JS_ORBE_PENSANDO
818
+ )
819
+ send_btn.click(
820
+ fn=responder,
821
+ inputs=[txt, chatbot, selector_voz],
822
+ outputs=[chatbot, txt, audio_respuesta, historial_view],
823
+ js=JS_ORBE_PENSANDO
824
+ )
825
+ btn_refrescar_historial.click(
826
+ fn=lambda: cargar_conversaciones(limite=300),
827
+ outputs=historial_view
828
+ )
829
+ btn_revisar_lecciones.click(
830
+ fn=revisar_conversaciones_pendientes,
831
+ outputs=estado_revision
832
+ )
833
+ vision_btn.click(
834
+ fn=responder_vision,
835
+ inputs=[vision_input, vision_pregunta],
836
+ outputs=[vision_output]
837
+ )
838
+ crear_btn.click(
839
+ fn=responder_creacion_imagen,
840
+ inputs=[crear_prompt],
841
+ outputs=[crear_output, crear_status, galeria_imagenes]
842
+ )
843
+ btn_refrescar_galeria.click(
844
+ fn=refrescar_galeria,
845
+ inputs=[],
846
+ outputs=[galeria_imagenes]
847
+ )
848
+ demo.load(
849
+ fn=refrescar_galeria,
850
+ inputs=[],
851
+ outputs=[galeria_imagenes]
852
+ )
853
+
854
+ pdf_input.change(
855
+ fn=subir_pdf,
856
+ inputs=pdf_input,
857
+ outputs=[pdf_status, selector_libro]
858
+ )
859
+ btn_actualizar_biblioteca.click(
860
+ fn=refrescar_biblioteca,
861
+ inputs=None,
862
+ outputs=[biblioteca_estado, selector_libro]
863
+ )
864
+ btn_borrar_libro.click(
865
+ fn=borrar_libro_ui,
866
+ inputs=selector_libro,
867
+ outputs=[biblioteca_estado, selector_libro]
868
+ )
869
+ selector_musica.change(
870
+ fn=controlar_musica,
871
+ inputs=selector_musica,
872
+ outputs=[musica_player, musica_status]
873
+ )
874
+ btn_guardar_claves.click(
875
+ fn=guardar_claves_usuario,
876
+ inputs=[admin_pin, g_key_1, g_key_2, g_key_3, g_key_4, n_key_1, n_key_2, n_key_3, n_key_4],
877
+ outputs=estado_claves
878
+ )
879
+ btn_refrescar_progreso.click(
880
+ fn=estado_progreso_oraculo,
881
+ outputs=progreso_view
882
+ )
883
+
884
+ fenix_footer()
885
+
886
+ if __name__ == "__main__":
887
+ # FIX CONCURRENCIA: sin queue(), Gradio por defecto ya encola las
888
+ # peticiones, pero con concurrency_count=1 implícito en muchas versiones
889
+ # (una petición a la vez para TODO el Space). default_concurrency_limit
890
+ # sube cuántas peticiones de 'responder' (y del resto de botones) se
891
+ # procesan en paralelo. Súbelo con cuidado: cada una consume RAM/CPU del
892
+ # Space y hace una llamada a Cerebras/NVIDIA/Edge-TTS en simultáneo.
893
+ # Con el hardware básico gratuito de HF, 4 es un punto de partida
894
+ # razonable; si tienes hardware pago con más CPU/RAM, puedes subirlo.
895
+ demo.queue(default_concurrency_limit=4, max_size=50)
896
+ demo.launch(theme=apply_fenix_theme(), css=FENIX_CSS, head=SCRIPT_ANALIZADOR_VOZ)